diff --git a/CHANGELOG.md b/CHANGELOG.md index 0224ea957c..4f72580c29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,34 @@ # Changelog -## [3.8.2-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.3-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.1...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) + +### 📖 Documentation + +- documentation: add example to `repack-version` command [\#2669](https://github.com/pypeclub/OpenPype/pull/2669) +- Update docusaurus [\#2639](https://github.com/pypeclub/OpenPype/pull/2639) +- Documentation: Fixed relative links [\#2621](https://github.com/pypeclub/OpenPype/pull/2621) + +**🚀 Enhancements** + +- Ftrack: Sync description to assets [\#2670](https://github.com/pypeclub/OpenPype/pull/2670) +- Houdini: Moved to OpenPype [\#2658](https://github.com/pypeclub/OpenPype/pull/2658) +- Maya: Move implementation to OpenPype [\#2649](https://github.com/pypeclub/OpenPype/pull/2649) + +**🐛 Bug fixes** + +- Maya: Fix menu callbacks [\#2671](https://github.com/pypeclub/OpenPype/pull/2671) +- hiero: removing obsolete unsupported plugin [\#2667](https://github.com/pypeclub/OpenPype/pull/2667) + +**Merged pull requests:** + +- Fix python install in docker for centos7 [\#2664](https://github.com/pypeclub/OpenPype/pull/2664) +- Deadline: Be able to pass Mongo url to job [\#2616](https://github.com/pypeclub/OpenPype/pull/2616) + +## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2) ### 📖 Documentation @@ -10,8 +36,12 @@ **🚀 Enhancements** +- TVPaint: Image loaders also work on review family [\#2638](https://github.com/pypeclub/OpenPype/pull/2638) +- General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629) - nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627) - Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602) +- Nuke: load color space from representation data [\#2576](https://github.com/pypeclub/OpenPype/pull/2576) +- New Publisher: New features and preparations for new standalone publisher [\#2556](https://github.com/pypeclub/OpenPype/pull/2556) **🐛 Bug fixes** @@ -20,6 +50,8 @@ **Merged pull requests:** +- Docker: enhance dockerfiles with metadata, fix pyenv initialization [\#2647](https://github.com/pypeclub/OpenPype/pull/2647) +- WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641) - Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613) ## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01) @@ -30,6 +62,7 @@ - Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600) - Loader: Allow to toggle default family filters between "include" or "exclude" filtering [\#2541](https://github.com/pypeclub/OpenPype/pull/2541) +- Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536) **🐛 Bug fixes** @@ -41,7 +74,6 @@ - Webpublisher: Fix - subset names from processed .psd used wrong value for task [\#2586](https://github.com/pypeclub/OpenPype/pull/2586) - `vrscene` creator Deadline webservice URL handling [\#2580](https://github.com/pypeclub/OpenPype/pull/2580) - global: track name was failing if duplicated root word in name [\#2568](https://github.com/pypeclub/OpenPype/pull/2568) -- Validate Maya Rig produces no cycle errors [\#2484](https://github.com/pypeclub/OpenPype/pull/2484) **Merged pull requests:** @@ -63,7 +95,6 @@ - Maya : V-Ray Proxy - load all ABC files via proxy [\#2544](https://github.com/pypeclub/OpenPype/pull/2544) - Maya to Unreal: Extended static mesh workflow [\#2537](https://github.com/pypeclub/OpenPype/pull/2537) - Flame: collecting publishable instances [\#2519](https://github.com/pypeclub/OpenPype/pull/2519) -- Flame: create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495) **🚀 Enhancements** @@ -73,15 +104,10 @@ - Settings: PathInput strip passed string [\#2550](https://github.com/pypeclub/OpenPype/pull/2550) - Global: Exctract Review anatomy fill data with output name [\#2548](https://github.com/pypeclub/OpenPype/pull/2548) - Cosmetics: Clean up some cosmetics / typos [\#2542](https://github.com/pypeclub/OpenPype/pull/2542) -- Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536) - General: Validate if current process OpenPype version is requested version [\#2529](https://github.com/pypeclub/OpenPype/pull/2529) - General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525) - Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521) - Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) -- TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) -- Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499) -- Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498) -- Maya: Collect 'fps' animation data only for "review" instances [\#2486](https://github.com/pypeclub/OpenPype/pull/2486) **🐛 Bug fixes** @@ -115,10 +141,6 @@ [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.14...3.7.0) -**🐛 Bug fixes** - -- TVPaint: Create render layer dialog is in front [\#2471](https://github.com/pypeclub/OpenPype/pull/2471) - ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.1...3.6.4) diff --git a/Dockerfile.centos7 b/Dockerfile.centos7 index 16cb02cd20..be3db58b62 100644 --- a/Dockerfile.centos7 +++ b/Dockerfile.centos7 @@ -42,7 +42,8 @@ RUN yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.n patchelf \ automake \ autoconf \ - ncurses \ + patch \ + ncurses \ ncurses-devel \ qt5-qtbase-devel \ xcb-util-wm \ diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 74d38751e1..3c26312b38 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -118,6 +118,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): instance.anatomyData = context.data["anatomyData"] instance.outputDir = self._get_output_dir(instance) + instance.context = context settings = get_project_settings(os.getenv("AVALON_PROJECT")) reviewable_subset_filter = \ @@ -142,7 +143,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): break self.log.info("New instance:: {}".format(instance)) - instances.append(instance) return instances diff --git a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py index 31a249591e..4284d11235 100644 --- a/openpype/hosts/harmony/plugins/publish/collect_farm_render.py +++ b/openpype/hosts/harmony/plugins/publish/collect_farm_render.py @@ -176,6 +176,7 @@ class CollectFarmRender(openpype.lib.abstract_collect_render. ignoreFrameHandleCheck=True ) + render_instance.context = context self.log.debug(render_instance) instances.append(render_instance) diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py index 15bd10fdb0..2d674b3fa7 100644 --- a/openpype/hosts/hiero/__init__.py +++ b/openpype/hosts/hiero/__init__.py @@ -18,6 +18,7 @@ def add_implementation_envs(env, _app): new_hiero_paths.append(norm_path) env["HIERO_PLUGIN_PATH"] = os.pathsep.join(new_hiero_paths) + env.pop("QT_AUTO_SCREEN_SCALE_FACTOR", None) # Try to add QuickTime to PATH quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" diff --git a/openpype/hosts/hiero/api/startup/Python/Startup/version_everywhere.py b/openpype/hosts/hiero/api/startup/Python/Startup/version_everywhere.py deleted file mode 100644 index 3d60b213d5..0000000000 --- a/openpype/hosts/hiero/api/startup/Python/Startup/version_everywhere.py +++ /dev/null @@ -1,352 +0,0 @@ -# version_up_everywhere.py -# Adds action to enable a Clip/Shot to be Min/Max/Next/Prev versioned in all shots used in a Project. -# -# Usage: -# 1) Copy file to /Python/Startup -# 2) Right-click on Clip(s) or Bins containing Clips in in the Bin View, or on Shots in the Timeline/Spreadsheet -# 3) Set Version for all Shots > OPTION to update the version in all shots where the Clip is used in the Project. - -import hiero.core -try: - from PySide.QtGui import * - from PySide.QtCore import * -except: - from PySide2.QtGui import * - from PySide2.QtWidgets import * - from PySide2.QtCore import * - - -def whereAmI(self, searchType="TrackItem"): - """returns a list of TrackItem or Sequnece objects in the Project which contain this Clip. - By default this will return a list of TrackItems where the Clip is used in its project. - You can also return a list of Sequences by specifying the searchType to be "Sequence". - Should consider putting this into hiero.core.Clip by default? - - Example usage: - - shotsForClip = clip.whereAmI("TrackItem") - sequencesForClip = clip.whereAmI("Sequence") - """ - proj = self.project() - - if ("TrackItem" not in searchType) and ("Sequence" not in searchType): - print("searchType argument must be \"TrackItem\" or \"Sequence\"") - return None - - # If user specifies a TrackItem, then it will return - searches = hiero.core.findItemsInProject(proj, searchType) - - if len(searches) == 0: - print("Unable to find {} in any items of type: {}".format( - str(self), searchType)) - return None - - # Case 1: Looking for Shots (trackItems) - clipUsedIn = [] - if isinstance(searches[0], hiero.core.TrackItem): - for shot in searches: - # We have to wrap this in a try/except because it's possible through the Python API for a Shot to exist without a Clip in the Bin - try: - - # For versioning to work, we must look to the BinItem that a Clip is wrapped in. - if shot.source().binItem() == self.binItem(): - clipUsedIn.append(shot) - - # If we throw an exception here its because the Shot did not have a Source Clip in the Bin. - except RuntimeError: - hiero.core.log.info( - 'Unable to find Parent Clip BinItem for Shot: %s, Source:%s' - % (shot, shot.source())) - pass - - # Case 1: Looking for Shots (trackItems) - elif isinstance(searches[0], hiero.core.Sequence): - for seq in searches: - # Iterate tracks > shots... - tracks = seq.items() - for track in tracks: - shots = track.items() - for shot in shots: - if shot.source().binItem() == self.binItem(): - clipUsedIn.append(seq) - - return clipUsedIn - - -# Add whereAmI method to Clip object -hiero.core.Clip.whereAmI = whereAmI - - -#### MAIN VERSION EVERYWHERE GUBBINS ##### -class VersionAllMenu(object): - - # These are a set of action names we can use for operating on multiple Clip/TrackItems - eMaxVersion = "Max Version" - eMinVersion = "Min Version" - eNextVersion = "Next Version" - ePreviousVersion = "Previous Version" - - # This is the title used for the Version Menu title. It's long isn't it? - actionTitle = "Set Version for all Shots" - - def __init__(self): - self._versionEverywhereMenu = None - self._versionActions = [] - - hiero.core.events.registerInterest("kShowContextMenu/kBin", - self.binViewEventHandler) - hiero.core.events.registerInterest("kShowContextMenu/kTimeline", - self.binViewEventHandler) - hiero.core.events.registerInterest("kShowContextMenu/kSpreadsheet", - self.binViewEventHandler) - - def showVersionUpdateReportFromShotManifest(self, sequenceShotManifest): - """This just displays an info Message box, based on a Sequence[Shot] manifest dictionary""" - - # Now present an info dialog, explaining where shots were updated - updateReportString = "The following Versions were updated:\n" - for seq in sequenceShotManifest.keys(): - updateReportString += "%s:\n Shots:\n" % (seq.name()) - for shot in sequenceShotManifest[seq]: - updateReportString += ' %s\n (New Version: %s)\n' % ( - shot.name(), shot.currentVersion().name()) - updateReportString += "\n" - - infoBox = QMessageBox(hiero.ui.mainWindow()) - infoBox.setIcon(QMessageBox.Information) - - if len(sequenceShotManifest) <= 0: - infoBox.setText("No Shot Versions were updated") - infoBox.setInformativeText( - "Clip could not be found in any Shots in this Project") - else: - infoBox.setText( - "Versions were updated in %i Sequences of this Project." % - (len(sequenceShotManifest))) - infoBox.setInformativeText("Show Details for more info.") - infoBox.setDetailedText(updateReportString) - - infoBox.exec_() - - def makeVersionActionForSingleClip(self, version): - """This is used to populate the QAction list of Versions when a single Clip is selected in the BinView. - It also triggers the Version Update action based on the version passed to it. - (Not sure if this is good design practice, but it's compact!)""" - action = QAction(version.name(), None) - action.setData(lambda: version) - - def updateAllTrackItems(): - currentClip = version.item() - trackItems = currentClip.whereAmI() - if not trackItems: - return - - proj = currentClip.project() - - # A Sequence-Shot manifest dictionary - sequenceShotManifest = {} - - # Make this all undo-able in a single Group undo - with proj.beginUndo( - "Update All Versions for %s" % currentClip.name()): - for shot in trackItems: - seq = shot.parentSequence() - if seq not in sequenceShotManifest.keys(): - sequenceShotManifest[seq] = [shot] - else: - sequenceShotManifest[seq] += [shot] - shot.setCurrentVersion(version) - - # We also should update the current Version of the selected Clip for completeness... - currentClip.binItem().setActiveVersion(version) - - # Now disaplay a Dialog which informs the user of where and what was changed - self.showVersionUpdateReportFromShotManifest(sequenceShotManifest) - - action.triggered.connect(updateAllTrackItems) - return action - - # This is just a convenience method for returning QActions with a title, triggered method and icon. - def makeAction(self, title, method, icon=None): - action = QAction(title, None) - action.setIcon(QIcon(icon)) - - # We do this magic, so that the title string from the action is used to trigger the version change - def methodWrapper(): - method(title) - - action.triggered.connect(methodWrapper) - return action - - def clipSelectionFromView(self, view): - """Helper method to return a list of Clips in the Active View""" - selection = hiero.ui.activeView().selection() - - if len(selection) == 0: - return None - - if isinstance(view, hiero.ui.BinView): - # We could have a mixture of Bins and Clips selected, so sort of the Clips and Clips inside Bins - clipItems = [ - item.activeItem() for item in selection - if hasattr(item, "activeItem") - and isinstance(item.activeItem(), hiero.core.Clip) - ] - - # We'll also append Bins here, and see if can find Clips inside - bins = [ - item for item in selection if isinstance(item, hiero.core.Bin) - ] - - # We search inside of a Bin for a Clip which is not already in clipBinItems - if len(bins) > 0: - # Grab the Clips inside of a Bin and append them to a list - for bin in bins: - clips = hiero.core.findItemsInBin(bin, "Clip") - for clip in clips: - if clip not in clipItems: - clipItems.append(clip) - - elif isinstance(view, - (hiero.ui.TimelineEditor, hiero.ui.SpreadsheetView)): - # Here, we have shots. To get to the Clip froma TrackItem, just call source() - clipItems = [ - item.source() for item in selection if hasattr(item, "source") - and isinstance(item, hiero.core.TrackItem) - ] - - return clipItems - - # This generates the Version Up Everywhere menu - def createVersionEveryWhereMenuForView(self, view): - - versionEverywhereMenu = QMenu(self.actionTitle) - self._versionActions = [] - # We look to the activeView for a selection of Clips - clips = self.clipSelectionFromView(view) - - # And bail if nothing is found - if len(clips) == 0: - return versionEverywhereMenu - - # Now, if we have just one Clip selected, we'll form a special menu, which lists all versions - if len(clips) == 1: - - # Get a reversed list of Versions, so that bigger ones appear at top - versions = list(reversed(clips[0].binItem().items())) - for version in versions: - self._versionActions += [ - self.makeVersionActionForSingleClip(version) - ] - - elif len(clips) > 1: - # We will add Max/Min/Prev/Next options, which can be called on a TrackItem, without the need for a Version object - self._versionActions += [ - self.makeAction( - self.eMaxVersion, - self.setTrackItemVersionForClipSelection, - icon=None) - ] - self._versionActions += [ - self.makeAction( - self.eMinVersion, - self.setTrackItemVersionForClipSelection, - icon=None) - ] - self._versionActions += [ - self.makeAction( - self.eNextVersion, - self.setTrackItemVersionForClipSelection, - icon=None) - ] - self._versionActions += [ - self.makeAction( - self.ePreviousVersion, - self.setTrackItemVersionForClipSelection, - icon=None) - ] - - for act in self._versionActions: - versionEverywhereMenu.addAction(act) - - return versionEverywhereMenu - - def setTrackItemVersionForClipSelection(self, versionOption): - - view = hiero.ui.activeView() - if not view: - return - - clipSelection = self.clipSelectionFromView(view) - - if len(clipSelection) == 0: - return - - proj = clipSelection[0].project() - - # Create a Sequence-Shot Manifest, to report to users where a Shot was updated - sequenceShotManifest = {} - - with proj.beginUndo("Update multiple Versions"): - for clip in clipSelection: - - # Look to see if it exists in a TrackItem somewhere... - shotUsage = clip.whereAmI("TrackItem") - - # Next, depending on the versionOption, make the appropriate update - # There's probably a more neat/compact way of doing this... - for shot in shotUsage: - - # This step is done for reporting reasons - seq = shot.parentSequence() - if seq not in sequenceShotManifest.keys(): - sequenceShotManifest[seq] = [shot] - else: - sequenceShotManifest[seq] += [shot] - - if versionOption == self.eMaxVersion: - shot.maxVersion() - elif versionOption == self.eMinVersion: - shot.minVersion() - elif versionOption == self.eNextVersion: - shot.nextVersion() - elif versionOption == self.ePreviousVersion: - shot.prevVersion() - - # Finally, for completeness, set the Max/Min version of the Clip too (if chosen) - # Note: It doesn't make sense to do Next/Prev on a Clip here because next/prev means different things for different Shots - if versionOption == self.eMaxVersion: - clip.binItem().maxVersion() - elif versionOption == self.eMinVersion: - clip.binItem().minVersion() - - # Now disaplay a Dialog which informs the user of where and what was changed - self.showVersionUpdateReportFromShotManifest(sequenceShotManifest) - - # This handles events from the Project Bin View - def binViewEventHandler(self, event): - - if not hasattr(event.sender, "selection"): - # Something has gone wrong, we should only be here if raised - # by the Bin view which gives a selection. - return - selection = event.sender.selection() - - # Return if there's no Selection. We won't add the Localise Menu. - if selection == None: - return - - view = hiero.ui.activeView() - # Only add the Menu if Bins or Sequences are selected (this ensures menu isn't added in the Tags Pane) - if len(selection) > 0: - self._versionEverywhereMenu = self.createVersionEveryWhereMenuForView( - view) - hiero.ui.insertMenuAction( - self._versionEverywhereMenu.menuAction(), - event.menu, - after="foundry.menu.version") - return - - -# Instantiate the Menu to get it to register itself. -VersionAllMenu = VersionAllMenu() diff --git a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py similarity index 97% rename from openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py rename to openpype/hosts/hiero/plugins/publish/collect_clip_effects.py index 9ade7603e0..8d2ed9a9c2 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/collect_clip_effects.py @@ -2,11 +2,11 @@ import re import pyblish.api -class PreCollectClipEffects(pyblish.api.InstancePlugin): +class CollectClipEffects(pyblish.api.InstancePlugin): """Collect soft effects instances.""" - order = pyblish.api.CollectorOrder - 0.479 - label = "Precollect Clip Effects Instances" + order = pyblish.api.CollectorOrder - 0.078 + label = "Collect Clip Effects Instances" families = ["clip"] def process(self, instance): diff --git a/openpype/hosts/houdini/api/__init__.py b/openpype/hosts/houdini/api/__init__.py index 7328236b97..e1500aa5f5 100644 --- a/openpype/hosts/houdini/api/__init__.py +++ b/openpype/hosts/houdini/api/__init__.py @@ -1,174 +1,60 @@ -import os -import sys -import logging -import contextlib +from .pipeline import ( + install, + uninstall, -import hou - -from pyblish import api as pyblish -from avalon import api as avalon - -import openpype.hosts.houdini -from openpype.hosts.houdini.api import lib - -from openpype.lib import ( - any_outdated + ls, + containerise, ) -from .lib import get_asset_fps +from .plugin import ( + Creator, +) -log = logging.getLogger("openpype.hosts.houdini") +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.houdini.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +from .lib import ( + lsattr, + lsattrs, + read, + + maintained_selection, + unique_name +) -def install(): +__all__ = [ + "install", + "uninstall", - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + "ls", + "containerise", - log.info("Installing callbacks ... ") - # avalon.on("init", on_init) - avalon.before("save", before_save) - avalon.on("save", on_save) - avalon.on("open", on_open) - avalon.on("new", on_new) + "Creator", - pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + # Workfiles API + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", - log.info("Setting default family states for loader..") - avalon.data["familiesStateToggled"] = [ - "imagesequence", - "review" - ] + # Utility functions + "lsattr", + "lsattrs", + "read", - # add houdini vendor packages - hou_pythonpath = os.path.join(os.path.dirname(HOST_DIR), "vendor") + "maintained_selection", + "unique_name" +] - sys.path.append(hou_pythonpath) - - # Set asset FPS for the empty scene directly after launch of Houdini - # so it initializes into the correct scene FPS - _set_asset_fps() - - -def before_save(*args): - return lib.validate_fps() - - -def on_save(*args): - - avalon.logger.info("Running callback on save..") - - nodes = lib.get_id_required_nodes() - for node, new_id in lib.generate_ids(nodes): - lib.set_id(node, new_id, overwrite=False) - - -def on_open(*args): - - if not hou.isUIAvailable(): - log.debug("Batch mode detected, ignoring `on_open` callbacks..") - return - - avalon.logger.info("Running callback on open..") - - # Validate FPS after update_task_from_path to - # ensure it is using correct FPS for the asset - lib.validate_fps() - - if any_outdated(): - from openpype.widgets import popup - - log.warning("Scene has outdated content.") - - # Get main window - parent = hou.ui.mainQtWindow() - if parent is None: - log.info("Skipping outdated content pop-up " - "because Houdini window can't be found.") - else: - - # Show outdated pop-up - def _on_show_inventory(): - import avalon.tools.sceneinventory as tool - tool.show(parent=parent) - - dialog = popup.Popup(parent=parent) - dialog.setWindowTitle("Houdini scene has outdated content") - dialog.setMessage("There are outdated containers in " - "your Houdini scene.") - dialog.on_clicked.connect(_on_show_inventory) - dialog.show() - - -def on_new(_): - """Set project resolution and fps when create a new file""" - avalon.logger.info("Running callback on new..") - _set_asset_fps() - - -def _set_asset_fps(): - """Set Houdini scene FPS to the default required for current asset""" - - # Set new scene fps - fps = get_asset_fps() - print("Setting scene FPS to %i" % fps) - lib.set_scene_fps(fps) - - -def on_pyblish_instance_toggled(instance, new_value, old_value): - """Toggle saver tool passthrough states on instance toggles.""" - @contextlib.contextmanager - def main_take(no_update=True): - """Enter root take during context""" - original_take = hou.takes.currentTake() - original_update_mode = hou.updateModeSetting() - root = hou.takes.rootTake() - has_changed = False - try: - if original_take != root: - has_changed = True - if no_update: - hou.setUpdateMode(hou.updateMode.Manual) - hou.takes.setCurrentTake(root) - yield - finally: - if has_changed: - if no_update: - hou.setUpdateMode(original_update_mode) - hou.takes.setCurrentTake(original_take) - - if not instance.data.get("_allowToggleBypass", True): - return - - nodes = instance[:] - if not nodes: - return - - # Assume instance node is first node - instance_node = nodes[0] - - if not hasattr(instance_node, "isBypassed"): - # Likely not a node that can actually be bypassed - log.debug("Can't bypass node: %s", instance_node.path()) - return - - if instance_node.isBypassed() != (not old_value): - print("%s old bypass state didn't match old instance state, " - "updating anyway.." % instance_node.path()) - - try: - # Go into the main take, because when in another take changing - # the bypass state of a note cannot be done due to it being locked - # by default. - with main_take(no_update=True): - instance_node.bypass(not new_value) - except hou.PermissionError as exc: - log.warning("%s - %s", instance_node.path(), exc) +# Backwards API compatibility +open = open_file +save = save_file diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 53f0e59ea9..eaaba94ed5 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -2,9 +2,11 @@ import uuid import logging from contextlib import contextmanager -from openpype.api import get_asset +import six + from avalon import api, io -from avalon.houdini import lib as houdini +from openpype.api import get_asset + import hou @@ -15,11 +17,11 @@ def get_asset_fps(): """Return current asset fps.""" return get_asset()["data"].get("fps") -def set_id(node, unique_id, overwrite=False): +def set_id(node, unique_id, overwrite=False): exists = node.parm("id") if not exists: - houdini.imprint(node, {"id": unique_id}) + imprint(node, {"id": unique_id}) if not exists and overwrite: node.setParm("id", unique_id) @@ -342,3 +344,183 @@ def render_rop(ropnode): import traceback traceback.print_exc() raise RuntimeError("Render failed: {0}".format(exc)) + + +def children_as_string(node): + return [c.name() for c in node.children()] + + +def imprint(node, data): + """Store attributes with value on a node + + Depending on the type of attribute it creates the correct parameter + template. Houdini uses a template per type, see the docs for more + information. + + http://www.sidefx.com/docs/houdini/hom/hou/ParmTemplate.html + + Args: + node(hou.Node): node object from Houdini + data(dict): collection of attributes and their value + + Returns: + None + + """ + + parm_group = node.parmTemplateGroup() + + parm_folder = hou.FolderParmTemplate("folder", "Extra") + for key, value in data.items(): + if value is None: + continue + + if isinstance(value, float): + parm = hou.FloatParmTemplate(name=key, + label=key, + num_components=1, + default_value=(value,)) + elif isinstance(value, bool): + parm = hou.ToggleParmTemplate(name=key, + label=key, + default_value=value) + elif isinstance(value, int): + parm = hou.IntParmTemplate(name=key, + label=key, + num_components=1, + default_value=(value,)) + elif isinstance(value, six.string_types): + parm = hou.StringParmTemplate(name=key, + label=key, + num_components=1, + default_value=(value,)) + else: + raise TypeError("Unsupported type: %r" % type(value)) + + parm_folder.addParmTemplate(parm) + + parm_group.append(parm_folder) + node.setParmTemplateGroup(parm_group) + + +def lsattr(attr, value=None): + if value is None: + nodes = list(hou.node("/obj").allNodes()) + return [n for n in nodes if n.parm(attr)] + return lsattrs({attr: value}) + + +def lsattrs(attrs): + """Return nodes matching `key` and `value` + + Arguments: + attrs (dict): collection of attribute: value + + Example: + >> lsattrs({"id": "myId"}) + ["myNode"] + >> lsattr("id") + ["myNode", "myOtherNode"] + + Returns: + list + """ + + matches = set() + nodes = list(hou.node("/obj").allNodes()) # returns generator object + for node in nodes: + for attr in attrs: + if not node.parm(attr): + continue + elif node.evalParm(attr) != attrs[attr]: + continue + else: + matches.add(node) + + return list(matches) + + +def read(node): + """Read the container data in to a dict + + Args: + node(hou.Node): Houdini node + + Returns: + dict + + """ + # `spareParms` returns a tuple of hou.Parm objects + return {parameter.name(): parameter.eval() for + parameter in node.spareParms()} + + +def unique_name(name, format="%03d", namespace="", prefix="", suffix="", + separator="_"): + """Return unique `name` + + The function takes into consideration an optional `namespace` + and `suffix`. The suffix is included in evaluating whether a + name exists - such as `name` + "_GRP" - but isn't included + in the returned value. + + If a namespace is provided, only names within that namespace + are considered when evaluating whether the name is unique. + + Arguments: + format (str, optional): The `name` is given a number, this determines + how this number is formatted. Defaults to a padding of 2. + E.g. my_name01, my_name02. + namespace (str, optional): Only consider names within this namespace. + suffix (str, optional): Only consider names with this suffix. + + Example: + >>> name = hou.node("/obj").createNode("geo", name="MyName") + >>> assert hou.node("/obj/MyName") + True + >>> unique = unique_name(name) + >>> assert hou.node("/obj/{}".format(unique)) + False + + """ + + iteration = 1 + + parts = [prefix, name, format % iteration, suffix] + if namespace: + parts.insert(0, namespace) + + unique = separator.join(parts) + children = children_as_string(hou.node("/obj")) + while unique in children: + iteration += 1 + unique = separator.join(parts) + + if suffix: + return unique[:-len(suffix)] + + return unique + + +@contextmanager +def maintained_selection(): + """Maintain selection during context + Example: + >>> with maintained_selection(): + ... # Modify selection + ... node.setSelected(on=False, clear_all_selected=True) + >>> # Selection restored + """ + + previous_selection = hou.selectedNodes() + try: + yield + finally: + # Clear the selection + # todo: does hou.clearAllSelected() do the same? + for node in hou.selectedNodes(): + node.setSelected(on=False) + + if previous_selection: + for node in previous_selection: + node.setSelected(on=True) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py new file mode 100644 index 0000000000..4054d5991f --- /dev/null +++ b/openpype/hosts/houdini/api/pipeline.py @@ -0,0 +1,349 @@ +import os +import sys +import logging +import contextlib + +import hou + +import pyblish.api +import avalon.api +from avalon.pipeline import AVALON_CONTAINER_ID +from avalon.lib import find_submodule + +import openpype.hosts.houdini +from openpype.hosts.houdini.api import lib + +from openpype.lib import ( + any_outdated +) + +from .lib import get_asset_fps + +log = logging.getLogger("openpype.hosts.houdini") + +AVALON_CONTAINERS = "/obj/AVALON_CONTAINERS" +IS_HEADLESS = not hasattr(hou, "ui") + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.houdini.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + + +self = sys.modules[__name__] +self._has_been_setup = False +self._parent = None +self._events = dict() + + +def install(): + _register_callbacks() + + pyblish.api.register_host("houdini") + pyblish.api.register_host("hython") + pyblish.api.register_host("hpython") + + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + + log.info("Installing callbacks ... ") + # avalon.on("init", on_init) + avalon.api.before("save", before_save) + avalon.api.on("save", on_save) + avalon.api.on("open", on_open) + avalon.api.on("new", on_new) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled + ) + + log.info("Setting default family states for loader..") + avalon.api.data["familiesStateToggled"] = [ + "imagesequence", + "review" + ] + + self._has_been_setup = True + # add houdini vendor packages + hou_pythonpath = os.path.join(os.path.dirname(HOST_DIR), "vendor") + + sys.path.append(hou_pythonpath) + + # Set asset FPS for the empty scene directly after launch of Houdini + # so it initializes into the correct scene FPS + _set_asset_fps() + + +def uninstall(): + """Uninstall Houdini-specific functionality of avalon-core. + + This function is called automatically on calling `api.uninstall()`. + """ + + pyblish.api.deregister_host("hython") + pyblish.api.deregister_host("hpython") + pyblish.api.deregister_host("houdini") + + +def _register_callbacks(): + for event in self._events.copy().values(): + if event is None: + continue + + try: + hou.hipFile.removeEventCallback(event) + except RuntimeError as e: + log.info(e) + + self._events[on_file_event_callback] = hou.hipFile.addEventCallback( + on_file_event_callback + ) + + +def on_file_event_callback(event): + if event == hou.hipFileEventType.AfterLoad: + avalon.api.emit("open", [event]) + elif event == hou.hipFileEventType.AfterSave: + avalon.api.emit("save", [event]) + elif event == hou.hipFileEventType.BeforeSave: + avalon.api.emit("before_save", [event]) + elif event == hou.hipFileEventType.AfterClear: + avalon.api.emit("new", [event]) + + +def get_main_window(): + """Acquire Houdini's main window""" + if self._parent is None: + self._parent = hou.ui.mainQtWindow() + return self._parent + + +def teardown(): + """Remove integration""" + if not self._has_been_setup: + return + + self._has_been_setup = False + print("pyblish: Integration torn down successfully") + + +def containerise(name, + namespace, + nodes, + context, + loader=None, + suffix=""): + """Bundle `nodes` into a subnet and imprint it with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + nodes (list): Long names of nodes to containerise + context (dict): Asset information + loader (str, optional): Name of loader used to produce this container. + suffix (str, optional): Suffix of container, defaults to `_CON`. + + Returns: + container (str): Name of container assembly + + """ + + # Ensure AVALON_CONTAINERS subnet exists + subnet = hou.node(AVALON_CONTAINERS) + if subnet is None: + obj_network = hou.node("/obj") + subnet = obj_network.createNode("subnet", + node_name="AVALON_CONTAINERS") + + # Create proper container name + container_name = "{}_{}".format(name, suffix or "CON") + container = hou.node("/obj/{}".format(name)) + container.setName(container_name, unique_name=True) + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace, + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + } + + lib.imprint(container, data) + + # "Parent" the container under the container network + hou.moveNodesTo([container], subnet) + + subnet.node(container_name).moveToGoodPosition() + + return container + + +def parse_container(container): + """Return the container node's full container data. + + Args: + container (hou.Node): A container node name. + + Returns: + dict: The container schema data for this container node. + + """ + data = lib.read(container) + + # Backwards compatibility pre-schemas for containers + data["schema"] = data.get("schema", "openpype:container-1.0") + + # Append transient data + data["objectName"] = container.path() + data["node"] = container + + return data + + +def ls(): + containers = [] + for identifier in (AVALON_CONTAINER_ID, + "pyblish.mindbender.container"): + containers += lib.lsattr("id", identifier) + + has_metadata_collector = False + config_host = find_submodule(avalon.api.registered_config(), "houdini") + if hasattr(config_host, "collect_container_metadata"): + has_metadata_collector = True + + for container in sorted(containers, + # Hou 19+ Python 3 hou.ObjNode are not + # sortable due to not supporting greater + # than comparisons + key=lambda node: node.path()): + data = parse_container(container) + + # Collect custom data if attribute is present + if has_metadata_collector: + metadata = config_host.collect_container_metadata(container) + data.update(metadata) + + yield data + + +def before_save(*args): + return lib.validate_fps() + + +def on_save(*args): + + log.info("Running callback on save..") + + nodes = lib.get_id_required_nodes() + for node, new_id in lib.generate_ids(nodes): + lib.set_id(node, new_id, overwrite=False) + + +def on_open(*args): + + if not hou.isUIAvailable(): + log.debug("Batch mode detected, ignoring `on_open` callbacks..") + return + + log.info("Running callback on open..") + + # Validate FPS after update_task_from_path to + # ensure it is using correct FPS for the asset + lib.validate_fps() + + if any_outdated(): + from openpype.widgets import popup + + log.warning("Scene has outdated content.") + + # Get main window + parent = get_main_window() + if parent is None: + log.info("Skipping outdated content pop-up " + "because Houdini window can't be found.") + else: + + # Show outdated pop-up + def _on_show_inventory(): + from openpype.tools.utils import host_tools + host_tools.show_scene_inventory(parent=parent) + + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Houdini scene has outdated content") + dialog.setMessage("There are outdated containers in " + "your Houdini scene.") + dialog.on_clicked.connect(_on_show_inventory) + dialog.show() + + +def on_new(_): + """Set project resolution and fps when create a new file""" + log.info("Running callback on new..") + _set_asset_fps() + + +def _set_asset_fps(): + """Set Houdini scene FPS to the default required for current asset""" + + # Set new scene fps + fps = get_asset_fps() + print("Setting scene FPS to %i" % fps) + lib.set_scene_fps(fps) + + +def on_pyblish_instance_toggled(instance, new_value, old_value): + """Toggle saver tool passthrough states on instance toggles.""" + @contextlib.contextmanager + def main_take(no_update=True): + """Enter root take during context""" + original_take = hou.takes.currentTake() + original_update_mode = hou.updateModeSetting() + root = hou.takes.rootTake() + has_changed = False + try: + if original_take != root: + has_changed = True + if no_update: + hou.setUpdateMode(hou.updateMode.Manual) + hou.takes.setCurrentTake(root) + yield + finally: + if has_changed: + if no_update: + hou.setUpdateMode(original_update_mode) + hou.takes.setCurrentTake(original_take) + + if not instance.data.get("_allowToggleBypass", True): + return + + nodes = instance[:] + if not nodes: + return + + # Assume instance node is first node + instance_node = nodes[0] + + if not hasattr(instance_node, "isBypassed"): + # Likely not a node that can actually be bypassed + log.debug("Can't bypass node: %s", instance_node.path()) + return + + if instance_node.isBypassed() != (not old_value): + print("%s old bypass state didn't match old instance state, " + "updating anyway.." % instance_node.path()) + + try: + # Go into the main take, because when in another take changing + # the bypass state of a note cannot be done due to it being locked + # by default. + with main_take(no_update=True): + instance_node.bypass(not new_value) + except hou.PermissionError as exc: + log.warning("%s - %s", instance_node.path(), exc) diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index 63d9bba470..e64b505d2c 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -1,25 +1,82 @@ # -*- coding: utf-8 -*- """Houdini specific Avalon/Pyblish plugin definitions.""" import sys -from avalon.api import CreatorError -from avalon import houdini import six +import avalon.api +from avalon.api import CreatorError import hou from openpype.api import PypeCreatorMixin +from .lib import imprint class OpenPypeCreatorError(CreatorError): pass -class Creator(PypeCreatorMixin, houdini.Creator): +class Creator(PypeCreatorMixin, avalon.api.Creator): + """Creator plugin to create instances in Houdini + + To support the wide range of node types for render output (Alembic, VDB, + Mantra) the Creator needs a node type to create the correct instance + + By default, if none is given, is `geometry`. An example of accepted node + types: geometry, alembic, ifd (mantra) + + Please check the Houdini documentation for more node types. + + Tip: to find the exact node type to create press the `i` left of the node + when hovering over a node. The information is visible under the name of + the node. + + """ + + def __init__(self, *args, **kwargs): + super(Creator, self).__init__(*args, **kwargs) + self.nodes = list() + def process(self): + """This is the base functionality to create instances in Houdini + + The selected nodes are stored in self to be used in an override method. + This is currently necessary in order to support the multiple output + types in Houdini which can only be rendered through their own node. + + Default node type if none is given is `geometry` + + It also makes it easier to apply custom settings per instance type + + Example of override method for Alembic: + + def process(self): + instance = super(CreateEpicNode, self, process() + # Set paramaters for Alembic node + instance.setParms( + {"sop_path": "$HIP/%s.abc" % self.nodes[0]} + ) + + Returns: + hou.Node + + """ try: - # re-raise as standard Python exception so - # Avalon can catch it - instance = super(Creator, self).process() + if (self.options or {}).get("useSelection"): + self.nodes = hou.selectedNodes() + + # Get the node type and remove it from the data, not needed + node_type = self.data.pop("node_type", None) + if node_type is None: + node_type = "geometry" + + # Get out node + out = hou.node("/out") + instance = out.createNode(node_type, node_name=self.name) + instance.moveToGoodPosition() + + imprint(instance, self.data) + self._process(instance) + except hou.Error as er: six.reraise( OpenPypeCreatorError, diff --git a/openpype/hosts/houdini/api/workio.py b/openpype/hosts/houdini/api/workio.py new file mode 100644 index 0000000000..e7310163ea --- /dev/null +++ b/openpype/hosts/houdini/api/workio.py @@ -0,0 +1,58 @@ +"""Host API required Work Files tool""" +import os + +import hou +from avalon import api + + +def file_extensions(): + return api.HOST_WORKFILE_EXTENSIONS["houdini"] + + +def has_unsaved_changes(): + return hou.hipFile.hasUnsavedChanges() + + +def save_file(filepath): + + # Force forwards slashes to avoid segfault + filepath = filepath.replace("\\", "/") + + hou.hipFile.save(file_name=filepath, + save_to_recent_files=True) + + return filepath + + +def open_file(filepath): + + # Force forwards slashes to avoid segfault + filepath = filepath.replace("\\", "/") + + hou.hipFile.load(filepath, + suppress_save_prompt=True, + ignore_load_warnings=False) + + return filepath + + +def current_file(): + + current_filepath = hou.hipFile.path() + if (os.path.basename(current_filepath) == "untitled.hip" and + not os.path.exists(current_filepath)): + # By default a new scene in houdini is saved in the current + # working directory as "untitled.hip" so we need to capture + # that and consider it 'not saved' when it's in that state. + return None + + return current_filepath + + +def work_root(session): + work_dir = session["AVALON_WORKDIR"] + scene_dir = session.get("AVALON_SCENEDIR") + if scene_dir: + return os.path.join(work_dir, scene_dir) + else: + return work_dir diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py index 459da8bfdf..0a9c1bad1e 100644 --- a/openpype/hosts/houdini/plugins/create/create_hda.py +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -from openpype.hosts.houdini.api import plugin -from avalon.houdini import lib -from avalon import io import hou +from avalon import io +from openpype.hosts.houdini.api import lib +from openpype.hosts.houdini.api import plugin class CreateHDA(plugin.Creator): diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index df66d56008..eaab81f396 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -1,6 +1,7 @@ +import os from avalon import api -from avalon.houdini import pipeline +from openpype.hosts.houdini.api import pipeline class AbcLoader(api.Loader): @@ -14,8 +15,6 @@ class AbcLoader(api.Loader): color = "orange" def load(self, context, name=None, namespace=None, data=None): - - import os import hou # Format file name, Houdini only wants forward slashes diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 8b98b7c05e..8916d3b9b7 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline +from openpype.hosts.houdini.api import pipeline ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py index 6610d5e513..f5f2fb7481 100644 --- a/openpype/hosts/houdini/plugins/load/load_hda.py +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from avalon import api -from avalon.houdini import pipeline +from openpype.hosts.houdini.api import pipeline class HdaLoader(api.Loader): diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 4ff2777d77..39f583677b 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -1,7 +1,7 @@ import os from avalon import api -from avalon.houdini import pipeline, lib +from openpype.hosts.houdini.api import lib, pipeline import hou diff --git a/openpype/hosts/houdini/plugins/load/load_usd_layer.py b/openpype/hosts/houdini/plugins/load/load_usd_layer.py index 7483101409..232ce59479 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_layer.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_layer.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline, lib +from openpype.hosts.houdini.api import lib, pipeline class USDSublayerLoader(api.Loader): diff --git a/openpype/hosts/houdini/plugins/load/load_usd_reference.py b/openpype/hosts/houdini/plugins/load/load_usd_reference.py index cab3cb5269..224bfc2d61 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_reference.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_reference.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline, lib +from openpype.hosts.houdini.api import lib, pipeline class USDReferenceLoader(api.Loader): diff --git a/openpype/hosts/houdini/plugins/load/load_vdb.py b/openpype/hosts/houdini/plugins/load/load_vdb.py index 5f7e400b39..40aa7a1d18 100644 --- a/openpype/hosts/houdini/plugins/load/load_vdb.py +++ b/openpype/hosts/houdini/plugins/load/load_vdb.py @@ -2,7 +2,7 @@ import os import re from avalon import api -from avalon.houdini import pipeline +from openpype.hosts.houdini.api import pipeline class VdbLoader(api.Loader): diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 12d118f0cc..d38927984a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -2,7 +2,7 @@ import hou import pyblish.api -from avalon.houdini import lib +from openpype.hosts.houdini.api import lib class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py index 7df5e8b6f2..0600730d00 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py @@ -1,6 +1,6 @@ import hou import pyblish.api -from avalon.houdini import lib +from openpype.hosts.houdini.api import lib import openpype.hosts.houdini.api.usd as hou_usdlib import openpype.lib.usdlib as usdlib diff --git a/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py b/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py index 3ae16efe56..c635a53074 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py +++ b/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py @@ -2,7 +2,7 @@ import pyblish.api import openpype.api import hou -from avalon.houdini import lib +from openpype.hosts.houdini.api import lib class CollectRemotePublishSettings(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index c34310cf72..bc4a2e809a 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -56,18 +56,6 @@ host_tools.show_workfiles(parent) ]]> - - - - - - - - - - diff --git a/openpype/hosts/houdini/startup/scripts/123.py b/openpype/hosts/houdini/startup/scripts/123.py index 4233d68c15..eb33b49759 100644 --- a/openpype/hosts/houdini/startup/scripts/123.py +++ b/openpype/hosts/houdini/startup/scripts/123.py @@ -1,9 +1,10 @@ -from avalon import api, houdini +import avalon.api +from openpype.hosts.houdini import api def main(): print("Installing OpenPype ...") - api.install(houdini) + avalon.api.install(api) main() diff --git a/openpype/hosts/houdini/startup/scripts/houdinicore.py b/openpype/hosts/houdini/startup/scripts/houdinicore.py index 4233d68c15..eb33b49759 100644 --- a/openpype/hosts/houdini/startup/scripts/houdinicore.py +++ b/openpype/hosts/houdini/startup/scripts/houdinicore.py @@ -1,9 +1,10 @@ -from avalon import api, houdini +import avalon.api +from openpype.hosts.houdini import api def main(): print("Installing OpenPype ...") - api.install(houdini) + avalon.api.install(api) main() diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index 549f100007..b7d26a7818 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -5,9 +5,7 @@ def add_implementation_envs(env, _app): # Add requirements to PYTHONPATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_python_paths = [ - os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), - os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), - os.path.join(pype_root, "tools", "mayalookassigner") + os.path.join(pype_root, "openpype", "hosts", "maya", "startup") ] old_python_path = env.get("PYTHONPATH") or "" for path in old_python_path.split(os.pathsep): diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 0ad1c8ba29..9ea798e927 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -1,233 +1,91 @@ -import os -import logging -import weakref +"""Public API -from maya import utils, cmds +Anything that isn't defined here is INTERNAL and unreliable for external use. -from avalon import api as avalon -from avalon import pipeline -from avalon.maya import suspended_refresh -from avalon.maya.pipeline import IS_HEADLESS -from openpype.tools.utils import host_tools -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") +from .pipeline import ( + install, + uninstall, -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.maya.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + ls, + containerise, + + lock, + unlock, + is_locked, + lock_ignored, + +) +from .plugin import ( + Creator, + Loader +) + +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +from .lib import ( + export_alembic, + lsattr, + lsattrs, + read, + + apply_shaders, + without_extension, + maintained_selection, + suspended_refresh, + + unique_name, + unique_namespace, +) -def install(): - from openpype.settings import get_project_settings +__all__ = [ + "install", + "uninstall", - project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) - # process path mapping - dirmap_processor = MayaDirmap("maya", project_settings) - dirmap_processor.process_dirmap() + "ls", + "containerise", - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) - log.info(PUBLISH_PATH) - menu.install() + "lock", + "unlock", + "is_locked", + "lock_ignored", - log.info("Installing callbacks ... ") - avalon.on("init", on_init) + "Creator", + "Loader", - # Callbacks below are not required for headless mode, the `init` however - # is important to load referenced Alembics correctly at rendertime. - if IS_HEADLESS: - log.info("Running in headless mode, skipping Maya " - "save/open/new callback installation..") - return + # Workfiles API + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root", - avalon.on("save", on_save) - avalon.on("open", on_open) - avalon.on("new", on_new) - avalon.before("save", on_before_save) - avalon.on("taskChanged", on_task_changed) - avalon.on("before.workfile.save", before_workfile_save) + # Utility functions + "export_alembic", + "lsattr", + "lsattrs", + "read", - log.info("Setting default family states for loader..") - avalon.data["familiesStateToggled"] = ["imagesequence"] + "unique_name", + "unique_namespace", + "apply_shaders", + "without_extension", + "maintained_selection", + "suspended_refresh", -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) +] - menu.uninstall() - - -def on_init(_): - avalon.logger.info("Running callback on init..") - - def safe_deferred(fn): - """Execute deferred the function in a try-except""" - - def _fn(): - """safely call in deferred callback""" - try: - fn() - except Exception as exc: - print(exc) - - try: - utils.executeDeferred(_fn) - except Exception as exc: - print(exc) - - # Force load Alembic so referenced alembics - # work correctly on scene open - cmds.loadPlugin("AbcImport", quiet=True) - cmds.loadPlugin("AbcExport", quiet=True) - - # Force load objExport plug-in (requested by artists) - cmds.loadPlugin("objExport", quiet=True) - - from .customize import ( - override_component_mask_commands, - override_toolbox_ui - ) - safe_deferred(override_component_mask_commands) - - launch_workfiles = os.environ.get("WORKFILES_STARTUP") - - if launch_workfiles: - safe_deferred(host_tools.show_workfiles) - - if not IS_HEADLESS: - safe_deferred(override_toolbox_ui) - - -def on_before_save(return_code, _): - """Run validation for scene's FPS prior to saving""" - return lib.validate_fps() - - -def on_save(_): - """Automatically add IDs to new nodes - - Any transform of a mesh, without an existing ID, is given one - automatically on file save. - """ - - avalon.logger.info("Running callback on save..") - - # # Update current task for the current scene - # update_task_from_path(cmds.file(query=True, sceneName=True)) - - # Generate ids of the current context on nodes in the scene - nodes = lib.get_id_required_nodes(referenced_nodes=False) - for node, new_id in lib.generate_ids(nodes): - lib.set_id(node, new_id, overwrite=False) - - -def on_open(_): - """On scene open let's assume the containers have changed.""" - - from Qt import QtWidgets - from openpype.widgets import popup - - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.remove_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_change_observer()") - # # Update current task for the current scene - # update_task_from_path(cmds.file(query=True, sceneName=True)) - - # Validate FPS after update_task_from_path to - # ensure it is using correct FPS for the asset - lib.validate_fps() - lib.fix_incompatible_containers() - - if any_outdated(): - log.warning("Scene has outdated content.") - - # Find maya main window - top_level_widgets = {w.objectName(): w for w in - QtWidgets.QApplication.topLevelWidgets()} - parent = top_level_widgets.get("MayaWindow", None) - - if parent is None: - log.info("Skipping outdated content pop-up " - "because Maya window can't be found.") - else: - - # Show outdated pop-up - def _on_show_inventory(): - host_tools.show_scene_inventory(parent=parent) - - dialog = popup.Popup(parent=parent) - dialog.setWindowTitle("Maya scene has outdated content") - dialog.setMessage("There are outdated containers in " - "your Maya scene.") - dialog.on_show.connect(_on_show_inventory) - dialog.show() - - -def on_new(_): - """Set project resolution and fps when create a new file""" - avalon.logger.info("Running callback on new..") - with suspended_refresh(): - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.remove_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_observer()") - cmds.evalDeferred( - "from openpype.hosts.maya.api import lib;" - "lib.add_render_layer_change_observer()") - lib.set_context_settings() - - -def on_task_changed(*args): - """Wrapped function of app initialize and maya's on task changed""" - # Run - with suspended_refresh(): - lib.set_context_settings() - lib.update_content_on_context_change() - - msg = " project: {}\n asset: {}\n task:{}".format( - avalon.Session["AVALON_PROJECT"], - avalon.Session["AVALON_ASSET"], - avalon.Session["AVALON_TASK"] - ) - - lib.show_message( - "Context was changed", - ("Context was changed to:\n{}".format(msg)), - ) - - -def before_workfile_save(event): - workdir_path = event.workdir_path - if workdir_path: - copy_workspace_mel(workdir_path) - - -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)) +# Backwards API compatibility +open = open_file +save = save_file diff --git a/openpype/hosts/maya/api/action.py b/openpype/hosts/maya/api/action.py index a98b906d8c..ab26748c8a 100644 --- a/openpype/hosts/maya/api/action.py +++ b/openpype/hosts/maya/api/action.py @@ -2,7 +2,7 @@ from __future__ import absolute_import import pyblish.api - +from avalon import io from openpype.api import get_errored_instances_from_context @@ -72,8 +72,7 @@ class GenerateUUIDsOnInvalidAction(pyblish.api.Action): nodes (list): all nodes to regenerate ids on """ - from openpype.hosts.maya.api import lib - import avalon.io as io + from . import lib asset = instance.data['asset'] asset_id = io.find_one({"name": asset, "type": "asset"}, diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index d4c2b6a225..c774afcc12 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- """OpenPype script commands to be used directly in Maya.""" +from maya import cmds +from avalon import api, io class ToolWindows: @@ -51,3 +53,134 @@ def edit_shader_definitions(): window = ShaderDefinitionsEditor(parent=main_window) ToolWindows.set_window("shader_definition_editor", window) window.show() + + +def reset_frame_range(): + """Set frame range to current asset""" + # Set FPS first + fps = {15: 'game', + 24: 'film', + 25: 'pal', + 30: 'ntsc', + 48: 'show', + 50: 'palf', + 60: 'ntscf', + 23.98: '23.976fps', + 23.976: '23.976fps', + 29.97: '29.97fps', + 47.952: '47.952fps', + 47.95: '47.952fps', + 59.94: '59.94fps', + 44100: '44100fps', + 48000: '48000fps' + }.get(float(api.Session.get("AVALON_FPS", 25)), "pal") + + cmds.currentUnit(time=fps) + + # Set frame start/end + asset_name = api.Session["AVALON_ASSET"] + asset = io.find_one({"name": asset_name, "type": "asset"}) + + frame_start = asset["data"].get("frameStart") + frame_end = asset["data"].get("frameEnd") + # Backwards compatibility + if frame_start is None or frame_end is None: + frame_start = asset["data"].get("edit_in") + frame_end = asset["data"].get("edit_out") + + if frame_start is None or frame_end is None: + cmds.warning("No edit information found for %s" % asset_name) + return + + handles = asset["data"].get("handles") or 0 + handle_start = asset["data"].get("handleStart") + if handle_start is None: + handle_start = handles + + handle_end = asset["data"].get("handleEnd") + if handle_end is None: + handle_end = handles + + frame_start -= int(handle_start) + frame_end += int(handle_end) + + cmds.playbackOptions(minTime=frame_start) + cmds.playbackOptions(maxTime=frame_end) + cmds.playbackOptions(animationStartTime=frame_start) + cmds.playbackOptions(animationEndTime=frame_end) + cmds.playbackOptions(minTime=frame_start) + cmds.playbackOptions(maxTime=frame_end) + cmds.currentTime(frame_start) + + cmds.setAttr("defaultRenderGlobals.startFrame", frame_start) + cmds.setAttr("defaultRenderGlobals.endFrame", frame_end) + + +def _resolution_from_document(doc): + if not doc or "data" not in doc: + print("Entered document is not valid. \"{}\"".format(str(doc))) + return None + + resolution_width = doc["data"].get("resolutionWidth") + resolution_height = doc["data"].get("resolutionHeight") + # Backwards compatibility + if resolution_width is None or resolution_height is None: + resolution_width = doc["data"].get("resolution_width") + resolution_height = doc["data"].get("resolution_height") + + # Make sure both width and heigh are set + if resolution_width is None or resolution_height is None: + cmds.warning( + "No resolution information found for \"{}\"".format(doc["name"]) + ) + return None + + return int(resolution_width), int(resolution_height) + + +def reset_resolution(): + # Default values + resolution_width = 1920 + resolution_height = 1080 + + # Get resolution from asset + asset_name = api.Session["AVALON_ASSET"] + asset_doc = io.find_one({"name": asset_name, "type": "asset"}) + resolution = _resolution_from_document(asset_doc) + # Try get resolution from project + if resolution is None: + # TODO go through visualParents + print(( + "Asset \"{}\" does not have set resolution." + " Trying to get resolution from project" + ).format(asset_name)) + project_doc = io.find_one({"type": "project"}) + resolution = _resolution_from_document(project_doc) + + if resolution is None: + msg = "Using default resolution {}x{}" + else: + resolution_width, resolution_height = resolution + msg = "Setting resolution to {}x{}" + + print(msg.format(resolution_width, resolution_height)) + + # set for different renderers + # arnold, vray, redshift, renderman + + renderer = cmds.getAttr("defaultRenderGlobals.currentRenderer").lower() + # handle various renderman names + if renderer.startswith("renderman"): + renderer = "renderman" + + # default attributes are usable for Arnold, Renderman and Redshift + width_attr_name = "defaultResolution.width" + height_attr_name = "defaultResolution.height" + + # Vray has its own way + if renderer == "vray": + width_attr_name = "vraySettings.width" + height_attr_name = "vraySettings.height" + + cmds.setAttr(width_attr_name, resolution_width) + cmds.setAttr(height_attr_name, resolution_height) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index c7fb042ead..37fd543315 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -8,10 +8,9 @@ from functools import partial import maya.cmds as mc import maya.mel as mel -from avalon.maya import pipeline from openpype.api import resources from openpype.tools.utils import host_tools - +from .lib import get_main_window log = logging.getLogger(__name__) @@ -76,6 +75,7 @@ def override_component_mask_commands(): def override_toolbox_ui(): """Add custom buttons in Toolbox as replacement for Maya web help icon.""" icons = resources.get_resource("icons") + parent_widget = get_main_window() # Ensure the maya web icon on toolbox exists web_button = "ToolBox|MainToolboxLayout|mayaWebButton" @@ -115,7 +115,7 @@ def override_toolbox_ui(): label="Work Files", image=os.path.join(icons, "workfiles.png"), command=lambda: host_tools.show_workfiles( - parent=pipeline._parent + parent=parent_widget ), width=icon_size, height=icon_size, @@ -130,7 +130,7 @@ def override_toolbox_ui(): label="Loader", image=os.path.join(icons, "loader.png"), command=lambda: host_tools.show_loader( - parent=pipeline._parent, use_context=True + parent=parent_widget, use_context=True ), width=icon_size, height=icon_size, @@ -145,7 +145,7 @@ def override_toolbox_ui(): label="Inventory", image=os.path.join(icons, "inventory.png"), command=lambda: host_tools.show_scene_inventory( - parent=pipeline._parent + parent=parent_widget ), width=icon_size, height=icon_size, diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b236fa7cdb..1f6c8c1deb 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -1,7 +1,8 @@ """Standalone helper functions""" -import re import os +import sys +import re import platform import uuid import math @@ -18,16 +19,19 @@ import bson from maya import cmds, mel import maya.api.OpenMaya as om -from avalon import api, maya, io, pipeline -import avalon.maya.lib -import avalon.maya.interactive +from avalon import api, io, pipeline from openpype import lib from openpype.api import get_anatomy_settings +from .commands import reset_frame_range +self = sys.modules[__name__] +self._parent = None + log = logging.getLogger(__name__) +IS_HEADLESS = not hasattr(cmds, "about") or cmds.about(batch=True) ATTRIBUTE_DICT = {"int": {"attributeType": "long"}, "str": {"dataType": "string"}, "unicode": {"dataType": "string"}, @@ -100,6 +104,155 @@ FLOAT_FPS = {23.98, 23.976, 29.97, 47.952, 59.94} RENDERLIKE_INSTANCE_FAMILIES = ["rendering", "vrayscene"] +def get_main_window(): + """Acquire Maya's main window""" + from Qt import QtWidgets + + if self._parent is None: + self._parent = { + widget.objectName(): widget + for widget in QtWidgets.QApplication.topLevelWidgets() + }["MayaWindow"] + return self._parent + + +@contextlib.contextmanager +def suspended_refresh(): + """Suspend viewport refreshes""" + + try: + cmds.refresh(suspend=True) + yield + finally: + cmds.refresh(suspend=False) + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> scene = cmds.file(new=True, force=True) + >>> node = cmds.createNode("transform", name="Test") + >>> cmds.select("persp") + >>> with maintained_selection(): + ... cmds.select("Test", replace=True) + >>> "Test" in cmds.ls(selection=True) + False + + """ + + previous_selection = cmds.ls(selection=True) + try: + yield + finally: + if previous_selection: + cmds.select(previous_selection, + replace=True, + noExpand=True) + else: + cmds.select(clear=True) + + +def unique_name(name, format="%02d", namespace="", prefix="", suffix=""): + """Return unique `name` + + The function takes into consideration an optional `namespace` + and `suffix`. The suffix is included in evaluating whether a + name exists - such as `name` + "_GRP" - but isn't included + in the returned value. + + If a namespace is provided, only names within that namespace + are considered when evaluating whether the name is unique. + + Arguments: + format (str, optional): The `name` is given a number, this determines + how this number is formatted. Defaults to a padding of 2. + E.g. my_name01, my_name02. + namespace (str, optional): Only consider names within this namespace. + suffix (str, optional): Only consider names with this suffix. + + Example: + >>> name = cmds.createNode("transform", name="MyName") + >>> cmds.objExists(name) + True + >>> unique = unique_name(name) + >>> cmds.objExists(unique) + False + + """ + + iteration = 1 + unique = prefix + (name + format % iteration) + suffix + + while cmds.objExists(namespace + ":" + unique): + iteration += 1 + unique = prefix + (name + format % iteration) + suffix + + if suffix: + return unique[:-len(suffix)] + + return unique + + +def unique_namespace(namespace, format="%02d", prefix="", suffix=""): + """Return unique namespace + + Similar to :func:`unique_name` but evaluating namespaces + as opposed to object names. + + Arguments: + namespace (str): Name of namespace to consider + format (str, optional): Formatting of the given iteration number + suffix (str, optional): Only consider namespaces with this suffix. + + """ + + iteration = 1 + unique = prefix + (namespace + format % iteration) + suffix + + # The `existing` set does not just contain the namespaces but *all* nodes + # within "current namespace". We need all because the namespace could + # also clash with a node name. To be truly unique and valid one needs to + # check against all. + existing = set(cmds.namespaceInfo(listNamespace=True)) + while unique in existing: + iteration += 1 + unique = prefix + (namespace + format % iteration) + suffix + + return unique + + +def read(node): + """Return user-defined attributes from `node`""" + + data = dict() + + for attr in cmds.listAttr(node, userDefined=True) or list(): + try: + value = cmds.getAttr(node + "." + attr, asString=True) + + except RuntimeError: + # For Message type attribute or others that have connections, + # take source node name as value. + source = cmds.listConnections(node + "." + attr, + source=True, + destination=False) + source = cmds.ls(source, long=True) or [None] + value = source[0] + + except ValueError: + # Some attributes cannot be read directly, + # such as mesh and color attributes. These + # are considered non-essential to this + # particular publishing pipeline. + value = None + + data[attr] = value + + return data + + def _get_mel_global(name): """Return the value of a mel global variable""" return mel.eval("$%s = $%s;" % (name, name)) @@ -280,6 +433,73 @@ def shape_from_element(element): return node +def export_alembic(nodes, + file, + frame_range=None, + write_uv=True, + write_visibility=True, + attribute_prefix=None): + """Wrap native MEL command with limited set of arguments + + Arguments: + nodes (list): Long names of nodes to cache + + file (str): Absolute path to output destination + + frame_range (tuple, optional): Start- and end-frame of cache, + default to current animation range. + + write_uv (bool, optional): Whether or not to include UVs, + default to True + + write_visibility (bool, optional): Turn on to store the visibility + state of objects in the Alembic file. Otherwise, all objects are + considered visible, default to True + + attribute_prefix (str, optional): Include all user-defined + attributes with this prefix. + + """ + + if frame_range is None: + frame_range = ( + cmds.playbackOptions(query=True, ast=True), + cmds.playbackOptions(query=True, aet=True) + ) + + options = [ + ("file", file), + ("frameRange", "%s %s" % frame_range), + ] + [("root", mesh) for mesh in nodes] + + if isinstance(attribute_prefix, string_types): + # Include all attributes prefixed with "mb" + # TODO(marcus): This would be a good candidate for + # external registration, so that the developer + # doesn't have to edit this function to modify + # the behavior of Alembic export. + options.append(("attrPrefix", str(attribute_prefix))) + + if write_uv: + options.append(("uvWrite", "")) + + if write_visibility: + options.append(("writeVisibility", "")) + + # Generate MEL command + mel_args = list() + for key, value in options: + mel_args.append("-{0} {1}".format(key, value)) + + mel_args_string = " ".join(mel_args) + mel_cmd = "AbcExport -j \"{0}\"".format(mel_args_string) + + # For debuggability, put the string passed to MEL in the Script editor. + print("mel.eval('%s')" % mel_cmd) + + return mel.eval(mel_cmd) + + def collect_animation_data(fps=False): """Get the basic animation data @@ -305,6 +525,256 @@ def collect_animation_data(fps=False): return data +def imprint(node, data): + """Write `data` to `node` as userDefined attributes + + Arguments: + node (str): Long name of node + data (dict): Dictionary of key/value pairs + + Example: + >>> from maya import cmds + >>> def compute(): + ... return 6 + ... + >>> cube, generator = cmds.polyCube() + >>> imprint(cube, { + ... "regularString": "myFamily", + ... "computedValue": lambda: compute() + ... }) + ... + >>> cmds.getAttr(cube + ".computedValue") + 6 + + """ + + for key, value in data.items(): + + if callable(value): + # Support values evaluated at imprint + value = value() + + if isinstance(value, bool): + add_type = {"attributeType": "bool"} + set_type = {"keyable": False, "channelBox": True} + elif isinstance(value, string_types): + add_type = {"dataType": "string"} + set_type = {"type": "string"} + elif isinstance(value, int): + add_type = {"attributeType": "long"} + set_type = {"keyable": False, "channelBox": True} + elif isinstance(value, float): + add_type = {"attributeType": "double"} + set_type = {"keyable": False, "channelBox": True} + elif isinstance(value, (list, tuple)): + add_type = {"attributeType": "enum", "enumName": ":".join(value)} + set_type = {"keyable": False, "channelBox": True} + value = 0 # enum default + else: + raise TypeError("Unsupported type: %r" % type(value)) + + cmds.addAttr(node, longName=key, **add_type) + cmds.setAttr(node + "." + key, value, **set_type) + + +def serialise_shaders(nodes): + """Generate a shader set dictionary + + Arguments: + nodes (list): Absolute paths to nodes + + Returns: + dictionary of (shader: id) pairs + + Schema: + { + "shader1": ["id1", "id2"], + "shader2": ["id3", "id1"] + } + + Example: + { + "Bazooka_Brothers01_:blinn4SG": [ + "f9520572-ac1d-11e6-b39e-3085a99791c9.f[4922:5001]", + "f9520572-ac1d-11e6-b39e-3085a99791c9.f[4587:4634]", + "f9520572-ac1d-11e6-b39e-3085a99791c9.f[1120:1567]", + "f9520572-ac1d-11e6-b39e-3085a99791c9.f[4251:4362]" + ], + "lambert2SG": [ + "f9520571-ac1d-11e6-9dbb-3085a99791c9" + ] + } + + """ + + valid_nodes = cmds.ls( + nodes, + long=True, + recursive=True, + showType=True, + objectsOnly=True, + type="transform" + ) + + meshes_by_id = {} + for mesh in valid_nodes: + shapes = cmds.listRelatives(valid_nodes[0], + shapes=True, + fullPath=True) or list() + + if shapes: + shape = shapes[0] + if not cmds.nodeType(shape): + continue + + try: + id_ = cmds.getAttr(mesh + ".mbID") + + if id_ not in meshes_by_id: + meshes_by_id[id_] = list() + + meshes_by_id[id_].append(mesh) + + except ValueError: + continue + + meshes_by_shader = dict() + for mesh in meshes_by_id.values(): + shape = cmds.listRelatives(mesh, + shapes=True, + fullPath=True) or list() + + for shader in cmds.listConnections(shape, + type="shadingEngine") or list(): + + # Objects in this group are those that haven't got + # any shaders. These are expected to be managed + # elsewhere, such as by the default model loader. + if shader == "initialShadingGroup": + continue + + if shader not in meshes_by_shader: + meshes_by_shader[shader] = list() + + shaded = cmds.sets(shader, query=True) or list() + meshes_by_shader[shader].extend(shaded) + + shader_by_id = {} + for shader, shaded in meshes_by_shader.items(): + + if shader not in shader_by_id: + shader_by_id[shader] = list() + + for mesh in shaded: + + # Enable shader assignment to faces. + name = mesh.split(".f[")[0] + + transform = name + if cmds.objectType(transform) == "mesh": + transform = cmds.listRelatives(name, parent=True)[0] + + try: + id_ = cmds.getAttr(transform + ".mbID") + shader_by_id[shader].append(mesh.replace(name, id_)) + except KeyError: + continue + + # Remove duplicates + shader_by_id[shader] = list(set(shader_by_id[shader])) + + return shader_by_id + + +def lsattr(attr, value=None): + """Return nodes matching `key` and `value` + + Arguments: + attr (str): Name of Maya attribute + value (object, optional): Value of attribute. If none + is provided, return all nodes with this attribute. + + Example: + >> lsattr("id", "myId") + ["myNode"] + >> lsattr("id") + ["myNode", "myOtherNode"] + + """ + + if value is None: + return cmds.ls("*.%s" % attr, + recursive=True, + objectsOnly=True, + long=True) + return lsattrs({attr: value}) + + +def lsattrs(attrs): + """Return nodes with the given attribute(s). + + Arguments: + attrs (dict): Name and value pairs of expected matches + + Example: + >> # Return nodes with an `age` of five. + >> lsattr({"age": "five"}) + >> # Return nodes with both `age` and `color` of five and blue. + >> lsattr({"age": "five", "color": "blue"}) + + Return: + list: matching nodes. + + """ + + dep_fn = om.MFnDependencyNode() + dag_fn = om.MFnDagNode() + selection_list = om.MSelectionList() + + first_attr = next(iter(attrs)) + + try: + selection_list.add("*.{0}".format(first_attr), + searchChildNamespaces=True) + except RuntimeError as exc: + if str(exc).endswith("Object does not exist"): + return [] + + matches = set() + for i in range(selection_list.length()): + node = selection_list.getDependNode(i) + if node.hasFn(om.MFn.kDagNode): + fn_node = dag_fn.setObject(node) + full_path_names = [path.fullPathName() + for path in fn_node.getAllPaths()] + else: + fn_node = dep_fn.setObject(node) + full_path_names = [fn_node.name()] + + for attr in attrs: + try: + plug = fn_node.findPlug(attr, True) + if plug.asString() != attrs[attr]: + break + except RuntimeError: + break + else: + matches.update(full_path_names) + + return list(matches) + + +@contextlib.contextmanager +def without_extension(): + """Use cmds.file with defaultExtensions=False""" + previous_setting = cmds.file(defaultExtensions=True, query=True) + try: + cmds.file(defaultExtensions=False) + yield + finally: + cmds.file(defaultExtensions=previous_setting) + + @contextlib.contextmanager def attribute_values(attr_values): """Remaps node attributes to values during context. @@ -736,7 +1206,7 @@ def namespaced(namespace, new=True): """ original = cmds.namespaceInfo(cur=True, absoluteName=True) if new: - namespace = avalon.maya.lib.unique_namespace(namespace) + namespace = unique_namespace(namespace) cmds.namespace(add=namespace) try: @@ -1408,7 +1878,7 @@ def assign_look_by_version(nodes, version_id): raise RuntimeError("Could not find LookLoader, this is a bug") # Reference the look file - with maya.maintained_selection(): + with maintained_selection(): container_node = pipeline.load(Loader, look_representation) # Get container members @@ -1947,7 +2417,7 @@ def set_context_settings(): reset_scene_resolution() # Set frame range. - avalon.maya.interactive.reset_frame_range() + reset_frame_range() # Set colorspace set_colorspace() @@ -1970,34 +2440,27 @@ def validate_fps(): # rounding, we have to round those numbers coming from Maya. current_fps = float_round(mel.eval('currentTimeUnitToFPS()'), 2) - if current_fps != fps: + fps_match = current_fps == fps + if not fps_match and not IS_HEADLESS: + from openpype.widgets import popup - from Qt import QtWidgets - from ...widgets import popup + parent = get_main_window() - # Find maya main window - top_level_widgets = {w.objectName(): w for w in - QtWidgets.QApplication.topLevelWidgets()} + dialog = popup.Popup2(parent=parent) + dialog.setModal(True) + dialog.setWindowTitle("Maya scene not in line with project") + dialog.setMessage("The FPS is out of sync, please fix") - parent = top_level_widgets.get("MayaWindow", None) - if parent is None: - pass - else: - dialog = popup.Popup2(parent=parent) - dialog.setModal(True) - dialog.setWindowTitle("Maya scene not in line with project") - dialog.setMessage("The FPS is out of sync, please fix") + # Set new text for button (add optional argument for the popup?) + toggle = dialog.widgets["toggle"] + update = toggle.isChecked() + dialog.on_show.connect(lambda: set_scene_fps(fps, update)) - # Set new text for button (add optional argument for the popup?) - toggle = dialog.widgets["toggle"] - update = toggle.isChecked() - dialog.on_show.connect(lambda: set_scene_fps(fps, update)) + dialog.show() - dialog.show() + return False - return False - - return True + return fps_match def bake(nodes, @@ -2386,7 +2849,7 @@ def get_attr_in_layer(attr, layer): def fix_incompatible_containers(): """Return whether the current scene has any outdated content""" - host = avalon.api.registered_host() + host = api.registered_host() for container in host.ls(): loader = container['loader'] diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index df5058dfd5..b1934c757d 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -1,58 +1,146 @@ -import sys import os import logging from Qt import QtWidgets, QtGui +import maya.utils import maya.cmds as cmds -from avalon.maya import pipeline +import avalon.api 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 +from .lib import get_main_window, IS_HEADLESS +from .commands import reset_frame_range log = logging.getLogger(__name__) +MENU_NAME = "op_maya_menu" + def _get_menu(menu_name=None): """Return the menu instance if it currently exists in Maya""" if menu_name is None: - menu_name = pipeline._menu + menu_name = MENU_NAME widgets = {w.objectName(): w for w in QtWidgets.QApplication.allWidgets()} return widgets.get(menu_name) -def deferred(): - def add_build_workfiles_item(): - # Add build first workfile - cmds.menuItem(divider=True, parent=pipeline._menu) +def install(): + if cmds.about(batch=True): + log.info("Skipping openpype.menu initialization in batch mode..") + return + + def deferred(): + from avalon.tools import publish + parent_widget = get_main_window() + cmds.menu( + MENU_NAME, + label=avalon.api.Session["AVALON_LABEL"], + tearOff=True, + parent="MayaWindow" + ) + + # Create context menu + context_label = "{}, {}".format( + avalon.api.Session["AVALON_ASSET"], + avalon.api.Session["AVALON_TASK"] + ) + cmds.menuItem( + "currentContext", + label=context_label, + parent=MENU_NAME, + enable=False + ) + + cmds.setParent("..", menu=True) + + cmds.menuItem(divider=True) + + # Create default items + cmds.menuItem( + "Create...", + command=lambda *args: host_tools.show_creator(parent=parent_widget) + ) + + cmds.menuItem( + "Load...", + command=lambda *args: host_tools.show_loader( + parent=parent_widget, + use_context=True + ) + ) + + cmds.menuItem( + "Publish...", + command=lambda *args: host_tools.show_publish( + parent=parent_widget + ), + image=publish.ICON + ) + + cmds.menuItem( + "Manage...", + command=lambda *args: host_tools.show_scene_inventory( + parent=parent_widget + ) + ) + + cmds.menuItem( + "Library...", + command=lambda *args: host_tools.show_library_loader( + parent=parent_widget + ) + ) + + cmds.menuItem(divider=True) + + cmds.menuItem( + "Work Files...", + command=lambda *args: host_tools.show_workfiles( + parent=parent_widget + ), + ) + + cmds.menuItem( + "Reset Frame Range", + command=lambda *args: reset_frame_range() + ) + + cmds.menuItem( + "Reset Resolution", + command=lambda *args: lib.reset_scene_resolution() + ) + + cmds.menuItem( + "Set Colorspace", + command=lambda *args: lib.set_colorspace(), + ) + cmds.menuItem(divider=True, parent=MENU_NAME) cmds.menuItem( "Build First Workfile", - parent=pipeline._menu, + parent=MENU_NAME, command=lambda *args: BuildWorkfile().process() ) - def add_look_assigner_item(): cmds.menuItem( - "Look assigner", - parent=pipeline._menu, + "Look assigner...", command=lambda *args: host_tools.show_look_assigner( - pipeline._parent + parent_widget ) ) - def add_experimental_item(): cmds.menuItem( "Experimental tools...", - parent=pipeline._menu, command=lambda *args: host_tools.show_experimental_tools_dialog( - pipeline._parent + parent_widget ) ) + cmds.setParent(MENU_NAME, menu=True) def add_scripts_menu(): try: @@ -82,124 +170,13 @@ def deferred(): # apply configuration studio_menu.build_from_configuration(studio_menu, config) - def modify_workfiles(): - # 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() == "Work Files": - 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( - "Work Files", - parent=pipeline._menu, - command=lambda *args: host_tools.show_workfiles(pipeline._parent), - insertAfter=after_action - ) - - # Remove replaced action - 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() - - # Try to find "System" menu action in the menu - system_menu = None - for action in top_menu.actions(): - if action.text() == "System": - system_menu = action - break - - if system_menu is None: - return - - # Try to find "Project manager" action in "System" menu - project_manager_action = None - for action in system_menu.menu().children(): - if hasattr(action, "text") and action.text() == "Project Manager": - project_manager_action = action - break - - # Remove "Project manager" action if was found - 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() - add_colorspace() - add_scripts_menu() + # Allow time for uninstallation to finish. + # We use Maya's executeDeferred instead of QTimer.singleShot + # so that it only gets called after Maya UI has initialized too. + # This is crucial with Maya 2020+ which initializes without UI + # first as a QCoreApplication + maya.utils.executeDeferred(deferred) + cmds.evalDeferred(add_scripts_menu, lowestPriority=True) def uninstall(): @@ -214,18 +191,27 @@ def uninstall(): log.error(e) -def install(): - if cmds.about(batch=True): - log.info("Skipping openpype.menu initialization in batch mode..") - return - - # Allow time for uninstallation to finish. - cmds.evalDeferred(deferred, lowestPriority=True) - - def popup(): """Pop-up the existing menu near the mouse cursor.""" menu = _get_menu() cursor = QtGui.QCursor() point = cursor.pos() menu.exec_(point) + + +def update_menu_task_label(): + """Update the task label in Avalon menu to current session""" + + if IS_HEADLESS: + return + + object_name = "{}|currentContext".format(MENU_NAME) + if not cmds.menuItem(object_name, query=True, exists=True): + log.warning("Can't find menuItem: {}".format(object_name)) + return + + label = "{}, {}".format( + avalon.api.Session["AVALON_ASSET"], + avalon.api.Session["AVALON_TASK"] + ) + cmds.menuItem(object_name, edit=True, label=label) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py new file mode 100644 index 0000000000..476ceb840b --- /dev/null +++ b/openpype/hosts/maya/api/pipeline.py @@ -0,0 +1,596 @@ +import os +import sys +import errno +import logging +import contextlib + +from maya import utils, cmds, OpenMaya +import maya.api.OpenMaya as om + +import pyblish.api +import avalon.api + +from avalon.lib import find_submodule +from avalon.pipeline import AVALON_CONTAINER_ID + +import openpype.hosts.maya +from openpype.tools.utils import host_tools +from openpype.lib import any_outdated +from openpype.lib.path_tools import HostDirmap +from openpype.hosts.maya.lib import copy_workspace_mel +from . import menu, lib + +log = logging.getLogger("openpype.hosts.maya") + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.maya.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +AVALON_CONTAINERS = ":AVALON_CONTAINERS" + +self = sys.modules[__name__] +self._ignore_lock = False +self._events = {} + + +def install(): + from openpype.settings import get_project_settings + + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + # process path mapping + dirmap_processor = MayaDirmap("maya", project_settings) + dirmap_processor.process_dirmap() + + pyblish.api.register_plugin_path(PUBLISH_PATH) + pyblish.api.register_host("mayabatch") + pyblish.api.register_host("mayapy") + pyblish.api.register_host("maya") + + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) + log.info(PUBLISH_PATH) + + log.info("Installing callbacks ... ") + avalon.api.on("init", on_init) + + # Callbacks below are not required for headless mode, the `init` however + # is important to load referenced Alembics correctly at rendertime. + if lib.IS_HEADLESS: + log.info(("Running in headless mode, skipping Maya " + "save/open/new callback installation..")) + return + + _set_project() + _register_callbacks() + + menu.install() + + avalon.api.on("save", on_save) + avalon.api.on("open", on_open) + avalon.api.on("new", on_new) + avalon.api.before("save", on_before_save) + avalon.api.on("taskChanged", on_task_changed) + avalon.api.on("before.workfile.save", before_workfile_save) + + log.info("Setting default family states for loader..") + avalon.api.data["familiesStateToggled"] = ["imagesequence"] + + +def _set_project(): + """Sets the maya project to the current Session's work directory. + + Returns: + None + + """ + workdir = avalon.api.Session["AVALON_WORKDIR"] + + try: + os.makedirs(workdir) + except OSError as e: + # An already existing working directory is fine. + if e.errno == errno.EEXIST: + pass + else: + raise + + cmds.workspace(workdir, openWorkspace=True) + + +def _register_callbacks(): + for handler, event in self._events.copy().items(): + if event is None: + continue + + try: + OpenMaya.MMessage.removeCallback(event) + self._events[handler] = None + except RuntimeError as e: + log.info(e) + + self._events[_on_scene_save] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kBeforeSave, _on_scene_save + ) + + self._events[_before_scene_save] = OpenMaya.MSceneMessage.addCheckCallback( + OpenMaya.MSceneMessage.kBeforeSaveCheck, _before_scene_save + ) + + self._events[_on_scene_new] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kAfterNew, _on_scene_new + ) + + self._events[_on_maya_initialized] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kMayaInitialized, _on_maya_initialized + ) + + self._events[_on_scene_open] = OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kAfterOpen, _on_scene_open + ) + + log.info("Installed event handler _on_scene_save..") + log.info("Installed event handler _before_scene_save..") + log.info("Installed event handler _on_scene_new..") + log.info("Installed event handler _on_maya_initialized..") + log.info("Installed event handler _on_scene_open..") + + +def _on_maya_initialized(*args): + avalon.api.emit("init", args) + + if cmds.about(batch=True): + log.warning("Running batch mode ...") + return + + # Keep reference to the main Window, once a main window exists. + lib.get_main_window() + + +def _on_scene_new(*args): + avalon.api.emit("new", args) + + +def _on_scene_save(*args): + avalon.api.emit("save", args) + + +def _on_scene_open(*args): + avalon.api.emit("open", args) + + +def _before_scene_save(return_code, client_data): + + # Default to allowing the action. Registered + # callbacks can optionally set this to False + # in order to block the operation. + OpenMaya.MScriptUtil.setBool(return_code, True) + + avalon.api.emit("before_save", [return_code, client_data]) + + +def uninstall(): + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + pyblish.api.deregister_host("mayabatch") + pyblish.api.deregister_host("mayapy") + pyblish.api.deregister_host("maya") + + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path( + avalon.api.InventoryAction, INVENTORY_PATH + ) + + menu.uninstall() + + +def lock(): + """Lock scene + + Add an invisible node to your Maya scene with the name of the + current file, indicating that this file is "locked" and cannot + be modified any further. + + """ + + if not cmds.objExists("lock"): + with lib.maintained_selection(): + cmds.createNode("objectSet", name="lock") + cmds.addAttr("lock", ln="basename", dataType="string") + + # Permanently hide from outliner + cmds.setAttr("lock.verticesOnlySet", True) + + fname = cmds.file(query=True, sceneName=True) + basename = os.path.basename(fname) + cmds.setAttr("lock.basename", basename, type="string") + + +def unlock(): + """Permanently unlock a locked scene + + Doesn't throw an error if scene is already unlocked. + + """ + + try: + cmds.delete("lock") + except ValueError: + pass + + +def is_locked(): + """Query whether current scene is locked""" + fname = cmds.file(query=True, sceneName=True) + basename = os.path.basename(fname) + + if self._ignore_lock: + return False + + try: + return cmds.getAttr("lock.basename") == basename + except ValueError: + return False + + +@contextlib.contextmanager +def lock_ignored(): + """Context manager for temporarily ignoring the lock of a scene + + The purpose of this function is to enable locking a scene and + saving it with the lock still in place. + + Example: + >>> with lock_ignored(): + ... pass # Do things without lock + + """ + + self._ignore_lock = True + + try: + yield + finally: + self._ignore_lock = False + + +def parse_container(container): + """Return the container node's full container data. + + Args: + container (str): A container node name. + + Returns: + dict: The container schema data for this container node. + + """ + data = lib.read(container) + + # Backwards compatibility pre-schemas for containers + data["schema"] = data.get("schema", "openpype:container-1.0") + + # Append transient data + data["objectName"] = container + + return data + + +def _ls(): + """Yields Avalon container node names. + + Used by `ls()` to retrieve the nodes and then query the full container's + data. + + Yields: + str: Avalon container node name (objectSet) + + """ + + def _maya_iterate(iterator): + """Helper to iterate a maya iterator""" + while not iterator.isDone(): + yield iterator.thisNode() + iterator.next() + + ids = {AVALON_CONTAINER_ID, + # Backwards compatibility + "pyblish.mindbender.container"} + + # Iterate over all 'set' nodes in the scene to detect whether + # they have the avalon container ".id" attribute. + fn_dep = om.MFnDependencyNode() + iterator = om.MItDependencyNodes(om.MFn.kSet) + for mobject in _maya_iterate(iterator): + if mobject.apiTypeStr != "kSet": + # Only match by exact type + continue + + fn_dep.setObject(mobject) + if not fn_dep.hasAttribute("id"): + continue + + plug = fn_dep.findPlug("id", True) + value = plug.asString() + if value in ids: + yield fn_dep.name() + + +def ls(): + """Yields containers from active Maya scene + + This is the host-equivalent of api.ls(), but instead of listing + assets on disk, it lists assets already loaded in Maya; once loaded + they are called 'containers' + + Yields: + dict: container + + """ + container_names = _ls() + + has_metadata_collector = False + config_host = find_submodule(avalon.api.registered_config(), "maya") + if hasattr(config_host, "collect_container_metadata"): + has_metadata_collector = True + + for container in sorted(container_names): + data = parse_container(container) + + # Collect custom data if attribute is present + if has_metadata_collector: + metadata = config_host.collect_container_metadata(container) + data.update(metadata) + + yield data + + +def containerise(name, + namespace, + nodes, + context, + loader=None, + suffix="CON"): + """Bundle `nodes` into an assembly and imprint it with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + nodes (list): Long names of nodes to containerise + context (dict): Asset information + loader (str, optional): Name of loader used to produce this container. + suffix (str, optional): Suffix of container, defaults to `_CON`. + + Returns: + container (str): Name of container assembly + + """ + container = cmds.sets(nodes, name="%s_%s_%s" % (namespace, name, suffix)) + + data = [ + ("schema", "openpype:container-2.0"), + ("id", AVALON_CONTAINER_ID), + ("name", name), + ("namespace", namespace), + ("loader", str(loader)), + ("representation", context["representation"]["_id"]), + ] + + for key, value in data: + if not value: + continue + + if isinstance(value, (int, float)): + cmds.addAttr(container, longName=key, attributeType="short") + cmds.setAttr(container + "." + key, value) + + else: + cmds.addAttr(container, longName=key, dataType="string") + cmds.setAttr(container + "." + key, value, type="string") + + main_container = cmds.ls(AVALON_CONTAINERS, type="objectSet") + if not main_container: + main_container = cmds.sets(empty=True, name=AVALON_CONTAINERS) + + # Implement #399: Maya 2019+ hide AVALON_CONTAINERS on creation.. + if cmds.attributeQuery("hiddenInOutliner", + node=main_container, + exists=True): + cmds.setAttr(main_container + ".hiddenInOutliner", True) + else: + main_container = main_container[0] + + cmds.sets(container, addElement=main_container) + + # Implement #399: Maya 2019+ hide containers in outliner + if cmds.attributeQuery("hiddenInOutliner", + node=container, + exists=True): + cmds.setAttr(container + ".hiddenInOutliner", True) + + return container + + +def on_init(_): + log.info("Running callback on init..") + + def safe_deferred(fn): + """Execute deferred the function in a try-except""" + + def _fn(): + """safely call in deferred callback""" + try: + fn() + except Exception as exc: + print(exc) + + try: + utils.executeDeferred(_fn) + except Exception as exc: + print(exc) + + # Force load Alembic so referenced alembics + # work correctly on scene open + cmds.loadPlugin("AbcImport", quiet=True) + cmds.loadPlugin("AbcExport", quiet=True) + + # Force load objExport plug-in (requested by artists) + cmds.loadPlugin("objExport", quiet=True) + + from .customize import ( + override_component_mask_commands, + override_toolbox_ui + ) + safe_deferred(override_component_mask_commands) + + launch_workfiles = os.environ.get("WORKFILES_STARTUP") + + if launch_workfiles: + safe_deferred(host_tools.show_workfiles) + + if not lib.IS_HEADLESS: + safe_deferred(override_toolbox_ui) + + +def on_before_save(return_code, _): + """Run validation for scene's FPS prior to saving""" + return lib.validate_fps() + + +def on_save(_): + """Automatically add IDs to new nodes + + Any transform of a mesh, without an existing ID, is given one + automatically on file save. + """ + + log.info("Running callback on save..") + + # # Update current task for the current scene + # update_task_from_path(cmds.file(query=True, sceneName=True)) + + # Generate ids of the current context on nodes in the scene + nodes = lib.get_id_required_nodes(referenced_nodes=False) + for node, new_id in lib.generate_ids(nodes): + lib.set_id(node, new_id, overwrite=False) + + +def on_open(_): + """On scene open let's assume the containers have changed.""" + + from Qt import QtWidgets + from openpype.widgets import popup + + cmds.evalDeferred( + "from openpype.hosts.maya.api import lib;" + "lib.remove_render_layer_observer()") + cmds.evalDeferred( + "from openpype.hosts.maya.api import lib;" + "lib.add_render_layer_observer()") + cmds.evalDeferred( + "from openpype.hosts.maya.api import lib;" + "lib.add_render_layer_change_observer()") + # # Update current task for the current scene + # update_task_from_path(cmds.file(query=True, sceneName=True)) + + # Validate FPS after update_task_from_path to + # ensure it is using correct FPS for the asset + lib.validate_fps() + lib.fix_incompatible_containers() + + if any_outdated(): + log.warning("Scene has outdated content.") + + # Find maya main window + top_level_widgets = {w.objectName(): w for w in + QtWidgets.QApplication.topLevelWidgets()} + parent = top_level_widgets.get("MayaWindow", None) + + if parent is None: + log.info("Skipping outdated content pop-up " + "because Maya window can't be found.") + else: + + # Show outdated pop-up + def _on_show_inventory(): + host_tools.show_scene_inventory(parent=parent) + + dialog = popup.Popup(parent=parent) + dialog.setWindowTitle("Maya scene has outdated content") + dialog.setMessage("There are outdated containers in " + "your Maya scene.") + dialog.on_show.connect(_on_show_inventory) + dialog.show() + + +def on_new(_): + """Set project resolution and fps when create a new file""" + log.info("Running callback on new..") + with lib.suspended_refresh(): + cmds.evalDeferred( + "from openpype.hosts.maya.api import lib;" + "lib.remove_render_layer_observer()") + cmds.evalDeferred( + "from openpype.hosts.maya.api import lib;" + "lib.add_render_layer_observer()") + cmds.evalDeferred( + "from openpype.hosts.maya.api import lib;" + "lib.add_render_layer_change_observer()") + lib.set_context_settings() + + +def on_task_changed(*args): + """Wrapped function of app initialize and maya's on task changed""" + # Run + menu.update_menu_task_label() + + workdir = avalon.api.Session["AVALON_WORKDIR"] + if os.path.exists(workdir): + log.info("Updating Maya workspace for task change to %s", workdir) + + _set_project() + + # Set Maya fileDialog's start-dir to /scenes + frule_scene = cmds.workspace(fileRuleEntry="scene") + cmds.optionVar(stringValue=("browserLocationmayaBinaryscene", + workdir + "/" + frule_scene)) + + else: + log.warning(( + "Can't set project for new context because path does not exist: {}" + ).format(workdir)) + + with lib.suspended_refresh(): + lib.set_context_settings() + lib.update_content_on_context_change() + + msg = " project: {}\n asset: {}\n task:{}".format( + avalon.api.Session["AVALON_PROJECT"], + avalon.api.Session["AVALON_ASSET"], + avalon.api.Session["AVALON_TASK"] + ) + + lib.show_message( + "Context was changed", + ("Context was changed to:\n{}".format(msg)), + ) + + +def before_workfile_save(event): + workdir_path = event.workdir_path + if workdir_path: + copy_workspace_mel(workdir_path) + + +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/plugin.py b/openpype/hosts/maya/api/plugin.py index a5f03cd576..64e910627d 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -1,8 +1,14 @@ +import os + +from maya import cmds + from avalon import api from avalon.vendor import qargparse -import avalon.maya from openpype.api import PypeCreatorMixin +from .pipeline import containerise +from . import lib + def get_reference_node(members, log=None): """Get the reference node from the container members @@ -14,8 +20,6 @@ def get_reference_node(members, log=None): """ - from maya import cmds - # Collect the references without .placeHolderList[] attributes as # unique entries (objects only) and skipping the sharedReferenceNode. references = set() @@ -61,8 +65,6 @@ def get_reference_node_parents(ref): list: The upstream parent reference nodes. """ - from maya import cmds - parent = cmds.referenceQuery(ref, referenceNode=True, parent=True) @@ -75,11 +77,25 @@ def get_reference_node_parents(ref): return parents -class Creator(PypeCreatorMixin, avalon.maya.Creator): - pass +class Creator(PypeCreatorMixin, api.Creator): + def process(self): + nodes = list() + + with lib.undo_chunk(): + if (self.options or {}).get("useSelection"): + nodes = cmds.ls(selection=True) + + instance = cmds.sets(nodes, name=self.name) + lib.imprint(instance, self.data) + + return instance -class ReferenceLoader(api.Loader): +class Loader(api.Loader): + hosts = ["maya"] + + +class ReferenceLoader(Loader): """A basic ReferenceLoader for Maya This will implement the basic behavior for a loader to inherit from that @@ -117,11 +133,6 @@ class ReferenceLoader(api.Loader): namespace=None, options=None ): - - import os - from avalon.maya import lib - from avalon.maya.pipeline import containerise - assert os.path.exists(self.fname), "%s does not exist." % self.fname asset = context['asset'] @@ -182,8 +193,6 @@ class ReferenceLoader(api.Loader): def update(self, container, representation): - - import os from maya import cmds node = container["objectName"] diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index 4f826b8fde..1a7c3933a1 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -9,8 +9,10 @@ import six from maya import cmds from avalon import api, io -from avalon.maya.lib import unique_namespace -from openpype.hosts.maya.api.lib import matrix_equals +from openpype.hosts.maya.api.lib import ( + matrix_equals, + unique_namespace +) log = logging.getLogger("PackageLoader") @@ -239,7 +241,7 @@ def get_contained_containers(container): """ import avalon.schema - from avalon.maya.pipeline import parse_container + from .pipeline import parse_container # Get avalon containers in this package setdress container containers = [] diff --git a/openpype/hosts/maya/api/workio.py b/openpype/hosts/maya/api/workio.py new file mode 100644 index 0000000000..698c48e81e --- /dev/null +++ b/openpype/hosts/maya/api/workio.py @@ -0,0 +1,67 @@ +"""Host API required Work Files tool""" +import os +from maya import cmds +from avalon import api + + +def file_extensions(): + return api.HOST_WORKFILE_EXTENSIONS["maya"] + + +def has_unsaved_changes(): + return cmds.file(query=True, modified=True) + + +def save_file(filepath): + cmds.file(rename=filepath) + ext = os.path.splitext(filepath)[1] + if ext == ".mb": + file_type = "mayaBinary" + else: + file_type = "mayaAscii" + cmds.file(save=True, type=file_type) + + +def open_file(filepath): + return cmds.file(filepath, open=True, force=True) + + +def current_file(): + + current_filepath = cmds.file(query=True, sceneName=True) + if not current_filepath: + return None + + return current_filepath + + +def work_root(session): + work_dir = session["AVALON_WORKDIR"] + scene_dir = None + + # Query scene file rule from workspace.mel if it exists in WORKDIR + # We are parsing the workspace.mel manually as opposed to temporarily + # setting the Workspace in Maya in a context manager since Maya had a + # tendency to crash on frequently changing the workspace when this + # function was called many times as one scrolled through Work Files assets. + workspace_mel = os.path.join(work_dir, "workspace.mel") + if os.path.exists(workspace_mel): + scene_rule = 'workspace -fr "scene" ' + # We need to use builtins as `open` is overridden by the workio API + open_file = __builtins__["open"] + with open_file(workspace_mel, "r") as f: + for line in f: + if line.strip().startswith(scene_rule): + # remainder == "rule"; + remainder = line[len(scene_rule):] + # scene_dir == rule + scene_dir = remainder.split('"')[1] + else: + # We can't query a workspace that does not exist + # so we return similar to what we do in other hosts. + scene_dir = session.get("AVALON_SCENEDIR") + + if scene_dir: + return os.path.join(work_dir, scene_dir) + else: + return work_dir diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py index e3cad4cf2e..119edccb7a 100644 --- a/openpype/hosts/maya/plugins/inventory/import_modelrender.py +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -1,4 +1,9 @@ -from avalon import api, io +import json +from avalon import api, io, pipeline +from openpype.hosts.maya.api.lib import ( + maintained_selection, + apply_shaders +) class ImportModelRender(api.InventoryAction): @@ -49,10 +54,8 @@ class ImportModelRender(api.InventoryAction): Returns: None """ - import json + from maya import cmds - from avalon import maya, io, pipeline - from openpype.hosts.maya.api import lib # Get representations of shader file and relationships look_repr = io.find_one({ @@ -77,7 +80,7 @@ class ImportModelRender(api.InventoryAction): json_file = pipeline.get_representation_path_from_context(context) # Import the look file - with maya.maintained_selection(): + with maintained_selection(): shader_nodes = cmds.file(maya_file, i=True, # import returnNewNodes=True) @@ -89,4 +92,4 @@ class ImportModelRender(api.InventoryAction): relationships = json.load(f) # Assign relationships - lib.apply_shaders(relationships, shader_nodes, nodes) + apply_shaders(relationships, shader_nodes, nodes) diff --git a/openpype/hosts/maya/plugins/load/_load_animation.py b/openpype/hosts/maya/plugins/load/_load_animation.py index b1784f1590..bce1f0fc67 100644 --- a/openpype/hosts/maya/plugins/load/_load_animation.py +++ b/openpype/hosts/maya/plugins/load/_load_animation.py @@ -17,7 +17,7 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, data): import maya.cmds as cmds - from avalon import maya + from openpype.hosts.maya.api.lib import unique_namespace cmds.loadPlugin("AbcImport.mll", quiet=True) # Prevent identical alembic nodes from being shared @@ -27,9 +27,11 @@ class AbcLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # Assuming name is subset name from the animation, we split the number # suffix from the name to ensure the namespace is unique name = name.split("_")[0] - namespace = maya.unique_namespace("{}_".format(name), - format="%03d", - suffix="_abc") + namespace = unique_namespace( + "{}_".format(name), + format="%03d", + suffix="_abc" + ) # hero_001 (abc) # asset_counter{optional} diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 1a9adf6142..1cc7ee0c03 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -3,6 +3,10 @@ """ from avalon import api +from openpype.hosts.maya.api.lib import ( + maintained_selection, + unique_namespace +) class SetFrameRangeLoader(api.Loader): @@ -98,22 +102,19 @@ class ImportMayaLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): import maya.cmds as cmds - from avalon import maya - from avalon.maya import lib - choice = self.display_warning() if choice is False: return asset = context['asset'] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset["name"] + "_", prefix="_" if asset["name"][0].isdigit() else "", suffix="_", ) - with maya.maintained_selection(): + with maintained_selection(): cmds.file(self.fname, i=True, preserveReferences=True, diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py index 891f21916c..18b34d2233 100644 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ b/openpype/hosts/maya/plugins/load/load_ass.py @@ -1,9 +1,15 @@ +import os +import clique + from avalon import api +from openpype.api import get_project_settings import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api.plugin import get_reference_node -import os -from openpype.api import get_project_settings -import clique +from openpype.hosts.maya.api.lib import ( + maintained_selection, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -20,7 +26,6 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, options): import maya.cmds as cmds - from avalon import maya import pymel.core as pm version = context['version'] @@ -35,7 +40,7 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): except ValueError: family = "ass" - with maya.maintained_selection(): + with maintained_selection(): groupName = "{}:{}".format(namespace, name) path = self.fname @@ -95,8 +100,6 @@ class AssProxyLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): self.update(container, representation) def update(self, container, representation): - - import os from maya import cmds import pymel.core as pm @@ -175,8 +178,6 @@ class AssStandinLoader(api.Loader): def load(self, context, name, namespace, options): import maya.cmds as cmds - import avalon.maya.lib as lib - from avalon.maya.pipeline import containerise import mtoa.ui.arnoldmenu import pymel.core as pm @@ -188,7 +189,7 @@ class AssStandinLoader(api.Loader): frameStart = version_data.get("frameStart", None) asset = context['asset']['name'] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", diff --git a/openpype/hosts/maya/plugins/load/load_assembly.py b/openpype/hosts/maya/plugins/load/load_assembly.py index 2f0ca3922e..0151da7253 100644 --- a/openpype/hosts/maya/plugins/load/load_assembly.py +++ b/openpype/hosts/maya/plugins/load/load_assembly.py @@ -13,11 +13,11 @@ class AssemblyLoader(api.Loader): def load(self, context, name, namespace, data): - from avalon.maya.pipeline import containerise - from avalon.maya import lib + from openpype.hosts.maya.api.pipeline import containerise + from openpype.hosts.maya.api.lib import unique_namespace asset = context['asset']['name'] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", @@ -25,9 +25,11 @@ class AssemblyLoader(api.Loader): from openpype.hosts.maya.api import setdress - containers = setdress.load_package(filepath=self.fname, - name=name, - namespace=namespace) + containers = setdress.load_package( + filepath=self.fname, + name=name, + namespace=namespace + ) self[:] = containers diff --git a/openpype/hosts/maya/plugins/load/load_audio.py b/openpype/hosts/maya/plugins/load/load_audio.py index 0611dcc189..99f1f7c172 100644 --- a/openpype/hosts/maya/plugins/load/load_audio.py +++ b/openpype/hosts/maya/plugins/load/load_audio.py @@ -1,7 +1,7 @@ -from avalon import api, io -from avalon.maya.pipeline import containerise -from avalon.maya import lib from maya import cmds, mel +from avalon import api, io +from openpype.hosts.maya.api.pipeline import containerise +from openpype.hosts.maya.api.lib import unique_namespace class AudioLoader(api.Loader): @@ -27,7 +27,7 @@ class AudioLoader(api.Loader): ) asset = context["asset"]["name"] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", diff --git a/openpype/hosts/maya/plugins/load/load_gpucache.py b/openpype/hosts/maya/plugins/load/load_gpucache.py index 444f98f22e..2e0b7bb810 100644 --- a/openpype/hosts/maya/plugins/load/load_gpucache.py +++ b/openpype/hosts/maya/plugins/load/load_gpucache.py @@ -17,11 +17,11 @@ class GpuCacheLoader(api.Loader): def load(self, context, name, namespace, data): import maya.cmds as cmds - import avalon.maya.lib as lib - from avalon.maya.pipeline import containerise + from openpype.hosts.maya.api.pipeline import containerise + from openpype.hosts.maya.api.lib import unique_namespace asset = context['asset']['name'] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", diff --git a/openpype/hosts/maya/plugins/load/load_image_plane.py b/openpype/hosts/maya/plugins/load/load_image_plane.py index 0652147324..8e33f51389 100644 --- a/openpype/hosts/maya/plugins/load/load_image_plane.py +++ b/openpype/hosts/maya/plugins/load/load_image_plane.py @@ -1,8 +1,9 @@ -from avalon import api, io -from avalon.maya.pipeline import containerise -from avalon.maya import lib from Qt import QtWidgets, QtCore +from avalon import api, io +from openpype.hosts.maya.api.pipeline import containerise +from openpype.hosts.maya.api.lib import unique_namespace + from maya import cmds @@ -88,7 +89,7 @@ class ImagePlaneLoader(api.Loader): new_nodes = [] image_plane_depth = 1000 asset = context['asset']['name'] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset + "_", prefix="_" if asset[0].isdigit() else "", suffix="_", diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index 8e14778fd2..ef1076f2cb 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -1,13 +1,15 @@ # -*- coding: utf-8 -*- """Look loader.""" -import openpype.hosts.maya.api.plugin -from avalon import api, io import json -import openpype.hosts.maya.api.lib from collections import defaultdict -from openpype.widgets.message_window import ScrollMessageBox + from Qt import QtWidgets +from avalon import api, io +import openpype.hosts.maya.api.plugin +from openpype.hosts.maya.api import lib +from openpype.widgets.message_window import ScrollMessageBox + from openpype.hosts.maya.api.plugin import get_reference_node @@ -36,9 +38,8 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """ import maya.cmds as cmds - from avalon import maya - with maya.maintained_selection(): + with lib.maintained_selection(): nodes = cmds.file(self.fname, namespace=namespace, reference=True, @@ -140,9 +141,7 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): cmds.file(cr=reference_node) # cleanReference # reapply shading groups from json representation on orig nodes - openpype.hosts.maya.api.lib.apply_shaders(json_data, - shader_nodes, - orig_nodes) + lib.apply_shaders(json_data, shader_nodes, orig_nodes) msg = ["During reference update some edits failed.", "All successful edits were kept intact.\n", @@ -159,8 +158,8 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # region compute lookup nodes_by_id = defaultdict(list) for n in nodes: - nodes_by_id[openpype.hosts.maya.api.lib.get_id(n)].append(n) - openpype.hosts.maya.api.lib.apply_attributes(attributes, nodes_by_id) + nodes_by_id[lib.get_id(n)].append(n) + lib.apply_attributes(attributes, nodes_by_id) # Update metadata cmds.setAttr("{}.representation".format(node), diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py index 4c6a187bc3..fd2ae0f1d3 100644 --- a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py @@ -1,11 +1,18 @@ # -*- coding: utf-8 -*- """Loader for Redshift proxy.""" -from avalon.maya import lib +import os +import clique + +import maya.cmds as cmds + from avalon import api from openpype.api import get_project_settings -import os -import maya.cmds as cmds -import clique +from openpype.hosts.maya.api.lib import ( + namespaced, + maintained_selection, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise class RedshiftProxyLoader(api.Loader): @@ -21,17 +28,13 @@ class RedshiftProxyLoader(api.Loader): def load(self, context, name=None, namespace=None, options=None): """Plugin entry point.""" - - from avalon.maya.pipeline import containerise - from openpype.hosts.maya.api.lib import namespaced - try: family = context["representation"]["context"]["family"] except ValueError: family = "redshiftproxy" asset_name = context['asset']["name"] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", @@ -40,7 +43,7 @@ class RedshiftProxyLoader(api.Loader): # Ensure Redshift for Maya is loaded. cmds.loadPlugin("redshift4maya", quiet=True) - with lib.maintained_selection(): + with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): nodes, group_node = self.create_rs_proxy( diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 2cc24f1360..0565b0b95c 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,9 +1,10 @@ -import openpype.hosts.maya.api.plugin -from avalon import api, maya -from maya import cmds import os +from maya import cmds +from avalon import api from openpype.api import get_project_settings from openpype.lib import get_creator_by_name +import openpype.hosts.maya.api.plugin +from openpype.hosts.maya.api.lib import maintained_selection class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): @@ -32,7 +33,6 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def process_reference(self, context, name, namespace, options): import maya.cmds as cmds - from avalon import maya import pymel.core as pm try: @@ -44,7 +44,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # True by default to keep legacy behaviours attach_to_root = options.get("attach_to_root", True) - with maya.maintained_selection(): + with maintained_selection(): cmds.loadPlugin("AbcImport.mll", quiet=True) nodes = cmds.file(self.fname, namespace=namespace, @@ -149,7 +149,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # Create the animation instance creator_plugin = get_creator_by_name(self.animation_creator_name) - with maya.maintained_selection(): + with maintained_selection(): cmds.select([output, controls] + roots, noExpand=True) api.create( creator_plugin, diff --git a/openpype/hosts/maya/plugins/load/load_rendersetup.py b/openpype/hosts/maya/plugins/load/load_rendersetup.py index 574ae9bd3d..efeff2f193 100644 --- a/openpype/hosts/maya/plugins/load/load_rendersetup.py +++ b/openpype/hosts/maya/plugins/load/load_rendersetup.py @@ -11,8 +11,8 @@ import six import sys from avalon import api -from avalon.maya import lib -from openpype.hosts.maya.api import lib as pypelib +from openpype.hosts.maya.api import lib +from openpype.hosts.maya.api.pipeline import containerise from maya import cmds import maya.app.renderSetup.model.renderSetup as renderSetup @@ -31,7 +31,6 @@ class RenderSetupLoader(api.Loader): def load(self, context, name, namespace, data): """Load RenderSetup settings.""" - from avalon.maya.pipeline import containerise # from openpype.hosts.maya.api.lib import namespaced @@ -83,7 +82,7 @@ class RenderSetupLoader(api.Loader): def update(self, container, representation): """Update RenderSetup setting by overwriting existing settings.""" - pypelib.show_message( + lib.show_message( "Render setup update", "Render setup setting will be overwritten by new version. All " "setting specified by user not included in loaded version " diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py b/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py index f5662ba462..3e1d67ae9a 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py @@ -1,7 +1,8 @@ -from avalon import api import os +from avalon import api from openpype.api import get_project_settings + class LoadVDBtoRedShift(api.Loader): """Load OpenVDB in a Redshift Volume Shape""" @@ -15,8 +16,8 @@ class LoadVDBtoRedShift(api.Loader): def load(self, context, name=None, namespace=None, data=None): from maya import cmds - import avalon.maya.lib as lib - from avalon.maya.pipeline import containerise + from openpype.hosts.maya.api.pipeline import containerise + from openpype.hosts.maya.api.lib import unique_namespace try: family = context["representation"]["context"]["family"] @@ -45,7 +46,7 @@ class LoadVDBtoRedShift(api.Loader): asset = context['asset'] asset_name = asset["name"] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py index ed561e1131..099c020093 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_vray.py @@ -1,6 +1,6 @@ +import os from avalon import api from openpype.api import get_project_settings -import os from maya import cmds @@ -80,8 +80,8 @@ class LoadVDBtoVRay(api.Loader): def load(self, context, name, namespace, data): - import avalon.maya.lib as lib - from avalon.maya.pipeline import containerise + from openpype.hosts.maya.api.lib import unique_namespace + from openpype.hosts.maya.api.pipeline import containerise assert os.path.exists(self.fname), ( "Path does not exist: %s" % self.fname @@ -111,7 +111,7 @@ class LoadVDBtoVRay(api.Loader): asset = context['asset'] asset_name = asset["name"] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", diff --git a/openpype/hosts/maya/plugins/load/load_vrayproxy.py b/openpype/hosts/maya/plugins/load/load_vrayproxy.py index 806cf1fd18..ac2fe635b3 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayproxy.py +++ b/openpype/hosts/maya/plugins/load/load_vrayproxy.py @@ -9,9 +9,14 @@ import os import maya.cmds as cmds -from avalon.maya import lib from avalon import api, io from openpype.api import get_project_settings +from openpype.hosts.maya.api.lib import ( + maintained_selection, + namespaced, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise class VRayProxyLoader(api.Loader): @@ -36,8 +41,6 @@ class VRayProxyLoader(api.Loader): options (dict): Optional loader options. """ - from avalon.maya.pipeline import containerise - from openpype.hosts.maya.api.lib import namespaced try: family = context["representation"]["context"]["family"] @@ -48,7 +51,7 @@ class VRayProxyLoader(api.Loader): self.fname = self._get_abc(context["version"]["_id"]) or self.fname asset_name = context['asset']["name"] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", @@ -57,7 +60,7 @@ class VRayProxyLoader(api.Loader): # Ensure V-Ray for Maya is loaded. cmds.loadPlugin("vrayformaya", quiet=True) - with lib.maintained_selection(): + with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): nodes, group_node = self.create_vray_proxy( diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 465dab2a76..2e85514938 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,8 +1,13 @@ -from avalon.maya import lib -from avalon import api -from openpype.api import config import os import maya.cmds as cmds +from avalon import api +from openpype.api import get_project_settings +from openpype.hosts.maya.api.lib import ( + maintained_selection, + namespaced, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise class VRaySceneLoader(api.Loader): @@ -18,8 +23,6 @@ class VRaySceneLoader(api.Loader): def load(self, context, name, namespace, data): - from avalon.maya.pipeline import containerise - from openpype.hosts.maya.lib import namespaced try: family = context["representation"]["context"]["family"] @@ -27,7 +30,7 @@ class VRaySceneLoader(api.Loader): family = "vrayscene_layer" asset_name = context['asset']["name"] - namespace = namespace or lib.unique_namespace( + namespace = namespace or unique_namespace( asset_name + "_", prefix="_" if asset_name[0].isdigit() else "", suffix="_", @@ -36,7 +39,7 @@ class VRaySceneLoader(api.Loader): # Ensure V-Ray for Maya is loaded. cmds.loadPlugin("vrayformaya", quiet=True) - with lib.maintained_selection(): + with maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): nodes, group_node = self.create_vray_scene(name, @@ -47,8 +50,8 @@ class VRaySceneLoader(api.Loader): return # colour the group node - presets = config.get_presets(project=os.environ['AVALON_PROJECT']) - colors = presets['plugins']['maya']['load']['colors'] + presets = get_project_settings(os.environ['AVALON_PROJECT']) + colors = presets['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index de0ea6823c..dfe75173ac 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -3,14 +3,14 @@ import json import re import glob from collections import defaultdict +from pprint import pprint from maya import cmds from avalon import api, io -from avalon.maya import lib as avalon_lib, pipeline -from openpype.hosts.maya.api import lib from openpype.api import get_project_settings -from pprint import pprint +from openpype.hosts.maya.api import lib +from openpype.hosts.maya.api.pipeline import containerise class YetiCacheLoader(api.Loader): @@ -75,11 +75,13 @@ class YetiCacheLoader(api.Loader): self[:] = nodes - return pipeline.containerise(name=name, - namespace=namespace, - nodes=nodes, - context=context, - loader=self.__class__.__name__) + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__ + ) def remove(self, container): @@ -239,9 +241,11 @@ class YetiCacheLoader(api.Loader): asset_name = "{}_".format(asset) prefix = "_" if asset_name[0].isdigit()else "" - namespace = avalon_lib.unique_namespace(asset_name, - prefix=prefix, - suffix="_") + namespace = lib.unique_namespace( + asset_name, + prefix=prefix, + suffix="_" + ) return namespace diff --git a/openpype/hosts/maya/plugins/load/load_yeti_rig.py b/openpype/hosts/maya/plugins/load/load_yeti_rig.py index 3f67f98f51..b4d31b473f 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_rig.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_rig.py @@ -25,7 +25,6 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): self, context, name=None, namespace=None, options=None): import maya.cmds as cmds - from avalon import maya # get roots of selected hierarchies selected_roots = [] @@ -53,7 +52,7 @@ class YetiRigLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): scene_lookup[cb_id] = node # load rig - with maya.maintained_selection(): + with lib.maintained_selection(): nodes = cmds.file(self.fname, namespace=namespace, reference=True, diff --git a/openpype/hosts/maya/plugins/publish/collect_assembly.py b/openpype/hosts/maya/plugins/publish/collect_assembly.py index 313636793b..1a65bf1fde 100644 --- a/openpype/hosts/maya/plugins/publish/collect_assembly.py +++ b/openpype/hosts/maya/plugins/publish/collect_assembly.py @@ -2,7 +2,7 @@ from collections import defaultdict import pyblish.api from maya import cmds, mel -from avalon import maya as avalon +from openpype.hosts.maya import api from openpype.hosts.maya.api import lib # TODO : Publish of assembly: -unique namespace for all assets, VALIDATOR! @@ -30,7 +30,7 @@ class CollectAssembly(pyblish.api.InstancePlugin): def process(self, instance): # Find containers - containers = avalon.ls() + containers = api.ls() # Get all content from the instance instance_lookup = set(cmds.ls(instance, type="transform", long=True)) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index cbddb86e53..13ae1924b9 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -49,7 +49,7 @@ import maya.app.renderSetup.model.renderSetup as renderSetup import pyblish.api -from avalon import maya, api +from avalon import api from openpype.hosts.maya.api.lib_renderproducts import get as get_layer_render_products # noqa: E501 from openpype.hosts.maya.api import lib @@ -409,7 +409,7 @@ class CollectMayaRender(pyblish.api.ContextPlugin): dict: only overrides with values """ - attributes = maya.read(render_globals) + attributes = lib.read(render_globals) options = {"renderGlobals": {}} options["renderGlobals"]["Priority"] = attributes["priority"] diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 7ecc40a68d..269972d996 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -2,9 +2,12 @@ import os from maya import cmds -import avalon.maya import openpype.api -from openpype.hosts.maya.api.lib import extract_alembic +from openpype.hosts.maya.api.lib import ( + extract_alembic, + suspended_refresh, + maintained_selection +) class ExtractAnimation(openpype.api.Extractor): @@ -71,8 +74,8 @@ class ExtractAnimation(openpype.api.Extractor): # Since Maya 2017 alembic supports multiple uv sets - write them. options["writeUVSets"] = True - with avalon.maya.suspended_refresh(): - with avalon.maya.maintained_selection(): + with suspended_refresh(): + with maintained_selection(): cmds.select(nodes, noExpand=True) extract_alembic(file=path, startFrame=float(start), diff --git a/openpype/hosts/maya/plugins/publish/extract_ass.py b/openpype/hosts/maya/plugins/publish/extract_ass.py index 7461ccdf78..ab149de700 100644 --- a/openpype/hosts/maya/plugins/publish/extract_ass.py +++ b/openpype/hosts/maya/plugins/publish/extract_ass.py @@ -1,9 +1,9 @@ import os -import avalon.maya import openpype.api from maya import cmds +from openpype.hosts.maya.api.lib import maintained_selection class ExtractAssStandin(openpype.api.Extractor): @@ -30,7 +30,7 @@ class ExtractAssStandin(openpype.api.Extractor): # Write out .ass file self.log.info("Writing: '%s'" % file_path) - with avalon.maya.maintained_selection(): + with maintained_selection(): self.log.info("Writing: {}".format(instance.data["setMembers"])) cmds.select(instance.data["setMembers"], noExpand=True) diff --git a/openpype/hosts/maya/plugins/publish/extract_assproxy.py b/openpype/hosts/maya/plugins/publish/extract_assproxy.py index f53f385b8b..93720dbb82 100644 --- a/openpype/hosts/maya/plugins/publish/extract_assproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_assproxy.py @@ -1,10 +1,10 @@ import os - -from maya import cmds import contextlib -import avalon.maya +from maya import cmds + import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection class ExtractAssProxy(openpype.api.Extractor): @@ -54,7 +54,7 @@ class ExtractAssProxy(openpype.api.Extractor): noIntermediate=True) self.log.info(members) - with avalon.maya.maintained_selection(): + with maintained_selection(): with unparent(members[0]): cmds.select(members, noExpand=True) cmds.file(path, diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index 8950ed6254..806a079940 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -2,9 +2,7 @@ import os from maya import cmds -import avalon.maya import openpype.api - from openpype.hosts.maya.api import lib @@ -54,7 +52,7 @@ class ExtractCameraAlembic(openpype.api.Extractor): path = os.path.join(dir_path, filename) # Perform alembic extraction - with avalon.maya.maintained_selection(): + with lib.maintained_selection(): cmds.select(camera, replace=True, noExpand=True) # Enforce forward slashes for AbcExport because we're @@ -86,7 +84,7 @@ class ExtractCameraAlembic(openpype.api.Extractor): job_str += " -attr {0}".format(attr) with lib.evaluation("off"): - with avalon.maya.suspended_refresh(): + with lib.suspended_refresh(): cmds.AbcExport(j=job_str, verbose=False) if "representations" not in instance.data: diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 888dc636b2..9d25b147de 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -5,7 +5,6 @@ import itertools from maya import cmds -import avalon.maya import openpype.api from openpype.hosts.maya.api import lib @@ -157,9 +156,9 @@ class ExtractCameraMayaScene(openpype.api.Extractor): path = os.path.join(dir_path, filename) # Perform extraction - with avalon.maya.maintained_selection(): + with lib.maintained_selection(): with lib.evaluation("off"): - with avalon.maya.suspended_refresh(): + with lib.suspended_refresh(): if bake_to_worldspace: self.log.info( "Performing camera bakes: {}".format(transform)) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index e4894f28cd..844084b9ab 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -3,12 +3,12 @@ import os from maya import cmds # noqa import maya.mel as mel # noqa -from openpype.hosts.maya.api.lib import root_parent - import pyblish.api -import avalon.maya - import openpype.api +from openpype.hosts.maya.api.lib import ( + root_parent, + maintained_selection +) class ExtractFBX(openpype.api.Extractor): @@ -205,13 +205,13 @@ class ExtractFBX(openpype.api.Extractor): # Export if "unrealStaticMesh" in instance.data["families"]: - with avalon.maya.maintained_selection(): + with maintained_selection(): with root_parent(members): self.log.info("Un-parenting: {}".format(members)) cmds.select(members, r=1, noExpand=True) mel.eval('FBXExport -f "{}" -s'.format(path)) else: - with avalon.maya.maintained_selection(): + with maintained_selection(): cmds.select(members, r=1, noExpand=True) mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index bf79ddbf44..fe89038a24 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -11,8 +11,7 @@ from collections import OrderedDict from maya import cmds # noqa import pyblish.api -import avalon.maya -from avalon import io, api +from avalon import io import openpype.api from openpype.hosts.maya.api import lib @@ -239,7 +238,7 @@ class ExtractLook(openpype.api.Extractor): # getting incorrectly remapped. (LKD-17, PLN-101) with no_workspace_dir(): with lib.attribute_values(remap): - with avalon.maya.maintained_selection(): + with lib.maintained_selection(): cmds.select(sets, noExpand=True) cmds.file( maya_path, diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index e7fb5bc8cb..9c432cbc67 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -4,8 +4,8 @@ import os from maya import cmds -import avalon.maya import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection class ExtractMayaSceneRaw(openpype.api.Extractor): @@ -59,7 +59,7 @@ class ExtractMayaSceneRaw(openpype.api.Extractor): # Perform extraction self.log.info("Performing extraction ...") - with avalon.maya.maintained_selection(): + with maintained_selection(): cmds.select(members, noExpand=True) cmds.file(path, force=True, diff --git a/openpype/hosts/maya/plugins/publish/extract_model.py b/openpype/hosts/maya/plugins/publish/extract_model.py index 40cc9427f3..0282d1e9c8 100644 --- a/openpype/hosts/maya/plugins/publish/extract_model.py +++ b/openpype/hosts/maya/plugins/publish/extract_model.py @@ -4,7 +4,6 @@ import os from maya import cmds -import avalon.maya import openpype.api from openpype.hosts.maya.api import lib @@ -74,7 +73,7 @@ class ExtractModel(openpype.api.Extractor): polygonObject=1): with lib.shader(members, shadingEngine="initialShadingGroup"): - with avalon.maya.maintained_selection(): + with lib.maintained_selection(): cmds.select(members, noExpand=True) cmds.file(path, force=True, diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index 630cc39398..60502fdde1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -2,9 +2,12 @@ import os from maya import cmds -import avalon.maya import openpype.api -from openpype.hosts.maya.api.lib import extract_alembic +from openpype.hosts.maya.api.lib import ( + extract_alembic, + suspended_refresh, + maintained_selection +) class ExtractAlembic(openpype.api.Extractor): @@ -70,8 +73,8 @@ class ExtractAlembic(openpype.api.Extractor): # Since Maya 2017 alembic supports multiple uv sets - write them. options["writeUVSets"] = True - with avalon.maya.suspended_refresh(): - with avalon.maya.maintained_selection(): + with suspended_refresh(): + with maintained_selection(): cmds.select(nodes, noExpand=True) extract_alembic(file=path, startFrame=start, diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 7c9e201986..23cac9190d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -2,11 +2,11 @@ """Redshift Proxy extractor.""" import os -import avalon.maya -import openpype.api - from maya import cmds +import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection + class ExtractRedshiftProxy(openpype.api.Extractor): """Extract the content of the instance to a redshift proxy file.""" @@ -54,7 +54,7 @@ class ExtractRedshiftProxy(openpype.api.Extractor): # Write out rs file self.log.info("Writing: '%s'" % file_path) - with avalon.maya.maintained_selection(): + with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.file(file_path, pr=False, diff --git a/openpype/hosts/maya/plugins/publish/extract_rig.py b/openpype/hosts/maya/plugins/publish/extract_rig.py index b28b60114e..53c1eeb671 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig.py @@ -4,8 +4,8 @@ import os from maya import cmds -import avalon.maya import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection class ExtractRig(openpype.api.Extractor): @@ -40,7 +40,7 @@ class ExtractRig(openpype.api.Extractor): # Perform extraction self.log.info("Performing extraction ...") - with avalon.maya.maintained_selection(): + with maintained_selection(): cmds.select(instance, noExpand=True) cmds.file(path, force=True, diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index 7103601b85..615bc27878 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -1,10 +1,10 @@ import os -import avalon.maya -import openpype.api - from maya import cmds +import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection + class ExtractVRayProxy(openpype.api.Extractor): """Extract the content of the instance to a vrmesh file @@ -41,7 +41,7 @@ class ExtractVRayProxy(openpype.api.Extractor): # Write out vrmesh file self.log.info("Writing: '%s'" % file_path) - with avalon.maya.maintained_selection(): + with maintained_selection(): cmds.select(instance.data["setMembers"], noExpand=True) cmds.vrayCreateProxy(exportType=1, dir=staging_dir, diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index 1d7c0fa717..5d41697e5f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -3,9 +3,9 @@ import os import re -import avalon.maya import openpype.api from openpype.hosts.maya.api.render_setup_tools import export_in_rs_layer +from openpype.hosts.maya.api.lib import maintained_selection from maya import cmds @@ -57,7 +57,7 @@ class ExtractVrayscene(openpype.api.Extractor): # Write out vrscene file self.log.info("Writing: '%s'" % file_path) - with avalon.maya.maintained_selection(): + with maintained_selection(): if "*" not in instance.data["setMembers"]: self.log.info( "Exporting: {}".format(instance.data["setMembers"])) diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py index d69911c404..5728682abe 100644 --- a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py @@ -2,8 +2,11 @@ import os from maya import cmds -import avalon.maya import openpype.api +from openpype.hosts.maya.api.lib import ( + suspended_refresh, + maintained_selection +) class ExtractXgenCache(openpype.api.Extractor): @@ -32,8 +35,8 @@ class ExtractXgenCache(openpype.api.Extractor): filename = "{name}.abc".format(**instance.data) path = os.path.join(parent_dir, filename) - with avalon.maya.suspended_refresh(): - with avalon.maya.maintained_selection(): + with suspended_refresh(): + with maintained_selection(): command = ( '-file ' + path diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py index 56d5dfe901..d12567a55a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py @@ -7,9 +7,8 @@ import contextlib from maya import cmds -import avalon.maya.lib as lib import openpype.api -import openpype.hosts.maya.api.lib as maya +from openpype.hosts.maya.api import lib @contextlib.contextmanager diff --git a/openpype/hosts/maya/plugins/publish/validate_cycle_error.py b/openpype/hosts/maya/plugins/publish/validate_cycle_error.py index d4faf2e562..4dfe0b8add 100644 --- a/openpype/hosts/maya/plugins/publish/validate_cycle_error.py +++ b/openpype/hosts/maya/plugins/publish/validate_cycle_error.py @@ -2,10 +2,9 @@ from maya import cmds import pyblish.api -from avalon import maya - import openpype.api import openpype.hosts.maya.api.action +from openpype.hosts.maya.api.lib import maintained_selection class ValidateCycleError(pyblish.api.InstancePlugin): @@ -26,7 +25,7 @@ class ValidateCycleError(pyblish.api.InstancePlugin): @classmethod def get_invalid(cls, instance): - with maya.maintained_selection(): + with maintained_selection(): cmds.select(instance[:], noExpand=True) plugs = cmds.cycleCheck(all=False, # check selection only list=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py index 6b3f508561..90eb01aa12 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -3,7 +3,7 @@ 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.lib import maintained_selection class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin): @@ -67,7 +67,7 @@ class ValidateMeshArnoldAttributes(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - with maya.maintained_selection(): + with maintained_selection(): with pc.UndoChunk(): temp_transform = pc.polyCube()[0] diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py index 839aab0d0b..ab0beb2a9c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py @@ -3,7 +3,6 @@ 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 diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 6b5c5d1398..343eaccb7d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -5,8 +5,6 @@ import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib -from avalon.maya import maintained_selection - class ValidateShapeZero(pyblish.api.Validator): """Shape components may not have any "tweak" values @@ -51,7 +49,7 @@ class ValidateShapeZero(pyblish.api.Validator): if not invalid_shapes: return - with maintained_selection(): + with lib.maintained_selection(): with lib.tool("selectSuperContext"): for shape in invalid_shapes: cmds.polyCollapseTweaks(shape) diff --git a/openpype/hosts/maya/startup/userSetup.py b/openpype/hosts/maya/startup/userSetup.py index 6d27c66882..b89244817a 100644 --- a/openpype/hosts/maya/startup/userSetup.py +++ b/openpype/hosts/maya/startup/userSetup.py @@ -1,8 +1,12 @@ import os +import avalon.api from openpype.api import get_project_settings +from openpype.hosts.maya import api import openpype.hosts.maya.api.lib as mlib from maya import cmds +avalon.api.install(api) + print("starting OpenPype usersetup") diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 707cd476c5..cb0a11789b 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -10,6 +10,7 @@ import avalon.api from openpype.api import Logger from openpype.tools.utils import host_tools from openpype.lib.remote_publish import headless_publish +from openpype.lib import env_value_to_bool from .launch_logic import ProcessLauncher, stub @@ -34,20 +35,19 @@ def main(*subprocess_args): launcher = ProcessLauncher(subprocess_args) launcher.start() - if os.environ.get("HEADLESS_PUBLISH"): + if env_value_to_bool("HEADLESS_PUBLISH"): launcher.execute_in_main_thread( headless_publish, log, "ClosePS", os.environ.get("IS_TEST") ) - elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): - save = False - if os.getenv("WORKFILES_SAVE_AS"): - save = True + elif env_value_to_bool("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", + default=True): launcher.execute_in_main_thread( - host_tools.show_workfiles, save=save + host_tools.show_workfiles, + save=env_value_to_bool("WORKFILES_SAVE_AS") ) sys.exit(app.exec_()) diff --git a/openpype/hosts/testhost/plugins/create/auto_creator.py b/openpype/hosts/testhost/plugins/create/auto_creator.py index 0690164ae5..45c573e487 100644 --- a/openpype/hosts/testhost/plugins/create/auto_creator.py +++ b/openpype/hosts/testhost/plugins/create/auto_creator.py @@ -11,7 +11,7 @@ class MyAutoCreator(AutoCreator): identifier = "workfile" family = "workfile" - def get_attribute_defs(self): + def get_instance_attr_defs(self): output = [ lib.NumberDef("number_key", label="Number") ] diff --git a/openpype/hosts/testhost/plugins/create/test_creator_1.py b/openpype/hosts/testhost/plugins/create/test_creator_1.py index 6ec4d16467..45c30e8a27 100644 --- a/openpype/hosts/testhost/plugins/create/test_creator_1.py +++ b/openpype/hosts/testhost/plugins/create/test_creator_1.py @@ -1,3 +1,4 @@ +import json from openpype import resources from openpype.hosts.testhost.api import pipeline from openpype.pipeline import ( @@ -13,6 +14,8 @@ class TestCreatorOne(Creator): family = "test" description = "Testing creator of testhost" + create_allow_context_change = False + def get_icon(self): return resources.get_openpype_splash_filepath() @@ -33,7 +36,10 @@ class TestCreatorOne(Creator): for instance in instances: self._remove_instance_from_context(instance) - def create(self, subset_name, data, options=None): + def create(self, subset_name, data, pre_create_data): + print("Data that can be used in create:\n{}".format( + json.dumps(pre_create_data, indent=4) + )) new_instance = CreatedInstance(self.family, subset_name, data, self) pipeline.HostContext.add_instance(new_instance.data_to_store()) self.log.info(new_instance.data) @@ -46,9 +52,21 @@ class TestCreatorOne(Creator): "different_variant" ] - def get_attribute_defs(self): + def get_instance_attr_defs(self): output = [ - lib.NumberDef("number_key", label="Number") + lib.NumberDef("number_key", label="Number"), + ] + return output + + def get_pre_create_attr_defs(self): + output = [ + lib.BoolDef("use_selection", label="Use selection"), + lib.UISeparatorDef(), + lib.UILabelDef("Testing label"), + lib.FileDef("filepath", folders=True, label="Filepath"), + lib.FileDef( + "filepath_2", multipath=True, folders=True, label="Filepath 2" + ) ] return output diff --git a/openpype/hosts/testhost/plugins/create/test_creator_2.py b/openpype/hosts/testhost/plugins/create/test_creator_2.py index 4b1430a6a2..e66304a038 100644 --- a/openpype/hosts/testhost/plugins/create/test_creator_2.py +++ b/openpype/hosts/testhost/plugins/create/test_creator_2.py @@ -15,7 +15,7 @@ class TestCreatorTwo(Creator): def get_icon(self): return "cube" - def create(self, subset_name, data, options=None): + def create(self, subset_name, data, pre_create_data): new_instance = CreatedInstance(self.family, subset_name, data, self) pipeline.HostContext.add_instance(new_instance.data_to_store()) self.log.info(new_instance.data) @@ -38,7 +38,7 @@ class TestCreatorTwo(Creator): for instance in instances: self._remove_instance_from_context(instance) - def get_attribute_defs(self): + def get_instance_attr_defs(self): output = [ lib.NumberDef("number_key"), lib.TextDef("text_key") diff --git a/openpype/hosts/testhost/plugins/publish/collect_context.py b/openpype/hosts/testhost/plugins/publish/collect_context.py index 0ab98fb84b..bbb8477cdf 100644 --- a/openpype/hosts/testhost/plugins/publish/collect_context.py +++ b/openpype/hosts/testhost/plugins/publish/collect_context.py @@ -19,7 +19,7 @@ class CollectContextDataTestHost( hosts = ["testhost"] @classmethod - def get_attribute_defs(cls): + def get_instance_attr_defs(cls): return [ attribute_definitions.BoolDef( "test_bool", diff --git a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py index 3c035eccb6..979ab83f11 100644 --- a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py +++ b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py @@ -20,7 +20,7 @@ class CollectInstanceOneTestHost( hosts = ["testhost"] @classmethod - def get_attribute_defs(cls): + def get_instance_attr_defs(cls): return [ attribute_definitions.NumberDef( "version", diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py b/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py index 062c5ce0da..ca14538d7d 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_batch_data.py @@ -59,6 +59,7 @@ class CollectBatchData(pyblish.api.ContextPlugin): context.data["asset"] = asset_name context.data["task"] = task_name context.data["taskType"] = task_type + context.data["project_name"] = project_name self._set_ctx_path(batch_data) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index c1b1d66cb8..abad14106f 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -13,8 +13,10 @@ import tempfile from avalon import io import pyblish.api from openpype.lib import prepare_template_data -from openpype.lib.plugin_tools import parse_json - +from openpype.lib.plugin_tools import ( + parse_json, + get_subset_name_with_asset_doc +) class CollectPublishedFiles(pyblish.api.ContextPlugin): """ @@ -34,7 +36,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): targets = ["filespublish"] # from Settings - task_type_to_family = {} + task_type_to_family = [] def process(self, context): batch_dir = context.data["batchDir"] @@ -47,8 +49,13 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): self.log.info("task_sub:: {}".format(task_subfolders)) asset_name = context.data["asset"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) task_name = context.data["task"] task_type = context.data["taskType"] + project_name = context.data["project_name"] for task_dir in task_subfolders: task_data = parse_json(os.path.join(task_dir, "manifest.json")) @@ -57,20 +64,21 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): is_sequence = len(task_data["files"]) > 1 _, extension = os.path.splitext(task_data["files"][0]) - family, families, subset_template, tags = self._get_family( + family, families, tags = self._get_family( self.task_type_to_family, task_type, is_sequence, extension.replace(".", '')) - subset = self._get_subset_name( - family, subset_template, task_name, task_data["variant"] + subset_name = get_subset_name_with_asset_doc( + family, task_data["variant"], task_name, asset_doc, + project_name=project_name, host_name="webpublisher" ) - version = self._get_last_version(asset_name, subset) + 1 + version = self._get_last_version(asset_name, subset_name) + 1 - instance = context.create_instance(subset) + instance = context.create_instance(subset_name) instance.data["asset"] = asset_name - instance.data["subset"] = subset + instance.data["subset"] = subset_name instance.data["family"] = family instance.data["families"] = families instance.data["version"] = version @@ -149,7 +157,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): extension (str): without '.' Returns: - (family, [families], subset_template_name, tags) tuple + (family, [families], tags) tuple AssertionError if not matching family found """ task_type = task_type.lower() @@ -160,12 +168,21 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): assert task_obj, "No family configuration for '{}'".format(task_type) found_family = None - for family, content in task_obj.items(): - if is_sequence != content["is_sequence"]: + families_config = [] + # backward compatibility, should be removed pretty soon + if isinstance(task_obj, dict): + for family, config in task_obj: + config["result_family"] = family + families_config.append(config) + else: + families_config = task_obj + + for config in families_config: + if is_sequence != config["is_sequence"]: continue - if extension in content["extensions"] or \ - '' in content["extensions"]: # all extensions setting - found_family = family + if (extension in config["extensions"] or + '' in config["extensions"]): # all extensions setting + found_family = config["result_family"] break msg = "No family found for combination of " +\ @@ -173,10 +190,9 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): task_type, is_sequence, extension) assert found_family, msg - return found_family, \ - content["families"], \ - content["subset_template_name"], \ - content["tags"] + return (found_family, + config["families"], + config["tags"]) def _get_last_version(self, asset_name, subset_name): """Returns version number or 0 for 'asset' and 'subset'""" diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index e2d041b512..1f9089aa27 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -359,12 +359,19 @@ class ConfiguredExtensionsEndpoint(_RestApiEndpoint): "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"]) + configs = collect_conf.get("task_type_to_family", []) + mappings = [] + for _, conf_mappings in configs.items(): + if isinstance(conf_mappings, dict): + conf_mappings = conf_mappings.values() + for conf_mapping in conf_mappings: + mappings.append(conf_mapping) + + for mapping in mappings: + if mapping["is_sequence"]: + configured["sequence_exts"].update(mapping["extensions"]) + else: + configured["file_exts"].update(mapping["extensions"]) return Response( status=200, diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 7dd9a8793b..ebe7648ad7 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -84,6 +84,7 @@ from .avalon_context import ( get_hierarchy, get_linked_assets, get_latest_version, + get_system_general_anatomy_data, get_workfile_template_key, get_workfile_template_key_from_context, @@ -222,6 +223,7 @@ __all__ = [ "get_hierarchy", "get_linked_assets", "get_latest_version", + "get_system_general_anatomy_data", "get_workfile_template_key", "get_workfile_template_key_from_context", diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index d9c8a0993d..3839aad45d 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -76,6 +76,7 @@ class RenderInstance(object): deadlineSubmissionJob = attr.ib(default=None) anatomyData = attr.ib(default=None) outputDir = attr.ib(default=None) + context = attr.ib(default=None) @frameStart.validator def check_frame_start(self, _, value): diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 88f333a2af..ad21712aa3 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1081,10 +1081,19 @@ class ApplicationLaunchContext: # Prepare data that will be passed to midprocess # - store arguments to a json and pass path to json as last argument # - pass environments to set + app_env = self.kwargs.pop("env", {}) json_data = { "args": self.launch_args, - "env": self.kwargs.pop("env", {}) + "env": app_env } + if app_env: + # Filter environments of subprocess + self.kwargs["env"] = { + key: value + for key, value in os.environ.items() + if key in app_env + } + # Create temp file json_temp = tempfile.NamedTemporaryFile( mode="w", prefix="op_app_args", suffix=".json", delete=False diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 1254580657..3ce205c499 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -9,7 +9,10 @@ import collections import functools import getpass -from openpype.settings import get_project_settings +from openpype.settings import ( + get_project_settings, + get_system_settings +) from .anatomy import Anatomy from .profiles_filtering import filter_profiles @@ -258,6 +261,18 @@ def get_hierarchy(asset_name=None): return "/".join(hierarchy_items) +def get_system_general_anatomy_data(): + system_settings = get_system_settings() + studio_name = system_settings["general"]["studio_name"] + studio_code = system_settings["general"]["studio_code"] + return { + "studio": { + "name": studio_name, + "code": studio_code + } + } + + def get_linked_asset_ids(asset_doc): """Return linked asset ids for `asset_doc` from DB @@ -536,6 +551,10 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name): "user": getpass.getuser(), "hierarchy": hierarchy, } + + system_general_data = get_system_general_anatomy_data() + data.update(system_general_data) + return data @@ -1505,7 +1524,7 @@ def _get_task_context_data_for_anatomy( "requested task type: `{}`".format(task_type) ) - return { + data = { "project": { "name": project_doc["name"], "code": project_doc["data"].get("code") @@ -1518,6 +1537,11 @@ def _get_task_context_data_for_anatomy( } } + system_general_data = get_system_general_anatomy_data() + data.update(system_general_data) + + return data + def get_custom_workfile_template_by_context( template_profiles, project_doc, asset_doc, task_name, anatomy=None diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 3d587e2f29..36f6858a78 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -4,12 +4,35 @@ import logging import collections import tempfile +import xml.etree.ElementTree + from .execute import run_subprocess from .vendor_bin_utils import ( get_oiio_tools_path, is_oiio_supported ) +# Max length of string that is supported by ffmpeg +MAX_FFMPEG_STRING_LEN = 8196 +# OIIO known xml tags +STRING_TAGS = { + "format" +} +INT_TAGS = { + "x", "y", "z", + "width", "height", "depth", + "full_x", "full_y", "full_z", + "full_width", "full_height", "full_depth", + "tile_width", "tile_height", "tile_depth", + "nchannels", + "alpha_channel", + "z_channel", + "deep", + "subimages", +} +# Regex to parse array attributes +ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") + def get_transcode_temp_directory(): """Creates temporary folder for transcoding. @@ -24,87 +47,215 @@ def get_transcode_temp_directory(): def get_oiio_info_for_input(filepath, logger=None): - """Call oiiotool to get information about input and return stdout.""" - args = [ - get_oiio_tools_path(), "--info", "-v", filepath - ] - return run_subprocess(args, logger=logger) + """Call oiiotool to get information about input and return stdout. - -def parse_oiio_info(oiio_info): - """Create an object based on output from oiiotool. - - Removes quotation marks from compression value. Parse channels into - dictionary - key is channel name value is determined type of channel - (e.g. 'uint', 'float'). - - Args: - oiio_info (str): Output of calling "oiiotool --info -v " - - Returns: - dict: Loaded data from output. + Stdout should contain xml format string. """ - lines = [ - line.strip() - for line in oiio_info.split("\n") + args = [ + get_oiio_tools_path(), "--info", "-v", "-i:infoformat=xml", filepath ] - # Each line should contain information about one key - # key - value are separated with ": " - oiio_sep = ": " - data_map = {} - for line in lines: - parts = line.split(oiio_sep) - if len(parts) < 2: + output = run_subprocess(args, logger=logger) + output = output.replace("\r\n", "\n") + + xml_started = False + lines = [] + for line in output.split("\n"): + if not xml_started: + if not line.startswith("<"): + continue + xml_started = True + if xml_started: + lines.append(line) + + if not xml_started: + raise ValueError( + "Failed to read input file \"{}\".\nOutput:\n{}".format( + filepath, output + ) + ) + + xml_text = "\n".join(lines) + return parse_oiio_xml_output(xml_text, logger=logger) + + +class RationalToInt: + """Rational value stored as division of 2 integers using string.""" + def __init__(self, string_value): + parts = string_value.split("/") + top = float(parts[0]) + bottom = 1.0 + if len(parts) != 1: + bottom = float(parts[1]) + + self._value = top / bottom + self._string_value = string_value + + @property + def value(self): + return self._value + + @property + def string_value(self): + return self._string_value + + def __format__(self, *args, **kwargs): + return self._string_value.__format__(*args, **kwargs) + + def __float__(self): + return self._value + + def __str__(self): + return self._string_value + + def __repr__(self): + return "<{}> {}".format(self.__class__.__name__, self._string_value) + + +def convert_value_by_type_name(value_type, value, logger=None): + """Convert value to proper type based on type name. + + In some cases value types have custom python class. + """ + if logger is None: + logger = logging.getLogger(__name__) + + # Simple types + if value_type == "string": + return value + + if value_type == "int": + return int(value) + + if value_type == "float": + return float(value) + + # Vectors will probably have more types + if value_type == "vec2f": + return [float(item) for item in value.split(",")] + + # Matrix should be always have square size of element 3x3, 4x4 + # - are returned as list of lists + if value_type == "matrix": + output = [] + current_index = -1 + parts = value.split(",") + parts_len = len(parts) + if parts_len == 1: + divisor = 1 + elif parts_len == 4: + divisor = 2 + elif parts_len == 9: + divisor == 3 + elif parts_len == 16: + divisor = 4 + else: + logger.info("Unknown matrix resolution {}. Value: \"{}\"".format( + parts_len, value + )) + for part in parts: + output.append(float(part)) + return output + + for idx, item in enumerate(parts): + list_index = idx % divisor + if list_index > current_index: + current_index = list_index + output.append([]) + output[list_index].append(float(item)) + return output + + if value_type == "rational2i": + return RationalToInt(value) + + # Array of other types is converted to list + re_result = ARRAY_TYPE_REGEX.findall(value_type) + if re_result: + array_type = re_result[0] + output = [] + for item in value.split(","): + output.append( + convert_value_by_type_name(array_type, item, logger=logger) + ) + return output + + logger.info(( + "MISSING IMPLEMENTATION:" + " Unknown attrib type \"{}\". Value: {}" + ).format(value_type, value)) + return value + + +def parse_oiio_xml_output(xml_string, logger=None): + """Parse xml output from OIIO info command.""" + output = {} + if not xml_string: + return output + + if logger is None: + logger = logging.getLogger("OIIO-xml-parse") + + tree = xml.etree.ElementTree.fromstring(xml_string) + attribs = {} + output["attribs"] = attribs + for child in tree: + tag_name = child.tag + if tag_name == "attrib": + attrib_def = child.attrib + value = convert_value_by_type_name( + attrib_def["type"], child.text, logger=logger + ) + + attribs[attrib_def["name"]] = value continue - key = parts.pop(0) - value = oiio_sep.join(parts) - data_map[key] = value - if "compression" in data_map: - value = data_map["compression"] - data_map["compression"] = value.replace("\"", "") + # Channels are stored as tex on each child + if tag_name == "channelnames": + value = [] + for channel in child: + value.append(channel.text) - channels_info = {} - channels_value = data_map.get("channel list") or "" - if channels_value: - channels = channels_value.split(", ") - type_regex = re.compile(r"(?P[^\(]+) \((?P[^\)]+)\)") - for channel in channels: - match = type_regex.search(channel) - if not match: - channel_name = channel - channel_type = "uint" - else: - channel_name = match.group("name") - channel_type = match.group("type") - channels_info[channel_name] = channel_type - data_map["channels_info"] = channels_info - return data_map + # Convert known integer type tags to int + elif tag_name in INT_TAGS: + value = int(child.text) + + # Keep value of known string tags + elif tag_name in STRING_TAGS: + value = child.text + + # Keep value as text for unknown tags + # - feel free to add more tags + else: + value = child.text + logger.info(( + "MISSING IMPLEMENTATION:" + " Unknown tag \"{}\". Value \"{}\"" + ).format(tag_name, value)) + + output[child.tag] = value + + return output -def get_convert_rgb_channels(channels_info): +def get_convert_rgb_channels(channel_names): """Get first available RGB(A) group from channels info. ## Examples ``` # Ideal situation - channels_info: { - "R": ..., - "G": ..., - "B": ..., - "A": ... + channels_info: [ + "R", "G", "B", "A" } ``` Result will be `("R", "G", "B", "A")` ``` # Not ideal situation - channels_info: { - "beauty.red": ..., - "beuaty.green": ..., - "beauty.blue": ..., - "depth.Z": ... - } + channels_info: [ + "beauty.red", + "beuaty.green", + "beauty.blue", + "depth.Z" + ] ``` Result will be `("beauty.red", "beauty.green", "beauty.blue", None)` @@ -116,7 +267,7 @@ def get_convert_rgb_channels(channels_info): """ rgb_by_main_name = collections.defaultdict(dict) main_name_order = [""] - for channel_name in channels_info.keys(): + for channel_name in channel_names: name_parts = channel_name.split(".") rgb_part = name_parts.pop(-1).lower() main_name = ".".join(name_parts) @@ -166,28 +317,35 @@ def should_convert_for_ffmpeg(src_filepath): return None # Load info about info from oiio tool - oiio_info = get_oiio_info_for_input(src_filepath) - input_info = parse_oiio_info(oiio_info) + input_info = get_oiio_info_for_input(src_filepath) + if not input_info: + return None # Check compression - compression = input_info["compression"] + compression = input_info["attribs"].get("compression") if compression in ("dwaa", "dwab"): return True # Check channels - channels_info = input_info["channels_info"] - review_channels = get_convert_rgb_channels(channels_info) + channel_names = input_info["channelnames"] + review_channels = get_convert_rgb_channels(channel_names) if review_channels is None: return None + for attr_value in input_info["attribs"].values(): + if ( + isinstance(attr_value, str) + and len(attr_value) > MAX_FFMPEG_STRING_LEN + ): + return True return False def convert_for_ffmpeg( first_input_path, output_dir, - input_frame_start, - input_frame_end, + input_frame_start=None, + input_frame_end=None, logger=None ): """Contert source file to format supported in ffmpeg. @@ -221,46 +379,76 @@ def convert_for_ffmpeg( if input_frame_start is not None and input_frame_end is not None: is_sequence = int(input_frame_end) != int(input_frame_start) - oiio_info = get_oiio_info_for_input(first_input_path) - input_info = parse_oiio_info(oiio_info) + input_info = get_oiio_info_for_input(first_input_path) # Change compression only if source compression is "dwaa" or "dwab" # - they're not supported in ffmpeg - compression = input_info["compression"] + compression = input_info["attribs"].get("compression") if compression in ("dwaa", "dwab"): compression = "none" # Prepare subprocess arguments - oiio_cmd = [ - get_oiio_tools_path(), - "--compression", compression, - first_input_path - ] + oiio_cmd = [get_oiio_tools_path()] + # Add input compression if available + if compression: + oiio_cmd.extend(["--compression", compression]) - channels_info = input_info["channels_info"] - review_channels = get_convert_rgb_channels(channels_info) + # Collect channels to export + channel_names = input_info["channelnames"] + review_channels = get_convert_rgb_channels(channel_names) if review_channels is None: raise ValueError( "Couldn't find channels that can be used for conversion." ) red, green, blue, alpha = review_channels + input_channels = [red, green, blue] channels_arg = "R={},G={},B={}".format(red, green, blue) if alpha is not None: channels_arg += ",A={}".format(alpha) - oiio_cmd.append("--ch") - oiio_cmd.append(channels_arg) + input_channels.append(alpha) + input_channels_str = ",".join(input_channels) + + oiio_cmd.extend([ + # Tell oiiotool which channels should be loaded + # - other channels are not loaded to memory so helps to avoid memory + # leak issues + "-i:ch={}".format(input_channels_str), first_input_path, + # Tell oiiotool which channels should be put to top stack (and output) + "--ch", channels_arg + ]) # Add frame definitions to arguments if is_sequence: - oiio_cmd.append("--frames") - oiio_cmd.append("{}-{}".format(input_frame_start, input_frame_end)) + oiio_cmd.extend([ + "--frames", "{}-{}".format(input_frame_start, input_frame_end) + ]) + + ignore_attr_changes_added = False + for attr_name, attr_value in input_info["attribs"].items(): + if not isinstance(attr_value, str): + continue + + # Remove attributes that have string value longer than allowed length + # for ffmpeg + if len(attr_value) > MAX_FFMPEG_STRING_LEN: + if not ignore_attr_changes_added: + # Attrite changes won't be added to attributes itself + ignore_attr_changes_added = True + oiio_cmd.append("--sansattrib") + # Set attribute to empty string + logger.info(( + "Removed attribute \"{}\" from metadata" + " because has too long value ({} chars)." + ).format(attr_name, len(attr_value))) + oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output base_file_name = os.path.basename(first_input_path) output_path = os.path.join(output_dir, base_file_name) - oiio_cmd.append("-o") - oiio_cmd.append(output_path) + oiio_cmd.extend([ + "-o", output_path + ]) logger.debug("Conversion command: {}".format(" ".join(oiio_cmd))) run_subprocess(oiio_cmd, logger=logger) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py index 53231bd7e4..fc056342a8 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -9,6 +9,8 @@ class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.01 label = "Default Deadline Webservice" + pass_mongo_url = False + def process(self, context): try: deadline_module = context.data.get("openPypeModules")["deadline"] @@ -19,3 +21,5 @@ class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): # get default deadline webservice url from deadline module self.log.debug(deadline_module.deadline_urls) context.data["defaultDeadline"] = deadline_module.deadline_urls["default"] # noqa: E501 + + context.data["deadlinePassMongoUrl"] = self.pass_mongo_url diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_aftereffects_deadline.py index 69159fda1a..a43e38ff94 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_aftereffects_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_aftereffects_deadline.py @@ -67,6 +67,9 @@ class AfterEffectsSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" ] + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **api.Session) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_harmony_deadline.py index 37041a84b1..6683c34dbd 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_harmony_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_harmony_deadline.py @@ -276,6 +276,9 @@ class HarmonySubmitDeadline( "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" ] + # Add mongo url if it's enabled + if self._instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **api.Session) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py index c3228bfe52..c683eb68a8 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -105,15 +105,21 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): # Clarify job name per submission (include instance name) payload["JobInfo"]["Name"] = job_name + " - %s" % instance self.submit_job( - payload, instances=[instance], deadline=AVALON_DEADLINE + context, + payload, + instances=[instance], + deadline=AVALON_DEADLINE ) else: # Submit a single job self.submit_job( - payload, instances=instance_names, deadline=AVALON_DEADLINE + context, + payload, + instances=instance_names, + deadline=AVALON_DEADLINE ) - def submit_job(self, payload, instances, deadline): + def submit_job(self, context, payload, instances, deadline): # Ensure we operate on a copy, a shallow copy is fine. payload = payload.copy() @@ -125,6 +131,9 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): # similar environment using it, e.g. "houdini17.5;pluginx2.3" "AVALON_TOOLS", ] + # Add mongo url if it's enabled + if context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") environment = dict( {key: os.environ[key] for key in keys if key in os.environ}, diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py index fa146c0d30..2cd6b0e6b0 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -101,6 +101,10 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): # similar environment using it, e.g. "maya2018;vray4.x;yeti3.1.9" "AVALON_TOOLS", ] + # Add mongo url if it's enabled + if context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **api.Session) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py index 51a19e2aad..a87c0c374a 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_maya_deadline.py @@ -498,6 +498,9 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): "OPENPYPE_DEV", "OPENPYPE_LOG_NO_COLORS" ] + # Add mongo url if it's enabled + if instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **api.Session) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_nuke_deadline.py index ae9cd985eb..d6bd11620d 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -249,6 +249,10 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): "TOOL_ENV", "FOUNDRY_LICENSE" ] + # Add mongo url if it's enabled + if instance.context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + # add allowed keys from preset if any if self.env_allowed_keys: keys += self.env_allowed_keys diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py index 516bd755d0..a77a968815 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py @@ -227,12 +227,17 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): environment["OPENPYPE_USERNAME"] = instance.context.data["user"] environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" + # Add mongo url if it's enabled + if instance.context.data.get("deadlinePassMongoUrl"): + mongo_url = os.environ.get("OPENPYPE_MONGO") + if mongo_url: + environment["OPENPYPE_MONGO"] = mongo_url args = [ 'publish', roothless_metadata_path, "--targets", "deadline", - "--targets", "filesequence" + "--targets", "farm" ] # Generate the payload for Deadline submission @@ -273,18 +278,18 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): else: payload["JobInfo"]["JobDependency0"] = job["_id"] - i = 0 - for index, key in enumerate(environment): + index = 0 + for key in environment: if key.upper() in self.enviro_filter: payload["JobInfo"].update( { "EnvironmentKeyValue%d" - % i: "{key}={value}".format( + % index: "{key}={value}".format( key=key, value=environment[key] ) } ) - i += 1 + index += 1 # remove secondary pool payload["JobInfo"].pop("SecondaryPool", None) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py b/openpype/modules/default_modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py index 3f63ce6fac..868bbb8463 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/action_push_frame_values_to_task.py @@ -2,7 +2,10 @@ import sys import json import collections import ftrack_api -from openpype_modules.ftrack.lib import ServerAction +from openpype_modules.ftrack.lib import ( + ServerAction, + query_custom_attributes +) class PushHierValuesToNonHier(ServerAction): @@ -51,10 +54,6 @@ class PushHierValuesToNonHier(ServerAction): " from CustomAttributeConfiguration" " where key in ({})" ) - cust_attr_value_query = ( - "select value, entity_id from CustomAttributeValue" - " where entity_id in ({}) and configuration_id in ({})" - ) # configurable settings_key = "sync_hier_entity_attributes" @@ -344,25 +343,11 @@ class PushHierValuesToNonHier(ServerAction): all_ids_with_parents.add(parent_id) _entity_id = parent_id - joined_entity_ids = self.join_query_keys(all_ids_with_parents) - - hier_attr_ids = self.join_query_keys( - tuple(hier_attr["id"] for hier_attr in hier_attrs) - ) + hier_attr_ids = tuple(hier_attr["id"] for hier_attr in hier_attrs) hier_attrs_key_by_id = { hier_attr["id"]: hier_attr["key"] for hier_attr in hier_attrs } - call_expr = [{ - "action": "query", - "expression": self.cust_attr_value_query.format( - joined_entity_ids, hier_attr_ids - ) - }] - if hasattr(session, "call"): - [values] = session.call(call_expr) - else: - [values] = session._call(call_expr) values_per_entity_id = {} for entity_id in all_ids_with_parents: @@ -370,7 +355,10 @@ class PushHierValuesToNonHier(ServerAction): for key in hier_attrs_key_by_id.values(): values_per_entity_id[entity_id][key] = None - for item in values["data"]: + values = query_custom_attributes( + session, all_ids_with_parents, hier_attr_ids, True + ) + for item in values: entity_id = item["entity_id"] key = hier_attrs_key_by_id[item["configuration_id"]] diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/default_modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index 10b165e7f6..0914933de4 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -17,11 +17,6 @@ class PushFrameValuesToTaskEvent(BaseEvent): " (object_type_id in ({}) or is_hierarchical is true)" ) - cust_attr_query = ( - "select value, entity_id from ContextCustomAttributeValue " - "where entity_id in ({}) and configuration_id in ({})" - ) - _cached_task_object_id = None _cached_interest_object_ids = None _cached_user_id = None @@ -273,16 +268,23 @@ class PushFrameValuesToTaskEvent(BaseEvent): hier_attr_ids.append(attr_id) conf_ids = list(hier_attr_ids) + task_conf_ids = [] for key, attr_id in task_attrs.items(): attr_key_by_id[attr_id] = key nonhier_id_by_key[key] = attr_id conf_ids.append(attr_id) + task_conf_ids.append(attr_id) # Query custom attribute values # - result does not contain values for all entities only result of # query callback to ftrack server result = query_custom_attributes( - session, conf_ids, whole_hierarchy_ids + session, list(hier_attr_ids), whole_hierarchy_ids, True + ) + result.extend( + query_custom_attributes( + session, task_conf_ids, whole_hierarchy_ids, False + ) ) # Prepare variables where result will be stored @@ -547,7 +549,7 @@ class PushFrameValuesToTaskEvent(BaseEvent): ) attr_ids = set(attr_id_to_key.keys()) - current_values_by_id = self.current_values( + current_values_by_id = self.get_current_values( session, attr_ids, entity_ids, task_entity_ids, hier_attrs ) @@ -642,27 +644,17 @@ class PushFrameValuesToTaskEvent(BaseEvent): return interesting_data, changed_keys_by_object_id - def current_values( + def get_current_values( self, session, attr_ids, entity_ids, task_entity_ids, hier_attrs ): current_values_by_id = {} if not attr_ids or not entity_ids: return current_values_by_id - joined_conf_ids = self.join_query_keys(attr_ids) - joined_entity_ids = self.join_query_keys(entity_ids) - call_expr = [{ - "action": "query", - "expression": self.cust_attr_query.format( - joined_entity_ids, joined_conf_ids - ) - }] - if hasattr(session, "call"): - [values] = session.call(call_expr) - else: - [values] = session._call(call_expr) - - for item in values["data"]: + values = query_custom_attributes( + session, attr_ids, entity_ids, True + ) + for item in values: entity_id = item["entity_id"] attr_id = item["configuration_id"] if entity_id in task_entity_ids and attr_id in hier_attrs: diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_links.py b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_links.py index 83132acd85..9610e7f5de 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_links.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_links.py @@ -128,7 +128,7 @@ class SyncLinksToAvalon(BaseEvent): def _get_mongo_ids_by_ftrack_ids(self, session, attr_id, ftrack_ids): output = query_custom_attributes( - session, [attr_id], ftrack_ids + session, [attr_id], ftrack_ids, True ) mongo_id_by_ftrack_id = {} for item in output: 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 a4982627ff..9f85000dbb 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 @@ -17,6 +17,7 @@ from avalon.api import AvalonMongoDB from openpype_modules.ftrack.lib import ( get_openpype_attr, + query_custom_attributes, CUST_ATTR_ID_KEY, CUST_ATTR_AUTO_SYNC, @@ -48,8 +49,8 @@ class SyncToAvalonEvent(BaseEvent): ) entities_query_by_id = ( - "select id, name, parent_id, link, custom_attributes from TypedContext" - " where project_id is \"{}\" and id in ({})" + "select id, name, parent_id, link, custom_attributes, description" + " from TypedContext where project_id is \"{}\" and id in ({})" ) # useful for getting all tasks for asset @@ -1073,9 +1074,8 @@ class SyncToAvalonEvent(BaseEvent): self.create_entity_in_avalon(entity, parent_avalon_ent) for child in entity["children"]: - if child.entity_type.lower() == "task": - continue - children_queue.append(child) + if child.entity_type.lower() != "task": + children_queue.append(child) while children_queue: entity = children_queue.popleft() @@ -1145,7 +1145,8 @@ class SyncToAvalonEvent(BaseEvent): "entityType": ftrack_ent.entity_type, "parents": parents, "tasks": {}, - "visualParent": vis_par + "visualParent": vis_par, + "description": ftrack_ent["description"] } } cust_attrs = self.get_cust_attr_values(ftrack_ent) @@ -1822,7 +1823,15 @@ class SyncToAvalonEvent(BaseEvent): if ent_cust_attrs is None: ent_cust_attrs = {} - for key, values in ent_info["changes"].items(): + ent_changes = ent_info["changes"] + if "description" in ent_changes: + if "data" not in self.updates[mongo_id]: + self.updates[mongo_id]["data"] = {} + self.updates[mongo_id]["data"]["description"] = ( + ent_changes["description"]["new"] or "" + ) + + for key, values in ent_changes.items(): if key in hier_attrs_by_key: self.hier_cust_attrs_changes[key].append(ftrack_id) continue @@ -2122,22 +2131,12 @@ class SyncToAvalonEvent(BaseEvent): for key in hier_cust_attrs_keys: configuration_ids.add(hier_attr_id_by_key[key]) - entity_ids_joined = self.join_query_keys(cust_attrs_ftrack_ids) - attributes_joined = self.join_query_keys(configuration_ids) - - queries = [{ - "action": "query", - "expression": ( - "select value, entity_id, configuration_id" - " from CustomAttributeValue " - "where entity_id in ({}) and configuration_id in ({})" - ).format(entity_ids_joined, attributes_joined) - }] - - if hasattr(self.process_session, "call"): - [values] = self.process_session.call(queries) - else: - [values] = self.process_session._call(queries) + values = query_custom_attributes( + self.process_session, + configuration_ids, + cust_attrs_ftrack_ids, + True + ) ftrack_project_id = self.cur_project["id"] @@ -2162,7 +2161,7 @@ class SyncToAvalonEvent(BaseEvent): # PREPARE DATA BEFORE THIS avalon_hier = [] - for item in values["data"]: + for item in values: value = item["value"] if value is None: continue diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py index dc97ed972d..f06162bfda 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py @@ -19,8 +19,8 @@ class CleanHierarchicalAttrsAction(BaseAction): " from TypedContext where project_id is \"{}\"" ) cust_attr_query = ( - "select value, entity_id from CustomAttributeValue " - "where entity_id in ({}) and configuration_id is \"{}\"" + "select value, entity_id from CustomAttributeValue" + " where entity_id in ({}) and configuration_id is \"{}\"" ) settings_key = "clean_hierarchical_attr" @@ -65,17 +65,14 @@ class CleanHierarchicalAttrsAction(BaseAction): ) ) configuration_id = attr["id"] - call_expr = [{ - "action": "query", - "expression": self.cust_attr_query.format( + values = session.query( + self.cust_attr_query.format( entity_ids_joined, configuration_id ) - }] - - [values] = self.session.call(call_expr) + ).all() data = {} - for item in values["data"]: + for item in values: value = item["value"] if value is None: data[item["entity_id"]] = value @@ -90,10 +87,10 @@ class CleanHierarchicalAttrsAction(BaseAction): len(data), configuration_key )) for entity_id, value in data.items(): - entity_key = collections.OrderedDict({ - "configuration_id": configuration_id, - "entity_id": entity_id - }) + entity_key = collections.OrderedDict(( + ("configuration_id", configuration_id), + ("entity_id", entity_id) + )) session.recorded_operations.push( ftrack_api.operation.DeleteEntityOperation( "CustomAttributeValue", diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py index 0bd243ab4c..cb5b88ad50 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -306,8 +306,8 @@ class CustomAttributes(BaseAction): } cust_attr_query = ( - "select value, entity_id from ContextCustomAttributeValue " - "where configuration_id is {}" + "select value, entity_id from CustomAttributeValue" + " where configuration_id is {}" ) for attr_def in object_type_attrs: attr_ent_type = attr_def["entity_type"] @@ -328,21 +328,14 @@ class CustomAttributes(BaseAction): self.log.debug(( "Converting Avalon MongoID attr for Entity type \"{}\"." ).format(entity_type_label)) - - call_expr = [{ - "action": "query", - "expression": cust_attr_query.format(attr_def["id"]) - }] - if hasattr(session, "call"): - [values] = session.call(call_expr) - else: - [values] = session._call(call_expr) - - for value in values["data"]: - table_values = collections.OrderedDict({ - "configuration_id": hierarchical_attr["id"], - "entity_id": value["entity_id"] - }) + values = session.query( + cust_attr_query.format(attr_def["id"]) + ).all() + for value in values: + table_values = collections.OrderedDict([ + ("configuration_id", hierarchical_attr["id"]), + ("entity_id", value["entity_id"]) + ]) session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 38ec02749a..5c38df2e03 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -303,9 +303,10 @@ class FtrackModule( # TODO add add permissions check # TODO add value validations # - value type and list items - entity_key = collections.OrderedDict() - entity_key["configuration_id"] = configuration["id"] - entity_key["entity_id"] = project_id + entity_key = collections.OrderedDict([ + ("configuration_id", configuration["id"]), + ("entity_id", project_id) + ]) session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py index f58eb91485..06e8784287 100644 --- a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py @@ -1,11 +1,8 @@ -import os import re import json import collections import copy -import six - from avalon.api import AvalonMongoDB import avalon @@ -18,7 +15,7 @@ from openpype.api import ( from openpype.lib import ApplicationManager from .constants import CUST_ATTR_ID_KEY -from .custom_attributes import get_openpype_attr +from .custom_attributes import get_openpype_attr, query_custom_attributes from bson.objectid import ObjectId from bson.errors import InvalidId @@ -235,33 +232,19 @@ def get_hierarchical_attributes_values( entity_ids = [item["id"] for item in entity["link"]] - join_ent_ids = join_query_keys(entity_ids) - join_attribute_ids = join_query_keys(attr_key_by_id.keys()) - - queries = [] - queries.append({ - "action": "query", - "expression": ( - "select value, configuration_id, entity_id" - " from CustomAttributeValue" - " where entity_id in ({}) and configuration_id in ({})" - ).format(join_ent_ids, join_attribute_ids) - }) - - if hasattr(session, "call"): - [values] = session.call(queries) - else: - [values] = session._call(queries) + values = query_custom_attributes( + session, list(attr_key_by_id.keys()), entity_ids, True + ) hier_values = {} for key, val in defaults.items(): hier_values[key] = val - if not values["data"]: + if not values: return hier_values values_by_entity_id = collections.defaultdict(dict) - for item in values["data"]: + for item in values: value = item["value"] if value is None: continue @@ -304,7 +287,7 @@ class SyncEntitiesFactory: " from Project where full_name is \"{}\"" ) entities_query = ( - "select id, name, type_id, parent_id, link" + "select id, name, type_id, parent_id, link, description" " from TypedContext where project_id is \"{}\"" ) ignore_custom_attr_key = "avalon_ignore_sync" @@ -861,33 +844,6 @@ class SyncEntitiesFactory: self.entities_dict[parent_id]["children"].remove(ftrack_id) - def _query_custom_attributes(self, session, conf_ids, entity_ids): - output = [] - # Prepare values to query - attributes_joined = join_query_keys(conf_ids) - attributes_len = len(conf_ids) - chunk_size = int(5000 / attributes_len) - for idx in range(0, len(entity_ids), chunk_size): - entity_ids_joined = join_query_keys( - entity_ids[idx:idx + chunk_size] - ) - - call_expr = [{ - "action": "query", - "expression": ( - "select value, entity_id from ContextCustomAttributeValue " - "where entity_id in ({}) and configuration_id in ({})" - ).format(entity_ids_joined, attributes_joined) - }] - if hasattr(session, "call"): - [result] = session.call(call_expr) - else: - [result] = session._call(call_expr) - - for item in result["data"]: - output.append(item) - return output - def set_cutom_attributes(self): self.log.debug("* Preparing custom attributes") # Get custom attributes and values @@ -994,7 +950,7 @@ class SyncEntitiesFactory: copy.deepcopy(prepared_avalon_attr_ca_id) ) - items = self._query_custom_attributes( + items = query_custom_attributes( self.session, list(attribute_key_by_id.keys()), sync_ids @@ -1082,10 +1038,11 @@ class SyncEntitiesFactory: for key, val in prepare_dict_avalon.items(): entity_dict["avalon_attrs"][key] = val - items = self._query_custom_attributes( + items = query_custom_attributes( self.session, list(attribute_key_by_id.keys()), - sync_ids + sync_ids, + True ) avalon_hier = [] @@ -1231,6 +1188,8 @@ class SyncEntitiesFactory: data[key] = val if ftrack_id != self.ft_project_id: + data["description"] = entity["description"] + ent_path_items = [ent["name"] for ent in entity["link"]] parents = ent_path_items[1:len(ent_path_items) - 1:] @@ -1804,10 +1763,10 @@ class SyncEntitiesFactory: configuration_id = self.entities_dict[ftrack_id][ "avalon_attrs_id"][CUST_ATTR_ID_KEY] - _entity_key = collections.OrderedDict({ - "configuration_id": configuration_id, - "entity_id": ftrack_id - }) + _entity_key = collections.OrderedDict([ + ("configuration_id", configuration_id), + ("entity_id", ftrack_id) + ]) self.session.recorded_operations.push( ftrack_api.operation.UpdateEntityOperation( diff --git a/openpype/modules/default_modules/ftrack/lib/custom_attributes.py b/openpype/modules/default_modules/ftrack/lib/custom_attributes.py index 8aab769009..29c6b5e7f8 100644 --- a/openpype/modules/default_modules/ftrack/lib/custom_attributes.py +++ b/openpype/modules/default_modules/ftrack/lib/custom_attributes.py @@ -88,26 +88,36 @@ def join_query_keys(keys): return ",".join(["\"{}\"".format(key) for key in keys]) -def query_custom_attributes(session, conf_ids, entity_ids, table_name=None): +def query_custom_attributes( + session, conf_ids, entity_ids, only_set_values=False +): """Query custom attribute values from ftrack database. Using ftrack call method result may differ based on used table name and version of ftrack server. + For hierarchical attributes you shou always use `only_set_values=True` + otherwise result will be default value of custom attribute and it would not + be possible to differentiate if value is set on entity or default value is + used. + Args: session(ftrack_api.Session): Connected ftrack session. conf_id(list, set, tuple): Configuration(attribute) ids which are queried. entity_ids(list, set, tuple): Entity ids for which are values queried. - table_name(str): Table nam from which values are queried. Not - recommended to change until you know what it means. + only_set_values(bool): Entities that don't have explicitly set + value won't return a value. If is set to False then default custom + attribute value is returned if value is not set. """ output = [] # Just skip if not conf_ids or not entity_ids: return output - if table_name is None: + if only_set_values: + table_name = "CustomAttributeValue" + else: table_name = "ContextCustomAttributeValue" # Prepare values to query @@ -122,19 +132,16 @@ def query_custom_attributes(session, conf_ids, entity_ids, table_name=None): entity_ids_joined = join_query_keys( entity_ids[idx:idx + chunk_size] ) - - call_expr = [{ - "action": "query", - "expression": ( - "select value, entity_id from {}" - " where entity_id in ({}) and configuration_id in ({})" - ).format(table_name, entity_ids_joined, attributes_joined) - }] - if hasattr(session, "call"): - [result] = session.call(call_expr) - else: - [result] = session._call(call_expr) - - for item in result["data"]: - output.append(item) + output.extend( + session.query( + ( + "select value, entity_id from {}" + " where entity_id in ({}) and configuration_id in ({})" + ).format( + table_name, + entity_ids_joined, + attributes_joined + ) + ).all() + ) return output diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 7b0f50b1dc..4454d31d83 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -94,6 +94,7 @@ class AttributeValues: attr_defs_by_key = { attr_def.key: attr_def for attr_def in attr_defs + if attr_def.is_value_def } for key, value in values.items(): if key not in attr_defs_by_key: @@ -306,8 +307,6 @@ class PublishAttributes: 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 @@ -420,7 +419,7 @@ class CreatedInstance: # Stored creator specific attribute values # {key: value} creator_values = copy.deepcopy(orig_creator_attributes) - creator_attr_defs = creator.get_attribute_defs() + creator_attr_defs = creator.get_instance_attr_defs() self._data["creator_attributes"] = CreatorAttributeValues( self, creator_attr_defs, creator_values, orig_creator_attributes diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index aa2e3333ce..1ac2c420a2 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -80,7 +80,7 @@ class BaseCreator: self.create_context.creator_removed_instance(instance) @abstractmethod - def create(self, options=None): + def create(self): """Create new instance. Replacement of `process` method from avalon implementation. @@ -163,7 +163,7 @@ class BaseCreator: dynamic_data=dynamic_data ) - def get_attribute_defs(self): + def get_instance_attr_defs(self): """Plugin attribute definitions. Attribute definitions of plugin that hold data about created instance @@ -199,15 +199,22 @@ class Creator(BaseCreator): # - may not be used if `get_detail_description` is overriden detailed_description = None + # It does make sense to change context on creation + # - in some cases it may confuse artists because it would not be used + # e.g. for buld creators + create_allow_context_change = True + @abstractmethod - def create(self, subset_name, instance_data, options=None): + def create(self, subset_name, instance_data, pre_create_data): """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_data(dict): Base data for instance. + pre_create_data(dict): Data based on pre creation attributes. + Those may affect how creator works. """ # instance = CreatedInstance( @@ -258,6 +265,19 @@ class Creator(BaseCreator): return None + def get_pre_create_attr_defs(self): + """Plugin attribute definitions needed for creation. + Attribute definitions of plugin that define how creation will work. + Values of these definitions are passed to `create` method. + 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 AutoCreator(BaseCreator): """Creator which is automatically triggered without user interaction. diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py index e2c15cbd2d..ed38889c66 100644 --- a/openpype/pipeline/lib/__init__.py +++ b/openpype/pipeline/lib/__init__.py @@ -5,11 +5,17 @@ from .events import ( from .attribute_definitions import ( AbtractAttrDef, + + UIDef, + UISeparatorDef, + UILabelDef, + UnknownDef, NumberDef, TextDef, EnumDef, - BoolDef + BoolDef, + FileDef, ) @@ -18,9 +24,15 @@ __all__ = ( "BeforeWorkfileSave", "AbtractAttrDef", + + "UIDef", + "UISeparatorDef", + "UILabelDef", + "UnknownDef", "NumberDef", "TextDef", "EnumDef", - "BoolDef" + "BoolDef", + "FileDef", ) diff --git a/openpype/pipeline/lib/attribute_definitions.py b/openpype/pipeline/lib/attribute_definitions.py index 2b34e15bc4..189a5e7acd 100644 --- a/openpype/pipeline/lib/attribute_definitions.py +++ b/openpype/pipeline/lib/attribute_definitions.py @@ -38,13 +38,21 @@ class AbtractAttrDef: key(str): Under which key will be attribute value stored. label(str): Attribute label. tooltip(str): Attribute tooltip. + is_label_horizontal(bool): UI specific argument. Specify if label is + next to value input or ahead. """ + is_value_def = True - def __init__(self, key, default, label=None, tooltip=None): + def __init__( + self, key, default, label=None, tooltip=None, is_label_horizontal=None + ): + if is_label_horizontal is None: + is_label_horizontal = True self.key = key self.label = label self.tooltip = tooltip self.default = default + self.is_label_horizontal = is_label_horizontal self._id = uuid.uuid4() self.__init__class__ = AbtractAttrDef @@ -68,8 +76,39 @@ class AbtractAttrDef: pass +# ----------------------------------------- +# UI attribute definitoins won't hold value +# ----------------------------------------- + +class UIDef(AbtractAttrDef): + is_value_def = False + + def __init__(self, key=None, default=None, *args, **kwargs): + super(UIDef, self).__init__(key, default, *args, **kwargs) + + def convert_value(self, value): + return value + + +class UISeparatorDef(UIDef): + pass + + +class UILabelDef(UIDef): + def __init__(self, label): + super(UILabelDef, self).__init__(label=label) + + +# --------------------------------------- +# Attribute defintioins should hold value +# --------------------------------------- + class UnknownDef(AbtractAttrDef): - """Definition is not known because definition is not available.""" + """Definition is not known because definition is not available. + + This attribute can be used to keep existing data unchanged but does not + have known definition of type. + """ def __init__(self, key, default=None, **kwargs): kwargs["default"] = default super(UnknownDef, self).__init__(key, **kwargs) @@ -261,3 +300,90 @@ class BoolDef(AbtractAttrDef): if isinstance(value, bool): return value return self.default + + +class FileDef(AbtractAttrDef): + """File definition. + It is possible to define filters of allowed file extensions and if supports + folders. + Args: + multipath(bool): Allow multiple path. + folders(bool): Allow folder paths. + extensions(list): Allow files with extensions. Empty list will + allow all extensions and None will disable files completely. + default(str, list): Defautl value. + """ + + def __init__( + self, key, multipath=False, folders=None, extensions=None, + default=None, **kwargs + ): + if folders is None and extensions is None: + folders = True + extensions = [] + + if default is None: + if multipath: + default = [] + else: + default = "" + else: + if multipath: + if not isinstance(default, (tuple, list, set)): + raise TypeError(( + "'default' argument must be 'list', 'tuple' or 'set'" + ", not '{}'" + ).format(type(default))) + + else: + if not isinstance(default, six.string_types): + raise TypeError(( + "'default' argument must be 'str' not '{}'" + ).format(type(default))) + default = default.strip() + + # Change horizontal label + is_label_horizontal = kwargs.get("is_label_horizontal") + if is_label_horizontal is None: + is_label_horizontal = True + if multipath: + is_label_horizontal = False + kwargs["is_label_horizontal"] = is_label_horizontal + + self.multipath = multipath + self.folders = folders + self.extensions = extensions + super(FileDef, self).__init__(key, default=default, **kwargs) + + def __eq__(self, other): + if not super(FileDef, self).__eq__(other): + return False + + return ( + self.multipath == other.multipath + and self.folders == other.folders + and self.extensions == other.extensions + ) + + def convert_value(self, value): + if isinstance(value, six.string_types): + if self.multipath: + value = [value.strip()] + else: + value = value.strip() + return value + + if isinstance(value, (tuple, list, set)): + _value = [] + for item in value: + if isinstance(item, six.string_types): + _value.append(item.strip()) + + if self.multipath: + return _value + + if not _value: + return self.default + return _value[0].strip() + + return str(value).strip() diff --git a/openpype/plugins/publish/cleanup_farm.py b/openpype/plugins/publish/cleanup_farm.py new file mode 100644 index 0000000000..ab0c6e469e --- /dev/null +++ b/openpype/plugins/publish/cleanup_farm.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""Cleanup leftover files from publish.""" +import os +import shutil +import pyblish.api +import avalon.api + + +class CleanUpFarm(pyblish.api.ContextPlugin): + """Cleans up the staging directory after a successful publish. + + This will also clean published renders and delete their parent directories. + """ + + order = pyblish.api.IntegratorOrder + 11 + label = "Clean Up Farm" + enabled = True + + # Keep "filesequence" for backwards compatibility of older jobs + targets = ["filesequence", "farm"] + allowed_hosts = ("maya", ) + + def process(self, context): + # Get source host from which farm publishing was started + src_host_name = avalon.api.Session.get("AVALON_APP") + self.log.debug("Host name from session is {}".format(src_host_name)) + # Skip process if is not in list of source hosts in which this + # plugin should run + if src_host_name not in self.allowed_hosts: + self.log.info(( + "Source host \"{}\" is not in list of enabled hosts {}." + " Skipping" + ).format(str(src_host_name), str(self.allowed_hosts))) + return + + self.log.debug("Preparing filepaths to remove") + # Collect directories to remove + dirpaths_to_remove = set() + for instance in context: + staging_dir = instance.data.get("stagingDir") + if staging_dir: + dirpaths_to_remove.add(os.path.normpath(staging_dir)) + + if "representations" in instance.data: + for repre in instance.data["representations"]: + staging_dir = repre.get("stagingDir") + if staging_dir: + dirpaths_to_remove.add(os.path.normpath(staging_dir)) + + if not dirpaths_to_remove: + self.log.info("Nothing to remove. Skipping") + return + + self.log.debug("Filepaths to remove are:\n{}".format( + "\n".join(["- {}".format(path) for path in dirpaths_to_remove]) + )) + + # clean dirs which are empty + for dirpath in dirpaths_to_remove: + if not os.path.exists(dirpath): + self.log.debug("Skipping not existing directory \"{}\"".format( + dirpath + )) + continue + + self.log.debug("Removing directory \"{}\"".format(dirpath)) + try: + shutil.rmtree(dirpath) + except OSError: + self.log.warning( + "Failed to remove directory \"{}\"".format(dirpath), + exc_info=True + ) diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index 07de1b4420..b0474b93ce 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -12,11 +12,11 @@ Provides: context -> anatomyData """ -import os import json - -from openpype.lib import ApplicationManager -from avalon import api, lib +from openpype.lib import ( + get_system_general_anatomy_data +) +from avalon import api import pyblish.api @@ -44,6 +44,7 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): label = "Collect Anatomy Context Data" def process(self, context): + task_name = api.Session["AVALON_TASK"] project_entity = context.data["projectEntity"] @@ -79,6 +80,10 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): "app": context.data["hostName"] } + # add system general settings anatomy data + system_general_data = get_system_general_anatomy_data() + context_data.update(system_general_data) + datetime_data = context.data.get("datetimeData") or {} context_data.update(datetime_data) diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 2f55f2bdb5..1005c38b9d 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -21,7 +21,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): """ order = pyblish.api.CollectorOrder - 0.2 - targets = ["filesequence"] + # Keep "filesequence" for backwards compatibility of older jobs + targets = ["filesequence", "farm"] label = "Collect rendered frames" _context = None diff --git a/openpype/plugins/publish/validate_filesequences.py b/openpype/plugins/publish/validate_filesequences.py index 2f4ac3de4f..8a877d79bb 100644 --- a/openpype/plugins/publish/validate_filesequences.py +++ b/openpype/plugins/publish/validate_filesequences.py @@ -5,7 +5,8 @@ class ValidateFileSequences(pyblish.api.ContextPlugin): """Validates whether any file sequences were collected.""" order = pyblish.api.ValidatorOrder - targets = ["filesequence"] + # Keep "filesequence" for backwards compatibility of older jobs + targets = ["filesequence", "farm"] label = "Validate File Sequences" def process(self, context): diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 50535e25dc..47f5e7fcc0 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -134,7 +134,7 @@ class PypeCommands: print(f"setting target: {target}") pyblish.api.register_target(target) else: - pyblish.api.register_target("filesequence") + pyblish.api.register_target("farm") os.environ["OPENPYPE_PUBLISH_DATA"] = os.pathsep.join(paths) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 9fb964b494..f4e46fcddc 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -1,6 +1,9 @@ { "deadline_servers": [], "publish": { + "CollectDefaultDeadlineServer": { + "pass_mongo_url": false + }, "ValidateExpectedFiles": { "enabled": true, "active": true, diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 9c0c6f6958..f3aad2a51b 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -208,6 +208,9 @@ "CleanUp": { "paterns": [], "remove_temp_renders": false + }, + "CleanUpFarm": { + "enabled": false } }, "tools": { diff --git a/openpype/settings/defaults/project_settings/webpublisher.json b/openpype/settings/defaults/project_settings/webpublisher.json index 9db98acd5a..77168c25e6 100644 --- a/openpype/settings/defaults/project_settings/webpublisher.json +++ b/openpype/settings/defaults/project_settings/webpublisher.json @@ -2,17 +2,17 @@ "publish": { "CollectPublishedFiles": { "task_type_to_family": { - "Animation": { - "workfile": { + "Animation": [ + { "is_sequence": false, "extensions": [ "tvp" ], "families": [], "tags": [], - "subset_template_name": "" + "result_family": "workfile" }, - "render": { + { "is_sequence": true, "extensions": [ "png", @@ -26,20 +26,20 @@ "tags": [ "review" ], - "subset_template_name": "" + "result_family": "render" } - }, - "Compositing": { - "workfile": { + ], + "Compositing": [ + { "is_sequence": false, "extensions": [ "aep" ], "families": [], "tags": [], - "subset_template_name": "" + "result_family": "workfile" }, - "render": { + { "is_sequence": true, "extensions": [ "png", @@ -53,20 +53,20 @@ "tags": [ "review" ], - "subset_template_name": "" + "result_family": "render" } - }, - "Layout": { - "workfile": { + ], + "Layout": [ + { "is_sequence": false, "extensions": [ "psd" ], "families": [], "tags": [], - "subset_template_name": "" + "result_family": "workfile" }, - "image": { + { "is_sequence": false, "extensions": [ "png", @@ -81,20 +81,21 @@ "tags": [ "review" ], - "subset_template_name": "" + "result_family": "image" } - }, - "default_task_type": { - "workfile": { + ], + "default_task_type": [ + { "is_sequence": false, "extensions": [ - "tvp" + "tvp", + "psd" ], "families": [], "tags": [], - "subset_template_name": "{family}{Variant}" + "result_family": "workfile" }, - "render": { + { "is_sequence": true, "extensions": [ "png", @@ -108,9 +109,9 @@ "tags": [ "review" ], - "subset_template_name": "{family}{Variant}" + "result_family": "render" } - }, + ], "__dynamic_keys_labels__": { "default_task_type": "Default task type" } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index eb9eeb5448..7c44791160 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -17,6 +17,19 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectDefaultDeadlineServer", + "label": "Default Deadline Webservice", + "children": [ + { + "type": "boolean", + "key": "pass_mongo_url", + "label": "Pass Mongo url to job" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json index 78f38f111d..b76a0fa844 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json @@ -24,10 +24,10 @@ "label": "Task type to family mapping", "collapsible_key": true, "object_type": { - "type": "dict-modifiable", - "collapsible": false, + "type": "list", + "collapsible": true, "key": "task_type", - "collapsible_key": false, + "collapsible_key": true, "object_type": { "type": "dict", "children": [ @@ -52,10 +52,13 @@ "type": "schema", "name": "schema_representation_tags" }, + { + "type": "separator" + }, { "type": "text", - "key": "subset_template_name", - "label": "Subset template name" + "key": "result_family", + "label": "Resulting family" } ] } 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 3f9776bcd6..e608e9ff63 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 @@ -677,8 +677,22 @@ "label": "Remove Temp renders", "default": false } - ] + }, + { + "type": "dict", + "collapsible": false, + "key": "CleanUpFarm", + "label": "Clean Up Farm", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] } ] } diff --git a/openpype/style/data.json b/openpype/style/data.json index e65690378d..2a97b66cc2 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -66,7 +66,7 @@ }, "nice-checkbox": { "bg-checked": "#56a06f", - "bg-unchecked": "#434b56", + "bg-unchecked": "#21252B", "bg-checker": "#D3D8DE", "bg-checker-hover": "#F0F2F5" }, diff --git a/openpype/style/style.css b/openpype/style/style.css index 03b7b522f9..5f2ac9499f 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -387,10 +387,16 @@ QHeaderView::section:only-one { QHeaderView::down-arrow { image: url(:/openpype/images/down_arrow.png); + padding-right: 4px; + subcontrol-origin: padding; + subcontrol-position: center right; } QHeaderView::up-arrow { image: url(:/openpype/images/up_arrow.png); + padding-right: 4px; + subcontrol-origin: padding; + subcontrol-position: center right; } /* Checkboxes */ @@ -1198,6 +1204,10 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { font-size: 36pt; } +#OverlayFrame { + background: rgba(0, 0, 0, 127); +} + #BreadcrumbsPathInput { padding: 2px; font-size: 9pt; diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index ba0d9dd6b5..5dad41c349 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -6,6 +6,7 @@ from avalon.vendor import qtawesome from .delegates import ActionDelegate from . import lib +from .actions import ApplicationAction from .models import ActionModel from openpype.tools.flickcharm import FlickCharm from .constants import ( @@ -239,10 +240,12 @@ class ActionBar(QtWidgets.QWidget): is_variant_group = index.data(VARIANT_GROUP_ROLE) if not is_group and not is_variant_group: action = index.data(ACTION_ROLE) - if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): - action.data["start_last_workfile"] = False - else: - action.data.pop("start_last_workfile", None) + # Change data of application action + if issubclass(action, ApplicationAction): + if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE): + action.data["start_last_workfile"] = False + else: + action.data.pop("start_last_workfile", None) self._start_animation(index) self.action_clicked.emit(action) return diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 842352cba1..31487ff132 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -2,7 +2,9 @@ from Qt import QtWidgets, QtCore from .widgets import ( NameTextEdit, - FilterComboBox + FilterComboBox, + SpinBoxScrollFixed, + DoubleSpinBoxScrollFixed ) from .multiselection_combobox import MultiSelectionComboBox @@ -89,9 +91,9 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): if self.decimals > 0: - editor = QtWidgets.QDoubleSpinBox(parent) + editor = DoubleSpinBoxScrollFixed(parent) else: - editor = QtWidgets.QSpinBox(parent) + editor = SpinBoxScrollFixed(parent) editor.setObjectName("NumberEditor") # Set min/max diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 4b5aca35ef..02d4eda0fc 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -429,3 +429,29 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): def _on_confirm_text_change(self): enabled = self._confirm_input.text() == self._project_name self._confirm_btn.setEnabled(enabled) + + +class SpinBoxScrollFixed(QtWidgets.QSpinBox): + """QSpinBox which only allow edits change with scroll wheel when active""" + def __init__(self, *args, **kwargs): + super(SpinBoxScrollFixed, self).__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def wheelEvent(self, event): + if not self.hasFocus(): + event.ignore() + else: + super(SpinBoxScrollFixed, self).wheelEvent(event) + + +class DoubleSpinBoxScrollFixed(QtWidgets.QDoubleSpinBox): + """QDoubleSpinBox which only allow edits with scroll wheel when active""" + def __init__(self, *args, **kwargs): + super(DoubleSpinBoxScrollFixed, self).__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + def wheelEvent(self, event): + if not self.hasFocus(): + event.ignore() + else: + super(DoubleSpinBoxScrollFixed, self).wheelEvent(event) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 3dd1bd6dc9..2ce0eaad62 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -605,7 +605,9 @@ class PublisherController: found_idx = idx break - value = instance.creator_attributes[attr_def.key] + value = None + if attr_def.is_value_def: + value = instance.creator_attributes[attr_def.key] if found_idx is None: idx = len(output) output.append((attr_def, [instance], [value])) diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py index 9b22a6cf25..55afc349ff 100644 --- a/openpype/tools/publisher/widgets/__init__.py +++ b/openpype/tools/publisher/widgets/__init__.py @@ -9,8 +9,6 @@ from .border_label_widget import ( from .widgets import ( SubsetAttributesWidget, - PixmapLabel, - StopBtn, ResetBtn, ValidateBtn, @@ -44,8 +42,6 @@ __all__ = ( "SubsetAttributesWidget", "BorderedLabelWidget", - "PixmapLabel", - "StopBtn", "ResetBtn", "ValidateBtn", diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py new file mode 100644 index 0000000000..b8696a2665 --- /dev/null +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -0,0 +1,273 @@ +import collections + +from Qt import QtWidgets, QtCore, QtGui +from openpype.tools.utils import ( + PlaceholderLineEdit, + RecursiveSortFilterProxyModel +) +from openpype.tools.utils.assets_widget import ( + SingleSelectAssetsWidget, + ASSET_ID_ROLE, + ASSET_NAME_ROLE +) + + +class CreateDialogAssetsWidget(SingleSelectAssetsWidget): + current_context_required = QtCore.Signal() + + def __init__(self, controller, parent): + self._controller = controller + super(CreateDialogAssetsWidget, self).__init__(None, parent) + + self.set_refresh_btn_visibility(False) + self.set_current_asset_btn_visibility(False) + + self._current_asset_name = None + self._last_selection = None + self._enabled = None + + def _on_current_asset_click(self): + self.current_context_required.emit() + + def set_enabled(self, enabled): + if self._enabled == enabled: + return + self._enabled = enabled + if not enabled: + self._last_selection = self.get_selected_asset_id() + self._clear_selection() + elif self._last_selection is not None: + self.select_asset(self._last_selection) + + def _select_indexes(self, *args, **kwargs): + super(CreateDialogAssetsWidget, self)._select_indexes(*args, **kwargs) + if self._enabled: + return + self._last_selection = self.get_selected_asset_id() + self._clear_selection() + + def set_current_asset_name(self, asset_name): + self._current_asset_name = asset_name + # Hide set current asset if there is no one + self.set_current_asset_btn_visibility(asset_name is not None) + + def _get_current_session_asset(self): + return self._current_asset_name + + def _create_source_model(self): + return AssetsHierarchyModel(self._controller) + + def _refresh_model(self): + self._model.reset() + self._on_model_refresh(self._model.rowCount() > 0) + + +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 = {} + self._items_by_asset_id = {} + + def reset(self): + self.clear() + + self._items_by_name = {} + self._items_by_asset_id = {} + assets_by_parent_id = self._controller.get_asset_hierarchy() + + items_by_name = {} + items_by_asset_id = {} + _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] + child_id = child["_id"] + item = QtGui.QStandardItem(name) + item.setFlags( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + ) + item.setData(child_id, ASSET_ID_ROLE) + item.setData(name, ASSET_NAME_ROLE) + + items_by_name[name] = item + items_by_asset_id[child_id] = item + items.append(item) + _queue.append((item, child_id)) + + parent_item.appendRows(items) + + self._items_by_name = items_by_name + self._items_by_asset_id = items_by_asset_id + + def get_index_by_asset_id(self, asset_id): + item = self._items_by_asset_id.get(asset_id) + if item is not None: + return item.index() + return QtCore.QModelIndex() + + def get_index_by_asset_name(self, asset_name): + item = self._items_by_name.get(asset_name) + if item is None: + return QtCore.QModelIndex() + return item.index() + + def name_is_valid(self, item_name): + return item_name in self._items_by_name + + +class AssetsDialog(QtWidgets.QDialog): + """Dialog to select asset for a context of instance.""" + def __init__(self, controller, parent): + super(AssetsDialog, self).__init__(parent) + self.setWindowTitle("Select asset") + + model = AssetsHierarchyModel(controller) + proxy_model = RecursiveSortFilterProxyModel() + proxy_model.setSourceModel(model) + proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + filter_input = PlaceholderLineEdit(self) + filter_input.setPlaceholderText("Filter assets..") + + asset_view = QtWidgets.QTreeView(self) + asset_view.setModel(proxy_model) + asset_view.setHeaderHidden(True) + asset_view.setFrameShape(QtWidgets.QFrame.NoFrame) + asset_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + asset_view.setAlternatingRowColors(True) + asset_view.setSelectionBehavior(QtWidgets.QTreeView.SelectRows) + asset_view.setAllColumnsShowFocus(True) + + ok_btn = QtWidgets.QPushButton("OK", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(filter_input, 0) + layout.addWidget(asset_view, 1) + layout.addLayout(btns_layout, 0) + + filter_input.textChanged.connect(self._on_filter_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self._filter_input = filter_input + self._ok_btn = ok_btn + self._cancel_btn = cancel_btn + + self._model = model + self._proxy_model = proxy_model + + self._asset_view = asset_view + + self._selected_asset = None + # Soft refresh is enabled + # - reset will happen at all cost if soft reset is enabled + # - adds ability to call reset on multiple places without repeating + self._soft_reset_enabled = True + + def showEvent(self, event): + """Refresh asset model on show.""" + super(AssetsDialog, self).showEvent(event) + # Refresh on show + self.reset(False) + + def reset(self, force=True): + """Reset asset model.""" + if not force and not self._soft_reset_enabled: + return + + if self._soft_reset_enabled: + self._soft_reset_enabled = False + + self._model.reset() + + def name_is_valid(self, name): + """Is asset name valid. + + Args: + name(str): Asset name that should be checked. + """ + # Make sure we're reset + self.reset(False) + # Valid the name by model + return self._model.name_is_valid(name) + + def _on_filter_change(self, text): + """Trigger change of filter of assets.""" + self._proxy_model.setFilterFixedString(text) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + index = self._asset_view.currentIndex() + asset_name = None + if index.isValid(): + asset_name = index.data(QtCore.Qt.DisplayRole) + self._selected_asset = asset_name + self.done(1) + + def set_selected_assets(self, asset_names): + """Change preselected asset before showing the dialog. + + This also resets model and clean filter. + """ + self.reset(False) + self._asset_view.collapseAll() + self._filter_input.setText("") + + indexes = [] + for asset_name in asset_names: + index = self._model.get_index_by_asset_name(asset_name) + if index.isValid(): + indexes.append(index) + + if not indexes: + return + + index_deque = collections.deque() + for index in indexes: + index_deque.append(index) + + all_indexes = [] + while index_deque: + index = index_deque.popleft() + all_indexes.append(index) + + parent_index = index.parent() + if parent_index.isValid(): + index_deque.append(parent_index) + + for index in all_indexes: + proxy_index = self._proxy_model.mapFromSource(index) + self._asset_view.expand(proxy_index) + + def get_selected_asset(self): + """Get selected asset name.""" + return self._selected_asset diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index ff0dfc95ab..086cd5c59c 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -27,12 +27,12 @@ from Qt import QtWidgets, QtCore from openpype.widgets.nice_checkbox import NiceCheckbox +from openpype.tools.utils import BaseClickableFrame from .widgets import ( AbstractInstanceView, ContextWarningLabel, - ClickableFrame, IconValuePixmapLabel, - TransparentPixmapLabel + PublishPixmapLabel ) from ..constants import ( CONTEXT_ID, @@ -140,7 +140,7 @@ class GroupWidget(QtWidgets.QWidget): widget_idx += 1 -class CardWidget(ClickableFrame): +class CardWidget(BaseClickableFrame): """Clickable card used as bigger button.""" selected = QtCore.Signal(str, str) # Group identifier of card @@ -184,7 +184,7 @@ class ContextCardWidget(CardWidget): self._id = CONTEXT_ID self._group_identifier = "" - icon_widget = TransparentPixmapLabel(self) + icon_widget = PublishPixmapLabel(None, self) icon_widget.setObjectName("FamilyIconLabel") label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 84fc6d4e97..f9f8310e09 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -15,6 +15,9 @@ from openpype.pipeline.create import ( ) from .widgets import IconValuePixmapLabel +from .assets_widget import CreateDialogAssetsWidget +from .tasks_widget import CreateDialogTasksWidget +from .precreate_widget import PreCreateWidget from ..constants import ( VARIANT_TOOLTIP, CREATOR_IDENTIFIER_ROLE, @@ -202,7 +205,23 @@ class CreateDialog(QtWidgets.QDialog): self._name_pattern = name_pattern self._compiled_name_pattern = re.compile(name_pattern) + context_widget = QtWidgets.QWidget(self) + + assets_widget = CreateDialogAssetsWidget(controller, context_widget) + tasks_widget = CreateDialogTasksWidget(controller, context_widget) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.setSpacing(0) + context_layout.addWidget(assets_widget, 2) + context_layout.addWidget(tasks_widget, 1) + + # Precreate attributes widgets + pre_create_widget = PreCreateWidget(self) + + # TODO add HELP button creator_description_widget = CreatorDescriptionWidget(self) + creator_description_widget.setVisible(False) creators_view = QtWidgets.QListView(self) creators_model = QtGui.QStandardItemModel() @@ -235,27 +254,46 @@ class CreateDialog(QtWidgets.QDialog): 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) + mid_widget = QtWidgets.QWidget(self) + mid_layout = QtWidgets.QVBoxLayout(mid_widget) + mid_layout.setContentsMargins(0, 0, 0, 0) + mid_layout.addWidget(QtWidgets.QLabel("Choose family:", self)) + mid_layout.addWidget(creators_view, 1) + mid_layout.addLayout(form_layout, 0) + mid_layout.addWidget(create_btn, 0) layout = QtWidgets.QHBoxLayout(self) - layout.addLayout(left_layout, 0) - layout.addSpacing(5) - layout.addWidget(creator_description_widget, 1) + layout.setSpacing(10) + layout.addWidget(context_widget, 1) + layout.addWidget(mid_widget, 1) + layout.addWidget(pre_create_widget, 1) + + prereq_timer = QtCore.QTimer() + prereq_timer.setInterval(50) + prereq_timer.setSingleShot(True) + + prereq_timer.timeout.connect(self._on_prereq_timer) 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 + self._on_creator_item_change ) variant_hints_menu.triggered.connect(self._on_variant_action) + assets_widget.selection_changed.connect(self._on_asset_change) + assets_widget.current_context_required.connect( + self._on_current_session_context_request + ) + tasks_widget.task_changed.connect(self._on_task_change) controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._pre_create_widget = pre_create_widget + + self._context_widget = context_widget + self._assets_widget = assets_widget + self._tasks_widget = tasks_widget self.creator_description_widget = creator_description_widget self.subset_name_input = subset_name_input @@ -269,12 +307,54 @@ class CreateDialog(QtWidgets.QDialog): self.creators_view = creators_view self.create_btn = create_btn + self._prereq_timer = prereq_timer + + def _context_change_is_enabled(self): + return self._context_widget.isEnabled() + + def _get_asset_name(self): + asset_name = None + if self._context_change_is_enabled(): + asset_name = self._assets_widget.get_selected_asset_name() + + if asset_name is None: + asset_name = self._asset_name + return asset_name + + def _get_task_name(self): + task_name = None + if self._context_change_is_enabled(): + # Don't use selection of task if asset is not set + asset_name = self._assets_widget.get_selected_asset_name() + if asset_name: + task_name = self._tasks_widget.get_selected_task_name() + + if not task_name: + task_name = self._task_name + return task_name + @property def dbcon(self): return self.controller.dbcon + def _set_context_enabled(self, enabled): + self._assets_widget.set_enabled(enabled) + self._tasks_widget.set_enabled(enabled) + self._context_widget.setEnabled(enabled) + def refresh(self): - self._prereq_available = True + # Get context before refresh to keep selection of asset and + # task widgets + asset_name = self._get_asset_name() + task_name = self._get_task_name() + + self._prereq_available = False + + # Disable context widget so refresh of asset will use context asset + # name + self._set_context_enabled(False) + + self._assets_widget.refresh() # Refresh data before update of creators self._refresh_asset() @@ -282,21 +362,36 @@ class CreateDialog(QtWidgets.QDialog): # data self._refresh_creators() + self._assets_widget.set_current_asset_name(self._asset_name) + self._assets_widget.select_asset_by_name(asset_name) + self._tasks_widget.set_asset_name(asset_name) + self._tasks_widget.select_task_name(task_name) + + self._invalidate_prereq() + + def _invalidate_prereq(self): + self._prereq_timer.start() + + def _on_prereq_timer(self): + prereq_available = True + if self.creators_model.rowCount() < 1: + prereq_available = False + 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 + prereq_available = False - if self.creators_model.rowCount() < 1: - self._prereq_available = False + if prereq_available != self._prereq_available: + self._prereq_available = prereq_available - 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) + self.create_btn.setEnabled(prereq_available) + self.creators_view.setEnabled(prereq_available) + self.variant_input.setEnabled(prereq_available) + self.variant_hints_btn.setEnabled(prereq_available) + self._on_variant_change() def _refresh_asset(self): - asset_name = self._asset_name + asset_name = self._get_asset_name() # Skip if asset did not change if self._asset_doc and self._asset_doc["name"] == asset_name: @@ -324,6 +419,9 @@ class CreateDialog(QtWidgets.QDialog): ) self._subset_names = set(subset_docs.distinct("name")) + if not asset_doc: + self.subset_name_input.setText("< Asset is not set >") + def _refresh_creators(self): # Refresh creators and add their families to list existing_items = {} @@ -366,25 +464,60 @@ class CreateDialog(QtWidgets.QDialog): if not indexes: index = self.creators_model.index(0, 0) self.creators_view.setCurrentIndex(index) + else: + index = indexes[0] + + identifier = index.data(CREATOR_IDENTIFIER_ROLE) + + self._set_creator(identifier) 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): + def _on_asset_change(self): + self._refresh_asset() + + asset_name = self._assets_widget.get_selected_asset_name() + self._tasks_widget.set_asset_name(asset_name) + if self._context_change_is_enabled(): + self._invalidate_prereq() + + def _on_task_change(self): + if self._context_change_is_enabled(): + self._invalidate_prereq() + + def _on_current_session_context_request(self): + self._assets_widget.set_current_session_asset() + if self._task_name: + self._tasks_widget.select_task_name(self._task_name) + + def _on_creator_item_change(self, new_index, _old_index): identifier = None if new_index.isValid(): identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) + self._set_creator(identifier) + def _set_creator(self, identifier): creator = self.controller.manual_creators.get(identifier) self.creator_description_widget.set_plugin(creator) + self._pre_create_widget.set_plugin(creator) self._selected_creator = creator + if not creator: + self._set_context_enabled(False) return + if ( + creator.create_allow_context_change + != self._context_change_is_enabled() + ): + self._set_context_enabled(creator.create_allow_context_change) + self._refresh_asset() + default_variants = creator.get_default_variants() if not default_variants: default_variants = ["Main"] @@ -410,12 +543,19 @@ class CreateDialog(QtWidgets.QDialog): 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: + def _on_variant_change(self, variant_value=None): + if not self._prereq_available: + return + + # This should probably never happen? + if not self._selected_creator: if self.subset_name_input.text(): self.subset_name_input.setText("") return + if variant_value is None: + variant_value = self.variant_input.text() + match = self._compiled_name_pattern.match(variant_value) valid = bool(match) self.create_btn.setEnabled(valid) @@ -425,7 +565,7 @@ class CreateDialog(QtWidgets.QDialog): return project_name = self.controller.project_name - task_name = self._task_name + task_name = self._get_task_name() asset_doc = copy.deepcopy(self._asset_doc) # Calculate subset name with Creator plugin @@ -522,9 +662,9 @@ class CreateDialog(QtWidgets.QDialog): 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 = {} + asset_name = self._get_asset_name() + task_name = self._get_task_name() + pre_create_data = self._pre_create_widget.current_value() # Where to define these data? # - what data show be stored? instance_data = { @@ -537,7 +677,7 @@ class CreateDialog(QtWidgets.QDialog): error_info = None try: self.controller.create( - creator_identifier, subset_name, instance_data, options + creator_identifier, subset_name, instance_data, pre_create_data ) except CreatorError as exc: diff --git a/openpype/tools/publisher/widgets/precreate_widget.py b/openpype/tools/publisher/widgets/precreate_widget.py new file mode 100644 index 0000000000..eaadfe890b --- /dev/null +++ b/openpype/tools/publisher/widgets/precreate_widget.py @@ -0,0 +1,133 @@ +from Qt import QtWidgets, QtCore + +from openpype.widgets.attribute_defs import create_widget_for_attr_def + + +class PreCreateWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(PreCreateWidget, self).__init__(parent) + + # Precreate attribute defininitions of Creator + scroll_area = QtWidgets.QScrollArea(self) + contet_widget = QtWidgets.QWidget(scroll_area) + scroll_area.setWidget(contet_widget) + scroll_area.setWidgetResizable(True) + + attributes_widget = AttributesWidget(contet_widget) + contet_layout = QtWidgets.QVBoxLayout(contet_widget) + contet_layout.setContentsMargins(0, 0, 0, 0) + contet_layout.addWidget(attributes_widget, 0) + contet_layout.addStretch(1) + + # Widget showed when there are no attribute definitions from creator + empty_widget = QtWidgets.QWidget(self) + empty_widget.setVisible(False) + + # Label showed when creator is not selected + no_creator_label = QtWidgets.QLabel( + "Creator is not selected", + empty_widget + ) + no_creator_label.setWordWrap(True) + + # Creator does not have precreate attributes + empty_label = QtWidgets.QLabel( + "This creator has no configurable options", + empty_widget + ) + empty_label.setWordWrap(True) + empty_label.setVisible(False) + + empty_layout = QtWidgets.QVBoxLayout(empty_widget) + empty_layout.setContentsMargins(0, 0, 0, 0) + empty_layout.addWidget(empty_label, 0, QtCore.Qt.AlignCenter) + empty_layout.addWidget(no_creator_label, 0, QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(scroll_area, 1) + main_layout.addWidget(empty_widget, 1) + + self._scroll_area = scroll_area + self._empty_widget = empty_widget + + self._empty_label = empty_label + self._no_creator_label = no_creator_label + self._attributes_widget = attributes_widget + + def current_value(self): + return self._attributes_widget.current_value() + + def set_plugin(self, creator): + attr_defs = [] + creator_selected = False + if creator is not None: + creator_selected = True + attr_defs = creator.get_pre_create_attr_defs() + + self._attributes_widget.set_attr_defs(attr_defs) + + attr_defs_available = len(attr_defs) > 0 + self._scroll_area.setVisible(attr_defs_available) + self._empty_widget.setVisible(not attr_defs_available) + + self._empty_label.setVisible(creator_selected) + self._no_creator_label.setVisible(not creator_selected) + + +class AttributesWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(AttributesWidget, self).__init__(parent) + + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self._layout = layout + + self._widgets = [] + + def current_value(self): + output = {} + for widget in self._widgets: + attr_def = widget.attr_def + if attr_def.is_value_def: + output[attr_def.key] = widget.current_value() + return output + + def clear_attr_defs(self): + while self._layout.count(): + item = self._layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + + self._widgets = [] + + def set_attr_defs(self, attr_defs): + self.clear_attr_defs() + + row = 0 + for attr_def in attr_defs: + widget = create_widget_for_attr_def(attr_def, self) + + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + + if attr_def.label: + label_widget = QtWidgets.QLabel(attr_def.label, self) + self._layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + self._layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + self._widgets.append(widget) + + row += 1 diff --git a/openpype/tools/publisher/widgets/models.py b/openpype/tools/publisher/widgets/tasks_widget.py similarity index 55% rename from openpype/tools/publisher/widgets/models.py rename to openpype/tools/publisher/widgets/tasks_widget.py index 0cfd771ef1..a0b3a340ae 100644 --- a/openpype/tools/publisher/widgets/models.py +++ b/openpype/tools/publisher/widgets/tasks_widget.py @@ -1,62 +1,6 @@ -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() +from openpype.tools.utils.tasks_widget import TasksWidget, TASK_NAME_ROLE class TasksModel(QtGui.QStandardItemModel): @@ -75,6 +19,7 @@ class TasksModel(QtGui.QStandardItemModel): """ def __init__(self, controller): super(TasksModel, self).__init__() + self._controller = controller self._items_by_name = {} self._asset_names = [] @@ -141,6 +86,7 @@ class TasksModel(QtGui.QStandardItemModel): 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( @@ -162,40 +108,62 @@ class TasksModel(QtGui.QStandardItemModel): continue item = QtGui.QStandardItem(task_name) + item.setData(task_name, TASK_NAME_ROLE) self._items_by_name[task_name] = item new_items.append(item) root_item.appendRows(new_items) + def headerData(self, section, orientation, role=None): + if role is None: + role = QtCore.Qt.EditRole + # Show nice labels in the header + if section == 0: + if ( + role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) + and orientation == QtCore.Qt.Horizontal + ): + return "Tasks" -class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): - """Recursive proxy model. + return super(TasksModel, self).headerData(section, orientation, role) - 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() +class CreateDialogTasksWidget(TasksWidget): + def __init__(self, controller, parent): + self._controller = controller + super(CreateDialogTasksWidget, self).__init__(None, parent) - # Check current index itself - value = model.data(source_index, self.filterRole()) - if re.search(pattern, value, re.IGNORECASE): - return True + self._enabled = None - rows = model.rowCount(source_index) - for idx in range(rows): - if self.filterAcceptsRow(idx, source_index): - return True - return False + def _create_source_model(self): + return TasksModel(self._controller) - return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow( - row, parent_index - ) + def set_asset_name(self, asset_name): + current = self.get_selected_task_name() + if current: + self._last_selected_task_name = current + + self._tasks_model.set_asset_names([asset_name]) + if self._last_selected_task_name and self._enabled: + self.select_task_name(self._last_selected_task_name) + + # Force a task changed emit. + self.task_changed.emit() + + def select_task_name(self, task_name): + super(CreateDialogTasksWidget, self).select_task_name(task_name) + if not self._enabled: + current = self.get_selected_task_name() + if current: + self._last_selected_task_name = current + self._clear_selection() + + def set_enabled(self, enabled): + self._enabled = enabled + if not enabled: + last_selected_task_name = self.get_selected_task_name() + if last_selected_task_name: + self._last_selected_task_name = last_selected_task_name + self._clear_selection() + + elif self._last_selected_task_name is not None: + self.select_task_name(self._last_selected_task_name) diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index 28b3c3f95d..bb88e1783c 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -6,8 +6,8 @@ except Exception: from Qt import QtWidgets, QtCore, QtGui +from openpype.tools.utils import BaseClickableFrame from .widgets import ( - ClickableFrame, IconValuePixmapLabel ) @@ -55,7 +55,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._error_info = error_info self._selected = False - title_frame = ClickableFrame(self) + title_frame = BaseClickableFrame(self) title_frame.setObjectName("ValidationErrorTitleFrame") title_frame._mouse_release_callback = self._mouse_release_callback @@ -168,7 +168,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) -class ActionButton(ClickableFrame): +class ActionButton(BaseClickableFrame): """Plugin's action callback button. Action may have label or icon or both. diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index a85fea9cbc..a63258efb7 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -8,14 +8,17 @@ from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from openpype.widgets.attribute_defs import create_widget_for_attr_def +from openpype.tools import resources from openpype.tools.flickcharm import FlickCharm -from openpype.tools.utils import PlaceholderLineEdit -from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS -from .models import ( - AssetsHierarchyModel, - TasksModel, - RecursiveSortFilterProxyModel, +from openpype.tools.utils import ( + PlaceholderLineEdit, + IconButton, + PixmapLabel, + BaseClickableFrame ) +from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS +from .assets_widget import AssetsDialog +from .tasks_widget import TasksModel from .icons import ( get_pixmap, get_icon_path @@ -26,49 +29,14 @@ from ..constants import ( ) -class PixmapLabel(QtWidgets.QLabel): - """Label resizing image to height of font.""" - def __init__(self, pixmap, parent): - super(PixmapLabel, self).__init__(parent) - self._source_pixmap = pixmap - - def set_source_pixmap(self, pixmap): - """Change source image.""" - self._source_pixmap = pixmap - self._set_resized_pix() - - def _set_resized_pix(self): +class PublishPixmapLabel(PixmapLabel): + def _get_pix_size(self): size = self.fontMetrics().height() size += size % 2 - self.setPixmap( - self._source_pixmap.scaled( - size, - size, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - ) - - def resizeEvent(self, event): - self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) + return size, size -class TransparentPixmapLabel(QtWidgets.QLabel): - """Transparent label resizing to width and height of font.""" - def __init__(self, *args, **kwargs): - super(TransparentPixmapLabel, self).__init__(*args, **kwargs) - - def resizeEvent(self, event): - size = self.fontMetrics().height() - size += size % 2 - pix = QtGui.QPixmap(size, size) - pix.fill(QtCore.Qt.transparent) - self.setPixmap(pix) - super(TransparentPixmapLabel, self).resizeEvent(event) - - -class IconValuePixmapLabel(PixmapLabel): +class IconValuePixmapLabel(PublishPixmapLabel): """Label resizing to width and height of font. Handle icon parsing from creators/instances. Using of QAwesome module @@ -125,7 +93,7 @@ class IconValuePixmapLabel(PixmapLabel): return self._default_pixmap() -class ContextWarningLabel(PixmapLabel): +class ContextWarningLabel(PublishPixmapLabel): """Pixmap label with warning icon.""" def __init__(self, parent): pix = get_pixmap("warning") @@ -138,29 +106,6 @@ class ContextWarningLabel(PixmapLabel): self.setObjectName("FamilyIconLabel") -class IconButton(QtWidgets.QPushButton): - """PushButton with icon and size of font. - - Using font metrics height as icon size reference. - """ - - def __init__(self, *args, **kwargs): - super(IconButton, self).__init__(*args, **kwargs) - self.setObjectName("IconButton") - - def sizeHint(self): - result = super(IconButton, self).sizeHint() - icon_h = self.iconSize().height() - font_height = self.fontMetrics().height() - text_set = bool(self.text()) - if not text_set and icon_h < font_height: - new_size = result.height() - icon_h + font_height - result.setHeight(new_size) - result.setWidth(new_size) - - return result - - class PublishIconBtn(IconButton): """Button using alpha of source image to redraw with different color. @@ -314,7 +259,7 @@ class ShowPublishReportBtn(PublishIconBtn): class RemoveInstanceBtn(PublishIconBtn): """Create remove button.""" def __init__(self, parent=None): - icon_path = get_icon_path("delete") + icon_path = resources.get_icon_path("delete") super(RemoveInstanceBtn, self).__init__(icon_path, parent) self.setToolTip("Remove selected instances") @@ -359,170 +304,6 @@ class AbstractInstanceView(QtWidgets.QWidget): ).format(self.__class__.__name__)) -class ClickableFrame(QtWidgets.QFrame): - """Widget that catch left mouse click and can trigger a callback. - - Callback is defined by overriding `_mouse_release_callback`. - """ - def __init__(self, parent): - super(ClickableFrame, self).__init__(parent) - - self._mouse_pressed = False - - def _mouse_release_callback(self): - pass - - def mousePressEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - self._mouse_pressed = True - super(ClickableFrame, self).mousePressEvent(event) - - def mouseReleaseEvent(self, event): - if self._mouse_pressed: - self._mouse_pressed = False - if self.rect().contains(event.pos()): - self._mouse_release_callback() - - super(ClickableFrame, self).mouseReleaseEvent(event) - - -class AssetsDialog(QtWidgets.QDialog): - """Dialog to select asset for a context of instance.""" - def __init__(self, controller, parent): - super(AssetsDialog, self).__init__(parent) - self.setWindowTitle("Select asset") - - model = AssetsHierarchyModel(controller) - proxy_model = RecursiveSortFilterProxyModel() - proxy_model.setSourceModel(model) - proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - - filter_input = PlaceholderLineEdit(self) - filter_input.setPlaceholderText("Filter assets..") - - asset_view = QtWidgets.QTreeView(self) - asset_view.setModel(proxy_model) - asset_view.setHeaderHidden(True) - asset_view.setFrameShape(QtWidgets.QFrame.NoFrame) - asset_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) - asset_view.setAlternatingRowColors(True) - asset_view.setSelectionBehavior(QtWidgets.QTreeView.SelectRows) - asset_view.setAllColumnsShowFocus(True) - - ok_btn = QtWidgets.QPushButton("OK", self) - cancel_btn = QtWidgets.QPushButton("Cancel", self) - - btns_layout = QtWidgets.QHBoxLayout() - btns_layout.addStretch(1) - btns_layout.addWidget(ok_btn) - btns_layout.addWidget(cancel_btn) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(filter_input, 0) - layout.addWidget(asset_view, 1) - layout.addLayout(btns_layout, 0) - - filter_input.textChanged.connect(self._on_filter_change) - ok_btn.clicked.connect(self._on_ok_clicked) - cancel_btn.clicked.connect(self._on_cancel_clicked) - - self._filter_input = filter_input - self._ok_btn = ok_btn - self._cancel_btn = cancel_btn - - self._model = model - self._proxy_model = proxy_model - - self._asset_view = asset_view - - self._selected_asset = None - # Soft refresh is enabled - # - reset will happen at all cost if soft reset is enabled - # - adds ability to call reset on multiple places without repeating - self._soft_reset_enabled = True - - def showEvent(self, event): - """Refresh asset model on show.""" - super(AssetsDialog, self).showEvent(event) - # Refresh on show - self.reset(False) - - def reset(self, force=True): - """Reset asset model.""" - if not force and not self._soft_reset_enabled: - return - - if self._soft_reset_enabled: - self._soft_reset_enabled = False - - self._model.reset() - - def name_is_valid(self, name): - """Is asset name valid. - - Args: - name(str): Asset name that should be checked. - """ - # Make sure we're reset - self.reset(False) - # Valid the name by model - return self._model.name_is_valid(name) - - def _on_filter_change(self, text): - """Trigger change of filter of assets.""" - self._proxy_model.setFilterFixedString(text) - - def _on_cancel_clicked(self): - self.done(0) - - def _on_ok_clicked(self): - index = self._asset_view.currentIndex() - asset_name = None - if index.isValid(): - asset_name = index.data(QtCore.Qt.DisplayRole) - self._selected_asset = asset_name - self.done(1) - - def set_selected_assets(self, asset_names): - """Change preselected asset before showing the dialog. - - This also resets model and clean filter. - """ - self.reset(False) - self._asset_view.collapseAll() - self._filter_input.setText("") - - indexes = [] - for asset_name in asset_names: - index = self._model.get_index_by_name(asset_name) - if index.isValid(): - indexes.append(index) - - if not indexes: - return - - index_deque = collections.deque() - for index in indexes: - index_deque.append(index) - - all_indexes = [] - while index_deque: - index = index_deque.popleft() - all_indexes.append(index) - - parent_index = index.parent() - if parent_index.isValid(): - index_deque.append(parent_index) - - for index in all_indexes: - proxy_index = self._proxy_model.mapFromSource(index) - self._asset_view.expand(proxy_index) - - def get_selected_asset(self): - """Get selected asset name.""" - return self._selected_asset - - class ClickableLineEdit(QtWidgets.QLineEdit): """QLineEdit capturing left mouse click. @@ -554,7 +335,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit): event.accept() -class AssetsField(ClickableFrame): +class AssetsField(BaseClickableFrame): """Field where asset name of selected instance/s is showed. Click on the field will trigger `AssetsDialog`. @@ -1394,12 +1175,13 @@ class CreatorAttrsWidget(QtWidgets.QWidget): content_layout = QtWidgets.QFormLayout(content_widget) for attr_def, attr_instances, values in result: widget = create_widget_for_attr_def(attr_def, content_widget) - if len(values) == 1: - value = values[0] - if value is not None: - widget.set_value(values[0]) - else: - widget.set_value(values, True) + if attr_def.is_value_def: + if len(values) == 1: + value = values[0] + if value is not None: + widget.set_value(values[0]) + else: + widget.set_value(values, True) label = attr_def.label or attr_def.key content_layout.addRow(label, widget) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index bb58813e55..642bd17589 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -4,7 +4,10 @@ from openpype import ( resources, style ) -from openpype.tools.utils import PlaceholderLineEdit +from openpype.tools.utils import ( + PlaceholderLineEdit, + PixmapLabel +) from .control import PublisherController from .widgets import ( BorderedLabelWidget, @@ -14,8 +17,6 @@ from .widgets import ( InstanceListView, CreateDialog, - PixmapLabel, - StopBtn, ResetBtn, ValidateBtn, @@ -32,7 +33,7 @@ class PublisherWindow(QtWidgets.QDialog): default_width = 1000 default_height = 600 - def __init__(self, parent=None): + def __init__(self, parent=None, reset_on_show=None): super(PublisherWindow, self).__init__(parent) self.setWindowTitle("OpenPype publisher") @@ -40,6 +41,9 @@ class PublisherWindow(QtWidgets.QDialog): icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) + if reset_on_show is None: + reset_on_show = True + if parent is None: on_top_flag = QtCore.Qt.WindowStaysOnTopHint else: @@ -54,6 +58,7 @@ class PublisherWindow(QtWidgets.QDialog): | on_top_flag ) + self._reset_on_show = reset_on_show self._first_show = True self._refreshing_instances = False @@ -116,12 +121,16 @@ class PublisherWindow(QtWidgets.QDialog): subset_view_btns_layout.addWidget(change_view_btn) # Layout of view and buttons - subset_view_layout = QtWidgets.QVBoxLayout() + # - widget 'subset_view_widget' is necessary + # - only layout won't be resized automatically to minimum size hint + # on child resize request! + subset_view_widget = QtWidgets.QWidget(subset_views_widget) + subset_view_layout = QtWidgets.QVBoxLayout(subset_view_widget) subset_view_layout.setContentsMargins(0, 0, 0, 0) subset_view_layout.addLayout(subset_views_layout, 1) subset_view_layout.addLayout(subset_view_btns_layout, 0) - subset_views_widget.set_center_widget(subset_view_layout) + subset_views_widget.set_center_widget(subset_view_widget) # Whole subset layout with attributes and details subset_content_widget = QtWidgets.QWidget(subset_frame) @@ -248,7 +257,8 @@ class PublisherWindow(QtWidgets.QDialog): self._first_show = False self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) - self.reset() + if self._reset_on_show: + self.reset() def closeEvent(self, event): self.controller.save_changes() @@ -381,6 +391,12 @@ class PublisherWindow(QtWidgets.QDialog): context_title = self.controller.get_context_title() self.set_context_label(context_title) + # Give a change to process Resize Request + QtWidgets.QApplication.processEvents() + # Trigger update geometry of + widget = self.subset_views_layout.currentWidget() + widget.updateGeometry() + def _on_subset_change(self, *_args): # Ignore changes if in middle of refreshing if self._refreshing_instances: diff --git a/openpype/tools/resources/__init__.py b/openpype/tools/resources/__init__.py new file mode 100644 index 0000000000..fd5c45f901 --- /dev/null +++ b/openpype/tools/resources/__init__.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/delete.png b/openpype/tools/resources/images/delete.png similarity index 100% rename from openpype/tools/publisher/widgets/images/delete.png rename to openpype/tools/resources/images/delete.png diff --git a/openpype/tools/resources/images/file.png b/openpype/tools/resources/images/file.png new file mode 100644 index 0000000000..4b05a0665f Binary files /dev/null and b/openpype/tools/resources/images/file.png differ diff --git a/openpype/tools/resources/images/files.png b/openpype/tools/resources/images/files.png new file mode 100644 index 0000000000..667d6148ab Binary files /dev/null and b/openpype/tools/resources/images/files.png differ diff --git a/openpype/tools/resources/images/folder.png b/openpype/tools/resources/images/folder.png new file mode 100644 index 0000000000..9c55e69927 Binary files /dev/null and b/openpype/tools/resources/images/folder.png differ diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index eb0cb1eef5..ac93595682 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -3,6 +3,8 @@ from .widgets import ( BaseClickableFrame, ClickableFrame, ExpandBtn, + PixmapLabel, + IconButton, ) from .error_dialog import ErrorMessageBox @@ -11,15 +13,22 @@ from .lib import ( paint_image_with_color ) +from .models import ( + RecursiveSortFilterProxyModel, +) __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", "ExpandBtn", + "PixmapLabel", + "IconButton", "ErrorMessageBox", "WrappedCallbackItem", "paint_image_with_color", + + "RecursiveSortFilterProxyModel", ) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index 1495586b04..55e34285fc 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -635,9 +635,10 @@ class AssetsWidget(QtWidgets.QWidget): selection_model = view.selectionModel() selection_model.selectionChanged.connect(self._on_selection_change) refresh_btn.clicked.connect(self.refresh) - current_asset_btn.clicked.connect(self.set_current_session_asset) + current_asset_btn.clicked.connect(self._on_current_asset_click) view.doubleClicked.connect(self.double_clicked) + self._refresh_btn = refresh_btn self._current_asset_btn = current_asset_btn self._model = model self._proxy = proxy @@ -668,11 +669,30 @@ class AssetsWidget(QtWidgets.QWidget): def stop_refresh(self): self._model.stop_refresh() + def _get_current_session_asset(self): + return self.dbcon.Session.get("AVALON_ASSET") + + def _on_current_asset_click(self): + """Trigger change of asset to current context asset. + This separation gives ability to override this method and use it + in differnt way. + """ + self.set_current_session_asset() + def set_current_session_asset(self): - asset_name = self.dbcon.Session.get("AVALON_ASSET") + asset_name = self._get_current_session_asset() if asset_name: self.select_asset_by_name(asset_name) + def set_refresh_btn_visibility(self, visible=None): + """Hide set refresh button. + Some tools may have their global refresh button or do not support + refresh at all. + """ + if visible is None: + visible = not self._refresh_btn.isVisible() + self._refresh_btn.setVisible(visible) + def set_current_asset_btn_visibility(self, visible=None): """Hide set current asset button. @@ -727,6 +747,10 @@ class AssetsWidget(QtWidgets.QWidget): def _set_loading_state(self, loading, empty): self._view.set_loading_state(loading, empty) + def _clear_selection(self): + selection_model = self._view.selectionModel() + selection_model.clearSelection() + def _select_indexes(self, indexes): valid_indexes = [ index diff --git a/openpype/tools/utils/models.py b/openpype/tools/utils/models.py index df3eee41a2..2b5b156eeb 100644 --- a/openpype/tools/utils/models.py +++ b/openpype/tools/utils/models.py @@ -199,31 +199,37 @@ class Item(dict): class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): - """Filters to the regex if any of the children matches allow parent""" - def filterAcceptsRow(self, row, parent): + """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(): - pattern = regex.pattern() model = self.sourceModel() - source_index = model.index(row, self.filterKeyColumn(), parent) + source_index = model.index( + row, self.filterKeyColumn(), parent_index + ) if source_index.isValid(): + pattern = regex.pattern() + # Check current index itself - key = model.data(source_index, self.filterRole()) - if re.search(pattern, key, re.IGNORECASE): + value = model.data(source_index, self.filterRole()) + if re.search(pattern, value, re.IGNORECASE): return True - # Check children rows = model.rowCount(source_index) - for i in range(rows): - if self.filterAcceptsRow(i, source_index): + for idx in range(rows): + if self.filterAcceptsRow(idx, source_index): return True # Otherwise filter it return False - return super( - RecursiveSortFilterProxyModel, self - ).filterAcceptsRow(row, parent) + return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow( + row, parent_index + ) class ProjectModel(QtGui.QStandardItemModel): diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 6e6cd17ffd..6c7787d06a 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -255,6 +255,10 @@ class TasksWidget(QtWidgets.QWidget): # Force a task changed emit. self.task_changed.emit() + def _clear_selection(self): + selection_model = self._tasks_view.selectionModel() + selection_model.clearSelection() + def select_task_name(self, task_name): """Select a task by name. @@ -285,6 +289,10 @@ class TasksWidget(QtWidgets.QWidget): self._tasks_view.setCurrentIndex(index) break + last_selected_task_name = self.get_selected_task_name() + if last_selected_task_name: + self._last_selected_task_name = last_selected_task_name + def get_selected_task_name(self): """Return name of task at current index (selected) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index e82bced927..c62b838231 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -148,6 +148,65 @@ class ImageButton(QtWidgets.QPushButton): return self.iconSize() +class IconButton(QtWidgets.QPushButton): + """PushButton with icon and size of font. + + Using font metrics height as icon size reference. + """ + + def __init__(self, *args, **kwargs): + super(IconButton, self).__init__(*args, **kwargs) + self.setObjectName("IconButton") + + def sizeHint(self): + result = super(IconButton, self).sizeHint() + icon_h = self.iconSize().height() + font_height = self.fontMetrics().height() + text_set = bool(self.text()) + if not text_set and icon_h < font_height: + new_size = result.height() - icon_h + font_height + result.setHeight(new_size) + result.setWidth(new_size) + + return result + + +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap + + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() + + def _get_pix_size(self): + size = self.fontMetrics().height() + size += size % 2 + return size, size + + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) + + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) + + class OptionalMenu(QtWidgets.QMenu): """A subclass of `QtWidgets.QMenu` to work with `OptionalAction` diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index b7f9ff8786..40edec76bd 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -21,14 +21,13 @@ from openpype.tools.utils.tasks_widget import TasksWidget from openpype.tools.utils.delegates import PrettyTimeDelegate from openpype.lib import ( Anatomy, - get_workdir, get_workfile_doc, create_workfile_doc, save_workfile_data_to_doc, get_workfile_template_key, - create_workdir_extra_folders + create_workdir_extra_folders, + get_system_general_anatomy_data ) - from .model import FilesModel from .view import FilesView @@ -110,6 +109,10 @@ class NameWindow(QtWidgets.QDialog): "ext": None } + # add system general settings anatomy data + system_general_data = get_system_general_anatomy_data() + self.data.update(system_general_data) + # Store project anatomy self.anatomy = anatomy self.template = anatomy.templates[template_key]["file"] diff --git a/openpype/version.py b/openpype/version.py index 5ebc6d00c8..a2859ba2bc 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.8.2-nightly.2" +__version__ = "3.8.3-nightly.1" diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py new file mode 100644 index 0000000000..fb48528bdc --- /dev/null +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -0,0 +1,645 @@ +import os +import collections +import uuid +import clique +from Qt import QtWidgets, QtCore, QtGui + +from openpype.tools.utils import paint_image_with_color +# TODO change imports +from openpype.tools.resources import ( + get_pixmap, + get_image, +) +from openpype.tools.utils import ( + IconButton, + PixmapLabel +) + +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2 +ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3 +FILENAMES_ROLE = QtCore.Qt.UserRole + 4 +DIRPATH_ROLE = QtCore.Qt.UserRole + 5 +IS_DIR_ROLE = QtCore.Qt.UserRole + 6 +EXT_ROLE = QtCore.Qt.UserRole + 7 + + +class DropEmpty(QtWidgets.QWidget): + _drop_enabled_text = "Drag & Drop\n(drop files here)" + + def __init__(self, parent): + super(DropEmpty, self).__init__(parent) + label_widget = QtWidgets.QLabel(self._drop_enabled_text, self) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addSpacing(10) + layout.addWidget( + label_widget, + alignment=QtCore.Qt.AlignCenter + ) + layout.addSpacing(10) + + self._label_widget = label_widget + + def paintEvent(self, event): + super(DropEmpty, self).paintEvent(event) + painter = QtGui.QPainter(self) + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + painter.setPen(pen) + content_margins = self.layout().contentsMargins() + + left_m = content_margins.left() + top_m = content_margins.top() + rect = QtCore.QRect( + left_m, + top_m, + ( + self.rect().width() + - (left_m + content_margins.right() + pen.width()) + ), + ( + self.rect().height() + - (top_m + content_margins.bottom() + pen.width()) + ) + ) + painter.drawRect(rect) + + +class FilesModel(QtGui.QStandardItemModel): + sequence_exts = [ + ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal", + ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits", + ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer", + ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2", + ".jng", ".jpeg", ".jpeg-ls", ".2000", ".jpg", ".xr", + ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", + ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", + ".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras", + ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep", + ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf", + ".xpm", ".xwd" + ] + + def __init__(self): + super(FilesModel, self).__init__() + self._filenames_by_dirpath = collections.defaultdict(set) + self._items_by_dirpath = collections.defaultdict(list) + + def add_filepaths(self, filepaths): + if not filepaths: + return + + new_dirpaths = set() + for filepath in filepaths: + filename = os.path.basename(filepath) + dirpath = os.path.dirname(filepath) + filenames = self._filenames_by_dirpath[dirpath] + if filename not in filenames: + new_dirpaths.add(dirpath) + filenames.add(filename) + self._refresh_items(new_dirpaths) + + def remove_item_by_ids(self, item_ids): + if not item_ids: + return + + remaining_ids = set(item_ids) + result = collections.defaultdict(list) + for dirpath, items in self._items_by_dirpath.items(): + if not remaining_ids: + break + for item in items: + if not remaining_ids: + break + item_id = item.data(ITEM_ID_ROLE) + if item_id in remaining_ids: + remaining_ids.remove(item_id) + result[dirpath].append(item) + + if not result: + return + + dirpaths = set(result.keys()) + for dirpath, items in result.items(): + filenames_cache = self._filenames_by_dirpath[dirpath] + for item in items: + filenames = item.data(FILENAMES_ROLE) + + self._items_by_dirpath[dirpath].remove(item) + self.removeRows(item.row(), 1) + for filename in filenames: + if filename in filenames_cache: + filenames_cache.remove(filename) + + self._refresh_items(dirpaths) + + def _refresh_items(self, dirpaths=None): + if dirpaths is None: + dirpaths = set(self._items_by_dirpath.keys()) + + new_items = [] + for dirpath in dirpaths: + items_to_remove = list(self._items_by_dirpath[dirpath]) + cols, remainders = clique.assemble( + self._filenames_by_dirpath[dirpath] + ) + filtered_cols = [] + for collection in cols: + filenames = set(collection) + valid_col = True + for filename in filenames: + ext = os.path.splitext(filename)[-1] + valid_col = ext in self.sequence_exts + break + + if valid_col: + filtered_cols.append(collection) + else: + for filename in filenames: + remainders.append(filename) + + for filename in remainders: + found = False + for item in items_to_remove: + item_filenames = item.data(FILENAMES_ROLE) + if filename in item_filenames and len(item_filenames) == 1: + found = True + items_to_remove.remove(item) + break + + if found: + continue + + fullpath = os.path.join(dirpath, filename) + if os.path.isdir(fullpath): + icon_pixmap = get_pixmap(filename="folder.png") + else: + icon_pixmap = get_pixmap(filename="file.png") + label = filename + filenames = [filename] + item = self._create_item( + label, filenames, dirpath, icon_pixmap + ) + new_items.append(item) + self._items_by_dirpath[dirpath].append(item) + + for collection in filtered_cols: + filenames = set(collection) + found = False + for item in items_to_remove: + item_filenames = item.data(FILENAMES_ROLE) + if item_filenames == filenames: + found = True + items_to_remove.remove(item) + break + + if found: + continue + + col_range = collection.format("{ranges}") + label = "{}<{}>{}".format( + collection.head, col_range, collection.tail + ) + icon_pixmap = get_pixmap(filename="files.png") + item = self._create_item( + label, filenames, dirpath, icon_pixmap + ) + new_items.append(item) + self._items_by_dirpath[dirpath].append(item) + + for item in items_to_remove: + self._items_by_dirpath[dirpath].remove(item) + self.removeRows(item.row(), 1) + + if new_items: + self.invisibleRootItem().appendRows(new_items) + + def _create_item(self, label, filenames, dirpath, icon_pixmap=None): + first_filename = None + for filename in filenames: + first_filename = filename + break + ext = os.path.splitext(first_filename)[-1] + is_dir = False + if len(filenames) == 1: + filepath = os.path.join(dirpath, first_filename) + is_dir = os.path.isdir(filepath) + + item = QtGui.QStandardItem() + item.setData(str(uuid.uuid4()), ITEM_ID_ROLE) + item.setData(label, ITEM_LABEL_ROLE) + item.setData(filenames, FILENAMES_ROLE) + item.setData(dirpath, DIRPATH_ROLE) + item.setData(icon_pixmap, ITEM_ICON_ROLE) + item.setData(ext, EXT_ROLE) + item.setData(is_dir, IS_DIR_ROLE) + + return item + + +class FilesProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(FilesProxyModel, self).__init__(*args, **kwargs) + self._allow_folders = False + self._allowed_extensions = None + + def set_allow_folders(self, allow=None): + if allow is None: + allow = not self._allow_folders + + if allow == self._allow_folders: + return + self._allow_folders = allow + self.invalidateFilter() + + def set_allowed_extensions(self, extensions=None): + if extensions is not None: + extensions = set(extensions) + + if self._allowed_extensions != extensions: + self._allowed_extensions = extensions + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent_index): + model = self.sourceModel() + index = model.index(row, self.filterKeyColumn(), parent_index) + # First check if item is folder and if folders are enabled + if index.data(IS_DIR_ROLE): + if not self._allow_folders: + return False + return True + + # Check if there are any allowed extensions + if self._allowed_extensions is None: + return False + + if index.data(EXT_ROLE) not in self._allowed_extensions: + return False + return True + + def lessThan(self, left, right): + left_comparison = left.data(DIRPATH_ROLE) + right_comparison = right.data(DIRPATH_ROLE) + if left_comparison == right_comparison: + left_comparison = left.data(ITEM_LABEL_ROLE) + right_comparison = right.data(ITEM_LABEL_ROLE) + + if sorted((left_comparison, right_comparison))[0] == left_comparison: + return True + return False + + +class ItemWidget(QtWidgets.QWidget): + remove_requested = QtCore.Signal(str) + + def __init__(self, item_id, label, pixmap_icon, parent=None): + self._item_id = item_id + + super(ItemWidget, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + icon_widget = PixmapLabel(pixmap_icon, self) + label_widget = QtWidgets.QLabel(label, self) + pixmap = paint_image_with_color( + get_image(filename="delete.png"), QtCore.Qt.white + ) + remove_btn = IconButton(self) + remove_btn.setIcon(QtGui.QIcon(pixmap)) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(icon_widget, 0) + layout.addWidget(label_widget, 1) + layout.addWidget(remove_btn, 0) + + remove_btn.clicked.connect(self._on_remove_clicked) + + self._icon_widget = icon_widget + self._label_widget = label_widget + self._remove_btn = remove_btn + + def _on_remove_clicked(self): + self.remove_requested.emit(self._item_id) + + +class FilesView(QtWidgets.QListView): + """View showing instances and their groups.""" + def __init__(self, *args, **kwargs): + super(FilesView, self).__init__(*args, **kwargs) + + self.setEditTriggers(QtWidgets.QListView.NoEditTriggers) + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + + def get_selected_item_ids(self): + """Ids of selected instances.""" + selected_item_ids = set() + for index in self.selectionModel().selectedIndexes(): + instance_id = index.data(ITEM_ID_ROLE) + if instance_id is not None: + selected_item_ids.add(instance_id) + return selected_item_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(FilesView, self).event(event) + + +class MultiFilesWidget(QtWidgets.QFrame): + value_changed = QtCore.Signal() + + def __init__(self, parent): + super(MultiFilesWidget, self).__init__(parent) + self.setAcceptDrops(True) + + empty_widget = DropEmpty(self) + + files_model = FilesModel() + files_proxy_model = FilesProxyModel() + files_proxy_model.setSourceModel(files_model) + files_view = FilesView(self) + files_view.setModel(files_proxy_model) + files_view.setVisible(False) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(empty_widget, 1) + layout.addWidget(files_view, 1) + + files_proxy_model.rowsInserted.connect(self._on_rows_inserted) + files_proxy_model.rowsRemoved.connect(self._on_rows_removed) + + self._in_set_value = False + + self._empty_widget = empty_widget + self._files_model = files_model + self._files_proxy_model = files_proxy_model + self._files_view = files_view + + self._widgets_by_id = {} + + def set_value(self, value, multivalue): + self._in_set_value = True + widget_ids = set(self._widgets_by_id.keys()) + self._remove_item_by_ids(widget_ids) + + # TODO how to display multivalue? + all_same = True + if multivalue: + new_value = set() + item_row = None + for _value in value: + _value_set = set(_value) + new_value |= _value_set + if item_row is None: + item_row = _value_set + elif item_row != _value_set: + all_same = False + value = new_value + + if value: + self._add_filepaths(value) + self._in_set_value = False + + def current_value(self): + model = self._files_proxy_model + filepaths = set() + for row in range(model.rowCount()): + index = model.index(row, 0) + dirpath = index.data(DIRPATH_ROLE) + filenames = index.data(FILENAMES_ROLE) + for filename in filenames: + filepaths.add(os.path.join(dirpath, filename)) + return filepaths + + def set_filters(self, folders_allowed, exts_filter): + self._files_proxy_model.set_allow_folders(folders_allowed) + self._files_proxy_model.set_allowed_extensions(exts_filter) + + def _on_rows_inserted(self, parent_index, start_row, end_row): + for row in range(start_row, end_row + 1): + index = self._files_proxy_model.index(row, 0, parent_index) + item_id = index.data(ITEM_ID_ROLE) + if item_id in self._widgets_by_id: + continue + label = index.data(ITEM_LABEL_ROLE) + pixmap_icon = index.data(ITEM_ICON_ROLE) + + widget = ItemWidget(item_id, label, pixmap_icon) + self._files_view.setIndexWidget(index, widget) + self._files_proxy_model.setData( + index, widget.sizeHint(), QtCore.Qt.SizeHintRole + ) + widget.remove_requested.connect(self._on_remove_request) + self._widgets_by_id[item_id] = widget + + self._files_proxy_model.sort(0) + + if not self._in_set_value: + self.value_changed.emit() + + def _on_rows_removed(self, parent_index, start_row, end_row): + available_item_ids = set() + for row in range(self._files_proxy_model.rowCount()): + index = self._files_proxy_model.index(row, 0) + item_id = index.data(ITEM_ID_ROLE) + available_item_ids.add(index.data(ITEM_ID_ROLE)) + + widget_ids = set(self._widgets_by_id.keys()) + for item_id in available_item_ids: + if item_id in widget_ids: + widget_ids.remove(item_id) + + for item_id in widget_ids: + widget = self._widgets_by_id.pop(item_id) + widget.setVisible(False) + widget.deleteLater() + + if not self._in_set_value: + self.value_changed.emit() + + def _on_remove_request(self, item_id): + found_index = None + for row in range(self._files_model.rowCount()): + index = self._files_model.index(row, 0) + _item_id = index.data(ITEM_ID_ROLE) + if item_id == _item_id: + found_index = index + break + + if found_index is None: + return + + items_to_delete = self._files_view.get_selected_item_ids() + if item_id not in items_to_delete: + items_to_delete = [item_id] + + self._remove_item_by_ids(items_to_delete) + + def sizeHint(self): + # Get size hints of widget and visible widgets + result = super(MultiFilesWidget, self).sizeHint() + if not self._files_view.isVisible(): + not_visible_hint = self._files_view.sizeHint() + else: + not_visible_hint = self._empty_widget.sizeHint() + + # Get margins of this widget + margins = self.layout().contentsMargins() + + # Change size hint based on result of maximum size hint of widgets + result.setWidth(max( + result.width(), + not_visible_hint.width() + margins.left() + margins.right() + )) + result.setHeight(max( + result.height(), + not_visible_hint.height() + margins.top() + margins.bottom() + )) + + return result + + def dragEnterEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + if os.path.exists(filepath): + filepaths.append(filepath) + if filepaths: + self._add_filepaths(filepaths) + event.accept() + + def _add_filepaths(self, filepaths): + self._files_model.add_filepaths(filepaths) + self._update_visibility() + + def _remove_item_by_ids(self, item_ids): + self._files_model.remove_item_by_ids(item_ids) + self._update_visibility() + + def _update_visibility(self): + files_exists = self._files_model.rowCount() > 0 + self._files_view.setVisible(files_exists) + self._empty_widget.setVisible(not files_exists) + + +class SingleFileWidget(QtWidgets.QWidget): + value_changed = QtCore.Signal() + + def __init__(self, parent): + super(SingleFileWidget, self).__init__(parent) + + self.setAcceptDrops(True) + + filepath_input = QtWidgets.QLineEdit(self) + + browse_btn = QtWidgets.QPushButton("Browse", self) + browse_btn.setVisible(False) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(filepath_input, 1) + layout.addWidget(browse_btn, 0) + + browse_btn.clicked.connect(self._on_browse_clicked) + filepath_input.textChanged.connect(self._on_text_change) + + self._in_set_value = False + + self._filepath_input = filepath_input + self._folders_allowed = False + self._exts_filter = [] + + def set_value(self, value, multivalue): + self._in_set_value = True + + if multivalue: + set_value = set(value) + if len(set_value) == 1: + value = tuple(set_value)[0] + else: + value = "< Multiselection >" + self._filepath_input.setText(value) + + self._in_set_value = False + + def current_value(self): + return self._filepath_input.text() + + def set_filters(self, folders_allowed, exts_filter): + self._folders_allowed = folders_allowed + self._exts_filter = exts_filter + + def _on_text_change(self, text): + if not self._in_set_value: + self.value_changed.emit() + + def _on_browse_clicked(self): + # TODO implement file dialog logic in '_on_browse_clicked' + print("_on_browse_clicked") + + def dragEnterEvent(self, event): + mime_data = event.mimeData() + if not mime_data.hasUrls(): + return + + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + if os.path.exists(filepath): + filepaths.append(filepath) + + # TODO add folder, extensions check + if len(filepaths) == 1: + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + if os.path.exists(filepath): + filepaths.append(filepath) + # TODO filter check + if len(filepaths) == 1: + self.set_value(filepaths[0], False) + event.accept() diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index 1cfed08363..a6f1b8d6c9 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -1,14 +1,19 @@ import uuid + +from Qt import QtWidgets, QtCore + from openpype.pipeline.lib import ( AbtractAttrDef, UnknownDef, NumberDef, TextDef, EnumDef, - BoolDef + BoolDef, + FileDef, + UISeparatorDef, + UILabelDef ) from openpype.widgets.nice_checkbox import NiceCheckbox -from Qt import QtWidgets, QtCore def create_widget_for_attr_def(attr_def, parent=None): @@ -32,12 +37,22 @@ def create_widget_for_attr_def(attr_def, parent=None): if isinstance(attr_def, UnknownDef): return UnknownAttrWidget(attr_def, parent) + if isinstance(attr_def, FileDef): + return FileAttrWidget(attr_def, parent) + + if isinstance(attr_def, UISeparatorDef): + return SeparatorAttrWidget(attr_def, parent) + + if isinstance(attr_def, UILabelDef): + return LabelAttrWidget(attr_def, parent) + raise ValueError("Unknown attribute definition \"{}\"".format( str(type(attr_def)) )) class _BaseAttrDefWidget(QtWidgets.QWidget): + # Type 'object' may not work with older PySide versions value_changed = QtCore.Signal(object, uuid.UUID) def __init__(self, attr_def, parent): @@ -68,12 +83,36 @@ class _BaseAttrDefWidget(QtWidgets.QWidget): def set_value(self, value, multivalue=False): raise NotImplementedError( - "Method 'current_value' is not implemented. {}".format( + "Method 'set_value' is not implemented. {}".format( self.__class__.__name__ ) ) +class SeparatorAttrWidget(_BaseAttrDefWidget): + def _ui_init(self): + input_widget = QtWidgets.QWidget(self) + input_widget.setObjectName("Separator") + input_widget.setMinimumHeight(2) + input_widget.setMaximumHeight(2) + + self._input_widget = input_widget + + self.main_layout.addWidget(input_widget, 0) + + +class LabelAttrWidget(_BaseAttrDefWidget): + def _ui_init(self): + input_widget = QtWidgets.QLabel(self) + label = self.attr_def.label + if label: + input_widget.setText(str(label)) + + self._input_widget = input_widget + + self.main_layout.addWidget(input_widget, 0) + + class NumberAttrWidget(_BaseAttrDefWidget): def _ui_init(self): decimals = self.attr_def.decimals @@ -83,6 +122,9 @@ class NumberAttrWidget(_BaseAttrDefWidget): else: input_widget = QtWidgets.QSpinBox(self) + if self.attr_def.tooltip: + input_widget.setToolTip(self.attr_def.tooltip) + input_widget.setMinimum(self.attr_def.minimum) input_widget.setMaximum(self.attr_def.maximum) input_widget.setValue(self.attr_def.default) @@ -136,6 +178,9 @@ class TextAttrWidget(_BaseAttrDefWidget): ): input_widget.setPlaceholderText(self.attr_def.placeholder) + if self.attr_def.tooltip: + input_widget.setToolTip(self.attr_def.tooltip) + if self.attr_def.default: if self.multiline: input_widget.setPlainText(self.attr_def.default) @@ -184,6 +229,9 @@ class BoolAttrWidget(_BaseAttrDefWidget): input_widget = NiceCheckbox(parent=self) input_widget.setChecked(self.attr_def.default) + if self.attr_def.tooltip: + input_widget.setToolTip(self.attr_def.tooltip) + input_widget.stateChanged.connect(self._on_value_change) self._input_widget = input_widget @@ -220,6 +268,9 @@ class EnumAttrWidget(_BaseAttrDefWidget): combo_delegate = QtWidgets.QStyledItemDelegate(input_widget) input_widget.setItemDelegate(combo_delegate) + if self.attr_def.tooltip: + input_widget.setToolTip(self.attr_def.tooltip) + items = self.attr_def.items for key, label in items.items(): input_widget.addItem(label, key) @@ -281,3 +332,40 @@ class UnknownAttrWidget(_BaseAttrDefWidget): if str_value != self._value: self._value = str_value self._input_widget.setText(str_value) + + +class FileAttrWidget(_BaseAttrDefWidget): + def _ui_init(self): + self.multipath = self.attr_def.multipath + if self.multipath: + from .files_widget import MultiFilesWidget + + input_widget = MultiFilesWidget(self) + + else: + from .files_widget import SingleFileWidget + + input_widget = SingleFileWidget(self) + + if self.attr_def.tooltip: + input_widget.setToolTip(self.attr_def.tooltip) + + input_widget.set_filters( + self.attr_def.folders, self.attr_def.extensions + ) + + input_widget.value_changed.connect(self._on_value_change) + + self._input_widget = input_widget + + self.main_layout.addWidget(input_widget, 0) + + def _on_value_change(self): + new_value = self.current_value() + self.value_changed.emit(new_value, self.attr_def.id) + + def current_value(self): + return self._input_widget.current_value() + + def set_value(self, value, multivalue=False): + self._input_widget.set_value(value, multivalue) diff --git a/openpype/widgets/popup.py b/openpype/widgets/popup.py index 3c3f6283c4..e661d3d293 100644 --- a/openpype/widgets/popup.py +++ b/openpype/widgets/popup.py @@ -132,12 +132,12 @@ class Popup2(Popup): """ parent_widget = self.parent() - app = QtWidgets.QApplication.instance() + desktop = QtWidgets.QApplication.desktop() if parent_widget: - screen = app.desktop().screenNumber(parent_widget) + screen = desktop.screenNumber(parent_widget) else: - screen = app.desktop().screenNumber(app.desktop().cursor().pos()) - center_point = app.desktop().screenGeometry(screen).center() + screen = desktop.screenNumber(desktop.cursor().pos()) + center_point = desktop.screenGeometry(screen).center() frame_geo = self.frameGeometry() frame_geo.moveCenter(center_point) diff --git a/pyproject.toml b/pyproject.toml index 68713802cf..5a2ebe3aa0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.8.2-nightly.2" # OpenPype +version = "3.8.3-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/website/docs/admin_distribute.md b/website/docs/admin_distribute.md index f3778249f8..9e9d21998d 100644 --- a/website/docs/admin_distribute.md +++ b/website/docs/admin_distribute.md @@ -32,8 +32,8 @@ You have two ways of making this happen #### Automatic Updates -Every time an Artist launches OpenPype on their workstation, it will look to a pre-defined -[openPype update location](#self) for any versions that are newer than the +Every time and Artist launches OpenPype on their workstation, it will look to a pre-defined +[openPype update location](admin_settings_system.md#openpype-deployment-control) for any versions that are newer than the latest, locally installed version. If such version is found, it will be downloaded, automatically extracted to the correct place and launched. This will become the default version to run for the artist, until a higher version is detected in the update location again. diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 05a231c21a..93bf32798f 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -12,7 +12,7 @@ sidebar_label: Maya Render Settings Validator is here to make sure artists will submit renders we correct settings. Some of these settings are needed by OpenPype but some -can be defined by TD using [OpenPype Settings UI](admin_settings). +can be defined by TD using [OpenPype Settings UI](admin_settings.md). OpenPype enforced settings include: diff --git a/website/docs/admin_hosts_tvpaint.md b/website/docs/admin_hosts_tvpaint.md index a900ce96a8..6c86a06686 100644 --- a/website/docs/admin_hosts_tvpaint.md +++ b/website/docs/admin_hosts_tvpaint.md @@ -10,13 +10,13 @@ import TabItem from '@theme/TabItem'; ## Subset name templates Definition of possible subset name templates in TVPaint integration. -### [Render Layer](artist_hosts_tvpaint#render-layer) +### Render Layer Render layer has additional keys for subset name template. It is possible to use **render_layer** and **render_pass**. - Key **render_layer** is alias for variant (user's input). - For key **render_pass** is used predefined value `"Beauty"` (ATM value can't be changed). -### [Render pass](artist_hosts_tvpaint#render-pass) +### Render pass Render pass has additional keys for subset name template. It is possible to use **render_layer** and **render_pass**. - Key **render_layer** is filled with value of **render_pass** from `renderLayer` group. - Key **render_pass** is alias for variant (user's input). @@ -26,5 +26,5 @@ It is recommended to use same subset name template for both **renderLayer** and - Example template: `"{family}{Task}_{Render_layer}_{Render_pass}"` ::: -### [Review](artist_hosts_tvpaint#review) and [Workfile](artist_hosts_tvpaint#workfile) +### Review and Workfile Families **review** and **workfile** are not manually created but are automatically generated during publishing. That's why it is recommended to not use **variant** key in their subset name template. diff --git a/website/docs/admin_openpype_commands.md b/website/docs/admin_openpype_commands.md index c17bca70ad..74cb895ac9 100644 --- a/website/docs/admin_openpype_commands.md +++ b/website/docs/admin_openpype_commands.md @@ -26,7 +26,7 @@ openpype_console --use-version=3.0.0-foo+bar `--validate-version` to validate integrity of given version -For more information [see here](admin_use#run-openpype). +For more information [see here](admin_use.md#run-openpype). ## Commands @@ -85,8 +85,8 @@ openpype_console eventserver --ftrack-url= --ftrack-user= --ftrack-ap | `--asset` | Asset name (default taken from `AVALON_ASSET` if set) | | `--task` | Task name (default taken from `AVALON_TASK` is set) | | `--tools` | *Optional: Additional tools to add* | -| `--user` | *Optional: User on behalf to run* | -| `--ftrack-server` / `-fs` | *Optional: Ftrack server URL* | +| `--user` | *Optional: User on behalf to run* | +| `--ftrack-server` / `-fs` | *Optional: Ftrack server URL* | | `--ftrack-user` / `-fu` | *Optional: Ftrack user* | | `--ftrack-key` / `-fk` | *Optional: Ftrack API key* | @@ -166,3 +166,6 @@ Takes path to unzipped and possibly modified OpenPype version. Files will be zipped, checksums recalculated and version will be determined by folder name (and written to `version.py`). +```shell +./openpype_console repack-version /path/to/some/modified/unzipped/version/openpype-v3.8.3-modified +``` diff --git a/website/docs/admin_settings.md b/website/docs/admin_settings.md index abc64a1cc9..ba4bb1a9be 100644 --- a/website/docs/admin_settings.md +++ b/website/docs/admin_settings.md @@ -9,11 +9,10 @@ import TabItem from '@theme/TabItem'; OpenPype stores all of it's settings and configuration in the mongo database. To make the configuration as easy as possible we provide a robust GUI where you can access and change everything that is configurable -**Settings** GUI can be started from the tray menu. +**Settings** GUI can be started from the tray menu Admin -> Studio Settings. Please keep in mind that these settings are set-up for the full studio and not per-individual. If you're looking for individual artist settings, you can head to -[Local Settings](#local-settings) section in the artist documentation. - +[Local Settings](admin_settings_local.md) section in the artist documentation. ## Categories @@ -23,16 +22,18 @@ We use simple colour coding to show you any changes to the settings: - **Orange**: [Project Override](#project-overrides) - **Blue**: Changed and unsaved value +![Colour coding](assets\settings\settings_colour_coding.png) + You'll find that settings are split into categories: -### [System](admin_settings_system) +### System System sections contains all settings that can be configured on a studio level, but cannot be changed on a per-project basis. These include mostly high level options like path to mongo database, toggling major modules on and off and configuring studio wide application availability. -### [Project](admin_settings_project) +### Project Project tab contains most of OpenPype settings and all of them can be configured and overridden on a per-project basis if need be. This includes most of the workflow behaviors like what formats to export, naming conventions, publishing validations, automatic assets loaders and a lot more. diff --git a/website/docs/admin_settings_local.md b/website/docs/admin_settings_local.md new file mode 100644 index 0000000000..b254beb53b --- /dev/null +++ b/website/docs/admin_settings_local.md @@ -0,0 +1,28 @@ +--- +id: admin_settings_local +title: Working with local settings +sidebar_label: Working with local settings +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +OpenPype stores some of it's settings and configuration in local file system. These settings are specific to each individual machine and provides the mechanism for local overrides + +**Local Settings** GUI can be started from the tray menu. + +![Local Settings](assets/settings/settings_local.png) + +## Categories + +### OpenPype Mongo URL + +### General + +### Experimental tools + +### Applications + +### Project Settings + + diff --git a/website/docs/admin_settings_system.md b/website/docs/admin_settings_system.md index 7310de9562..8daba91db1 100644 --- a/website/docs/admin_settings_system.md +++ b/website/docs/admin_settings_system.md @@ -28,13 +28,13 @@ Uses `subst` command, if configured volume character in `Destination` field alre ### OpenPype deployment control **`Versions Repository`** - Location where automatic update mechanism searches for zip files with -OpenPype update packages. To read more about preparing OpenPype for automatic updates go to [Admin Distribute docs](admin_distribute#2-openpype-codebase) +OpenPype update packages. To read more about preparing OpenPype for automatic updates go to [Admin Distribute docs](admin_distribute.md#2-openpype-codebase) **`Production version`** - Define what is current production version. When value is not set then latest version available in versions repository is resolved as production version. **`Staging version`** - Define what is current staging version. When value is not set then latest staging version available in versions repository is resolved as staging version. -For more information about Production and Staging go to [Distribute](admin_distribute#staging-vs-production). +For more information about Production and Staging go to [Distribute](admin_distribute.md#staging-vs-production). **Production version** and **Staging version** fields will define which version will be used in studio. Filling explicit version will force new OpenPype processes to use it. That gives more control over studio deployment especially when some workstations don't have access to version repository (e.g. remote users). It can be also used to downgrade studio version when newer version have production breaking bug. diff --git a/website/docs/artist_hosts_blender.md b/website/docs/artist_hosts_blender.md index 877e99bff4..cfbcced22f 100644 --- a/website/docs/artist_hosts_blender.md +++ b/website/docs/artist_hosts_blender.md @@ -55,12 +55,12 @@ can edit that field to change it to different asset (but that one must already e `Subset` field is a name you can decide on. It should describe what kind of data you have in the model. For example, you can name it `Proxy` to indicate that this is -low resolution stuff. See [Subset](artist_concepts#subset). +low resolution stuff. See [Subset](artist_concepts.md#subset).