diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6ed6ae428c..96e768e420 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,6 +6,8 @@ labels: bug assignees: '' --- +**Running version** +[ex. 3.14.1-nightly.2] **Describe the bug** A clear and concise description of what the bug is. diff --git a/CHANGELOG.md b/CHANGELOG.md index 46bf56f5bd..dca0e7ecef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,96 @@ # Changelog +## [3.14.4-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.3...HEAD) + +**🚀 Enhancements** + +- General: Set root environments before DCC launch [\#3947](https://github.com/pypeclub/OpenPype/pull/3947) +- Refactor: changed legacy way to update database for Hero version integrate [\#3941](https://github.com/pypeclub/OpenPype/pull/3941) +- Maya: Moved plugin from global to maya [\#3939](https://github.com/pypeclub/OpenPype/pull/3939) +- Fusion: Implement Alembic and FBX mesh loader [\#3927](https://github.com/pypeclub/OpenPype/pull/3927) +- Publisher: Instances can be marked as stored [\#3846](https://github.com/pypeclub/OpenPype/pull/3846) + +**🐛 Bug fixes** + +- Maya: Deadline OutputFilePath hack regression for Renderman [\#3950](https://github.com/pypeclub/OpenPype/pull/3950) +- Houdini: Fix validate workfile paths for non-parm file references [\#3948](https://github.com/pypeclub/OpenPype/pull/3948) +- Photoshop: missed sync published version of workfile with workfile [\#3946](https://github.com/pypeclub/OpenPype/pull/3946) +- Maya: fix regression of Renderman Deadline hack [\#3943](https://github.com/pypeclub/OpenPype/pull/3943) +- Tray: Change order of attribute changes [\#3938](https://github.com/pypeclub/OpenPype/pull/3938) +- AttributeDefs: Fix crashing multivalue of files widget [\#3937](https://github.com/pypeclub/OpenPype/pull/3937) +- General: Fix links query on hero version [\#3900](https://github.com/pypeclub/OpenPype/pull/3900) +- Publisher: Files Drag n Drop cleanup [\#3888](https://github.com/pypeclub/OpenPype/pull/3888) +- Maya: Render settings validation attribute check tweak logging [\#3821](https://github.com/pypeclub/OpenPype/pull/3821) + +**🔀 Refactored code** + +- General: Direct settings imports [\#3934](https://github.com/pypeclub/OpenPype/pull/3934) +- General: import 'Logger' from 'openpype.lib' [\#3926](https://github.com/pypeclub/OpenPype/pull/3926) + +**Merged pull requests:** + +- Maya + Yeti: Load Yeti Cache fix frame number recognition [\#3942](https://github.com/pypeclub/OpenPype/pull/3942) +- Fusion: Implement callbacks to Fusion's event system thread [\#3928](https://github.com/pypeclub/OpenPype/pull/3928) +- Photoshop: create single frame image in Ftrack as review [\#3908](https://github.com/pypeclub/OpenPype/pull/3908) +- Maya: Warn correctly about nodes in render instance with unexpected names [\#3816](https://github.com/pypeclub/OpenPype/pull/3816) + +## [3.14.3](https://github.com/pypeclub/OpenPype/tree/3.14.3) (2022-10-03) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.14.3-nightly.7...3.14.3) + +**🚀 Enhancements** + +- Publisher: Enhancement proposals [\#3897](https://github.com/pypeclub/OpenPype/pull/3897) +- Maya: better logging in Maketx [\#3886](https://github.com/pypeclub/OpenPype/pull/3886) +- Photoshop: review can be turned off [\#3885](https://github.com/pypeclub/OpenPype/pull/3885) +- TrayPublisher: added persisting of last selected project [\#3871](https://github.com/pypeclub/OpenPype/pull/3871) +- TrayPublisher: added text filter on project name to Tray Publisher [\#3867](https://github.com/pypeclub/OpenPype/pull/3867) +- Github issues adding `running version` section [\#3864](https://github.com/pypeclub/OpenPype/pull/3864) +- Publisher: Increase size of main window [\#3862](https://github.com/pypeclub/OpenPype/pull/3862) +- Flame: make migratable projects after creation [\#3860](https://github.com/pypeclub/OpenPype/pull/3860) +- Photoshop: synchronize image version with workfile [\#3854](https://github.com/pypeclub/OpenPype/pull/3854) +- General: Transcoding handle float2 attr type [\#3849](https://github.com/pypeclub/OpenPype/pull/3849) +- General: Simple script for getting license information about used packages [\#3843](https://github.com/pypeclub/OpenPype/pull/3843) +- General: Workfile template build enhancements [\#3838](https://github.com/pypeclub/OpenPype/pull/3838) +- General: lock task workfiles when they are working on [\#3810](https://github.com/pypeclub/OpenPype/pull/3810) + +**🐛 Bug fixes** + +- Maya: Fix Render single camera validator [\#3929](https://github.com/pypeclub/OpenPype/pull/3929) +- Flame: loading multilayer exr to batch/reel is working [\#3901](https://github.com/pypeclub/OpenPype/pull/3901) +- Hiero: Fix inventory check on launch [\#3895](https://github.com/pypeclub/OpenPype/pull/3895) +- WebPublisher: Fix import after refactor [\#3891](https://github.com/pypeclub/OpenPype/pull/3891) +- TVPaint: Fix renaming of rendered files [\#3882](https://github.com/pypeclub/OpenPype/pull/3882) +- Publisher: Nice checkbox visible in Python 2 [\#3877](https://github.com/pypeclub/OpenPype/pull/3877) +- Settings: Add missing default settings [\#3870](https://github.com/pypeclub/OpenPype/pull/3870) +- General: Copy of workfile does not use 'copy' function but 'copyfile' [\#3869](https://github.com/pypeclub/OpenPype/pull/3869) +- Tray Publisher: skip plugin if otioTimeline is missing [\#3856](https://github.com/pypeclub/OpenPype/pull/3856) +- Flame: retimed attributes are integrated with settings [\#3855](https://github.com/pypeclub/OpenPype/pull/3855) +- Maya: Extract Playblast fix textures + labelize viewport show settings [\#3852](https://github.com/pypeclub/OpenPype/pull/3852) + +**🔀 Refactored code** + +- Maya: Remove unused 'openpype.api' imports in plugins [\#3925](https://github.com/pypeclub/OpenPype/pull/3925) +- Resolve: Use new Extractor location [\#3918](https://github.com/pypeclub/OpenPype/pull/3918) +- Unreal: Use new Extractor location [\#3917](https://github.com/pypeclub/OpenPype/pull/3917) +- Flame: Use new Extractor location [\#3916](https://github.com/pypeclub/OpenPype/pull/3916) +- Houdini: Use new Extractor location [\#3894](https://github.com/pypeclub/OpenPype/pull/3894) +- Harmony: Use new Extractor location [\#3893](https://github.com/pypeclub/OpenPype/pull/3893) +- Hiero: Use new Extractor location [\#3851](https://github.com/pypeclub/OpenPype/pull/3851) +- Maya: Remove old legacy \(ftrack\) plug-ins that are of no use anymore [\#3819](https://github.com/pypeclub/OpenPype/pull/3819) +- Nuke: Use new Extractor location [\#3799](https://github.com/pypeclub/OpenPype/pull/3799) + +**Merged pull requests:** + +- Maya: Fix Scene Inventory possibly starting off-screen due to maya preferences [\#3923](https://github.com/pypeclub/OpenPype/pull/3923) +- Maya: RenderSettings set default image format for V-Ray+Redshift to exr [\#3879](https://github.com/pypeclub/OpenPype/pull/3879) +- Remove lockfile during publish [\#3874](https://github.com/pypeclub/OpenPype/pull/3874) + ## [3.14.2](https://github.com/pypeclub/OpenPype/tree/3.14.2) (2022-09-12) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.14.1...3.14.2) - -**🆕 New features** - -- Nuke: Build workfile by template [\#3763](https://github.com/pypeclub/OpenPype/pull/3763) -- Houdini: Publishing workfiles [\#3697](https://github.com/pypeclub/OpenPype/pull/3697) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.14.2-nightly.5...3.14.2) **🚀 Enhancements** @@ -15,9 +98,6 @@ - Flame: OpenPype submenu to batch and media manager [\#3825](https://github.com/pypeclub/OpenPype/pull/3825) - General: Better pixmap scaling [\#3809](https://github.com/pypeclub/OpenPype/pull/3809) - Photoshop: attempt to speed up ExtractImage [\#3793](https://github.com/pypeclub/OpenPype/pull/3793) -- SyncServer: Added cli commands for sync server [\#3765](https://github.com/pypeclub/OpenPype/pull/3765) -- Kitsu: Drop 'entities root' setting. [\#3739](https://github.com/pypeclub/OpenPype/pull/3739) -- git: update gitignore [\#3722](https://github.com/pypeclub/OpenPype/pull/3722) **🐛 Bug fixes** @@ -27,105 +107,15 @@ - Igniter: Fix status handling when version is already installed [\#3804](https://github.com/pypeclub/OpenPype/pull/3804) - Resolve: Addon import is Python 2 compatible [\#3798](https://github.com/pypeclub/OpenPype/pull/3798) - Hiero: retimed clip publishing is working [\#3792](https://github.com/pypeclub/OpenPype/pull/3792) -- nuke: validate write node is not failing due wrong type [\#3780](https://github.com/pypeclub/OpenPype/pull/3780) -- Fix - changed format of version string in pyproject.toml [\#3777](https://github.com/pypeclub/OpenPype/pull/3777) -- Ftrack status fix typo prgoress -\> progress [\#3761](https://github.com/pypeclub/OpenPype/pull/3761) -- Fix version resolution [\#3757](https://github.com/pypeclub/OpenPype/pull/3757) - -**🔀 Refactored code** - -- Photoshop: Use new Extractor location [\#3789](https://github.com/pypeclub/OpenPype/pull/3789) -- Blender: Use new Extractor location [\#3787](https://github.com/pypeclub/OpenPype/pull/3787) -- AfterEffects: Use new Extractor location [\#3784](https://github.com/pypeclub/OpenPype/pull/3784) -- General: Remove unused teshost [\#3773](https://github.com/pypeclub/OpenPype/pull/3773) -- General: Copied 'Extractor' plugin to publish pipeline [\#3771](https://github.com/pypeclub/OpenPype/pull/3771) -- General: Move queries of asset and representation links [\#3770](https://github.com/pypeclub/OpenPype/pull/3770) -- General: Move create project folders to pipeline [\#3768](https://github.com/pypeclub/OpenPype/pull/3768) -- General: Create project function moved to client code [\#3766](https://github.com/pypeclub/OpenPype/pull/3766) -- Maya: Refactor submit deadline to use AbstractSubmitDeadline [\#3759](https://github.com/pypeclub/OpenPype/pull/3759) -- General: Change publish template settings location [\#3755](https://github.com/pypeclub/OpenPype/pull/3755) -- General: Move hostdirname functionality into host [\#3749](https://github.com/pypeclub/OpenPype/pull/3749) -- Photoshop: Defined photoshop as addon [\#3736](https://github.com/pypeclub/OpenPype/pull/3736) -- Houdini: Define houdini as addon [\#3735](https://github.com/pypeclub/OpenPype/pull/3735) -- Fusion: Defined fusion as addon [\#3733](https://github.com/pypeclub/OpenPype/pull/3733) -- Flame: Defined flame as addon [\#3732](https://github.com/pypeclub/OpenPype/pull/3732) -- Blender: Define blender as module [\#3729](https://github.com/pypeclub/OpenPype/pull/3729) -- Resolve: Define resolve as addon [\#3727](https://github.com/pypeclub/OpenPype/pull/3727) - -**Merged pull requests:** - -- Standalone Publisher: Ignore empty labels, then still use name like other asset models [\#3779](https://github.com/pypeclub/OpenPype/pull/3779) -- Kitsu - sync\_all\_project - add list ignore\_projects [\#3776](https://github.com/pypeclub/OpenPype/pull/3776) ## [3.14.1](https://github.com/pypeclub/OpenPype/tree/3.14.1) (2022-08-30) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.14.1-nightly.4...3.14.1) -### 📖 Documentation - -- Documentation: Few updates [\#3698](https://github.com/pypeclub/OpenPype/pull/3698) - -**🚀 Enhancements** - -- General: Thumbnail can use project roots [\#3750](https://github.com/pypeclub/OpenPype/pull/3750) -- Settings: Remove settings lock on tray exit [\#3720](https://github.com/pypeclub/OpenPype/pull/3720) -- General: Added helper getters to modules manager [\#3712](https://github.com/pypeclub/OpenPype/pull/3712) -- Unreal: Define unreal as module and use host class [\#3701](https://github.com/pypeclub/OpenPype/pull/3701) -- Settings: Lock settings UI session [\#3700](https://github.com/pypeclub/OpenPype/pull/3700) -- General: Benevolent context label collector [\#3686](https://github.com/pypeclub/OpenPype/pull/3686) - -**🐛 Bug fixes** - -- Maya: Fix typo in getPanel argument `with\_focus` -\> `withFocus` [\#3753](https://github.com/pypeclub/OpenPype/pull/3753) -- General: Smaller fixes of imports [\#3748](https://github.com/pypeclub/OpenPype/pull/3748) -- General: Logger tweaks [\#3741](https://github.com/pypeclub/OpenPype/pull/3741) -- Nuke: missing job dependency if multiple bake streams [\#3737](https://github.com/pypeclub/OpenPype/pull/3737) -- Nuke: color-space settings from anatomy is working [\#3721](https://github.com/pypeclub/OpenPype/pull/3721) -- Settings: Fix studio default anatomy save [\#3716](https://github.com/pypeclub/OpenPype/pull/3716) -- Maya: Use project name instead of project code [\#3709](https://github.com/pypeclub/OpenPype/pull/3709) -- Settings: Fix project overrides save [\#3708](https://github.com/pypeclub/OpenPype/pull/3708) -- Workfiles tool: Fix published workfile filtering [\#3704](https://github.com/pypeclub/OpenPype/pull/3704) -- PS, AE: Provide default variant value for workfile subset [\#3703](https://github.com/pypeclub/OpenPype/pull/3703) -- Flame: retime is working on clip publishing [\#3684](https://github.com/pypeclub/OpenPype/pull/3684) - -**🔀 Refactored code** - -- General: Move delivery logic to pipeline [\#3751](https://github.com/pypeclub/OpenPype/pull/3751) -- General: Move publish utils to pipeline [\#3745](https://github.com/pypeclub/OpenPype/pull/3745) -- General: Host addons cleanup [\#3744](https://github.com/pypeclub/OpenPype/pull/3744) -- Webpublisher: Webpublisher is used as addon [\#3740](https://github.com/pypeclub/OpenPype/pull/3740) -- Harmony: Defined harmony as addon [\#3734](https://github.com/pypeclub/OpenPype/pull/3734) -- General: Module interfaces cleanup [\#3731](https://github.com/pypeclub/OpenPype/pull/3731) -- AfterEffects: Move AE functions from general lib [\#3730](https://github.com/pypeclub/OpenPype/pull/3730) -- AfterEffects: Define AfterEffects as module [\#3728](https://github.com/pypeclub/OpenPype/pull/3728) -- General: Replace PypeLogger with Logger [\#3725](https://github.com/pypeclub/OpenPype/pull/3725) -- Nuke: Define nuke as module [\#3724](https://github.com/pypeclub/OpenPype/pull/3724) -- General: Move subset name functionality [\#3723](https://github.com/pypeclub/OpenPype/pull/3723) -- General: Move creators plugin getter [\#3714](https://github.com/pypeclub/OpenPype/pull/3714) -- General: Move constants from lib to client [\#3713](https://github.com/pypeclub/OpenPype/pull/3713) -- Loader: Subset groups using client operations [\#3710](https://github.com/pypeclub/OpenPype/pull/3710) -- TVPaint: Defined as module [\#3707](https://github.com/pypeclub/OpenPype/pull/3707) -- StandalonePublisher: Define StandalonePublisher as module [\#3706](https://github.com/pypeclub/OpenPype/pull/3706) -- TrayPublisher: Define TrayPublisher as module [\#3705](https://github.com/pypeclub/OpenPype/pull/3705) -- General: Move context specific functions to context tools [\#3702](https://github.com/pypeclub/OpenPype/pull/3702) - -**Merged pull requests:** - -- Hiero: Define hiero as module [\#3717](https://github.com/pypeclub/OpenPype/pull/3717) -- Deadline: better logging for DL webservice failures [\#3694](https://github.com/pypeclub/OpenPype/pull/3694) - ## [3.14.0](https://github.com/pypeclub/OpenPype/tree/3.14.0) (2022-08-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.14.0-nightly.1...3.14.0) -**🚀 Enhancements** - -- Ftrack: Addiotional component metadata [\#3685](https://github.com/pypeclub/OpenPype/pull/3685) - -**🐛 Bug fixes** - -- General: Switch from hero version to versioned works [\#3691](https://github.com/pypeclub/OpenPype/pull/3691) - ## [3.13.0](https://github.com/pypeclub/OpenPype/tree/3.13.0) (2022-08-09) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.13.0-nightly.1...3.13.0) diff --git a/common/openpype_common/distribution/README.md b/common/openpype_common/distribution/README.md new file mode 100644 index 0000000000..212eb267b8 --- /dev/null +++ b/common/openpype_common/distribution/README.md @@ -0,0 +1,18 @@ +Addon distribution tool +------------------------ + +Code in this folder is backend portion of Addon distribution logic for v4 server. + +Each host, module will be separate Addon in the future. Each v4 server could run different set of Addons. + +Client (running on artist machine) will in the first step ask v4 for list of enabled addons. +(It expects list of json documents matching to `addon_distribution.py:AddonInfo` object.) +Next it will compare presence of enabled addon version in local folder. In the case of missing version of +an addon, client will use information in the addon to download (from http/shared local disk/git) zip file +and unzip it. + +Required part of addon distribution will be sharing of dependencies (python libraries, utilities) which is not part of this folder. + +Location of this folder might change in the future as it will be required for a clint to add this folder to sys.path reliably. + +This code needs to be independent on Openpype code as much as possible! \ No newline at end of file diff --git a/common/openpype_common/distribution/__init__.py b/common/openpype_common/distribution/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/openpype_common/distribution/addon_distribution.py b/common/openpype_common/distribution/addon_distribution.py new file mode 100644 index 0000000000..5e48639dec --- /dev/null +++ b/common/openpype_common/distribution/addon_distribution.py @@ -0,0 +1,208 @@ +import os +from enum import Enum +from abc import abstractmethod +import attr +import logging +import requests +import platform +import shutil + +from .file_handler import RemoteFileHandler +from .addon_info import AddonInfo + + +class UpdateState(Enum): + EXISTS = "exists" + UPDATED = "updated" + FAILED = "failed" + + +class AddonDownloader: + log = logging.getLogger(__name__) + + def __init__(self): + self._downloaders = {} + + def register_format(self, downloader_type, downloader): + self._downloaders[downloader_type.value] = downloader + + def get_downloader(self, downloader_type): + downloader = self._downloaders.get(downloader_type) + if not downloader: + raise ValueError(f"{downloader_type} not implemented") + return downloader() + + @classmethod + @abstractmethod + def download(cls, source, destination): + """Returns url to downloaded addon zip file. + + Args: + source (dict): {type:"http", "url":"https://} ...} + destination (str): local folder to unzip + Returns: + (str) local path to addon zip file + """ + pass + + @classmethod + def check_hash(cls, addon_path, addon_hash): + """Compares 'hash' of downloaded 'addon_url' file. + + Args: + addon_path (str): local path to addon zip file + addon_hash (str): sha256 hash of zip file + Raises: + ValueError if hashes doesn't match + """ + if not os.path.exists(addon_path): + raise ValueError(f"{addon_path} doesn't exist.") + if not RemoteFileHandler.check_integrity(addon_path, + addon_hash, + hash_type="sha256"): + raise ValueError(f"{addon_path} doesn't match expected hash.") + + @classmethod + def unzip(cls, addon_zip_path, destination): + """Unzips local 'addon_zip_path' to 'destination'. + + Args: + addon_zip_path (str): local path to addon zip file + destination (str): local folder to unzip + """ + RemoteFileHandler.unzip(addon_zip_path, destination) + os.remove(addon_zip_path) + + @classmethod + def remove(cls, addon_url): + pass + + +class OSAddonDownloader(AddonDownloader): + + @classmethod + def download(cls, source, destination): + # OS doesnt need to download, unzip directly + addon_url = source["path"].get(platform.system().lower()) + if not os.path.exists(addon_url): + raise ValueError("{} is not accessible".format(addon_url)) + return addon_url + + +class HTTPAddonDownloader(AddonDownloader): + CHUNK_SIZE = 100000 + + @classmethod + def download(cls, source, destination): + source_url = source["url"] + cls.log.debug(f"Downloading {source_url} to {destination}") + file_name = os.path.basename(destination) + _, ext = os.path.splitext(file_name) + if (ext.replace(".", '') not + in set(RemoteFileHandler.IMPLEMENTED_ZIP_FORMATS)): + file_name += ".zip" + RemoteFileHandler.download_url(source_url, + destination, + filename=file_name) + + return os.path.join(destination, file_name) + + +def get_addons_info(server_endpoint): + """Returns list of addon information from Server""" + # TODO temp + # addon_info = AddonInfo( + # **{"name": "openpype_slack", + # "version": "1.0.0", + # "addon_url": "c:/projects/openpype_slack_1.0.0.zip", + # "type": UrlType.FILESYSTEM, + # "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658"}) # noqa + # + # http_addon = AddonInfo( + # **{"name": "openpype_slack", + # "version": "1.0.0", + # "addon_url": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing", # noqa + # "type": UrlType.HTTP, + # "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658"}) # noqa + + response = requests.get(server_endpoint) + if not response.ok: + raise Exception(response.text) + + addons_info = [] + for addon in response.json(): + addons_info.append(AddonInfo(**addon)) + return addons_info + + +def update_addon_state(addon_infos, destination_folder, factory, + log=None): + """Loops through all 'addon_infos', compares local version, unzips. + + Loops through server provided list of dictionaries with information about + available addons. Looks if each addon is already present and deployed. + If isn't, addon zip gets downloaded and unzipped into 'destination_folder'. + Args: + addon_infos (list of AddonInfo) + destination_folder (str): local path + factory (AddonDownloader): factory to get appropriate downloader per + addon type + log (logging.Logger) + Returns: + (dict): {"addon_full_name": UpdateState.value + (eg. "exists"|"updated"|"failed") + """ + if not log: + log = logging.getLogger(__name__) + + download_states = {} + for addon in addon_infos: + full_name = "{}_{}".format(addon.name, addon.version) + addon_dest = os.path.join(destination_folder, full_name) + + if os.path.isdir(addon_dest): + log.debug(f"Addon version folder {addon_dest} already exists.") + download_states[full_name] = UpdateState.EXISTS.value + continue + + for source in addon.sources: + download_states[full_name] = UpdateState.FAILED.value + try: + downloader = factory.get_downloader(source.type) + zip_file_path = downloader.download(attr.asdict(source), + addon_dest) + downloader.check_hash(zip_file_path, addon.hash) + downloader.unzip(zip_file_path, addon_dest) + download_states[full_name] = UpdateState.UPDATED.value + break + except Exception: + log.warning(f"Error happened during updating {addon.name}", + exc_info=True) + if os.path.isdir(addon_dest): + log.debug(f"Cleaning {addon_dest}") + shutil.rmtree(addon_dest) + + return download_states + + +def check_addons(server_endpoint, addon_folder, downloaders): + """Main entry point to compare existing addons with those on server. + + Args: + server_endpoint (str): url to v4 server endpoint + addon_folder (str): local dir path for addons + downloaders (AddonDownloader): factory of downloaders + + Raises: + (RuntimeError) if any addon failed update + """ + addons_info = get_addons_info(server_endpoint) + result = update_addon_state(addons_info, + addon_folder, + downloaders) + if UpdateState.FAILED.value in result.values(): + raise RuntimeError(f"Unable to update some addons {result}") + + +def cli(*args): + raise NotImplementedError diff --git a/common/openpype_common/distribution/addon_info.py b/common/openpype_common/distribution/addon_info.py new file mode 100644 index 0000000000..00ece11f3b --- /dev/null +++ b/common/openpype_common/distribution/addon_info.py @@ -0,0 +1,80 @@ +import attr +from enum import Enum + + +class UrlType(Enum): + HTTP = "http" + GIT = "git" + FILESYSTEM = "filesystem" + + +@attr.s +class MultiPlatformPath(object): + windows = attr.ib(default=None) + linux = attr.ib(default=None) + darwin = attr.ib(default=None) + + +@attr.s +class AddonSource(object): + type = attr.ib() + + +@attr.s +class LocalAddonSource(AddonSource): + path = attr.ib(default=attr.Factory(MultiPlatformPath)) + + +@attr.s +class WebAddonSource(AddonSource): + url = attr.ib(default=None) + + +@attr.s +class VersionData(object): + version_data = attr.ib(default=None) + + +@attr.s +class AddonInfo(object): + """Object matching json payload from Server""" + name = attr.ib() + version = attr.ib() + title = attr.ib(default=None) + sources = attr.ib(default=attr.Factory(dict)) + hash = attr.ib(default=None) + description = attr.ib(default=None) + license = attr.ib(default=None) + authors = attr.ib(default=None) + + @classmethod + def from_dict(cls, data): + sources = [] + + production_version = data.get("productionVersion") + if not production_version: + return + + # server payload contains info about all versions + # active addon must have 'productionVersion' and matching version info + version_data = data.get("versions", {})[production_version] + + for source in version_data.get("clientSourceInfo", []): + if source.get("type") == UrlType.FILESYSTEM.value: + source_addon = LocalAddonSource(type=source["type"], + path=source["path"]) + if source.get("type") == UrlType.HTTP.value: + source_addon = WebAddonSource(type=source["type"], + url=source["url"]) + + sources.append(source_addon) + + return cls(name=data.get("name"), + version=production_version, + sources=sources, + hash=data.get("hash"), + description=data.get("description"), + title=data.get("title"), + license=data.get("license"), + authors=data.get("authors")) + diff --git a/tests/lib/file_handler.py b/common/openpype_common/distribution/file_handler.py similarity index 83% rename from tests/lib/file_handler.py rename to common/openpype_common/distribution/file_handler.py index ee3abc6ecb..f585c77632 100644 --- a/tests/lib/file_handler.py +++ b/common/openpype_common/distribution/file_handler.py @@ -21,7 +21,7 @@ class RemoteFileHandler: 'tar.gz', 'tar.xz', 'tar.bz2'] @staticmethod - def calculate_md5(fpath, chunk_size): + def calculate_md5(fpath, chunk_size=10000): md5 = hashlib.md5() with open(fpath, 'rb') as f: for chunk in iter(lambda: f.read(chunk_size), b''): @@ -33,17 +33,45 @@ class RemoteFileHandler: return md5 == RemoteFileHandler.calculate_md5(fpath, **kwargs) @staticmethod - def check_integrity(fpath, md5=None): + def calculate_sha256(fpath): + """Calculate sha256 for content of the file. + + Args: + fpath (str): Path to file. + + Returns: + str: hex encoded sha256 + + """ + h = hashlib.sha256() + b = bytearray(128 * 1024) + mv = memoryview(b) + with open(fpath, 'rb', buffering=0) as f: + for n in iter(lambda: f.readinto(mv), 0): + h.update(mv[:n]) + return h.hexdigest() + + @staticmethod + def check_sha256(fpath, sha256, **kwargs): + return sha256 == RemoteFileHandler.calculate_sha256(fpath, **kwargs) + + @staticmethod + def check_integrity(fpath, hash_value=None, hash_type=None): if not os.path.isfile(fpath): return False - if md5 is None: + if hash_value is None: return True - return RemoteFileHandler.check_md5(fpath, md5) + if not hash_type: + raise ValueError("Provide hash type, md5 or sha256") + if hash_type == 'md5': + return RemoteFileHandler.check_md5(fpath, hash_value) + if hash_type == "sha256": + return RemoteFileHandler.check_sha256(fpath, hash_value) @staticmethod def download_url( url, root, filename=None, - md5=None, max_redirect_hops=3 + sha256=None, max_redirect_hops=3 ): """Download a file from a url and place it in root. Args: @@ -51,7 +79,7 @@ class RemoteFileHandler: root (str): Directory to place downloaded file in filename (str, optional): Name to save the file under. If None, use the basename of the URL - md5 (str, optional): MD5 checksum of the download. + sha256 (str, optional): sha256 checksum of the download. If None, do not check max_redirect_hops (int, optional): Maximum number of redirect hops allowed @@ -64,7 +92,8 @@ class RemoteFileHandler: os.makedirs(root, exist_ok=True) # check if file is already present locally - if RemoteFileHandler.check_integrity(fpath, md5): + if RemoteFileHandler.check_integrity(fpath, + sha256, hash_type="sha256"): print('Using downloaded and verified file: ' + fpath) return @@ -76,7 +105,7 @@ class RemoteFileHandler: file_id = RemoteFileHandler._get_google_drive_file_id(url) if file_id is not None: return RemoteFileHandler.download_file_from_google_drive( - file_id, root, filename, md5) + file_id, root, filename, sha256) # download the file try: @@ -92,20 +121,21 @@ class RemoteFileHandler: raise e # check integrity of downloaded file - if not RemoteFileHandler.check_integrity(fpath, md5): + if not RemoteFileHandler.check_integrity(fpath, + sha256, hash_type="sha256"): raise RuntimeError("File not found or corrupted.") @staticmethod def download_file_from_google_drive(file_id, root, filename=None, - md5=None): + sha256=None): """Download a Google Drive file from and place it in root. Args: file_id (str): id of file to be downloaded root (str): Directory to place downloaded file in filename (str, optional): Name to save the file under. If None, use the id of the file. - md5 (str, optional): MD5 checksum of the download. + sha256 (str, optional): sha256 checksum of the download. If None, do not check """ # Based on https://stackoverflow.com/questions/38511444/python-download-files-from-google-drive-using-url # noqa @@ -119,8 +149,8 @@ class RemoteFileHandler: os.makedirs(root, exist_ok=True) - if os.path.isfile(fpath) and RemoteFileHandler.check_integrity(fpath, - md5): + if os.path.isfile(fpath) and RemoteFileHandler.check_integrity( + fpath, sha256, hash_type="sha256"): print('Using downloaded and verified file: ' + fpath) else: session = requests.Session() diff --git a/common/openpype_common/distribution/tests/test_addon_distributtion.py b/common/openpype_common/distribution/tests/test_addon_distributtion.py new file mode 100644 index 0000000000..765ea0596a --- /dev/null +++ b/common/openpype_common/distribution/tests/test_addon_distributtion.py @@ -0,0 +1,167 @@ +import pytest +import attr +import tempfile + +from common.openpype_common.distribution.addon_distribution import ( + AddonDownloader, + OSAddonDownloader, + HTTPAddonDownloader, + AddonInfo, + update_addon_state, + UpdateState +) +from common.openpype_common.distribution.addon_info import UrlType + + +@pytest.fixture +def addon_downloader(): + addon_downloader = AddonDownloader() + addon_downloader.register_format(UrlType.FILESYSTEM, OSAddonDownloader) + addon_downloader.register_format(UrlType.HTTP, HTTPAddonDownloader) + + yield addon_downloader + + +@pytest.fixture +def http_downloader(addon_downloader): + yield addon_downloader.get_downloader(UrlType.HTTP.value) + + +@pytest.fixture +def temp_folder(): + yield tempfile.mkdtemp() + + +@pytest.fixture +def sample_addon_info(): + addon_info = { + "versions": { + "1.0.0": { + "clientPyproject": { + "tool": { + "poetry": { + "dependencies": { + "nxtools": "^1.6", + "orjson": "^3.6.7", + "typer": "^0.4.1", + "email-validator": "^1.1.3", + "python": "^3.10", + "fastapi": "^0.73.0" + } + } + } + }, + "hasSettings": True, + "clientSourceInfo": [ + { + "type": "http", + "url": "https://drive.google.com/file/d/1TcuV8c2OV8CcbPeWi7lxOdqWsEqQNPYy/view?usp=sharing" # noqa + }, + { + "type": "filesystem", + "path": { + "windows": ["P:/sources/some_file.zip", + "W:/sources/some_file.zip"], # noqa + "linux": ["/mnt/srv/sources/some_file.zip"], + "darwin": ["/Volumes/srv/sources/some_file.zip"] + } + } + ], + "frontendScopes": { + "project": { + "sidebar": "hierarchy" + } + } + } + }, + "description": "", + "title": "Slack addon", + "name": "openpype_slack", + "productionVersion": "1.0.0", + "hash": "4be25eb6215e91e5894d3c5475aeb1e379d081d3f5b43b4ee15b0891cf5f5658" # noqa + } + yield addon_info + + +def test_register(printer): + addon_downloader = AddonDownloader() + + assert len(addon_downloader._downloaders) == 0, "Contains registered" + + addon_downloader.register_format(UrlType.FILESYSTEM, OSAddonDownloader) + assert len(addon_downloader._downloaders) == 1, "Should contain one" + + +def test_get_downloader(printer, addon_downloader): + assert addon_downloader.get_downloader(UrlType.FILESYSTEM.value), "Should find" # noqa + + with pytest.raises(ValueError): + addon_downloader.get_downloader("unknown"), "Shouldn't find" + + +def test_addon_info(printer, sample_addon_info): + """Tests parsing of expected payload from v4 server into AadonInfo.""" + valid_minimum = { + "name": "openpype_slack", + "productionVersion": "1.0.0", + "versions": { + "1.0.0": { + "clientSourceInfo": [ + { + "type": "filesystem", + "path": { + "windows": [ + "P:/sources/some_file.zip", + "W:/sources/some_file.zip"], + "linux": [ + "/mnt/srv/sources/some_file.zip"], + "darwin": [ + "/Volumes/srv/sources/some_file.zip"] # noqa + } + } + ] + } + } + } + + assert AddonInfo.from_dict(valid_minimum), "Missing required fields" + + valid_minimum["versions"].pop("1.0.0") + with pytest.raises(KeyError): + assert not AddonInfo.from_dict(valid_minimum), "Must fail without version data" # noqa + + valid_minimum.pop("productionVersion") + assert not AddonInfo.from_dict( + valid_minimum), "none if not productionVersion" # noqa + + addon = AddonInfo.from_dict(sample_addon_info) + assert addon, "Should be created" + assert addon.name == "openpype_slack", "Incorrect name" + assert addon.version == "1.0.0", "Incorrect version" + + with pytest.raises(TypeError): + assert addon["name"], "Dict approach not implemented" + + addon_as_dict = attr.asdict(addon) + assert addon_as_dict["name"], "Dict approach should work" + + +def test_update_addon_state(printer, sample_addon_info, + temp_folder, addon_downloader): + """Tests possible cases of addon update.""" + addon_info = AddonInfo.from_dict(sample_addon_info) + orig_hash = addon_info.hash + + addon_info.hash = "brokenhash" + result = update_addon_state([addon_info], temp_folder, addon_downloader) + assert result["openpype_slack_1.0.0"] == UpdateState.FAILED.value, \ + "Update should failed because of wrong hash" + + addon_info.hash = orig_hash + result = update_addon_state([addon_info], temp_folder, addon_downloader) + assert result["openpype_slack_1.0.0"] == UpdateState.UPDATED.value, \ + "Addon should have been updated" + + result = update_addon_state([addon_info], temp_folder, addon_downloader) + assert result["openpype_slack_1.0.0"] == UpdateState.EXISTS.value, \ + "Addon should already exist" diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index ccc9d4ac52..c6772a3930 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -804,6 +804,8 @@ class BootstrapRepos: """ version = OpenPypeVersion.version_in_str(zip_file.name) destination_dir = self.data_dir / f"{version.major}.{version.minor}" + if not destination_dir.exists(): + destination_dir.mkdir(parents=True) destination = destination_dir / zip_file.name if destination.exists(): diff --git a/igniter/install_thread.py b/igniter/install_thread.py index 0cccf664e7..3c9abece65 100644 --- a/igniter/install_thread.py +++ b/igniter/install_thread.py @@ -33,7 +33,6 @@ class InstallThread(QThread): def __init__(self, parent=None,): self._mongo = None - self._path = None self._result = None QThread.__init__(self, parent) @@ -62,143 +61,117 @@ class InstallThread(QThread): progress_callback=self.set_progress, message=self.message) local_version = OpenPypeVersion.get_installed_version_str() - # if user did enter nothing, we install OpenPype from local version. - # zip content of `repos`, copy it to user data dir and append - # version to it. - if not self._path: - # user did not entered url - if not self._mongo: - # it not set in environment - if not os.getenv("OPENPYPE_MONGO"): - # try to get it from settings registry - try: - self._mongo = bs.secure_registry.get_item( - "openPypeMongo") - except ValueError: - self.message.emit( - "!!! We need MongoDB URL to proceed.", True) - self._set_result(-1) - return - else: - self._mongo = os.getenv("OPENPYPE_MONGO") - else: - self.message.emit("Saving mongo connection string ...", False) - bs.secure_registry.set_item("openPypeMongo", self._mongo) - - os.environ["OPENPYPE_MONGO"] = self._mongo - - self.message.emit( - f"Detecting installed OpenPype versions in {bs.data_dir}", - False) - detected = bs.find_openpype(include_zips=True) - - if detected: - if not OpenPypeVersion.get_installed_version().is_compatible( - detected[-1]): - self.message.emit(( - f"Latest detected version {detected[-1]} " - "is not compatible with the currently running " - f"{local_version}" - ), True) - self.message.emit(( - "Filtering detected versions to compatible ones..." - ), False) - - detected = [ - version for version in detected - if version.is_compatible( - OpenPypeVersion.get_installed_version()) - ] - - if OpenPypeVersion( - version=local_version, path=Path()) < detected[-1]: - self.message.emit(( - f"Latest installed version {detected[-1]} is newer " - f"then currently running {local_version}" - ), False) - self.message.emit("Skipping OpenPype install ...", False) - if detected[-1].path.suffix.lower() == ".zip": - bs.extract_openpype(detected[-1]) - self._set_result(0) - return - - if OpenPypeVersion(version=local_version).get_main_version() == detected[-1].get_main_version(): # noqa - self.message.emit(( - f"Latest installed version is the same as " - f"currently running {local_version}" - ), False) - self.message.emit("Skipping OpenPype install ...", False) - self._set_result(0) - return - - self.message.emit(( - "All installed versions are older then " - f"currently running one {local_version}" - ), False) - else: - if getattr(sys, 'frozen', False): - self.message.emit("None detected.", True) - self.message.emit(("We will use OpenPype coming with " - "installer."), False) - openpype_version = bs.create_version_from_frozen_code() - if not openpype_version: - self.message.emit( - f"!!! Install failed - {openpype_version}", True) - self._set_result(-1) - return - self.message.emit(f"Using: {openpype_version}", False) - bs.install_version(openpype_version) - self.message.emit(f"Installed as {openpype_version}", False) - self.progress.emit(100) - self._set_result(1) - return - else: - self.message.emit("None detected.", False) - - self.message.emit( - f"We will use local OpenPype version {local_version}", False) - - local_openpype = bs.create_version_from_live_code() - if not local_openpype: - self.message.emit( - f"!!! Install failed - {local_openpype}", True) - self._set_result(-1) - return + # user did not entered url + if self._mongo: + self.message.emit("Saving mongo connection string ...", False) + bs.secure_registry.set_item("openPypeMongo", self._mongo) + elif os.getenv("OPENPYPE_MONGO"): + self._mongo = os.getenv("OPENPYPE_MONGO") + else: + # try to get it from settings registry try: - bs.install_version(local_openpype) - except (OpenPypeVersionExists, - OpenPypeVersionInvalid, - OpenPypeVersionIOError) as e: - self.message.emit(f"Installed failed: ", True) - self.message.emit(str(e), True) + self._mongo = bs.secure_registry.get_item( + "openPypeMongo") + except ValueError: + self.message.emit( + "!!! We need MongoDB URL to proceed.", True) self._set_result(-1) return + os.environ["OPENPYPE_MONGO"] = self._mongo - self.message.emit(f"Installed as {local_openpype}", False) + self.message.emit( + f"Detecting installed OpenPype versions in {bs.data_dir}", + False) + detected = bs.find_openpype(include_zips=True) + if not detected and getattr(sys, 'frozen', False): + self.message.emit("None detected.", True) + self.message.emit(("We will use OpenPype coming with " + "installer."), False) + openpype_version = bs.create_version_from_frozen_code() + if not openpype_version: + self.message.emit( + f"!!! Install failed - {openpype_version}", True) + self._set_result(-1) + return + self.message.emit(f"Using: {openpype_version}", False) + bs.install_version(openpype_version) + self.message.emit(f"Installed as {openpype_version}", False) self.progress.emit(100) self._set_result(1) return - else: - # if we have mongo connection string, validate it, set it to - # user settings and get OPENPYPE_PATH from there. - if self._mongo: - if not validate_mongo_connection(self._mongo): - self.message.emit( - f"!!! invalid mongo url {self._mongo}", True) - self._set_result(-1) - return - bs.secure_registry.set_item("openPypeMongo", self._mongo) - os.environ["OPENPYPE_MONGO"] = self._mongo - self.message.emit(f"processing {self._path}", True) - repo_file = bs.process_entered_location(self._path) + if detected and not OpenPypeVersion.get_installed_version().is_compatible(detected[-1]): # noqa: E501 + self.message.emit(( + f"Latest detected version {detected[-1]} " + "is not compatible with the currently running " + f"{local_version}" + ), True) + self.message.emit(( + "Filtering detected versions to compatible ones..." + ), False) - if not repo_file: - self.message.emit("!!! Cannot install", True) - self._set_result(-1) + # filter results to get only compatible versions + detected = [ + version for version in detected + if version.is_compatible( + OpenPypeVersion.get_installed_version()) + ] + + if detected: + if OpenPypeVersion( + version=local_version, path=Path()) < detected[-1]: + self.message.emit(( + f"Latest installed version {detected[-1]} is newer " + f"then currently running {local_version}" + ), False) + self.message.emit("Skipping OpenPype install ...", False) + if detected[-1].path.suffix.lower() == ".zip": + bs.extract_openpype(detected[-1]) + self._set_result(0) return + if OpenPypeVersion(version=local_version).get_main_version() == detected[-1].get_main_version(): # noqa: E501 + self.message.emit(( + f"Latest installed version is the same as " + f"currently running {local_version}" + ), False) + self.message.emit("Skipping OpenPype install ...", False) + self._set_result(0) + return + + self.message.emit(( + "All installed versions are older then " + f"currently running one {local_version}" + ), False) + + self.message.emit("None detected.", False) + + self.message.emit( + f"We will use local OpenPype version {local_version}", False) + + local_openpype = bs.create_version_from_live_code() + if not local_openpype: + self.message.emit( + f"!!! Install failed - {local_openpype}", True) + self._set_result(-1) + return + + try: + bs.install_version(local_openpype) + except (OpenPypeVersionExists, + OpenPypeVersionInvalid, + OpenPypeVersionIOError) as e: + self.message.emit(f"Installed failed: ", True) + self.message.emit(str(e), True) + self._set_result(-1) + return + + self.message.emit(f"Installed as {local_openpype}", False) + self.progress.emit(100) + self._set_result(1) + return + self.progress.emit(100) self._set_result(1) return diff --git a/inno_setup.iss b/inno_setup.iss index fa050ef1d6..3adde52a8b 100644 --- a/inno_setup.iss +++ b/inno_setup.iss @@ -48,8 +48,8 @@ Source: "build\{#build}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdir ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] -Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\openpype_gui.exe" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\openpype_gui.exe"; Tasks: desktopicon +Name: "{autoprograms}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe" +Name: "{autodesktop}\{#MyAppName} {#AppVer}"; Filename: "{app}\openpype_gui.exe"; Tasks: desktopicon [Run] Filename: "{app}\openpype_gui.exe"; Description: "{cm:LaunchProgram,OpenPype}"; Flags: nowait postinstall skipifsilent diff --git a/openpype/client/entity_links.py b/openpype/client/entity_links.py index 66214f469c..e42ac58aff 100644 --- a/openpype/client/entity_links.py +++ b/openpype/client/entity_links.py @@ -2,6 +2,7 @@ from .mongo import get_project_connection from .entities import ( get_assets, get_asset_by_id, + get_version_by_id, get_representation_by_id, convert_id, ) @@ -127,12 +128,20 @@ def get_linked_representation_id( if not version_id: return [] + version_doc = get_version_by_id( + project_name, version_id, fields=["type", "version_id"] + ) + if version_doc["type"] == "hero_version": + version_id = version_doc["version_id"] + if max_depth is None: max_depth = 0 match = { "_id": version_id, - "type": {"$in": ["version", "hero_version"]} + # Links are not stored to hero versions at this moment so filter + # is limited to just versions + "type": "version" } graph_lookup = { @@ -187,7 +196,7 @@ def _process_referenced_pipeline_result(result, link_type): referenced_version_ids = set() correctly_linked_ids = set() for item in result: - input_links = item["data"].get("inputLinks") + input_links = item.get("data", {}).get("inputLinks") if not input_links: continue @@ -203,7 +212,7 @@ def _process_referenced_pipeline_result(result, link_type): continue for output in sorted(outputs_recursive, key=lambda o: o["depth"]): - output_links = output["data"].get("inputLinks") + output_links = output.get("data", {}).get("inputLinks") if not output_links: continue diff --git a/openpype/client/operations.py b/openpype/client/operations.py index 48e8645726..fd639c34a7 100644 --- a/openpype/client/operations.py +++ b/openpype/client/operations.py @@ -23,6 +23,7 @@ CURRENT_PROJECT_CONFIG_SCHEMA = "openpype:config-2.0" CURRENT_ASSET_DOC_SCHEMA = "openpype:asset-3.0" CURRENT_SUBSET_SCHEMA = "openpype:subset-3.0" CURRENT_VERSION_SCHEMA = "openpype:version-3.0" +CURRENT_HERO_VERSION_SCHEMA = "openpype:hero_version-1.0" CURRENT_REPRESENTATION_SCHEMA = "openpype:representation-2.0" CURRENT_WORKFILE_INFO_SCHEMA = "openpype:workfile-1.0" CURRENT_THUMBNAIL_SCHEMA = "openpype:thumbnail-1.0" @@ -162,6 +163,34 @@ def new_version_doc(version, subset_id, data=None, entity_id=None): } +def new_hero_version_doc(version_id, subset_id, data=None, entity_id=None): + """Create skeleton data of hero version document. + + Args: + version_id (ObjectId): Is considered as unique identifier of version + under subset. + subset_id (Union[str, ObjectId]): Id of parent subset. + data (Dict[str, Any]): Version document data. + entity_id (Union[str, ObjectId]): Predefined id of document. New id is + created if not passed. + + Returns: + Dict[str, Any]: Skeleton of version document. + """ + + if data is None: + data = {} + + return { + "_id": _create_or_convert_to_mongo_id(entity_id), + "schema": CURRENT_HERO_VERSION_SCHEMA, + "type": "hero_version", + "version_id": version_id, + "parent": subset_id, + "data": data + } + + def new_representation_doc( name, version_id, context, data=None, entity_id=None ): @@ -293,6 +322,20 @@ def prepare_version_update_data(old_doc, new_doc, replace=True): return _prepare_update_data(old_doc, new_doc, replace) +def prepare_hero_version_update_data(old_doc, new_doc, replace=True): + """Compare two hero version documents and prepare update data. + + Based on compared values will create update data for 'UpdateOperation'. + + Empty output means that documents are identical. + + Returns: + Dict[str, Any]: Changes between old and new document. + """ + + return _prepare_update_data(old_doc, new_doc, replace) + + def prepare_representation_update_data(old_doc, new_doc, replace=True): """Compare two representation documents and prepare update data. diff --git a/openpype/hooks/pre_add_last_workfile_arg.py b/openpype/hooks/pre_add_last_workfile_arg.py index 8edccd48d4..3609620917 100644 --- a/openpype/hooks/pre_add_last_workfile_arg.py +++ b/openpype/hooks/pre_add_last_workfile_arg.py @@ -19,6 +19,7 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook): "hiero", "houdini", "nukestudio", + "fusion", "blender", "photoshop", "tvpaint", diff --git a/openpype/host/__init__.py b/openpype/host/__init__.py index 519888fce3..da1237c739 100644 --- a/openpype/host/__init__.py +++ b/openpype/host/__init__.py @@ -5,6 +5,7 @@ from .host import ( from .interfaces import ( IWorkfileHost, ILoadHost, + IPublishHost, INewPublisher, ) @@ -16,6 +17,7 @@ __all__ = ( "IWorkfileHost", "ILoadHost", + "IPublishHost", "INewPublisher", "HostDirmap", diff --git a/openpype/host/interfaces.py b/openpype/host/interfaces.py index cbf12b0d13..3b2df745d1 100644 --- a/openpype/host/interfaces.py +++ b/openpype/host/interfaces.py @@ -282,7 +282,7 @@ class IWorkfileHost: return self.workfile_has_unsaved_changes() -class INewPublisher: +class IPublishHost: """Functions related to new creation system in new publisher. New publisher is not storing information only about each created instance @@ -306,12 +306,14 @@ class INewPublisher: workflow. """ - if isinstance(host, INewPublisher): + if isinstance(host, IPublishHost): return [] required = [ "get_context_data", "update_context_data", + "get_context_title", + "get_current_context", ] missing = [] for name in required: @@ -330,7 +332,7 @@ class INewPublisher: MissingMethodsError: If there are missing methods on host implementation. """ - missing = INewPublisher.get_missing_publish_methods(host) + missing = IPublishHost.get_missing_publish_methods(host) if missing: raise MissingMethodsError(host, missing) @@ -368,3 +370,17 @@ class INewPublisher: """ pass + + +class INewPublisher(IPublishHost): + """Legacy interface replaced by 'IPublishHost'. + + Deprecated: + 'INewPublisher' is replaced by 'IPublishHost' please change your + imports. + There is no "reasonable" way hot mark these classes as deprecated + to show warning of wrong import. Deprecated since 3.14.* will be + removed in 3.15.* + """ + + pass diff --git a/openpype/hosts/aftereffects/api/launch_logic.py b/openpype/hosts/aftereffects/api/launch_logic.py index 30a3e1f1c3..9c8513fe8c 100644 --- a/openpype/hosts/aftereffects/api/launch_logic.py +++ b/openpype/hosts/aftereffects/api/launch_logic.py @@ -12,6 +12,7 @@ from wsrpc_aiohttp import ( from Qt import QtCore +from openpype.lib import Logger from openpype.pipeline import legacy_io from openpype.tools.utils import host_tools from openpype.tools.adobe_webserver.app import WebServerTool @@ -84,8 +85,6 @@ class ProcessLauncher(QtCore.QObject): @property def log(self): if self._log is None: - from openpype.api import Logger - self._log = Logger.get_logger("{}-launcher".format( self.route_name)) return self._log diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index c13c22ced5..7026fe3f05 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -4,8 +4,7 @@ from Qt import QtWidgets import pyblish.api -from openpype import lib -from openpype.api import Logger +from openpype.lib import Logger, register_event_callback from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, @@ -16,9 +15,8 @@ from openpype.pipeline import ( ) from openpype.pipeline.load import any_outdated_containers import openpype.hosts.aftereffects -from openpype.lib import register_event_callback -from .launch_logic import get_stub +from .launch_logic import get_stub, ConnectionNotEstablishedYet log = Logger.get_logger(__name__) @@ -111,7 +109,7 @@ def ls(): """ try: stub = get_stub() # only after AfterEffects is up - except lib.ConnectionNotEstablishedYet: + except ConnectionNotEstablishedYet: print("Not connected yet, ignoring") return @@ -284,7 +282,7 @@ def _get_stub(): """ try: stub = get_stub() # only after Photoshop is up - except lib.ConnectionNotEstablishedYet: + except ConnectionNotEstablishedYet: print("Not connected yet, ignoring") return diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 9cd1ace821..05912885f7 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -6,7 +6,7 @@ from typing import Dict, List, Union import bpy import addon_utils -from openpype.api import Logger +from openpype.lib import Logger from . import pipeline diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index ea405b028e..c2aee1e653 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -20,8 +20,8 @@ from openpype.pipeline import ( deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) -from openpype.api import Logger from openpype.lib import ( + Logger, register_event_callback, emit_event ) diff --git a/openpype/hosts/celaction/api/cli.py b/openpype/hosts/celaction/api/cli.py index eb91def090..88fc11cafb 100644 --- a/openpype/hosts/celaction/api/cli.py +++ b/openpype/hosts/celaction/api/cli.py @@ -6,9 +6,8 @@ import argparse import pyblish.api import pyblish.util -from openpype.api import Logger -import openpype import openpype.hosts.celaction +from openpype.lib import Logger from openpype.hosts.celaction import api as celaction from openpype.tools.utils import host_tools from openpype.pipeline import install_openpype_plugins diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 94c46fe937..6aca5c5ce6 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -12,6 +12,9 @@ import xml.etree.cElementTree as cET from copy import deepcopy, copy from xml.etree import ElementTree as ET from pprint import pformat + +from openpype.lib import Logger, run_subprocess + from .constants import ( MARKER_COLOR, MARKER_DURATION, @@ -20,9 +23,7 @@ from .constants import ( MARKER_PUBLISH_DEFAULT ) -import openpype.api as openpype - -log = openpype.Logger.get_logger(__name__) +log = Logger.get_logger(__name__) FRAME_PATTERN = re.compile(r"[\._](\d+)[\.]") @@ -766,11 +767,11 @@ class MediaInfoFile(object): _drop_mode = None _file_pattern = None - def __init__(self, path, **kwargs): + def __init__(self, path, logger=None): # replace log if any - if kwargs.get("logger"): - self.log = kwargs["logger"] + if logger: + self.log = logger # test if `dl_get_media_info` paht exists self._validate_media_script_path() @@ -1016,7 +1017,7 @@ class MediaInfoFile(object): try: # execute creation of clip xml template data - openpype.run_subprocess(cmd_args) + run_subprocess(cmd_args) except TypeError as error: raise TypeError( "Error creating `{}` due: {}".format(fpath, error)) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index da44be1b15..3a23389961 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -5,7 +5,7 @@ import os import contextlib from pyblish import api as pyblish -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, @@ -90,8 +90,7 @@ def containerise(flame_clip_segment, def ls(): """List available containers. """ - # TODO: ls - pass + return [] def parse_container(tl_segment, validate=True): @@ -107,6 +106,7 @@ def update_container(tl_segment, data=None): # TODO: update_container pass + def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 145b1f0921..092ce9d106 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -6,16 +6,17 @@ from xml.etree import ElementTree as ET from Qt import QtCore, QtWidgets -import openpype.api as openpype import qargparse from openpype import style +from openpype.settings import get_current_project_settings +from openpype.lib import Logger from openpype.pipeline import LegacyCreator, LoaderPlugin from . import constants from . import lib as flib from . import pipeline as fpipeline -log = openpype.Logger.get_logger(__name__) +log = Logger.get_logger(__name__) class CreatorWidget(QtWidgets.QDialog): @@ -305,7 +306,7 @@ class Creator(LegacyCreator): def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) - self.presets = openpype.get_current_project_settings()[ + self.presets = get_current_project_settings()[ "flame"]["create"].get(self.__class__.__name__, {}) # adding basic current context flame objects @@ -678,6 +679,7 @@ class ClipLoader(LoaderPlugin): `update` logic. """ + log = log options = [ qargparse.Boolean( @@ -694,16 +696,20 @@ class OpenClipSolver(flib.MediaInfoFile): log = log - def __init__(self, openclip_file_path, feed_data): + def __init__(self, openclip_file_path, feed_data, logger=None): self.out_file = openclip_file_path + # replace log if any + if logger: + self.log = logger + # new feed variables: feed_path = feed_data.pop("path") # initialize parent class super(OpenClipSolver, self).__init__( feed_path, - **feed_data + logger=logger ) # get other metadata @@ -751,17 +757,18 @@ class OpenClipSolver(flib.MediaInfoFile): self.log.info("Building new openClip") self.log.debug(">> self.clip_data: {}".format(self.clip_data)) - # clip data comming from MediaInfoFile - tmp_xml_feeds = self.clip_data.find('tracks/track/feeds') - tmp_xml_feeds.set('currentVersion', self.feed_version_name) - for tmp_feed in tmp_xml_feeds: - tmp_feed.set('vuid', self.feed_version_name) + for tmp_xml_track in self.clip_data.iter("track"): + tmp_xml_feeds = tmp_xml_track.find('feeds') + tmp_xml_feeds.set('currentVersion', self.feed_version_name) - # add colorspace if any is set - if self.feed_colorspace: - self._add_colorspace(tmp_feed, self.feed_colorspace) + for tmp_feed in tmp_xml_track.iter("feed"): + tmp_feed.set('vuid', self.feed_version_name) - self._clear_handler(tmp_feed) + # add colorspace if any is set + if self.feed_colorspace: + self._add_colorspace(tmp_feed, self.feed_colorspace) + + self._clear_handler(tmp_feed) tmp_xml_versions_obj = self.clip_data.find('versions') tmp_xml_versions_obj.set('currentVersion', self.feed_version_name) @@ -774,6 +781,17 @@ class OpenClipSolver(flib.MediaInfoFile): self.write_clip_data_to_file(self.out_file, self.clip_data) + def _get_xml_track_obj_by_uid(self, xml_data, uid): + # loop all tracks of input xml data + for xml_track in xml_data.iter("track"): + track_uid = xml_track.get("uid") + self.log.debug( + ">> track_uid:uid: {}:{}".format(track_uid, uid)) + + # get matching uids + if uid == track_uid: + return xml_track + def _update_open_clip(self): self.log.info("Updating openClip ..") @@ -783,52 +801,81 @@ class OpenClipSolver(flib.MediaInfoFile): self.log.debug(">> out_xml: {}".format(out_xml)) self.log.debug(">> self.clip_data: {}".format(self.clip_data)) - # Get new feed from tmp file - tmp_xml_feed = self.clip_data.find('tracks/track/feeds/feed') + # loop tmp tracks + updated_any = False + for tmp_xml_track in self.clip_data.iter("track"): + # get tmp track uid + tmp_track_uid = tmp_xml_track.get("uid") + self.log.debug(">> tmp_track_uid: {}".format(tmp_track_uid)) - self._clear_handler(tmp_xml_feed) + # get out data track by uid + out_track_element = self._get_xml_track_obj_by_uid( + out_xml, tmp_track_uid) + self.log.debug( + ">> out_track_element: {}".format(out_track_element)) - # update fps from MediaInfoFile class - if self.fps: - tmp_feed_fps_obj = tmp_xml_feed.find( - "startTimecode/rate") - tmp_feed_fps_obj.text = str(self.fps) + # loop tmp feeds + for tmp_xml_feed in tmp_xml_track.iter("feed"): + new_path_obj = tmp_xml_feed.find( + "spans/span/path") + new_path = new_path_obj.text - # update start_frame from MediaInfoFile class - if self.start_frame: - tmp_feed_nb_ticks_obj = tmp_xml_feed.find( - "startTimecode/nbTicks") - tmp_feed_nb_ticks_obj.text = str(self.start_frame) + # check if feed path already exists in track's feeds + if ( + out_track_element is not None + and self._feed_exists(out_track_element, new_path) + ): + continue - # update drop_mode from MediaInfoFile class - if self.drop_mode: - tmp_feed_drop_mode_obj = tmp_xml_feed.find( - "startTimecode/dropMode") - tmp_feed_drop_mode_obj.text = str(self.drop_mode) + # rename versions on feeds + tmp_xml_feed.set('vuid', self.feed_version_name) + self._clear_handler(tmp_xml_feed) - new_path_obj = tmp_xml_feed.find( - "spans/span/path") - new_path = new_path_obj.text + # update fps from MediaInfoFile class + if self.fps is not None: + tmp_feed_fps_obj = tmp_xml_feed.find( + "startTimecode/rate") + tmp_feed_fps_obj.text = str(self.fps) - feed_added = False - if not self._feed_exists(out_xml, new_path): - tmp_xml_feed.set('vuid', self.feed_version_name) - # Append new temp file feed to .clip source out xml - out_track = out_xml.find("tracks/track") - # add colorspace if any is set - if self.feed_colorspace: - self._add_colorspace(tmp_xml_feed, self.feed_colorspace) + # update start_frame from MediaInfoFile class + if self.start_frame is not None: + tmp_feed_nb_ticks_obj = tmp_xml_feed.find( + "startTimecode/nbTicks") + tmp_feed_nb_ticks_obj.text = str(self.start_frame) - out_feeds = out_track.find('feeds') - out_feeds.set('currentVersion', self.feed_version_name) - out_feeds.append(tmp_xml_feed) + # update drop_mode from MediaInfoFile class + if self.drop_mode is not None: + tmp_feed_drop_mode_obj = tmp_xml_feed.find( + "startTimecode/dropMode") + tmp_feed_drop_mode_obj.text = str(self.drop_mode) - self.log.info( - "Appending new feed: {}".format( - self.feed_version_name)) - feed_added = True + # add colorspace if any is set + if self.feed_colorspace is not None: + self._add_colorspace(tmp_xml_feed, self.feed_colorspace) - if feed_added: + # then append/update feed to correct track in output + if out_track_element: + self.log.debug("updating track element ..") + # update already present track + out_feeds = out_track_element.find('feeds') + out_feeds.set('currentVersion', self.feed_version_name) + out_feeds.append(tmp_xml_feed) + + self.log.info( + "Appending new feed: {}".format( + self.feed_version_name)) + else: + self.log.debug("adding new track element ..") + # create new track as it doesnt exists yet + # set current version to feeds on tmp + tmp_xml_feeds = tmp_xml_track.find('feeds') + tmp_xml_feeds.set('currentVersion', self.feed_version_name) + out_tracks = out_xml.find("tracks") + out_tracks.append(tmp_xml_track) + + updated_any = True + + if updated_any: # Append vUID to versions out_xml_versions_obj = out_xml.find('versions') out_xml_versions_obj.set( diff --git a/openpype/hosts/flame/api/render_utils.py b/openpype/hosts/flame/api/render_utils.py index a29d6be695..7e50c2b23e 100644 --- a/openpype/hosts/flame/api/render_utils.py +++ b/openpype/hosts/flame/api/render_utils.py @@ -1,6 +1,6 @@ import os from xml.etree import ElementTree as ET -from openpype.api import Logger +from openpype.lib import Logger log = Logger.get_logger(__name__) diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 2dfdfa8f48..fb8bdee42d 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -4,7 +4,7 @@ Flame utils for syncing scripts import os import shutil -from openpype.api import Logger +from openpype.lib import Logger log = Logger.get_logger(__name__) diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index ad2b0dc897..f0fdaa86ba 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -22,6 +22,7 @@ class FlamePrelaunch(PreLaunchHook): in environment var FLAME_SCRIPT_DIR. """ app_groups = ["flame"] + permissions = 0o777 wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") @@ -38,19 +39,12 @@ class FlamePrelaunch(PreLaunchHook): """Hook entry method.""" project_doc = self.data["project_doc"] project_name = project_doc["name"] + volume_name = _env.get("FLAME_WIRETAP_VOLUME") # get image io - project_anatomy = self.data["anatomy"] + project_settings = self.data["project_settings"] - # make sure anatomy settings are having flame key - if not project_anatomy["imageio"].get("flame"): - raise ApplicationLaunchFailed(( - "Anatomy project settings are missing `flame` key. " - "Please make sure you remove project overides on " - "Anatomy Image io") - ) - - imageio_flame = project_anatomy["imageio"]["flame"] + imageio_flame = project_settings["flame"]["imageio"] # get user name and host name user_name = get_openpype_username() @@ -81,7 +75,7 @@ class FlamePrelaunch(PreLaunchHook): data_to_script = { # from settings "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), + "volume_name": volume_name, "group_name": _env.get("FLAME_WIRETAP_GROUP"), "color_policy": str(imageio_flame["project"]["colourPolicy"]), @@ -99,8 +93,41 @@ class FlamePrelaunch(PreLaunchHook): app_arguments = self._get_launch_arguments(data_to_script) + # fix project data permission issue + self._fix_permissions(project_name, volume_name) + self.launch_context.launch_args.extend(app_arguments) + def _fix_permissions(self, project_name, volume_name): + """Work around for project data permissions + + Reported issue: when project is created locally on one machine, + it is impossible to migrate it to other machine. Autodesk Flame + is crating some unmanagable files which needs to be opened to 0o777. + + Args: + project_name (str): project name + volume_name (str): studio volume + """ + dirs_to_modify = [ + "/usr/discreet/project/{}".format(project_name), + "/opt/Autodesk/clip/{}/{}.prj".format(volume_name, project_name), + "/usr/discreet/clip/{}/{}.prj".format(volume_name, project_name) + ] + + for dirtm in dirs_to_modify: + for root, dirs, files in os.walk(dirtm): + try: + for name in set(dirs) | set(files): + path = os.path.join(root, name) + st = os.stat(path) + if oct(st.st_mode) != self.permissions: + os.chmod(path, self.permissions) + + except OSError as exc: + self.log.warning("Not able to open files: {}".format(exc)) + + def _get_flame_fps(self, fps_num): fps_table = { float(23.976): "23.976 fps", diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py index b03a39a7ca..4fb041a4b2 100644 --- a/openpype/hosts/flame/plugins/create/create_shot_clip.py +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -23,10 +23,11 @@ class CreateShotClip(opfapi.Creator): # nested dictionary (only one level allowed # for sections and dict) for _k, _v in v["value"].items(): - if presets.get(_k): + if presets.get(_k) is not None: gui_inputs[k][ "value"][_k]["value"] = presets[_k] - if presets.get(k): + + if presets.get(k) is not None: gui_inputs[k]["value"] = presets[k] # open widget for plugins inputs diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index b12f2f9690..0843dde76a 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -4,6 +4,7 @@ from pprint import pformat import openpype.hosts.flame.api as opfapi from openpype.lib import StringTemplate + class LoadClip(opfapi.ClipLoader): """Load a subset to timeline as clip @@ -60,8 +61,6 @@ class LoadClip(opfapi.ClipLoader): "path": self.fname.replace("\\", "/"), "colorspace": colorspace, "version": "v{:0>3}".format(version_name), - "logger": self.log - } self.log.debug(pformat( loading_context @@ -69,7 +68,8 @@ class LoadClip(opfapi.ClipLoader): self.log.debug(openclip_path) # make openpype clip file - opfapi.OpenClipSolver(openclip_path, loading_context).make() + opfapi.OpenClipSolver( + openclip_path, loading_context, logger=self.log).make() # prepare Reel group in actual desktop opc = self._get_clip( diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index fb4a3dc6e9..3b049b861b 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -64,8 +64,6 @@ class LoadClipBatch(opfapi.ClipLoader): "path": self.fname.replace("\\", "/"), "colorspace": colorspace, "version": "v{:0>3}".format(version_name), - "logger": self.log - } self.log.debug(pformat( loading_context @@ -73,7 +71,8 @@ class LoadClipBatch(opfapi.ClipLoader): self.log.debug(openclip_path) # make openpype clip file - opfapi.OpenClipSolver(openclip_path, loading_context).make() + opfapi.OpenClipSolver( + openclip_path, loading_context, logger=self.log).make() # prepare Reel group in actual desktop opc = self._get_clip( diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index d6ff13b059..76d48dded2 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -131,9 +131,8 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): "fps": self.fps, "workfileFrameStart": workfile_start, "sourceFirstFrame": int(first_frame), - "notRetimedHandles": ( - not marker_data.get("retimedHandles")), - "notRetimedFramerange": ( + "retimedHandles": marker_data.get("retimedHandles"), + "shotDurationFromSource": ( not marker_data.get("retimedFramerange")), "path": file_path, "flameAddTasks": self.add_tasks, diff --git a/openpype/hosts/flame/plugins/publish/extract_otio_file.py b/openpype/hosts/flame/plugins/publish/extract_otio_file.py index 7dd75974fc..e5bfa42ce6 100644 --- a/openpype/hosts/flame/plugins/publish/extract_otio_file.py +++ b/openpype/hosts/flame/plugins/publish/extract_otio_file.py @@ -1,10 +1,10 @@ import os import pyblish.api -import openpype.api import opentimelineio as otio +from openpype.pipeline import publish -class ExtractOTIOFile(openpype.api.Extractor): +class ExtractOTIOFile(publish.Extractor): """ Extractor export OTIO file """ diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 1d42330e23..d5294d61c2 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -1,11 +1,11 @@ import os import re import tempfile -from pprint import pformat from copy import deepcopy import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.flame import api as opfapi from openpype.hosts.flame.api import MediaInfoFile from openpype.pipeline.editorial import ( @@ -15,7 +15,7 @@ from openpype.pipeline.editorial import ( import flame -class ExtractSubsetResources(openpype.api.Extractor): +class ExtractSubsetResources(publish.Extractor): """ Extractor for transcoding files from Flame clip """ @@ -80,10 +80,10 @@ class ExtractSubsetResources(openpype.api.Extractor): retimed_data = self._get_retimed_attributes(instance) # get individual keys - r_handle_start = retimed_data["handle_start"] - r_handle_end = retimed_data["handle_end"] - r_source_dur = retimed_data["source_duration"] - r_speed = retimed_data["speed"] + retimed_handle_start = retimed_data["handle_start"] + retimed_handle_end = retimed_data["handle_end"] + retimed_source_duration = retimed_data["source_duration"] + retimed_speed = retimed_data["speed"] # get handles value - take only the max from both handle_start = instance.data["handleStart"] @@ -97,22 +97,23 @@ class ExtractSubsetResources(openpype.api.Extractor): source_end_handles = instance.data["sourceEndH"] # retime if needed - if r_speed != 1.0: + if retimed_speed != 1.0: if retimed_handles: # handles are retimed source_start_handles = ( - instance.data["sourceStart"] - r_handle_start) + instance.data["sourceStart"] - retimed_handle_start) source_end_handles = ( source_start_handles - + (r_source_dur - 1) - + r_handle_start - + r_handle_end + + (retimed_source_duration - 1) + + retimed_handle_start + + retimed_handle_end ) + else: # handles are not retimed source_end_handles = ( source_start_handles - + (r_source_dur - 1) + + (retimed_source_duration - 1) + handle_start + handle_end ) @@ -121,11 +122,11 @@ class ExtractSubsetResources(openpype.api.Extractor): frame_start_handle = frame_start - handle_start repre_frame_start = frame_start_handle if include_handles: - if r_speed == 1.0 or not retimed_handles: + if retimed_speed == 1.0 or not retimed_handles: frame_start_handle = frame_start else: frame_start_handle = ( - frame_start - handle_start) + r_handle_start + frame_start - handle_start) + retimed_handle_start self.log.debug("_ frame_start_handle: {}".format( frame_start_handle)) @@ -136,6 +137,9 @@ class ExtractSubsetResources(openpype.api.Extractor): source_duration_handles = ( source_end_handles - source_start_handles) + 1 + self.log.debug("_ source_duration_handles: {}".format( + source_duration_handles)) + # create staging dir path staging_dir = self.staging_dir(instance) @@ -159,15 +163,30 @@ class ExtractSubsetResources(openpype.api.Extractor): if version_data: instance.data["versionData"].update(version_data) - if r_speed != 1.0: - instance.data["versionData"].update({ - "frameStart": frame_start_handle, - "frameEnd": ( - (frame_start_handle + source_duration_handles - 1) - - (r_handle_start + r_handle_end) - ) - }) - self.log.debug("_ i_version_data: {}".format( + # version data start frame + version_frame_start = frame_start + if include_handles: + version_frame_start = frame_start_handle + if retimed_speed != 1.0: + if retimed_handles: + instance.data["versionData"].update({ + "frameStart": version_frame_start, + "frameEnd": ( + (version_frame_start + source_duration_handles - 1) + - (retimed_handle_start + retimed_handle_end) + ) + }) + else: + instance.data["versionData"].update({ + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": version_frame_start, + "frameEnd": ( + (version_frame_start + source_duration_handles - 1) + - (handle_start + handle_end) + ) + }) + self.log.debug("_ version_data: {}".format( instance.data["versionData"] )) diff --git a/openpype/hosts/fusion/addon.py b/openpype/hosts/fusion/addon.py index e257005061..1913cc2e30 100644 --- a/openpype/hosts/fusion/addon.py +++ b/openpype/hosts/fusion/addon.py @@ -19,5 +19,14 @@ class FusionAddon(OpenPypeModule, IHostAddon): os.path.join(FUSION_HOST_DIR, "hooks") ] + def add_implementation_envs(self, env, _app): + # Set default values if are not already set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "Yes" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value + def get_workfile_extensions(self): return [".comp"] diff --git a/openpype/hosts/fusion/api/__init__.py b/openpype/hosts/fusion/api/__init__.py index 19d1e092fe..ed70dbca50 100644 --- a/openpype/hosts/fusion/api/__init__.py +++ b/openpype/hosts/fusion/api/__init__.py @@ -5,10 +5,7 @@ from .pipeline import ( ls, imprint_container, - parse_container, - - get_current_comp, - comp_lock_and_undo_chunk + parse_container ) from .workio import ( @@ -22,8 +19,10 @@ from .workio import ( from .lib import ( maintained_selection, - get_additional_data, - update_frame_range + update_frame_range, + set_asset_framerange, + get_current_comp, + comp_lock_and_undo_chunk ) from .menu import launch_openpype_menu @@ -38,9 +37,6 @@ __all__ = [ "imprint_container", "parse_container", - "get_current_comp", - "comp_lock_and_undo_chunk", - # workio "open_file", "save_file", @@ -51,8 +47,10 @@ __all__ = [ # lib "maintained_selection", - "get_additional_data", "update_frame_range", + "set_asset_framerange", + "get_current_comp", + "comp_lock_and_undo_chunk", # menu "launch_openpype_menu", diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 001eb636ee..a33e5cf289 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -3,8 +3,7 @@ import sys import re import contextlib -from Qt import QtGui - +from openpype.lib import Logger from openpype.client import ( get_asset_by_name, get_subset_by_name, @@ -17,13 +16,14 @@ from openpype.pipeline import ( switch_container, legacy_io, ) -from .pipeline import get_current_comp, comp_lock_and_undo_chunk +from openpype.pipeline.context_tools import get_current_project_asset self = sys.modules[__name__] self._project = None -def update_frame_range(start, end, comp=None, set_render_range=True): +def update_frame_range(start, end, comp=None, set_render_range=True, + handle_start=0, handle_end=0): """Set Fusion comp's start and end frame range Args: @@ -32,6 +32,8 @@ def update_frame_range(start, end, comp=None, set_render_range=True): comp (object, Optional): comp object from fusion set_render_range (bool, Optional): When True this will also set the composition's render start and end frame. + handle_start (float, int, Optional): frame handles before start frame + handle_end (float, int, Optional): frame handles after end frame Returns: None @@ -41,11 +43,16 @@ def update_frame_range(start, end, comp=None, set_render_range=True): if not comp: comp = get_current_comp() + # Convert any potential none type to zero + handle_start = handle_start or 0 + handle_end = handle_end or 0 + attrs = { - "COMPN_GlobalStart": start, - "COMPN_GlobalEnd": end + "COMPN_GlobalStart": start - handle_start, + "COMPN_GlobalEnd": end + handle_end } + # set frame range if set_render_range: attrs.update({ "COMPN_RenderStart": start, @@ -56,24 +63,122 @@ def update_frame_range(start, end, comp=None, set_render_range=True): comp.SetAttrs(attrs) -def get_additional_data(container): - """Get Fusion related data for the container +def set_asset_framerange(): + """Set Comp's frame range based on current asset""" + asset_doc = get_current_project_asset() + start = asset_doc["data"]["frameStart"] + end = asset_doc["data"]["frameEnd"] + handle_start = asset_doc["data"]["handleStart"] + handle_end = asset_doc["data"]["handleEnd"] + update_frame_range(start, end, set_render_range=True, + handle_start=handle_start, + handle_end=handle_end) - Args: - container(dict): the container found by the ls() function - Returns: - dict +def set_asset_resolution(): + """Set Comp's resolution width x height default based on current asset""" + asset_doc = get_current_project_asset() + width = asset_doc["data"]["resolutionWidth"] + height = asset_doc["data"]["resolutionHeight"] + comp = get_current_comp() + + print("Setting comp frame format resolution to {}x{}".format(width, + height)) + comp.SetPrefs({ + "Comp.FrameFormat.Width": width, + "Comp.FrameFormat.Height": height, + }) + + +def validate_comp_prefs(comp=None, force_repair=False): + """Validate current comp defaults with asset settings. + + Validates fps, resolutionWidth, resolutionHeight, aspectRatio. + + This does *not* validate frameStart, frameEnd, handleStart and handleEnd. """ - tool = container["_tool"] - tile_color = tool.TileColor - if tile_color is None: - return {} + if comp is None: + comp = get_current_comp() - return {"color": QtGui.QColor.fromRgbF(tile_color["R"], - tile_color["G"], - tile_color["B"])} + log = Logger.get_logger("validate_comp_prefs") + + fields = [ + "name", + "data.fps", + "data.resolutionWidth", + "data.resolutionHeight", + "data.pixelAspect" + ] + asset_doc = get_current_project_asset(fields=fields) + asset_data = asset_doc["data"] + + comp_frame_format_prefs = comp.GetPrefs("Comp.FrameFormat") + + # Pixel aspect ratio in Fusion is set as AspectX and AspectY so we convert + # the data to something that is more sensible to Fusion + asset_data["pixelAspectX"] = asset_data.pop("pixelAspect") + asset_data["pixelAspectY"] = 1.0 + + validations = [ + ("fps", "Rate", "FPS"), + ("resolutionWidth", "Width", "Resolution Width"), + ("resolutionHeight", "Height", "Resolution Height"), + ("pixelAspectX", "AspectX", "Pixel Aspect Ratio X"), + ("pixelAspectY", "AspectY", "Pixel Aspect Ratio Y") + ] + + invalid = [] + for key, comp_key, label in validations: + asset_value = asset_data[key] + comp_value = comp_frame_format_prefs.get(comp_key) + if asset_value != comp_value: + invalid_msg = "{} {} should be {}".format(label, + comp_value, + asset_value) + invalid.append(invalid_msg) + + if not force_repair: + # Do not log warning if we force repair anyway + log.warning( + "Comp {pref} {value} does not match asset " + "'{asset_name}' {pref} {asset_value}".format( + pref=label, + value=comp_value, + asset_name=asset_doc["name"], + asset_value=asset_value) + ) + + if invalid: + + def _on_repair(): + attributes = dict() + for key, comp_key, _label in validations: + value = asset_data[key] + comp_key_full = "Comp.FrameFormat.{}".format(comp_key) + attributes[comp_key_full] = value + comp.SetPrefs(attributes) + + if force_repair: + log.info("Applying default Comp preferences..") + _on_repair() + return + + from . import menu + from openpype.widgets import popup + from openpype.style import load_stylesheet + dialog = popup.Popup(parent=menu.menu) + dialog.setWindowTitle("Fusion comp has invalid configuration") + + msg = "Comp preferences mismatches '{}'".format(asset_doc["name"]) + msg += "\n" + "\n".join(invalid) + dialog.setMessage(msg) + dialog.setButtonText("Repair") + dialog.on_clicked.connect(_on_repair) + dialog.show() + dialog.raise_() + dialog.activateWindow() + dialog.setStyleSheet(load_stylesheet()) def switch_item(container, @@ -195,3 +300,21 @@ def get_frame_path(path): padding = 4 # default Fusion padding return filename, padding, ext + + +def get_current_comp(): + """Hack to get current comp in this session""" + fusion = getattr(sys.modules["__main__"], "fusion", None) + return fusion.CurrentComp if fusion else None + + +@contextlib.contextmanager +def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"): + """Lock comp and open an undo chunk during the context""" + try: + comp.Lock() + comp.StartUndo(undo_queue_name) + yield + finally: + comp.Unlock() + comp.EndUndo() diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py index 6234322d7f..39126935e6 100644 --- a/openpype/hosts/fusion/api/menu.py +++ b/openpype/hosts/fusion/api/menu.py @@ -1,43 +1,26 @@ -import os import sys -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui -from openpype import style from openpype.tools.utils import host_tools - +from openpype.style import load_stylesheet +from openpype.lib import register_event_callback from openpype.hosts.fusion.scripts import ( set_rendermode, duplicate_with_inputs ) +from openpype.hosts.fusion.api.lib import ( + set_asset_framerange, + set_asset_resolution +) +from openpype.pipeline import legacy_io +from openpype.resources import get_openpype_icon_filepath +from .pipeline import FusionEventHandler +from .pulse import FusionPulse -def load_stylesheet(): - path = os.path.join(os.path.dirname(__file__), "menu_style.qss") - if not os.path.exists(path): - print("Unable to load stylesheet, file not found in resources") - return "" - - with open(path, "r") as file_stream: - stylesheet = file_stream.read() - return stylesheet - - -class Spacer(QtWidgets.QWidget): - def __init__(self, height, *args, **kwargs): - super(Spacer, self).__init__(*args, **kwargs) - - self.setFixedHeight(height) - - real_spacer = QtWidgets.QWidget(self) - real_spacer.setObjectName("Spacer") - real_spacer.setFixedHeight(height) - - layout = QtWidgets.QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(real_spacer) - - self.setLayout(layout) +self = sys.modules[__name__] +self.menu = None class OpenPypeMenu(QtWidgets.QWidget): @@ -46,15 +29,29 @@ class OpenPypeMenu(QtWidgets.QWidget): self.setObjectName("OpenPypeMenu") + icon_path = get_openpype_icon_filepath() + icon = QtGui.QIcon(icon_path) + self.setWindowIcon(icon) + self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowMinimizeButtonHint | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowStaysOnTopHint ) self.render_mode_widget = None self.setWindowTitle("OpenPype") + + asset_label = QtWidgets.QLabel("Context", self) + asset_label.setStyleSheet("""QLabel { + font-size: 14px; + font-weight: 600; + color: #5f9fb8; + }""") + asset_label.setAlignment(QtCore.Qt.AlignHCenter) + workfiles_btn = QtWidgets.QPushButton("Workfiles...", self) create_btn = QtWidgets.QPushButton("Create...", self) publish_btn = QtWidgets.QPushButton("Publish...", self) @@ -62,77 +59,111 @@ class OpenPypeMenu(QtWidgets.QWidget): manager_btn = QtWidgets.QPushButton("Manage...", self) libload_btn = QtWidgets.QPushButton("Library...", self) rendermode_btn = QtWidgets.QPushButton("Set render mode...", self) + set_framerange_btn = QtWidgets.QPushButton("Set Frame Range", self) + set_resolution_btn = QtWidgets.QPushButton("Set Resolution", self) duplicate_with_inputs_btn = QtWidgets.QPushButton( "Duplicate with input connections", self ) - reset_resolution_btn = QtWidgets.QPushButton( - "Reset Resolution from project", self - ) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) + layout.addWidget(asset_label) + + layout.addSpacing(20) + layout.addWidget(workfiles_btn) + + layout.addSpacing(20) + layout.addWidget(create_btn) - layout.addWidget(publish_btn) layout.addWidget(load_btn) + layout.addWidget(publish_btn) layout.addWidget(manager_btn) - layout.addWidget(Spacer(15, self)) + layout.addSpacing(20) layout.addWidget(libload_btn) - layout.addWidget(Spacer(15, self)) + layout.addSpacing(20) + layout.addWidget(set_framerange_btn) + layout.addWidget(set_resolution_btn) layout.addWidget(rendermode_btn) - layout.addWidget(Spacer(15, self)) + layout.addSpacing(20) layout.addWidget(duplicate_with_inputs_btn) - layout.addWidget(reset_resolution_btn) self.setLayout(layout) + # Store reference so we can update the label + self.asset_label = asset_label + workfiles_btn.clicked.connect(self.on_workfile_clicked) create_btn.clicked.connect(self.on_create_clicked) publish_btn.clicked.connect(self.on_publish_clicked) load_btn.clicked.connect(self.on_load_clicked) manager_btn.clicked.connect(self.on_manager_clicked) libload_btn.clicked.connect(self.on_libload_clicked) - rendermode_btn.clicked.connect(self.on_rendernode_clicked) + rendermode_btn.clicked.connect(self.on_rendermode_clicked) duplicate_with_inputs_btn.clicked.connect( self.on_duplicate_with_inputs_clicked) - reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked) + set_resolution_btn.clicked.connect(self.on_set_resolution_clicked) + set_framerange_btn.clicked.connect(self.on_set_framerange_clicked) + + self._callbacks = [] + self.register_callback("taskChanged", self.on_task_changed) + self.on_task_changed() + + # Force close current process if Fusion is closed + self._pulse = FusionPulse(parent=self) + self._pulse.start() + + # Detect Fusion events as OpenPype events + self._event_handler = FusionEventHandler(parent=self) + self._event_handler.start() + + def on_task_changed(self): + # Update current context label + label = legacy_io.Session["AVALON_ASSET"] + self.asset_label.setText(label) + + def register_callback(self, name, fn): + + # Create a wrapper callback that we only store + # for as long as we want it to persist as callback + def _callback(*args): + fn() + + self._callbacks.append(_callback) + register_event_callback(name, _callback) + + def deregister_all_callbacks(self): + self._callbacks[:] = [] def on_workfile_clicked(self): - print("Clicked Workfile") host_tools.show_workfiles() def on_create_clicked(self): - print("Clicked Create") host_tools.show_creator() def on_publish_clicked(self): - print("Clicked Publish") host_tools.show_publish() def on_load_clicked(self): - print("Clicked Load") host_tools.show_loader(use_context=True) def on_manager_clicked(self): - print("Clicked Manager") host_tools.show_scene_inventory() def on_libload_clicked(self): - print("Clicked Library") host_tools.show_library_loader() - def on_rendernode_clicked(self): - print("Clicked Set Render Mode") + def on_rendermode_clicked(self): if self.render_mode_widget is None: window = set_rendermode.SetRenderMode() - window.setStyleSheet(style.load_stylesheet()) + window.setStyleSheet(load_stylesheet()) window.show() self.render_mode_widget = window else: @@ -140,15 +171,16 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_duplicate_with_inputs_clicked(self): duplicate_with_inputs.duplicate_with_input_connections() - print("Clicked Set Colorspace") - def on_reset_resolution_clicked(self): - print("Clicked Reset Resolution") + def on_set_resolution_clicked(self): + set_asset_resolution() + + def on_set_framerange_clicked(self): + set_asset_framerange() def launch_openpype_menu(): app = QtWidgets.QApplication(sys.argv) - app.setQuitOnLastWindowClosed(False) pype_menu = OpenPypeMenu() @@ -156,5 +188,8 @@ def launch_openpype_menu(): pype_menu.setStyleSheet(stylesheet) pype_menu.show() + self.menu = pype_menu - sys.exit(app.exec_()) + result = app.exec_() + print("Shutting down..") + sys.exit(result) diff --git a/openpype/hosts/fusion/api/menu_style.qss b/openpype/hosts/fusion/api/menu_style.qss deleted file mode 100644 index 12c474b070..0000000000 --- a/openpype/hosts/fusion/api/menu_style.qss +++ /dev/null @@ -1,29 +0,0 @@ -QWidget { - background-color: #282828; - border-radius: 3; -} - -QPushButton { - border: 1px solid #090909; - background-color: #201f1f; - color: #ffffff; - padding: 5; -} - -QPushButton:focus { - background-color: "#171717"; - color: #d0d0d0; -} - -QPushButton:hover { - background-color: "#171717"; - color: #e64b3d; -} - -#OpenPypeMenu { - border: 1px solid #fef9ef; -} - -#Spacer { - background-color: #282828; -} diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 987eae214b..b22ee5328f 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -4,11 +4,15 @@ Basic avalon integration import os import sys import logging -import contextlib import pyblish.api +from Qt import QtCore -from openpype.lib import Logger +from openpype.lib import ( + Logger, + register_event_callback, + emit_event +) from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, @@ -18,7 +22,15 @@ from openpype.pipeline import ( deregister_inventory_action_path, AVALON_CONTAINER_ID, ) +from openpype.pipeline.load import any_outdated_containers from openpype.hosts.fusion import FUSION_HOST_DIR +from openpype.tools.utils import host_tools + +from .lib import ( + get_current_comp, + comp_lock_and_undo_chunk, + validate_comp_prefs +) log = Logger.get_logger(__name__) @@ -30,16 +42,17 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") -class CompLogHandler(logging.Handler): +class FusionLogHandler(logging.Handler): + # Keep a reference to fusion's Print function (Remote Object) + _print = getattr(sys.modules["__main__"], "fusion").Print + def emit(self, record): entry = self.format(record) - comp = get_current_comp() - if comp: - comp.Print(entry) + self._print(entry) def install(): - """Install fusion-specific functionality of avalon-core. + """Install fusion-specific functionality of OpenPype. This is where you install menus and register families, data and loaders into fusion. @@ -51,20 +64,18 @@ def install(): """ # Remove all handlers associated with the root logger object, because - # that one sometimes logs as "warnings" incorrectly. + # that one always logs as "warnings" incorrectly. for handler in logging.root.handlers[:]: logging.root.removeHandler(handler) # Attach default logging handler that prints to active comp logger = logging.getLogger() formatter = logging.Formatter(fmt="%(message)s\n") - handler = CompLogHandler() + handler = FusionLogHandler() handler.setFormatter(formatter) logger.addHandler(handler) logger.setLevel(logging.DEBUG) - log.info("openpype.hosts.fusion installed") - pyblish.api.register_host("fusion") pyblish.api.register_plugin_path(PUBLISH_PATH) log.info("Registering Fusion plug-ins..") @@ -77,6 +88,11 @@ def install(): "instanceToggled", on_pyblish_instance_toggled ) + # Register events + register_event_callback("open", on_after_open) + register_event_callback("save", on_save) + register_event_callback("new", on_new) + def uninstall(): """Uninstall all that was installed @@ -102,7 +118,7 @@ def uninstall(): ) -def on_pyblish_instance_toggled(instance, new_value, old_value): +def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle saver tool passthrough states on instance toggles.""" comp = instance.context.data.get("currentComp") if not comp: @@ -125,6 +141,48 @@ def on_pyblish_instance_toggled(instance, new_value, old_value): tool.SetAttrs({"TOOLB_PassThrough": passthrough}) +def on_new(event): + comp = event["Rets"]["comp"] + validate_comp_prefs(comp, force_repair=True) + + +def on_save(event): + comp = event["sender"] + validate_comp_prefs(comp) + + +def on_after_open(event): + comp = event["sender"] + validate_comp_prefs(comp) + + if any_outdated_containers(): + log.warning("Scene has outdated content.") + + # Find OpenPype menu to attach to + from . import menu + + def _on_show_scene_inventory(): + # ensure that comp is active + frame = comp.CurrentFrame + if not frame: + print("Comp is closed, skipping show scene inventory") + return + frame.ActivateFrame() # raise comp window + host_tools.show_scene_inventory() + + from openpype.widgets import popup + from openpype.style import load_stylesheet + dialog = popup.Popup(parent=menu.menu) + dialog.setWindowTitle("Fusion comp has outdated content") + dialog.setMessage("There are outdated containers in " + "your Fusion comp.") + dialog.on_clicked.connect(_on_show_scene_inventory) + dialog.show() + dialog.raise_() + dialog.activateWindow() + dialog.setStyleSheet(load_stylesheet()) + + def ls(): """List containers from active Fusion scene @@ -138,7 +196,7 @@ def ls(): """ comp = get_current_comp() - tools = comp.GetToolList(False, "Loader").values() + tools = comp.GetToolList(False).values() for tool in tools: container = parse_container(tool) @@ -210,19 +268,114 @@ def parse_container(tool): return container -def get_current_comp(): - """Hack to get current comp in this session""" - fusion = getattr(sys.modules["__main__"], "fusion", None) - return fusion.CurrentComp if fusion else None +class FusionEventThread(QtCore.QThread): + """QThread which will periodically ping Fusion app for any events. + + The fusion.UIManager must be set up to be notified of events before they'll + be reported by this thread, for example: + fusion.UIManager.AddNotify("Comp_Save", None) + + """ + + on_event = QtCore.Signal(dict) + + def run(self): + + app = getattr(sys.modules["__main__"], "app", None) + if app is None: + # No Fusion app found + return + + # As optimization store the GetEvent method directly because every + # getattr of UIManager.GetEvent tries to resolve the Remote Function + # through the PyRemoteObject + get_event = app.UIManager.GetEvent + delay = int(os.environ.get("OPENPYPE_FUSION_CALLBACK_INTERVAL", 1000)) + while True: + if self.isInterruptionRequested(): + return + + # Process all events that have been queued up until now + while True: + event = get_event(False) + if not event: + break + self.on_event.emit(event) + + # Wait some time before processing events again + # to not keep blocking the UI + self.msleep(delay) -@contextlib.contextmanager -def comp_lock_and_undo_chunk(comp, undo_queue_name="Script CMD"): - """Lock comp and open an undo chunk during the context""" - try: - comp.Lock() - comp.StartUndo(undo_queue_name) - yield - finally: - comp.Unlock() - comp.EndUndo() +class FusionEventHandler(QtCore.QObject): + """Emits OpenPype events based on Fusion events captured in a QThread. + + This will emit the following OpenPype events based on Fusion actions: + save: Comp_Save, Comp_SaveAs + open: Comp_Opened + new: Comp_New + + To use this you can attach it to you Qt UI so it runs in the background. + E.g. + >>> handler = FusionEventHandler(parent=window) + >>> handler.start() + + + """ + ACTION_IDS = [ + "Comp_Save", + "Comp_SaveAs", + "Comp_New", + "Comp_Opened" + ] + + def __init__(self, parent=None): + super(FusionEventHandler, self).__init__(parent=parent) + + # Set up Fusion event callbacks + fusion = getattr(sys.modules["__main__"], "fusion", None) + ui = fusion.UIManager + + # Add notifications for the ones we want to listen to + notifiers = [] + for action_id in self.ACTION_IDS: + notifier = ui.AddNotify(action_id, None) + notifiers.append(notifier) + + # TODO: Not entirely sure whether these must be kept to avoid + # garbage collection + self._notifiers = notifiers + + self._event_thread = FusionEventThread(parent=self) + self._event_thread.on_event.connect(self._on_event) + + def start(self): + self._event_thread.start() + + def stop(self): + self._event_thread.stop() + + def _on_event(self, event): + """Handle Fusion events to emit OpenPype events""" + if not event: + return + + what = event["what"] + + # Comp Save + if what in {"Comp_Save", "Comp_SaveAs"}: + if not event["Rets"].get("success"): + # If the Save action is cancelled it will still emit an + # event but with "success": False so we ignore those cases + return + # Comp was saved + emit_event("save", data=event) + return + + # Comp New + elif what in {"Comp_New"}: + emit_event("new", data=event) + + # Comp Opened + elif what in {"Comp_Opened"}: + emit_event("open", data=event) diff --git a/openpype/hosts/fusion/api/pulse.py b/openpype/hosts/fusion/api/pulse.py new file mode 100644 index 0000000000..eb7ef3785d --- /dev/null +++ b/openpype/hosts/fusion/api/pulse.py @@ -0,0 +1,63 @@ +import os +import sys + +from Qt import QtCore + + +class PulseThread(QtCore.QThread): + no_response = QtCore.Signal() + + def __init__(self, parent=None): + super(PulseThread, self).__init__(parent=parent) + + def run(self): + app = getattr(sys.modules["__main__"], "app", None) + + # Interval in milliseconds + interval = os.environ.get("OPENPYPE_FUSION_PULSE_INTERVAL", 1000) + + while True: + if self.isInterruptionRequested(): + return + + # We don't need to call Test because PyRemoteObject of the app + # will actually fail to even resolve the Test function if it has + # gone down. So we can actually already just check by confirming + # the method is still getting resolved. (Optimization) + if app.Test is None: + self.no_response.emit() + + self.msleep(interval) + + +class FusionPulse(QtCore.QObject): + """A Timer that checks whether host app is still alive. + + This checks whether the Fusion process is still active at a certain + interval. This is useful due to how Fusion runs its scripts. Each script + runs in its own environment and process (a `fusionscript` process each). + If Fusion would go down and we have a UI process running at the same time + then it can happen that the `fusionscript.exe` will remain running in the + background in limbo due to e.g. a Qt interface's QApplication that keeps + running infinitely. + + Warning: + When the host is not detected this will automatically exit + the current process. + + """ + + def __init__(self, parent=None): + super(FusionPulse, self).__init__(parent=parent) + self._thread = PulseThread(parent=self) + self._thread.no_response.connect(self.on_no_response) + + def on_no_response(self): + print("Pulse detected no response from Fusion..") + sys.exit(1) + + def start(self): + self._thread.start() + + def stop(self): + self._thread.requestInterruption() diff --git a/openpype/hosts/fusion/api/workio.py b/openpype/hosts/fusion/api/workio.py index 89752d3e6d..939b2ff4be 100644 --- a/openpype/hosts/fusion/api/workio.py +++ b/openpype/hosts/fusion/api/workio.py @@ -2,7 +2,7 @@ import sys import os -from .pipeline import get_current_comp +from .lib import get_current_comp def file_extensions(): diff --git a/openpype/hosts/fusion/deploy/Config/openpype_menu.fu b/openpype/hosts/fusion/deploy/Config/openpype_menu.fu new file mode 100644 index 0000000000..8b8d448259 --- /dev/null +++ b/openpype/hosts/fusion/deploy/Config/openpype_menu.fu @@ -0,0 +1,60 @@ +{ + Action + { + ID = "OpenPype_Menu", + Category = "OpenPype", + Name = "OpenPype Menu", + + Targets = + { + Composition = + { + Execute = _Lua [=[ + local scriptPath = app:MapPath("OpenPype:MenuScripts/openpype_menu.py") + if bmd.fileexists(scriptPath) == false then + print("[OpenPype Error] Can't run file: " .. scriptPath) + else + target:RunScript(scriptPath) + end + ]=], + }, + }, + }, + Action + { + ID = "OpenPype_Install_PySide2", + Category = "OpenPype", + Name = "Install PySide2", + + Targets = + { + Composition = + { + Execute = _Lua [=[ + local scriptPath = app:MapPath("OpenPype:MenuScripts/install_pyside2.py") + if bmd.fileexists(scriptPath) == false then + print("[OpenPype Error] Can't run file: " .. scriptPath) + else + target:RunScript(scriptPath) + end + ]=], + }, + }, + }, + Menus + { + Target = "ChildFrame", + + Before "Help" + { + Sub "OpenPype" + { + "OpenPype_Menu{}", + "_", + Sub "Admin" { + "OpenPype_Install_PySide2{}" + } + } + }, + }, +} diff --git a/openpype/hosts/fusion/deploy/MenuScripts/README.md b/openpype/hosts/fusion/deploy/MenuScripts/README.md new file mode 100644 index 0000000000..f87eaea4a2 --- /dev/null +++ b/openpype/hosts/fusion/deploy/MenuScripts/README.md @@ -0,0 +1,6 @@ +### OpenPype deploy MenuScripts + +Note that this `MenuScripts` is not an official Fusion folder. +OpenPype only uses this folder in `{fusion}/deploy/` to trigger the OpenPype menu actions. + +They are used in the actions defined in `.fu` files in `{fusion}/deploy/Config`. \ No newline at end of file diff --git a/openpype/hosts/fusion/deploy/MenuScripts/install_pyside2.py b/openpype/hosts/fusion/deploy/MenuScripts/install_pyside2.py new file mode 100644 index 0000000000..ab9f13ce05 --- /dev/null +++ b/openpype/hosts/fusion/deploy/MenuScripts/install_pyside2.py @@ -0,0 +1,29 @@ +# This is just a quick hack for users running Py3 locally but having no +# Qt library installed +import os +import subprocess +import importlib + + +try: + from Qt import QtWidgets # noqa: F401 + from Qt import __binding__ + print(f"Qt binding: {__binding__}") + mod = importlib.import_module(__binding__) + print(f"Qt path: {mod.__file__}") + print("Qt library found, nothing to do..") + +except ImportError: + print("Assuming no Qt library is installed..") + print('Installing PySide2 for Python 3.6: ' + f'{os.environ["FUSION16_PYTHON36_HOME"]}') + + # Get full path to python executable + exe = "python.exe" if os.name == 'nt' else "python" + python = os.path.join(os.environ["FUSION16_PYTHON36_HOME"], exe) + assert os.path.exists(python), f"Python doesn't exist: {python}" + + # Do python -m pip install PySide2 + args = [python, "-m", "pip", "install", "PySide2"] + print(f"Args: {args}") + subprocess.Popen(args) diff --git a/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py b/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py similarity index 51% rename from openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py rename to openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py index 870e74280a..2918c552c8 100644 --- a/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py +++ b/openpype/hosts/fusion/deploy/MenuScripts/openpype_menu.py @@ -9,6 +9,10 @@ from openpype.pipeline import ( def main(env): + # This script working directory starts in Fusion application folder. + # However the contents of that folder can conflict with Qt library dlls + # so we make sure to move out of it to avoid DLL Load Failed errors. + os.chdir("..") from openpype.hosts.fusion import api from openpype.hosts.fusion.api import menu @@ -20,6 +24,11 @@ def main(env): menu.launch_openpype_menu() + # Initiate a QTimer to check if Fusion is still alive every X interval + # If Fusion is not found - kill itself + # todo(roy): Implement timer that ensures UI doesn't remain when e.g. + # Fusion closes down + if __name__ == "__main__": result = main(os.environ) diff --git a/openpype/hosts/fusion/utility_scripts/32bit/backgrounds_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py similarity index 100% rename from openpype/hosts/fusion/utility_scripts/32bit/backgrounds_selected_to32bit.py rename to openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_selected_to32bit.py diff --git a/openpype/hosts/fusion/utility_scripts/32bit/backgrounds_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py similarity index 100% rename from openpype/hosts/fusion/utility_scripts/32bit/backgrounds_to32bit.py rename to openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/backgrounds_to32bit.py diff --git a/openpype/hosts/fusion/utility_scripts/32bit/loaders_selected_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py similarity index 100% rename from openpype/hosts/fusion/utility_scripts/32bit/loaders_selected_to32bit.py rename to openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_selected_to32bit.py diff --git a/openpype/hosts/fusion/utility_scripts/32bit/loaders_to32bit.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py similarity index 100% rename from openpype/hosts/fusion/utility_scripts/32bit/loaders_to32bit.py rename to openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/32bit/loaders_to32bit.py diff --git a/openpype/hosts/fusion/utility_scripts/switch_ui.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py similarity index 100% rename from openpype/hosts/fusion/utility_scripts/switch_ui.py rename to openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/switch_ui.py diff --git a/openpype/hosts/fusion/utility_scripts/update_loader_ranges.py b/openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py similarity index 100% rename from openpype/hosts/fusion/utility_scripts/update_loader_ranges.py rename to openpype/hosts/fusion/deploy/Scripts/Comp/OpenPype/update_loader_ranges.py diff --git a/openpype/hosts/fusion/deploy/fusion_shared.prefs b/openpype/hosts/fusion/deploy/fusion_shared.prefs new file mode 100644 index 0000000000..998c6a6d66 --- /dev/null +++ b/openpype/hosts/fusion/deploy/fusion_shared.prefs @@ -0,0 +1,19 @@ +{ +Locked = true, +Global = { + Paths = { + Map = { + ["OpenPype:"] = "$(OPENPYPE_FUSION)/deploy", + ["Reactor:"] = "$(REACTOR)", + + ["Config:"] = "UserPaths:Config;OpenPype:Config", + ["Scripts:"] = "UserPaths:Scripts;Reactor:System/Scripts;OpenPype:Scripts", + ["UserPaths:"] = "UserData:;AllData:;Fusion:;Reactor:Deploy" + }, + }, + Script = { + PythonVersion = 3, + Python3Forced = true + }, + }, +} \ No newline at end of file diff --git a/openpype/hosts/fusion/hooks/pre_fusion_ocio_hook.py b/openpype/hosts/fusion/hooks/pre_fusion_ocio_hook.py new file mode 100644 index 0000000000..d1ae5f64fd --- /dev/null +++ b/openpype/hosts/fusion/hooks/pre_fusion_ocio_hook.py @@ -0,0 +1,34 @@ +import os +import platform + +from openpype.lib import PreLaunchHook, ApplicationLaunchFailed + + +class FusionPreLaunchOCIO(PreLaunchHook): + """Set OCIO environment variable for Fusion""" + app_groups = ["fusion"] + + def execute(self): + """Hook entry method.""" + + # get image io + project_settings = self.data["project_settings"] + + # make sure anatomy settings are having flame key + imageio_fusion = project_settings["fusion"]["imageio"] + + ocio = imageio_fusion.get("ocio") + enabled = ocio.get("enabled", False) + if not enabled: + return + + platform_key = platform.system().lower() + ocio_path = ocio["configFilePath"][platform_key] + if not ocio_path: + raise ApplicationLaunchFailed( + "Fusion OCIO is enabled in project settings but no OCIO config" + f"path is set for your current platform: {platform_key}" + ) + + self.log.info(f"Setting OCIO config path: {ocio_path}") + self.launch_context.env["OCIO"] = os.pathsep.join(ocio_path) diff --git a/openpype/hosts/fusion/hooks/pre_fusion_setup.py b/openpype/hosts/fusion/hooks/pre_fusion_setup.py index e635a0ea74..d043d54322 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_setup.py +++ b/openpype/hosts/fusion/hooks/pre_fusion_setup.py @@ -1,114 +1,61 @@ import os -import shutil - -import openpype.hosts.fusion from openpype.lib import PreLaunchHook, ApplicationLaunchFailed +from openpype.hosts.fusion import FUSION_HOST_DIR class FusionPrelaunch(PreLaunchHook): - """ - This hook will check if current workfile path has Fusion - project inside. + """Prepares OpenPype Fusion environment + + Requires FUSION_PYTHON3_HOME to be defined in the environment for Fusion + to point at a valid Python 3 build for Fusion. That is Python 3.3-3.10 + for Fusion 18 and Fusion 3.6 for Fusion 16 and 17. + + This also sets FUSION16_MasterPrefs to apply the fusion master prefs + as set in openpype/hosts/fusion/deploy/fusion_shared.prefs to enable + the OpenPype menu and force Python 3 over Python 2. + """ app_groups = ["fusion"] def execute(self): - # making sure python 3.6 is installed at provided path - py36_dir = self.launch_context.env.get("PYTHON36") - if not py36_dir: + # making sure python 3 is installed at provided path + # Py 3.3-3.10 for Fusion 18+ or Py 3.6 for Fu 16-17 + py3_var = "FUSION_PYTHON3_HOME" + fusion_python3_home = self.launch_context.env.get(py3_var, "") + + self.log.info(f"Looking for Python 3 in: {fusion_python3_home}") + for path in fusion_python3_home.split(os.pathsep): + # Allow defining multiple paths to allow "fallback" to other + # path. But make to set only a single path as final variable. + py3_dir = os.path.normpath(path) + if os.path.isdir(py3_dir): + break + else: raise ApplicationLaunchFailed( - "Required environment variable \"PYTHON36\" is not set." - "\n\nFusion implementation requires to have" - " installed Python 3.6" + "Python 3 is not installed at the provided path.\n" + "Make sure the environment in fusion settings has " + "'FUSION_PYTHON3_HOME' set correctly and make sure " + "Python 3 is installed in the given path." + f"\n\nPYTHON36: {fusion_python3_home}" ) - py36_dir = os.path.normpath(py36_dir) - if not os.path.isdir(py36_dir): - raise ApplicationLaunchFailed( - "Python 3.6 is not installed at the provided path.\n" - "Either make sure the environments in fusion settings has" - " 'PYTHON36' set corectly or make sure Python 3.6 is installed" - f" in the given path.\n\nPYTHON36: {py36_dir}" - ) - self.log.info(f"Path to Fusion Python folder: '{py36_dir}'...") - self.launch_context.env["PYTHON36"] = py36_dir + self.log.info(f"Setting {py3_var}: '{py3_dir}'...") + self.launch_context.env[py3_var] = py3_dir - utility_dir = self.launch_context.env.get("FUSION_UTILITY_SCRIPTS_DIR") - if not utility_dir: - raise ApplicationLaunchFailed( - "Required Fusion utility script dir environment variable" - " \"FUSION_UTILITY_SCRIPTS_DIR\" is not set." - ) + # Fusion 18+ requires FUSION_PYTHON3_HOME to also be on PATH + self.launch_context.env["PATH"] += ";" + py3_dir - # setting utility scripts dir for scripts syncing - utility_dir = os.path.normpath(utility_dir) - if not os.path.isdir(utility_dir): - raise ApplicationLaunchFailed( - "Fusion utility script dir does not exist. Either make sure " - "the environments in fusion settings has" - " 'FUSION_UTILITY_SCRIPTS_DIR' set correctly or reinstall " - f"Fusion.\n\nFUSION_UTILITY_SCRIPTS_DIR: '{utility_dir}'" - ) + # Fusion 16 and 17 use FUSION16_PYTHON36_HOME instead of + # FUSION_PYTHON3_HOME and will only work with a Python 3.6 version + # TODO: Detect Fusion version to only set for specific Fusion build + self.launch_context.env["FUSION16_PYTHON36_HOME"] = py3_dir - self._sync_utility_scripts(self.launch_context.env) - self.log.info("Fusion Pype wrapper has been installed") + # Add our Fusion Master Prefs which is the only way to customize + # Fusion to define where it can read custom scripts and tools from + self.log.info(f"Setting OPENPYPE_FUSION: {FUSION_HOST_DIR}") + self.launch_context.env["OPENPYPE_FUSION"] = FUSION_HOST_DIR - def _sync_utility_scripts(self, env): - """ Synchronizing basic utlility scripts for resolve. - - To be able to run scripts from inside `Fusion/Workspace/Scripts` menu - all scripts has to be accessible from defined folder. - """ - if not env: - env = {k: v for k, v in os.environ.items()} - - # initiate inputs - scripts = {} - us_env = env.get("FUSION_UTILITY_SCRIPTS_SOURCE_DIR") - us_dir = env.get("FUSION_UTILITY_SCRIPTS_DIR", "") - us_paths = [os.path.join( - os.path.dirname(os.path.abspath(openpype.hosts.fusion.__file__)), - "utility_scripts" - )] - - # collect script dirs - if us_env: - self.log.info(f"Utility Scripts Env: `{us_env}`") - us_paths = us_env.split( - os.pathsep) + us_paths - - # collect scripts from dirs - for path in us_paths: - scripts.update({path: os.listdir(path)}) - - self.log.info(f"Utility Scripts Dir: `{us_paths}`") - self.log.info(f"Utility Scripts: `{scripts}`") - - # make sure no script file is in folder - if next((s for s in os.listdir(us_dir)), None): - for s in os.listdir(us_dir): - path = os.path.normpath( - os.path.join(us_dir, s)) - self.log.info(f"Removing `{path}`...") - - # remove file or directory if not in our folders - if not os.path.isdir(path): - os.remove(path) - else: - shutil.rmtree(path) - - # copy scripts into Resolve's utility scripts dir - for d, sl in scripts.items(): - # directory and scripts list - for s in sl: - # script in script list - src = os.path.normpath(os.path.join(d, s)) - dst = os.path.normpath(os.path.join(us_dir, s)) - - self.log.info(f"Copying `{src}` to `{dst}`...") - - # copy file or directory from our folders to fusion's folder - if not os.path.isdir(src): - shutil.copy2(src, dst) - else: - shutil.copytree(src, dst) + pref_var = "FUSION16_MasterPrefs" # used by Fusion 16, 17 and 18 + prefs = os.path.join(FUSION_HOST_DIR, "deploy", "fusion_shared.prefs") + self.log.info(f"Setting {pref_var}: {prefs}") + self.launch_context.env[pref_var] = prefs diff --git a/openpype/hosts/fusion/plugins/create/create_exr_saver.py b/openpype/hosts/fusion/plugins/create/create_exr_saver.py index 8bab5ee9b1..6d93fe710a 100644 --- a/openpype/hosts/fusion/plugins/create/create_exr_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_exr_saver.py @@ -1,6 +1,9 @@ import os -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + legacy_io +) from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk @@ -21,12 +24,9 @@ class CreateOpenEXRSaver(LegacyCreator): comp = get_current_comp() - # todo: improve method of getting current environment - # todo: pref avalon.Session over os.environ + workdir = os.path.normpath(legacy_io.Session["AVALON_WORKDIR"]) - workdir = os.path.normpath(os.environ["AVALON_WORKDIR"]) - - filename = "{}..tiff".format(self.name) + filename = "{}..exr".format(self.name) filepath = os.path.join(workdir, "render", filename) with comp_lock_and_undo_chunk(comp): @@ -39,10 +39,10 @@ class CreateOpenEXRSaver(LegacyCreator): saver["Clip"] = filepath saver["OutputFormat"] = file_format - # # # Set standard TIFF settings + # Check file format settings are available if saver[file_format] is None: - raise RuntimeError("File format is not set to TiffFormat, " - "this is a bug") + raise RuntimeError("File format is not set to {}, " + "this is a bug".format(file_format)) # Set file format attributes saver[file_format]["Depth"] = 1 # int8 | int16 | float32 | other diff --git a/openpype/hosts/fusion/plugins/load/load_alembic.py b/openpype/hosts/fusion/plugins/load/load_alembic.py new file mode 100644 index 0000000000..f8b8c2cb0a --- /dev/null +++ b/openpype/hosts/fusion/plugins/load/load_alembic.py @@ -0,0 +1,70 @@ +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.fusion.api import ( + imprint_container, + get_current_comp, + comp_lock_and_undo_chunk +) + + +class FusionLoadAlembicMesh(load.LoaderPlugin): + """Load Alembic mesh into Fusion""" + + families = ["pointcache", "model"] + representations = ["abc"] + + label = "Load alembic mesh" + order = -10 + icon = "code-fork" + color = "orange" + + tool_type = "SurfaceAlembicMesh" + + def load(self, context, name, namespace, data): + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + # Create the Loader with the filename path set + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp, "Create tool"): + + path = self.fname + + args = (-32768, -32768) + tool = comp.AddTool(self.tool_type, *args) + tool["Filename"] = path + + imprint_container(tool, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + """Update Alembic path""" + + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + path = get_representation_path(representation) + + with comp_lock_and_undo_chunk(comp, "Update tool"): + tool["Filename"] = path + + # Update the imprinted representation + tool.SetData("avalon.representation", str(representation["_id"])) + + def remove(self, container): + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + with comp_lock_and_undo_chunk(comp, "Remove tool"): + tool.Delete() diff --git a/openpype/hosts/fusion/plugins/load/load_fbx.py b/openpype/hosts/fusion/plugins/load/load_fbx.py new file mode 100644 index 0000000000..70fe82ffef --- /dev/null +++ b/openpype/hosts/fusion/plugins/load/load_fbx.py @@ -0,0 +1,71 @@ + +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.fusion.api import ( + imprint_container, + get_current_comp, + comp_lock_and_undo_chunk +) + + +class FusionLoadFBXMesh(load.LoaderPlugin): + """Load FBX mesh into Fusion""" + + families = ["*"] + representations = ["fbx"] + + label = "Load FBX mesh" + order = -10 + icon = "code-fork" + color = "orange" + + tool_type = "SurfaceFBXMesh" + + def load(self, context, name, namespace, data): + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + # Create the Loader with the filename path set + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp, "Create tool"): + + path = self.fname + + args = (-32768, -32768) + tool = comp.AddTool(self.tool_type, *args) + tool["ImportFile"] = path + + imprint_container(tool, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + """Update path""" + + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + path = get_representation_path(representation) + + with comp_lock_and_undo_chunk(comp, "Update tool"): + tool["ImportFile"] = path + + # Update the imprinted representation + tool.SetData("avalon.representation", str(representation["_id"])) + + def remove(self, container): + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + with comp_lock_and_undo_chunk(comp, "Remove tool"): + tool.Delete() diff --git a/openpype/hosts/fusion/plugins/load/load_sequence.py b/openpype/hosts/fusion/plugins/load/load_sequence.py index abd0f4e411..6f44c61d1b 100644 --- a/openpype/hosts/fusion/plugins/load/load_sequence.py +++ b/openpype/hosts/fusion/plugins/load/load_sequence.py @@ -101,6 +101,9 @@ def loader_shift(loader, frame, relative=True): else: shift = frame - old_in + if not shift: + return 0 + # Shifting global in will try to automatically compensate for the change # in the "ClipTimeStart" and "HoldFirstFrame" inputs, so we preserve those # input values to "just shift" the clip @@ -149,9 +152,8 @@ class FusionLoadSequence(load.LoaderPlugin): tool["Clip"] = path # Set global in point to start frame (if in version.data) - start = context["version"]["data"].get("frameStart", None) - if start is not None: - loader_shift(tool, start, relative=False) + start = self._get_start(context["version"], tool) + loader_shift(tool, start, relative=False) imprint_container(tool, name=name, @@ -214,12 +216,7 @@ class FusionLoadSequence(load.LoaderPlugin): # Get start frame from version data project_name = legacy_io.active_project() version = get_version_by_id(project_name, representation["parent"]) - start = version["data"].get("frameStart") - if start is None: - self.log.warning("Missing start frame for updated version" - "assuming starts at frame 0 for: " - "{} ({})".format(tool.Name, representation)) - start = 0 + start = self._get_start(version, tool) with comp_lock_and_undo_chunk(comp, "Update Loader"): @@ -256,3 +253,27 @@ class FusionLoadSequence(load.LoaderPlugin): """Get first file in representation root""" files = sorted(os.listdir(root)) return os.path.join(root, files[0]) + + def _get_start(self, version_doc, tool): + """Return real start frame of published files (incl. handles)""" + data = version_doc["data"] + + # Get start frame directly with handle if it's in data + start = data.get("frameStartHandle") + if start is not None: + return start + + # Get frame start without handles + start = data.get("frameStart") + if start is None: + self.log.warning("Missing start frame for version " + "assuming starts at frame 0 for: " + "{}".format(tool.Name)) + return 0 + + # Use `handleStart` if the data is available + handle_start = data.get("handleStart") + if handle_start: + start -= handle_start + + return start diff --git a/openpype/hosts/fusion/plugins/publish/collect_inputs.py b/openpype/hosts/fusion/plugins/publish/collect_inputs.py new file mode 100644 index 0000000000..8f9857b02f --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/collect_inputs.py @@ -0,0 +1,114 @@ +from bson.objectid import ObjectId + +import pyblish.api + +from openpype.pipeline import registered_host + + +def collect_input_containers(tools): + """Collect containers that contain any of the node in `nodes`. + + This will return any loaded Avalon container that contains at least one of + the nodes. As such, the Avalon container is an input for it. Or in short, + there are member nodes of that container. + + Returns: + list: Input avalon containers + + """ + + # Lookup by node ids + lookup = frozenset([tool.Name for tool in tools]) + + containers = [] + host = registered_host() + for container in host.ls(): + + name = container["_tool"].Name + + # We currently assume no "groups" as containers but just single tools + # like a single "Loader" operator. As such we just check whether the + # Loader is part of the processing queue. + if name in lookup: + containers.append(container) + + return containers + + +def iter_upstream(tool): + """Yields all upstream inputs for the current tool. + + Yields: + tool: The input tools. + + """ + + def get_connected_input_tools(tool): + """Helper function that returns connected input tools for a tool.""" + inputs = [] + + # Filter only to actual types that will have sensible upstream + # connections. So we ignore just "Number" inputs as they can be + # many to iterate, slowing things down quite a bit - and in practice + # they don't have upstream connections. + VALID_INPUT_TYPES = ['Image', 'Particles', 'Mask', 'DataType3D'] + for type_ in VALID_INPUT_TYPES: + for input_ in tool.GetInputList(type_).values(): + output = input_.GetConnectedOutput() + if output: + input_tool = output.GetTool() + inputs.append(input_tool) + + return inputs + + # Initialize process queue with the node's inputs itself + queue = get_connected_input_tools(tool) + + # We keep track of which node names we have processed so far, to ensure we + # don't process the same hierarchy again. We are not pushing the tool + # itself into the set as that doesn't correctly recognize the same tool. + # Since tool names are unique in a comp in Fusion we rely on that. + collected = set(tool.Name for tool in queue) + + # Traverse upstream references for all nodes and yield them as we + # process the queue. + while queue: + upstream_tool = queue.pop() + yield upstream_tool + + # Find upstream tools that are not collected yet. + upstream_inputs = get_connected_input_tools(upstream_tool) + upstream_inputs = [t for t in upstream_inputs if + t.Name not in collected] + + queue.extend(upstream_inputs) + collected.update(tool.Name for tool in upstream_inputs) + + +class CollectUpstreamInputs(pyblish.api.InstancePlugin): + """Collect source input containers used for this publish. + + This will include `inputs` data of which loaded publishes were used in the + generation of this publish. This leaves an upstream trace to what was used + as input. + + """ + + label = "Collect Inputs" + order = pyblish.api.CollectorOrder + 0.2 + hosts = ["fusion"] + + def process(self, instance): + + # Get all upstream and include itself + tool = instance[0] + nodes = list(iter_upstream(tool)) + nodes.append(tool) + + # Collect containers for the given set of nodes + containers = collect_input_containers(nodes) + + inputs = [ObjectId(c["representation"]) for c in containers] + instance.data["inputRepresentations"] = inputs + + self.log.info("Collected inputs: %s" % inputs) diff --git a/openpype/hosts/fusion/plugins/publish/collect_instances.py b/openpype/hosts/fusion/plugins/publish/collect_instances.py index b2192d1dd9..fe60b83827 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_instances.py +++ b/openpype/hosts/fusion/plugins/publish/collect_instances.py @@ -4,19 +4,21 @@ import pyblish.api def get_comp_render_range(comp): - """Return comp's start and end render range.""" + """Return comp's start-end render range and global start-end range.""" comp_attrs = comp.GetAttrs() start = comp_attrs["COMPN_RenderStart"] end = comp_attrs["COMPN_RenderEnd"] + global_start = comp_attrs["COMPN_GlobalStart"] + global_end = comp_attrs["COMPN_GlobalEnd"] # Whenever render ranges are undefined fall back # to the comp's global start and end if start == -1000000000: - start = comp_attrs["COMPN_GlobalEnd"] + start = global_start if end == -1000000000: - end = comp_attrs["COMPN_GlobalStart"] + end = global_end - return start, end + return start, end, global_start, global_end class CollectInstances(pyblish.api.ContextPlugin): @@ -42,9 +44,11 @@ class CollectInstances(pyblish.api.ContextPlugin): tools = comp.GetToolList(False).values() savers = [tool for tool in tools if tool.ID == "Saver"] - start, end = get_comp_render_range(comp) + start, end, global_start, global_end = get_comp_render_range(comp) context.data["frameStart"] = int(start) context.data["frameEnd"] = int(end) + context.data["frameStartHandle"] = int(global_start) + context.data["frameEndHandle"] = int(global_end) for tool in savers: path = tool["Clip"][comp.TIME_UNDEFINED] @@ -78,8 +82,10 @@ class CollectInstances(pyblish.api.ContextPlugin): "label": label, "frameStart": context.data["frameStart"], "frameEnd": context.data["frameEnd"], + "frameStartHandle": context.data["frameStartHandle"], + "frameEndHandle": context.data["frameStartHandle"], "fps": context.data["fps"], - "families": ["render", "review", "ftrack"], + "families": ["render", "review"], "family": "render", "active": active, "publish": active # backwards compatibility diff --git a/openpype/hosts/fusion/plugins/publish/render_local.py b/openpype/hosts/fusion/plugins/publish/render_local.py index 601c2ffccf..79e458b40a 100644 --- a/openpype/hosts/fusion/plugins/publish/render_local.py +++ b/openpype/hosts/fusion/plugins/publish/render_local.py @@ -20,6 +20,8 @@ class Fusionlocal(pyblish.api.InstancePlugin): def process(self, instance): + # This plug-in runs only once and thus assumes all instances + # currently will render the same frame range context = instance.context key = "__hasRun{}".format(self.__class__.__name__) if context.data.get(key, False): @@ -28,8 +30,8 @@ class Fusionlocal(pyblish.api.InstancePlugin): context.data[key] = True current_comp = context.data["currentComp"] - frame_start = current_comp.GetAttrs("COMPN_RenderStart") - frame_end = current_comp.GetAttrs("COMPN_RenderEnd") + frame_start = context.data["frameStartHandle"] + frame_end = context.data["frameEndHandle"] path = instance.data["path"] output_dir = instance.data["outputDir"] @@ -40,7 +42,11 @@ class Fusionlocal(pyblish.api.InstancePlugin): self.log.info("End frame: {}".format(frame_end)) with comp_lock_and_undo_chunk(current_comp): - result = current_comp.Render() + result = current_comp.Render({ + "Start": frame_start, + "End": frame_end, + "Wait": True + }) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py deleted file mode 100644 index 49ef340679..0000000000 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ /dev/null @@ -1,284 +0,0 @@ -import os -import re -import sys -import logging - -from openpype.client import ( - get_asset_by_name, - get_versions, -) -from openpype.pipeline import ( - legacy_io, - install_host, - registered_host, -) -from openpype.lib import version_up -from openpype.hosts.fusion import api -from openpype.hosts.fusion.api import lib -from openpype.pipeline.context_tools import get_workdir_from_session - -log = logging.getLogger("Update Slap Comp") - - -def _format_version_folder(folder): - """Format a version folder based on the filepath - - Assumption here is made that, if the path does not exists the folder - will be "v001" - - Args: - folder: file path to a folder - - Returns: - str: new version folder name - """ - - new_version = 1 - if os.path.isdir(folder): - re_version = re.compile(r"v\d+$") - versions = [i for i in os.listdir(folder) if os.path.isdir(i) - and re_version.match(i)] - if versions: - # ensure the "v" is not included - new_version = int(max(versions)[1:]) + 1 - - version_folder = "v{:03d}".format(new_version) - - return version_folder - - -def _get_fusion_instance(): - fusion = getattr(sys.modules["__main__"], "fusion", None) - if fusion is None: - try: - # Support for FuScript.exe, BlackmagicFusion module for py2 only - import BlackmagicFusion as bmf - fusion = bmf.scriptapp("Fusion") - except ImportError: - raise RuntimeError("Could not find a Fusion instance") - return fusion - - -def _format_filepath(session): - - project = session["AVALON_PROJECT"] - asset = session["AVALON_ASSET"] - - # Save updated slap comp - work_path = get_workdir_from_session(session) - walk_to_dir = os.path.join(work_path, "scenes", "slapcomp") - slapcomp_dir = os.path.abspath(walk_to_dir) - - # Ensure destination exists - if not os.path.isdir(slapcomp_dir): - log.warning("Folder did not exist, creating folder structure") - os.makedirs(slapcomp_dir) - - # Compute output path - new_filename = "{}_{}_slapcomp_v001.comp".format(project, asset) - new_filepath = os.path.join(slapcomp_dir, new_filename) - - # Create new unique filepath - if os.path.exists(new_filepath): - new_filepath = version_up(new_filepath) - - return new_filepath - - -def _update_savers(comp, session): - """Update all savers of the current comp to ensure the output is correct - - This will refactor the Saver file outputs to the renders of the new session - that is provided. - - In the case the original saver path had a path set relative to a /fusion/ - folder then that relative path will be matched with the exception of all - "version" (e.g. v010) references will be reset to v001. Otherwise only a - version folder will be computed in the new session's work "render" folder - to dump the files in and keeping the original filenames. - - Args: - comp (object): current comp instance - session (dict): the current Avalon session - - Returns: - None - """ - - new_work = get_workdir_from_session(session) - renders = os.path.join(new_work, "renders") - version_folder = _format_version_folder(renders) - renders_version = os.path.join(renders, version_folder) - - comp.Print("New renders to: %s\n" % renders) - - with api.comp_lock_and_undo_chunk(comp): - savers = comp.GetToolList(False, "Saver").values() - for saver in savers: - filepath = saver.GetAttrs("TOOLST_Clip_Name")[1.0] - - # Get old relative path to the "fusion" app folder so we can apply - # the same relative path afterwards. If not found fall back to - # using just a version folder with the filename in it. - # todo: can we make this less magical? - relpath = filepath.replace("\\", "/").rsplit("/fusion/", 1)[-1] - - if os.path.isabs(relpath): - # If not relative to a "/fusion/" folder then just use filename - filename = os.path.basename(filepath) - log.warning("Can't parse relative path, refactoring to only" - "filename in a version folder: %s" % filename) - new_path = os.path.join(renders_version, filename) - - else: - # Else reuse the relative path - # Reset version in folder and filename in the relative path - # to v001. The version should be is only detected when prefixed - # with either `_v` (underscore) or `/v` (folder) - version_pattern = r"(/|_)v[0-9]+" - if re.search(version_pattern, relpath): - new_relpath = re.sub(version_pattern, - r"\1v001", - relpath) - log.info("Resetting version folders to v001: " - "%s -> %s" % (relpath, new_relpath)) - relpath = new_relpath - - new_path = os.path.join(new_work, relpath) - - saver["Clip"] = new_path - - -def update_frame_range(comp, representations): - """Update the frame range of the comp and render length - - The start and end frame are based on the lowest start frame and the highest - end frame - - Args: - comp (object): current focused comp - representations (list) collection of dicts - - Returns: - None - - """ - - project_name = legacy_io.active_project() - version_ids = {r["parent"] for r in representations} - versions = list(get_versions(project_name, version_ids)) - - versions = [v for v in versions - if v["data"].get("frameStart", None) is not None] - - if not versions: - log.warning("No versions loaded to match frame range to.\n") - return - - start = min(v["data"]["frameStart"] for v in versions) - end = max(v["data"]["frameEnd"] for v in versions) - - lib.update_frame_range(start, end, comp=comp) - - -def switch(asset_name, filepath=None, new=True): - """Switch the current containers of the file to the other asset (shot) - - Args: - filepath (str): file path of the comp file - asset_name (str): name of the asset (shot) - new (bool): Save updated comp under a different name - - Returns: - comp path (str): new filepath of the updated comp - - """ - - # If filepath provided, ensure it is valid absolute path - if filepath is not None: - if not os.path.isabs(filepath): - filepath = os.path.abspath(filepath) - - assert os.path.exists(filepath), "%s must exist " % filepath - - # Assert asset name exists - # It is better to do this here then to wait till switch_shot does it - project_name = legacy_io.active_project() - asset = get_asset_by_name(project_name, asset_name) - assert asset, "Could not find '%s' in the database" % asset_name - - # Go to comp - if not filepath: - current_comp = api.get_current_comp() - assert current_comp is not None, "Could not find current comp" - else: - fusion = _get_fusion_instance() - current_comp = fusion.LoadComp(filepath, quiet=True) - assert current_comp is not None, ( - "Fusion could not load '{}'").format(filepath) - - host = registered_host() - containers = list(host.ls()) - assert containers, "Nothing to update" - - representations = [] - for container in containers: - try: - representation = lib.switch_item( - container, - asset_name=asset_name) - representations.append(representation) - except Exception as e: - current_comp.Print("Error in switching! %s\n" % e.message) - - message = "Switched %i Loaders of the %i\n" % (len(representations), - len(containers)) - current_comp.Print(message) - - # Build the session to switch to - switch_to_session = legacy_io.Session.copy() - switch_to_session["AVALON_ASSET"] = asset['name'] - - if new: - comp_path = _format_filepath(switch_to_session) - - # Update savers output based on new session - _update_savers(current_comp, switch_to_session) - else: - comp_path = version_up(filepath) - - current_comp.Print(comp_path) - - current_comp.Print("\nUpdating frame range") - update_frame_range(current_comp, representations) - - current_comp.Save(comp_path) - - return comp_path - - -if __name__ == '__main__': - - # QUESTION: can we convert this to gui rather then standalone script? - # TODO: convert to gui tool - import argparse - - parser = argparse.ArgumentParser(description="Switch to a shot within an" - "existing comp file") - - parser.add_argument("--file_path", - type=str, - default=True, - help="File path of the comp to use") - - parser.add_argument("--asset_name", - type=str, - default=True, - help="Name of the asset (shot) to switch") - - args, unknown = parser.parse_args() - - install_host(api) - switch(args.asset_name, args.file_path) - - sys.exit(0) diff --git a/openpype/hosts/harmony/plugins/publish/extract_palette.py b/openpype/hosts/harmony/plugins/publish/extract_palette.py index fae778f6b0..69c6e098ff 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_palette.py +++ b/openpype/hosts/harmony/plugins/publish/extract_palette.py @@ -6,10 +6,10 @@ import csv from PIL import Image, ImageDraw, ImageFont import openpype.hosts.harmony.api as harmony -import openpype.api +from openpype.pipeline import publish -class ExtractPalette(openpype.api.Extractor): +class ExtractPalette(publish.Extractor): """Extract palette.""" label = "Extract Palette" diff --git a/openpype/hosts/harmony/plugins/publish/extract_template.py b/openpype/hosts/harmony/plugins/publish/extract_template.py index d25b07bba3..458bf25a3c 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_template.py +++ b/openpype/hosts/harmony/plugins/publish/extract_template.py @@ -3,12 +3,11 @@ import os import shutil -import openpype.api +from openpype.pipeline import publish import openpype.hosts.harmony.api as harmony -import openpype.hosts.harmony -class ExtractTemplate(openpype.api.Extractor): +class ExtractTemplate(publish.Extractor): """Extract the connected nodes to the composite instance.""" label = "Extract Template" @@ -50,7 +49,7 @@ class ExtractTemplate(openpype.api.Extractor): dependencies.remove(instance.data["setMembers"][0]) # Export template. - openpype.hosts.harmony.api.export_template( + harmony.export_template( unique_backdrops, dependencies, filepath ) diff --git a/openpype/hosts/harmony/plugins/publish/extract_workfile.py b/openpype/hosts/harmony/plugins/publish/extract_workfile.py index 7f25ec8150..9bb3090558 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_workfile.py +++ b/openpype/hosts/harmony/plugins/publish/extract_workfile.py @@ -4,10 +4,10 @@ import os import shutil from zipfile import ZipFile -import openpype.api +from openpype.pipeline import publish -class ExtractWorkfile(openpype.api.Extractor): +class ExtractWorkfile(publish.Extractor): """Extract and zip complete workfile folder into zip.""" label = "Extract Workfile" diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index e288cea2b1..e5d35945af 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -13,14 +13,10 @@ import hiero from Qt import QtWidgets -from openpype.client import ( - get_project, - get_versions, - get_last_versions, - get_representations, -) -from openpype.settings import get_anatomy_settings +from openpype.client import get_project +from openpype.settings import get_project_settings from openpype.pipeline import legacy_io, Anatomy +from openpype.pipeline.load import filter_containers from openpype.lib import Logger from . import tags @@ -882,8 +878,7 @@ def apply_colorspace_project(): project.close() # get presets for hiero - imageio = get_anatomy_settings( - project_name)["imageio"].get("hiero", None) + imageio = get_project_settings(project_name)["hiero"]["imageio"] presets = imageio.get("workfile") # save the workfile as subversion "comment:_colorspaceChange" @@ -936,8 +931,7 @@ def apply_colorspace_clips(): clips = project.clips() # get presets for hiero - imageio = get_anatomy_settings( - project_name)["imageio"].get("hiero", None) + imageio = get_project_settings(project_name)["hiero"]["imageio"] from pprint import pprint presets = imageio.get("regexInputs", {}).get("inputs", {}) @@ -1055,6 +1049,10 @@ def sync_clip_name_to_data_asset(track_items_list): print("asset was changed in clip: {}".format(ti_name)) +def set_track_color(track_item, color): + track_item.source().binItem().setColor(color) + + def check_inventory_versions(track_items=None): """ Actual version color idetifier of Loaded containers @@ -1066,68 +1064,29 @@ def check_inventory_versions(track_items=None): """ from . import parse_container - track_item = track_items or get_track_items() + track_items = track_items or get_track_items() # presets clip_color_last = "green" clip_color = "red" - item_with_repre_id = [] - repre_ids = set() + containers = [] # Find all containers and collect it's node and representation ids - for track_item in track_item: + for track_item in track_items: container = parse_container(track_item) if container: - repre_id = container["representation"] - repre_ids.add(repre_id) - item_with_repre_id.append((track_item, repre_id)) + containers.append(container) # Skip if nothing was found - if not repre_ids: + if not containers: return project_name = legacy_io.active_project() - # Find representations based on found containers - repre_docs = get_representations( - project_name, - repre_ids=repre_ids, - fields=["_id", "parent"] - ) - # Store representations by id and collect version ids - repre_docs_by_id = {} - version_ids = set() - for repre_doc in repre_docs: - # Use stringed representation id to match value in containers - repre_id = str(repre_doc["_id"]) - repre_docs_by_id[repre_id] = repre_doc - version_ids.add(repre_doc["parent"]) + filter_result = filter_containers(containers, project_name) + for container in filter_result.latest: + set_track_color(container["_track_item"], clip_color) - version_docs = get_versions( - project_name, version_ids, fields=["_id", "name", "parent"] - ) - # Store versions by id and collect subset ids - version_docs_by_id = {} - subset_ids = set() - for version_doc in version_docs: - version_docs_by_id[version_doc["_id"]] = version_doc - subset_ids.add(version_doc["parent"]) - - # Query last versions based on subset ids - last_versions_by_subset_id = get_last_versions( - project_name, subset_ids=subset_ids, fields=["_id", "parent"] - ) - - for item in item_with_repre_id: - # Some python versions of nuke can't unfold tuple in for loop - track_item, repre_id = item - - repre_doc = repre_docs_by_id[repre_id] - version_doc = version_docs_by_id[repre_doc["parent"]] - last_version_doc = last_versions_by_subset_id[version_doc["parent"]] - # Check if last version is same as current version - if version_doc["_id"] == last_version_doc["_id"]: - track_item.source().binItem().setColor(clip_color_last) - else: - track_item.source().binItem().setColor(clip_color) + for container in filter_result.outdated: + set_track_color(container["_track_item"], clip_color_last) def selection_changed_timeline(event): diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index 541a1f1f92..2a7560c6ba 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -4,7 +4,7 @@ import sys import hiero.core from hiero.ui import findMenuAction -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import legacy_io from openpype.tools.utils import host_tools diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index 77fedbbbdc..ea8a9e836a 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -8,7 +8,7 @@ import hiero from Qt import QtWidgets, QtCore import qargparse -import openpype.api as openpype +from openpype.settings import get_current_project_settings from openpype.lib import Logger from openpype.pipeline import LoaderPlugin, LegacyCreator from openpype.pipeline.context_tools import get_current_project_asset @@ -606,7 +606,7 @@ class Creator(LegacyCreator): def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) import openpype.hosts.hiero.api as phiero - self.presets = openpype.get_current_project_settings()[ + self.presets = get_current_project_settings()[ "hiero"]["create"].get(self.__class__.__name__, {}) # adding basic current context resolve objects diff --git a/openpype/hosts/hiero/api/tags.py b/openpype/hosts/hiero/api/tags.py index 10df96fa53..fac26da03a 100644 --- a/openpype/hosts/hiero/api/tags.py +++ b/openpype/hosts/hiero/api/tags.py @@ -3,7 +3,7 @@ import os import hiero from openpype.client import get_project, get_assets -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import legacy_io log = Logger.get_logger(__name__) diff --git a/openpype/hosts/hiero/api/workio.py b/openpype/hosts/hiero/api/workio.py index 762e22804f..040fd1435a 100644 --- a/openpype/hosts/hiero/api/workio.py +++ b/openpype/hosts/hiero/api/workio.py @@ -1,7 +1,7 @@ import os import hiero -from openpype.api import Logger +from openpype.lib import Logger log = Logger.get_logger(__name__) diff --git a/openpype/hosts/hiero/plugins/publish/extract_clip_effects.py b/openpype/hosts/hiero/plugins/publish/extract_clip_effects.py index 5b0aa270a7..7fb381ff7e 100644 --- a/openpype/hosts/hiero/plugins/publish/extract_clip_effects.py +++ b/openpype/hosts/hiero/plugins/publish/extract_clip_effects.py @@ -2,10 +2,11 @@ import os import json import pyblish.api -import openpype + +from openpype.pipeline import publish -class ExtractClipEffects(openpype.api.Extractor): +class ExtractClipEffects(publish.Extractor): """Extract clip effects instances.""" order = pyblish.api.ExtractorOrder diff --git a/openpype/hosts/hiero/plugins/publish/extract_frames.py b/openpype/hosts/hiero/plugins/publish/extract_frames.py index aa3eda2e9f..f865d2fb39 100644 --- a/openpype/hosts/hiero/plugins/publish/extract_frames.py +++ b/openpype/hosts/hiero/plugins/publish/extract_frames.py @@ -1,9 +1,14 @@ import os import pyblish.api -import openpype + +from openpype.lib import ( + get_oiio_tools_path, + run_subprocess, +) +from openpype.pipeline import publish -class ExtractFrames(openpype.api.Extractor): +class ExtractFrames(publish.Extractor): """Extracts frames""" order = pyblish.api.ExtractorOrder @@ -13,7 +18,7 @@ class ExtractFrames(openpype.api.Extractor): movie_extensions = ["mov", "mp4"] def process(self, instance): - oiio_tool_path = openpype.lib.get_oiio_tools_path() + oiio_tool_path = get_oiio_tools_path() staging_dir = self.staging_dir(instance) output_template = os.path.join(staging_dir, instance.data["name"]) sequence = instance.context.data["activeTimeline"] @@ -43,7 +48,7 @@ class ExtractFrames(openpype.api.Extractor): args.extend(["--powc", "0.45,0.45,0.45,1.0"]) args.extend([input_path, "-o", output_path]) - output = openpype.api.run_subprocess(args) + output = run_subprocess(args) failed_output = "oiiotool produced no output." if failed_output in output: diff --git a/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py b/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py index d12e7665bf..e64aa89b26 100644 --- a/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/hiero/plugins/publish/extract_thumbnail.py @@ -1,9 +1,10 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish -class ExtractThumnail(openpype.api.Extractor): +class ExtractThumnail(publish.Extractor): """ Extractor for track item's tumnails """ diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 2ae8a4dbf7..e4af1913ef 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -14,7 +14,7 @@ from openpype.pipeline import ( ) from openpype.pipeline.load import any_outdated_containers from openpype.hosts.houdini import HOUDINI_HOST_DIR -from openpype.hosts.houdini.api import lib +from openpype.hosts.houdini.api import lib, shelves from openpype.lib import ( register_event_callback, @@ -73,6 +73,7 @@ def install(): # so it initializes into the correct scene FPS, Frame Range, etc. # todo: make sure this doesn't trigger when opening with last workfile _set_context_settings() + shelves.generate_shelves() def uninstall(): diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py new file mode 100644 index 0000000000..3ccab964cd --- /dev/null +++ b/openpype/hosts/houdini/api/shelves.py @@ -0,0 +1,182 @@ +import os +import logging +import platform + +from openpype.settings import get_project_settings + +import hou + +log = logging.getLogger("openpype.hosts.houdini.shelves") + + +def generate_shelves(): + """This function generates complete shelves from shelf set to tools + in Houdini from openpype project settings houdini shelf definition. + """ + current_os = platform.system().lower() + + # load configuration of houdini shelves + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + shelves_set_config = project_settings["houdini"]["shelves"] + + if not shelves_set_config: + log.debug("No custom shelves found in project settings.") + return + + for shelf_set_config in shelves_set_config: + shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') + shelf_set_os_filepath = shelf_set_filepath[current_os] + if shelf_set_os_filepath: + if not os.path.isfile(shelf_set_os_filepath): + log.error("Shelf path doesn't exist - " + "{}".format(shelf_set_os_filepath)) + continue + + hou.shelves.newShelfSet(file_path=shelf_set_os_filepath) + continue + + shelf_set_name = shelf_set_config.get('shelf_set_name') + if not shelf_set_name: + log.warning("No name found in shelf set definition.") + continue + + shelves_definition = shelf_set_config.get('shelf_definition') + if not shelves_definition: + log.debug( + "No shelf definition found for shelf set named '{}'".format( + shelf_set_name + ) + ) + continue + + shelf_set = get_or_create_shelf_set(shelf_set_name) + for shelf_definition in shelves_definition: + shelf_name = shelf_definition.get('shelf_name') + if not shelf_name: + log.warning("No name found in shelf definition.") + continue + + shelf = get_or_create_shelf(shelf_name) + + if not shelf_definition.get('tools_list'): + log.debug( + "No tool definition found for shelf named {}".format( + shelf_name + ) + ) + continue + + mandatory_attributes = {'name', 'script'} + for tool_definition in shelf_definition.get('tools_list'): + # We verify that the name and script attibutes of the tool + # are set + if not all( + tool_definition[key] for key in mandatory_attributes + ): + log.warning( + "You need to specify at least the name and the " + "script path of the tool.") + continue + + tool = get_or_create_tool(tool_definition, shelf) + + if not tool: + continue + + # Add the tool to the shelf if not already in it + if tool not in shelf.tools(): + shelf.setTools(list(shelf.tools()) + [tool]) + + # Add the shelf in the shelf set if not already in it + if shelf not in shelf_set.shelves(): + shelf_set.setShelves(shelf_set.shelves() + (shelf,)) + + +def get_or_create_shelf_set(shelf_set_label): + """This function verifies if the shelf set label exists. If not, + creates a new shelf set. + + Arguments: + shelf_set_label (str): The label of the shelf set + + Returns: + hou.ShelfSet: The shelf set existing or the new one + """ + all_shelves_sets = hou.shelves.shelfSets().values() + + shelf_set = next((shelf for shelf in all_shelves_sets if + shelf.label() == shelf_set_label), None) + if shelf_set: + return shelf_set + + shelf_set_name = shelf_set_label.replace(' ', '_').lower() + new_shelf_set = hou.shelves.newShelfSet( + name=shelf_set_name, + label=shelf_set_label + ) + return new_shelf_set + + +def get_or_create_shelf(shelf_label): + """This function verifies if the shelf label exists. If not, creates + a new shelf. + + Arguments: + shelf_label (str): The label of the shelf + + Returns: + hou.Shelf: The shelf existing or the new one + """ + all_shelves = hou.shelves.shelves().values() + + shelf = next((s for s in all_shelves if s.label() == shelf_label), None) + if shelf: + return shelf + + shelf_name = shelf_label.replace(' ', '_').lower() + new_shelf = hou.shelves.newShelf( + name=shelf_name, + label=shelf_label + ) + return new_shelf + + +def get_or_create_tool(tool_definition, shelf): + """This function verifies if the tool exists and updates it. If not, creates + a new one. + + Arguments: + tool_definition (dict): Dict with label, script, icon and help + shelf (hou.Shelf): The parent shelf of the tool + + Returns: + hou.Tool: The tool updated or the new one + """ + existing_tools = shelf.tools() + tool_label = tool_definition.get('label') + + existing_tool = next( + (tool for tool in existing_tools if tool.label() == tool_label), + None + ) + if existing_tool: + tool_definition.pop('name', None) + tool_definition.pop('label', None) + existing_tool.setData(**tool_definition) + return existing_tool + + tool_name = tool_label.replace(' ', '_').lower() + + if not os.path.exists(tool_definition['script']): + log.warning( + "This path doesn't exist - {}".format(tool_definition['script']) + ) + return + + with open(tool_definition['script']) as f: + script = f.read() + tool_definition.update({'script': script}) + + new_tool = hou.shelves.newTool(name=tool_name, **tool_definition) + + return new_tool diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 928c2ee734..c78798e58a 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -73,7 +73,7 @@ class ImageLoader(load.LoaderPlugin): # Imprint it manually data = { - "schema": "avalon-core:container-2.0", + "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, diff --git a/openpype/hosts/houdini/plugins/load/load_usd_layer.py b/openpype/hosts/houdini/plugins/load/load_usd_layer.py index 48580fc3aa..2e5079925b 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_layer.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_layer.py @@ -43,7 +43,7 @@ class USDSublayerLoader(load.LoaderPlugin): # Imprint it manually data = { - "schema": "avalon-core:container-2.0", + "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, diff --git a/openpype/hosts/houdini/plugins/load/load_usd_reference.py b/openpype/hosts/houdini/plugins/load/load_usd_reference.py index 6851c77e6d..c4371db39b 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_reference.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_reference.py @@ -43,7 +43,7 @@ class USDReferenceLoader(load.LoaderPlugin): # Imprint it manually data = { - "schema": "avalon-core:container-2.0", + "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, diff --git a/openpype/hosts/houdini/plugins/publish/collect_inputs.py b/openpype/hosts/houdini/plugins/publish/collect_inputs.py index 8c7098c710..9ee0248bd9 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_inputs.py +++ b/openpype/hosts/houdini/plugins/publish/collect_inputs.py @@ -1,3 +1,5 @@ +from bson.objectid import ObjectId + import pyblish.api from openpype.pipeline import registered_host @@ -115,7 +117,7 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin): # Collect containers for the given set of nodes containers = collect_input_containers(nodes) - inputs = [c["representation"] for c in containers] - instance.data["inputs"] = inputs + inputs = [ObjectId(c["representation"]) for c in containers] + instance.data["inputRepresentations"] = inputs self.log.info("Collected inputs: %s" % inputs) diff --git a/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py b/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py index c635a53074..d56d389be0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py +++ b/openpype/hosts/houdini/plugins/publish/collect_remote_publish.py @@ -1,7 +1,7 @@ import pyblish.api -import openpype.api import hou +from openpype.pipeline.publish import RepairAction from openpype.hosts.houdini.api import lib @@ -13,7 +13,7 @@ class CollectRemotePublishSettings(pyblish.api.ContextPlugin): hosts = ["houdini"] targets = ["deadline"] label = "Remote Publish Submission Settings" - actions = [openpype.api.RepairAction] + actions = [RepairAction] def process(self, context): diff --git a/openpype/hosts/houdini/plugins/publish/extract_alembic.py b/openpype/hosts/houdini/plugins/publish/extract_alembic.py index 83b790407f..758d4c560b 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_alembic.py +++ b/openpype/hosts/houdini/plugins/publish/extract_alembic.py @@ -1,11 +1,12 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -class ExtractAlembic(openpype.api.Extractor): +class ExtractAlembic(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Alembic" diff --git a/openpype/hosts/houdini/plugins/publish/extract_ass.py b/openpype/hosts/houdini/plugins/publish/extract_ass.py index e56e40df85..a302b451cb 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_ass.py +++ b/openpype/hosts/houdini/plugins/publish/extract_ass.py @@ -1,11 +1,12 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -class ExtractAss(openpype.api.Extractor): +class ExtractAss(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.1 label = "Extract Ass" diff --git a/openpype/hosts/houdini/plugins/publish/extract_composite.py b/openpype/hosts/houdini/plugins/publish/extract_composite.py index f300b6d28d..23e875f107 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_composite.py +++ b/openpype/hosts/houdini/plugins/publish/extract_composite.py @@ -1,12 +1,12 @@ import os import pyblish.api -import openpype.api +from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -class ExtractComposite(openpype.api.Extractor): +class ExtractComposite(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Composite (Image Sequence)" diff --git a/openpype/hosts/houdini/plugins/publish/extract_hda.py b/openpype/hosts/houdini/plugins/publish/extract_hda.py index 301dd4e297..7dd03a92b7 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_hda.py +++ b/openpype/hosts/houdini/plugins/publish/extract_hda.py @@ -4,10 +4,11 @@ import os from pprint import pformat import pyblish.api -import openpype.api + +from openpype.pipeline import publish -class ExtractHDA(openpype.api.Extractor): +class ExtractHDA(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract HDA" diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index c754d60c59..ca9be64a47 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -1,11 +1,12 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -class ExtractRedshiftProxy(openpype.api.Extractor): +class ExtractRedshiftProxy(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.1 label = "Extract Redshift Proxy" diff --git a/openpype/hosts/houdini/plugins/publish/extract_usd.py b/openpype/hosts/houdini/plugins/publish/extract_usd.py index 0fc26900fb..78c32affb4 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_usd.py +++ b/openpype/hosts/houdini/plugins/publish/extract_usd.py @@ -1,11 +1,12 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -class ExtractUSD(openpype.api.Extractor): +class ExtractUSD(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract USD" diff --git a/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py b/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py index 80919c023b..f686f712bb 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/extract_usd_layered.py @@ -5,7 +5,6 @@ import sys from collections import deque import pyblish.api -import openpype.api from openpype.client import ( get_asset_by_name, @@ -16,6 +15,7 @@ from openpype.client import ( from openpype.pipeline import ( get_representation_path, legacy_io, + publish, ) import openpype.hosts.houdini.api.usd as hou_usdlib from openpype.hosts.houdini.api.lib import render_rop @@ -160,7 +160,7 @@ def parm_values(overrides): parm.set(value) -class ExtractUSDLayered(openpype.api.Extractor): +class ExtractUSDLayered(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Layered USD" diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 113e1b0bcb..26ec423048 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -1,11 +1,12 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -class ExtractVDBCache(openpype.api.Extractor): +class ExtractVDBCache(publish.Extractor): order = pyblish.api.ExtractorOrder + 0.1 label = "Extract VDB Cache" diff --git a/openpype/hosts/houdini/plugins/publish/increment_current_file.py b/openpype/hosts/houdini/plugins/publish/increment_current_file.py index 5cb14d732a..c990f481d3 100644 --- a/openpype/hosts/houdini/plugins/publish/increment_current_file.py +++ b/openpype/hosts/houdini/plugins/publish/increment_current_file.py @@ -2,10 +2,9 @@ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host -from openpype.pipeline.publish import get_errored_plugins_from_context -class IncrementCurrentFile(pyblish.api.InstancePlugin): +class IncrementCurrentFile(pyblish.api.ContextPlugin): """Increment the current file. Saves the current scene with an increased version number. @@ -15,30 +14,10 @@ class IncrementCurrentFile(pyblish.api.InstancePlugin): label = "Increment current file" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["houdini"] - families = ["colorbleed.usdrender", "redshift_rop"] - targets = ["local"] + families = ["workfile"] + optional = True - def process(self, instance): - - # This should be a ContextPlugin, but this is a workaround - # for a bug in pyblish to run once for a family: issue #250 - context = instance.context - key = "__hasRun{}".format(self.__class__.__name__) - if context.data.get(key, False): - return - else: - context.data[key] = True - - context = instance.context - errored_plugins = get_errored_plugins_from_context(context) - if any( - plugin.__name__ == "HoudiniSubmitPublishDeadline" - for plugin in errored_plugins - ): - raise RuntimeError( - "Skipping incrementing current file because " - "submission to deadline failed." - ) + def process(self, context): # Filename must not have changed since collecting host = registered_host() diff --git a/openpype/hosts/houdini/plugins/publish/increment_current_file_deadline.py b/openpype/hosts/houdini/plugins/publish/increment_current_file_deadline.py deleted file mode 100644 index cb0d7e3680..0000000000 --- a/openpype/hosts/houdini/plugins/publish/increment_current_file_deadline.py +++ /dev/null @@ -1,35 +0,0 @@ -import pyblish.api - -import hou -from openpype.lib import version_up -from openpype.pipeline.publish import get_errored_plugins_from_context - - -class IncrementCurrentFileDeadline(pyblish.api.ContextPlugin): - """Increment the current file. - - Saves the current scene with an increased version number. - - """ - - label = "Increment current file" - order = pyblish.api.IntegratorOrder + 9.0 - hosts = ["houdini"] - targets = ["deadline"] - - def process(self, context): - - errored_plugins = get_errored_plugins_from_context(context) - if any( - plugin.__name__ == "HoudiniSubmitPublishDeadline" - for plugin in errored_plugins - ): - raise RuntimeError( - "Skipping incrementing current file because " - "submission to deadline failed." - ) - - current_filepath = context.data["currentFile"] - new_filepath = version_up(current_filepath) - - hou.hipFile.save(file_name=new_filepath, save_to_recent_files=True) diff --git a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py index 79b3e894e5..0bd78ff38a 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py @@ -35,6 +35,9 @@ class ValidateWorkfilePaths(pyblish.api.InstancePlugin): def get_invalid(cls): invalid = [] for param, _ in hou.fileReferences(): + if param is None: + continue + # skip nodes we are not interested in if param.node().type().name() not in cls.node_types: continue diff --git a/openpype/hosts/maya/addon.py b/openpype/hosts/maya/addon.py index 7b1f7bf754..cdd2bc1667 100644 --- a/openpype/hosts/maya/addon.py +++ b/openpype/hosts/maya/addon.py @@ -28,13 +28,16 @@ class MayaAddon(OpenPypeModule, IHostAddon): env["PYTHONPATH"] = os.pathsep.join(new_python_paths) - # Set default values if are not already set via settings - defaults = { - "OPENPYPE_LOG_NO_COLORS": "Yes" + # Set default environments + envs = { + "OPENPYPE_LOG_NO_COLORS": "Yes", + # For python module 'qtpy' + "QT_API": "PySide2", + # For python module 'Qt' + "QT_PREFERRED_BINDING": "PySide2" } - for key, value in defaults.items(): - if not env.get(key): - env[key] = value + for key, value in envs.items(): + env[key] = value def get_launch_hook_paths(self, app): if app.host_name != self.host_name: diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 683e6b24b0..f66858dfb6 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -8,7 +8,7 @@ from functools import partial import maya.cmds as cmds import maya.mel as mel -from openpype.api import resources +from openpype import resources from openpype.tools.utils import host_tools from .lib import get_main_window diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 58e160cb2f..292b95da84 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -23,7 +23,7 @@ from openpype.client import ( get_last_versions, get_representation_by_name ) -from openpype.api import get_anatomy_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( legacy_io, discover_loader_plugins, @@ -2483,7 +2483,7 @@ def load_capture_preset(data=None): # DISPLAY OPTIONS id = 'Display Options' disp_options = {} - for key in preset['Display Options']: + for key in preset[id]: if key.startswith('background'): disp_options[key] = preset['Display Options'][key] if len(disp_options[key]) == 4: @@ -3159,7 +3159,7 @@ def set_colorspace(): """Set Colorspace from project configuration """ project_name = os.getenv("AVALON_PROJECT") - imageio = get_anatomy_settings(project_name)["imageio"]["maya"] + imageio = get_project_settings(project_name)["maya"]["imageio"] # Maya 2022+ introduces new OCIO v2 color management settings that # can override the old color managenement preferences. OpenPype has diff --git a/openpype/hosts/maya/api/lib_rendersettings.py b/openpype/hosts/maya/api/lib_rendersettings.py index 7cd2193086..b0a283da5a 100644 --- a/openpype/hosts/maya/api/lib_rendersettings.py +++ b/openpype/hosts/maya/api/lib_rendersettings.py @@ -5,7 +5,8 @@ import maya.mel as mel import six import sys -from openpype.api import ( +from openpype.lib import Logger +from openpype.settings import ( get_project_settings, get_current_project_settings ) @@ -38,6 +39,8 @@ class RenderSettings(object): "underscore": "_" } + log = Logger.get_logger("RenderSettings") + @classmethod def get_image_prefix_attr(cls, renderer): return cls._image_prefix_nodes[renderer] @@ -133,20 +136,7 @@ class RenderSettings(object): cmds.setAttr( "defaultArnoldDriver.mergeAOVs", multi_exr) - # Passes additional options in from the schema as a list - # but converts it to a dictionary because ftrack doesn't - # allow fullstops in custom attributes. Then checks for - # type of MtoA attribute passed to adjust the `setAttr` - # command accordingly. self._additional_attribs_setter(additional_options) - for item in additional_options: - attribute, value = item - if (cmds.getAttr(str(attribute), type=True)) == "long": - cmds.setAttr(str(attribute), int(value)) - elif (cmds.getAttr(str(attribute), type=True)) == "bool": - cmds.setAttr(str(attribute), int(value), type = "Boolean") # noqa - elif (cmds.getAttr(str(attribute), type=True)) == "string": - cmds.setAttr(str(attribute), str(value), type = "string") # noqa reset_frame_range() def _set_redshift_settings(self, width, height): @@ -230,12 +220,20 @@ class RenderSettings(object): cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) def _additional_attribs_setter(self, additional_attribs): - print(additional_attribs) for item in additional_attribs: attribute, value = item - if (cmds.getAttr(str(attribute), type=True)) == "long": - cmds.setAttr(str(attribute), int(value)) - elif (cmds.getAttr(str(attribute), type=True)) == "bool": - cmds.setAttr(str(attribute), int(value)) # noqa - elif (cmds.getAttr(str(attribute), type=True)) == "string": - cmds.setAttr(str(attribute), str(value), type = "string") # noqa + attribute = str(attribute) # ensure str conversion from settings + attribute_type = cmds.getAttr(attribute, type=True) + if attribute_type in {"long", "bool"}: + cmds.setAttr(attribute, int(value)) + elif attribute_type == "string": + cmds.setAttr(attribute, str(value), type="string") + elif attribute_type in {"double", "doubleAngle", "doubleLinear"}: + cmds.setAttr(attribute, float(value)) + else: + self.log.error( + "Attribute {attribute} can not be set due to unsupported " + "type: {attribute_type}".format( + attribute=attribute, + attribute_type=attribute_type) + ) diff --git a/openpype/hosts/maya/api/lib_rendersetup.py b/openpype/hosts/maya/api/lib_rendersetup.py index 0fdc54a068..e616f26e1b 100644 --- a/openpype/hosts/maya/api/lib_rendersetup.py +++ b/openpype/hosts/maya/api/lib_rendersetup.py @@ -348,3 +348,71 @@ def get_attr_overrides(node_attr, layer, break return reversed(plug_overrides) + + +def get_shader_in_layer(node, layer): + """Return the assigned shader in a renderlayer without switching layers. + + This has been developed and tested for Legacy Renderlayers and *not* for + Render Setup. + + Note: This will also return the shader for any face assignments, however + it will *not* return the components they are assigned to. This could + be implemented, but since Maya's renderlayers are famous for breaking + with face assignments there has been no need for this function to + support that. + + Returns: + list: The list of assigned shaders in the given layer. + + """ + + def _get_connected_shader(plug): + """Return current shader""" + return cmds.listConnections(plug, + source=False, + destination=True, + plugs=False, + connections=False, + type="shadingEngine") or [] + + # We check the instObjGroups (shader connection) for layer overrides. + plug = node + ".instObjGroups" + + # Ignore complex query if we're in the layer anyway (optimization) + current_layer = cmds.editRenderLayerGlobals(query=True, + currentRenderLayer=True) + if layer == current_layer: + return _get_connected_shader(plug) + + connections = cmds.listConnections(plug, + plugs=True, + source=False, + destination=True, + type="renderLayer") or [] + connections = filter(lambda x: x.endswith(".outPlug"), connections) + if not connections: + # If no overrides anywhere on the shader, just get the current shader + return _get_connected_shader(plug) + + def _get_override(connections, layer): + """Return the overridden connection for that layer in connections""" + # If there's an override on that layer, return that. + for connection in connections: + if (connection.startswith(layer + ".outAdjustments") and + connection.endswith(".outPlug")): + + # This is a shader override on that layer so get the shader + # connected to .outValue of the .outAdjustment[i] + out_adjustment = connection.rsplit(".", 1)[0] + connection_attr = out_adjustment + ".outValue" + override = cmds.listConnections(connection_attr) or [] + + return override + + override_shader = _get_override(connections, layer) + if override_shader is not None: + return override_shader + else: + # Get the override for "defaultRenderLayer" (=masterLayer) + return _get_override(connections, layer="defaultRenderLayer") diff --git a/openpype/hosts/maya/api/lib_template_builder.py b/openpype/hosts/maya/api/lib_template_builder.py deleted file mode 100644 index 34a8450a26..0000000000 --- a/openpype/hosts/maya/api/lib_template_builder.py +++ /dev/null @@ -1,253 +0,0 @@ -import json -from collections import OrderedDict -import maya.cmds as cmds - -import qargparse -from openpype.tools.utils.widgets import OptionDialog -from .lib import get_main_window, imprint - -# To change as enum -build_types = ["context_asset", "linked_asset", "all_assets"] - - -def get_placeholder_attributes(node): - return { - attr: cmds.getAttr("{}.{}".format(node, attr)) - for attr in cmds.listAttr(node, userDefined=True)} - - -def delete_placeholder_attributes(node): - ''' - function to delete all extra placeholder attributes - ''' - extra_attributes = get_placeholder_attributes(node) - for attribute in extra_attributes: - cmds.deleteAttr(node + '.' + attribute) - - -def create_placeholder(): - args = placeholder_window() - - if not args: - return # operation canceled, no locator created - - # custom arg parse to force empty data query - # and still imprint them on placeholder - # and getting items when arg is of type Enumerator - options = create_options(args) - - # create placeholder name dynamically from args and options - placeholder_name = create_placeholder_name(args, options) - - selection = cmds.ls(selection=True) - if not selection: - raise ValueError("Nothing is selected") - - placeholder = cmds.spaceLocator(name=placeholder_name)[0] - - # get the long name of the placeholder (with the groups) - placeholder_full_name = cmds.ls(selection[0], long=True)[ - 0] + '|' + placeholder.replace('|', '') - - if selection: - cmds.parent(placeholder, selection[0]) - - imprint(placeholder_full_name, options) - - # Some tweaks because imprint force enums to to default value so we get - # back arg read and force them to attributes - imprint_enum(placeholder_full_name, args) - - # Add helper attributes to keep placeholder info - cmds.addAttr( - placeholder_full_name, - longName="parent", - hidden=True, - dataType="string" - ) - cmds.addAttr( - placeholder_full_name, - longName="index", - hidden=True, - attributeType="short", - defaultValue=-1 - ) - - cmds.setAttr(placeholder_full_name + '.parent', "", type="string") - - -def create_placeholder_name(args, options): - placeholder_builder_type = [ - arg.read() for arg in args if 'builder_type' in str(arg) - ][0] - placeholder_family = options['family'] - placeholder_name = placeholder_builder_type.split('_') - - # add famlily in any - if placeholder_family: - placeholder_name.insert(1, placeholder_family) - - # add loader arguments if any - if options['loader_args']: - pos = 2 - loader_args = options['loader_args'].replace('\'', '\"') - loader_args = json.loads(loader_args) - values = [v for v in loader_args.values()] - for i in range(len(values)): - placeholder_name.insert(i + pos, values[i]) - - placeholder_name = '_'.join(placeholder_name) - - return placeholder_name.capitalize() - - -def update_placeholder(): - placeholder = cmds.ls(selection=True) - if len(placeholder) == 0: - raise ValueError("No node selected") - if len(placeholder) > 1: - raise ValueError("Too many selected nodes") - placeholder = placeholder[0] - - args = placeholder_window(get_placeholder_attributes(placeholder)) - - if not args: - return # operation canceled - - # delete placeholder attributes - delete_placeholder_attributes(placeholder) - - options = create_options(args) - - imprint(placeholder, options) - imprint_enum(placeholder, args) - - cmds.addAttr( - placeholder, - longName="parent", - hidden=True, - dataType="string" - ) - cmds.addAttr( - placeholder, - longName="index", - hidden=True, - attributeType="short", - defaultValue=-1 - ) - - cmds.setAttr(placeholder + '.parent', '', type="string") - - -def create_options(args): - options = OrderedDict() - for arg in args: - if not type(arg) == qargparse.Separator: - options[str(arg)] = arg._data.get("items") or arg.read() - return options - - -def imprint_enum(placeholder, args): - """ - Imprint method doesn't act properly with enums. - Replacing the functionnality with this for now - """ - enum_values = {str(arg): arg.read() - for arg in args if arg._data.get("items")} - string_to_value_enum_table = { - build: i for i, build - in enumerate(build_types)} - for key, value in enum_values.items(): - cmds.setAttr( - placeholder + "." + key, - string_to_value_enum_table[value]) - - -def placeholder_window(options=None): - options = options or dict() - dialog = OptionDialog(parent=get_main_window()) - dialog.setWindowTitle("Create Placeholder") - - args = [ - qargparse.Separator("Main attributes"), - qargparse.Enum( - "builder_type", - label="Asset Builder Type", - default=options.get("builder_type", 0), - items=build_types, - help="""Asset Builder Type -Builder type describe what template loader will look for. -context_asset : Template loader will look for subsets of -current context asset (Asset bob will find asset) -linked_asset : Template loader will look for assets linked -to current context asset. -Linked asset are looked in avalon database under field "inputLinks" -""" - ), - qargparse.String( - "family", - default=options.get("family", ""), - label="OpenPype Family", - placeholder="ex: model, look ..."), - qargparse.String( - "representation", - default=options.get("representation", ""), - label="OpenPype Representation", - placeholder="ex: ma, abc ..."), - qargparse.String( - "loader", - default=options.get("loader", ""), - label="Loader", - placeholder="ex: ReferenceLoader, LightLoader ...", - help="""Loader -Defines what openpype loader will be used to load assets. -Useable loader depends on current host's loader list. -Field is case sensitive. -"""), - qargparse.String( - "loader_args", - default=options.get("loader_args", ""), - label="Loader Arguments", - placeholder='ex: {"camera":"persp", "lights":True}', - help="""Loader -Defines a dictionnary of arguments used to load assets. -Useable arguments depend on current placeholder Loader. -Field should be a valid python dict. Anything else will be ignored. -"""), - qargparse.Integer( - "order", - default=options.get("order", 0), - min=0, - max=999, - label="Order", - placeholder="ex: 0, 100 ... (smallest order loaded first)", - help="""Order -Order defines asset loading priority (0 to 999) -Priority rule is : "lowest is first to load"."""), - qargparse.Separator( - "Optional attributes"), - qargparse.String( - "asset", - default=options.get("asset", ""), - label="Asset filter", - placeholder="regex filtering by asset name", - help="Filtering assets by matching field regex to asset's name"), - qargparse.String( - "subset", - default=options.get("subset", ""), - label="Subset filter", - placeholder="regex filtering by subset name", - help="Filtering assets by matching field regex to subset's name"), - qargparse.String( - "hierarchy", - default=options.get("hierarchy", ""), - label="Hierarchy filter", - placeholder="regex filtering by asset's hierarchy", - help="Filtering assets by matching field asset's hierarchy") - ] - dialog.create(args) - - if not dialog.exec_(): - return None - - return args diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index 666f555660..e20f29049b 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -9,16 +9,17 @@ import maya.cmds as cmds from openpype.settings import get_project_settings from openpype.pipeline import legacy_io from openpype.pipeline.workfile import BuildWorkfile -from openpype.pipeline.workfile.build_template import ( - build_workfile_template, - update_workfile_template -) from openpype.tools.utils import host_tools from openpype.hosts.maya.api import lib, lib_rendersettings from .lib import get_main_window, IS_HEADLESS from .commands import reset_frame_range -from .lib_template_builder import create_placeholder, update_placeholder +from .workfile_template_builder import ( + create_placeholder, + update_placeholder, + build_workfile_template, + update_workfile_template, +) log = logging.getLogger(__name__) @@ -161,12 +162,12 @@ def install(): cmds.menuItem( "Create Placeholder", parent=builder_menu, - command=lambda *args: create_placeholder() + command=create_placeholder ) cmds.menuItem( "Update Placeholder", parent=builder_menu, - command=lambda *args: update_placeholder() + command=update_placeholder ) cmds.menuItem( "Build Workfile from template", diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index c963b5d996..b3bf738a2b 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -16,6 +16,7 @@ from openpype.host import ( HostDirmap, ) from openpype.tools.utils import host_tools +from openpype.tools.workfiles.lock_dialog import WorkfileLockDialog from openpype.lib import ( register_event_callback, emit_event @@ -31,10 +32,17 @@ from openpype.pipeline import ( AVALON_CONTAINER_ID, ) from openpype.pipeline.load import any_outdated_containers +from openpype.pipeline.workfile.lock_workfile import ( + create_workfile_lock, + remove_workfile_lock, + is_workfile_locked, + is_workfile_lock_enabled +) from openpype.hosts.maya import MAYA_ROOT_DIR -from openpype.hosts.maya.lib import copy_workspace_mel +from openpype.hosts.maya.lib import create_workspace_mel from . import menu, lib +from .workfile_template_builder import MayaPlaceholderLoadPlugin from .workio import ( open_file, save_file, @@ -63,7 +71,7 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost): self._op_events = {} def install(self): - project_name = os.getenv("AVALON_PROJECT") + project_name = legacy_io.active_project() project_settings = get_project_settings(project_name) # process path mapping dirmap_processor = MayaDirmap("maya", project_name, project_settings) @@ -99,8 +107,13 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost): register_event_callback("open", on_open) register_event_callback("new", on_new) register_event_callback("before.save", on_before_save) + register_event_callback("after.save", on_after_save) + register_event_callback("before.close", on_before_close) + register_event_callback("before.file.open", before_file_open) register_event_callback("taskChanged", on_task_changed) + register_event_callback("workfile.open.before", before_workfile_open) register_event_callback("workfile.save.before", before_workfile_save) + register_event_callback("workfile.save.before", after_workfile_save) def open_workfile(self, filepath): return open_file(filepath) @@ -123,6 +136,11 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost): def get_containers(self): return ls() + def get_workfile_build_placeholder_plugins(self): + return [ + MayaPlaceholderLoadPlugin + ] + @contextlib.contextmanager def maintained_selection(self): with lib.maintained_selection(): @@ -143,6 +161,13 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost): OpenMaya.MSceneMessage.kBeforeSave, _on_scene_save ) + self._op_events[_after_scene_save] = ( + OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kAfterSave, + _after_scene_save + ) + ) + self._op_events[_before_scene_save] = ( OpenMaya.MSceneMessage.addCheckCallback( OpenMaya.MSceneMessage.kBeforeSaveCheck, @@ -161,15 +186,35 @@ class MayaHost(HostBase, IWorkfileHost, ILoadHost): ) ) - self._op_events[_on_scene_open] = OpenMaya.MSceneMessage.addCallback( - OpenMaya.MSceneMessage.kAfterOpen, _on_scene_open + self._op_events[_on_scene_open] = ( + OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kAfterOpen, + _on_scene_open + ) + ) + + self._op_events[_before_scene_open] = ( + OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kBeforeOpen, + _before_scene_open + ) + ) + + self._op_events[_before_close_maya] = ( + OpenMaya.MSceneMessage.addCallback( + OpenMaya.MSceneMessage.kMayaExiting, + _before_close_maya + ) ) self.log.info("Installed event handler _on_scene_save..") self.log.info("Installed event handler _before_scene_save..") + self.log.info("Installed event handler _on_after_save..") self.log.info("Installed event handler _on_scene_new..") self.log.info("Installed event handler _on_maya_initialized..") self.log.info("Installed event handler _on_scene_open..") + self.log.info("Installed event handler _check_lock_file..") + self.log.info("Installed event handler _before_close_maya..") def _set_project(): @@ -208,6 +253,10 @@ def _on_scene_new(*args): emit_event("new") +def _after_scene_save(*arg): + emit_event("after.save") + + def _on_scene_save(*args): emit_event("save") @@ -216,6 +265,14 @@ def _on_scene_open(*args): emit_event("open") +def _before_close_maya(*args): + emit_event("before.close") + + +def _before_scene_open(*args): + emit_event("before.file.open") + + def _before_scene_save(return_code, client_data): # Default to allowing the action. Registered @@ -229,6 +286,23 @@ def _before_scene_save(return_code, client_data): ) +def _remove_workfile_lock(): + """Remove workfile lock on current file""" + if not handle_workfile_locks(): + return + filepath = current_file() + log.info("Removing lock on current file {}...".format(filepath)) + if filepath: + remove_workfile_lock(filepath) + + +def handle_workfile_locks(): + if lib.IS_HEADLESS: + return False + project_name = legacy_io.active_project() + return is_workfile_lock_enabled(MayaHost.name, project_name) + + def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) pyblish.api.deregister_host("mayabatch") @@ -426,6 +500,46 @@ def on_before_save(): return lib.validate_fps() +def on_after_save(): + """Check if there is a lockfile after save""" + check_lock_on_current_file() + + +def check_lock_on_current_file(): + + """Check if there is a user opening the file""" + if not handle_workfile_locks(): + return + log.info("Running callback on checking the lock file...") + + # add the lock file when opening the file + filepath = current_file() + + if is_workfile_locked(filepath): + # add lockfile dialog + workfile_dialog = WorkfileLockDialog(filepath) + if not workfile_dialog.exec_(): + cmds.file(new=True) + return + + create_workfile_lock(filepath) + + +def on_before_close(): + """Delete the lock file after user quitting the Maya Scene""" + log.info("Closing Maya...") + # delete the lock file + filepath = current_file() + if handle_workfile_locks(): + remove_workfile_lock(filepath) + + +def before_file_open(): + """check lock file when the file changed""" + # delete the lock file + _remove_workfile_lock() + + def on_save(): """Automatically add IDs to new nodes @@ -434,6 +548,8 @@ def on_save(): """ log.info("Running callback on save..") + # remove lockfile if users jumps over from one scene to another + _remove_workfile_lock() # # Update current task for the current scene # update_task_from_path(cmds.file(query=True, sceneName=True)) @@ -491,6 +607,9 @@ def on_open(): dialog.on_clicked.connect(_on_show_inventory) dialog.show() + # create lock file for the maya scene + check_lock_on_current_file() + def on_new(): """Set project resolution and fps when create a new file""" @@ -506,6 +625,7 @@ def on_new(): "from openpype.hosts.maya.api import lib;" "lib.add_render_layer_change_observer()") lib.set_context_settings() + _remove_workfile_lock() def on_task_changed(): @@ -533,7 +653,7 @@ def on_task_changed(): lib.update_content_on_context_change() msg = " project: {}\n asset: {}\n task:{}".format( - legacy_io.Session["AVALON_PROJECT"], + legacy_io.active_project(), legacy_io.Session["AVALON_ASSET"], legacy_io.Session["AVALON_TASK"] ) @@ -544,10 +664,26 @@ def on_task_changed(): ) +def before_workfile_open(): + if handle_workfile_locks(): + _remove_workfile_lock() + + def before_workfile_save(event): + project_name = legacy_io.active_project() + if handle_workfile_locks(): + _remove_workfile_lock() workdir_path = event["workdir_path"] if workdir_path: - copy_workspace_mel(workdir_path) + create_workspace_mel(workdir_path, project_name) + + +def after_workfile_save(event): + workfile_name = event["filename"] + if handle_workfile_locks(): + if workfile_name: + if not is_workfile_locked(workfile_name): + create_workfile_lock(workfile_name) class MayaDirmap(HostDirmap): diff --git a/openpype/hosts/maya/api/template_loader.py b/openpype/hosts/maya/api/template_loader.py deleted file mode 100644 index ecffafc93d..0000000000 --- a/openpype/hosts/maya/api/template_loader.py +++ /dev/null @@ -1,252 +0,0 @@ -import re -from maya import cmds - -from openpype.client import get_representations -from openpype.pipeline import legacy_io -from openpype.pipeline.workfile.abstract_template_loader import ( - AbstractPlaceholder, - AbstractTemplateLoader -) -from openpype.pipeline.workfile.build_template_exceptions import ( - TemplateAlreadyImported -) - -PLACEHOLDER_SET = 'PLACEHOLDERS_SET' - - -class MayaTemplateLoader(AbstractTemplateLoader): - """Concrete implementation of AbstractTemplateLoader for maya - """ - - def import_template(self, path): - """Import template into current scene. - Block if a template is already loaded. - Args: - path (str): A path to current template (usually given by - get_template_path implementation) - Returns: - bool: Wether the template was succesfully imported or not - """ - if cmds.objExists(PLACEHOLDER_SET): - raise TemplateAlreadyImported( - "Build template already loaded\n" - "Clean scene if needed (File > New Scene)") - - cmds.sets(name=PLACEHOLDER_SET, empty=True) - self.new_nodes = cmds.file(path, i=True, returnNewNodes=True) - cmds.setAttr(PLACEHOLDER_SET + '.hiddenInOutliner', True) - - for set in cmds.listSets(allSets=True): - if (cmds.objExists(set) and - cmds.attributeQuery('id', node=set, exists=True) and - cmds.getAttr(set + '.id') == 'pyblish.avalon.instance'): - if cmds.attributeQuery('asset', node=set, exists=True): - cmds.setAttr( - set + '.asset', - legacy_io.Session['AVALON_ASSET'], type='string' - ) - - return True - - def template_already_imported(self, err_msg): - clearButton = "Clear scene and build" - updateButton = "Update template" - abortButton = "Abort" - - title = "Scene already builded" - message = ( - "It's seems a template was already build for this scene.\n" - "Error message reveived :\n\n\"{}\"".format(err_msg)) - buttons = [clearButton, updateButton, abortButton] - defaultButton = clearButton - cancelButton = abortButton - dismissString = abortButton - answer = cmds.confirmDialog( - t=title, - m=message, - b=buttons, - db=defaultButton, - cb=cancelButton, - ds=dismissString) - - if answer == clearButton: - cmds.file(newFile=True, force=True) - self.import_template(self.template_path) - self.populate_template() - elif answer == updateButton: - self.update_missing_containers() - elif answer == abortButton: - return - - @staticmethod - def get_template_nodes(): - attributes = cmds.ls('*.builder_type', long=True) - return [attribute.rpartition('.')[0] for attribute in attributes] - - def get_loaded_containers_by_id(self): - try: - containers = cmds.sets("AVALON_CONTAINERS", q=True) - except ValueError: - return None - - return [ - cmds.getAttr(container + '.representation') - for container in containers] - - -class MayaPlaceholder(AbstractPlaceholder): - """Concrete implementation of AbstractPlaceholder for maya - """ - - optional_keys = {'asset', 'subset', 'hierarchy'} - - def get_data(self, node): - user_data = dict() - for attr in self.required_keys.union(self.optional_keys): - attribute_name = '{}.{}'.format(node, attr) - if not cmds.attributeQuery(attr, node=node, exists=True): - print("{} not found".format(attribute_name)) - continue - user_data[attr] = cmds.getAttr( - attribute_name, - asString=True) - user_data['parent'] = ( - cmds.getAttr(node + '.parent', asString=True) - or node.rpartition('|')[0] - or "" - ) - user_data['node'] = node - if user_data['parent']: - siblings = cmds.listRelatives(user_data['parent'], children=True) - else: - siblings = cmds.ls(assemblies=True) - node_shortname = user_data['node'].rpartition('|')[2] - current_index = cmds.getAttr(node + '.index', asString=True) - user_data['index'] = ( - current_index if current_index >= 0 - else siblings.index(node_shortname)) - - self.data = user_data - - def parent_in_hierarchy(self, containers): - """Parent loaded container to placeholder's parent - ie : Set loaded content as placeholder's sibling - Args: - containers (String): Placeholder loaded containers - """ - if not containers: - return - - roots = cmds.sets(containers, q=True) - nodes_to_parent = [] - for root in roots: - if root.endswith("_RN"): - refRoot = cmds.referenceQuery(root, n=True)[0] - refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot] - nodes_to_parent.extend(refRoot) - elif root in cmds.listSets(allSets=True): - if not cmds.sets(root, q=True): - return - else: - continue - else: - nodes_to_parent.append(root) - - if self.data['parent']: - cmds.parent(nodes_to_parent, self.data['parent']) - # Move loaded nodes to correct index in outliner hierarchy - placeholder_node = self.data['node'] - placeholder_form = cmds.xform( - placeholder_node, - q=True, - matrix=True, - worldSpace=True - ) - for node in set(nodes_to_parent): - cmds.reorder(node, front=True) - cmds.reorder(node, relative=self.data['index']) - cmds.xform(node, matrix=placeholder_form, ws=True) - - holding_sets = cmds.listSets(object=placeholder_node) - if not holding_sets: - return - for holding_set in holding_sets: - cmds.sets(roots, forceElement=holding_set) - - def clean(self): - """Hide placeholder, parent them to root - add them to placeholder set and register placeholder's parent - to keep placeholder info available for future use - """ - node = self.data['node'] - if self.data['parent']: - cmds.setAttr(node + '.parent', self.data['parent'], type='string') - if cmds.getAttr(node + '.index') < 0: - cmds.setAttr(node + '.index', self.data['index']) - - holding_sets = cmds.listSets(object=node) - if holding_sets: - for set in holding_sets: - cmds.sets(node, remove=set) - - if cmds.listRelatives(node, p=True): - node = cmds.parent(node, world=True)[0] - cmds.sets(node, addElement=PLACEHOLDER_SET) - cmds.hide(node) - cmds.setAttr(node + '.hiddenInOutliner', True) - - def get_representations(self, current_asset_doc, linked_asset_docs): - project_name = legacy_io.active_project() - - builder_type = self.data["builder_type"] - if builder_type == "context_asset": - context_filters = { - "asset": [current_asset_doc["name"]], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representations": [self.data["representation"]], - "family": [self.data["family"]] - } - - elif builder_type != "linked_asset": - context_filters = { - "asset": [re.compile(self.data["asset"])], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]] - } - - else: - asset_regex = re.compile(self.data["asset"]) - linked_asset_names = [] - for asset_doc in linked_asset_docs: - asset_name = asset_doc["name"] - if asset_regex.match(asset_name): - linked_asset_names.append(asset_name) - - context_filters = { - "asset": linked_asset_names, - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]], - } - - return list(get_representations( - project_name, - context_filters=context_filters - )) - - def err_message(self): - return ( - "Error while trying to load a representation.\n" - "Either the subset wasn't published or the template is malformed." - "\n\n" - "Builder was looking for :\n{attributes}".format( - attributes="\n".join([ - "{}: {}".format(key.title(), value) - for key, value in self.data.items()] - ) - ) - ) diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py new file mode 100644 index 0000000000..ef043ed0f4 --- /dev/null +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -0,0 +1,330 @@ +import json + +from maya import cmds + +from openpype.pipeline import registered_host +from openpype.pipeline.workfile.workfile_template_builder import ( + TemplateAlreadyImported, + AbstractTemplateBuilder, + PlaceholderPlugin, + LoadPlaceholderItem, + PlaceholderLoadMixin, +) +from openpype.tools.workfile_template_build import ( + WorkfileBuildPlaceholderDialog, +) + +from .lib import read, imprint + +PLACEHOLDER_SET = "PLACEHOLDERS_SET" + + +class MayaTemplateBuilder(AbstractTemplateBuilder): + """Concrete implementation of AbstractTemplateBuilder for maya""" + + def import_template(self, path): + """Import template into current scene. + Block if a template is already loaded. + + Args: + path (str): A path to current template (usually given by + get_template_path implementation) + + Returns: + bool: Wether the template was succesfully imported or not + """ + + if cmds.objExists(PLACEHOLDER_SET): + raise TemplateAlreadyImported(( + "Build template already loaded\n" + "Clean scene if needed (File > New Scene)" + )) + + cmds.sets(name=PLACEHOLDER_SET, empty=True) + cmds.file(path, i=True, returnNewNodes=True) + + cmds.setAttr(PLACEHOLDER_SET + ".hiddenInOutliner", True) + + return True + + +class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): + identifier = "maya.load" + label = "Maya load" + + def _collect_scene_placeholders(self): + # Cache placeholder data to shared data + placeholder_nodes = self.builder.get_shared_populate_data( + "placeholder_nodes" + ) + if placeholder_nodes is None: + attributes = cmds.ls("*.plugin_identifier", long=True) + placeholder_nodes = {} + for attribute in attributes: + node_name = attribute.rpartition(".")[0] + placeholder_nodes[node_name] = ( + self._parse_placeholder_node_data(node_name) + ) + + self.builder.set_shared_populate_data( + "placeholder_nodes", placeholder_nodes + ) + return placeholder_nodes + + def _parse_placeholder_node_data(self, node_name): + placeholder_data = read(node_name) + parent_name = ( + cmds.getAttr(node_name + ".parent", asString=True) + or node_name.rpartition("|")[0] + or "" + ) + if parent_name: + siblings = cmds.listRelatives(parent_name, children=True) + else: + siblings = cmds.ls(assemblies=True) + node_shortname = node_name.rpartition("|")[2] + current_index = cmds.getAttr(node_name + ".index", asString=True) + if current_index < 0: + current_index = siblings.index(node_shortname) + + placeholder_data.update({ + "parent": parent_name, + "index": current_index + }) + return placeholder_data + + def _create_placeholder_name(self, placeholder_data): + placeholder_name_parts = placeholder_data["builder_type"].split("_") + + pos = 1 + # add famlily in any + placeholder_family = placeholder_data["family"] + if placeholder_family: + placeholder_name_parts.insert(pos, placeholder_family) + pos += 1 + + # add loader arguments if any + loader_args = placeholder_data["loader_args"] + if loader_args: + loader_args = json.loads(loader_args.replace('\'', '\"')) + values = [v for v in loader_args.values()] + for value in values: + placeholder_name_parts.insert(pos, value) + pos += 1 + + placeholder_name = "_".join(placeholder_name_parts) + + return placeholder_name.capitalize() + + def _get_loaded_repre_ids(self): + loaded_representation_ids = self.builder.get_shared_populate_data( + "loaded_representation_ids" + ) + if loaded_representation_ids is None: + try: + containers = cmds.sets("AVALON_CONTAINERS", q=True) + except ValueError: + containers = [] + + loaded_representation_ids = { + cmds.getAttr(container + ".representation") + for container in containers + } + self.builder.set_shared_populate_data( + "loaded_representation_ids", loaded_representation_ids + ) + return loaded_representation_ids + + def create_placeholder(self, placeholder_data): + selection = cmds.ls(selection=True) + if not selection: + raise ValueError("Nothing is selected") + if len(selection) > 1: + raise ValueError("More then one item are selected") + + placeholder_data["plugin_identifier"] = self.identifier + + placeholder_name = self._create_placeholder_name(placeholder_data) + + placeholder = cmds.spaceLocator(name=placeholder_name)[0] + # TODO: this can crash if selection can't be used + cmds.parent(placeholder, selection[0]) + + # get the long name of the placeholder (with the groups) + placeholder_full_name = ( + cmds.ls(selection[0], long=True)[0] + + "|" + + placeholder.replace("|", "") + ) + + imprint(placeholder_full_name, placeholder_data) + + # Add helper attributes to keep placeholder info + cmds.addAttr( + placeholder_full_name, + longName="parent", + hidden=True, + dataType="string" + ) + cmds.addAttr( + placeholder_full_name, + longName="index", + hidden=True, + attributeType="short", + defaultValue=-1 + ) + + cmds.setAttr(placeholder_full_name + ".parent", "", type="string") + + def update_placeholder(self, placeholder_item, placeholder_data): + node_name = placeholder_item.scene_identifier + new_values = {} + for key, value in placeholder_data.items(): + placeholder_value = placeholder_item.data.get(key) + if value != placeholder_value: + new_values[key] = value + placeholder_item.data[key] = value + + for key in new_values.keys(): + cmds.deleteAttr(node_name + "." + key) + + imprint(node_name, new_values) + + def collect_placeholders(self): + output = [] + scene_placeholders = self._collect_scene_placeholders() + for node_name, placeholder_data in scene_placeholders.items(): + if placeholder_data.get("plugin_identifier") != self.identifier: + continue + + # TODO do data validations and maybe updgrades if are invalid + output.append( + LoadPlaceholderItem(node_name, placeholder_data, self) + ) + + return output + + def populate_placeholder(self, placeholder): + self.populate_load_placeholder(placeholder) + + def repopulate_placeholder(self, placeholder): + repre_ids = self._get_loaded_repre_ids() + self.populate_load_placeholder(placeholder, repre_ids) + + def get_placeholder_options(self, options=None): + return self.get_load_plugin_options(options) + + def cleanup_placeholder(self, placeholder, failed): + """Hide placeholder, parent them to root + add them to placeholder set and register placeholder's parent + to keep placeholder info available for future use + """ + + node = placeholder._scene_identifier + node_parent = placeholder.data["parent"] + if node_parent: + cmds.setAttr(node + ".parent", node_parent, type="string") + + if cmds.getAttr(node + ".index") < 0: + cmds.setAttr(node + ".index", placeholder.data["index"]) + + holding_sets = cmds.listSets(object=node) + if holding_sets: + for set in holding_sets: + cmds.sets(node, remove=set) + + if cmds.listRelatives(node, p=True): + node = cmds.parent(node, world=True)[0] + cmds.sets(node, addElement=PLACEHOLDER_SET) + cmds.hide(node) + cmds.setAttr(node + ".hiddenInOutliner", True) + + def load_succeed(self, placeholder, container): + self._parent_in_hierarhchy(placeholder, container) + + def _parent_in_hierarchy(self, placeholder, container): + """Parent loaded container to placeholder's parent. + + ie : Set loaded content as placeholder's sibling + + Args: + container (str): Placeholder loaded containers + """ + + if not container: + return + + roots = cmds.sets(container, q=True) + nodes_to_parent = [] + for root in roots: + if root.endswith("_RN"): + refRoot = cmds.referenceQuery(root, n=True)[0] + refRoot = cmds.listRelatives(refRoot, parent=True) or [refRoot] + nodes_to_parent.extend(refRoot) + elif root not in cmds.listSets(allSets=True): + nodes_to_parent.append(root) + + elif not cmds.sets(root, q=True): + return + + if placeholder.data["parent"]: + cmds.parent(nodes_to_parent, placeholder.data["parent"]) + # Move loaded nodes to correct index in outliner hierarchy + placeholder_form = cmds.xform( + placeholder.scene_identifier, + q=True, + matrix=True, + worldSpace=True + ) + for node in set(nodes_to_parent): + cmds.reorder(node, front=True) + cmds.reorder(node, relative=placeholder.data["index"]) + cmds.xform(node, matrix=placeholder_form, ws=True) + + holding_sets = cmds.listSets(object=placeholder.scene_identifier) + if not holding_sets: + return + for holding_set in holding_sets: + cmds.sets(roots, forceElement=holding_set) + + +def build_workfile_template(*args): + builder = MayaTemplateBuilder(registered_host()) + builder.build_template() + + +def update_workfile_template(*args): + builder = MayaTemplateBuilder(registered_host()) + builder.rebuild_template() + + +def create_placeholder(*args): + host = registered_host() + builder = MayaTemplateBuilder(host) + window = WorkfileBuildPlaceholderDialog(host, builder) + window.exec_() + + +def update_placeholder(*args): + host = registered_host() + builder = MayaTemplateBuilder(host) + placeholder_items_by_id = { + placeholder_item.scene_identifier: placeholder_item + for placeholder_item in builder.get_placeholders() + } + placeholder_items = [] + for node_name in cmds.ls(selection=True, long=True): + if node_name in placeholder_items_by_id: + placeholder_items.append(placeholder_items_by_id[node_name]) + + # TODO show UI at least + if len(placeholder_items) == 0: + raise ValueError("No node selected") + + if len(placeholder_items) > 1: + raise ValueError("Too many selected nodes") + + placeholder_item = placeholder_items[0] + window = WorkfileBuildPlaceholderDialog(host, builder) + window.set_update_mode(placeholder_item) + window.exec_() diff --git a/openpype/hosts/maya/hooks/pre_copy_mel.py b/openpype/hosts/maya/hooks/pre_copy_mel.py index b11e18241e..6f90af4b7c 100644 --- a/openpype/hosts/maya/hooks/pre_copy_mel.py +++ b/openpype/hosts/maya/hooks/pre_copy_mel.py @@ -1,5 +1,5 @@ from openpype.lib import PreLaunchHook -from openpype.hosts.maya.lib import copy_workspace_mel +from openpype.hosts.maya.lib import create_workspace_mel class PreCopyMel(PreLaunchHook): @@ -10,9 +10,10 @@ class PreCopyMel(PreLaunchHook): app_groups = ["maya"] def execute(self): + project_name = self.launch_context.env.get("AVALON_PROJECT") workdir = self.launch_context.env.get("AVALON_WORKDIR") if not workdir: self.log.warning("BUG: Workdir is not filled.") return - copy_workspace_mel(workdir) + create_workspace_mel(workdir, project_name) diff --git a/openpype/hosts/maya/lib.py b/openpype/hosts/maya/lib.py index 6c142053e6..ffb2f0b27c 100644 --- a/openpype/hosts/maya/lib.py +++ b/openpype/hosts/maya/lib.py @@ -1,26 +1,24 @@ import os -import shutil +from openpype.settings import get_project_settings +from openpype.lib import Logger -def copy_workspace_mel(workdir): - # Check that source mel exists - current_dir = os.path.dirname(os.path.abspath(__file__)) - src_filepath = os.path.join(current_dir, "resources", "workspace.mel") - if not os.path.exists(src_filepath): - print("Source mel file does not exist. {}".format(src_filepath)) - return - - # Skip if workspace.mel already exists +def create_workspace_mel(workdir, project_name): dst_filepath = os.path.join(workdir, "workspace.mel") if os.path.exists(dst_filepath): return - # Create workdir if does not exists yet if not os.path.exists(workdir): os.makedirs(workdir) - # Copy file - print("Copying workspace mel \"{}\" -> \"{}\"".format( - src_filepath, dst_filepath - )) - shutil.copy(src_filepath, dst_filepath) + project_setting = get_project_settings(project_name) + mel_script = project_setting["maya"].get("mel_workspace") + + # Skip if mel script in settings is empty + if not mel_script: + log = Logger.get_logger("create_workspace_mel") + log.debug("File 'workspace.mel' not created. Settings value is empty.") + return + + with open(dst_filepath, "w") as mel_file: + mel_file.write(mel_script) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 5418ec1f2f..2b2c978d3c 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -9,7 +9,7 @@ import requests from maya import cmds from maya.app.renderSetup.model import renderSetup -from openpype.api import ( +from openpype.settings import ( get_system_settings, get_project_settings, ) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index 4e4417ff34..44cbee0502 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Creator for Unreal Static Meshes.""" from openpype.hosts.maya.api import plugin, lib -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import legacy_io from maya import cmds # noqa diff --git a/openpype/hosts/maya/plugins/create/create_vrayscene.py b/openpype/hosts/maya/plugins/create/create_vrayscene.py index 45c4b7e443..59d80e6d5b 100644 --- a/openpype/hosts/maya/plugins/create/create_vrayscene.py +++ b/openpype/hosts/maya/plugins/create/create_vrayscene.py @@ -12,7 +12,7 @@ from openpype.hosts.maya.api import ( lib, plugin ) -from openpype.api import ( +from openpype.settings import ( get_system_settings, get_project_settings ) diff --git a/openpype/hosts/maya/plugins/load/load_ass.py b/openpype/hosts/maya/plugins/load/load_ass.py index d1b12ceaba..5db6fc3dfa 100644 --- a/openpype/hosts/maya/plugins/load/load_ass.py +++ b/openpype/hosts/maya/plugins/load/load_ass.py @@ -1,7 +1,7 @@ import os import clique -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path diff --git a/openpype/hosts/maya/plugins/load/load_gpucache.py b/openpype/hosts/maya/plugins/load/load_gpucache.py index 179819f904..a09f924c7b 100644 --- a/openpype/hosts/maya/plugins/load/load_gpucache.py +++ b/openpype/hosts/maya/plugins/load/load_gpucache.py @@ -4,7 +4,7 @@ from openpype.pipeline import ( load, get_representation_path ) -from openpype.api import get_project_settings +from openpype.settings import get_project_settings class GpuCacheLoader(load.LoaderPlugin): diff --git a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py index d93a9f02a2..c288e23ded 100644 --- a/openpype/hosts/maya/plugins/load/load_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/load/load_redshift_proxy.py @@ -5,7 +5,7 @@ import clique import maya.cmds as cmds -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 5a06661df9..c762a29326 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -1,7 +1,7 @@ import os from maya import cmds -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import legacy_io from openpype.pipeline.create import ( legacy_create, diff --git a/openpype/hosts/maya/plugins/load/load_vdb_to_arnold.py b/openpype/hosts/maya/plugins/load/load_vdb_to_arnold.py index d458c5abda..8a386cecfd 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_arnold.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_arnold.py @@ -1,6 +1,6 @@ import os -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path 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 c6a69dfe35..1f02321dc8 100644 --- a/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py +++ b/openpype/hosts/maya/plugins/load/load_vdb_to_redshift.py @@ -1,6 +1,6 @@ import os -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path 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 3a16264ec0..9267c59c02 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 openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path diff --git a/openpype/hosts/maya/plugins/load/load_vrayproxy.py b/openpype/hosts/maya/plugins/load/load_vrayproxy.py index e3d6166d3a..720a132aa7 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayproxy.py +++ b/openpype/hosts/maya/plugins/load/load_vrayproxy.py @@ -10,7 +10,7 @@ import os import maya.cmds as cmds from openpype.client import get_representation_by_name -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( legacy_io, load, diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 61132088cc..d87992f9a7 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import os import maya.cmds as cmds # noqa -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index 8435ba2493..090047e22d 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -6,7 +6,7 @@ from collections import defaultdict import clique from maya import cmds -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import ( load, get_representation_path @@ -250,7 +250,7 @@ class YetiCacheLoader(load.LoaderPlugin): """ name = node_name.replace(":", "_") - pattern = r"^({name})(\.[0-4]+)?(\.fur)$".format(name=re.escape(name)) + pattern = r"^({name})(\.[0-9]+)?(\.fur)$".format(name=re.escape(name)) files = [fname for fname in os.listdir(root) if re.match(pattern, fname)] diff --git a/openpype/hosts/maya/plugins/load/load_yeti_rig.py b/openpype/hosts/maya/plugins/load/load_yeti_rig.py index 4b730ad2c1..651607de8a 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_rig.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_rig.py @@ -1,7 +1,7 @@ import os from collections import defaultdict -from openpype.api import get_project_settings +from openpype.settings import get_project_settings import openpype.hosts.maya.api.plugin from openpype.hosts.maya.api import lib diff --git a/openpype/hosts/maya/plugins/publish/collect_inputs.py b/openpype/hosts/maya/plugins/publish/collect_inputs.py new file mode 100644 index 0000000000..470fceffc9 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_inputs.py @@ -0,0 +1,215 @@ +import copy +from bson.objectid import ObjectId + +from maya import cmds +import maya.api.OpenMaya as om +import pyblish.api + +from openpype.pipeline import registered_host +from openpype.hosts.maya.api.lib import get_container_members +from openpype.hosts.maya.api.lib_rendersetup import get_shader_in_layer + + +def iter_history(nodes, + filter=om.MFn.kInvalid, + direction=om.MItDependencyGraph.kUpstream): + """Iterate unique upstream history for list of nodes. + + This acts as a replacement to maya.cmds.listHistory. + It's faster by about 2x-3x. It returns less than + maya.cmds.listHistory as it excludes the input nodes + from the output (unless an input node was history + for another input node). It also excludes duplicates. + + Args: + nodes (list): Maya node names to start search from. + filter (om.MFn.Type): Filter to only specific types. + e.g. to dag nodes using om.MFn.kDagNode + direction (om.MItDependencyGraph.Direction): Direction to traverse in. + Defaults to upstream. + + Yields: + str: Node names in upstream history. + + """ + if not nodes: + return + + sel = om.MSelectionList() + for node in nodes: + sel.add(node) + + it = om.MItDependencyGraph(sel.getDependNode(0)) # init iterator + handle = om.MObjectHandle + + traversed = set() + fn_dep = om.MFnDependencyNode() + fn_dag = om.MFnDagNode() + for i in range(sel.length()): + + start_node = sel.getDependNode(i) + start_node_hash = handle(start_node).hashCode() + if start_node_hash in traversed: + continue + + it.resetTo(start_node, + filter=filter, + direction=direction) + while not it.isDone(): + + node = it.currentNode() + node_hash = handle(node).hashCode() + + if node_hash in traversed: + it.prune() + it.next() # noqa: B305 + continue + + traversed.add(node_hash) + + if node.hasFn(om.MFn.kDagNode): + fn_dag.setObject(node) + yield fn_dag.fullPathName() + else: + fn_dep.setObject(node) + yield fn_dep.name() + + it.next() # noqa: B305 + + +def collect_input_containers(containers, nodes): + """Collect containers that contain any of the node in `nodes`. + + This will return any loaded Avalon container that contains at least one of + the nodes. As such, the Avalon container is an input for it. Or in short, + there are member nodes of that container. + + Returns: + list: Input avalon containers + + """ + # Assume the containers have collected their cached '_members' data + # in the collector. + return [container for container in containers + if any(node in container["_members"] for node in nodes)] + + +class CollectUpstreamInputs(pyblish.api.InstancePlugin): + """Collect input source inputs for this publish. + + This will include `inputs` data of which loaded publishes were used in the + generation of this publish. This leaves an upstream trace to what was used + as input. + + """ + + label = "Collect Inputs" + order = pyblish.api.CollectorOrder + 0.34 + hosts = ["maya"] + + def process(self, instance): + + # For large scenes the querying of "host.ls()" can be relatively slow + # e.g. up to a second. Many instances calling it easily slows this + # down. As such, we cache it so we trigger it only once. + # todo: Instead of hidden cache make "CollectContainers" plug-in + cache_key = "__cache_containers" + scene_containers = instance.context.data.get(cache_key, None) + if scene_containers is None: + # Query the scenes' containers if there's no cache yet + host = registered_host() + scene_containers = list(host.ls()) + for container in scene_containers: + # Embed the members into the container dictionary + container_members = set(get_container_members(container)) + container["_members"] = container_members + instance.context.data["__cache_containers"] = scene_containers + + # Collect the relevant input containers for this instance + if "renderlayer" in set(instance.data.get("families", [])): + # Special behavior for renderlayers + self.log.debug("Collecting renderlayer inputs....") + containers = self._collect_renderlayer_inputs(scene_containers, + instance) + + else: + # Basic behavior + nodes = instance[:] + + # Include any input connections of history with long names + # For optimization purposes only trace upstream from shape nodes + # looking for used dag nodes. This way having just a constraint + # on a transform is also ignored which tended to give irrelevant + # inputs for the majority of our use cases. We tend to care more + # about geometry inputs. + shapes = cmds.ls(nodes, + type=("mesh", "nurbsSurface", "nurbsCurve"), + noIntermediate=True) + if shapes: + history = list(iter_history(shapes, filter=om.MFn.kShape)) + history = cmds.ls(history, long=True) + + # Include the transforms in the collected history as shapes + # are excluded from containers + transforms = cmds.listRelatives(cmds.ls(history, shapes=True), + parent=True, + fullPath=True, + type="transform") + if transforms: + history.extend(transforms) + + if history: + nodes = list(set(nodes + history)) + + # Collect containers for the given set of nodes + containers = collect_input_containers(scene_containers, + nodes) + + inputs = [ObjectId(c["representation"]) for c in containers] + instance.data["inputRepresentations"] = inputs + + self.log.info("Collected inputs: %s" % inputs) + + def _collect_renderlayer_inputs(self, scene_containers, instance): + """Collects inputs from nodes in renderlayer, incl. shaders + camera""" + + # Get the renderlayer + renderlayer = instance.data.get("setMembers") + + if renderlayer == "defaultRenderLayer": + # Assume all loaded containers in the scene are inputs + # for the masterlayer + return copy.deepcopy(scene_containers) + else: + # Get the members of the layer + members = cmds.editRenderLayerMembers(renderlayer, + query=True, + fullNames=True) or [] + + # In some cases invalid objects are returned from + # `editRenderLayerMembers` so we filter them out + members = cmds.ls(members, long=True) + + # Include all children + children = cmds.listRelatives(members, + allDescendents=True, + fullPath=True) or [] + members.extend(children) + + # Include assigned shaders in renderlayer + shapes = cmds.ls(members, shapes=True, long=True) + shaders = set() + for shape in shapes: + shape_shaders = get_shader_in_layer(shape, layer=renderlayer) + if not shape_shaders: + continue + shaders.update(shape_shaders) + members.extend(shaders) + + # Explicitly include the camera being rendered in renderlayer + cameras = instance.data.get("cameras") + members.extend(cameras) + + containers = collect_input_containers(scene_containers, members) + + return containers diff --git a/openpype/hosts/maya/plugins/publish/collect_maya_scene.py b/openpype/hosts/maya/plugins/publish/collect_maya_scene.py deleted file mode 100644 index eb21b17989..0000000000 --- a/openpype/hosts/maya/plugins/publish/collect_maya_scene.py +++ /dev/null @@ -1,25 +0,0 @@ -from maya import cmds - -import pyblish.api - - -class CollectMayaScene(pyblish.api.InstancePlugin): - """Collect Maya Scene Data - - """ - - order = pyblish.api.CollectorOrder + 0.2 - label = 'Collect Model Data' - families = ["mayaScene"] - - def process(self, instance): - # Extract only current frame (override) - frame = cmds.currentTime(query=True) - instance.data["frameStart"] = frame - instance.data["frameEnd"] = frame - - # make ftrack publishable - if instance.data.get('families'): - instance.data['families'].append('ftrack') - else: - instance.data['families'] = ['ftrack'] diff --git a/openpype/hosts/maya/plugins/publish/collect_maya_scene_time.py b/openpype/hosts/maya/plugins/publish/collect_maya_scene_time.py new file mode 100644 index 0000000000..7e198df14d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_maya_scene_time.py @@ -0,0 +1,26 @@ +from maya import cmds + +import pyblish.api + + +class CollectMayaSceneTime(pyblish.api.InstancePlugin): + """Collect Maya Scene playback range + + This allows to reproduce the playback range for the content to be loaded. + It does *not* limit the extracted data to only data inside that time range. + + """ + + order = pyblish.api.CollectorOrder + 0.2 + label = 'Collect Maya Scene Time' + families = ["mayaScene"] + + def process(self, instance): + instance.data.update({ + "frameStart": cmds.playbackOptions(query=True, minTime=True), + "frameEnd": cmds.playbackOptions(query=True, maxTime=True), + "frameStartHandle": cmds.playbackOptions(query=True, + animationStartTime=True), + "frameEndHandle": cmds.playbackOptions(query=True, + animationEndTime=True) + }) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 14aac2f206..b1ad3ca58e 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -102,23 +102,26 @@ class CollectMayaRender(pyblish.api.ContextPlugin): } for layer in collected_render_layers: - try: - if layer.startswith("LAYER_"): - # this is support for legacy mode where render layers - # started with `LAYER_` prefix. - expected_layer_name = re.search( - r"^LAYER_(.*)", layer).group(1) - else: - # new way is to prefix render layer name with instance - # namespace. - expected_layer_name = re.search( - r"^.+:(.*)", layer).group(1) - except IndexError: + if layer.startswith("LAYER_"): + # this is support for legacy mode where render layers + # started with `LAYER_` prefix. + layer_name_pattern = r"^LAYER_(.*)" + else: + # new way is to prefix render layer name with instance + # namespace. + layer_name_pattern = r"^.+:(.*)" + + # todo: We should have a more explicit way to link the renderlayer + match = re.match(layer_name_pattern, layer) + if not match: msg = "Invalid layer name in set [ {} ]".format(layer) self.log.warning(msg) continue - self.log.info("processing %s" % layer) + expected_layer_name = match.group(1) + self.log.info("Processing '{}' as layer [ {} ]" + "".format(layer, expected_layer_name)) + # check if layer is part of renderSetup if expected_layer_name not in maya_render_layers: msg = "Render layer [ {} ] is not in " "Render Setup".format( diff --git a/openpype/hosts/maya/plugins/publish/collect_rig.py b/openpype/hosts/maya/plugins/publish/collect_rig.py deleted file mode 100644 index 98ae1e8009..0000000000 --- a/openpype/hosts/maya/plugins/publish/collect_rig.py +++ /dev/null @@ -1,22 +0,0 @@ -from maya import cmds - -import pyblish.api - - -class CollectRigData(pyblish.api.InstancePlugin): - """Collect rig data - - Ensures rigs are published to Ftrack. - - """ - - order = pyblish.api.CollectorOrder + 0.2 - label = 'Collect Rig Data' - families = ["rig"] - - def process(self, instance): - # make ftrack publishable - if instance.data.get('families'): - instance.data['families'].append('ftrack') - else: - instance.data['families'] = ['ftrack'] diff --git a/openpype/hosts/maya/plugins/publish/extract_ass.py b/openpype/hosts/maya/plugins/publish/extract_ass.py index 760f410f91..5c21a4ff08 100644 --- a/openpype/hosts/maya/plugins/publish/extract_ass.py +++ b/openpype/hosts/maya/plugins/publish/extract_ass.py @@ -1,12 +1,12 @@ import os -import openpype.api - from maya import cmds + +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractAssStandin(openpype.api.Extractor): +class ExtractAssStandin(publish.Extractor): """Extract the content of the instance to a ass file Things to pay attention to: diff --git a/openpype/hosts/maya/plugins/publish/extract_assembly.py b/openpype/hosts/maya/plugins/publish/extract_assembly.py index 120805894e..35932003ee 100644 --- a/openpype/hosts/maya/plugins/publish/extract_assembly.py +++ b/openpype/hosts/maya/plugins/publish/extract_assembly.py @@ -1,14 +1,13 @@ +import os import json -import os - -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import extract_alembic from maya import cmds -class ExtractAssembly(openpype.api.Extractor): +class ExtractAssembly(publish.Extractor): """Produce an alembic of just point positions and normals. Positions and normals are preserved, but nothing more, diff --git a/openpype/hosts/maya/plugins/publish/extract_assproxy.py b/openpype/hosts/maya/plugins/publish/extract_assproxy.py index 93720dbb82..4937a28a9e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_assproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_assproxy.py @@ -3,17 +3,17 @@ import contextlib from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractAssProxy(openpype.api.Extractor): +class ExtractAssProxy(publish.Extractor): """Extract proxy model as Maya Ascii to use as arnold standin """ - order = openpype.api.Extractor.order + 0.2 + order = publish.Extractor.order + 0.2 label = "Ass Proxy (Maya ASCII)" hosts = ["maya"] families = ["ass"] diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index b744bfd0fe..aa445a0387 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -2,11 +2,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api import lib -class ExtractCameraAlembic(openpype.api.Extractor): +class ExtractCameraAlembic(publish.Extractor): """Extract a Camera as Alembic. The cameras gets baked to world space by default. Only when the instance's diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 8d6c4b5f3c..7467fa027d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -5,7 +5,7 @@ import itertools from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api import lib @@ -78,7 +78,7 @@ def unlock(plug): cmds.disconnectAttr(source, destination) -class ExtractCameraMayaScene(openpype.api.Extractor): +class ExtractCameraMayaScene(publish.Extractor): """Extract a Camera as Maya Scene. This will create a duplicate of the camera that will be baked *with* diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index fbbe8e06b0..9af3acef65 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -4,13 +4,13 @@ import os from maya import cmds # noqa import maya.mel as mel # noqa import pyblish.api -import openpype.api -from openpype.hosts.maya.api.lib import maintained_selection +from openpype.pipeline import publish +from openpype.hosts.maya.api.lib import maintained_selection from openpype.hosts.maya.api import fbx -class ExtractFBX(openpype.api.Extractor): +class ExtractFBX(publish.Extractor): """Extract FBX from Maya. This extracts reproducible FBX exports ignoring any of the diff --git a/openpype/hosts/maya/plugins/publish/extract_layout.py b/openpype/hosts/maya/plugins/publish/extract_layout.py index 991217684a..0f499b09b1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_layout.py +++ b/openpype/hosts/maya/plugins/publish/extract_layout.py @@ -5,13 +5,11 @@ import json from maya import cmds from maya.api import OpenMaya as om -from bson.objectid import ObjectId - -from openpype.pipeline import legacy_io -import openpype.api +from openpype.client import get_representation_by_id +from openpype.pipeline import legacy_io, publish -class ExtractLayout(openpype.api.Extractor): +class ExtractLayout(publish.Extractor): """Extract a layout.""" label = "Extract Layout" @@ -30,6 +28,8 @@ class ExtractLayout(openpype.api.Extractor): instance.data["representations"] = [] json_data = [] + # TODO representation queries can be refactored to be faster + project_name = legacy_io.active_project() for asset in cmds.sets(str(instance), query=True): # Find the container @@ -43,11 +43,11 @@ class ExtractLayout(openpype.api.Extractor): representation_id = cmds.getAttr(f"{container}.representation") - representation = legacy_io.find_one( - { - "type": "representation", - "_id": ObjectId(representation_id) - }, projection={"parent": True, "context.family": True}) + representation = get_representation_by_id( + project_name, + representation_id, + fields=["parent", "context.family"] + ) self.log.info(representation) @@ -102,9 +102,10 @@ class ExtractLayout(openpype.api.Extractor): for i in range(0, len(t_matrix_list), row_length): t_matrix.append(t_matrix_list[i:i + row_length]) - json_element["transform_matrix"] = [] - for row in t_matrix: - json_element["transform_matrix"].append(list(row)) + json_element["transform_matrix"] = [ + list(row) + for row in t_matrix + ] basis_list = [ 1, 0, 0, 0, diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index ce3b265566..403b4ee6bc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -13,8 +13,8 @@ from maya import cmds # noqa import pyblish.api -import openpype.api -from openpype.pipeline import legacy_io +from openpype.lib import source_hash, run_subprocess +from openpype.pipeline import legacy_io, publish from openpype.hosts.maya.api import lib # Modes for transfer @@ -68,7 +68,7 @@ def find_paths_by_hash(texture_hash): return legacy_io.distinct(key, {"type": "version"}) -def maketx(source, destination, *args): +def maketx(source, destination, args, logger): """Make `.tx` using `maketx` with some default settings. The settings are based on default as used in Arnold's @@ -79,7 +79,8 @@ def maketx(source, destination, *args): Args: source (str): Path to source file. destination (str): Writing destination path. - *args: Additional arguments for `maketx`. + args (list): Additional arguments for `maketx`. + logger (logging.Logger): Logger to log messages to. Returns: str: Output of `maketx` command. @@ -94,7 +95,7 @@ def maketx(source, destination, *args): "OIIO tool not found in {}".format(maketx_path)) raise AssertionError("OIIO tool not found") - cmd = [ + subprocess_args = [ maketx_path, "-v", # verbose "-u", # update mode @@ -103,27 +104,20 @@ def maketx(source, destination, *args): "--checknan", # use oiio-optimized settings for tile-size, planarconfig, metadata "--oiio", - "--filter lanczos3", - escape_space(source) + "--filter", "lanczos3", + source ] - cmd.extend(args) - cmd.extend(["-o", escape_space(destination)]) + subprocess_args.extend(args) + subprocess_args.extend(["-o", destination]) - cmd = " ".join(cmd) + cmd = " ".join(subprocess_args) + logger.debug(cmd) - CREATE_NO_WINDOW = 0x08000000 # noqa - kwargs = dict(args=cmd, stderr=subprocess.STDOUT) - - if sys.platform == "win32": - kwargs["creationflags"] = CREATE_NO_WINDOW try: - out = subprocess.check_output(**kwargs) - except subprocess.CalledProcessError as exc: - print(exc) - import traceback - - traceback.print_exc() + out = run_subprocess(subprocess_args) + except Exception: + logger.error("Maketx converion failed", exc_info=True) raise return out @@ -161,7 +155,7 @@ def no_workspace_dir(): os.rmdir(fake_workspace_dir) -class ExtractLook(openpype.api.Extractor): +class ExtractLook(publish.Extractor): """Extract Look (Maya Scene + JSON) Only extracts the sets (shadingEngines and alike) alongside a .json file @@ -505,7 +499,7 @@ class ExtractLook(openpype.api.Extractor): args = [] if do_maketx: args.append("maketx") - texture_hash = openpype.api.source_hash(filepath, *args) + texture_hash = source_hash(filepath, *args) # If source has been published before with the same settings, # then don't reprocess but hardlink from the original @@ -524,15 +518,17 @@ class ExtractLook(openpype.api.Extractor): if do_maketx and ext != ".tx": # Produce .tx file in staging if source file is not .tx converted = os.path.join(staging, "resources", fname + ".tx") - + additional_args = [ + "--sattrib", + "sourceHash", + texture_hash + ] if linearize: self.log.info("tx: converting sRGB -> linear") - colorconvert = "--colorconvert sRGB linear" - else: - colorconvert = "" + additional_args.extend(["--colorconvert", "sRGB", "linear"]) config_path = get_ocio_config_path("nuke-default") - color_config = "--colorconfig {0}".format(config_path) + additional_args.extend(["--colorconfig", config_path]) # Ensure folder exists if not os.path.exists(os.path.dirname(converted)): os.makedirs(os.path.dirname(converted)) @@ -541,12 +537,8 @@ class ExtractLook(openpype.api.Extractor): maketx( filepath, converted, - # Include `source-hash` as string metadata - "--sattrib", - "sourceHash", - escape_space(texture_hash), - colorconvert, - color_config + additional_args, + self.log ) return converted, COPY, texture_hash 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 3a47cdadb5..3769ec3605 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -4,12 +4,11 @@ import os from maya import cmds -import openpype.api from openpype.hosts.maya.api.lib import maintained_selection -from openpype.pipeline import AVALON_CONTAINER_ID +from openpype.pipeline import AVALON_CONTAINER_ID, publish -class ExtractMayaSceneRaw(openpype.api.Extractor): +class ExtractMayaSceneRaw(publish.Extractor): """Extract as Maya Scene (raw). This will preserve all references, construction history, etc. diff --git a/openpype/hosts/maya/plugins/publish/extract_model.py b/openpype/hosts/maya/plugins/publish/extract_model.py index 0282d1e9c8..7c8c3a2981 100644 --- a/openpype/hosts/maya/plugins/publish/extract_model.py +++ b/openpype/hosts/maya/plugins/publish/extract_model.py @@ -4,11 +4,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api import lib -class ExtractModel(openpype.api.Extractor): +class ExtractModel(publish.Extractor): """Extract as Model (Maya Scene). Only extracts contents based on the original "setMembers" data to ensure diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py index 82e2b41929..92137acb95 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_look.py @@ -2,11 +2,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractMultiverseLook(openpype.api.Extractor): +class ExtractMultiverseLook(publish.Extractor): """Extractor for Multiverse USD look data. This will extract: diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 3654be7b34..6c352bebe6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -3,11 +3,11 @@ import six from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractMultiverseUsd(openpype.api.Extractor): +class ExtractMultiverseUsd(publish.Extractor): """Extractor for Multiverse USD Asset data. This will extract settings for a Multiverse Write Asset operation: diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index ad9303657f..a62729c198 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -2,11 +2,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractMultiverseUsdComposition(openpype.api.Extractor): +class ExtractMultiverseUsdComposition(publish.Extractor): """Extractor of Multiverse USD Composition data. This will extract settings for a Multiverse Write Composition operation: diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index d44e3878b8..0628623e88 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -1,12 +1,12 @@ import os -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection from maya import cmds -class ExtractMultiverseUsdOverride(openpype.api.Extractor): +class ExtractMultiverseUsdOverride(publish.Extractor): """Extractor for Multiverse USD Override data. This will extract settings for a Multiverse Write Override operation: diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 871adda0c3..8df7edc3f4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -1,18 +1,16 @@ import os -import glob -import contextlib import clique import capture +from openpype.pipeline import publish from openpype.hosts.maya.api import lib -import openpype.api from maya import cmds import pymel.core as pm -class ExtractPlayblast(openpype.api.Extractor): +class ExtractPlayblast(publish.Extractor): """Extract viewport playblast. Takes review camera and creates review Quicktime video based on viewport @@ -138,8 +136,10 @@ class ExtractPlayblast(openpype.api.Extractor): self.log.debug("playblast path {}".format(path)) collected_files = os.listdir(stagingdir) + patterns = [clique.PATTERNS["frames"]] collections, remainder = clique.assemble(collected_files, - minimum_items=1) + minimum_items=1, + patterns=patterns) self.log.debug("filename {}".format(filename)) frame_collection = None diff --git a/openpype/hosts/maya/plugins/publish/extract_pointcache.py b/openpype/hosts/maya/plugins/publish/extract_pointcache.py index bf6feecef3..7c1c6d5c12 100644 --- a/openpype/hosts/maya/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/maya/plugins/publish/extract_pointcache.py @@ -2,7 +2,7 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import ( extract_alembic, suspended_refresh, @@ -11,7 +11,7 @@ from openpype.hosts.maya.api.lib import ( ) -class ExtractAlembic(openpype.api.Extractor): +class ExtractAlembic(publish.Extractor): """Produce an alembic of just point positions and normals. Positions and normals, uvs, creases are preserved, but nothing more, diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py index 23cac9190d..4377275635 100644 --- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py @@ -4,11 +4,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractRedshiftProxy(openpype.api.Extractor): +class ExtractRedshiftProxy(publish.Extractor): """Extract the content of the instance to a redshift proxy file.""" label = "Redshift Proxy (.rs)" diff --git a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py index 6bdd5f590e..5970c038a4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rendersetup.py +++ b/openpype/hosts/maya/plugins/publish/extract_rendersetup.py @@ -1,10 +1,11 @@ -import json import os -import openpype.api +import json + import maya.app.renderSetup.model.renderSetup as renderSetup +from openpype.pipeline import publish -class ExtractRenderSetup(openpype.api.Extractor): +class ExtractRenderSetup(publish.Extractor): """ Produce renderSetup template file diff --git a/openpype/hosts/maya/plugins/publish/extract_rig.py b/openpype/hosts/maya/plugins/publish/extract_rig.py index 53c1eeb671..c71a2f710d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_rig.py @@ -4,11 +4,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractRig(openpype.api.Extractor): +class ExtractRig(publish.Extractor): """Extract rig as Maya Scene.""" label = "Extract Rig (Maya Scene)" diff --git a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py index 9380da5128..854301ea48 100644 --- a/openpype/hosts/maya/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/maya/plugins/publish/extract_thumbnail.py @@ -3,14 +3,14 @@ import glob import capture +from openpype.pipeline import publish from openpype.hosts.maya.api import lib -import openpype.api from maya import cmds import pymel.core as pm -class ExtractThumbnail(openpype.api.Extractor): +class ExtractThumbnail(publish.Extractor): """Extract viewport thumbnail. Takes review camera and creates a thumbnail based on viewport diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 7ef7f2f181..258120db2f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -6,7 +6,8 @@ from contextlib import contextmanager from maya import cmds # noqa import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.maya.api import fbx @@ -20,7 +21,7 @@ def renamed(original_name, renamed_name): cmds.rename(renamed_name, original_name) -class ExtractUnrealSkeletalMesh(openpype.api.Extractor): +class ExtractUnrealSkeletalMesh(publish.Extractor): """Extract Unreal Skeletal Mesh as FBX from Maya. """ order = pyblish.api.ExtractorOrder - 0.1 diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 69d51f9ff1..44f0615a27 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -5,7 +5,8 @@ import os from maya import cmds # noqa import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import ( parent_nodes, maintained_selection @@ -13,7 +14,7 @@ from openpype.hosts.maya.api.lib import ( from openpype.hosts.maya.api import fbx -class ExtractUnrealStaticMesh(openpype.api.Extractor): +class ExtractUnrealStaticMesh(publish.Extractor): """Extract Unreal Static Mesh as FBX from Maya. """ order = pyblish.api.ExtractorOrder - 0.1 diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index 562ca078e1..38bf02245a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -2,11 +2,11 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import maintained_selection -class ExtractVRayProxy(openpype.api.Extractor): +class ExtractVRayProxy(publish.Extractor): """Extract the content of the instance to a vrmesh file Things to pay attention to: diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py index 5d41697e5f..8442df1611 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayscene.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayscene.py @@ -3,14 +3,14 @@ import os import re -import openpype.api +from openpype.pipeline import publish 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 -class ExtractVrayscene(openpype.api.Extractor): +class ExtractVrayscene(publish.Extractor): """Extractor for vrscene.""" label = "VRay Scene (.vrscene)" diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py index 5728682abe..77350f343e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_xgen_cache.py @@ -2,14 +2,14 @@ import os from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api.lib import ( suspended_refresh, maintained_selection ) -class ExtractXgenCache(openpype.api.Extractor): +class ExtractXgenCache(publish.Extractor): """Produce an alembic of just xgen interactive groom """ diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py index cf6db00e9a..b61f599cab 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py @@ -3,10 +3,10 @@ import json from maya import cmds -import openpype.api +from openpype.pipeline import publish -class ExtractYetiCache(openpype.api.Extractor): +class ExtractYetiCache(publish.Extractor): """Producing Yeti cache files using scene time range. This will extract Yeti cache file sequence and fur settings. diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py index 6e21bffa4e..1d0c5e88c3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_rig.py @@ -7,7 +7,7 @@ import contextlib from maya import cmds -import openpype.api +from openpype.pipeline import publish from openpype.hosts.maya.api import lib @@ -90,7 +90,7 @@ def yetigraph_attribute_values(assumed_destination, resources): pass -class ExtractYetiRig(openpype.api.Extractor): +class ExtractYetiRig(publish.Extractor): """Extract the Yeti rig to a Maya Scene and write the Yeti rig data.""" label = "Extract Yeti Rig" diff --git a/openpype/hosts/maya/plugins/publish/save_scene.py b/openpype/hosts/maya/plugins/publish/save_scene.py index 50a2f2112a..45e62e7b44 100644 --- a/openpype/hosts/maya/plugins/publish/save_scene.py +++ b/openpype/hosts/maya/plugins/publish/save_scene.py @@ -1,4 +1,8 @@ import pyblish.api +from openpype.pipeline.workfile.lock_workfile import ( + is_workfile_lock_enabled, + remove_workfile_lock +) class SaveCurrentScene(pyblish.api.ContextPlugin): @@ -22,6 +26,10 @@ class SaveCurrentScene(pyblish.api.ContextPlugin): self.log.debug("Skipping file save as there " "are no modifications..") return - + project_name = context.data["projectName"] + project_settings = context.data["project_settings"] + # remove lockfile before saving + if is_workfile_lock_enabled("maya", project_name, project_settings): + remove_workfile_lock(current) self.log.info("Saving current file..") cmds.file(save=True, force=True) diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index c4250a20bd..df06220fd3 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -11,7 +11,7 @@ import pyblish.api from openpype.lib import requests_post from openpype.hosts.maya.api import lib from openpype.pipeline import legacy_io -from openpype.api import get_system_settings +from openpype.settings import get_system_settings # mapping between Maya renderer names and Muster template ids diff --git a/openpype/hosts/maya/plugins/publish/validate_animation_content.py b/openpype/hosts/maya/plugins/publish/validate_animation_content.py index 6f7a6b905a..9dbb09a046 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animation_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_animation_content.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_animation_out_set_related_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_animation_out_set_related_node_ids.py index aa27633402..649913fff6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_animation_out_set_related_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_animation_out_set_related_node_ids.py @@ -1,7 +1,6 @@ import maya.cmds as cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py index a9ea5a6d15..229da63c42 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_namespaces.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api import openpype.hosts.maya.api.action diff --git a/openpype/hosts/maya/plugins/publish/validate_assembly_transforms.py b/openpype/hosts/maya/plugins/publish/validate_assembly_transforms.py index fb25b617be..3f2c59b95b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_assembly_transforms.py +++ b/openpype/hosts/maya/plugins/publish/validate_assembly_transforms.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api from maya import cmds diff --git a/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py b/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py index 19c1179e52..bd1529e252 100644 --- a/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_camera_attributes.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_camera_contents.py b/openpype/hosts/maya/plugins/publish/validate_camera_contents.py index f846319807..1ce8026fc2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_camera_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_camera_contents.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_color_sets.py b/openpype/hosts/maya/plugins/publish/validate_color_sets.py index cab9d6ebab..905417bafa 100644 --- a/openpype/hosts/maya/plugins/publish/validate_color_sets.py +++ b/openpype/hosts/maya/plugins/publish/validate_color_sets.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_cycle_error.py b/openpype/hosts/maya/plugins/publish/validate_cycle_error.py index d3b8316d94..210ee4127c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_cycle_error.py +++ b/openpype/hosts/maya/plugins/publish/validate_cycle_error.py @@ -2,7 +2,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import maintained_selection from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_has_members.py b/openpype/hosts/maya/plugins/publish/validate_instance_has_members.py index bf92ac5099..4870f27bff 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_has_members.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_has_members.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_look_contents.py b/openpype/hosts/maya/plugins/publish/validate_look_contents.py index d9819b05d5..53501d11e5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_contents.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_look_id_reference_edits.py b/openpype/hosts/maya/plugins/publish/validate_look_id_reference_edits.py index f223c1a42b..a266a0fd74 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_id_reference_edits.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_id_reference_edits.py @@ -2,7 +2,6 @@ from collections import defaultdict from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py b/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py index 210fcb174d..f81e511ff3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_members_unique.py @@ -1,7 +1,6 @@ from collections import defaultdict import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidatePipelineOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_look_no_default_shaders.py b/openpype/hosts/maya/plugins/publish/validate_look_no_default_shaders.py index 95f8fa20d0..db6aadae8d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_no_default_shaders.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_no_default_shaders.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_look_sets.py b/openpype/hosts/maya/plugins/publish/validate_look_sets.py index 3a60b771f4..8434ddde04 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_sets.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_sets.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_look_shading_group.py b/openpype/hosts/maya/plugins/publish/validate_look_shading_group.py index 7d043eddb8..9b57b06ee7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_shading_group.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_shading_group.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_look_single_shader.py b/openpype/hosts/maya/plugins/publish/validate_look_single_shader.py index 51e1232bb7..788e440d12 100644 --- a/openpype/hosts/maya/plugins/publish/validate_look_single_shader.py +++ b/openpype/hosts/maya/plugins/publish/validate_look_single_shader.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder 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 abfe1213a0..c1c0636b9e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_arnold_attributes.py @@ -1,7 +1,6 @@ import pymel.core as pc from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api.lib import maintained_selection from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py b/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py index 4d2885d6e2..36a0da7a59 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_has_uv.py @@ -3,7 +3,6 @@ import re from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateMeshOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py b/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py index e7a73c21b0..4427c6eece 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_lamina_faces.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateMeshOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py index 24d6188ec8..5b67db3307 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_no_negative_scale.py b/openpype/hosts/maya/plugins/publish/validate_mesh_no_negative_scale.py index 18ceccaa28..664e2b5772 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_no_negative_scale.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_no_negative_scale.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateMeshOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_non_manifold.py b/openpype/hosts/maya/plugins/publish/validate_mesh_non_manifold.py index e75a132d50..d7711da722 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_non_manifold.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_non_manifold.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateMeshOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py b/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py index 8c03b54971..0ef2716559 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_non_zero_edge.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ValidateMeshOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py index 7d88161058..c8892a8e59 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py @@ -2,7 +2,6 @@ from maya import cmds import maya.api.OpenMaya as om2 import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index dde3e4fead..be7324a68f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api import openpype.hosts.maya.api.action import math import maya.api.OpenMaya as om diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_shader_connections.py b/openpype/hosts/maya/plugins/publish/validate_mesh_shader_connections.py index 9621fd5aa8..2a0abe975c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_shader_connections.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_shader_connections.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py b/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py index 3fb09356d3..6ca8c06ba5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_single_uv_set.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_uv_set_map1.py b/openpype/hosts/maya/plugins/publish/validate_mesh_uv_set_map1.py index 2711682f76..40ddb916ca 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_uv_set_map1.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_uv_set_map1.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py b/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py index 350a5f4789..1e6d290ae7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_vertices_have_edges.py @@ -3,7 +3,6 @@ import re from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_model_content.py b/openpype/hosts/maya/plugins/publish/validate_model_content.py index 0557858639..723346a285 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_content.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_content.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_model_name.py b/openpype/hosts/maya/plugins/publish/validate_model_name.py index 99a4b2654e..2dec9ba267 100644 --- a/openpype/hosts/maya/plugins/publish/validate_model_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_model_name.py @@ -5,7 +5,6 @@ import re from maya import cmds import pyblish.api -import openpype.api from openpype.pipeline import legacy_io from openpype.pipeline.publish import ValidateContentsOrder import openpype.hosts.maya.api.action diff --git a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py index 62f360cd86..67fc1616c2 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py +++ b/openpype/hosts/maya/plugins/publish/validate_mvlook_contents.py @@ -1,6 +1,5 @@ import os import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_no_animation.py b/openpype/hosts/maya/plugins/publish/validate_no_animation.py index 177de1468d..2e7cafe4ab 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_animation.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_animation.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py b/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py index d4ddb28070..1a5773e6a7 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_default_camera.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_no_namespace.py b/openpype/hosts/maya/plugins/publish/validate_no_namespace.py index 95caa1007f..01c77e5b2e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_namespace.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_namespace.py @@ -2,7 +2,6 @@ import pymel.core as pm import maya.cmds as cmds import pyblish.api -import openpype.api from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, diff --git a/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py b/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py index f31fd09c95..b430c2b63c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_null_transforms.py @@ -1,7 +1,6 @@ import maya.cmds as cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_no_unknown_nodes.py b/openpype/hosts/maya/plugins/publish/validate_no_unknown_nodes.py index 20fe34f2fd..2cfdc28128 100644 --- a/openpype/hosts/maya/plugins/publish/validate_no_unknown_nodes.py +++ b/openpype/hosts/maya/plugins/publish/validate_no_unknown_nodes.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_node_ids.py index 877ba0e781..796f4c8d76 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids.py @@ -1,5 +1,5 @@ import pyblish.api -import openpype.api + from openpype.pipeline.publish import ValidatePipelineOrder import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_deformed_shapes.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_deformed_shapes.py index 1fe4a34e07..68c47f3a96 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids_deformed_shapes.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_deformed_shapes.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_in_database.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_in_database.py index a5b1215f30..b2f28fd4e5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids_in_database.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_in_database.py @@ -1,6 +1,5 @@ import pyblish.api -import openpype.api from openpype.client import get_assets from openpype.pipeline import legacy_io from openpype.pipeline.publish import ValidatePipelineOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_related.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_related.py index a7595d7392..f901dc58c4 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids_related.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_related.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api from openpype.pipeline.publish import ValidatePipelineOrder import openpype.hosts.maya.api.action diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py index 5ff18358e2..f7a5e6e292 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py @@ -1,7 +1,6 @@ from collections import defaultdict import pyblish.api -import openpype.api from openpype.pipeline.publish import ValidatePipelineOrder import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib diff --git a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py index 2f22d6da1e..0f608dab2c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_render_no_default_cameras.py b/openpype/hosts/maya/plugins/publish/validate_render_no_default_cameras.py index da35f42291..67ece75af8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_no_default_cameras.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_no_default_cameras.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py b/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py index fc41b1cf5b..77322fefd5 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_single_camera.py @@ -3,9 +3,8 @@ import re import pyblish.api from maya import cmds -import openpype.api import openpype.hosts.maya.api.action -from openpype.hosts.maya.api.render_settings import RenderSettings +from openpype.hosts.maya.api.lib_rendersettings import RenderSettings from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 08ecc0d149..ca44e98605 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -13,6 +13,12 @@ from openpype.pipeline.publish import ( from openpype.hosts.maya.api import lib +def get_redshift_image_format_labels(): + """Return nice labels for Redshift image formats.""" + var = "$g_redshiftImageFormatLabels" + return mel.eval("{0}={0}".format(var)) + + class ValidateRenderSettings(pyblish.api.InstancePlugin): """Validates the global render settings @@ -105,8 +111,9 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): # Get the node attributes for current renderer attrs = lib.RENDER_ATTRS.get(renderer, lib.RENDER_ATTRS['default']) + # Prefix attribute can return None when a value was never set prefix = lib.get_attr_in_layer(cls.ImagePrefixes[renderer], - layer=layer) + layer=layer) or "" padding = lib.get_attr_in_layer("{node}.{padding}".format(**attrs), layer=layer) @@ -183,18 +190,22 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): redshift_AOV_prefix )) invalid = True - # get aov format - aov_ext = cmds.getAttr( - "{}.fileFormat".format(aov), asString=True) - default_ext = cmds.getAttr( - "redshiftOptions.imageFormat", asString=True) + # check aov file format + aov_ext = cmds.getAttr("{}.fileFormat".format(aov)) + default_ext = cmds.getAttr("redshiftOptions.imageFormat") + aov_type = cmds.getAttr("{}.aovType".format(aov)) + if aov_type == "Cryptomatte": + # redshift Cryptomatte AOV always uses "Cryptomatte (EXR)" + # so we ignore validating file format for it. + pass - if default_ext != aov_ext: - cls.log.error(("AOV file format is not the same " - "as the one set globally " - "{} != {}").format(default_ext, - aov_ext)) + elif default_ext != aov_ext: + labels = get_redshift_image_format_labels() + cls.log.error( + "AOV file format {} does not match global file format " + "{}".format(labels[aov_ext], labels[default_ext]) + ) invalid = True if renderer == "renderman": @@ -257,14 +268,20 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): # go through definitions and test if such node.attribute exists. # if so, compare its value from the one required. for attr, value in OrderedDict(validation_settings).items(): - # first get node of that type cls.log.debug("{}: {}".format(attr, value)) - node_type = attr.split(".")[0] - attribute_name = ".".join(attr.split(".")[1:]) + if "." not in attr: + cls.log.warning("Skipping invalid attribute defined in " + "validation settings: '{}'".format(attr)) + continue + + node_type, attribute_name = attr.split(".", 1) + + # first get node of that type nodes = cmds.ls(type=node_type) - if not isinstance(nodes, list): - cls.log.warning("No nodes of '{}' found.".format(node_type)) + if not nodes: + cls.log.warning( + "No nodes of type '{}' found.".format(node_type)) continue for node in nodes: @@ -302,6 +319,9 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): default = lib.RENDER_ATTRS['default'] render_attrs = lib.RENDER_ATTRS.get(renderer, default) + # Repair animation must be enabled + cmds.setAttr("defaultRenderGlobals.animation", True) + # Repair prefix if renderer != "renderman": node = render_attrs["node"] @@ -334,8 +354,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cmds.optionMenuGrp("vrayRenderElementSeparator", v=instance.data.get("aovSeparator", "_")) cmds.setAttr( - "{}.fileNameRenderElementSeparator".format( - node), + "{}.fileNameRenderElementSeparator".format(node), instance.data.get("aovSeparator", "_"), type="string" ) diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py index 3d486cf7a4..55b2ebd6d8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers_arnold_attributes.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api from openpype.pipeline.publish import ( ValidateContentsOrder, diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py b/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py index 86967d7502..d5bf7fd1cf 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_joints_hidden.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py index 70128ac493..03ba381f8d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_out_set_node_ids.py @@ -1,7 +1,7 @@ import maya.cmds as cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py index f075f42ff2..f3ed1a36ef 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_output_ids.py @@ -2,7 +2,6 @@ import pymel.core as pc import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( RepairAction, diff --git a/openpype/hosts/maya/plugins/publish/validate_shader_name.py b/openpype/hosts/maya/plugins/publish/validate_shader_name.py index 522b42fd00..b3e51f011d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shader_name.py +++ b/openpype/hosts/maya/plugins/publish/validate_shader_name.py @@ -2,7 +2,7 @@ import re from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py b/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py index 25bd3442a3..651c6bcec9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_default_names.py @@ -3,7 +3,7 @@ import re from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ( ValidateContentsOrder, diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py index 0980d6b4b6..f58c0aaf81 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api from maya import cmds diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 9e30735d40..7a7e9a0aee 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.hosts.maya.api import lib from openpype.pipeline.publish import ( diff --git a/openpype/hosts/maya/plugins/publish/validate_skinCluster_deformer_set.py b/openpype/hosts/maya/plugins/publish/validate_skinCluster_deformer_set.py index 86ff914cb0..b45d2b120a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skinCluster_deformer_set.py +++ b/openpype/hosts/maya/plugins/publish/validate_skinCluster_deformer_set.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_step_size.py b/openpype/hosts/maya/plugins/publish/validate_step_size.py index 552a936966..294458f63c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_step_size.py +++ b/openpype/hosts/maya/plugins/publish/validate_step_size.py @@ -1,5 +1,5 @@ import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py b/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py index 64faf9ecb6..4615e2ec07 100644 --- a/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py +++ b/openpype/hosts/maya/plugins/publish/validate_transform_naming_suffix.py @@ -3,7 +3,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_transform_zero.py b/openpype/hosts/maya/plugins/publish/validate_transform_zero.py index 9e232f6023..da569195e8 100644 --- a/openpype/hosts/maya/plugins/publish/validate_transform_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_transform_zero.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/plugins/publish/validate_unique_names.py b/openpype/hosts/maya/plugins/publish/validate_unique_names.py similarity index 94% rename from openpype/plugins/publish/validate_unique_names.py rename to openpype/hosts/maya/plugins/publish/validate_unique_names.py index 33a460f7cc..05776ee0f3 100644 --- a/openpype/plugins/publish/validate_unique_names.py +++ b/openpype/hosts/maya/plugins/publish/validate_unique_names.py @@ -1,7 +1,6 @@ from maya import cmds import pyblish.api -import openpype.api import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder @@ -24,7 +23,7 @@ class ValidateUniqueNames(pyblish.api.Validator): """Returns the invalid transforms in the instance. Returns: - list: Non unique name transforms + list: Non-unique name transforms. """ diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py index 1ed3e5531c..4211e76a73 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py @@ -2,8 +2,9 @@ from maya import cmds import pyblish.api -import openpype.api + from openpype.pipeline.publish import ValidateMeshOrder +import openpype.hosts.maya.api.action class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index a4bb54f5af..1425190b82 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -3,7 +3,7 @@ import re import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline import legacy_io from openpype.settings import get_project_settings diff --git a/openpype/hosts/maya/plugins/publish/validate_visible_only.py b/openpype/hosts/maya/plugins/publish/validate_visible_only.py index f326b91796..faf634f258 100644 --- a/openpype/hosts/maya/plugins/publish/validate_visible_only.py +++ b/openpype/hosts/maya/plugins/publish/validate_visible_only.py @@ -1,6 +1,5 @@ import pyblish.api -import openpype.api from openpype.hosts.maya.api.lib import iter_visible_nodes_in_range import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/plugins/publish/validate_vrayproxy_members.py b/openpype/hosts/maya/plugins/publish/validate_vrayproxy_members.py index b94e5cbbed..855a96e6b9 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vrayproxy_members.py +++ b/openpype/hosts/maya/plugins/publish/validate_vrayproxy_members.py @@ -1,5 +1,4 @@ import pyblish.api -import openpype.api from maya import cmds diff --git a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py index 0fe89634f5..ebef44774d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py +++ b/openpype/hosts/maya/plugins/publish/validate_yeti_rig_input_in_instance.py @@ -1,7 +1,7 @@ from maya import cmds import pyblish.api -import openpype.api + import openpype.hosts.maya.api.action from openpype.pipeline.publish import ValidateContentsOrder diff --git a/openpype/hosts/maya/resources/workspace.mel b/openpype/hosts/maya/resources/workspace.mel deleted file mode 100644 index f7213fa4f6..0000000000 --- a/openpype/hosts/maya/resources/workspace.mel +++ /dev/null @@ -1,11 +0,0 @@ -//Maya 2018 Project Definition - -workspace -fr "shaders" "renderData/shaders"; -workspace -fr "alembicCache" "cache/alembic"; -workspace -fr "mayaAscii" ""; -workspace -fr "mayaBinary" ""; -workspace -fr "renderData" "renderData"; -workspace -fr "fileCache" "cache/nCache"; -workspace -fr "scene" ""; -workspace -fr "sourceImages" "sourceimages"; -workspace -fr "images" "renders"; diff --git a/openpype/hosts/maya/startup/userSetup.py b/openpype/hosts/maya/startup/userSetup.py index 10e68c2ddb..40cd51f2d8 100644 --- a/openpype/hosts/maya/startup/userSetup.py +++ b/openpype/hosts/maya/startup/userSetup.py @@ -1,5 +1,5 @@ import os -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.pipeline import install_host from openpype.hosts.maya.api import MayaHost from maya import cmds diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py index 962f31c177..c65058874b 100644 --- a/openpype/hosts/nuke/api/__init__.py +++ b/openpype/hosts/nuke/api/__init__.py @@ -21,6 +21,8 @@ from .pipeline import ( containerise, parse_container, update_container, + + get_workfile_build_placeholder_plugins, ) from .lib import ( maintained_selection, @@ -55,6 +57,8 @@ __all__ = ( "parse_container", "update_container", + "get_workfile_build_placeholder_plugins", + "maintained_selection", "reset_selection", "get_view_process_node", diff --git a/openpype/hosts/nuke/api/gizmo_menu.py b/openpype/hosts/nuke/api/gizmo_menu.py index 0f1a3e03fc..9edfc62e3b 100644 --- a/openpype/hosts/nuke/api/gizmo_menu.py +++ b/openpype/hosts/nuke/api/gizmo_menu.py @@ -2,7 +2,7 @@ import os import re import nuke -from openpype.api import Logger +from openpype.lib import Logger log = Logger.get_logger(__name__) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index e55fdbfcb2..1aea04d889 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -563,7 +563,15 @@ def get_node_path(path, padding=4): def get_nuke_imageio_settings(): - return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] + project_imageio = get_project_settings( + Context.project_name)["nuke"]["imageio"] + + # backward compatibility for project started before 3.10 + # those are still having `__legacy__` knob types + if not project_imageio["enabled"]: + return get_anatomy_settings(Context.project_name)["imageio"]["nuke"] + + return get_project_settings(Context.project_name)["nuke"]["imageio"] def get_created_node_imageio_setting_legacy(nodeclass, creator, subset): diff --git a/openpype/hosts/nuke/api/lib_template_builder.py b/openpype/hosts/nuke/api/lib_template_builder.py deleted file mode 100644 index 61baa23928..0000000000 --- a/openpype/hosts/nuke/api/lib_template_builder.py +++ /dev/null @@ -1,220 +0,0 @@ -from collections import OrderedDict - -import qargparse - -import nuke - -from openpype.tools.utils.widgets import OptionDialog - -from .lib import imprint, get_main_window - - -# To change as enum -build_types = ["context_asset", "linked_asset", "all_assets"] - - -def get_placeholder_attributes(node, enumerate=False): - list_atts = { - "builder_type", - "family", - "representation", - "loader", - "loader_args", - "order", - "asset", - "subset", - "hierarchy", - "siblings", - "last_loaded" - } - attributes = {} - for attr in node.knobs().keys(): - if attr in list_atts: - if enumerate: - try: - attributes[attr] = node.knob(attr).values() - except AttributeError: - attributes[attr] = node.knob(attr).getValue() - else: - attributes[attr] = node.knob(attr).getValue() - - return attributes - - -def delete_placeholder_attributes(node): - """Delete all extra placeholder attributes.""" - - extra_attributes = get_placeholder_attributes(node) - for attribute in extra_attributes.keys(): - try: - node.removeKnob(node.knob(attribute)) - except ValueError: - continue - - -def hide_placeholder_attributes(node): - """Hide all extra placeholder attributes.""" - - extra_attributes = get_placeholder_attributes(node) - for attribute in extra_attributes.keys(): - try: - node.knob(attribute).setVisible(False) - except ValueError: - continue - - -def create_placeholder(): - args = placeholder_window() - if not args: - # operation canceled, no locator created - return - - placeholder = nuke.nodes.NoOp() - placeholder.setName("PLACEHOLDER") - placeholder.knob("tile_color").setValue(4278190335) - - # custom arg parse to force empty data query - # and still imprint them on placeholder - # and getting items when arg is of type Enumerator - options = OrderedDict() - for arg in args: - if not type(arg) == qargparse.Separator: - options[str(arg)] = arg._data.get("items") or arg.read() - imprint(placeholder, options) - imprint(placeholder, {"is_placeholder": True}) - placeholder.knob("is_placeholder").setVisible(False) - - -def update_placeholder(): - placeholder = nuke.selectedNodes() - if not placeholder: - raise ValueError("No node selected") - if len(placeholder) > 1: - raise ValueError("Too many selected nodes") - placeholder = placeholder[0] - - args = placeholder_window(get_placeholder_attributes(placeholder)) - if not args: - return # operation canceled - # delete placeholder attributes - delete_placeholder_attributes(placeholder) - - options = OrderedDict() - for arg in args: - if not type(arg) == qargparse.Separator: - options[str(arg)] = arg._data.get("items") or arg.read() - imprint(placeholder, options) - - -def imprint_enum(placeholder, args): - """ - Imprint method doesn't act properly with enums. - Replacing the functionnality with this for now - """ - - enum_values = { - str(arg): arg.read() - for arg in args - if arg._data.get("items") - } - string_to_value_enum_table = { - build: idx - for idx, build in enumerate(build_types) - } - attrs = {} - for key, value in enum_values.items(): - attrs[key] = string_to_value_enum_table[value] - - -def placeholder_window(options=None): - options = options or dict() - dialog = OptionDialog(parent=get_main_window()) - dialog.setWindowTitle("Create Placeholder") - - args = [ - qargparse.Separator("Main attributes"), - qargparse.Enum( - "builder_type", - label="Asset Builder Type", - default=options.get("builder_type", 0), - items=build_types, - help="""Asset Builder Type -Builder type describe what template loader will look for. - -context_asset : Template loader will look for subsets of -current context asset (Asset bob will find asset) - -linked_asset : Template loader will look for assets linked -to current context asset. -Linked asset are looked in OpenPype database under field "inputLinks" -""" - ), - qargparse.String( - "family", - default=options.get("family", ""), - label="OpenPype Family", - placeholder="ex: image, plate ..."), - qargparse.String( - "representation", - default=options.get("representation", ""), - label="OpenPype Representation", - placeholder="ex: mov, png ..."), - qargparse.String( - "loader", - default=options.get("loader", ""), - label="Loader", - placeholder="ex: LoadClip, LoadImage ...", - help="""Loader - -Defines what openpype loader will be used to load assets. -Useable loader depends on current host's loader list. -Field is case sensitive. -"""), - qargparse.String( - "loader_args", - default=options.get("loader_args", ""), - label="Loader Arguments", - placeholder='ex: {"camera":"persp", "lights":True}', - help="""Loader - -Defines a dictionnary of arguments used to load assets. -Useable arguments depend on current placeholder Loader. -Field should be a valid python dict. Anything else will be ignored. -"""), - qargparse.Integer( - "order", - default=options.get("order", 0), - min=0, - max=999, - label="Order", - placeholder="ex: 0, 100 ... (smallest order loaded first)", - help="""Order - -Order defines asset loading priority (0 to 999) -Priority rule is : "lowest is first to load"."""), - qargparse.Separator( - "Optional attributes "), - qargparse.String( - "asset", - default=options.get("asset", ""), - label="Asset filter", - placeholder="regex filtering by asset name", - help="Filtering assets by matching field regex to asset's name"), - qargparse.String( - "subset", - default=options.get("subset", ""), - label="Subset filter", - placeholder="regex filtering by subset name", - help="Filtering assets by matching field regex to subset's name"), - qargparse.String( - "hierarchy", - default=options.get("hierarchy", ""), - label="Hierarchy filter", - placeholder="regex filtering by asset's hierarchy", - help="Filtering assets by matching field asset's hierarchy") - ] - dialog.create(args) - if not dialog.exec_(): - return None - - return args diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index bac42128cc..7db420f6af 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -7,11 +7,8 @@ import nuke import pyblish.api import openpype -from openpype.api import ( - Logger, - get_current_project_settings -) -from openpype.lib import register_event_callback +from openpype.settings import get_current_project_settings +from openpype.lib import register_event_callback, Logger from openpype.pipeline import ( register_loader_plugin_path, register_creator_plugin_path, @@ -22,10 +19,6 @@ from openpype.pipeline import ( AVALON_CONTAINER_ID, ) from openpype.pipeline.workfile import BuildWorkfile -from openpype.pipeline.workfile.build_template import ( - build_workfile_template, - update_workfile_template -) from openpype.tools.utils import host_tools from .command import viewer_update_and_undo_stop @@ -40,8 +33,12 @@ from .lib import ( set_avalon_knob_data, read_avalon_data, ) -from .lib_template_builder import ( - create_placeholder, update_placeholder +from .workfile_template_builder import ( + NukePlaceholderLoadPlugin, + build_workfile_template, + update_workfile_template, + create_placeholder, + update_placeholder, ) log = Logger.get_logger(__name__) @@ -141,6 +138,12 @@ def _show_workfiles(): host_tools.show_workfiles(parent=None, on_top=False) +def get_workfile_build_placeholder_plugins(): + return [ + NukePlaceholderLoadPlugin + ] + + def _install_menu(): # uninstall original avalon menu main_window = get_main_window() diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 37ce03dc55..91bb90ff99 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -6,7 +6,7 @@ from abc import abstractmethod import nuke -from openpype.api import get_current_project_settings +from openpype.settings import get_current_project_settings from openpype.pipeline import ( LegacyCreator, LoaderPlugin, diff --git a/openpype/hosts/nuke/api/utils.py b/openpype/hosts/nuke/api/utils.py index 5b0c607292..6bcb752dd1 100644 --- a/openpype/hosts/nuke/api/utils.py +++ b/openpype/hosts/nuke/api/utils.py @@ -1,7 +1,7 @@ import os import nuke -from openpype.api import resources +from openpype import resources from .lib import maintained_selection diff --git a/openpype/hosts/nuke/api/template_loader.py b/openpype/hosts/nuke/api/workfile_template_builder.py similarity index 51% rename from openpype/hosts/nuke/api/template_loader.py rename to openpype/hosts/nuke/api/workfile_template_builder.py index 5ff4b8fc41..7a2e442e32 100644 --- a/openpype/hosts/nuke/api/template_loader.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -1,13 +1,16 @@ -import re import collections import nuke -from openpype.client import get_representations -from openpype.pipeline import legacy_io -from openpype.pipeline.workfile.abstract_template_loader import ( - AbstractPlaceholder, - AbstractTemplateLoader, +from openpype.pipeline import registered_host +from openpype.pipeline.workfile.workfile_template_builder import ( + AbstractTemplateBuilder, + PlaceholderPlugin, + LoadPlaceholderItem, + PlaceholderLoadMixin, +) +from openpype.tools.workfile_template_build import ( + WorkfileBuildPlaceholderDialog, ) from .lib import ( @@ -25,19 +28,11 @@ from .lib import ( node_tempfile, ) -from .lib_template_builder import ( - delete_placeholder_attributes, - get_placeholder_attributes, - hide_placeholder_attributes -) - PLACEHOLDER_SET = "PLACEHOLDERS_SET" -class NukeTemplateLoader(AbstractTemplateLoader): - """Concrete implementation of AbstractTemplateLoader for Nuke - - """ +class NukeTemplateBuilder(AbstractTemplateBuilder): + """Concrete implementation of AbstractTemplateBuilder for maya""" def import_template(self, path): """Import template into current scene. @@ -58,224 +53,255 @@ class NukeTemplateLoader(AbstractTemplateLoader): return True - def preload(self, placeholder, loaders_by_name, last_representation): - placeholder.data["nodes_init"] = nuke.allNodes() - placeholder.data["last_repre_id"] = str(last_representation["_id"]) - def populate_template(self, ignored_ids=None): - processed_key = "_node_processed" +class NukePlaceholderPlugin(PlaceholderPlugin): + node_color = 4278190335 - processed_nodes = [] - nodes = self.get_template_nodes() - while nodes: - # Mark nodes as processed so they're not re-executed - # - that can happen if processing of placeholder node fails - for node in nodes: - imprint(node, {processed_key: True}) - processed_nodes.append(node) + def _collect_scene_placeholders(self): + # Cache placeholder data to shared data + placeholder_nodes = self.builder.get_shared_populate_data( + "placeholder_nodes" + ) + if placeholder_nodes is None: + placeholder_nodes = {} + all_groups = collections.deque() + all_groups.append(nuke.thisGroup()) + while all_groups: + group = all_groups.popleft() + for node in group.nodes(): + if isinstance(node, nuke.Group): + all_groups.append(node) - super(NukeTemplateLoader, self).populate_template(ignored_ids) + node_knobs = node.knobs() + if ( + "builder_type" not in node_knobs + or "is_placeholder" not in node_knobs + or not node.knob("is_placeholder").value() + ): + continue - # Recollect nodes to repopulate - nodes = [] - for node in self.get_template_nodes(): - # Skip already processed nodes - if ( - processed_key in node.knobs() - and node.knob(processed_key).value() - ): - continue - nodes.append(node) + if "empty" in node_knobs and node.knob("empty").value(): + continue - for node in processed_nodes: - knob = node.knob(processed_key) + placeholder_nodes[node.fullName()] = node + + self.builder.set_shared_populate_data( + "placeholder_nodes", placeholder_nodes + ) + return placeholder_nodes + + def create_placeholder(self, placeholder_data): + placeholder_data["plugin_identifier"] = self.identifier + + placeholder = nuke.nodes.NoOp() + placeholder.setName("PLACEHOLDER") + placeholder.knob("tile_color").setValue(self.node_color) + + imprint(placeholder, placeholder_data) + imprint(placeholder, {"is_placeholder": True}) + placeholder.knob("is_placeholder").setVisible(False) + + def update_placeholder(self, placeholder_item, placeholder_data): + node = nuke.toNode(placeholder_item.scene_identifier) + imprint(node, placeholder_data) + + def _parse_placeholder_node_data(self, node): + placeholder_data = {} + for key in self.get_placeholder_keys(): + knob = node.knob(key) + value = None if knob is not None: - node.removeKnob(knob) - - @staticmethod - def get_template_nodes(): - placeholders = [] - all_groups = collections.deque() - all_groups.append(nuke.thisGroup()) - while all_groups: - group = all_groups.popleft() - for node in group.nodes(): - if isinstance(node, nuke.Group): - all_groups.append(node) - - node_knobs = node.knobs() - if ( - "builder_type" not in node_knobs - or "is_placeholder" not in node_knobs - or not node.knob("is_placeholder").value() - ): - continue - - if "empty" in node_knobs and node.knob("empty").value(): - continue - - placeholders.append(node) - - return placeholders - - def update_missing_containers(self): - nodes_by_id = collections.defaultdict(list) - - for node in nuke.allNodes(): - node_knobs = node.knobs().keys() - if "repre_id" in node_knobs: - repre_id = node.knob("repre_id").getValue() - nodes_by_id[repre_id].append(node.name()) - - if "empty" in node_knobs: - node.removeKnob(node.knob("empty")) - imprint(node, {"empty": False}) - - for node_names in nodes_by_id.values(): - node = None - for node_name in node_names: - node_by_name = nuke.toNode(node_name) - if "builder_type" in node_by_name.knobs().keys(): - node = node_by_name - break - - if node is None: - continue - - placeholder = nuke.nodes.NoOp() - placeholder.setName("PLACEHOLDER") - placeholder.knob("tile_color").setValue(4278190335) - attributes = get_placeholder_attributes(node, enumerate=True) - imprint(placeholder, attributes) - pos_x = int(node.knob("x").getValue()) - pos_y = int(node.knob("y").getValue()) - placeholder.setXYpos(pos_x, pos_y) - imprint(placeholder, {"nb_children": 1}) - refresh_node(placeholder) - - self.populate_template(self.get_loaded_containers_by_id()) - - def get_loaded_containers_by_id(self): - repre_ids = set() - for node in nuke.allNodes(): - if "repre_id" in node.knobs(): - repre_ids.add(node.knob("repre_id").getValue()) - - # Removes duplicates in the list - return list(repre_ids) - - def delete_placeholder(self, placeholder): - placeholder_node = placeholder.data["node"] - last_loaded = placeholder.data["last_loaded"] - if not placeholder.data["delete"]: - if "empty" in placeholder_node.knobs().keys(): - placeholder_node.removeKnob(placeholder_node.knob("empty")) - imprint(placeholder_node, {"empty": True}) - return - - if not last_loaded: - nuke.delete(placeholder_node) - return - - if "last_loaded" in placeholder_node.knobs().keys(): - for node_name in placeholder_node.knob("last_loaded").values(): - node = nuke.toNode(node_name) - try: - delete_placeholder_attributes(node) - except Exception: - pass - - last_loaded_names = [ - loaded_node.name() - for loaded_node in last_loaded - ] - imprint(placeholder_node, {"last_loaded": last_loaded_names}) - - for node in last_loaded: - refresh_node(node) - refresh_node(placeholder_node) - if "builder_type" not in node.knobs().keys(): - attributes = get_placeholder_attributes(placeholder_node, True) - imprint(node, attributes) - imprint(node, {"is_placeholder": False}) - hide_placeholder_attributes(node) - node.knob("is_placeholder").setVisible(False) - imprint( - node, - { - "x": placeholder_node.xpos(), - "y": placeholder_node.ypos() - } - ) - node.knob("x").setVisible(False) - node.knob("y").setVisible(False) - nuke.delete(placeholder_node) + value = knob.getValue() + placeholder_data[key] = value + return placeholder_data -class NukePlaceholder(AbstractPlaceholder): - """Concrete implementation of AbstractPlaceholder for Nuke""" +class NukePlaceholderLoadPlugin(NukePlaceholderPlugin, PlaceholderLoadMixin): + identifier = "nuke.load" + label = "Nuke load" - optional_keys = {"asset", "subset", "hierarchy"} + def _parse_placeholder_node_data(self, node): + placeholder_data = super( + NukePlaceholderLoadPlugin, self + )._parse_placeholder_node_data(node) - def get_data(self, node): - user_data = dict() node_knobs = node.knobs() - for attr in self.required_keys.union(self.optional_keys): - if attr in node_knobs: - user_data[attr] = node_knobs[attr].getValue() - user_data["node"] = node - nb_children = 0 if "nb_children" in node_knobs: nb_children = int(node_knobs["nb_children"].getValue()) - user_data["nb_children"] = nb_children + placeholder_data["nb_children"] = nb_children siblings = [] if "siblings" in node_knobs: siblings = node_knobs["siblings"].values() - user_data["siblings"] = siblings + placeholder_data["siblings"] = siblings node_full_name = node.fullName() - user_data["group_name"] = node_full_name.rpartition(".")[0] - user_data["last_loaded"] = [] - user_data["delete"] = False - self.data = user_data + placeholder_data["group_name"] = node_full_name.rpartition(".")[0] + placeholder_data["last_loaded"] = [] + placeholder_data["delete"] = False + return placeholder_data - def parent_in_hierarchy(self, containers): - return + def _get_loaded_repre_ids(self): + loaded_representation_ids = self.builder.get_shared_populate_data( + "loaded_representation_ids" + ) + if loaded_representation_ids is None: + loaded_representation_ids = set() + for node in nuke.allNodes(): + if "repre_id" in node.knobs(): + loaded_representation_ids.add( + node.knob("repre_id").getValue() + ) - def create_sib_copies(self): - """ creating copies of the palce_holder siblings (the ones who were - loaded with it) for the new nodes added + self.builder.set_shared_populate_data( + "loaded_representation_ids", loaded_representation_ids + ) + return loaded_representation_ids + + def _before_repre_load(self, placeholder, representation): + placeholder.data["nodes_init"] = nuke.allNodes() + placeholder.data["last_repre_id"] = str(representation["_id"]) + + def collect_placeholders(self): + output = [] + scene_placeholders = self._collect_scene_placeholders() + for node_name, node in scene_placeholders.items(): + plugin_identifier_knob = node.knob("plugin_identifier") + if ( + plugin_identifier_knob is None + or plugin_identifier_knob.getValue() != self.identifier + ): + continue + + placeholder_data = self._parse_placeholder_node_data(node) + # TODO do data validations and maybe updgrades if are invalid + output.append( + LoadPlaceholderItem(node_name, placeholder_data, self) + ) + + return output + + def populate_placeholder(self, placeholder): + self.populate_load_placeholder(placeholder) + + def repopulate_placeholder(self, placeholder): + repre_ids = self._get_loaded_repre_ids() + self.populate_load_placeholder(placeholder, repre_ids) + + def get_placeholder_options(self, options=None): + return self.get_load_plugin_options(options) + + def cleanup_placeholder(self, placeholder, failed): + # deselect all selected nodes + placeholder_node = nuke.toNode(placeholder.scene_identifier) + + # getting the latest nodes added + # TODO get from shared populate data! + nodes_init = placeholder.data["nodes_init"] + nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init)) + self.log.debug("Loaded nodes: {}".format(nodes_loaded)) + if not nodes_loaded: + return + + placeholder.data["delete"] = True + + nodes_loaded = self._move_to_placeholder_group( + placeholder, nodes_loaded + ) + placeholder.data["last_loaded"] = nodes_loaded + refresh_nodes(nodes_loaded) + + # positioning of the loaded nodes + min_x, min_y, _, _ = get_extreme_positions(nodes_loaded) + for node in nodes_loaded: + xpos = (node.xpos() - min_x) + placeholder_node.xpos() + ypos = (node.ypos() - min_y) + placeholder_node.ypos() + node.setXYpos(xpos, ypos) + refresh_nodes(nodes_loaded) + + # fix the problem of z_order for backdrops + self._fix_z_order(placeholder) + self._imprint_siblings(placeholder) + + if placeholder.data["nb_children"] == 0: + # save initial nodes postions and dimensions, update them + # and set inputs and outputs of loaded nodes + + self._imprint_inits() + self._update_nodes(placeholder, nuke.allNodes(), nodes_loaded) + self._set_loaded_connections(placeholder) + + elif placeholder.data["siblings"]: + # create copies of placeholder siblings for the new loaded nodes, + # set their inputs and outpus and update all nodes positions and + # dimensions and siblings names + + siblings = get_nodes_by_names(placeholder.data["siblings"]) + refresh_nodes(siblings) + copies = self._create_sib_copies(placeholder) + new_nodes = list(copies.values()) # copies nodes + self._update_nodes(new_nodes, nodes_loaded) + placeholder_node.removeKnob(placeholder_node.knob("siblings")) + new_nodes_name = get_names_from_nodes(new_nodes) + imprint(placeholder_node, {"siblings": new_nodes_name}) + self._set_copies_connections(placeholder, copies) + + self._update_nodes( + nuke.allNodes(), + new_nodes + nodes_loaded, + 20 + ) + + new_siblings = get_names_from_nodes(new_nodes) + placeholder.data["siblings"] = new_siblings + + else: + # if the placeholder doesn't have siblings, the loaded + # nodes will be placed in a free space + + xpointer, ypointer = find_free_space_to_paste_nodes( + nodes_loaded, direction="bottom", offset=200 + ) + node = nuke.createNode("NoOp") + reset_selection() + nuke.delete(node) + for node in nodes_loaded: + xpos = (node.xpos() - min_x) + xpointer + ypos = (node.ypos() - min_y) + ypointer + node.setXYpos(xpos, ypos) + + placeholder.data["nb_children"] += 1 + reset_selection() + # go back to root group + nuke.root().begin() + + def _move_to_placeholder_group(self, placeholder, nodes_loaded): + """ + opening the placeholder's group and copying loaded nodes in it. Returns : - copies (dict) : with copied nodes names and their copies + nodes_loaded (list): the new list of pasted nodes """ - copies = {} - siblings = get_nodes_by_names(self.data["siblings"]) - for node in siblings: - new_node = duplicate_node(node) + groups_name = placeholder.data["group_name"] + reset_selection() + select_nodes(nodes_loaded) + if groups_name: + with node_tempfile() as filepath: + nuke.nodeCopy(filepath) + for node in nuke.selectedNodes(): + nuke.delete(node) + group = nuke.toNode(groups_name) + group.begin() + nuke.nodePaste(filepath) + nodes_loaded = nuke.selectedNodes() + return nodes_loaded - x_init = int(new_node.knob("x_init").getValue()) - y_init = int(new_node.knob("y_init").getValue()) - new_node.setXYpos(x_init, y_init) - if isinstance(new_node, nuke.BackdropNode): - w_init = new_node.knob("w_init").getValue() - h_init = new_node.knob("h_init").getValue() - new_node.knob("bdwidth").setValue(w_init) - new_node.knob("bdheight").setValue(h_init) - refresh_node(node) - - if "repre_id" in node.knobs().keys(): - node.removeKnob(node.knob("repre_id")) - copies[node.name()] = new_node - return copies - - def fix_z_order(self): + def _fix_z_order(self, placeholder): """Fix the problem of z_order when a backdrop is loaded.""" - nodes_loaded = self.data["last_loaded"] + nodes_loaded = placeholder.data["last_loaded"] loaded_backdrops = [] bd_orders = set() for node in nodes_loaded: @@ -287,7 +313,7 @@ class NukePlaceholder(AbstractPlaceholder): return sib_orders = set() - for node_name in self.data["siblings"]: + for node_name in placeholder.data["siblings"]: node = nuke.toNode(node_name) if isinstance(node, nuke.BackdropNode): sib_orders.add(node.knob("z_order").getValue()) @@ -302,7 +328,56 @@ class NukePlaceholder(AbstractPlaceholder): backdrop_node.knob("z_order").setValue( z_order + max_order - min_order + 1) - def update_nodes(self, nodes, considered_nodes, offset_y=None): + def _imprint_siblings(self, placeholder): + """ + - add siblings names to placeholder attributes (nodes loaded with it) + - add Id to the attributes of all the other nodes + """ + + loaded_nodes = placeholder.data["last_loaded"] + loaded_nodes_set = set(loaded_nodes) + data = {"repre_id": str(placeholder.data["last_repre_id"])} + + for node in loaded_nodes: + node_knobs = node.knobs() + if "builder_type" not in node_knobs: + # save the id of representation for all imported nodes + imprint(node, data) + node.knob("repre_id").setVisible(False) + refresh_node(node) + continue + + if ( + "is_placeholder" not in node_knobs + or ( + "is_placeholder" in node_knobs + and node.knob("is_placeholder").value() + ) + ): + siblings = list(loaded_nodes_set - {node}) + siblings_name = get_names_from_nodes(siblings) + siblings = {"siblings": siblings_name} + imprint(node, siblings) + + def _imprint_inits(self): + """Add initial positions and dimensions to the attributes""" + + for node in nuke.allNodes(): + refresh_node(node) + imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()}) + node.knob("x_init").setVisible(False) + node.knob("y_init").setVisible(False) + width = node.screenWidth() + height = node.screenHeight() + if "bdwidth" in node.knobs(): + imprint(node, {"w_init": width, "h_init": height}) + node.knob("w_init").setVisible(False) + node.knob("h_init").setVisible(False) + refresh_node(node) + + def _update_nodes( + self, placeholder, nodes, considered_nodes, offset_y=None + ): """Adjust backdrop nodes dimensions and positions. Considering some nodes sizes. @@ -314,7 +389,7 @@ class NukePlaceholder(AbstractPlaceholder): offset (int): distance between copies """ - placeholder_node = self.data["node"] + placeholder_node = nuke.toNode(placeholder.scene_identifier) min_x, min_y, max_x, max_y = get_extreme_positions(considered_nodes) @@ -330,7 +405,7 @@ class NukePlaceholder(AbstractPlaceholder): min_x = placeholder_node.xpos() min_y = placeholder_node.ypos() else: - siblings = get_nodes_by_names(self.data["siblings"]) + siblings = get_nodes_by_names(placeholder.data["siblings"]) minX, _, maxX, _ = get_extreme_positions(siblings) diff_y = max_y - min_y + 20 diff_x = abs(max_x - min_x - maxX + minX) @@ -369,59 +444,14 @@ class NukePlaceholder(AbstractPlaceholder): refresh_node(node) - def imprint_inits(self): - """Add initial positions and dimensions to the attributes""" - - for node in nuke.allNodes(): - refresh_node(node) - imprint(node, {"x_init": node.xpos(), "y_init": node.ypos()}) - node.knob("x_init").setVisible(False) - node.knob("y_init").setVisible(False) - width = node.screenWidth() - height = node.screenHeight() - if "bdwidth" in node.knobs(): - imprint(node, {"w_init": width, "h_init": height}) - node.knob("w_init").setVisible(False) - node.knob("h_init").setVisible(False) - refresh_node(node) - - def imprint_siblings(self): - """ - - add siblings names to placeholder attributes (nodes loaded with it) - - add Id to the attributes of all the other nodes - """ - - loaded_nodes = self.data["last_loaded"] - loaded_nodes_set = set(loaded_nodes) - data = {"repre_id": str(self.data["last_repre_id"])} - - for node in loaded_nodes: - node_knobs = node.knobs() - if "builder_type" not in node_knobs: - # save the id of representation for all imported nodes - imprint(node, data) - node.knob("repre_id").setVisible(False) - refresh_node(node) - continue - - if ( - "is_placeholder" not in node_knobs - or ( - "is_placeholder" in node_knobs - and node.knob("is_placeholder").value() - ) - ): - siblings = list(loaded_nodes_set - {node}) - siblings_name = get_names_from_nodes(siblings) - siblings = {"siblings": siblings_name} - imprint(node, siblings) - - def set_loaded_connections(self): + def _set_loaded_connections(self, placeholder): """ set inputs and outputs of loaded nodes""" - placeholder_node = self.data["node"] - input_node, output_node = get_group_io_nodes(self.data["last_loaded"]) + placeholder_node = nuke.toNode(placeholder.scene_identifier) + input_node, output_node = get_group_io_nodes( + placeholder.data["last_loaded"] + ) for node in placeholder_node.dependent(): for idx in range(node.inputs()): if node.input(idx) == placeholder_node: @@ -432,15 +462,45 @@ class NukePlaceholder(AbstractPlaceholder): if placeholder_node.input(idx) == node: input_node.setInput(0, node) - def set_copies_connections(self, copies): + def _create_sib_copies(self, placeholder): + """ creating copies of the palce_holder siblings (the ones who were + loaded with it) for the new nodes added + + Returns : + copies (dict) : with copied nodes names and their copies + """ + + copies = {} + siblings = get_nodes_by_names(placeholder.data["siblings"]) + for node in siblings: + new_node = duplicate_node(node) + + x_init = int(new_node.knob("x_init").getValue()) + y_init = int(new_node.knob("y_init").getValue()) + new_node.setXYpos(x_init, y_init) + if isinstance(new_node, nuke.BackdropNode): + w_init = new_node.knob("w_init").getValue() + h_init = new_node.knob("h_init").getValue() + new_node.knob("bdwidth").setValue(w_init) + new_node.knob("bdheight").setValue(h_init) + refresh_node(node) + + if "repre_id" in node.knobs().keys(): + node.removeKnob(node.knob("repre_id")) + copies[node.name()] = new_node + return copies + + def _set_copies_connections(self, placeholder, copies): """Set inputs and outputs of the copies. Args: copies (dict): Copied nodes by their names. """ - last_input, last_output = get_group_io_nodes(self.data["last_loaded"]) - siblings = get_nodes_by_names(self.data["siblings"]) + last_input, last_output = get_group_io_nodes( + placeholder.data["last_loaded"] + ) + siblings = get_nodes_by_names(placeholder.data["siblings"]) siblings_input, siblings_output = get_group_io_nodes(siblings) copy_input = copies[siblings_input.name()] copy_output = copies[siblings_output.name()] @@ -474,166 +534,45 @@ class NukePlaceholder(AbstractPlaceholder): siblings_input.setInput(0, copy_output) - def move_to_placeholder_group(self, nodes_loaded): - """ - opening the placeholder's group and copying loaded nodes in it. - Returns : - nodes_loaded (list): the new list of pasted nodes - """ +def build_workfile_template(*args): + builder = NukeTemplateBuilder(registered_host()) + builder.build_template() - groups_name = self.data["group_name"] - reset_selection() - select_nodes(nodes_loaded) - if groups_name: - with node_tempfile() as filepath: - nuke.nodeCopy(filepath) - for node in nuke.selectedNodes(): - nuke.delete(node) - group = nuke.toNode(groups_name) - group.begin() - nuke.nodePaste(filepath) - nodes_loaded = nuke.selectedNodes() - return nodes_loaded - def clean(self): - # deselect all selected nodes - placeholder_node = self.data["node"] +def update_workfile_template(*args): + builder = NukeTemplateBuilder(registered_host()) + builder.rebuild_template() - # getting the latest nodes added - nodes_init = self.data["nodes_init"] - nodes_loaded = list(set(nuke.allNodes()) - set(nodes_init)) - self.log.debug("Loaded nodes: {}".format(nodes_loaded)) - if not nodes_loaded: - return - self.data["delete"] = True +def create_placeholder(*args): + host = registered_host() + builder = NukeTemplateBuilder(host) + window = WorkfileBuildPlaceholderDialog(host, builder) + window.exec_() - nodes_loaded = self.move_to_placeholder_group(nodes_loaded) - self.data["last_loaded"] = nodes_loaded - refresh_nodes(nodes_loaded) - # positioning of the loaded nodes - min_x, min_y, _, _ = get_extreme_positions(nodes_loaded) - for node in nodes_loaded: - xpos = (node.xpos() - min_x) + placeholder_node.xpos() - ypos = (node.ypos() - min_y) + placeholder_node.ypos() - node.setXYpos(xpos, ypos) - refresh_nodes(nodes_loaded) +def update_placeholder(*args): + host = registered_host() + builder = NukeTemplateBuilder(host) + placeholder_items_by_id = { + placeholder_item.scene_identifier: placeholder_item + for placeholder_item in builder.get_placeholders() + } + placeholder_items = [] + for node in nuke.selectedNodes(): + node_name = node.fullName() + if node_name in placeholder_items_by_id: + placeholder_items.append(placeholder_items_by_id[node_name]) - self.fix_z_order() # fix the problem of z_order for backdrops - self.imprint_siblings() + # TODO show UI at least + if len(placeholder_items) == 0: + raise ValueError("No node selected") - if self.data["nb_children"] == 0: - # save initial nodes postions and dimensions, update them - # and set inputs and outputs of loaded nodes + if len(placeholder_items) > 1: + raise ValueError("Too many selected nodes") - self.imprint_inits() - self.update_nodes(nuke.allNodes(), nodes_loaded) - self.set_loaded_connections() - - elif self.data["siblings"]: - # create copies of placeholder siblings for the new loaded nodes, - # set their inputs and outpus and update all nodes positions and - # dimensions and siblings names - - siblings = get_nodes_by_names(self.data["siblings"]) - refresh_nodes(siblings) - copies = self.create_sib_copies() - new_nodes = list(copies.values()) # copies nodes - self.update_nodes(new_nodes, nodes_loaded) - placeholder_node.removeKnob(placeholder_node.knob("siblings")) - new_nodes_name = get_names_from_nodes(new_nodes) - imprint(placeholder_node, {"siblings": new_nodes_name}) - self.set_copies_connections(copies) - - self.update_nodes( - nuke.allNodes(), - new_nodes + nodes_loaded, - 20 - ) - - new_siblings = get_names_from_nodes(new_nodes) - self.data["siblings"] = new_siblings - - else: - # if the placeholder doesn't have siblings, the loaded - # nodes will be placed in a free space - - xpointer, ypointer = find_free_space_to_paste_nodes( - nodes_loaded, direction="bottom", offset=200 - ) - node = nuke.createNode("NoOp") - reset_selection() - nuke.delete(node) - for node in nodes_loaded: - xpos = (node.xpos() - min_x) + xpointer - ypos = (node.ypos() - min_y) + ypointer - node.setXYpos(xpos, ypos) - - self.data["nb_children"] += 1 - reset_selection() - # go back to root group - nuke.root().begin() - - def get_representations(self, current_asset_doc, linked_asset_docs): - project_name = legacy_io.active_project() - - builder_type = self.data["builder_type"] - if builder_type == "context_asset": - context_filters = { - "asset": [re.compile(self.data["asset"])], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representations": [self.data["representation"]], - "family": [self.data["family"]] - } - - elif builder_type != "linked_asset": - context_filters = { - "asset": [ - current_asset_doc["name"], - re.compile(self.data["asset"]) - ], - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]] - } - - else: - asset_regex = re.compile(self.data["asset"]) - linked_asset_names = [] - for asset_doc in linked_asset_docs: - asset_name = asset_doc["name"] - if asset_regex.match(asset_name): - linked_asset_names.append(asset_name) - - if not linked_asset_names: - return [] - - context_filters = { - "asset": linked_asset_names, - "subset": [re.compile(self.data["subset"])], - "hierarchy": [re.compile(self.data["hierarchy"])], - "representation": [self.data["representation"]], - "family": [self.data["family"]], - } - - return list(get_representations( - project_name, - context_filters=context_filters - )) - - def err_message(self): - return ( - "Error while trying to load a representation.\n" - "Either the subset wasn't published or the template is malformed." - "\n\n" - "Builder was looking for:\n{attributes}".format( - attributes="\n".join([ - "{}: {}".format(key.title(), value) - for key, value in self.data.items()] - ) - ) - ) + placeholder_item = placeholder_items[0] + window = WorkfileBuildPlaceholderDialog(host, builder) + window.set_update_mode(placeholder_item) + window.exec_() diff --git a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py index c04c939a8d..764499ff0c 100644 --- a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py +++ b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py @@ -1,4 +1,4 @@ -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import InventoryAction from openpype.hosts.nuke.api.lib import set_avalon_knob_data diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 346773b5af..654ea367c8 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -425,7 +425,7 @@ class LoadClip(plugin.NukeLoader): colorspace = repre_data.get("colorspace") colorspace = colorspace or version_data.get("colorspace") - # colorspace from `project_anatomy/imageio/nuke/regexInputs` + # colorspace from `project_settings/nuke/imageio/regexInputs` iio_colorspace = get_imageio_input_colorspace(path) # Set colorspace defined in version data diff --git a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py index 0a2df0898e..d1e5c4cc5a 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_backdrop.py +++ b/openpype/hosts/nuke/plugins/publish/extract_backdrop.py @@ -4,7 +4,7 @@ import nuke import pyblish.api -import openpype +from openpype.pipeline import publish from openpype.hosts.nuke.api.lib import ( maintained_selection, reset_selection, @@ -12,7 +12,7 @@ from openpype.hosts.nuke.api.lib import ( ) -class ExtractBackdropNode(openpype.api.Extractor): +class ExtractBackdropNode(publish.Extractor): """Extracting content of backdrop nodes Will create nuke script only with containing nodes. diff --git a/openpype/hosts/nuke/plugins/publish/extract_camera.py b/openpype/hosts/nuke/plugins/publish/extract_camera.py index 54f65a0be3..b751bfab03 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_camera.py +++ b/openpype/hosts/nuke/plugins/publish/extract_camera.py @@ -5,11 +5,12 @@ from pprint import pformat import nuke import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractCamera(openpype.api.Extractor): +class ExtractCamera(publish.Extractor): """ 3D camera exctractor """ label = 'Exctract Camera' diff --git a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py index 2d5bfdeb5e..3047ad6724 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_gizmo.py +++ b/openpype/hosts/nuke/plugins/publish/extract_gizmo.py @@ -3,7 +3,7 @@ import nuke import pyblish.api -import openpype +from openpype.pipeline import publish from openpype.hosts.nuke.api import utils as pnutils from openpype.hosts.nuke.api.lib import ( maintained_selection, @@ -12,7 +12,7 @@ from openpype.hosts.nuke.api.lib import ( ) -class ExtractGizmo(openpype.api.Extractor): +class ExtractGizmo(publish.Extractor): """Extracting Gizmo (Group) node Will create nuke script only with the Gizmo node. diff --git a/openpype/hosts/nuke/plugins/publish/extract_model.py b/openpype/hosts/nuke/plugins/publish/extract_model.py index 0375263338..d82cb3110b 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_model.py +++ b/openpype/hosts/nuke/plugins/publish/extract_model.py @@ -2,14 +2,15 @@ import os from pprint import pformat import nuke import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.nuke.api.lib import ( maintained_selection, select_nodes ) -class ExtractModel(openpype.api.Extractor): +class ExtractModel(publish.Extractor): """ 3D model exctractor """ label = 'Exctract Model' diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py index 8879f0c999..843d588786 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py +++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py @@ -1,11 +1,13 @@ -import pyblish.api -import nuke import os -import openpype + +import pyblish.api import clique +import nuke + +from openpype.pipeline import publish -class NukeRenderLocal(openpype.api.Extractor): +class NukeRenderLocal(publish.Extractor): # TODO: rewrite docstring to nuke """Render the current Nuke composition locally. diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py index 38a8140cff..3c85b21b08 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -1,10 +1,11 @@ import os -import pyblish.api -import openpype from pprint import pformat +import pyblish.api + +from openpype.pipeline import publish -class ExtractReviewData(openpype.api.Extractor): +class ExtractReviewData(publish.Extractor): """Extracts review tag into available representation """ diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py index 4cf2fd7d9f..67779e9599 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_lut.py @@ -1,11 +1,12 @@ import os import pyblish.api -import openpype + +from openpype.pipeline import publish from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewDataLut(openpype.api.Extractor): +class ExtractReviewDataLut(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index fc16e189fb..3fcfc2a4b5 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -1,13 +1,14 @@ import os -from pprint import pformat import re +from pprint import pformat import pyblish.api -import openpype + +from openpype.pipeline import publish from openpype.hosts.nuke.api import plugin from openpype.hosts.nuke.api.lib import maintained_selection -class ExtractReviewDataMov(openpype.api.Extractor): +class ExtractReviewDataMov(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py diff --git a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py index b5cad143db..e7197b4fa8 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py +++ b/openpype/hosts/nuke/plugins/publish/extract_slate_frame.py @@ -6,7 +6,7 @@ import copy import pyblish.api import six -import openpype +from openpype.pipeline import publish from openpype.hosts.nuke.api import ( maintained_selection, duplicate_node, @@ -14,7 +14,7 @@ from openpype.hosts.nuke.api import ( ) -class ExtractSlateFrame(openpype.api.Extractor): +class ExtractSlateFrame(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 2a919051d2..19eae9638b 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -2,7 +2,8 @@ import sys import os import nuke import pyblish.api -import openpype + +from openpype.pipeline import publish from openpype.hosts.nuke.api import ( maintained_selection, get_view_process_node @@ -13,7 +14,7 @@ if sys.version_info[0] >= 3: unicode = str -class ExtractThumbnail(openpype.api.Extractor): +class ExtractThumbnail(publish.Extractor): """Extracts movie and thumbnail with baked in luts must be run after extract_render_local.py diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py index 26a563b13b..3e2881f298 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_nodes.py @@ -77,11 +77,14 @@ class ValidateNukeWriteNode(pyblish.api.InstancePlugin): # fix type differences if type(node_value) in (int, float): - if isinstance(value, list): - value = color_gui_to_int(value) - else: - value = float(value) - node_value = float(node_value) + try: + if isinstance(value, list): + value = color_gui_to_int(value) + else: + value = float(value) + node_value = float(node_value) + except ValueError: + value = str(value) else: value = str(value) node_value = str(node_value) diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 1461d41385..5e29121e9b 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,7 +1,7 @@ import nuke import os -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import install_host from openpype.hosts.nuke import api from openpype.hosts.nuke.api.lib import ( diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 0bbb19523d..1f0203dca6 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -10,7 +10,7 @@ from wsrpc_aiohttp import ( from Qt import QtCore -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import legacy_io from openpype.tools.utils import host_tools from openpype.tools.adobe_webserver.app import WebServerTool diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index f660096630..9f6fc0983c 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -3,8 +3,7 @@ from Qt import QtWidgets import pyblish.api -from openpype.api import Logger -from openpype.lib import register_event_callback +from openpype.lib import register_event_callback, Logger from openpype.pipeline import ( legacy_io, register_loader_plugin_path, diff --git a/openpype/hosts/photoshop/plugins/publish/collect_published_version.py b/openpype/hosts/photoshop/plugins/publish/collect_published_version.py new file mode 100644 index 0000000000..2502689e4b --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_published_version.py @@ -0,0 +1,55 @@ +"""Collects published version of workfile and increments it. + +For synchronization of published image and workfile version it is required +to store workfile version from workfile file name in context.data["version"]. +In remote publishing this name is unreliable (artist might not follow naming +convention etc.), last published workfile version for particular workfile +subset is used instead. + +This plugin runs only in remote publishing (eg. Webpublisher). + +Requires: + context.data["assetEntity"] + +Provides: + context["version"] - incremented latest published workfile version +""" + +import pyblish.api + +from openpype.client import get_last_version_by_subset_name + + +class CollectPublishedVersion(pyblish.api.ContextPlugin): + """Collects published version of workfile and increments it.""" + + order = pyblish.api.CollectorOrder + 0.190 + label = "Collect published version" + hosts = ["photoshop"] + targets = ["remotepublish"] + + def process(self, context): + workfile_subset_name = None + for instance in context: + if instance.data["family"] == "workfile": + workfile_subset_name = instance.data["subset"] + break + + if not workfile_subset_name: + self.log.warning("No workfile instance found, " + "synchronization of version will not work.") + return + + project_name = context.data["projectName"] + asset_doc = context.data["assetEntity"] + asset_id = asset_doc["_id"] + + version_doc = get_last_version_by_subset_name(project_name, + workfile_subset_name, + asset_id) + version_int = 1 + if version_doc: + version_int += int(version_doc["name"]) + + self.log.debug(f"Setting {version_int} to context.") + context.data["version"] = version_int diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 7f395b46d7..7e598a8250 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -25,6 +25,8 @@ class CollectReview(pyblish.api.ContextPlugin): hosts = ["photoshop"] order = pyblish.api.CollectorOrder + 0.1 + publish = True + def process(self, context): family = "review" subset = get_subset_name( @@ -45,5 +47,6 @@ class CollectReview(pyblish.api.ContextPlugin): "family": family, "families": [], "representations": [], - "asset": os.environ["AVALON_ASSET"] + "asset": os.environ["AVALON_ASSET"], + "publish": self.publish }) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_version.py b/openpype/hosts/photoshop/plugins/publish/collect_version.py new file mode 100644 index 0000000000..cda71d8643 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_version.py @@ -0,0 +1,29 @@ +import pyblish.api + + +class CollectVersion(pyblish.api.InstancePlugin): + """Collect version for publishable instances. + + Used to synchronize version from workfile to all publishable instances: + - image (manually created or color coded) + - review + - workfile + + Dev comment: + Explicit collector created to control this from single place and not from + 3 different. + + Workfile set here explicitly as version might to be forced from latest + 1 + because of Webpublisher. + (This plugin must run after CollectPublishedVersion!) + """ + order = pyblish.api.CollectorOrder + 0.200 + label = 'Collect Version' + + hosts = ["photoshop"] + families = ["image", "review", "workfile"] + + def process(self, instance): + workfile_version = instance.context.data["version"] + self.log.debug(f"Applying version {workfile_version}") + instance.data["version"] = workfile_version diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index e5fee311f8..01022ce0b2 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -49,7 +49,7 @@ class ExtractReview(publish.Extractor): if self.make_image_sequence and len(layers) > 1: self.log.info("Extract layers to image sequence.") - img_list = self._saves_sequences_layers(staging_dir, layers) + img_list = self._save_sequence_images(staging_dir, layers) instance.data["representations"].append({ "name": "jpg", @@ -64,7 +64,7 @@ class ExtractReview(publish.Extractor): processed_img_names = img_list else: self.log.info("Extract layers to flatten image.") - img_list = self._saves_flattened_layers(staging_dir, layers) + img_list = self._save_flatten_image(staging_dir, layers) instance.data["representations"].append({ "name": "jpg", @@ -84,6 +84,67 @@ class ExtractReview(publish.Extractor): source_files_pattern = self._check_and_resize(processed_img_names, source_files_pattern, staging_dir) + self._generate_thumbnail(ffmpeg_path, instance, source_files_pattern, + staging_dir) + + no_of_frames = len(processed_img_names) + if no_of_frames > 1: + self._generate_mov(ffmpeg_path, instance, fps, no_of_frames, + source_files_pattern, staging_dir) + + self.log.info(f"Extracted {instance} to {staging_dir}") + + def _generate_mov(self, ffmpeg_path, instance, fps, no_of_frames, + source_files_pattern, staging_dir): + """Generates .mov to upload to Ftrack. + + Args: + ffmpeg_path (str): path to ffmpeg + instance (Pyblish Instance) + fps (str) + no_of_frames (int): + source_files_pattern (str): name of source file + staging_dir (str): temporary location to store thumbnail + Updates: + instance - adds representation portion + """ + # Generate mov. + mov_path = os.path.join(staging_dir, "review.mov") + self.log.info(f"Generate mov review: {mov_path}") + args = [ + ffmpeg_path, + "-y", + "-i", source_files_pattern, + "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", + "-vframes", str(no_of_frames), + mov_path + ] + self.log.debug("mov args:: {}".format(args)) + _output = run_subprocess(args) + instance.data["representations"].append({ + "name": "mov", + "ext": "mov", + "files": os.path.basename(mov_path), + "stagingDir": staging_dir, + "frameStart": 1, + "frameEnd": no_of_frames, + "fps": fps, + "preview": True, + "tags": self.mov_options['tags'] + }) + + def _generate_thumbnail(self, ffmpeg_path, instance, source_files_pattern, + staging_dir): + """Generates scaled down thumbnail and adds it as representation. + + Args: + ffmpeg_path (str): path to ffmpeg + instance (Pyblish Instance) + source_files_pattern (str): name of source file + staging_dir (str): temporary location to store thumbnail + Updates: + instance - adds representation portion + """ # Generate thumbnail thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") self.log.info(f"Generate thumbnail {thumbnail_path}") @@ -96,50 +157,16 @@ class ExtractReview(publish.Extractor): thumbnail_path ] self.log.debug("thumbnail args:: {}".format(args)) - output = run_subprocess(args) - + _output = run_subprocess(args) instance.data["representations"].append({ "name": "thumbnail", "ext": "jpg", + "outputName": "thumb", "files": os.path.basename(thumbnail_path), "stagingDir": staging_dir, - "tags": ["thumbnail"] + "tags": ["thumbnail", "delete"] }) - # Generate mov. - mov_path = os.path.join(staging_dir, "review.mov") - self.log.info(f"Generate mov review: {mov_path}") - img_number = len(img_list) - args = [ - ffmpeg_path, - "-y", - "-i", source_files_pattern, - "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", - "-vframes", str(img_number), - mov_path - ] - self.log.debug("mov args:: {}".format(args)) - output = run_subprocess(args) - self.log.debug(output) - instance.data["representations"].append({ - "name": "mov", - "ext": "mov", - "files": os.path.basename(mov_path), - "stagingDir": staging_dir, - "frameStart": 1, - "frameEnd": img_number, - "fps": fps, - "preview": True, - "tags": self.mov_options['tags'] - }) - - # Required for extract_review plugin (L222 onwards). - instance.data["frameStart"] = 1 - instance.data["frameEnd"] = img_number - instance.data["fps"] = 25 - - self.log.info(f"Extracted {instance} to {staging_dir}") - def _check_and_resize(self, processed_img_names, source_files_pattern, staging_dir): """Check if saved image could be used in ffmpeg. @@ -168,37 +195,12 @@ class ExtractReview(publish.Extractor): return source_files_pattern - def _get_image_path_from_instances(self, instance): - img_list = [] - - for instance in sorted(instance.context): - if instance.data["family"] != "image": - continue - - for rep in instance.data["representations"]: - img_path = os.path.join( - rep["stagingDir"], - rep["files"] - ) - img_list.append(img_path) - - return img_list - - def _copy_image_to_staging_dir(self, staging_dir, img_list): - copy_files = [] - for i, img_src in enumerate(img_list): - img_filename = self.output_seq_filename % i - img_dst = os.path.join(staging_dir, img_filename) - - self.log.debug( - "Copying file .. {} -> {}".format(img_src, img_dst) - ) - shutil.copy(img_src, img_dst) - copy_files.append(img_filename) - - return copy_files - def _get_layers_from_image_instances(self, instance): + """Collect all layers from 'instance'. + + Returns: + (list) of PSItem + """ layers = [] for image_instance in instance.context: if image_instance.data["family"] != "image": @@ -210,7 +212,12 @@ class ExtractReview(publish.Extractor): return sorted(layers) - def _saves_flattened_layers(self, staging_dir, layers): + def _save_flatten_image(self, staging_dir, layers): + """Creates flat image from 'layers' into 'staging_dir'. + + Returns: + (str): path to new image + """ img_filename = self.output_seq_filename % 0 output_image_path = os.path.join(staging_dir, img_filename) stub = photoshop.stub() @@ -224,7 +231,13 @@ class ExtractReview(publish.Extractor): return img_filename - def _saves_sequences_layers(self, staging_dir, layers): + def _save_sequence_images(self, staging_dir, layers): + """Creates separate flat images from 'layers' into 'staging_dir'. + + Used as source for multi frames .mov to review at once. + Returns: + (list): paths to new images + """ stub = photoshop.stub() list_img_filename = [] diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index b03125d502..3995077d21 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -504,7 +504,7 @@ class Creator(LegacyCreator): def __init__(self, *args, **kwargs): super(Creator, self).__init__(*args, **kwargs) - from openpype.api import get_current_project_settings + from openpype.settings import get_current_project_settings resolve_p_settings = get_current_project_settings().get("resolve") self.presets = {} if resolve_p_settings: diff --git a/openpype/hosts/resolve/api/workio.py b/openpype/hosts/resolve/api/workio.py index 5a742ecf7e..5ce73eea53 100644 --- a/openpype/hosts/resolve/api/workio.py +++ b/openpype/hosts/resolve/api/workio.py @@ -1,7 +1,7 @@ """Host API required Work Files tool""" import os -from openpype.api import Logger +from openpype.lib import Logger from .lib import ( get_project_manager, get_current_project, diff --git a/openpype/hosts/resolve/plugins/publish/extract_workfile.py b/openpype/hosts/resolve/plugins/publish/extract_workfile.py index ea8f19cd8c..535f879b58 100644 --- a/openpype/hosts/resolve/plugins/publish/extract_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/extract_workfile.py @@ -1,10 +1,11 @@ import os import pyblish.api -import openpype.api + +from openpype.pipeline import publish from openpype.hosts.resolve.api.lib import get_project_manager -class ExtractWorkfile(openpype.api.Extractor): +class ExtractWorkfile(publish.Extractor): """ Extractor export DRP workfile file representation """ diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py index 2d9db7801e..0a8ddaa343 100644 --- a/openpype/hosts/traypublisher/api/pipeline.py +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -9,7 +9,7 @@ from openpype.pipeline import ( register_creator_plugin_path, legacy_io, ) -from openpype.host import HostBase, INewPublisher +from openpype.host import HostBase, IPublishHost ROOT_DIR = os.path.dirname(os.path.dirname( @@ -19,7 +19,7 @@ PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish") CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create") -class TrayPublisherHost(HostBase, INewPublisher): +class TrayPublisherHost(HostBase, IPublishHost): name = "traypublisher" def install(self): diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index a3eead51c8..89c25389cb 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -11,27 +11,9 @@ from .pipeline import ( remove_instances, HostContext, ) +from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS + -IMAGE_EXTENSIONS = [ - ".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", ".jpeg", ".2000", ".jpg", ".xr", - ".jpeg", ".xt", ".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" -] -VIDEO_EXTENSIONS = [ - ".3g2", ".3gp", ".amv", ".asf", ".avi", ".drc", ".f4a", ".f4b", - ".f4p", ".f4v", ".flv", ".gif", ".gifv", ".m2v", ".m4p", ".m4v", - ".mkv", ".mng", ".mov", ".mp2", ".mp4", ".mpe", ".mpeg", ".mpg", - ".mpv", ".mxf", ".nsv", ".ogg", ".ogv", ".qt", ".rm", ".rmvb", - ".roq", ".svi", ".vob", ".webm", ".wmv", ".yuv" -] REVIEW_EXTENSIONS = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS @@ -104,6 +86,8 @@ class TrayPublishCreator(Creator): # Host implementation of storing metadata about instance HostContext.add_instance(new_instance.data_to_store()) + new_instance.mark_as_stored() + # Add instance to current context self._add_instance_to_context(new_instance) diff --git a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py index 41c1c29bb0..df6253b0c2 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_from_settings.py +++ b/openpype/hosts/traypublisher/plugins/create/create_from_settings.py @@ -1,5 +1,6 @@ import os -from openpype.api import get_project_settings, Logger +from openpype.lib import Logger +from openpype.settings import get_project_settings log = Logger.get_logger(__name__) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 427c927264..cbaa059809 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -10,7 +10,7 @@ import pyblish.api from openpype.client import get_project, get_asset_by_name from openpype.hosts import tvpaint -from openpype.api import get_current_project_settings +from openpype.settings import get_current_project_settings from openpype.lib import register_event_callback from openpype.pipeline import ( legacy_io, diff --git a/openpype/hosts/tvpaint/lib.py b/openpype/hosts/tvpaint/lib.py index c67ab1e4fb..95653b6ecb 100644 --- a/openpype/hosts/tvpaint/lib.py +++ b/openpype/hosts/tvpaint/lib.py @@ -646,9 +646,6 @@ def rename_filepaths_by_frame_start( filepaths_by_frame, range_start, range_end, new_frame_start ): """Change frames in filenames of finished images to new frame start.""" - # Skip if source first frame is same as destination first frame - if range_start == new_frame_start: - return # Calculate frame end new_frame_end = range_end + (new_frame_start - range_start) @@ -669,14 +666,17 @@ def rename_filepaths_by_frame_start( source_range = range(range_start, range_end + 1) output_range = range(new_frame_start, new_frame_end + 1) + # Skip if source first frame is same as destination first frame new_dst_filepaths = {} for src_frame, dst_frame in zip(source_range, output_range): - src_filepath = filepaths_by_frame[src_frame] - src_dirpath = os.path.dirname(src_filepath) + src_filepath = os.path.normpath(filepaths_by_frame[src_frame]) + dirpath, src_filename = os.path.split(src_filepath) dst_filename = filename_template.format(frame=dst_frame) - dst_filepath = os.path.join(src_dirpath, dst_filename) + dst_filepath = os.path.join(dirpath, dst_filename) - os.rename(src_filepath, dst_filepath) + if src_filename != dst_filename: + os.rename(src_filepath, dst_filepath) new_dst_filepaths[dst_frame] = dst_filepath + return new_dst_filepaths diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 926c932a85..c1d66ddf2a 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -24,7 +24,7 @@ from openpype.pipeline import ( legacy_io, ) from openpype.pipeline.context_tools import get_current_project_asset -from openpype.api import get_current_project_settings +from openpype.settings import get_current_project_settings from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py index ce53824563..4e37cc6a86 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_camera.py +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -6,10 +6,10 @@ import unreal from unreal import EditorAssetLibrary as eal from unreal import EditorLevelLibrary as ell -import openpype.api +from openpype.pipeline import publish -class ExtractCamera(openpype.api.Extractor): +class ExtractCamera(publish.Extractor): """Extract a camera.""" label = "Extract Camera" diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index 8924df36a7..cac7991f00 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -3,18 +3,15 @@ import os import json import math -from bson.objectid import ObjectId - import unreal from unreal import EditorLevelLibrary as ell from unreal import EditorAssetLibrary as eal from openpype.client import get_representation_by_name -import openpype.api -from openpype.pipeline import legacy_io +from openpype.pipeline import legacy_io, publish -class ExtractLayout(openpype.api.Extractor): +class ExtractLayout(publish.Extractor): """Extract a layout.""" label = "Extract Layout" diff --git a/openpype/hosts/unreal/plugins/publish/extract_look.py b/openpype/hosts/unreal/plugins/publish/extract_look.py index ea39949417..f999ad8651 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_look.py +++ b/openpype/hosts/unreal/plugins/publish/extract_look.py @@ -5,10 +5,10 @@ import os import unreal from unreal import MaterialEditingLibrary as mat_lib -import openpype.api +from openpype.pipeline import publish -class ExtractLook(openpype.api.Extractor): +class ExtractLook(publish.Extractor): """Extract look.""" label = "Extract Look" diff --git a/openpype/hosts/unreal/plugins/publish/extract_render.py b/openpype/hosts/unreal/plugins/publish/extract_render.py index 37fe7e916f..8ff38fbee0 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_render.py +++ b/openpype/hosts/unreal/plugins/publish/extract_render.py @@ -2,10 +2,10 @@ from pathlib import Path import unreal -import openpype.api +from openpype.pipeline import publish -class ExtractRender(openpype.api.Extractor): +class ExtractRender(publish.Extractor): """Extract render.""" label = "Extract Render" diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 278a102f9d..dd4646f356 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -37,6 +37,15 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): This is not applicable for 'studio' processing where host application is called to process uploaded workfile and render frames itself. + + For each task configure what properties should resulting instance have + based on uploaded files: + - uploading sequence of 'png' >> create instance of 'render' family, + by adding 'review' to 'Families' and 'Create review' to Tags it will + produce review. + + There might be difference between single(>>image) and sequence(>>render) + uploaded files. """ # must be really early, context values are only in json file order = pyblish.api.CollectorOrder - 0.490 @@ -46,6 +55,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): # from Settings task_type_to_family = [] + sync_next_version = False # find max version to be published, use for all def process(self, context): batch_dir = context.data["batchDir"] @@ -64,6 +74,9 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): task_type = context.data["taskType"] project_name = context.data["project_name"] variant = context.data["variant"] + + next_versions = [] + instances = [] for task_dir in task_subfolders: task_data = parse_json(os.path.join(task_dir, "manifest.json")) @@ -90,11 +103,14 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): version = self._get_next_version( project_name, asset_doc, subset_name ) + next_versions.append(version) instance = context.create_instance(subset_name) instance.data["asset"] = asset_name instance.data["subset"] = subset_name + # set configurable result family instance.data["family"] = family + # set configurable additional families instance.data["families"] = families instance.data["version"] = version instance.data["stagingDir"] = tempfile.mkdtemp() @@ -137,8 +153,18 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): instance.data["handleStart"] = asset_doc["data"]["handleStart"] instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] + instances.append(instance) self.log.info("instance.data:: {}".format(instance.data)) + if not self.sync_next_version: + return + + # overwrite specific version with same version for all + max_next_version = max(next_versions) + for inst in instances: + inst.data["version"] = max_next_version + self.log.debug("overwritten version:: {}".format(max_next_version)) + def _get_subset_name(self, family, subset_template, task_name, variant): fill_pairs = { "variant": variant, @@ -176,7 +202,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): "ext": ext[1:], "files": files, "stagingDir": task_dir, - "tags": tags + "tags": tags # configurable tags from Settings } self.log.info("sequences repre_data.data:: {}".format(repre_data)) return [repre_data] diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index e249ae4f1c..990dc7495a 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1403,6 +1403,7 @@ def get_app_environments_for_context( "env": env }) + data["env"].update(anatomy.root_environments()) prepare_app_environments(data, env_group, modules_manager) prepare_context_environments(data, env_group, modules_manager) diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 17658eef93..37446f01f8 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -9,6 +9,49 @@ import six import clique +def get_attributes_keys(attribute_definitions): + """Collect keys from list of attribute definitions. + + Args: + attribute_definitions (List[AbtractAttrDef]): Objects of attribute + definitions. + + Returns: + Set[str]: Keys that will be created using passed attribute definitions. + """ + + keys = set() + if not attribute_definitions: + return keys + + for attribute_def in attribute_definitions: + if not isinstance(attribute_def, UIDef): + keys.add(attribute_def.key) + return keys + + +def get_default_values(attribute_definitions): + """Receive default values for attribute definitions. + + Args: + attribute_definitions (List[AbtractAttrDef]): Attribute definitions for + which default values should be collected. + + Returns: + Dict[str, Any]: Default values for passet attribute definitions. + """ + + output = {} + if not attribute_definitions: + return output + + for attr_def in attribute_definitions: + # Skip UI definitions + if not isinstance(attr_def, UIDef): + output[attr_def.key] = attr_def.default + return output + + class AbstractAttrDefMeta(ABCMeta): """Meta class to validate existence of 'key' attribute. diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 60d5d3ed4a..e736ba8ef0 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -42,6 +42,28 @@ XML_CHAR_REF_REGEX_HEX = re.compile(r"&#x?[0-9a-fA-F]+;") # Regex to parse array attributes ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") +IMAGE_EXTENSIONS = [ + ".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", ".jpeg", ".2000", ".jpg", ".xr", + ".jpeg", ".xt", ".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" +] + +VIDEO_EXTENSIONS = [ + ".3g2", ".3gp", ".amv", ".asf", ".avi", ".drc", ".f4a", ".f4b", + ".f4p", ".f4v", ".flv", ".gif", ".gifv", ".m2v", ".m4p", ".m4v", + ".mkv", ".mng", ".mov", ".mp2", ".mp4", ".mpe", ".mpeg", ".mpg", + ".mpv", ".mxf", ".nsv", ".ogg", ".ogv", ".qt", ".rm", ".rmvb", + ".roq", ".svi", ".vob", ".webm", ".wmv", ".yuv" +] + def get_transcode_temp_directory(): """Creates temporary folder for transcoding. @@ -139,7 +161,7 @@ def convert_value_by_type_name(value_type, value, logger=None): return float(value) # Vectors will probably have more types - if value_type == "vec2f": + if value_type in ("vec2f", "float2"): return [float(item) for item in value.split(",")] # Matrix should be always have square size of element 3x3, 4x4 @@ -154,7 +176,7 @@ def convert_value_by_type_name(value_type, value, logger=None): elif parts_len == 4: divisor = 2 elif parts_len == 9: - divisor == 3 + divisor = 3 elif parts_len == 16: divisor = 4 else: @@ -204,8 +226,8 @@ def convert_value_by_type_name(value_type, value, logger=None): ) return output - logger.info(( - "MISSING IMPLEMENTATION:" + logger.debug(( + "Dev note (missing implementation):" " Unknown attrib type \"{}\". Value: {}" ).format(value_type, value)) return value @@ -263,8 +285,8 @@ def parse_oiio_xml_output(xml_string, logger=None): # - feel free to add more tags else: value = child.text - logger.info(( - "MISSING IMPLEMENTATION:" + logger.debug(( + "Dev note (missing implementation):" " Unknown tag \"{}\". Value \"{}\"" ).format(tag_name, value)) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 7c486b7c34..ff061c6957 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -36,8 +36,19 @@ from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo +def _validate_deadline_bool_value(instance, attribute, value): + if not isinstance(value, (str, bool)): + raise TypeError( + "Attribute {} must be str or bool.".format(attribute)) + if value not in {"1", "0", True, False}: + raise ValueError( + ("Value of {} must be one of " + "'0', '1', True, False").format(attribute) + ) + + @attr.s -class MayaPluginInfo: +class MayaPluginInfo(object): SceneFile = attr.ib(default=None) # Input OutputFilePath = attr.ib(default=None) # Output directory and filename OutputFilePrefix = attr.ib(default=None) @@ -46,11 +57,13 @@ class MayaPluginInfo: RenderLayer = attr.ib(default=None) # Render only this layer Renderer = attr.ib(default=None) ProjectPath = attr.ib(default=None) # Resolve relative references - RenderSetupIncludeLights = attr.ib(default=None) # Include all lights flag + # Include all lights flag + RenderSetupIncludeLights = attr.ib( + default="1", validator=_validate_deadline_bool_value) @attr.s -class PythonPluginInfo: +class PythonPluginInfo(object): ScriptFile = attr.ib() Version = attr.ib(default="3.6") Arguments = attr.ib(default=None) @@ -58,7 +71,7 @@ class PythonPluginInfo: @attr.s -class VRayPluginInfo: +class VRayPluginInfo(object): InputFilename = attr.ib(default=None) # Input SeparateFilesPerFrame = attr.ib(default=None) VRayEngine = attr.ib(default="V-Ray") @@ -69,7 +82,7 @@ class VRayPluginInfo: @attr.s -class ArnoldPluginInfo: +class ArnoldPluginInfo(object): ArnoldFile = attr.ib(default=None) @@ -185,12 +198,26 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): instance = self._instance context = instance.context + # Set it to default Maya behaviour if it cannot be determined + # from instance (but it should be, by the Collector). + + default_rs_include_lights = ( + instance.context.data['project_settings'] + ['maya'] + ['RenderSettings'] + ['enable_all_lights'] + ) + + rs_include_lights = instance.data.get( + "renderSetupIncludeLights", default_rs_include_lights) + if rs_include_lights not in {"1", "0", True, False}: + rs_include_lights = default_rs_include_lights plugin_info = MayaPluginInfo( SceneFile=self.scene_path, Version=cmds.about(version=True), RenderLayer=instance.data['setMembers'], Renderer=instance.data["renderer"], - RenderSetupIncludeLights=instance.data.get("renderSetupIncludeLights"), # noqa + RenderSetupIncludeLights=rs_include_lights, # noqa ProjectPath=context.data["workspaceDir"], UsingRenderLayers=True, ) @@ -475,6 +502,13 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): layer_metadata = render_products.layer_data layer_prefix = layer_metadata.filePrefix + plugin_info = copy.deepcopy(self.plugin_info) + plugin_info.update({ + # Output directory and filename + "OutputFilePath": data["dirname"].replace("\\", "/"), + "OutputFilePrefix": layer_prefix, + }) + # This hack is here because of how Deadline handles Renderman version. # it considers everything with `renderman` set as version older than # Renderman 22, and so if we are using renderman > 21 we need to set @@ -491,12 +525,11 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): if int(rman_version.split(".")[0]) > 22: renderer = "renderman22" - plugin_info = copy.deepcopy(self.plugin_info) - plugin_info.update({ - # Output directory and filename - "OutputFilePath": data["dirname"].replace("\\", "/"), - "OutputFilePrefix": layer_prefix, - }) + plugin_info["Renderer"] = renderer + + # this is needed because renderman plugin in Deadline + # handles directory and file prefixes separately + plugin_info["OutputFilePath"] = job_info.OutputDirectory[0] return job_info, plugin_info @@ -734,7 +767,7 @@ def _format_tiles( Result for tile 0 for 4x4 will be: `maya///_tile_1x1_4x4__` - Calculating coordinates is tricky as in Job they are defined as top, + Calculating coordinates is tricky as in Job they are defined as top, left, bottom, right with zero being in top-left corner. But Assembler configuration file takes tile coordinates as X, Y, Width and Height and zero is bottom left corner. @@ -743,13 +776,13 @@ def _format_tiles( filename (str): Filename to process as tiles. index (int): Index of that file if it is sequence. tiles_x (int): Number of tiles in X. - tiles_y (int): Number if tikes in Y. + tiles_y (int): Number of tiles in Y. width (int): Width resolution of final image. height (int): Height resolution of final image. prefix (str): Image prefix. Returns: - (dict, dict): Tuple of two dictionaires - first can be used to + (dict, dict): Tuple of two dictionaries - first can be used to extend JobInfo, second has tiles x, y, width and height used for assembler configuration. @@ -776,21 +809,24 @@ def _format_tiles( tiles_x, tiles_y ) - top = height - (tile_y * h_space) - bottom = height - ((tile_y - 1) * h_space) - 1 - left = (tile_x - 1) * w_space - right = (tile_x * w_space) - 1 - # Job Info new_filename = "{}/{}{}".format( os.path.dirname(filename), tile_prefix, os.path.basename(filename) ) - out["JobInfo"]["OutputFilename{}Tile{}".format(index, tile)] = new_filename # noqa + + top = height - (tile_y * h_space) + bottom = height - ((tile_y - 1) * h_space) - 1 + left = (tile_x - 1) * w_space + right = (tile_x * w_space) - 1 + + # Job info + out["JobInfo"]["OutputFilename{}Tile{}".format(index, tile)] = new_filename # noqa: E501 # Plugin Info - out["PluginInfo"]["RegionPrefix{}".format(tile)] = "/{}".format(tile_prefix).join(prefix.rsplit("/", 1)) # noqa: E501 + out["PluginInfo"]["RegionPrefix{}".format(str(tile))] = \ + "/{}".format(tile_prefix).join(prefix.rsplit("/", 1)) out["PluginInfo"]["RegionTop{}".format(tile)] = top out["PluginInfo"]["RegionBottom{}".format(tile)] = bottom out["PluginInfo"]["RegionLeft{}".format(tile)] = left diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index c9d1daffd1..aba505b3c6 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -778,7 +778,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "resolutionHeight": data.get("resolutionHeight", 1080), "multipartExr": data.get("multipartExr", False), "jobBatchName": data.get("jobBatchName", ""), - "useSequenceForReview": data.get("useSequenceForReview", True) + "useSequenceForReview": data.get("useSequenceForReview", True), + # map inputVersions `ObjectId` -> `str` so json supports it + "inputVersions": list(map(str, data.get("inputVersions", []))) } # skip locking version if we are creating v01 diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index 9fca1b5391..625a3f1a28 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -56,7 +56,7 @@ def convert_value_by_type_name(value_type, value): return float(value) # Vectors will probably have more types - if value_type == "vec2f": + if value_type in ("vec2f", "float2"): return [float(item) for item in value.split(",")] # Matrix should be always have square size of element 3x3, 4x4 @@ -71,7 +71,7 @@ def convert_value_by_type_name(value_type, value): elif parts_len == 4: divisor = 2 elif parts_len == 9: - divisor == 3 + divisor = 3 elif parts_len == 16: divisor = 4 else: @@ -127,7 +127,7 @@ def convert_value_by_type_name(value_type, value): return output print(( - "MISSING IMPLEMENTATION:" + "Dev note (missing implementation):" " Unknown attrib type \"{}\". Value: {}" ).format(value_type, value)) return value @@ -183,7 +183,7 @@ def parse_oiio_xml_output(xml_string): else: value = child.text print(( - "MISSING IMPLEMENTATION:" + "Dev note (missing implementation):" " Unknown tag \"{}\". Value \"{}\"" ).format(tag_name, value)) @@ -453,7 +453,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): # Swap to have input as foreground args.append("--swap") # Paste foreground to background - args.append("--paste +{}+{}".format(pos_x, pos_y)) + args.append("--paste {x:+d}{y:+d}".format(x=pos_x, y=pos_y)) args.append("-o") args.append(output_path) diff --git a/openpype/modules/ftrack/__init__.py b/openpype/modules/ftrack/__init__.py index 7261254c6f..e520f08337 100644 --- a/openpype/modules/ftrack/__init__.py +++ b/openpype/modules/ftrack/__init__.py @@ -1,9 +1,13 @@ from .ftrack_module import ( FtrackModule, - FTRACK_MODULE_DIR + FTRACK_MODULE_DIR, + + resolve_ftrack_url, ) __all__ = ( "FtrackModule", - "FTRACK_MODULE_DIR" + "FTRACK_MODULE_DIR", + + "resolve_ftrack_url", ) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index d04440a564..c19cfd1502 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -18,7 +18,7 @@ from openpype_modules.ftrack.lib import ( tool_definitions_from_app_manager ) -from openpype.api import get_system_settings +from openpype.settings import get_system_settings from openpype.lib import ApplicationManager """ diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index cb4f204523..75ffd7f864 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -6,14 +6,16 @@ import platform import click from openpype.modules import OpenPypeModule -from openpype_interfaces import ( +from openpype.modules.interfaces import ( ITrayModule, IPluginPaths, ISettingsChangeListener ) from openpype.settings import SaveWarningExc +from openpype.lib import Logger FTRACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) +_URL_NOT_SET = object() class FtrackModule( @@ -28,17 +30,8 @@ class FtrackModule( ftrack_settings = settings[self.name] self.enabled = ftrack_settings["enabled"] - # Add http schema - ftrack_url = ftrack_settings["ftrack_server"].strip("/ ") - if ftrack_url: - if "http" not in ftrack_url: - ftrack_url = "https://" + ftrack_url - - # Check if "ftrack.app" is part os url - if "ftrackapp.com" not in ftrack_url: - ftrack_url = ftrack_url + ".ftrackapp.com" - - self.ftrack_url = ftrack_url + self._settings_ftrack_url = ftrack_settings["ftrack_server"] + self._ftrack_url = _URL_NOT_SET current_dir = os.path.dirname(os.path.abspath(__file__)) low_platform = platform.system().lower() @@ -70,6 +63,16 @@ class FtrackModule( self.timers_manager_connector = None self._timers_manager_module = None + def get_ftrack_url(self): + if self._ftrack_url is _URL_NOT_SET: + self._ftrack_url = resolve_ftrack_url( + self._settings_ftrack_url, + logger=self.log + ) + return self._ftrack_url + + ftrack_url = property(get_ftrack_url) + def get_global_environments(self): """Ftrack's global environments.""" return { @@ -479,6 +482,51 @@ class FtrackModule( click_group.add_command(cli_main) +def _check_ftrack_url(url): + import requests + + try: + result = requests.get(url, allow_redirects=False) + except requests.exceptions.RequestException: + return False + + if (result.status_code != 200 or "FTRACK_VERSION" not in result.headers): + return False + return True + + +def resolve_ftrack_url(url, logger=None): + """Checks if Ftrack server is responding.""" + + if logger is None: + logger = Logger.get_logger(__name__) + + url = url.strip("/ ") + if not url: + logger.error("Ftrack URL is not set!") + return None + + if not url.startswith("http"): + url = "https://" + url + + ftrack_url = None + if not url.endswith("ftrackapp.com"): + ftrackapp_url = url + ".ftrackapp.com" + if _check_ftrack_url(ftrackapp_url): + ftrack_url = ftrackapp_url + + if not ftrack_url and _check_ftrack_url(url): + ftrack_url = url + + if ftrack_url: + logger.debug("Ftrack server \"{}\" is accessible.".format(ftrack_url)) + + else: + logger.error("Ftrack server \"{}\" is not accessible!".format(url)) + + return ftrack_url + + @click.group(FtrackModule.name, help="Ftrack module related commands.") def cli_main(): pass diff --git a/openpype/modules/ftrack/ftrack_server/__init__.py b/openpype/modules/ftrack/ftrack_server/__init__.py index 9e3920b500..8e5f7c4c51 100644 --- a/openpype/modules/ftrack/ftrack_server/__init__.py +++ b/openpype/modules/ftrack/ftrack_server/__init__.py @@ -1,8 +1,6 @@ from .ftrack_server import FtrackServer -from .lib import check_ftrack_url __all__ = ( "FtrackServer", - "check_ftrack_url" ) diff --git a/openpype/modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/ftrack/ftrack_server/event_server_cli.py index 3ef7c8270a..20c5ab24a8 100644 --- a/openpype/modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/ftrack/ftrack_server/event_server_cli.py @@ -20,9 +20,11 @@ from openpype.lib import ( get_openpype_version, get_build_version, ) -from openpype_modules.ftrack import FTRACK_MODULE_DIR +from openpype_modules.ftrack import ( + FTRACK_MODULE_DIR, + resolve_ftrack_url, +) from openpype_modules.ftrack.lib import credentials -from openpype_modules.ftrack.ftrack_server.lib import check_ftrack_url from openpype_modules.ftrack.ftrack_server import socket_thread @@ -114,7 +116,7 @@ def legacy_server(ftrack_url): while True: if not ftrack_accessible: - ftrack_accessible = check_ftrack_url(ftrack_url) + ftrack_accessible = resolve_ftrack_url(ftrack_url) # Run threads only if Ftrack is accessible if not ftrack_accessible and not printed_ftrack_error: @@ -257,7 +259,7 @@ def main_loop(ftrack_url): while True: # Check if accessible Ftrack and Mongo url if not ftrack_accessible: - ftrack_accessible = check_ftrack_url(ftrack_url) + ftrack_accessible = resolve_ftrack_url(ftrack_url) if not mongo_accessible: mongo_accessible = check_mongo_url(mongo_uri) @@ -441,7 +443,7 @@ def run_event_server( os.environ["CLOCKIFY_API_KEY"] = clockify_api_key # Check url regex and accessibility - ftrack_url = check_ftrack_url(ftrack_url) + ftrack_url = resolve_ftrack_url(ftrack_url) if not ftrack_url: print('Exiting! < Please enter Ftrack server url >') return 1 diff --git a/openpype/modules/ftrack/ftrack_server/lib.py b/openpype/modules/ftrack/ftrack_server/lib.py index 947dacf917..c8143f739c 100644 --- a/openpype/modules/ftrack/ftrack_server/lib.py +++ b/openpype/modules/ftrack/ftrack_server/lib.py @@ -26,45 +26,12 @@ except ImportError: from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info from openpype.client import OpenPypeMongoConnection -from openpype.api import Logger +from openpype.lib import Logger TOPIC_STATUS_SERVER = "openpype.event.server.status" TOPIC_STATUS_SERVER_RESULT = "openpype.event.server.status.result" -def check_ftrack_url(url, log_errors=True, logger=None): - """Checks if Ftrack server is responding""" - if logger is None: - logger = Logger.get_logger(__name__) - - if not url: - logger.error("Ftrack URL is not set!") - return None - - url = url.strip('/ ') - - if 'http' not in url: - if url.endswith('ftrackapp.com'): - url = 'https://' + url - else: - url = 'https://{0}.ftrackapp.com'.format(url) - try: - result = requests.get(url, allow_redirects=False) - except requests.exceptions.RequestException: - if log_errors: - logger.error("Entered Ftrack URL is not accesible!") - return False - - if (result.status_code != 200 or 'FTRACK_VERSION' not in result.headers): - if log_errors: - logger.error("Entered Ftrack URL is not accesible!") - return False - - logger.debug("Ftrack server {} is accessible.".format(url)) - - return url - - class SocketBaseEventHub(ftrack_api.event.hub.EventHub): hearbeat_msg = b"hearbeat" diff --git a/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py b/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py index d5a95fad91..86ecffd5b8 100644 --- a/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py +++ b/openpype/modules/ftrack/launch_hooks/post_ftrack_changes.py @@ -1,7 +1,7 @@ import os import ftrack_api -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.lib import PostLaunchHook diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 72be6a8e9a..935d1e85c9 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -19,11 +19,8 @@ from openpype.client.operations import ( CURRENT_PROJECT_SCHEMA, CURRENT_PROJECT_CONFIG_SCHEMA, ) -from openpype.api import ( - Logger, - get_anatomy_settings -) -from openpype.lib import ApplicationManager +from openpype.settings import get_anatomy_settings +from openpype.lib import ApplicationManager, Logger from openpype.pipeline import AvalonMongoDB, schema from .constants import CUST_ATTR_ID_KEY, FPS_KEYS diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 6024781d87..96f573fe25 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -9,6 +9,7 @@ from openpype.lib.transcoding import ( convert_ffprobe_fps_to_float, ) from openpype.lib.profiles_filtering import filter_profiles +from openpype.lib.transcoding import VIDEO_EXTENSIONS class IntegrateFtrackInstance(pyblish.api.InstancePlugin): @@ -35,7 +36,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): family_mapping = { "camera": "cam", "look": "look", - "mayaascii": "scene", + "mayaAscii": "scene", "model": "geo", "rig": "rig", "setdress": "setdress", @@ -74,11 +75,15 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): version_number = int(instance_version) family = instance.data["family"] - family_low = family.lower() + # Perform case-insensitive family mapping + family_low = family.lower() asset_type = instance.data.get("ftrackFamily") - if not asset_type and family_low in self.family_mapping: - asset_type = self.family_mapping[family_low] + if not asset_type: + for map_family, map_value in self.family_mapping.items(): + if map_family.lower() == family_low: + asset_type = map_value + break if not asset_type: asset_type = "upload" @@ -86,15 +91,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): self.log.debug( "Family: {}\nMapping: {}".format(family_low, self.family_mapping) ) - - # Ignore this instance if neither "ftrackFamily" or a family mapping is - # found. - if not asset_type: - self.log.info(( - "Family \"{}\" does not match any asset type mapping" - ).format(family)) - return - status_name = self._get_asset_version_status_name(instance) # Base of component item data @@ -126,6 +122,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): review_representations = [] thumbnail_representations = [] other_representations = [] + has_movie_review = False for repre in instance_repres: self.log.debug("Representation {}".format(repre)) repre_tags = repre.get("tags") or [] @@ -134,6 +131,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): elif "ftrackreview" in repre_tags: review_representations.append(repre) + if self._is_repre_video(repre): + has_movie_review = True else: other_representations.append(repre) @@ -151,65 +150,53 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # TODO what if there is multiple thumbnails? first_thumbnail_component = None first_thumbnail_component_repre = None - for repre in thumbnail_representations: - repre_path = self._get_repre_path(instance, repre, False) - if not repre_path: - self.log.warning( - "Published path is not set and source was removed." + + if has_movie_review: + for repre in thumbnail_representations: + repre_path = self._get_repre_path(instance, repre, False) + if not repre_path: + self.log.warning( + "Published path is not set and source was removed." + ) + continue + + # Create copy of base comp item and append it + thumbnail_item = copy.deepcopy(base_component_item) + thumbnail_item["component_path"] = repre_path + thumbnail_item["component_data"] = { + "name": "thumbnail" + } + thumbnail_item["thumbnail"] = True + + # Create copy of item before setting location + if "delete" not in repre["tags"]: + src_components_to_add.append(copy.deepcopy(thumbnail_item)) + # Create copy of first thumbnail + if first_thumbnail_component is None: + first_thumbnail_component_repre = repre + first_thumbnail_component = thumbnail_item + # Set location + thumbnail_item["component_location_name"] = ( + ftrack_server_location_name ) - continue - # Create copy of base comp item and append it - thumbnail_item = copy.deepcopy(base_component_item) - thumbnail_item["component_path"] = repre_path - thumbnail_item["component_data"] = { - "name": "thumbnail" - } - thumbnail_item["thumbnail"] = True - - # Create copy of item before setting location - src_components_to_add.append(copy.deepcopy(thumbnail_item)) - # Create copy of first thumbnail - if first_thumbnail_component is None: - first_thumbnail_component_repre = repre - first_thumbnail_component = thumbnail_item - # Set location - thumbnail_item["component_location_name"] = ( - ftrack_server_location_name - ) - - # Add item to component list - component_list.append(thumbnail_item) + # Add item to component list + component_list.append(thumbnail_item) if first_thumbnail_component is not None: - width = first_thumbnail_component_repre.get("width") - height = first_thumbnail_component_repre.get("height") - if not width or not height: - component_path = first_thumbnail_component["component_path"] - streams = [] - try: - streams = get_ffprobe_streams(component_path) - except Exception: - self.log.debug(( - "Failed to retrieve information about intput {}" - ).format(component_path)) + metadata = self._prepare_image_component_metadata( + first_thumbnail_component_repre, + first_thumbnail_component["component_path"] + ) - for stream in streams: - if "width" in stream and "height" in stream: - width = stream["width"] - height = stream["height"] - break - - if width and height: + if metadata: component_data = first_thumbnail_component["component_data"] - component_data["name"] = "ftrackreview-image" - component_data["metadata"] = { - "ftr_meta": json.dumps({ - "width": width, - "height": height, - "format": "image" - }) - } + component_data["metadata"] = metadata + + if review_representations: + component_data["name"] = "thumbnail" + else: + component_data["name"] = "ftrackreview-image" # Create review components # Change asset name of each new component for review @@ -218,6 +205,11 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): extended_asset_name = "" multiple_reviewable = len(review_representations) > 1 for repre in review_representations: + if not self._is_repre_video(repre) and has_movie_review: + self.log.debug("Movie repre has priority " + "from {}".format(repre)) + continue + repre_path = self._get_repre_path(instance, repre, False) if not repre_path: self.log.warning( @@ -266,12 +258,23 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Change location review_item["component_path"] = repre_path # Change component data - review_item["component_data"] = { - # Default component name is "main". - "name": "ftrackreview-mp4", - "metadata": self._prepare_component_metadata( + + if self._is_repre_video(repre): + component_name = "ftrackreview-mp4" + metadata = self._prepare_video_component_metadata( instance, repre, repre_path, True ) + else: + component_name = "ftrackreview-image" + metadata = self._prepare_image_component_metadata( + repre, repre_path + ) + review_item["thumbnail"] = True + + review_item["component_data"] = { + # Default component name is "main". + "name": component_name, + "metadata": metadata } if is_first_review_repre: @@ -281,7 +284,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): not_first_components.append(review_item) # Create copy of item before setting location - src_components_to_add.append(copy.deepcopy(review_item)) + if "delete" not in repre["tags"]: + src_components_to_add.append(copy.deepcopy(review_item)) # Set location review_item["component_location_name"] = ( @@ -427,7 +431,18 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): return matching_profile["status"] or None def _prepare_component_metadata( - self, instance, repre, component_path, is_review + self, instance, repre, component_path, is_review=None + ): + if self._is_repre_video(repre): + return self._prepare_video_component_metadata(instance, repre, + component_path, + is_review) + else: + return self._prepare_image_component_metadata(repre, + component_path) + + def _prepare_video_component_metadata( + self, instance, repre, component_path, is_review=None ): metadata = {} if "openpype_version" in self.additional_metadata_keys: @@ -439,9 +454,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): try: streams = get_ffprobe_streams(component_path) except Exception: - self.log.debug(( - "Failed to retrieve information about intput {}" - ).format(component_path)) + self.log.debug( + "Failed to retrieve information about " + "input {}".format(component_path)) # Find video streams video_streams = [ @@ -485,9 +500,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): input_framerate ) except ValueError: - self.log.warning(( - "Could not convert ffprobe fps to float \"{}\"" - ).format(input_framerate)) + self.log.warning( + "Could not convert ffprobe " + "fps to float \"{}\"".format(input_framerate)) continue stream_width = tmp_width @@ -559,3 +574,37 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "frameRate": float(fps) }) return metadata + + def _prepare_image_component_metadata(self, repre, component_path): + width = repre.get("width") + height = repre.get("height") + if not width or not height: + streams = [] + try: + streams = get_ffprobe_streams(component_path) + except Exception: + self.log.debug( + "Failed to retrieve information " + "about input {}".format(component_path)) + + for stream in streams: + if "width" in stream and "height" in stream: + width = stream["width"] + height = stream["height"] + break + + metadata = {} + if width and height: + metadata = { + "ftr_meta": json.dumps({ + "width": width, + "height": height, + "format": "image" + }) + } + + return metadata + + def _is_repre_video(self, repre): + repre_ext = ".{}".format(repre["ext"]) + return repre_ext in VIDEO_EXTENSIONS diff --git a/openpype/modules/ftrack/scripts/sub_event_status.py b/openpype/modules/ftrack/scripts/sub_event_status.py index 3163642e3f..6c7ecb8351 100644 --- a/openpype/modules/ftrack/scripts/sub_event_status.py +++ b/openpype/modules/ftrack/scripts/sub_event_status.py @@ -15,8 +15,8 @@ from openpype_modules.ftrack.ftrack_server.lib import ( TOPIC_STATUS_SERVER, TOPIC_STATUS_SERVER_RESULT ) -from openpype.api import Logger from openpype.lib import ( + Logger, is_current_version_studio_latest, is_running_from_build, get_expected_version, diff --git a/openpype/modules/ftrack/scripts/sub_event_storer.py b/openpype/modules/ftrack/scripts/sub_event_storer.py index 204cce89e8..a7e77951af 100644 --- a/openpype/modules/ftrack/scripts/sub_event_storer.py +++ b/openpype/modules/ftrack/scripts/sub_event_storer.py @@ -17,10 +17,10 @@ from openpype_modules.ftrack.ftrack_server.lib import ( ) from openpype_modules.ftrack.lib import get_ftrack_event_mongo_info from openpype.lib import ( + Logger, get_openpype_version, get_build_version ) -from openpype.api import Logger log = Logger.get_logger("Event storer") subprocess_started = datetime.datetime.now() diff --git a/openpype/modules/ftrack/tray/ftrack_tray.py b/openpype/modules/ftrack/tray/ftrack_tray.py index bf774c427f..6f061881e8 100644 --- a/openpype/modules/ftrack/tray/ftrack_tray.py +++ b/openpype/modules/ftrack/tray/ftrack_tray.py @@ -6,22 +6,18 @@ import threading from Qt import QtCore, QtWidgets, QtGui import ftrack_api -from ..ftrack_server.lib import check_ftrack_url -from ..ftrack_server import socket_thread -from ..lib import credentials -from ..ftrack_module import FTRACK_MODULE_DIR -from . import login_dialog - from openpype import resources from openpype.lib import Logger - - -log = Logger.get_logger("FtrackModule") +from openpype_modules.ftrack import resolve_ftrack_url, FTRACK_MODULE_DIR +from openpype_modules.ftrack.ftrack_server import socket_thread +from openpype_modules.ftrack.lib import credentials +from . import login_dialog class FtrackTrayWrapper: def __init__(self, module): self.module = module + self.log = Logger.get_logger(self.__class__.__name__) self.thread_action_server = None self.thread_socket_server = None @@ -62,19 +58,19 @@ class FtrackTrayWrapper: if validation: self.widget_login.set_credentials(ft_user, ft_api_key) self.module.set_credentials_to_env(ft_user, ft_api_key) - log.info("Connected to Ftrack successfully") + self.log.info("Connected to Ftrack successfully") self.on_login_change() return validation if not validation and ft_user and ft_api_key: - log.warning( + self.log.warning( "Current Ftrack credentials are not valid. {}: {} - {}".format( str(os.environ.get("FTRACK_SERVER")), ft_user, ft_api_key ) ) - log.info("Please sign in to Ftrack") + self.log.info("Please sign in to Ftrack") self.bool_logged = False self.show_login_widget() self.set_menu_visibility() @@ -104,7 +100,7 @@ class FtrackTrayWrapper: self.action_credentials.setIcon(self.icon_not_logged) self.action_credentials.setToolTip("Logged out") - log.info("Logged out of Ftrack") + self.log.info("Logged out of Ftrack") self.bool_logged = False self.set_menu_visibility() @@ -126,10 +122,6 @@ class FtrackTrayWrapper: ftrack_url = self.module.ftrack_url os.environ["FTRACK_SERVER"] = ftrack_url - parent_file_path = os.path.dirname( - os.path.dirname(os.path.realpath(__file__)) - ) - min_fail_seconds = 5 max_fail_count = 3 wait_time_after_max_fail = 10 @@ -154,17 +146,19 @@ class FtrackTrayWrapper: # Main loop while True: if not self.bool_action_server_running: - log.debug("Action server was pushed to stop.") + self.log.debug("Action server was pushed to stop.") break # Check if accessible Ftrack and Mongo url if not ftrack_accessible: - ftrack_accessible = check_ftrack_url(ftrack_url) + ftrack_accessible = resolve_ftrack_url(ftrack_url) # Run threads only if Ftrack is accessible if not ftrack_accessible: if not printed_ftrack_error: - log.warning("Can't access Ftrack {}".format(ftrack_url)) + self.log.warning( + "Can't access Ftrack {}".format(ftrack_url) + ) if self.thread_socket_server is not None: self.thread_socket_server.stop() @@ -191,7 +185,7 @@ class FtrackTrayWrapper: self.set_menu_visibility() elif failed_count == max_fail_count: - log.warning(( + self.log.warning(( "Action server failed {} times." " I'll try to run again {}s later" ).format( @@ -243,10 +237,10 @@ class FtrackTrayWrapper: self.thread_action_server.join() self.thread_action_server = None - log.info("Ftrack action server was forced to stop") + self.log.info("Ftrack action server was forced to stop") except Exception: - log.warning( + self.log.warning( "Error has happened during Killing action server", exc_info=True ) @@ -343,7 +337,7 @@ class FtrackTrayWrapper: self.thread_timer = None except Exception as e: - log.error("During Killing Timer event server: {0}".format(e)) + self.log.error("During Killing Timer event server: {0}".format(e)) def changed_user(self): self.stop_action_server() diff --git a/openpype/modules/job_queue/module.py b/openpype/modules/job_queue/module.py index f1d7251e85..7075fcea14 100644 --- a/openpype/modules/job_queue/module.py +++ b/openpype/modules/job_queue/module.py @@ -43,7 +43,7 @@ import platform import click from openpype.modules import OpenPypeModule -from openpype.api import get_system_settings +from openpype.settings import get_system_settings class JobQueueModule(OpenPypeModule): diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 4a064f6a16..10e80b3c89 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -21,6 +21,9 @@ from openpype.pipeline import AvalonMongoDB from openpype.settings import get_project_settings from openpype.modules.kitsu.utils.credentials import validate_credentials +from openpype.lib import Logger + +log = Logger.get_logger(__name__) # Accepted namin pattern for OP naming_pattern = re.compile("^[a-zA-Z0-9_.]*$") @@ -230,7 +233,6 @@ def update_op_assets( }, ) ) - return assets_with_update @@ -248,7 +250,7 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: project_name = project["name"] project_doc = get_project(project_name) if not project_doc: - print(f"Creating project '{project_name}'") + log.info(f"Creating project '{project_name}'") project_doc = create_project(project_name, project_name) # Project data and tasks @@ -268,12 +270,18 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: { "code": project_code, "fps": float(project["fps"]), - "resolutionWidth": int(project["resolution"].split("x")[0]), - "resolutionHeight": int(project["resolution"].split("x")[1]), "zou_id": project["id"], } ) + match_res = re.match(r"(\d+)x(\d+)", project["resolution"]) + if match_res: + project_data['resolutionWidth'] = int(match_res.group(1)) + project_data['resolutionHeight'] = int(match_res.group(2)) + else: + log.warning(f"\'{project['resolution']}\' does not match the expected" + " format for the resolution, for example: 1920x1080") + return UpdateOne( {"_id": project_doc["_id"]}, { @@ -334,7 +342,7 @@ def sync_project_from_kitsu(dbcon: AvalonMongoDB, project: dict): if not project: project = gazu.project.get_project_by_name(project["name"]) - print(f"Synchronizing {project['name']}...") + log.info(f"Synchronizing {project['name']}...") # Get all assets from zou all_assets = gazu.asset.all_assets_for_project(project) diff --git a/openpype/modules/kitsu/utils/update_zou_with_op.py b/openpype/modules/kitsu/utils/update_zou_with_op.py index da924aa5ee..39baf31b93 100644 --- a/openpype/modules/kitsu/utils/update_zou_with_op.py +++ b/openpype/modules/kitsu/utils/update_zou_with_op.py @@ -12,7 +12,7 @@ from openpype.client import ( get_assets, ) from openpype.pipeline import AvalonMongoDB -from openpype.api import get_project_settings +from openpype.settings import get_project_settings from openpype.modules.kitsu.utils.credentials import validate_credentials diff --git a/openpype/modules/log_viewer/log_view_module.py b/openpype/modules/log_viewer/log_view_module.py index 14be6b392e..da1628b71f 100644 --- a/openpype/modules/log_viewer/log_view_module.py +++ b/openpype/modules/log_viewer/log_view_module.py @@ -1,4 +1,3 @@ -from openpype.api import Logger from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayModule diff --git a/openpype/modules/shotgrid/lib/settings.py b/openpype/modules/shotgrid/lib/settings.py index 924099f04b..5b0b728f55 100644 --- a/openpype/modules/shotgrid/lib/settings.py +++ b/openpype/modules/shotgrid/lib/settings.py @@ -1,4 +1,4 @@ -from openpype.api import get_system_settings, get_project_settings +from openpype.settings import get_system_settings, get_project_settings from openpype.modules.shotgrid.lib.const import MODULE_NAME diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 01bc891d08..8f55dc529b 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -4,7 +4,7 @@ import shutil import threading import time -from openpype.api import Logger +from openpype.lib import Logger from openpype.pipeline import Anatomy from .abstract_provider import AbstractProvider diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 00fe353208..af0ee79f47 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -30,7 +30,7 @@ from .workfile import ( from . import ( legacy_io, register_loader_plugin_path, - register_inventory_action, + register_inventory_action_path, register_creator_plugin_path, deregister_loader_plugin_path, ) @@ -197,7 +197,7 @@ def install_openpype_plugins(project_name=None, host_name=None): pyblish.api.register_plugin_path(path) register_loader_plugin_path(path) register_creator_plugin_path(path) - register_inventory_action(path) + register_inventory_action_path(path) def uninstall_host(): diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index eaaed39357..c1cf4dab44 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -7,7 +7,11 @@ from uuid import uuid4 from contextlib import contextmanager from openpype.client import get_assets -from openpype.host import INewPublisher +from openpype.settings import ( + get_system_settings, + get_project_settings +) +from openpype.host import IPublishHost from openpype.pipeline import legacy_io from openpype.pipeline.mongodb import ( AvalonMongoDB, @@ -20,11 +24,6 @@ from .creator_plugins import ( discover_creator_plugins, ) -from openpype.api import ( - get_system_settings, - get_project_settings -) - UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) @@ -167,7 +166,10 @@ class AttributeValues: return self._data.pop(key, default) def reset_values(self): - self._data = [] + self._data = {} + + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self._data) @property def attr_defs(self): @@ -304,6 +306,9 @@ class PublishAttributes: for name in self._plugin_names_order: yield name + def mark_as_stored(self): + self._origin_data = copy.deepcopy(self._data) + def data_to_store(self): """Convert attribute values to "data to store".""" @@ -402,8 +407,12 @@ class CreatedInstance: self.creator = creator # Instance members may have actions on them + # TODO implement members logic self._members = [] + # Data that can be used for lifetime of object + self._transient_data = {} + # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) # Store original value of passed data @@ -596,6 +605,26 @@ class CreatedInstance: return self + @property + def transient_data(self): + """Data stored for lifetime of instance object. + + These data are not stored to scene and will be lost on object + deletion. + + Can be used to store objects. In some host implementations is not + possible to reference to object in scene with some unique identifier + (e.g. node in Fusion.). In that case it is handy to store the object + here. Should be used that way only if instance data are stored on the + node itself. + + Returns: + Dict[str, Any]: Dictionary object where you can store data related + to instance for lifetime of instance object. + """ + + return self._transient_data + def changes(self): """Calculate and return changes.""" @@ -623,6 +652,25 @@ class CreatedInstance: changes[key] = (old_value, None) return changes + def mark_as_stored(self): + """Should be called when instance data are stored. + + Origin data are replaced by current data so changes are cleared. + """ + + orig_keys = set(self._orig_data.keys()) + for key, value in self._data.items(): + orig_keys.discard(key) + if key in ("creator_attributes", "publish_attributes"): + continue + self._orig_data[key] = copy.deepcopy(value) + + for key in orig_keys: + self._orig_data.pop(key) + + self.creator_attributes.mark_as_stored() + self.publish_attributes.mark_as_stored() + @property def creator_attributes(self): return self._data["creator_attributes"] @@ -636,6 +684,18 @@ class CreatedInstance: return self._data["publish_attributes"] def data_to_store(self): + """Collect data that contain json parsable types. + + It is possible to recreate the instance using these data. + + Todo: + We probably don't need OrderedDict. When data are loaded they + are not ordered anymore. + + Returns: + OrderedDict: Ordered dictionary with instance data. + """ + output = collections.OrderedDict() for key, value in self._data.items(): if key in ("creator_attributes", "publish_attributes"): @@ -771,7 +831,7 @@ class CreateContext: """ missing = set( - INewPublisher.get_missing_publish_methods(host) + IPublishHost.get_missing_publish_methods(host) ) return missing diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index bf2fdd2c5f..05ba8902aa 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -9,7 +9,7 @@ from abc import ( import six from openpype.settings import get_system_settings, get_project_settings -from .subset_name import get_subset_name +from openpype.lib import Logger from openpype.pipeline.plugin_discover import ( discover, register_plugin, @@ -18,6 +18,7 @@ from openpype.pipeline.plugin_discover import ( deregister_plugin_path ) +from .subset_name import get_subset_name from .legacy_create import LegacyCreator @@ -81,6 +82,13 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless + self.apply_settings(project_settings, system_settings) + + def apply_settings(self, project_settings, system_settings): + """Method called on initialization of plugin to apply settings.""" + + pass + @property def identifier(self): """Identifier of creator (must be unique). @@ -136,8 +144,6 @@ class BaseCreator: """ if self._log is None: - from openpype.api import Logger - self._log = Logger.get_logger(self.__class__.__name__) return self._log @@ -240,7 +246,7 @@ class BaseCreator: return self.icon def get_dynamic_data( - self, variant, task_name, asset_doc, project_name, host_name + self, variant, task_name, asset_doc, project_name, host_name, instance ): """Dynamic data for subset name filling. @@ -251,7 +257,13 @@ class BaseCreator: return {} def get_subset_name( - self, variant, task_name, asset_doc, project_name, host_name=None + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None ): """Return subset name for passed context. @@ -265,16 +277,21 @@ class BaseCreator: Asset document is not used yet but is required if would like to use task type in subset templates. + Method is also called on subset name update. In that case origin + instance is passed in. + Args: variant(str): Subset name variant. In most of cases user input. task_name(str): For which task subset is created. asset_doc(dict): Asset document for which subset is created. project_name(str): Project name. host_name(str): Which host creates subset. + instance(str|None): Object of 'CreatedInstance' for which is + subset name updated. Passed only on subset name update. """ dynamic_data = self.get_dynamic_data( - variant, task_name, asset_doc, project_name, host_name + variant, task_name, asset_doc, project_name, host_name, instance ) return get_subset_name( diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index bf38a0b3c8..e96f64f2a4 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -5,6 +5,7 @@ from .utils import ( InvalidRepresentationContext, get_repres_contexts, + get_contexts_for_repre_docs, get_subset_contexts, get_representation_context, @@ -54,6 +55,7 @@ __all__ = ( "InvalidRepresentationContext", "get_repres_contexts", + "get_contexts_for_repre_docs", "get_subset_contexts", "get_representation_context", diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 83b904e4a7..784d4628f3 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -37,7 +37,7 @@ log = logging.getLogger(__name__) ContainersFilterResult = collections.namedtuple( "ContainersFilterResult", - ["latest", "outdated", "not_foud", "invalid"] + ["latest", "outdated", "not_found", "invalid"] ) @@ -87,13 +87,20 @@ def get_repres_contexts(representation_ids, dbcon=None): if not dbcon: dbcon = legacy_io - contexts = {} if not representation_ids: - return contexts + return {} project_name = dbcon.active_project() repre_docs = get_representations(project_name, representation_ids) + return get_contexts_for_repre_docs(project_name, repre_docs) + + +def get_contexts_for_repre_docs(project_name, repre_docs): + contexts = {} + if not repre_docs: + return contexts + repre_docs_by_id = {} version_ids = set() for repre_doc in repre_docs: @@ -808,7 +815,7 @@ def filter_containers(containers, project_name): Categories are 'latest', 'outdated', 'invalid' and 'not_found'. The 'lastest' containers are from last version, 'outdated' are not, - 'invalid' are invalid containers (invalid content) and 'not_foud' has + 'invalid' are invalid containers (invalid content) and 'not_found' has some missing entity in database. Args: diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index 004e530b1c..7edd9ac290 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -2,7 +2,7 @@ import os import inspect import traceback -from openpype.api import Logger +from openpype.lib import Logger from openpype.lib.python_module_tools import ( modules_from_path, classes_from_module, diff --git a/openpype/pipeline/workfile/abstract_template_loader.py b/openpype/pipeline/workfile/abstract_template_loader.py deleted file mode 100644 index e2fbea98ca..0000000000 --- a/openpype/pipeline/workfile/abstract_template_loader.py +++ /dev/null @@ -1,528 +0,0 @@ -import os -from abc import ABCMeta, abstractmethod - -import six -import logging -from functools import reduce - -from openpype.client import ( - get_asset_by_name, - get_linked_assets, -) -from openpype.settings import get_project_settings -from openpype.lib import ( - StringTemplate, - Logger, - filter_profiles, -) -from openpype.pipeline import legacy_io, Anatomy -from openpype.pipeline.load import ( - get_loaders_by_name, - get_representation_context, - load_with_repre_context, -) - -from .build_template_exceptions import ( - TemplateAlreadyImported, - TemplateLoadingFailed, - TemplateProfileNotFound, - TemplateNotFound -) - -log = logging.getLogger(__name__) - - -def update_representations(entities, entity): - if entity['context']['subset'] not in entities: - entities[entity['context']['subset']] = entity - else: - current = entities[entity['context']['subset']] - incomming = entity - entities[entity['context']['subset']] = max( - current, incomming, - key=lambda entity: entity["context"].get("version", -1)) - - return entities - - -def parse_loader_args(loader_args): - if not loader_args: - return dict() - try: - parsed_args = eval(loader_args) - if not isinstance(parsed_args, dict): - return dict() - else: - return parsed_args - except Exception as err: - print( - "Error while parsing loader arguments '{}'.\n{}: {}\n\n" - "Continuing with default arguments. . .".format( - loader_args, - err.__class__.__name__, - err)) - return dict() - - -@six.add_metaclass(ABCMeta) -class AbstractTemplateLoader: - """ - Abstraction of Template Loader. - Properties: - template_path : property to get current template path - Methods: - import_template : Abstract Method. Used to load template, - depending on current host - get_template_nodes : Abstract Method. Used to query nodes acting - as placeholders. Depending on current host - """ - - _log = None - - def __init__(self, placeholder_class): - # TODO template loader should expect host as and argument - # - host have all responsibility for most of code (also provide - # placeholder class) - # - also have responsibility for current context - # - this won't work in DCCs where multiple workfiles with - # different contexts can be opened at single time - # - template loader should have ability to change context - project_name = legacy_io.active_project() - asset_name = legacy_io.Session["AVALON_ASSET"] - - self.loaders_by_name = get_loaders_by_name() - self.current_asset = asset_name - self.project_name = project_name - self.host_name = legacy_io.Session["AVALON_APP"] - self.task_name = legacy_io.Session["AVALON_TASK"] - self.placeholder_class = placeholder_class - self.current_asset_doc = get_asset_by_name(project_name, asset_name) - self.task_type = ( - self.current_asset_doc - .get("data", {}) - .get("tasks", {}) - .get(self.task_name, {}) - .get("type") - ) - - self.log.info( - "BUILDING ASSET FROM TEMPLATE :\n" - "Starting templated build for {asset} in {project}\n\n" - "Asset : {asset}\n" - "Task : {task_name} ({task_type})\n" - "Host : {host}\n" - "Project : {project}\n".format( - asset=self.current_asset, - host=self.host_name, - project=self.project_name, - task_name=self.task_name, - task_type=self.task_type - )) - # Skip if there is no loader - if not self.loaders_by_name: - self.log.warning( - "There is no registered loaders. No assets will be loaded") - return - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(self.__class__.__name__) - return self._log - - def template_already_imported(self, err_msg): - """In case template was already loaded. - Raise the error as a default action. - Override this method in your template loader implementation - to manage this case.""" - self.log.error("{}: {}".format( - err_msg.__class__.__name__, - err_msg)) - raise TemplateAlreadyImported(err_msg) - - def template_loading_failed(self, err_msg): - """In case template loading failed - Raise the error as a default action. - Override this method in your template loader implementation - to manage this case. - """ - self.log.error("{}: {}".format( - err_msg.__class__.__name__, - err_msg)) - raise TemplateLoadingFailed(err_msg) - - @property - def template_path(self): - """ - Property returning template path. Avoiding setter. - Getting template path from open pype settings based on current avalon - session and solving the path variables if needed. - Returns: - str: Solved template path - Raises: - TemplateProfileNotFound: No profile found from settings for - current avalon session - KeyError: Could not solve path because a key does not exists - in avalon context - TemplateNotFound: Solved path does not exists on current filesystem - """ - project_name = self.project_name - host_name = self.host_name - task_name = self.task_name - task_type = self.task_type - - anatomy = Anatomy(project_name) - project_settings = get_project_settings(project_name) - - build_info = project_settings[host_name]["templated_workfile_build"] - profile = filter_profiles( - build_info["profiles"], - { - "task_types": task_type, - "task_names": task_name - } - ) - - if not profile: - raise TemplateProfileNotFound( - "No matching profile found for task '{}' of type '{}' " - "with host '{}'".format(task_name, task_type, host_name) - ) - - path = profile["path"] - if not path: - raise TemplateLoadingFailed( - "Template path is not set.\n" - "Path need to be set in {}\\Template Workfile Build " - "Settings\\Profiles".format(host_name.title())) - - # Try fill path with environments and anatomy roots - fill_data = { - key: value - for key, value in os.environ.items() - } - fill_data["root"] = anatomy.roots - result = StringTemplate.format_template(path, fill_data) - if result.solved: - path = result.normalized() - - if path and os.path.exists(path): - self.log.info("Found template at: '{}'".format(path)) - return path - - solved_path = None - while True: - try: - solved_path = anatomy.path_remapper(path) - except KeyError as missing_key: - raise KeyError( - "Could not solve key '{}' in template path '{}'".format( - missing_key, path)) - - if solved_path is None: - solved_path = path - if solved_path == path: - break - path = solved_path - - solved_path = os.path.normpath(solved_path) - if not os.path.exists(solved_path): - raise TemplateNotFound( - "Template found in openPype settings for task '{}' with host " - "'{}' does not exists. (Not found : {})".format( - task_name, host_name, solved_path)) - - self.log.info("Found template at: '{}'".format(solved_path)) - - return solved_path - - def populate_template(self, ignored_ids=None): - """ - Use template placeholders to load assets and parent them in hierarchy - Arguments : - ignored_ids : - Returns: - None - """ - - loaders_by_name = self.loaders_by_name - current_asset_doc = self.current_asset_doc - linked_assets = get_linked_assets(current_asset_doc) - - ignored_ids = ignored_ids or [] - placeholders = self.get_placeholders() - self.log.debug("Placeholders found in template: {}".format( - [placeholder.name for placeholder in placeholders] - )) - for placeholder in placeholders: - self.log.debug("Start to processing placeholder {}".format( - placeholder.name - )) - placeholder_representations = self.get_placeholder_representations( - placeholder, - current_asset_doc, - linked_assets - ) - - if not placeholder_representations: - self.log.info( - "There's no representation for this placeholder: " - "{}".format(placeholder.name) - ) - continue - - for representation in placeholder_representations: - self.preload(placeholder, loaders_by_name, representation) - - if self.load_data_is_incorrect( - placeholder, - representation, - ignored_ids): - continue - - self.log.info( - "Loading {}_{} with loader {}\n" - "Loader arguments used : {}".format( - representation['context']['asset'], - representation['context']['subset'], - placeholder.loader_name, - placeholder.loader_args)) - - try: - container = self.load( - placeholder, loaders_by_name, representation) - except Exception: - self.load_failed(placeholder, representation) - else: - self.load_succeed(placeholder, container) - finally: - self.postload(placeholder) - - def get_placeholder_representations( - self, placeholder, current_asset_doc, linked_asset_docs - ): - placeholder_representations = placeholder.get_representations( - current_asset_doc, - linked_asset_docs - ) - for repre_doc in reduce( - update_representations, - placeholder_representations, - dict() - ).values(): - yield repre_doc - - def load_data_is_incorrect( - self, placeholder, last_representation, ignored_ids): - if not last_representation: - self.log.warning(placeholder.err_message()) - return True - if (str(last_representation['_id']) in ignored_ids): - print("Ignoring : ", last_representation['_id']) - return True - return False - - def preload(self, placeholder, loaders_by_name, last_representation): - pass - - def load(self, placeholder, loaders_by_name, last_representation): - repre = get_representation_context(last_representation) - return load_with_repre_context( - loaders_by_name[placeholder.loader_name], - repre, - options=parse_loader_args(placeholder.loader_args)) - - def load_succeed(self, placeholder, container): - placeholder.parent_in_hierarchy(container) - - def load_failed(self, placeholder, last_representation): - self.log.warning( - "Got error trying to load {}:{} with {}".format( - last_representation['context']['asset'], - last_representation['context']['subset'], - placeholder.loader_name - ), - exc_info=True - ) - - def postload(self, placeholder): - placeholder.clean() - - def update_missing_containers(self): - loaded_containers_ids = self.get_loaded_containers_by_id() - self.populate_template(ignored_ids=loaded_containers_ids) - - def get_placeholders(self): - placeholders = map(self.placeholder_class, self.get_template_nodes()) - valid_placeholders = filter( - lambda i: i.is_valid, - placeholders - ) - sorted_placeholders = list(sorted( - valid_placeholders, - key=lambda i: i.order - )) - return sorted_placeholders - - @abstractmethod - def get_loaded_containers_by_id(self): - """ - Collect already loaded containers for updating scene - Return: - dict (string, node): A dictionnary id as key - and containers as value - """ - pass - - @abstractmethod - def import_template(self, template_path): - """ - Import template in current host - Args: - template_path (str): fullpath to current task and - host's template file - Return: - None - """ - pass - - @abstractmethod - def get_template_nodes(self): - """ - Returning a list of nodes acting as host placeholders for - templating. The data representation is by user. - AbstractLoadTemplate (and LoadTemplate) won't directly manipulate nodes - Args : - None - Returns: - list(AnyNode): Solved template path - """ - pass - - -@six.add_metaclass(ABCMeta) -class AbstractPlaceholder: - """Abstraction of placeholders logic. - - Properties: - required_keys: A list of mandatory keys to decribe placeholder - and assets to load. - optional_keys: A list of optional keys to decribe - placeholder and assets to load - loader_name: Name of linked loader to use while loading assets - - Args: - identifier (str): Placeholder identifier. Should be possible to be - used as identifier in "a scene" (e.g. unique node name). - """ - - required_keys = { - "builder_type", - "family", - "representation", - "order", - "loader", - "loader_args" - } - optional_keys = {} - - def __init__(self, identifier): - self._log = None - self._name = identifier - self.get_data(identifier) - - @property - def log(self): - if self._log is None: - self._log = Logger.get_logger(repr(self)) - return self._log - - def __repr__(self): - return "< {} {} >".format(self.__class__.__name__, self.name) - - @property - def name(self): - return self._name - - @property - def loader_args(self): - return self.data["loader_args"] - - @property - def builder_type(self): - return self.data["builder_type"] - - @property - def order(self): - return self.data["order"] - - @property - def loader_name(self): - """Return placeholder loader name. - - Returns: - str: Loader name that will be used to load placeholder - representations. - """ - - return self.data["loader"] - - @property - def is_valid(self): - """Test validity of placeholder. - - i.e.: every required key exists in placeholder data - - Returns: - bool: True if every key is in data - """ - - if set(self.required_keys).issubset(self.data.keys()): - self.log.debug("Valid placeholder : {}".format(self.name)) - return True - self.log.info("Placeholder is not valid : {}".format(self.name)) - return False - - @abstractmethod - def parent_in_hierarchy(self, container): - """Place loaded container in correct hierarchy given by placeholder - - Args: - container (Dict[str, Any]): Loaded container created by loader. - """ - - pass - - @abstractmethod - def clean(self): - """Clean placeholder from hierarchy after loading assets.""" - - pass - - @abstractmethod - def get_representations(self, current_asset_doc, linked_asset_docs): - """Query representations based on placeholder data. - - Args: - current_asset_doc (Dict[str, Any]): Document of current - context asset. - linked_asset_docs (List[Dict[str, Any]]): Documents of assets - linked to current context asset. - - Returns: - Iterable[Dict[str, Any]]: Representations that are matching - placeholder filters. - """ - - pass - - @abstractmethod - def get_data(self, identifier): - """Collect information about placeholder by identifier. - - Args: - identifier (str): A unique placeholder identifier defined by - implementation. - """ - - pass diff --git a/openpype/pipeline/workfile/build_template.py b/openpype/pipeline/workfile/build_template.py deleted file mode 100644 index 3328dfbc9e..0000000000 --- a/openpype/pipeline/workfile/build_template.py +++ /dev/null @@ -1,72 +0,0 @@ -import os -from importlib import import_module -from openpype.lib import classes_from_module -from openpype.host import HostBase -from openpype.pipeline import registered_host - -from .abstract_template_loader import ( - AbstractPlaceholder, - AbstractTemplateLoader) - -from .build_template_exceptions import ( - TemplateLoadingFailed, - TemplateAlreadyImported, - MissingHostTemplateModule, - MissingTemplatePlaceholderClass, - MissingTemplateLoaderClass -) - -_module_path_format = 'openpype.hosts.{host}.api.template_loader' - - -def build_workfile_template(*args): - template_loader = build_template_loader() - try: - template_loader.import_template(template_loader.template_path) - except TemplateAlreadyImported as err: - template_loader.template_already_imported(err) - except TemplateLoadingFailed as err: - template_loader.template_loading_failed(err) - else: - template_loader.populate_template() - - -def update_workfile_template(*args): - template_loader = build_template_loader() - template_loader.update_missing_containers() - - -def build_template_loader(): - # TODO refactor to use advantage of 'HostBase' and don't import dynamically - # - hosts should have methods that gives option to return builders - host = registered_host() - if isinstance(host, HostBase): - host_name = host.name - else: - host_name = os.environ.get("AVALON_APP") - if not host_name: - host_name = host.__name__.split(".")[-2] - - module_path = _module_path_format.format(host=host_name) - module = import_module(module_path) - if not module: - raise MissingHostTemplateModule( - "No template loader found for host {}".format(host_name)) - - template_loader_class = classes_from_module( - AbstractTemplateLoader, - module - ) - template_placeholder_class = classes_from_module( - AbstractPlaceholder, - module - ) - - if not template_loader_class: - raise MissingTemplateLoaderClass() - template_loader_class = template_loader_class[0] - - if not template_placeholder_class: - raise MissingTemplatePlaceholderClass() - template_placeholder_class = template_placeholder_class[0] - return template_loader_class(template_placeholder_class) diff --git a/openpype/pipeline/workfile/build_template_exceptions.py b/openpype/pipeline/workfile/build_template_exceptions.py deleted file mode 100644 index 7a5075e3dc..0000000000 --- a/openpype/pipeline/workfile/build_template_exceptions.py +++ /dev/null @@ -1,35 +0,0 @@ -class MissingHostTemplateModule(Exception): - """Error raised when expected module does not exists""" - pass - - -class MissingTemplatePlaceholderClass(Exception): - """Error raised when module doesn't implement a placeholder class""" - pass - - -class MissingTemplateLoaderClass(Exception): - """Error raised when module doesn't implement a template loader class""" - pass - - -class TemplateNotFound(Exception): - """Exception raised when template does not exist.""" - pass - - -class TemplateProfileNotFound(Exception): - """Exception raised when current profile - doesn't match any template profile""" - pass - - -class TemplateAlreadyImported(Exception): - """Error raised when Template was already imported by host for - this session""" - pass - - -class TemplateLoadingFailed(Exception): - """Error raised whend Template loader was unable to load the template""" - pass diff --git a/openpype/pipeline/workfile/build_workfile.py b/openpype/pipeline/workfile/build_workfile.py index 0b8a444436..87b9df158f 100644 --- a/openpype/pipeline/workfile/build_workfile.py +++ b/openpype/pipeline/workfile/build_workfile.py @@ -1,3 +1,14 @@ +"""Workfile build based on settings. + +Workfile builder will do stuff based on project settings. Advantage is that +it need only access to settings. Disadvantage is that it is hard to focus +build per context and being explicit about loaded content. + +For more explicit workfile build is recommended 'AbstractTemplateBuilder' +from '~/openpype/pipeline/workfile/workfile_template_builder'. Which gives +more abilities to define how build happens but require more code to achive it. +""" + import os import re import collections diff --git a/openpype/pipeline/workfile/lock_workfile.py b/openpype/pipeline/workfile/lock_workfile.py new file mode 100644 index 0000000000..fbec44247a --- /dev/null +++ b/openpype/pipeline/workfile/lock_workfile.py @@ -0,0 +1,82 @@ +import os +import json +from uuid import uuid4 +from openpype.lib import Logger, filter_profiles +from openpype.lib.pype_info import get_workstation_info +from openpype.settings import get_project_settings + + +def _read_lock_file(lock_filepath): + if not os.path.exists(lock_filepath): + log = Logger.get_logger("_read_lock_file") + log.debug("lock file is not created or readable as expected!") + with open(lock_filepath, "r") as stream: + data = json.load(stream) + return data + + +def _get_lock_file(filepath): + return filepath + ".oplock" + + +def is_workfile_locked(filepath): + lock_filepath = _get_lock_file(filepath) + if not os.path.exists(lock_filepath): + return False + return True + + +def get_workfile_lock_data(filepath): + lock_filepath = _get_lock_file(filepath) + return _read_lock_file(lock_filepath) + + +def is_workfile_locked_for_current_process(filepath): + if not is_workfile_locked(filepath): + return False + + lock_filepath = _get_lock_file(filepath) + data = _read_lock_file(lock_filepath) + return data["process_id"] == _get_process_id() + + +def delete_workfile_lock(filepath): + lock_filepath = _get_lock_file(filepath) + if os.path.exists(lock_filepath): + os.remove(lock_filepath) + + +def create_workfile_lock(filepath): + lock_filepath = _get_lock_file(filepath) + info = get_workstation_info() + info["process_id"] = _get_process_id() + with open(lock_filepath, "w") as stream: + json.dump(info, stream) + + +def remove_workfile_lock(filepath): + if is_workfile_locked_for_current_process(filepath): + delete_workfile_lock(filepath) + + +def _get_process_id(): + process_id = os.environ.get("OPENPYPE_PROCESS_ID") + if not process_id: + process_id = str(uuid4()) + os.environ["OPENPYPE_PROCESS_ID"] = process_id + return process_id + + +def is_workfile_lock_enabled(host_name, project_name, project_setting=None): + if project_setting is None: + project_setting = get_project_settings(project_name) + workfile_lock_profiles = ( + project_setting + ["global"] + ["tools"] + ["Workfiles"] + ["workfile_lock_profiles"]) + profile = filter_profiles(workfile_lock_profiles, {"host_name": host_name}) + if not profile: + return False + return profile["enabled"] diff --git a/openpype/pipeline/workfile/workfile_template_builder.py b/openpype/pipeline/workfile/workfile_template_builder.py new file mode 100644 index 0000000000..582657c735 --- /dev/null +++ b/openpype/pipeline/workfile/workfile_template_builder.py @@ -0,0 +1,1451 @@ +"""Workfile build mechanism using workfile templates. + +Build templates are manually prepared using plugin definitions which create +placeholders inside the template which are populated on import. + +This approach is very explicit to achive very specific build logic that can be +targeted by task types and names. + +Placeholders are created using placeholder plugins which should care about +logic and data of placeholder items. 'PlaceholderItem' is used to keep track +about it's progress. +""" + +import os +import re +import collections +import copy +from abc import ABCMeta, abstractmethod + +import six + +from openpype.client import ( + get_asset_by_name, + get_linked_assets, + get_representations, +) +from openpype.settings import ( + get_project_settings, + get_system_settings, +) +from openpype.host import HostBase +from openpype.lib import ( + Logger, + StringTemplate, + filter_profiles, + attribute_definitions, +) +from openpype.lib.attribute_definitions import get_attributes_keys +from openpype.pipeline import legacy_io, Anatomy +from openpype.pipeline.load import ( + get_loaders_by_name, + get_contexts_for_repre_docs, + load_with_repre_context, +) +from openpype.pipeline.create import get_legacy_creator_by_name + + +class TemplateNotFound(Exception): + """Exception raised when template does not exist.""" + pass + + +class TemplateProfileNotFound(Exception): + """Exception raised when current profile + doesn't match any template profile""" + pass + + +class TemplateAlreadyImported(Exception): + """Error raised when Template was already imported by host for + this session""" + pass + + +class TemplateLoadFailed(Exception): + """Error raised whend Template loader was unable to load the template""" + pass + + +@six.add_metaclass(ABCMeta) +class AbstractTemplateBuilder(object): + """Abstraction of Template Builder. + + Builder cares about context, shared data, cache, discovery of plugins + and trigger logic. Provides public api for host workfile build systen. + + Rest of logic is based on plugins that care about collection and creation + of placeholder items. + + Population of placeholders happens in loops. Each loop will collect all + available placeholders, skip already populated, and populate the rest. + + Builder item has 2 types of shared data. Refresh lifetime which are cleared + on refresh and populate lifetime which are cleared after loop of + placeholder population. + + Args: + host (Union[HostBase, ModuleType]): Implementation of host. + """ + + _log = None + + def __init__(self, host): + # Get host name + if isinstance(host, HostBase): + host_name = host.name + else: + host_name = os.environ.get("AVALON_APP") + + self._host = host + self._host_name = host_name + + # Shared data across placeholder plugins + self._shared_data = {} + self._shared_populate_data = {} + + # Where created objects of placeholder plugins will be stored + self._placeholder_plugins = None + self._loaders_by_name = None + self._creators_by_name = None + + self._system_settings = None + self._project_settings = None + + self._current_asset_doc = None + self._linked_asset_docs = None + self._task_type = None + + @property + def project_name(self): + return legacy_io.active_project() + + @property + def current_asset_name(self): + return legacy_io.Session["AVALON_ASSET"] + + @property + def current_task_name(self): + return legacy_io.Session["AVALON_TASK"] + + @property + def system_settings(self): + if self._system_settings is None: + self._system_settings = get_system_settings() + return self._system_settings + + @property + def project_settings(self): + if self._project_settings is None: + self._project_settings = get_project_settings(self.project_name) + return self._project_settings + + @property + def current_asset_doc(self): + if self._current_asset_doc is None: + self._current_asset_doc = get_asset_by_name( + self.project_name, self.current_asset_name + ) + return self._current_asset_doc + + @property + def linked_asset_docs(self): + if self._linked_asset_docs is None: + self._linked_asset_docs = get_linked_assets( + self.current_asset_doc + ) + return self._linked_asset_docs + + @property + def current_task_type(self): + asset_doc = self.current_asset_doc + if not asset_doc: + return None + return ( + asset_doc + .get("data", {}) + .get("tasks", {}) + .get(self.current_task_name, {}) + .get("type") + ) + + def get_placeholder_plugin_classes(self): + """Get placeholder plugin classes that can be used to build template. + + Default implementation looks for method + 'get_workfile_build_placeholder_plugins' on host. + + Returns: + List[PlaceholderPlugin]: Plugin classes available for host. + """ + + if hasattr(self._host, "get_workfile_build_placeholder_plugins"): + return self._host.get_workfile_build_placeholder_plugins() + return [] + + @property + def host(self): + """Access to host implementation. + + Returns: + Union[HostBase, ModuleType]: Implementation of host. + """ + + return self._host + + @property + def host_name(self): + """Name of 'host' implementation. + + Returns: + str: Host's name. + """ + + return self._host_name + + @property + def log(self): + """Dynamically created logger for the plugin.""" + + if self._log is None: + self._log = Logger.get_logger(repr(self)) + return self._log + + def refresh(self): + """Reset cached data.""" + + self._placeholder_plugins = None + self._loaders_by_name = None + self._creators_by_name = None + + self._current_asset_doc = None + self._linked_asset_docs = None + self._task_type = None + + self._system_settings = None + self._project_settings = None + + self.clear_shared_data() + self.clear_shared_populate_data() + + def get_loaders_by_name(self): + if self._loaders_by_name is None: + self._loaders_by_name = get_loaders_by_name() + return self._loaders_by_name + + def get_creators_by_name(self): + if self._creators_by_name is None: + self._creators_by_name = get_legacy_creator_by_name() + return self._creators_by_name + + def get_shared_data(self, key): + """Receive shared data across plugins and placeholders. + + This can be used to scroll scene only once to look for placeholder + items if the storing is unified but each placeholder plugin would have + to call it again. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + return self._shared_data.get(key) + + def set_shared_data(self, key, value): + """Store share data across plugins and placeholders. + + Store data that can be afterwards accessed from any future call. It + is good practice to check if the same value is not already stored under + different key or if the key is not already used for something else. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + self._shared_data[key] = value + + def clear_shared_data(self): + """Clear shared data. + + Method only clear shared data to default state. + """ + + self._shared_data = {} + + def clear_shared_populate_data(self): + """Receive shared data across plugins and placeholders. + + These data are cleared after each loop of populating of template. + + This can be used to scroll scene only once to look for placeholder + items if the storing is unified but each placeholder plugin would have + to call it again. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + self._shared_populate_data = {} + + def get_shared_populate_data(self, key): + """Store share populate data across plugins and placeholders. + + These data are cleared after each loop of populating of template. + + Store data that can be afterwards accessed from any future call. It + is good practice to check if the same value is not already stored under + different key or if the key is not already used for something else. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + return self._shared_populate_data.get(key) + + def set_shared_populate_data(self, key, value): + """Store share populate data across plugins and placeholders. + + These data are cleared after each loop of populating of template. + + Store data that can be afterwards accessed from any future call. It + is good practice to check if the same value is not already stored under + different key or if the key is not already used for something else. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + self._shared_populate_data[key] = value + + @property + def placeholder_plugins(self): + """Access to initialized placeholder plugins. + + Returns: + List[PlaceholderPlugin]: Initialized plugins available for host. + """ + + if self._placeholder_plugins is None: + placeholder_plugins = {} + for cls in self.get_placeholder_plugin_classes(): + try: + plugin = cls(self) + placeholder_plugins[plugin.identifier] = plugin + + except Exception: + self.log.warning( + "Failed to initialize placeholder plugin {}".format( + cls.__name__ + ), + exc_info=True + ) + + self._placeholder_plugins = placeholder_plugins + return self._placeholder_plugins + + def create_placeholder(self, plugin_identifier, placeholder_data): + """Create new placeholder using plugin identifier and data. + + Args: + plugin_identifier (str): Identifier of plugin. That's how builder + know which plugin should be used. + placeholder_data (Dict[str, Any]): Placeholder item data. They + should match options required by the plugin. + + Returns: + PlaceholderItem: Created placeholder item. + """ + + plugin = self.placeholder_plugins[plugin_identifier] + return plugin.create_placeholder(placeholder_data) + + def get_placeholders(self): + """Collect placeholder items from scene. + + Each placeholder plugin can collect it's placeholders and return them. + This method does not use cached values but always go through the scene. + + Returns: + List[PlaceholderItem]: Sorted placeholder items. + """ + + placeholders = [] + for placeholder_plugin in self.placeholder_plugins.values(): + result = placeholder_plugin.collect_placeholders() + if result: + placeholders.extend(result) + + return list(sorted( + placeholders, + key=lambda i: i.order + )) + + def build_template(self, template_path=None, level_limit=None): + """Main callback for building workfile from template path. + + Todo: + Handle report of populated placeholders from + 'populate_scene_placeholders' to be shown to a user. + + Args: + template_path (str): Path to a template file with placeholders. + Template from settings 'get_template_path' used when not + passed. + level_limit (int): Limit of populate loops. Related to + 'populate_scene_placeholders' method. + """ + + if template_path is None: + template_path = self.get_template_path() + self.import_template(template_path) + self.populate_scene_placeholders(level_limit) + + def rebuild_template(self): + """Go through existing placeholders in scene and update them. + + This could not make sense for all plugin types so this is optional + logic for plugins. + + Note: + Logic is not importing the template again but using placeholders + that were already available. We should maybe change the method + name. + + Question: + Should this also handle subloops as it is possible that another + template is loaded during processing? + """ + + if not self.placeholder_plugins: + self.log.info("There are no placeholder plugins available.") + return + + placeholders = self.get_placeholders() + if not placeholders: + self.log.info("No placeholders were found.") + return + + for placeholder in placeholders: + plugin = placeholder.plugin + plugin.repopulate_placeholder(placeholder) + + self.clear_shared_populate_data() + + @abstractmethod + def import_template(self, template_path): + """ + Import template in current host. + + Should load the content of template into scene so + 'populate_scene_placeholders' can be started. + + Args: + template_path (str): Fullpath for current task and + host's template file. + """ + + pass + + def _prepare_placeholders(self, placeholders): + """Run preparation part for placeholders on plugins. + + Args: + placeholders (List[PlaceholderItem]): Placeholder items that will + be processed. + """ + + # Prepare placeholder items by plugin + plugins_by_identifier = {} + placeholders_by_plugin_id = collections.defaultdict(list) + for placeholder in placeholders: + plugin = placeholder.plugin + identifier = plugin.identifier + plugins_by_identifier[identifier] = plugin + placeholders_by_plugin_id[identifier].append(placeholder) + + # Plugin should prepare data for passed placeholders + for identifier, placeholders in placeholders_by_plugin_id.items(): + plugin = plugins_by_identifier[identifier] + plugin.prepare_placeholders(placeholders) + + def populate_scene_placeholders(self, level_limit=None): + """Find placeholders in scene using plugins and process them. + + This should happen after 'import_template'. + + Collect available placeholders from scene. All of them are processed + after that shared data are cleared. Placeholder items are collected + again and if there are any new the loop happens again. This is possible + to change with defying 'level_limit'. + + Placeholders are marked as processed so they're not re-processed. To + identify which placeholders were already processed is used + placeholder's 'scene_identifier'. + + Args: + level_limit (int): Level of loops that can happen. Default is 1000. + """ + + if not self.placeholder_plugins: + self.log.warning("There are no placeholder plugins available.") + return + + placeholders = self.get_placeholders() + if not placeholders: + self.log.warning("No placeholders were found.") + return + + # Avoid infinite loop + # - 1000 iterations of placeholders processing must be enough + if not level_limit: + level_limit = 1000 + + placeholder_by_scene_id = { + placeholder.scene_identifier: placeholder + for placeholder in placeholders + } + all_processed = len(placeholders) == 0 + # Counter is checked at the ned of a loop so the loop happens at least + # once. + iter_counter = 0 + while not all_processed: + filtered_placeholders = [] + for placeholder in placeholders: + if placeholder.finished: + continue + + if placeholder.in_progress: + self.log.warning(( + "Placeholder that should be processed" + " is already in progress." + )) + continue + filtered_placeholders.append(placeholder) + + self._prepare_placeholders(filtered_placeholders) + + for placeholder in filtered_placeholders: + placeholder.set_in_progress() + placeholder_plugin = placeholder.plugin + try: + placeholder_plugin.populate_placeholder(placeholder) + + except Exception as exc: + self.log.warning( + ( + "Failed to process placeholder {} with plugin {}" + ).format( + placeholder.scene_identifier, + placeholder_plugin.__class__.__name__ + ), + exc_info=True + ) + placeholder.set_failed(exc) + + placeholder.set_finished() + + # Clear shared data before getting new placeholders + self.clear_shared_populate_data() + + iter_counter += 1 + if iter_counter >= level_limit: + break + + all_processed = True + collected_placeholders = self.get_placeholders() + for placeholder in collected_placeholders: + identifier = placeholder.scene_identifier + if identifier in placeholder_by_scene_id: + continue + + all_processed = False + placeholder_by_scene_id[identifier] = placeholder + placeholders.append(placeholder) + + self.refresh() + + def _get_build_profiles(self): + """Get build profiles for workfile build template path. + + Returns: + List[Dict[str, Any]]: Profiles for template path resolving. + """ + + return ( + self.project_settings + [self.host_name] + ["templated_workfile_build"] + ["profiles"] + ) + + def get_template_path(self): + """Unified way how template path is received usign settings. + + Method is dependent on '_get_build_profiles' which should return filter + profiles to resolve path to a template. Default implementation looks + into host settings: + - 'project_settings/{host name}/templated_workfile_build/profiles' + + Returns: + str: Path to a template file with placeholders. + + Raises: + TemplateProfileNotFound: When profiles are not filled. + TemplateLoadFailed: Profile was found but path is not set. + TemplateNotFound: Path was set but file does not exists. + """ + + host_name = self.host_name + project_name = self.project_name + task_name = self.current_task_name + task_type = self.current_task_type + + build_profiles = self._get_build_profiles() + profile = filter_profiles( + build_profiles, + { + "task_types": task_type, + "task_names": task_name + } + ) + + if not profile: + raise TemplateProfileNotFound(( + "No matching profile found for task '{}' of type '{}' " + "with host '{}'" + ).format(task_name, task_type, host_name)) + + path = profile["path"] + if not path: + raise TemplateLoadFailed(( + "Template path is not set.\n" + "Path need to be set in {}\\Template Workfile Build " + "Settings\\Profiles" + ).format(host_name.title())) + + # Try fill path with environments and anatomy roots + anatomy = Anatomy(project_name) + fill_data = { + key: value + for key, value in os.environ.items() + } + fill_data["root"] = anatomy.roots + result = StringTemplate.format_template(path, fill_data) + if result.solved: + path = result.normalized() + + if path and os.path.exists(path): + self.log.info("Found template at: '{}'".format(path)) + return path + + solved_path = None + while True: + try: + solved_path = anatomy.path_remapper(path) + except KeyError as missing_key: + raise KeyError( + "Could not solve key '{}' in template path '{}'".format( + missing_key, path)) + + if solved_path is None: + solved_path = path + if solved_path == path: + break + path = solved_path + + solved_path = os.path.normpath(solved_path) + if not os.path.exists(solved_path): + raise TemplateNotFound( + "Template found in openPype settings for task '{}' with host " + "'{}' does not exists. (Not found : {})".format( + task_name, host_name, solved_path)) + + self.log.info("Found template at: '{}'".format(solved_path)) + + return solved_path + + +@six.add_metaclass(ABCMeta) +class PlaceholderPlugin(object): + """Plugin which care about handling of placeholder items logic. + + Plugin create and update placeholders in scene and populate them on + template import. Populating means that based on placeholder data happens + a logic in the scene. Most common logic is to load representation using + loaders or to create instances in scene. + """ + + label = None + _log = None + + def __init__(self, builder): + self._builder = builder + + @property + def builder(self): + """Access to builder which initialized the plugin. + + Returns: + AbstractTemplateBuilder: Loader of template build. + """ + + return self._builder + + @property + def project_name(self): + return self._builder.project_name + + @property + def log(self): + """Dynamically created logger for the plugin.""" + + if self._log is None: + self._log = Logger.get_logger(repr(self)) + return self._log + + @property + def identifier(self): + """Identifier which will be stored to placeholder. + + Default implementation uses class name. + + Returns: + str: Unique identifier of placeholder plugin. + """ + + return self.__class__.__name__ + + @abstractmethod + def create_placeholder(self, placeholder_data): + """Create new placeholder in scene and get it's item. + + It matters on the plugin implementation if placeholder will use + selection in scene or create new node. + + Args: + placeholder_data (Dict[str, Any]): Data that were created + based on attribute definitions from 'get_placeholder_options'. + + Returns: + PlaceholderItem: Created placeholder item. + """ + + pass + + @abstractmethod + def update_placeholder(self, placeholder_item, placeholder_data): + """Update placeholder item with new data. + + New data should be propagated to object of placeholder item itself + and also into the scene. + + Reason: + Some placeholder plugins may require some special way how the + updates should be propagated to object. + + Args: + placeholder_item (PlaceholderItem): Object of placeholder that + should be updated. + placeholder_data (Dict[str, Any]): Data related to placeholder. + Should match plugin options. + """ + + pass + + @abstractmethod + def collect_placeholders(self): + """Collect placeholders from scene. + + Returns: + List[PlaceholderItem]: Placeholder objects. + """ + + pass + + def get_placeholder_options(self, options=None): + """Placeholder options for data showed. + + Returns: + List[AbtractAttrDef]: Attribute definitions of placeholder options. + """ + + return [] + + def get_placeholder_keys(self): + """Get placeholder keys that are stored in scene. + + Returns: + Set[str]: Key of placeholder keys that are stored in scene. + """ + + option_keys = get_attributes_keys(self.get_placeholder_options()) + option_keys.add("plugin_identifier") + return option_keys + + def prepare_placeholders(self, placeholders): + """Preparation part of placeholders. + + Args: + placeholders (List[PlaceholderItem]): List of placeholders that + will be processed. + """ + + pass + + @abstractmethod + def populate_placeholder(self, placeholder): + """Process single placeholder item. + + Processing of placeholders is defined by their order thus can't be + processed in batch. + + Args: + placeholder (PlaceholderItem): Placeholder that should be + processed. + """ + + pass + + def repopulate_placeholder(self, placeholder): + """Update scene with current context for passed placeholder. + + Can be used to re-run placeholder logic (if it make sense). + """ + + pass + + def get_plugin_shared_data(self, key): + """Receive shared data across plugin and placeholders. + + Using shared data from builder but stored under plugin identifier. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + plugin_data = self.builder.get_shared_data(self.identifier) + if plugin_data is None: + return None + return plugin_data.get(key) + + def set_plugin_shared_data(self, key, value): + """Store share data across plugin and placeholders. + + Using shared data from builder but stored under plugin identifier. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + plugin_data = self.builder.get_shared_data(self.identifier) + if plugin_data is None: + plugin_data = {} + plugin_data[key] = value + self.builder.set_shared_data(self.identifier, plugin_data) + + def get_plugin_shared_populate_data(self, key): + """Receive shared data across plugin and placeholders. + + Using shared populate data from builder but stored under plugin + identifier. + + Shared populate data are cleaned up during populate while loop. + + Args: + key (str): Key under which are shared data stored. + + Returns: + Union[None, Any]: None if key was not set. + """ + + plugin_data = self.builder.get_shared_populate_data(self.identifier) + if plugin_data is None: + return None + return plugin_data.get(key) + + def set_plugin_shared_populate_data(self, key, value): + """Store share data across plugin and placeholders. + + Using shared data from builder but stored under plugin identifier. + + Key should be self explanatory to content. + - wrong: 'asset' + - good: 'asset_name' + + Shared populate data are cleaned up during populate while loop. + + Args: + key (str): Key under which is key stored. + value (Any): Value that should be stored under the key. + """ + + plugin_data = self.builder.get_shared_populate_data(self.identifier) + if plugin_data is None: + plugin_data = {} + plugin_data[key] = value + self.builder.set_shared_populate_data(self.identifier, plugin_data) + + +class PlaceholderItem(object): + """Item representing single item in scene that is a placeholder to process. + + Items are always created and updated by their plugins. Each plugin can use + modified class of 'PlacehoderItem' but only to add more options instead of + new other. + + Scene identifier is used to avoid processing of the palceholder item + multiple times so must be unique across whole workfile builder. + + Args: + scene_identifier (str): Unique scene identifier. If placeholder is + created from the same "node" it must have same identifier. + data (Dict[str, Any]): Data related to placeholder. They're defined + by plugin. + plugin (PlaceholderPlugin): Plugin which created the placeholder item. + """ + + default_order = 100 + + def __init__(self, scene_identifier, data, plugin): + self._log = None + self._scene_identifier = scene_identifier + self._data = data + self._plugin = plugin + + # Keep track about state of Placeholder process + self._state = 0 + + # Error messages to be shown in UI + # - all other messages should be logged + self._errors = [] # -> List[str] + + @property + def plugin(self): + """Access to plugin which created placeholder. + + Returns: + PlaceholderPlugin: Plugin object. + """ + + return self._plugin + + @property + def builder(self): + """Access to builder. + + Returns: + AbstractTemplateBuilder: Builder which is the top part of + placeholder. + """ + + return self.plugin.builder + + @property + def data(self): + """Placeholder data which can modify how placeholder is processed. + + Possible general keys + - order: Can define the order in which is palceholder processed. + Lower == earlier. + + Other keys are defined by placeholder and should validate them on item + creation. + + Returns: + Dict[str, Any]: Placeholder item data. + """ + + return self._data + + def to_dict(self): + """Create copy of item's data. + + Returns: + Dict[str, Any]: Placeholder data. + """ + + return copy.deepcopy(self.data) + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(repr(self)) + return self._log + + def __repr__(self): + return "< {} {} >".format(self.__class__.__name__, self.name) + + @property + def order(self): + """Order of item processing.""" + + order = self._data.get("order") + if order is None: + return self.default_order + return order + + @property + def scene_identifier(self): + return self._scene_identifier + + @property + def finished(self): + """Item was already processed.""" + + return self._state == 2 + + @property + def in_progress(self): + """Processing is in progress.""" + + return self._state == 1 + + def set_in_progress(self): + """Change to in progress state.""" + + self._state = 1 + + def set_finished(self): + """Change to finished state.""" + + self._state = 2 + + def set_failed(self, exception): + self.add_error(str(exception)) + + def add_error(self, error): + """Set placeholder item as failed and mark it as finished.""" + + self._errors.append(error) + + def get_errors(self): + """Exception with which the placeholder process failed. + + Gives ability to access the exception. + """ + + return self._errors + + +class PlaceholderLoadMixin(object): + """Mixin prepared for loading placeholder plugins. + + Implementation prepares options for placeholders with + 'get_load_plugin_options'. + + For placeholder population is implemented 'populate_load_placeholder'. + + PlaceholderItem can have implemented methods: + - 'load_failed' - called when loading of one representation failed + - 'load_succeed' - called when loading of one representation succeeded + """ + + def get_load_plugin_options(self, options=None): + """Unified attribute definitions for load placeholder. + + Common function for placeholder plugins used for loading of + repsentations. Use it in 'get_placeholder_options'. + + Args: + plugin (PlaceholderPlugin): Plugin used for loading of + representations. + options (Dict[str, Any]): Already available options which are used + as defaults for attributes. + + Returns: + List[AbtractAttrDef]: Attribute definitions common for load + plugins. + """ + + loaders_by_name = self.builder.get_loaders_by_name() + loader_items = [ + (loader_name, loader.label or loader_name) + for loader_name, loader in loaders_by_name.items() + ] + + loader_items = list(sorted(loader_items, key=lambda i: i[1])) + options = options or {} + return [ + attribute_definitions.UISeparatorDef(), + attribute_definitions.UILabelDef("Main attributes"), + attribute_definitions.UISeparatorDef(), + + attribute_definitions.EnumDef( + "builder_type", + label="Asset Builder Type", + default=options.get("builder_type"), + items=[ + ("context_asset", "Current asset"), + ("linked_asset", "Linked assets"), + ("all_assets", "All assets") + ], + tooltip=( + "Asset Builder Type\n" + "\nBuilder type describe what template loader will look" + " for." + "\ncontext_asset : Template loader will look for subsets" + " of current context asset (Asset bob will find asset)" + "\nlinked_asset : Template loader will look for assets" + " linked to current context asset." + "\nLinked asset are looked in database under" + " field \"inputLinks\"" + ) + ), + attribute_definitions.TextDef( + "family", + label="Family", + default=options.get("family"), + placeholder="model, look, ..." + ), + attribute_definitions.TextDef( + "representation", + label="Representation name", + default=options.get("representation"), + placeholder="ma, abc, ..." + ), + attribute_definitions.EnumDef( + "loader", + label="Loader", + default=options.get("loader"), + items=loader_items, + tooltip=( + "Loader" + "\nDefines what OpenPype loader will be used to" + " load assets." + "\nUseable loader depends on current host's loader list." + "\nField is case sensitive." + ) + ), + attribute_definitions.TextDef( + "loader_args", + label="Loader Arguments", + default=options.get("loader_args"), + placeholder='{"camera":"persp", "lights":True}', + tooltip=( + "Loader" + "\nDefines a dictionnary of arguments used to load assets." + "\nUseable arguments depend on current placeholder Loader." + "\nField should be a valid python dict." + " Anything else will be ignored." + ) + ), + attribute_definitions.NumberDef( + "order", + label="Order", + default=options.get("order") or 0, + decimals=0, + minimum=0, + maximum=999, + tooltip=( + "Order" + "\nOrder defines asset loading priority (0 to 999)" + "\nPriority rule is : \"lowest is first to load\"." + ) + ), + attribute_definitions.UISeparatorDef(), + attribute_definitions.UILabelDef("Optional attributes"), + attribute_definitions.UISeparatorDef(), + attribute_definitions.TextDef( + "asset", + label="Asset filter", + default=options.get("asset"), + placeholder="regex filtering by asset name", + tooltip=( + "Filtering assets by matching field regex to asset's name" + ) + ), + attribute_definitions.TextDef( + "subset", + label="Subset filter", + default=options.get("subset"), + placeholder="regex filtering by subset name", + tooltip=( + "Filtering assets by matching field regex to subset's name" + ) + ), + attribute_definitions.TextDef( + "hierarchy", + label="Hierarchy filter", + default=options.get("hierarchy"), + placeholder="regex filtering by asset's hierarchy", + tooltip=( + "Filtering assets by matching field asset's hierarchy" + ) + ) + ] + + def parse_loader_args(self, loader_args): + """Helper function to parse string of loader arugments. + + Empty dictionary is returned if conversion fails. + + Args: + loader_args (str): Loader args filled by user. + + Returns: + Dict[str, Any]: Parsed arguments used as dictionary. + """ + + if not loader_args: + return {} + + try: + parsed_args = eval(loader_args) + if isinstance(parsed_args, dict): + return parsed_args + + except Exception as err: + print( + "Error while parsing loader arguments '{}'.\n{}: {}\n\n" + "Continuing with default arguments. . .".format( + loader_args, err.__class__.__name__, err)) + + return {} + + def _get_representations(self, placeholder): + """Prepared query of representations based on load options. + + This function is directly connected to options defined in + 'get_load_plugin_options'. + + Note: + This returns all representation documents from all versions of + matching subset. To filter for last version use + '_reduce_last_version_repre_docs'. + + Args: + placeholder (PlaceholderItem): Item which should be populated. + + Returns: + List[Dict[str, Any]]: Representation documents matching filters + from placeholder data. + """ + + project_name = self.builder.project_name + current_asset_doc = self.builder.current_asset_doc + linked_asset_docs = self.builder.linked_asset_docs + + builder_type = placeholder.data["builder_type"] + if builder_type == "context_asset": + context_filters = { + "asset": [current_asset_doc["name"]], + "subset": [re.compile(placeholder.data["subset"])], + "hierarchy": [re.compile(placeholder.data["hierarchy"])], + "representation": [placeholder.data["representation"]], + "family": [placeholder.data["family"]] + } + + elif builder_type != "linked_asset": + context_filters = { + "asset": [re.compile(placeholder.data["asset"])], + "subset": [re.compile(placeholder.data["subset"])], + "hierarchy": [re.compile(placeholder.data["hierarchy"])], + "representation": [placeholder.data["representation"]], + "family": [placeholder.data["family"]] + } + + else: + asset_regex = re.compile(placeholder.data["asset"]) + linked_asset_names = [] + for asset_doc in linked_asset_docs: + asset_name = asset_doc["name"] + if asset_regex.match(asset_name): + linked_asset_names.append(asset_name) + + context_filters = { + "asset": linked_asset_names, + "subset": [re.compile(placeholder.data["subset"])], + "hierarchy": [re.compile(placeholder.data["hierarchy"])], + "representation": [placeholder.data["representation"]], + "family": [placeholder.data["family"]], + } + + return list(get_representations( + project_name, + context_filters=context_filters + )) + + def _before_repre_load(self, placeholder, representation): + """Can be overriden. Is called before representation is loaded.""" + + pass + + def _reduce_last_version_repre_docs(self, representations): + """Reduce representations to last verison.""" + + mapping = {} + for repre_doc in representations: + repre_context = repre_doc["context"] + + asset_name = repre_context["asset"] + subset_name = repre_context["subset"] + version = repre_context.get("version", -1) + + if asset_name not in mapping: + mapping[asset_name] = {} + + subset_mapping = mapping[asset_name] + if subset_name not in subset_mapping: + subset_mapping[subset_name] = collections.defaultdict(list) + + version_mapping = subset_mapping[subset_name] + version_mapping[version].append(repre_doc) + + output = [] + for subset_mapping in mapping.values(): + for version_mapping in subset_mapping.values(): + last_version = tuple(sorted(version_mapping.keys()))[-1] + output.extend(version_mapping[last_version]) + return output + + def populate_load_placeholder(self, placeholder, ignore_repre_ids=None): + """Load placeholder is goind to load matching representations. + + Note: + Ignore repre ids is to avoid loading the same representation again + on load. But the representation can be loaded with different loader + and there could be published new version of matching subset for the + representation. We should maybe expect containers. + + Also import loaders don't have containers at all... + + Args: + placeholder (PlaceholderItem): Placeholder item with information + about requested representations. + ignore_repre_ids (Iterable[Union[str, ObjectId]]): Representation + ids that should be skipped. + """ + + if ignore_repre_ids is None: + ignore_repre_ids = set() + + # TODO check loader existence + loader_name = placeholder.data["loader"] + loader_args = placeholder.data["loader_args"] + + placeholder_representations = self._get_representations(placeholder) + + filtered_representations = [] + for representation in self._reduce_last_version_repre_docs( + placeholder_representations + ): + repre_id = str(representation["_id"]) + if repre_id not in ignore_repre_ids: + filtered_representations.append(representation) + + if not filtered_representations: + self.log.info(( + "There's no representation for this placeholder: {}" + ).format(placeholder.scene_identifier)) + return + + repre_load_contexts = get_contexts_for_repre_docs( + self.project_name, filtered_representations + ) + loaders_by_name = self.builder.get_loaders_by_name() + for repre_load_context in repre_load_contexts.values(): + representation = repre_load_context["representation"] + repre_context = representation["context"] + self._before_repre_load( + placeholder, representation + ) + self.log.info( + "Loading {} from {} with loader {}\n" + "Loader arguments used : {}".format( + repre_context["subset"], + repre_context["asset"], + loader_name, + loader_args + ) + ) + try: + container = load_with_repre_context( + loaders_by_name[loader_name], + repre_load_context, + options=self.parse_loader_args(loader_args) + ) + + except Exception: + failed = True + self.load_failed(placeholder, representation) + + else: + failed = False + self.load_succeed(placeholder, container) + self.cleanup_placeholder(placeholder, failed) + + def load_failed(self, placeholder, representation): + if hasattr(placeholder, "load_failed"): + placeholder.load_failed(representation) + + def load_succeed(self, placeholder, container): + if hasattr(placeholder, "load_succeed"): + placeholder.load_succeed(container) + + def cleanup_placeholder(self, placeholder, failed): + """Cleanup placeholder after load of single representation. + + Can be called multiple times during placeholder item populating and is + called even if loading failed. + + Args: + placeholder (PlaceholderItem): Item which was just used to load + representation. + failed (bool): Loading of representation failed. + """ + + pass + + +class LoadPlaceholderItem(PlaceholderItem): + """PlaceholderItem for plugin which is loading representations. + + Connected to 'PlaceholderLoadMixin'. + """ + + def __init__(self, *args, **kwargs): + super(LoadPlaceholderItem, self).__init__(*args, **kwargs) + self._failed_representations = [] + + def get_errors(self): + if not self._failed_representations: + return [] + message = ( + "Failed to load {} representations using Loader {}" + ).format( + len(self._failed_representations), + self.data["loader"] + ) + return [message] + + def load_failed(self, representation): + self._failed_representations.append(representation) diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 9236c698ed..fc0f97b187 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -25,7 +25,9 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): for created_instance in create_context.instances: instance_data = created_instance.data_to_store() if instance_data["active"]: - self.create_instance(context, instance_data) + self.create_instance( + context, instance_data, created_instance.transient_data + ) # Update global data to context context.data.update(create_context.context_data_to_store()) @@ -37,7 +39,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): legacy_io.Session[key] = value os.environ[key] = value - def create_instance(self, context, in_data): + def create_instance(self, context, in_data, transient_data): subset = in_data["subset"] # If instance data already contain families then use it instance_families = in_data.get("families") or [] @@ -56,5 +58,8 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): for key, value in in_data.items(): if key not in instance.data: instance.data[key] = value + + instance.data["transientData"] = transient_data + self.log.info("collected instance: {}".format(instance.data)) self.log.info("parsing data: {}".format(in_data)) diff --git a/openpype/plugins/publish/collect_input_representations_to_versions.py b/openpype/plugins/publish/collect_input_representations_to_versions.py new file mode 100644 index 0000000000..18a19bce80 --- /dev/null +++ b/openpype/plugins/publish/collect_input_representations_to_versions.py @@ -0,0 +1,47 @@ +import pyblish.api + +from bson.objectid import ObjectId + +from openpype.client import get_representations + + +class CollectInputRepresentationsToVersions(pyblish.api.ContextPlugin): + """Converts collected input representations to input versions. + + Any data in `instance.data["inputRepresentations"]` gets converted into + `instance.data["inputVersions"]` as supported in OpenPype v3. + + """ + # This is a ContextPlugin because then we can query the database only once + # for the conversion of representation ids to version ids (optimization) + label = "Input Representations to Versions" + order = pyblish.api.CollectorOrder + 0.499 + hosts = ["*"] + + def process(self, context): + # Query all version ids for representation ids from the database once + representations = set() + for instance in context: + inst_repre = instance.data.get("inputRepresentations", []) + representations.update(inst_repre) + + representations_docs = get_representations( + project_name=context.data["projectEntity"]["name"], + representation_ids=representations, + fields=["_id", "parent"]) + + representation_id_to_version_id = { + repre["_id"]: repre["parent"] for repre in representations_docs + } + + for instance in context: + inst_repre = instance.data.get("inputRepresentations", []) + if not inst_repre: + continue + + input_versions = instance.data.get("inputVersions", []) + for repre_id in inst_repre: + repre_id = ObjectId(repre_id) + version_id = representation_id_to_version_id[repre_id] + input_versions.append(version_id) + instance.data["inputVersions"] = input_versions diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index cfb0318950..9a68b6e43d 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -10,6 +10,7 @@ import opentimelineio as otio import pyblish.api from pprint import pformat from openpype.pipeline.editorial import ( + get_media_range_with_retimes, otio_range_to_frame_range, otio_range_with_handles ) @@ -29,7 +30,7 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] - workfile_source_duration = instance.data.get("notRetimedFramerange") + workfile_source_duration = instance.data.get("shotDurationFromSource") # get ranges otio_tl_range = otio_clip.range_in_parent() @@ -57,8 +58,15 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): # in case of retimed clip and frame range should not be retimed if workfile_source_duration: - frame_end = frame_start + otio.opentime.to_frames( - otio_src_range.duration, otio_src_range.duration.rate) - 1 + # get available range trimmed with processed retimes + retimed_attributes = get_media_range_with_retimes( + otio_clip, 0, 0) + self.log.debug( + ">> retimed_attributes: {}".format(retimed_attributes)) + media_in = int(retimed_attributes["mediaIn"]) + media_out = int(retimed_attributes["mediaOut"]) + frame_end = frame_start + (media_out - media_in) + 1 + self.log.debug(frame_end) data = { "frameStart": frame_start, diff --git a/openpype/plugins/publish/collect_settings.py b/openpype/plugins/publish/collect_settings.py index d56eabd1b5..a418a6400c 100644 --- a/openpype/plugins/publish/collect_settings.py +++ b/openpype/plugins/publish/collect_settings.py @@ -1,5 +1,8 @@ from pyblish import api -from openpype.api import get_current_project_settings, get_system_settings +from openpype.settings import ( + get_current_project_settings, + get_system_settings, +) class CollectSettings(api.ContextPlugin): diff --git a/openpype/plugins/publish/extract_otio_file.py b/openpype/plugins/publish/extract_otio_file.py index c692205d81..1a6a82117d 100644 --- a/openpype/plugins/publish/extract_otio_file.py +++ b/openpype/plugins/publish/extract_otio_file.py @@ -16,6 +16,8 @@ class ExtractOTIOFile(publish.Extractor): hosts = ["resolve", "hiero", "traypublisher"] def process(self, instance): + if not instance.context.data.get("otioTimeline"): + return # create representation data if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 8972e6ab70..0998e643e6 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -418,6 +418,11 @@ class IntegrateAsset(pyblish.api.InstancePlugin): subset_group = instance.data.get("subsetGroup") if subset_group: data["subsetGroup"] = subset_group + elif existing_subset_doc: + # Preserve previous subset group if new version does not set it + if "subsetGroup" in existing_subset_doc.get("data", {}): + subset_group = existing_subset_doc["data"]["subsetGroup"] + data["subsetGroup"] = subset_group subset_id = None if existing_subset_doc: diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 96d768e1c1..5f4d284740 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -4,8 +4,6 @@ import clique import errno import shutil -from bson.objectid import ObjectId -from pymongo import InsertOne, ReplaceOne import pyblish.api from openpype.client import ( @@ -14,10 +12,15 @@ from openpype.client import ( get_archived_representations, get_representations, ) +from openpype.client.operations import ( + OperationsSession, + new_hero_version_doc, + prepare_hero_version_update_data, + prepare_representation_update_data, +) from openpype.lib import create_hard_link from openpype.pipeline import ( - schema, - legacy_io, + schema ) from openpype.pipeline.publish import get_publish_template_name @@ -187,35 +190,32 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): repre["name"].lower(): repre for repre in old_repres } + op_session = OperationsSession() + + entity_id = None if old_version: - new_version_id = old_version["_id"] - else: - new_version_id = ObjectId() - - new_hero_version = { - "_id": new_version_id, - "version_id": src_version_entity["_id"], - "parent": src_version_entity["parent"], - "type": "hero_version", - "schema": "openpype:hero_version-1.0" - } - schema.validate(new_hero_version) - - # Don't make changes in database until everything is O.K. - bulk_writes = [] + entity_id = old_version["_id"] + new_hero_version = new_hero_version_doc( + src_version_entity["_id"], + src_version_entity["parent"], + entity_id=entity_id + ) if old_version: self.log.debug("Replacing old hero version.") - bulk_writes.append( - ReplaceOne( - {"_id": new_hero_version["_id"]}, - new_hero_version - ) + update_data = prepare_hero_version_update_data( + old_version, new_hero_version + ) + op_session.update_entity( + project_name, + new_hero_version["type"], + old_version["_id"], + update_data ) else: self.log.debug("Creating first hero version.") - bulk_writes.append( - InsertOne(new_hero_version) + op_session.create_entity( + project_name, new_hero_version["type"], new_hero_version ) # Separate old representations into `to replace` and `to delete` @@ -235,7 +235,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): archived_repres = list(get_archived_representations( project_name, # Check what is type of archived representation - version_ids=[new_version_id] + version_ids=[new_hero_version["_id"]] )) archived_repres_by_name = {} for repre in archived_repres: @@ -382,12 +382,15 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): # Replace current representation if repre_name_low in old_repres_to_replace: old_repre = old_repres_to_replace.pop(repre_name_low) + repre["_id"] = old_repre["_id"] - bulk_writes.append( - ReplaceOne( - {"_id": old_repre["_id"]}, - repre - ) + update_data = prepare_representation_update_data( + old_repre, repre) + op_session.update_entity( + project_name, + old_repre["type"], + old_repre["_id"], + update_data ) # Unarchive representation @@ -395,21 +398,21 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): archived_repre = archived_repres_by_name.pop( repre_name_low ) - old_id = archived_repre["old_id"] - repre["_id"] = old_id - bulk_writes.append( - ReplaceOne( - {"old_id": old_id}, - repre - ) + repre["_id"] = archived_repre["old_id"] + update_data = prepare_representation_update_data( + archived_repre, repre) + op_session.update_entity( + project_name, + old_repre["type"], + archived_repre["_id"], + update_data ) # Create representation else: - repre["_id"] = ObjectId() - bulk_writes.append( - InsertOne(repre) - ) + repre.pop("_id", None) + op_session.create_entity(project_name, "representation", + repre) self.path_checks = [] @@ -430,28 +433,22 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): archived_repre = archived_repres_by_name.pop( repre_name_low ) - repre["old_id"] = repre["_id"] - repre["_id"] = archived_repre["_id"] - repre["type"] = archived_repre["type"] - bulk_writes.append( - ReplaceOne( - {"_id": archived_repre["_id"]}, - repre - ) - ) + changes = {"old_id": repre["_id"], + "_id": archived_repre["_id"], + "type": archived_repre["type"]} + op_session.update_entity(project_name, + archived_repre["type"], + archived_repre["_id"], + changes) else: - repre["old_id"] = repre["_id"] - repre["_id"] = ObjectId() + repre["old_id"] = repre.pop("_id") repre["type"] = "archived_representation" - bulk_writes.append( - InsertOne(repre) - ) + op_session.create_entity(project_name, + "archived_representation", + repre) - if bulk_writes: - legacy_io.database[project_name].bulk_write( - bulk_writes - ) + op_session.commit() # Remove backuped previous hero if ( @@ -577,8 +574,11 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): return except OSError as exc: - # re-raise exception if different than cross drive path - if exc.errno != errno.EXDEV: + # re-raise exception if different than + # EXDEV - cross drive path + # EINVAL - wrong format, must be NTFS + self.log.debug("Hardlink failed with errno:'{}'".format(exc.errno)) + if exc.errno not in [errno.EXDEV, errno.EINVAL]: raise shutil.copy(src_path, dst_path) diff --git a/openpype/plugins/publish/integrate_thumbnail.py b/openpype/plugins/publish/integrate_thumbnail.py index d86cec10ad..e7046ba2ea 100644 --- a/openpype/plugins/publish/integrate_thumbnail.py +++ b/openpype/plugins/publish/integrate_thumbnail.py @@ -1,3 +1,13 @@ +""" Integrate Thumbnails for Openpype use in Loaders. + + This thumbnail is different from 'thumbnail' representation which could + be uploaded to Ftrack, or used as any other representation in Loaders to + pull into a scene. + + This one is used only as image describing content of published item and + shows up only in Loader in right column section. +""" + import os import sys import errno @@ -12,7 +22,7 @@ from openpype.client.operations import OperationsSession, new_thumbnail_doc class IntegrateThumbnails(pyblish.api.InstancePlugin): - """Integrate Thumbnails.""" + """Integrate Thumbnails for Openpype use in Loaders.""" label = "Integrate Thumbnails" order = pyblish.api.IntegratorOrder + 0.01 diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py new file mode 100644 index 0000000000..f9e23223e6 --- /dev/null +++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py @@ -0,0 +1,72 @@ +""" Marks thumbnail representation for integrate to DB or not. + + Some hosts produce thumbnail representation, most of them do not create + them explicitly, but they created during extract phase. + + In some cases it might be useful to override implicit setting for host/task + + This plugin needs to run after extract phase, but before integrate.py as + thumbnail is part of review family and integrated there. + + It should be better to control integration of thumbnail in one place than + configure it in multiple places on host implementations. +""" +import pyblish.api + +from openpype.lib.profiles_filtering import filter_profiles + + +class PreIntegrateThumbnails(pyblish.api.InstancePlugin): + """Marks thumbnail representation for integrate to DB or not.""" + + label = "Override Integrate Thumbnail Representations" + order = pyblish.api.IntegratorOrder - 0.1 + families = ["review"] + + integrate_profiles = {} + + def process(self, instance): + repres = instance.data.get("representations") + if not repres: + return + + thumbnail_repre = None + for repre in repres: + if repre["name"] == "thumbnail": + thumbnail_repre = repre + break + + if not thumbnail_repre: + return + + family = instance.data["family"] + subset_name = instance.data["subset"] + host_name = instance.context.data["hostName"] + + anatomy_data = instance.data["anatomyData"] + task = anatomy_data.get("task", {}) + + found_profile = filter_profiles( + self.integrate_profiles, + { + "hosts": host_name, + "task_names": task.get("name"), + "task_types": task.get("type"), + "families": family, + "subsets": subset_name, + }, + logger=self.log + ) + + if not found_profile: + return + + if not found_profile["integrate_thumbnail"]: + if "delete" not in thumbnail_repre["tags"]: + thumbnail_repre["tags"].append("delete") + else: + if "delete" in thumbnail_repre["tags"]: + thumbnail_repre["tags"].remove("delete") + + self.log.debug( + "Thumbnail repre tags {}".format(thumbnail_repre["tags"])) diff --git a/openpype/plugins/publish/validate_version.py b/openpype/plugins/publish/validate_version.py index b94152ef2d..b91633430f 100644 --- a/openpype/plugins/publish/validate_version.py +++ b/openpype/plugins/publish/validate_version.py @@ -10,7 +10,8 @@ class ValidateVersion(pyblish.api.InstancePlugin): order = pyblish.api.ValidatorOrder label = "Validate Version" - hosts = ["nuke", "maya", "houdini", "blender", "standalonepublisher"] + hosts = ["nuke", "maya", "houdini", "blender", "standalonepublisher", + "photoshop", "aftereffects"] optional = False active = True diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 85561495fd..d08a812c61 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -187,7 +187,7 @@ class PypeCommands: (to choose validator for example) """ - from openpype.hosts.webpublisher.cli_functions import ( + from openpype.hosts.webpublisher.publish_functions import ( cli_publish_from_app ) @@ -218,7 +218,7 @@ class PypeCommands: RuntimeError: When there is no path to process. """ - from openpype.hosts.webpublisher.cli_functions import ( + from openpype.hosts.webpublisher.publish_functions import ( cli_publish ) diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 2720e0286d..7acecfaae0 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -36,35 +36,35 @@ "layout" ] }, - "ExtractBlendAnimation": { - "enabled": true, - "optional": true, - "active": true - }, - "ExtractCamera": { - "enabled": true, - "optional": true, - "active": true - }, "ExtractFBX": { "enabled": true, "optional": true, "active": false }, - "ExtractAnimationFBX": { - "enabled": true, - "optional": true, - "active": false - }, "ExtractABC": { "enabled": true, "optional": true, "active": false }, + "ExtractBlendAnimation": { + "enabled": true, + "optional": true, + "active": true + }, + "ExtractAnimationFBX": { + "enabled": true, + "optional": true, + "active": false + }, + "ExtractCamera": { + "enabled": true, + "optional": true, + "active": true + }, "ExtractLayout": { "enabled": true, "optional": true, "active": false } } -} +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index bfdc58d9ee..0f3080ad64 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -1,4 +1,23 @@ { + "imageio": { + "project": { + "colourPolicy": "ACES 1.1", + "frameDepth": "16-bit fp", + "fieldDominance": "PROGRESSIVE" + }, + "profilesMapping": { + "inputs": [ + { + "flameName": "ACEScg", + "ocioName": "ACES - ACEScg" + }, + { + "flameName": "Rec.709 video", + "ocioName": "Output - Rec.709" + } + ] + } + }, "create": { "CreateShotClip": { "hierarchy": "{folder}/{sequence}", @@ -17,7 +36,9 @@ "workfileFrameStart": 1001, "handleStart": 5, "handleEnd": 5, - "includeHandles": false + "includeHandles": false, + "retimedHandles": true, + "retimedFramerange": true } }, "publish": { diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 09b194e21c..cdf861df4a 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -455,7 +455,7 @@ "family_mapping": { "camera": "cam", "look": "look", - "mayaascii": "scene", + "mayaAscii": "scene", "model": "geo", "rig": "rig", "setdress": "setdress", diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json new file mode 100644 index 0000000000..1b4c4c55b5 --- /dev/null +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -0,0 +1,12 @@ +{ + "imageio": { + "ocio": { + "enabled": false, + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + } + } + } +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 14957d2b48..1b7dc7a41a 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -164,6 +164,10 @@ } ] }, + "PreIntegrateThumbnails": { + "enabled": true, + "integrate_profiles": [] + }, "IntegrateSubsetGroup": { "subset_grouping_profiles": [ { @@ -407,7 +411,8 @@ "enabled": false } ], - "extra_folders": [] + "extra_folders": [], + "workfile_lock_profiles": [] }, "loader": { "family_filter_profiles": [ diff --git a/openpype/settings/defaults/project_settings/hiero.json b/openpype/settings/defaults/project_settings/hiero.json index e9e7199330..d2ba697305 100644 --- a/openpype/settings/defaults/project_settings/hiero.json +++ b/openpype/settings/defaults/project_settings/hiero.json @@ -1,4 +1,29 @@ { + "imageio": { + "workfile": { + "ocioConfigName": "nuke-default", + "ocioconfigpath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "workingSpace": "linear", + "sixteenBitLut": "sRGB", + "eightBitLut": "sRGB", + "floatLut": "linear", + "logLut": "Cineon", + "viewerLut": "sRGB", + "thumbnailLut": "sRGB" + }, + "regexInputs": { + "inputs": [ + { + "regex": "[^-a-zA-Z0-9](plateRef).*(?=mp4)", + "colorspace": "sRGB" + } + ] + } + }, "create": { "CreateShotClip": { "hierarchy": "{folder}/{sequence}", diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index b7d2104ba1..1517983569 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,4 +1,5 @@ { + "shelves": [], "create": { "CreateArnoldAss": { "enabled": true, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 38063bc2c1..00e47d37da 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -1,4 +1,27 @@ { + "imageio": { + "colorManagementPreference_v2": { + "enabled": true, + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "renderSpace": "ACEScg", + "displayName": "sRGB", + "viewName": "ACES 1.0 SDR-video" + }, + "colorManagementPreference": { + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "renderSpace": "scene-linear Rec 709/sRGB", + "viewTransform": "sRGB gamma" + } + }, + "mel_workspace": "workspace -fr \"shaders\" \"renderData/shaders\";\nworkspace -fr \"images\" \"renders\";\nworkspace -fr \"particles\" \"particles\";\nworkspace -fr \"mayaAscii\" \"\";\nworkspace -fr \"mayaBinary\" \"\";\nworkspace -fr \"scene\" \"\";\nworkspace -fr \"alembicCache\" \"cache/alembic\";\nworkspace -fr \"renderData\" \"renderData\";\nworkspace -fr \"sourceImages\" \"sourceimages\";\nworkspace -fr \"fileCache\" \"cache/nCache\";\n", "ext_mapping": { "model": "ma", "mayaAscii": "ma", @@ -34,7 +57,7 @@ "RenderSettings": { "apply_render_settings": true, "default_render_image_folder": "renders", - "enable_all_lights": false, + "enable_all_lights": true, "aov_separator": "underscore", "reset_current_frame": false, "arnold_renderer": { @@ -48,7 +71,7 @@ "vray_renderer": { "image_prefix": "maya///", "engine": "1", - "image_format": "png", + "image_format": "exr", "aov_list": [], "additional_options": [] }, @@ -56,7 +79,7 @@ "image_prefix": "maya///", "primary_gi_engine": "0", "secondary_gi_engine": "0", - "image_format": "iff", + "image_format": "exr", "multilayer_exr": true, "force_combine": true, "aov_list": [], @@ -677,25 +700,20 @@ "isolate_view": true, "off_screen": true }, - "PanZoom": { - "pan_zoom": true - }, "Renderer": { "rendererName": "vp2Renderer" }, "Resolution": { "width": 1920, - "height": 1080, - "percent": 1.0, - "mode": "Custom" + "height": 1080 }, "Viewport Options": { "override_viewport_options": true, "displayLights": "default", + "displayTextures": true, "textureMaxResolution": 1024, "renderDepthOfField": true, "shadows": true, - "textures": true, "twoSidedLighting": true, "lineAAEnable": true, "multiSample": 8, @@ -718,7 +736,6 @@ "motionBlurShutterOpenFraction": 0.2, "cameras": false, "clipGhosts": false, - "controlVertices": false, "deformers": false, "dimensions": false, "dynamicConstraints": false, @@ -730,8 +747,7 @@ "grid": false, "hairSystems": true, "handles": false, - "hud": false, - "hulls": false, + "headsUpDisplay": false, "ikHandles": false, "imagePlane": true, "joints": false, @@ -742,7 +758,9 @@ "nCloths": false, "nParticles": false, "nRigids": false, + "controlVertices": false, "nurbsCurves": false, + "hulls": false, "nurbsSurfaces": false, "particleInstancers": false, "pivots": false, @@ -750,7 +768,8 @@ "pluginShapes": false, "polymeshes": true, "strokes": false, - "subdivSurfaces": false + "subdivSurfaces": false, + "textures": false }, "Camera Options": { "displayGateMask": false, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index c3eda2cbb4..e5cbacbda7 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -8,6 +8,197 @@ "build_workfile": "ctrl+alt+b" } }, + "imageio": { + "enabled": false, + "viewer": { + "viewerProcess": "sRGB" + }, + "baking": { + "viewerProcess": "rec709" + }, + "workfile": { + "colorManagement": "Nuke", + "OCIO_config": "nuke-default", + "customOCIOConfigPath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "workingSpaceLUT": "linear", + "monitorLut": "sRGB", + "int8Lut": "sRGB", + "int16Lut": "sRGB", + "logLut": "Cineon", + "floatLut": "linear" + }, + "nodes": { + "requiredNodes": [ + { + "plugins": [ + "CreateWriteRender" + ], + "nukeNodeClass": "Write", + "knobs": [ + { + "type": "text", + "name": "file_type", + "value": "exr" + }, + { + "type": "text", + "name": "datatype", + "value": "16 bit half" + }, + { + "type": "text", + "name": "compression", + "value": "Zip (1 scanline)" + }, + { + "type": "bool", + "name": "autocrop", + "value": true + }, + { + "type": "color_gui", + "name": "tile_color", + "value": [ + 186, + 35, + 35, + 255 + ] + }, + { + "type": "text", + "name": "channels", + "value": "rgb" + }, + { + "type": "text", + "name": "colorspace", + "value": "linear" + }, + { + "type": "bool", + "name": "create_directories", + "value": true + } + ] + }, + { + "plugins": [ + "CreateWritePrerender" + ], + "nukeNodeClass": "Write", + "knobs": [ + { + "type": "text", + "name": "file_type", + "value": "exr" + }, + { + "type": "text", + "name": "datatype", + "value": "16 bit half" + }, + { + "type": "text", + "name": "compression", + "value": "Zip (1 scanline)" + }, + { + "type": "bool", + "name": "autocrop", + "value": true + }, + { + "type": "color_gui", + "name": "tile_color", + "value": [ + 171, + 171, + 10, + 255 + ] + }, + { + "type": "text", + "name": "channels", + "value": "rgb" + }, + { + "type": "text", + "name": "colorspace", + "value": "linear" + }, + { + "type": "bool", + "name": "create_directories", + "value": true + } + ] + }, + { + "plugins": [ + "CreateWriteStill" + ], + "nukeNodeClass": "Write", + "knobs": [ + { + "type": "text", + "name": "file_type", + "value": "tiff" + }, + { + "type": "text", + "name": "datatype", + "value": "16 bit" + }, + { + "type": "text", + "name": "compression", + "value": "Deflate" + }, + { + "type": "color_gui", + "name": "tile_color", + "value": [ + 56, + 162, + 7, + 255 + ] + }, + { + "type": "text", + "name": "channels", + "value": "rgb" + }, + { + "type": "text", + "name": "colorspace", + "value": "sRGB" + }, + { + "type": "bool", + "name": "create_directories", + "value": true + } + ] + } + ], + "overrideNodes": [] + }, + "regexInputs": { + "inputs": [ + { + "regex": "(beauty).*(?=.exr)", + "colorspace": "linear" + } + ] + } + }, "nuke-dirmap": { "enabled": false, "paths": { diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 552c2c9cad..fa0dc7b1c4 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -15,6 +15,12 @@ "CollectInstances": { "flatten_subset_template": "" }, + "CollectReview": { + "publish": true + }, + "CollectVersion": { + "enabled": false + }, "ValidateContainers": { "enabled": true, "optional": true, @@ -34,7 +40,10 @@ "make_image_sequence": false, "max_downscale_size": 8192, "jpg_options": { - "tags": [] + "tags": [ + "review", + "ftrackreview" + ] }, "mov_options": { "tags": [ diff --git a/openpype/settings/defaults/project_settings/webpublisher.json b/openpype/settings/defaults/project_settings/webpublisher.json index cba472514e..09c7d3ec94 100644 --- a/openpype/settings/defaults/project_settings/webpublisher.json +++ b/openpype/settings/defaults/project_settings/webpublisher.json @@ -10,6 +10,7 @@ ], "publish": { "CollectPublishedFiles": { + "sync_next_version": false, "task_type_to_family": { "Animation": [ { diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 30b0a5cbe3..c37c3d299e 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -706,30 +706,28 @@ "icon": "{}/app_icons/fusion.png", "host_name": "fusion", "environment": { - "FUSION_UTILITY_SCRIPTS_SOURCE_DIR": [], - "FUSION_UTILITY_SCRIPTS_DIR": { - "windows": "{PROGRAMDATA}/Blackmagic Design/Fusion/Scripts/Comp", - "darwin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", - "linux": "/opt/Fusion/Scripts/Comp" - }, - "PYTHON36": { + "FUSION_PYTHON3_HOME": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" - }, - "PYTHONPATH": [ - "{PYTHON36}/Lib/site-packages", - "{VIRTUAL_ENV}/Lib/site-packages", - "{PYTHONPATH}" - ], - "PATH": [ - "{PYTHON36}", - "{PYTHON36}/Scripts", - "{PATH}" - ], - "OPENPYPE_LOG_NO_COLORS": "Yes" + } }, "variants": { + "18": { + "executables": { + "windows": [ + "C:\\Program Files\\Blackmagic Design\\Fusion 18\\Fusion.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, "17": { "executables": { "windows": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 80b1baad1b..0b9fbf7470 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -90,6 +90,10 @@ "type": "schema", "name": "schema_project_nuke" }, + { + "type": "schema", + "name": "schema_project_fusion" + }, { "type": "schema", "name": "schema_project_hiero" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index ca62679b3d..73664300aa 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -5,6 +5,69 @@ "label": "Flame", "is_file": true, "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (ImageIO)", + "is_group": true, + "children": [ + { + "key": "project", + "type": "dict", + "label": "Project", + "collapsible": false, + "children": [ + { + "type": "form", + "children": [ + { + "type": "text", + "key": "colourPolicy", + "label": "Colour Policy (name or path)" + }, + { + "type": "text", + "key": "frameDepth", + "label": "Image Depth" + }, + { + "type": "text", + "key": "fieldDominance", + "label": "Field Dominance" + } + ] + } + ] + }, + { + "key": "profilesMapping", + "type": "dict", + "label": "Profile names mapping", + "collapsible": true, + "children": [ + { + "type": "list", + "key": "inputs", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "flameName", + "label": "Flame name" + }, + { + "type": "text", + "key": "ocioName", + "label": "OCIO name" + } + ] + } + } + ] + } + ] + }, { "type": "dict", "collapsible": true, @@ -128,6 +191,16 @@ "type": "boolean", "key": "includeHandles", "label": "Enable handles including" + }, + { + "type": "boolean", + "key": "retimedHandles", + "label": "Enable retimed handles" + }, + { + "type": "boolean", + "key": "retimedFramerange", + "label": "Enable retimed shot frameranges" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json new file mode 100644 index 0000000000..8f98a8173f --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json @@ -0,0 +1,38 @@ +{ + "type": "dict", + "collapsible": true, + "key": "fusion", + "label": "Fusion", + "is_file": true, + "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (ImageIO)", + "collapsible": true, + "children": [ + { + "key": "ocio", + "type": "dict", + "label": "OpenColorIO (OCIO)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Set OCIO variable for Fusion" + }, + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + } + ] + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json index 3108d2197e..9e18522def 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json @@ -5,6 +5,116 @@ "label": "Hiero", "is_file": true, "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (ImageIO)", + "is_group": true, + "collapsible": true, + "children": [ + { + "key": "workfile", + "type": "dict", + "label": "Workfile", + "collapsible": false, + "children": [ + { + "type": "form", + "children": [ + { + "type": "enum", + "key": "ocioConfigName", + "label": "OpenColorIO Config", + "enum_items": [ + { + "nuke-default": "nuke-default" + }, + { + "aces_1.0.3": "aces_1.0.3" + }, + { + "aces_1.1": "aces_1.1" + }, + { + "custom": "custom" + } + ] + }, + { + "type": "path", + "key": "ocioconfigpath", + "label": "Custom OCIO path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "workingSpace", + "label": "Working Space" + }, + { + "type": "text", + "key": "sixteenBitLut", + "label": "16 Bit Files" + }, + { + "type": "text", + "key": "eightBitLut", + "label": "8 Bit Files" + }, + { + "type": "text", + "key": "floatLut", + "label": "Floating Point Files" + }, + { + "type": "text", + "key": "logLut", + "label": "Log Files" + }, + { + "type": "text", + "key": "viewerLut", + "label": "Viewer" + }, + { + "type": "text", + "key": "thumbnailLut", + "label": "Thumbnails" + } + ] + } + ] + }, + { + "key": "regexInputs", + "type": "dict", + "label": "Colorspace on Inputs by regex detection", + "collapsible": true, + "children": [ + { + "type": "list", + "key": "inputs", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "regex", + "label": "Regex" + }, + { + "type": "text", + "key": "colorspace", + "label": "Colorspace" + } + ] + } + } + ] + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json index d8728c0f4b..808f154226 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json @@ -5,6 +5,10 @@ "label": "Houdini", "is_file": true, "children": [ + { + "type": "schema", + "name": "schema_houdini_scriptshelf" + }, { "type": "schema", "name": "schema_houdini_create" @@ -14,4 +18,4 @@ "name": "schema_houdini_publish" } ] -} +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index 816874779e..b2d79797a3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -5,6 +5,83 @@ "label": "Maya", "is_file": true, "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (ImageIO)", + "collapsible": true, + "is_group": true, + "children": [ + { + "key": "colorManagementPreference_v2", + "type": "dict", + "label": "Color Management Preference v2 (Maya 2022+)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Use Color Management Preference v2" + }, + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "displayName", + "label": "Display" + }, + { + "type": "text", + "key": "viewName", + "label": "View" + } + ] + }, + { + "key": "colorManagementPreference", + "type": "dict", + "label": "Color Management Preference (legacy)", + "collapsible": true, + "children": [ + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "viewTransform", + "label": "Viewer Transform" + } + ] + } + ] + }, + { + "type": "text", + "multiline" : true, + "use_label_wrap": true, + "key": "mel_workspace", + "label": "Maya MEL Workspace" + }, { "type": "dict-modifiable", "key": "ext_mapping", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json index 7cf82b9e69..154eca254b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_nuke.json @@ -46,6 +46,10 @@ } ] }, + { + "type": "schema", + "name": "schema_nuke_imageio" + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 7aa49c99a4..b768db30ee 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -131,6 +131,35 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectReview", + "label": "Collect Review", + "children": [ + { + "type": "boolean", + "key": "publish", + "label": "Active" + } + ] + }, + { + "type": "dict", + "key": "CollectVersion", + "label": "Collect Version", + "children": [ + { + "type": "label", + "label": "Synchronize version for image and review instances by workfile version." + }, + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", 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 2ef7a05b21..a81a403bcb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json @@ -49,6 +49,19 @@ "key": "CollectPublishedFiles", "label": "Collect Published Files", "children": [ + { + "type": "label", + "label": "Select if all versions of published items should be kept same. (As max(published) + 1.)" + }, + { + "type": "boolean", + "key": "sync_next_version", + "label": "Sync next publish version" + }, + { + "type": "label", + "label": "Configure resulting family and tags on representation based on uploaded file and task.
Eg. '.png' is uploaded >> create instance of 'render' family
'Create review' in Tags >> mark representation to create review from." + }, { "type": "dict-modifiable", "collapsible": true, @@ -74,6 +87,9 @@ "label": "Extensions", "object_type": "text" }, + { + "type": "separator" + }, { "type": "list", "key": "families", @@ -84,9 +100,6 @@ "type": "schema", "name": "schema_representation_tags" }, - { - "type": "separator" - }, { "type": "text", "key": "result_family", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index ef8c907dda..93b6adae6b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -1,10 +1,14 @@ { "type": "dict", "key": "imageio", - "label": "Color Management and Output Formats", + "label": "Color Management and Output Formats (Deprecated)", "is_file": true, "is_group": true, "children": [ + { + "type": "label", + "label": "These settings are deprecated and have moved to: project_settings/{app}/imageio.
You can right click to copy each host's values and paste them to apply to each host as needed.
Changing these values here will not do anything." + }, { "key": "hiero", "type": "dict", 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 297f96aa8c..773dea1229 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 @@ -555,6 +555,73 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "PreIntegrateThumbnails", + "label": "Override Integrate Thumbnail Representations", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "Explicitly set if Thumbnail representation should be integrated into DB.
If no matching profile set, existing state from Host implementation is kept." + }, + { + "type": "list", + "key": "integrate_profiles", + "label": "Integrate profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "subsets", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "integrate_thumbnail", + "label": "Integrate thumbnail" + } + ] + } + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index c919cd73c5..ba446135e2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -238,6 +238,31 @@ } ] } + }, + { + "type": "list", + "key": "workfile_lock_profiles", + "label": "Workfile lock profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "hosts-enum", + "key": "host_name", + "label": "Hosts", + "multiselection": true + }, + { + "type": "splitter" + }, + { + "key": "enabled", + "label": "Enabled", + "type": "boolean" + } + ] + } } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json new file mode 100644 index 0000000000..bab9b604b4 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -0,0 +1,71 @@ +{ + "type": "list", + "key": "shelves", + "label": "Shelves Manager", + "is_group": true, + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path (optional)", + "multipath": false, + "multiplatform": true + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] + } + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json index 7a40f349cc..62c33f55fc 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_capture.json @@ -94,18 +94,6 @@ } ] }, - - { - "type": "dict", - "key": "PanZoom", - "children": [ - { - "type": "boolean", - "key": "pan_zoom", - "label": " Pan Zoom" - } - ] - }, { "type": "splitter" }, @@ -153,19 +141,6 @@ "decimal": 0, "minimum": 0, "maximum": 99999 - }, - { - "type": "number", - "key": "percent", - "label": "percent", - "decimal": 1, - "minimum": 0, - "maximum": 200 - }, - { - "type": "text", - "key": "mode", - "label": "Mode" } ] }, @@ -195,6 +170,11 @@ { "nolights": "No Lights"} ] }, + { + "type": "boolean", + "key": "displayTextures", + "label": "Display Textures" + }, { "type": "number", "key": "textureMaxResolution", @@ -217,11 +197,6 @@ "key": "shadows", "label": "Display Shadows" }, - { - "type": "boolean", - "key": "textures", - "label": "Display Textures" - }, { "type": "boolean", "key": "twoSidedLighting", @@ -369,120 +344,114 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Show" + }, { "type": "boolean", "key": "cameras", - "label": "cameras" + "label": "Cameras" }, { "type": "boolean", "key": "clipGhosts", - "label": "clipGhosts" - }, - { - "type": "boolean", - "key": "controlVertices", - "label": "controlVertices" + "label": "Clip Ghosts" }, { "type": "boolean", "key": "deformers", - "label": "deformers" + "label": "Deformers" }, { "type": "boolean", "key": "dimensions", - "label": "dimensions" + "label": "Dimensions" }, { "type": "boolean", "key": "dynamicConstraints", - "label": "dynamicConstraints" + "label": "Dynamic Constraints" }, { "type": "boolean", "key": "dynamics", - "label": "dynamics" + "label": "Dynamics" }, { "type": "boolean", "key": "fluids", - "label": "fluids" + "label": "Fluids" }, { "type": "boolean", "key": "follicles", - "label": "follicles" + "label": "Follicles" }, { "type": "boolean", "key": "gpuCacheDisplayFilter", - "label": "gpuCacheDisplayFilter" + "label": "GPU Cache" }, { "type": "boolean", "key": "greasePencils", - "label": "greasePencils" + "label": "Grease Pencil" }, { "type": "boolean", "key": "grid", - "label": "grid" + "label": "Grid" }, { "type": "boolean", "key": "hairSystems", - "label": "hairSystems" + "label": "Hair Systems" }, { "type": "boolean", "key": "handles", - "label": "handles" + "label": "Handles" }, { "type": "boolean", - "key": "hud", - "label": "hud" - }, - { - "type": "boolean", - "key": "hulls", - "label": "hulls" + "key": "headsUpDisplay", + "label": "HUD" }, { "type": "boolean", "key": "ikHandles", - "label": "ikHandles" + "label": "IK Handles" }, { "type": "boolean", "key": "imagePlane", - "label": "imagePlane" + "label": "Image Planes" }, { "type": "boolean", "key": "joints", - "label": "joints" + "label": "Joints" }, { "type": "boolean", "key": "lights", - "label": "lights" + "label": "Lights" }, { "type": "boolean", "key": "locators", - "label": "locators" + "label": "Locators" }, { "type": "boolean", "key": "manipulators", - "label": "manipulators" + "label": "Manipulators" }, { "type": "boolean", "key": "motionTrails", - "label": "motionTrails" + "label": "Motion Trails" }, { "type": "boolean", @@ -499,50 +468,65 @@ "key": "nRigids", "label": "nRigids" }, + { + "type": "boolean", + "key": "controlVertices", + "label": "NURBS CVs" + }, { "type": "boolean", "key": "nurbsCurves", - "label": "nurbsCurves" + "label": "NURBS Curves" + }, + { + "type": "boolean", + "key": "hulls", + "label": "NURBS Hulls" }, { "type": "boolean", "key": "nurbsSurfaces", - "label": "nurbsSurfaces" + "label": "NURBS Surfaces" }, { "type": "boolean", "key": "particleInstancers", - "label": "particleInstancers" + "label": "Particle Instancers" }, { "type": "boolean", "key": "pivots", - "label": "pivots" + "label": "Pivots" }, { "type": "boolean", "key": "planes", - "label": "planes" + "label": "Planes" }, { "type": "boolean", "key": "pluginShapes", - "label": "pluginShapes" + "label": "Plugin Shapes" }, { "type": "boolean", "key": "polymeshes", - "label": "polymeshes" + "label": "Polygons" }, { "type": "boolean", "key": "strokes", - "label": "strokes" + "label": "Strokes" }, { "type": "boolean", "key": "subdivSurfaces", - "label": "subdivSurfaces" + "label": "Subdiv Surfaces" + }, + { + "type": "boolean", + "key": "textures", + "label": "Texture Placements" } ] }, @@ -555,47 +539,47 @@ { "type": "boolean", "key": "displayGateMask", - "label": "displayGateMask" + "label": "Display Gate Mask" }, { "type": "boolean", "key": "displayResolution", - "label": "displayResolution" + "label": "Display Resolution" }, { "type": "boolean", "key": "displayFilmGate", - "label": "displayFilmGate" + "label": "Display Film Gate" }, { "type": "boolean", "key": "displayFieldChart", - "label": "displayFieldChart" + "label": "Display Field Chart" }, { "type": "boolean", "key": "displaySafeAction", - "label": "displaySafeAction" + "label": "Display Safe Action" }, { "type": "boolean", "key": "displaySafeTitle", - "label": "displaySafeTitle" + "label": "Display Safe Title" }, { "type": "boolean", "key": "displayFilmPivot", - "label": "displayFilmPivot" + "label": "Display Film Pivot" }, { "type": "boolean", "key": "displayFilmOrigin", - "label": "displayFilmOrigin" + "label": "Display Film Origin" }, { "type": "number", "key": "overscan", - "label": "overscan", + "label": "Overscan", "decimal": 1, "minimum": 0, "maximum": 10 diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json index 6ee02ca78f..0cbb684fc6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_render_settings.json @@ -140,7 +140,7 @@ }, { "type": "label", - "label": "Add additional options - put attribute and value, like AASamples" + "label": "Add additional options - put attribute and value, like defaultArnoldRenderOptions.AASamples = 4" }, { "type": "dict-modifiable", @@ -276,7 +276,7 @@ }, { "type": "label", - "label": "Add additional options - put attribute and value, like aaFilterSize" + "label": "Add additional options - put attribute and value, like vraySettings.aaFilterSize = 1.5" }, { "type": "dict-modifiable", @@ -405,7 +405,7 @@ }, { "type": "label", - "label": "Add additional options - put attribute and value, like reflectionMaxTraceDepth" + "label": "Add additional options - put attribute and value, like redshiftOptions.reflectionMaxTraceDepth = 3" }, { "type": "dict-modifiable", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json new file mode 100644 index 0000000000..52db853ef6 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json @@ -0,0 +1,254 @@ +{ + "key": "imageio", + "type": "dict", + "label": "Color Management (ImageIO)", + "checkbox_key": "enabled", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "key": "viewer", + "type": "dict", + "label": "Viewer", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "viewerProcess", + "label": "Viewer Process" + } + ] + }, + { + "key": "baking", + "type": "dict", + "label": "Extract-review baking profile", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "viewerProcess", + "label": "Viewer Process" + } + ] + }, + { + "key": "workfile", + "type": "dict", + "label": "Workfile", + "collapsible": false, + "children": [ + { + "type": "form", + "children": [ + { + "type": "enum", + "key": "colorManagement", + "label": "color management", + "enum_items": [ + { + "Nuke": "Nuke" + }, + { + "OCIO": "OCIO" + } + ] + }, + { + "type": "enum", + "key": "OCIO_config", + "label": "OpenColorIO Config", + "enum_items": [ + { + "nuke-default": "nuke-default" + }, + { + "spi-vfx": "spi-vfx" + }, + { + "spi-anim": "spi-anim" + }, + { + "aces_0.1.1": "aces_0.1.1" + }, + { + "aces_0.7.1": "aces_0.7.1" + }, + { + "aces_1.0.1": "aces_1.0.1" + }, + { + "aces_1.0.3": "aces_1.0.3" + }, + { + "aces_1.1": "aces_1.1" + }, + { + "aces_1.2": "aces_1.2" + }, + { + "custom": "custom" + } + ] + }, + { + "type": "path", + "key": "customOCIOConfigPath", + "label": "Custom OCIO config path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "workingSpaceLUT", + "label": "Working Space" + }, + { + "type": "text", + "key": "monitorLut", + "label": "monitor" + }, + { + "type": "text", + "key": "int8Lut", + "label": "8-bit files" + }, + { + "type": "text", + "key": "int16Lut", + "label": "16-bit files" + }, + { + "type": "text", + "key": "logLut", + "label": "log files" + }, + { + "type": "text", + "key": "floatLut", + "label": "float files" + } + ] + } + ] + }, + { + "key": "nodes", + "type": "dict", + "label": "Nodes", + "collapsible": true, + "children": [ + { + "key": "requiredNodes", + "type": "list", + "label": "Plugin required", + "object_type": { + "type": "dict", + "children": [ + { + "type": "list", + "key": "plugins", + "label": "Used in plugins", + "object_type": { + "type": "text", + "key": "pluginClass" + } + }, + { + "type": "text", + "key": "nukeNodeClass", + "label": "Nuke Node Class" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Knobs", + "key": "knobs" + } + ] + } + + ] + } + }, + { + "type": "splitter" + }, + { + "type": "list", + "key": "overrideNodes", + "label": "Plugin's node overrides", + "object_type": { + "type": "dict", + "children": [ + { + "type": "list", + "key": "plugins", + "label": "Used in plugins", + "object_type": { + "type": "text", + "key": "pluginClass" + } + }, + { + "type": "text", + "key": "nukeNodeClass", + "label": "Nuke Node Class" + }, + { + "key": "subsets", + "label": "Subsets", + "type": "list", + "object_type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Knobs overrides", + "key": "knobs" + } + ] + } + ] + } + } + ] + }, + { + "key": "regexInputs", + "type": "dict", + "label": "Colorspace on Inputs by regex detection", + "collapsible": true, + "children": [ + { + "type": "list", + "key": "inputs", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "regex", + "label": "Regex" + }, + { + "type": "text", + "key": "colorspace", + "label": "Colorspace" + } + ] + } + } + ] + } + ] +} \ No newline at end of file diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index b2a1a4ce6c..473fb42bb5 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -1,4 +1,5 @@ import os +import copy import json import collections import six @@ -19,6 +20,9 @@ class _Cache: disabled_entity_icon_color = None deprecated_entity_font_color = None + colors_data = None + objected_colors = None + def get_style_image_path(image_name): # All filenames are lowered @@ -46,8 +50,11 @@ def _get_colors_raw_data(): def get_colors_data(): """Only color data from stylesheet data.""" - data = _get_colors_raw_data() - return data.get("color") or {} + if _Cache.colors_data is None: + data = _get_colors_raw_data() + color_data = data.get("color") or {} + _Cache.colors_data = color_data + return copy.deepcopy(_Cache.colors_data) def _convert_color_values_to_objects(value): @@ -75,17 +82,38 @@ def _convert_color_values_to_objects(value): return parse_color(value) -def get_objected_colors(): +def get_objected_colors(*keys): """Colors parsed from stylesheet data into color definitions. + You can pass multiple arguments to get a key from the data dict's colors. + Because this functions returns a deep copy of the cached data this allows + a much smaller dataset to be copied and thus result in a faster function. + It is however a micro-optimization in the area of 0.001s and smaller. + + For example: + >>> get_colors_data() # copy of full colors dict + >>> get_colors_data("font") + >>> get_colors_data("loader", "asset-view") + + Args: + *keys: Each key argument will return a key nested deeper in the + objected colors data. + Returns: - dict: Parsed color objects by keys in data. + Any: Parsed color objects by keys in data. """ - colors_data = get_colors_data() - output = {} - for key, value in colors_data.items(): - output[key] = _convert_color_values_to_objects(value) - return output + if _Cache.objected_colors is None: + colors_data = get_colors_data() + output = {} + for key, value in colors_data.items(): + output[key] = _convert_color_values_to_objects(value) + + _Cache.objected_colors = output + + output = _Cache.objected_colors + for key in keys: + output = output[key] + return copy.deepcopy(output) def _load_stylesheet(): diff --git a/openpype/style/color_defs.py b/openpype/style/color_defs.py index 0f4e145ca0..f1eab38c24 100644 --- a/openpype/style/color_defs.py +++ b/openpype/style/color_defs.py @@ -296,7 +296,7 @@ class HSLColor: if "%" in sat_str: sat = float(sat_str.rstrip("%")) / 100 else: - sat = float(sat) + sat = float(sat_str) if "%" in light_str: light = float(light_str.rstrip("%")) / 100 @@ -337,8 +337,8 @@ class HSLAColor: as float (0-1 range). Examples: - "hsl(27, 0.7, 0.3)" - "hsl(27, 70%, 30%)" + "hsla(27, 0.7, 0.3, 0.5)" + "hsla(27, 70%, 30%, 0.5)" """ def __init__(self, value): modified_color = value.lower().strip() @@ -350,7 +350,7 @@ class HSLAColor: if "%" in sat_str: sat = float(sat_str.rstrip("%")) / 100 else: - sat = float(sat) + sat = float(sat_str) if "%" in light_str: light = float(light_str.rstrip("%")) / 100 diff --git a/openpype/style/data.json b/openpype/style/data.json index 15d9472e3e..fef69071ed 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -20,7 +20,7 @@ "color": { "font": "#D3D8DE", "font-hover": "#F0F2F5", - "font-disabled": "#99A3B2", + "font-disabled": "#5b6779", "font-view-selection": "#ffffff", "font-view-hover": "#F0F2F5", @@ -89,8 +89,10 @@ }, "publisher": { "error": "#AA5050", + "crash": "#FF6432", "success": "#458056", "warning": "#ffc671", + "tab-bg": "#16191d", "list-view-group": { "bg": "#434a56", "bg-hover": "rgba(168, 175, 189, 0.3)", diff --git a/openpype/style/style.css b/openpype/style/style.css index 72d12a9230..4d13dc7c89 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -856,6 +856,33 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { } /* New Create/Publish UI */ +PublisherTabsWidget { + background: {color:publisher:tab-bg}; +} + +PublisherTabBtn { + border-radius: 0px; + background: {color:bg-inputs}; + font-size: 9pt; + font-weight: regular; + padding: 0.5em 1em 0.5em 1em; +} + +PublisherTabBtn:disabled { + background: {color:bg-inputs}; +} + +PublisherTabBtn:hover { + background: {color:bg-buttons}; +} + +PublisherTabBtn[active="1"] { + background: {color:bg}; +} +PublisherTabBtn[active="1"]:hover { + background: {color:bg}; +} + #CreatorDetailedDescription { padding-left: 5px; padding-right: 5px; @@ -865,18 +892,16 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { } #CreateDialogHelpButton { - background: rgba(255, 255, 255, 31); + background: {color:bg-buttons}; border-top-left-radius: 0.2em; border-bottom-left-radius: 0.2em; border-top-right-radius: 0; border-bottom-right-radius: 0; - font-size: 10pt; font-weight: bold; - padding: 0px; } #CreateDialogHelpButton:hover { - background: rgba(255, 255, 255, 63); + background: {color:bg-button-hover}; } #CreateDialogHelpButton QWidget { background: transparent; @@ -944,19 +969,8 @@ VariantInputsWidget QToolButton { color: {color:publisher:error}; } -#PublishFrame { - background: rgba(0, 0, 0, 127); -} -#PublishFrame[state="1"] { - background: rgb(22, 25, 29); -} -#PublishFrame[state="2"] { - background: {color:bg}; -} - #PublishInfoFrame { background: {color:bg}; - border: 2px solid black; border-radius: 0.3em; } @@ -965,7 +979,7 @@ VariantInputsWidget QToolButton { } #PublishInfoFrame[state="0"] { - background: {color:publisher:error}; + background: {color:publisher:crash}; } #PublishInfoFrame[state="1"] { @@ -989,6 +1003,11 @@ VariantInputsWidget QToolButton { font-size: 13pt; } +ValidationArtistMessage QLabel { + font-size: 20pt; + font-weight: bold; +} + #ValidationActionButton { border-radius: 0.2em; padding: 4px 6px 4px 6px; @@ -1005,17 +1024,16 @@ VariantInputsWidget QToolButton { } #ValidationErrorTitleFrame { - background: {color:bg-inputs}; - border-left: 4px solid transparent; + border-radius: 0.2em; + background: {color:bg-buttons}; } #ValidationErrorTitleFrame:hover { - border-left-color: {color:border}; + background: {color:bg-buttons-hover}; } #ValidationErrorTitleFrame[selected="1"] { - background: {color:bg}; - border-left-color: {palette:blue-light}; + background: {color:bg-view-selection}; } #ValidationErrorInstanceList { diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index a3937d6a40..e2396ed29e 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -6,7 +6,7 @@ from Qt import QtWidgets, QtCore from openpype.client import get_asset_by_name, get_subsets from openpype import style -from openpype.api import get_current_project_settings +from openpype.settings import get_current_project_settings from openpype.tools.utils.lib import qt_app_context from openpype.pipeline import legacy_io from openpype.pipeline.create import ( diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index 546bda1c34..34d06f72cc 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -4,8 +4,9 @@ from Qt import QtWidgets, QtGui from openpype import PLUGINS_DIR from openpype import style -from openpype.api import Logger, resources +from openpype import resources from openpype.lib import ( + Logger, ApplictionExecutableNotFound, ApplicationLaunchFailed ) diff --git a/openpype/tools/launcher/lib.py b/openpype/tools/launcher/lib.py index c1392b7b8f..68e57c6b92 100644 --- a/openpype/tools/launcher/lib.py +++ b/openpype/tools/launcher/lib.py @@ -1,7 +1,7 @@ import os from Qt import QtGui import qtawesome -from openpype.api import resources +from openpype import resources ICON_CACHE = {} NOT_FOUND = type("NotFound", (object, ), {}) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index dab6949613..a9eaa932bb 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -4,7 +4,7 @@ import logging from Qt import QtWidgets, QtCore, QtGui from openpype import style -from openpype.api import resources +from openpype import resources from openpype.pipeline import AvalonMongoDB import qtawesome diff --git a/openpype/tools/loader/delegates.py b/openpype/tools/loader/delegates.py new file mode 100644 index 0000000000..e6663d48f1 --- /dev/null +++ b/openpype/tools/loader/delegates.py @@ -0,0 +1,28 @@ +from Qt import QtWidgets, QtGui, QtCore + + +class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for Loaded in Scene state columns. + + Shows "yes" or "no" for True or False values + Colorizes green or dark grey based on True or False values + + """ + + def __init__(self, *args, **kwargs): + super(LoadedInSceneDelegate, self).__init__(*args, **kwargs) + self._colors = { + True: QtGui.QColor(80, 170, 80), + False: QtGui.QColor(90, 90, 90) + } + + def displayText(self, value, locale): + return "yes" if value else "no" + + def initStyleOption(self, option, index): + super(LoadedInSceneDelegate, self).initStyleOption(option, index) + + # Colorize based on value + value = index.data(QtCore.Qt.DisplayRole) + color = self._colors[bool(value)] + option.palette.setBrush(QtGui.QPalette.Text, color) diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index 929e497890..77a8669c46 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -17,6 +17,7 @@ from openpype.client import ( get_representations ) from openpype.pipeline import ( + registered_host, HeroVersionType, schema, ) @@ -24,6 +25,7 @@ from openpype.pipeline import ( from openpype.style import get_default_entity_icon_color from openpype.tools.utils.models import TreeModel, Item from openpype.tools.utils import lib +from openpype.host import ILoadHost from openpype.modules import ModulesManager from openpype.tools.utils.constants import ( @@ -136,6 +138,7 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): "duration", "handles", "step", + "loaded_in_scene", "repre_info" ] @@ -150,6 +153,7 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): "duration": "Duration", "handles": "Handles", "step": "Step", + "loaded_in_scene": "In scene", "repre_info": "Availability" } @@ -231,8 +235,14 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): self._doc_fetching_stop = False self._doc_payload = {} - self.doc_fetched.connect(self._on_doc_fetched) + self._host = registered_host() + self._loaded_representation_ids = set() + # Refresh loaded scene containers only every 3 seconds at most + self._host_loaded_refresh_timeout = 3 + self._host_loaded_refresh_time = 0 + + self.doc_fetched.connect(self._on_doc_fetched) self.refresh() def get_item_by_id(self, item_id): @@ -474,6 +484,27 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): last_versions_by_subset_id[subset_id] = hero_version + # Check loaded subsets + loaded_subset_ids = set() + ids = self._loaded_representation_ids + if ids: + if self._doc_fetching_stop: + return + + # Get subset ids from loaded representations in workfile + # todo: optimize with aggregation query to distinct subset id + representations = get_representations(project_name, + representation_ids=ids, + fields=["parent"]) + version_ids = set(repre["parent"] for repre in representations) + versions = get_versions(project_name, + version_ids=version_ids, + fields=["parent"]) + loaded_subset_ids = set(version["parent"] for version in versions) + + if self._doc_fetching_stop: + return + repre_info_by_version_id = {} if self.sync_server.enabled: versions_by_id = {} @@ -501,7 +532,8 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): "subset_docs_by_id": subset_docs_by_id, "subset_families": subset_families, "last_versions_by_subset_id": last_versions_by_subset_id, - "repre_info_by_version_id": repre_info_by_version_id + "repre_info_by_version_id": repre_info_by_version_id, + "subsets_loaded_by_id": loaded_subset_ids } self.doc_fetched.emit() @@ -533,6 +565,20 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): self.doc_fetched.emit() return + # Collect scene container representations to compare loaded state + # This runs in the main thread because it involves the host DCC + if self._host: + time_since_refresh = time.time() - self._host_loaded_refresh_time + if time_since_refresh > self._host_loaded_refresh_timeout: + if isinstance(self._host, ILoadHost): + containers = self._host.get_containers() + else: + containers = self._host.ls() + + repre_ids = {con.get("representation") for con in containers} + self._loaded_representation_ids = repre_ids + self._host_loaded_refresh_time = time.time() + self.fetch_subset_and_version() def _on_doc_fetched(self): @@ -554,6 +600,10 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): "repre_info_by_version_id" ) + subsets_loaded_by_id = self._doc_payload.get( + "subsets_loaded_by_id" + ) + if ( asset_docs_by_id is None or subset_docs_by_id is None @@ -568,7 +618,8 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id, - repre_info_by_version_id + repre_info_by_version_id, + subsets_loaded_by_id ) self.endResetModel() self.refreshed.emit(True) @@ -596,8 +647,12 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): return merge_group def _fill_subset_items( - self, asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id, - repre_info_by_version_id + self, + asset_docs_by_id, + subset_docs_by_id, + last_versions_by_subset_id, + repre_info_by_version_id, + subsets_loaded_by_id ): _groups_tuple = self.groups_config.split_subsets_for_groups( subset_docs_by_id.values(), self._grouping @@ -621,6 +676,35 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): "index": self.index(group_item.row(), 0) } + def _add_subset_item(subset_doc, parent_item, parent_index): + last_version = last_versions_by_subset_id.get( + subset_doc["_id"] + ) + # do not show subset without version + if not last_version: + return + + data = copy.deepcopy(subset_doc) + data["subset"] = subset_doc["name"] + + asset_id = subset_doc["parent"] + data["asset"] = asset_docs_by_id[asset_id]["name"] + + data["last_version"] = last_version + data["loaded_in_scene"] = subset_doc["_id"] in subsets_loaded_by_id + + # Sync server data + data.update( + self._get_last_repre_info(repre_info_by_version_id, + last_version["_id"])) + + item = Item() + item.update(data) + self.add_child(item, parent_item) + + index = self.index(item.row(), 0, parent_index) + self.set_version(index, last_version) + subset_counter = 0 for group_name, subset_docs_by_name in subset_docs_by_group.items(): parent_item = group_item_by_name[group_name]["item"] @@ -643,31 +727,9 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): _parent_index = parent_index for subset_doc in subset_docs: - asset_id = subset_doc["parent"] - - data = copy.deepcopy(subset_doc) - data["subset"] = subset_name - data["asset"] = asset_docs_by_id[asset_id]["name"] - - last_version = last_versions_by_subset_id.get( - subset_doc["_id"] - ) - data["last_version"] = last_version - - # do not show subset without version - if not last_version: - continue - - data.update( - self._get_last_repre_info(repre_info_by_version_id, - last_version["_id"])) - - item = Item() - item.update(data) - self.add_child(item, _parent_item) - - index = self.index(item.row(), 0, _parent_index) - self.set_version(index, last_version) + _add_subset_item(subset_doc, + parent_item=_parent_item, + parent_index=_parent_index) for subset_name in sorted(subset_docs_without_group.keys()): subset_docs = subset_docs_without_group[subset_name] @@ -682,31 +744,9 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): subset_counter += 1 for subset_doc in subset_docs: - asset_id = subset_doc["parent"] - - data = copy.deepcopy(subset_doc) - data["subset"] = subset_name - data["asset"] = asset_docs_by_id[asset_id]["name"] - - last_version = last_versions_by_subset_id.get( - subset_doc["_id"] - ) - data["last_version"] = last_version - - # do not show subset without version - if not last_version: - continue - - data.update( - self._get_last_repre_info(repre_info_by_version_id, - last_version["_id"])) - - item = Item() - item.update(data) - self.add_child(item, parent_item) - - index = self.index(item.row(), 0, parent_index) - self.set_version(index, last_version) + _add_subset_item(subset_doc, + parent_item=parent_item, + parent_index=parent_index) def data(self, index, role): if not index.isValid(): diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index cbf5720803..d37ce500e0 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -58,6 +58,7 @@ from .model import ( ITEM_ID_ROLE ) from . import lib +from .delegates import LoadedInSceneDelegate from openpype.tools.utils.constants import ( LOCAL_PROVIDER_ROLE, @@ -169,6 +170,7 @@ class SubsetWidget(QtWidgets.QWidget): ("duration", 60), ("handles", 55), ("step", 10), + ("loaded_in_scene", 25), ("repre_info", 65) ) @@ -234,6 +236,10 @@ class SubsetWidget(QtWidgets.QWidget): column = model.Columns.index("repre_info") view.setItemDelegateForColumn(column, avail_delegate) + loaded_in_scene_delegate = LoadedInSceneDelegate(view) + column = model.Columns.index("loaded_in_scene") + view.setItemDelegateForColumn(column, loaded_in_scene_delegate) + layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(top_bar_layout) @@ -1250,7 +1256,11 @@ class RepresentationWidget(QtWidgets.QWidget): repre_doc["parent"] for repre_doc in repre_docs ] - version_docs = get_versions(project_name, version_ids=version_ids) + version_docs = get_versions( + project_name, + version_ids=version_ids, + hero=True + ) version_docs_by_id = {} version_docs_by_subset_id = collections.defaultdict(list) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index b48bb61386..b4c89f221f 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -1,22 +1,17 @@ import os import copy -import inspect import logging import traceback import collections -import weakref -try: - from weakref import WeakMethod -except Exception: - from openpype.lib.python_2_comp import WeakMethod - import pyblish.api from openpype.client import get_assets +from openpype.lib.events import EventSystem from openpype.pipeline import ( PublishValidationError, registered_host, + legacy_io, ) from openpype.pipeline.create import CreateContext @@ -107,17 +102,13 @@ class AssetDocsCache: self._asset_docs = None self._task_names_by_asset_name = {} - @property - def dbcon(self): - return self._controller.dbcon - def reset(self): self._asset_docs = None self._task_names_by_asset_name = {} def _query(self): if self._asset_docs is None: - project_name = self.dbcon.active_project() + project_name = self._controller.project_name asset_docs = get_assets( project_name, fields=self.projection.keys() ) @@ -360,11 +351,15 @@ class PublisherController: dbcon (AvalonMongoDB): Connection to mongo with context. headless (bool): Headless publishing. ATM not implemented or used. """ + def __init__(self, dbcon=None, headless=False): self.log = logging.getLogger("PublisherController") self.host = registered_host() self.headless = headless + # Inner event system of controller + self._event_system = EventSystem() + self.create_context = CreateContext( self.host, dbcon, headless=headless, reset=False ) @@ -405,18 +400,6 @@ class PublisherController: # Plugin iterator self._main_thread_iter = None - # Variables where callbacks are stored - self._instances_refresh_callback_refs = set() - self._plugins_refresh_callback_refs = set() - - self._publish_reset_callback_refs = set() - self._publish_started_callback_refs = set() - self._publish_validated_callback_refs = set() - self._publish_stopped_callback_refs = set() - - self._publish_instance_changed_callback_refs = set() - self._publish_plugin_changed_callback_refs = set() - # State flags to prevent executing method which is already in progress self._resetting_plugins = False self._resetting_instances = False @@ -426,13 +409,42 @@ class PublisherController: @property def project_name(self): - """Current project context.""" - return self.dbcon.Session["AVALON_PROJECT"] + """Current project context defined by host. + + Returns: + str: Project name. + """ + + if not hasattr(self.host, "get_current_context"): + return legacy_io.active_project() + + return self.host.get_current_context()["project_name"] @property - def dbcon(self): - """Pointer to AvalonMongoDB in creator context.""" - return self.create_context.dbcon + def current_asset_name(self): + """Current context asset name defined by host. + + Returns: + Union[str, None]: Asset name or None if asset is not set. + """ + + if not hasattr(self.host, "get_current_context"): + return legacy_io.Session["AVALON_ASSET"] + + return self.host.get_current_context()["asset_name"] + + @property + def current_task_name(self): + """Current context task name defined by host. + + Returns: + Union[str, None]: Task name or None if task is not set. + """ + + if not hasattr(self.host, "get_current_context"): + return legacy_io.Session["AVALON_TASK"] + + return self.host.get_current_context()["task_name"] @property def instances(self): @@ -464,58 +476,35 @@ class PublisherController: """Publish plugins with possible attribute definitions.""" return self.create_context.plugins_with_defs - def _create_reference(self, callback): - if inspect.ismethod(callback): - ref = WeakMethod(callback) - elif callable(callback): - ref = weakref.ref(callback) - else: - raise TypeError("Expected function or method got {}".format( - str(type(callback)) - )) - return ref + @property + def event_system(self): + """Inner event system for publisher controller. - def add_instances_refresh_callback(self, callback): - """Callbacks triggered on instances refresh.""" - ref = self._create_reference(callback) - self._instances_refresh_callback_refs.add(ref) + Known topics: + "show.detailed.help" - Detailed help requested (UI related). + "show.card.message" - Show card message request (UI related). + "instances.refresh.finished" - Instances are refreshed. + "plugins.refresh.finished" - Plugins refreshed. + "publish.reset.finished" - Controller reset finished. + "publish.process.started" - Publishing started. Can be started from + paused state. + "publish.process.validated" - Publishing passed validation. + "publish.process.stopped" - Publishing stopped/paused process. + "publish.process.plugin.changed" - Plugin state has changed. + "publish.process.instance.changed" - Instance state has changed. - def add_plugins_refresh_callback(self, callback): - """Callbacks triggered on plugins refresh.""" - ref = self._create_reference(callback) - self._plugins_refresh_callback_refs.add(ref) + Returns: + EventSystem: Event system which can trigger callbacks for topics. + """ + + return self._event_system + + def _emit_event(self, topic, data=None): + if data is None: + data = {} + self._event_system.emit(topic, data, "controller") # --- Publish specific callbacks --- - def add_publish_reset_callback(self, callback): - """Callbacks triggered on publishing reset.""" - ref = self._create_reference(callback) - self._publish_reset_callback_refs.add(ref) - - def add_publish_started_callback(self, callback): - """Callbacks triggered on publishing start.""" - ref = self._create_reference(callback) - self._publish_started_callback_refs.add(ref) - - def add_publish_validated_callback(self, callback): - """Callbacks triggered on passing last possible validation order.""" - ref = self._create_reference(callback) - self._publish_validated_callback_refs.add(ref) - - def add_instance_change_callback(self, callback): - """Callbacks triggered before next publish instance process.""" - ref = self._create_reference(callback) - self._publish_instance_changed_callback_refs.add(ref) - - def add_plugin_change_callback(self, callback): - """Callbacks triggered before next plugin processing.""" - ref = self._create_reference(callback) - self._publish_plugin_changed_callback_refs.add(ref) - - def add_publish_stopped_callback(self, callback): - """Callbacks triggered on publishing stop (any reason).""" - ref = self._create_reference(callback) - self._publish_stopped_callback_refs.add(ref) - def get_asset_docs(self): """Get asset documents from cache for whole project.""" return self._asset_docs_cache.get_asset_docs() @@ -556,20 +545,6 @@ class PublisherController: ) return result - def _trigger_callbacks(self, callbacks, *args, **kwargs): - """Helper method to trigger callbacks stored by their rerence.""" - # Trigger reset callbacks - to_remove = set() - for ref in callbacks: - callback = ref() - if callback: - callback(*args, **kwargs) - else: - to_remove.add(ref) - - for ref in to_remove: - callbacks.remove(ref) - def reset(self): """Reset everything related to creation and publishing.""" # Stop publishing @@ -585,6 +560,8 @@ class PublisherController: self._reset_publish() self._reset_instances() + self.emit_card_message("Refreshed..") + def _reset_plugins(self): """Reset to initial state.""" if self._resetting_plugins: @@ -596,7 +573,7 @@ class PublisherController: self._resetting_plugins = False - self._trigger_callbacks(self._plugins_refresh_callback_refs) + self._emit_event("plugins.refresh.finished") def _reset_instances(self): """Reset create instances.""" @@ -612,7 +589,10 @@ class PublisherController: self._resetting_instances = False - self._trigger_callbacks(self._instances_refresh_callback_refs) + self._emit_event("instances.refresh.finished") + + def emit_card_message(self, message): + self._emit_event("show.card.message", {"message": message}) def get_creator_attribute_definitions(self, instances): """Collect creator attribute definitions for multuple instances. @@ -709,7 +689,7 @@ class PublisherController: creator = self.creators[creator_identifier] creator.create(subset_name, instance_data, options) - self._trigger_callbacks(self._instances_refresh_callback_refs) + self._emit_event("instances.refresh.finished") def save_changes(self): """Save changes happened during creation.""" @@ -724,7 +704,7 @@ class PublisherController: self.create_context.remove_instances(instances) - self._trigger_callbacks(self._instances_refresh_callback_refs) + self._emit_event("instances.refresh.finished") # --- Publish specific implementations --- @property @@ -793,7 +773,7 @@ class PublisherController: self._publish_max_progress = len(self.publish_plugins) self._publish_progress = 0 - self._trigger_callbacks(self._publish_reset_callback_refs) + self._emit_event("publish.reset.finished") def set_comment(self, comment): self._publish_context.data["comment"] = comment @@ -820,7 +800,8 @@ class PublisherController: self.save_changes() self._publish_is_running = True - self._trigger_callbacks(self._publish_started_callback_refs) + + self._emit_event("publish.process.started") self._main_thread_processor.start() self._publish_next_process() @@ -828,10 +809,12 @@ class PublisherController: """Stop or pause publishing.""" self._publish_is_running = False self._main_thread_processor.stop() - self._trigger_callbacks(self._publish_stopped_callback_refs) + + self._emit_event("publish.process.stopped") def stop_publish(self): """Stop publishing process (any reason).""" + if self._publish_is_running: self._stop_publish() @@ -892,9 +875,7 @@ class PublisherController: ) # Trigger callbacks when validation stage is passed if self._publish_validated: - self._trigger_callbacks( - self._publish_validated_callback_refs - ) + self._emit_event("publish.process.validated") # Stop if plugin is over validation order and process # should process up to validation. @@ -912,9 +893,14 @@ class PublisherController: self._publish_report.add_plugin_iter(plugin, self._publish_context) # Trigger callback that new plugin is going to be processed - self._trigger_callbacks( - self._publish_plugin_changed_callback_refs, plugin + plugin_label = plugin.__name__ + if hasattr(plugin, "label") and plugin.label: + plugin_label = plugin.label + self._emit_event( + "publish.process.plugin.changed", + {"plugin_label": plugin_label} ) + # Plugin is instance plugin if plugin.__instanceEnabled__: instances = pyblish.logic.instances_by_plugin( @@ -928,11 +914,15 @@ class PublisherController: if instance.data.get("publish") is False: continue - self._trigger_callbacks( - self._publish_instance_changed_callback_refs, - self._publish_context, - instance + instance_label = ( + instance.data.get("label") + or instance.data["name"] ) + self._emit_event( + "publish.process.instance.changed", + {"instance_label": instance_label} + ) + yield MainThreadItem( self._process_and_continue, plugin, instance ) @@ -944,10 +934,14 @@ class PublisherController: [plugin], families ) if plugins: - self._trigger_callbacks( - self._publish_instance_changed_callback_refs, - self._publish_context, - None + instance_label = ( + self._publish_context.data.get("label") + or self._publish_context.data.get("name") + or "Context" + ) + self._emit_event( + "publish.process.instance.changed", + {"instance_label": instance_label} ) yield MainThreadItem( self._process_and_continue, plugin, None diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index 61eb814a56..dc82448495 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -331,7 +331,7 @@ class DetailsPopup(QtWidgets.QDialog): self.closed.emit() -class PublishReportViewerWidget(QtWidgets.QWidget): +class PublishReportViewerWidget(QtWidgets.QFrame): def __init__(self, parent=None): super(PublishReportViewerWidget, self).__init__(parent) diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py index 55afc349ff..a02c69d5e0 100644 --- a/openpype/tools/publisher/widgets/__init__.py +++ b/openpype/tools/publisher/widgets/__init__.py @@ -3,35 +3,20 @@ from .icons import ( get_pixmap, get_icon ) -from .border_label_widget import ( - BorderedLabelWidget -) from .widgets import ( - SubsetAttributesWidget, - StopBtn, ResetBtn, ValidateBtn, PublishBtn, - - CreateInstanceBtn, - RemoveInstanceBtn, - ChangeViewBtn ) -from .publish_widget import ( - PublishFrame -) -from .create_dialog import ( - CreateDialog -) - -from .card_view_widgets import ( - InstanceCardView -) - -from .list_view_widgets import ( - InstanceListView +from .help_widget import ( + HelpButton, + HelpDialog, ) +from .publish_frame import PublishFrame +from .tabs_widget import PublisherTabsWidget +from .overview_widget import OverviewWidget +from .validations_widget import ValidationsWidget __all__ = ( @@ -39,22 +24,17 @@ __all__ = ( "get_pixmap", "get_icon", - "SubsetAttributesWidget", - "BorderedLabelWidget", - "StopBtn", "ResetBtn", "ValidateBtn", "PublishBtn", - "CreateInstanceBtn", - "RemoveInstanceBtn", - "ChangeViewBtn", + "HelpButton", + "HelpDialog", "PublishFrame", - "CreateDialog", - - "InstanceCardView", - "InstanceListView", + "PublisherTabsWidget", + "OverviewWidget", + "ValidationsWidget", ) diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index 46fdcc6526..39bf3886ea 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -13,18 +13,17 @@ from openpype.tools.utils.assets_widget import ( ) -class CreateDialogAssetsWidget(SingleSelectAssetsWidget): +class CreateWidgetAssetsWidget(SingleSelectAssetsWidget): current_context_required = QtCore.Signal() header_height_changed = QtCore.Signal(int) def __init__(self, controller, parent): self._controller = controller - super(CreateDialogAssetsWidget, self).__init__(None, parent) + super(CreateWidgetAssetsWidget, 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 @@ -42,11 +41,11 @@ class CreateDialogAssetsWidget(SingleSelectAssetsWidget): self.header_height_changed.emit(height) def resizeEvent(self, event): - super(CreateDialogAssetsWidget, self).resizeEvent(event) + super(CreateWidgetAssetsWidget, self).resizeEvent(event) self._check_header_height() def showEvent(self, event): - super(CreateDialogAssetsWidget, self).showEvent(event) + super(CreateWidgetAssetsWidget, self).showEvent(event) self._check_header_height() def _on_current_asset_click(self): @@ -63,19 +62,19 @@ class CreateDialogAssetsWidget(SingleSelectAssetsWidget): self.select_asset(self._last_selection) def _select_indexes(self, *args, **kwargs): - super(CreateDialogAssetsWidget, self)._select_indexes(*args, **kwargs) + super(CreateWidgetAssetsWidget, 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 + def update_current_asset(self): # Hide set current asset if there is no one - self.set_current_asset_btn_visibility(asset_name is not None) + asset_name = self._get_current_session_asset() + self.set_current_asset_btn_visibility(bool(asset_name)) def _get_current_session_asset(self): - return self._current_asset_name + return self._controller.current_asset_name def _create_source_model(self): return AssetsHierarchyModel(self._controller) diff --git a/openpype/tools/publisher/widgets/border_label_widget.py b/openpype/tools/publisher/widgets/border_label_widget.py index 696a9050b8..8e09dd817e 100644 --- a/openpype/tools/publisher/widgets/border_label_widget.py +++ b/openpype/tools/publisher/widgets/border_label_widget.py @@ -158,8 +158,7 @@ class BorderedLabelWidget(QtWidgets.QFrame): """ def __init__(self, label, parent): super(BorderedLabelWidget, self).__init__(parent) - colors_data = get_objected_colors() - color_value = colors_data.get("border") + color_value = get_objected_colors("border") color = None if color_value: color = color_value.get_qcolor() diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_widget.py similarity index 57% rename from openpype/tools/publisher/widgets/create_dialog.py rename to openpype/tools/publisher/widgets/create_widget.py index 173df7d5c8..4c9fa63d24 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -3,11 +3,6 @@ import re import traceback import copy -import qtawesome -try: - import commonmark -except Exception: - commonmark = None from Qt import QtWidgets, QtCore, QtGui from openpype.client import get_asset_by_name, get_subsets @@ -16,15 +11,14 @@ from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, TaskNotSetError, ) -from openpype.tools.utils import ( - ErrorMessageBox, - MessageOverlayObject, - ClickableFrame, -) +from openpype.tools.utils import ErrorMessageBox -from .widgets import IconValuePixmapLabel -from .assets_widget import CreateDialogAssetsWidget -from .tasks_widget import CreateDialogTasksWidget +from .widgets import ( + IconValuePixmapLabel, + CreateBtn, +) +from .assets_widget import CreateWidgetAssetsWidget +from .tasks_widget import CreateWidgetTasksWidget from .precreate_widget import PreCreateWidget from ..constants import ( VARIANT_TOOLTIP, @@ -118,8 +112,6 @@ class CreateErrorMessageBox(ErrorMessageBox): # TODO add creator identifier/label to details class CreatorShortDescWidget(QtWidgets.QWidget): - height_changed = QtCore.Signal(int) - def __init__(self, parent=None): super(CreatorShortDescWidget, self).__init__(parent=parent) @@ -158,22 +150,6 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._family_label = family_label self._description_label = description_label - self._last_height = None - - def _check_height_change(self): - height = self.height() - if height != self._last_height: - self._last_height = height - self.height_changed.emit(height) - - def showEvent(self, event): - super(CreatorShortDescWidget, self).showEvent(event) - self._check_height_change() - - def resizeEvent(self, event): - super(CreatorShortDescWidget, self).resizeEvent(event) - self._check_height_change() - def set_plugin(self, plugin=None): if not plugin: self._icon_widget.set_icon_def(None) @@ -190,122 +166,14 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._description_label.setText(description) -class HelpButton(ClickableFrame): - resized = QtCore.Signal(int) - question_mark_icon_name = "fa.question" - help_icon_name = "fa.question-circle" - hide_icon_name = "fa.angle-left" - - def __init__(self, *args, **kwargs): - super(HelpButton, self).__init__(*args, **kwargs) - self.setObjectName("CreateDialogHelpButton") - - question_mark_label = QtWidgets.QLabel(self) - help_widget = QtWidgets.QWidget(self) - - help_question = QtWidgets.QLabel(help_widget) - help_label = QtWidgets.QLabel("Help", help_widget) - hide_icon = QtWidgets.QLabel(help_widget) - - help_layout = QtWidgets.QHBoxLayout(help_widget) - help_layout.setContentsMargins(0, 0, 5, 0) - help_layout.addWidget(help_question, 0) - help_layout.addWidget(help_label, 0) - help_layout.addStretch(1) - help_layout.addWidget(hide_icon, 0) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(question_mark_label, 0) - layout.addWidget(help_widget, 1) - - help_widget.setVisible(False) - - self._question_mark_label = question_mark_label - self._help_widget = help_widget - self._help_question = help_question - self._hide_icon = hide_icon - - self._expanded = None - self.set_expanded() - - def set_expanded(self, expanded=None): - if self._expanded is expanded: - if expanded is not None: - return - expanded = False - self._expanded = expanded - self._help_widget.setVisible(expanded) - self._update_content() - - def _update_content(self): - width = self.get_icon_width() - if self._expanded: - question_mark_pix = QtGui.QPixmap(width, width) - question_mark_pix.fill(QtCore.Qt.transparent) - - else: - question_mark_icon = qtawesome.icon( - self.question_mark_icon_name, color=QtCore.Qt.white - ) - question_mark_pix = question_mark_icon.pixmap(width, width) - - hide_icon = qtawesome.icon( - self.hide_icon_name, color=QtCore.Qt.white - ) - help_question_icon = qtawesome.icon( - self.help_icon_name, color=QtCore.Qt.white - ) - self._question_mark_label.setPixmap(question_mark_pix) - self._question_mark_label.setMaximumWidth(width) - self._hide_icon.setPixmap(hide_icon.pixmap(width, width)) - self._help_question.setPixmap(help_question_icon.pixmap(width, width)) - - def get_icon_width(self): - metrics = self.fontMetrics() - return metrics.height() - - def set_pos_and_size(self, pos_x, pos_y, width, height): - update_icon = self.height() != height - self.move(pos_x, pos_y) - self.resize(width, height) - - if update_icon: - self._update_content() - self.updateGeometry() - - def showEvent(self, event): - super(HelpButton, self).showEvent(event) - self.resized.emit(self.height()) - - def resizeEvent(self, event): - super(HelpButton, self).resizeEvent(event) - self.resized.emit(self.height()) - - -class CreateDialog(QtWidgets.QDialog): - default_size = (1000, 560) - - def __init__( - self, controller, asset_name=None, task_name=None, parent=None - ): - super(CreateDialog, self).__init__(parent) +class CreateWidget(QtWidgets.QWidget): + def __init__(self, controller, parent=None): + super(CreateWidget, self).__init__(parent) self.setWindowTitle("Create new instance") - self.controller = controller + self._controller = controller - if asset_name is None: - asset_name = self.dbcon.Session.get("AVALON_ASSET") - - if task_name is None: - task_name = self.dbcon.Session.get("AVALON_TASK") - - self._asset_name = asset_name - self._task_name = task_name - - self._last_pos = None self._asset_doc = None self._subset_names = None self._selected_creator = None @@ -318,12 +186,12 @@ class CreateDialog(QtWidgets.QDialog): self._name_pattern = name_pattern self._compiled_name_pattern = re.compile(name_pattern) - overlay_object = MessageOverlayObject(self) + main_splitter_widget = QtWidgets.QSplitter(self) - context_widget = QtWidgets.QWidget(self) + context_widget = QtWidgets.QWidget(main_splitter_widget) - assets_widget = CreateDialogAssetsWidget(controller, context_widget) - tasks_widget = CreateDialogTasksWidget(controller, context_widget) + assets_widget = CreateWidgetAssetsWidget(controller, context_widget) + tasks_widget = CreateWidgetTasksWidget(controller, context_widget) context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) @@ -332,21 +200,44 @@ class CreateDialog(QtWidgets.QDialog): context_layout.addWidget(tasks_widget, 1) # --- Creators view --- - creators_header_widget = QtWidgets.QWidget(self) - header_label_widget = QtWidgets.QLabel( - "Choose family:", creators_header_widget - ) - creators_header_layout = QtWidgets.QHBoxLayout(creators_header_widget) - creators_header_layout.setContentsMargins(0, 0, 0, 0) - creators_header_layout.addWidget(header_label_widget, 1) + creators_widget = QtWidgets.QWidget(main_splitter_widget) - creators_view = QtWidgets.QListView(self) + creator_short_desc_widget = CreatorShortDescWidget(creators_widget) + + attr_separator_widget = QtWidgets.QWidget(creators_widget) + attr_separator_widget.setObjectName("Separator") + attr_separator_widget.setMinimumHeight(1) + attr_separator_widget.setMaximumHeight(1) + + creators_splitter = QtWidgets.QSplitter(creators_widget) + + creators_view_widget = QtWidgets.QWidget(creators_splitter) + + creator_view_label = QtWidgets.QLabel( + "Choose publish type", creators_view_widget + ) + + creators_view = QtWidgets.QListView(creators_view_widget) creators_model = QtGui.QStandardItemModel() creators_sort_model = QtCore.QSortFilterProxyModel() creators_sort_model.setSourceModel(creators_model) creators_view.setModel(creators_sort_model) - variant_widget = VariantInputsWidget(self) + creators_view_layout = QtWidgets.QVBoxLayout(creators_view_widget) + creators_view_layout.setContentsMargins(0, 0, 0, 0) + creators_view_layout.addWidget(creator_view_label, 0) + creators_view_layout.addWidget(creators_view, 1) + + # --- Creator attr defs --- + creators_attrs_widget = QtWidgets.QWidget(creators_splitter) + + variant_subset_label = QtWidgets.QLabel( + "Create options", creators_attrs_widget + ) + + variant_subset_widget = QtWidgets.QWidget(creators_attrs_widget) + # Variant and subset input + variant_widget = VariantInputsWidget(creators_attrs_widget) variant_input = QtWidgets.QLineEdit(variant_widget) variant_input.setObjectName("VariantInput") @@ -365,39 +256,20 @@ class CreateDialog(QtWidgets.QDialog): variant_layout.addWidget(variant_input, 1) variant_layout.addWidget(variant_hints_btn, 0, QtCore.Qt.AlignVCenter) - subset_name_input = QtWidgets.QLineEdit(self) + subset_name_input = QtWidgets.QLineEdit(variant_subset_widget) subset_name_input.setEnabled(False) - form_layout = QtWidgets.QFormLayout() - form_layout.addRow("Variant:", variant_widget) - form_layout.addRow("Subset:", subset_name_input) - - mid_widget = QtWidgets.QWidget(self) - mid_layout = QtWidgets.QVBoxLayout(mid_widget) - mid_layout.setContentsMargins(0, 0, 0, 0) - mid_layout.addWidget(creators_header_widget, 0) - mid_layout.addWidget(creators_view, 1) - mid_layout.addLayout(form_layout, 0) - # ------------ - - # --- Creator short info and attr defs --- - creator_attrs_widget = QtWidgets.QWidget(self) - - creator_short_desc_widget = CreatorShortDescWidget( - creator_attrs_widget - ) - - attr_separator_widget = QtWidgets.QWidget(self) - attr_separator_widget.setObjectName("Separator") - attr_separator_widget.setMinimumHeight(1) - attr_separator_widget.setMaximumHeight(1) + variant_subset_layout = QtWidgets.QFormLayout(variant_subset_widget) + variant_subset_layout.setContentsMargins(0, 0, 0, 0) + variant_subset_layout.addRow("Variant", variant_widget) + variant_subset_layout.addRow("Subset", subset_name_input) # Precreate attributes widget - pre_create_widget = PreCreateWidget(creator_attrs_widget) + pre_create_widget = PreCreateWidget(creators_attrs_widget) # Create button - create_btn_wrapper = QtWidgets.QWidget(creator_attrs_widget) - create_btn = QtWidgets.QPushButton("Create", create_btn_wrapper) + create_btn_wrapper = QtWidgets.QWidget(creators_attrs_widget) + create_btn = CreateBtn(create_btn_wrapper) create_btn.setEnabled(False) create_btn_wrap_layout = QtWidgets.QHBoxLayout(create_btn_wrapper) @@ -405,79 +277,45 @@ class CreateDialog(QtWidgets.QDialog): create_btn_wrap_layout.addStretch(1) create_btn_wrap_layout.addWidget(create_btn, 0) - creator_attrs_layout = QtWidgets.QVBoxLayout(creator_attrs_widget) - creator_attrs_layout.setContentsMargins(0, 0, 0, 0) - creator_attrs_layout.addWidget(creator_short_desc_widget, 0) - creator_attrs_layout.addWidget(attr_separator_widget, 0) - creator_attrs_layout.addWidget(pre_create_widget, 1) - creator_attrs_layout.addWidget(create_btn_wrapper, 0) - # ------------------------------------- + creators_attrs_layout = QtWidgets.QVBoxLayout(creators_attrs_widget) + creators_attrs_layout.setContentsMargins(0, 0, 0, 0) + creators_attrs_layout.addWidget(variant_subset_label, 0) + creators_attrs_layout.addWidget(variant_subset_widget, 0) + creators_attrs_layout.addWidget(pre_create_widget, 1) + creators_attrs_layout.addWidget(create_btn_wrapper, 0) + + creators_splitter.addWidget(creators_view_widget) + creators_splitter.addWidget(creators_attrs_widget) + creators_splitter.setStretchFactor(0, 1) + creators_splitter.setStretchFactor(1, 2) + + creators_layout = QtWidgets.QVBoxLayout(creators_widget) + creators_layout.setContentsMargins(0, 0, 0, 0) + creators_layout.addWidget(creator_short_desc_widget, 0) + creators_layout.addWidget(attr_separator_widget, 0) + creators_layout.addWidget(creators_splitter, 1) + # ------------ # --- Detailed information about creator --- # Detailed description of creator - detail_description_widget = QtWidgets.QWidget(self) - - detail_placoholder_widget = QtWidgets.QWidget( - detail_description_widget - ) - detail_placoholder_widget.setAttribute( - QtCore.Qt.WA_TranslucentBackground - ) - - detail_description_input = QtWidgets.QTextEdit( - detail_description_widget - ) - detail_description_input.setObjectName("CreatorDetailedDescription") - detail_description_input.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - - detail_description_layout = QtWidgets.QVBoxLayout( - detail_description_widget - ) - detail_description_layout.setContentsMargins(0, 0, 0, 0) - detail_description_layout.setSpacing(0) - detail_description_layout.addWidget(detail_placoholder_widget, 0) - detail_description_layout.addWidget(detail_description_input, 1) - - detail_description_widget.setVisible(False) + # TODO this has no way how can be showed now # ------------------------------------------- - splitter_widget = QtWidgets.QSplitter(self) - splitter_widget.addWidget(context_widget) - splitter_widget.addWidget(mid_widget) - splitter_widget.addWidget(creator_attrs_widget) - splitter_widget.addWidget(detail_description_widget) - splitter_widget.setStretchFactor(0, 1) - splitter_widget.setStretchFactor(1, 1) - splitter_widget.setStretchFactor(2, 1) - splitter_widget.setStretchFactor(3, 1) + main_splitter_widget.addWidget(context_widget) + main_splitter_widget.addWidget(creators_widget) + main_splitter_widget.setStretchFactor(0, 1) + main_splitter_widget.setStretchFactor(1, 3) - layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(splitter_widget, 1) - - # Floating help button - # - Create this button as last to be fully visible - help_btn = HelpButton(self) + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(main_splitter_widget, 1) prereq_timer = QtCore.QTimer() prereq_timer.setInterval(50) prereq_timer.setSingleShot(True) - desc_width_anim_timer = QtCore.QTimer() - desc_width_anim_timer.setInterval(10) - prereq_timer.timeout.connect(self._invalidate_prereq) - desc_width_anim_timer.timeout.connect(self._on_desc_animation) - - help_btn.clicked.connect(self._on_help_btn) - help_btn.resized.connect(self._on_help_btn_resize) - - assets_widget.header_height_changed.connect( - self._on_asset_filter_height_change - ) - create_btn.clicked.connect(self._on_create) variant_widget.resized.connect(self._on_variant_widget_resize) variant_input.returnPressed.connect(self._on_create) @@ -492,16 +330,14 @@ class CreateDialog(QtWidgets.QDialog): self._on_current_session_context_request ) tasks_widget.task_changed.connect(self._on_task_change) - creator_short_desc_widget.height_changed.connect( - self._on_description_height_change + + controller.event_system.add_callback( + "plugins.refresh.finished", self._on_plugins_refresh ) - splitter_widget.splitterMoved.connect(self._on_splitter_move) - controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._main_splitter_widget = main_splitter_widget - self._overlay_object = overlay_object - - self._splitter_widget = splitter_widget + self._creators_splitter = creators_splitter self._context_widget = context_widget self._assets_widget = assets_widget @@ -514,7 +350,6 @@ class CreateDialog(QtWidgets.QDialog): self.variant_hints_menu = variant_hints_menu self.variant_hints_group = variant_hints_group - self._creators_header_widget = creators_header_widget self._creators_model = creators_model self._creators_sort_model = creators_sort_model self._creators_view = creators_view @@ -524,26 +359,16 @@ class CreateDialog(QtWidgets.QDialog): self._pre_create_widget = pre_create_widget self._attr_separator_widget = attr_separator_widget - self._detail_placoholder_widget = detail_placoholder_widget - self._detail_description_widget = detail_description_widget - self._detail_description_input = detail_description_input - self._help_btn = help_btn - self._prereq_timer = prereq_timer self._first_show = True - # Description animation - self._description_size_policy = detail_description_widget.sizePolicy() - self._desc_width_anim_timer = desc_width_anim_timer - self._desc_widget_step = 0 - self._last_description_width = None - self._last_full_width = 0 - self._expected_description_width = 0 - self._last_desc_max_width = None - self._other_widgets_widths = [] + @property + def current_asset_name(self): + return self._controller.current_asset_name - def _emit_message(self, message): - self._overlay_object.add_message(message) + @property + def current_task_name(self): + return self._controller.current_task_name def _context_change_is_enabled(self): return self._context_widget.isEnabled() @@ -554,7 +379,7 @@ class CreateDialog(QtWidgets.QDialog): asset_name = self._assets_widget.get_selected_asset_name() if asset_name is None: - asset_name = self._asset_name + asset_name = self.current_asset_name return asset_name def _get_task_name(self): @@ -566,13 +391,9 @@ class CreateDialog(QtWidgets.QDialog): task_name = self._tasks_widget.get_selected_task_name() if not task_name: - task_name = self._task_name + task_name = self.current_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) @@ -601,7 +422,7 @@ class CreateDialog(QtWidgets.QDialog): # data self._refresh_creators() - self._assets_widget.set_current_asset_name(self._asset_name) + self._assets_widget.update_current_asset() 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) @@ -611,10 +432,6 @@ class CreateDialog(QtWidgets.QDialog): def _invalidate_prereq_deffered(self): self._prereq_timer.start() - def _on_asset_filter_height_change(self, height): - self._creators_header_widget.setMinimumHeight(height) - self._creators_header_widget.setMaximumHeight(height) - def _invalidate_prereq(self): prereq_available = True creator_btn_tooltips = [] @@ -660,7 +477,7 @@ class CreateDialog(QtWidgets.QDialog): if asset_name is None: return - project_name = self.dbcon.active_project() + project_name = self._controller.project_name asset_doc = get_asset_by_name(project_name, asset_name) self._asset_doc = asset_doc @@ -689,7 +506,7 @@ class CreateDialog(QtWidgets.QDialog): # Add new families new_creators = set() - for identifier, creator in self.controller.manual_creators.items(): + for identifier, creator in self._controller.manual_creators.items(): # TODO add details about creator new_creators.add(identifier) if identifier in existing_items: @@ -729,8 +546,7 @@ class CreateDialog(QtWidgets.QDialog): def _on_plugins_refresh(self): # Trigger refresh only if is visible - if self.isVisible(): - self.refresh() + self.refresh() def _on_asset_change(self): self._refresh_asset() @@ -746,14 +562,9 @@ class CreateDialog(QtWidgets.QDialog): 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_description_height_change(self): - # Use separator's 'y' position as height - height = self._attr_separator_widget.y() - self._detail_placoholder_widget.setMinimumHeight(height) - self._detail_placoholder_widget.setMaximumHeight(height) + task_name = self.current_task_name + if task_name: + self._tasks_widget.select_task_name(task_name) def _on_creator_item_change(self, new_index, _old_index): identifier = None @@ -761,196 +572,21 @@ class CreateDialog(QtWidgets.QDialog): identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) self._set_creator_by_identifier(identifier) - def _update_help_btn(self): - short_desc_rect = self._creator_short_desc_widget.rect() - - # point = short_desc_rect.topRight() - point = short_desc_rect.center() - mapped_point = self._creator_short_desc_widget.mapTo(self, point) - # pos_y = mapped_point.y() - center_pos_y = mapped_point.y() - icon_width = self._help_btn.get_icon_width() - - _height = int(icon_width * 2.5) - height = min(_height, short_desc_rect.height()) - pos_y = center_pos_y - int(height / 2) - - pos_x = self.width() - icon_width - if self._detail_placoholder_widget.isVisible(): - pos_x -= ( - self._detail_placoholder_widget.width() - + self._splitter_widget.handle(3).width() - ) - - width = self.width() - pos_x - - self._help_btn.set_pos_and_size( - max(0, pos_x), max(0, pos_y), - width, height - ) - - def _on_help_btn_resize(self, height): - if self._creator_short_desc_widget.height() != height: - self._update_help_btn() - - def _on_splitter_move(self, *args): - self._update_help_btn() - - def _on_help_btn(self): - if self._desc_width_anim_timer.isActive(): - return - - final_size = self.size() - cur_sizes = self._splitter_widget.sizes() - - if self._desc_widget_step == 0: - now_visible = self._detail_description_widget.isVisible() - else: - now_visible = self._desc_widget_step > 0 - - sizes = [] - for idx, value in enumerate(cur_sizes): - if idx < 3: - sizes.append(value) - - self._last_full_width = final_size.width() - self._other_widgets_widths = list(sizes) - - if now_visible: - cur_desc_width = self._detail_description_widget.width() - if cur_desc_width < 1: - cur_desc_width = 2 - step_size = int(cur_desc_width / 5) - if step_size < 1: - step_size = 1 - - step_size *= -1 - expected_width = 0 - desc_width = cur_desc_width - 1 - width = final_size.width() - 1 - min_max = desc_width - self._last_description_width = cur_desc_width - - else: - self._detail_description_widget.setVisible(True) - handle = self._splitter_widget.handle(3) - desc_width = handle.sizeHint().width() - if self._last_description_width: - expected_width = self._last_description_width - else: - hint = self._detail_description_widget.sizeHint() - expected_width = hint.width() - - width = final_size.width() + desc_width - step_size = int(expected_width / 5) - if step_size < 1: - step_size = 1 - min_max = 0 - - if self._last_desc_max_width is None: - self._last_desc_max_width = ( - self._detail_description_widget.maximumWidth() - ) - self._detail_description_widget.setMinimumWidth(min_max) - self._detail_description_widget.setMaximumWidth(min_max) - self._expected_description_width = expected_width - self._desc_widget_step = step_size - - self._desc_width_anim_timer.start() - - sizes.append(desc_width) - - final_size.setWidth(width) - - self._splitter_widget.setSizes(sizes) - self.resize(final_size) - - self._help_btn.set_expanded(not now_visible) - - def _on_desc_animation(self): - current_width = self._detail_description_widget.width() - - desc_width = None - last_step = False - growing = self._desc_widget_step > 0 - - # Growing - if growing: - if current_width < self._expected_description_width: - desc_width = current_width + self._desc_widget_step - if desc_width >= self._expected_description_width: - desc_width = self._expected_description_width - last_step = True - - # Decreasing - elif self._desc_widget_step < 0: - if current_width > self._expected_description_width: - desc_width = current_width + self._desc_widget_step - if desc_width <= self._expected_description_width: - desc_width = self._expected_description_width - last_step = True - - if desc_width is None: - self._desc_widget_step = 0 - self._desc_width_anim_timer.stop() - return - - if last_step and not growing: - self._detail_description_widget.setVisible(False) - QtWidgets.QApplication.processEvents() - - width = self._last_full_width - handle_width = self._splitter_widget.handle(3).width() - if growing: - width += (handle_width + desc_width) - else: - width -= self._last_description_width - if last_step: - width -= handle_width - else: - width += desc_width - - if not last_step or growing: - self._detail_description_widget.setMaximumWidth(desc_width) - self._detail_description_widget.setMinimumWidth(desc_width) - - window_size = self.size() - window_size.setWidth(width) - self.resize(window_size) - if not last_step: - return - - self._desc_widget_step = 0 - self._desc_width_anim_timer.stop() - - if not growing: - return - - self._detail_description_widget.setMinimumWidth(0) - self._detail_description_widget.setMaximumWidth( - self._last_desc_max_width - ) - self._detail_description_widget.setSizePolicy( - self._description_size_policy - ) - - sizes = list(self._other_widgets_widths) - sizes.append(desc_width) - self._splitter_widget.setSizes(sizes) - def _set_creator_detailed_text(self, creator): - if not creator: - self._detail_description_input.setPlainText("") - return - detailed_description = creator.get_detail_description() or "" - if commonmark: - html = commonmark.commonmark(detailed_description) - self._detail_description_input.setHtml(html) - else: - self._detail_description_input.setMarkdown(detailed_description) + # TODO implement + description = "" + if creator is not None: + description = creator.get_detail_description() or description + self._controller.event_system.emit( + "show.detailed.help", + { + "message": description + }, + "create.widget" + ) def _set_creator_by_identifier(self, identifier): - creator = self.controller.manual_creators.get(identifier) + creator = self._controller.manual_creators.get(identifier) self._set_creator(creator) def _set_creator(self, creator): @@ -1034,7 +670,7 @@ class CreateDialog(QtWidgets.QDialog): self.subset_name_input.setText("< Valid variant >") return - project_name = self.controller.project_name + project_name = self._controller.project_name task_name = self._get_task_name() asset_doc = copy.deepcopy(self._asset_doc) @@ -1116,41 +752,19 @@ class CreateDialog(QtWidgets.QDialog): self.variant_input.style().polish(self.variant_input) def _on_first_show(self): - center = self.rect().center() - - width, height = self.default_size - self.resize(width, height) - part = int(width / 7) - self._splitter_widget.setSizes( - [part * 2, part * 2, width - (part * 4)] - ) - - new_pos = self.mapToGlobal(center) - new_pos.setX(new_pos.x() - int(self.width() / 2)) - new_pos.setY(new_pos.y() - int(self.height() / 2)) - self.move(new_pos) - - def moveEvent(self, event): - super(CreateDialog, self).moveEvent(event) - self._last_pos = self.pos() + width = self.width() + part = int(width / 4) + rem_width = width - part + self._main_splitter_widget.setSizes([part, rem_width]) + rem_width = rem_width - part + self._creators_splitter.setSizes([part, rem_width]) def showEvent(self, event): - super(CreateDialog, self).showEvent(event) + super(CreateWidget, self).showEvent(event) if self._first_show: self._first_show = False self._on_first_show() - if self._last_pos is not None: - self.move(self._last_pos) - - self._update_help_btn() - - self.refresh() - - def resizeEvent(self, event): - super(CreateDialog, self).resizeEvent(event) - self._update_help_btn() - def _on_create(self): indexes = self._creators_view.selectedIndexes() if not indexes or len(indexes) > 1: @@ -1186,7 +800,7 @@ class CreateDialog(QtWidgets.QDialog): error_msg = None formatted_traceback = None try: - self.controller.create( + self._controller.create( creator_identifier, subset_name, instance_data, @@ -1207,7 +821,7 @@ class CreateDialog(QtWidgets.QDialog): if error_msg is None: self._set_creator(self._selected_creator) - self._emit_message("Creation finished...") + self._controller.emit_card_message("Creation finished...") else: box = CreateErrorMessageBox( creator_label, diff --git a/openpype/tools/publisher/widgets/help_widget.py b/openpype/tools/publisher/widgets/help_widget.py new file mode 100644 index 0000000000..7da07b1e78 --- /dev/null +++ b/openpype/tools/publisher/widgets/help_widget.py @@ -0,0 +1,82 @@ +try: + import commonmark +except Exception: + commonmark = None + +from Qt import QtWidgets, QtCore + + +class HelpButton(QtWidgets.QPushButton): + """Button used to trigger help dialog.""" + + def __init__(self, parent): + super(HelpButton, self).__init__(parent) + self.setObjectName("CreateDialogHelpButton") + self.setText("?") + + +class HelpWidget(QtWidgets.QWidget): + """Widget showing help for single functionality.""" + + def __init__(self, parent): + super(HelpWidget, self).__init__(parent) + + # TODO add hints what to help with? + detail_description_input = QtWidgets.QTextEdit(self) + detail_description_input.setObjectName("CreatorDetailedDescription") + detail_description_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(detail_description_input, 1) + + self._detail_description_input = detail_description_input + + self.set_detailed_text() + + def set_detailed_text(self, text=None): + if not text: + text = "We didn't prepare help for this part..." + + if commonmark: + html = commonmark.commonmark(text) + self._detail_description_input.setHtml(html) + else: + self._detail_description_input.setMarkdown(text) + + +class HelpDialog(QtWidgets.QDialog): + default_width = 530 + default_height = 340 + + def __init__(self, controller, parent): + super(HelpDialog, self).__init__(parent) + + self.setWindowTitle("Help dialog") + + help_content = HelpWidget(self) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.addWidget(help_content, 1) + + controller.event_system.add_callback( + "show.detailed.help", self._on_help_request + ) + + self._controller = controller + + self._help_content = help_content + + def _on_help_request(self, event): + message = event.get("message") + self.set_detailed_text(message) + + def set_detailed_text(self, text=None): + self._help_content.set_detailed_text(text) + + def showEvent(self, event): + super(HelpDialog, self).showEvent(event) + self.resize(self.default_width, self.default_height) diff --git a/openpype/tools/publisher/widgets/images/copy.png b/openpype/tools/publisher/widgets/images/copy.png deleted file mode 100644 index 522afcdc87..0000000000 Binary files a/openpype/tools/publisher/widgets/images/copy.png and /dev/null differ diff --git a/openpype/tools/publisher/widgets/images/create.png b/openpype/tools/publisher/widgets/images/create.png new file mode 100644 index 0000000000..d691f364dd Binary files /dev/null and b/openpype/tools/publisher/widgets/images/create.png differ diff --git a/openpype/tools/publisher/widgets/images/download_arrow.png b/openpype/tools/publisher/widgets/images/download_arrow.png deleted file mode 100644 index a35a12fb39..0000000000 Binary files a/openpype/tools/publisher/widgets/images/download_arrow.png and /dev/null differ diff --git a/openpype/tools/publisher/widgets/images/validate.png b/openpype/tools/publisher/widgets/images/validate.png index d3cfa0b75d..c8472e9d31 100644 Binary files a/openpype/tools/publisher/widgets/images/validate.png and b/openpype/tools/publisher/widgets/images/validate.png differ diff --git a/openpype/tools/publisher/widgets/images/view_report.png b/openpype/tools/publisher/widgets/images/view_report.png index 50e214c3f8..6f3efd5e19 100644 Binary files a/openpype/tools/publisher/widgets/images/view_report.png and b/openpype/tools/publisher/widgets/images/view_report.png differ diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py index 6e31ba635b..a701181e5b 100644 --- a/openpype/tools/publisher/widgets/list_view_widgets.py +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -54,8 +54,7 @@ class ListItemDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, parent): super(ListItemDelegate, self).__init__(parent) - colors_data = get_objected_colors() - group_color_info = colors_data["publisher"]["list-view-group"] + group_color_info = get_objected_colors("publisher", "list-view-group") self._group_colors = { key: value.get_qcolor() diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py new file mode 100644 index 0000000000..08c2ce0513 --- /dev/null +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -0,0 +1,368 @@ +from Qt import QtWidgets, QtCore + +from .border_label_widget import BorderedLabelWidget + +from .card_view_widgets import InstanceCardView +from .list_view_widgets import InstanceListView +from .widgets import ( + SubsetAttributesWidget, + CreateInstanceBtn, + RemoveInstanceBtn, + ChangeViewBtn, +) +from .create_widget import CreateWidget + + +class OverviewWidget(QtWidgets.QFrame): + active_changed = QtCore.Signal() + instance_context_changed = QtCore.Signal() + create_requested = QtCore.Signal() + + anim_end_value = 200 + anim_duration = 200 + + def __init__(self, controller, parent): + super(OverviewWidget, self).__init__(parent) + + self._refreshing_instances = False + self._controller = controller + + create_widget = CreateWidget(controller, self) + + # --- Created Subsets/Instances --- + # Common widget for creation and overview + subset_views_widget = BorderedLabelWidget( + "Subsets to publish", self + ) + + subset_view_cards = InstanceCardView(controller, subset_views_widget) + subset_list_view = InstanceListView(controller, subset_views_widget) + + subset_views_layout = QtWidgets.QStackedLayout() + subset_views_layout.addWidget(subset_view_cards) + subset_views_layout.addWidget(subset_list_view) + subset_views_layout.setCurrentWidget(subset_view_cards) + + # Buttons at the bottom of subset view + create_btn = CreateInstanceBtn(self) + delete_btn = RemoveInstanceBtn(self) + change_view_btn = ChangeViewBtn(self) + + # --- Overview --- + # Subset details widget + subset_attributes_wrap = BorderedLabelWidget( + "Publish options", self + ) + subset_attributes_widget = SubsetAttributesWidget( + controller, subset_attributes_wrap + ) + subset_attributes_wrap.set_center_widget(subset_attributes_widget) + + # Layout of buttons at the bottom of subset view + subset_view_btns_layout = QtWidgets.QHBoxLayout() + subset_view_btns_layout.setContentsMargins(0, 5, 0, 0) + subset_view_btns_layout.addWidget(create_btn) + subset_view_btns_layout.addSpacing(5) + subset_view_btns_layout.addWidget(delete_btn) + subset_view_btns_layout.addStretch(1) + subset_view_btns_layout.addWidget(change_view_btn) + + # Layout of view and buttons + # - 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_widget) + + # Whole subset layout with attributes and details + subset_content_widget = QtWidgets.QWidget(self) + subset_content_layout = QtWidgets.QHBoxLayout(subset_content_widget) + subset_content_layout.setContentsMargins(0, 0, 0, 0) + subset_content_layout.addWidget(create_widget, 7) + subset_content_layout.addWidget(subset_views_widget, 3) + subset_content_layout.addWidget(subset_attributes_wrap, 7) + + # Subset frame layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(subset_content_widget, 1) + + change_anim = QtCore.QVariantAnimation() + change_anim.setStartValue(0) + change_anim.setEndValue(self.anim_end_value) + change_anim.setDuration(self.anim_duration) + change_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + + # --- Calbacks for instances/subsets view --- + create_btn.clicked.connect(self._on_create_clicked) + delete_btn.clicked.connect(self._on_delete_clicked) + change_view_btn.clicked.connect(self._on_change_view_clicked) + + change_anim.valueChanged.connect(self._on_change_anim) + change_anim.finished.connect(self._on_change_anim_finished) + + # Selection changed + subset_list_view.selection_changed.connect( + self._on_subset_change + ) + subset_view_cards.selection_changed.connect( + self._on_subset_change + ) + # Active instances changed + subset_list_view.active_changed.connect( + self._on_active_changed + ) + subset_view_cards.active_changed.connect( + self._on_active_changed + ) + # Instance context has changed + subset_attributes_widget.instance_context_changed.connect( + self._on_instance_context_change + ) + + # --- Controller callbacks --- + controller.event_system.add_callback( + "publish.process.started", self._on_publish_start + ) + controller.event_system.add_callback( + "publish.reset.finished", self._on_publish_reset + ) + controller.event_system.add_callback( + "instances.refresh.finished", self._on_instances_refresh + ) + + self._subset_content_widget = subset_content_widget + self._subset_content_layout = subset_content_layout + + self._subset_view_cards = subset_view_cards + self._subset_list_view = subset_list_view + self._subset_views_layout = subset_views_layout + + self._delete_btn = delete_btn + + self._subset_attributes_widget = subset_attributes_widget + self._create_widget = create_widget + self._subset_views_widget = subset_views_widget + self._subset_attributes_wrap = subset_attributes_wrap + + self._change_anim = change_anim + + # Start in create mode + self._create_widget_policy = create_widget.sizePolicy() + self._subset_views_widget_policy = subset_views_widget.sizePolicy() + self._subset_attributes_wrap_policy = ( + subset_attributes_wrap.sizePolicy() + ) + self._max_widget_width = None + self._current_state = "create" + subset_attributes_wrap.setVisible(False) + + def set_state(self, new_state, animate): + if new_state == self._current_state: + return + + self._current_state = new_state + + anim_is_running = ( + self._change_anim.state() == self._change_anim.Running + ) + if not animate: + self._change_visibility_for_state() + if anim_is_running: + self._change_anim.stop() + return + + if self._max_widget_width is None: + self._max_widget_width = self._subset_views_widget.maximumWidth() + + if new_state == "create": + direction = self._change_anim.Backward + else: + direction = self._change_anim.Forward + self._change_anim.setDirection(direction) + + if not anim_is_running: + view_width = self._subset_views_widget.width() + self._subset_views_widget.setMinimumWidth(view_width) + self._subset_views_widget.setMaximumWidth(view_width) + self._change_anim.start() + + def _on_create_clicked(self): + """Pass signal to parent widget which should care about changing state. + + We don't change anything here until the parent will care about it. + """ + + self.create_requested.emit() + + def _on_delete_clicked(self): + instances, _ = self.get_selected_items() + + # Ask user if he really wants to remove instances + dialog = QtWidgets.QMessageBox(self) + dialog.setIcon(QtWidgets.QMessageBox.Question) + dialog.setWindowTitle("Are you sure?") + if len(instances) > 1: + msg = ( + "Do you really want to remove {} instances?" + ).format(len(instances)) + else: + msg = ( + "Do you really want to remove the instance?" + ) + dialog.setText(msg) + dialog.setStandardButtons( + QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel + ) + dialog.setDefaultButton(QtWidgets.QMessageBox.Ok) + dialog.setEscapeButton(QtWidgets.QMessageBox.Cancel) + dialog.exec_() + # Skip if OK was not clicked + if dialog.result() == QtWidgets.QMessageBox.Ok: + self._controller.remove_instances(instances) + + def _on_change_view_clicked(self): + self._change_view_type() + + def _on_subset_change(self, *_args): + # Ignore changes if in middle of refreshing + if self._refreshing_instances: + return + + instances, context_selected = self.get_selected_items() + + # Disable delete button if nothing is selected + self._delete_btn.setEnabled(len(instances) > 0) + + self._subset_attributes_widget.set_current_instances( + instances, context_selected + ) + + def _on_active_changed(self): + if self._refreshing_instances: + return + self.active_changed.emit() + + def _on_change_anim(self, value): + self._create_widget.setVisible(True) + self._subset_attributes_wrap.setVisible(True) + width = ( + self._subset_content_widget.width() + - ( + self._subset_views_widget.width() + + (self._subset_content_layout.spacing() * 2) + ) + ) + subset_attrs_width = int(float(width) / self.anim_end_value) * value + if subset_attrs_width > width: + subset_attrs_width = width + create_width = width - subset_attrs_width + + self._create_widget.setMinimumWidth(create_width) + self._create_widget.setMaximumWidth(create_width) + self._subset_attributes_wrap.setMinimumWidth(subset_attrs_width) + self._subset_attributes_wrap.setMaximumWidth(subset_attrs_width) + + def _on_change_anim_finished(self): + self._change_visibility_for_state() + self._create_widget.setMinimumWidth(0) + self._create_widget.setMaximumWidth(self._max_widget_width) + self._subset_attributes_wrap.setMinimumWidth(0) + self._subset_attributes_wrap.setMaximumWidth(self._max_widget_width) + self._subset_views_widget.setMinimumWidth(0) + self._subset_views_widget.setMaximumWidth(self._max_widget_width) + self._create_widget.setSizePolicy( + self._create_widget_policy + ) + self._subset_attributes_wrap.setSizePolicy( + self._subset_attributes_wrap_policy + ) + self._subset_views_widget.setSizePolicy( + self._subset_views_widget_policy + ) + + def _change_visibility_for_state(self): + self._create_widget.setVisible( + self._current_state == "create" + ) + self._subset_attributes_wrap.setVisible( + self._current_state == "publish" + ) + + def _on_instance_context_change(self): + current_idx = self._subset_views_layout.currentIndex() + for idx in range(self._subset_views_layout.count()): + if idx == current_idx: + continue + widget = self._subset_views_layout.widget(idx) + if widget.refreshed: + widget.set_refreshed(False) + + current_widget = self._subset_views_layout.widget(current_idx) + current_widget.refresh_instance_states() + + self.instance_context_changed.emit() + + def get_selected_items(self): + view = self._subset_views_layout.currentWidget() + return view.get_selected_items() + + def _change_view_type(self): + idx = self._subset_views_layout.currentIndex() + new_idx = (idx + 1) % self._subset_views_layout.count() + self._subset_views_layout.setCurrentIndex(new_idx) + + new_view = self._subset_views_layout.currentWidget() + if not new_view.refreshed: + new_view.refresh() + new_view.set_refreshed(True) + else: + new_view.refresh_instance_states() + + self._on_subset_change() + + def _refresh_instances(self): + if self._refreshing_instances: + return + + self._refreshing_instances = True + + for idx in range(self._subset_views_layout.count()): + widget = self._subset_views_layout.widget(idx) + widget.set_refreshed(False) + + view = self._subset_views_layout.currentWidget() + view.refresh() + view.set_refreshed(True) + + self._refreshing_instances = False + + # Force to change instance and refresh details + self._on_subset_change() + + def _on_publish_start(self): + """Publish started.""" + + self._subset_attributes_wrap.setEnabled(False) + + def _on_publish_reset(self): + """Context in controller has been refreshed.""" + + self._subset_attributes_wrap.setEnabled(True) + self._subset_content_widget.setEnabled(self._controller.host_is_valid) + + def _on_instances_refresh(self): + """Controller refreshed instances.""" + + self._refresh_instances() + + # Give a change to process Resize Request + QtWidgets.QApplication.processEvents() + # Trigger update geometry of + widget = self._subset_views_layout.currentWidget() + widget.updateGeometry() diff --git a/openpype/tools/publisher/widgets/publish_frame.py b/openpype/tools/publisher/widgets/publish_frame.py new file mode 100644 index 0000000000..ddaac7027d --- /dev/null +++ b/openpype/tools/publisher/widgets/publish_frame.py @@ -0,0 +1,520 @@ +import os +import json +import time + +from Qt import QtWidgets, QtCore + +from openpype.pipeline import KnownPublishError + +from .widgets import ( + StopBtn, + ResetBtn, + ValidateBtn, + PublishBtn, + PublishReportBtn, +) + + +class PublishFrame(QtWidgets.QWidget): + """Frame showed during publishing. + + Shows all information related to publishing. Contains validation error + widget which is showed if only validation error happens during validation. + + Processing layer is default layer. Validation error layer is shown if only + validation exception is raised during publishing. Report layer is available + only when publishing process is stopped and must be manually triggered to + change into that layer. + + +------------------------------------------------------------------------+ + | < Main label > | + | < Label top > | + | (#### 10% ) | + | | + | | + +------------------------------------------------------------------------+ + """ + + details_page_requested = QtCore.Signal() + + def __init__(self, controller, borders, parent): + super(PublishFrame, self).__init__(parent) + + # Bottom part of widget where process and callback buttons are showed + # - QFrame used to be able set background using stylesheets easily + # and not override all children widgets style + content_frame = QtWidgets.QFrame(self) + content_frame.setObjectName("PublishInfoFrame") + + top_content_widget = QtWidgets.QWidget(content_frame) + + # Center widget displaying current state (without any specific info) + main_label = QtWidgets.QLabel(top_content_widget) + main_label.setObjectName("PublishInfoMainLabel") + main_label.setAlignment(QtCore.Qt.AlignCenter) + + # Supporting labels for main label + # Top label is displayed just under main label + message_label_top = QtWidgets.QLabel(top_content_widget) + message_label_top.setAlignment(QtCore.Qt.AlignCenter) + + # Label showing currently processed instance + progress_widget = QtWidgets.QWidget(top_content_widget) + instance_plugin_widget = QtWidgets.QWidget(progress_widget) + instance_label = QtWidgets.QLabel( + "", instance_plugin_widget + ) + instance_label.setAlignment( + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + # Label showing currently processed plugin + plugin_label = QtWidgets.QLabel( + "", instance_plugin_widget + ) + plugin_label.setAlignment( + QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter + ) + instance_plugin_layout = QtWidgets.QHBoxLayout(instance_plugin_widget) + instance_plugin_layout.setContentsMargins(0, 0, 0, 0) + instance_plugin_layout.addWidget(instance_label, 1) + instance_plugin_layout.addWidget(plugin_label, 1) + + # Progress bar showing progress of publishing + progress_bar = QtWidgets.QProgressBar(progress_widget) + progress_bar.setObjectName("PublishProgressBar") + + progress_layout = QtWidgets.QVBoxLayout(progress_widget) + progress_layout.setSpacing(5) + progress_layout.setContentsMargins(0, 0, 0, 0) + progress_layout.addWidget(instance_plugin_widget, 0) + progress_layout.addWidget(progress_bar, 0) + + top_content_layout = QtWidgets.QVBoxLayout(top_content_widget) + top_content_layout.setContentsMargins(0, 0, 0, 0) + top_content_layout.setSpacing(5) + top_content_layout.setAlignment(QtCore.Qt.AlignCenter) + top_content_layout.addWidget(main_label) + # TODO stretches should be probably replaced by spacing... + # - stretch in floating frame doesn't make sense + top_content_layout.addWidget(message_label_top) + top_content_layout.addWidget(progress_widget) + + # Publishing buttons to stop, reset or trigger publishing + footer_widget = QtWidgets.QWidget(content_frame) + + report_btn = PublishReportBtn(footer_widget) + + shrunk_main_label = QtWidgets.QLabel(footer_widget) + shrunk_main_label.setObjectName("PublishInfoMainLabel") + shrunk_main_label.setAlignment( + QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft + ) + + reset_btn = ResetBtn(footer_widget) + stop_btn = StopBtn(footer_widget) + validate_btn = ValidateBtn(footer_widget) + publish_btn = PublishBtn(footer_widget) + + report_btn.add_action("Go to details", "go_to_report") + report_btn.add_action("Copy report", "copy_report") + report_btn.add_action("Export report", "export_report") + + # Footer on info frame layout + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addWidget(report_btn, 0) + footer_layout.addWidget(shrunk_main_label, 1) + footer_layout.addWidget(reset_btn, 0) + footer_layout.addWidget(stop_btn, 0) + footer_layout.addWidget(validate_btn, 0) + footer_layout.addWidget(publish_btn, 0) + + # Info frame content + content_layout = QtWidgets.QVBoxLayout(content_frame) + content_layout.setSpacing(5) + + content_layout.addWidget(top_content_widget) + content_layout.addWidget(footer_widget) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(borders, 0, borders, borders) + main_layout.addWidget(content_frame) + + shrunk_anim = QtCore.QVariantAnimation() + shrunk_anim.setDuration(140) + shrunk_anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) + + # Force translucent background for widgets + for widget in ( + self, + top_content_widget, + footer_widget, + progress_widget, + instance_plugin_widget, + ): + widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + report_btn.triggered.connect(self._on_report_triggered) + reset_btn.clicked.connect(self._on_reset_clicked) + stop_btn.clicked.connect(self._on_stop_clicked) + validate_btn.clicked.connect(self._on_validate_clicked) + publish_btn.clicked.connect(self._on_publish_clicked) + + shrunk_anim.valueChanged.connect(self._on_shrunk_anim) + shrunk_anim.finished.connect(self._on_shrunk_anim_finish) + + controller.event_system.add_callback( + "publish.reset.finished", self._on_publish_reset + ) + controller.event_system.add_callback( + "publish.process.started", self._on_publish_start + ) + controller.event_system.add_callback( + "publish.process.validated", self._on_publish_validated + ) + controller.event_system.add_callback( + "publish.process.stopped", self._on_publish_stop + ) + + controller.event_system.add_callback( + "publish.process.instance.changed", self._on_instance_change + ) + controller.event_system.add_callback( + "publish.process.plugin.changed", self._on_plugin_change + ) + + self._shrunk_anim = shrunk_anim + + self.controller = controller + + self._content_frame = content_frame + self._content_layout = content_layout + self._top_content_layout = top_content_layout + self._top_content_widget = top_content_widget + + self._main_label = main_label + self._message_label_top = message_label_top + + self._instance_label = instance_label + self._plugin_label = plugin_label + + self._progress_bar = progress_bar + self._progress_widget = progress_widget + + self._shrunk_main_label = shrunk_main_label + self._reset_btn = reset_btn + self._stop_btn = stop_btn + self._validate_btn = validate_btn + self._publish_btn = publish_btn + + self._shrunken = False + self._top_widget_max_height = None + self._top_widget_size_policy = top_content_widget.sizePolicy() + self._last_instance_label = None + self._last_plugin_label = None + + def mouseReleaseEvent(self, event): + super(PublishFrame, self).mouseReleaseEvent(event) + self._change_shrunk_state() + + def _change_shrunk_state(self): + self.set_shrunk_state(not self._shrunken) + + def set_shrunk_state(self, shrunk): + if shrunk is self._shrunken: + return + + if self._top_widget_max_height is None: + self._top_widget_max_height = ( + self._top_content_widget.maximumHeight() + ) + + self._shrunken = shrunk + + anim_is_running = ( + self._shrunk_anim.state() == self._shrunk_anim.Running + ) + if not self.isVisible(): + if anim_is_running: + self._shrunk_anim.stop() + self._on_shrunk_anim_finish() + return + + start = 0 + end = 0 + if shrunk: + start = self._top_content_widget.height() + else: + if anim_is_running: + start = self._shrunk_anim.currentValue() + hint = self._top_content_widget.minimumSizeHint() + end = hint.height() + + self._shrunk_anim.setStartValue(start) + self._shrunk_anim.setEndValue(end) + if not anim_is_running: + self._shrunk_anim.start() + + def _on_shrunk_anim(self, value): + diff = self._top_content_widget.height() - value + if not self._top_content_widget.isVisible(): + diff -= self._content_layout.spacing() + + window_pos = self.pos() + window_pos_y = window_pos.y() + diff + window_height = self.height() - diff + + self._top_content_widget.setMinimumHeight(value) + self._top_content_widget.setMaximumHeight(value) + self._top_content_widget.setVisible(True) + + self.resize(self.width(), window_height) + self.move(window_pos.x(), window_pos_y) + + def _on_shrunk_anim_finish(self): + self._top_content_widget.setVisible(not self._shrunken) + self._top_content_widget.setMinimumHeight(0) + self._top_content_widget.setMaximumHeight( + self._top_widget_max_height + ) + self._top_content_widget.setSizePolicy(self._top_widget_size_policy) + + if self._shrunken: + self._shrunk_main_label.setText(self._main_label.text()) + else: + self._shrunk_main_label.setText("") + + if self._shrunken: + content_frame_hint = self._content_frame.sizeHint() + + layout = self.layout() + margins = layout.contentsMargins() + window_height = ( + content_frame_hint.height() + + margins.bottom() + + margins.top() + ) + diff = self.height() - window_height + window_pos = self.pos() + window_pos_y = window_pos.y() + diff + self.resize(self.width(), window_height) + self.move(window_pos.x(), window_pos_y) + + def _set_main_label(self, message): + self._main_label.setText(message) + if self._shrunken: + self._shrunk_main_label.setText(message) + + def _on_publish_reset(self): + self._last_instance_label = None + self._last_plugin_label = None + + self._set_success_property() + self._set_progress_visibility(True) + + self._main_label.setText("Hit publish (play button)! If you want") + self._message_label_top.setText("") + + self._reset_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + self._validate_btn.setEnabled(True) + self._publish_btn.setEnabled(True) + + self._progress_bar.setValue(self.controller.publish_progress) + self._progress_bar.setMaximum(self.controller.publish_max_progress) + + def _on_publish_start(self): + if self._last_plugin_label: + self._plugin_label.setText(self._last_plugin_label) + + if self._last_instance_label: + self._instance_label.setText(self._last_instance_label) + + self._set_success_property(-1) + self._set_progress_visibility(True) + self._set_main_label("Publishing...") + + self._reset_btn.setEnabled(False) + self._stop_btn.setEnabled(True) + self._validate_btn.setEnabled(False) + self._publish_btn.setEnabled(False) + + self.set_shrunk_state(False) + + def _on_publish_validated(self): + self._validate_btn.setEnabled(False) + + def _on_instance_change(self, event): + """Change instance label when instance is going to be processed.""" + + self._last_instance_label = event["instance_label"] + self._instance_label.setText(event["instance_label"]) + QtWidgets.QApplication.processEvents() + + def _on_plugin_change(self, event): + """Change plugin label when instance is going to be processed.""" + + self._last_plugin_label = event["plugin_label"] + self._progress_bar.setValue(self.controller.publish_progress) + self._plugin_label.setText(event["plugin_label"]) + QtWidgets.QApplication.processEvents() + + def _on_publish_stop(self): + self._progress_bar.setValue(self.controller.publish_progress) + + self._reset_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + + self._instance_label.setText("") + self._plugin_label.setText("") + + validate_enabled = not self.controller.publish_has_crashed + publish_enabled = not self.controller.publish_has_crashed + if validate_enabled: + validate_enabled = not self.controller.publish_has_validated + if publish_enabled: + if ( + self.controller.publish_has_validated + and self.controller.publish_has_validation_errors + ): + publish_enabled = False + + else: + publish_enabled = not self.controller.publish_has_finished + + self._validate_btn.setEnabled(validate_enabled) + self._publish_btn.setEnabled(publish_enabled) + + error = self.controller.get_publish_crash_error() + validation_errors = self.controller.get_validation_errors() + if error: + self._set_error(error) + + elif validation_errors: + self._set_progress_visibility(False) + self._set_validation_errors() + + elif self.controller.publish_has_finished: + self._set_finished() + + else: + self._set_stopped() + + def _set_stopped(self): + main_label = "Publish paused" + if self.controller.publish_has_validated: + main_label += " - Validation passed" + + self._set_main_label(main_label) + self._message_label_top.setText( + "Hit publish (play button) to continue." + ) + + self._set_success_property(-1) + + def _set_error(self, error): + self._set_main_label("Error happened") + if isinstance(error, KnownPublishError): + msg = str(error) + else: + msg = ( + "Something went wrong. Send report" + " to your supervisor or OpenPype." + ) + self._message_label_top.setText(msg) + + self._set_success_property(0) + + def _set_validation_errors(self): + self._set_main_label("Your publish didn't pass studio validations") + self._message_label_top.setText("Check results above please") + self._set_success_property(2) + + def _set_finished(self): + self._set_main_label("Finished") + self._message_label_top.setText("") + self._set_success_property(1) + + def _set_progress_visibility(self, visible): + window_height = self.height() + self._progress_widget.setVisible(visible) + # Ignore rescaling and move of widget if is shrunken of progress bar + # should be visible + if self._shrunken or visible: + return + + height = self._progress_widget.height() + diff = height + self._top_content_layout.spacing() + + window_pos = self.pos() + window_pos_y = self.pos().y() + diff + window_height -= diff + + self.resize(self.width(), window_height) + self.move(window_pos.x(), window_pos_y) + + def _set_success_property(self, state=None): + if state is None: + state = "" + else: + state = str(state) + + for widget in (self._progress_bar, self._content_frame): + if widget.property("state") != state: + widget.setProperty("state", state) + widget.style().polish(widget) + + def _copy_report(self): + logs = self.controller.get_publish_report() + logs_string = json.dumps(logs, indent=4) + + mime_data = QtCore.QMimeData() + mime_data.setText(logs_string) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + + def _export_report(self): + default_filename = "publish-report-{}".format( + time.strftime("%y%m%d-%H-%M") + ) + default_filepath = os.path.join( + os.path.expanduser("~"), + default_filename + ) + new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName( + self, "Save report", default_filepath, ".json" + ) + if not ext or not new_filepath: + return + + logs = self.controller.get_publish_report() + full_path = new_filepath + ext + dir_path = os.path.dirname(full_path) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + with open(full_path, "w") as file_stream: + json.dump(logs, file_stream) + + def _on_report_triggered(self, identifier): + if identifier == "export_report": + self._export_report() + + elif identifier == "copy_report": + self._copy_report() + + elif identifier == "go_to_report": + self.details_page_requested.emit() + + def _on_reset_clicked(self): + self.controller.reset() + + def _on_stop_clicked(self): + self.controller.stop_publish() + + def _on_validate_clicked(self): + self.controller.validate() + + def _on_publish_clicked(self): + self.controller.publish() diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py deleted file mode 100644 index b32b5381d1..0000000000 --- a/openpype/tools/publisher/widgets/publish_widget.py +++ /dev/null @@ -1,519 +0,0 @@ -import os -import json -import time - -from Qt import QtWidgets, QtCore, QtGui - -from openpype.pipeline import KnownPublishError - -from .validations_widget import ValidationsWidget -from ..publish_report_viewer import PublishReportViewerWidget -from .widgets import ( - StopBtn, - ResetBtn, - ValidateBtn, - PublishBtn, - CopyPublishReportBtn, - SavePublishReportBtn, - ShowPublishReportBtn -) - - -class ActionsButton(QtWidgets.QToolButton): - def __init__(self, parent=None): - super(ActionsButton, self).__init__(parent) - - self.setText("< No action >") - self.setPopupMode(self.MenuButtonPopup) - menu = QtWidgets.QMenu(self) - - self.setMenu(menu) - - self._menu = menu - self._actions = [] - self._current_action = None - - self.clicked.connect(self._on_click) - - def current_action(self): - return self._current_action - - def add_action(self, action): - self._actions.append(action) - action.triggered.connect(self._on_action_trigger) - self._menu.addAction(action) - if self._current_action is None: - self._set_action(action) - - def set_action(self, action): - if action not in self._actions: - self.add_action(action) - self._set_action(action) - - def _set_action(self, action): - if action is self._current_action: - return - self._current_action = action - self.setText(action.text()) - self.setIcon(action.icon()) - - def _on_click(self): - self._current_action.trigger() - - def _on_action_trigger(self): - action = self.sender() - if action not in self._actions: - return - - self._set_action(action) - - -class PublishFrame(QtWidgets.QFrame): - """Frame showed during publishing. - - Shows all information related to publishing. Contains validation error - widget which is showed if only validation error happens during validation. - - Processing layer is default layer. Validation error layer is shown if only - validation exception is raised during publishing. Report layer is available - only when publishing process is stopped and must be manually triggered to - change into that layer. - - +------------------------------------------------------------------------+ - | | - | | - | | - | < Validation error widget > | - | | - | | - | | - | | - +------------------------------------------------------------------------+ - | < Main label > | - | < Label top > | - | (#### 10% ) | - | | - | Report: