diff --git a/CHANGELOG.md b/CHANGELOG.md index 1eb8455a09..e92c16dc5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,44 +1,83 @@ # Changelog -## [3.7.0-nightly.9](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.8.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.4...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.7.0...HEAD) + +**🆕 New features** + +- Flame: OpenTimelineIO Export Modul [\#2398](https://github.com/pypeclub/OpenPype/pull/2398) + +**🚀 Enhancements** + +- Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510) +- TimersManager: Move module one hierarchy higher [\#2501](https://github.com/pypeclub/OpenPype/pull/2501) +- Ftrack: Event handlers settings [\#2496](https://github.com/pypeclub/OpenPype/pull/2496) +- Tools: Fix style and modality of errors in loader and creator [\#2489](https://github.com/pypeclub/OpenPype/pull/2489) +- Project Manager: Remove project button cleanup [\#2482](https://github.com/pypeclub/OpenPype/pull/2482) +- Tools: Be able to change models of tasks and assets widgets [\#2475](https://github.com/pypeclub/OpenPype/pull/2475) +- Publish pype: Reduce publish process defering [\#2464](https://github.com/pypeclub/OpenPype/pull/2464) +- Maya: Improve speed of Collect History logic [\#2460](https://github.com/pypeclub/OpenPype/pull/2460) +- Maya: Validate Rig Controllers - fix Error: in script editor [\#2459](https://github.com/pypeclub/OpenPype/pull/2459) +- Maya: Optimize Validate Locked Normals speed for dense polymeshes [\#2457](https://github.com/pypeclub/OpenPype/pull/2457) +- Fix \#2453 Refactor missing \_get\_reference\_node method [\#2455](https://github.com/pypeclub/OpenPype/pull/2455) +- Houdini: Remove broken unique name counter [\#2450](https://github.com/pypeclub/OpenPype/pull/2450) +- Maya: Improve lib.polyConstraint performance when Select tool is not the active tool context [\#2447](https://github.com/pypeclub/OpenPype/pull/2447) +- Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383) + +**🐛 Bug fixes** + +- General: Settings work if OpenPypeVersion is available [\#2494](https://github.com/pypeclub/OpenPype/pull/2494) +- General: PYTHONPATH may break OpenPype dependencies [\#2493](https://github.com/pypeclub/OpenPype/pull/2493) +- Workfiles tool: Files widget show files on first show [\#2488](https://github.com/pypeclub/OpenPype/pull/2488) +- General: Custom template paths filter fix [\#2483](https://github.com/pypeclub/OpenPype/pull/2483) +- Loader: Remove always on top flag in tray [\#2480](https://github.com/pypeclub/OpenPype/pull/2480) +- General: Anatomy does not return root envs as unicode [\#2465](https://github.com/pypeclub/OpenPype/pull/2465) + +**Merged pull requests:** + +- General: Modules import function output fix [\#2492](https://github.com/pypeclub/OpenPype/pull/2492) +- AE: fix hiding of alert window below Publish [\#2491](https://github.com/pypeclub/OpenPype/pull/2491) +- Maya: Validate NGONs re-use polyConstraint code from openpype.host.maya.api.lib [\#2458](https://github.com/pypeclub/OpenPype/pull/2458) +- Version handling [\#2363](https://github.com/pypeclub/OpenPype/pull/2363) + +## [3.7.0](https://github.com/pypeclub/OpenPype/tree/3.7.0) (2022-01-04) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.14...3.7.0) **Deprecated:** - General: Default modules hierarchy n2 [\#2368](https://github.com/pypeclub/OpenPype/pull/2368) -**🆕 New features** - -- Settings UI use OpenPype styles [\#2296](https://github.com/pypeclub/OpenPype/pull/2296) - **🚀 Enhancements** +- General: Workdir extra folders [\#2462](https://github.com/pypeclub/OpenPype/pull/2462) +- Photoshop: New style validations for New publisher [\#2429](https://github.com/pypeclub/OpenPype/pull/2429) +- General: Environment variables groups [\#2424](https://github.com/pypeclub/OpenPype/pull/2424) +- Unreal: Dynamic menu created in Python [\#2422](https://github.com/pypeclub/OpenPype/pull/2422) - Settings UI: Hyperlinks to settings [\#2420](https://github.com/pypeclub/OpenPype/pull/2420) - Modules: JobQueue module moved one hierarchy level higher [\#2419](https://github.com/pypeclub/OpenPype/pull/2419) +- TimersManager: Start timer post launch hook [\#2418](https://github.com/pypeclub/OpenPype/pull/2418) +- General: Run applications as separate processes under linux [\#2408](https://github.com/pypeclub/OpenPype/pull/2408) - Ftrack: Check existence of object type on recreation [\#2404](https://github.com/pypeclub/OpenPype/pull/2404) +- Enhancement: Global cleanup plugin that explicitly remove paths from context [\#2402](https://github.com/pypeclub/OpenPype/pull/2402) +- General: MongoDB ability to specify replica set groups [\#2401](https://github.com/pypeclub/OpenPype/pull/2401) - Flame: moving `utility\_scripts` to api folder also with `scripts` [\#2385](https://github.com/pypeclub/OpenPype/pull/2385) - Centos 7 dependency compatibility [\#2384](https://github.com/pypeclub/OpenPype/pull/2384) - Enhancement: Settings: Use project settings values from another project [\#2382](https://github.com/pypeclub/OpenPype/pull/2382) - Blender 3: Support auto install for new blender version [\#2377](https://github.com/pypeclub/OpenPype/pull/2377) - Maya add render image path to settings [\#2375](https://github.com/pypeclub/OpenPype/pull/2375) -- Settings: Webpublisher in hosts enum [\#2367](https://github.com/pypeclub/OpenPype/pull/2367) - Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365) -- Burnins: Be able recognize mxf OPAtom format [\#2361](https://github.com/pypeclub/OpenPype/pull/2361) - Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356) -- Local settings: Copyable studio paths [\#2349](https://github.com/pypeclub/OpenPype/pull/2349) -- Assets Widget: Clear model on project change [\#2345](https://github.com/pypeclub/OpenPype/pull/2345) -- General: OpenPype default modules hierarchy [\#2338](https://github.com/pypeclub/OpenPype/pull/2338) -- General: FFprobe error exception contain original error message [\#2328](https://github.com/pypeclub/OpenPype/pull/2328) -- Resolve: Add experimental button to menu [\#2325](https://github.com/pypeclub/OpenPype/pull/2325) -- General: Reduce vendor imports [\#2305](https://github.com/pypeclub/OpenPype/pull/2305) -- Ftrack: Synchronize input links [\#2287](https://github.com/pypeclub/OpenPype/pull/2287) **🐛 Bug fixes** +- TVPaint: Create render layer dialog is in front [\#2471](https://github.com/pypeclub/OpenPype/pull/2471) +- Short Pyblish plugin path [\#2428](https://github.com/pypeclub/OpenPype/pull/2428) - PS: Introduced settings for invalid characters to use in ValidateNaming plugin [\#2417](https://github.com/pypeclub/OpenPype/pull/2417) - Settings UI: Breadcrumbs path does not create new entities [\#2416](https://github.com/pypeclub/OpenPype/pull/2416) - AfterEffects: Variant 2022 is in defaults but missing in schemas [\#2412](https://github.com/pypeclub/OpenPype/pull/2412) +- Nuke: baking representations was not additive [\#2406](https://github.com/pypeclub/OpenPype/pull/2406) - General: Fix access to environments from default settings [\#2403](https://github.com/pypeclub/OpenPype/pull/2403) - Fix: Placeholder Input color set fix [\#2399](https://github.com/pypeclub/OpenPype/pull/2399) - Settings: Fix state change of wrapper label [\#2396](https://github.com/pypeclub/OpenPype/pull/2396) @@ -48,43 +87,25 @@ - Nuke: fixing menu re-drawing during context change [\#2374](https://github.com/pypeclub/OpenPype/pull/2374) - Webpublisher: Fix assignment of families of TVpaint instances [\#2373](https://github.com/pypeclub/OpenPype/pull/2373) - Nuke: fixing node name based on switched asset name [\#2369](https://github.com/pypeclub/OpenPype/pull/2369) -- JobQueue: Fix loading of settings [\#2362](https://github.com/pypeclub/OpenPype/pull/2362) - Tools: Placeholder color [\#2359](https://github.com/pypeclub/OpenPype/pull/2359) -- Launcher: Minimize button on MacOs [\#2355](https://github.com/pypeclub/OpenPype/pull/2355) -- StandalonePublisher: Fix import of constant [\#2354](https://github.com/pypeclub/OpenPype/pull/2354) -- Adobe products show issue [\#2347](https://github.com/pypeclub/OpenPype/pull/2347) -- Maya Look Assigner: Fix Python 3 compatibility [\#2343](https://github.com/pypeclub/OpenPype/pull/2343) -- Remove wrongly used host for hook [\#2342](https://github.com/pypeclub/OpenPype/pull/2342) -- Tools: Use Qt context on tools show [\#2340](https://github.com/pypeclub/OpenPype/pull/2340) -- Flame: Fix default argument value in custom dictionary [\#2339](https://github.com/pypeclub/OpenPype/pull/2339) -- Timers Manager: Disable auto stop timer on linux platform [\#2334](https://github.com/pypeclub/OpenPype/pull/2334) -- Fix - provider icons are pulled from a folder [\#2326](https://github.com/pypeclub/OpenPype/pull/2326) -- Royal Render: Fix plugin order and OpenPype auto-detection [\#2291](https://github.com/pypeclub/OpenPype/pull/2291) +- Houdini: Fix HDA creation [\#2350](https://github.com/pypeclub/OpenPype/pull/2350) **Merged pull requests:** +- Forced cx\_freeze to include sqlite3 into build [\#2432](https://github.com/pypeclub/OpenPype/pull/2432) +- Maya: Replaced PATH usage with vendored oiio path for maketx utility [\#2405](https://github.com/pypeclub/OpenPype/pull/2405) - \[Fix\]\[MAYA\] Handle message type attribute within CollectLook [\#2394](https://github.com/pypeclub/OpenPype/pull/2394) - Add validator to check correct version of extension for PS and AE [\#2387](https://github.com/pypeclub/OpenPype/pull/2387) - Linux : flip updating submodules logic [\#2357](https://github.com/pypeclub/OpenPype/pull/2357) -- Update of avalon-core [\#2346](https://github.com/pypeclub/OpenPype/pull/2346) -- Maya: configurable model top level validation [\#2321](https://github.com/pypeclub/OpenPype/pull/2321) ## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.1...3.6.4) -**🐛 Bug fixes** - -- Nuke: inventory update removes all loaded read nodes [\#2294](https://github.com/pypeclub/OpenPype/pull/2294) - ## [3.6.3](https://github.com/pypeclub/OpenPype/tree/3.6.3) (2021-11-19) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.3-nightly.1...3.6.3) -**🐛 Bug fixes** - -- Deadline: Fix publish targets [\#2280](https://github.com/pypeclub/OpenPype/pull/2280) - ## [3.6.2](https://github.com/pypeclub/OpenPype/tree/3.6.2) (2021-11-18) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.2-nightly.2...3.6.2) diff --git a/app_launcher.py b/app_launcher.py new file mode 100644 index 0000000000..6dc1518370 --- /dev/null +++ b/app_launcher.py @@ -0,0 +1,49 @@ +"""Launch process that is not child process of python or OpenPype. + +This is written for linux distributions where process tree may affect what +is when closed or blocked to be closed. +""" + +import os +import sys +import subprocess +import json + + +def main(input_json_path): + """Read launch arguments from json file and launch the process. + + Expected that json contains "args" key with string or list of strings. + + Arguments are converted to string using `list2cmdline`. At the end is added + `&` which will cause that launched process is detached and running as + "background" process. + + ## Notes + @iLLiCiT: This should be possible to do with 'disown' or double forking but + I didn't find a way how to do it properly. Disown didn't work as + expected for me and double forking killed parent process which is + unexpected too. + """ + with open(input_json_path, "r") as stream: + data = json.load(stream) + + # Change environment variables + env = data.get("env") or {} + for key, value in env.items(): + os.environ[key] = value + + # Prepare launch arguments + args = data["args"] + if isinstance(args, list): + args = subprocess.list2cmdline(args) + + # Run the command as background process + shell_cmd = args + " &" + os.system(shell_cmd) + sys.exit(0) + + +if __name__ == "__main__": + # Expect that last argument is path to a json with launch args information + main(sys.argv[-1]) diff --git a/igniter/__init__.py b/igniter/__init__.py index defd45e233..02cba6a483 100644 --- a/igniter/__init__.py +++ b/igniter/__init__.py @@ -6,9 +6,18 @@ import sys os.chdir(os.path.dirname(__file__)) # for override sys.path in Deadline -from .bootstrap_repos import BootstrapRepos +from .bootstrap_repos import ( + BootstrapRepos, + OpenPypeVersion +) from .version import __version__ as version +# Store OpenPypeVersion to 'sys.modules' +# - this makes it available in OpenPype processes without modifying +# 'sys.path' or 'PYTHONPATH' +if "OpenPypeVersion" not in sys.modules: + sys.modules["OpenPypeVersion"] = OpenPypeVersion + def open_dialog(): """Show Igniter dialog.""" @@ -22,7 +31,9 @@ def open_dialog(): if scale_attr is not None: QtWidgets.QApplication.setAttribute(scale_attr) - app = QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) d = InstallDialog() d.open() @@ -43,7 +54,9 @@ def open_update_window(openpype_version): if scale_attr is not None: QtWidgets.QApplication.setAttribute(scale_attr) - app = QtWidgets.QApplication(sys.argv) + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) d = UpdateWindow(version=openpype_version) d.open() @@ -53,9 +66,32 @@ def open_update_window(openpype_version): return version_path +def show_message_dialog(title, message): + """Show dialog with a message and title to user.""" + if os.getenv("OPENPYPE_HEADLESS_MODE"): + print("!!! Can't open dialog in headless mode. Exiting.") + sys.exit(1) + from Qt import QtWidgets, QtCore + from .message_dialog import MessageDialog + + scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None) + if scale_attr is not None: + QtWidgets.QApplication.setAttribute(scale_attr) + + app = QtWidgets.QApplication.instance() + if not app: + app = QtWidgets.QApplication(sys.argv) + + dialog = MessageDialog(title, message) + dialog.open() + + app.exec_() + + __all__ = [ "BootstrapRepos", "open_dialog", "open_update_window", + "show_message_dialog", "version" ] diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 151597e505..637f821366 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -22,7 +22,10 @@ from .user_settings import ( OpenPypeSecureRegistry, OpenPypeSettingsRegistry ) -from .tools import get_openpype_path_from_db +from .tools import ( + get_openpype_path_from_db, + get_expected_studio_version_str +) LOG_INFO = 0 @@ -60,6 +63,7 @@ class OpenPypeVersion(semver.VersionInfo): staging = False path = None _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 + _installed_version = None def __init__(self, *args, **kwargs): """Create OpenPype version. @@ -232,6 +236,390 @@ class OpenPypeVersion(semver.VersionInfo): else: return hash(str(self)) + @staticmethod + def is_version_in_dir( + dir_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: + """Test if path item is OpenPype version matching detected version. + + If item is directory that might (based on it's name) + contain OpenPype version, check if it really does contain + OpenPype and that their versions matches. + + Args: + dir_item (Path): Directory to test. + version (OpenPypeVersion): OpenPype version detected + from name. + + Returns: + Tuple: State and reason, True if it is valid OpenPype version, + False otherwise. + + """ + try: + # add one 'openpype' level as inside dir there should + # be many other repositories. + version_str = OpenPypeVersion.get_version_string_from_directory( + dir_item) # noqa: E501 + version_check = OpenPypeVersion(version=version_str) + except ValueError: + return False, f"cannot determine version from {dir_item}" + + version_main = version_check.get_main_version() + detected_main = version.get_main_version() + if version_main != detected_main: + return False, (f"dir version ({version}) and " + f"its content version ({version_check}) " + "doesn't match. Skipping.") + return True, "Versions match" + + @staticmethod + def is_version_in_zip( + zip_item: Path, version: OpenPypeVersion) -> Tuple[bool, str]: + """Test if zip path is OpenPype version matching detected version. + + Open zip file, look inside and parse version from OpenPype + inside it. If there is none, or it is different from + version specified in file name, skip it. + + Args: + zip_item (Path): Zip file to test. + version (OpenPypeVersion): Pype version detected + from name. + + Returns: + Tuple: State and reason, True if it is valid OpenPype version, + False otherwise. + + """ + # skip non-zip files + if zip_item.suffix.lower() != ".zip": + return False, "Not a zip" + + try: + with ZipFile(zip_item, "r") as zip_file: + with zip_file.open( + "openpype/version.py") as version_file: + zip_version = {} + exec(version_file.read(), zip_version) + try: + version_check = OpenPypeVersion( + version=zip_version["__version__"]) + except ValueError as e: + return False, str(e) + + version_main = version_check.get_main_version() # + # noqa: E501 + detected_main = version.get_main_version() + # noqa: E501 + + if version_main != detected_main: + return False, (f"zip version ({version}) " + f"and its content version " + f"({version_check}) " + "doesn't match. Skipping.") + except BadZipFile: + return False, f"{zip_item} is not a zip file" + except KeyError: + return False, "Zip does not contain OpenPype" + return True, "Versions match" + + @staticmethod + def get_version_string_from_directory(repo_dir: Path) -> Union[str, None]: + """Get version of OpenPype in given directory. + + Note: in frozen OpenPype installed in user data dir, this must point + one level deeper as it is: + `openpype-version-v3.0.0/openpype/version.py` + + Args: + repo_dir (Path): Path to OpenPype repo. + + Returns: + str: version string. + None: if OpenPype is not found. + + """ + # try to find version + version_file = Path(repo_dir) / "openpype" / "version.py" + if not version_file.exists(): + return None + + version = {} + with version_file.open("r") as fp: + exec(fp.read(), version) + + return version['__version__'] + + @classmethod + def get_openpype_path(cls): + """Path to openpype zip directory. + + Path can be set through environment variable 'OPENPYPE_PATH' which + is set during start of OpenPype if is not available. + """ + return os.getenv("OPENPYPE_PATH") + + @classmethod + def openpype_path_is_set(cls): + """Path to OpenPype zip directory is set.""" + if cls.get_openpype_path(): + return True + return False + + @classmethod + def openpype_path_is_accessible(cls): + """Path to OpenPype zip directory is accessible. + + Exists for this machine. + """ + # First check if is set + if not cls.openpype_path_is_set(): + return False + + # Validate existence + if Path(cls.get_openpype_path()).exists(): + return True + return False + + @classmethod + def get_local_versions( + cls, production: bool = None, staging: bool = None + ) -> List: + """Get all versions available on this machine. + + Arguments give ability to specify if filtering is needed. If both + arguments are set to None all found versions are returned. + + Args: + production (bool): Return production versions. + staging (bool): Return staging versions. + """ + # Return all local versions if arguments are set to None + if production is None and staging is None: + production = True + staging = True + + elif production is None and not staging: + production = True + + elif staging is None and not production: + staging = True + + # Just return empty output if both are disabled + if not production and not staging: + return [] + + dir_to_search = Path(user_data_dir("openpype", "pypeclub")) + versions = OpenPypeVersion.get_versions_from_directory( + dir_to_search + ) + filtered_versions = [] + for version in versions: + if version.is_staging(): + if staging: + filtered_versions.append(version) + elif production: + filtered_versions.append(version) + return list(sorted(set(filtered_versions))) + + @classmethod + def get_remote_versions( + cls, production: bool = None, staging: bool = None + ) -> List: + """Get all versions available in OpenPype Path. + + Arguments give ability to specify if filtering is needed. If both + arguments are set to None all found versions are returned. + + Args: + production (bool): Return production versions. + staging (bool): Return staging versions. + """ + # Return all local versions if arguments are set to None + if production is None and staging is None: + production = True + staging = True + + elif production is None and not staging: + production = True + + elif staging is None and not production: + staging = True + + # Just return empty output if both are disabled + if not production and not staging: + return [] + + dir_to_search = None + if cls.openpype_path_is_accessible(): + dir_to_search = Path(cls.get_openpype_path()) + else: + registry = OpenPypeSettingsRegistry() + try: + registry_dir = Path(str(registry.get_item("openPypePath"))) + if registry_dir.exists(): + dir_to_search = registry_dir + + except ValueError: + # nothing found in registry, we'll use data dir + pass + + if not dir_to_search: + return [] + + versions = cls.get_versions_from_directory(dir_to_search) + filtered_versions = [] + for version in versions: + if version.is_staging(): + if staging: + filtered_versions.append(version) + elif production: + filtered_versions.append(version) + return list(sorted(set(filtered_versions))) + + @staticmethod + def get_versions_from_directory(openpype_dir: Path) -> List: + """Get all detected OpenPype versions in directory. + + Args: + openpype_dir (Path): Directory to scan. + + Returns: + list of OpenPypeVersion + + Throws: + ValueError: if invalid path is specified. + + """ + if not openpype_dir.exists() and not openpype_dir.is_dir(): + raise ValueError("specified directory is invalid") + + _openpype_versions = [] + # iterate over directory in first level and find all that might + # contain OpenPype. + for item in openpype_dir.iterdir(): + + # if file, strip extension, in case of dir not. + name = item.name if item.is_dir() else item.stem + result = OpenPypeVersion.version_in_str(name) + + if result: + detected_version: OpenPypeVersion + detected_version = result + + if item.is_dir() and not OpenPypeVersion.is_version_in_dir( + item, detected_version + )[0]: + continue + + if item.is_file() and not OpenPypeVersion.is_version_in_zip( + item, detected_version + )[0]: + continue + + detected_version.path = item + _openpype_versions.append(detected_version) + + return sorted(_openpype_versions) + + @staticmethod + def get_installed_version_str() -> str: + """Get version of local OpenPype.""" + + version = {} + path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" + with open(path, "r") as fp: + exec(fp.read(), version) + return version["__version__"] + + @classmethod + def get_installed_version(cls): + """Get version of OpenPype inside build.""" + if cls._installed_version is None: + installed_version_str = cls.get_installed_version_str() + if installed_version_str: + cls._installed_version = OpenPypeVersion( + version=installed_version_str, + path=Path(os.environ["OPENPYPE_ROOT"]) + ) + return cls._installed_version + + @staticmethod + def get_latest_version( + staging: bool = False, + local: bool = None, + remote: bool = None + ) -> OpenPypeVersion: + """Get latest available version. + + The version does not contain information about path and source. + + This is utility version to get latest version from all found. Build + version is not listed if staging is enabled. + + Arguments 'local' and 'remote' define if local and remote repository + versions are used. All versions are used if both are not set (or set + to 'None'). If only one of them is set to 'True' the other is disabled. + It is possible to set both to 'True' (same as both set to None) and to + 'False' in that case only build version can be used. + + Args: + staging (bool, optional): List staging versions if True. + local (bool, optional): List local versions if True. + remote (bool, optional): List remote versions if True. + """ + if local is None and remote is None: + local = True + remote = True + + elif local is None and not remote: + local = True + + elif remote is None and not local: + remote = True + + installed_version = OpenPypeVersion.get_installed_version() + local_versions = [] + remote_versions = [] + if local: + local_versions = OpenPypeVersion.get_local_versions( + staging=staging + ) + if remote: + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging + ) + all_versions = local_versions + remote_versions + if not staging: + all_versions.append(installed_version) + + if not all_versions: + return None + + all_versions.sort() + return all_versions[-1] + + @classmethod + def get_expected_studio_version(cls, staging=False, global_settings=None): + """Expected OpenPype version that should be used at the moment. + + If version is not defined in settings the latest found version is + used. + + Using precached global settings is needed for usage inside OpenPype. + + Args: + staging (bool): Staging version or production version. + global_settings (dict): Optional precached global settings. + + Returns: + OpenPypeVersion: Version that should be used. + """ + result = get_expected_studio_version_str(staging, global_settings) + if not result: + return None + return OpenPypeVersion(version=result) + class BootstrapRepos: """Class for bootstrapping local OpenPype installation. @@ -301,16 +689,6 @@ class BootstrapRepos: return v.path return None - @staticmethod - def get_local_live_version() -> str: - """Get version of local OpenPype.""" - - version = {} - path = Path(os.environ["OPENPYPE_ROOT"]) / "openpype" / "version.py" - with open(path, "r") as fp: - exec(fp.read(), version) - return version["__version__"] - @staticmethod def get_version(repo_dir: Path) -> Union[str, None]: """Get version of OpenPype in given directory. @@ -358,7 +736,7 @@ class BootstrapRepos: # version and use it as a source. Otherwise repo_dir is user # entered location. if not repo_dir: - version = self.get_local_live_version() + version = OpenPypeVersion.get_installed_version_str() repo_dir = self.live_repo_dir else: version = self.get_version(repo_dir) @@ -384,7 +762,7 @@ class BootstrapRepos: destination = self._move_zip_to_data_dir(temp_zip) - return OpenPypeVersion(version=version, path=destination) + return OpenPypeVersion(version=version, path=Path(destination)) def _move_zip_to_data_dir(self, zip_file) -> Union[None, Path]: """Move zip with OpenPype version to user data directory. @@ -734,6 +1112,65 @@ class BootstrapRepos: os.environ["PYTHONPATH"] = os.pathsep.join(paths) + @staticmethod + def find_openpype_version(version, staging): + if isinstance(version, str): + version = OpenPypeVersion(version=version) + + installed_version = OpenPypeVersion.get_installed_version() + if installed_version == version: + return installed_version + + local_versions = OpenPypeVersion.get_local_versions( + staging=staging, production=not staging + ) + zip_version = None + for local_version in local_versions: + if local_version == version: + if local_version.path.suffix.lower() == ".zip": + zip_version = local_version + else: + return local_version + + if zip_version is not None: + return zip_version + + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging, production=not staging + ) + for remote_version in remote_versions: + if remote_version == version: + return remote_version + return None + + @staticmethod + def find_latest_openpype_version(staging): + installed_version = OpenPypeVersion.get_installed_version() + local_versions = OpenPypeVersion.get_local_versions( + staging=staging + ) + remote_versions = OpenPypeVersion.get_remote_versions( + staging=staging + ) + all_versions = local_versions + remote_versions + if not staging: + all_versions.append(installed_version) + + if not all_versions: + return None + + all_versions.sort() + latest_version = all_versions[-1] + if latest_version == installed_version: + return latest_version + + if not latest_version.path.is_dir(): + for version in local_versions: + if version == latest_version and version.path.is_dir(): + latest_version = version + break + return latest_version + def find_openpype( self, openpype_path: Union[Path, str] = None, diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 1fe67e3397..251adebc9f 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -12,7 +12,8 @@ from Qt.QtCore import QTimer # noqa from .install_thread import InstallThread from .tools import ( validate_mongo_connection, - get_openpype_path_from_db + get_openpype_path_from_db, + get_openpype_icon_path ) from .nice_progress_bar import NiceProgressBar @@ -187,7 +188,6 @@ class InstallDialog(QtWidgets.QDialog): current_dir = os.path.dirname(os.path.abspath(__file__)) roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf") poppins_font_path = os.path.join(current_dir, "Poppins") - icon_path = os.path.join(current_dir, "openpype_icon.png") # Install roboto font QtGui.QFontDatabase.addApplicationFont(roboto_font_path) @@ -196,6 +196,7 @@ class InstallDialog(QtWidgets.QDialog): QtGui.QFontDatabase.addApplicationFont(filename) # Load logo + icon_path = get_openpype_icon_path() pixmap_openpype_logo = QtGui.QPixmap(icon_path) # Set logo as icon of window self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) diff --git a/igniter/message_dialog.py b/igniter/message_dialog.py new file mode 100644 index 0000000000..c8e875cc37 --- /dev/null +++ b/igniter/message_dialog.py @@ -0,0 +1,44 @@ +from Qt import QtWidgets, QtGui + +from .tools import ( + load_stylesheet, + get_openpype_icon_path +) + + +class MessageDialog(QtWidgets.QDialog): + """Simple message dialog with title, message and OK button.""" + def __init__(self, title, message): + super(MessageDialog, self).__init__() + + # Set logo as icon of window + icon_path = get_openpype_icon_path() + pixmap_openpype_logo = QtGui.QPixmap(icon_path) + self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo)) + + # Set title + self.setWindowTitle(title) + + # Set message + label_widget = QtWidgets.QLabel(message, self) + + ok_btn = QtWidgets.QPushButton("OK", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(label_widget, 1) + layout.addLayout(btns_layout, 0) + + ok_btn.clicked.connect(self._on_ok_clicked) + + self._label_widget = label_widget + self._ok_btn = ok_btn + + def _on_ok_clicked(self): + self.close() + + def showEvent(self, event): + super(MessageDialog, self).showEvent(event) + self.setStyleSheet(load_stylesheet()) diff --git a/igniter/tools.py b/igniter/tools.py index 3e862f5803..735402e9a2 100644 --- a/igniter/tools.py +++ b/igniter/tools.py @@ -16,6 +16,11 @@ from pymongo.errors import ( ) +class OpenPypeVersionNotFound(Exception): + """OpenPype version was not found in remote and local repository.""" + pass + + def should_add_certificate_path_to_mongo_url(mongo_url): """Check if should add ca certificate to mongo url. @@ -182,6 +187,28 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]: return None +def get_expected_studio_version_str( + staging=False, global_settings=None +) -> str: + """Version that should be currently used in studio. + + Args: + staging (bool): Get current version for staging. + global_settings (dict): Optional precached global settings. + + Returns: + str: OpenPype version which should be used. Empty string means latest. + """ + mongo_url = os.environ.get("OPENPYPE_MONGO") + if global_settings is None: + global_settings = get_openpype_global_settings(mongo_url) + if staging: + key = "staging_version" + else: + key = "production_version" + return global_settings.get(key) or "" + + def load_stylesheet() -> str: """Load css style sheet. @@ -192,3 +219,11 @@ def load_stylesheet() -> str: stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css" return stylesheet_path.read_text() + + +def get_openpype_icon_path() -> str: + """Path to OpenPype icon png file.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "openpype_icon.png" + ) diff --git a/openpype/action.py b/openpype/action.py index 3fc6dd1a8f..50741875e4 100644 --- a/openpype/action.py +++ b/openpype/action.py @@ -72,7 +72,7 @@ class RepairContextAction(pyblish.api.Action): is available on the plugin. """ - label = "Repair Context" + label = "Repair" on = "failed" # This action is only available on a failed plug-in def process(self, context, plugin): diff --git a/openpype/api.py b/openpype/api.py index a6529202ff..51854492ab 100644 --- a/openpype/api.py +++ b/openpype/api.py @@ -31,8 +31,6 @@ from .lib import ( ) from .lib.mongo import ( - decompose_url, - compose_url, get_default_components ) @@ -84,8 +82,6 @@ __all__ = [ "Anatomy", "config", "execute", - "decompose_url", - "compose_url", "get_default_components", "ApplicationManager", "BuildWorkfile", diff --git a/openpype/cli.py b/openpype/cli.py index 6b20fb5203..6e9c237b0e 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -138,7 +138,10 @@ def webpublisherwebserver(debug, executable, upload_dir, host=None, port=None): @click.option("--asset", help="Asset name", default=None) @click.option("--task", help="Task name", default=None) @click.option("--app", help="Application name", default=None) -def extractenvironments(output_json_path, project, asset, task, app): +@click.option( + "--envgroup", help="Environment group (e.g. \"farm\")", default=None +) +def extractenvironments(output_json_path, project, asset, task, app, envgroup): """Extract environment variables for entered context to a json file. Entered output filepath will be created if does not exists. @@ -149,7 +152,7 @@ def extractenvironments(output_json_path, project, asset, task, app): Context options are "project", "asset", "task", "app" """ PypeCommands.extractenvironments( - output_json_path, project, asset, task, app + output_json_path, project, asset, task, app, envgroup ) diff --git a/openpype/hooks/pre_create_extra_workdir_folders.py b/openpype/hooks/pre_create_extra_workdir_folders.py new file mode 100644 index 0000000000..d79c5831ee --- /dev/null +++ b/openpype/hooks/pre_create_extra_workdir_folders.py @@ -0,0 +1,33 @@ +import os +from openpype.lib import ( + PreLaunchHook, + create_workdir_extra_folders +) + + +class AddLastWorkfileToLaunchArgs(PreLaunchHook): + """Add last workfile path to launch arguments. + + This is not possible to do for all applications the same way. + """ + + # Execute after workfile template copy + order = 15 + + def execute(self): + if not self.application.is_host: + return + + env = self.data.get("env") or {} + workdir = env.get("AVALON_WORKDIR") + if not workdir or not os.path.exists(workdir): + return + + host_name = self.application.host_name + task_type = self.data["task_type"] + task_name = self.data["task_name"] + project_name = self.data["project_name"] + + create_workdir_extra_folders( + workdir, host_name, task_type, task_name, project_name, + ) diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index b32fb5e44a..6b08cdb444 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -48,7 +48,7 @@ class GlobalHostDataHook(PreLaunchHook): "log": self.log }) - prepare_host_environments(temp_data) + prepare_host_environments(temp_data, self.launch_context.env_group) prepare_context_environments(temp_data) temp_data.pop("log") diff --git a/openpype/hooks/pre_non_python_host_launch.py b/openpype/hooks/pre_non_python_host_launch.py index 848ed675a8..29e40d28c8 100644 --- a/openpype/hooks/pre_non_python_host_launch.py +++ b/openpype/hooks/pre_non_python_host_launch.py @@ -3,7 +3,7 @@ import subprocess from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) from openpype import PACKAGE_DIR as OPENPYPE_DIR @@ -35,7 +35,7 @@ class NonPythonHostHook(PreLaunchHook): "non_python_host_launch.py" ) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", script_path, executable_path ) # Add workfile path if exists @@ -48,4 +48,3 @@ class NonPythonHostHook(PreLaunchHook): if remainders: self.launch_context.launch_args.extend(remainders) - diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index b796e9eaac..c73a1a1fc1 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,6 +1,7 @@ -import openpype.api -from Qt import QtWidgets from avalon import aftereffects +from avalon.api import CreatorError + +import openpype.api import logging @@ -27,14 +28,13 @@ class CreateRender(openpype.api.Creator): folders=False, footages=False) if len(items) > 1: - self._show_msg("Please select only single composition at time.") - return False + raise CreatorError("Please select only single " + "composition at time.") if not items: - self._show_msg("Nothing to create. Select composition " + - "if 'useSelection' or create at least " + - "one composition.") - return False + raise CreatorError("Nothing to create. Select composition " + + "if 'useSelection' or create at least " + + "one composition.") existing_subsets = [instance['subset'].lower() for instance in aftereffects.list_instances()] @@ -42,8 +42,7 @@ class CreateRender(openpype.api.Creator): item = items.pop() if self.name.lower() in existing_subsets: txt = "Instance with name \"{}\" already exists.".format(self.name) - self._show_msg(txt) - return False + raise CreatorError(txt) self.data["members"] = [item.id] self.data["uuid"] = item.id # for SubsetManager @@ -54,9 +53,3 @@ class CreateRender(openpype.api.Creator): stub.imprint(item, self.data) stub.set_label_color(item.id, 14) # Cyan options 0 - 16 stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) - - def _show_msg(self, txt): - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Warning) - msg.setText(txt) - msg.exec_() diff --git a/openpype/hosts/aftereffects/plugins/load/load_background.py b/openpype/hosts/aftereffects/plugins/load/load_background.py index 9856abe3fe..4d3d46a442 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_background.py +++ b/openpype/hosts/aftereffects/plugins/load/load_background.py @@ -22,21 +22,23 @@ class BackgroundLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): items = stub.get_items(comps=True) - existing_items = [layer.name for layer in items] + existing_items = [layer.name.replace(stub.LOADED_ICON, '') + for layer in items] comp_name = get_unique_layer_name( existing_items, "{}_{}".format(context["asset"]["name"], name)) layers = get_background_layers(self.fname) + if not layers: + raise ValueError("No layers found in {}".format(self.fname)) + comp = stub.import_background(None, stub.LOADED_ICON + comp_name, layers) if not comp: - self.log.warning( - "Import background failed.") - self.log.warning("Check host app for alert error.") - return + raise ValueError("Import background failed. " + "Please contact support") self[:] = [comp] namespace = namespace or comp_name diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py index 48e8dc86c9..02befa76e2 100644 --- a/openpype/hosts/flame/__init__.py +++ b/openpype/hosts/flame/__init__.py @@ -1,105 +1,5 @@ -from .api.utils import ( - setup -) - -from .api.pipeline import ( - install, - uninstall, - ls, - containerise, - update_container, - maintained_selection, - remove_instance, - list_instances, - imprint -) - -from .api.lib import ( - FlameAppFramework, - maintain_current_timeline, - get_project_manager, - get_current_project, - get_current_timeline, - create_bin, -) - -from .api.menu import ( - FlameMenuProjectConnect, - FlameMenuTimeline -) - -from .api.workio import ( - open_file, - save_file, - current_file, - has_unsaved_changes, - file_extensions, - work_root -) - import os HOST_DIR = os.path.dirname( os.path.abspath(__file__) ) -API_DIR = os.path.join(HOST_DIR, "api") -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") - -app_framework = None -apps = [] - - -__all__ = [ - "HOST_DIR", - "API_DIR", - "PLUGINS_DIR", - "PUBLISH_PATH", - "LOAD_PATH", - "CREATE_PATH", - "INVENTORY_PATH", - "INVENTORY_PATH", - - "app_framework", - "apps", - - # pipeline - "install", - "uninstall", - "ls", - "containerise", - "update_container", - "reload_pipeline", - "maintained_selection", - "remove_instance", - "list_instances", - "imprint", - - # utils - "setup", - - # lib - "FlameAppFramework", - "maintain_current_timeline", - "get_project_manager", - "get_current_project", - "get_current_timeline", - "create_bin", - - # menu - "FlameMenuProjectConnect", - "FlameMenuTimeline", - - # plugin - - # workio - "open_file", - "save_file", - "current_file", - "has_unsaved_changes", - "file_extensions", - "work_root" -] diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 50a6b3f098..dc47488dc1 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -1,3 +1,115 @@ """ OpenPype Autodesk Flame api """ +from .constants import ( + COLOR_MAP, + MARKER_NAME, + MARKER_COLOR, + MARKER_DURATION, + MARKER_PUBLISH_DEFAULT +) +from .lib import ( + CTX, + FlameAppFramework, + get_project_manager, + get_current_project, + get_current_sequence, + create_bin, + create_segment_data_marker, + get_segment_data_marker, + set_segment_data_marker, + set_publish_attribute, + get_publish_attribute, + get_sequence_segments, + maintained_segment_selection, + reset_segment_selection, + get_segment_attributes +) +from .utils import ( + setup +) +from .pipeline import ( + install, + uninstall, + ls, + containerise, + update_container, + remove_instance, + list_instances, + imprint, + maintained_selection +) +from .menu import ( + FlameMenuProjectConnect, + FlameMenuTimeline +) +from .plugin import ( + Creator, + PublishableClip +) +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +__all__ = [ + # constants + "COLOR_MAP", + "MARKER_NAME", + "MARKER_COLOR", + "MARKER_DURATION", + "MARKER_PUBLISH_DEFAULT", + + # lib + "CTX", + "FlameAppFramework", + "get_project_manager", + "get_current_project", + "get_current_sequence", + "create_bin", + "create_segment_data_marker", + "get_segment_data_marker", + "set_segment_data_marker", + "set_publish_attribute", + "get_publish_attribute", + "get_sequence_segments", + "maintained_segment_selection", + "reset_segment_selection", + "get_segment_attributes", + + # pipeline + "install", + "uninstall", + "ls", + "containerise", + "update_container", + "reload_pipeline", + "maintained_selection", + "remove_instance", + "list_instances", + "imprint", + "maintained_selection", + + # utils + "setup", + + # menu + "FlameMenuProjectConnect", + "FlameMenuTimeline", + + # plugin + "Creator", + "PublishableClip", + + # workio + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +] diff --git a/openpype/hosts/flame/api/constants.py b/openpype/hosts/flame/api/constants.py new file mode 100644 index 0000000000..1833031e13 --- /dev/null +++ b/openpype/hosts/flame/api/constants.py @@ -0,0 +1,24 @@ + +""" +OpenPype Flame api constances +""" +# OpenPype marker workflow variables +MARKER_NAME = "OpenPypeData" +MARKER_DURATION = 0 +MARKER_COLOR = "cyan" +MARKER_PUBLISH_DEFAULT = False + +# OpenPype color definitions +COLOR_MAP = { + "red": (1.0, 0.0, 0.0), + "orange": (1.0, 0.5, 0.0), + "yellow": (1.0, 1.0, 0.0), + "pink": (1.0, 0.5, 1.0), + "white": (1.0, 1.0, 1.0), + "green": (0.0, 1.0, 0.0), + "cyan": (0.0, 1.0, 1.0), + "blue": (0.0, 0.0, 1.0), + "purple": (0.5, 0.0, 0.5), + "magenta": (0.5, 0.0, 1.0), + "black": (0.0, 0.0, 0.0) +} diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 89e020b329..7788a6b3f4 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -1,12 +1,27 @@ import sys import os +import re +import json import pickle import contextlib from pprint import pformat - +from .constants import ( + MARKER_COLOR, + MARKER_DURATION, + MARKER_NAME, + COLOR_MAP, + MARKER_PUBLISH_DEFAULT +) from openpype.api import Logger -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) + + +class CTX: + # singleton used for passing data between api modules + app_framework = None + flame_apps = [] + selection = None @contextlib.contextmanager @@ -115,10 +130,13 @@ class FlameAppFramework(object): ) self.log.info("[{}] waking up".format(self.__class__.__name__)) - self.load_prefs() + + try: + self.load_prefs() + except RuntimeError: + self.save_prefs() # menu auto-refresh defaults - if not self.prefs_global.get("menu_auto_refresh"): self.prefs_global["menu_auto_refresh"] = { "media_panel": True, @@ -207,40 +225,6 @@ class FlameAppFramework(object): return True -@contextlib.contextmanager -def maintain_current_timeline(to_timeline, from_timeline=None): - """Maintain current timeline selection during context - - Attributes: - from_timeline (resolve.Timeline)[optional]: - Example: - >>> print(from_timeline.GetName()) - timeline1 - >>> print(to_timeline.GetName()) - timeline2 - - >>> with maintain_current_timeline(to_timeline): - ... print(get_current_timeline().GetName()) - timeline2 - - >>> print(get_current_timeline().GetName()) - timeline1 - """ - # todo: this is still Resolve's implementation - project = get_current_project() - working_timeline = from_timeline or project.GetCurrentTimeline() - - # swith to the input timeline - project.SetCurrentTimeline(to_timeline) - - try: - # do a work - yield - finally: - # put the original working timeline to context - project.SetCurrentTimeline(working_timeline) - - def get_project_manager(): # TODO: get_project_manager return @@ -252,13 +236,32 @@ def get_media_storage(): def get_current_project(): - # TODO: get_current_project - return + import flame + return flame.project.current_project -def get_current_timeline(new=False): - # TODO: get_current_timeline - return +def get_current_sequence(selection): + import flame + + def segment_to_sequence(_segment): + track = _segment.parent + version = track.parent + return version.parent + + process_timeline = None + + if len(selection) == 1: + if isinstance(selection[0], flame.PySequence): + process_timeline = selection[0] + if isinstance(selection[0], flame.PySegment): + process_timeline = segment_to_sequence(selection[0]) + else: + for segment in selection: + if isinstance(segment, flame.PySegment): + process_timeline = segment_to_sequence(segment) + break + + return process_timeline def create_bin(name, root=None): @@ -272,3 +275,287 @@ def rescan_hooks(): flame.execute_shortcut('Rescan Python Hooks') except Exception: pass + + +def get_metadata(project_name, _log=None): + from adsk.libwiretapPythonClientAPI import ( + WireTapClient, + WireTapServerHandle, + WireTapNodeHandle, + WireTapStr + ) + + class GetProjectColorPolicy(object): + def __init__(self, host_name=None, _log=None): + # Create a connection to the Backburner manager using the Wiretap + # python API. + # + self.log = _log or log + self.host_name = host_name or "localhost" + self._wiretap_client = WireTapClient() + if not self._wiretap_client.init(): + raise Exception("Could not initialize Wiretap Client") + self._server = WireTapServerHandle( + "{}:IFFFS".format(self.host_name)) + + def process(self, project_name): + policy_node_handle = WireTapNodeHandle( + self._server, + "/projects/{}/syncolor/policy".format(project_name) + ) + self.log.info(policy_node_handle) + + policy = WireTapStr() + if not policy_node_handle.getNodeTypeStr(policy): + self.log.warning( + "Could not retrieve policy of '%s': %s" % ( + policy_node_handle.getNodeId().id(), + policy_node_handle.lastError() + ) + ) + + return policy.c_str() + + policy_wiretap = GetProjectColorPolicy(_log=_log) + return policy_wiretap.process(project_name) + + +def get_segment_data_marker(segment, with_marker=None): + """ + Get openpype track item tag created by creator or loader plugin. + + Attributes: + segment (flame.PySegment): flame api object + with_marker (bool)[optional]: if true it will return also marker object + + Returns: + dict: openpype tag data + + Returns(with_marker=True): + flame.PyMarker, dict + """ + for marker in segment.markers: + comment = marker.comment.get_value() + color = marker.colour.get_value() + name = marker.name.get_value() + + if (name == MARKER_NAME) and ( + color == COLOR_MAP[MARKER_COLOR]): + if not with_marker: + return json.loads(comment) + else: + return marker, json.loads(comment) + + +def set_segment_data_marker(segment, data=None): + """ + Set openpype track item tag to input segment. + + Attributes: + segment (flame.PySegment): flame api object + + Returns: + dict: json loaded data + """ + data = data or dict() + + marker_data = get_segment_data_marker(segment, True) + + if marker_data: + # get available openpype tag if any + marker, tag_data = marker_data + # update tag data with new data + tag_data.update(data) + # update marker with tag data + marker.comment = json.dumps(tag_data) + else: + # update tag data with new data + marker = create_segment_data_marker(segment) + # add tag data to marker's comment + marker.comment = json.dumps(data) + + +def set_publish_attribute(segment, value): + """ Set Publish attribute in input Tag object + + Attribute: + segment (flame.PySegment)): flame api object + value (bool): True or False + """ + tag_data = get_segment_data_marker(segment) + tag_data["publish"] = value + + # set data to the publish attribute + set_segment_data_marker(segment, tag_data) + + +def get_publish_attribute(segment): + """ Get Publish attribute from input Tag object + + Attribute: + segment (flame.PySegment)): flame api object + + Returns: + bool: True or False + """ + tag_data = get_segment_data_marker(segment) + + if not tag_data: + set_publish_attribute(segment, MARKER_PUBLISH_DEFAULT) + return MARKER_PUBLISH_DEFAULT + + return tag_data["publish"] + + +def create_segment_data_marker(segment): + """ Create openpype marker on a segment. + + Attributes: + segment (flame.PySegment): flame api object + + Returns: + flame.PyMarker: flame api object + """ + # get duration of segment + duration = segment.record_duration.relative_frame + # calculate start frame of the new marker + start_frame = int(segment.record_in.relative_frame) + int(duration / 2) + # create marker + marker = segment.create_marker(start_frame) + # set marker name + marker.name = MARKER_NAME + # set duration + marker.duration = MARKER_DURATION + # set colour + marker.colour = COLOR_MAP[MARKER_COLOR] # Red + + return marker + + +def get_sequence_segments(sequence, selected=False): + segments = [] + # loop versions in sequence + for ver in sequence.versions: + # loop track in versions + for track in ver.tracks: + # ignore all empty tracks and hidden too + if len(track.segments) == 0 and track.hidden: + continue + # loop all segment in remaining tracks + for segment in track.segments: + if segment.name.get_value() == "": + continue + if ( + selected is True + and segment.selected.get_value() is not True + ): + continue + # add it to original selection + segments.append(segment) + return segments + + +@contextlib.contextmanager +def maintained_segment_selection(sequence): + """Maintain selection during context + + Attributes: + sequence (flame.PySequence): python api object + + Yield: + list of flame.PySegment + + Example: + >>> with maintained_segment_selection(sequence) as selected_segments: + ... for segment in selected_segments: + ... segment.selected = False + >>> print(segment.selected) + True + """ + selected_segments = get_sequence_segments(sequence, True) + try: + # do the operation on selected segments + yield selected_segments + finally: + # reset all selected clips + reset_segment_selection(sequence) + # select only original selection of segments + for segment in selected_segments: + segment.selected = True + + +def reset_segment_selection(sequence): + """Deselect all selected nodes + """ + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: + segment.selected = False + + +def _get_shot_tokens_values(clip, tokens): + old_value = None + output = {} + + if not clip.shot_name: + return output + + old_value = clip.shot_name.get_value() + + for token in tokens: + clip.shot_name.set_value(token) + _key = str(re.sub("[<>]", "", token)).replace(" ", "_") + + try: + output[_key] = int(clip.shot_name.get_value()) + except ValueError: + output[_key] = clip.shot_name.get_value() + + clip.shot_name.set_value(old_value) + + return output + + +def get_segment_attributes(segment): + if str(segment.name)[1:-1] == "": + return None + + # Add timeline segment to tree + clip_data = { + "segment_name": segment.name.get_value(), + "segment_comment": segment.comment.get_value(), + "tape_name": segment.tape_name, + "source_name": segment.source_name, + "fpath": segment.file_path, + "PySegment": segment + } + + # add all available shot tokens + shot_tokens = _get_shot_tokens_values(segment, [ + "", "", "", "", "", + "", "" + ]) + clip_data.update(shot_tokens) + + # populate shot source metadata + segment_attrs = [ + "record_duration", "record_in", "record_out", + "source_duration", "source_in", "source_out" + ] + segment_attrs_data = {} + for attr_name in segment_attrs: + if not hasattr(segment, attr_name): + continue + attr = getattr(segment, attr_name) + segment_attrs_data[attr] = str(attr).replace("+", ":") + + if attr in ["record_in", "record_out"]: + clip_data[attr_name] = attr.relative_frame + else: + clip_data[attr_name] = attr.frame + + clip_data["segment_timecodes"] = segment_attrs_data + + return clip_data diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index b4f1728acf..b7a94e7866 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -1,10 +1,9 @@ import os from Qt import QtWidgets from copy import deepcopy - +from pprint import pformat from openpype.tools.utils.host_tools import HostToolsHelper - menu_group_name = 'OpenPype' default_flame_export_presets = { @@ -26,6 +25,17 @@ default_flame_export_presets = { } +def callback_selection(selection, function): + import openpype.hosts.flame.api as opfapi + opfapi.CTX.selection = selection + print("Hook Selection: \n\t{}".format( + pformat({ + index: (type(item), item.name) + for index, item in enumerate(opfapi.CTX.selection)}) + )) + function() + + class _FlameMenuApp(object): def __init__(self, framework): self.name = self.__class__.__name__ @@ -97,23 +107,12 @@ class FlameMenuProjectConnect(_FlameMenuApp): if not self.flame: return [] - flame_project_name = self.flame_project_name - self.log.info("______ {} ______".format(flame_project_name)) - menu = deepcopy(self.menu) menu['actions'].append({ "name": "Workfiles ...", "execute": lambda x: self.tools_helper.show_workfiles() }) - menu['actions'].append({ - "name": "Create ...", - "execute": lambda x: self.tools_helper.show_creator() - }) - menu['actions'].append({ - "name": "Publish ...", - "execute": lambda x: self.tools_helper.show_publish() - }) menu['actions'].append({ "name": "Load ...", "execute": lambda x: self.tools_helper.show_loader() @@ -128,9 +127,6 @@ class FlameMenuProjectConnect(_FlameMenuApp): }) return menu - def get_projects(self, *args, **kwargs): - pass - def refresh(self, *args, **kwargs): self.rescan() @@ -165,18 +161,17 @@ class FlameMenuTimeline(_FlameMenuApp): if not self.flame: return [] - flame_project_name = self.flame_project_name - self.log.info("______ {} ______".format(flame_project_name)) - menu = deepcopy(self.menu) menu['actions'].append({ "name": "Create ...", - "execute": lambda x: self.tools_helper.show_creator() + "execute": lambda x: callback_selection( + x, self.tools_helper.show_creator) }) menu['actions'].append({ "name": "Publish ...", - "execute": lambda x: self.tools_helper.show_publish() + "execute": lambda x: callback_selection( + x, self.tools_helper.show_publish) }) menu['actions'].append({ "name": "Load ...", @@ -189,9 +184,6 @@ class FlameMenuTimeline(_FlameMenuApp): return menu - def get_projects(self, *args, **kwargs): - pass - def refresh(self, *args, **kwargs): self.rescan() diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 26dfe7c032..30c70b491b 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -1,25 +1,33 @@ """ Basic avalon integration """ +import os import contextlib from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger +from .lib import ( + set_segment_data_marker, + set_publish_attribute, + maintained_segment_selection, + get_current_sequence, + reset_segment_selection +) +from .. import HOST_DIR + +API_DIR = os.path.join(HOST_DIR, "api") +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") AVALON_CONTAINERS = "AVALON_CONTAINERS" -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def install(): - from .. import ( - PUBLISH_PATH, - LOAD_PATH, - CREATE_PATH, - INVENTORY_PATH - ) - # TODO: install - # Disable all families except for the ones we explicitly want to see family_states = [ "imagesequence", @@ -32,33 +40,24 @@ def install(): avalon.data["familiesStateDefault"] = False avalon.data["familiesStateToggled"] = family_states - log.info("openpype.hosts.flame installed") pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) - log.info("Registering Flame plug-ins..") - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) avalon.register_plugin_path(avalon.Creator, CREATE_PATH) avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + log.info("OpenPype Flame plug-ins registred ...") # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + log.info("OpenPype Flame host installed ...") def uninstall(): - from .. import ( - PUBLISH_PATH, - LOAD_PATH, - CREATE_PATH, - INVENTORY_PATH - ) - - # TODO: uninstall pyblish.deregister_host("flame") - pyblish.deregister_plugin_path(PUBLISH_PATH) - log.info("Deregistering DaVinci Resovle plug-ins..") + log.info("Deregistering Flame plug-ins..") + pyblish.deregister_plugin_path(PUBLISH_PATH) avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) @@ -66,6 +65,8 @@ def uninstall(): # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + log.info("OpenPype Flame host uninstalled ...") + def containerise(tl_segment, name, @@ -97,32 +98,6 @@ def update_container(tl_segment, data=None): # TODO: update_container pass - -@contextlib.contextmanager -def maintained_selection(): - """Maintain selection during context - - Example: - >>> with maintained_selection(): - ... node['selected'].setValue(True) - >>> print(node['selected'].value()) - False - """ - # TODO: maintained_selection + remove undo steps - - try: - # do the operation - yield - finally: - pass - - -def reset_selection(): - """Deselect all selected nodes - """ - pass - - def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle node passthrough states on instance toggles.""" @@ -150,6 +125,46 @@ def list_instances(): pass -def imprint(item, data=None): - # TODO: imprint - pass +def imprint(segment, data=None): + """ + Adding openpype data to Flame timeline segment. + + Also including publish attribute into tag. + + Arguments: + segment (flame.PySegment)): flame api object + data (dict): Any data which needst to be imprinted + + Examples: + data = { + 'asset': 'sq020sh0280', + 'family': 'render', + 'subset': 'subsetMain' + } + """ + data = data or {} + + set_segment_data_marker(segment, data) + + # add publish attribute + set_publish_attribute(segment, True) + + +@contextlib.contextmanager +def maintained_selection(): + import flame + from .lib import CTX + + # check if segment is selected + if isinstance(CTX.selection[0], flame.PySegment): + sequence = get_current_sequence(CTX.selection) + + try: + with maintained_segment_selection(sequence) as selected: + yield + finally: + # reset all selected clips + reset_segment_selection(sequence) + # select only original selection of segments + for segment in selected: + segment.selected = True diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 2a28a20a75..f34999bcf3 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,3 +1,646 @@ -# Creator plugin functions +import re +from Qt import QtWidgets, QtCore +import openpype.api as openpype +from openpype import style +from . import ( + lib as flib, + pipeline as fpipeline, + constants +) + +from copy import deepcopy + +log = openpype.Logger.get_logger(__name__) + + +class CreatorWidget(QtWidgets.QDialog): + + # output items + items = dict() + _results_back = None + + def __init__(self, name, info, ui_inputs, parent=None): + super(CreatorWidget, self).__init__(parent) + + self.setObjectName(name) + + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.WindowTitleHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowStaysOnTopHint + ) + self.setWindowTitle(name or "Pype Creator Input") + self.resize(500, 700) + + # Where inputs and labels are set + self.content_widget = [QtWidgets.QWidget(self)] + top_layout = QtWidgets.QFormLayout(self.content_widget[0]) + top_layout.setObjectName("ContentLayout") + top_layout.addWidget(Spacer(5, self)) + + # first add widget tag line + top_layout.addWidget(QtWidgets.QLabel(info)) + + # main dynamic layout + self.scroll_area = QtWidgets.QScrollArea(self, widgetResizable=True) + self.scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarAsNeeded) + self.scroll_area.setVerticalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOn) + self.scroll_area.setHorizontalScrollBarPolicy( + QtCore.Qt.ScrollBarAlwaysOff) + self.scroll_area.setWidgetResizable(True) + + self.content_widget.append(self.scroll_area) + + scroll_widget = QtWidgets.QWidget(self) + in_scroll_area = QtWidgets.QVBoxLayout(scroll_widget) + self.content_layout = [in_scroll_area] + + # add preset data into input widget layout + self.items = self.populate_widgets(ui_inputs) + self.scroll_area.setWidget(scroll_widget) + + # Confirmation buttons + btns_widget = QtWidgets.QWidget(self) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + + cancel_btn = QtWidgets.QPushButton("Cancel") + btns_layout.addWidget(cancel_btn) + + ok_btn = QtWidgets.QPushButton("Ok") + btns_layout.addWidget(ok_btn) + + # Main layout of the dialog + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(0) + + # adding content widget + for w in self.content_widget: + main_layout.addWidget(w) + + main_layout.addWidget(btns_widget) + + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self.setStyleSheet(style.load_stylesheet()) + + @classmethod + def set_results_back(cls, value): + cls._results_back = value + + @classmethod + def get_results_back(cls): + return cls._results_back + + def _on_ok_clicked(self): + log.debug("ok is clicked: {}".format(self.items)) + results_back = self._values(self.items) + self.set_results_back(results_back) + self.close() + + def _on_cancel_clicked(self): + self.set_results_back(None) + self.close() + + def showEvent(self, event): + self.set_results_back(None) + super(CreatorWidget, self).showEvent(event) + + def _values(self, data, new_data=None): + new_data = new_data or dict() + for k, v in data.items(): + new_data[k] = { + "target": None, + "value": None + } + if v["type"] == "dict": + new_data[k]["target"] = v["target"] + new_data[k]["value"] = self._values(v["value"]) + if v["type"] == "section": + new_data.pop(k) + new_data = self._values(v["value"], new_data) + elif getattr(v["value"], "currentText", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].currentText() + elif getattr(v["value"], "isChecked", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].isChecked() + elif getattr(v["value"], "value", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].value() + elif getattr(v["value"], "text", None): + new_data[k]["target"] = v["target"] + new_data[k]["value"] = v["value"].text() + + return new_data + + def camel_case_split(self, text): + matches = re.finditer( + '.+?(?:(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|$)', text) + return " ".join([str(m.group(0)).capitalize() for m in matches]) + + def create_row(self, layout, type_name, text, **kwargs): + # get type attribute from qwidgets + attr = getattr(QtWidgets, type_name) + + # convert label text to normal capitalized text with spaces + label_text = self.camel_case_split(text) + + # assign the new text to lable widget + label = QtWidgets.QLabel(label_text) + label.setObjectName("LineLabel") + + # create attribute name text strip of spaces + attr_name = text.replace(" ", "") + + # create attribute and assign default values + setattr( + self, + attr_name, + attr(parent=self)) + + # assign the created attribute to variable + item = getattr(self, attr_name) + for func, val in kwargs.items(): + if getattr(item, func): + func_attr = getattr(item, func) + func_attr(val) + + # add to layout + layout.addRow(label, item) + + return item + + def populate_widgets(self, data, content_layout=None): + """ + Populate widget from input dict. + + Each plugin has its own set of widget rows defined in dictionary + each row values should have following keys: `type`, `target`, + `label`, `order`, `value` and optionally also `toolTip`. + + Args: + data (dict): widget rows or organized groups defined + by types `dict` or `section` + content_layout (QtWidgets.QFormLayout)[optional]: used when nesting + + Returns: + dict: redefined data dict updated with created widgets + + """ + + content_layout = content_layout or self.content_layout[-1] + # fix order of process by defined order value + ordered_keys = list(data.keys()) + for k, v in data.items(): + try: + # try removing a key from index which should + # be filled with new + ordered_keys.pop(v["order"]) + except IndexError: + pass + # add key into correct order + ordered_keys.insert(v["order"], k) + + # process ordered + for k in ordered_keys: + v = data[k] + tool_tip = v.get("toolTip", "") + if v["type"] == "dict": + self.content_layout.append(QtWidgets.QWidget(self)) + content_layout.addWidget(self.content_layout[-1]) + self.content_layout[-1].setObjectName("sectionHeadline") + + headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) + headline.addWidget(Spacer(20, self)) + headline.addWidget(QtWidgets.QLabel(v["label"])) + + # adding nested layout with label + self.content_layout.append(QtWidgets.QWidget(self)) + self.content_layout[-1].setObjectName("sectionContent") + + nested_content_layout = QtWidgets.QFormLayout( + self.content_layout[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + content_layout.addWidget(self.content_layout[-1]) + + # add nested key as label + data[k]["value"] = self.populate_widgets( + v["value"], nested_content_layout) + + if v["type"] == "section": + self.content_layout.append(QtWidgets.QWidget(self)) + content_layout.addWidget(self.content_layout[-1]) + self.content_layout[-1].setObjectName("sectionHeadline") + + headline = QtWidgets.QVBoxLayout(self.content_layout[-1]) + headline.addWidget(Spacer(20, self)) + headline.addWidget(QtWidgets.QLabel(v["label"])) + + # adding nested layout with label + self.content_layout.append(QtWidgets.QWidget(self)) + self.content_layout[-1].setObjectName("sectionContent") + + nested_content_layout = QtWidgets.QFormLayout( + self.content_layout[-1]) + nested_content_layout.setObjectName("NestedContentLayout") + content_layout.addWidget(self.content_layout[-1]) + + # add nested key as label + data[k]["value"] = self.populate_widgets( + v["value"], nested_content_layout) + + elif v["type"] == "QLineEdit": + data[k]["value"] = self.create_row( + content_layout, "QLineEdit", v["label"], + setText=v["value"], setToolTip=tool_tip) + elif v["type"] == "QComboBox": + data[k]["value"] = self.create_row( + content_layout, "QComboBox", v["label"], + addItems=v["value"], setToolTip=tool_tip) + elif v["type"] == "QCheckBox": + data[k]["value"] = self.create_row( + content_layout, "QCheckBox", v["label"], + setChecked=v["value"], setToolTip=tool_tip) + elif v["type"] == "QSpinBox": + data[k]["value"] = self.create_row( + content_layout, "QSpinBox", v["label"], + setValue=v["value"], setMinimum=0, + setMaximum=100000, setToolTip=tool_tip) + return data + + +class Spacer(QtWidgets.QWidget): + def __init__(self, height, *args, **kwargs): + super(self.__class__, self).__init__(*args, **kwargs) + + self.setFixedHeight(height) + + real_spacer = QtWidgets.QWidget(self) + real_spacer.setObjectName("Spacer") + real_spacer.setFixedHeight(height) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(real_spacer) + + self.setLayout(layout) + + +class Creator(openpype.Creator): + """Creator class wrapper + """ + clip_color = constants.COLOR_MAP["purple"] + rename_index = None + + def __init__(self, *args, **kwargs): + super(Creator, self).__init__(*args, **kwargs) + self.presets = openpype.get_current_project_settings()[ + "flame"]["create"].get(self.__class__.__name__, {}) + + # adding basic current context flame objects + self.project = flib.get_current_project() + self.sequence = flib.get_current_sequence(flib.CTX.selection) + + if (self.options or {}).get("useSelection"): + self.selected = flib.get_sequence_segments(self.sequence, True) + else: + self.selected = flib.get_sequence_segments(self.sequence) + + def create_widget(self, *args, **kwargs): + widget = CreatorWidget(*args, **kwargs) + widget.exec_() + return widget.get_results_back() + + +class PublishableClip: + """ + Convert a segment to publishable instance + + Args: + segment (flame.PySegment): flame api object + kwargs (optional): additional data needed for rename=True (presets) + + Returns: + flame.PySegment: flame api object + """ + vertical_clip_match = {} + marker_data = {} + types = { + "shot": "shot", + "folder": "folder", + "episode": "episode", + "sequence": "sequence", + "track": "sequence", + } + + # parents search patern + parents_search_patern = r"\{([a-z]*?)\}" + + # default templates for non-ui use + rename_default = False + hierarchy_default = "{_folder_}/{_sequence_}/{_track_}" + clip_name_default = "shot_{_trackIndex_:0>3}_{_clipIndex_:0>4}" + subset_name_default = "[ track name ]" + review_track_default = "[ none ]" + subset_family_default = "plate" + count_from_default = 10 + count_steps_default = 10 + vertical_sync_default = False + driving_layer_default = "" + index_from_segment_default = False + + def __init__(self, segment, **kwargs): + self.rename_index = kwargs["rename_index"] + self.family = kwargs["family"] + self.log = kwargs["log"] + + # get main parent objects + self.current_segment = segment + sequence_name = flib.get_current_sequence([segment]).name.get_value() + self.sequence_name = str(sequence_name).replace(" ", "_") + + self.clip_data = flib.get_segment_attributes(segment) + # segment (clip) main attributes + self.cs_name = self.clip_data["segment_name"] + self.cs_index = int(self.clip_data["segment"]) + + # get track name and index + self.track_index = int(self.clip_data["track"]) + track_name = self.clip_data["track_name"] + self.track_name = str(track_name).replace(" ", "_").replace( + "*", "noname{}".format(self.track_index)) + + # adding tag.family into tag + if kwargs.get("avalon"): + self.marker_data.update(kwargs["avalon"]) + + # add publish attribute to marker data + self.marker_data.update({"publish": True}) + + # adding ui inputs if any + self.ui_inputs = kwargs.get("ui_inputs", {}) + + self.log.info("Inside of plugin: {}".format( + self.marker_data + )) + # populate default data before we get other attributes + self._populate_segment_default_data() + + # use all populated default data to create all important attributes + self._populate_attributes() + + # create parents with correct types + self._create_parents() + + def convert(self): + + # solve segment data and add them to marker data + self._convert_to_marker_data() + + # if track name is in review track name and also if driving track name + # is not in review track name: skip tag creation + if (self.track_name in self.review_layer) and ( + self.driving_layer not in self.review_layer): + return + + # deal with clip name + new_name = self.marker_data.pop("newClipName") + + if self.rename: + # rename segment + self.current_segment.name = str(new_name) + self.marker_data["asset"] = str(new_name) + else: + self.marker_data["asset"] = self.cs_name + self.marker_data["hierarchyData"]["shot"] = self.cs_name + + if self.marker_data["heroTrack"] and self.review_layer: + self.marker_data.update({"reviewTrack": self.review_layer}) + else: + self.marker_data.update({"reviewTrack": None}) + + # create pype tag on track_item and add data + fpipeline.imprint(self.current_segment, self.marker_data) + + return self.current_segment + + def _populate_segment_default_data(self): + """ Populate default formating data from segment. """ + + self.current_segment_default_data = { + "_folder_": "shots", + "_sequence_": self.sequence_name, + "_track_": self.track_name, + "_clip_": self.cs_name, + "_trackIndex_": self.track_index, + "_clipIndex_": self.cs_index + } + + def _populate_attributes(self): + """ Populate main object attributes. """ + # segment frame range and parent track name for vertical sync check + self.clip_in = int(self.clip_data["record_in"]) + self.clip_out = int(self.clip_data["record_out"]) + + # define ui inputs if non gui mode was used + self.shot_num = self.cs_index + self.log.debug( + "____ self.shot_num: {}".format(self.shot_num)) + + # ui_inputs data or default values if gui was not used + self.rename = self.ui_inputs.get( + "clipRename", {}).get("value") or self.rename_default + self.clip_name = self.ui_inputs.get( + "clipName", {}).get("value") or self.clip_name_default + self.hierarchy = self.ui_inputs.get( + "hierarchy", {}).get("value") or self.hierarchy_default + self.hierarchy_data = self.ui_inputs.get( + "hierarchyData", {}).get("value") or \ + self.current_segment_default_data.copy() + self.index_from_segment = self.ui_inputs.get( + "segmentIndex", {}).get("value") or self.index_from_segment_default + self.count_from = self.ui_inputs.get( + "countFrom", {}).get("value") or self.count_from_default + self.count_steps = self.ui_inputs.get( + "countSteps", {}).get("value") or self.count_steps_default + self.subset_name = self.ui_inputs.get( + "subsetName", {}).get("value") or self.subset_name_default + self.subset_family = self.ui_inputs.get( + "subsetFamily", {}).get("value") or self.subset_family_default + self.vertical_sync = self.ui_inputs.get( + "vSyncOn", {}).get("value") or self.vertical_sync_default + self.driving_layer = self.ui_inputs.get( + "vSyncTrack", {}).get("value") or self.driving_layer_default + self.review_track = self.ui_inputs.get( + "reviewTrack", {}).get("value") or self.review_track_default + self.audio = self.ui_inputs.get( + "audio", {}).get("value") or False + + # build subset name from layer name + if self.subset_name == "[ track name ]": + self.subset_name = self.track_name + + # create subset for publishing + self.subset = self.subset_family + self.subset_name.capitalize() + + def _replace_hash_to_expression(self, name, text): + """ Replace hash with number in correct padding. """ + _spl = text.split("#") + _len = (len(_spl) - 1) + _repl = "{{{0}:0>{1}}}".format(name, _len) + return text.replace(("#" * _len), _repl) + + def _convert_to_marker_data(self): + """ Convert internal data to marker data. + + Populating the marker data into internal variable self.marker_data + """ + # define vertical sync attributes + hero_track = True + self.review_layer = "" + if self.vertical_sync and self.track_name not in self.driving_layer: + # if it is not then define vertical sync as None + hero_track = False + + # increasing steps by index of rename iteration + if not self.index_from_segment: + self.count_steps *= self.rename_index + + hierarchy_formating_data = {} + hierarchy_data = deepcopy(self.hierarchy_data) + _data = self.current_segment_default_data.copy() + if self.ui_inputs: + # adding tag metadata from ui + for _k, _v in self.ui_inputs.items(): + if _v["target"] == "tag": + self.marker_data[_k] = _v["value"] + + # driving layer is set as positive match + if hero_track or self.vertical_sync: + # mark review layer + if self.review_track and ( + self.review_track not in self.review_track_default): + # if review layer is defined and not the same as defalut + self.review_layer = self.review_track + + # shot num calculate + if self.index_from_segment: + # use clip index from timeline + self.shot_num = self.count_steps * self.cs_index + else: + if self.rename_index == 0: + self.shot_num = self.count_from + else: + self.shot_num = self.count_from + self.count_steps + + # clip name sequence number + _data.update({"shot": self.shot_num}) + + # solve # in test to pythonic expression + for _k, _v in hierarchy_data.items(): + if "#" not in _v["value"]: + continue + hierarchy_data[ + _k]["value"] = self._replace_hash_to_expression( + _k, _v["value"]) + + # fill up pythonic expresisons in hierarchy data + for k, _v in hierarchy_data.items(): + hierarchy_formating_data[k] = _v["value"].format(**_data) + else: + # if no gui mode then just pass default data + hierarchy_formating_data = hierarchy_data + + tag_hierarchy_data = self._solve_tag_hierarchy_data( + hierarchy_formating_data + ) + + tag_hierarchy_data.update({"heroTrack": True}) + if hero_track and self.vertical_sync: + self.vertical_clip_match.update({ + (self.clip_in, self.clip_out): tag_hierarchy_data + }) + + if not hero_track and self.vertical_sync: + # driving layer is set as negative match + for (_in, _out), hero_data in self.vertical_clip_match.items(): + hero_data.update({"heroTrack": False}) + if _in == self.clip_in and _out == self.clip_out: + data_subset = hero_data["subset"] + # add track index in case duplicity of names in hero data + if self.subset in data_subset: + hero_data["subset"] = self.subset + str( + self.track_index) + # in case track name and subset name is the same then add + if self.subset_name == self.track_name: + hero_data["subset"] = self.subset + # assing data to return hierarchy data to tag + tag_hierarchy_data = hero_data + + # add data to return data dict + self.marker_data.update(tag_hierarchy_data) + + def _solve_tag_hierarchy_data(self, hierarchy_formating_data): + """ Solve marker data from hierarchy data and templates. """ + # fill up clip name and hierarchy keys + hierarchy_filled = self.hierarchy.format(**hierarchy_formating_data) + clip_name_filled = self.clip_name.format(**hierarchy_formating_data) + + # remove shot from hierarchy data: is not needed anymore + hierarchy_formating_data.pop("shot") + + return { + "newClipName": clip_name_filled, + "hierarchy": hierarchy_filled, + "parents": self.parents, + "hierarchyData": hierarchy_formating_data, + "subset": self.subset, + "family": self.subset_family, + "families": [self.family] + } + + def _convert_to_entity(self, type, template): + """ Converting input key to key with type. """ + # convert to entity type + entity_type = self.types.get(type, None) + + assert entity_type, "Missing entity type for `{}`".format( + type + ) + + # first collect formating data to use for formating template + formating_data = {} + for _k, _v in self.hierarchy_data.items(): + value = _v["value"].format( + **self.current_segment_default_data) + formating_data[_k] = value + + return { + "entity_type": entity_type, + "entity_name": template.format( + **formating_data + ) + } + + def _create_parents(self): + """ Create parents and return it in list. """ + self.parents = [] + + patern = re.compile(self.parents_search_patern) + + par_split = [(patern.findall(t).pop(), t) + for t in self.hierarchy.split("/")] + + for type, template in par_split: + parent = self._convert_to_entity(type, template) + self.parents.append(parent) + + # Publishing plugin functions # Loader plugin functions diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index d8dc1884cf..2cd9a46184 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -9,26 +9,14 @@ import json import xml.dom.minidom as minidom from copy import deepcopy import datetime - -try: - from libwiretapPythonClientAPI import ( - WireTapClientInit) -except ImportError: - flame_python_path = "/opt/Autodesk/flame_2021/python" - flame_exe_path = ( - "/opt/Autodesk/flame_2021/bin/flame.app" - "/Contents/MacOS/startApp") - - sys.path.append(flame_python_path) - - from libwiretapPythonClientAPI import ( - WireTapClientInit, - WireTapClientUninit, - WireTapNodeHandle, - WireTapServerHandle, - WireTapInt, - WireTapStr - ) +from libwiretapPythonClientAPI import ( # noqa + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr +) class WireTapCom(object): @@ -55,6 +43,9 @@ class WireTapCom(object): self.volume_name = volume_name or "stonefs" self.group_name = group_name or "staff" + # wiretap tools dir path + self.wiretap_tools_dir = os.getenv("OPENPYPE_WIRETAP_TOOLS") + # initialize WireTap client WireTapClientInit() @@ -84,9 +75,11 @@ class WireTapCom(object): workspace_name = kwargs.get("workspace_name") color_policy = kwargs.get("color_policy") - self._project_prep(project_name) - self._set_project_settings(project_name, project_data) - self._set_project_colorspace(project_name, color_policy) + project_exists = self._project_prep(project_name) + if not project_exists: + self._set_project_settings(project_name, project_data) + self._set_project_colorspace(project_name, color_policy) + user_name = self._user_prep(user_name) if workspace_name is None: @@ -169,18 +162,15 @@ class WireTapCom(object): # check if volumes exists if self.volume_name not in volumes: raise AttributeError( - ("Volume '{}' does not exist '{}'").format( + ("Volume '{}' does not exist in '{}'").format( self.volume_name, volumes) ) # form cmd arguments project_create_cmd = [ os.path.join( - "/opt/Autodesk/", - "wiretap", - "tools", - "2021", - "wiretap_create_node", + self.wiretap_tools_dir, + "wiretap_create_node" ), '-n', os.path.join("/volumes", self.volume_name), @@ -202,6 +192,7 @@ class WireTapCom(object): print( "A new project '{}' is created.".format(project_name)) + return project_exists def _get_all_volumes(self): """Request all available volumens from WireTap @@ -431,11 +422,8 @@ class WireTapCom(object): color_policy = color_policy or "Legacy" project_colorspace_cmd = [ os.path.join( - "/opt/Autodesk/", - "wiretap", - "tools", - "2021", - "wiretap_duplicate_node", + self.wiretap_tools_dir, + "wiretap_duplicate_node" ), "-s", "/syncolor/policies/Autodesk/{}".format(color_policy), diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py index c5fa881f3c..72614f2b5d 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_in_flame.py @@ -5,17 +5,14 @@ from pprint import pformat import atexit import openpype import avalon -import openpype.hosts.flame as opflame - -flh = sys.modules[__name__] -flh._project = None +import openpype.hosts.flame.api as opfapi def openpype_install(): """Registering OpenPype in context """ openpype.install() - avalon.api.install(opflame) + avalon.api.install(opfapi) print("Avalon registred hosts: {}".format( avalon.api.registered_host())) @@ -48,30 +45,34 @@ sys.excepthook = exeption_handler def cleanup(): """Cleaning up Flame framework context """ - if opflame.apps: - print('`{}` cleaning up apps:\n {}\n'.format( - __file__, pformat(opflame.apps))) - while len(opflame.apps): - app = opflame.apps.pop() + if opfapi.CTX.flame_apps: + print('`{}` cleaning up flame_apps:\n {}\n'.format( + __file__, pformat(opfapi.CTX.flame_apps))) + while len(opfapi.CTX.flame_apps): + app = opfapi.CTX.flame_apps.pop() print('`{}` removing : {}'.format(__file__, app.name)) del app - opflame.apps = [] + opfapi.CTX.flame_apps = [] - if opflame.app_framework: - print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name) - opflame.app_framework.save_prefs() - opflame.app_framework = None + if opfapi.CTX.app_framework: + print('openpype\t: {} cleaning up'.format( + opfapi.CTX.app_framework.bundle_name) + ) + opfapi.CTX.app_framework.save_prefs() + opfapi.CTX.app_framework = None atexit.register(cleanup) def load_apps(): - """Load available apps into Flame framework + """Load available flame_apps into Flame framework """ - opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) - opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) - opflame.app_framework.log.info("Apps are loaded") + opfapi.CTX.flame_apps.append( + opfapi.FlameMenuProjectConnect(opfapi.CTX.app_framework)) + opfapi.CTX.flame_apps.append( + opfapi.FlameMenuTimeline(opfapi.CTX.app_framework)) + opfapi.CTX.app_framework.log.info("Apps are loaded") def project_changed_dict(info): @@ -89,10 +90,10 @@ def app_initialized(parent=None): Args: parent (obj, optional): Parent object. Defaults to None. """ - opflame.app_framework = opflame.FlameAppFramework() + opfapi.CTX.app_framework = opfapi.FlameAppFramework() print("{} initializing".format( - opflame.app_framework.bundle_name)) + opfapi.CTX.app_framework.bundle_name)) load_apps() @@ -103,7 +104,7 @@ Initialisation of the hook is starting from here First it needs to test if it can import the flame modul. This will happen only in case a project has been loaded. Then `app_initialized` will load main Framework which will load -all menu objects as apps. +all menu objects as flame_apps. """ try: @@ -131,15 +132,15 @@ def _build_app_menu(app_name): # first find the relative appname app = None - for _app in opflame.apps: + for _app in opfapi.CTX.flame_apps: if _app.__class__.__name__ == app_name: app = _app if app: menu.append(app.build_menu()) - if opflame.app_framework: - menu_auto_refresh = opflame.app_framework.prefs_global.get( + if opfapi.CTX.app_framework: + menu_auto_refresh = opfapi.CTX.app_framework.prefs_global.get( 'menu_auto_refresh', {}) if menu_auto_refresh.get('timeline_menu', True): try: @@ -163,8 +164,8 @@ def project_saved(project_name, save_time, is_auto_save): save_time (str): time when it was saved is_auto_save (bool): autosave is on or off """ - if opflame.app_framework: - opflame.app_framework.save_prefs() + if opfapi.CTX.app_framework: + opfapi.CTX.app_framework.save_prefs() def get_main_menu_custom_ui_actions(): diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py index 201c7d2fac..b9899900f5 100644 --- a/openpype/hosts/flame/api/utils.py +++ b/openpype/hosts/flame/api/utils.py @@ -5,7 +5,7 @@ Flame utils for syncing scripts import os import shutil from openpype.api import Logger -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def _sync_utility_scripts(env=None): @@ -75,10 +75,19 @@ def _sync_utility_scripts(env=None): path = os.path.join(flame_shared_dir, _itm) log.info("Removing `{path}`...".format(**locals())) - if os.path.isdir(path): - shutil.rmtree(path, onerror=None) - else: - os.remove(path) + + try: + if os.path.isdir(path): + shutil.rmtree(path, onerror=None) + else: + os.remove(path) + except PermissionError as msg: + log.warning( + "Not able to remove: `{}`, Problem with: `{}`".format( + path, + msg + ) + ) # copy scripts into Resolve's utility scripts dir for dirpath, scriptlist in scripts.items(): @@ -88,13 +97,22 @@ def _sync_utility_scripts(env=None): src = os.path.join(dirpath, _script) dst = os.path.join(flame_shared_dir, _script) log.info("Copying `{src}` to `{dst}`...".format(**locals())) - if os.path.isdir(src): - shutil.copytree( - src, dst, symlinks=False, - ignore=None, ignore_dangling_symlinks=False + + try: + if os.path.isdir(src): + shutil.copytree( + src, dst, symlinks=False, + ignore=None, ignore_dangling_symlinks=False + ) + else: + shutil.copy2(src, dst) + except (PermissionError, FileExistsError) as msg: + log.warning( + "Not able to coppy to: `{}`, Problem with: `{}`".format( + dst, + msg + ) ) - else: - shutil.copy2(src, dst) def setup(env=None): diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py index d2e2408798..0c96c0752a 100644 --- a/openpype/hosts/flame/api/workio.py +++ b/openpype/hosts/flame/api/workio.py @@ -8,7 +8,7 @@ from openpype.api import Logger # ) -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) exported_projet_ext = ".otoc" diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 159fb37410..fe8acda257 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -6,6 +6,7 @@ import socket from openpype.lib import ( PreLaunchHook, get_openpype_username) from openpype.hosts import flame as opflame +import openpype.hosts.flame.api as opfapi import openpype from pprint import pformat @@ -18,18 +19,18 @@ class FlamePrelaunch(PreLaunchHook): """ app_groups = ["flame"] - # todo: replace version number with avalon launch app version - flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7" - wtc_script_path = os.path.join( opflame.HOST_DIR, "api", "scripts", "wiretap_com.py") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.signature = "( {} )".format(self.__class__.__name__) def execute(self): + _env = self.launch_context.env + self.flame_python_exe = _env["OPENPYPE_FLAME_PYTHON_EXEC"] + self.flame_pythonpath = _env["OPENPYPE_FLAME_PYTHONPATH"] + """Hook entry method.""" project_doc = self.data["project_doc"] user_name = get_openpype_username() @@ -55,12 +56,11 @@ class FlamePrelaunch(PreLaunchHook): "FieldDominance": "PROGRESSIVE" } - data_to_script = { # from settings - "host_name": os.getenv("FLAME_WIRETAP_HOSTNAME") or hostname, - "volume_name": os.getenv("FLAME_WIRETAP_VOLUME"), - "group_name": os.getenv("FLAME_WIRETAP_GROUP"), + "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, + "volume_name": _env.get("FLAME_WIRETAP_VOLUME"), + "group_name": _env.get("FLAME_WIRETAP_GROUP"), "color_policy": "ACES 1.1", # from project @@ -68,14 +68,28 @@ class FlamePrelaunch(PreLaunchHook): "user_name": user_name, "project_data": project_data } + + self.log.info(pformat(dict(_env))) + self.log.info(pformat(data_to_script)) + + # add to python path from settings + self._add_pythonpath() + app_arguments = self._get_launch_arguments(data_to_script) - self.log.info(pformat(dict(self.launch_context.env))) - - opflame.setup(self.launch_context.env) + opfapi.setup(self.launch_context.env) self.launch_context.launch_args.extend(app_arguments) + def _add_pythonpath(self): + pythonpath = self.launch_context.env.get("PYTHONPATH") + + # separate it explicity by `;` that is what we use in settings + new_pythonpath = self.flame_pythonpath.split(os.pathsep) + new_pythonpath += pythonpath.split(os.pathsep) + + self.launch_context.env["PYTHONPATH"] = os.pathsep.join(new_pythonpath) + def _get_launch_arguments(self, script_data): # Dump data to string dumped_script_data = json.dumps(script_data) @@ -83,7 +97,9 @@ class FlamePrelaunch(PreLaunchHook): with make_temp_file(dumped_script_data) as tmp_json_path: # Prepare subprocess arguments args = [ - self.flame_python_exe, + self.flame_python_exe.format( + **self.launch_context.env + ), self.wtc_script_path, tmp_json_path ] @@ -91,7 +107,7 @@ class FlamePrelaunch(PreLaunchHook): process_kwargs = { "logger": self.log, - "env": {} + "env": self.launch_context.env } openpype.api.run_subprocess(args, **process_kwargs) diff --git a/openpype/hosts/photoshop/hooks/__init__.py b/openpype/hosts/flame/otio/__init__.py similarity index 100% rename from openpype/hosts/photoshop/hooks/__init__.py rename to openpype/hosts/flame/otio/__init__.py diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py new file mode 100644 index 0000000000..aea1f387e8 --- /dev/null +++ b/openpype/hosts/flame/otio/flame_export.py @@ -0,0 +1,657 @@ +""" compatibility OpenTimelineIO 0.12.0 and newer +""" + +import os +import re +import json +import logging +import opentimelineio as otio +from . import utils + +import flame +from pprint import pformat + +reload(utils) # noqa + +log = logging.getLogger(__name__) + + +TRACK_TYPES = { + "video": otio.schema.TrackKind.Video, + "audio": otio.schema.TrackKind.Audio +} +MARKERS_COLOR_MAP = { + (1.0, 0.0, 0.0): otio.schema.MarkerColor.RED, + (1.0, 0.5, 0.0): otio.schema.MarkerColor.ORANGE, + (1.0, 1.0, 0.0): otio.schema.MarkerColor.YELLOW, + (1.0, 0.5, 1.0): otio.schema.MarkerColor.PINK, + (1.0, 1.0, 1.0): otio.schema.MarkerColor.WHITE, + (0.0, 1.0, 0.0): otio.schema.MarkerColor.GREEN, + (0.0, 1.0, 1.0): otio.schema.MarkerColor.CYAN, + (0.0, 0.0, 1.0): otio.schema.MarkerColor.BLUE, + (0.5, 0.0, 0.5): otio.schema.MarkerColor.PURPLE, + (0.5, 0.0, 1.0): otio.schema.MarkerColor.MAGENTA, + (0.0, 0.0, 0.0): otio.schema.MarkerColor.BLACK +} +MARKERS_INCLUDE = True + + +class CTX: + _fps = None + _tl_start_frame = None + project = None + clips = None + + @classmethod + def set_fps(cls, new_fps): + if not isinstance(new_fps, float): + raise TypeError("Invalid fps type {}".format(type(new_fps))) + if cls._fps != new_fps: + cls._fps = new_fps + + @classmethod + def get_fps(cls): + return cls._fps + + @classmethod + def set_tl_start_frame(cls, number): + if not isinstance(number, int): + raise TypeError("Invalid timeline start frame type {}".format( + type(number))) + if cls._tl_start_frame != number: + cls._tl_start_frame = number + + @classmethod + def get_tl_start_frame(cls): + return cls._tl_start_frame + + +def flatten(_list): + for item in _list: + if isinstance(item, (list, tuple)): + for sub_item in flatten(item): + yield sub_item + else: + yield item + + +def get_current_flame_project(): + project = flame.project.current_project + return project + + +def create_otio_rational_time(frame, fps): + return otio.opentime.RationalTime( + float(frame), + float(fps) + ) + + +def create_otio_time_range(start_frame, frame_duration, fps): + return otio.opentime.TimeRange( + start_time=create_otio_rational_time(start_frame, fps), + duration=create_otio_rational_time(frame_duration, fps) + ) + + +def _get_metadata(item): + if hasattr(item, 'metadata'): + if not item.metadata: + return {} + return {key: value for key, value in dict(item.metadata)} + return {} + + +def create_time_effects(otio_clip, item): + # todo #2426: add retiming effects to export + # get all subtrack items + # subTrackItems = flatten(track_item.parent().subTrackItems()) + # speed = track_item.playbackSpeed() + + # otio_effect = None + # # retime on track item + # if speed != 1.: + # # make effect + # otio_effect = otio.schema.LinearTimeWarp() + # otio_effect.name = "Speed" + # otio_effect.time_scalar = speed + # otio_effect.metadata = {} + + # # freeze frame effect + # if speed == 0.: + # otio_effect = otio.schema.FreezeFrame() + # otio_effect.name = "FreezeFrame" + # otio_effect.metadata = {} + + # if otio_effect: + # # add otio effect to clip effects + # otio_clip.effects.append(otio_effect) + + # # loop trought and get all Timewarps + # for effect in subTrackItems: + # if ((track_item not in effect.linkedItems()) + # and (len(effect.linkedItems()) > 0)): + # continue + # # avoid all effect which are not TimeWarp and disabled + # if "TimeWarp" not in effect.name(): + # continue + + # if not effect.isEnabled(): + # continue + + # node = effect.node() + # name = node["name"].value() + + # # solve effect class as effect name + # _name = effect.name() + # if "_" in _name: + # effect_name = re.sub(r"(?:_)[_0-9]+", "", _name) # more numbers + # else: + # effect_name = re.sub(r"\d+", "", _name) # one number + + # metadata = {} + # # add knob to metadata + # for knob in ["lookup", "length"]: + # value = node[knob].value() + # animated = node[knob].isAnimated() + # if animated: + # value = [ + # ((node[knob].getValueAt(i)) - i) + # for i in range( + # track_item.timelineIn(), + # track_item.timelineOut() + 1) + # ] + + # metadata[knob] = value + + # # make effect + # otio_effect = otio.schema.TimeEffect() + # otio_effect.name = name + # otio_effect.effect_name = effect_name + # otio_effect.metadata = metadata + + # # add otio effect to clip effects + # otio_clip.effects.append(otio_effect) + pass + + +def _get_marker_color(flame_colour): + # clamp colors to closes half numbers + _flame_colour = [ + (lambda x: round(x * 2) / 2)(c) + for c in flame_colour] + + for color, otio_color_type in MARKERS_COLOR_MAP.items(): + if _flame_colour == list(color): + return otio_color_type + + return otio.schema.MarkerColor.RED + + +def _get_flame_markers(item): + output_markers = [] + + time_in = item.record_in.relative_frame + + for marker in item.markers: + log.debug(marker) + start_frame = marker.location.get_value().relative_frame + + start_frame = (start_frame - time_in) + 1 + + marker_data = { + "name": marker.name.get_value(), + "duration": marker.duration.get_value().relative_frame, + "comment": marker.comment.get_value(), + "start_frame": start_frame, + "colour": marker.colour.get_value() + } + + output_markers.append(marker_data) + + return output_markers + + +def create_otio_markers(otio_item, item): + markers = _get_flame_markers(item) + for marker in markers: + frame_rate = CTX.get_fps() + + marked_range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + marker["start_frame"], + frame_rate + ), + duration=otio.opentime.RationalTime( + marker["duration"], + frame_rate + ) + ) + + # testing the comment if it is not containing json string + check_if_json = re.findall( + re.compile(r"[{:}]"), + marker["comment"] + ) + + # to identify this as json, at least 3 items in the list should + # be present ["{", ":", "}"] + metadata = {} + if len(check_if_json) >= 3: + # this is json string + try: + # capture exceptions which are related to strings only + metadata.update( + json.loads(marker["comment"]) + ) + except ValueError as msg: + log.error("Marker json conversion: {}".format(msg)) + else: + metadata["comment"] = marker["comment"] + + otio_marker = otio.schema.Marker( + name=marker["name"], + color=_get_marker_color( + marker["colour"]), + marked_range=marked_range, + metadata=metadata + ) + + otio_item.markers.append(otio_marker) + + +def create_otio_reference(clip_data): + metadata = _get_metadata(clip_data) + + # get file info for path and start frame + frame_start = 0 + fps = CTX.get_fps() + + path = clip_data["fpath"] + + reel_clip = None + match_reel_clip = [ + clip for clip in CTX.clips + if clip["fpath"] == path + ] + if match_reel_clip: + reel_clip = match_reel_clip.pop() + fps = reel_clip["fps"] + + file_name = os.path.basename(path) + file_head, extension = os.path.splitext(file_name) + + # get padding and other file infos + log.debug("_ path: {}".format(path)) + + is_sequence = padding = utils.get_frame_from_path(path) + if is_sequence: + number = utils.get_frame_from_path(path) + file_head = file_name.split(number)[:-1] + frame_start = int(number) + + frame_duration = clip_data["source_duration"] + + if is_sequence: + metadata.update({ + "isSequence": True, + "padding": padding + }) + + otio_ex_ref_item = None + + if is_sequence: + # if it is file sequence try to create `ImageSequenceReference` + # the OTIO might not be compatible so return nothing and do it old way + try: + dirname = os.path.dirname(path) + otio_ex_ref_item = otio.schema.ImageSequenceReference( + target_url_base=dirname + os.sep, + name_prefix=file_head, + name_suffix=extension, + start_frame=frame_start, + frame_zero_padding=padding, + rate=fps, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + except AttributeError: + pass + + if not otio_ex_ref_item: + reformat_path = utils.get_reformated_path(path, padded=False) + # in case old OTIO or video file create `ExternalReference` + otio_ex_ref_item = otio.schema.ExternalReference( + target_url=reformat_path, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + + # add metadata to otio item + add_otio_metadata(otio_ex_ref_item, clip_data, **metadata) + + return otio_ex_ref_item + + +def create_otio_clip(clip_data): + segment = clip_data["PySegment"] + + # create media reference + media_reference = create_otio_reference(clip_data) + + # calculate source in + first_frame = utils.get_frame_from_path(clip_data["fpath"]) or 0 + source_in = int(clip_data["source_in"]) - int(first_frame) + + # creatae source range + source_range = create_otio_time_range( + source_in, + clip_data["record_duration"], + CTX.get_fps() + ) + + otio_clip = otio.schema.Clip( + name=clip_data["segment_name"], + source_range=source_range, + media_reference=media_reference + ) + + # Add markers + if MARKERS_INCLUDE: + create_otio_markers(otio_clip, segment) + + return otio_clip + + +def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): + return otio.schema.Gap( + source_range=create_otio_time_range( + gap_start, + (clip_start - tl_start_frame) - gap_start, + fps + ) + ) + + +def get_clips_in_reels(project): + output_clips = [] + project_desktop = project.current_workspace.desktop + + for reel_group in project_desktop.reel_groups: + for reel in reel_group.reels: + for clip in reel.clips: + clip_data = { + "PyClip": clip, + "fps": float(str(clip.frame_rate)[:-4]) + } + + attrs = [ + "name", "width", "height", + "ratio", "sample_rate", "bit_depth" + ] + + for attr in attrs: + val = getattr(clip, attr) + clip_data[attr] = val + + version = clip.versions[-1] + track = version.tracks[-1] + for segment in track.segments: + segment_data = _get_segment_attributes(segment) + clip_data.update(segment_data) + + output_clips.append(clip_data) + + return output_clips + + +def _get_colourspace_policy(): + + output = {} + # get policies project path + policy_dir = "/opt/Autodesk/project/{}/synColor/policy".format( + CTX.project.name + ) + log.debug(policy_dir) + policy_fp = os.path.join(policy_dir, "policy.cfg") + + if not os.path.exists(policy_fp): + return output + + with open(policy_fp) as file: + dict_conf = dict(line.strip().split(' = ', 1) for line in file) + output.update( + {"openpype.flame.{}".format(k): v for k, v in dict_conf.items()} + ) + return output + + +def _create_otio_timeline(sequence): + + metadata = _get_metadata(sequence) + + # find colour policy files and add them to metadata + colorspace_policy = _get_colourspace_policy() + metadata.update(colorspace_policy) + + metadata.update({ + "openpype.timeline.width": int(sequence.width), + "openpype.timeline.height": int(sequence.height), + "openpype.timeline.pixelAspect": 1 + }) + + rt_start_time = create_otio_rational_time( + CTX.get_tl_start_frame(), CTX.get_fps()) + + return otio.schema.Timeline( + name=str(sequence.name)[1:-1], + global_start_time=rt_start_time, + metadata=metadata + ) + + +def create_otio_track(track_type, track_name): + return otio.schema.Track( + name=track_name, + kind=TRACK_TYPES[track_type] + ) + + +def add_otio_gap(clip_data, otio_track, prev_out): + gap_length = clip_data["record_in"] - prev_out + if prev_out != 0: + gap_length -= 1 + + gap = otio.opentime.TimeRange( + duration=otio.opentime.RationalTime( + gap_length, + CTX.get_fps() + ) + ) + otio_gap = otio.schema.Gap(source_range=gap) + otio_track.append(otio_gap) + + +def add_otio_metadata(otio_item, item, **kwargs): + metadata = _get_metadata(item) + + # add additional metadata from kwargs + if kwargs: + metadata.update(kwargs) + + # add metadata to otio item metadata + for key, value in metadata.items(): + otio_item.metadata.update({key: value}) + + +def _get_shot_tokens_values(clip, tokens): + old_value = None + output = {} + + if not clip.shot_name: + return output + + old_value = clip.shot_name.get_value() + + for token in tokens: + clip.shot_name.set_value(token) + _key = re.sub("[ <>]", "", token) + + try: + output[_key] = int(clip.shot_name.get_value()) + except ValueError: + output[_key] = clip.shot_name.get_value() + + clip.shot_name.set_value(old_value) + + return output + + +def _get_segment_attributes(segment): + # log.debug(dir(segment)) + + if str(segment.name)[1:-1] == "": + return None + + # Add timeline segment to tree + clip_data = { + "segment_name": segment.name.get_value(), + "segment_comment": segment.comment.get_value(), + "tape_name": segment.tape_name, + "source_name": segment.source_name, + "fpath": segment.file_path, + "PySegment": segment + } + + # add all available shot tokens + shot_tokens = _get_shot_tokens_values(segment, [ + "", "", "", "", + ]) + clip_data.update(shot_tokens) + + # populate shot source metadata + segment_attrs = [ + "record_duration", "record_in", "record_out", + "source_duration", "source_in", "source_out" + ] + segment_attrs_data = {} + for attr in segment_attrs: + if not hasattr(segment, attr): + continue + _value = getattr(segment, attr) + segment_attrs_data[attr] = str(_value).replace("+", ":") + + if attr in ["record_in", "record_out"]: + clip_data[attr] = _value.relative_frame + else: + clip_data[attr] = _value.frame + + clip_data["segment_timecodes"] = segment_attrs_data + + return clip_data + + +def create_otio_timeline(sequence): + log.info(dir(sequence)) + log.info(sequence.attributes) + + CTX.project = get_current_flame_project() + CTX.clips = get_clips_in_reels(CTX.project) + + log.debug(pformat( + CTX.clips + )) + + # get current timeline + CTX.set_fps( + float(str(sequence.frame_rate)[:-4])) + + tl_start_frame = utils.timecode_to_frames( + str(sequence.start_time).replace("+", ":"), + CTX.get_fps() + ) + CTX.set_tl_start_frame(tl_start_frame) + + # convert timeline to otio + otio_timeline = _create_otio_timeline(sequence) + + # create otio tracks and clips + for ver in sequence.versions: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + return None + + # convert track to otio + otio_track = create_otio_track( + "video", str(track.name)[1:-1]) + + all_segments = [] + for segment in track.segments: + clip_data = _get_segment_attributes(segment) + if not clip_data: + continue + all_segments.append(clip_data) + + segments_ordered = { + itemindex: clip_data + for itemindex, clip_data in enumerate( + all_segments) + } + log.debug("_ segments_ordered: {}".format( + pformat(segments_ordered) + )) + if not segments_ordered: + continue + + for itemindex, segment_data in segments_ordered.items(): + log.debug("_ itemindex: {}".format(itemindex)) + + # Add Gap if needed + if itemindex == 0: + # if it is first track item at track then add + # it to previouse item + prev_item = segment_data + + else: + # get previouse item + prev_item = segments_ordered[itemindex - 1] + + log.debug("_ segment_data: {}".format(segment_data)) + + # calculate clip frame range difference from each other + clip_diff = segment_data["record_in"] - prev_item["record_out"] + + # add gap if first track item is not starting + # at first timeline frame + if itemindex == 0 and segment_data["record_in"] > 0: + add_otio_gap(segment_data, otio_track, 0) + + # or add gap if following track items are having + # frame range differences from each other + elif itemindex and clip_diff != 1: + add_otio_gap( + segment_data, otio_track, prev_item["record_out"]) + + # create otio clip and add it to track + otio_clip = create_otio_clip(segment_data) + otio_track.append(otio_clip) + + log.debug("_ otio_clip: {}".format(otio_clip)) + + # create otio marker + # create otio metadata + + # add track to otio timeline + otio_timeline.tracks.append(otio_track) + + return otio_timeline + + +def write_to_file(otio_timeline, path): + otio.adapters.write_to_file(otio_timeline, path) diff --git a/openpype/hosts/flame/otio/utils.py b/openpype/hosts/flame/otio/utils.py new file mode 100644 index 0000000000..229946343b --- /dev/null +++ b/openpype/hosts/flame/otio/utils.py @@ -0,0 +1,95 @@ +import re +import opentimelineio as otio +import logging +log = logging.getLogger(__name__) + + +def timecode_to_frames(timecode, framerate): + rt = otio.opentime.from_timecode(timecode, framerate) + return int(otio.opentime.to_frames(rt)) + + +def frames_to_timecode(frames, framerate): + rt = otio.opentime.from_frames(frames, framerate) + return otio.opentime.to_timecode(rt) + + +def frames_to_seconds(frames, framerate): + rt = otio.opentime.from_frames(frames, framerate) + return otio.opentime.to_seconds(rt) + + +def get_reformated_path(path, padded=True): + """ + Return fixed python expression path + + Args: + path (str): path url or simple file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.1001.exr") > plate.%04d.exr + + """ + padding = get_padding_from_path(path) + found = get_frame_from_path(path) + + if not found: + log.info("Path is not sequence: {}".format(path)) + return path + + if padded: + path = path.replace(found, "%0{}d".format(padding)) + else: + path = path.replace(found, "%d") + + return path + + +def get_padding_from_path(path): + """ + Return padding number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.0001.exr") > 4 + + """ + found = get_frame_from_path(path) + + if found: + return len(found) + else: + return None + + +def get_frame_from_path(path): + """ + Return sequence number from Flame path style + + Args: + path (str): path url or simple file name + + Returns: + int: sequence frame number + + Example: + def get_frame_from_path(path): + ("plate.0001.exr") > 0001 + + """ + frame_pattern = re.compile(r"[._](\d+)[.]") + + found = re.findall(frame_pattern, path) + + if found: + return found.pop() + else: + return None diff --git a/openpype/hosts/flame/plugins/create/create_shot_clip.py b/openpype/hosts/flame/plugins/create/create_shot_clip.py new file mode 100644 index 0000000000..f055c77a89 --- /dev/null +++ b/openpype/hosts/flame/plugins/create/create_shot_clip.py @@ -0,0 +1,275 @@ +from copy import deepcopy +import openpype.hosts.flame.api as opfapi + + +class CreateShotClip(opfapi.Creator): + """Publishable clip""" + + label = "Create Publishable Clip" + family = "clip" + icon = "film" + defaults = ["Main"] + + presets = None + + def process(self): + # Creator copy of object attributes that are modified during `process` + presets = deepcopy(self.presets) + gui_inputs = self.get_gui_inputs() + + # get key pares from presets and match it on ui inputs + for k, v in gui_inputs.items(): + if v["type"] in ("dict", "section"): + # nested dictionary (only one level allowed + # for sections and dict) + for _k, _v in v["value"].items(): + if presets.get(_k): + gui_inputs[k][ + "value"][_k]["value"] = presets[_k] + if presets.get(k): + gui_inputs[k]["value"] = presets[k] + + # open widget for plugins inputs + results_back = self.create_widget( + "Pype publish attributes creator", + "Define sequential rename and fill hierarchy data.", + gui_inputs + ) + + if len(self.selected) < 1: + return + + if not results_back: + print("Operation aborted") + return + + # get ui output for track name for vertical sync + v_sync_track = results_back["vSyncTrack"]["value"] + + # sort selected trackItems by + sorted_selected_segments = [] + unsorted_selected_segments = [] + for _segment in self.selected: + if _segment.parent.name.get_value() in v_sync_track: + sorted_selected_segments.append(_segment) + else: + unsorted_selected_segments.append(_segment) + + sorted_selected_segments.extend(unsorted_selected_segments) + + kwargs = { + "log": self.log, + "ui_inputs": results_back, + "avalon": self.data, + "family": self.data["family"] + } + + for i, segment in enumerate(sorted_selected_segments): + kwargs["rename_index"] = i + # convert track item to timeline media pool item + opfapi.PublishableClip(segment, **kwargs).convert() + + def get_gui_inputs(self): + gui_tracks = self._get_video_track_names( + opfapi.get_current_sequence(opfapi.CTX.selection) + ) + return deepcopy({ + "renameHierarchy": { + "type": "section", + "label": "Shot Hierarchy And Rename Settings", + "target": "ui", + "order": 0, + "value": { + "hierarchy": { + "value": "{folder}/{sequence}", + "type": "QLineEdit", + "label": "Shot Parent Hierarchy", + "target": "tag", + "toolTip": "Parents folder for shot root folder, Template filled with `Hierarchy Data` section", # noqa + "order": 0}, + "clipRename": { + "value": False, + "type": "QCheckBox", + "label": "Rename clips", + "target": "ui", + "toolTip": "Renaming selected clips on fly", # noqa + "order": 1}, + "clipName": { + "value": "{sequence}{shot}", + "type": "QLineEdit", + "label": "Clip Name Template", + "target": "ui", + "toolTip": "template for creating shot namespaused for renaming (use rename: on)", # noqa + "order": 2}, + "segmentIndex": { + "value": True, + "type": "QCheckBox", + "label": "Segment index", + "target": "ui", + "toolTip": "Take number from segment index", # noqa + "order": 3}, + "countFrom": { + "value": 10, + "type": "QSpinBox", + "label": "Count sequence from", + "target": "ui", + "toolTip": "Set when the sequence number stafrom", # noqa + "order": 4}, + "countSteps": { + "value": 10, + "type": "QSpinBox", + "label": "Stepping number", + "target": "ui", + "toolTip": "What number is adding every new step", # noqa + "order": 5}, + } + }, + "hierarchyData": { + "type": "dict", + "label": "Shot Template Keywords", + "target": "tag", + "order": 1, + "value": { + "folder": { + "value": "shots", + "type": "QLineEdit", + "label": "{folder}", + "target": "tag", + "toolTip": "Name of folder used for root of generated shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 0}, + "episode": { + "value": "ep01", + "type": "QLineEdit", + "label": "{episode}", + "target": "tag", + "toolTip": "Name of episode.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 1}, + "sequence": { + "value": "sq01", + "type": "QLineEdit", + "label": "{sequence}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 2}, + "track": { + "value": "{_track_}", + "type": "QLineEdit", + "label": "{track}", + "target": "tag", + "toolTip": "Name of sequence of shots.\nUsable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 3}, + "shot": { + "value": "sh###", + "type": "QLineEdit", + "label": "{shot}", + "target": "tag", + "toolTip": "Name of shot. `#` is converted to paded number. \nAlso could be used with usable tokens:\n\t{_clip_}: name of used clip\n\t{_track_}: name of parent track layer\n\t{_sequence_}: name of parent sequence (timeline)", # noqa + "order": 4} + } + }, + "verticalSync": { + "type": "section", + "label": "Vertical Synchronization Of Attributes", + "target": "ui", + "order": 2, + "value": { + "vSyncOn": { + "value": True, + "type": "QCheckBox", + "label": "Enable Vertical Sync", + "target": "ui", + "toolTip": "Switch on if you want clips above each other to share its attributes", # noqa + "order": 0}, + "vSyncTrack": { + "value": gui_tracks, # noqa + "type": "QComboBox", + "label": "Hero track", + "target": "ui", + "toolTip": "Select driving track name which should be hero for all others", # noqa + "order": 1} + } + }, + "publishSettings": { + "type": "section", + "label": "Publish Settings", + "target": "ui", + "order": 3, + "value": { + "subsetName": { + "value": ["[ track name ]", "main", "bg", "fg", "bg", + "animatic"], + "type": "QComboBox", + "label": "Subset Name", + "target": "ui", + "toolTip": "chose subset name patern, if [ track name ] is selected, name of track layer will be used", # noqa + "order": 0}, + "subsetFamily": { + "value": ["plate", "take"], + "type": "QComboBox", + "label": "Subset Family", + "target": "ui", "toolTip": "What use of this subset is for", # noqa + "order": 1}, + "reviewTrack": { + "value": ["< none >"] + gui_tracks, + "type": "QComboBox", + "label": "Use Review Track", + "target": "ui", + "toolTip": "Generate preview videos on fly, if `< none >` is defined nothing will be generated.", # noqa + "order": 2}, + "audio": { + "value": False, + "type": "QCheckBox", + "label": "Include audio", + "target": "tag", + "toolTip": "Process subsets with corresponding audio", # noqa + "order": 3}, + "sourceResolution": { + "value": False, + "type": "QCheckBox", + "label": "Source resolution", + "target": "tag", + "toolTip": "Is resloution taken from timeline or source?", # noqa + "order": 4}, + } + }, + "frameRangeAttr": { + "type": "section", + "label": "Shot Attributes", + "target": "ui", + "order": 4, + "value": { + "workfileFrameStart": { + "value": 1001, + "type": "QSpinBox", + "label": "Workfiles Start Frame", + "target": "tag", + "toolTip": "Set workfile starting frame number", # noqa + "order": 0 + }, + "handleStart": { + "value": 0, + "type": "QSpinBox", + "label": "Handle Start", + "target": "tag", + "toolTip": "Handle at start of clip", # noqa + "order": 1 + }, + "handleEnd": { + "value": 0, + "type": "QSpinBox", + "label": "Handle End", + "target": "tag", + "toolTip": "Handle at end of clip", # noqa + "order": 2 + } + } + } + }) + + def _get_video_track_names(self, sequence): + track_names = [] + for ver in sequence.versions: + for track in ver.tracks: + track_names.append(track.name.get_value()) + + return track_names diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py new file mode 100644 index 0000000000..73401368b1 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -0,0 +1,63 @@ +import os +import pyblish.api +import tempfile +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export as otio_export +import opentimelineio as otio +from pprint import pformat +reload(otio_export) # noqa + + +@pyblish.api.log +class CollectTestSelection(pyblish.api.ContextPlugin): + """testing selection sharing + """ + + order = pyblish.api.CollectorOrder + label = "test selection" + hosts = ["flame"] + + def process(self, context): + self.log.info( + "Active Selection: {}".format(opfapi.CTX.selection)) + + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) + + self.test_imprint_data(sequence) + self.test_otio_export(sequence) + + def test_otio_export(self, sequence): + test_dir = os.path.normpath( + tempfile.mkdtemp(prefix="test_pyblish_tmp_") + ) + export_path = os.path.normpath( + os.path.join( + test_dir, "otio_timeline_export.otio" + ) + ) + otio_timeline = otio_export.create_otio_timeline(sequence) + otio_export.write_to_file( + otio_timeline, export_path + ) + read_timeline_otio = otio.adapters.read_from_file(export_path) + + if otio_timeline != read_timeline_otio: + raise Exception("Exported timeline is different from original") + + self.log.info(pformat(otio_timeline)) + self.log.info("Otio exported to: {}".format(export_path)) + + def test_imprint_data(self, sequence): + with opfapi.maintained_segment_selection(sequence) as sel_segments: + for segment in sel_segments: + if str(segment.name)[1:-1] == "": + continue + + self.log.debug("Segment with OpenPypeData: {}".format( + segment.name)) + + opfapi.imprint(segment, { + 'asset': segment.name.get_value(), + 'family': 'render', + 'subset': 'subsetMain' + }) diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py index 2af1e4a257..459da8bfdf 100644 --- a/openpype/hosts/houdini/plugins/create/create_hda.py +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -45,19 +45,14 @@ class CreateHDA(plugin.Creator): if (self.options or {}).get("useSelection") and self.nodes: # if we have `use selection` enabled and we have some # selected nodes ... - to_hda = self.nodes[0] - if len(self.nodes) > 1: - # if there is more then one node, create subnet first - subnet = out.createNode( - "subnet", node_name="{}_subnet".format(self.name)) - to_hda = subnet - else: - # in case of no selection, just create subnet node - subnet = out.createNode( - "subnet", node_name="{}_subnet".format(self.name)) + subnet = out.collapseIntoSubnet( + self.nodes, + subnet_name="{}_subnet".format(self.name)) subnet.moveToGoodPosition() to_hda = subnet - + else: + to_hda = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) if not to_hda.type().definition(): # if node type has not its definition, it is not user # created hda. We test if hda can be created from the node. @@ -69,13 +64,12 @@ class CreateHDA(plugin.Creator): name=subset_name, hda_file_name="$HIP/{}.hda".format(subset_name) ) - hou.moveNodesTo(self.nodes, hda_node) hda_node.layoutChildren() + elif self._check_existing(subset_name): + raise plugin.OpenPypeCreatorError( + ("subset {} is already published with different HDA" + "definition.").format(subset_name)) else: - if self._check_existing(subset_name): - raise plugin.OpenPypeCreatorError( - ("subset {} is already published with different HDA" - "definition.").format(subset_name)) hda_node = to_hda hda_node.setName(subset_name) diff --git a/openpype/hosts/houdini/plugins/load/load_alembic.py b/openpype/hosts/houdini/plugins/load/load_alembic.py index cd0f0f0d2d..df66d56008 100644 --- a/openpype/hosts/houdini/plugins/load/load_alembic.py +++ b/openpype/hosts/houdini/plugins/load/load_alembic.py @@ -1,6 +1,6 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline class AbcLoader(api.Loader): @@ -25,16 +25,9 @@ class AbcLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 + # Define node name namespace = namespace if namespace else context["asset"]["name"] - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a new geo node container = obj.createNode("geo", node_name=node_name) diff --git a/openpype/hosts/houdini/plugins/load/load_camera.py b/openpype/hosts/houdini/plugins/load/load_camera.py index 83246b7d97..8b98b7c05e 100644 --- a/openpype/hosts/houdini/plugins/load/load_camera.py +++ b/openpype/hosts/houdini/plugins/load/load_camera.py @@ -1,5 +1,5 @@ from avalon import api -from avalon.houdini import pipeline, lib +from avalon.houdini import pipeline ARCHIVE_EXPRESSION = ('__import__("_alembic_hom_extensions")' @@ -97,18 +97,9 @@ class CameraLoader(api.Loader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 - asset_name = context["asset"]["name"] - - namespace = namespace or asset_name - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) - - children = lib.children_as_string(hou.node("/obj")) - while node_name in children: - counter += 1 - node_name = "{0}_{1:03d}".format(formatted, counter) + # Define node name + namespace = namespace if namespace else context["asset"]["name"] + node_name = "{}_{}".format(namespace, name) if namespace else name # Create a archive node container = self.create_and_connect(obj, "alembicarchive", node_name) diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index ef77c3230b..8d21794c1b 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -37,7 +37,7 @@ class CollectFrames(pyblish.api.InstancePlugin): # Check if frames are bigger than 1 (file collection) # override the result - if end_frame - start_frame > 1: + if end_frame - start_frame > 0: result = self.create_file_list( match, int(start_frame), int(end_frame) ) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..3f93bc2ab5 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -313,13 +313,7 @@ def attribute_values(attr_values): """ - # NOTE(antirotor): this didn't work for some reason for Yeti attributes - # original = [(attr, cmds.getAttr(attr)) for attr in attr_values] - original = [] - for attr in attr_values: - type = cmds.getAttr(attr, type=True) - value = cmds.getAttr(attr) - original.append((attr, str(value) if type == "string" else value)) + original = [(attr, cmds.getAttr(attr)) for attr in attr_values] try: for attr, value in attr_values.items(): if isinstance(value, string_types): @@ -331,6 +325,12 @@ def attribute_values(attr_values): for attr, value in original: if isinstance(value, string_types): cmds.setAttr(attr, value, type="string") + elif value is None and cmds.getAttr(attr, type=True) == "string": + # In some cases the maya.cmds.getAttr command returns None + # for string attributes but this value cannot assigned. + # Note: After setting it once to "" it will then return "" + # instead of None. So this would only happen once. + cmds.setAttr(attr, "", type="string") else: cmds.setAttr(attr, value) @@ -745,6 +745,33 @@ def namespaced(namespace, new=True): cmds.namespace(set=original) +@contextlib.contextmanager +def maintained_selection_api(): + """Maintain selection using the Maya Python API. + + Warning: This is *not* added to the undo stack. + + """ + original = om.MGlobal.getActiveSelectionList() + try: + yield + finally: + om.MGlobal.setActiveSelectionList(original) + + +@contextlib.contextmanager +def tool(context): + """Set a tool context during the context manager. + + """ + original = cmds.currentCtx() + try: + cmds.setToolTo(context) + yield + finally: + cmds.setToolTo(original) + + def polyConstraint(components, *args, **kwargs): """Return the list of *components* with the constraints applied. @@ -763,17 +790,25 @@ def polyConstraint(components, *args, **kwargs): kwargs.pop('mode', None) with no_undo(flush=False): - with maya.maintained_selection(): - # Apply constraint using mode=2 (current and next) so - # it applies to the selection made before it; because just - # a `maya.cmds.select()` call will not trigger the constraint. - with reset_polySelectConstraint(): - cmds.select(components, r=1, noExpand=True) - cmds.polySelectConstraint(*args, mode=2, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result + # Reverting selection to the original selection using + # `maya.cmds.select` can be slow in rare cases where previously + # `maya.cmds.polySelectConstraint` had set constrain to "All and Next" + # and the "Random" setting was activated. To work around this we + # revert to the original selection using the Maya API. This is safe + # since we're not generating any undo change anyway. + with tool("selectSuperContext"): + # Selection can be very slow when in a manipulator mode. + # So we force the selection context which is fast. + with maintained_selection_api(): + # Apply constraint using mode=2 (current and next) so + # it applies to the selection made before it; because just + # a `maya.cmds.select()` call will not trigger the constraint. + with reset_polySelectConstraint(): + cmds.select(components, r=1, noExpand=True) + cmds.polySelectConstraint(*args, mode=2, **kwargs) + result = cmds.ls(selection=True) + cmds.select(clear=True) + return result @contextlib.contextmanager diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index fdad0e0989..a5f03cd576 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -100,6 +100,13 @@ class ReferenceLoader(api.Loader): "offset", label="Position Offset", help="Offset loaded models for easier selection." + ), + qargparse.Boolean( + "attach_to_root", + label="Group imported asset", + default=True, + help="Should a group be created to encapsulate" + " imported representation ?" ) ] diff --git a/openpype/hosts/maya/plugins/load/load_look.py b/openpype/hosts/maya/plugins/load/load_look.py index fca612eff4..8e14778fd2 100644 --- a/openpype/hosts/maya/plugins/load/load_look.py +++ b/openpype/hosts/maya/plugins/load/load_look.py @@ -8,6 +8,8 @@ from collections import defaultdict from openpype.widgets.message_window import ScrollMessageBox from Qt import QtWidgets +from openpype.hosts.maya.api.plugin import get_reference_node + class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): """Specific loader for lookdev""" @@ -70,7 +72,7 @@ class LookLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): # Get reference node from container members members = cmds.sets(node, query=True, nodesOnly=True) - reference_node = self._get_reference_node(members) + reference_node = get_reference_node(members, log=self.log) shader_nodes = cmds.ls(members, type='shadingEngine') orig_nodes = set(self._get_nodes_with_shader(shader_nodes)) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index cfe8149218..dd64fd0a16 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -40,85 +40,88 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): except ValueError: family = "model" + group_name = "{}:_GRP".format(namespace) + # True by default to keep legacy behaviours + attach_to_root = options.get("attach_to_root", True) + with maya.maintained_selection(): - groupName = "{}:_GRP".format(namespace) cmds.loadPlugin("AbcImport.mll", quiet=True) nodes = cmds.file(self.fname, namespace=namespace, sharedReferenceFile=False, - groupReference=True, - groupName=groupName, reference=True, - returnNewNodes=True) - - # namespace = cmds.referenceQuery(nodes[0], namespace=True) + returnNewNodes=True, + groupReference=attach_to_root, + groupName=group_name) shapes = cmds.ls(nodes, shapes=True, long=True) - newNodes = (list(set(nodes) - set(shapes))) + new_nodes = (list(set(nodes) - set(shapes))) current_namespace = pm.namespaceInfo(currentNamespace=True) if current_namespace != ":": - groupName = current_namespace + ":" + groupName + group_name = current_namespace + ":" + group_name - groupNode = pm.PyNode(groupName) - roots = set() + self[:] = new_nodes - for node in newNodes: - try: - roots.add(pm.PyNode(node).getAllParents()[-2]) - except: # noqa: E722 - pass + if attach_to_root: + group_node = pm.PyNode(group_name) + roots = set() - if family not in ["layout", "setdress", "mayaAscii", "mayaScene"]: + for node in new_nodes: + try: + roots.add(pm.PyNode(node).getAllParents()[-2]) + except: # noqa: E722 + pass + + if family not in ["layout", "setdress", + "mayaAscii", "mayaScene"]: + for root in roots: + root.setParent(world=True) + + group_node.zeroTransformPivots() for root in roots: - root.setParent(world=True) + root.setParent(group_node) - groupNode.zeroTransformPivots() - for root in roots: - root.setParent(groupNode) + cmds.setAttr(group_name + ".displayHandle", 1) - cmds.setAttr(groupName + ".displayHandle", 1) + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings['maya']['load']['colors'] + c = colors.get(family) + if c is not None: + group_node.useOutlinerColor.set(1) + group_node.outlinerColor.set( + (float(c[0]) / 255), + (float(c[1]) / 255), + (float(c[2]) / 255)) - settings = get_project_settings(os.environ['AVALON_PROJECT']) - colors = settings['maya']['load']['colors'] - c = colors.get(family) - if c is not None: - groupNode.useOutlinerColor.set(1) - groupNode.outlinerColor.set( - (float(c[0])/255), - (float(c[1])/255), - (float(c[2])/255) - ) - - self[:] = newNodes - - cmds.setAttr(groupName + ".displayHandle", 1) - # get bounding box - bbox = cmds.exactWorldBoundingBox(groupName) - # get pivot position on world space - pivot = cmds.xform(groupName, q=True, sp=True, ws=True) - # center of bounding box - cx = (bbox[0] + bbox[3]) / 2 - cy = (bbox[1] + bbox[4]) / 2 - cz = (bbox[2] + bbox[5]) / 2 - # add pivot position to calculate offset - cx = cx + pivot[0] - cy = cy + pivot[1] - cz = cz + pivot[2] - # set selection handle offset to center of bounding box - cmds.setAttr(groupName + ".selectHandleX", cx) - cmds.setAttr(groupName + ".selectHandleY", cy) - cmds.setAttr(groupName + ".selectHandleZ", cz) + cmds.setAttr(group_name + ".displayHandle", 1) + # get bounding box + bbox = cmds.exactWorldBoundingBox(group_name) + # get pivot position on world space + pivot = cmds.xform(group_name, q=True, sp=True, ws=True) + # center of bounding box + cx = (bbox[0] + bbox[3]) / 2 + cy = (bbox[1] + bbox[4]) / 2 + cz = (bbox[2] + bbox[5]) / 2 + # add pivot position to calculate offset + cx = cx + pivot[0] + cy = cy + pivot[1] + cz = cz + pivot[2] + # set selection handle offset to center of bounding box + cmds.setAttr(group_name + ".selectHandleX", cx) + cmds.setAttr(group_name + ".selectHandleY", cy) + cmds.setAttr(group_name + ".selectHandleZ", cz) if family == "rig": self._post_process_rig(name, namespace, context, options) else: - if "translate" in options: - cmds.setAttr(groupName + ".t", *options["translate"]) - return newNodes + if "translate" in options: + cmds.setAttr(group_name + ".t", *options["translate"]) + + return new_nodes def switch(self, container, representation): self.update(container, representation) diff --git a/openpype/hosts/maya/plugins/publish/collect_history.py b/openpype/hosts/maya/plugins/publish/collect_history.py index 16c8e4342e..71f0169971 100644 --- a/openpype/hosts/maya/plugins/publish/collect_history.py +++ b/openpype/hosts/maya/plugins/publish/collect_history.py @@ -22,15 +22,22 @@ class CollectMayaHistory(pyblish.api.InstancePlugin): def process(self, instance): - # Collect the history with long names - history = cmds.listHistory(instance, leaf=False) or [] - history = cmds.ls(history, long=True) + kwargs = {} + if int(cmds.about(version=True)) >= 2020: + # New flag since Maya 2020 which makes cmds.listHistory faster + kwargs = {"fastIteration": True} + else: + self.log.debug("Ignoring `fastIteration` flag before Maya 2020..") - # Remove invalid node types (like renderlayers) - invalid = cmds.ls(history, type="renderLayer", long=True) - if invalid: - invalid = set(invalid) # optimize lookup - history = [x for x in history if x not in invalid] + # Collect the history with long names + history = set(cmds.listHistory(instance, leaf=False, **kwargs) or []) + history = cmds.ls(list(history), long=True) + + # Exclude invalid nodes (like renderlayers) + exclude = cmds.ls(type="renderLayer", long=True) + if exclude: + exclude = set(exclude) # optimize lookup + history = [x for x in history if x not in exclude] # Combine members with history members = instance[:] + history diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 2407617b6f..953539f65c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -55,8 +55,16 @@ def maketx(source, destination, *args): str: Output of `maketx` command. """ + from openpype.lib import get_oiio_tools_path + + maketx_path = get_oiio_tools_path("maketx") + if not os.path.exists(maketx_path): + print( + "OIIO tool not found in {}".format(maketx_path)) + raise AssertionError("OIIO tool not found") + cmd = [ - "maketx", + maketx_path, "-v", # verbose "-u", # update mode # unpremultiply before conversion (recommended when alpha present) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py index e8cc019b52..839aab0d0b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_ngons.py @@ -7,21 +7,6 @@ from avalon import maya from openpype.hosts.maya.api import lib -def polyConstraint(objects, *args, **kwargs): - kwargs.pop('mode', None) - - with lib.no_undo(flush=False): - with maya.maintained_selection(): - with lib.reset_polySelectConstraint(): - cmds.select(objects, r=1, noExpand=True) - # Acting as 'polyCleanupArgList' for n-sided polygon selection - cmds.polySelectConstraint(*args, mode=3, **kwargs) - result = cmds.ls(selection=True) - cmds.select(clear=True) - - return result - - class ValidateMeshNgons(pyblish.api.Validator): """Ensure that meshes don't have ngons @@ -41,8 +26,17 @@ class ValidateMeshNgons(pyblish.api.Validator): @staticmethod def get_invalid(instance): - meshes = cmds.ls(instance, type='mesh') - return polyConstraint(meshes, type=8, size=3) + meshes = cmds.ls(instance, type='mesh', long=True) + + # Get all faces + faces = ['{0}.f[*]'.format(node) for node in meshes] + + # Filter to n-sided polygon faces (ngons) + invalid = lib.polyConstraint(faces, + t=0x0008, # type=face + size=3) # size=nsided + + return invalid def process(self, instance): """Process all the nodes in the instance "objectSet""" 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 b14781b608..750932df54 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_normals_unlocked.py @@ -1,4 +1,5 @@ from maya import cmds +import maya.api.OpenMaya as om2 import pyblish.api import openpype.api @@ -25,10 +26,16 @@ class ValidateMeshNormalsUnlocked(pyblish.api.Validator): @staticmethod def has_locked_normals(mesh): - """Return whether a mesh node has locked normals""" - return any(cmds.polyNormalPerVertex("{}.vtxFace[*][*]".format(mesh), - query=True, - freezeNormal=True)) + """Return whether mesh has at least one locked normal""" + + sel = om2.MGlobal.getSelectionListByName(mesh) + node = sel.getDependNode(0) + fn_mesh = om2.MFnMesh(node) + _, normal_ids = fn_mesh.getNormalIds() + for normal_id in normal_ids: + if fn_mesh.isNormalLocked(normal_id): + return True + return False @classmethod def get_invalid(cls, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py index 4e028d1d24..d5a1fd3529 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py +++ b/openpype/hosts/maya/plugins/publish/validate_rig_controllers.py @@ -164,7 +164,8 @@ class ValidateRigControllers(pyblish.api.InstancePlugin): continue # Ignore proxy connections. - if cmds.addAttr(plug, query=True, usedAsProxy=True): + if (cmds.addAttr(plug, query=True, exists=True) and + cmds.addAttr(plug, query=True, usedAsProxy=True)): continue # Check for incoming connections diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py index 2c594ef5f3..6b5c5d1398 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_zero.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_zero.py @@ -3,14 +3,15 @@ from maya import cmds import pyblish.api import openpype.api import openpype.hosts.maya.api.action +from openpype.hosts.maya.api import lib + +from avalon.maya import maintained_selection class ValidateShapeZero(pyblish.api.Validator): - """shape can't have any values + """Shape components may not have any "tweak" values - To solve this issue, try freezing the shapes. So long - as the translation, rotation and scaling values are zero, - you're all good. + To solve this issue, try freezing the shapes. """ @@ -47,13 +48,22 @@ class ValidateShapeZero(pyblish.api.Validator): @classmethod def repair(cls, instance): invalid_shapes = cls.get_invalid(instance) - for shape in invalid_shapes: - cmds.polyCollapseTweaks(shape) + if not invalid_shapes: + return + + with maintained_selection(): + with lib.tool("selectSuperContext"): + for shape in invalid_shapes: + cmds.polyCollapseTweaks(shape) + # cmds.polyCollapseTweaks keeps selecting the geometry + # after each command. When running on many meshes + # after one another this tends to get really heavy + cmds.select(clear=True) def process(self, instance): """Process all the nodes in the instance "objectSet""" invalid = self.get_invalid(instance) if invalid: - raise ValueError("Nodes found with shape or vertices not freezed" - "values: {0}".format(invalid)) + raise ValueError("Shapes found with non-zero component tweaks: " + "{0}".format(invalid)) 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 261fca6583..32962b57a6 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -42,6 +42,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): # generate data with anlib.maintained_selection(): + generated_repres = [] for o_name, o_data in self.outputs.items(): f_families = o_data["filter"]["families"] f_task_types = o_data["filter"]["task_types"] @@ -112,11 +113,13 @@ class ExtractReviewDataMov(openpype.api.Extractor): }) else: data = exporter.generate_mov(**o_data) + generated_repres.extend(data["representations"]) - self.log.info(data["representations"]) + self.log.info(generated_repres) - # assign to representations - instance.data["representations"] += data["representations"] + if generated_repres: + # assign to representations + instance.data["representations"] += generated_repres self.log.debug( "_ representations: {}".format( diff --git a/openpype/hosts/photoshop/api/README.md b/openpype/hosts/photoshop/api/README.md new file mode 100644 index 0000000000..b958f53803 --- /dev/null +++ b/openpype/hosts/photoshop/api/README.md @@ -0,0 +1,255 @@ +# Photoshop Integration + +## Setup + +The Photoshop integration requires two components to work; `extension` and `server`. + +### Extension + +To install the extension download [Extension Manager Command Line tool (ExManCmd)](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#option-2---exmancmd). + +``` +ExManCmd /install {path to avalon-core}\avalon\photoshop\extension.zxp +``` + +### Server + +The easiest way to get the server and Photoshop launch is with: + +``` +python -c ^"import avalon.photoshop;avalon.photoshop.launch(""C:\Program Files\Adobe\Adobe Photoshop 2020\Photoshop.exe"")^" +``` + +`avalon.photoshop.launch` launches the application and server, and also closes the server when Photoshop exists. + +## Usage + +The Photoshop extension can be found under `Window > Extensions > Avalon`. Once launched you should be presented with a panel like this: + +![Avalon Panel](panel.PNG "Avalon Panel") + + +## Developing + +### Extension +When developing the extension you can load it [unsigned](https://github.com/Adobe-CEP/CEP-Resources/blob/master/CEP_9.x/Documentation/CEP%209.0%20HTML%20Extension%20Cookbook.md#debugging-unsigned-extensions). + +When signing the extension you can use this [guide](https://github.com/Adobe-CEP/Getting-Started-guides/tree/master/Package%20Distribute%20Install#package-distribute-install-guide). + +``` +ZXPSignCmd -selfSignedCert NA NA Avalon Avalon-Photoshop avalon extension.p12 +ZXPSignCmd -sign {path to avalon-core}\avalon\photoshop\extension {path to avalon-core}\avalon\photoshop\extension.zxp extension.p12 avalon +``` + +### Plugin Examples + +These plugins were made with the [polly config](https://github.com/mindbender-studio/config). To fully integrate and load, you will have to use this config and add `image` to the [integration plugin](https://github.com/mindbender-studio/config/blob/master/polly/plugins/publish/integrate_asset.py). + +#### Creator Plugin +```python +from avalon import photoshop + + +class CreateImage(photoshop.Creator): + """Image folder for publish.""" + + name = "imageDefault" + label = "Image" + family = "image" + + def __init__(self, *args, **kwargs): + super(CreateImage, self).__init__(*args, **kwargs) +``` + +#### Collector Plugin +```python +import pythoncom + +import pyblish.api + + +class CollectInstances(pyblish.api.ContextPlugin): + """Gather instances by LayerSet and file metadata + + This collector takes into account assets that are associated with + an LayerSet and marked with a unique identifier; + + Identifier: + id (str): "pyblish.avalon.instance" + """ + + label = "Instances" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + families_mapping = { + "image": [] + } + + def process(self, context): + # Necessary call when running in a different thread which pyblish-qml + # can be. + pythoncom.CoInitialize() + + photoshop_client = PhotoshopClientStub() + layers = photoshop_client.get_layers() + layers_meta = photoshop_client.get_layers_metadata() + for layer in layers: + layer_data = photoshop_client.read(layer, layers_meta) + + # Skip layers without metadata. + if layer_data is None: + continue + + # Skip containers. + if "container" in layer_data["id"]: + continue + + # child_layers = [*layer.Layers] + # self.log.debug("child_layers {}".format(child_layers)) + # if not child_layers: + # self.log.info("%s skipped, it was empty." % layer.Name) + # continue + + instance = context.create_instance(layer.name) + instance.append(layer) + instance.data.update(layer_data) + instance.data["families"] = self.families_mapping[ + layer_data["family"] + ] + instance.data["publish"] = layer.visible + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) +``` + +#### Extractor Plugin +```python +import os + +import openpype.api +from avalon import photoshop + + +class ExtractImage(openpype.api.Extractor): + """Produce a flattened image file from instance + + This plug-in takes into account only the layers in the group. + """ + + label = "Extract Image" + hosts = ["photoshop"] + families = ["image"] + formats = ["png", "jpg"] + + def process(self, instance): + + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) + + # Perform extraction + stub = photoshop.stub() + files = {} + with photoshop.maintained_selection(): + self.log.info("Extracting %s" % str(list(instance))) + with photoshop.maintained_visibility(): + # Hide all other layers. + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers([instance[0]])]) + + for layer in stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + stub.set_visible(layer.id, False) + + save_options = [] + if "png" in self.formats: + save_options.append('png') + if "jpg" in self.formats: + save_options.append('jpg') + + file_basename = os.path.splitext( + stub.get_active_document_name() + )[0] + for extension in save_options: + _filename = "{}.{}".format(file_basename, extension) + files[extension] = _filename + + full_filename = os.path.join(staging_dir, _filename) + stub.saveAs(full_filename, extension, True) + + representations = [] + for extension, filename in files.items(): + representations.append({ + "name": extension, + "ext": extension, + "files": filename, + "stagingDir": staging_dir + }) + instance.data["representations"] = representations + instance.data["stagingDir"] = staging_dir + + self.log.info(f"Extracted {instance} to {staging_dir}") +``` + +#### Loader Plugin +```python +from avalon import api, photoshop + +stub = photoshop.stub() + + +class ImageLoader(api.Loader): + """Load images + + Stores the imported asset in a container named after the asset. + """ + + families = ["image"] + representations = ["*"] + + def load(self, context, name=None, namespace=None, data=None): + with photoshop.maintained_selection(): + layer = stub.import_smart_object(self.fname) + + self[:] = [layer] + + return photoshop.containerise( + name, + namespace, + layer, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + layer = container.pop("layer") + + with photoshop.maintained_selection(): + stub.replace_smart_object( + layer, api.get_representation_path(representation) + ) + + stub.imprint( + layer, {"representation": str(representation["_id"])} + ) + + def remove(self, container): + container["layer"].Delete() + + def switch(self, container, representation): + self.update(container, representation) +``` +For easier debugging of Javascript: +https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1 +Add --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome +then localhost:8078 (port set in `photoshop\extension\.debug`) + +Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01 + +Or install CEF client from https://github.com/Adobe-CEP/CEP-Resources/tree/master/CEP_9.x +## Resources + - https://github.com/lohriialo/photoshop-scripting-python + - https://www.adobe.com/devnet/photoshop/scripting.html + - https://github.com/Adobe-CEP/Getting-Started-guides + - https://github.com/Adobe-CEP/CEP-Resources diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index d978d6ecc1..4cc2aa2c78 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -1,79 +1,63 @@ -import os -import sys -import logging +"""Public API -from Qt import QtWidgets +Anything that isn't defined here is INTERNAL and unreliable for external use. -from avalon import io -from avalon import api as avalon -from openpype import lib -from pyblish import api as pyblish -import openpype.hosts.photoshop +""" -log = logging.getLogger("openpype.hosts.photoshop") +from .launch_logic import stub -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +from .pipeline import ( + ls, + list_instances, + remove_instance, + install, + uninstall, + containerise +) +from .plugin import ( + PhotoshopLoader, + Creator, + get_unique_layer_name +) +from .workio import ( + file_extensions, + has_unsaved_changes, + save_file, + open_file, + current_file, + work_root, +) -def check_inventory(): - if not lib.any_outdated(): - return +from .lib import ( + maintained_selection, + maintained_visibility +) - host = avalon.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) +__all__ = [ + # launch_logic + "stub", - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) + # pipeline + "ls", + "list_instances", + "remove_instance", + "install", + "containerise", - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() + # Plugin + "PhotoshopLoader", + "Creator", + "get_unique_layer_name", - # Garbage collect QApplication. - del app + # workfiles + "file_extensions", + "has_unsaved_changes", + "save_file", + "open_file", + "current_file", + "work_root", - -def application_launch(): - check_inventory() - - -def install(): - print("Installing Pype config...") - - pyblish.register_plugin_path(PUBLISH_PATH) - avalon.register_plugin_path(avalon.Loader, LOAD_PATH) - avalon.register_plugin_path(avalon.Creator, CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - - avalon.on("application.launched", application_launch) - -def uninstall(): - pyblish.deregister_plugin_path(PUBLISH_PATH) - avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) - avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) - -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value + # lib + "maintained_selection", + "maintained_visibility", +] diff --git a/openpype/hosts/photoshop/api/extension.zxp b/openpype/hosts/photoshop/api/extension.zxp new file mode 100644 index 0000000000..a25ec96e7d Binary files /dev/null and b/openpype/hosts/photoshop/api/extension.zxp differ diff --git a/openpype/hosts/photoshop/api/extension/.debug b/openpype/hosts/photoshop/api/extension/.debug new file mode 100644 index 0000000000..a0e2f3c9e0 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/.debug @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml new file mode 100644 index 0000000000..6396cd2412 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/CSXS/manifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + ./index.html + + + + true + + + applicationActivate + com.adobe.csxs.events.ApplicationInitialized + + + + Panel + OpenPype + + + 300 + 140 + + + 400 + 200 + + + + ./icons/avalon-logo-48.png + + + + + + diff --git a/openpype/hosts/photoshop/api/extension/client/CSInterface.js b/openpype/hosts/photoshop/api/extension/client/CSInterface.js new file mode 100644 index 0000000000..4239391efd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/CSInterface.js @@ -0,0 +1,1193 @@ +/************************************************************************************************** +* +* ADOBE SYSTEMS INCORPORATED +* Copyright 2013 Adobe Systems Incorporated +* All Rights Reserved. +* +* NOTICE: Adobe permits you to use, modify, and distribute this file in accordance with the +* terms of the Adobe license agreement accompanying it. If you have received this file from a +* source other than Adobe, then your use, modification, or distribution of it requires the prior +* written permission of Adobe. +* +**************************************************************************************************/ + +/** CSInterface - v8.0.0 */ + +/** + * Stores constants for the window types supported by the CSXS infrastructure. + */ +function CSXSWindowType() +{ +} + +/** Constant for the CSXS window type Panel. */ +CSXSWindowType._PANEL = "Panel"; + +/** Constant for the CSXS window type Modeless. */ +CSXSWindowType._MODELESS = "Modeless"; + +/** Constant for the CSXS window type ModalDialog. */ +CSXSWindowType._MODAL_DIALOG = "ModalDialog"; + +/** EvalScript error message */ +EvalScript_ErrMessage = "EvalScript error."; + +/** + * @class Version + * Defines a version number with major, minor, micro, and special + * components. The major, minor and micro values are numeric; the special + * value can be any string. + * + * @param major The major version component, a positive integer up to nine digits long. + * @param minor The minor version component, a positive integer up to nine digits long. + * @param micro The micro version component, a positive integer up to nine digits long. + * @param special The special version component, an arbitrary string. + * + * @return A new \c Version object. + */ +function Version(major, minor, micro, special) +{ + this.major = major; + this.minor = minor; + this.micro = micro; + this.special = special; +} + +/** + * The maximum value allowed for a numeric version component. + * This reflects the maximum value allowed in PlugPlug and the manifest schema. + */ +Version.MAX_NUM = 999999999; + +/** + * @class VersionBound + * Defines a boundary for a version range, which associates a \c Version object + * with a flag for whether it is an inclusive or exclusive boundary. + * + * @param version The \c #Version object. + * @param inclusive True if this boundary is inclusive, false if it is exclusive. + * + * @return A new \c VersionBound object. + */ +function VersionBound(version, inclusive) +{ + this.version = version; + this.inclusive = inclusive; +} + +/** + * @class VersionRange + * Defines a range of versions using a lower boundary and optional upper boundary. + * + * @param lowerBound The \c #VersionBound object. + * @param upperBound The \c #VersionBound object, or null for a range with no upper boundary. + * + * @return A new \c VersionRange object. + */ +function VersionRange(lowerBound, upperBound) +{ + this.lowerBound = lowerBound; + this.upperBound = upperBound; +} + +/** + * @class Runtime + * Represents a runtime related to the CEP infrastructure. + * Extensions can declare dependencies on particular + * CEP runtime versions in the extension manifest. + * + * @param name The runtime name. + * @param version A \c #VersionRange object that defines a range of valid versions. + * + * @return A new \c Runtime object. + */ +function Runtime(name, versionRange) +{ + this.name = name; + this.versionRange = versionRange; +} + +/** +* @class Extension +* Encapsulates a CEP-based extension to an Adobe application. +* +* @param id The unique identifier of this extension. +* @param name The localizable display name of this extension. +* @param mainPath The path of the "index.html" file. +* @param basePath The base path of this extension. +* @param windowType The window type of the main window of this extension. + Valid values are defined by \c #CSXSWindowType. +* @param width The default width in pixels of the main window of this extension. +* @param height The default height in pixels of the main window of this extension. +* @param minWidth The minimum width in pixels of the main window of this extension. +* @param minHeight The minimum height in pixels of the main window of this extension. +* @param maxWidth The maximum width in pixels of the main window of this extension. +* @param maxHeight The maximum height in pixels of the main window of this extension. +* @param defaultExtensionDataXml The extension data contained in the default \c ExtensionDispatchInfo section of the extension manifest. +* @param specialExtensionDataXml The extension data contained in the application-specific \c ExtensionDispatchInfo section of the extension manifest. +* @param requiredRuntimeList An array of \c Runtime objects for runtimes required by this extension. +* @param isAutoVisible True if this extension is visible on loading. +* @param isPluginExtension True if this extension has been deployed in the Plugins folder of the host application. +* +* @return A new \c Extension object. +*/ +function Extension(id, name, mainPath, basePath, windowType, width, height, minWidth, minHeight, maxWidth, maxHeight, + defaultExtensionDataXml, specialExtensionDataXml, requiredRuntimeList, isAutoVisible, isPluginExtension) +{ + this.id = id; + this.name = name; + this.mainPath = mainPath; + this.basePath = basePath; + this.windowType = windowType; + this.width = width; + this.height = height; + this.minWidth = minWidth; + this.minHeight = minHeight; + this.maxWidth = maxWidth; + this.maxHeight = maxHeight; + this.defaultExtensionDataXml = defaultExtensionDataXml; + this.specialExtensionDataXml = specialExtensionDataXml; + this.requiredRuntimeList = requiredRuntimeList; + this.isAutoVisible = isAutoVisible; + this.isPluginExtension = isPluginExtension; +} + +/** + * @class CSEvent + * A standard JavaScript event, the base class for CEP events. + * + * @param type The name of the event type. + * @param scope The scope of event, can be "GLOBAL" or "APPLICATION". + * @param appId The unique identifier of the application that generated the event. + * @param extensionId The unique identifier of the extension that generated the event. + * + * @return A new \c CSEvent object + */ +function CSEvent(type, scope, appId, extensionId) +{ + this.type = type; + this.scope = scope; + this.appId = appId; + this.extensionId = extensionId; +} + +/** Event-specific data. */ +CSEvent.prototype.data = ""; + +/** + * @class SystemPath + * Stores operating-system-specific location constants for use in the + * \c #CSInterface.getSystemPath() method. + * @return A new \c SystemPath object. + */ +function SystemPath() +{ +} + +/** The path to user data. */ +SystemPath.USER_DATA = "userData"; + +/** The path to common files for Adobe applications. */ +SystemPath.COMMON_FILES = "commonFiles"; + +/** The path to the user's default document folder. */ +SystemPath.MY_DOCUMENTS = "myDocuments"; + +/** @deprecated. Use \c #SystemPath.Extension. */ +SystemPath.APPLICATION = "application"; + +/** The path to current extension. */ +SystemPath.EXTENSION = "extension"; + +/** The path to hosting application's executable. */ +SystemPath.HOST_APPLICATION = "hostApplication"; + +/** + * @class ColorType + * Stores color-type constants. + */ +function ColorType() +{ +} + +/** RGB color type. */ +ColorType.RGB = "rgb"; + +/** Gradient color type. */ +ColorType.GRADIENT = "gradient"; + +/** Null color type. */ +ColorType.NONE = "none"; + +/** + * @class RGBColor + * Stores an RGB color with red, green, blue, and alpha values. + * All values are in the range [0.0 to 255.0]. Invalid numeric values are + * converted to numbers within this range. + * + * @param red The red value, in the range [0.0 to 255.0]. + * @param green The green value, in the range [0.0 to 255.0]. + * @param blue The blue value, in the range [0.0 to 255.0]. + * @param alpha The alpha (transparency) value, in the range [0.0 to 255.0]. + * The default, 255.0, means that the color is fully opaque. + * + * @return A new RGBColor object. + */ +function RGBColor(red, green, blue, alpha) +{ + this.red = red; + this.green = green; + this.blue = blue; + this.alpha = alpha; +} + +/** + * @class Direction + * A point value in which the y component is 0 and the x component + * is positive or negative for a right or left direction, + * or the x component is 0 and the y component is positive or negative for + * an up or down direction. + * + * @param x The horizontal component of the point. + * @param y The vertical component of the point. + * + * @return A new \c Direction object. + */ +function Direction(x, y) +{ + this.x = x; + this.y = y; +} + +/** + * @class GradientStop + * Stores gradient stop information. + * + * @param offset The offset of the gradient stop, in the range [0.0 to 1.0]. + * @param rgbColor The color of the gradient at this point, an \c #RGBColor object. + * + * @return GradientStop object. + */ +function GradientStop(offset, rgbColor) +{ + this.offset = offset; + this.rgbColor = rgbColor; +} + +/** + * @class GradientColor + * Stores gradient color information. + * + * @param type The gradient type, must be "linear". + * @param direction A \c #Direction object for the direction of the gradient + (up, down, right, or left). + * @param numStops The number of stops in the gradient. + * @param gradientStopList An array of \c #GradientStop objects. + * + * @return A new \c GradientColor object. + */ +function GradientColor(type, direction, numStops, arrGradientStop) +{ + this.type = type; + this.direction = direction; + this.numStops = numStops; + this.arrGradientStop = arrGradientStop; +} + +/** + * @class UIColor + * Stores color information, including the type, anti-alias level, and specific color + * values in a color object of an appropriate type. + * + * @param type The color type, 1 for "rgb" and 2 for "gradient". + The supplied color object must correspond to this type. + * @param antialiasLevel The anti-alias level constant. + * @param color A \c #RGBColor or \c #GradientColor object containing specific color information. + * + * @return A new \c UIColor object. + */ +function UIColor(type, antialiasLevel, color) +{ + this.type = type; + this.antialiasLevel = antialiasLevel; + this.color = color; +} + +/** + * @class AppSkinInfo + * Stores window-skin properties, such as color and font. All color parameter values are \c #UIColor objects except that systemHighlightColor is \c #RGBColor object. + * + * @param baseFontFamily The base font family of the application. + * @param baseFontSize The base font size of the application. + * @param appBarBackgroundColor The application bar background color. + * @param panelBackgroundColor The background color of the extension panel. + * @param appBarBackgroundColorSRGB The application bar background color, as sRGB. + * @param panelBackgroundColorSRGB The background color of the extension panel, as sRGB. + * @param systemHighlightColor The highlight color of the extension panel, if provided by the host application. Otherwise, the operating-system highlight color. + * + * @return AppSkinInfo object. + */ +function AppSkinInfo(baseFontFamily, baseFontSize, appBarBackgroundColor, panelBackgroundColor, appBarBackgroundColorSRGB, panelBackgroundColorSRGB, systemHighlightColor) +{ + this.baseFontFamily = baseFontFamily; + this.baseFontSize = baseFontSize; + this.appBarBackgroundColor = appBarBackgroundColor; + this.panelBackgroundColor = panelBackgroundColor; + this.appBarBackgroundColorSRGB = appBarBackgroundColorSRGB; + this.panelBackgroundColorSRGB = panelBackgroundColorSRGB; + this.systemHighlightColor = systemHighlightColor; +} + +/** + * @class HostEnvironment + * Stores information about the environment in which the extension is loaded. + * + * @param appName The application's name. + * @param appVersion The application's version. + * @param appLocale The application's current license locale. + * @param appUILocale The application's current UI locale. + * @param appId The application's unique identifier. + * @param isAppOnline True if the application is currently online. + * @param appSkinInfo An \c #AppSkinInfo object containing the application's default color and font styles. + * + * @return A new \c HostEnvironment object. + */ +function HostEnvironment(appName, appVersion, appLocale, appUILocale, appId, isAppOnline, appSkinInfo) +{ + this.appName = appName; + this.appVersion = appVersion; + this.appLocale = appLocale; + this.appUILocale = appUILocale; + this.appId = appId; + this.isAppOnline = isAppOnline; + this.appSkinInfo = appSkinInfo; +} + +/** + * @class HostCapabilities + * Stores information about the host capabilities. + * + * @param EXTENDED_PANEL_MENU True if the application supports panel menu. + * @param EXTENDED_PANEL_ICONS True if the application supports panel icon. + * @param DELEGATE_APE_ENGINE True if the application supports delegated APE engine. + * @param SUPPORT_HTML_EXTENSIONS True if the application supports HTML extensions. + * @param DISABLE_FLASH_EXTENSIONS True if the application disables FLASH extensions. + * + * @return A new \c HostCapabilities object. + */ +function HostCapabilities(EXTENDED_PANEL_MENU, EXTENDED_PANEL_ICONS, DELEGATE_APE_ENGINE, SUPPORT_HTML_EXTENSIONS, DISABLE_FLASH_EXTENSIONS) +{ + this.EXTENDED_PANEL_MENU = EXTENDED_PANEL_MENU; + this.EXTENDED_PANEL_ICONS = EXTENDED_PANEL_ICONS; + this.DELEGATE_APE_ENGINE = DELEGATE_APE_ENGINE; + this.SUPPORT_HTML_EXTENSIONS = SUPPORT_HTML_EXTENSIONS; + this.DISABLE_FLASH_EXTENSIONS = DISABLE_FLASH_EXTENSIONS; // Since 5.0.0 +} + +/** + * @class ApiVersion + * Stores current api version. + * + * Since 4.2.0 + * + * @param major The major version + * @param minor The minor version. + * @param micro The micro version. + * + * @return ApiVersion object. + */ +function ApiVersion(major, minor, micro) +{ + this.major = major; + this.minor = minor; + this.micro = micro; +} + +/** + * @class MenuItemStatus + * Stores flyout menu item status + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function MenuItemStatus(menuItemLabel, enabled, checked) +{ + this.menuItemLabel = menuItemLabel; + this.enabled = enabled; + this.checked = checked; +} + +/** + * @class ContextMenuItemStatus + * Stores the status of the context menu item. + * + * Since 5.2.0 + * + * @param menuItemID The menu item id. + * @param enabled True if user wants to enable the menu item. + * @param checked True if user wants to check the menu item. + * + * @return MenuItemStatus object. + */ +function ContextMenuItemStatus(menuItemID, enabled, checked) +{ + this.menuItemID = menuItemID; + this.enabled = enabled; + this.checked = checked; +} +//------------------------------ CSInterface ---------------------------------- + +/** + * @class CSInterface + * This is the entry point to the CEP extensibility infrastructure. + * Instantiate this object and use it to: + *
    + *
  • Access information about the host application in which an extension is running
  • + *
  • Launch an extension
  • + *
  • Register interest in event notifications, and dispatch events
  • + *
+ * + * @return A new \c CSInterface object + */ +function CSInterface() +{ +} + +/** + * User can add this event listener to handle native application theme color changes. + * Callback function gives extensions ability to fine-tune their theme color after the + * global theme color has been changed. + * The callback function should be like below: + * + * @example + * // event is a CSEvent object, but user can ignore it. + * function OnAppThemeColorChanged(event) + * { + * // Should get a latest HostEnvironment object from application. + * var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo; + * // Gets the style information such as color info from the skinInfo, + * // and redraw all UI controls of your extension according to the style info. + * } + */ +CSInterface.THEME_COLOR_CHANGED_EVENT = "com.adobe.csxs.events.ThemeColorChanged"; + +/** The host environment data object. */ +CSInterface.prototype.hostEnvironment = window.__adobe_cep__ ? JSON.parse(window.__adobe_cep__.getHostEnvironment()) : null; + +/** Retrieves information about the host environment in which the + * extension is currently running. + * + * @return A \c #HostEnvironment object. + */ +CSInterface.prototype.getHostEnvironment = function() +{ + this.hostEnvironment = JSON.parse(window.__adobe_cep__.getHostEnvironment()); + return this.hostEnvironment; +}; + +/** Closes this extension. */ +CSInterface.prototype.closeExtension = function() +{ + window.__adobe_cep__.closeExtension(); +}; + +/** + * Retrieves a path for which a constant is defined in the system. + * + * @param pathType The path-type constant defined in \c #SystemPath , + * + * @return The platform-specific system path string. + */ +CSInterface.prototype.getSystemPath = function(pathType) +{ + var path = decodeURI(window.__adobe_cep__.getSystemPath(pathType)); + var OSVersion = this.getOSInformation(); + if (OSVersion.indexOf("Windows") >= 0) + { + path = path.replace("file:///", ""); + } + else if (OSVersion.indexOf("Mac") >= 0) + { + path = path.replace("file://", ""); + } + return path; +}; + +/** + * Evaluates a JavaScript script, which can use the JavaScript DOM + * of the host application. + * + * @param script The JavaScript script. + * @param callback Optional. A callback function that receives the result of execution. + * If execution fails, the callback function receives the error message \c EvalScript_ErrMessage. + */ +CSInterface.prototype.evalScript = function(script, callback) +{ + if(callback === null || callback === undefined) + { + callback = function(result){}; + } + window.__adobe_cep__.evalScript(script, callback); +}; + +/** + * Retrieves the unique identifier of the application. + * in which the extension is currently running. + * + * @return The unique ID string. + */ +CSInterface.prototype.getApplicationID = function() +{ + var appId = this.hostEnvironment.appId; + return appId; +}; + +/** + * Retrieves host capability information for the application + * in which the extension is currently running. + * + * @return A \c #HostCapabilities object. + */ +CSInterface.prototype.getHostCapabilities = function() +{ + var hostCapabilities = JSON.parse(window.__adobe_cep__.getHostCapabilities() ); + return hostCapabilities; +}; + +/** + * Triggers a CEP event programmatically. Yoy can use it to dispatch + * an event of a predefined type, or of a type you have defined. + * + * @param event A \c CSEvent object. + */ +CSInterface.prototype.dispatchEvent = function(event) +{ + if (typeof event.data == "object") + { + event.data = JSON.stringify(event.data); + } + + window.__adobe_cep__.dispatchEvent(event); +}; + +/** + * Registers an interest in a CEP event of a particular type, and + * assigns an event handler. + * The event infrastructure notifies your extension when events of this type occur, + * passing the event object to the registered handler function. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.addEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.addEventListener(type, listener, obj); +}; + +/** + * Removes a registered event listener. + * + * @param type The name of the event type of interest. + * @param listener The JavaScript handler function or method that was registered. + * @param obj Optional, the object containing the handler method, if any. + * Default is null. + */ +CSInterface.prototype.removeEventListener = function(type, listener, obj) +{ + window.__adobe_cep__.removeEventListener(type, listener, obj); +}; + +/** + * Loads and launches another extension, or activates the extension if it is already loaded. + * + * @param extensionId The extension's unique identifier. + * @param startupParams Not currently used, pass "". + * + * @example + * To launch the extension "help" with ID "HLP" from this extension, call: + * requestOpenExtension("HLP", ""); + * + */ +CSInterface.prototype.requestOpenExtension = function(extensionId, params) +{ + window.__adobe_cep__.requestOpenExtension(extensionId, params); +}; + +/** + * Retrieves the list of extensions currently loaded in the current host application. + * The extension list is initialized once, and remains the same during the lifetime + * of the CEP session. + * + * @param extensionIds Optional, an array of unique identifiers for extensions of interest. + * If omitted, retrieves data for all extensions. + * + * @return Zero or more \c #Extension objects. + */ +CSInterface.prototype.getExtensions = function(extensionIds) +{ + var extensionIdsStr = JSON.stringify(extensionIds); + var extensionsStr = window.__adobe_cep__.getExtensions(extensionIdsStr); + + var extensions = JSON.parse(extensionsStr); + return extensions; +}; + +/** + * Retrieves network-related preferences. + * + * @return A JavaScript object containing network preferences. + */ +CSInterface.prototype.getNetworkPreferences = function() +{ + var result = window.__adobe_cep__.getNetworkPreferences(); + var networkPre = JSON.parse(result); + + return networkPre; +}; + +/** + * Initializes the resource bundle for this extension with property values + * for the current application and locale. + * To support multiple locales, you must define a property file for each locale, + * containing keyed display-string values for that locale. + * See localization documentation for Extension Builder and related products. + * + * Keys can be in the + * form key.value="localized string", for use in HTML text elements. + * For example, in this input element, the localized \c key.value string is displayed + * instead of the empty \c value string: + * + * + * + * @return An object containing the resource bundle information. + */ +CSInterface.prototype.initResourceBundle = function() +{ + var resourceBundle = JSON.parse(window.__adobe_cep__.initResourceBundle()); + var resElms = document.querySelectorAll('[data-locale]'); + for (var n = 0; n < resElms.length; n++) + { + var resEl = resElms[n]; + // Get the resource key from the element. + var resKey = resEl.getAttribute('data-locale'); + if (resKey) + { + // Get all the resources that start with the key. + for (var key in resourceBundle) + { + if (key.indexOf(resKey) === 0) + { + var resValue = resourceBundle[key]; + if (key.length == resKey.length) + { + resEl.innerHTML = resValue; + } + else if ('.' == key.charAt(resKey.length)) + { + var attrKey = key.substring(resKey.length + 1); + resEl[attrKey] = resValue; + } + } + } + } + } + return resourceBundle; +}; + +/** + * Writes installation information to a file. + * + * @return The file path. + */ +CSInterface.prototype.dumpInstallationInfo = function() +{ + return window.__adobe_cep__.dumpInstallationInfo(); +}; + +/** + * Retrieves version information for the current Operating System, + * See http://www.useragentstring.com/pages/Chrome/ for Chrome \c navigator.userAgent values. + * + * @return A string containing the OS version, or "unknown Operation System". + * If user customizes the User Agent by setting CEF command parameter "--user-agent", only + * "Mac OS X" or "Windows" will be returned. + */ +CSInterface.prototype.getOSInformation = function() +{ + var userAgent = navigator.userAgent; + + if ((navigator.platform == "Win32") || (navigator.platform == "Windows")) + { + var winVersion = "Windows"; + var winBit = ""; + if (userAgent.indexOf("Windows") > -1) + { + if (userAgent.indexOf("Windows NT 5.0") > -1) + { + winVersion = "Windows 2000"; + } + else if (userAgent.indexOf("Windows NT 5.1") > -1) + { + winVersion = "Windows XP"; + } + else if (userAgent.indexOf("Windows NT 5.2") > -1) + { + winVersion = "Windows Server 2003"; + } + else if (userAgent.indexOf("Windows NT 6.0") > -1) + { + winVersion = "Windows Vista"; + } + else if (userAgent.indexOf("Windows NT 6.1") > -1) + { + winVersion = "Windows 7"; + } + else if (userAgent.indexOf("Windows NT 6.2") > -1) + { + winVersion = "Windows 8"; + } + else if (userAgent.indexOf("Windows NT 6.3") > -1) + { + winVersion = "Windows 8.1"; + } + else if (userAgent.indexOf("Windows NT 10") > -1) + { + winVersion = "Windows 10"; + } + + if (userAgent.indexOf("WOW64") > -1 || userAgent.indexOf("Win64") > -1) + { + winBit = " 64-bit"; + } + else + { + winBit = " 32-bit"; + } + } + + return winVersion + winBit; + } + else if ((navigator.platform == "MacIntel") || (navigator.platform == "Macintosh")) + { + var result = "Mac OS X"; + + if (userAgent.indexOf("Mac OS X") > -1) + { + result = userAgent.substring(userAgent.indexOf("Mac OS X"), userAgent.indexOf(")")); + result = result.replace(/_/g, "."); + } + + return result; + } + + return "Unknown Operation System"; +}; + +/** + * Opens a page in the default system browser. + * + * Since 4.2.0 + * + * @param url The URL of the page/file to open, or the email address. + * Must use HTTP/HTTPS/file/mailto protocol. For example: + * "http://www.adobe.com" + * "https://github.com" + * "file:///C:/log.txt" + * "mailto:test@adobe.com" + * + * @return One of these error codes:\n + *
    \n + *
  • NO_ERROR - 0
  • \n + *
  • ERR_UNKNOWN - 1
  • \n + *
  • ERR_INVALID_PARAMS - 2
  • \n + *
  • ERR_INVALID_URL - 201
  • \n + *
\n + */ +CSInterface.prototype.openURLInDefaultBrowser = function(url) +{ + return cep.util.openURLInDefaultBrowser(url); +}; + +/** + * Retrieves extension ID. + * + * Since 4.2.0 + * + * @return extension ID. + */ +CSInterface.prototype.getExtensionID = function() +{ + return window.__adobe_cep__.getExtensionId(); +}; + +/** + * Retrieves the scale factor of screen. + * On Windows platform, the value of scale factor might be different from operating system's scale factor, + * since host application may use its self-defined scale factor. + * + * Since 4.2.0 + * + * @return One of the following float number. + *
    \n + *
  • -1.0 when error occurs
  • \n + *
  • 1.0 means normal screen
  • \n + *
  • >1.0 means HiDPI screen
  • \n + *
\n + */ +CSInterface.prototype.getScaleFactor = function() +{ + return window.__adobe_cep__.getScaleFactor(); +}; + +/** + * Set a handler to detect any changes of scale factor. This only works on Mac. + * + * Since 4.2.0 + * + * @param handler The function to be called when scale factor is changed. + * + */ +CSInterface.prototype.setScaleFactorChangedHandler = function(handler) +{ + window.__adobe_cep__.setScaleFactorChangedHandler(handler); +}; + +/** + * Retrieves current API version. + * + * Since 4.2.0 + * + * @return ApiVersion object. + * + */ +CSInterface.prototype.getCurrentApiVersion = function() +{ + var apiVersion = JSON.parse(window.__adobe_cep__.getCurrentApiVersion()); + return apiVersion; +}; + +/** + * Set panel flyout menu by an XML. + * + * Since 5.2.0 + * + * Register a callback function for "com.adobe.csxs.events.flyoutMenuClicked" to get notified when a + * menu item is clicked. + * The "data" attribute of event is an object which contains "menuId" and "menuName" attributes. + * + * Register callback functions for "com.adobe.csxs.events.flyoutMenuOpened" and "com.adobe.csxs.events.flyoutMenuClosed" + * respectively to get notified when flyout menu is opened or closed. + * + * @param menu A XML string which describes menu structure. + * An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setPanelFlyoutMenu = function(menu) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeSync("setPanelFlyoutMenu", menu); +}; + +/** + * Updates a menu item in the extension window's flyout menu, by setting the enabled + * and selection status. + * + * Since 5.2.0 + * + * @param menuItemLabel The menu item label. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + * + * @return false when the host application does not support this functionality (HostCapabilities.EXTENDED_PANEL_MENU is false). + * Fails silently if menu label is invalid. + * + * @see HostCapabilities.EXTENDED_PANEL_MENU + */ +CSInterface.prototype.updatePanelMenuItem = function(menuItemLabel, enabled, checked) +{ + var ret = false; + if (this.getHostCapabilities().EXTENDED_PANEL_MENU) + { + var itemStatus = new MenuItemStatus(menuItemLabel, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updatePanelMenuItem", JSON.stringify(itemStatus)); + } + return ret; +}; + + +/** + * Set context menu by XML string. + * + * Since 5.2.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item name is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + https://developer.chrome.com/extensions/contextMenus + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + * + * @param menu A XML string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu XML: + * + * + * + * + * + * + * + * + * + * + * + */ +CSInterface.prototype.setContextMenu = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenu", menu, callback); +}; + +/** + * Set context menu by JSON string. + * + * Since 6.0.0 + * + * There are a number of conventions used to communicate what type of menu item to create and how it should be handled. + * - an item without menu ID or menu name is disabled and is not shown. + * - if the item label is "---" (three hyphens) then it is treated as a separator. The menu ID in this case will always be NULL. + * - Checkable attribute takes precedence over Checked attribute. + * - a PNG icon. For optimal display results please supply a 16 x 16px icon as larger dimensions will increase the size of the menu item. + The Chrome extension contextMenus API was taken as a reference. + * - the items with icons and checkable items cannot coexist on the same menu level. The former take precedences over the latter. + https://developer.chrome.com/extensions/contextMenus + * + * @param menu A JSON string which describes menu structure. + * @param callback The callback function which is called when a menu item is clicked. The only parameter is the returned ID of clicked menu item. + * + * @description An example menu JSON: + * + * { + * "menu": [ + * { + * "id": "menuItemId1", + * "label": "testExample1", + * "enabled": true, + * "checkable": true, + * "checked": false, + * "icon": "./image/small_16X16.png" + * }, + * { + * "id": "menuItemId2", + * "label": "testExample2", + * "menu": [ + * { + * "id": "menuItemId2-1", + * "label": "testExample2-1", + * "menu": [ + * { + * "id": "menuItemId2-1-1", + * "label": "testExample2-1-1", + * "enabled": false, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "id": "menuItemId2-2", + * "label": "testExample2-2", + * "enabled": true, + * "checkable": true, + * "checked": true + * } + * ] + * }, + * { + * "label": "---" + * }, + * { + * "id": "menuItemId3", + * "label": "testExample3", + * "enabled": false, + * "checkable": true, + * "checked": false + * } + * ] + * } + * + */ +CSInterface.prototype.setContextMenuByJSON = function(menu, callback) +{ + if ("string" != typeof menu) + { + return; + } + + window.__adobe_cep__.invokeAsync("setContextMenuByJSON", menu, callback); +}; + +/** + * Updates a context menu item by setting the enabled and selection status. + * + * Since 5.2.0 + * + * @param menuItemID The menu item ID. + * @param enabled True to enable the item, false to disable it (gray it out). + * @param checked True to select the item, false to deselect it. + */ +CSInterface.prototype.updateContextMenuItem = function(menuItemID, enabled, checked) +{ + var itemStatus = new ContextMenuItemStatus(menuItemID, enabled, checked); + ret = window.__adobe_cep__.invokeSync("updateContextMenuItem", JSON.stringify(itemStatus)); +}; + +/** + * Get the visibility status of an extension window. + * + * Since 6.0.0 + * + * @return true if the extension window is visible; false if the extension window is hidden. + */ +CSInterface.prototype.isWindowVisible = function() +{ + return window.__adobe_cep__.invokeSync("isWindowVisible", ""); +}; + +/** + * Resize extension's content to the specified dimensions. + * 1. Works with modal and modeless extensions in all Adobe products. + * 2. Extension's manifest min/max size constraints apply and take precedence. + * 3. For panel extensions + * 3.1 This works in all Adobe products except: + * * Premiere Pro + * * Prelude + * * After Effects + * 3.2 When the panel is in certain states (especially when being docked), + * it will not change to the desired dimensions even when the + * specified size satisfies min/max constraints. + * + * Since 6.0.0 + * + * @param width The new width + * @param height The new height + */ +CSInterface.prototype.resizeContent = function(width, height) +{ + window.__adobe_cep__.resizeContent(width, height); +}; + +/** + * Register the invalid certificate callback for an extension. + * This callback will be triggered when the extension tries to access the web site that contains the invalid certificate on the main frame. + * But if the extension does not call this function and tries to access the web site containing the invalid certificate, a default error page will be shown. + * + * Since 6.1.0 + * + * @param callback the callback function + */ +CSInterface.prototype.registerInvalidCertificateCallback = function(callback) +{ + return window.__adobe_cep__.registerInvalidCertificateCallback(callback); +}; + +/** + * Register an interest in some key events to prevent them from being sent to the host application. + * + * This function works with modeless extensions and panel extensions. + * Generally all the key events will be sent to the host application for these two extensions if the current focused element + * is not text input or dropdown, + * If you want to intercept some key events and want them to be handled in the extension, please call this function + * in advance to prevent them being sent to the host application. + * + * Since 6.1.0 + * + * @param keyEventsInterest A JSON string describing those key events you are interested in. A null object or + an empty string will lead to removing the interest + * + * This JSON string should be an array, each object has following keys: + * + * keyCode: [Required] represents an OS system dependent virtual key code identifying + * the unmodified value of the pressed key. + * ctrlKey: [optional] a Boolean that indicates if the control key was pressed (true) or not (false) when the event occurred. + * altKey: [optional] a Boolean that indicates if the alt key was pressed (true) or not (false) when the event occurred. + * shiftKey: [optional] a Boolean that indicates if the shift key was pressed (true) or not (false) when the event occurred. + * metaKey: [optional] (Mac Only) a Boolean that indicates if the Meta key was pressed (true) or not (false) when the event occurred. + * On Macintosh keyboards, this is the command key. To detect Windows key on Windows, please use keyCode instead. + * An example JSON string: + * + * [ + * { + * "keyCode": 48 + * }, + * { + * "keyCode": 123, + * "ctrlKey": true + * }, + * { + * "keyCode": 123, + * "ctrlKey": true, + * "metaKey": true + * } + * ] + * + */ +CSInterface.prototype.registerKeyEventsInterest = function(keyEventsInterest) +{ + return window.__adobe_cep__.registerKeyEventsInterest(keyEventsInterest); +}; + +/** + * Set the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @param title The window title. + */ +CSInterface.prototype.setWindowTitle = function(title) +{ + window.__adobe_cep__.invokeSync("setWindowTitle", title); +}; + +/** + * Get the title of the extension window. + * This function works with modal and modeless extensions in all Adobe products, and panel extensions in Photoshop, InDesign, InCopy, Illustrator, Flash Pro and Dreamweaver. + * + * Since 6.1.0 + * + * @return The window title. + */ +CSInterface.prototype.getWindowTitle = function() +{ + return window.__adobe_cep__.invokeSync("getWindowTitle", ""); +}; diff --git a/openpype/hosts/photoshop/api/extension/client/client.js b/openpype/hosts/photoshop/api/extension/client/client.js new file mode 100644 index 0000000000..f4ba4cfe47 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/client.js @@ -0,0 +1,300 @@ + // client facing part of extension, creates WSRPC client (jsx cannot + // do that) + // consumes RPC calls from server (OpenPype) calls ./host/index.jsx and + // returns values back (in json format) + + var logReturn = function(result){ log.warn('Result: ' + result);}; + + var csInterface = new CSInterface(); + + log.warn("script start"); + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + function myCallBack(){ + log.warn("Triggered index.jsx"); + } + // importing through manifest.xml isn't working because relative paths + // possibly TODO + jsx.evalFile('./host/index.jsx', myCallBack); + + function runEvalScript(script) { + // because of asynchronous nature of functions in jsx + // this waits for response + return new Promise(function(resolve, reject){ + csInterface.evalScript(script, resolve); + }); + } + + /** main entry point **/ + startUp("WEBSOCKET_URL"); + + // get websocket server url from environment value + async function startUp(url){ + log.warn("url", url); + promis = runEvalScript("getEnv('" + url + "')"); + + var res = await promis; + // run rest only after resolved promise + main(res); + } + + function get_extension_version(){ + /** Returns version number from extension manifest.xml **/ + log.debug("get_extension_version") + var path = csInterface.getSystemPath(SystemPath.EXTENSION); + log.debug("extension path " + path); + + var result = window.cep.fs.readFile(path + "/CSXS/manifest.xml"); + var version = undefined; + if(result.err === 0){ + if (window.DOMParser) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(result.data.toString(), 'text/xml'); + const children = xmlDoc.children; + + for (let i = 0; i <= children.length; i++) { + if (children[i] && children[i].getAttribute('ExtensionBundleVersion')) { + version = children[i].getAttribute('ExtensionBundleVersion'); + } + } + } + } + return version + } + + function main(websocket_url){ + // creates connection to 'websocket_url', registers routes + log.warn("websocket_url", websocket_url); + var default_url = 'ws://localhost:8099/ws/'; + + if (websocket_url == ''){ + websocket_url = default_url; + } + log.warn("connecting to:", websocket_url); + RPC = new WSRPC(websocket_url, 5000); // spin connection + + RPC.connect(); + + log.warn("connected"); + + function EscapeStringForJSX(str){ + // Replaces: + // \ with \\ + // ' with \' + // " with \" + // See: https://stackoverflow.com/a/3967927/5285364 + return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"'); + } + + RPC.addRoute('Photoshop.open', function (data) { + log.warn('Server called client route "open":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("fileOpen('" + escapedPath +"')") + .then(function(result){ + log.warn("open: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.read', function (data) { + log.warn('Server called client route "read":', data); + return runEvalScript("getHeadline()") + .then(function(result){ + log.warn("getHeadline: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_layers', function (data) { + log.warn('Server called client route "get_layers":', data); + return runEvalScript("getLayers()") + .then(function(result){ + log.warn("getLayers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.set_visible', function (data) { + log.warn('Server called client route "set_visible":', data); + return runEvalScript("setVisible(" + data.layer_id + ", " + + data.visibility + ")") + .then(function(result){ + log.warn("setVisible: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_name', function (data) { + log.warn('Server called client route "get_active_document_name":', + data); + return runEvalScript("getActiveDocumentName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_active_document_full_name', function (data) { + log.warn('Server called client route ' + + '"get_active_document_full_name":', data); + return runEvalScript("getActiveDocumentFullName()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.save', function (data) { + log.warn('Server called client route "save":', data); + + return runEvalScript("save()") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_selected_layers', function (data) { + log.warn('Server called client route "get_selected_layers":', data); + + return runEvalScript("getSelectedLayers()") + .then(function(result){ + log.warn("get_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.create_group', function (data) { + log.warn('Server called client route "create_group":', data); + + return runEvalScript("createGroup('" + data.name + "')") + .then(function(result){ + log.warn("createGroup: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.group_selected_layers', function (data) { + log.warn('Server called client route "group_selected_layers":', + data); + + return runEvalScript("groupSelectedLayers(null, "+ + "'" + data.name +"')") + .then(function(result){ + log.warn("group_selected_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.import_smart_object', function (data) { + log.warn('Server called client "import_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("importSmartObject('" + escapedPath +"', " + + "'"+ data.name +"',"+ + + data.as_reference +")") + .then(function(result){ + log.warn("import_smart_object: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.replace_smart_object', function (data) { + log.warn('Server called route "replace_smart_object":', data); + var escapedPath = EscapeStringForJSX(data.path); + return runEvalScript("replaceSmartObjects("+data.layer_id+"," + + "'" + escapedPath +"',"+ + "'"+ data.name +"')") + .then(function(result){ + log.warn("replaceSmartObjects: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.delete_layer', function (data) { + log.warn('Server called route "delete_layer":', data); + return runEvalScript("deleteLayer("+data.layer_id+")") + .then(function(result){ + log.warn("delete_layer: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.rename_layer', function (data) { + log.warn('Server called route "rename_layer":', data); + return runEvalScript("renameLayer("+data.layer_id+", " + + "'"+ data.name +"')") + .then(function(result){ + log.warn("rename_layer: " + result); + return result; + }); +}); + + RPC.addRoute('Photoshop.select_layers', function (data) { + log.warn('Server called client route "select_layers":', data); + + return runEvalScript("selectLayers('" + data.layers +"')") + .then(function(result){ + log.warn("select_layers: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.is_saved', function (data) { + log.warn('Server called client route "is_saved":', data); + + return runEvalScript("isSaved()") + .then(function(result){ + log.warn("is_saved: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.saveAs', function (data) { + log.warn('Server called client route "saveAsJPEG":', data); + var escapedPath = EscapeStringForJSX(data.image_path); + return runEvalScript("saveAs('" + escapedPath + "', " + + "'" + data.ext + "', " + + data.as_copy + ")") + .then(function(result){ + log.warn("save: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.imprint', function (data) { + log.warn('Server called client route "imprint":', data); + var escaped = data.payload.replace(/\n/g, "\\n"); + return runEvalScript("imprint('" + escaped + "')") + .then(function(result){ + log.warn("imprint: " + result); + return result; + }); + }); + + RPC.addRoute('Photoshop.get_extension_version', function (data) { + log.warn('Server called client route "get_extension_version":', data); + return get_extension_version(); + }); + + RPC.addRoute('Photoshop.close', function (data) { + log.warn('Server called client route "close":', data); + return runEvalScript("close()"); + }); + + RPC.call('Photoshop.ping').then(function (data) { + log.warn('Result for calling server route "ping": ', data); + return runEvalScript("ping()") + .then(function(result){ + log.warn("ping: " + result); + return result; + }); + + }, function (error) { + log.warn(error); + }); + + } + + log.warn("end script"); diff --git a/openpype/hosts/photoshop/api/extension/client/loglevel.min.js b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js new file mode 100644 index 0000000000..648d7e9ff6 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/loglevel.min.js @@ -0,0 +1,2 @@ +/*! loglevel - v1.6.8 - https://github.com/pimterry/loglevel - (c) 2020 Tim Perry - licensed MIT */ +!function(a,b){"use strict";"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.log=b()}(this,function(){"use strict";function a(a,b){var c=a[b];if("function"==typeof c.bind)return c.bind(a);try{return Function.prototype.bind.call(c,a)}catch(b){return function(){return Function.prototype.apply.apply(c,[a,arguments])}}}function b(){console.log&&(console.log.apply?console.log.apply(console,arguments):Function.prototype.apply.apply(console.log,[console,arguments])),console.trace&&console.trace()}function c(c){return"debug"===c&&(c="log"),typeof console!==i&&("trace"===c&&j?b:void 0!==console[c]?a(console,c):void 0!==console.log?a(console,"log"):h)}function d(a,b){for(var c=0;c=0&&b<=j.levels.SILENT))throw"log.setLevel() called with invalid level: "+b;if(h=b,!1!==c&&e(b),d.call(j,b,a),typeof console===i&&b 1 && arguments[1] !== undefined ? arguments[1] : 1000; + + _classCallCheck(this, WSRPC); + + var self = this; + URL = getAbsoluteWsUrl(URL); + self.id = 1; + self.eventId = 0; + self.socketStarted = false; + self.eventStore = { + onconnect: {}, + onerror: {}, + onclose: {}, + onchange: {} + }; + self.connectionNumber = 0; + self.oneTimeEventStore = { + onconnect: [], + onerror: [], + onclose: [], + onchange: [] + }; + self.callQueue = []; + + function createSocket() { + var ws = new WebSocket(URL); + + var rejectQueue = function rejectQueue() { + self.connectionNumber++; // rejects incoming calls + + var deferred; //reject all pending calls + + while (0 < self.callQueue.length) { + var callObj = self.callQueue.shift(); + deferred = self.store[callObj.id]; + delete self.store[callObj.id]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } // reject all from the store + + + for (var key in self.store) { + if (!self.store.hasOwnProperty(key)) continue; + deferred = self.store[key]; + + if (deferred && deferred.promise.isPending()) { + deferred.reject('WebSocket error occurred'); + } + } + }; + + function reconnect(callEvents) { + setTimeout(function () { + try { + self.socket = createSocket(); + self.id = 1; + } catch (exc) { + callEvents('onerror', exc); + delete self.socket; + console.error(exc); + } + }, reconnectTimeout); + } + + ws.onclose = function (err) { + log('ONCLOSE CALLED', 'STATE', self.public.state()); + trace(err); + + for (var serial in self.store) { + if (!self.store.hasOwnProperty(serial)) continue; + + if (self.store[serial].hasOwnProperty('reject')) { + self.store[serial].reject('Connection closed'); + } + } + + rejectQueue(); + callEvents('onclose', err); + callEvents('onchange', err); + reconnect(callEvents); + }; + + ws.onerror = function (err) { + log('ONERROR CALLED', 'STATE', self.public.state()); + trace(err); + rejectQueue(); + callEvents('onerror', err); + callEvents('onchange', err); + log('WebSocket has been closed by error: ', err); + }; + + function tryCallEvent(func, event) { + try { + return func(event); + } catch (e) { + if (e.hasOwnProperty('stack')) { + log(e.stack); + } else { + log('Event function', func, 'raised unknown error:', e); + } + + console.error(e); + } + } + + function callEvents(evName, event) { + while (0 < self.oneTimeEventStore[evName].length) { + var deferred = self.oneTimeEventStore[evName].shift(); + if (deferred.hasOwnProperty('resolve') && deferred.promise.isPending()) deferred.resolve(); + } + + for (var i in self.eventStore[evName]) { + if (!self.eventStore[evName].hasOwnProperty(i)) continue; + var cur = self.eventStore[evName][i]; + tryCallEvent(cur, event); + } + } + + ws.onopen = function (ev) { + log('ONOPEN CALLED', 'STATE', self.public.state()); + trace(ev); + + while (0 < self.callQueue.length) { + // noinspection JSUnresolvedFunction + self.socket.send(JSON.stringify(self.callQueue.shift(), 0, 1)); + } + + callEvents('onconnect', ev); + callEvents('onchange', ev); + }; + + function handleCall(self, data) { + if (!self.routes.hasOwnProperty(data.method)) throw new Error('Route not found'); + var connectionNumber = self.connectionNumber; + var deferred = new Deferred(); + deferred.promise.then(function (result) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + result: result + })); + }, function (error) { + if (connectionNumber !== self.connectionNumber) return; + self.socket.send(JSON.stringify({ + id: data.id, + error: error + })); + }); + var func = self.routes[data.method]; + if (self.asyncRoutes[data.method]) return func.apply(deferred, [data.params]); + + function badPromise() { + throw new Error("You should register route with async flag."); + } + + var promiseMock = { + resolve: badPromise, + reject: badPromise + }; + + try { + deferred.resolve(func.apply(promiseMock, [data.params])); + } catch (e) { + deferred.reject(e); + console.error(e); + } + } + + function handleError(self, data) { + if (!self.store.hasOwnProperty(data.id)) return log('Unknown callback'); + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + log('REJECTING', data.error); + deferred.reject(data.error); + } + + function handleResult(self, data) { + var deferred = self.store[data.id]; + if (typeof deferred === 'undefined') return log('Confirmation without handler'); + delete self.store[data.id]; + + if (data.hasOwnProperty('result')) { + return deferred.resolve(data.result); + } + + return deferred.reject(data.error); + } + + ws.onmessage = function (message) { + log('ONMESSAGE CALLED', 'STATE', self.public.state()); + trace(message); + if (message.type !== 'message') return; + var data; + + try { + data = JSON.parse(message.data); + log(data); + + if (data.hasOwnProperty('method')) { + return handleCall(self, data); + } else if (data.hasOwnProperty('error') && data.error === null) { + return handleError(self, data); + } else { + return handleResult(self, data); + } + } catch (exception) { + var err = { + error: exception.message, + result: null, + id: data ? data.id : null + }; + self.socket.send(JSON.stringify(err)); + console.error(exception); + } + }; + + return ws; + } + + function makeCall(func, args, params) { + self.id += 2; + var deferred = new Deferred(); + var callObj = Object.freeze({ + id: self.id, + method: func, + params: args + }); + var state = self.public.state(); + + if (state === 'OPEN') { + self.store[self.id] = deferred; + self.socket.send(JSON.stringify(callObj)); + } else if (state === 'CONNECTING') { + log('SOCKET IS', state); + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } else { + log('SOCKET IS', state); + + if (params && params['noWait']) { + deferred.reject("Socket is: ".concat(state)); + } else { + self.store[self.id] = deferred; + self.callQueue.push(callObj); + } + } + + return deferred.promise; + } + + self.asyncRoutes = {}; + self.routes = {}; + self.store = {}; + self.public = Object.freeze({ + call: function call(func, args, params) { + return makeCall(func, args, params); + }, + addRoute: function addRoute(route, callback, isAsync) { + self.asyncRoutes[route] = isAsync || false; + self.routes[route] = callback; + }, + deleteRoute: function deleteRoute(route) { + delete self.asyncRoutes[route]; + return delete self.routes[route]; + }, + addEventListener: function addEventListener(event, func) { + var eventId = self.eventId++; + self.eventStore[event][eventId] = func; + return eventId; + }, + removeEventListener: function removeEventListener(event, index) { + if (self.eventStore[event].hasOwnProperty(index)) { + delete self.eventStore[event][index]; + return true; + } else { + return false; + } + }, + onEvent: function onEvent(event) { + var deferred = new Deferred(); + self.oneTimeEventStore[event].push(deferred); + return deferred.promise; + }, + destroy: function destroy() { + return self.socket.close(); + }, + state: function state() { + return readyState[this.stateCode()]; + }, + stateCode: function stateCode() { + if (self.socketStarted && self.socket) return self.socket.readyState; + return 3; + }, + connect: function connect() { + self.socketStarted = true; + self.socket = createSocket(); + } + }); + self.public.addRoute('log', function (argsObj) { + //console.info("Websocket sent: ".concat(argsObj)); + }); + self.public.addRoute('ping', function (data) { + return data; + }); + return self.public; + }; + + WSRPC.DEBUG = false; + WSRPC.TRACE = false; + + return WSRPC; + +})); +//# sourceMappingURL=wsrpc.js.map diff --git a/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js new file mode 100644 index 0000000000..f1264b91c4 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/client/wsrpc.min.js @@ -0,0 +1 @@ +!function(global,factory){"object"==typeof exports&&"undefined"!=typeof module?module.exports=factory():"function"==typeof define&&define.amd?define(factory):(global=global||self).WSRPC=factory()}(this,function(){"use strict";function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function Deferred(){_classCallCheck(this,Deferred);var self=this;function wrapper(func){return function(){if(!self.done)return self.done=!0,func.apply(this,arguments);console.error(new Error("Promise already done"))}}return self.resolve=null,self.reject=null,self.done=!1,self.promise=new Promise(function(resolve,reject){self.resolve=wrapper(resolve),self.reject=wrapper(reject)}),self.promise.isPending=function(){return!self.done},self}function logGroup(group,level,args){console.group(group),console[level].apply(this,args),console.groupEnd()}function log(){WSRPC.DEBUG&&logGroup("WSRPC.DEBUG","trace",arguments)}function trace(msg){if(WSRPC.TRACE){var payload=msg;"data"in msg&&(payload=JSON.parse(msg.data)),logGroup("WSRPC.TRACE","trace",[payload])}}var readyState=Object.freeze({0:"CONNECTING",1:"OPEN",2:"CLOSING",3:"CLOSED"}),WSRPC=function WSRPC(URL){var reconnectTimeout=1 // +// forceEval is now by default true // +// It wraps the scripts in a try catch and an eval providing useful error handling // +// One can set in the jsx engine $.includeStack = true to return the call stack in the event of an error // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/////////////////////////////////////////////////////////////////////////////////////////////////////////// +// JSX.js for calling jsx code from the js engine // +// 2 methods included // +// 1) jsx.evalScript AKA jsx.eval // +// 2) jsx.evalFile AKA jsx.file // +// Special features // +// 1) Allows all changes in your jsx code to be reloaded into your extension at the click of a button // +// 2) Can enable the $.fileName property to work and provides a $.__fileName() method as an alternative // +// 3) Can force a callBack result from InDesign // +// 4) No more csInterface.evalScript('alert("hello "' + title + " " + name + '");') // +// use jsx.evalScript('alert("hello __title__ __name__");', {title: title, name: name}); // +// 5) execute jsx files from your jsx folder like this jsx.evalFile('myFabJsxScript.jsx'); // +// or from a relative path jsx.evalFile('../myFabScripts/myFabJsxScript.jsx'); // +// or from an absolute url jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) // +// or from an absolute url jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) // +// 6) Parameter can be entered in the from of a parameter list which can be in any order or as an object // +// 7) Not camelCase sensitive (very useful for the illiterate) // +// Dead easy to use BUT SPEND THE 3 TO 5 MINUTES IT SHOULD TAKE TO READ THE INSTRUCTIONS // +/////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/* jshint undef:true, unused:true, esversion:6 */ + +////////////////////////////////////// +// jsx is the interface for the API // +////////////////////////////////////// + +var jsx; + +// Wrap everything in an anonymous function to prevent leeks +(function() { + ///////////////////////////////////////////////////////////////////// + // Substitute some CSInterface functions to avoid dependency on it // + ///////////////////////////////////////////////////////////////////// + + var __dirname = (function() { + var path, isMac; + path = decodeURI(window.__adobe_cep__.getSystemPath('extension')); + isMac = navigator.platform[0] === 'M'; // [M]ac + path = path.replace('file://' + (isMac ? '' : '/'), ''); + return path; + })(); + + var evalScript = function(script, callback) { + callback = callback || function() {}; + window.__adobe_cep__.evalScript(script, callback); + }; + + + //////////////////////////////////////////// + // In place of using the node path module // + //////////////////////////////////////////// + + // jshint undef: true, unused: true + + // A very minified version of the NodeJs Path module!! + // For use outside of NodeJs + // Majorly nicked by Trevor from Joyent + var path = (function() { + + var isString = function(arg) { + return typeof arg === 'string'; + }; + + // var isObject = function(arg) { + // return typeof arg === 'object' && arg !== null; + // }; + + var basename = function(path) { + if (!isString(path)) { + throw new TypeError('Argument to path.basename must be a string'); + } + var bits = path.split(/[\/\\]/g); + return bits[bits.length - 1]; + }; + + // jshint undef: true + // Regex to split a windows path into three parts: [*, device, slash, + // tail] windows-only + var splitDeviceRe = + /^([a-zA-Z]:|[\\\/]{2}[^\\\/]+[\\\/]+[^\\\/]+)?([\\\/])?([\s\S]*?)$/; + + // Regex to split the tail part of the above into [*, dir, basename, ext] + // var splitTailRe = + // /^([\s\S]*?)((?:\.{1,2}|[^\\\/]+?|)(\.[^.\/\\]*|))(?:[\\\/]*)$/; + + var win32 = {}; + // Function to split a filename into [root, dir, basename, ext] + // var win32SplitPath = function(filename) { + // // Separate device+slash from tail + // var result = splitDeviceRe.exec(filename), + // device = (result[1] || '') + (result[2] || ''), + // tail = result[3] || ''; + // // Split the tail into dir, basename and extension + // var result2 = splitTailRe.exec(tail), + // dir = result2[1], + // basename = result2[2], + // ext = result2[3]; + // return [device, dir, basename, ext]; + // }; + + var win32StatPath = function(path) { + var result = splitDeviceRe.exec(path), + device = result[1] || '', + isUnc = !!device && device[1] !== ':'; + return { + device: device, + isUnc: isUnc, + isAbsolute: isUnc || !!result[2], // UNC paths are always absolute + tail: result[3] + }; + }; + + var normalizeUNCRoot = function(device) { + return '\\\\' + device.replace(/^[\\\/]+/, '').replace(/[\\\/]+/g, '\\'); + }; + + var normalizeArray = function(parts, allowAboveRoot) { + var res = []; + for (var i = 0; i < parts.length; i++) { + var p = parts[i]; + + // ignore empty parts + if (!p || p === '.') + continue; + + if (p === '..') { + if (res.length && res[res.length - 1] !== '..') { + res.pop(); + } else if (allowAboveRoot) { + res.push('..'); + } + } else { + res.push(p); + } + } + + return res; + }; + + win32.normalize = function(path) { + var result = win32StatPath(path), + device = result.device, + isUnc = result.isUnc, + isAbsolute = result.isAbsolute, + tail = result.tail, + trailingSlash = /[\\\/]$/.test(tail); + + // Normalize the tail path + tail = normalizeArray(tail.split(/[\\\/]+/), !isAbsolute).join('\\'); + + if (!tail && !isAbsolute) { + tail = '.'; + } + if (tail && trailingSlash) { + tail += '\\'; + } + + // Convert slashes to backslashes when `device` points to an UNC root. + // Also squash multiple slashes into a single one where appropriate. + if (isUnc) { + device = normalizeUNCRoot(device); + } + + return device + (isAbsolute ? '\\' : '') + tail; + }; + win32.join = function() { + var paths = []; + for (var i = 0; i < arguments.length; i++) { + var arg = arguments[i]; + if (!isString(arg)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (arg) { + paths.push(arg); + } + } + + var joined = paths.join('\\'); + + // Make sure that the joined path doesn't start with two slashes, because + // normalize() will mistake it for an UNC path then. + // + // This step is skipped when it is very clear that the user actually + // intended to point at an UNC path. This is assumed when the first + // non-empty string arguments starts with exactly two slashes followed by + // at least one more non-slash character. + // + // Note that for normalize() to treat a path as an UNC path it needs to + // have at least 2 components, so we don't filter for that here. + // This means that the user can use join to construct UNC paths from + // a server name and a share name; for example: + // path.join('//server', 'share') -> '\\\\server\\share\') + if (!/^[\\\/]{2}[^\\\/]/.test(paths[0])) { + joined = joined.replace(/^[\\\/]{2,}/, '\\'); + } + return win32.normalize(joined); + }; + + var posix = {}; + + // posix version + posix.join = function() { + var path = ''; + for (var i = 0; i < arguments.length; i++) { + var segment = arguments[i]; + if (!isString(segment)) { + throw new TypeError('Arguments to path.join must be strings'); + } + if (segment) { + if (!path) { + path += segment; + } else { + path += '/' + segment; + } + } + } + return posix.normalize(path); + }; + + // path.normalize(path) + // posix version + posix.normalize = function(path) { + var isAbsolute = path.charAt(0) === '/', + trailingSlash = path && path[path.length - 1] === '/'; + + // Normalize the path + path = normalizeArray(path.split('/'), !isAbsolute).join('/'); + + if (!path && !isAbsolute) { + path = '.'; + } + if (path && trailingSlash) { + path += '/'; + } + + return (isAbsolute ? '/' : '') + path; + }; + + win32.basename = posix.basename = basename; + + this.win32 = win32; + this.posix = posix; + return (navigator.platform[0] === 'M') ? posix : win32; + })(); + + //////////////////////////////////////////////////////////////////////////////////////////////////////// + // The is the "main" function which is to be prototyped // + // It run a small snippet in the jsx engine that // + // 1) Assigns $.__dirname with the value of the extensions __dirname base path // + // 2) Sets up a method $.__fileName() for retrieving from within the jsx script it's $.fileName value // + // more on that method later // + // At the end of the script the global declaration jsx = new Jsx(); has been made. // + // If you like you can remove that and include in your relevant functions // + // var jsx = new Jsx(); You would never call the Jsx function without the "new" declaration // + //////////////////////////////////////////////////////////////////////////////////////////////////////// + var Jsx = function() { + var jsxScript; + // Setup jsx function to enable the jsx scripts to easily retrieve their file location + jsxScript = [ + '$.level = 0;', + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}' + ].join(''); + evalScript(jsxScript); + return this; + }; + + /** + * [evalScript] For calling jsx scripts from the js engine + * + * The jsx.evalScript method is used for calling jsx scripts directly from the js engine + * Allows for easy replacement i.e. variable insertions and for forcing eval. + * For convenience jsx.eval or jsx.script or jsx.evalscript can be used instead of calling jsx.evalScript + * + * @param {String} jsxScript + * The string that makes up the jsx script + * it can contain a simple template like syntax for replacements + * 'alert("__foo__");' + * the __foo__ will be replaced as per the replacements parameter + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * given the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: true + * }); + * note that either lower or camelCase key names are valid + * i.e. both callback or callBack will work + * + * The following keys are the same jsx || script || jsxScript || jsxscript || file + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * The following keys are the same forceEvalScript || forceevalscript || evalScript || evalscript; + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalScript = function() { + var arg, i, key, replaceThis, withThis, args, callback, forceEval, replacements, jsxScript, isBin; + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + args = arguments; + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 4; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor === String) { + jsxScript = arg; + continue; + } + if (arg.constructor === Object) { + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + // Have changed the forceEval default to be true as I prefer the error handling + if (forceEval !== false) { + forceEval = true; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // On Illustrator and other apps the result of the jsx script is automatically passed as a string // + // if you have a "script" containing the single number 1 and nothing else then the callBack will register as "1" // + // On InDesign that same script will provide a blank callBack // + // Let's say we have a callBack function var callBack = function(result){alert(result);} // + // On Ai your see the 1 in the alert // + // On ID your just see a blank alert // + // To see the 1 in the alert you need to convert the result to a string and then it will show // + // So if we rewrite out 1 byte script to '1' i.e. surround the 1 in quotes then the call back alert will show 1 // + // If the scripts planed one can make sure that the results always passed as a string (including errors) // + // otherwise one can wrap the script in an eval and then have the result passed as a string // + // I have not gone through all the apps but can say // + // for Ai you never need to set the forceEval to true // + // for ID you if you have not coded your script appropriately and your want to send a result to the callBack then set forceEval to true // + // I changed this that even on Illustrator it applies the try catch, Note the try catch will fail if $.level is set to 1 // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + if (forceEval) { + + isBin = (jsxScript.substring(0, 10) === '@JSXBIN@ES') ? '' : '\n'; + jsxScript = ( + // "\n''') + '';} catch(e){(function(e){var n, a=[]; for (n in e){a.push(n + ': ' + e[n])}; return a.join('\n')})(e)}"); + // "\n''') + '';} catch(e){e + (e.line ? ('\\nLine ' + (+e.line - 1)) : '')}"); + [ + "$.level = 0;", + "try{eval('''" + isBin, // need to add an extra line otherwise #targetengine doesn't work ;-] + jsxScript.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"') + "\n''') + '';", + "} catch (e) {", + " (function(e) {", + " var line, sourceLine, name, description, ErrorMessage, fileName, start, end, bug;", + " line = +e.line" + (isBin === '' ? ';' : ' - 1;'), // To take into account the extra line added + " fileName = File(e.fileName).fsName;", + " sourceLine = line && e.source.split(/[\\r\\n]/)[line];", + " name = e.name;", + " description = e.description;", + " ErrorMessage = name + ' ' + e.number + ': ' + description;", + " if (fileName.length && !(/[\\/\\\\]\\d+$/.test(fileName))) {", + " ErrorMessage += '\\nFile: ' + fileName;", + " line++;", + " }", + " if (line){", + " ErrorMessage += '\\nLine: ' + line +", + " '-> ' + ((sourceLine.length < 300) ? sourceLine : sourceLine.substring(0,300) + '...');", + " }", + " if (e.start) {ErrorMessage += '\\nBug: ' + e.source.substring(e.start - 1, e.end)}", + " if ($.includeStack) {ErrorMessage += '\\nStack:' + $.stack;}", + " return ErrorMessage;", + " })(e);", + "}" + ].join('') + ); + + } + + ///////////////////////////////////////////////////////////// + // deal with the replacements // + // Note it's probably better to use ${template} `literals` // + ///////////////////////////////////////////////////////////// + + if (replacements) { + for (key in replacements) { + if (replacements.hasOwnProperty(key)) { + replaceThis = new RegExp('__' + key + '__', 'g'); + withThis = replacements[key]; + jsxScript = jsxScript.replace(replaceThis, withThis + ''); + } + } + } + + + try { + evalScript(jsxScript, callback); + return true; + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + }; + + + /** + * [evalFile] For calling jsx scripts from the js engine + * + * The jsx.evalFiles method is used for executing saved jsx scripts + * where the jsxScript parameter is a string of the jsx scripts file location. + * For convenience jsx.file or jsx.evalfile can be used instead of jsx.evalFile + * + * @param {String} file + * The path to jsx script + * If only the base name is provided then the path will be presumed to be the + * To execute files stored in the jsx folder located in the __dirname folder use + * jsx.evalFile('myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located in the __dirname folder use + * jsx.evalFile('./myFabScripts/myFabJsxScript.jsx'); + * To execute files stored in the a folder myFabScripts located at an absolute url use + * jsx.evalFile('/Path/to/my/FabJsxScript.jsx'); (mac) + * or jsx.evalFile('C:Path/to/my/FabJsxScript.jsx'); (windows) + * + * @param {Function} callback + * The callback function you want the jsx script to trigger on completion + * The result of the jsx script is passed as the argument to that function + * The function can exist in some other file. + * Note that InDesign does not automatically pass the callBack as a string. + * Either write your InDesign in a way that it returns a sting the form of + * return 'this is my result surrounded by quotes' + * or use the force eval option + * [Optional DEFAULT no callBack] + * + * @param {Object} replacements + * The replacements to make on the jsx script + * give the following script (template) + * 'alert("__message__: " + __val__);' + * and we want to change the script to + * 'alert("I was born in the year: " + 1234);' + * we would pass the following object + * {"message": 'I was born in the year', "val": 1234} + * or if not using reserved words like do we can leave out the key quotes + * {message: 'I was born in the year', val: 1234} + * By default when possible the forceEvalScript will be set to true + * The forceEvalScript option cannot be true when there are replacements + * To force the forceEvalScript to be false you can send a blank set of replacements + * jsx.evalFile('myFabScript.jsx', {}); Will NOT be executed using the $.evalScript method + * jsx.evalFile('myFabScript.jsx'); Will YES be executed using the $.evalScript method + * see the forceEvalScript parameter for details on this + * [Optional DEFAULT no replacements] + * + * @param {Bolean} forceEval + * If the script should be wrapped in an eval and try catch + * This will 1) provide useful error feedback if heaven forbid it is needed + * 2) The result will be a string which is required for callback results in InDesign + * [Optional DEFAULT true] + * + * If no replacements are needed then the jsx script is be executed by using the $.evalFile method + * This exposes the true value of the $.fileName property + * In such a case it's best to avoid using the $.__fileName() with no base name as it won't work + * BUT one can still use the $.__fileName('baseName') method which is more accurate than the standard $.fileName property + * Let's say you have a Drive called "Graphics" AND YOU HAVE a root folder on your "main" drive called "Graphics" + * You call a script jsx.evalFile('/Volumes/Graphics/myFabScript.jsx'); + * $.fileName will give you '/Graphics/myFabScript.jsx' which is wrong + * $.__fileName('myFabScript.jsx') will give you '/Volumes/Graphics/myFabScript.jsx' which is correct + * $.__fileName() will not give you a reliable result + * Note that if your calling multiple versions of myFabScript.jsx stored in multiple folders then you can get stuffed! + * i.e. if the fileName is important to you then don't do that. + * It also will force the result of the jsx file as a string which is particularly useful for InDesign callBacks + * + * Note 1) The order of the parameters is irrelevant + * Note 2) One can pass the arguments as an object if desired + * jsx.evalScript(myCallBackFunction, 'alert("__myMessage__");', true); + * is the same as + * jsx.evalScript({ + * script: 'alert("__myMessage__");', + * replacements: {myMessage: 'Hi there'}, + * callBack: myCallBackFunction, + * eval: false, + * }); + * note that either lower or camelCase key names or valid + * i.e. both callback or callBack will work + * + * The following keys are the same file || jsx || script || jsxScript || jsxscript + * The following keys are the same callBack || callback + * The following keys are the same replacements || replace + * The following keys are the same eval || forceEval || forceeval + * + * @return {Boolean} if the jsxScript was executed or not + */ + + Jsx.prototype.evalFile = function() { + var arg, args, callback, fileName, fileNameScript, forceEval, forceEvalScript, + i, jsxFolder, jsxScript, newLine, replacements, success; + + success = true; // optimistic + args = arguments; + + jsxFolder = path.join(__dirname, 'jsx'); + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + // $.fileName does not return it's correct path in the jsx engine for files called from the js engine // + // In Illustrator it returns an integer in InDesign it returns an empty string // + // This script injection allows for the script to know it's path by calling // + // $.__fileName(); // + // on Illustrator this works pretty well // + // on InDesign it's best to use with a bit of care // + // If the a second script has been called the InDesing will "forget" the path to the first script // + // 2 work-arounds for this // + // 1) at the beginning of your script add var thePathToMeIs = $.fileName(); // + // thePathToMeIs will not be forgotten after running the second script // + // 2) $.__fileName('myBaseName.jsx'); // + // for example you have file with the following path // + // /path/to/me.jsx // + // Call $.__fileName('me.jsx') and you will get /path/to/me.jsx even after executing a second script // + // Note When the forceEvalScript option is used then you just use the regular $.fileName property // + ////////////////////////////////////////////////////////////////////////////////////////////////////////// + fileNameScript = [ + // The if statement should not normally be executed + 'if(!$.__fileNames){', + ' $.__fileNames = {};', + ' $.__dirname = "__dirname__";'.replace('__dirname__', __dirname), + ' $.__fileName = function(name){', + ' name = name || $.fileName;', + ' return ($.__fileNames && $.__fileNames[name]) || $.fileName;', + ' };', + '}', + '$.__fileNames["__basename__"] = $.__fileNames["" + $.fileName] = "__fileName__";' + ].join(''); + + ////////////////////////////////////////////////////////////////////////////////////// + // sort out order which arguments into jsxScript, callback, replacements, forceEval // + ////////////////////////////////////////////////////////////////////////////////////// + + + // Detect if the parameters were passed as an object and if so allow for various keys + if (args.length === 1 && (arg = args[0]) instanceof Object) { + jsxScript = arg.jsxScript || arg.jsx || arg.script || arg.file || arg.jsxscript; + callback = arg.callBack || arg.callback; + replacements = arg.replacements || arg.replace; + forceEval = arg.eval || arg.forceEval || arg.forceeval; + } else { + for (i = 0; i < 5; i++) { + arg = args[i]; + if (arg === undefined) { + continue; + } + if (arg.constructor.name === 'String') { + jsxScript = arg; + continue; + } + if (arg.constructor.name === 'Object') { + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // If no replacements are provided then the $.evalScript method will be used // + // This will allow directly for the $.fileName property to be used // + // If one does not want the $.evalScript method to be used then // + // either send a blank object as the replacements {} // + // or explicitly set the forceEvalScript option to false // + // This can only be done if the parameters are passed as an object // + // i.e. jsx.evalFile({file:'myFabScript.jsx', forceEvalScript: false}); // + // if the file was called using // + // i.e. jsx.evalFile('myFabScript.jsx'); // + // then the following jsx code is called $.evalFile(new File('Path/to/myFabScript.jsx', 10000000000)) + ''; // + // forceEval is never needed if the forceEvalScript is triggered // + ////////////////////////////////////////////////////////////////////////////////////////////////////////////// + replacements = arg; + continue; + } + if (arg.constructor === Function) { + callback = arg; + continue; + } + if (arg === false) { + forceEval = false; + } + } + } + + // If no script provide then not too much to do! + if (!jsxScript) { + return false; + } + + forceEvalScript = !replacements; + + + ////////////////////////////////////////////////////// + // Get path of script // + // Check if it's literal, relative or in jsx folder // + ////////////////////////////////////////////////////// + + if (/^\/|[a-zA-Z]+:/.test(jsxScript)) { // absolute path Mac | Windows + jsxScript = path.normalize(jsxScript); + } else if (/^\.+\//.test(jsxScript)) { + jsxScript = path.join(__dirname, jsxScript); // relative path + } else { + jsxScript = path.join(jsxFolder, jsxScript); // files in the jsxFolder + } + + if (forceEvalScript) { + jsxScript = jsxScript.replace(/"/g, '\\"'); + // Check that the path exist, should change this to asynchronous at some point + if (!window.cep.fs.stat(jsxScript).err) { + jsxScript = fileNameScript.replace(/__fileName__/, jsxScript).replace(/__basename__/, path.basename(jsxScript)) + + '$.evalFile(new File("' + jsxScript.replace(/\\/g, '\\\\') + '")) + "";'; + return this.evalScript(jsxScript, callback, forceEval); + } else { + throw new Error(`The file: {jsxScript} could not be found / read`); + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Replacements made so we can't use $.evalFile and need to read the jsx script for ourselves // + //////////////////////////////////////////////////////////////////////////////////////////////// + + fileName = jsxScript.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + try { + jsxScript = window.cep.fs.readFile(jsxScript).data; + } catch (er) { + throw new Error(`The file: ${fileName} could not be read`); + } + // It is desirable that the injected fileNameScript is on the same line as the 1st line of the script + // This is so that the $.line or error.line returns the same value as the actual file + // However if the 1st line contains a # directive then we need to insert a new line and stuff the above problem + // When possible i.e. when there's no replacements then $.evalFile will be used and then the whole issue is avoided + newLine = /^\s*#/.test(jsxScript) ? '\n' : ''; + jsxScript = fileNameScript.replace(/__fileName__/, fileName).replace(/__basename__/, path.basename(fileName)) + newLine + jsxScript; + + try { + // evalScript(jsxScript, callback); + return this.evalScript(jsxScript, callback, replacements, forceEval); + } catch (err) { + //////////////////////////////////////////////// + // Do whatever error handling you want here ! // + //////////////////////////////////////////////// + var newErr; + newErr = new Error(err); + alert('Error Eek: ' + newErr.stack); + return false; + } + + return success; // success should be an array but for now it's a Boolean + }; + + + //////////////////////////////////// + // Setup alternative method names // + //////////////////////////////////// + Jsx.prototype.eval = Jsx.prototype.script = Jsx.prototype.evalscript = Jsx.prototype.evalScript; + Jsx.prototype.file = Jsx.prototype.evalfile = Jsx.prototype.evalFile; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // Examples // + // jsx.evalScript('alert("foo");'); // + // jsx.evalFile('foo.jsx'); // where foo.jsx is stored in the jsx folder at the base of the extensions directory // + // jsx.evalFile('../myFolder/foo.jsx'); // where a relative or absolute file path is given // + // // + // using conventional methods one would use in the case were the values to swap were supplied by variables // + // csInterface.evalScript('var q = "' + name + '"; alert("' + myString + '" ' + myOp + ' q);q;', callback); // + // Using all the '' + foo + '' is very error prone // + // jsx.evalScript('var q = "__name__"; alert(__string__ __opp__ q);q;',{'name':'Fred', 'string':'Hello ', 'opp':'+'}, callBack); // + // is much simpler and less error prone // + // // + // more readable to use object // + // jsx.evalFile({ // + // file: 'yetAnotherFabScript.jsx', // + // replacements: {"this": foo, That: bar, and: "&&", the: foo2, other: bar2}, // + // eval: true // + // }) // + // Enjoy // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + + jsx = new Jsx(); +})(); diff --git a/openpype/hosts/photoshop/api/extension/host/index.jsx b/openpype/hosts/photoshop/api/extension/host/index.jsx new file mode 100644 index 0000000000..2acec1ebc1 --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/index.jsx @@ -0,0 +1,484 @@ +#include "json.js"; +#target photoshop + +var LogFactory=function(file,write,store,level,defaultStatus,continuing){if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}else if(!file)file={file:{}};write=(file.write!==undefined)?file.write:write;if(write===undefined){write=true;}store=(file.store!==undefined)?file.store||false:store||false;level=(file.level!==undefined)?file.level:level;defaultStatus=(file.defaultStatus!==undefined)?file.defaultStatus:defaultStatus;if(defaultStatus===undefined){defaultStatus='LOG';}continuing=(file.continuing!==undefined)?file.continuing:continuing||false;file=file.file||{};var stack,times,logTime,logPoint,icons,statuses,LOG_LEVEL,LOG_STATUS;stack=[];times=[];logTime=new Date();logPoint='Log Factory Start';icons={"1":"\ud83d\udd50","130":"\ud83d\udd5c","2":"\ud83d\udd51","230":"\ud83d\udd5d","3":"\ud83d\udd52","330":"\ud83d\udd5e","4":"\ud83d\udd53","430":"\ud83d\udd5f","5":"\ud83d\udd54","530":"\ud83d\udd60","6":"\ud83d\udd55","630":"\ud83d\udd61","7":"\ud83d\udd56","730":"\ud83d\udd62","8":"\ud83d\udd57","830":"\ud83d\udd63","9":"\ud83d\udd58","930":"\ud83d\udd64","10":"\ud83d\udd59","1030":"\ud83d\udd65","11":"\ud83d\udd5a","1130":"\ud83d\udd66","12":"\ud83d\udd5b","1230":"\ud83d\udd67","AIRPLANE":"\ud83d\udee9","ALARM":"\u23f0","AMBULANCE":"\ud83d\ude91","ANCHOR":"\u2693","ANGRY":"\ud83d\ude20","ANGUISHED":"\ud83d\ude27","ANT":"\ud83d\udc1c","ANTENNA":"\ud83d\udce1","APPLE":"\ud83c\udf4f","APPLE2":"\ud83c\udf4e","ATM":"\ud83c\udfe7","ATOM":"\u269b","BABYBOTTLE":"\ud83c\udf7c","BAD:":"\ud83d\udc4e","BANANA":"\ud83c\udf4c","BANDAGE":"\ud83e\udd15","BANK":"\ud83c\udfe6","BATTERY":"\ud83d\udd0b","BED":"\ud83d\udecf","BEE":"\ud83d\udc1d","BEER":"\ud83c\udf7a","BELL":"\ud83d\udd14","BELLOFF":"\ud83d\udd15","BIRD":"\ud83d\udc26","BLACKFLAG":"\ud83c\udff4","BLUSH":"\ud83d\ude0a","BOMB":"\ud83d\udca3","BOOK":"\ud83d\udcd5","BOOKMARK":"\ud83d\udd16","BOOKS":"\ud83d\udcda","BOW":"\ud83c\udff9","BOWLING":"\ud83c\udfb3","BRIEFCASE":"\ud83d\udcbc","BROKEN":"\ud83d\udc94","BUG":"\ud83d\udc1b","BUILDING":"\ud83c\udfdb","BUILDINGS":"\ud83c\udfd8","BULB":"\ud83d\udca1","BUS":"\ud83d\ude8c","CACTUS":"\ud83c\udf35","CALENDAR":"\ud83d\udcc5","CAMEL":"\ud83d\udc2a","CAMERA":"\ud83d\udcf7","CANDLE":"\ud83d\udd6f","CAR":"\ud83d\ude98","CAROUSEL":"\ud83c\udfa0","CASTLE":"\ud83c\udff0","CATEYES":"\ud83d\ude3b","CATJOY":"\ud83d\ude39","CATMOUTH":"\ud83d\ude3a","CATSMILE":"\ud83d\ude3c","CD":"\ud83d\udcbf","CHECK":"\u2714","CHEQFLAG":"\ud83c\udfc1","CHICK":"\ud83d\udc25","CHICKEN":"\ud83d\udc14","CHICKHEAD":"\ud83d\udc24","CIRCLEBLACK":"\u26ab","CIRCLEBLUE":"\ud83d\udd35","CIRCLERED":"\ud83d\udd34","CIRCLEWHITE":"\u26aa","CIRCUS":"\ud83c\udfaa","CLAPPER":"\ud83c\udfac","CLAPPING":"\ud83d\udc4f","CLIP":"\ud83d\udcce","CLIPBOARD":"\ud83d\udccb","CLOUD":"\ud83c\udf28","CLOVER":"\ud83c\udf40","CLOWN":"\ud83e\udd21","COLDSWEAT":"\ud83d\ude13","COLDSWEAT2":"\ud83d\ude30","COMPRESS":"\ud83d\udddc","CONFOUNDED":"\ud83d\ude16","CONFUSED":"\ud83d\ude15","CONSTRUCTION":"\ud83d\udea7","CONTROL":"\ud83c\udf9b","COOKIE":"\ud83c\udf6a","COOKING":"\ud83c\udf73","COOL":"\ud83d\ude0e","COOLBOX":"\ud83c\udd92","COPYRIGHT":"\u00a9","CRANE":"\ud83c\udfd7","CRAYON":"\ud83d\udd8d","CREDITCARD":"\ud83d\udcb3","CROSS":"\u2716","CROSSBOX:":"\u274e","CRY":"\ud83d\ude22","CRYCAT":"\ud83d\ude3f","CRYSTALBALL":"\ud83d\udd2e","CUSTOMS":"\ud83d\udec3","DELICIOUS":"\ud83d\ude0b","DERELICT":"\ud83c\udfda","DESKTOP":"\ud83d\udda5","DIAMONDLB":"\ud83d\udd37","DIAMONDLO":"\ud83d\udd36","DIAMONDSB":"\ud83d\udd39","DIAMONDSO":"\ud83d\udd38","DICE":"\ud83c\udfb2","DISAPPOINTED":"\ud83d\ude1e","CRY2":"\ud83d\ude25","DIVISION":"\u2797","DIZZY":"\ud83d\ude35","DOLLAR":"\ud83d\udcb5","DOLLAR2":"\ud83d\udcb2","DOWNARROW":"\u2b07","DVD":"\ud83d\udcc0","EJECT":"\u23cf","ELEPHANT":"\ud83d\udc18","EMAIL":"\ud83d\udce7","ENVELOPE":"\ud83d\udce8","ENVELOPE2":"\u2709","ENVELOPE_DOWN":"\ud83d\udce9","EURO":"\ud83d\udcb6","EVIL":"\ud83d\ude08","EXPRESSIONLESS":"\ud83d\ude11","EYES":"\ud83d\udc40","FACTORY":"\ud83c\udfed","FAX":"\ud83d\udce0","FEARFUL":"\ud83d\ude28","FILEBOX":"\ud83d\uddc3","FILECABINET":"\ud83d\uddc4","FIRE":"\ud83d\udd25","FIREENGINE":"\ud83d\ude92","FIST":"\ud83d\udc4a","FLOWER":"\ud83c\udf37","FLOWER2":"\ud83c\udf38","FLUSHED":"\ud83d\ude33","FOLDER":"\ud83d\udcc1","FOLDER2":"\ud83d\udcc2","FREE":"\ud83c\udd93","FROG":"\ud83d\udc38","FROWN":"\ud83d\ude41","GEAR":"\u2699","GLOBE":"\ud83c\udf0d","GLOWINGSTAR":"\ud83c\udf1f","GOOD:":"\ud83d\udc4d","GRIMACING":"\ud83d\ude2c","GRIN":"\ud83d\ude00","GRINNINGCAT":"\ud83d\ude38","HALO":"\ud83d\ude07","HAMMER":"\ud83d\udd28","HAMSTER":"\ud83d\udc39","HAND":"\u270b","HANDDOWN":"\ud83d\udc47","HANDLEFT":"\ud83d\udc48","HANDRIGHT":"\ud83d\udc49","HANDUP":"\ud83d\udc46","HATCHING":"\ud83d\udc23","HAZARD":"\u2623","HEADPHONE":"\ud83c\udfa7","HEARNOEVIL":"\ud83d\ude49","HEARTBLUE":"\ud83d\udc99","HEARTEYES":"\ud83d\ude0d","HEARTGREEN":"\ud83d\udc9a","HEARTYELLOW":"\ud83d\udc9b","HELICOPTER":"\ud83d\ude81","HERB":"\ud83c\udf3f","HIGH_BRIGHTNESS":"\ud83d\udd06","HIGHVOLTAGE":"\u26a1","HIT":"\ud83c\udfaf","HONEY":"\ud83c\udf6f","HOT":"\ud83c\udf36","HOURGLASS":"\u23f3","HOUSE":"\ud83c\udfe0","HUGGINGFACE":"\ud83e\udd17","HUNDRED":"\ud83d\udcaf","HUSHED":"\ud83d\ude2f","ID":"\ud83c\udd94","INBOX":"\ud83d\udce5","INDEX":"\ud83d\uddc2","JOY":"\ud83d\ude02","KEY":"\ud83d\udd11","KISS":"\ud83d\ude18","KISS2":"\ud83d\ude17","KISS3":"\ud83d\ude19","KISS4":"\ud83d\ude1a","KISSINGCAT":"\ud83d\ude3d","KNIFE":"\ud83d\udd2a","LABEL":"\ud83c\udff7","LADYBIRD":"\ud83d\udc1e","LANDING":"\ud83d\udeec","LAPTOP":"\ud83d\udcbb","LEFTARROW":"\u2b05","LEMON":"\ud83c\udf4b","LIGHTNINGCLOUD":"\ud83c\udf29","LINK":"\ud83d\udd17","LITTER":"\ud83d\udeae","LOCK":"\ud83d\udd12","LOLLIPOP":"\ud83c\udf6d","LOUDSPEAKER":"\ud83d\udce2","LOW_BRIGHTNESS":"\ud83d\udd05","MAD":"\ud83d\ude1c","MAGNIFYING_GLASS":"\ud83d\udd0d","MASK":"\ud83d\ude37","MEDAL":"\ud83c\udf96","MEMO":"\ud83d\udcdd","MIC":"\ud83c\udfa4","MICROSCOPE":"\ud83d\udd2c","MINUS":"\u2796","MOBILE":"\ud83d\udcf1","MONEY":"\ud83d\udcb0","MONEYMOUTH":"\ud83e\udd11","MONKEY":"\ud83d\udc35","MOUSE":"\ud83d\udc2d","MOUSE2":"\ud83d\udc01","MOUTHLESS":"\ud83d\ude36","MOVIE":"\ud83c\udfa5","MUGS":"\ud83c\udf7b","NERD":"\ud83e\udd13","NEUTRAL":"\ud83d\ude10","NEW":"\ud83c\udd95","NOENTRY":"\ud83d\udeab","NOTEBOOK":"\ud83d\udcd4","NOTEPAD":"\ud83d\uddd2","NUTANDBOLT":"\ud83d\udd29","O":"\u2b55","OFFICE":"\ud83c\udfe2","OK":"\ud83c\udd97","OKHAND":"\ud83d\udc4c","OLDKEY":"\ud83d\udddd","OPENLOCK":"\ud83d\udd13","OPENMOUTH":"\ud83d\ude2e","OUTBOX":"\ud83d\udce4","PACKAGE":"\ud83d\udce6","PAGE":"\ud83d\udcc4","PAINTBRUSH":"\ud83d\udd8c","PALETTE":"\ud83c\udfa8","PANDA":"\ud83d\udc3c","PASSPORT":"\ud83d\udec2","PAWS":"\ud83d\udc3e","PEN":"\ud83d\udd8a","PEN2":"\ud83d\udd8b","PENSIVE":"\ud83d\ude14","PERFORMING":"\ud83c\udfad","PHONE":"\ud83d\udcde","PILL":"\ud83d\udc8a","PING":"\u2757","PLATE":"\ud83c\udf7d","PLUG":"\ud83d\udd0c","PLUS":"\u2795","POLICE":"\ud83d\ude93","POLICELIGHT":"\ud83d\udea8","POSTOFFICE":"\ud83c\udfe4","POUND":"\ud83d\udcb7","POUTING":"\ud83d\ude21","POUTINGCAT":"\ud83d\ude3e","PRESENT":"\ud83c\udf81","PRINTER":"\ud83d\udda8","PROJECTOR":"\ud83d\udcfd","PUSHPIN":"\ud83d\udccc","QUESTION":"\u2753","RABBIT":"\ud83d\udc30","RADIOACTIVE":"\u2622","RADIOBUTTON":"\ud83d\udd18","RAINCLOUD":"\ud83c\udf27","RAT":"\ud83d\udc00","RECYCLE":"\u267b","REGISTERED":"\u00ae","RELIEVED":"\ud83d\ude0c","ROBOT":"\ud83e\udd16","ROCKET":"\ud83d\ude80","ROLLING":"\ud83d\ude44","ROOSTER":"\ud83d\udc13","RULER":"\ud83d\udccf","SATELLITE":"\ud83d\udef0","SAVE":"\ud83d\udcbe","SCHOOL":"\ud83c\udfeb","SCISSORS":"\u2702","SCREAMING":"\ud83d\ude31","SCROLL":"\ud83d\udcdc","SEAT":"\ud83d\udcba","SEEDLING":"\ud83c\udf31","SEENOEVIL":"\ud83d\ude48","SHIELD":"\ud83d\udee1","SHIP":"\ud83d\udea2","SHOCKED":"\ud83d\ude32","SHOWER":"\ud83d\udebf","SLEEPING":"\ud83d\ude34","SLEEPY":"\ud83d\ude2a","SLIDER":"\ud83c\udf9a","SLOT":"\ud83c\udfb0","SMILE":"\ud83d\ude42","SMILING":"\ud83d\ude03","SMILINGCLOSEDEYES":"\ud83d\ude06","SMILINGEYES":"\ud83d\ude04","SMILINGSWEAT":"\ud83d\ude05","SMIRK":"\ud83d\ude0f","SNAIL":"\ud83d\udc0c","SNAKE":"\ud83d\udc0d","SOCCER":"\u26bd","SOS":"\ud83c\udd98","SPEAKER":"\ud83d\udd08","SPEAKEROFF":"\ud83d\udd07","SPEAKNOEVIL":"\ud83d\ude4a","SPIDER":"\ud83d\udd77","SPIDERWEB":"\ud83d\udd78","STAR":"\u2b50","STOP":"\u26d4","STOPWATCH":"\u23f1","SULK":"\ud83d\ude26","SUNFLOWER":"\ud83c\udf3b","SUNGLASSES":"\ud83d\udd76","SYRINGE":"\ud83d\udc89","TAKEOFF":"\ud83d\udeeb","TAXI":"\ud83d\ude95","TELESCOPE":"\ud83d\udd2d","TEMPORATURE":"\ud83e\udd12","TENNIS":"\ud83c\udfbe","THERMOMETER":"\ud83c\udf21","THINKING":"\ud83e\udd14","THUNDERCLOUD":"\u26c8","TICKBOX":"\u2705","TICKET":"\ud83c\udf9f","TIRED":"\ud83d\ude2b","TOILET":"\ud83d\udebd","TOMATO":"\ud83c\udf45","TONGUE":"\ud83d\ude1b","TOOLS":"\ud83d\udee0","TORCH":"\ud83d\udd26","TORNADO":"\ud83c\udf2a","TOUNG2":"\ud83d\ude1d","TRADEMARK":"\u2122","TRAFFICLIGHT":"\ud83d\udea6","TRASH":"\ud83d\uddd1","TREE":"\ud83c\udf32","TRIANGLE_LEFT":"\u25c0","TRIANGLE_RIGHT":"\u25b6","TRIANGLEDOWN":"\ud83d\udd3b","TRIANGLEUP":"\ud83d\udd3a","TRIANGULARFLAG":"\ud83d\udea9","TROPHY":"\ud83c\udfc6","TRUCK":"\ud83d\ude9a","TRUMPET":"\ud83c\udfba","TURKEY":"\ud83e\udd83","TURTLE":"\ud83d\udc22","UMBRELLA":"\u26f1","UNAMUSED":"\ud83d\ude12","UPARROW":"\u2b06","UPSIDEDOWN":"\ud83d\ude43","WARNING":"\u26a0","WATCH":"\u231a","WAVING":"\ud83d\udc4b","WEARY":"\ud83d\ude29","WEARYCAT":"\ud83d\ude40","WHITEFLAG":"\ud83c\udff3","WINEGLASS":"\ud83c\udf77","WINK":"\ud83d\ude09","WORRIED":"\ud83d\ude1f","WRENCH":"\ud83d\udd27","X":"\u274c","YEN":"\ud83d\udcb4","ZIPPERFACE":"\ud83e\udd10","UNDEFINED":"","":""};statuses={F:'FATAL',B:'BUG',C:'CRITICAL',E:'ERROR',W:'WARNING',I:'INFO',IM:'IMPORTANT',D:'DEBUG',L:'LOG',CO:'CONSTANT',FU:'FUNCTION',R:'RETURN',V:'VARIABLE',S:'STACK',RE:'RESULT',ST:'STOPPER',TI:'TIMER',T:'TRACE'};LOG_LEVEL={NONE:7,OFF:7,FATAL:6,ERROR:5,WARN:4,INFO:3,UNDEFINED:2,'':2,DEFAULT:2,DEBUG:2,TRACE:1,ON:0,ALL:0,};LOG_STATUS={OFF:LOG_LEVEL.OFF,NONE:LOG_LEVEL.OFF,NO:LOG_LEVEL.OFF,NOPE:LOG_LEVEL.OFF,FALSE:LOG_LEVEL.OFF,FATAL:LOG_LEVEL.FATAL,BUG:LOG_LEVEL.ERROR,CRITICAL:LOG_LEVEL.ERROR,ERROR:LOG_LEVEL.ERROR,WARNING:LOG_LEVEL.WARN,INFO:LOG_LEVEL.INFO,IMPORTANT:LOG_LEVEL.INFO,DEBUG:LOG_LEVEL.DEBUG,LOG:LOG_LEVEL.DEBUG,STACK:LOG_LEVEL.DEBUG,CONSTANT:LOG_LEVEL.DEBUG,FUNCTION:LOG_LEVEL.DEBUG,VARIABLE:LOG_LEVEL.DEBUG,RETURN:LOG_LEVEL.DEBUG,RESULT:LOG_LEVEL.TRACE,STOPPER:LOG_LEVEL.TRACE,TIMER:LOG_LEVEL.TRACE,TRACE:LOG_LEVEL.TRACE,ALL:LOG_LEVEL.ALL,YES:LOG_LEVEL.ALL,YEP:LOG_LEVEL.ALL,TRUE:LOG_LEVEL.ALL};var logFile,logFolder;var LOG=function(message,status,icon){if(LOG.level!==LOG_LEVEL.OFF&&(LOG.write||LOG.store)&&LOG.arguments.length)return LOG.addMessage(message,status,icon);};LOG.logDecodeLevel=function(level){if(level==~~level)return Math.abs(level);var lev;level+='';level=level.toUpperCase();if(level in statuses){level=statuses[level];}lev=LOG_LEVEL[level];if(lev!==undefined)return lev;lev=LOG_STATUS[level];if(lev!==undefined)return lev;return LOG_LEVEL.DEFAULT;};LOG.write=write;LOG.store=store;LOG.level=LOG.logDecodeLevel(level);LOG.status=defaultStatus;LOG.addMessage=function(message,status,icon){var date=new Date(),count,bool,logStatus;if(status&&status.constructor.name==='String'){status=status.toUpperCase();status=statuses[status]||status;}else status=LOG.status;logStatus=LOG_STATUS[status]||LOG_STATUS.ALL;if(logStatus999)?'['+LOG.count+'] ':(' ['+LOG.count+'] ').slice(-7);message=count+status+icon+(message instanceof Object?message.toSource():message)+date;if(LOG.store){stack.push(message);}if(LOG.write){bool=file&&file.writable&&logFile.writeln(message);if(!bool){file.writable=true;LOG.setFile(logFile);logFile.writeln(message);}}LOG.count++;return true;};var logNewFile=function(file,isCookie,overwrite){file.encoding='UTF-8';file.lineFeed=($.os[0]=='M')?'Macintosh':' Windows';if(isCookie)return file.open(overwrite?'w':'e')&&file;file.writable=LOG.write;logFile=file;logFolder=file.parent;if(continuing){LOG.count=LOG.setCount(file);}return(!LOG.write&&file||(file.open('a')&&file));};LOG.setFile=function(file,isCookie,overwrite){var bool,folder,fileName,suffix,newFileName,f,d,safeFileName;d=new Date();f=$.stack.split("\n")[0].replace(/^\[\(?/,'').replace(/\)?\]$/,'');if(f==~~f){f=$.fileName.replace(/[^\/]+\//g,'');}safeFileName=File.encode((isCookie?'/COOKIE_':'/LOG_')+f.replace(/^\//,'')+'_'+(1900+d.getYear())+(''+d).replace(/...(...)(..).+/,'_$1_$2')+(isCookie?'.txt':'.log'));if(file&&file.constructor.name=='String'){file=(file.match('/'))?new File(file):new File((logFolder||Folder.temp)+'/'+file);}if(file instanceof File){folder=file.parent;bool=folder.exists||folder.create();if(!bool)folder=Folder.temp;fileName=File.decode(file.name);suffix=fileName.match(/\.[^.]+$/);suffix=suffix?suffix[0]:'';fileName='/'+fileName;newFileName=fileName.replace(/\.[^.]+$/,'')+'_'+(+(new Date())+suffix);f=logNewFile(file,isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+newFileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(folder+safeFileName),isCookie,overwrite);if(f)return f;if(folder!=Folder.temp){f=logNewFile(new File(Folder.temp+fileName),isCookie,overwrite);if(f)return f;f=logNewFile(new File(Folder.temp+safeFileName),isCookie,overwrite);return f||new File(Folder.temp+safeFileName);}}return LOG.setFile(((logFile&&!isCookie)?new File(logFile):new File(Folder.temp+safeFileName)),isCookie,overwrite );};LOG.setCount=function(file){if(~~file===file){LOG.count=file;return LOG.count;}if(file===undefined){file=logFile;}if(file&&file.constructor===String){file=new File(file);}var logNumbers,contents;if(!file.length||!file.exists){LOG.count=1;return 1;}file.open('r');file.encoding='utf-8';file.seek(10000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}if(file.length<10001){file.close();LOG.count=1;return 1;}file.seek(10000000,2);contents='\n'+file.read();logNumbers=contents.match(/\n{0,3}\[\d+\] \[\w+\]+/g);if(logNumbers){logNumbers=+logNumbers[logNumbers.length-1].match(/\d+/)+1;file.close();LOG.count=logNumbers;return logNumbers;}file.close();LOG.count=1;return 1;};LOG.setLevel=function(level){LOG.level=LOG.logDecodeLevel(level);return LOG.level;};LOG.setStatus=function(status){status=(''+status).toUpperCase();LOG.status=statuses[status]||status;return LOG.status;};LOG.cookie=function(file,level,overwrite,setLevel){var log,cookie;if(!file){file={file:file};}if(file&&(file.constructor===String||file.constructor===File)){file={file:file};}log=file;if(log.level===undefined){log.level=(level!==undefined)?level:'NONE';}if(log.overwrite===undefined){log.overwrite=(overwrite!==undefined)?overwrite:false;}if(log.setLevel===undefined){log.setLevel=(setLevel!==undefined)?setLevel:true;}setLevel=log.setLevel;overwrite=log.overwrite;level=log.level;file=log.file;file=LOG.setFile(file,true,overwrite);if(overwrite){file.write(level);}else{cookie=file.read();if(cookie.length){level=cookie;}else{file.write(level);}}file.close();if(setLevel){LOG.setLevel(level);}return{path:file,level:level};};LOG.args=function(args,funct,line){if(LOG.level>LOG_STATUS.FUNCTION)return;if(!(args&&(''+args.constructor).replace(/\s+/g,'')==='functionObject(){[nativecode]}'))return;if(!LOG.args.STRIP_COMMENTS){LOG.args.STRIP_COMMENTS=/((\/.*$)|(\/\*[\s\S]*?\*\/))/mg;}if(!LOG.args.ARGUMENT_NAMES){LOG.args.ARGUMENT_NAMES=/([^\s,]+)/g;}if(!LOG.args.OUTER_BRACKETS){LOG.args.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.args.NEW_SOMETHING){LOG.args.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}var functionString,argumentNames,stackInfo,report,functionName,arg,argsL,n,argName,argValue,argsTotal;if(funct===~~funct){line=funct;}if(!(funct instanceof Function)){funct=args.callee;}if(!(funct instanceof Function))return;functionName=funct.name;functionString=(''+funct).replace(LOG.args.STRIP_COMMENTS,'');argumentNames=functionString.slice(functionString.indexOf('(')+1,functionString.indexOf(')')).match(LOG.args.ARGUMENT_NAMES);argumentNames=argumentNames||[];report=[];report.push('--------------');report.push('Function Data:');report.push('--------------');report.push('Function Name:'+functionName);argsL=args.length;stackInfo=$.stack.split(/[\n\r]/);stackInfo.pop();stackInfo=stackInfo.join('\n ');report.push('Call stack:'+stackInfo);if(line){report.push('Function Line around:'+line);}report.push('Arguments Provided:'+argsL);report.push('Named Arguments:'+argumentNames.length);if(argumentNames.length){report.push('Arguments Names:'+argumentNames.join(','));}if(argsL){report.push('----------------');report.push('Argument Values:');report.push('----------------');}argsTotal=Math.max(argsL,argumentNames.length);for(n=0;n=argsL){argValue='NO VALUE PROVIDED';}else if(arg===undefined){argValue='undefined';}else if(arg===null){argValue='null';}else{argValue=arg.toSource().replace(LOG.args.OUTER_BRACKETS,'$1').replace(LOG.args.NEW_SOMETHING,'$1');}report.push((argName?argName:'arguments['+n+']')+':'+argValue);}report.push('');report=report.join('\n ');LOG(report,'f');return report;};LOG.stack=function(reverse){var st=$.stack.split('\n');st.pop();st.pop();if(reverse){st.reverse();}return LOG(st.join('\n '),'s');};LOG.values=function(values){var n,value,map=[];if(!(values instanceof Object||values instanceof Array)){return;}if(!LOG.values.OUTER_BRACKETS){LOG.values.OUTER_BRACKETS=/^\((.+)?\)$/;}if(!LOG.values.NEW_SOMETHING){LOG.values.NEW_SOMETHING=/^new \w+\((.+)?\)$/;}for(n in values){try{value=values[n];if(value===undefined){value='undefined';}else if(value===null){value='null';}else{value=value.toSource().replace(LOG.values.OUTER_BRACKETS,'$1').replace(LOG.values.NEW_SOMETHING,'$1');}}catch(e){value='\uD83D\uDEAB '+e;}map.push(n+':'+value);}if(map.length){map=map.join('\n ')+'\n ';return LOG(map,'v');}};LOG.reset=function(all){stack.length=0;LOG.count=1;if(all!==false){if(logFile instanceof File){logFile.close();}logFile=LOG.store=LOG.writeToFile=undefined;LOG.write=true;logFolder=Folder.temp;logTime=new Date();logPoint='After Log Reset';}};LOG.stopper=function(message){var newLogTime,t,m,newLogPoint;newLogTime=new Date();newLogPoint=(LOG.count!==undefined)?'LOG#'+LOG.count:'BEFORE LOG#1';LOG.time=t=newLogTime-logTime;if(message===false){return;}message=message||'Stopper start point';t=LOG.prettyTime(t);m=message+'\n '+'From '+logPoint+' to '+newLogPoint+' took '+t+' Starting '+logTime+' '+logTime.getMilliseconds()+'ms'+' Ending '+newLogTime+' '+newLogTime.getMilliseconds()+'ms';LOG(m,'st');logPoint=newLogPoint;logTime=newLogTime;return m;};LOG.start=function(message){var t=new Date();times.push([t,(message!==undefined)?message+'':'']);};LOG.stop=function(message){if(!times.length)return;message=(message)?message+' ':'';var nt,startLog,ot,om,td,m;nt=new Date();startLog=times.pop();ot=startLog[0];om=startLog[1];td=nt-ot;if(om.length){om+=' ';}m=om+'STARTED ['+ot+' '+ot.getMilliseconds()+'ms]\n '+message+'FINISHED ['+nt+' '+nt.getMilliseconds()+'ms]\n TOTAL TIME ['+LOG.prettyTime(td)+']';LOG(m,'ti');return m;};LOG.prettyTime=function(t){var h,m,s,ms;h=Math.floor(t / 3600000);m=Math.floor((t % 3600000)/ 60000);s=Math.floor((t % 60000)/ 1000);ms=t % 1000;t=(!t)?'<1ms':((h)?h+' hours ':'')+((m)?m+' minutes ':'')+((s)?s+' seconds ':'')+((ms&&(h||m||s))?'&':'')+((ms)?ms+'ms':'');return t;};LOG.get=function(){if(!stack.length)return 'THE LOG IS NOT SET TO STORE';var a=fetchLogLines(arguments);return a?'\n'+a.join('\n'):'NO LOGS AVAILABLE';};var fetchLogLines=function(){var args=arguments[0];if(!args.length)return stack;var c,n,l,a=[],ln,start,end,j,sl;l=args.length;sl=stack.length-1;n=0;for(c=0;cln)?sl+ln+1:ln-1;if(ln>=0&&ln<=sl)a[n++]=stack[ln];}else if(ln instanceof Array&&ln.length===2){start=ln[0];end=ln[1];if(!(~~start===start&&~~end===end))continue;start=(0>start)?sl+start+1:start-1;end=(0>end)?sl+end+1:end-1;start=Math.max(Math.min(sl,start),0);end=Math.min(Math.max(end,0),sl);if(start<=end)for(j=start;j<=end;j++)a[n++]=stack[j];else for(j=start;j>=end;j--)a[n++]=stack[j];}}return(n)?a:false;};LOG.file=function(){return logFile;};LOG.openFolder=function(){if(logFolder)return logFolder.execute();};LOG.show=LOG.execute=function(){if(logFile)return logFile.execute();};LOG.close=function(){if(logFile)return logFile.close();};LOG.setFile(file);if(!$.summary.difference){$.summary.difference=function(){return $.summary().replace(/ *([0-9]+)([^ ]+)(\n?)/g,$.summary.updateSnapshot );};}if(!$.summary.updateSnapshot){$.summary.updateSnapshot=function(full,count,name,lf){var snapshot=$.summary.snapshot;count=Number(count);var prev=snapshot[name]?snapshot[name]:0;snapshot[name]=count;var diff=count-prev;if(diff===0)return "";return " ".substring(String(diff).length)+diff+" "+name+lf;};}if(!$.summary.snapshot){$.summary.snapshot=[];$.summary.difference();}$.gc();$.gc();$.summary.difference();LOG.sumDiff=function(message){$.gc();$.gc();var diff=$.summary.difference();if(diff.length<8){diff=' - NONE -';}if(message===undefined){message='';}message+=diff;return LOG('$.summary.difference():'+message,'v');};return LOG;}; + +var log = new LogFactory('myLog.log'); // =>; creates the new log factory - put full path where + +function getEnv(variable){ + return $.getenv(variable); +} + +function fileOpen(path){ + return app.open(new File(path)); +} + +function getLayerTypeWithName(layerName) { + var type = 'NA'; + var nameParts = layerName.split('_'); + var namePrefix = nameParts[0]; + namePrefix = namePrefix.toLowerCase(); + switch (namePrefix) { + case 'guide': + case 'tl': + case 'tr': + case 'bl': + case 'br': + type = 'GUIDE'; + break; + case 'fg': + type = 'FG'; + break; + case 'bg': + type = 'BG'; + break; + case 'obj': + default: + type = 'OBJ'; + break; + } + + return type; +} + +function getLayers() { + /** + * Get json representation of list of layers. + * Much faster this way than in DOM traversal (2s vs 45s on same file) + * + * Format of single layer info: + * id : number + * name: string + * group: boolean - true if layer is a group + * parents:array - list of ids of parent groups, useful for selection + * all children layers from parent layerSet (eg. group) + * type: string - type of layer guessed from its name + * visible:boolean - true if visible + **/ + if (documents.length == 0){ + return '[]'; + } + var ref1 = new ActionReference(); + ref1.putEnumerated(charIDToTypeID('Dcmn'), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt')); + var count = executeActionGet(ref1).getInteger(charIDToTypeID('NmbL')); + + // get all layer names + var layers = []; + var layer = {}; + + var parents = []; + for (var i = count; i >= 1; i--) { + var layer = {}; + var ref2 = new ActionReference(); + ref2.putIndex(charIDToTypeID('Lyr '), i); + + var desc = executeActionGet(ref2); // Access layer index #i + var layerSection = typeIDToStringID(desc.getEnumerationValue( + stringIDToTypeID('layerSection'))); + + layer.id = desc.getInteger(stringIDToTypeID("layerID")); + layer.name = desc.getString(stringIDToTypeID("name")); + layer.color_code = typeIDToStringID(desc.getEnumerationValue(stringIDToTypeID('color'))); + layer.group = false; + layer.parents = parents.slice(); + layer.type = getLayerTypeWithName(layer.name); + layer.visible = desc.getBoolean(stringIDToTypeID("visible")); + //log(" name: " + layer.name + " groupId " + layer.groupId + + //" group " + layer.group); + if (layerSection == 'layerSectionStart') { // Group start and end + parents.push(layer.id); + layer.group = true; + } + if (layerSection == 'layerSectionEnd') { + parents.pop(); + continue; + } + layers.push(JSON.stringify(layer)); + } + try{ + var bck = activeDocument.backgroundLayer; + layer.id = bck.id; + layer.name = bck.name; + layer.group = false; + layer.parents = []; + layer.type = 'background'; + layer.visible = bck.visible; + layers.push(JSON.stringify(layer)); + }catch(e){ + // do nothing, no background layer + }; + //log("layers " + layers); + return '[' + layers + ']'; +} + +function setVisible(layer_id, visibility){ + /** + * Sets particular 'layer_id' to 'visibility' if true > show + **/ + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + executeAction(visibility?stringIDToTypeID("show"):stringIDToTypeID("hide"), + desc, DialogModes.NO); + +} + +function getHeadline(){ + /** + * Returns headline of current document with metadata + * + **/ + if (documents.length == 0){ + return ''; + } + var headline = app.activeDocument.info.headline; + + return headline; +} + +function isSaved(){ + return app.activeDocument.saved; +} + +function save(){ + /** Saves active document **/ + return app.activeDocument.save(); +} + +function saveAs(output_path, ext, as_copy){ + /** Exports scene to various formats + * + * Currently implemented: 'jpg', 'png', 'psd' + * + * output_path - escaped file path on local system + * ext - extension for export + * as_copy - create copy, do not overwrite + * + * */ + var saveName = output_path; + var saveOptions; + if (ext == 'jpg'){ + saveOptions = new JPEGSaveOptions(); + saveOptions.quality = 12; + saveOptions.embedColorProfile = true; + saveOptions.formatOptions = FormatOptions.PROGRESSIVE; + if(saveOptions.formatOptions == FormatOptions.PROGRESSIVE){ + saveOptions.scans = 5}; + saveOptions.matte = MatteType.NONE; + } + if (ext == 'png'){ + saveOptions = new PNGSaveOptions(); + saveOptions.interlaced = true; + saveOptions.transparency = true; + } + if (ext == 'psd'){ + saveOptions = null; + return app.activeDocument.saveAs(new File(saveName)); + } + if (ext == 'psb'){ + return savePSB(output_path); + } + + return app.activeDocument.saveAs(new File(saveName), saveOptions, as_copy); + +} + +function getActiveDocumentName(){ + /** + * Returns file name of active document + * */ + if (documents.length == 0){ + return null; + } + return app.activeDocument.name; +} + +function getActiveDocumentFullName(){ + /** + * Returns file name of active document with file path. + * activeDocument.fullName returns path in URI (eg /c/.. insted of c:/) + * */ + if (documents.length == 0){ + return null; + } + var f = new File(app.activeDocument.fullName); + var path = f.fsName; + f.close(); + return path; +} + +function imprint(payload){ + /** + * Sets headline content of current document with metadata. Stores + * information about assets created through Avalon. + * Content accessible in PS through File > File Info + * + **/ + app.activeDocument.info.headline = payload; +} + +function getSelectedLayers(doc) { + /** + * Returns json representation of currently selected layers. + * Works in three steps - 1) creates new group with selected layers + * 2) traverses this group + * 3) deletes newly created group, not neede + * Bit weird, but Adobe.. + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var selLayers = []; + _grp = groupSelectedLayers(doc); + + var group = doc.activeLayer; + var layers = group.layers; + + // // group is fake at this point + // var itself_name = ''; + // if (layers){ + // itself_name = layers[0].name; + // } + + + for (var i = 0; i < layers.length; i++) { + var layer = {}; + layer.id = layers[i].id; + layer.name = layers[i].name; + long_names =_get_parents_names(group.parent, layers[i].name); + var t = layers[i].kind; + if ((typeof t !== 'undefined') && + (layers[i].kind.toString() == 'LayerKind.NORMAL')){ + layer.group = false; + }else{ + layer.group = true; + } + layer.long_name = long_names; + + selLayers.push(layer); + } + + _undo(); + + return JSON.stringify(selLayers); +}; + +function selectLayers(selectedLayers){ + /** + * Selects layers from list of ids + **/ + selectedLayers = JSON.parse(selectedLayers); + var layers = new Array(); + var id54 = charIDToTypeID( "slct" ); + var desc12 = new ActionDescriptor(); + var id55 = charIDToTypeID( "null" ); + var ref9 = new ActionReference(); + + var existing_layers = JSON.parse(getLayers()); + var existing_ids = []; + for (var y = 0; y < existing_layers.length; y++){ + existing_ids.push(existing_layers[y]["id"]); + } + for (var i = 0; i < selectedLayers.length; i++) { + // a check to see if the id stil exists + var id = selectedLayers[i]; + if(existing_ids.toString().indexOf(id)>=0){ + layers[i] = charIDToTypeID( "Lyr " ); + ref9.putIdentifier(layers[i], id); + } + } + desc12.putReference( id55, ref9 ); + var id58 = charIDToTypeID( "MkVs" ); + desc12.putBoolean( id58, false ); + executeAction( id54, desc12, DialogModes.NO ); +} + +function groupSelectedLayers(doc, name) { + /** + * Groups selected layers into new group. + * Returns json representation of Layer for server to consume + * + * Args: + * doc(activeDocument) + * name (str): new name of created group + **/ + if (doc == null){ + doc = app.activeDocument; + } + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putClass( stringIDToTypeID('layerSection') ); + desc.putReference( charIDToTypeID('null'), ref ); + var lref = new ActionReference(); + lref.putEnumerated( charIDToTypeID('Lyr '), charIDToTypeID('Ordn'), + charIDToTypeID('Trgt') ); + desc.putReference( charIDToTypeID('From'), lref); + executeAction( charIDToTypeID('Mk '), desc, DialogModes.NO ); + + var group = doc.activeLayer; + if (name){ + // Add special character to highlight group that will be published + group.name = name; + } + var layer = {}; + layer.id = group.id; + layer.name = name; // keep name clean + layer.group = true; + + layer.long_name = _get_parents_names(group, name); + + return JSON.stringify(layer); +}; + +function importSmartObject(path, name, link){ + /** + * Creates new layer with an image from 'path' + * + * path: absolute path to loaded file + * name: sets name of newly created laye + * + **/ + var desc1 = new ActionDescriptor(); + desc1.putPath( app.charIDToTypeID("null"), new File(path) ); + link = link || false; + if (link) { + desc1.putBoolean( app.charIDToTypeID('Lnkd'), true ); + } + + desc1.putEnumerated(app.charIDToTypeID("FTcs"), app.charIDToTypeID("QCSt"), + app.charIDToTypeID("Qcsa")); + var desc2 = new ActionDescriptor(); + desc2.putUnitDouble(app.charIDToTypeID("Hrzn"), + app.charIDToTypeID("#Pxl"), 0.0); + desc2.putUnitDouble(app.charIDToTypeID("Vrtc"), + app.charIDToTypeID("#Pxl"), 0.0); + + desc1.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), desc2); + executeAction(charIDToTypeID("Plc " ), desc1, DialogModes.NO); + + var docRef = app.activeDocument + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } + var layer = {} + layer.id = currentActivelayer.id; + layer.name = currentActivelayer.name; + return JSON.stringify(layer); +} + +function replaceSmartObjects(layer_id, path, name){ + /** + * Updates content of 'layer' with an image from 'path' + * + **/ + + var desc = new ActionDescriptor(); + var ref = new ActionReference(); + ref.putIdentifier(stringIDToTypeID("layer"), layer_id); + desc.putReference(stringIDToTypeID("null"), ref); + + desc.putPath(charIDToTypeID('null'), new File(path) ); + desc.putInteger(charIDToTypeID("PgNm"), 1); + + executeAction(stringIDToTypeID('placedLayerReplaceContents'), + desc, DialogModes.NO ); + var currentActivelayer = app.activeDocument.activeLayer; + if (name){ + currentActivelayer.name = name; + } +} + +function createGroup(name){ + /** + * Creates new group with a 'name' + * Because of asynchronous nature, only group.id is available + **/ + group = app.activeDocument.layerSets.add(); + // Add special character to highlight group that will be published + group.name = name; + + return group.id; // only id available at this time :| +} + +function deleteLayer(layer_id){ + /*** + * Deletes layer by its layer_id + * + * layer_id (int) + **/ + var d = new ActionDescriptor(); + var r = new ActionReference(); + + r.putIdentifier(stringIDToTypeID("layer"), layer_id); + d.putReference(stringIDToTypeID("null"), r); + executeAction(stringIDToTypeID("delete"), d, DialogModes.NO); +} + +function _undo() { + executeAction(charIDToTypeID("undo", undefined, DialogModes.NO)); +}; + +function savePSB(output_path){ + /*** + * Saves file as .psb to 'output_path' + * + * output_path (str) + **/ + var desc1 = new ActionDescriptor(); + var desc2 = new ActionDescriptor(); + desc2.putBoolean( stringIDToTypeID('maximizeCompatibility'), true ); + desc1.putObject( charIDToTypeID('As '), charIDToTypeID('Pht8'), desc2 ); + desc1.putPath( charIDToTypeID('In '), new File(output_path) ); + desc1.putBoolean( charIDToTypeID('LwCs'), true ); + executeAction( charIDToTypeID('save'), desc1, DialogModes.NO ); +} + +function close(){ + executeAction(stringIDToTypeID("quit"), undefined, DialogModes.NO ); +} + +function renameLayer(layer_id, new_name){ + /*** + * Renames 'layer_id' to 'new_name' + * + * Via Action (fast) + * + * Args: + * layer_id(int) + * new_name(str) + * + * output_path (str) + **/ + doc = app.activeDocument; + selectLayers('['+layer_id+']'); + + doc.activeLayer.name = new_name; +} + +function _get_parents_names(layer, itself_name){ + var long_names = [itself_name]; + while (layer.parent){ + if (layer.typename != "LayerSet"){ + break; + } + long_names.push(layer.name); + layer = layer.parent; + } + return long_names; +} + +// triggers when panel is opened, good for debugging +//log(getActiveDocumentName()); +// log.show(); +// var a = app.activeDocument.activeLayer; +// log(a); +//getSelectedLayers(); +// importSmartObject("c:/projects/test.jpg", "a aaNewLayer", true); +// log("dpc"); +// replaceSmartObjects(153, "▼Jungle_imageTest_001", "c:/projects/test_project_test_asset_TestTask_v001.png"); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/host/json.js b/openpype/hosts/photoshop/api/extension/host/json.js new file mode 100644 index 0000000000..397349bbfd --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/host/json.js @@ -0,0 +1,530 @@ +// json2.js +// 2017-06-12 +// Public Domain. +// NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. + +// USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO +// NOT CONTROL. + +// This file creates a global JSON object containing two methods: stringify +// and parse. This file provides the ES5 JSON capability to ES3 systems. +// If a project might run on IE8 or earlier, then this file should be included. +// This file does nothing on ES5 systems. + +// JSON.stringify(value, replacer, space) +// value any JavaScript value, usually an object or array. +// replacer an optional parameter that determines how object +// values are stringified for objects. It can be a +// function or an array of strings. +// space an optional parameter that specifies the indentation +// of nested structures. If it is omitted, the text will +// be packed without extra whitespace. If it is a number, +// it will specify the number of spaces to indent at each +// level. If it is a string (such as "\t" or " "), +// it contains the characters used to indent at each level. +// This method produces a JSON text from a JavaScript value. +// When an object value is found, if the object contains a toJSON +// method, its toJSON method will be called and the result will be +// stringified. A toJSON method does not serialize: it returns the +// value represented by the name/value pair that should be serialized, +// or undefined if nothing should be serialized. The toJSON method +// will be passed the key associated with the value, and this will be +// bound to the value. + +// For example, this would serialize Dates as ISO strings. + +// Date.prototype.toJSON = function (key) { +// function f(n) { +// // Format integers to have at least two digits. +// return (n < 10) +// ? "0" + n +// : n; +// } +// return this.getUTCFullYear() + "-" + +// f(this.getUTCMonth() + 1) + "-" + +// f(this.getUTCDate()) + "T" + +// f(this.getUTCHours()) + ":" + +// f(this.getUTCMinutes()) + ":" + +// f(this.getUTCSeconds()) + "Z"; +// }; + +// You can provide an optional replacer method. It will be passed the +// key and value of each member, with this bound to the containing +// object. The value that is returned from your method will be +// serialized. If your method returns undefined, then the member will +// be excluded from the serialization. + +// If the replacer parameter is an array of strings, then it will be +// used to select the members to be serialized. It filters the results +// such that only members with keys listed in the replacer array are +// stringified. + +// Values that do not have JSON representations, such as undefined or +// functions, will not be serialized. Such values in objects will be +// dropped; in arrays they will be replaced with null. You can use +// a replacer function to replace those with JSON values. + +// JSON.stringify(undefined) returns undefined. + +// The optional space parameter produces a stringification of the +// value that is filled with line breaks and indentation to make it +// easier to read. + +// If the space parameter is a non-empty string, then that string will +// be used for indentation. If the space parameter is a number, then +// the indentation will be that many spaces. + +// Example: + +// text = JSON.stringify(["e", {pluribus: "unum"}]); +// // text is '["e",{"pluribus":"unum"}]' + +// text = JSON.stringify(["e", {pluribus: "unum"}], null, "\t"); +// // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' + +// text = JSON.stringify([new Date()], function (key, value) { +// return this[key] instanceof Date +// ? "Date(" + this[key] + ")" +// : value; +// }); +// // text is '["Date(---current time---)"]' + +// JSON.parse(text, reviver) +// This method parses a JSON text to produce an object or array. +// It can throw a SyntaxError exception. + +// The optional reviver parameter is a function that can filter and +// transform the results. It receives each of the keys and values, +// and its return value is used instead of the original value. +// If it returns what it received, then the structure is not modified. +// If it returns undefined then the member is deleted. + +// Example: + +// // Parse the text. Values that look like ISO date strings will +// // be converted to Date objects. + +// myData = JSON.parse(text, function (key, value) { +// var a; +// if (typeof value === "string") { +// a = +// /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); +// if (a) { +// return new Date(Date.UTC( +// +a[1], +a[2] - 1, +a[3], +a[4], +a[5], +a[6] +// )); +// } +// return value; +// } +// }); + +// myData = JSON.parse( +// "[\"Date(09/09/2001)\"]", +// function (key, value) { +// var d; +// if ( +// typeof value === "string" +// && value.slice(0, 5) === "Date(" +// && value.slice(-1) === ")" +// ) { +// d = new Date(value.slice(5, -1)); +// if (d) { +// return d; +// } +// } +// return value; +// } +// ); + +// This is a reference implementation. You are free to copy, modify, or +// redistribute. + +/*jslint + eval, for, this +*/ + +/*property + JSON, apply, call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, + getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, + lastIndex, length, parse, prototype, push, replace, slice, stringify, + test, toJSON, toString, valueOf +*/ + + +// Create a JSON object only if one does not already exist. We create the +// methods in a closure to avoid creating global variables. + +if (typeof JSON !== "object") { + JSON = {}; +} + +(function () { + "use strict"; + + var rx_one = /^[\],:{}\s]*$/; + var rx_two = /\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g; + var rx_three = /"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g; + var rx_four = /(?:^|:|,)(?:\s*\[)+/g; + var rx_escapable = /[\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + var rx_dangerous = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g; + + function f(n) { + // Format integers to have at least two digits. + return (n < 10) + ? "0" + n + : n; + } + + function this_value() { + return this.valueOf(); + } + + if (typeof Date.prototype.toJSON !== "function") { + + Date.prototype.toJSON = function () { + + return isFinite(this.valueOf()) + ? ( + this.getUTCFullYear() + + "-" + + f(this.getUTCMonth() + 1) + + "-" + + f(this.getUTCDate()) + + "T" + + f(this.getUTCHours()) + + ":" + + f(this.getUTCMinutes()) + + ":" + + f(this.getUTCSeconds()) + + "Z" + ) + : null; + }; + + Boolean.prototype.toJSON = this_value; + Number.prototype.toJSON = this_value; + String.prototype.toJSON = this_value; + } + + var gap; + var indent; + var meta; + var rep; + + + function quote(string) { + +// If the string contains no control characters, no quote characters, and no +// backslash characters, then we can safely slap some quotes around it. +// Otherwise we must also replace the offending characters with safe escape +// sequences. + + rx_escapable.lastIndex = 0; + return rx_escapable.test(string) + ? "\"" + string.replace(rx_escapable, function (a) { + var c = meta[a]; + return typeof c === "string" + ? c + : "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4); + }) + "\"" + : "\"" + string + "\""; + } + + + function str(key, holder) { + +// Produce a string from holder[key]. + + var i; // The loop counter. + var k; // The member key. + var v; // The member value. + var length; + var mind = gap; + var partial; + var value = holder[key]; + +// If the value has a toJSON method, call it to obtain a replacement value. + + if ( + value + && typeof value === "object" + && typeof value.toJSON === "function" + ) { + value = value.toJSON(key); + } + +// If we were called with a replacer function, then call the replacer to +// obtain a replacement value. + + if (typeof rep === "function") { + value = rep.call(holder, key, value); + } + +// What happens next depends on the value's type. + + switch (typeof value) { + case "string": + return quote(value); + + case "number": + +// JSON numbers must be finite. Encode non-finite numbers as null. + + return (isFinite(value)) + ? String(value) + : "null"; + + case "boolean": + case "null": + +// If the value is a boolean or null, convert it to a string. Note: +// typeof null does not produce "null". The case is included here in +// the remote chance that this gets fixed someday. + + return String(value); + +// If the type is "object", we might be dealing with an object or an array or +// null. + + case "object": + +// Due to a specification blunder in ECMAScript, typeof null is "object", +// so watch out for that case. + + if (!value) { + return "null"; + } + +// Make an array to hold the partial results of stringifying this object value. + + gap += indent; + partial = []; + +// Is the value an array? + + if (Object.prototype.toString.apply(value) === "[object Array]") { + +// The value is an array. Stringify every element. Use null as a placeholder +// for non-JSON values. + + length = value.length; + for (i = 0; i < length; i += 1) { + partial[i] = str(i, value) || "null"; + } + +// Join all of the elements together, separated with commas, and wrap them in +// brackets. + + v = partial.length === 0 + ? "[]" + : gap + ? ( + "[\n" + + gap + + partial.join(",\n" + gap) + + "\n" + + mind + + "]" + ) + : "[" + partial.join(",") + "]"; + gap = mind; + return v; + } + +// If the replacer is an array, use it to select the members to be stringified. + + if (rep && typeof rep === "object") { + length = rep.length; + for (i = 0; i < length; i += 1) { + if (typeof rep[i] === "string") { + k = rep[i]; + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } else { + +// Otherwise, iterate through all of the keys in the object. + + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = str(k, value); + if (v) { + partial.push(quote(k) + ( + (gap) + ? ": " + : ":" + ) + v); + } + } + } + } + +// Join all of the member texts together, separated with commas, +// and wrap them in braces. + + v = partial.length === 0 + ? "{}" + : gap + ? "{\n" + gap + partial.join(",\n" + gap) + "\n" + mind + "}" + : "{" + partial.join(",") + "}"; + gap = mind; + return v; + } + } + +// If the JSON object does not yet have a stringify method, give it one. + + if (typeof JSON.stringify !== "function") { + meta = { // table of character substitutions + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\f": "\\f", + "\r": "\\r", + "\"": "\\\"", + "\\": "\\\\" + }; + JSON.stringify = function (value, replacer, space) { + +// The stringify method takes a value and an optional replacer, and an optional +// space parameter, and returns a JSON text. The replacer can be a function +// that can replace values, or an array of strings that will select the keys. +// A default replacer method can be provided. Use of the space parameter can +// produce text that is more easily readable. + + var i; + gap = ""; + indent = ""; + +// If the space parameter is a number, make an indent string containing that +// many spaces. + + if (typeof space === "number") { + for (i = 0; i < space; i += 1) { + indent += " "; + } + +// If the space parameter is a string, it will be used as the indent string. + + } else if (typeof space === "string") { + indent = space; + } + +// If there is a replacer, it must be a function or an array. +// Otherwise, throw an error. + + rep = replacer; + if (replacer && typeof replacer !== "function" && ( + typeof replacer !== "object" + || typeof replacer.length !== "number" + )) { + throw new Error("JSON.stringify"); + } + +// Make a fake root object containing our value under the key of "". +// Return the result of stringifying the value. + + return str("", {"": value}); + }; + } + + +// If the JSON object does not yet have a parse method, give it one. + + if (typeof JSON.parse !== "function") { + JSON.parse = function (text, reviver) { + +// The parse method takes a text and an optional reviver function, and returns +// a JavaScript value if the text is a valid JSON text. + + var j; + + function walk(holder, key) { + +// The walk method is used to recursively walk the resulting structure so +// that modifications can be made. + + var k; + var v; + var value = holder[key]; + if (value && typeof value === "object") { + for (k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + v = walk(value, k); + if (v !== undefined) { + value[k] = v; + } else { + delete value[k]; + } + } + } + } + return reviver.call(holder, key, value); + } + + +// Parsing happens in four stages. In the first stage, we replace certain +// Unicode characters with escape sequences. JavaScript handles many characters +// incorrectly, either silently deleting them, or treating them as line endings. + + text = String(text); + rx_dangerous.lastIndex = 0; + if (rx_dangerous.test(text)) { + text = text.replace(rx_dangerous, function (a) { + return ( + "\\u" + + ("0000" + a.charCodeAt(0).toString(16)).slice(-4) + ); + }); + } + +// In the second stage, we run the text against regular expressions that look +// for non-JSON patterns. We are especially concerned with "()" and "new" +// because they can cause invocation, and "=" because it can cause mutation. +// But just to be safe, we want to reject all unexpected forms. + +// We split the second stage into 4 regexp operations in order to work around +// crippling inefficiencies in IE's and Safari's regexp engines. First we +// replace the JSON backslash pairs with "@" (a non-JSON character). Second, we +// replace all simple value tokens with "]" characters. Third, we delete all +// open brackets that follow a colon or comma or that begin the text. Finally, +// we look to see that the remaining characters are only whitespace or "]" or +// "," or ":" or "{" or "}". If that is so, then the text is safe for eval. + + if ( + rx_one.test( + text + .replace(rx_two, "@") + .replace(rx_three, "]") + .replace(rx_four, "") + ) + ) { + +// In the third stage we use the eval function to compile the text into a +// JavaScript structure. The "{" operator is subject to a syntactic ambiguity +// in JavaScript: it can begin a block or an object literal. We wrap the text +// in parens to eliminate the ambiguity. + + j = eval("(" + text + ")"); + +// In the optional fourth stage, we recursively walk the new structure, passing +// each name/value pair to a reviver function for possible transformation. + + return (typeof reviver === "function") + ? walk({"": j}, "") + : j; + } + +// If the text is not JSON parseable, then a SyntaxError is thrown. + + throw new SyntaxError("JSON.parse"); + }; + } +}()); \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png b/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png new file mode 100644 index 0000000000..33fe2a606b Binary files /dev/null and b/openpype/hosts/photoshop/api/extension/icons/avalon-logo-48.png differ diff --git a/openpype/hosts/photoshop/api/extension/index.html b/openpype/hosts/photoshop/api/extension/index.html new file mode 100644 index 0000000000..501e753c0b --- /dev/null +++ b/openpype/hosts/photoshop/api/extension/index.html @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py new file mode 100644 index 0000000000..16a1d23244 --- /dev/null +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -0,0 +1,365 @@ +import os +import subprocess +import collections +import asyncio + +from wsrpc_aiohttp import ( + WebSocketRoute, + WebSocketAsync +) + +from Qt import QtCore + +from openpype.api import Logger +from openpype.tools.utils import host_tools + +from avalon import api +from avalon.tools.webserver.app import WebServerTool + +from .ws_stub import PhotoshopServerStub + +log = Logger.get_logger(__name__) + + +class ConnectionNotEstablishedYet(Exception): + pass + + +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + + def __init__(self, callback, *args, **kwargs): + self._done = False + self._exception = self.not_set + self._result = self.not_set + self._callback = callback + self._args = args + self._kwargs = kwargs + + @property + def done(self): + return self._done + + @property + def exception(self): + return self._exception + + @property + def result(self): + return self._result + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + log.debug("Executing process in main thread") + if self.done: + log.warning("- item is already processed") + return + + log.info("Running callback: {}".format(str(self._callback))) + try: + result = self._callback(*self._args, **self._kwargs) + self._result = result + + except Exception as exc: + self._exception = exc + + finally: + self._done = True + + +def stub(): + """ + Convenience function to get server RPC stub to call methods directed + for host (Photoshop). + It expects already created connection, started from client. + Currently created when panel is opened (PS: Window>Extensions>Avalon) + :return: where functions could be called from + """ + ps_stub = PhotoshopServerStub() + if not ps_stub.client: + raise ConnectionNotEstablishedYet("Connection is not created yet") + + return ps_stub + + +def show_tool_by_name(tool_name): + kwargs = {} + if tool_name == "loader": + kwargs["use_context"] = True + + host_tools.show_tool_by_name(tool_name, **kwargs) + + +class ProcessLauncher(QtCore.QObject): + route_name = "Photoshop" + _main_thread_callbacks = collections.deque() + + def __init__(self, subprocess_args): + self._subprocess_args = subprocess_args + self._log = None + + super(ProcessLauncher, self).__init__() + + # Keep track if launcher was already started + self._started = False + + self._process = None + self._websocket_server = None + + start_process_timer = QtCore.QTimer() + start_process_timer.setInterval(100) + + loop_timer = QtCore.QTimer() + loop_timer.setInterval(200) + + start_process_timer.timeout.connect(self._on_start_process_timer) + loop_timer.timeout.connect(self._on_loop_timer) + + self._start_process_timer = start_process_timer + self._loop_timer = loop_timer + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger( + "{}-launcher".format(self.route_name) + ) + return self._log + + @property + def websocket_server_is_running(self): + if self._websocket_server is not None: + return self._websocket_server.is_running + return False + + @property + def is_process_running(self): + if self._process is not None: + return self._process.poll() is None + return False + + @property + def is_host_connected(self): + """Returns True if connected, False if app is not running at all.""" + if not self.is_process_running: + return False + + try: + _stub = stub() + if _stub: + return True + except Exception: + pass + + return None + + @classmethod + def execute_in_main_thread(cls, callback, *args, **kwargs): + item = MainThreadItem(callback, *args, **kwargs) + cls._main_thread_callbacks.append(item) + return item + + def start(self): + if self._started: + return + self.log.info("Started launch logic of AfterEffects") + self._started = True + self._start_process_timer.start() + + def exit(self): + """ Exit whole application. """ + if self._start_process_timer.isActive(): + self._start_process_timer.stop() + if self._loop_timer.isActive(): + self._loop_timer.stop() + + if self._websocket_server is not None: + self._websocket_server.stop() + + if self._process: + self._process.kill() + self._process.wait() + + QtCore.QCoreApplication.exit() + + def _on_loop_timer(self): + # TODO find better way and catch errors + # Run only callbacks that are in queue at the moment + cls = self.__class__ + for _ in range(len(cls._main_thread_callbacks)): + if cls._main_thread_callbacks: + item = cls._main_thread_callbacks.popleft() + item.execute() + + if not self.is_process_running: + self.log.info("Host process is not running. Closing") + self.exit() + + elif not self.websocket_server_is_running: + self.log.info("Websocket server is not running. Closing") + self.exit() + + def _on_start_process_timer(self): + # TODO add try except validations for each part in this method + # Start server as first thing + if self._websocket_server is None: + self._init_server() + return + + # TODO add waiting time + # Wait for webserver + if not self.websocket_server_is_running: + return + + # Start application process + if self._process is None: + self._start_process() + self.log.info("Waiting for host to connect") + return + + # TODO add waiting time + # Wait until host is connected + if self.is_host_connected: + self._start_process_timer.stop() + self._loop_timer.start() + elif ( + not self.is_process_running + or not self.websocket_server_is_running + ): + self.exit() + + def _init_server(self): + if self._websocket_server is not None: + return + + self.log.debug( + "Initialization of websocket server for host communication" + ) + + self._websocket_server = websocket_server = WebServerTool() + if websocket_server.port_occupied( + websocket_server.host_name, + websocket_server.port + ): + self.log.info( + "Server already running, sending actual context and exit." + ) + asyncio.run(websocket_server.send_context_change(self.route_name)) + self.exit() + return + + # Add Websocket route + websocket_server.add_route("*", "/ws/", WebSocketAsync) + # Add after effects route to websocket handler + + print("Adding {} route".format(self.route_name)) + WebSocketAsync.add_route( + self.route_name, PhotoshopRoute + ) + self.log.info("Starting websocket server for host communication") + websocket_server.start_server() + + def _start_process(self): + if self._process is not None: + return + self.log.info("Starting host process") + try: + self._process = subprocess.Popen( + self._subprocess_args, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except Exception: + self.log.info("exce", exc_info=True) + self.exit() + + +class PhotoshopRoute(WebSocketRoute): + """ + One route, mimicking external application (like Harmony, etc). + All functions could be called from client. + 'do_notify' function calls function on the client - mimicking + notification after long running job on the server or similar + """ + instance = None + + def init(self, **kwargs): + # Python __init__ must be return "self". + # This method might return anything. + log.debug("someone called Photoshop route") + self.instance = self + return kwargs + + # server functions + async def ping(self): + log.debug("someone called Photoshop route ping") + + # This method calls function on the client side + # client functions + async def set_context(self, project, asset, task): + """ + Sets 'project' and 'asset' to envs, eg. setting context + + Args: + project (str) + asset (str) + """ + log.info("Setting context change") + log.info("project {} asset {} ".format(project, asset)) + if project: + api.Session["AVALON_PROJECT"] = project + os.environ["AVALON_PROJECT"] = project + if asset: + api.Session["AVALON_ASSET"] = asset + os.environ["AVALON_ASSET"] = asset + if task: + api.Session["AVALON_TASK"] = task + os.environ["AVALON_TASK"] = task + + async def read(self): + log.debug("photoshop.read client calls server server calls " + "photoshop client") + return await self.socket.call('photoshop.read') + + # panel routes for tools + async def creator_route(self): + self._tool_route("creator") + + async def workfiles_route(self): + self._tool_route("workfiles") + + async def loader_route(self): + self._tool_route("loader") + + async def publish_route(self): + self._tool_route("publish") + + async def sceneinventory_route(self): + self._tool_route("sceneinventory") + + async def subsetmanager_route(self): + self._tool_route("subsetmanager") + + async def experimental_tools_route(self): + self._tool_route("experimental_tools") + + def _tool_route(self, _tool_name): + """The address accessed when clicking on the buttons.""" + + ProcessLauncher.execute_in_main_thread(show_tool_by_name, _tool_name) + + # Required return statement. + return "nothing" diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py new file mode 100644 index 0000000000..707cd476c5 --- /dev/null +++ b/openpype/hosts/photoshop/api/lib.py @@ -0,0 +1,78 @@ +import os +import sys +import contextlib +import traceback + +from Qt import QtWidgets + +import avalon.api + +from openpype.api import Logger +from openpype.tools.utils import host_tools +from openpype.lib.remote_publish import headless_publish + +from .launch_logic import ProcessLauncher, stub + +log = Logger.get_logger(__name__) + + +def safe_excepthook(*args): + traceback.print_exception(*args) + + +def main(*subprocess_args): + from openpype.hosts.photoshop import api + + avalon.api.install(api) + sys.excepthook = safe_excepthook + + # coloring in ConsoleTrayApp + os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" + app = QtWidgets.QApplication([]) + app.setQuitOnLastWindowClosed(False) + + launcher = ProcessLauncher(subprocess_args) + launcher.start() + + if os.environ.get("HEADLESS_PUBLISH"): + launcher.execute_in_main_thread( + headless_publish, + log, + "ClosePS", + os.environ.get("IS_TEST") + ) + elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True): + save = False + if os.getenv("WORKFILES_SAVE_AS"): + save = True + + launcher.execute_in_main_thread( + host_tools.show_workfiles, save=save + ) + + sys.exit(app.exec_()) + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context.""" + selection = stub().get_selected_layers() + try: + yield selection + finally: + stub().select_layers(selection) + + +@contextlib.contextmanager +def maintained_visibility(): + """Maintain visibility during context.""" + visibility = {} + layers = stub().get_layers() + for layer in layers: + visibility[layer.id] = layer.visible + try: + yield + finally: + for layer in layers: + stub().set_visible(layer.id, visibility[layer.id]) + pass diff --git a/openpype/hosts/photoshop/api/panel.PNG b/openpype/hosts/photoshop/api/panel.PNG new file mode 100644 index 0000000000..be5db3b8df Binary files /dev/null and b/openpype/hosts/photoshop/api/panel.PNG differ diff --git a/openpype/hosts/photoshop/api/panel_failure.PNG b/openpype/hosts/photoshop/api/panel_failure.PNG new file mode 100644 index 0000000000..67afc4e212 Binary files /dev/null and b/openpype/hosts/photoshop/api/panel_failure.PNG differ diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py new file mode 100644 index 0000000000..25983f2471 --- /dev/null +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -0,0 +1,229 @@ +import os +import sys +from Qt import QtWidgets + +import pyblish.api +import avalon.api +from avalon import pipeline, io + +from openpype.api import Logger +import openpype.hosts.photoshop + +from . import lib + +log = Logger.get_logger(__name__) + +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.photoshop.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + + +def check_inventory(): + if not lib.any_outdated(): + return + + host = avalon.api.registered_host() + outdated_containers = [] + for container in host.ls(): + representation = container['representation'] + representation_doc = io.find_one( + { + "_id": io.ObjectId(representation), + "type": "representation" + }, + projection={"parent": True} + ) + if representation_doc and not lib.is_latest(representation_doc): + outdated_containers.append(container) + + # Warn about outdated containers. + print("Starting new QApplication..") + + message_box = QtWidgets.QMessageBox() + message_box.setIcon(QtWidgets.QMessageBox.Warning) + msg = "There are outdated containers in the scene." + message_box.setText(msg) + message_box.exec_() + + +def on_application_launch(): + check_inventory() + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle layer visibility on instance toggles.""" + instance[0].Visible = new_value + + +def install(): + """Install Photoshop-specific functionality of avalon-core. + + This function is called automatically on calling `api.install(photoshop)`. + """ + log.info("Installing OpenPype Photoshop...") + pyblish.api.register_host("photoshop") + + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + log.info(PUBLISH_PATH) + + pyblish.api.register_callback( + "instanceToggled", on_pyblish_instance_toggled + ) + + avalon.api.on("application.launched", on_application_launch) + + +def uninstall(): + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + + +def ls(): + """Yields containers from active Photoshop document + + This is the host-equivalent of api.ls(), but instead of listing + assets on disk, it lists assets already loaded in Photoshop; once loaded + they are called 'containers' + + Yields: + dict: container + + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + layers_meta = stub.get_layers_metadata() # minimalize calls to PS + for layer in stub.get_layers(): + data = stub.read(layer, layers_meta) + + # Skip non-tagged layers. + if not data: + continue + + # Filter to only containers. + if "container" not in data["id"]: + continue + + # Append transient data + data["objectName"] = layer.name.replace(stub.LOADED_ICON, '') + data["layer"] = layer + + yield data + + +def list_instances(): + """List all created instances to publish from current workfile. + + Pulls from File > File Info + + For SubsetManager + + Returns: + (list) of dictionaries matching instances format + """ + stub = _get_stub() + + if not stub: + return [] + + instances = [] + layers_meta = stub.get_layers_metadata() + if layers_meta: + for key, instance in layers_meta.items(): + schema = instance.get("schema") + if schema and "container" in schema: + continue + + instance['uuid'] = key + instances.append(instance) + + return instances + + +def remove_instance(instance): + """Remove instance from current workfile metadata. + + Updates metadata of current file in File > File Info and removes + icon highlight on group layer. + + For SubsetManager + + Args: + instance (dict): instance representation from subsetmanager model + """ + stub = _get_stub() + + if not stub: + return + + stub.remove_instance(instance.get("uuid")) + layer = stub.get_layer(instance.get("uuid")) + if layer: + stub.rename_layer(instance.get("uuid"), + layer.name.replace(stub.PUBLISH_ICON, '')) + + +def _get_stub(): + """Handle pulling stub from PS to run operations on host + + Returns: + (PhotoshopServerStub) or None + """ + try: + stub = lib.stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + return stub + + +def containerise( + name, namespace, layer, context, loader=None, suffix="_CON" +): + """Imprint layer with metadata + + Containerisation enables a tracking of version, author and origin + for loaded assets. + + Arguments: + name (str): Name of resulting assembly + namespace (str): Namespace under which to host container + layer (PSItem): Layer to containerise + context (dict): Asset information + loader (str, optional): Name of loader used to produce this container. + suffix (str, optional): Suffix of container, defaults to `_CON`. + + Returns: + container (str): Name of container assembly + """ + layer.name = name + suffix + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace, + "loader": str(loader), + "representation": str(context["representation"]["_id"]), + "members": [str(layer.id)] + } + stub = lib.stub() + stub.imprint(layer, data) + + return layer diff --git a/openpype/hosts/photoshop/api/plugin.py b/openpype/hosts/photoshop/api/plugin.py new file mode 100644 index 0000000000..e0db67de2c --- /dev/null +++ b/openpype/hosts/photoshop/api/plugin.py @@ -0,0 +1,69 @@ +import re + +import avalon.api +from .launch_logic import stub + + +def get_unique_layer_name(layers, asset_name, subset_name): + """ + Gets all layer names and if 'asset_name_subset_name' is present, it + increases suffix by 1 (eg. creates unique layer name - for Loader) + Args: + layers (list) of dict with layers info (name, id etc.) + asset_name (string): + subset_name (string): + + Returns: + (string): name_00X (without version) + """ + name = "{}_{}".format(asset_name, subset_name) + names = {} + for layer in layers: + layer_name = re.sub(r'_\d{3}$', '', layer.name) + if layer_name in names.keys(): + names[layer_name] = names[layer_name] + 1 + else: + names[layer_name] = 1 + occurrences = names.get(name, 0) + + return "{}_{:0>3d}".format(name, occurrences + 1) + + +class PhotoshopLoader(avalon.api.Loader): + @staticmethod + def get_stub(): + return stub() + + +class Creator(avalon.api.Creator): + """Creator plugin to create instances in Photoshop + + A LayerSet is created to support any number of layers in an instance. If + the selection is used, these layers will be added to the LayerSet. + """ + + def process(self): + # Photoshop can have multiple LayerSets with the same name, which does + # not work with Avalon. + msg = "Instance with name \"{}\" already exists.".format(self.name) + stub = lib.stub() # only after Photoshop is up + for layer in stub.get_layers(): + if self.name.lower() == layer.Name.lower(): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Warning) + msg.setText(msg) + msg.exec_() + return False + + # Store selection because adding a group will change selection. + with lib.maintained_selection(): + + # Add selection to group. + if (self.options or {}).get("useSelection"): + group = stub.group_selected_layers(self.name) + else: + group = stub.create_group(self.name) + + stub.imprint(group, self.data) + + return group diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py new file mode 100644 index 0000000000..0bf3ed2bd9 --- /dev/null +++ b/openpype/hosts/photoshop/api/workio.py @@ -0,0 +1,51 @@ +"""Host API required Work Files tool""" +import os + +import avalon.api + +from . import lib + + +def _active_document(): + document_name = lib.stub().get_active_document_name() + if not document_name: + return None + + return document_name + + +def file_extensions(): + return avalon.api.HOST_WORKFILE_EXTENSIONS["photoshop"] + + +def has_unsaved_changes(): + if _active_document(): + return not lib.stub().is_saved() + + return False + + +def save_file(filepath): + _, ext = os.path.splitext(filepath) + lib.stub().saveAs(filepath, ext[1:], True) + + +def open_file(filepath): + lib.stub().open(filepath) + + return True + + +def current_file(): + try: + full_name = lib.stub().get_active_document_full_name() + if full_name and full_name != "null": + return os.path.normpath(full_name).replace("\\", "/") + except Exception: + pass + + return None + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py new file mode 100644 index 0000000000..b8f66332c6 --- /dev/null +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -0,0 +1,495 @@ +""" + Stub handling connection from server to client. + Used anywhere solution is calling client methods. +""" +import sys +import json +import attr +from wsrpc_aiohttp import WebSocketAsync + +from avalon.tools.webserver.app import WebServerTool + + +@attr.s +class PSItem(object): + """ + Object denoting layer or group item in PS. Each item is created in + PS by any Loader, but contains same fields, which are being used + in later processing. + """ + # metadata + id = attr.ib() # id created by AE, could be used for querying + name = attr.ib() # name of item + group = attr.ib(default=None) # item type (footage, folder, comp) + parents = attr.ib(factory=list) + visible = attr.ib(default=True) + type = attr.ib(default=None) + # all imported elements, single for + members = attr.ib(factory=list) + long_name = attr.ib(default=None) + color_code = attr.ib(default=None) # color code of layer + + +class PhotoshopServerStub: + """ + Stub for calling function on client (Photoshop js) side. + Expects that client is already connected (started when avalon menu + is opened). + 'self.websocketserver.call' is used as async wrapper + """ + PUBLISH_ICON = '\u2117 ' + LOADED_ICON = '\u25bc' + + def __init__(self): + self.websocketserver = WebServerTool.get_instance() + self.client = self.get_client() + + @staticmethod + def get_client(): + """ + Return first connected client to WebSocket + TODO implement selection by Route + :return: client + """ + clients = WebSocketAsync.get_clients() + client = None + if len(clients) > 0: + key = list(clients.keys())[0] + client = clients.get(key) + + return client + + def open(self, path): + """Open file located at 'path' (local). + + Args: + path(string): file path locally + Returns: None + """ + self.websocketserver.call( + self.client.call('Photoshop.open', path=path) + ) + + def read(self, layer, layers_meta=None): + """Parses layer metadata from Headline field of active document. + + Args: + layer: (PSItem) + layers_meta: full list from Headline (for performance in loops) + Returns: + """ + if layers_meta is None: + layers_meta = self.get_layers_metadata() + + return layers_meta.get(str(layer.id)) + + def imprint(self, layer, data, all_layers=None, layers_meta=None): + """Save layer metadata to Headline field of active document + + Stores metadata in format: + [{ + "active":true, + "subset":"imageBG", + "family":"image", + "id":"pyblish.avalon.instance", + "asset":"Town", + "uuid": "8" + }] - for created instances + OR + [{ + "schema": "openpype:container-2.0", + "id": "pyblish.avalon.instance", + "name": "imageMG", + "namespace": "Jungle_imageMG_001", + "loader": "ImageLoader", + "representation": "5fbfc0ee30a946093c6ff18a", + "members": [ + "40" + ] + }] - for loaded instances + + Args: + layer (PSItem): + data(string): json representation for single layer + all_layers (list of PSItem): for performance, could be + injected for usage in loop, if not, single call will be + triggered + layers_meta(string): json representation from Headline + (for performance - provide only if imprint is in + loop - value should be same) + Returns: None + """ + if not layers_meta: + layers_meta = self.get_layers_metadata() + + # json.dumps writes integer values in a dictionary to string, so + # anticipating it here. + if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: + if data: + layers_meta[str(layer.id)].update(data) + else: + layers_meta.pop(str(layer.id)) + else: + layers_meta[str(layer.id)] = data + + # Ensure only valid ids are stored. + if not all_layers: + all_layers = self.get_layers() + layer_ids = [layer.id for layer in all_layers] + cleaned_data = [] + + for layer_id in layers_meta: + if int(layer_id) in layer_ids: + cleaned_data.append(layers_meta[layer_id]) + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) + + def get_layers(self): + """Returns JSON document with all(?) layers in active document. + + Returns: + Format of tuple: { 'id':'123', + 'name': 'My Layer 1', + 'type': 'GUIDE'|'FG'|'BG'|'OBJ' + 'visible': 'true'|'false' + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_layers') + ) + + return self._to_records(res) + + def get_layer(self, layer_id): + """ + Returns PSItem for specific 'layer_id' or None if not found + Args: + layer_id (string): unique layer id, stored in 'uuid' field + + Returns: + (PSItem) or None + """ + layers = self.get_layers() + for layer in layers: + if str(layer.id) == str(layer_id): + return layer + + def get_layers_in_layers(self, layers): + """Return all layers that belong to layers (might be groups). + + Args: + layers : + + Returns: + + """ + all_layers = self.get_layers() + ret = [] + parent_ids = set([lay.id for lay in layers]) + + for layer in all_layers: + parents = set(layer.parents) + if len(parent_ids & parents) > 0: + ret.append(layer) + if layer.id in parent_ids: + ret.append(layer) + + return ret + + def create_group(self, name): + """Create new group (eg. LayerSet) + + Returns: + + """ + enhanced_name = self.PUBLISH_ICON + name + ret = self.websocketserver.call( + self.client.call('Photoshop.create_group', name=enhanced_name) + ) + # create group on PS is asynchronous, returns only id + return PSItem(id=ret, name=name, group=True) + + def group_selected_layers(self, name): + """Group selected layers into new LayerSet (eg. group) + + Returns: + (Layer) + """ + enhanced_name = self.PUBLISH_ICON + name + res = self.websocketserver.call( + self.client.call( + 'Photoshop.group_selected_layers', name=enhanced_name + ) + ) + res = self._to_records(res) + if res: + rec = res.pop() + rec.name = rec.name.replace(self.PUBLISH_ICON, '') + return rec + raise ValueError("No group record returned") + + def get_selected_layers(self): + """Get a list of actually selected layers. + + Returns: + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_selected_layers') + ) + return self._to_records(res) + + def select_layers(self, layers): + """Selects specified layers in Photoshop by its ids. + + Args: + layers: + """ + layers_id = [str(lay.id) for lay in layers] + self.websocketserver.call( + self.client.call( + 'Photoshop.select_layers', + layers=json.dumps(layers_id) + ) + ) + + def get_active_document_full_name(self): + """Returns full name with path of active document via ws call + + Returns(string): + full path with name + """ + res = self.websocketserver.call( + self.client.call('Photoshop.get_active_document_full_name') + ) + + return res + + def get_active_document_name(self): + """Returns just a name of active document via ws call + + Returns(string): + file name + """ + return self.websocketserver.call( + self.client.call('Photoshop.get_active_document_name') + ) + + def is_saved(self): + """Returns true if no changes in active document + + Returns: + + """ + return self.websocketserver.call( + self.client.call('Photoshop.is_saved') + ) + + def save(self): + """Saves active document""" + self.websocketserver.call( + self.client.call('Photoshop.save') + ) + + def saveAs(self, image_path, ext, as_copy): + """Saves active document to psd (copy) or png or jpg + + Args: + image_path(string): full local path + ext: + as_copy: + Returns: None + """ + self.websocketserver.call( + self.client.call( + 'Photoshop.saveAs', + image_path=image_path, + ext=ext, + as_copy=as_copy + ) + ) + + def set_visible(self, layer_id, visibility): + """Set layer with 'layer_id' to 'visibility' + + Args: + layer_id: + visibility: + Returns: None + """ + self.websocketserver.call( + self.client.call( + 'Photoshop.set_visible', + layer_id=layer_id, + visibility=visibility + ) + ) + + def get_layers_metadata(self): + """Reads layers metadata from Headline from active document in PS. + (Headline accessible by File > File Info) + + Returns: + (string): - json documents + example: + {"8":{"active":true,"subset":"imageBG", + "family":"image","id":"pyblish.avalon.instance", + "asset":"Town"}} + 8 is layer(group) id - used for deletion, update etc. + """ + layers_data = {} + res = self.websocketserver.call(self.client.call('Photoshop.read')) + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + pass + # format of metadata changed from {} to [] because of standardization + # keep current implementation logic as its working + if not isinstance(layers_data, dict): + temp_layers_meta = {} + for layer_meta in layers_data: + layer_id = layer_meta.get("uuid") + if not layer_id: + layer_id = layer_meta.get("members")[0] + + temp_layers_meta[layer_id] = layer_meta + layers_data = temp_layers_meta + else: + # legacy version of metadata + for layer_id, layer_meta in layers_data.items(): + if layer_meta.get("schema") != "openpype:container-2.0": + layer_meta["uuid"] = str(layer_id) + else: + layer_meta["members"] = [str(layer_id)] + + return layers_data + + def import_smart_object(self, path, layer_name, as_reference=False): + """Import the file at `path` as a smart object to active document. + + Args: + path (str): File path to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + as_reference (bool): pull in content or reference + """ + enhanced_name = self.LOADED_ICON + layer_name + res = self.websocketserver.call( + self.client.call( + 'Photoshop.import_smart_object', + path=path, + name=enhanced_name, + as_reference=as_reference + ) + ) + rec = self._to_records(res).pop() + if rec: + rec.name = rec.name.replace(self.LOADED_ICON, '') + return rec + + def replace_smart_object(self, layer, path, layer_name): + """Replace the smart object `layer` with file at `path` + + Args: + layer (PSItem): + path (str): File to import. + layer_name (str): Unique layer name to differentiate how many times + same smart object was loaded + """ + enhanced_name = self.LOADED_ICON + layer_name + self.websocketserver.call( + self.client.call( + 'Photoshop.replace_smart_object', + layer_id=layer.id, + path=path, + name=enhanced_name + ) + ) + + def delete_layer(self, layer_id): + """Deletes specific layer by it's id. + + Args: + layer_id (int): id of layer to delete + """ + self.websocketserver.call( + self.client.call('Photoshop.delete_layer', layer_id=layer_id) + ) + + def rename_layer(self, layer_id, name): + """Renames specific layer by it's id. + + Args: + layer_id (int): id of layer to delete + name (str): new name + """ + self.websocketserver.call( + self.client.call( + 'Photoshop.rename_layer', + layer_id=layer_id, + name=name + ) + ) + + def remove_instance(self, instance_id): + cleaned_data = {} + + for key, instance in self.get_layers_metadata().items(): + if key != instance_id: + cleaned_data[key] = instance + + payload = json.dumps(cleaned_data, indent=4) + + self.websocketserver.call( + self.client.call('Photoshop.imprint', payload=payload) + ) + + def get_extension_version(self): + """Returns version number of installed extension.""" + return self.websocketserver.call( + self.client.call('Photoshop.get_extension_version') + ) + + def close(self): + """Shutting down PS and process too. + + For webpublishing only. + """ + # TODO change client.call to method with checks for client + self.websocketserver.call(self.client.call('Photoshop.close')) + + def _to_records(self, res): + """Converts string json representation into list of PSItem for + dot notation access to work. + + Args: + res (string): valid json + + Returns: + + """ + try: + layers_data = json.loads(res) + except json.decoder.JSONDecodeError: + raise ValueError("Received broken JSON {}".format(res)) + ret = [] + + # convert to AEItem to use dot donation + if isinstance(layers_data, dict): + layers_data = [layers_data] + for d in layers_data: + # currently implemented and expected fields + ret.append(PSItem( + d.get('id'), + d.get('name'), + d.get('group'), + d.get('parents'), + d.get('visible'), + d.get('type'), + d.get('members'), + d.get('long_name'), + d.get("color_code") + )) + return ret diff --git a/openpype/hosts/photoshop/plugins/__init__.py b/openpype/hosts/photoshop/plugins/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 657d41aa93..cf41bb4020 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,6 +1,6 @@ from Qt import QtWidgets import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CreateImage(openpype.api.Creator): diff --git a/openpype/hosts/photoshop/plugins/lib.py b/openpype/hosts/photoshop/plugins/lib.py deleted file mode 100644 index 74aff06114..0000000000 --- a/openpype/hosts/photoshop/plugins/lib.py +++ /dev/null @@ -1,26 +0,0 @@ -import re - - -def get_unique_layer_name(layers, asset_name, subset_name): - """ - Gets all layer names and if 'asset_name_subset_name' is present, it - increases suffix by 1 (eg. creates unique layer name - for Loader) - Args: - layers (list) of dict with layers info (name, id etc.) - asset_name (string): - subset_name (string): - - Returns: - (string): name_00X (without version) - """ - name = "{}_{}".format(asset_name, subset_name) - names = {} - for layer in layers: - layer_name = re.sub(r'_\d{3}$', '', layer.name) - if layer_name in names.keys(): - names[layer_name] = names[layer_name] + 1 - else: - names[layer_name] = 1 - occurrences = names.get(name, 0) - - return "{}_{:0>3d}".format(name, occurrences + 1) diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 981a1ed204..3b1cfe9636 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -1,12 +1,11 @@ import re -from avalon import api, photoshop +from avalon import api +from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name -stub = photoshop.stub() - -class ImageLoader(api.Loader): +class ImageLoader(photoshop.PhotoshopLoader): """Load images Stores the imported asset in a container named after the asset. @@ -16,11 +15,14 @@ class ImageLoader(api.Loader): representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), + context["asset"]["name"], + name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -35,6 +37,8 @@ class ImageLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() + layer = container.pop("layer") context = representation.get("context", {}) @@ -44,9 +48,9 @@ class ImageLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -66,6 +70,8 @@ class ImageLoader(api.Loader): Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() + layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -73,5 +79,5 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): + def import_layer(self, file_name, layer_name, stub): return stub.import_smart_object(file_name, layer_name) diff --git a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py index 8704627b12..ab4682e63e 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py +++ b/openpype/hosts/photoshop/plugins/load/load_image_from_sequence.py @@ -1,17 +1,13 @@ import os -from avalon import api -from avalon import photoshop from avalon.pipeline import get_representation_path_from_context from avalon.vendor import qargparse -from openpype.lib import Anatomy -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name - -stub = photoshop.stub() +from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name -class ImageFromSequenceLoader(api.Loader): +class ImageFromSequenceLoader(photoshop.PhotoshopLoader): """ Load specifing image from sequence Used only as quick load of reference file from a sequence. @@ -35,15 +31,16 @@ class ImageFromSequenceLoader(api.Loader): def load(self, context, name=None, namespace=None, data=None): if data.get("frame"): - self.fname = os.path.join(os.path.dirname(self.fname), - data["frame"]) + self.fname = os.path.join( + os.path.dirname(self.fname), data["frame"] + ) if not os.path.exists(self.fname): return - stub = photoshop.stub() - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): layer = stub.import_smart_object(self.fname, layer_name) @@ -95,4 +92,3 @@ class ImageFromSequenceLoader(api.Loader): def remove(self, container): """No update possible, not containerized.""" pass - diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index 0cb4e4a69f..60142d4a1f 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -1,30 +1,30 @@ import re -from avalon import api, photoshop +from avalon import api -from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name - -stub = photoshop.stub() +from openpype.hosts.photoshop import api as photoshop +from openpype.hosts.photoshop.api import get_unique_layer_name -class ReferenceLoader(api.Loader): +class ReferenceLoader(photoshop.PhotoshopLoader): """Load reference images - Stores the imported asset in a container named after the asset. + Stores the imported asset in a container named after the asset. - Inheriting from 'load_image' didn't work because of - "Cannot write to closing transport", possible refactor. + Inheriting from 'load_image' didn't work because of + "Cannot write to closing transport", possible refactor. """ families = ["image", "render"] representations = ["*"] def load(self, context, name=None, namespace=None, data=None): - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"]["name"], - name) + stub = self.get_stub() + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"]["name"], name + ) with photoshop.maintained_selection(): - layer = self.import_layer(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name, stub) self[:] = [layer] namespace = namespace or layer_name @@ -39,6 +39,7 @@ class ReferenceLoader(api.Loader): def update(self, container, representation): """ Switch asset or change version """ + stub = self.get_stub() layer = container.pop("layer") context = representation.get("context", {}) @@ -48,9 +49,9 @@ class ReferenceLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = get_unique_layer_name(stub.get_layers(), - context["asset"], - context["subset"]) + layer_name = get_unique_layer_name( + stub.get_layers(), context["asset"], context["subset"] + ) else: # switching version - keep same name layer_name = container["namespace"] @@ -65,11 +66,12 @@ class ReferenceLoader(api.Loader): ) def remove(self, container): - """ - Removes element from scene: deletes layer + removes from Headline + """Removes element from scene: deletes layer + removes from Headline + Args: container (dict): container to be removed - used to get layer_id """ + stub = self.get_stub() layer = container.pop("layer") stub.imprint(layer, {}) stub.delete_layer(layer.id) @@ -77,6 +79,7 @@ class ReferenceLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) - def import_layer(self, file_name, layer_name): - return stub.import_smart_object(file_name, layer_name, - as_reference=True) + def import_layer(self, file_name, layer_name, stub): + return stub.import_smart_object( + file_name, layer_name, as_reference=True + ) diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py index 2f0eab0ee5..b4ded96001 100644 --- a/openpype/hosts/photoshop/plugins/publish/closePS.py +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -4,7 +4,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ClosePS(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py index 4d4829555e..5daf47c6ac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_current_file.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_current_file.py @@ -2,7 +2,7 @@ import os import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectCurrentFile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py index f07ff0b0ff..64c99b4fc1 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_extension_version.py @@ -2,7 +2,7 @@ import os import re import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectExtensionVersion(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 5390df768b..f67cc0cbac 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,6 +1,6 @@ import pyblish.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class CollectInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py index c76e15484e..e264d04d9f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -1,10 +1,11 @@ -import pyblish.api import os import re -from avalon import photoshop +import pyblish.api + from openpype.lib import prepare_template_data from openpype.lib.plugin_tools import parse_json +from openpype.hosts.photoshop import api as photoshop class CollectRemoteInstances(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 88817c3969..db1ede14d5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,5 +1,5 @@ -import pyblish.api import os +import pyblish.api class CollectWorkfile(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index ae9892e290..2ba81e0bac 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -1,7 +1,7 @@ import os import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractImage(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 8c4d05b282..1ad442279a 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -2,7 +2,7 @@ import os import openpype.api import openpype.lib -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractReview(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py index 0180640c90..03086f389f 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_save_scene.py @@ -1,5 +1,5 @@ import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ExtractSaveScene(openpype.api.Extractor): diff --git a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py index 709fb988fc..92132c393b 100644 --- a/openpype/hosts/photoshop/plugins/publish/increment_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/increment_workfile.py @@ -3,7 +3,7 @@ import pyblish.api from openpype.action import get_errored_plugins_from_data from openpype.lib import version_up -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class IncrementWorkfile(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py index 4dc1972074..ebe9cc21ea 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_instance_asset.py @@ -1,7 +1,7 @@ from avalon import api import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateInstanceAssetRepair(pyblish.api.Action): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index 1635096f4b..b40e44d016 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -2,7 +2,7 @@ import re import pyblish.api import openpype.api -from avalon import photoshop +from openpype.hosts.photoshop import api as photoshop class ValidateNamingRepair(pyblish.api.Action): diff --git a/openpype/hosts/tvpaint/__init__.py b/openpype/hosts/tvpaint/__init__.py index 0e793fcf9f..09b7c52cd1 100644 --- a/openpype/hosts/tvpaint/__init__.py +++ b/openpype/hosts/tvpaint/__init__.py @@ -1,3 +1,6 @@ +import os + + def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" defaults = { @@ -6,3 +9,12 @@ def add_implementation_envs(env, _app): for key, value in defaults.items(): if not env.get(key): env[key] = value + + +def get_launch_script_path(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + return os.path.join( + current_dir, + "api", + "launch_script.py" + ) diff --git a/openpype/hosts/tvpaint/api/__init__.py b/openpype/hosts/tvpaint/api/__init__.py index 1c50987d6d..c461b33f4b 100644 --- a/openpype/hosts/tvpaint/api/__init__.py +++ b/openpype/hosts/tvpaint/api/__init__.py @@ -1,93 +1,49 @@ -import os -import logging +from .communication_server import CommunicationWrapper +from . import lib +from . import launch_script +from . import workio +from . import pipeline +from . import plugin +from .pipeline import ( + install, + uninstall, + maintained_selection, + remove_instance, + list_instances, + ls +) -import requests - -import avalon.api -import pyblish.api -from avalon.tvpaint import pipeline -from avalon.tvpaint.communication_server import register_localization_file -from .lib import set_context_settings - -from openpype.hosts import tvpaint -from openpype.api import get_current_project_settings - -log = logging.getLogger(__name__) - -HOST_DIR = os.path.dirname(os.path.abspath(tvpaint.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +from .workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root, +) -def on_instance_toggle(instance, old_value, new_value): - # Review may not have real instance in wokrfile metadata - if not instance.data.get("uuid"): - return +__all__ = ( + "CommunicationWrapper", - instance_id = instance.data["uuid"] - found_idx = None - current_instances = pipeline.list_instances() - for idx, workfile_instance in enumerate(current_instances): - if workfile_instance["uuid"] == instance_id: - found_idx = idx - break + "lib", + "launch_script", + "workio", + "pipeline", + "plugin", - if found_idx is None: - return + "install", + "uninstall", + "maintained_selection", + "remove_instance", + "list_instances", + "ls", - if "active" in current_instances[found_idx]: - current_instances[found_idx]["active"] = new_value - pipeline._write_instances(current_instances) - - -def initial_launch(): - # Setup project settings if its the template that's launched. - # TODO also check for template creation when it's possible to define - # templates - last_workfile = os.environ.get("AVALON_LAST_WORKFILE") - if not last_workfile or os.path.exists(last_workfile): - return - - log.info("Setting up project...") - set_context_settings() - - -def application_exit(): - data = get_current_project_settings() - stop_timer = data["tvpaint"]["stop_timer_on_application_exit"] - - if not stop_timer: - return - - # Stop application timer. - webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") - rest_api_url = "{}/timers_manager/stop_timer".format(webserver_url) - requests.post(rest_api_url) - - -def install(): - log.info("OpenPype - Installing TVPaint integration") - localization_file = os.path.join(HOST_DIR, "resources", "avalon.loc") - register_localization_file(localization_file) - - pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) - - registered_callbacks = ( - pyblish.api.registered_callbacks().get("instanceToggled") or [] - ) - if on_instance_toggle not in registered_callbacks: - pyblish.api.register_callback("instanceToggled", on_instance_toggle) - - avalon.api.on("application.launched", initial_launch) - avalon.api.on("application.exit", application_exit) - - -def uninstall(): - log.info("OpenPype - Uninstalling TVPaint integration") - pyblish.api.deregister_plugin_path(PUBLISH_PATH) - avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + # Workfiles API + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +) diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py new file mode 100644 index 0000000000..6c8aca5445 --- /dev/null +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -0,0 +1,939 @@ +import os +import json +import time +import subprocess +import collections +import asyncio +import logging +import socket +import platform +import filecmp +import tempfile +import threading +import shutil +from queue import Queue +from contextlib import closing + +from aiohttp import web +from aiohttp_json_rpc import JsonRpc +from aiohttp_json_rpc.protocol import ( + encode_request, encode_error, decode_msg, JsonRpcMsgTyp +) +from aiohttp_json_rpc.exceptions import RpcError + +from avalon import api +from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +class CommunicationWrapper: + # TODO add logs and exceptions + communicator = None + + log = logging.getLogger("CommunicationWrapper") + + @classmethod + def create_qt_communicator(cls, *args, **kwargs): + """Create communicator for Artist usage.""" + communicator = QtCommunicator(*args, **kwargs) + cls.set_communicator(communicator) + return communicator + + @classmethod + def set_communicator(cls, communicator): + if not cls.communicator: + cls.communicator = communicator + else: + cls.log.warning("Communicator was set multiple times.") + + @classmethod + def client(cls): + if not cls.communicator: + return None + return cls.communicator.client() + + @classmethod + def execute_george(cls, george_script): + """Execute passed goerge script in TVPaint.""" + if not cls.communicator: + return + return cls.communicator.execute_george(george_script) + + +class WebSocketServer: + def __init__(self): + self.client = None + + self.loop = asyncio.new_event_loop() + self.app = web.Application(loop=self.loop) + self.port = self.find_free_port() + self.websocket_thread = WebsocketServerThread( + self, self.port, loop=self.loop + ) + + @property + def server_is_running(self): + return self.websocket_thread.server_is_running + + def add_route(self, *args, **kwargs): + self.app.router.add_route(*args, **kwargs) + + @staticmethod + def find_free_port(): + with closing( + socket.socket(socket.AF_INET, socket.SOCK_STREAM) + ) as sock: + sock.bind(("", 0)) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + port = sock.getsockname()[1] + return port + + def start(self): + self.websocket_thread.start() + + def stop(self): + try: + if self.websocket_thread.is_running: + log.debug("Stopping websocket server") + self.websocket_thread.is_running = False + self.websocket_thread.stop() + except Exception: + log.warning( + "Error has happened during Killing websocket server", + exc_info=True + ) + + +class WebsocketServerThread(threading.Thread): + """ Listener for websocket rpc requests. + + It would be probably better to "attach" this to main thread (as for + example Harmony needs to run something on main thread), but currently + it creates separate thread and separate asyncio event loop + """ + def __init__(self, module, port, loop): + super(WebsocketServerThread, self).__init__() + self.is_running = False + self.server_is_running = False + self.port = port + self.module = module + self.loop = loop + self.runner = None + self.site = None + self.tasks = [] + + def run(self): + self.is_running = True + + try: + log.debug("Starting websocket server") + + self.loop.run_until_complete(self.start_server()) + + log.info( + "Running Websocket server on URL:" + " \"ws://localhost:{}\"".format(self.port) + ) + + asyncio.ensure_future(self.check_shutdown(), loop=self.loop) + + self.server_is_running = True + self.loop.run_forever() + + except Exception: + log.warning( + "Websocket Server service has failed", exc_info=True + ) + finally: + self.server_is_running = False + # optional + self.loop.close() + + self.is_running = False + log.info("Websocket server stopped") + + async def start_server(self): + """ Starts runner and TCPsite """ + self.runner = web.AppRunner(self.module.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, "localhost", self.port) + await self.site.start() + + def stop(self): + """Sets is_running flag to false, 'check_shutdown' shuts server down""" + self.is_running = False + + async def check_shutdown(self): + """ Future that is running and checks if server should be running + periodically. + """ + while self.is_running: + while self.tasks: + task = self.tasks.pop(0) + log.debug("waiting for task {}".format(task)) + await task + log.debug("returned value {}".format(task.result)) + + await asyncio.sleep(0.5) + + log.debug("## Server shutdown started") + + await self.site.stop() + log.debug("# Site stopped") + await self.runner.cleanup() + log.debug("# Server runner stopped") + tasks = [ + task for task in asyncio.all_tasks() + if task is not asyncio.current_task() + ] + list(map(lambda task: task.cancel(), tasks)) # cancel all the tasks + results = await asyncio.gather(*tasks, return_exceptions=True) + log.debug(f"Finished awaiting cancelled tasks, results: {results}...") + await self.loop.shutdown_asyncgens() + # to really make sure everything else has time to stop + await asyncio.sleep(0.07) + self.loop.stop() + + +class BaseTVPaintRpc(JsonRpc): + def __init__(self, communication_obj, route_name="", **kwargs): + super().__init__(**kwargs) + self.requests_ids = collections.defaultdict(lambda: 0) + self.waiting_requests = collections.defaultdict(list) + self.responses = collections.defaultdict(list) + + self.route_name = route_name + self.communication_obj = communication_obj + + async def _handle_rpc_msg(self, http_request, raw_msg): + # This is duplicated code from super but there is no way how to do it + # to be able handle server->client requests + host = http_request.host + if host in self.waiting_requests: + try: + _raw_message = raw_msg.data + msg = decode_msg(_raw_message) + + except RpcError as error: + await self._ws_send_str(http_request, encode_error(error)) + return + + if msg.type in (JsonRpcMsgTyp.RESULT, JsonRpcMsgTyp.ERROR): + msg_data = json.loads(_raw_message) + if msg_data.get("id") in self.waiting_requests[host]: + self.responses[host].append(msg_data) + return + + return await super()._handle_rpc_msg(http_request, raw_msg) + + def client_connected(self): + # TODO This is poor check. Add check it is client from TVPaint + if self.clients: + return True + return False + + def send_notification(self, client, method, params=None): + if params is None: + params = [] + asyncio.run_coroutine_threadsafe( + client.ws.send_str(encode_request(method, params=params)), + loop=self.loop + ) + + def send_request(self, client, method, params=None, timeout=0): + if params is None: + params = [] + + client_host = client.host + + request_id = self.requests_ids[client_host] + self.requests_ids[client_host] += 1 + + self.waiting_requests[client_host].append(request_id) + + log.debug("Sending request to client {} ({}, {}) id: {}".format( + client_host, method, params, request_id + )) + future = asyncio.run_coroutine_threadsafe( + client.ws.send_str(encode_request(method, request_id, params)), + loop=self.loop + ) + result = future.result() + + not_found = object() + response = not_found + start = time.time() + while True: + if client.ws.closed: + return None + + for _response in self.responses[client_host]: + _id = _response.get("id") + if _id == request_id: + response = _response + break + + if response is not not_found: + break + + if timeout > 0 and (time.time() - start) > timeout: + raise Exception("Timeout passed") + return + + time.sleep(0.1) + + if response is not_found: + raise Exception("Connection closed") + + self.responses[client_host].remove(response) + + error = response.get("error") + result = response.get("result") + if error: + raise Exception("Error happened: {}".format(error)) + return result + + +class QtTVPaintRpc(BaseTVPaintRpc): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + from openpype.tools.utils import host_tools + self.tools_helper = host_tools.HostToolsHelper() + + route_name = self.route_name + + # Register methods + self.add_methods( + (route_name, self.workfiles_tool), + (route_name, self.loader_tool), + (route_name, self.creator_tool), + (route_name, self.subset_manager_tool), + (route_name, self.publish_tool), + (route_name, self.scene_inventory_tool), + (route_name, self.library_loader_tool), + (route_name, self.experimental_tools) + ) + + # Panel routes for tools + async def workfiles_tool(self): + log.info("Triggering Workfile tool") + item = MainThreadItem(self.tools_helper.show_workfiles) + self._execute_in_main_thread(item) + return + + async def loader_tool(self): + log.info("Triggering Loader tool") + item = MainThreadItem(self.tools_helper.show_loader) + self._execute_in_main_thread(item) + return + + async def creator_tool(self): + log.info("Triggering Creator tool") + item = MainThreadItem(self.tools_helper.show_creator) + await self._async_execute_in_main_thread(item, wait=False) + + async def subset_manager_tool(self): + log.info("Triggering Subset Manager tool") + item = MainThreadItem(self.tools_helper.show_subset_manager) + # Do not wait for result of callback + self._execute_in_main_thread(item, wait=False) + return + + async def publish_tool(self): + log.info("Triggering Publish tool") + item = MainThreadItem(self.tools_helper.show_publish) + self._execute_in_main_thread(item) + return + + async def scene_inventory_tool(self): + """Open Scene Inventory tool. + + Funciton can't confirm if tool was opened becauise one part of + SceneInventory initialization is calling websocket request to host but + host can't response because is waiting for response from this call. + """ + log.info("Triggering Scene inventory tool") + item = MainThreadItem(self.tools_helper.show_scene_inventory) + # Do not wait for result of callback + self._execute_in_main_thread(item, wait=False) + return + + async def library_loader_tool(self): + log.info("Triggering Library loader tool") + item = MainThreadItem(self.tools_helper.show_library_loader) + self._execute_in_main_thread(item) + return + + async def experimental_tools(self): + log.info("Triggering Library loader tool") + item = MainThreadItem(self.tools_helper.show_experimental_tools_dialog) + self._execute_in_main_thread(item) + return + + async def _async_execute_in_main_thread(self, item, **kwargs): + await self.communication_obj.async_execute_in_main_thread( + item, **kwargs + ) + + def _execute_in_main_thread(self, item, **kwargs): + return self.communication_obj.execute_in_main_thread(item, **kwargs) + + +class MainThreadItem: + """Structure to store information about callback in main thread. + + Item should be used to execute callback in main thread which may be needed + for execution of Qt objects. + + Item store callback (callable variable), arguments and keyword arguments + for the callback. Item hold information about it's process. + """ + not_set = object() + sleep_time = 0.1 + + def __init__(self, callback, *args, **kwargs): + self.done = False + self.exception = self.not_set + self.result = self.not_set + self.callback = callback + self.args = args + self.kwargs = kwargs + + def execute(self): + """Execute callback and store it's result. + + Method must be called from main thread. Item is marked as `done` + when callback execution finished. Store output of callback of exception + information when callback raise one. + """ + log.debug("Executing process in main thread") + if self.done: + log.warning("- item is already processed") + return + + callback = self.callback + args = self.args + kwargs = self.kwargs + log.info("Running callback: {}".format(str(callback))) + try: + result = callback(*args, **kwargs) + self.result = result + + except Exception as exc: + self.exception = exc + + finally: + self.done = True + + def wait(self): + """Wait for result from main thread. + + This method stops current thread until callback is executed. + + Returns: + object: Output of callback. May be any type or object. + + Raises: + Exception: Reraise any exception that happened during callback + execution. + """ + while not self.done: + time.sleep(self.sleep_time) + + if self.exception is self.not_set: + return self.result + raise self.exception + + async def async_wait(self): + """Wait for result from main thread. + + Returns: + object: Output of callback. May be any type or object. + + Raises: + Exception: Reraise any exception that happened during callback + execution. + """ + while not self.done: + await asyncio.sleep(self.sleep_time) + + if self.exception is self.not_set: + return self.result + raise self.exception + + +class BaseCommunicator: + def __init__(self): + self.process = None + self.websocket_server = None + self.websocket_rpc = None + self.exit_code = None + self._connected_client = None + + @property + def server_is_running(self): + if self.websocket_server is None: + return False + return self.websocket_server.server_is_running + + def _windows_file_process(self, src_dst_mapping, to_remove): + """Windows specific file processing asking for admin permissions. + + It is required to have administration permissions to modify plugin + files in TVPaint installation folder. + + Method requires `pywin32` python module. + + Args: + src_dst_mapping (list, tuple, set): Mapping of source file to + destination. Both must be full path. Each item must be iterable + of size 2 `(C:/src/file.dll, C:/dst/file.dll)`. + to_remove (list): Fullpath to files that should be removed. + """ + + import pythoncom + from win32comext.shell import shell + + # Create temp folder where plugin files are temporary copied + # - reason is that copy to TVPaint requires administartion permissions + # but admin may not have access to source folder + tmp_dir = os.path.normpath( + tempfile.mkdtemp(prefix="tvpaint_copy_") + ) + + # Copy source to temp folder and create new mapping + dst_folders = collections.defaultdict(list) + new_src_dst_mapping = [] + for old_src, dst in src_dst_mapping: + new_src = os.path.join(tmp_dir, os.path.split(old_src)[1]) + shutil.copy(old_src, new_src) + new_src_dst_mapping.append((new_src, dst)) + + for src, dst in new_src_dst_mapping: + src = os.path.normpath(src) + dst = os.path.normpath(dst) + dst_filename = os.path.basename(dst) + dst_folder_path = os.path.dirname(dst) + dst_folders[dst_folder_path].append((dst_filename, src)) + + # create an instance of IFileOperation + fo = pythoncom.CoCreateInstance( + shell.CLSID_FileOperation, + None, + pythoncom.CLSCTX_ALL, + shell.IID_IFileOperation + ) + # Add delete command to file operation object + for filepath in to_remove: + item = shell.SHCreateItemFromParsingName( + filepath, None, shell.IID_IShellItem + ) + fo.DeleteItem(item) + + # here you can use SetOperationFlags, progress Sinks, etc. + for folder_path, items in dst_folders.items(): + # create an instance of IShellItem for the target folder + folder_item = shell.SHCreateItemFromParsingName( + folder_path, None, shell.IID_IShellItem + ) + for _dst_filename, source_file_path in items: + # create an instance of IShellItem for the source item + copy_item = shell.SHCreateItemFromParsingName( + source_file_path, None, shell.IID_IShellItem + ) + # queue the copy operation + fo.CopyItem(copy_item, folder_item, _dst_filename, None) + + # commit + fo.PerformOperations() + + # Remove temp folder + shutil.rmtree(tmp_dir) + + def _prepare_windows_plugin(self, launch_args): + """Copy plugin to TVPaint plugins and set PATH to dependencies. + + Check if plugin in TVPaint's plugins exist and match to plugin + version to current implementation version. Based on 64-bit or 32-bit + version of the plugin. Path to libraries required for plugin is added + to PATH variable. + """ + + host_executable = launch_args[0] + executable_file = os.path.basename(host_executable) + if "64bit" in executable_file: + subfolder = "windows_x64" + elif "32bit" in executable_file: + subfolder = "windows_x86" + else: + raise ValueError( + "Can't determine if executable " + "leads to 32-bit or 64-bit TVPaint!" + ) + + plugin_files_path = get_plugin_files_path() + # Folder for right windows plugin files + source_plugins_dir = os.path.join(plugin_files_path, subfolder) + + # Path to libraies (.dll) required for plugin library + # - additional libraries can be copied to TVPaint installation folder + # (next to executable) or added to PATH environment variable + additional_libs_folder = os.path.join( + source_plugins_dir, + "additional_libraries" + ) + additional_libs_folder = additional_libs_folder.replace("\\", "/") + if additional_libs_folder not in os.environ["PATH"]: + os.environ["PATH"] += (os.pathsep + additional_libs_folder) + + # Path to TVPaint's plugins folder (where we want to add our plugin) + host_plugins_path = os.path.join( + os.path.dirname(host_executable), + "plugins" + ) + + # Files that must be copied to TVPaint's plugin folder + plugin_dir = os.path.join(source_plugins_dir, "plugin") + + to_copy = [] + to_remove = [] + # Remove old plugin name + deprecated_filepath = os.path.join( + host_plugins_path, "AvalonPlugin.dll" + ) + if os.path.exists(deprecated_filepath): + to_remove.append(deprecated_filepath) + + for filename in os.listdir(plugin_dir): + src_full_path = os.path.join(plugin_dir, filename) + dst_full_path = os.path.join(host_plugins_path, filename) + if dst_full_path in to_remove: + to_remove.remove(dst_full_path) + + if ( + not os.path.exists(dst_full_path) + or not filecmp.cmp(src_full_path, dst_full_path) + ): + to_copy.append((src_full_path, dst_full_path)) + + # Skip copy if everything is done + if not to_copy and not to_remove: + return + + # Try to copy + try: + self._windows_file_process(to_copy, to_remove) + except Exception: + log.error("Plugin copy failed", exc_info=True) + + # Validate copy was done + invalid_copy = [] + for src, dst in to_copy: + if not os.path.exists(dst) or not filecmp.cmp(src, dst): + invalid_copy.append((src, dst)) + + # Validate delete was dones + invalid_remove = [] + for filepath in to_remove: + if os.path.exists(filepath): + invalid_remove.append(filepath) + + if not invalid_remove and not invalid_copy: + return + + msg_parts = [] + if invalid_remove: + msg_parts.append( + "Failed to remove files: {}".format(", ".join(invalid_remove)) + ) + + if invalid_copy: + _invalid = [ + "\"{}\" -> \"{}\"".format(src, dst) + for src, dst in invalid_copy + ] + msg_parts.append( + "Failed to copy files: {}".format(", ".join(_invalid)) + ) + raise RuntimeError(" & ".join(msg_parts)) + + def _launch_tv_paint(self, launch_args): + flags = ( + subprocess.DETACHED_PROCESS + | subprocess.CREATE_NEW_PROCESS_GROUP + ) + env = os.environ.copy() + # Remove QuickTime from PATH on windows + # - quicktime overrides TVPaint's ffmpeg encode/decode which may + # cause issues on loading + if platform.system().lower() == "windows": + new_path = [] + for path in env["PATH"].split(os.pathsep): + if path and "quicktime" not in path.lower(): + new_path.append(path) + env["PATH"] = os.pathsep.join(new_path) + + kwargs = { + "env": env, + "creationflags": flags + } + self.process = subprocess.Popen(launch_args, **kwargs) + + def _create_routes(self): + self.websocket_rpc = BaseTVPaintRpc( + self, loop=self.websocket_server.loop + ) + self.websocket_server.add_route( + "*", "/", self.websocket_rpc.handle_request + ) + + def _start_webserver(self): + self.websocket_server.start() + # Make sure RPC is using same loop as websocket server + while not self.websocket_server.server_is_running: + time.sleep(0.1) + + def _stop_webserver(self): + self.websocket_server.stop() + + def _exit(self, exit_code=None): + self._stop_webserver() + if exit_code is not None: + self.exit_code = exit_code + + def stop(self): + """Stop communication and currently running python process.""" + log.info("Stopping communication") + self._exit() + + def launch(self, launch_args): + """Prepare all required data and launch host. + + First is prepared websocket server as communication point for host, + when server is ready to use host is launched as subprocess. + """ + if platform.system().lower() == "windows": + self._prepare_windows_plugin(launch_args) + + # Launch TVPaint and the websocket server. + log.info("Launching TVPaint") + self.websocket_server = WebSocketServer() + + self._create_routes() + + os.environ["WEBSOCKET_URL"] = "ws://localhost:{}".format( + self.websocket_server.port + ) + + log.info("Added request handler for url: {}".format( + os.environ["WEBSOCKET_URL"] + )) + + self._start_webserver() + + # Start TVPaint when server is running + self._launch_tv_paint(launch_args) + + log.info("Waiting for client connection") + while True: + if self.process.poll() is not None: + log.debug("Host process is not alive. Exiting") + self._exit(1) + return + + if self.websocket_rpc.client_connected(): + log.info("Client has connected") + break + time.sleep(0.5) + + self._on_client_connect() + + api.emit("application.launched") + + def _on_client_connect(self): + self._initial_textfile_write() + + def _initial_textfile_write(self): + """Show popup about Write to file at start of TVPaint.""" + tmp_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + tmp_file.close() + tmp_filepath = tmp_file.name.replace("\\", "/") + george_script = ( + "tv_writetextfile \"strict\" \"append\" \"{}\" \"empty\"" + ).format(tmp_filepath) + + result = CommunicationWrapper.execute_george(george_script) + + # Remote the file + os.remove(tmp_filepath) + + if result is None: + log.warning( + "Host was probably closed before plugin was initialized." + ) + elif result.lower() == "forbidden": + log.warning("User didn't confirm saving files.") + + def _client(self): + if not self.websocket_rpc: + log.warning("Communicator's server did not start yet.") + return None + + for client in self.websocket_rpc.clients: + if not client.ws.closed: + return client + log.warning("Client is not yet connected to Communicator.") + return None + + def client(self): + if not self._connected_client or self._connected_client.ws.closed: + self._connected_client = self._client() + return self._connected_client + + def send_request(self, method, params=None): + client = self.client() + if not client: + return + + return self.websocket_rpc.send_request( + client, method, params + ) + + def send_notification(self, method, params=None): + client = self.client() + if not client: + return + + self.websocket_rpc.send_notification( + client, method, params + ) + + def execute_george(self, george_script): + """Execute passed goerge script in TVPaint.""" + return self.send_request( + "execute_george", [george_script] + ) + + def execute_george_through_file(self, george_script): + """Execute george script with temp file. + + Allows to execute multiline george script without stopping websocket + client. + + On windows make sure script does not contain paths with backwards + slashes in paths, TVPaint won't execute properly in that case. + + Args: + george_script (str): George script to execute. May be multilined. + """ + temporary_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".grg", delete=False + ) + temporary_file.write(george_script) + temporary_file.close() + temp_file_path = temporary_file.name.replace("\\", "/") + self.execute_george("tv_runscript {}".format(temp_file_path)) + os.remove(temp_file_path) + + +class QtCommunicator(BaseCommunicator): + menu_definitions = { + "title": "OpenPype Tools", + "menu_items": [ + { + "callback": "workfiles_tool", + "label": "Workfiles", + "help": "Open workfiles tool" + }, { + "callback": "loader_tool", + "label": "Load", + "help": "Open loader tool" + }, { + "callback": "creator_tool", + "label": "Create", + "help": "Open creator tool" + }, { + "callback": "scene_inventory_tool", + "label": "Scene inventory", + "help": "Open scene inventory tool" + }, { + "callback": "publish_tool", + "label": "Publish", + "help": "Open publisher" + }, { + "callback": "library_loader_tool", + "label": "Library", + "help": "Open library loader tool" + }, { + "callback": "subset_manager_tool", + "label": "Subset Manager", + "help": "Open subset manager tool" + }, { + "callback": "experimental_tools", + "label": "Experimental tools", + "help": "Open experimental tools dialog" + } + ] + } + + def __init__(self, qt_app): + super().__init__() + self.callback_queue = Queue() + self.qt_app = qt_app + + def _create_routes(self): + self.websocket_rpc = QtTVPaintRpc( + self, loop=self.websocket_server.loop + ) + self.websocket_server.add_route( + "*", "/", self.websocket_rpc.handle_request + ) + + def execute_in_main_thread(self, main_thread_item, wait=True): + """Add `MainThreadItem` to callback queue and wait for result.""" + self.callback_queue.put(main_thread_item) + if wait: + return main_thread_item.wait() + return + + async def async_execute_in_main_thread(self, main_thread_item, wait=True): + """Add `MainThreadItem` to callback queue and wait for result.""" + self.callback_queue.put(main_thread_item) + if wait: + return await main_thread_item.async_wait() + + def main_thread_listen(self): + """Get last `MainThreadItem` from queue. + + Must be called from main thread. + + Method checks if host process is still running as it may cause + issues if not. + """ + # check if host still running + if self.process.poll() is not None: + self._exit() + return None + + if self.callback_queue.empty(): + return None + return self.callback_queue.get() + + def _on_client_connect(self): + super()._on_client_connect() + self._build_menu() + + def _build_menu(self): + self.send_request( + "define_menu", [self.menu_definitions] + ) + + def _exit(self, *args, **kwargs): + super()._exit(*args, **kwargs) + api.emit("application.exit") + self.qt_app.exit(self.exit_code) diff --git a/openpype/hosts/tvpaint/api/launch_script.py b/openpype/hosts/tvpaint/api/launch_script.py new file mode 100644 index 0000000000..e66bf61df6 --- /dev/null +++ b/openpype/hosts/tvpaint/api/launch_script.py @@ -0,0 +1,84 @@ +import os +import sys +import signal +import traceback +import ctypes +import platform +import logging + +from Qt import QtWidgets, QtCore, QtGui + +from avalon import api +from openpype import style +from openpype.hosts.tvpaint.api.communication_server import ( + CommunicationWrapper +) +from openpype.hosts.tvpaint import api as tvpaint_host + +log = logging.getLogger(__name__) + + +def safe_excepthook(*args): + traceback.print_exception(*args) + + +def main(launch_args): + # Be sure server won't crash at any moment but just print traceback + sys.excepthook = safe_excepthook + + # Create QtApplication for tools + # - QApplicaiton is also main thread/event loop of the server + qt_app = QtWidgets.QApplication([]) + + # Execute pipeline installation + api.install(tvpaint_host) + + # Create Communicator object and trigger launch + # - this must be done before anything is processed + communicator = CommunicationWrapper.create_qt_communicator(qt_app) + communicator.launch(launch_args) + + def process_in_main_thread(): + """Execution of `MainThreadItem`.""" + item = communicator.main_thread_listen() + if item: + item.execute() + + timer = QtCore.QTimer() + timer.setInterval(100) + timer.timeout.connect(process_in_main_thread) + timer.start() + + # Register terminal signal handler + def signal_handler(*_args): + print("You pressed Ctrl+C. Process ended.") + communicator.stop() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + qt_app.setQuitOnLastWindowClosed(False) + qt_app.setStyleSheet(style.load_stylesheet()) + + # Load avalon icon + icon_path = style.app_icon_path() + if icon_path: + icon = QtGui.QIcon(icon_path) + qt_app.setWindowIcon(icon) + + # Set application name to be able show application icon in task bar + if platform.system().lower() == "windows": + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + u"WebsocketServer" + ) + + # Run Qt application event processing + sys.exit(qt_app.exec_()) + + +if __name__ == "__main__": + args = list(sys.argv) + if os.path.abspath(__file__) == os.path.normpath(args[0]): + # Pop path to script + args.pop(0) + main(args) diff --git a/openpype/hosts/tvpaint/api/lib.py b/openpype/hosts/tvpaint/api/lib.py index 539cebe646..654aff19d8 100644 --- a/openpype/hosts/tvpaint/api/lib.py +++ b/openpype/hosts/tvpaint/api/lib.py @@ -1,85 +1,534 @@ -from PIL import Image +import os +import logging +import tempfile import avalon.io -from avalon.tvpaint.lib import execute_george + +from . import CommunicationWrapper + +log = logging.getLogger(__name__) -def composite_images(input_image_paths, output_filepath): - """Composite images in order from passed list. +def execute_george(george_script, communicator=None): + if not communicator: + communicator = CommunicationWrapper.communicator + return communicator.execute_george(george_script) - Raises: - ValueError: When entered list is empty. + +def execute_george_through_file(george_script, communicator=None): + """Execute george script with temp file. + + Allows to execute multiline george script without stopping websocket + client. + + On windows make sure script does not contain paths with backwards + slashes in paths, TVPaint won't execute properly in that case. + + Args: + george_script (str): George script to execute. May be multilined. """ - if not input_image_paths: - raise ValueError("Nothing to composite.") + if not communicator: + communicator = CommunicationWrapper.communicator - img_obj = None - for image_filepath in input_image_paths: - _img_obj = Image.open(image_filepath) - if img_obj is None: - img_obj = _img_obj - else: - img_obj.alpha_composite(_img_obj) - img_obj.save(output_filepath) + return communicator.execute_george_through_file(george_script) -def set_context_settings(asset_doc=None): - """Set workfile settings by asset document data. +def parse_layers_data(data): + """Parse layers data loaded in 'get_layers_data'.""" + layers = [] + layers_raw = data.split("\n") + for layer_raw in layers_raw: + layer_raw = layer_raw.strip() + if not layer_raw: + continue + ( + layer_id, group_id, visible, position, opacity, name, + layer_type, + frame_start, frame_end, prelighttable, postlighttable, + selected, editable, sencil_state + ) = layer_raw.split("|") + layer = { + "layer_id": int(layer_id), + "group_id": int(group_id), + "visible": visible == "ON", + "position": int(position), + "opacity": int(opacity), + "name": name, + "type": layer_type, + "frame_start": int(frame_start), + "frame_end": int(frame_end), + "prelighttable": prelighttable == "1", + "postlighttable": postlighttable == "1", + "selected": selected == "1", + "editable": editable == "1", + "sencil_state": sencil_state + } + layers.append(layer) + return layers - Change fps, resolution and frame start/end. + +def get_layers_data_george_script(output_filepath, layer_ids=None): + """Prepare george script which will collect all layers from workfile.""" + output_filepath = output_filepath.replace("\\", "/") + george_script_lines = [ + # Variable containing full path to output file + "output_path = \"{}\"".format(output_filepath), + # Get Current Layer ID + "tv_LayerCurrentID", + "current_layer_id = result" + ] + # Script part for getting and storing layer information to temp + layer_data_getter = ( + # Get information about layer's group + "tv_layercolor \"get\" layer_id", + "group_id = result", + "tv_LayerInfo layer_id", + ( + "PARSE result visible position opacity name" + " type startFrame endFrame prelighttable postlighttable" + " selected editable sencilState" + ), + # Check if layer ID match `tv_LayerCurrentID` + "IF CMP(current_layer_id, layer_id)==1", + # - mark layer as selected if layer id match to current layer id + "selected=1", + "END", + # Prepare line with data separated by "|" + ( + "line = layer_id'|'group_id'|'visible'|'position'|'opacity'|'" + "name'|'type'|'startFrame'|'endFrame'|'prelighttable'|'" + "postlighttable'|'selected'|'editable'|'sencilState" + ), + # Write data to output file + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line", + ) + + # Collect data for all layers if layers are not specified + if layer_ids is None: + george_script_lines.extend(( + # Layer loop variables + "loop = 1", + "idx = 0", + # Layers loop + "WHILE loop", + "tv_LayerGetID idx", + "layer_id = result", + "idx = idx + 1", + # Stop loop if layer_id is "NONE" + "IF CMP(layer_id, \"NONE\")==1", + "loop = 0", + "ELSE", + *layer_data_getter, + "END", + "END" + )) + else: + for layer_id in layer_ids: + george_script_lines.append("layer_id = {}".format(layer_id)) + george_script_lines.extend(layer_data_getter) + + return "\n".join(george_script_lines) + + +def layers_data(layer_ids=None, communicator=None): + """Backwards compatible function of 'get_layers_data'.""" + return get_layers_data(layer_ids, communicator) + + +def get_layers_data(layer_ids=None, communicator=None): + """Collect all layers information from currently opened workfile.""" + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + output_file.close() + if layer_ids is not None and isinstance(layer_ids, int): + layer_ids = [layer_ids] + + output_filepath = output_file.name + + george_script = get_layers_data_george_script(output_filepath, layer_ids) + + execute_george_through_file(george_script, communicator) + + with open(output_filepath, "r") as stream: + data = stream.read() + + output = parse_layers_data(data) + os.remove(output_filepath) + return output + + +def parse_group_data(data): + """Paser group data collected in 'get_groups_data'.""" + output = [] + groups_raw = data.split("\n") + for group_raw in groups_raw: + group_raw = group_raw.strip() + if not group_raw: + continue + + parts = group_raw.split(" ") + # Check for length and concatenate 2 last items until length match + # - this happens if name contain spaces + while len(parts) > 6: + last_item = parts.pop(-1) + parts[-1] = " ".join([parts[-1], last_item]) + clip_id, group_id, red, green, blue, name = parts + + group = { + "group_id": int(group_id), + "name": name, + "clip_id": int(clip_id), + "red": int(red), + "green": int(green), + "blue": int(blue), + } + output.append(group) + return output + + +def groups_data(communicator=None): + """Backwards compatible function of 'get_groups_data'.""" + return get_groups_data(communicator) + + +def get_groups_data(communicator=None): + """Information about groups from current workfile.""" + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + output_file.close() + + output_filepath = output_file.name.replace("\\", "/") + george_script_lines = ( + # Variable containing full path to output file + "output_path = \"{}\"".format(output_filepath), + "loop = 1", + "FOR idx = 1 TO 12", + "tv_layercolor \"getcolor\" 0 idx", + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' result", + "END" + ) + george_script = "\n".join(george_script_lines) + execute_george_through_file(george_script, communicator) + + with open(output_filepath, "r") as stream: + data = stream.read() + + output = parse_group_data(data) + os.remove(output_filepath) + return output + + +def get_layers_pre_post_behavior(layer_ids, communicator=None): + """Collect data about pre and post behavior of layer ids. + + Pre and Post behaviors is enumerator of possible values: + - "none" + - "repeat" / "loop" + - "pingpong" + - "hold" + + Example output: + ```json + { + 0: { + "pre": "none", + "post": "loop" + } + } + ``` + + Returns: + dict: Key is layer id value is dictionary with "pre" and "post" keys. """ - if asset_doc is None: - # Use current session asset if not passed - asset_doc = avalon.io.find_one({ - "type": "asset", - "name": avalon.io.Session["AVALON_ASSET"] - }) + # Skip if is empty + if not layer_ids: + return {} - project_doc = avalon.io.find_one({"type": "project"}) + # Auto convert to list + if not isinstance(layer_ids, (list, set, tuple)): + layer_ids = [layer_ids] - framerate = asset_doc["data"].get("fps") - if framerate is None: - framerate = project_doc["data"].get("fps") + # Prepare temp file + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + output_file.close() - if framerate is not None: - execute_george( - "tv_framerate {} \"timestretch\"".format(framerate) - ) - else: - print("Framerate was not found!") + output_filepath = output_file.name.replace("\\", "/") + george_script_lines = [ + # Variable containing full path to output file + "output_path = \"{}\"".format(output_filepath), + ] + for layer_id in layer_ids: + george_script_lines.extend([ + "layer_id = {}".format(layer_id), + "tv_layerprebehavior layer_id", + "pre_beh = result", + "tv_layerpostbehavior layer_id", + "post_beh = result", + "line = layer_id'|'pre_beh'|'post_beh", + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line" + ]) - width_key = "resolutionWidth" - height_key = "resolutionHeight" + george_script = "\n".join(george_script_lines) + execute_george_through_file(george_script, communicator) - width = asset_doc["data"].get(width_key) - height = asset_doc["data"].get(height_key) - if width is None or height is None: - width = project_doc["data"].get(width_key) - height = project_doc["data"].get(height_key) + # Read data + with open(output_filepath, "r") as stream: + data = stream.read() - if width is None or height is None: - print("Resolution was not found!") - else: - execute_george("tv_resizepage {} {} 0".format(width, height)) + # Remove temp file + os.remove(output_filepath) - frame_start = asset_doc["data"].get("frameStart") - frame_end = asset_doc["data"].get("frameEnd") + # Parse data + output = {} + raw_lines = data.split("\n") + for raw_line in raw_lines: + line = raw_line.strip() + if not line: + continue + parts = line.split("|") + if len(parts) != 3: + continue + layer_id, pre_beh, post_beh = parts + output[int(layer_id)] = { + "pre": pre_beh.lower(), + "post": post_beh.lower() + } + return output - if frame_start is None or frame_end is None: - print("Frame range was not found!") - return - handles = asset_doc["data"].get("handles") or 0 - handle_start = asset_doc["data"].get("handleStart") - handle_end = asset_doc["data"].get("handleEnd") +def get_layers_exposure_frames(layer_ids, layers_data=None, communicator=None): + """Get exposure frames. - if handle_start is None or handle_end is None: - handle_start = handles - handle_end = handles + Easily said returns frames where keyframes are. Recognized with george + function `tv_exposureinfo` returning "Head". - # Always start from 0 Mark In and set only Mark Out - mark_in = 0 - mark_out = mark_in + (frame_end - frame_start) + handle_start + handle_end + Args: + layer_ids (list): Ids of a layers for which exposure frames should + look for. + layers_data (list): Precollected layers data. If are not passed then + 'get_layers_data' is used. + communicator (BaseCommunicator): Communicator used for communication + with TVPaint. - execute_george("tv_markin {} set".format(mark_in)) - execute_george("tv_markout {} set".format(mark_out)) + Returns: + dict: Frames where exposure is set to "Head" by layer id. + """ + + if layers_data is None: + layers_data = get_layers_data(layer_ids) + _layers_by_id = { + layer["layer_id"]: layer + for layer in layers_data + } + layers_by_id = { + layer_id: _layers_by_id.get(layer_id) + for layer_id in layer_ids + } + tmp_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + tmp_file.close() + tmp_output_path = tmp_file.name.replace("\\", "/") + george_script_lines = [ + "output_path = \"{}\"".format(tmp_output_path) + ] + + output = {} + layer_id_mapping = {} + for layer_id, layer_data in layers_by_id.items(): + layer_id_mapping[str(layer_id)] = layer_id + output[layer_id] = [] + if not layer_data: + continue + first_frame = layer_data["frame_start"] + last_frame = layer_data["frame_end"] + george_script_lines.extend([ + "line = \"\"", + "layer_id = {}".format(layer_id), + "line = line''layer_id", + "tv_layerset layer_id", + "frame = {}".format(first_frame), + "WHILE (frame <= {})".format(last_frame), + "tv_exposureinfo frame", + "exposure = result", + "IF (CMP(exposure, \"Head\") == 1)", + "line = line'|'frame", + "END", + "frame = frame + 1", + "END", + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' line" + ]) + + execute_george_through_file("\n".join(george_script_lines), communicator) + + with open(tmp_output_path, "r") as stream: + data = stream.read() + + os.remove(tmp_output_path) + + lines = [] + for line in data.split("\n"): + line = line.strip() + if line: + lines.append(line) + + for line in lines: + line_items = list(line.split("|")) + layer_id = line_items.pop(0) + _layer_id = layer_id_mapping[layer_id] + output[_layer_id] = [int(frame) for frame in line_items] + + return output + + +def get_exposure_frames( + layer_id, first_frame=None, last_frame=None, communicator=None +): + """Get exposure frames. + + Easily said returns frames where keyframes are. Recognized with george + function `tv_exposureinfo` returning "Head". + + Args: + layer_id (int): Id of a layer for which exposure frames should + look for. + first_frame (int): From which frame will look for exposure frames. + Used layers first frame if not entered. + last_frame (int): Last frame where will look for exposure frames. + Used layers last frame if not entered. + + Returns: + list: Frames where exposure is set to "Head". + """ + if first_frame is None or last_frame is None: + layer = layers_data(layer_id)[0] + if first_frame is None: + first_frame = layer["frame_start"] + if last_frame is None: + last_frame = layer["frame_end"] + + tmp_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + tmp_file.close() + tmp_output_path = tmp_file.name.replace("\\", "/") + george_script_lines = [ + "tv_layerset {}".format(layer_id), + "output_path = \"{}\"".format(tmp_output_path), + "output = \"\"", + "frame = {}".format(first_frame), + "WHILE (frame <= {})".format(last_frame), + "tv_exposureinfo frame", + "exposure = result", + "IF (CMP(exposure, \"Head\") == 1)", + "IF (CMP(output, \"\") == 1)", + "output = output''frame", + "ELSE", + "output = output'|'frame", + "END", + "END", + "frame = frame + 1", + "END", + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' output" + ] + + execute_george_through_file("\n".join(george_script_lines), communicator) + + with open(tmp_output_path, "r") as stream: + data = stream.read() + + os.remove(tmp_output_path) + + lines = [] + for line in data.split("\n"): + line = line.strip() + if line: + lines.append(line) + + exposure_frames = [] + for line in lines: + for frame in line.split("|"): + exposure_frames.append(int(frame)) + return exposure_frames + + +def get_scene_data(communicator=None): + """Scene data of currently opened scene. + + Result contains resolution, pixel aspect, fps mark in/out with states, + frame start and background color. + + Returns: + dict: Scene data collected in many ways. + """ + workfile_info = execute_george("tv_projectinfo", communicator) + workfile_info_parts = workfile_info.split(" ") + + # Project frame start - not used + workfile_info_parts.pop(-1) + field_order = workfile_info_parts.pop(-1) + frame_rate = float(workfile_info_parts.pop(-1)) + pixel_apsect = float(workfile_info_parts.pop(-1)) + height = int(workfile_info_parts.pop(-1)) + width = int(workfile_info_parts.pop(-1)) + + # Marks return as "{frame - 1} {state} ", example "0 set". + result = execute_george("tv_markin", communicator) + mark_in_frame, mark_in_state, _ = result.split(" ") + + result = execute_george("tv_markout", communicator) + mark_out_frame, mark_out_state, _ = result.split(" ") + + start_frame = execute_george("tv_startframe", communicator) + return { + "width": width, + "height": height, + "pixel_aspect": pixel_apsect, + "fps": frame_rate, + "field_order": field_order, + "mark_in": int(mark_in_frame), + "mark_in_state": mark_in_state, + "mark_in_set": mark_in_state == "set", + "mark_out": int(mark_out_frame), + "mark_out_state": mark_out_state, + "mark_out_set": mark_out_state == "set", + "start_frame": int(start_frame), + "bg_color": get_scene_bg_color(communicator) + } + + +def get_scene_bg_color(communicator=None): + """Background color set on scene. + + Is important for review exporting where scene bg color is used as + background. + """ + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + output_file.close() + output_filepath = output_file.name.replace("\\", "/") + george_script_lines = [ + # Variable containing full path to output file + "output_path = \"{}\"".format(output_filepath), + "tv_background", + "bg_color = result", + # Write data to output file + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' bg_color" + ] + + george_script = "\n".join(george_script_lines) + execute_george_through_file(george_script, communicator) + + with open(output_filepath, "r") as stream: + data = stream.read() + + os.remove(output_filepath) + data = data.strip() + if not data: + return None + return data.split(" ") diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py new file mode 100644 index 0000000000..e7c5159bbc --- /dev/null +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -0,0 +1,491 @@ +import os +import json +import contextlib +import tempfile +import logging + +import requests + +import pyblish.api +import avalon.api + +from avalon import io +from avalon.pipeline import AVALON_CONTAINER_ID + +from openpype.hosts import tvpaint +from openpype.api import get_current_project_settings + +from .lib import ( + execute_george, + execute_george_through_file +) + +log = logging.getLogger(__name__) + +HOST_DIR = os.path.dirname(os.path.abspath(tvpaint.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") + +METADATA_SECTION = "avalon" +SECTION_NAME_CONTEXT = "context" +SECTION_NAME_INSTANCES = "instances" +SECTION_NAME_CONTAINERS = "containers" +# Maximum length of metadata chunk string +# TODO find out the max (500 is safe enough) +TVPAINT_CHUNK_LENGTH = 500 + +"""TVPaint's Metadata + +Metadata are stored to TVPaint's workfile. + +Workfile works similar to .ini file but has few limitation. Most important +limitation is that value under key has limited length. Due to this limitation +each metadata section/key stores number of "subkeys" that are related to +the section. + +Example: +Metadata key `"instances"` may have stored value "2". In that case it is +expected that there are also keys `["instances0", "instances1"]`. + +Workfile data looks like: +``` +[avalon] +instances0=[{{__dq__}id{__dq__}: {__dq__}pyblish.avalon.instance{__dq__... +instances1=...more data... +instances=2 +``` +""" + + +def install(): + """Install Maya-specific functionality of avalon-core. + + This function is called automatically on calling `api.install(maya)`. + + """ + log.info("OpenPype - Installing TVPaint integration") + io.install() + + # Create workdir folder if does not exist yet + workdir = io.Session["AVALON_WORKDIR"] + if not os.path.exists(workdir): + os.makedirs(workdir) + + pyblish.api.register_host("tvpaint") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + + registered_callbacks = ( + pyblish.api.registered_callbacks().get("instanceToggled") or [] + ) + if on_instance_toggle not in registered_callbacks: + pyblish.api.register_callback("instanceToggled", on_instance_toggle) + + avalon.api.on("application.launched", initial_launch) + avalon.api.on("application.exit", application_exit) + + +def uninstall(): + """Uninstall TVPaint-specific functionality of avalon-core. + + This function is called automatically on calling `api.uninstall()`. + + """ + log.info("OpenPype - Uninstalling TVPaint integration") + pyblish.api.deregister_host("tvpaint") + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) + avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + + +def containerise( + name, namespace, members, context, loader, current_containers=None +): + """Add new container to metadata. + + Args: + name (str): Container name. + namespace (str): Container namespace. + members (list): List of members that were loaded and belongs + to the container (layer names). + current_containers (list): Preloaded containers. Should be used only + on update/switch when containers were modified durring the process. + + Returns: + dict: Container data stored to workfile metadata. + """ + + container_data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "members": members, + "name": name, + "namespace": namespace, + "loader": str(loader), + "representation": str(context["representation"]["_id"]) + } + if current_containers is None: + current_containers = ls() + + # Add container to containers list + current_containers.append(container_data) + + # Store data to metadata + write_workfile_metadata(SECTION_NAME_CONTAINERS, current_containers) + + return container_data + + +@contextlib.contextmanager +def maintained_selection(): + # TODO implement logic + try: + yield + finally: + pass + + +def split_metadata_string(text, chunk_length=None): + """Split string by length. + + Split text to chunks by entered length. + Example: + ```python + text = "ABCDEFGHIJKLM" + result = split_metadata_string(text, 3) + print(result) + >>> ['ABC', 'DEF', 'GHI', 'JKL'] + ``` + + Args: + text (str): Text that will be split into chunks. + chunk_length (int): Single chunk size. Default chunk_length is + set to global variable `TVPAINT_CHUNK_LENGTH`. + + Returns: + list: List of strings wil at least one item. + """ + if chunk_length is None: + chunk_length = TVPAINT_CHUNK_LENGTH + chunks = [] + for idx in range(chunk_length, len(text) + chunk_length, chunk_length): + start_idx = idx - chunk_length + chunks.append(text[start_idx:idx]) + return chunks + + +def get_workfile_metadata_string_for_keys(metadata_keys): + """Read metadata for specific keys from current project workfile. + + All values from entered keys are stored to single string without separator. + + Function is designed to help get all values for one metadata key at once. + So order of passed keys matteres. + + Args: + metadata_keys (list, str): Metadata keys for which data should be + retrieved. Order of keys matters! It is possible to enter only + single key as string. + """ + # Add ability to pass only single key + if isinstance(metadata_keys, str): + metadata_keys = [metadata_keys] + + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="a_tvp_", suffix=".txt", delete=False + ) + output_file.close() + output_filepath = output_file.name.replace("\\", "/") + + george_script_parts = [] + george_script_parts.append( + "output_path = \"{}\"".format(output_filepath) + ) + # Store data for each index of metadata key + for metadata_key in metadata_keys: + george_script_parts.append( + "tv_readprojectstring \"{}\" \"{}\" \"\"".format( + METADATA_SECTION, metadata_key + ) + ) + george_script_parts.append( + "tv_writetextfile \"strict\" \"append\" '\"'output_path'\"' result" + ) + + # Execute the script + george_script = "\n".join(george_script_parts) + execute_george_through_file(george_script) + + # Load data from temp file + with open(output_filepath, "r") as stream: + file_content = stream.read() + + # Remove `\n` from content + output_string = file_content.replace("\n", "") + + # Delete temp file + os.remove(output_filepath) + + return output_string + + +def get_workfile_metadata_string(metadata_key): + """Read metadata for specific key from current project workfile.""" + result = get_workfile_metadata_string_for_keys([metadata_key]) + if not result: + return None + + stripped_result = result.strip() + if not stripped_result: + return None + + # NOTE Backwards compatibility when metadata key did not store range of key + # indexes but the value itself + # NOTE We don't have to care about negative values with `isdecimal` check + if not stripped_result.isdecimal(): + metadata_string = result + else: + keys = [] + for idx in range(int(stripped_result)): + keys.append("{}{}".format(metadata_key, idx)) + metadata_string = get_workfile_metadata_string_for_keys(keys) + + # Replace quotes plaholders with their values + metadata_string = ( + metadata_string + .replace("{__sq__}", "'") + .replace("{__dq__}", "\"") + ) + return metadata_string + + +def get_workfile_metadata(metadata_key, default=None): + """Read and parse metadata for specific key from current project workfile. + + Pipeline use function to store loaded and created instances within keys + stored in `SECTION_NAME_INSTANCES` and `SECTION_NAME_CONTAINERS` + constants. + + Args: + metadata_key (str): Key defying which key should read. It is expected + value contain json serializable string. + """ + if default is None: + default = [] + + json_string = get_workfile_metadata_string(metadata_key) + if json_string: + try: + return json.loads(json_string) + except json.decoder.JSONDecodeError: + # TODO remove when backwards compatibility of storing metadata + # will be removed + print(( + "Fixed invalid metadata in workfile." + " Not serializable string was: {}" + ).format(json_string)) + write_workfile_metadata(metadata_key, default) + return default + + +def write_workfile_metadata(metadata_key, value): + """Write metadata for specific key into current project workfile. + + George script has specific way how to work with quotes which should be + solved automatically with this function. + + Args: + metadata_key (str): Key defying under which key value will be stored. + value (dict,list,str): Data to store they must be json serializable. + """ + if isinstance(value, (dict, list)): + value = json.dumps(value) + + if not value: + value = "" + + # Handle quotes in dumped json string + # - replace single and double quotes with placeholders + value = ( + value + .replace("'", "{__sq__}") + .replace("\"", "{__dq__}") + ) + chunks = split_metadata_string(value) + chunks_len = len(chunks) + + write_template = "tv_writeprojectstring \"{}\" \"{}\" \"{}\"" + george_script_parts = [] + # Add information about chunks length to metadata key itself + george_script_parts.append( + write_template.format(METADATA_SECTION, metadata_key, chunks_len) + ) + # Add chunk values to indexed metadata keys + for idx, chunk_value in enumerate(chunks): + sub_key = "{}{}".format(metadata_key, idx) + george_script_parts.append( + write_template.format(METADATA_SECTION, sub_key, chunk_value) + ) + + george_script = "\n".join(george_script_parts) + + return execute_george_through_file(george_script) + + +def get_current_workfile_context(): + """Return context in which was workfile saved.""" + return get_workfile_metadata(SECTION_NAME_CONTEXT, {}) + + +def save_current_workfile_context(context): + """Save context which was used to create a workfile.""" + return write_workfile_metadata(SECTION_NAME_CONTEXT, context) + + +def remove_instance(instance): + """Remove instance from current workfile metadata.""" + current_instances = get_workfile_metadata(SECTION_NAME_INSTANCES) + instance_id = instance.get("uuid") + found_idx = None + if instance_id: + for idx, _inst in enumerate(current_instances): + if _inst["uuid"] == instance_id: + found_idx = idx + break + + if found_idx is None: + return + current_instances.pop(found_idx) + write_instances(current_instances) + + +def list_instances(): + """List all created instances from current workfile.""" + return get_workfile_metadata(SECTION_NAME_INSTANCES) + + +def write_instances(data): + return write_workfile_metadata(SECTION_NAME_INSTANCES, data) + + +# Backwards compatibility +def _write_instances(*args, **kwargs): + return write_instances(*args, **kwargs) + + +def ls(): + return get_workfile_metadata(SECTION_NAME_CONTAINERS) + + +def on_instance_toggle(instance, old_value, new_value): + """Update instance data in workfile on publish toggle.""" + # Review may not have real instance in wokrfile metadata + if not instance.data.get("uuid"): + return + + instance_id = instance.data["uuid"] + found_idx = None + current_instances = list_instances() + for idx, workfile_instance in enumerate(current_instances): + if workfile_instance["uuid"] == instance_id: + found_idx = idx + break + + if found_idx is None: + return + + if "active" in current_instances[found_idx]: + current_instances[found_idx]["active"] = new_value + write_instances(current_instances) + + +def initial_launch(): + # Setup project settings if its the template that's launched. + # TODO also check for template creation when it's possible to define + # templates + last_workfile = os.environ.get("AVALON_LAST_WORKFILE") + if not last_workfile or os.path.exists(last_workfile): + return + + log.info("Setting up project...") + set_context_settings() + + +def application_exit(): + data = get_current_project_settings() + stop_timer = data["tvpaint"]["stop_timer_on_application_exit"] + + if not stop_timer: + return + + # Stop application timer. + webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") + rest_api_url = "{}/timers_manager/stop_timer".format(webserver_url) + requests.post(rest_api_url) + + +def set_context_settings(asset_doc=None): + """Set workfile settings by asset document data. + + Change fps, resolution and frame start/end. + """ + if asset_doc is None: + # Use current session asset if not passed + asset_doc = avalon.io.find_one({ + "type": "asset", + "name": avalon.io.Session["AVALON_ASSET"] + }) + + project_doc = avalon.io.find_one({"type": "project"}) + + framerate = asset_doc["data"].get("fps") + if framerate is None: + framerate = project_doc["data"].get("fps") + + if framerate is not None: + execute_george( + "tv_framerate {} \"timestretch\"".format(framerate) + ) + else: + print("Framerate was not found!") + + width_key = "resolutionWidth" + height_key = "resolutionHeight" + + width = asset_doc["data"].get(width_key) + height = asset_doc["data"].get(height_key) + if width is None or height is None: + width = project_doc["data"].get(width_key) + height = project_doc["data"].get(height_key) + + if width is None or height is None: + print("Resolution was not found!") + else: + execute_george( + "tv_resizepage {} {} 0".format(width, height) + ) + + frame_start = asset_doc["data"].get("frameStart") + frame_end = asset_doc["data"].get("frameEnd") + + if frame_start is None or frame_end is None: + print("Frame range was not found!") + return + + handles = asset_doc["data"].get("handles") or 0 + handle_start = asset_doc["data"].get("handleStart") + handle_end = asset_doc["data"].get("handleEnd") + + if handle_start is None or handle_end is None: + handle_start = handles + handle_end = handles + + # Always start from 0 Mark In and set only Mark Out + mark_in = 0 + mark_out = mark_in + (frame_end - frame_start) + handle_start + handle_end + + execute_george("tv_markin {} set".format(mark_in)) + execute_george("tv_markout {} set".format(mark_out)) diff --git a/openpype/hosts/tvpaint/api/plugin.py b/openpype/hosts/tvpaint/api/plugin.py index e148e44a27..e65c25b8d1 100644 --- a/openpype/hosts/tvpaint/api/plugin.py +++ b/openpype/hosts/tvpaint/api/plugin.py @@ -1,8 +1,21 @@ +import re +import uuid + +import avalon.api + from openpype.api import PypeCreatorMixin -from avalon.tvpaint import pipeline +from openpype.hosts.tvpaint.api import ( + pipeline, + lib +) -class Creator(PypeCreatorMixin, pipeline.Creator): +class Creator(PypeCreatorMixin, avalon.api.Creator): + def __init__(self, *args, **kwargs): + super(Creator, self).__init__(*args, **kwargs) + # Add unified identifier created with `uuid` module + self.data["uuid"] = str(uuid.uuid4()) + @classmethod def get_dynamic_data(cls, *args, **kwargs): dynamic_data = super(Creator, cls).get_dynamic_data(*args, **kwargs) @@ -17,3 +30,95 @@ class Creator(PypeCreatorMixin, pipeline.Creator): if "task" not in dynamic_data and task_name: dynamic_data["task"] = task_name return dynamic_data + + @staticmethod + def are_instances_same(instance_1, instance_2): + """Compare instances but skip keys with unique values. + + During compare are skiped keys that will be 100% sure + different on new instance, like "id". + + Returns: + bool: True if instances are same. + """ + if ( + not isinstance(instance_1, dict) + or not isinstance(instance_2, dict) + ): + return instance_1 == instance_2 + + checked_keys = set() + checked_keys.add("id") + for key, value in instance_1.items(): + if key not in checked_keys: + if key not in instance_2: + return False + if value != instance_2[key]: + return False + checked_keys.add(key) + + for key in instance_2.keys(): + if key not in checked_keys: + return False + return True + + def write_instances(self, data): + self.log.debug( + "Storing instance data to workfile. {}".format(str(data)) + ) + return pipeline.write_instances(data) + + def process(self): + data = pipeline.list_instances() + data.append(self.data) + self.write_instances(data) + + +class Loader(avalon.api.Loader): + hosts = ["tvpaint"] + + @staticmethod + def get_members_from_container(container): + if "members" not in container and "objectName" in container: + # Backwards compatibility + layer_ids_str = container.get("objectName") + return [ + int(layer_id) for layer_id in layer_ids_str.split("|") + ] + return container["members"] + + def get_unique_layer_name(self, asset_name, name): + """Layer name with counter as suffix. + + Find higher 3 digit suffix from all layer names in scene matching regex + `{asset_name}_{name}_{suffix}`. Higher 3 digit suffix is used + as base for next number if scene does not contain layer matching regex + `0` is used ase base. + + Args: + asset_name (str): Name of subset's parent asset document. + name (str): Name of loaded subset. + + Returns: + (str): `{asset_name}_{name}_{higher suffix + 1}` + """ + layer_name_base = "{}_{}".format(asset_name, name) + + counter_regex = re.compile(r"_(\d{3})$") + + higher_counter = 0 + for layer in lib.get_layers_data(): + layer_name = layer["name"] + if not layer_name.startswith(layer_name_base): + continue + number_subpart = layer_name[len(layer_name_base):] + groups = counter_regex.findall(number_subpart) + if len(groups) != 1: + continue + + counter = int(groups[0]) + if counter > higher_counter: + higher_counter = counter + continue + + return "{}_{:0>3d}".format(layer_name_base, higher_counter + 1) diff --git a/openpype/hosts/tvpaint/api/workio.py b/openpype/hosts/tvpaint/api/workio.py new file mode 100644 index 0000000000..c513bec6cf --- /dev/null +++ b/openpype/hosts/tvpaint/api/workio.py @@ -0,0 +1,55 @@ +"""Host API required for Work Files. +# TODO @iLLiCiT implement functions: + has_unsaved_changes +""" + +from avalon import api +from .lib import ( + execute_george, + execute_george_through_file +) +from .pipeline import save_current_workfile_context + + +def open_file(filepath): + """Open the scene file in Blender.""" + george_script = "tv_LoadProject '\"'\"{}\"'\"'".format( + filepath.replace("\\", "/") + ) + return execute_george_through_file(george_script) + + +def save_file(filepath): + """Save the open scene file.""" + # Store context to workfile before save + context = { + "project": api.Session["AVALON_PROJECT"], + "asset": api.Session["AVALON_ASSET"], + "task": api.Session["AVALON_TASK"] + } + save_current_workfile_context(context) + + # Execute george script to save workfile. + george_script = "tv_SaveProject {}".format(filepath.replace("\\", "/")) + return execute_george(george_script) + + +def current_file(): + """Return the path of the open scene file.""" + george_script = "tv_GetProjectName" + return execute_george(george_script) + + +def has_unsaved_changes(): + """Does the open scene file have unsaved changes?""" + return False + + +def file_extensions(): + """Return the supported file extensions for Blender scene files.""" + return api.HOST_WORKFILE_EXTENSIONS["tvpaint"] + + +def work_root(session): + """Return the default root to browse for work files.""" + return session["AVALON_WORKDIR"] diff --git a/openpype/hosts/tvpaint/hooks/pre_launch_args.py b/openpype/hosts/tvpaint/hooks/pre_launch_args.py index b0b13529ca..2a8f49d5b0 100644 --- a/openpype/hosts/tvpaint/hooks/pre_launch_args.py +++ b/openpype/hosts/tvpaint/hooks/pre_launch_args.py @@ -4,7 +4,7 @@ import shutil from openpype.hosts import tvpaint from openpype.lib import ( PreLaunchHook, - get_pype_execute_args + get_openpype_execute_args ) import avalon @@ -30,7 +30,7 @@ class TvpaintPrelaunchHook(PreLaunchHook): while self.launch_context.launch_args: remainders.append(self.launch_context.launch_args.pop(0)) - new_launch_args = get_pype_execute_args( + new_launch_args = get_openpype_execute_args( "run", self.launch_script_path(), executable_path ) @@ -44,10 +44,6 @@ class TvpaintPrelaunchHook(PreLaunchHook): self.launch_context.launch_args.extend(remainders) def launch_script_path(self): - avalon_dir = os.path.dirname(os.path.abspath(avalon.__file__)) - script_path = os.path.join( - avalon_dir, - "tvpaint", - "launch_script.py" - ) - return script_path \ No newline at end of file + from openpype.hosts.tvpaint import get_launch_script_path + + return get_launch_script_path() diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py index af6c0f0eee..40a7d15990 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py @@ -1,11 +1,12 @@ from avalon.api import CreatorError -from avalon.tvpaint import ( + +from openpype.lib import prepare_template_data +from openpype.hosts.tvpaint.api import ( + plugin, pipeline, lib, CommunicationWrapper ) -from openpype.hosts.tvpaint.api import plugin -from openpype.lib import prepare_template_data class CreateRenderlayer(plugin.Creator): @@ -56,7 +57,7 @@ class CreateRenderlayer(plugin.Creator): # Validate that communication is initialized if CommunicationWrapper.communicator: # Get currently selected layers - layers_data = lib.layers_data() + layers_data = lib.get_layers_data() selected_layers = [ layer @@ -75,7 +76,7 @@ class CreateRenderlayer(plugin.Creator): def process(self): self.log.debug("Query data from workfile.") instances = pipeline.list_instances() - layers_data = lib.layers_data() + layers_data = lib.get_layers_data() self.log.debug("Checking for selection groups.") # Collect group ids from selection @@ -102,7 +103,7 @@ class CreateRenderlayer(plugin.Creator): self.log.debug(f"Selected group id is \"{group_id}\".") self.data["group_id"] = group_id - group_data = lib.groups_data() + group_data = lib.get_groups_data() group_name = None for group in group_data: if group["group_id"] == group_id: @@ -169,7 +170,7 @@ class CreateRenderlayer(plugin.Creator): return self.log.debug("Querying groups data from workfile.") - groups_data = lib.groups_data() + groups_data = lib.get_groups_data() self.log.debug("Changing name of the group.") selected_group = None @@ -196,6 +197,7 @@ class CreateRenderlayer(plugin.Creator): ) def _ask_user_subset_override(self, instance): + from Qt import QtCore from Qt.QtWidgets import QMessageBox title = "Subset \"{}\" already exist".format(instance["subset"]) @@ -205,6 +207,10 @@ class CreateRenderlayer(plugin.Creator): ).format(instance["subset"]) dialog = QMessageBox() + dialog.setWindowFlags( + dialog.windowFlags() + | QtCore.Qt.WindowStaysOnTopHint + ) dialog.setWindowTitle(title) dialog.setText(text) dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.No) diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py index ad06520210..af962052fc 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py @@ -1,11 +1,11 @@ from avalon.api import CreatorError -from avalon.tvpaint import ( +from openpype.lib import prepare_template_data +from openpype.hosts.tvpaint.api import ( + plugin, pipeline, lib, CommunicationWrapper ) -from openpype.hosts.tvpaint.api import plugin -from openpype.lib import prepare_template_data class CreateRenderPass(plugin.Creator): diff --git a/openpype/hosts/tvpaint/plugins/load/load_image.py b/openpype/hosts/tvpaint/plugins/load/load_image.py index f77fab87f8..1246fe8248 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_image.py @@ -1,8 +1,8 @@ from avalon.vendor import qargparse -from avalon.tvpaint import lib, pipeline +from openpype.hosts.tvpaint.api import lib, plugin -class ImportImage(pipeline.Loader): +class ImportImage(plugin.Loader): """Load image or image sequence to TVPaint as new layer.""" families = ["render", "image", "background", "plate"] diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py index b8b20ed20a..b5e0a86686 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py @@ -1,10 +1,10 @@ import collections from avalon.pipeline import get_representation_context from avalon.vendor import qargparse -from avalon.tvpaint import lib, pipeline +from openpype.hosts.tvpaint.api import lib, pipeline, plugin -class LoadImage(pipeline.Loader): +class LoadImage(plugin.Loader): """Load image or image sequence to TVPaint as new layer.""" families = ["render", "image", "background", "plate"] diff --git a/openpype/hosts/tvpaint/plugins/load/load_sound.py b/openpype/hosts/tvpaint/plugins/load/load_sound.py index c83748fe06..3f42370f5c 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_sound.py +++ b/openpype/hosts/tvpaint/plugins/load/load_sound.py @@ -1,9 +1,9 @@ import os import tempfile -from avalon.tvpaint import lib, pipeline +from openpype.hosts.tvpaint.api import lib, plugin -class ImportSound(pipeline.Loader): +class ImportSound(plugin.Loader): """Load sound to TVPaint. Sound layers does not have ids but only position index so we can't diff --git a/openpype/hosts/tvpaint/plugins/load/load_workfile.py b/openpype/hosts/tvpaint/plugins/load/load_workfile.py index f410a1ab9d..33e2a76cc9 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_workfile.py +++ b/openpype/hosts/tvpaint/plugins/load/load_workfile.py @@ -1,16 +1,16 @@ import getpass import os -from avalon.tvpaint import lib, pipeline, get_current_workfile_context from avalon import api, io from openpype.lib import ( get_workfile_template_key_from_context, get_workdir_data ) from openpype.api import Anatomy +from openpype.hosts.tvpaint.api import lib, pipeline, plugin -class LoadWorkfile(pipeline.Loader): +class LoadWorkfile(plugin.Loader): """Load workfile.""" families = ["workfile"] @@ -24,7 +24,7 @@ class LoadWorkfile(pipeline.Loader): host = api.registered_host() current_file = host.current_file() - context = get_current_workfile_context() + context = pipeline.get_current_workfile_context() filepath = self.fname.replace("\\", "/") diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 1d7a48e389..31d2fd1fd5 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -218,7 +218,7 @@ class CollectInstances(pyblish.api.ContextPlugin): # - not 100% working as it was found out that layer ids can't be # used as unified identifier across multiple workstations layers_by_id = { - layer["id"]: layer + layer["layer_id"]: layer for layer in layers_data } layer_ids = instance_data["layer_ids"] diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index f4259f1b5f..f5c86c613b 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -4,7 +4,7 @@ import tempfile import pyblish.api import avalon.api -from avalon.tvpaint import pipeline, lib +from openpype.hosts.tvpaint.api import pipeline, lib class ResetTVPaintWorkfileMetadata(pyblish.api.Action): diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 6235b6211d..b6b8bd0d9e 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -2,17 +2,18 @@ import os import copy import tempfile +from PIL import Image + import pyblish.api -from avalon.tvpaint import lib -from openpype.hosts.tvpaint.api.lib import composite_images +from openpype.hosts.tvpaint.api import lib from openpype.hosts.tvpaint.lib import ( calculate_layers_extraction_data, get_frame_filename_template, fill_reference_frames, composite_rendered_layers, - rename_filepaths_by_frame_start + rename_filepaths_by_frame_start, + composite_images ) -from PIL import Image class ExtractSequence(pyblish.api.Extractor): diff --git a/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py index a96a8e3d5d..c9f2434cef 100644 --- a/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/tvpaint/plugins/publish/increment_workfile_version.py @@ -1,7 +1,7 @@ import pyblish.api -from avalon.tvpaint import workio from openpype.api import version_up +from openpype.hosts.tvpaint.api import workio class IncrementWorkfileVersion(pyblish.api.ContextPlugin): diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py index 4ce8d5347d..0fdeba0a21 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_asset_name.py @@ -1,5 +1,5 @@ import pyblish.api -from avalon.tvpaint import pipeline +from openpype.hosts.tvpaint.api import pipeline class FixAssetNames(pyblish.api.Action): diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py index e2ef81e4a4..9d55bb21a9 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py @@ -1,7 +1,7 @@ import json import pyblish.api -from avalon.tvpaint import lib +from openpype.hosts.tvpaint.api import lib class ValidateMarksRepair(pyblish.api.Action): diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py index d769d47736..e2f8386757 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_start_frame.py @@ -1,5 +1,5 @@ import pyblish.api -from avalon.tvpaint import lib +from openpype.hosts.tvpaint.api import lib class RepairStartFrame(pyblish.api.Action): diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py index 757da3294a..48fbeedb59 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_workfile_metadata.py @@ -1,5 +1,5 @@ import pyblish.api -from avalon.tvpaint import save_file +from openpype.hosts.tvpaint.api import save_file class ValidateWorkfileMetadataRepair(pyblish.api.Action): diff --git a/openpype/hosts/tvpaint/resources/avalon.loc b/openpype/hosts/tvpaint/resources/avalon.loc deleted file mode 100644 index 3cfb7e9db4..0000000000 --- a/openpype/hosts/tvpaint/resources/avalon.loc +++ /dev/null @@ -1,37 +0,0 @@ -#------------------------------------------------- -#------------ AVALON PLUGIN LOC FILE ------------- -#------------------------------------------------- - -#Language : English -#Version : 1.0 -#Date : 27/10/2020 - -#------------------------------------------------- -#------------ COMMON ----------------------------- -#------------------------------------------------- - -$100 "OpenPype Tools" - -$10010 "Workfiles" -$10020 "Load" -$10030 "Create" -$10040 "Scene inventory" -$10050 "Publish" -$10060 "Library" - -#------------ Help ------------------------------- - -$20010 "Open workfiles tool" -$20020 "Open loader tool" -$20030 "Open creator tool" -$20040 "Open scene inventory tool" -$20050 "Open publisher" -$20060 "Open library loader tool" - -#------------ Errors ----------------------------- - -$30001 "Can't Open Requester !" - -#------------------------------------------------- -#------------ END -------------------------------- -#------------------------------------------------- diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/__init__.py b/openpype/hosts/tvpaint/tvpaint_plugin/__init__.py new file mode 100644 index 0000000000..59a7aaf99b --- /dev/null +++ b/openpype/hosts/tvpaint/tvpaint_plugin/__init__.py @@ -0,0 +1,6 @@ +import os + + +def get_plugin_files_path(): + current_dir = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(current_dir, "plugin_files") diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/CMakeLists.txt b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/CMakeLists.txt new file mode 100644 index 0000000000..ecd94acc99 --- /dev/null +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.17) +project(OpenPypePlugin C CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(IP_ENABLE_UNICODE OFF) +set(IP_ENABLE_DOCTEST OFF) + +if(MSVC) + set(CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON) + add_definitions(-D_CRT_SECURE_NO_WARNINGS) +endif() + +# TODO better options +option(BOOST_ROOT "Path to root of Boost" "") + +option(OPENSSL_INCLUDE "OpenSSL include path" "") +option(OPENSSL_LIB_DIR "OpenSSL lib path" "") + +option(WEBSOCKETPP_INCLUDE "Websocketpp include path" "") +option(WEBSOCKETPP_LIB_DIR "Websocketpp lib path" "") + +option(JSONRPCPP_INCLUDE "Jsonrpcpp include path" "") + +find_package(Boost 1.72.0 COMPONENTS random) + +include_directories( + "${TVPAINT_SDK_INCLUDE}" + "${OPENSSL_INCLUDE}" + "${WEBSOCKETPP_INCLUDE}" + "${JSONRPCPP_INCLUDE}" + "${Boost_INCLUDE_DIR}" +) +link_directories( + "${OPENSSL_LIB_DIR}" + "${WEBSOCKETPP_LIB_DIR}" +) + +add_library(jsonrpcpp INTERFACE) + +add_library(${PROJECT_NAME} SHARED library.cpp library.def "${TVPAINT_SDK_LIB}/dllx.c") + +target_link_libraries(${PROJECT_NAME} ${Boost_LIBRARIES}) +target_link_libraries(${PROJECT_NAME} jsonrpcpp) diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md new file mode 100644 index 0000000000..03b0a31f51 --- /dev/null +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/README.md @@ -0,0 +1,34 @@ +README for TVPaint Avalon plugin +================================ +Introduction +------------ +This project is dedicated to integrate Avalon functionality to TVPaint. +This implementaiton is using TVPaint plugin (C/C++) which can communicate with python process. The communication should allow to trigger tools or pipeline functions from TVPaint and accept requests from python process at the same time. + +Current implementation is based on websocket protocol, using json-rpc communication (specification 2.0). Project is in beta stage, tested only on Windows. + +To be able to load plugin, environment variable `WEBSOCKET_URL` must be set otherwise plugin won't load at all. Plugin should not affect TVPaint if python server crash, but buttons won't work. + +## Requirements - Python server +- python >= 3.6 +- aiohttp +- aiohttp-json-rpc + +### Windows +- pywin32 - required only for plugin installation + +## Requirements - Plugin compilation +- TVPaint SDK - Ask for SDK on TVPaint support. +- Boost 1.72.0 - Boost is used across other plugins (Should be possible to use different version with CMakeLists modification) +- Websocket++/Websocketpp - Websocket library (https://github.com/zaphoyd/websocketpp) +- OpenSSL library - Required by Websocketpp +- jsonrpcpp - C++ library handling json-rpc 2.0 (https://github.com/badaix/jsonrpcpp) +- nlohmann/json - Required for jsonrpcpp (https://github.com/nlohmann/json) + +### jsonrpcpp +This library has `nlohmann/json` as it's part, but current `master` has old version which has bug and probably won't be possible to use library on windows without using last `nlohmann/json`. + +## TODO +- modify code and CMake to be able to compile on MacOS/Linux +- separate websocket logic from plugin logic +- hide buttons and show error message if server is closed diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp new file mode 100644 index 0000000000..a57124084b --- /dev/null +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.cpp @@ -0,0 +1,790 @@ +#ifdef _WIN32 +// Include before +#include +#endif + +#include +#include +#include +#include +#include +#include +#include + +#include "plugdllx.h" + +#include + +#include +#include + +#include "json.hpp" +#include "jsonrpcpp.hpp" + + +// All functions not exported should be static. +// All global variables should be static. + +// mReq Identification of the requester. (=0 closed, !=0 requester ID) +static struct { + bool firstParams; + DWORD mReq; + void* mLocalFile; + PIFilter *current_filter; + // Id counter for client requests + int client_request_id; + // There are new menu items + bool newMenuItems; + // Menu item definitions received from connection + nlohmann::json menuItems; + // Menu items used in requester by their ID + nlohmann::json menuItemsById; + std::list menuItemsIds; + // Messages from server before processing. + // - messages can't be process at the moment of recieve as client is running in thread + std::queue messages; + // Responses to requests mapped by request id + std::map responses; + +} Data = { + true, + 0, + nullptr, + nullptr, + 1, + false, + nlohmann::json::object(), + nlohmann::json::object() +}; + +// Json rpc 2.0 parser - for handling messages and callbacks +jsonrpcpp::Parser parser; +typedef websocketpp::client client; + + +class connection_metadata { +private: + websocketpp::connection_hdl m_hdl; + client *m_endpoint; + std::string m_status; +public: + typedef websocketpp::lib::shared_ptr ptr; + + connection_metadata(websocketpp::connection_hdl hdl, client *endpoint) + : m_hdl(hdl), m_status("Connecting") { + m_endpoint = endpoint; + } + + void on_open(client *c, websocketpp::connection_hdl hdl) { + m_status = "Open"; + } + + void on_fail(client *c, websocketpp::connection_hdl hdl) { + m_status = "Failed"; + } + + void on_close(client *c, websocketpp::connection_hdl hdl) { + m_status = "Closed"; + } + + void on_message(websocketpp::connection_hdl, client::message_ptr msg) { + std::string json_str; + if (msg->get_opcode() == websocketpp::frame::opcode::text) { + json_str = msg->get_payload(); + } else { + json_str = websocketpp::utility::to_hex(msg->get_payload()); + } + process_message(json_str); + } + + void process_message(std::string msg) { + std::cout << "--> " << msg << "\n"; + try { + jsonrpcpp::entity_ptr entity = parser.do_parse(msg); + if (!entity) { + // Return error code? + + } else if (entity->is_response()) { + jsonrpcpp::Response response = jsonrpcpp::Response(entity->to_json()); + Data.responses[response.id().int_id()] = response; + + } else if (entity->is_request() || entity->is_notification()) { + Data.messages.push(msg); + } + } + catch (const jsonrpcpp::RequestException &e) { + std::string message = e.to_json().dump(); + std::cout << "<-- " << e.to_json().dump() << "\n"; + send(message); + } + catch (const jsonrpcpp::ParseErrorException &e) { + std::string message = e.to_json().dump(); + std::cout << "<-- " << message << "\n"; + send(message); + } + catch (const jsonrpcpp::RpcException &e) { + std::cerr << "RpcException: " << e.what() << "\n"; + std::string message = jsonrpcpp::ParseErrorException(e.what()).to_json().dump(); + std::cout << "<-- " << message << "\n"; + send(message); + } + catch (const std::exception &e) { + std::cerr << "Exception: " << e.what() << "\n"; + } + } + + void send(std::string message) { + if (get_status() != "Open") { + return; + } + websocketpp::lib::error_code ec; + + m_endpoint->send(m_hdl, message, websocketpp::frame::opcode::text, ec); + if (ec) { + std::cout << "> Error sending message: " << ec.message() << std::endl; + return; + } + } + + void send_notification(jsonrpcpp::Notification *notification) { + send(notification->to_json().dump()); + } + + void send_response(jsonrpcpp::Response *response) { + send(response->to_json().dump()); + } + + void send_request(jsonrpcpp::Request *request) { + send(request->to_json().dump()); + } + + websocketpp::connection_hdl get_hdl() const { + return m_hdl; + } + + std::string get_status() const { + return m_status; + } +}; + + +class websocket_endpoint { +private: + client m_endpoint; + connection_metadata::ptr client_metadata; + websocketpp::lib::shared_ptr m_thread; + bool thread_is_running = false; + +public: + websocket_endpoint() { + m_endpoint.clear_access_channels(websocketpp::log::alevel::all); + m_endpoint.clear_error_channels(websocketpp::log::elevel::all); + } + + ~websocket_endpoint() { + close_connection(); + } + + void close_connection() { + m_endpoint.stop_perpetual(); + if (connected()) + { + // Close client + close(websocketpp::close::status::normal, ""); + } + if (thread_is_running) { + // Join thread + m_thread->join(); + thread_is_running = false; + } + } + + bool connected() + { + return (client_metadata && client_metadata->get_status() == "Open"); + } + int connect(std::string const &uri) { + if (client_metadata && client_metadata->get_status() == "Open") { + std::cout << "> Already connected" << std::endl; + return 0; + } + + m_endpoint.init_asio(); + m_endpoint.start_perpetual(); + + m_thread.reset(new websocketpp::lib::thread(&client::run, &m_endpoint)); + thread_is_running = true; + + websocketpp::lib::error_code ec; + + client::connection_ptr con = m_endpoint.get_connection(uri, ec); + + if (ec) { + std::cout << "> Connect initialization error: " << ec.message() << std::endl; + return -1; + } + + client_metadata = websocketpp::lib::make_shared(con->get_handle(), &m_endpoint); + + con->set_open_handler(websocketpp::lib::bind( + &connection_metadata::on_open, + client_metadata, + &m_endpoint, + websocketpp::lib::placeholders::_1 + )); + con->set_fail_handler(websocketpp::lib::bind( + &connection_metadata::on_fail, + client_metadata, + &m_endpoint, + websocketpp::lib::placeholders::_1 + )); + con->set_close_handler(websocketpp::lib::bind( + &connection_metadata::on_close, + client_metadata, + &m_endpoint, + websocketpp::lib::placeholders::_1 + )); + con->set_message_handler(websocketpp::lib::bind( + &connection_metadata::on_message, + client_metadata, + websocketpp::lib::placeholders::_1, + websocketpp::lib::placeholders::_2 + )); + + m_endpoint.connect(con); + + return 1; + } + + void close(websocketpp::close::status::value code, std::string reason) { + if (!client_metadata || client_metadata->get_status() != "Open") { + std::cout << "> Not connected yet" << std::endl; + return; + } + + websocketpp::lib::error_code ec; + + m_endpoint.close(client_metadata->get_hdl(), code, reason, ec); + if (ec) { + std::cout << "> Error initiating close: " << ec.message() << std::endl; + } + } + + void send(std::string message) { + if (!client_metadata || client_metadata->get_status() != "Open") { + std::cout << "> Not connected yet" << std::endl; + return; + } + + client_metadata->send(message); + } + + void send_notification(jsonrpcpp::Notification *notification) { + client_metadata->send_notification(notification); + } + + void send_response(jsonrpcpp::Response *response) { + client_metadata->send(response->to_json().dump()); + } + + void send_response(std::shared_ptr response) { + client_metadata->send(response->to_json().dump()); + } + + void send_request(jsonrpcpp::Request *request) { + client_metadata->send_request(request); + } +}; + +class Communicator { +private: + // URL to websocket server + std::string websocket_url; + // Should be avalon plugin available? + // - this may change during processing if websocketet url is not set or server is down + bool use_avalon; +public: + Communicator(); + websocket_endpoint endpoint; + bool is_connected(); + bool is_usable(); + void connect(); + void process_requests(); + jsonrpcpp::Response call_method(std::string method_name, nlohmann::json params); + void call_notification(std::string method_name, nlohmann::json params); +}; + +Communicator::Communicator() { + // URL to websocket server + websocket_url = std::getenv("WEBSOCKET_URL"); + // Should be avalon plugin available? + // - this may change during processing if websocketet url is not set or server is down + if (websocket_url == "") { + use_avalon = false; + } else { + use_avalon = true; + } +} + +bool Communicator::is_connected(){ + return endpoint.connected(); +} + +bool Communicator::is_usable(){ + return use_avalon; +} + +void Communicator::connect() +{ + if (!use_avalon) { + return; + } + int con_result; + con_result = endpoint.connect(websocket_url); + if (con_result == -1) + { + use_avalon = false; + } else { + use_avalon = true; + } +} + +void Communicator::call_notification(std::string method_name, nlohmann::json params) { + if (!use_avalon || !is_connected()) {return;} + + jsonrpcpp::Notification notification = {method_name, params}; + endpoint.send_notification(¬ification); +} + +jsonrpcpp::Response Communicator::call_method(std::string method_name, nlohmann::json params) { + jsonrpcpp::Response response; + if (!use_avalon || !is_connected()) + { + return response; + } + int request_id = Data.client_request_id++; + jsonrpcpp::Request request = {request_id, method_name, params}; + endpoint.send_request(&request); + + bool found = false; + while (!found) { + std::map::iterator iter = Data.responses.find(request_id); + if (iter != Data.responses.end()) { + //element found == was found response + response = iter->second; + Data.responses.erase(request_id); + found = true; + } else { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + return response; +} + +void Communicator::process_requests() { + if (!use_avalon || !is_connected() || Data.messages.empty()) {return;} + + std::string msg = Data.messages.front(); + Data.messages.pop(); + std::cout << "Parsing: " << msg << std::endl; + // TODO: add try->except block + auto response = parser.parse(msg); + if (response->is_response()) { + endpoint.send_response(response); + } else { + jsonrpcpp::request_ptr request = std::dynamic_pointer_cast(response); + jsonrpcpp::Error error("Method \"" + request->method() + "\" not found", -32601); + jsonrpcpp::Response _response(request->id(), error); + endpoint.send_response(&_response); + } +} + +jsonrpcpp::response_ptr define_menu(const jsonrpcpp::Id &id, const jsonrpcpp::Parameter ¶ms) { + /* Define plugin menu. + + Menu is defined with json with "title" and "menu_items". + Each item in "menu_items" must have keys: + - "callback" - callback called with RPC when button is clicked + - "label" - label of button + - "help" - tooltip of button + ``` + { + "title": "< Menu title>", + "menu_items": [ + { + "callback": "workfiles_tool", + "label": "Workfiles", + "help": "Open workfiles tool" + }, + ... + ] + } + ``` + */ + Data.menuItems = params.to_json()[0]; + Data.newMenuItems = true; + + std::string output; + + return std::make_shared(id, output); +} + +jsonrpcpp::response_ptr execute_george(const jsonrpcpp::Id &id, const jsonrpcpp::Parameter ¶ms) { + const char *george_script; + char cmd_output[1024] = {0}; + char empty_char = {0}; + std::string std_george_script; + std::string output; + + nlohmann::json json_params = params.to_json(); + std_george_script = json_params[0]; + george_script = std_george_script.c_str(); + + // Result of `TVSendCmd` is int with length of output string + TVSendCmd(Data.current_filter, george_script, cmd_output); + + for (int i = 0; i < sizeof(cmd_output); i++) + { + if (cmd_output[i] == empty_char){ + break; + } + output += cmd_output[i]; + } + return std::make_shared(id, output); +} + +void register_callbacks(){ + parser.register_request_callback("define_menu", define_menu); + parser.register_request_callback("execute_george", execute_george); +} + +Communicator communication; + +//////////////////////////////////////////////////////////////////////////////////////// + +static char* GetLocalString( PIFilter* iFilter, int iNum, char* iDefault ) +{ + char* str; + + if( Data.mLocalFile == NULL ) + return iDefault; + + str = TVGetLocalString( iFilter, Data.mLocalFile, iNum ); + if( str == NULL || strlen( str ) == 0 ) + return iDefault; + + return str; +} + +/**************************************************************************************/ +// Localisation + +// numbers (like 10011) are IDs in the localized file. +// strings are the default values to use when the ID is not found +// in the localized file (or the localized file doesn't exist). +std::string label_from_evn() +{ + std::string _plugin_label = "Avalon"; + if (std::getenv("AVALON_LABEL") && std::getenv("AVALON_LABEL") != "") + { + _plugin_label = std::getenv("AVALON_LABEL"); + } + return _plugin_label; +} +std::string plugin_label = label_from_evn(); + +#define TXT_REQUESTER GetLocalString( iFilter, 100, "OpenPype Tools" ) + +#define TXT_REQUESTER_ERROR GetLocalString( iFilter, 30001, "Can't Open Requester !" ) + +//////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////////////////////////////////// + +// The functions directly called by Aura through the plugin interface + + + +/**************************************************************************************/ +// "About" function. + + +void FAR PASCAL PI_About( PIFilter* iFilter ) +{ + char text[256]; + + sprintf( text, "%s %d,%d", iFilter->PIName, iFilter->PIVersion, iFilter->PIRevision ); + + // Just open a warning popup with the filter name and version. + // You can open a much nicer requester if you want. + TVWarning( iFilter, text ); +} + + +/**************************************************************************************/ +// Function called at Aura startup, when the filter is loaded. +// Should do as little as possible to keep Aura's startup time small. + +int FAR PASCAL PI_Open( PIFilter* iFilter ) +{ + Data.current_filter = iFilter; + char tmp[256]; + + strcpy( iFilter->PIName, plugin_label.c_str() ); + iFilter->PIVersion = 1; + iFilter->PIRevision = 0; + + // If this plugin was the one open at Aura shutdown, re-open it + TVReadUserString( iFilter, iFilter->PIName, "Open", tmp, "0", 255 ); + if( atoi( tmp ) ) + { + PI_Parameters( iFilter, NULL ); // NULL as iArg means "open the requester" + } + + communication.connect(); + register_callbacks(); + return 1; // OK +} + + +/**************************************************************************************/ +// Aura shutdown: we make all the necessary cleanup + +void FAR PASCAL PI_Close( PIFilter* iFilter ) +{ + if( Data.mLocalFile ) + { + TVCloseLocalFile( iFilter, Data.mLocalFile ); + } + if( Data.mReq ) + { + TVCloseReq( iFilter, Data.mReq ); + } + communication.endpoint.close_connection(); +} + + +/**************************************************************************************/ +// we have something to do ! + +int FAR PASCAL PI_Parameters( PIFilter* iFilter, char* iArg ) +{ + if( !iArg ) + { + + // If the requester is not open, we open it. + if( Data.mReq == 0) + { + // Create empty requester because menu items are defined with + // `define_menu` callback + DWORD req = TVOpenFilterReqEx( + iFilter, + 185, + 20, + NULL, + NULL, + PIRF_STANDARD_REQ | PIRF_COLLAPSABLE_REQ, + FILTERREQ_NO_TBAR + ); + if( req == 0 ) + { + TVWarning( iFilter, TXT_REQUESTER_ERROR ); + return 0; + } + + + Data.mReq = req; + // This is a very simple requester, so we create it's content right here instead + // of waiting for the PICBREQ_OPEN message... + // Not recommended for more complex requesters. (see the other examples) + + // Sets the title of the requester. + TVSetReqTitle( iFilter, Data.mReq, TXT_REQUESTER ); + // Request to listen to ticks + TVGrabTicks(iFilter, req, PITICKS_FLAG_ON); + } + else + { + // If it is already open, we just put it on front of all other requesters. + TVReqToFront( iFilter, Data.mReq ); + } + } + + return 1; +} + + +int newMenuItemsProcess(PIFilter* iFilter) { + // Menu items defined with `define_menu` should be propagated. + + // Change flag that there are new menu items (avoid infinite loop) + Data.newMenuItems = false; + // Skip if requester does not exists + if (Data.mReq == 0) { + return 0; + } + // Remove all previous menu items + for (int menu_id : Data.menuItemsIds) + { + TVRemoveButtonReq(iFilter, Data.mReq, menu_id); + } + // Clear caches + Data.menuItemsById.clear(); + Data.menuItemsIds.clear(); + + // We use a variable to contains the vertical position of the buttons. + // Each time we create a button, we add its size to this variable. + // This makes it very easy to add/remove/displace buttons in a requester. + int x_pos = 9; + int y_pos = 5; + + // Menu width + int menu_width = 185; + // Single menu item width + int btn_width = menu_width - 19; + // Single row height (btn height is 18) + int row_height = 20; + // Additional height to menu + int height_offset = 5; + + // This is a very simple requester, so we create it's content right here instead + // of waiting for the PICBREQ_OPEN message... + // Not recommended for more complex requesters. (see the other examples) + + const char *menu_title = TXT_REQUESTER; + if (Data.menuItems.contains("title")) + { + menu_title = Data.menuItems["title"].get()->c_str(); + } + // Sets the title of the requester. + TVSetReqTitle( iFilter, Data.mReq, menu_title ); + + // Resize menu + // First get current position and sizes (we only need the position) + int current_x = 0; + int current_y = 0; + int current_width = 0; + int current_height = 0; + TVInfoReq(iFilter, Data.mReq, ¤t_x, ¤t_y, ¤t_width, ¤t_height); + + // Calculate new height + int menu_height = (row_height * Data.menuItems["menu_items"].size()) + height_offset; + // Resize + TVResizeReq(iFilter, Data.mReq, current_x, current_y, menu_width, menu_height); + + // Add menu items + int item_counter = 1; + for (auto& item : Data.menuItems["menu_items"].items()) + { + int item_id = item_counter * 10; + item_counter ++; + std::string item_id_str = std::to_string(item_id); + nlohmann::json item_data = item.value(); + const char *item_label = item_data["label"].get()->c_str(); + const char *help_text = item_data["help"].get()->c_str(); + std::string item_callback = item_data["callback"].get(); + TVAddButtonReq(iFilter, Data.mReq, x_pos, y_pos, btn_width, 0, item_id, PIRBF_BUTTON_NORMAL|PIRBF_BUTTON_ACTION, item_label); + TVSetButtonInfoText( iFilter, Data.mReq, item_id, help_text ); + y_pos += row_height; + + Data.menuItemsById[std::to_string(item_id)] = item_callback; + Data.menuItemsIds.push_back(item_id); + } + + return 1; +} +/**************************************************************************************/ +// something happenned that needs our attention. +// Global variable where current button up data are stored +std::string button_up_item_id_str; +int FAR PASCAL PI_Msg( PIFilter* iFilter, INTPTR iEvent, INTPTR iReq, INTPTR* iArgs ) +{ + Data.current_filter = iFilter; + // what did happen ? + switch( iEvent ) + { + // The user just 'clicked' on a normal button + case PICBREQ_BUTTON_UP: + button_up_item_id_str = std::to_string(iArgs[0]); + if (Data.menuItemsById.contains(button_up_item_id_str)) + { + std::string callback_name = Data.menuItemsById[button_up_item_id_str].get(); + communication.call_method(callback_name, nlohmann::json::array()); + } + TVExecute( iFilter ); + break; + + // The requester was just closed. + case PICBREQ_CLOSE: + // requester doesn't exists anymore + Data.mReq = 0; + + char tmp[256]; + // Save the requester state (opened or closed) + // iArgs[4] contains a flag which tells us if the requester + // has been closed by the user (flag=0) or by Aura's shutdown (flag=1). + // If it was by Aura's shutdown, that means this requester was the + // last one open, so we should reopen this one the next time Aura + // is started. Else we won't open it next time. + sprintf( tmp, "%d", (int)(iArgs[4]) ); + + // Save it in Aura's init file. + TVWriteUserString( iFilter, iFilter->PIName, "Open", tmp ); + break; + + case PICBREQ_TICKS: + if (Data.newMenuItems) + { + newMenuItemsProcess(iFilter); + } + communication.process_requests(); + } + + return 1; +} + + +/**************************************************************************************/ +// Start of the 'execution' of the filter for a new sequence. +// - iNumImages contains the total number of frames to be processed. +// Here you should allocate memory that is used for all frames, +// and precompute all the stuff that doesn't change from frame to frame. + + +int FAR PASCAL PI_SequenceStart( PIFilter* iFilter, int iNumImages ) +{ + // In this simple example we don't have anything to allocate/precompute. + + // 1 means 'continue', 0 means 'error, abort' (like 'not enough memory') + return 1; +} + + +// Here you should cleanup what you've done in PI_SequenceStart + +void FAR PASCAL PI_SequenceFinish( PIFilter* iFilter ) +{} + + +/**************************************************************************************/ +// This is called before each frame. +// Here you should allocate memory and precompute all the stuff you can. + +int FAR PASCAL PI_Start( PIFilter* iFilter, double iPos, double iSize ) +{ + return 1; +} + + +void FAR PASCAL PI_Finish( PIFilter* iFilter ) +{ + // nothing special to cleanup +} + + +/**************************************************************************************/ +// 'Execution' of the filter. +int FAR PASCAL PI_Work( PIFilter* iFilter ) +{ + return 1; +} diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.def b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.def new file mode 100644 index 0000000000..882f2b4719 --- /dev/null +++ b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_code/library.def @@ -0,0 +1,10 @@ +LIBRARY Avalonplugin +EXPORTS + PI_Msg + PI_Open + PI_About + PI_Parameters + PI_Start + PI_Work + PI_Finish + PI_Close diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/additional_libraries/boost_random-vc142-mt-x64-1_72.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/additional_libraries/boost_random-vc142-mt-x64-1_72.dll new file mode 100644 index 0000000000..46bd533b72 Binary files /dev/null and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/additional_libraries/boost_random-vc142-mt-x64-1_72.dll differ diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll new file mode 100644 index 0000000000..293a7b19b0 Binary files /dev/null and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x64/plugin/OpenPypePlugin.dll differ diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/additional_libraries/boost_random-vc142-mt-x32-1_72.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/additional_libraries/boost_random-vc142-mt-x32-1_72.dll new file mode 100644 index 0000000000..ccf2fd8562 Binary files /dev/null and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/additional_libraries/boost_random-vc142-mt-x32-1_72.dll differ diff --git a/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll new file mode 100644 index 0000000000..9671d8a27b Binary files /dev/null and b/openpype/hosts/tvpaint/tvpaint_plugin/plugin_files/windows_x86/plugin/OpenPypePlugin.dll differ diff --git a/openpype/hosts/tvpaint/worker/worker.py b/openpype/hosts/tvpaint/worker/worker.py index 738656fa91..cfd40bc7ba 100644 --- a/openpype/hosts/tvpaint/worker/worker.py +++ b/openpype/hosts/tvpaint/worker/worker.py @@ -2,7 +2,7 @@ import signal import time import asyncio -from avalon.tvpaint.communication_server import ( +from openpype.hosts.tvpaint.api.communication_server import ( BaseCommunicator, CommunicationWrapper ) diff --git a/openpype/hosts/tvpaint/worker/worker_job.py b/openpype/hosts/tvpaint/worker/worker_job.py index c3893b6f2e..519d42ce73 100644 --- a/openpype/hosts/tvpaint/worker/worker_job.py +++ b/openpype/hosts/tvpaint/worker/worker_job.py @@ -256,7 +256,7 @@ class CollectSceneData(BaseCommand): name = "collect_scene_data" def execute(self): - from avalon.tvpaint.lib import ( + from openpype.hosts.tvpaint.api.lib import ( get_layers_data, get_groups_data, get_layers_pre_post_behavior, diff --git a/openpype/hosts/unreal/api/tools_ui.py b/openpype/hosts/unreal/api/tools_ui.py new file mode 100644 index 0000000000..93361c3574 --- /dev/null +++ b/openpype/hosts/unreal/api/tools_ui.py @@ -0,0 +1,158 @@ +import sys +from Qt import QtWidgets, QtCore, QtGui + +from openpype import ( + resources, + style +) +from openpype.tools.utils import host_tools +from openpype.tools.utils.lib import qt_app_context + + +class ToolsBtnsWidget(QtWidgets.QWidget): + """Widget containing buttons which are clickable.""" + tool_required = QtCore.Signal(str) + + def __init__(self, parent=None): + super(ToolsBtnsWidget, self).__init__(parent) + + create_btn = QtWidgets.QPushButton("Create...", self) + load_btn = QtWidgets.QPushButton("Load...", self) + publish_btn = QtWidgets.QPushButton("Publish...", self) + manage_btn = QtWidgets.QPushButton("Manage...", self) + experimental_tools_btn = QtWidgets.QPushButton( + "Experimental tools...", self + ) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(create_btn, 0) + layout.addWidget(load_btn, 0) + layout.addWidget(publish_btn, 0) + layout.addWidget(manage_btn, 0) + layout.addWidget(experimental_tools_btn, 0) + layout.addStretch(1) + + create_btn.clicked.connect(self._on_create) + load_btn.clicked.connect(self._on_load) + publish_btn.clicked.connect(self._on_publish) + manage_btn.clicked.connect(self._on_manage) + experimental_tools_btn.clicked.connect(self._on_experimental) + + def _on_create(self): + self.tool_required.emit("creator") + + def _on_load(self): + self.tool_required.emit("loader") + + def _on_publish(self): + self.tool_required.emit("publish") + + def _on_manage(self): + self.tool_required.emit("sceneinventory") + + def _on_experimental(self): + self.tool_required.emit("experimental_tools") + + +class ToolsDialog(QtWidgets.QDialog): + """Dialog with tool buttons that will stay opened until user close it.""" + def __init__(self, *args, **kwargs): + super(ToolsDialog, self).__init__(*args, **kwargs) + + self.setWindowTitle("OpenPype tools") + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.Window + | QtCore.Qt.WindowStaysOnTopHint + ) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + tools_widget = ToolsBtnsWidget(self) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(tools_widget) + + tools_widget.tool_required.connect(self._on_tool_require) + self._tools_widget = tools_widget + + self._first_show = True + + def sizeHint(self): + result = super(ToolsDialog, self).sizeHint() + result.setWidth(result.width() * 2) + return result + + def showEvent(self, event): + super(ToolsDialog, self).showEvent(event) + if self._first_show: + self.setStyleSheet(style.load_stylesheet()) + self._first_show = False + + def _on_tool_require(self, tool_name): + host_tools.show_tool_by_name(tool_name, parent=self) + + +class ToolsPopup(ToolsDialog): + """Popup with tool buttons that will close when loose focus.""" + def __init__(self, *args, **kwargs): + super(ToolsPopup, self).__init__(*args, **kwargs) + + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint + | QtCore.Qt.Popup + ) + + def showEvent(self, event): + super(ToolsPopup, self).showEvent(event) + app = QtWidgets.QApplication.instance() + app.processEvents() + pos = QtGui.QCursor.pos() + self.move(pos) + + +class WindowCache: + """Cached objects and methods to be used in global scope.""" + _dialog = None + _popup = None + _first_show = True + + @classmethod + def _before_show(cls): + """Create QApplication if does not exists yet.""" + if not cls._first_show: + return + + cls._first_show = False + if not QtWidgets.QApplication.instance(): + QtWidgets.QApplication(sys.argv) + + @classmethod + def show_popup(cls): + cls._before_show() + with qt_app_context(): + if cls._popup is None: + cls._popup = ToolsPopup() + + cls._popup.show() + + @classmethod + def show_dialog(cls): + cls._before_show() + with qt_app_context(): + if cls._dialog is None: + cls._dialog = ToolsDialog() + + cls._dialog.show() + cls._dialog.raise_() + cls._dialog.activateWindow() + + +def show_tools_popup(): + WindowCache.show_popup() + + +def show_tools_dialog(): + WindowCache.show_dialog() diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index efd2cddf7e..12e47a8961 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -24,16 +24,18 @@ from .env_tools import ( from .terminal import Terminal from .execute import ( + get_openpype_execute_args, get_pype_execute_args, + get_linux_launcher_args, execute, run_subprocess, + run_openpype_process, + clean_envs_for_openpype_process, path_to_subprocess_arg, CREATE_NO_WINDOW ) from .log import PypeLogger, timeit from .mongo import ( - decompose_url, - compose_url, get_default_components, validate_mongo_connection, OpenPypeMongoConnection @@ -150,7 +152,8 @@ from .path_tools import ( get_version_from_path, get_last_version_from_path, create_project_folders, - get_project_basic_paths + create_workdir_extra_folders, + get_project_basic_paths, ) from .editorial import ( @@ -173,9 +176,13 @@ from .pype_info import ( terminal = Terminal __all__ = [ + "get_openpype_execute_args", "get_pype_execute_args", + "get_linux_launcher_args", "execute", "run_subprocess", + "run_openpype_process", + "clean_envs_for_openpype_process", "path_to_subprocess_arg", "CREATE_NO_WINDOW", @@ -276,8 +283,6 @@ __all__ = [ "get_datetime_data", "PypeLogger", - "decompose_url", - "compose_url", "get_default_components", "validate_mongo_connection", "OpenPypeMongoConnection", @@ -294,6 +299,7 @@ __all__ = [ "frames_to_timecode", "make_sequence_collection", "create_project_folders", + "create_workdir_extra_folders", "get_project_basic_paths", "get_openpype_version", diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 66ecbd66d1..5f7285fe6c 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -1568,8 +1568,11 @@ class Roots: key_items = [self.env_prefix] for _key in keys: key_items.append(_key.upper()) + key = "_".join(key_items) - return {key: roots.value} + # Make sure key and value does not contain unicode + # - can happen in Python 2 hosts + return {str(key): str(roots.value)} output = {} for _key, _value in roots.items(): diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 6eb44a9694..d0438e12a6 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1,8 +1,8 @@ import os import sys -import re import copy import json +import tempfile import platform import collections import inspect @@ -37,10 +37,102 @@ from .python_module_tools import ( modules_from_path, classes_from_module ) +from .execute import get_linux_launcher_args _logger = None +PLATFORM_NAMES = {"windows", "linux", "darwin"} +DEFAULT_ENV_SUBGROUP = "standard" + + +def parse_environments(env_data, env_group=None, platform_name=None): + """Parse environment values from settings byt group and platfrom. + + Data may contain up to 2 hierarchical levels of dictionaries. At the end + of the last level must be string or list. List is joined using platform + specific joiner (';' for windows and ':' for linux and mac). + + Hierarchical levels can contain keys for subgroups and platform name. + Platform specific values must be always last level of dictionary. Platform + names are "windows" (MS Windows), "linux" (any linux distribution) and + "darwin" (any MacOS distribution). + + Subgroups are helpers added mainly for standard and on farm usage. Farm + may require different environments for e.g. licence related values or + plugins. Default subgroup is "standard". + + Examples: + ``` + { + # Unchanged value + "ENV_KEY1": "value", + # Empty values are kept (unset environment variable) + "ENV_KEY2": "", + + # Join list values with ':' or ';' + "ENV_KEY3": ["value1", "value2"], + + # Environment groups + "ENV_KEY4": { + "standard": "DEMO_SERVER_URL", + "farm": "LICENCE_SERVER_URL" + }, + + # Platform specific (and only for windows and mac) + "ENV_KEY5": { + "windows": "windows value", + "darwin": ["value 1", "value 2"] + }, + + # Environment groups and platform combination + "ENV_KEY6": { + "farm": "FARM_VALUE", + "standard": { + "windows": ["value1", "value2"], + "linux": "value1", + "darwin": "" + } + } + } + ``` + """ + output = {} + if not env_data: + return output + + if not env_group: + env_group = DEFAULT_ENV_SUBGROUP + + if not platform_name: + platform_name = platform.system().lower() + + for key, value in env_data.items(): + if isinstance(value, dict): + # Look if any key is platform key + # - expect that represents environment group if does not contain + # platform keys + if not PLATFORM_NAMES.intersection(set(value.keys())): + # Skip the key if group is not available + if env_group not in value: + continue + value = value[env_group] + + # Check again if value is dictionary + # - this time there should be only platform keys + if isinstance(value, dict): + value = value.get(platform_name) + + # Check if value is list and join it's values + # QUESTION Should empty values be skipped? + if isinstance(value, (list, tuple)): + value = os.pathsep.join(value) + + # Set key to output if value is string + if isinstance(value, six.string_types): + output[key] = value + return output + def get_logger(): """Global lib.applications logger getter.""" @@ -640,6 +732,10 @@ class LaunchHook: def app_name(self): return getattr(self.application, "full_name", None) + @property + def modules_manager(self): + return getattr(self.launch_context, "modules_manager", None) + def validate(self): """Optional validation of launch hook on initialization. @@ -701,16 +797,25 @@ class ApplicationLaunchContext: preparation to store objects usable in multiple places. """ - def __init__(self, application, executable, **data): + def __init__(self, application, executable, env_group=None, **data): + from openpype.modules import ModulesManager + # Application object self.application = application + self.modules_manager = ModulesManager() + # Logger logger_name = "{}-{}".format(self.__class__.__name__, self.app_name) self.log = PypeLogger.get_logger(logger_name) self.executable = executable + if env_group is None: + env_group = DEFAULT_ENV_SUBGROUP + + self.env_group = env_group + self.data = dict(data) # subprocess.Popen launch arguments (first argument in constructor) @@ -812,10 +917,7 @@ class ApplicationLaunchContext: paths.append(path) # Load modules paths - from openpype.modules import ModulesManager - - manager = ModulesManager() - paths.extend(manager.collect_launch_hook_paths()) + paths.extend(self.modules_manager.collect_launch_hook_paths()) return paths @@ -921,6 +1023,48 @@ class ApplicationLaunchContext: def manager(self): return self.application.manager + def _run_process(self): + # Windows and MacOS have easier process start + low_platform = platform.system().lower() + if low_platform in ("windows", "darwin"): + return subprocess.Popen(self.launch_args, **self.kwargs) + + # Linux uses mid process + # - it is possible that the mid process executable is not + # available for this version of OpenPype in that case use standard + # launch + launch_args = get_linux_launcher_args() + if launch_args is None: + return subprocess.Popen(self.launch_args, **self.kwargs) + + # Prepare data that will be passed to midprocess + # - store arguments to a json and pass path to json as last argument + # - pass environments to set + json_data = { + "args": self.launch_args, + "env": self.kwargs.pop("env", {}) + } + # Create temp file + json_temp = tempfile.NamedTemporaryFile( + mode="w", prefix="op_app_args", suffix=".json", delete=False + ) + json_temp.close() + json_temp_filpath = json_temp.name + with open(json_temp_filpath, "w") as stream: + json.dump(json_data, stream) + + launch_args.append(json_temp_filpath) + + # Create mid-process which will launch application + process = subprocess.Popen(launch_args, **self.kwargs) + # Wait until the process finishes + # - This is important! The process would stay in "open" state. + process.wait() + # Remove the temp file + os.remove(json_temp_filpath) + # Return process which is already terminated + return process + def launch(self): """Collect data for new process and then create it. @@ -957,8 +1101,10 @@ class ApplicationLaunchContext: self.app_name, args_len_str, args ) ) + self.launch_args = args + # Run process - self.process = subprocess.Popen(args, **self.kwargs) + self.process = self._run_process() # Process post launch hooks for postlaunch_hook in self.postlaunch_hooks: @@ -1047,7 +1193,7 @@ class EnvironmentPrepData(dict): def get_app_environments_for_context( - project_name, asset_name, task_name, app_name, env=None + project_name, asset_name, task_name, app_name, env_group=None, env=None ): """Prepare environment variables by context. Args: @@ -1099,8 +1245,8 @@ def get_app_environments_for_context( "env": env }) - prepare_host_environments(data) - prepare_context_environments(data) + prepare_host_environments(data, env_group) + prepare_context_environments(data, env_group) # Discard avalon connection dbcon.uninstall() @@ -1120,7 +1266,7 @@ def _merge_env(env, current_env): return result -def prepare_host_environments(data, implementation_envs=True): +def prepare_host_environments(data, env_group=None, implementation_envs=True): """Modify launch environments based on launched app and context. Args: @@ -1174,7 +1320,7 @@ def prepare_host_environments(data, implementation_envs=True): continue # Choose right platform - tool_env = acre.parse(_env_values) + tool_env = parse_environments(_env_values, env_group) # Merge dictionaries env_values = _merge_env(tool_env, env_values) @@ -1206,7 +1352,9 @@ def prepare_host_environments(data, implementation_envs=True): data["env"].pop(key, None) -def apply_project_environments_value(project_name, env, project_settings=None): +def apply_project_environments_value( + project_name, env, project_settings=None, env_group=None +): """Apply project specific environments on passed environments. The enviornments are applied on passed `env` argument value so it is not @@ -1234,14 +1382,15 @@ def apply_project_environments_value(project_name, env, project_settings=None): env_value = project_settings["global"]["project_environments"] if env_value: + parsed_value = parse_environments(env_value, env_group) env.update(acre.compute( - _merge_env(acre.parse(env_value), env), + _merge_env(parsed_value, env), cleanup=False )) return env -def prepare_context_environments(data): +def prepare_context_environments(data, env_group=None): """Modify launch environemnts with context data for launched host. Args: @@ -1271,7 +1420,7 @@ def prepare_context_environments(data): data["project_settings"] = project_settings # Apply project specific environments on current env value apply_project_environments_value( - project_name, data["env"], project_settings + project_name, data["env"], project_settings, env_group ) app = data["app"] diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index e3bceff275..8180e416a9 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1433,7 +1433,11 @@ def get_creator_by_name(creator_name, case_sensitive=False): @with_avalon def change_timer_to_current_context(): - """Called after context change to change timers""" + """Called after context change to change timers. + + TODO: + - use TimersManager's static method instead of reimplementing it here + """ webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") if not webserver_url: log.warning("Couldn't find webserver url") @@ -1448,8 +1452,7 @@ def change_timer_to_current_context(): data = { "project_name": avalon.io.Session["AVALON_PROJECT"], "asset_name": avalon.io.Session["AVALON_ASSET"], - "task_name": avalon.io.Session["AVALON_TASK"], - "hierarchy": get_hierarchy() + "task_name": avalon.io.Session["AVALON_TASK"] } requests.post(rest_api_url, json=data) @@ -1557,7 +1560,7 @@ def get_custom_workfile_template_by_context( # get path from matching profile matching_item = filter_profiles( template_profiles, - {"task_type": current_task_type} + {"task_types": current_task_type} ) # when path is available try to format it in case # there are some anatomy template strings diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index ad77b2f899..3cf67a379c 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -1,7 +1,6 @@ import os -import shlex import subprocess -import platform +import distutils.spawn from .log import PypeLogger as Logger @@ -139,6 +138,49 @@ def run_subprocess(*args, **kwargs): return full_output +def clean_envs_for_openpype_process(env=None): + """Modify environemnts that may affect OpenPype process. + + Main reason to implement this function is to pop PYTHONPATH which may be + affected by in-host environments. + """ + if env is None: + env = os.environ + return { + key: value + for key, value in env.items() + if key not in ("PYTHONPATH",) + } + + +def run_openpype_process(*args, **kwargs): + """Execute OpenPype process with passed arguments and wait. + + Wrapper for 'run_process' which prepends OpenPype executable arguments + before passed arguments and define environments if are not passed. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_openpype_process("run", "") + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + """ + args = get_openpype_execute_args(*args) + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + # Skip envs that can affect OpenPype process + # - fill more if you find more + env = clean_envs_for_openpype_process(os.environ) + return run_subprocess(args, env=env, **kwargs) + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. @@ -148,6 +190,18 @@ def path_to_subprocess_arg(path): def get_pype_execute_args(*args): + """Backwards compatible function for 'get_openpype_execute_args'.""" + import traceback + + log = Logger.get_logger("get_pype_execute_args") + stack = "\n".join(traceback.format_stack()) + log.warning(( + "Using deprecated function 'get_pype_execute_args'. Called from:\n{}" + ).format(stack)) + return get_openpype_execute_args(*args) + + +def get_openpype_execute_args(*args): """Arguments to run pype command. Arguments for subprocess when need to spawn new pype process. Which may be @@ -175,3 +229,46 @@ def get_pype_execute_args(*args): pype_args.extend(args) return pype_args + + +def get_linux_launcher_args(*args): + """Path to application mid process executable. + + This function should be able as arguments are different when used + from code and build. + + It is possible that this function is used in OpenPype build which does + not have yet the new executable. In that case 'None' is returned. + + Args: + args (iterable): List of additional arguments added after executable + argument. + + Returns: + list: Executables with possible positional argument to script when + called from code. + """ + filename = "app_launcher" + openpype_executable = os.environ["OPENPYPE_EXECUTABLE"] + + executable_filename = os.path.basename(openpype_executable) + if "python" in executable_filename.lower(): + script_path = os.path.join( + os.environ["OPENPYPE_ROOT"], + "{}.py".format(filename) + ) + launch_args = [openpype_executable, script_path] + else: + new_executable = os.path.join( + os.path.dirname(openpype_executable), + filename + ) + executable_path = distutils.spawn.find_executable(new_executable) + if executable_path is None: + return None + launch_args = [executable_path] + + if args: + launch_args.extend(args) + + return launch_args diff --git a/openpype/lib/log.py b/openpype/lib/log.py index 85cbc733ba..a42faef008 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -27,7 +27,7 @@ import copy from . import Terminal from .mongo import ( MongoEnvNotSet, - decompose_url, + get_default_components, OpenPypeMongoConnection ) try: @@ -202,8 +202,9 @@ class PypeLogger: use_mongo_logging = None mongo_process_id = None - # Information about mongo url - log_mongo_url = None + # Backwards compatibility - was used in start.py + # TODO remove when all old builds are replaced with new one + # not using 'log_mongo_url_components' log_mongo_url_components = None # Database name in Mongo @@ -282,9 +283,9 @@ class PypeLogger: if not cls.use_mongo_logging: return - components = cls.log_mongo_url_components + components = get_default_components() kwargs = { - "host": cls.log_mongo_url, + "host": components["host"], "database_name": cls.log_database_name, "collection": cls.log_collection_name, "username": components["username"], @@ -324,6 +325,7 @@ class PypeLogger: # Change initialization state to prevent runtime changes # if is executed during runtime cls.initialized = False + cls.log_mongo_url_components = get_default_components() # Define if should logging to mongo be used use_mongo_logging = bool(log4mongo is not None) @@ -354,14 +356,8 @@ class PypeLogger: # Define if is in OPENPYPE_DEBUG mode cls.pype_debug = int(os.getenv("OPENPYPE_DEBUG") or "0") - # Mongo URL where logs will be stored - cls.log_mongo_url = os.environ.get("OPENPYPE_MONGO") - - if not cls.log_mongo_url: + if not os.environ.get("OPENPYPE_MONGO"): cls.use_mongo_logging = False - else: - # Decompose url - cls.log_mongo_url_components = decompose_url(cls.log_mongo_url) # Mark as initialized cls.initialized = True @@ -474,7 +470,7 @@ class PypeLogger: if not cls.initialized: cls.initialize() - return OpenPypeMongoConnection.get_mongo_client(cls.log_mongo_url) + return OpenPypeMongoConnection.get_mongo_client() def timeit(method): diff --git a/openpype/lib/mongo.py b/openpype/lib/mongo.py index 0fd4517b5b..7e0bd4f796 100644 --- a/openpype/lib/mongo.py +++ b/openpype/lib/mongo.py @@ -15,7 +15,19 @@ class MongoEnvNotSet(Exception): pass -def decompose_url(url): +def _decompose_url(url): + """Decompose mongo url to basic components. + + Used for creation of MongoHandler which expect mongo url components as + separated kwargs. Components are at the end not used as we're setting + connection directly this is just a dumb components for MongoHandler + validation pass. + """ + # Use first url from passed url + # - this is beacuse it is possible to pass multiple urls for multiple + # replica sets which would crash on urlparse otherwise + # - please don't use comma in username of password + url = url.split(",")[0] components = { "scheme": None, "host": None, @@ -48,42 +60,13 @@ def decompose_url(url): return components -def compose_url(scheme=None, - host=None, - username=None, - password=None, - port=None, - auth_db=None): - - url = "{scheme}://" - - if username and password: - url += "{username}:{password}@" - - url += "{host}" - if port: - url += ":{port}" - - if auth_db: - url += "?authSource={auth_db}" - - return url.format(**{ - "scheme": scheme, - "host": host, - "username": username, - "password": password, - "port": port, - "auth_db": auth_db - }) - - def get_default_components(): mongo_url = os.environ.get("OPENPYPE_MONGO") if mongo_url is None: raise MongoEnvNotSet( "URL for Mongo logging connection is not set." ) - return decompose_url(mongo_url) + return _decompose_url(mongo_url) def should_add_certificate_path_to_mongo_url(mongo_url): diff --git a/openpype/lib/openpype_version.py b/openpype/lib/openpype_version.py new file mode 100644 index 0000000000..e3a4e1fa3e --- /dev/null +++ b/openpype/lib/openpype_version.py @@ -0,0 +1,85 @@ +"""Lib access to OpenPypeVersion from igniter. + +Access to logic from igniter is available only for OpenPype processes. +Is meant to be able check OpenPype versions for studio. The logic is dependent +on igniter's inner logic of versions. + +Keep in mind that all functions except 'get_installed_version' does not return +OpenPype version located in build but versions available in remote versions +repository or locally available. +""" + +import sys + + +def get_OpenPypeVersion(): + """Access to OpenPypeVersion class stored in sys modules.""" + return sys.modules.get("OpenPypeVersion") + + +def op_version_control_available(): + """Check if current process has access to OpenPypeVersion.""" + if get_OpenPypeVersion() is None: + return False + return True + + +def get_installed_version(): + """Get OpenPype version inside build. + + This version is not returned by any other functions here. + """ + if op_version_control_available(): + return get_OpenPypeVersion().get_installed_version() + return None + + +def get_available_versions(*args, **kwargs): + """Get list of available versions.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_available_versions( + *args, **kwargs + ) + return None + + +def openpype_path_is_set(): + """OpenPype repository path is set in settings.""" + if op_version_control_available(): + return get_OpenPypeVersion().openpype_path_is_set() + return None + + +def openpype_path_is_accessible(): + """OpenPype version repository path can be accessed.""" + if op_version_control_available(): + return get_OpenPypeVersion().openpype_path_is_accessible() + return None + + +def get_local_versions(*args, **kwargs): + """OpenPype versions available on this workstation.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_local_versions(*args, **kwargs) + return None + + +def get_remote_versions(*args, **kwargs): + """OpenPype versions in repository path.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_remote_versions(*args, **kwargs) + return None + + +def get_latest_version(*args, **kwargs): + """Get latest version from repository path.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_latest_version(*args, **kwargs) + return None + + +def get_expected_studio_version(staging=False): + """Expected production or staging version in studio.""" + if op_version_control_available(): + return get_OpenPypeVersion().get_expected_studio_version(staging) + return None diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py index 9bb0231ca7..12e9e2db9c 100644 --- a/openpype/lib/path_tools.py +++ b/openpype/lib/path_tools.py @@ -1,13 +1,15 @@ -import json -import logging import os import re import abc +import json +import logging import six +from openpype.settings import get_project_settings +from openpype.settings.lib import get_site_local_overrides from .anatomy import Anatomy -from openpype.settings import get_project_settings +from .profiles_filtering import filter_profiles log = logging.getLogger(__name__) @@ -200,6 +202,58 @@ def get_project_basic_paths(project_name): return _list_path_items(folder_structure) +def create_workdir_extra_folders( + workdir, host_name, task_type, task_name, project_name, + project_settings=None +): + """Create extra folders in work directory based on context. + + Args: + workdir (str): Path to workdir where workfiles is stored. + host_name (str): Name of host implementation. + task_type (str): Type of task for which extra folders should be + created. + task_name (str): Name of task for which extra folders should be + created. + project_name (str): Name of project on which task is. + project_settings (dict): Prepared project settings. Are loaded if not + passed. + """ + # Load project settings if not set + if not project_settings: + project_settings = get_project_settings(project_name) + + # Load extra folders profiles + extra_folders_profiles = ( + project_settings["global"]["tools"]["Workfiles"]["extra_folders"] + ) + # Skip if are empty + if not extra_folders_profiles: + return + + # Prepare profiles filters + filter_data = { + "task_types": task_type, + "task_names": task_name, + "hosts": host_name + } + profile = filter_profiles(extra_folders_profiles, filter_data) + if profile is None: + return + + for subfolder in profile["folders"]: + # Make sure backslashes are converted to forwards slashes + # and does not start with slash + subfolder = subfolder.replace("\\", "/").lstrip("/") + # Skip empty strings + if not subfolder: + continue + + fullpath = os.path.join(workdir, subfolder) + if not os.path.exists(fullpath): + os.makedirs(fullpath) + + @six.add_metaclass(abc.ABCMeta) class HostDirmap: """ diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 2a859da7cb..7c66f9760d 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -227,20 +227,27 @@ def filter_pyblish_plugins(plugins): # iterate over plugins for plugin in plugins[:]: - file = os.path.normpath(inspect.getsourcefile(plugin)) - file = os.path.normpath(file) - - # host determined from path - host_from_file = file.split(os.path.sep)[-4:-3][0] - plugin_kind = file.split(os.path.sep)[-2:-1][0] - - # TODO: change after all plugins are moved one level up - if host_from_file == "openpype": - host_from_file = "global" - try: config_data = presets[host]["publish"][plugin.__name__] except KeyError: + # host determined from path + file = os.path.normpath(inspect.getsourcefile(plugin)) + file = os.path.normpath(file) + + split_path = file.split(os.path.sep) + if len(split_path) < 4: + log.warning( + 'plugin path too short to extract host {}'.format(file) + ) + continue + + host_from_file = split_path[-4] + plugin_kind = split_path[-2] + + # TODO: change after all plugins are moved one level up + if host_from_file == "openpype": + host_from_file = "global" + try: config_data = presets[host_from_file][plugin_kind][plugin.__name__] # noqa: E501 except KeyError: diff --git a/openpype/lib/pype_info.py b/openpype/lib/pype_info.py index 33715e369d..15856bfb19 100644 --- a/openpype/lib/pype_info.py +++ b/openpype/lib/pype_info.py @@ -7,7 +7,7 @@ import socket import openpype.version from openpype.settings.lib import get_local_settings -from .execute import get_pype_execute_args +from .execute import get_openpype_execute_args from .local_settings import get_local_site_id from .python_module_tools import import_filepath @@ -71,7 +71,7 @@ def is_running_staging(): def get_pype_info(): """Information about currently used Pype process.""" - executable_args = get_pype_execute_args() + executable_args = get_openpype_execute_args() if is_running_from_build(): version_type = "build" else: diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 69da4cc661..f62c848e4a 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -49,32 +49,30 @@ def modules_from_path(folder_path): Arguments: path (str): Path to folder containing python scripts. - return_crasher (bool): Crashed module paths with exception info - will be returned too. Returns: - list, tuple: List of modules when `return_crashed` is False else tuple - with list of modules at first place and tuple of path and exception - info at second place. + tuple: First list contains successfully imported modules + and second list contains tuples of path and exception. """ crashed = [] modules = [] + output = (modules, crashed) # Just skip and return empty list if path is not set if not folder_path: - return modules + return output # Do not allow relative imports if folder_path.startswith("."): log.warning(( "BUG: Relative paths are not allowed for security reasons. {}" ).format(folder_path)) - return modules + return output folder_path = os.path.normpath(folder_path) if not os.path.isdir(folder_path): log.warning("Not a directory path: {}".format(folder_path)) - return modules + return output for filename in os.listdir(folder_path): # Ignore files which start with underscore @@ -101,7 +99,7 @@ def modules_from_path(folder_path): ) continue - return modules, crashed + return output def recursive_bases_from_class(klass): diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index 9e650a097e..51a22323f1 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -13,14 +13,6 @@ class AvalonModule(OpenPypeModule, ITrayModule): avalon_settings = modules_settings[self.name] - # Check if environment is already set - avalon_mongo_url = os.environ.get("AVALON_MONGO") - if not avalon_mongo_url: - avalon_mongo_url = avalon_settings["AVALON_MONGO"] - # Use pype mongo if Avalon's mongo not defined - if not avalon_mongo_url: - avalon_mongo_url = os.environ["OPENPYPE_MONGO"] - thumbnail_root = os.environ.get("AVALON_THUMBNAIL_ROOT") if not thumbnail_root: thumbnail_root = avalon_settings["AVALON_THUMBNAIL_ROOT"] @@ -31,7 +23,6 @@ class AvalonModule(OpenPypeModule, ITrayModule): avalon_mongo_timeout = avalon_settings["AVALON_TIMEOUT"] self.thumbnail_root = thumbnail_root - self.avalon_mongo_url = avalon_mongo_url self.avalon_mongo_timeout = avalon_mongo_timeout # Tray attributes @@ -51,12 +42,20 @@ class AvalonModule(OpenPypeModule, ITrayModule): def tray_init(self): # Add library tool try: + from Qt import QtCore from openpype.tools.libraryloader import LibraryLoaderWindow - self.libraryloader = LibraryLoaderWindow( + libraryloader = LibraryLoaderWindow( show_projects=True, show_libraries=True ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self.libraryloader = libraryloader + except Exception: self.log.warning( "Couldn't load Library loader tool for tray.", diff --git a/openpype/modules/base.py b/openpype/modules/base.py index b5c491a1c0..d566692439 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -42,6 +42,7 @@ DEFAULT_OPENPYPE_MODULES = ( "settings_action", "standalonepublish_action", "job_queue", + "timers_manager", ) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py index 994dbd90e4..8bbef9ad73 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py @@ -111,13 +111,6 @@ class CreateFolders(BaseAction): publish_template = publish_template[key] publish_has_apps = "{app" in publish_template - tools_settings = project_settings["global"]["tools"] - app_presets = tools_settings["Workfiles"]["sw_folders"] - app_manager_apps = None - if app_presets and (work_has_apps or publish_has_apps): - app_manager_apps = ApplicationManager().applications - - cached_apps = {} collected_paths = [] for entity in all_entities: if entity.entity_type.lower() == "project": @@ -143,26 +136,10 @@ class CreateFolders(BaseAction): if child["object_type"]["name"].lower() != "task": continue tasks_created = True - task_type_name = child["type"]["name"].lower() task_data = ent_data.copy() task_data["task"] = child["name"] apps = [] - if app_manager_apps: - possible_apps = app_presets.get(task_type_name) or [] - for app_name in possible_apps: - - if app_name in cached_apps: - apps.append(cached_apps[app_name]) - continue - - app_def = app_manager_apps.get(app_name) - if app_def and app_def.is_host: - app_dir = app_def.host_name - else: - app_dir = app_name - cached_apps[app_name] = app_dir - apps.append(app_dir) # Template wok if work_has_apps: diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 8a7525d65b..38ec02749a 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -1,6 +1,7 @@ import os import json import collections +import platform import click @@ -42,18 +43,26 @@ class FtrackModule( self.ftrack_url = ftrack_url current_dir = os.path.dirname(os.path.abspath(__file__)) + low_platform = platform.system().lower() + + # Server event handler paths server_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_server") ] - server_event_handlers_paths.extend( - ftrack_settings["ftrack_events_path"] - ) + settings_server_paths = ftrack_settings["ftrack_events_path"] + if isinstance(settings_server_paths, dict): + settings_server_paths = settings_server_paths[low_platform] + server_event_handlers_paths.extend(settings_server_paths) + + # User event handler paths user_event_handlers_paths = [ os.path.join(current_dir, "event_handlers_user") ] - user_event_handlers_paths.extend( - ftrack_settings["ftrack_actions_path"] - ) + settings_action_paths = ftrack_settings["ftrack_actions_path"] + if isinstance(settings_action_paths, dict): + settings_action_paths = settings_action_paths[low_platform] + user_event_handlers_paths.extend(settings_action_paths) + # Prepare attribute self.server_event_handlers_paths = server_event_handlers_paths self.user_event_handlers_paths = user_event_handlers_paths diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py index 1a76905b38..90ce757242 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/event_server_cli.py @@ -14,7 +14,7 @@ import uuid import ftrack_api import pymongo from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, OpenPypeMongoConnection, get_openpype_version, get_build_version, @@ -136,7 +136,7 @@ def legacy_server(ftrack_url): if subproc is None: if subproc_failed_count < max_fail_count: - args = get_pype_execute_args("run", subproc_path) + args = get_openpype_execute_args("run", subproc_path) subproc = subprocess.Popen( args, stdout=subprocess.PIPE @@ -248,7 +248,7 @@ def main_loop(ftrack_url): ["Username", getpass.getuser()], ["Host Name", host_name], ["Host IP", socket.gethostbyname(host_name)], - ["OpenPype executable", get_pype_execute_args()[-1]], + ["OpenPype executable", get_openpype_execute_args()[-1]], ["OpenPype version", get_openpype_version() or "N/A"], ["OpenPype build version", get_build_version() or "N/A"] ] diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py b/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py index bd67fba3d6..8944591b71 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/ftrack_server.py @@ -63,6 +63,12 @@ class FtrackServer: # Iterate all paths register_functions = [] for path in paths: + # Try to format path with environments + try: + path = path.format(**os.environ) + except BaseException: + pass + # Get all modules with functions modules, crashed = modules_from_path(path) for filepath, exc_info in crashed: diff --git a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py index eb8ec4d06c..f49ca5557e 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py +++ b/openpype/modules/default_modules/ftrack/ftrack_server/socket_thread.py @@ -6,7 +6,7 @@ import threading import traceback import subprocess from openpype.api import Logger -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args class SocketThread(threading.Thread): @@ -59,7 +59,7 @@ class SocketThread(threading.Thread): env = os.environ.copy() env["OPENPYPE_PROCESS_MONGO_ID"] = str(Logger.mongo_process_id) # OpenPype executable (with path to start script if not build) - args = get_pype_execute_args( + args = get_openpype_execute_args( # Add `run` command "run", self.filepath, diff --git a/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py b/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py index df16cde2b8..d5a95fad91 100644 --- a/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py +++ b/openpype/modules/default_modules/ftrack/launch_hooks/post_ftrack_changes.py @@ -52,7 +52,7 @@ class PostFtrackHook(PostLaunchHook): ) if entity: self.ftrack_status_change(session, entity, project_name) - self.start_timer(session, entity, ftrack_api) + except Exception: self.log.warning( "Couldn't finish Ftrack procedure.", exc_info=True @@ -160,26 +160,3 @@ class PostFtrackHook(PostLaunchHook): " on Ftrack entity type \"{}\"" ).format(next_status_name, entity.entity_type) self.log.warning(msg) - - def start_timer(self, session, entity, _ftrack_api): - """Start Ftrack timer on task from context.""" - self.log.debug("Triggering timer start.") - - user_entity = session.query("User where username is \"{}\"".format( - os.environ["FTRACK_API_USER"] - )).first() - if not user_entity: - self.log.warning( - "Couldn't find user with username \"{}\" in Ftrack".format( - os.environ["FTRACK_API_USER"] - ) - ) - return - - try: - user_entity.start_timer(entity, force=True) - session.commit() - self.log.debug("Timer start triggered successfully.") - - except Exception: - self.log.warning("Couldn't trigger Ftrack timer.", exc_info=True) diff --git a/openpype/modules/slack/manifest.yml b/openpype/modules/slack/manifest.yml index 37d4669903..7a65cc5915 100644 --- a/openpype/modules/slack/manifest.yml +++ b/openpype/modules/slack/manifest.yml @@ -15,8 +15,10 @@ oauth_config: scopes: bot: - chat:write + - chat:write.customize - chat:write.public - files:write + - channels:read settings: org_deploy_enabled: false socket_mode_enabled: false diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 7b81d3c364..5d014382a3 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -2,8 +2,10 @@ import os import six import pyblish.api import copy +from datetime import datetime from openpype.lib.plugin_tools import prepare_template_data +from openpype.lib import OpenPypeMongoConnection class IntegrateSlackAPI(pyblish.api.InstancePlugin): @@ -14,6 +16,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): Project settings > Slack > Publish plugins > Notification to Slack. If instance contains 'thumbnail' it uploads it. Bot must be present in the target channel. + If instance contains 'review' it could upload (if configured) or place + link with {review_filepath} placeholder. Message template can contain {} placeholders from anatomyData. """ order = pyblish.api.IntegratorOrder + 0.499 @@ -23,44 +27,81 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): optional = True def process(self, instance): - published_path = self._get_thumbnail_path(instance) + thumbnail_path = self._get_thumbnail_path(instance) + review_path = self._get_review_path(instance) + publish_files = set() for message_profile in instance.data["slack_channel_message_profiles"]: message = self._get_filled_message(message_profile["message"], - instance) + instance, + review_path) + self.log.info("message:: {}".format(message)) if not message: return + if message_profile["upload_thumbnail"] and thumbnail_path: + publish_files.add(thumbnail_path) + + if message_profile["upload_review"] and review_path: + publish_files.add(review_path) + + project = instance.context.data["anatomyData"]["project"]["code"] for channel in message_profile["channels"]: if six.PY2: - self._python2_call(instance.data["slack_token"], - channel, - message, - published_path, - message_profile["upload_thumbnail"]) + msg_id, file_ids = \ + self._python2_call(instance.data["slack_token"], + channel, + message, + publish_files) else: - self._python3_call(instance.data["slack_token"], - channel, - message, - published_path, - message_profile["upload_thumbnail"]) + msg_id, file_ids = \ + self._python3_call(instance.data["slack_token"], + channel, + message, + publish_files) - def _get_filled_message(self, message_templ, instance): - """Use message_templ and data from instance to get message content.""" + msg = { + "type": "slack", + "msg_id": msg_id, + "file_ids": file_ids, + "project": project, + "created_dt": datetime.now() + } + mongo_client = OpenPypeMongoConnection.get_mongo_client() + database_name = os.environ["OPENPYPE_DATABASE_NAME"] + dbcon = mongo_client[database_name]["notification_messages"] + dbcon.insert_one(msg) + + def _get_filled_message(self, message_templ, instance, review_path=None): + """Use message_templ and data from instance to get message content. + + Reviews might be large, so allow only adding link to message instead of + uploading only. + """ fill_data = copy.deepcopy(instance.context.data["anatomyData"]) - fill_pairs = ( + fill_pairs = [ ("asset", instance.data.get("asset", fill_data.get("asset"))), ("subset", instance.data.get("subset", fill_data.get("subset"))), - ("task", instance.data.get("task", fill_data.get("task"))), ("username", instance.data.get("username", fill_data.get("username"))), ("app", instance.data.get("app", fill_data.get("app"))), ("family", instance.data.get("family", fill_data.get("family"))), ("version", str(instance.data.get("version", fill_data.get("version")))) - ) + ] + if review_path: + fill_pairs.append(("review_filepath", review_path)) + task_data = instance.data.get("task") + if not task_data: + task_data = fill_data.get("task") + for key, value in task_data.items(): + fill_key = "task[{}]".format(key) + fill_pairs.append((fill_key, value)) + fill_pairs.append(("task", task_data["name"])) + + self.log.debug("fill_pairs ::{}".format(fill_pairs)) multiple_case_variants = prepare_template_data(fill_pairs) fill_data.update(multiple_case_variants) @@ -79,67 +120,87 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): published_path = None for repre in instance.data['representations']: if repre.get('thumbnail') or "thumbnail" in repre.get('tags', []): - repre_files = repre["files"] - if isinstance(repre_files, (tuple, list, set)): - filename = repre_files[0] - else: - filename = repre_files - - published_path = os.path.join( - repre['stagingDir'], filename - ) + if os.path.exists(repre["published_path"]): + published_path = repre["published_path"] break return published_path - def _python2_call(self, token, channel, message, - published_path, upload_thumbnail): + def _get_review_path(self, instance): + """Returns abs url for review if present in instance repres""" + published_path = None + for repre in instance.data['representations']: + tags = repre.get('tags', []) + if (repre.get("review") + or "review" in tags + or "burnin" in tags): + if os.path.exists(repre["published_path"]): + published_path = repre["published_path"] + if "burnin" in tags: # burnin has precedence if exists + break + return published_path + + def _python2_call(self, token, channel, message, publish_files): from slackclient import SlackClient try: client = SlackClient(token) - if upload_thumbnail and \ - published_path and os.path.exists(published_path): - with open(published_path, 'rb') as pf: + attachment_str = "\n\n Attachment links: \n" + file_ids = [] + for p_file in publish_files: + with open(p_file, 'rb') as pf: response = client.api_call( "files.upload", - channels=channel, - initial_comment=message, file=pf, - title=os.path.basename(published_path) + channel=channel, + title=os.path.basename(p_file) ) - else: - response = client.api_call( - "chat.postMessage", - channel=channel, - text=message - ) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(p_file)) + file_ids.append(response["file"]["id"]) + if publish_files: + message += attachment_str + + response = client.api_call( + "chat.postMessage", + channel=channel, + text=message + ) if response.get("error"): error_str = self._enrich_error(str(response.get("error")), channel) self.log.warning("Error happened: {}".format(error_str)) + else: + return response["ts"], file_ids except Exception as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e), channel) self.log.warning("Error happened: {}".format(error_str)) - def _python3_call(self, token, channel, message, - published_path, upload_thumbnail): + def _python3_call(self, token, channel, message, publish_files): from slack_sdk import WebClient from slack_sdk.errors import SlackApiError try: client = WebClient(token=token) - if upload_thumbnail and \ - published_path and os.path.exists(published_path): - _ = client.files_upload( - channels=channel, - initial_comment=message, - file=published_path, - ) - else: - _ = client.chat_postMessage( - channel=channel, - text=message - ) + attachment_str = "\n\n Attachment links: \n" + file_ids = [] + for published_file in publish_files: + response = client.files_upload( + file=published_file, + filename=os.path.basename(published_file)) + attachment_str += "\n<{}|{}>".format( + response["file"]["permalink"], + os.path.basename(published_file)) + file_ids.append(response["file"]["id"]) + + if publish_files: + message += attachment_str + + response = client.chat_postMessage( + channel=channel, + text=message + ) + return response.data["ts"], file_ids except SlackApiError as e: # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e.response["error"]), channel) diff --git a/openpype/modules/slack/resources/openpype_icon.png b/openpype/modules/slack/resources/openpype_icon.png new file mode 100644 index 0000000000..bb38dcf577 Binary files /dev/null and b/openpype/modules/slack/resources/openpype_icon.png differ diff --git a/openpype/modules/standalonepublish_action.py b/openpype/modules/standalonepublish_action.py index 9321a415a9..ba53ce9b9e 100644 --- a/openpype/modules/standalonepublish_action.py +++ b/openpype/modules/standalonepublish_action.py @@ -1,7 +1,7 @@ import os import platform import subprocess -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayAction @@ -35,7 +35,7 @@ class StandAlonePublishAction(OpenPypeModule, ITrayAction): self.publish_paths.extend(publish_paths) def run_standalone_publisher(self): - args = get_pype_execute_args("standalonepublisher") + args = get_openpype_execute_args("standalonepublisher") kwargs = {} if platform.system().lower() == "darwin": new_args = ["open", "-na", args.pop(0), "--args"] diff --git a/openpype/modules/default_modules/timers_manager/__init__.py b/openpype/modules/timers_manager/__init__.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/__init__.py rename to openpype/modules/timers_manager/__init__.py diff --git a/openpype/modules/timers_manager/exceptions.py b/openpype/modules/timers_manager/exceptions.py new file mode 100644 index 0000000000..5a9e00765d --- /dev/null +++ b/openpype/modules/timers_manager/exceptions.py @@ -0,0 +1,3 @@ +class InvalidContextError(ValueError): + """Context for which the timer should be started is invalid.""" + pass diff --git a/openpype/modules/default_modules/timers_manager/idle_threads.py b/openpype/modules/timers_manager/idle_threads.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/idle_threads.py rename to openpype/modules/timers_manager/idle_threads.py diff --git a/openpype/modules/timers_manager/launch_hooks/post_start_timer.py b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py new file mode 100644 index 0000000000..d6ae013403 --- /dev/null +++ b/openpype/modules/timers_manager/launch_hooks/post_start_timer.py @@ -0,0 +1,45 @@ +from openpype.lib import PostLaunchHook + + +class PostStartTimerHook(PostLaunchHook): + """Start timer with TimersManager module. + + This module requires enabled TimerManager module. + """ + order = None + + def execute(self): + project_name = self.data.get("project_name") + asset_name = self.data.get("asset_name") + task_name = self.data.get("task_name") + + missing_context_keys = set() + if not project_name: + missing_context_keys.add("project_name") + if not asset_name: + missing_context_keys.add("asset_name") + if not task_name: + missing_context_keys.add("task_name") + + if missing_context_keys: + missing_keys_str = ", ".join([ + "\"{}\"".format(key) for key in missing_context_keys + ]) + self.log.debug("Hook {} skipped. Missing data keys: {}".format( + self.__class__.__name__, missing_keys_str + )) + return + + timers_manager = self.modules_manager.modules_by_name.get( + "timers_manager" + ) + if not timers_manager or not timers_manager.enabled: + self.log.info(( + "Skipping starting timer because" + " TimersManager is not available." + )) + return + + timers_manager.start_timer_with_webserver( + project_name, asset_name, task_name, logger=self.log + ) diff --git a/openpype/modules/default_modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py similarity index 77% rename from openpype/modules/default_modules/timers_manager/rest_api.py rename to openpype/modules/timers_manager/rest_api.py index 19b72d688b..f16cb316c3 100644 --- a/openpype/modules/default_modules/timers_manager/rest_api.py +++ b/openpype/modules/timers_manager/rest_api.py @@ -39,17 +39,23 @@ class TimersManagerModuleRestApi: async def start_timer(self, request): data = await request.json() try: - project_name = data['project_name'] - asset_name = data['asset_name'] - task_name = data['task_name'] - hierarchy = data['hierarchy'] + project_name = data["project_name"] + asset_name = data["asset_name"] + task_name = data["task_name"] except KeyError: - log.error("Payload must contain fields 'project_name, " + - "'asset_name', 'task_name', 'hierarchy'") - return Response(status=400) + msg = ( + "Payload must contain fields 'project_name," + " 'asset_name' and 'task_name'" + ) + log.error(msg) + return Response(status=400, message=msg) self.module.stop_timers() - self.module.start_timer(project_name, asset_name, task_name, hierarchy) + try: + self.module.start_timer(project_name, asset_name, task_name) + except Exception as exc: + return Response(status=404, message=str(exc)) + return Response(status=200) async def stop_timer(self, request): diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py similarity index 67% rename from openpype/modules/default_modules/timers_manager/timers_manager.py rename to openpype/modules/timers_manager/timers_manager.py index 0f165ff0ac..47d020104b 100644 --- a/openpype/modules/default_modules/timers_manager/timers_manager.py +++ b/openpype/modules/timers_manager/timers_manager.py @@ -1,9 +1,15 @@ import os import platform -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayService + from avalon.api import AvalonMongoDB +from openpype.modules import OpenPypeModule +from openpype_interfaces import ( + ITrayService, + ILaunchHookPaths +) +from .exceptions import InvalidContextError + class ExampleTimersManagerConnector: """Timers manager can handle timers of multiple modules/addons. @@ -64,7 +70,7 @@ class ExampleTimersManagerConnector: self._timers_manager_module.timer_stopped(self._module.id) -class TimersManager(OpenPypeModule, ITrayService): +class TimersManager(OpenPypeModule, ITrayService, ILaunchHookPaths): """ Handles about Timers. Should be able to start/stop all timers at once. @@ -151,47 +157,112 @@ class TimersManager(OpenPypeModule, ITrayService): self._idle_manager.stop() self._idle_manager.wait() - def start_timer(self, project_name, asset_name, task_name, hierarchy): - """ - Start timer for 'project_name', 'asset_name' and 'task_name' + def get_timer_data_for_path(self, task_path): + """Convert string path to a timer data. - Called from REST api by hosts. - - Args: - project_name (string) - asset_name (string) - task_name (string) - hierarchy (string) + It is expected that first item is project name, last item is task name + and parent asset name is before task name. """ + path_items = task_path.split("/") + if len(path_items) < 3: + raise InvalidContextError("Invalid path \"{}\"".format(task_path)) + task_name = path_items.pop(-1) + asset_name = path_items.pop(-1) + project_name = path_items.pop(0) + return self.get_timer_data_for_context( + project_name, asset_name, task_name, self.log + ) + + def get_launch_hook_paths(self): + """Implementation of `ILaunchHookPaths`.""" + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "launch_hooks" + ) + + @staticmethod + def get_timer_data_for_context( + project_name, asset_name, task_name, logger=None + ): + """Prepare data for timer related callbacks. + + TODO: + - return predefined object that has access to asset document etc. + """ + if not project_name or not asset_name or not task_name: + raise InvalidContextError(( + "Missing context information got" + " Project: \"{}\" Asset: \"{}\" Task: \"{}\"" + ).format(str(project_name), str(asset_name), str(task_name))) + dbconn = AvalonMongoDB() dbconn.install() dbconn.Session["AVALON_PROJECT"] = project_name - asset_doc = dbconn.find_one({ - "type": "asset", "name": asset_name - }) + asset_doc = dbconn.find_one( + { + "type": "asset", + "name": asset_name + }, + { + "data.tasks": True, + "data.parents": True + } + ) if not asset_doc: - raise ValueError("Uknown asset {}".format(asset_name)) + dbconn.uninstall() + raise InvalidContextError(( + "Asset \"{}\" not found in project \"{}\"" + ).format(asset_name, project_name)) - task_type = '' + asset_data = asset_doc.get("data") or {} + asset_tasks = asset_data.get("tasks") or {} + if task_name not in asset_tasks: + dbconn.uninstall() + raise InvalidContextError(( + "Task \"{}\" not found on asset \"{}\" in project \"{}\"" + ).format(task_name, asset_name, project_name)) + + task_type = "" try: - task_type = asset_doc["data"]["tasks"][task_name]["type"] + task_type = asset_tasks[task_name]["type"] except KeyError: - self.log.warning("Couldn't find task_type for {}". - format(task_name)) + msg = "Couldn't find task_type for {}".format(task_name) + if logger is not None: + logger.warning(msg) + else: + print(msg) - hierarchy = hierarchy.split("\\") - hierarchy.append(asset_name) + hierarchy_items = asset_data.get("parents") or [] + hierarchy_items.append(asset_name) - data = { + dbconn.uninstall() + return { "project_name": project_name, "task_name": task_name, "task_type": task_type, - "hierarchy": hierarchy + "hierarchy": hierarchy_items } + + def start_timer(self, project_name, asset_name, task_name): + """Start timer for passed context. + + Args: + project_name (str): Project name + asset_name (str): Asset name + task_name (str): Task name + """ + data = self.get_timer_data_for_context( + project_name, asset_name, task_name, self.log + ) self.timer_started(None, data) def get_task_time(self, project_name, asset_name, task_name): + """Get total time for passed context. + + TODO: + - convert context to timer data + """ times = {} for module_id, connector in self._connectors_by_module_id.items(): if hasattr(connector, "get_task_time"): @@ -202,6 +273,10 @@ class TimersManager(OpenPypeModule, ITrayService): return times def timer_started(self, source_id, data): + """Connector triggered that timer has started. + + New timer has started for context in data. + """ for module_id, connector in self._connectors_by_module_id.items(): if module_id == source_id: continue @@ -219,6 +294,14 @@ class TimersManager(OpenPypeModule, ITrayService): self.is_running = True def timer_stopped(self, source_id): + """Connector triggered that hist timer has stopped. + + Should stop all other timers. + + TODO: + - pass context for which timer has stopped to validate if timers are + same and valid + """ for module_id, connector in self._connectors_by_module_id.items(): if module_id == source_id: continue @@ -237,6 +320,7 @@ class TimersManager(OpenPypeModule, ITrayService): self.timer_started(None, self.last_task) def stop_timers(self): + """Stop all timers.""" if self.is_running is False: return @@ -295,18 +379,40 @@ class TimersManager(OpenPypeModule, ITrayService): self, server_manager ) - def change_timer_from_host(self, project_name, asset_name, task_name): - """Prepared method for calling change timers on REST api""" + @staticmethod + def start_timer_with_webserver( + project_name, asset_name, task_name, logger=None + ): + """Prepared method for calling change timers on REST api. + + Webserver must be active. At the moment is Webserver running only when + OpenPype Tray is used. + + Args: + project_name (str): Project name. + asset_name (str): Asset name. + task_name (str): Task name. + logger (logging.Logger): Logger object. Using 'print' if not + passed. + """ webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") if not webserver_url: - self.log.warning("Couldn't find webserver url") + msg = "Couldn't find webserver url" + if logger is not None: + logger.warning(msg) + else: + print(msg) return rest_api_url = "{}/timers_manager/start_timer".format(webserver_url) try: import requests except Exception: - self.log.warning("Couldn't start timer") + msg = "Couldn't start timer ('requests' is not available)" + if logger is not None: + logger.warning(msg) + else: + print(msg) return data = { "project_name": project_name, @@ -314,4 +420,4 @@ class TimersManager(OpenPypeModule, ITrayService): "task_name": task_name } - requests.post(rest_api_url, json=data) + return requests.post(rest_api_url, json=data) diff --git a/openpype/modules/default_modules/timers_manager/widget_user_idle.py b/openpype/modules/timers_manager/widget_user_idle.py similarity index 100% rename from openpype/modules/default_modules/timers_manager/widget_user_idle.py rename to openpype/modules/timers_manager/widget_user_idle.py diff --git a/openpype/plugins/load/delivery.py b/openpype/plugins/load/delivery.py index a8cb0070ee..1037d6dc16 100644 --- a/openpype/plugins/load/delivery.py +++ b/openpype/plugins/load/delivery.py @@ -1,13 +1,13 @@ -from collections import defaultdict import copy +from collections import defaultdict from Qt import QtWidgets, QtCore, QtGui -from avalon import api, style +from avalon import api from avalon.api import AvalonMongoDB from openpype.api import Anatomy, config -from openpype import resources +from openpype import resources, style from openpype.lib.delivery import ( sizeof_fmt, @@ -58,6 +58,18 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): def __init__(self, contexts, log=None, parent=None): super(DeliveryOptionsDialog, self).__init__(parent=parent) + self.setWindowTitle("OpenPype - Deliver versions") + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + + self.setWindowFlags( + QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.WindowCloseButtonHint + | QtCore.Qt.WindowMinimizeButtonHint + ) + + self.setStyleSheet(style.load_stylesheet()) + project = contexts[0]["project"]["name"] self.anatomy = Anatomy(project) self._representations = None @@ -70,16 +82,6 @@ class DeliveryOptionsDialog(QtWidgets.QDialog): self._set_representations(contexts) - self.setWindowTitle("OpenPype - Deliver versions") - icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) - self.setWindowIcon(icon) - - self.setWindowFlags( - QtCore.Qt.WindowCloseButtonHint | - QtCore.Qt.WindowMinimizeButtonHint - ) - self.setStyleSheet(style.load_stylesheet()) - dropdown = QtWidgets.QComboBox() self.templates = self._get_templates(self.anatomy) for name, _ in self.templates.items(): diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index b8104078d9..f29e6ccd4e 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -15,6 +15,25 @@ class CleanUp(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder + 10 label = "Clean Up" + hosts = [ + "aftereffects", + "blender", + "celaction", + "flame", + "fusion", + "harmony", + "hiero", + "houdini", + "maya", + "nuke", + "photoshop", + "resolve", + "tvpaint", + "unreal", + "standalonepublisher", + "webpublisher", + "shell" + ] exclude_families = ["clip"] optional = True active = True diff --git a/openpype/plugins/publish/cleanup_explicit.py b/openpype/plugins/publish/cleanup_explicit.py new file mode 100644 index 0000000000..88bba34532 --- /dev/null +++ b/openpype/plugins/publish/cleanup_explicit.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +"""Cleanup files when publishing is done.""" +import os +import shutil +import pyblish.api + + +class ExplicitCleanUp(pyblish.api.ContextPlugin): + """Cleans up the files and folder defined to be deleted. + + plugin is looking for 2 keys into context data: + - `cleanupFullPaths` - full paths that should be removed not matter if + is path to file or to directory + - `cleanupEmptyDirs` - full paths to directories that should be removed + only if do not contain any file in it but will be removed if contain + sub-folders + """ + + order = pyblish.api.IntegratorOrder + 10 + label = "Explicit Clean Up" + optional = True + active = True + + def process(self, context): + cleanup_full_paths = context.data.get("cleanupFullPaths") + cleanup_empty_dirs = context.data.get("cleanupEmptyDirs") + + self._remove_full_paths(cleanup_full_paths) + self._remove_empty_dirs(cleanup_empty_dirs) + + def _remove_full_paths(self, full_paths): + """Remove files and folders from disc. + + Folders are removed with whole content. + """ + if not full_paths: + self.log.debug("No full paths to cleanup were collected.") + return + + # Separate paths into files and directories + filepaths = set() + dirpaths = set() + for path in full_paths: + # Skip empty items + if not path: + continue + # Normalize path + normalized = os.path.normpath(path) + # Check if path exists + if not os.path.exists(normalized): + continue + + if os.path.isfile(normalized): + filepaths.add(normalized) + else: + dirpaths.add(normalized) + + # Store failed paths with exception + failed = [] + # Store removed filepaths for logging + succeded_files = set() + # Remove file by file + for filepath in filepaths: + try: + os.remove(filepath) + succeded_files.add(filepath) + except Exception as exc: + failed.append((filepath, exc)) + + if succeded_files: + self.log.info( + "Removed files:\n{}".format("\n".join(succeded_files)) + ) + + # Delete folders with it's content + succeded_dirs = set() + for dirpath in dirpaths: + # Check if directory still exists + # - it is possible that directory was already deleted with + # different dirpath to delete + if os.path.exists(dirpath): + try: + shutil.rmtree(dirpath) + succeded_dirs.add(dirpath) + except Exception: + failed.append(dirpath) + + if succeded_dirs: + self.log.info( + "Removed direcoties:\n{}".format("\n".join(succeded_dirs)) + ) + + # Prepare lines for report of failed removements + lines = [] + for filepath, exc in failed: + lines.append("{}: {}".format(filepath, str(exc))) + + if lines: + self.log.warning( + "Failed to remove filepaths:\n{}".format("\n".join(lines)) + ) + + def _remove_empty_dirs(self, empty_dirpaths): + """Remove directories if do not contain any files.""" + if not empty_dirpaths: + self.log.debug("No empty dirs to cleanup were collected.") + return + + # First filtering of directories and making sure those are + # existing directories + filtered_dirpaths = set() + for path in empty_dirpaths: + if ( + path + and os.path.exists(path) + and os.path.isdir(path) + ): + filtered_dirpaths.add(os.path.normpath(path)) + + to_delete_dirpaths = set() + to_skip_dirpaths = set() + # Check if contain any files (or it's subfolders contain files) + for dirpath in filtered_dirpaths: + valid = True + for _, _, filenames in os.walk(dirpath): + if filenames: + valid = False + break + + if valid: + to_delete_dirpaths.add(dirpath) + else: + to_skip_dirpaths.add(dirpath) + + if to_skip_dirpaths: + self.log.debug( + "Skipped directories because contain files:\n{}".format( + "\n".join(to_skip_dirpaths) + ) + ) + + # Remove empty directies + for dirpath in to_delete_dirpaths: + if os.path.exists(dirpath): + shutil.rmtree(dirpath) + + if to_delete_dirpaths: + self.log.debug( + "Deleted empty directories:\n{}".format( + "\n".join(to_delete_dirpaths) + ) + ) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index df7dc47e17..459c66ee43 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -13,7 +13,7 @@ import pyblish import openpype import openpype.api from openpype.lib import ( - get_pype_execute_args, + run_openpype_process, get_transcode_temp_directory, convert_for_ffmpeg, @@ -168,9 +168,8 @@ class ExtractBurnin(openpype.api.Extractor): anatomy = instance.context.data["anatomy"] scriptpath = self.burnin_script_path() - # Executable args that will execute the script - # [pype executable, *pype script, "run"] - executable_args = get_pype_execute_args("run", scriptpath) + # Args that will execute the script + executable_args = ["run", scriptpath] burnins_per_repres = self._get_burnins_per_representations( instance, burnin_defs ) @@ -313,7 +312,7 @@ class ExtractBurnin(openpype.api.Extractor): if platform.system().lower() == "windows": process_kwargs["creationflags"] = CREATE_NO_WINDOW - openpype.api.run_subprocess(args, **process_kwargs) + run_openpype_process(*args, **process_kwargs) # Remove the temporary json os.remove(temporary_json_filepath) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index b6c2e49385..be29c7bf9c 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -187,6 +187,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) + fill_data = copy.deepcopy(instance.data["anatomyData"]) for repre, outputs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) @@ -293,7 +294,7 @@ class ExtractReview(pyblish.api.InstancePlugin): try: # temporary until oiiotool is supported cross platform ffmpeg_args = self._ffmpeg_arguments( - output_def, instance, new_repre, temp_data + output_def, instance, new_repre, temp_data, fill_data ) except ZeroDivisionError: if 'exr' in temp_data["origin_repre"]["ext"]: @@ -446,7 +447,9 @@ class ExtractReview(pyblish.api.InstancePlugin): "handles_are_set": handles_are_set } - def _ffmpeg_arguments(self, output_def, instance, new_repre, temp_data): + def _ffmpeg_arguments( + self, output_def, instance, new_repre, temp_data, fill_data + ): """Prepares ffmpeg arguments for expected extraction. Prepares input and output arguments based on output definition and @@ -472,9 +475,6 @@ class ExtractReview(pyblish.api.InstancePlugin): ffmpeg_input_args = [ value for value in _ffmpeg_input_args if value.strip() ] - ffmpeg_output_args = [ - value for value in _ffmpeg_output_args if value.strip() - ] ffmpeg_video_filters = [ value for value in _ffmpeg_video_filters if value.strip() ] @@ -482,6 +482,21 @@ class ExtractReview(pyblish.api.InstancePlugin): value for value in _ffmpeg_audio_filters if value.strip() ] + ffmpeg_output_args = [] + for value in _ffmpeg_output_args: + value = value.strip() + if not value: + continue + try: + value = value.format(**fill_data) + except Exception: + self.log.warning( + "Failed to format ffmpeg argument: {}".format(value), + exc_info=True + ) + pass + ffmpeg_output_args.append(value) + # Prepare input and output filepaths self.input_output_paths(new_repre, output_def, temp_data) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 1b0b8da2ff..cec2e470b3 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -580,7 +580,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if repre.get("outputName"): representation["context"]["output"] = repre['outputName'] - if sequence_repre and repre.get("frameStart"): + if sequence_repre and repre.get("frameStart") is not None: representation['context']['frame'] = ( dst_padding_exp % int(repre.get("frameStart")) ) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index a6330bae1f..e25b56744e 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -305,13 +305,16 @@ class PypeCommands: log.info("Publish finished.") @staticmethod - def extractenvironments(output_json_path, project, asset, task, app): - env = os.environ.copy() + def extractenvironments( + output_json_path, project, asset, task, app, env_group + ): if all((project, asset, task, app)): from openpype.api import get_app_environments_for_context env = get_app_environments_for_context( - project, asset, task, app, env + project, asset, task, app, env_group ) + else: + env = os.environ.copy() output_dir = os.path.dirname(output_json_path) if not os.path.exists(output_dir): diff --git a/openpype/scripts/non_python_host_launch.py b/openpype/scripts/non_python_host_launch.py index 32c4b23f4f..6b17e6a037 100644 --- a/openpype/scripts/non_python_host_launch.py +++ b/openpype/scripts/non_python_host_launch.py @@ -81,7 +81,7 @@ def main(argv): host_name = os.environ["AVALON_APP"].lower() if host_name == "photoshop": - from avalon.photoshop.lib import main + from openpype.hosts.photoshop.api.lib import main elif host_name == "aftereffects": from avalon.aftereffects.lib import main elif host_name == "harmony": diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 15a62ef38e..639657d68f 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -157,6 +157,16 @@ def _dnxhd_codec_args(stream_data, source_ffmpeg_cmd): if pix_fmt: output.extend(["-pix_fmt", pix_fmt]) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-b:v", "-vb", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + output.extend([arg, args[idx + 1]]) + output.extend(["-g", "1"]) return output @@ -359,7 +369,8 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if frame_start is None: replacement_final = replacement_size = str(MISSING_KEY_VALUE) else: - replacement_final = "%{eif:n+" + str(frame_start) + ":d}" + replacement_final = "%{eif:n+" + str(frame_start) + ":d:" + \ + str(len(str(frame_end))) + "}" replacement_size = str(frame_end) final_text = final_text.replace( @@ -715,6 +726,15 @@ def burnins_from_data( ffmpeg_args.extend( get_codec_args(burnin.ffprobe_data, source_ffmpeg_cmd) ) + # Use arguments from source if are available source arguments + if source_ffmpeg_cmd: + copy_args = ( + "-metadata", + ) + args = source_ffmpeg_cmd.split(" ") + for idx, arg in enumerate(args): + if arg in copy_args: + ffmpeg_args.extend([arg, args[idx + 1]]) # Use group one (same as `-intra` argument, which is deprecated) ffmpeg_args_str = " ".join(ffmpeg_args) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json new file mode 100644 index 0000000000..b6fbdecc95 --- /dev/null +++ b/openpype/settings/defaults/project_settings/flame.json @@ -0,0 +1,20 @@ +{ + "create": { + "CreateShotClip": { + "hierarchy": "{folder}/{sequence}", + "clipRename": true, + "clipName": "{track}{sequence}{shot}", + "countFrom": 10, + "countSteps": 10, + "folder": "shots", + "episode": "ep01", + "sequence": "sq01", + "track": "{_track_}", + "shot": "sh###", + "vSyncOn": false, + "workfileFrameStart": 1001, + "handleStart": 10, + "handleEnd": 10 + } + } +} \ 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 55732f80ce..cff1259c98 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -291,21 +291,7 @@ "enabled": false } ], - "sw_folders": { - "compositing": [ - "nuke", - "ae" - ], - "modeling": [ - "maya", - "blender", - "zbrush" - ], - "lookdev": [ - "substance", - "textures" - ] - } + "extra_folders": [] }, "loader": { "family_filter_profiles": [ diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index b75b0168ec..a756071106 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -166,6 +166,11 @@ "enabled": false, "regex": "(?P.*)_(.*)_SHD" }, + "ValidateShadingEngine": { + "enabled": true, + "optional": true, + "active": true + }, "ValidateAttributes": { "enabled": false, "attributes": {} diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 1cbe09f576..4a8b6d82a2 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -129,7 +129,11 @@ "darwin": [], "linux": [] }, - "environment": {} + "environment": { + "OPENPYPE_FLAME_PYTHON_EXEC": "/opt/Autodesk/python/2021/bin/python2.7", + "OPENPYPE_FLAME_PYTHONPATH": "/opt/Autodesk/flame_2021/python", + "OPENPYPE_WIRETAP_TOOLS": "/opt/Autodesk/wiretap/tools/2021" + } }, "__dynamic_keys_labels__": { "2021": "2021 (Testing Only)" @@ -142,7 +146,10 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"] + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] }, "variants": { "13-0": { @@ -248,7 +255,10 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": ["{NUKE_PATH}", "{OPENPYPE_STUDIO_PLUGINS}/nuke"] + "NUKE_PATH": [ + "{NUKE_PATH}", + "{OPENPYPE_STUDIO_PLUGINS}/nuke" + ] }, "variants": { "13-0": { diff --git a/openpype/settings/defaults/system_settings/general.json b/openpype/settings/defaults/system_settings/general.json index f54e8b2b16..a07152eaf8 100644 --- a/openpype/settings/defaults/system_settings/general.json +++ b/openpype/settings/defaults/system_settings/general.json @@ -2,6 +2,8 @@ "studio_name": "Studio name", "studio_code": "stu", "admin_password": "", + "production_version": "", + "staging_version": "", "environment": { "__environment_keys__": { "global": [] diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index f0caa153de..b31dd6856c 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -15,8 +15,16 @@ "ftrack": { "enabled": true, "ftrack_server": "", - "ftrack_actions_path": [], - "ftrack_events_path": [], + "ftrack_actions_path": { + "windows": [], + "darwin": [], + "linux": [] + }, + "ftrack_events_path": { + "windows": [], + "darwin": [], + "linux": [] + }, "intent": { "items": { "-": "-", diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index ccf2a5993e..a173e2454f 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -57,7 +57,7 @@ from .exceptions import ( SchemaError, DefaultsNotDefined, StudioDefaultsNotDefined, - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType, InvalidKeySymbols, SchemaMissingFileInfo, @@ -106,7 +106,7 @@ from .enum_entity import ( ToolsEnumEntity, TaskTypeEnumEntity, DeadlineUrlEnumEntity, - AnatomyTemplatesEnumEntity + AnatomyTemplatesEnumEntity, ) from .list_entity import ListEntity @@ -122,12 +122,15 @@ from .dict_conditional import ( ) from .anatomy_entities import AnatomyEntity - +from .op_version_entity import ( + ProductionVersionsInputEntity, + StagingVersionsInputEntity +) __all__ = ( "DefaultsNotDefined", "StudioDefaultsNotDefined", - "BaseInvalidValueType", + "BaseInvalidValue", "InvalidValueType", "InvalidKeySymbols", "SchemaMissingFileInfo", @@ -181,5 +184,8 @@ __all__ = ( "DictConditionalEntity", "SyncServerProviders", - "AnatomyEntity" + "AnatomyEntity", + + "ProductionVersionsInputEntity", + "StagingVersionsInputEntity" ) diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index cbc042d29d..582937481a 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -9,7 +9,7 @@ from .lib import ( ) from .exceptions import ( - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType, SchemeGroupHierarchyBug, EntitySchemaError @@ -437,7 +437,7 @@ class BaseItemEntity(BaseEntity): try: new_value = self.convert_to_valid_type(value) - except BaseInvalidValueType: + except BaseInvalidValue: new_value = NOT_SET if new_value is not NOT_SET: diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index 3becf2d865..bdaab6f583 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -1,7 +1,7 @@ from .lib import STRING_TYPE from .input_entities import InputEntity from .exceptions import ( - BaseInvalidValueType, + BaseInvalidValue, InvalidValueType ) @@ -47,7 +47,7 @@ class ColorEntity(InputEntity): reason = "Color entity expect 4 items in list got {}".format( len(value) ) - raise BaseInvalidValueType(reason, self.path) + raise BaseInvalidValue(reason, self.path) new_value = [] for item in value: @@ -60,7 +60,7 @@ class ColorEntity(InputEntity): reason = ( "Color entity expect 4 integers in range 0-255 got {}" ).format(value) - raise BaseInvalidValueType(reason, self.path) + raise BaseInvalidValue(reason, self.path) new_value.append(item) # Make sure diff --git a/openpype/settings/entities/exceptions.py b/openpype/settings/entities/exceptions.py index f352c94f20..d1728a7b12 100644 --- a/openpype/settings/entities/exceptions.py +++ b/openpype/settings/entities/exceptions.py @@ -15,14 +15,14 @@ class StudioDefaultsNotDefined(Exception): super(StudioDefaultsNotDefined, self).__init__(msg) -class BaseInvalidValueType(Exception): +class BaseInvalidValue(Exception): def __init__(self, reason, path): msg = "Path \"{}\". {}".format(path, reason) self.msg = msg - super(BaseInvalidValueType, self).__init__(msg) + super(BaseInvalidValue, self).__init__(msg) -class InvalidValueType(BaseInvalidValueType): +class InvalidValueType(BaseInvalidValue): def __init__(self, valid_types, invalid_type, path): joined_types = ", ".join( [str(valid_type) for valid_type in valid_types] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 16893747a6..ff32df9262 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -441,6 +441,16 @@ class TextEntity(InputEntity): # GUI attributes self.multiline = self.schema_data.get("multiline", False) self.placeholder_text = self.schema_data.get("placeholder") + self.value_hints = self.schema_data.get("value_hints") or [] + + def schema_validations(self): + if self.multiline and self.value_hints: + reason = ( + "TextEntity entity can't use value hints" + " for multiline input (yet)." + ) + raise EntitySchemaError(self, reason) + super(TextEntity, self).schema_validations() def _convert_to_valid_type(self, value): # Allow numbers converted to string diff --git a/openpype/settings/entities/op_version_entity.py b/openpype/settings/entities/op_version_entity.py new file mode 100644 index 0000000000..782d65a446 --- /dev/null +++ b/openpype/settings/entities/op_version_entity.py @@ -0,0 +1,89 @@ +from openpype.lib.openpype_version import ( + get_remote_versions, + get_OpenPypeVersion, + get_installed_version +) +from .input_entities import TextEntity +from .lib import ( + OverrideState, + NOT_SET +) +from .exceptions import BaseInvalidValue + + +class OpenPypeVersionInput(TextEntity): + """Entity to store OpenPype version to use. + + Settings created on another machine may affect available versions + on current user's machine. Text input element is provided to explicitly + set version not yet showing up the user's machine. + + It is possible to enter empty string. In that case is used any latest + version. Any other string must match regex of OpenPype version semantic. + """ + def _item_initialization(self): + super(OpenPypeVersionInput, self)._item_initialization() + self.multiline = False + self.placeholder_text = "Latest" + self.value_hints = [] + + def _get_openpype_versions(self): + """This is abstract method returning version hints for UI purposes.""" + raise NotImplementedError(( + "{} does not have implemented '_get_openpype_versions'" + ).format(self.__class__.__name__)) + + def set_override_state(self, state, *args, **kwargs): + """Update value hints for UI purposes.""" + value_hints = [] + if state is OverrideState.STUDIO: + versions = self._get_openpype_versions() + for version in versions: + version_str = str(version) + if version_str not in value_hints: + value_hints.append(version_str) + + self.value_hints = value_hints + + super(OpenPypeVersionInput, self).set_override_state( + state, *args, **kwargs + ) + + def convert_to_valid_type(self, value): + """Add validation of version regex.""" + if value and value is not NOT_SET: + OpenPypeVersion = get_OpenPypeVersion() + if OpenPypeVersion is not None: + try: + OpenPypeVersion(version=value) + except Exception: + raise BaseInvalidValue( + "Value \"{}\"is not valid version format.".format( + value + ), + self.path + ) + return super(OpenPypeVersionInput, self).convert_to_valid_type(value) + + +class ProductionVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define production version.""" + schema_types = ["production-versions-text"] + + def _get_openpype_versions(self): + versions = get_remote_versions(staging=False, production=True) + if versions is None: + return [] + versions.append(get_installed_version()) + return sorted(versions) + + +class StagingVersionsInputEntity(OpenPypeVersionInput): + """Entity meant only for global settings to define staging version.""" + schema_types = ["staging-versions-text"] + + def _get_openpype_versions(self): + versions = get_remote_versions(staging=True, production=False) + if versions is None: + return [] + return sorted(versions) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index c9eca5dedd..8a2ad451ee 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -110,6 +110,10 @@ "type": "schema", "name": "schema_project_celaction" }, + { + "type": "schema", + "name": "schema_project_flame" + }, { "type": "schema", "name": "schema_project_resolve" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json new file mode 100644 index 0000000000..d713c37620 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -0,0 +1,124 @@ +{ + "type": "dict", + "collapsible": true, + "key": "flame", + "label": "Flame", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Create plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CreateShotClip", + "label": "Create Shot Clip", + "is_group": true, + "children": [ + { + "type": "collapsible-wrap", + "label": "Shot Hierarchy And Rename Settings", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "hierarchy", + "label": "Shot parent hierarchy" + }, + { + "type": "boolean", + "key": "clipRename", + "label": "Rename clips" + }, + { + "type": "text", + "key": "clipName", + "label": "Clip name template" + }, + { + "type": "number", + "key": "countFrom", + "label": "Count sequence from" + }, + { + "type": "number", + "key": "countSteps", + "label": "Stepping number" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Shot Template Keywords", + "collapsible": false, + "children": [ + { + "type": "text", + "key": "folder", + "label": "{folder}" + }, + { + "type": "text", + "key": "episode", + "label": "{episode}" + }, + { + "type": "text", + "key": "sequence", + "label": "{sequence}" + }, + { + "type": "text", + "key": "track", + "label": "{track}" + }, + { + "type": "text", + "key": "shot", + "label": "{shot}" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Vertical Synchronization Of Attributes", + "collapsible": false, + "children": [ + { + "type": "boolean", + "key": "vSyncOn", + "label": "Enable Vertical Sync" + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Shot Attributes", + "collapsible": false, + "children": [ + { + "type": "number", + "key": "workfileFrameStart", + "label": "Workfiles Start Frame" + }, + { + "type": "number", + "key": "handleStart", + "label": "Handle start (head)" + }, + { + "type": "number", + "key": "handleEnd", + "label": "Handle end (tail)" + } + ] + } + ] + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 9ca4e443bd..4e82c991e7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -91,6 +91,11 @@ "key": "upload_thumbnail", "label": "Upload thumbnail" }, + { + "type": "boolean", + "key": "upload_review", + "label": "Upload review" + }, { "type": "text", "multiline": 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 26d3771d8a..bb71c9bde6 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 @@ -195,14 +195,48 @@ } }, { - "type": "dict-modifiable", + "type": "list", + "key": "extra_folders", + "label": "Extra work folders", "collapsible": true, - "key": "sw_folders", - "label": "Extra task folders", + "use_label_wrap": true, "is_group": true, "object_type": { - "type": "list", - "object_type": "text" + "type": "dict", + "children": [ + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "type": "task-types-enum", + "key": "task_types", + "label": "Task types" + }, + { + "label": "Task names", + "key": "task_names", + "type": "list", + "object_type": "text" + }, + { + "type": "splitter" + }, + { + "type": "label", + "label": "Folders will be created in directory next to workfile. Items may contain nested directories (e.g. resources/images)." + }, + { + "key": "folders", + "label": "Folders", + "type": "list", + "highlight_content": true, + "collapsible": false, + "object_type": "text" + } + ] } } ] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 606dd6c2bb..7c9a5a6b46 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -72,6 +72,17 @@ ] }, + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateShadingEngine", + "label": "Validate Look Shading Engine Naming" + } + ] + }, + { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json index 5f659522c3..654ddf2938 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json @@ -21,19 +21,23 @@ }, { "type": "label", - "label": "Additional Ftrack paths" + "label": "Additional Ftrack event handlers paths" }, { - "type": "list", + "type": "path", "key": "ftrack_actions_path", - "label": "Action paths", - "object_type": "text" + "label": "User paths", + "use_label_wrap": true, + "multipath": true, + "multiplatform": true }, { - "type": "list", + "type": "path", "key": "ftrack_events_path", - "label": "Event paths", - "object_type": "text" + "label": "Server paths", + "use_label_wrap": true, + "multipath": true, + "multiplatform": true }, { "type": "separator" diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 51a58a6e27..b4c83fc85f 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -20,7 +20,7 @@ }, { "type": "label", - "label": "This is NOT a securely stored password!. It only acts as a simple barrier to stop users from accessing studio wide settings." + "label": "This is NOT a securely stored password! It only acts as a simple barrier to stop users from accessing studio wide settings." }, { "type": "text", @@ -30,6 +30,23 @@ { "type": "splitter" }, + { + "type": "label", + "label": "Define explicit OpenPype version that should be used. Keep empty to use latest available version." + }, + { + "type": "production-versions-text", + "key": "production_version", + "label": "Production version" + }, + { + "type": "staging-versions-text", + "key": "staging_version", + "label": "Staging version" + }, + { + "type": "splitter" + }, { "key": "environment", "label": "Environment", diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index c59e2bc542..51e390bb6d 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -168,7 +168,13 @@ class CacheValues: class MongoSettingsHandler(SettingsHandler): """Settings handler that use mongo for storing and loading of settings.""" - global_general_keys = ("openpype_path", "admin_password", "disk_mapping") + global_general_keys = ( + "openpype_path", + "admin_password", + "disk_mapping", + "production_version", + "staging_version" + ) def __init__(self): # Get mongo connection diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index cb0595d522..ea88b342ee 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -1,8 +1,10 @@ import os import json import collections -from openpype import resources import six + +from openpype import resources + from .color_defs import parse_color @@ -12,6 +14,18 @@ _FONT_IDS = None current_dir = os.path.dirname(os.path.abspath(__file__)) +def get_style_image_path(image_name): + # All filenames are lowered + image_name = image_name.lower() + # Male sure filename has png extension + if not image_name.endswith(".png"): + image_name += ".png" + filepath = os.path.join(current_dir, "images", image_name) + if os.path.exists(filepath): + return filepath + return None + + def _get_colors_raw_data(): """Read data file with stylesheet fill values. @@ -160,6 +174,11 @@ def load_stylesheet(): return _STYLESHEET_CACHE -def app_icon_path(): +def get_app_icon_path(): """Path to OpenPype icon.""" return resources.get_openpype_icon_filepath() + + +def app_icon_path(): + # Backwards compatibility + return get_app_icon_path() diff --git a/openpype/style/data.json b/openpype/style/data.json index 026eaf4264..b3dffd7c71 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -51,6 +51,9 @@ "border-hover": "rgba(168, 175, 189, .3)", "border-focus": "rgb(92, 173, 214)", + "delete-btn-bg": "rgb(201, 54, 54)", + "delete-btn-bg-disabled": "rgba(201, 54, 54, 64)", + "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", @@ -111,7 +114,9 @@ "focus-border": "#839caf", "image-btn": "#bfccd6", "image-btn-hover": "#189aea", - "image-btn-disabled": "#bfccd6" + "image-btn-disabled": "#bfccd6", + "version-exists": "#458056", + "version-not-found": "#ffc671" } } } diff --git a/openpype/style/style.css b/openpype/style/style.css index 4159fe1676..7f7f30e2bc 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -536,6 +536,27 @@ QAbstractItemView::branch:!has-children:!has-siblings:adjoins-item { background: transparent; } +CompleterView { + border: 1px solid #555555; + background: {color:bg-inputs}; +} + +CompleterView::item:selected { + background: {color:bg-view-hover}; +} + +CompleterView::item:selected:hover { + background: {color:bg-view-hover}; +} + +CompleterView::right-arrow { + min-width: 10px; +} +CompleterView::separator { + background: {color:bg-menu-separator}; + height: 2px; + margin-right: 5px; +} /* Progress bar */ QProgressBar { @@ -713,6 +734,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-view-hover}; } +#DeleteButton { + background: {color:delete-btn-bg}; +} +#DeleteButton:disabled { + background: {color:delete-btn-bg-disabled}; +} + /* Launcher specific stylesheets */ #IconView[mode="icon"] { /* font size can't be set on items */ @@ -1158,6 +1186,14 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border-radius: 5px; } +#OpenPypeVersionLabel[state="success"] { + color: {color:settings:version-exists}; +} + +#OpenPypeVersionLabel[state="warning"] { + color: {color:settings:version-not-found}; +} + #ShadowWidget { font-size: 36pt; } diff --git a/openpype/tools/creator/widgets.py b/openpype/tools/creator/widgets.py index 89c90cc048..9dd435c1cc 100644 --- a/openpype/tools/creator/widgets.py +++ b/openpype/tools/creator/widgets.py @@ -7,9 +7,10 @@ from avalon.vendor import qtawesome from openpype import style from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS +from openpype.tools.utils import ErrorMessageBox -class CreateErrorMessageBox(QtWidgets.QDialog): +class CreateErrorMessageBox(ErrorMessageBox): def __init__( self, family, @@ -17,23 +18,38 @@ class CreateErrorMessageBox(QtWidgets.QDialog): asset_name, exc_msg, formatted_traceback, - parent=None + parent ): - super(CreateErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Creation failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) - self.setWindowFlags( - self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint - ) + self._family = family + self._subset_name = subset_name + self._asset_name = asset_name + self._exc_msg = exc_msg + self._formatted_traceback = formatted_traceback + super(CreateErrorMessageBox, self).__init__("Creation failed", parent) - body_layout = QtWidgets.QVBoxLayout(self) - - main_label = ( + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( "Failed to create" ) - main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) + return label_widget + def _get_report_data(self): + report_message = ( + "Failed to create Subset: \"{subset}\" Family: \"{family}\"" + " in Asset: \"{asset}\"" + "\n\nError: {message}" + ).format( + subset=self._subset_name, + family=self._family, + asset=self._asset_name, + message=self._exc_msg + ) + if self._formatted_traceback: + report_message += "\n\n{}".format(self._formatted_traceback) + return [report_message] + + def _create_content(self, content_layout): item_name_template = ( "Family: {}
" "Subset: {}
" @@ -42,50 +58,29 @@ class CreateErrorMessageBox(QtWidgets.QDialog): exc_msg_template = "{}" line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) - item_name = item_name_template.format(family, subset_name, asset_name) - item_name_widget = QtWidgets.QLabel( - item_name.replace("\n", "
"), self - ) - body_layout.addWidget(item_name_widget) - - exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) - message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) - - if formatted_traceback: - tb_widget = QtWidgets.QLabel( - formatted_traceback.replace("\n", "
"), self + item_name_widget = QtWidgets.QLabel(self) + item_name_widget.setText( + item_name_template.format( + self._family, self._subset_name, self._asset_name ) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) - - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - button_box.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok ) - button_box.accepted.connect(self._on_accept) - footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) + content_layout.addWidget(item_name_widget) - def showEvent(self, event): - self.setStyleSheet(style.load_stylesheet()) - super(CreateErrorMessageBox, self).showEvent(event) + message_label_widget = QtWidgets.QLabel(self) + message_label_widget.setText( + exc_msg_template.format(self.convert_text_for_html(self._exc_msg)) + ) + content_layout.addWidget(message_label_widget) - def _on_accept(self): - self.close() - - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line + if self._formatted_traceback: + line_widget = self._create_line() + tb_widget = self._create_traceback_widget( + self._formatted_traceback + ) + content_layout.addWidget(line_widget) + content_layout.addWidget(tb_widget) class SubsetNameValidator(QtGui.QRegExpValidator): diff --git a/openpype/tools/creator/window.py b/openpype/tools/creator/window.py index dca1735121..22a6d5ce9c 100644 --- a/openpype/tools/creator/window.py +++ b/openpype/tools/creator/window.py @@ -445,7 +445,11 @@ class CreatorWindow(QtWidgets.QDialog): if error_info: box = CreateErrorMessageBox( - creator_plugin.family, subset_name, asset_name, *error_info + creator_plugin.family, + subset_name, + asset_name, + *error_info, + parent=self ) box.show() # Store dialog so is not garbage collected before is shown diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index 583065633b..62bf5538de 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -363,7 +363,6 @@ class LoaderWindow(QtWidgets.QDialog): # Active must be in the selected rows otherwise we # assume it's not actually an "active" current index. - version_docs = None version_doc = None active = selection.currentIndex() rows = selection.selectedRows(column=active.column()) @@ -375,9 +374,10 @@ class LoaderWindow(QtWidgets.QDialog): not (item.get("isGroup") or item.get("isMerged")) ): version_doc = item["version_document"] + self._version_info_widget.set_version(version_doc) + version_docs = [] if rows: - version_docs = [] for index in rows: if not index or not index.isValid(): continue @@ -390,8 +390,6 @@ class LoaderWindow(QtWidgets.QDialog): else: version_docs.append(item["version_document"]) - self._version_info_widget.set_version(version_doc) - thumbnail_src_ids = [ version_doc["_id"] for version_doc in version_docs @@ -402,7 +400,7 @@ class LoaderWindow(QtWidgets.QDialog): self._thumbnail_widget.set_thumbnail(thumbnail_src_ids) if self._repres_widget is not None: - version_ids = [doc["_id"] for doc in version_docs or []] + version_ids = [doc["_id"] for doc in version_docs] self._repres_widget.set_version_ids(version_ids) # self._repres_widget.change_visibility("subset", len(rows) > 1) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index ea45fd4364..ed130f765c 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -11,7 +11,10 @@ from Qt import QtWidgets, QtCore, QtGui from avalon import api, pipeline from avalon.lib import HeroVersionType -from openpype.tools.utils import lib as tools_lib +from openpype.tools.utils import ( + ErrorMessageBox, + lib as tools_lib +) from openpype.tools.utils.delegates import ( VersionDelegate, PrettyTimeDelegate @@ -64,20 +67,37 @@ class OverlayFrame(QtWidgets.QFrame): self.label_widget.setText(label) -class LoadErrorMessageBox(QtWidgets.QDialog): +class LoadErrorMessageBox(ErrorMessageBox): def __init__(self, messages, parent=None): - super(LoadErrorMessageBox, self).__init__(parent) - self.setWindowTitle("Loading failed") - self.setFocusPolicy(QtCore.Qt.StrongFocus) + self._messages = messages + super(LoadErrorMessageBox, self).__init__("Loading failed", parent) - body_layout = QtWidgets.QVBoxLayout(self) - - main_label = ( + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( "Failed to load items" ) - main_label_widget = QtWidgets.QLabel(main_label, self) - body_layout.addWidget(main_label_widget) + return label_widget + def _get_report_data(self): + report_data = [] + for exc_msg, tb_text, repre, subset, version in self._messages: + report_message = ( + "During load error happened on Subset: \"{subset}\"" + " Representation: \"{repre}\" Version: {version}" + "\n\nError message: {message}" + ).format( + subset=subset, + repre=repre, + version=version, + message=exc_msg + ) + if tb_text: + report_message += "\n\n{}".format(tb_text) + report_data.append(report_message) + return report_data + + def _create_content(self, content_layout): item_name_template = ( "Subset: {}
" "Version: {}
" @@ -85,46 +105,27 @@ class LoadErrorMessageBox(QtWidgets.QDialog): ) exc_msg_template = "{}" - for exc_msg, tb, repre, subset, version in messages: + for exc_msg, tb_text, repre, subset, version in self._messages: line = self._create_line() - body_layout.addWidget(line) + content_layout.addWidget(line) item_name = item_name_template.format(subset, version, repre) item_name_widget = QtWidgets.QLabel( item_name.replace("\n", "
"), self ) - body_layout.addWidget(item_name_widget) + item_name_widget.setWordWrap(True) + content_layout.addWidget(item_name_widget) exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) message_label_widget = QtWidgets.QLabel(exc_msg, self) - body_layout.addWidget(message_label_widget) + message_label_widget.setWordWrap(True) + content_layout.addWidget(message_label_widget) - if tb: - tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self) - tb_widget.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - body_layout.addWidget(tb_widget) - - footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QHBoxLayout(footer_widget) - buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) - buttonBox.setStandardButtons( - QtWidgets.QDialogButtonBox.StandardButton.Ok - ) - buttonBox.accepted.connect(self._on_accept) - footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight) - body_layout.addWidget(footer_widget) - - def _on_accept(self): - self.close() - - def _create_line(self): - line = QtWidgets.QFrame(self) - line.setFixedHeight(2) - line.setFrameShape(QtWidgets.QFrame.HLine) - line.setFrameShadow(QtWidgets.QFrame.Sunken) - return line + if tb_text: + line = self._create_line() + tb_widget = self._create_traceback_widget(tb_text, self) + content_layout.addWidget(line) + content_layout.addWidget(tb_widget) class SubsetWidget(QtWidgets.QWidget): @@ -535,7 +536,7 @@ class SubsetWidget(QtWidgets.QWidget): self.load_ended.emit() if error_info: - box = LoadErrorMessageBox(error_info) + box = LoadErrorMessageBox(error_info, self) box.show() def selected_subsets(self, _groups=False, _merged=False, _other=True): @@ -1431,7 +1432,7 @@ class RepresentationWidget(QtWidgets.QWidget): self.load_ended.emit() if errors: - box = LoadErrorMessageBox(errors) + box = LoadErrorMessageBox(errors, self) box.show() def _get_optional_labels(self, loaders, selected_side): diff --git a/openpype/tools/project_manager/project_manager/images/warning.png b/openpype/tools/project_manager/project_manager/images/warning.png new file mode 100644 index 0000000000..3b4ae861f9 Binary files /dev/null and b/openpype/tools/project_manager/project_manager/images/warning.png differ diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index d3d6857a63..9fa7a5520b 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -1,6 +1,7 @@ import os from Qt import QtCore, QtGui +from openpype.style import get_objected_colors from avalon.vendor import qtawesome @@ -90,6 +91,17 @@ class ResourceCache: icon.addPixmap(disabled_pix, QtGui.QIcon.Disabled, QtGui.QIcon.Off) return icon + @classmethod + def get_warning_pixmap(cls): + src_image = get_warning_image() + colors = get_objected_colors() + color_value = colors["delete-btn-bg"] + + return paint_image_with_color( + src_image, + color_value.get_qcolor() + ) + def get_remove_image(): image_path = os.path.join( @@ -100,6 +112,15 @@ def get_remove_image(): return QtGui.QImage(image_path) +def get_warning_image(): + image_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + "warning.png" + ) + return QtGui.QImage(image_path) + + def paint_image_with_color(image, color): """TODO: This function should be imported from utils. diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index e4c58a8a2c..4b5aca35ef 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -4,6 +4,7 @@ from .constants import ( NAME_ALLOWED_SYMBOLS, NAME_REGEX ) +from .style import ResourceCache from openpype.lib import ( create_project, PROJECT_NAME_ALLOWED_SYMBOLS, @@ -13,7 +14,7 @@ from openpype.style import load_stylesheet from openpype.tools.utils import PlaceholderLineEdit from avalon.api import AvalonMongoDB -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui class NameTextEdit(QtWidgets.QLineEdit): @@ -291,42 +292,41 @@ class CreateProjectDialog(QtWidgets.QDialog): return project_names, project_codes -class _SameSizeBtns(QtWidgets.QPushButton): - """Button that keep width of all button added as related. +# TODO PixmapLabel should be moved to 'utils' in other future PR so should be +# imported from there +class PixmapLabel(QtWidgets.QLabel): + """Label resizing image to height of font.""" + def __init__(self, pixmap, parent): + super(PixmapLabel, self).__init__(parent) + self._empty_pixmap = QtGui.QPixmap(0, 0) + self._source_pixmap = pixmap - This happens without changing min/max/fix size of button. Which is - welcomed for multidisplay desktops with different resolution. - """ - def __init__(self, *args, **kwargs): - super(_SameSizeBtns, self).__init__(*args, **kwargs) - self._related_btns = [] + def set_source_pixmap(self, pixmap): + """Change source image.""" + self._source_pixmap = pixmap + self._set_resized_pix() - def add_related_btn(self, btn): - """Add related button which should be checked for width. + def _get_pix_size(self): + size = self.fontMetrics().height() * 4 + return size, size - Args: - btn (_SameSizeBtns): Other object of _SameSizeBtns. - """ - self._related_btns.append(btn) + def _set_resized_pix(self): + if self._source_pixmap is None: + self.setPixmap(self._empty_pixmap) + return + width, height = self._get_pix_size() + self.setPixmap( + self._source_pixmap.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) - def hint_width(self): - """Get size hint of button not related to others.""" - return super(_SameSizeBtns, self).sizeHint().width() - - def sizeHint(self): - """Calculate size hint based on size hint of this button and related. - - If width is lower than any other button it is changed to higher. - """ - result = super(_SameSizeBtns, self).sizeHint() - width = result.width() - for btn in self._related_btns: - btn_width = btn.hint_width() - if btn_width > width: - width = btn_width - - result.setWidth(width) - return result + def resizeEvent(self, event): + self._set_resized_pix() + super(PixmapLabel, self).resizeEvent(event) class ConfirmProjectDeletion(QtWidgets.QDialog): @@ -336,35 +336,50 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self.setWindowTitle("Delete project?") - message = ( - "Project \"{}\" with all related data will be" - " permanently removed from the database (This actions won't remove" - " any files on disk)." - ).format(project_name) - message_label = QtWidgets.QLabel(message, self) + top_widget = QtWidgets.QWidget(self) + + warning_pixmap = ResourceCache.get_warning_pixmap() + warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + + message_label = QtWidgets.QLabel(top_widget) message_label.setWordWrap(True) + message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + message_label.setText(( + "WARNING: This cannot be undone.

" + "Project \"{}\" with all related data will be" + " permanently removed from the database. (This action won't remove" + " any files on disk.)" + ).format(project_name)) + + top_layout = QtWidgets.QHBoxLayout(top_widget) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget( + warning_icon_label, 0, + QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + ) + top_layout.addWidget(message_label, 1) question_label = QtWidgets.QLabel("Are you sure?", self) confirm_input = PlaceholderLineEdit(self) - confirm_input.setPlaceholderText("Type \"Delete\" to confirm...") + confirm_input.setPlaceholderText( + "Type \"{}\" to confirm...".format(project_name) + ) - cancel_btn = _SameSizeBtns("Cancel", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) cancel_btn.setToolTip("Cancel deletion of the project") - confirm_btn = _SameSizeBtns("Delete", self) + confirm_btn = QtWidgets.QPushButton("Permanently Delete Project", self) + confirm_btn.setObjectName("DeleteButton") confirm_btn.setEnabled(False) confirm_btn.setToolTip("Confirm deletion") - cancel_btn.add_related_btn(confirm_btn) - confirm_btn.add_related_btn(cancel_btn) - btns_layout = QtWidgets.QHBoxLayout() btns_layout.addStretch(1) btns_layout.addWidget(cancel_btn, 0) btns_layout.addWidget(confirm_btn, 0) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(message_label, 0) + layout.addWidget(top_widget, 0) layout.addStretch(1) layout.addWidget(question_label, 0) layout.addWidget(confirm_input, 0) @@ -379,6 +394,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._confirm_btn = confirm_btn self._confirm_input = confirm_input self._result = 0 + self._project_name = project_name self.setMinimumWidth(480) self.setMaximumWidth(650) @@ -411,5 +427,5 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): self._on_confirm_click() def _on_confirm_text_change(self): - enabled = self._confirm_input.text().lower() == "delete" + enabled = self._confirm_input.text() == self._project_name self._confirm_btn.setEnabled(enabled) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index a05811e813..0298d565a5 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -78,7 +78,9 @@ class ProjectManagerWindow(QtWidgets.QWidget): ) create_folders_btn.setEnabled(False) - remove_projects_btn = QtWidgets.QPushButton(project_widget) + remove_projects_btn = QtWidgets.QPushButton( + "Delete project", project_widget + ) remove_projects_btn.setIcon(ResourceCache.get_icon("remove")) remove_projects_btn.setObjectName("IconBtn") diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 24ec9dcb0e..860c009f15 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -41,12 +41,15 @@ class MainThreadProcess(QtCore.QObject): This approach gives ability to update UI meanwhile plugin is in progress. """ + + timer_interval = 3 + def __init__(self): super(MainThreadProcess, self).__init__() self._items_to_process = collections.deque() timer = QtCore.QTimer() - timer.setInterval(50) + timer.setInterval(self.timer_interval) timer.timeout.connect(self._execute) diff --git a/openpype/tools/pyblish_pype/control.py b/openpype/tools/pyblish_pype/control.py index 2a9e67097e..d2b74e316a 100644 --- a/openpype/tools/pyblish_pype/control.py +++ b/openpype/tools/pyblish_pype/control.py @@ -9,6 +9,7 @@ import os import sys import inspect import logging +import collections from Qt import QtCore @@ -28,6 +29,74 @@ class IterationBreak(Exception): pass +class MainThreadItem: + """Callback with args and kwargs.""" + def __init__(self, callback, *args, **kwargs): + self.callback = callback + self.args = args + self.kwargs = kwargs + + def process(self): + self.callback(*self.args, **self.kwargs) + + +class MainThreadProcess(QtCore.QObject): + """Qt based main thread process executor. + + Has timer which controls each 50ms if there is new item to process. + + This approach gives ability to update UI meanwhile plugin is in progress. + """ + timer_interval = 3 + + def __init__(self): + super(MainThreadProcess, self).__init__() + self._items_to_process = collections.deque() + + timer = QtCore.QTimer() + timer.setInterval(self.timer_interval) + + timer.timeout.connect(self._execute) + + self._timer = timer + + def process(self, func, *args, **kwargs): + item = MainThreadItem(func, *args, **kwargs) + self.add_item(item) + + def add_item(self, item): + self._items_to_process.append(item) + + def _execute(self): + if not self._items_to_process: + return + + item = self._items_to_process.popleft() + item.process() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + + def clear(self): + if self._timer.isActive(): + self._timer.stop() + self._items_to_process = collections.deque() + + def stop_if_empty(self): + if self._timer.isActive(): + item = MainThreadItem(self._stop_if_empty) + self.add_item(item) + + def _stop_if_empty(self): + if not self._items_to_process: + self.stop() + + class Controller(QtCore.QObject): log = logging.getLogger("PyblishController") # Emitted when the GUI is about to start processing; @@ -71,6 +140,7 @@ class Controller(QtCore.QObject): self.plugins = {} self.optional_default = {} self.instance_toggled.connect(self._on_instance_toggled) + self._main_thread_processor = MainThreadProcess() def reset_variables(self): self.log.debug("Resetting pyblish context variables") @@ -169,7 +239,11 @@ class Controller(QtCore.QObject): def reset(self): """Discover plug-ins and run collection.""" + self._main_thread_processor.clear() + self._main_thread_processor.process(self._reset) + self._main_thread_processor.start() + def _reset(self): self.reset_context() self.reset_variables() @@ -210,21 +284,25 @@ class Controller(QtCore.QObject): if self.is_running: self.is_running = False self.was_finished.emit() + self._main_thread_processor.stop() def stop(self): self.log.debug("Stopping") self.stopped = True def act(self, plugin, action): - def on_next(): - result = pyblish.plugin.process( - plugin, self.context, None, action.id - ) - self.is_running = False - self.was_acted.emit(result) - self.is_running = True - util.defer(100, on_next) + item = MainThreadItem(self._process_action, plugin, action) + self._main_thread_processor.add_item(item) + self._main_thread_processor.start() + self._main_thread_processor.stop_if_empty() + + def _process_action(self, plugin, action): + result = pyblish.plugin.process( + plugin, self.context, None, action.id + ) + self.is_running = False + self.was_acted.emit(result) def emit_(self, signal, kwargs): pyblish.api.emit(signal, **kwargs) @@ -355,11 +433,13 @@ class Controller(QtCore.QObject): self.passed_group.emit(self.processing["next_group_order"]) - def iterate_and_process(self, on_finished=lambda: None): + def iterate_and_process(self, on_finished=None): """ Iterating inserted plugins with current context. Collectors do not contain instances, they are None when collecting! This process don't stop on one """ + self._main_thread_processor.start() + def on_next(): self.log.debug("Looking for next pair to process") try: @@ -371,13 +451,19 @@ class Controller(QtCore.QObject): self.log.debug("Iteration break was raised") self.is_running = False self.was_stopped.emit() + self._main_thread_processor.stop() return except StopIteration: self.log.debug("Iteration stop was raised") self.is_running = False # All pairs were processed successfully! - return util.defer(500, on_finished) + if on_finished is not None: + self._main_thread_processor.add_item( + MainThreadItem(on_finished) + ) + self._main_thread_processor.stop_if_empty() + return except Exception as exc: self.log.warning( @@ -385,12 +471,15 @@ class Controller(QtCore.QObject): exc_info=True ) exc_msg = str(exc) - return util.defer( - 500, lambda: on_unexpected_error(error=exc_msg) + self._main_thread_processor.add_item( + MainThreadItem(on_unexpected_error, error=exc_msg) ) + return self.about_to_process.emit(*self.current_pair) - util.defer(100, on_process) + self._main_thread_processor.add_item( + MainThreadItem(on_process) + ) def on_process(): try: @@ -411,11 +500,14 @@ class Controller(QtCore.QObject): exc_info=True ) exc_msg = str(exc) - return util.defer( - 500, lambda: on_unexpected_error(error=exc_msg) + self._main_thread_processor.add_item( + MainThreadItem(on_unexpected_error, error=exc_msg) ) + return - util.defer(10, on_next) + self._main_thread_processor.add_item( + MainThreadItem(on_next) + ) def on_unexpected_error(error): # TODO this should be handled much differently @@ -423,24 +515,42 @@ class Controller(QtCore.QObject): self.is_running = False self.was_stopped.emit() util.u_print(u"An unexpected error occurred:\n %s" % error) - return util.defer(500, on_finished) + if on_finished is not None: + self._main_thread_processor.add_item( + MainThreadItem(on_finished) + ) + self._main_thread_processor.stop_if_empty() self.is_running = True - util.defer(10, on_next) + self._main_thread_processor.add_item( + MainThreadItem(on_next) + ) def collect(self): """ Iterate and process Collect plugins - load_plugins method is launched again when finished """ - self.iterate_and_process() + self._main_thread_processor.process(self._start_collect) + self._main_thread_processor.start() def validate(self): """ Process plugins to validations_order value.""" - self.processing["stop_on_validation"] = True - self.iterate_and_process() + self._main_thread_processor.process(self._start_validate) + self._main_thread_processor.start() def publish(self): """ Iterate and process all remaining plugins.""" + self._main_thread_processor.process(self._start_publish) + self._main_thread_processor.start() + + def _start_collect(self): + self.iterate_and_process() + + def _start_validate(self): + self.processing["stop_on_validation"] = True + self.iterate_and_process() + + def _start_publish(self): self.processing["stop_on_validation"] = False self.iterate_and_process(self.on_published) diff --git a/openpype/tools/pyblish_pype/window.py b/openpype/tools/pyblish_pype/window.py index 536f793216..fdd2d80e23 100644 --- a/openpype/tools/pyblish_pype/window.py +++ b/openpype/tools/pyblish_pype/window.py @@ -1148,7 +1148,7 @@ class Window(QtWidgets.QDialog): self.comment_box.placeholder.setVisible(False) self.comment_box.placeholder.setVisible(True) # Launch controller reset - util.defer(500, self.controller.reset) + self.controller.reset() def validate(self): self.info(self.tr("Preparing validate..")) @@ -1159,7 +1159,7 @@ class Window(QtWidgets.QDialog): self.button_suspend_logs.setEnabled(False) - util.defer(5, self.controller.validate) + self.controller.validate() def publish(self): self.info(self.tr("Preparing publish..")) @@ -1170,7 +1170,7 @@ class Window(QtWidgets.QDialog): self.button_suspend_logs.setEnabled(False) - util.defer(5, self.controller.publish) + self.controller.publish() def act(self, plugin_item, action): self.info("%s %s.." % (self.tr("Preparing"), action)) @@ -1187,9 +1187,7 @@ class Window(QtWidgets.QDialog): ) # Give Qt time to draw - util.defer(100, lambda: self.controller.act( - plugin_item.plugin, action - )) + self.controller.act(plugin_item.plugin, action) self.info(self.tr("Action prepared.")) @@ -1267,7 +1265,7 @@ class Window(QtWidgets.QDialog): self.info(self.tr("..as soon as processing is finished..")) self.controller.stop() self.finished.connect(self.close) - util.defer(2000, on_problem) + util.defer(200, on_problem) return event.ignore() self.state["is_closing"] = True diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index 8a420c2447..e271585852 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -32,6 +32,15 @@ class BaseWidget(QtWidgets.QWidget): self.label_widget = None self.create_ui() + @staticmethod + def set_style_property(obj, property_name, property_value): + """Change QWidget property and polish it's style.""" + if obj.property(property_name) == property_value: + return + + obj.setProperty(property_name, property_value) + obj.style().polish(obj) + def scroll_to(self, widget): self.category_widget.scroll_to(widget) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index b046085975..adbde00bf1 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -29,6 +29,9 @@ from openpype.settings.entities import ( StudioDefaultsNotDefined, SchemaError ) +from openpype.settings.entities.op_version_entity import ( + OpenPypeVersionInput +) from openpype.settings import SaveWarningExc from .widgets import ProjectListWidget @@ -47,6 +50,7 @@ from .item_widgets import ( BoolWidget, DictImmutableKeysWidget, TextWidget, + OpenPypeVersionText, NumberWidget, RawJsonWidget, EnumeratorWidget, @@ -118,6 +122,9 @@ class SettingsCategoryWidget(QtWidgets.QWidget): elif isinstance(entity, BoolEntity): return BoolWidget(*args) + elif isinstance(entity, OpenPypeVersionInput): + return OpenPypeVersionText(*args) + elif isinstance(entity, TextEntity): return TextWidget(*args) diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py index 2e00967a60..22f672da2b 100644 --- a/openpype/tools/settings/settings/item_widgets.py +++ b/openpype/tools/settings/settings/item_widgets.py @@ -2,6 +2,10 @@ import json from Qt import QtWidgets, QtCore, QtGui +from openpype.widgets.sliders import NiceSlider +from openpype.tools.settings import CHILD_OFFSET +from openpype.settings.entities.exceptions import BaseInvalidValue + from .widgets import ( ExpandingWidget, NumberSpinBox, @@ -22,9 +26,6 @@ from .base import ( InputWidget ) -from openpype.widgets.sliders import NiceSlider -from openpype.tools.settings import CHILD_OFFSET - class DictImmutableKeysWidget(BaseWidget): def create_ui(self): @@ -378,6 +379,16 @@ class TextWidget(InputWidget): self.input_field.focused_in.connect(self._on_input_focus) self.input_field.textChanged.connect(self._on_value_change) + self._refresh_completer() + + def _refresh_completer(self): + # Multiline entity can't have completer + # - there is not space for this UI component + if self.entity.multiline: + return + + self.input_field.update_completer_values(self.entity.value_hints) + def _on_input_focus(self): self.focused_in() @@ -406,6 +417,86 @@ class TextWidget(InputWidget): self.entity.set(self.input_value()) +class OpenPypeVersionText(TextWidget): + def __init__(self, *args, **kwargs): + self._info_widget = None + super(OpenPypeVersionText, self).__init__(*args, **kwargs) + + def create_ui(self): + super(OpenPypeVersionText, self).create_ui() + info_widget = QtWidgets.QLabel(self) + info_widget.setObjectName("OpenPypeVersionLabel") + self.content_layout.addWidget(info_widget, 1) + + self._info_widget = info_widget + + def _update_info_widget(self): + value = self.input_value() + + message = "" + tooltip = "" + state = None + if self._is_invalid: + message = "Invalid OpenPype version format" + + elif value == "": + message = "Use latest available version" + tooltip = ( + "Latest version from OpenPype zip repository will be used" + ) + + elif value in self.entity.value_hints: + state = "success" + message = "Version {} will be used".format(value) + + else: + state = "warning" + message = ( + "Version {} not found in listed versions".format(value) + ) + if self.entity.value_hints: + tooltip = "Listed versions: {}".format(", ".join( + ['"{}"'.format(hint) for hint in self.entity.value_hints] + )) + else: + tooltip = "No versions were listed" + + self._info_widget.setText(message) + self._info_widget.setToolTip(tooltip) + self.set_style_property(self._info_widget, "state", state) + + def set_entity_value(self): + super(OpenPypeVersionText, self).set_entity_value() + self._invalidate() + self._update_info_widget() + + def _on_value_change_timer(self): + value = self.input_value() + self._invalidate() + if not self.is_invalid: + self.entity.set(value) + self.update_style() + else: + # Manually trigger hierachical style update + self.ignore_input_changes.set_ignore(True) + self.ignore_input_changes.set_ignore(False) + + self._update_info_widget() + + def _invalidate(self): + value = self.input_value() + try: + self.entity.convert_to_valid_type(value) + is_invalid = False + except BaseInvalidValue: + is_invalid = True + self._is_invalid = is_invalid + + def _on_entity_change(self): + super(OpenPypeVersionText, self)._on_entity_change() + self._refresh_completer() + + class NumberWidget(InputWidget): _slider_widget = None diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 4c7bf87ce8..b5c08ef79b 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -1,4 +1,5 @@ import os +import copy from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from avalon.mongodb import ( @@ -25,13 +26,235 @@ from .constants import ( ) +class CompleterFilter(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(CompleterFilter, self).__init__(*args, **kwargs) + + self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + self._text_filter = "" + + def set_text_filter(self, text): + if self._text_filter == text: + return + self._text_filter = text + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent_index): + if not self._text_filter: + return True + model = self.sourceModel() + index = model.index(row, self.filterKeyColumn(), parent_index) + value = index.data(QtCore.Qt.DisplayRole) + if self._text_filter in value: + if self._text_filter == value: + return False + return True + return False + + +class CompleterView(QtWidgets.QListView): + row_activated = QtCore.Signal(str) + + def __init__(self, parent): + super(CompleterView, self).__init__(parent) + + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint + | QtCore.Qt.Tool + ) + delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(delegate) + + model = QtGui.QStandardItemModel() + filter_model = CompleterFilter() + filter_model.setSourceModel(model) + self.setModel(filter_model) + + # self.installEventFilter(parent) + + self.clicked.connect(self._on_activated) + + self._last_loaded_values = None + self._model = model + self._filter_model = filter_model + self._delegate = delegate + + def _on_activated(self, index): + if index.isValid(): + value = index.data(QtCore.Qt.DisplayRole) + self.row_activated.emit(value) + + def set_text_filter(self, text): + self._filter_model.set_text_filter(text) + self._update_geo() + + def sizeHint(self): + result = super(CompleterView, self).sizeHint() + if self._filter_model.rowCount() == 0: + result.setHeight(0) + + return result + + def showEvent(self, event): + super(CompleterView, self).showEvent(event) + self._update_geo() + + def _update_geo(self): + size_hint = self.sizeHint() + self.resize(size_hint.width(), size_hint.height()) + + def update_values(self, values): + if not values: + values = [] + + if self._last_loaded_values == values: + return + self._last_loaded_values = copy.deepcopy(values) + + root_item = self._model.invisibleRootItem() + existing_values = {} + for row in reversed(range(root_item.rowCount())): + child = root_item.child(row) + value = child.data(QtCore.Qt.DisplayRole) + if value not in values: + root_item.removeRows(child.row()) + else: + existing_values[value] = child + + for row, value in enumerate(values): + if value in existing_values: + item = existing_values[value] + if item.row() == row: + continue + else: + item = QtGui.QStandardItem(value) + item.setEditable(False) + + root_item.setChild(row, item) + + self._update_geo() + + def _get_selected_row(self): + indexes = self.selectionModel().selectedIndexes() + if not indexes: + return -1 + return indexes[0].row() + + def _select_row(self, row): + index = self._filter_model.index(row, 0) + self.setCurrentIndex(index) + + def move_up(self): + rows = self._filter_model.rowCount() + if rows == 0: + return + + selected_row = self._get_selected_row() + if selected_row < 0: + new_row = rows - 1 + else: + new_row = selected_row - 1 + if new_row < 0: + new_row = rows - 1 + + if new_row != selected_row: + self._select_row(new_row) + + def move_down(self): + rows = self._filter_model.rowCount() + if rows == 0: + return + + selected_row = self._get_selected_row() + if selected_row < 0: + new_row = 0 + else: + new_row = selected_row + 1 + if new_row >= rows: + new_row = 0 + + if new_row != selected_row: + self._select_row(new_row) + + def enter_pressed(self): + selected_row = self._get_selected_row() + if selected_row < 0: + return + index = self._filter_model.index(selected_row, 0) + self._on_activated(index) + + class SettingsLineEdit(PlaceholderLineEdit): focused_in = QtCore.Signal() + def __init__(self, *args, **kwargs): + super(SettingsLineEdit, self).__init__(*args, **kwargs) + + self._completer = None + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, text): + if self._completer is not None: + self._completer.set_text_filter(text) + + def _update_completer(self): + if self._completer is None or not self._completer.isVisible(): + return + point = self.frameGeometry().bottomLeft() + new_point = self.mapToGlobal(point) + self._completer.move(new_point) + def focusInEvent(self, event): super(SettingsLineEdit, self).focusInEvent(event) self.focused_in.emit() + if self._completer is None: + return + self._completer.show() + self._update_completer() + + def focusOutEvent(self, event): + super(SettingsLineEdit, self).focusOutEvent(event) + if self._completer is not None: + self._completer.hide() + + def paintEvent(self, event): + super(SettingsLineEdit, self).paintEvent(event) + self._update_completer() + + def update_completer_values(self, values): + if not values and self._completer is None: + return + + self._create_completer() + + self._completer.update_values(values) + + def _create_completer(self): + if self._completer is None: + self._completer = CompleterView(self) + self._completer.row_activated.connect(self._completer_activated) + + def _completer_activated(self, text): + self.setText(text) + + def keyPressEvent(self, event): + if self._completer is None: + super(SettingsLineEdit, self).keyPressEvent(event) + return + + key = event.key() + if key == QtCore.Qt.Key_Up: + self._completer.move_up() + elif key == QtCore.Qt.Key_Down: + self._completer.move_down() + elif key in (QtCore.Qt.Key_Enter, QtCore.Qt.Key_Return): + self._completer.enter_pressed() + else: + super(SettingsLineEdit, self).keyPressEvent(event) + class SettingsPlainTextEdit(QtWidgets.QPlainTextEdit): focused_in = QtCore.Signal() diff --git a/openpype/tools/standalonepublish/widgets/widget_components.py b/openpype/tools/standalonepublish/widgets/widget_components.py index 2ac54af4e3..4d7f94f825 100644 --- a/openpype/tools/standalonepublish/widgets/widget_components.py +++ b/openpype/tools/standalonepublish/widgets/widget_components.py @@ -10,7 +10,7 @@ from .constants import HOST_NAME from avalon import io from openpype.api import execute, Logger from openpype.lib import ( - get_pype_execute_args, + get_openpype_execute_args, apply_project_environments_value ) @@ -193,7 +193,7 @@ def cli_publish(data, publish_paths, gui=True): project_name = os.environ["AVALON_PROJECT"] env_copy = apply_project_environments_value(project_name, envcopy) - args = get_pype_execute_args("run", PUBLISH_SCRIPT_PATH) + args = get_openpype_execute_args("run", PUBLISH_SCRIPT_PATH) result = execute(args, env=envcopy) result = {} diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 8c6a6d3266..df0238c848 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -14,7 +14,7 @@ from openpype.api import ( resources, get_system_settings ) -from openpype.lib import get_pype_execute_args +from openpype.lib import get_openpype_execute_args from openpype.modules import TrayModulesManager from openpype import style from openpype.settings import ( @@ -208,10 +208,10 @@ class TrayManager: First creates new process with same argument and close current tray. """ - args = get_pype_execute_args() + args = get_openpype_execute_args() # Create a copy of sys.argv additional_args = list(sys.argv) - # Check last argument from `get_pype_execute_args` + # Check last argument from `get_openpype_execute_args` # - when running from code it is the same as first from sys.argv if args[-1] == additional_args[0]: additional_args.pop(0) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 7f15e64767..4dd6bdd05f 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -1,8 +1,18 @@ from .widgets import ( PlaceholderLineEdit, + BaseClickableFrame, + ClickableFrame, + ExpandBtn, ) +from .error_dialog import ErrorMessageBox + __all__ = ( "PlaceholderLineEdit", + "BaseClickableFrame", + "ClickableFrame", + "ExpandBtn", + + "ErrorMessageBox" ) diff --git a/openpype/tools/utils/assets_widget.py b/openpype/tools/utils/assets_widget.py index f310aafe89..1495586b04 100644 --- a/openpype/tools/utils/assets_widget.py +++ b/openpype/tools/utils/assets_widget.py @@ -306,6 +306,8 @@ class AssetModel(QtGui.QStandardItemModel): self._items_with_color_by_id = {} self._items_by_asset_id = {} + self._last_project_name = None + @property def refreshing(self): return self._refreshing @@ -347,7 +349,7 @@ class AssetModel(QtGui.QStandardItemModel): return self.get_indexes_by_asset_ids(asset_ids) - def refresh(self, force=False, clear=False): + def refresh(self, force=False): """Refresh the data for the model. Args: @@ -360,7 +362,13 @@ class AssetModel(QtGui.QStandardItemModel): return self.stop_refresh() - if clear: + project_name = self.dbcon.Session.get("AVALON_PROJECT") + clear_model = False + if project_name != self._last_project_name: + clear_model = True + self._last_project_name = project_name + + if clear_model: self._clear_items() # Fetch documents from mongo @@ -401,11 +409,18 @@ class AssetModel(QtGui.QStandardItemModel): self._clear_items() return + self._fill_assets(self._doc_payload) + + self.refreshed.emit(bool(self._items_by_asset_id)) + + self._stop_fetch_thread() + + def _fill_assets(self, asset_docs): # Collect asset documents as needed asset_ids = set() asset_docs_by_id = {} asset_ids_by_parents = collections.defaultdict(set) - for asset_doc in self._doc_payload: + for asset_doc in asset_docs: asset_id = asset_doc["_id"] asset_data = asset_doc.get("data") or {} parent_id = asset_data.get("visualParent") @@ -511,10 +526,6 @@ class AssetModel(QtGui.QStandardItemModel): except Exception: pass - self.refreshed.emit(bool(self._items_by_asset_id)) - - self._stop_fetch_thread() - def _threaded_fetch(self): asset_docs = self._fetch_asset_docs() if not self._refreshing: @@ -582,11 +593,8 @@ class AssetsWidget(QtWidgets.QWidget): self.dbcon = dbcon # Tree View - model = AssetModel(dbcon=self.dbcon, parent=self) - proxy = RecursiveSortFilterProxyModel() - proxy.setSourceModel(model) - proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + model = self._create_source_model() + proxy = self._create_proxy_model(model) view = AssetsView(self) view.setModel(proxy) @@ -628,7 +636,6 @@ class AssetsWidget(QtWidgets.QWidget): selection_model.selectionChanged.connect(self._on_selection_change) refresh_btn.clicked.connect(self.refresh) current_asset_btn.clicked.connect(self.set_current_session_asset) - model.refreshed.connect(self._on_model_refresh) view.doubleClicked.connect(self.double_clicked) self._current_asset_btn = current_asset_btn @@ -639,17 +646,24 @@ class AssetsWidget(QtWidgets.QWidget): self.model_selection = {} + def _create_source_model(self): + model = AssetModel(dbcon=self.dbcon, parent=self) + model.refreshed.connect(self._on_model_refresh) + return model + + def _create_proxy_model(self, source_model): + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(source_model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + return proxy + @property def refreshing(self): return self._model.refreshing def refresh(self): - project_name = self.dbcon.Session.get("AVALON_PROJECT") - clear_model = False - if project_name != self._last_project_name: - clear_model = True - self._last_project_name = project_name - self._refresh_model(clear_model) + self._refresh_model() def stop_refresh(self): self._model.stop_refresh() @@ -691,18 +705,24 @@ class AssetsWidget(QtWidgets.QWidget): self._proxy.setFilterFixedString(new_text) def _on_model_refresh(self, has_item): + """This method should be triggered on model refresh. + + Default implementation register this callback in '_create_source_model' + so if you're modifying model keep in mind that this method should be + called when refresh is done. + """ self._proxy.sort(0) self._set_loading_state(loading=False, empty=not has_item) self.refreshed.emit() - def _refresh_model(self, clear=False): + def _refresh_model(self): # Store selection self._set_loading_state(loading=True, empty=True) # Trigger signal before refresh is called self.refresh_triggered.emit() # Refresh model - self._model.refresh(clear=clear) + self._model.refresh() def _set_loading_state(self, loading, empty): self._view.set_loading_state(loading, empty) diff --git a/openpype/tools/utils/error_dialog.py b/openpype/tools/utils/error_dialog.py new file mode 100644 index 0000000000..f7b12bb69f --- /dev/null +++ b/openpype/tools/utils/error_dialog.py @@ -0,0 +1,152 @@ +from Qt import QtWidgets, QtCore + +from .widgets import ClickableFrame, ExpandBtn + + +def convert_text_for_html(text): + return ( + text + .replace("<", "<") + .replace(">", ">") + .replace("\n", "
") + .replace(" ", " ") + ) + + +class TracebackWidget(QtWidgets.QWidget): + def __init__(self, tb_text, parent): + super(TracebackWidget, self).__init__(parent) + + # Modify text to match html + # - add more replacements when needed + tb_text = convert_text_for_html(tb_text) + expand_btn = ExpandBtn(self) + + clickable_frame = ClickableFrame(self) + clickable_layout = QtWidgets.QHBoxLayout(clickable_frame) + clickable_layout.setContentsMargins(0, 0, 0, 0) + + expand_label = QtWidgets.QLabel("Details", clickable_frame) + clickable_layout.addWidget(expand_label, 0) + clickable_layout.addStretch(1) + + show_details_layout = QtWidgets.QHBoxLayout() + show_details_layout.addWidget(expand_btn, 0) + show_details_layout.addWidget(clickable_frame, 1) + + text_widget = QtWidgets.QLabel(self) + text_widget.setText(tb_text) + text_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + text_widget.setVisible(False) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(show_details_layout, 0) + layout.addWidget(text_widget, 1) + + clickable_frame.clicked.connect(self._on_show_details_click) + expand_btn.clicked.connect(self._on_show_details_click) + + self._expand_btn = expand_btn + self._text_widget = text_widget + + def _on_show_details_click(self): + self._text_widget.setVisible(not self._text_widget.isVisible()) + self._expand_btn.set_collapsed(not self._text_widget.isVisible()) + + +class ErrorMessageBox(QtWidgets.QDialog): + _default_width = 660 + _default_height = 350 + + def __init__(self, title, parent): + super(ErrorMessageBox, self).__init__(parent) + self.setWindowTitle(title) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + top_widget = self._create_top_widget(self) + + content_scroll = QtWidgets.QScrollArea(self) + content_scroll.setWidgetResizable(True) + + content_widget = QtWidgets.QWidget(content_scroll) + content_scroll.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + # Store content widget before creation of content + self._content_widget = content_widget + + self._create_content(content_layout) + + content_layout.addStretch(1) + + copy_report_btn = QtWidgets.QPushButton("Copy report", self) + ok_btn = QtWidgets.QPushButton("OK", self) + + footer_layout = QtWidgets.QHBoxLayout() + footer_layout.addWidget(copy_report_btn, 0) + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn, 0) + + bottom_line = self._create_line() + body_layout = QtWidgets.QVBoxLayout(self) + body_layout.addWidget(top_widget, 0) + body_layout.addWidget(content_scroll, 1) + body_layout.addWidget(bottom_line, 0) + body_layout.addLayout(footer_layout, 0) + + copy_report_btn.clicked.connect(self._on_copy_report) + ok_btn.clicked.connect(self._on_ok_clicked) + + self.resize(self._default_width, self._default_height) + + report_data = self._get_report_data() + if not report_data: + copy_report_btn.setVisible(False) + + self._report_data = report_data + + @staticmethod + def convert_text_for_html(text): + return convert_text_for_html(text) + + def _create_top_widget(self, parent_widget): + label_widget = QtWidgets.QLabel(parent_widget) + label_widget.setText( + "Something went wrong" + ) + return label_widget + + def _create_content(self, content_layout): + raise NotImplementedError( + "Method '_fill_content_layout' is not implemented!" + ) + + def _get_report_data(self): + return [] + + def _on_ok_clicked(self): + self.close() + + def _on_copy_report(self): + report_text = (10 * "*").join(self._report_data) + + mime_data = QtCore.QMimeData() + mime_data.setText(report_text) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setObjectName("Separator") + line.setMinimumHeight(2) + line.setMaximumHeight(2) + return line + + def _create_traceback_widget(self, traceback_text, parent=None): + if parent is None: + parent = self._content_widget + return TracebackWidget(traceback_text, parent) diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py index 419e77c780..6e6cd17ffd 100644 --- a/openpype/tools/utils/tasks_widget.py +++ b/openpype/tools/utils/tasks_widget.py @@ -194,6 +194,8 @@ class TasksWidget(QtWidgets.QWidget): task_changed = QtCore.Signal() def __init__(self, dbcon, parent=None): + self._dbcon = dbcon + super(TasksWidget, self).__init__(parent) tasks_view = DeselectableTreeView(self) @@ -204,9 +206,8 @@ class TasksWidget(QtWidgets.QWidget): header_view = tasks_view.header() header_view.setSortIndicator(0, QtCore.Qt.AscendingOrder) - tasks_model = TasksModel(dbcon) - tasks_proxy = TasksProxyModel() - tasks_proxy.setSourceModel(tasks_model) + tasks_model = self._create_source_model() + tasks_proxy = self._create_proxy_model(tasks_model) tasks_view.setModel(tasks_proxy) layout = QtWidgets.QVBoxLayout(self) @@ -222,6 +223,19 @@ class TasksWidget(QtWidgets.QWidget): self._last_selected_task_name = None + def _create_source_model(self): + """Create source model of tasks widget. + + Model must have available 'refresh' method and 'set_asset_id' to change + context of asset. + """ + return TasksModel(self._dbcon) + + def _create_proxy_model(self, source_model): + proxy = TasksProxyModel() + proxy.setSourceModel(source_model) + return proxy + def refresh(self): self._tasks_model.refresh() diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 3bfa092a21..c32eae043e 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -3,7 +3,10 @@ import logging from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome, qargparse -from openpype.style import get_objected_colors +from openpype.style import ( + get_objected_colors, + get_style_image_path +) log = logging.getLogger(__name__) @@ -25,6 +28,100 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class BaseClickableFrame(QtWidgets.QFrame): + """Widget that catch left mouse click and can trigger a callback. + + Callback is defined by overriding `_mouse_release_callback`. + """ + def __init__(self, parent): + super(BaseClickableFrame, self).__init__(parent) + + self._mouse_pressed = False + + def _mouse_release_callback(self): + pass + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(BaseClickableFrame, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self._mouse_release_callback() + + super(BaseClickableFrame, self).mouseReleaseEvent(event) + + +class ClickableFrame(BaseClickableFrame): + """Extended clickable frame which triggers 'clicked' signal.""" + clicked = QtCore.Signal() + + def _mouse_release_callback(self): + self.clicked.emit() + + +class ExpandBtnLabel(QtWidgets.QLabel): + """Label showing expand icon meant for ExpandBtn.""" + def __init__(self, parent): + super(ExpandBtnLabel, self).__init__(parent) + self._source_collapsed_pix = QtGui.QPixmap( + get_style_image_path("branch_closed") + ) + self._source_expanded_pix = QtGui.QPixmap( + get_style_image_path("branch_open") + ) + + self._current_image = self._source_collapsed_pix + self._collapsed = True + + def set_collapsed(self, collapsed): + if self._collapsed == collapsed: + return + self._collapsed = collapsed + if collapsed: + self._current_image = self._source_collapsed_pix + else: + self._current_image = self._source_expanded_pix + self._set_resized_pix() + + def resizeEvent(self, event): + self._set_resized_pix() + super(ExpandBtnLabel, self).resizeEvent(event) + + def _set_resized_pix(self): + size = int(self.fontMetrics().height() / 2) + if size < 1: + size = 1 + size += size % 2 + self.setPixmap( + self._current_image.scaled( + size, + size, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + ) + + +class ExpandBtn(ClickableFrame): + def __init__(self, parent=None): + super(ExpandBtn, self).__init__(parent) + + pixmap_label = ExpandBtnLabel(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(pixmap_label) + + self._pixmap_label = pixmap_label + + def set_collapsed(self, collapsed): + self._pixmap_label.set_collapsed(collapsed) + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font. diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 7973b88b82..f4a86050cb 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -12,7 +12,6 @@ from avalon import io, api, pipeline from openpype import style from openpype.tools.utils.lib import ( - schedule, qt_app_context ) from openpype.tools.utils import PlaceholderLineEdit @@ -25,7 +24,8 @@ from openpype.lib import ( get_workfile_doc, create_workfile_doc, save_workfile_data_to_doc, - get_workfile_template_key + get_workfile_template_key, + create_workdir_extra_folders ) from .model import FilesModel @@ -672,7 +672,13 @@ class FilesWidget(QtWidgets.QWidget): self.set_asset_task( self._asset_id, self._task_name, self._task_type ) - + create_workdir_extra_folders( + self.root, + api.Session["AVALON_APP"], + self._task_type, + self._task_name, + api.Session["AVALON_PROJECT"] + ) pipeline.emit("after.workfile.save", [file_path]) self.workfile_created.emit(file_path) @@ -729,7 +735,7 @@ class FilesWidget(QtWidgets.QWidget): self.files_model.refresh() if self.auto_select_latest_modified: - schedule(self._select_last_modified_file, 100) + self._select_last_modified_file() def on_context_menu(self, point): index = self.files_view.indexAt(point) @@ -934,8 +940,8 @@ class Window(QtWidgets.QMainWindow): # Connect signals set_context_timer.timeout.connect(self._on_context_set_timeout) - assets_widget.selection_changed.connect(self.on_asset_changed) - tasks_widget.task_changed.connect(self.on_task_changed) + assets_widget.selection_changed.connect(self._on_asset_changed) + tasks_widget.task_changed.connect(self._on_task_changed) files_widget.file_selected.connect(self.on_file_select) files_widget.workfile_created.connect(self.on_workfile_create) files_widget.file_opened.connect(self._on_file_opened) @@ -980,13 +986,6 @@ class Window(QtWidgets.QMainWindow): def set_save_enabled(self, enabled): self.files_widget.btn_save.setEnabled(enabled) - def on_task_changed(self): - # Since we query the disk give it slightly more delay - schedule(self._on_task_changed, 100, channel="mongo") - - def on_asset_changed(self): - schedule(self._on_asset_changed, 50, channel="mongo") - def on_file_select(self, filepath): asset_id = self.assets_widget.get_selected_asset_id() task_name = self.tasks_widget.get_selected_task_name() @@ -1075,6 +1074,7 @@ class Window(QtWidgets.QMainWindow): if "task" in context: self.tasks_widget.select_task_name(context["task"]) + self._on_task_changed() def _on_asset_changed(self): asset_id = self.assets_widget.get_selected_asset_id() diff --git a/openpype/version.py b/openpype/version.py index 544160d41c..1f005d6952 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.7.0-nightly.9" +__version__ = "3.8.0-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 07a9ac8e43..f9155f05a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.7.0-nightly.9" # OpenPype +version = "3.8.0-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/repos/avalon-core b/repos/avalon-core index 4f10fb1255..ffe9e910f1 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 4f10fb1255beb156f23afa1bb8362dfc53d0c6f8 +Subproject commit ffe9e910f1f382e222d457d8e4a8426c41ed43ae diff --git a/setup.py b/setup.py index 6891b3c419..3ee6ad43ea 100644 --- a/setup.py +++ b/setup.py @@ -70,8 +70,13 @@ with open(openpype_root / "openpype" / "version.py") as fp: version_match = re.search(r"(\d+\.\d+.\d+).*", version["__version__"]) __version__ = version_match.group(1) +low_platform_name = platform.system().lower() +IS_WINDOWS = low_platform_name == "windows" +IS_LINUX = low_platform_name == "linux" +IS_MACOS = low_platform_name == "darwin" + base = None -if sys.platform == "win32": +if IS_WINDOWS: base = "Win32GUI" # ----------------------------------------------------------------------- @@ -100,7 +105,8 @@ install_requires = [ "filecmp", "dns", # Python defaults (cx_Freeze skip them by default) - "dbm" + "dbm", + "sqlite3" ] includes = [] @@ -123,7 +129,7 @@ include_files = [ "README.md" ] -if sys.platform == "win32": +if IS_WINDOWS: install_requires.extend([ # `pywin32` packages "win32ctypes", @@ -155,6 +161,15 @@ executables = [ Executable("start.py", base=None, target_name="openpype_console", icon=icon_path.as_posix()) ] +if IS_LINUX: + executables.append( + Executable( + "app_launcher.py", + base=None, + target_name="app_launcher", + icon=icon_path.as_posix() + ) + ) setup( name="OpenPype", diff --git a/start.py b/start.py index 01831e9b4d..b6c14526f9 100644 --- a/start.py +++ b/start.py @@ -196,7 +196,8 @@ from igniter import BootstrapRepos # noqa: E402 from igniter.tools import ( get_openpype_global_settings, get_openpype_path_from_db, - validate_mongo_connection + validate_mongo_connection, + OpenPypeVersionNotFound ) # noqa from igniter.bootstrap_repos import OpenPypeVersion # noqa: E402 @@ -467,23 +468,41 @@ def _process_arguments() -> tuple: use_version = None use_staging = False commands = [] - for arg in sys.argv: - if arg == "--use-version": - _print("!!! Please use option --use-version like:") - _print(" --use-version=3.0.0") - sys.exit(1) - if arg.startswith("--use-version="): - m = re.search( - r"--use-version=(?P\d+\.\d+\.\d+(?:\S*)?)", arg) - if m and m.group('version'): - use_version = m.group('version') - _print(">>> Requested version [ {} ]".format(use_version)) - sys.argv.remove(arg) - if "+staging" in use_version: - use_staging = True - break + # OpenPype version specification through arguments + use_version_arg = "--use-version" + + for arg in sys.argv: + if arg.startswith(use_version_arg): + # Remove arg from sys argv + sys.argv.remove(arg) + # Extract string after use version arg + use_version_value = arg[len(use_version_arg):] + + if ( + not use_version_value + or not use_version_value.startswith("=") + ): + _print("!!! Please use option --use-version like:") + _print(" --use-version=3.0.0") + sys.exit(1) + + version_str = use_version_value[1:] + use_version = None + if version_str.lower() == "latest": + use_version = "latest" else: + m = re.search( + r"(?P\d+\.\d+\.\d+(?:\S*)?)", version_str + ) + if m and m.group('version'): + use_version = m.group('version') + _print(">>> Requested version [ {} ]".format(use_version)) + if "+staging" in use_version: + use_staging = True + break + + if use_version is None: _print("!!! Requested version isn't in correct format.") _print((" Use --list-versions to find out" " proper version string.")) @@ -521,7 +540,7 @@ def _process_arguments() -> tuple: if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": _print("!!! Cannot open Igniter dialog in headless mode.") sys.exit(1) - import igniter + return_code = igniter.open_dialog() # this is when we want to run OpenPype without installing anything. @@ -646,67 +665,62 @@ def _find_frozen_openpype(use_version: str = None, (if requested). """ - version_path = None - openpype_version = None - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) + # Collect OpenPype versions + installed_version = OpenPypeVersion.get_installed_version() + # Expected version that should be used by studio settings + # - this option is used only if version is not explictly set and if + # studio has set explicit version in settings + studio_version = OpenPypeVersion.get_expected_studio_version(use_staging) + + if use_version is not None: + # Specific version is defined + if use_version.lower() == "latest": + # Version says to use latest version + _print("Finding latest version defined by use version") + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) + else: + _print("Finding specified version \"{}\"".format(use_version)) + openpype_version = bootstrap.find_openpype_version( + use_version, use_staging + ) + + if openpype_version is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format( + use_version + ) + ) + + elif studio_version is not None: + # Studio has defined a version to use + _print("Finding studio version \"{}\"".format(studio_version)) + openpype_version = bootstrap.find_openpype_version( + studio_version, use_staging + ) + if openpype_version is None: + raise OpenPypeVersionNotFound(( + "Requested OpenPype version \"{}\" defined by settings" + " was not found." + ).format(studio_version)) + + else: + # Default behavior to use latest version + _print("Finding latest version") + openpype_version = bootstrap.find_latest_openpype_version( + use_staging + ) + if openpype_version is None: + if use_staging: + reason = "Didn't find any staging versions." + else: + reason = "Didn't find any versions." + raise OpenPypeVersionNotFound(reason) + # get local frozen version and add it to detected version so if it is # newer it will be used instead. - local_version_str = bootstrap.get_version( - Path(os.environ["OPENPYPE_ROOT"])) - if local_version_str: - local_version = OpenPypeVersion( - version=local_version_str, - path=Path(os.environ["OPENPYPE_ROOT"])) - if local_version not in openpype_versions: - openpype_versions.append(local_version) - openpype_versions.sort() - # if latest is currently running, ditch whole list - # and run from current without installing it. - if local_version == openpype_versions[-1]: - os.environ["OPENPYPE_TRYOUT"] = "1" - openpype_versions = [] - else: - _print("!!! Warning: cannot determine current running version.") - - if not os.getenv("OPENPYPE_TRYOUT"): - try: - # use latest one found (last in the list is latest) - openpype_version = openpype_versions[-1] - except IndexError: - # no OpenPype version found, run Igniter and ask for them. - _print('*** No OpenPype versions found.') - if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": - _print("!!! Cannot open Igniter dialog in headless mode.") - sys.exit(1) - _print("--- launching setup UI ...") - import igniter - return_code = igniter.open_dialog() - if return_code == 2: - os.environ["OPENPYPE_TRYOUT"] = "1" - if return_code == 3: - # run OpenPype after installation - - _print('>>> Finding OpenPype again ...') - openpype_versions = bootstrap.find_openpype( - staging=use_staging) - try: - openpype_version = openpype_versions[-1] - except IndexError: - _print(("!!! Something is wrong and we didn't " - "found it again.")) - sys.exit(1) - elif return_code != 2: - _print(f" . finished ({return_code})") - sys.exit(return_code) - - if not openpype_versions: - # no openpype versions found anyway, lets use then the one - # shipped with frozen OpenPype - if not os.getenv("OPENPYPE_TRYOUT"): - _print("*** Still no luck finding OpenPype.") - _print(("*** We'll try to use the one coming " - "with OpenPype installation.")) + if installed_version == openpype_version: version_path = _bootstrap_from_code(use_version, use_staging) openpype_version = OpenPypeVersion( version=BootstrapRepos.get_version(version_path), @@ -714,22 +728,6 @@ def _find_frozen_openpype(use_version: str = None, _initialize_environment(openpype_version) return version_path - # get path of version specified in `--use-version` - local_version = bootstrap.get_version(OPENPYPE_ROOT) - if use_version and use_version != local_version: - # force the one user has selected - openpype_version = None - openpype_versions = bootstrap.find_openpype(include_zips=True, - staging=use_staging) - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if found: - openpype_version = sorted(found)[-1] - if not openpype_version: - _print(f"!!! Requested version {use_version} was not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) - # test if latest detected is installed (in user data dir) is_inside = False try: @@ -742,12 +740,12 @@ def _find_frozen_openpype(use_version: str = None, if not is_inside: # install latest version to user data dir - if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1": - import igniter - version_path = igniter.open_update_window(openpype_version) - else: + if os.getenv("OPENPYPE_HEADLESS_MODE") == "1": version_path = bootstrap.install_version( - openpype_version, force=True) + openpype_version, force=True + ) + else: + version_path = igniter.open_update_window(openpype_version) openpype_version.path = version_path _initialize_environment(openpype_version) @@ -783,6 +781,13 @@ def _bootstrap_from_code(use_version, use_staging): # run through repos and add them to `sys.path` and `PYTHONPATH` # set root _openpype_root = OPENPYPE_ROOT + # Unset use version if latest should be used + # - when executed from code then code is expected as latest + # - when executed from build then build is already marked as latest + # in '_find_frozen_openpype' + if use_version and use_version.lower() == "latest": + use_version = None + if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) switch_str = f" - will switch to {use_version}" if use_version else "" @@ -790,47 +795,45 @@ def _bootstrap_from_code(use_version, use_staging): assert local_version else: # get current version of OpenPype - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() - version_to_use = None - openpype_versions = bootstrap.find_openpype( - include_zips=True, staging=use_staging) - if use_staging and not use_version: - try: - version_to_use = openpype_versions[-1] - except IndexError: - _print("!!! No staging versions are found.") - list_versions(openpype_versions, local_version) - sys.exit(1) + # All cases when should be used different version than build + if (use_version and use_version != local_version) or use_staging: + if use_version: + # Explicit version should be used + version_to_use = bootstrap.find_openpype_version( + use_version, use_staging + ) + if version_to_use is None: + raise OpenPypeVersionNotFound( + "Requested version \"{}\" was not found.".format( + use_version + ) + ) + else: + # Staging version should be used + version_to_use = bootstrap.find_latest_openpype_version( + use_staging + ) + if version_to_use is None: + if use_staging: + reason = "Didn't find any staging versions." + else: + # This reason is backup for possible bug in code + reason = "Didn't find any versions." + raise OpenPypeVersionNotFound(reason) + + # Start extraction of version if needed if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) + version_to_use.path = bootstrap.extract_openpype(version_to_use) bootstrap.add_paths_from_directory(version_to_use.path) - os.environ["OPENPYPE_VERSION"] = str(version_to_use) + os.environ["OPENPYPE_VERSION"] = use_version version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 + os.environ["OPENPYPE_REPOS_ROOT"] = ( + version_path / "openpype" + ).as_posix() _openpype_root = version_to_use.path.as_posix() - elif use_version and use_version != local_version: - v: OpenPypeVersion - found = [v for v in openpype_versions if str(v) == use_version] - if found: - version_to_use = sorted(found)[-1] - - if version_to_use: - # use specified - if version_to_use.path.is_file(): - version_to_use.path = bootstrap.extract_openpype( - version_to_use) - bootstrap.add_paths_from_directory(version_to_use.path) - os.environ["OPENPYPE_VERSION"] = use_version - version_path = version_to_use.path - os.environ["OPENPYPE_REPOS_ROOT"] = (version_path / "openpype").as_posix() # noqa: E501 - _openpype_root = version_to_use.path.as_posix() - else: - _print(f"!!! Requested version {use_version} was not found.") - list_versions(openpype_versions, local_version) - sys.exit(1) else: os.environ["OPENPYPE_VERSION"] = local_version version_path = Path(_openpype_root) @@ -889,16 +892,6 @@ def boot(): # ------------------------------------------------------------------------ _startup_validations() - # ------------------------------------------------------------------------ - # Play animation - # ------------------------------------------------------------------------ - - # from igniter.terminal_splash import play_animation - - # don't play for silenced commands - # if all(item not in sys.argv for item in silent_commands): - # play_animation() - # ------------------------------------------------------------------------ # Process arguments # ------------------------------------------------------------------------ @@ -940,7 +933,7 @@ def boot(): if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(OPENPYPE_ROOT)) else: - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() if "validate" in commands: _print(f">>> Validating version [ {use_version} ]") @@ -969,7 +962,6 @@ def boot(): ) sys.exit(1) - if not openpype_path: _print("*** Cannot get OpenPype path from database.") @@ -989,7 +981,7 @@ def boot(): if getattr(sys, 'frozen', False): local_version = bootstrap.get_version(Path(_openpype_root)) else: - local_version = bootstrap.get_local_live_version() + local_version = OpenPypeVersion.get_installed_version_str() list_versions(openpype_versions, local_version) sys.exit(1) @@ -1003,6 +995,15 @@ def boot(): # find versions of OpenPype to be used with frozen code try: version_path = _find_frozen_openpype(use_version, use_staging) + except OpenPypeVersionNotFound as exc: + message = str(exc) + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + sys.exit(1) + except RuntimeError as e: # no version to run _print(f"!!! {e}") @@ -1015,7 +1016,17 @@ def boot(): sys.exit(1) _print(f"--- version is valid") else: - version_path = _bootstrap_from_code(use_version, use_staging) + try: + version_path = _bootstrap_from_code(use_version, use_staging) + + except OpenPypeVersionNotFound as exc: + message = str(exc) + _print(message) + if os.environ.get("OPENPYPE_HEADLESS_MODE") == "1": + list_versions(openpype_versions, local_version) + else: + igniter.show_message_dialog("Version not found", message) + sys.exit(1) # set this to point either to `python` from venv in case of live code # or to `openpype` or `openpype_console` in case of frozen code @@ -1110,15 +1121,15 @@ def get_info(use_staging=None) -> list: # Reinitialize PypeLogger.initialize() - log_components = PypeLogger.log_mongo_url_components - if log_components["host"]: - inf.append(("Logging to MongoDB", log_components["host"])) - inf.append((" - port", log_components["port"] or "")) + mongo_components = get_default_components() + if mongo_components["host"]: + inf.append(("Logging to MongoDB", mongo_components["host"])) + inf.append((" - port", mongo_components["port"] or "")) inf.append((" - database", PypeLogger.log_database_name)) inf.append((" - collection", PypeLogger.log_collection_name)) - inf.append((" - user", log_components["username"] or "")) - if log_components["auth_db"]: - inf.append((" - auth source", log_components["auth_db"])) + inf.append((" - user", mongo_components["username"] or "")) + if mongo_components["auth_db"]: + inf.append((" - auth source", mongo_components["auth_db"])) maximum = max(len(i[0]) for i in inf) formatted = [] diff --git a/tests/unit/igniter/test_bootstrap_repos.py b/tests/unit/igniter/test_bootstrap_repos.py index d6e861c262..65cd5a2399 100644 --- a/tests/unit/igniter/test_bootstrap_repos.py +++ b/tests/unit/igniter/test_bootstrap_repos.py @@ -140,9 +140,10 @@ def test_search_string_for_openpype_version(printer): ] for ver_string in strings: printer(f"testing {ver_string[0]} should be {ver_string[1]}") - assert OpenPypeVersion.version_in_str(ver_string[0]) == \ - ver_string[1] - + assert isinstance( + OpenPypeVersion.version_in_str(ver_string[0]), + OpenPypeVersion if ver_string[1] else type(None) + ) @pytest.mark.slow def test_install_live_repos(fix_bootstrap, printer, monkeypatch, pytestconfig): diff --git a/tools/create_zip.ps1 b/tools/create_zip.ps1 index c27857b480..e33445d1fa 100644 --- a/tools/create_zip.ps1 +++ b/tools/create_zip.ps1 @@ -96,9 +96,9 @@ if (-not (Test-Path -PathType Container -Path "$($env:POETRY_HOME)\bin")) { Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Cleaning cache files ... " -NoNewline -Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse| Where-Object { $_.FullName -inotmatch 'build' } | Remove-Item -Force -Recurse +Get-ChildItem $openpype_root -Filter "__pycache__" -Force -Recurse| Where-Object {( $_.FullName -inotmatch '\\build\\' ) -and ( $_.FullName -inotmatch '\\.venv' )} | Remove-Item -Force -Recurse +Get-ChildItem $openpype_root -Filter "*.pyc" -Force -Recurse | Where-Object {( $_.FullName -inotmatch '\\build\\' ) -and ( $_.FullName -inotmatch '\\.venv' )} | Remove-Item -Force +Get-ChildItem $openpype_root -Filter "*.pyo" -Force -Recurse | Where-Object {( $_.FullName -inotmatch '\\build\\' ) -and ( $_.FullName -inotmatch '\\.venv' )} | Remove-Item -Force Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green diff --git a/tools/create_zip.py b/tools/create_zip.py index 32a4d27e8b..2fc351469a 100644 --- a/tools/create_zip.py +++ b/tools/create_zip.py @@ -31,7 +31,9 @@ def main(path): bs = bootstrap_repos.BootstrapRepos(progress_callback=progress) if path: out_path = Path(path) - bs.data_dir = out_path.parent + bs.data_dir = out_path + if out_path.is_file(): + bs.data_dir = out_path.parent _print(f"Creating zip in {bs.data_dir} ...") repo_file = bs.create_version_from_live_code() diff --git a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py index 0aa5adaa20..ba1e5f6c6a 100644 --- a/vendor/deadline/custom/plugins/GlobalJobPreLoad.py +++ b/vendor/deadline/custom/plugins/GlobalJobPreLoad.py @@ -48,6 +48,7 @@ def inject_openpype_environment(deadlinePlugin): add_args['asset'] = job.GetJobEnvironmentKeyValue('AVALON_ASSET') add_args['task'] = job.GetJobEnvironmentKeyValue('AVALON_TASK') add_args['app'] = job.GetJobEnvironmentKeyValue('AVALON_APP_NAME') + add_args["envgroup"] = "farm" if all(add_args.values()): for key, value in add_args.items(): diff --git a/website/docs/module_slack.md b/website/docs/module_slack.md index f71fcc2bb7..02676d68a8 100644 --- a/website/docs/module_slack.md +++ b/website/docs/module_slack.md @@ -20,6 +20,12 @@ Slack application must be installed to company's Slack first. Please locate `openpype/modules/slack/manifest.yml` file in deployed OpenPype installation and follow instruction at https://api.slack.com/reference/manifests#using and follow "Creating apps with manifests". +### App icon + +If you would like to enrich bot with an icon, Slack admin must add the icon after app installation. + +Go to your Slack app home (something like https://api.slack.com/apps/XXXXXXXX/general?) > Basic information > Display Information. +You can upload any image you want, or for your convenience locate prepared OpenPype icon in your installed Openpype installation in `openpype\modules\slac\resources`. ## System Settings @@ -61,16 +67,33 @@ Integration can upload 'thumbnail' file (if present in an instance), for that bo manually added to target channel by Slack admin! (In target channel write: ```/invite @OpenPypeNotifier``) +#### Upload review +Integration can upload 'review' file (if present in an instance), for that bot must be +manually added to target channel by Slack admin! +(In target channel write: ```/invite @OpenPypeNotifier``) + +Burnin version (usually .mp4) is preferred if present. + +Please be sure that this configuration is viable for your use case. In case of uploading large reviews to Slack, +all publishes will be slowed down and you might hit a file limit on Slack pretty soon (it is 5GB for Free version of Slack, any file cannot be bigger than 1GB). +You might try to add `{review_link}` to message content. This link might help users to find review easier on their machines. +(It won't show a playable preview though!) + #### Message Message content can use Templating (see [Available template keys](admin_settings_project_anatomy#available-template-keys)). Few keys also have Capitalized and UPPERCASE format. Values will be modified accordingly ({Asset} >> "Asset", {FAMILY} >> "RENDER"). -**Available keys:** -- asset -- subset -- task -- username -- app -- family -- version +**Additional implemented keys:** +- review_filepath + +##### Message example +``` +{Subset} was published for {ASSET} in {task[name]} task. + +Here you can find review {review_filepath} +``` + +#### Message retention +Currently no purging of old messages is implemented in Openpype. Admins of Slack should set their own retention of messages and files per channel. +(see https://slack.com/help/articles/203457187-Customize-message-and-file-retention-policies) diff --git a/website/yarn.lock b/website/yarn.lock index ae40005384..89da2289de 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -1961,9 +1961,9 @@ ajv@^6.1.0, ajv@^6.12.4, ajv@^6.12.5: uri-js "^4.2.2" algoliasearch-helper@^3.3.4: - version "3.4.4" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.4.4.tgz#f2eb46bc4d2f6fed82c7201b8ac4ce0a1988ae67" - integrity sha512-OjyVLjykaYKCMxxRMZNiwLp8CS310E0qAeIY2NaublcmLAh8/SL19+zYHp7XCLtMem2ZXwl3ywMiA32O9jszuw== + version "3.6.2" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.6.2.tgz#45e19b12589cfa0c611b573287f65266ea2cc14a" + integrity sha512-Xx0NOA6k4ySn+R2l3UMSONAaMkyfmrZ3AP1geEMo32MxDJQJesZABZYsldO9fa6FKQxH91afhi4hO1G0Zc2opg== dependencies: events "^1.1.1"