diff --git a/pype/api.py b/pype/api.py index 44a31f2626..ce13688d13 100644 --- a/pype/api.py +++ b/pype/api.py @@ -40,7 +40,8 @@ from .lib import ( get_version_from_path, get_last_version_from_path, modified_environ, - add_tool_to_environment + add_tool_to_environment, + get_latest_version ) # Special naming case for subprocess since its a built-in method. @@ -85,5 +86,6 @@ __all__ = [ "modified_environ", "add_tool_to_environment", - "subprocess" + "subprocess", + "get_latest_version" ] diff --git a/pype/hosts/harmony/__init__.py b/pype/hosts/harmony/__init__.py index 3cae695852..d4b7d91fdb 100644 --- a/pype/hosts/harmony/__init__.py +++ b/pype/hosts/harmony/__init__.py @@ -151,27 +151,31 @@ def application_launch(): def export_template(backdrops, nodes, filepath): func = """function func(args) { - // Add an extra node just so a new group can be created. + var temp_node = node.add("Top", "temp_note", "NOTE", 0, 0, 0); var template_group = node.createGroup(temp_node, "temp_group"); node.deleteNode( template_group + "/temp_note" ); - // This will make Node View to focus on the new group. + selection.clearSelection(); + for (var f = 0; f < args[1].length; f++) + { + selection.addNodeToSelection(args[1][f]); + } + + Action.perform("copy()", "Node View"); + selection.clearSelection(); selection.addNodeToSelection(template_group); Action.perform("onActionEnterGroup()", "Node View"); + Action.perform("paste()", "Node View"); // Recreate backdrops in group. for (var i = 0 ; i < args[0].length; i++) { + MessageLog.trace(args[0][i]); Backdrop.addBackdrop(template_group, args[0][i]); }; - // Copy-paste the selected nodes into the new group. - var drag_object = copyPaste.copy(args[1], 1, frame.numberOf, ""); - copyPaste.pasteNewNodes(drag_object, template_group, ""); - - // Select all nodes within group and export as template. Action.perform( "selectAll()", "Node View" ); copyPaste.createTemplateFromSelection(args[2], args[3]); diff --git a/pype/hosts/maya/expected_files.py b/pype/hosts/maya/expected_files.py index a7204cba93..77d55eb1c1 100644 --- a/pype/hosts/maya/expected_files.py +++ b/pype/hosts/maya/expected_files.py @@ -158,6 +158,25 @@ class AExpectedFiles: """To be implemented by renderer class.""" pass + def sanitize_camera_name(self, camera): + """Sanitize camera name. + + Remove Maya illegal characters from camera name. + + Args: + camera (str): Maya camera name. + + Returns: + (str): sanitized camera name + + Example: + >>> sanizite_camera_name('test:camera_01') + test_camera_01 + + """ + sanitized = re.sub('[^0-9a-zA-Z_]+', '_', camera) + return sanitized + def get_renderer_prefix(self): """Return prefix for specific renderer. @@ -252,7 +271,7 @@ class AExpectedFiles: mappings = ( (R_SUBSTITUTE_SCENE_TOKEN, layer_data["sceneName"]), (R_SUBSTITUTE_LAYER_TOKEN, layer_data["layerName"]), - (R_SUBSTITUTE_CAMERA_TOKEN, cam), + (R_SUBSTITUTE_CAMERA_TOKEN, self.sanitize_camera_name(cam)), # this is required to remove unfilled aov token, for example # in Redshift (R_REMOVE_AOV_TOKEN, ""), @@ -287,7 +306,8 @@ class AExpectedFiles: mappings = ( (R_SUBSTITUTE_SCENE_TOKEN, layer_data["sceneName"]), (R_SUBSTITUTE_LAYER_TOKEN, layer_data["layerName"]), - (R_SUBSTITUTE_CAMERA_TOKEN, cam), + (R_SUBSTITUTE_CAMERA_TOKEN, + self.sanitize_camera_name(cam)), (R_SUBSTITUTE_AOV_TOKEN, aov[0]), (R_CLEAN_FRAME_TOKEN, ""), (R_CLEAN_EXT_TOKEN, ""), @@ -314,7 +334,8 @@ class AExpectedFiles: # camera name to AOV to allow per camera AOVs. aov_name = aov[0] if len(layer_data["cameras"]) > 1: - aov_name = "{}_{}".format(aov[0], cam) + aov_name = "{}_{}".format(aov[0], + self.sanitize_camera_name(cam)) aov_file_list[aov_name] = aov_files file_prefix = layer_data["filePrefix"] diff --git a/pype/hosts/nuke/lib.py b/pype/hosts/nuke/lib.py index 72a8836a03..8c0e37b15d 100644 --- a/pype/hosts/nuke/lib.py +++ b/pype/hosts/nuke/lib.py @@ -1445,7 +1445,7 @@ class ExporterReview: anlib.reset_selection() ipn_orig = None for v in [n for n in nuke.allNodes() - if "Viewer" in n.Class()]: + if "Viewer" == n.Class()]: ip = v['input_process'].getValue() ipn = v['input_process_node'].getValue() if "VIEWER_INPUT" not in ipn and ip: diff --git a/pype/hosts/nukestudio/tags.json b/pype/hosts/nukestudio/tags.json new file mode 100644 index 0000000000..56fcfcbce9 --- /dev/null +++ b/pype/hosts/nukestudio/tags.json @@ -0,0 +1,262 @@ +{ + "Hierarchy": { + "editable": "1", + "note": "{folder}/{sequence}/{shot}", + "icon": { + "path": "hierarchy.png" + }, + "metadata": { + "folder": "FOLDER_NAME", + "shot": "{clip}", + "track": "{track}", + "sequence": "{sequence}", + "episode": "EPISODE_NAME", + "root": "{projectroot}" + } + }, + "Source Resolution": { + "editable": "1", + "note": "Use source resolution", + "icon": { + "path": "resolution.png" + }, + "metadata": { + "family": "resolution" + } + }, + "Retiming": { + "editable": "1", + "note": "Clip has retime or TimeWarp effects (or multiple effects stacked on the clip)", + "icon": { + "path": "retiming.png" + }, + "metadata": { + "family": "retiming", + "marginIn": 1, + "marginOut": 1 + } + }, + "Frame start": { + "editable": "1", + "note": "Starting frame for comps. \n\n> Use `value` and add either number or write `source` (if you want to preserve source frame numbering)", + "icon": { + "path": "icons:TagBackground.png" + }, + "metadata": { + "family": "frameStart", + "value": "1001" + } + }, + "[Lenses]": { + "Set lense here": { + "editable": "1", + "note": "Adjust parameters of your lense and then drop to clip. Remember! You can always overwrite on clip", + "icon": { + "path": "lense.png" + }, + "metadata": { + "focalLengthMm": 57 + + } + } + }, + "[Subsets]": { + "Audio": { + "editable": "1", + "note": "Export with Audio", + "icon": { + "path": "volume.png" + }, + "metadata": { + "family": "audio", + "subset": "main" + } + }, + "plateFg": { + "editable": "1", + "note": "Add to publish to \"forground\" subset. Change metadata subset name if different order number", + "icon": { + "path": "z_layer_fg.png" + }, + "metadata": { + "family": "plate", + "subset": "Fg01" + } + }, + "plateBg": { + "editable": "1", + "note": "Add to publish to \"background\" subset. Change metadata subset name if different order number", + "icon": { + "path": "z_layer_bg.png" + }, + "metadata": { + "family": "plate", + "subset": "Bg01" + } + }, + "plateRef": { + "editable": "1", + "note": "Add to publish to \"reference\" subset.", + "icon": { + "path": "icons:Reference.png" + }, + "metadata": { + "family": "plate", + "subset": "Ref" + } + }, + "plateMain": { + "editable": "1", + "note": "Add to publish to \"main\" subset.", + "icon": { + "path": "z_layer_main.png" + }, + "metadata": { + "family": "plate", + "subset": "main" + } + }, + "plateProxy": { + "editable": "1", + "note": "Add to publish to \"proxy\" subset.", + "icon": { + "path": "z_layer_main.png" + }, + "metadata": { + "family": "plate", + "subset": "proxy" + } + }, + "review": { + "editable": "1", + "note": "Upload to Ftrack as review component.", + "icon": { + "path": "review.png" + }, + "metadata": { + "family": "review", + "track": "review" + } + } + }, + "[Handles]": { + "start: add 20 frames": { + "editable": "1", + "note": "Adding frames to start of selected clip", + "icon": { + "path": "3_add_handles_start.png" + }, + "metadata": { + "family": "handles", + "value": "20", + "args": "{'op':'add','where':'start'}" + } + }, + "start: add 10 frames": { + "editable": "1", + "note": "Adding frames to start of selected clip", + "icon": { + "path": "3_add_handles_start.png" + }, + "metadata": { + "family": "handles", + "value": "10", + "args": "{'op':'add','where':'start'}" + } + }, + "start: add 5 frames": { + "editable": "1", + "note": "Adding frames to start of selected clip", + "icon": { + "path": "3_add_handles_start.png" + }, + "metadata": { + "family": "handles", + "value": "5", + "args": "{'op':'add','where':'start'}" + } + }, + "start: add 0 frames": { + "editable": "1", + "note": "Adding frames to start of selected clip", + "icon": { + "path": "3_add_handles_start.png" + }, + "metadata": { + "family": "handles", + "value": "0", + "args": "{'op':'add','where':'start'}" + } + }, + "end: add 20 frames": { + "editable": "1", + "note": "Adding frames to end of selected clip", + "icon": { + "path": "1_add_handles_end.png" + }, + "metadata": { + "family": "handles", + "value": "20", + "args": "{'op':'add','where':'end'}" + } + }, + "end: add 10 frames": { + "editable": "1", + "note": "Adding frames to end of selected clip", + "icon": { + "path": "1_add_handles_end.png" + }, + "metadata": { + "family": "handles", + "value": "10", + "args": "{'op':'add','where':'end'}" + } + }, + "end: add 5 frames": { + "editable": "1", + "note": "Adding frames to end of selected clip", + "icon": { + "path": "1_add_handles_end.png" + }, + "metadata": { + "family": "handles", + "value": "5", + "args": "{'op':'add','where':'end'}" + } + }, + "end: add 0 frames": { + "editable": "1", + "note": "Adding frames to end of selected clip", + "icon": { + "path": "1_add_handles_end.png" + }, + "metadata": { + "family": "handles", + "value": "0", + "args": "{'op':'add','where':'end'}" + } + } + }, + "NukeScript": { + "editable": "1", + "note": "Collecting track items to Nuke scripts.", + "icon": { + "path": "icons:TagNuke.png" + }, + "metadata": { + "family": "nukescript", + "subset": "main" + } + }, + "Comment": { + "editable": "1", + "note": "Comment on a shot.", + "icon": { + "path": "icons:TagComment.png" + }, + "metadata": { + "family": "comment", + "subset": "main" + } + } +} diff --git a/pype/hosts/nukestudio/tags.py b/pype/hosts/nukestudio/tags.py index c97f13d17c..c2b1d0d728 100644 --- a/pype/hosts/nukestudio/tags.py +++ b/pype/hosts/nukestudio/tags.py @@ -1,16 +1,22 @@ import re import os +import json import hiero -from pype.api import ( - config, - Logger -) +from pype.api import Logger from avalon import io log = Logger().get_logger(__name__, "nukestudio") +def tag_data(): + current_dir = os.path.dirname(__file__) + json_path = os.path.join(current_dir, "tags.json") + with open(json_path, "r") as json_stream: + data = json.load(json_stream) + return data + + def create_tag(key, value): """ Creating Tag object. @@ -58,13 +64,9 @@ def add_tags_from_presets(): return log.debug("Setting default tags on project: {}".format(project.name())) - - # get all presets - presets = config.get_presets() - # get nukestudio tag.json from presets - nks_pres = presets["nukestudio"] - nks_pres_tags = nks_pres.get("tags", None) + # get nukestudio tags.json + nks_pres_tags = tag_data() # Get project task types. tasks = io.find_one({"type": "project"})["config"]["tasks"] diff --git a/pype/lib.py b/pype/lib.py index b62840f3a4..f99cd73e09 100644 --- a/pype/lib.py +++ b/pype/lib.py @@ -1379,3 +1379,40 @@ def ffprobe_streams(path_to_file): popen_output = popen.communicate()[0] log.debug("FFprobe output: {}".format(popen_output)) return json.loads(popen_output)["streams"] + + +def get_latest_version(asset_name, subset_name): + """Retrieve latest version from `asset_name`, and `subset_name`. + + Args: + asset_name (str): Name of asset. + subset_name (str): Name of subset. + """ + # Get asset + asset_name = io.find_one( + {"type": "asset", "name": asset_name}, projection={"name": True} + ) + + subset = io.find_one( + {"type": "subset", "name": subset_name, "parent": asset_name["_id"]}, + projection={"_id": True, "name": True}, + ) + + # Check if subsets actually exists. + assert subset, "No subsets found." + + # Get version + version_projection = { + "name": True, + "parent": True, + } + + version = io.find_one( + {"type": "version", "parent": subset["_id"]}, + projection=version_projection, + sort=[("name", -1)], + ) + + assert version, "No version found, this is a bug" + + return version diff --git a/pype/modules/ftrack/actions/action_delete_old_versions.py b/pype/modules/ftrack/actions/action_delete_old_versions.py index 46652b136a..6a4c5a0cae 100644 --- a/pype/modules/ftrack/actions/action_delete_old_versions.py +++ b/pype/modules/ftrack/actions/action_delete_old_versions.py @@ -105,11 +105,34 @@ class DeleteOldVersions(BaseAction): "value": False }) + items.append(self.splitter_item) + + items.append({ + "type": "label", + "value": ( + "This will NOT delete any files and only return the " + "total size of the files." + ) + }) + items.append({ + "type": "boolean", + "name": "only_calculate", + "label": "Only calculate size of files.", + "value": False + }) + return { "items": items, "title": self.inteface_title } + def sizeof_fmt(self, num, suffix='B'): + for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: + if abs(num) < 1024.0: + return "%3.1f%s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f%s%s" % (num, 'Yi', suffix) + def launch(self, session, entities, event): values = event["data"].get("values") if not values: @@ -117,6 +140,7 @@ class DeleteOldVersions(BaseAction): versions_count = int(values["last_versions_count"]) force_to_remove = values["force_delete_publish_folder"] + only_calculate = values["only_calculate"] _val1 = "OFF" if force_to_remove: @@ -318,10 +342,29 @@ class DeleteOldVersions(BaseAction): "Folder does not exist. Deleting it's files skipped: {}" ).format(paths_msg)) + # Size of files. + size = 0 + + if only_calculate: + if force_to_remove: + size = self.delete_whole_dir_paths( + dir_paths.values(), delete=False + ) + else: + size = self.delete_only_repre_files( + dir_paths, file_paths_by_dir, delete=False + ) + + msg = "Total size of files: " + self.sizeof_fmt(size) + + self.log.warning(msg) + + return {"success": True, "message": msg} + if force_to_remove: - self.delete_whole_dir_paths(dir_paths.values()) + size = self.delete_whole_dir_paths(dir_paths.values()) else: - self.delete_only_repre_files(dir_paths, file_paths_by_dir) + size = self.delete_only_repre_files(dir_paths, file_paths_by_dir) mongo_changes_bulk = [] for version in versions: @@ -383,17 +426,31 @@ class DeleteOldVersions(BaseAction): "message": msg } - return True + msg = "Total size of files deleted: " + self.sizeof_fmt(size) + + self.log.warning(msg) + + return {"success": True, "message": msg} + + def delete_whole_dir_paths(self, dir_paths, delete=True): + size = 0 - def delete_whole_dir_paths(self, dir_paths): for dir_path in dir_paths: # Delete all files and fodlers in dir path for root, dirs, files in os.walk(dir_path, topdown=False): for name in files: - os.remove(os.path.join(root, name)) + file_path = os.path.join(root, name) + size += os.path.getsize(file_path) + if delete: + os.remove(file_path) + self.log.debug("Removed file: {}".format(file_path)) for name in dirs: - os.rmdir(os.path.join(root, name)) + if delete: + os.rmdir(os.path.join(root, name)) + + if not delete: + continue # Delete even the folder and it's parents folders if they are empty while True: @@ -406,7 +463,11 @@ class DeleteOldVersions(BaseAction): os.rmdir(os.path.join(dir_path)) - def delete_only_repre_files(self, dir_paths, file_paths): + return size + + def delete_only_repre_files(self, dir_paths, file_paths, delete=True): + size = 0 + for dir_id, dir_path in dir_paths.items(): dir_files = os.listdir(dir_path) collections, remainders = clique.assemble(dir_files) @@ -420,8 +481,13 @@ class DeleteOldVersions(BaseAction): "File was not found: {}".format(file_path) ) continue - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) + + size += os.path.getsize(file_path) + + if delete: + os.remove(file_path) + self.log.debug("Removed file: {}".format(file_path)) + remainders.remove(file_path_base) continue @@ -440,21 +506,34 @@ class DeleteOldVersions(BaseAction): final_col.head = os.path.join(dir_path, final_col.head) for _file_path in final_col: if os.path.exists(_file_path): - os.remove(_file_path) + + size += os.path.getsize(_file_path) + + if delete: + os.remove(_file_path) + self.log.debug( + "Removed file: {}".format(_file_path) + ) + _seq_path = final_col.format("{head}{padding}{tail}") self.log.debug("Removed files: {}".format(_seq_path)) collections.remove(final_col) elif os.path.exists(file_path): - os.remove(file_path) - self.log.debug("Removed file: {}".format(file_path)) + size += os.path.getsize(file_path) + if delete: + os.remove(file_path) + self.log.debug("Removed file: {}".format(file_path)) else: self.log.warning( "File was not found: {}".format(file_path) ) # Delete as much as possible parent folders + if not delete: + return size + for dir_path in dir_paths.values(): while True: if not os.path.exists(dir_path): @@ -467,6 +546,8 @@ class DeleteOldVersions(BaseAction): self.log.debug("Removed folder: {}".format(dir_path)) os.rmdir(dir_path) + return size + def path_from_represenation(self, representation, anatomy): try: template = representation["data"]["template"] diff --git a/pype/modules/ftrack/actions/action_delivery.py b/pype/modules/ftrack/actions/action_delivery.py index d4b86d1278..231aebdf7a 100644 --- a/pype/modules/ftrack/actions/action_delivery.py +++ b/pype/modules/ftrack/actions/action_delivery.py @@ -81,13 +81,15 @@ class Delivery(BaseAction): anatomy = Anatomy(project_name) new_anatomies = [] first = None - for key in (anatomy.templates.get("delivery") or {}): - new_anatomies.append({ - "label": key, - "value": key - }) - if first is None: - first = key + for key, template in (anatomy.templates.get("delivery") or {}).items(): + # Use only keys with `{root}` or `{root[*]}` in value + if isinstance(template, str) and "{root" in template: + new_anatomies.append({ + "label": key, + "value": key + }) + if first is None: + first = key skipped = False # Add message if there are any common components @@ -293,6 +295,20 @@ class Delivery(BaseAction): repres_to_deliver.append(repre) anatomy = Anatomy(project_name) + + format_dict = {} + if location_path: + location_path = location_path.replace("\\", "/") + root_names = anatomy.root_names_from_templates( + anatomy.templates["delivery"] + ) + if root_names is None: + format_dict["root"] = location_path + else: + format_dict["root"] = {} + for name in root_names: + format_dict["root"][name] = location_path + for repre in repres_to_deliver: # Get destination repre path anatomy_data = copy.deepcopy(repre["context"]) @@ -339,25 +355,33 @@ class Delivery(BaseAction): repre_path = self.path_from_represenation(repre, anatomy) # TODO add backup solution where root of path from component # is repalced with root - if not frame: - self.process_single_file( - repre_path, anatomy, anatomy_name, anatomy_data - ) + args = ( + repre_path, + anatomy, + anatomy_name, + anatomy_data, + format_dict + ) + if not frame: + self.process_single_file(*args) else: - self.process_sequence( - repre_path, anatomy, anatomy_name, anatomy_data - ) + self.process_sequence(*args) self.db_con.uninstall() return self.report() def process_single_file( - self, repre_path, anatomy, anatomy_name, anatomy_data + self, repre_path, anatomy, anatomy_name, anatomy_data, format_dict ): anatomy_filled = anatomy.format(anatomy_data) - delivery_path = anatomy_filled["delivery"][anatomy_name] + if format_dict: + template_result = anatomy_filled["delivery"][anatomy_name] + delivery_path = template_result.rootless.format(**format_dict) + else: + delivery_path = anatomy_filled["delivery"][anatomy_name] + delivery_folder = os.path.dirname(delivery_path) if not os.path.exists(delivery_folder): os.makedirs(delivery_folder) @@ -365,7 +389,7 @@ class Delivery(BaseAction): self.copy_file(repre_path, delivery_path) def process_sequence( - self, repre_path, anatomy, anatomy_name, anatomy_data + self, repre_path, anatomy, anatomy_name, anatomy_data, format_dict ): dir_path, file_name = os.path.split(str(repre_path)) @@ -408,8 +432,12 @@ class Delivery(BaseAction): anatomy_data["frame"] = frame_indicator anatomy_filled = anatomy.format(anatomy_data) - delivery_path = anatomy_filled["delivery"][anatomy_name] - print(delivery_path) + if format_dict: + template_result = anatomy_filled["delivery"][anatomy_name] + delivery_path = template_result.rootless.format(**format_dict) + else: + delivery_path = anatomy_filled["delivery"][anatomy_name] + delivery_folder = os.path.dirname(delivery_path) dst_head, dst_tail = delivery_path.split(frame_indicator) dst_padding = src_collection.padding diff --git a/pype/modules/ftrack/lib/avalon_sync.py b/pype/modules/ftrack/lib/avalon_sync.py index f78e617f3a..4bab1676d4 100644 --- a/pype/modules/ftrack/lib/avalon_sync.py +++ b/pype/modules/ftrack/lib/avalon_sync.py @@ -81,11 +81,12 @@ def check_regex(name, entity_type, in_schema=None, schema_patterns=None): def get_pype_attr(session, split_hierarchical=True): custom_attributes = [] hier_custom_attributes = [] + # TODO remove deprecated "avalon" group from query cust_attrs_query = ( "select id, entity_type, object_type_id, is_hierarchical, default" " from CustomAttributeConfiguration" - " where group.name = \"{}\"" - ).format(CUST_ATTR_GROUP) + " where group.name in (\"avalon\", \"pype\")" + ) all_avalon_attr = session.query(cust_attrs_query).all() for cust_attr in all_avalon_attr: if split_hierarchical and cust_attr["is_hierarchical"]: diff --git a/pype/modules/logging/gui/app.py b/pype/modules/logging/gui/app.py index 99b0b230a9..c0e180c8a1 100644 --- a/pype/modules/logging/gui/app.py +++ b/pype/modules/logging/gui/app.py @@ -8,9 +8,9 @@ class LogsWindow(QtWidgets.QWidget): super(LogsWindow, self).__init__(parent) self.setStyleSheet(style.load_stylesheet()) - self.resize(1200, 800) - logs_widget = LogsWidget(parent=self) + self.resize(1400, 800) log_detail = OutputWidget(parent=self) + logs_widget = LogsWidget(log_detail, parent=self) main_layout = QtWidgets.QHBoxLayout() @@ -18,8 +18,6 @@ class LogsWindow(QtWidgets.QWidget): log_splitter.setOrientation(QtCore.Qt.Horizontal) log_splitter.addWidget(logs_widget) log_splitter.addWidget(log_detail) - log_splitter.setStretchFactor(0, 65) - log_splitter.setStretchFactor(1, 35) main_layout.addWidget(log_splitter) @@ -28,10 +26,3 @@ class LogsWindow(QtWidgets.QWidget): self.setLayout(main_layout) self.setWindowTitle("Logs") - - self.logs_widget.active_changed.connect(self.on_selection_changed) - - def on_selection_changed(self): - index = self.logs_widget.selected_log() - node = index.data(self.logs_widget.model.NodeRole) - self.log_detail.set_detail(node) diff --git a/pype/modules/logging/gui/lib.py b/pype/modules/logging/gui/lib.py deleted file mode 100644 index 85782e071e..0000000000 --- a/pype/modules/logging/gui/lib.py +++ /dev/null @@ -1,94 +0,0 @@ -import contextlib -from Qt import QtCore - - -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 - - -@contextlib.contextmanager -def preserve_states( - tree_view, column=0, role=None, - preserve_expanded=True, preserve_selection=True, - expanded_role=QtCore.Qt.DisplayRole, selection_role=QtCore.Qt.DisplayRole - -): - """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 - - """ - # When `role` is set then override both expanded and selection roles - if role: - expanded_role = role - selection_role = role - - model = tree_view.model() - selection_model = tree_view.selectionModel() - flags = selection_model.Select | selection_model.Rows - - expanded = set() - - if preserve_expanded: - for index in _iter_model_rows( - model, column=column, include_root=False - ): - if tree_view.isExpanded(index): - value = index.data(expanded_role) - expanded.add(value) - - selected = None - - if preserve_selection: - selected_rows = selection_model.selectedRows() - if selected_rows: - selected = set(row.data(selection_role) for row in selected_rows) - - try: - yield - finally: - if expanded: - for index in _iter_model_rows( - model, column=0, include_root=False - ): - value = index.data(expanded_role) - is_expanded = value in expanded - # skip if new index was created meanwhile - if is_expanded is None: - continue - tree_view.setExpanded(index, is_expanded) - - if selected: - # 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(selection_role) - state = value in selected - if state: - tree_view.scrollTo(index) # Ensure item is visible - selection_model.select(index, flags) diff --git a/pype/modules/logging/gui/models.py b/pype/modules/logging/gui/models.py index ce1fa236a9..ae2666f501 100644 --- a/pype/modules/logging/gui/models.py +++ b/pype/modules/logging/gui/models.py @@ -1,21 +1,20 @@ import collections -from Qt import QtCore +from Qt import QtCore, QtGui from pype.api import Logger from pypeapp.lib.log import _bootstrap_mongo_log, LOG_COLLECTION_NAME log = Logger().get_logger("LogModel", "LoggingModule") -class LogModel(QtCore.QAbstractItemModel): - COLUMNS = [ +class LogModel(QtGui.QStandardItemModel): + COLUMNS = ( "process_name", "hostname", "hostip", "username", "system_name", "started" - ] - + ) colums_mapping = { "process_name": "Process Name", "process_id": "Process Id", @@ -25,30 +24,53 @@ class LogModel(QtCore.QAbstractItemModel): "system_name": "System name", "started": "Started at" } - process_keys = [ + process_keys = ( "process_id", "hostname", "hostip", "username", "system_name", "process_name" - ] - log_keys = [ + ) + log_keys = ( "timestamp", "level", "thread", "threadName", "message", "loggerName", "fileName", "module", "method", "lineNumber" - ] + ) default_value = "- Not set -" - NodeRole = QtCore.Qt.UserRole + 1 + + ROLE_LOGS = QtCore.Qt.UserRole + 2 + ROLE_PROCESS_ID = QtCore.Qt.UserRole + 3 def __init__(self, parent=None): super(LogModel, self).__init__(parent) - self._root_node = Node() + self.log_by_process = None self.dbcon = None + # Crash if connection is not possible to skip this module database = _bootstrap_mongo_log() if LOG_COLLECTION_NAME in database.list_collection_names(): self.dbcon = database[LOG_COLLECTION_NAME] - def add_log(self, log): - node = Node(log) - self._root_node.add_child(node) + def headerData(self, section, orientation, role): + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section < len(self.COLUMNS): + key = self.COLUMNS[section] + return self.colums_mapping.get(key, key) + + super(LogModel, self).headerData(section, orientation, role) + + def add_process_logs(self, process_logs): + items = [] + first_item = True + for key in self.COLUMNS: + display_value = str(process_logs[key]) + item = QtGui.QStandardItem(display_value) + if first_item: + first_item = False + item.setData(process_logs["_logs"], self.ROLE_LOGS) + item.setData(process_logs["process_id"], self.ROLE_PROCESS_ID) + items.append(item) + self.appendRow(items) def refresh(self): self.log_by_process = collections.defaultdict(list) @@ -65,16 +87,13 @@ class LogModel(QtCore.QAbstractItemModel): continue if process_id not in self.process_info: - proc_dict = {} + proc_dict = {"_logs": []} for key in self.process_keys: proc_dict[key] = ( item.get(key) or self.default_value ) self.process_info[process_id] = proc_dict - if "_logs" not in self.process_info[process_id]: - self.process_info[process_id]["_logs"] = [] - log_item = {} for key in self.log_keys: log_item[key] = item.get(key) or self.default_value @@ -89,114 +108,29 @@ class LogModel(QtCore.QAbstractItemModel): item["_logs"], key=lambda item: item["timestamp"] ) item["started"] = item["_logs"][0]["timestamp"] - self.add_log(item) + self.add_process_logs(item) self.endResetModel() - 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() +class LogsFilterProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super(LogsFilterProxy, self).__init__(*args, **kwargs) + self.col_usernames = None + self.filter_usernames = set() - key = self.COLUMNS[column] - if key == "started": - return str(node.get(key, None)) - return node.get(key, None) + def update_users_filter(self, users): + self.filter_usernames = set() + for user in users or tuple(): + self.filter_usernames.add(user) + self.invalidateFilter() - if role == self.NodeRole: - return index.internalPointer() - - def index(self, row, column, parent): - """Return index for row/column under parent""" - - if not parent.isValid(): - parent_node = self._root_node - else: - parent_node = parent.internalPointer() - - child_item = parent_node.child(row) - if child_item: - return self.createIndex(row, column, child_item) - return QtCore.QModelIndex() - - def rowCount(self, parent): - node = self._root_node - if parent.isValid(): - node = parent.internalPointer() - return node.childCount() - - def columnCount(self, parent): - return len(self.COLUMNS) - - def parent(self, index): - return QtCore.QModelIndex() - - def headerData(self, section, orientation, role): - if role == QtCore.Qt.DisplayRole: - if section < len(self.COLUMNS): - key = self.COLUMNS[section] - return self.colums_mapping.get(key, key) - - super(LogModel, self).headerData(section, orientation, role) - - def flags(self, index): - return (QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) - - def clear(self): - self.beginResetModel() - self._root_node = Node() - self.endResetModel() - - -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) + def filterAcceptsRow(self, source_row, source_parent): + if self.col_usernames is not None: + index = self.sourceModel().index( + source_row, self.col_usernames, source_parent + ) + user = index.data(QtCore.Qt.DisplayRole) + if user not in self.filter_usernames: + return False + return True diff --git a/pype/modules/logging/gui/widgets.py b/pype/modules/logging/gui/widgets.py index cf20066397..cd0df283bf 100644 --- a/pype/modules/logging/gui/widgets.py +++ b/pype/modules/logging/gui/widgets.py @@ -1,6 +1,6 @@ -from Qt import QtCore, QtWidgets, QtGui -from PyQt5.QtCore import QVariant -from .models import LogModel +from Qt import QtCore, QtWidgets +from avalon.vendor import qtawesome +from .models import LogModel, LogsFilterProxy class SearchComboBox(QtWidgets.QComboBox): @@ -50,37 +50,6 @@ class SearchComboBox(QtWidgets.QComboBox): return text -class CheckableComboBox2(QtWidgets.QComboBox): - def __init__(self, parent=None): - super(CheckableComboBox, self).__init__(parent) - self.view().pressed.connect(self.handleItemPressed) - self._changed = False - - def handleItemPressed(self, index): - item = self.model().itemFromIndex(index) - if item.checkState() == QtCore.Qt.Checked: - item.setCheckState(QtCore.Qt.Unchecked) - else: - item.setCheckState(QtCore.Qt.Checked) - self._changed = True - - def hidePopup(self): - if not self._changed: - super(CheckableComboBox, self).hidePopup() - self._changed = False - - def itemChecked(self, index): - item = self.model().item(index, self.modelColumn()) - return item.checkState() == QtCore.Qt.Checked - - def setItemChecked(self, index, checked=True): - item = self.model().item(index, self.modelColumn()) - if checked: - item.setCheckState(QtCore.Qt.Checked) - else: - item.setCheckState(QtCore.Qt.Unchecked) - - class SelectableMenu(QtWidgets.QMenu): selection_changed = QtCore.Signal() @@ -137,144 +106,108 @@ class CustomCombo(QtWidgets.QWidget): yield action -class CheckableComboBox(QtWidgets.QComboBox): - def __init__(self, parent=None): - super(CheckableComboBox, self).__init__(parent) - - view = QtWidgets.QTreeView() - view.header().hide() - view.setRootIsDecorated(False) - - model = QtGui.QStandardItemModel() - - view.pressed.connect(self.handleItemPressed) - self._changed = False - - self.setView(view) - self.setModel(model) - - self.view = view - self.model = model - - def handleItemPressed(self, index): - item = self.model.itemFromIndex(index) - if item.checkState() == QtCore.Qt.Checked: - item.setCheckState(QtCore.Qt.Unchecked) - else: - item.setCheckState(QtCore.Qt.Checked) - self._changed = True - - def hidePopup(self): - if not self._changed: - super(CheckableComboBox, self).hidePopup() - self._changed = False - - def itemChecked(self, index): - item = self.model.item(index, self.modelColumn()) - return item.checkState() == QtCore.Qt.Checked - - def setItemChecked(self, index, checked=True): - item = self.model.item(index, self.modelColumn()) - if checked: - item.setCheckState(QtCore.Qt.Checked) - else: - item.setCheckState(QtCore.Qt.Unchecked) - - def addItems(self, items): - for text, checked in items: - text_item = QtGui.QStandardItem(text) - checked_item = QtGui.QStandardItem() - checked_item.setData(QVariant(checked), QtCore.Qt.CheckStateRole) - self.model.appendRow([text_item, checked_item]) - - class LogsWidget(QtWidgets.QWidget): """A widget that lists the published subsets for an asset""" - active_changed = QtCore.Signal() - - def __init__(self, parent=None): + def __init__(self, detail_widget, parent=None): super(LogsWidget, self).__init__(parent=parent) model = LogModel() + proxy_model = LogsFilterProxy() + proxy_model.setSourceModel(model) + proxy_model.col_usernames = model.COLUMNS.index("username") filter_layout = QtWidgets.QHBoxLayout() # user_filter = SearchComboBox(self, "Users") user_filter = CustomCombo("Users", self) - users = model.dbcon.distinct("user") + users = model.dbcon.distinct("username") user_filter.populate(users) - user_filter.selection_changed.connect(self.user_changed) + user_filter.selection_changed.connect(self._user_changed) + + proxy_model.update_users_filter(users) level_filter = CustomCombo("Levels", self) # levels = [(level, True) for level in model.dbcon.distinct("level")] levels = model.dbcon.distinct("level") level_filter.addItems(levels) + level_filter.selection_changed.connect(self._level_changed) - date_from_label = QtWidgets.QLabel("From:") - date_filter_from = QtWidgets.QDateTimeEdit() + detail_widget.update_level_filter(levels) - date_from_layout = QtWidgets.QVBoxLayout() - date_from_layout.addWidget(date_from_label) - date_from_layout.addWidget(date_filter_from) + spacer = QtWidgets.QWidget() - # now = datetime.datetime.now() - # QtCore.QDateTime( - # now.year, - # now.month, - # now.day, - # now.hour, - # now.minute, - # second=0, - # msec=0, - # timeSpec=0 - # ) - date_to_label = QtWidgets.QLabel("To:") - date_filter_to = QtWidgets.QDateTimeEdit() - - date_to_layout = QtWidgets.QVBoxLayout() - date_to_layout.addWidget(date_to_label) - date_to_layout.addWidget(date_filter_to) + icon = qtawesome.icon("fa.refresh", color="white") + refresh_btn = QtWidgets.QPushButton(icon, "") filter_layout.addWidget(user_filter) filter_layout.addWidget(level_filter) - - filter_layout.addLayout(date_from_layout) - filter_layout.addLayout(date_to_layout) + filter_layout.addWidget(spacer, 1) + filter_layout.addWidget(refresh_btn) view = QtWidgets.QTreeView(self) view.setAllColumnsShowFocus(True) + view.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addLayout(filter_layout) layout.addWidget(view) + view.setModel(proxy_model) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) view.setSortingEnabled(True) view.sortByColumn( model.COLUMNS.index("started"), - QtCore.Qt.AscendingOrder + QtCore.Qt.DescendingOrder ) - view.setModel(model) - view.pressed.connect(self._on_activated) - # prepare - model.refresh() + view.selectionModel().selectionChanged.connect(self._on_index_change) + refresh_btn.clicked.connect(self._on_refresh_clicked) # Store to memory self.model = model + self.proxy_model = proxy_model self.view = view self.user_filter = user_filter self.level_filter = level_filter - def _on_activated(self, *args, **kwargs): - self.active_changed.emit() + self.detail_widget = detail_widget + self.refresh_btn = refresh_btn - def user_changed(self): + # prepare + self.refresh() + + def refresh(self): + self.model.refresh() + self.detail_widget.refresh() + + def _on_refresh_clicked(self): + self.refresh() + + def _on_index_change(self, to_index, from_index): + index = self._selected_log() + if index: + logs = index.data(self.model.ROLE_LOGS) + else: + logs = [] + self.detail_widget.set_detail(logs) + + def _user_changed(self): + checked_values = set() for action in self.user_filter.items(): - print(action) + if action.isChecked(): + checked_values.add(action.text()) + self.proxy_model.update_users_filter(checked_values) + + def _level_changed(self): + checked_values = set() + for action in self.level_filter.items(): + if action.isChecked(): + checked_values.add(action.text()) + self.detail_widget.update_level_filter(checked_values) def on_context_menu(self, point): # TODO will be any actions? it's ready @@ -288,7 +221,7 @@ class LogsWidget(QtWidgets.QWidget): selection = self.view.selectionModel() rows = selection.selectedRows(column=0) - def selected_log(self): + def _selected_log(self): selection = self.view.selectionModel() rows = selection.selectedRows(column=0) if len(rows) == 1: @@ -300,22 +233,55 @@ class OutputWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(OutputWidget, self).__init__(parent=parent) layout = QtWidgets.QVBoxLayout(self) + + show_timecode_checkbox = QtWidgets.QCheckBox("Show timestamp") + output_text = QtWidgets.QTextEdit() output_text.setReadOnly(True) # output_text.setLineWrapMode(QtWidgets.QTextEdit.FixedPixelWidth) + layout.addWidget(show_timecode_checkbox) layout.addWidget(output_text) + show_timecode_checkbox.stateChanged.connect( + self.on_show_timecode_change + ) self.setLayout(layout) self.output_text = output_text + self.show_timecode_checkbox = show_timecode_checkbox + + self.refresh() + + def refresh(self): + self.set_detail() + + def show_timecode(self): + return self.show_timecode_checkbox.isChecked() + + def on_show_timecode_change(self): + self.set_detail(self.las_logs) + + def update_level_filter(self, levels): + self.filter_levels = set() + for level in levels or tuple(): + self.filter_levels.add(level.lower()) + + self.set_detail(self.las_logs) def add_line(self, line): self.output_text.append(line) - def set_detail(self, node): + def set_detail(self, logs=None): + self.las_logs = logs self.output_text.clear() - for log in node["_logs"]: + if not logs: + return + + show_timecode = self.show_timecode() + for log in logs: level = log["level"].lower() + if level not in self.filter_levels: + continue line_f = "{message}" if level == "debug": @@ -353,66 +319,13 @@ class OutputWidget(QtWidgets.QWidget): line = line_f.format(**log) + if show_timecode: + timestamp = log["timestamp"] + line = timestamp.strftime("%Y-%d-%m %H:%M:%S") + " " + line + self.add_line(line) if not exc: continue for _line in exc["stackTrace"].split("\n"): self.add_line(_line) - - -class LogDetailWidget(QtWidgets.QWidget): - """A Widget that display information about a specific version""" - data_rows = [ - "user", - "message", - "level", - "logname", - "method", - "module", - "fileName", - "lineNumber", - "host", - "timestamp" - ] - - html_text = u""" -

{user} - {timestamp}

-User
{user}
-
Level
{level}
-
Message
{message}
-
Log Name
{logname}

Method
{method}
-
File
{fileName}
-
Line
{lineNumber}
-
Host
{host}
-
Timestamp
{timestamp}
-""" - - def __init__(self, parent=None): - super(LogDetailWidget, self).__init__(parent=parent) - - layout = QtWidgets.QVBoxLayout(self) - - label = QtWidgets.QLabel("Detail") - detail_widget = QtWidgets.QTextEdit() - detail_widget.setReadOnly(True) - layout.addWidget(label) - layout.addWidget(detail_widget) - - self.detail_widget = detail_widget - - self.setEnabled(True) - - self.set_detail(None) - - def set_detail(self, detail_data): - if not detail_data: - self.detail_widget.setText("") - return - - data = dict() - for row in self.data_rows: - value = detail_data.get(row) or "< Not set >" - data[row] = value - - self.detail_widget.setHtml(self.html_text.format(**data)) diff --git a/pype/plugins/celaction/publish/collect_render_path.py b/pype/plugins/celaction/publish/collect_render_path.py index a3918a52b6..9cbb0e4880 100644 --- a/pype/plugins/celaction/publish/collect_render_path.py +++ b/pype/plugins/celaction/publish/collect_render_path.py @@ -12,7 +12,7 @@ class CollectRenderPath(pyblish.api.InstancePlugin): # Presets anatomy_render_key = None - anatomy_publish_render_key = None + publish_render_metadata = None def process(self, instance): anatomy = instance.context.data["anatomy"] @@ -28,7 +28,7 @@ class CollectRenderPath(pyblish.api.InstancePlugin): # get anatomy rendering keys anatomy_render_key = self.anatomy_render_key or "render" - anatomy_publish_render_key = self.anatomy_publish_render_key or "render" + publish_render_metadata = self.publish_render_metadata or "render" # get folder and path for rendering images from celaction render_dir = anatomy_filled[anatomy_render_key]["folder"] @@ -46,8 +46,11 @@ class CollectRenderPath(pyblish.api.InstancePlugin): instance.data["path"] = render_path # get anatomy for published renders folder path - if anatomy_filled.get(anatomy_publish_render_key): - instance.data["publishRenderFolder"] = anatomy_filled[ - anatomy_publish_render_key]["folder"] + if anatomy_filled.get(publish_render_metadata): + instance.data["publishRenderMetadataFolder"] = anatomy_filled[ + publish_render_metadata]["folder"] + self.log.info("Metadata render path: `{}`".format( + instance.data["publishRenderMetadataFolder"] + )) self.log.info(f"Render output path set to: `{render_path}`") diff --git a/pype/plugins/celaction/publish/integrate_version_up.py b/pype/plugins/celaction/publish/integrate_version_up.py index 1822ceabcb..e15c5d5bf6 100644 --- a/pype/plugins/celaction/publish/integrate_version_up.py +++ b/pype/plugins/celaction/publish/integrate_version_up.py @@ -1,5 +1,5 @@ import shutil -import re +import pype import pyblish.api @@ -12,57 +12,9 @@ class VersionUpScene(pyblish.api.ContextPlugin): def process(self, context): current_file = context.data.get('currentFile') - v_up = get_version_up(current_file) + v_up = pype.lib.version_up(current_file) self.log.debug('Current file is: {}'.format(current_file)) self.log.debug('Version up: {}'.format(v_up)) shutil.copy2(current_file, v_up) self.log.info('Scene saved into new version: {}'.format(v_up)) - - -def version_get(string, prefix, suffix=None): - """Extract version information from filenames used by DD (and Weta, apparently) - These are _v# or /v# or .v# where v is a prefix string, in our case - we use "v" for render version and "c" for camera track version. - See the version.py and camera.py plugins for usage.""" - - if string is None: - raise ValueError("Empty version string - no match") - - regex = r"[/_.]{}\d+".format(prefix) - matches = re.findall(regex, string, re.IGNORECASE) - if not len(matches): - msg = f"No `_{prefix}#` found in `{string}`" - raise ValueError(msg) - return (matches[-1:][0][1], re.search(r"\d+", matches[-1:][0]).group()) - - -def version_set(string, prefix, oldintval, newintval): - """Changes version information from filenames used by DD (and Weta, apparently) - These are _v# or /v# or .v# where v is a prefix string, in our case - we use "v" for render version and "c" for camera track version. - See the version.py and camera.py plugins for usage.""" - - regex = r"[/_.]{}\d+".format(prefix) - matches = re.findall(regex, string, re.IGNORECASE) - if not len(matches): - return "" - - # Filter to retain only version strings with matching numbers - matches = filter(lambda s: int(s[2:]) == oldintval, matches) - - # Replace all version strings with matching numbers - for match in matches: - # use expression instead of expr so 0 prefix does not make octal - fmt = "%%(#)0%dd" % (len(match) - 2) - newfullvalue = match[0] + prefix + str(fmt % {"#": newintval}) - string = re.sub(match, newfullvalue, string) - return string - - -def get_version_up(path): - """ Returns the next version of the path """ - - (prefix, v) = version_get(path, 'v') - v = int(v) - return version_set(path, prefix, v, v + 1) diff --git a/pype/plugins/global/publish/cleanup.py b/pype/plugins/global/publish/cleanup.py index 3ab41f90ca..e891b7b7f6 100644 --- a/pype/plugins/global/publish/cleanup.py +++ b/pype/plugins/global/publish/cleanup.py @@ -1,11 +1,18 @@ +# -*- coding: utf-8 -*- +"""Cleanup leftover files from publish.""" import os import shutil import pyblish.api def clean_renders(instance): - transfers = instance.data.get("transfers", list()) + """Delete renders after publishing. + Args: + instance (pyblish.api.Instace): Instance to work on. + + """ + transfers = instance.data.get("transfers", list()) current_families = instance.data.get("families", list()) instance_family = instance.data.get("family", None) dirnames = [] @@ -40,6 +47,7 @@ class CleanUp(pyblish.api.InstancePlugin): active = True def process(self, instance): + """Plugin entry point.""" # Get the errored instances failed = [] for result in instance.context.data["results"]: @@ -52,7 +60,7 @@ class CleanUp(pyblish.api.InstancePlugin): ) ) - self.log.info("Cleaning renders ...") + self.log.info("Performing cleanup on {}".format(instance)) clean_renders(instance) if [ef for ef in self.exclude_families @@ -60,16 +68,21 @@ class CleanUp(pyblish.api.InstancePlugin): return import tempfile + temp_root = tempfile.gettempdir() staging_dir = instance.data.get("stagingDir", None) - if not staging_dir or not os.path.exists(staging_dir): - self.log.info("No staging directory found: %s" % staging_dir) + + if not staging_dir: + self.log.info("Staging dir not set.") return - temp_root = tempfile.gettempdir() if not os.path.normpath(staging_dir).startswith(temp_root): self.log.info("Skipping cleanup. Staging directory is not in the " "temp folder: %s" % staging_dir) return - self.log.info("Removing staging directory ...") + if not os.path.exists(staging_dir): + self.log.info("No staging directory found: %s" % staging_dir) + return + + self.log.info("Removing staging directory {}".format(staging_dir)) shutil.rmtree(staging_dir) diff --git a/pype/plugins/global/publish/submit_publish_job.py b/pype/plugins/global/publish/submit_publish_job.py index dd9e84aade..3053c80b11 100644 --- a/pype/plugins/global/publish/submit_publish_job.py +++ b/pype/plugins/global/publish/submit_publish_job.py @@ -206,7 +206,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): def _create_metadata_path(self, instance): ins_data = instance.data # Ensure output dir exists - output_dir = ins_data.get("publishRenderFolder", ins_data["outputDir"]) + output_dir = ins_data.get( + "publishRenderMetadataFolder", ins_data["outputDir"]) try: if not os.path.isdir(output_dir): diff --git a/pype/plugins/global/publish/validate_instance_in_context.py b/pype/plugins/global/publish/validate_instance_in_context.py new file mode 100644 index 0000000000..a4fc555161 --- /dev/null +++ b/pype/plugins/global/publish/validate_instance_in_context.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""Validate if instance asset is the same as context asset.""" +from __future__ import absolute_import + +import pyblish.api +import pype.api + + +class SelectInvalidInstances(pyblish.api.Action): + """Select invalid instances in Outliner.""" + + label = "Select Instances" + icon = "briefcase" + on = "failed" + + def process(self, context, plugin): + """Process invalid validators and select invalid instances.""" + # Get the errored instances + failed = [] + for result in context.data["results"]: + if result["error"] is None: + continue + if result["instance"] is None: + continue + if result["instance"] in failed: + continue + if result["plugin"] != plugin: + continue + + failed.append(result["instance"]) + + # Apply pyblish.logic to get the instances for the plug-in + instances = pyblish.api.instances_by_plugin(failed, plugin) + + if instances: + self.log.info( + "Selecting invalid nodes: %s" % ", ".join( + [str(x) for x in instances] + ) + ) + self.select(instances) + else: + self.log.info("No invalid nodes found.") + self.deselect() + + def select(self, instances): + if "nuke" in pyblish.api.registered_hosts(): + import avalon.nuke.lib + import nuke + avalon.nuke.lib.select_nodes( + [nuke.toNode(str(x)) for x in instances] + ) + + if "maya" in pyblish.api.registered_hosts(): + from maya import cmds + cmds.select(instances, replace=True, noExpand=True) + + def deselect(self): + if "nuke" in pyblish.api.registered_hosts(): + import avalon.nuke.lib + avalon.nuke.lib.reset_selection() + + if "maya" in pyblish.api.registered_hosts(): + from maya import cmds + cmds.select(deselect=True) + + +class RepairSelectInvalidInstances(pyblish.api.Action): + """Repair the instance asset.""" + + label = "Repair" + icon = "wrench" + on = "failed" + + def process(self, context, plugin): + # Get the errored instances + failed = [] + for result in context.data["results"]: + if result["error"] is None: + continue + if result["instance"] is None: + continue + if result["instance"] in failed: + continue + if result["plugin"] != plugin: + continue + + failed.append(result["instance"]) + + # Apply pyblish.logic to get the instances for the plug-in + instances = pyblish.api.instances_by_plugin(failed, plugin) + + context_asset = context.data["assetEntity"]["name"] + for instance in instances: + self.set_attribute(instance, context_asset) + + def set_attribute(self, instance, context_asset): + if "nuke" in pyblish.api.registered_hosts(): + import nuke + nuke.toNode( + instance.data.get("name") + )["avalon:asset"].setValue(context_asset) + + if "maya" in pyblish.api.registered_hosts(): + from maya import cmds + cmds.setAttr( + instance.data.get("name") + ".asset", + context_asset, + type="string" + ) + + +class ValidateInstanceInContext(pyblish.api.InstancePlugin): + """Validator to check if instance asset match context asset. + + When working in per-shot style you always publish data in context of + current asset (shot). This validator checks if this is so. It is optional + so it can be disabled when needed. + + Action on this validator will select invalid instances in Outliner. + """ + + order = pype.api.ValidateContentsOrder + label = "Instance in same Context" + optional = True + hosts = ["maya", "nuke"] + actions = [SelectInvalidInstances, RepairSelectInvalidInstances] + + def process(self, instance): + asset = instance.data.get("asset") + context_asset = instance.context.data["assetEntity"]["name"] + msg = "{} has asset {}".format(instance.name, asset) + assert asset == context_asset, msg diff --git a/pype/plugins/harmony/publish/extract_render.py b/pype/plugins/harmony/publish/extract_render.py index fe1352f9f9..45b52e0307 100644 --- a/pype/plugins/harmony/publish/extract_render.py +++ b/pype/plugins/harmony/publish/extract_render.py @@ -72,19 +72,27 @@ class ExtractRender(pyblish.api.InstancePlugin): self.log.info(output.decode("utf-8")) # Collect rendered files. + self.log.debug(path) files = os.listdir(path) + self.log.debug(files) collections, remainder = clique.assemble(files, minimum_items=1) assert not remainder, ( "There should not be a remainder for {0}: {1}".format( instance[0], remainder ) ) - assert len(collections) == 1, ( - "There should only be one image sequence in {}. Found: {}".format( - path, len(collections) - ) - ) - collection = collections[0] + self.log.debug(collections) + if len(collections) > 1: + for col in collections: + if len(list(col)) > 1: + collection = col + else: + # assert len(collections) == 1, ( + # "There should only be one image sequence in {}. Found: {}".format( + # path, len(collections) + # ) + # ) + collection = collections[0] # Generate thumbnail. thumbnail_path = os.path.join(path, "thumbnail.png") diff --git a/pype/plugins/harmony/publish/validate_scene_settings.py b/pype/plugins/harmony/publish/validate_scene_settings.py index aa9a70bd85..3602f1ca22 100644 --- a/pype/plugins/harmony/publish/validate_scene_settings.py +++ b/pype/plugins/harmony/publish/validate_scene_settings.py @@ -28,8 +28,11 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): hosts = ["harmony"] actions = [ValidateSceneSettingsRepair] + frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] + def process(self, instance): expected_settings = pype.hosts.harmony.get_asset_settings() + self.log.info(expected_settings) # Harmony is expected to start at 1. frame_start = expected_settings["frameStart"] @@ -37,6 +40,14 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): expected_settings["frameEnd"] = frame_end - frame_start + 1 expected_settings["frameStart"] = 1 + + + self.log.info(instance.context.data['anatomyData']['asset']) + + if any(string in instance.context.data['anatomyData']['asset'] + for string in frame_check_filter): + expected_settings.pop("frameEnd") + func = """function func() { return { diff --git a/pype/plugins/maya/load/load_rendersetup.py b/pype/plugins/maya/load/load_rendersetup.py index b38e2988b1..45a314a9d1 100644 --- a/pype/plugins/maya/load/load_rendersetup.py +++ b/pype/plugins/maya/load/load_rendersetup.py @@ -1,14 +1,25 @@ -from avalon import api -import maya.app.renderSetup.model.renderSetup as renderSetup -from avalon.maya import lib -from maya import cmds +# -*- coding: utf-8 -*- +"""Load and update RenderSetup settings. + +Working with RenderSetup setting is Maya is done utilizing json files. +When this json is loaded, it will overwrite all settings on RenderSetup +instance. +""" + import json +import six +import sys + +from avalon import api +from avalon.maya import lib +from pype.hosts.maya import lib as pypelib + +from maya import cmds +import maya.app.renderSetup.model.renderSetup as renderSetup class RenderSetupLoader(api.Loader): - """ - This will load json preset for RenderSetup, overwriting current one. - """ + """Load json preset for RenderSetup overwriting current one.""" families = ["rendersetup"] representations = ["json"] @@ -19,7 +30,7 @@ class RenderSetupLoader(api.Loader): color = "orange" def load(self, context, name, namespace, data): - + """Load RenderSetup settings.""" from avalon.maya.pipeline import containerise # from pype.hosts.maya.lib import namespaced @@ -29,7 +40,7 @@ class RenderSetupLoader(api.Loader): prefix="_" if asset[0].isdigit() else "", suffix="_", ) - + self.log.info(">>> loading json [ {} ]".format(self.fname)) with open(self.fname, "r") as file: renderSetup.instance().decode( json.load(file), renderSetup.DECODE_AND_OVERWRITE, None) @@ -42,9 +53,56 @@ class RenderSetupLoader(api.Loader): if not nodes: return + self.log.info(">>> containerising [ {} ]".format(name)) return containerise( name=name, namespace=namespace, nodes=nodes, context=context, loader=self.__class__.__name__) + + def remove(self, container): + """Remove RenderSetup settings instance.""" + from maya import cmds + + container_name = container["objectName"] + + self.log.info("Removing '%s' from Maya.." % container["name"]) + + container_content = cmds.sets(container_name, query=True) + nodes = cmds.ls(container_content, long=True) + + nodes.append(container_name) + + try: + cmds.delete(nodes) + except ValueError: + # Already implicitly deleted by Maya upon removing reference + pass + + def update(self, container, representation): + """Update RenderSetup setting by overwriting existing settings.""" + pypelib.show_message( + "Render setup update", + "Render setup setting will be overwritten by new version. All " + "setting specified by user not included in loaded version " + "will be lost.") + path = api.get_representation_path(representation) + with open(path, "r") as file: + try: + renderSetup.instance().decode( + json.load(file), renderSetup.DECODE_AND_OVERWRITE, None) + except Exception: + self.log.error("There were errors during loading") + six.reraise(*sys.exc_info()) + + # Update metadata + node = container["objectName"] + cmds.setAttr("{}.representation".format(node), + str(representation["_id"]), + type="string") + self.log.info("... updated") + + def switch(self, container, representation): + """Switch representations.""" + self.update(container, representation) diff --git a/pype/plugins/maya/publish/extract_camera_alembic.py b/pype/plugins/maya/publish/extract_camera_alembic.py index cc090760ff..c61ec5e19e 100644 --- a/pype/plugins/maya/publish/extract_camera_alembic.py +++ b/pype/plugins/maya/publish/extract_camera_alembic.py @@ -19,6 +19,7 @@ class ExtractCameraAlembic(pype.api.Extractor): label = "Camera (Alembic)" hosts = ["maya"] families = ["camera"] + bake_attributes = [] def process(self, instance): @@ -66,6 +67,14 @@ class ExtractCameraAlembic(pype.api.Extractor): job_str += ' -file "{0}"'.format(path) + # bake specified attributes in preset + assert isinstance(self.bake_attributes, (list, tuple)), ( + "Attributes to bake must be specified as a list" + ) + for attr in self.bake_attributes: + self.log.info("Adding {} attribute".format(attr)) + job_str += " -attr {0}".format(attr) + with lib.evaluation("off"): with avalon.maya.suspended_refresh(): cmds.AbcExport(j=job_str, verbose=False) diff --git a/pype/plugins/maya/publish/extract_camera_mayaAscii.py b/pype/plugins/maya/publish/extract_camera_mayaScene.py similarity index 82% rename from pype/plugins/maya/publish/extract_camera_mayaAscii.py rename to pype/plugins/maya/publish/extract_camera_mayaScene.py index 973d8d452a..03dde031e9 100644 --- a/pype/plugins/maya/publish/extract_camera_mayaAscii.py +++ b/pype/plugins/maya/publish/extract_camera_mayaScene.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract camera as Maya Scene.""" import os from maya import cmds @@ -65,8 +67,8 @@ def unlock(plug): cmds.disconnectAttr(source, destination) -class ExtractCameraMayaAscii(pype.api.Extractor): - """Extract a Camera as Maya Ascii. +class ExtractCameraMayaScene(pype.api.Extractor): + """Extract a Camera as Maya Scene. This will create a duplicate of the camera that will be baked *with* substeps and handles for the required frames. This temporary duplicate @@ -81,13 +83,28 @@ class ExtractCameraMayaAscii(pype.api.Extractor): """ - label = "Camera (Maya Ascii)" + label = "Camera (Maya Scene)" hosts = ["maya"] families = ["camera"] + scene_type = "ma" def process(self, instance): - + """Plugin entry point.""" # get settings + ext_mapping = instance.context.data["presets"]["maya"].get("ext_mapping") # noqa: E501 + if ext_mapping: + self.log.info("Looking in presets for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except AttributeError: + # no preset found + pass + framerange = [instance.data.get("frameStart", 1), instance.data.get("frameEnd", 1)] handles = instance.data.get("handles", 0) @@ -95,7 +112,7 @@ class ExtractCameraMayaAscii(pype.api.Extractor): bake_to_worldspace = instance.data("bakeToWorldSpace", True) if not bake_to_worldspace: - self.log.warning("Camera (Maya Ascii) export only supports world" + self.log.warning("Camera (Maya Scene) export only supports world" "space baked camera extractions. The disabled " "bake to world space is ignored...") @@ -115,7 +132,7 @@ class ExtractCameraMayaAscii(pype.api.Extractor): # Define extract output file path dir_path = self.staging_dir(instance) - filename = "{0}.ma".format(instance.name) + filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(dir_path, filename) # Perform extraction @@ -152,7 +169,7 @@ class ExtractCameraMayaAscii(pype.api.Extractor): cmds.select(baked_shapes, noExpand=True) cmds.file(path, force=True, - typ="mayaAscii", + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=False, constructionHistory=False, @@ -164,15 +181,15 @@ class ExtractCameraMayaAscii(pype.api.Extractor): # Delete the baked hierarchy if bake_to_worldspace: cmds.delete(baked) - - massage_ma_file(path) + if self.scene_type == "ma": + massage_ma_file(path) if "representations" not in instance.data: instance.data["representations"] = [] representation = { - 'name': 'ma', - 'ext': 'ma', + 'name': self.scene_type, + 'ext': self.scene_type, 'files': filename, "stagingDir": dir_path, } diff --git a/pype/plugins/maya/publish/extract_maya_ascii_raw.py b/pype/plugins/maya/publish/extract_maya_scene_raw.py similarity index 60% rename from pype/plugins/maya/publish/extract_maya_ascii_raw.py rename to pype/plugins/maya/publish/extract_maya_scene_raw.py index 895b6acbfe..2971572552 100644 --- a/pype/plugins/maya/publish/extract_maya_ascii_raw.py +++ b/pype/plugins/maya/publish/extract_maya_scene_raw.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract data as Maya scene (raw).""" import os from maya import cmds @@ -6,24 +8,37 @@ import avalon.maya import pype.api -class ExtractMayaAsciiRaw(pype.api.Extractor): - """Extract as Maya Ascii (raw) +class ExtractMayaSceneRaw(pype.api.Extractor): + """Extract as Maya Scene (raw). This will preserve all references, construction history, etc. - """ - label = "Maya ASCII (Raw)" + label = "Maya Scene (Raw)" hosts = ["maya"] families = ["mayaAscii", "setdress", "layout"] + scene_type = "ma" def process(self, instance): - + """Plugin entry point.""" + ext_mapping = instance.context.data["presets"]["maya"].get("ext_mapping") # noqa: E501 + if ext_mapping: + self.log.info("Looking in presets for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except AttributeError: + # no preset found + pass # Define extract output file path dir_path = self.staging_dir(instance) - filename = "{0}.ma".format(instance.name) + filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(dir_path, filename) # Whether to include all nodes in the instance (including those from @@ -38,12 +53,12 @@ class ExtractMayaAsciiRaw(pype.api.Extractor): members = instance[:] # Perform extraction - self.log.info("Performing extraction..") + self.log.info("Performing extraction ...") with avalon.maya.maintained_selection(): cmds.select(members, noExpand=True) cmds.file(path, force=True, - typ="mayaAscii", + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=True, constructionHistory=True, @@ -55,8 +70,8 @@ class ExtractMayaAsciiRaw(pype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'ma', - 'ext': 'ma', + 'name': self.scene_type, + 'ext': self.scene_type, 'files': filename, "stagingDir": dir_path } diff --git a/pype/plugins/maya/publish/extract_model.py b/pype/plugins/maya/publish/extract_model.py index ba56194eea..330e471e53 100644 --- a/pype/plugins/maya/publish/extract_model.py +++ b/pype/plugins/maya/publish/extract_model.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract model as Maya Scene.""" import os from maya import cmds @@ -8,7 +10,7 @@ from pype.hosts.maya import lib class ExtractModel(pype.api.Extractor): - """Extract as Model (Maya Ascii) + """Extract as Model (Maya Scene). Only extracts contents based on the original "setMembers" data to ensure publishing the least amount of required shapes. From that it only takes @@ -22,19 +24,33 @@ class ExtractModel(pype.api.Extractor): """ - label = "Model (Maya ASCII)" + label = "Model (Maya Scene)" hosts = ["maya"] families = ["model"] + scene_type = "ma" def process(self, instance): - + """Plugin entry point.""" + ext_mapping = instance.context.data["presets"]["maya"].get("ext_mapping") # noqa: E501 + if ext_mapping: + self.log.info("Looking in presets for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except AttributeError: + # no preset found + pass # Define extract output file path stagingdir = self.staging_dir(instance) - filename = "{0}.ma".format(instance.name) + filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(stagingdir, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.info("Performing extraction ...") # Get only the shape contents we need in such a way that we avoid # taking along intermediateObjects @@ -59,7 +75,7 @@ class ExtractModel(pype.api.Extractor): cmds.select(members, noExpand=True) cmds.file(path, force=True, - typ="mayaAscii", + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=False, channels=False, @@ -73,8 +89,8 @@ class ExtractModel(pype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'ma', - 'ext': 'ma', + 'name': self.scene_type, + 'ext': self.scene_type, 'files': filename, "stagingDir": stagingdir, } diff --git a/pype/plugins/maya/publish/extract_rig.py b/pype/plugins/maya/publish/extract_rig.py index c98e562313..8ebeae4184 100644 --- a/pype/plugins/maya/publish/extract_rig.py +++ b/pype/plugins/maya/publish/extract_rig.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract rig as Maya Scene.""" import os from maya import cmds @@ -7,26 +9,40 @@ import pype.api class ExtractRig(pype.api.Extractor): - """Extract rig as Maya Ascii""" + """Extract rig as Maya Scene.""" - label = "Extract Rig (Maya ASCII)" + label = "Extract Rig (Maya Scene)" hosts = ["maya"] families = ["rig"] + scene_type = "ma" def process(self, instance): - + """Plugin entry point.""" + ext_mapping = instance.context.data["presets"]["maya"].get("ext_mapping") # noqa: E501 + if ext_mapping: + self.log.info("Looking in presets for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except AttributeError: + # no preset found + pass # Define extract output file path dir_path = self.staging_dir(instance) - filename = "{0}.ma".format(instance.name) + filename = "{0}.{1}".format(instance.name, self.scene_type) path = os.path.join(dir_path, filename) # Perform extraction - self.log.info("Performing extraction..") + self.log.info("Performing extraction ...") with avalon.maya.maintained_selection(): cmds.select(instance, noExpand=True) cmds.file(path, force=True, - typ="mayaAscii", + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 exportSelected=True, preserveReferences=False, channels=True, @@ -38,12 +54,11 @@ class ExtractRig(pype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'ma', - 'ext': 'ma', + 'name': self.scene_type, + 'ext': self.scene_type, 'files': filename, "stagingDir": dir_path } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) diff --git a/pype/plugins/maya/publish/extract_yeti_rig.py b/pype/plugins/maya/publish/extract_yeti_rig.py index d7bbcd6555..2f66d3e026 100644 --- a/pype/plugins/maya/publish/extract_yeti_rig.py +++ b/pype/plugins/maya/publish/extract_yeti_rig.py @@ -1,3 +1,6 @@ +# -*- coding: utf-8 -*- +"""Extract Yeti rig.""" + import os import json import contextlib @@ -11,7 +14,7 @@ import pype.hosts.maya.lib as maya @contextlib.contextmanager def disconnect_plugs(settings, members): - + """Disconnect and store attribute connections.""" members = cmds.ls(members, long=True) original_connections = [] try: @@ -55,7 +58,7 @@ def disconnect_plugs(settings, members): @contextlib.contextmanager def yetigraph_attribute_values(assumed_destination, resources): - + """Get values from Yeti attributes in graph.""" try: for resource in resources: if "graphnode" not in resource: @@ -89,14 +92,28 @@ def yetigraph_attribute_values(assumed_destination, resources): class ExtractYetiRig(pype.api.Extractor): - """Extract the Yeti rig to a MayaAscii and write the Yeti rig data""" + """Extract the Yeti rig to a Maya Scene and write the Yeti rig data.""" label = "Extract Yeti Rig" hosts = ["maya"] families = ["yetiRig"] + scene_type = "ma" def process(self, instance): - + """Plugin entry point.""" + ext_mapping = instance.context.data["presets"]["maya"].get("ext_mapping") # noqa: E501 + if ext_mapping: + self.log.info("Looking in presets for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except AttributeError: + # no preset found + pass yeti_nodes = cmds.ls(instance, type="pgYetiMaya") if not yeti_nodes: raise RuntimeError("No pgYetiMaya nodes found in the instance") @@ -106,7 +123,8 @@ class ExtractYetiRig(pype.api.Extractor): settings_path = os.path.join(dirname, "yeti.rigsettings") # Yeti related staging dirs - maya_path = os.path.join(dirname, "yeti_rig.ma") + maya_path = os.path.join( + dirname, "yeti_rig.{}".format(self.scene_type)) self.log.info("Writing metadata file") @@ -153,7 +171,7 @@ class ExtractYetiRig(pype.api.Extractor): cmds.file(maya_path, force=True, exportSelected=True, - typ="mayaAscii", + typ="mayaAscii" if self.scene_type == "ma" else "mayaBinary", # noqa: E501 preserveReferences=False, constructionHistory=True, shader=False) @@ -163,21 +181,21 @@ class ExtractYetiRig(pype.api.Extractor): if "representations" not in instance.data: instance.data["representations"] = [] - self.log.info("rig file: {}".format("yeti_rig.ma")) + self.log.info("rig file: {}".format(maya_path)) instance.data["representations"].append( { - 'name': "ma", - 'ext': 'ma', - 'files': "yeti_rig.ma", + 'name': self.scene_type, + 'ext': self.scene_type, + 'files': os.path.basename(maya_path), 'stagingDir': dirname } ) - self.log.info("settings file: {}".format("yeti.rigsettings")) + self.log.info("settings file: {}".format(settings)) instance.data["representations"].append( { 'name': 'rigsettings', 'ext': 'rigsettings', - 'files': 'yeti.rigsettings', + 'files': os.path.basename(settings), 'stagingDir': dirname } ) diff --git a/pype/plugins/maya/publish/validate_attributes.py b/pype/plugins/maya/publish/validate_attributes.py index 6ecebfa107..a77fbe5e93 100644 --- a/pype/plugins/maya/publish/validate_attributes.py +++ b/pype/plugins/maya/publish/validate_attributes.py @@ -62,9 +62,16 @@ class ValidateAttributes(pyblish.api.ContextPlugin): for family in families: for preset in presets[family]: [node_name, attribute_name] = preset.split(".") - attributes.update( - {node_name: {attribute_name: presets[family][preset]}} - ) + try: + attributes[node_name].update( + {attribute_name: presets[family][preset]} + ) + except KeyError: + attributes.update({ + node_name: { + attribute_name: presets[family][preset] + } + }) # Get invalid attributes. nodes = pm.ls() diff --git a/pype/plugins/maya/publish/validate_instance_in_context.py b/pype/plugins/maya/publish/validate_instance_in_context.py deleted file mode 100644 index 542249bb2d..0000000000 --- a/pype/plugins/maya/publish/validate_instance_in_context.py +++ /dev/null @@ -1,108 +0,0 @@ -# -*- coding: utf-8 -*- -"""Validate if instance asset is the same as context asset.""" -from __future__ import absolute_import -import pyblish.api -from pype.action import get_errored_instances_from_context -import pype.api - - -class SelectInvalidInstances(pyblish.api.Action): - """Select invalid instances in Outliner.""" - - label = "Show Instances" - icon = "briefcase" - on = "failed" - - def process(self, context, plugin): - """Process invalid validators and select invalid instances.""" - try: - from maya import cmds - except ImportError: - raise ImportError("Current host is not Maya") - - errored_instances = get_errored_instances_from_context(context) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(errored_instances, plugin) - - # Get the invalid nodes for the plug-ins - self.log.info("Finding invalid nodes..") - invalid = list() - for _instance in instances: - invalid_instances = plugin.get_invalid(context) - if invalid_instances: - if isinstance(invalid_instances, (list, tuple)): - invalid.extend(invalid_instances) - else: - self.log.warning("Plug-in returned to be invalid, " - "but has no selectable nodes.") - - # Ensure unique (process each node only once) - invalid = list(set(invalid)) - - if invalid: - self.log.info("Selecting invalid nodes: %s" % ", ".join(invalid)) - cmds.select(invalid, replace=True, noExpand=True) - else: - self.log.info("No invalid nodes found.") - cmds.select(deselect=True) - - -class RepairSelectInvalidInstances(pyblish.api.Action): - """Repair the instance asset.""" - - label = "Repair" - icon = "wrench" - on = "failed" - - def process(self, context, plugin): - from maya import cmds - # Get the errored instances - failed = [] - for result in context.data["results"]: - if (result["error"] is not None and result["instance"] is not None - and result["instance"] not in failed): - failed.append(result["instance"]) - - # Apply pyblish.logic to get the instances for the plug-in - instances = pyblish.api.instances_by_plugin(failed, plugin) - context_asset = context.data["assetEntity"]["name"] - for instance in instances: - cmds.setAttr(instance.data.get("name") + ".asset", - context_asset, type="string") - - -class ValidateInstanceInContext(pyblish.api.ContextPlugin): - """Validator to check if instance asset match context asset. - - When working in per-shot style you always publish data in context of - current asset (shot). This validator checks if this is so. It is optional - so it can be disabled when needed. - - Action on this validator will select invalid instances in Outliner. - """ - - order = pype.api.ValidateContentsOrder - label = "Instance in same Context" - optional = True - actions = [SelectInvalidInstances, RepairSelectInvalidInstances] - - @classmethod - def get_invalid(cls, context): - """Get invalid instances.""" - invalid = [] - context_asset = context.data["assetEntity"]["name"] - cls.log.info("we are in {}".format(context_asset)) - for instance in context: - asset = instance.data.get("asset") - if asset != context_asset: - cls.log.warning("{} has asset {}".format(instance.name, asset)) - invalid.append(instance.name) - - return invalid - - def process(self, context): - """Check instances.""" - invalid = self.get_invalid(context) - if invalid: - raise AssertionError("Some instances doesn't share same context") diff --git a/pype/plugins/nuke/publish/collect_instances.py b/pype/plugins/nuke/publish/collect_instances.py index 0bbede11c0..9085e12bd8 100644 --- a/pype/plugins/nuke/publish/collect_instances.py +++ b/pype/plugins/nuke/publish/collect_instances.py @@ -106,7 +106,7 @@ class CollectNukeInstances(pyblish.api.ContextPlugin): instance.data.update({ "subset": subset, - "asset": os.environ["AVALON_ASSET"], + "asset": avalon_knob_data["asset"], "label": node.name(), "name": node.name(), "subset": subset, diff --git a/pype/plugins/nuke/publish/collect_review.py b/pype/plugins/nuke/publish/collect_review.py index c95c94541d..e7e8da19a1 100644 --- a/pype/plugins/nuke/publish/collect_review.py +++ b/pype/plugins/nuke/publish/collect_review.py @@ -1,4 +1,7 @@ import pyblish.api +import pype.api +from avalon import io, api + import nuke @@ -23,6 +26,21 @@ class CollectReview(pyblish.api.InstancePlugin): if not node["review"].value(): return + # Add audio to instance if it exists. + try: + version = pype.api.get_latest_version( + instance.context.data["assetEntity"]["name"], "audioMain" + ) + representation = io.find_one( + {"type": "representation", "parent": version["_id"]} + ) + instance.data["audio"] = [{ + "offset": 0, + "filename": api.get_representation_path(representation) + }] + except AssertionError: + pass + instance.data["families"].append("review") instance.data['families'].append('ftrack') diff --git a/pype/plugins/nuke/publish/extract_thumbnail.py b/pype/plugins/nuke/publish/extract_thumbnail.py index 5e9302a01a..a3ef09bc9f 100644 --- a/pype/plugins/nuke/publish/extract_thumbnail.py +++ b/pype/plugins/nuke/publish/extract_thumbnail.py @@ -152,7 +152,7 @@ class ExtractThumbnail(pype.api.Extractor): ipn_orig = None for v in [n for n in nuke.allNodes() - if "Viewer" in n.Class()]: + if "Viewer" == n.Class()]: ip = v['input_process'].getValue() ipn = v['input_process_node'].getValue() if "VIEWER_INPUT" not in ipn and ip: diff --git a/pype/plugins/photoshop/publish/extract_image.py b/pype/plugins/photoshop/publish/extract_image.py index da3197c7da..6dfccdc4f2 100644 --- a/pype/plugins/photoshop/publish/extract_image.py +++ b/pype/plugins/photoshop/publish/extract_image.py @@ -13,6 +13,7 @@ class ExtractImage(pype.api.Extractor): label = "Extract Image" hosts = ["photoshop"] families = ["image"] + formats = ["png", "jpg"] def process(self, instance): @@ -32,20 +33,22 @@ class ExtractImage(pype.api.Extractor): if layer.id not in extract_ids: layer.Visible = False - save_options = { - "png": photoshop.com_objects.PNGSaveOptions(), - "jpg": photoshop.com_objects.JPEGSaveOptions() - } + save_options = {} + if "png" in self.formats: + save_options["png"] = photoshop.com_objects.PNGSaveOptions() + if "jpg" in self.formats: + save_options["jpg"] = photoshop.com_objects.JPEGSaveOptions() + file_basename = os.path.splitext( + photoshop.app().ActiveDocument.Name + )[0] for extension, save_option in save_options.items(): + _filename = "{}.{}".format(file_basename, extension) + files[extension] = _filename + + full_filename = os.path.join(staging_dir, _filename) photoshop.app().ActiveDocument.SaveAs( - staging_dir, save_option, True - ) - files[extension] = "{} copy.{}".format( - os.path.splitext( - photoshop.app().ActiveDocument.Name - )[0], - extension + full_filename, save_option, True ) representations = [] diff --git a/pype/plugins/photoshop/publish/extract_review.py b/pype/plugins/photoshop/publish/extract_review.py index 8aebd1ea87..078ee53899 100644 --- a/pype/plugins/photoshop/publish/extract_review.py +++ b/pype/plugins/photoshop/publish/extract_review.py @@ -24,9 +24,10 @@ class ExtractReview(pype.api.Extractor): layers.append(image_instance[0]) # Perform extraction - output_image = "{} copy.jpg".format( + output_image = "{}.jpg".format( os.path.splitext(photoshop.app().ActiveDocument.Name)[0] ) + output_image_path = os.path.join(staging_dir, output_image) with photoshop.maintained_visibility(): # Hide all other layers. extract_ids = [ @@ -39,7 +40,9 @@ class ExtractReview(pype.api.Extractor): layer.Visible = False photoshop.app().ActiveDocument.SaveAs( - staging_dir, photoshop.com_objects.JPEGSaveOptions(), True + output_image_path, + photoshop.com_objects.JPEGSaveOptions(), + True ) ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") @@ -56,7 +59,7 @@ class ExtractReview(pype.api.Extractor): thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") args = [ ffmpeg_path, "-y", - "-i", os.path.join(staging_dir, output_image), + "-i", output_image_path, "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path @@ -77,7 +80,7 @@ class ExtractReview(pype.api.Extractor): mov_path = os.path.join(staging_dir, "review.mov") args = [ ffmpeg_path, "-y", - "-i", os.path.join(staging_dir, output_image), + "-i", output_image_path, "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-vframes", "1", mov_path diff --git a/pype/scripts/otio_burnin.py b/pype/scripts/otio_burnin.py index 104ff0255c..16e24757dd 100644 --- a/pype/scripts/otio_burnin.py +++ b/pype/scripts/otio_burnin.py @@ -528,6 +528,9 @@ def burnins_from_data( if pix_fmt: ffmpeg_args.append("-pix_fmt {}".format(pix_fmt)) + # Use group one (same as `-intra` argument, which is deprecated) + ffmpeg_args.append("-g 1") + ffmpeg_args_str = " ".join(ffmpeg_args) burnin.render( output_path, args=ffmpeg_args_str, overwrite=overwrite, **data diff --git a/pype/version.py b/pype/version.py index 7f6646a762..ddcf716b76 100644 --- a/pype/version.py +++ b/pype/version.py @@ -1 +1 @@ -__version__ = "2.11.0" +__version__ = "2.11.3"