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 @@
+
+
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 @@
+
+
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()