diff --git a/pype/plugins/standalonepublish/publish/collect_context.py b/pype/plugins/standalonepublish/publish/collect_context.py new file mode 100644 index 0000000000..cbe9df1ef6 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_context.py @@ -0,0 +1,88 @@ +import os +import pyblish.api +from avalon import ( + io, + api as avalon +) +import json +import logging +import clique + + +log = logging.getLogger("collector") + + +class CollectContextDataSAPublish(pyblish.api.ContextPlugin): + """ + Collecting temp json data sent from a host context + and path for returning json data back to hostself. + + Setting avalon session into correct context + + Args: + context (obj): pyblish context session + + """ + + label = "Collect Context - SA Publish" + order = pyblish.api.CollectorOrder - 0.49 + + def process(self, context): + # get json paths from os and load them + io.install() + input_json_path = os.environ.get("SAPUBLISH_INPATH") + output_json_path = os.environ.get("SAPUBLISH_OUTPATH") + + context.data["stagingDir"] = os.path.dirname(input_json_path) + context.data["returnJsonPath"] = output_json_path + + with open(input_json_path, "r") as f: + in_data = json.load(f) + + project_name = in_data['project'] + asset_name = in_data['asset'] + family = in_data['family'] + subset = in_data['subset'] + + project = io.find_one({'type': 'project'}) + asset = io.find_one({ + 'type': 'asset', + 'name': asset_name + }) + context.data['project'] = project + context.data['asset'] = asset + + instance = context.create_instance(subset) + + instance.data.update({ + "subset": family + subset, + "asset": asset_name, + "label": family + subset, + "name": family + subset, + "family": family, + "families": [family, 'ftrack'], + }) + self.log.info("collected instance: {}".format(instance.data)) + + instance.data["files"] = list() + instance.data['destination_list'] = list() + instance.data['representations'] = list() + + for component in in_data['representations']: + # instance.add(node) + component['destination'] = component['files'] + collections, remainder = clique.assemble(component['files']) + if collections: + self.log.debug(collections) + range = collections[0].format('{range}') + instance.data['startFrame'] = range.split('-')[0] + instance.data['endFrame'] = range.split('-')[1] + + + instance.data["files"].append(component) + instance.data["representations"].append(component) + + # "is_thumbnail": component['thumbnail'], + # "is_preview": component['preview'] + + self.log.info(in_data) diff --git a/pype/plugins/standalonepublish/publish/collect_ftrack_api.py b/pype/plugins/standalonepublish/publish/collect_ftrack_api.py new file mode 100644 index 0000000000..6df998350c --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_ftrack_api.py @@ -0,0 +1,40 @@ +import os +import pyblish.api + +try: + import ftrack_api_old as ftrack_api +except Exception: + import ftrack_api + + +class CollectFtrackApi(pyblish.api.ContextPlugin): + """ Collects an ftrack session and the current task id. """ + + order = pyblish.api.CollectorOrder + label = "Collect Ftrack Api" + + def process(self, context): + + # Collect session + session = ftrack_api.Session() + context.data["ftrackSession"] = session + + # Collect task + + project = os.environ.get('AVALON_PROJECT', '') + asset = os.environ.get('AVALON_ASSET', '') + task = os.environ.get('AVALON_TASK', None) + + if task: + result = session.query('Task where\ + project.full_name is "{0}" and\ + name is "{1}" and\ + parent.name is "{2}"'.format(project, task, asset)).one() + context.data["ftrackTask"] = result + else: + result = session.query('TypedContext where\ + project.full_name is "{0}" and\ + name is "{1}"'.format(project, asset)).one() + context.data["ftrackEntity"] = result + + self.log.info(result) diff --git a/pype/plugins/standalonepublish/publish/collect_templates.py b/pype/plugins/standalonepublish/publish/collect_templates.py new file mode 100644 index 0000000000..b59b20892b --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_templates.py @@ -0,0 +1,17 @@ + +import pype.api as pype +from pypeapp import Anatomy + +import pyblish.api + + +class CollectTemplates(pyblish.api.ContextPlugin): + """Inject the current working file into context""" + + order = pyblish.api.CollectorOrder + label = "Collect Templates" + + def process(self, context): + # pype.load_data_from_templates() + context.data['anatomy'] = Anatomy() + self.log.info("Anatomy templates collected...") diff --git a/pype/plugins/standalonepublish/publish/collect_time.py b/pype/plugins/standalonepublish/publish/collect_time.py new file mode 100644 index 0000000000..e0adc7dfc3 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/collect_time.py @@ -0,0 +1,12 @@ +import pyblish.api +from avalon import api + + +class CollectTime(pyblish.api.ContextPlugin): + """Store global time at the time of publish""" + + label = "Collect Current Time" + order = pyblish.api.CollectorOrder + + def process(self, context): + context.data["time"] = api.time() diff --git a/pype/plugins/standalonepublish/publish/integrate.py b/pype/plugins/standalonepublish/publish/integrate.py new file mode 100644 index 0000000000..b6771a52e0 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate.py @@ -0,0 +1,448 @@ +import os +import logging +import shutil + +import errno +import pyblish.api +from avalon import api, io +from avalon.vendor import filelink +import clique + + +log = logging.getLogger(__name__) + + +class IntegrateAsset(pyblish.api.InstancePlugin): + """Resolve any dependency issies + + This plug-in resolves any paths which, if not updated might break + the published file. + + The order of families is important, when working with lookdev you want to + first publish the texture, update the texture paths in the nodes and then + publish the shading network. Same goes for file dependent assets. + """ + + label = "Integrate Asset" + order = pyblish.api.IntegratorOrder + families = ["animation", + "camera", + "look", + "mayaAscii", + "model", + "pointcache", + "vdbcache", + "setdress", + "assembly", + "layout", + "rig", + "vrayproxy", + "yetiRig", + "yeticache", + "nukescript", + # "review", + "workfile", + "scene", + "ass"] + exclude_families = ["clip"] + + def process(self, instance): + + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + + self.register(instance) + + self.log.info("Integrating Asset in to the database ...") + self.integrate(instance) + + def register(self, instance): + # Required environment variables + PROJECT = api.Session["AVALON_PROJECT"] + ASSET = instance.data.get("asset") or api.Session["AVALON_ASSET"] + LOCATION = api.Session["AVALON_LOCATION"] + + context = instance.context + # Atomicity + # + # Guarantee atomic publishes - each asset contains + # an identical set of members. + # __ + # / o + # / \ + # | o | + # \ / + # o __/ + # + assert all(result["success"] for result in context.data["results"]), ( + "Atomicity not held, aborting.") + + # Assemble + # + # | + # v + # ---> <---- + # ^ + # | + # + # stagingdir = instance.data.get("stagingDir") + # assert stagingdir, ("Incomplete instance \"%s\": " + # "Missing reference to staging area." % instance) + + # extra check if stagingDir actually exists and is available + + # self.log.debug("Establishing staging directory @ %s" % stagingdir) + + # Ensure at least one file is set up for transfer in staging dir. + files = instance.data.get("files", []) + assert files, "Instance has no files to transfer" + assert isinstance(files, (list, tuple)), ( + "Instance 'files' must be a list, got: {0}".format(files) + ) + + project = io.find_one({"type": "project"}) + + asset = io.find_one({"type": "asset", + "name": ASSET, + "parent": project["_id"]}) + + assert all([project, asset]), ("Could not find current project or " + "asset '%s'" % ASSET) + + subset = self.get_subset(asset, instance) + + # get next version + latest_version = io.find_one({"type": "version", + "parent": subset["_id"]}, + {"name": True}, + sort=[("name", -1)]) + + next_version = 1 + if latest_version is not None: + next_version += latest_version["name"] + + self.log.info("Verifying version from assumed destination") + + # assumed_data = instance.data["assumedTemplateData"] + # assumed_version = assumed_data["version"] + # if assumed_version != next_version: + # raise AttributeError("Assumed version 'v{0:03d}' does not match" + # "next version in database " + # "('v{1:03d}')".format(assumed_version, + # next_version)) + + self.log.debug("Next version: v{0:03d}".format(next_version)) + + version_data = self.create_version_data(context, instance) + version = self.create_version(subset=subset, + version_number=next_version, + locations=[LOCATION], + data=version_data) + + self.log.debug("Creating version ...") + version_id = io.insert_one(version).inserted_id + instance.data['version'] = version['name'] + # Write to disk + # _ + # | | + # _| |_ + # ____\ / + # |\ \ / \ + # \ \ v \ + # \ \________. + # \|________| + # + root = api.registered_root() + hierarchy = "" + parents = io.find_one({ + "type": 'asset', + "name": ASSET + })['data']['parents'] + if parents and len(parents) > 0: + # hierarchy = os.path.sep.join(hierarchy) + hierarchy = os.path.join(*parents) + + template_data = {"root": root, + "project": {"name": PROJECT, + "code": project['data']['code']}, + "silo": asset['silo'], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": int(version["name"]), + "hierarchy": hierarchy} + + template_publish = project["config"]["template"]["publish"] + anatomy = instance.context.data['anatomy'] + + # Find the representations to transfer amongst the files + # Each should be a single representation (as such, a single extension) + representations = [] + destination_list = [] + if 'transfers' not in instance.data: + instance.data['transfers'] = [] + + for idx, repre in enumerate(instance.data["representations"]): + + # Collection + # _______ + # |______|\ + # | |\| + # | || + # | || + # | || + # |_______| + # + + files = repre['files'] + + if len(files) > 1: + src_collections, remainder = clique.assemble(files) + self.log.debug("dst_collections: {}".format(str(src_collections))) + src_collection = src_collections[0] + # Assert that each member has identical suffix + src_head = src_collection.format("{head}") + src_tail = ext = src_collection.format("{tail}") + + test_dest_files = list() + for i in [1, 2]: + template_data["representation"] = src_tail[1:] + template_data["frame"] = src_collection.format( + "{padding}") % i + anatomy_filled = anatomy.format(template_data) + test_dest_files.append(anatomy_filled["publish"]["path"]) + + dst_collections, remainder = clique.assemble(test_dest_files) + dst_collection = dst_collections[0] + dst_head = dst_collection.format("{head}") + dst_tail = dst_collection.format("{tail}") + + instance.data["representations"][idx]['published_path'] = dst_collection.format() + + for i in src_collection.indexes: + src_padding = src_collection.format("{padding}") % i + src_file_name = "{0}{1}{2}".format( + src_head, src_padding, src_tail) + dst_padding = dst_collection.format("{padding}") % i + dst = "{0}{1}{2}".format(dst_head, dst_padding, dst_tail) + + # src = os.path.join(stagingdir, src_file_name) + src = src_file_name + self.log.debug("source: {}".format(src)) + + instance.data["transfers"].append([src, dst]) + + else: + # Single file + # _______ + # | |\ + # | | + # | | + # | | + # |_______| + # + fname = files[0] + # assert not os.path.isabs(fname), ( + # "Given file name is a full path" + # ) + # _, ext = os.path.splitext(fname) + + template_data["representation"] = repre['representation'] + + # src = os.path.join(stagingdir, fname) + src = fname + anatomy_filled = anatomy.format(template_data) + dst = anatomy_filled["publish"]["path"] + + instance.data["transfers"].append([src, dst]) + template = anatomy.templates["publish"]["path"] + instance.data["representations"][idx]['published_path'] = dst + + representation = { + "schema": "pype:representation-2.0", + "type": "representation", + "parent": version_id, + "name": repre['representation'], + "data": {'path': dst, 'template': template}, + "dependencies": instance.data.get("dependencies", "").split(), + + # Imprint shortcut to context + # for performance reasons. + "context": { + "root": root, + "project": {"name": PROJECT, + "code": project['data']['code']}, + # 'task': api.Session["AVALON_TASK"], + "silo": asset['silo'], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": version["name"], + "hierarchy": hierarchy, + # "representation": repre['representation'] + } + } + + destination_list.append(dst) + instance.data['destination_list'] = destination_list + representations.append(representation) + + self.log.info("Registering {} items".format(len(representations))) + + io.insert_many(representations) + + def integrate(self, instance): + """Move the files + + Through `instance.data["transfers"]` + + Args: + instance: the instance to integrate + """ + + transfers = instance.data.get("transfers", list()) + + for src, dest in transfers: + self.log.info("Copying file .. {} -> {}".format(src, dest)) + self.copy_file(src, dest) + + # Produce hardlinked copies + # Note: hardlink can only be produced between two files on the same + # server/disk and editing one of the two will edit both files at once. + # As such it is recommended to only make hardlinks between static files + # to ensure publishes remain safe and non-edited. + hardlinks = instance.data.get("hardlinks", list()) + for src, dest in hardlinks: + self.log.info("Hardlinking file .. {} -> {}".format(src, dest)) + self.hardlink_file(src, dest) + + def copy_file(self, src, dst): + """ Copy given source to destination + + Arguments: + src (str): the source file which needs to be copied + dst (str): the destination of the sourc file + Returns: + None + """ + + dirname = os.path.dirname(dst) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + raise + + shutil.copy(src, dst) + + def hardlink_file(self, src, dst): + + dirname = os.path.dirname(dst) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + raise + + filelink.create(src, dst, filelink.HARDLINK) + + def get_subset(self, asset, instance): + + subset = io.find_one({"type": "subset", + "parent": asset["_id"], + "name": instance.data["subset"]}) + + if subset is None: + subset_name = instance.data["subset"] + self.log.info("Subset '%s' not found, creating.." % subset_name) + + _id = io.insert_one({ + "schema": "avalon-core:subset-2.0", + "type": "subset", + "name": subset_name, + "data": {}, + "parent": asset["_id"] + }).inserted_id + + subset = io.find_one({"_id": _id}) + + return subset + + def create_version(self, subset, version_number, locations, data=None): + """ Copy given source to destination + + Args: + subset (dict): the registered subset of the asset + version_number (int): the version number + locations (list): the currently registered locations + + Returns: + dict: collection of data to create a version + """ + # Imprint currently registered location + version_locations = [location for location in locations if + location is not None] + + return {"schema": "avalon-core:version-2.0", + "type": "version", + "parent": subset["_id"], + "name": version_number, + "locations": version_locations, + "data": data} + + def create_version_data(self, context, instance): + """Create the data collection for the version + + Args: + context: the current context + instance: the current instance being published + + Returns: + dict: the required information with instance.data as key + """ + + families = [] + current_families = instance.data.get("families", list()) + instance_family = instance.data.get("family", None) + + if instance_family is not None: + families.append(instance_family) + families += current_families + + self.log.debug("Registered root: {}".format(api.registered_root())) + # # create relative source path for DB + # try: + # source = instance.data['source'] + # except KeyError: + # source = context.data["currentFile"] + # + # relative_path = os.path.relpath(source, api.registered_root()) + # source = os.path.join("{root}", relative_path).replace("\\", "/") + + source = "standalone" + + # self.log.debug("Source: {}".format(source)) + version_data = {"families": families, + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": context.data.get("comment"), + "machine": context.data.get("machine"), + "fps": context.data.get("fps")} + + # Include optional data if present in + optionals = [ + "startFrame", "endFrame", "step", "handles", "sourceHashes" + ] + for key in optionals: + if key in instance.data: + version_data[key] = instance.data[key] + + return version_data diff --git a/pype/plugins/standalonepublish/publish/integrate_ftrack_api.py b/pype/plugins/standalonepublish/publish/integrate_ftrack_api.py new file mode 100644 index 0000000000..9eff10ba67 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate_ftrack_api.py @@ -0,0 +1,315 @@ +import os +import sys +import pyblish.api +import clique + + +class IntegrateFtrackApi(pyblish.api.InstancePlugin): + """ Commit components to server. """ + + order = pyblish.api.IntegratorOrder+0.499 + label = "Integrate Ftrack Api" + families = ["ftrack"] + + def query(self, entitytype, data): + """ Generate a query expression from data supplied. + + If a value is not a string, we'll add the id of the entity to the + query. + + Args: + entitytype (str): The type of entity to query. + data (dict): The data to identify the entity. + exclusions (list): All keys to exclude from the query. + + Returns: + str: String query to use with "session.query" + """ + queries = [] + if sys.version_info[0] < 3: + for key, value in data.iteritems(): + if not isinstance(value, (basestring, int)): + self.log.info("value: {}".format(value)) + if "id" in value.keys(): + queries.append( + "{0}.id is \"{1}\"".format(key, value["id"]) + ) + else: + queries.append("{0} is \"{1}\"".format(key, value)) + else: + for key, value in data.items(): + if not isinstance(value, (str, int)): + self.log.info("value: {}".format(value)) + if "id" in value.keys(): + queries.append( + "{0}.id is \"{1}\"".format(key, value["id"]) + ) + else: + queries.append("{0} is \"{1}\"".format(key, value)) + + query = ( + "select id from " + entitytype + " where " + " and ".join(queries) + ) + self.log.debug(query) + return query + + def process(self, instance): + + session = instance.context.data["ftrackSession"] + if instance.context.data.get("ftrackTask"): + task = instance.context.data["ftrackTask"] + name = task['full_name'] + parent = task["parent"] + elif instance.context.data.get("ftrackEntity"): + task = None + name = instance.context.data.get("ftrackEntity")['name'] + parent = instance.context.data.get("ftrackEntity") + + info_msg = "Created new {entity_type} with data: {data}" + info_msg += ", metadata: {metadata}." + + # Iterate over components and publish + for data in instance.data.get("ftrackComponentsList", []): + + # AssetType + # Get existing entity. + assettype_data = {"short": "upload"} + assettype_data.update(data.get("assettype_data", {})) + self.log.debug("data: {}".format(data)) + + assettype_entity = session.query( + self.query("AssetType", assettype_data) + ).first() + + # Create a new entity if none exits. + if not assettype_entity: + assettype_entity = session.create("AssetType", assettype_data) + self.log.debug( + "Created new AssetType with data: ".format(assettype_data) + ) + + # Asset + # Get existing entity. + asset_data = { + "name": name, + "type": assettype_entity, + "parent": parent, + } + asset_data.update(data.get("asset_data", {})) + + asset_entity = session.query( + self.query("Asset", asset_data) + ).first() + + self.log.info("asset entity: {}".format(asset_entity)) + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + asset_metadata = asset_data.pop("metadata", {}) + + # Create a new entity if none exits. + if not asset_entity: + asset_entity = session.create("Asset", asset_data) + self.log.debug( + info_msg.format( + entity_type="Asset", + data=asset_data, + metadata=asset_metadata + ) + ) + + # Adding metadata + existing_asset_metadata = asset_entity["metadata"] + existing_asset_metadata.update(asset_metadata) + asset_entity["metadata"] = existing_asset_metadata + + # AssetVersion + # Get existing entity. + assetversion_data = { + "version": 0, + "asset": asset_entity, + } + if task: + assetversion_data['task'] = task + + assetversion_data.update(data.get("assetversion_data", {})) + + assetversion_entity = session.query( + self.query("AssetVersion", assetversion_data) + ).first() + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + assetversion_metadata = assetversion_data.pop("metadata", {}) + + # Create a new entity if none exits. + if not assetversion_entity: + assetversion_entity = session.create( + "AssetVersion", assetversion_data + ) + self.log.debug( + info_msg.format( + entity_type="AssetVersion", + data=assetversion_data, + metadata=assetversion_metadata + ) + ) + + # Adding metadata + existing_assetversion_metadata = assetversion_entity["metadata"] + existing_assetversion_metadata.update(assetversion_metadata) + assetversion_entity["metadata"] = existing_assetversion_metadata + + # Have to commit the version and asset, because location can't + # determine the final location without. + session.commit() + + # Component + # Get existing entity. + component_data = { + "name": "main", + "version": assetversion_entity + } + component_data.update(data.get("component_data", {})) + + component_entity = session.query( + self.query("Component", component_data) + ).first() + + component_overwrite = data.get("component_overwrite", False) + location = data.get("component_location", session.pick_location()) + + # Overwrite existing component data if requested. + if component_entity and component_overwrite: + + origin_location = session.query( + "Location where name is \"ftrack.origin\"" + ).one() + + # Removing existing members from location + components = list(component_entity.get("members", [])) + components += [component_entity] + for component in components: + for loc in component["component_locations"]: + if location["id"] == loc["location_id"]: + location.remove_component( + component, recursive=False + ) + + # Deleting existing members on component entity + for member in component_entity.get("members", []): + session.delete(member) + del(member) + + session.commit() + + # Reset members in memory + if "members" in component_entity.keys(): + component_entity["members"] = [] + + # Add components to origin location + try: + collection = clique.parse(data["component_path"]) + except ValueError: + # Assume its a single file + # Changing file type + name, ext = os.path.splitext(data["component_path"]) + component_entity["file_type"] = ext + + origin_location.add_component( + component_entity, data["component_path"] + ) + else: + # Changing file type + component_entity["file_type"] = collection.format("{tail}") + + # Create member components for sequence. + for member_path in collection: + + size = 0 + try: + size = os.path.getsize(member_path) + except OSError: + pass + + name = collection.match(member_path).group("index") + + member_data = { + "name": name, + "container": component_entity, + "size": size, + "file_type": os.path.splitext(member_path)[-1] + } + + component = session.create( + "FileComponent", member_data + ) + origin_location.add_component( + component, member_path, recursive=False + ) + component_entity["members"].append(component) + + # Add components to location. + location.add_component( + component_entity, origin_location, recursive=True + ) + + data["component"] = component_entity + msg = "Overwriting Component with path: {0}, data: {1}, " + msg += "location: {2}" + self.log.info( + msg.format( + data["component_path"], + component_data, + location + ) + ) + + # Extracting metadata, and adding after entity creation. This is + # due to a ftrack_api bug where you can't add metadata on creation. + component_metadata = component_data.pop("metadata", {}) + + # Create new component if none exists. + new_component = False + if not component_entity: + component_entity = assetversion_entity.create_component( + data["component_path"], + data=component_data, + location=location + ) + data["component"] = component_entity + msg = "Created new Component with path: {0}, data: {1}" + msg += ", metadata: {2}, location: {3}" + self.log.info( + msg.format( + data["component_path"], + component_data, + component_metadata, + location + ) + ) + new_component = True + + # Adding metadata + existing_component_metadata = component_entity["metadata"] + existing_component_metadata.update(component_metadata) + component_entity["metadata"] = existing_component_metadata + + # if component_data['name'] = 'ftrackreview-mp4-mp4': + # assetversion_entity["thumbnail_id"] + + # Setting assetversion thumbnail + if data.get("thumbnail", False): + assetversion_entity["thumbnail_id"] = component_entity["id"] + + # Inform user about no changes to the database. + if (component_entity and not component_overwrite and + not new_component): + data["component"] = component_entity + self.log.info( + "Found existing component, and no request to overwrite. " + "Nothing has been changed." + ) + else: + # Commit changes. + session.commit() diff --git a/pype/plugins/standalonepublish/publish/integrate_ftrack_instances.py b/pype/plugins/standalonepublish/publish/integrate_ftrack_instances.py new file mode 100644 index 0000000000..8d938bceb0 --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate_ftrack_instances.py @@ -0,0 +1,101 @@ +import pyblish.api +import os +import json + + +class IntegrateFtrackInstance(pyblish.api.InstancePlugin): + """Collect ftrack component data + + Add ftrack component list to instance. + + + """ + + order = pyblish.api.IntegratorOrder + 0.48 + label = 'Integrate Ftrack Component' + families = ["ftrack"] + + family_mapping = {'camera': 'cam', + 'look': 'look', + 'mayaAscii': 'scene', + 'model': 'geo', + 'rig': 'rig', + 'setdress': 'setdress', + 'pointcache': 'cache', + 'write': 'img', + 'render': 'render', + 'nukescript': 'comp', + 'review': 'mov'} + + def process(self, instance): + self.log.debug('instance {}'.format(instance)) + + if instance.data.get('version'): + version_number = int(instance.data.get('version')) + + family = instance.data['family'].lower() + + asset_type = '' + asset_type = self.family_mapping[family] + + componentList = [] + ft_session = instance.context.data["ftrackSession"] + + components = instance.data['representations'] + + for comp in components: + self.log.debug('component {}'.format(comp)) + # filename, ext = os.path.splitext(file) + # self.log.debug('dest ext: ' + ext) + + # ext = comp['Context'] + + if comp['thumbnail']: + location = ft_session.query( + 'Location where name is "ftrack.server"').one() + component_data = { + "name": "thumbnail" # Default component name is "main". + } + elif comp['preview']: + if not instance.data.get('startFrameReview'): + instance.data['startFrameReview'] = instance.data['startFrame'] + if not instance.data.get('endFrameReview'): + instance.data['endFrameReview'] = instance.data['endFrame'] + location = ft_session.query( + 'Location where name is "ftrack.server"').one() + component_data = { + # Default component name is "main". + "name": "ftrackreview-mp4", + "metadata": {'ftr_meta': json.dumps({ + 'frameIn': int(instance.data['startFrameReview']), + 'frameOut': int(instance.data['endFrameReview']), + 'frameRate': 25.0})} + } + else: + component_data = { + "name": comp['representation'] # Default component name is "main". + } + location = ft_session.query( + 'Location where name is "ftrack.unmanaged"').one() + + self.log.debug('location {}'.format(location)) + + componentList.append({"assettype_data": { + "short": asset_type, + }, + "asset_data": { + "name": instance.data["subset"], + }, + "assetversion_data": { + "version": version_number, + }, + "component_data": component_data, + "component_path": comp['published_path'], + 'component_location': location, + "component_overwrite": False, + "thumbnail": comp['thumbnail'] + } + ) + + self.log.debug('componentsList: {}'.format(str(componentList))) + instance.data["ftrackComponentsList"] = componentList diff --git a/pype/plugins/standalonepublish/publish/integrate_rendered_frames.py b/pype/plugins/standalonepublish/publish/integrate_rendered_frames.py new file mode 100644 index 0000000000..43653ab0ed --- /dev/null +++ b/pype/plugins/standalonepublish/publish/integrate_rendered_frames.py @@ -0,0 +1,436 @@ +import os +import logging +import shutil +import clique + +import errno +import pyblish.api +from avalon import api, io + + +log = logging.getLogger(__name__) + + +class IntegrateFrames(pyblish.api.InstancePlugin): + """Resolve any dependency issies + + This plug-in resolves any paths which, if not updated might break + the published file. + + The order of families is important, when working with lookdev you want to + first publish the texture, update the texture paths in the nodes and then + publish the shading network. Same goes for file dependent assets. + """ + + label = "Integrate Frames" + order = pyblish.api.IntegratorOrder + families = [ + "imagesequence", + "render", + "write", + "source", + 'review'] + + family_targets = [".frames", ".local", ".review", "review", "imagesequence", "render", "source"] + exclude_families = ["clip"] + + def process(self, instance): + if [ef for ef in self.exclude_families + if instance.data["family"] in ef]: + return + + families = [f for f in instance.data["families"] + for search in self.family_targets + if search in f] + + if not families: + return + + self.register(instance) + + # self.log.info("Integrating Asset in to the database ...") + # self.log.info("instance.data: {}".format(instance.data)) + if instance.data.get('transfer', True): + self.integrate(instance) + + def register(self, instance): + + # Required environment variables + PROJECT = api.Session["AVALON_PROJECT"] + ASSET = instance.data.get("asset") or api.Session["AVALON_ASSET"] + LOCATION = api.Session["AVALON_LOCATION"] + + context = instance.context + # Atomicity + # + # Guarantee atomic publishes - each asset contains + # an identical set of members. + # __ + # / o + # / \ + # | o | + # \ / + # o __/ + # + assert all(result["success"] for result in context.data["results"]), ( + "Atomicity not held, aborting.") + + # Assemble + # + # | + # v + # ---> <---- + # ^ + # | + # + # stagingdir = instance.data.get("stagingDir") + # assert stagingdir, ("Incomplete instance \"%s\": " + # "Missing reference to staging area." % instance) + + # extra check if stagingDir actually exists and is available + + # self.log.debug("Establishing staging directory @ %s" % stagingdir) + + project = io.find_one({"type": "project"}) + + asset = io.find_one({"type": "asset", + "name": ASSET, + "parent": project["_id"]}) + + assert all([project, asset]), ("Could not find current project or " + "asset '%s'" % ASSET) + + subset = self.get_subset(asset, instance) + + # get next version + latest_version = io.find_one({"type": "version", + "parent": subset["_id"]}, + {"name": True}, + sort=[("name", -1)]) + + next_version = 1 + if latest_version is not None: + next_version += latest_version["name"] + + self.log.info("Verifying version from assumed destination") + + # assumed_data = instance.data["assumedTemplateData"] + # assumed_version = assumed_data["version"] + # if assumed_version != next_version: + # raise AttributeError("Assumed version 'v{0:03d}' does not match" + # "next version in database " + # "('v{1:03d}')".format(assumed_version, + # next_version)) + + if instance.data.get('version'): + next_version = int(instance.data.get('version')) + + instance.data['version'] = next_version + + self.log.debug("Next version: v{0:03d}".format(next_version)) + + version_data = self.create_version_data(context, instance) + version = self.create_version(subset=subset, + version_number=next_version, + locations=[LOCATION], + data=version_data) + + self.log.debug("Creating version ...") + version_id = io.insert_one(version).inserted_id + + # Write to disk + # _ + # | | + # _| |_ + # ____\ / + # |\ \ / \ + # \ \ v \ + # \ \________. + # \|________| + # + root = api.registered_root() + hierarchy = "" + parents = io.find_one({"type": 'asset', "name": ASSET})[ + 'data']['parents'] + if parents and len(parents) > 0: + # hierarchy = os.path.sep.join(hierarchy) + hierarchy = os.path.join(*parents) + + template_data = {"root": root, + "project": {"name": PROJECT, + "code": project['data']['code']}, + "silo": asset['silo'], + "task": api.Session["AVALON_TASK"], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": int(version["name"]), + "hierarchy": hierarchy} + + # template_publish = project["config"]["template"]["publish"] + anatomy = instance.context.data['anatomy'] + + # Find the representations to transfer amongst the files + # Each should be a single representation (as such, a single extension) + representations = [] + destination_list = [] + + if 'transfers' not in instance.data: + instance.data['transfers'] = [] + + # for repre in instance.data["representations"]: + for idx, repre in enumerate(instance.data["representations"]): + # Collection + # _______ + # |______|\ + # | |\| + # | || + # | || + # | || + # |_______| + # + + files = repre['files'] + + if len(files) > 1: + + src_collections, remainder = clique.assemble(files) + self.log.debug("dst_collections: {}".format(str(src_collections))) + src_collection = src_collections[0] + # Assert that each member has identical suffix + src_head = src_collection.format("{head}") + src_tail = ext = src_collection.format("{tail}") + + test_dest_files = list() + for i in [1, 2]: + template_data["representation"] = repre['representation'] + template_data["frame"] = src_collection.format( + "{padding}") % i + anatomy_filled = anatomy.format(template_data) + test_dest_files.append(anatomy_filled["render"]["path"]) + + dst_collections, remainder = clique.assemble(test_dest_files) + dst_collection = dst_collections[0] + dst_head = dst_collection.format("{head}") + dst_tail = dst_collection.format("{tail}") + + instance.data["representations"][idx]['published_path'] = dst_collection.format() + + for i in src_collection.indexes: + src_padding = src_collection.format("{padding}") % i + src_file_name = "{0}{1}{2}".format( + src_head, src_padding, src_tail) + dst_padding = dst_collection.format("{padding}") % i + dst = "{0}{1}{2}".format(dst_head, dst_padding, dst_tail) + + # src = os.path.join(stagingdir, src_file_name) + src = src_file_name + self.log.debug("source: {}".format(src)) + + instance.data["transfers"].append([src, dst]) + + else: + # Single file + # _______ + # | |\ + # | | + # | | + # | | + # |_______| + # + + template_data.pop("frame", None) + + fname = files[0] + + self.log.info("fname: {}".format(fname)) + + # assert not os.path.isabs(fname), ( + # "Given file name is a full path" + # ) + # _, ext = os.path.splitext(fname) + + template_data["representation"] = repre['representation'] + + # src = os.path.join(stagingdir, fname) + src = src_file_name + + anatomy_filled = anatomy.format(template_data) + dst = anatomy_filled["render"]["path"] + + instance.data["transfers"].append([src, dst]) + instance.data["representations"][idx]['published_path'] = dst + + if repre['ext'] not in ["jpeg", "jpg", "mov", "mp4", "wav"]: + template_data["frame"] = "#" * int(anatomy_filled["render"]["padding"]) + + anatomy_filled = anatomy.format(template_data) + path_to_save = anatomy_filled["render"]["path"] + template = anatomy.templates["render"]["path"] + + self.log.debug("path_to_save: {}".format(path_to_save)) + + representation = { + "schema": "pype:representation-2.0", + "type": "representation", + "parent": version_id, + "name": repre['representation'], + "data": {'path': path_to_save, 'template': template}, + "dependencies": instance.data.get("dependencies", "").split(), + + # Imprint shortcut to context + # for performance reasons. + "context": { + "root": root, + "project": { + "name": PROJECT, + "code": project['data']['code'] + }, + "task": api.Session["AVALON_TASK"], + "silo": asset['silo'], + "asset": ASSET, + "family": instance.data['family'], + "subset": subset["name"], + "version": int(version["name"]), + "hierarchy": hierarchy, + "representation": repre['representation'] + } + } + + destination_list.append(dst) + instance.data['destination_list'] = destination_list + representations.append(representation) + + self.log.info("Registering {} items".format(len(representations))) + io.insert_many(representations) + + def integrate(self, instance): + """Move the files + + Through `instance.data["transfers"]` + + Args: + instance: the instance to integrate + """ + + transfers = instance.data["transfers"] + + for src, dest in transfers: + src = os.path.normpath(src) + dest = os.path.normpath(dest) + if src in dest: + continue + + self.log.info("Copying file .. {} -> {}".format(src, dest)) + self.copy_file(src, dest) + + def copy_file(self, src, dst): + """ Copy given source to destination + + Arguments: + src (str): the source file which needs to be copied + dst (str): the destination of the sourc file + Returns: + None + """ + + dirname = os.path.dirname(dst) + try: + os.makedirs(dirname) + except OSError as e: + if e.errno == errno.EEXIST: + pass + else: + self.log.critical("An unexpected error occurred.") + raise + + shutil.copy(src, dst) + + def get_subset(self, asset, instance): + + subset = io.find_one({"type": "subset", + "parent": asset["_id"], + "name": instance.data["subset"]}) + + if subset is None: + subset_name = instance.data["subset"] + self.log.info("Subset '%s' not found, creating.." % subset_name) + + _id = io.insert_one({ + "schema": "pype:subset-2.0", + "type": "subset", + "name": subset_name, + "data": {}, + "parent": asset["_id"] + }).inserted_id + + subset = io.find_one({"_id": _id}) + + return subset + + def create_version(self, subset, version_number, locations, data=None): + """ Copy given source to destination + + Args: + subset (dict): the registered subset of the asset + version_number (int): the version number + locations (list): the currently registered locations + + Returns: + dict: collection of data to create a version + """ + # Imprint currently registered location + version_locations = [location for location in locations if + location is not None] + + return {"schema": "pype:version-2.0", + "type": "version", + "parent": subset["_id"], + "name": version_number, + "locations": version_locations, + "data": data} + + def create_version_data(self, context, instance): + """Create the data collection for the version + + Args: + context: the current context + instance: the current instance being published + + Returns: + dict: the required information with instance.data as key + """ + + families = [] + current_families = instance.data.get("families", list()) + instance_family = instance.data.get("family", None) + + if instance_family is not None: + families.append(instance_family) + families += current_families + + # try: + # source = instance.data['source'] + # except KeyError: + # source = context.data["currentFile"] + # + # relative_path = os.path.relpath(source, api.registered_root()) + # source = os.path.join("{root}", relative_path).replace("\\", "/") + + source = "standalone" + + version_data = {"families": families, + "time": context.data["time"], + "author": context.data["user"], + "source": source, + "comment": context.data.get("comment")} + + # Include optional data if present in + optionals = ["startFrame", "endFrame", "step", + "handles", "colorspace", "fps", "outputDir"] + + for key in optionals: + if key in instance.data: + version_data[key] = instance.data.get(key, None) + + return version_data diff --git a/pype/standalonepublish/__init__.py b/pype/standalonepublish/__init__.py new file mode 100644 index 0000000000..c7be80f189 --- /dev/null +++ b/pype/standalonepublish/__init__.py @@ -0,0 +1,12 @@ +from .standalonepublish_module import StandAlonePublishModule +from .app import ( + show, + cli +) +__all__ = [ + "show", + "cli" +] + +def tray_init(tray_widget, main_widget): + return StandAlonePublishModule(main_widget, tray_widget) diff --git a/pype/standalonepublish/__main__.py b/pype/standalonepublish/__main__.py new file mode 100644 index 0000000000..d77bc585c5 --- /dev/null +++ b/pype/standalonepublish/__main__.py @@ -0,0 +1,5 @@ +from . import cli + +if __name__ == '__main__': + import sys + sys.exit(cli(sys.argv[1:])) diff --git a/pype/standalonepublish/app.py b/pype/standalonepublish/app.py new file mode 100644 index 0000000000..956cdb6300 --- /dev/null +++ b/pype/standalonepublish/app.py @@ -0,0 +1,241 @@ +import os +import sys +import json +from subprocess import Popen +from pype import lib as pypelib +from avalon.vendor.Qt import QtWidgets, QtCore +from avalon import api, style, schema +from avalon.tools import lib as parentlib +from .widgets import * +# Move this to pype lib? +from avalon.tools.libraryloader.io_nonsingleton import DbConnector + +module = sys.modules[__name__] +module.window = None + +class Window(QtWidgets.QDialog): + """Main window of Standalone publisher. + + :param parent: Main widget that cares about all GUIs + :type parent: QtWidgets.QMainWindow + """ + _db = DbConnector() + _jobs = {} + valid_family = False + valid_components = False + initialized = False + WIDTH = 1100 + HEIGHT = 500 + NOT_SELECTED = '< Nothing is selected >' + + def __init__(self, parent=None): + super(Window, self).__init__(parent=parent) + self._db.install() + + self.setWindowTitle("Standalone Publish") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose) + self.setStyleSheet(style.load_stylesheet()) + + # Validators + self.valid_parent = False + + # statusbar - added under asset_widget + label_message = QtWidgets.QLabel() + label_message.setFixedHeight(20) + + # assets widget + widget_assets_wrap = QtWidgets.QWidget() + widget_assets_wrap.setContentsMargins(0, 0, 0, 0) + widget_assets = AssetWidget(self) + + layout_assets = QtWidgets.QVBoxLayout(widget_assets_wrap) + layout_assets.addWidget(widget_assets) + layout_assets.addWidget(label_message) + + # family widget + widget_family = FamilyWidget(self) + + # components widget + widget_components = ComponentsWidget(self) + + # Body + body = QtWidgets.QSplitter() + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Horizontal) + body.addWidget(widget_assets_wrap) + body.addWidget(widget_family) + body.addWidget(widget_components) + body.setStretchFactor(body.indexOf(widget_assets_wrap), 2) + body.setStretchFactor(body.indexOf(widget_family), 3) + body.setStretchFactor(body.indexOf(widget_components), 5) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + + self.resize(self.WIDTH, self.HEIGHT) + + # signals + widget_assets.selection_changed.connect(self.on_asset_changed) + + self.label_message = label_message + self.widget_assets = widget_assets + self.widget_family = widget_family + self.widget_components = widget_components + + self.echo("Connected to Database") + + # on start + self.on_start() + + @property + def db(self): + ''' Returns DB object for MongoDB I/O + ''' + return self._db + + def on_start(self): + ''' Things must be done when initilized. + ''' + # Refresh asset input in Family widget + self.on_asset_changed() + self.widget_components.validation() + # Initializing shadow widget + self.shadow_widget = ShadowWidget(self) + self.shadow_widget.setVisible(False) + + def resizeEvent(self, event=None): + ''' Helps resize shadow widget + ''' + position_x = (self.frameGeometry().width()-self.shadow_widget.frameGeometry().width())/2 + position_y = (self.frameGeometry().height()-self.shadow_widget.frameGeometry().height())/2 + self.shadow_widget.move(position_x, position_y) + w = self.frameGeometry().width() + h = self.frameGeometry().height() + self.shadow_widget.resize(QtCore.QSize(w, h)) + if event: + super().resizeEvent(event) + + def get_avalon_parent(self, entity): + ''' Avalon DB entities helper - get all parents (exclude project). + ''' + parent_id = entity['data']['visualParent'] + parents = [] + if parent_id is not None: + parent = self.db.find_one({'_id': parent_id}) + parents.extend(self.get_avalon_parent(parent)) + parents.append(parent['name']) + return parents + + def echo(self, message): + ''' Shows message in label that disappear in 5s + :param message: Message that will be displayed + :type message: str + ''' + self.label_message.setText(str(message)) + def clear_text(): + ''' Helps prevent crash if this Window object + is deleted before 5s passed + ''' + try: + self.label_message.set_text("") + except: + pass + QtCore.QTimer.singleShot(5000, lambda: clear_text()) + + def on_asset_changed(self): + '''Callback on asset selection changed + + Updates the task view. + + ''' + selected = self.widget_assets.get_selected_assets() + if len(selected) == 1: + self.valid_parent = True + asset = self.db.find_one({"_id": selected[0], "type": "asset"}) + self.widget_family.change_asset(asset['name']) + else: + self.valid_parent = False + self.widget_family.change_asset(self.NOT_SELECTED) + self.widget_family.on_data_changed() + + def keyPressEvent(self, event): + ''' Handling Ctrl+V KeyPress event + Can handle: + - files/folders in clipboard (tested only on Windows OS) + - copied path of file/folder in clipboard ('c:/path/to/folder') + ''' + if event.key() == QtCore.Qt.Key_V and event.modifiers() == QtCore.Qt.ControlModifier: + clip = QtWidgets.QApplication.clipboard() + self.widget_components.process_mime_data(clip) + super().keyPressEvent(event) + + def working_start(self, msg=None): + ''' Shows shadowed foreground with message + :param msg: Message that will be displayed + (set to `Please wait...` if `None` entered) + :type msg: str + ''' + if msg is None: + msg = 'Please wait...' + self.shadow_widget.message = msg + self.shadow_widget.setVisible(True) + self.resizeEvent() + QtWidgets.QApplication.processEvents() + + def working_stop(self): + ''' Hides shadowed foreground + ''' + if self.shadow_widget.isVisible(): + self.shadow_widget.setVisible(False) + + def set_valid_family(self, valid): + ''' Sets `valid_family` attribute for validation + + .. note:: + if set to `False` publishing is not possible + ''' + self.valid_family = valid + # If widget_components not initialized yet + if hasattr(self, 'widget_components'): + self.widget_components.validation() + + def collect_data(self): + ''' Collecting necessary data for pyblish from child widgets + ''' + data = {} + data.update(self.widget_assets.collect_data()) + data.update(self.widget_family.collect_data()) + data.update(self.widget_components.collect_data()) + + return data + +def show(parent=None, debug=False): + try: + module.window.close() + del module.window + except (RuntimeError, AttributeError): + pass + + with parentlib.application(): + window = Window(parent) + window.show() + + module.window = window + + +def cli(args): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("project") + parser.add_argument("asset") + + args = parser.parse_args(args) + # project = args.project + # asset = args.asset + + show() diff --git a/pype/standalonepublish/publish.py b/pype/standalonepublish/publish.py new file mode 100644 index 0000000000..4442f0243c --- /dev/null +++ b/pype/standalonepublish/publish.py @@ -0,0 +1,154 @@ +import os +import sys +import json +import tempfile +import random +import string + +from avalon import io +from avalon import api as avalon +from avalon.tools import publish as av_publish + +import pype +from pypeapp import execute + +import pyblish.api + + +# Registers Global pyblish plugins +# pype.install() +# Registers Standalone pyblish plugins +PUBLISH_PATH = os.path.sep.join( + [pype.PLUGINS_DIR, 'standalonepublish', 'publish'] +) +pyblish.api.register_plugin_path(PUBLISH_PATH) + +# # Registers Standalone pyblish plugins +# PUBLISH_PATH = os.path.sep.join( +# [pype.PLUGINS_DIR, 'ftrack', 'publish'] +# ) +# pyblish.api.register_plugin_path(PUBLISH_PATH) + + +def set_context(project, asset, app): + ''' Sets context for pyblish (must be done before pyblish is launched) + :param project: Name of `Project` where instance should be published + :type project: str + :param asset: Name of `Asset` where instance should be published + :type asset: str + ''' + os.environ["AVALON_PROJECT"] = project + io.Session["AVALON_PROJECT"] = project + os.environ["AVALON_ASSET"] = asset + io.Session["AVALON_ASSET"] = asset + + io.install() + + av_project = io.find_one({'type': 'project'}) + av_asset = io.find_one({ + "type": 'asset', + "name": asset + }) + + parents = av_asset['data']['parents'] + hierarchy = '' + if parents and len(parents) > 0: + hierarchy = os.path.sep.join(parents) + + os.environ["AVALON_HIERARCHY"] = hierarchy + io.Session["AVALON_HIERARCHY"] = hierarchy + + os.environ["AVALON_PROJECTCODE"] = av_project['data'].get('code', '') + io.Session["AVALON_PROJECTCODE"] = av_project['data'].get('code', '') + + io.Session["current_dir"] = os.path.normpath(os.getcwd()) + + os.environ["AVALON_APP"] = app + io.Session["AVALON_APP"] = app + + io.uninstall() + + +def publish(data, gui=True): + # cli pyblish seems like better solution + return cli_publish(data, gui) + # # this uses avalon pyblish launch tool + # avalon_api_publish(data, gui) + + +def avalon_api_publish(data, gui=True): + ''' Launches Pyblish (GUI by default) + :param data: Should include data for pyblish and standalone collector + :type data: dict + :param gui: Pyblish will be launched in GUI mode if set to True + :type gui: bool + ''' + io.install() + + # Create hash name folder in temp + chars = "".join( [random.choice(string.ascii_letters) for i in range(15)] ) + staging_dir = tempfile.mkdtemp(chars) + + # create also json and fill with data + json_data_path = staging_dir + os.path.basename(staging_dir) + '.json' + with open(json_data_path, 'w') as outfile: + json.dump(data, outfile) + + args = [ + "-pp", os.pathsep.join(pyblish.api.registered_paths()) + ] + + os.environ["PYBLISH_HOSTS"] = "shell" + os.environ["SAPUBLISH_INPATH"] = json_data_path + + if gui: + av_publish.show() + else: + returncode = execute([ + sys.executable, "-u", "-m", "pyblish" + ] + args, env=os.environ) + + io.uninstall() + + +def cli_publish(data, gui=True): + io.install() + + # Create hash name folder in temp + chars = "".join( [random.choice(string.ascii_letters) for i in range(15)] ) + staging_dir = tempfile.mkdtemp(chars) + + # create json for return data + return_data_path = ( + staging_dir + os.path.basename(staging_dir) + 'return.json' + ) + # create also json and fill with data + json_data_path = staging_dir + os.path.basename(staging_dir) + '.json' + with open(json_data_path, 'w') as outfile: + json.dump(data, outfile) + + args = [ + "-pp", os.pathsep.join(pyblish.api.registered_paths()) + ] + + if gui: + args += ["gui"] + + os.environ["PYBLISH_HOSTS"] = "shell" + os.environ["SAPUBLISH_INPATH"] = json_data_path + os.environ["SAPUBLISH_OUTPATH"] = return_data_path + + returncode = execute([ + sys.executable, "-u", "-m", "pyblish" + ] + args, env=os.environ) + + result = {} + if os.path.exists(json_data_path): + with open(json_data_path, "r") as f: + result = json.load(f) + + io.uninstall() + # TODO: check if was pyblish successful + # if successful return True + print('Check result here') + return False diff --git a/pype/standalonepublish/resources/__init__.py b/pype/standalonepublish/resources/__init__.py new file mode 100644 index 0000000000..ce329ee585 --- /dev/null +++ b/pype/standalonepublish/resources/__init__.py @@ -0,0 +1,14 @@ +import os + + +resource_path = os.path.dirname(__file__) + + +def get_resource(*args): + """ Serves to simple resources access + + :param \*args: should contain *subfolder* names and *filename* of + resource from resources folder + :type \*args: list + """ + return os.path.normpath(os.path.join(resource_path, *args)) diff --git a/pype/standalonepublish/resources/edit.svg b/pype/standalonepublish/resources/edit.svg new file mode 100644 index 0000000000..26451b4a9d --- /dev/null +++ b/pype/standalonepublish/resources/edit.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/pype/standalonepublish/resources/file.png b/pype/standalonepublish/resources/file.png new file mode 100644 index 0000000000..7a830ad133 Binary files /dev/null and b/pype/standalonepublish/resources/file.png differ diff --git a/pype/standalonepublish/resources/files.png b/pype/standalonepublish/resources/files.png new file mode 100644 index 0000000000..f6f89fe149 Binary files /dev/null and b/pype/standalonepublish/resources/files.png differ diff --git a/pype/standalonepublish/resources/houdini.png b/pype/standalonepublish/resources/houdini.png new file mode 100644 index 0000000000..11cfa46dce Binary files /dev/null and b/pype/standalonepublish/resources/houdini.png differ diff --git a/pype/standalonepublish/resources/image_file.png b/pype/standalonepublish/resources/image_file.png new file mode 100644 index 0000000000..adea862e5b Binary files /dev/null and b/pype/standalonepublish/resources/image_file.png differ diff --git a/pype/standalonepublish/resources/image_files.png b/pype/standalonepublish/resources/image_files.png new file mode 100644 index 0000000000..2db779ab30 Binary files /dev/null and b/pype/standalonepublish/resources/image_files.png differ diff --git a/pype/standalonepublish/resources/information.svg b/pype/standalonepublish/resources/information.svg new file mode 100644 index 0000000000..e0f73a7eb1 --- /dev/null +++ b/pype/standalonepublish/resources/information.svg @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/pype/standalonepublish/resources/maya.png b/pype/standalonepublish/resources/maya.png new file mode 100644 index 0000000000..e84a6a3742 Binary files /dev/null and b/pype/standalonepublish/resources/maya.png differ diff --git a/pype/standalonepublish/resources/menu.svg b/pype/standalonepublish/resources/menu.svg new file mode 100644 index 0000000000..ac1e728011 --- /dev/null +++ b/pype/standalonepublish/resources/menu.svg @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/pype/standalonepublish/resources/nuke.png b/pype/standalonepublish/resources/nuke.png new file mode 100644 index 0000000000..4234454096 Binary files /dev/null and b/pype/standalonepublish/resources/nuke.png differ diff --git a/pype/standalonepublish/resources/premiere.png b/pype/standalonepublish/resources/premiere.png new file mode 100644 index 0000000000..eb5b3d1ba2 Binary files /dev/null and b/pype/standalonepublish/resources/premiere.png differ diff --git a/pype/standalonepublish/resources/preview.svg b/pype/standalonepublish/resources/preview.svg new file mode 100644 index 0000000000..4a9810c1d5 --- /dev/null +++ b/pype/standalonepublish/resources/preview.svg @@ -0,0 +1,19 @@ + + + + + PREVIEW + + diff --git a/pype/standalonepublish/resources/thumbnail.svg b/pype/standalonepublish/resources/thumbnail.svg new file mode 100644 index 0000000000..dbc228f8c8 --- /dev/null +++ b/pype/standalonepublish/resources/thumbnail.svg @@ -0,0 +1,19 @@ + + + + + THUMBNAIL + + diff --git a/pype/standalonepublish/resources/trash.svg b/pype/standalonepublish/resources/trash.svg new file mode 100644 index 0000000000..07905024c0 --- /dev/null +++ b/pype/standalonepublish/resources/trash.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/pype/standalonepublish/resources/video_file.png b/pype/standalonepublish/resources/video_file.png new file mode 100644 index 0000000000..346277e40f Binary files /dev/null and b/pype/standalonepublish/resources/video_file.png differ diff --git a/pype/standalonepublish/standalonepublish_module.py b/pype/standalonepublish/standalonepublish_module.py new file mode 100644 index 0000000000..703f457138 --- /dev/null +++ b/pype/standalonepublish/standalonepublish_module.py @@ -0,0 +1,18 @@ +from .app import show +from .widgets import QtWidgets + + +class StandAlonePublishModule: + def __init__(self, main_parent=None, parent=None): + self.main_parent = main_parent + self.parent_widget = parent + + def tray_menu(self, parent_menu): + self.run_action = QtWidgets.QAction( + "Publish", parent_menu + ) + self.run_action.triggered.connect(self.show) + parent_menu.addAction(self.run_action) + + def show(self): + show(self.main_parent, False) diff --git a/pype/standalonepublish/widgets/__init__.py b/pype/standalonepublish/widgets/__init__.py new file mode 100644 index 0000000000..cd99e15bed --- /dev/null +++ b/pype/standalonepublish/widgets/__init__.py @@ -0,0 +1,34 @@ +from avalon.vendor.Qt import * +from avalon.vendor import qtawesome as awesome +from avalon import style + +HelpRole = QtCore.Qt.UserRole + 2 +FamilyRole = QtCore.Qt.UserRole + 3 +ExistsRole = QtCore.Qt.UserRole + 4 +PluginRole = QtCore.Qt.UserRole + 5 + +from ..resources import get_resource +from .button_from_svgs import SvgResizable, SvgButton + +from .model_node import Node +from .model_tree import TreeModel +from .model_asset import AssetModel +from .model_filter_proxy_exact_match import ExactMatchesFilterProxyModel +from .model_filter_proxy_recursive_sort import RecursiveSortFilterProxyModel +from .model_tasks_template import TasksTemplateModel +from .model_tree_view_deselectable import DeselectableTreeView + +from .widget_asset_view import AssetView +from .widget_asset import AssetWidget +from .widget_family_desc import FamilyDescriptionWidget +from .widget_family import FamilyWidget + +from .widget_drop_empty import DropEmpty +from .widget_component_item import ComponentItem +from .widget_components_list import ComponentsList + +from .widget_drop_frame import DropDataFrame + +from .widget_components import ComponentsWidget + +from.widget_shadow import ShadowWidget diff --git a/pype/standalonepublish/widgets/button_from_svgs.py b/pype/standalonepublish/widgets/button_from_svgs.py new file mode 100644 index 0000000000..4255c5f29b --- /dev/null +++ b/pype/standalonepublish/widgets/button_from_svgs.py @@ -0,0 +1,113 @@ +from xml.dom import minidom + +from . import QtGui, QtCore, QtWidgets +from PyQt5 import QtSvg, QtXml + + +class SvgResizable(QtSvg.QSvgWidget): + clicked = QtCore.Signal() + + def __init__(self, filepath, width=None, height=None, fill=None): + super().__init__() + self.xmldoc = minidom.parse(filepath) + itemlist = self.xmldoc.getElementsByTagName('svg') + for element in itemlist: + if fill: + element.setAttribute('fill', str(fill)) + # TODO auto scale if only one is set + if width is not None and height is not None: + self.setMaximumSize(width, height) + self.setMinimumSize(width, height) + xml_string = self.xmldoc.toxml() + svg_bytes = bytearray(xml_string, encoding='utf-8') + + self.load(svg_bytes) + + def change_color(self, color): + element = self.xmldoc.getElementsByTagName('svg')[0] + element.setAttribute('fill', str(color)) + xml_string = self.xmldoc.toxml() + svg_bytes = bytearray(xml_string, encoding='utf-8') + self.load(svg_bytes) + + def mousePressEvent(self, event): + self.clicked.emit() + + +class SvgButton(QtWidgets.QFrame): + clicked = QtCore.Signal() + def __init__( + self, filepath, width=None, height=None, fills=[], + parent=None, checkable=True + ): + super().__init__(parent) + self.checkable = checkable + self.checked = False + + xmldoc = minidom.parse(filepath) + element = xmldoc.getElementsByTagName('svg')[0] + c_actual = '#777777' + if element.hasAttribute('fill'): + c_actual = element.getAttribute('fill') + self.store_fills(fills, c_actual) + + self.installEventFilter(self) + self.svg_widget = SvgResizable(filepath, width, height, self.c_normal) + xmldoc = minidom.parse(filepath) + + layout = QtWidgets.QHBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.svg_widget) + + if width is not None and height is not None: + self.setMaximumSize(width, height) + self.setMinimumSize(width, height) + + def store_fills(self, fills, actual): + if len(fills) == 0: + fills = [actual, actual, actual, actual] + elif len(fills) == 1: + fills = [fills[0], fills[0], fills[0], fills[0]] + elif len(fills) == 2: + fills = [fills[0], fills[1], fills[1], fills[1]] + elif len(fills) == 3: + fills = [fills[0], fills[1], fills[2], fills[2]] + self.c_normal = fills[0] + self.c_hover = fills[1] + self.c_active = fills[2] + self.c_active_hover = fills[3] + + def eventFilter(self, object, event): + if event.type() == QtCore.QEvent.Enter: + self.hoverEnterEvent(event) + return True + elif event.type() == QtCore.QEvent.Leave: + self.hoverLeaveEvent(event) + return True + elif event.type() == QtCore.QEvent.MouseButtonRelease: + self.mousePressEvent(event) + return False + + def change_checked(self, hover=True): + if self.checkable: + self.checked = not self.checked + if hover: + self.hoverEnterEvent() + else: + self.hoverLeaveEvent() + + def hoverEnterEvent(self, event=None): + color = self.c_hover + if self.checked: + color = self.c_active_hover + self.svg_widget.change_color(color) + + def hoverLeaveEvent(self, event=None): + color = self.c_normal + if self.checked: + color = self.c_active + self.svg_widget.change_color(color) + + def mousePressEvent(self, event=None): + self.clicked.emit() diff --git a/pype/standalonepublish/widgets/model_asset.py b/pype/standalonepublish/widgets/model_asset.py new file mode 100644 index 0000000000..fdf844342e --- /dev/null +++ b/pype/standalonepublish/widgets/model_asset.py @@ -0,0 +1,158 @@ +import logging +from . import QtCore, QtGui +from . import TreeModel, Node +from . import style, awesome + + +log = logging.getLogger(__name__) + + +def _iter_model_rows(model, + column, + include_root=False): + """Iterate over all row indices in a model""" + indices = [QtCore.QModelIndex()] # start iteration at root + + for index in indices: + + # Add children to the iterations + child_rows = model.rowCount(index) + for child_row in range(child_rows): + child_index = model.index(child_row, column, index) + indices.append(child_index) + + if not include_root and not index.isValid(): + continue + + yield index + + +class AssetModel(TreeModel): + """A model listing assets in the silo in the active project. + + The assets are displayed in a treeview, they are visually parented by + a `visualParent` field in the database containing an `_id` to a parent + asset. + + """ + + COLUMNS = ["label"] + Name = 0 + Deprecated = 2 + ObjectId = 3 + + DocumentRole = QtCore.Qt.UserRole + 2 + ObjectIdRole = QtCore.Qt.UserRole + 3 + + def __init__(self, parent): + super(AssetModel, self).__init__(parent=parent) + self.parent_widget = parent + self.refresh() + + @property + def db(self): + return self.parent_widget.db + + def _add_hierarchy(self, parent=None): + + # Find the assets under the parent + find_data = { + "type": "asset" + } + if parent is None: + find_data['$or'] = [ + {'data.visualParent': {'$exists': False}}, + {'data.visualParent': None} + ] + else: + find_data["data.visualParent"] = parent['_id'] + + assets = self.db.find(find_data).sort('name', 1) + for asset in assets: + # get label from data, otherwise use name + data = asset.get("data", {}) + label = data.get("label", asset['name']) + tags = data.get("tags", []) + + # store for the asset for optimization + deprecated = "deprecated" in tags + + node = Node({ + "_id": asset['_id'], + "name": asset["name"], + "label": label, + "type": asset['type'], + "tags": ", ".join(tags), + "deprecated": deprecated, + "_document": asset + }) + self.add_child(node, parent=parent) + + # Add asset's children recursively + self._add_hierarchy(node) + + def refresh(self): + """Refresh the data for the model.""" + + self.clear() + if ( + self.db.active_project() is None or + self.db.active_project() == '' + ): + return + self.beginResetModel() + self._add_hierarchy(parent=None) + self.endResetModel() + + def flags(self, index): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + def data(self, index, role): + + if not index.isValid(): + return + + node = index.internalPointer() + if role == QtCore.Qt.DecorationRole: # icon + + column = index.column() + if column == self.Name: + + # Allow a custom icon and custom icon color to be defined + data = node["_document"]["data"] + icon = data.get("icon", None) + color = data.get("color", style.colors.default) + + if icon is None: + # Use default icons if no custom one is specified. + # If it has children show a full folder, otherwise + # show an open folder + has_children = self.rowCount(index) > 0 + icon = "folder" if has_children else "folder-o" + + # Make the color darker when the asset is deprecated + if node.get("deprecated", False): + color = QtGui.QColor(color).darker(250) + + try: + key = "fa.{0}".format(icon) # font-awesome key + icon = awesome.icon(key, color=color) + return icon + except Exception as exception: + # Log an error message instead of erroring out completely + # when the icon couldn't be created (e.g. invalid name) + log.error(exception) + + return + + if role == QtCore.Qt.ForegroundRole: # font color + if "deprecated" in node.get("tags", []): + return QtGui.QColor(style.colors.light).darker(250) + + if role == self.ObjectIdRole: + return node.get("_id", None) + + if role == self.DocumentRole: + return node.get("_document", None) + + return super(AssetModel, self).data(index, role) diff --git a/pype/standalonepublish/widgets/model_filter_proxy_exact_match.py b/pype/standalonepublish/widgets/model_filter_proxy_exact_match.py new file mode 100644 index 0000000000..862e4071db --- /dev/null +++ b/pype/standalonepublish/widgets/model_filter_proxy_exact_match.py @@ -0,0 +1,28 @@ +from . import QtCore + + +class ExactMatchesFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filter model to where key column's value is in the filtered tags""" + + def __init__(self, *args, **kwargs): + super(ExactMatchesFilterProxyModel, self).__init__(*args, **kwargs) + self._filters = set() + + def setFilters(self, filters): + self._filters = set(filters) + + def filterAcceptsRow(self, source_row, source_parent): + + # No filter + if not self._filters: + return True + + else: + model = self.sourceModel() + column = self.filterKeyColumn() + idx = model.index(source_row, column, source_parent) + data = model.data(idx, self.filterRole()) + if data in self._filters: + return True + else: + return False diff --git a/pype/standalonepublish/widgets/model_filter_proxy_recursive_sort.py b/pype/standalonepublish/widgets/model_filter_proxy_recursive_sort.py new file mode 100644 index 0000000000..04ee88229f --- /dev/null +++ b/pype/standalonepublish/widgets/model_filter_proxy_recursive_sort.py @@ -0,0 +1,30 @@ +from . import QtCore + + +class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): + """Filters to the regex if any of the children matches allow parent""" + def filterAcceptsRow(self, row, parent): + + regex = self.filterRegExp() + if not regex.isEmpty(): + pattern = regex.pattern() + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if source_index.isValid(): + + # Check current index itself + key = model.data(source_index, self.filterRole()) + if re.search(pattern, key, re.IGNORECASE): + return True + + # Check children + rows = model.rowCount(source_index) + for i in range(rows): + if self.filterAcceptsRow(i, source_index): + return True + + # Otherwise filter it + return False + + return super(RecursiveSortFilterProxyModel, + self).filterAcceptsRow(row, parent) diff --git a/pype/standalonepublish/widgets/model_node.py b/pype/standalonepublish/widgets/model_node.py new file mode 100644 index 0000000000..e8326d5b90 --- /dev/null +++ b/pype/standalonepublish/widgets/model_node.py @@ -0,0 +1,56 @@ +import logging + + +log = logging.getLogger(__name__) + + +class Node(dict): + """A node that can be represented in a tree view. + + The node can store data just like a dictionary. + + >>> data = {"name": "John", "score": 10} + >>> node = Node(data) + >>> assert node["name"] == "John" + + """ + + def __init__(self, data=None): + super(Node, self).__init__() + + self._children = list() + self._parent = None + + if data is not None: + assert isinstance(data, dict) + self.update(data) + + def childCount(self): + return len(self._children) + + def child(self, row): + + if row >= len(self._children): + log.warning("Invalid row as child: {0}".format(row)) + return + + return self._children[row] + + def children(self): + return self._children + + def parent(self): + return self._parent + + def row(self): + """ + Returns: + int: Index of this node under parent""" + if self._parent is not None: + siblings = self.parent().children() + return siblings.index(self) + + def add_child(self, child): + """Add a child to this node""" + child._parent = self + self._children.append(child) diff --git a/pype/standalonepublish/widgets/model_tasks_template.py b/pype/standalonepublish/widgets/model_tasks_template.py new file mode 100644 index 0000000000..4af3b9eea7 --- /dev/null +++ b/pype/standalonepublish/widgets/model_tasks_template.py @@ -0,0 +1,65 @@ +from . import QtCore, TreeModel +from . import Node +from . import awesome, style + + +class TasksTemplateModel(TreeModel): + """A model listing the tasks combined for a list of assets""" + + COLUMNS = ["Tasks"] + + def __init__(self): + super(TasksTemplateModel, self).__init__() + self.selectable = False + self._icons = { + "__default__": awesome.icon("fa.folder-o", + color=style.colors.default) + } + + def set_tasks(self, tasks): + """Set assets to track by their database id + + Arguments: + asset_ids (list): List of asset ids. + + """ + + self.clear() + + # let cleared task view if no tasks are available + if len(tasks) == 0: + return + + self.beginResetModel() + + icon = self._icons["__default__"] + for task in tasks: + node = Node({ + "Tasks": task, + "icon": icon + }) + + self.add_child(node) + + self.endResetModel() + + def flags(self, index): + if self.selectable is False: + return QtCore.Qt.ItemIsEnabled + else: + return ( + QtCore.Qt.ItemIsEnabled | + QtCore.Qt.ItemIsSelectable + ) + + def data(self, index, role): + + if not index.isValid(): + return + + # Add icon to the first column + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + return index.internalPointer()['icon'] + + return super(TasksTemplateModel, self).data(index, role) diff --git a/pype/standalonepublish/widgets/model_tree.py b/pype/standalonepublish/widgets/model_tree.py new file mode 100644 index 0000000000..e4f1aa5eb7 --- /dev/null +++ b/pype/standalonepublish/widgets/model_tree.py @@ -0,0 +1,122 @@ +from . import QtCore +from . import Node + + +class TreeModel(QtCore.QAbstractItemModel): + + COLUMNS = list() + NodeRole = QtCore.Qt.UserRole + 1 + + def __init__(self, parent=None): + super(TreeModel, self).__init__(parent) + self._root_node = Node() + + def rowCount(self, parent): + if parent.isValid(): + node = parent.internalPointer() + else: + node = self._root_node + + return node.childCount() + + def columnCount(self, parent): + return len(self.COLUMNS) + + def data(self, index, role): + + if not index.isValid(): + return None + + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + + node = index.internalPointer() + column = index.column() + + key = self.COLUMNS[column] + return node.get(key, None) + + if role == self.NodeRole: + return index.internalPointer() + + def setData(self, index, value, role=QtCore.Qt.EditRole): + """Change the data on the nodes. + + Returns: + bool: Whether the edit was successful + """ + + if index.isValid(): + if role == QtCore.Qt.EditRole: + + node = index.internalPointer() + column = index.column() + key = self.COLUMNS[column] + node[key] = value + + # passing `list()` for PyQt5 (see PYSIDE-462) + self.dataChanged.emit(index, index, list()) + + # must return true if successful + return True + + return False + + def setColumns(self, keys): + assert isinstance(keys, (list, tuple)) + self.COLUMNS = keys + + def headerData(self, section, orientation, role): + + if role == QtCore.Qt.DisplayRole: + if section < len(self.COLUMNS): + return self.COLUMNS[section] + + super(TreeModel, self).headerData(section, orientation, role) + + def flags(self, index): + return ( + QtCore.Qt.ItemIsEnabled | + QtCore.Qt.ItemIsSelectable + ) + + def parent(self, index): + + node = index.internalPointer() + parent_node = node.parent() + + # If it has no parents we return invalid + if parent_node == self._root_node or not parent_node: + return QtCore.QModelIndex() + + return self.createIndex(parent_node.row(), 0, parent_node) + + def index(self, row, column, parent): + """Return index for row/column under parent""" + + if not parent.isValid(): + parentNode = self._root_node + else: + parentNode = parent.internalPointer() + + childItem = parentNode.child(row) + if childItem: + return self.createIndex(row, column, childItem) + else: + return QtCore.QModelIndex() + + def add_child(self, node, parent=None): + if parent is None: + parent = self._root_node + + parent.add_child(node) + + def column_name(self, column): + """Return column key by index""" + + if column < len(self.COLUMNS): + return self.COLUMNS[column] + + def clear(self): + self.beginResetModel() + self._root_node = Node() + self.endResetModel() diff --git a/pype/standalonepublish/widgets/model_tree_view_deselectable.py b/pype/standalonepublish/widgets/model_tree_view_deselectable.py new file mode 100644 index 0000000000..78bec44d36 --- /dev/null +++ b/pype/standalonepublish/widgets/model_tree_view_deselectable.py @@ -0,0 +1,16 @@ +from . import QtWidgets, QtCore + + +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + QtWidgets.QTreeView.mousePressEvent(self, event) diff --git a/pype/standalonepublish/widgets/widget_asset.py b/pype/standalonepublish/widgets/widget_asset.py new file mode 100644 index 0000000000..45e9757d71 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_asset.py @@ -0,0 +1,274 @@ +import contextlib +from . import QtWidgets, QtCore +from . import RecursiveSortFilterProxyModel, AssetModel, AssetView +from . import awesome, style + +@contextlib.contextmanager +def preserve_expanded_rows(tree_view, + column=0, + role=QtCore.Qt.DisplayRole): + """Preserves expanded row in QTreeView by column's data role. + + This function is created to maintain the expand vs collapse status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + Arguments: + tree_view (QWidgets.QTreeView): the tree view which is + nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + + model = tree_view.model() + + expanded = set() + + for index in _iter_model_rows(model, + column=column, + include_root=False): + if tree_view.isExpanded(index): + value = index.data(role) + expanded.add(value) + + try: + yield + finally: + if not expanded: + return + + for index in _iter_model_rows(model, + column=column, + include_root=False): + value = index.data(role) + state = value in expanded + if state: + tree_view.expand(index) + else: + tree_view.collapse(index) + + +@contextlib.contextmanager +def preserve_selection(tree_view, + column=0, + role=QtCore.Qt.DisplayRole, + current_index=True): + """Preserves row selection in QTreeView by column's data role. + + This function is created to maintain the selection status of + the model items. When refresh is triggered the items which are expanded + will stay expanded and vise versa. + + tree_view (QWidgets.QTreeView): the tree view nested in the application + column (int): the column to retrieve the data from + role (int): the role which dictates what will be returned + + Returns: + None + + """ + + model = tree_view.model() + selection_model = tree_view.selectionModel() + flags = selection_model.Select | selection_model.Rows + + if current_index: + current_index_value = tree_view.currentIndex().data(role) + else: + current_index_value = None + + selected_rows = selection_model.selectedRows() + if not selected_rows: + yield + return + + selected = set(row.data(role) for row in selected_rows) + try: + yield + finally: + if not selected: + return + + # Go through all indices, select the ones with similar data + for index in _iter_model_rows(model, + column=column, + include_root=False): + + value = index.data(role) + state = value in selected + if state: + tree_view.scrollTo(index) # Ensure item is visible + selection_model.select(index, flags) + + if current_index_value and value == current_index_value: + tree_view.setCurrentIndex(index) + + +class AssetWidget(QtWidgets.QWidget): + """A Widget to display a tree of assets with filter + + To list the assets of the active project: + >>> # widget = AssetWidget() + >>> # widget.refresh() + >>> # widget.show() + + """ + + assets_refreshed = QtCore.Signal() # on model refresh + selection_changed = QtCore.Signal() # on view selection change + current_changed = QtCore.Signal() # on view current index change + + def __init__(self, parent): + super(AssetWidget, self).__init__(parent=parent) + self.setContentsMargins(0, 0, 0, 0) + + self.parent_widget = parent + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + + # Project + self.combo_projects = QtWidgets.QComboBox() + self._set_projects() + self.combo_projects.currentTextChanged.connect(self.on_project_change) + # Tree View + model = AssetModel(self) + proxy = RecursiveSortFilterProxyModel() + proxy.setSourceModel(model) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + view = AssetView() + view.setModel(proxy) + + # Header + header = QtWidgets.QHBoxLayout() + + icon = awesome.icon("fa.refresh", color=style.colors.light) + refresh = QtWidgets.QPushButton(icon, "") + refresh.setToolTip("Refresh items") + + filter = QtWidgets.QLineEdit() + filter.textChanged.connect(proxy.setFilterFixedString) + filter.setPlaceholderText("Filter assets..") + + header.addWidget(filter) + header.addWidget(refresh) + + # Layout + layout.addWidget(self.combo_projects) + layout.addLayout(header) + layout.addWidget(view) + + # Signals/Slots + selection = view.selectionModel() + selection.selectionChanged.connect(self.selection_changed) + selection.currentChanged.connect(self.current_changed) + refresh.clicked.connect(self.refresh) + + self.refreshButton = refresh + self.model = model + self.proxy = proxy + self.view = view + + @property + def db(self): + return self.parent_widget.db + + def collect_data(self): + project = self.db.find_one({'type': 'project'}) + asset = self.db.find_one({'_id': self.get_active_asset()}) + data = { + 'project': project['name'], + 'asset': asset['name'], + 'parents': self.get_parents(asset) + } + return data + + def get_parents(self, entity): + output = [] + if entity.get('data', {}).get('visualParent', None) is None: + return output + parent = self.db.find_one({'_id': entity['data']['visualParent']}) + output.append(parent['name']) + output.extend(self.get_parents(parent)) + return output + + def _set_projects(self): + projects = list() + for project in self.db.projects(): + projects.append(project['name']) + + self.combo_projects.clear() + if len(projects) > 0: + self.combo_projects.addItems(projects) + self.db.activate_project(projects[0]) + + def on_project_change(self): + projects = list() + for project in self.db.projects(): + projects.append(project['name']) + project_name = self.combo_projects.currentText() + if project_name in projects: + self.db.activate_project(project_name) + self.refresh() + + def _refresh_model(self): + self.model.refresh() + self.assets_refreshed.emit() + + def refresh(self): + self._refresh_model() + + def get_active_asset(self): + """Return the asset id the current asset.""" + current = self.view.currentIndex() + return current.data(self.model.ObjectIdRole) + + def get_active_index(self): + return self.view.currentIndex() + + def get_selected_assets(self): + """Return the assets' ids that are selected.""" + selection = self.view.selectionModel() + rows = selection.selectedRows() + return [row.data(self.model.ObjectIdRole) for row in rows] + + def select_assets(self, assets, expand=True): + """Select assets by name. + + Args: + assets (list): List of asset names + expand (bool): Whether to also expand to the asset in the view + + Returns: + None + + """ + # TODO: Instead of individual selection optimize for many assets + + assert isinstance(assets, + (tuple, list)), "Assets must be list or tuple" + + # Clear selection + selection_model = self.view.selectionModel() + selection_model.clearSelection() + + # Select + mode = selection_model.Select | selection_model.Rows + for index in _iter_model_rows(self.proxy, + column=0, + include_root=False): + data = index.data(self.model.NodeRole) + name = data['name'] + if name in assets: + selection_model.select(index, mode) + + if expand: + self.view.expand(index) + + # Set the currently active index + self.view.setCurrentIndex(index) diff --git a/pype/standalonepublish/widgets/widget_asset_view.py b/pype/standalonepublish/widgets/widget_asset_view.py new file mode 100644 index 0000000000..27bf374599 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_asset_view.py @@ -0,0 +1,16 @@ +from . import QtCore +from . import DeselectableTreeView + + +class AssetView(DeselectableTreeView): + """Item view. + + This implements a context menu. + + """ + + def __init__(self): + super(AssetView, self).__init__() + self.setIndentation(15) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setHeaderHidden(True) diff --git a/pype/standalonepublish/widgets/widget_component_item.py b/pype/standalonepublish/widgets/widget_component_item.py new file mode 100644 index 0000000000..2e0df9a00c --- /dev/null +++ b/pype/standalonepublish/widgets/widget_component_item.py @@ -0,0 +1,294 @@ +import os +from . import QtCore, QtGui, QtWidgets +from . import SvgButton +from . import get_resource +from avalon import style + + +class ComponentItem(QtWidgets.QFrame): + C_NORMAL = '#777777' + C_HOVER = '#ffffff' + C_ACTIVE = '#4BB543' + C_ACTIVE_HOVER = '#4BF543' + signal_remove = QtCore.Signal(object) + signal_thumbnail = QtCore.Signal(object) + signal_preview = QtCore.Signal(object) + signal_repre_change = QtCore.Signal(object, object) + + def __init__(self, parent, main_parent): + super().__init__() + self.has_valid_repre = True + self.actions = [] + self.resize(290, 70) + self.setMinimumSize(QtCore.QSize(0, 70)) + self.parent_list = parent + self.parent_widget = main_parent + # Font + font = QtGui.QFont() + font.setFamily("DejaVu Sans Condensed") + font.setPointSize(9) + font.setBold(True) + font.setWeight(50) + font.setKerning(True) + + # Main widgets + frame = QtWidgets.QFrame(self) + frame.setFrameShape(QtWidgets.QFrame.StyledPanel) + frame.setFrameShadow(QtWidgets.QFrame.Raised) + + layout_main = QtWidgets.QHBoxLayout(frame) + layout_main.setSpacing(2) + layout_main.setContentsMargins(2, 2, 2, 2) + + # Image + Info + frame_image_info = QtWidgets.QFrame(frame) + + # Layout image info + layout = QtWidgets.QVBoxLayout(frame_image_info) + layout.setSpacing(2) + layout.setContentsMargins(2, 2, 2, 2) + + self.icon = QtWidgets.QLabel(frame) + self.icon.setMinimumSize(QtCore.QSize(22, 22)) + self.icon.setMaximumSize(QtCore.QSize(22, 22)) + self.icon.setText("") + self.icon.setScaledContents(True) + + self.btn_action_menu = SvgButton( + get_resource('menu.svg'), 22, 22, + [self.C_NORMAL, self.C_HOVER], + frame_image_info, False + ) + + self.action_menu = QtWidgets.QMenu() + + expanding_sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + expanding_sizePolicy.setHorizontalStretch(0) + expanding_sizePolicy.setVerticalStretch(0) + + layout.addWidget(self.icon, alignment=QtCore.Qt.AlignCenter) + layout.addWidget(self.btn_action_menu, alignment=QtCore.Qt.AlignCenter) + + layout_main.addWidget(frame_image_info) + + # Name + representation + self.name = QtWidgets.QLabel(frame) + self.file_info = QtWidgets.QLabel(frame) + self.ext = QtWidgets.QLabel(frame) + + self.name.setFont(font) + self.file_info.setFont(font) + self.ext.setFont(font) + + self.file_info.setStyleSheet('padding-left:3px;') + + expanding_sizePolicy.setHeightForWidth(self.name.sizePolicy().hasHeightForWidth()) + + frame_name_repre = QtWidgets.QFrame(frame) + + self.file_info.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.ext.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + self.name.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) + + layout = QtWidgets.QHBoxLayout(frame_name_repre) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.name, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.file_info, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.ext, alignment=QtCore.Qt.AlignRight) + + frame_name_repre.setSizePolicy( + QtWidgets.QSizePolicy.MinimumExpanding, QtWidgets.QSizePolicy.MinimumExpanding + ) + + # Repre + icons + frame_repre_icons = QtWidgets.QFrame(frame) + + frame_repre = QtWidgets.QFrame(frame_repre_icons) + + label_repre = QtWidgets.QLabel() + label_repre.setText('Representation:') + + self.input_repre = QtWidgets.QLineEdit() + self.input_repre.setMaximumWidth(50) + + layout = QtWidgets.QHBoxLayout(frame_repre) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(label_repre, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.input_repre, alignment=QtCore.Qt.AlignLeft) + + frame_icons = QtWidgets.QFrame(frame_repre_icons) + + self.preview = SvgButton( + get_resource('preview.svg'), 64, 18, + [self.C_NORMAL, self.C_HOVER, self.C_ACTIVE, self.C_ACTIVE_HOVER], + frame_icons + ) + + self.thumbnail = SvgButton( + get_resource('thumbnail.svg'), 84, 18, + [self.C_NORMAL, self.C_HOVER, self.C_ACTIVE, self.C_ACTIVE_HOVER], + frame_icons + ) + + layout = QtWidgets.QHBoxLayout(frame_icons) + layout.setSpacing(6) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.thumbnail) + layout.addWidget(self.preview) + + layout = QtWidgets.QHBoxLayout(frame_repre_icons) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + layout.addWidget(frame_repre, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(frame_icons, alignment=QtCore.Qt.AlignRight) + + frame_middle = QtWidgets.QFrame(frame) + + layout = QtWidgets.QVBoxLayout(frame_middle) + layout.setSpacing(0) + layout.setContentsMargins(4, 0, 4, 0) + layout.addWidget(frame_name_repre) + layout.addWidget(frame_repre_icons) + + layout.setStretchFactor(frame_name_repre, 1) + layout.setStretchFactor(frame_repre_icons, 1) + + layout_main.addWidget(frame_middle) + + self.remove = SvgButton( + get_resource('trash.svg'), 22, 22, + [self.C_NORMAL, self.C_HOVER], + frame, False + ) + + layout_main.addWidget(self.remove) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(2, 2, 2, 2) + layout.addWidget(frame) + + self.preview.setToolTip('Mark component as Preview') + self.thumbnail.setToolTip('Component will be selected as thumbnail') + + # self.frame.setStyleSheet("border: 1px solid black;") + + def set_context(self, data): + self.btn_action_menu.setVisible(False) + self.in_data = data + self.remove.clicked.connect(self._remove) + self.thumbnail.clicked.connect(self._thumbnail_clicked) + self.preview.clicked.connect(self._preview_clicked) + self.input_repre.textChanged.connect(self._handle_duplicate_repre) + name = data['name'] + representation = data['representation'] + ext = data['ext'] + file_info = data['file_info'] + thumb = data['thumb'] + prev = data['prev'] + icon = data['icon'] + + resource = None + if icon is not None: + resource = get_resource('{}.png'.format(icon)) + + if resource is None or not os.path.isfile(resource): + if data['is_sequence']: + resource = get_resource('files.png') + else: + resource = get_resource('file.png') + + pixmap = QtGui.QPixmap(resource) + self.icon.setPixmap(pixmap) + + self.name.setText(name) + self.input_repre.setText(representation) + self.ext.setText('( {} )'.format(ext)) + if file_info is None: + self.file_info.setVisible(False) + else: + self.file_info.setText('[{}]'.format(file_info)) + + self.thumbnail.setVisible(thumb) + self.preview.setVisible(prev) + + def add_action(self, action_name): + if action_name.lower() == 'split': + for action in self.actions: + if action.text() == 'Split to frames': + return + new_action = QtWidgets.QAction('Split to frames', self) + new_action.triggered.connect(self.split_sequence) + elif action_name.lower() == 'merge': + for action in self.actions: + if action.text() == 'Merge components': + return + new_action = QtWidgets.QAction('Merge components', self) + new_action.triggered.connect(self.merge_sequence) + else: + print('unknown action') + return + self.action_menu.addAction(new_action) + self.actions.append(new_action) + if not self.btn_action_menu.isVisible(): + self.btn_action_menu.setVisible(True) + self.btn_action_menu.clicked.connect(self.show_actions) + self.action_menu.setStyleSheet(style.load_stylesheet()) + + def set_repre_name_valid(self, valid): + self.has_valid_repre = valid + if valid: + self.input_repre.setStyleSheet("") + else: + self.input_repre.setStyleSheet("border: 1px solid red;") + + def split_sequence(self): + self.parent_widget.split_items(self) + + def merge_sequence(self): + self.parent_widget.merge_items(self) + + def show_actions(self): + position = QtGui.QCursor().pos() + self.action_menu.popup(position) + + def _remove(self): + self.signal_remove.emit(self) + + def _thumbnail_clicked(self): + self.signal_thumbnail.emit(self) + + def _preview_clicked(self): + self.signal_preview.emit(self) + + def _handle_duplicate_repre(self, repre_name): + self.signal_repre_change.emit(self, repre_name) + + def is_thumbnail(self): + return self.thumbnail.checked + + def change_thumbnail(self, hover=True): + self.thumbnail.change_checked(hover) + + def is_preview(self): + return self.preview.checked + + def change_preview(self, hover=True): + self.preview.change_checked(hover) + + def collect_data(self): + data = { + 'ext': self.in_data['ext'], + 'label': self.name.text(), + 'representation': self.input_repre.text(), + 'files': self.in_data['files'], + 'thumbnail': self.is_thumbnail(), + 'preview': self.is_preview() + } + return data diff --git a/pype/standalonepublish/widgets/widget_components.py b/pype/standalonepublish/widgets/widget_components.py new file mode 100644 index 0000000000..1e1fdf88e3 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_components.py @@ -0,0 +1,128 @@ +from . import QtWidgets, QtCore, QtGui +from . import DropDataFrame + +from .. import publish + + +class ComponentsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super().__init__() + self.initialized = False + self.valid_components = False + self.valid_family = False + self.valid_repre_names = False + + body = QtWidgets.QWidget() + self.parent_widget = parent + self.drop_frame = DropDataFrame(self) + + buttons = QtWidgets.QWidget() + + layout = QtWidgets.QHBoxLayout(buttons) + + self.btn_browse = QtWidgets.QPushButton('Browse') + self.btn_browse.setToolTip('Browse for file(s).') + self.btn_browse.setFocusPolicy(QtCore.Qt.NoFocus) + + self.btn_publish = QtWidgets.QPushButton('Publish') + self.btn_publish.setToolTip('Publishes data.') + self.btn_publish.setFocusPolicy(QtCore.Qt.NoFocus) + + layout.addWidget(self.btn_browse, alignment=QtCore.Qt.AlignLeft) + layout.addWidget(self.btn_publish, alignment=QtCore.Qt.AlignRight) + + layout = QtWidgets.QVBoxLayout(body) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.drop_frame) + layout.addWidget(buttons) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(body) + + self.btn_browse.clicked.connect(self._browse) + self.btn_publish.clicked.connect(self._publish) + self.initialized = True + + def validation(self): + if self.initialized is False: + return + valid = ( + self.parent_widget.valid_family and + self.valid_components and + self.valid_repre_names + ) + self.btn_publish.setEnabled(valid) + + def set_valid_components(self, valid): + self.valid_components = valid + self.validation() + + def set_valid_repre_names(self, valid): + self.valid_repre_names = valid + self.validation() + + def process_mime_data(self, mime_data): + self.drop_frame.process_ent_mime(mime_data) + + def collect_data(self): + return self.drop_frame.collect_data() + + def _browse(self): + options = [ + QtWidgets.QFileDialog.DontResolveSymlinks, + QtWidgets.QFileDialog.DontUseNativeDialog + ] + folders = False + if folders: + # browse folders specifics + caption = "Browse folders to publish image sequences" + file_mode = QtWidgets.QFileDialog.Directory + options.append(QtWidgets.QFileDialog.ShowDirsOnly) + else: + # browse files specifics + caption = "Browse files to publish" + file_mode = QtWidgets.QFileDialog.ExistingFiles + + # create the dialog + file_dialog = QtWidgets.QFileDialog(parent=self, caption=caption) + file_dialog.setLabelText(QtWidgets.QFileDialog.Accept, "Select") + file_dialog.setLabelText(QtWidgets.QFileDialog.Reject, "Cancel") + file_dialog.setFileMode(file_mode) + + # set the appropriate options + for option in options: + file_dialog.setOption(option) + + # browse! + if not file_dialog.exec_(): + return + + # process the browsed files/folders for publishing + paths = file_dialog.selectedFiles() + self.drop_frame._process_paths(paths) + + def working_start(self, msg=None): + if hasattr(self, 'parent_widget'): + self.parent_widget.working_start(msg) + + def working_stop(self): + if hasattr(self, 'parent_widget'): + self.parent_widget.working_stop() + + def _publish(self): + self.working_start('Pyblish is running') + try: + data = self.parent_widget.collect_data() + publish.set_context( + data['project'], data['asset'], 'standalonepublish' + ) + result = publish.publish(data) + # Clear widgets from components list if publishing was successful + if result: + self.drop_frame.components_list.clear_widgets() + self.drop_frame._refresh_view() + finally: + self.working_stop() diff --git a/pype/standalonepublish/widgets/widget_components_list.py b/pype/standalonepublish/widgets/widget_components_list.py new file mode 100644 index 0000000000..f85e9f0aa6 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_components_list.py @@ -0,0 +1,89 @@ +from . import QtCore, QtGui, QtWidgets + + +class ComponentsList(QtWidgets.QTableWidget): + def __init__(self, parent=None): + super().__init__(parent=parent) + + self._main_column = 0 + + self.setColumnCount(1) + self.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectRows + ) + self.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.setVerticalScrollMode( + QtWidgets.QAbstractItemView.ScrollPerPixel + ) + self.verticalHeader().hide() + + try: + self.verticalHeader().setResizeMode( + QtWidgets.QHeaderView.ResizeToContents + ) + except Exception: + self.verticalHeader().setSectionResizeMode( + QtWidgets.QHeaderView.ResizeToContents + ) + + self.horizontalHeader().setStretchLastSection(True) + self.horizontalHeader().hide() + + def count(self): + return self.rowCount() + + def add_widget(self, widget, row=None): + if row is None: + row = self.count() + + self.insertRow(row) + self.setCellWidget(row, self._main_column, widget) + + self.resizeRowToContents(row) + + return row + + def remove_widget(self, row): + self.removeRow(row) + + def move_widget(self, widget, newRow): + oldRow = self.indexOfWidget(widget) + if oldRow: + self.insertRow(newRow) + # Collect the oldRow after insert to make sure we move the correct + # widget. + oldRow = self.indexOfWidget(widget) + + self.setCellWidget(newRow, self._main_column, widget) + self.resizeRowToContents(oldRow) + + # Remove the old row + self.removeRow(oldRow) + + def clear_widgets(self): + '''Remove all widgets.''' + self.clear() + self.setRowCount(0) + + def widget_index(self, widget): + index = None + for row in range(self.count()): + candidateWidget = self.widget_at(row) + if candidateWidget == widget: + index = row + break + + return index + + def widgets(self): + widgets = [] + for row in range(self.count()): + widget = self.widget_at(row) + widgets.append(widget) + + return widgets + + def widget_at(self, row): + return self.cellWidget(row, self._main_column) diff --git a/pype/standalonepublish/widgets/widget_drop_empty.py b/pype/standalonepublish/widgets/widget_drop_empty.py new file mode 100644 index 0000000000..a68b91da59 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_drop_empty.py @@ -0,0 +1,52 @@ +import os +import logging +import clique +from . import QtWidgets, QtCore, QtGui + + +class DropEmpty(QtWidgets.QWidget): + + def __init__(self, parent): + '''Initialise DataDropZone widget.''' + super().__init__(parent) + + layout = QtWidgets.QVBoxLayout(self) + + BottomCenterAlignment = QtCore.Qt.AlignBottom | QtCore.Qt.AlignHCenter + TopCenterAlignment = QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + + font = QtGui.QFont() + font.setFamily("DejaVu Sans Condensed") + font.setPointSize(26) + font.setBold(True) + font.setWeight(50) + font.setKerning(True) + + self._label = QtWidgets.QLabel('Drag & Drop') + self._label.setFont(font) + self._label.setStyleSheet( + 'background-color: rgb(255, 255, 255, 0);' + ) + + font.setPointSize(12) + self._sub_label = QtWidgets.QLabel('(drop files here)') + self._sub_label.setFont(font) + self._sub_label.setStyleSheet( + 'background-color: rgb(255, 255, 255, 0);' + ) + + layout.addWidget(self._label, alignment=BottomCenterAlignment) + layout.addWidget(self._sub_label, alignment=TopCenterAlignment) + + def paintEvent(self, event): + super().paintEvent(event) + painter = QtGui.QPainter(self) + pen = QtGui.QPen() + pen.setWidth(1); + pen.setBrush(QtCore.Qt.darkGray); + pen.setStyle(QtCore.Qt.DashLine); + painter.setPen(pen) + painter.drawRect( + 10, 10, + self.rect().width()-15, self.rect().height()-15 + ) diff --git a/pype/standalonepublish/widgets/widget_drop_frame.py b/pype/standalonepublish/widgets/widget_drop_frame.py new file mode 100644 index 0000000000..cffe673152 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_drop_frame.py @@ -0,0 +1,427 @@ +import os +import re +import clique +import subprocess +from pypeapp import config +from . import QtWidgets, QtCore +from . import DropEmpty, ComponentsList, ComponentItem + + +class DropDataFrame(QtWidgets.QFrame): + def __init__(self, parent): + super().__init__() + self.parent_widget = parent + self.presets = config.get_presets()['standalone_publish'] + + self.setAcceptDrops(True) + layout = QtWidgets.QVBoxLayout(self) + self.components_list = ComponentsList(self) + layout.addWidget(self.components_list) + + self.drop_widget = DropEmpty(self) + + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(self.drop_widget.sizePolicy().hasHeightForWidth()) + self.drop_widget.setSizePolicy(sizePolicy) + + layout.addWidget(self.drop_widget) + + self._refresh_view() + + def dragEnterEvent(self, event): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + self.process_ent_mime(event) + event.accept() + + def process_ent_mime(self, ent): + paths = [] + if ent.mimeData().hasUrls(): + paths = self._processMimeData(ent.mimeData()) + else: + # If path is in clipboard as string + try: + path = ent.text() + if os.path.exists(path): + paths.append(path) + else: + print('Dropped invalid file/folder') + except Exception: + pass + if paths: + self._process_paths(paths) + + def _processMimeData(self, mimeData): + paths = [] + + for path in mimeData.urls(): + local_path = path.toLocalFile() + if os.path.isfile(local_path) or os.path.isdir(local_path): + paths.append(local_path) + else: + print('Invalid input: "{}"'.format(local_path)) + return paths + + def _add_item(self, data, actions=[]): + # Assign to self so garbage collector wont remove the component + # during initialization + new_component = ComponentItem(self.components_list, self) + new_component.set_context(data) + self.components_list.add_widget(new_component) + + new_component.signal_remove.connect(self._remove_item) + new_component.signal_preview.connect(self._set_preview) + new_component.signal_thumbnail.connect( + self._set_thumbnail + ) + new_component.signal_repre_change.connect(self.repre_name_changed) + for action in actions: + new_component.add_action(action) + + if len(self.components_list.widgets()) == 1: + self.parent_widget.set_valid_repre_names(True) + self._refresh_view() + + def _set_thumbnail(self, in_item): + checked_item = None + for item in self.components_list.widgets(): + if item.is_thumbnail(): + checked_item = item + break + if checked_item is None or checked_item == in_item: + in_item.change_thumbnail() + else: + checked_item.change_thumbnail(False) + in_item.change_thumbnail() + + def _set_preview(self, in_item): + checked_item = None + for item in self.components_list.widgets(): + if item.is_preview(): + checked_item = item + break + if checked_item is None or checked_item == in_item: + in_item.change_preview() + else: + checked_item.change_preview(False) + in_item.change_preview() + + def _remove_item(self, in_item): + valid_repre = in_item.has_valid_repre is True + + self.components_list.remove_widget( + self.components_list.widget_index(in_item) + ) + self._refresh_view() + if valid_repre: + return + for item in self.components_list.widgets(): + if item.has_valid_repre: + continue + self.repre_name_changed(item, item.input_repre.text()) + + def _refresh_view(self): + _bool = len(self.components_list.widgets()) == 0 + self.components_list.setVisible(not _bool) + self.drop_widget.setVisible(_bool) + + self.parent_widget.set_valid_components(not _bool) + + def _process_paths(self, in_paths): + self.parent_widget.working_start() + paths = self._get_all_paths(in_paths) + collections, remainders = clique.assemble(paths) + for collection in collections: + self._process_collection(collection) + for remainder in remainders: + self._process_remainder(remainder) + self.parent_widget.working_stop() + + def _get_all_paths(self, paths): + output_paths = [] + for path in paths: + path = os.path.normpath(path) + if os.path.isfile(path): + output_paths.append(path) + elif os.path.isdir(path): + s_paths = [] + for s_item in os.listdir(path): + s_path = os.path.sep.join([path, s_item]) + s_paths.append(s_path) + output_paths.extend(self._get_all_paths(s_paths)) + else: + print('Invalid path: "{}"'.format(path)) + return output_paths + + def _process_collection(self, collection): + file_base = os.path.basename(collection.head) + folder_path = os.path.dirname(collection.head) + if file_base[-1] in ['.', '_']: + file_base = file_base[:-1] + file_ext = collection.tail + repr_name = file_ext.replace('.', '') + range = collection.format('{ranges}') + + actions = [] + + data = { + 'files': [file for file in collection], + 'name': file_base, + 'ext': file_ext, + 'file_info': range, + 'representation': repr_name, + 'folder_path': folder_path, + 'is_sequence': True, + 'actions': actions + } + self._process_data(data) + + def _get_ranges(self, indexes): + if len(indexes) == 1: + return str(indexes[0]) + ranges = [] + first = None + last = None + for index in indexes: + if first is None: + first = index + last = index + elif (last+1) == index: + last = index + else: + if first == last: + range = str(first) + else: + range = '{}-{}'.format(first, last) + ranges.append(range) + first = index + last = index + if first == last: + range = str(first) + else: + range = '{}-{}'.format(first, last) + ranges.append(range) + return ', '.join(ranges) + + def _process_remainder(self, remainder): + filename = os.path.basename(remainder) + folder_path = os.path.dirname(remainder) + file_base, file_ext = os.path.splitext(filename) + repr_name = file_ext.replace('.', '') + file_info = None + + files = [] + files.append(remainder) + + actions = [] + + data = { + 'files': files, + 'name': file_base, + 'ext': file_ext, + 'representation': repr_name, + 'folder_path': folder_path, + 'is_sequence': False, + 'actions': actions + } + data['file_info'] = self.get_file_info(data) + + self._process_data(data) + + def get_file_info(self, data): + output = None + if data['ext'] == '.mov': + try: + # ffProbe must be in PATH + filepath = data['files'][0] + args = ['ffprobe', '-show_streams', filepath] + p = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + shell=True + ) + datalines=[] + for line in iter(p.stdout.readline, b''): + line = line.decode("utf-8").replace('\r\n', '') + datalines.append(line) + + find_value = 'codec_name' + for line in datalines: + if line.startswith(find_value): + output = line.replace(find_value + '=', '') + break + except Exception as e: + pass + return output + + def _process_data(self, data): + ext = data['ext'] + icon = 'default' + for ico, exts in self.presets['extensions'].items(): + if ext in exts: + icon = ico + break + # Add 's' to icon_name if is sequence (image -> images) + if data['is_sequence']: + icon += 's' + data['icon'] = icon + data['thumb'] = ( + ext in self.presets['extensions']['image_file'] or + ext in self.presets['extensions']['video_file'] + ) + data['prev'] = ext in self.presets['extensions']['video_file'] + + actions = [] + new_is_seq = data['is_sequence'] + + found = False + for item in self.components_list.widgets(): + if data['ext'] != item.in_data['ext']: + continue + if data['folder_path'] != item.in_data['folder_path']: + continue + + ex_is_seq = item.in_data['is_sequence'] + + # If both are single files + if not new_is_seq and not ex_is_seq: + if data['name'] == item.in_data['name']: + found = True + break + paths = data['files'] + paths.extend(item.in_data['files']) + c, r = clique.assemble(paths) + if len(c) == 0: + continue + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + + # If new is sequence and ex is single file + elif new_is_seq and not ex_is_seq: + if data['name'] not in item.in_data['name']: + continue + ex_file = item.in_data['files'][0] + + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + continue + + # If new is single file existing is sequence + elif not new_is_seq and ex_is_seq: + if item.in_data['name'] not in data['name']: + continue + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + + # If both are sequence + else: + if data['name'] != item.in_data['name']: + continue + if data['files'] == item.in_data['files']: + found = True + break + a_name = 'merge' + item.add_action(a_name) + if a_name not in actions: + actions.append(a_name) + + if new_is_seq: + actions.append('split') + + if found is False: + new_repre = self.handle_new_repre_name(data['representation']) + data['representation'] = new_repre + self._add_item(data, actions) + + def handle_new_repre_name(self, repre_name): + renamed = False + for item in self.components_list.widgets(): + if repre_name == item.input_repre.text(): + check_regex = '_\w+$' + result = re.findall(check_regex, repre_name) + next_num = 2 + if len(result) == 1: + repre_name = repre_name.replace(result[0], '') + next_num = int(result[0].replace('_', '')) + next_num += 1 + repre_name = '{}_{}'.format(repre_name, next_num) + renamed = True + break + if renamed: + return self.handle_new_repre_name(repre_name) + return repre_name + + def repre_name_changed(self, in_item, repre_name): + is_valid = True + if repre_name.strip() == '': + in_item.set_repre_name_valid(False) + is_valid = False + else: + for item in self.components_list.widgets(): + if item == in_item: + continue + if item.input_repre.text() == repre_name: + item.set_repre_name_valid(False) + in_item.set_repre_name_valid(False) + is_valid = False + global_valid = is_valid + if is_valid: + in_item.set_repre_name_valid(True) + for item in self.components_list.widgets(): + if item.has_valid_repre: + continue + self.repre_name_changed(item, item.input_repre.text()) + for item in self.components_list.widgets(): + if not item.has_valid_repre: + global_valid = False + break + self.parent_widget.set_valid_repre_names(global_valid) + + def merge_items(self, in_item): + self.parent_widget.working_start() + items = [] + in_paths = in_item.in_data['files'] + paths = in_paths + for item in self.components_list.widgets(): + if item.in_data['files'] == in_paths: + items.append(item) + continue + copy_paths = paths.copy() + copy_paths.extend(item.in_data['files']) + collections, remainders = clique.assemble(copy_paths) + if len(collections) == 1 and len(remainders) == 0: + paths.extend(item.in_data['files']) + items.append(item) + for item in items: + self._remove_item(item) + self._process_paths(paths) + self.parent_widget.working_stop() + + def split_items(self, item): + self.parent_widget.working_start() + paths = item.in_data['files'] + self._remove_item(item) + for path in paths: + self._process_remainder(path) + self.parent_widget.working_stop() + + def collect_data(self): + data = {'representations' : []} + for item in self.components_list.widgets(): + data['representations'].append(item.collect_data()) + return data diff --git a/pype/standalonepublish/widgets/widget_family.py b/pype/standalonepublish/widgets/widget_family.py new file mode 100644 index 0000000000..7259ecdb64 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_family.py @@ -0,0 +1,288 @@ +import os +import sys +import inspect +import json +from collections import namedtuple + +from . import QtWidgets, QtCore +from . import HelpRole, FamilyRole, ExistsRole, PluginRole +from . import FamilyDescriptionWidget + +from pypeapp import config + + +class FamilyWidget(QtWidgets.QWidget): + + stateChanged = QtCore.Signal(bool) + data = dict() + _jobs = dict() + Separator = "---separator---" + + def __init__(self, parent): + super().__init__(parent) + # Store internal states in here + self.state = {"valid": False} + self.parent_widget = parent + + body = QtWidgets.QWidget() + lists = QtWidgets.QWidget() + + container = QtWidgets.QWidget() + + list_families = QtWidgets.QListWidget() + input_asset = QtWidgets.QLineEdit() + input_asset.setEnabled(False) + input_asset.setStyleSheet("color: #BBBBBB;") + input_subset = QtWidgets.QLineEdit() + input_result = QtWidgets.QLineEdit() + input_result.setStyleSheet("color: gray;") + input_result.setEnabled(False) + + # region Menu for default subset names + btn_subset = QtWidgets.QPushButton() + btn_subset.setFixedWidth(18) + btn_subset.setFixedHeight(20) + menu_subset = QtWidgets.QMenu(btn_subset) + btn_subset.setMenu(menu_subset) + + # endregion + name_layout = QtWidgets.QHBoxLayout() + name_layout.addWidget(input_subset) + name_layout.addWidget(btn_subset) + name_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(container) + + header = FamilyDescriptionWidget(self) + layout.addWidget(header) + + layout.addWidget(QtWidgets.QLabel("Family")) + layout.addWidget(list_families) + layout.addWidget(QtWidgets.QLabel("Asset")) + layout.addWidget(input_asset) + layout.addWidget(QtWidgets.QLabel("Subset")) + layout.addLayout(name_layout) + layout.addWidget(input_result) + layout.setContentsMargins(0, 0, 0, 0) + + options = QtWidgets.QWidget() + + layout = QtWidgets.QGridLayout(options) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QHBoxLayout(lists) + layout.addWidget(container) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(body) + layout.addWidget(lists) + layout.addWidget(options, 0, QtCore.Qt.AlignLeft) + layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(body) + + input_subset.textChanged.connect(self.on_data_changed) + input_asset.textChanged.connect(self.on_data_changed) + list_families.currentItemChanged.connect(self.on_selection_changed) + list_families.currentItemChanged.connect(header.set_item) + + self.stateChanged.connect(self._on_state_changed) + + self.input_subset = input_subset + self.menu_subset = menu_subset + self.btn_subset = btn_subset + self.list_families = list_families + self.input_asset = input_asset + self.input_result = input_result + + self.refresh() + + def collect_data(self): + plugin = self.list_families.currentItem().data(PluginRole) + family = plugin.family.rsplit(".", 1)[-1] + data = { + 'family': family, + 'subset': self.input_subset.text() + } + return data + + @property + def db(self): + return self.parent_widget.db + + def change_asset(self, name): + self.input_asset.setText(name) + + def _on_state_changed(self, state): + self.state['valid'] = state + self.parent_widget.set_valid_family(state) + + def _build_menu(self, default_names): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + # Get and destroy the action group + group = self.btn_subset.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + state = any(default_names) + self.btn_subset.setEnabled(state) + if state is False: + return + + # Build new action group + group = QtWidgets.QActionGroup(self.btn_subset) + for name in default_names: + if name == self.Separator: + self.menu_subset.addSeparator() + continue + action = group.addAction(name) + self.menu_subset.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + def _on_action_clicked(self, action): + self.input_subset.setText(action.text()) + + def _on_data_changed(self): + item = self.list_families.currentItem() + subset_name = self.input_subset.text() + asset_name = self.input_asset.text() + + # Get the assets from the database which match with the name + assets_db = self.db.find(filter={"type": "asset"}, projection={"name": 1}) + assets = [asset for asset in assets_db if asset_name in asset["name"]] + if item is None: + return + if assets: + # Get plugin and family + plugin = item.data(PluginRole) + if plugin is None: + return + family = plugin.family.rsplit(".", 1)[-1] + + # Get all subsets of the current asset + asset_ids = [asset["_id"] for asset in assets] + subsets = self.db.find(filter={"type": "subset", + "name": {"$regex": "{}*".format(family), + "$options": "i"}, + "parent": {"$in": asset_ids}}) or [] + + # Get all subsets' their subset name, "Default", "High", "Low" + existed_subsets = [sub["name"].split(family)[-1] + for sub in subsets] + + if plugin.defaults and isinstance(plugin.defaults, list): + defaults = plugin.defaults[:] + [self.Separator] + lowered = [d.lower() for d in plugin.defaults] + for sub in [s for s in existed_subsets + if s.lower() not in lowered]: + defaults.append(sub) + else: + defaults = existed_subsets + + self._build_menu(defaults) + + # Update the result + if subset_name: + subset_name = subset_name[0].upper() + subset_name[1:] + self.input_result.setText("{}{}".format(family, subset_name)) + + item.setData(ExistsRole, True) + self.echo("Ready ..") + else: + self._build_menu([]) + item.setData(ExistsRole, False) + if asset_name != self.parent_widget.NOT_SELECTED: + self.echo("'%s' not found .." % asset_name) + + # Update the valid state + valid = ( + subset_name.strip() != "" and + asset_name.strip() != "" and + item.data(QtCore.Qt.ItemIsEnabled) and + item.data(ExistsRole) + ) + self.stateChanged.emit(valid) + + def on_data_changed(self, *args): + + # Set invalid state until it's reconfirmed to be valid by the + # scheduled callback so any form of creation is held back until + # valid again + self.stateChanged.emit(False) + self.schedule(self._on_data_changed, 500, channel="gui") + + def on_selection_changed(self, *args): + plugin = self.list_families.currentItem().data(PluginRole) + if plugin is None: + return + + if plugin.defaults and isinstance(plugin.defaults, list): + default = plugin.defaults[0] + else: + default = "Default" + + self.input_subset.setText(default) + + self.on_data_changed() + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidently perform Maya commands + whilst trying to name an instance. + + """ + + def refresh(self): + has_families = False + presets = config.get_presets().get('standalone_publish', {}) + + for creator in presets.get('families', {}).values(): + creator = namedtuple("Creator", creator.keys())(*creator.values()) + + label = creator.label or creator.family + item = QtWidgets.QListWidgetItem(label) + item.setData(QtCore.Qt.ItemIsEnabled, True) + item.setData(HelpRole, creator.help or "") + item.setData(FamilyRole, creator.family) + item.setData(PluginRole, creator) + item.setData(ExistsRole, False) + self.list_families.addItem(item) + + has_families = True + + if not has_families: + item = QtWidgets.QListWidgetItem("No registered families") + item.setData(QtCore.Qt.ItemIsEnabled, False) + self.list_families.addItem(item) + + self.list_families.setCurrentItem(self.list_families.item(0)) + + def echo(self, message): + if hasattr(self.parent_widget, 'echo'): + self.parent_widget.echo(message) + + def schedule(self, func, time, channel="default"): + try: + self._jobs[channel].stop() + except (AttributeError, KeyError): + pass + + timer = QtCore.QTimer() + timer.setSingleShot(True) + timer.timeout.connect(func) + timer.start(time) + + self._jobs[channel] = timer diff --git a/pype/standalonepublish/widgets/widget_family_desc.py b/pype/standalonepublish/widgets/widget_family_desc.py new file mode 100644 index 0000000000..e329f28ba6 --- /dev/null +++ b/pype/standalonepublish/widgets/widget_family_desc.py @@ -0,0 +1,101 @@ +import os +import sys +import inspect +import json + +from . import QtWidgets, QtCore, QtGui +from . import HelpRole, FamilyRole, ExistsRole, PluginRole +from . import awesome +from pype.vendor import six +from pype import lib as pypelib + + +class FamilyDescriptionWidget(QtWidgets.QWidget): + """A family description widget. + + Shows a family icon, family name and a help description. + Used in creator header. + + _________________ + | ____ | + | |icon| FAMILY | + | |____| help | + |_________________| + + """ + + SIZE = 35 + + def __init__(self, parent=None): + super(FamilyDescriptionWidget, self).__init__(parent=parent) + + # Header font + font = QtGui.QFont() + font.setBold(True) + font.setPointSize(14) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + icon = QtWidgets.QLabel() + icon.setSizePolicy(QtWidgets.QSizePolicy.Maximum, + QtWidgets.QSizePolicy.Maximum) + + # Add 4 pixel padding to avoid icon being cut off + icon.setFixedWidth(self.SIZE + 4) + icon.setFixedHeight(self.SIZE + 4) + icon.setStyleSheet(""" + QLabel { + padding-right: 5px; + } + """) + + label_layout = QtWidgets.QVBoxLayout() + label_layout.setSpacing(0) + + family = QtWidgets.QLabel("family") + family.setFont(font) + family.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft) + + help = QtWidgets.QLabel("help") + help.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + + label_layout.addWidget(family) + label_layout.addWidget(help) + + layout.addWidget(icon) + layout.addLayout(label_layout) + + self.help = help + self.family = family + self.icon = icon + + def set_item(self, item): + """Update elements to display information of a family item. + + Args: + family (dict): A family item as registered with name, help and icon + + Returns: + None + + """ + if not item: + return + + # Support a font-awesome icon + plugin = item.data(PluginRole) + icon = getattr(plugin, "icon", "info-circle") + assert isinstance(icon, six.string_types) + icon = awesome.icon("fa.{}".format(icon), color="white") + pixmap = icon.pixmap(self.SIZE, self.SIZE) + pixmap = pixmap.scaled(self.SIZE, self.SIZE) + + # Parse a clean line from the Creator's docstring + docstring = plugin.help or "" + + help = docstring.splitlines()[0] if docstring else "" + + self.icon.setPixmap(pixmap) + self.family.setText(item.data(FamilyRole)) + self.help.setText(help) diff --git a/pype/standalonepublish/widgets/widget_shadow.py b/pype/standalonepublish/widgets/widget_shadow.py new file mode 100644 index 0000000000..1bb9cee44b --- /dev/null +++ b/pype/standalonepublish/widgets/widget_shadow.py @@ -0,0 +1,40 @@ +from . import QtWidgets, QtCore, QtGui + + +class ShadowWidget(QtWidgets.QWidget): + def __init__(self, parent): + self.parent_widget = parent + super().__init__(parent) + w = self.parent_widget.frameGeometry().width() + h = self.parent_widget.frameGeometry().height() + self.resize(QtCore.QSize(w, h)) + palette = QtGui.QPalette(self.palette()) + palette.setColor(palette.Background, QtCore.Qt.transparent) + self.setPalette(palette) + self.message = '' + + font = QtGui.QFont() + font.setFamily("DejaVu Sans Condensed") + font.setPointSize(40) + font.setBold(True) + font.setWeight(50) + font.setKerning(True) + self.font = font + + def paintEvent(self, event): + painter = QtGui.QPainter() + painter.begin(self) + painter.setFont(self.font) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.fillRect(event.rect(), QtGui.QBrush(QtGui.QColor(0, 0, 0, 127))) + painter.drawText( + QtCore.QRectF( + 0.0, + 0.0, + self.parent_widget.frameGeometry().width(), + self.parent_widget.frameGeometry().height() + ), + QtCore.Qt.AlignCenter|QtCore.Qt.AlignCenter, + self.message + ) + painter.end()