Merge branch 'develop' into feature/OP-2461_slate-generator-handling

This commit is contained in:
Jakub Jezek 2022-02-07 11:37:54 +01:00
commit ed86f542ab
No known key found for this signature in database
GPG key ID: D8548FBF690B100A
56 changed files with 3206 additions and 1297 deletions

View file

@ -1,16 +1,31 @@
# Changelog
## [3.8.2-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD)
## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.1...HEAD)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.1...3.8.2)
**🚀 Enhancements**
- TVPaint: Image loaders also work on review family [\#2638](https://github.com/pypeclub/OpenPype/pull/2638)
- General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629)
- nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627)
- Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602)
- Nuke: load color space from representation data [\#2576](https://github.com/pypeclub/OpenPype/pull/2576)
**🐛 Bug fixes**
- Global: fix broken otio review extractor [\#2590](https://github.com/pypeclub/OpenPype/pull/2590)
- Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628)
### 📖 Documentation
- Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617)
**Merged pull requests:**
- Docker: enhance dockerfiles with metadata, fix pyenv initialization [\#2647](https://github.com/pypeclub/OpenPype/pull/2647)
- WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641)
- Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613)
- Webpublisher: Skip version collect [\#2591](https://github.com/pypeclub/OpenPype/pull/2591)
## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01)
@ -19,6 +34,7 @@
**🚀 Enhancements**
- Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600)
- Webpublisher: Added endpoint to reprocess batch through UI [\#2555](https://github.com/pypeclub/OpenPype/pull/2555)
- Loader: Allow to toggle default family filters between "include" or "exclude" filtering [\#2541](https://github.com/pypeclub/OpenPype/pull/2541)
- Launcher: Added context menu to to skip opening last workfile [\#2536](https://github.com/pypeclub/OpenPype/pull/2536)
@ -28,6 +44,7 @@
- hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618)
- Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615)
- switch distutils to sysconfig for `get\_platform\(\)` [\#2594](https://github.com/pypeclub/OpenPype/pull/2594)
- Global: fix broken otio review extractor [\#2590](https://github.com/pypeclub/OpenPype/pull/2590)
- Fix poetry index and speedcopy update [\#2589](https://github.com/pypeclub/OpenPype/pull/2589)
- Webpublisher: Fix - subset names from processed .psd used wrong value for task [\#2586](https://github.com/pypeclub/OpenPype/pull/2586)
- `vrscene` creator Deadline webservice URL handling [\#2580](https://github.com/pypeclub/OpenPype/pull/2580)
@ -37,17 +54,12 @@
**Merged pull requests:**
- Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595)
- Webpublisher: Skip version collect [\#2591](https://github.com/pypeclub/OpenPype/pull/2591)
- build\(deps\): bump pillow from 8.4.0 to 9.0.0 [\#2523](https://github.com/pypeclub/OpenPype/pull/2523)
## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.0-nightly.7...3.8.0)
### 📖 Documentation
- Variable in docs renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546)
**🆕 New features**
- Flame: extracting segments with trans-coding [\#2547](https://github.com/pypeclub/OpenPype/pull/2547)
@ -60,7 +72,6 @@
- Webpublisher: Moved error at the beginning of the log [\#2559](https://github.com/pypeclub/OpenPype/pull/2559)
- Ftrack: Use ApplicationManager to get DJV path [\#2558](https://github.com/pypeclub/OpenPype/pull/2558)
- Webpublisher: Added endpoint to reprocess batch through UI [\#2555](https://github.com/pypeclub/OpenPype/pull/2555)
- Settings: PathInput strip passed string [\#2550](https://github.com/pypeclub/OpenPype/pull/2550)
- Global: Exctract Review anatomy fill data with output name [\#2548](https://github.com/pypeclub/OpenPype/pull/2548)
- Cosmetics: Clean up some cosmetics / typos [\#2542](https://github.com/pypeclub/OpenPype/pull/2542)
@ -92,6 +103,10 @@
- Maya: reset empty string attributes correctly to "" instead of "None" [\#2506](https://github.com/pypeclub/OpenPype/pull/2506)
- Improve FusionPreLaunch hook errors [\#2505](https://github.com/pypeclub/OpenPype/pull/2505)
### 📖 Documentation
- Variable in docs renamed to proper name [\#2546](https://github.com/pypeclub/OpenPype/pull/2546)
**Merged pull requests:**
- AfterEffects: Move implementation to OpenPype [\#2543](https://github.com/pypeclub/OpenPype/pull/2543)
@ -104,14 +119,6 @@
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.14...3.7.0)
**🚀 Enhancements**
- General: Workdir extra folders [\#2462](https://github.com/pypeclub/OpenPype/pull/2462)
**🐛 Bug fixes**
- TVPaint: Create render layer dialog is in front [\#2471](https://github.com/pypeclub/OpenPype/pull/2471)
## [3.6.4](https://github.com/pypeclub/OpenPype/tree/3.6.4) (2021-11-23)
[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.7.0-nightly.1...3.6.4)

View file

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

View file

@ -1,11 +1,15 @@
# Build Pype docker image
FROM centos:7 AS builder
ARG OPENPYPE_PYTHON_VERSION=3.7.10
ARG OPENPYPE_PYTHON_VERSION=3.7.12
LABEL org.opencontainers.image.name="pypeclub/openpype"
LABEL org.opencontainers.image.title="OpenPype Docker Image"
LABEL org.opencontainers.image.url="https://openpype.io/"
LABEL org.opencontainers.image.source="https://github.com/pypeclub/pype"
LABEL org.opencontainers.image.documentation="https://openpype.io/docs/system_introduction"
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.version=$VERSION
USER root

View file

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

View file

@ -29,6 +29,10 @@ from .lib import (
maintained_selection
)
from .utils import (
colorspace_exists_on_node,
get_colorspace_list
)
__all__ = (
"file_extensions",
@ -54,4 +58,7 @@ __all__ = (
"update_container",
"maintained_selection",
"colorspace_exists_on_node",
"get_colorspace_list"
)

View file

@ -753,7 +753,7 @@ def script_name():
def add_button_write_to_read(node):
name = "createReadNode"
label = "Create Read From Rendered"
label = "Read From Rendered"
value = "import write_to_read;\
write_to_read.write_to_read(nuke.thisNode(), allow_relative=False)"
knob = nuke.PyScript_Knob(name, label, value)
@ -761,6 +761,15 @@ def add_button_write_to_read(node):
node.addKnob(knob)
def add_button_clear_rendered(node, path):
name = "clearRendered"
label = "Clear Rendered"
value = "import clear_rendered;\
clear_rendered.clear_rendered(\"{}\")".format(path)
knob = nuke.PyScript_Knob(name, label, value)
node.addKnob(knob)
def create_write_node(name, data, input=None, prenodes=None,
review=True, linked_knobs=None, farm=True):
''' Creating write node which is group node
@ -988,6 +997,9 @@ def create_write_node(name, data, input=None, prenodes=None,
# adding write to read button
add_button_write_to_read(GN)
# adding write to read button
add_button_clear_rendered(GN, os.path.dirname(fpath))
# Deadline tab.
add_deadline_tab(GN)

View file

@ -82,3 +82,50 @@ def bake_gizmos_recursively(in_group=None):
if node.Class() == "Group":
bake_gizmos_recursively(node)
def colorspace_exists_on_node(node, colorspace_name):
""" Check if colorspace exists on node
Look through all options in the colorpsace knob, and see if we have an
exact match to one of the items.
Args:
node (nuke.Node): nuke node object
colorspace_name (str): color profile name
Returns:
bool: True if exists
"""
try:
colorspace_knob = node['colorspace']
except ValueError:
# knob is not available on input node
return False
all_clrs = get_colorspace_list(colorspace_knob)
return colorspace_name in all_clrs
def get_colorspace_list(colorspace_knob):
"""Get available colorspace profile names
Args:
colorspace_knob (nuke.Knob): nuke knob object
Returns:
list: list of strings names of profiles
"""
all_clrs = list(colorspace_knob.values())
reduced_clrs = []
if not colorspace_knob.getFlag(nuke.STRIP_CASCADE_PREFIX):
return all_clrs
# strip colorspace with nested path
for clrs in all_clrs:
clrs = clrs.split('/')[-1]
reduced_clrs.append(clrs)
return reduced_clrs

View file

@ -9,7 +9,8 @@ from openpype.hosts.nuke.api.lib import (
from openpype.hosts.nuke.api import (
containerise,
update_container,
viewer_update_and_undo_stop
viewer_update_and_undo_stop,
colorspace_exists_on_node
)
from openpype.hosts.nuke.api import plugin
@ -66,11 +67,11 @@ class LoadClip(plugin.NukeLoader):
)
def load(self, context, name, namespace, options):
repre = context["representation"]
# reste container id so it is always unique for each instance
self.reset_container_id()
is_sequence = len(context["representation"]["files"]) > 1
is_sequence = len(repre["files"]) > 1
file = self.fname.replace("\\", "/")
@ -79,14 +80,13 @@ class LoadClip(plugin.NukeLoader):
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"]
repre_id = repre["_id"]
repre_cont = repre["context"]
self.log.info("version_data: {}\n".format(version_data))
self.log.debug(
"Representation id `{}` ".format(repr_id))
"Representation id `{}` ".format(repre_id))
self.handle_start = version_data.get("handleStart", 0)
self.handle_end = version_data.get("handleEnd", 0)
@ -101,7 +101,7 @@ class LoadClip(plugin.NukeLoader):
first = 1
last = first + duration
elif "#" not in file:
frame = repr_cont.get("frame")
frame = repre_cont.get("frame")
assert frame, "Representation is not sequence"
padding = len(frame)
@ -113,10 +113,10 @@ class LoadClip(plugin.NukeLoader):
if not file:
self.log.warning(
"Representation id `{}` is failing to load".format(repr_id))
"Representation id `{}` is failing to load".format(repre_id))
return
read_name = self._get_node_name(context["representation"])
read_name = self._get_node_name(repre)
# Create the Loader with the filename path set
read_node = nuke.createNode(
@ -128,11 +128,8 @@ class LoadClip(plugin.NukeLoader):
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)
used_colorspace = self._set_colorspace(
read_node, version_data, repre["data"])
self._set_range_to_node(read_node, first, last, start_at_workfile)
@ -145,6 +142,12 @@ class LoadClip(plugin.NukeLoader):
for k in add_keys:
if k == 'version':
data_imprint.update({k: context["version"]['name']})
elif k == 'colorspace':
colorspace = repre["data"].get(k)
colorspace = colorspace or version_data.get(k)
data_imprint["db_colorspace"] = colorspace
if used_colorspace:
data_imprint["used_colorspace"] = used_colorspace
else:
data_imprint.update(
{k: context["version"]['data'].get(k, str(None))})
@ -192,10 +195,13 @@ class LoadClip(plugin.NukeLoader):
"_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"]
repre_id = representation["_id"]
repre_cont = representation["context"]
# colorspace profile
colorspace = representation["data"].get("colorspace")
colorspace = colorspace or version_data.get("colorspace")
self.handle_start = version_data.get("handleStart", 0)
self.handle_end = version_data.get("handleEnd", 0)
@ -210,7 +216,7 @@ class LoadClip(plugin.NukeLoader):
first = 1
last = first + duration
elif "#" not in file:
frame = repr_cont.get("frame")
frame = repre_cont.get("frame")
assert frame, "Representation is not sequence"
padding = len(frame)
@ -218,7 +224,7 @@ class LoadClip(plugin.NukeLoader):
if not file:
self.log.warning(
"Representation id `{}` is failing to load".format(repr_id))
"Representation id `{}` is failing to load".format(repre_id))
return
read_name = self._get_node_name(representation)
@ -229,12 +235,9 @@ class LoadClip(plugin.NukeLoader):
# 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)
used_colorspace = self._set_colorspace(
read_node, version_data, representation["data"],
path=file)
self._set_range_to_node(read_node, first, last, start_at_workfile)
@ -243,7 +246,7 @@ class LoadClip(plugin.NukeLoader):
"frameStart": str(first),
"frameEnd": str(last),
"version": str(version.get("name")),
"colorspace": colorspace,
"db_colorspace": colorspace,
"source": version_data.get("source"),
"handleStart": str(self.handle_start),
"handleEnd": str(self.handle_end),
@ -251,6 +254,10 @@ class LoadClip(plugin.NukeLoader):
"author": version_data.get("author")
}
# add used colorspace if found any
if used_colorspace:
updated_dict["used_colorspace"] = used_colorspace
# change color of read_node
# get all versions in list
versions = io.find({
@ -365,14 +372,37 @@ class LoadClip(plugin.NukeLoader):
def _get_node_name(self, representation):
repr_cont = representation["context"]
repre_cont = representation["context"]
name_data = {
"asset": repr_cont["asset"],
"subset": repr_cont["subset"],
"asset": repre_cont["asset"],
"subset": repre_cont["subset"],
"representation": representation["name"],
"ext": repr_cont["representation"],
"ext": repre_cont["representation"],
"id": representation["_id"],
"class_name": self.__class__.__name__
}
return self.node_name_template.format(**name_data)
def _set_colorspace(self, node, version_data, repre_data, path=None):
output_color = None
path = path or self.fname.replace("\\", "/")
# get colorspace
colorspace = repre_data.get("colorspace")
colorspace = colorspace or version_data.get("colorspace")
# colorspace from `project_anatomy/imageio/nuke/regexInputs`
iio_colorspace = get_imageio_input_colorspace(path)
# Set colorspace defined in version data
if (
colorspace is not None
and colorspace_exists_on_node(node, str(colorspace))
):
node["colorspace"].setValue(str(colorspace))
output_color = str(colorspace)
elif iio_colorspace is not None:
node["colorspace"].setValue(iio_colorspace)
output_color = iio_colorspace
return output_color

View file

@ -0,0 +1,11 @@
import os
from openpype.api import Logger
log = Logger().get_logger(__name__)
def clear_rendered(dir_path):
for _f in os.listdir(dir_path):
_f_path = os.path.join(dir_path, _f)
log.info("Removing: `{}`".format(_f_path))
os.remove(_f_path)

View file

@ -10,6 +10,7 @@ import avalon.api
from openpype.api import Logger
from openpype.tools.utils import host_tools
from openpype.lib.remote_publish import headless_publish
from openpype.lib import env_value_to_bool
from .launch_logic import ProcessLauncher, stub
@ -34,20 +35,19 @@ def main(*subprocess_args):
launcher = ProcessLauncher(subprocess_args)
launcher.start()
if os.environ.get("HEADLESS_PUBLISH"):
if env_value_to_bool("HEADLESS_PUBLISH"):
launcher.execute_in_main_thread(
headless_publish,
log,
"ClosePS",
os.environ.get("IS_TEST")
)
elif os.environ.get("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH", True):
save = False
if os.getenv("WORKFILES_SAVE_AS"):
save = True
elif env_value_to_bool("AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH",
default=True):
launcher.execute_in_main_thread(
host_tools.show_workfiles, save=save
host_tools.show_workfiles,
save=env_value_to_bool("WORKFILES_SAVE_AS")
)
sys.exit(app.exec_())

View file

@ -17,9 +17,7 @@ class CreateImage(openpype.api.Creator):
create_group = False
stub = photoshop.stub()
useSelection = False
if (self.options or {}).get("useSelection"):
useSelection = True
multiple_instances = False
selection = stub.get_selected_layers()
self.log.info("selection {}".format(selection))
@ -64,8 +62,7 @@ class CreateImage(openpype.api.Creator):
# No selection creates an empty group.
create_group = True
else:
stub.select_layers(stub.get_layers())
group = stub.group_selected_layers(self.name)
group = stub.create_group(self.name)
groups.append(group)
if create_group:
@ -77,16 +74,15 @@ class CreateImage(openpype.api.Creator):
group = stub.group_selected_layers(layer.name)
groups.append(group)
creator_subset_name = self.data["subset"]
for group in groups:
long_names = []
group.name = group.name.replace(stub.PUBLISH_ICON, ''). \
replace(stub.LOADED_ICON, '')
if useSelection:
subset_name = self.data["subset"] + group.name
else:
# use value provided by user from Creator
subset_name = self.data["subset"]
subset_name = creator_subset_name
if len(groups) > 1:
subset_name += group.name.title().replace(" ", "")
if group.long_name:
for directory in group.long_name[::-1]:

View file

@ -9,7 +9,7 @@ from openpype.hosts.photoshop import api as photoshop
class CollectRemoteInstances(pyblish.api.ContextPlugin):
"""Gather instances configured color code of a layer.
"""Creates instances for configured color code of a layer.
Used in remote publishing when artists marks publishable layers by color-
coding.
@ -46,6 +46,11 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin):
stub = photoshop.stub()
layers = stub.get_layers()
existing_subset_names = []
for instance in context:
if instance.data.get('publish'):
existing_subset_names.append(instance.data.get('subset'))
asset, task_name, task_type = get_batch_asset_task_info(
task_data["context"])
@ -55,6 +60,10 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin):
instance_names = []
for layer in layers:
self.log.debug("Layer:: {}".format(layer))
if layer.parents:
self.log.debug("!!! Not a top layer, skip")
continue
resolved_family, resolved_subset_template = self._resolve_mapping(
layer
)
@ -66,8 +75,19 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin):
self.log.debug("!!! Not found family or template, skip")
continue
if layer.parents:
self.log.debug("!!! Not a top layer, skip")
fill_pairs = {
"variant": variant,
"family": resolved_family,
"task": task_name,
"layer": layer.name
}
subset = resolved_subset_template.format(
**prepare_template_data(fill_pairs))
if subset in existing_subset_names:
self.log.info(
"Subset {} already created, skipping.".format(subset))
continue
instance = context.create_instance(layer.name)
@ -76,15 +96,6 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin):
instance.data["publish"] = layer.visible
instance.data["asset"] = asset
instance.data["task"] = task_name
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)

View file

@ -1,3 +1,4 @@
import collections
import pyblish.api
import openpype.api
@ -16,11 +17,14 @@ class ValidateSubsetUniqueness(pyblish.api.ContextPlugin):
subset_names = []
for instance in context:
self.log.info("instance:: {}".format(instance.data))
if instance.data.get('publish'):
subset_names.append(instance.data.get('subset'))
msg = (
"Instance subset names are not unique. " +
"Remove duplicates via SubsetManager."
)
assert len(subset_names) == len(set(subset_names)), msg
non_unique = \
[item
for item, count in collections.Counter(subset_names).items()
if count > 1]
msg = ("Instance subset names {} are not unique. ".format(non_unique) +
"Remove duplicates via SubsetManager.")
assert not non_unique, msg

View file

@ -11,7 +11,7 @@ class MyAutoCreator(AutoCreator):
identifier = "workfile"
family = "workfile"
def get_attribute_defs(self):
def get_instance_attr_defs(self):
output = [
lib.NumberDef("number_key", label="Number")
]

View file

@ -1,3 +1,4 @@
import json
from openpype import resources
from openpype.hosts.testhost.api import pipeline
from openpype.pipeline import (
@ -13,6 +14,8 @@ class TestCreatorOne(Creator):
family = "test"
description = "Testing creator of testhost"
create_allow_context_change = False
def get_icon(self):
return resources.get_openpype_splash_filepath()
@ -33,7 +36,10 @@ class TestCreatorOne(Creator):
for instance in instances:
self._remove_instance_from_context(instance)
def create(self, subset_name, data, options=None):
def create(self, subset_name, data, pre_create_data):
print("Data that can be used in create:\n{}".format(
json.dumps(pre_create_data, indent=4)
))
new_instance = CreatedInstance(self.family, subset_name, data, self)
pipeline.HostContext.add_instance(new_instance.data_to_store())
self.log.info(new_instance.data)
@ -46,9 +52,21 @@ class TestCreatorOne(Creator):
"different_variant"
]
def get_attribute_defs(self):
def get_instance_attr_defs(self):
output = [
lib.NumberDef("number_key", label="Number")
lib.NumberDef("number_key", label="Number"),
]
return output
def get_pre_create_attr_defs(self):
output = [
lib.BoolDef("use_selection", label="Use selection"),
lib.UISeparatorDef(),
lib.UILabelDef("Testing label"),
lib.FileDef("filepath", folders=True, label="Filepath"),
lib.FileDef(
"filepath_2", multipath=True, folders=True, label="Filepath 2"
)
]
return output

View file

@ -15,7 +15,7 @@ class TestCreatorTwo(Creator):
def get_icon(self):
return "cube"
def create(self, subset_name, data, options=None):
def create(self, subset_name, data, pre_create_data):
new_instance = CreatedInstance(self.family, subset_name, data, self)
pipeline.HostContext.add_instance(new_instance.data_to_store())
self.log.info(new_instance.data)
@ -38,7 +38,7 @@ class TestCreatorTwo(Creator):
for instance in instances:
self._remove_instance_from_context(instance)
def get_attribute_defs(self):
def get_instance_attr_defs(self):
output = [
lib.NumberDef("number_key"),
lib.TextDef("text_key")

View file

@ -19,7 +19,7 @@ class CollectContextDataTestHost(
hosts = ["testhost"]
@classmethod
def get_attribute_defs(cls):
def get_instance_attr_defs(cls):
return [
attribute_definitions.BoolDef(
"test_bool",

View file

@ -20,7 +20,7 @@ class CollectInstanceOneTestHost(
hosts = ["testhost"]
@classmethod
def get_attribute_defs(cls):
def get_instance_attr_defs(cls):
return [
attribute_definitions.NumberDef(
"version",

View file

@ -5,7 +5,7 @@ from openpype.hosts.tvpaint.api import lib, plugin
class ImportImage(plugin.Loader):
"""Load image or image sequence to TVPaint as new layer."""
families = ["render", "image", "background", "plate"]
families = ["render", "image", "background", "plate", "review"]
representations = ["*"]
label = "Import Image"

View file

@ -7,7 +7,7 @@ from openpype.hosts.tvpaint.api import lib, pipeline, plugin
class LoadImage(plugin.Loader):
"""Load image or image sequence to TVPaint as new layer."""
families = ["render", "image", "background", "plate"]
families = ["render", "image", "background", "plate", "review"]
representations = ["*"]
label = "Load Image"

View file

@ -0,0 +1,247 @@
"""These lib functions are primarily for development purposes.
WARNING: This is not meant for production data.
Goal is to be able create package of current state of project with related
documents from mongo and files from disk to zip file and then be able recreate
the project based on the zip.
This gives ability to create project where a changes and tests can be done.
Keep in mind that to be able create a package of project has few requirements.
Possible requirement should be listed in 'pack_project' function.
"""
import os
import json
import platform
import tempfile
import shutil
import datetime
import zipfile
from bson.json_util import (
loads,
dumps,
CANONICAL_JSON_OPTIONS
)
from avalon.api import AvalonMongoDB
DOCUMENTS_FILE_NAME = "database"
METADATA_FILE_NAME = "metadata"
PROJECT_FILES_DIR = "project_files"
def add_timestamp(filepath):
"""Add timestamp string to a file."""
base, ext = os.path.splitext(filepath)
timestamp = datetime.datetime.now().strftime("%y%m%d_%H%M%S")
new_base = "{}_{}".format(base, timestamp)
return new_base + ext
def pack_project(project_name, destination_dir=None):
"""Make a package of a project with mongo documents and files.
This function has few restrictions:
- project must have only one root
- project must have all templates starting with
"{root[...]}/{project[name]}"
Args:
project_name(str): Project that should be packaged.
destination_dir(str): Optinal path where zip will be stored. Project's
root is used if not passed.
"""
print("Creating package of project \"{}\"".format(project_name))
# Validate existence of project
dbcon = AvalonMongoDB()
dbcon.Session["AVALON_PROJECT"] = project_name
project_doc = dbcon.find_one({"type": "project"})
if not project_doc:
raise ValueError("Project \"{}\" was not found in database".format(
project_name
))
roots = project_doc["config"]["roots"]
# Determine root directory of project
source_root = None
source_root_name = None
for root_name, root_value in roots.items():
if source_root is not None:
raise ValueError(
"Packaging is supported only for single root projects"
)
source_root = root_value
source_root_name = root_name
root_path = source_root[platform.system().lower()]
print("Using root \"{}\" with path \"{}\"".format(
source_root_name, root_path
))
project_source_path = os.path.join(root_path, project_name)
if not os.path.exists(project_source_path):
raise ValueError("Didn't find source of project files")
# Determine zip filepath where data will be stored
if not destination_dir:
destination_dir = root_path
destination_dir = os.path.normpath(destination_dir)
if not os.path.exists(destination_dir):
os.makedirs(destination_dir)
zip_path = os.path.join(destination_dir, project_name + ".zip")
print("Project will be packaged into \"{}\"".format(zip_path))
# Rename already existing zip
if os.path.exists(zip_path):
dst_filepath = add_timestamp(zip_path)
os.rename(zip_path, dst_filepath)
# We can add more data
metadata = {
"project_name": project_name,
"root": source_root,
"version": 1
}
# Create temp json file where metadata are stored
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as s:
temp_metadata_json = s.name
with open(temp_metadata_json, "w") as stream:
json.dump(metadata, stream)
# Create temp json file where database documents are stored
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as s:
temp_docs_json = s.name
# Query all project documents and store them to temp json
docs = list(dbcon.find({}))
data = dumps(
docs, json_options=CANONICAL_JSON_OPTIONS
)
with open(temp_docs_json, "w") as stream:
stream.write(data)
print("Packing files into zip")
# Write all to zip file
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zip_stream:
# Add metadata file
zip_stream.write(temp_metadata_json, METADATA_FILE_NAME + ".json")
# Add database documents
zip_stream.write(temp_docs_json, DOCUMENTS_FILE_NAME + ".json")
# Add project files to zip
for root, _, filenames in os.walk(project_source_path):
for filename in filenames:
filepath = os.path.join(root, filename)
# TODO add one more folder
archive_name = os.path.join(
PROJECT_FILES_DIR,
os.path.relpath(filepath, root_path)
)
zip_stream.write(filepath, archive_name)
print("Cleaning up")
# Cleanup
os.remove(temp_docs_json)
os.remove(temp_metadata_json)
dbcon.uninstall()
print("*** Packing finished ***")
def unpack_project(path_to_zip, new_root=None):
"""Unpack project zip file to recreate project.
Args:
path_to_zip(str): Path to zip which was created using 'pack_project'
function.
new_root(str): Optional way how to set different root path for unpacked
project.
"""
print("Unpacking project from zip {}".format(path_to_zip))
if not os.path.exists(path_to_zip):
print("Zip file does not exists: {}".format(path_to_zip))
return
tmp_dir = tempfile.mkdtemp(prefix="unpack_")
print("Zip is extracted to temp: {}".format(tmp_dir))
with zipfile.ZipFile(path_to_zip, "r") as zip_stream:
zip_stream.extractall(tmp_dir)
metadata_json_path = os.path.join(tmp_dir, METADATA_FILE_NAME + ".json")
with open(metadata_json_path, "r") as stream:
metadata = json.load(stream)
docs_json_path = os.path.join(tmp_dir, DOCUMENTS_FILE_NAME + ".json")
with open(docs_json_path, "r") as stream:
content = stream.readlines()
docs = loads("".join(content))
low_platform = platform.system().lower()
project_name = metadata["project_name"]
source_root = metadata["root"]
root_path = source_root[low_platform]
# Drop existing collection
dbcon = AvalonMongoDB()
database = dbcon.database
if project_name in database.list_collection_names():
database.drop_collection(project_name)
print("Removed existing project collection")
print("Creating project documents ({})".format(len(docs)))
# Create new collection with loaded docs
collection = database[project_name]
collection.insert_many(docs)
# Skip change of root if is the same as the one stored in metadata
if (
new_root
and (os.path.normpath(new_root) == os.path.normpath(root_path))
):
new_root = None
if new_root:
print("Using different root path {}".format(new_root))
root_path = new_root
project_doc = collection.find_one({"type": "project"})
roots = project_doc["config"]["roots"]
key = tuple(roots.keys())[0]
update_key = "config.roots.{}.{}".format(key, low_platform)
collection.update_one(
{"_id": project_doc["_id"]},
{"$set": {
update_key: new_root
}}
)
# Make sure root path exists
if not os.path.exists(root_path):
os.makedirs(root_path)
src_project_files_dir = os.path.join(
tmp_dir, PROJECT_FILES_DIR, project_name
)
dst_project_files_dir = os.path.normpath(
os.path.join(root_path, project_name)
)
if os.path.exists(dst_project_files_dir):
new_path = add_timestamp(dst_project_files_dir)
print("Project folder already exists. Renamed \"{}\" -> \"{}\"".format(
dst_project_files_dir, new_path
))
os.rename(dst_project_files_dir, new_path)
print("Moving project files from temp \"{}\" -> \"{}\"".format(
src_project_files_dir, dst_project_files_dir
))
shutil.move(src_project_files_dir, dst_project_files_dir)
# CLeanup
print("Cleaning up")
shutil.rmtree(tmp_dir)
dbcon.uninstall()
print("*** Unpack finished ***")

View file

@ -88,7 +88,6 @@ def publish(log, close_plugin_name=None):
if close_plugin: # close host app explicitly after error
context = pyblish.api.Context()
close_plugin().process(context)
sys.exit(1)
def publish_and_log(dbcon, _id, log, close_plugin_name=None, batch_id=None):
@ -137,7 +136,7 @@ def publish_and_log(dbcon, _id, log, close_plugin_name=None, batch_id=None):
if close_plugin: # close host app explicitly after error
context = pyblish.api.Context()
close_plugin().process(context)
sys.exit(1)
return
elif processed % log_every == 0:
# pyblish returns progress in 0.0 - 2.0
progress = min(round(result["progress"] / 2 * 100), 99)

View file

@ -94,6 +94,7 @@ class AttributeValues:
attr_defs_by_key = {
attr_def.key: attr_def
for attr_def in attr_defs
if attr_def.is_value_def
}
for key, value in values.items():
if key not in attr_defs_by_key:
@ -306,8 +307,6 @@ class PublishAttributes:
self._plugin_names_order = []
self._missing_plugins = []
self.attr_plugins = attr_plugins or []
if not attr_plugins:
return
origin_data = self._origin_data
data = self._data
@ -420,7 +419,7 @@ class CreatedInstance:
# Stored creator specific attribute values
# {key: value}
creator_values = copy.deepcopy(orig_creator_attributes)
creator_attr_defs = creator.get_attribute_defs()
creator_attr_defs = creator.get_instance_attr_defs()
self._data["creator_attributes"] = CreatorAttributeValues(
self, creator_attr_defs, creator_values, orig_creator_attributes

View file

@ -80,7 +80,7 @@ class BaseCreator:
self.create_context.creator_removed_instance(instance)
@abstractmethod
def create(self, options=None):
def create(self):
"""Create new instance.
Replacement of `process` method from avalon implementation.
@ -163,7 +163,7 @@ class BaseCreator:
dynamic_data=dynamic_data
)
def get_attribute_defs(self):
def get_instance_attr_defs(self):
"""Plugin attribute definitions.
Attribute definitions of plugin that hold data about created instance
@ -199,15 +199,22 @@ class Creator(BaseCreator):
# - may not be used if `get_detail_description` is overriden
detailed_description = None
# It does make sense to change context on creation
# - in some cases it may confuse artists because it would not be used
# e.g. for buld creators
create_allow_context_change = True
@abstractmethod
def create(self, subset_name, instance_data, options=None):
def create(self, subset_name, instance_data, pre_create_data):
"""Create new instance and store it.
Ideally should be stored to workfile using host implementation.
Args:
subset_name(str): Subset name of created instance.
instance_data(dict):
instance_data(dict): Base data for instance.
pre_create_data(dict): Data based on pre creation attributes.
Those may affect how creator works.
"""
# instance = CreatedInstance(
@ -258,6 +265,19 @@ class Creator(BaseCreator):
return None
def get_pre_create_attr_defs(self):
"""Plugin attribute definitions needed for creation.
Attribute definitions of plugin that define how creation will work.
Values of these definitions are passed to `create` method.
NOTE:
Convert method should be implemented which should care about updating
keys/values when plugin attributes change.
Returns:
list<AbtractAttrDef>: Attribute definitions that can be tweaked for
created instance.
"""
return []
class AutoCreator(BaseCreator):
"""Creator which is automatically triggered without user interaction.

View file

@ -5,11 +5,17 @@ from .events import (
from .attribute_definitions import (
AbtractAttrDef,
UIDef,
UISeparatorDef,
UILabelDef,
UnknownDef,
NumberDef,
TextDef,
EnumDef,
BoolDef
BoolDef,
FileDef,
)
@ -18,9 +24,15 @@ __all__ = (
"BeforeWorkfileSave",
"AbtractAttrDef",
"UIDef",
"UISeparatorDef",
"UILabelDef",
"UnknownDef",
"NumberDef",
"TextDef",
"EnumDef",
"BoolDef"
"BoolDef",
"FileDef",
)

View file

@ -38,13 +38,21 @@ class AbtractAttrDef:
key(str): Under which key will be attribute value stored.
label(str): Attribute label.
tooltip(str): Attribute tooltip.
is_label_horizontal(bool): UI specific argument. Specify if label is
next to value input or ahead.
"""
is_value_def = True
def __init__(self, key, default, label=None, tooltip=None):
def __init__(
self, key, default, label=None, tooltip=None, is_label_horizontal=None
):
if is_label_horizontal is None:
is_label_horizontal = True
self.key = key
self.label = label
self.tooltip = tooltip
self.default = default
self.is_label_horizontal = is_label_horizontal
self._id = uuid.uuid4()
self.__init__class__ = AbtractAttrDef
@ -68,8 +76,39 @@ class AbtractAttrDef:
pass
# -----------------------------------------
# UI attribute definitoins won't hold value
# -----------------------------------------
class UIDef(AbtractAttrDef):
is_value_def = False
def __init__(self, key=None, default=None, *args, **kwargs):
super(UIDef, self).__init__(key, default, *args, **kwargs)
def convert_value(self, value):
return value
class UISeparatorDef(UIDef):
pass
class UILabelDef(UIDef):
def __init__(self, label):
super(UILabelDef, self).__init__(label=label)
# ---------------------------------------
# Attribute defintioins should hold value
# ---------------------------------------
class UnknownDef(AbtractAttrDef):
"""Definition is not known because definition is not available."""
"""Definition is not known because definition is not available.
This attribute can be used to keep existing data unchanged but does not
have known definition of type.
"""
def __init__(self, key, default=None, **kwargs):
kwargs["default"] = default
super(UnknownDef, self).__init__(key, **kwargs)
@ -261,3 +300,90 @@ class BoolDef(AbtractAttrDef):
if isinstance(value, bool):
return value
return self.default
class FileDef(AbtractAttrDef):
"""File definition.
It is possible to define filters of allowed file extensions and if supports
folders.
Args:
multipath(bool): Allow multiple path.
folders(bool): Allow folder paths.
extensions(list<str>): Allow files with extensions. Empty list will
allow all extensions and None will disable files completely.
default(str, list<str>): Defautl value.
"""
def __init__(
self, key, multipath=False, folders=None, extensions=None,
default=None, **kwargs
):
if folders is None and extensions is None:
folders = True
extensions = []
if default is None:
if multipath:
default = []
else:
default = ""
else:
if multipath:
if not isinstance(default, (tuple, list, set)):
raise TypeError((
"'default' argument must be 'list', 'tuple' or 'set'"
", not '{}'"
).format(type(default)))
else:
if not isinstance(default, six.string_types):
raise TypeError((
"'default' argument must be 'str' not '{}'"
).format(type(default)))
default = default.strip()
# Change horizontal label
is_label_horizontal = kwargs.get("is_label_horizontal")
if is_label_horizontal is None:
is_label_horizontal = True
if multipath:
is_label_horizontal = False
kwargs["is_label_horizontal"] = is_label_horizontal
self.multipath = multipath
self.folders = folders
self.extensions = extensions
super(FileDef, self).__init__(key, default=default, **kwargs)
def __eq__(self, other):
if not super(FileDef, self).__eq__(other):
return False
return (
self.multipath == other.multipath
and self.folders == other.folders
and self.extensions == other.extensions
)
def convert_value(self, value):
if isinstance(value, six.string_types):
if self.multipath:
value = [value.strip()]
else:
value = value.strip()
return value
if isinstance(value, (tuple, list, set)):
_value = []
for item in value:
if isinstance(item, six.string_types):
_value.append(item.strip())
if self.multipath:
return _value
if not _value:
return self.default
return _value[0].strip()
return str(value).strip()

View file

@ -251,7 +251,10 @@ class PypeCommands:
data = {
"last_workfile_path": workfile_path,
"start_last_workfile": True
"start_last_workfile": True,
"project_name": project,
"asset_name": asset,
"task_name": task_name
}
launched_app = application_manager.launch(app_name, **data)
@ -433,3 +436,13 @@ class PypeCommands:
version_packer = VersionRepacker(directory)
version_packer.process()
def pack_project(self, project_name, dirpath):
from openpype.lib.project_backpack import pack_project
pack_project(project_name, dirpath)
def unpack_project(self, zip_filepath, new_root):
from openpype.lib.project_backpack import unpack_project
unpack_project(zip_filepath, new_root)

View file

@ -66,7 +66,7 @@
},
"nice-checkbox": {
"bg-checked": "#56a06f",
"bg-unchecked": "#434b56",
"bg-unchecked": "#21252B",
"bg-checker": "#D3D8DE",
"bg-checker-hover": "#F0F2F5"
},

View file

@ -387,10 +387,16 @@ QHeaderView::section:only-one {
QHeaderView::down-arrow {
image: url(:/openpype/images/down_arrow.png);
padding-right: 4px;
subcontrol-origin: padding;
subcontrol-position: center right;
}
QHeaderView::up-arrow {
image: url(:/openpype/images/up_arrow.png);
padding-right: 4px;
subcontrol-origin: padding;
subcontrol-position: center right;
}
/* Checkboxes */
@ -1198,6 +1204,10 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {
font-size: 36pt;
}
#OverlayFrame {
background: rgba(0, 0, 0, 127);
}
#BreadcrumbsPathInput {
padding: 2px;
font-size: 9pt;

View file

@ -605,7 +605,9 @@ class PublisherController:
found_idx = idx
break
value = instance.creator_attributes[attr_def.key]
value = None
if attr_def.is_value_def:
value = instance.creator_attributes[attr_def.key]
if found_idx is None:
idx = len(output)
output.append((attr_def, [instance], [value]))

View file

@ -9,8 +9,6 @@ from .border_label_widget import (
from .widgets import (
SubsetAttributesWidget,
PixmapLabel,
StopBtn,
ResetBtn,
ValidateBtn,
@ -44,8 +42,6 @@ __all__ = (
"SubsetAttributesWidget",
"BorderedLabelWidget",
"PixmapLabel",
"StopBtn",
"ResetBtn",
"ValidateBtn",

View file

@ -0,0 +1,273 @@
import collections
from Qt import QtWidgets, QtCore, QtGui
from openpype.tools.utils import (
PlaceholderLineEdit,
RecursiveSortFilterProxyModel
)
from openpype.tools.utils.assets_widget import (
SingleSelectAssetsWidget,
ASSET_ID_ROLE,
ASSET_NAME_ROLE
)
class CreateDialogAssetsWidget(SingleSelectAssetsWidget):
current_context_required = QtCore.Signal()
def __init__(self, controller, parent):
self._controller = controller
super(CreateDialogAssetsWidget, self).__init__(None, parent)
self.set_refresh_btn_visibility(False)
self.set_current_asset_btn_visibility(False)
self._current_asset_name = None
self._last_selection = None
self._enabled = None
def _on_current_asset_click(self):
self.current_context_required.emit()
def set_enabled(self, enabled):
if self._enabled == enabled:
return
self._enabled = enabled
if not enabled:
self._last_selection = self.get_selected_asset_id()
self._clear_selection()
elif self._last_selection is not None:
self.select_asset(self._last_selection)
def _select_indexes(self, *args, **kwargs):
super(CreateDialogAssetsWidget, self)._select_indexes(*args, **kwargs)
if self._enabled:
return
self._last_selection = self.get_selected_asset_id()
self._clear_selection()
def set_current_asset_name(self, asset_name):
self._current_asset_name = asset_name
# Hide set current asset if there is no one
self.set_current_asset_btn_visibility(asset_name is not None)
def _get_current_session_asset(self):
return self._current_asset_name
def _create_source_model(self):
return AssetsHierarchyModel(self._controller)
def _refresh_model(self):
self._model.reset()
self._on_model_refresh(self._model.rowCount() > 0)
class AssetsHierarchyModel(QtGui.QStandardItemModel):
"""Assets hiearrchy model.
For selecting asset for which should beinstance created.
Uses controller to load asset hierarchy. All asset documents are stored by
their parents.
"""
def __init__(self, controller):
super(AssetsHierarchyModel, self).__init__()
self._controller = controller
self._items_by_name = {}
self._items_by_asset_id = {}
def reset(self):
self.clear()
self._items_by_name = {}
self._items_by_asset_id = {}
assets_by_parent_id = self._controller.get_asset_hierarchy()
items_by_name = {}
items_by_asset_id = {}
_queue = collections.deque()
_queue.append((self.invisibleRootItem(), None))
while _queue:
parent_item, parent_id = _queue.popleft()
children = assets_by_parent_id.get(parent_id)
if not children:
continue
children_by_name = {
child["name"]: child
for child in children
}
items = []
for name in sorted(children_by_name.keys()):
child = children_by_name[name]
child_id = child["_id"]
item = QtGui.QStandardItem(name)
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
)
item.setData(child_id, ASSET_ID_ROLE)
item.setData(name, ASSET_NAME_ROLE)
items_by_name[name] = item
items_by_asset_id[child_id] = item
items.append(item)
_queue.append((item, child_id))
parent_item.appendRows(items)
self._items_by_name = items_by_name
self._items_by_asset_id = items_by_asset_id
def get_index_by_asset_id(self, asset_id):
item = self._items_by_asset_id.get(asset_id)
if item is not None:
return item.index()
return QtCore.QModelIndex()
def get_index_by_asset_name(self, asset_name):
item = self._items_by_name.get(asset_name)
if item is None:
return QtCore.QModelIndex()
return item.index()
def name_is_valid(self, item_name):
return item_name in self._items_by_name
class AssetsDialog(QtWidgets.QDialog):
"""Dialog to select asset for a context of instance."""
def __init__(self, controller, parent):
super(AssetsDialog, self).__init__(parent)
self.setWindowTitle("Select asset")
model = AssetsHierarchyModel(controller)
proxy_model = RecursiveSortFilterProxyModel()
proxy_model.setSourceModel(model)
proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter assets..")
asset_view = QtWidgets.QTreeView(self)
asset_view.setModel(proxy_model)
asset_view.setHeaderHidden(True)
asset_view.setFrameShape(QtWidgets.QFrame.NoFrame)
asset_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
asset_view.setAlternatingRowColors(True)
asset_view.setSelectionBehavior(QtWidgets.QTreeView.SelectRows)
asset_view.setAllColumnsShowFocus(True)
ok_btn = QtWidgets.QPushButton("OK", self)
cancel_btn = QtWidgets.QPushButton("Cancel", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0)
layout.addWidget(asset_view, 1)
layout.addLayout(btns_layout, 0)
filter_input.textChanged.connect(self._on_filter_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._filter_input = filter_input
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
self._model = model
self._proxy_model = proxy_model
self._asset_view = asset_view
self._selected_asset = None
# Soft refresh is enabled
# - reset will happen at all cost if soft reset is enabled
# - adds ability to call reset on multiple places without repeating
self._soft_reset_enabled = True
def showEvent(self, event):
"""Refresh asset model on show."""
super(AssetsDialog, self).showEvent(event)
# Refresh on show
self.reset(False)
def reset(self, force=True):
"""Reset asset model."""
if not force and not self._soft_reset_enabled:
return
if self._soft_reset_enabled:
self._soft_reset_enabled = False
self._model.reset()
def name_is_valid(self, name):
"""Is asset name valid.
Args:
name(str): Asset name that should be checked.
"""
# Make sure we're reset
self.reset(False)
# Valid the name by model
return self._model.name_is_valid(name)
def _on_filter_change(self, text):
"""Trigger change of filter of assets."""
self._proxy_model.setFilterFixedString(text)
def _on_cancel_clicked(self):
self.done(0)
def _on_ok_clicked(self):
index = self._asset_view.currentIndex()
asset_name = None
if index.isValid():
asset_name = index.data(QtCore.Qt.DisplayRole)
self._selected_asset = asset_name
self.done(1)
def set_selected_assets(self, asset_names):
"""Change preselected asset before showing the dialog.
This also resets model and clean filter.
"""
self.reset(False)
self._asset_view.collapseAll()
self._filter_input.setText("")
indexes = []
for asset_name in asset_names:
index = self._model.get_index_by_asset_name(asset_name)
if index.isValid():
indexes.append(index)
if not indexes:
return
index_deque = collections.deque()
for index in indexes:
index_deque.append(index)
all_indexes = []
while index_deque:
index = index_deque.popleft()
all_indexes.append(index)
parent_index = index.parent()
if parent_index.isValid():
index_deque.append(parent_index)
for index in all_indexes:
proxy_index = self._proxy_model.mapFromSource(index)
self._asset_view.expand(proxy_index)
def get_selected_asset(self):
"""Get selected asset name."""
return self._selected_asset

View file

@ -27,12 +27,12 @@ from Qt import QtWidgets, QtCore
from openpype.widgets.nice_checkbox import NiceCheckbox
from openpype.tools.utils import BaseClickableFrame
from .widgets import (
AbstractInstanceView,
ContextWarningLabel,
ClickableFrame,
IconValuePixmapLabel,
TransparentPixmapLabel
PublishPixmapLabel
)
from ..constants import (
CONTEXT_ID,
@ -140,7 +140,7 @@ class GroupWidget(QtWidgets.QWidget):
widget_idx += 1
class CardWidget(ClickableFrame):
class CardWidget(BaseClickableFrame):
"""Clickable card used as bigger button."""
selected = QtCore.Signal(str, str)
# Group identifier of card
@ -184,7 +184,7 @@ class ContextCardWidget(CardWidget):
self._id = CONTEXT_ID
self._group_identifier = ""
icon_widget = TransparentPixmapLabel(self)
icon_widget = PublishPixmapLabel(None, self)
icon_widget.setObjectName("FamilyIconLabel")
label_widget = QtWidgets.QLabel(CONTEXT_LABEL, self)

View file

@ -15,6 +15,9 @@ from openpype.pipeline.create import (
)
from .widgets import IconValuePixmapLabel
from .assets_widget import CreateDialogAssetsWidget
from .tasks_widget import CreateDialogTasksWidget
from .precreate_widget import PreCreateWidget
from ..constants import (
VARIANT_TOOLTIP,
CREATOR_IDENTIFIER_ROLE,
@ -202,7 +205,23 @@ class CreateDialog(QtWidgets.QDialog):
self._name_pattern = name_pattern
self._compiled_name_pattern = re.compile(name_pattern)
context_widget = QtWidgets.QWidget(self)
assets_widget = CreateDialogAssetsWidget(controller, context_widget)
tasks_widget = CreateDialogTasksWidget(controller, context_widget)
context_layout = QtWidgets.QVBoxLayout(context_widget)
context_layout.setContentsMargins(0, 0, 0, 0)
context_layout.setSpacing(0)
context_layout.addWidget(assets_widget, 2)
context_layout.addWidget(tasks_widget, 1)
# Precreate attributes widgets
pre_create_widget = PreCreateWidget(self)
# TODO add HELP button
creator_description_widget = CreatorDescriptionWidget(self)
creator_description_widget.setVisible(False)
creators_view = QtWidgets.QListView(self)
creators_model = QtGui.QStandardItemModel()
@ -235,27 +254,46 @@ class CreateDialog(QtWidgets.QDialog):
form_layout.addRow("Name:", variant_layout)
form_layout.addRow("Subset:", subset_name_input)
left_layout = QtWidgets.QVBoxLayout()
left_layout.addWidget(QtWidgets.QLabel("Choose family:", self))
left_layout.addWidget(creators_view, 1)
left_layout.addLayout(form_layout, 0)
left_layout.addWidget(create_btn, 0)
mid_widget = QtWidgets.QWidget(self)
mid_layout = QtWidgets.QVBoxLayout(mid_widget)
mid_layout.setContentsMargins(0, 0, 0, 0)
mid_layout.addWidget(QtWidgets.QLabel("Choose family:", self))
mid_layout.addWidget(creators_view, 1)
mid_layout.addLayout(form_layout, 0)
mid_layout.addWidget(create_btn, 0)
layout = QtWidgets.QHBoxLayout(self)
layout.addLayout(left_layout, 0)
layout.addSpacing(5)
layout.addWidget(creator_description_widget, 1)
layout.setSpacing(10)
layout.addWidget(context_widget, 1)
layout.addWidget(mid_widget, 1)
layout.addWidget(pre_create_widget, 1)
prereq_timer = QtCore.QTimer()
prereq_timer.setInterval(50)
prereq_timer.setSingleShot(True)
prereq_timer.timeout.connect(self._on_prereq_timer)
create_btn.clicked.connect(self._on_create)
variant_input.returnPressed.connect(self._on_create)
variant_input.textChanged.connect(self._on_variant_change)
creators_view.selectionModel().currentChanged.connect(
self._on_item_change
self._on_creator_item_change
)
variant_hints_menu.triggered.connect(self._on_variant_action)
assets_widget.selection_changed.connect(self._on_asset_change)
assets_widget.current_context_required.connect(
self._on_current_session_context_request
)
tasks_widget.task_changed.connect(self._on_task_change)
controller.add_plugins_refresh_callback(self._on_plugins_refresh)
self._pre_create_widget = pre_create_widget
self._context_widget = context_widget
self._assets_widget = assets_widget
self._tasks_widget = tasks_widget
self.creator_description_widget = creator_description_widget
self.subset_name_input = subset_name_input
@ -269,12 +307,54 @@ class CreateDialog(QtWidgets.QDialog):
self.creators_view = creators_view
self.create_btn = create_btn
self._prereq_timer = prereq_timer
def _context_change_is_enabled(self):
return self._context_widget.isEnabled()
def _get_asset_name(self):
asset_name = None
if self._context_change_is_enabled():
asset_name = self._assets_widget.get_selected_asset_name()
if asset_name is None:
asset_name = self._asset_name
return asset_name
def _get_task_name(self):
task_name = None
if self._context_change_is_enabled():
# Don't use selection of task if asset is not set
asset_name = self._assets_widget.get_selected_asset_name()
if asset_name:
task_name = self._tasks_widget.get_selected_task_name()
if not task_name:
task_name = self._task_name
return task_name
@property
def dbcon(self):
return self.controller.dbcon
def _set_context_enabled(self, enabled):
self._assets_widget.set_enabled(enabled)
self._tasks_widget.set_enabled(enabled)
self._context_widget.setEnabled(enabled)
def refresh(self):
self._prereq_available = True
# Get context before refresh to keep selection of asset and
# task widgets
asset_name = self._get_asset_name()
task_name = self._get_task_name()
self._prereq_available = False
# Disable context widget so refresh of asset will use context asset
# name
self._set_context_enabled(False)
self._assets_widget.refresh()
# Refresh data before update of creators
self._refresh_asset()
@ -282,21 +362,36 @@ class CreateDialog(QtWidgets.QDialog):
# data
self._refresh_creators()
self._assets_widget.set_current_asset_name(self._asset_name)
self._assets_widget.select_asset_by_name(asset_name)
self._tasks_widget.set_asset_name(asset_name)
self._tasks_widget.select_task_name(task_name)
self._invalidate_prereq()
def _invalidate_prereq(self):
self._prereq_timer.start()
def _on_prereq_timer(self):
prereq_available = True
if self.creators_model.rowCount() < 1:
prereq_available = False
if self._asset_doc is None:
# QUESTION how to handle invalid asset?
self.subset_name_input.setText("< Asset is not set >")
self._prereq_available = False
prereq_available = False
if self.creators_model.rowCount() < 1:
self._prereq_available = False
if prereq_available != self._prereq_available:
self._prereq_available = prereq_available
self.create_btn.setEnabled(self._prereq_available)
self.creators_view.setEnabled(self._prereq_available)
self.variant_input.setEnabled(self._prereq_available)
self.variant_hints_btn.setEnabled(self._prereq_available)
self.create_btn.setEnabled(prereq_available)
self.creators_view.setEnabled(prereq_available)
self.variant_input.setEnabled(prereq_available)
self.variant_hints_btn.setEnabled(prereq_available)
self._on_variant_change()
def _refresh_asset(self):
asset_name = self._asset_name
asset_name = self._get_asset_name()
# Skip if asset did not change
if self._asset_doc and self._asset_doc["name"] == asset_name:
@ -324,6 +419,9 @@ class CreateDialog(QtWidgets.QDialog):
)
self._subset_names = set(subset_docs.distinct("name"))
if not asset_doc:
self.subset_name_input.setText("< Asset is not set >")
def _refresh_creators(self):
# Refresh creators and add their families to list
existing_items = {}
@ -366,25 +464,60 @@ class CreateDialog(QtWidgets.QDialog):
if not indexes:
index = self.creators_model.index(0, 0)
self.creators_view.setCurrentIndex(index)
else:
index = indexes[0]
identifier = index.data(CREATOR_IDENTIFIER_ROLE)
self._set_creator(identifier)
def _on_plugins_refresh(self):
# Trigger refresh only if is visible
if self.isVisible():
self.refresh()
def _on_item_change(self, new_index, _old_index):
def _on_asset_change(self):
self._refresh_asset()
asset_name = self._assets_widget.get_selected_asset_name()
self._tasks_widget.set_asset_name(asset_name)
if self._context_change_is_enabled():
self._invalidate_prereq()
def _on_task_change(self):
if self._context_change_is_enabled():
self._invalidate_prereq()
def _on_current_session_context_request(self):
self._assets_widget.set_current_session_asset()
if self._task_name:
self._tasks_widget.select_task_name(self._task_name)
def _on_creator_item_change(self, new_index, _old_index):
identifier = None
if new_index.isValid():
identifier = new_index.data(CREATOR_IDENTIFIER_ROLE)
self._set_creator(identifier)
def _set_creator(self, identifier):
creator = self.controller.manual_creators.get(identifier)
self.creator_description_widget.set_plugin(creator)
self._pre_create_widget.set_plugin(creator)
self._selected_creator = creator
if not creator:
self._set_context_enabled(False)
return
if (
creator.create_allow_context_change
!= self._context_change_is_enabled()
):
self._set_context_enabled(creator.create_allow_context_change)
self._refresh_asset()
default_variants = creator.get_default_variants()
if not default_variants:
default_variants = ["Main"]
@ -410,12 +543,19 @@ class CreateDialog(QtWidgets.QDialog):
if self.variant_input.text() != value:
self.variant_input.setText(value)
def _on_variant_change(self, variant_value):
if not self._prereq_available or not self._selected_creator:
def _on_variant_change(self, variant_value=None):
if not self._prereq_available:
return
# This should probably never happen?
if not self._selected_creator:
if self.subset_name_input.text():
self.subset_name_input.setText("")
return
if variant_value is None:
variant_value = self.variant_input.text()
match = self._compiled_name_pattern.match(variant_value)
valid = bool(match)
self.create_btn.setEnabled(valid)
@ -425,7 +565,7 @@ class CreateDialog(QtWidgets.QDialog):
return
project_name = self.controller.project_name
task_name = self._task_name
task_name = self._get_task_name()
asset_doc = copy.deepcopy(self._asset_doc)
# Calculate subset name with Creator plugin
@ -522,9 +662,9 @@ class CreateDialog(QtWidgets.QDialog):
family = index.data(FAMILY_ROLE)
subset_name = self.subset_name_input.text()
variant = self.variant_input.text()
asset_name = self._asset_name
task_name = self._task_name
options = {}
asset_name = self._get_asset_name()
task_name = self._get_task_name()
pre_create_data = self._pre_create_widget.current_value()
# Where to define these data?
# - what data show be stored?
instance_data = {
@ -537,7 +677,7 @@ class CreateDialog(QtWidgets.QDialog):
error_info = None
try:
self.controller.create(
creator_identifier, subset_name, instance_data, options
creator_identifier, subset_name, instance_data, pre_create_data
)
except CreatorError as exc:

View file

@ -0,0 +1,133 @@
from Qt import QtWidgets, QtCore
from openpype.widgets.attribute_defs import create_widget_for_attr_def
class PreCreateWidget(QtWidgets.QWidget):
def __init__(self, parent):
super(PreCreateWidget, self).__init__(parent)
# Precreate attribute defininitions of Creator
scroll_area = QtWidgets.QScrollArea(self)
contet_widget = QtWidgets.QWidget(scroll_area)
scroll_area.setWidget(contet_widget)
scroll_area.setWidgetResizable(True)
attributes_widget = AttributesWidget(contet_widget)
contet_layout = QtWidgets.QVBoxLayout(contet_widget)
contet_layout.setContentsMargins(0, 0, 0, 0)
contet_layout.addWidget(attributes_widget, 0)
contet_layout.addStretch(1)
# Widget showed when there are no attribute definitions from creator
empty_widget = QtWidgets.QWidget(self)
empty_widget.setVisible(False)
# Label showed when creator is not selected
no_creator_label = QtWidgets.QLabel(
"Creator is not selected",
empty_widget
)
no_creator_label.setWordWrap(True)
# Creator does not have precreate attributes
empty_label = QtWidgets.QLabel(
"This creator has no configurable options",
empty_widget
)
empty_label.setWordWrap(True)
empty_label.setVisible(False)
empty_layout = QtWidgets.QVBoxLayout(empty_widget)
empty_layout.setContentsMargins(0, 0, 0, 0)
empty_layout.addWidget(empty_label, 0, QtCore.Qt.AlignCenter)
empty_layout.addWidget(no_creator_label, 0, QtCore.Qt.AlignCenter)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.addWidget(scroll_area, 1)
main_layout.addWidget(empty_widget, 1)
self._scroll_area = scroll_area
self._empty_widget = empty_widget
self._empty_label = empty_label
self._no_creator_label = no_creator_label
self._attributes_widget = attributes_widget
def current_value(self):
return self._attributes_widget.current_value()
def set_plugin(self, creator):
attr_defs = []
creator_selected = False
if creator is not None:
creator_selected = True
attr_defs = creator.get_pre_create_attr_defs()
self._attributes_widget.set_attr_defs(attr_defs)
attr_defs_available = len(attr_defs) > 0
self._scroll_area.setVisible(attr_defs_available)
self._empty_widget.setVisible(not attr_defs_available)
self._empty_label.setVisible(creator_selected)
self._no_creator_label.setVisible(not creator_selected)
class AttributesWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super(AttributesWidget, self).__init__(parent)
layout = QtWidgets.QGridLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self._layout = layout
self._widgets = []
def current_value(self):
output = {}
for widget in self._widgets:
attr_def = widget.attr_def
if attr_def.is_value_def:
output[attr_def.key] = widget.current_value()
return output
def clear_attr_defs(self):
while self._layout.count():
item = self._layout.takeAt(0)
widget = item.widget()
if widget:
widget.setVisible(False)
widget.deleteLater()
self._widgets = []
def set_attr_defs(self, attr_defs):
self.clear_attr_defs()
row = 0
for attr_def in attr_defs:
widget = create_widget_for_attr_def(attr_def, self)
expand_cols = 2
if attr_def.is_value_def and attr_def.is_label_horizontal:
expand_cols = 1
col_num = 2 - expand_cols
if attr_def.label:
label_widget = QtWidgets.QLabel(attr_def.label, self)
self._layout.addWidget(
label_widget, row, 0, 1, expand_cols
)
if not attr_def.is_label_horizontal:
row += 1
self._layout.addWidget(
widget, row, col_num, 1, expand_cols
)
self._widgets.append(widget)
row += 1

View file

@ -1,62 +1,6 @@
import re
import collections
from Qt import QtCore, QtGui
class AssetsHierarchyModel(QtGui.QStandardItemModel):
"""Assets hiearrchy model.
For selecting asset for which should beinstance created.
Uses controller to load asset hierarchy. All asset documents are stored by
their parents.
"""
def __init__(self, controller):
super(AssetsHierarchyModel, self).__init__()
self._controller = controller
self._items_by_name = {}
def reset(self):
self.clear()
self._items_by_name = {}
assets_by_parent_id = self._controller.get_asset_hierarchy()
items_by_name = {}
_queue = collections.deque()
_queue.append((self.invisibleRootItem(), None))
while _queue:
parent_item, parent_id = _queue.popleft()
children = assets_by_parent_id.get(parent_id)
if not children:
continue
children_by_name = {
child["name"]: child
for child in children
}
items = []
for name in sorted(children_by_name.keys()):
child = children_by_name[name]
item = QtGui.QStandardItem(name)
items_by_name[name] = item
items.append(item)
_queue.append((item, child["_id"]))
parent_item.appendRows(items)
self._items_by_name = items_by_name
def name_is_valid(self, item_name):
return item_name in self._items_by_name
def get_index_by_name(self, item_name):
item = self._items_by_name.get(item_name)
if item:
return item.index()
return QtCore.QModelIndex()
from openpype.tools.utils.tasks_widget import TasksWidget, TASK_NAME_ROLE
class TasksModel(QtGui.QStandardItemModel):
@ -75,6 +19,7 @@ class TasksModel(QtGui.QStandardItemModel):
"""
def __init__(self, controller):
super(TasksModel, self).__init__()
self._controller = controller
self._items_by_name = {}
self._asset_names = []
@ -141,6 +86,7 @@ class TasksModel(QtGui.QStandardItemModel):
task_names_by_asset_name = (
self._controller.get_task_names_by_asset_names(self._asset_names)
)
self._task_names_by_asset_name = task_names_by_asset_name
new_task_names = self.get_intersection_of_tasks(
@ -162,40 +108,62 @@ class TasksModel(QtGui.QStandardItemModel):
continue
item = QtGui.QStandardItem(task_name)
item.setData(task_name, TASK_NAME_ROLE)
self._items_by_name[task_name] = item
new_items.append(item)
root_item.appendRows(new_items)
def headerData(self, section, orientation, role=None):
if role is None:
role = QtCore.Qt.EditRole
# Show nice labels in the header
if section == 0:
if (
role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole)
and orientation == QtCore.Qt.Horizontal
):
return "Tasks"
class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
"""Recursive proxy model.
return super(TasksModel, self).headerData(section, orientation, role)
Item is not filtered if any children match the filter.
Use case: Filtering by string - parent won't be filtered if does not match
the filter string but first checks if any children does.
"""
def filterAcceptsRow(self, row, parent_index):
regex = self.filterRegExp()
if not regex.isEmpty():
model = self.sourceModel()
source_index = model.index(
row, self.filterKeyColumn(), parent_index
)
if source_index.isValid():
pattern = regex.pattern()
class CreateDialogTasksWidget(TasksWidget):
def __init__(self, controller, parent):
self._controller = controller
super(CreateDialogTasksWidget, self).__init__(None, parent)
# Check current index itself
value = model.data(source_index, self.filterRole())
if re.search(pattern, value, re.IGNORECASE):
return True
self._enabled = None
rows = model.rowCount(source_index)
for idx in range(rows):
if self.filterAcceptsRow(idx, source_index):
return True
return False
def _create_source_model(self):
return TasksModel(self._controller)
return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow(
row, parent_index
)
def set_asset_name(self, asset_name):
current = self.get_selected_task_name()
if current:
self._last_selected_task_name = current
self._tasks_model.set_asset_names([asset_name])
if self._last_selected_task_name and self._enabled:
self.select_task_name(self._last_selected_task_name)
# Force a task changed emit.
self.task_changed.emit()
def select_task_name(self, task_name):
super(CreateDialogTasksWidget, self).select_task_name(task_name)
if not self._enabled:
current = self.get_selected_task_name()
if current:
self._last_selected_task_name = current
self._clear_selection()
def set_enabled(self, enabled):
self._enabled = enabled
if not enabled:
last_selected_task_name = self.get_selected_task_name()
if last_selected_task_name:
self._last_selected_task_name = last_selected_task_name
self._clear_selection()
elif self._last_selected_task_name is not None:
self.select_task_name(self._last_selected_task_name)

View file

@ -6,8 +6,8 @@ except Exception:
from Qt import QtWidgets, QtCore, QtGui
from openpype.tools.utils import BaseClickableFrame
from .widgets import (
ClickableFrame,
IconValuePixmapLabel
)
@ -55,7 +55,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
self._error_info = error_info
self._selected = False
title_frame = ClickableFrame(self)
title_frame = BaseClickableFrame(self)
title_frame.setObjectName("ValidationErrorTitleFrame")
title_frame._mouse_release_callback = self._mouse_release_callback
@ -168,7 +168,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget):
self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow)
class ActionButton(ClickableFrame):
class ActionButton(BaseClickableFrame):
"""Plugin's action callback button.
Action may have label or icon or both.

View file

@ -8,14 +8,17 @@ from Qt import QtWidgets, QtCore, QtGui
from avalon.vendor import qtawesome
from openpype.widgets.attribute_defs import create_widget_for_attr_def
from openpype.tools import resources
from openpype.tools.flickcharm import FlickCharm
from openpype.tools.utils import PlaceholderLineEdit
from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
from .models import (
AssetsHierarchyModel,
TasksModel,
RecursiveSortFilterProxyModel,
from openpype.tools.utils import (
PlaceholderLineEdit,
IconButton,
PixmapLabel,
BaseClickableFrame
)
from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS
from .assets_widget import AssetsDialog
from .tasks_widget import TasksModel
from .icons import (
get_pixmap,
get_icon_path
@ -26,49 +29,14 @@ from ..constants import (
)
class PixmapLabel(QtWidgets.QLabel):
"""Label resizing image to height of font."""
def __init__(self, pixmap, parent):
super(PixmapLabel, self).__init__(parent)
self._source_pixmap = pixmap
def set_source_pixmap(self, pixmap):
"""Change source image."""
self._source_pixmap = pixmap
self._set_resized_pix()
def _set_resized_pix(self):
class PublishPixmapLabel(PixmapLabel):
def _get_pix_size(self):
size = self.fontMetrics().height()
size += size % 2
self.setPixmap(
self._source_pixmap.scaled(
size,
size,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation
)
)
def resizeEvent(self, event):
self._set_resized_pix()
super(PixmapLabel, self).resizeEvent(event)
return size, size
class TransparentPixmapLabel(QtWidgets.QLabel):
"""Transparent label resizing to width and height of font."""
def __init__(self, *args, **kwargs):
super(TransparentPixmapLabel, self).__init__(*args, **kwargs)
def resizeEvent(self, event):
size = self.fontMetrics().height()
size += size % 2
pix = QtGui.QPixmap(size, size)
pix.fill(QtCore.Qt.transparent)
self.setPixmap(pix)
super(TransparentPixmapLabel, self).resizeEvent(event)
class IconValuePixmapLabel(PixmapLabel):
class IconValuePixmapLabel(PublishPixmapLabel):
"""Label resizing to width and height of font.
Handle icon parsing from creators/instances. Using of QAwesome module
@ -125,7 +93,7 @@ class IconValuePixmapLabel(PixmapLabel):
return self._default_pixmap()
class ContextWarningLabel(PixmapLabel):
class ContextWarningLabel(PublishPixmapLabel):
"""Pixmap label with warning icon."""
def __init__(self, parent):
pix = get_pixmap("warning")
@ -138,29 +106,6 @@ class ContextWarningLabel(PixmapLabel):
self.setObjectName("FamilyIconLabel")
class IconButton(QtWidgets.QPushButton):
"""PushButton with icon and size of font.
Using font metrics height as icon size reference.
"""
def __init__(self, *args, **kwargs):
super(IconButton, self).__init__(*args, **kwargs)
self.setObjectName("IconButton")
def sizeHint(self):
result = super(IconButton, self).sizeHint()
icon_h = self.iconSize().height()
font_height = self.fontMetrics().height()
text_set = bool(self.text())
if not text_set and icon_h < font_height:
new_size = result.height() - icon_h + font_height
result.setHeight(new_size)
result.setWidth(new_size)
return result
class PublishIconBtn(IconButton):
"""Button using alpha of source image to redraw with different color.
@ -314,7 +259,7 @@ class ShowPublishReportBtn(PublishIconBtn):
class RemoveInstanceBtn(PublishIconBtn):
"""Create remove button."""
def __init__(self, parent=None):
icon_path = get_icon_path("delete")
icon_path = resources.get_icon_path("delete")
super(RemoveInstanceBtn, self).__init__(icon_path, parent)
self.setToolTip("Remove selected instances")
@ -359,170 +304,6 @@ class AbstractInstanceView(QtWidgets.QWidget):
).format(self.__class__.__name__))
class ClickableFrame(QtWidgets.QFrame):
"""Widget that catch left mouse click and can trigger a callback.
Callback is defined by overriding `_mouse_release_callback`.
"""
def __init__(self, parent):
super(ClickableFrame, self).__init__(parent)
self._mouse_pressed = False
def _mouse_release_callback(self):
pass
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
super(ClickableFrame, self).mousePressEvent(event)
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
self._mouse_pressed = False
if self.rect().contains(event.pos()):
self._mouse_release_callback()
super(ClickableFrame, self).mouseReleaseEvent(event)
class AssetsDialog(QtWidgets.QDialog):
"""Dialog to select asset for a context of instance."""
def __init__(self, controller, parent):
super(AssetsDialog, self).__init__(parent)
self.setWindowTitle("Select asset")
model = AssetsHierarchyModel(controller)
proxy_model = RecursiveSortFilterProxyModel()
proxy_model.setSourceModel(model)
proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter assets..")
asset_view = QtWidgets.QTreeView(self)
asset_view.setModel(proxy_model)
asset_view.setHeaderHidden(True)
asset_view.setFrameShape(QtWidgets.QFrame.NoFrame)
asset_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers)
asset_view.setAlternatingRowColors(True)
asset_view.setSelectionBehavior(QtWidgets.QTreeView.SelectRows)
asset_view.setAllColumnsShowFocus(True)
ok_btn = QtWidgets.QPushButton("OK", self)
cancel_btn = QtWidgets.QPushButton("Cancel", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0)
layout.addWidget(asset_view, 1)
layout.addLayout(btns_layout, 0)
filter_input.textChanged.connect(self._on_filter_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._filter_input = filter_input
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
self._model = model
self._proxy_model = proxy_model
self._asset_view = asset_view
self._selected_asset = None
# Soft refresh is enabled
# - reset will happen at all cost if soft reset is enabled
# - adds ability to call reset on multiple places without repeating
self._soft_reset_enabled = True
def showEvent(self, event):
"""Refresh asset model on show."""
super(AssetsDialog, self).showEvent(event)
# Refresh on show
self.reset(False)
def reset(self, force=True):
"""Reset asset model."""
if not force and not self._soft_reset_enabled:
return
if self._soft_reset_enabled:
self._soft_reset_enabled = False
self._model.reset()
def name_is_valid(self, name):
"""Is asset name valid.
Args:
name(str): Asset name that should be checked.
"""
# Make sure we're reset
self.reset(False)
# Valid the name by model
return self._model.name_is_valid(name)
def _on_filter_change(self, text):
"""Trigger change of filter of assets."""
self._proxy_model.setFilterFixedString(text)
def _on_cancel_clicked(self):
self.done(0)
def _on_ok_clicked(self):
index = self._asset_view.currentIndex()
asset_name = None
if index.isValid():
asset_name = index.data(QtCore.Qt.DisplayRole)
self._selected_asset = asset_name
self.done(1)
def set_selected_assets(self, asset_names):
"""Change preselected asset before showing the dialog.
This also resets model and clean filter.
"""
self.reset(False)
self._asset_view.collapseAll()
self._filter_input.setText("")
indexes = []
for asset_name in asset_names:
index = self._model.get_index_by_name(asset_name)
if index.isValid():
indexes.append(index)
if not indexes:
return
index_deque = collections.deque()
for index in indexes:
index_deque.append(index)
all_indexes = []
while index_deque:
index = index_deque.popleft()
all_indexes.append(index)
parent_index = index.parent()
if parent_index.isValid():
index_deque.append(parent_index)
for index in all_indexes:
proxy_index = self._proxy_model.mapFromSource(index)
self._asset_view.expand(proxy_index)
def get_selected_asset(self):
"""Get selected asset name."""
return self._selected_asset
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.
@ -554,7 +335,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
event.accept()
class AssetsField(ClickableFrame):
class AssetsField(BaseClickableFrame):
"""Field where asset name of selected instance/s is showed.
Click on the field will trigger `AssetsDialog`.
@ -1394,12 +1175,13 @@ class CreatorAttrsWidget(QtWidgets.QWidget):
content_layout = QtWidgets.QFormLayout(content_widget)
for attr_def, attr_instances, values in result:
widget = create_widget_for_attr_def(attr_def, content_widget)
if len(values) == 1:
value = values[0]
if value is not None:
widget.set_value(values[0])
else:
widget.set_value(values, True)
if attr_def.is_value_def:
if len(values) == 1:
value = values[0]
if value is not None:
widget.set_value(values[0])
else:
widget.set_value(values, True)
label = attr_def.label or attr_def.key
content_layout.addRow(label, widget)

View file

@ -4,7 +4,10 @@ from openpype import (
resources,
style
)
from openpype.tools.utils import PlaceholderLineEdit
from openpype.tools.utils import (
PlaceholderLineEdit,
PixmapLabel
)
from .control import PublisherController
from .widgets import (
BorderedLabelWidget,
@ -14,8 +17,6 @@ from .widgets import (
InstanceListView,
CreateDialog,
PixmapLabel,
StopBtn,
ResetBtn,
ValidateBtn,
@ -32,7 +33,7 @@ class PublisherWindow(QtWidgets.QDialog):
default_width = 1000
default_height = 600
def __init__(self, parent=None):
def __init__(self, parent=None, reset_on_show=None):
super(PublisherWindow, self).__init__(parent)
self.setWindowTitle("OpenPype publisher")
@ -40,6 +41,9 @@ class PublisherWindow(QtWidgets.QDialog):
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
if reset_on_show is None:
reset_on_show = True
if parent is None:
on_top_flag = QtCore.Qt.WindowStaysOnTopHint
else:
@ -54,6 +58,7 @@ class PublisherWindow(QtWidgets.QDialog):
| on_top_flag
)
self._reset_on_show = reset_on_show
self._first_show = True
self._refreshing_instances = False
@ -116,12 +121,16 @@ class PublisherWindow(QtWidgets.QDialog):
subset_view_btns_layout.addWidget(change_view_btn)
# Layout of view and buttons
subset_view_layout = QtWidgets.QVBoxLayout()
# - widget 'subset_view_widget' is necessary
# - only layout won't be resized automatically to minimum size hint
# on child resize request!
subset_view_widget = QtWidgets.QWidget(subset_views_widget)
subset_view_layout = QtWidgets.QVBoxLayout(subset_view_widget)
subset_view_layout.setContentsMargins(0, 0, 0, 0)
subset_view_layout.addLayout(subset_views_layout, 1)
subset_view_layout.addLayout(subset_view_btns_layout, 0)
subset_views_widget.set_center_widget(subset_view_layout)
subset_views_widget.set_center_widget(subset_view_widget)
# Whole subset layout with attributes and details
subset_content_widget = QtWidgets.QWidget(subset_frame)
@ -248,7 +257,8 @@ class PublisherWindow(QtWidgets.QDialog):
self._first_show = False
self.resize(self.default_width, self.default_height)
self.setStyleSheet(style.load_stylesheet())
self.reset()
if self._reset_on_show:
self.reset()
def closeEvent(self, event):
self.controller.save_changes()
@ -381,6 +391,12 @@ class PublisherWindow(QtWidgets.QDialog):
context_title = self.controller.get_context_title()
self.set_context_label(context_title)
# Give a change to process Resize Request
QtWidgets.QApplication.processEvents()
# Trigger update geometry of
widget = self.subset_views_layout.currentWidget()
widget.updateGeometry()
def _on_subset_change(self, *_args):
# Ignore changes if in middle of refreshing
if self._refreshing_instances:

View file

@ -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

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -3,6 +3,8 @@ from .widgets import (
BaseClickableFrame,
ClickableFrame,
ExpandBtn,
PixmapLabel,
IconButton,
)
from .error_dialog import ErrorMessageBox
@ -11,15 +13,22 @@ from .lib import (
paint_image_with_color
)
from .models import (
RecursiveSortFilterProxyModel,
)
__all__ = (
"PlaceholderLineEdit",
"BaseClickableFrame",
"ClickableFrame",
"ExpandBtn",
"PixmapLabel",
"IconButton",
"ErrorMessageBox",
"WrappedCallbackItem",
"paint_image_with_color",
"RecursiveSortFilterProxyModel",
)

View file

@ -635,9 +635,10 @@ class AssetsWidget(QtWidgets.QWidget):
selection_model = view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
refresh_btn.clicked.connect(self.refresh)
current_asset_btn.clicked.connect(self.set_current_session_asset)
current_asset_btn.clicked.connect(self._on_current_asset_click)
view.doubleClicked.connect(self.double_clicked)
self._refresh_btn = refresh_btn
self._current_asset_btn = current_asset_btn
self._model = model
self._proxy = proxy
@ -668,11 +669,30 @@ class AssetsWidget(QtWidgets.QWidget):
def stop_refresh(self):
self._model.stop_refresh()
def _get_current_session_asset(self):
return self.dbcon.Session.get("AVALON_ASSET")
def _on_current_asset_click(self):
"""Trigger change of asset to current context asset.
This separation gives ability to override this method and use it
in differnt way.
"""
self.set_current_session_asset()
def set_current_session_asset(self):
asset_name = self.dbcon.Session.get("AVALON_ASSET")
asset_name = self._get_current_session_asset()
if asset_name:
self.select_asset_by_name(asset_name)
def set_refresh_btn_visibility(self, visible=None):
"""Hide set refresh button.
Some tools may have their global refresh button or do not support
refresh at all.
"""
if visible is None:
visible = not self._refresh_btn.isVisible()
self._refresh_btn.setVisible(visible)
def set_current_asset_btn_visibility(self, visible=None):
"""Hide set current asset button.
@ -727,6 +747,10 @@ class AssetsWidget(QtWidgets.QWidget):
def _set_loading_state(self, loading, empty):
self._view.set_loading_state(loading, empty)
def _clear_selection(self):
selection_model = self._view.selectionModel()
selection_model.clearSelection()
def _select_indexes(self, indexes):
valid_indexes = [
index

View file

@ -199,31 +199,37 @@ class Item(dict):
class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filters to the regex if any of the children matches allow parent"""
def filterAcceptsRow(self, row, parent):
"""Recursive proxy model.
Item is not filtered if any children match the filter.
Use case: Filtering by string - parent won't be filtered if does not match
the filter string but first checks if any children does.
"""
def filterAcceptsRow(self, row, parent_index):
regex = self.filterRegExp()
if not regex.isEmpty():
pattern = regex.pattern()
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
source_index = model.index(
row, self.filterKeyColumn(), parent_index
)
if source_index.isValid():
pattern = regex.pattern()
# Check current index itself
key = model.data(source_index, self.filterRole())
if re.search(pattern, key, re.IGNORECASE):
value = model.data(source_index, self.filterRole())
if re.search(pattern, value, re.IGNORECASE):
return True
# Check children
rows = model.rowCount(source_index)
for i in range(rows):
if self.filterAcceptsRow(i, source_index):
for idx in range(rows):
if self.filterAcceptsRow(idx, source_index):
return True
# Otherwise filter it
return False
return super(
RecursiveSortFilterProxyModel, self
).filterAcceptsRow(row, parent)
return super(RecursiveSortFilterProxyModel, self).filterAcceptsRow(
row, parent_index
)
class ProjectModel(QtGui.QStandardItemModel):

View file

@ -255,6 +255,10 @@ class TasksWidget(QtWidgets.QWidget):
# Force a task changed emit.
self.task_changed.emit()
def _clear_selection(self):
selection_model = self._tasks_view.selectionModel()
selection_model.clearSelection()
def select_task_name(self, task_name):
"""Select a task by name.
@ -285,6 +289,10 @@ class TasksWidget(QtWidgets.QWidget):
self._tasks_view.setCurrentIndex(index)
break
last_selected_task_name = self.get_selected_task_name()
if last_selected_task_name:
self._last_selected_task_name = last_selected_task_name
def get_selected_task_name(self):
"""Return name of task at current index (selected)

View file

@ -148,6 +148,65 @@ class ImageButton(QtWidgets.QPushButton):
return self.iconSize()
class IconButton(QtWidgets.QPushButton):
"""PushButton with icon and size of font.
Using font metrics height as icon size reference.
"""
def __init__(self, *args, **kwargs):
super(IconButton, self).__init__(*args, **kwargs)
self.setObjectName("IconButton")
def sizeHint(self):
result = super(IconButton, self).sizeHint()
icon_h = self.iconSize().height()
font_height = self.fontMetrics().height()
text_set = bool(self.text())
if not text_set and icon_h < font_height:
new_size = result.height() - icon_h + font_height
result.setHeight(new_size)
result.setWidth(new_size)
return result
class PixmapLabel(QtWidgets.QLabel):
"""Label resizing image to height of font."""
def __init__(self, pixmap, parent):
super(PixmapLabel, self).__init__(parent)
self._empty_pixmap = QtGui.QPixmap(0, 0)
self._source_pixmap = pixmap
def set_source_pixmap(self, pixmap):
"""Change source image."""
self._source_pixmap = pixmap
self._set_resized_pix()
def _get_pix_size(self):
size = self.fontMetrics().height()
size += size % 2
return size, size
def _set_resized_pix(self):
if self._source_pixmap is None:
self.setPixmap(self._empty_pixmap)
return
width, height = self._get_pix_size()
self.setPixmap(
self._source_pixmap.scaled(
width,
height,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation
)
)
def resizeEvent(self, event):
self._set_resized_pix()
super(PixmapLabel, self).resizeEvent(event)
class OptionalMenu(QtWidgets.QMenu):
"""A subclass of `QtWidgets.QMenu` to work with `OptionalAction`

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
__version__ = "3.8.2-nightly.1"
__version__ = "3.8.2"

View file

@ -0,0 +1,645 @@
import os
import collections
import uuid
import clique
from Qt import QtWidgets, QtCore, QtGui
from openpype.tools.utils import paint_image_with_color
# TODO change imports
from openpype.tools.resources import (
get_pixmap,
get_image,
)
from openpype.tools.utils import (
IconButton,
PixmapLabel
)
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2
ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3
FILENAMES_ROLE = QtCore.Qt.UserRole + 4
DIRPATH_ROLE = QtCore.Qt.UserRole + 5
IS_DIR_ROLE = QtCore.Qt.UserRole + 6
EXT_ROLE = QtCore.Qt.UserRole + 7
class DropEmpty(QtWidgets.QWidget):
_drop_enabled_text = "Drag & Drop\n(drop files here)"
def __init__(self, parent):
super(DropEmpty, self).__init__(parent)
label_widget = QtWidgets.QLabel(self._drop_enabled_text, self)
label_widget.setAlignment(QtCore.Qt.AlignCenter)
label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addSpacing(10)
layout.addWidget(
label_widget,
alignment=QtCore.Qt.AlignCenter
)
layout.addSpacing(10)
self._label_widget = label_widget
def paintEvent(self, event):
super(DropEmpty, self).paintEvent(event)
painter = QtGui.QPainter(self)
pen = QtGui.QPen()
pen.setWidth(1)
pen.setBrush(QtCore.Qt.darkGray)
pen.setStyle(QtCore.Qt.DashLine)
painter.setPen(pen)
content_margins = self.layout().contentsMargins()
left_m = content_margins.left()
top_m = content_margins.top()
rect = QtCore.QRect(
left_m,
top_m,
(
self.rect().width()
- (left_m + content_margins.right() + pen.width())
),
(
self.rect().height()
- (top_m + content_margins.bottom() + pen.width())
)
)
painter.drawRect(rect)
class FilesModel(QtGui.QStandardItemModel):
sequence_exts = [
".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal",
".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits",
".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer",
".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2",
".jng", ".jpeg", ".jpeg-ls", ".2000", ".jpg", ".xr",
".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd",
".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf",
".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras",
".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep",
".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf",
".xpm", ".xwd"
]
def __init__(self):
super(FilesModel, self).__init__()
self._filenames_by_dirpath = collections.defaultdict(set)
self._items_by_dirpath = collections.defaultdict(list)
def add_filepaths(self, filepaths):
if not filepaths:
return
new_dirpaths = set()
for filepath in filepaths:
filename = os.path.basename(filepath)
dirpath = os.path.dirname(filepath)
filenames = self._filenames_by_dirpath[dirpath]
if filename not in filenames:
new_dirpaths.add(dirpath)
filenames.add(filename)
self._refresh_items(new_dirpaths)
def remove_item_by_ids(self, item_ids):
if not item_ids:
return
remaining_ids = set(item_ids)
result = collections.defaultdict(list)
for dirpath, items in self._items_by_dirpath.items():
if not remaining_ids:
break
for item in items:
if not remaining_ids:
break
item_id = item.data(ITEM_ID_ROLE)
if item_id in remaining_ids:
remaining_ids.remove(item_id)
result[dirpath].append(item)
if not result:
return
dirpaths = set(result.keys())
for dirpath, items in result.items():
filenames_cache = self._filenames_by_dirpath[dirpath]
for item in items:
filenames = item.data(FILENAMES_ROLE)
self._items_by_dirpath[dirpath].remove(item)
self.removeRows(item.row(), 1)
for filename in filenames:
if filename in filenames_cache:
filenames_cache.remove(filename)
self._refresh_items(dirpaths)
def _refresh_items(self, dirpaths=None):
if dirpaths is None:
dirpaths = set(self._items_by_dirpath.keys())
new_items = []
for dirpath in dirpaths:
items_to_remove = list(self._items_by_dirpath[dirpath])
cols, remainders = clique.assemble(
self._filenames_by_dirpath[dirpath]
)
filtered_cols = []
for collection in cols:
filenames = set(collection)
valid_col = True
for filename in filenames:
ext = os.path.splitext(filename)[-1]
valid_col = ext in self.sequence_exts
break
if valid_col:
filtered_cols.append(collection)
else:
for filename in filenames:
remainders.append(filename)
for filename in remainders:
found = False
for item in items_to_remove:
item_filenames = item.data(FILENAMES_ROLE)
if filename in item_filenames and len(item_filenames) == 1:
found = True
items_to_remove.remove(item)
break
if found:
continue
fullpath = os.path.join(dirpath, filename)
if os.path.isdir(fullpath):
icon_pixmap = get_pixmap(filename="folder.png")
else:
icon_pixmap = get_pixmap(filename="file.png")
label = filename
filenames = [filename]
item = self._create_item(
label, filenames, dirpath, icon_pixmap
)
new_items.append(item)
self._items_by_dirpath[dirpath].append(item)
for collection in filtered_cols:
filenames = set(collection)
found = False
for item in items_to_remove:
item_filenames = item.data(FILENAMES_ROLE)
if item_filenames == filenames:
found = True
items_to_remove.remove(item)
break
if found:
continue
col_range = collection.format("{ranges}")
label = "{}<{}>{}".format(
collection.head, col_range, collection.tail
)
icon_pixmap = get_pixmap(filename="files.png")
item = self._create_item(
label, filenames, dirpath, icon_pixmap
)
new_items.append(item)
self._items_by_dirpath[dirpath].append(item)
for item in items_to_remove:
self._items_by_dirpath[dirpath].remove(item)
self.removeRows(item.row(), 1)
if new_items:
self.invisibleRootItem().appendRows(new_items)
def _create_item(self, label, filenames, dirpath, icon_pixmap=None):
first_filename = None
for filename in filenames:
first_filename = filename
break
ext = os.path.splitext(first_filename)[-1]
is_dir = False
if len(filenames) == 1:
filepath = os.path.join(dirpath, first_filename)
is_dir = os.path.isdir(filepath)
item = QtGui.QStandardItem()
item.setData(str(uuid.uuid4()), ITEM_ID_ROLE)
item.setData(label, ITEM_LABEL_ROLE)
item.setData(filenames, FILENAMES_ROLE)
item.setData(dirpath, DIRPATH_ROLE)
item.setData(icon_pixmap, ITEM_ICON_ROLE)
item.setData(ext, EXT_ROLE)
item.setData(is_dir, IS_DIR_ROLE)
return item
class FilesProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super(FilesProxyModel, self).__init__(*args, **kwargs)
self._allow_folders = False
self._allowed_extensions = None
def set_allow_folders(self, allow=None):
if allow is None:
allow = not self._allow_folders
if allow == self._allow_folders:
return
self._allow_folders = allow
self.invalidateFilter()
def set_allowed_extensions(self, extensions=None):
if extensions is not None:
extensions = set(extensions)
if self._allowed_extensions != extensions:
self._allowed_extensions = extensions
self.invalidateFilter()
def filterAcceptsRow(self, row, parent_index):
model = self.sourceModel()
index = model.index(row, self.filterKeyColumn(), parent_index)
# First check if item is folder and if folders are enabled
if index.data(IS_DIR_ROLE):
if not self._allow_folders:
return False
return True
# Check if there are any allowed extensions
if self._allowed_extensions is None:
return False
if index.data(EXT_ROLE) not in self._allowed_extensions:
return False
return True
def lessThan(self, left, right):
left_comparison = left.data(DIRPATH_ROLE)
right_comparison = right.data(DIRPATH_ROLE)
if left_comparison == right_comparison:
left_comparison = left.data(ITEM_LABEL_ROLE)
right_comparison = right.data(ITEM_LABEL_ROLE)
if sorted((left_comparison, right_comparison))[0] == left_comparison:
return True
return False
class ItemWidget(QtWidgets.QWidget):
remove_requested = QtCore.Signal(str)
def __init__(self, item_id, label, pixmap_icon, parent=None):
self._item_id = item_id
super(ItemWidget, self).__init__(parent)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
icon_widget = PixmapLabel(pixmap_icon, self)
label_widget = QtWidgets.QLabel(label, self)
pixmap = paint_image_with_color(
get_image(filename="delete.png"), QtCore.Qt.white
)
remove_btn = IconButton(self)
remove_btn.setIcon(QtGui.QIcon(pixmap))
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(icon_widget, 0)
layout.addWidget(label_widget, 1)
layout.addWidget(remove_btn, 0)
remove_btn.clicked.connect(self._on_remove_clicked)
self._icon_widget = icon_widget
self._label_widget = label_widget
self._remove_btn = remove_btn
def _on_remove_clicked(self):
self.remove_requested.emit(self._item_id)
class FilesView(QtWidgets.QListView):
"""View showing instances and their groups."""
def __init__(self, *args, **kwargs):
super(FilesView, self).__init__(*args, **kwargs)
self.setEditTriggers(QtWidgets.QListView.NoEditTriggers)
self.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection
)
def get_selected_item_ids(self):
"""Ids of selected instances."""
selected_item_ids = set()
for index in self.selectionModel().selectedIndexes():
instance_id = index.data(ITEM_ID_ROLE)
if instance_id is not None:
selected_item_ids.add(instance_id)
return selected_item_ids
def event(self, event):
if not event.type() == QtCore.QEvent.KeyPress:
pass
elif event.key() == QtCore.Qt.Key_Space:
self.toggle_requested.emit(-1)
return True
elif event.key() == QtCore.Qt.Key_Backspace:
self.toggle_requested.emit(0)
return True
elif event.key() == QtCore.Qt.Key_Return:
self.toggle_requested.emit(1)
return True
return super(FilesView, self).event(event)
class MultiFilesWidget(QtWidgets.QFrame):
value_changed = QtCore.Signal()
def __init__(self, parent):
super(MultiFilesWidget, self).__init__(parent)
self.setAcceptDrops(True)
empty_widget = DropEmpty(self)
files_model = FilesModel()
files_proxy_model = FilesProxyModel()
files_proxy_model.setSourceModel(files_model)
files_view = FilesView(self)
files_view.setModel(files_proxy_model)
files_view.setVisible(False)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(empty_widget, 1)
layout.addWidget(files_view, 1)
files_proxy_model.rowsInserted.connect(self._on_rows_inserted)
files_proxy_model.rowsRemoved.connect(self._on_rows_removed)
self._in_set_value = False
self._empty_widget = empty_widget
self._files_model = files_model
self._files_proxy_model = files_proxy_model
self._files_view = files_view
self._widgets_by_id = {}
def set_value(self, value, multivalue):
self._in_set_value = True
widget_ids = set(self._widgets_by_id.keys())
self._remove_item_by_ids(widget_ids)
# TODO how to display multivalue?
all_same = True
if multivalue:
new_value = set()
item_row = None
for _value in value:
_value_set = set(_value)
new_value |= _value_set
if item_row is None:
item_row = _value_set
elif item_row != _value_set:
all_same = False
value = new_value
if value:
self._add_filepaths(value)
self._in_set_value = False
def current_value(self):
model = self._files_proxy_model
filepaths = set()
for row in range(model.rowCount()):
index = model.index(row, 0)
dirpath = index.data(DIRPATH_ROLE)
filenames = index.data(FILENAMES_ROLE)
for filename in filenames:
filepaths.add(os.path.join(dirpath, filename))
return filepaths
def set_filters(self, folders_allowed, exts_filter):
self._files_proxy_model.set_allow_folders(folders_allowed)
self._files_proxy_model.set_allowed_extensions(exts_filter)
def _on_rows_inserted(self, parent_index, start_row, end_row):
for row in range(start_row, end_row + 1):
index = self._files_proxy_model.index(row, 0, parent_index)
item_id = index.data(ITEM_ID_ROLE)
if item_id in self._widgets_by_id:
continue
label = index.data(ITEM_LABEL_ROLE)
pixmap_icon = index.data(ITEM_ICON_ROLE)
widget = ItemWidget(item_id, label, pixmap_icon)
self._files_view.setIndexWidget(index, widget)
self._files_proxy_model.setData(
index, widget.sizeHint(), QtCore.Qt.SizeHintRole
)
widget.remove_requested.connect(self._on_remove_request)
self._widgets_by_id[item_id] = widget
self._files_proxy_model.sort(0)
if not self._in_set_value:
self.value_changed.emit()
def _on_rows_removed(self, parent_index, start_row, end_row):
available_item_ids = set()
for row in range(self._files_proxy_model.rowCount()):
index = self._files_proxy_model.index(row, 0)
item_id = index.data(ITEM_ID_ROLE)
available_item_ids.add(index.data(ITEM_ID_ROLE))
widget_ids = set(self._widgets_by_id.keys())
for item_id in available_item_ids:
if item_id in widget_ids:
widget_ids.remove(item_id)
for item_id in widget_ids:
widget = self._widgets_by_id.pop(item_id)
widget.setVisible(False)
widget.deleteLater()
if not self._in_set_value:
self.value_changed.emit()
def _on_remove_request(self, item_id):
found_index = None
for row in range(self._files_model.rowCount()):
index = self._files_model.index(row, 0)
_item_id = index.data(ITEM_ID_ROLE)
if item_id == _item_id:
found_index = index
break
if found_index is None:
return
items_to_delete = self._files_view.get_selected_item_ids()
if item_id not in items_to_delete:
items_to_delete = [item_id]
self._remove_item_by_ids(items_to_delete)
def sizeHint(self):
# Get size hints of widget and visible widgets
result = super(MultiFilesWidget, self).sizeHint()
if not self._files_view.isVisible():
not_visible_hint = self._files_view.sizeHint()
else:
not_visible_hint = self._empty_widget.sizeHint()
# Get margins of this widget
margins = self.layout().contentsMargins()
# Change size hint based on result of maximum size hint of widgets
result.setWidth(max(
result.width(),
not_visible_hint.width() + margins.left() + margins.right()
))
result.setHeight(max(
result.height(),
not_visible_hint.height() + margins.top() + margins.bottom()
))
return result
def dragEnterEvent(self, event):
mime_data = event.mimeData()
if mime_data.hasUrls():
event.setDropAction(QtCore.Qt.CopyAction)
event.accept()
def dragLeaveEvent(self, event):
event.accept()
def dropEvent(self, event):
mime_data = event.mimeData()
if mime_data.hasUrls():
filepaths = []
for url in mime_data.urls():
filepath = url.toLocalFile()
if os.path.exists(filepath):
filepaths.append(filepath)
if filepaths:
self._add_filepaths(filepaths)
event.accept()
def _add_filepaths(self, filepaths):
self._files_model.add_filepaths(filepaths)
self._update_visibility()
def _remove_item_by_ids(self, item_ids):
self._files_model.remove_item_by_ids(item_ids)
self._update_visibility()
def _update_visibility(self):
files_exists = self._files_model.rowCount() > 0
self._files_view.setVisible(files_exists)
self._empty_widget.setVisible(not files_exists)
class SingleFileWidget(QtWidgets.QWidget):
value_changed = QtCore.Signal()
def __init__(self, parent):
super(SingleFileWidget, self).__init__(parent)
self.setAcceptDrops(True)
filepath_input = QtWidgets.QLineEdit(self)
browse_btn = QtWidgets.QPushButton("Browse", self)
browse_btn.setVisible(False)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(filepath_input, 1)
layout.addWidget(browse_btn, 0)
browse_btn.clicked.connect(self._on_browse_clicked)
filepath_input.textChanged.connect(self._on_text_change)
self._in_set_value = False
self._filepath_input = filepath_input
self._folders_allowed = False
self._exts_filter = []
def set_value(self, value, multivalue):
self._in_set_value = True
if multivalue:
set_value = set(value)
if len(set_value) == 1:
value = tuple(set_value)[0]
else:
value = "< Multiselection >"
self._filepath_input.setText(value)
self._in_set_value = False
def current_value(self):
return self._filepath_input.text()
def set_filters(self, folders_allowed, exts_filter):
self._folders_allowed = folders_allowed
self._exts_filter = exts_filter
def _on_text_change(self, text):
if not self._in_set_value:
self.value_changed.emit()
def _on_browse_clicked(self):
# TODO implement file dialog logic in '_on_browse_clicked'
print("_on_browse_clicked")
def dragEnterEvent(self, event):
mime_data = event.mimeData()
if not mime_data.hasUrls():
return
filepaths = []
for url in mime_data.urls():
filepath = url.toLocalFile()
if os.path.exists(filepath):
filepaths.append(filepath)
# TODO add folder, extensions check
if len(filepaths) == 1:
event.setDropAction(QtCore.Qt.CopyAction)
event.accept()
def dragLeaveEvent(self, event):
event.accept()
def dropEvent(self, event):
mime_data = event.mimeData()
if mime_data.hasUrls():
filepaths = []
for url in mime_data.urls():
filepath = url.toLocalFile()
if os.path.exists(filepath):
filepaths.append(filepath)
# TODO filter check
if len(filepaths) == 1:
self.set_value(filepaths[0], False)
event.accept()

View file

@ -1,14 +1,19 @@
import uuid
from Qt import QtWidgets, QtCore
from openpype.pipeline.lib import (
AbtractAttrDef,
UnknownDef,
NumberDef,
TextDef,
EnumDef,
BoolDef
BoolDef,
FileDef,
UISeparatorDef,
UILabelDef
)
from openpype.widgets.nice_checkbox import NiceCheckbox
from Qt import QtWidgets, QtCore
def create_widget_for_attr_def(attr_def, parent=None):
@ -32,12 +37,22 @@ def create_widget_for_attr_def(attr_def, parent=None):
if isinstance(attr_def, UnknownDef):
return UnknownAttrWidget(attr_def, parent)
if isinstance(attr_def, FileDef):
return FileAttrWidget(attr_def, parent)
if isinstance(attr_def, UISeparatorDef):
return SeparatorAttrWidget(attr_def, parent)
if isinstance(attr_def, UILabelDef):
return LabelAttrWidget(attr_def, parent)
raise ValueError("Unknown attribute definition \"{}\"".format(
str(type(attr_def))
))
class _BaseAttrDefWidget(QtWidgets.QWidget):
# Type 'object' may not work with older PySide versions
value_changed = QtCore.Signal(object, uuid.UUID)
def __init__(self, attr_def, parent):
@ -68,12 +83,36 @@ class _BaseAttrDefWidget(QtWidgets.QWidget):
def set_value(self, value, multivalue=False):
raise NotImplementedError(
"Method 'current_value' is not implemented. {}".format(
"Method 'set_value' is not implemented. {}".format(
self.__class__.__name__
)
)
class SeparatorAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
input_widget = QtWidgets.QWidget(self)
input_widget.setObjectName("Separator")
input_widget.setMinimumHeight(2)
input_widget.setMaximumHeight(2)
self._input_widget = input_widget
self.main_layout.addWidget(input_widget, 0)
class LabelAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
input_widget = QtWidgets.QLabel(self)
label = self.attr_def.label
if label:
input_widget.setText(str(label))
self._input_widget = input_widget
self.main_layout.addWidget(input_widget, 0)
class NumberAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
decimals = self.attr_def.decimals
@ -83,6 +122,9 @@ class NumberAttrWidget(_BaseAttrDefWidget):
else:
input_widget = QtWidgets.QSpinBox(self)
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
input_widget.setMinimum(self.attr_def.minimum)
input_widget.setMaximum(self.attr_def.maximum)
input_widget.setValue(self.attr_def.default)
@ -136,6 +178,9 @@ class TextAttrWidget(_BaseAttrDefWidget):
):
input_widget.setPlaceholderText(self.attr_def.placeholder)
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
if self.attr_def.default:
if self.multiline:
input_widget.setPlainText(self.attr_def.default)
@ -184,6 +229,9 @@ class BoolAttrWidget(_BaseAttrDefWidget):
input_widget = NiceCheckbox(parent=self)
input_widget.setChecked(self.attr_def.default)
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
input_widget.stateChanged.connect(self._on_value_change)
self._input_widget = input_widget
@ -220,6 +268,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
combo_delegate = QtWidgets.QStyledItemDelegate(input_widget)
input_widget.setItemDelegate(combo_delegate)
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
items = self.attr_def.items
for key, label in items.items():
input_widget.addItem(label, key)
@ -281,3 +332,40 @@ class UnknownAttrWidget(_BaseAttrDefWidget):
if str_value != self._value:
self._value = str_value
self._input_widget.setText(str_value)
class FileAttrWidget(_BaseAttrDefWidget):
def _ui_init(self):
self.multipath = self.attr_def.multipath
if self.multipath:
from .files_widget import MultiFilesWidget
input_widget = MultiFilesWidget(self)
else:
from .files_widget import SingleFileWidget
input_widget = SingleFileWidget(self)
if self.attr_def.tooltip:
input_widget.setToolTip(self.attr_def.tooltip)
input_widget.set_filters(
self.attr_def.folders, self.attr_def.extensions
)
input_widget.value_changed.connect(self._on_value_change)
self._input_widget = input_widget
self.main_layout.addWidget(input_widget, 0)
def _on_value_change(self):
new_value = self.current_value()
self.value_changed.emit(new_value, self.attr_def.id)
def current_value(self):
return self._input_widget.current_value()
def set_value(self, value, multivalue=False):
self._input_widget.set_value(value, multivalue)

1678
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
version = "3.8.2-nightly.1" # OpenPype
version = "3.8.2" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team <info@openpype.io>"]
license = "MIT License"
@ -71,7 +71,7 @@ dropbox = "^11.20.0"
flake8 = "^3.7"
autopep8 = "^1.4"
coverage = "*"
cx_freeze = { version = "6.7", source = "openpype" }
cx_freeze = "~6.9"
GitPython = "^3.1.17"
jedi = "^0.13"
Jinja2 = "^2.11"

View file

@ -194,7 +194,6 @@ main () {
"$POETRY_HOME/bin/poetry" run pip install setuptools==49.6.0
"$POETRY_HOME/bin/poetry" run pip install --disable-pip-version-check --force-reinstall wheel
"$POETRY_HOME/bin/poetry" run python -m pip install --disable-pip-version-check --force-reinstall pip
"$POETRY_HOME/bin/poetry" run pip install --disable-pip-version-check --force-reinstall cx_freeze -i $openpype_index --extra-index-url https://pypi.org/simple
}
return_code=0

View file

@ -68,7 +68,7 @@ main () {
echo -e "${BIGreen}>>>${RST} Running docker build ..."
# docker build --pull --no-cache -t pypeclub/openpype:$openpype_version .
docker build --pull --iidfile $openpype_root/build/docker-image.id -t pypeclub/openpype:$openpype_version -f $dockerfile .
docker build --pull --iidfile $openpype_root/build/docker-image.id --build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') --build-arg VERSION=$openpype_version -t pypeclub/openpype:$openpype_version -f $dockerfile .
if [ $? -ne 0 ] ; then
echo $?
echo -e "${BIRed}!!!${RST} Docker build failed."