Merge branch 'develop' into feature/OP-2158_add-new-cleanup-for-farm-purposes-as-quickfix

This commit is contained in:
Ondrej Samohel 2022-02-08 14:44:06 +01:00
commit ca4dffe8ee
No known key found for this signature in database
GPG key ID: 02376E18990A97C6
651 changed files with 30432 additions and 8920 deletions

View file

@ -37,6 +37,7 @@ jobs:
- name: 🔨 Build
shell: pwsh
run: |
$env:SKIP_THIRD_PARTY_VALIDATION="1"
./tools/build.ps1
Ubuntu-latest:
@ -61,6 +62,7 @@ jobs:
- name: 🔨 Build
run: |
export SKIP_THIRD_PARTY_VALIDATION="1"
./tools/build.sh
# MacOS-latest:

View file

@ -1,84 +1,124 @@
# Changelog
## [3.8.0-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07)
[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)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.1...3.8.2)
**🚀 Enhancements**
- 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)
- Maya : add option to not group reference in ReferenceLoader [\#2383](https://github.com/pypeclub/OpenPype/pull/2383)
- TVPaint: Image loaders also work on review family [\#2638](https://github.com/pypeclub/OpenPype/pull/2638)
- General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629)
- nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627)
- Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602)
- Nuke: load color space from representation data [\#2576](https://github.com/pypeclub/OpenPype/pull/2576)
**🐛 Bug fixes**
- 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)
- Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628)
### 📖 Documentation
- Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617)
**Merged pull requests:**
- Docker: enhance dockerfiles with metadata, fix pyenv initialization [\#2647](https://github.com/pypeclub/OpenPype/pull/2647)
- WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641)
- Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613)
- Webpublisher: Skip version collect [\#2591](https://github.com/pypeclub/OpenPype/pull/2591)
## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.1-nightly.3...3.8.1)
**🚀 Enhancements**
- Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600)
- Webpublisher: Added endpoint to reprocess batch through UI [\#2555](https://github.com/pypeclub/OpenPype/pull/2555)
- Loader: Allow to toggle default family filters between "include" or "exclude" filtering [\#2541](https://github.com/pypeclub/OpenPype/pull/2541)
- Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536)
**🐛 Bug fixes**
- Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619)
- hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618)
- Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615)
- switch distutils to sysconfig for `get\_platform\(\)` [\#2594](https://github.com/pypeclub/OpenPype/pull/2594)
- Global: fix broken otio review extractor [\#2590](https://github.com/pypeclub/OpenPype/pull/2590)
- Fix poetry index and speedcopy update [\#2589](https://github.com/pypeclub/OpenPype/pull/2589)
- Webpublisher: Fix - subset names from processed .psd used wrong value for task [\#2586](https://github.com/pypeclub/OpenPype/pull/2586)
- `vrscene` creator Deadline webservice URL handling [\#2580](https://github.com/pypeclub/OpenPype/pull/2580)
- global: track name was failing if duplicated root word in name [\#2568](https://github.com/pypeclub/OpenPype/pull/2568)
- Validate Maya Rig produces no cycle errors [\#2484](https://github.com/pypeclub/OpenPype/pull/2484)
**Merged pull requests:**
- Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595)
- build\(deps\): bump pillow from 8.4.0 to 9.0.0 [\#2523](https://github.com/pypeclub/OpenPype/pull/2523)
## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.0-nightly.7...3.8.0)
**🆕 New features**
- Flame: extracting segments with trans-coding [\#2547](https://github.com/pypeclub/OpenPype/pull/2547)
- Maya : V-Ray Proxy - load all ABC files via proxy [\#2544](https://github.com/pypeclub/OpenPype/pull/2544)
- Maya to Unreal: Extended static mesh workflow [\#2537](https://github.com/pypeclub/OpenPype/pull/2537)
- Flame: collecting publishable instances [\#2519](https://github.com/pypeclub/OpenPype/pull/2519)
- Flame: create publishable clips [\#2495](https://github.com/pypeclub/OpenPype/pull/2495)
**🚀 Enhancements**
- Webpublisher: Moved error at the beginning of the log [\#2559](https://github.com/pypeclub/OpenPype/pull/2559)
- Ftrack: Use ApplicationManager to get DJV path [\#2558](https://github.com/pypeclub/OpenPype/pull/2558)
- Settings: PathInput strip passed string [\#2550](https://github.com/pypeclub/OpenPype/pull/2550)
- Global: Exctract Review anatomy fill data with output name [\#2548](https://github.com/pypeclub/OpenPype/pull/2548)
- Cosmetics: Clean up some cosmetics / typos [\#2542](https://github.com/pypeclub/OpenPype/pull/2542)
- General: Validate if current process OpenPype version is requested version [\#2529](https://github.com/pypeclub/OpenPype/pull/2529)
- General: Be able to use anatomy data in ffmpeg output arguments [\#2525](https://github.com/pypeclub/OpenPype/pull/2525)
- Expose toggle publish plug-in settings for Maya Look Shading Engine Naming [\#2521](https://github.com/pypeclub/OpenPype/pull/2521)
- Photoshop: Move implementation to OpenPype [\#2510](https://github.com/pypeclub/OpenPype/pull/2510)
- Slack: notifications are sent with Openpype logo and bot name [\#2499](https://github.com/pypeclub/OpenPype/pull/2499)
- Slack: Add review to notification message [\#2498](https://github.com/pypeclub/OpenPype/pull/2498)
- Maya: Collect 'fps' animation data only for "review" instances [\#2486](https://github.com/pypeclub/OpenPype/pull/2486)
**🐛 Bug fixes**
- AfterEffects: Fix - removed obsolete import [\#2577](https://github.com/pypeclub/OpenPype/pull/2577)
- General: OpenPype version updates [\#2575](https://github.com/pypeclub/OpenPype/pull/2575)
- Ftrack: Delete action revision [\#2563](https://github.com/pypeclub/OpenPype/pull/2563)
- Webpublisher: ftrack shows incorrect user names [\#2560](https://github.com/pypeclub/OpenPype/pull/2560)
- General: Do not validate version if build does not support it [\#2557](https://github.com/pypeclub/OpenPype/pull/2557)
- Webpublisher: Fixed progress reporting [\#2553](https://github.com/pypeclub/OpenPype/pull/2553)
- Fix Maya AssProxyLoader version switch [\#2551](https://github.com/pypeclub/OpenPype/pull/2551)
- General: Fix install thread in igniter [\#2549](https://github.com/pypeclub/OpenPype/pull/2549)
- Houdini: vdbcache family preserve frame numbers on publish integration + enable validate version for Houdini [\#2535](https://github.com/pypeclub/OpenPype/pull/2535)
- Maya: Fix Load VDB to V-Ray [\#2533](https://github.com/pypeclub/OpenPype/pull/2533)
- Maya: ReferenceLoader fix not unique group name error for attach to root [\#2532](https://github.com/pypeclub/OpenPype/pull/2532)
- Maya: namespaced context go back to original namespace when started from inside a namespace [\#2531](https://github.com/pypeclub/OpenPype/pull/2531)
- Fix create zip tool - path argument [\#2522](https://github.com/pypeclub/OpenPype/pull/2522)
- Maya: Fix Extract Look with space in names [\#2518](https://github.com/pypeclub/OpenPype/pull/2518)
- Fix published frame content for sequence starting with 0 [\#2513](https://github.com/pypeclub/OpenPype/pull/2513)
- Maya: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506)
- Improve FusionPreLaunch hook errors [\#2505](https://github.com/pypeclub/OpenPype/pull/2505)
### 📖 Documentation
- Variable in docs renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546)
**Merged pull requests:**
- AfterEffects: Move implementation to OpenPype [\#2543](https://github.com/pypeclub/OpenPype/pull/2543)
- Maya: Remove Maya Look Assigner check on startup [\#2540](https://github.com/pypeclub/OpenPype/pull/2540)
- build\(deps\): bump shelljs from 0.8.4 to 0.8.5 in /website [\#2538](https://github.com/pypeclub/OpenPype/pull/2538)
- build\(deps\): bump follow-redirects from 1.14.4 to 1.14.7 in /website [\#2534](https://github.com/pypeclub/OpenPype/pull/2534)
- Nuke: Merge avalon's implementation into OpenPype [\#2514](https://github.com/pypeclub/OpenPype/pull/2514)
## [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)
**🚀 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)
- Hiero: python3 compatibility [\#2365](https://github.com/pypeclub/OpenPype/pull/2365)
- Maya: Add is\_static\_image\_plane and is\_in\_all\_views option in imagePlaneLoader [\#2356](https://github.com/pypeclub/OpenPype/pull/2356)
- TVPaint: Move implementation to OpenPype [\#2336](https://github.com/pypeclub/OpenPype/pull/2336)
**🐛 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)
- Flame: fix ftrack publisher [\#2381](https://github.com/pypeclub/OpenPype/pull/2381)
- hiero: solve custom ocio path [\#2379](https://github.com/pypeclub/OpenPype/pull/2379)
- hiero: fix workio and flatten [\#2378](https://github.com/pypeclub/OpenPype/pull/2378)
- 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)
- 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)
## [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)

View file

@ -1,13 +1,18 @@
# Build Pype docker image
FROM debian:bookworm-slim AS builder
FROM ubuntu:focal AS builder
ARG OPENPYPE_PYTHON_VERSION=3.7.12
ARG BUILD_DATE
ARG VERSION
LABEL maintainer="info@openpype.io"
LABEL description="Docker Image to build and run OpenPype"
LABEL description="Docker Image to build and run OpenPype under Ubuntu 20.04"
LABEL org.opencontainers.image.name="pypeclub/openpype"
LABEL org.opencontainers.image.title="OpenPype Docker Image"
LABEL org.opencontainers.image.url="https://openpype.io/"
LABEL org.opencontainers.image.source="https://github.com/pypeclub/pype"
LABEL org.opencontainers.image.source="https://github.com/pypeclub/OpenPype"
LABEL org.opencontainers.image.documentation="https://openpype.io/docs/system_introduction"
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.version=$VERSION
USER root
@ -42,14 +47,19 @@ RUN apt-get update \
SHELL ["/bin/bash", "-c"]
RUN mkdir /opt/openpype
# download and install pyenv
RUN curl https://pyenv.run | bash \
&& echo 'export PATH="$HOME/.pyenv/bin:$PATH"'>> $HOME/.bashrc \
&& echo 'eval "$(pyenv init -)"' >> $HOME/.bashrc \
&& echo 'eval "$(pyenv virtualenv-init -)"' >> $HOME/.bashrc \
&& echo 'eval "$(pyenv init --path)"' >> $HOME/.bashrc \
&& source $HOME/.bashrc && pyenv install ${OPENPYPE_PYTHON_VERSION}
&& echo 'export PATH="$HOME/.pyenv/bin:$PATH"'>> $HOME/init_pyenv.sh \
&& echo 'eval "$(pyenv init -)"' >> $HOME/init_pyenv.sh \
&& echo 'eval "$(pyenv virtualenv-init -)"' >> $HOME/init_pyenv.sh \
&& echo 'eval "$(pyenv init --path)"' >> $HOME/init_pyenv.sh
# install python with pyenv
RUN source $HOME/init_pyenv.sh \
&& pyenv install ${OPENPYPE_PYTHON_VERSION}
COPY . /opt/openpype/
@ -57,13 +67,16 @@ RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/b
WORKDIR /opt/openpype
# set local python version
RUN cd /opt/openpype \
&& source $HOME/.bashrc \
&& source $HOME/init_pyenv.sh \
&& pyenv local ${OPENPYPE_PYTHON_VERSION}
RUN source $HOME/.bashrc \
# fetch third party tools/libraries
RUN source $HOME/init_pyenv.sh \
&& ./tools/create_env.sh \
&& ./tools/fetch_thirdparty_libs.sh
RUN source $HOME/.bashrc \
# build openpype
RUN source $HOME/init_pyenv.sh \
&& bash ./tools/build.sh

View file

@ -1,11 +1,15 @@
# Build Pype docker image
FROM centos:7 AS builder
ARG OPENPYPE_PYTHON_VERSION=3.7.10
ARG OPENPYPE_PYTHON_VERSION=3.7.12
LABEL org.opencontainers.image.name="pypeclub/openpype"
LABEL org.opencontainers.image.title="OpenPype Docker Image"
LABEL org.opencontainers.image.url="https://openpype.io/"
LABEL org.opencontainers.image.source="https://github.com/pypeclub/pype"
LABEL org.opencontainers.image.documentation="https://openpype.io/docs/system_introduction"
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.version=$VERSION
USER root
@ -38,7 +42,8 @@ RUN yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.n
patchelf \
automake \
autoconf \
ncurses \
patch \
ncurses \
ncurses-devel \
qt5-qtbase-devel \
xcb-util-wm \

View file

@ -762,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.
@ -912,7 +912,6 @@ class BootstrapRepos:
processed_path = file
self._print(f"- processing {processed_path}")
checksums.append(
(
sha256sum(file.as_posix()),
@ -1544,7 +1543,8 @@ class BootstrapRepos:
Args:
zip_item (Path): Zip file to test.
detected_version (OpenPypeVersion): Pype version detected from name.
detected_version (OpenPypeVersion): Pype version detected from
name.
Returns:
True if it is valid OpenPype version, False otherwise.

View file

@ -60,7 +60,7 @@ class InstallThread(QThread):
# find local version of OpenPype
bs = BootstrapRepos(
progress_callback=self.set_progress, message=self.message)
local_version = bs.get_local_live_version()
local_version = OpenPypeVersion.get_installed_version_str()
# if user did entered nothing, we install OpenPype from local version.
# zip content of `repos`, copy it to user data dir and append

View file

@ -412,3 +412,23 @@ def repack_version(directory):
directory name.
"""
PypeCommands().repack_version(directory)
@main.command()
@click.option("--project", help="Project name")
@click.option(
"--dirpath", help="Directory where package is stored", default=None
)
def pack_project(project, dirpath):
"""Create a package of project with all files and database dump."""
PypeCommands().pack_project(project, dirpath)
@main.command()
@click.option("--zipfile", help="Path to zip file")
@click.option(
"--root", help="Replace root which was stored in project", default=None
)
def unpack_project(zipfile, root):
"""Create a package of project with all files and database dump."""
PypeCommands().unpack_project(zipfile, root)

View file

@ -6,6 +6,8 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
"""Add last workfile path to launch arguments.
This is not possible to do for all applications the same way.
Checks 'start_last_workfile', if set to False, it will not open last
workfile. This property is set explicitly in Launcher.
"""
# Execute after workfile template copy

View file

@ -43,6 +43,7 @@ class GlobalHostDataHook(PreLaunchHook):
"env": self.launch_context.env,
"start_last_workfile": self.data.get("start_last_workfile"),
"last_workfile_path": self.data.get("last_workfile_path"),
"log": self.log

View file

@ -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,12 +35,15 @@ 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
workfile_path = self.data["last_workfile_path"]
if os.path.exists(workfile_path):
if (
self.data.get("start_last_workfile")
and workfile_path
and os.path.exists(workfile_path)):
new_launch_args.append(workfile_path)
# Append as whole list as these areguments should not be separated
@ -48,4 +51,3 @@ class NonPythonHostHook(PreLaunchHook):
if remainders:
self.launch_context.launch_args.extend(remainders)

View file

@ -0,0 +1,66 @@
# AfterEffects Integration
Requirements: This extension requires use of Javascript engine, which is
available since CC 16.0.
Please check your File>Project Settings>Expressions>Expressions Engine
## Setup
The After Effects 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
```
OR
download [Anastasiys Extension Manager](https://install.anastasiy.com/)
### Server
The easiest way to get the server and After Effects launch is with:
```
python -c ^"import avalon.photoshop;avalon.aftereffects.launch(""c:\Program Files\Adobe\Adobe After Effects 2020\Support Files\AfterFX.exe"")^"
```
`avalon.aftereffects.launch` launches the application and server, and also closes the server when After Effects exists.
## Usage
The After Effects extension can be found under `Window > Extensions > OpenPype`. 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-After-Effects avalon extension.p12
ZXPSignCmd -sign {path to avalon-core}\avalon\aftereffects\extension {path to avalon-core}\avalon\aftereffects\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).
Expected deployed extension location on default Windows:
`c:\Program Files (x86)\Common Files\Adobe\CEP\extensions\com.openpype.AE.panel`
For easier debugging of Javascript:
https://community.adobe.com/t5/download-install/adobe-extension-debuger-problem/td-p/10911704?page=1
Add (optional) --enable-blink-features=ShadowDOMV0,CustomElementsV0 when starting Chrome
then localhost:8092
Or use Visual Studio Code https://medium.com/adobetech/extendscript-debugger-for-visual-studio-code-public-release-a2ff6161fa01
## Resources
- https://javascript-tools-guide.readthedocs.io/introduction/index.html
- https://github.com/Adobe-CEP/Getting-Started-guides
- https://github.com/Adobe-CEP/CEP-Resources

View file

@ -1,115 +1,68 @@
import os
import sys
import logging
"""Public API
from avalon import io
from avalon import api as avalon
from Qt import QtWidgets
from openpype import lib, api
import pyblish.api as pyblish
import openpype.hosts.aftereffects
Anything that isn't defined here is INTERNAL and unreliable for external use.
"""
from .launch_logic import (
get_stub,
stub,
)
from .pipeline import (
ls,
get_asset_settings,
install,
uninstall,
list_instances,
remove_instance,
containerise
)
from .workio import (
file_extensions,
has_unsaved_changes,
save_file,
open_file,
current_file,
work_root,
)
from .lib import (
maintained_selection,
get_extension_manifest_path
)
from .plugin import (
AfterEffectsLoader
)
log = logging.getLogger("openpype.hosts.aftereffects")
__all__ = [
# launch_logic
"get_stub",
"stub",
# pipeline
"ls",
"get_asset_settings",
"install",
"uninstall",
"list_instances",
"remove_instance",
"containerise",
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.aftereffects.__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")
"file_extensions",
"has_unsaved_changes",
"save_file",
"open_file",
"current_file",
"work_root",
# lib
"maintained_selection",
"get_extension_manifest_path",
def check_inventory():
if not lib.any_outdated():
return
host = pyblish.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..")
app = QtWidgets.QApplication(sys.argv)
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_()
# Garbage collect QApplication.
del app
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
def get_asset_settings():
"""Get settings on current asset from database.
Returns:
dict: Scene data.
"""
asset_data = lib.get_asset()["data"]
fps = asset_data.get("fps")
frame_start = asset_data.get("frameStart")
frame_end = asset_data.get("frameEnd")
handle_start = asset_data.get("handleStart")
handle_end = asset_data.get("handleEnd")
resolution_width = asset_data.get("resolutionWidth")
resolution_height = asset_data.get("resolutionHeight")
duration = (frame_end - frame_start + 1) + handle_start + handle_end
entity_type = asset_data.get("entityType")
scene_data = {
"fps": fps,
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
"handleEnd": handle_end,
"resolutionWidth": resolution_width,
"resolutionHeight": resolution_height,
"duration": duration
}
return scene_data
# plugin
"AfterEffectsLoader"
]

Binary file not shown.

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionList>
<Extension Id="com.openpype.AE.panel">
<HostList>
<!-- Comment Host tags according to the apps you want your panel to support -->
<!-- Photoshop -->
<Host Name="PHXS" Port="8088"/>
<!-- Illustrator -->
<Host Name="ILST" Port="8089"/>
<!-- InDesign -->
<Host Name="IDSN" Port="8090" />
<!-- Premiere -->
<Host Name="PPRO" Port="8091" />
<!-- AfterEffects -->
<Host Name="AEFT" Port="8092" />
<!-- PRELUDE -->
<Host Name="PRLD" Port="8093" />
<!-- FLASH Pro -->
<Host Name="FLPR" Port="8094" />
</HostList>
</Extension>
</ExtensionList>

View file

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionManifest Version="8.0" ExtensionBundleId="com.openpype.AE.panel" ExtensionBundleVersion="1.0.21"
ExtensionBundleName="openpype" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ExtensionList>
<Extension Id="com.openpype.AE.panel" Version="1.0" />
</ExtensionList>
<ExecutionEnvironment>
<HostList>
<!-- Uncomment Host tags according to the apps you want your panel to support -->
<!-- Photoshop -->
<!--<Host Name="PHXS" Version="[14.0,19.0]" /> -->
<!-- <Host Name="PHSP" Version="[14.0,19.0]" /> -->
<!-- Illustrator -->
<!-- <Host Name="ILST" Version="[18.0,22.0]" /> -->
<!-- InDesign -->
<!-- <Host Name="IDSN" Version="[10.0,13.0]" /> -->
<!-- Premiere -->
<!-- <Host Name="PPRO" Version="[8.0,12.0]" /> -->
<!-- AfterEffects -->
<Host Name="AEFT" Version="[13.0,99.0]" />
<!-- PRELUDE -->
<!-- <Host Name="PRLD" Version="[3.0,7.0]" /> -->
<!-- FLASH Pro -->
<!-- <Host Name="FLPR" Version="[14.0,18.0]" /> -->
</HostList>
<LocaleList>
<Locale Code="All" />
</LocaleList>
<RequiredRuntimeList>
<RequiredRuntime Name="CSXS" Version="9.0" />
</RequiredRuntimeList>
</ExecutionEnvironment>
<DispatchInfoList>
<Extension Id="com.openpype.AE.panel">
<DispatchInfo >
<Resources>
<MainPath>./index.html</MainPath>
<ScriptPath>./jsx/hostscript.jsx</ScriptPath>
</Resources>
<Lifecycle>
<AutoVisible>true</AutoVisible>
</Lifecycle>
<UI>
<Type>Panel</Type>
<Menu>OpenPype</Menu>
<Geometry>
<Size>
<Height>200</Height>
<Width>100</Width>
</Size>
<!--<MinSize>
<Height>550</Height>
<Width>400</Width>
</MinSize>
<MaxSize>
<Height>550</Height>
<Width>400</Width>
</MaxSize>-->
</Geometry>
<Icons>
<Icon Type="Normal">./icons/iconNormal.png</Icon>
<Icon Type="RollOver">./icons/iconRollover.png</Icon>
<Icon Type="Disabled">./icons/iconDisabled.png</Icon>
<Icon Type="DarkNormal">./icons/iconDarkNormal.png</Icon>
<Icon Type="DarkRollOver">./icons/iconDarkRollover.png</Icon>
</Icons>
</UI>
</DispatchInfo>
</Extension>
</DispatchInfoList>
</ExtensionManifest>

View file

@ -0,0 +1,327 @@
/*
* HTML5 Boilerplate
*
* What follows is the result of much research on cross-browser styling.
* Credit left inline and big thanks to Nicolas Gallagher, Jonathan Neal,
* Kroc Camen, and the H5BP dev community and team.
*
* Detailed information about this CSS: h5bp.com/css
*
* ==|== normalize ==========================================================
*/
/* =============================================================================
HTML5 display definitions
========================================================================== */
article, aside, details, figcaption, figure, footer, header, hgroup, nav, section { display: block; }
audio, canvas, video { display: inline-block; *display: inline; *zoom: 1; }
audio:not([controls]) { display: none; }
[hidden] { display: none; }
/* =============================================================================
Base
========================================================================== */
/*
* 1. Correct text resizing oddly in IE6/7 when body font-size is set using em units
* 2. Force vertical scrollbar in non-IE
* 3. Prevent iOS text size adjust on device orientation change, without disabling user zoom: h5bp.com/g
*/
html { font-size: 100%; overflow-y: scroll; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
body { margin: 0; font-size: 100%; line-height: 1.231; }
body, button, input, select, textarea { font-family: helvetica, arial,"lucida grande", verdana, "メイリオ", " Pゴシック", sans-serif; color: #222; }
/*
* Remove text-shadow in selection highlight: h5bp.com/i
* These selection declarations have to be separate
* Also: hot pink! (or customize the background color to match your design)
*/
::selection { text-shadow: none; background-color: highlight; color: highlighttext; }
/* =============================================================================
Links
========================================================================== */
a { color: #00e; }
a:visited { color: #551a8b; }
a:hover { color: #06e; }
a:focus { outline: thin dotted; }
/* Improve readability when focused and hovered in all browsers: h5bp.com/h */
a:hover, a:active { outline: 0; }
/* =============================================================================
Typography
========================================================================== */
abbr[title] { border-bottom: 1px dotted; }
b, strong { font-weight: bold; }
blockquote { margin: 1em 40px; }
dfn { font-style: italic; }
hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; }
ins { background: #ff9; color: #000; text-decoration: none; }
mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; }
/* Redeclare monospace font family: h5bp.com/j */
pre, code, kbd, samp { font-family: monospace, serif; _font-family: 'courier new', monospace; font-size: 1em; }
/* Improve readability of pre-formatted text in all browsers */
pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; }
q { quotes: none; }
q:before, q:after { content: ""; content: none; }
small { font-size: 85%; }
/* Position subscript and superscript content without affecting line-height: h5bp.com/k */
sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; }
sup { top: -0.5em; }
sub { bottom: -0.25em; }
/* =============================================================================
Lists
========================================================================== */
ul, ol { margin: 1em 0; padding: 0 0 0 40px; }
dd { margin: 0 0 0 40px; }
nav ul, nav ol { list-style: none; list-style-image: none; margin: 0; padding: 0; }
/* =============================================================================
Embedded content
========================================================================== */
/*
* 1. Improve image quality when scaled in IE7: h5bp.com/d
* 2. Remove the gap between images and borders on image containers: h5bp.com/e
*/
img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; }
/*
* Correct overflow not hidden in IE9
*/
svg:not(:root) { overflow: hidden; }
/* =============================================================================
Figures
========================================================================== */
figure { margin: 0; }
/* =============================================================================
Forms
========================================================================== */
form { margin: 0; }
fieldset { border: 0; margin: 0; padding: 0; }
/* Indicate that 'label' will shift focus to the associated form element */
label { cursor: pointer; }
/*
* 1. Correct color not inheriting in IE6/7/8/9
* 2. Correct alignment displayed oddly in IE6/7
*/
legend { border: 0; *margin-left: -7px; padding: 0; }
/*
* 1. Correct font-size not inheriting in all browsers
* 2. Remove margins in FF3/4 S5 Chrome
* 3. Define consistent vertical alignment display in all browsers
*/
button, input, select, textarea { font-size: 100%; margin: 0; vertical-align: baseline; *vertical-align: middle; }
/*
* 1. Define line-height as normal to match FF3/4 (set using !important in the UA stylesheet)
*/
button, input { line-height: normal; }
/*
* 1. Display hand cursor for clickable form elements
* 2. Allow styling of clickable form elements in iOS
* 3. Correct inner spacing displayed oddly in IE7 (doesn't effect IE6)
*/
button, input[type="button"], input[type="reset"], input[type="submit"] { cursor: pointer; -webkit-appearance: button; *overflow: visible; }
/*
* Consistent box sizing and appearance
*/
input[type="checkbox"], input[type="radio"] { box-sizing: border-box; padding: 0; }
input[type="search"] { -webkit-appearance: textfield; -moz-box-sizing: content-box; -webkit-box-sizing: content-box; box-sizing: content-box; }
input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; }
/*
* Remove inner padding and border in FF3/4: h5bp.com/l
*/
button::-moz-focus-inner, input::-moz-focus-inner { border: 0; padding: 0; }
/*
* 1. Remove default vertical scrollbar in IE6/7/8/9
* 2. Allow only vertical resizing
*/
textarea { overflow: auto; vertical-align: top; resize: vertical; }
/* Colors for form validity */
input:valid, textarea:valid { }
input:invalid, textarea:invalid { background-color: #f0dddd; }
/* =============================================================================
Tables
========================================================================== */
table { border-collapse: collapse; border-spacing: 0; }
td { vertical-align: top; }
/* ==|== primary styles =====================================================
Author:
========================================================================== */
/* ==|== media queries ======================================================
PLACEHOLDER Media Queries for Responsive Design.
These override the primary ('mobile first') styles
Modify as content requires.
========================================================================== */
@media only screen and (min-width: 480px) {
/* Style adjustments for viewports 480px and over go here */
}
@media only screen and (min-width: 768px) {
/* Style adjustments for viewports 768px and over go here */
}
/* ==|== non-semantic helper classes ========================================
Please define your styles before this section.
========================================================================== */
/* For image replacement */
.ir { display: block; border: 0; text-indent: -999em; overflow: hidden; background-color: transparent; background-repeat: no-repeat; text-align: left; direction: ltr; }
.ir br { display: none; }
/* Hide from both screenreaders and browsers: h5bp.com/u */
.hidden { display: none !important; visibility: hidden; }
/* Hide only visually, but have it available for screenreaders: h5bp.com/v */
.visuallyhidden { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; }
/* Extends the .visuallyhidden class to allow the element to be focusable when navigated to via the keyboard: h5bp.com/p */
.visuallyhidden.focusable:active, .visuallyhidden.focusable:focus { clip: auto; height: auto; margin: 0; overflow: visible; position: static; width: auto; }
/* Hide visually and from screenreaders, but maintain layout */
.invisible { visibility: hidden; }
/* Contain floats: h5bp.com/q */
.clearfix:before, .clearfix:after { content: ""; display: table; }
.clearfix:after { clear: both; }
.clearfix { *zoom: 1; }
/* ==|== print styles =======================================================
Print styles.
Inlined to avoid required HTTP connection: h5bp.com/r
========================================================================== */
@media print {
* { background: transparent !important; color: black !important; box-shadow:none !important; text-shadow: none !important; filter:none !important; -ms-filter: none !important; } /* Black prints faster: h5bp.com/s */
a, a:visited { text-decoration: underline; }
a[href]:after { content: " (" attr(href) ")"; }
abbr[title]:after { content: " (" attr(title) ")"; }
.ir a:after, a[href^="javascript:"]:after, a[href^="#"]:after { content: ""; } /* Don't show links for images, or javascript/internal links */
pre, blockquote { border: 1px solid #999; page-break-inside: avoid; }
table { display: table-header-group; } /* h5bp.com/t */
tr, img { page-break-inside: avoid; }
img { max-width: 100% !important; }
@page { margin: 0.5cm; }
p, h2, h3 { orphans: 3; widows: 3; }
h2, h3 { page-break-after: avoid; }
}
/* reflow reset for -webkit-margin-before: 1em */
p { margin: 0; }
html {
overflow-y: auto;
background-color: transparent;
height: 100%;
}
body {
background: #fff;
font: normal 100%;
position: relative;
height: 100%;
}
body, div, img, p, button, input, select, textarea {
box-sizing: border-box;
}
.image {
display: block;
}
input {
cursor: default;
display: block;
}
input[type=button] {
background-color: #e5e9e8;
border: 1px solid #9daca9;
border-radius: 4px;
box-shadow: inset 0 1px #fff;
font: inherit;
letter-spacing: inherit;
text-indent: inherit;
color: inherit;
}
input[type=button]:hover {
background-color: #eff1f1;
}
input[type=button]:active {
background-color: #d2d6d6;
border: 1px solid #9daca9;
box-shadow: inset 0 1px rgba(0,0,0,0.1);
}
/* Reset anchor styles to an unstyled default to be in parity with design surface. It
is presumed that most link styles in real-world designs are custom (non-default). */
a, a:visited, a:hover, a:active {
color: inherit;
text-decoration: inherit;
}

View file

@ -0,0 +1,51 @@
/*Your styles*/
body {
margin: 10px;
}
#content {
margin-right:auto;
margin-left:auto;
vertical-align:middle;
width:100%;
}
#btn_test{
width: 100%;
}
/*
Those classes will be edited at runtime with values specified
by the settings of the CC application
*/
.hostFontColor{}
.hostFontFamily{}
.hostFontSize{}
/*font family, color and size*/
.hostFont{}
/*background color*/
.hostBgd{}
/*lighter background color*/
.hostBgdLight{}
/*darker background color*/
.hostBgdDark{}
/*background color and font*/
.hostElt{}
.hostButton{
border:1px solid;
border-radius:2px;
height:20px;
vertical-align:bottom;
font-family:inherit;
color:inherit;
font-size:inherit;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,136 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="css/topcoat-desktop-dark.min.css"/>
<link id="hostStyle" rel="stylesheet" href="css/styles.css"/>
<style type="text/css">
html, body, iframe {
width: 100%;
height: 100%;
border: 0px;
margin: 0px;
overflow: hidden;
}
button {width: 100%;}
</style>
<style>
button {width: 100%;}
body {margin:0; padding:0; height: 100%;}
html {height: 100%;}
</style>
<title></title>
<script src="js/libs/jquery-2.0.2.min.js"></script>
<script type=text/javascript>
$(function() {
$("a#workfiles-button").bind("click", function() {
RPC.call('AfterEffects.workfiles_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#creator-button").bind("click", function() {
RPC.call('AfterEffects.creator_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#loader-button").bind("click", function() {
RPC.call('AfterEffects.loader_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#publish-button").bind("click", function() {
RPC.call('AfterEffects.publish_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#sceneinventory-button").bind("click", function() {
RPC.call('AfterEffects.sceneinventory_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#subsetmanager-button").bind("click", function() {
RPC.call('AfterEffects.subsetmanager_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
<script type=text/javascript>
$(function() {
$("a#experimental-button").bind("click", function() {
RPC.call('AfterEffects.experimental_tools_route').then(function (data) {
}, function (error) {
alert(error);
});
});
});
</script>
</head>
<body class="hostElt">
<div id="content">
<div>
<div></div><a href=# id=workfiles-button><button class="hostFontSize">Workfiles...</button></a></div>
<div> <a href=# id=creator-button><button class="hostFontSize">Create...</button></a></div>
<div><a href=# id=loader-button><button class="hostFontSize">Load...</button></a></div>
<div><a href=# id=publish-button><button class="hostFontSize">Publish...</button></a></div>
<div><a href=# id=sceneinventory-button><button class="hostFontSize">Manage...</button></a></div>
<div><a href=# id=subsetmanager-button><button class="hostFontSize">Subset Manager...</button></a></div>
<div><a href=# id=experimental-button><button class="hostFontSize">Experimental Tools...</button></a></div>
</div>
</div>
<!-- <script src="js/libs/PlayerDebugMode"></script> -->
<script src="js/libs/wsrpc.js"></script>
<script src="js/libs/loglevel.min.js"></script>
<script src="js/libs/CSInterface.js"></script>
<script src="js/themeManager.js"></script>
<script src="js/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -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 "&nbsp;"),
// 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");
};
}
}());

View file

@ -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<k.length;c++){var d=k[c];this[d]=c<a?h:this.methodFactory(d,a,b)}this.log=this.debug}function e(a,b,c){return function(){typeof console!==i&&(d.call(this,b,c),this[a].apply(this,arguments))}}function f(a,b,d){return c(a)||e.apply(this,arguments)}function g(a,b,c){function e(a){var b=(k[a]||"silent").toUpperCase();if(typeof window!==i){try{return void(window.localStorage[l]=b)}catch(a){}try{window.document.cookie=encodeURIComponent(l)+"="+b+";"}catch(a){}}}function g(){var a;if(typeof window!==i){try{a=window.localStorage[l]}catch(a){}if(typeof a===i)try{var b=window.document.cookie,c=b.indexOf(encodeURIComponent(l)+"=");-1!==c&&(a=/^([^;]+)/.exec(b.slice(c))[1])}catch(a){}return void 0===j.levels[a]&&(a=void 0),a}}var h,j=this,l="loglevel";a&&(l+=":"+a),j.name=a,j.levels={TRACE:0,DEBUG:1,INFO:2,WARN:3,ERROR:4,SILENT:5},j.methodFactory=c||f,j.getLevel=function(){return h},j.setLevel=function(b,c){if("string"==typeof b&&void 0!==j.levels[b.toUpperCase()]&&(b=j.levels[b.toUpperCase()]),!("number"==typeof b&&b>=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<j.levels.SILENT)return"No console available for logging"},j.setDefaultLevel=function(a){g()||j.setLevel(a,!1)},j.enableAll=function(a){j.setLevel(j.levels.TRACE,a)},j.disableAll=function(a){j.setLevel(j.levels.SILENT,a)};var m=g();null==m&&(m=null==b?"WARN":b),j.setLevel(m,!1)}var h=function(){},i="undefined",j=typeof window!==i&&typeof window.navigator!==i&&/Trident\/|MSIE /.test(window.navigator.userAgent),k=["trace","debug","info","warn","error"],l=new g,m={};l.getLogger=function(a){if("string"!=typeof a||""===a)throw new TypeError("You must supply a name when creating a logger.");var b=m[a];return b||(b=m[a]=new g(a,l.getLevel(),l.methodFactory)),b};var n=typeof window!==i?window.log:void 0;return l.noConflict=function(){return typeof window!==i&&window.log===l&&(window.log=n),l},l.getLoggers=function(){return m},l});

View file

@ -0,0 +1,393 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.WSRPC = factory());
}(this, function () { 'use strict';
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
var Deferred = function Deferred() {
_classCallCheck(this, Deferred);
var self = this;
self.resolve = null;
self.reject = null;
self.done = false;
function wrapper(func) {
return function () {
if (self.done) throw new Error('Promise already done');
self.done = true;
return func.apply(this, arguments);
};
}
self.promise = new Promise(function (resolve, reject) {
self.resolve = wrapper(resolve);
self.reject = wrapper(reject);
});
self.promise.isPending = function () {
return !self.done;
};
return self;
};
function logGroup(group, level, args) {
console.group(group);
console[level].apply(this, args);
console.groupEnd();
}
function log() {
if (!WSRPC.DEBUG) return;
logGroup('WSRPC.DEBUG', 'trace', arguments);
}
function trace(msg) {
if (!WSRPC.TRACE) return;
var payload = msg;
if ('data' in msg) payload = JSON.parse(msg.data);
logGroup("WSRPC.TRACE", 'trace', [payload]);
}
function getAbsoluteWsUrl(url) {
if (/^\w+:\/\//.test(url)) return url;
if (typeof window == 'undefined' && window.location.host.length < 1) throw new Error("Can not construct absolute URL from ".concat(window.location));
var scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
var port = window.location.port === '' ? ":".concat(window.location.port) : '';
var host = window.location.host;
var path = url.replace(/^\/+/gm, '');
return "".concat(scheme, "//").concat(host).concat(port, "/").concat(path);
}
var readyState = Object.freeze({
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSING',
3: 'CLOSED'
});
var WSRPC = function WSRPC(URL) {
var reconnectTimeout = arguments.length > 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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,347 @@
/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true,
indent: 4, maxerr: 50 */
/*global $, window, location, CSInterface, SystemPath, themeManager*/
var csInterface = new CSInterface();
log.warn("script start");
WSRPC.DEBUG = false;
WSRPC.TRACE = false;
// get websocket server url from environment value
async function startUp(url){
promis = runEvalScript("getEnv('" + url + "')");
var res = await promis;
log.warn("res: " + res);
promis = runEvalScript("getEnv('OPENPYPE_DEBUG')");
var debug = await promis;
log.warn("debug: " + debug);
if (debug && debug.toString() == '3'){
WSRPC.DEBUG = true;
WSRPC.TRACE = true;
}
// 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 '{"result":"' + version + '"}'
}
function main(websocket_url){
// creates connection to 'websocket_url', registers routes
var default_url = 'ws://localhost:8099/ws/';
if (websocket_url == ''){
websocket_url = default_url;
}
RPC = new WSRPC(websocket_url, 5000); // spin connection
RPC.connect();
log.warn("connected");
RPC.addRoute('AfterEffects.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('AfterEffects.get_metadata', function (data) {
log.warn('Server called client route "get_metadata":', data);
return runEvalScript("getMetadata()")
.then(function(result){
log.warn("getMetadata: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.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("get_active_document_name: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.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("get_active_document_full_name: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.get_items', function (data) {
log.warn('Server called client route "get_items":', data);
return runEvalScript("getItems(" + data.comps + "," +
data.folders + "," +
data.footages + ")")
.then(function(result){
log.warn("get_items: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.get_selected_items', function (data) {
log.warn('Server called client route "get_selected_items":', data);
return runEvalScript("getSelectedItems(" + data.comps + "," +
data.folders + "," +
data.footages + ")")
.then(function(result){
log.warn("get_items: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.import_file', function (data) {
log.warn('Server called client route "import_file":', data);
var escapedPath = EscapeStringForJSX(data.path);
return runEvalScript("importFile('" + escapedPath +"', " +
"'" + data.item_name + "'," +
"'" + JSON.stringify(
data.import_options) + "')")
.then(function(result){
log.warn("importFile: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.replace_item', function (data) {
log.warn('Server called client route "replace_item":', data);
var escapedPath = EscapeStringForJSX(data.path);
return runEvalScript("replaceItem(" + data.item_id + ", " +
"'" + escapedPath + "', " +
"'" + data.item_name + "')")
.then(function(result){
log.warn("replaceItem: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.rename_item', function (data) {
log.warn('Server called client route "rename_item":', data);
return runEvalScript("renameItem(" + data.item_id + ", " +
"'" + data.item_name + "')")
.then(function(result){
log.warn("renameItem: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.delete_item', function (data) {
log.warn('Server called client route "delete_item":', data);
return runEvalScript("deleteItem(" + data.item_id + ")")
.then(function(result){
log.warn("deleteItem: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.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('AfterEffects.set_label_color', function (data) {
log.warn('Server called client route "set_label_color":', data);
return runEvalScript("setLabelColor(" + data.item_id + "," +
data.color_idx + ")")
.then(function(result){
log.warn("imprint: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.get_work_area', function (data) {
log.warn('Server called client route "get_work_area":', data);
return runEvalScript("getWorkArea(" + data.item_id + ")")
.then(function(result){
log.warn("getWorkArea: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.set_work_area', function (data) {
log.warn('Server called client route "set_work_area":', data);
return runEvalScript("setWorkArea(" + data.item_id + ',' +
data.start + ',' +
data.duration + ',' +
data.frame_rate + ")")
.then(function(result){
log.warn("getWorkArea: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.saveAs', function (data) {
log.warn('Server called client route "saveAs":', data);
var escapedPath = EscapeStringForJSX(data.image_path);
return runEvalScript("saveAs('" + escapedPath + "', " +
data.as_copy + ")")
.then(function(result){
log.warn("saveAs: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.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('AfterEffects.get_render_info', function (data) {
log.warn('Server called client route "get_render_info":', data);
return runEvalScript("getRenderInfo()")
.then(function(result){
log.warn("get_render_info: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.get_audio_url', function (data) {
log.warn('Server called client route "get_audio_url":', data);
return runEvalScript("getAudioUrlForComp(" + data.item_id + ")")
.then(function(result){
log.warn("getAudioUrlForComp: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.import_background', function (data) {
log.warn('Server called client route "import_background":', data);
return runEvalScript("importBackground(" + data.comp_id + ", " +
"'" + data.comp_name + "', " +
JSON.stringify(data.files) + ")")
.then(function(result){
log.warn("importBackground: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.reload_background', function (data) {
log.warn('Server called client route "reload_background":', data);
return runEvalScript("reloadBackground(" + data.comp_id + ", " +
"'" + data.comp_name + "', " +
JSON.stringify(data.files) + ")")
.then(function(result){
log.warn("reloadBackground: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.add_item_as_layer', function (data) {
log.warn('Server called client route "add_item_as_layer":', data);
return runEvalScript("addItemAsLayerToComp(" + data.comp_id + ", " +
data.item_id + "," +
" null )")
.then(function(result){
log.warn("addItemAsLayerToComp: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.render', function (data) {
log.warn('Server called client route "render":', data);
var escapedPath = EscapeStringForJSX(data.folder_url);
return runEvalScript("render('" + escapedPath +"')")
.then(function(result){
log.warn("render: " + result);
return result;
});
});
RPC.addRoute('AfterEffects.get_extension_version', function (data) {
log.warn('Server called client route "get_extension_version":', data);
return get_extension_version();
});
RPC.addRoute('AfterEffects.close', function (data) {
log.warn('Server called client route "close":', data);
return runEvalScript("close()");
});
}
/** main entry point **/
startUp("WEBSOCKET_URL");
(function () {
'use strict';
var csInterface = new CSInterface();
function init() {
themeManager.init();
$("#btn_test").click(function () {
csInterface.evalScript('sayHello()');
});
}
init();
}());
function EscapeStringForJSX(str){
// Replaces:
// \ with \\
// ' with \'
// " with \"
// See: https://stackoverflow.com/a/3967927/5285364
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g,'\\"');
}
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);
});
}

View file

@ -0,0 +1,128 @@
/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true, indent: 4, maxerr: 50 */
/*global window, document, CSInterface*/
/*
Responsible for overwriting CSS at runtime according to CC app
settings as defined by the end user.
*/
var themeManager = (function () {
'use strict';
/**
* Convert the Color object to string in hexadecimal format;
*/
function toHex(color, delta) {
function computeValue(value, delta) {
var computedValue = !isNaN(delta) ? value + delta : value;
if (computedValue < 0) {
computedValue = 0;
} else if (computedValue > 255) {
computedValue = 255;
}
computedValue = Math.floor(computedValue);
computedValue = computedValue.toString(16);
return computedValue.length === 1 ? "0" + computedValue : computedValue;
}
var hex = "";
if (color) {
hex = computeValue(color.red, delta) + computeValue(color.green, delta) + computeValue(color.blue, delta);
}
return hex;
}
function reverseColor(color, delta) {
return toHex({
red: Math.abs(255 - color.red),
green: Math.abs(255 - color.green),
blue: Math.abs(255 - color.blue)
},
delta);
}
function addRule(stylesheetId, selector, rule) {
var stylesheet = document.getElementById(stylesheetId);
if (stylesheet) {
stylesheet = stylesheet.sheet;
if (stylesheet.addRule) {
stylesheet.addRule(selector, rule);
} else if (stylesheet.insertRule) {
stylesheet.insertRule(selector + ' { ' + rule + ' }', stylesheet.cssRules.length);
}
}
}
/**
* Update the theme with the AppSkinInfo retrieved from the host product.
*/
function updateThemeWithAppSkinInfo(appSkinInfo) {
var panelBgColor = appSkinInfo.panelBackgroundColor.color;
var bgdColor = toHex(panelBgColor);
var darkBgdColor = toHex(panelBgColor, 20);
var fontColor = "F0F0F0";
if (panelBgColor.red > 122) {
fontColor = "000000";
}
var lightBgdColor = toHex(panelBgColor, -100);
var styleId = "hostStyle";
addRule(styleId, ".hostElt", "background-color:" + "#" + bgdColor);
addRule(styleId, ".hostElt", "font-size:" + appSkinInfo.baseFontSize + "px;");
addRule(styleId, ".hostElt", "font-family:" + appSkinInfo.baseFontFamily);
addRule(styleId, ".hostElt", "color:" + "#" + fontColor);
addRule(styleId, ".hostBgd", "background-color:" + "#" + bgdColor);
addRule(styleId, ".hostBgdDark", "background-color: " + "#" + darkBgdColor);
addRule(styleId, ".hostBgdLight", "background-color: " + "#" + lightBgdColor);
addRule(styleId, ".hostFontSize", "font-size:" + appSkinInfo.baseFontSize + "px;");
addRule(styleId, ".hostFontFamily", "font-family:" + appSkinInfo.baseFontFamily);
addRule(styleId, ".hostFontColor", "color:" + "#" + fontColor);
addRule(styleId, ".hostFont", "font-size:" + appSkinInfo.baseFontSize + "px;");
addRule(styleId, ".hostFont", "font-family:" + appSkinInfo.baseFontFamily);
addRule(styleId, ".hostFont", "color:" + "#" + fontColor);
addRule(styleId, ".hostButton", "background-color:" + "#" + darkBgdColor);
addRule(styleId, ".hostButton:hover", "background-color:" + "#" + bgdColor);
addRule(styleId, ".hostButton:active", "background-color:" + "#" + darkBgdColor);
addRule(styleId, ".hostButton", "border-color: " + "#" + lightBgdColor);
}
function onAppThemeColorChanged(event) {
var skinInfo = JSON.parse(window.__adobe_cep__.getHostEnvironment()).appSkinInfo;
updateThemeWithAppSkinInfo(skinInfo);
}
function init() {
var csInterface = new CSInterface();
updateThemeWithAppSkinInfo(csInterface.hostEnvironment.appSkinInfo);
csInterface.addEventListener(CSInterface.THEME_COLOR_CHANGED_EVENT, onAppThemeColorChanged);
}
return {
init: init
};
}());

View file

@ -0,0 +1,723 @@
/*jslint vars: true, plusplus: true, devel: true, nomen: true, regexp: true,
indent: 4, maxerr: 50 */
/*global $, Folder*/
#include "../js/libs/json.js";
/* All public API function should return JSON! */
app.preferences.savePrefAsBool("General Section", "Show Welcome Screen", false) ;
if(!Array.prototype.indexOf) {
Array.prototype.indexOf = function ( item ) {
var index = 0, length = this.length;
for ( ; index < length; index++ ) {
if ( this[index] === item )
return index;
}
return -1;
};
}
function sayHello(){
alert("hello from ExtendScript");
}
function getEnv(variable){
return $.getenv(variable);
}
function getMetadata(){
/**
* Returns payload in 'Label' field of project's metadata
*
**/
if (ExternalObject.AdobeXMPScript === undefined){
ExternalObject.AdobeXMPScript =
new ExternalObject('lib:AdobeXMPScript');
}
var proj = app.project;
var meta = new XMPMeta(app.project.xmpPacket);
var schemaNS = XMPMeta.getNamespaceURI("xmp");
var label = "xmp:Label";
if (meta.doesPropertyExist(schemaNS, label)){
var prop = meta.getProperty(schemaNS, label);
return prop.value;
}
return _prepareSingleValue([]);
}
function imprint(payload){
/**
* Stores payload in 'Label' field of project's metadata
*
* Args:
* payload (string): json content
*/
if (ExternalObject.AdobeXMPScript === undefined){
ExternalObject.AdobeXMPScript =
new ExternalObject('lib:AdobeXMPScript');
}
var proj = app.project;
var meta = new XMPMeta(app.project.xmpPacket);
var schemaNS = XMPMeta.getNamespaceURI("xmp");
var label = "xmp:Label";
meta.setProperty(schemaNS, label, payload);
app.project.xmpPacket = meta.serialize();
}
function fileOpen(path){
/**
* Opens (project) file on 'path'
*/
fp = new File(path);
return _prepareSingleValue(app.open(fp))
}
function getActiveDocumentName(){
/**
* Returns file name of active document
* */
var file = app.project.file;
if (file){
return _prepareSingleValue(file.name)
}
return _prepareError("No file open currently");
}
function getActiveDocumentFullName(){
/**
* Returns absolute path to current project
* */
var file = app.project.file;
if (file){
var f = new File(file.fullName);
var path = f.fsName;
f.close();
return _prepareSingleValue(path)
}
return _prepareError("No file open currently");
}
function getItems(comps, folders, footages){
/**
* Returns JSON representation of compositions and
* if 'collectLayers' then layers in comps too.
*
* Args:
* comps (bool): return selected compositions
* folders (bool): return folders
* footages (bool): return FootageItem
* Returns:
* (list) of JSON items
*/
var items = []
for (i = 1; i <= app.project.items.length; ++i){
var item = app.project.items[i];
if (!item){
continue;
}
var ret = _getItem(item, comps, folders, footages);
if (ret){
items.push(ret);
}
}
return '[' + items.join() + ']';
}
function getSelectedItems(comps, folders, footages){
/**
* Returns list of selected items from Project menu
*
* Args:
* comps (bool): return selected compositions
* folders (bool): return folders
* footages (bool): return FootageItem
* Returns:
* (list) of JSON items
*/
var items = []
for (i = 0; i < app.project.selection.length; ++i){
var item = app.project.selection[i];
if (!item){
continue;
}
var ret = _getItem(item, comps, folders, footages);
if (ret){
items.push(ret);
}
}
return '[' + items.join() + ']';
}
function _getItem(item, comps, folders, footages){
/**
* Auxiliary function as project items and selections
* are indexed in different way :/
* Refactor
*/
var item_type = '';
if (item instanceof FolderItem){
item_type = 'folder';
if (!folders){
return "{}";
}
}
if (item instanceof FootageItem){
item_type = 'footage';
if (!footages){
return "{}";
}
}
if (item instanceof CompItem){
item_type = 'comp';
if (!comps){
return "{}";
}
}
var item = {"name": item.name,
"id": item.id,
"type": item_type};
return JSON.stringify(item);
}
function importFile(path, item_name, import_options){
/**
* Imports file (image tested for now) as a FootageItem.
* Creates new composition
*
* Args:
* path (string): absolute path to image file
* item_name (string): label for composition
* Returns:
* JSON {name, id}
*/
var comp;
var ret = {};
try{
import_options = JSON.parse(import_options);
} catch (e){
return _prepareError("Couldn't parse import options " + import_options);
}
app.beginUndoGroup("Import File");
fp = new File(path);
if (fp.exists){
try {
im_opt = new ImportOptions(fp);
importAsType = import_options["ImportAsType"];
if ('ImportAsType' in import_options){ // refactor
if (importAsType.indexOf('COMP') > 0){
im_opt.importAs = ImportAsType.COMP;
}
if (importAsType.indexOf('FOOTAGE') > 0){
im_opt.importAs = ImportAsType.FOOTAGE;
}
if (importAsType.indexOf('COMP_CROPPED_LAYERS') > 0){
im_opt.importAs = ImportAsType.COMP_CROPPED_LAYERS;
}
if (importAsType.indexOf('PROJECT') > 0){
im_opt.importAs = ImportAsType.PROJECT;
}
}
if ('sequence' in import_options){
im_opt.sequence = true;
}
comp = app.project.importFile(im_opt);
if (app.project.selection.length == 2 &&
app.project.selection[0] instanceof FolderItem){
comp.parentFolder = app.project.selection[0]
}
} catch (error) {
return _prepareError(error.toString() + importOptions.file.fsName);
} finally {
fp.close();
}
}else{
return _prepareError("File " + path + " not found.");
}
if (comp){
comp.name = item_name;
comp.label = 9; // Green
ret = {"name": comp.name, "id": comp.id}
}
app.endUndoGroup();
return JSON.stringify(ret);
}
function setLabelColor(comp_id, color_idx){
/**
* Set item_id label to 'color_idx' color
* Args:
* item_id (int): item id
* color_idx (int): 0-16 index from Label
*/
var item = app.project.itemByID(comp_id);
if (item){
item.label = color_idx;
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function replaceItem(comp_id, path, item_name){
/**
* Replaces loaded file with new file and updates name
*
* Args:
* comp_id (int): id of composition, not a index!
* path (string): absolute path to new file
* item_name (string): new composition name
*/
app.beginUndoGroup("Replace File");
fp = new File(path);
if (!fp.exists){
return _prepareError("File " + path + " not found.");
}
var item = app.project.itemByID(comp_id);
if (item){
try{
if (isFileSequence(item)) {
item.replaceWithSequence(fp, false);
}else{
item.replace(fp);
}
item.name = item_name;
} catch (error) {
return _prepareError(error.toString() + path);
} finally {
fp.close();
}
}else{
return _prepareError("There is no composition with "+ comp_id);
}
app.endUndoGroup();
}
function renameItem(item_id, new_name){
/**
* Renames item with 'item_id' to 'new_name'
*
* Args:
* item_id (int): id to search item
* new_name (str)
*/
var item = app.project.itemByID(item_id);
if (item){
item.name = new_name;
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function deleteItem(item_id){
/**
* Delete any 'item_id'
*
* Not restricted only to comp, it could delete
* any item with 'id'
*/
var item = app.project.itemByID(item_id);
if (item){
item.remove();
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function getWorkArea(comp_id){
/**
* Returns information about workarea - are that will be
* rendered. All calculation will be done in OpenPype,
* easier to modify without redeploy of extension.
*
* Returns
* (dict)
*/
var item = app.project.itemByID(comp_id);
if (item){
return JSON.stringify({
"workAreaStart": item.displayStartFrame,
"workAreaDuration": item.duration,
"frameRate": item.frameRate});
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function setWorkArea(comp_id, workAreaStart, workAreaDuration, frameRate){
/**
* Sets work area info from outside (from Ftrack via OpenPype)
*/
var item = app.project.itemByID(comp_id);
if (item){
item.displayStartTime = workAreaStart;
item.duration = workAreaDuration;
item.frameRate = frameRate;
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function save(){
/**
* Saves current project
*/
app.project.save(); //TODO path is wrong, File instead
}
function saveAs(path){
/**
* Saves current project as 'path'
* */
app.project.save(fp = new File(path));
}
function getRenderInfo(){
/***
Get info from render queue.
Currently pulls only file name to parse extension and
if it is sequence in Python
**/
try{
var render_item = app.project.renderQueue.item(1);
if (render_item.status == RQItemStatus.DONE){
render_item.duplicate(); // create new, cannot change status if DONE
render_item.remove(); // remove existing to limit duplications
render_item = app.project.renderQueue.item(1);
}
render_item.render = true; // always set render queue to render
var item = render_item.outputModule(1);
} catch (error) {
return _prepareError("There is no render queue, create one");
}
var file_url = item.file.toString();
return JSON.stringify({
"file_name": file_url
})
}
function getAudioUrlForComp(comp_id){
/**
* Searches composition for audio layer
*
* Only single AVLayer is expected!
* Used for collecting Audio
*
* Args:
* comp_id (int): id of composition
* Return:
* (str) with url to audio content
*/
var item = app.project.itemByID(comp_id);
if (item){
for (i = 1; i <= item.numLayers; ++i){
var layer = item.layers[i];
if (layer instanceof AVLayer){
return layer.source.file.fsName.toString();
}
}
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function addItemAsLayerToComp(comp_id, item_id, found_comp){
/**
* Adds already imported FootageItem ('item_id') as a new
* layer to composition ('comp_id').
*
* Args:
* comp_id (int): id of target composition
* item_id (int): FootageItem.id
* found_comp (CompItem, optional): to limit quering if
* comp already found previously
*/
var comp = found_comp || app.project.itemByID(comp_id);
if (comp){
item = app.project.itemByID(item_id);
if (item){
comp.layers.add(item);
}else{
return _prepareError("There is no item with " + item_id);
}
}else{
return _prepareError("There is no composition with "+ comp_id);
}
}
function importBackground(comp_id, composition_name, files_to_import){
/**
* Imports backgrounds images to existing or new composition.
*
* If comp_id is not provided, new composition is created, basic
* values (width, heights, frameRatio) takes from first imported
* image.
*
* Args:
* comp_id (int): id of existing composition (null if new)
* composition_name (str): used when new composition
* files_to_import (list): list of absolute paths to import and
* add as layers
*
* Returns:
* (str): json representation (id, name, members)
*/
var comp;
var folder;
var imported_ids = [];
if (comp_id){
comp = app.project.itemByID(comp_id);
folder = comp.parentFolder;
}else{
if (app.project.selection.length > 1){
return _prepareError(
"Too many items selected, select only target composition!");
}else{
selected_item = app.project.activeItem;
if (selected_item instanceof Folder){
comp = selected_item;
folder = selected_item;
}
}
}
if (files_to_import){
for (i = 0; i < files_to_import.length; ++i){
item = _importItem(files_to_import[i]);
if (!item){
return _prepareError(
"No item for " + item_json["id"] +
". Import background failed.")
}
if (!comp){
folder = app.project.items.addFolder(composition_name);
imported_ids.push(folder.id);
comp = app.project.items.addComp(composition_name, item.width,
item.height, item.pixelAspect,
1, 26.7); // hardcode defaults
imported_ids.push(comp.id);
comp.parentFolder = folder;
}
imported_ids.push(item.id)
item.parentFolder = folder;
addItemAsLayerToComp(comp.id, item.id, comp);
}
}
var item = {"name": comp.name,
"id": folder.id,
"members": imported_ids};
return JSON.stringify(item);
}
function reloadBackground(comp_id, composition_name, files_to_import){
/**
* Reloads existing composition.
*
* It deletes complete composition with encompassing folder, recreates
* from scratch via 'importBackground' functionality.
*
* Args:
* comp_id (int): id of existing composition (null if new)
* composition_name (str): used when new composition
* files_to_import (list): list of absolute paths to import and
* add as layers
*
* Returns:
* (str): json representation (id, name, members)
*
*/
var imported_ids = []; // keep track of members of composition
comp = app.project.itemByID(comp_id);
folder = comp.parentFolder;
if (folder){
renameItem(folder.id, composition_name);
imported_ids.push(folder.id);
}
if (comp){
renameItem(comp.id, composition_name);
imported_ids.push(comp.id);
}
var existing_layer_names = [];
var existing_layer_ids = []; // because ExtendedScript doesnt have keys()
for (i = 1; i <= folder.items.length; ++i){
layer = folder.items[i];
//because comp.layers[i] doesnt have 'id' accessible
if (layer instanceof CompItem){
continue;
}
existing_layer_names.push(layer.name);
existing_layer_ids.push(layer.id);
}
var new_filenames = [];
if (files_to_import){
for (i = 0; i < files_to_import.length; ++i){
file_name = _get_file_name(files_to_import[i]);
new_filenames.push(file_name);
idx = existing_layer_names.indexOf(file_name);
if (idx >= 0){ // update
var layer_id = existing_layer_ids[idx];
replaceItem(layer_id, files_to_import[i], file_name);
imported_ids.push(layer_id);
}else{ // new layer
item = _importItem(files_to_import[i]);
if (!item){
return _prepareError(
"No item for " + files_to_import[i] +
". Reload background failed.");
}
imported_ids.push(item.id);
item.parentFolder = folder;
addItemAsLayerToComp(comp.id, item.id, comp);
}
}
}
_delete_obsolete_items(folder, new_filenames);
var item = {"name": comp.name,
"id": folder.id,
"members": imported_ids};
return JSON.stringify(item);
}
function _get_file_name(file_url){
/**
* Returns file name without extension from 'file_url'
*
* Args:
* file_url (str): full absolute url
* Returns:
* (str)
*/
fp = new File(file_url);
file_name = fp.name.substring(0, fp.name.lastIndexOf("."));
return file_name;
}
function _delete_obsolete_items(folder, new_filenames){
/***
* Goes through 'folder' and removes layers not in new
* background
*
* Args:
* folder (FolderItem)
* new_filenames (array): list of layer names in new bg
*/
// remove items in old, but not in new
delete_ids = []
for (i = 1; i <= folder.items.length; ++i){
layer = folder.items[i];
//because comp.layers[i] doesnt have 'id' accessible
if (layer instanceof CompItem){
continue;
}
if (new_filenames.indexOf(layer.name) < 0){
delete_ids.push(layer.id);
}
}
for (i = 0; i < delete_ids.length; ++i){
deleteItem(delete_ids[i]);
}
}
function _importItem(file_url){
/**
* Imports 'file_url' as new FootageItem
*
* Args:
* file_url (str): file url with content
* Returns:
* (FootageItem)
*/
file_name = _get_file_name(file_url);
//importFile prepared previously to return json
item_json = importFile(file_url, file_name, JSON.stringify({"ImportAsType":"FOOTAGE"}));
item_json = JSON.parse(item_json);
item = app.project.itemByID(item_json["id"]);
return item;
}
function isFileSequence (item){
/**
* Check that item is a recognizable sequence
*/
if (item instanceof FootageItem && item.mainSource instanceof FileSource && !(item.mainSource.isStill) && item.hasVideo){
var extname = item.mainSource.file.fsName.split('.').pop();
return extname.match(new RegExp("(ai|bmp|bw|cin|cr2|crw|dcr|dng|dib|dpx|eps|erf|exr|gif|hdr|ico|icb|iff|jpe|jpeg|jpg|mos|mrw|nef|orf|pbm|pef|pct|pcx|pdf|pic|pict|png|ps|psd|pxr|raf|raw|rgb|rgbe|rla|rle|rpf|sgi|srf|tdi|tga|tif|tiff|vda|vst|x3f|xyze)", "i")) !== null;
}
return false;
}
function render(target_folder){
var out_dir = new Folder(target_folder);
var out_dir = out_dir.fsName;
for (i = 1; i <= app.project.renderQueue.numItems; ++i){
var render_item = app.project.renderQueue.item(i);
var om1 = app.project.renderQueue.item(i).outputModule(1);
var file_name = File.decode( om1.file.name ).replace('℗', ''); // Name contains special character, space?
var omItem1_settable_str = app.project.renderQueue.item(i).outputModule(1).getSettings( GetSettingsFormat.STRING_SETTABLE );
if (render_item.status == RQItemStatus.DONE){
render_item.duplicate();
render_item.remove();
continue;
}
var targetFolder = new Folder(target_folder);
if (!targetFolder.exists) {
targetFolder.create();
}
om1.file = new File(targetFolder.fsName + '/' + file_name);
}
app.project.renderQueue.render();
}
function close(){
app.project.close(CloseOptions.DO_NOT_SAVE_CHANGES);
app.quit();
}
function _prepareSingleValue(value){
return JSON.stringify({"result": value})
}
function _prepareError(error_msg){
return JSON.stringify({"error": error_msg})
}

View file

@ -0,0 +1,319 @@
import os
import subprocess
import collections
import logging
import asyncio
import functools
from wsrpc_aiohttp import (
WebSocketRoute,
WebSocketAsync
)
from Qt import QtCore
from openpype.tools.utils import host_tools
from avalon import api
from avalon.tools.webserver.app import WebServerTool
from .ws_stub import AfterEffectsServerStub
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
class ConnectionNotEstablishedYet(Exception):
pass
def get_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: <PhotoshopClientStub> where functions could be called from
"""
ae_stub = AfterEffectsServerStub()
if not ae_stub.client:
raise ConnectionNotEstablishedYet("Connection is not created yet")
return ae_stub
def stub():
return get_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 = "AfterEffects"
_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 alreadu 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:
from openpype.api import Logger
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 = get_stub()
if _stub:
return True
except Exception:
pass
return None
@classmethod
def execute_in_main_thread(cls, callback):
cls._main_thread_callbacks.append(callback)
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:
callback = cls._main_thread_callbacks.popleft()
callback()
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, AfterEffectsRoute
)
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 AfterEffectsRoute(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 AfterEffects route")
self.instance = self
return kwargs
# server functions
async def ping(self):
log.debug("someone called AfterEffects 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("aftereffects.read client calls server server calls "
"aftereffects client")
return await self.socket.call('aftereffects.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."""
partial_method = functools.partial(show_tool_by_name,
_tool_name)
ProcessLauncher.execute_in_main_thread(partial_method)
# Required return statement.
return "nothing"

View file

@ -0,0 +1,71 @@
import os
import sys
import contextlib
import traceback
import logging
from Qt import QtWidgets
from openpype.lib.remote_publish import headless_publish
from openpype.tools.utils import host_tools
from .launch_logic import ProcessLauncher, get_stub
log = logging.getLogger(__name__)
log.setLevel(logging.DEBUG)
def safe_excepthook(*args):
traceback.print_exception(*args)
def main(*subprocess_args):
sys.excepthook = safe_excepthook
import avalon.api
from openpype.hosts.aftereffects import api
avalon.api.install(api)
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"):
# reusing ConsoleTrayApp approach as it was already implemented
launcher.execute_in_main_thread(lambda: headless_publish(
log,
"CloseAE",
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(
lambda: host_tools.show_tool_by_name("workfiles", save=save)
)
sys.exit(app.exec_())
@contextlib.contextmanager
def maintained_selection():
"""Maintain selection during context."""
selection = get_stub().get_selected_items(True, False, False)
try:
yield selection
finally:
pass
def get_extension_manifest_path():
return os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"extension",
"CSXS",
"manifest.xml"
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,272 @@
import os
import sys
from Qt import QtWidgets
import pyblish.api
import avalon.api
from avalon import io, pipeline
from openpype import lib
from openpype.api import Logger
import openpype.hosts.aftereffects
from .launch_logic import get_stub
log = Logger.get_logger(__name__)
HOST_DIR = os.path.dirname(
os.path.abspath(openpype.hosts.aftereffects.__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 = pyblish.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..")
app = QtWidgets.QApplication(sys.argv)
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 application_launch():
check_inventory()
def install():
print("Installing Pype config...")
pyblish.api.register_host("aftereffects")
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", 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 on_pyblish_instance_toggled(instance, old_value, new_value):
"""Toggle layer visibility on instance toggles."""
instance[0].Visible = new_value
def get_asset_settings():
"""Get settings on current asset from database.
Returns:
dict: Scene data.
"""
asset_data = lib.get_asset()["data"]
fps = asset_data.get("fps")
frame_start = asset_data.get("frameStart")
frame_end = asset_data.get("frameEnd")
handle_start = asset_data.get("handleStart")
handle_end = asset_data.get("handleEnd")
resolution_width = asset_data.get("resolutionWidth")
resolution_height = asset_data.get("resolutionHeight")
duration = (frame_end - frame_start + 1) + handle_start + handle_end
return {
"fps": fps,
"frameStart": frame_start,
"frameEnd": frame_end,
"handleStart": handle_start,
"handleEnd": handle_end,
"resolutionWidth": resolution_width,
"resolutionHeight": resolution_height,
"duration": duration
}
def containerise(name,
namespace,
comp,
context,
loader=None,
suffix="_CON"):
"""
Containerisation enables a tracking of version, author and origin
for loaded assets.
Creates dictionary payloads that gets saved into file metadata. Each
container contains of who loaded (loader) and members (single or multiple
in case of background).
Arguments:
name (str): Name of resulting assembly
namespace (str): Namespace under which to host container
comp (Comp): Composition 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
"""
data = {
"schema": "openpype:container-2.0",
"id": pipeline.AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace,
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
"members": comp.members or [comp.id]
}
stub = get_stub()
stub.imprint(comp, data)
return comp
def _get_stub():
"""
Handle pulling stub from PS to run operations on host
Returns:
(AEServerStub) or None
"""
try:
stub = get_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 ls():
"""Yields containers from active AfterEffects document.
This is the host-equivalent of api.ls(), but instead of listing
assets on disk, it lists assets already loaded in AE; once loaded
they are called 'containers'. Used in Manage tool.
Containers could be on multiple levels, single images/videos/was as a
FootageItem, or multiple items - backgrounds (folder with automatically
created composition and all imported layers).
Yields:
dict: container
"""
try:
stub = get_stub() # only after AfterEffects is up
except lib.ConnectionNotEstablishedYet:
print("Not connected yet, ignoring")
return
layers_meta = stub.get_metadata()
for item in stub.get_items(comps=True,
folders=True,
footages=True):
data = stub.read(item, 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"] = item.name.replace(stub.LOADED_ICON, '')
data["layer"] = item
yield data
def list_instances():
"""
List all created instances from current workfile which
will be published.
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_metadata()
for instance in layers_meta:
if instance.get("schema") and \
"container" in instance.get("schema"):
continue
uuid_val = instance.get("uuid")
if uuid_val:
instance['uuid'] = uuid_val
else:
instance['uuid'] = instance.get("members")[0] # legacy
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"))
item = stub.get_item(instance.get("uuid"))
if item:
stub.rename_item(item.id,
item.name.replace(stub.PUBLISH_ICON, ''))

View file

@ -0,0 +1,9 @@
import avalon.api
from .launch_logic import get_stub
class AfterEffectsLoader(avalon.api.Loader):
@staticmethod
def get_stub():
return get_stub()

View file

@ -0,0 +1,49 @@
"""Host API required Work Files tool"""
import os
from .launch_logic import get_stub
from avalon import api
def _active_document():
document_name = get_stub().get_active_document_name()
if not document_name:
return None
return document_name
def file_extensions():
return api.HOST_WORKFILE_EXTENSIONS["aftereffects"]
def has_unsaved_changes():
if _active_document():
return not get_stub().is_saved()
return False
def save_file(filepath):
get_stub().saveAs(filepath, True)
def open_file(filepath):
get_stub().open(filepath)
return True
def current_file():
try:
full_name = get_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("\\", "/")

View file

@ -0,0 +1,605 @@
"""
Stub handling connection from server to client.
Used anywhere solution is calling client methods.
"""
import json
import logging
import attr
from wsrpc_aiohttp import WebSocketAsync
from avalon.tools.webserver.app import WebServerTool
@attr.s
class AEItem(object):
"""
Object denoting Item in AE. Each item is created in AE 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
item_type = attr.ib(default=None) # item type (footage, folder, comp)
# all imported elements, single for
# regular image, array for Backgrounds
members = attr.ib(factory=list)
workAreaStart = attr.ib(default=None)
workAreaDuration = attr.ib(default=None)
frameRate = attr.ib(default=None)
file_name = attr.ib(default=None)
class AfterEffectsServerStub():
"""
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()
self.log = logging.getLogger(self.__class__.__name__)
@staticmethod
def get_client():
"""
Return first connected client to WebSocket
TODO implement selection by Route
:return: <WebSocketAsync> 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
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.open', path=path))
return self._handle_return(res)
def get_metadata(self):
"""
Get complete stored JSON with metadata from AE.Metadata.Label
field.
It contains containers loaded by any Loader OR instances creted
by Creator.
Returns:
(list)
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_metadata'))
metadata = self._handle_return(res)
return metadata or []
def read(self, item, layers_meta=None):
"""
Parses item metadata from Label field of active document.
Used as filter to pick metadata for specific 'item' only.
Args:
item (AEItem): pulled info from AE
layers_meta (dict): full list from Headline
(load and inject for better performance in loops)
Returns:
(dict):
"""
if layers_meta is None:
layers_meta = self.get_metadata()
for item_meta in layers_meta:
if 'container' in item_meta.get('id') and \
str(item.id) == str(item_meta.get('members')[0]):
return item_meta
self.log.debug("Couldn't find layer metadata")
def imprint(self, item, data, all_items=None, items_meta=None):
"""
Save item metadata to Label field of metadata of active document
Args:
item (AEItem):
data(string): json representation for single layer
all_items (list of item): for performance, could be
injected for usage in loop, if not, single call will be
triggered
items_meta(string): json representation from Headline
(for performance - provide only if imprint is in
loop - value should be same)
Returns: None
"""
if not items_meta:
items_meta = self.get_metadata()
result_meta = []
# fix existing
is_new = True
for item_meta in items_meta:
if item_meta.get('members') \
and str(item.id) == str(item_meta.get('members')[0]):
is_new = False
if data:
item_meta.update(data)
result_meta.append(item_meta)
else:
result_meta.append(item_meta)
if is_new:
result_meta.append(data)
# Ensure only valid ids are stored.
if not all_items:
# loaders create FootageItem now
all_items = self.get_items(comps=True,
folders=True,
footages=True)
item_ids = [int(item.id) for item in all_items]
cleaned_data = []
for meta in result_meta:
# for creation of instance OR loaded container
if 'instance' in meta.get('id') or \
int(meta.get('members')[0]) in item_ids:
cleaned_data.append(meta)
payload = json.dumps(cleaned_data, indent=4)
res = self.websocketserver.call(self.client.call
('AfterEffects.imprint',
payload=payload))
return self._handle_return(res)
def get_active_document_full_name(self):
"""
Returns just a name of active document via ws call
Returns(string): file name
"""
res = self.websocketserver.call(self.client.call(
'AfterEffects.get_active_document_full_name'))
return self._handle_return(res)
def get_active_document_name(self):
"""
Returns just a name of active document via ws call
Returns(string): file name
"""
res = self.websocketserver.call(self.client.call(
'AfterEffects.get_active_document_name'))
return self._handle_return(res)
def get_items(self, comps, folders=False, footages=False):
"""
Get all items from Project panel according to arguments.
There are multiple different types:
CompItem (could have multiple layers - source for Creator,
will be rendered)
FolderItem (collection type, currently used for Background
loading)
FootageItem (imported file - created by Loader)
Args:
comps (bool): return CompItems
folders (bool): return FolderItem
footages (bool: return FootageItem
Returns:
(list) of namedtuples
"""
res = self.websocketserver.call(
self.client.call('AfterEffects.get_items',
comps=comps,
folders=folders,
footages=footages)
)
return self._to_records(self._handle_return(res))
def get_selected_items(self, comps, folders=False, footages=False):
"""
Same as get_items but using selected items only
Args:
comps (bool): return CompItems
folders (bool): return FolderItem
footages (bool: return FootageItem
Returns:
(list) of namedtuples
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_selected_items',
comps=comps,
folders=folders,
footages=footages)
)
return self._to_records(self._handle_return(res))
def get_item(self, item_id):
"""
Returns metadata for particular 'item_id' or None
Args:
item_id (int, or string)
"""
for item in self.get_items(True, True, True):
if str(item.id) == str(item_id):
return item
return None
def import_file(self, path, item_name, import_options=None):
"""
Imports file as a FootageItem. Used in Loader
Args:
path (string): absolute path for asset file
item_name (string): label for created FootageItem
import_options (dict): different files (img vs psd) need different
config
"""
res = self.websocketserver.call(
self.client.call('AfterEffects.import_file',
path=path,
item_name=item_name,
import_options=import_options)
)
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def replace_item(self, item_id, path, item_name):
""" Replace FootageItem with new file
Args:
item_id (int):
path (string):absolute path
item_name (string): label on item in Project list
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.replace_item',
item_id=item_id,
path=path, item_name=item_name))
return self._handle_return(res)
def rename_item(self, item_id, item_name):
""" Replace item with item_name
Args:
item_id (int):
item_name (string): label on item in Project list
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.rename_item',
item_id=item_id,
item_name=item_name))
return self._handle_return(res)
def delete_item(self, item_id):
""" Deletes *Item in a file
Args:
item_id (int):
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.delete_item',
item_id=item_id))
return self._handle_return(res)
def remove_instance(self, instance_id):
"""
Removes instance with 'instance_id' from file's metadata and
saves them.
Keep matching item in file though.
Args:
instance_id(string): instance uuid
"""
cleaned_data = []
for instance in self.get_metadata():
uuid_val = instance.get("uuid")
if not uuid_val:
uuid_val = instance.get("members")[0] # legacy
if uuid_val != instance_id:
cleaned_data.append(instance)
payload = json.dumps(cleaned_data, indent=4)
res = self.websocketserver.call(self.client.call
('AfterEffects.imprint',
payload=payload))
return self._handle_return(res)
def is_saved(self):
# TODO
return True
def set_label_color(self, item_id, color_idx):
"""
Used for highlight additional information in Project panel.
Green color is loaded asset, blue is created asset
Args:
item_id (int):
color_idx (int): 0-16 Label colors from AE Project view
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.set_label_color',
item_id=item_id,
color_idx=color_idx))
return self._handle_return(res)
def get_work_area(self, item_id):
""" Get work are information for render purposes
Args:
item_id (int):
Returns:
(namedtuple)
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_work_area',
item_id=item_id
))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def set_work_area(self, item, start, duration, frame_rate):
"""
Set work area to predefined values (from Ftrack).
Work area directs what gets rendered.
Beware of rounding, AE expects seconds, not frames directly.
Args:
item (dict):
start (float): workAreaStart in seconds
duration (float): in seconds
frame_rate (float): frames in seconds
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.set_work_area',
item_id=item.id,
start=start,
duration=duration,
frame_rate=frame_rate))
return self._handle_return(res)
def save(self):
"""
Saves active document
Returns: None
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.save'))
return self._handle_return(res)
def saveAs(self, project_path, as_copy):
"""
Saves active project to aep (copy) or png or jpg
Args:
project_path(string): full local path
as_copy: <boolean>
Returns: None
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.saveAs',
image_path=project_path,
as_copy=as_copy))
return self._handle_return(res)
def get_render_info(self):
""" Get render queue info for render purposes
Returns:
(namedtuple): with 'file_name' field
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_render_info'))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def get_audio_url(self, item_id):
""" Get audio layer absolute url for comp
Args:
item_id (int): composition id
Returns:
(str): absolute path url
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.get_audio_url',
item_id=item_id))
return self._handle_return(res)
def import_background(self, comp_id, comp_name, files):
"""
Imports backgrounds images to existing or new composition.
If comp_id is not provided, new composition is created, basic
values (width, heights, frameRatio) takes from first imported
image.
All images from background json are imported as a FootageItem and
separate layer is created for each of them under composition.
Order of imported 'files' is important.
Args:
comp_id (int): id of existing composition (null if new)
comp_name (str): used when new composition
files (list): list of absolute paths to import and
add as layers
Returns:
(AEItem): object with id of created folder, all imported images
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.import_background',
comp_id=comp_id,
comp_name=comp_name,
files=files))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def reload_background(self, comp_id, comp_name, files):
"""
Reloads backgrounds images to existing composition.
It actually deletes complete folder with imported images and
created composition for safety.
Args:
comp_id (int): id of existing composition to be overwritten
comp_name (str): new name of composition (could be same as old
if version up only)
files (list): list of absolute paths to import and
add as layers
Returns:
(AEItem): object with id of created folder, all imported images
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.reload_background',
comp_id=comp_id,
comp_name=comp_name,
files=files))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def add_item_as_layer(self, comp_id, item_id):
"""
Adds already imported FootageItem ('item_id') as a new
layer to composition ('comp_id').
Args:
comp_id (int): id of target composition
item_id (int): FootageItem.id
comp already found previously
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.add_item_as_layer',
comp_id=comp_id,
item_id=item_id))
records = self._to_records(self._handle_return(res))
if records:
return records.pop()
def render(self, folder_url):
"""
Render all renderqueueitem to 'folder_url'
Args:
folder_url(string): local folder path for collecting
Returns: None
"""
res = self.websocketserver.call(self.client.call
('AfterEffects.render',
folder_url=folder_url))
return self._handle_return(res)
def get_extension_version(self):
"""Returns version number of installed extension."""
res = self.websocketserver.call(self.client.call(
'AfterEffects.get_extension_version'))
return self._handle_return(res)
def close(self):
res = self.websocketserver.call(self.client.call('AfterEffects.close'))
return self._handle_return(res)
def _handle_return(self, res):
"""Wraps return, throws ValueError if 'error' key is present."""
if res and isinstance(res, str) and res != "undefined":
try:
parsed = json.loads(res)
except json.decoder.JSONDecodeError:
raise ValueError("Received broken JSON {}".format(res))
if not parsed: # empty list
return parsed
first_item = parsed
if isinstance(parsed, list):
first_item = parsed[0]
if first_item:
if first_item.get("error"):
raise ValueError(first_item["error"])
# singular values (file name etc)
if first_item.get("result") is not None:
return first_item["result"]
return parsed # parsed
return res
def _to_records(self, payload):
"""
Converts string json representation into list of AEItem
dot notation access to work.
Returns: <list of AEItem>
payload(dict): - dictionary from json representation, expected to
come from _handle_return
"""
if not payload:
return []
if isinstance(payload, str): # safety fallback
try:
payload = json.loads(payload)
except json.decoder.JSONDecodeError:
raise ValueError("Received broken JSON {}".format(payload))
if isinstance(payload, dict):
payload = [payload]
ret = []
# convert to AEItem to use dot donation
for d in payload:
if not d:
continue
# currently implemented and expected fields
item = AEItem(d.get('id'),
d.get('name'),
d.get('type'),
d.get('members'),
d.get('workAreaStart'),
d.get('workAreaDuration'),
d.get('frameRate'),
d.get('file_name'))
ret.append(item)
return ret

View file

@ -1,9 +1,5 @@
from openpype.hosts.aftereffects.plugins.create import create_render
import logging
log = logging.getLogger(__name__)
class CreateLocalRender(create_render.CreateRender):
""" Creator to render locally.

View file

@ -1,10 +1,10 @@
from avalon.api import CreatorError
import openpype.api
from Qt import QtWidgets
from avalon import aftereffects
import logging
log = logging.getLogger(__name__)
from openpype.hosts.aftereffects.api import (
get_stub,
list_instances
)
class CreateRender(openpype.api.Creator):
@ -21,42 +21,41 @@ class CreateRender(openpype.api.Creator):
family = "render"
def process(self):
stub = aftereffects.stub() # only after After Effects is up
stub = get_stub() # only after After Effects is up
if (self.options or {}).get("useSelection"):
items = stub.get_selected_items(comps=True,
folders=False,
footages=False)
items = stub.get_selected_items(
comps=True, 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()]
existing_subsets = [
instance['subset'].lower()
for instance in list_instances()
]
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
self.data["subset"] = self.data["subset"]\
.replace(stub.PUBLISH_ICON, '')\
self.data["subset"] = (
self.data["subset"]
.replace(stub.PUBLISH_ICON, '')
.replace(stub.LOADED_ICON, '')
)
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_()

View file

@ -1,13 +1,18 @@
import re
from avalon import api, aftereffects
import avalon.api
from openpype.lib import get_background_layers, get_unique_layer_name
stub = aftereffects.stub()
from openpype.lib import (
get_background_layers,
get_unique_layer_name
)
from openpype.hosts.aftereffects.api import (
AfterEffectsLoader,
containerise
)
class BackgroundLoader(api.Loader):
class BackgroundLoader(AfterEffectsLoader):
"""
Load images from Background family
Creates for each background separate folder with all imported images
@ -21,27 +26,30 @@ class BackgroundLoader(api.Loader):
representations = ["json"]
def load(self, context, name=None, namespace=None, data=None):
stub = self.get_stub()
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
return aftereffects.containerise(
return containerise(
name,
namespace,
comp,
@ -51,6 +59,7 @@ class BackgroundLoader(api.Loader):
def update(self, container, representation):
""" Switch asset or change version """
stub = self.get_stub()
context = representation.get("context", {})
_ = container.pop("layer")
@ -69,7 +78,7 @@ class BackgroundLoader(api.Loader):
else: # switching version - keep same name
comp_name = container["namespace"]
path = api.get_representation_path(representation)
path = avalon.api.get_representation_path(representation)
layers = get_background_layers(path)
comp = stub.reload_background(container["members"][1],
@ -92,6 +101,7 @@ class BackgroundLoader(api.Loader):
container (dict): container to be removed - used to get layer_id
"""
print("!!!! container:: {}".format(container))
stub = self.get_stub()
layer = container.pop("layer")
stub.imprint(layer, {})
stub.delete_item(layer.id)

View file

@ -1,11 +1,15 @@
from avalon import api, aftereffects
from openpype import lib
import re
stub = aftereffects.stub()
import avalon.api
from openpype import lib
from openpype.hosts.aftereffects.api import (
AfterEffectsLoader,
containerise
)
class FileLoader(api.Loader):
class FileLoader(AfterEffectsLoader):
"""Load images
Stores the imported asset in a container named after the asset.
@ -21,6 +25,7 @@ class FileLoader(api.Loader):
representations = ["*"]
def load(self, context, name=None, namespace=None, data=None):
stub = self.get_stub()
layers = stub.get_items(comps=True, folders=True, footages=True)
existing_layers = [layer.name for layer in layers]
comp_name = lib.get_unique_layer_name(
@ -60,7 +65,7 @@ class FileLoader(api.Loader):
self[:] = [comp]
namespace = namespace or comp_name
return aftereffects.containerise(
return containerise(
name,
namespace,
comp,
@ -70,6 +75,7 @@ class FileLoader(api.Loader):
def update(self, container, representation):
""" Switch asset or change version """
stub = self.get_stub()
layer = container.pop("layer")
context = representation.get("context", {})
@ -86,7 +92,7 @@ class FileLoader(api.Loader):
"{}_{}".format(context["asset"], context["subset"]))
else: # switching version - keep same name
layer_name = container["namespace"]
path = api.get_representation_path(representation)
path = avalon.api.get_representation_path(representation)
# with aftereffects.maintained_selection(): # TODO
stub.replace_item(layer.id, path, stub.LOADED_ICON + layer_name)
stub.imprint(
@ -101,6 +107,7 @@ class FileLoader(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_item(layer.id)

View file

@ -1,6 +1,6 @@
import pyblish.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class AddPublishHighlight(pyblish.api.InstancePlugin):
@ -15,7 +15,7 @@ class AddPublishHighlight(pyblish.api.InstancePlugin):
optional = True
def process(self, instance):
stub = aftereffects.stub()
stub = get_stub()
item = instance.data
# comp name contains highlight icon
stub.rename_item(item["comp_id"], item["comp_name"])

View file

@ -2,7 +2,7 @@
"""Close AE after publish. For Webpublishing only."""
import pyblish.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class CloseAE(pyblish.api.ContextPlugin):
@ -20,7 +20,7 @@ class CloseAE(pyblish.api.ContextPlugin):
def process(self, context):
self.log.info("CloseAE")
stub = aftereffects.stub()
stub = get_stub()
self.log.info("Shutting down AE")
stub.save()
stub.close()

View file

@ -2,7 +2,7 @@ import os
import pyblish.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class CollectAudio(pyblish.api.ContextPlugin):
@ -21,7 +21,8 @@ class CollectAudio(pyblish.api.ContextPlugin):
comp_id = instance.data["comp_id"]
if not comp_id:
self.log.debug("No comp_id filled in instance")
# @iLLiCiTiT QUESTION Should return or continue?
return
context.data["audioFile"] = os.path.normpath(
aftereffects.stub().get_audio_url(comp_id)
get_stub().get_audio_url(comp_id)
).replace("\\", "/")

View file

@ -2,7 +2,7 @@ import os
import pyblish.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class CollectCurrentFile(pyblish.api.ContextPlugin):
@ -14,5 +14,5 @@ class CollectCurrentFile(pyblish.api.ContextPlugin):
def process(self, context):
context.data["currentFile"] = os.path.normpath(
aftereffects.stub().get_active_document_full_name()
get_stub().get_active_document_full_name()
).replace("\\", "/")

View file

@ -2,7 +2,10 @@ import os
import re
import pyblish.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import (
get_stub,
get_extension_manifest_path
)
class CollectExtensionVersion(pyblish.api.ContextPlugin):
@ -27,13 +30,12 @@ class CollectExtensionVersion(pyblish.api.ContextPlugin):
active = True
def process(self, context):
installed_version = aftereffects.stub().get_extension_version()
installed_version = get_stub().get_extension_version()
if not installed_version:
raise ValueError("Unknown version, probably old extension")
manifest_url = os.path.join(os.path.dirname(aftereffects.__file__),
"extension", "CSXS", "manifest.xml")
manifest_url = get_extension_manifest_path()
if not os.path.exists(manifest_url):
self.log.debug("Unable to locate extension manifest, not checking")

View file

@ -1,15 +1,16 @@
import os
import re
import attr
import tempfile
import attr
from avalon import aftereffects
import pyblish.api
from openpype.settings import get_project_settings
from openpype.lib import abstract_collect_render
from openpype.lib.abstract_collect_render import RenderInstance
from openpype.hosts.aftereffects.api import get_stub
@attr.s
class AERenderInstance(RenderInstance):
@ -35,7 +36,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
padding_width = 6
rendered_extension = 'png'
stub = aftereffects.stub()
stub = get_stub()
def get_instances(self, context):
instances = []
@ -117,6 +118,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
instance.anatomyData = context.data["anatomyData"]
instance.outputDir = self._get_output_dir(instance)
instance.context = context
settings = get_project_settings(os.getenv("AVALON_PROJECT"))
reviewable_subset_filter = \
@ -141,7 +143,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
break
self.log.info("New instance:: {}".format(instance))
instances.append(instance)
return instances
@ -157,7 +158,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
in url
Returns:
(list) of absolut urls to rendered file
(list) of absolute urls to rendered file
"""
start = render_instance.frameStart
end = render_instance.frameEnd

View file

@ -1,9 +1,9 @@
import os
import six
import sys
import six
import openpype.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class ExtractLocalRender(openpype.api.Extractor):
@ -15,7 +15,7 @@ class ExtractLocalRender(openpype.api.Extractor):
families = ["render"]
def process(self, instance):
stub = aftereffects.stub()
stub = get_stub()
staging_dir = instance.data["stagingDir"]
self.log.info("staging_dir::{}".format(staging_dir))
@ -55,8 +55,7 @@ class ExtractLocalRender(openpype.api.Extractor):
ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg")
# Generate thumbnail.
thumbnail_path = os.path.join(staging_dir,
"thumbnail.jpg")
thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg")
args = [
ffmpeg_path, "-y",

View file

@ -1,5 +1,5 @@
import openpype.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class ExtractSaveScene(openpype.api.Extractor):
@ -11,5 +11,5 @@ class ExtractSaveScene(openpype.api.Extractor):
families = ["workfile"]
def process(self, instance):
stub = aftereffects.stub()
stub = get_stub()
stub.save()

View file

@ -2,7 +2,7 @@ import pyblish.api
from openpype.action import get_errored_plugins_from_data
from openpype.lib import version_up
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class IncrementWorkfile(pyblish.api.InstancePlugin):
@ -25,6 +25,6 @@ class IncrementWorkfile(pyblish.api.InstancePlugin):
)
scene_path = version_up(instance.context.data["currentFile"])
aftereffects.stub().saveAs(scene_path, True)
get_stub().saveAs(scene_path, True)
self.log.info("Incremented workfile to: {}".format(scene_path))

View file

@ -1,5 +1,5 @@
import openpype.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class RemovePublishHighlight(openpype.api.Extractor):
@ -16,7 +16,7 @@ class RemovePublishHighlight(openpype.api.Extractor):
families = ["render.farm"]
def process(self, instance):
stub = aftereffects.stub()
stub = get_stub()
self.log.debug("instance::{}".format(instance.data))
item = instance.data
comp_name = item["comp_name"].replace(stub.PUBLISH_ICON, '')

View file

@ -1,7 +1,7 @@
from avalon import api
import pyblish.api
import openpype.api
from avalon import aftereffects
from openpype.hosts.aftereffects.api import get_stub
class ValidateInstanceAssetRepair(pyblish.api.Action):
@ -22,7 +22,7 @@ class ValidateInstanceAssetRepair(pyblish.api.Action):
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
stub = aftereffects.stub()
stub = get_stub()
for instance in instances:
data = stub.read(instance[0])

View file

@ -5,11 +5,7 @@ import re
import pyblish.api
from avalon import aftereffects
import openpype.hosts.aftereffects.api as api
stub = aftereffects.stub()
from openpype.hosts.aftereffects.api import get_asset_settings
class ValidateSceneSettings(pyblish.api.InstancePlugin):
@ -47,7 +43,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
resolutionWidth
resolutionHeight
TODO support in extension is missing for now
By defaults validates duration (how many frames should be published)
"""
@ -62,7 +58,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin):
def process(self, instance):
"""Plugin entry point."""
expected_settings = api.get_asset_settings()
expected_settings = get_asset_settings()
self.log.info("config from DB::{}".format(expected_settings))
if any(re.search(pattern, os.getenv('AVALON_TASK'))

View file

@ -5,11 +5,8 @@ def add_implementation_envs(env, _app):
"""Modify environments to contain all required for implementation."""
# Prepare path to implementation script
implementation_user_script_path = os.path.join(
os.environ["OPENPYPE_REPOS_ROOT"],
"repos",
"avalon-core",
"setup",
"blender"
os.path.dirname(os.path.abspath(__file__)),
"blender_addon"
)
# Add blender implementation script path to PYTHONPATH

View file

@ -1,94 +1,64 @@
import os
import sys
import traceback
"""Public API
import bpy
Anything that isn't defined here is INTERNAL and unreliable for external use.
from .lib import append_user_scripts
"""
from avalon import api as avalon
from pyblish import api as pyblish
from .pipeline import (
install,
uninstall,
ls,
publish,
containerise,
)
import openpype.hosts.blender
from .plugin import (
Creator,
Loader,
)
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__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 .workio import (
open_file,
save_file,
current_file,
has_unsaved_changes,
file_extensions,
work_root,
)
ORIGINAL_EXCEPTHOOK = sys.excepthook
from .lib import (
lsattr,
lsattrs,
read,
maintained_selection,
get_selection,
# unique_name,
)
def pype_excepthook_handler(*args):
traceback.print_exception(*args)
__all__ = [
"install",
"uninstall",
"ls",
"publish",
"containerise",
"Creator",
"Loader",
def install():
"""Install Blender configuration for Avalon."""
sys.excepthook = pype_excepthook_handler
pyblish.register_plugin_path(str(PUBLISH_PATH))
avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH))
avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH))
append_user_scripts()
avalon.on("new", on_new)
avalon.on("open", on_open)
# Workfiles API
"open_file",
"save_file",
"current_file",
"has_unsaved_changes",
"file_extensions",
"work_root",
def uninstall():
"""Uninstall Blender configuration for Avalon."""
sys.excepthook = ORIGINAL_EXCEPTHOOK
pyblish.deregister_plugin_path(str(PUBLISH_PATH))
avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH))
avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH))
def set_start_end_frames():
from avalon import io
asset_name = io.Session["AVALON_ASSET"]
asset_doc = io.find_one({
"type": "asset",
"name": asset_name
})
scene = bpy.context.scene
# Default scene settings
frameStart = scene.frame_start
frameEnd = scene.frame_end
fps = scene.render.fps
resolution_x = scene.render.resolution_x
resolution_y = scene.render.resolution_y
# Check if settings are set
data = asset_doc.get("data")
if not data:
return
if data.get("frameStart"):
frameStart = data.get("frameStart")
if data.get("frameEnd"):
frameEnd = data.get("frameEnd")
if data.get("fps"):
fps = data.get("fps")
if data.get("resolutionWidth"):
resolution_x = data.get("resolutionWidth")
if data.get("resolutionHeight"):
resolution_y = data.get("resolutionHeight")
scene.frame_start = frameStart
scene.frame_end = frameEnd
scene.render.fps = fps
scene.render.resolution_x = resolution_x
scene.render.resolution_y = resolution_y
def on_new(arg1, arg2):
set_start_end_frames()
def on_open(arg1, arg2):
set_start_end_frames()
# Utility functions
"maintained_selection",
"lsattr",
"lsattrs",
"read",
"get_selection",
# "unique_name",
]

View file

@ -25,7 +25,7 @@ class SelectInvalidAction(pyblish.api.Action):
invalid.extend(invalid_nodes)
else:
self.log.warning(
"Failed plug-in doens't have any selectable objects."
"Failed plug-in doesn't have any selectable objects."
)
bpy.ops.object.select_all(action='DESELECT')

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

View file

@ -1,15 +1,22 @@
import os
import traceback
import importlib
import contextlib
from typing import Dict, List, Union
import bpy
import addon_utils
from openpype.api import Logger
from . import pipeline
log = Logger.get_logger(__name__)
def load_scripts(paths):
"""Copy of `load_scripts` from Blender's implementation.
It is possible that whis function will be changed in future and usage will
It is possible that this function will be changed in future and usage will
be based on Blender version.
"""
import bpy_types
@ -125,3 +132,155 @@ def append_user_scripts():
except Exception:
print("Couldn't load user scripts \"{}\"".format(user_scripts))
traceback.print_exc()
def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict):
r"""Write `data` to `node` as userDefined attributes
Arguments:
node: Long name of node
data: Dictionary of key/value pairs
Example:
>>> import bpy
>>> def compute():
... return 6
...
>>> bpy.ops.mesh.primitive_cube_add()
>>> cube = bpy.context.view_layer.objects.active
>>> imprint(cube, {
... "regularString": "myFamily",
... "computedValue": lambda: compute()
... })
...
>>> cube['avalon']['computedValue']
6
"""
imprint_data = dict()
for key, value in data.items():
if value is None:
continue
if callable(value):
# Support values evaluated at imprint
value = value()
if not isinstance(value, (int, float, bool, str, list)):
raise TypeError(f"Unsupported type: {type(value)}")
imprint_data[key] = value
pipeline.metadata_update(node, imprint_data)
def lsattr(attr: str,
value: Union[str, int, bool, List, Dict, None] = None) -> List:
r"""Return nodes matching `attr` and `value`
Arguments:
attr: Name of Blender property
value: Value of attribute. If none
is provided, return all nodes with this attribute.
Example:
>>> lsattr("id", "myId")
... [bpy.data.objects["myNode"]
>>> lsattr("id")
... [bpy.data.objects["myNode"], bpy.data.objects["myOtherNode"]]
Returns:
list
"""
return lsattrs({attr: value})
def lsattrs(attrs: Dict) -> List:
r"""Return nodes with the given attribute(s).
Arguments:
attrs: Name and value pairs of expected matches
Example:
>>> lsattrs({"age": 5}) # Return nodes with an `age` of 5
# Return nodes with both `age` and `color` of 5 and blue
>>> lsattrs({"age": 5, "color": "blue"})
Returns a list.
"""
# For now return all objects, not filtered by scene/collection/view_layer.
matches = set()
for coll in dir(bpy.data):
if not isinstance(
getattr(bpy.data, coll),
bpy.types.bpy_prop_collection,
):
continue
for node in getattr(bpy.data, coll):
for attr, value in attrs.items():
avalon_prop = node.get(pipeline.AVALON_PROPERTY)
if not avalon_prop:
continue
if (avalon_prop.get(attr)
and (value is None or avalon_prop.get(attr) == value)):
matches.add(node)
return list(matches)
def read(node: bpy.types.bpy_struct_meta_idprop):
"""Return user-defined attributes from `node`"""
data = dict(node.get(pipeline.AVALON_PROPERTY))
# Ignore hidden/internal data
data = {
key: value
for key, value in data.items() if not key.startswith("_")
}
return data
def get_selection() -> List[bpy.types.Object]:
"""Return the selected objects from the current scene."""
return [obj for obj in bpy.context.scene.objects if obj.select_get()]
@contextlib.contextmanager
def maintained_selection():
r"""Maintain selection during context
Example:
>>> with maintained_selection():
... # Modify selection
... bpy.ops.object.select_all(action='DESELECT')
>>> # Selection restored
"""
previous_selection = get_selection()
previous_active = bpy.context.view_layer.objects.active
try:
yield
finally:
# Clear the selection
for node in get_selection():
node.select_set(state=False)
if previous_selection:
for node in previous_selection:
try:
node.select_set(state=True)
except ReferenceError:
# This could happen if a selected node was deleted during
# the context.
log.exception("Failed to reselect")
continue
try:
bpy.context.view_layer.objects.active = previous_active
except ReferenceError:
# This could happen if the active node was deleted during the
# context.
log.exception("Failed to set active object.")

View file

@ -0,0 +1,410 @@
"""Blender operators and menus for use with Avalon."""
import os
import sys
import platform
import time
import traceback
import collections
from pathlib import Path
from types import ModuleType
from typing import Dict, List, Optional, Union
from Qt import QtWidgets, QtCore
import bpy
import bpy.utils.previews
import avalon.api
from openpype.tools.utils import host_tools
from openpype import style
from .workio import OpenFileCacher
PREVIEW_COLLECTIONS: Dict = dict()
# This seems like a good value to keep the Qt app responsive and doesn't slow
# down Blender. At least on macOS I the interace of Blender gets very laggy if
# you make it smaller.
TIMER_INTERVAL: float = 0.01
class BlenderApplication(QtWidgets.QApplication):
_instance = None
blender_windows = {}
def __init__(self, *args, **kwargs):
super(BlenderApplication, self).__init__(*args, **kwargs)
self.setQuitOnLastWindowClosed(False)
self.setStyleSheet(style.load_stylesheet())
self.lastWindowClosed.connect(self.__class__.reset)
@classmethod
def get_app(cls):
if cls._instance is None:
cls._instance = cls(sys.argv)
return cls._instance
@classmethod
def reset(cls):
cls._instance = None
@classmethod
def store_window(cls, identifier, window):
current_window = cls.get_window(identifier)
cls.blender_windows[identifier] = window
if current_window:
current_window.close()
# current_window.deleteLater()
@classmethod
def get_window(cls, identifier):
return cls.blender_windows.get(identifier)
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.
"""
print("Executing process in main thread")
if self.done:
print("- item is already processed")
return
callback = self.callback
args = self.args
kwargs = self.kwargs
print("Running callback: {}".format(str(callback)))
try:
result = callback(*args, **kwargs)
self.result = result
except Exception:
self.exception = sys.exc_info()
finally:
print("Done")
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:
print(self.done)
time.sleep(self.sleep_time)
if self.exception is self.not_set:
return self.result
raise self.exception
class GlobalClass:
app = None
main_thread_callbacks = collections.deque()
is_windows = platform.system().lower() == "windows"
def execute_in_main_thread(main_thead_item):
print("execute_in_main_thread")
GlobalClass.main_thread_callbacks.append(main_thead_item)
def _process_app_events() -> Optional[float]:
"""Process the events of the Qt app if the window is still visible.
If the app has any top level windows and at least one of them is visible
return the time after which this function should be run again. Else return
None, so the function is not run again and will be unregistered.
"""
while GlobalClass.main_thread_callbacks:
main_thread_item = GlobalClass.main_thread_callbacks.popleft()
main_thread_item.execute()
if main_thread_item.exception is not MainThreadItem.not_set:
_clc, val, tb = main_thread_item.exception
msg = str(val)
detail = "\n".join(traceback.format_exception(_clc, val, tb))
dialog = QtWidgets.QMessageBox(
QtWidgets.QMessageBox.Warning,
"Error",
msg)
dialog.setMinimumWidth(500)
dialog.setDetailedText(detail)
dialog.exec_()
if not GlobalClass.is_windows:
if OpenFileCacher.opening_file:
return TIMER_INTERVAL
app = GlobalClass.app
if app._instance:
app.processEvents()
return TIMER_INTERVAL
return TIMER_INTERVAL
class LaunchQtApp(bpy.types.Operator):
"""A Base class for opertors to launch a Qt app."""
_app: QtWidgets.QApplication
_window = Union[QtWidgets.QDialog, ModuleType]
_tool_name: str = None
_init_args: Optional[List] = list()
_init_kwargs: Optional[Dict] = dict()
bl_idname: str = None
def __init__(self):
if self.bl_idname is None:
raise NotImplementedError("Attribute `bl_idname` must be set!")
print(f"Initialising {self.bl_idname}...")
self._app = BlenderApplication.get_app()
GlobalClass.app = self._app
bpy.app.timers.register(
_process_app_events,
persistent=True
)
def execute(self, context):
"""Execute the operator.
The child class must implement `execute()` where it only has to set
`self._window` to the desired Qt window and then simply run
`return super().execute(context)`.
`self._window` is expected to have a `show` method.
If the `show` method requires arguments, you can set `self._show_args`
and `self._show_kwargs`. `args` should be a list, `kwargs` a
dictionary.
"""
if self._tool_name is None:
if self._window is None:
raise AttributeError("`self._window` is not set.")
else:
window = self._app.get_window(self.bl_idname)
if window is None:
window = host_tools.get_tool_by_name(self._tool_name)
self._app.store_window(self.bl_idname, window)
self._window = window
if not isinstance(
self._window,
(QtWidgets.QMainWindow, QtWidgets.QDialog, ModuleType)
):
raise AttributeError(
"`window` should be a `QDialog or module`. Got: {}".format(
str(type(window))
)
)
self.before_window_show()
if isinstance(self._window, ModuleType):
self._window.show()
window = None
if hasattr(self._window, "window"):
window = self._window.window
elif hasattr(self._window, "_window"):
window = self._window.window
if window:
self._app.store_window(self.bl_idname, window)
else:
origin_flags = self._window.windowFlags()
on_top_flags = origin_flags | QtCore.Qt.WindowStaysOnTopHint
self._window.setWindowFlags(on_top_flags)
self._window.show()
if on_top_flags != origin_flags:
self._window.setWindowFlags(origin_flags)
self._window.show()
return {'FINISHED'}
def before_window_show(self):
return
class LaunchCreator(LaunchQtApp):
"""Launch Avalon Creator."""
bl_idname = "wm.avalon_creator"
bl_label = "Create..."
_tool_name = "creator"
def before_window_show(self):
self._window.refresh()
class LaunchLoader(LaunchQtApp):
"""Launch Avalon Loader."""
bl_idname = "wm.avalon_loader"
bl_label = "Load..."
_tool_name = "loader"
def before_window_show(self):
self._window.set_context(
{"asset": avalon.api.Session["AVALON_ASSET"]},
refresh=True
)
class LaunchPublisher(LaunchQtApp):
"""Launch Avalon Publisher."""
bl_idname = "wm.avalon_publisher"
bl_label = "Publish..."
def execute(self, context):
host_tools.show_publish()
return {"FINISHED"}
class LaunchManager(LaunchQtApp):
"""Launch Avalon Manager."""
bl_idname = "wm.avalon_manager"
bl_label = "Manage..."
_tool_name = "sceneinventory"
def before_window_show(self):
self._window.refresh()
class LaunchWorkFiles(LaunchQtApp):
"""Launch Avalon Work Files."""
bl_idname = "wm.avalon_workfiles"
bl_label = "Work Files..."
_tool_name = "workfiles"
def execute(self, context):
result = super().execute(context)
self._window.set_context({
"asset": avalon.api.Session["AVALON_ASSET"],
"silo": avalon.api.Session["AVALON_SILO"],
"task": avalon.api.Session["AVALON_TASK"]
})
return result
def before_window_show(self):
self._window.root = str(Path(
os.environ.get("AVALON_WORKDIR", ""),
os.environ.get("AVALON_SCENEDIR", ""),
))
self._window.refresh()
class TOPBAR_MT_avalon(bpy.types.Menu):
"""Avalon menu."""
bl_idname = "TOPBAR_MT_avalon"
bl_label = os.environ.get("AVALON_LABEL")
def draw(self, context):
"""Draw the menu in the UI."""
layout = self.layout
pcoll = PREVIEW_COLLECTIONS.get("avalon")
if pcoll:
pyblish_menu_icon = pcoll["pyblish_menu_icon"]
pyblish_menu_icon_id = pyblish_menu_icon.icon_id
else:
pyblish_menu_icon_id = 0
asset = avalon.api.Session['AVALON_ASSET']
task = avalon.api.Session['AVALON_TASK']
context_label = f"{asset}, {task}"
context_label_item = layout.row()
context_label_item.operator(
LaunchWorkFiles.bl_idname, text=context_label
)
context_label_item.enabled = False
layout.separator()
layout.operator(LaunchCreator.bl_idname, text="Create...")
layout.operator(LaunchLoader.bl_idname, text="Load...")
layout.operator(
LaunchPublisher.bl_idname,
text="Publish...",
icon_value=pyblish_menu_icon_id,
)
layout.operator(LaunchManager.bl_idname, text="Manage...")
layout.separator()
layout.operator(LaunchWorkFiles.bl_idname, text="Work Files...")
# TODO (jasper): maybe add 'Reload Pipeline', 'Reset Frame Range' and
# 'Reset Resolution'?
def draw_avalon_menu(self, context):
"""Draw the Avalon menu in the top bar."""
self.layout.menu(TOPBAR_MT_avalon.bl_idname)
classes = [
LaunchCreator,
LaunchLoader,
LaunchPublisher,
LaunchManager,
LaunchWorkFiles,
TOPBAR_MT_avalon,
]
def register():
"Register the operators and menu."
pcoll = bpy.utils.previews.new()
pyblish_icon_file = Path(__file__).parent / "icons" / "pyblish-32x32.png"
pcoll.load("pyblish_menu_icon", str(pyblish_icon_file.absolute()), 'IMAGE')
PREVIEW_COLLECTIONS["avalon"] = pcoll
for cls in classes:
bpy.utils.register_class(cls)
bpy.types.TOPBAR_MT_editor_menus.append(draw_avalon_menu)
def unregister():
"""Unregister the operators and menu."""
pcoll = PREVIEW_COLLECTIONS.pop("avalon")
bpy.utils.previews.remove(pcoll)
bpy.types.TOPBAR_MT_editor_menus.remove(draw_avalon_menu)
for cls in reversed(classes):
bpy.utils.unregister_class(cls)

View file

@ -0,0 +1,427 @@
import os
import sys
import importlib
import traceback
from typing import Callable, Dict, Iterator, List, Optional
import bpy
from . import lib
from . import ops
import pyblish.api
import avalon.api
from avalon import io, schema
from avalon.pipeline import AVALON_CONTAINER_ID
from openpype.api import Logger
import openpype.hosts.blender
HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.blender.__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")
ORIGINAL_EXCEPTHOOK = sys.excepthook
AVALON_INSTANCES = "AVALON_INSTANCES"
AVALON_CONTAINERS = "AVALON_CONTAINERS"
AVALON_PROPERTY = 'avalon'
IS_HEADLESS = bpy.app.background
log = Logger.get_logger(__name__)
def pype_excepthook_handler(*args):
traceback.print_exception(*args)
def install():
"""Install Blender configuration for Avalon."""
sys.excepthook = pype_excepthook_handler
pyblish.api.register_host("blender")
pyblish.api.register_plugin_path(str(PUBLISH_PATH))
avalon.api.register_plugin_path(avalon.api.Loader, str(LOAD_PATH))
avalon.api.register_plugin_path(avalon.api.Creator, str(CREATE_PATH))
lib.append_user_scripts()
avalon.api.on("new", on_new)
avalon.api.on("open", on_open)
_register_callbacks()
_register_events()
if not IS_HEADLESS:
ops.register()
def uninstall():
"""Uninstall Blender configuration for Avalon."""
sys.excepthook = ORIGINAL_EXCEPTHOOK
pyblish.api.deregister_host("blender")
pyblish.api.deregister_plugin_path(str(PUBLISH_PATH))
avalon.api.deregister_plugin_path(avalon.api.Loader, str(LOAD_PATH))
avalon.api.deregister_plugin_path(avalon.api.Creator, str(CREATE_PATH))
if not IS_HEADLESS:
ops.unregister()
def set_start_end_frames():
asset_name = io.Session["AVALON_ASSET"]
asset_doc = io.find_one({
"type": "asset",
"name": asset_name
})
scene = bpy.context.scene
# Default scene settings
frameStart = scene.frame_start
frameEnd = scene.frame_end
fps = scene.render.fps
resolution_x = scene.render.resolution_x
resolution_y = scene.render.resolution_y
# Check if settings are set
data = asset_doc.get("data")
if not data:
return
if data.get("frameStart"):
frameStart = data.get("frameStart")
if data.get("frameEnd"):
frameEnd = data.get("frameEnd")
if data.get("fps"):
fps = data.get("fps")
if data.get("resolutionWidth"):
resolution_x = data.get("resolutionWidth")
if data.get("resolutionHeight"):
resolution_y = data.get("resolutionHeight")
scene.frame_start = frameStart
scene.frame_end = frameEnd
scene.render.fps = fps
scene.render.resolution_x = resolution_x
scene.render.resolution_y = resolution_y
def on_new(arg1, arg2):
set_start_end_frames()
def on_open(arg1, arg2):
set_start_end_frames()
@bpy.app.handlers.persistent
def _on_save_pre(*args):
avalon.api.emit("before_save", args)
@bpy.app.handlers.persistent
def _on_save_post(*args):
avalon.api.emit("save", args)
@bpy.app.handlers.persistent
def _on_load_post(*args):
# Detect new file or opening an existing file
if bpy.data.filepath:
# Likely this was an open operation since it has a filepath
avalon.api.emit("open", args)
else:
avalon.api.emit("new", args)
ops.OpenFileCacher.post_load()
def _register_callbacks():
"""Register callbacks for certain events."""
def _remove_handler(handlers: List, callback: Callable):
"""Remove the callback from the given handler list."""
try:
handlers.remove(callback)
except ValueError:
pass
# TODO (jasper): implement on_init callback?
# Be sure to remove existig ones first.
_remove_handler(bpy.app.handlers.save_pre, _on_save_pre)
_remove_handler(bpy.app.handlers.save_post, _on_save_post)
_remove_handler(bpy.app.handlers.load_post, _on_load_post)
bpy.app.handlers.save_pre.append(_on_save_pre)
bpy.app.handlers.save_post.append(_on_save_post)
bpy.app.handlers.load_post.append(_on_load_post)
log.info("Installed event handler _on_save_pre...")
log.info("Installed event handler _on_save_post...")
log.info("Installed event handler _on_load_post...")
def _on_task_changed(*args):
"""Callback for when the task in the context is changed."""
# TODO (jasper): Blender has no concept of projects or workspace.
# It would be nice to override 'bpy.ops.wm.open_mainfile' so it takes the
# workdir as starting directory. But I don't know if that is possible.
# Another option would be to create a custom 'File Selector' and add the
# `directory` attribute, so it opens in that directory (does it?).
# https://docs.blender.org/api/blender2.8/bpy.types.Operator.html#calling-a-file-selector
# https://docs.blender.org/api/blender2.8/bpy.types.WindowManager.html#bpy.types.WindowManager.fileselect_add
workdir = avalon.api.Session["AVALON_WORKDIR"]
log.debug("New working directory: %s", workdir)
def _register_events():
"""Install callbacks for specific events."""
avalon.api.on("taskChanged", _on_task_changed)
log.info("Installed event callback for 'taskChanged'...")
def reload_pipeline(*args):
"""Attempt to reload pipeline at run-time.
Warning:
This is primarily for development and debugging purposes and not well
tested.
"""
avalon.api.uninstall()
for module in (
"avalon.io",
"avalon.lib",
"avalon.pipeline",
"avalon.tools.creator.app",
"avalon.tools.manager.app",
"avalon.api",
"avalon.tools",
):
module = importlib.import_module(module)
importlib.reload(module)
def _discover_gui() -> Optional[Callable]:
"""Return the most desirable of the currently registered GUIs"""
# Prefer last registered
guis = reversed(pyblish.api.registered_guis())
for gui in guis:
try:
gui = __import__(gui).show
except (ImportError, AttributeError):
continue
else:
return gui
return None
def add_to_avalon_container(container: bpy.types.Collection):
"""Add the container to the Avalon container."""
avalon_container = bpy.data.collections.get(AVALON_CONTAINERS)
if not avalon_container:
avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS)
# Link the container to the scene so it's easily visible to the artist
# and can be managed easily. Otherwise it's only found in "Blender
# File" view and it will be removed by Blenders garbage collection,
# unless you set a 'fake user'.
bpy.context.scene.collection.children.link(avalon_container)
avalon_container.children.link(container)
# Disable Avalon containers for the view layers.
for view_layer in bpy.context.scene.view_layers:
for child in view_layer.layer_collection.children:
if child.collection == avalon_container:
child.exclude = True
def metadata_update(node: bpy.types.bpy_struct_meta_idprop, data: Dict):
"""Imprint the node with metadata.
Existing metadata will be updated.
"""
if not node.get(AVALON_PROPERTY):
node[AVALON_PROPERTY] = dict()
for key, value in data.items():
if value is None:
continue
node[AVALON_PROPERTY][key] = value
def containerise(name: str,
namespace: str,
nodes: List,
context: Dict,
loader: Optional[str] = None,
suffix: Optional[str] = "CON") -> bpy.types.Collection:
"""Bundle `nodes` into an assembly and imprint it with metadata
Containerisation enables a tracking of version, author and origin
for loaded assets.
Arguments:
name: Name of resulting assembly
namespace: Namespace under which to host container
nodes: Long names of nodes to containerise
context: Asset information
loader: Name of loader used to produce this container.
suffix: Suffix of container, defaults to `_CON`.
Returns:
The container assembly
"""
node_name = f"{context['asset']['name']}_{name}"
if namespace:
node_name = f"{namespace}:{node_name}"
if suffix:
node_name = f"{node_name}_{suffix}"
container = bpy.data.collections.new(name=node_name)
# Link the children nodes
for obj in nodes:
container.objects.link(obj)
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
}
metadata_update(container, data)
add_to_avalon_container(container)
return container
def containerise_existing(
container: bpy.types.Collection,
name: str,
namespace: str,
context: Dict,
loader: Optional[str] = None,
suffix: Optional[str] = "CON") -> bpy.types.Collection:
"""Imprint or update container with metadata.
Arguments:
name: Name of resulting assembly
namespace: Namespace under which to host container
context: Asset information
loader: Name of loader used to produce this container.
suffix: Suffix of container, defaults to `_CON`.
Returns:
The container assembly
"""
node_name = container.name
if suffix:
node_name = f"{node_name}_{suffix}"
container.name = node_name
data = {
"schema": "openpype:container-2.0",
"id": AVALON_CONTAINER_ID,
"name": name,
"namespace": namespace or '',
"loader": str(loader),
"representation": str(context["representation"]["_id"]),
}
metadata_update(container, data)
add_to_avalon_container(container)
return container
def parse_container(container: bpy.types.Collection,
validate: bool = True) -> Dict:
"""Return the container node's full container data.
Args:
container: A container node name.
validate: turn the validation for the container on or off
Returns:
The container schema data for this container node.
"""
data = lib.read(container)
# Append transient data
data["objectName"] = container.name
if validate:
schema.validate(data)
return data
def ls() -> Iterator:
"""List containers from active Blender scene.
This is the host-equivalent of api.ls(), but instead of listing assets on
disk, it lists assets already loaded in Blender; once loaded they are
called containers.
"""
for container in lib.lsattr("id", AVALON_CONTAINER_ID):
yield parse_container(container)
def update_hierarchy(containers):
"""Hierarchical container support
This is the function to support Scene Inventory to draw hierarchical
view for containers.
We need both parent and children to visualize the graph.
"""
all_containers = set(ls()) # lookup set
for container in containers:
# Find parent
# FIXME (jasperge): re-evaluate this. How would it be possible
# to 'nest' assets? Collections can have several parents, for
# now assume it has only 1 parent
parent = [
coll for coll in bpy.data.collections if container in coll.children
]
for node in parent:
if node in all_containers:
container["parent"] = node
break
log.debug("Container: %s", container)
yield container
def publish():
"""Shorthand to publish from within host."""
return pyblish.util.publish()

View file

@ -5,10 +5,17 @@ from typing import Dict, List, Optional
import bpy
from avalon import api, blender
from avalon.blender import ops
from avalon.blender.pipeline import AVALON_CONTAINERS
import avalon.api
from openpype.api import PypeCreatorMixin
from .pipeline import AVALON_CONTAINERS
from .ops import (
MainThreadItem,
execute_in_main_thread
)
from .lib import (
imprint,
get_selection
)
VALID_EXTENSIONS = [".blend", ".json", ".abc", ".fbx"]
@ -42,10 +49,13 @@ def get_unique_number(
return f"{count:0>2}"
def prepare_data(data, container_name):
def prepare_data(data, container_name=None):
name = data.name
local_data = data.make_local()
local_data.name = f"{container_name}:{name}"
if container_name:
local_data.name = f"{container_name}:{name}"
else:
local_data.name = f"{name}"
return local_data
@ -119,11 +129,27 @@ def deselect_all():
bpy.context.view_layer.objects.active = active
class Creator(PypeCreatorMixin, blender.Creator):
pass
class Creator(PypeCreatorMixin, avalon.api.Creator):
"""Base class for Creator plug-ins."""
def process(self):
collection = bpy.data.collections.new(name=self.data["subset"])
bpy.context.scene.collection.children.link(collection)
imprint(collection, self.data)
if (self.options or {}).get("useSelection"):
for obj in get_selection():
collection.objects.link(obj)
return collection
class AssetLoader(api.Loader):
class Loader(avalon.api.Loader):
"""Base class for Loader plug-ins."""
hosts = ["blender"]
class AssetLoader(avalon.api.Loader):
"""A basic AssetLoader for Blender
This will implement the basic logic for linking/appending assets
@ -191,8 +217,8 @@ class AssetLoader(api.Loader):
namespace: Optional[str] = None,
options: Optional[Dict] = None) -> Optional[bpy.types.Collection]:
""" Run the loader on Blender main thread"""
mti = ops.MainThreadItem(self._load, context, name, namespace, options)
ops.execute_in_main_thread(mti)
mti = MainThreadItem(self._load, context, name, namespace, options)
execute_in_main_thread(mti)
def _load(self,
context: dict,
@ -257,8 +283,8 @@ class AssetLoader(api.Loader):
def update(self, container: Dict, representation: Dict):
""" Run the update on Blender main thread"""
mti = ops.MainThreadItem(self.exec_update, container, representation)
ops.execute_in_main_thread(mti)
mti = MainThreadItem(self.exec_update, container, representation)
execute_in_main_thread(mti)
def exec_remove(self, container: Dict) -> bool:
"""Must be implemented by a sub-class"""
@ -266,5 +292,5 @@ class AssetLoader(api.Loader):
def remove(self, container: Dict) -> bool:
""" Run the remove on Blender main thread"""
mti = ops.MainThreadItem(self.exec_remove, container)
ops.execute_in_main_thread(mti)
mti = MainThreadItem(self.exec_remove, container)
execute_in_main_thread(mti)

View file

@ -0,0 +1,90 @@
"""Host API required for Work Files."""
from pathlib import Path
from typing import List, Optional
import bpy
from avalon import api
class OpenFileCacher:
"""Store information about opening file.
When file is opening QApplcation events should not be processed.
"""
opening_file = False
@classmethod
def post_load(cls):
cls.opening_file = False
@classmethod
def set_opening(cls):
cls.opening_file = True
def open_file(filepath: str) -> Optional[str]:
"""Open the scene file in Blender."""
OpenFileCacher.set_opening()
preferences = bpy.context.preferences
load_ui = preferences.filepaths.use_load_ui
use_scripts = preferences.filepaths.use_scripts_auto_execute
result = bpy.ops.wm.open_mainfile(
filepath=filepath,
load_ui=load_ui,
use_scripts=use_scripts,
)
if result == {'FINISHED'}:
return filepath
return None
def save_file(filepath: str, copy: bool = False) -> Optional[str]:
"""Save the open scene file."""
preferences = bpy.context.preferences
compress = preferences.filepaths.use_file_compression
relative_remap = preferences.filepaths.use_relative_paths
result = bpy.ops.wm.save_as_mainfile(
filepath=filepath,
compress=compress,
relative_remap=relative_remap,
copy=copy,
)
if result == {'FINISHED'}:
return filepath
return None
def current_file() -> Optional[str]:
"""Return the path of the open scene file."""
current_filepath = bpy.data.filepath
if Path(current_filepath).is_file():
return current_filepath
return None
def has_unsaved_changes() -> bool:
"""Does the open scene file have unsaved changes?"""
return bpy.data.is_dirty
def file_extensions() -> List[str]:
"""Return the supported file extensions for Blender scene files."""
return api.HOST_WORKFILE_EXTENSIONS["blender"]
def work_root(session: dict) -> str:
"""Return the default root to browse for work files."""
work_dir = session["AVALON_WORKDIR"]
scene_dir = session.get("AVALON_SCENEDIR")
if scene_dir:
return str(Path(work_dir, scene_dir))
return work_dir

View file

@ -0,0 +1,4 @@
from avalon import pipeline
from openpype.hosts.blender import api
pipeline.install(api)

View file

@ -21,7 +21,7 @@ class InstallPySideToBlender(PreLaunchHook):
platforms = ["windows"]
def execute(self):
# Prelaunch hook is not crutial
# Prelaunch hook is not crucial
try:
self.inner_execute()
except Exception:
@ -156,7 +156,7 @@ class InstallPySideToBlender(PreLaunchHook):
except pywintypes.error:
pass
self.log.warning("Failed to instal PySide2 module to blender.")
self.log.warning("Failed to install PySide2 module to blender.")
def is_pyside_installed(self, python_executable):
"""Check if PySide2 module is in blender's pip list.

View file

@ -4,7 +4,7 @@ import bpy
from avalon import api
import openpype.hosts.blender.api.plugin
from avalon.blender import lib
from openpype.hosts.blender.api import lib
class CreateAction(openpype.hosts.blender.api.plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateAnimation(plugin.Creator):
@ -22,7 +21,7 @@ class CreateAnimation(plugin.Creator):
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Containter or create it if it does not exist
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateCamera(plugin.Creator):
@ -22,7 +21,7 @@ class CreateCamera(plugin.Creator):
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Containter or create it if it does not exist
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateLayout(plugin.Creator):
@ -22,7 +21,7 @@ class CreateLayout(plugin.Creator):
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Containter or create it if it does not exist
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateModel(plugin.Creator):
@ -22,7 +21,7 @@ class CreateModel(plugin.Creator):
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Containter or create it if it does not exist
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)

View file

@ -3,8 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib
import openpype.hosts.blender.api.plugin
from openpype.hosts.blender.api import lib
class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):

View file

@ -3,9 +3,8 @@
import bpy
from avalon import api
from avalon.blender import lib, ops
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib, ops
from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES
class CreateRig(plugin.Creator):
@ -22,7 +21,7 @@ class CreateRig(plugin.Creator):
ops.execute_in_main_thread(mti)
def _process(self):
# Get Instance Containter or create it if it does not exist
# Get Instance Container or create it if it does not exist
instances = bpy.data.collections.get(AVALON_INSTANCES)
if not instances:
instances = bpy.data.collections.new(name=AVALON_INSTANCES)

View file

@ -7,11 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
from openpype.hosts.blender.api import plugin, lib
class CacheModelLoader(plugin.AssetLoader):

View file

@ -1,16 +1,11 @@
"""Load an animation in Blender."""
import logging
from typing import Dict, List, Optional
import bpy
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
logger = logging.getLogger("openpype").getChild(
"blender").getChild("load_animation")
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class BlendAnimationLoader(plugin.AssetLoader):

View file

@ -7,10 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class AudioLoader(plugin.AssetLoader):

View file

@ -8,10 +8,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
logger = logging.getLogger("openpype").getChild(
"blender").getChild("load_camera")

View file

@ -7,11 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class FbxCameraLoader(plugin.AssetLoader):

View file

@ -7,11 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender import lib
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api import plugin, lib
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class FbxModelLoader(plugin.AssetLoader):

View file

@ -7,10 +7,13 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype import lib
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class BlendLayoutLoader(plugin.AssetLoader):
@ -59,7 +62,9 @@ class BlendLayoutLoader(plugin.AssetLoader):
library = bpy.data.libraries.get(bpy.path.basename(libpath))
bpy.data.libraries.remove(library)
def _process(self, libpath, asset_group, group_name, actions):
def _process(
self, libpath, asset_group, group_name, asset, representation, actions
):
with bpy.data.libraries.load(
libpath, link=True, relative=False
) as (data_from, data_to):
@ -72,7 +77,8 @@ class BlendLayoutLoader(plugin.AssetLoader):
container = None
for empty in empties:
if empty.get(AVALON_PROPERTY):
if (empty.get(AVALON_PROPERTY) and
empty.get(AVALON_PROPERTY).get('family') == 'layout'):
container = empty
break
@ -83,12 +89,16 @@ class BlendLayoutLoader(plugin.AssetLoader):
objects = []
nodes = list(container.children)
for obj in nodes:
obj.parent = asset_group
allowed_types = ['ARMATURE', 'MESH', 'EMPTY']
for obj in nodes:
objects.append(obj)
nodes.extend(list(obj.children))
if obj.type in allowed_types:
obj.parent = asset_group
for obj in nodes:
if obj.type in allowed_types:
objects.append(obj)
nodes.extend(list(obj.children))
objects.reverse()
@ -106,7 +116,7 @@ class BlendLayoutLoader(plugin.AssetLoader):
parent.objects.link(obj)
for obj in objects:
local_obj = plugin.prepare_data(obj, group_name)
local_obj = plugin.prepare_data(obj)
action = None
@ -114,7 +124,7 @@ class BlendLayoutLoader(plugin.AssetLoader):
action = actions.get(local_obj.name, None)
if local_obj.type == 'MESH':
plugin.prepare_data(local_obj.data, group_name)
plugin.prepare_data(local_obj.data)
if obj != local_obj:
for constraint in constraints:
@ -123,15 +133,18 @@ class BlendLayoutLoader(plugin.AssetLoader):
for material_slot in local_obj.material_slots:
if material_slot.material:
plugin.prepare_data(material_slot.material, group_name)
plugin.prepare_data(material_slot.material)
elif local_obj.type == 'ARMATURE':
plugin.prepare_data(local_obj.data, group_name)
plugin.prepare_data(local_obj.data)
if action is not None:
if local_obj.animation_data is None:
local_obj.animation_data_create()
local_obj.animation_data.action = action
elif local_obj.animation_data.action is not None:
elif (local_obj.animation_data and
local_obj.animation_data.action is not None):
plugin.prepare_data(
local_obj.animation_data.action, group_name)
local_obj.animation_data.action)
# Set link the drivers to the local object
if local_obj.data.animation_data:
@ -140,6 +153,21 @@ class BlendLayoutLoader(plugin.AssetLoader):
for t in v.targets:
t.id = local_obj
elif local_obj.type == 'EMPTY':
creator_plugin = lib.get_creator_by_name("CreateAnimation")
if not creator_plugin:
raise ValueError("Creator plugin \"CreateAnimation\" was "
"not found.")
api.create(
creator_plugin,
name=local_obj.name.split(':')[-1] + "_animation",
asset=asset,
options={"useSelection": False,
"asset_group": local_obj},
data={"dependencies": representation}
)
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
@ -148,7 +176,63 @@ class BlendLayoutLoader(plugin.AssetLoader):
objects.reverse()
bpy.data.orphans_purge(do_local_ids=False)
armatures = [
obj for obj in bpy.data.objects
if obj.type == 'ARMATURE' and obj.library is None]
arm_act = {}
# The armatures with an animation need to be at the center of the
# scene to be hooked correctly by the curves modifiers.
for armature in armatures:
if armature.animation_data and armature.animation_data.action:
arm_act[armature] = armature.animation_data.action
armature.animation_data.action = None
armature.location = (0.0, 0.0, 0.0)
for bone in armature.pose.bones:
bone.location = (0.0, 0.0, 0.0)
bone.rotation_euler = (0.0, 0.0, 0.0)
curves = [obj for obj in data_to.objects if obj.type == 'CURVE']
for curve in curves:
curve_name = curve.name.split(':')[0]
curve_obj = bpy.data.objects.get(curve_name)
local_obj = plugin.prepare_data(curve)
plugin.prepare_data(local_obj.data)
# Curves need to reset the hook, but to do that they need to be
# in the view layer.
parent.objects.link(local_obj)
plugin.deselect_all()
local_obj.select_set(True)
bpy.context.view_layer.objects.active = local_obj
if local_obj.library is None:
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.object.hook_reset()
bpy.ops.object.mode_set(mode='OBJECT')
parent.objects.unlink(local_obj)
local_obj.use_fake_user = True
for mod in local_obj.modifiers:
mod.object = bpy.data.objects.get(f"{mod.object.name}")
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
avalon_info = local_obj[AVALON_PROPERTY]
avalon_info.update({"container_name": group_name})
local_obj.parent = curve_obj
objects.append(local_obj)
for armature in armatures:
if arm_act.get(armature):
armature.animation_data.action = arm_act[armature]
while bpy.data.orphans_purge(do_local_ids=False):
pass
plugin.deselect_all()
@ -168,6 +252,7 @@ class BlendLayoutLoader(plugin.AssetLoader):
libpath = self.fname
asset = context["asset"]["name"]
subset = context["subset"]["name"]
representation = str(context["representation"]["_id"])
asset_name = plugin.asset_name(asset, subset)
unique_number = plugin.get_unique_number(asset, subset)
@ -183,7 +268,8 @@ class BlendLayoutLoader(plugin.AssetLoader):
asset_group.empty_display_type = 'SINGLE_ARROW'
avalon_container.objects.link(asset_group)
objects = self._process(libpath, asset_group, group_name, None)
objects = self._process(
libpath, asset_group, group_name, asset, representation, None)
for child in asset_group.children:
if child.get(AVALON_PROPERTY):

View file

@ -1,18 +1,20 @@
"""Load a layout in Blender."""
import json
from pathlib import Path
from pprint import pformat
from typing import Dict, Optional
import bpy
import json
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype import lib
from openpype.hosts.blender.api.pipeline import (
AVALON_INSTANCES,
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
from openpype.hosts.blender.api import plugin
@ -92,6 +94,10 @@ class JsonLayoutLoader(plugin.AssetLoader):
'animation_asset': asset
}
if element.get('animation'):
options['animation_file'] = str(Path(libpath).with_suffix(
'')) + "." + element.get('animation')
# This should return the loaded asset, but the load call will be
# added to the queue to run in the Blender main thread, so
# at this time it will not return anything. The assets will be
@ -104,20 +110,22 @@ class JsonLayoutLoader(plugin.AssetLoader):
options=options
)
# Create the camera asset and the camera instance
creator_plugin = lib.get_creator_by_name("CreateCamera")
if not creator_plugin:
raise ValueError("Creator plugin \"CreateCamera\" was "
"not found.")
# Camera creation when loading a layout is not necessary for now,
# but the code is worth keeping in case we need it in the future.
# # Create the camera asset and the camera instance
# creator_plugin = lib.get_creator_by_name("CreateCamera")
# if not creator_plugin:
# raise ValueError("Creator plugin \"CreateCamera\" was "
# "not found.")
api.create(
creator_plugin,
name="camera",
# name=f"{unique_number}_{subset}_animation",
asset=asset,
options={"useSelection": False}
# data={"dependencies": str(context["representation"]["_id"])}
)
# api.create(
# creator_plugin,
# name="camera",
# # name=f"{unique_number}_{subset}_animation",
# asset=asset,
# options={"useSelection": False}
# # data={"dependencies": str(context["representation"]["_id"])}
# )
def process_asset(self,
context: dict,

View file

@ -8,8 +8,12 @@ import os
import json
import bpy
from avalon import api, blender
import openpype.hosts.blender.api.plugin as plugin
from avalon import api
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
containerise_existing,
AVALON_PROPERTY
)
class BlendLookLoader(plugin.AssetLoader):
@ -105,7 +109,7 @@ class BlendLookLoader(plugin.AssetLoader):
container = bpy.data.collections.new(lib_container)
container.name = container_name
blender.pipeline.containerise_existing(
containerise_existing(
container,
name,
namespace,
@ -113,7 +117,7 @@ class BlendLookLoader(plugin.AssetLoader):
self.__class__.__name__,
)
metadata = container.get(blender.pipeline.AVALON_PROPERTY)
metadata = container.get(AVALON_PROPERTY)
metadata["libpath"] = libpath
metadata["lib_container"] = lib_container
@ -161,7 +165,7 @@ class BlendLookLoader(plugin.AssetLoader):
f"Unsupported file: {libpath}"
)
collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY)
collection_metadata = collection.get(AVALON_PROPERTY)
collection_libpath = collection_metadata["libpath"]
normalized_collection_libpath = (
@ -204,7 +208,7 @@ class BlendLookLoader(plugin.AssetLoader):
if not collection:
return False
collection_metadata = collection.get(blender.pipeline.AVALON_PROPERTY)
collection_metadata = collection.get(AVALON_PROPERTY)
for obj in collection_metadata['objects']:
for child in self.get_all_children(obj):

View file

@ -7,10 +7,12 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class BlendModelLoader(plugin.AssetLoader):
@ -81,7 +83,8 @@ class BlendModelLoader(plugin.AssetLoader):
plugin.prepare_data(local_obj.data, group_name)
for material_slot in local_obj.material_slots:
plugin.prepare_data(material_slot.material, group_name)
if material_slot.material:
plugin.prepare_data(material_slot.material, group_name)
if not local_obj.get(AVALON_PROPERTY):
local_obj[AVALON_PROPERTY] = dict()
@ -245,7 +248,8 @@ class BlendModelLoader(plugin.AssetLoader):
# If it is the last object to use that library, remove it
if count == 1:
library = bpy.data.libraries.get(bpy.path.basename(group_libpath))
bpy.data.libraries.remove(library)
if library:
bpy.data.libraries.remove(library)
self._process(str(libpath), asset_group, object_name)
@ -253,6 +257,7 @@ class BlendModelLoader(plugin.AssetLoader):
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
metadata["parent"] = str(representation["parent"])
def exec_remove(self, container: Dict) -> bool:
"""Remove an existing container from a Blender scene.

View file

@ -7,11 +7,14 @@ from typing import Dict, List, Optional
import bpy
from avalon import api
from avalon.blender.pipeline import AVALON_CONTAINERS
from avalon.blender.pipeline import AVALON_CONTAINER_ID
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender import lib as avalon_lib
from openpype import lib
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import (
AVALON_CONTAINERS,
AVALON_PROPERTY,
AVALON_CONTAINER_ID
)
class BlendRigLoader(plugin.AssetLoader):
@ -110,6 +113,8 @@ class BlendRigLoader(plugin.AssetLoader):
plugin.prepare_data(local_obj.data, group_name)
if action is not None:
if local_obj.animation_data is None:
local_obj.animation_data_create()
local_obj.animation_data.action = action
elif (local_obj.animation_data and
local_obj.animation_data.action is not None):
@ -194,12 +199,14 @@ class BlendRigLoader(plugin.AssetLoader):
plugin.deselect_all()
create_animation = False
anim_file = None
if options is not None:
parent = options.get('parent')
transform = options.get('transform')
action = options.get('action')
create_animation = options.get('create_animation')
anim_file = options.get('animation_file')
if parent and transform:
location = transform.get('translation')
@ -252,6 +259,26 @@ class BlendRigLoader(plugin.AssetLoader):
plugin.deselect_all()
if anim_file:
bpy.ops.import_scene.fbx(filepath=anim_file, anim_offset=0.0)
imported = avalon_lib.get_selection()
armature = [
o for o in asset_group.children if o.type == 'ARMATURE'][0]
imported_group = [
o for o in imported if o.type == 'EMPTY'][0]
for obj in imported:
if obj.type == 'ARMATURE':
if not armature.animation_data:
armature.animation_data_create()
armature.animation_data.action = obj.animation_data.action
self._remove(imported_group)
bpy.data.objects.remove(imported_group)
bpy.context.scene.collection.objects.link(asset_group)
asset_group[AVALON_PROPERTY] = {
@ -348,6 +375,7 @@ class BlendRigLoader(plugin.AssetLoader):
metadata["libpath"] = str(libpath)
metadata["representation"] = str(representation["_id"])
metadata["parent"] = str(representation["parent"])
def exec_remove(self, container: Dict) -> bool:
"""Remove an existing asset group from a Blender scene.

View file

@ -1,11 +1,13 @@
import json
from typing import Generator
import bpy
import json
import pyblish.api
from avalon.blender.pipeline import AVALON_PROPERTY
from avalon.blender.pipeline import AVALON_INSTANCES
from openpype.hosts.blender.api.pipeline import (
AVALON_INSTANCES,
AVALON_PROPERTY,
)
class CollectInstances(pyblish.api.ContextPlugin):

View file

@ -1,10 +1,10 @@
import os
import bpy
from openpype import api
from openpype.hosts.blender.api import plugin
from avalon.blender.pipeline import AVALON_PROPERTY
import bpy
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class ExtractABC(api.Extractor):

View file

@ -2,7 +2,6 @@ import os
import bpy
# import avalon.blender.workio
import openpype.api

View file

@ -29,12 +29,13 @@ class ExtractBlendAnimation(openpype.api.Extractor):
if isinstance(obj, bpy.types.Object) and obj.type == 'EMPTY':
child = obj.children[0]
if child and child.type == 'ARMATURE':
if not obj.animation_data:
obj.animation_data_create()
obj.animation_data.action = child.animation_data.action
obj.animation_data_clear()
data_blocks.add(child.animation_data.action)
data_blocks.add(obj)
if child.animation_data and child.animation_data.action:
if not obj.animation_data:
obj.animation_data_create()
obj.animation_data.action = child.animation_data.action
obj.animation_data_clear()
data_blocks.add(child.animation_data.action)
data_blocks.add(obj)
bpy.data.libraries.write(filepath, data_blocks)

View file

@ -1,10 +1,10 @@
import os
import bpy
from openpype import api
from openpype.hosts.blender.api import plugin
import bpy
class ExtractCamera(api.Extractor):
"""Extract as the camera as FBX."""

View file

@ -1,10 +1,10 @@
import os
import bpy
from openpype import api
from openpype.hosts.blender.api import plugin
from avalon.blender.pipeline import AVALON_PROPERTY
import bpy
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class ExtractFBX(api.Extractor):
@ -50,6 +50,9 @@ class ExtractFBX(api.Extractor):
new_materials.append(mat)
new_materials_objs.append(obj)
scale_length = bpy.context.scene.unit_settings.scale_length
bpy.context.scene.unit_settings.scale_length = 0.01
# We export the fbx
bpy.ops.export_scene.fbx(
context,
@ -60,6 +63,8 @@ class ExtractFBX(api.Extractor):
add_leaf_bones=False
)
bpy.context.scene.unit_settings.scale_length = scale_length
plugin.deselect_all()
for mat in new_materials:

View file

@ -7,7 +7,7 @@ import bpy_extras.anim_utils
from openpype import api
from openpype.hosts.blender.api import plugin
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
class ExtractAnimationFBX(api.Extractor):
@ -37,13 +37,6 @@ class ExtractAnimationFBX(api.Extractor):
armature = [
obj for obj in asset_group.children if obj.type == 'ARMATURE'][0]
asset_group_name = asset_group.name
asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name")
armature_name = armature.name
original_name = armature_name.split(':')[1]
armature.name = original_name
object_action_pairs = []
original_actions = []
@ -66,6 +59,13 @@ class ExtractAnimationFBX(api.Extractor):
self.log.info("Object have no animation.")
return
asset_group_name = asset_group.name
asset_group.name = asset_group.get(AVALON_PROPERTY).get("asset_name")
armature_name = armature.name
original_name = armature_name.split(':')[1]
armature.name = original_name
object_action_pairs.append((armature, copy_action))
original_actions.append(curr_action)
@ -123,7 +123,7 @@ class ExtractAnimationFBX(api.Extractor):
json_path = os.path.join(stagingdir, json_filename)
json_dict = {
"instance_name": asset_group.get(AVALON_PROPERTY).get("namespace")
"instance_name": asset_group.get(AVALON_PROPERTY).get("objectName")
}
# collection = instance.data.get("name")

View file

@ -2,9 +2,12 @@ import os
import json
import bpy
import bpy_extras
import bpy_extras.anim_utils
from avalon import io
from avalon.blender.pipeline import AVALON_PROPERTY
from openpype.hosts.blender.api import plugin
from openpype.hosts.blender.api.pipeline import AVALON_PROPERTY
import openpype.api
@ -16,6 +19,99 @@ class ExtractLayout(openpype.api.Extractor):
families = ["layout"]
optional = True
def _export_animation(self, asset, instance, stagingdir, fbx_count):
n = fbx_count
for obj in asset.children:
if obj.type != "ARMATURE":
continue
object_action_pairs = []
original_actions = []
starting_frames = []
ending_frames = []
# For each armature, we make a copy of the current action
curr_action = None
copy_action = None
if obj.animation_data and obj.animation_data.action:
curr_action = obj.animation_data.action
copy_action = curr_action.copy()
curr_frame_range = curr_action.frame_range
starting_frames.append(curr_frame_range[0])
ending_frames.append(curr_frame_range[1])
else:
self.log.info("Object have no animation.")
continue
asset_group_name = asset.name
asset.name = asset.get(AVALON_PROPERTY).get("asset_name")
armature_name = obj.name
original_name = armature_name.split(':')[1]
obj.name = original_name
object_action_pairs.append((obj, copy_action))
original_actions.append(curr_action)
# We compute the starting and ending frames
max_frame = min(starting_frames)
min_frame = max(ending_frames)
# We bake the copy of the current action for each object
bpy_extras.anim_utils.bake_action_objects(
object_action_pairs,
frames=range(int(min_frame), int(max_frame)),
do_object=False,
do_clean=False
)
for o in bpy.data.objects:
o.select_set(False)
asset.select_set(True)
obj.select_set(True)
fbx_filename = f"{n:03d}.fbx"
filepath = os.path.join(stagingdir, fbx_filename)
override = plugin.create_blender_context(
active=asset, selected=[asset, obj])
bpy.ops.export_scene.fbx(
override,
filepath=filepath,
use_active_collection=False,
use_selection=True,
bake_anim_use_nla_strips=False,
bake_anim_use_all_actions=False,
add_leaf_bones=False,
armature_nodetype='ROOT',
object_types={'EMPTY', 'ARMATURE'}
)
obj.name = armature_name
asset.name = asset_group_name
asset.select_set(False)
obj.select_set(False)
# We delete the baked action and set the original one back
for i in range(0, len(object_action_pairs)):
pair = object_action_pairs[i]
action = original_actions[i]
if action:
pair[0].animation_data.action = action
if pair[1]:
pair[1].user_clear()
bpy.data.actions.remove(pair[1])
return fbx_filename, n + 1
return None, n
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
@ -23,10 +119,16 @@ class ExtractLayout(openpype.api.Extractor):
# Perform extraction
self.log.info("Performing extraction..")
if "representations" not in instance.data:
instance.data["representations"] = []
json_data = []
fbx_files = []
asset_group = bpy.data.objects[str(instance)]
fbx_count = 0
for asset in asset_group.children:
metadata = asset.get(AVALON_PROPERTY)
@ -34,6 +136,7 @@ class ExtractLayout(openpype.api.Extractor):
family = metadata["family"]
self.log.debug("Parent: {}".format(parent))
# Get blend reference
blend = io.find_one(
{
"type": "representation",
@ -41,10 +144,39 @@ class ExtractLayout(openpype.api.Extractor):
"name": "blend"
},
projection={"_id": True})
blend_id = blend["_id"]
blend_id = None
if blend:
blend_id = blend["_id"]
# Get fbx reference
fbx = io.find_one(
{
"type": "representation",
"parent": io.ObjectId(parent),
"name": "fbx"
},
projection={"_id": True})
fbx_id = None
if fbx:
fbx_id = fbx["_id"]
# Get abc reference
abc = io.find_one(
{
"type": "representation",
"parent": io.ObjectId(parent),
"name": "abc"
},
projection={"_id": True})
abc_id = None
if abc:
abc_id = abc["_id"]
json_element = {}
json_element["reference"] = str(blend_id)
if blend_id:
json_element["reference"] = str(blend_id)
if fbx_id:
json_element["reference_fbx"] = str(fbx_id)
if abc_id:
json_element["reference_abc"] = str(abc_id)
json_element["family"] = family
json_element["instance_name"] = asset.name
json_element["asset_name"] = metadata["asset_name"]
@ -67,6 +199,16 @@ class ExtractLayout(openpype.api.Extractor):
"z": asset.scale.z
}
}
# Extract the animation as well
if family == "rig":
f, n = self._export_animation(
asset, instance, stagingdir, fbx_count)
if f:
fbx_files.append(f)
json_element["animation"] = f
fbx_count = n
json_data.append(json_element)
json_filename = "{}.json".format(instance.name)
@ -75,16 +217,32 @@ class ExtractLayout(openpype.api.Extractor):
with open(json_path, "w+") as file:
json.dump(json_data, fp=file, indent=2)
if "representations" not in instance.data:
instance.data["representations"] = []
representation = {
json_representation = {
'name': 'json',
'ext': 'json',
'files': json_filename,
"stagingDir": stagingdir,
}
instance.data["representations"].append(representation)
instance.data["representations"].append(json_representation)
self.log.debug(fbx_files)
if len(fbx_files) == 1:
fbx_representation = {
'name': 'fbx',
'ext': '000.fbx',
'files': fbx_files[0],
"stagingDir": stagingdir,
}
instance.data["representations"].append(fbx_representation)
elif len(fbx_files) > 1:
fbx_representation = {
'name': 'fbx',
'ext': 'fbx',
'files': fbx_files,
"stagingDir": stagingdir,
}
instance.data["representations"].append(fbx_representation)
self.log.info("Extracted instance '%s' to: %s",
instance.name, representation)
instance.name, json_representation)

View file

@ -1,5 +1,5 @@
import pyblish.api
import avalon.blender.workio
from openpype.hosts.blender.api.workio import save_file
class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
@ -9,17 +9,17 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin):
label = "Increment Workfile Version"
optional = True
hosts = ["blender"]
families = ["animation", "model", "rig", "action"]
families = ["animation", "model", "rig", "action", "layout"]
def process(self, context):
assert all(result["success"] for result in context.data["results"]), (
"Publishing not succesfull so version is not increased.")
"Publishing not successful so version is not increased.")
from openpype.lib import version_up
path = context.data["currentFile"]
filepath = version_up(path)
avalon.blender.workio.save_file(filepath, copy=False)
save_file(filepath, copy=False)
self.log.info('Incrementing script version')

View file

@ -5,15 +5,15 @@ import openpype.hosts.blender.api.action
class ValidateObjectIsInObjectMode(pyblish.api.InstancePlugin):
"""Validate that the current object is in Object Mode."""
"""Validate that the objects in the instance are in Object Mode."""
order = pyblish.api.ValidatorOrder - 0.01
hosts = ["blender"]
families = ["model", "rig"]
families = ["model", "rig", "layout"]
category = "geometry"
label = "Object is in Object Mode"
label = "Validate Object Mode"
actions = [openpype.hosts.blender.api.action.SelectInvalidAction]
optional = True
optional = False
@classmethod
def get_invalid(cls, instance) -> List:

Some files were not shown because too many files have changed in this diff Show more