diff --git a/.github/workflows/milestone_assign.yml b/.github/workflows/milestone_assign.yml index c5a231e59e..4b52dfc30d 100644 --- a/.github/workflows/milestone_assign.yml +++ b/.github/workflows/milestone_assign.yml @@ -2,7 +2,7 @@ name: Milestone - assign to PRs on: pull_request_target: - types: [opened, reopened, edited, synchronize] + types: [closed] jobs: run_if_release: diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index addcbed24c..077f56d769 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -63,7 +63,8 @@ class OpenPypeVersion(semver.VersionInfo): """ staging = False path = None - _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?") # noqa: E501 + # this should match any string complying with https://semver.org/ + _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P[a-zA-Z\d\-.]*))?(?:\+(?P[a-zA-Z\d\-.]*))?") # noqa: E501 _installed_version = None def __init__(self, *args, **kwargs): @@ -211,6 +212,8 @@ class OpenPypeVersion(semver.VersionInfo): OpenPypeVersion: of detected or None. """ + # strip .zip ext if present + string = re.sub(r"\.zip$", "", string, flags=re.IGNORECASE) m = re.search(OpenPypeVersion._VERSION_REGEX, string) if not m: return None diff --git a/openpype/hooks/pre_copy_last_published_workfile.py b/openpype/hooks/pre_copy_last_published_workfile.py new file mode 100644 index 0000000000..44144e5fff --- /dev/null +++ b/openpype/hooks/pre_copy_last_published_workfile.py @@ -0,0 +1,177 @@ +import os +import shutil +from time import sleep +from openpype.client.entities import ( + get_last_version_by_subset_id, + get_representations, + get_subsets, +) +from openpype.lib import PreLaunchHook +from openpype.lib.local_settings import get_local_site_id +from openpype.lib.profiles_filtering import filter_profiles +from openpype.pipeline.load.utils import get_representation_path +from openpype.settings.lib import get_project_settings + + +class CopyLastPublishedWorkfile(PreLaunchHook): + """Copy last published workfile as first workfile. + + Prelaunch hook works only if last workfile leads to not existing file. + - That is possible only if it's first version. + """ + + # Before `AddLastWorkfileToLaunchArgs` + order = -1 + app_groups = ["blender", "photoshop", "tvpaint", "aftereffects"] + + def execute(self): + """Check if local workfile doesn't exist, else copy it. + + 1- Check if setting for this feature is enabled + 2- Check if workfile in work area doesn't exist + 3- Check if published workfile exists and is copied locally in publish + 4- Substitute copied published workfile as first workfile + + Returns: + None: This is a void method. + """ + + sync_server = self.modules_manager.get("sync_server") + if not sync_server or not sync_server.enabled: + self.log.deubg("Sync server module is not enabled or available") + return + + # Check there is no workfile available + last_workfile = self.data.get("last_workfile_path") + if os.path.exists(last_workfile): + self.log.debug( + "Last workfile exists. Skipping {} process.".format( + self.__class__.__name__ + ) + ) + return + + # Get data + project_name = self.data["project_name"] + task_name = self.data["task_name"] + task_type = self.data["task_type"] + host_name = self.application.host_name + + # Check settings has enabled it + project_settings = get_project_settings(project_name) + profiles = project_settings["global"]["tools"]["Workfiles"][ + "last_workfile_on_startup" + ] + filter_data = { + "tasks": task_name, + "task_types": task_type, + "hosts": host_name, + } + last_workfile_settings = filter_profiles(profiles, filter_data) + use_last_published_workfile = last_workfile_settings.get( + "use_last_published_workfile" + ) + if use_last_published_workfile is None: + self.log.info( + ( + "Seems like old version of settings is used." + ' Can\'t access custom templates in host "{}".'.format( + host_name + ) + ) + ) + return + elif use_last_published_workfile is False: + self.log.info( + ( + 'Project "{}" has turned off to use last published' + ' workfile as first workfile for host "{}"'.format( + project_name, host_name + ) + ) + ) + return + + self.log.info("Trying to fetch last published workfile...") + + project_doc = self.data.get("project_doc") + asset_doc = self.data.get("asset_doc") + anatomy = self.data.get("anatomy") + + # Check it can proceed + if not project_doc and not asset_doc: + return + + # Get subset id + subset_id = next( + ( + subset["_id"] + for subset in get_subsets( + project_name, + asset_ids=[asset_doc["_id"]], + fields=["_id", "data.family", "data.families"], + ) + if subset["data"].get("family") == "workfile" + # Legacy compatibility + or "workfile" in subset["data"].get("families", {}) + ), + None, + ) + if not subset_id: + self.log.debug( + 'No any workfile for asset "{}".'.format(asset_doc["name"]) + ) + return + + # Get workfile representation + last_version_doc = get_last_version_by_subset_id( + project_name, subset_id, fields=["_id"] + ) + if not last_version_doc: + self.log.debug("Subset does not have any versions") + return + + workfile_representation = next( + ( + representation + for representation in get_representations( + project_name, version_ids=[last_version_doc["_id"]] + ) + if representation["context"]["task"]["name"] == task_name + ), + None, + ) + + if not workfile_representation: + self.log.debug( + 'No published workfile for task "{}" and host "{}".'.format( + task_name, host_name + ) + ) + return + + local_site_id = get_local_site_id() + sync_server.add_site( + project_name, + workfile_representation["_id"], + local_site_id, + force=True, + priority=99, + reset_timer=True, + ) + + while not sync_server.is_representation_on_site( + project_name, workfile_representation["_id"], local_site_id + ): + sleep(5) + + # Get paths + published_workfile_path = get_representation_path( + workfile_representation, root=anatomy.roots + ) + local_workfile_dir = os.path.dirname(last_workfile) + + # Copy file and substitute path + self.data["last_workfile_path"] = shutil.copy( + published_workfile_path, local_workfile_dir + ) diff --git a/openpype/hosts/aftereffects/addon.py b/openpype/hosts/aftereffects/addon.py index 94843e7dc5..79df550312 100644 --- a/openpype/hosts/aftereffects/addon.py +++ b/openpype/hosts/aftereffects/addon.py @@ -1,5 +1,4 @@ -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon class AfterEffectsAddon(OpenPypeModule, IHostAddon): diff --git a/openpype/hosts/blender/addon.py b/openpype/hosts/blender/addon.py index 3ee638a5bb..f1da9b808c 100644 --- a/openpype/hosts/blender/addon.py +++ b/openpype/hosts/blender/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon BLENDER_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/flame/addon.py b/openpype/hosts/flame/addon.py index 5a34413bb0..d9359fc5bf 100644 --- a/openpype/hosts/flame/addon.py +++ b/openpype/hosts/flame/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon HOST_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/flame/api/menu.py b/openpype/hosts/flame/api/menu.py index f72a352bba..319ed7afb6 100644 --- a/openpype/hosts/flame/api/menu.py +++ b/openpype/hosts/flame/api/menu.py @@ -225,7 +225,8 @@ class FlameMenuUniversal(_FlameMenuApp): menu['actions'].append({ "name": "Load...", - "execute": lambda x: self.tools_helper.show_loader() + "execute": lambda x: callback_selection( + x, self.tools_helper.show_loader) }) menu['actions'].append({ "name": "Manage...", diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 092ce9d106..26129ebaa6 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -4,13 +4,13 @@ import shutil from copy import deepcopy from xml.etree import ElementTree as ET +import qargparse from Qt import QtCore, QtWidgets -import qargparse from openpype import style -from openpype.settings import get_current_project_settings from openpype.lib import Logger from openpype.pipeline import LegacyCreator, LoaderPlugin +from openpype.settings import get_current_project_settings from . import constants from . import lib as flib @@ -690,6 +690,54 @@ class ClipLoader(LoaderPlugin): ) ] + _mapping = None + + def get_colorspace(self, context): + """Get colorspace name + + Look either to version data or representation data. + + Args: + context (dict): version context data + + Returns: + str: colorspace name or None + """ + version = context['version'] + version_data = version.get("data", {}) + colorspace = version_data.get( + "colorspace", None + ) + + if ( + not colorspace + or colorspace == "Unknown" + ): + colorspace = context["representation"]["data"].get( + "colorspace", None) + + return colorspace + + @classmethod + def get_native_colorspace(cls, input_colorspace): + """Return native colorspace name. + + Args: + input_colorspace (str | None): colorspace name + + Returns: + str: native colorspace name defined in mapping or None + """ + if not cls._mapping: + settings = get_current_project_settings()["flame"] + mapping = settings["imageio"]["profilesMapping"]["inputs"] + cls._mapping = { + input["ocioName"]: input["flameName"] + for input in mapping + } + + return cls._mapping.get(input_colorspace) + class OpenClipSolver(flib.MediaInfoFile): create_new_clip = False diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 0843dde76a..f8cb7b3e11 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -36,14 +36,15 @@ class LoadClip(opfapi.ClipLoader): version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) - colorspace = version_data.get("colorspace", None) + colorspace = self.get_colorspace(context) + clip_name = StringTemplate(self.clip_name_template).format( context["representation"]["context"]) - # TODO: settings in imageio # convert colorspace with ocio to flame mapping # in imageio flame section - colorspace = colorspace + colorspace = self.get_native_colorspace(colorspace) + self.log.info("Loading with colorspace: `{}`".format(colorspace)) # create workfile path workfile_dir = os.environ["AVALON_WORKDIR"] diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index 3b049b861b..048ac19431 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -1,3 +1,4 @@ +from copy import deepcopy import os import flame from pprint import pformat @@ -22,7 +23,7 @@ class LoadClipBatch(opfapi.ClipLoader): # settings reel_name = "OP_LoadedReel" - clip_name_template = "{asset}_{subset}<_{output}>" + clip_name_template = "{batch}_{asset}_{subset}<_{output}>" def load(self, context, name, namespace, options): @@ -34,19 +35,22 @@ class LoadClipBatch(opfapi.ClipLoader): version = context['version'] version_data = version.get("data", {}) version_name = version.get("name", None) - colorspace = version_data.get("colorspace", None) + colorspace = self.get_colorspace(context) # in case output is not in context replace key to representation if not context["representation"]["context"].get("output"): self.clip_name_template.replace("output", "representation") - clip_name = StringTemplate(self.clip_name_template).format( - context["representation"]["context"]) + formating_data = deepcopy(context["representation"]["context"]) + formating_data["batch"] = self.batch.name.get_value() + + clip_name = StringTemplate(self.clip_name_template).format( + formating_data) - # TODO: settings in imageio # convert colorspace with ocio to flame mapping # in imageio flame section - colorspace = colorspace + colorspace = self.get_native_colorspace(colorspace) + self.log.info("Loading with colorspace: `{}`".format(colorspace)) # create workfile path workfile_dir = options.get("workdir") or os.environ["AVALON_WORKDIR"] @@ -56,6 +60,7 @@ class LoadClipBatch(opfapi.ClipLoader): openclip_path = os.path.join( openclip_dir, clip_name + ".clip" ) + if not os.path.exists(openclip_dir): os.makedirs(openclip_dir) diff --git a/openpype/hosts/fusion/addon.py b/openpype/hosts/fusion/addon.py index 1913cc2e30..d1bd1566b7 100644 --- a/openpype/hosts/fusion/addon.py +++ b/openpype/hosts/fusion/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon FUSION_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/harmony/addon.py b/openpype/hosts/harmony/addon.py index 872a7490b5..efef40ab92 100644 --- a/openpype/hosts/harmony/addon.py +++ b/openpype/hosts/harmony/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon HARMONY_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/hiero/addon.py b/openpype/hosts/hiero/addon.py index 3523e9aed7..f5bb94dbaa 100644 --- a/openpype/hosts/hiero/addon.py +++ b/openpype/hosts/hiero/addon.py @@ -1,7 +1,6 @@ import os import platform -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon HIERO_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index ea8a9e836a..5ec1c78aaa 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -170,7 +170,10 @@ class CreatorWidget(QtWidgets.QDialog): for func, val in kwargs.items(): if getattr(item, func): func_attr = getattr(item, func) - func_attr(val) + if isinstance(val, tuple): + func_attr(*val) + else: + func_attr(val) # add to layout layout.addRow(label, item) @@ -273,8 +276,8 @@ class CreatorWidget(QtWidgets.QDialog): elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], - setValue=v["value"], setMinimum=0, - setMaximum=100000, setToolTip=tool_tip) + setRange=(1, 9999999), setValue=v["value"], + setToolTip=tool_tip) return data diff --git a/openpype/hosts/houdini/addon.py b/openpype/hosts/houdini/addon.py index 8d88e83c56..80856b0624 100644 --- a/openpype/hosts/houdini/addon.py +++ b/openpype/hosts/houdini/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon HOUDINI_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/maya/addon.py b/openpype/hosts/maya/addon.py index cdd2bc1667..b9ecb8279f 100644 --- a/openpype/hosts/maya/addon.py +++ b/openpype/hosts/maya/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon MAYA_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/maya/plugins/load/load_abc_to_standin.py b/openpype/hosts/maya/plugins/load/load_abc_to_standin.py new file mode 100644 index 0000000000..605a492e4d --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_abc_to_standin.py @@ -0,0 +1,132 @@ +import os + +from openpype.pipeline import ( + legacy_io, + load, + get_representation_path +) +from openpype.settings import get_project_settings + + +class AlembicStandinLoader(load.LoaderPlugin): + """Load Alembic as Arnold Standin""" + + families = ["animation", "model", "pointcache"] + representations = ["abc"] + + label = "Import Alembic as Arnold Standin" + order = -5 + icon = "code-fork" + color = "orange" + + def load(self, context, name, namespace, options): + + import maya.cmds as cmds + import mtoa.ui.arnoldmenu + from openpype.hosts.maya.api.pipeline import containerise + from openpype.hosts.maya.api.lib import unique_namespace + + version = context["version"] + version_data = version.get("data", {}) + family = version["data"]["families"] + self.log.info("version_data: {}\n".format(version_data)) + self.log.info("family: {}\n".format(family)) + frameStart = version_data.get("frameStart", None) + + asset = context["asset"]["name"] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + # Root group + label = "{}:{}".format(namespace, name) + root = cmds.group(name=label, empty=True) + + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings["maya"]["load"]["colors"] + fps = legacy_io.Session["AVALON_FPS"] + c = colors.get(family[0]) + if c is not None: + r = (float(c[0]) / 255) + g = (float(c[1]) / 255) + b = (float(c[2]) / 255) + cmds.setAttr(root + ".useOutlinerColor", 1) + cmds.setAttr(root + ".outlinerColor", + r, g, b) + + transform_name = label + "_ABC" + + standinShape = cmds.ls(mtoa.ui.arnoldmenu.createStandIn())[0] + standin = cmds.listRelatives(standinShape, parent=True, + typ="transform") + standin = cmds.rename(standin, transform_name) + standinShape = cmds.listRelatives(standin, children=True)[0] + + cmds.parent(standin, root) + + # Set the standin filepath + cmds.setAttr(standinShape + ".dso", self.fname, type="string") + cmds.setAttr(standinShape + ".abcFPS", float(fps)) + + if frameStart is None: + cmds.setAttr(standinShape + ".useFrameExtension", 0) + + elif "model" in family: + cmds.setAttr(standinShape + ".useFrameExtension", 0) + + else: + cmds.setAttr(standinShape + ".useFrameExtension", 1) + + nodes = [root, standin] + self[:] = nodes + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__) + + def update(self, container, representation): + + import pymel.core as pm + + path = get_representation_path(representation) + fps = legacy_io.Session["AVALON_FPS"] + # Update the standin + standins = list() + members = pm.sets(container['objectName'], query=True) + self.log.info("container:{}".format(container)) + for member in members: + shape = member.getShape() + if (shape and shape.type() == "aiStandIn"): + standins.append(shape) + + for standin in standins: + standin.dso.set(path) + standin.abcFPS.set(float(fps)) + if "modelMain" in container['objectName']: + standin.useFrameExtension.set(0) + else: + standin.useFrameExtension.set(1) + + container = pm.PyNode(container["objectName"]) + container.representation.set(str(representation["_id"])) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + import maya.cmds as cmds + members = cmds.sets(container['objectName'], query=True) + cmds.lockNode(members, lock=False) + cmds.delete([container['objectName']] + members) + + # Clean up the namespace + try: + cmds.namespace(removeNamespace=container['namespace'], + deleteNamespaceContent=True) + except RuntimeError: + pass diff --git a/openpype/hosts/maya/plugins/load/load_yeti_cache.py b/openpype/hosts/maya/plugins/load/load_yeti_cache.py index 090047e22d..5ba381050a 100644 --- a/openpype/hosts/maya/plugins/load/load_yeti_cache.py +++ b/openpype/hosts/maya/plugins/load/load_yeti_cache.py @@ -73,8 +73,8 @@ class YetiCacheLoader(load.LoaderPlugin): c = colors.get(family) if c is not None: - cmds.setAttr(group_name + ".useOutlinerColor", 1) - cmds.setAttr(group_name + ".outlinerColor", + cmds.setAttr(group_node + ".useOutlinerColor", 1) + cmds.setAttr(group_node + ".outlinerColor", (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) diff --git a/openpype/hosts/nuke/addon.py b/openpype/hosts/nuke/addon.py index 54e4da5195..1c5d5c4005 100644 --- a/openpype/hosts/nuke/addon.py +++ b/openpype/hosts/nuke/addon.py @@ -1,7 +1,6 @@ import os import platform -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon NUKE_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/nuke/plugins/load/load_clip.py b/openpype/hosts/nuke/plugins/load/load_clip.py index 666312167f..b17356c5c7 100644 --- a/openpype/hosts/nuke/plugins/load/load_clip.py +++ b/openpype/hosts/nuke/plugins/load/load_clip.py @@ -1,7 +1,8 @@ -import os import nuke import qargparse - +from pprint import pformat +from copy import deepcopy +from openpype.lib import Logger from openpype.client import ( get_version_by_id, get_last_version_by_subset_id, @@ -28,6 +29,7 @@ class LoadClip(plugin.NukeLoader): Either it is image sequence or video file. """ + log = Logger.get_logger(__name__) families = [ "source", @@ -85,24 +87,19 @@ class LoadClip(plugin.NukeLoader): + plugin.get_review_presets_config() ) - def _fix_path_for_knob(self, filepath, repre_cont): - basename = os.path.basename(filepath) - dirname = os.path.dirname(filepath) - frame = repre_cont.get("frame") - assert frame, "Representation is not sequence" - - padding = len(str(frame)) - basename = basename.replace(frame, "#" * padding) - return os.path.join(dirname, basename).replace("\\", "/") - def load(self, context, name, namespace, options): - repre = context["representation"] + representation = context["representation"] # reste container id so it is always unique for each instance self.reset_container_id() - is_sequence = len(repre["files"]) > 1 + is_sequence = len(representation["files"]) > 1 - filepath = self.fname.replace("\\", "/") + if is_sequence: + representation = self._representation_with_hash_in_frame( + representation + ) + filepath = get_representation_path(representation).replace("\\", "/") + self.log.debug("_ filepath: {}".format(filepath)) start_at_workfile = options.get( "start_at_workfile", self.options_defaults["start_at_workfile"]) @@ -112,11 +109,10 @@ class LoadClip(plugin.NukeLoader): version = context['version'] version_data = version.get("data", {}) - repre_id = repre["_id"] + repre_id = representation["_id"] - repre_cont = repre["context"] - - self.log.info("version_data: {}\n".format(version_data)) + self.log.debug("_ version_data: {}\n".format( + pformat(version_data))) self.log.debug( "Representation id `{}` ".format(repre_id)) @@ -132,8 +128,6 @@ class LoadClip(plugin.NukeLoader): duration = last - first first = 1 last = first + duration - elif "#" not in filepath: - filepath = self._fix_path_for_knob(filepath, repre_cont) # Fallback to asset name when namespace is None if namespace is None: @@ -144,7 +138,7 @@ class LoadClip(plugin.NukeLoader): "Representation id `{}` is failing to load".format(repre_id)) return - read_name = self._get_node_name(repre) + read_name = self._get_node_name(representation) # Create the Loader with the filename path set read_node = nuke.createNode( @@ -157,7 +151,7 @@ class LoadClip(plugin.NukeLoader): read_node["file"].setValue(filepath) used_colorspace = self._set_colorspace( - read_node, version_data, repre["data"]) + read_node, version_data, representation["data"]) self._set_range_to_node(read_node, first, last, start_at_workfile) @@ -179,7 +173,7 @@ class LoadClip(plugin.NukeLoader): data_imprint[k] = version elif k == 'colorspace': - colorspace = repre["data"].get(k) + colorspace = representation["data"].get(k) colorspace = colorspace or version_data.get(k) data_imprint["db_colorspace"] = colorspace if used_colorspace: @@ -213,6 +207,20 @@ class LoadClip(plugin.NukeLoader): def switch(self, container, representation): self.update(container, representation) + def _representation_with_hash_in_frame(self, representation): + """Convert frame key value to padded hash + + Args: + representation (dict): representation data + + Returns: + dict: altered representation data + """ + representation = deepcopy(representation) + frame = representation["context"]["frame"] + representation["context"]["frame"] = "#" * len(str(frame)) + return representation + def update(self, container, representation): """Update the Loader's path @@ -225,7 +233,13 @@ class LoadClip(plugin.NukeLoader): is_sequence = len(representation["files"]) > 1 read_node = nuke.toNode(container['objectName']) + + if is_sequence: + representation = self._representation_with_hash_in_frame( + representation + ) filepath = get_representation_path(representation).replace("\\", "/") + self.log.debug("_ filepath: {}".format(filepath)) start_at_workfile = "start at" in read_node['frame_mode'].value() @@ -240,8 +254,6 @@ class LoadClip(plugin.NukeLoader): version_data = version_doc.get("data", {}) repre_id = representation["_id"] - repre_cont = representation["context"] - # colorspace profile colorspace = representation["data"].get("colorspace") colorspace = colorspace or version_data.get("colorspace") @@ -258,8 +270,6 @@ class LoadClip(plugin.NukeLoader): duration = last - first first = 1 last = first + duration - elif "#" not in filepath: - filepath = self._fix_path_for_knob(filepath, repre_cont) if not filepath: self.log.warning( @@ -348,8 +358,10 @@ class LoadClip(plugin.NukeLoader): time_warp_nodes = version_data.get('timewarps', []) last_node = None source_id = self.get_container_id(parent_node) - self.log.info("__ source_id: {}".format(source_id)) - self.log.info("__ members: {}".format(self.get_members(parent_node))) + self.log.debug("__ source_id: {}".format(source_id)) + self.log.debug("__ members: {}".format( + self.get_members(parent_node))) + dependent_nodes = self.clear_members(parent_node) with maintained_selection(): diff --git a/openpype/hosts/photoshop/addon.py b/openpype/hosts/photoshop/addon.py index a41d91554b..965a545ac5 100644 --- a/openpype/hosts/photoshop/addon.py +++ b/openpype/hosts/photoshop/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon PHOTOSHOP_HOST_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/resolve/addon.py b/openpype/hosts/resolve/addon.py index a31da52a6d..02c1d7957f 100644 --- a/openpype/hosts/resolve/addon.py +++ b/openpype/hosts/resolve/addon.py @@ -1,7 +1,6 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon from .utils import RESOLVE_ROOT_DIR diff --git a/openpype/hosts/standalonepublisher/addon.py b/openpype/hosts/standalonepublisher/addon.py index 98ec44d4e2..65a4226664 100644 --- a/openpype/hosts/standalonepublisher/addon.py +++ b/openpype/hosts/standalonepublisher/addon.py @@ -4,8 +4,7 @@ import click from openpype.lib import get_openpype_execute_args from openpype.lib.execute import run_detached_process -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import ITrayAction, IHostAddon +from openpype.modules import OpenPypeModule, ITrayAction, IHostAddon STANDALONEPUBLISH_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/traypublisher/addon.py b/openpype/hosts/traypublisher/addon.py index c86c835ed9..c157799898 100644 --- a/openpype/hosts/traypublisher/addon.py +++ b/openpype/hosts/traypublisher/addon.py @@ -4,8 +4,7 @@ import click from openpype.lib import get_openpype_execute_args from openpype.lib.execute import run_detached_process -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import ITrayAction, IHostAddon +from openpype.modules import OpenPypeModule, ITrayAction, IHostAddon TRAYPUBLISH_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 555041d389..75930f0f31 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -1,49 +1,33 @@ from openpype.lib.attribute_definitions import FileDef +from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS from openpype.pipeline.create import ( Creator, HiddenCreator, - CreatedInstance + CreatedInstance, + cache_and_get_instances, + PRE_CREATE_THUMBNAIL_KEY, ) - from .pipeline import ( list_instances, update_instances, remove_instances, HostContext, ) -from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS - -REVIEW_EXTENSIONS = IMAGE_EXTENSIONS + VIDEO_EXTENSIONS - - -def _cache_and_get_instances(creator): - """Cache instances in shared data. - - Args: - creator (Creator): Plugin which would like to get instances from host. - - Returns: - List[Dict[str, Any]]: Cached instances list from host implementation. - """ - - shared_key = "openpype.traypublisher.instances" - if shared_key not in creator.collection_shared_data: - creator.collection_shared_data[shared_key] = list_instances() - return creator.collection_shared_data[shared_key] +REVIEW_EXTENSIONS = set(IMAGE_EXTENSIONS) | set(VIDEO_EXTENSIONS) +SHARED_DATA_KEY = "openpype.traypublisher.instances" class HiddenTrayPublishCreator(HiddenCreator): host_name = "traypublisher" def collect_instances(self): - for instance_data in _cache_and_get_instances(self): - creator_id = instance_data.get("creator_identifier") - if creator_id == self.identifier: - instance = CreatedInstance.from_existing( - instance_data, self - ) - self._add_instance_to_context(instance) + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, list_instances + ) + for instance_data in instances_by_identifier[self.identifier]: + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) def update_instances(self, update_list): update_instances(update_list) @@ -74,13 +58,12 @@ class TrayPublishCreator(Creator): host_name = "traypublisher" def collect_instances(self): - for instance_data in _cache_and_get_instances(self): - creator_id = instance_data.get("creator_identifier") - if creator_id == self.identifier: - instance = CreatedInstance.from_existing( - instance_data, self - ) - self._add_instance_to_context(instance) + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, list_instances + ) + for instance_data in instances_by_identifier[self.identifier]: + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) def update_instances(self, update_list): update_instances(update_list) @@ -110,11 +93,14 @@ class TrayPublishCreator(Creator): class SettingsCreator(TrayPublishCreator): create_allow_context_change = True + create_allow_thumbnail = True extensions = [] def create(self, subset_name, data, pre_create_data): # Pass precreate data to creator attributes + thumbnail_path = pre_create_data.pop(PRE_CREATE_THUMBNAIL_KEY, None) + data["creator_attributes"] = pre_create_data data["settings_creator"] = True # Create new instance @@ -122,6 +108,9 @@ class SettingsCreator(TrayPublishCreator): self._store_new_instance(new_instance) + if thumbnail_path: + self.set_instance_thumbnail_path(new_instance.id, thumbnail_path) + def get_instance_attr_defs(self): return [ FileDef( diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_movie_batch.py b/openpype/hosts/traypublisher/plugins/publish/collect_movie_batch.py index 3d93e2c927..5f8b2878b7 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_movie_batch.py @@ -40,7 +40,8 @@ class CollectMovieBatch( if creator_attributes["add_review_family"]: repre["tags"].append("review") instance.data["families"].append("review") - instance.data["thumbnailSource"] = file_url + if not instance.data.get("thumbnailSource"): + instance.data["thumbnailSource"] = file_url instance.data["source"] = file_url diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py index 7035a61d7b..183195a515 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -188,7 +188,8 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): if "review" not in instance.data["families"]: instance.data["families"].append("review") - instance.data["thumbnailSource"] = first_filepath + if not instance.data.get("thumbnailSource"): + instance.data["thumbnailSource"] = first_filepath review_representation["tags"].append("review") self.log.debug("Representation {} was marked for review. {}".format( diff --git a/openpype/hosts/tvpaint/addon.py b/openpype/hosts/tvpaint/addon.py index d710e63f93..b695bf8ecc 100644 --- a/openpype/hosts/tvpaint/addon.py +++ b/openpype/hosts/tvpaint/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon TVPAINT_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/tvpaint/plugins/load/load_image.py b/openpype/hosts/tvpaint/plugins/load/load_image.py index 151db94135..5283d04355 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_image.py @@ -1,4 +1,4 @@ -import qargparse +from openpype.lib.attribute_definitions import BoolDef from openpype.hosts.tvpaint.api import plugin from openpype.hosts.tvpaint.api.lib import execute_george_through_file @@ -27,26 +27,28 @@ class ImportImage(plugin.Loader): "preload": True } - options = [ - qargparse.Boolean( - "stretch", - label="Stretch to project size", - default=True, - help="Stretch loaded image/s to project resolution?" - ), - qargparse.Boolean( - "timestretch", - label="Stretch to timeline length", - default=True, - help="Clip loaded image/s to timeline length?" - ), - qargparse.Boolean( - "preload", - label="Preload loaded image/s", - default=True, - help="Preload image/s?" - ) - ] + @classmethod + def get_options(cls, contexts): + return [ + BoolDef( + "stretch", + label="Stretch to project size", + default=cls.defaults["stretch"], + tooltip="Stretch loaded image/s to project resolution?" + ), + BoolDef( + "timestretch", + label="Stretch to timeline length", + default=cls.defaults["timestretch"], + tooltip="Clip loaded image/s to timeline length?" + ), + BoolDef( + "preload", + label="Preload loaded image/s", + default=cls.defaults["preload"], + tooltip="Preload image/s?" + ) + ] def load(self, context, name, namespace, options): stretch = options.get("stretch", self.defaults["stretch"]) diff --git a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py index 393236fba6..7f7a68cc41 100644 --- a/openpype/hosts/tvpaint/plugins/load/load_reference_image.py +++ b/openpype/hosts/tvpaint/plugins/load/load_reference_image.py @@ -1,7 +1,6 @@ import collections -import qargparse - +from openpype.lib.attribute_definitions import BoolDef from openpype.pipeline import ( get_representation_context, register_host, @@ -42,26 +41,28 @@ class LoadImage(plugin.Loader): "preload": True } - options = [ - qargparse.Boolean( - "stretch", - label="Stretch to project size", - default=True, - help="Stretch loaded image/s to project resolution?" - ), - qargparse.Boolean( - "timestretch", - label="Stretch to timeline length", - default=True, - help="Clip loaded image/s to timeline length?" - ), - qargparse.Boolean( - "preload", - label="Preload loaded image/s", - default=True, - help="Preload image/s?" - ) - ] + @classmethod + def get_options(cls, contexts): + return [ + BoolDef( + "stretch", + label="Stretch to project size", + default=cls.defaults["stretch"], + tooltip="Stretch loaded image/s to project resolution?" + ), + BoolDef( + "timestretch", + label="Stretch to timeline length", + default=cls.defaults["timestretch"], + tooltip="Clip loaded image/s to timeline length?" + ), + BoolDef( + "preload", + label="Preload loaded image/s", + default=cls.defaults["preload"], + tooltip="Preload image/s?" + ) + ] def load(self, context, name, namespace, options): stretch = options.get("stretch", self.defaults["stretch"]) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py index f291c363b8..d5b79758ad 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instance_frames.py @@ -6,7 +6,7 @@ class CollectOutputFrameRange(pyblish.api.ContextPlugin): When instances are collected context does not contain `frameStart` and `frameEnd` keys yet. They are collected in global plugin - `CollectAvalonEntities`. + `CollectContextEntities`. """ label = "Collect output frame range" order = pyblish.api.CollectorOrder diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py index 12d50e17ff..0030b0fd1c 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_marks.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_marks.py @@ -39,7 +39,7 @@ class ValidateMarks(pyblish.api.ContextPlugin): def get_expected_data(context): scene_mark_in = context.data["sceneMarkIn"] - # Data collected in `CollectAvalonEntities` + # Data collected in `CollectContextEntities` frame_end = context.data["frameEnd"] frame_start = context.data["frameStart"] handle_start = context.data["handleStart"] diff --git a/openpype/hosts/unreal/addon.py b/openpype/hosts/unreal/addon.py index 16736214c5..e2c8484651 100644 --- a/openpype/hosts/unreal/addon.py +++ b/openpype/hosts/unreal/addon.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon UNREAL_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/webpublisher/addon.py b/openpype/hosts/webpublisher/addon.py index a64d74e62b..eb7fced2e6 100644 --- a/openpype/hosts/webpublisher/addon.py +++ b/openpype/hosts/webpublisher/addon.py @@ -2,8 +2,7 @@ import os import click -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IHostAddon +from openpype.modules import OpenPypeModule, IHostAddon WEBPUBLISHER_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py index dd4646f356..2bf097de41 100644 --- a/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py +++ b/openpype/hosts/webpublisher/plugins/publish/collect_published_files.py @@ -83,8 +83,9 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): self.log.info("task_data:: {}".format(task_data)) is_sequence = len(task_data["files"]) > 1 + first_file = task_data["files"][0] - _, extension = os.path.splitext(task_data["files"][0]) + _, extension = os.path.splitext(first_file) family, families, tags = self._get_family( self.task_type_to_family, task_type, @@ -149,10 +150,13 @@ class CollectPublishedFiles(pyblish.api.ContextPlugin): self.log.warning("Unable to count frames " "duration {}".format(no_of_frames)) - # raise ValueError("STOP") instance.data["handleStart"] = asset_doc["data"]["handleStart"] instance.data["handleEnd"] = asset_doc["data"]["handleEnd"] + if "review" in tags: + first_file_path = os.path.join(task_dir, first_file) + instance.data["thumbnailSource"] = first_file_path + instances.append(instance) self.log.info("instance.data:: {}".format(instance.data)) diff --git a/openpype/hosts/webpublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/webpublisher/plugins/publish/extract_thumbnail.py deleted file mode 100644 index a56521891b..0000000000 --- a/openpype/hosts/webpublisher/plugins/publish/extract_thumbnail.py +++ /dev/null @@ -1,137 +0,0 @@ -import os -import shutil - -import pyblish.api -from openpype.lib import ( - get_ffmpeg_tool_path, - - run_subprocess, - - get_transcode_temp_directory, - convert_input_paths_for_ffmpeg, - should_convert_for_ffmpeg -) - - -class ExtractThumbnail(pyblish.api.InstancePlugin): - """Create jpg thumbnail from input using ffmpeg.""" - - label = "Extract Thumbnail" - order = pyblish.api.ExtractorOrder - families = [ - "render", - "image" - ] - hosts = ["webpublisher"] - targets = ["filespublish"] - - def process(self, instance): - self.log.info("subset {}".format(instance.data['subset'])) - - filtered_repres = self._get_filtered_repres(instance) - for repre in filtered_repres: - repre_files = repre["files"] - if not isinstance(repre_files, (list, tuple)): - input_file = repre_files - else: - file_index = int(float(len(repre_files)) * 0.5) - input_file = repre_files[file_index] - - stagingdir = os.path.normpath(repre["stagingDir"]) - - full_input_path = os.path.join(stagingdir, input_file) - self.log.info("Input filepath: {}".format(full_input_path)) - - do_convert = should_convert_for_ffmpeg(full_input_path) - # If result is None the requirement of conversion can't be - # determined - if do_convert is None: - self.log.info(( - "Can't determine if representation requires conversion." - " Skipped." - )) - continue - - # Do conversion if needed - # - change staging dir of source representation - # - must be set back after output definitions processing - convert_dir = None - if do_convert: - convert_dir = get_transcode_temp_directory() - filename = os.path.basename(full_input_path) - convert_input_paths_for_ffmpeg( - [full_input_path], - convert_dir, - self.log - ) - full_input_path = os.path.join(convert_dir, filename) - - filename = os.path.splitext(input_file)[0] - while filename.endswith("."): - filename = filename[:-1] - thumbnail_filename = filename + "_thumbnail.jpg" - full_output_path = os.path.join(stagingdir, thumbnail_filename) - - self.log.info("output {}".format(full_output_path)) - - ffmpeg_args = [ - get_ffmpeg_tool_path("ffmpeg"), - "-y", - "-i", full_input_path, - "-vframes", "1", - full_output_path - ] - - # run subprocess - self.log.debug("{}".format(" ".join(ffmpeg_args))) - try: # temporary until oiiotool is supported cross platform - run_subprocess( - ffmpeg_args, logger=self.log - ) - except RuntimeError as exp: - if "Compression" in str(exp): - self.log.debug( - "Unsupported compression on input files. Skipping!!!" - ) - return - self.log.warning("Conversion crashed", exc_info=True) - raise - - new_repre = { - "name": "thumbnail", - "ext": "jpg", - "files": thumbnail_filename, - "stagingDir": stagingdir, - "thumbnail": True, - "tags": ["thumbnail"] - } - - # adding representation - self.log.debug("Adding: {}".format(new_repre)) - instance.data["representations"].append(new_repre) - - # Cleanup temp folder - if convert_dir is not None and os.path.exists(convert_dir): - shutil.rmtree(convert_dir) - - def _get_filtered_repres(self, instance): - filtered_repres = [] - repres = instance.data.get("representations") or [] - for repre in repres: - self.log.debug(repre) - tags = repre.get("tags") or [] - # Skip instance if already has thumbnail representation - if "thumbnail" in tags: - return [] - - if "review" not in tags: - continue - - if not repre.get("files"): - self.log.info(( - "Representation \"{}\" don't have files. Skipping" - ).format(repre["name"])) - continue - - filtered_repres.append(repre) - return filtered_repres diff --git a/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py b/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py index a5e4868411..d8b7bb9078 100644 --- a/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py +++ b/openpype/hosts/webpublisher/plugins/publish/validate_tvpaint_workfile_data.py @@ -13,7 +13,7 @@ class ValidateWorkfileData(pyblish.api.ContextPlugin): targets = ["tvpaint_worker"] def process(self, context): - # Data collected in `CollectAvalonEntities` + # Data collected in `CollectContextEntities` frame_start = context.data["frameStart"] frame_end = context.data["frameEnd"] handle_start = context.data["handleStart"] diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index bb0b07948f..6baeaec045 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -91,7 +91,7 @@ class AbstractAttrDefMeta(ABCMeta): @six.add_metaclass(AbstractAttrDefMeta) -class AbtractAttrDef: +class AbtractAttrDef(object): """Abstraction of attribute definiton. Each attribute definition must have implemented validation and @@ -541,6 +541,13 @@ class FileDefItem(object): return ext return None + @property + def lower_ext(self): + ext = self.ext + if ext is not None: + return ext.lower() + return ext + @property def is_dir(self): if self.is_empty: diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index e736ba8ef0..0bfccd3443 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -42,7 +42,7 @@ XML_CHAR_REF_REGEX_HEX = re.compile(r"&#x?[0-9a-fA-F]+;") # Regex to parse array attributes ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$") -IMAGE_EXTENSIONS = [ +IMAGE_EXTENSIONS = { ".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave", ".cal", ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr", ".fits", ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc", ".icer", @@ -54,15 +54,15 @@ IMAGE_EXTENSIONS = [ ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf", ".xpm", ".xwd" -] +} -VIDEO_EXTENSIONS = [ +VIDEO_EXTENSIONS = { ".3g2", ".3gp", ".amv", ".asf", ".avi", ".drc", ".f4a", ".f4b", ".f4p", ".f4v", ".flv", ".gif", ".gifv", ".m2v", ".m4p", ".m4v", ".mkv", ".mng", ".mov", ".mp2", ".mp4", ".mpe", ".mpeg", ".mpg", ".mpv", ".mxf", ".nsv", ".ogg", ".ogv", ".qt", ".rm", ".rmvb", ".roq", ".svi", ".vob", ".webm", ".wmv", ".yuv" -] +} def get_transcode_temp_directory(): diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 02e7dc13ab..1f345feea9 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -1,4 +1,14 @@ # -*- coding: utf-8 -*- +from .interfaces import ( + ILaunchHookPaths, + IPluginPaths, + ITrayModule, + ITrayAction, + ITrayService, + ISettingsChangeListener, + IHostAddon, +) + from .base import ( OpenPypeModule, OpenPypeAddOn, @@ -17,6 +27,14 @@ from .base import ( __all__ = ( + "ILaunchHookPaths", + "IPluginPaths", + "ITrayModule", + "ITrayAction", + "ITrayService", + "ISettingsChangeListener", + "IHostAddon", + "OpenPypeModule", "OpenPypeAddOn", diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index 1d21de129b..f9085522b0 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -1,7 +1,6 @@ import os -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayModule +from openpype.modules import OpenPypeModule, ITrayModule class AvalonModule(OpenPypeModule, ITrayModule): diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 09aea50424..4761462df0 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -9,6 +9,7 @@ import logging import platform import threading import collections +import traceback from uuid import uuid4 from abc import ABCMeta, abstractmethod import six @@ -139,6 +140,15 @@ class _InterfacesClass(_ModuleClass): "cannot import name '{}' from 'openpype_interfaces'" ).format(attr_name)) + if _LoadCache.interfaces_loaded and attr_name != "log": + stack = list(traceback.extract_stack()) + stack.pop(-1) + self.log.warning(( + "Using deprecated import of \"{}\" from 'openpype_interfaces'." + " Please switch to use import" + " from 'openpype.modules.interfaces'" + " (will be removed after 3.16.x).{}" + ).format(attr_name, "".join(traceback.format_list(stack)))) return self.__attributes__[attr_name] diff --git a/openpype/modules/clockify/clockify_module.py b/openpype/modules/clockify/clockify_module.py index 932ce87c36..14fcb01f67 100644 --- a/openpype/modules/clockify/clockify_module.py +++ b/openpype/modules/clockify/clockify_module.py @@ -2,16 +2,17 @@ import os import threading import time +from openpype.modules import ( + OpenPypeModule, + ITrayModule, + IPluginPaths +) + from .clockify_api import ClockifyAPI from .constants import ( CLOCKIFY_FTRACK_USER_PATH, CLOCKIFY_FTRACK_SERVER_PATH ) -from openpype.modules import OpenPypeModule -from openpype_interfaces import ( - ITrayModule, - IPluginPaths -) class ClockifyModule( diff --git a/openpype/modules/deadline/deadline_module.py b/openpype/modules/deadline/deadline_module.py index bbd0f74e8a..9855f8c1b1 100644 --- a/openpype/modules/deadline/deadline_module.py +++ b/openpype/modules/deadline/deadline_module.py @@ -4,8 +4,7 @@ import six import sys from openpype.lib import requests_get, Logger -from openpype.modules import OpenPypeModule -from openpype_interfaces import IPluginPaths +from openpype.modules import OpenPypeModule, IPluginPaths class DeadlineWebserviceError(Exception): diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index aba505b3c6..35f2532c16 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -457,9 +457,15 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): cam = [c for c in cameras if c in col.head] if cam: - subset_name = '{}_{}_{}'.format(group_name, cam, aov) + if aov: + subset_name = '{}_{}_{}'.format(group_name, cam, aov) + else: + subset_name = '{}_{}'.format(group_name, cam) else: - subset_name = '{}_{}'.format(group_name, aov) + if aov: + subset_name = '{}_{}'.format(group_name, aov) + else: + subset_name = '{}'.format(group_name) if isinstance(col, (list, tuple)): staging = os.path.dirname(col[0]) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index 61b95cf06d..9b35c9502d 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -7,7 +7,12 @@ import json import platform import uuid import re -from Deadline.Scripting import RepositoryUtils, FileUtils, DirectoryUtils +from Deadline.Scripting import ( + RepositoryUtils, + FileUtils, + DirectoryUtils, + ProcessUtils, +) def get_openpype_version_from_path(path, build=True): @@ -162,9 +167,8 @@ def inject_openpype_environment(deadlinePlugin): print(">>> Temporary path: {}".format(export_url)) args = [ - exe, "--headless", - 'extractenvironments', + "extractenvironments", export_url ] @@ -188,15 +192,18 @@ def inject_openpype_environment(deadlinePlugin): if not os.environ.get("OPENPYPE_MONGO"): print(">>> Missing OPENPYPE_MONGO env var, process won't work") - env = os.environ - env["OPENPYPE_HEADLESS_MODE"] = "1" - env["AVALON_TIMEOUT"] = "5000" + os.environ["AVALON_TIMEOUT"] = "5000" - print(">>> Executing: {}".format(" ".join(args))) - std_output = subprocess.check_output(args, - cwd=os.path.dirname(exe), - env=env) - print(">>> Process result {}".format(std_output)) + args_str = subprocess.list2cmdline(args) + print(">>> Executing: {} {}".format(exe, args_str)) + process = ProcessUtils.SpawnProcess( + exe, args_str, os.path.dirname(exe) + ) + ProcessUtils.WaitForExit(process, -1) + if process.ExitCode != 0: + raise RuntimeError( + "Failed to run OpenPype process to extract environments." + ) print(">>> Loading file ...") with open(export_url) as fp: diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py index 50554b1e43..ead647b41d 100644 --- a/openpype/modules/example_addons/example_addon/addon.py +++ b/openpype/modules/example_addons/example_addon/addon.py @@ -13,10 +13,7 @@ import click from openpype.modules import ( JsonFilesSettingsDef, OpenPypeAddOn, - ModulesManager -) -# Import interface defined by this addon to be able find other addons using it -from openpype_interfaces import ( + ModulesManager, IPluginPaths, ITrayAction ) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index 678af0e577..6f14f8428d 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -5,8 +5,8 @@ import platform import click -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import ( +from openpype.modules import ( + OpenPypeModule, ITrayModule, IPluginPaths, ISettingsChangeListener diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index 23c032715b..b91373af20 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -3,8 +3,11 @@ import click import os -from openpype.modules import OpenPypeModule -from openpype_interfaces import IPluginPaths, ITrayAction +from openpype.modules import ( + OpenPypeModule, + IPluginPaths, + ITrayAction, +) class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): diff --git a/openpype/modules/kitsu/plugins/publish/collect_kitsu_username.py b/openpype/modules/kitsu/plugins/publish/collect_kitsu_username.py new file mode 100644 index 0000000000..896050f7e2 --- /dev/null +++ b/openpype/modules/kitsu/plugins/publish/collect_kitsu_username.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +import os +import re + +import pyblish.api + + +class CollectKitsuUsername(pyblish.api.ContextPlugin): + """Collect Kitsu username from the kitsu login""" + + order = pyblish.api.CollectorOrder + 0.499 + label = "Kitsu username" + + def process(self, context): + kitsu_login = os.environ.get('KITSU_LOGIN') + + if not kitsu_login: + return + + kitsu_username = kitsu_login.split("@")[0].replace('.', ' ') + new_username = re.sub('[^a-zA-Z]', ' ', kitsu_username).title() + + for instance in context: + # Don't override customData if it already exists + if 'customData' not in instance.data: + instance.data['customData'] = {} + + instance.data['customData']["kitsuUsername"] = new_username diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index e3252e3842..c4331b6094 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -1,5 +1,7 @@ -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayAction +from openpype.modules import ( + OpenPypeModule, + ITrayAction, +) class LauncherAction(OpenPypeModule, ITrayAction): diff --git a/openpype/modules/log_viewer/log_view_module.py b/openpype/modules/log_viewer/log_view_module.py index da1628b71f..31e954fadd 100644 --- a/openpype/modules/log_viewer/log_view_module.py +++ b/openpype/modules/log_viewer/log_view_module.py @@ -1,5 +1,4 @@ -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayModule +from openpype.modules import OpenPypeModule, ITrayModule class LogViewModule(OpenPypeModule, ITrayModule): diff --git a/openpype/modules/muster/muster.py b/openpype/modules/muster/muster.py index 6e26ad2d7b..8d395d16e8 100644 --- a/openpype/modules/muster/muster.py +++ b/openpype/modules/muster/muster.py @@ -2,8 +2,7 @@ import os import json import appdirs import requests -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayModule +from openpype.modules import OpenPypeModule, ITrayModule class MusterModule(OpenPypeModule, ITrayModule): diff --git a/openpype/modules/project_manager_action.py b/openpype/modules/project_manager_action.py index 251964a059..5f74dd9ee5 100644 --- a/openpype/modules/project_manager_action.py +++ b/openpype/modules/project_manager_action.py @@ -1,5 +1,4 @@ -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayAction +from openpype.modules import OpenPypeModule, ITrayAction class ProjectManagerAction(OpenPypeModule, ITrayAction): diff --git a/openpype/modules/python_console_interpreter/module.py b/openpype/modules/python_console_interpreter/module.py index 8c4a2fba73..cb99c05e37 100644 --- a/openpype/modules/python_console_interpreter/module.py +++ b/openpype/modules/python_console_interpreter/module.py @@ -1,5 +1,4 @@ -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayAction +from openpype.modules import OpenPypeModule, ITrayAction class PythonInterpreterAction(OpenPypeModule, ITrayAction): diff --git a/openpype/modules/royalrender/royal_render_module.py b/openpype/modules/royalrender/royal_render_module.py index 4f72860ad6..10d74d01d1 100644 --- a/openpype/modules/royalrender/royal_render_module.py +++ b/openpype/modules/royalrender/royal_render_module.py @@ -2,8 +2,7 @@ """Module providing support for Royal Render.""" import os import openpype.modules -from openpype.modules import OpenPypeModule -from openpype_interfaces import IPluginPaths +from openpype.modules import OpenPypeModule, IPluginPaths class RoyalRenderModule(OpenPypeModule, IPluginPaths): diff --git a/openpype/modules/settings_action.py b/openpype/modules/settings_action.py index 1e7eca4dec..1902caff1d 100644 --- a/openpype/modules/settings_action.py +++ b/openpype/modules/settings_action.py @@ -1,5 +1,4 @@ -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayAction +from openpype.modules import OpenPypeModule, ITrayAction class SettingsAction(OpenPypeModule, ITrayAction): diff --git a/openpype/modules/shotgrid/shotgrid_module.py b/openpype/modules/shotgrid/shotgrid_module.py index 281c6fdcad..d26647d06a 100644 --- a/openpype/modules/shotgrid/shotgrid_module.py +++ b/openpype/modules/shotgrid/shotgrid_module.py @@ -1,12 +1,11 @@ import os -from openpype_interfaces import ( +from openpype.modules import ( + OpenPypeModule, ITrayModule, IPluginPaths, ) -from openpype.modules import OpenPypeModule - SHOTGRID_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/modules/slack/slack_module.py b/openpype/modules/slack/slack_module.py index 499c1c19ce..797ae19f4a 100644 --- a/openpype/modules/slack/slack_module.py +++ b/openpype/modules/slack/slack_module.py @@ -1,6 +1,5 @@ import os -from openpype.modules import OpenPypeModule -from openpype.modules.interfaces import IPluginPaths +from openpype.modules import OpenPypeModule, IPluginPaths SLACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) diff --git a/openpype/modules/sync_server/rest_api.py b/openpype/modules/sync_server/rest_api.py new file mode 100644 index 0000000000..a7d9dd80b7 --- /dev/null +++ b/openpype/modules/sync_server/rest_api.py @@ -0,0 +1,37 @@ +from aiohttp.web_response import Response +from openpype.lib import Logger + + +class SyncServerModuleRestApi: + """ + REST API endpoint used for calling from hosts when context change + happens in Workfile app. + """ + + def __init__(self, user_module, server_manager): + self._log = None + self.module = user_module + self.server_manager = server_manager + + self.prefix = "/sync_server" + + self.register() + + @property + def log(self): + if self._log is None: + self._log = Logger.get_logger(self.__class__.__name__) + return self._log + + def register(self): + self.server_manager.add_route( + "POST", + self.prefix + "/reset_timer", + self.reset_timer, + ) + + async def reset_timer(self, _request): + """Force timer to run immediately.""" + self.module.reset_timer() + + return Response(status=200) diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 8b11055e65..d0a40a60ff 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -236,6 +236,7 @@ class SyncServerThread(threading.Thread): """ def __init__(self, module): self.log = Logger.get_logger(self.__class__.__name__) + super(SyncServerThread, self).__init__() self.module = module self.loop = None diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index a478faa9ef..653ee50541 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -11,9 +11,12 @@ from collections import deque, defaultdict import click from bson.objectid import ObjectId -from openpype.client import get_projects -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayModule +from openpype.client import ( + get_projects, + get_representations, + get_representation_by_id, +) +from openpype.modules import OpenPypeModule, ITrayModule from openpype.settings import ( get_project_settings, get_system_settings, @@ -30,9 +33,6 @@ from .providers import lib from .utils import time_function, SyncStatus, SiteAlreadyPresentError -from openpype.client import get_representations, get_representation_by_id - - log = Logger.get_logger("SyncServer") @@ -136,14 +136,14 @@ class SyncServerModule(OpenPypeModule, ITrayModule): """ Start of Public API """ def add_site(self, project_name, representation_id, site_name=None, - force=False): + force=False, priority=None, reset_timer=False): """ Adds new site to representation to be synced. 'project_name' must have synchronization enabled (globally or project only) - Used as a API endpoint from outside applications (Loader etc). + Used as an API endpoint from outside applications (Loader etc). Use 'force' to reset existing site. @@ -152,6 +152,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): representation_id (string): MongoDB _id value site_name (string): name of configured and active site force (bool): reset site if exists + priority (int): set priority + reset_timer (bool): if delay timer should be reset, eg. user mark + some representation to be synced manually Throws: SiteAlreadyPresentError - if adding already existing site and @@ -167,7 +170,11 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self.reset_site_on_representation(project_name, representation_id, site_name=site_name, - force=force) + force=force, + priority=priority) + + if reset_timer: + self.reset_timer() def remove_site(self, project_name, representation_id, site_name, remove_local_files=False): @@ -911,7 +918,59 @@ class SyncServerModule(OpenPypeModule, ITrayModule): In case of user's involvement (reset site), start that right away. """ - self.sync_server_thread.reset_timer() + + if not self.enabled: + return + + if self.sync_server_thread is None: + self._reset_timer_with_rest_api() + else: + self.sync_server_thread.reset_timer() + + def is_representation_on_site( + self, project_name, representation_id, site_name + ): + """Checks if 'representation_id' has all files avail. on 'site_name'""" + representation = get_representation_by_id(project_name, + representation_id, + fields=["_id", "files"]) + if not representation: + return False + + on_site = False + for file_info in representation.get("files", []): + for site in file_info.get("sites", []): + if site["name"] != site_name: + continue + + if (site.get("progress") or site.get("error") or + not site.get("created_dt")): + return False + on_site = True + + return on_site + + def _reset_timer_with_rest_api(self): + # POST to webserver sites to add to representations + webserver_url = os.environ.get("OPENPYPE_WEBSERVER_URL") + if not webserver_url: + self.log.warning("Couldn't find webserver url") + return + + rest_api_url = "{}/sync_server/reset_timer".format( + webserver_url + ) + + try: + import requests + except Exception: + self.log.warning( + "Couldn't add sites to representations " + "('requests' is not available)" + ) + return + + requests.post(rest_api_url) def get_enabled_projects(self): """Returns list of projects which have SyncServer enabled.""" @@ -1544,12 +1603,12 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Args: project_name (string): name of project - force to db connection as each file might come from different collection - new_file_id (string): + new_file_id (string): only present if file synced successfully file (dictionary): info about processed file (pulled from DB) representation (dictionary): parent repr of file (from DB) site (string): label ('gdrive', 'S3') error (string): exception message - progress (float): 0-1 of progress of upload/download + progress (float): 0-0.99 of progress of upload/download priority (int): 0-100 set priority Returns: @@ -1655,7 +1714,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def reset_site_on_representation(self, project_name, representation_id, side=None, file_id=None, site_name=None, - remove=False, pause=None, force=False): + remove=False, pause=None, force=False, + priority=None): """ Reset information about synchronization for particular 'file_id' and provider. @@ -1678,6 +1738,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): remove (bool): if True remove site altogether pause (bool or None): if True - pause, False - unpause force (bool): hard reset - currently only for add_site + priority (int): set priority Raises: SiteAlreadyPresentError - if adding already existing site and @@ -1705,6 +1766,10 @@ class SyncServerModule(OpenPypeModule, ITrayModule): elem = {"name": site_name} + # Add priority + if priority: + elem["priority"] = priority + if file_id: # reset site for particular file self._reset_site_for_file(project_name, representation_id, elem, file_id, site_name) @@ -2089,6 +2154,15 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def cli(self, click_group): click_group.add_command(cli_main) + # Webserver module implementation + def webserver_initialization(self, server_manager): + """Add routes for syncs.""" + if self.tray_initialized: + from .rest_api import SyncServerModuleRestApi + self.rest_api_obj = SyncServerModuleRestApi( + self, server_manager + ) + @click.group(SyncServerModule.name, help="SyncServer module related commands.") def cli_main(): diff --git a/openpype/modules/timers_manager/rest_api.py b/openpype/modules/timers_manager/rest_api.py index 4a2e9e6575..979db9075b 100644 --- a/openpype/modules/timers_manager/rest_api.py +++ b/openpype/modules/timers_manager/rest_api.py @@ -21,7 +21,7 @@ class TimersManagerModuleRestApi: @property def log(self): if self._log is None: - self._log = Logger.get_logger(self.__ckass__.__name__) + self._log = Logger.get_logger(self.__class__.__name__) return self._log def register(self): diff --git a/openpype/modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py index c168e9534d..0ba68285a4 100644 --- a/openpype/modules/timers_manager/timers_manager.py +++ b/openpype/modules/timers_manager/timers_manager.py @@ -3,8 +3,8 @@ import platform from openpype.client import get_asset_by_name -from openpype.modules import OpenPypeModule -from openpype_interfaces import ( +from openpype.modules import ( + OpenPypeModule, ITrayService, IPluginPaths ) diff --git a/openpype/modules/webserver/host_console_listener.py b/openpype/modules/webserver/host_console_listener.py index 6138f9f097..fdfe1ba688 100644 --- a/openpype/modules/webserver/host_console_listener.py +++ b/openpype/modules/webserver/host_console_listener.py @@ -5,7 +5,7 @@ import logging from concurrent.futures import CancelledError from Qt import QtWidgets -from openpype_interfaces import ITrayService +from openpype.modules import ITrayService log = logging.getLogger(__name__) diff --git a/openpype/modules/webserver/webserver_module.py b/openpype/modules/webserver/webserver_module.py index 16861abd29..354ab1e4f9 100644 --- a/openpype/modules/webserver/webserver_module.py +++ b/openpype/modules/webserver/webserver_module.py @@ -24,8 +24,7 @@ import os import socket from openpype import resources -from openpype.modules import OpenPypeModule -from openpype_interfaces import ITrayService +from openpype.modules import OpenPypeModule, ITrayService class WebServerModule(OpenPypeModule, ITrayService): diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 2cf785d981..f5319c5a48 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -85,6 +85,7 @@ from .context_tools import ( register_host, registered_host, deregister_host, + get_process_id, ) install = install_host uninstall = uninstall_host diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index af0ee79f47..0ec19d50fe 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -5,6 +5,7 @@ import json import types import logging import platform +import uuid import pyblish.api from pyblish.lib import MessageHandler @@ -37,6 +38,7 @@ from . import ( _is_installed = False +_process_id = None _registered_root = {"_": ""} _registered_host = {"_": None} # Keep modules manager (and it's modules) in memory @@ -546,3 +548,18 @@ def change_current_context(asset_doc, task_name, template_key=None): emit_event("taskChanged", data) return changes + + +def get_process_id(): + """Fake process id created on demand using uuid. + + Can be used to create process specific folders in temp directory. + + Returns: + str: Process id. + """ + + global _process_id + if _process_id is None: + _process_id = str(uuid.uuid4()) + return _process_id diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index 4b91951a08..b0877d0a29 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -1,6 +1,7 @@ from .constants import ( SUBSET_NAME_ALLOWED_SYMBOLS, DEFAULT_SUBSET_TEMPLATE, + PRE_CREATE_THUMBNAIL_KEY, ) from .subset_name import ( @@ -24,6 +25,8 @@ from .creator_plugins import ( deregister_creator_plugin, register_creator_plugin_path, deregister_creator_plugin_path, + + cache_and_get_instances, ) from .context import ( @@ -40,6 +43,7 @@ from .legacy_create import ( __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", + "PRE_CREATE_THUMBNAIL_KEY", "TaskNotSetError", "get_subset_name", diff --git a/openpype/pipeline/create/constants.py b/openpype/pipeline/create/constants.py index 3af9651947..375cfc4a12 100644 --- a/openpype/pipeline/create/constants.py +++ b/openpype/pipeline/create/constants.py @@ -1,8 +1,10 @@ SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_." DEFAULT_SUBSET_TEMPLATE = "{family}{Variant}" +PRE_CREATE_THUMBNAIL_KEY = "thumbnail_source" __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", + "PRE_CREATE_THUMBNAIL_KEY", ) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 52a1729233..4fd460ffea 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -1077,6 +1077,8 @@ class CreateContext: # Shared data across creators during collection phase self._collection_shared_data = None + self.thumbnail_paths_by_instance_id = {} + # Trigger reset if was enabled if reset: self.reset(discover_publish_plugins) @@ -1146,6 +1148,29 @@ class CreateContext: self.reset_finalization() + def refresh_thumbnails(self): + """Cleanup thumbnail paths. + + Remove all thumbnail filepaths that are empty or lead to files which + does not exists or of instances that are not available anymore. + """ + + invalid = set() + for instance_id, path in self.thumbnail_paths_by_instance_id.items(): + instance_available = True + if instance_id is not None: + instance_available = instance_id in self._instances_by_id + + if ( + not instance_available + or not path + or not os.path.exists(path) + ): + invalid.add(instance_id) + + for instance_id in invalid: + self.thumbnail_paths_by_instance_id.pop(instance_id) + def reset_preparation(self): """Prepare attributes that must be prepared/cleaned before reset.""" @@ -1157,6 +1182,7 @@ class CreateContext: # Stop access to collection shared data self._collection_shared_data = None + self.refresh_thumbnails() def reset_avalon_context(self): """Give ability to reset avalon context. diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index c69abb8861..782534d589 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,5 +1,6 @@ import os import copy +import collections from abc import ( ABCMeta, @@ -442,6 +443,13 @@ class BaseCreator: return self.create_context.collection_shared_data + def set_instance_thumbnail_path(self, instance_id, thumbnail_path=None): + """Set path to thumbnail for instance.""" + + self.create_context.thumbnail_paths_by_instance_id[instance_id] = ( + thumbnail_path + ) + class Creator(BaseCreator): """Creator that has more information for artist to show in UI. @@ -468,6 +476,13 @@ class Creator(BaseCreator): # - in some cases it may confuse artists because it would not be used # e.g. for buld creators create_allow_context_change = True + # A thumbnail can be passed in precreate attributes + # - if is set to True is should expect that a thumbnail path under key + # PRE_CREATE_THUMBNAIL_KEY can be sent in data with precreate data + # - is disabled by default because the feature was added in later stages + # and creators who would not expect PRE_CREATE_THUMBNAIL_KEY could + # cause issues with instance data + create_allow_thumbnail = False # Precreate attribute definitions showed before creation # - similar to instance attribute definitions @@ -660,3 +675,34 @@ def deregister_creator_plugin_path(path): deregister_plugin_path(BaseCreator, path) deregister_plugin_path(LegacyCreator, path) deregister_plugin_path(SubsetConvertorPlugin, path) + + +def cache_and_get_instances(creator, shared_key, list_instances_func): + """Common approach to cache instances in shared data. + + This is helper function which does not handle cases when a 'shared_key' is + used for different list instances functions. The same approach of caching + instances into 'collection_shared_data' is not required but is so common + we've decided to unify it to some degree. + + Function 'list_instances_func' is called only if 'shared_key' is not + available in 'collection_shared_data' on creator. + + Args: + creator (Creator): Plugin which would like to get instance data. + shared_key (str): Key under which output of function will be stored. + list_instances_func (Function): Function that will return instance data + if data were not yet stored under 'shared_key'. + + Returns: + Dict[str, Dict[str, Any]]: Cached instances by creator identifier from + result of passed function. + """ + + if shared_key not in creator.collection_shared_data: + value = collections.defaultdict(list) + for instance in list_instances_func(): + identifier = instance.get("creator_identifier") + value[identifier].append(instance) + creator.collection_shared_data[shared_key] = value + return creator.collection_shared_data[shared_key] diff --git a/openpype/pipeline/workfile/lock_workfile.py b/openpype/pipeline/workfile/lock_workfile.py index fbec44247a..579840c07d 100644 --- a/openpype/pipeline/workfile/lock_workfile.py +++ b/openpype/pipeline/workfile/lock_workfile.py @@ -1,9 +1,9 @@ import os import json -from uuid import uuid4 from openpype.lib import Logger, filter_profiles from openpype.lib.pype_info import get_workstation_info from openpype.settings import get_project_settings +from openpype.pipeline import get_process_id def _read_lock_file(lock_filepath): @@ -37,7 +37,7 @@ def is_workfile_locked_for_current_process(filepath): lock_filepath = _get_lock_file(filepath) data = _read_lock_file(lock_filepath) - return data["process_id"] == _get_process_id() + return data["process_id"] == get_process_id() def delete_workfile_lock(filepath): @@ -49,7 +49,7 @@ def delete_workfile_lock(filepath): def create_workfile_lock(filepath): lock_filepath = _get_lock_file(filepath) info = get_workstation_info() - info["process_id"] = _get_process_id() + info["process_id"] = get_process_id() with open(lock_filepath, "w") as stream: json.dump(info, stream) @@ -59,14 +59,6 @@ def remove_workfile_lock(filepath): delete_workfile_lock(filepath) -def _get_process_id(): - process_id = os.environ.get("OPENPYPE_PROCESS_ID") - if not process_id: - process_id = str(uuid4()) - os.environ["OPENPYPE_PROCESS_ID"] = process_id - return process_id - - def is_workfile_lock_enabled(host_name, project_name, project_setting=None): if project_setting is None: project_setting = get_project_settings(project_name) diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index 8433816908..55ce8e06f4 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -15,7 +15,6 @@ Provides: import json import pyblish.api -from openpype.pipeline import legacy_io from openpype.pipeline.template_data import get_template_data @@ -53,7 +52,7 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): asset_entity = context.data.get("assetEntity") task_name = None if asset_entity: - task_name = legacy_io.Session["AVALON_TASK"] + task_name = context.data["task"] anatomy_data = get_template_data( project_entity, asset_entity, task_name, host_name, system_settings diff --git a/openpype/plugins/publish/collect_avalon_entities.py b/openpype/plugins/publish/collect_context_entities.py similarity index 90% rename from openpype/plugins/publish/collect_avalon_entities.py rename to openpype/plugins/publish/collect_context_entities.py index 3b05b6ae98..31fbeb5dbd 100644 --- a/openpype/plugins/publish/collect_avalon_entities.py +++ b/openpype/plugins/publish/collect_context_entities.py @@ -3,6 +3,8 @@ Requires: session -> AVALON_ASSET context -> projectName + context -> asset + context -> task Provides: context -> projectEntity - Project document from database. @@ -13,20 +15,19 @@ Provides: import pyblish.api from openpype.client import get_project, get_asset_by_name -from openpype.pipeline import legacy_io, KnownPublishError +from openpype.pipeline import KnownPublishError -class CollectAvalonEntities(pyblish.api.ContextPlugin): - """Collect Anatomy into Context.""" +class CollectContextEntities(pyblish.api.ContextPlugin): + """Collect entities into Context.""" order = pyblish.api.CollectorOrder - 0.1 - label = "Collect Avalon Entities" + label = "Collect Context Entities" def process(self, context): - legacy_io.install() project_name = context.data["projectName"] - asset_name = legacy_io.Session["AVALON_ASSET"] - task_name = legacy_io.Session["AVALON_TASK"] + asset_name = context.data["asset"] + task_name = context.data["task"] project_entity = get_project(project_name) if not project_entity: diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index fc0f97b187..ddb6908a4c 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -19,14 +19,28 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): if not create_context: return + thumbnail_paths_by_instance_id = ( + create_context.thumbnail_paths_by_instance_id + ) + context.data["thumbnailSource"] = ( + thumbnail_paths_by_instance_id.get(None) + ) + project_name = create_context.project_name if project_name: context.data["projectName"] = project_name + for created_instance in create_context.instances: instance_data = created_instance.data_to_store() if instance_data["active"]: + thumbnail_path = thumbnail_paths_by_instance_id.get( + created_instance.id + ) self.create_instance( - context, instance_data, created_instance.transient_data + context, + instance_data, + created_instance.transient_data, + thumbnail_path ) # Update global data to context @@ -39,7 +53,13 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): legacy_io.Session[key] = value os.environ[key] = value - def create_instance(self, context, in_data, transient_data): + def create_instance( + self, + context, + in_data, + transient_data, + thumbnail_path + ): subset = in_data["subset"] # If instance data already contain families then use it instance_families = in_data.get("families") or [] @@ -53,7 +73,8 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): "name": subset, "family": in_data["family"], "families": instance_families, - "representations": [] + "representations": [], + "thumbnailSource": thumbnail_path }) for key, value in in_data.items(): if key not in instance.data: diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index d457bdc988..1f9b30fba3 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -3,26 +3,26 @@ import re import copy import json import shutil - from abc import ABCMeta, abstractmethod + import six - import clique - +import speedcopy import pyblish.api from openpype.lib import ( get_ffmpeg_tool_path, - get_ffprobe_streams, path_to_subprocess_arg, run_subprocess, - +) +from openpype.lib.transcoding import ( + IMAGE_EXTENSIONS, + get_ffprobe_streams, should_convert_for_ffmpeg, convert_input_paths_for_ffmpeg, - get_transcode_temp_directory + get_transcode_temp_directory, ) -import speedcopy class ExtractReview(pyblish.api.InstancePlugin): @@ -175,6 +175,26 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_representations.append((repre, outputs)) return outputs_per_representations + def _single_frame_filter(self, input_filepaths, output_defs): + single_frame_image = False + if len(input_filepaths) == 1: + ext = os.path.splitext(input_filepaths[0])[-1] + single_frame_image = ext in IMAGE_EXTENSIONS + + filtered_defs = [] + for output_def in output_defs: + output_filters = output_def.get("filter") or {} + frame_filter = output_filters.get("single_frame_filter") + if ( + (not single_frame_image and frame_filter == "single_frame") + or (single_frame_image and frame_filter == "multi_frame") + ): + continue + + filtered_defs.append(output_def) + + return filtered_defs + @staticmethod def get_instance_label(instance): return ( @@ -195,7 +215,7 @@ class ExtractReview(pyblish.api.InstancePlugin): outputs_per_repres = self._get_outputs_per_representations( instance, profile_outputs ) - for repre, outpu_defs in outputs_per_repres: + for repre, output_defs in outputs_per_repres: # Check if input should be preconverted before processing # Store original staging dir (it's value may change) src_repre_staging_dir = repre["stagingDir"] @@ -216,6 +236,16 @@ class ExtractReview(pyblish.api.InstancePlugin): if first_input_path is None: first_input_path = filepath + filtered_output_defs = self._single_frame_filter( + input_filepaths, output_defs + ) + if not filtered_output_defs: + self.log.debug(( + "Repre: {} - All output definitions were filtered" + " out by single frame filter. Skipping" + ).format(repre["name"])) + continue + # Skip if file is not set if first_input_path is None: self.log.warning(( @@ -249,7 +279,10 @@ class ExtractReview(pyblish.api.InstancePlugin): try: self._render_output_definitions( - instance, repre, src_repre_staging_dir, outpu_defs + instance, + repre, + src_repre_staging_dir, + filtered_output_defs ) finally: @@ -263,10 +296,10 @@ class ExtractReview(pyblish.api.InstancePlugin): shutil.rmtree(new_staging_dir) def _render_output_definitions( - self, instance, repre, src_repre_staging_dir, outpu_defs + self, instance, repre, src_repre_staging_dir, output_defs ): fill_data = copy.deepcopy(instance.data["anatomyData"]) - for _output_def in outpu_defs: + for _output_def in output_defs: output_def = copy.deepcopy(_output_def) # Make sure output definition has "tags" key if "tags" not in output_def: @@ -1659,9 +1692,7 @@ class ExtractReview(pyblish.api.InstancePlugin): return True return False - def filter_output_defs( - self, profile, subset_name, families - ): + def filter_output_defs(self, profile, subset_name, families): """Return outputs matching input instance families. Output definitions without families filter are marked as valid. diff --git a/openpype/hosts/traypublisher/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail_from_source.py similarity index 81% rename from openpype/hosts/traypublisher/plugins/publish/extract_thumbnail.py rename to openpype/plugins/publish/extract_thumbnail_from_source.py index 7781bb7b3e..8da1213807 100644 --- a/openpype/hosts/traypublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -34,28 +34,55 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): label = "Extract Thumbnail (from source)" # Before 'ExtractThumbnail' in global plugins order = pyblish.api.ExtractorOrder - 0.00001 - hosts = ["traypublisher"] def process(self, instance): + self._create_context_thumbnail(instance.context) + subset_name = instance.data["subset"] self.log.info( "Processing instance with subset name {}".format(subset_name) ) - thumbnail_source = instance.data.get("thumbnailSource") if not thumbnail_source: self.log.debug("Thumbnail source not filled. Skipping.") return - elif not os.path.exists(thumbnail_source): - self.log.debug( - "Thumbnail source file was not found {}. Skipping.".format( - thumbnail_source)) + # Check if already has thumbnail created + if self._instance_has_thumbnail(instance): + self.log.info("Thumbnail representation already present.") return - # Check if already has thumbnail created - if self._already_has_thumbnail(instance): - self.log.info("Thumbnail representation already present.") + dst_filepath = self._create_thumbnail( + instance.context, thumbnail_source + ) + if not dst_filepath: + return + + dst_staging, dst_filename = os.path.split(dst_filepath) + new_repre = { + "name": "thumbnail", + "ext": "jpg", + "files": dst_filename, + "stagingDir": dst_staging, + "thumbnail": True, + "tags": ["thumbnail"] + } + + # adding representation + self.log.debug( + "Adding thumbnail representation: {}".format(new_repre) + ) + instance.data["representations"].append(new_repre) + + def _create_thumbnail(self, context, thumbnail_source): + if not thumbnail_source: + self.log.debug("Thumbnail source not filled. Skipping.") + return + + if not os.path.exists(thumbnail_source): + self.log.debug(( + "Thumbnail source is set but file was not found {}. Skipping." + ).format(thumbnail_source)) return # Create temp directory for thumbnail @@ -65,7 +92,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): "Create temp directory {} for thumbnail".format(dst_staging) ) # Store new staging to cleanup paths - instance.context.data["cleanupFullPaths"].append(dst_staging) + context.data["cleanupFullPaths"].append(dst_staging) thumbnail_created = False oiio_supported = is_oiio_supported() @@ -97,26 +124,12 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): ) # Skip representation and try next one if wasn't created - if not thumbnail_created: - self.log.warning("Thumbanil has not been created.") - return + if thumbnail_created: + return full_output_path - new_repre = { - "name": "thumbnail", - "ext": "jpg", - "files": dst_filename, - "stagingDir": dst_staging, - "thumbnail": True, - "tags": ["thumbnail"] - } + self.log.warning("Thumbanil has not been created.") - # adding representation - self.log.debug( - "Adding thumbnail representation: {}".format(new_repre) - ) - instance.data["representations"].append(new_repre) - - def _already_has_thumbnail(self, instance): + def _instance_has_thumbnail(self, instance): if "representations" not in instance.data: self.log.warning( "Instance does not have 'representations' key filled" @@ -171,3 +184,11 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): exc_info=True ) return False + + def _create_context_thumbnail(self, context): + if "thumbnailPath" in context.data: + return + + thumbnail_source = context.data.get("thumbnailSource") + thumbnail_path = self._create_thumbnail(context, thumbnail_source) + context.data["thumbnailPath"] = thumbnail_path diff --git a/openpype/plugins/publish/integrate_thumbnail.py b/openpype/plugins/publish/integrate_thumbnail.py index e7046ba2ea..f74c3d9609 100644 --- a/openpype/plugins/publish/integrate_thumbnail.py +++ b/openpype/plugins/publish/integrate_thumbnail.py @@ -13,166 +13,279 @@ import sys import errno import shutil import copy +import collections import six import pyblish.api -from openpype.client import get_version_by_id +from openpype.client import get_versions from openpype.client.operations import OperationsSession, new_thumbnail_doc +InstanceFilterResult = collections.namedtuple( + "InstanceFilterResult", + ["instance", "thumbnail_path", "version_id"] +) -class IntegrateThumbnails(pyblish.api.InstancePlugin): + +class IntegrateThumbnails(pyblish.api.ContextPlugin): """Integrate Thumbnails for Openpype use in Loaders.""" label = "Integrate Thumbnails" order = pyblish.api.IntegratorOrder + 0.01 - families = ["review"] required_context_keys = [ "project", "asset", "task", "subset", "version" ] - def process(self, instance): + def process(self, context): + # Filter instances which can be used for integration + filtered_instance_items = self._prepare_instances(context) + if not filtered_instance_items: + self.log.info( + "All instances were filtered. Thumbnail integration skipped." + ) + return + + # Initial validation of available templated and required keys env_key = "AVALON_THUMBNAIL_ROOT" thumbnail_root_format_key = "{thumbnail_root}" thumbnail_root = os.environ.get(env_key) or "" - published_repres = instance.data.get("published_representations") - if not published_repres: - self.log.debug( - "There are no published representations on the instance." - ) - return - - anatomy = instance.context.data["anatomy"] + anatomy = context.data["anatomy"] project_name = anatomy.project_name if "publish" not in anatomy.templates: - self.log.warning("Anatomy is missing the \"publish\" key!") + self.log.warning( + "Anatomy is missing the \"publish\" key. Skipping." + ) return if "thumbnail" not in anatomy.templates["publish"]: self.log.warning(( - "There is no \"thumbnail\" template set for the project \"{}\"" + "There is no \"thumbnail\" template set for the project" + " \"{}\". Skipping." ).format(project_name)) return thumbnail_template = anatomy.templates["publish"]["thumbnail"] + if not thumbnail_template: + self.log.info("Thumbnail template is not filled. Skipping.") + return + if ( not thumbnail_root and thumbnail_root_format_key in thumbnail_template ): - self.log.warning(( - "{} is not set. Skipping thumbnail integration." - ).format(env_key)) + self.log.warning(("{} is not set. Skipping.").format(env_key)) return - thumb_repre = None - thumb_repre_anatomy_data = None - for repre_info in published_repres.values(): - repre = repre_info["representation"] - if repre["name"].lower() == "thumbnail": - thumb_repre = repre - thumb_repre_anatomy_data = repre_info["anatomy_data"] + # Collect verion ids from all filtered instance + version_ids = { + instance_items.version_id + for instance_items in filtered_instance_items + } + # Query versions + version_docs = get_versions( + project_name, + version_ids=version_ids, + hero=True, + fields=["_id", "type", "name"] + ) + # Store version by their id (converted to string) + version_docs_by_str_id = { + str(version_doc["_id"]): version_doc + for version_doc in version_docs + } + self._integrate_thumbnails( + filtered_instance_items, + version_docs_by_str_id, + anatomy, + thumbnail_root + ) + + def _prepare_instances(self, context): + context_thumbnail_path = context.get("thumbnailPath") + valid_context_thumbnail = False + if context_thumbnail_path and os.path.exists(context_thumbnail_path): + valid_context_thumbnail = True + + filtered_instances = [] + for instance in context: + instance_label = self._get_instance_label(instance) + # Skip instances without published representations + # - there is no place where to put the thumbnail + published_repres = instance.data.get("published_representations") + if not published_repres: + self.log.debug(( + "There are no published representations" + " on the instance {}." + ).format(instance_label)) + continue + + # Find thumbnail path on instance + thumbnail_path = self._get_instance_thumbnail_path( + published_repres) + if thumbnail_path: + self.log.debug(( + "Found thumbnail path for instance \"{}\"." + " Thumbnail path: {}" + ).format(instance_label, thumbnail_path)) + + elif valid_context_thumbnail: + # Use context thumbnail path if is available + thumbnail_path = context_thumbnail_path + self.log.debug(( + "Using context thumbnail path for instance \"{}\"." + " Thumbnail path: {}" + ).format(instance_label, thumbnail_path)) + + # Skip instance if thumbnail path is not available for it + if not thumbnail_path: + self.log.info(( + "Skipping thumbnail integration for instance \"{}\"." + " Instance and context" + " thumbnail paths are not available." + ).format(instance_label)) + continue + + version_id = str(self._get_version_id(published_repres)) + filtered_instances.append( + InstanceFilterResult(instance, thumbnail_path, version_id) + ) + return filtered_instances + + def _get_version_id(self, published_representations): + for repre_info in published_representations.values(): + return repre_info["representation"]["parent"] + + def _get_instance_thumbnail_path(self, published_representations): + thumb_repre_doc = None + for repre_info in published_representations.values(): + repre_doc = repre_info["representation"] + if repre_doc["name"].lower() == "thumbnail": + thumb_repre_doc = repre_doc break - if not thumb_repre: + if thumb_repre_doc is None: self.log.debug( "There is not representation with name \"thumbnail\"" ) - return + return None - version = get_version_by_id(project_name, thumb_repre["parent"]) - if not version: - raise AssertionError( - "There does not exist version with id {}".format( - str(thumb_repre["parent"]) - ) + path = thumb_repre_doc["data"]["path"] + if not os.path.exists(path): + self.log.warning( + "Thumbnail file cannot be found. Path: {}".format(path) + ) + return None + return os.path.normpath(path) + + def _integrate_thumbnails( + self, + filtered_instance_items, + version_docs_by_str_id, + anatomy, + thumbnail_root + ): + op_session = OperationsSession() + project_name = anatomy.project_name + + for instance_item in filtered_instance_items: + instance, thumbnail_path, version_id = instance_item + instance_label = self._get_instance_label(instance) + version_doc = version_docs_by_str_id.get(version_id) + if not version_doc: + self.log.warning(( + "Version entity for instance \"{}\" was not found." + ).format(instance_label)) + continue + + filename, file_extension = os.path.splitext(thumbnail_path) + # Create id for mongo entity now to fill anatomy template + thumbnail_doc = new_thumbnail_doc() + thumbnail_id = thumbnail_doc["_id"] + + # Prepare anatomy template fill data + template_data = copy.deepcopy(instance.data["anatomyData"]) + template_data.update({ + "_id": str(thumbnail_id), + "ext": file_extension[1:], + "name": "thumbnail", + "thumbnail_root": thumbnail_root, + "thumbnail_type": "thumbnail" + }) + + anatomy_filled = anatomy.format(template_data) + thumbnail_template = anatomy.templates["publish"]["thumbnail"] + template_filled = anatomy_filled["publish"]["thumbnail"] + + dst_full_path = os.path.normpath(str(template_filled)) + self.log.debug("Copying file .. {} -> {}".format( + thumbnail_path, dst_full_path + )) + dirname = os.path.dirname(dst_full_path) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno != errno.EEXIST: + tp, value, tb = sys.exc_info() + six.reraise(tp, value, tb) + + shutil.copy(thumbnail_path, dst_full_path) + + # Clean template data from keys that are dynamic + for key in ("_id", "thumbnail_root"): + template_data.pop(key, None) + + repre_context = template_filled.used_values + for key in self.required_context_keys: + value = template_data.get(key) + if not value: + continue + repre_context[key] = template_data[key] + + thumbnail_doc["data"] = { + "template": thumbnail_template, + "template_data": repre_context + } + op_session.create_entity( + project_name, thumbnail_doc["type"], thumbnail_doc + ) + # Create thumbnail entity + self.log.debug( + "Creating entity in database {}".format(str(thumbnail_doc)) ) - # Get full path to thumbnail file from representation - src_full_path = os.path.normpath(thumb_repre["data"]["path"]) - if not os.path.exists(src_full_path): - self.log.warning("Thumbnail file was not found. Path: {}".format( - src_full_path + # Set thumbnail id for version + op_session.update_entity( + project_name, + version_doc["type"], + version_doc["_id"], + {"data.thumbnail_id": thumbnail_id} + ) + if version_doc["type"] == "hero_version": + version_name = "Hero" + else: + version_name = version_doc["name"] + self.log.debug("Setting thumbnail for version \"{}\" <{}>".format( + version_name, version_id )) - return - filename, file_extension = os.path.splitext(src_full_path) - # Create id for mongo entity now to fill anatomy template - thumbnail_doc = new_thumbnail_doc() - thumbnail_id = thumbnail_doc["_id"] - - # Prepare anatomy template fill data - template_data = copy.deepcopy(thumb_repre_anatomy_data) - template_data.update({ - "_id": str(thumbnail_id), - "ext": file_extension[1:], - "thumbnail_root": thumbnail_root, - "thumbnail_type": "thumbnail" - }) - - anatomy_filled = anatomy.format(template_data) - template_filled = anatomy_filled["publish"]["thumbnail"] - - dst_full_path = os.path.normpath(str(template_filled)) - self.log.debug( - "Copying file .. {} -> {}".format(src_full_path, dst_full_path) - ) - dirname = os.path.dirname(dst_full_path) - try: - os.makedirs(dirname) - except OSError as e: - if e.errno != errno.EEXIST: - tp, value, tb = sys.exc_info() - six.reraise(tp, value, tb) - - shutil.copy(src_full_path, dst_full_path) - - # Clean template data from keys that are dynamic - for key in ("_id", "thumbnail_root"): - template_data.pop(key, None) - - repre_context = template_filled.used_values - for key in self.required_context_keys: - value = template_data.get(key) - if not value: - continue - repre_context[key] = template_data[key] - - op_session = OperationsSession() - - thumbnail_doc["data"] = { - "template": thumbnail_template, - "template_data": repre_context - } - op_session.create_entity( - project_name, thumbnail_doc["type"], thumbnail_doc - ) - # Create thumbnail entity - self.log.debug( - "Creating entity in database {}".format(str(thumbnail_doc)) - ) - - # Set thumbnail id for version - op_session.update_entity( - project_name, - version["type"], - version["_id"], - {"data.thumbnail_id": thumbnail_id} - ) - self.log.debug("Setting thumbnail for version \"{}\" <{}>".format( - version["name"], str(version["_id"]) - )) - - asset_entity = instance.data["assetEntity"] - op_session.update_entity( - project_name, - asset_entity["type"], - asset_entity["_id"], - {"data.thumbnail_id": thumbnail_id} - ) - self.log.debug("Setting thumbnail for asset \"{}\" <{}>".format( - asset_entity["name"], str(version["_id"]) - )) + asset_entity = instance.data["assetEntity"] + op_session.update_entity( + project_name, + asset_entity["type"], + asset_entity["_id"], + {"data.thumbnail_id": thumbnail_id} + ) + self.log.debug("Setting thumbnail for asset \"{}\" <{}>".format( + asset_entity["name"], version_id + )) op_session.commit() + + def _get_instance_label(self, instance): + return ( + instance.data.get("label") + or instance.data.get("name") + or "N/A" + ) diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py index f9e23223e6..b88ccee9dc 100644 --- a/openpype/plugins/publish/preintegrate_thumbnail_representation.py +++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py @@ -21,9 +21,8 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin): label = "Override Integrate Thumbnail Representations" order = pyblish.api.IntegratorOrder - 0.1 - families = ["review"] - integrate_profiles = {} + integrate_profiles = [] def process(self, instance): repres = instance.data.get("representations") diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index 0f3080ad64..34baf9ba06 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -142,7 +142,7 @@ "exr16fpdwaa" ], "reel_name": "OP_LoadedReel", - "clip_name_template": "{asset}_{subset}<_{output}>" + "clip_name_template": "{batch}_{asset}_{subset}<_{output}>" } } } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index b128564bc2..7daa4afa79 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -53,6 +53,62 @@ "families": [], "hosts": [], "outputs": { + "png": { + "ext": "png", + "tags": [ + "ftrackreview" + ], + "burnins": [], + "ffmpeg_args": { + "video_filters": [], + "audio_filters": [], + "input": [], + "output": [] + }, + "filter": { + "families": [ + "render", + "review", + "ftrack" + ], + "subsets": [], + "custom_tags": [], + "single_frame_filter": "single_frame" + }, + "overscan_crop": "", + "overscan_color": [ + 0, + 0, + 0, + 255 + ], + "width": 1920, + "height": 1080, + "scale_pixel_aspect": true, + "bg_color": [ + 0, + 0, + 0, + 0 + ], + "letter_box": { + "enabled": false, + "ratio": 0.0, + "fill_color": [ + 0, + 0, + 0, + 255 + ], + "line_thickness": 0, + "line_color": [ + 255, + 0, + 0, + 255 + ] + } + }, "h264": { "ext": "mp4", "tags": [ @@ -79,7 +135,8 @@ "ftrack" ], "subsets": [], - "custom_tags": [] + "custom_tags": [], + "single_frame_filter": "multi_frame" }, "overscan_crop": "", "overscan_color": [ @@ -401,7 +458,8 @@ "hosts": [], "task_types": [], "tasks": [], - "enabled": true + "enabled": true, + "use_last_published_workfile": false } ], "open_workfile_tool_on_startup": [ diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 5db2a79772..e99b96b8c4 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -303,5 +303,12 @@ "extensions": [ ".mov" ] + }, + "publish": { + "ValidateFrameRange": { + "enabled": true, + "optional": true, + "active": true + } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 7c61aeed50..faa5033d2a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -311,6 +311,24 @@ "object_type": "text" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "schema_template", + "name": "template_validate_plugin", + "template_data": [ + { + "key": "ValidateFrameRange", + "label": "Validate frame range" + } + ] + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json index a2a566da0e..3667c9d5d8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_attributes.json @@ -16,22 +16,26 @@ { "type": "number", "key": "frameStart", - "label": "Frame Start" + "label": "Frame Start", + "maximum": 999999999 }, { "type": "number", "key": "frameEnd", - "label": "Frame End" + "label": "Frame End", + "maximum": 999999999 }, { "type": "number", "key": "clipIn", - "label": "Clip In" + "label": "Clip In", + "maximum": 999999999 }, { "type": "number", "key": "clipOut", - "label": "Clip Out" + "label": "Clip Out", + "maximum": 999999999 }, { "type": "number", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 51fc8dedf3..742437fbde 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -304,6 +304,20 @@ "label": "Custom Tags", "type": "list", "object_type": "text" + }, + { + "type": "label", + "label": "Use output always / only if input is 1 frame image / only if has 2+ frames or is video" + }, + { + "type": "enum", + "key": "single_frame_filter", + "default": "everytime", + "enum_items": [ + {"everytime": "Always"}, + {"single_frame": "Only if input has 1 image frame"}, + {"multi_frame": "Only if input is video or sequence of frames"} + ] } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index ba446135e2..962008d476 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -149,6 +149,11 @@ "type": "boolean", "key": "enabled", "label": "Enabled" + }, + { + "type": "boolean", + "key": "use_last_published_workfile", + "label": "Use last published workfile" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_validate_plugin.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_validate_plugin.json new file mode 100644 index 0000000000..b57cad6719 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_validate_plugin.json @@ -0,0 +1,26 @@ +[ + { + "type": "dict", + "collapsible": true, + "key": "{key}", + "label": "{label}", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + } + ] + } +] diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 5eaddf6e6e..288c587d03 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -138,8 +138,7 @@ def save_studio_settings(data): SaveWarningExc: If any module raises the exception. """ # Notify Pype modules - from openpype.modules import ModulesManager - from openpype_interfaces import ISettingsChangeListener + from openpype.modules import ModulesManager, ISettingsChangeListener old_data = get_system_settings() default_values = get_default_settings()[SYSTEM_SETTINGS_KEY] @@ -186,8 +185,7 @@ def save_project_settings(project_name, overrides): SaveWarningExc: If any module raises the exception. """ # Notify Pype modules - from openpype.modules import ModulesManager - from openpype_interfaces import ISettingsChangeListener + from openpype.modules import ModulesManager, ISettingsChangeListener default_values = get_default_settings()[PROJECT_SETTINGS_KEY] if project_name: @@ -248,8 +246,7 @@ def save_project_anatomy(project_name, anatomy_data): SaveWarningExc: If any module raises the exception. """ # Notify Pype modules - from openpype.modules import ModulesManager - from openpype_interfaces import ISettingsChangeListener + from openpype.modules import ModulesManager, ISettingsChangeListener default_values = get_default_settings()[PROJECT_ANATOMY_KEY] if project_name: diff --git a/openpype/style/data.json b/openpype/style/data.json index 146af84663..404ca6944c 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -27,7 +27,7 @@ "bg": "#2C313A", "bg-inputs": "#21252B", "bg-buttons": "#434a56", - "bg-button-hover": "rgba(168, 175, 189, 0.3)", + "bg-button-hover": "rgb(81, 86, 97)", "bg-inputs-disabled": "#2C313A", "bg-buttons-disabled": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index 9919973b06..887c044dae 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -884,6 +884,26 @@ PublisherTabBtn[active="1"]:hover { background: {color:bg}; } +PixmapButton{ + border: 0px solid transparent; + border-radius: 0.2em; + background: {color:bg-buttons}; +} +PixmapButton:hover { + background: {color:bg-button-hover}; +} +PixmapButton:disabled { + background: {color:bg-buttons-disabled}; +} + +#ThumbnailPixmapHoverButton { + font-size: 11pt; + background: {color:bg-view}; +} +#ThumbnailPixmapHoverButton:hover { + background: {color:bg-button-hover}; +} + #CreatorDetailedDescription { padding-left: 5px; padding-right: 5px; @@ -911,11 +931,11 @@ PublisherTabBtn[active="1"]:hover { #PublishLogConsole { font-family: "Noto Sans Mono"; } -VariantInputsWidget QLineEdit { +#VariantInputsWidget QLineEdit { border-bottom-right-radius: 0px; border-top-right-radius: 0px; } -VariantInputsWidget QToolButton { +#VariantInputsWidget QToolButton { border-bottom-left-radius: 0px; border-top-left-radius: 0px; padding-top: 0.5em; diff --git a/openpype/widgets/attribute_defs/__init__.py b/openpype/tools/attribute_defs/__init__.py similarity index 65% rename from openpype/widgets/attribute_defs/__init__.py rename to openpype/tools/attribute_defs/__init__.py index ce6b80109e..f991fdec3d 100644 --- a/openpype/widgets/attribute_defs/__init__.py +++ b/openpype/tools/attribute_defs/__init__.py @@ -3,8 +3,14 @@ from .widgets import ( AttributeDefinitionsWidget, ) +from .dialog import ( + AttributeDefinitionsDialog, +) + __all__ = ( "create_widget_for_attr_def", "AttributeDefinitionsWidget", + + "AttributeDefinitionsDialog", ) diff --git a/openpype/tools/attribute_defs/dialog.py b/openpype/tools/attribute_defs/dialog.py new file mode 100644 index 0000000000..69923d54e5 --- /dev/null +++ b/openpype/tools/attribute_defs/dialog.py @@ -0,0 +1,33 @@ +from Qt import QtWidgets + +from .widgets import AttributeDefinitionsWidget + + +class AttributeDefinitionsDialog(QtWidgets.QDialog): + def __init__(self, attr_defs, parent=None): + super(AttributeDefinitionsDialog, self).__init__(parent) + + attrs_widget = AttributeDefinitionsWidget(attr_defs, self) + + btns_widget = QtWidgets.QWidget(self) + ok_btn = QtWidgets.QPushButton("OK", btns_widget) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn, 0) + btns_layout.addWidget(cancel_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(attrs_widget, 0) + main_layout.addStretch(1) + main_layout.addWidget(btns_widget, 0) + + ok_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + + self._attrs_widget = attrs_widget + + def get_values(self): + return self._attrs_widget.current_value() diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/tools/attribute_defs/files_widget.py similarity index 99% rename from openpype/widgets/attribute_defs/files_widget.py rename to openpype/tools/attribute_defs/files_widget.py index 3f1e6a34e1..738e50ba07 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/tools/attribute_defs/files_widget.py @@ -349,7 +349,7 @@ class FilesModel(QtGui.QStandardItemModel): item.setData(file_item.filenames, FILENAMES_ROLE) item.setData(file_item.directory, DIRPATH_ROLE) item.setData(icon_pixmap, ITEM_ICON_ROLE) - item.setData(file_item.ext, EXT_ROLE) + item.setData(file_item.lower_ext, EXT_ROLE) item.setData(file_item.is_dir, IS_DIR_ROLE) item.setData(file_item.is_sequence, IS_SEQUENCE_ROLE) @@ -463,7 +463,7 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): for filepath in filepaths: if os.path.isfile(filepath): _, ext = os.path.splitext(filepath) - if ext in self._allowed_extensions: + if ext.lower() in self._allowed_extensions: return True elif self._allow_folders: @@ -475,7 +475,7 @@ class FilesProxyModel(QtCore.QSortFilterProxyModel): for filepath in filepaths: if os.path.isfile(filepath): _, ext = os.path.splitext(filepath) - if ext in self._allowed_extensions: + if ext.lower() in self._allowed_extensions: filtered_paths.append(filepath) elif self._allow_folders: diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/tools/attribute_defs/widgets.py similarity index 100% rename from openpype/widgets/attribute_defs/widgets.py rename to openpype/tools/attribute_defs/widgets.py diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py index 28e94237ec..78a25d8d85 100644 --- a/openpype/tools/loader/lib.py +++ b/openpype/tools/loader/lib.py @@ -2,6 +2,8 @@ import inspect from Qt import QtGui import qtawesome +from openpype.lib.attribute_definitions import AbtractAttrDef +from openpype.tools.attribute_defs import AttributeDefinitionsDialog from openpype.tools.utils.widgets import ( OptionalAction, OptionDialog @@ -34,21 +36,30 @@ def get_options(action, loader, parent, repre_contexts): None when dialog was closed or cancelled, in all other cases {} if no options """ + # Pop option dialog options = {} loader_options = loader.get_options(repre_contexts) - if getattr(action, "optioned", False) and loader_options: + if not getattr(action, "optioned", False) or not loader_options: + return options + + if isinstance(loader_options[0], AbtractAttrDef): + qargparse_options = False + dialog = AttributeDefinitionsDialog(loader_options, parent) + else: + qargparse_options = True dialog = OptionDialog(parent) - dialog.setWindowTitle(action.label + " Options") dialog.create(loader_options) - if not dialog.exec_(): - return None + dialog.setWindowTitle(action.label + " Options") - # Get option - options = dialog.parse() + if not dialog.exec_(): + return None - return options + # Get option + if qargparse_options: + return dialog.parse() + return dialog.get_values() def add_representation_loaders_to_menu(loaders, menu, repre_contexts): diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index cca892ef72..8d1fe54e83 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -28,7 +28,7 @@ class NameDef: class NumberDef: def __init__(self, minimum=None, maximum=None, decimals=None): self.minimum = 0 if minimum is None else minimum - self.maximum = 999999 if maximum is None else maximum + self.maximum = 999999999 if maximum is None else maximum self.decimals = 0 if decimals is None else decimals diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py index 8bea69c812..74337ea1ab 100644 --- a/openpype/tools/publisher/constants.py +++ b/openpype/tools/publisher/constants.py @@ -20,9 +20,10 @@ INSTANCE_ID_ROLE = QtCore.Qt.UserRole + 1 SORT_VALUE_ROLE = QtCore.Qt.UserRole + 2 IS_GROUP_ROLE = QtCore.Qt.UserRole + 3 CREATOR_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 4 -FAMILY_ROLE = QtCore.Qt.UserRole + 5 -GROUP_ROLE = QtCore.Qt.UserRole + 6 -CONVERTER_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 7 +CREATOR_THUMBNAIL_ENABLED_ROLE = QtCore.Qt.UserRole + 5 +FAMILY_ROLE = QtCore.Qt.UserRole + 6 +GROUP_ROLE = QtCore.Qt.UserRole + 7 +CONVERTER_IDENTIFIER_ROLE = QtCore.Qt.UserRole + 8 __all__ = ( diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index e05cffe20e..615f3eb8d9 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -4,6 +4,8 @@ import logging import traceback import collections import uuid +import tempfile +import shutil from abc import ABCMeta, abstractmethod, abstractproperty import six @@ -24,6 +26,7 @@ from openpype.pipeline import ( KnownPublishError, registered_host, legacy_io, + get_process_id, ) from openpype.pipeline.create import ( CreateContext, @@ -87,9 +90,9 @@ class AssetDocsCache: return project_name = self._controller.project_name - asset_docs = get_assets( + asset_docs = list(get_assets( project_name, fields=self.projection.keys() - ) + )) asset_docs_by_name = {} task_names_by_asset_name = {} for asset_doc in asset_docs: @@ -825,6 +828,7 @@ class CreatorItem: default_variant, default_variants, create_allow_context_change, + create_allow_thumbnail, pre_create_attributes_defs ): self.identifier = identifier @@ -838,6 +842,7 @@ class CreatorItem: self.default_variant = default_variant self.default_variants = default_variants self.create_allow_context_change = create_allow_context_change + self.create_allow_thumbnail = create_allow_thumbnail self.instance_attributes_defs = instance_attributes_defs self.pre_create_attributes_defs = pre_create_attributes_defs @@ -864,6 +869,7 @@ class CreatorItem: default_variants = None pre_create_attr_defs = None create_allow_context_change = None + create_allow_thumbnail = None if creator_type is CreatorTypes.artist: description = creator.get_description() detail_description = creator.get_detail_description() @@ -871,6 +877,7 @@ class CreatorItem: default_variants = creator.get_default_variants() pre_create_attr_defs = creator.get_pre_create_attr_defs() create_allow_context_change = creator.create_allow_context_change + create_allow_thumbnail = creator.create_allow_thumbnail identifier = creator.identifier return cls( @@ -886,6 +893,7 @@ class CreatorItem: default_variant, default_variants, create_allow_context_change, + create_allow_thumbnail, pre_create_attr_defs ) @@ -914,6 +922,7 @@ class CreatorItem: "default_variant": self.default_variant, "default_variants": self.default_variants, "create_allow_context_change": self.create_allow_context_change, + "create_allow_thumbnail": self.create_allow_thumbnail, "instance_attributes_defs": instance_attributes_defs, "pre_create_attributes_defs": pre_create_attributes_defs, } @@ -1115,11 +1124,13 @@ class AbstractPublisherController(object): pass + @abstractmethod def save_changes(self): """Save changes in create context.""" pass + @abstractmethod def remove_instances(self, instance_ids): """Remove list of instances from create context.""" # TODO expect instance ids @@ -1256,6 +1267,14 @@ class AbstractPublisherController(object): def trigger_convertor_items(self, convertor_identifiers): pass + @abstractmethod + def get_thumbnail_paths_for_instances(self, instance_ids): + pass + + @abstractmethod + def set_thumbnail_paths_for_instances(self, thumbnail_path_mapping): + pass + @abstractmethod def set_comment(self, comment): """Set comment on pyblish context. @@ -1283,6 +1302,22 @@ class AbstractPublisherController(object): pass + @abstractmethod + def get_thumbnail_temp_dir_path(self): + """Return path to directory where thumbnails can be temporary stored. + + Returns: + str: Path to a directory. + """ + + pass + + @abstractmethod + def clear_thumbnail_temp_dir_path(self): + """Remove content of thumbnail temp directory.""" + + pass + class BasePublisherController(AbstractPublisherController): """Implement common logic for controllers. @@ -1523,6 +1558,26 @@ class BasePublisherController(AbstractPublisherController): return creator_item.icon return None + def get_thumbnail_temp_dir_path(self): + """Return path to directory where thumbnails can be temporary stored. + + Returns: + str: Path to a directory. + """ + + return os.path.join( + tempfile.gettempdir(), + "publisher_thumbnails", + get_process_id() + ) + + def clear_thumbnail_temp_dir_path(self): + """Remove content of thumbnail temp directory.""" + + dirpath = self.get_thumbnail_temp_dir_path() + if os.path.exists(dirpath): + shutil.rmtree(dirpath) + class PublisherController(BasePublisherController): """Middleware between UI, CreateContext and publish Context. @@ -1778,6 +1833,29 @@ class PublisherController(BasePublisherController): self._on_create_instance_change() + def get_thumbnail_paths_for_instances(self, instance_ids): + thumbnail_paths_by_instance_id = ( + self._create_context.thumbnail_paths_by_instance_id + ) + return { + instance_id: thumbnail_paths_by_instance_id.get(instance_id) + for instance_id in instance_ids + } + + def set_thumbnail_paths_for_instances(self, thumbnail_path_mapping): + thumbnail_paths_by_instance_id = ( + self._create_context.thumbnail_paths_by_instance_id + ) + for instance_id, thumbnail_path in thumbnail_path_mapping.items(): + thumbnail_paths_by_instance_id[instance_id] = thumbnail_path + + self._emit_event( + "instance.thumbnail.changed", + { + "mapping": thumbnail_path_mapping + } + ) + def emit_card_message( self, message, message_type=CardMessageTypes.standard ): diff --git a/openpype/tools/publisher/control_qt.py b/openpype/tools/publisher/control_qt.py index 56132a4046..8b5856f234 100644 --- a/openpype/tools/publisher/control_qt.py +++ b/openpype/tools/publisher/control_qt.py @@ -115,6 +115,11 @@ class QtRemotePublishController(BasePublisherController): super().__init__(*args, **kwargs) self._created_instances = {} + self._thumbnail_paths_by_instance_id = None + + def _reset_attributes(self): + super()._reset_attributes() + self._thumbnail_paths_by_instance_id = None @abstractmethod def _get_serialized_instances(self): @@ -180,6 +185,11 @@ class QtRemotePublishController(BasePublisherController): self.host_is_valid = event["value"] return + # Don't skip because UI want know about it too + if event.topic == "instance.thumbnail.changed": + for instance_id, path in event["mapping"].items(): + self.thumbnail_paths_by_instance_id[instance_id] = path + # Topics that can be just passed by because are not affecting # controller itself # - "show.card.message" @@ -256,6 +266,42 @@ class QtRemotePublishController(BasePublisherController): def get_existing_subset_names(self, asset_name): pass + @property + def thumbnail_paths_by_instance_id(self): + if self._thumbnail_paths_by_instance_id is None: + self._thumbnail_paths_by_instance_id = ( + self._collect_thumbnail_paths_by_instance_id() + ) + return self._thumbnail_paths_by_instance_id + + def get_thumbnail_path_for_instance(self, instance_id): + return self.thumbnail_paths_by_instance_id.get(instance_id) + + def set_thumbnail_path_for_instance(self, instance_id, thumbnail_path): + self._set_thumbnail_path_on_context(self, instance_id, thumbnail_path) + + @abstractmethod + def _collect_thumbnail_paths_by_instance_id(self): + """Collect thumbnail paths by instance id in remote controller. + + These should be collected from 'CreatedContext' there. + + Returns: + Dict[str, str]: Mapping of thumbnail path by instance id. + """ + + pass + + @abstractmethod + def _set_thumbnail_path_on_context(self, instance_id, thumbnail_path): + """Send change of thumbnail path in remote controller. + + That should trigger event 'instance.thumbnail.changed' which is + captured and handled in default implementation in this class. + """ + + pass + @abstractmethod def get_subset_name( self, diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index ff388fb277..0d35ac3512 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -27,6 +27,9 @@ class PluginLoadReportModel(QtGui.QStandardItemModel): parent = self.invisibleRootItem() parent.removeRows(0, parent.rowCount()) + if report is None: + return + new_items = [] new_items_by_filepath = {} for filepath in report.crashed_plugin_paths.keys(): diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 2c249d058c..646ae69e7f 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -367,6 +367,7 @@ class LoadedFilesView(QtWidgets.QTreeView): def _on_rows_inserted(self): header = self.header() header.resizeSections(header.ResizeToContents) + self._update_remove_btn() def resizeEvent(self, event): super(LoadedFilesView, self).resizeEvent(event) diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 910b2adfc7..7bdac46273 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -1,15 +1,14 @@ -import sys import re -import traceback from Qt import QtWidgets, QtCore, QtGui from openpype.pipeline.create import ( - CreatorError, SUBSET_NAME_ALLOWED_SYMBOLS, + PRE_CREATE_THUMBNAIL_KEY, TaskNotSetError, ) +from .thumbnail_widget import ThumbnailWidget from .widgets import ( IconValuePixmapLabel, CreateBtn, @@ -20,17 +19,18 @@ from .precreate_widget import PreCreateWidget from ..constants import ( VARIANT_TOOLTIP, CREATOR_IDENTIFIER_ROLE, - FAMILY_ROLE + FAMILY_ROLE, + CREATOR_THUMBNAIL_ENABLED_ROLE, ) SEPARATORS = ("---separator---", "---") -class VariantInputsWidget(QtWidgets.QWidget): +class ResizeControlWidget(QtWidgets.QWidget): resized = QtCore.Signal() def resizeEvent(self, event): - super(VariantInputsWidget, self).resizeEvent(event) + super(ResizeControlWidget, self).resizeEvent(event) self.resized.emit() @@ -153,13 +153,20 @@ class CreateWidget(QtWidgets.QWidget): # --- Creator attr defs --- creators_attrs_widget = QtWidgets.QWidget(creators_splitter) + # Top part - variant / subset name + thumbnail + creators_attrs_top = QtWidgets.QWidget(creators_attrs_widget) + + # Basics - variant / subset name + creator_basics_widget = ResizeControlWidget(creators_attrs_top) + variant_subset_label = QtWidgets.QLabel( - "Create options", creators_attrs_widget + "Create options", creator_basics_widget ) - variant_subset_widget = QtWidgets.QWidget(creators_attrs_widget) + variant_subset_widget = QtWidgets.QWidget(creator_basics_widget) # Variant and subset input - variant_widget = VariantInputsWidget(creators_attrs_widget) + variant_widget = ResizeControlWidget(variant_subset_widget) + variant_widget.setObjectName("VariantInputsWidget") variant_input = QtWidgets.QLineEdit(variant_widget) variant_input.setObjectName("VariantInput") @@ -186,6 +193,18 @@ class CreateWidget(QtWidgets.QWidget): variant_subset_layout.addRow("Variant", variant_widget) variant_subset_layout.addRow("Subset", subset_name_input) + creator_basics_layout = QtWidgets.QVBoxLayout(creator_basics_widget) + creator_basics_layout.setContentsMargins(0, 0, 0, 0) + creator_basics_layout.addWidget(variant_subset_label, 0) + creator_basics_layout.addWidget(variant_subset_widget, 0) + + thumbnail_widget = ThumbnailWidget(controller, creators_attrs_top) + + creators_attrs_top_layout = QtWidgets.QHBoxLayout(creators_attrs_top) + creators_attrs_top_layout.setContentsMargins(0, 0, 0, 0) + creators_attrs_top_layout.addWidget(creator_basics_widget, 1) + creators_attrs_top_layout.addWidget(thumbnail_widget, 0) + # Precreate attributes widget pre_create_widget = PreCreateWidget(creators_attrs_widget) @@ -201,8 +220,7 @@ class CreateWidget(QtWidgets.QWidget): creators_attrs_layout = QtWidgets.QVBoxLayout(creators_attrs_widget) creators_attrs_layout.setContentsMargins(0, 0, 0, 0) - creators_attrs_layout.addWidget(variant_subset_label, 0) - creators_attrs_layout.addWidget(variant_subset_widget, 0) + creators_attrs_layout.addWidget(creators_attrs_top, 0) creators_attrs_layout.addWidget(pre_create_widget, 1) creators_attrs_layout.addWidget(create_btn_wrapper, 0) @@ -240,6 +258,7 @@ class CreateWidget(QtWidgets.QWidget): create_btn.clicked.connect(self._on_create) variant_widget.resized.connect(self._on_variant_widget_resize) + creator_basics_widget.resized.connect(self._on_creator_basics_resize) variant_input.returnPressed.connect(self._on_create) variant_input.textChanged.connect(self._on_variant_change) creators_view.selectionModel().currentChanged.connect( @@ -252,6 +271,8 @@ class CreateWidget(QtWidgets.QWidget): self._on_current_session_context_request ) tasks_widget.task_changed.connect(self._on_task_change) + thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) + thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear) controller.event_system.add_callback( "plugins.refresh.finished", self._on_plugins_refresh @@ -278,11 +299,14 @@ class CreateWidget(QtWidgets.QWidget): self._create_btn = create_btn self._creator_short_desc_widget = creator_short_desc_widget + self._creator_basics_widget = creator_basics_widget + self._thumbnail_widget = thumbnail_widget self._pre_create_widget = pre_create_widget self._attr_separator_widget = attr_separator_widget self._prereq_timer = prereq_timer self._first_show = True + self._last_thumbnail_path = None @property def current_asset_name(self): @@ -434,6 +458,10 @@ class CreateWidget(QtWidgets.QWidget): item.setData(creator_item.label, QtCore.Qt.DisplayRole) item.setData(identifier, CREATOR_IDENTIFIER_ROLE) + item.setData( + creator_item.create_allow_thumbnail, + CREATOR_THUMBNAIL_ENABLED_ROLE + ) item.setData(creator_item.family, FAMILY_ROLE) # Remove families that are no more available @@ -473,6 +501,13 @@ class CreateWidget(QtWidgets.QWidget): if self._context_change_is_enabled(): self._invalidate_prereq_deffered() + def _on_thumbnail_create(self, thumbnail_path): + self._last_thumbnail_path = thumbnail_path + self._thumbnail_widget.set_current_thumbnails([thumbnail_path]) + + def _on_thumbnail_clear(self): + self._last_thumbnail_path = None + def _on_current_session_context_request(self): self._assets_widget.set_current_session_asset() task_name = self.current_task_name @@ -527,6 +562,10 @@ class CreateWidget(QtWidgets.QWidget): self._set_context_enabled(creator_item.create_allow_context_change) self._refresh_asset() + self._thumbnail_widget.setVisible( + creator_item.create_allow_thumbnail + ) + default_variants = creator_item.default_variants if not default_variants: default_variants = ["Main"] @@ -684,6 +723,11 @@ class CreateWidget(QtWidgets.QWidget): self._first_show = False self._on_first_show() + def _on_creator_basics_resize(self): + self._thumbnail_widget.set_height( + self._creator_basics_widget.sizeHint().height() + ) + def _on_create(self): indexes = self._creators_view.selectedIndexes() if not indexes or len(indexes) > 1: @@ -706,6 +750,11 @@ class CreateWidget(QtWidgets.QWidget): task_name = self._get_task_name() pre_create_data = self._pre_create_widget.current_value() + if index.data(CREATOR_THUMBNAIL_ENABLED_ROLE): + pre_create_data[PRE_CREATE_THUMBNAIL_KEY] = ( + self._last_thumbnail_path + ) + # Where to define these data? # - what data show be stored? instance_data = { @@ -725,3 +774,5 @@ class CreateWidget(QtWidgets.QWidget): if success: self._set_creator(self._selected_creator) self._controller.emit_card_message("Creation finished...") + self._last_thumbnail_path = None + self._thumbnail_widget.set_current_thumbnails() diff --git a/openpype/tools/publisher/widgets/images/clear_thumbnail.png b/openpype/tools/publisher/widgets/images/clear_thumbnail.png new file mode 100644 index 0000000000..406328cb51 Binary files /dev/null and b/openpype/tools/publisher/widgets/images/clear_thumbnail.png differ diff --git a/openpype/tools/publisher/widgets/precreate_widget.py b/openpype/tools/publisher/widgets/precreate_widget.py index ef34c9bcb5..b688a83053 100644 --- a/openpype/tools/publisher/widgets/precreate_widget.py +++ b/openpype/tools/publisher/widgets/precreate_widget.py @@ -1,6 +1,6 @@ from Qt import QtWidgets, QtCore -from openpype.widgets.attribute_defs import create_widget_for_attr_def +from openpype.tools.attribute_defs import create_widget_for_attr_def class PreCreateWidget(QtWidgets.QWidget): diff --git a/openpype/tools/publisher/widgets/thumbnail_widget.py b/openpype/tools/publisher/widgets/thumbnail_widget.py new file mode 100644 index 0000000000..035ec4b04b --- /dev/null +++ b/openpype/tools/publisher/widgets/thumbnail_widget.py @@ -0,0 +1,508 @@ +import os +import uuid + +from Qt import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors +from openpype.lib import ( + run_subprocess, + is_oiio_supported, + get_oiio_tools_path, + get_ffmpeg_tool_path, +) +from openpype.lib.transcoding import ( + IMAGE_EXTENSIONS, + VIDEO_EXTENSIONS, +) + +from openpype.tools.utils import ( + paint_image_with_color, + PixmapButton, +) +from openpype.tools.publisher.control import CardMessageTypes + +from .icons import get_image + + +class ThumbnailPainterWidget(QtWidgets.QWidget): + width_ratio = 3.0 + height_ratio = 2.0 + border_width = 1 + max_thumbnails = 3 + offset_sep = 4 + checker_boxes_count = 20 + + def __init__(self, parent): + super(ThumbnailPainterWidget, self).__init__(parent) + + border_color = get_objected_colors("bg-buttons").get_qcolor() + thumbnail_bg_color = get_objected_colors("bg-view").get_qcolor() + overlay_color = get_objected_colors("font").get_qcolor() + + default_image = get_image("thumbnail") + default_pix = paint_image_with_color(default_image, border_color) + + self.border_color = border_color + self.thumbnail_bg_color = thumbnail_bg_color + self.overlay_color = overlay_color + self._default_pix = default_pix + + self._cached_pix = None + self._current_pixes = None + self._has_pixes = False + + @property + def has_pixes(self): + return self._has_pixes + + def clear_cache(self): + self._cached_pix = None + self.repaint() + + def set_current_thumbnails(self, thumbnail_paths=None): + pixes = [] + if thumbnail_paths: + for thumbnail_path in thumbnail_paths: + pixes.append(QtGui.QPixmap(thumbnail_path)) + + self._current_pixes = pixes or None + self._has_pixes = self._current_pixes is not None + self.clear_cache() + + def paintEvent(self, event): + if self._cached_pix is None: + self._cache_pix() + + painter = QtGui.QPainter() + painter.begin(self) + painter.drawPixmap(0, 0, self._cached_pix) + painter.end() + + def _paint_checker(self, width, height): + checker_size = int(float(width) / self.checker_boxes_count) + if checker_size < 1: + checker_size = 1 + + checker_pix = QtGui.QPixmap(checker_size * 2, checker_size * 2) + checker_pix.fill(QtCore.Qt.transparent) + checker_painter = QtGui.QPainter() + checker_painter.begin(checker_pix) + checker_painter.setPen(QtCore.Qt.NoPen) + checker_painter.setBrush(QtGui.QColor(89, 89, 89)) + checker_painter.drawRect( + 0, 0, checker_pix.width(), checker_pix.height() + ) + checker_painter.setBrush(QtGui.QColor(188, 187, 187)) + checker_painter.drawRect( + 0, 0, checker_size, checker_size + ) + checker_painter.drawRect( + checker_size, checker_size, checker_size, checker_size + ) + checker_painter.end() + return checker_pix + + def _paint_default_pix(self, pix_width, pix_height): + full_border_width = 2 * self.border_width + width = pix_width - full_border_width + height = pix_height - full_border_width + if width > 100: + width = int(width * 0.6) + height = int(height * 0.6) + + scaled_pix = self._default_pix.scaled( + width, + height, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + pix_painter.setRenderHints( + pix_painter.Antialiasing + | pix_painter.SmoothPixmapTransform + | pix_painter.HighQualityAntialiasing + ) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + return new_pix + + def _draw_thumbnails(self, thumbnails, pix_width, pix_height): + full_border_width = 2 * self.border_width + + checker_pix = self._paint_checker(pix_width, pix_height) + + backgrounded_images = [] + for src_pix in thumbnails: + scaled_pix = src_pix.scaled( + pix_width - full_border_width, + pix_height - full_border_width, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + pos_x = int( + (pix_width - scaled_pix.width()) / 2 + ) + pos_y = int( + (pix_height - scaled_pix.height()) / 2 + ) + + new_pix = QtGui.QPixmap(pix_width, pix_height) + new_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(new_pix) + pix_painter.setRenderHints( + pix_painter.Antialiasing + | pix_painter.SmoothPixmapTransform + | pix_painter.HighQualityAntialiasing + ) + + tiled_rect = QtCore.QRectF( + pos_x, pos_y, scaled_pix.width(), scaled_pix.height() + ) + pix_painter.drawTiledPixmap( + tiled_rect, + checker_pix, + QtCore.QPointF(0.0, 0.0) + ) + pix_painter.drawPixmap(pos_x, pos_y, scaled_pix) + pix_painter.end() + backgrounded_images.append(new_pix) + return backgrounded_images + + def _cache_pix(self): + rect = self.rect() + rect_width = rect.width() + rect_height = rect.height() + + pix_x_offset = 0 + pix_y_offset = 0 + expected_height = int( + (rect_width / self.width_ratio) * self.height_ratio + ) + if expected_height > rect_height: + expected_height = rect_height + expected_width = int( + (rect_height / self.height_ratio) * self.width_ratio + ) + pix_x_offset = (rect_width - expected_width) / 2 + else: + expected_width = rect_width + pix_y_offset = (rect_height - expected_height) / 2 + + if self._current_pixes is None: + used_default_pix = True + pixes_to_draw = None + pixes_len = 1 + else: + used_default_pix = False + pixes_to_draw = self._current_pixes + if len(pixes_to_draw) > self.max_thumbnails: + pixes_to_draw = pixes_to_draw[:-self.max_thumbnails] + pixes_len = len(pixes_to_draw) + + width_offset, height_offset = self._get_pix_offset_size( + expected_width, expected_height, pixes_len + ) + pix_width = expected_width - width_offset + pix_height = expected_height - height_offset + + if used_default_pix: + thumbnail_images = [self._paint_default_pix(pix_width, pix_height)] + else: + thumbnail_images = self._draw_thumbnails( + pixes_to_draw, pix_width, pix_height + ) + + if pixes_len == 1: + width_offset_part = 0 + height_offset_part = 0 + else: + width_offset_part = int(float(width_offset) / (pixes_len - 1)) + height_offset_part = int(float(height_offset) / (pixes_len - 1)) + full_width_offset = width_offset + pix_x_offset + + final_pix = QtGui.QPixmap(rect_width, rect_height) + final_pix.fill(QtCore.Qt.transparent) + + bg_pen = QtGui.QPen() + bg_pen.setWidth(self.border_width) + bg_pen.setColor(self.border_color) + + final_painter = QtGui.QPainter() + final_painter.begin(final_pix) + final_painter.setRenderHints( + final_painter.Antialiasing + | final_painter.SmoothPixmapTransform + | final_painter.HighQualityAntialiasing + ) + final_painter.setBrush(QtGui.QBrush(self.thumbnail_bg_color)) + final_painter.setPen(bg_pen) + final_painter.drawRect(rect) + + for idx, pix in enumerate(thumbnail_images): + x_offset = full_width_offset - (width_offset_part * idx) + y_offset = (height_offset_part * idx) + pix_y_offset + final_painter.drawPixmap(x_offset, y_offset, pix) + + # Draw drop enabled dashes + if used_default_pix: + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + final_painter.setPen(pen) + final_painter.setBrush(QtCore.Qt.transparent) + final_painter.drawRect(rect) + + final_painter.end() + + self._cached_pix = final_pix + + def _get_pix_offset_size(self, width, height, image_count): + if image_count == 1: + return 0, 0 + + part_width = width / self.offset_sep + part_height = height / self.offset_sep + return part_width, part_height + + +class ThumbnailWidget(QtWidgets.QWidget): + """Instance thumbnail widget.""" + + thumbnail_created = QtCore.Signal(str) + thumbnail_cleared = QtCore.Signal() + + def __init__(self, controller, parent): + # Missing implementation for thumbnail + # - widget kept to make a visial offset of global attr widget offset + super(ThumbnailWidget, self).__init__(parent) + self.setAcceptDrops(True) + + thumbnail_painter = ThumbnailPainterWidget(self) + + buttons_widget = QtWidgets.QWidget(self) + buttons_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + icon_color = get_objected_colors("bg-view-selection").get_qcolor() + icon_color.setAlpha(255) + clear_image = get_image("clear_thumbnail") + clear_pix = paint_image_with_color(clear_image, icon_color) + + clear_button = PixmapButton(clear_pix, buttons_widget) + clear_button.setObjectName("ThumbnailPixmapHoverButton") + + buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) + buttons_layout.setContentsMargins(3, 3, 3, 3) + buttons_layout.addStretch(1) + buttons_layout.addWidget(clear_button, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(thumbnail_painter) + + clear_button.clicked.connect(self._on_clear_clicked) + + self._controller = controller + self._output_dir = controller.get_thumbnail_temp_dir_path() + + self._review_extensions = set(IMAGE_EXTENSIONS) | set(VIDEO_EXTENSIONS) + + self._height = None + self._width = None + self._adapted_to_size = True + self._last_width = None + self._last_height = None + + self._buttons_widget = buttons_widget + self._thumbnail_painter = thumbnail_painter + + @property + def width_ratio(self): + return self._thumbnail_painter.width_ratio + + @property + def height_ratio(self): + return self._thumbnail_painter.height_ratio + + def _get_filepath_from_event(self, event): + mime_data = event.mimeData() + if not mime_data.hasUrls(): + return None + + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + if os.path.exists(filepath): + filepaths.append(filepath) + + if len(filepaths) == 1: + filepath = filepaths[0] + ext = os.path.splitext(filepath)[-1] + if ext in self._review_extensions: + return filepath + return None + + def dragEnterEvent(self, event): + filepath = self._get_filepath_from_event(event) + if filepath: + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + filepath = self._get_filepath_from_event(event) + if not filepath: + return + + output = export_thumbnail(filepath, self._output_dir) + if output: + self.thumbnail_created.emit(output) + else: + self._controller.emit_card_message( + "Couldn't convert the source for thumbnail", + CardMessageTypes.error + ) + + def set_adapted_to_hint(self, enabled): + self._adapted_to_size = enabled + if self._width is not None: + self.setMinimumHeight(0) + self._width = None + + if self._height is not None: + self.setMinimumWidth(0) + self._height = None + + def set_width(self, width): + if self._width == width: + return + + self._adapted_to_size = False + self._width = width + self.setMinimumHeight(int( + (width / self.width_ratio) * self.height_ratio + )) + if self._height is not None: + self.setMinimumWidth(0) + self._height = None + self._thumbnail_painter.clear_cache() + + def set_height(self, height): + if self._height == height: + return + + self._height = height + self._adapted_to_size = False + self.setMinimumWidth(int( + (height / self.height_ratio) * self.width_ratio + )) + if self._width is not None: + self.setMinimumHeight(0) + self._width = None + + self._thumbnail_painter.clear_cache() + + def set_current_thumbnails(self, thumbnail_paths=None): + self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) + self._update_buttons_position() + + def _on_clear_clicked(self): + self.set_current_thumbnails() + self.thumbnail_cleared.emit() + + def _adapt_to_size(self): + if not self._adapted_to_size: + return + + width = self.width() + height = self.height() + if width == self._last_width and height == self._last_height: + return + + self._last_width = width + self._last_height = height + self._thumbnail_painter.clear_cache() + + def _update_buttons_position(self): + self._buttons_widget.setVisible(self._thumbnail_painter.has_pixes) + size = self.size() + my_height = size.height() + height = self._buttons_widget.sizeHint().height() + self._buttons_widget.setGeometry( + 0, my_height - height, + size.width(), height + ) + + def resizeEvent(self, event): + super(ThumbnailWidget, self).resizeEvent(event) + self._adapt_to_size() + self._update_buttons_position() + + def showEvent(self, event): + super(ThumbnailWidget, self).showEvent(event) + self._adapt_to_size() + self._update_buttons_position() + + +def _run_silent_subprocess(args): + with open(os.devnull, "w") as devnull: + run_subprocess(args, stdout=devnull, stderr=devnull) + + +def _convert_thumbnail_oiio(src_path, dst_path): + if not is_oiio_supported(): + return None + + oiio_cmd = [ + get_oiio_tools_path(), + "-i", src_path, + "--subimage", "0", + "-o", dst_path + ] + try: + _run_silent_subprocess(oiio_cmd) + except Exception: + return None + return dst_path + + +def _convert_thumbnail_ffmpeg(src_path, dst_path): + ffmpeg_cmd = [ + get_ffmpeg_tool_path(), + "-y", + "-i", src_path, + dst_path + ] + try: + _run_silent_subprocess(ffmpeg_cmd) + except Exception: + return None + return dst_path + + +def export_thumbnail(src_path, root_dir): + if not os.path.exists(root_dir): + os.makedirs(root_dir) + + ext = os.path.splitext(src_path)[-1] + if ext not in (".jpeg", ".jpg", ".png"): + ext = ".jpeg" + filename = str(uuid.uuid4()) + ext + dst_path = os.path.join(root_dir, filename) + + output_path = _convert_thumbnail_oiio(src_path, dst_path) + if not output_path: + output_path = _convert_thumbnail_ffmpeg(src_path, dst_path) + return output_path diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index d4c2623790..447fd7bc12 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -3,11 +3,13 @@ import os import re import copy import functools +import uuid +import shutil import collections from Qt import QtWidgets, QtCore, QtGui import qtawesome -from openpype.widgets.attribute_defs import create_widget_for_attr_def +from openpype.tools.attribute_defs import create_widget_for_attr_def from openpype.tools import resources from openpype.tools.flickcharm import FlickCharm from openpype.tools.utils import ( @@ -22,6 +24,7 @@ from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, TaskNotSetError, ) +from .thumbnail_widget import ThumbnailWidget from .assets_widget import AssetsDialog from .tasks_widget import TasksModel from .icons import ( @@ -124,6 +127,7 @@ class PublishIconBtn(IconButton): - error : other error happened - success : publishing finished """ + def __init__(self, pixmap_path, *args, **kwargs): super(PublishIconBtn, self).__init__(*args, **kwargs) @@ -1063,6 +1067,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): def _on_submit(self): """Commit changes for selected instances.""" + variant_value = None asset_name = None task_name = None @@ -1131,6 +1136,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): def _on_cancel(self): """Cancel changes and set back to their irigin value.""" + self.variant_input.reset_to_origin() self.asset_value_widget.reset_to_origin() self.task_value_widget.reset_to_origin() @@ -1223,7 +1229,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): Attributes are defined on creator so are dynamic. Their look and type is based on attribute definitions that are defined in `~/openpype/pipeline/lib/attribute_definitions.py` and their widget - representation in `~/openpype/widgets/attribute_defs/*`. + representation in `~/openpype/tools/attribute_defs/*`. Widgets are disabled if context of instance is not valid. @@ -1256,6 +1262,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): def set_instances_valid(self, valid): """Change valid state of current instances.""" + if ( self._content_widget is not None and self._content_widget.isEnabled() != valid @@ -1264,6 +1271,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): def set_current_instances(self, instances): """Set current instances for which are attribute definitions shown.""" + prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() @@ -1345,7 +1353,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): Look and type of attributes is based on attribute definitions that are defined in `~/openpype/pipeline/lib/attribute_definitions.py` and their - widget representation in `~/openpype/widgets/attribute_defs/*`. + widget representation in `~/openpype/tools/attribute_defs/*`. Widgets are disabled if context of instance is not valid. @@ -1353,6 +1361,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): families. Similar definitions are merged into one (different label does not count). """ + def __init__(self, controller, parent): super(PublishPluginAttrsWidget, self).__init__(parent) @@ -1386,6 +1395,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): def set_current_instances(self, instances, context_selected): """Set current instances for which are attribute definitions shown.""" + prev_content_widget = self._scroll_area.widget() if prev_content_widget: self._scroll_area.takeWidget() @@ -1471,7 +1481,7 @@ class SubsetAttributesWidget(QtWidgets.QWidget): # Global attributes global_attrs_widget = GlobalAttrsWidget(controller, top_widget) - thumbnail_widget = ThumbnailWidget(top_widget) + thumbnail_widget = ThumbnailWidget(controller, top_widget) top_layout = QtWidgets.QHBoxLayout(top_widget) top_layout.setContentsMargins(0, 0, 0, 0) @@ -1559,6 +1569,12 @@ class SubsetAttributesWidget(QtWidgets.QWidget): self._on_instance_context_changed ) convert_btn.clicked.connect(self._on_convert_click) + thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) + thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear) + + controller.event_system.add_callback( + "instance.thumbnail.changed", self._on_thumbnail_changed + ) self._controller = controller @@ -1568,7 +1584,7 @@ class SubsetAttributesWidget(QtWidgets.QWidget): self.creator_attrs_widget = creator_attrs_widget self.publish_attrs_widget = publish_attrs_widget - self.thumbnail_widget = thumbnail_widget + self._thumbnail_widget = thumbnail_widget self.top_bottom = top_bottom self.bottom_separator = bottom_separator @@ -1595,10 +1611,11 @@ class SubsetAttributesWidget(QtWidgets.QWidget): """Change currently selected items. Args: - instances(list): List of currently selected + instances(List[CreatedInstance]): List of currently selected instances. context_selected(bool): Is context selected. """ + all_valid = True for instance in instances: if not instance.has_valid_context: @@ -1620,35 +1637,74 @@ class SubsetAttributesWidget(QtWidgets.QWidget): self.creator_attrs_widget.set_instances_valid(all_valid) self.publish_attrs_widget.set_instances_valid(all_valid) + self._update_thumbnails() -class ThumbnailWidget(QtWidgets.QWidget): - """Instance thumbnail widget. + def _on_thumbnail_create(self, path): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) - Logic implementation of this widget is missing but widget is used - to offset `GlobalAttrsWidget` inputs visually. - """ - def __init__(self, parent): - super(ThumbnailWidget, self).__init__(parent) + if not instance_ids: + return - # Missing implementation for thumbnail - # - widget kept to make a visial offset of global attr widget offset - # default_pix = get_pixmap("thumbnail") - default_pix = QtGui.QPixmap(10, 10) - default_pix.fill(QtCore.Qt.transparent) + mapping = {} + if len(instance_ids) == 1: + mapping[instance_ids[0]] = path - thumbnail_label = QtWidgets.QLabel(self) - thumbnail_label.setPixmap( - default_pix.scaled( - 200, 100, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) + else: + for instance_id in instance_ids: + root = os.path.dirname(path) + ext = os.path.splitext(path)[-1] + dst_path = os.path.join(root, str(uuid.uuid4()) + ext) + shutil.copy(path, dst_path) + mapping[instance_id] = dst_path + + self._controller.set_thumbnail_paths_for_instances(mapping) + + def _on_thumbnail_clear(self): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + return + + mapping = { + instance_id: None + for instance_id in instance_ids + } + self._controller.set_thumbnail_paths_for_instances(mapping) + + def _on_thumbnail_changed(self, event): + self._update_thumbnails() + + def _update_thumbnails(self): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + self._thumbnail_widget.setVisible(False) + self._thumbnail_widget.set_current_thumbnails(None) + return + + mapping = self._controller.get_thumbnail_paths_for_instances( + instance_ids ) + thumbnail_paths = [] + for instance_id in instance_ids: + path = mapping[instance_id] + if path: + thumbnail_paths.append(path) - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(thumbnail_label, alignment=QtCore.Qt.AlignCenter) - - self.thumbnail_label = thumbnail_label - self.default_pix = default_pix - self.current_pix = None + self._thumbnail_widget.setVisible(True) + self._thumbnail_widget.set_current_thumbnails(thumbnail_paths) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index a3387043b8..5875f7aa68 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -225,6 +225,12 @@ class PublisherWindow(QtWidgets.QDialog): # Floating publish frame publish_frame = PublishFrame(controller, self.footer_border, self) + # Timer started on show -> connected to timer counter + # - helps to deffer on show logic by 3 event loops + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + show_timer.timeout.connect(self._on_show_timer) + errors_dialog_message_timer = QtCore.QTimer() errors_dialog_message_timer.setInterval(100) errors_dialog_message_timer.timeout.connect( @@ -329,7 +335,6 @@ class PublisherWindow(QtWidgets.QDialog): # forin init self._reset_on_first_show = reset_on_show self._reset_on_show = True - self._restart_timer = None self._publish_frame_visible = None self._error_messages_to_show = collections.deque() @@ -337,6 +342,9 @@ class PublisherWindow(QtWidgets.QDialog): self._set_publish_visibility(False) + self._show_timer = show_timer + self._show_counter = 0 + @property def controller(self): return self._controller @@ -347,22 +355,19 @@ class PublisherWindow(QtWidgets.QDialog): self._first_show = False self._on_first_show() - if not self._reset_on_show: - return - - self._reset_on_show = False - # Detach showing - give OS chance to draw the window - timer = QtCore.QTimer() - timer.setSingleShot(True) - timer.setInterval(1) - timer.timeout.connect(self._on_show_restart_timer) - self._restart_timer = timer - timer.start() + self._show_timer.start() def resizeEvent(self, event): super(PublisherWindow, self).resizeEvent(event) self._update_publish_frame_rect() + def keyPressEvent(self, event): + # Ignore escape button to close window + if event.key() == QtCore.Qt.Key_Escape: + event.accept() + return + super(PublisherWindow, self).keyPressEvent(event) + def _on_overlay_message(self, event): self._overlay_object.add_message( event["message"], @@ -374,15 +379,26 @@ class PublisherWindow(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) self._reset_on_show = self._reset_on_first_show - def _on_show_restart_timer(self): - """Callback for '_restart_timer' timer.""" + def _on_show_timer(self): + # Add 1 to counter until hits 2 + if self._show_counter < 3: + self._show_counter += 1 + return - self._restart_timer = None - self.reset() + # Stop the timer + self._show_timer.stop() + # Reset counter when done for next show event + self._show_counter = 0 + + # Reset if requested + if self._reset_on_show: + self._reset_on_show = False + self.reset() def closeEvent(self, event): self.save_changes() self._reset_on_show = True + self._controller.clear_thumbnail_temp_dir_path() super(PublisherWindow, self).closeEvent(event) def save_changes(self): diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index f4b2c13a12..e1b3943317 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -892,6 +892,10 @@ class ProjectWidget(SettingsCategoryWidget): def __init__(self, *args, **kwargs): super(ProjectWidget, self).__init__(*args, **kwargs) + def set_edit_mode(self, enabled): + super(ProjectWidget, self).set_edit_mode(enabled) + self.project_list_widget.set_edit_mode(enabled) + def _check_last_saved_info(self): if self.is_modifying_defaults: return True diff --git a/openpype/tools/settings/settings/constants.py b/openpype/tools/settings/settings/constants.py index d98d18c8bf..23526e4de9 100644 --- a/openpype/tools/settings/settings/constants.py +++ b/openpype/tools/settings/settings/constants.py @@ -24,7 +24,6 @@ __all__ = ( "SETTINGS_PATH_KEY", "ROOT_KEY", - "SETTINGS_PATH_KEY", "VALUE_KEY", "SAVE_TIME_KEY", "PROJECT_NAME_KEY", diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 722717df89..b8ad21e7e4 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -646,6 +646,9 @@ class UnsavedChangesDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(UnsavedChangesDialog, self).__init__(parent) + + self.setWindowTitle("Unsaved changes") + message_label = QtWidgets.QLabel(self.message) btns_widget = QtWidgets.QWidget(self) @@ -1009,6 +1012,7 @@ class ProjectListWidget(QtWidgets.QWidget): self._entity = None self.current_project = None + self._edit_mode = True super(ProjectListWidget, self).__init__(parent) self.setObjectName("ProjectListWidget") @@ -1061,6 +1065,10 @@ class ProjectListWidget(QtWidgets.QWidget): self.project_model = project_model self.inactive_chk = inactive_chk + def set_edit_mode(self, enabled): + if self._edit_mode is not enabled: + self._edit_mode = enabled + def set_entity(self, entity): self._entity = entity @@ -1112,7 +1120,7 @@ class ProjectListWidget(QtWidgets.QWidget): save_changes = False change_project = False - if self.validate_context_change(): + if not self._edit_mode or self.validate_context_change(): change_project = True else: diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 3842a4e216..d4189af4d8 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -401,7 +401,7 @@ class TrayManager: def initialize_modules(self): """Add modules to tray.""" - from openpype_interfaces import ( + from openpype.modules import ( ITrayAction, ITrayService ) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 019ea16391..31c8232f47 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -7,6 +7,7 @@ from .widgets import ( ExpandBtn, PixmapLabel, IconButton, + PixmapButton, SeparatorWidget, ) from .views import DeselectableTreeView @@ -38,6 +39,7 @@ __all__ = ( "ExpandBtn", "PixmapLabel", "IconButton", + "PixmapButton", "SeparatorWidget", "DeselectableTreeView", diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index d8dd80046a..5302946c28 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -79,6 +79,11 @@ def paint_image_with_color(image, color): pixmap.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(pixmap) + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + | painter.HighQualityAntialiasing + ) painter.setClipRegion(alpha_region) painter.setPen(QtCore.Qt.NoPen) painter.setBrush(color) diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index ca65182124..88893a57d5 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -3,10 +3,12 @@ import logging from Qt import QtWidgets, QtCore, QtGui import qargparse import qtawesome + from openpype.style import ( get_objected_colors, get_style_image_path ) +from openpype.lib.attribute_definitions import AbtractAttrDef log = logging.getLogger(__name__) @@ -252,6 +254,90 @@ class PixmapLabel(QtWidgets.QLabel): super(PixmapLabel, self).resizeEvent(event) +class PixmapButtonPainter(QtWidgets.QWidget): + def __init__(self, pixmap, parent): + super(PixmapButtonPainter, self).__init__(parent) + + self._pixmap = pixmap + self._cached_pixmap = None + + def set_pixmap(self, pixmap): + self._pixmap = pixmap + self._cached_pixmap = None + + self.repaint() + + def _cache_pixmap(self): + size = self.size() + self._cached_pixmap = self._pixmap.scaled( + size.width(), + size.height(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + + def paintEvent(self, event): + painter = QtGui.QPainter() + painter.begin(self) + if self._pixmap is None: + painter.end() + return + + painter.setRenderHints( + painter.Antialiasing + | painter.SmoothPixmapTransform + | painter.HighQualityAntialiasing + ) + if self._cached_pixmap is None: + self._cache_pixmap() + + painter.drawPixmap(0, 0, self._cached_pixmap) + + painter.end() + + +class PixmapButton(ClickableFrame): + def __init__(self, pixmap=None, parent=None): + super(PixmapButton, self).__init__(parent) + + button_painter = PixmapButtonPainter(pixmap, self) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + + self._button_painter = button_painter + + def setContentsMargins(self, *args): + layout = self.layout() + layout.setContentsMargins(*args) + self._update_painter_geo() + + def set_pixmap(self, pixmap): + self._button_painter.set_pixmap(pixmap) + + def sizeHint(self): + font_height = self.fontMetrics().height() + return QtCore.QSize(font_height, font_height) + + def resizeEvent(self, event): + super(PixmapButton, self).resizeEvent(event) + self._update_painter_geo() + + def showEvent(self, event): + super(PixmapButton, self).showEvent(event) + self._update_painter_geo() + + def _update_painter_geo(self): + size = self.size() + layout = self.layout() + left, top, right, bottom = layout.getContentsMargins() + self._button_painter.setGeometry( + left, + top, + size.width() - (left + right), + size.height() - (top + bottom) + ) + + class OptionalMenu(QtWidgets.QMenu): """A subclass of `QtWidgets.QMenu` to work with `OptionalAction` @@ -317,8 +403,26 @@ class OptionalAction(QtWidgets.QWidgetAction): def set_option_tip(self, options): sep = "\n\n" - mak = (lambda opt: opt["name"] + " :\n " + opt["help"]) - self.option_tip = sep.join(mak(opt) for opt in options) + if not options or not isinstance(options[0], AbtractAttrDef): + mak = (lambda opt: opt["name"] + " :\n " + opt["help"]) + self.option_tip = sep.join(mak(opt) for opt in options) + return + + option_items = [] + for option in options: + option_lines = [] + if option.label: + option_lines.append( + "{} ({}) :".format(option.label, option.key) + ) + else: + option_lines.append("{} :".format(option.key)) + + if option.tooltip: + option_lines.append(" - {}".format(option.tooltip)) + option_items.append("\n".join(option_lines)) + + self.option_tip = sep.join(option_items) def on_option(self): self.optioned = True @@ -474,8 +578,10 @@ class SeparatorWidget(QtWidgets.QFrame): self.set_size(size) def set_size(self, size): - if size == self._size: - return + if size != self._size: + self._set_size(size) + + def _set_size(self, size): if self._orientation == QtCore.Qt.Vertical: self.setMinimumWidth(size) self.setMaximumWidth(size) @@ -499,6 +605,4 @@ class SeparatorWidget(QtWidgets.QFrame): self._orientation = orientation - size = self._size - self._size = None - self.set_size(size) + self._set_size(self._size) diff --git a/openpype/tools/workfile_template_build/window.py b/openpype/tools/workfile_template_build/window.py index ea4e2fec5a..22e26be451 100644 --- a/openpype/tools/workfile_template_build/window.py +++ b/openpype/tools/workfile_template_build/window.py @@ -3,7 +3,7 @@ from Qt import QtWidgets from openpype import style from openpype.lib import Logger from openpype.pipeline import legacy_io -from openpype.widgets.attribute_defs import AttributeDefinitionsWidget +from openpype.tools.attribute_defs import AttributeDefinitionsWidget class WorkfileBuildPlaceholderDialog(QtWidgets.QDialog): diff --git a/openpype/version.py b/openpype/version.py index 442c5f033b..268f33083a 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.14.7-nightly.1" +__version__ = "3.14.7-nightly.5" diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 24ea09b6fb..9666c6568a 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -135,6 +135,12 @@ Profile may generate multiple outputs from a single input. Each output must defi - set alpha to `0` to not use this option at all (in most of cases background stays black) - other than `0` alpha will draw color as background +- **`Additional filtering`** + - Profile filtering defines which group of output definitions is used but output definitions may require more specific filters on their own. + - They may filter by subset name (regex can be used) or publish families. Publish families are more complex as are based on knowing code base. + - Filtering by custom tags -> this is used for targeting to output definitions from other extractors using settings (at this moment only Nuke bake extractor can target using custom tags). + - Nuke extractor settings path: `project_settings/nuke/publish/ExtractReviewDataMov/outputs/baking/add_custom_tags` + - Filtering by input length. Input may be video, sequence or single image. It is possible that `.mp4` should be created only when input is video or sequence and to create review `.png` when input is single frame. In some cases the output should be created even if it's single frame or multi frame input. ### IntegrateAssetNew diff --git a/website/yarn.lock b/website/yarn.lock index 04b9dd658b..220a489dfa 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -1543,15 +1543,37 @@ dependencies: "@hapi/hoek" "^9.0.0" +"@jridgewell/gen-mapping@^0.3.0": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" + integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== + dependencies: + "@jridgewell/set-array" "^1.0.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.9" + "@jridgewell/resolve-uri@^3.0.3": - version "3.0.5" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz#68eb521368db76d040a6315cdb24bf2483037b9c" - integrity sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew== + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/set-array@^1.0.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" + integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== + +"@jridgewell/source-map@^0.3.2": + version "0.3.2" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" + integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" "@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.11" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz#771a1d8d744eeb71b6adb35808e1a6c7b9b8c8ec" - integrity sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg== + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== "@jridgewell/trace-mapping@^0.3.0": version "0.3.4" @@ -1561,6 +1583,14 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.9": + version "0.3.14" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" + integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@mdx-js/mdx@1.6.22", "@mdx-js/mdx@^1.6.21": version "1.6.22" resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba" @@ -2140,10 +2170,10 @@ acorn@^6.1.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== -acorn@^8.0.4, acorn@^8.4.1: - version "8.7.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf" - integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ== +acorn@^8.0.4, acorn@^8.4.1, acorn@^8.5.0: + version "8.7.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" + integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== address@^1.0.1, address@^1.1.2: version "1.1.2" @@ -4782,9 +4812,9 @@ loader-runner@^4.2.0: integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw== loader-utils@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" - integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + version "1.4.2" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.2.tgz#29a957f3a63973883eb684f10ffd3d151fec01a3" + integrity sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" @@ -5124,7 +5154,12 @@ minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.2.0: + version "1.2.7" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" + integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g== + +minimist@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== @@ -6838,11 +6873,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@~0.7.2: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - sourcemap-codec@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" @@ -7048,12 +7078,13 @@ terser-webpack-plugin@^5.1.3, terser-webpack-plugin@^5.2.4: terser "^5.7.2" terser@^5.10.0, terser@^5.7.2: - version "5.10.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.10.0.tgz#b86390809c0389105eb0a0b62397563096ddafcc" - integrity sha512-AMmF99DMfEDiRJfxfY5jj5wNH/bYO09cniSqhfoyxc8sFoYIgkJy86G04UoZU5VjlpnplVu0K6Tx6E9b5+DlHA== + version "5.14.2" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10" + integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA== dependencies: + "@jridgewell/source-map" "^0.3.2" + acorn "^8.5.0" commander "^2.20.0" - source-map "~0.7.2" source-map-support "~0.5.20" text-table@^0.2.0: