diff --git a/.dockerignore b/.dockerignore index 07c1c151ce..9c506b9964 100644 --- a/.dockerignore +++ b/.dockerignore @@ -87,7 +87,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -142,5 +142,6 @@ cython_debug/ .poetry/ .github/ vendor/bin/ +vendor/python/ docs/ website/ diff --git a/.gitignore b/.gitignore index 221a2f2241..fa3fae1ad2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ Temporary Items /dist/ /vendor/bin/* +/vendor/python/* /.venv /venv/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 32943834d0..0f0749ba52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,68 @@ # Changelog -## [3.5.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.6.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.4.1...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...HEAD) **πŸ†• New features** +- Flame: a host basic integration [\#2165](https://github.com/pypeclub/OpenPype/pull/2165) +- Houdini: simple HDA workflow [\#2072](https://github.com/pypeclub/OpenPype/pull/2072) + +**πŸš€ Enhancements** + +- Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196) +- Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185) +- Settings: Dictionary based on project roots [\#2184](https://github.com/pypeclub/OpenPype/pull/2184) +- Subset name: Be able to pass asset document to get subset name [\#2179](https://github.com/pypeclub/OpenPype/pull/2179) +- Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167) +- Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166) +- Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149) +- Burnins: DNxHD profiles handling [\#2142](https://github.com/pypeclub/OpenPype/pull/2142) +- Tools: Single access point for host tools [\#2139](https://github.com/pypeclub/OpenPype/pull/2139) + +**πŸ› Bug fixes** + +- Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191) +- StandalonePublisher: Source validator don't expect representations [\#2190](https://github.com/pypeclub/OpenPype/pull/2190) +- MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175) +- Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163) +- Tools: Stylesheets are applied after tool show [\#2161](https://github.com/pypeclub/OpenPype/pull/2161) +- Maya: Collect render - fix UNC path support πŸ› [\#2158](https://github.com/pypeclub/OpenPype/pull/2158) +- Maya: Fix hotbox broken by scriptsmenu [\#2151](https://github.com/pypeclub/OpenPype/pull/2151) +- Ftrack: Ignore save warnings exception in Prepare project action [\#2150](https://github.com/pypeclub/OpenPype/pull/2150) +- Loader thumbnails with smooth edges [\#2147](https://github.com/pypeclub/OpenPype/pull/2147) +- Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138) + +**Merged pull requests:** + +- Bump pillow from 8.2.0 to 8.3.2 [\#2162](https://github.com/pypeclub/OpenPype/pull/2162) +- Bump axios from 0.21.1 to 0.21.4 in /website [\#2059](https://github.com/pypeclub/OpenPype/pull/2059) + +## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.5.0-nightly.8...3.5.0) + +**Deprecated:** + +- Maya: Change mayaAscii family to mayaScene [\#2106](https://github.com/pypeclub/OpenPype/pull/2106) + +**πŸ†• New features** + +- Added project and task into context change message in Maya [\#2131](https://github.com/pypeclub/OpenPype/pull/2131) +- Add ExtractBurnin to photoshop review [\#2124](https://github.com/pypeclub/OpenPype/pull/2124) +- PYPE-1218 - changed namespace to contain subset name in Maya [\#2114](https://github.com/pypeclub/OpenPype/pull/2114) - Added running configurable disk mapping command before start of OP [\#2091](https://github.com/pypeclub/OpenPype/pull/2091) - SFTP provider [\#2073](https://github.com/pypeclub/OpenPype/pull/2073) - Maya: Validate setdress top group [\#2068](https://github.com/pypeclub/OpenPype/pull/2068) **πŸš€ Enhancements** +- Maya: make rig validators configurable in settings [\#2137](https://github.com/pypeclub/OpenPype/pull/2137) +- Settings: Updated readme for entity types in settings [\#2132](https://github.com/pypeclub/OpenPype/pull/2132) +- Nuke: unified clip loader [\#2128](https://github.com/pypeclub/OpenPype/pull/2128) - Settings UI: Project model refreshing and sorting [\#2104](https://github.com/pypeclub/OpenPype/pull/2104) +- Create Read From Rendered - Disable Relative paths by default [\#2093](https://github.com/pypeclub/OpenPype/pull/2093) - Added choosing different dirmap mapping if workfile synched locally [\#2088](https://github.com/pypeclub/OpenPype/pull/2088) - General: Remove IdleManager module [\#2084](https://github.com/pypeclub/OpenPype/pull/2084) - Tray UI: Message box about missing settings defaults [\#2080](https://github.com/pypeclub/OpenPype/pull/2080) @@ -23,35 +73,38 @@ - Nuke: Adding `still` image family workflow [\#2064](https://github.com/pypeclub/OpenPype/pull/2064) - Maya: validate authorized loaded plugins [\#2062](https://github.com/pypeclub/OpenPype/pull/2062) - Tools: add support for pyenv on windows [\#2051](https://github.com/pypeclub/OpenPype/pull/2051) -- SyncServer: Dropbox Provider [\#1979](https://github.com/pypeclub/OpenPype/pull/1979) **πŸ› Bug fixes** +- Maya: fix model publishing [\#2130](https://github.com/pypeclub/OpenPype/pull/2130) +- Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129) +- General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120) +- Hiero: publishing effect first time makes wrong resources path [\#2115](https://github.com/pypeclub/OpenPype/pull/2115) +- Add startup script for Houdini Core. [\#2110](https://github.com/pypeclub/OpenPype/pull/2110) - TVPaint: Behavior name of loop also accept repeat [\#2109](https://github.com/pypeclub/OpenPype/pull/2109) - Ftrack: Project settings save custom attributes skip unknown attributes [\#2103](https://github.com/pypeclub/OpenPype/pull/2103) +- Blender: Fix NoneType error when animation\_data is missing for a rig [\#2101](https://github.com/pypeclub/OpenPype/pull/2101) - Fix broken import in sftp provider [\#2100](https://github.com/pypeclub/OpenPype/pull/2100) - Global: Fix docstring on publish plugin extract review [\#2097](https://github.com/pypeclub/OpenPype/pull/2097) +- Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) - General: Cloud mongo ca certificate issue [\#2095](https://github.com/pypeclub/OpenPype/pull/2095) - TVPaint: Creator use context from workfile [\#2087](https://github.com/pypeclub/OpenPype/pull/2087) - Blender: fix texture missing when publishing blend files [\#2085](https://github.com/pypeclub/OpenPype/pull/2085) - General: Startup validations oiio tool path fix on linux [\#2083](https://github.com/pypeclub/OpenPype/pull/2083) +- Deadline: Collect deadline server does not check existence of deadline key [\#2082](https://github.com/pypeclub/OpenPype/pull/2082) - Blender: fixed Curves with modifiers in Rigs [\#2081](https://github.com/pypeclub/OpenPype/pull/2081) +- Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) +- Maya: Fix multi-camera renders [\#2065](https://github.com/pypeclub/OpenPype/pull/2065) - Fix Sync Queue when project disabled [\#2063](https://github.com/pypeclub/OpenPype/pull/2063) **Merged pull requests:** -- Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) - Bump pywin32 from 300 to 301 [\#2086](https://github.com/pypeclub/OpenPype/pull/2086) -- Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) ## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.1-nightly.1...3.4.1) -**πŸ†• New features** - -- Settings: Flag project as deactivated and hide from tools' view [\#2008](https://github.com/pypeclub/OpenPype/pull/2008) - **πŸš€ Enhancements** - General: Startup validations [\#2054](https://github.com/pypeclub/OpenPype/pull/2054) @@ -59,14 +112,6 @@ - Ftrack: Removed ftrack interface [\#2049](https://github.com/pypeclub/OpenPype/pull/2049) - Settings UI: Deffered set value on entity [\#2044](https://github.com/pypeclub/OpenPype/pull/2044) - Loader: Families filtering [\#2043](https://github.com/pypeclub/OpenPype/pull/2043) -- Settings UI: Project view enhancements [\#2042](https://github.com/pypeclub/OpenPype/pull/2042) -- Added possibility to configure of synchronization of workfile version… [\#2041](https://github.com/pypeclub/OpenPype/pull/2041) -- Settings for Nuke IncrementScriptVersion [\#2039](https://github.com/pypeclub/OpenPype/pull/2039) -- Loader & Library loader: Use tools from OpenPype [\#2038](https://github.com/pypeclub/OpenPype/pull/2038) -- Adding predefined project folders creation in PM [\#2030](https://github.com/pypeclub/OpenPype/pull/2030) -- WebserverModule: Removed interface of webserver module [\#2028](https://github.com/pypeclub/OpenPype/pull/2028) -- TimersManager: Removed interface of timers manager [\#2024](https://github.com/pypeclub/OpenPype/pull/2024) -- Feature Maya import asset from scene inventory [\#2018](https://github.com/pypeclub/OpenPype/pull/2018) **πŸ› Bug fixes** @@ -75,7 +120,6 @@ - Differentiate jpg sequences from thumbnail [\#2056](https://github.com/pypeclub/OpenPype/pull/2056) - FFmpeg: Split command to list does not work [\#2046](https://github.com/pypeclub/OpenPype/pull/2046) - Removed shell flag in subprocess call [\#2045](https://github.com/pypeclub/OpenPype/pull/2045) -- Hiero: Fix "none" named tags [\#2033](https://github.com/pypeclub/OpenPype/pull/2033) **Merged pull requests:** @@ -85,47 +129,6 @@ [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.4.0-nightly.6...3.4.0) -### πŸ“– Documentation - -- Documentation: Ftrack launch argsuments update [\#2014](https://github.com/pypeclub/OpenPype/pull/2014) - -**πŸ†• New features** - -- Nuke: Compatibility with Nuke 13 [\#2003](https://github.com/pypeclub/OpenPype/pull/2003) - -**πŸš€ Enhancements** - -- General: Task types in profiles [\#2036](https://github.com/pypeclub/OpenPype/pull/2036) -- Console interpreter: Handle invalid sizes on initialization [\#2022](https://github.com/pypeclub/OpenPype/pull/2022) -- Ftrack: Show OpenPype versions in event server status [\#2019](https://github.com/pypeclub/OpenPype/pull/2019) -- General: Staging icon [\#2017](https://github.com/pypeclub/OpenPype/pull/2017) -- Ftrack: Sync to avalon actions have jobs [\#2015](https://github.com/pypeclub/OpenPype/pull/2015) -- Modules: Connect method is not required [\#2009](https://github.com/pypeclub/OpenPype/pull/2009) -- Settings UI: Number with configurable steps [\#2001](https://github.com/pypeclub/OpenPype/pull/2001) -- Moving project folder structure creation out of ftrack module \#1989 [\#1996](https://github.com/pypeclub/OpenPype/pull/1996) -- Configurable items for providers without Settings [\#1987](https://github.com/pypeclub/OpenPype/pull/1987) -- Global: Example addons [\#1986](https://github.com/pypeclub/OpenPype/pull/1986) -- Standalone Publisher: Extract harmony zip handle workfile template [\#1982](https://github.com/pypeclub/OpenPype/pull/1982) -- Settings UI: Number sliders [\#1978](https://github.com/pypeclub/OpenPype/pull/1978) - -**πŸ› Bug fixes** - -- Workfiles tool: Task selection [\#2040](https://github.com/pypeclub/OpenPype/pull/2040) -- Ftrack: Delete old versions missing settings key [\#2037](https://github.com/pypeclub/OpenPype/pull/2037) -- Nuke: typo on a button [\#2034](https://github.com/pypeclub/OpenPype/pull/2034) -- FFmpeg: Subprocess arguments as list [\#2032](https://github.com/pypeclub/OpenPype/pull/2032) -- General: Fix Python 2 breaking line [\#2016](https://github.com/pypeclub/OpenPype/pull/2016) -- Bugfix/webpublisher task type [\#2006](https://github.com/pypeclub/OpenPype/pull/2006) -- Nuke thumbnails generated from middle of the sequence [\#1992](https://github.com/pypeclub/OpenPype/pull/1992) -- Nuke: last version from path gets correct version [\#1990](https://github.com/pypeclub/OpenPype/pull/1990) -- nuke, resolve, hiero: precollector order lest then 0.5 [\#1984](https://github.com/pypeclub/OpenPype/pull/1984) -- Last workfile with multiple work templates [\#1981](https://github.com/pypeclub/OpenPype/pull/1981) -- Collectors order [\#1977](https://github.com/pypeclub/OpenPype/pull/1977) -- Stop timer was within validator order range. [\#1975](https://github.com/pypeclub/OpenPype/pull/1975) -- Ftrack: arrow submodule has https url source [\#1974](https://github.com/pypeclub/OpenPype/pull/1974) -- Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) -- Deadline: Houdini plugins in different hierarchy [\#1970](https://github.com/pypeclub/OpenPype/pull/1970) - ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.3.1-nightly.1...3.3.1) diff --git a/Dockerfile b/Dockerfile index 2d8ed27b15..cef83b5811 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ # Build Pype docker image -FROM centos:7 AS builder -ARG OPENPYPE_PYTHON_VERSION=3.7.10 +FROM debian:bookworm-slim AS builder +ARG OPENPYPE_PYTHON_VERSION=3.7.12 +LABEL maintainer="info@openpype.io" +LABEL description="Docker Image to build and run OpenPype" LABEL org.opencontainers.image.name="pypeclub/openpype" LABEL org.opencontainers.image.title="OpenPype Docker Image" LABEL org.opencontainers.image.url="https://openpype.io/" @@ -9,56 +11,49 @@ LABEL org.opencontainers.image.source="https://github.com/pypeclub/pype" USER root -# update base -RUN yum -y install deltarpm \ - && yum -y update \ - && yum clean all +ARG DEBIAN_FRONTEND=noninteractive -# add tools we need -RUN yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm \ - && yum -y install centos-release-scl \ - && yum -y install \ +# update base +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ bash \ - which \ git \ - devtoolset-7-gcc* \ - make \ cmake \ + make \ curl \ wget \ - gcc \ - zlib-devel \ - bzip2 \ - bzip2-devel \ - readline-devel \ - sqlite sqlite-devel \ - openssl-devel \ - tk-devel libffi-devel \ - qt5-qtbase-devel \ - patchelf \ - && yum clean all + build-essential \ + checkinstall \ + libssl-dev \ + zlib1g-dev \ + libbz2-dev \ + libreadline-dev \ + libsqlite3-dev \ + llvm \ + libncursesw5-dev \ + xz-utils \ + tk-dev \ + libxml2-dev \ + libxmlsec1-dev \ + libffi-dev \ + liblzma-dev \ + patchelf + +SHELL ["/bin/bash", "-c"] RUN mkdir /opt/openpype -# RUN useradd -m pype -# RUN chown pype /opt/openpype -# USER pype -RUN curl https://pyenv.run | bash -ENV PYTHON_CONFIGURE_OPTS --enable-shared - -RUN echo 'export PATH="$HOME/.pyenv/bin:$PATH"'>> $HOME/.bashrc \ +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 -RUN source $HOME/.bashrc && pyenv install ${OPENPYPE_PYTHON_VERSION} + && echo 'eval "$(pyenv init --path)"' >> $HOME/.bashrc \ + && source $HOME/.bashrc && pyenv install ${OPENPYPE_PYTHON_VERSION} COPY . /opt/openpype/ -RUN rm -rf /openpype/.poetry || echo "No Poetry installed yet." -# USER root -# RUN chown -R pype /opt/openpype -RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/build.sh -# USER pype +RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/build.sh WORKDIR /opt/openpype @@ -67,16 +62,8 @@ RUN cd /opt/openpype \ && pyenv local ${OPENPYPE_PYTHON_VERSION} RUN source $HOME/.bashrc \ - && ./tools/create_env.sh - -RUN source $HOME/.bashrc \ + && ./tools/create_env.sh \ && ./tools/fetch_thirdparty_libs.sh RUN source $HOME/.bashrc \ - && bash ./tools/build.sh \ - && cp /usr/lib64/libffi* ./build/exe.linux-x86_64-3.7/lib \ - && cp /usr/lib64/libssl* ./build/exe.linux-x86_64-3.7/lib \ - && cp /usr/lib64/libcrypto* ./build/exe.linux-x86_64-3.7/lib - -RUN cd /opt/openpype \ - rm -rf ./vendor/bin + && bash ./tools/build.sh diff --git a/Dockerfile.centos7 b/Dockerfile.centos7 new file mode 100644 index 0000000000..f3b257e66b --- /dev/null +++ b/Dockerfile.centos7 @@ -0,0 +1,98 @@ +# Build Pype docker image +FROM centos:7 AS builder +ARG OPENPYPE_PYTHON_VERSION=3.7.10 + +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" + +USER root + +# update base +RUN yum -y install deltarpm \ + && yum -y update \ + && yum clean all + +# add tools we need +RUN yum -y install https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm \ + && yum -y install centos-release-scl \ + && yum -y install \ + bash \ + which \ + git \ + make \ + devtoolset-7 \ + cmake \ + curl \ + wget \ + gcc \ + zlib-devel \ + bzip2 \ + bzip2-devel \ + readline-devel \ + sqlite sqlite-devel \ + openssl-devel \ + openssl-libs \ + tk-devel libffi-devel \ + patchelf \ + automake \ + autoconf \ + ncurses \ + ncurses-devel \ + qt5-qtbase-devel \ + && yum clean all + +# we need to build our own patchelf +WORKDIR /temp-patchelf +RUN git clone https://github.com/NixOS/patchelf.git . \ + && source scl_source enable devtoolset-7 \ + && ./bootstrap.sh \ + && ./configure \ + && make \ + && make install + +RUN mkdir /opt/openpype +# RUN useradd -m pype +# RUN chown pype /opt/openpype +# USER pype + +RUN curl https://pyenv.run | bash +# ENV PYTHON_CONFIGURE_OPTS --enable-shared + +RUN 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 +RUN source $HOME/.bashrc && pyenv install ${OPENPYPE_PYTHON_VERSION} + +COPY . /opt/openpype/ +RUN rm -rf /openpype/.poetry || echo "No Poetry installed yet." +# USER root +# RUN chown -R pype /opt/openpype +RUN chmod +x /opt/openpype/tools/create_env.sh && chmod +x /opt/openpype/tools/build.sh + +# USER pype + +WORKDIR /opt/openpype + +RUN cd /opt/openpype \ + && source $HOME/.bashrc \ + && pyenv local ${OPENPYPE_PYTHON_VERSION} + +RUN source $HOME/.bashrc \ + && ./tools/create_env.sh + +RUN source $HOME/.bashrc \ + && ./tools/fetch_thirdparty_libs.sh + +RUN source $HOME/.bashrc \ + && bash ./tools/build.sh + +RUN cp /usr/lib64/libffi* ./build/exe.linux-x86_64-3.7/lib \ + && cp /usr/lib64/libssl* ./build/exe.linux-x86_64-3.7/lib \ + && cp /usr/lib64/libcrypto* ./build/exe.linux-x86_64-3.7/lib \ + && cp /root/.pyenv/versions/${OPENPYPE_PYTHON_VERSION}/lib/libpython* ./build/exe.linux-x86_64-3.7/lib + +RUN cd /opt/openpype \ + rm -rf ./vendor/bin diff --git a/README.md b/README.md index 209af24c75..0e450fc48d 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,12 @@ Easiest way to build OpenPype on Linux is using [Docker](https://www.docker.com/ sudo ./tools/docker_build.sh ``` +This will by default use Debian as base image. If you need to make Centos 7 compatible build, please run: + +```sh +sudo ./tools/docker_build.sh centos7 +``` + If all is successful, you'll find built OpenPype in `./build/` folder. #### Manual build @@ -158,6 +164,11 @@ you'll need also additional libraries for Qt5: ```sh sudo apt install qt5-default ``` +or if you are on Ubuntu > 20.04, there is no `qt5-default` packages so you need to install its content individually: + +```sh +sudo apt-get install qtbase5-dev qtchooser qt5-qmake qtbase5-dev-tools +```
diff --git a/openpype/__init__.py b/openpype/__init__.py index 9d55006a67..11b563ebfe 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -69,6 +69,7 @@ def install(): """Install Pype to Avalon.""" from pyblish.lib import MessageHandler from openpype.modules import load_modules + from avalon import pipeline # Make sure modules are loaded load_modules() @@ -117,7 +118,9 @@ def install(): # apply monkey patched discover to original one log.info("Patching discovery") + avalon.discover = patched_discover + pipeline.discover = patched_discover avalon.on("taskChanged", _on_task_change) diff --git a/openpype/cli.py b/openpype/cli.py index b79055d474..8afa6b2c75 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -164,6 +164,25 @@ def publish(debug, paths, targets): PypeCommands.publish(list(paths), targets) +@main.command() +@click.argument("path") +@click.option("-d", "--debug", is_flag=True, help="Print debug messages") +@click.option("-h", "--host", help="Host") +@click.option("-u", "--user", help="User email address") +@click.option("-p", "--project", help="Project") +@click.option("-t", "--targets", help="Targets", default=None, + multiple=True) +def remotepublishfromapp(debug, project, path, host, targets=None, user=None): + """Start CLI publishing. + + Publish collects json from paths provided as an argument. + More than one path is allowed. + """ + if debug: + os.environ['OPENPYPE_DEBUG'] = '3' + PypeCommands.remotepublishfromapp(project, path, host, user, + targets=targets) + @main.command() @click.argument("path") @click.option("-d", "--debug", is_flag=True, help="Print debug messages") diff --git a/openpype/hooks/pre_foundry_apps.py b/openpype/hooks/pre_foundry_apps.py index 85f68c6b60..7df1a6a833 100644 --- a/openpype/hooks/pre_foundry_apps.py +++ b/openpype/hooks/pre_foundry_apps.py @@ -13,7 +13,7 @@ class LaunchFoundryAppsWindows(PreLaunchHook): # Should be as last hook because must change launch arguments to string order = 1000 - app_groups = ["nuke", "nukex", "hiero", "nukestudio"] + app_groups = ["nuke", "nukex", "hiero", "nukestudio", "photoshop"] platforms = ["windows"] def execute(self): diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py index 50b73ade2b..6d437059b8 100644 --- a/openpype/hosts/blender/api/plugin.py +++ b/openpype/hosts/blender/api/plugin.py @@ -95,6 +95,30 @@ def get_local_collection_with_name(name): return None +def deselect_all(): + """Deselect all objects in the scene. + + Blender gives context error if trying to deselect object that it isn't + in object mode. + """ + modes = [] + active = bpy.context.view_layer.objects.active + + for obj in bpy.data.objects: + if obj.mode != 'OBJECT': + modes.append((obj, obj.mode)) + bpy.context.view_layer.objects.active = obj + bpy.ops.object.mode_set(mode='OBJECT') + + bpy.ops.object.select_all(action='DESELECT') + + for p in modes: + bpy.context.view_layer.objects.active = p[0] + bpy.ops.object.mode_set(mode=p[1]) + + bpy.context.view_layer.objects.active = active + + class Creator(PypeCreatorMixin, blender.Creator): pass diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index c7fea30787..98ccca313c 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -3,11 +3,12 @@ import bpy from avalon import api -from avalon.blender import lib -import openpype.hosts.blender.api.plugin +from avalon.blender import lib, ops +from avalon.blender.pipeline import AVALON_INSTANCES +from openpype.hosts.blender.api import plugin -class CreateCamera(openpype.hosts.blender.api.plugin.Creator): +class CreateCamera(plugin.Creator): """Polygonal static geometry""" name = "cameraMain" @@ -16,17 +17,46 @@ class CreateCamera(openpype.hosts.blender.api.plugin.Creator): icon = "video-camera" def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + def _process(self): + # Get Instance Containter or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object asset = self.data["asset"] subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + name = plugin.asset_name(asset, subset) + + camera = bpy.data.cameras.new(subset) + camera_obj = bpy.data.objects.new(subset, camera) + + instances.objects.link(camera_obj) + + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) self.data['task'] = api.Session.get('AVALON_TASK') - lib.imprint(collection, self.data) + print(f"self.data: {self.data}") + lib.imprint(asset_group, self.data) if (self.options or {}).get("useSelection"): - for obj in lib.get_selection(): - collection.objects.link(obj) + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + obj.select_set(True) + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + else: + plugin.deselect_all() + camera_obj.select_set(True) + asset_group.select_set(True) + bpy.context.view_layer.objects.active = asset_group + bpy.ops.object.parent_set(keep_transform=True) - return collection + return asset_group diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 92656fac9e..5969432c36 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -47,7 +47,7 @@ class CacheModelLoader(plugin.AssetLoader): bpy.data.objects.remove(empty) def _process(self, libpath, asset_group, group_name): - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() collection = bpy.context.view_layer.active_layer_collection.collection @@ -109,7 +109,7 @@ class CacheModelLoader(plugin.AssetLoader): avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects diff --git a/openpype/hosts/blender/plugins/load/load_camera.py b/openpype/hosts/blender/plugins/load/load_camera.py deleted file mode 100644 index 30300100e0..0000000000 --- a/openpype/hosts/blender/plugins/load/load_camera.py +++ /dev/null @@ -1,247 +0,0 @@ -"""Load a camera asset in Blender.""" - -import logging -from pathlib import Path -from pprint import pformat -from typing import Dict, List, Optional - -from avalon import api, blender -import bpy -import openpype.hosts.blender.api.plugin - -logger = logging.getLogger("openpype").getChild("blender").getChild("load_camera") - - -class BlendCameraLoader(openpype.hosts.blender.api.plugin.AssetLoader): - """Load a camera from a .blend file. - - Warning: - Loading the same asset more then once is not properly supported at the - moment. - """ - - families = ["camera"] - representations = ["blend"] - - label = "Link Camera" - icon = "code-fork" - color = "orange" - - def _remove(self, objects, lib_container): - for obj in list(objects): - bpy.data.cameras.remove(obj.data) - - bpy.data.collections.remove(bpy.data.collections[lib_container]) - - def _process(self, libpath, lib_container, container_name, actions): - - relative = bpy.context.preferences.filepaths.use_relative_paths - with bpy.data.libraries.load( - libpath, link=True, relative=relative - ) as (_, data_to): - data_to.collections = [lib_container] - - scene = bpy.context.scene - - scene.collection.children.link(bpy.data.collections[lib_container]) - - camera_container = scene.collection.children[lib_container].make_local() - - objects_list = [] - - for obj in camera_container.objects: - local_obj = obj.make_local() - local_obj.data.make_local() - - if not local_obj.get(blender.pipeline.AVALON_PROPERTY): - local_obj[blender.pipeline.AVALON_PROPERTY] = dict() - - avalon_info = local_obj[blender.pipeline.AVALON_PROPERTY] - avalon_info.update({"container_name": container_name}) - - if actions[0] is not None: - if local_obj.animation_data is None: - local_obj.animation_data_create() - local_obj.animation_data.action = actions[0] - - if actions[1] is not None: - if local_obj.data.animation_data is None: - local_obj.data.animation_data_create() - local_obj.data.animation_data.action = actions[1] - - objects_list.append(local_obj) - - camera_container.pop(blender.pipeline.AVALON_PROPERTY) - - bpy.ops.object.select_all(action='DESELECT') - - return objects_list - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - lib_container = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - container_name = openpype.hosts.blender.api.plugin.asset_name( - asset, subset, namespace - ) - - container = bpy.data.collections.new(lib_container) - container.name = container_name - blender.pipeline.containerise_existing( - container, - name, - namespace, - context, - self.__class__.__name__, - ) - - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) - - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container - - objects_list = self._process( - libpath, lib_container, container_name, (None, None)) - - # Save the list of objects in the metadata container - container_metadata["objects"] = objects_list - - nodes = list(container.objects) - nodes.append(container) - self[:] = nodes - return nodes - - def update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all objects of the current collection, load the new - ones and add them to the collection. - If the objects of the collection are used in another collection they - will not be removed, only unlinked. Normally this should not be the - case though. - - Warning: - No nested collections are supported at the moment! - """ - - collection = bpy.data.collections.get( - container["objectName"] - ) - - libpath = Path(api.get_representation_path(representation)) - extension = libpath.suffix.lower() - - logger.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert collection, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert not (collection.children), ( - "Nested collections are not supported." - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in openpype.hosts.blender.api.plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - collection_libpath = collection_metadata["libpath"] - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - - normalized_collection_libpath = ( - str(Path(bpy.path.abspath(collection_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - logger.debug( - "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_collection_libpath, - normalized_libpath, - ) - if normalized_collection_libpath == normalized_libpath: - logger.info("Library already loaded, not updating...") - return - - camera = objects[0] - - camera_action = None - camera_data_action = None - - if camera.animation_data and camera.animation_data.action: - camera_action = camera.animation_data.action - - if camera.data.animation_data and camera.data.animation_data.action: - camera_data_action = camera.data.animation_data.action - - actions = (camera_action, camera_data_action) - - self._remove(objects, lib_container) - - objects_list = self._process( - str(libpath), lib_container, collection.name, actions) - - # Save the list of objects in the metadata container - collection_metadata["objects"] = objects_list - collection_metadata["libpath"] = str(libpath) - collection_metadata["representation"] = str(representation["_id"]) - - bpy.ops.object.select_all(action='DESELECT') - - def remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - - Warning: - No nested collections are supported at the moment! - """ - - collection = bpy.data.collections.get( - container["objectName"] - ) - if not collection: - return False - assert not (collection.children), ( - "Nested collections are not supported." - ) - - collection_metadata = collection.get( - blender.pipeline.AVALON_PROPERTY) - objects = collection_metadata["objects"] - lib_container = collection_metadata["lib_container"] - - self._remove(objects, lib_container) - - bpy.data.collections.remove(collection) - - return True diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py new file mode 100644 index 0000000000..834eb467d8 --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py @@ -0,0 +1,252 @@ +"""Load a camera asset in Blender.""" + +import logging +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import bpy + +from avalon import api +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin + +logger = logging.getLogger("openpype").getChild( + "blender").getChild("load_camera") + + +class BlendCameraLoader(plugin.AssetLoader): + """Load a camera from a .blend file. + + Warning: + Loading the same asset more then once is not properly supported at the + moment. + """ + + families = ["camera"] + representations = ["blend"] + + label = "Link Camera (Blend)" + icon = "code-fork" + color = "orange" + + def _remove(self, asset_group): + objects = list(asset_group.children) + + for obj in objects: + if obj.type == 'CAMERA': + bpy.data.cameras.remove(obj.data) + + def _process(self, libpath, asset_group, group_name): + with bpy.data.libraries.load( + libpath, link=True, relative=False + ) as (data_from, data_to): + data_to.objects = data_from.objects + + parent = bpy.context.scene.collection + + empties = [obj for obj in data_to.objects if obj.type == 'EMPTY'] + + container = None + + for empty in empties: + if empty.get(AVALON_PROPERTY): + container = empty + break + + assert container, "No asset group found" + + # Children must be linked before parents, + # otherwise the hierarchy will break + objects = [] + nodes = list(container.children) + + for obj in nodes: + obj.parent = asset_group + + for obj in nodes: + objects.append(obj) + nodes.extend(list(obj.children)) + + objects.reverse() + + for obj in objects: + parent.objects.link(obj) + + for obj in objects: + local_obj = plugin.prepare_data(obj, group_name) + + if local_obj.type != 'EMPTY': + plugin.prepare_data(local_obj.data, group_name) + + if not local_obj.get(AVALON_PROPERTY): + local_obj[AVALON_PROPERTY] = dict() + + avalon_info = local_obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + objects.reverse() + + bpy.data.orphans_purge(do_local_ids=False) + + plugin.deselect_all() + + return objects + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + asset_group = bpy.data.objects.new(group_name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + avalon_container.objects.link(asset_group) + + objects = self._process(libpath, asset_group, group_name) + + bpy.context.scene.collection.objects.link(asset_group) + + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name + } + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all children of the asset group, load the new ones + and add them as children of the group. + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] + + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, + normalized_libpath, + ) + if normalized_group_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + # Check how many assets use the same library + count = 0 + for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: + if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath: + count += 1 + + mat = asset_group.matrix_basis.copy() + + self._remove(asset_group) + + # If it is the last object to use that library, remove it + if count == 1: + library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) + if library: + bpy.data.libraries.remove(library) + + self._process(str(libpath), asset_group, object_name) + + asset_group.matrix_basis = mat + + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) + metadata["parent"] = str(representation["parent"]) + + def exec_remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = asset_group.get(AVALON_PROPERTY).get('libpath') + + # Check how many assets use the same library + count = 0 + for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: + if obj.get(AVALON_PROPERTY).get('libpath') == libpath: + count += 1 + + if not asset_group: + return False + + self._remove(asset_group) + + bpy.data.objects.remove(asset_group) + + # If it is the last object to use that library, remove it + if count == 1: + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + + return True diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py new file mode 100644 index 0000000000..5edba7ec0c --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -0,0 +1,218 @@ +"""Load an asset in Blender from an Alembic file.""" + +from pathlib import Path +from pprint import pformat +from typing import Dict, List, Optional + +import bpy + +from avalon import api +from avalon.blender import lib +from avalon.blender.pipeline import AVALON_CONTAINERS +from avalon.blender.pipeline import AVALON_CONTAINER_ID +from avalon.blender.pipeline import AVALON_PROPERTY +from openpype.hosts.blender.api import plugin + + +class FbxCameraLoader(plugin.AssetLoader): + """Load a camera from FBX. + + Stores the imported asset in an empty named after the asset. + """ + + families = ["camera"] + representations = ["fbx"] + + label = "Load Camera (FBX)" + icon = "code-fork" + color = "orange" + + def _remove(self, asset_group): + objects = list(asset_group.children) + + for obj in objects: + if obj.type == 'CAMERA': + bpy.data.cameras.remove(obj.data) + elif obj.type == 'EMPTY': + objects.extend(obj.children) + bpy.data.objects.remove(obj) + + def _process(self, libpath, asset_group, group_name): + plugin.deselect_all() + + collection = bpy.context.view_layer.active_layer_collection.collection + + bpy.ops.import_scene.fbx(filepath=libpath) + + parent = bpy.context.scene.collection + + objects = lib.get_selection() + + for obj in objects: + obj.parent = asset_group + + for obj in objects: + parent.objects.link(obj) + collection.objects.unlink(obj) + + for obj in objects: + name = obj.name + obj.name = f"{group_name}:{name}" + if obj.type != 'EMPTY': + name_data = obj.data.name + obj.data.name = f"{group_name}:{name_data}" + + if not obj.get(AVALON_PROPERTY): + obj[AVALON_PROPERTY] = dict() + + avalon_info = obj[AVALON_PROPERTY] + avalon_info.update({"container_name": group_name}) + + plugin.deselect_all() + + return objects + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + asset_group = bpy.data.objects.new(group_name, object_data=None) + avalon_container.objects.link(asset_group) + + objects = self._process(libpath, asset_group, group_name) + + objects = [] + nodes = list(asset_group.children) + + for obj in nodes: + objects.append(obj) + nodes.extend(list(obj.children)) + + bpy.context.scene.collection.objects.link(asset_group) + + asset_group[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name + } + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """Update the loaded asset. + + This will remove all objects of the current collection, load the new + ones and add them to the collection. + If the objects of the collection are used in another collection they + will not be removed, only unlinked. Normally this should not be the + case though. + + Warning: + No nested collections are supported at the moment! + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + libpath = Path(api.get_representation_path(representation)) + extension = libpath.suffix.lower() + + self.log.info( + "Container: %s\nRepresentation: %s", + pformat(container, indent=2), + pformat(representation, indent=2), + ) + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + assert libpath, ( + "No existing library file found for {container['objectName']}" + ) + assert libpath.is_file(), ( + f"The file doesn't exist: {libpath}" + ) + assert extension in plugin.VALID_EXTENSIONS, ( + f"Unsupported file: {libpath}" + ) + + metadata = asset_group.get(AVALON_PROPERTY) + group_libpath = metadata["libpath"] + + normalized_group_libpath = ( + str(Path(bpy.path.abspath(group_libpath)).resolve()) + ) + normalized_libpath = ( + str(Path(bpy.path.abspath(str(libpath))).resolve()) + ) + self.log.debug( + "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", + normalized_group_libpath, + normalized_libpath, + ) + if normalized_group_libpath == normalized_libpath: + self.log.info("Library already loaded, not updating...") + return + + mat = asset_group.matrix_basis.copy() + + self._remove(asset_group) + self._process(str(libpath), asset_group, object_name) + + asset_group.matrix_basis = mat + + metadata["libpath"] = str(libpath) + metadata["representation"] = str(representation["_id"]) + + def exec_remove(self, container: Dict) -> bool: + """Remove an existing container from a Blender scene. + + Arguments: + container (openpype:container-1.0): Container to remove, + from `host.ls()`. + + Returns: + bool: Whether the container was deleted. + + Warning: + No nested collections are supported at the moment! + """ + object_name = container["objectName"] + asset_group = bpy.data.objects.get(object_name) + + if not asset_group: + return False + + self._remove(asset_group) + + bpy.data.objects.remove(asset_group) + + return True diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index b80dc69adc..5f69aecb1a 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -46,7 +46,7 @@ class FbxModelLoader(plugin.AssetLoader): bpy.data.objects.remove(obj) def _process(self, libpath, asset_group, group_name, action): - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() collection = bpy.context.view_layer.active_layer_collection.collection @@ -112,7 +112,7 @@ class FbxModelLoader(plugin.AssetLoader): avalon_info = obj[AVALON_PROPERTY] avalon_info.update({"container_name": group_name}) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index 85cb4dfbd3..4c1f751a77 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -150,7 +150,7 @@ class BlendLayoutLoader(plugin.AssetLoader): bpy.data.orphans_purge(do_local_ids=False) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 1a4dbbb5cb..442cf05d85 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -12,6 +12,7 @@ from avalon.blender.pipeline import AVALON_CONTAINERS from avalon.blender.pipeline import AVALON_CONTAINER_ID from avalon.blender.pipeline import AVALON_PROPERTY from avalon.blender.pipeline import AVALON_INSTANCES +from openpype import lib from openpype.hosts.blender.api import plugin @@ -59,7 +60,7 @@ class JsonLayoutLoader(plugin.AssetLoader): return None def _process(self, libpath, asset, asset_group, actions): - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() with open(libpath, "r") as fp: data = json.load(fp) @@ -103,6 +104,21 @@ class JsonLayoutLoader(plugin.AssetLoader): options=options ) + # Create the camera asset and the camera instance + creator_plugin = lib.get_creator_by_name("CreateCamera") + if not creator_plugin: + raise ValueError("Creator plugin \"CreateCamera\" was " + "not found.") + + api.create( + creator_plugin, + name="camera", + # name=f"{unique_number}_{subset}_animation", + asset=asset, + options={"useSelection": False} + # data={"dependencies": str(context["representation"]["_id"])} + ) + def process_asset(self, context: dict, name: str, diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index af5591c299..c33c656dec 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -93,7 +93,7 @@ class BlendModelLoader(plugin.AssetLoader): bpy.data.orphans_purge(do_local_ids=False) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects @@ -126,7 +126,7 @@ class BlendModelLoader(plugin.AssetLoader): asset_group.empty_display_type = 'SINGLE_ARROW' avalon_container.objects.link(asset_group) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() if options is not None: parent = options.get('parent') @@ -158,7 +158,7 @@ class BlendModelLoader(plugin.AssetLoader): bpy.ops.object.parent_set(keep_transform=True) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() objects = self._process(libpath, asset_group, group_name) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index 6062c293df..e80da8af45 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -156,7 +156,7 @@ class BlendRigLoader(plugin.AssetLoader): while bpy.data.orphans_purge(do_local_ids=False): pass - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() return objects @@ -191,7 +191,7 @@ class BlendRigLoader(plugin.AssetLoader): action = None - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() create_animation = False @@ -227,7 +227,7 @@ class BlendRigLoader(plugin.AssetLoader): bpy.ops.object.parent_set(keep_transform=True) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() objects = self._process(libpath, asset_group, group_name, action) @@ -250,7 +250,7 @@ class BlendRigLoader(plugin.AssetLoader): data={"dependencies": str(context["representation"]["_id"])} ) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() bpy.context.scene.collection.objects.link(asset_group) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 4696da3db4..b75bec4e28 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -28,7 +28,7 @@ class ExtractABC(api.Extractor): # Perform extraction self.log.info("Performing extraction..") - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() selected = [] asset_group = None @@ -50,7 +50,7 @@ class ExtractABC(api.Extractor): flatten=False ) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/blender/plugins/publish/extract_camera.py b/openpype/hosts/blender/plugins/publish/extract_camera.py new file mode 100644 index 0000000000..a0e78178c8 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_camera.py @@ -0,0 +1,73 @@ +import os + +from openpype import api +from openpype.hosts.blender.api import plugin + +import bpy + + +class ExtractCamera(api.Extractor): + """Extract as the camera as FBX.""" + + label = "Extract Camera" + hosts = ["blender"] + families = ["camera"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + filename = f"{instance.name}.fbx" + filepath = os.path.join(stagingdir, filename) + + # Perform extraction + self.log.info("Performing extraction..") + + plugin.deselect_all() + + selected = [] + + camera = None + + for obj in instance: + if obj.type == "CAMERA": + obj.select_set(True) + selected.append(obj) + camera = obj + break + + assert camera, "No camera found" + + context = plugin.create_blender_context( + active=camera, selected=selected) + + scale_length = bpy.context.scene.unit_settings.scale_length + bpy.context.scene.unit_settings.scale_length = 0.01 + + # We export the fbx + bpy.ops.export_scene.fbx( + context, + filepath=filepath, + use_active_collection=False, + use_selection=True, + object_types={'CAMERA'}, + bake_anim_simplify_factor=0.0 + ) + + bpy.context.scene.unit_settings.scale_length = scale_length + + plugin.deselect_all() + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/blender/plugins/publish/extract_fbx.py b/openpype/hosts/blender/plugins/publish/extract_fbx.py index b91f2a75ef..f9ffdea1d1 100644 --- a/openpype/hosts/blender/plugins/publish/extract_fbx.py +++ b/openpype/hosts/blender/plugins/publish/extract_fbx.py @@ -24,7 +24,7 @@ class ExtractFBX(api.Extractor): # Perform extraction self.log.info("Performing extraction..") - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() selected = [] asset_group = None @@ -60,7 +60,7 @@ class ExtractFBX(api.Extractor): add_leaf_bones=False ) - bpy.ops.object.select_all(action='DESELECT') + plugin.deselect_all() for mat in new_materials: bpy.data.materials.remove(mat) diff --git a/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py new file mode 100644 index 0000000000..39b9b67511 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/validate_camera_zero_keyframe.py @@ -0,0 +1,48 @@ +from typing import List + +import mathutils + +import pyblish.api +import openpype.hosts.blender.api.action + + +class ValidateCameraZeroKeyframe(pyblish.api.InstancePlugin): + """Camera must have a keyframe at frame 0. + + Unreal shifts the first keyframe to frame 0. Forcing the camera to have + a keyframe at frame 0 will ensure that the animation will be the same + in Unreal and Blender. + """ + + order = openpype.api.ValidateContentsOrder + hosts = ["blender"] + families = ["camera"] + category = "geometry" + version = (0, 1, 0) + label = "Zero Keyframe" + actions = [openpype.hosts.blender.api.action.SelectInvalidAction] + + _identity = mathutils.Matrix() + + @classmethod + def get_invalid(cls, instance) -> List: + invalid = [] + for obj in [obj for obj in instance]: + if obj.type == "CAMERA": + if obj.animation_data and obj.animation_data.action: + action = obj.animation_data.action + frames_set = set() + for fcu in action.fcurves: + for kp in fcu.keyframe_points: + frames_set.add(kp.co[0]) + frames = list(frames_set) + frames.sort() + if frames[0] != 0.0: + invalid.append(obj) + return invalid + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise RuntimeError( + f"Object found in instance is not in Object Mode: {invalid}") diff --git a/openpype/hosts/celaction/api/cli.py b/openpype/hosts/celaction/api/cli.py index acd9da8229..bc1e3eaf89 100644 --- a/openpype/hosts/celaction/api/cli.py +++ b/openpype/hosts/celaction/api/cli.py @@ -4,7 +4,6 @@ import copy import argparse from avalon import io -from avalon.tools import publish import pyblish.api import pyblish.util @@ -13,6 +12,7 @@ from openpype.api import Logger import openpype import openpype.hosts.celaction from openpype.hosts.celaction import api as celaction +from openpype.tools.utils import host_tools log = Logger().get_logger("Celaction_cli_publisher") @@ -82,7 +82,7 @@ def main(): pyblish.api.register_host(publish_host) - return publish.show() + return host_tools.show_publish() if __name__ == "__main__": diff --git a/openpype/hosts/flame/__init__.py b/openpype/hosts/flame/__init__.py new file mode 100644 index 0000000000..48e8dc86c9 --- /dev/null +++ b/openpype/hosts/flame/__init__.py @@ -0,0 +1,105 @@ +from .api.utils import ( + setup +) + +from .api.pipeline import ( + install, + uninstall, + ls, + containerise, + update_container, + maintained_selection, + remove_instance, + list_instances, + imprint +) + +from .api.lib import ( + FlameAppFramework, + maintain_current_timeline, + get_project_manager, + get_current_project, + get_current_timeline, + create_bin, +) + +from .api.menu import ( + FlameMenuProjectConnect, + FlameMenuTimeline +) + +from .api.workio import ( + open_file, + save_file, + current_file, + has_unsaved_changes, + file_extensions, + work_root +) + +import os + +HOST_DIR = os.path.dirname( + os.path.abspath(__file__) +) +API_DIR = os.path.join(HOST_DIR, "api") +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + +app_framework = None +apps = [] + + +__all__ = [ + "HOST_DIR", + "API_DIR", + "PLUGINS_DIR", + "PUBLISH_PATH", + "LOAD_PATH", + "CREATE_PATH", + "INVENTORY_PATH", + "INVENTORY_PATH", + + "app_framework", + "apps", + + # pipeline + "install", + "uninstall", + "ls", + "containerise", + "update_container", + "reload_pipeline", + "maintained_selection", + "remove_instance", + "list_instances", + "imprint", + + # utils + "setup", + + # lib + "FlameAppFramework", + "maintain_current_timeline", + "get_project_manager", + "get_current_project", + "get_current_timeline", + "create_bin", + + # menu + "FlameMenuProjectConnect", + "FlameMenuTimeline", + + # plugin + + # workio + "open_file", + "save_file", + "current_file", + "has_unsaved_changes", + "file_extensions", + "work_root" +] diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py new file mode 100644 index 0000000000..50a6b3f098 --- /dev/null +++ b/openpype/hosts/flame/api/__init__.py @@ -0,0 +1,3 @@ +""" +OpenPype Autodesk Flame api +""" diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py new file mode 100644 index 0000000000..48331dcbc2 --- /dev/null +++ b/openpype/hosts/flame/api/lib.py @@ -0,0 +1,276 @@ +import sys +import os +import pickle +import contextlib +from pprint import pformat + +from openpype.api import Logger + +log = Logger().get_logger(__name__) + + +@contextlib.contextmanager +def io_preferences_file(klass, filepath, write=False): + try: + flag = "w" if write else "r" + yield open(filepath, flag) + + except IOError as _error: + klass.log.info("Unable to work with preferences `{}`: {}".format( + filepath, _error)) + + +class FlameAppFramework(object): + # flameAppFramework class takes care of preferences + + class prefs_dict(dict): + + def __init__(self, master, name, **kwargs): + self.name = name + self.master = master + if not self.master.get(self.name): + self.master[self.name] = {} + self.master[self.name].__init__() + + def __getitem__(self, k): + return self.master[self.name].__getitem__(k) + + def __setitem__(self, k, v): + return self.master[self.name].__setitem__(k, v) + + def __delitem__(self, k): + return self.master[self.name].__delitem__(k) + + def get(self, k, default=None): + return self.master[self.name].get(k, default) + + def setdefault(self, k, default=None): + return self.master[self.name].setdefault(k, default) + + def pop(self, k, v=object()): + if v is object(): + return self.master[self.name].pop(k) + return self.master[self.name].pop(k, v) + + def update(self, mapping=(), **kwargs): + self.master[self.name].update(mapping, **kwargs) + + def __contains__(self, k): + return self.master[self.name].__contains__(k) + + def copy(self): # don"t delegate w/ super - dict.copy() -> dict :( + return type(self)(self) + + def keys(self): + return self.master[self.name].keys() + + @classmethod + def fromkeys(cls, keys, v=None): + return cls.master[cls.name].fromkeys(keys, v) + + def __repr__(self): + return "{0}({1})".format( + type(self).__name__, self.master[self.name].__repr__()) + + def master_keys(self): + return self.master.keys() + + def __init__(self): + self.name = self.__class__.__name__ + self.bundle_name = "OpenPypeFlame" + # self.prefs scope is limited to flame project and user + self.prefs = {} + self.prefs_user = {} + self.prefs_global = {} + self.log = log + + try: + import flame + self.flame = flame + self.flame_project_name = self.flame.project.current_project.name + self.flame_user_name = flame.users.current_user.name + except Exception: + self.flame = None + self.flame_project_name = None + self.flame_user_name = None + + import socket + self.hostname = socket.gethostname() + + if sys.platform == "darwin": + self.prefs_folder = os.path.join( + os.path.expanduser("~"), + "Library", + "Caches", + "OpenPype", + self.bundle_name + ) + elif sys.platform.startswith("linux"): + self.prefs_folder = os.path.join( + os.path.expanduser("~"), + ".OpenPype", + self.bundle_name) + + self.prefs_folder = os.path.join( + self.prefs_folder, + self.hostname, + ) + + self.log.info("[{}] waking up".format(self.__class__.__name__)) + self.load_prefs() + + # menu auto-refresh defaults + + if not self.prefs_global.get("menu_auto_refresh"): + self.prefs_global["menu_auto_refresh"] = { + "media_panel": True, + "batch": True, + "main_menu": True, + "timeline_menu": True + } + + self.apps = [] + + def get_pref_file_paths(self): + + prefix = self.prefs_folder + os.path.sep + self.bundle_name + prefs_file_path = "_".join([ + prefix, self.flame_user_name, + self.flame_project_name]) + ".prefs" + prefs_user_file_path = "_".join([ + prefix, self.flame_user_name]) + ".prefs" + prefs_global_file_path = prefix + ".prefs" + + return (prefs_file_path, prefs_user_file_path, prefs_global_file_path) + + def load_prefs(self): + + (proj_pref_path, user_pref_path, + glob_pref_path) = self.get_pref_file_paths() + + with io_preferences_file(self, proj_pref_path) as prefs_file: + self.prefs = pickle.load(prefs_file) + self.log.info( + "Project - preferences contents:\n{}".format( + pformat(self.prefs) + )) + + with io_preferences_file(self, user_pref_path) as prefs_file: + self.prefs_user = pickle.load(prefs_file) + self.log.info( + "User - preferences contents:\n{}".format( + pformat(self.prefs_user) + )) + + with io_preferences_file(self, glob_pref_path) as prefs_file: + self.prefs_global = pickle.load(prefs_file) + self.log.info( + "Global - preferences contents:\n{}".format( + pformat(self.prefs_global) + )) + + return True + + def save_prefs(self): + # make sure the preference folder is available + if not os.path.isdir(self.prefs_folder): + try: + os.makedirs(self.prefs_folder) + except Exception: + self.log.info("Unable to create folder {}".format( + self.prefs_folder)) + return False + + # get all pref file paths + (proj_pref_path, user_pref_path, + glob_pref_path) = self.get_pref_file_paths() + + with io_preferences_file(self, proj_pref_path, True) as prefs_file: + pickle.dump(self.prefs, prefs_file) + self.log.info( + "Project - preferences contents:\n{}".format( + pformat(self.prefs) + )) + + with io_preferences_file(self, user_pref_path, True) as prefs_file: + pickle.dump(self.prefs_user, prefs_file) + self.log.info( + "User - preferences contents:\n{}".format( + pformat(self.prefs_user) + )) + + with io_preferences_file(self, glob_pref_path, True) as prefs_file: + pickle.dump(self.prefs_global, prefs_file) + self.log.info( + "Global - preferences contents:\n{}".format( + pformat(self.prefs_global) + )) + + return True + + +@contextlib.contextmanager +def maintain_current_timeline(to_timeline, from_timeline=None): + """Maintain current timeline selection during context + + Attributes: + from_timeline (resolve.Timeline)[optional]: + Example: + >>> print(from_timeline.GetName()) + timeline1 + >>> print(to_timeline.GetName()) + timeline2 + + >>> with maintain_current_timeline(to_timeline): + ... print(get_current_timeline().GetName()) + timeline2 + + >>> print(get_current_timeline().GetName()) + timeline1 + """ + # todo: this is still Resolve's implementation + project = get_current_project() + working_timeline = from_timeline or project.GetCurrentTimeline() + + # swith to the input timeline + project.SetCurrentTimeline(to_timeline) + + try: + # do a work + yield + finally: + # put the original working timeline to context + project.SetCurrentTimeline(working_timeline) + + +def get_project_manager(): + # TODO: get_project_manager + return + + +def get_media_storage(): + # TODO: get_media_storage + return + + +def get_current_project(): + # TODO: get_current_project + return + + +def get_current_timeline(new=False): + # TODO: get_current_timeline + return + + +def create_bin(name, root=None): + # TODO: create_bin + return + + +def rescan_hooks(): + import flame + try: + flame.execute_shortcut('Rescan Python Hooks') + except Exception: + pass diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py new file mode 100644 index 0000000000..b4f1728acf --- /dev/null +++ b/openpype/hosts/flame/api/menu.py @@ -0,0 +1,208 @@ +import os +from Qt import QtWidgets +from copy import deepcopy + +from openpype.tools.utils.host_tools import HostToolsHelper + + +menu_group_name = 'OpenPype' + +default_flame_export_presets = { + 'Publish': { + 'PresetVisibility': 2, + 'PresetType': 0, + 'PresetFile': 'OpenEXR/OpenEXR (16-bit fp PIZ).xml' + }, + 'Preview': { + 'PresetVisibility': 3, + 'PresetType': 2, + 'PresetFile': 'Generate Preview.xml' + }, + 'Thumbnail': { + 'PresetVisibility': 3, + 'PresetType': 0, + 'PresetFile': 'Generate Thumbnail.xml' + } +} + + +class _FlameMenuApp(object): + def __init__(self, framework): + self.name = self.__class__.__name__ + self.framework = framework + self.log = framework.log + self.menu_group_name = menu_group_name + self.dynamic_menu_data = {} + + # flame module is only avaliable when a + # flame project is loaded and initialized + self.flame = None + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + self.flame_project_name = flame.project.current_project.name + self.prefs = self.framework.prefs_dict(self.framework.prefs, self.name) + self.prefs_user = self.framework.prefs_dict( + self.framework.prefs_user, self.name) + self.prefs_global = self.framework.prefs_dict( + self.framework.prefs_global, self.name) + + self.mbox = QtWidgets.QMessageBox() + + self.menu = { + "actions": [{ + 'name': os.getenv("AVALON_PROJECT", "project"), + 'isEnabled': False + }], + "name": self.menu_group_name + } + self.tools_helper = HostToolsHelper() + + def __getattr__(self, name): + def method(*args, **kwargs): + print('calling %s' % name) + return method + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') + + +class FlameMenuProjectConnect(_FlameMenuApp): + + # flameMenuProjectconnect app takes care of the preferences dialog as well + + def __init__(self, framework): + _FlameMenuApp.__init__(self, framework) + + def __getattr__(self, name): + def method(*args, **kwargs): + project = self.dynamic_menu_data.get(name) + if project: + self.link_project(project) + return method + + def build_menu(self): + if not self.flame: + return [] + + flame_project_name = self.flame_project_name + self.log.info("______ {} ______".format(flame_project_name)) + + menu = deepcopy(self.menu) + + menu['actions'].append({ + "name": "Workfiles ...", + "execute": lambda x: self.tools_helper.show_workfiles() + }) + menu['actions'].append({ + "name": "Create ...", + "execute": lambda x: self.tools_helper.show_creator() + }) + menu['actions'].append({ + "name": "Publish ...", + "execute": lambda x: self.tools_helper.show_publish() + }) + menu['actions'].append({ + "name": "Load ...", + "execute": lambda x: self.tools_helper.show_loader() + }) + menu['actions'].append({ + "name": "Manage ...", + "execute": lambda x: self.tools_helper.show_scene_inventory() + }) + menu['actions'].append({ + "name": "Library ...", + "execute": lambda x: self.tools_helper.show_library_loader() + }) + return menu + + def get_projects(self, *args, **kwargs): + pass + + def refresh(self, *args, **kwargs): + self.rescan() + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') + + +class FlameMenuTimeline(_FlameMenuApp): + + # flameMenuProjectconnect app takes care of the preferences dialog as well + + def __init__(self, framework): + _FlameMenuApp.__init__(self, framework) + + def __getattr__(self, name): + def method(*args, **kwargs): + project = self.dynamic_menu_data.get(name) + if project: + self.link_project(project) + return method + + def build_menu(self): + if not self.flame: + return [] + + flame_project_name = self.flame_project_name + self.log.info("______ {} ______".format(flame_project_name)) + + menu = deepcopy(self.menu) + + menu['actions'].append({ + "name": "Create ...", + "execute": lambda x: self.tools_helper.show_creator() + }) + menu['actions'].append({ + "name": "Publish ...", + "execute": lambda x: self.tools_helper.show_publish() + }) + menu['actions'].append({ + "name": "Load ...", + "execute": lambda x: self.tools_helper.show_loader() + }) + menu['actions'].append({ + "name": "Manage ...", + "execute": lambda x: self.tools_helper.show_scene_inventory() + }) + + return menu + + def get_projects(self, *args, **kwargs): + pass + + def refresh(self, *args, **kwargs): + self.rescan() + + def rescan(self, *args, **kwargs): + if not self.flame: + try: + import flame + self.flame = flame + except ImportError: + self.flame = None + + if self.flame: + self.flame.execute_shortcut('Rescan Python Hooks') + self.log.info('Rescan Python Hooks') diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py new file mode 100644 index 0000000000..26dfe7c032 --- /dev/null +++ b/openpype/hosts/flame/api/pipeline.py @@ -0,0 +1,155 @@ +""" +Basic avalon integration +""" +import contextlib +from avalon import api as avalon +from pyblish import api as pyblish +from openpype.api import Logger + +AVALON_CONTAINERS = "AVALON_CONTAINERS" + +log = Logger().get_logger(__name__) + + +def install(): + from .. import ( + PUBLISH_PATH, + LOAD_PATH, + CREATE_PATH, + INVENTORY_PATH + ) + # TODO: install + + # Disable all families except for the ones we explicitly want to see + family_states = [ + "imagesequence", + "render2d", + "plate", + "render", + "mov", + "clip" + ] + avalon.data["familiesStateDefault"] = False + avalon.data["familiesStateToggled"] = family_states + + log.info("openpype.hosts.flame installed") + + pyblish.register_host("flame") + pyblish.register_plugin_path(PUBLISH_PATH) + log.info("Registering Flame plug-ins..") + + avalon.register_plugin_path(avalon.Loader, LOAD_PATH) + avalon.register_plugin_path(avalon.Creator, CREATE_PATH) + avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # register callback for switching publishable + pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) + + +def uninstall(): + from .. import ( + PUBLISH_PATH, + LOAD_PATH, + CREATE_PATH, + INVENTORY_PATH + ) + + # TODO: uninstall + pyblish.deregister_host("flame") + pyblish.deregister_plugin_path(PUBLISH_PATH) + log.info("Deregistering DaVinci Resovle plug-ins..") + + avalon.deregister_plugin_path(avalon.Loader, LOAD_PATH) + avalon.deregister_plugin_path(avalon.Creator, CREATE_PATH) + avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) + + # register callback for switching publishable + pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) + + +def containerise(tl_segment, + name, + namespace, + context, + loader=None, + data=None): + # TODO: containerise + pass + + +def ls(): + """List available containers. + """ + # TODO: ls + pass + + +def parse_container(tl_segment, validate=True): + """Return container data from timeline_item's openpype tag. + """ + # TODO: parse_container + pass + + +def update_container(tl_segment, data=None): + """Update container data to input timeline_item's openpype tag. + """ + # TODO: update_container + pass + + +@contextlib.contextmanager +def maintained_selection(): + """Maintain selection during context + + Example: + >>> with maintained_selection(): + ... node['selected'].setValue(True) + >>> print(node['selected'].value()) + False + """ + # TODO: maintained_selection + remove undo steps + + try: + # do the operation + yield + finally: + pass + + +def reset_selection(): + """Deselect all selected nodes + """ + pass + + +def on_pyblish_instance_toggled(instance, old_value, new_value): + """Toggle node passthrough states on instance toggles.""" + + log.info("instance toggle: {}, old_value: {}, new_value:{} ".format( + instance, old_value, new_value)) + + # from openpype.hosts.resolve import ( + # set_publish_attribute + # ) + + # # Whether instances should be passthrough based on new value + # timeline_item = instance.data["item"] + # set_publish_attribute(timeline_item, new_value) + + +def remove_instance(instance): + """Remove instance marker from track item.""" + # TODO: remove_instance + pass + + +def list_instances(): + """List all created instances from current workfile.""" + # TODO: list_instances + pass + + +def imprint(item, data=None): + # TODO: imprint + pass diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py new file mode 100644 index 0000000000..2a28a20a75 --- /dev/null +++ b/openpype/hosts/flame/api/plugin.py @@ -0,0 +1,3 @@ +# Creator plugin functions +# Publishing plugin functions +# Loader plugin functions diff --git a/openpype/hosts/flame/api/utils.py b/openpype/hosts/flame/api/utils.py new file mode 100644 index 0000000000..a750046362 --- /dev/null +++ b/openpype/hosts/flame/api/utils.py @@ -0,0 +1,108 @@ +""" +Flame utils for syncing scripts +""" + +import os +import shutil +from openpype.api import Logger +log = Logger().get_logger(__name__) + + +def _sync_utility_scripts(env=None): + """ Synchronizing basic utlility scripts for flame. + + To be able to run start OpenPype within Flame we have to copy + all utility_scripts and additional FLAME_SCRIPT_DIR into + `/opt/Autodesk/shared/python`. This will be always synchronizing those + folders. + """ + from .. import HOST_DIR + + env = env or os.environ + + # initiate inputs + scripts = {} + fsd_env = env.get("FLAME_SCRIPT_DIRS", "") + flame_shared_dir = "/opt/Autodesk/shared/python" + + fsd_paths = [os.path.join( + HOST_DIR, + "utility_scripts" + )] + + # collect script dirs + log.info("FLAME_SCRIPT_DIRS: `{fsd_env}`".format(**locals())) + log.info("fsd_paths: `{fsd_paths}`".format(**locals())) + + # add application environment setting for FLAME_SCRIPT_DIR + # to script path search + for _dirpath in fsd_env.split(os.pathsep): + if not os.path.isdir(_dirpath): + log.warning("Path is not a valid dir: `{_dirpath}`".format( + **locals())) + continue + fsd_paths.append(_dirpath) + + # collect scripts from dirs + for path in fsd_paths: + scripts.update({path: os.listdir(path)}) + + remove_black_list = [] + for _k, s_list in scripts.items(): + remove_black_list += s_list + + log.info("remove_black_list: `{remove_black_list}`".format(**locals())) + log.info("Additional Flame script paths: `{fsd_paths}`".format(**locals())) + log.info("Flame Scripts: `{scripts}`".format(**locals())) + + # make sure no script file is in folder + if next(iter(os.listdir(flame_shared_dir)), None): + for _itm in os.listdir(flame_shared_dir): + skip = False + + # skip all scripts and folders which are not maintained + if _itm not in remove_black_list: + skip = True + + # do not skyp if pyc in extension + if not os.path.isdir(_itm) and "pyc" in os.path.splitext(_itm)[-1]: + skip = False + + # continue if skip in true + if skip: + continue + + path = os.path.join(flame_shared_dir, _itm) + log.info("Removing `{path}`...".format(**locals())) + if os.path.isdir(path): + shutil.rmtree(path, onerror=None) + else: + os.remove(path) + + # copy scripts into Resolve's utility scripts dir + for dirpath, scriptlist in scripts.items(): + # directory and scripts list + for _script in scriptlist: + # script in script list + src = os.path.join(dirpath, _script) + dst = os.path.join(flame_shared_dir, _script) + log.info("Copying `{src}` to `{dst}`...".format(**locals())) + if os.path.isdir(src): + shutil.copytree( + src, dst, symlinks=False, + ignore=None, ignore_dangling_symlinks=False + ) + else: + shutil.copy2(src, dst) + + +def setup(env=None): + """ Wrapper installer started from + `flame/hooks/pre_flame_setup.py` + """ + env = env or os.environ + + # synchronize resolve utility scripts + _sync_utility_scripts(env) + + log.info("Flame OpenPype wrapper has been installed") diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py new file mode 100644 index 0000000000..d2e2408798 --- /dev/null +++ b/openpype/hosts/flame/api/workio.py @@ -0,0 +1,37 @@ +"""Host API required Work Files tool""" + +import os +from openpype.api import Logger +# from .. import ( +# get_project_manager, +# get_current_project +# ) + + +log = Logger().get_logger(__name__) + +exported_projet_ext = ".otoc" + + +def file_extensions(): + return [exported_projet_ext] + + +def has_unsaved_changes(): + pass + + +def save_file(filepath): + pass + + +def open_file(filepath): + pass + + +def current_file(): + pass + + +def work_root(session): + return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py new file mode 100644 index 0000000000..368a70f395 --- /dev/null +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -0,0 +1,132 @@ +import os +import json +import tempfile +import contextlib +from openpype.lib import ( + PreLaunchHook, get_openpype_username) +from openpype.hosts import flame as opflame +import openpype +from pprint import pformat + + +class FlamePrelaunch(PreLaunchHook): + """ Flame prelaunch hook + + Will make sure flame_script_dirs are coppied to user's folder defined + in environment var FLAME_SCRIPT_DIR. + """ + app_groups = ["flame"] + + # todo: replace version number with avalon launch app version + flame_python_exe = "/opt/Autodesk/python/2021/bin/python2.7" + + wtc_script_path = os.path.join( + opflame.HOST_DIR, "scripts", "wiretap_com.py") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.signature = "( {} )".format(self.__class__.__name__) + + def execute(self): + """Hook entry method.""" + project_doc = self.data["project_doc"] + user_name = get_openpype_username() + + self.log.debug("Collected user \"{}\"".format(user_name)) + self.log.info(pformat(project_doc)) + _db_p_data = project_doc["data"] + width = _db_p_data["resolutionWidth"] + height = _db_p_data["resolutionHeight"] + fps = int(_db_p_data["fps"]) + + project_data = { + "Name": project_doc["name"], + "Nickname": _db_p_data["code"], + "Description": "Created by OpenPype", + "SetupDir": project_doc["name"], + "FrameWidth": int(width), + "FrameHeight": int(height), + "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]), + "FrameRate": "{} fps".format(fps), + "FrameDepth": "16-bit fp", + "FieldDominance": "PROGRESSIVE" + } + + data_to_script = { + # from settings + "host_name": "localhost", + "volume_name": "stonefs", + "group_name": "staff", + "color_policy": "ACES 1.1", + + # from project + "project_name": project_doc["name"], + "user_name": user_name, + "project_data": project_data + } + app_arguments = self._get_launch_arguments(data_to_script) + + self.log.info(pformat(dict(self.launch_context.env))) + + opflame.setup(self.launch_context.env) + + self.launch_context.launch_args.extend(app_arguments) + + def _get_launch_arguments(self, script_data): + # Dump data to string + dumped_script_data = json.dumps(script_data) + + with make_temp_file(dumped_script_data) as tmp_json_path: + # Prepare subprocess arguments + args = [ + self.flame_python_exe, + self.wtc_script_path, + tmp_json_path + ] + self.log.info("Executing: {}".format(" ".join(args))) + + process_kwargs = { + "logger": self.log, + "env": {} + } + + openpype.api.run_subprocess(args, **process_kwargs) + + # process returned json file to pass launch args + return_json_data = open(tmp_json_path).read() + returned_data = json.loads(return_json_data) + app_args = returned_data.get("app_args") + self.log.info("____ app_args: `{}`".format(app_args)) + + if not app_args: + RuntimeError("App arguments were not solved") + + return app_args + + +@contextlib.contextmanager +def make_temp_file(data): + try: + # Store dumped json to temporary file + temporary_json_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) + temporary_json_file.write(data) + temporary_json_file.close() + temporary_json_filepath = temporary_json_file.name.replace( + "\\", "/" + ) + + yield temporary_json_filepath + + except IOError as _error: + raise IOError( + "Not able to create temp json file: {}".format( + _error + ) + ) + + finally: + # Remove the temporary json + os.remove(temporary_json_filepath) diff --git a/openpype/hosts/flame/scripts/wiretap_com.py b/openpype/hosts/flame/scripts/wiretap_com.py new file mode 100644 index 0000000000..d8dc1884cf --- /dev/null +++ b/openpype/hosts/flame/scripts/wiretap_com.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python2.7 +# -*- coding: utf-8 -*- + +from __future__ import absolute_import +import os +import sys +import subprocess +import json +import xml.dom.minidom as minidom +from copy import deepcopy +import datetime + +try: + from libwiretapPythonClientAPI import ( + WireTapClientInit) +except ImportError: + flame_python_path = "/opt/Autodesk/flame_2021/python" + flame_exe_path = ( + "/opt/Autodesk/flame_2021/bin/flame.app" + "/Contents/MacOS/startApp") + + sys.path.append(flame_python_path) + + from libwiretapPythonClientAPI import ( + WireTapClientInit, + WireTapClientUninit, + WireTapNodeHandle, + WireTapServerHandle, + WireTapInt, + WireTapStr + ) + + +class WireTapCom(object): + """ + Comunicator class wrapper for talking to WireTap db. + + This way we are able to set new project with settings and + correct colorspace policy. Also we are able to create new user + or get actuall user with similar name (users are usually cloning + their profiles and adding date stamp into suffix). + """ + + def __init__(self, host_name=None, volume_name=None, group_name=None): + """Initialisation of WireTap communication class + + Args: + host_name (str, optional): Name of host server. Defaults to None. + volume_name (str, optional): Name of volume. Defaults to None. + group_name (str, optional): Name of user group. Defaults to None. + """ + # set main attributes of server + # if there are none set the default installation + self.host_name = host_name or "localhost" + self.volume_name = volume_name or "stonefs" + self.group_name = group_name or "staff" + + # initialize WireTap client + WireTapClientInit() + + # add the server to shared variable + self._server = WireTapServerHandle("{}:IFFFS".format(self.host_name)) + print("WireTap connected at '{}'...".format( + self.host_name)) + + def close(self): + self._server = None + WireTapClientUninit() + print("WireTap closed...") + + def get_launch_args( + self, project_name, project_data, user_name, *args, **kwargs): + """Forming launch arguments for OpenPype launcher. + + Args: + project_name (str): name of project + project_data (dict): Flame compatible project data + user_name (str): name of user + + Returns: + list: arguments + """ + + workspace_name = kwargs.get("workspace_name") + color_policy = kwargs.get("color_policy") + + self._project_prep(project_name) + self._set_project_settings(project_name, project_data) + self._set_project_colorspace(project_name, color_policy) + user_name = self._user_prep(user_name) + + if workspace_name is None: + # default workspace + print("Using a default workspace") + return [ + "--start-project={}".format(project_name), + "--start-user={}".format(user_name), + "--create-workspace" + ] + + else: + print( + "Using a custom workspace '{}'".format(workspace_name)) + + self._workspace_prep(project_name, workspace_name) + return [ + "--start-project={}".format(project_name), + "--start-user={}".format(user_name), + "--create-workspace", + "--start-workspace={}".format(workspace_name) + ] + + def _workspace_prep(self, project_name, workspace_name): + """Preparing a workspace + + In case it doesn not exists it will create one + + Args: + project_name (str): project name + workspace_name (str): workspace name + + Raises: + AttributeError: unable to create workspace + """ + workspace_exists = self._child_is_in_parent_path( + "/projects/{}".format(project_name), workspace_name, "WORKSPACE" + ) + if not workspace_exists: + project = WireTapNodeHandle( + self._server, "/projects/{}".format(project_name)) + + workspace_node = WireTapNodeHandle() + created_workspace = project.createNode( + workspace_name, "WORKSPACE", workspace_node) + + if not created_workspace: + raise AttributeError( + "Cannot create workspace `{}` in " + "project `{}`: `{}`".format( + workspace_name, project_name, project.lastError()) + ) + + print( + "Workspace `{}` is successfully created".format(workspace_name)) + + def _project_prep(self, project_name): + """Preparing a project + + In case it doesn not exists it will create one + + Args: + project_name (str): project name + + Raises: + AttributeError: unable to create project + """ + # test if projeft exists + project_exists = self._child_is_in_parent_path( + "/projects", project_name, "PROJECT") + + if not project_exists: + volumes = self._get_all_volumes() + + if len(volumes) == 0: + raise AttributeError( + "Not able to create new project. No Volumes existing" + ) + + # check if volumes exists + if self.volume_name not in volumes: + raise AttributeError( + ("Volume '{}' does not exist '{}'").format( + self.volume_name, volumes) + ) + + # form cmd arguments + project_create_cmd = [ + os.path.join( + "/opt/Autodesk/", + "wiretap", + "tools", + "2021", + "wiretap_create_node", + ), + '-n', + os.path.join("/volumes", self.volume_name), + '-d', + project_name, + '-g', + ] + + project_create_cmd.append(self.group_name) + + print(project_create_cmd) + + exit_code = subprocess.call( + project_create_cmd, + cwd=os.path.expanduser('~')) + + if exit_code != 0: + RuntimeError("Cannot create project in flame db") + + print( + "A new project '{}' is created.".format(project_name)) + + def _get_all_volumes(self): + """Request all available volumens from WireTap + + Returns: + list: all available volumes in server + + Rises: + AttributeError: unable to get any volumes childs from server + """ + root = WireTapNodeHandle(self._server, "/volumes") + children_num = WireTapInt(0) + + get_children_num = root.getNumChildren(children_num) + if not get_children_num: + raise AttributeError( + "Cannot get number of volumes: {}".format(root.lastError()) + ) + + volumes = [] + + # go trough all children and get volume names + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + + # get a child + if not root.getChild(child_idx, child_obj): + raise AttributeError( + "Unable to get child: {}".format(root.lastError())) + + node_name = WireTapStr() + get_children_name = child_obj.getDisplayName(node_name) + + if not get_children_name: + raise AttributeError( + "Unable to get child name: {}".format( + child_obj.lastError()) + ) + + volumes.append(node_name.c_str()) + + return volumes + + def _user_prep(self, user_name): + """Ensuring user does exists in user's stack + + Args: + user_name (str): name of a user + + Raises: + AttributeError: unable to create user + """ + + # get all used usernames in db + used_names = self._get_usernames() + print(">> used_names: {}".format(used_names)) + + # filter only those which are sharing input user name + filtered_users = [user for user in used_names if user_name in user] + + if filtered_users: + # todo: need to find lastly created following regex patern for + # date used in name + return filtered_users.pop() + + # create new user name with date in suffix + now = datetime.datetime.now() # current date and time + date = now.strftime("%Y%m%d") + new_user_name = "{}_{}".format(user_name, date) + print(new_user_name) + + if not self._child_is_in_parent_path("/users", new_user_name, "USER"): + # Create the new user + users = WireTapNodeHandle(self._server, "/users") + + user_node = WireTapNodeHandle() + created_user = users.createNode(new_user_name, "USER", user_node) + if not created_user: + raise AttributeError( + "User {} cannot be created: {}".format( + new_user_name, users.lastError()) + ) + + print("User `{}` is created".format(new_user_name)) + return new_user_name + + def _get_usernames(self): + """Requesting all available users from WireTap + + Returns: + list: all available user names + + Raises: + AttributeError: there are no users in server + """ + root = WireTapNodeHandle(self._server, "/users") + children_num = WireTapInt(0) + + get_children_num = root.getNumChildren(children_num) + if not get_children_num: + raise AttributeError( + "Cannot get number of volumes: {}".format(root.lastError()) + ) + + usernames = [] + + # go trough all children and get volume names + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + + # get a child + if not root.getChild(child_idx, child_obj): + raise AttributeError( + "Unable to get child: {}".format(root.lastError())) + + node_name = WireTapStr() + get_children_name = child_obj.getDisplayName(node_name) + + if not get_children_name: + raise AttributeError( + "Unable to get child name: {}".format( + child_obj.lastError()) + ) + + usernames.append(node_name.c_str()) + + return usernames + + def _child_is_in_parent_path(self, parent_path, child_name, child_type): + """Checking if a given child is in parent path. + + Args: + parent_path (str): db path to parent + child_name (str): name of child + child_type (str): type of child + + Raises: + AttributeError: Not able to get number of children + AttributeError: Not able to get children form parent + AttributeError: Not able to get children name + AttributeError: Not able to get children type + + Returns: + bool: True if child is in parent path + """ + parent = WireTapNodeHandle(self._server, parent_path) + + # iterate number of children + children_num = WireTapInt(0) + requested = parent.getNumChildren(children_num) + if not requested: + raise AttributeError(( + "Error: Cannot request number of " + "childrens from the node {}. Make sure your " + "wiretap service is running: {}").format( + parent_path, parent.lastError()) + ) + + # iterate children + child_obj = WireTapNodeHandle() + for child_idx in range(children_num): + if not parent.getChild(child_idx, child_obj): + raise AttributeError( + "Cannot get child: {}".format( + parent.lastError())) + + node_name = WireTapStr() + node_type = WireTapStr() + + if not child_obj.getDisplayName(node_name): + raise AttributeError( + "Unable to get child name: %s" % child_obj.lastError() + ) + if not child_obj.getNodeTypeStr(node_type): + raise AttributeError( + "Unable to obtain child type: %s" % child_obj.lastError() + ) + + if (node_name.c_str() == child_name) and ( + node_type.c_str() == child_type): + return True + + return False + + def _set_project_settings(self, project_name, project_data): + """Setting project attributes. + + Args: + project_name (str): name of project + project_data (dict): data with project attributes + (flame compatible) + + Raises: + AttributeError: Not able to set project attributes + """ + # generated xml from project_data dict + _xml = "" + for key, value in project_data.items(): + _xml += "<{}>{}".format(key, value, key) + _xml += "" + + pretty_xml = minidom.parseString(_xml).toprettyxml() + print("__ xml: {}".format(pretty_xml)) + + # set project data to wiretap + project_node = WireTapNodeHandle( + self._server, "/projects/{}".format(project_name)) + + if not project_node.setMetaData("XML", _xml): + raise AttributeError( + "Not able to set project attributes {}. Error: {}".format( + project_name, project_node.lastError()) + ) + + print("Project settings successfully set.") + + def _set_project_colorspace(self, project_name, color_policy): + """Set project's colorspace policy. + + Args: + project_name (str): name of project + color_policy (str): name of policy + + Raises: + RuntimeError: Not able to set colorspace policy + """ + color_policy = color_policy or "Legacy" + project_colorspace_cmd = [ + os.path.join( + "/opt/Autodesk/", + "wiretap", + "tools", + "2021", + "wiretap_duplicate_node", + ), + "-s", + "/syncolor/policies/Autodesk/{}".format(color_policy), + "-n", + "/projects/{}/syncolor".format(project_name) + ] + + print(project_colorspace_cmd) + + exit_code = subprocess.call( + project_colorspace_cmd, + cwd=os.path.expanduser('~')) + + if exit_code != 0: + RuntimeError("Cannot set colorspace {} on project {}".format( + color_policy, project_name + )) + + +if __name__ == "__main__": + # get json exchange data + json_path = sys.argv[-1] + json_data = open(json_path).read() + in_data = json.loads(json_data) + out_data = deepcopy(in_data) + + # get main server attributes + host_name = in_data.pop("host_name") + volume_name = in_data.pop("volume_name") + group_name = in_data.pop("group_name") + + # initialize class + wiretap_handler = WireTapCom(host_name, volume_name, group_name) + + try: + app_args = wiretap_handler.get_launch_args( + project_name=in_data.pop("project_name"), + project_data=in_data.pop("project_data"), + user_name=in_data.pop("user_name"), + **in_data + ) + finally: + wiretap_handler.close() + + # set returned args back to out data + out_data.update({ + "app_args": app_args + }) + + # write it out back to the exchange json file + with open(json_path, "w") as file_stream: + json.dump(out_data, file_stream, indent=4) diff --git a/openpype/hosts/flame/utility_scripts/openpype_in_flame.py b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py new file mode 100644 index 0000000000..c5fa881f3c --- /dev/null +++ b/openpype/hosts/flame/utility_scripts/openpype_in_flame.py @@ -0,0 +1,191 @@ +from __future__ import print_function +import sys +from Qt import QtWidgets +from pprint import pformat +import atexit +import openpype +import avalon +import openpype.hosts.flame as opflame + +flh = sys.modules[__name__] +flh._project = None + + +def openpype_install(): + """Registering OpenPype in context + """ + openpype.install() + avalon.api.install(opflame) + print("Avalon registred hosts: {}".format( + avalon.api.registered_host())) + + +# Exception handler +def exeption_handler(exctype, value, _traceback): + """Exception handler for improving UX + + Args: + exctype (str): type of exception + value (str): exception value + tb (str): traceback to show + """ + import traceback + msg = "OpenPype: Python exception {} in {}".format(value, exctype) + mbox = QtWidgets.QMessageBox() + mbox.setText(msg) + mbox.setDetailedText( + pformat(traceback.format_exception(exctype, value, _traceback))) + mbox.setStyleSheet('QLabel{min-width: 800px;}') + mbox.exec_() + sys.__excepthook__(exctype, value, _traceback) + + +# add exception handler into sys module +sys.excepthook = exeption_handler + + +# register clean up logic to be called at Flame exit +def cleanup(): + """Cleaning up Flame framework context + """ + if opflame.apps: + print('`{}` cleaning up apps:\n {}\n'.format( + __file__, pformat(opflame.apps))) + while len(opflame.apps): + app = opflame.apps.pop() + print('`{}` removing : {}'.format(__file__, app.name)) + del app + opflame.apps = [] + + if opflame.app_framework: + print('PYTHON\t: %s cleaning up' % opflame.app_framework.bundle_name) + opflame.app_framework.save_prefs() + opflame.app_framework = None + + +atexit.register(cleanup) + + +def load_apps(): + """Load available apps into Flame framework + """ + opflame.apps.append(opflame.FlameMenuProjectConnect(opflame.app_framework)) + opflame.apps.append(opflame.FlameMenuTimeline(opflame.app_framework)) + opflame.app_framework.log.info("Apps are loaded") + + +def project_changed_dict(info): + """Hook for project change action + + Args: + info (str): info text + """ + cleanup() + + +def app_initialized(parent=None): + """Inicialization of Framework + + Args: + parent (obj, optional): Parent object. Defaults to None. + """ + opflame.app_framework = opflame.FlameAppFramework() + + print("{} initializing".format( + opflame.app_framework.bundle_name)) + + load_apps() + + +""" +Initialisation of the hook is starting from here + +First it needs to test if it can import the flame modul. +This will happen only in case a project has been loaded. +Then `app_initialized` will load main Framework which will load +all menu objects as apps. +""" + +try: + import flame # noqa + app_initialized(parent=None) +except ImportError: + print("!!!! not able to import flame module !!!!") + + +def rescan_hooks(): + import flame # noqa + flame.execute_shortcut('Rescan Python Hooks') + + +def _build_app_menu(app_name): + """Flame menu object generator + + Args: + app_name (str): name of menu object app + + Returns: + list: menu object + """ + menu = [] + + # first find the relative appname + app = None + for _app in opflame.apps: + if _app.__class__.__name__ == app_name: + app = _app + + if app: + menu.append(app.build_menu()) + + if opflame.app_framework: + menu_auto_refresh = opflame.app_framework.prefs_global.get( + 'menu_auto_refresh', {}) + if menu_auto_refresh.get('timeline_menu', True): + try: + import flame # noqa + flame.schedule_idle_event(rescan_hooks) + except ImportError: + print("!-!!! not able to import flame module !!!!") + + return menu + + +""" Flame hooks are starting here +""" + + +def project_saved(project_name, save_time, is_auto_save): + """Hook to activate when project is saved + + Args: + project_name (str): name of project + save_time (str): time when it was saved + is_auto_save (bool): autosave is on or off + """ + if opflame.app_framework: + opflame.app_framework.save_prefs() + + +def get_main_menu_custom_ui_actions(): + """Hook to create submenu in start menu + + Returns: + list: menu object + """ + # install openpype and the host + openpype_install() + + return _build_app_menu("FlameMenuProjectConnect") + + +def get_timeline_custom_ui_actions(): + """Hook to create submenu in timeline + + Returns: + list: menu object + """ + # install openpype and the host + openpype_install() + + return _build_app_menu("FlameMenuTimeline") diff --git a/openpype/hosts/fusion/api/__init__.py b/openpype/hosts/fusion/api/__init__.py index 5581a0a9cb..5aee978c15 100644 --- a/openpype/hosts/fusion/api/__init__.py +++ b/openpype/hosts/fusion/api/__init__.py @@ -1,8 +1,6 @@ from .pipeline import ( install, - uninstall, - publish, - launch_workfiles_app + uninstall ) from .utils import ( @@ -22,12 +20,9 @@ __all__ = [ # pipeline "install", "uninstall", - "publish", - "launch_workfiles_app", # utils "setup", - "get_resolve_module", # lib "get_additional_data", diff --git a/openpype/hosts/fusion/api/menu.py b/openpype/hosts/fusion/api/menu.py index 9093aa9e5e..5d2efb4911 100644 --- a/openpype/hosts/fusion/api/menu.py +++ b/openpype/hosts/fusion/api/menu.py @@ -3,19 +3,7 @@ import sys from Qt import QtWidgets, QtCore -from .pipeline import ( - publish, - launch_workfiles_app -) - -from avalon.tools import ( - creator, - sceneinventory, -) -from openpype.tools import ( - loader, - libraryloader -) +from openpype.tools.utils import host_tools from openpype.hosts.fusion.scripts import ( set_rendermode, @@ -36,7 +24,7 @@ def load_stylesheet(): class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) + super(Spacer, self).__init__(*args, **kwargs) self.setFixedHeight(height) @@ -53,7 +41,7 @@ class Spacer(QtWidgets.QWidget): class OpenPypeMenu(QtWidgets.QWidget): def __init__(self, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) + super(OpenPypeMenu, self).__init__(*args, **kwargs) self.setObjectName("OpenPypeMenu") @@ -117,27 +105,27 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_workfile_clicked(self): print("Clicked Workfile") - launch_workfiles_app() + host_tools.show_workfiles() def on_create_clicked(self): print("Clicked Create") - creator.show() + host_tools.show_creator() def on_publish_clicked(self): print("Clicked Publish") - publish(None) + host_tools.show_publish() def on_load_clicked(self): print("Clicked Load") - loader.show(use_context=True) + host_tools.show_loader(use_context=True) def on_inventory_clicked(self): print("Clicked Inventory") - sceneinventory.show() + host_tools.show_scene_inventory() def on_libload_clicked(self): print("Clicked Library") - libraryloader.show() + host_tools.show_library_loader() def on_rendernode_clicked(self): from avalon import style diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 688e75f6fe..c721146830 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -3,7 +3,6 @@ Basic avalon integration """ import os -from openpype.tools import workfiles from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger @@ -98,14 +97,3 @@ def on_pyblish_instance_toggled(instance, new_value, old_value): current = attrs["TOOLB_PassThrough"] if current != passthrough: tool.SetAttrs({"TOOLB_PassThrough": passthrough}) - - -def launch_workfiles_app(*args): - workdir = os.environ["AVALON_WORKDIR"] - workfiles.show(workdir) - - -def publish(parent): - """Shorthand to publish from within host""" - from avalon.tools import publish - return publish.show(parent) diff --git a/openpype/hosts/harmony/api/__init__.py b/openpype/hosts/harmony/api/__init__.py index fd21725bd5..bcf7dffbe7 100644 --- a/openpype/hosts/harmony/api/__init__.py +++ b/openpype/hosts/harmony/api/__init__.py @@ -3,17 +3,14 @@ import os from pathlib import Path import logging -import re from openpype import lib -from openpype.api import (get_current_project_settings) import openpype.hosts.harmony import pyblish.api from avalon import io, harmony import avalon.api -import avalon.tools.sceneinventory log = logging.getLogger("openpype.hosts.harmony") diff --git a/openpype/hosts/hiero/api/menu.py b/openpype/hosts/hiero/api/menu.py index bcd78aa5bb..e3de220777 100644 --- a/openpype/hosts/hiero/api/menu.py +++ b/openpype/hosts/hiero/api/menu.py @@ -2,6 +2,7 @@ import os import sys import hiero.core from openpype.api import Logger +from openpype.tools.utils import host_tools from avalon.api import Session from hiero.ui import findMenuAction @@ -41,8 +42,6 @@ def menu_install(): apply_colorspace_project, apply_colorspace_clips ) # here is the best place to add menu - from avalon.tools import creator, sceneinventory - from openpype.tools import loader from avalon.vendor.Qt import QtGui menu_name = os.environ['AVALON_LABEL'] @@ -87,15 +86,15 @@ def menu_install(): creator_action = menu.addAction("Create ...") creator_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) - creator_action.triggered.connect(creator.show) + creator_action.triggered.connect(host_tools.show_creator) loader_action = menu.addAction("Load ...") loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) - loader_action.triggered.connect(loader.show) + loader_action.triggered.connect(host_tools.show_loader) sceneinventory_action = menu.addAction("Manage ...") sceneinventory_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png")) - sceneinventory_action.triggered.connect(sceneinventory.show) + sceneinventory_action.triggered.connect(host_tools.show_scene_inventory) menu.addSeparator() if os.getenv("OPENPYPE_DEVELOP"): diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index 12f6923de7..6f6588e1be 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -4,13 +4,12 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon.tools import publish as _publish -from openpype.tools import workfiles from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api as avalon from avalon import schema from pyblish import api as pyblish from openpype.api import Logger +from openpype.tools.utils import host_tools from . import lib, menu, events log = Logger().get_logger(__name__) @@ -211,15 +210,13 @@ def update_container(track_item, data=None): def launch_workfiles_app(*args): ''' Wrapping function for workfiles launcher ''' - workdir = os.environ["AVALON_WORKDIR"] - # show workfile gui - workfiles.show(workdir) + host_tools.show_workfiles() def publish(parent): """Shorthand to publish from within host""" - return _publish.show(parent) + return host_tools.show_publish(parent) @contextlib.contextmanager diff --git a/openpype/hosts/houdini/api/plugin.py b/openpype/hosts/houdini/api/plugin.py index efdaa60084..63d9bba470 100644 --- a/openpype/hosts/houdini/api/plugin.py +++ b/openpype/hosts/houdini/api/plugin.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Houdini specific Avalon/Pyblish plugin definitions.""" import sys +from avalon.api import CreatorError from avalon import houdini import six @@ -8,7 +9,7 @@ import hou from openpype.api import PypeCreatorMixin -class OpenPypeCreatorError(Exception): +class OpenPypeCreatorError(CreatorError): pass diff --git a/openpype/hosts/houdini/api/usd.py b/openpype/hosts/houdini/api/usd.py index 850ffb60e5..6f808779ea 100644 --- a/openpype/hosts/houdini/api/usd.py +++ b/openpype/hosts/houdini/api/usd.py @@ -4,8 +4,8 @@ import contextlib import logging from Qt import QtCore, QtGui -from avalon.tools.widgets import AssetWidget -from avalon import style +from openpype.tools.utils.widgets import AssetWidget +from avalon import style, io from pxr import Sdf @@ -31,7 +31,7 @@ def pick_asset(node): # Construct the AssetWidget as a frameless popup so it automatically # closes when clicked outside of it. global tool - tool = AssetWidget(silo_creatable=False) + tool = AssetWidget(io) tool.setContentsMargins(5, 5, 5, 5) tool.setWindowTitle("Pick Asset") tool.setStyleSheet(style.load_stylesheet()) @@ -41,8 +41,6 @@ def pick_asset(node): # Select the current asset if there is any name = parm.eval() if name: - from avalon import io - db_asset = io.find_one({"name": name, "type": "asset"}) if db_asset: silo = db_asset.get("silo") diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py new file mode 100644 index 0000000000..2af1e4a257 --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +from openpype.hosts.houdini.api import plugin +from avalon.houdini import lib +from avalon import io +import hou + + +class CreateHDA(plugin.Creator): + """Publish Houdini Digital Asset file.""" + + name = "hda" + label = "Houdini Digital Asset (Hda)" + family = "hda" + icon = "gears" + maintain_selection = False + + def __init__(self, *args, **kwargs): + super(CreateHDA, self).__init__(*args, **kwargs) + self.data.pop("active", None) + + def _check_existing(self, subset_name): + # type: (str) -> bool + """Check if existing subset name versions already exists.""" + # Get all subsets of the current asset + asset_id = io.find_one({"name": self.data["asset"], "type": "asset"}, + projection={"_id": True})['_id'] + subset_docs = io.find( + { + "type": "subset", + "parent": asset_id + }, {"name": 1} + ) + existing_subset_names = set(subset_docs.distinct("name")) + existing_subset_names_low = { + _name.lower() for _name in existing_subset_names + } + return subset_name.lower() in existing_subset_names_low + + def _process(self, instance): + subset_name = self.data["subset"] + # get selected nodes + out = hou.node("/obj") + self.nodes = hou.selectedNodes() + + if (self.options or {}).get("useSelection") and self.nodes: + # if we have `use selection` enabled and we have some + # selected nodes ... + to_hda = self.nodes[0] + if len(self.nodes) > 1: + # if there is more then one node, create subnet first + subnet = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) + to_hda = subnet + else: + # in case of no selection, just create subnet node + subnet = out.createNode( + "subnet", node_name="{}_subnet".format(self.name)) + subnet.moveToGoodPosition() + to_hda = subnet + + if not to_hda.type().definition(): + # if node type has not its definition, it is not user + # created hda. We test if hda can be created from the node. + if not to_hda.canCreateDigitalAsset(): + raise Exception( + "cannot create hda from node {}".format(to_hda)) + + hda_node = to_hda.createDigitalAsset( + name=subset_name, + hda_file_name="$HIP/{}.hda".format(subset_name) + ) + hou.moveNodesTo(self.nodes, hda_node) + hda_node.layoutChildren() + else: + if self._check_existing(subset_name): + raise plugin.OpenPypeCreatorError( + ("subset {} is already published with different HDA" + "definition.").format(subset_name)) + hda_node = to_hda + + hda_node.setName(subset_name) + + # delete node created by Avalon in /out + # this needs to be addressed in future Houdini workflow refactor. + + hou.node("/out/{}".format(subset_name)).destroy() + + try: + lib.imprint(hda_node, self.data) + except hou.OperationFailed: + raise plugin.OpenPypeCreatorError( + ("Cannot set metadata on asset. Might be that it already is " + "OpenPype asset.") + ) + + return hda_node diff --git a/openpype/hosts/houdini/plugins/load/load_hda.py b/openpype/hosts/houdini/plugins/load/load_hda.py new file mode 100644 index 0000000000..6610d5e513 --- /dev/null +++ b/openpype/hosts/houdini/plugins/load/load_hda.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from avalon import api + +from avalon.houdini import pipeline + + +class HdaLoader(api.Loader): + """Load Houdini Digital Asset file.""" + + families = ["hda"] + label = "Load Hda" + representations = ["hda"] + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, data=None): + import os + import hou + + # Format file name, Houdini only wants forward slashes + file_path = os.path.normpath(self.fname) + file_path = file_path.replace("\\", "/") + + # Get the root node + obj = hou.node("/obj") + + # Create a unique name + counter = 1 + namespace = namespace or context["asset"]["name"] + formatted = "{}_{}".format(namespace, name) if namespace else name + node_name = "{0}_{1:03d}".format(formatted, counter) + + hou.hda.installFile(file_path) + hda_node = obj.createNode(name, node_name) + + self[:] = [hda_node] + + return pipeline.containerise( + node_name, + namespace, + [hda_node], + context, + self.__class__.__name__, + suffix="", + ) + + def update(self, container, representation): + import hou + + hda_node = container["node"] + file_path = api.get_representation_path(representation) + file_path = file_path.replace("\\", "/") + hou.hda.installFile(file_path) + defs = hda_node.type().allInstalledDefinitions() + def_paths = [d.libraryFilePath() for d in defs] + new = def_paths.index(file_path) + defs[new].setIsPreferred(True) + + def remove(self, container): + node = container["node"] + node.destroy() diff --git a/openpype/hosts/houdini/plugins/publish/collect_active_state.py b/openpype/hosts/houdini/plugins/publish/collect_active_state.py index 1193f0cd19..862d5720e1 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_active_state.py +++ b/openpype/hosts/houdini/plugins/publish/collect_active_state.py @@ -23,8 +23,10 @@ class CollectInstanceActiveState(pyblish.api.InstancePlugin): return # Check bypass state and reverse + active = True node = instance[0] - active = not node.isBypassed() + if hasattr(node, "isBypassed"): + active = not node.isBypassed() # Set instance active state instance.data.update( diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 1b36526783..ac081ac297 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -31,6 +31,7 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): nodes = hou.node("/out").children() + nodes += hou.node("/obj").children() # Include instances in USD stage only when it exists so it # remains backwards compatible with version before houdini 18 @@ -49,9 +50,12 @@ class CollectInstances(pyblish.api.ContextPlugin): has_family = node.evalParm("family") assert has_family, "'%s' is missing 'family'" % node.name() + self.log.info("processing {}".format(node)) + data = lib.read(node) # Check bypass state and reverse - data.update({"active": not node.isBypassed()}) + if hasattr(node, "isBypassed"): + data.update({"active": not node.isBypassed()}) # temporarily translation of `active` to `publish` till issue has # been resolved, https://github.com/pyblish/pyblish-base/issues/307 diff --git a/openpype/hosts/houdini/plugins/publish/extract_hda.py b/openpype/hosts/houdini/plugins/publish/extract_hda.py new file mode 100644 index 0000000000..301dd4e297 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_hda.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import os + +from pprint import pformat + +import pyblish.api +import openpype.api + + +class ExtractHDA(openpype.api.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract HDA" + hosts = ["houdini"] + families = ["hda"] + + def process(self, instance): + self.log.info(pformat(instance.data)) + hda_node = instance[0] + hda_def = hda_node.type().definition() + hda_options = hda_def.options() + hda_options.setSaveInitialParmsAndContents(True) + + next_version = instance.data["anatomyData"]["version"] + self.log.info("setting version: {}".format(next_version)) + hda_def.setVersion(str(next_version)) + hda_def.setOptions(hda_options) + hda_def.save(hda_def.libraryFilePath(), hda_node, hda_options) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + file = os.path.basename(hda_def.libraryFilePath()) + staging_dir = os.path.dirname(hda_def.libraryFilePath()) + self.log.info("Using HDA from {}".format(hda_def.libraryFilePath())) + + representation = { + 'name': 'hda', + 'ext': 'hda', + 'files': file, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_bypass.py b/openpype/hosts/houdini/plugins/publish/validate_bypass.py index 79c67c3008..fc4e18f701 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_bypass.py +++ b/openpype/hosts/houdini/plugins/publish/validate_bypass.py @@ -35,5 +35,5 @@ class ValidateBypassed(pyblish.api.InstancePlugin): def get_invalid(cls, instance): rop = instance[0] - if rop.isBypassed(): + if hasattr(rop, "isBypassed") and rop.isBypassed(): return [rop] diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index 76585085e2..2b556a2e75 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -7,24 +7,30 @@ @@ -32,9 +38,9 @@ cbsceneinventory.show() @@ -43,9 +49,10 @@ publish.show(parent) diff --git a/openpype/hosts/maya/api/__init__.py b/openpype/hosts/maya/api/__init__.py index 725a075417..0a8370eafc 100644 --- a/openpype/hosts/maya/api/__init__.py +++ b/openpype/hosts/maya/api/__init__.py @@ -8,7 +8,7 @@ from avalon import api as avalon from avalon import pipeline from avalon.maya import suspended_refresh from avalon.maya.pipeline import IS_HEADLESS -from openpype.tools import workfiles +from openpype.tools.utils import host_tools from pyblish import api as pyblish from openpype.lib import any_outdated import openpype.hosts.maya @@ -208,16 +208,12 @@ def on_init(_): launch_workfiles = os.environ.get("WORKFILES_STARTUP") if launch_workfiles: - safe_deferred(launch_workfiles_app) + safe_deferred(host_tools.show_workfiles) if not IS_HEADLESS: safe_deferred(override_toolbox_ui) -def launch_workfiles_app(): - workfiles.show(os.environ["AVALON_WORKDIR"]) - - def on_before_save(return_code, _): """Run validation for scene's FPS prior to saving""" return lib.validate_fps() @@ -279,8 +275,7 @@ def on_open(_): # Show outdated pop-up def _on_show_inventory(): - import avalon.tools.sceneinventory as tool - tool.show(parent=parent) + host_tools.show_scene_inventory(parent=parent) dialog = popup.Popup(parent=parent) dialog.setWindowTitle("Maya scene has outdated content") @@ -313,9 +308,15 @@ def on_task_changed(*args): lib.set_context_settings() lib.update_content_on_context_change() + msg = " project: {}\n asset: {}\n task:{}".format( + avalon.Session["AVALON_PROJECT"], + avalon.Session["AVALON_ASSET"], + avalon.Session["AVALON_TASK"] + ) + lib.show_message( "Context was changed", - ("Context was changed to {}".format(avalon.Session["AVALON_ASSET"])), + ("Context was changed to:\n{}".format(msg)), ) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index a84412963b..8474262626 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -1,10 +1,16 @@ """A set of commands that install overrides to Maya's UI""" +import os +import logging + +from functools import partial + import maya.cmds as mc import maya.mel as mel -from functools import partial -import os -import logging + +from avalon.maya import pipeline +from openpype.api import resources +from openpype.tools.utils import host_tools log = logging.getLogger(__name__) @@ -69,39 +75,8 @@ def override_component_mask_commands(): def override_toolbox_ui(): """Add custom buttons in Toolbox as replacement for Maya web help icon.""" - inventory = None - loader = None - launch_workfiles_app = None - mayalookassigner = None - try: - import avalon.tools.sceneinventory as inventory - except Exception: - log.warning("Could not import SceneInventory tool") - - try: - import openpype.tools.loader as loader - except Exception: - log.warning("Could not import Loader tool") - - try: - from avalon.maya.pipeline import launch_workfiles_app - except Exception: - log.warning("Could not import Workfiles tool") - - try: - from openpype.tools import mayalookassigner - except Exception: - log.warning("Could not import Maya Look assigner tool") - - from openpype.api import resources - icons = resources.get_resource("icons") - if not any(( - mayalookassigner, launch_workfiles_app, loader, inventory - )): - return - # Ensure the maya web icon on toolbox exists web_button = "ToolBox|MainToolboxLayout|mayaWebButton" if not mc.iconTextButton(web_button, query=True, exists=True): @@ -120,14 +95,23 @@ def override_toolbox_ui(): # Create our controls background_color = (0.267, 0.267, 0.267) controls = [] - if mayalookassigner: + look_assigner = None + try: + look_assigner = host_tools.get_tool_by_name( + "lookassigner", + parent=pipeline._parent + ) + except Exception: + log.warning("Couldn't create Look assigner window.", exc_info=True) + + if look_assigner is not None: controls.append( mc.iconTextButton( "pype_toolbox_lookmanager", annotation="Look Manager", label="Look Manager", image=os.path.join(icons, "lookmanager.png"), - command=lambda: mayalookassigner.show(), + command=host_tools.show_look_assigner, bgc=background_color, width=icon_size, height=icon_size, @@ -135,50 +119,53 @@ def override_toolbox_ui(): ) ) - if launch_workfiles_app: - controls.append( - mc.iconTextButton( - "pype_toolbox_workfiles", - annotation="Work Files", - label="Work Files", - image=os.path.join(icons, "workfiles.png"), - command=lambda: launch_workfiles_app(), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent - ) + controls.append( + mc.iconTextButton( + "pype_toolbox_workfiles", + annotation="Work Files", + label="Work Files", + image=os.path.join(icons, "workfiles.png"), + command=lambda: host_tools.show_workfiles( + parent=pipeline._parent + ), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent ) + ) - if loader: - controls.append( - mc.iconTextButton( - "pype_toolbox_loader", - annotation="Loader", - label="Loader", - image=os.path.join(icons, "loader.png"), - command=lambda: loader.show(use_context=True), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent - ) + controls.append( + mc.iconTextButton( + "pype_toolbox_loader", + annotation="Loader", + label="Loader", + image=os.path.join(icons, "loader.png"), + command=lambda: host_tools.show_loader( + parent=pipeline._parent, use_context=True + ), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent ) + ) - if inventory: - controls.append( - mc.iconTextButton( - "pype_toolbox_manager", - annotation="Inventory", - label="Inventory", - image=os.path.join(icons, "inventory.png"), - command=lambda: inventory.show(), - bgc=background_color, - width=icon_size, - height=icon_size, - parent=parent - ) + controls.append( + mc.iconTextButton( + "pype_toolbox_manager", + annotation="Inventory", + label="Inventory", + image=os.path.join(icons, "inventory.png"), + command=lambda: host_tools.show_scene_inventory( + parent=pipeline._parent + ), + bgc=background_color, + width=icon_size, + height=icon_size, + parent=parent ) + ) # Add the buttons on the bottom and stack # them above each other with side padding diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b24235447f..88c0525f3b 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2,6 +2,7 @@ import re import os +import platform import uuid import math @@ -22,6 +23,7 @@ import avalon.maya.lib import avalon.maya.interactive from openpype import lib +from openpype.api import get_anatomy_settings log = logging.getLogger(__name__) @@ -437,7 +439,8 @@ def empty_sets(sets, force=False): cmds.connectAttr(src, dest) # Restore original members - for origin_set, members in original.iteritems(): + _iteritems = getattr(original, "iteritems", original.items) + for origin_set, members in _iteritems(): cmds.sets(members, forceElement=origin_set) @@ -581,7 +584,7 @@ def get_shader_assignments_from_shapes(shapes, components=True): # Build a mapping from parent to shapes to include in lookup. transforms = {shape.rsplit("|", 1)[0]: shape for shape in shapes} - lookup = set(shapes + transforms.keys()) + lookup = set(shapes) | set(transforms.keys()) component_assignments = defaultdict(list) for shading_group in assignments.keys(): @@ -669,7 +672,8 @@ def displaySmoothness(nodes, yield finally: # Revert state - for node, state in originals.iteritems(): + _iteritems = getattr(originals, "iteritems", originals.items) + for node, state in _iteritems(): if state: cmds.displaySmoothness(node, **state) @@ -712,7 +716,8 @@ def no_display_layers(nodes): yield finally: # Restore original members - for layer, members in original.iteritems(): + _iteritems = getattr(original, "iteritems", original.items) + for layer, members in _iteritems(): cmds.editDisplayLayerMembers(layer, members, noRecurse=True) @@ -1819,7 +1824,7 @@ def set_scene_fps(fps, update=True): cmds.file(modified=True) -def set_scene_resolution(width, height): +def set_scene_resolution(width, height, pixelAspect): """Set the render resolution Args: @@ -1847,6 +1852,36 @@ def set_scene_resolution(width, height): cmds.setAttr("%s.width" % control_node, width) cmds.setAttr("%s.height" % control_node, height) + deviceAspectRatio = ((float(width) / float(height)) * float(pixelAspect)) + cmds.setAttr("%s.deviceAspectRatio" % control_node, deviceAspectRatio) + cmds.setAttr("%s.pixelAspect" % control_node, pixelAspect) + + +def reset_scene_resolution(): + """Apply the scene resolution from the project definition + + scene resolution can be overwritten by an asset if the asset.data contains + any information regarding scene resolution . + + Returns: + None + """ + + project_doc = io.find_one({"type": "project"}) + project_data = project_doc["data"] + asset_data = lib.get_asset()["data"] + + # Set project resolution + width_key = "resolutionWidth" + height_key = "resolutionHeight" + pixelAspect_key = "pixelAspect" + + width = asset_data.get(width_key, project_data.get(width_key, 1920)) + height = asset_data.get(height_key, project_data.get(height_key, 1080)) + pixelAspect = asset_data.get(pixelAspect_key, + project_data.get(pixelAspect_key, 1)) + + set_scene_resolution(width, height, pixelAspect) def set_context_settings(): """Apply the project settings from the project definition @@ -1873,18 +1908,14 @@ def set_context_settings(): api.Session["AVALON_FPS"] = str(fps) set_scene_fps(fps) - # Set project resolution - width_key = "resolutionWidth" - height_key = "resolutionHeight" - - width = asset_data.get(width_key, project_data.get(width_key, 1920)) - height = asset_data.get(height_key, project_data.get(height_key, 1080)) - - set_scene_resolution(width, height) + reset_scene_resolution() # Set frame range. avalon.maya.interactive.reset_frame_range() + # Set colorspace + set_colorspace() + # Valid FPS def validate_fps(): @@ -2740,3 +2771,49 @@ def iter_shader_edits(relationships, shader_nodes, nodes_by_id, label=None): "uuid": data["uuid"], "nodes": nodes, "attributes": attr_value} + + +def set_colorspace(): + """Set Colorspace from project configuration + """ + project_name = os.getenv("AVALON_PROJECT") + imageio = get_anatomy_settings(project_name)["imageio"]["maya"] + root_dict = imageio["colorManagementPreference"] + + if not isinstance(root_dict, dict): + msg = "set_colorspace(): argument should be dictionary" + log.error(msg) + + log.debug(">> root_dict: {}".format(root_dict)) + + # first enable color management + cmds.colorManagementPrefs(e=True, cmEnabled=True) + cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) + + # second set config path + if root_dict.get("configFilePath"): + unresolved_path = root_dict["configFilePath"] + ocio_paths = unresolved_path[platform.system().lower()] + + resolved_path = None + for ocio_p in ocio_paths: + resolved_path = str(ocio_p).format(**os.environ) + if not os.path.exists(resolved_path): + continue + + if resolved_path: + filepath = str(resolved_path).replace("\\", "/") + cmds.colorManagementPrefs(e=True, configFilePath=filepath) + cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True) + log.debug("maya '{}' changed to: {}".format( + "configFilePath", resolved_path)) + root_dict.pop("configFilePath") + else: + cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False) + cmds.colorManagementPrefs(e=True, configFilePath="" ) + + # third set rendering space and view transform + renderSpace = root_dict["renderSpace"] + cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace) + viewTransform = root_dict["viewTransform"] + cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index ad225dcd28..df5058dfd5 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -2,13 +2,16 @@ import sys import os import logging -from avalon.vendor.Qt import QtWidgets, QtGui -from avalon.maya import pipeline -from openpype.api import BuildWorkfile -import maya.cmds as cmds -from openpype.settings import get_project_settings +from Qt import QtWidgets, QtGui -self = sys.modules[__name__] +import maya.cmds as cmds + +from avalon.maya import pipeline + +from openpype.api import BuildWorkfile +from openpype.settings import get_project_settings +from openpype.tools.utils import host_tools +from openpype.hosts.maya.api import lib log = logging.getLogger(__name__) @@ -19,10 +22,8 @@ def _get_menu(menu_name=None): if menu_name is None: menu_name = pipeline._menu - widgets = dict(( - w.objectName(), w) for w in QtWidgets.QApplication.allWidgets()) - menu = widgets.get(menu_name) - return menu + widgets = {w.objectName(): w for w in QtWidgets.QApplication.allWidgets()} + return widgets.get(menu_name) def deferred(): @@ -36,25 +37,52 @@ def deferred(): ) def add_look_assigner_item(): - import mayalookassigner cmds.menuItem( "Look assigner", parent=pipeline._menu, - command=lambda *args: mayalookassigner.show() + command=lambda *args: host_tools.show_look_assigner( + pipeline._parent + ) ) - def modify_workfiles(): - from openpype.tools import workfiles - - def launch_workfiles_app(*_args, **_kwargs): - workfiles.show( - os.path.join( - cmds.workspace(query=True, rootDirectory=True), - cmds.workspace(fileRuleEntry="scene") - ), - parent=pipeline._parent + def add_experimental_item(): + cmds.menuItem( + "Experimental tools...", + parent=pipeline._menu, + command=lambda *args: host_tools.show_experimental_tools_dialog( + pipeline._parent ) + ) + def add_scripts_menu(): + try: + import scriptsmenu.launchformaya as launchformaya + except ImportError: + log.warning( + "Skipping studio.menu install, because " + "'scriptsmenu' module seems unavailable." + ) + return + + # load configuration of custom menu + project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) + config = project_settings["maya"]["scriptsmenu"]["definition"] + _menu = project_settings["maya"]["scriptsmenu"]["name"] + + if not config: + log.warning("Skipping studio menu, no definition found.") + return + + # run the launcher for Maya menu + studio_menu = launchformaya.main( + title=_menu.title(), + objectName=_menu.title().lower().replace(" ", "_") + ) + + # apply configuration + studio_menu.build_from_configuration(studio_menu, config) + + def modify_workfiles(): # Find the pipeline menu top_menu = _get_menu() @@ -75,7 +103,7 @@ def deferred(): cmds.menuItem( "Work Files", parent=pipeline._menu, - command=launch_workfiles_app, + command=lambda *args: host_tools.show_workfiles(pipeline._parent), insertAfter=after_action ) @@ -83,6 +111,35 @@ def deferred(): if workfile_action: top_menu.removeAction(workfile_action) + def modify_resolution(): + # Find the pipeline menu + top_menu = _get_menu() + + # Try to find resolution tool action in the menu + resolution_action = None + for action in top_menu.actions(): + if action.text() == "Reset Resolution": + resolution_action = action + break + + # Add at the top of menu if "Work Files" action was not found + after_action = "" + if resolution_action: + # Use action's object name for `insertAfter` argument + after_action = resolution_action.objectName() + + # Insert action to menu + cmds.menuItem( + "Reset Resolution", + parent=pipeline._menu, + command=lambda *args: lib.reset_scene_resolution(), + insertAfter=after_action + ) + + # Remove replaced action + if resolution_action: + top_menu.removeAction(resolution_action) + def remove_project_manager(): top_menu = _get_menu() @@ -107,40 +164,42 @@ def deferred(): if project_manager_action is not None: system_menu.menu().removeAction(project_manager_action) + def add_colorspace(): + # Find the pipeline menu + top_menu = _get_menu() + + # Try to find workfile tool action in the menu + workfile_action = None + for action in top_menu.actions(): + if action.text() == "Reset Resolution": + workfile_action = action + break + + # Add at the top of menu if "Work Files" action was not found + after_action = "" + if workfile_action: + # Use action's object name for `insertAfter` argument + after_action = workfile_action.objectName() + + # Insert action to menu + cmds.menuItem( + "Set Colorspace", + parent=pipeline._menu, + command=lambda *args: lib.set_colorspace(), + insertAfter=after_action + ) + log.info("Attempting to install scripts menu ...") + # add_scripts_menu() add_build_workfiles_item() add_look_assigner_item() + add_experimental_item() modify_workfiles() + modify_resolution() remove_project_manager() - - try: - import scriptsmenu.launchformaya as launchformaya - import scriptsmenu.scriptsmenu as scriptsmenu - except ImportError: - log.warning( - "Skipping studio.menu install, because " - "'scriptsmenu' module seems unavailable." - ) - return - - # load configuration of custom menu - project_settings = get_project_settings(os.getenv("AVALON_PROJECT")) - config = project_settings["maya"]["scriptsmenu"]["definition"] - _menu = project_settings["maya"]["scriptsmenu"]["name"] - - if not config: - log.warning("Skipping studio menu, no definition found.") - return - - # run the launcher for Maya menu - studio_menu = launchformaya.main( - title=_menu.title(), - objectName=_menu.title().lower().replace(" ", "_") - ) - - # apply configuration - studio_menu.build_from_configuration(studio_menu, config) + add_colorspace() + add_scripts_menu() def uninstall(): @@ -161,7 +220,7 @@ def install(): return # Allow time for uninstallation to finish. - cmds.evalDeferred(deferred) + cmds.evalDeferred(deferred, lowestPriority=True) def popup(): diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 448cb814d9..fdad0e0989 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -123,7 +123,7 @@ class ReferenceLoader(api.Loader): count = options.get("count") or 1 for c in range(0, count): namespace = namespace or lib.unique_namespace( - asset["name"] + "_", + "{}_{}_".format(asset["name"], context["subset"]["name"]), prefix="_" if asset["name"][0].isdigit() else "", suffix="_", ) diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index be26572039..3537fa3837 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -5,6 +5,7 @@ import os import contextlib import copy +import six from maya import cmds from avalon import api, io @@ -69,7 +70,8 @@ def unlocked(nodes): yield finally: # Reapply original states - for uuid, state in states.iteritems(): + _iteritems = getattr(states, "iteritems", states.items) + for uuid, state in _iteritems(): nodes_from_id = cmds.ls(uuid, long=True) if nodes_from_id: node = nodes_from_id[0] @@ -94,7 +96,7 @@ def load_package(filepath, name, namespace=None): # Define a unique namespace for the package namespace = os.path.basename(filepath).split(".")[0] unique_namespace(namespace) - assert isinstance(namespace, basestring) + assert isinstance(namespace, six.string_types) # Load the setdress package data with open(filepath, "r") as fp: diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 6dd161cf98..cfe8149218 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -41,14 +41,13 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): family = "model" with maya.maintained_selection(): - - groupName = "{}:{}".format(namespace, name) + groupName = "{}:_GRP".format(namespace) cmds.loadPlugin("AbcImport.mll", quiet=True) nodes = cmds.file(self.fname, namespace=namespace, sharedReferenceFile=False, groupReference=True, - groupName="{}:{}".format(namespace, name), + groupName=groupName, reference=True, returnNewNodes=True) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 575cc2456b..d2f277329a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -244,17 +244,17 @@ class CollectMayaRender(pyblish.api.ContextPlugin): # metadata file will be located in top-most common # directory. # TODO: use `os.path.commonpath()` after switch to Python 3 + publish_meta_path = os.path.normpath(publish_meta_path) common_publish_meta_path = os.path.splitdrive( publish_meta_path)[0] if common_publish_meta_path: common_publish_meta_path += os.path.sep - for part in publish_meta_path.split("/"): + for part in publish_meta_path.replace( + common_publish_meta_path, "").split(os.path.sep): common_publish_meta_path = os.path.join( common_publish_meta_path, part) if part == expected_layer_name: break - common_publish_meta_path = common_publish_meta_path.replace( - "\\", "/") self.log.info( "Publish meta path: {}".format(common_publish_meta_path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index e5f3b0cda4..720a61b0a7 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -183,7 +183,8 @@ class ExtractFBX(openpype.api.Extractor): # Apply the FBX overrides through MEL since the commands # only work correctly in MEL according to online # available discussions on the topic - for option, value in options.iteritems(): + _iteritems = getattr(options, "iteritems", options.items) + for option, value in _iteritems(): key = option[0].upper() + option[1:] # uppercase first letter # Boolean must be passed as lower-case strings diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index bbf25ebdc7..e0b85907e9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -205,6 +205,9 @@ class ExtractLook(openpype.api.Extractor): lookdata = instance.data["lookData"] relationships = lookdata["relationships"] sets = relationships.keys() + if not sets: + self.log.info("No sets found") + return results = self.process_resources(instance, staging_dir=dir_path) transfers = results["fileTransfers"] diff --git a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py index 1c97f0faf7..207cf56cfe 100644 --- a/openpype/hosts/maya/plugins/publish/submit_maya_muster.py +++ b/openpype/hosts/maya/plugins/publish/submit_maya_muster.py @@ -383,7 +383,7 @@ class MayaSubmitMuster(pyblish.api.InstancePlugin): "attributes": { "environmental_variables": { "value": ", ".join("{!s}={!r}".format(k, v) - for (k, v) in env.iteritems()), + for (k, v) in env.items()), "state": True, "subst": False diff --git a/openpype/hosts/maya/plugins/publish/validate_instance_subset.py b/openpype/hosts/maya/plugins/publish/validate_instance_subset.py index a8c16425d6..539f3f9d3c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_instance_subset.py +++ b/openpype/hosts/maya/plugins/publish/validate_instance_subset.py @@ -2,6 +2,8 @@ import pyblish.api import openpype.api import string +import six + # Allow only characters, numbers and underscore allowed = set(string.ascii_lowercase + string.ascii_uppercase + @@ -29,7 +31,7 @@ class ValidateSubsetName(pyblish.api.InstancePlugin): raise RuntimeError("Instance is missing subset " "name: {0}".format(subset)) - if not isinstance(subset, basestring): + if not isinstance(subset, six.string_types): raise TypeError("Instance subset name must be string, " "got: {0} ({1})".format(subset, type(subset))) diff --git a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py index 39bb148911..ed9ef526d6 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_ids_unique.py @@ -52,7 +52,8 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin): # Take only the ids with more than one member invalid = list() - for _ids, members in ids.iteritems(): + _iteritems = getattr(ids, "iteritems", ids.items) + for _ids, members in _iteritems(): if len(members) > 1: cls.log.error("ID found on multiple nodes: '%s'" % members) invalid.extend(members) diff --git a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py index 671c744a22..38f3ab1e68 100644 --- a/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py +++ b/openpype/hosts/maya/plugins/publish/validate_node_no_ghosting.py @@ -32,7 +32,10 @@ class ValidateNodeNoGhosting(pyblish.api.InstancePlugin): nodes = cmds.ls(instance, long=True, type=['transform', 'shape']) invalid = [] for node in nodes: - for attr, required_value in cls._attributes.iteritems(): + _iteritems = getattr( + cls._attributes, "iteritems", cls._attributes.items + ) + for attr, required_value in _iteritems(): if cmds.attributeQuery(attr, node=node, exists=True): value = cmds.getAttr('{0}.{1}'.format(node, attr)) diff --git a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py index 667a1f13be..714451bb98 100644 --- a/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py +++ b/openpype/hosts/maya/plugins/publish/validate_shape_render_stats.py @@ -33,7 +33,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator): shapes = cmds.ls(instance, long=True, type='surfaceShape') invalid = [] for shape in shapes: - for attr, default_value in cls.defaults.iteritems(): + _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items) + for attr, default_value in _iteritems(): if cmds.attributeQuery(attr, node=shape, exists=True): value = cmds.getAttr('{}.{}'.format(shape, attr)) if value != default_value: @@ -52,7 +53,8 @@ class ValidateShapeRenderStats(pyblish.api.Validator): @classmethod def repair(cls, instance): for shape in cls.get_invalid(instance): - for attr, default_value in cls.defaults.iteritems(): + _iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items) + for attr, default_value in _iteritems(): if cmds.attributeQuery(attr, node=shape, exists=True): plug = '{0}.{1}'.format(shape, attr) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 6fe9af744b..9ee3a4464b 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -7,7 +7,6 @@ from collections import OrderedDict from avalon import api, io, lib -from openpype.tools import workfiles import avalon.nuke from avalon.nuke import lib as anlib from avalon.nuke import ( @@ -24,7 +23,7 @@ from openpype.api import ( get_current_project_settings, ApplicationManager ) - +from openpype.tools.utils import host_tools import nuke from .utils import set_context_favorites @@ -1032,27 +1031,6 @@ class WorkfileSettings(object): log.error(msg) nuke.message(msg) - bbox = self._asset_entity.get('data', {}).get('crop') - - if bbox: - try: - x, y, r, t = bbox.split(".") - data.update( - { - "x": int(x), - "y": int(y), - "r": int(r), - "t": int(t), - } - ) - except Exception as e: - bbox = None - msg = ("{}:{} \nFormat:Crop need to be set with dots, " - "example: 0.0.1920.1080, " - "/nSetting to default").format(__name__, e) - log.error(msg) - nuke.message(msg) - existing_format = None for format in nuke.formats(): if data["name"] == format.name(): @@ -1064,12 +1042,6 @@ class WorkfileSettings(object): existing_format.setWidth(data["width"]) existing_format.setHeight(data["height"]) existing_format.setPixelAspect(data["pixel_aspect"]) - - if bbox: - existing_format.setX(data["x"]) - existing_format.setY(data["y"]) - existing_format.setR(data["r"]) - existing_format.setT(data["t"]) else: format_string = self.make_format_string(**data) log.info("Creating new format: {}".format(format_string)) @@ -1689,7 +1661,7 @@ def launch_workfiles_app(): if not opnl.workfiles_launched: opnl.workfiles_launched = True - workfiles.show(os.environ["AVALON_WORKDIR"]) + host_tools.show_workfiles() def process_workfile_builder(): diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py index 021ea04159..3e74893589 100644 --- a/openpype/hosts/nuke/api/menu.py +++ b/openpype/hosts/nuke/api/menu.py @@ -4,7 +4,7 @@ from avalon.api import Session from .lib import WorkfileSettings from openpype.api import Logger, BuildWorkfile, get_current_project_settings -from openpype.tools import workfiles +from openpype.tools.utils import host_tools log = Logger().get_logger(__name__) @@ -25,7 +25,7 @@ def install(): menu.removeItem(rm_item[1].name()) menu.addCommand( name, - workfiles.show, + host_tools.show_workfiles, index=2 ) menu.addSeparator(index=3) @@ -84,6 +84,12 @@ def install(): ) log.debug("Adding menu item: {}".format(name)) + # Add experimental tools action + menu.addSeparator() + menu.addCommand( + "Experimental tools...", + host_tools.show_experimental_tools_dialog + ) # adding shortcuts add_shortcuts_from_presets() diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 0ad98146b1..62eadecaf4 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -1,4 +1,10 @@ +import random +import string + import avalon.nuke +from avalon.nuke import lib as anlib +from avalon import api + from openpype.api import ( get_current_project_settings, PypeCreatorMixin @@ -23,3 +29,68 @@ class PypeCreator(PypeCreatorMixin, avalon.nuke.pipeline.Creator): self.log.error(msg + '\n\nPlease use other subset name!') raise NameError("`{0}: {1}".format(__name__, msg)) return + + +def get_review_presets_config(): + settings = get_current_project_settings() + review_profiles = ( + settings["global"] + ["publish"] + ["ExtractReview"] + ["profiles"] + ) + + outputs = {} + for profile in review_profiles: + outputs.update(profile.get("outputs", {})) + + return [str(name) for name, _prop in outputs.items()] + + +class NukeLoader(api.Loader): + container_id_knob = "containerId" + container_id = ''.join(random.choice( + string.ascii_uppercase + string.digits) for _ in range(10)) + + def get_container_id(self, node): + id_knob = node.knobs().get(self.container_id_knob) + return id_knob.value() if id_knob else None + + def get_members(self, source): + """Return nodes that has same 'containerId' as `source`""" + source_id = self.get_container_id(source) + return [node for node in nuke.allNodes(recurseGroups=True) + if self.get_container_id(node) == source_id + and node is not source] if source_id else [] + + def set_as_member(self, node): + source_id = self.get_container_id(node) + + if source_id: + node[self.container_id_knob].setValue(self.container_id) + else: + HIDEN_FLAG = 0x00040000 + _knob = anlib.Knobby( + "String_Knob", + self.container_id, + flags=[nuke.READ_ONLY, HIDEN_FLAG]) + knob = _knob.create(self.container_id_knob) + node.addKnob(knob) + + def clear_members(self, parent_node): + members = self.get_members(parent_node) + + dependent_nodes = None + for node in members: + _depndc = [n for n in node.dependent() if n not in members] + if not _depndc: + continue + + dependent_nodes = _depndc + break + + for member in members: + self.log.info("removing node: `{}".format(member.name())) + nuke.delete(member) + + return dependent_nodes diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index a1381122ee..5f13fddf4e 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -99,7 +99,7 @@ class CreateWriteRender(plugin.PypeCreator): "fpath_template": ("{work}/renders/nuke/{subset}" "/{subset}.{frame}.{ext}")}) - # add crop node to cut off all outside of format bounding box + # add reformat node to cut off all outside of format bounding box # get width and height try: width, height = (selected_node.width(), selected_node.height()) @@ -109,15 +109,11 @@ class CreateWriteRender(plugin.PypeCreator): _prenodes = [ { - "name": "Crop01", - "class": "Crop", + "name": "Reformat01", + "class": "Reformat", "knobs": [ - ("box", [ - 0.0, - 0.0, - width, - height - ]) + ("resize", 0), + ("black_outside", 1), ], "dependent": None } diff --git a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py new file mode 100644 index 0000000000..e7ae51fa86 --- /dev/null +++ b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py @@ -0,0 +1,37 @@ +from avalon import api, style +from avalon.nuke import lib as anlib +from openpype.api import ( + Logger) + + +class RepairOldLoaders(api.InventoryAction): + + label = "Repair Old Loaders" + icon = "gears" + color = style.colors.alert + + log = Logger().get_logger(__name__) + + def process(self, containers): + import nuke + new_loader = "LoadClip" + + for cdata in containers: + orig_loader = cdata["loader"] + orig_name = cdata["objectName"] + if orig_loader not in ["LoadSequence", "LoadMov"]: + self.log.warning( + "This repair action is only working on " + "`LoadSequence` and `LoadMov` Loaders") + continue + + new_name = orig_name.replace(orig_loader, new_loader) + node = nuke.toNode(cdata["objectName"]) + + cdata.update({ + "loader": new_loader, + "objectName": new_name + }) + node["name"].setValue(new_name) + # get data from avalon knob + anlib.set_avalon_knob_data(node, cdata) diff --git a/openpype/hosts/nuke/plugins/inventory/select_containers.py b/openpype/hosts/nuke/plugins/inventory/select_containers.py index b420f53431..bd00983172 100644 --- a/openpype/hosts/nuke/plugins/inventory/select_containers.py +++ b/openpype/hosts/nuke/plugins/inventory/select_containers.py @@ -8,10 +8,10 @@ class SelectContainers(api.InventoryAction): color = "#d8d8d8" def process(self, containers): - + import nuke import avalon.nuke - nodes = [i["_node"] for i in containers] + nodes = [nuke.toNode(i["objectName"]) for i in containers] with avalon.nuke.viewer_update_and_undo_stop(): # clear previous_selection diff --git a/openpype/hosts/nuke/plugins/inventory/set_tool_color.py b/openpype/hosts/nuke/plugins/inventory/set_tool_color.py deleted file mode 100644 index 7a81444c90..0000000000 --- a/openpype/hosts/nuke/plugins/inventory/set_tool_color.py +++ /dev/null @@ -1,68 +0,0 @@ -# from avalon import api, style -# from avalon.vendor.Qt import QtGui, QtWidgets -# -# import avalon.fusion -# -# -# class FusionSetToolColor(api.InventoryAction): -# """Update the color of the selected tools""" -# -# label = "Set Tool Color" -# icon = "plus" -# color = "#d8d8d8" -# _fallback_color = QtGui.QColor(1.0, 1.0, 1.0) -# -# def process(self, containers): -# """Color all selected tools the selected colors""" -# -# result = [] -# comp = avalon.fusion.get_current_comp() -# -# # Get tool color -# first = containers[0] -# tool = first["_node"] -# color = tool.TileColor -# -# if color is not None: -# qcolor = QtGui.QColor().fromRgbF(color["R"], color["G"], color["B"]) -# else: -# qcolor = self._fallback_color -# -# # Launch pick color -# picked_color = self.get_color_picker(qcolor) -# if not picked_color: -# return -# -# with avalon.fusion.comp_lock_and_undo_chunk(comp): -# for container in containers: -# # Convert color to RGB 0-1 floats -# rgb_f = picked_color.getRgbF() -# rgb_f_table = {"R": rgb_f[0], "G": rgb_f[1], "B": rgb_f[2]} -# -# # Update tool -# tool = container["_node"] -# tool.TileColor = rgb_f_table -# -# result.append(container) -# -# return result -# -# def get_color_picker(self, color): -# """Launch color picker and return chosen color -# -# Args: -# color(QtGui.QColor): Start color to display -# -# Returns: -# QtGui.QColor -# -# """ -# -# color_dialog = QtWidgets.QColorDialog(color) -# color_dialog.setStyleSheet(style.load_stylesheet()) -# -# accepted = color_dialog.exec_() -# if not accepted: -# return -# -# return color_dialog.selectedColor() diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py new file mode 100644 index 0000000000..f8fc5e3928 --- /dev/null +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -0,0 +1,371 @@ +import nuke +from avalon.vendor import qargparse +from avalon import api, io + +from openpype.hosts.nuke.api.lib import ( + get_imageio_input_colorspace +) +from avalon.nuke import ( + containerise, + update_container, + viewer_update_and_undo_stop, + maintained_selection +) +from openpype.hosts.nuke.api import plugin + + +class LoadClip(plugin.NukeLoader): + """Load clip into Nuke + + Either it is image sequence or video file. + """ + + families = [ + "source", + "plate", + "render", + "prerender", + "review" + ] + representations = [ + "exr", + "dpx", + "mov", + "review", + "mp4" + ] + + label = "Load Clip" + order = -20 + icon = "file-video-o" + color = "white" + + script_start = int(nuke.root()["first_frame"].value()) + + # option gui + defaults = { + "start_at_workfile": True + } + + options = [ + qargparse.Boolean( + "start_at_workfile", + help="Load at workfile start frame", + default=True + ) + ] + + node_name_template = "{class_name}_{ext}" + + @classmethod + def get_representations(cls): + return ( + cls.representations + + cls._representations + + plugin.get_review_presets_config() + ) + + def load(self, context, name, namespace, options): + + is_sequence = len(context["representation"]["files"]) > 1 + + file = self.fname.replace("\\", "/") + + start_at_workfile = options.get( + "start_at_workfile", self.defaults["start_at_workfile"]) + + version = context['version'] + version_data = version.get("data", {}) + repr_id = context["representation"]["_id"] + colorspace = version_data.get("colorspace") + iio_colorspace = get_imageio_input_colorspace(file) + repr_cont = context["representation"]["context"] + + self.log.info("version_data: {}\n".format(version_data)) + self.log.debug( + "Representation id `{}` ".format(repr_id)) + + self.handle_start = version_data.get("handleStart", 0) + self.handle_end = version_data.get("handleEnd", 0) + + first = version_data.get("frameStart", None) + last = version_data.get("frameEnd", None) + first -= self.handle_start + last += self.handle_end + + if not is_sequence: + duration = last - first + 1 + first = 1 + last = first + duration + elif "#" not in file: + frame = repr_cont.get("frame") + assert frame, "Representation is not sequence" + + padding = len(frame) + file = file.replace(frame, "#" * padding) + + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + if not file: + self.log.warning( + "Representation id `{}` is failing to load".format(repr_id)) + return + + name_data = { + "asset": repr_cont["asset"], + "subset": repr_cont["subset"], + "representation": context["representation"]["name"], + "ext": repr_cont["representation"], + "id": context["representation"]["_id"], + "class_name": self.__class__.__name__ + } + + read_name = self.node_name_template.format(**name_data) + + # Create the Loader with the filename path set + read_node = nuke.createNode( + "Read", + "name {}".format(read_name)) + + # to avoid multiple undo steps for rest of process + # we will switch off undo-ing + with viewer_update_and_undo_stop(): + read_node["file"].setValue(file) + + # Set colorspace defined in version data + if colorspace: + read_node["colorspace"].setValue(str(colorspace)) + elif iio_colorspace is not None: + read_node["colorspace"].setValue(iio_colorspace) + + self.set_range_to_node(read_node, first, last, start_at_workfile) + + # add additional metadata from the version to imprint Avalon knob + add_keys = ["frameStart", "frameEnd", + "source", "colorspace", "author", "fps", "version", + "handleStart", "handleEnd"] + + data_imprint = {} + for k in add_keys: + if k == 'version': + data_imprint.update({k: context["version"]['name']}) + else: + data_imprint.update( + {k: context["version"]['data'].get(k, str(None))}) + + data_imprint.update({"objectName": read_name}) + + read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) + + container = containerise( + read_node, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__, + data=data_imprint) + + if version_data.get("retime", None): + self.make_retimes(read_node, version_data) + + self.set_as_member(read_node) + + return container + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + """Update the Loader's path + + Nuke automatically tries to reset some variables when changing + the loader's path to a new file. These automatic changes are to its + inputs: + + """ + + is_sequence = len(representation["files"]) > 1 + + read_node = nuke.toNode(container['objectName']) + file = api.get_representation_path(representation).replace("\\", "/") + + start_at_workfile = bool("start at" in read_node['frame_mode'].value()) + + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + version_data = version.get("data", {}) + repr_id = representation["_id"] + colorspace = version_data.get("colorspace") + iio_colorspace = get_imageio_input_colorspace(file) + repr_cont = representation["context"] + + self.handle_start = version_data.get("handleStart", 0) + self.handle_end = version_data.get("handleEnd", 0) + + first = version_data.get("frameStart", None) + last = version_data.get("frameEnd", None) + first -= self.handle_start + last += self.handle_end + + if not is_sequence: + duration = last - first + 1 + first = 1 + last = first + duration + elif "#" not in file: + frame = repr_cont.get("frame") + assert frame, "Representation is not sequence" + + padding = len(frame) + file = file.replace(frame, "#" * padding) + + if not file: + self.log.warning( + "Representation id `{}` is failing to load".format(repr_id)) + return + + read_node["file"].setValue(file) + + # to avoid multiple undo steps for rest of process + # we will switch off undo-ing + with viewer_update_and_undo_stop(): + + # Set colorspace defined in version data + if colorspace: + read_node["colorspace"].setValue(str(colorspace)) + elif iio_colorspace is not None: + read_node["colorspace"].setValue(iio_colorspace) + + self.set_range_to_node(read_node, first, last, start_at_workfile) + + updated_dict = { + "representation": str(representation["_id"]), + "frameStart": str(first), + "frameEnd": str(last), + "version": str(version.get("name")), + "colorspace": colorspace, + "source": version_data.get("source"), + "handleStart": str(self.handle_start), + "handleEnd": str(self.handle_end), + "fps": str(version_data.get("fps")), + "author": version_data.get("author"), + "outputDir": version_data.get("outputDir"), + } + + # change color of read_node + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + if version.get("name") not in [max_version]: + read_node["tile_color"].setValue(int("0xd84f20ff", 16)) + else: + read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) + + # Update the imprinted representation + update_container( + read_node, + updated_dict + ) + self.log.info("udated to version: {}".format(version.get("name"))) + + if version_data.get("retime", None): + self.make_retimes(read_node, version_data) + else: + self.clear_members(read_node) + + self.set_as_member(read_node) + + def set_range_to_node(self, read_node, first, last, start_at_workfile): + read_node['origfirst'].setValue(int(first)) + read_node['first'].setValue(int(first)) + read_node['origlast'].setValue(int(last)) + read_node['last'].setValue(int(last)) + + # set start frame depending on workfile or version + self.loader_shift(read_node, start_at_workfile) + + def remove(self, container): + + from avalon.nuke import viewer_update_and_undo_stop + + read_node = nuke.toNode(container['objectName']) + assert read_node.Class() == "Read", "Must be Read" + + with viewer_update_and_undo_stop(): + members = self.get_members(read_node) + nuke.delete(read_node) + for member in members: + nuke.delete(member) + + def make_retimes(self, parent_node, version_data): + ''' Create all retime and timewarping nodes with coppied animation ''' + speed = version_data.get('speed', 1) + time_warp_nodes = version_data.get('timewarps', []) + last_node = None + source_id = self.get_container_id(parent_node) + self.log.info("__ source_id: {}".format(source_id)) + self.log.info("__ members: {}".format(self.get_members(parent_node))) + dependent_nodes = self.clear_members(parent_node) + + with maintained_selection(): + parent_node['selected'].setValue(True) + + if speed != 1: + rtn = nuke.createNode( + "Retime", + "speed {}".format(speed)) + + rtn["before"].setValue("continue") + rtn["after"].setValue("continue") + rtn["input.first_lock"].setValue(True) + rtn["input.first"].setValue( + self.script_start + ) + self.set_as_member(rtn) + last_node = rtn + + if time_warp_nodes != []: + start_anim = self.script_start + (self.handle_start / speed) + for timewarp in time_warp_nodes: + twn = nuke.createNode( + timewarp["Class"], + "name {}".format(timewarp["name"]) + ) + if isinstance(timewarp["lookup"], list): + # if array for animation + twn["lookup"].setAnimated() + for i, value in enumerate(timewarp["lookup"]): + twn["lookup"].setValueAt( + (start_anim + i) + value, + (start_anim + i)) + else: + # if static value `int` + twn["lookup"].setValue(timewarp["lookup"]) + + self.set_as_member(twn) + last_node = twn + + if dependent_nodes: + # connect to original inputs + for i, n in enumerate(dependent_nodes): + last_node.setInput(i, n) + + def loader_shift(self, read_node, workfile_start=False): + """ Set start frame of read node to a workfile start + + Args: + read_node (nuke.Node): The nuke's read node + workfile_start (bool): set workfile start frame if true + + """ + if workfile_start: + read_node['frame_mode'].setValue("start at") + read_node['frame'].setValue(str(self.script_start)) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 9b8bc43d12..2af44d6eba 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -12,7 +12,15 @@ from openpype.hosts.nuke.api.lib import ( class LoadImage(api.Loader): """Load still image into Nuke""" - families = ["render", "source", "plate", "review", "image"] + families = [ + "render2d", + "source", + "plate", + "render", + "prerender", + "review", + "image" + ] representations = ["exr", "dpx", "jpg", "jpeg", "png", "psd", "tiff"] label = "Load Image" @@ -33,6 +41,10 @@ class LoadImage(api.Loader): ) ] + @classmethod + def get_representations(cls): + return cls.representations + cls._representations + def load(self, context, name, namespace, options): from avalon.nuke import ( containerise, diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py deleted file mode 100644 index f7523d0a6e..0000000000 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ /dev/null @@ -1,347 +0,0 @@ -import nuke -from avalon.vendor import qargparse -from avalon import api, io -from openpype.api import get_current_project_settings -from openpype.hosts.nuke.api.lib import ( - get_imageio_input_colorspace -) - - -def add_review_presets_config(): - returning = { - "families": list(), - "representations": list() - } - settings = get_current_project_settings() - review_profiles = ( - settings["global"] - ["publish"] - ["ExtractReview"] - ["profiles"] - ) - - outputs = {} - for profile in review_profiles: - outputs.update(profile.get("outputs", {})) - - for output, properities in outputs.items(): - returning["representations"].append(output) - returning["families"] += properities.get("families", []) - - return returning - - -class LoadMov(api.Loader): - """Load mov file into Nuke""" - families = ["render", "source", "plate", "review"] - representations = ["mov", "review", "mp4"] - - label = "Load mov" - order = -10 - icon = "code-fork" - color = "orange" - - first_frame = nuke.root()["first_frame"].value() - - # options gui - defaults = { - "start_at_workfile": True - } - - options = [ - qargparse.Boolean( - "start_at_workfile", - help="Load at workfile start frame", - default=True - ) - ] - - node_name_template = "{class_name}_{ext}" - - def load(self, context, name, namespace, options): - from avalon.nuke import ( - containerise, - viewer_update_and_undo_stop - ) - - start_at_workfile = options.get( - "start_at_workfile", self.defaults["start_at_workfile"]) - - version = context['version'] - version_data = version.get("data", {}) - repr_id = context["representation"]["_id"] - - self.handle_start = version_data.get("handleStart", 0) - self.handle_end = version_data.get("handleEnd", 0) - - orig_first = version_data.get("frameStart") - orig_last = version_data.get("frameEnd") - diff = orig_first - 1 - - first = orig_first - diff - last = orig_last - diff - - colorspace = version_data.get("colorspace") - repr_cont = context["representation"]["context"] - - self.log.debug( - "Representation id `{}` ".format(repr_id)) - - context["representation"]["_id"] - # create handles offset (only to last, because of mov) - last += self.handle_start + self.handle_end - - # Fallback to asset name when namespace is None - if namespace is None: - namespace = context['asset']['name'] - - file = self.fname - - if not file: - self.log.warning( - "Representation id `{}` is failing to load".format(repr_id)) - return - - file = file.replace("\\", "/") - - name_data = { - "asset": repr_cont["asset"], - "subset": repr_cont["subset"], - "representation": context["representation"]["name"], - "ext": repr_cont["representation"], - "id": context["representation"]["_id"], - "class_name": self.__class__.__name__ - } - - read_name = self.node_name_template.format(**name_data) - - read_node = nuke.createNode( - "Read", - "name {}".format(read_name) - ) - - # to avoid multiple undo steps for rest of process - # we will switch off undo-ing - with viewer_update_and_undo_stop(): - read_node["file"].setValue(file) - - read_node["origfirst"].setValue(first) - read_node["first"].setValue(first) - read_node["origlast"].setValue(last) - read_node["last"].setValue(last) - read_node['frame_mode'].setValue("start at") - - if start_at_workfile: - # start at workfile start - read_node['frame'].setValue(str(self.first_frame)) - else: - # start at version frame start - read_node['frame'].setValue( - str(orig_first - self.handle_start)) - - if colorspace: - read_node["colorspace"].setValue(str(colorspace)) - - preset_clrsp = get_imageio_input_colorspace(file) - - if preset_clrsp is not None: - read_node["colorspace"].setValue(preset_clrsp) - - # add additional metadata from the version to imprint Avalon knob - add_keys = [ - "frameStart", "frameEnd", "handles", "source", "author", - "fps", "version", "handleStart", "handleEnd" - ] - - data_imprint = {} - for key in add_keys: - if key == 'version': - data_imprint.update({ - key: context["version"]['name'] - }) - else: - data_imprint.update({ - key: context["version"]['data'].get(key, str(None)) - }) - - data_imprint.update({"objectName": read_name}) - - read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) - - if version_data.get("retime", None): - speed = version_data.get("speed", 1) - time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(speed, time_warp_nodes) - - return containerise( - read_node, - name=name, - namespace=namespace, - context=context, - loader=self.__class__.__name__, - data=data_imprint - ) - - def switch(self, container, representation): - self.update(container, representation) - - def update(self, container, representation): - """Update the Loader's path - - Nuke automatically tries to reset some variables when changing - the loader's path to a new file. These automatic changes are to its - inputs: - - """ - - from avalon.nuke import ( - update_container - ) - - read_node = nuke.toNode(container['objectName']) - - assert read_node.Class() == "Read", "Must be Read" - - file = self.fname - - if not file: - repr_id = representation["_id"] - self.log.warning( - "Representation id `{}` is failing to load".format(repr_id)) - return - - file = file.replace("\\", "/") - - # Get start frame from version data - version = io.find_one({ - "type": "version", - "_id": representation["parent"] - }) - - # get all versions in list - versions = io.find({ - "type": "version", - "parent": version["parent"] - }).distinct('name') - - max_version = max(versions) - - version_data = version.get("data", {}) - - orig_first = version_data.get("frameStart") - orig_last = version_data.get("frameEnd") - diff = orig_first - 1 - - # set first to 1 - first = orig_first - diff - last = orig_last - diff - self.handle_start = version_data.get("handleStart", 0) - self.handle_end = version_data.get("handleEnd", 0) - colorspace = version_data.get("colorspace") - - if first is None: - self.log.warning(( - "Missing start frame for updated version" - "assuming starts at frame 0 for: " - "{} ({})").format( - read_node['name'].value(), representation)) - first = 0 - - # create handles offset (only to last, because of mov) - last += self.handle_start + self.handle_end - - read_node["file"].setValue(file) - - # Set the global in to the start frame of the sequence - read_node["origfirst"].setValue(first) - read_node["first"].setValue(first) - read_node["origlast"].setValue(last) - read_node["last"].setValue(last) - read_node['frame_mode'].setValue("start at") - - if int(float(self.first_frame)) == int( - float(read_node['frame'].value())): - # start at workfile start - read_node['frame'].setValue(str(self.first_frame)) - else: - # start at version frame start - read_node['frame'].setValue(str(orig_first - self.handle_start)) - - if colorspace: - read_node["colorspace"].setValue(str(colorspace)) - - preset_clrsp = get_imageio_input_colorspace(file) - - if preset_clrsp is not None: - read_node["colorspace"].setValue(preset_clrsp) - - updated_dict = {} - updated_dict.update({ - "representation": str(representation["_id"]), - "frameStart": str(first), - "frameEnd": str(last), - "version": str(version.get("name")), - "colorspace": version_data.get("colorspace"), - "source": version_data.get("source"), - "handleStart": str(self.handle_start), - "handleEnd": str(self.handle_end), - "fps": str(version_data.get("fps")), - "author": version_data.get("author"), - "outputDir": version_data.get("outputDir") - }) - - # change color of node - if version.get("name") not in [max_version]: - read_node["tile_color"].setValue(int("0xd84f20ff", 16)) - else: - read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) - - if version_data.get("retime", None): - speed = version_data.get("speed", 1) - time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(speed, time_warp_nodes) - - # Update the imprinted representation - update_container( - read_node, updated_dict - ) - self.log.info("udated to version: {}".format(version.get("name"))) - - def remove(self, container): - - from avalon.nuke import viewer_update_and_undo_stop - - read_node = nuke.toNode(container['objectName']) - assert read_node.Class() == "Read", "Must be Read" - - with viewer_update_and_undo_stop(): - nuke.delete(read_node) - - def make_retimes(self, speed, time_warp_nodes): - ''' Create all retime and timewarping nodes with coppied animation ''' - if speed != 1: - rtn = nuke.createNode( - "Retime", - "speed {}".format(speed)) - rtn["before"].setValue("continue") - rtn["after"].setValue("continue") - rtn["input.first_lock"].setValue(True) - rtn["input.first"].setValue( - self.first_frame - ) - - if time_warp_nodes != []: - start_anim = self.first_frame + (self.handle_start / speed) - for timewarp in time_warp_nodes: - twn = nuke.createNode(timewarp["Class"], - "name {}".format(timewarp["name"])) - if isinstance(timewarp["lookup"], list): - # if array for animation - twn["lookup"].setAnimated() - for i, value in enumerate(timewarp["lookup"]): - twn["lookup"].setValueAt( - (start_anim + i) + value, - (start_anim + i)) - else: - # if static value `int` - twn["lookup"].setValue(timewarp["lookup"]) diff --git a/openpype/hosts/nuke/plugins/load/load_sequence.py b/openpype/hosts/nuke/plugins/load/load_sequence.py deleted file mode 100644 index 003b406ee7..0000000000 --- a/openpype/hosts/nuke/plugins/load/load_sequence.py +++ /dev/null @@ -1,320 +0,0 @@ -import nuke -from avalon.vendor import qargparse -from avalon import api, io -from openpype.hosts.nuke.api.lib import ( - get_imageio_input_colorspace -) - - -class LoadSequence(api.Loader): - """Load image sequence into Nuke""" - - families = ["render", "source", "plate", "review"] - representations = ["exr", "dpx"] - - label = "Load Image Sequence" - order = -20 - icon = "file-video-o" - color = "white" - - script_start = nuke.root()["first_frame"].value() - - # option gui - defaults = { - "start_at_workfile": True - } - - options = [ - qargparse.Boolean( - "start_at_workfile", - help="Load at workfile start frame", - default=True - ) - ] - - node_name_template = "{class_name}_{ext}" - - def load(self, context, name, namespace, options): - from avalon.nuke import ( - containerise, - viewer_update_and_undo_stop - ) - - start_at_workfile = options.get( - "start_at_workfile", self.defaults["start_at_workfile"]) - - version = context['version'] - version_data = version.get("data", {}) - repr_id = context["representation"]["_id"] - - self.log.info("version_data: {}\n".format(version_data)) - self.log.debug( - "Representation id `{}` ".format(repr_id)) - - self.first_frame = int(nuke.root()["first_frame"].getValue()) - self.handle_start = version_data.get("handleStart", 0) - self.handle_end = version_data.get("handleEnd", 0) - - first = version_data.get("frameStart", None) - last = version_data.get("frameEnd", None) - - # Fallback to asset name when namespace is None - if namespace is None: - namespace = context['asset']['name'] - - first -= self.handle_start - last += self.handle_end - - file = self.fname - - if not file: - repr_id = context["representation"]["_id"] - self.log.warning( - "Representation id `{}` is failing to load".format(repr_id)) - return - - file = file.replace("\\", "/") - - repr_cont = context["representation"]["context"] - assert repr_cont.get("frame"), "Representation is not sequence" - - if "#" not in file: - frame = repr_cont.get("frame") - if frame: - padding = len(frame) - file = file.replace(frame, "#" * padding) - - name_data = { - "asset": repr_cont["asset"], - "subset": repr_cont["subset"], - "representation": context["representation"]["name"], - "ext": repr_cont["representation"], - "id": context["representation"]["_id"], - "class_name": self.__class__.__name__ - } - - read_name = self.node_name_template.format(**name_data) - - # Create the Loader with the filename path set - read_node = nuke.createNode( - "Read", - "name {}".format(read_name)) - - # to avoid multiple undo steps for rest of process - # we will switch off undo-ing - with viewer_update_and_undo_stop(): - read_node["file"].setValue(file) - - # Set colorspace defined in version data - colorspace = context["version"]["data"].get("colorspace") - if colorspace: - read_node["colorspace"].setValue(str(colorspace)) - - preset_clrsp = get_imageio_input_colorspace(file) - - if preset_clrsp is not None: - read_node["colorspace"].setValue(preset_clrsp) - - # set start frame depending on workfile or version - self.loader_shift(read_node, start_at_workfile) - read_node["origfirst"].setValue(int(first)) - read_node["first"].setValue(int(first)) - read_node["origlast"].setValue(int(last)) - read_node["last"].setValue(int(last)) - - # add additional metadata from the version to imprint Avalon knob - add_keys = ["frameStart", "frameEnd", - "source", "colorspace", "author", "fps", "version", - "handleStart", "handleEnd"] - - data_imprint = {} - for k in add_keys: - if k == 'version': - data_imprint.update({k: context["version"]['name']}) - else: - data_imprint.update( - {k: context["version"]['data'].get(k, str(None))}) - - data_imprint.update({"objectName": read_name}) - - read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) - - if version_data.get("retime", None): - speed = version_data.get("speed", 1) - time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(speed, time_warp_nodes) - - return containerise(read_node, - name=name, - namespace=namespace, - context=context, - loader=self.__class__.__name__, - data=data_imprint) - - def switch(self, container, representation): - self.update(container, representation) - - def update(self, container, representation): - """Update the Loader's path - - Nuke automatically tries to reset some variables when changing - the loader's path to a new file. These automatic changes are to its - inputs: - - """ - - from avalon.nuke import ( - update_container - ) - - read_node = nuke.toNode(container['objectName']) - - assert read_node.Class() == "Read", "Must be Read" - - repr_cont = representation["context"] - assert repr_cont.get("frame"), "Representation is not sequence" - - file = api.get_representation_path(representation) - - if not file: - repr_id = representation["_id"] - self.log.warning( - "Representation id `{}` is failing to load".format(repr_id)) - return - - file = file.replace("\\", "/") - - if "#" not in file: - frame = repr_cont.get("frame") - if frame: - padding = len(frame) - file = file.replace(frame, "#" * padding) - - # Get start frame from version data - version = io.find_one({ - "type": "version", - "_id": representation["parent"] - }) - - # get all versions in list - versions = io.find({ - "type": "version", - "parent": version["parent"] - }).distinct('name') - - max_version = max(versions) - - version_data = version.get("data", {}) - - self.first_frame = int(nuke.root()["first_frame"].getValue()) - self.handle_start = version_data.get("handleStart", 0) - self.handle_end = version_data.get("handleEnd", 0) - - first = version_data.get("frameStart") - last = version_data.get("frameEnd") - - if first is None: - self.log.warning( - "Missing start frame for updated version" - "assuming starts at frame 0 for: " - "{} ({})".format(read_node['name'].value(), representation)) - first = 0 - - first -= self.handle_start - last += self.handle_end - - read_node["file"].setValue(file) - - # set start frame depending on workfile or version - self.loader_shift( - read_node, - bool("start at" in read_node['frame_mode'].value())) - - read_node["origfirst"].setValue(int(first)) - read_node["first"].setValue(int(first)) - read_node["origlast"].setValue(int(last)) - read_node["last"].setValue(int(last)) - - updated_dict = {} - updated_dict.update({ - "representation": str(representation["_id"]), - "frameStart": str(first), - "frameEnd": str(last), - "version": str(version.get("name")), - "colorspace": version_data.get("colorspace"), - "source": version_data.get("source"), - "handleStart": str(self.handle_start), - "handleEnd": str(self.handle_end), - "fps": str(version_data.get("fps")), - "author": version_data.get("author"), - "outputDir": version_data.get("outputDir"), - }) - - # change color of read_node - if version.get("name") not in [max_version]: - read_node["tile_color"].setValue(int("0xd84f20ff", 16)) - else: - read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) - - if version_data.get("retime", None): - speed = version_data.get("speed", 1) - time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(speed, time_warp_nodes) - - # Update the imprinted representation - update_container( - read_node, - updated_dict - ) - self.log.info("udated to version: {}".format(version.get("name"))) - - def remove(self, container): - - from avalon.nuke import viewer_update_and_undo_stop - - read_node = nuke.toNode(container['objectName']) - assert read_node.Class() == "Read", "Must be Read" - - with viewer_update_and_undo_stop(): - nuke.delete(read_node) - - def make_retimes(self, speed, time_warp_nodes): - ''' Create all retime and timewarping nodes with coppied animation ''' - if speed != 1: - rtn = nuke.createNode( - "Retime", - "speed {}".format(speed)) - rtn["before"].setValue("continue") - rtn["after"].setValue("continue") - rtn["input.first_lock"].setValue(True) - rtn["input.first"].setValue( - self.first_frame - ) - - if time_warp_nodes != []: - start_anim = self.first_frame + (self.handle_start / speed) - for timewarp in time_warp_nodes: - twn = nuke.createNode(timewarp["Class"], - "name {}".format(timewarp["name"])) - if isinstance(timewarp["lookup"], list): - # if array for animation - twn["lookup"].setAnimated() - for i, value in enumerate(timewarp["lookup"]): - twn["lookup"].setValueAt( - (start_anim + i) + value, - (start_anim + i)) - else: - # if static value `int` - twn["lookup"].setValue(timewarp["lookup"]) - - def loader_shift(self, read_node, workfile_start=False): - """ Set start frame of read node to a workfile start - - Args: - read_node (nuke.Node): The nuke's read node - workfile_start (bool): set workfile start frame if true - - """ - if workfile_start: - read_node['frame_mode'].setValue("start at") - read_node['frame'].setValue(str(self.script_start)) diff --git a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py index 2563ee929f..27094b8d74 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py +++ b/openpype/hosts/nuke/plugins/publish/validate_output_resolution.py @@ -56,8 +56,8 @@ class ValidateOutputResolution(pyblish.api.InstancePlugin): def process(self, instance): - # Skip bounding box check if a crop node exists. - if instance[0].dependencies()[0].Class() == "Crop": + # Skip bounding box check if a reformat node exists. + if instance[0].dependencies()[0].Class() == "Reformat": return msg = "Bounding box is outside the format." diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index d043323768..981a1ed204 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -6,7 +6,6 @@ from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name stub = photoshop.stub() - class ImageLoader(api.Loader): """Load images @@ -21,7 +20,7 @@ class ImageLoader(api.Loader): context["asset"]["name"], name) with photoshop.maintained_selection(): - layer = stub.import_smart_object(self.fname, layer_name) + layer = self.import_layer(self.fname, layer_name) self[:] = [layer] namespace = namespace or layer_name @@ -45,8 +44,9 @@ class ImageLoader(api.Loader): layer_name = "{}_{}".format(context["asset"], context["subset"]) # switching assets if namespace_from_container != layer_name: - layer_name = self._get_unique_layer_name(context["asset"], - context["subset"]) + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"], + context["subset"]) else: # switching version - keep same name layer_name = container["namespace"] @@ -72,3 +72,6 @@ class ImageLoader(api.Loader): def switch(self, container, representation): self.update(container, representation) + + def import_layer(self, file_name, layer_name): + return stub.import_smart_object(file_name, layer_name) diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py new file mode 100644 index 0000000000..0cb4e4a69f --- /dev/null +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -0,0 +1,82 @@ +import re + +from avalon import api, photoshop + +from openpype.hosts.photoshop.plugins.lib import get_unique_layer_name + +stub = photoshop.stub() + + +class ReferenceLoader(api.Loader): + """Load reference images + + Stores the imported asset in a container named after the asset. + + Inheriting from 'load_image' didn't work because of + "Cannot write to closing transport", possible refactor. + """ + + families = ["image", "render"] + representations = ["*"] + + def load(self, context, name=None, namespace=None, data=None): + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"]["name"], + name) + with photoshop.maintained_selection(): + layer = self.import_layer(self.fname, layer_name) + + self[:] = [layer] + namespace = namespace or layer_name + + return photoshop.containerise( + name, + namespace, + layer, + context, + self.__class__.__name__ + ) + + def update(self, container, representation): + """ Switch asset or change version """ + layer = container.pop("layer") + + context = representation.get("context", {}) + + namespace_from_container = re.sub(r'_\d{3}$', '', + container["namespace"]) + layer_name = "{}_{}".format(context["asset"], context["subset"]) + # switching assets + if namespace_from_container != layer_name: + layer_name = get_unique_layer_name(stub.get_layers(), + context["asset"], + context["subset"]) + else: # switching version - keep same name + layer_name = container["namespace"] + + path = api.get_representation_path(representation) + with photoshop.maintained_selection(): + stub.replace_smart_object( + layer, path, layer_name + ) + + stub.imprint( + layer, {"representation": str(representation["_id"])} + ) + + def remove(self, container): + """ + Removes element from scene: deletes layer + removes from Headline + Args: + container (dict): container to be removed - used to get layer_id + """ + layer = container.pop("layer") + stub.imprint(layer, {}) + stub.delete_layer(layer.id) + + def switch(self, container, representation): + self.update(container, representation) + + def import_layer(self, file_name, layer_name): + return stub.import_smart_object(file_name, layer_name, + as_reference=True) diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py new file mode 100644 index 0000000000..19994a0db8 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +"""Close PS after publish. For Webpublishing only.""" +import os + +import pyblish.api + +from avalon import photoshop + + +class ClosePS(pyblish.api.ContextPlugin): + """Close PS after publish. For Webpublishing only. + """ + + order = pyblish.api.IntegratorOrder + 14 + label = "Close PS" + optional = True + active = True + + hosts = ["photoshop"] + + def process(self, context): + self.log.info("ClosePS") + if not os.environ.get("IS_HEADLESS"): + return + + stub = photoshop.stub() + self.log.info("Shutting down PS") + stub.save() + stub.close() + self.log.info("PS closed") diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py new file mode 100644 index 0000000000..12f9fa5ab5 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -0,0 +1,135 @@ +import pyblish.api +import os +import re + +from avalon import photoshop +from openpype.lib import prepare_template_data +from openpype.lib.plugin_tools import parse_json + + +class CollectRemoteInstances(pyblish.api.ContextPlugin): + """Gather instances configured color code of a layer. + + Used in remote publishing when artists marks publishable layers by color- + coding. + + Identifier: + id (str): "pyblish.avalon.instance" + """ + order = pyblish.api.CollectorOrder + 0.100 + + label = "Instances" + order = pyblish.api.CollectorOrder + hosts = ["photoshop"] + + # configurable by Settings + color_code_mapping = [] + + def process(self, context): + self.log.info("CollectRemoteInstances") + self.log.info("mapping:: {}".format(self.color_code_mapping)) + if not os.environ.get("IS_HEADLESS"): + self.log.debug("Not headless publishing, skipping.") + return + + # parse variant if used in webpublishing, comes from webpublisher batch + batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + variant = "Main" + if batch_dir and os.path.exists(batch_dir): + # TODO check if batch manifest is same as tasks manifests + task_data = parse_json(os.path.join(batch_dir, + "manifest.json")) + if not task_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_dir)) + variant = task_data["variant"] + + stub = photoshop.stub() + layers = stub.get_layers() + + instance_names = [] + for layer in layers: + self.log.info("Layer:: {}".format(layer)) + resolved_family, resolved_subset_template = self._resolve_mapping( + layer + ) + self.log.info("resolved_family {}".format(resolved_family)) + self.log.info("resolved_subset_template {}".format( + resolved_subset_template)) + + if not resolved_subset_template or not resolved_family: + self.log.debug("!!! Not marked, skip") + continue + + if layer.parents: + self.log.debug("!!! Not a top layer, skip") + continue + + instance = context.create_instance(layer.name) + instance.append(layer) + instance.data["family"] = resolved_family + instance.data["publish"] = layer.visible + instance.data["asset"] = context.data["assetEntity"]["name"] + instance.data["task"] = context.data["taskType"] + + fill_pairs = { + "variant": variant, + "family": instance.data["family"], + "task": instance.data["task"], + "layer": layer.name + } + subset = resolved_subset_template.format( + **prepare_template_data(fill_pairs)) + instance.data["subset"] = subset + + instance_names.append(layer.name) + + # Produce diagnostic message for any graphical + # user interface interested in visualising it. + self.log.info("Found: \"%s\" " % instance.data["name"]) + self.log.info("instance: {} ".format(instance.data)) + + if len(instance_names) != len(set(instance_names)): + self.log.warning("Duplicate instances found. " + + "Remove unwanted via SubsetManager") + + def _resolve_mapping(self, layer): + """Matches 'layer' color code and name to mapping. + + If both color code AND name regex is configured, BOTH must be valid + If layer matches to multiple mappings, only first is used! + """ + family_list = [] + family = None + subset_name_list = [] + resolved_subset_template = None + for mapping in self.color_code_mapping: + if mapping["color_code"] and \ + layer.color_code not in mapping["color_code"]: + continue + + if mapping["layer_name_regex"] and \ + not any(re.search(pattern, layer.name) + for pattern in mapping["layer_name_regex"]): + continue + + family_list.append(mapping["family"]) + subset_name_list.append(mapping["subset_template_name"]) + + if len(subset_name_list) > 1: + self.log.warning("Multiple mappings found for '{}'". + format(layer.name)) + self.log.warning("Only first subset name template used!") + subset_name_list[:] = subset_name_list[0] + + if len(family_list) > 1: + self.log.warning("Multiple mappings found for '{}'". + format(layer.name)) + self.log.warning("Only first family used!") + family_list[:] = family_list[0] + if subset_name_list: + resolved_subset_template = subset_name_list.pop() + if family_list: + family = family_list.pop() + + return family, resolved_subset_template diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 87574d1269..ae9892e290 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -12,7 +12,7 @@ class ExtractImage(openpype.api.Extractor): label = "Extract Image" hosts = ["photoshop"] - families = ["image"] + families = ["image", "background"] formats = ["png", "jpg"] def process(self, instance): diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 1c53c3a2ef..8c4d05b282 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -17,6 +17,10 @@ class ExtractReview(openpype.api.Extractor): hosts = ["photoshop"] families = ["review"] + # Extract Options + jpg_options = None + mov_options = None + def process(self, instance): staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) @@ -53,7 +57,8 @@ class ExtractReview(openpype.api.Extractor): "name": "jpg", "ext": "jpg", "files": output_image, - "stagingDir": staging_dir + "stagingDir": staging_dir, + "tags": self.jpg_options['tags'] }) instance.data["stagingDir"] = staging_dir @@ -97,7 +102,7 @@ class ExtractReview(openpype.api.Extractor): "frameEnd": 1, "fps": 25, "preview": True, - "tags": ["review", "ftrackreview"] + "tags": self.mov_options['tags'] }) # Required for extract_review plugin (L222 onwards). diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index c639fd2db8..262ce739dd 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -8,15 +8,7 @@ from .pipeline import ( launch_workfiles_app ) -from avalon.tools import ( - creator, - sceneinventory, - subsetmanager -) -from openpype.tools import ( - loader, - libraryloader, -) +from openpype.tools.utils import host_tools def load_stylesheet(): @@ -32,7 +24,7 @@ def load_stylesheet(): class Spacer(QtWidgets.QWidget): def __init__(self, height, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) + super(Spacer, self).__init__(*args, **kwargs) self.setFixedHeight(height) @@ -49,7 +41,7 @@ class Spacer(QtWidgets.QWidget): class OpenPypeMenu(QtWidgets.QWidget): def __init__(self, *args, **kwargs): - super(self.__class__, self).__init__(*args, **kwargs) + super(OpenPypeMenu, self).__init__(*args, **kwargs) self.setObjectName("OpenPypeMenu") @@ -119,7 +111,7 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_create_clicked(self): print("Clicked Create") - creator.show() + host_tools.show_creator() def on_publish_clicked(self): print("Clicked Publish") @@ -127,19 +119,19 @@ class OpenPypeMenu(QtWidgets.QWidget): def on_load_clicked(self): print("Clicked Load") - loader.show(use_context=True) + host_tools.show_loader(use_context=True) def on_inventory_clicked(self): print("Clicked Inventory") - sceneinventory.show() + host_tools.show_scene_inventory() def on_subsetm_clicked(self): print("Clicked Subset Manager") - subsetmanager.show() + host_tools.show_subset_manager() def on_libload_clicked(self): print("Clicked Library") - libraryloader.show() + host_tools.show_library_loader() def on_rename_clicked(self): print("Clicked Rename") diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index 80249310e8..ce95cfe02a 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -4,7 +4,6 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from openpype.tools import workfiles from avalon import api as avalon from avalon import schema from avalon.pipeline import AVALON_CONTAINER_ID @@ -12,6 +11,7 @@ from pyblish import api as pyblish from openpype.api import Logger from . import lib from . import PLUGINS_DIR +from openpype.tools.utils import host_tools log = Logger().get_logger(__name__) PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") @@ -212,14 +212,12 @@ def update_container(timeline_item, data=None): def launch_workfiles_app(*args): - workdir = os.environ["AVALON_WORKDIR"] - workfiles.show(workdir) + host_tools.show_workfiles() def publish(parent): """Shorthand to publish from within host""" - from avalon.tools import publish - return publish.show(parent) + return host_tools.show_publish() @contextlib.contextmanager diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py index a5177335b3..9f075d66cf 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_bulk_mov_instances.py @@ -3,7 +3,7 @@ import json import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectBulkMovInstances(pyblish.api.InstancePlugin): @@ -26,16 +26,10 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): context = instance.context asset_name = instance.data["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - { - "_id": 1, - "data.tasks": 1 - } - ) + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) if not asset_doc: raise AssertionError(( "Couldn't find Asset document with name \"{}\"" @@ -53,11 +47,11 @@ class CollectBulkMovInstances(pyblish.api.InstancePlugin): task_name = available_task_names[_task_name_low] break - subset_name = get_subset_name( + subset_name = get_subset_name_with_asset_doc( self.new_instance_family, self.subset_name_variant, task_name, - asset_doc["_id"], + asset_doc, io.Session["AVALON_PROJECT"] ) instance_name = f"{asset_name}_{subset_name}" diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py new file mode 100644 index 0000000000..eec675e97f --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_sources.py @@ -0,0 +1,37 @@ +import pyblish.api +import openpype.api + +import os + + +class ValidateSources(pyblish.api.InstancePlugin): + """Validates source files. + + Loops through all 'files' in 'stagingDir' if actually exist. They might + got deleted between starting of SP and now. + + """ + + order = openpype.api.ValidateContentsOrder + label = "Check source files" + + optional = True # only for unforeseeable cases + + hosts = ["standalonepublisher"] + + def process(self, instance): + self.log.info("instance {}".format(instance.data)) + + for repre in instance.data.get("representations") or []: + files = [] + if isinstance(repre["files"], str): + files.append(repre["files"]) + else: + files = list(repre["files"]) + + for file_name in files: + source_file = os.path.join(repre["stagingDir"], + file_name) + + if not os.path.exists(source_file): + raise ValueError("File {} not found".format(source_file)) diff --git a/openpype/hosts/testhost/README.md b/openpype/hosts/testhost/README.md new file mode 100644 index 0000000000..f69e02a3b3 --- /dev/null +++ b/openpype/hosts/testhost/README.md @@ -0,0 +1,16 @@ +# What is `testhost` +Host `testhost` was created to fake running host for testing of publisher. + +Does not have any proper launch mechanism at the moment. There is python script `./run_publish.py` which will show publisher window. The script requires to set few variables to run. Execution will register host `testhost`, register global publish plugins and register creator and publish plugins from `./plugins`. + +## Data +Created instances and context data are stored into json files inside `./api` folder. Can be easily modified to save them to a different place. + +## Plugins +Test host has few plugins to be able test publishing. + +### Creators +They are just example plugins using functions from `api` to create/remove/update data. One of them is auto creator which means that is triggered on each reset of create context. Others are manual creators both creating the same family. + +### Publishers +Collectors are example plugin to use `get_attribute_defs` to define attributes for specific families or for context. Validators are to test `PublishValidationError`. diff --git a/openpype/hosts/testhost/__init__.py b/openpype/hosts/testhost/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/testhost/api/__init__.py b/openpype/hosts/testhost/api/__init__.py new file mode 100644 index 0000000000..7840b25892 --- /dev/null +++ b/openpype/hosts/testhost/api/__init__.py @@ -0,0 +1,43 @@ +import os +import logging +import pyblish.api +import avalon.api +from openpype.pipeline import BaseCreator + +from .pipeline import ( + ls, + list_instances, + update_instances, + remove_instances, + get_context_data, + update_context_data, + get_context_title +) + + +HOST_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") + +log = logging.getLogger(__name__) + + +def install(): + log.info("OpenPype - Installing TestHost integration") + pyblish.api.register_host("testhost") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + + +__all__ = ( + "ls", + "list_instances", + "update_instances", + "remove_instances", + "get_context_data", + "update_context_data", + "get_context_title", + + "install" +) diff --git a/openpype/hosts/testhost/api/context.json b/openpype/hosts/testhost/api/context.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/openpype/hosts/testhost/api/context.json @@ -0,0 +1 @@ +{} diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json new file mode 100644 index 0000000000..84021eff91 --- /dev/null +++ b/openpype/hosts/testhost/api/instances.json @@ -0,0 +1,108 @@ +[ + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMyVariant", + "version": 1, + "asset": "sq01_sh0010", + "task": "Compositing", + "variant": "myVariant", + "uuid": "a485f148-9121-46a5-8157-aa64df0fb449", + "creator_attributes": { + "number_key": 10, + "ha": 10 + }, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": false + } + }, + "creator_identifier": "test_one" + }, + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMyVariant2", + "version": 1, + "asset": "sq01_sh0010", + "task": "Compositing", + "variant": "myVariant2", + "uuid": "a485f148-9121-46a5-8157-aa64df0fb444", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + }, + "creator_identifier": "test_one" + }, + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMain", + "version": 1, + "asset": "sq01_sh0010", + "task": "Compositing", + "variant": "Main", + "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + }, + "creator_identifier": "test_two" + }, + { + "id": "pyblish.avalon.instance", + "active": true, + "family": "test", + "subset": "testMain2", + "version": 1, + "asset": "sq01_sh0020", + "task": "Compositing", + "variant": "Main2", + "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + }, + "creator_identifier": "test_two" + }, + { + "id": "pyblish.avalon.instance", + "family": "test_three", + "subset": "test_threeMain2", + "active": true, + "version": 1, + "asset": "sq01_sh0020", + "task": "Compositing", + "variant": "Main2", + "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec", + "creator_attributes": {}, + "publish_attributes": { + "CollectFtrackApi": { + "add_ftrack_family": true + } + } + }, + { + "id": "pyblish.avalon.instance", + "family": "workfile", + "subset": "workfileMain", + "active": true, + "creator_identifier": "workfile", + "version": 1, + "asset": "Alpaca_01", + "task": "modeling", + "variant": "Main", + "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", + "creator_attributes": {}, + "publish_attributes": {} + } +] \ No newline at end of file diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py new file mode 100644 index 0000000000..49f1d3f33d --- /dev/null +++ b/openpype/hosts/testhost/api/pipeline.py @@ -0,0 +1,156 @@ +import os +import json + + +class HostContext: + instances_json_path = None + context_json_path = None + + @classmethod + def get_context_title(cls): + project_name = os.environ.get("AVALON_PROJECT") + if not project_name: + return "TestHost" + + asset_name = os.environ.get("AVALON_ASSET") + if not asset_name: + return project_name + + from avalon import io + + asset_doc = io.find_one( + {"type": "asset", "name": asset_name}, + {"data.parents": 1} + ) + parents = asset_doc.get("data", {}).get("parents") or [] + + hierarchy = [project_name] + hierarchy.extend(parents) + hierarchy.append("{}".format(asset_name)) + task_name = os.environ.get("AVALON_TASK") + if task_name: + hierarchy.append(task_name) + + return "/".join(hierarchy) + + @classmethod + def get_current_dir_filepath(cls, filename): + return os.path.join( + os.path.dirname(os.path.abspath(__file__)), + filename + ) + + @classmethod + def get_instances_json_path(cls): + if cls.instances_json_path is None: + cls.instances_json_path = cls.get_current_dir_filepath( + "instances.json" + ) + return cls.instances_json_path + + @classmethod + def get_context_json_path(cls): + if cls.context_json_path is None: + cls.context_json_path = cls.get_current_dir_filepath( + "context.json" + ) + return cls.context_json_path + + @classmethod + def add_instance(cls, instance): + instances = cls.get_instances() + instances.append(instance) + cls.save_instances(instances) + + @classmethod + def save_instances(cls, instances): + json_path = cls.get_instances_json_path() + with open(json_path, "w") as json_stream: + json.dump(instances, json_stream, indent=4) + + @classmethod + def get_instances(cls): + json_path = cls.get_instances_json_path() + if not os.path.exists(json_path): + instances = [] + with open(json_path, "w") as json_stream: + json.dump(json_stream, instances) + else: + with open(json_path, "r") as json_stream: + instances = json.load(json_stream) + return instances + + @classmethod + def get_context_data(cls): + json_path = cls.get_context_json_path() + if not os.path.exists(json_path): + data = {} + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + else: + with open(json_path, "r") as json_stream: + data = json.load(json_stream) + return data + + @classmethod + def save_context_data(cls, data): + json_path = cls.get_context_json_path() + with open(json_path, "w") as json_stream: + json.dump(data, json_stream, indent=4) + + +def ls(): + return [] + + +def list_instances(): + return HostContext.get_instances() + + +def update_instances(update_list): + updated_instances = {} + for instance, _changes in update_list: + updated_instances[instance.id] = instance.data_to_store() + + instances = HostContext.get_instances() + for instance_data in instances: + instance_id = instance_data["uuid"] + if instance_id in updated_instances: + new_instance_data = updated_instances[instance_id] + old_keys = set(instance_data.keys()) + new_keys = set(new_instance_data.keys()) + instance_data.update(new_instance_data) + for key in (old_keys - new_keys): + instance_data.pop(key) + + HostContext.save_instances(instances) + + +def remove_instances(instances): + if not isinstance(instances, (tuple, list)): + instances = [instances] + + current_instances = HostContext.get_instances() + for instance in instances: + instance_id = instance.data["uuid"] + found_idx = None + for idx, _instance in enumerate(current_instances): + if instance_id == _instance["uuid"]: + found_idx = idx + break + + if found_idx is not None: + current_instances.pop(found_idx) + HostContext.save_instances(current_instances) + + +def get_context_data(): + return HostContext.get_context_data() + + +def update_context_data(data, changes): + HostContext.save_context_data(data) + + +def get_context_title(): + return HostContext.get_context_title() diff --git a/openpype/hosts/testhost/plugins/create/auto_creator.py b/openpype/hosts/testhost/plugins/create/auto_creator.py new file mode 100644 index 0000000000..0690164ae5 --- /dev/null +++ b/openpype/hosts/testhost/plugins/create/auto_creator.py @@ -0,0 +1,74 @@ +from openpype.hosts.testhost.api import pipeline +from openpype.pipeline import ( + AutoCreator, + CreatedInstance, + lib +) +from avalon import io + + +class MyAutoCreator(AutoCreator): + identifier = "workfile" + family = "workfile" + + def get_attribute_defs(self): + output = [ + lib.NumberDef("number_key", label="Number") + ] + return output + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + subset_name = instance_data["subset"] + instance = CreatedInstance( + self.family, subset_name, instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def create(self, options=None): + existing_instance = None + for instance in self.create_context.instances: + if instance.family == self.family: + existing_instance = instance + break + + variant = "Main" + project_name = io.Session["AVALON_PROJECT"] + asset_name = io.Session["AVALON_ASSET"] + task_name = io.Session["AVALON_TASK"] + host_name = io.Session["AVALON_APP"] + + if existing_instance is None: + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant + } + data.update(self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name diff --git a/openpype/hosts/testhost/plugins/create/test_creator_1.py b/openpype/hosts/testhost/plugins/create/test_creator_1.py new file mode 100644 index 0000000000..6ec4d16467 --- /dev/null +++ b/openpype/hosts/testhost/plugins/create/test_creator_1.py @@ -0,0 +1,70 @@ +from openpype import resources +from openpype.hosts.testhost.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class TestCreatorOne(Creator): + identifier = "test_one" + label = "test" + family = "test" + description = "Testing creator of testhost" + + def get_icon(self): + return resources.get_openpype_splash_filepath() + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def create(self, subset_name, data, options=None): + new_instance = CreatedInstance(self.family, subset_name, data, self) + pipeline.HostContext.add_instance(new_instance.data_to_store()) + self.log.info(new_instance.data) + self._add_instance_to_context(new_instance) + + def get_default_variants(self): + return [ + "myVariant", + "variantTwo", + "different_variant" + ] + + def get_attribute_defs(self): + output = [ + lib.NumberDef("number_key", label="Number") + ] + return output + + def get_detail_description(self): + return """# Relictus funes est Nyseides currusque nunc oblita + +## Causa sed + +Lorem markdownum posito consumptis, *plebe Amorque*, abstitimus rogatus fictaque +gladium Circe, nos? Bos aeternum quae. Utque me, si aliquem cladis, et vestigia +arbor, sic mea ferre lacrimae agantur prospiciens hactenus. Amanti dentes pete, +vos quid laudemque rastrorumque terras in gratantibus **radix** erat cedemus? + +Pudor tu ponderibus verbaque illa; ire ergo iam Venus patris certe longae +cruentum lecta, et quaeque. Sit doce nox. Anteit ad tempora magni plenaque et +videres mersit sibique auctor in tendunt mittit cunctos ventisque gravitate +volucris quemquam Aeneaden. Pectore Mensis somnus; pectora +[ferunt](http://www.mox.org/oculosbracchia)? Fertilitatis bella dulce et suum? + """ diff --git a/openpype/hosts/testhost/plugins/create/test_creator_2.py b/openpype/hosts/testhost/plugins/create/test_creator_2.py new file mode 100644 index 0000000000..4b1430a6a2 --- /dev/null +++ b/openpype/hosts/testhost/plugins/create/test_creator_2.py @@ -0,0 +1,74 @@ +from openpype.hosts.testhost.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class TestCreatorTwo(Creator): + identifier = "test_two" + label = "test" + family = "test" + description = "A second testing creator" + + def get_icon(self): + return "cube" + + def create(self, subset_name, data, options=None): + new_instance = CreatedInstance(self.family, subset_name, data, self) + pipeline.HostContext.add_instance(new_instance.data_to_store()) + self.log.info(new_instance.data) + self._add_instance_to_context(new_instance) + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def get_attribute_defs(self): + output = [ + lib.NumberDef("number_key"), + lib.TextDef("text_key") + ] + return output + + def get_detail_description(self): + return """# Lorem ipsum, dolor sit amet. [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://github.com/sindresorhus/awesome) + +> A curated list of awesome lorem ipsum generators. + +Inspired by the [awesome](https://github.com/sindresorhus/awesome) list thing. + + +## Table of Contents + +- [Legend](#legend) +- [Practical](#briefcase-practical) +- [Whimsical](#roller_coaster-whimsical) + - [Animals](#rabbit-animals) + - [Eras](#tophat-eras) + - [Famous Individuals](#sunglasses-famous-individuals) + - [Music](#microphone-music) + - [Food and Drink](#pizza-food-and-drink) + - [Geographic and Dialects](#earth_africa-geographic-and-dialects) + - [Literature](#books-literature) + - [Miscellaneous](#cyclone-miscellaneous) + - [Sports and Fitness](#bicyclist-sports-and-fitness) + - [TV and Film](#movie_camera-tv-and-film) +- [Tools, Apps, and Extensions](#wrench-tools-apps-and-extensions) +- [Contribute](#contribute) +- [TODO](#todo) +""" diff --git a/openpype/hosts/testhost/plugins/publish/collect_context.py b/openpype/hosts/testhost/plugins/publish/collect_context.py new file mode 100644 index 0000000000..0ab98fb84b --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/collect_context.py @@ -0,0 +1,34 @@ +import pyblish.api + +from openpype.pipeline import ( + OpenPypePyblishPluginMixin, + attribute_definitions +) + + +class CollectContextDataTestHost( + pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin +): + """ + Collecting temp json data sent from a host context + and path for returning json data back to hostself. + """ + + label = "Collect Source - Test Host" + order = pyblish.api.CollectorOrder - 0.4 + hosts = ["testhost"] + + @classmethod + def get_attribute_defs(cls): + return [ + attribute_definitions.BoolDef( + "test_bool", + True, + label="Bool input" + ) + ] + + def process(self, context): + # get json paths from os and load them + for instance in context: + instance.data["source"] = "testhost" diff --git a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py new file mode 100644 index 0000000000..3c035eccb6 --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py @@ -0,0 +1,54 @@ +import json +import pyblish.api + +from openpype.pipeline import ( + OpenPypePyblishPluginMixin, + attribute_definitions +) + + +class CollectInstanceOneTestHost( + pyblish.api.InstancePlugin, OpenPypePyblishPluginMixin +): + """ + Collecting temp json data sent from a host context + and path for returning json data back to hostself. + """ + + label = "Collect Instance 1 - Test Host" + order = pyblish.api.CollectorOrder - 0.3 + hosts = ["testhost"] + + @classmethod + def get_attribute_defs(cls): + return [ + attribute_definitions.NumberDef( + "version", + default=1, + minimum=1, + maximum=999, + decimals=0, + label="Version" + ) + ] + + def process(self, instance): + self._debug_log(instance) + + publish_attributes = instance.data.get("publish_attributes") + if not publish_attributes: + return + + values = publish_attributes.get(self.__class__.__name__) + if not values: + return + + instance.data["version"] = values["version"] + + def _debug_log(self, instance): + def _default_json(value): + return str(value) + + self.log.info( + json.dumps(instance.data, indent=4, default=_default_json) + ) diff --git a/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py new file mode 100644 index 0000000000..46e996a569 --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/validate_context_with_error.py @@ -0,0 +1,57 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateInstanceAssetRepair(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + pass + + +description = """ +## Publish plugins + +### Validate Scene Settings + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### AfterEffects Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. +""" + + +class ValidateContextWithError(pyblish.api.ContextPlugin): + """Validate the instance asset is the current selected context asset. + + As it might happen that multiple worfiles are opened, switching + between them would mess with selected context. + In that case outputs might be output under wrong asset! + + Repair action will use Context asset value (from Workfiles or Launcher) + Closing and reopening with Workfiles will refresh Context value. + """ + + label = "Validate Context With Error" + hosts = ["testhost"] + actions = [ValidateInstanceAssetRepair] + order = pyblish.api.ValidatorOrder + + def process(self, context): + raise PublishValidationError("Crashing", "Context error", description) diff --git a/openpype/hosts/testhost/plugins/publish/validate_with_error.py b/openpype/hosts/testhost/plugins/publish/validate_with_error.py new file mode 100644 index 0000000000..5a2888a8b0 --- /dev/null +++ b/openpype/hosts/testhost/plugins/publish/validate_with_error.py @@ -0,0 +1,57 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateInstanceAssetRepair(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + pass + + +description = """ +## Publish plugins + +### Validate Scene Settings + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### AfterEffects Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. +""" + + +class ValidateWithError(pyblish.api.InstancePlugin): + """Validate the instance asset is the current selected context asset. + + As it might happen that multiple worfiles are opened, switching + between them would mess with selected context. + In that case outputs might be output under wrong asset! + + Repair action will use Context asset value (from Workfiles or Launcher) + Closing and reopening with Workfiles will refresh Context value. + """ + + label = "Validate With Error" + hosts = ["testhost"] + actions = [ValidateInstanceAssetRepair] + order = pyblish.api.ValidatorOrder + + def process(self, instance): + raise PublishValidationError("Crashing", "Instance error", description) diff --git a/openpype/hosts/testhost/run_publish.py b/openpype/hosts/testhost/run_publish.py new file mode 100644 index 0000000000..44860a30e4 --- /dev/null +++ b/openpype/hosts/testhost/run_publish.py @@ -0,0 +1,70 @@ +import os +import sys + +mongo_url = "" +project_name = "" +asset_name = "" +task_name = "" +ftrack_url = "" +ftrack_username = "" +ftrack_api_key = "" + + +def multi_dirname(path, times=1): + for _ in range(times): + path = os.path.dirname(path) + return path + + +host_name = "testhost" +current_file = os.path.abspath(__file__) +openpype_dir = multi_dirname(current_file, 4) + +os.environ["OPENPYPE_MONGO"] = mongo_url +os.environ["OPENPYPE_ROOT"] = openpype_dir +os.environ["AVALON_MONGO"] = mongo_url +os.environ["AVALON_PROJECT"] = project_name +os.environ["AVALON_ASSET"] = asset_name +os.environ["AVALON_TASK"] = task_name +os.environ["AVALON_APP"] = host_name +os.environ["OPENPYPE_DATABASE_NAME"] = "openpype" +os.environ["AVALON_CONFIG"] = "openpype" +os.environ["AVALON_TIMEOUT"] = "1000" +os.environ["AVALON_DB"] = "avalon" +os.environ["FTRACK_SERVER"] = ftrack_url +os.environ["FTRACK_API_USER"] = ftrack_username +os.environ["FTRACK_API_KEY"] = ftrack_api_key +for path in [ + openpype_dir, + r"{}\repos\avalon-core".format(openpype_dir), + r"{}\.venv\Lib\site-packages".format(openpype_dir) +]: + sys.path.append(path) + +from Qt import QtWidgets, QtCore + +from openpype.tools.publisher.window import PublisherWindow + + +def main(): + """Main function for testing purposes.""" + import avalon.api + import pyblish.api + from openpype.modules import ModulesManager + from openpype.hosts.testhost import api as testhost + + manager = ModulesManager() + for plugin_path in manager.collect_plugin_paths()["publish"]: + pyblish.api.register_plugin_path(plugin_path) + + avalon.api.install(testhost) + + QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) + app = QtWidgets.QApplication([]) + window = PublisherWindow() + window.show() + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index dfa8f17ee9..1d7a48e389 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -4,7 +4,7 @@ import copy import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectInstances(pyblish.api.ContextPlugin): @@ -70,16 +70,10 @@ class CollectInstances(pyblish.api.ContextPlugin): # - not sure if it's good idea to require asset id in # get_subset_name? asset_name = context.data["workfile_context"]["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - {"_id": 1} - ) - asset_id = None - if asset_doc: - asset_id = asset_doc["_id"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) # Project name from workfile context project_name = context.data["workfile_context"]["project"] @@ -88,11 +82,11 @@ class CollectInstances(pyblish.api.ContextPlugin): # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] - new_subset_name = get_subset_name( + new_subset_name = get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name, host_name ) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index 65e38ea258..68ba350a85 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -3,7 +3,7 @@ import json import pyblish.api from avalon import io -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectWorkfile(pyblish.api.ContextPlugin): @@ -28,16 +28,10 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # get_subset_name? family = "workfile" asset_name = context.data["workfile_context"]["asset"] - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - {"_id": 1} - ) - asset_id = None - if asset_doc: - asset_id = asset_doc["_id"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) # Project name from workfile context project_name = context.data["workfile_context"]["project"] @@ -46,11 +40,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] - subset_name = get_subset_name( + subset_name = get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name, host_name ) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index 7e34c3ff15..c0fafbb667 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -253,6 +253,7 @@ def create_unreal_project(project_name: str, "Plugins": [ {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, + {"Name": "SequencerScripting", "Enabled": True}, {"Name": "Avalon", "Enabled": True} ] } diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 01b8b6bc05..880dba5cfb 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -6,7 +6,9 @@ from pathlib import Path from openpype.lib import ( PreLaunchHook, ApplicationLaunchFailed, - ApplicationNotFound + ApplicationNotFound, + get_workdir_data, + get_workfile_template_key ) from openpype.hosts.unreal.api import lib as unreal_lib @@ -25,13 +27,46 @@ class UnrealPrelaunchHook(PreLaunchHook): self.signature = "( {} )".format(self.__class__.__name__) + def _get_work_filename(self): + # Use last workfile if was found + if self.data.get("last_workfile_path"): + last_workfile = Path(self.data.get("last_workfile_path")) + if last_workfile and last_workfile.exists(): + return last_workfile.name + + # Prepare data for fill data and for getting workfile template key + task_name = self.data["task_name"] + anatomy = self.data["anatomy"] + asset_doc = self.data["asset_doc"] + project_doc = self.data["project_doc"] + + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + + workdir_data = get_workdir_data( + project_doc, asset_doc, task_name, self.host_name + ) + # QUESTION raise exception if version is part of filename template? + workdir_data["version"] = 1 + workdir_data["ext"] = "uproject" + + # Get workfile template key for current context + workfile_template_key = get_workfile_template_key( + task_type, + self.host_name, + project_name=project_doc["name"] + ) + # Fill templates + filled_anatomy = anatomy.format(workdir_data) + + # Return filename + return filled_anatomy[workfile_template_key]["file"] + def execute(self): """Hook entry method.""" - asset_name = self.data["asset_name"] - task_name = self.data["task_name"] workdir = self.launch_context.env["AVALON_WORKDIR"] engine_version = self.app_name.split("/")[-1].replace("-", ".") - unreal_project_name = f"{asset_name}_{task_name}" try: if int(engine_version.split(".")[0]) < 4 and \ int(engine_version.split(".")[1]) < 26: @@ -45,6 +80,8 @@ class UnrealPrelaunchHook(PreLaunchHook): # so lets keep it quite. ... + unreal_project_filename = self._get_work_filename() + unreal_project_name = os.path.splitext(unreal_project_filename)[0] # Unreal is sensitive about project names longer then 20 chars if len(unreal_project_name) > 20: self.log.warning(( @@ -89,10 +126,10 @@ class UnrealPrelaunchHook(PreLaunchHook): ue4_path = unreal_lib.get_editor_executable_path( Path(detected[engine_version])) - self.launch_context.launch_args.append(ue4_path.as_posix()) + self.launch_context.launch_args = [ue4_path.as_posix()] project_path.mkdir(parents=True, exist_ok=True) - project_file = project_path / f"{unreal_project_name}.uproject" + project_file = project_path / unreal_project_filename if not project_file.is_file(): engine_path = detected[engine_version] self.log.info(( diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py new file mode 100644 index 0000000000..eda2b52be3 --- /dev/null +++ b/openpype/hosts/unreal/plugins/create/create_camera.py @@ -0,0 +1,43 @@ +import unreal +from unreal import EditorAssetLibrary as eal +from unreal import EditorLevelLibrary as ell + +from openpype.hosts.unreal.api.plugin import Creator +from avalon.unreal import ( + instantiate, +) + + +class CreateCamera(Creator): + """Layout output for character rigs""" + + name = "layoutMain" + label = "Camera" + family = "camera" + icon = "cubes" + + root = "/Game/Avalon/Instances" + suffix = "_INS" + + def __init__(self, *args, **kwargs): + super(CreateCamera, self).__init__(*args, **kwargs) + + def process(self): + data = self.data + + name = data["subset"] + + data["level"] = ell.get_editor_world().get_path_name() + + if not eal.does_directory_exist(self.root): + eal.make_directory(self.root) + + factory = unreal.LevelSequenceFactoryNew() + tools = unreal.AssetToolsHelpers().get_asset_tools() + tools.create_asset(name, f"{self.root}/{name}", None, factory) + + asset_name = f"{self.root}/{name}/{name}.{name}" + + data["members"] = [asset_name] + + instantiate(f"{self.root}", name, data, None, self.suffix) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py new file mode 100644 index 0000000000..b2b25eec73 --- /dev/null +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -0,0 +1,206 @@ +import os + +from avalon import api, io, pipeline +from avalon.unreal import lib +from avalon.unreal import pipeline as unreal_pipeline +import unreal + + +class CameraLoader(api.Loader): + """Load Unreal StaticMesh from FBX""" + + families = ["camera"] + label = "Load Camera" + representations = ["fbx"] + icon = "cube" + color = "orange" + + def load(self, context, name, namespace, data): + """ + Load and containerise representation into Content Browser. + + This is two step process. First, import FBX to temporary path and + then call `containerise()` on it - this moves all content to new + directory and then it will create AssetContainer there and imprint it + with metadata. This will mark this path as container. + + Args: + context (dict): application context + name (str): subset name + namespace (str): in Unreal this is basically path to container. + This is not passed here, so namespace is set + by `containerise()` because only then we know + real path. + data (dict): Those would be data to be imprinted. This is not used + now, data are imprinted by `containerise()`. + + Returns: + list(str): list of container content + """ + + # Create directory for asset and avalon container + root = "/Game/Avalon/Assets" + asset = context.get('asset').get('name') + suffix = "_CON" + if asset: + asset_name = "{}_{}".format(asset, name) + else: + asset_name = "{}".format(name) + + tools = unreal.AssetToolsHelpers().get_asset_tools() + + unique_number = 1 + + if unreal.EditorAssetLibrary.does_directory_exist(f"{root}/{asset}"): + asset_content = unreal.EditorAssetLibrary.list_assets( + f"{root}/{asset}", recursive=False, include_folder=True + ) + + # Get highest number to make a unique name + folders = [a for a in asset_content + if a[-1] == "/" and f"{name}_" in a] + f_numbers = [] + for f in folders: + # Get number from folder name. Splits the string by "_" and + # removes the last element (which is a "/"). + f_numbers.append(int(f.split("_")[-1][:-1])) + f_numbers.sort() + if not f_numbers: + unique_number = 1 + else: + unique_number = f_numbers[-1] + 1 + + asset_dir, container_name = tools.create_unique_asset_name( + f"{root}/{asset}/{name}_{unique_number:02d}", suffix="") + + container_name += suffix + + unreal.EditorAssetLibrary.make_directory(asset_dir) + + sequence = tools.create_asset( + asset_name=asset_name, + package_path=asset_dir, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + io_asset = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": io_asset + }) + + data = asset_doc.get("data") + + if data: + sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) + sequence.set_playback_start(data.get("frameStart")) + sequence.set_playback_end(data.get("frameEnd")) + + settings = unreal.MovieSceneUserImportFBXSettings() + settings.set_editor_property('reduce_keys', False) + + unreal.SequencerTools.import_fbx( + unreal.EditorLevelLibrary.get_editor_world(), + sequence, + sequence.get_bindings(), + settings, + self.fname + ) + + # Create Asset Container + lib.create_avalon_container(container=container_name, path=asset_dir) + + data = { + "schema": "openpype:container-2.0", + "id": pipeline.AVALON_CONTAINER_ID, + "asset": asset, + "namespace": asset_dir, + "container_name": container_name, + "asset_name": asset_name, + "loader": str(self.__class__.__name__), + "representation": context["representation"]["_id"], + "parent": context["representation"]["parent"], + "family": context["representation"]["context"]["family"] + } + unreal_pipeline.imprint( + "{}/{}".format(asset_dir, container_name), data) + + asset_content = unreal.EditorAssetLibrary.list_assets( + asset_dir, recursive=True, include_folder=True + ) + + for a in asset_content: + unreal.EditorAssetLibrary.save_asset(a) + + return asset_content + + def update(self, container, representation): + path = container["namespace"] + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + tools = unreal.AssetToolsHelpers().get_asset_tools() + + asset_content = unreal.EditorAssetLibrary.list_assets( + path, recursive=False, include_folder=False + ) + asset_name = "" + for a in asset_content: + asset = ar.get_asset_by_object_path(a) + if a.endswith("_CON"): + loaded_asset = unreal.EditorAssetLibrary.load_asset(a) + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, "representation", str(representation["_id"]) + ) + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, "parent", str(representation["parent"]) + ) + asset_name = unreal.EditorAssetLibrary.get_metadata_tag( + loaded_asset, "asset_name" + ) + elif asset.asset_class == "LevelSequence": + unreal.EditorAssetLibrary.delete_asset(a) + + sequence = tools.create_asset( + asset_name=asset_name, + package_path=path, + asset_class=unreal.LevelSequence, + factory=unreal.LevelSequenceFactoryNew() + ) + + io_asset = io.Session["AVALON_ASSET"] + asset_doc = io.find_one({ + "type": "asset", + "name": io_asset + }) + + data = asset_doc.get("data") + + if data: + sequence.set_display_rate(unreal.FrameRate(data.get("fps"), 1.0)) + sequence.set_playback_start(data.get("frameStart")) + sequence.set_playback_end(data.get("frameEnd")) + + settings = unreal.MovieSceneUserImportFBXSettings() + settings.set_editor_property('reduce_keys', False) + + unreal.SequencerTools.import_fbx( + unreal.EditorLevelLibrary.get_editor_world(), + sequence, + sequence.get_bindings(), + settings, + str(representation["data"]["path"]) + ) + + def remove(self, container): + path = container["namespace"] + parent_path = os.path.dirname(path) + + unreal.EditorAssetLibrary.delete_directory(path) + + asset_content = unreal.EditorAssetLibrary.list_assets( + parent_path, recursive=False, include_folder=True + ) + + if len(asset_content) == 0: + unreal.EditorAssetLibrary.delete_directory(parent_path) diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py new file mode 100644 index 0000000000..10862fc0ef --- /dev/null +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -0,0 +1,54 @@ +import os + +import unreal +from unreal import EditorAssetLibrary as eal +from unreal import EditorLevelLibrary as ell + +import openpype.api + + +class ExtractCamera(openpype.api.Extractor): + """Extract a camera.""" + + label = "Extract Camera" + hosts = ["unreal"] + families = ["camera"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + fbx_filename = "{}.fbx".format(instance.name) + + # Perform extraction + self.log.info("Performing extraction..") + + # Check if the loaded level is the same of the instance + current_level = ell.get_editor_world().get_path_name() + assert current_level == instance.data.get("level"), \ + "Wrong level loaded" + + for member in instance[:]: + data = eal.find_asset_data(member) + if data.asset_class == "LevelSequence": + ar = unreal.AssetRegistryHelpers.get_asset_registry() + sequence = ar.get_asset_by_object_path(member).get_asset() + unreal.SequencerTools.export_fbx( + ell.get_editor_world(), + sequence, + sequence.get_bindings(), + unreal.FbxExportOption(), + os.path.join(stagingdir, fbx_filename) + ) + break + + if "representations" not in instance.data: + instance.data["representations"] = [] + + fbx_representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': fbx_filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(fbx_representation) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index 7e9b98956a..ecd65ebae4 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -15,6 +15,7 @@ import tempfile import pyblish.api from avalon import io from openpype.lib import prepare_template_data +from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info class CollectPublishedFiles(pyblish.api.ContextPlugin): @@ -33,22 +34,6 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): # from Settings task_type_to_family = {} - def _load_json(self, path): - path = path.strip('\"') - assert os.path.isfile(path), ( - "Path to json file doesn't exist. \"{}\"".format(path) - ) - data = None - with open(path, "r") as json_file: - try: - data = json.load(json_file) - except Exception as exc: - self.log.error( - "Error loading json: " - "{} - Exception: {}".format(path, exc) - ) - return data - def _process_batch(self, dir_url): task_subfolders = [ os.path.join(dir_url, o) @@ -56,22 +41,15 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): if os.path.isdir(os.path.join(dir_url, o))] self.log.info("task_sub:: {}".format(task_subfolders)) for task_dir in task_subfolders: - task_data = self._load_json(os.path.join(task_dir, - "manifest.json")) + task_data = parse_json(os.path.join(task_dir, + "manifest.json")) self.log.info("task_data:: {}".format(task_data)) ctx = task_data["context"] - task_type = "default_task_type" - task_name = None - if ctx["type"] == "task": - items = ctx["path"].split('/') - asset = items[-2] - os.environ["AVALON_TASK"] = ctx["name"] - task_name = ctx["name"] - task_type = ctx["attributes"]["type"] - else: - asset = ctx["name"] - os.environ["AVALON_TASK"] = "" + asset, task_name, task_type = get_batch_asset_task_info(ctx) + + if task_name: + os.environ["AVALON_TASK"] = task_name is_sequence = len(task_data["files"]) > 1 @@ -261,7 +239,7 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): assert batch_dir, ( "Missing `OPENPYPE_PUBLISH_DATA`") - assert batch_dir, \ + assert os.path.exists(batch_dir), \ "Folder {} doesn't exist".format(batch_dir) project_name = os.environ.get("AVALON_PROJECT") diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index 0014d1b344..920ed042dc 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -11,6 +11,7 @@ from avalon.api import AvalonMongoDB from openpype.lib import OpenPypeMongoConnection from openpype_modules.avalon_apps.rest_api import _RestApiEndpoint +from openpype.lib.plugin_tools import parse_json from openpype.lib import PypeLogger @@ -175,6 +176,9 @@ class TaskNode(Node): class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): """Triggers headless publishing of batch.""" async def post(self, request) -> Response: + # for postprocessing in host, currently only PS + host_map = {"photoshop": [".psd", ".psb"]} + output = {} log.info("WebpublisherBatchPublishEndpoint called") content = await request.json() @@ -182,10 +186,44 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): batch_path = os.path.join(self.resource.upload_dir, content["batch"]) + add_args = { + "host": "webpublisher", + "project": content["project_name"], + "user": content["user"] + } + + command = "remotepublish" + + if content.get("studio_processing"): + log.info("Post processing called") + + batch_data = parse_json(os.path.join(batch_path, "manifest.json")) + if not batch_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_path)) + task_dir_name = batch_data["tasks"][0] + task_data = parse_json(os.path.join(batch_path, task_dir_name, + "manifest.json")) + if not task_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(task_data)) + + command = "remotepublishfromapp" + for host, extensions in host_map.items(): + for ext in extensions: + for file_name in task_data["files"]: + if ext in file_name: + add_args["host"] = host + break + + if not add_args.get("host"): + raise ValueError( + "Couldn't discern host from {}".format(task_data["files"])) + openpype_app = self.resource.executable args = [ openpype_app, - 'remotepublish', + command, batch_path ] @@ -193,12 +231,6 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): msg = "Non existent OpenPype executable {}".format(openpype_app) raise RuntimeError(msg) - add_args = { - "host": "webpublisher", - "project": content["project_name"], - "user": content["user"] - } - for key, value in add_args.items(): args.append("--{}".format(key)) args.append(value) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 74004a1239..ee4821b80d 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -130,6 +130,7 @@ from .applications import ( from .plugin_tools import ( TaskNotSetError, get_subset_name, + get_subset_name_with_asset_doc, prepare_template_data, filter_pyblish_plugins, set_plugin_attributes_from_settings, @@ -249,6 +250,7 @@ __all__ = [ "TaskNotSetError", "get_subset_name", + "get_subset_name_with_asset_doc", "filter_pyblish_plugins", "set_plugin_attributes_from_settings", "source_hash", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index cc8cb8e7be..b9bcecd3a0 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -461,13 +461,8 @@ class ApplicationExecutable: # On MacOS check if exists path to executable when ends with `.app` # - it is common that path will lead to "/Applications/Blender" but # real path is "/Applications/Blender.app" - if ( - platform.system().lower() == "darwin" - and not os.path.exists(executable) - ): - _executable = executable + ".app" - if os.path.exists(_executable): - executable = _executable + if platform.system().lower() == "darwin": + executable = self.macos_executable_prep(executable) self.executable_path = executable @@ -477,6 +472,45 @@ class ApplicationExecutable: def __repr__(self): return "<{}> {}".format(self.__class__.__name__, self.executable_path) + @staticmethod + def macos_executable_prep(executable): + """Try to find full path to executable file. + + Real executable is stored in '*.app/Contents/MacOS/'. + + Having path to '*.app' gives ability to read it's plist info and + use "CFBundleExecutable" key from plist to know what is "executable." + + Plist is stored in '*.app/Contents/Info.plist'. + + This is because some '*.app' directories don't have same permissions + as real executable. + """ + # Try to find if there is `.app` file + if not os.path.exists(executable): + _executable = executable + ".app" + if os.path.exists(_executable): + executable = _executable + + # Try to find real executable if executable has `Contents` subfolder + contents_dir = os.path.join(executable, "Contents") + if os.path.exists(contents_dir): + executable_filename = None + # Load plist file and check for bundle executable + plist_filepath = os.path.join(contents_dir, "Info.plist") + if os.path.exists(plist_filepath): + import plistlib + + parsed_plist = plistlib.readPlist(plist_filepath) + executable_filename = parsed_plist.get("CFBundleExecutable") + + if executable_filename: + executable = os.path.join( + contents_dir, "MacOS", executable_filename + ) + + return executable + def as_args(self): return [self.executable_path] diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index 5735cbc99d..c89e2e7ae0 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -245,6 +245,27 @@ def process_sequence( report_items["Source file was not found"].append(msg) return report_items, 0 + delivery_templates = anatomy.templates.get("delivery") or {} + delivery_template = delivery_templates.get(template_name) + if delivery_template is None: + msg = ( + "Delivery template \"{}\" in anatomy of project \"{}\"" + " was not found" + ).format(template_name, anatomy.project_name) + report_items[""].append(msg) + return report_items, 0 + + # Check if 'frame' key is available in template which is required + # for sequence delivery + if "{frame" not in delivery_template: + msg = ( + "Delivery template \"{}\" in anatomy of project \"{}\"" + "does not contain '{{frame}}' key to fill. Delivery of sequence" + " can't be processed." + ).format(template_name, anatomy.project_name) + report_items[""].append(msg) + return report_items, 0 + dir_path, file_name = os.path.split(str(src_path)) context = repre["context"] diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index 9dccadc44e..aa9e0c9b57 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -6,6 +6,7 @@ import logging import re import json import tempfile +import distutils from .execute import run_subprocess from .profiles_filtering import filter_profiles @@ -27,17 +28,44 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) -def get_subset_name( +def get_subset_name_with_asset_doc( family, variant, task_name, - asset_id, + asset_doc, project_name=None, host_name=None, default_template=None, - dynamic_data=None, - dbcon=None + dynamic_data=None ): + """Calculate subset name based on passed context and OpenPype settings. + + Subst name templates are defined in `project_settings/global/tools/creator + /subset_name_profiles` where are profiles with host name, family, task name + and task type filters. If context does not match any profile then + `DEFAULT_SUBSET_TEMPLATE` is used as default template. + + That's main reason why so many arguments are required to calculate subset + name. + + Args: + family (str): Instance family. + variant (str): In most of cases it is user input during creation. + task_name (str): Task name on which context is instance created. + asset_doc (dict): Queried asset document with it's tasks in data. + Used to get task type. + project_name (str): Name of project on which is instance created. + Important for project settings that are loaded. + host_name (str): One of filtering criteria for template profile + filters. + default_template (str): Default template if any profile does not match + passed context. Constant 'DEFAULT_SUBSET_TEMPLATE' is used if + is not passed. + dynamic_data (dict): Dynamic data specific for a creator which creates + instance. + dbcon (AvalonMongoDB): Mongo connection to be able query asset document + if 'asset_doc' is not passed. + """ if not family: return "" @@ -52,25 +80,6 @@ def get_subset_name( project_name = avalon.api.Session["AVALON_PROJECT"] - # Function should expect asset document instead of asset id - # - that way `dbcon` is not needed - if dbcon is None: - from avalon.api import AvalonMongoDB - - dbcon = AvalonMongoDB() - dbcon.Session["AVALON_PROJECT"] = project_name - - dbcon.install() - - asset_doc = dbcon.find_one( - { - "type": "asset", - "_id": asset_id - }, - { - "data.tasks": True - } - ) asset_tasks = asset_doc.get("data", {}).get("tasks") or {} task_info = asset_tasks.get(task_name) or {} task_type = task_info.get("type") @@ -112,6 +121,49 @@ def get_subset_name( return template.format(**prepare_template_data(fill_pairs)) +def get_subset_name( + family, + variant, + task_name, + asset_id, + project_name=None, + host_name=None, + default_template=None, + dynamic_data=None, + dbcon=None +): + """Calculate subset name using OpenPype settings. + + This variant of function expects asset id as argument. + + This is legacy function should be replaced with + `get_subset_name_with_asset_doc` where asset document is expected. + """ + if dbcon is None: + from avalon.api import AvalonMongoDB + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + dbcon.install() + + asset_doc = dbcon.find_one( + {"_id": asset_id}, + {"data.tasks": True} + ) or {} + + return get_subset_name_with_asset_doc( + family, + variant, + task_name, + asset_doc, + project_name, + host_name, + default_template, + dynamic_data + ) + + def prepare_template_data(fill_pairs): """ Prepares formatted data for filling template. @@ -377,7 +429,7 @@ def oiio_supported(): """ Checks if oiiotool is configured for this platform. - Expects full path to executable. + Triggers simple subprocess, handles exception if fails. 'should_decompress' will throw exception if configured, but not present or not working. @@ -385,7 +437,10 @@ def oiio_supported(): (bool) """ oiio_path = get_oiio_tools_path() - if not oiio_path or not os.path.exists(oiio_path): + if oiio_path: + oiio_path = distutils.spawn.find_executable(oiio_path) + + if not oiio_path: log.debug("OIIOTool is not configured or not present at {}". format(oiio_path)) return False @@ -483,3 +538,48 @@ def should_decompress(file_url): "compression: \"dwab\"" in output return False + + +def parse_json(path): + """Parses json file at 'path' location + + Returns: + (dict) or None if unparsable + Raises: + AsssertionError if 'path' doesn't exist + """ + path = path.strip('\"') + assert os.path.isfile(path), ( + "Path to json file doesn't exist. \"{}\"".format(path) + ) + data = None + with open(path, "r") as json_file: + try: + data = json.load(json_file) + except Exception as exc: + log.error( + "Error loading json: " + "{} - Exception: {}".format(path, exc) + ) + return data + + +def get_batch_asset_task_info(ctx): + """Parses context data from webpublisher's batch metadata + + Returns: + (tuple): asset, task_name (Optional), task_type + """ + task_type = "default_task_type" + task_name = None + asset = None + + if ctx["type"] == "task": + items = ctx["path"].split('/') + asset = items[-2] + task_name = ctx["name"] + task_type = ctx["attributes"]["type"] + else: + asset = ctx["name"] + + return asset, task_name, task_type diff --git a/openpype/lib/python_2_comp.py b/openpype/lib/python_2_comp.py new file mode 100644 index 0000000000..d7137dbe9c --- /dev/null +++ b/openpype/lib/python_2_comp.py @@ -0,0 +1,41 @@ +import weakref + + +class _weak_callable: + def __init__(self, obj, func): + self.im_self = obj + self.im_func = func + + def __call__(self, *args, **kws): + if self.im_self is None: + return self.im_func(*args, **kws) + else: + return self.im_func(self.im_self, *args, **kws) + + +class WeakMethod: + """ Wraps a function or, more importantly, a bound method in + a way that allows a bound method's object to be GCed, while + providing the same interface as a normal weak reference. """ + + def __init__(self, fn): + try: + self._obj = weakref.ref(fn.im_self) + self._meth = fn.im_func + except AttributeError: + # It's not a bound method + self._obj = None + self._meth = fn + + def __call__(self): + if self._dead(): + return None + return _weak_callable(self._getobj(), self._meth) + + def _dead(self): + return self._obj is not None and self._obj() is None + + def _getobj(self): + if self._obj is None: + return None + return self._obj() diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py new file mode 100644 index 0000000000..51007cfad2 --- /dev/null +++ b/openpype/lib/remote_publish.py @@ -0,0 +1,159 @@ +import os +from datetime import datetime +import sys +from bson.objectid import ObjectId + +import pyblish.util +import pyblish.api + +from openpype import uninstall +from openpype.lib.mongo import OpenPypeMongoConnection + + +def get_webpublish_conn(): + """Get connection to OP 'webpublishes' collection.""" + mongo_client = OpenPypeMongoConnection.get_mongo_client() + database_name = os.environ["OPENPYPE_DATABASE_NAME"] + return mongo_client[database_name]["webpublishes"] + + +def start_webpublish_log(dbcon, batch_id, user): + """Start new log record for 'batch_id' + + Args: + dbcon (OpenPypeMongoConnection) + batch_id (str) + user (str) + Returns + (ObjectId) from DB + """ + return dbcon.insert_one({ + "batch_id": batch_id, + "start_date": datetime.now(), + "user": user, + "status": "in_progress" + }).inserted_id + + +def publish_and_log(dbcon, _id, log, close_plugin_name=None): + """Loops through all plugins, logs ok and fails into OP DB. + + Args: + dbcon (OpenPypeMongoConnection) + _id (str) + log (OpenPypeLogger) + close_plugin_name (str): name of plugin with responsibility to + close host app + """ + # Error exit as soon as any error occurs. + error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" + + close_plugin = _get_close_plugin(close_plugin_name, log) + + if isinstance(_id, str): + _id = ObjectId(_id) + + log_lines = [] + for result in pyblish.util.publish_iter(): + for record in result["records"]: + log_lines.append("{}: {}".format( + result["plugin"].label, record.msg)) + + if result["error"]: + log.error(error_format.format(**result)) + uninstall() + log_lines.append(error_format.format(**result)) + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "finish_date": datetime.now(), + "status": "error", + "log": os.linesep.join(log_lines) + + }} + ) + if close_plugin: # close host app explicitly after error + context = pyblish.api.Context() + close_plugin().process(context) + sys.exit(1) + else: + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "progress": max(result["progress"], 0.95), + "log": os.linesep.join(log_lines) + }} + ) + + # final update + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "finish_date": datetime.now(), + "status": "finished_ok", + "progress": 1, + "log": os.linesep.join(log_lines) + }} + ) + + +def fail_batch(_id, batches_in_progress, dbcon): + """Set current batch as failed as there are some stuck batches.""" + running_batches = [str(batch["_id"]) + for batch in batches_in_progress + if batch["_id"] != _id] + msg = "There are still running batches {}\n". \ + format("\n".join(running_batches)) + msg += "Ask admin to check them and reprocess current batch" + dbcon.update_one( + {"_id": _id}, + {"$set": + { + "finish_date": datetime.now(), + "status": "error", + "log": msg + + }} + ) + raise ValueError(msg) + + +def find_variant_key(application_manager, host): + """Searches for latest installed variant for 'host' + + Args: + application_manager (ApplicationManager) + host (str) + Returns + (string) (optional) + Raises: + (ValueError) if no variant found + """ + app_group = application_manager.app_groups.get(host) + if not app_group or not app_group.enabled: + raise ValueError("No application {} configured".format(host)) + + found_variant_key = None + # finds most up-to-date variant if any installed + for variant_key, variant in app_group.variants.items(): + for executable in variant.executables: + if executable.exists(): + found_variant_key = variant_key + + if not found_variant_key: + raise ValueError("No executable for {} found".format(host)) + + return found_variant_key + + +def _get_close_plugin(close_plugin_name, log): + if close_plugin_name: + plugins = pyblish.api.discover() + for plugin in plugins: + if plugin.__name__ == close_plugin_name: + return plugin + + log.warning("Close plugin not found, app might not close.") diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 748c7857a9..7779fff6ec 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -48,7 +48,7 @@ class _ModuleClass(object): def __getattr__(self, attr_name): if attr_name not in self.__attributes__: - if attr_name in ("__path__"): + if attr_name in ("__path__", "__file__"): return None raise ImportError("No module named {}.{}".format( self.name, attr_name @@ -104,6 +104,9 @@ class _InterfacesClass(_ModuleClass): """ def __getattr__(self, attr_name): if attr_name not in self.__attributes__: + if attr_name in ("__path__", "__file__"): + return None + # Fake Interface if is not missing self.__attributes__[attr_name] = type( attr_name, diff --git a/openpype/modules/default_modules/avalon_apps/avalon_app.py b/openpype/modules/default_modules/avalon_apps/avalon_app.py index d21b37e520..9e650a097e 100644 --- a/openpype/modules/default_modules/avalon_apps/avalon_app.py +++ b/openpype/modules/default_modules/avalon_apps/avalon_app.py @@ -1,6 +1,5 @@ import os import openpype -from openpype import resources from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayModule @@ -52,16 +51,12 @@ class AvalonModule(OpenPypeModule, ITrayModule): def tray_init(self): # Add library tool try: - from Qt import QtGui - from avalon import style from openpype.tools.libraryloader import LibraryLoaderWindow self.libraryloader = LibraryLoaderWindow( - icon=QtGui.QIcon(resources.get_openpype_icon_filepath()), show_projects=True, show_libraries=True ) - self.libraryloader.setStyleSheet(style.load_stylesheet()) except Exception: self.log.warning( "Couldn't load Library loader tool for tray.", @@ -70,6 +65,9 @@ class AvalonModule(OpenPypeModule, ITrayModule): # Definition of Tray menu def tray_menu(self, tray_menu): + if self.libraryloader is None: + return + from Qt import QtWidgets # Actions action_library_loader = QtWidgets.QAction( @@ -87,6 +85,9 @@ class AvalonModule(OpenPypeModule, ITrayModule): return def show_library_loader(self): + if self.libraryloader is None: + return + self.libraryloader.show() # Raise and activate the window diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index 968bffd890..1bc4eaa067 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -11,7 +11,7 @@ import pyblish.api class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): """Collect Deadline Webservice URL from instance.""" - order = pyblish.api.CollectorOrder + 0.01 + order = pyblish.api.CollectorOrder + 0.02 label = "Deadline Webservice from the Instance" families = ["rendering"] diff --git a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py index afb8583069..53231bd7e4 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -6,7 +6,7 @@ import pyblish.api class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): """Collect default Deadline Webservice URL.""" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder + 0.01 label = "Default Deadline Webservice" def process(self, context): diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/action_prepare_project.py b/openpype/modules/default_modules/ftrack/event_handlers_server/action_prepare_project.py index 85317031b2..2e55be2743 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/action_prepare_project.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/action_prepare_project.py @@ -3,6 +3,7 @@ import json from avalon.api import AvalonMongoDB from openpype.api import ProjectSettings from openpype.lib import create_project +from openpype.settings import SaveWarningExc from openpype_modules.ftrack.lib import ( ServerAction, @@ -312,7 +313,6 @@ class PrepareProjectServer(ServerAction): if not in_data: return - root_values = {} root_key = "__root__" for key in tuple(in_data.keys()): @@ -392,7 +392,12 @@ class PrepareProjectServer(ServerAction): else: attributes_entity[key] = value - project_settings.save() + try: + project_settings.save() + except SaveWarningExc as exc: + self.log.info("Few warnings happened during settings save:") + for warning in exc.warnings: + self.log.info(str(warning)) # Change custom attributes on project if custom_attribute_values: diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_prepare_project.py index 87d3329179..3759bc81ac 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_prepare_project.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_prepare_project.py @@ -3,6 +3,7 @@ import json from avalon.api import AvalonMongoDB from openpype.api import ProjectSettings from openpype.lib import create_project +from openpype.settings import SaveWarningExc from openpype_modules.ftrack.lib import ( BaseAction, @@ -417,7 +418,12 @@ class PrepareProjectLocal(BaseAction): else: attributes_entity[key] = value - project_settings.save() + try: + project_settings.save() + except SaveWarningExc as exc: + self.log.info("Few warnings happened during settings save:") + for warning in exc.warnings: + self.log.info(str(warning)) # Change custom attributes on project if custom_attribute_values: diff --git a/openpype/modules/default_modules/ftrack/ftrack_module.py b/openpype/modules/default_modules/ftrack/ftrack_module.py index 5a1fdbc276..73a4dfee82 100644 --- a/openpype/modules/default_modules/ftrack/ftrack_module.py +++ b/openpype/modules/default_modules/ftrack/ftrack_module.py @@ -371,7 +371,7 @@ class FtrackModule( return self.tray_module.validate() def tray_exit(self): - return self.tray_module.stop_action_server() + self.tray_module.tray_exit() def set_credentials_to_env(self, username, api_key): os.environ["FTRACK_API_USER"] = username or "" diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py index 39b7433e11..844a397066 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -26,14 +26,21 @@ class CollectUsername(pyblish.api.ContextPlugin): """ order = pyblish.api.CollectorOrder - 0.488 label = "Collect ftrack username" - hosts = ["webpublisher"] + hosts = ["webpublisher", "photoshop"] _context = None def process(self, context): + self.log.info("CollectUsername") + # photoshop could be triggered remotely in webpublisher fashion + if os.environ["AVALON_APP"] == "photoshop": + if not os.environ.get("IS_HEADLESS"): + self.log.debug("Regular process, skipping") + return + os.environ["FTRACK_API_USER"] = os.environ["FTRACK_BOT_API_USER"] os.environ["FTRACK_API_KEY"] = os.environ["FTRACK_BOT_API_KEY"] - self.log.info("CollectUsername") + for instance in context: email = instance.data["user_email"] self.log.info("email:: {}".format(email)) diff --git a/openpype/modules/default_modules/ftrack/tray/ftrack_tray.py b/openpype/modules/default_modules/ftrack/tray/ftrack_tray.py index 34e4646767..c6201a94f6 100644 --- a/openpype/modules/default_modules/ftrack/tray/ftrack_tray.py +++ b/openpype/modules/default_modules/ftrack/tray/ftrack_tray.py @@ -289,6 +289,10 @@ class FtrackTrayWrapper: parent_menu.addMenu(tray_menu) + def tray_exit(self): + self.stop_action_server() + self.stop_timer_thread() + # Definition of visibility of each menu actions def set_menu_visibility(self): self.tray_server_menu.menuAction().setVisible(self.bool_logged) diff --git a/openpype/modules/default_modules/timers_manager/timers_manager.py b/openpype/modules/default_modules/timers_manager/timers_manager.py index 1199db0611..7687d056f8 100644 --- a/openpype/modules/default_modules/timers_manager/timers_manager.py +++ b/openpype/modules/default_modules/timers_manager/timers_manager.py @@ -150,6 +150,7 @@ class TimersManager(OpenPypeModule, ITrayService): def tray_exit(self): if self._idle_manager: self._idle_manager.stop() + self._idle_manager.wait() def start_timer(self, project_name, asset_name, task_name, hierarchy): """ diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py new file mode 100644 index 0000000000..e968df4011 --- /dev/null +++ b/openpype/pipeline/__init__.py @@ -0,0 +1,28 @@ +from .lib import attribute_definitions + +from .create import ( + BaseCreator, + Creator, + AutoCreator, + CreatedInstance +) + +from .publish import ( + PublishValidationError, + KnownPublishError, + OpenPypePyblishPluginMixin +) + + +__all__ = ( + "attribute_definitions", + + "BaseCreator", + "Creator", + "AutoCreator", + "CreatedInstance", + + "PublishValidationError", + "KnownPublishError", + "OpenPypePyblishPluginMixin" +) diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md new file mode 100644 index 0000000000..9eef7c72a7 --- /dev/null +++ b/openpype/pipeline/create/README.md @@ -0,0 +1,78 @@ +# Create +Creation is process defying what and how will be published. May work in a different way based on host implementation. + +## CreateContext +Entry point of creation. All data and metadata are handled through create context. Context hold all global data and instances. Is responsible for loading of plugins (create, publish), triggering creator methods, validation of host implementation and emitting changes to creators and host. + +Discovers Creator plugins to be able create new instances and convert existing instances. Creators may have defined attributes that are specific for their instances. Attributes definition can enhance behavior of instance during publishing. + +Publish plugins are loaded because they can also define attributes definitions. These are less family specific To be able define attributes Publish plugin must inherit from `OpenPypePyblishPluginMixin` and must override `get_attribute_defs` class method which must return list of attribute definitions. Values of publish plugin definitions are stored per plugin name under `publish_attributes`. Also can override `convert_attribute_values` class method which gives ability to modify values on instance before are used in CreatedInstance. Method `convert_attribute_values` can be also used without `get_attribute_defs` to modify values when changing compatibility (remove metadata from instance because are irrelevant). + +Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. + +Except creating and removing instances are all changes not automatically propagated to host context (scene/workfile/...) to propagate changes call `save_changes` which trigger update of all instances in context using Creators implementation. + + +## CreatedInstance +Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance. +Family tells how should be instance processed and subset what name will published item have. +- There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product. + +`CreatedInstance` is entity holding the data which are stored and used. + +```python +{ + # Immutable data after creation + ## Identifier that this data represents instance for publishing (automatically assigned) + "id": "pyblish.avalon.instance", + ## Identifier of this specific instance (automatically assigned) + "uuid": , + ## Instance family (used from Creator) + "family": , + + # Mutable data + ## Subset name based on subset name template - may change overtime (on context change) + "subset": , + ## Instance is active and will be published + "active": True, + ## Version of instance + "version": 1, + # Identifier of creator (is unique) + "creator_identifier": "", + ## Creator specific attributes (defined by Creator) + "creator_attributes": {...}, + ## Publish plugin specific plugins (defined by Publish plugin) + "publish_attributes": { + # Attribute values are stored by publish plugin name + # - Duplicated plugin names can cause clashes! + : {...}, + ... + }, + ## Additional data related to instance (`asset`, `task`, etc.) + ... +} +``` + +## Creator +To be able create, update, remove or collect existing instances there must be defined a creator. Creator must have unique identifier and can represents a family. There can be multiple Creators for single family. Identifier of creator should contain family (advise). + +Creator has abstract methods to handle instances. For new instance creation is used `create` which should create metadata in host context and add new instance object to `CreateContext`. To collect existing instances is used `collect_instances` which should find all existing instances related to creator and add them to `CreateContext`. To update data of instance is used `update_instances` which is called from `CreateContext` on `save_changes`. To remove instance use `remove_instances` which should remove metadata from host context and remove instance from `CreateContext`. + +Creator has access to `CreateContext` which created object of the creator. All new instances or removed instances must be told to context. To do so use methods `_add_instance_to_context` and `_remove_instance_from_context` where `CreatedInstance` is passed. They should be called from `create` if new instance was created and from `remove_instances` if instance was removed. + +Creators don't have strictly defined how are instances handled but it is good practice to define a way which is host specific. It is not strict because there are cases when host implementation just can't handle all requirements of all creators. + +### AutoCreator +Auto-creators are automatically executed when `CreateContext` is reset. They can be used to create instances that should be always available and may not require artist's manual creation (e.g. `workfile`). Should not create duplicated instance and validate existence before creates a new. Method `remove_instances` is implemented to do nothing. + +## Host +Host implementation must have available global context metadata handler functions. One to get current context data and second to update them. Currently are to context data stored only context publish plugin attribute values. + +### Get global context data (`get_context_data`) +There are data that are not specific for any instance but are specific for whole context (e.g. Context plugins values). + +### Update global context data (`update_context_data`) +Update global context data. + +### Optional title of context +It is recommended to implement `get_context_title` function. String returned from this function will be shown in UI as context in which artist is. diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py new file mode 100644 index 0000000000..610ef6d8e2 --- /dev/null +++ b/openpype/pipeline/create/__init__.py @@ -0,0 +1,24 @@ +from .creator_plugins import ( + CreatorError, + + BaseCreator, + Creator, + AutoCreator +) + +from .context import ( + CreatedInstance, + CreateContext +) + + +__all__ = ( + "CreatorError", + + "BaseCreator", + "Creator", + "AutoCreator", + + "CreatedInstance", + "CreateContext" +) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py new file mode 100644 index 0000000000..7b0f50b1dc --- /dev/null +++ b/openpype/pipeline/create/context.py @@ -0,0 +1,1142 @@ +import os +import copy +import logging +import collections +import inspect +from uuid import uuid4 +from contextlib import contextmanager + +from ..lib import UnknownDef +from .creator_plugins import ( + BaseCreator, + Creator, + AutoCreator +) + +from openpype.api import ( + get_system_settings, + get_project_settings +) + + +class ImmutableKeyError(TypeError): + """Accessed key is immutable so does not allow changes or removements.""" + def __init__(self, key, msg=None): + self.immutable_key = key + if not msg: + msg = "Key \"{}\" is immutable and does not allow changes.".format( + key + ) + super(ImmutableKeyError, self).__init__(msg) + + +class HostMissRequiredMethod(Exception): + """Host does not have implemented required functions for creation.""" + def __init__(self, host, missing_methods): + self.missing_methods = missing_methods + self.host = host + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + dirpath = os.path.dirname( + os.path.normpath(inspect.getsourcefile(host)) + ) + dirpath_parts = dirpath.split(os.path.sep) + host_name = dirpath_parts.pop(-1) + if host_name == "api": + host_name = dirpath_parts.pop(-1) + + msg = "Host \"{}\" does not have implemented method/s {}".format( + host_name, joined_methods + ) + super(HostMissRequiredMethod, self).__init__(msg) + + +class InstanceMember: + """Representation of instance member. + + TODO: + Implement and use! + """ + def __init__(self, instance, name): + self.instance = instance + + instance.add_members(self) + + self.name = name + self._actions = [] + + def add_action(self, label, callback): + self._actions.append({ + "label": label, + "callback": callback + }) + + +class AttributeValues: + """Container which keep values of Attribute definitions. + + Goal is to have one object which hold values of attribute definitions for + single instance. + + Has dictionary like methods. Not all of them are allowed all the time. + + Args: + attr_defs(AbtractAttrDef): Defintions of value type and properties. + values(dict): Values after possible conversion. + origin_data(dict): Values loaded from host before conversion. + """ + def __init__(self, attr_defs, values, origin_data=None): + if origin_data is None: + origin_data = copy.deepcopy(values) + self._origin_data = origin_data + + attr_defs_by_key = { + attr_def.key: attr_def + for attr_def in attr_defs + } + for key, value in values.items(): + if key not in attr_defs_by_key: + new_def = UnknownDef(key, label=key, default=value) + attr_defs.append(new_def) + attr_defs_by_key[key] = new_def + + self._attr_defs = attr_defs + self._attr_defs_by_key = attr_defs_by_key + + self._data = {} + for attr_def in attr_defs: + value = values.get(attr_def.key) + if value is not None: + self._data[attr_def.key] = value + + def __setitem__(self, key, value): + if key not in self._attr_defs_by_key: + raise KeyError("Key \"{}\" was not found.".format(key)) + + old_value = self._data.get(key) + if old_value == value: + return + self._data[key] = value + + def __getitem__(self, key): + if key not in self._attr_defs_by_key: + return self._data[key] + return self._data.get(key, self._attr_defs_by_key[key].default) + + def __contains__(self, key): + return key in self._attr_defs_by_key + + def get(self, key, default=None): + if key in self._attr_defs_by_key: + return self[key] + return default + + def keys(self): + return self._attr_defs_by_key.keys() + + def values(self): + for key in self._attr_defs_by_key.keys(): + yield self._data.get(key) + + def items(self): + for key in self._attr_defs_by_key.keys(): + yield key, self._data.get(key) + + def update(self, value): + for _key, _value in dict(value): + self[_key] = _value + + def pop(self, key, default=None): + return self._data.pop(key, default) + + def reset_values(self): + self._data = [] + + @property + def attr_defs(self): + """Pointer to attribute definitions.""" + return self._attr_defs + + def data_to_store(self): + """Create new dictionary with data to store.""" + output = {} + for key in self._data: + output[key] = self[key] + return output + + @staticmethod + def calculate_changes(new_data, old_data): + """Calculate changes of 2 dictionary objects.""" + changes = {} + for key, new_value in new_data.items(): + old_value = old_data.get(key) + if old_value != new_value: + changes[key] = (old_value, new_value) + return changes + + def changes(self): + return self.calculate_changes(self._data, self._origin_data) + + +class CreatorAttributeValues(AttributeValues): + """Creator specific attribute values of an instance. + + Args: + instance (CreatedInstance): Instance for which are values hold. + """ + def __init__(self, instance, *args, **kwargs): + self.instance = instance + super(CreatorAttributeValues, self).__init__(*args, **kwargs) + + +class PublishAttributeValues(AttributeValues): + """Publish plugin specific attribute values. + + Values are for single plugin which can be on `CreatedInstance` + or context values stored on `CreateContext`. + + Args: + publish_attributes(PublishAttributes): Wrapper for multiple publish + attributes is used as parent object. + """ + def __init__(self, publish_attributes, *args, **kwargs): + self.publish_attributes = publish_attributes + super(PublishAttributeValues, self).__init__(*args, **kwargs) + + @property + def parent(self): + self.publish_attributes.parent + + +class PublishAttributes: + """Wrapper for publish plugin attribute definitions. + + Cares about handling attribute definitions of multiple publish plugins. + + Args: + parent(CreatedInstance, CreateContext): Parent for which will be + data stored and from which are data loaded. + origin_data(dict): Loaded data by plugin class name. + attr_plugins(list): List of publish plugins that may have defined + attribute definitions. + """ + def __init__(self, parent, origin_data, attr_plugins=None): + self.parent = parent + self._origin_data = copy.deepcopy(origin_data) + + attr_plugins = attr_plugins or [] + self.attr_plugins = attr_plugins + + self._data = copy.deepcopy(origin_data) + self._plugin_names_order = [] + self._missing_plugins = [] + + self.set_publish_plugins(attr_plugins) + + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + + def pop(self, key, default=None): + """Remove or reset value for plugin. + + Plugin values are reset to defaults if plugin is available but + data of plugin which was not found are removed. + + Args: + key(str): Plugin name. + default: Default value if plugin was not found. + """ + if key not in self._data: + return default + + if key in self._missing_plugins: + self._missing_plugins.remove(key) + removed_item = self._data.pop(key) + return removed_item.data_to_store() + + value_item = self._data[key] + # Prepare value to return + output = value_item.data_to_store() + # Reset values + value_item.reset_values() + return output + + def plugin_names_order(self): + """Plugin names order by their 'order' attribute.""" + for name in self._plugin_names_order: + yield name + + def data_to_store(self): + """Convert attribute values to "data to store".""" + output = {} + for key, attr_value in self._data.items(): + output[key] = attr_value.data_to_store() + return output + + def changes(self): + """Return changes per each key.""" + changes = {} + for key, attr_val in self._data.items(): + attr_changes = attr_val.changes() + if attr_changes: + if key not in changes: + changes[key] = {} + changes[key].update(attr_val) + + for key, value in self._origin_data.items(): + if key not in self._data: + changes[key] = (value, None) + return changes + + def set_publish_plugins(self, attr_plugins): + """Set publish plugins attribute definitions.""" + self._plugin_names_order = [] + self._missing_plugins = [] + self.attr_plugins = attr_plugins or [] + if not attr_plugins: + return + + origin_data = self._origin_data + data = self._data + self._data = {} + added_keys = set() + for plugin in attr_plugins: + output = plugin.convert_attribute_values(data) + if output is not None: + data = output + attr_defs = plugin.get_attribute_defs() + if not attr_defs: + continue + + key = plugin.__name__ + added_keys.add(key) + self._plugin_names_order.append(key) + + value = data.get(key) or {} + orig_value = copy.deepcopy(origin_data.get(key) or {}) + self._data[key] = PublishAttributeValues( + self, attr_defs, value, orig_value + ) + + for key, value in data.items(): + if key not in added_keys: + self._missing_plugins.append(key) + self._data[key] = PublishAttributeValues( + self, [], value, value + ) + + +class CreatedInstance: + """Instance entity with data that will be stored to workfile. + + I think `data` must be required argument containing all minimum information + about instance like "asset" and "task" and all data used for filling subset + name as creators may have custom data for subset name filling. + + Args: + family(str): Name of family that will be created. + subset_name(str): Name of subset that will be created. + data(dict): Data used for filling subset name or override data from + already existing instance. + creator(BaseCreator): Creator responsible for instance. + host(ModuleType): Host implementation loaded with + `avalon.api.registered_host`. + new(bool): Is instance new. + """ + # Keys that can't be changed or removed from data after loading using + # creator. + # - 'creator_attributes' and 'publish_attributes' can change values of + # their individual children but not on their own + __immutable_keys = ( + "id", + "uuid", + "family", + "creator_identifier", + "creator_attributes", + "publish_attributes" + ) + + def __init__( + self, family, subset_name, data, creator, new=True + ): + self.creator = creator + + # Instance members may have actions on them + self._members = [] + + # Create a copy of passed data to avoid changing them on the fly + data = copy.deepcopy(data or {}) + # Store original value of passed data + self._orig_data = copy.deepcopy(data) + + # Pop family and subset to prevent unexpected changes + data.pop("family", None) + data.pop("subset", None) + + # Pop dictionary values that will be converted to objects to be able + # catch changes + orig_creator_attributes = data.pop("creator_attributes", None) or {} + orig_publish_attributes = data.pop("publish_attributes", None) or {} + + # QUESTION Does it make sense to have data stored as ordered dict? + self._data = collections.OrderedDict() + # QUESTION Do we need this "id" information on instance? + self._data["id"] = "pyblish.avalon.instance" + self._data["family"] = family + self._data["subset"] = subset_name + self._data["active"] = data.get("active", True) + self._data["creator_identifier"] = creator.identifier + + # QUESTION handle version of instance here or in creator? + version = None + if not new: + version = data.get("version") + + if version is None: + version = 1 + self._data["version"] = version + + # Pop from source data all keys that are defined in `_data` before + # this moment and through their values away + # - they should be the same and if are not then should not change + # already set values + for key in self._data.keys(): + if key in data: + data.pop(key) + + # Stored creator specific attribute values + # {key: value} + creator_values = copy.deepcopy(orig_creator_attributes) + creator_attr_defs = creator.get_attribute_defs() + + self._data["creator_attributes"] = CreatorAttributeValues( + self, creator_attr_defs, creator_values, orig_creator_attributes + ) + + # Stored publish specific attribute values + # {: {key: value}} + # - must be set using 'set_publish_plugins' + self._data["publish_attributes"] = PublishAttributes( + self, orig_publish_attributes, None + ) + if data: + self._data.update(data) + + if not self._data.get("uuid"): + self._data["uuid"] = str(uuid4()) + + self._asset_is_valid = self.has_set_asset + self._task_is_valid = self.has_set_task + + def __str__(self): + return ( + "" + " {data}" + ).format( + subset=str(self._data), + creator_identifier=self.creator_identifier, + family=self.family, + data=str(self._data) + ) + + # --- Dictionary like methods --- + def __getitem__(self, key): + return self._data[key] + + def __contains__(self, key): + return key in self._data + + def __setitem__(self, key, value): + # Validate immutable keys + if key not in self.__immutable_keys: + self._data[key] = value + + elif value != self._data.get(key): + # Raise exception if key is immutable and value has changed + raise ImmutableKeyError(key) + + def get(self, key, default=None): + return self._data.get(key, default) + + def pop(self, key, *args, **kwargs): + # Raise exception if is trying to pop key which is immutable + if key in self.__immutable_keys: + raise ImmutableKeyError(key) + + self._data.pop(key, *args, **kwargs) + + def keys(self): + return self._data.keys() + + def values(self): + return self._data.values() + + def items(self): + return self._data.items() + # ------ + + @property + def family(self): + return self._data["family"] + + @property + def subset_name(self): + return self._data["subset"] + + @property + def creator_identifier(self): + return self.creator.identifier + + @property + def creator_label(self): + return self.creator.label or self.creator_identifier + + @property + def create_context(self): + return self.creator.create_context + + @property + def host(self): + return self.create_context.host + + @property + def has_set_asset(self): + """Asset name is set in data.""" + return "asset" in self._data + + @property + def has_set_task(self): + """Task name is set in data.""" + return "task" in self._data + + @property + def has_valid_context(self): + """Context data are valid for publishing.""" + return self.has_valid_asset and self.has_valid_task + + @property + def has_valid_asset(self): + """Asset set in context exists in project.""" + if not self.has_set_asset: + return False + return self._asset_is_valid + + @property + def has_valid_task(self): + """Task set in context exists in project.""" + if not self.has_set_task: + return False + return self._task_is_valid + + def set_asset_invalid(self, invalid): + # TODO replace with `set_asset_name` + self._asset_is_valid = not invalid + + def set_task_invalid(self, invalid): + # TODO replace with `set_task_name` + self._task_is_valid = not invalid + + @property + def id(self): + """Instance identifier.""" + return self._data["uuid"] + + @property + def data(self): + """Legacy access to data. + + Access to data is needed to modify values. + """ + return self + + def changes(self): + """Calculate and return changes.""" + changes = {} + new_keys = set() + for key, new_value in self._data.items(): + new_keys.add(key) + if key in ("creator_attributes", "publish_attributes"): + continue + + old_value = self._orig_data.get(key) + if old_value != new_value: + changes[key] = (old_value, new_value) + + creator_attr_changes = self.creator_attributes.changes() + if creator_attr_changes: + changes["creator_attributes"] = creator_attr_changes + + publish_attr_changes = self.publish_attributes.changes() + if publish_attr_changes: + changes["publish_attributes"] = publish_attr_changes + + for key, old_value in self._orig_data.items(): + if key not in new_keys: + changes[key] = (old_value, None) + return changes + + @property + def creator_attributes(self): + return self._data["creator_attributes"] + + @property + def creator_attribute_defs(self): + return self.creator_attributes.attr_defs + + @property + def publish_attributes(self): + return self._data["publish_attributes"] + + def data_to_store(self): + output = collections.OrderedDict() + for key, value in self._data.items(): + if key in ("creator_attributes", "publish_attributes"): + continue + output[key] = value + + output["creator_attributes"] = self.creator_attributes.data_to_store() + output["publish_attributes"] = self.publish_attributes.data_to_store() + + return output + + @classmethod + def from_existing(cls, instance_data, creator): + """Convert instance data from workfile to CreatedInstance.""" + instance_data = copy.deepcopy(instance_data) + + family = instance_data.get("family", None) + if family is None: + family = creator.family + subset_name = instance_data.get("subset", None) + + return cls( + family, subset_name, instance_data, creator, new=False + ) + + def set_publish_plugins(self, attr_plugins): + self.publish_attributes.set_publish_plugins(attr_plugins) + + def add_members(self, members): + """Currently unused method.""" + for member in members: + if member not in self._members: + self._members.append(member) + + +class CreateContext: + """Context of instance creation. + + Context itself also can store data related to whole creation (workfile). + - those are mainly for Context publish plugins + + Args: + host(ModuleType): Host implementation which handles implementation and + global metadata. + dbcon(AvalonMongoDB): Connection to mongo with context (at least + project). + headless(bool): Context is created out of UI (Current not used). + reset(bool): Reset context on initialization. + discover_publish_plugins(bool): Discover publish plugins during reset + phase. + """ + # Methods required in host implementaion to be able create instances + # or change context data. + required_methods = ( + "get_context_data", + "update_context_data" + ) + + def __init__( + self, host, dbcon=None, headless=False, reset=True, + discover_publish_plugins=True + ): + # Create conncetion if is not passed + if dbcon is None: + import avalon.api + + session = avalon.api.session_data_from_environment(True) + dbcon = avalon.api.AvalonMongoDB(session) + dbcon.install() + + self.dbcon = dbcon + self.host = host + + # Prepare attribute for logger (Created on demand in `log` property) + self._log = None + + # Publish context plugins attributes and it's values + self._publish_attributes = PublishAttributes(self, {}) + self._original_context_data = {} + + # Validate host implementation + # - defines if context is capable of handling context data + host_is_valid = True + missing_methods = self.get_host_misssing_methods(host) + if missing_methods: + host_is_valid = False + joined_methods = ", ".join( + ['"{}"'.format(name) for name in missing_methods] + ) + self.log.warning(( + "Host miss required methods to be able use creation." + " Missing methods: {}" + ).format(joined_methods)) + + self._host_is_valid = host_is_valid + # Currently unused variable + self.headless = headless + + # Instances by their ID + self._instances_by_id = {} + + # Discovered creators + self.creators = {} + # Prepare categories of creators + self.autocreators = {} + # Manual creators + self.manual_creators = {} + + self.publish_discover_result = None + self.publish_plugins = [] + self.plugins_with_defs = [] + self._attr_plugins_by_family = {} + + # Helpers for validating context of collected instances + # - they can be validation for multiple instances at one time + # using context manager which will trigger validation + # after leaving of last context manager scope + self._bulk_counter = 0 + self._bulk_instances_to_process = [] + + # Trigger reset if was enabled + if reset: + self.reset(discover_publish_plugins) + + @property + def instances(self): + return self._instances_by_id.values() + + @property + def publish_attributes(self): + """Access to global publish attributes.""" + return self._publish_attributes + + @classmethod + def get_host_misssing_methods(cls, host): + """Collect missing methods from host. + + Args: + host(ModuleType): Host implementaion. + """ + missing = set() + for attr_name in cls.required_methods: + if not hasattr(host, attr_name): + missing.add(attr_name) + return missing + + @property + def host_is_valid(self): + """Is host valid for creation.""" + return self._host_is_valid + + @property + def log(self): + """Dynamic access to logger.""" + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + def reset(self, discover_publish_plugins=True): + """Reset context with all plugins and instances. + + All changes will be lost if were not saved explicitely. + """ + self.reset_avalon_context() + self.reset_plugins(discover_publish_plugins) + self.reset_context_data() + + with self.bulk_instances_collection(): + self.reset_instances() + self.execute_autocreators() + + def reset_avalon_context(self): + """Give ability to reset avalon context. + + Reset is based on optional host implementation of `get_current_context` + function or using `avalon.api.Session`. + + Some hosts have ability to change context file without using workfiles + tool but that change is not propagated to + """ + import avalon.api + + project_name = asset_name = task_name = None + if hasattr(self.host, "get_current_context"): + host_context = self.host.get_current_context() + if host_context: + project_name = host_context.get("project_name") + asset_name = host_context.get("asset_name") + task_name = host_context.get("task_name") + + if not project_name: + project_name = avalon.api.Session.get("AVALON_PROJECT") + if not asset_name: + asset_name = avalon.api.Session.get("AVALON_ASSET") + if not task_name: + task_name = avalon.api.Session.get("AVALON_TASK") + + if project_name: + self.dbcon.Session["AVALON_PROJECT"] = project_name + if asset_name: + self.dbcon.Session["AVALON_ASSET"] = asset_name + if task_name: + self.dbcon.Session["AVALON_TASK"] = task_name + + def reset_plugins(self, discover_publish_plugins=True): + """Reload plugins. + + Reloads creators from preregistered paths and can load publish plugins + if it's enabled on context. + """ + import avalon.api + import pyblish.logic + + from openpype.pipeline import OpenPypePyblishPluginMixin + from openpype.pipeline.publish import ( + publish_plugins_discover, + DiscoverResult + ) + + # Reset publish plugins + self._attr_plugins_by_family = {} + + discover_result = DiscoverResult() + plugins_with_defs = [] + plugins_by_targets = [] + if discover_publish_plugins: + discover_result = publish_plugins_discover() + publish_plugins = discover_result.plugins + + targets = pyblish.logic.registered_targets() or ["default"] + plugins_by_targets = pyblish.logic.plugins_by_targets( + publish_plugins, targets + ) + # Collect plugins that can have attribute definitions + for plugin in publish_plugins: + if OpenPypePyblishPluginMixin in inspect.getmro(plugin): + plugins_with_defs.append(plugin) + + self.publish_discover_result = discover_result + self.publish_plugins = plugins_by_targets + self.plugins_with_defs = plugins_with_defs + + # Prepare settings + project_name = self.dbcon.Session["AVALON_PROJECT"] + system_settings = get_system_settings() + project_settings = get_project_settings(project_name) + + # Discover and prepare creators + creators = {} + autocreators = {} + manual_creators = {} + for creator_class in avalon.api.discover(BaseCreator): + if inspect.isabstract(creator_class): + self.log.info( + "Skipping abstract Creator {}".format(str(creator_class)) + ) + continue + + creator_identifier = creator_class.identifier + if creator_identifier in creators: + self.log.warning(( + "Duplicated Creator identifier. " + "Using first and skipping following" + )) + continue + creator = creator_class( + self, + system_settings, + project_settings, + self.headless + ) + creators[creator_identifier] = creator + if isinstance(creator, AutoCreator): + autocreators[creator_identifier] = creator + elif isinstance(creator, Creator): + manual_creators[creator_identifier] = creator + + self.autocreators = autocreators + self.manual_creators = manual_creators + + self.creators = creators + + def reset_context_data(self): + """Reload context data using host implementation. + + These data are not related to any instance but may be needed for whole + publishing. + """ + if not self.host_is_valid: + self._original_context_data = {} + self._publish_attributes = PublishAttributes(self, {}) + return + + original_data = self.host.get_context_data() or {} + self._original_context_data = copy.deepcopy(original_data) + + publish_attributes = original_data.get("publish_attributes") or {} + + attr_plugins = self._get_publish_plugins_with_attr_for_context() + self._publish_attributes = PublishAttributes( + self, publish_attributes, attr_plugins + ) + + def context_data_to_store(self): + """Data that should be stored by host function. + + The same data should be returned on loading. + """ + return { + "publish_attributes": self._publish_attributes.data_to_store() + } + + def context_data_changes(self): + """Changes of attributes.""" + changes = {} + publish_attribute_changes = self._publish_attributes.changes() + if publish_attribute_changes: + changes["publish_attributes"] = publish_attribute_changes + return changes + + def creator_adds_instance(self, instance): + """Creator adds new instance to context. + + Instances should be added only from creators. + + Args: + instance(CreatedInstance): Instance with prepared data from + creator. + + TODO: Rename method to more suit. + """ + # Add instance to instances list + if instance.id in self._instances_by_id: + self.log.warning(( + "Instance with id {} is already added to context." + ).format(instance.id)) + return + + self._instances_by_id[instance.id] = instance + # Prepare publish plugin attributes and set it on instance + attr_plugins = self._get_publish_plugins_with_attr_for_family( + instance.creator.family + ) + instance.set_publish_plugins(attr_plugins) + + # Add instance to be validated inside 'bulk_instances_collection' + # context manager if is inside bulk + with self.bulk_instances_collection(): + self._bulk_instances_to_process.append(instance) + + def creator_removed_instance(self, instance): + self._instances_by_id.pop(instance.id, None) + + @contextmanager + def bulk_instances_collection(self): + """Validate context of instances in bulk. + + This can be used for single instance or for adding multiple instances + which is helpfull on reset. + + Should not be executed from multiple threads. + """ + self._bulk_counter += 1 + try: + yield + finally: + self._bulk_counter -= 1 + + # Trigger validation if there is no more context manager for bulk + # instance validation + if self._bulk_counter == 0: + ( + self._bulk_instances_to_process, + instances_to_validate + ) = ( + [], + self._bulk_instances_to_process + ) + self.validate_instances_context(instances_to_validate) + + def reset_instances(self): + """Reload instances""" + self._instances_by_id = {} + + # Collect instances + for creator in self.creators.values(): + creator.collect_instances() + + def execute_autocreators(self): + """Execute discovered AutoCreator plugins. + + Reset instances if any autocreator executed properly. + """ + for identifier, creator in self.autocreators.items(): + try: + creator.create() + + except Exception: + # TODO raise report exception if any crashed + msg = ( + "Failed to run AutoCreator with identifier \"{}\" ({})." + ).format(identifier, inspect.getfile(creator.__class__)) + self.log.warning(msg, exc_info=True) + + def validate_instances_context(self, instances=None): + """Validate 'asset' and 'task' instance context.""" + # Use all instances from context if 'instances' are not passed + if instances is None: + instances = tuple(self._instances_by_id.values()) + + # Skip if instances are empty + if not instances: + return + + task_names_by_asset_name = collections.defaultdict(set) + for instance in instances: + task_name = instance.get("task") + asset_name = instance.get("asset") + if asset_name and task_name: + task_names_by_asset_name[asset_name].add(task_name) + + asset_names = [ + asset_name + for asset_name in task_names_by_asset_name.keys() + if asset_name is not None + ] + asset_docs = list(self.dbcon.find( + { + "type": "asset", + "name": {"$in": asset_names} + }, + { + "name": True, + "data.tasks": True + } + )) + + task_names_by_asset_name = {} + for asset_doc in asset_docs: + asset_name = asset_doc["name"] + tasks = asset_doc.get("data", {}).get("tasks") or {} + task_names_by_asset_name[asset_name] = set(tasks.keys()) + + for instance in instances: + if not instance.has_valid_asset or not instance.has_valid_task: + continue + + asset_name = instance["asset"] + if asset_name not in task_names_by_asset_name: + instance.set_asset_invalid(True) + continue + + task_name = instance["task"] + if not task_name: + continue + + if task_name not in task_names_by_asset_name[asset_name]: + instance.set_task_invalid(True) + + def save_changes(self): + """Save changes. Update all changed values.""" + if not self.host_is_valid: + missing_methods = self.get_host_misssing_methods(self.host) + raise HostMissRequiredMethod(self.host, missing_methods) + + self._save_context_changes() + self._save_instance_changes() + + def _save_context_changes(self): + """Save global context values.""" + changes = self.context_data_changes() + if changes: + data = self.context_data_to_store() + self.host.update_context_data(data, changes) + + def _save_instance_changes(self): + """Save instance specific values.""" + instances_by_identifier = collections.defaultdict(list) + for instance in self._instances_by_id.values(): + identifier = instance.creator_identifier + instances_by_identifier[identifier].append(instance) + + for identifier, cretor_instances in instances_by_identifier.items(): + update_list = [] + for instance in cretor_instances: + instance_changes = instance.changes() + if instance_changes: + update_list.append((instance, instance_changes)) + + creator = self.creators[identifier] + if update_list: + creator.update_instances(update_list) + + def remove_instances(self, instances): + """Remove instances from context. + + Args: + instances(list): Instances that should be removed + from context. + """ + instances_by_identifier = collections.defaultdict(list) + for instance in instances: + identifier = instance.creator_identifier + instances_by_identifier[identifier].append(instance) + + for identifier, creator_instances in instances_by_identifier.items(): + creator = self.creators.get(identifier) + creator.remove_instances(creator_instances) + + def _get_publish_plugins_with_attr_for_family(self, family): + """Publish plugin attributes for passed family. + + Attribute definitions for specific family are cached. + + Args: + family(str): Instance family for which should be attribute + definitions returned. + """ + if family not in self._attr_plugins_by_family: + import pyblish.logic + + filtered_plugins = pyblish.logic.plugins_by_families( + self.plugins_with_defs, [family] + ) + plugins = [] + for plugin in filtered_plugins: + if plugin.__instanceEnabled__: + plugins.append(plugin) + self._attr_plugins_by_family[family] = plugins + + return self._attr_plugins_by_family[family] + + def _get_publish_plugins_with_attr_for_context(self): + """Publish plugins attributes for Context plugins.""" + plugins = [] + for plugin in self.plugins_with_defs: + if not plugin.__instanceEnabled__: + plugins.append(plugin) + return plugins diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py new file mode 100644 index 0000000000..aa2e3333ce --- /dev/null +++ b/openpype/pipeline/create/creator_plugins.py @@ -0,0 +1,269 @@ +import copy +import logging + +from abc import ( + ABCMeta, + abstractmethod, + abstractproperty +) +import six + +from openpype.lib import get_subset_name_with_asset_doc + + +class CreatorError(Exception): + """Should be raised when creator failed because of known issue. + + Message of error should be user readable. + """ + + def __init__(self, message): + super(CreatorError, self).__init__(message) + + +@six.add_metaclass(ABCMeta) +class BaseCreator: + """Plugin that create and modify instance data before publishing process. + + We should maybe find better name as creation is only one part of it's logic + and to avoid expectations that it is the same as `avalon.api.Creator`. + + Single object should be used for multiple instances instead of single + instance per one creator object. Do not store temp data or mid-process data + to `self` if it's not Plugin specific. + """ + + # Label shown in UI + label = None + + # Variable to store logger + _log = None + + # Creator is enabled (Probably does not have reason of existence?) + enabled = True + + # Creator (and family) icon + # - may not be used if `get_icon` is reimplemented + icon = None + + def __init__( + self, create_context, system_settings, project_settings, headless=False + ): + # Reference to CreateContext + self.create_context = create_context + + # Creator is running in headless mode (without UI elemets) + # - we may use UI inside processing this attribute should be checked + self.headless = headless + + @abstractproperty + def identifier(self): + """Identifier of creator (must be unique).""" + pass + + @abstractproperty + def family(self): + """Family that plugin represents.""" + pass + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + def _add_instance_to_context(self, instance): + """Helper method to ad d""" + self.create_context.creator_adds_instance(instance) + + def _remove_instance_from_context(self, instance): + self.create_context.creator_removed_instance(instance) + + @abstractmethod + def create(self, options=None): + """Create new instance. + + Replacement of `process` method from avalon implementation. + - must expect all data that were passed to init in previous + implementation + """ + pass + + @abstractmethod + def collect_instances(self, attr_plugins=None): + pass + + @abstractmethod + def update_instances(self, update_list): + pass + + @abstractmethod + def remove_instances(self, instances): + """Method called on instance removement. + + Can also remove instance metadata from context but should return + 'True' if did so. + + Args: + instance(list): Instance objects which should be + removed. + """ + pass + + def get_icon(self): + """Icon of creator (family). + + Can return path to image file or awesome icon name. + """ + return self.icon + + def get_dynamic_data( + self, variant, task_name, asset_doc, project_name, host_name + ): + """Dynamic data for subset name filling. + + These may be get dynamically created based on current context of + workfile. + """ + return {} + + def get_subset_name( + self, variant, task_name, asset_doc, project_name, host_name=None + ): + """Return subset name for passed context. + + CHANGES: + Argument `asset_id` was replaced with `asset_doc`. It is easier to + query asset before. In some cases would this method be called multiple + times and it would be too slow to query asset document on each + callback. + + NOTE: + Asset document is not used yet but is required if would like to use + task type in subset templates. + + Args: + variant(str): Subset name variant. In most of cases user input. + task_name(str): For which task subset is created. + asset_doc(dict): Asset document for which subset is created. + project_name(str): Project name. + host_name(str): Which host creates subset. + """ + dynamic_data = self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + ) + + return get_subset_name_with_asset_doc( + self.family, + variant, + task_name, + asset_doc, + project_name, + host_name, + dynamic_data=dynamic_data + ) + + def get_attribute_defs(self): + """Plugin attribute definitions. + + Attribute definitions of plugin that hold data about created instance + and values are stored to metadata for future usage and for publishing + purposes. + + NOTE: + Convert method should be implemented which should care about updating + keys/values when plugin attributes change. + + Returns: + list: Attribute definitions that can be tweaked for + created instance. + """ + return [] + + +class Creator(BaseCreator): + """Creator that has more information for artist to show in UI. + + Creation requires prepared subset name and instance data. + """ + + # GUI Purposes + # - default_variants may not be used if `get_default_variants` is overriden + default_variants = [] + + # Short description of family + # - may not be used if `get_description` is overriden + description = None + + # Detailed description of family for artists + # - may not be used if `get_detail_description` is overriden + detailed_description = None + + @abstractmethod + def create(self, subset_name, instance_data, options=None): + """Create new instance and store it. + + Ideally should be stored to workfile using host implementation. + + Args: + subset_name(str): Subset name of created instance. + instance_data(dict): + """ + + # instance = CreatedInstance( + # self.family, subset_name, instance_data + # ) + pass + + def get_description(self): + """Short description of family and plugin. + + Returns: + str: Short description of family. + """ + return self.description + + def get_detail_description(self): + """Description of family and plugin. + + Can be detailed with markdown or html tags. + + Returns: + str: Detailed description of family for artist. + """ + return self.detailed_description + + def get_default_variants(self): + """Default variant values for UI tooltips. + + Replacement of `defatults` attribute. Using method gives ability to + have some "logic" other than attribute values. + + By default returns `default_variants` value. + + Returns: + list: Whisper variants for user input. + """ + return copy.deepcopy(self.default_variants) + + def get_default_variant(self): + """Default variant value that will be used to prefill variant input. + + This is for user input and value may not be content of result from + `get_default_variants`. + + Can return `None`. In that case first element from + `get_default_variants` should be used. + """ + + return None + + +class AutoCreator(BaseCreator): + """Creator which is automatically triggered without user interaction. + + Can be used e.g. for `workfile`. + """ + def remove_instances(self, instances): + """Skip removement.""" + pass diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py new file mode 100644 index 0000000000..1bb65be79b --- /dev/null +++ b/openpype/pipeline/lib/__init__.py @@ -0,0 +1,18 @@ +from .attribute_definitions import ( + AbtractAttrDef, + UnknownDef, + NumberDef, + TextDef, + EnumDef, + BoolDef +) + + +__all__ = ( + "AbtractAttrDef", + "UnknownDef", + "NumberDef", + "TextDef", + "EnumDef", + "BoolDef" +) diff --git a/openpype/pipeline/lib/attribute_definitions.py b/openpype/pipeline/lib/attribute_definitions.py new file mode 100644 index 0000000000..2b34e15bc4 --- /dev/null +++ b/openpype/pipeline/lib/attribute_definitions.py @@ -0,0 +1,263 @@ +import re +import collections +import uuid +from abc import ABCMeta, abstractmethod +import six + + +class AbstractAttrDefMeta(ABCMeta): + """Meta class to validate existence of 'key' attribute. + + Each object of `AbtractAttrDef` mus have defined 'key' attribute. + """ + def __call__(self, *args, **kwargs): + obj = super(AbstractAttrDefMeta, self).__call__(*args, **kwargs) + init_class = getattr(obj, "__init__class__", None) + if init_class is not AbtractAttrDef: + raise TypeError("{} super was not called in __init__.".format( + type(obj) + )) + return obj + + +@six.add_metaclass(AbstractAttrDefMeta) +class AbtractAttrDef: + """Abstraction of attribute definiton. + + Each attribute definition must have implemented validation and + conversion method. + + Attribute definition should have ability to return "default" value. That + can be based on passed data into `__init__` so is not abstracted to + attribute. + + QUESTION: + How to force to set `key` attribute? + + Args: + key(str): Under which key will be attribute value stored. + label(str): Attribute label. + tooltip(str): Attribute tooltip. + """ + + def __init__(self, key, default, label=None, tooltip=None): + self.key = key + self.label = label + self.tooltip = tooltip + self.default = default + self._id = uuid.uuid4() + + self.__init__class__ = AbtractAttrDef + + @property + def id(self): + return self._id + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + return self.key == other.key + + @abstractmethod + def convert_value(self, value): + """Convert value to a valid one. + + Convert passed value to a valid type. Use default if value can't be + converted. + """ + pass + + +class UnknownDef(AbtractAttrDef): + """Definition is not known because definition is not available.""" + def __init__(self, key, default=None, **kwargs): + kwargs["default"] = default + super(UnknownDef, self).__init__(key, **kwargs) + + def convert_value(self, value): + return value + + +class NumberDef(AbtractAttrDef): + """Number definition. + + Number can have defined minimum/maximum value and decimal points. Value + is integer if decimals are 0. + + Args: + minimum(int, float): Minimum possible value. + maximum(int, float): Maximum possible value. + decimals(int): Maximum decimal points of value. + default(int, float): Default value for conversion. + """ + + def __init__( + self, key, minimum=None, maximum=None, decimals=None, default=None, + **kwargs + ): + minimum = 0 if minimum is None else minimum + maximum = 999999 if maximum is None else maximum + # Swap min/max when are passed in opposited order + if minimum > maximum: + maximum, minimum = minimum, maximum + + if default is None: + default = 0 + + elif not isinstance(default, (int, float)): + raise TypeError(( + "'default' argument must be 'int' or 'float', not '{}'" + ).format(type(default))) + + # Fix default value by mim/max values + if default < minimum: + default = minimum + + elif default > maximum: + default = maximum + + super(NumberDef, self).__init__(key, default=default, **kwargs) + + self.minimum = minimum + self.maximum = maximum + self.decimals = 0 if decimals is None else decimals + + def __eq__(self, other): + if not super(NumberDef, self).__eq__(other): + return False + + return ( + self.decimals == other.decimals + and self.maximum == other.maximum + and self.maximum == other.maximum + ) + + def convert_value(self, value): + if isinstance(value, six.string_types): + try: + value = float(value) + except Exception: + pass + + if not isinstance(value, (int, float)): + return self.default + + if self.decimals == 0: + return int(value) + return round(float(value), self.decimals) + + +class TextDef(AbtractAttrDef): + """Text definition. + + Text can have multiline option so endline characters are allowed regex + validation can be applied placeholder for UI purposes and default value. + + Regex validation is not part of attribute implemntentation. + + Args: + multiline(bool): Text has single or multiline support. + regex(str, re.Pattern): Regex validation. + placeholder(str): UI placeholder for attribute. + default(str, None): Default value. Empty string used when not defined. + """ + def __init__( + self, key, multiline=None, regex=None, placeholder=None, default=None, + **kwargs + ): + if default is None: + default = "" + + super(TextDef, self).__init__(key, default=default, **kwargs) + + if multiline is None: + multiline = False + + elif not isinstance(default, six.string_types): + raise TypeError(( + "'default' argument must be a {}, not '{}'" + ).format(six.string_types, type(default))) + + if isinstance(regex, six.string_types): + regex = re.compile(regex) + + self.multiline = multiline + self.placeholder = placeholder + self.regex = regex + + def __eq__(self, other): + if not super(TextDef, self).__eq__(other): + return False + + return ( + self.multiline == other.multiline + and self.regex == other.regex + ) + + def convert_value(self, value): + if isinstance(value, six.string_types): + return value + return self.default + + +class EnumDef(AbtractAttrDef): + """Enumeration of single item from items. + + Args: + items: Items definition that can be coverted to + `collections.OrderedDict`. Dictionary represent {value: label} + relation. + default: Default value. Must be one key(value) from passed items. + """ + + def __init__(self, key, items, default=None, **kwargs): + if not items: + raise ValueError(( + "Empty 'items' value. {} must have" + " defined values on initialization." + ).format(self.__class__.__name__)) + + items = collections.OrderedDict(items) + if default not in items: + for _key in items.keys(): + default = _key + break + + super(EnumDef, self).__init__(key, default=default, **kwargs) + + self.items = items + + def __eq__(self, other): + if not super(EnumDef, self).__eq__(other): + return False + + if set(self.items.keys()) != set(other.items.keys()): + return False + + for key, label in self.items.items(): + if other.items[key] != label: + return False + return True + + def convert_value(self, value): + if value in self.items: + return value + return self.default + + +class BoolDef(AbtractAttrDef): + """Boolean representation. + + Args: + default(bool): Default value. Set to `False` if not defined. + """ + + def __init__(self, key, default=None, **kwargs): + if default is None: + default = False + super(BoolDef, self).__init__(key, default=default, **kwargs) + + def convert_value(self, value): + if isinstance(value, bool): + return value + return self.default diff --git a/openpype/pipeline/publish/README.md b/openpype/pipeline/publish/README.md new file mode 100644 index 0000000000..870d29314d --- /dev/null +++ b/openpype/pipeline/publish/README.md @@ -0,0 +1,38 @@ +# Publish +OpenPype is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. OpenPype's (new) publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. + +## Exceptions +OpenPype define few specific exceptions that should be used in publish plugins. + +### Validation exception +Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. + +Exception `PublishValidationError` 3 arguments: +- **message** Which is not used in UI but for headless publishing. +- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin. +- **description** Detailed description of happened issue where markdown and html can be used. + + +### Known errors +When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. + +## Plugin extension +Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). + +```python +import pyblish.api +from openpype.pipeline import OpenPypePyblishPluginMixin + + +# Example context plugin +class MyExtendedPlugin( + pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin +): + pass + +``` + +### Extensions +Currently only extension is ability to define attributes for instances during creation. Method `get_attribute_defs` returns attribute definitions for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be implemented `convert_attribute_values`. Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure. + +Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py new file mode 100644 index 0000000000..ca958816fe --- /dev/null +++ b/openpype/pipeline/publish/__init__.py @@ -0,0 +1,20 @@ +from .publish_plugins import ( + PublishValidationError, + KnownPublishError, + OpenPypePyblishPluginMixin +) + +from .lib import ( + DiscoverResult, + publish_plugins_discover +) + + +__all__ = ( + "PublishValidationError", + "KnownPublishError", + "OpenPypePyblishPluginMixin", + + "DiscoverResult", + "publish_plugins_discover" +) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py new file mode 100644 index 0000000000..0fa712a301 --- /dev/null +++ b/openpype/pipeline/publish/lib.py @@ -0,0 +1,126 @@ +import os +import sys +import types + +import six +import pyblish.plugin + + +class DiscoverResult: + """Hold result of publish plugins discovery. + + Stores discovered plugins duplicated plugins and file paths which + crashed on execution of file. + """ + def __init__(self): + self.plugins = [] + self.crashed_file_paths = {} + self.duplicated_plugins = [] + + def __iter__(self): + for plugin in self.plugins: + yield plugin + + def __getitem__(self, item): + return self.plugins[item] + + def __setitem__(self, item, value): + self.plugins[item] = value + + +def publish_plugins_discover(paths=None): + """Find and return available pyblish plug-ins + + Overriden function from `pyblish` module to be able collect crashed files + and reason of their crash. + + Arguments: + paths (list, optional): Paths to discover plug-ins from. + If no paths are provided, all paths are searched. + + """ + + # The only difference with `pyblish.api.discover` + result = DiscoverResult() + + plugins = dict() + plugin_names = [] + + allow_duplicates = pyblish.plugin.ALLOW_DUPLICATES + log = pyblish.plugin.log + + # Include plug-ins from registered paths + if not paths: + paths = pyblish.plugin.plugin_paths() + + for path in paths: + path = os.path.normpath(path) + if not os.path.isdir(path): + continue + + for fname in os.listdir(path): + if fname.startswith("_"): + continue + + abspath = os.path.join(path, fname) + + if not os.path.isfile(abspath): + continue + + mod_name, mod_ext = os.path.splitext(fname) + + if not mod_ext == ".py": + continue + + module = types.ModuleType(mod_name) + module.__file__ = abspath + + try: + with open(abspath, "rb") as f: + six.exec_(f.read(), module.__dict__) + + # Store reference to original module, to avoid + # garbage collection from collecting it's global + # imports, such as `import os`. + sys.modules[abspath] = module + + except Exception as err: + result.crashed_file_paths[abspath] = sys.exc_info() + + log.debug("Skipped: \"%s\" (%s)", mod_name, err) + continue + + for plugin in pyblish.plugin.plugins_from_module(module): + if not allow_duplicates and plugin.__name__ in plugin_names: + result.duplicated_plugins.append(plugin) + log.debug("Duplicate plug-in found: %s", plugin) + continue + + plugin_names.append(plugin.__name__) + + plugin.__module__ = module.__file__ + key = "{0}.{1}".format(plugin.__module__, plugin.__name__) + plugins[key] = plugin + + # Include plug-ins from registration. + # Directly registered plug-ins take precedence. + for plugin in pyblish.plugin.registered_plugins(): + if not allow_duplicates and plugin.__name__ in plugin_names: + result.duplicated_plugins.append(plugin) + log.debug("Duplicate plug-in found: %s", plugin) + continue + + plugin_names.append(plugin.__name__) + + plugins[plugin.__name__] = plugin + + plugins = list(plugins.values()) + pyblish.plugin.sort(plugins) # In-place + + # In-place user-defined filter + for filter_ in pyblish.plugin._registered_plugin_filters: + filter_(plugins) + + result.plugins = plugins + + return result diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py new file mode 100644 index 0000000000..b60b9f43a7 --- /dev/null +++ b/openpype/pipeline/publish/publish_plugins.py @@ -0,0 +1,86 @@ +class PublishValidationError(Exception): + """Validation error happened during publishing. + + This exception should be used when validation publishing failed. + + Has additional UI specific attributes that may be handy for artist. + + Args: + message(str): Message of error. Short explanation an issue. + title(str): Title showed in UI. All instances are grouped under + single title. + description(str): Detailed description of an error. It is possible + to use Markdown syntax. + """ + def __init__(self, message, title=None, description=None): + self.message = message + self.title = title or "< Missing title >" + self.description = description or message + super(PublishValidationError, self).__init__(message) + + +class KnownPublishError(Exception): + """Publishing crashed because of known error. + + Message will be shown in UI for artist. + """ + pass + + +class OpenPypePyblishPluginMixin: + # TODO + # executable_in_thread = False + # + # state_message = None + # state_percent = None + # _state_change_callbacks = [] + # + # def set_state(self, percent=None, message=None): + # """Inner callback of plugin that would help to show in UI state. + # + # Plugin have registered callbacks on state change which could trigger + # update message and percent in UI and repaint the change. + # + # This part must be optional and should not be used to display errors + # or for logging. + # + # Message should be short without details. + # + # Args: + # percent(int): Percent of processing in range <1-100>. + # message(str): Message which will be shown to user (if in UI). + # """ + # if percent is not None: + # self.state_percent = percent + # + # if message: + # self.state_message = message + # + # for callback in self._state_change_callbacks: + # callback(self) + + @classmethod + def get_attribute_defs(cls): + """Publish attribute definitions. + + Attributes available for all families in plugin's `families` attribute. + Returns: + list: Attribute definitions for plugin. + """ + return [] + + @classmethod + def convert_attribute_values(cls, attribute_values): + if cls.__name__ not in attribute_values: + return attribute_values + + plugin_values = attribute_values[cls.__name__] + + attr_defs = cls.get_attribute_defs() + for attr_def in attr_defs: + key = attr_def.key + if key in plugin_values: + plugin_values[key] = attr_def.convert_value( + plugin_values[key] + ) + return attribute_values diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py new file mode 100644 index 0000000000..16e3f669c3 --- /dev/null +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -0,0 +1,57 @@ +"""Create instances based on CreateContext. + +""" +import os +import pyblish.api +import avalon.api + + +class CollectFromCreateContext(pyblish.api.ContextPlugin): + """Collect instances and data from CreateContext from new publishing.""" + + label = "Collect From Create Context" + order = pyblish.api.CollectorOrder - 0.5 + + def process(self, context): + create_context = context.data.pop("create_context", None) + # Skip if create context is not available + if not create_context: + return + + for created_instance in create_context.instances: + instance_data = created_instance.data_to_store() + if instance_data["active"]: + self.create_instance(context, instance_data) + + # Update global data to context + context.data.update(create_context.context_data_to_store()) + + # Update context data + for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"): + value = create_context.dbcon.Session.get(key) + if value is not None: + avalon.api.Session[key] = value + os.environ[key] = value + + def create_instance(self, context, in_data): + subset = in_data["subset"] + # If instance data already contain families then use it + instance_families = in_data.get("families") or [] + + instance = context.create_instance(subset) + instance.data.update({ + "subset": subset, + "asset": in_data["asset"], + "task": in_data["task"], + "label": subset, + "name": subset, + "family": in_data["family"], + "families": instance_families + }) + for key, value in in_data.items(): + if key not in instance.data: + instance.data[key] = value + self.log.info("collected instance: {}".format(instance.data)) + self.log.info("parsing data: {}".format(in_data)) + + instance.data["representations"] = list() diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index c21f09ab8d..fa181301ee 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -68,6 +68,12 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "representation": "TEMP" }) + # For the first time publish + if instance.data.get("hierarchy"): + template_data.update({ + "hierarchy": instance.data["hierarchy"] + }) + anatomy_filled = anatomy.format(template_data) if "folder" in anatomy.templates["publish"]: diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 207e696fb1..06eb85c593 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -45,7 +45,8 @@ class ExtractBurnin(openpype.api.Extractor): "aftereffects", "tvpaint", "webpublisher", - "aftereffects" + "aftereffects", + "photoshop" # "resolve" ] optional = True diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 451ea1d80d..753ed78083 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -99,7 +99,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "camerarig", "redshiftproxy", "effect", - "xgen" + "xgen", + "hda" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/openpype/plugins/publish/validate_containers.py b/openpype/plugins/publish/validate_containers.py index 784221c3b6..ce91bd3396 100644 --- a/openpype/plugins/publish/validate_containers.py +++ b/openpype/plugins/publish/validate_containers.py @@ -9,9 +9,9 @@ class ShowInventory(pyblish.api.Action): on = "failed" def process(self, context, plugin): - from avalon.tools import sceneinventory + from openpype.tools.utils import host_tools - sceneinventory.show() + host_tools.show_scene_inventory() class ValidateContainers(pyblish.api.ContextPlugin): diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index d6895bcb2c..769b173087 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -3,11 +3,18 @@ import os import sys import json -import click -from datetime import datetime +import time from openpype.lib import PypeLogger from openpype.api import get_app_environments_for_context +from openpype.lib.plugin_tools import parse_json, get_batch_asset_task_info +from openpype.lib.remote_publish import ( + get_webpublish_conn, + start_webpublish_log, + publish_and_log, + fail_batch, + find_variant_key +) class PypeCommands: @@ -121,10 +128,116 @@ class PypeCommands: log.info("Publish finished.") uninstall() + @staticmethod + def remotepublishfromapp(project, batch_dir, host, user, targets=None): + """Opens installed variant of 'host' and run remote publish there. + + Currently implemented and tested for Photoshop where customer + wants to process uploaded .psd file and publish collected layers + from there. + + Checks if no other batches are running (status =='in_progress). If + so, it sleeps for SLEEP (this is separate process), + waits for WAIT_FOR seconds altogether. + + Requires installed host application on the machine. + + Runs publish process as user would, in automatic fashion. + """ + SLEEP = 5 # seconds for another loop check for concurrently runs + WAIT_FOR = 300 # seconds to wait for conc. runs + + from openpype import install, uninstall + from openpype.api import Logger + + log = Logger.get_logger() + + log.info("remotepublishphotoshop command") + + install() + + from openpype.lib import ApplicationManager + application_manager = ApplicationManager() + + found_variant_key = find_variant_key(application_manager, host) + + app_name = "{}/{}".format(host, found_variant_key) + + batch_data = None + if batch_dir and os.path.exists(batch_dir): + batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) + + if not batch_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_dir)) + + asset, task_name, _task_type = get_batch_asset_task_info( + batch_data["context"]) + + # processing from app expects JUST ONE task in batch and 1 workfile + task_dir_name = batch_data["tasks"][0] + task_data = parse_json(os.path.join(batch_dir, task_dir_name, + "manifest.json")) + + workfile_path = os.path.join(batch_dir, + task_dir_name, + task_data["files"][0]) + + print("workfile_path {}".format(workfile_path)) + + _, batch_id = os.path.split(batch_dir) + dbcon = get_webpublish_conn() + # safer to start logging here, launch might be broken altogether + _id = start_webpublish_log(dbcon, batch_id, user) + + in_progress = True + slept_times = 0 + while in_progress: + batches_in_progress = list(dbcon.find({ + "status": "in_progress" + })) + if len(batches_in_progress) > 1: + if slept_times * SLEEP >= WAIT_FOR: + fail_batch(_id, batches_in_progress, dbcon) + + print("Another batch running, sleeping for a bit") + time.sleep(SLEEP) + slept_times += 1 + else: + in_progress = False + + # must have for proper launch of app + env = get_app_environments_for_context( + project, + asset, + task_name, + app_name + ) + os.environ.update(env) + + os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir + os.environ["IS_HEADLESS"] = "true" + # must pass identifier to update log lines for a batch + os.environ["BATCH_LOG_ID"] = str(_id) + + data = { + "last_workfile_path": workfile_path, + "start_last_workfile": True + } + + launched_app = application_manager.launch(app_name, **data) + + while launched_app.poll() is None: + time.sleep(0.5) + + uninstall() + @staticmethod def remotepublish(project, batch_path, host, user, targets=None): """Start headless publishing. + Used to publish rendered assets, workfiles etc. + Publish use json from passed paths argument. Args: @@ -145,7 +258,6 @@ class PypeCommands: from openpype import install, uninstall from openpype.api import Logger - from openpype.lib import OpenPypeMongoConnection # Register target and host import pyblish.api @@ -177,62 +289,11 @@ class PypeCommands: log.info("Running publish ...") - # Error exit as soon as any error occurs. - error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" - - mongo_client = OpenPypeMongoConnection.get_mongo_client() - database_name = os.environ["OPENPYPE_DATABASE_NAME"] - dbcon = mongo_client[database_name]["webpublishes"] - _, batch_id = os.path.split(batch_path) - _id = dbcon.insert_one({ - "batch_id": batch_id, - "start_date": datetime.now(), - "user": user, - "status": "in_progress" - }).inserted_id + dbcon = get_webpublish_conn() + _id = start_webpublish_log(dbcon, batch_id, user) - log_lines = [] - for result in pyblish.util.publish_iter(): - for record in result["records"]: - log_lines.append("{}: {}".format( - result["plugin"].label, record.msg)) - - if result["error"]: - log.error(error_format.format(**result)) - uninstall() - log_lines.append(error_format.format(**result)) - dbcon.update_one( - {"_id": _id}, - {"$set": - { - "finish_date": datetime.now(), - "status": "error", - "log": os.linesep.join(log_lines) - - }} - ) - sys.exit(1) - else: - dbcon.update_one( - {"_id": _id}, - {"$set": - { - "progress": max(result["progress"], 0.95), - "log": os.linesep.join(log_lines) - }} - ) - - dbcon.update_one( - {"_id": _id}, - {"$set": - { - "finish_date": datetime.now(), - "status": "finished_ok", - "progress": 1, - "log": os.linesep.join(log_lines) - }} - ) + publish_and_log(dbcon, _id, log) log.info("Publish finished.") uninstall() diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index c6886fea73..f463933525 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -50,3 +50,11 @@ def get_openpype_splash_filepath(staging=None): else: splash_file_name = "openpype_splash.png" return get_resource("icons", splash_file_name) + + +def pype_icon_filepath(staging=None): + return get_openpype_icon_filepath(staging) + + +def pype_splash_filepath(staging=None): + return get_openpype_splash_filepath(staging) diff --git a/openpype/resources/app_icons/flame.png b/openpype/resources/app_icons/flame.png new file mode 100644 index 0000000000..ba9b69e45f Binary files /dev/null and b/openpype/resources/app_icons/flame.png differ diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index 184d7689e3..206abfc0b4 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -109,9 +109,7 @@ def _prores_codec_args(ffprobe_data, source_ffmpeg_cmd): def _h264_codec_args(ffprobe_data, source_ffmpeg_cmd): - output = [] - - output.extend(["-codec:v", "h264"]) + output = ["-codec:v", "h264"] # Use arguments from source if are available source arguments if source_ffmpeg_cmd: @@ -137,6 +135,32 @@ def _h264_codec_args(ffprobe_data, source_ffmpeg_cmd): return output +def _dnxhd_codec_args(ffprobe_data, source_ffmpeg_cmd): + output = ["-codec:v", "dnxhd"] + + # Use source profile (profiles in metadata are not usable in args directly) + profile = ffprobe_data.get("profile") or "" + # Lower profile and replace space with underscore + cleaned_profile = profile.lower().replace(" ", "_") + dnx_profiles = { + "dnxhd", + "dnxhr_lb", + "dnxhr_sq", + "dnxhr_hq", + "dnxhr_hqx", + "dnxhr_444" + } + if cleaned_profile in dnx_profiles: + output.extend(["-profile:v", cleaned_profile]) + + pix_fmt = ffprobe_data.get("pix_fmt") + if pix_fmt: + output.extend(["-pix_fmt", pix_fmt]) + + output.extend(["-g", "1"]) + return output + + def get_codec_args(ffprobe_data, source_ffmpeg_cmd): codec_name = ffprobe_data.get("codec_name") # Codec "prores" @@ -147,6 +171,10 @@ def get_codec_args(ffprobe_data, source_ffmpeg_cmd): if codec_name == "h264": return _h264_codec_args(ffprobe_data, source_ffmpeg_cmd) + # Coded DNxHD + if codec_name == "dnxhd": + return _dnxhd_codec_args(ffprobe_data, source_ffmpeg_cmd) + output = [] if codec_name: output.extend(["-codec:v", codec_name]) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 38313a3d84..fc34ef6813 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -162,9 +162,7 @@ ] } ], - "customNodes": [ - - ] + "customNodes": [] }, "regexInputs": { "inputs": [ @@ -174,5 +172,16 @@ } ] } + }, + "maya": { + "colorManagementPreference": { + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "renderSpace": "scene-linear Rec 709/sRGB", + "viewTransform": "sRGB gamma" + } } } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index c592d74350..f8f3432d0f 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -315,6 +315,21 @@ "optional": true, "active": true }, + "ValidateRigContents": { + "enabled": false, + "optional": true, + "active": true + }, + "ValidateRigJointsHidden": { + "enabled": false, + "optional": true, + "active": true + }, + "ValidateRigControllers": { + "enabled": false, + "optional": true, + "active": true + }, "ValidateCameraAttributes": { "enabled": false, "optional": true, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index dd65df02e5..e3c7834e4a 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -117,16 +117,7 @@ "load": { "LoadImage": { "enabled": true, - "families": [ - "render2d", - "source", - "plate", - "render", - "prerender", - "review", - "image" - ], - "representations": [ + "_representations": [ "exr", "dpx", "jpg", @@ -137,39 +128,9 @@ ], "node_name_template": "{class_name}_{ext}" }, - "LoadMov": { + "LoadClip": { "enabled": true, - "families": [ - "source", - "plate", - "render", - "prerender", - "review" - ], - "representations": [ - "mov", - "review", - "mp4", - "h264" - ], - "node_name_template": "{class_name}_{ext}" - }, - "LoadSequence": { - "enabled": true, - "families": [ - "render2d", - "source", - "plate", - "render", - "prerender", - "review" - ], - "representations": [ - "exr", - "dpx", - "jpg", - "jpeg", - "png" + "_representations": [ ], "node_name_template": "{class_name}_{ext}" } diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 4c36e4bd49..eb9f96e348 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -12,11 +12,33 @@ "optional": true, "active": true }, + "CollectRemoteInstances": { + "color_code_mapping": [ + { + "color_code": [], + "layer_name_regex": [], + "family": "", + "subset_template_name": "" + } + ] + }, "ExtractImage": { "formats": [ "png", "jpg" ] + }, + "ExtractReview": { + "jpg_options": { + "tags": [ + ] + }, + "mov_options": { + "tags": [ + "review", + "ftrackreview" + ] + } } }, "workfile_builder": { diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index cfdeca4b87..cc80a94d3f 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -97,6 +97,42 @@ } } }, + "flame": { + "enabled": true, + "label": "Flame", + "icon": "{}/app_icons/flame.png", + "host_name": "flame", + "environment": { + "FLAME_SCRIPT_DIRS": { + "windows": "", + "darwin": "", + "linux": "" + } + }, + "variants": { + "2021": { + "use_python_2": true, + "executables": { + "windows": [], + "darwin": [ + "/opt/Autodesk/flame_2021/bin/flame.app/Contents/MacOS/startApp" + ], + "linux": [ + "/opt/Autodesk/flame_2021/bin/flame" + ] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, + "__dynamic_keys_labels__": { + "2021": "2021 (Testing Only)" + } + } + }, "nuke": { "enabled": true, "label": "Nuke", @@ -620,12 +656,12 @@ "FUSION_UTILITY_SCRIPTS_SOURCE_DIR": [], "FUSION_UTILITY_SCRIPTS_DIR": { "windows": "{PROGRAMDATA}/Blackmagic Design/Fusion/Scripts/Comp", - "darvin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", + "darwin": "/Library/Application Support/Blackmagic Design/Fusion/Scripts/Comp", "linux": "/opt/Fusion/Scripts/Comp" }, "PYTHON36": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", - "darvin": "~/Library/Python/3.6/bin", + "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" }, "PYTHONPATH": [ @@ -686,22 +722,22 @@ "RESOLVE_UTILITY_SCRIPTS_SOURCE_DIR": [], "RESOLVE_SCRIPT_API": { "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Support/Developer/Scripting", - "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", + "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting", "linux": "/opt/resolve/Developer/Scripting" }, "RESOLVE_SCRIPT_LIB": { "windows": "C:/Program Files/Blackmagic Design/DaVinci Resolve/fusionscript.dll", - "darvin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", + "darwin": "/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fusionscript.so", "linux": "/opt/resolve/libs/Fusion/fusionscript.so" }, "RESOLVE_UTILITY_SCRIPTS_DIR": { "windows": "{PROGRAMDATA}/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", - "darvin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", + "darwin": "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Comp", "linux": "/opt/resolve/Fusion/Scripts/Comp" }, "PYTHON36_RESOLVE": { "windows": "{LOCALAPPDATA}/Programs/Python/Python36", - "darvin": "~/Library/Python/3.6/bin", + "darwin": "~/Library/Python/3.6/bin", "linux": "/opt/Python/3.6/bin" }, "PYTHONPATH": [ @@ -973,8 +1009,6 @@ }, "variants": { "2020": { - "enabled": true, - "variant_label": "2020", "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2020\\Photoshop.exe" @@ -990,8 +1024,6 @@ "environment": {} }, "2021": { - "enabled": true, - "variant_label": "2021", "executables": { "windows": [ "C:\\Program Files\\Adobe\\Adobe Photoshop 2021\\Photoshop.exe" @@ -1005,6 +1037,21 @@ "linux": [] }, "environment": {} + }, + "2022": { + "executables": { + "windows": [ + "C:\\Program Files\\Adobe\\Adobe Photoshop 2022\\Photoshop.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} } } }, diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index aae2d1fa89..775bf40ac4 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -110,7 +110,10 @@ from .enum_entity import ( ) from .list_entity import ListEntity -from .dict_immutable_keys_entity import DictImmutableKeysEntity +from .dict_immutable_keys_entity import ( + DictImmutableKeysEntity, + RootsDictEntity +) from .dict_mutable_keys_entity import DictMutableKeysEntity from .dict_conditional import ( DictConditionalEntity, @@ -169,6 +172,7 @@ __all__ = ( "ListEntity", "DictImmutableKeysEntity", + "RootsDictEntity", "DictMutableKeysEntity", diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 0e8274d374..341968bd75 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -510,7 +510,7 @@ class BaseItemEntity(BaseEntity): pass @abstractmethod - def _item_initalization(self): + def _item_initialization(self): """Entity specific initialization process.""" pass @@ -920,7 +920,7 @@ class ItemEntity(BaseItemEntity): _default_label_wrap["collapsed"] ) - self._item_initalization() + self._item_initialization() def save(self): """Call save on root item.""" diff --git a/openpype/settings/entities/color_entity.py b/openpype/settings/entities/color_entity.py index dfaa75e761..3becf2d865 100644 --- a/openpype/settings/entities/color_entity.py +++ b/openpype/settings/entities/color_entity.py @@ -9,7 +9,7 @@ from .exceptions import ( class ColorEntity(InputEntity): schema_types = ["color"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.value_on_not_set = [0, 0, 0, 255] self.use_alpha = self.schema_data.get("use_alpha", True) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 6f27760570..0cb8827991 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -107,7 +107,7 @@ class DictConditionalEntity(ItemEntity): for _key, _value in new_value.items(): self.non_gui_children[self.current_enum][_key].set(_value) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = NOT_SET self._studio_override_metadata = NOT_SET self._project_override_metadata = NOT_SET diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 57e21ff5f3..d0cd41d11c 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -4,7 +4,8 @@ import collections from .lib import ( WRAPPER_TYPES, OverrideState, - NOT_SET + NOT_SET, + STRING_TYPE ) from openpype.settings.constants import ( METADATA_KEYS, @@ -18,6 +19,7 @@ from . import ( GUIEntity ) from .exceptions import ( + DefaultsNotDefined, SchemaDuplicatedKeys, EntitySchemaError, InvalidKeySymbols @@ -172,7 +174,7 @@ class DictImmutableKeysEntity(ItemEntity): for child_obj in added_children: self.gui_layout.append(child_obj) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = NOT_SET self._studio_override_metadata = NOT_SET self._project_override_metadata = NOT_SET @@ -547,3 +549,178 @@ class DictImmutableKeysEntity(ItemEntity): super(DictImmutableKeysEntity, self).reset_callbacks() for child_entity in self.children: child_entity.reset_callbacks() + + +class RootsDictEntity(DictImmutableKeysEntity): + """Entity that adds ability to fill value for roots of current project. + + Value schema is defined by `object_type`. + + It is not possible to change override state (Studio values will always + contain studio overrides and same for project). That is because roots can + be totally different for each project. + """ + _origin_schema_data = None + schema_types = ["dict-roots"] + + def _item_initialization(self): + origin_schema_data = self.schema_data + + self.separate_items = origin_schema_data.get("separate_items", True) + object_type = origin_schema_data.get("object_type") + if isinstance(object_type, STRING_TYPE): + object_type = {"type": object_type} + self.object_type = object_type + + if not self.is_group: + self.is_group = True + + schema_data = copy.deepcopy(self.schema_data) + schema_data["children"] = [] + + self.schema_data = schema_data + self._origin_schema_data = origin_schema_data + + self._default_value = NOT_SET + self._studio_value = NOT_SET + self._project_value = NOT_SET + + super(RootsDictEntity, self)._item_initialization() + + def schema_validations(self): + if self.object_type is None: + reason = ( + "Missing children definitions for root values" + " ('object_type' not filled)." + ) + raise EntitySchemaError(self, reason) + + if not isinstance(self.object_type, dict): + reason = ( + "Children definitions for root values must be dictionary" + " ('object_type' is \"{}\")." + ).format(str(type(self.object_type))) + raise EntitySchemaError(self, reason) + + super(RootsDictEntity, self).schema_validations() + + def set_override_state(self, state, ignore_missing_defaults): + self.children = [] + self.non_gui_children = {} + self.gui_layout = [] + + roots_entity = self.get_entity_from_path( + "project_anatomy/roots" + ) + children = [] + first = True + for key in roots_entity.keys(): + if first: + first = False + elif self.separate_items: + children.append({"type": "separator"}) + child = copy.deepcopy(self.object_type) + child["key"] = key + child["label"] = key + children.append(child) + + schema_data = copy.deepcopy(self.schema_data) + schema_data["children"] = children + + self._add_children(schema_data) + + self._set_children_values(state) + + super(RootsDictEntity, self).set_override_state( + state, True + ) + + if state == OverrideState.STUDIO: + self.add_to_studio_default() + + elif state == OverrideState.PROJECT: + self.add_to_project_override() + + def on_child_change(self, child_obj): + if self._override_state is OverrideState.STUDIO: + if not child_obj.has_studio_override: + self.add_to_studio_default() + + elif self._override_state is OverrideState.PROJECT: + if not child_obj.has_project_override: + self.add_to_project_override() + + return super(RootsDictEntity, self).on_child_change(child_obj) + + def _set_children_values(self, state): + if state >= OverrideState.DEFAULTS: + default_value = self._default_value + if default_value is NOT_SET: + if state > OverrideState.DEFAULTS: + raise DefaultsNotDefined(self) + else: + default_value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = default_value.get(key, NOT_SET) + child_obj.update_default_value(child_value) + + if state >= OverrideState.STUDIO: + value = self._studio_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_studio_value(child_value) + + if state >= OverrideState.PROJECT: + value = self._project_value + if value is NOT_SET: + value = {} + + for key, child_obj in self.non_gui_children.items(): + child_value = value.get(key, NOT_SET) + child_obj.update_project_value(child_value) + + def _update_current_metadata(self): + """Override this method as this entity should not have metadata.""" + self._metadata_are_modified = False + self._current_metadata = {} + + def update_default_value(self, value): + """Update default values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "default") + value, _ = self._prepare_value(value) + + self._default_value = value + self._default_metadata = {} + self.has_default_value = value is not NOT_SET + + def update_studio_value(self, value): + """Update studio override values. + + Not an api method, should be called by parent. + """ + value = self._check_update_value(value, "studio override") + value, _ = self._prepare_value(value) + + self._studio_value = value + self._studio_override_metadata = {} + self.had_studio_override = value is not NOT_SET + + def update_project_value(self, value): + """Update project override values. + + Not an api method, should be called by parent. + """ + + value = self._check_update_value(value, "project override") + value, _metadata = self._prepare_value(value) + + self._project_value = value + self._project_override_metadata = {} + self.had_project_override = value is not NOT_SET diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index f75fb23d82..cff346e9ea 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -191,7 +191,7 @@ class DictMutableKeysEntity(EndpointEntity): child_entity = self.children_by_key[key] self.set_child_label(child_entity, label) - def _item_initalization(self): + def _item_initialization(self): self._default_metadata = {} self._studio_override_metadata = {} self._project_override_metadata = {} diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index a5e734f039..ab3cebbd42 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -8,7 +8,7 @@ from .lib import ( class BaseEnumEntity(InputEntity): - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = None self.enum_items = None @@ -70,7 +70,7 @@ class BaseEnumEntity(InputEntity): class EnumEntity(BaseEnumEntity): schema_types = ["enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", False) self.enum_items = self.schema_data.get("enum_items") # Default is optional and non breaking attribute @@ -143,6 +143,7 @@ class HostsEnumEntity(BaseEnumEntity): "aftereffects", "blender", "celaction", + "flame", "fusion", "harmony", "hiero", @@ -156,7 +157,7 @@ class HostsEnumEntity(BaseEnumEntity): "standalonepublisher" ] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) use_empty_value = False if not self.multiselection: @@ -249,7 +250,7 @@ class HostsEnumEntity(BaseEnumEntity): class AppsEnumEntity(BaseEnumEntity): schema_types = ["apps-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = [] self.enum_items = [] @@ -316,7 +317,7 @@ class AppsEnumEntity(BaseEnumEntity): class ToolsEnumEntity(BaseEnumEntity): schema_types = ["tools-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = True self.value_on_not_set = [] self.enum_items = [] @@ -375,7 +376,7 @@ class ToolsEnumEntity(BaseEnumEntity): class TaskTypeEnumEntity(BaseEnumEntity): schema_types = ["task-types-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) if self.multiselection: self.valid_value_types = (list, ) @@ -451,7 +452,7 @@ class TaskTypeEnumEntity(BaseEnumEntity): class DeadlineUrlEnumEntity(BaseEnumEntity): schema_types = ["deadline_url-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) self.enum_items = [] @@ -502,7 +503,7 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] - def _item_initalization(self): + def _item_initialization(self): self.multiselection = False self.enum_items = [] diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 0ded3ab7e5..a0598d405e 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -362,7 +362,7 @@ class NumberEntity(InputEntity): float_number_regex = re.compile(r"^\d+\.\d+$") int_number_regex = re.compile(r"^\d+$") - def _item_initalization(self): + def _item_initialization(self): self.minimum = self.schema_data.get("minimum", -99999) self.maximum = self.schema_data.get("maximum", 99999) self.decimal = self.schema_data.get("decimal", 0) @@ -420,7 +420,7 @@ class NumberEntity(InputEntity): class BoolEntity(InputEntity): schema_types = ["boolean"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (bool, ) value_on_not_set = self.convert_to_valid_type( self.schema_data.get("default", True) @@ -431,7 +431,7 @@ class BoolEntity(InputEntity): class TextEntity(InputEntity): schema_types = ["text"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" @@ -449,7 +449,7 @@ class TextEntity(InputEntity): class PathInput(InputEntity): schema_types = ["path-input"] - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (STRING_TYPE, ) self.value_on_not_set = "" @@ -460,7 +460,7 @@ class PathInput(InputEntity): class RawJsonEntity(InputEntity): schema_types = ["raw-json"] - def _item_initalization(self): + def _item_initialization(self): # Schema must define if valid value is dict or list store_as_string = self.schema_data.get("store_as_string", False) is_list = self.schema_data.get("is_list", False) diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index c7c9c3097e..ff0a982900 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -48,7 +48,7 @@ class PathEntity(ItemEntity): raise AttributeError(self.attribute_error_msg.format("items")) return self.child_obj.items() - def _item_initalization(self): + def _item_initialization(self): if self.group_item is None and not self.is_group: self.is_group = True @@ -216,7 +216,7 @@ class ListStrictEntity(ItemEntity): return self.children[idx] return default - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.require_key = True diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index b06f4d7a2e..5d89a81351 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -149,7 +149,7 @@ class ListEntity(EndpointEntity): return list(value) return NOT_SET - def _item_initalization(self): + def _item_initialization(self): self.valid_value_types = (list, ) self.children = [] self.value_on_not_set = [] diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 05d20ee60b..b8baed8a93 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -65,7 +65,7 @@ class RootEntity(BaseItemEntity): super(RootEntity, self).__init__(schema_data) self._require_restart_callbacks = [] self._item_ids_require_restart = set() - self._item_initalization() + self._item_initialization() if reset: self.reset() @@ -176,7 +176,7 @@ class RootEntity(BaseItemEntity): for child_obj in added_children: self.gui_layout.append(child_obj) - def _item_initalization(self): + def _item_initialization(self): # Store `self` to `root_item` for children entities self.root_item = self diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index c8432f0f2e..4e8dcc36ce 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -2,7 +2,7 @@ ## Basic rules - configurations does not define GUI, but GUI defines configurations! -- output is always json (yaml is not needed for anatomy templates anymore) +- output is always json serializable - GUI schema has multiple input types, all inputs are represented by a dictionary - each input may have "input modifiers" (keys in dictionary) that are required or optional - only required modifier for all input items is key `"type"` which says what type of item it is @@ -13,16 +13,16 @@ - `"is_group"` - define that all values under key in hierarchy will be overriden if any value is modified, this information is also stored to overrides - this keys is not allowed for all inputs as they may have not reason for that - key is validated, can be only once in hierarchy but is not required -- currently there are `system configurations` and `project configurations` +- currently there are `system settings` and `project settings` ## Inner schema - GUI schemas are huge json files, to be able to split whole configuration into multiple schema there's type `schema` -- system configuration schemas are stored in `~/tools/settings/settings/gui_schemas/system_schema/` and project configurations in `~/tools/settings/settings/gui_schemas/projects_schema/` +- system configuration schemas are stored in `~/openpype/settings/entities/schemas/system_schema/` and project configurations in `~/openpype/settings/entities/schemas/projects_schema/` - each schema name is filename of json file except extension (without ".json") - if content is dictionary content will be used as `schema` else will be used as `schema_template` ### schema -- can have only key `"children"` which is list of strings, each string should represent another schema (order matters) string represebts name of the schema +- can have only key `"children"` which is list of strings, each string should represent another schema (order matters) string represents name of the schema - will just paste schemas from other schema file in order of "children" list ``` @@ -32,8 +32,9 @@ } ``` -### schema_template +### template - allows to define schema "templates" to not duplicate same content multiple times +- legacy name is `schema_template` (still usable) ```javascript // EXAMPLE json file content (filename: example_template.json) [ @@ -59,11 +60,11 @@ // EXAMPLE usage of the template in schema { "type": "dict", - "key": "schema_template_examples", + "key": "template_examples", "label": "Schema template examples", "children": [ { - "type": "schema_template", + "type": "template", // filename of template (example_template.json) "name": "example_template", "template_data": { @@ -72,7 +73,7 @@ "multipath_executables": false } }, { - "type": "schema_template", + "type": "template", "name": "example_template", "template_data": { "host_label": "Maya 2020", @@ -98,8 +99,16 @@ ... } ``` -- Unfilled fields can be also used for non string values, in that case value must contain only one key and value for fill must contain right type. +- Unfilled fields can be also used for non string values(e.g. dictionary), in that case value must contain only one key and value for fill must contain right type. ```javascript +// Passed data +{ + "executable_multiplatform": { + "type": "schema", + "name": "my_multiplatform_schema" + } +} +// Template content { ... // Allowed @@ -121,32 +130,34 @@ "name": "project_settings/global" } ``` -- all valid `ModuleSettingsDef` classes where calling of `get_settings_schemas` +- all valid `BaseModuleSettingsDef` classes where calling of `get_settings_schemas` will return dictionary where is key "project_settings/global" with schemas will extend and replace this item -- works almost the same way as templates +- dynamic schemas work almost the same way as templates - one item can be replaced by multiple items (or by 0 items) - goal is to dynamically loaded settings of OpenPype addons without having their schemas or default values in main repository + - values of these schemas are saved using the `BaseModuleSettingsDef` methods +- easiest is to use `JsonFilesSettingsDef` which has full implementation of storing default values to json files all you have to implement is method `get_settings_root_path` which should return path to root directory where settings schema can be found and will be saved ## Basic Dictionary inputs - these inputs wraps another inputs into {key: value} relation ## dict -- this is another dictionary input wrapping more inputs but visually makes them different -- item may be used as widget (in `list` or `dict-modifiable`) +- this is dictionary type wrapping more inputs with keys defined in schema +- may be used as dynamic children (e.g. in `list` or `dict-modifiable`) - in that case the only key modifier is `children` which is list of it's keys - USAGE: e.g. List of dictionaries where each dictionary have same structure. -- item may be with or without `"label"` if is not used as widget - - required keys are `"key"` under which will be stored - - without label it is just wrap item holding `"key"` - - can't have `"is_group"` key set to True as it breaks visual override showing - - if `"label"` is entetered there which will be shown in GUI - - item with label can be collapsible - - that can be set with key `"collapsible"` as `True`/`False` (Default: `True`) - - with key `"collapsed"` as `True`/`False` can be set that is collapsed when GUI is opened (Default: `False`) - - it is possible to add darker background with `"highlight_content"` (Default: `False`) - - darker background has limits of maximum applies after 3-4 nested highlighted items there is not difference in the color +- if is not used as dynamic children then must have defined `"key"` under which are it's values stored +- may be with or without `"label"` (only for GUI) + - `"label"` must be set to be able mark item as group with `"is_group"` key set to True +- item with label can visually wrap it's children + - this option is enabled by default to turn off set `"use_label_wrap"` to `False` + - label wrap is by default collapsible + - that can be set with key `"collapsible"` to `True`/`False` + - with key `"collapsed"` as `True`/`False` can be set that is collapsed when GUI is opened (Default: `False`) + - it is possible to add lighter background with `"highlight_content"` (Default: `False`) + - lighter background has limits of maximum applies after 3-4 nested highlighted items there is not much difference in the color - output is dictionary `{the "key": children values}` ``` # Example @@ -197,9 +208,28 @@ } ``` +## dict-roots +- entity can be used only in Project settings +- keys of dictionary are based on current project roots +- they are not updated "live" it is required to save root changes and then + modify values on this entity + # TODO do live updates +``` +{ + "type": "dict-roots", + "key": "roots", + "label": "Roots", + "object_type": { + "type": "path", + "multiplatform": true, + "multipath": false + } +} +``` + ## dict-conditional -- is similar to `dict` but has only one child entity that will be always available -- the one entity is enumerator of possible values and based on value of the entity are defined and used other children entities +- is similar to `dict` but has always available one enum entity + - the enum entity has single selection and it's value define other children entities - each value of enumerator have defined children that will be used - there is no way how to have shared entities across multiple enum items - value from enumerator is also stored next to other values @@ -207,22 +237,27 @@ - `enum_key` must match key regex and any enum item can't have children with same key - `enum_label` is label of the entity for UI purposes - enum items are define with `enum_children` - - it's a list where each item represents enum item + - it's a list where each item represents single item for the enum - all items in `enum_children` must have at least `key` key which represents value stored under `enum_key` - - items can define `label` for UI purposes + - enum items can define `label` for UI purposes - most important part is that item can define `children` key where are definitions of it's children (`children` value works the same way as in `dict`) - to set default value for `enum_key` set it with `enum_default` - entity must have defined `"label"` if is not used as widget -- is set as group if any parent is not group -- if `"label"` is entetered there which will be shown in GUI - - item with label can be collapsible - - that can be set with key `"collapsible"` as `True`/`False` (Default: `True`) - - with key `"collapsed"` as `True`/`False` can be set that is collapsed when GUI is opened (Default: `False`) - - it is possible to add darker background with `"highlight_content"` (Default: `False`) - - darker background has limits of maximum applies after 3-4 nested highlighted items there is not difference in the color - - output is dictionary `{the "key": children values}` +- is set as group if any parent is not group (can't have children as group) +- may be with or without `"label"` (only for GUI) + - `"label"` must be set to be able mark item as group with `"is_group"` key set to True +- item with label can visually wrap it's children + - this option is enabled by default to turn off set `"use_label_wrap"` to `False` + - label wrap is by default collapsible + - that can be set with key `"collapsible"` to `True`/`False` + - with key `"collapsed"` as `True`/`False` can be set that is collapsed when GUI is opened (Default: `False`) + - it is possible to add lighter background with `"highlight_content"` (Default: `False`) + - lighter background has limits of maximum applies after 3-4 nested highlighted items there is not much difference in the color - for UI porposes was added `enum_is_horizontal` which will make combobox appear next to children inputs instead of on top of them (Default: `False`) - this has extended ability of `enum_on_right` which will move combobox to right side next to children widgets (Default: `False`) +- output is dictionary `{the "key": children values}` +- using this type as template item for list type can be used to create infinite hierarchies + ``` # Example { @@ -298,8 +333,8 @@ How output of the schema could look like on save: ``` ## Inputs for setting any kind of value (`Pure` inputs) -- all these input must have defined `"key"` under which will be stored and `"label"` which will be shown next to input - - unless they are used in different types of inputs (later) "as widgets" in that case `"key"` and `"label"` are not required as there is not place where to set them +- all inputs must have defined `"key"` if are not used as dynamic item + - they can also have defined `"label"` ### boolean - simple checkbox, nothing more to set @@ -355,21 +390,15 @@ How output of the schema could look like on save: ``` ### path-input -- enhanced text input - - does not allow to enter backslash, is auto-converted to forward slash - - may be added another validations, like do not allow end path with slash - this input is implemented to add additional features to text input -- this is meant to be used in proxy input `path-widget` +- this is meant to be used in proxy input `path` - DO NOT USE this input in schema please ### raw-json - a little bit enhanced text input for raw json +- can store dictionary (`{}`) or list (`[]`) but not both + - by default stores dictionary to change it to list set `is_list` to `True` - has validations of json format - - empty value is invalid value, always must be json serializable - - valid value types are list `[]` and dictionary `{}` -- schema also defines valid value type - - by default it is dictionary - - to be able use list it is required to define `is_list` to `true` - output can be stored as string - this is to allow any keys in dictionary - set key `store_as_string` to `true` @@ -385,7 +414,7 @@ How output of the schema could look like on save: ``` ### enum -- returns value of single on multiple items from predefined values +- enumeration of values that are predefined in schema - multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`) - values are defined under value of key `"enum_items"` as list - each item in list is simple dictionary where value is label and key is value which will be stored @@ -415,6 +444,8 @@ How output of the schema could look like on save: - have only single selection mode - it is possible to define default value `default` - `"work"` is used if default value is not specified +- enum values are not updated on the fly it is required to save templates and + reset settings to recache values ``` { "key": "host", @@ -449,6 +480,42 @@ How output of the schema could look like on save: } ``` +### apps-enum +- enumeration of available application and their variants from system settings + - applications without host name are excluded +- can be used only in project settings +- has only `multiselection` +- used only in project anatomy +``` +{ + "type": "apps-enum", + "key": "applications", + "label": "Applications" +} +``` + +### tools-enum +- enumeration of available tools and their variants from system settings +- can be used only in project settings +- has only `multiselection` +- used only in project anatomy +``` +{ + "type": "tools-enum", + "key": "tools_env", + "label": "Tools" +} +``` + +### task-types-enum +- enumeration of task types from current project +- enum values are not updated on the fly and modifications of task types on project require save and reset to be propagated to this enum +- has set `multiselection` to `True` but can be changed to `False` in schema + +### deadline_url-enum +- deadline module specific enumerator using deadline system settings to fill it's values +- TODO: move this type to deadline module + ## Inputs for setting value using Pure inputs - these inputs also have required `"key"` - attribute `"label"` is required in few conditions @@ -594,7 +661,7 @@ How output of the schema could look like on save: } ``` -### path-widget +### path - input for paths, use `path-input` internally - has 2 input modifiers `"multiplatform"` and `"multipath"` - `"multiplatform"` - adds `"windows"`, `"linux"` and `"darwin"` path inputs result is dictionary @@ -685,12 +752,13 @@ How output of the schema could look like on save: } ``` -### splitter -- visual splitter of items (more divider than splitter) +### separator +- legacy name is `splitter` (still usable) +- visual separator of items (more divider than separator) ``` { - "type": "splitter" + "type": "separator" } ``` diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 3b65f08ac4..f00bf78fe4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -43,6 +43,61 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "is_group": true, + "key": "CollectRemoteInstances", + "label": "Collect Instances for Webpublish", + "children": [ + { + "type": "label", + "label": "Set color for publishable layers, set publishable families." + }, + { + "type": "list", + "key": "color_code_mapping", + "label": "Color code mappings", + "use_label_wrap": false, + "collapsible": false, + "object_type": { + "type": "dict", + "children": [ + { + "type": "list", + "key": "color_code", + "label": "Color codes for layers", + "object_type": "text" + }, + { + "type": "list", + "key": "layer_name_regex", + "label": "Layer name regex", + "object_type": "text" + }, + { + "type": "splitter" + }, + { + "key": "family", + "label": "Resulting family", + "type": "enum", + "enum_items": [ + { + "image": "image" + } + ] + }, + { + "type": "text", + "key": "subset_template_name", + "label": "Subset template name" + } + ] + } + } + ] + }, { "type": "dict", "collapsible": true, @@ -60,7 +115,39 @@ "object_type": "text" } ] - } + }, + { + "type": "dict", + "collapsible": true, + "key": "ExtractReview", + "label": "Extract Review", + "children": [ + { + "type": "dict", + "collapsible": false, + "key": "jpg_options", + "label": "Extracted jpg Options", + "children": [ + { + "type": "schema", + "name": "schema_representation_tags" + } + ] + }, + { + "type": "dict", + "collapsible": false, + "key": "mov_options", + "label": "Extracted mov Options", + "children": [ + { + "type": "schema", + "name": "schema_representation_tags" + } + ] + } + ] + } ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 3c589f9492..7423d6fd3e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -358,6 +358,38 @@ ] } ] + }, + { + "key": "maya", + "type": "dict", + "label": "Maya", + "children": [ + { + "key": "colorManagementPreference", + "type": "dict", + "label": "Color Managment Preference", + "collapsible": false, + "children": [ + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "viewTransform", + "label": "Viewer Transform" + } + ] + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 26ebfb2bd7..cbacd12efa 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -166,7 +166,6 @@ } ] }, - { "type": "collapsible-wrap", "label": "Model", @@ -329,6 +328,30 @@ } ] }, + { + "type": "collapsible-wrap", + "label": "Rig", + "children": [ + { + "type": "schema_template", + "name": "template_publish_plugin", + "template_data": [ + { + "key": "ValidateRigContents", + "label": "Validate Rig Contents" + }, + { + "key": "ValidateRigJointsHidden", + "label": "Validate Rig Joints Hidden" + }, + { + "key": "ValidateRigControllers", + "label": "Validate Rig Controllers" + } + ] + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_load.json index 737843ad98..5bd8337e4c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_load.json @@ -13,12 +13,8 @@ "label": "Image Loader" }, { - "key": "LoadMov", - "label": "Movie Loader" - }, - { - "key": "LoadSequence", - "label": "Image Sequence Loader" + "key": "LoadClip", + "label": "Clip Loader" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_loader_plugin_nuke.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_loader_plugin_nuke.json index d01691ed5f..7ee8d0bda0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_loader_plugin_nuke.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_loader_plugin_nuke.json @@ -13,13 +13,7 @@ }, { "type": "list", - "key": "families", - "label": "Families", - "object_type": "text" - }, - { - "type": "list", - "key": "representations", + "key": "_representations", "label": "Representations", "object_type": "text" }, diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json index af6a2d49f4..c30e1f6848 100644 --- a/openpype/settings/entities/schemas/system_schema/example_schema.json +++ b/openpype/settings/entities/schemas/system_schema/example_schema.json @@ -95,11 +95,11 @@ }, { "type": "dict", - "key": "schema_template_exaples", + "key": "template_exaples", "label": "Schema template examples", "children": [ { - "type": "schema_template", + "type": "template", "name": "example_template", "template_data": { "host_label": "Application 1", @@ -108,7 +108,7 @@ } }, { - "type": "schema_template", + "type": "template", "name": "example_template", "template_data": { "host_label": "Application 2", diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json new file mode 100644 index 0000000000..1a9d8d4716 --- /dev/null +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_flame.json @@ -0,0 +1,39 @@ +{ + "type": "dict", + "key": "flame", + "label": "Autodesk Flame", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "schema_template", + "name": "template_host_unchangables" + }, + { + "key": "environment", + "label": "Environment", + "type": "raw-json" + }, + { + "type": "dict-modifiable", + "key": "variants", + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items" + } + ] + } + } + ] +} diff --git a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json index 7bcd89c650..0687b9699b 100644 --- a/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json +++ b/openpype/settings/entities/schemas/system_schema/host_settings/schema_photoshop.json @@ -20,26 +20,21 @@ "type": "raw-json" }, { - "type": "dict", + "type": "dict-modifiable", "key": "variants", - "children": [ - { - "type": "schema_template", - "name": "template_host_variant", - "template_data": [ - { - "app_variant_label": "2020", - "app_variant": "2020", - "variant_skip_paths": ["use_python_2"] - }, - { - "app_variant_label": "2021", - "app_variant": "2021", - "variant_skip_paths": ["use_python_2"] - } - ] - } - ] + "collapsible_key": true, + "use_label_wrap": false, + "object_type": { + "type": "dict", + "collapsible": true, + "children": [ + { + "type": "schema_template", + "name": "template_host_variant_items", + "skip_paths": ["use_python_2"] + } + ] + } } ] } diff --git a/openpype/settings/entities/schemas/system_schema/schema_applications.json b/openpype/settings/entities/schemas/system_schema/schema_applications.json index efdd021ede..1767250aae 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_applications.json +++ b/openpype/settings/entities/schemas/system_schema/schema_applications.json @@ -9,6 +9,10 @@ "type": "schema", "name": "schema_maya" }, + { + "type": "schema", + "name": "schema_flame" + }, { "type": "schema_template", "name": "template_nuke", diff --git a/openpype/settings/entities/schemas/system_schema/schema_general.json b/openpype/settings/entities/schemas/system_schema/schema_general.json index 31cd997d14..51a58a6e27 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_general.json +++ b/openpype/settings/entities/schemas/system_schema/schema_general.json @@ -44,6 +44,7 @@ "type": "dict", "key": "disk_mapping", "label": "Disk mapping", + "is_group": true, "use_label_wrap": false, "collapsible": false, "children": [ diff --git a/openpype/style/__init__.py b/openpype/style/__init__.py index 0d7904d133..fd39e93b5d 100644 --- a/openpype/style/__init__.py +++ b/openpype/style/__init__.py @@ -2,6 +2,8 @@ import os import json import collections from openpype import resources +import six +from .color_defs import parse_color _STYLESHEET_CACHE = None @@ -10,7 +12,71 @@ _FONT_IDS = None current_dir = os.path.dirname(os.path.abspath(__file__)) +def _get_colors_raw_data(): + """Read data file with stylesheet fill values. + + Returns: + dict: Loaded data for stylesheet. + """ + data_path = os.path.join(current_dir, "data.json") + with open(data_path, "r") as data_stream: + data = json.load(data_stream) + return data + + +def get_colors_data(): + """Only color data from stylesheet data.""" + data = _get_colors_raw_data() + return data.get("color") or {} + + +def _convert_color_values_to_objects(value): + """Parse all string values in dictionary to Color definitions. + + Recursive function calling itself if value is dictionary. + + Args: + value (dict, str): String is parsed into color definition object and + dictionary is passed into this function. + + Raises: + TypeError: If value in color data do not contain string of dictionary. + """ + if isinstance(value, dict): + output = {} + for _key, _value in value.items(): + output[_key] = _convert_color_values_to_objects(_value) + return output + + if not isinstance(value, six.string_types): + raise TypeError(( + "Unexpected type in colors data '{}'. Expected 'str' or 'dict'." + ).format(str(type(value)))) + return parse_color(value) + + +def get_objected_colors(): + """Colors parsed from stylesheet data into color definitions. + + Returns: + dict: Parsed color objects by keys in data. + """ + colors_data = get_colors_data() + output = {} + for key, value in colors_data.items(): + output[key] = _convert_color_values_to_objects(value) + return output + + def _load_stylesheet(): + """Load strylesheet and trigger all related callbacks. + + Style require more than a stylesheet string. Stylesheet string + contains paths to resources which must be registered into Qt application + and load fonts used in stylesheets. + + Also replace values from stylesheet data into stylesheet text. + """ from . import qrc_resources qrc_resources.qInitResources() @@ -19,9 +85,7 @@ def _load_stylesheet(): with open(style_path, "r") as style_file: stylesheet = style_file.read() - data_path = os.path.join(current_dir, "data.json") - with open(data_path, "r") as data_stream: - data = json.load(data_stream) + data = _get_colors_raw_data() data_deque = collections.deque() for item in data.items(): @@ -44,6 +108,7 @@ def _load_stylesheet(): def _load_font(): + """Load and register fonts into Qt application.""" from Qt import QtGui global _FONT_IDS @@ -83,6 +148,7 @@ def _load_font(): def load_stylesheet(): + """Load and return OpenPype Qt stylesheet.""" global _STYLESHEET_CACHE if _STYLESHEET_CACHE is None: _STYLESHEET_CACHE = _load_stylesheet() @@ -91,4 +157,5 @@ def load_stylesheet(): def app_icon_path(): + """Path to OpenPype icon.""" return resources.get_openpype_icon_filepath() diff --git a/openpype/style/color_defs.py b/openpype/style/color_defs.py new file mode 100644 index 0000000000..0f4e145ca0 --- /dev/null +++ b/openpype/style/color_defs.py @@ -0,0 +1,391 @@ +"""Color definitions that can be used to parse strings for stylesheet. + +Each definition must have available method `get_qcolor` which should return +`QtGui.QColor` representation of the color. + +# TODO create abstract class to force this method implementation + +Usage: Some colors may be not be used only in stylesheet but is required to +use them in code too. To not hardcode these color values into code it is better +to use same colors that are available fro stylesheets. + +It is possible that some colors may not be used in stylesheet at all and thei +definition is used only in code. +""" + +import re + + +def parse_color(value): + """Parse string value of color to one of objected representation. + + Args: + value(str): Color definition usable in stylesheet. + """ + modified_value = value.strip().lower() + if modified_value.startswith("hsla"): + return HSLAColor(value) + + if modified_value.startswith("hsl"): + return HSLColor(value) + + if modified_value.startswith("#"): + return HEXColor(value) + + if modified_value.startswith("rgba"): + return RGBAColor(value) + + if modified_value.startswith("rgb"): + return RGBColor(value) + return UnknownColor(value) + + +def create_qcolor(*args): + """Create QtGui.QColor object. + + Args: + *args (tuple): It is possible to pass initialization arguments for + Qcolor. + """ + from Qt import QtGui + + return QtGui.QColor(*args) + + +def min_max_check(value, min_value, max_value): + """Validate number value if is in passed range. + + Args: + value (int, float): Value which is validated. + min_value (int, float): Minimum possible value. Validation is skipped + if passed value is None. + max_value (int, float): Maximum possible value. Validation is skipped + if passed value is None. + + Raises: + ValueError: When 'value' is out of specified range. + """ + if min_value is not None and value < min_value: + raise ValueError("Minimum expected value is '{}' got '{}'".format( + min_value, value + )) + + if max_value is not None and value > max_value: + raise ValueError("Maximum expected value is '{}' got '{}'".format( + min_value, value + )) + + +def int_validation(value, min_value=None, max_value=None): + """Validation of integer value within range. + + Args: + value (int): Validated value. + min_value (int): Minimum possible value. + max_value (int): Maximum possible value. + + Raises: + TypeError: If 'value' is not 'int' type. + """ + if not isinstance(value, int): + raise TypeError(( + "Invalid type of hue expected 'int' got {}" + ).format(str(type(value)))) + + min_max_check(value, min_value, max_value) + + +def float_validation(value, min_value=None, max_value=None): + """Validation of float value within range. + + Args: + value (float): Validated value. + min_value (float): Minimum possible value. + max_value (float): Maximum possible value. + + Raises: + TypeError: If 'value' is not 'float' type. + """ + if not isinstance(value, float): + raise TypeError(( + "Invalid type of hue expected 'int' got {}" + ).format(str(type(value)))) + + min_max_check(value, min_value, max_value) + + +class UnknownColor: + """Color from stylesheet data without known color definition. + + This is backup for unknown color definitions which may be for example + constants or definition not yet defined by class. + """ + def __init__(self, value): + self.value = value + + def get_qcolor(self): + return create_qcolor(self.value) + + +class HEXColor: + """Hex color definition. + + Hex color is defined by '#' and 3 or 6 hex values (0-F). + + Examples: + "#fff" + "#f3f3f3" + """ + regex = re.compile(r"[a-fA-F0-9]{3}(?:[a-fA-F0-9]{3})?$") + + def __init__(self, color_string): + red, green, blue = self.hex_to_rgb(color_string) + + self._color_string = color_string + self._red = red + self._green = green + self._blue = blue + + @property + def red(self): + return self._red + + @property + def green(self): + return self._green + + @property + def blue(self): + return self._blue + + def to_stylesheet_str(self): + return self._color_string + + @classmethod + def hex_to_rgb(cls, value): + """Convert hex value to rgb.""" + hex_value = value.lstrip("#") + if not cls.regex.match(hex_value): + raise ValueError("\"{}\" is not a valid HEX code.".format(value)) + + output = [] + if len(hex_value) == 3: + for char in hex_value: + output.append(int(char * 2, 16)) + else: + for idx in range(3): + start_idx = idx * 2 + output.append(int(hex_value[start_idx:start_idx + 2], 16)) + return output + + def get_qcolor(self): + return create_qcolor(self.red, self.green, self.blue) + + +class RGBColor: + """Color defined by red green and blue values. + + Each color has possible integer range 0-255. + + Examples: + "rgb(255, 127, 0)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("rgb(") + red_str, green_str, blue_str = ( + item.strip() for item in content.split(",") + ) + red = int(red_str) + green = int(green_str) + blue = int(blue_str) + + int_validation(red, 0, 255) + int_validation(green, 0, 255) + int_validation(blue, 0, 255) + + self._red = red + self._green = green + self._blue = blue + + @property + def red(self): + return self._red + + @property + def green(self): + return self._green + + @property + def blue(self): + return self._blue + + def get_qcolor(self): + return create_qcolor(self.red, self.green, self.blue) + + +class RGBAColor: + """Color defined by red green, blue and alpha values. + + Each color has possible integer range 0-255. + + Examples: + "rgba(255, 127, 0, 127)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("rgba(") + red_str, green_str, blue_str, alpha_str = ( + item.strip() for item in content.split(",") + ) + red = int(red_str) + green = int(green_str) + blue = int(blue_str) + if "." in alpha_str: + alpha = int(float(alpha_str) * 100) + else: + alpha = int(alpha_str) + + int_validation(red, 0, 255) + int_validation(green, 0, 255) + int_validation(blue, 0, 255) + int_validation(alpha, 0, 255) + + self._red = red + self._green = green + self._blue = blue + self._alpha = alpha + + @property + def red(self): + return self._red + + @property + def green(self): + return self._green + + @property + def blue(self): + return self._blue + + @property + def alpha(self): + return self._alpha + + def get_qcolor(self): + return create_qcolor(self.red, self.green, self.blue, self.alpha) + + +class HSLColor: + """Color defined by hue, saturation and light values. + + Hue is defined as integer in rage 0-360. Saturation and light can be + defined as float or percent value. + + Examples: + "hsl(27, 0.7, 0.3)" + "hsl(27, 70%, 30%)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("hsl(") + hue_str, sat_str, light_str = ( + item.strip() for item in content.split(",") + ) + hue = int(hue_str) % 360 + if "%" in sat_str: + sat = float(sat_str.rstrip("%")) / 100 + else: + sat = float(sat) + + if "%" in light_str: + light = float(light_str.rstrip("%")) / 100 + else: + light = float(light_str) + + int_validation(hue, 0, 360) + float_validation(sat, 0, 1) + float_validation(light, 0, 1) + + self._hue = hue + self._saturation = sat + self._light = light + + @property + def hue(self): + return self._hue + + @property + def saturation(self): + return self._saturation + + @property + def light(self): + return self._light + + def get_qcolor(self): + color = create_qcolor() + color.setHslF(self.hue / 360, self.saturation, self.light) + return color + + +class HSLAColor: + """Color defined by hue, saturation, light and alpha values. + + Hue is defined as integer in rage 0-360. Saturation and light can be + defined as float (0-1 range) or percent value(0-100%). And alpha + as float (0-1 range). + + Examples: + "hsl(27, 0.7, 0.3)" + "hsl(27, 70%, 30%)" + """ + def __init__(self, value): + modified_color = value.lower().strip() + content = modified_color.rstrip(")").lstrip("hsla(") + hue_str, sat_str, light_str, alpha_str = ( + item.strip() for item in content.split(",") + ) + hue = int(hue_str) % 360 + if "%" in sat_str: + sat = float(sat_str.rstrip("%")) / 100 + else: + sat = float(sat) + + if "%" in light_str: + light = float(light_str.rstrip("%")) / 100 + else: + light = float(light_str) + + alpha = float(alpha_str) + + int_validation(hue, 0, 360) + float_validation(sat, 0, 1) + float_validation(light, 0, 1) + float_validation(alpha, 0, 1) + + self._hue = hue + self._saturation = sat + self._light = light + self._alpha = alpha + + @property + def hue(self): + return self._hue + + @property + def saturation(self): + return self._saturation + + @property + def light(self): + return self._light + + @property + def alpha(self): + return self._alpha + + def get_qcolor(self): + color = create_qcolor() + color.setHslF(self.hue / 360, self.saturation, self.light, self.alpha) + return color diff --git a/openpype/style/data.json b/openpype/style/data.json index a58829d946..26f6743d72 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -28,25 +28,49 @@ "bg": "#2C313A", "bg-inputs": "#21252B", "bg-buttons": "#434a56", - "bg-button-hover": "hsla(220, 14%, 70%, .3)", + "bg-button-hover": "rgba(168, 175, 189, 0.3)", "bg-inputs-disabled": "#2C313A", "bg-buttons-disabled": "#434a56", + "bg-splitter": "#434a56", + "bg-splitter-hover": "rgba(168, 175, 189, 0.3)", + "bg-menu-separator": "rgba(75, 83, 98, 127)", "bg-scroll-handle": "#4B5362", "bg-view": "#21252B", "bg-view-header": "#373D48", - "bg-view-hover": "hsla(220, 14%, 70%, .3)", + "bg-view-hover": "rgba(168, 175, 189, .3)", "bg-view-alternate": "rgb(36, 42, 50)", "bg-view-disabled": "#434a56", "bg-view-alternate-disabled": "#2C313A", - "bg-view-selection": "hsla(200, 60%, 60%, .4)", - "bg-view-selection-hover": "hsla(200, 60%, 60%, .8)", + "bg-view-selection": "rgba(92, 173, 214, .4)", + "bg-view-selection-hover": "rgba(92, 173, 214, .8)", "border": "#373D48", - "border-hover": "hsla(220, 14%, 70%, .3)", - "border-focus": "hsl(200, 60%, 60%)" + "border-hover": "rgba(168, 175, 189, .3)", + "border-focus": "hsl(200, 60%, 60%)", + + "loader": { + "asset-view": { + "selected": "rgba(168, 175, 189, 0.6)", + "hover": "rgba(168, 175, 189, 0.3)", + "selected-hover": "rgba(168, 175, 189, 0.7)" + } + }, + "publisher": { + "error": "#AA5050", + "success": "#458056", + "warning": "#ffc671", + "list-view-group": { + "bg": "#434a56", + "bg-hover": "rgba(168, 175, 189, 0.3)", + "bg-selected-hover": "rgba(92, 173, 214, 0.4)", + "bg-expander": "#2C313A", + "bg-expander-hover": "#2d6c9f", + "bg-expander-selected-hover": "#3784c5" + } + } } } diff --git a/openpype/style/images/transparent.png b/openpype/style/images/transparent.png new file mode 100644 index 0000000000..0f2e143b39 Binary files /dev/null and b/openpype/style/images/transparent.png differ diff --git a/openpype/style/pyqt5_resources.py b/openpype/style/pyqt5_resources.py index 3dc21be12a..5a3f901840 100644 --- a/openpype/style/pyqt5_resources.py +++ b/openpype/style/pyqt5_resources.py @@ -10,19 +10,7 @@ from PyQt5 import QtCore qt_resource_data = b"\ -\x00\x00\x00\xa0\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1c\x1f\x24\ -\xc6\x09\x17\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ -\x05\xff\xcf\xc3\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x0e\ -\xa3\x21\x9c\xc3\x68\x88\x61\x1a\x0a\x00\x00\x6d\x84\x09\x75\x37\ -\x9e\xd9\x23\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x07\x30\ +\x00\x00\x07\x06\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ \x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ @@ -80,10 +68,10 @@ qt_resource_data = b"\ \x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ \x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ \x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ \x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ \x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ \x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ \x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ \x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ @@ -94,14 +82,14 @@ qt_resource_data = b"\ \x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ \x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ \x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ -\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\x30\x30\ +\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\x30\x30\ \x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ \x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ \x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ \x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ \x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ \x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ -\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x48\x8b\x5b\x5e\x00\x00\x01\x83\ +\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x85\x9d\x9f\x08\x00\x00\x01\x83\ \x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ \x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ \xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ @@ -128,30 +116,15 @@ qt_resource_data = b"\ \xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ \x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ \x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x97\x49\x44\x41\x54\x18\x95\x6d\xcf\xb1\x6a\x02\x41\ -\x14\x85\xe1\x6f\xb7\xb6\xd0\x27\x48\x3d\x56\x69\x03\xb1\xb4\x48\ -\x3b\x6c\xa5\xf1\x39\xf6\x59\x02\x56\x42\xba\x61\x0a\x0b\x3b\x1b\ -\x1b\x6b\x41\x18\x02\x29\x6d\xe3\xbe\x82\xcd\x06\x16\xd9\xdb\xdd\ -\x9f\xff\x5c\xee\xa9\x62\x2a\x13\x4c\x73\x13\x6e\x46\x26\xa6\xf2\ -\x82\xae\x46\x8b\xdf\x98\xca\xfb\x88\xb4\xc0\x0f\xda\x1a\x5b\x74\ -\xd8\xc7\x54\xc2\x40\x9a\x63\x8f\x3f\x7c\x55\x3d\x7c\xc5\x09\x77\ -\xbc\xa1\xc2\x19\x33\x2c\x72\x13\x2e\xd5\xe0\xc2\x12\x07\x5c\x51\ -\x23\xe0\x23\x37\xe1\xa8\x4f\x0e\x7f\xda\x60\xd7\xaf\x9f\xb9\x09\ -\xdf\x63\x05\xff\xe5\x75\x4c\x65\xf5\xcc\x1f\x0d\x33\x2c\x83\xb6\ -\x06\x44\x83\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa5\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ -\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ -\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ -\xae\x42\x60\x82\ +\x00\x00\x00\x6d\x49\x44\x41\x54\x18\x95\x75\xcf\xc1\x09\xc2\x50\ +\x10\x84\xe1\xd7\x85\x07\x9b\xd0\x43\x40\xd2\x82\x78\x14\x7b\x30\ +\x57\x21\x8d\x84\x60\x3f\x62\x4b\x7a\x48\xcc\x97\x83\xfb\x30\x04\ +\xdf\x9c\x86\x7f\x67\x99\xdd\x84\x0d\xaa\x54\x10\x6a\x6c\x13\x1e\ +\xbe\xba\xfe\x09\x35\x31\x7b\xe6\x8d\x0f\x26\x1c\x17\xa1\x53\xb0\ +\x11\x87\x0c\x2f\x01\x07\xec\xb0\x0f\x3f\xe1\xbc\xae\x69\xa3\xe6\ +\x85\x77\xf8\x5b\xe9\xf0\xbb\x9f\xfa\xd2\x83\x39\xdc\xa3\x5b\xf3\ +\x19\x2e\xa8\x89\xb5\x30\xf7\x43\xa0\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ \x00\x00\x00\xa0\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -164,6 +137,19 @@ qt_resource_data = b"\ \x05\x73\x3e\xc0\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x4e\ \x8a\x00\x9c\x93\x22\x80\x61\x1a\x0a\x00\x00\x29\x95\x08\xaf\x88\ \xac\xba\x34\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ \x00\x00\x07\xad\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -289,6 +275,45 @@ qt_resource_data = b"\ \x5e\x78\xa2\x9e\x0e\xa7\x20\x74\x47\x39\x1d\xf6\xe1\x95\x2b\xd6\ \xb1\x44\x8e\x0e\xcb\x58\xf0\x0f\x52\x8a\x79\x18\xdc\xe2\x02\x70\ \x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ +\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ +\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ \x00\x00\x00\xa0\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -313,18 +338,26 @@ qt_resource_data = b"\ \x0d\xe6\x7c\x80\xb1\x18\x91\x05\x52\x04\xe0\x42\x08\x15\x29\x02\ \x0c\x0c\x8c\xc8\x02\x08\x95\x68\x00\x00\xac\xac\x07\x90\x4e\x65\ \x34\xac\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\x9e\ +\x00\x00\x00\x45\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90\x77\x53\xde\ +\x00\x00\x00\x0c\x49\x44\x41\x54\x08\x99\x63\x60\x60\x60\x00\x00\ +\x00\x04\x00\x01\xa3\x0a\x15\xe3\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x00\xa5\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ \x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ \x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ \x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x0f\xfd\ -\x8f\xf8\x2e\x00\x00\x00\x22\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1\x42\x48\x2a\x0c\x19\ -\x18\x18\x91\x05\x10\x2a\xd1\x00\x00\xca\xb5\x07\xd2\x76\xbb\xb2\ -\xc5\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ \x00\x00\x00\xa5\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -341,15 +374,170 @@ qt_resource_data = b"\ \x00\x00\x00\xa6\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ \x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ \x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ -\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ -\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1f\x20\xb9\ +\x8d\x77\xe9\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x06\xe6\x7c\x60\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +\x64\x60\x60\x62\x60\x48\x11\x40\xe2\x20\x73\x19\x90\x8d\x40\x02\ +\x00\x23\xed\x08\xaf\x64\x9f\x0f\x15\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa5\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x07\x30\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ +\x00\x00\x04\xb0\x69\x54\x58\x74\x58\x4d\x4c\x3a\x63\x6f\x6d\x2e\ +\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\x00\x00\x00\x3c\x3f\ +\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\x69\x6e\x3d\x22\xef\ +\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\x30\x4d\x70\x43\x65\ +\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\x7a\x6b\x63\x39\x64\ +\x22\x3f\x3e\x0a\x3c\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\x62\x65\x3a\x6e\x73\ +\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\x6d\x70\x74\x6b\x3d\ +\x22\x58\x4d\x50\x20\x43\x6f\x72\x65\x20\x35\x2e\x35\x2e\x30\x22\ +\x3e\x0a\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x20\x78\x6d\x6c\x6e\ +\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\ +\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\ +\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\ +\x73\x23\x22\x3e\x0a\x20\x20\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\ +\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\ +\x74\x3d\x22\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x65\ +\x78\x69\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\ +\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x65\x78\x69\x66\x2f\x31\x2e\ +\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x74\x69\ +\x66\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\ +\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x74\x69\x66\x66\x2f\x31\x2e\x30\ +\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x70\x68\x6f\ +\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ +\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\x68\x6f\x74\ +\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\ +\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\x70\x3d\x22\x68\x74\x74\x70\x3a\ +\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\ +\x61\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ +\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ +\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x0a\x20\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\ +\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\ +\x78\x61\x70\x2f\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\ +\x73\x6f\x75\x72\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x0a\x20\x20\ +\x20\x65\x78\x69\x66\x3a\x50\x69\x78\x65\x6c\x58\x44\x69\x6d\x65\ +\x6e\x73\x69\x6f\x6e\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x65\x78\ +\x69\x66\x3a\x50\x69\x78\x65\x6c\x59\x44\x69\x6d\x65\x6e\x73\x69\ +\x6f\x6e\x3d\x22\x37\x22\x0a\x20\x20\x20\x65\x78\x69\x66\x3a\x43\ +\x6f\x6c\x6f\x72\x53\x70\x61\x63\x65\x3d\x22\x31\x22\x0a\x20\x20\ +\x20\x74\x69\x66\x66\x3a\x49\x6d\x61\x67\x65\x57\x69\x64\x74\x68\ +\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x49\x6d\ +\x61\x67\x65\x4c\x65\x6e\x67\x74\x68\x3d\x22\x37\x22\x0a\x20\x20\ +\x20\x74\x69\x66\x66\x3a\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\ +\x55\x6e\x69\x74\x3d\x22\x32\x22\x0a\x20\x20\x20\x74\x69\x66\x66\ +\x3a\x58\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\ +\x2e\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x59\x52\x65\x73\ +\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\x2e\x30\x22\x0a\x20\ +\x20\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\ +\x72\x4d\x6f\x64\x65\x3d\x22\x33\x22\x0a\x20\x20\x20\x70\x68\x6f\ +\x74\x6f\x73\x68\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\ +\x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ +\x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ +\x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ +\x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ +\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ +\x2d\x33\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\ +\x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ +\x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ +\x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ +\x6c\x69\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x61\ +\x63\x74\x69\x6f\x6e\x3d\x22\x70\x72\x6f\x64\x75\x63\x65\x64\x22\ +\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\ +\x74\x77\x61\x72\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x66\x66\x69\ +\x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ +\x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ +\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ +\x31\x54\x31\x32\x3a\x33\x33\x3a\x31\x34\x2b\x30\x32\x3a\x30\x30\ +\x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ +\x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ +\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ +\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ +\x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ +\x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ +\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x48\x8b\x5b\x5e\x00\x00\x01\x83\ +\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ +\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ +\xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ +\x1b\x8b\x99\x18\x0a\x8b\x99\x51\x7e\x6d\x66\x9e\x79\x33\x6a\xde\ +\x78\xbd\x37\xd2\x64\xab\x6c\xa7\x28\xb1\xf1\x6b\xc1\x5f\xc0\x56\ +\x59\x2b\x45\xa4\x64\xa7\xac\x89\x0d\x7a\xce\x9b\x51\x23\x99\x73\ +\x3b\xf7\x7c\xee\xf7\xde\x73\xba\xf7\x5c\x70\x44\xd3\x8a\x66\x56\ +\xfa\x41\xcb\x64\x8d\x70\x28\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ +\x85\x1a\x3a\xf1\xc6\x14\x53\x9f\x8c\x8c\x46\x29\x6b\xef\xb7\x54\ +\xd8\xf1\xba\xdb\xae\x55\xfe\xdc\xbf\x56\xb7\x98\x30\x15\xa8\xa8\ +\x16\x1e\x56\x74\x23\x2b\x3c\x26\x3c\xb1\x9a\xd5\x6d\xde\x12\x6e\ +\x52\x52\xb1\x45\xe1\x13\xe1\x2e\x43\x2e\x28\x7c\x63\xeb\xf1\x22\ +\x3f\xdb\x9c\x2c\xf2\xa7\xcd\x46\x34\x1c\x04\x47\x83\xb0\x2f\xf9\ +\x8b\xe3\xbf\x58\x49\x19\x9a\xb0\xbc\x9c\x36\x2d\xbd\xa2\xfc\xdc\ +\xc7\x7e\x89\x3b\x91\x99\x8e\x48\x6c\x15\xf7\x62\x12\x26\x44\x00\ +\x1f\xe3\x8c\x10\x64\x80\x5e\x86\x64\x1e\xa0\x9b\x3e\x7a\x64\x45\ +\x99\x7c\x7f\x21\x7f\x8a\x65\xc9\x55\x64\xd6\xc9\x61\xb0\x44\x92\ +\x14\x59\xba\x44\x5d\x91\xea\x09\x89\xaa\xe8\x09\x19\x69\x72\x76\ +\xff\xff\xf6\xd5\x54\xfb\xfb\x8a\xd5\xdd\x01\xa8\x7a\xb4\xac\xd7\ +\x76\x70\x6d\xc2\x57\xde\xb2\x3e\x0e\x2c\xeb\xeb\x10\x9c\x0f\x70\ +\x9e\x29\xe5\x2f\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9e\x75\ +\x38\xbd\x28\x69\xf1\x6d\x38\xdb\x80\xe6\x7b\x3d\x66\xc4\x0a\x92\ +\x53\xdc\xa1\xaa\xf0\x72\x0c\xf5\xb3\xd0\x78\x05\xb5\xf3\xc5\x9e\ +\xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ +\x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x97\x49\x44\x41\x54\x18\x95\x6d\xcf\xb1\x6a\x02\x41\ +\x14\x85\xe1\x6f\xb7\xb6\xd0\x27\x48\x3d\x56\x69\x03\xb1\xb4\x48\ +\x3b\x6c\xa5\xf1\x39\xf6\x59\x02\x56\x42\xba\x61\x0a\x0b\x3b\x1b\ +\x1b\x6b\x41\x18\x02\x29\x6d\xe3\xbe\x82\xcd\x06\x16\xd9\xdb\xdd\ +\x9f\xff\x5c\xee\xa9\x62\x2a\x13\x4c\x73\x13\x6e\x46\x26\xa6\xf2\ +\x82\xae\x46\x8b\xdf\x98\xca\xfb\x88\xb4\xc0\x0f\xda\x1a\x5b\x74\ +\xd8\xc7\x54\xc2\x40\x9a\x63\x8f\x3f\x7c\x55\x3d\x7c\xc5\x09\x77\ +\xbc\xa1\xc2\x19\x33\x2c\x72\x13\x2e\xd5\xe0\xc2\x12\x07\x5c\x51\ +\x23\xe0\x23\x37\xe1\xa8\x4f\x0e\x7f\xda\x60\xd7\xaf\x9f\xb9\x09\ +\xdf\x63\x05\xff\xe5\x75\x4c\x65\xf5\xcc\x1f\x0d\x33\x2c\x83\xb6\ +\x06\x44\x83\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa0\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1c\x1f\x24\ +\xc6\x09\x17\x00\x00\x00\x24\x49\x44\x41\x54\x08\xd7\x63\x60\x40\ +\x05\xff\xcf\xc3\x58\x4c\xc8\x5c\x26\x64\x59\x26\x64\xc5\x70\x0e\ +\xa3\x21\x9c\xc3\x68\x88\x61\x1a\x0a\x00\x00\x6d\x84\x09\x75\x37\ +\x9e\xd9\x23\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ +\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ +\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ \x44\xae\x42\x60\x82\ \x00\x00\x07\xdd\ \x89\ @@ -479,45 +667,6 @@ qt_resource_data = b"\ \x71\x5b\x73\x5c\x40\x48\xa5\xdd\x61\x81\x0d\x9e\x6b\x8e\xff\xfd\ \xcf\x3f\xcc\x31\xe9\x01\x1c\x00\x73\x52\x2d\x71\xe4\x4a\x1b\x69\ \x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ -\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ -\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa5\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ -\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ -\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ -\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1f\x20\xb9\ -\x8d\x77\xe9\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x06\xe6\x7c\x60\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -\x64\x60\x60\x62\x60\x48\x11\x40\xe2\x20\x73\x19\x90\x8d\x40\x02\ -\x00\x23\xed\x08\xaf\x64\x9f\x0f\x15\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ \x00\x00\x00\x9e\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ @@ -530,160 +679,18 @@ qt_resource_data = b"\ \x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1\x42\x48\x2a\x0c\x19\ \x18\x18\x91\x05\x10\x2a\xd1\x00\x00\xca\xb5\x07\xd2\x76\xbb\xb2\ \xc5\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ +\x00\x00\x00\x9e\ \x89\ \x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ \x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ \x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ \x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ -\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ -\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ -\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ -\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x00\xa6\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x14\x1d\x00\xb0\ -\xd5\x35\xa3\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ -\x06\xfe\x9f\x67\x60\x60\x42\x30\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -\x64\x60\x60\x62\x60\x60\x34\x44\xe2\x20\x73\x19\x90\x8d\x40\x02\ -\x00\x64\x40\x09\x75\x86\xb3\xad\x9c\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ -\x00\x00\x07\x06\ -\x89\ -\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ -\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x00\x31\xac\xdc\x63\ -\x00\x00\x04\xb0\x69\x54\x58\x74\x58\x4d\x4c\x3a\x63\x6f\x6d\x2e\ -\x61\x64\x6f\x62\x65\x2e\x78\x6d\x70\x00\x00\x00\x00\x00\x3c\x3f\ -\x78\x70\x61\x63\x6b\x65\x74\x20\x62\x65\x67\x69\x6e\x3d\x22\xef\ -\xbb\xbf\x22\x20\x69\x64\x3d\x22\x57\x35\x4d\x30\x4d\x70\x43\x65\ -\x68\x69\x48\x7a\x72\x65\x53\x7a\x4e\x54\x63\x7a\x6b\x63\x39\x64\ -\x22\x3f\x3e\x0a\x3c\x78\x3a\x78\x6d\x70\x6d\x65\x74\x61\x20\x78\ -\x6d\x6c\x6e\x73\x3a\x78\x3d\x22\x61\x64\x6f\x62\x65\x3a\x6e\x73\ -\x3a\x6d\x65\x74\x61\x2f\x22\x20\x78\x3a\x78\x6d\x70\x74\x6b\x3d\ -\x22\x58\x4d\x50\x20\x43\x6f\x72\x65\x20\x35\x2e\x35\x2e\x30\x22\ -\x3e\x0a\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x20\x78\x6d\x6c\x6e\ -\x73\x3a\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\ -\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\ -\x2f\x32\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\ -\x73\x23\x22\x3e\x0a\x20\x20\x3c\x72\x64\x66\x3a\x44\x65\x73\x63\ -\x72\x69\x70\x74\x69\x6f\x6e\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\ -\x74\x3d\x22\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x65\ -\x78\x69\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\ -\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x65\x78\x69\x66\x2f\x31\x2e\ -\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x74\x69\ -\x66\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\ -\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x74\x69\x66\x66\x2f\x31\x2e\x30\ -\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x70\x68\x6f\ -\x74\x6f\x73\x68\x6f\x70\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x6e\ -\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x70\x68\x6f\x74\ -\x6f\x73\x68\x6f\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\ -\x78\x6d\x6c\x6e\x73\x3a\x78\x6d\x70\x3d\x22\x68\x74\x74\x70\x3a\ -\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\ -\x61\x70\x2f\x31\x2e\x30\x2f\x22\x0a\x20\x20\x20\x20\x78\x6d\x6c\ -\x6e\x73\x3a\x78\x6d\x70\x4d\x4d\x3d\x22\x68\x74\x74\x70\x3a\x2f\ -\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\x78\x61\ -\x70\x2f\x31\x2e\x30\x2f\x6d\x6d\x2f\x22\x0a\x20\x20\x20\x20\x78\ -\x6d\x6c\x6e\x73\x3a\x73\x74\x45\x76\x74\x3d\x22\x68\x74\x74\x70\ -\x3a\x2f\x2f\x6e\x73\x2e\x61\x64\x6f\x62\x65\x2e\x63\x6f\x6d\x2f\ -\x78\x61\x70\x2f\x31\x2e\x30\x2f\x73\x54\x79\x70\x65\x2f\x52\x65\ -\x73\x6f\x75\x72\x63\x65\x45\x76\x65\x6e\x74\x23\x22\x0a\x20\x20\ -\x20\x65\x78\x69\x66\x3a\x50\x69\x78\x65\x6c\x58\x44\x69\x6d\x65\ -\x6e\x73\x69\x6f\x6e\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x65\x78\ -\x69\x66\x3a\x50\x69\x78\x65\x6c\x59\x44\x69\x6d\x65\x6e\x73\x69\ -\x6f\x6e\x3d\x22\x37\x22\x0a\x20\x20\x20\x65\x78\x69\x66\x3a\x43\ -\x6f\x6c\x6f\x72\x53\x70\x61\x63\x65\x3d\x22\x31\x22\x0a\x20\x20\ -\x20\x74\x69\x66\x66\x3a\x49\x6d\x61\x67\x65\x57\x69\x64\x74\x68\ -\x3d\x22\x31\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x49\x6d\ -\x61\x67\x65\x4c\x65\x6e\x67\x74\x68\x3d\x22\x37\x22\x0a\x20\x20\ -\x20\x74\x69\x66\x66\x3a\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\ -\x55\x6e\x69\x74\x3d\x22\x32\x22\x0a\x20\x20\x20\x74\x69\x66\x66\ -\x3a\x58\x52\x65\x73\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\ -\x2e\x30\x22\x0a\x20\x20\x20\x74\x69\x66\x66\x3a\x59\x52\x65\x73\ -\x6f\x6c\x75\x74\x69\x6f\x6e\x3d\x22\x37\x32\x2e\x30\x22\x0a\x20\ -\x20\x20\x70\x68\x6f\x74\x6f\x73\x68\x6f\x70\x3a\x43\x6f\x6c\x6f\ -\x72\x4d\x6f\x64\x65\x3d\x22\x33\x22\x0a\x20\x20\x20\x70\x68\x6f\ -\x74\x6f\x73\x68\x6f\x70\x3a\x49\x43\x43\x50\x72\x6f\x66\x69\x6c\ -\x65\x3d\x22\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\x36\ -\x2d\x32\x2e\x31\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x6f\x64\ -\x69\x66\x79\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ -\x30\x30\x22\x0a\x20\x20\x20\x78\x6d\x70\x3a\x4d\x65\x74\x61\x64\ -\x61\x74\x61\x44\x61\x74\x65\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\ -\x2d\x33\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\ -\x30\x30\x22\x3e\x0a\x20\x20\x20\x3c\x78\x6d\x70\x4d\x4d\x3a\x48\ -\x69\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x20\x20\x3c\x72\x64\x66\ -\x3a\x53\x65\x71\x3e\x0a\x20\x20\x20\x20\x20\x3c\x72\x64\x66\x3a\ -\x6c\x69\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x61\ -\x63\x74\x69\x6f\x6e\x3d\x22\x70\x72\x6f\x64\x75\x63\x65\x64\x22\ -\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\x3a\x73\x6f\x66\ -\x74\x77\x61\x72\x65\x41\x67\x65\x6e\x74\x3d\x22\x41\x66\x66\x69\ -\x6e\x69\x74\x79\x20\x44\x65\x73\x69\x67\x6e\x65\x72\x20\x31\x2e\ -\x39\x2e\x32\x22\x0a\x20\x20\x20\x20\x20\x20\x73\x74\x45\x76\x74\ -\x3a\x77\x68\x65\x6e\x3d\x22\x32\x30\x32\x31\x2d\x30\x35\x2d\x33\ -\x31\x54\x31\x32\x3a\x33\x30\x3a\x31\x31\x2b\x30\x32\x3a\x30\x30\ -\x22\x2f\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x53\x65\ -\x71\x3e\x0a\x20\x20\x20\x3c\x2f\x78\x6d\x70\x4d\x4d\x3a\x48\x69\ -\x73\x74\x6f\x72\x79\x3e\x0a\x20\x20\x3c\x2f\x72\x64\x66\x3a\x44\ -\x65\x73\x63\x72\x69\x70\x74\x69\x6f\x6e\x3e\x0a\x20\x3c\x2f\x72\ -\x64\x66\x3a\x52\x44\x46\x3e\x0a\x3c\x2f\x78\x3a\x78\x6d\x70\x6d\ -\x65\x74\x61\x3e\x0a\x3c\x3f\x78\x70\x61\x63\x6b\x65\x74\x20\x65\ -\x6e\x64\x3d\x22\x72\x22\x3f\x3e\x85\x9d\x9f\x08\x00\x00\x01\x83\ -\x69\x43\x43\x50\x73\x52\x47\x42\x20\x49\x45\x43\x36\x31\x39\x36\ -\x36\x2d\x32\x2e\x31\x00\x00\x28\x91\x75\x91\xcf\x2b\x44\x51\x14\ -\xc7\x3f\x66\x68\xfc\x18\x8d\x62\x61\x31\x65\x12\x16\x42\x83\x12\ -\x1b\x8b\x99\x18\x0a\x8b\x99\x51\x7e\x6d\x66\x9e\x79\x33\x6a\xde\ -\x78\xbd\x37\xd2\x64\xab\x6c\xa7\x28\xb1\xf1\x6b\xc1\x5f\xc0\x56\ -\x59\x2b\x45\xa4\x64\xa7\xac\x89\x0d\x7a\xce\x9b\x51\x23\x99\x73\ -\x3b\xf7\x7c\xee\xf7\xde\x73\xba\xf7\x5c\x70\x44\xd3\x8a\x66\x56\ -\xfa\x41\xcb\x64\x8d\x70\x28\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ -\x85\x1a\x3a\xf1\xc6\x14\x53\x9f\x8c\x8c\x46\x29\x6b\xef\xb7\x54\ -\xd8\xf1\xba\xdb\xae\x55\xfe\xdc\xbf\x56\xb7\x98\x30\x15\xa8\xa8\ -\x16\x1e\x56\x74\x23\x2b\x3c\x26\x3c\xb1\x9a\xd5\x6d\xde\x12\x6e\ -\x52\x52\xb1\x45\xe1\x13\xe1\x2e\x43\x2e\x28\x7c\x63\xeb\xf1\x22\ -\x3f\xdb\x9c\x2c\xf2\xa7\xcd\x46\x34\x1c\x04\x47\x83\xb0\x2f\xf9\ -\x8b\xe3\xbf\x58\x49\x19\x9a\xb0\xbc\x9c\x36\x2d\xbd\xa2\xfc\xdc\ -\xc7\x7e\x89\x3b\x91\x99\x8e\x48\x6c\x15\xf7\x62\x12\x26\x44\x00\ -\x1f\xe3\x8c\x10\x64\x80\x5e\x86\x64\x1e\xa0\x9b\x3e\x7a\x64\x45\ -\x99\x7c\x7f\x21\x7f\x8a\x65\xc9\x55\x64\xd6\xc9\x61\xb0\x44\x92\ -\x14\x59\xba\x44\x5d\x91\xea\x09\x89\xaa\xe8\x09\x19\x69\x72\x76\ -\xff\xff\xf6\xd5\x54\xfb\xfb\x8a\xd5\xdd\x01\xa8\x7a\xb4\xac\xd7\ -\x76\x70\x6d\xc2\x57\xde\xb2\x3e\x0e\x2c\xeb\xeb\x10\x9c\x0f\x70\ -\x9e\x29\xe5\x2f\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9e\x75\ -\x38\xbd\x28\x69\xf1\x6d\x38\xdb\x80\xe6\x7b\x3d\x66\xc4\x0a\x92\ -\x53\xdc\xa1\xaa\xf0\x72\x0c\xf5\xb3\xd0\x78\x05\xb5\xf3\xc5\x9e\ -\xfd\xec\x73\x74\x07\xd1\x35\xf9\xaa\x4b\xd8\xd9\x85\x0e\x39\xef\ -\x59\xf8\x06\x8e\xfd\x67\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09\x70\ -\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x6d\x49\x44\x41\x54\x18\x95\x75\xcf\xc1\x09\xc2\x50\ -\x10\x84\xe1\xd7\x85\x07\x9b\xd0\x43\x40\xd2\x82\x78\x14\x7b\x30\ -\x57\x21\x8d\x84\x60\x3f\x62\x4b\x7a\x48\xcc\x97\x83\xfb\x30\x04\ -\xdf\x9c\x86\x7f\x67\x99\xdd\x84\x0d\xaa\x54\x10\x6a\x6c\x13\x1e\ -\xbe\xba\xfe\x09\x35\x31\x7b\xe6\x8d\x0f\x26\x1c\x17\xa1\x53\xb0\ -\x11\x87\x0c\x2f\x01\x07\xec\xb0\x0f\x3f\xe1\xbc\xae\x69\xa3\xe6\ -\x85\x77\xf8\x5b\xe9\xf0\xbb\x9f\xfa\xd2\x83\x39\xdc\xa3\x5b\xf3\ -\x19\x2e\xa8\x89\xb5\x30\xf7\x43\xa0\x00\x00\x00\x00\x49\x45\x4e\ -\x44\xae\x42\x60\x82\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x0f\xfd\ +\x8f\xf8\x2e\x00\x00\x00\x22\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1\x42\x48\x2a\x0c\x19\ +\x18\x18\x91\x05\x10\x2a\xd1\x00\x00\xca\xb5\x07\xd2\x76\xbb\xb2\ +\xc5\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82\ " qt_resource_name = b"\ @@ -696,119 +703,124 @@ qt_resource_name = b"\ \x00\x69\ \x00\x6d\x00\x61\x00\x67\x00\x65\x00\x73\ \x00\x0f\ -\x02\x9f\x05\x87\ -\x00\x72\ -\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x12\ -\x05\x8f\x9d\x07\ +\x06\x53\x25\xa7\ \x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ -\x00\x67\ -\x00\x1b\ -\x03\x5a\x32\x27\ -\x00\x63\ -\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ -\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x18\ \x03\x8e\xde\x67\ \x00\x72\ \x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\ \x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x11\ -\x0b\xda\x30\xa7\ -\x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\ -\x00\x12\ -\x03\x8d\x04\x47\ -\x00\x72\ -\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ -\x00\x67\ -\x00\x15\ -\x0f\xf3\xc0\x07\ -\x00\x75\ -\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\ -\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x0f\ -\x01\x73\x8b\x07\ -\x00\x75\ -\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x0e\ -\x04\xa2\xfc\xa7\ -\x00\x64\ -\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x12\ -\x01\x2e\x03\x27\ -\x00\x63\ -\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\ -\x00\x67\ -\x00\x14\ -\x04\x5e\x2d\xa7\ -\x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x5f\x00\x6f\x00\x6e\x00\x2e\ -\x00\x70\x00\x6e\x00\x67\ -\x00\x17\ -\x0c\xab\x51\x07\ -\x00\x64\ -\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ -\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x11\ -\x01\x1f\xc3\x87\ -\x00\x64\ -\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\ -\x00\x17\ -\x0c\x65\xce\x07\ -\x00\x6c\ -\x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ -\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x0c\ -\x06\xe6\xe6\x67\ -\x00\x75\ -\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x15\ \x03\x27\x72\x67\ \x00\x63\ \x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\ \x00\x2e\x00\x70\x00\x6e\x00\x67\ \x00\x11\ +\x0b\xda\x30\xa7\ +\x00\x62\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\ +\x00\x17\ +\x0c\xab\x51\x07\ +\x00\x64\ +\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ +\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x11\ \x00\xb8\x8c\x07\ \x00\x6c\ \x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ \ +\x00\x12\ +\x01\x2e\x03\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ +\x00\x0f\ +\x02\x9f\x05\x87\ +\x00\x72\ +\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x15\ +\x0f\xf3\xc0\x07\ +\x00\x75\ +\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\ +\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x0f\ +\x0c\xe2\x68\x67\ +\x00\x74\ +\x00\x72\x00\x61\x00\x6e\x00\x73\x00\x70\x00\x61\x00\x72\x00\x65\x00\x6e\x00\x74\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x0e\ +\x04\xa2\xfc\xa7\ +\x00\x64\ +\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x1b\ +\x03\x5a\x32\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ +\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x17\ +\x0c\x65\xce\x07\ +\x00\x6c\ +\x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\x00\x73\x00\x61\x00\x62\x00\x6c\ +\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x11\ +\x01\x1f\xc3\x87\ +\x00\x64\ +\x00\x6f\x00\x77\x00\x6e\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\ +\x00\x12\ +\x05\x8f\x9d\x07\ +\x00\x62\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ +\x00\x12\ +\x03\x8d\x04\x47\ +\x00\x72\ +\x00\x69\x00\x67\x00\x68\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ \x00\x0e\ \x0e\xde\xfa\xc7\ \x00\x6c\ \x00\x65\x00\x66\x00\x74\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ -\x00\x0f\ -\x06\x53\x25\xa7\ +\x00\x14\ +\x04\x5e\x2d\xa7\ \x00\x62\ -\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x6f\x00\x70\x00\x65\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x72\x00\x61\x00\x6e\x00\x63\x00\x68\x00\x5f\x00\x63\x00\x6c\x00\x6f\x00\x73\x00\x65\x00\x64\x00\x5f\x00\x6f\x00\x6e\x00\x2e\ +\x00\x70\x00\x6e\x00\x67\ +\x00\x0c\ +\x06\xe6\xe6\x67\ +\x00\x75\ +\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\x00\x67\ +\x00\x0f\ +\x01\x73\x8b\x07\ +\x00\x75\ +\x00\x70\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\ " qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x13\x00\x00\x00\x03\ -\x00\x00\x02\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x1f\x3c\ -\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x1c\x9d\ -\x00\x00\x01\xb0\x00\x00\x00\x00\x00\x01\x00\x00\x13\x68\ -\x00\x00\x01\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x12\x1d\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ +\x00\x00\x01\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x10\xb3\ +\x00\x00\x02\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x15\x93\ +\x00\x00\x01\x36\x00\x00\x00\x00\x00\x01\x00\x00\x11\x5d\ +\x00\x00\x03\x54\x00\x00\x00\x00\x00\x01\x00\x00\x27\x41\ +\x00\x00\x01\x60\x00\x00\x00\x00\x00\x01\x00\x00\x12\x07\ +\x00\x00\x00\x82\x00\x00\x00\x00\x00\x01\x00\x00\x07\xae\ +\x00\x00\x01\xfa\x00\x00\x00\x00\x00\x01\x00\x00\x14\x40\ +\x00\x00\x02\xbc\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x70\ +\x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x07\x0a\ +\x00\x00\x03\x08\x00\x00\x00\x00\x00\x01\x00\x00\x1e\xbe\ +\x00\x00\x01\xd8\x00\x00\x00\x00\x00\x01\x00\x00\x13\x97\ +\x00\x00\x02\x92\x00\x00\x00\x00\x00\x01\x00\x00\x16\x3c\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x02\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x92\ -\x00\x00\x00\x76\x00\x00\x00\x00\x00\x01\x00\x00\x07\xd8\ -\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00\x10\xd6\ -\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x08\x81\ -\x00\x00\x01\xda\x00\x00\x00\x00\x00\x01\x00\x00\x14\x12\ -\x00\x00\x01\x8e\x00\x00\x00\x00\x00\x01\x00\x00\x12\xbf\ -\x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa4\ -\x00\x00\x03\x30\x00\x00\x00\x00\x00\x01\x00\x00\x20\x90\ -\x00\x00\x02\x98\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xf0\ -\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x00\x09\x25\ -\x00\x00\x02\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x46\ -\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x00\x1b\xf3\ -\x00\x00\x03\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xe6\ -\x00\x00\x01\x3a\x00\x00\x00\x00\x00\x01\x00\x00\x11\x7a\ +\x00\x00\x03\x36\x00\x00\x00\x00\x00\x01\x00\x00\x26\x9f\ +\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x08\x58\ +\x00\x00\x02\x36\x00\x00\x00\x00\x00\x01\x00\x00\x14\xe9\ +\x00\x00\x00\xda\x00\x00\x00\x00\x00\x01\x00\x00\x10\x09\ +\x00\x00\x01\xb4\x00\x00\x00\x00\x00\x01\x00\x00\x13\x4e\ +\x00\x00\x02\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x14\ +\x00\x00\x01\x84\x00\x00\x00\x00\x00\x01\x00\x00\x12\xab\ " qt_resource_struct_v2 = b"\ @@ -816,49 +828,50 @@ qt_resource_struct_v2 = b"\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x16\x00\x02\x00\x00\x00\x13\x00\x00\x00\x03\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x02\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x1f\x3c\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x1c\x9d\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x01\xb0\x00\x00\x00\x00\x00\x01\x00\x00\x13\x68\ -\x00\x00\x01\x79\xb4\x72\xcc\x9c\ -\x00\x00\x01\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x12\x1d\ -\x00\x00\x01\x76\x41\x9d\xa2\x39\ +\x00\x00\x01\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x10\xb3\ +\x00\x00\x01\x79\xec\x37\x3f\xbc\ +\x00\x00\x02\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x15\x93\ +\x00\x00\x01\x79\xec\x37\x3f\xba\ +\x00\x00\x01\x36\x00\x00\x00\x00\x00\x01\x00\x00\x11\x5d\ +\x00\x00\x01\x79\xec\x37\x3f\xb6\ +\x00\x00\x03\x54\x00\x00\x00\x00\x00\x01\x00\x00\x27\x41\ +\x00\x00\x01\x79\xec\x37\x3f\xc0\ +\x00\x00\x01\x60\x00\x00\x00\x00\x00\x01\x00\x00\x12\x07\ +\x00\x00\x01\x79\xec\x37\x3f\xbc\ +\x00\x00\x00\x82\x00\x00\x00\x00\x00\x01\x00\x00\x07\xae\ +\x00\x00\x01\x79\xec\x37\x3f\xb7\ +\x00\x00\x01\xfa\x00\x00\x00\x00\x00\x01\x00\x00\x14\x40\ +\x00\x00\x01\x79\xec\x37\x3f\xb7\ +\x00\x00\x02\xbc\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x70\ +\x00\x00\x01\x79\xec\x37\x3f\xbe\ +\x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x07\x0a\ +\x00\x00\x01\x79\xec\x37\x3f\xbd\ +\x00\x00\x03\x08\x00\x00\x00\x00\x00\x01\x00\x00\x1e\xbe\ +\x00\x00\x01\x79\xec\x37\x3f\xb4\ +\x00\x00\x01\xd8\x00\x00\x00\x00\x00\x01\x00\x00\x13\x97\ +\x00\x00\x01\x79\xec\x37\x3f\xb8\ +\x00\x00\x02\x92\x00\x00\x00\x00\x00\x01\x00\x00\x16\x3c\ +\x00\x00\x01\x79\xec\x37\x3f\xb5\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x76\x41\x9d\xa2\x37\ -\x00\x00\x02\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x92\ -\x00\x00\x01\x79\xb4\x72\xcc\x9c\ -\x00\x00\x00\x76\x00\x00\x00\x00\x00\x01\x00\x00\x07\xd8\ -\x00\x00\x01\x79\xb4\x72\xcc\x9c\ -\x00\x00\x01\x10\x00\x00\x00\x00\x00\x01\x00\x00\x10\xd6\ -\x00\x00\x01\x76\x41\x9d\xa2\x37\ -\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x08\x81\ -\x00\x00\x01\x76\x41\x9d\xa2\x37\ -\x00\x00\x01\xda\x00\x00\x00\x00\x00\x01\x00\x00\x14\x12\ -\x00\x00\x01\x79\xc2\x05\x2b\x60\ -\x00\x00\x01\x8e\x00\x00\x00\x00\x00\x01\x00\x00\x12\xbf\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa4\ -\x00\x00\x01\x79\xc1\xfc\x16\x91\ -\x00\x00\x03\x30\x00\x00\x00\x00\x00\x01\x00\x00\x20\x90\ -\x00\x00\x01\x79\xc1\xf9\x4b\x78\ -\x00\x00\x02\x98\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xf0\ -\x00\x00\x01\x76\x41\x9d\xa2\x39\ -\x00\x00\x00\xe8\x00\x00\x00\x00\x00\x01\x00\x00\x09\x25\ -\x00\x00\x01\x79\xc2\x05\x91\x2a\ -\x00\x00\x02\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1d\x46\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x00\x1b\xf3\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x03\x0e\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xe6\ -\x00\x00\x01\x76\x41\x9d\xa2\x35\ -\x00\x00\x01\x3a\x00\x00\x00\x00\x00\x01\x00\x00\x11\x7a\ -\x00\x00\x01\x76\x41\x9d\xa2\x39\ +\x00\x00\x01\x79\xec\x37\x3f\xb5\ +\x00\x00\x03\x36\x00\x00\x00\x00\x00\x01\x00\x00\x26\x9f\ +\x00\x00\x01\x79\xec\x37\x3f\xbe\ +\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x08\x58\ +\x00\x00\x01\x79\xec\x37\x3f\xb3\ +\x00\x00\x02\x36\x00\x00\x00\x00\x00\x01\x00\x00\x14\xe9\ +\x00\x00\x01\x79\xec\x37\x3f\xbb\ +\x00\x00\x00\xda\x00\x00\x00\x00\x00\x01\x00\x00\x10\x09\ +\x00\x00\x01\x79\xec\x37\x3f\xb9\ +\x00\x00\x01\xb4\x00\x00\x00\x00\x00\x01\x00\x00\x13\x4e\ +\x00\x00\x01\x7c\xa7\x41\xfc\x00\ +\x00\x00\x02\xe6\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x14\ +\x00\x00\x01\x79\xec\x37\x3f\xbb\ +\x00\x00\x01\x84\x00\x00\x00\x00\x00\x01\x00\x00\x12\xab\ +\x00\x00\x01\x79\xec\x37\x3f\xbf\ " - qt_version = [int(v) for v in QtCore.qVersion().split('.')] if qt_version < [5, 8, 0]: rcc_version = 1 diff --git a/openpype/style/pyside2_resources.py b/openpype/style/pyside2_resources.py index ee68a74b8e..dff01eec49 100644 --- a/openpype/style/pyside2_resources.py +++ b/openpype/style/pyside2_resources.py @@ -1,75 +1,15 @@ -# Resource object code (Python 3) -# Created by: object code -# Created by: The Resource Compiler for Qt version 5.15.2 +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created: Fri Oct 22 11:42:52 2021 +# by: The Resource Compiler for PySide2 (Qt v5.6.1) +# # WARNING! All changes made in this file will be lost! from PySide2 import QtCore - qt_resource_data = b"\ -\x00\x00\x00\x9f\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x14\x1f\xf9\ -#\xd9\x0b\x00\x00\x00#IDAT\x08\xd7c`\xc0\ -\x0d\xe6|\x80\xb1\x18\x91\x05R\x04\xe0B\x08\x15)\x02\ -\x0c\x0c\x8c\xc8\x02\x08\x95h\x00\x00\xac\xac\x07\x90Ne\ -4\xac\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ -;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ -\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ -\x00\x00\x00\xa5\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ -\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ -200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ -\xaeB`\x82\ -\x00\x00\x00\xa5\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ -\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ -200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ -\xaeB`\x82\ -\x00\x00\x00\xa0\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f\x0d\xfc\ -R+\x9c\x00\x00\x00$IDAT\x08\xd7c`@\ -\x05s>\xc0XL\xc8\x5c&dY&d\xc5pN\ -\x8a\x00\x9c\x93\x22\x80a\x1a\x0a\x00\x00)\x95\x08\xaf\x88\ -\xac\xba4\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x00\xa6\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -83,31 +23,33 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ d``b``4D\xe2 s\x19\x90\x8d@\x02\ \x00d@\x09u\x86\xb3\xad\x9c\x00\x00\x00\x00IEN\ D\xaeB`\x82\ -\x00\x00\x00\x9e\ +\x00\x00\x00\xa5\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ +\x00\x00\x00\xa6\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ \x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ \x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ -\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ -\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ -\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ -\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\x9e\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ -\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ -\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ -\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ -\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x07\x06\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x070\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ \x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x001\xac\xdcc\ @@ -165,10 +107,10 @@ toshop:ICCProfil\ e=\x22sRGB IEC61966\ -2.1\x22\x0a xmp:Mod\ ifyDate=\x222021-05\ --31T12:30:11+02:\ +-31T12:33:14+02:\ 00\x22\x0a xmp:Metad\ ataDate=\x222021-05\ --31T12:30:11+02:\ +-31T12:33:14+02:\ 00\x22>\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a\x0a\x85\x9d\x9f\x08\x00\x00\x01\x83\ +nd=\x22r\x22?>H\x8b[^\x00\x00\x01\x83\ iCCPsRGB IEC6196\ 6-2.1\x00\x00(\x91u\x91\xcf+DQ\x14\ \xc7?fh\xfc\x18\x8dba1e\x12\x16B\x83\x12\ @@ -213,28 +155,54 @@ S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\xc5\x9e\ \xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e9\xef\ Y\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00mIDAT\x18\x95u\xcf\xc1\x09\xc2P\ -\x10\x84\xe1\xd7\x85\x07\x9b\xd0C@\xd2\x82x\x14{0\ -W!\x8d\x84`?bKzH\xcc\x97\x83\xfb0\x04\ -\xdf\x9c\x86\x7fg\x99\xdd\x84\x0d\xaaT\x10jl\x13\x1e\ -\xbe\xba\xfe\x0951{\xe6\x8d\x0f&\x1c\x17\xa1S\xb0\ -\x11\x87\x0c/\x01\x07\xec\xb0\x0f?\xe1\xbc\xaei\xa3\xe6\ -\x85w\xf8[\xe9\xf0\xbb\x9f\xfa\xd2\x839\xdc\xa3[\xf3\ -\x19.\xa8\x89\xb50\xf7C\xa0\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ -\x00\x00\x00\xa6\ +\x00\x00\x00\x97IDAT\x18\x95m\xcf\xb1j\x02A\ +\x14\x85\xe1o\xb7\xb6\xd0'H=Vi\x03\xb1\xb4H\ +;l\xa5\xf19\xf6Y\x02VB\xbaa\x0a\x0b;\x1b\ +\x1bkA\x18\x02)m\xe3\xbe\x82\xcd\x06\x16\xd9\xdb\xdd\ +\x9f\xff\x5c\xee\xa9b*\x13Ls\x13nF&\xa6\xf2\ +\x82\xaeF\x8b\xdf\x98\xca\xfb\x88\xb4\xc0\x0f\xda\x1a[t\ +\xd8\xc7T\xc2@\x9ac\x8f?|U=|\xc5\x09w\ +\xbc\xa1\xc2\x193,r\x13.\xd5\xe0\xc2\x12\x07\x5cQ\ +#\xe0#7\xe1\xa8O\x0e\x7f\xda`\xd7\xaf\x9f\xb9\x09\ +\xdfc\x05\xff\xe5uLe\xf5\xcc\x1f\x0d3,\x83\xb6\ +\x06D\x83\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\xa0\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ \x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ \x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ \x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f \xb9\ -\x8dw\xe9\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x06\xe6|```B0\xa1\x1c\x08\x93\x81\x81\x09\xc1\ -d``b`H\x11@\xe2 s\x19\x90\x8d@\x02\ -\x00#\xed\x08\xafd\x9f\x0f\x15\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ +\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ +\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ +\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ +\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\x9e\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ +\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ +\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ +\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ +\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\xa5\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ \x00\x00\x07\xdd\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -363,6 +331,221 @@ zpp\xf0\xe3\x0e.\xa4\xd2\xae\xf0\x8a\xf7\x9a\xe3V\ q[s\x5c@H\xa5\xdda\x81\x0d\x9ek\x8e\xff\xfd\ \xcf?\xcc1\xe9\x01\x1c\x00sR-q\xe4J\x1bi\ \x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\x9e\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15\x0f\xfd\ +\x8f\xf8.\x00\x00\x00\x22IDAT\x08\xd7c`\xc0\ +\x0d\xfe\x9f\x87\xb1\x18\x91\x05\x18\x0d\xe1BH*\x0c\x19\ +\x18\x18\x91\x05\x10*\xd1\x00\x00\xca\xb5\x07\xd2v\xbb\xb2\ +\xc5\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa5\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ +\x00\x00\x00\xa0\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f\x0d\xfc\ +R+\x9c\x00\x00\x00$IDAT\x08\xd7c`@\ +\x05s>\xc0XL\xc8\x5c&dY&d\xc5pN\ +\x8a\x00\x9c\x93\x22\x80a\x1a\x0a\x00\x00)\x95\x08\xaf\x88\ +\xac\xba4\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x00\x9f\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x14\x1f\xf9\ +#\xd9\x0b\x00\x00\x00#IDAT\x08\xd7c`\xc0\ +\x0d\xe6|\x80\xb1\x18\x91\x05R\x04\xe0B\x08\x15)\x02\ +\x0c\x0c\x8c\xc8\x02\x08\x95h\x00\x00\xac\xac\x07\x90Ne\ +4\xac\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x07\x06\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x001\xac\xdcc\ +\x00\x00\x04\xb0iTXtXML:com.\ +adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a\x0a\x85\x9d\x9f\x08\x00\x00\x01\x83\ +iCCPsRGB IEC6196\ +6-2.1\x00\x00(\x91u\x91\xcf+DQ\x14\ +\xc7?fh\xfc\x18\x8dba1e\x12\x16B\x83\x12\ +\x1b\x8b\x99\x18\x0a\x8b\x99Q~mf\x9ey3j\xde\ +x\xbd7\xd2d\xabl\xa7(\xb1\xf1k\xc1_\xc0V\ +Y+E\xa4d\xa7\xac\x89\x0dz\xce\x9bQ#\x99s\ +;\xf7|\xee\xf7\xdes\xba\xf7\x5cpD\xd3\x8afV\ +\xfaA\xcbd\x8dp(\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ +\x85\x1a:\xf1\xc6\x14S\x9f\x8c\x8cF)k\xef\xb7T\ +\xd8\xf1\xba\xdb\xaeU\xfe\xdc\xbfV\xb7\x980\x15\xa8\xa8\ +\x16\x1eVt#+<&<\xb1\x9a\xd5m\xde\x12n\ +RR\xb1E\xe1\x13\xe1.C.(|c\xeb\xf1\x22\ +?\xdb\x9c,\xf2\xa7\xcdF4\x1c\x04G\x83\xb0/\xf9\ +\x8b\xe3\xbfXI\x19\x9a\xb0\xbc\x9c6-\xbd\xa2\xfc\xdc\ +\xc7~\x89;\x91\x99\x8eHl\x15\xf7b\x12&D\x00\ +\x1f\xe3\x8c\x10d\x80^\x86d\x1e\xa0\x9b>zdE\ +\x99|\x7f!\x7f\x8ae\xc9Ud\xd6\xc9a\xb0D\x92\ +\x14Y\xbaD]\x91\xea\x09\x89\xaa\xe8\x09\x19irv\ +\xff\xff\xf6\xd5T\xfb\xfb\x8a\xd5\xdd\x01\xa8z\xb4\xac\xd7\ +vpm\xc2W\xde\xb2>\x0e,\xeb\xeb\x10\x9c\x0fp\ +\x9e)\xe5/\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9eu\ +8\xbd(i\xf1m8\xdb\x80\xe6{=f\xc4\x0a\x92\ +S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\xc5\x9e\ +\xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e9\xef\ +Y\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00mIDAT\x18\x95u\xcf\xc1\x09\xc2P\ +\x10\x84\xe1\xd7\x85\x07\x9b\xd0C@\xd2\x82x\x14{0\ +W!\x8d\x84`?bKzH\xcc\x97\x83\xfb0\x04\ +\xdf\x9c\x86\x7fg\x99\xdd\x84\x0d\xaaT\x10jl\x13\x1e\ +\xbe\xba\xfe\x0951{\xe6\x8d\x0f&\x1c\x17\xa1S\xb0\ +\x11\x87\x0c/\x01\x07\xec\xb0\x0f?\xe1\xbc\xaei\xa3\xe6\ +\x85w\xf8[\xe9\xf0\xbb\x9f\xfa\xd2\x839\xdc\xa3[\xf3\ +\x19.\xa8\x89\xb50\xf7C\xa0\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1f \xb9\ +\x8dw\xe9\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x06\xe6|```B0\xa1\x1c\x08\x93\x81\x81\x09\xc1\ +d``b`H\x11@\xe2 s\x19\x90\x8d@\x02\ +\x00#\xed\x08\xafd\x9f\x0f\x15\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +\x00\x00\x00\xa0\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ +\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ +\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ +\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ +\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x07\xad\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -501,186 +684,6 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ d``b``4D\xe2 s\x19\x90\x8d@\x02\ \x00d@\x09u\x86\xb3\xad\x9c\x00\x00\x00\x00IEN\ D\xaeB`\x82\ -\x00\x00\x00\xa5\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ -\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ -200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ -\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ -\xaeB`\x82\ -\x00\x00\x00\xa0\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ -\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ -\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ -\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ -\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x070\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x0a\x00\x00\x00\x07\x08\x06\x00\x00\x001\xac\xdcc\ -\x00\x00\x04\xb0iTXtXML:com.\ -adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a \x0a\x0aH\x8b[^\x00\x00\x01\x83\ -iCCPsRGB IEC6196\ -6-2.1\x00\x00(\x91u\x91\xcf+DQ\x14\ -\xc7?fh\xfc\x18\x8dba1e\x12\x16B\x83\x12\ -\x1b\x8b\x99\x18\x0a\x8b\x99Q~mf\x9ey3j\xde\ -x\xbd7\xd2d\xabl\xa7(\xb1\xf1k\xc1_\xc0V\ -Y+E\xa4d\xa7\xac\x89\x0dz\xce\x9bQ#\x99s\ -;\xf7|\xee\xf7\xdes\xba\xf7\x5cpD\xd3\x8afV\ -\xfaA\xcbd\x8dp(\xe0\x9b\x99\x9d\xf3\xb9\x9e\xa8\xa2\ -\x85\x1a:\xf1\xc6\x14S\x9f\x8c\x8cF)k\xef\xb7T\ -\xd8\xf1\xba\xdb\xaeU\xfe\xdc\xbfV\xb7\x980\x15\xa8\xa8\ -\x16\x1eVt#+<&<\xb1\x9a\xd5m\xde\x12n\ -RR\xb1E\xe1\x13\xe1.C.(|c\xeb\xf1\x22\ -?\xdb\x9c,\xf2\xa7\xcdF4\x1c\x04G\x83\xb0/\xf9\ -\x8b\xe3\xbfXI\x19\x9a\xb0\xbc\x9c6-\xbd\xa2\xfc\xdc\ -\xc7~\x89;\x91\x99\x8eHl\x15\xf7b\x12&D\x00\ -\x1f\xe3\x8c\x10d\x80^\x86d\x1e\xa0\x9b>zdE\ -\x99|\x7f!\x7f\x8ae\xc9Ud\xd6\xc9a\xb0D\x92\ -\x14Y\xbaD]\x91\xea\x09\x89\xaa\xe8\x09\x19irv\ -\xff\xff\xf6\xd5T\xfb\xfb\x8a\xd5\xdd\x01\xa8z\xb4\xac\xd7\ -vpm\xc2W\xde\xb2>\x0e,\xeb\xeb\x10\x9c\x0fp\ -\x9e)\xe5/\xef\xc3\xe0\x9b\xe8\xf9\x92\xd6\xb6\x07\x9eu\ -8\xbd(i\xf1m8\xdb\x80\xe6{=f\xc4\x0a\x92\ -S\xdc\xa1\xaa\xf0r\x0c\xf5\xb3\xd0x\x05\xb5\xf3\xc5\x9e\ -\xfd\xecst\x07\xd15\xf9\xaaK\xd8\xd9\x85\x0e9\xef\ -Y\xf8\x06\x8e\xfdg\xf8\xfd\x8a\x18\x97\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x97IDAT\x18\x95m\xcf\xb1j\x02A\ -\x14\x85\xe1o\xb7\xb6\xd0'H=Vi\x03\xb1\xb4H\ -;l\xa5\xf19\xf6Y\x02VB\xbaa\x0a\x0b;\x1b\ -\x1bkA\x18\x02)m\xe3\xbe\x82\xcd\x06\x16\xd9\xdb\xdd\ -\x9f\xff\x5c\xee\xa9b*\x13Ls\x13nF&\xa6\xf2\ -\x82\xaeF\x8b\xdf\x98\xca\xfb\x88\xb4\xc0\x0f\xda\x1a[t\ -\xd8\xc7T\xc2@\x9ac\x8f?|U=|\xc5\x09w\ -\xbc\xa1\xc2\x193,r\x13.\xd5\xe0\xc2\x12\x07\x5cQ\ -#\xe0#7\xe1\xa8O\x0e\x7f\xda`\xd7\xaf\x9f\xb9\x09\ -\xdfc\x05\xff\xe5uLe\xf5\xcc\x1f\x0d3,\x83\xb6\ -\x06D\x83\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ -;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ -\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ -\x00\x00\x00\xa0\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x06\x00\x00\x00\x09\x08\x04\x00\x00\x00\xbb\x93\x95\x16\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x14\x1c\x1f$\ -\xc6\x09\x17\x00\x00\x00$IDAT\x08\xd7c`@\ -\x05\xff\xcf\xc3XL\xc8\x5c&dY&d\xc5p\x0e\ -\xa3!\x9c\xc3h\x88a\x1a\x0a\x00\x00m\x84\x09u7\ -\x9e\xd9#\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x00\xa6\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ -\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ -\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ -HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ -\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ -;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ -\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ -\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ -\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ " qt_resource_name = b"\ @@ -692,62 +695,6 @@ qt_resource_name = b"\ \x07\x03}\xc3\ \x00i\ \x00m\x00a\x00g\x00e\x00s\ -\x00\x15\ -\x0f\xf3\xc0\x07\ -\x00u\ -\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\ -\x00.\x00p\x00n\x00g\ -\x00\x12\ -\x01.\x03'\ -\x00c\ -\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\ -\x00g\ -\x00\x0e\ -\x04\xa2\xfc\xa7\ -\x00d\ -\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ -\x00\x1b\ -\x03Z2'\ -\x00c\ -\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ -\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ -\x00\x18\ -\x03\x8e\xdeg\ -\x00r\ -\x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\ -\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ -\x00\x11\ -\x00\xb8\x8c\x07\ -\x00l\ -\x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ -\ -\x00\x0f\ -\x01s\x8b\x07\ -\x00u\ -\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ -\x00\x0c\ -\x06\xe6\xe6g\ -\x00u\ -\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ -\x00\x0f\ -\x06S%\xa7\ -\x00b\ -\x00r\x00a\x00n\x00c\x00h\x00_\x00o\x00p\x00e\x00n\x00.\x00p\x00n\x00g\ -\x00\x17\ -\x0ce\xce\x07\ -\x00l\ -\x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ -\x00e\x00d\x00.\x00p\x00n\x00g\ -\x00\x14\ -\x04^-\xa7\ -\x00b\ -\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00_\x00o\x00n\x00.\ -\x00p\x00n\x00g\ -\x00\x11\ -\x0b\xda0\xa7\ -\x00b\ -\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00.\x00p\x00n\x00g\ -\ \x00\x0e\ \x0e\xde\xfa\xc7\ \x00l\ @@ -757,87 +704,121 @@ qt_resource_name = b"\ \x00d\ \x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ \ -\x00\x0f\ -\x02\x9f\x05\x87\ -\x00r\ -\x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x12\ +\x01.\x03'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\ +\x00g\ \x00\x12\ \x05\x8f\x9d\x07\ \x00b\ \x00r\x00a\x00n\x00c\x00h\x00_\x00o\x00p\x00e\x00n\x00_\x00o\x00n\x00.\x00p\x00n\ \x00g\ -\x00\x17\ -\x0c\xabQ\x07\ -\x00d\ -\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ -\x00e\x00d\x00.\x00p\x00n\x00g\ \x00\x12\ \x03\x8d\x04G\ \x00r\ \x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\ \x00g\ +\x00\x0f\ +\x01s\x8b\x07\ +\x00u\ +\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ +\x00\x1b\ +\x03Z2'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ +\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x14\ +\x04^-\xa7\ +\x00b\ +\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00_\x00o\x00n\x00.\ +\x00p\x00n\x00g\ +\x00\x0c\ +\x06\xe6\xe6g\ +\x00u\ +\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x17\ +\x0c\xabQ\x07\ +\x00d\ +\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ +\x00e\x00d\x00.\x00p\x00n\x00g\ \x00\x15\ \x03'rg\ \x00c\ \x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\ \x00.\x00p\x00n\x00g\ +\x00\x0e\ +\x04\xa2\xfc\xa7\ +\x00d\ +\x00o\x00w\x00n\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x18\ +\x03\x8e\xdeg\ +\x00r\ +\x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\ +\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x15\ +\x0f\xf3\xc0\x07\ +\x00u\ +\x00p\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\x00e\x00d\ +\x00.\x00p\x00n\x00g\ +\x00\x0f\ +\x06S%\xa7\ +\x00b\ +\x00r\x00a\x00n\x00c\x00h\x00_\x00o\x00p\x00e\x00n\x00.\x00p\x00n\x00g\ +\x00\x17\ +\x0ce\xce\x07\ +\x00l\ +\x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\x00s\x00a\x00b\x00l\ +\x00e\x00d\x00.\x00p\x00n\x00g\ +\x00\x0f\ +\x02\x9f\x05\x87\ +\x00r\ +\x00i\x00g\x00h\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\x00g\ +\x00\x11\ +\x0b\xda0\xa7\ +\x00b\ +\x00r\x00a\x00n\x00c\x00h\x00_\x00c\x00l\x00o\x00s\x00e\x00d\x00.\x00p\x00n\x00g\ +\ +\x00\x11\ +\x00\xb8\x8c\x07\ +\x00l\ +\x00e\x00f\x00t\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00o\x00n\x00.\x00p\x00n\x00g\ +\ " qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ -\x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ -\x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x16\x00\x02\x00\x00\x00\x13\x00\x00\x00\x03\ -\x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x01\x16\x00\x00\x00\x00\x00\x01\x00\x00\x03C\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02P\x00\x00\x00\x00\x00\x01\x00\x00\x1d!\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x00X\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa3\ -\x00\x00\x01y\xb4r\xcc\x9c\ -\x00\x00\x01>\x00\x00\x00\x00\x00\x01\x00\x00\x03\xed\ -\x00\x00\x01vA\x9d\xa29\ -\x00\x00\x02x\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xca\ -\x00\x00\x01vA\x9d\xa27\ -\x00\x00\x03$\x00\x00\x00\x00\x00\x01\x00\x00&\xf0\ -\x00\x00\x01y\xb4r\xcc\x9c\ -\x00\x00\x00\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x01\xf6\ -\x00\x00\x01y\xb4r\xcc\x9c\ -\x00\x00\x02\xfa\x00\x00\x00\x00\x00\x01\x00\x00&L\ -\x00\x00\x01vA\x9d\xa27\ -\x00\x00\x00\xe0\x00\x00\x00\x00\x00\x01\x00\x00\x02\x9f\ -\x00\x00\x01vA\x9d\xa27\ -\x00\x00\x01\xd8\x00\x00\x00\x00\x00\x01\x00\x00\x0c\xe5\ -\x00\x00\x01y\xc2\x05+`\ -\x00\x00\x00\x82\x00\x00\x00\x00\x00\x01\x00\x00\x01M\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x1en\ -\x00\x00\x01y\xc1\xfc\x16\x91\ -\x00\x00\x01\x80\x00\x00\x00\x00\x00\x01\x00\x00\x051\ -\x00\x00\x01y\xc1\xf9Kx\ -\x00\x00\x01b\x00\x00\x00\x00\x00\x01\x00\x00\x04\x8f\ -\x00\x00\x01vA\x9d\xa29\ -\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x14\xc6\ -\x00\x00\x01y\xc2\x05\x91*\ -\x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x0c;\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02\xc6\x00\x00\x00\x00\x00\x01\x00\x00%\xa2\ -\x00\x00\x01vA\x9d\xa25\ -\x00\x00\x02.\x00\x00\x00\x00\x00\x01\x00\x00\x1cw\ -\x00\x00\x01vA\x9d\xa25\ +\x00\x00\x03,\x00\x00\x00\x00\x00\x01\x00\x00&\xf0\ +\x00\x00\x00J\x00\x00\x00\x00\x00\x01\x00\x00\x00\xaa\ +\x00\x00\x00r\x00\x00\x00\x00\x00\x01\x00\x00\x01S\ +\x00\x00\x00\xf0\x00\x00\x00\x00\x00\x01\x00\x00\x09\xd5\ +\x00\x00\x02\xe0\x00\x00\x00\x00\x00\x01\x00\x00\x1e\x9b\ +\x00\x00\x01\xd0\x00\x00\x00\x00\x00\x01\x00\x00\x14M\ +\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x0aw\ +\x00\x00\x00\xc6\x00\x00\x00\x00\x00\x01\x00\x00\x091\ +\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00\x15\xa0\ +\x00\x00\x01P\x00\x00\x00\x00\x00\x01\x00\x00\x0b \ +\x00\x00\x02\x00\x00\x00\x00\x00\x00\x01\x00\x00\x14\xf7\ +\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x01\xfd\ +\x00\x00\x02\x88\x00\x00\x00\x00\x00\x01\x00\x00\x16\xe7\ +\x00\x00\x01~\x00\x00\x00\x00\x00\x01\x00\x00\x13\x01\ +\x00\x00\x03\x04\x00\x00\x00\x00\x00\x01\x00\x00\x1f?\ +\x00\x00\x02\xac\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xf1\ +\x00\x00\x01\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa3\ \x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01vA\x9d\xa29\ +\x00\x00\x02X\x00\x00\x00\x00\x00\x01\x00\x00\x16D\ " def qInitResources(): QtCore.qRegisterResourceData( - 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + 0x01, qt_resource_struct, qt_resource_name, qt_resource_data ) def qCleanupResources(): QtCore.qUnregisterResourceData( - 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + 0x01, qt_resource_struct, qt_resource_name, qt_resource_data ) diff --git a/openpype/style/resources.qrc b/openpype/style/resources.qrc index a583d9458e..e2e69711f4 100644 --- a/openpype/style/resources.qrc +++ b/openpype/style/resources.qrc @@ -19,5 +19,6 @@ images/up_arrow.png images/up_arrow_disabled.png images/up_arrow_on.png + images/transparent.png diff --git a/openpype/style/style.css b/openpype/style/style.css index 830ed85f9b..0dd7b27fe4 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -57,10 +57,15 @@ QAbstractSpinBox:focus, QLineEdit:focus, QPlainTextEdit:focus, QTextEdit:focus{ border-color: {color:border-focus}; } +/* Checkbox */ +QCheckBox { + background: transparent; +} + /* Buttons */ QPushButton { text-align:center center; - border: 1px solid transparent; + border: 0px solid transparent; border-radius: 0.2em; padding: 3px 5px 3px 5px; background: {color:bg-buttons}; @@ -86,15 +91,15 @@ QPushButton::menu-indicator { } QToolButton { - border: none; - background: transparent; + border: 0px solid transparent; + background: {color:bg-buttons}; border-radius: 0.2em; padding: 2px; } QToolButton:hover { - background: #333840; - border-color: {color:border-hover}; + background: {color:bg-button-hover}; + color: {color:font-hover}; } QToolButton:disabled { @@ -104,14 +109,15 @@ QToolButton:disabled { QToolButton[popupMode="1"], QToolButton[popupMode="MenuButtonPopup"] { /* make way for the popup button */ padding-right: 20px; - border: 1px solid {color:bg-buttons}; } QToolButton::menu-button { width: 16px; - /* Set border only of left side. */ + background: transparent; border: 1px solid transparent; - border-left: 1px solid {color:bg-buttons}; + border-left: 1px solid qlineargradient(x1:0, y1:0, x2:0, y2:1, stop: 0 transparent, stop:0.2 {color:font}, stop:0.8 {color:font}, stop: 1 transparent); + padding: 3px 0px 3px 0px; + border-radius: 0; } QToolButton::menu-arrow { @@ -200,12 +206,28 @@ QComboBox::down-arrow, QComboBox::down-arrow:on, QComboBox::down-arrow:hover, QC } /* Splitter */ -QSplitter { - border: none; +QSplitter::handle { + border: 3px solid transparent; } -QSplitter::handle { - border: 1px dotted {color:bg-menu-separator}; +QSplitter::handle:horizontal { + /* must be single like because of Nuke*/ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0,stop:0.3 rgba(0, 0, 0, 0),stop:0.5 {color:bg-splitter},stop:0.7 rgba(0, 0, 0, 0)); +} + +QSplitter::handle:vertical { + /* must be single like because of Nuke*/ + background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0.3 rgba(0, 0, 0, 0),stop:0.5 {color:bg-splitter},stop:0.7 rgba(0, 0, 0, 0)); +} + +QSplitter::handle:horizontal:hover { + /* must be single like because of Nuke*/ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0,stop:0.3 rgba(0, 0, 0, 0),stop:0.5 {color:bg-splitter-hover},stop:0.7 rgba(0, 0, 0, 0)); +} + +QSplitter::handle:vertical:hover { + /* must be single like because of Nuke*/ + background: qlineargradient(x1:0, y1:0, x2:0, y2:1,stop:0.3 rgba(0, 0, 0, 0),stop:0.5 {color:bg-splitter-hover},stop:0.7 rgba(0, 0, 0, 0)); } /* SLider */ @@ -232,18 +254,15 @@ QSlider::groove:focus { border-color: {color:border-focus}; } QSlider::handle { - background: qlineargradient( - x1: 0, y1: 0.5, - x2: 1, y2: 0.5, - stop: 0 {palette:blue-base}, - stop: 1 {palette:green-base} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1: 0, y1: 0.5, x2: 1, y2: 0.5,stop: 0 {palette:blue-base},stop: 1 {palette:green-base}); border: 1px solid #5c5c5c; width: 10px; height: 10px; border-radius: 5px; } + QSlider::handle:horizontal { margin: -2px 0; } @@ -252,12 +271,8 @@ QSlider::handle:vertical { } QSlider::handle:disabled { - background: qlineargradient( - x1:0, y1:0, - x2:1, y2:1, - stop:0 {color:bg-buttons}, - stop:1 {color:bg-buttons-disabled} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1:0, y1:0,x2:1, y2:1,stop:0 {color:bg-buttons},stop:1 {color:bg-buttons-disabled}); } /* Tab widget*/ @@ -275,19 +290,15 @@ QTabBar::tab { border-left: 3px solid transparent; border-top: 1px solid {color:border}; border-right: 1px solid {color:border}; - background: qlineargradient( - x1: 0, y1: 1, x2: 0, y2: 0, - stop: 0.5 {color:bg}, stop: 1.0 {color:bg-inputs} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0,stop: 0.5 {color:bg}, stop: 1.0 {color:bg-inputs}); } QTabBar::tab:selected { background: {color:grey-lighter}; border-left: 3px solid {color:border-focus}; - background: qlineargradient( - x1: 0, y1: 1, x2: 0, y2: 0, - stop: 0.5 {color:bg}, stop: 1.0 {color:border} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0,stop: 0.5 {color:bg}, stop: 1.0 {color:border}); } QTabBar::tab:!selected { @@ -335,6 +346,15 @@ QHeaderView::section:first { QHeaderView::section:last { border-right: none; } + +QHeaderView::down-arrow { + image: url(:/openpype/images/down_arrow.png); +} + +QHeaderView::up-arrow { + image: url(:/openpype/images/up_arrow.png); +} + /* Views QListView QTreeView QTableView */ QAbstractItemView { border: 0px solid {color:border}; @@ -393,23 +413,42 @@ QAbstractItemView::branch:open:has-children:has-siblings { QAbstractItemView::branch:open:has-children:!has-siblings:hover, QAbstractItemView::branch:open:has-children:has-siblings:hover { border-image: none; - image: url(:/openpype/images//branch_open_on.png); + image: url(:/openpype/images/branch_open_on.png); background: transparent; } QAbstractItemView::branch:has-children:!has-siblings:closed, QAbstractItemView::branch:closed:has-children:has-siblings { border-image: none; - image: url(:/openpype/images//branch_closed.png); + image: url(:/openpype/images/branch_closed.png); background: transparent; } QAbstractItemView::branch:has-children:!has-siblings:closed:hover, QAbstractItemView::branch:closed:has-children:has-siblings:hover { border-image: none; - image: url(:/openpype/images//branch_closed_on.png); + image: url(:/openpype/images/branch_closed_on.png); background: transparent; } +QAbstractItemView::branch:has-siblings:!adjoins-item { + border-image: none; + image: url(:/openpype/images/transparent.png); + background: transparent; +} + +QAbstractItemView::branch:has-siblings:adjoins-item { + border-image: none; + image: url(:/openpype/images/transparent.png); + background: transparent; +} + +QAbstractItemView::branch:!has-children:!has-siblings:adjoins-item { + border-image: none; + image: url(:/openpype/images/transparent.png); + background: transparent; +} + + /* Progress bar */ QProgressBar { border: 1px solid {color:border}; @@ -425,12 +464,8 @@ QProgressBar:vertical { } QProgressBar::chunk { - background: qlineargradient( - x1: 0, y1: 0.5, - x2: 1, y2: 0.5, - stop: 0 {palette:blue-base}, - stop: 1 {palette:green-base} - ); + /* must be single like because of Nuke*/ + background: qlineargradient(x1: 0, y1: 0.5,x2: 1, y2: 0.5,stop: 0 {palette:blue-base},stop: 1 {palette:green-base}); } /* Scroll bars */ @@ -542,7 +577,9 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:bg-menu-separator}; } -#IconBtn {} +#IconButton { + padding: 4px 4px 4px 4px; +} /* Password dialog*/ #PasswordBtn { @@ -566,6 +603,13 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { padding-right: 3px; } +#InfoText { + padding-left: 30px; + padding-top: 20px; + background: transparent; + border: 1px solid {color:border}; +} + #TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { background: transparent; border-radius: 0.3em; @@ -629,3 +673,182 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #PythonInterpreterOutput, #PythonCodeEditor { font-family: "Roboto Mono"; } + +#SubsetView::item, #RepresentationView:item { + padding: 5px 1px; + border: 0px; +} + +#OptionalActionBody, #OptionalActionOption { + background: transparent; +} + +#OptionalActionBody[state="hover"], #OptionalActionOption[state="hover"] { + background: {color:bg-view-hover}; +} + +/* New Create/Publish UI */ +#PublishLogConsole { + font-family: "Roboto Mono"; +} + +#VariantInput[state="new"], #VariantInput[state="new"]:focus, #VariantInput[state="new"]:hover { + border-color: {color:publisher:success}; +} +#VariantInput[state="invalid"], #VariantInput[state="invalid"]:focus, #VariantInput[state="invalid"]:hover { + border-color: {color:publisher:error}; +} + +#VariantInput[state="empty"], #VariantInput[state="empty"]:focus, #VariantInput[state="empty"]:hover { + border-color: {color:bg-inputs}; +} + +#VariantInput[state="exists"], #VariantInput[state="exists"]:focus, #VariantInput[state="exists"]:hover { + border-color: #4E76BB; +} + +#MultipleItemView { + background: transparent; + border: none; +} + +#MultipleItemView:item { + background: {color:bg-view-selection}; + border-radius: 0.4em; +} + +#InstanceListView::item { + border-radius: 0.3em; + margin: 1px; +} +#InstanceListGroupWidget { + border: none; + background: transparent; +} + +#CardViewWidget { + background: {color:bg-buttons}; + border-radius: 0.2em; +} +#CardViewWidget:hover { + background: {color:bg-button-hover}; +} +#CardViewWidget[state="selected"] { + background: {color:bg-view-selection}; +} + +#ListViewSubsetName[state="invalid"] { + color: {color:publisher:error}; +} + +#PublishFrame { + background: rgba(0, 0, 0, 127); +} +#PublishFrame[state="1"] { + background: rgb(22, 25, 29); +} +#PublishFrame[state="2"] { + background: {color:bg}; +} + +#PublishInfoFrame { + background: {color:bg}; + border: 2px solid black; + border-radius: 0.3em; +} + +#PublishInfoFrame[state="-1"] { + background: rgb(194, 226, 236); +} + +#PublishInfoFrame[state="0"] { + background: {color:publisher:error}; +} + +#PublishInfoFrame[state="1"] { + background: {color:publisher:success}; +} + +#PublishInfoFrame[state="2"] { + background: {color:publisher:warning}; +} + +#PublishInfoFrame QLabel { + color: black; + font-style: bold; +} + +#PublishInfoMainLabel { + font-size: 12pt; +} + +#PublishContextLabel { + font-size: 13pt; +} + +#ValidationActionButton { + border-radius: 0.2em; + padding: 4px 6px 4px 6px; + background: {color:bg-buttons}; +} + +#ValidationActionButton:hover { + background: {color:bg-button-hover}; + color: {color:font-hover}; +} + +#ValidationActionButton:disabled { + background: {color:bg-buttons-disabled}; +} + +#ValidationErrorTitleFrame { + background: {color:bg-inputs}; + border-left: 4px solid transparent; +} + +#ValidationErrorTitleFrame:hover { + border-left-color: {color:border}; +} + +#ValidationErrorTitleFrame[selected="1"] { + background: {color:bg}; + border-left-color: {palette:blue-light}; +} + +#ValidationErrorInstanceList { + border-radius: 0; +} + +#ValidationErrorInstanceList::item { + border-bottom: 1px solid {color:border}; + border-left: 1px solid {color:border}; +} + +#TasksCombobox[state="invalid"], #AssetNameInput[state="invalid"] { + border-color: {color:publisher:error}; +} + +#PublishProgressBar[state="0"]::chunk { + background: {color:bg-buttons}; +} + +#PublishDetailViews { + background: transparent; +} +#PublishDetailViews::item { + margin: 1px 0px 1px 0px; +} +#PublishCommentInput { + padding: 0.2em; +} +#FamilyIconLabel { + font-size: 14pt; +} +#ArrowBtn, #ArrowBtn:disabled, #ArrowBtn:hover { + background: transparent; +} + +#NiceCheckbox { + /* Default size hint of NiceCheckbox is defined by font size. */ + font-size: 7pt; +} diff --git a/openpype/tools/experimental_tools/__init__.py b/openpype/tools/experimental_tools/__init__.py new file mode 100644 index 0000000000..d6315e4655 --- /dev/null +++ b/openpype/tools/experimental_tools/__init__.py @@ -0,0 +1,14 @@ +from .tools_def import ( + ExperimentalTools, + LOCAL_EXPERIMENTAL_KEY +) + +from .dialog import ExperimentalToolsDialog + + +__all__ = ( + "ExperimentalTools", + "LOCAL_EXPERIMENTAL_KEY", + + "ExperimentalToolsDialog" +) diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py new file mode 100644 index 0000000000..ad65caa8e3 --- /dev/null +++ b/openpype/tools/experimental_tools/dialog.py @@ -0,0 +1,215 @@ +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import ( + load_stylesheet, + app_icon_path +) + +from .tools_def import ExperimentalTools + + +class ToolButton(QtWidgets.QPushButton): + triggered = QtCore.Signal(str) + + def __init__(self, identifier, *args, **kwargs): + super(ToolButton, self).__init__(*args, **kwargs) + self._identifier = identifier + + self.clicked.connect(self._on_click) + + def _on_click(self): + self.triggered.emit(self._identifier) + + +class ExperimentalToolsDialog(QtWidgets.QDialog): + refresh_interval = 3000 + + def __init__(self, parent=None): + super(ExperimentalToolsDialog, self).__init__(parent) + self.setWindowTitle("OpenPype Experimental tools") + icon = QtGui.QIcon(app_icon_path()) + self.setWindowIcon(icon) + self.setStyleSheet(load_stylesheet()) + + # Widgets for cases there are not available experimental tools + empty_widget = QtWidgets.QWidget(self) + + empty_label = QtWidgets.QLabel( + "There are no experimental tools available...", empty_widget + ) + + empty_btns_layout = QtWidgets.QHBoxLayout() + ok_btn = QtWidgets.QPushButton("OK", empty_widget) + + empty_btns_layout.setContentsMargins(0, 0, 0, 0) + empty_btns_layout.addStretch(1) + empty_btns_layout.addWidget(ok_btn, 0) + + empty_layout = QtWidgets.QVBoxLayout(empty_widget) + empty_layout.setContentsMargins(0, 0, 0, 0) + empty_layout.addWidget(empty_label) + empty_layout.addStretch(1) + empty_layout.addLayout(empty_btns_layout) + + # Content of Experimental tools + + # Layout where buttons are added + content_layout = QtWidgets.QVBoxLayout() + content_layout.setContentsMargins(0, 0, 0, 0) + + # Separator line + separator_widget = QtWidgets.QWidget(self) + separator_widget.setObjectName("Separator") + separator_widget.setMinimumHeight(2) + separator_widget.setMaximumHeight(2) + + # Label describing how to turn off tools + tool_btns_widget = QtWidgets.QWidget(self) + tool_btns_label = QtWidgets.QLabel( + ( + "You can enable these features in" + "
OpenPype tray -> Settings -> Experimental tools" + ), + tool_btns_widget + ) + tool_btns_label.setAlignment(QtCore.Qt.AlignCenter) + + tool_btns_layout = QtWidgets.QVBoxLayout(tool_btns_widget) + tool_btns_layout.setContentsMargins(0, 0, 0, 0) + tool_btns_layout.addLayout(content_layout) + tool_btns_layout.addStretch(1) + tool_btns_layout.addWidget(separator_widget, 0) + tool_btns_layout.addWidget(tool_btns_label, 0) + + experimental_tools = ExperimentalTools( + parent=parent, filter_hosts=True + ) + + # Main layout + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(empty_widget, 1) + layout.addWidget(tool_btns_widget, 1) + + refresh_timer = QtCore.QTimer() + refresh_timer.setInterval(self.refresh_interval) + refresh_timer.timeout.connect(self._on_refresh_timeout) + + ok_btn.clicked.connect(self._on_ok_click) + + self._empty_widget = empty_widget + self._tool_btns_widget = tool_btns_widget + self._content_layout = content_layout + + self._experimental_tools = experimental_tools + self._buttons_by_tool_identifier = {} + + self._refresh_timer = refresh_timer + + # Is dialog first shown + self._first_show = True + # Trigger refresh when window get's activity + self._refresh_on_active = True + # Is window active + self._window_is_active = False + + def refresh(self): + self._experimental_tools.refresh_availability() + + buttons_to_remove = set(self._buttons_by_tool_identifier.keys()) + for idx, tool in enumerate(self._experimental_tools.tools): + identifier = tool.identifier + if identifier in buttons_to_remove: + buttons_to_remove.remove(identifier) + is_new = False + button = self._buttons_by_tool_identifier[identifier] + else: + is_new = True + button = ToolButton(identifier, self._tool_btns_widget) + button.triggered.connect(self._on_btn_trigger) + self._buttons_by_tool_identifier[identifier] = button + self._content_layout.insertWidget(idx, button) + + if button.text() != tool.label: + button.setText(tool.label) + + if tool.enabled: + button.setToolTip(tool.tooltip) + + elif is_new or button.isEnabled(): + button.setToolTip(( + "You can enable this tool in local settings." + "\n\nOpenPype Tray > Settings > Experimental Tools" + )) + + if tool.enabled != button.isEnabled(): + button.setEnabled(tool.enabled) + + for identifier in buttons_to_remove: + button = self._buttons_by_tool_identifier.pop(identifier) + button.setVisible(False) + idx = self._content_layout.indexOf(button) + self._content_layout.takeAt(idx) + button.deleteLater() + + self._set_visibility() + + def _is_content_visible(self): + return len(self._buttons_by_tool_identifier) > 0 + + def _set_visibility(self): + content_visible = self._is_content_visible() + self._tool_btns_widget.setVisible(content_visible) + self._empty_widget.setVisible(not content_visible) + + def _on_ok_click(self): + self.close() + + def _on_btn_trigger(self, identifier): + tool = self._experimental_tools.tools_by_identifier.get(identifier) + if tool is not None: + tool.execute() + + def showEvent(self, event): + super(ExperimentalToolsDialog, self).showEvent(event) + + if self._refresh_on_active: + # Start/Restart timer + self._refresh_timer.start() + # Refresh + self.refresh() + + elif not self._refresh_timer.isActive(): + self._refresh_timer.start() + + if self._first_show: + self._first_show = False + # Set stylesheet + self.setStyleSheet(load_stylesheet()) + # Resize dialog if there is not content + if not self._is_content_visible(): + size = self.size() + size.setWidth(size.width() + size.width() / 3) + self.resize(size) + + def changeEvent(self, event): + if event.type() == QtCore.QEvent.ActivationChange: + self._window_is_active = self.isActiveWindow() + if self._window_is_active and self._refresh_on_active: + self._refresh_timer.start() + self.refresh() + + super(ExperimentalToolsDialog, self).changeEvent(event) + + def _on_refresh_timeout(self): + # Stop timer if window is not visible + if not self.isVisible(): + self._refresh_on_active = True + self._refresh_timer.stop() + + # Skip refreshing if window is not active + elif not self._window_is_active: + self._refresh_on_active = True + + # Window is active and visible so we're refreshing buttons + else: + self.refresh() diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py new file mode 100644 index 0000000000..991eb5e4a3 --- /dev/null +++ b/openpype/tools/experimental_tools/tools_def.py @@ -0,0 +1,161 @@ +import os +from openpype.settings import get_local_settings + +# Constant key under which local settings are stored +LOCAL_EXPERIMENTAL_KEY = "experimental_tools" + + +class ExperimentalTool: + """Definition of experimental tool. + + Definition is used in local settings and in experimental tools dialog. + + Args: + identifier (str): String identifier of tool (unique). + label (str): Label shown in UI. + callback (function): Callback for UI button. + tooltip (str): Tooltip showed on button. + hosts_filter (list): List of host names for which is tool available. + Some tools may not be available in all hosts. + """ + def __init__( + self, identifier, label, callback, tooltip, hosts_filter=None + ): + self.identifier = identifier + self.label = label + self.callback = callback + self.tooltip = tooltip + self.hosts_filter = hosts_filter + self._enabled = True + + def is_available_for_host(self, host_name): + if self.hosts_filter: + return host_name in self.hosts_filter + return True + + @property + def enabled(self): + """Is tool enabled and button is clickable.""" + return self._enabled + + def set_enabled(self, enabled=True): + """Change if tool is enabled.""" + self._enabled = enabled + + def execute(self): + """Trigger registerd callback.""" + self.callback() + + +class ExperimentalTools: + """Wrapper around experimental tools. + + To add/remove experimental tool just add/remove tool to + `experimental_tools` variable in __init__ function. + + Args: + parent (QtWidgets.QWidget): Parent widget for tools. + host_name (str): Name of host in which context we're now. Environment + value 'AVALON_APP' is used when not passed. + filter_hosts (bool): Should filter tools. By default is set to 'True' + when 'host_name' is passed. Is always set to 'False' if 'host_name' + is not defined. + """ + def __init__(self, parent=None, host_name=None, filter_hosts=None): + # Definition of experimental tools + experimental_tools = [ + ExperimentalTool( + "publisher", + "New publisher", + self._show_publisher, + "Combined creation and publishing into one tool." + ) + ] + + # --- Example tool (callback will just print on click) --- + # def example_callback(*args): + # print("Triggered tool") + # + # experimental_tools = [ + # ExperimentalTool( + # "example", + # "Example experimental tool", + # example_callback, + # "Example tool tooltip." + # ) + # ] + + # Try to get host name from env variable `AVALON_APP` + if not host_name: + host_name = os.environ.get("AVALON_APP") + + # Decide if filtering by host name should happen + if filter_hosts is None: + filter_hosts = host_name is not None + + if filter_hosts and not host_name: + filter_hosts = False + + # Filter tools by host name + if filter_hosts: + experimental_tools = [ + tool + for tool in experimental_tools + if tool.is_available_for_host(host_name) + ] + + # Store tools by identifier + tools_by_identifier = {} + for tool in experimental_tools: + if tool.identifier in tools_by_identifier: + raise KeyError(( + "Duplicated experimental tool identifier \"{}\"" + ).format(tool.identifier)) + tools_by_identifier[tool.identifier] = tool + + self._tools_by_identifier = tools_by_identifier + self._tools = experimental_tools + self._parent_widget = parent + + self._publisher_tool = None + + @property + def tools(self): + """Tools in list. + + Returns: + list: Tools filtered by host name if filtering was enabled + on initialization. + """ + return self._tools + + @property + def tools_by_identifier(self): + """Tools by their identifier. + + Returns: + dict: Tools by identifier filtered by host name if filtering + was enabled on initialization. + """ + return self._tools_by_identifier + + def refresh_availability(self): + """Reload local settings and check if any tool changed ability.""" + local_settings = get_local_settings() + experimental_settings = ( + local_settings.get(LOCAL_EXPERIMENTAL_KEY) + ) or {} + + for identifier, eperimental_tool in self.tools_by_identifier.items(): + enabled = experimental_settings.get(identifier, False) + eperimental_tool.set_enabled(enabled) + + def _show_publisher(self): + if self._publisher_tool is None: + from openpype.tools import publisher + + self._publisher_tool = publisher.PublisherWindow( + parent=self._parent_widget + ) + + self._publisher_tool.show() diff --git a/openpype/tools/launcher/flickcharm.py b/openpype/tools/flickcharm.py similarity index 100% rename from openpype/tools/launcher/flickcharm.py rename to openpype/tools/flickcharm.py diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 35c7d98be1..5e01488ae6 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -6,8 +6,8 @@ from avalon.vendor import qtawesome from .delegates import ActionDelegate from . import lib -from .models import TaskModel, ActionModel, ProjectModel -from .flickcharm import FlickCharm +from .models import TaskModel, ActionModel +from openpype.tools.flickcharm import FlickCharm from .constants import ( ACTION_ROLE, GROUP_ROLE, diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index 9b839fb2bc..454445824e 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -8,8 +8,7 @@ from avalon.api import AvalonMongoDB from openpype import style from openpype.api import resources -from avalon.tools import lib as tools_lib -from avalon.tools.widgets import AssetWidget +from openpype.tools.utils.widgets import AssetWidget from avalon.vendor import qtawesome from .models import ProjectModel from .lib import get_action_label, ProjectHandler @@ -21,7 +20,7 @@ from .widgets import ( SlidePageWidget ) -from .flickcharm import FlickCharm +from openpype.tools.flickcharm import FlickCharm class ProjectIconView(QtWidgets.QListView): diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py index 8080c547c9..d7c6c162e6 100644 --- a/openpype/tools/libraryloader/app.py +++ b/openpype/tools/libraryloader/app.py @@ -2,8 +2,8 @@ import sys from Qt import QtWidgets, QtCore, QtGui -from avalon import style from avalon.api import AvalonMongoDB +from openpype import style from openpype.tools.utils import lib as tools_lib from openpype.tools.loader.widgets import ( ThumbnailWidget, @@ -28,152 +28,182 @@ class LibraryLoaderWindow(QtWidgets.QDialog): tool_title = "Library Loader 0.5" tool_name = "library_loader" + message_timeout = 5000 + def __init__( self, parent=None, icon=None, show_projects=False, show_libraries=True ): super(LibraryLoaderWindow, self).__init__(parent) + # Window modifications + self.setWindowTitle(self.tool_title) + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + icon = QtGui.QIcon(style.app_icon_path()) + self.setWindowIcon(icon) + + self._first_show = True self._initial_refresh = False self._ignore_project_change = False - # Enable minimize and maximize for app - self.setWindowTitle(self.tool_title) - self.setWindowFlags(QtCore.Qt.Window) - self.setFocusPolicy(QtCore.Qt.StrongFocus) - if icon is not None: - self.setWindowIcon(icon) - # self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + dbcon = AvalonMongoDB() + dbcon.install() + dbcon.Session["AVALON_PROJECT"] = None - body = QtWidgets.QWidget() - footer = QtWidgets.QWidget() - footer.setFixedHeight(20) - - container = QtWidgets.QWidget() - - self.dbcon = AvalonMongoDB() - self.dbcon.install() - self.dbcon.Session["AVALON_PROJECT"] = None + self.dbcon = dbcon self.show_projects = show_projects self.show_libraries = show_libraries # Groups config - self.groups_config = tools_lib.GroupsConfig(self.dbcon) - self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon) + self.groups_config = tools_lib.GroupsConfig(dbcon) + self.family_config_cache = tools_lib.FamilyConfigCache(dbcon) - assets = AssetWidget( - self.dbcon, multiselection=True, parent=self + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # --- Left part --- + left_side_splitter = QtWidgets.QSplitter(main_splitter) + left_side_splitter.setOrientation(QtCore.Qt.Vertical) + + # Project combobox + projects_combobox = QtWidgets.QComboBox(left_side_splitter) + combobox_delegate = QtWidgets.QStyledItemDelegate(self) + projects_combobox.setItemDelegate(combobox_delegate) + + # Assets widget + assets_widget = AssetWidget( + dbcon, multiselection=True, parent=left_side_splitter ) - families = FamilyListView( - self.dbcon, self.family_config_cache, parent=self + + # Families widget + families_filter_view = FamilyListView( + dbcon, self.family_config_cache, left_side_splitter ) - subsets = LibrarySubsetWidget( - self.dbcon, + left_side_splitter.addWidget(projects_combobox) + left_side_splitter.addWidget(assets_widget) + left_side_splitter.addWidget(families_filter_view) + left_side_splitter.setStretchFactor(1, 65) + left_side_splitter.setStretchFactor(2, 35) + + # --- Middle part --- + # Subsets widget + subsets_widget = LibrarySubsetWidget( + dbcon, self.groups_config, self.family_config_cache, tool_name=self.tool_name, parent=self ) - version = VersionWidget(self.dbcon) - thumbnail = ThumbnailWidget(self.dbcon) - - # Project - self.combo_projects = QtWidgets.QComboBox() - - # Create splitter to show / hide family filters - asset_filter_splitter = QtWidgets.QSplitter() - asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) - asset_filter_splitter.addWidget(self.combo_projects) - asset_filter_splitter.addWidget(assets) - asset_filter_splitter.addWidget(families) - asset_filter_splitter.setStretchFactor(1, 65) - asset_filter_splitter.setStretchFactor(2, 35) - - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - - representations = RepresentationWidget(self.dbcon) - thumb_ver_splitter = QtWidgets.QSplitter() + # --- Right part --- + thumb_ver_splitter = QtWidgets.QSplitter(main_splitter) thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) - thumb_ver_splitter.addWidget(thumbnail) - thumb_ver_splitter.addWidget(version) - if sync_server.enabled: - thumb_ver_splitter.addWidget(representations) + + thumbnail_widget = ThumbnailWidget(dbcon, parent=thumb_ver_splitter) + version_info_widget = VersionWidget(dbcon, parent=thumb_ver_splitter) + + thumb_ver_splitter.addWidget(thumbnail_widget) + thumb_ver_splitter.addWidget(version_info_widget) + thumb_ver_splitter.setStretchFactor(0, 30) thumb_ver_splitter.setStretchFactor(1, 35) - container_layout = QtWidgets.QHBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - split = QtWidgets.QSplitter() - split.addWidget(asset_filter_splitter) - split.addWidget(subsets) - split.addWidget(thumb_ver_splitter) - split.setSizes([180, 950, 200]) - container_layout.addWidget(split) + manager = ModulesManager() + sync_server = manager.modules_by_name.get("sync_server") + sync_server_enabled = False + if sync_server is not None: + sync_server_enabled = sync_server.enabled - body_layout = QtWidgets.QHBoxLayout(body) - body_layout.addWidget(container) - body_layout.setContentsMargins(0, 0, 0, 0) + repres_widget = None + if sync_server_enabled: + repres_widget = RepresentationWidget( + dbcon, self.tool_name, parent=thumb_ver_splitter + ) + thumb_ver_splitter.addWidget(repres_widget) - message = QtWidgets.QLabel() - message.hide() + main_splitter.addWidget(left_side_splitter) + main_splitter.addWidget(subsets_widget) + main_splitter.addWidget(thumb_ver_splitter) + if sync_server_enabled: + main_splitter.setSizes([250, 1000, 550]) + else: + main_splitter.setSizes([250, 850, 200]) - footer_layout = QtWidgets.QVBoxLayout(footer) - footer_layout.addWidget(message) + # --- Footer --- + footer_widget = QtWidgets.QWidget(self) + footer_widget.setFixedHeight(20) + + message_label = QtWidgets.QLabel(footer_widget) + + footer_layout = QtWidgets.QVBoxLayout(footer_widget) footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addWidget(message_label) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - layout.addWidget(footer) + layout.addWidget(main_splitter) + layout.addWidget(footer_widget) self.data = { - "widgets": { - "families": families, - "assets": assets, - "subsets": subsets, - "version": version, - "thumbnail": thumbnail, - "representations": representations - }, - "label": { - "message": message, - }, "state": { "assetIds": None } } - families.active_changed.connect(subsets.set_family_filters) - assets.selection_changed.connect(self.on_assetschanged) - assets.refresh_triggered.connect(self.on_assetschanged) - assets.view.clicked.connect(self.on_assetview_click) - subsets.active_changed.connect(self.on_subsetschanged) - subsets.version_changed.connect(self.on_versionschanged) - subsets.refreshed.connect(self._on_subset_refresh) - self.combo_projects.currentTextChanged.connect(self.on_project_change) + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_timeout) + message_timer.setSingleShot(True) + + message_timer.timeout.connect(self._on_message_timeout) + + families_filter_view.active_changed.connect( + self._on_family_filter_change + ) + assets_widget.selection_changed.connect(self.on_assetschanged) + assets_widget.refresh_triggered.connect(self.on_assetschanged) + assets_widget.view.clicked.connect(self.on_assetview_click) + subsets_widget.active_changed.connect(self.on_subsetschanged) + subsets_widget.version_changed.connect(self.on_versionschanged) + subsets_widget.refreshed.connect(self._on_subset_refresh) + projects_combobox.currentTextChanged.connect(self.on_project_change) self.sync_server = sync_server + self._sync_server_enabled = sync_server_enabled - # Set default thumbnail on start - thumbnail.set_thumbnail(None) + self._combobox_delegate = combobox_delegate + self._projects_combobox = projects_combobox + self._assets_widget = assets_widget + self._families_filter_view = families_filter_view - # Defaults - if sync_server.enabled: - split.setSizes([250, 1000, 550]) - self.resize(1800, 900) - else: - split.setSizes([250, 850, 200]) - self.resize(1300, 700) + self._subsets_widget = subsets_widget + + self._version_info_widget = version_info_widget + self._thumbnail_widget = thumbnail_widget + self._repres_widget = repres_widget + + self._message_label = message_label + self._message_timer = message_timer def showEvent(self, event): super(LibraryLoaderWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + if self._sync_server_enabled: + self.resize(1800, 900) + else: + self.resize(1300, 700) + if not self._initial_refresh: + self._initial_refresh = True self.refresh() def on_assetview_click(self, *args): - subsets_widget = self.data["widgets"]["subsets"] - selection_model = subsets_widget.view.selectionModel() + selection_model = self._subsets_widget.view.selectionModel() if selection_model.selectedIndexes(): selection_model.clearSelection() @@ -184,7 +214,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self._ignore_project_change = True # Cleanup - self.combo_projects.clear() + self._projects_combobox.clear() # Fill combobox with projects select_project_item = QtGui.QStandardItem("< Select project >") @@ -199,18 +229,18 @@ class LibraryLoaderWindow(QtWidgets.QDialog): item.setData(project_name, QtCore.Qt.UserRole + 1) combobox_items.append(item) - root_item = self.combo_projects.model().invisibleRootItem() + root_item = self._projects_combobox.model().invisibleRootItem() root_item.appendRows(combobox_items) index = 0 self._ignore_project_change = False if old_project_name: - index = self.combo_projects.findText( + index = self._projects_combobox.findText( old_project_name, QtCore.Qt.MatchFixedString ) - self.combo_projects.setCurrentIndex(index) + self._projects_combobox.setCurrentIndex(index) def get_filtered_projects(self): projects = list() @@ -228,8 +258,8 @@ class LibraryLoaderWindow(QtWidgets.QDialog): if self._ignore_project_change: return - row = self.combo_projects.currentIndex() - index = self.combo_projects.model().index(row, 0) + row = self._projects_combobox.currentIndex() + index = self._projects_combobox.model().index(row, 0) project_name = index.data(QtCore.Qt.UserRole + 1) self.dbcon.Session["AVALON_PROJECT"] = project_name @@ -242,11 +272,9 @@ class LibraryLoaderWindow(QtWidgets.QDialog): "Config `%s` has no function `install`" % _config.__name__ ) - subsets = self.data["widgets"]["subsets"] - representations = self.data["widgets"]["representations"] - - subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) - representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"]) + self._subsets_widget.on_project_change(project_name) + if self._repres_widget: + self._repres_widget.on_project_change(project_name) self.family_config_cache.refresh() self.groups_config.refresh() @@ -260,13 +288,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): @property def current_project(self): - if ( - not self.dbcon.active_project() or - self.dbcon.active_project() == "" - ): - return None - - return self.dbcon.active_project() + return self.dbcon.active_project() or None # ------------------------------- # Delay calling blocking methods @@ -289,12 +311,11 @@ class LibraryLoaderWindow(QtWidgets.QDialog): tools_lib.schedule(self._versionschanged, 150, channel="mongo") def _on_subset_refresh(self, has_item): - subsets_widget = self.data["widgets"]["subsets"] - families_view = self.data["widgets"]["families"] - - subsets_widget.set_loading_state(loading=False, empty=not has_item) - families = subsets_widget.get_subsets_families() - families_view.set_enabled_families(families) + self._subsets_widget.set_loading_state( + loading=False, empty=not has_item + ) + families = self._subsets_widget.get_subsets_families() + self._families_filter_view.set_enabled_families(families) def set_context(self, context, refresh=True): self.echo("Setting context: {}".format(context)) @@ -304,6 +325,9 @@ class LibraryLoaderWindow(QtWidgets.QDialog): ) # ------------------------------ + def _on_family_filter_change(self, families): + self._subsets_widget.set_family_filters(families) + def _refresh(self): if not self._initial_refresh: self._initial_refresh = True @@ -319,74 +343,69 @@ class LibraryLoaderWindow(QtWidgets.QDialog): ) assert project_doc, "This is a bug" - assets_widget = self.data["widgets"]["assets"] - families_view = self.data["widgets"]["families"] - families_view.set_enabled_families(set()) - families_view.refresh() + self._families_filter_view.set_enabled_families(set()) + self._families_filter_view.refresh() - assets_widget.model.stop_fetch_thread() - assets_widget.refresh() - assets_widget.setFocus() + self._assets_widget.model.stop_fetch_thread() + self._assets_widget.refresh() + self._assets_widget.setFocus() def clear_assets_underlines(self): last_asset_ids = self.data["state"]["assetIds"] if not last_asset_ids: return - assets_widget = self.data["widgets"]["assets"] - id_role = assets_widget.model.ObjectIdRole + assets_model = self._assets_widget.model + id_role = assets_model.ObjectIdRole - for index in tools_lib.iter_model_rows(assets_widget.model, 0): + for index in tools_lib.iter_model_rows(assets_model, 0): if index.data(id_role) not in last_asset_ids: continue - assets_widget.model.setData( - index, [], assets_widget.model.subsetColorsRole + assets_model.setData( + index, [], assets_model.subsetColorsRole ) def _assetschanged(self): """Selected assets have changed""" - assets_widget = self.data["widgets"]["assets"] - subsets_widget = self.data["widgets"]["subsets"] - subsets_model = subsets_widget.model + subsets_model = self._subsets_widget.model subsets_model.clear() self.clear_assets_underlines() if not self.dbcon.Session.get("AVALON_PROJECT"): - subsets_widget.set_loading_state( + self._subsets_widget.set_loading_state( loading=False, empty=True ) return # filter None docs they are silo - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() if len(asset_docs) == 0: return asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] # Start loading - subsets_widget.set_loading_state( + self._subsets_widget.set_loading_state( loading=bool(asset_ids), empty=True ) subsets_model.set_assets(asset_ids) - subsets_widget.view.setColumnHidden( + self._subsets_widget.view.setColumnHidden( subsets_model.Columns.index("asset"), len(asset_ids) < 2 ) # Clear the version information on asset change - self.data["widgets"]["version"].set_version(None) - self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + self._version_info_widget.set_version(None) + self._thumbnail_widget.set_thumbnail(asset_docs) self.data["state"]["assetIds"] = asset_ids - representations = self.data["widgets"]["representations"] # reset repre list - representations.set_version_ids([]) + self._repres_widget.set_version_ids([]) def _subsetschanged(self): asset_ids = self.data["state"]["assetIds"] @@ -395,8 +414,9 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self._versionschanged() return - subsets = self.data["widgets"]["subsets"] - selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + selected_subsets = self._subsets_widget.selected_subsets( + _merged=True, _other=False + ) asset_models = {} asset_ids = [] @@ -417,26 +437,24 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self.clear_assets_underlines() - assets_widget = self.data["widgets"]["assets"] - indexes = assets_widget.view.selectionModel().selectedRows() + indexes = self._assets_widget.view.selectionModel().selectedRows() + assets_model = self._assets_widget.model for index in indexes: - id = index.data(assets_widget.model.ObjectIdRole) + id = index.data(assets_model.ObjectIdRole) if id not in asset_models: continue - assets_widget.model.setData( - index, asset_models[id], assets_widget.model.subsetColorsRole + assets_model.setData( + index, asset_models[id], assets_model.subsetColorsRole ) # Trigger repaint - assets_widget.view.updateGeometries() + self._assets_widget.view.updateGeometries() # Set version in Version Widget self._versionschanged() def _versionschanged(self): - - subsets = self.data["widgets"]["subsets"] - selection = subsets.view.selectionModel() + selection = self._subsets_widget.view.selectionModel() # Active must be in the selected rows otherwise we # assume it's not actually an "active" current index. @@ -445,7 +463,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): active = selection.currentIndex() rows = selection.selectedRows(column=active.column()) if active and active in rows: - item = active.data(subsets.model.ItemRole) + item = active.data(self._subsets_widget.model.ItemRole) if ( item is not None and not (item.get("isGroup") or item.get("isMerged")) @@ -457,7 +475,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): for index in rows: if not index or not index.isValid(): continue - item = index.data(subsets.model.ItemRole) + item = index.data(self._subsets_widget.model.ItemRole) if ( item is None or item.get("isGroup") @@ -466,20 +484,18 @@ class LibraryLoaderWindow(QtWidgets.QDialog): continue version_docs.append(item["version_document"]) - self.data["widgets"]["version"].set_version(version_doc) + self._version_info_widget.set_version(version_doc) thumbnail_docs = version_docs if not thumbnail_docs: - assets_widget = self.data["widgets"]["assets"] - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() if len(asset_docs) > 0: thumbnail_docs = asset_docs - self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + self._thumbnail_widget.set_thumbnail(thumbnail_docs) - representations = self.data["widgets"]["representations"] version_ids = [doc["_id"] for doc in version_docs or []] - representations.set_version_ids(version_ids) + self._repres_widget.set_version_ids(version_ids) def _set_context(self, context, refresh=True): """Set the selection in the interface using a context. @@ -507,16 +523,15 @@ class LibraryLoaderWindow(QtWidgets.QDialog): # scheduled refresh and the silo tabs are not shown. self._refresh_assets() - asset_widget = self.data["widgets"]["assets"] - asset_widget.select_assets(asset) + self._assets_widget.select_assets(asset) + + def _on_message_timeout(self): + self._message_label.setText("") def echo(self, message): - widget = self.data["label"]["message"] - widget.setText(str(message)) - widget.show() + self._message_label.setText(str(message)) print(message) - - tools_lib.schedule(widget.hide, 5000, channel="message") + self._message_timer.start() def closeEvent(self, event): # Kill on holding SHIFT @@ -573,7 +588,6 @@ def show( window = LibraryLoaderWindow( parent, icon, show_projects, show_libraries ) - window.setStyleSheet(style.load_stylesheet()) window.show() module.window = window diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index c18b6e798a..04da08326f 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -1,10 +1,10 @@ import sys from Qt import QtWidgets, QtCore -from avalon import api, io, style, pipeline +from avalon import api, io, pipeline +from openpype import style from openpype.tools.utils.widgets import AssetWidget - from openpype.tools.utils import lib from .widgets import ( @@ -37,6 +37,7 @@ class LoaderWindow(QtWidgets.QDialog): """Asset loader interface""" tool_name = "loader" + message_timeout = 5000 def __init__(self, parent=None): super(LoaderWindow, self).__init__(parent) @@ -51,86 +52,91 @@ class LoaderWindow(QtWidgets.QDialog): self.family_config_cache = lib.FamilyConfigCache(io) # Enable minimize and maximize for app - self.setWindowFlags(QtCore.Qt.Window) + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) self.setFocusPolicy(QtCore.Qt.StrongFocus) - body = QtWidgets.QWidget() - footer = QtWidgets.QWidget() - footer.setFixedHeight(20) + main_splitter = QtWidgets.QSplitter(self) - container = QtWidgets.QWidget() + # --- Left part --- + left_side_splitter = QtWidgets.QSplitter(main_splitter) + left_side_splitter.setOrientation(QtCore.Qt.Vertical) - assets = AssetWidget(io, multiselection=True, parent=self) - assets.set_current_asset_btn_visibility(True) + # Assets widget + assets_widget = AssetWidget( + io, multiselection=True, parent=left_side_splitter + ) + assets_widget.set_current_asset_btn_visibility(True) - families = FamilyListView(io, self.family_config_cache, self) - subsets = SubsetWidget( + # Families widget + families_filter_view = FamilyListView( + io, self.family_config_cache, left_side_splitter + ) + left_side_splitter.addWidget(assets_widget) + left_side_splitter.addWidget(families_filter_view) + left_side_splitter.setStretchFactor(0, 65) + left_side_splitter.setStretchFactor(1, 35) + + # --- Middle part --- + # Subsets widget + subsets_widget = SubsetWidget( io, self.groups_config, self.family_config_cache, tool_name=self.tool_name, - parent=self + parent=main_splitter ) - version = VersionWidget(io) - thumbnail = ThumbnailWidget(io) - representations = RepresentationWidget(io, self.tool_name) - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - - thumb_ver_splitter = QtWidgets.QSplitter() + # --- Right part --- + thumb_ver_splitter = QtWidgets.QSplitter(main_splitter) thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical) - thumb_ver_splitter.addWidget(thumbnail) - thumb_ver_splitter.addWidget(version) - if sync_server.enabled: - thumb_ver_splitter.addWidget(representations) + + thumbnail_widget = ThumbnailWidget(io, parent=thumb_ver_splitter) + version_info_widget = VersionWidget(io, parent=thumb_ver_splitter) + + thumb_ver_splitter.addWidget(thumbnail_widget) + thumb_ver_splitter.addWidget(version_info_widget) + thumb_ver_splitter.setStretchFactor(0, 30) thumb_ver_splitter.setStretchFactor(1, 35) - # Create splitter to show / hide family filters - asset_filter_splitter = QtWidgets.QSplitter() - asset_filter_splitter.setOrientation(QtCore.Qt.Vertical) - asset_filter_splitter.addWidget(assets) - asset_filter_splitter.addWidget(families) - asset_filter_splitter.setStretchFactor(0, 65) - asset_filter_splitter.setStretchFactor(1, 35) + manager = ModulesManager() + sync_server = manager.modules_by_name.get("sync_server") + sync_server_enabled = False + if sync_server is not None: + sync_server_enabled = sync_server.enabled - container_layout = QtWidgets.QHBoxLayout(container) - container_layout.setContentsMargins(0, 0, 0, 0) - split = QtWidgets.QSplitter() - split.addWidget(asset_filter_splitter) - split.addWidget(subsets) - split.addWidget(thumb_ver_splitter) + repres_widget = None + if sync_server_enabled: + repres_widget = RepresentationWidget( + io, self.tool_name, parent=thumb_ver_splitter + ) + thumb_ver_splitter.addWidget(repres_widget) - container_layout.addWidget(split) + main_splitter.addWidget(left_side_splitter) + main_splitter.addWidget(subsets_widget) + main_splitter.addWidget(thumb_ver_splitter) - body_layout = QtWidgets.QHBoxLayout(body) - body_layout.addWidget(container) - body_layout.setContentsMargins(0, 0, 0, 0) + if sync_server_enabled: + main_splitter.setSizes([250, 1000, 550]) + else: + main_splitter.setSizes([250, 850, 200]) - message = QtWidgets.QLabel() - message.hide() + footer_widget = QtWidgets.QWidget(self) - footer_layout = QtWidgets.QVBoxLayout(footer) - footer_layout.addWidget(message) + message_label = QtWidgets.QLabel(footer_widget) + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addWidget(message_label, 1) layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(body) - layout.addWidget(footer) + layout.addWidget(main_splitter, 1) + layout.addWidget(footer_widget, 0) self.data = { - "widgets": { - "families": families, - "assets": assets, - "subsets": subsets, - "version": version, - "thumbnail": thumbnail, - "representations": representations - }, - "label": { - "message": message, - }, "state": { "assetIds": None } @@ -139,19 +145,44 @@ class LoaderWindow(QtWidgets.QDialog): overlay_frame = OverlayFrame("Loading...", self) overlay_frame.setVisible(False) - families.active_changed.connect(subsets.set_family_filters) - assets.selection_changed.connect(self.on_assetschanged) - assets.refresh_triggered.connect(self.on_assetschanged) - assets.view.clicked.connect(self.on_assetview_click) - subsets.active_changed.connect(self.on_subsetschanged) - subsets.version_changed.connect(self.on_versionschanged) - subsets.refreshed.connect(self._on_subset_refresh) + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_timeout) + message_timer.setSingleShot(True) - subsets.load_started.connect(self._on_load_start) - subsets.load_ended.connect(self._on_load_end) - representations.load_started.connect(self._on_load_start) - representations.load_ended.connect(self._on_load_end) + message_timer.timeout.connect(self._on_message_timeout) + families_filter_view.active_changed.connect( + self._on_family_filter_change + ) + assets_widget.selection_changed.connect(self.on_assetschanged) + assets_widget.refresh_triggered.connect(self.on_assetschanged) + # TODO do not touch view in asset widget + assets_widget.view.clicked.connect(self.on_assetview_click) + subsets_widget.active_changed.connect(self.on_subsetschanged) + subsets_widget.version_changed.connect(self.on_versionschanged) + subsets_widget.refreshed.connect(self._on_subset_refresh) + + subsets_widget.load_started.connect(self._on_load_start) + subsets_widget.load_ended.connect(self._on_load_end) + if repres_widget: + repres_widget.load_started.connect(self._on_load_start) + repres_widget.load_ended.connect(self._on_load_end) + + self._sync_server_enabled = sync_server_enabled + + self._assets_widget = assets_widget + self._families_filter_view = families_filter_view + + self._subsets_widget = subsets_widget + + self._version_info_widget = version_info_widget + self._thumbnail_widget = thumbnail_widget + self._repres_widget = repres_widget + + self._message_label = message_label + self._message_timer = message_timer + + # TODO add overlay using stack widget self._overlay_frame = overlay_frame self.family_config_cache.refresh() @@ -160,13 +191,7 @@ class LoaderWindow(QtWidgets.QDialog): self._refresh() self._assetschanged() - # Defaults - if sync_server.enabled: - split.setSizes([250, 1000, 550]) - self.resize(1800, 900) - else: - split.setSizes([250, 850, 200]) - self.resize(1300, 700) + self._first_show = True def resizeEvent(self, event): super(LoaderWindow, self).resizeEvent(event) @@ -176,13 +201,23 @@ class LoaderWindow(QtWidgets.QDialog): super(LoaderWindow, self).moveEvent(event) self._overlay_frame.move(0, 0) + def showEvent(self, event): + super(LoaderWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + if self._sync_server_enabled: + self.resize(1800, 900) + else: + self.resize(1300, 700) + # ------------------------------- # Delay calling blocking methods # ------------------------------- def on_assetview_click(self, *args): - subsets_widget = self.data["widgets"]["subsets"] - selection_model = subsets_widget.view.selectionModel() + # TODO do not touch inner attributes of subset widget + selection_model = self._subsets_widget.view.selectionModel() if selection_model.selectedIndexes(): selection_model.clearSelection() @@ -216,12 +251,11 @@ class LoaderWindow(QtWidgets.QDialog): self._overlay_frame.setVisible(False) def _on_subset_refresh(self, has_item): - subsets_widget = self.data["widgets"]["subsets"] - families_view = self.data["widgets"]["families"] - - subsets_widget.set_loading_state(loading=False, empty=not has_item) - families = subsets_widget.get_subsets_families() - families_view.set_enabled_families(families) + self._subsets_widget.set_loading_state( + loading=False, empty=not has_item + ) + families = self._subsets_widget.get_subsets_families() + self._families_filter_view.set_enabled_families(families) def _on_load_end(self): # Delay hiding as click events happened during loading should be @@ -229,14 +263,14 @@ class LoaderWindow(QtWidgets.QDialog): QtCore.QTimer.singleShot(100, self._hide_overlay) # ------------------------------ + def _on_family_filter_change(self, families): + self._subsets_widget.set_family_filters(families) def on_context_task_change(self, *args, **kwargs): - assets_widget = self.data["widgets"]["assets"] - families_view = self.data["widgets"]["families"] # Refresh families config - families_view.refresh() + self._families_filter_view.refresh() # Change to context asset on context change - assets_widget.select_assets(io.Session["AVALON_ASSET"]) + self._assets_widget.select_assets(io.Session["AVALON_ASSET"]) def _refresh(self): """Load assets from database""" @@ -245,12 +279,10 @@ class LoaderWindow(QtWidgets.QDialog): project = io.find_one({"type": "project"}, {"type": 1}) assert project, "Project was not found! This is a bug" - assets_widget = self.data["widgets"]["assets"] - assets_widget.refresh() - assets_widget.setFocus() + self._assets_widget.refresh() + self._assets_widget.setFocus() - families_view = self.data["widgets"]["families"] - families_view.refresh() + self._families_filter_view.refresh() def clear_assets_underlines(self): """Clear colors from asset data to remove colored underlines @@ -258,11 +290,12 @@ class LoaderWindow(QtWidgets.QDialog): own selected subsets. These colors must be cleared from asset data on selection change so they match current selection. """ - last_asset_ids = self.data["state"]["assetIds"] + # TODO do not touch inner attributes of asset widget + last_asset_ids = self.data["state"]["assetIds"] or [] if not last_asset_ids: return - assets_widget = self.data["widgets"]["assets"] + assets_widget = self._assets_widget id_role = assets_widget.model.ObjectIdRole for index in lib.iter_model_rows(assets_widget.model, 0): @@ -275,15 +308,15 @@ class LoaderWindow(QtWidgets.QDialog): def _assetschanged(self): """Selected assets have changed""" - assets_widget = self.data["widgets"]["assets"] - subsets_widget = self.data["widgets"]["subsets"] + subsets_widget = self._subsets_widget + # TODO do not touch subset widget inner attributes subsets_model = subsets_widget.model subsets_model.clear() self.clear_assets_underlines() # filter None docs they are silo - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] # Start loading @@ -299,14 +332,14 @@ class LoaderWindow(QtWidgets.QDialog): ) # Clear the version information on asset change - self.data["widgets"]["version"].set_version(None) - self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs) + self._thumbnail_widget.set_thumbnail(asset_docs) + self._version_info_widget.set_version(None) self.data["state"]["assetIds"] = asset_ids - representations = self.data["widgets"]["representations"] # reset repre list - representations.set_version_ids([]) + if self._repres_widget is not None: + self._repres_widget.set_version_ids([]) def _subsetschanged(self): asset_ids = self.data["state"]["assetIds"] @@ -315,8 +348,9 @@ class LoaderWindow(QtWidgets.QDialog): self._versionschanged() return - subsets = self.data["widgets"]["subsets"] - selected_subsets = subsets.selected_subsets(_merged=True, _other=False) + selected_subsets = self._subsets_widget.selected_subsets( + _merged=True, _other=False + ) asset_models = {} asset_ids = [] @@ -337,7 +371,8 @@ class LoaderWindow(QtWidgets.QDialog): self.clear_assets_underlines() - assets_widget = self.data["widgets"]["assets"] + # TODO do not use inner attributes of asset widget + assets_widget = self._assets_widget indexes = assets_widget.view.selectionModel().selectedRows() for index in indexes: @@ -354,7 +389,7 @@ class LoaderWindow(QtWidgets.QDialog): self._versionschanged() def _versionschanged(self): - subsets = self.data["widgets"]["subsets"] + subsets = self._subsets_widget selection = subsets.view.selectionModel() # Active must be in the selected rows otherwise we @@ -386,23 +421,24 @@ class LoaderWindow(QtWidgets.QDialog): else: version_docs.append(item["version_document"]) - self.data["widgets"]["version"].set_version(version_doc) + self._version_info_widget.set_version(version_doc) thumbnail_docs = version_docs - assets_widget = self.data["widgets"]["assets"] - asset_docs = assets_widget.get_selected_assets() + asset_docs = self._assets_widget.get_selected_assets() if not thumbnail_docs: if len(asset_docs) > 0: thumbnail_docs = asset_docs - self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs) + self._thumbnail_widget.set_thumbnail(thumbnail_docs) - representations = self.data["widgets"]["representations"] - version_ids = [doc["_id"] for doc in version_docs or []] - representations.set_version_ids(version_ids) + if self._repres_widget is not None: + version_ids = [doc["_id"] for doc in version_docs or []] + self._repres_widget.set_version_ids(version_ids) - # representations.change_visibility("subset", len(rows) > 1) - # representations.change_visibility("asset", len(asset_docs) > 1) + # self._repres_widget.change_visibility("subset", len(rows) > 1) + # self._repres_widget.change_visibility( + # "asset", len(asset_docs) > 1 + # ) def _set_context(self, context, refresh=True): """Set the selection in the interface using a context. @@ -435,16 +471,15 @@ class LoaderWindow(QtWidgets.QDialog): # scheduled refresh and the silo tabs are not shown. self._refresh() - asset_widget = self.data["widgets"]["assets"] - asset_widget.select_assets(asset) + self._assets_widget.select_assets(asset) + + def _on_message_timeout(self): + self._message_label.setText("") def echo(self, message): - widget = self.data["label"]["message"] - widget.setText(str(message)) - widget.show() + self._message_label.setText(str(message)) print(message) - - lib.schedule(widget.hide, 5000, channel="message") + self._message_timer.start() def closeEvent(self, event): # Kill on holding SHIFT @@ -472,7 +507,7 @@ class LoaderWindow(QtWidgets.QDialog): event.setAccepted(True) # Avoid interfering other widgets def show_grouping_dialog(self): - subsets = self.data["widgets"]["subsets"] + subsets = self._subsets_widget if not subsets.is_groupable(): self.echo("Grouping not enabled.") return @@ -511,7 +546,8 @@ class SubsetGroupingDialog(QtWidgets.QDialog): self.items = items self.groups_config = groups_config - self.subsets = parent.data["widgets"]["subsets"] + # TODO do not touch inner attributes + self.subsets = parent._subsets_widget self.asset_ids = parent.data["state"]["assetIds"] name = QtWidgets.QLineEdit() @@ -630,7 +666,6 @@ def show(debug=False, parent=None, use_context=False): with lib.application(): window = LoaderWindow(parent) - window.setStyleSheet(style.load_stylesheet()) window.show() if use_context: diff --git a/openpype/tools/loader/images/default_thumbnail.png b/openpype/tools/loader/images/default_thumbnail.png index 97bd958e0d..adea862e5b 100644 Binary files a/openpype/tools/loader/images/default_thumbnail.png and b/openpype/tools/loader/images/default_thumbnail.png differ diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index 6b94fc6e44..4c075382ac 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -37,12 +37,13 @@ class OverlayFrame(QtWidgets.QFrame): super(OverlayFrame, self).__init__(parent) label_widget = QtWidgets.QLabel(label, self) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + main_layout = QtWidgets.QVBoxLayout(self) main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) self.label_widget = label_widget - label_widget.setStyleSheet("background: transparent;") self.setStyleSheet(( "background: rgba(0, 0, 0, 127);" "font-size: 60pt;" @@ -159,36 +160,40 @@ class SubsetWidget(QtWidgets.QWidget): grouping=enable_grouping ) proxy = SubsetFilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + family_proxy = FamiliesFilterProxyModel() family_proxy.setSourceModel(proxy) - subset_filter = QtWidgets.QLineEdit() + subset_filter = QtWidgets.QLineEdit(self) subset_filter.setPlaceholderText("Filter subsets..") - groupable = QtWidgets.QCheckBox("Enable Grouping") - groupable.setChecked(enable_grouping) + group_checkbox = QtWidgets.QCheckBox("Enable Grouping", self) + group_checkbox.setChecked(enable_grouping) top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(subset_filter) - top_bar_layout.addWidget(groupable) + top_bar_layout.addWidget(group_checkbox) - view = TreeViewSpinner() + view = TreeViewSpinner(self) + view.setModel(family_proxy) view.setObjectName("SubsetView") view.setIndentation(20) - view.setStyleSheet(""" - QTreeView::item{ - padding: 5px 1px; - border: 0px; - } - """) view.setAllColumnsShowFocus(True) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + view.setSortingEnabled(True) + view.sortByColumn(1, QtCore.Qt.AscendingOrder) + view.setAlternatingRowColors(True) # Set view delegates - version_delegate = VersionDelegate(self.dbcon) + version_delegate = VersionDelegate(self.dbcon, view) column = model.Columns.index("version") view.setItemDelegateForColumn(column, version_delegate) - time_delegate = PrettyTimeDelegate() + time_delegate = PrettyTimeDelegate(view) column = model.Columns.index("time") view.setItemDelegateForColumn(column, time_delegate) @@ -197,54 +202,39 @@ class SubsetWidget(QtWidgets.QWidget): layout.addLayout(top_bar_layout) layout.addWidget(view) - view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - view.setSortingEnabled(True) - view.sortByColumn(1, QtCore.Qt.AscendingOrder) - view.setAlternatingRowColors(True) - - self.data = { - "delegates": { - "version": version_delegate, - "time": time_delegate - }, - "state": { - "groupable": groupable - } - } - - self.proxy = proxy - self.model = model - self.view = view - self.filter = subset_filter - self.family_proxy = family_proxy - # settings and connections - self.proxy.setSourceModel(self.model) - self.proxy.setDynamicSortFilter(True) - self.proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - - self.view.setModel(self.family_proxy) - self.view.customContextMenuRequested.connect(self.on_context_menu) - for column_name, width in self.default_widths: idx = model.Columns.index(column_name) view.setColumnWidth(idx, width) + self.model = model + self.view = view + actual_project = dbcon.Session["AVALON_PROJECT"] self.on_project_change(actual_project) + view.customContextMenuRequested.connect(self.on_context_menu) + selection = view.selectionModel() selection.selectionChanged.connect(self.active_changed) version_delegate.version_changed.connect(self.version_changed) - groupable.stateChanged.connect(self.set_grouping) + group_checkbox.stateChanged.connect(self.set_grouping) - self.filter.textChanged.connect(self.proxy.setFilterRegExp) - self.filter.textChanged.connect(self.view.expandAll) + subset_filter.textChanged.connect(proxy.setFilterRegExp) + subset_filter.textChanged.connect(view.expandAll) model.refreshed.connect(self.refreshed) + self.proxy = proxy + self.family_proxy = family_proxy + + self._subset_filter = subset_filter + self._group_checkbox = group_checkbox + + self._version_delegate = version_delegate + self._time_delegate = time_delegate + self.model.refresh() def get_subsets_families(self): @@ -254,7 +244,7 @@ class SubsetWidget(QtWidgets.QWidget): self.family_proxy.setFamiliesFilter(families) def is_groupable(self): - return self.data["state"]["groupable"].checkState() + return self._group_checkbox.isChecked() def set_grouping(self, state): with tools_lib.preserve_selection(tree_view=self.view, @@ -755,6 +745,7 @@ class ThumbnailWidget(QtWidgets.QLabel): "default_thumbnail.png" ) self.default_pix = QtGui.QPixmap(default_pix_path) + self.set_pixmap() def height(self): width = self.width() @@ -786,7 +777,10 @@ class ThumbnailWidget(QtWidgets.QLabel): def scale_pixmap(self, pixmap): return pixmap.scaled( - self.width(), self.height(), QtCore.Qt.KeepAspectRatio + self.width(), + self.height(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation ) def set_thumbnail(self, entity=None): @@ -1128,7 +1122,8 @@ class RepresentationWidget(QtWidgets.QWidget): label = QtWidgets.QLabel("Representations", self) - tree_view = DeselectableTreeView() + tree_view = DeselectableTreeView(parent=self) + tree_view.setObjectName("RepresentationView") tree_view.setModel(proxy_model) tree_view.setAllColumnsShowFocus(True) tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) @@ -1138,12 +1133,6 @@ class RepresentationWidget(QtWidgets.QWidget): tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder) tree_view.setAlternatingRowColors(True) tree_view.setIndentation(20) - tree_view.setStyleSheet(""" - QTreeView::item{ - padding: 5px 1px; - border: 0px; - } - """) tree_view.collapseAll() for column_name, width in self.default_widths: diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 1fa3a3868a..0f5a930902 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -2,20 +2,27 @@ import sys import time import logging +from Qt import QtWidgets, QtCore + from openpype.hosts.maya.api.lib import assign_look_by_version from avalon import style, io from avalon.tools import lib -from avalon.vendor.Qt import QtWidgets, QtCore from maya import cmds # old api for MFileIO import maya.OpenMaya import maya.api.OpenMaya as om -from . import widgets -from . import commands -from . vray_proxies import vrayproxy_assign_look +from .widgets import ( + AssetOutliner, + LookOutliner +) +from .commands import ( + get_workfile, + remove_unused_looks +) +from .vray_proxies import vrayproxy_assign_look module = sys.modules[__name__] @@ -32,7 +39,7 @@ class App(QtWidgets.QWidget): # Store callback references self._callbacks = [] - filename = commands.get_workfile() + filename = get_workfile() self.setObjectName("lookManager") self.setWindowTitle("Look Manager 1.3.0 - [{}]".format(filename)) @@ -57,13 +64,13 @@ class App(QtWidgets.QWidget): """Build the UI""" # Assets (left) - asset_outliner = widgets.AssetOutliner() + asset_outliner = AssetOutliner() # Looks (right) looks_widget = QtWidgets.QWidget() looks_layout = QtWidgets.QVBoxLayout(looks_widget) - look_outliner = widgets.LookOutliner() # Database look overview + look_outliner = LookOutliner() # Database look overview assign_selected = QtWidgets.QCheckBox("Assign to selected only") assign_selected.setToolTip("Whether to assign only to selected nodes " @@ -124,7 +131,7 @@ class App(QtWidgets.QWidget): lambda: self.echo("Loaded assets..")) self.look_outliner.menu_apply_action.connect(self.on_process_selected) - self.remove_unused.clicked.connect(commands.remove_unused_looks) + self.remove_unused.clicked.connect(remove_unused_looks) # Maya renderlayer switch callback callback = om.MEventMessage.addEventCallback( diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index a53251cdef..f7d26f9adb 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -9,7 +9,7 @@ from openpype.hosts.maya.api import lib from avalon import io, api -import vray_proxies +from .vray_proxies import get_alembic_ids_cache log = logging.getLogger(__name__) @@ -146,7 +146,7 @@ def create_items_from_nodes(nodes): vray_proxy_nodes = cmds.ls(nodes, type="VRayProxy") for vp in vray_proxy_nodes: path = cmds.getAttr("{}.fileName".format(vp)) - ids = vray_proxies.get_alembic_ids_cache(path) + ids = get_alembic_ids_cache(path) parent_id = {} for k, _ in ids.items(): pid = k.split(":")[0] diff --git a/openpype/tools/mayalookassigner/models.py b/openpype/tools/mayalookassigner/models.py index 7c5133de82..80de6c1897 100644 --- a/openpype/tools/mayalookassigner/models.py +++ b/openpype/tools/mayalookassigner/models.py @@ -1,7 +1,8 @@ from collections import defaultdict -from avalon.tools import models -from avalon.vendor.Qt import QtCore +from Qt import QtCore + +from avalon.tools import models from avalon.vendor import qtawesome from avalon.style import colors diff --git a/openpype/tools/mayalookassigner/views.py b/openpype/tools/mayalookassigner/views.py index decf04ee57..993023bb45 100644 --- a/openpype/tools/mayalookassigner/views.py +++ b/openpype/tools/mayalookassigner/views.py @@ -1,4 +1,4 @@ -from avalon.vendor.Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore DEFAULT_COLOR = "#fb9c15" diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index 2dab266af9..625e9ef8c6 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -1,13 +1,16 @@ import logging from collections import defaultdict -from avalon.vendor.Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore # TODO: expose this better in avalon core from avalon.tools import lib from avalon.tools.models import TreeModel -from . import models +from .models import ( + AssetModel, + LookModel +) from . import commands from . import views @@ -30,7 +33,7 @@ class AssetOutliner(QtWidgets.QWidget): title.setAlignment(QtCore.Qt.AlignCenter) title.setStyleSheet("font-weight: bold; font-size: 12px") - model = models.AssetModel() + model = AssetModel() view = views.View() view.setModel(model) view.customContextMenuRequested.connect(self.right_mouse_menu) @@ -201,7 +204,7 @@ class LookOutliner(QtWidgets.QWidget): title.setStyleSheet("font-weight: bold; font-size: 12px") title.setAlignment(QtCore.Qt.AlignCenter) - model = models.LookModel() + model = LookModel() # Proxy for dynamic sorting proxy = QtCore.QSortFilterProxyModel() @@ -257,5 +260,3 @@ class LookOutliner(QtWidgets.QWidget): menu.addAction(apply_action) menu.exec_(globalpos) - - diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5b6ed78b50..b7ab9e40d0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1456,7 +1456,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): return raw_data = mime_data.data("application/copy_task") - encoded_data = QtCore.QByteArray.fromRawData(raw_data) + if isinstance(raw_data, QtCore.QByteArray): + # Raw data are already QByteArrat and we don't have to load them + encoded_data = raw_data + else: + encoded_data = QtCore.QByteArray.fromRawData(raw_data) stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly) text = stream.readQString() try: diff --git a/openpype/tools/publisher/__init__.py b/openpype/tools/publisher/__init__.py new file mode 100644 index 0000000000..a7b597eece --- /dev/null +++ b/openpype/tools/publisher/__init__.py @@ -0,0 +1,7 @@ +from .app import show +from .window import PublisherWindow + +__all__ = ( + "show", + "PublisherWindow" +) diff --git a/openpype/tools/publisher/app.py b/openpype/tools/publisher/app.py new file mode 100644 index 0000000000..bc1bd7cfbd --- /dev/null +++ b/openpype/tools/publisher/app.py @@ -0,0 +1,17 @@ +from .window import PublisherWindow + + +class _WindowCache: + window = None + + +def show(parent=None): + window = _WindowCache.window + if window is None: + window = PublisherWindow(parent) + _WindowCache.window = window + + window.show() + window.activateWindow() + + return window diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py new file mode 100644 index 0000000000..cf0850bde8 --- /dev/null +++ b/openpype/tools/publisher/constants.py @@ -0,0 +1,34 @@ +from Qt import QtCore + +# ID of context item in instance view +CONTEXT_ID = "context" +CONTEXT_LABEL = "Options" + +# Allowed symbols for subset name (and variant) +# - characters, numbers, unsercore and dash +SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_." +VARIANT_TOOLTIP = ( + "Variant may contain alphabetical characters (a-Z)" + "\nnumerical characters (0-9) dot (\".\") or underscore (\"_\")." +) + +# Roles for instance views +INSTANCE_ID_ROLE = QtCore.Qt.UserRole + 1 +SORT_VALUE_ROLE = QtCore.Qt.UserRole + 2 +IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 +CREATOR_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 4 +FAMILY_ROLE = QtCore.Qt.UserRole + 5 + + +__all__ = ( + "CONTEXT_ID", + + "SUBSET_NAME_ALLOWED_SYMBOLS", + "VARIANT_TOOLTIP", + + "INSTANCE_ID_ROLE", + "SORT_VALUE_ROLE", + "IS_GROUP_ROLE", + "CREATOR_IDENTIFIER_ROLE", + "FAMILY_ROLE" +) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py new file mode 100644 index 0000000000..24ec9dcb0e --- /dev/null +++ b/openpype/tools/publisher/control.py @@ -0,0 +1,991 @@ +import os +import copy +import inspect +import logging +import traceback +import collections + +import weakref +try: + from weakref import WeakMethod +except Exception: + from openpype.lib.python_2_comp import WeakMethod + +import avalon.api +import pyblish.api + +from openpype.pipeline import PublishValidationError +from openpype.pipeline.create import CreateContext + +from Qt import QtCore + +# Define constant for plugin orders offset +PLUGIN_ORDER_OFFSET = 0.5 + + +class MainThreadItem: + """Callback with args and kwargs.""" + def __init__(self, callback, *args, **kwargs): + self.callback = callback + self.args = args + self.kwargs = kwargs + + def process(self): + self.callback(*self.args, **self.kwargs) + + +class MainThreadProcess(QtCore.QObject): + """Qt based main thread process executor. + + Has timer which controls each 50ms if there is new item to process. + + This approach gives ability to update UI meanwhile plugin is in progress. + """ + def __init__(self): + super(MainThreadProcess, self).__init__() + self._items_to_process = collections.deque() + + timer = QtCore.QTimer() + timer.setInterval(50) + + timer.timeout.connect(self._execute) + + self._timer = timer + + def add_item(self, item): + self._items_to_process.append(item) + + def _execute(self): + if not self._items_to_process: + return + + item = self._items_to_process.popleft() + item.process() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + + def clear(self): + if self._timer.isActive(): + self._timer.stop() + self._items_to_process = collections.deque() + + +class AssetDocsCache: + """Cache asset documents for creation part.""" + projection = { + "_id": True, + "name": True, + "data.visualParent": True, + "data.tasks": True + } + + def __init__(self, controller): + self._controller = controller + self._asset_docs = None + self._task_names_by_asset_name = {} + + @property + def dbcon(self): + return self._controller.dbcon + + def reset(self): + self._asset_docs = None + self._task_names_by_asset_name = {} + + def _query(self): + if self._asset_docs is None: + asset_docs = list(self.dbcon.find( + {"type": "asset"}, + self.projection + )) + task_names_by_asset_name = {} + for asset_doc in asset_docs: + asset_name = asset_doc["name"] + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_names_by_asset_name[asset_name] = list(asset_tasks.keys()) + self._asset_docs = asset_docs + self._task_names_by_asset_name = task_names_by_asset_name + + def get_asset_docs(self): + self._query() + return copy.deepcopy(self._asset_docs) + + def get_task_names_by_asset_name(self): + self._query() + return copy.deepcopy(self._task_names_by_asset_name) + + +class PublishReport: + """Report for single publishing process. + + Report keeps current state of publishing and currently processed plugin. + """ + def __init__(self, controller): + self.controller = controller + self._publish_discover_result = None + self._plugin_data = [] + self._plugin_data_with_plugin = [] + + self._stored_plugins = [] + self._current_plugin_data = [] + self._all_instances_by_id = {} + self._current_context = None + + def reset(self, context, publish_discover_result=None): + """Reset report and clear all data.""" + self._publish_discover_result = publish_discover_result + self._plugin_data = [] + self._plugin_data_with_plugin = [] + self._current_plugin_data = {} + self._all_instances_by_id = {} + self._current_context = context + + def add_plugin_iter(self, plugin, context): + """Add report about single iteration of plugin.""" + for instance in context: + self._all_instances_by_id[instance.id] = instance + + if self._current_plugin_data: + self._current_plugin_data["passed"] = True + + self._current_plugin_data = self._add_plugin_data_item(plugin) + + def _get_plugin_data_item(self, plugin): + store_item = None + for item in self._plugin_data_with_plugin: + if item["plugin"] is plugin: + store_item = item["data"] + break + return store_item + + def _add_plugin_data_item(self, plugin): + if plugin in self._stored_plugins: + raise ValueError("Plugin is already stored") + + self._stored_plugins.append(plugin) + + label = None + if hasattr(plugin, "label"): + label = plugin.label + + plugin_data_item = { + "name": plugin.__name__, + "label": label, + "order": plugin.order, + "instances_data": [], + "actions_data": [], + "skipped": False, + "passed": False + } + self._plugin_data_with_plugin.append({ + "plugin": plugin, + "data": plugin_data_item + }) + self._plugin_data.append(plugin_data_item) + return plugin_data_item + + def set_plugin_skipped(self): + """Set that current plugin has been skipped.""" + self._current_plugin_data["skipped"] = True + + def add_result(self, result): + """Handle result of one plugin and it's instance.""" + instance = result["instance"] + instance_id = None + if instance is not None: + instance_id = instance.id + self._current_plugin_data["instances_data"].append({ + "id": instance_id, + "logs": self._extract_instance_log_items(result) + }) + + def add_action_result(self, action, result): + """Add result of single action.""" + plugin = result["plugin"] + + store_item = self._get_plugin_data_item(plugin) + if store_item is None: + store_item = self._add_plugin_data_item(plugin) + + action_name = action.__name__ + action_label = action.label or action_name + log_items = self._extract_log_items(result) + store_item["actions_data"].append({ + "success": result["success"], + "name": action_name, + "label": action_label, + "logs": log_items + }) + + def get_report(self, publish_plugins=None): + """Report data with all details of current state.""" + instances_details = {} + for instance in self._all_instances_by_id.values(): + instances_details[instance.id] = self._extract_instance_data( + instance, instance in self._current_context + ) + + plugins_data = copy.deepcopy(self._plugin_data) + if plugins_data and not plugins_data[-1]["passed"]: + plugins_data[-1]["passed"] = True + + if publish_plugins: + for plugin in publish_plugins: + if plugin not in self._stored_plugins: + plugins_data.append(self._add_plugin_data_item(plugin)) + + crashed_file_paths = {} + if self._publish_discover_result is not None: + items = self._publish_discover_result.crashed_file_paths.items() + for filepath, exc_info in items: + crashed_file_paths[filepath] = "".join( + traceback.format_exception(*exc_info) + ) + + return { + "plugins_data": plugins_data, + "instances": instances_details, + "context": self._extract_context_data(self._current_context), + "crashed_file_paths": crashed_file_paths + } + + def _extract_context_data(self, context): + return { + "label": context.data.get("label") + } + + def _extract_instance_data(self, instance, exists): + return { + "name": instance.data.get("name"), + "label": instance.data.get("label"), + "family": instance.data["family"], + "families": instance.data.get("families") or [], + "exists": exists + } + + def _extract_instance_log_items(self, result): + instance = result["instance"] + instance_id = None + if instance: + instance_id = instance.id + + log_items = self._extract_log_items(result) + for item in log_items: + item["instance_id"] = instance_id + return log_items + + def _extract_log_items(self, result): + output = [] + records = result.get("records") or [] + for record in records: + record_exc_info = record.exc_info + if record_exc_info is not None: + record_exc_info = "".join( + traceback.format_exception(*record_exc_info) + ) + + try: + msg = record.getMessage() + except Exception: + msg = str(record.msg) + + output.append({ + "type": "record", + "msg": msg, + "name": record.name, + "lineno": record.lineno, + "levelno": record.levelno, + "levelname": record.levelname, + "threadName": record.threadName, + "filename": record.filename, + "pathname": record.pathname, + "msecs": record.msecs, + "exc_info": record_exc_info + }) + + exception = result.get("error") + if exception: + fname, line_no, func, exc = exception.traceback + output.append({ + "type": "error", + "msg": str(exception), + "filename": str(fname), + "lineno": str(line_no), + "func": str(func), + "traceback": exception.formatted_traceback + }) + + return output + + +class PublisherController: + """Middleware between UI, CreateContext and publish Context. + + Handle both creation and publishing parts. + + Args: + dbcon (AvalonMongoDB): Connection to mongo with context. + headless (bool): Headless publishing. ATM not implemented or used. + """ + def __init__(self, dbcon=None, headless=False): + self.log = logging.getLogger("PublisherController") + self.host = avalon.api.registered_host() + self.headless = headless + + self.create_context = CreateContext( + self.host, dbcon, headless=headless, reset=False + ) + + # pyblish.api.Context + self._publish_context = None + # Pyblish report + self._publish_report = PublishReport(self) + # Store exceptions of validation error + self._publish_validation_errors = [] + # Currently processing plugin errors + self._publish_current_plugin_validation_errors = None + # Any other exception that happened during publishing + self._publish_error = None + # Publishing is in progress + self._publish_is_running = False + # Publishing is over validation order + self._publish_validated = False + # Publishing should stop at validation stage + self._publish_up_validation = False + # All publish plugins are processed + self._publish_finished = False + self._publish_max_progress = 0 + self._publish_progress = 0 + # This information is not much important for controller but for widget + # which can change (and set) the comment. + self._publish_comment_is_set = False + + # Validation order + # - plugin with order same or higher than this value is extractor or + # higher + self._validation_order = ( + pyblish.api.ValidatorOrder + PLUGIN_ORDER_OFFSET + ) + + # Qt based main thread processor + self._main_thread_processor = MainThreadProcess() + # Plugin iterator + self._main_thread_iter = None + + # Variables where callbacks are stored + self._instances_refresh_callback_refs = set() + self._plugins_refresh_callback_refs = set() + + self._publish_reset_callback_refs = set() + self._publish_started_callback_refs = set() + self._publish_validated_callback_refs = set() + self._publish_stopped_callback_refs = set() + + self._publish_instance_changed_callback_refs = set() + self._publish_plugin_changed_callback_refs = set() + + # State flags to prevent executing method which is already in progress + self._resetting_plugins = False + self._resetting_instances = False + + # Cacher of avalon documents + self._asset_docs_cache = AssetDocsCache(self) + + @property + def project_name(self): + """Current project context.""" + return self.dbcon.Session["AVALON_PROJECT"] + + @property + def dbcon(self): + """Pointer to AvalonMongoDB in creator context.""" + return self.create_context.dbcon + + @property + def instances(self): + """Current instances in create context.""" + return self.create_context.instances + + @property + def creators(self): + """All creators loaded in create context.""" + return self.create_context.creators + + @property + def manual_creators(self): + """Creators that can be shown in create dialog.""" + return self.create_context.manual_creators + + @property + def host_is_valid(self): + """Host is valid for creation.""" + return self.create_context.host_is_valid + + @property + def publish_plugins(self): + """Publish plugins.""" + return self.create_context.publish_plugins + + @property + def plugins_with_defs(self): + """Publish plugins with possible attribute definitions.""" + return self.create_context.plugins_with_defs + + def _create_reference(self, callback): + if inspect.ismethod(callback): + ref = WeakMethod(callback) + elif callable(callback): + ref = weakref.ref(callback) + else: + raise TypeError("Expected function or method got {}".format( + str(type(callback)) + )) + return ref + + def add_instances_refresh_callback(self, callback): + """Callbacks triggered on instances refresh.""" + ref = self._create_reference(callback) + self._instances_refresh_callback_refs.add(ref) + + def add_plugins_refresh_callback(self, callback): + """Callbacks triggered on plugins refresh.""" + ref = self._create_reference(callback) + self._plugins_refresh_callback_refs.add(ref) + + # --- Publish specific callbacks --- + def add_publish_reset_callback(self, callback): + """Callbacks triggered on publishing reset.""" + ref = self._create_reference(callback) + self._publish_reset_callback_refs.add(ref) + + def add_publish_started_callback(self, callback): + """Callbacks triggered on publishing start.""" + ref = self._create_reference(callback) + self._publish_started_callback_refs.add(ref) + + def add_publish_validated_callback(self, callback): + """Callbacks triggered on passing last possible validation order.""" + ref = self._create_reference(callback) + self._publish_validated_callback_refs.add(ref) + + def add_instance_change_callback(self, callback): + """Callbacks triggered before next publish instance process.""" + ref = self._create_reference(callback) + self._publish_instance_changed_callback_refs.add(ref) + + def add_plugin_change_callback(self, callback): + """Callbacks triggered before next plugin processing.""" + ref = self._create_reference(callback) + self._publish_plugin_changed_callback_refs.add(ref) + + def add_publish_stopped_callback(self, callback): + """Callbacks triggered on publishing stop (any reason).""" + ref = self._create_reference(callback) + self._publish_stopped_callback_refs.add(ref) + + def get_asset_docs(self): + """Get asset documents from cache for whole project.""" + return self._asset_docs_cache.get_asset_docs() + + def get_context_title(self): + """Get context title for artist shown at the top of main window.""" + context_title = None + if hasattr(self.host, "get_context_title"): + context_title = self.host.get_context_title() + + if context_title is None: + context_title = os.environ.get("AVALON_APP_NAME") + if context_title is None: + context_title = os.environ.get("AVALON_APP") + + return context_title + + def get_asset_hierarchy(self): + """Prepare asset documents into hierarchy.""" + _queue = collections.deque(self.get_asset_docs()) + + output = collections.defaultdict(list) + while _queue: + asset_doc = _queue.popleft() + parent_id = asset_doc["data"]["visualParent"] + output[parent_id].append(asset_doc) + return output + + def get_task_names_by_asset_names(self, asset_names): + """Prepare task names by asset name.""" + task_names_by_asset_name = ( + self._asset_docs_cache.get_task_names_by_asset_name() + ) + result = {} + for asset_name in asset_names: + result[asset_name] = set( + task_names_by_asset_name.get(asset_name) or [] + ) + return result + + def _trigger_callbacks(self, callbacks, *args, **kwargs): + """Helper method to trigger callbacks stored by their rerence.""" + # Trigger reset callbacks + to_remove = set() + for ref in callbacks: + callback = ref() + if callback: + callback(*args, **kwargs) + else: + to_remove.add(ref) + + for ref in to_remove: + callbacks.remove(ref) + + def reset(self): + """Reset everything related to creation and publishing.""" + # Stop publishing + self.stop_publish() + + # Reset avalon context + self.create_context.reset_avalon_context() + + self._reset_plugins() + # Publish part must be resetted after plugins + self._reset_publish() + self._reset_instances() + + def _reset_plugins(self): + """Reset to initial state.""" + if self._resetting_plugins: + return + + self._resetting_plugins = True + + self.create_context.reset_plugins() + + self._resetting_plugins = False + + self._trigger_callbacks(self._plugins_refresh_callback_refs) + + def _reset_instances(self): + """Reset create instances.""" + if self._resetting_instances: + return + + self._resetting_instances = True + + self.create_context.reset_context_data() + with self.create_context.bulk_instances_collection(): + self.create_context.reset_instances() + self.create_context.execute_autocreators() + + self._resetting_instances = False + + self._trigger_callbacks(self._instances_refresh_callback_refs) + + def get_creator_attribute_definitions(self, instances): + """Collect creator attribute definitions for multuple instances. + + Args: + instances(list): List of created instances for + which should be attribute definitions returned. + """ + output = [] + _attr_defs = {} + for instance in instances: + for attr_def in instance.creator_attribute_defs: + found_idx = None + for idx, _attr_def in _attr_defs.items(): + if attr_def == _attr_def: + found_idx = idx + break + + value = instance.creator_attributes[attr_def.key] + if found_idx is None: + idx = len(output) + output.append((attr_def, [instance], [value])) + _attr_defs[idx] = attr_def + else: + item = output[found_idx] + item[1].append(instance) + item[2].append(value) + return output + + def get_publish_attribute_definitions(self, instances, include_context): + """Collect publish attribute definitions for passed instances. + + Args: + instances(list): List of created instances for + which should be attribute definitions returned. + include_context(bool): Add context specific attribute definitions. + """ + _tmp_items = [] + if include_context: + _tmp_items.append(self.create_context) + + for instance in instances: + _tmp_items.append(instance) + + all_defs_by_plugin_name = {} + all_plugin_values = {} + for item in _tmp_items: + for plugin_name, attr_val in item.publish_attributes.items(): + attr_defs = attr_val.attr_defs + if not attr_defs: + continue + + if plugin_name not in all_defs_by_plugin_name: + all_defs_by_plugin_name[plugin_name] = attr_val.attr_defs + + if plugin_name not in all_plugin_values: + all_plugin_values[plugin_name] = {} + + plugin_values = all_plugin_values[plugin_name] + + for attr_def in attr_defs: + if attr_def.key not in plugin_values: + plugin_values[attr_def.key] = [] + attr_values = plugin_values[attr_def.key] + + value = attr_val[attr_def.key] + attr_values.append((item, value)) + + output = [] + for plugin in self.plugins_with_defs: + plugin_name = plugin.__name__ + if plugin_name not in all_defs_by_plugin_name: + continue + output.append(( + plugin_name, + all_defs_by_plugin_name[plugin_name], + all_plugin_values + )) + return output + + def get_icon_for_family(self, family): + """TODO rename to get creator icon.""" + creator = self.creators.get(family) + if creator is not None: + return creator.get_icon() + return None + + def create( + self, creator_identifier, subset_name, instance_data, options + ): + """Trigger creation and refresh of instances in UI.""" + creator = self.creators[creator_identifier] + creator.create(subset_name, instance_data, options) + + self._trigger_callbacks(self._instances_refresh_callback_refs) + + def save_changes(self): + """Save changes happened during creation.""" + if self.create_context.host_is_valid: + self.create_context.save_changes() + + def remove_instances(self, instances): + """""" + # QUESTION Expect that instaces are really removed? In that case save + # reset is not required and save changes too. + self.save_changes() + + self.create_context.remove_instances(instances) + + self._trigger_callbacks(self._instances_refresh_callback_refs) + + # --- Publish specific implementations --- + @property + def publish_has_finished(self): + return self._publish_finished + + @property + def publish_is_running(self): + return self._publish_is_running + + @property + def publish_has_validated(self): + return self._publish_validated + + @property + def publish_has_crashed(self): + return bool(self._publish_error) + + @property + def publish_has_validation_errors(self): + return bool(self._publish_validation_errors) + + @property + def publish_max_progress(self): + return self._publish_max_progress + + @property + def publish_progress(self): + return self._publish_progress + + @property + def publish_comment_is_set(self): + return self._publish_comment_is_set + + def get_publish_crash_error(self): + return self._publish_error + + def get_publish_report(self): + return self._publish_report.get_report(self.publish_plugins) + + def get_validation_errors(self): + return self._publish_validation_errors + + def _reset_publish(self): + self._publish_is_running = False + self._publish_validated = False + self._publish_up_validation = False + self._publish_finished = False + self._publish_comment_is_set = False + self._main_thread_processor.clear() + self._main_thread_iter = self._publish_iterator() + self._publish_context = pyblish.api.Context() + # Make sure "comment" is set on publish context + self._publish_context.data["comment"] = "" + # Add access to create context during publishing + # - must not be used for changing CreatedInstances during publishing! + # QUESTION + # - pop the key after first collector using it would be safest option? + self._publish_context.data["create_context"] = self.create_context + + self._publish_report.reset( + self._publish_context, + self.create_context.publish_discover_result + ) + self._publish_validation_errors = [] + self._publish_current_plugin_validation_errors = None + self._publish_error = None + + self._publish_max_progress = len(self.publish_plugins) + self._publish_progress = 0 + + self._trigger_callbacks(self._publish_reset_callback_refs) + + def set_comment(self, comment): + self._publish_context.data["comment"] = comment + self._publish_comment_is_set = True + + def publish(self): + """Run publishing.""" + self._publish_up_validation = False + self._start_publish() + + def validate(self): + """Run publishing and stop after Validation.""" + if self._publish_validated: + return + self._publish_up_validation = True + self._start_publish() + + def _start_publish(self): + """Start or continue in publishing.""" + if self._publish_is_running: + return + + # Make sure changes are saved + self.save_changes() + + self._publish_is_running = True + self._trigger_callbacks(self._publish_started_callback_refs) + self._main_thread_processor.start() + self._publish_next_process() + + def _stop_publish(self): + """Stop or pause publishing.""" + self._publish_is_running = False + self._main_thread_processor.stop() + self._trigger_callbacks(self._publish_stopped_callback_refs) + + def stop_publish(self): + """Stop publishing process (any reason).""" + if self._publish_is_running: + self._stop_publish() + + def run_action(self, plugin, action): + # TODO handle result in UI + result = pyblish.plugin.process( + plugin, self._publish_context, None, action.id + ) + self._publish_report.add_action_result(action, result) + + def _publish_next_process(self): + # Validations of progress before using iterator + # - same conditions may be inside iterator but they may be used + # only in specific cases (e.g. when it happens for a first time) + + # There are validation errors and validation is passed + # - can't do any progree + if ( + self._publish_validated + and self._publish_validation_errors + ): + item = MainThreadItem(self.stop_publish) + + # Any unexpected error happened + # - everything should stop + elif self._publish_error: + item = MainThreadItem(self.stop_publish) + + # Everything is ok so try to get new processing item + else: + item = next(self._main_thread_iter) + + self._main_thread_processor.add_item(item) + + def _publish_iterator(self): + """Main logic center of publishing. + + Iterator returns `MainThreadItem` objects with callbacks that should be + processed in main thread (threaded in future?). Cares about changing + states of currently processed publish plugin and instance. Also + change state of processed orders like validation order has passed etc. + + Also stops publishing if should stop on validation. + + QUESTION: + Does validate button still make sense? + """ + for idx, plugin in enumerate(self.publish_plugins): + self._publish_progress = idx + # Add plugin to publish report + self._publish_report.add_plugin_iter(plugin, self._publish_context) + + # Reset current plugin validations error + self._publish_current_plugin_validation_errors = None + + # Check if plugin is over validation order + if not self._publish_validated: + self._publish_validated = ( + plugin.order >= self._validation_order + ) + # Trigger callbacks when validation stage is passed + if self._publish_validated: + self._trigger_callbacks( + self._publish_validated_callback_refs + ) + + # Stop if plugin is over validation order and process + # should process up to validation. + if self._publish_up_validation and self._publish_validated: + yield MainThreadItem(self.stop_publish) + + # Stop if validation is over and validation errors happened + if ( + self._publish_validated + and self._publish_validation_errors + ): + yield MainThreadItem(self.stop_publish) + + # Trigger callback that new plugin is going to be processed + self._trigger_callbacks( + self._publish_plugin_changed_callback_refs, plugin + ) + # Plugin is instance plugin + if plugin.__instanceEnabled__: + instances = pyblish.logic.instances_by_plugin( + self._publish_context, plugin + ) + if not instances: + self._publish_report.set_plugin_skipped() + continue + + for instance in instances: + if instance.data.get("publish") is False: + continue + + self._trigger_callbacks( + self._publish_instance_changed_callback_refs, + self._publish_context, + instance + ) + yield MainThreadItem( + self._process_and_continue, plugin, instance + ) + else: + families = collect_families_from_instances( + self._publish_context, only_active=True + ) + plugins = pyblish.logic.plugins_by_families( + [plugin], families + ) + if plugins: + self._trigger_callbacks( + self._publish_instance_changed_callback_refs, + self._publish_context, + None + ) + yield MainThreadItem( + self._process_and_continue, plugin, None + ) + else: + self._publish_report.set_plugin_skipped() + + # Cleanup of publishing process + self._publish_finished = True + self._publish_progress = self._publish_max_progress + yield MainThreadItem(self.stop_publish) + + def _add_validation_error(self, result): + if self._publish_current_plugin_validation_errors is None: + self._publish_current_plugin_validation_errors = { + "plugin": result["plugin"], + "errors": [] + } + self._publish_validation_errors.append( + self._publish_current_plugin_validation_errors + ) + + self._publish_current_plugin_validation_errors["errors"].append({ + "exception": result["error"], + "instance": result["instance"] + }) + + def _process_and_continue(self, plugin, instance): + result = pyblish.plugin.process( + plugin, self._publish_context, instance + ) + + self._publish_report.add_result(result) + + exception = result.get("error") + if exception: + if ( + isinstance(exception, PublishValidationError) + and not self._publish_validated + ): + self._add_validation_error(result) + + else: + self._publish_error = exception + + self._publish_next_process() + + +def collect_families_from_instances(instances, only_active=False): + """Collect all families for passed publish instances. + + Args: + instances(list): List of publish instances from + which are families collected. + only_active(bool): Return families only for active instances. + """ + all_families = set() + for instance in instances: + if only_active: + if instance.data.get("publish") is False: + continue + family = instance.data.get("family") + if family: + all_families.add(family) + + families = instance.data.get("families") or tuple() + for family in families: + all_families.add(family) + + return list(all_families) diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py new file mode 100644 index 0000000000..3cfaaa5a05 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/__init__.py @@ -0,0 +1,14 @@ +from .widgets import ( + PublishReportViewerWidget +) + +from .window import ( + PublishReportViewerWindow +) + + +__all__ = ( + "PublishReportViewerWidget", + + "PublishReportViewerWindow", +) diff --git a/openpype/tools/publisher/publish_report_viewer/constants.py b/openpype/tools/publisher/publish_report_viewer/constants.py new file mode 100644 index 0000000000..8fbb9342ca --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/constants.py @@ -0,0 +1,20 @@ +from Qt import QtCore + + +ITEM_ID_ROLE = QtCore.Qt.UserRole + 1 +ITEM_IS_GROUP_ROLE = QtCore.Qt.UserRole + 2 +ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 3 +ITEM_ERRORED_ROLE = QtCore.Qt.UserRole + 4 +PLUGIN_SKIPPED_ROLE = QtCore.Qt.UserRole + 5 +PLUGIN_PASSED_ROLE = QtCore.Qt.UserRole + 6 +INSTANCE_REMOVED_ROLE = QtCore.Qt.UserRole + 7 + + +__all__ = ( + "ITEM_ID_ROLE", + "ITEM_IS_GROUP_ROLE", + "ITEM_LABEL_ROLE", + "ITEM_ERRORED_ROLE", + "PLUGIN_SKIPPED_ROLE", + "INSTANCE_REMOVED_ROLE" +) diff --git a/openpype/tools/publisher/publish_report_viewer/delegates.py b/openpype/tools/publisher/publish_report_viewer/delegates.py new file mode 100644 index 0000000000..9cd4f52174 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/delegates.py @@ -0,0 +1,331 @@ +import collections +from Qt import QtWidgets, QtCore, QtGui +from .constants import ( + ITEM_IS_GROUP_ROLE, + ITEM_ERRORED_ROLE, + PLUGIN_SKIPPED_ROLE, + PLUGIN_PASSED_ROLE, + INSTANCE_REMOVED_ROLE +) + +colors = { + "error": QtGui.QColor("#ff4a4a"), + "warning": QtGui.QColor("#ff9900"), + "ok": QtGui.QColor("#77AE24"), + "active": QtGui.QColor("#99CEEE"), + "idle": QtCore.Qt.white, + "inactive": QtGui.QColor("#888"), + "hover": QtGui.QColor(255, 255, 255, 5), + "selected": QtGui.QColor(255, 255, 255, 10), + "outline": QtGui.QColor("#333"), + "group": QtGui.QColor("#21252B"), + "group-hover": QtGui.QColor("#3c3c3c"), + "group-selected-hover": QtGui.QColor("#555555") +} + + +class GroupItemDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for instance header""" + + _item_icons_by_name_and_size = collections.defaultdict(dict) + + _minus_pixmaps = {} + _plus_pixmaps = {} + _path_stroker = None + + _item_pix_offset_ratio = 1.0 / 5.0 + _item_border_size = 1.0 / 7.0 + _group_pix_offset_ratio = 1.0 / 3.0 + _group_pix_stroke_size_ratio = 1.0 / 7.0 + + @classmethod + def _get_path_stroker(cls): + if cls._path_stroker is None: + path_stroker = QtGui.QPainterPathStroker() + path_stroker.setCapStyle(QtCore.Qt.RoundCap) + path_stroker.setJoinStyle(QtCore.Qt.RoundJoin) + + cls._path_stroker = path_stroker + return cls._path_stroker + + @classmethod + def _get_plus_pixmap(cls, size): + pix = cls._minus_pixmaps.get(size) + if pix is not None: + return pix + + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + offset = int(size * cls._group_pix_offset_ratio) + pnt_1 = QtCore.QPoint(offset, int(size / 2)) + pnt_2 = QtCore.QPoint(size - offset, int(size / 2)) + pnt_3 = QtCore.QPoint(int(size / 2), offset) + pnt_4 = QtCore.QPoint(int(size / 2), size - offset) + path_1 = QtGui.QPainterPath(pnt_1) + path_1.lineTo(pnt_2) + path_2 = QtGui.QPainterPath(pnt_3) + path_2.lineTo(pnt_4) + + path_stroker = cls._get_path_stroker() + path_stroker.setWidth(size * cls._group_pix_stroke_size_ratio) + stroked_path_1 = path_stroker.createStroke(path_1) + stroked_path_2 = path_stroker.createStroke(path_2) + + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pix) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(QtCore.Qt.white) + painter.drawPath(stroked_path_1) + painter.drawPath(stroked_path_2) + painter.end() + + cls._minus_pixmaps[size] = pix + + return pix + + @classmethod + def _get_minus_pixmap(cls, size): + pix = cls._plus_pixmaps.get(size) + if pix is not None: + return pix + + offset = int(size * cls._group_pix_offset_ratio) + pnt_1 = QtCore.QPoint(offset, int(size / 2)) + pnt_2 = QtCore.QPoint(size - offset, int(size / 2)) + path = QtGui.QPainterPath(pnt_1) + path.lineTo(pnt_2) + path_stroker = cls._get_path_stroker() + path_stroker.setWidth(size * cls._group_pix_stroke_size_ratio) + stroked_path = path_stroker.createStroke(path) + + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pix) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtCore.Qt.transparent) + painter.setBrush(QtCore.Qt.white) + painter.drawPath(stroked_path) + painter.end() + + cls._plus_pixmaps[size] = pix + + return pix + + @classmethod + def _get_icon_color(cls, name): + if name == "error": + return QtGui.QColor(colors["error"]) + return QtGui.QColor(QtCore.Qt.white) + + @classmethod + def _get_icon(cls, name, size): + icons_by_size = cls._item_icons_by_name_and_size[name] + if icons_by_size and size in icons_by_size: + return icons_by_size[size] + + offset = int(size * cls._item_pix_offset_ratio) + offset_size = size - (2 * offset) + pix = QtGui.QPixmap(size, size) + pix.fill(QtCore.Qt.transparent) + + painter = QtGui.QPainter(pix) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + draw_ellipse = True + if name == "error": + color = QtGui.QColor(colors["error"]) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(color) + + elif name == "skipped": + color = QtGui.QColor(QtCore.Qt.white) + pen = QtGui.QPen(color) + pen.setWidth(int(size * cls._item_border_size)) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + + elif name == "passed": + color = QtGui.QColor(colors["ok"]) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(color) + + elif name == "removed": + draw_ellipse = False + + offset = offset * 1.5 + p1 = QtCore.QPoint(offset, offset) + p2 = QtCore.QPoint(size - offset, size - offset) + p3 = QtCore.QPoint(offset, size - offset) + p4 = QtCore.QPoint(size - offset, offset) + + pen = QtGui.QPen(QtCore.Qt.white) + pen.setWidth(offset_size / 4) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawLine(p1, p2) + painter.drawLine(p3, p4) + + else: + color = QtGui.QColor(QtCore.Qt.white) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(color) + + if draw_ellipse: + painter.drawEllipse(offset, offset, offset_size, offset_size) + + painter.end() + + cls._item_icons_by_name_and_size[name][size] = pix + + return pix + + def paint(self, painter, option, index): + if index.data(ITEM_IS_GROUP_ROLE): + self.group_item_paint(painter, option, index) + else: + self.item_paint(painter, option, index) + + def item_paint(self, painter, option, index): + self.initStyleOption(option, index) + + widget = option.widget + if widget: + style = widget.style() + else: + style = QtWidgets.QApplicaion.style() + + style.proxy().drawPrimitive( + style.PE_PanelItemViewItem, option, painter, widget + ) + _rect = style.proxy().subElementRect( + style.SE_ItemViewItemText, option, widget + ) + bg_rect = QtCore.QRectF(option.rect) + bg_rect.setY(_rect.y()) + bg_rect.setHeight(_rect.height()) + + expander_rect = QtCore.QRectF(bg_rect) + expander_rect.setWidth(expander_rect.height() + 5) + + label_rect = QtCore.QRectF( + expander_rect.x() + expander_rect.width(), + expander_rect.y(), + bg_rect.width() - expander_rect.width(), + expander_rect.height() + ) + + icon_size = expander_rect.height() + if index.data(ITEM_ERRORED_ROLE): + expander_icon = self._get_icon("error", icon_size) + elif index.data(PLUGIN_SKIPPED_ROLE): + expander_icon = self._get_icon("skipped", icon_size) + elif index.data(PLUGIN_PASSED_ROLE): + expander_icon = self._get_icon("passed", icon_size) + elif index.data(INSTANCE_REMOVED_ROLE): + expander_icon = self._get_icon("removed", icon_size) + else: + expander_icon = self._get_icon("", icon_size) + + label = index.data(QtCore.Qt.DisplayRole) + label = option.fontMetrics.elidedText( + label, QtCore.Qt.ElideRight, label_rect.width() + ) + + painter.save() + # Draw icon + pix_point = QtCore.QPoint( + expander_rect.center().x() - int(expander_icon.width() / 2), + expander_rect.top() + ) + painter.drawPixmap(pix_point, expander_icon) + + # Draw label + painter.setFont(option.font) + painter.drawText(label_rect, QtCore.Qt.AlignVCenter, label) + + # Ok, we're done, tidy up. + painter.restore() + + def group_item_paint(self, painter, option, index): + """Paint text + _ + My label + """ + self.initStyleOption(option, index) + + widget = option.widget + if widget: + style = widget.style() + else: + style = QtWidgets.QApplicaion.style() + _rect = style.proxy().subElementRect( + style.SE_ItemViewItemText, option, widget + ) + + bg_rect = QtCore.QRectF(option.rect) + bg_rect.setY(_rect.y()) + bg_rect.setHeight(_rect.height()) + + expander_height = bg_rect.height() + expander_width = expander_height + 5 + expander_y_offset = expander_height % 2 + expander_height -= expander_y_offset + expander_rect = QtCore.QRectF( + bg_rect.x(), + bg_rect.y() + expander_y_offset, + expander_width, + expander_height + ) + + label_rect = QtCore.QRectF( + bg_rect.x() + expander_width, + bg_rect.y(), + bg_rect.width() - expander_width, + bg_rect.height() + ) + + bg_path = QtGui.QPainterPath() + radius = (bg_rect.height() / 2) - 0.01 + bg_path.addRoundedRect(bg_rect, radius, radius) + + painter.fillPath(bg_path, colors["group"]) + + selected = option.state & QtWidgets.QStyle.State_Selected + hovered = option.state & QtWidgets.QStyle.State_MouseOver + + if selected and hovered: + painter.fillPath(bg_path, colors["selected"]) + elif hovered: + painter.fillPath(bg_path, colors["hover"]) + + expanded = self.parent().isExpanded(index) + if expanded: + expander_icon = self._get_minus_pixmap(expander_height) + else: + expander_icon = self._get_plus_pixmap(expander_height) + + label = index.data(QtCore.Qt.DisplayRole) + label = option.fontMetrics.elidedText( + label, QtCore.Qt.ElideRight, label_rect.width() + ) + + # Maintain reference to state, so we can restore it once we're done + painter.save() + pix_point = QtCore.QPoint( + expander_rect.center().x() - int(expander_icon.width() / 2), + expander_rect.top() + ) + painter.drawPixmap(pix_point, expander_icon) + + # Draw label + painter.setFont(option.font) + painter.drawText(label_rect, QtCore.Qt.AlignVCenter, label) + + # Ok, we're done, tidy up. + painter.restore() diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py new file mode 100644 index 0000000000..460d3e12d1 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -0,0 +1,200 @@ +import uuid +from Qt import QtCore, QtGui + +import pyblish.api + +from .constants import ( + ITEM_ID_ROLE, + ITEM_IS_GROUP_ROLE, + ITEM_LABEL_ROLE, + ITEM_ERRORED_ROLE, + PLUGIN_SKIPPED_ROLE, + PLUGIN_PASSED_ROLE, + INSTANCE_REMOVED_ROLE +) + + +class InstancesModel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(InstancesModel, self).__init__(*args, **kwargs) + + self._items_by_id = {} + self._plugin_items_by_id = {} + + def get_items_by_id(self): + return self._items_by_id + + def set_report(self, report_item): + self.clear() + self._items_by_id.clear() + self._plugin_items_by_id.clear() + + root_item = self.invisibleRootItem() + + families = set(report_item.instance_items_by_family.keys()) + families.remove(None) + all_families = list(sorted(families)) + all_families.insert(0, None) + + family_items = [] + for family in all_families: + items = [] + instance_items = report_item.instance_items_by_family[family] + all_removed = True + for instance_item in instance_items: + item = QtGui.QStandardItem(instance_item.label) + item.setData(instance_item.label, ITEM_LABEL_ROLE) + item.setData(instance_item.errored, ITEM_ERRORED_ROLE) + item.setData(instance_item.id, ITEM_ID_ROLE) + item.setData(instance_item.removed, INSTANCE_REMOVED_ROLE) + if all_removed and not instance_item.removed: + all_removed = False + item.setData(False, ITEM_IS_GROUP_ROLE) + items.append(item) + self._items_by_id[instance_item.id] = item + self._plugin_items_by_id[instance_item.id] = item + + if family is None: + family_items.extend(items) + continue + + family_item = QtGui.QStandardItem(family) + family_item.setData(family, ITEM_LABEL_ROLE) + family_item.setFlags(QtCore.Qt.ItemIsEnabled) + family_id = uuid.uuid4() + family_item.setData(family_id, ITEM_ID_ROLE) + family_item.setData(all_removed, INSTANCE_REMOVED_ROLE) + family_item.setData(True, ITEM_IS_GROUP_ROLE) + family_item.appendRows(items) + family_items.append(family_item) + self._items_by_id[family_id] = family_item + + root_item.appendRows(family_items) + + +class InstanceProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(InstanceProxyModel, self).__init__(*args, **kwargs) + + self._ignore_removed = True + + @property + def ignore_removed(self): + return self._ignore_removed + + def set_ignore_removed(self, value): + if value == self._ignore_removed: + return + self._ignore_removed = value + + if self.sourceModel(): + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + source_index = self.sourceModel().index(row, 0, parent) + if self._ignore_removed and source_index.data(INSTANCE_REMOVED_ROLE): + return False + return True + + +class PluginsModel(QtGui.QStandardItemModel): + order_label_mapping = ( + (pyblish.api.CollectorOrder + 0.5, "Collect"), + (pyblish.api.ValidatorOrder + 0.5, "Validate"), + (pyblish.api.ExtractorOrder + 0.5, "Extract"), + (pyblish.api.IntegratorOrder + 0.5, "Integrate"), + (None, "Other") + ) + + def __init__(self, *args, **kwargs): + super(PluginsModel, self).__init__(*args, **kwargs) + + self._items_by_id = {} + self._plugin_items_by_id = {} + + def get_items_by_id(self): + return self._items_by_id + + def set_report(self, report_item): + self.clear() + self._items_by_id.clear() + self._plugin_items_by_id.clear() + + root_item = self.invisibleRootItem() + + labels_iter = iter(self.order_label_mapping) + cur_order, cur_label = next(labels_iter) + cur_plugin_items = [] + + plugin_items_by_group_labels = [] + plugin_items_by_group_labels.append((cur_label, cur_plugin_items)) + for plugin_id in report_item.plugins_id_order: + plugin_item = report_item.plugins_items_by_id[plugin_id] + if cur_order is not None and plugin_item.order >= cur_order: + cur_order, cur_label = next(labels_iter) + cur_plugin_items = [] + plugin_items_by_group_labels.append( + (cur_label, cur_plugin_items) + ) + + cur_plugin_items.append(plugin_item) + + group_items = [] + for group_label, plugin_items in plugin_items_by_group_labels: + group_id = uuid.uuid4() + group_item = QtGui.QStandardItem(group_label) + group_item.setData(group_label, ITEM_LABEL_ROLE) + group_item.setData(group_id, ITEM_ID_ROLE) + group_item.setData(True, ITEM_IS_GROUP_ROLE) + group_item.setFlags(QtCore.Qt.ItemIsEnabled) + group_items.append(group_item) + + self._items_by_id[group_id] = group_item + + if not plugin_items: + continue + + items = [] + for plugin_item in plugin_items: + item = QtGui.QStandardItem(plugin_item.label) + item.setData(False, ITEM_IS_GROUP_ROLE) + item.setData(plugin_item.label, ITEM_LABEL_ROLE) + item.setData(plugin_item.id, ITEM_ID_ROLE) + item.setData(plugin_item.skipped, PLUGIN_SKIPPED_ROLE) + item.setData(plugin_item.passed, PLUGIN_PASSED_ROLE) + item.setData(plugin_item.errored, ITEM_ERRORED_ROLE) + items.append(item) + self._items_by_id[plugin_item.id] = item + self._plugin_items_by_id[plugin_item.id] = item + group_item.appendRows(items) + + root_item.appendRows(group_items) + + +class PluginProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(PluginProxyModel, self).__init__(*args, **kwargs) + + self._ignore_skipped = True + + @property + def ignore_skipped(self): + return self._ignore_skipped + + def set_ignore_skipped(self, value): + if value == self._ignore_skipped: + return + self._ignore_skipped = value + + if self.sourceModel(): + self.invalidateFilter() + + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + source_index = model.index(row, 0, parent) + if source_index.data(ITEM_IS_GROUP_ROLE): + return model.rowCount(source_index) > 0 + + if self._ignore_skipped and source_index.data(PLUGIN_SKIPPED_ROLE): + return False + return True diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py new file mode 100644 index 0000000000..24f1d33d0e --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -0,0 +1,334 @@ +import copy +import uuid + +from Qt import QtWidgets, QtCore + +from openpype.widgets.nice_checkbox import NiceCheckbox + +from .constants import ( + ITEM_ID_ROLE, + ITEM_IS_GROUP_ROLE +) +from .delegates import GroupItemDelegate +from .model import ( + InstancesModel, + InstanceProxyModel, + PluginsModel, + PluginProxyModel +) + + +class PluginItem: + def __init__(self, plugin_data): + self._id = uuid.uuid4() + + self.name = plugin_data["name"] + self.label = plugin_data["label"] + self.order = plugin_data["order"] + self.skipped = plugin_data["skipped"] + self.passed = plugin_data["passed"] + + logs = [] + errored = False + for instance_data in plugin_data["instances_data"]: + for log_item in instance_data["logs"]: + if not errored: + errored = log_item["type"] == "error" + logs.append(copy.deepcopy(log_item)) + + self.errored = errored + self.logs = logs + + @property + def id(self): + return self._id + + +class InstanceItem: + def __init__(self, instance_id, instance_data, report_data): + self._id = instance_id + self.label = instance_data.get("label") or instance_data.get("name") + self.family = instance_data.get("family") + self.removed = not instance_data.get("exists", True) + + logs = [] + for plugin_data in report_data["plugins_data"]: + for instance_data_item in plugin_data["instances_data"]: + if instance_data_item["id"] == self._id: + logs.extend(copy.deepcopy(instance_data_item["logs"])) + + errored = False + for log in logs: + if log["type"] == "error": + errored = True + break + + self.errored = errored + self.logs = logs + + @property + def id(self): + return self._id + + +class PublishReport: + def __init__(self, report_data): + data = copy.deepcopy(report_data) + + context_data = data["context"] + context_data["name"] = "context" + context_data["label"] = context_data["label"] or "Context" + + instance_items_by_id = {} + instance_items_by_family = {} + context_item = InstanceItem(None, context_data, data) + instance_items_by_id[context_item.id] = context_item + instance_items_by_family[context_item.family] = [context_item] + + for instance_id, instance_data in data["instances"].items(): + item = InstanceItem(instance_id, instance_data, data) + instance_items_by_id[item.id] = item + if item.family not in instance_items_by_family: + instance_items_by_family[item.family] = [] + instance_items_by_family[item.family].append(item) + + all_logs = [] + plugins_items_by_id = {} + plugins_id_order = [] + for plugin_data in data["plugins_data"]: + item = PluginItem(plugin_data) + plugins_id_order.append(item.id) + plugins_items_by_id[item.id] = item + all_logs.extend(copy.deepcopy(item.logs)) + + self.instance_items_by_id = instance_items_by_id + self.instance_items_by_family = instance_items_by_family + + self.plugins_id_order = plugins_id_order + self.plugins_items_by_id = plugins_items_by_id + + self.logs = all_logs + + +class DetailsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(DetailsWidget, self).__init__(parent) + + output_widget = QtWidgets.QPlainTextEdit(self) + output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + output_widget.setObjectName("PublishLogConsole") + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(output_widget) + + self._output_widget = output_widget + + def clear(self): + self._output_widget.setPlainText("") + + def set_logs(self, logs): + lines = [] + for log in logs: + if log["type"] == "record": + message = "{}: {}".format(log["levelname"], log["msg"]) + + lines.append(message) + exc_info = log["exc_info"] + if exc_info: + lines.append(exc_info) + + elif log["type"] == "error": + lines.append(log["traceback"]) + + else: + print(log["type"]) + + text = "\n".join(lines) + self._output_widget.setPlainText(text) + + +class PublishReportViewerWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(PublishReportViewerWidget, self).__init__(parent) + + instances_model = InstancesModel() + instances_proxy = InstanceProxyModel() + instances_proxy.setSourceModel(instances_model) + + plugins_model = PluginsModel() + plugins_proxy = PluginProxyModel() + plugins_proxy.setSourceModel(plugins_model) + + removed_instances_check = NiceCheckbox(parent=self) + removed_instances_check.setChecked(instances_proxy.ignore_removed) + removed_instances_label = QtWidgets.QLabel( + "Hide removed instances", self + ) + + removed_instances_layout = QtWidgets.QHBoxLayout() + removed_instances_layout.setContentsMargins(0, 0, 0, 0) + removed_instances_layout.addWidget(removed_instances_check, 0) + removed_instances_layout.addWidget(removed_instances_label, 1) + + instances_view = QtWidgets.QTreeView(self) + instances_view.setObjectName("PublishDetailViews") + instances_view.setModel(instances_proxy) + instances_view.setIndentation(0) + instances_view.setHeaderHidden(True) + instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + instances_view.setExpandsOnDoubleClick(False) + + instances_delegate = GroupItemDelegate(instances_view) + instances_view.setItemDelegate(instances_delegate) + + skipped_plugins_check = NiceCheckbox(parent=self) + skipped_plugins_check.setChecked(plugins_proxy.ignore_skipped) + skipped_plugins_label = QtWidgets.QLabel("Hide skipped plugins", self) + + skipped_plugins_layout = QtWidgets.QHBoxLayout() + skipped_plugins_layout.setContentsMargins(0, 0, 0, 0) + skipped_plugins_layout.addWidget(skipped_plugins_check, 0) + skipped_plugins_layout.addWidget(skipped_plugins_label, 1) + + plugins_view = QtWidgets.QTreeView(self) + plugins_view.setObjectName("PublishDetailViews") + plugins_view.setModel(plugins_proxy) + plugins_view.setIndentation(0) + plugins_view.setHeaderHidden(True) + plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + plugins_view.setExpandsOnDoubleClick(False) + + plugins_delegate = GroupItemDelegate(plugins_view) + plugins_view.setItemDelegate(plugins_delegate) + + details_widget = DetailsWidget(self) + + layout = QtWidgets.QGridLayout(self) + # Row 1 + layout.addLayout(removed_instances_layout, 0, 0) + layout.addLayout(skipped_plugins_layout, 0, 1) + # Row 2 + layout.addWidget(instances_view, 1, 0) + layout.addWidget(plugins_view, 1, 1) + layout.addWidget(details_widget, 1, 2) + + layout.setColumnStretch(2, 1) + + instances_view.selectionModel().selectionChanged.connect( + self._on_instance_change + ) + instances_view.clicked.connect(self._on_instance_view_clicked) + plugins_view.clicked.connect(self._on_plugin_view_clicked) + plugins_view.selectionModel().selectionChanged.connect( + self._on_plugin_change + ) + + skipped_plugins_check.stateChanged.connect( + self._on_skipped_plugin_check + ) + removed_instances_check.stateChanged.connect( + self._on_removed_instances_check + ) + + self._ignore_selection_changes = False + self._report_item = None + self._details_widget = details_widget + + self._removed_instances_check = removed_instances_check + self._instances_view = instances_view + self._instances_model = instances_model + self._instances_proxy = instances_proxy + + self._instances_delegate = instances_delegate + self._plugins_delegate = plugins_delegate + + self._skipped_plugins_check = skipped_plugins_check + self._plugins_view = plugins_view + self._plugins_model = plugins_model + self._plugins_proxy = plugins_proxy + + def _on_instance_view_clicked(self, index): + if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): + return + + if self._instances_view.isExpanded(index): + self._instances_view.collapse(index) + else: + self._instances_view.expand(index) + + def _on_plugin_view_clicked(self, index): + if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): + return + + if self._plugins_view.isExpanded(index): + self._plugins_view.collapse(index) + else: + self._plugins_view.expand(index) + + def set_report(self, report_data): + self._ignore_selection_changes = True + + report_item = PublishReport(report_data) + self._report_item = report_item + + self._instances_model.set_report(report_item) + self._plugins_model.set_report(report_item) + self._details_widget.set_logs(report_item.logs) + + self._ignore_selection_changes = False + + def _on_instance_change(self, *_args): + if self._ignore_selection_changes: + return + + valid_index = None + for index in self._instances_view.selectedIndexes(): + if index.isValid(): + valid_index = index + break + + if valid_index is None: + return + + if self._plugins_view.selectedIndexes(): + self._ignore_selection_changes = True + self._plugins_view.selectionModel().clearSelection() + self._ignore_selection_changes = False + + plugin_id = valid_index.data(ITEM_ID_ROLE) + instance_item = self._report_item.instance_items_by_id[plugin_id] + self._details_widget.set_logs(instance_item.logs) + + def _on_plugin_change(self, *_args): + if self._ignore_selection_changes: + return + + valid_index = None + for index in self._plugins_view.selectedIndexes(): + if index.isValid(): + valid_index = index + break + + if valid_index is None: + self._details_widget.set_logs(self._report_item.logs) + return + + if self._instances_view.selectedIndexes(): + self._ignore_selection_changes = True + self._instances_view.selectionModel().clearSelection() + self._ignore_selection_changes = False + + plugin_id = valid_index.data(ITEM_ID_ROLE) + plugin_item = self._report_item.plugins_items_by_id[plugin_id] + self._details_widget.set_logs(plugin_item.logs) + + def _on_skipped_plugin_check(self): + self._plugins_proxy.set_ignore_skipped( + self._skipped_plugins_check.isChecked() + ) + + def _on_removed_instances_check(self): + self._instances_proxy.set_ignore_removed( + self._removed_instances_check.isChecked() + ) diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py new file mode 100644 index 0000000000..7a0fef7d91 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -0,0 +1,29 @@ +from Qt import QtWidgets + +from openpype import style +if __package__: + from .widgets import PublishReportViewerWidget +else: + from widgets import PublishReportViewerWidget + + +class PublishReportViewerWindow(QtWidgets.QWidget): + # TODO add buttons to be able load report file or paste content of report + default_width = 1200 + default_height = 600 + + def __init__(self, parent=None): + super(PublishReportViewerWindow, self).__init__(parent) + + main_widget = PublishReportViewerWidget(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(main_widget) + + self._main_widget = main_widget + + self.resize(self.default_width, self.default_height) + self.setStyleSheet(style.load_stylesheet()) + + def set_report(self, report_data): + self._main_widget.set_report(report_data) diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py new file mode 100644 index 0000000000..9b22a6cf25 --- /dev/null +++ b/openpype/tools/publisher/widgets/__init__.py @@ -0,0 +1,64 @@ +from .icons import ( + get_icon_path, + get_pixmap, + get_icon +) +from .border_label_widget import ( + BorderedLabelWidget +) +from .widgets import ( + SubsetAttributesWidget, + + PixmapLabel, + + StopBtn, + ResetBtn, + ValidateBtn, + PublishBtn, + + CreateInstanceBtn, + RemoveInstanceBtn, + ChangeViewBtn +) +from .publish_widget import ( + PublishFrame +) +from .create_dialog import ( + CreateDialog +) + +from .card_view_widgets import ( + InstanceCardView +) + +from .list_view_widgets import ( + InstanceListView +) + + +__all__ = ( + "get_icon_path", + "get_pixmap", + "get_icon", + + "SubsetAttributesWidget", + "BorderedLabelWidget", + + "PixmapLabel", + + "StopBtn", + "ResetBtn", + "ValidateBtn", + "PublishBtn", + + "CreateInstanceBtn", + "RemoveInstanceBtn", + "ChangeViewBtn", + + "PublishFrame", + + "CreateDialog", + + "InstanceCardView", + "InstanceListView", +) diff --git a/openpype/tools/publisher/widgets/border_label_widget.py b/openpype/tools/publisher/widgets/border_label_widget.py new file mode 100644 index 0000000000..3d49af410a --- /dev/null +++ b/openpype/tools/publisher/widgets/border_label_widget.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors + + +class _VLineWidget(QtWidgets.QWidget): + """Widget drawing 1px wide vertical line. + + ``` β”‚ ``` + + Line is drawn in the middle of widget. + + It is expected that parent widget will set width. + """ + def __init__(self, color, left, parent): + super(_VLineWidget, self).__init__(parent) + self._color = color + self._left = left + + def paintEvent(self, event): + if not self.isVisible(): + return + + if self._left: + pos_x = 0 + else: + pos_x = self.width() + painter = QtGui.QPainter(self) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + ) + if self._color: + pen = QtGui.QPen(self._color) + else: + pen = painter.pen() + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawLine(pos_x, 0, pos_x, self.height()) + painter.end() + + +class _HBottomLineWidget(QtWidgets.QWidget): + """Widget drawing 1px wide vertical line with side lines going upwards. + + ```β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜``` + + Corners may have curve set by radius (`set_radius`). Radius should expect + height of widget. + + Bottom line is drawed at the bottom of widget. If radius is 0 then height + of widget should be 1px. + + It is expected that parent widget will set height and radius. + """ + def __init__(self, color, parent): + super(_HBottomLineWidget, self).__init__(parent) + self._color = color + self._radius = 0 + + def set_radius(self, radius): + self._radius = radius + + def paintEvent(self, event): + if not self.isVisible(): + return + + rect = QtCore.QRect( + 0, -self._radius, self.width(), self.height() + self._radius + ) + painter = QtGui.QPainter(self) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + ) + if self._color: + pen = QtGui.QPen(self._color) + else: + pen = painter.pen() + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawRoundedRect(rect, self._radius, self._radius) + painter.end() + + +class _HTopCornerLineWidget(QtWidgets.QWidget): + """Widget drawing 1px wide horizontal line with side line going downwards. + + ```────────┐``` + or + ```β”Œβ”€β”€β”€β”€β”€β”€β”€``` + + Horizontal line is drawed in the middle of widget. + + Widget represents left or right corner. Corner may have curve set by + radius (`set_radius`). Radius should expect height of widget (maximum half + height of widget). + + It is expected that parent widget will set height and radius. + """ + def __init__(self, color, left_side, parent): + super(_HTopCornerLineWidget, self).__init__(parent) + self._left_side = left_side + self._color = color + self._radius = 0 + + def set_radius(self, radius): + self._radius = radius + + def paintEvent(self, event): + if not self.isVisible(): + return + + pos_y = self.height() / 2 + + if self._left_side: + rect = QtCore.QRect( + 0, pos_y, self.width() + self._radius, self.height() + ) + else: + rect = QtCore.QRect( + -self._radius, + pos_y, + self.width() + self._radius, + self.height() + ) + + painter = QtGui.QPainter(self) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + ) + if self._color: + pen = QtGui.QPen(self._color) + else: + pen = painter.pen() + pen.setWidth(1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + painter.drawRoundedRect(rect, self._radius, self._radius) + painter.end() + + +class BorderedLabelWidget(QtWidgets.QFrame): + """Draws borders around widget with label in the middle of top. + + β”Œβ”€β”€β”€β”€β”€β”€β”€ Label ────────┐ + β”‚ β”‚ + β”‚ β”‚ + β”‚ CONTENT β”‚ + β”‚ β”‚ + β”‚ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + """ + def __init__(self, label, parent): + super(BorderedLabelWidget, self).__init__(parent) + colors_data = get_objected_colors() + color_value = colors_data.get("border") + color = None + if color_value: + color = color_value.get_qcolor() + + top_left_w = _HTopCornerLineWidget(color, True, self) + top_right_w = _HTopCornerLineWidget(color, False, self) + + label_widget = QtWidgets.QLabel(label, self) + + top_layout = QtWidgets.QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.setSpacing(5) + top_layout.addWidget(top_left_w, 1) + top_layout.addWidget(label_widget, 0) + top_layout.addWidget(top_right_w, 1) + + left_w = _VLineWidget(color, True, self) + right_w = _VLineWidget(color, False, self) + + bottom_w = _HBottomLineWidget(color, self) + + center_layout = QtWidgets.QHBoxLayout() + center_layout.setContentsMargins(5, 5, 5, 5) + + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + layout.addLayout(top_layout, 0, 0, 1, 3) + + layout.addWidget(left_w, 1, 0) + layout.addLayout(center_layout, 1, 1) + layout.addWidget(right_w, 1, 2) + + layout.addWidget(bottom_w, 2, 0, 1, 3) + + layout.setColumnStretch(1, 1) + layout.setRowStretch(1, 1) + + self._widget = None + + self._radius = 0 + + self._top_left_w = top_left_w + self._top_right_w = top_right_w + self._left_w = left_w + self._right_w = right_w + self._bottom_w = bottom_w + self._label_widget = label_widget + self._center_layout = center_layout + + def set_content_margins(self, value): + """Set margins around content.""" + self._center_layout.setContentsMargins( + value, value, value, value + ) + + def showEvent(self, event): + super(BorderedLabelWidget, self).showEvent(event) + + height = self._label_widget.height() + radius = (height + (height % 2)) / 2 + self._radius = radius + + side_width = 1 + radius + # Dont't use fixed width/height as that would set also set + # the other size (When fixed width is set then is also set + # fixed height). + self._left_w.setMinimumWidth(side_width) + self._left_w.setMaximumWidth(side_width) + self._right_w.setMinimumWidth(side_width) + self._right_w.setMaximumWidth(side_width) + self._bottom_w.setMinimumHeight(radius) + self._bottom_w.setMaximumHeight(radius) + self._bottom_w.set_radius(radius) + self._top_right_w.set_radius(radius) + self._top_left_w.set_radius(radius) + if self._widget: + self._widget.update() + + def set_center_widget(self, widget): + """Set content widget and add it to center.""" + while self._center_layout.count(): + item = self._center_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + self._widget = widget + if isinstance(widget, QtWidgets.QLayout): + self._center_layout.addLayout(widget) + else: + self._center_layout.addWidget(widget) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py new file mode 100644 index 0000000000..271d06e94c --- /dev/null +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -0,0 +1,539 @@ +# -*- coding: utf-8 -*- +"""Card view instance with more information about each instance. + +Instances are grouped under groups. Groups are defined by `creator_label` +attribute on instance (Group defined by creator). + +Only one item can be selected at a time. + +``` + : Icon. Can have Warning icon when context is not right +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Options β”‚ +β”‚ ────────── β”‚ +β”‚ [x]β”‚ +β”‚ [x]β”‚ +β”‚ ────────── β”‚ +β”‚ [x]β”‚ +β”‚ ... β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` +""" + +import re +import collections + +from Qt import QtWidgets, QtCore + +from openpype.widgets.nice_checkbox import NiceCheckbox + +from .widgets import ( + AbstractInstanceView, + ContextWarningLabel, + ClickableFrame, + IconValuePixmapLabel, + TransparentPixmapLabel +) +from ..constants import ( + CONTEXT_ID, + CONTEXT_LABEL +) + + +class GroupWidget(QtWidgets.QWidget): + """Widget wrapping instances under group.""" + selected = QtCore.Signal(str, str) + active_changed = QtCore.Signal() + removed_selected = QtCore.Signal() + + def __init__(self, group_name, group_icons, parent): + super(GroupWidget, self).__init__(parent) + + label_widget = QtWidgets.QLabel(group_name, self) + + line_widget = QtWidgets.QWidget(self) + line_widget.setObjectName("Separator") + line_widget.setMinimumHeight(2) + line_widget.setMaximumHeight(2) + + label_layout = QtWidgets.QHBoxLayout() + label_layout.setAlignment(QtCore.Qt.AlignVCenter) + label_layout.setSpacing(10) + label_layout.setContentsMargins(0, 0, 0, 0) + label_layout.addWidget(label_widget, 0) + label_layout.addWidget(line_widget, 1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(label_layout, 0) + + self._group = group_name + self._group_icons = group_icons + + self._widgets_by_id = {} + + self._label_widget = label_widget + self._content_layout = layout + + def get_widget_by_instance_id(self, instance_id): + """Get instance widget by it's id.""" + return self._widgets_by_id.get(instance_id) + + def update_instance_values(self): + """Trigger update on instance widgets.""" + for widget in self._widgets_by_id.values(): + widget.update_instance_values() + + def confirm_remove_instance_id(self, instance_id): + """Delete widget by instance id.""" + widget = self._widgets_by_id.pop(instance_id) + widget.setVisible(False) + self._content_layout.removeWidget(widget) + widget.deleteLater() + + def update_instances(self, instances): + """Update instances for the group. + + Args: + instances(list): List of instances in + CreateContext. + """ + # Store instances by id and by subset name + instances_by_id = {} + instances_by_subset_name = collections.defaultdict(list) + for instance in instances: + instances_by_id[instance.id] = instance + subset_name = instance["subset"] + instances_by_subset_name[subset_name].append(instance) + + # Remove instance widgets that are not in passed instances + for instance_id in tuple(self._widgets_by_id.keys()): + if instance_id in instances_by_id: + continue + + widget = self._widgets_by_id.pop(instance_id) + if widget.is_selected: + self.removed_selected.emit() + + widget.setVisible(False) + self._content_layout.removeWidget(widget) + widget.deleteLater() + + # Sort instances by subset name + sorted_subset_names = list(sorted(instances_by_subset_name.keys())) + # Add new instances to widget + widget_idx = 1 + for subset_names in sorted_subset_names: + for instance in instances_by_subset_name[subset_names]: + if instance.id in self._widgets_by_id: + widget = self._widgets_by_id[instance.id] + widget.update_instance(instance) + else: + group_icon = self._group_icons[instance.creator_identifier] + widget = InstanceCardWidget( + instance, group_icon, self + ) + widget.selected.connect(self.selected) + widget.active_changed.connect(self.active_changed) + self._widgets_by_id[instance.id] = widget + self._content_layout.insertWidget(widget_idx, widget) + widget_idx += 1 + + +class CardWidget(ClickableFrame): + """Clickable card used as bigger button.""" + selected = QtCore.Signal(str, str) + # Group identifier of card + # - this must be set because if send when mouse is released with card id + _group_identifier = None + + def __init__(self, parent): + super(CardWidget, self).__init__(parent) + self.setObjectName("CardViewWidget") + + self._selected = False + self._id = None + + @property + def is_selected(self): + """Is card selected.""" + return self._selected + + def set_selected(self, selected): + """Set card as selected.""" + if selected == self._selected: + return + self._selected = selected + state = "selected" if selected else "" + self.setProperty("state", state) + self.style().polish(self) + + def _mouse_release_callback(self): + """Trigger selected signal.""" + self.selected.emit(self._id, self._group_identifier) + + +class ContextCardWidget(CardWidget): + """Card for global context. + + Is not visually under group widget and is always at the top of card view. + """ + def __init__(self, parent): + super(ContextCardWidget, self).__init__(parent) + + self._id = CONTEXT_ID + self._group_identifier = "" + + icon_widget = TransparentPixmapLabel(self) + icon_widget.setObjectName("FamilyIconLabel") + + label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) + + icon_layout = QtWidgets.QHBoxLayout() + icon_layout.setContentsMargins(5, 5, 5, 5) + icon_layout.addWidget(icon_widget) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 5, 10, 5) + layout.addLayout(icon_layout, 0) + layout.addWidget(label_widget, 1) + + self._icon_widget = icon_widget + self._label_widget = label_widget + + +class InstanceCardWidget(CardWidget): + """Card widget representing instance.""" + active_changed = QtCore.Signal() + + def __init__(self, instance, group_icon, parent): + super(InstanceCardWidget, self).__init__(parent) + + self._id = instance.id + self._group_identifier = instance.creator_label + self._group_icon = group_icon + + self.instance = instance + + self._last_subset_name = None + self._last_variant = None + + icon_widget = IconValuePixmapLabel(group_icon, self) + icon_widget.setObjectName("FamilyIconLabel") + context_warning = ContextWarningLabel(self) + + icon_layout = QtWidgets.QHBoxLayout() + icon_layout.setContentsMargins(10, 5, 5, 5) + icon_layout.addWidget(icon_widget) + icon_layout.addWidget(context_warning) + + label_widget = QtWidgets.QLabel(self) + active_checkbox = NiceCheckbox(parent=self) + + expand_btn = QtWidgets.QToolButton(self) + # Not yet implemented + expand_btn.setVisible(False) + expand_btn.setObjectName("ArrowBtn") + expand_btn.setArrowType(QtCore.Qt.DownArrow) + expand_btn.setMaximumWidth(14) + expand_btn.setEnabled(False) + + detail_widget = QtWidgets.QWidget(self) + detail_widget.setVisible(False) + self.detail_widget = detail_widget + + top_layout = QtWidgets.QHBoxLayout() + top_layout.addLayout(icon_layout, 0) + top_layout.addWidget(label_widget, 1) + top_layout.addWidget(context_warning, 0) + top_layout.addWidget(active_checkbox, 0) + top_layout.addWidget(expand_btn, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 5, 10, 5) + layout.addLayout(top_layout) + layout.addWidget(detail_widget) + + active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground) + expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + active_checkbox.stateChanged.connect(self._on_active_change) + expand_btn.clicked.connect(self._on_expend_clicked) + + self._icon_widget = icon_widget + self._label_widget = label_widget + self._context_warning = context_warning + self._active_checkbox = active_checkbox + self._expand_btn = expand_btn + + self.update_instance_values() + + def set_active(self, new_value): + """Set instance as active.""" + checkbox_value = self._active_checkbox.isChecked() + instance_value = self.instance["active"] + + # First change instance value and them change checkbox + # - prevent to trigger `active_changed` signal + if instance_value != new_value: + self.instance["active"] = new_value + + if checkbox_value != new_value: + self._active_checkbox.setChecked(new_value) + + def update_instance(self, instance): + """Update instance object and update UI.""" + self.instance = instance + self.update_instance_values() + + def _validate_context(self): + valid = self.instance.has_valid_context + self._icon_widget.setVisible(valid) + self._context_warning.setVisible(not valid) + + def _update_subset_name(self): + variant = self.instance["variant"] + subset_name = self.instance["subset"] + if ( + variant == self._last_variant + and subset_name == self._last_subset_name + ): + return + + self._last_variant = variant + self._last_subset_name = subset_name + # Make `variant` bold + found_parts = set(re.findall(variant, subset_name, re.IGNORECASE)) + if found_parts: + for part in found_parts: + replacement = "{}".format(part) + subset_name = subset_name.replace(part, replacement) + + self._label_widget.setText(subset_name) + # HTML text will cause that label start catch mouse clicks + # - disabling with changing interaction flag + self._label_widget.setTextInteractionFlags( + QtCore.Qt.NoTextInteraction + ) + + def update_instance_values(self): + """Update instance data""" + self._update_subset_name() + self.set_active(self.instance["active"]) + self._validate_context() + + def _set_expanded(self, expanded=None): + if expanded is None: + expanded = not self.detail_widget.isVisible() + self.detail_widget.setVisible(expanded) + + def _on_active_change(self): + new_value = self._active_checkbox.isChecked() + old_value = self.instance["active"] + if new_value == old_value: + return + + self.instance["active"] = new_value + self.active_changed.emit() + + def _on_expend_clicked(self): + self._set_expanded() + + +class InstanceCardView(AbstractInstanceView): + """Publish access to card view. + + Wrapper of all widgets in card view. + """ + def __init__(self, controller, parent): + super(InstanceCardView, self).__init__(parent) + + self.controller = controller + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scrollbar_bg = scroll_area.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + scroll_area.setViewportMargins(0, 0, 0, 0) + + content_widget = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(content_widget) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area) + + self._scroll_area = scroll_area + self._content_layout = content_layout + self._content_widget = content_widget + + self._widgets_by_group = {} + self._context_widget = None + + self._selected_group = None + self._selected_instance_id = None + + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + self.sizePolicy().verticalPolicy() + ) + + def sizeHint(self): + """Modify sizeHint based on visibility of scroll bars.""" + # Calculate width hint by content widget and verticall scroll bar + scroll_bar = self._scroll_area.verticalScrollBar() + width = ( + self._content_widget.sizeHint().width() + + scroll_bar.sizeHint().width() + ) + + result = super(InstanceCardView, self).sizeHint() + result.setWidth(width) + return result + + def _get_selected_widget(self): + if self._selected_instance_id == CONTEXT_ID: + return self._context_widget + + group_widget = self._widgets_by_group.get( + self._selected_group + ) + if group_widget is not None: + widget = group_widget.get_widget_by_instance_id( + self._selected_instance_id + ) + if widget is not None: + return widget + + return None + + def refresh(self): + """Refresh instances in view based on CreatedContext.""" + # Create context item if is not already existing + # - this must be as first thing to do as context item should be at the + # top + if self._context_widget is None: + widget = ContextCardWidget(self._content_widget) + widget.selected.connect(self._on_widget_selection) + + self._context_widget = widget + + self.selection_changed.emit() + self._content_layout.insertWidget(0, widget) + + self.select_item(CONTEXT_ID, None) + + # Prepare instances by group and identifiers by group + instances_by_group = collections.defaultdict(list) + identifiers_by_group = collections.defaultdict(set) + for instance in self.controller.instances: + group_name = instance.creator_label + instances_by_group[group_name].append(instance) + identifiers_by_group[group_name].add( + instance.creator_identifier + ) + + # Remove groups that were not found in apassed instances + for group_name in tuple(self._widgets_by_group.keys()): + if group_name in instances_by_group: + continue + + if group_name == self._selected_group: + self._on_remove_selected() + widget = self._widgets_by_group.pop(group_name) + widget.setVisible(False) + self._content_layout.removeWidget(widget) + widget.deleteLater() + + # Sort groups + sorted_group_names = list(sorted(instances_by_group.keys())) + # Keep track of widget indexes + # - we start with 1 because Context item as at the top + widget_idx = 1 + for group_name in sorted_group_names: + if group_name in self._widgets_by_group: + group_widget = self._widgets_by_group[group_name] + else: + group_icons = { + idenfier: self.controller.get_icon_for_family(idenfier) + for idenfier in identifiers_by_group[group_name] + } + + group_widget = GroupWidget( + group_name, group_icons, self._content_widget + ) + group_widget.active_changed.connect(self._on_active_changed) + group_widget.selected.connect(self._on_widget_selection) + group_widget.removed_selected.connect( + self._on_remove_selected + ) + self._content_layout.insertWidget(widget_idx, group_widget) + self._widgets_by_group[group_name] = group_widget + + widget_idx += 1 + group_widget.update_instances( + instances_by_group[group_name] + ) + + def refresh_instance_states(self): + """Trigger update of instances on group widgets.""" + for widget in self._widgets_by_group.values(): + widget.update_instance_values() + + def _on_active_changed(self): + self.active_changed.emit() + + def _on_widget_selection(self, instance_id, group_name): + self.select_item(instance_id, group_name) + + def select_item(self, instance_id, group_name): + """Select specific item by instance id. + + Pass `CONTEXT_ID` as instance id and empty string as group to select + global context item. + """ + if instance_id == CONTEXT_ID: + new_widget = self._context_widget + else: + group_widget = self._widgets_by_group[group_name] + new_widget = group_widget.get_widget_by_instance_id(instance_id) + + selected_widget = self._get_selected_widget() + if new_widget is selected_widget: + return + + if selected_widget is not None: + selected_widget.set_selected(False) + + self._selected_instance_id = instance_id + self._selected_group = group_name + if new_widget is not None: + new_widget.set_selected(True) + + self.selection_changed.emit() + + def _on_remove_selected(self): + selected_widget = self._get_selected_widget() + if selected_widget is None: + self._on_widget_selection(CONTEXT_ID, None) + + def get_selected_items(self): + """Get selected instance ids and context.""" + instances = [] + context_selected = False + selected_widget = self._get_selected_widget() + if selected_widget is self._context_widget: + context_selected = True + + elif selected_widget is not None: + instances.append(selected_widget.instance) + + return instances, context_selected diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py new file mode 100644 index 0000000000..0206f038fb --- /dev/null +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -0,0 +1,559 @@ +import sys +import re +import traceback +import copy + +try: + import commonmark +except Exception: + commonmark = None +from Qt import QtWidgets, QtCore, QtGui + +from openpype.pipeline.create import CreatorError + +from .widgets import IconValuePixmapLabel +from ..constants import ( + SUBSET_NAME_ALLOWED_SYMBOLS, + VARIANT_TOOLTIP, + CREATOR_IDENTIFIER_ROLE, + FAMILY_ROLE +) + +SEPARATORS = ("---separator---", "---") + + +class CreateErrorMessageBox(QtWidgets.QDialog): + def __init__( + self, + creator_label, + subset_name, + asset_name, + exc_msg, + formatted_traceback, + parent=None + ): + super(CreateErrorMessageBox, self).__init__(parent) + self.setWindowTitle("Creation failed") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + if not parent: + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint + ) + + body_layout = QtWidgets.QVBoxLayout(self) + + main_label = ( + "Failed to create" + ) + main_label_widget = QtWidgets.QLabel(main_label, self) + body_layout.addWidget(main_label_widget) + + item_name_template = ( + "Creator: {}
" + "Subset: {}
" + "Asset: {}
" + ) + exc_msg_template = "{}" + + line = self._create_line() + body_layout.addWidget(line) + + item_name = item_name_template.format( + creator_label, subset_name, asset_name + ) + item_name_widget = QtWidgets.QLabel( + item_name.replace("\n", "
"), self + ) + body_layout.addWidget(item_name_widget) + + exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) + message_label_widget = QtWidgets.QLabel(exc_msg, self) + body_layout.addWidget(message_label_widget) + + if formatted_traceback: + tb_widget = QtWidgets.QLabel( + formatted_traceback.replace("\n", "
"), self + ) + tb_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + body_layout.addWidget(tb_widget) + + footer_widget = QtWidgets.QWidget(self) + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + button_box = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical) + button_box.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Ok + ) + button_box.accepted.connect(self._on_accept) + footer_layout.addWidget(button_box, alignment=QtCore.Qt.AlignRight) + body_layout.addWidget(footer_widget) + + def _on_accept(self): + self.close() + + def _create_line(self): + line = QtWidgets.QFrame(self) + line.setFixedHeight(2) + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + return line + + +# TODO add creator identifier/label to details +class CreatorDescriptionWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super(CreatorDescriptionWidget, self).__init__(parent=parent) + + icon_widget = IconValuePixmapLabel(None, self) + icon_widget.setObjectName("FamilyIconLabel") + + family_label = QtWidgets.QLabel("family") + family_label.setAlignment( + QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft + ) + + description_label = QtWidgets.QLabel("description") + description_label.setAlignment( + QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft + ) + + detail_description_widget = QtWidgets.QTextEdit(self) + detail_description_widget.setObjectName("InfoText") + detail_description_widget.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + label_layout = QtWidgets.QVBoxLayout() + label_layout.setSpacing(0) + label_layout.addWidget(family_label) + label_layout.addWidget(description_label) + + top_layout = QtWidgets.QHBoxLayout() + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget(icon_widget, 0) + top_layout.addLayout(label_layout, 1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(top_layout, 0) + layout.addWidget(detail_description_widget, 1) + + self.icon_widget = icon_widget + self.family_label = family_label + self.description_label = description_label + self.detail_description_widget = detail_description_widget + + def set_plugin(self, plugin=None): + if not plugin: + self.icon_widget.set_icon_def(None) + self.family_label.setText("") + self.description_label.setText("") + self.detail_description_widget.setPlainText("") + return + + plugin_icon = plugin.get_icon() + description = plugin.get_description() or "" + detailed_description = plugin.get_detail_description() or "" + + self.icon_widget.set_icon_def(plugin_icon) + self.family_label.setText("{}".format(plugin.family)) + self.family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.description_label.setText(description) + + if commonmark: + html = commonmark.commonmark(detailed_description) + self.detail_description_widget.setHtml(html) + else: + self.detail_description_widget.setMarkdown(detailed_description) + + +class CreateDialog(QtWidgets.QDialog): + def __init__( + self, controller, asset_name=None, task_name=None, parent=None + ): + super(CreateDialog, self).__init__(parent) + + self.setWindowTitle("Create new instance") + + self.controller = controller + + if asset_name is None: + asset_name = self.dbcon.Session.get("AVALON_ASSET") + + if task_name is None: + task_name = self.dbcon.Session.get("AVALON_TASK") + + self._asset_name = asset_name + self._task_name = task_name + + self._last_pos = None + self._asset_doc = None + self._subset_names = None + self._selected_creator = None + + self._prereq_available = False + + self.message_dialog = None + + name_pattern = "^[{}]*$".format(SUBSET_NAME_ALLOWED_SYMBOLS) + self._name_pattern = name_pattern + self._compiled_name_pattern = re.compile(name_pattern) + + creator_description_widget = CreatorDescriptionWidget(self) + + creators_view = QtWidgets.QListView(self) + creators_model = QtGui.QStandardItemModel() + creators_view.setModel(creators_model) + + variant_input = QtWidgets.QLineEdit(self) + variant_input.setObjectName("VariantInput") + variant_input.setToolTip(VARIANT_TOOLTIP) + + variant_hints_btn = QtWidgets.QPushButton(self) + variant_hints_btn.setFixedWidth(18) + + variant_hints_menu = QtWidgets.QMenu(variant_hints_btn) + variant_hints_group = QtWidgets.QActionGroup(variant_hints_menu) + variant_hints_btn.setMenu(variant_hints_menu) + + variant_layout = QtWidgets.QHBoxLayout() + variant_layout.setContentsMargins(0, 0, 0, 0) + variant_layout.setSpacing(0) + variant_layout.addWidget(variant_input, 1) + variant_layout.addWidget(variant_hints_btn, 0) + + subset_name_input = QtWidgets.QLineEdit(self) + subset_name_input.setEnabled(False) + + create_btn = QtWidgets.QPushButton("Create", self) + create_btn.setEnabled(False) + + form_layout = QtWidgets.QFormLayout() + form_layout.addRow("Name:", variant_layout) + form_layout.addRow("Subset:", subset_name_input) + + left_layout = QtWidgets.QVBoxLayout() + left_layout.addWidget(QtWidgets.QLabel("Choose family:", self)) + left_layout.addWidget(creators_view, 1) + left_layout.addLayout(form_layout, 0) + left_layout.addWidget(create_btn, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.addLayout(left_layout, 0) + layout.addSpacing(5) + layout.addWidget(creator_description_widget, 1) + + create_btn.clicked.connect(self._on_create) + variant_input.returnPressed.connect(self._on_create) + variant_input.textChanged.connect(self._on_variant_change) + creators_view.selectionModel().currentChanged.connect( + self._on_item_change + ) + variant_hints_menu.triggered.connect(self._on_variant_action) + + controller.add_plugins_refresh_callback(self._on_plugins_refresh) + + self.creator_description_widget = creator_description_widget + + self.subset_name_input = subset_name_input + + self.variant_input = variant_input + self.variant_hints_btn = variant_hints_btn + self.variant_hints_menu = variant_hints_menu + self.variant_hints_group = variant_hints_group + + self.creators_model = creators_model + self.creators_view = creators_view + self.create_btn = create_btn + + @property + def dbcon(self): + return self.controller.dbcon + + def refresh(self): + self._prereq_available = True + + # Refresh data before update of creators + self._refresh_asset() + # Then refresh creators which may trigger callbacks using refreshed + # data + self._refresh_creators() + + if self._asset_doc is None: + # QUESTION how to handle invalid asset? + self.subset_name_input.setText("< Asset is not set >") + self._prereq_available = False + + if self.creators_model.rowCount() < 1: + self._prereq_available = False + + self.create_btn.setEnabled(self._prereq_available) + self.creators_view.setEnabled(self._prereq_available) + self.variant_input.setEnabled(self._prereq_available) + self.variant_hints_btn.setEnabled(self._prereq_available) + + def _refresh_asset(self): + asset_name = self._asset_name + + # Skip if asset did not change + if self._asset_doc and self._asset_doc["name"] == asset_name: + return + + # Make sure `_asset_doc` and `_subset_names` variables are reset + self._asset_doc = None + self._subset_names = None + if asset_name is None: + return + + asset_doc = self.dbcon.find_one({ + "type": "asset", + "name": asset_name + }) + self._asset_doc = asset_doc + + if asset_doc: + subset_docs = self.dbcon.find( + { + "type": "subset", + "parent": asset_doc["_id"] + }, + {"name": 1} + ) + self._subset_names = set(subset_docs.distinct("name")) + + def _refresh_creators(self): + # Refresh creators and add their families to list + existing_items = {} + old_creators = set() + for row in range(self.creators_model.rowCount()): + item = self.creators_model.item(row, 0) + identifier = item.data(CREATOR_IDENTIFIER_ROLE) + existing_items[identifier] = item + old_creators.add(identifier) + + # Add new families + new_creators = set() + for identifier, creator in self.controller.manual_creators.items(): + # TODO add details about creator + new_creators.add(identifier) + if identifier in existing_items: + item = existing_items[identifier] + else: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + self.creators_model.appendRow(item) + + label = creator.label or identifier + item.setData(label, QtCore.Qt.DisplayRole) + item.setData(identifier, CREATOR_IDENTIFIER_ROLE) + item.setData(creator.family, FAMILY_ROLE) + + # Remove families that are no more available + for identifier in (old_creators - new_creators): + item = existing_items[identifier] + self.creators_model.takeRow(item.row()) + + if self.creators_model.rowCount() < 1: + return + + # Make sure there is a selection + indexes = self.creators_view.selectedIndexes() + if not indexes: + index = self.creators_model.index(0, 0) + self.creators_view.setCurrentIndex(index) + + def _on_plugins_refresh(self): + # Trigger refresh only if is visible + if self.isVisible(): + self.refresh() + + def _on_item_change(self, new_index, _old_index): + identifier = None + if new_index.isValid(): + identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) + + creator = self.controller.manual_creators.get(identifier) + + self.creator_description_widget.set_plugin(creator) + + self._selected_creator = creator + if not creator: + return + + default_variants = creator.get_default_variants() + if not default_variants: + default_variants = ["Main"] + + default_variant = creator.get_default_variant() + if not default_variant: + default_variant = default_variants[0] + + for action in tuple(self.variant_hints_menu.actions()): + self.variant_hints_menu.removeAction(action) + action.deleteLater() + + for variant in default_variants: + if variant in SEPARATORS: + self.variant_hints_menu.addSeparator() + elif variant: + self.variant_hints_menu.addAction(variant) + + self.variant_input.setText(default_variant or "Main") + + def _on_variant_action(self, action): + value = action.text() + if self.variant_input.text() != value: + self.variant_input.setText(value) + + def _on_variant_change(self, variant_value): + if not self._prereq_available or not self._selected_creator: + if self.subset_name_input.text(): + self.subset_name_input.setText("") + return + + match = self._compiled_name_pattern.match(variant_value) + valid = bool(match) + self.create_btn.setEnabled(valid) + if not valid: + self._set_variant_state_property("invalid") + self.subset_name_input.setText("< Invalid variant >") + return + + project_name = self.controller.project_name + task_name = self._task_name + + asset_doc = copy.deepcopy(self._asset_doc) + # Calculate subset name with Creator plugin + subset_name = self._selected_creator.get_subset_name( + variant_value, task_name, asset_doc, project_name + ) + self.subset_name_input.setText(subset_name) + + self._validate_subset_name(subset_name, variant_value) + + def _validate_subset_name(self, subset_name, variant_value): + # Get all subsets of the current asset + if self._subset_names: + existing_subset_names = set(self._subset_names) + else: + existing_subset_names = set() + existing_subset_names_low = set( + _name.lower() + for _name in existing_subset_names + ) + + # Replace + compare_regex = re.compile(re.sub( + variant_value, "(.+)", subset_name, flags=re.IGNORECASE + )) + variant_hints = set() + if variant_value: + for _name in existing_subset_names: + _result = compare_regex.search(_name) + if _result: + variant_hints |= set(_result.groups()) + + # Remove previous hints from menu + for action in tuple(self.variant_hints_group.actions()): + self.variant_hints_group.removeAction(action) + self.variant_hints_menu.removeAction(action) + action.deleteLater() + + # Add separator if there are hints and menu already has actions + if variant_hints and self.variant_hints_menu.actions(): + self.variant_hints_menu.addSeparator() + + # Add hints to actions + for variant_hint in variant_hints: + action = self.variant_hints_menu.addAction(variant_hint) + self.variant_hints_group.addAction(action) + + # Indicate subset existence + if not variant_value: + property_value = "empty" + + elif subset_name.lower() in existing_subset_names_low: + # validate existence of subset name with lowered text + # - "renderMain" vs. "rendermain" mean same path item for + # windows + property_value = "exists" + else: + property_value = "new" + + self._set_variant_state_property(property_value) + + variant_is_valid = variant_value.strip() != "" + if variant_is_valid != self.create_btn.isEnabled(): + self.create_btn.setEnabled(variant_is_valid) + + def _set_variant_state_property(self, state): + current_value = self.variant_input.property("state") + if current_value != state: + self.variant_input.setProperty("state", state) + self.variant_input.style().polish(self.variant_input) + + def moveEvent(self, event): + super(CreateDialog, self).moveEvent(event) + self._last_pos = self.pos() + + def showEvent(self, event): + super(CreateDialog, self).showEvent(event) + if self._last_pos is not None: + self.move(self._last_pos) + + self.refresh() + + def _on_create(self): + indexes = self.creators_view.selectedIndexes() + if not indexes or len(indexes) > 1: + return + + if not self.create_btn.isEnabled(): + return + + index = indexes[0] + creator_label = index.data(QtCore.Qt.DisplayRole) + creator_identifier = index.data(CREATOR_IDENTIFIER_ROLE) + family = index.data(FAMILY_ROLE) + subset_name = self.subset_name_input.text() + variant = self.variant_input.text() + asset_name = self._asset_name + task_name = self._task_name + options = {} + # Where to define these data? + # - what data show be stored? + instance_data = { + "asset": asset_name, + "task": task_name, + "variant": variant, + "family": family + } + + error_info = None + try: + self.controller.create( + creator_identifier, subset_name, instance_data, options + ) + + except CreatorError as exc: + error_info = (str(exc), None) + + # Use bare except because some hosts raise their exceptions that + # do not inherit from python's `BaseException` + except: + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info = (str(exc_value), formatted_traceback) + + if error_info: + box = CreateErrorMessageBox( + creator_label, subset_name, asset_name, *error_info + ) + box.show() + # Store dialog so is not garbage collected before is shown + self.message_dialog = box diff --git a/openpype/tools/publisher/widgets/icons.py b/openpype/tools/publisher/widgets/icons.py new file mode 100644 index 0000000000..fd5c45f901 --- /dev/null +++ b/openpype/tools/publisher/widgets/icons.py @@ -0,0 +1,45 @@ +import os + +from Qt import QtGui + + +def get_icon_path(icon_name=None, filename=None): + """Path to image in './images' folder.""" + if icon_name is None and filename is None: + return None + + if filename is None: + filename = "{}.png".format(icon_name) + + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "images", + filename + ) + if os.path.exists(path): + return path + return None + + +def get_image(icon_name=None, filename=None): + """Load image from './images' as QImage.""" + path = get_icon_path(icon_name, filename) + if path: + return QtGui.QImage(path) + return None + + +def get_pixmap(icon_name=None, filename=None): + """Load image from './images' as QPixmap.""" + path = get_icon_path(icon_name, filename) + if path: + return QtGui.QPixmap(path) + return None + + +def get_icon(icon_name=None, filename=None): + """Load image from './images' as QICon.""" + pix = get_pixmap(icon_name, filename) + if pix: + return QtGui.QIcon(pix) + return None diff --git a/openpype/tools/publisher/widgets/images/add.png b/openpype/tools/publisher/widgets/images/add.png new file mode 100644 index 0000000000..7fece2f3c6 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/add.png differ diff --git a/openpype/tools/publisher/widgets/images/branch_closed.png b/openpype/tools/publisher/widgets/images/branch_closed.png new file mode 100644 index 0000000000..135cd0b29d Binary files /dev/null and b/openpype/tools/publisher/widgets/images/branch_closed.png differ diff --git a/openpype/tools/publisher/widgets/images/branch_open.png b/openpype/tools/publisher/widgets/images/branch_open.png new file mode 100644 index 0000000000..1a83955306 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/branch_open.png differ diff --git a/openpype/tools/publisher/widgets/images/change_view.png b/openpype/tools/publisher/widgets/images/change_view.png new file mode 100644 index 0000000000..bda0ef1689 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/change_view.png differ diff --git a/openpype/tools/publisher/widgets/images/copy.png b/openpype/tools/publisher/widgets/images/copy.png new file mode 100644 index 0000000000..522afcdc87 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/copy.png differ diff --git a/openpype/tools/publisher/widgets/images/delete.png b/openpype/tools/publisher/widgets/images/delete.png new file mode 100644 index 0000000000..ab02768ba3 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/delete.png differ diff --git a/openpype/tools/publisher/widgets/images/download_arrow.png b/openpype/tools/publisher/widgets/images/download_arrow.png new file mode 100644 index 0000000000..a35a12fb39 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/download_arrow.png differ diff --git a/openpype/tools/publisher/widgets/images/minus.png b/openpype/tools/publisher/widgets/images/minus.png new file mode 100644 index 0000000000..4d0d6f486c Binary files /dev/null and b/openpype/tools/publisher/widgets/images/minus.png differ diff --git a/openpype/tools/publisher/widgets/images/play.png b/openpype/tools/publisher/widgets/images/play.png new file mode 100644 index 0000000000..7019bf19e9 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/play.png differ diff --git a/openpype/tools/publisher/widgets/images/refresh.png b/openpype/tools/publisher/widgets/images/refresh.png new file mode 100644 index 0000000000..0b7f1565a7 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/refresh.png differ diff --git a/openpype/tools/publisher/widgets/images/stop.png b/openpype/tools/publisher/widgets/images/stop.png new file mode 100644 index 0000000000..eda18d1db1 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/stop.png differ diff --git a/openpype/tools/publisher/widgets/images/thumbnail.png b/openpype/tools/publisher/widgets/images/thumbnail.png new file mode 100644 index 0000000000..adea862e5b Binary files /dev/null and b/openpype/tools/publisher/widgets/images/thumbnail.png differ diff --git a/openpype/tools/publisher/widgets/images/validate.png b/openpype/tools/publisher/widgets/images/validate.png new file mode 100644 index 0000000000..d3cfa0b75d Binary files /dev/null and b/openpype/tools/publisher/widgets/images/validate.png differ diff --git a/openpype/tools/publisher/widgets/images/view_report.png b/openpype/tools/publisher/widgets/images/view_report.png new file mode 100644 index 0000000000..50e214c3f8 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/view_report.png differ diff --git a/openpype/tools/publisher/widgets/images/warning.png b/openpype/tools/publisher/widgets/images/warning.png new file mode 100644 index 0000000000..76d1e34b6c Binary files /dev/null and b/openpype/tools/publisher/widgets/images/warning.png differ diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py new file mode 100644 index 0000000000..e87ea3e130 --- /dev/null +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -0,0 +1,815 @@ +"""Simple easy instance view grouping instances into collapsible groups. + +View has multiselection ability. Groups are defined by `creator_label` +attribute on instance (Group defined by creator). + +Each item can be enabled/disabled with their checkbox, whole group +can be enabled/disabled with checkbox on group or +selection can be enabled disabled using checkbox or keyboard key presses: +- Space - change state of selection to oposite +- Enter - enable selection +- Backspace - disable selection + +``` +|- Options +|- [x] +| |- [x] +| |- [x] +| ... +|- [ ] +| |- [ ] +| ... +... +``` +""" +import collections + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors +from openpype.widgets.nice_checkbox import NiceCheckbox +from .widgets import AbstractInstanceView +from ..constants import ( + INSTANCE_ID_ROLE, + SORT_VALUE_ROLE, + IS_GROUP_ROLE, + CONTEXT_ID, + CONTEXT_LABEL +) + + +class ListItemDelegate(QtWidgets.QStyledItemDelegate): + """Generic delegate for instance group. + + All indexes having `IS_GROUP_ROLE` data set to True will use + `group_item_paint` method to draw it's content otherwise default styled + item delegate paint method is used. + + Goal is to draw group items with different colors for normal, hover and + pressed state. + """ + radius_ratio = 0.3 + + def __init__(self, parent): + super(ListItemDelegate, self).__init__(parent) + + colors_data = get_objected_colors() + group_color_info = colors_data["publisher"]["list-view-group"] + + self._group_colors = { + key: value.get_qcolor() + for key, value in group_color_info.items() + } + + def paint(self, painter, option, index): + if index.data(IS_GROUP_ROLE): + self.group_item_paint(painter, option, index) + else: + super(ListItemDelegate, self).paint(painter, option, index) + + def group_item_paint(self, painter, option, index): + """Paint group item.""" + self.initStyleOption(option, index) + + bg_rect = QtCore.QRectF( + option.rect.left(), option.rect.top() + 1, + option.rect.width(), option.rect.height() - 2 + ) + ratio = bg_rect.height() * self.radius_ratio + bg_path = QtGui.QPainterPath() + bg_path.addRoundedRect( + QtCore.QRectF(bg_rect), ratio, ratio + ) + + painter.save() + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + | painter.TextAntialiasing + ) + + # Draw backgrounds + painter.fillPath(bg_path, self._group_colors["bg"]) + selected = option.state & QtWidgets.QStyle.State_Selected + hovered = option.state & QtWidgets.QStyle.State_MouseOver + if selected and hovered: + painter.fillPath(bg_path, self._group_colors["bg-selected-hover"]) + + elif hovered: + painter.fillPath(bg_path, self._group_colors["bg-hover"]) + + painter.restore() + + +class InstanceListItemWidget(QtWidgets.QWidget): + """Widget with instance info drawn over delegate paint. + + This is required to be able use custom checkbox on custom place. + """ + active_changed = QtCore.Signal(str, bool) + + def __init__(self, instance, parent): + super(InstanceListItemWidget, self).__init__(parent) + + self.instance = instance + + subset_name_label = QtWidgets.QLabel(instance["subset"], self) + subset_name_label.setObjectName("ListViewSubsetName") + + active_checkbox = NiceCheckbox(parent=self) + active_checkbox.setChecked(instance["active"]) + + layout = QtWidgets.QHBoxLayout(self) + content_margins = layout.contentsMargins() + layout.setContentsMargins(content_margins.left() + 2, 0, 2, 0) + layout.addWidget(subset_name_label) + layout.addStretch(1) + layout.addWidget(active_checkbox) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + subset_name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) + active_checkbox.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + active_checkbox.stateChanged.connect(self._on_active_change) + + self._subset_name_label = subset_name_label + self._active_checkbox = active_checkbox + + self._has_valid_context = None + + self._set_valid_property(instance.has_valid_context) + + def _set_valid_property(self, valid): + if self._has_valid_context == valid: + return + self._has_valid_context = valid + state = "" + if not valid: + state = "invalid" + self._subset_name_label.setProperty("state", state) + self._subset_name_label.style().polish(self._subset_name_label) + + def is_active(self): + """Instance is activated.""" + return self.instance["active"] + + def set_active(self, new_value): + """Change active state of instance and checkbox.""" + checkbox_value = self._active_checkbox.isChecked() + instance_value = self.instance["active"] + if new_value is None: + new_value = not instance_value + + # First change instance value and them change checkbox + # - prevent to trigger `active_changed` signal + if instance_value != new_value: + self.instance["active"] = new_value + + if checkbox_value != new_value: + self._active_checkbox.setChecked(new_value) + + def update_instance(self, instance): + """Update instance object.""" + self.instance = instance + self.update_instance_values() + + def update_instance_values(self): + """Update instance data propagated to widgets.""" + # Check subset name + subset_name = self.instance["subset"] + if subset_name != self._subset_name_label.text(): + self._subset_name_label.setText(subset_name) + # Check active state + self.set_active(self.instance["active"]) + # Check valid states + self._set_valid_property(self.instance.has_valid_context) + + def _on_active_change(self): + new_value = self._active_checkbox.isChecked() + old_value = self.instance["active"] + if new_value == old_value: + return + + self.instance["active"] = new_value + self.active_changed.emit(self.instance.id, new_value) + + +class ListContextWidget(QtWidgets.QFrame): + """Context (or global attributes) widget.""" + def __init__(self, parent): + super(ListContextWidget, self).__init__(parent) + + label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 0, 2, 0) + layout.addWidget( + label_widget, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self.label_widget = label_widget + + +class InstanceListGroupWidget(QtWidgets.QFrame): + """Widget representing group of instances. + + Has collapse/expand indicator, label of group and checkbox modifying all of + it's children. + """ + expand_changed = QtCore.Signal(str, bool) + toggle_requested = QtCore.Signal(str, int) + + def __init__(self, group_name, parent): + super(InstanceListGroupWidget, self).__init__(parent) + self.setObjectName("InstanceListGroupWidget") + + self.group_name = group_name + self._expanded = False + + expand_btn = QtWidgets.QToolButton(self) + expand_btn.setObjectName("ArrowBtn") + expand_btn.setArrowType(QtCore.Qt.RightArrow) + expand_btn.setMaximumWidth(14) + + name_label = QtWidgets.QLabel(group_name, self) + + toggle_checkbox = NiceCheckbox(parent=self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 0, 2, 0) + layout.addWidget(expand_btn) + layout.addWidget( + name_label, 1, QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + ) + layout.addWidget(toggle_checkbox, 0) + + name_label.setAttribute(QtCore.Qt.WA_TranslucentBackground) + expand_btn.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + expand_btn.clicked.connect(self._on_expand_clicked) + toggle_checkbox.stateChanged.connect(self._on_checkbox_change) + + self._ignore_state_change = False + + self._expected_checkstate = None + + self.name_label = name_label + self.expand_btn = expand_btn + self.toggle_checkbox = toggle_checkbox + + def set_checkstate(self, state): + """Change checkstate of "active" checkbox. + + Args: + state(QtCore.Qt.CheckState): Checkstate of checkbox. Have 3 + variants Unchecked, Checked and PartiallyChecked. + """ + if self.checkstate() == state: + return + self._ignore_state_change = True + self.toggle_checkbox.setCheckState(state) + self._ignore_state_change = False + + def checkstate(self): + """CUrrent checkstate of "active" checkbox.""" + return self.toggle_checkbox.checkState() + + def _on_checkbox_change(self, state): + if not self._ignore_state_change: + self.toggle_requested.emit(self.group_name, state) + + def _on_expand_clicked(self): + self.expand_changed.emit(self.group_name, not self._expanded) + + def set_expanded(self, expanded): + """Change icon of collapse/expand identifier.""" + if self._expanded == expanded: + return + + self._expanded = expanded + if expanded: + self.expand_btn.setArrowType(QtCore.Qt.DownArrow) + else: + self.expand_btn.setArrowType(QtCore.Qt.RightArrow) + + +class InstanceTreeView(QtWidgets.QTreeView): + """View showing instances and their groups.""" + toggle_requested = QtCore.Signal(int) + + def __init__(self, *args, **kwargs): + super(InstanceTreeView, self).__init__(*args, **kwargs) + + self.setObjectName("InstanceListView") + self.setHeaderHidden(True) + self.setIndentation(0) + self.setExpandsOnDoubleClick(False) + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.viewport().setMouseTracking(True) + self._pressed_group_index = None + + def _expand_item(self, index, expand=None): + is_expanded = self.isExpanded(index) + if expand is None: + expand = not is_expanded + + if expand != is_expanded: + if expand: + self.expand(index) + else: + self.collapse(index) + + def get_selected_instance_ids(self): + """Ids of selected instances.""" + instance_ids = set() + for index in self.selectionModel().selectedIndexes(): + instance_id = index.data(INSTANCE_ID_ROLE) + if instance_id is not None: + instance_ids.add(instance_id) + return instance_ids + + def event(self, event): + if not event.type() == QtCore.QEvent.KeyPress: + pass + + elif event.key() == QtCore.Qt.Key_Space: + self.toggle_requested.emit(-1) + return True + + elif event.key() == QtCore.Qt.Key_Backspace: + self.toggle_requested.emit(0) + return True + + elif event.key() == QtCore.Qt.Key_Return: + self.toggle_requested.emit(1) + return True + + return super(InstanceTreeView, self).event(event) + + def _mouse_press(self, event): + """Store index of pressed group. + + This is to be able change state of group and process mouse + "double click" as 2x "single click". + """ + if event.button() != QtCore.Qt.LeftButton: + return + + pressed_group_index = None + pos_index = self.indexAt(event.pos()) + if pos_index.data(IS_GROUP_ROLE): + pressed_group_index = pos_index + + self._pressed_group_index = pressed_group_index + + def mousePressEvent(self, event): + self._mouse_press(event) + super(InstanceTreeView, self).mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + self._mouse_press(event) + super(InstanceTreeView, self).mouseDoubleClickEvent(event) + + def _mouse_release(self, event, pressed_index): + if event.button() != QtCore.Qt.LeftButton: + return False + + pos_index = self.indexAt(event.pos()) + if not pos_index.data(IS_GROUP_ROLE) or pressed_index != pos_index: + return False + + if self.state() == QtWidgets.QTreeView.State.DragSelectingState: + indexes = self.selectionModel().selectedIndexes() + if len(indexes) != 1 or indexes[0] != pos_index: + return False + + self._expand_item(pos_index) + return True + + def mouseReleaseEvent(self, event): + pressed_index = self._pressed_group_index + self._pressed_group_index = None + result = self._mouse_release(event, pressed_index) + if not result: + super(InstanceTreeView, self).mouseReleaseEvent(event) + + +class InstanceListView(AbstractInstanceView): + """Widget providing abstract methods of AbstractInstanceView for list view. + + This is public access to and from list view. + """ + def __init__(self, controller, parent): + super(InstanceListView, self).__init__(parent) + + self.controller = controller + + instance_view = InstanceTreeView(self) + instance_delegate = ListItemDelegate(instance_view) + instance_view.setItemDelegate(instance_delegate) + instance_model = QtGui.QStandardItemModel() + + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(instance_model) + proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + proxy_model.setSortRole(SORT_VALUE_ROLE) + proxy_model.setFilterKeyColumn(0) + proxy_model.setDynamicSortFilter(True) + + instance_view.setModel(proxy_model) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(instance_view) + + instance_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + instance_view.collapsed.connect(self._on_collapse) + instance_view.expanded.connect(self._on_expand) + instance_view.toggle_requested.connect(self._on_toggle_request) + + self._group_items = {} + self._group_widgets = {} + self._widgets_by_id = {} + self._group_by_instance_id = {} + self._context_item = None + self._context_widget = None + + self._instance_view = instance_view + self._instance_delegate = instance_delegate + self._instance_model = instance_model + self._proxy_model = proxy_model + + def _on_expand(self, index): + group_name = index.data(SORT_VALUE_ROLE) + group_widget = self._group_widgets.get(group_name) + if group_widget: + group_widget.set_expanded(True) + + def _on_collapse(self, index): + group_name = index.data(SORT_VALUE_ROLE) + group_widget = self._group_widgets.get(group_name) + if group_widget: + group_widget.set_expanded(False) + + def _on_toggle_request(self, toggle): + selected_instance_ids = self._instance_view.get_selected_instance_ids() + if toggle == -1: + active = None + elif toggle == 1: + active = True + else: + active = False + + for instance_id in selected_instance_ids: + widget = self._widgets_by_id.get(instance_id) + if widget is not None: + widget.set_active(active) + + def _update_group_checkstate(self, group_name): + widget = self._group_widgets.get(group_name) + if widget is None: + return + + activity = None + for instance_id, _group_name in self._group_by_instance_id.items(): + if _group_name != group_name: + continue + + instance_widget = self._widgets_by_id.get(instance_id) + if not instance_widget: + continue + + if activity is None: + activity = int(instance_widget.is_active()) + + elif activity != instance_widget.is_active(): + activity = -1 + break + + if activity is None: + return + + state = QtCore.Qt.PartiallyChecked + if activity == 0: + state = QtCore.Qt.Unchecked + elif activity == 1: + state = QtCore.Qt.Checked + widget.set_checkstate(state) + + def refresh(self): + """Refresh instances in the view.""" + # Prepare instances by their groups + instances_by_group_name = collections.defaultdict(list) + group_names = set() + for instance in self.controller.instances: + group_label = instance.creator_label + group_names.add(group_label) + instances_by_group_name[group_label].append(instance) + + # Sort view at the end of refresh + # - is turned off until any change in view happens + sort_at_the_end = False + + # Access to root item of main model + root_item = self._instance_model.invisibleRootItem() + + # Create or use already existing context item + # - context widget does not change so we don't have to update anything + context_item = None + if self._context_item is None: + sort_at_the_end = True + context_item = QtGui.QStandardItem() + context_item.setData(0, SORT_VALUE_ROLE) + context_item.setData(CONTEXT_ID, INSTANCE_ID_ROLE) + + root_item.appendRow(context_item) + + index = self._instance_model.index( + context_item.row(), context_item.column() + ) + proxy_index = self._proxy_model.mapFromSource(index) + widget = ListContextWidget(self._instance_view) + self._instance_view.setIndexWidget(proxy_index, widget) + + self._context_widget = widget + self._context_item = context_item + + # Create new groups based on prepared `instances_by_group_name` + new_group_items = [] + for group_name in group_names: + if group_name in self._group_items: + continue + + group_item = QtGui.QStandardItem() + group_item.setData(group_name, SORT_VALUE_ROLE) + group_item.setData(True, IS_GROUP_ROLE) + group_item.setFlags(QtCore.Qt.ItemIsEnabled) + self._group_items[group_name] = group_item + new_group_items.append(group_item) + + # Add new group items to root item if there are any + if new_group_items: + # Trigger sort at the end + sort_at_the_end = True + root_item.appendRows(new_group_items) + + # Create widget for each new group item and store it for future usage + for group_item in new_group_items: + index = self._instance_model.index( + group_item.row(), group_item.column() + ) + proxy_index = self._proxy_model.mapFromSource(index) + group_name = group_item.data(SORT_VALUE_ROLE) + widget = InstanceListGroupWidget(group_name, self._instance_view) + widget.expand_changed.connect(self._on_group_expand_request) + widget.toggle_requested.connect(self._on_group_toggle_request) + self._group_widgets[group_name] = widget + self._instance_view.setIndexWidget(proxy_index, widget) + + # Remove groups that are not available anymore + for group_name in tuple(self._group_items.keys()): + if group_name in group_names: + continue + + group_item = self._group_items.pop(group_name) + root_item.removeRow(group_item.row()) + widget = self._group_widgets.pop(group_name) + widget.deleteLater() + + # Store which groups should be expanded at the end + expand_groups = set() + # Process changes in each group item + # - create new instance, update existing and remove not existing + for group_name, group_item in self._group_items.items(): + # Instance items to remove + # - will contain all exising instance ids at the start + # - instance ids may be removed when existing instances are checked + to_remove = set() + # Mapping of existing instances under group item + existing_mapping = {} + + # Get group index to be able get children indexes + group_index = self._instance_model.index( + group_item.row(), group_item.column() + ) + + # Iterate over children indexes of group item + for idx in range(group_item.rowCount()): + index = self._instance_model.index(idx, 0, group_index) + instance_id = index.data(INSTANCE_ID_ROLE) + # Add all instance into `to_remove` set + to_remove.add(instance_id) + existing_mapping[instance_id] = idx + + # Collect all new instances that are not existing under group + # New items + new_items = [] + # Tuples of new instance and instance itself + new_items_with_instance = [] + # Group activity (should be {-1;0;1} at the end) + # - 0 when all instances are disabled + # - 1 when all instances are enabled + # - -1 when it's mixed + activity = None + for instance in instances_by_group_name[group_name]: + instance_id = instance.id + # Handle group activity + if activity is None: + activity = int(instance["active"]) + elif activity == -1: + pass + elif activity != instance["active"]: + activity = -1 + + self._group_by_instance_id[instance_id] = group_name + # Remove instance id from `to_remove` if already exists and + # trigger update of widget + if instance_id in to_remove: + to_remove.remove(instance_id) + widget = self._widgets_by_id[instance_id] + widget.update_instance(instance) + continue + + # Create new item and store it as new + item = QtGui.QStandardItem() + item.setData(instance["subset"], SORT_VALUE_ROLE) + item.setData(instance_id, INSTANCE_ID_ROLE) + new_items.append(item) + new_items_with_instance.append((item, instance)) + + # Set checkstate of group checkbox + state = QtCore.Qt.PartiallyChecked + if activity == 0: + state = QtCore.Qt.Unchecked + elif activity == 1: + state = QtCore.Qt.Checked + + widget = self._group_widgets[group_name] + widget.set_checkstate(state) + + # Remove items that were not found + idx_to_remove = [] + for instance_id in to_remove: + idx_to_remove.append(existing_mapping[instance_id]) + + # Remove them in reverse order to prevend row index changes + for idx in reversed(sorted(idx_to_remove)): + group_item.removeRows(idx, 1) + + # Cleanup instance related widgets + for instance_id in to_remove: + self._group_by_instance_id.pop(instance_id) + widget = self._widgets_by_id.pop(instance_id) + widget.deleteLater() + + # Process new instance items and add them to model and create + # their widgets + if new_items: + # Trigger sort at the end when new instances are available + sort_at_the_end = True + + # Add items under group item + group_item.appendRows(new_items) + + for item, instance in new_items_with_instance: + if not instance.has_valid_context: + expand_groups.add(group_name) + item_index = self._instance_model.index( + item.row(), + item.column(), + group_index + ) + proxy_index = self._proxy_model.mapFromSource(item_index) + widget = InstanceListItemWidget( + instance, self._instance_view + ) + widget.active_changed.connect(self._on_active_changed) + self._instance_view.setIndexWidget(proxy_index, widget) + self._widgets_by_id[instance.id] = widget + + # Trigger sort at the end of refresh + if sort_at_the_end: + self._proxy_model.sort(0) + + # Expand groups marked for expanding + for group_name in expand_groups: + group_item = self._group_items[group_name] + proxy_index = self._proxy_model.mapFromSource(group_item.index()) + + self._instance_view.expand(proxy_index) + + def refresh_instance_states(self): + """Trigger update of all instances.""" + for widget in self._widgets_by_id.values(): + widget.update_instance_values() + + def _on_active_changed(self, changed_instance_id, new_value): + selected_instances, _ = self.get_selected_items() + + selected_ids = set() + found = False + for instance in selected_instances: + selected_ids.add(instance.id) + if not found and instance.id == changed_instance_id: + found = True + + if not found: + selected_ids = set() + selected_ids.add(changed_instance_id) + + self._change_active_instances(selected_ids, new_value) + group_names = set() + for instance_id in selected_ids: + group_name = self._group_by_instance_id.get(instance_id) + if group_name is not None: + group_names.add(group_name) + + for group_name in group_names: + self._update_group_checkstate(group_name) + + def _change_active_instances(self, instance_ids, new_value): + if not instance_ids: + return + + changed_ids = set() + for instance_id in instance_ids: + widget = self._widgets_by_id.get(instance_id) + if widget: + changed_ids.add(instance_id) + widget.set_active(new_value) + + if changed_ids: + self.active_changed.emit() + + def get_selected_items(self): + """Get selected instance ids and context selection. + + Returns: + tuple: Selected instance ids and boolean if context + is selected. + """ + instances = [] + context_selected = False + instances_by_id = { + instance.id: instance + for instance in self.controller.instances + } + + for index in self._instance_view.selectionModel().selectedIndexes(): + instance_id = index.data(INSTANCE_ID_ROLE) + if not context_selected and instance_id == CONTEXT_ID: + context_selected = True + + elif instance_id is not None: + instance = instances_by_id.get(instance_id) + if instance: + instances.append(instance) + + return instances, context_selected + + def _on_selection_change(self, *_args): + self.selection_changed.emit() + + def _on_group_expand_request(self, group_name, expanded): + group_item = self._group_items.get(group_name) + if not group_item: + return + + group_index = self._instance_model.index( + group_item.row(), group_item.column() + ) + proxy_index = self.mapFromSource(group_index) + self._instance_view.setExpanded(proxy_index, expanded) + + def _on_group_toggle_request(self, group_name, state): + if state == QtCore.Qt.PartiallyChecked: + return + + if state == QtCore.Qt.Checked: + active = True + else: + active = False + + group_item = self._group_items.get(group_name) + if not group_item: + return + + instance_ids = set() + for row in range(group_item.rowCount()): + item = group_item.child(row) + instance_id = item.data(INSTANCE_ID_ROLE) + if instance_id is not None: + instance_ids.add(instance_id) + + self._change_active_instances(instance_ids, active) + + proxy_index = self.mapFromSource(group_item.index()) + if not self._instance_view.isExpanded(proxy_index): + self._instance_view.expand(proxy_index) diff --git a/openpype/tools/publisher/widgets/models.py b/openpype/tools/publisher/widgets/models.py new file mode 100644 index 0000000000..0cfd771ef1 --- /dev/null +++ b/openpype/tools/publisher/widgets/models.py @@ -0,0 +1,201 @@ +import re +import collections + +from Qt import QtCore, QtGui + + +class AssetsHierarchyModel(QtGui.QStandardItemModel): + """Assets hiearrchy model. + + For selecting asset for which should beinstance created. + + Uses controller to load asset hierarchy. All asset documents are stored by + their parents. + """ + def __init__(self, controller): + super(AssetsHierarchyModel, self).__init__() + self._controller = controller + + self._items_by_name = {} + + def reset(self): + self.clear() + + self._items_by_name = {} + assets_by_parent_id = self._controller.get_asset_hierarchy() + + items_by_name = {} + _queue = collections.deque() + _queue.append((self.invisibleRootItem(), None)) + while _queue: + parent_item, parent_id = _queue.popleft() + children = assets_by_parent_id.get(parent_id) + if not children: + continue + + children_by_name = { + child["name"]: child + for child in children + } + items = [] + for name in sorted(children_by_name.keys()): + child = children_by_name[name] + item = QtGui.QStandardItem(name) + items_by_name[name] = item + items.append(item) + _queue.append((item, child["_id"])) + + parent_item.appendRows(items) + + self._items_by_name = items_by_name + + def name_is_valid(self, item_name): + return item_name in self._items_by_name + + def get_index_by_name(self, item_name): + item = self._items_by_name.get(item_name) + if item: + return item.index() + return QtCore.QModelIndex() + + +class TasksModel(QtGui.QStandardItemModel): + """Tasks model. + + Task model must have set context of asset documents. + + Items in model are based on 0-infinite asset documents. Always contain + an interserction of context asset tasks. When no assets are in context + them model is empty if 2 or more are in context assets that don't have + tasks with same names then model is empty too. + + Args: + controller (PublisherController): Controller which handles creation and + publishing. + """ + def __init__(self, controller): + super(TasksModel, self).__init__() + self._controller = controller + self._items_by_name = {} + self._asset_names = [] + self._task_names_by_asset_name = {} + + def set_asset_names(self, asset_names): + """Set assets context.""" + self._asset_names = asset_names + self.reset() + + @staticmethod + def get_intersection_of_tasks(task_names_by_asset_name): + """Calculate intersection of task names from passed data. + + Example: + ``` + # Passed `task_names_by_asset_name` + { + "asset_1": ["compositing", "animation"], + "asset_2": ["compositing", "editorial"] + } + ``` + Result: + ``` + # Set + {"compositing"} + ``` + + Args: + task_names_by_asset_name (dict): Task names in iterable by parent. + """ + tasks = None + for task_names in task_names_by_asset_name.values(): + if tasks is None: + tasks = set(task_names) + else: + tasks &= set(task_names) + + if not tasks: + break + return tasks or set() + + def is_task_name_valid(self, asset_name, task_name): + """Is task name available for asset. + + Args: + asset_name (str): Name of asset where should look for task. + task_name (str): Name of task which should be available in asset's + tasks. + """ + task_names = self._task_names_by_asset_name.get(asset_name) + if task_names and task_name in task_names: + return True + return False + + def reset(self): + """Update model by current context.""" + if not self._asset_names: + self._items_by_name = {} + self._task_names_by_asset_name = {} + self.clear() + return + + task_names_by_asset_name = ( + self._controller.get_task_names_by_asset_names(self._asset_names) + ) + self._task_names_by_asset_name = task_names_by_asset_name + + new_task_names = self.get_intersection_of_tasks( + task_names_by_asset_name + ) + old_task_names = set(self._items_by_name.keys()) + if new_task_names == old_task_names: + return + + root_item = self.invisibleRootItem() + for task_name in old_task_names: + if task_name not in new_task_names: + item = self._items_by_name.pop(task_name) + root_item.removeRow(item.row()) + + new_items = [] + for task_name in new_task_names: + if task_name in self._items_by_name: + continue + + item = QtGui.QStandardItem(task_name) + self._items_by_name[task_name] = item + new_items.append(item) + root_item.appendRows(new_items) + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Recursive proxy model. + + Item is not filtered if any children match the filter. + + Use case: Filtering by string - parent won't be filtered if does not match + the filter string but first checks if any children does. + """ + def filterAcceptsRow(self, row, parent_index): + regex = self.filterRegExp() + if not regex.isEmpty(): + model = self.sourceModel() + source_index = model.index( + row, self.filterKeyColumn(), parent_index + ) + if source_index.isValid(): + pattern = regex.pattern() + + # Check current index itself + value = model.data(source_index, self.filterRole()) + if re.search(pattern, value, re.IGNORECASE): + return True + + rows = model.rowCount(source_index) + for idx in range(rows): + if self.filterAcceptsRow(idx, source_index): + return True + return False + + return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow( + row, parent_index + ) diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py new file mode 100644 index 0000000000..e4f3579978 --- /dev/null +++ b/openpype/tools/publisher/widgets/publish_widget.py @@ -0,0 +1,521 @@ +import os +import json +import time + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.pipeline import KnownPublishError + +from .validations_widget import ValidationsWidget +from ..publish_report_viewer import PublishReportViewerWidget +from .widgets import ( + StopBtn, + ResetBtn, + ValidateBtn, + PublishBtn, + CopyPublishReportBtn, + SavePublishReportBtn, + ShowPublishReportBtn +) + + +class ActionsButton(QtWidgets.QToolButton): + def __init__(self, parent=None): + super(ActionsButton, self).__init__(parent) + + self.setText("< No action >") + self.setPopupMode(self.MenuButtonPopup) + menu = QtWidgets.QMenu(self) + + self.setMenu(menu) + + self._menu = menu + self._actions = [] + self._current_action = None + + self.clicked.connect(self._on_click) + + def current_action(self): + return self._current_action + + def add_action(self, action): + self._actions.append(action) + action.triggered.connect(self._on_action_trigger) + self._menu.addAction(action) + if self._current_action is None: + self._set_action(action) + + def set_action(self, action): + if action not in self._actions: + self.add_action(action) + self._set_action(action) + + def _set_action(self, action): + if action is self._current_action: + return + self._current_action = action + self.setText(action.text()) + self.setIcon(action.icon()) + + def _on_click(self): + self._current_action.trigger() + + def _on_action_trigger(self): + action = self.sender() + if action not in self._actions: + return + + self._set_action(action) + + +class PublishFrame(QtWidgets.QFrame): + """Frame showed during publishing. + + Shows all information related to publishing. Contains validation error + widget which is showed if only validation error happens during validation. + + Processing layer is default layer. Validation error layer is shown if only + validation exception is raised during publishing. Report layer is available + only when publishing process is stopped and must be manually triggered to + change into that layer. + + +------------------------------------------------------------------------+ + | | + | | + | | + | < Validation error widget > | + | | + | | + | | + | | + +------------------------------------------------------------------------+ + | < Main label > | + | < Label top > | + | (#### 10% ) | + | | + | Report: