Merged in 2.0/PYPE-285_standalone_publish (pull request #132)

2.0/PYPE-285 standalone publish

Approved-by: Milan Kolar <milan@orbi.tools>
This commit is contained in:
Milan Kolar 2019-05-12 20:40:25 +00:00
commit 4b977bcea2
48 changed files with 4328 additions and 0 deletions

View file

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

View file

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

View file

@ -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...")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
from . import cli
if __name__ == '__main__':
import sys
sys.exit(cli(sys.argv[1:]))

View file

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

View file

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

View file

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

View file

@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129 129" xmlns:xlink="http://www.w3.org/1999/xlink" enable-background="new 0 0 129 129" fill="#ffffff" width="32px" height="32px">
<g>
<g>
<path d="m119.2,114.3h-109.4c-2.3,0-4.1,1.9-4.1,4.1s1.9,4.1 4.1,4.1h109.5c2.3,0 4.1-1.9 4.1-4.1s-1.9-4.1-4.2-4.1z"/>
<path d="m5.7,78l-.1,19.5c0,1.1 0.4,2.2 1.2,3 0.8,0.8 1.8,1.2 2.9,1.2l19.4-.1c1.1,0 2.1-0.4 2.9-1.2l67-67c1.6-1.6 1.6-4.2 0-5.9l-19.2-19.4c-1.6-1.6-4.2-1.6-5.9-1.77636e-15l-13.4,13.5-53.6,53.5c-0.7,0.8-1.2,1.8-1.2,2.9zm71.2-61.1l13.5,13.5-7.6,7.6-13.5-13.5 7.6-7.6zm-62.9,62.9l49.4-49.4 13.5,13.5-49.4,49.3-13.6,.1 .1-13.5z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 330 330" fill="#ffffff" style="enable-background:new 0 0 330 330;" xml:space="preserve">
<g>
<path d="M165,0C74.019,0,0,74.02,0,165.001C0,255.982,74.019,330,165,330s165-74.018,165-164.999C330,74.02,255.981,0,165,0z
M165,300c-74.44,0-135-60.56-135-134.999C30,90.562,90.56,30,165,30s135,60.562,135,135.001C300,239.44,239.439,300,165,300z"/>
<path d="M164.998,70c-11.026,0-19.996,8.976-19.996,20.009c0,11.023,8.97,19.991,19.996,19.991
c11.026,0,19.996-8.968,19.996-19.991C184.994,78.976,176.024,70,164.998,70z"/>
<path d="M165,140c-8.284,0-15,6.716-15,15v90c0,8.284,6.716,15,15,15c8.284,0,15-6.716,15-15v-90C180,146.716,173.284,140,165,140z
"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 56 56" style="enable-background:new 0 0 56 56;" xml:space="preserve">
<g>
<path d="M28,0C12.561,0,0,12.561,0,28s12.561,28,28,28s28-12.561,28-28S43.439,0,28,0z M28,54C13.663,54,2,42.336,2,28
S13.663,2,28,2s26,11.664,26,26S42.337,54,28,54z"/>
<path d="M40,16H16c-0.553,0-1,0.448-1,1s0.447,1,1,1h24c0.553,0,1-0.448,1-1S40.553,16,40,16z"/>
<path d="M40,27H16c-0.553,0-1,0.448-1,1s0.447,1,1,1h24c0.553,0,1-0.448,1-1S40.553,27,40,27z"/>
<path d="M40,38H16c-0.553,0-1,0.448-1,1s0.447,1,1,1h24c0.553,0,1-0.448,1-1S40.553,38,40,38z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 690 200" fill="#777777" style="enable-background:new 0 0 690 200;" xml:space="preserve">
<g>
<path d="
M30,10
h630 a20,20 0 0 1 20,20
v140 a20,20 0 0 1 -20,20
h-630 a20,20 0 0 1 -20,-20
v-140 a20,20 0 0 1 20,-20 z
M17,37
v126 a20,20 0 0 0 20,20
h616 a20,20 0 0 0 20,-20
v-126 a20,20 0 0 0 -20,-20
h-616 a20,20 0 0 0 -20,20 z"/>
<text style='font-family:Trebuchet MS;font-size:140px;' x="70" y="155" class="small">PREVIEW</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 661 B

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 890 200" fill="#777777" style="enable-background:new 0 0 890 200;" xml:space="preserve">
<g>
<path d="
M30,10
h830 a20,20 0 0 1 20,20
v140 a20,20 0 0 1 -20,20
h-830 a20,20 0 0 1 -20,-20
v-140 a20,20 0 0 1 20,-20 z
M17,37
v126 a20,20 0 0 0 20,20
h816 a20,20 0 0 0 20,-20
v-126 a20,20 0 0 0 -20,-20
h-816 a20,20 0 0 0 -20,20 z"/>
<text style='font-family:Trebuchet MS;font-size:140px;' x="70" y="155" class="small">THUMBNAIL</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 663 B

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="482.428px" height="482.429px" viewBox="0 0 482.428 482.429" fill="#ffffff" style="enable-background:new 0 0 482.428 482.429;"
xml:space="preserve">
<g>
<path d="M381.163,57.799h-75.094C302.323,25.316,274.686,0,241.214,0c-33.471,0-61.104,25.315-64.85,57.799h-75.098
c-30.39,0-55.111,24.728-55.111,55.117v2.828c0,23.223,14.46,43.1,34.83,51.199v260.369c0,30.39,24.724,55.117,55.112,55.117
h210.236c30.389,0,55.111-24.729,55.111-55.117V166.944c20.369-8.1,34.83-27.977,34.83-51.199v-2.828
C436.274,82.527,411.551,57.799,381.163,57.799z M241.214,26.139c19.037,0,34.927,13.645,38.443,31.66h-76.879
C206.293,39.783,222.184,26.139,241.214,26.139z M375.305,427.312c0,15.978-13,28.979-28.973,28.979H136.096
c-15.973,0-28.973-13.002-28.973-28.979V170.861h268.182V427.312z M410.135,115.744c0,15.978-13,28.979-28.973,28.979H101.266
c-15.973,0-28.973-13.001-28.973-28.979v-2.828c0-15.978,13-28.979,28.973-28.979h279.897c15.973,0,28.973,13.001,28.973,28.979
V115.744z"/>
<path d="M171.144,422.863c7.218,0,13.069-5.853,13.069-13.068V262.641c0-7.216-5.852-13.07-13.069-13.07
c-7.217,0-13.069,5.854-13.069,13.07v147.154C158.074,417.012,163.926,422.863,171.144,422.863z"/>
<path d="M241.214,422.863c7.218,0,13.07-5.853,13.07-13.068V262.641c0-7.216-5.854-13.07-13.07-13.07
c-7.217,0-13.069,5.854-13.069,13.07v147.154C228.145,417.012,233.996,422.863,241.214,422.863z"/>
<path d="M311.284,422.863c7.217,0,13.068-5.853,13.068-13.068V262.641c0-7.216-5.852-13.07-13.068-13.07
c-7.219,0-13.07,5.854-13.07,13.07v147.154C298.213,417.012,304.067,422.863,311.284,422.863z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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