From 43759a2c98b714f53262e03f3c8fd327013e87d3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 26 Mar 2021 16:28:05 +0100 Subject: [PATCH 001/515] collect renderer specific nodes --- .../maya/plugins/publish/collect_look.py | 108 +++++++++++++----- .../maya/plugins/publish/extract_look.py | 43 ++++++- 2 files changed, 121 insertions(+), 30 deletions(-) diff --git a/pype/hosts/maya/plugins/publish/collect_look.py b/pype/hosts/maya/plugins/publish/collect_look.py index 35abc5a991..04987b44a9 100644 --- a/pype/hosts/maya/plugins/publish/collect_look.py +++ b/pype/hosts/maya/plugins/publish/collect_look.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Maya look collector.""" import re import os import glob @@ -16,6 +18,11 @@ SHAPE_ATTRS = ["castsShadows", "doubleSided", "opposite"] +RENDERER_NODE_TYPES = [ + # redshift + "RedshiftMeshParameters" +] + SHAPE_ATTRS = set(SHAPE_ATTRS) @@ -219,7 +226,6 @@ class CollectLook(pyblish.api.InstancePlugin): with lib.renderlayer(instance.data["renderlayer"]): self.collect(instance) - def collect(self, instance): self.log.info("Looking for look associations " @@ -228,6 +234,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Discover related object sets self.log.info("Gathering sets..") sets = self.collect_sets(instance) + render_nodes = [] # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) @@ -235,48 +242,91 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Gathering set relations..") # Ensure iteration happen in a list so we can remove keys from the # dict within the loop - for objset in list(sets): - self.log.debug("From %s.." % objset) + + # skipped types of attribute on render specific nodes + disabled_types = ["message", "TdataCompound"] + + for obj_set in list(sets): + self.log.debug("From {}".format(obj_set)) + + # if node is specified as renderer node type, it will be + # serialized with its attributes. + if cmds.nodeType(obj_set) in RENDERER_NODE_TYPES: + self.log.info("- {} is {}".format( + obj_set, cmds.nodeType(obj_set))) + + node_attrs = [] + + # serialize its attributes so they can be recreated on look + # load. + for attr in cmds.listAttr(obj_set): + # skip publishedNodeInfo attributes as they break + # getAttr() and we don't need them anyway + if attr.startswith("publishedNodeInfo"): + continue + + # skip attributes types defined in 'disabled_type' list + if cmds.getAttr("{}.{}".format(obj_set, attr), type=True) in disabled_types: # noqa + continue + + # self.log.debug("{}: {}".format(attr, cmds.getAttr("{}.{}".format(obj_set, attr), type=True))) # noqa + node_attrs.append(( + attr, + cmds.getAttr("{}.{}".format(obj_set, attr)) + )) + + render_nodes.append( + { + "name": obj_set, + "type": cmds.nodeType(obj_set), + "members": cmds.ls(cmds.sets( + obj_set, query=True), long=True), + "attributes": node_attrs + } + ) # Get all nodes of the current objectSet (shadingEngine) - for member in cmds.ls(cmds.sets(objset, query=True), long=True): + for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) if not member_data: continue # Add information of the node to the members list - sets[objset]["members"].append(member_data) + sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. - self.log.info("objset {}".format(sets[objset])) - if not sets[objset]["members"] or (not objset.endswith("SG")): - self.log.info("Removing redundant set information: " - "%s" % objset) - sets.pop(objset, None) + self.log.info("obj_set {}".format(sets[obj_set])) + if not sets[obj_set]["members"] or (not obj_set.endswith("SG")): + self.log.info( + "Removing redundant set information: {}".format(obj_set)) + sets.pop(obj_set, None) self.log.info("Gathering attribute changes to instance members..") attributes = self.collect_attributes_changed(instance) # Store data on the instance - instance.data["lookData"] = {"attributes": attributes, - "relationships": sets} + instance.data["lookData"] = { + "attributes": attributes, + "relationships": sets, + "render_nodes": render_nodes + } # Collect file nodes used by shading engines (if we have any) - files = list() - looksets = sets.keys() - shaderAttrs = [ - "surfaceShader", - "volumeShader", - "displacementShader", - "aiSurfaceShader", - "aiVolumeShader"] - materials = list() + files = [] + look_sets = sets.keys() + shader_attrs = [ + "surfaceShader", + "volumeShader", + "displacementShader", + "aiSurfaceShader", + "aiVolumeShader"] + if look_sets: + materials = [] - if looksets: - for look in looksets: - for at in shaderAttrs: + for look in look_sets: + for at in shader_attrs: try: con = cmds.listConnections("{}.{}".format(look, at)) except ValueError: @@ -289,10 +339,10 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Found materials:\n{}".format(materials)) - self.log.info("Found the following sets:\n{}".format(looksets)) + self.log.info("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets - # history = cmds.listHistory(looksets) - history = list() + # history = cmds.listHistory(look_sets) + history = [] for material in materials: history.extend(cmds.listHistory(material)) files = cmds.ls(history, type="file", long=True) @@ -313,7 +363,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Ensure unique shader sets # Add shader sets to the instance for unify ID validation - instance.extend(shader for shader in looksets if shader + instance.extend(shader for shader in look_sets if shader not in instance_lookup) self.log.info("Collected look for %s" % instance) @@ -331,7 +381,7 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ - sets = dict() + sets = {} for node in instance: related_sets = lib.get_related_sets(node) if not related_sets: diff --git a/pype/hosts/maya/plugins/publish/extract_look.py b/pype/hosts/maya/plugins/publish/extract_look.py index 2c4837b7a7..bddf5599d8 100644 --- a/pype/hosts/maya/plugins/publish/extract_look.py +++ b/pype/hosts/maya/plugins/publish/extract_look.py @@ -118,12 +118,53 @@ class ExtractLook(pype.api.Extractor): hosts = ["maya"] families = ["look"] order = pyblish.api.ExtractorOrder + 0.2 + scene_type = "ma" + + @staticmethod + def get_renderer_name(): + """Get renderer name from Maya. + + Returns: + str: Renderer name. + + """ + renderer = cmds.getAttr( + "defaultRenderGlobals.currentRenderer" + ).lower() + # handle various renderman names + if renderer.startswith("renderman"): + renderer = "renderman" + return renderer + + def get_maya_scene_type(self, instance): + """Get Maya scene type from settings. + + Args: + instance (pyblish.api.Instance): Instance with collected + project settings. + + """ + ext_mapping = ( + instance.context.data["project_settings"]["maya"]["ext_mapping"] + ) + if ext_mapping: + self.log.info("Looking in settings 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 KeyError: + # no preset found + pass def process(self, instance): # Define extract output file path dir_path = self.staging_dir(instance) - maya_fname = "{0}.ma".format(instance.name) + maya_fname = "{0}.{1}".format(instance.name, self.scene_type) json_fname = "{0}.json".format(instance.name) # Make texture dump folder From e6ccdc31364896217f9d59782ec834f4fa8e282d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 15:43:17 +0200 Subject: [PATCH 002/515] Hiero: rename `master` to `hero` --- pype/hosts/hiero/api/plugin.py | 30 +++++++++---------- .../hiero/plugins/create/create_shot_clip.py | 4 +-- .../publish/collect_hierarchy_context.py | 4 +-- test_localsystem.txt | 1 + 4 files changed, 20 insertions(+), 19 deletions(-) create mode 100644 test_localsystem.txt diff --git a/pype/hosts/hiero/api/plugin.py b/pype/hosts/hiero/api/plugin.py index 06ee403a5b..cfbe7f664f 100644 --- a/pype/hosts/hiero/api/plugin.py +++ b/pype/hosts/hiero/api/plugin.py @@ -266,7 +266,7 @@ class CreatorWidget(QtWidgets.QDialog): elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], - setValue=v["value"], setMaximum=10000, setToolTip=tool_tip) + setValue=v["value"], setMinimum=1, setMaximum=100000, setToolTip=tool_tip) return data @@ -782,13 +782,13 @@ class PublishClip: Populating the tag data into internal variable self.tag_data """ # define vertical sync attributes - master_layer = True + hero_track = True self.review_layer = "" if self.vertical_sync: # check if track name is not in driving layer if self.track_name not in self.driving_layer: # if it is not then define vertical sync as None - master_layer = False + hero_track = False # increasing steps by index of rename iteration self.count_steps *= self.rename_index @@ -802,7 +802,7 @@ class PublishClip: self.tag_data[_k] = _v["value"] # driving layer is set as positive match - if master_layer or self.vertical_sync: + if hero_track or self.vertical_sync: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): @@ -836,32 +836,32 @@ class PublishClip: hierarchy_formating_data ) - tag_hierarchy_data.update({"masterLayer": True}) - if master_layer and self.vertical_sync: + tag_hierarchy_data.update({"heroTrack": True}) + if hero_track and self.vertical_sync: self.vertical_clip_match.update({ (self.clip_in, self.clip_out): tag_hierarchy_data }) - if not master_layer and self.vertical_sync: + if not hero_track and self.vertical_sync: # driving layer is set as negative match - for (_in, _out), master_data in self.vertical_clip_match.items(): - master_data.update({"masterLayer": False}) + for (_in, _out), hero_data in self.vertical_clip_match.items(): + hero_data.update({"heroTrack": False}) if _in == self.clip_in and _out == self.clip_out: - data_subset = master_data["subset"] - # add track index in case duplicity of names in master data + data_subset = hero_data["subset"] + # add track index in case duplicity of names in hero data if self.subset in data_subset: - master_data["subset"] = self.subset + str( + hero_data["subset"] = self.subset + str( self.track_index) # in case track name and subset name is the same then add if self.subset_name == self.track_name: - master_data["subset"] = self.subset + hero_data["subset"] = self.subset # assing data to return hierarchy data to tag - tag_hierarchy_data = master_data + tag_hierarchy_data = hero_data # add data to return data dict self.tag_data.update(tag_hierarchy_data) - if master_layer and self.review_layer: + if hero_track and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) def _solve_tag_hierarchy_data(self, hierarchy_formating_data): diff --git a/pype/hosts/hiero/plugins/create/create_shot_clip.py b/pype/hosts/hiero/plugins/create/create_shot_clip.py index 9f8ae1a300..268f84b127 100644 --- a/pype/hosts/hiero/plugins/create/create_shot_clip.py +++ b/pype/hosts/hiero/plugins/create/create_shot_clip.py @@ -120,9 +120,9 @@ class CreateShotClip(phiero.Creator): "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", - "label": "Master track", + "label": "Hero track", "target": "ui", - "toolTip": "Select driving track name which should be mastering all others", # noqa + "toolTip": "Select driving track name which should be hero for all others", # noqa "order": 1} } }, diff --git a/pype/hosts/hiero/plugins/publish/collect_hierarchy_context.py b/pype/hosts/hiero/plugins/publish/collect_hierarchy_context.py index ba3e388c53..0696a58e39 100644 --- a/pype/hosts/hiero/plugins/publish/collect_hierarchy_context.py +++ b/pype/hosts/hiero/plugins/publish/collect_hierarchy_context.py @@ -39,8 +39,8 @@ class CollectHierarchy(pyblish.api.ContextPlugin): if not set(self.families).intersection(families): continue - # exclude if not masterLayer True - if not instance.data.get("masterLayer"): + # exclude if not heroTrack True + if not instance.data.get("heroTrack"): continue # update families to include `shot` for hierarchy integration diff --git a/test_localsystem.txt b/test_localsystem.txt new file mode 100644 index 0000000000..dde7986af8 --- /dev/null +++ b/test_localsystem.txt @@ -0,0 +1 @@ +I have run From 88a9c11773e74347bc4b93bb1848d40b5315e559 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 15:44:54 +0200 Subject: [PATCH 003/515] Hiero: allow only hero trackItem for review also fix name of attribute to reviewTrack --- pype/hosts/hiero/plugins/publish/collect_review.py | 2 +- .../hiero/plugins/publish/precollect_instances.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pype/hosts/hiero/plugins/publish/collect_review.py b/pype/hosts/hiero/plugins/publish/collect_review.py index 6f550223ce..4df490ab70 100644 --- a/pype/hosts/hiero/plugins/publish/collect_review.py +++ b/pype/hosts/hiero/plugins/publish/collect_review.py @@ -29,7 +29,7 @@ class CollectReview(api.InstancePlugin): Exception: description """ - review_track = instance.data.get("review") + review_track = instance.data.get("reviewTrack") video_tracks = instance.context.data["videoTracks"] for track in video_tracks: if review_track not in track.name(): diff --git a/pype/hosts/hiero/plugins/publish/precollect_instances.py b/pype/hosts/hiero/plugins/publish/precollect_instances.py index 2ca637cf57..51378b422e 100644 --- a/pype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/pype/hosts/hiero/plugins/publish/precollect_instances.py @@ -14,6 +14,7 @@ class PreCollectInstances(api.ContextPlugin): label = "Pre-collect Instances" hosts = ["hiero"] + def process(self, context): track_items = phiero.get_track_items( selected=True, check_tagged=True, check_enabled=True) @@ -34,7 +35,7 @@ class PreCollectInstances(api.ContextPlugin): "Processing enabled track items: {}".format(len(track_items))) for _ti in track_items: - data = dict() + data = {} clip = _ti.source() # get clips subtracks and anotations @@ -60,7 +61,8 @@ class PreCollectInstances(api.ContextPlugin): asset = tag_parsed_data["asset"] subset = tag_parsed_data["subset"] - review = tag_parsed_data.get("review") + review_track = tag_parsed_data.get("reviewTrack") + hiero_track = tag_parsed_data.get("heroTrack") audio = tag_parsed_data.get("audio") # remove audio attribute from data @@ -78,8 +80,8 @@ class PreCollectInstances(api.ContextPlugin): file_info = media_source.fileinfos().pop() source_first_frame = int(file_info.startFrame()) - # apply only for feview and master track instance - if review: + # apply only for review and master track instance + if review_track and hiero_track: families += ["review", "ftrack"] data.update({ @@ -94,6 +96,7 @@ class PreCollectInstances(api.ContextPlugin): # track item attributes "track": track.name(), "trackItem": track, + "reviewTrack": review_track, # version data "versionData": { @@ -113,7 +116,7 @@ class PreCollectInstances(api.ContextPlugin): instance = context.create_instance(**data) - self.log.info("Creating instance: {}".format(instance)) + self.log.info("Creating instance.data: {}".format(instance.data)) if audio: a_data = dict() From 6e8c574509a354ad2ffd2b650b01025ea62c8363 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 15:45:54 +0200 Subject: [PATCH 004/515] Hiero: fix `pype.api.subprocess` is obsolete api call --- .../hiero/plugins/publish/extract_review_preparation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pype/hosts/hiero/plugins/publish/extract_review_preparation.py b/pype/hosts/hiero/plugins/publish/extract_review_preparation.py index bc4a895ba4..3fdc946f3c 100644 --- a/pype/hosts/hiero/plugins/publish/extract_review_preparation.py +++ b/pype/hosts/hiero/plugins/publish/extract_review_preparation.py @@ -132,7 +132,7 @@ class ExtractReviewPreparation(pype.api.Extractor): ).format(**locals()) self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) - audio_check_output = pype.api.subprocess(ffprob_cmd) + audio_check_output = pype.api.run_subprocess(ffprob_cmd) self.log.debug( "audio_check_output: {}".format(audio_check_output)) @@ -167,7 +167,7 @@ class ExtractReviewPreparation(pype.api.Extractor): # try to get video native resolution data try: - resolution_output = pype.api.subprocess(( + resolution_output = pype.api.run_subprocess(( "\"{ffprobe_path}\" -i \"{full_input_path}\"" " -v error " "-select_streams v:0 -show_entries " @@ -280,7 +280,7 @@ class ExtractReviewPreparation(pype.api.Extractor): # run subprocess self.log.debug("Executing: {}".format(subprcs_cmd)) - output = pype.api.subprocess(subprcs_cmd) + output = pype.api.run_subprocess(subprcs_cmd) self.log.debug("Output: {}".format(output)) repre_new = { From c5ab3e5ebbbdb4c19fc96df6f2fa9f38550cf769 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 15:47:20 +0200 Subject: [PATCH 005/515] Global: Fix if None in `entity.data.tasks` then they should be dict and not list as it was before --- pype/plugins/publish/extract_hierarchy_avalon.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/pype/plugins/publish/extract_hierarchy_avalon.py b/pype/plugins/publish/extract_hierarchy_avalon.py index 74751c6807..5643f04a76 100644 --- a/pype/plugins/publish/extract_hierarchy_avalon.py +++ b/pype/plugins/publish/extract_hierarchy_avalon.py @@ -99,13 +99,20 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): if entity: # Do not override data, only update cur_entity_data = entity.get("data") or {} + entity_tasks = cur_entity_data["tasks"] or {} + + # create tasks as dict by default + if not entity_tasks: + cur_entity_data["tasks"] = entity_tasks + new_tasks = data.pop("tasks", {}) if "tasks" not in cur_entity_data and not new_tasks: continue for task_name in new_tasks: - if task_name in cur_entity_data["tasks"].keys(): + if task_name in entity_tasks.keys(): continue - cur_entity_data["tasks"][task_name] = new_tasks[task_name] + cur_entity_data["tasks"][task_name] = new_tasks[ + task_name] cur_entity_data.update(data) data = cur_entity_data else: From 3dc41d283b2c77b5bb43dfb37636f4331a692750 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 16:00:47 +0200 Subject: [PATCH 006/515] Hiero: hound fix --- pype/hosts/hiero/api/plugin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pype/hosts/hiero/api/plugin.py b/pype/hosts/hiero/api/plugin.py index cfbe7f664f..c2af4a011c 100644 --- a/pype/hosts/hiero/api/plugin.py +++ b/pype/hosts/hiero/api/plugin.py @@ -266,7 +266,8 @@ class CreatorWidget(QtWidgets.QDialog): elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], - setValue=v["value"], setMinimum=1, setMaximum=100000, setToolTip=tool_tip) + setValue=v["value"], setMinimum=1, + setMaximum=100000, setToolTip=tool_tip) return data From ae62e79695e07c1a24865fbec32e460b9c0540ce Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 17:56:52 +0200 Subject: [PATCH 007/515] Global: empty tasks should return empty dict --- pype/plugins/publish/collect_hierarchy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/plugins/publish/collect_hierarchy.py b/pype/plugins/publish/collect_hierarchy.py index 5c5dbf018c..27332976e9 100644 --- a/pype/plugins/publish/collect_hierarchy.py +++ b/pype/plugins/publish/collect_hierarchy.py @@ -50,7 +50,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): # suppose that all instances are Shots shot_data['entity_type'] = 'Shot' - shot_data['tasks'] = instance.data.get("tasks") or [] + shot_data['tasks'] = instance.data.get("tasks") or {} shot_data["comments"] = instance.data.get("comments", []) shot_data['custom_attributes'] = { From ee6d1db703931faaf987fda61c7388df6d3e7a29 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 18:52:41 +0200 Subject: [PATCH 008/515] Resolve: fixing import path to new `api` format --- pype/hosts/resolve/api/plugin.py | 2 +- pype/hosts/resolve/plugins/create/create_shot_clip.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pype/hosts/resolve/api/plugin.py b/pype/hosts/resolve/api/plugin.py index 0423f15c2a..974f29cb33 100644 --- a/pype/hosts/resolve/api/plugin.py +++ b/pype/hosts/resolve/api/plugin.py @@ -81,7 +81,7 @@ class CreatorWidget(QtWidgets.QDialog): ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) - stylesheet = resolve.menu.load_stylesheet() + stylesheet = resolve.api.menu.load_stylesheet() self.setStyleSheet(stylesheet) def _on_ok_clicked(self): diff --git a/pype/hosts/resolve/plugins/create/create_shot_clip.py b/pype/hosts/resolve/plugins/create/create_shot_clip.py index 09b2b73775..bf860194d3 100644 --- a/pype/hosts/resolve/plugins/create/create_shot_clip.py +++ b/pype/hosts/resolve/plugins/create/create_shot_clip.py @@ -1,6 +1,6 @@ # from pprint import pformat from pype.hosts import resolve -from pype.hosts.resolve import lib +from pype.hosts.resolve.api import lib class CreateShotClip(resolve.Creator): From b24bbe5a5760e055716fbf6f88dfaf8870249ba0 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 18:53:59 +0200 Subject: [PATCH 009/515] Resolve and Global: rename `master layer` to `hero track` --- pype/hosts/resolve/api/plugin.py | 30 +++++++++---------- .../plugins/create/create_shot_clip.py | 2 +- .../plugins/publish/collect_instances.py | 4 +-- pype/plugins/publish/collect_hierarchy.py | 2 +- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pype/hosts/resolve/api/plugin.py b/pype/hosts/resolve/api/plugin.py index 974f29cb33..ada7549b01 100644 --- a/pype/hosts/resolve/api/plugin.py +++ b/pype/hosts/resolve/api/plugin.py @@ -697,13 +697,13 @@ class PublishClip: Populating the tag data into internal variable self.tag_data """ # define vertical sync attributes - master_layer = True + hero_track = True self.review_layer = "" if self.vertical_sync: # check if track name is not in driving layer if self.track_name not in self.driving_layer: # if it is not then define vertical sync as None - master_layer = False + hero_track = False # increasing steps by index of rename iteration self.count_steps *= self.rename_index @@ -717,7 +717,7 @@ class PublishClip: self.tag_data[_k] = _v["value"] # driving layer is set as positive match - if master_layer or self.vertical_sync: + if hero_track or self.vertical_sync: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): @@ -751,33 +751,33 @@ class PublishClip: hierarchy_formating_data ) - tag_hierarchy_data.update({"masterLayer": True}) - if master_layer and self.vertical_sync: - # tag_hierarchy_data.update({"masterLayer": True}) + tag_hierarchy_data.update({"heroTrack": True}) + if hero_track and self.vertical_sync: + # tag_hierarchy_data.update({"heroTrack": True}) self.vertical_clip_match.update({ (self.clip_in, self.clip_out): tag_hierarchy_data }) - if not master_layer and self.vertical_sync: + if not hero_track and self.vertical_sync: # driving layer is set as negative match - for (_in, _out), master_data in self.vertical_clip_match.items(): - master_data.update({"masterLayer": False}) + for (_in, _out), hero_data in self.vertical_clip_match.items(): + hero_data.update({"heroTrack": False}) if _in == self.clip_in and _out == self.clip_out: - data_subset = master_data["subset"] - # add track index in case duplicity of names in master data + data_subset = hero_data["subset"] + # add track index in case duplicity of names in hero data if self.subset in data_subset: - master_data["subset"] = self.subset + str( + hero_data["subset"] = self.subset + str( self.track_index) # in case track name and subset name is the same then add if self.subset_name == self.track_name: - master_data["subset"] = self.subset + hero_data["subset"] = self.subset # assing data to return hierarchy data to tag - tag_hierarchy_data = master_data + tag_hierarchy_data = hero_data # add data to return data dict self.tag_data.update(tag_hierarchy_data) - if master_layer and self.review_layer: + if hero_track and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) def _solve_tag_hierarchy_data(self, hierarchy_formating_data): diff --git a/pype/hosts/resolve/plugins/create/create_shot_clip.py b/pype/hosts/resolve/plugins/create/create_shot_clip.py index bf860194d3..575e9f85a9 100644 --- a/pype/hosts/resolve/plugins/create/create_shot_clip.py +++ b/pype/hosts/resolve/plugins/create/create_shot_clip.py @@ -117,7 +117,7 @@ class CreateShotClip(resolve.Creator): "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", - "label": "Master track", + "label": "Hero track", "target": "ui", "toolTip": "Select driving track name which should be mastering all others", # noqa "order": 1} diff --git a/pype/hosts/resolve/plugins/publish/collect_instances.py b/pype/hosts/resolve/plugins/publish/collect_instances.py index b1eafd512e..e76da13f48 100644 --- a/pype/hosts/resolve/plugins/publish/collect_instances.py +++ b/pype/hosts/resolve/plugins/publish/collect_instances.py @@ -102,10 +102,10 @@ class CollectInstances(pyblish.api.ContextPlugin): }) def create_shot_instance(self, context, timeline_item, **data): - master_layer = data.get("masterLayer") + hero_track = data.get("heroTrack") hierarchy_data = data.get("hierarchyData") - if not master_layer: + if not hero_track: return if not hierarchy_data: diff --git a/pype/plugins/publish/collect_hierarchy.py b/pype/plugins/publish/collect_hierarchy.py index 27332976e9..390ce443b6 100644 --- a/pype/plugins/publish/collect_hierarchy.py +++ b/pype/plugins/publish/collect_hierarchy.py @@ -40,7 +40,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): continue # exclude if not masterLayer True - if not instance.data.get("masterLayer"): + if not instance.data.get("heroTrack"): continue # get asset build data if any available From ceec8340cf7def24e4305d19d2d1ee66fb1ca238 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 18:54:25 +0200 Subject: [PATCH 010/515] Resolve: fixing renamed function name --- pype/hosts/resolve/plugins/create/create_shot_clip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pype/hosts/resolve/plugins/create/create_shot_clip.py b/pype/hosts/resolve/plugins/create/create_shot_clip.py index 575e9f85a9..86851c7074 100644 --- a/pype/hosts/resolve/plugins/create/create_shot_clip.py +++ b/pype/hosts/resolve/plugins/create/create_shot_clip.py @@ -244,7 +244,7 @@ class CreateShotClip(resolve.Creator): sq_markers = self.timeline.GetMarkers() # create media bin for compound clips (trackItems) - mp_folder = resolve.create_current_sequence_media_bin(self.timeline) + mp_folder = resolve.create_bin(self.timeline.GetName()) kwargs = { "ui_inputs": widget.result, From d179e3a246c0fa99f99840b588f1428b326d775f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 18:54:49 +0200 Subject: [PATCH 011/515] Resolve: fixing to new function usage --- pype/hosts/resolve/utility_scripts/OTIO_export.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pype/hosts/resolve/utility_scripts/OTIO_export.py b/pype/hosts/resolve/utility_scripts/OTIO_export.py index a1142f56dd..daa51ea7ac 100644 --- a/pype/hosts/resolve/utility_scripts/OTIO_export.py +++ b/pype/hosts/resolve/utility_scripts/OTIO_export.py @@ -58,9 +58,8 @@ def _close_window(event): def _export_button(event): pm = resolve.GetProjectManager() project = pm.GetCurrentProject() - fps = project.GetSetting("timelineFrameRate") timeline = project.GetCurrentTimeline() - otio_timeline = otio_export.create_otio_timeline(timeline, fps) + otio_timeline = otio_export.create_otio_timeline(project) otio_path = os.path.join( itm["exportfilebttn"].Text, timeline.GetName() + ".otio") From 7196c1bb501bdcd8ac1189db98b9a5f57ce34975 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 19:27:20 +0200 Subject: [PATCH 012/515] removing unwanted scam file ;) --- test_localsystem.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test_localsystem.txt diff --git a/test_localsystem.txt b/test_localsystem.txt deleted file mode 100644 index dde7986af8..0000000000 --- a/test_localsystem.txt +++ /dev/null @@ -1 +0,0 @@ -I have run From fc7af0e4749530869c3d739075738a82b55ca849 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 30 Mar 2021 19:48:22 +0200 Subject: [PATCH 013/515] Global: ignore test_localsystem.txt --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 47a37410e6..f1295e32eb 100644 --- a/.gitignore +++ b/.gitignore @@ -63,7 +63,6 @@ coverage.xml .hypothesis/ .pytest_cache/ - # Node JS packages ################## node_modules/ @@ -79,4 +78,5 @@ pype/premiere/ppro/js/debug.log # VScode files .vscode/ .env -dump.sql \ No newline at end of file +dump.sql +test_localsystem.txt From 7354be225584c9f319bd76a65d3287509f1edced Mon Sep 17 00:00:00 2001 From: jezscha Date: Wed, 31 Mar 2021 08:41:12 +0000 Subject: [PATCH 014/515] Create draft PR for #916 From b315df6bbfe09978d11083163008d8d5218f5523 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Mar 2021 11:19:50 +0200 Subject: [PATCH 015/515] Resolve: adding subset manager to menu --- pype/hosts/resolve/api/menu.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pype/hosts/resolve/api/menu.py b/pype/hosts/resolve/api/menu.py index 73ea937513..0b049e4433 100644 --- a/pype/hosts/resolve/api/menu.py +++ b/pype/hosts/resolve/api/menu.py @@ -12,7 +12,8 @@ from avalon.tools import ( creator, loader, sceneinventory, - libraryloader + libraryloader, + subsetmanager ) @@ -64,6 +65,7 @@ class PypeMenu(QtWidgets.QWidget): publish_btn = QtWidgets.QPushButton("Publish", self) load_btn = QtWidgets.QPushButton("Load", self) inventory_btn = QtWidgets.QPushButton("Inventory", self) + subsetm_btn = QtWidgets.QPushButton("Subset Manager", self) libload_btn = QtWidgets.QPushButton("Library", self) rename_btn = QtWidgets.QPushButton("Rename", self) set_colorspace_btn = QtWidgets.QPushButton( @@ -81,6 +83,7 @@ class PypeMenu(QtWidgets.QWidget): layout.addWidget(publish_btn) layout.addWidget(load_btn) layout.addWidget(inventory_btn) + layout.addWidget(subsetm_btn) layout.addWidget(Spacer(15, self)) @@ -102,6 +105,7 @@ class PypeMenu(QtWidgets.QWidget): publish_btn.clicked.connect(self.on_publish_clicked) load_btn.clicked.connect(self.on_load_clicked) inventory_btn.clicked.connect(self.on_inventory_clicked) + subsetm_btn.clicked.connect(self.on_subsetm_clicked) libload_btn.clicked.connect(self.on_libload_clicked) rename_btn.clicked.connect(self.on_rename_clicked) set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) @@ -127,6 +131,10 @@ class PypeMenu(QtWidgets.QWidget): print("Clicked Inventory") sceneinventory.show() + def on_subsetm_clicked(self): + print("Clicked Subset Manager") + subsetmanager.show() + def on_libload_clicked(self): print("Clicked Library") libraryloader.show() From 310c9b2b6863f35952e6bc2d9e910141cdbcf1c1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Mar 2021 11:43:16 +0200 Subject: [PATCH 016/515] Resolve: listing instances --- pype/hosts/resolve/__init__.py | 6 +++++- pype/hosts/resolve/api/pipeline.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pype/hosts/resolve/__init__.py b/pype/hosts/resolve/__init__.py index 734e0bc5df..3e49ce3b9b 100644 --- a/pype/hosts/resolve/__init__.py +++ b/pype/hosts/resolve/__init__.py @@ -11,7 +11,9 @@ from .api.pipeline import ( update_container, publish, launch_workfiles_app, - maintained_selection + maintained_selection, + remove_instance, + list_instances ) from .api.lib import ( @@ -73,6 +75,8 @@ __all__ = [ "publish", "launch_workfiles_app", "maintained_selection", + "remove_instance", + "list_instances", # utils "setup", diff --git a/pype/hosts/resolve/api/pipeline.py b/pype/hosts/resolve/api/pipeline.py index 2d08203650..1ee9743518 100644 --- a/pype/hosts/resolve/api/pipeline.py +++ b/pype/hosts/resolve/api/pipeline.py @@ -258,3 +258,26 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): # Whether instances should be passthrough based on new value timeline_item = instance.data["item"] set_publish_attribute(timeline_item, new_value) + + +def remove_instance(instance): + """Remove instance marker from track item.""" + pass + + +def list_instances(): + """List all created instances from current workfile.""" + listed_instances = [] + selected_timeline_items = lib.get_current_timeline_items( + filter=True, selecting_color=lib.publish_clip_color) + + for timeline_item_data in selected_timeline_items: + timeline_item = timeline_item_data["clip"]["item"] + + # get pype tag data + tag_data = lib.get_timeline_item_pype_tag(timeline_item) + + if tag_data: + listed_instances.append(tag_data) + + return listed_instances From b1eb67794cabb0dfad280139f6fdec3cabb4dcf9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Mar 2021 11:43:44 +0200 Subject: [PATCH 017/515] Resolve: renaming plugins to precollect --- .../{collect_instances.py => precollect_instances.py} | 4 ++-- .../{collect_workfile.py => precollect_workfile.py} | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) rename pype/hosts/resolve/plugins/publish/{collect_instances.py => precollect_instances.py} (97%) rename pype/hosts/resolve/plugins/publish/{collect_workfile.py => precollect_workfile.py} (88%) diff --git a/pype/hosts/resolve/plugins/publish/collect_instances.py b/pype/hosts/resolve/plugins/publish/precollect_instances.py similarity index 97% rename from pype/hosts/resolve/plugins/publish/collect_instances.py rename to pype/hosts/resolve/plugins/publish/precollect_instances.py index b1eafd512e..e54bda0a49 100644 --- a/pype/hosts/resolve/plugins/publish/collect_instances.py +++ b/pype/hosts/resolve/plugins/publish/precollect_instances.py @@ -5,11 +5,11 @@ from pype.hosts import resolve from pprint import pformat -class CollectInstances(pyblish.api.ContextPlugin): +class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" order = pyblish.api.CollectorOrder - 0.59 - label = "Collect Instances" + label = "Precollect Instances" hosts = ["resolve"] def process(self, context): diff --git a/pype/hosts/resolve/plugins/publish/collect_workfile.py b/pype/hosts/resolve/plugins/publish/precollect_workfile.py similarity index 88% rename from pype/hosts/resolve/plugins/publish/collect_workfile.py rename to pype/hosts/resolve/plugins/publish/precollect_workfile.py index f7f90c9689..3e9a7f26b9 100644 --- a/pype/hosts/resolve/plugins/publish/collect_workfile.py +++ b/pype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -9,10 +9,10 @@ from pype.hosts.resolve.otio import davinci_export reload(davinci_export) -class CollectWorkfile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" +class PrecollectWorkfile(pyblish.api.ContextPlugin): + """Precollect the current working file into context""" - label = "Collect Workfile" + label = "Precollect Workfile" order = pyblish.api.CollectorOrder - 0.6 def process(self, context): @@ -21,8 +21,6 @@ class CollectWorkfile(pyblish.api.ContextPlugin): subset = "workfile" project = resolve.get_current_project() fps = project.GetSetting("timelineFrameRate") - - active_timeline = resolve.get_current_timeline() video_tracks = resolve.get_video_track_names() # adding otio timeline to context From cd2372300a7e979066a1ab2a0fd3a5e5cf143c1b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Mar 2021 11:52:05 +0200 Subject: [PATCH 018/515] Resolve: improving subset manager labels --- pype/hosts/resolve/api/pipeline.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pype/hosts/resolve/api/pipeline.py b/pype/hosts/resolve/api/pipeline.py index 1ee9743518..d1d1b5185a 100644 --- a/pype/hosts/resolve/api/pipeline.py +++ b/pype/hosts/resolve/api/pipeline.py @@ -273,11 +273,15 @@ def list_instances(): for timeline_item_data in selected_timeline_items: timeline_item = timeline_item_data["clip"]["item"] + ti_name = timeline_item.GetName().split(".")[0] # get pype tag data tag_data = lib.get_timeline_item_pype_tag(timeline_item) if tag_data: + asset = tag_data.get("asset") + subset = tag_data.get("subset") + tag_data["label"] = f"{ti_name} [{asset}-{subset}]" listed_instances.append(tag_data) return listed_instances From 00e3b75feb27d5f94a0a1c7daefd140f8334725a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Mar 2021 12:35:42 +0200 Subject: [PATCH 019/515] Auto stash before merge of "feature/916-resolve-instance-manager" and "3.0/bugfix/resolve-functionality-issues" --- pype/hosts/resolve/api/plugin.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pype/hosts/resolve/api/plugin.py b/pype/hosts/resolve/api/plugin.py index ada7549b01..4ed8c5bc4a 100644 --- a/pype/hosts/resolve/api/plugin.py +++ b/pype/hosts/resolve/api/plugin.py @@ -777,9 +777,11 @@ class PublishClip: # add data to return data dict self.tag_data.update(tag_hierarchy_data) + # add review track only to hero track if hero_track and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) + def _solve_tag_hierarchy_data(self, hierarchy_formating_data): """ Solve tag data from hierarchy data and templates. """ # fill up clip name and hierarchy keys From a1b1c43d4faf32d23bb219d7ddf166c75ee35b38 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 31 Mar 2021 12:47:42 +0200 Subject: [PATCH 020/515] Resolve: adding removing instance action --- pype/hosts/resolve/api/pipeline.py | 23 ++++++++++++++++++++++- pype/hosts/resolve/api/plugin.py | 4 ++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/pype/hosts/resolve/api/pipeline.py b/pype/hosts/resolve/api/pipeline.py index d1d1b5185a..9a74e50e6f 100644 --- a/pype/hosts/resolve/api/pipeline.py +++ b/pype/hosts/resolve/api/pipeline.py @@ -262,7 +262,28 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): def remove_instance(instance): """Remove instance marker from track item.""" - pass + instance_id = instance.get("uuid") + + selected_timeline_items = lib.get_current_timeline_items( + filter=True, selecting_color=lib.publish_clip_color) + + found_ti = None + for timeline_item_data in selected_timeline_items: + timeline_item = timeline_item_data["clip"]["item"] + + # get pype tag data + tag_data = lib.get_timeline_item_pype_tag(timeline_item) + _ti_id = tag_data.get("uuid") + if _ti_id == instance_id: + found_ti = timeline_item + break + + if found_ti is None: + return + + # removing instance by marker color + print(f"Removing instance: {found_ti.GetName()}") + found_ti.DeleteMarkersByColor(lib.pype_marker_color) def list_instances(): diff --git a/pype/hosts/resolve/api/plugin.py b/pype/hosts/resolve/api/plugin.py index 4ed8c5bc4a..0ba6c5e745 100644 --- a/pype/hosts/resolve/api/plugin.py +++ b/pype/hosts/resolve/api/plugin.py @@ -1,4 +1,5 @@ import re +import uuid from avalon import api import pype.api as pype from pype.hosts import resolve @@ -777,6 +778,9 @@ class PublishClip: # add data to return data dict self.tag_data.update(tag_hierarchy_data) + # add uuid to tag data + self.tag_data["uuid"] = str(uuid.uuid4()) + # add review track only to hero track if hero_track and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) From c1e4d19030224afce99e9f6ed2bf11b849d978b6 Mon Sep 17 00:00:00 2001 From: jezscha Date: Wed, 31 Mar 2021 11:43:22 +0000 Subject: [PATCH 021/515] Create draft PR for #1235 From 52a5183b7bea4a591ecaa8fe63cf424f4bfc4e6f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 31 Mar 2021 21:40:39 +0200 Subject: [PATCH 022/515] add script to download and extract dependencies --- README.md | 6 +- poetry.lock | 32 ++++++- pyproject.toml | 31 +++++- tools/fetch_thirdparty_libs.ps1 | 21 ++++ tools/fetch_thirdparty_libs.py | 165 ++++++++++++++++++++++++++++++++ tools/fetch_thirdparty_libs.sh | 129 +++++++++++++++++++++++++ 6 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 tools/fetch_thirdparty_libs.ps1 create mode 100644 tools/fetch_thirdparty_libs.py create mode 100755 tools/fetch_thirdparty_libs.sh diff --git a/README.md b/README.md index 456655bfb9..d6c98974e0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ git clone --recurse-submodules git@github.com:pypeclub/pype.git #### To build Pype: 1) Run `.\tools\create_env.ps1` to create virtual environment in `.\venv` -2) Run `.\tools\build.ps1` to build pype executables in `.\build\` +2) Run `.\tools\fetch_thirdparty_libs.ps1` to download third-party dependencies like ffmpeg and oiio. Those will be included in build. +3) Run `.\tools\build.ps1` to build pype executables in `.\build\` To create distributable Pype versions, run `./tools/create_zip.ps1` - that will create zip file with name `pype-vx.x.x.zip` parsed from current pype repository and @@ -105,7 +106,8 @@ pyenv local 3.7.9 #### To build Pype: 1) Run `.\tools\create_env.sh` to create virtual environment in `.\venv` -2) Run `.\tools\build.sh` to build Pype executables in `.\build\` +2) Run `.\tools\fetch_thirdparty_libs.sh` to download third-party dependencies like ffmpeg and oiio. Those will be included in build. +3) Run `.\tools\build.sh` to build Pype executables in `.\build\` ### Linux diff --git a/poetry.lock b/poetry.lock index e6c08b8ae9..1a0637dc47 100644 --- a/poetry.lock +++ b/poetry.lock @@ -296,6 +296,18 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "enlighten" +version = "1.9.0" +description = "Enlighten Progress Bar" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +blessed = ">=1.17.7" +prefixed = ">=0.3.2" + [[package]] name = "evdev" version = "1.4.0" @@ -637,7 +649,7 @@ view = ["PySide2 (>=5.11,<6.0)"] [package.source] type = "legacy" -url = "https://d.r1.wbsprt.com/pype.club/distribute" +url = "https://distribute.openpype.io/wheels" reference = "pype" [[package]] @@ -696,6 +708,14 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +[[package]] +name = "prefixed" +version = "0.3.2" +description = "Prefixed alternative numeric library" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "protobuf" version = "3.15.6" @@ -1391,7 +1411,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "4905515073ad2bf2a8517d513d68e48669b6a829f24e540b2dd60bc70cbea26b" +content-hash = "b356e327dbaa1aa38dbf1463901f64539f2c8d07be8d8a017e83b8a1554dbff9" [metadata.files] acre = [] @@ -1636,6 +1656,10 @@ docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] +enlighten = [ + {file = "enlighten-1.9.0-py2.py3-none-any.whl", hash = "sha256:5c59e41505702243c6b26437403e371d2a146ac72de5f706376f738ea8f32659"}, + {file = "enlighten-1.9.0.tar.gz", hash = "sha256:539cc308ccc0c3bfb50feb1b2da94c1a1ac21e80fe95e984221de8966d48f428"}, +] evdev = [ {file = "evdev-1.4.0.tar.gz", hash = "sha256:8782740eb1a86b187334c07feb5127d3faa0b236e113206dfe3ae8f77fb1aaf1"}, ] @@ -1896,6 +1920,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +prefixed = [ + {file = "prefixed-0.3.2-py2.py3-none-any.whl", hash = "sha256:5e107306462d63f2f03c529dbf11b0026fdfec621a9a008ca639d71de22995c3"}, + {file = "prefixed-0.3.2.tar.gz", hash = "sha256:ca48277ba5fa8346dd4b760847da930c7b84416387c39e93affef086add2c029"}, +] protobuf = [ {file = "protobuf-3.15.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1771ef20e88759c4d81db213e89b7a1fc53937968e12af6603c658ee4bcbfa38"}, {file = "protobuf-3.15.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1a66261a402d05c8ad8c1fde8631837307bf8d7e7740a4f3941fc3277c2e1528"}, diff --git a/pyproject.toml b/pyproject.toml index b6ca6574c4..589342da05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ jinxed = [ { version = "^1.0.1", markers = "sys_platform == 'linux'" } ] python3-xlib = { version="*", markers = "sys_platform == 'linux'"} +enlighten = "^1.9.0" [tool.poetry.dev-dependencies] flake8 = "^3.7" @@ -61,8 +62,8 @@ sphinx-rtd-theme = "*" sphinxcontrib-websupport = "*" sphinx-qt-documentation = "*" recommonmark = "*" -tqdm = "*" wheel = "*" +enlighten = "*" # cool terminal progress bars [tool.poetry.urls] "Bug Tracker" = "https://github.com/pypeclub/pype/issues" @@ -70,8 +71,34 @@ wheel = "*" [[tool.poetry.source]] name = "pype" -url = "https://d.r1.wbsprt.com/pype.club/distribute/" +url = "https://distribute.openpype.io/wheels/" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[pype] + +[pype.thirdparty.ffmpeg.windows] +url = "https://distribute.openpype.io/thirdparty/ffmpeg-4.13-windows.zip" +hash = "43988ebcba98313635f06f2ca7e2dd52670710ebceefaa77107321b1def30472" + +[pype.thirdparty.ffmpeg.linux] +url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-linux.tgz" +hash = "sha256:..." + +[pype.thirdparty.ffmpeg.darwin] +url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-darwin.tgz" +hash = "sha256:..." + +[pype.thirdparty.oiio.windows] +url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.0-windows.zip" +hash = "fd2e00278e01e85dcee7b4a6969d1a16f13016ec16700fb0366dbb1b1f3c37ad" + +[pype.thirdparty.oiio.linux] +url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-linux.tgz" +hash = "sha256:..." + +[pype.thirdparty.oiio.darwin] +url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz" +hash = "sha256:..." \ No newline at end of file diff --git a/tools/fetch_thirdparty_libs.ps1 b/tools/fetch_thirdparty_libs.ps1 new file mode 100644 index 0000000000..7eed5a22db --- /dev/null +++ b/tools/fetch_thirdparty_libs.ps1 @@ -0,0 +1,21 @@ +<# +.SYNOPSIS + Download and extract third-party dependencies for Pype. + +.DESCRIPTION + This will download third-party dependencies specified in pyproject.toml + and extract them to vendor/bin folder. + #> + +.EXAMPLE + +PS> .\fetch_thirdparty_libs.ps1 + +#> +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$pype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $pype_root + +& poetry run python "$($pype_root)\tools\fetch_thirdparty_libs.py" +Set-Location -Path $current_dir diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py new file mode 100644 index 0000000000..cda4d3a6fd --- /dev/null +++ b/tools/fetch_thirdparty_libs.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""Fetch, verify and process third-party dependencies of Pype. + +Those should be defined in `pyproject.toml` in Pype sources root. + +""" +import os +import sys +import toml +import shutil +from pathlib import Path +from urllib.parse import urlparse +import requests +import enlighten +import platform +import blessed +import tempfile +import math +import hashlib +import tarfile +import zipfile +import time + + +term = blessed.Terminal() +manager = enlighten.get_manager() +hash_buffer_size = 65536 + + +def sha256_sum(filename: Path): + """Calculate sha256 hash for given file. + + Args: + filename (Path): path to file. + + Returns: + str: hex hash. + + """ + _hash = hashlib.sha256() + with open(filename, 'rb', buffering=0) as f: + buffer = bytearray(128*1024) + mv = memoryview(buffer) + for n in iter(lambda: f.readinto(mv), 0): + _hash.update(mv[:n]) + return _hash.hexdigest() + + +def _print(msg: str, message_type: int = 0) -> None: + """Print message to console. + + Args: + msg (str): message to print + message_type (int): type of message (0 info, 1 error, 2 note) + + """ + if message_type == 0: + header = term.aquamarine3(">>> ") + elif message_type == 1: + header = term.orangered2("!!! ") + elif message_type == 2: + header = term.tan1("... ") + else: + header = term.darkolivegreen3("--- ") + + print("{}{}".format(header, msg)) + + +_print("Processing third-party dependencies ...") +start_time = time.time_ns() +pype_root = Path(os.path.dirname(__file__)).parent +pyproject = toml.load(pype_root / "pyproject.toml") +platform_name = platform.system().lower() + +try: + thirdparty = pyproject["pype"]["thirdparty"] +except AttributeError: + _print("No third-party libraries specified in pyproject.toml", 1) + sys.exit(1) + +for k, v in thirdparty.items(): + _print(f"processing {k}") + destination_path = pype_root / "vendor" / "bin" / k / platform_name + url = v.get(platform_name).get("url") + + + if not v.get(platform_name): + _print(("missing definition for current " + f"platform [ {platform_name} ]"), 1) + sys.exit(1) + + parsed_url = urlparse(url) + + # check if file is already extracted in /vendor/bin + if destination_path.exists(): + _print("destination path already exists, deleting ...", 2) + if destination_path.is_dir(): + try: + shutil.rmtree(destination_path) + except OSError as e: + _print("cannot delete folder.", 1) + raise SystemExit(e) + + # download file + _print(f"Downloading {url} ...") + with tempfile.TemporaryDirectory() as temp_dir: + temp_file = Path(temp_dir) / Path(parsed_url.path).name + + r = requests.get(url, stream=True) + content_len = int(r.headers.get('Content-Length', '0')) or None + with manager.counter(color='green', + total=content_len and math.ceil(content_len / 2 ** 20), # noqa: E501 + unit='MiB', leave=False) as counter: + with open(temp_file, 'wb', buffering=2 ** 24) as file_handle: + for chunk in r.iter_content(chunk_size=2 ** 20): + file_handle.write(chunk) + counter.update() + + # get file with checksum + _print("Calculating sha256 ...", 2) + calc_checksum = sha256_sum(temp_file) + if v.get(platform_name).get("hash") != calc_checksum: + _print("Downloaded files checksum invalid.") + sys.exit(1) + + _print("File OK", 3) + if not destination_path.exists(): + destination_path.mkdir(parents=True) + + # extract to destination + archive_type = temp_file.suffix.lstrip(".") + _print(f"Extracting {archive_type} file to {destination_path}") + if archive_type in ['zip']: + zip_file = zipfile.ZipFile(temp_file) + zip_file.extractall(destination_path) + zip_file.close() + + elif archive_type in [ + 'tar', 'tgz', 'tar.gz', 'tar.xz', 'tar.bz2' + ]: + if archive_type == 'tar': + tar_type = 'r:' + elif archive_type.endswith('xz'): + tar_type = 'r:xz' + elif archive_type.endswith('gz'): + tar_type = 'r:gz' + elif archive_type.endswith('bz2'): + tar_type = 'r:bz2' + else: + tar_type = 'r:*' + try: + tar_file = tarfile.open(temp_file, tar_type) + except tarfile.ReadError: + raise SystemExit( + "corrupted archive: also consider to download the " + "archive manually, add its path to the url, run " + "`./pype deploy`" + ) + tar_file.extractall(destination_path) + tar_file.close() + _print("Extraction OK", 3) + +end_time = time.time_ns() +total_time = (end_time - start_time) / 1000000000 +_print(f"Downloading and extracting took {total_time} secs.") diff --git a/tools/fetch_thirdparty_libs.sh b/tools/fetch_thirdparty_libs.sh new file mode 100755 index 0000000000..e305b4b3e4 --- /dev/null +++ b/tools/fetch_thirdparty_libs.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +# Run Pype Tray + + +art () { + cat <<-EOF + ____________ + /\\ ___ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ ___ ___ ___ + \\ \\ \\___/ /\\ \\ \\ \\\\ \\\\ \\ + \\ \\____\\ \\ \\_____\\ \\__\\\\__\\\\__\\ + \\/____/ \\/_____/ . PYPE Club . + +EOF +} + +# Colors for terminal + +RST='\033[0m' # Text Reset + +# Regular Colors +Black='\033[0;30m' # Black +Red='\033[0;31m' # Red +Green='\033[0;32m' # Green +Yellow='\033[0;33m' # Yellow +Blue='\033[0;34m' # Blue +Purple='\033[0;35m' # Purple +Cyan='\033[0;36m' # Cyan +White='\033[0;37m' # White + +# Bold +BBlack='\033[1;30m' # Black +BRed='\033[1;31m' # Red +BGreen='\033[1;32m' # Green +BYellow='\033[1;33m' # Yellow +BBlue='\033[1;34m' # Blue +BPurple='\033[1;35m' # Purple +BCyan='\033[1;36m' # Cyan +BWhite='\033[1;37m' # White + +# Bold High Intensity +BIBlack='\033[1;90m' # Black +BIRed='\033[1;91m' # Red +BIGreen='\033[1;92m' # Green +BIYellow='\033[1;93m' # Yellow +BIBlue='\033[1;94m' # Blue +BIPurple='\033[1;95m' # Purple +BICyan='\033[1;96m' # Cyan +BIWhite='\033[1;97m' # White + + +############################################################################## +# Detect required version of python +# Globals: +# colors +# PYTHON +# Arguments: +# None +# Returns: +# None +############################################################################### +detect_python () { + echo -e "${BIGreen}>>>${RST} Using python \c" + local version_command="import sys;print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1]))" + local python_version="$(python3 <<< ${version_command})" + oIFS="$IFS" + IFS=. + set -- $python_version + IFS="$oIFS" + if [ "$1" -ge "3" ] && [ "$2" -ge "6" ] ; then + if [ "$2" -gt "7" ] ; then + echo -e "${BIWhite}[${RST} ${BIRed}$1.$2 ${BIWhite}]${RST} - ${BIRed}FAILED${RST} ${BIYellow}Version is new and unsupported, use${RST} ${BIPurple}3.7.x${RST}"; return 1; + else + echo -e "${BIWhite}[${RST} ${BIGreen}$1.$2${RST} ${BIWhite}]${RST}" + fi + PYTHON="python3" + else + command -v python3 >/dev/null 2>&1 || { echo -e "${BIRed}$1.$2$ - ${BIRed}FAILED${RST} ${BIYellow}Version is old and unsupported${RST}"; return 1; } + fi +} + +############################################################################## +# Clean pyc files in specified directory +# Globals: +# None +# Arguments: +# Optional path to clean +# Returns: +# None +############################################################################### +clean_pyc () { + local path + path=$pype_root + echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" + find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete + echo -e "${BIGreen}DONE${RST}" +} + +############################################################################## +# Return absolute path +# Globals: +# None +# Arguments: +# Path to resolve +# Returns: +# None +############################################################################### +realpath () { + echo $(cd $(dirname "$1"); pwd)/$(basename "$1") +} + +# Main +main () { + echo -e "${BGreen}" + art + echo -e "${RST}" + detect_python || return 1 + + # Directories + pype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + pushd "$pype_root" > /dev/null || return > /dev/null + + echo -e "${BIGreen}>>>${RST} Running Pype tool ..." + poetry run python3 "$pype_root/tools/fetch_thirdparty_libs.py" +} + +main \ No newline at end of file From 91e8bfc941693d5ec84b5339658869b9cbcab38e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 31 Mar 2021 21:50:07 +0200 Subject: [PATCH 023/515] hound --- tools/fetch_thirdparty_libs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py index cda4d3a6fd..5d38d69767 100644 --- a/tools/fetch_thirdparty_libs.py +++ b/tools/fetch_thirdparty_libs.py @@ -39,7 +39,7 @@ def sha256_sum(filename: Path): """ _hash = hashlib.sha256() with open(filename, 'rb', buffering=0) as f: - buffer = bytearray(128*1024) + buffer = bytearray(128 * 1024) mv = memoryview(buffer) for n in iter(lambda: f.readinto(mv), 0): _hash.update(mv[:n]) @@ -83,7 +83,6 @@ for k, v in thirdparty.items(): destination_path = pype_root / "vendor" / "bin" / k / platform_name url = v.get(platform_name).get("url") - if not v.get(platform_name): _print(("missing definition for current " f"platform [ {platform_name} ]"), 1) From 3254e55144bbd2ce48ddf896e99941aac0f3ab7e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Apr 2021 11:41:57 +0200 Subject: [PATCH 024/515] merge develop --- openpype/modules/ftrack/python2_vendor/arrow | 1 + openpype/modules/ftrack/python2_vendor/ftrack-python-api | 1 + repos/avalon-core | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) create mode 160000 openpype/modules/ftrack/python2_vendor/arrow create mode 160000 openpype/modules/ftrack/python2_vendor/ftrack-python-api diff --git a/openpype/modules/ftrack/python2_vendor/arrow b/openpype/modules/ftrack/python2_vendor/arrow new file mode 160000 index 0000000000..b746fedf72 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow @@ -0,0 +1 @@ +Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/ftrack/python2_vendor/ftrack-python-api new file mode 160000 index 0000000000..d277f474ab --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api @@ -0,0 +1 @@ +Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/repos/avalon-core b/repos/avalon-core index 911a29a44d..bbba8765c4 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 911a29a44d5e6a128f4326deb1155184fe811fd7 +Subproject commit bbba8765c431ee124590e4f12d2e56db4d62eacd From 51b8b61365524e1702838db799a20ca2197e51c0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Apr 2021 11:46:38 +0200 Subject: [PATCH 025/515] rebrand to OpenPype --- pyproject.toml | 14 +++++++------- tools/fetch_thirdparty_libs.ps1 | 8 ++++---- tools/fetch_thirdparty_libs.py | 18 +++++++----------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 589342da05..bc808b5b9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,28 +77,28 @@ url = "https://distribute.openpype.io/wheels/" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" -[pype] +[openpype] -[pype.thirdparty.ffmpeg.windows] +[openpype.thirdparty.ffmpeg.windows] url = "https://distribute.openpype.io/thirdparty/ffmpeg-4.13-windows.zip" hash = "43988ebcba98313635f06f2ca7e2dd52670710ebceefaa77107321b1def30472" -[pype.thirdparty.ffmpeg.linux] +[openpype.thirdparty.ffmpeg.linux] url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-linux.tgz" hash = "sha256:..." -[pype.thirdparty.ffmpeg.darwin] +[openpype.thirdparty.ffmpeg.darwin] url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-darwin.tgz" hash = "sha256:..." -[pype.thirdparty.oiio.windows] +[openpype.thirdparty.oiio.windows] url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.0-windows.zip" hash = "fd2e00278e01e85dcee7b4a6969d1a16f13016ec16700fb0366dbb1b1f3c37ad" -[pype.thirdparty.oiio.linux] +[openpype.thirdparty.oiio.linux] url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-linux.tgz" hash = "sha256:..." -[pype.thirdparty.oiio.darwin] +[openpype.thirdparty.oiio.darwin] url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz" hash = "sha256:..." \ No newline at end of file diff --git a/tools/fetch_thirdparty_libs.ps1 b/tools/fetch_thirdparty_libs.ps1 index 7eed5a22db..f79cfdd267 100644 --- a/tools/fetch_thirdparty_libs.ps1 +++ b/tools/fetch_thirdparty_libs.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Download and extract third-party dependencies for Pype. + Download and extract third-party dependencies for OpenPype. .DESCRIPTION This will download third-party dependencies specified in pyproject.toml @@ -14,8 +14,8 @@ PS> .\fetch_thirdparty_libs.ps1 #> $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent -$pype_root = (Get-Item $script_dir).parent.FullName -Set-Location -Path $pype_root +$openpype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $openpype_root -& poetry run python "$($pype_root)\tools\fetch_thirdparty_libs.py" +& poetry run python "$($openpype_root)\tools\fetch_thirdparty_libs.py" Set-Location -Path $current_dir diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py index 5d38d69767..75ee052950 100644 --- a/tools/fetch_thirdparty_libs.py +++ b/tools/fetch_thirdparty_libs.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""Fetch, verify and process third-party dependencies of Pype. +"""Fetch, verify and process third-party dependencies of OpenPype. -Those should be defined in `pyproject.toml` in Pype sources root. +Those should be defined in `pyproject.toml` in OpenPype sources root. """ import os @@ -68,19 +68,19 @@ def _print(msg: str, message_type: int = 0) -> None: _print("Processing third-party dependencies ...") start_time = time.time_ns() -pype_root = Path(os.path.dirname(__file__)).parent -pyproject = toml.load(pype_root / "pyproject.toml") +openpype_root = Path(os.path.dirname(__file__)).parent +pyproject = toml.load(openpype_root / "pyproject.toml") platform_name = platform.system().lower() try: - thirdparty = pyproject["pype"]["thirdparty"] + thirdparty = pyproject["openpype"]["thirdparty"] except AttributeError: _print("No third-party libraries specified in pyproject.toml", 1) sys.exit(1) for k, v in thirdparty.items(): _print(f"processing {k}") - destination_path = pype_root / "vendor" / "bin" / k / platform_name + destination_path = openpype_root / "vendor" / "bin" / k / platform_name url = v.get(platform_name).get("url") if not v.get(platform_name): @@ -150,11 +150,7 @@ for k, v in thirdparty.items(): try: tar_file = tarfile.open(temp_file, tar_type) except tarfile.ReadError: - raise SystemExit( - "corrupted archive: also consider to download the " - "archive manually, add its path to the url, run " - "`./pype deploy`" - ) + raise SystemExit("corrupted archive") tar_file.extractall(destination_path) tar_file.close() _print("Extraction OK", 3) From 399f9bd059a190c8b1c640bb950e070e1b6dec5e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 6 Apr 2021 12:27:49 +0200 Subject: [PATCH 026/515] SyncServer adding functionality to Loader In one big commit as PR wasnt merged before rebranding and merge exploded --- openpype/modules/__init__.py | 4 +- openpype/modules/sync_server/__init__.py | 4 +- .../modules/sync_server/providers/__init__.py | 0 .../providers/abstract_provider.py | 109 +- .../modules/sync_server/providers/gdrive.py | 336 ++-- openpype/modules/sync_server/providers/lib.py | 8 +- .../sync_server/providers/local_drive.py | 60 +- openpype/modules/sync_server/sync_server.py | 1567 +++-------------- .../modules/sync_server/sync_server_module.py | 1194 +++++++++++++ openpype/modules/sync_server/tray/app.py | 298 +++- openpype/modules/sync_server/utils.py | 8 +- openpype/plugins/load/add_site.py | 33 + openpype/plugins/load/delete_old_versions.py | 6 +- openpype/plugins/load/remove_site.py | 33 + 14 files changed, 1986 insertions(+), 1674 deletions(-) create mode 100644 openpype/modules/sync_server/providers/__init__.py create mode 100644 openpype/modules/sync_server/sync_server_module.py create mode 100644 openpype/plugins/load/add_site.py create mode 100644 openpype/plugins/load/remove_site.py diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 4b120647e1..d7c6d99fe6 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -41,7 +41,7 @@ from .log_viewer import LogViewModule from .muster import MusterModule from .deadline import DeadlineModule from .standalonepublish_action import StandAlonePublishAction -from .sync_server import SyncServer +from .sync_server import SyncServerModule __all__ = ( @@ -82,5 +82,5 @@ __all__ = ( "DeadlineModule", "StandAlonePublishAction", - "SyncServer" + "SyncServerModule" ) diff --git a/openpype/modules/sync_server/__init__.py b/openpype/modules/sync_server/__init__.py index 7123536fcf..a814f0db62 100644 --- a/openpype/modules/sync_server/__init__.py +++ b/openpype/modules/sync_server/__init__.py @@ -1,5 +1,5 @@ -from openpype.modules.sync_server.sync_server import SyncServer +from openpype.modules.sync_server.sync_server_module import SyncServerModule def tray_init(tray_widget, main_widget): - return SyncServer() + return SyncServerModule() diff --git a/openpype/modules/sync_server/providers/__init__.py b/openpype/modules/sync_server/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 001d4c4d50..35dca87acf 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -1,16 +1,22 @@ -from abc import ABCMeta, abstractmethod +import abc, six +from openpype.api import Anatomy, Logger + +log = Logger().get_logger("SyncServer") -class AbstractProvider(metaclass=ABCMeta): +@six.add_metaclass(abc.ABCMeta) +class AbstractProvider: - def __init__(self, site_name, tree=None, presets=None): + def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None self.active = False self.site_name = site_name self.presets = presets - @abstractmethod + super(AbstractProvider, self).__init__() + + @abc.abstractmethod def is_active(self): """ Returns True if provider is activated, eg. has working credentials. @@ -18,36 +24,54 @@ class AbstractProvider(metaclass=ABCMeta): (boolean) """ - @abstractmethod - def upload_file(self, source_path, target_path, overwrite=True): + @abc.abstractmethod + def upload_file(self, source_path, path, + server, collection, file, representation, site, + overwrite=False): """ Copy file from 'source_path' to 'target_path' on provider. Use 'overwrite' boolean to rewrite existing file on provider Args: - source_path (string): absolute path on local system - target_path (string): absolute path on provider (GDrive etc.) - overwrite (boolean): True if overwite existing + source_path (string): + path (string): absolute path with or without name of the file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name Returns: (string) file_id of created file, raises exception """ pass - @abstractmethod - def download_file(self, source_path, local_path, overwrite=True): + @abc.abstractmethod + def download_file(self, source_path, local_path, + server, collection, file, representation, site, + overwrite=False): """ Download file from provider into local system Args: source_path (string): absolute path on provider - local_path (string): absolute path on local - overwrite (bool): default set to True + local_path (string): absolute path with or without name of the file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name Returns: None """ pass - @abstractmethod + @abc.abstractmethod def delete_file(self, path): """ Deletes file from 'path'. Expects path to specific file. @@ -60,7 +84,7 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod + @abc.abstractmethod def list_folder(self, folder_path): """ List all files and subfolders of particular path non-recursively. @@ -72,7 +96,7 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod + @abc.abstractmethod def create_folder(self, folder_path): """ Create all nonexistent folders and subfolders in 'path'. @@ -85,7 +109,7 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod + @abc.abstractmethod def get_tree(self): """ Creates folder structure for providers which do not provide @@ -94,16 +118,49 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod - def resolve_path(self, path, root_config, anatomy=None): + @abc.abstractmethod + def get_roots_config(self, anatomy=None): """ - Replaces root placeholders with appropriate real value from - 'root_configs' (from Settings or Local Settings) or Anatomy - (mainly for 'studio' site) + Returns root values for path resolving - Args: - path(string): path with '{root[work]}/...' - root_config(dict): from Settings or Local Settings - anatomy (Anatomy): prepared anatomy object for project + Takes value from Anatomy which takes values from Settings + overridden by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach """ pass + + def resolve_path(self, path, root_config=None, anatomy=None): + """ + Replaces all root placeholders with proper values + + Args: + path(string): root[work]/folder... + root_config (dict): {'work': "c:/..."...} + anatomy (Anatomy): object of Anatomy + Returns: + (string): proper url + """ + if root_config and not root_config.get("root"): + root_config = {"root": root_config} + else: + root_config = self.get_roots_config(anatomy) + + try: + if not root_config: + raise KeyError + + path = path.format(**root_config) + except KeyError: + try: + path = anatomy.fill_root(path) + except KeyError: + msg = "Error in resolving local root from anatomy" + log.error(msg) + raise ValueError(msg) + + return path diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 6c01bc4e6f..b6ece5263b 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -6,10 +6,11 @@ from googleapiclient import errors from .abstract_provider import AbstractProvider from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload from openpype.api import Logger -from openpype.api import get_system_settings +from openpype.api import get_system_settings, Anatomy from ..utils import time_function import time + SCOPES = ['https://www.googleapis.com/auth/drive.metadata.readonly', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive.readonly'] # for write|delete @@ -45,9 +46,10 @@ class GDriveHandler(AbstractProvider): MY_DRIVE_STR = 'My Drive' # name of root folder of regular Google drive CHUNK_SIZE = 2097152 # must be divisible by 256! - def __init__(self, site_name, tree=None, presets=None): + def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None self.active = False + self.project_name = project_name self.site_name = site_name self.presets = presets @@ -65,137 +67,6 @@ class GDriveHandler(AbstractProvider): self._tree = tree self.active = True - def _get_gd_service(self): - """ - Authorize client with 'credentials.json', uses service account. - Service account needs to have target folder shared with. - Produces service that communicates with GDrive API. - - Returns: - None - """ - creds = service_account.Credentials.from_service_account_file( - self.presets["credentials_url"], - scopes=SCOPES) - service = build('drive', 'v3', - credentials=creds, cache_discovery=False) - return service - - def _prepare_root_info(self): - """ - Prepare info about roots and theirs folder ids from 'presets'. - Configuration might be for single or multiroot projects. - Regular My Drive and Shared drives are implemented, their root - folder ids need to be queried in slightly different way. - - Returns: - (dicts) of dicts where root folders are keys - """ - roots = {} - for path in self.get_roots_config().values(): - if self.MY_DRIVE_STR in path: - roots[self.MY_DRIVE_STR] = self.service.files()\ - .get(fileId='root').execute() - else: - shared_drives = [] - page_token = None - - while True: - response = self.service.drives().list( - pageSize=100, - pageToken=page_token).execute() - shared_drives.extend(response.get('drives', [])) - page_token = response.get('nextPageToken', None) - if page_token is None: - break - - folders = path.split('/') - if len(folders) < 2: - raise ValueError("Wrong root folder definition {}". - format(path)) - - for shared_drive in shared_drives: - if folders[1] in shared_drive["name"]: - roots[shared_drive["name"]] = { - "name": shared_drive["name"], - "id": shared_drive["id"]} - if self.MY_DRIVE_STR not in roots: # add My Drive always - roots[self.MY_DRIVE_STR] = self.service.files() \ - .get(fileId='root').execute() - - return roots - - @time_function - def _build_tree(self, folders): - """ - Create in-memory structure resolving paths to folder id as - recursive querying might be slower. - Initialized in the time of class initialization. - Maybe should be persisted - Tree is structure of path to id: - '/ROOT': {'id': '1234567'} - '/ROOT/PROJECT_FOLDER': {'id':'222222'} - '/ROOT/PROJECT_FOLDER/Assets': {'id': '3434545'} - Args: - folders (list): list of dictionaries with folder metadata - Returns: - (dictionary) path as a key, folder id as a value - """ - log.debug("build_tree len {}".format(len(folders))) - root_ids = [] - default_root_id = None - tree = {} - ending_by = {} - for root_name, root in self.root.items(): # might be multiple roots - if root["id"] not in root_ids: - tree["/" + root_name] = {"id": root["id"]} - ending_by[root["id"]] = "/" + root_name - root_ids.append(root["id"]) - - if self.MY_DRIVE_STR == root_name: - default_root_id = root["id"] - - no_parents_yet = {} - while folders: - folder = folders.pop(0) - parents = folder.get("parents", []) - # weird cases, shared folders, etc, parent under root - if not parents: - parent = default_root_id - else: - parent = parents[0] - - if folder["id"] in root_ids: # do not process root - continue - - if parent in ending_by: - path_key = ending_by[parent] + "/" + folder["name"] - ending_by[folder["id"]] = path_key - tree[path_key] = {"id": folder["id"]} - else: - no_parents_yet.setdefault(parent, []).append((folder["id"], - folder["name"])) - loop_cnt = 0 - # break if looped more then X times - safety against infinite loop - while no_parents_yet and loop_cnt < 20: - - keys = list(no_parents_yet.keys()) - for parent in keys: - if parent in ending_by.keys(): - subfolders = no_parents_yet.pop(parent) - for folder_id, folder_name in subfolders: - path_key = ending_by[parent] + "/" + folder_name - ending_by[folder_id] = path_key - tree[path_key] = {"id": folder_id} - loop_cnt += 1 - - if len(no_parents_yet) > 0: - log.debug("Some folders path are not resolved {}". - format(no_parents_yet)) - log.debug("Remove deleted folders from trash.") - - return tree - def is_active(self): """ Returns True if provider is activated, eg. has working credentials. @@ -204,6 +75,21 @@ class GDriveHandler(AbstractProvider): """ return self.active + def get_roots_config(self, anatomy=None): + """ + Returns root values for path resolving + + Use only Settings as GDrive cannot be modified by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach + """ + # GDrive roots cannot be locally overridden + return self.presets['root'] + def get_tree(self): """ Building of the folder tree could be potentially expensive, @@ -217,26 +103,6 @@ class GDriveHandler(AbstractProvider): self._tree = self._build_tree(self.list_folders()) return self._tree - def get_roots_config(self): - """ - Returns value from presets of roots. It calculates with multi - roots. Config should be simple key value, or dictionary. - - Examples: - "root": "/My Drive" - OR - "root": {"root_ONE": "value", "root_TWO":"value} - Returns: - (dict) - {"root": {"root": "/My Drive"}} - OR - {"root": {"root_ONE": "value", "root_TWO":"value}} - Format is importing for usage of python's format ** approach - """ - roots = self.presets["root"] - if isinstance(roots, str): - roots = {"root": roots} - return roots - def create_folder(self, path): """ Create all nonexistent folders and subfolders in 'path'. @@ -510,20 +376,6 @@ class GDriveHandler(AbstractProvider): self.service.files().delete(fileId=file["id"], supportsAllDrives=True).execute() - def _get_folder_metadata(self, path): - """ - Get info about folder with 'path' - Args: - path (string): - - Returns: - (dictionary) with metadata or raises ValueError - """ - try: - return self.get_tree()[path] - except Exception: - raise ValueError("Uknown folder id {}".format(id)) - def list_folder(self, folder_path): """ List all files and subfolders of particular path non-recursively. @@ -678,15 +530,151 @@ class GDriveHandler(AbstractProvider): return return provider_presets - def resolve_path(self, path, root_config, anatomy=None): - if not root_config.get("root"): - root_config = {"root": root_config} + def _get_gd_service(self): + """ + Authorize client with 'credentials.json', uses service account. + Service account needs to have target folder shared with. + Produces service that communicates with GDrive API. + Returns: + None + """ + creds = service_account.Credentials.from_service_account_file( + self.presets["credentials_url"], + scopes=SCOPES) + service = build('drive', 'v3', + credentials=creds, cache_discovery=False) + return service + + def _prepare_root_info(self): + """ + Prepare info about roots and theirs folder ids from 'presets'. + Configuration might be for single or multiroot projects. + Regular My Drive and Shared drives are implemented, their root + folder ids need to be queried in slightly different way. + + Returns: + (dicts) of dicts where root folders are keys + """ + roots = {} + config_roots = self.get_roots_config() + for path in config_roots.values(): + if self.MY_DRIVE_STR in path: + roots[self.MY_DRIVE_STR] = self.service.files()\ + .get(fileId='root').execute() + else: + shared_drives = [] + page_token = None + + while True: + response = self.service.drives().list( + pageSize=100, + pageToken=page_token).execute() + shared_drives.extend(response.get('drives', [])) + page_token = response.get('nextPageToken', None) + if page_token is None: + break + + folders = path.split('/') + if len(folders) < 2: + raise ValueError("Wrong root folder definition {}". + format(path)) + + for shared_drive in shared_drives: + if folders[1] in shared_drive["name"]: + roots[shared_drive["name"]] = { + "name": shared_drive["name"], + "id": shared_drive["id"]} + if self.MY_DRIVE_STR not in roots: # add My Drive always + roots[self.MY_DRIVE_STR] = self.service.files() \ + .get(fileId='root').execute() + + return roots + + @time_function + def _build_tree(self, folders): + """ + Create in-memory structure resolving paths to folder id as + recursive querying might be slower. + Initialized in the time of class initialization. + Maybe should be persisted + Tree is structure of path to id: + '/ROOT': {'id': '1234567'} + '/ROOT/PROJECT_FOLDER': {'id':'222222'} + '/ROOT/PROJECT_FOLDER/Assets': {'id': '3434545'} + Args: + folders (list): list of dictionaries with folder metadata + Returns: + (dictionary) path as a key, folder id as a value + """ + log.debug("build_tree len {}".format(len(folders))) + root_ids = [] + default_root_id = None + tree = {} + ending_by = {} + for root_name, root in self.root.items(): # might be multiple roots + if root["id"] not in root_ids: + tree["/" + root_name] = {"id": root["id"]} + ending_by[root["id"]] = "/" + root_name + root_ids.append(root["id"]) + + if self.MY_DRIVE_STR == root_name: + default_root_id = root["id"] + + no_parents_yet = {} + while folders: + folder = folders.pop(0) + parents = folder.get("parents", []) + # weird cases, shared folders, etc, parent under root + if not parents: + parent = default_root_id + else: + parent = parents[0] + + if folder["id"] in root_ids: # do not process root + continue + + if parent in ending_by: + path_key = ending_by[parent] + "/" + folder["name"] + ending_by[folder["id"]] = path_key + tree[path_key] = {"id": folder["id"]} + else: + no_parents_yet.setdefault(parent, []).append((folder["id"], + folder["name"])) + loop_cnt = 0 + # break if looped more then X times - safety against infinite loop + while no_parents_yet and loop_cnt < 20: + + keys = list(no_parents_yet.keys()) + for parent in keys: + if parent in ending_by.keys(): + subfolders = no_parents_yet.pop(parent) + for folder_id, folder_name in subfolders: + path_key = ending_by[parent] + "/" + folder_name + ending_by[folder_id] = path_key + tree[path_key] = {"id": folder_id} + loop_cnt += 1 + + if len(no_parents_yet) > 0: + log.debug("Some folders path are not resolved {}". + format(no_parents_yet)) + log.debug("Remove deleted folders from trash.") + + return tree + + def _get_folder_metadata(self, path): + """ + Get info about folder with 'path' + Args: + path (string): + + Returns: + (dictionary) with metadata or raises ValueError + """ try: - return path.format(**root_config) - except KeyError: - msg = "Error in resolving remote root, unknown key" - log.error(msg) + return self.get_tree()[path] + except Exception: + raise ValueError("Uknown folder id {}".format(id)) def _handle_q(self, q, trashed=False): """ API list call contain trashed and hidden files/folder by default. diff --git a/openpype/modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py index 144594ecbe..58947e115d 100644 --- a/openpype/modules/sync_server/providers/lib.py +++ b/openpype/modules/sync_server/providers/lib.py @@ -1,4 +1,3 @@ -from enum import Enum from .gdrive import GDriveHandler from .local_drive import LocalDriveHandler @@ -25,7 +24,8 @@ class ProviderFactory: """ self.providers[provider] = (creator, batch_limit) - def get_provider(self, provider, site_name, tree=None, presets=None): + def get_provider(self, provider, project_name, site_name, + tree=None, presets=None): """ Returns new instance of provider client for specific site. One provider could have multiple sites. @@ -37,6 +37,7 @@ class ProviderFactory: provider (string): 'gdrive','S3' site_name (string): descriptor of site, different service accounts must have different site name + project_name (string): different projects could have diff. sites tree (dictionary): - folder paths to folder id structure presets (dictionary): config for provider and site (eg. "credentials_url"..) @@ -44,7 +45,8 @@ class ProviderFactory: (implementation of AbstractProvider) """ creator_info = self._get_creator_info(provider) - site = creator_info[0](site_name, tree, presets) # call init + # call init + site = creator_info[0](project_name, site_name, tree, presets) return site diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index fa8dd4c183..1f4fca80eb 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -4,7 +4,7 @@ import shutil import threading import time -from openpype.api import Logger +from openpype.api import Logger, Anatomy from .abstract_provider import AbstractProvider log = Logger().get_logger("SyncServer") @@ -12,6 +12,14 @@ log = Logger().get_logger("SyncServer") class LocalDriveHandler(AbstractProvider): """ Handles required operations on mounted disks with OS """ + def __init__(self, project_name, site_name, tree=None, presets=None): + self.presets = None + self.active = False + self.project_name = project_name + self.site_name = site_name + + self.active = self.is_active() + def is_active(self): return True @@ -82,27 +90,37 @@ class LocalDriveHandler(AbstractProvider): os.makedirs(folder_path, exist_ok=True) return folder_path + def get_roots_config(self, anatomy=None): + """ + Returns root values for path resolving + + Takes value from Anatomy which takes values from Settings + overridden by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach + """ + if not anatomy: + anatomy = Anatomy(self.project_name, + self._normalize_site_name(self.site_name)) + + return {'root': anatomy.roots} + def get_tree(self): return - def resolve_path(self, path, root_config, anatomy=None): - if root_config and not root_config.get("root"): - root_config = {"root": root_config} + def get_configurable_items_for_site(self): + """ + Returns list of items that should be configurable by User - try: - if not root_config: - raise KeyError - - path = path.format(**root_config) - except KeyError: - try: - path = anatomy.fill_root(path) - except KeyError: - msg = "Error in resolving local root from anatomy" - log.error(msg) - raise ValueError(msg) - - return path + Returns: + (list of dict) + [{key:"root", label:"root", value:"valueFromSettings"}] + """ + pass def _copy(self, source_path, target_path): print("copying {}->{}".format(source_path, target_path)) @@ -133,3 +151,9 @@ class LocalDriveHandler(AbstractProvider): ) target_file_size = os.path.getsize(target_path) time.sleep(0.5) + + def _normalize_site_name(self, site_name): + """Transform user id to 'local' for Local settings""" + if site_name != 'studio': + return 'local' + return site_name diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 62a5dc675c..e97c0e8844 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -1,1391 +1,225 @@ -from openpype.api import ( - Anatomy, - get_project_settings, - get_local_site_id) - +"""Python 3 only implementation.""" +import os +import asyncio import threading import concurrent.futures from concurrent.futures._base import CancelledError -from enum import Enum -from datetime import datetime - from .providers import lib -import os -from bson.objectid import ObjectId - -from avalon.api import AvalonMongoDB -from .utils import time_function - -import six from openpype.lib import PypeLogger -from .. import PypeModule, ITrayModule -from .providers.local_drive import LocalDriveHandler -if six.PY2: - web = asyncio = STATIC_DIR = WebSocketAsync = None -else: - import asyncio +from .utils import SyncStatus + log = PypeLogger().get_logger("SyncServer") -class SyncStatus(Enum): - DO_NOTHING = 0 - DO_UPLOAD = 1 - DO_DOWNLOAD = 2 - - -class SyncServer(PypeModule, ITrayModule): +async def upload(module, collection, file, representation, provider_name, + remote_site_name, tree=None, preset=None): """ - Synchronization server that is syncing published files from local to - any of implemented providers (like GDrive, S3 etc.) - Runs in the background and checks all representations, looks for files - that are marked to be in different location than 'studio' (temporary), - checks if 'created_dt' field is present denoting successful sync - with provider destination. - Sites structure is created during publish OR by calling 'add_site' - method. + Upload single 'file' of a 'representation' to 'provider'. + Source url is taken from 'file' portion, where {root} placeholder + is replaced by 'representation.Context.root' + Provider could be one of implemented in provider.py. - By default it will always contain 1 record with - "name" == self.presets["active_site"] and - filled "created_dt" AND 1 or multiple records for all defined - remote sites, where "created_dt" is not present. - This highlights that file should be uploaded to - remote destination + Updates MongoDB, fills in id of file from provider (ie. file_id + from GDrive), 'created_dt' - time of upload - ''' - example of synced file test_Cylinder_lookMain_v010.ma to GDrive - "files" : [ - { - "path" : "{root}/Test/Assets/Cylinder/publish/look/lookMain/v010/ - test_Cylinder_lookMain_v010.ma", - "_id" : ObjectId("5eeb25e411e06a16209ab78f"), - "hash" : "test_Cylinder_lookMain_v010,ma|1592468963,24|4822", - "size" : NumberLong(4822), - "sites" : [ - { - "name": "john_local_XD4345", - "created_dt" : ISODate("2020-05-22T08:05:44.000Z") - }, - { - "id" : ObjectId("5eeb25e411e06a16209ab78f"), - "name": "gdrive", - "created_dt" : ISODate("2020-05-55T08:54:35.833Z") - ] - } - }, - ''' - Each Tray app has assigned its own self.presets["local_id"] - used in sites as a name. - Tray is searching only for records where name matches its - self.presets["active_site"] + self.presets["remote_site"]. - "active_site" could be storage in studio ('studio'), or specific - "local_id" when user is working disconnected from home. - If the local record has its "created_dt" filled, it is a source and - process will try to upload the file to all defined remote sites. + 'provider_name' doesn't have to match to 'site_name', single + provider (GDrive) might have multiple sites ('projectA', + 'projectB') - Remote files "id" is real id that could be used in appropriate API. - Local files have "id" too, for conformity, contains just file name. - It is expected that multiple providers will be implemented in separate - classes and registered in 'providers.py'. + Args: + module(SyncServerModule): object to run SyncServerModule API + collection (str): source collection + file (dictionary): of file from representation in Mongo + representation (dictionary): of representation + provider_name (string): gdrive, gdc etc. + site_name (string): site on provider, single provider(gdrive) could + have multiple sites (different accounts, credentials) + tree (dictionary): injected memory structure for performance + preset (dictionary): site config ('credentials_url', 'root'...) """ - # limit querying DB to look for X number of representations that should - # be sync, we try to run more loops with less records - # actual number of files synced could be lower as providers can have - # different limits imposed by its API - # set 0 to no limit - REPRESENTATION_LIMIT = 100 - DEFAULT_SITE = 'studio' - LOCAL_SITE = 'local' - LOG_PROGRESS_SEC = 5 # how often log progress to DB + # create ids sequentially, upload file in parallel later + with module.lock: + # this part modifies structure on 'remote_site', only single + # thread can do that at a time, upload/download to prepared + # structure should be run in parallel + remote_handler = lib.factory.get_provider(provider_name, + collection, + remote_site_name, + tree=tree, + presets=preset) - name = "sync_server" - label = "Sync Server" - - def initialize(self, module_settings): - """ - Called during Module Manager creation. - - Collects needed data, checks asyncio presence. - Sets 'enabled' according to global settings for the module. - Shouldnt be doing any initialization, thats a job for 'tray_init' - """ - self.enabled = module_settings[self.name]["enabled"] - if asyncio is None: - raise AssertionError( - "SyncServer module requires Python 3.5 or higher." + file_path = file.get("path", "") + try: + local_file_path, remote_file_path = resolve_paths(module, + file_path, collection, remote_site_name, remote_handler ) - # some parts of code need to run sequentially, not in async - self.lock = None - self.connection = None # connection to avalon DB to update state - # settings for all enabled projects for sync - self.sync_project_settings = None - self.sync_server_thread = None # asyncio requires new thread - - self.action_show_widget = None - self._paused = False - self._paused_projects = set() - self._paused_representations = set() - self._anatomies = {} - - """ Start of Public API """ - def add_site(self, collection, representation_id, site_name=None): - """ - Adds new site to representation to be synced. - - 'collection' must have synchronization enabled (globally or - project only) - - Used as a API endpoint from outside applications (Loader etc) - - Args: - collection (string): project name (must match DB) - representation_id (string): MongoDB _id value - site_name (string): name of configured and active site - - Returns: - throws ValueError if any issue - """ - if not self.get_sync_project_setting(collection): - raise ValueError("Project not configured") - - if not site_name: - site_name = self.DEFAULT_SITE - - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name) - - # public facing API - def remove_site(self, collection, representation_id, site_name, - remove_local_files=False): - """ - Removes 'site_name' for particular 'representation_id' on - 'collection' - - Args: - collection (string): project name (must match DB) - representation_id (string): MongoDB _id value - site_name (string): name of configured and active site - remove_local_files (bool): remove only files for 'local_id' - site - - Returns: - throws ValueError if any issue - """ - if not self.get_sync_project_setting(collection): - raise ValueError("Project not configured") - - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name, - remove=True) - if remove_local_files: - self._remove_local_file(collection, representation_id, site_name) - - def clear_project(self, collection, site_name): - """ - Clear 'collection' of 'site_name' and its local files - - Works only on real local sites, not on 'studio' - """ - query = { - "type": "representation", - "files.sites.name": site_name - } - - representations = list( - self.connection.database[collection].find(query)) - if not representations: - self.log.debug("No repre found") - return - - for repre in representations: - self.remove_site(collection, repre.get("_id"), site_name, True) - - def pause_representation(self, collection, representation_id, site_name): - """ - Sets 'representation_id' as paused, eg. no syncing should be - happening on it. - - Args: - collection (string): project name - representation_id (string): MongoDB objectId value - site_name (string): 'gdrive', 'studio' etc. - """ - log.info("Pausing SyncServer for {}".format(representation_id)) - self._paused_representations.add(representation_id) - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=True) - - def unpause_representation(self, collection, representation_id, site_name): - """ - Sets 'representation_id' as unpaused. - - Does not fail or warn if repre wasn't paused. - - Args: - collection (string): project name - representation_id (string): MongoDB objectId value - site_name (string): 'gdrive', 'studio' etc. - """ - log.info("Unpausing SyncServer for {}".format(representation_id)) - try: - self._paused_representations.remove(representation_id) - except KeyError: - pass - # self.paused_representations is not persistent - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=False) - - def is_representation_paused(self, representation_id, - check_parents=False, project_name=None): - """ - Returns if 'representation_id' is paused or not. - - Args: - representation_id (string): MongoDB objectId value - check_parents (bool): check if parent project or server itself - are not paused - project_name (string): project to check if paused - - if 'check_parents', 'project_name' should be set too - Returns: - (bool) - """ - condition = representation_id in self._paused_representations - if check_parents and project_name: - condition = condition or \ - self.is_project_paused(project_name) or \ - self.is_paused() - return condition - - def pause_project(self, project_name): - """ - Sets 'project_name' as paused, eg. no syncing should be - happening on all representation inside. - - Args: - project_name (string): collection name - """ - log.info("Pausing SyncServer for {}".format(project_name)) - self._paused_projects.add(project_name) - - def unpause_project(self, project_name): - """ - Sets 'project_name' as unpaused - - Does not fail or warn if project wasn't paused. - - Args: - project_name (string): collection name - """ - log.info("Unpausing SyncServer for {}".format(project_name)) - try: - self._paused_projects.remove(project_name) - except KeyError: - pass - - def is_project_paused(self, project_name, check_parents=False): - """ - Returns if 'project_name' is paused or not. - - Args: - project_name (string): collection name - check_parents (bool): check if server itself - is not paused - Returns: - (bool) - """ - condition = project_name in self._paused_projects - if check_parents: - condition = condition or self.is_paused() - return condition - - def pause_server(self): - """ - Pause sync server - - It won't check anything, not uploading/downloading... - """ - log.info("Pausing SyncServer") - self._paused = True - - def unpause_server(self): - """ - Unpause server - """ - log.info("Unpausing SyncServer") - self._paused = False - - def is_paused(self): - """ Is server paused """ - return self._paused - - def get_active_sites(self, project_name): - """ - Returns list of active sites for 'project_name'. - - By default it returns ['studio'], this site is default - and always present even if SyncServer is not enabled. (for publish) - - Used mainly for Local settings for user override. - - Args: - project_name (string): - - Returns: - (list) of strings - """ - return self.get_active_sites_from_settings( - get_project_settings(project_name)) - - def get_active_sites_from_settings(self, settings): - """ - List available active sites from incoming 'settings'. Used for - returning 'default' values for Local Settings - - Args: - settings (dict): full settings (global + project) - Returns: - (list) of strings - """ - sync_settings = self._parse_sync_settings_from_settings(settings) - - return self._get_active_sites_from_settings(sync_settings) - - def get_active_site(self, project_name): - """ - Returns active (mine) site for 'project_name' from settings - - Returns: - (string) - """ - active_site = self.get_sync_project_setting( - project_name)['config']['active_site'] - if active_site == self.LOCAL_SITE: - return get_local_site_id() - return active_site - - # remote sites - def get_remote_sites(self, project_name): - """ - Returns all remote sites configured on 'project_name'. - - If 'project_name' is not enabled for syncing returns []. - - Used by Local setting to allow user choose remote site. - - Args: - project_name (string): - - Returns: - (list) of strings - """ - return self.get_remote_sites_from_settings( - get_project_settings(project_name)) - - def get_remote_sites_from_settings(self, settings): - """ - Get remote sites for returning 'default' values for Local Settings - """ - sync_settings = self._parse_sync_settings_from_settings(settings) - - return self._get_remote_sites_from_settings(sync_settings) - - def get_remote_site(self, project_name): - """ - Returns remote (theirs) site for 'project_name' from settings - """ - remote_site = self.get_sync_project_setting( - project_name)['config']['remote_site'] - if remote_site == self.LOCAL_SITE: - return get_local_site_id() - - return remote_site - - """ End of Public API """ - - def get_local_file_path(self, collection, file_path): - """ - Externalized for app - """ - local_file_path, _ = self._resolve_paths(file_path, collection) - - return local_file_path - - def _get_remote_sites_from_settings(self, sync_settings): - if not self.enabled or not sync_settings['enabled']: - return [] - - remote_sites = [self.DEFAULT_SITE, self.LOCAL_SITE] - if sync_settings: - remote_sites.extend(sync_settings.get("sites").keys()) - - return list(set(remote_sites)) - - def _get_active_sites_from_settings(self, sync_settings): - sites = [self.DEFAULT_SITE] - if self.enabled and sync_settings['enabled']: - sites.append(self.LOCAL_SITE) - - return sites - - def connect_with_modules(self, *_a, **kw): - return - - def tray_init(self): - """ - Actual initialization of Sync Server. - - Called when tray is initialized, it checks if module should be - enabled. If not, no initialization necessary. - """ - if not self.enabled: - return - - self.sync_project_settings = None - self.lock = threading.Lock() - - self.connection = AvalonMongoDB() - self.connection.install() - - try: - self.set_sync_project_settings() - self.sync_server_thread = SyncServerThread(self) - from .tray.app import SyncServerWindow - self.widget = SyncServerWindow(self) - except ValueError: - log.info("No system setting for sync. Not syncing.", exc_info=True) - self.enabled = False - except KeyError: - log.info(( - "There are not set presets for SyncServer OR " - "Credentials provided are invalid, " - "no syncing possible"). - format(str(self.sync_project_settings)), exc_info=True) - self.enabled = False - - def tray_start(self): - """ - Triggered when Tray is started. - - Checks if configuration presets are available and if there is - any provider ('gdrive', 'S3') that is activated - (eg. has valid credentials). + except Exception as exp: + print(exp) + + target_folder = os.path.dirname(remote_file_path) + folder_id = remote_handler.create_folder(target_folder) + + if not folder_id: + err = "Folder {} wasn't created. Check permissions.". \ + format(target_folder) + raise NotADirectoryError(err) + + loop = asyncio.get_running_loop() + file_id = await loop.run_in_executor(None, + remote_handler.upload_file, + local_file_path, + remote_file_path, + module, + collection, + file, + representation, + remote_site_name, + True + ) + return file_id + + +async def download(module, collection, file, representation, provider_name, + remote_site_name, tree=None, preset=None): + """ + Downloads file to local folder denoted in representation.Context. + + Args: + module(SyncServerModule): object to run SyncServerModule API + collection (str): source collection + file (dictionary) : info about processed file + representation (dictionary): repr that 'file' belongs to + provider_name (string): 'gdrive' etc + site_name (string): site on provider, single provider(gdrive) could + have multiple sites (different accounts, credentials) + tree (dictionary): injected memory structure for performance + preset (dictionary): site config ('credentials_url', 'root'...) Returns: - None - """ - if self.sync_project_settings and self.enabled: - self.sync_server_thread.start() - else: - log.info("No presets or active providers. " + - "Synchronization not possible.") + (string) - 'name' of local file + """ + with module.lock: + remote_handler = lib.factory.get_provider(provider_name, + collection, + remote_site_name, + tree=tree, + presets=preset) - def tray_exit(self): - """ - Stops sync thread if running. + file_path = file.get("path", "") + local_file_path, remote_file_path = resolve_paths( + module, file_path, collection, remote_site_name, remote_handler + ) - Called from Module Manager - """ - if not self.sync_server_thread: - return + local_folder = os.path.dirname(local_file_path) + os.makedirs(local_folder, exist_ok=True) - if not self.is_running: - return - try: - log.info("Stopping sync server server") - self.sync_server_thread.is_running = False - self.sync_server_thread.stop() - except Exception: - log.warning( - "Error has happened during Killing sync server", - exc_info=True - ) + local_site = module.get_active_site(collection) - def tray_menu(self, parent_menu): - if not self.enabled: - return + loop = asyncio.get_running_loop() + file_id = await loop.run_in_executor(None, + remote_handler.download_file, + remote_file_path, + local_file_path, + module, + collection, + file, + representation, + local_site, + True + ) + return file_id - from Qt import QtWidgets - """Add menu or action to Tray(or parent)'s menu""" - action = QtWidgets.QAction("SyncServer", parent_menu) - action.triggered.connect(self.show_widget) - parent_menu.addAction(action) - parent_menu.addSeparator() - self.action_show_widget = action +def resolve_paths(module, file_path, collection, + remote_site_name=None, remote_handler=None): + """ + Returns tuple of local and remote file paths with {root} + placeholders replaced with proper values from Settings or Anatomy - @property - def is_running(self): - return self.sync_server_thread.is_running + Ejected here because of Python 2 hosts (GDriveHandler is an issue) - def get_anatomy(self, project_name): - """ - Get already created or newly created anatomy for project - - Args: - project_name (string): - - Return: - (Anatomy) - """ - return self._anatomies.get('project_name') or Anatomy(project_name) - - def set_sync_project_settings(self): - """ - Set sync_project_settings for all projects (caching) - - For performance - """ - sync_project_settings = {} - if not self.connection: - self.connection = AvalonMongoDB() - self.connection.install() - - for collection in self.connection.database.collection_names(False): - sync_settings = self._parse_sync_settings_from_settings( - get_project_settings(collection)) - if sync_settings: - default_sites = self._get_default_site_configs() - sync_settings['sites'].update(default_sites) - sync_project_settings[collection] = sync_settings - - if not sync_project_settings: - log.info("No enabled and configured projects for sync.") - - self.sync_project_settings = sync_project_settings - - def get_sync_project_settings(self, refresh=False): - """ - Collects all projects which have enabled syncing and their settings Args: - refresh (bool): refresh presets from settings - used when user - changes site in Local Settings or any time up-to-date values - are necessary + module(SyncServerModule): object to run SyncServerModule API + file_path(string): path with {root} + collection(string): project name + remote_site_name(string): remote site + remote_handler(AbstractProvider): implementation Returns: - (dict): of settings, keys are project names - {'projectA':{enabled: True, sites:{}...} - """ - # presets set already, do not call again and again - if refresh or not self.sync_project_settings: - self.set_sync_project_settings() + (string, string) - proper absolute paths, remote path is optional + """ + remote_file_path = '' + if remote_handler: + remote_file_path = remote_handler.resolve_path(file_path) - return self.sync_project_settings + local_handler = lib.factory.get_provider( + 'local_drive', collection, module.get_active_site(collection)) + local_file_path = local_handler.resolve_path(file_path) - def get_sync_project_setting(self, project_name): - """ Handles pulling sync_server's settings for enabled 'project_name' + return local_file_path, remote_file_path - Args: - project_name (str): used in project settings - Returns: - (dict): settings dictionary for the enabled project, - empty if no settings or sync is disabled - """ - # presets set already, do not call again and again - # self.log.debug("project preset {}".format(self.presets)) - if self.sync_project_settings and \ - self.sync_project_settings.get(project_name): - return self.sync_project_settings.get(project_name) - settings = get_project_settings(project_name) - return self._parse_sync_settings_from_settings(settings) +def site_is_working(module, project_name, site_name): + """ + Confirm that 'site_name' is configured correctly for 'project_name'. - def site_is_working(self, project_name, site_name): - """ - Confirm that 'site_name' is configured correctly for 'project_name' - Args: - project_name(string): - site_name(string): - Returns - (bool) - """ - if self._get_configured_sites(project_name).get(site_name): - return True - return False + Must be here as lib.factory access doesn't work in Python 2 hosts. - def _parse_sync_settings_from_settings(self, settings): - """ settings from api.get_project_settings, TOOD rename """ - sync_settings = settings.get("global").get("sync_server") - if not sync_settings: - log.info("No project setting not syncing.") - return {} - if sync_settings.get("enabled"): - return sync_settings + Args: + module (SyncServerModule) + project_name(string): + site_name(string): + Returns + (bool) + """ + if _get_configured_sites(module, project_name).get(site_name): + return True + return False + +def _get_configured_sites(module, project_name): + """ + Loops through settings and looks for configured sites and checks + its handlers for particular 'project_name'. + + Args: + project_setting(dict): dictionary from Settings + only_project_name(string, optional): only interested in + particular project + Returns: + (dict of dict) + {'ProjectA': {'studio':True, 'gdrive':False}} + """ + settings = module.get_sync_project_setting(project_name) + return _get_configured_sites_from_setting(module, project_name, settings) + + +def _get_configured_sites_from_setting(module, project_name, project_setting): + if not project_setting.get("enabled"): return {} - def _get_configured_sites(self, project_name): - """ - Loops through settings and looks for configured sites and checks - its handlers for particular 'project_name'. - - Args: - project_setting(dict): dictionary from Settings - only_project_name(string, optional): only interested in - particular project - Returns: - (dict of dict) - {'ProjectA': {'studio':True, 'gdrive':False}} - """ - settings = self.get_sync_project_setting(project_name) - return self._get_configured_sites_from_setting(settings) - - def _get_configured_sites_from_setting(self, project_setting): - if not project_setting.get("enabled"): - return {} - - initiated_handlers = {} - configured_sites = {} - all_sites = self._get_default_site_configs() - all_sites.update(project_setting.get("sites")) - for site_name, config in all_sites.items(): - handler = initiated_handlers. \ - get((config["provider"], site_name)) - if not handler: - handler = lib.factory.get_provider(config["provider"], - site_name, - presets=config) - initiated_handlers[(config["provider"], site_name)] = \ - handler - - if handler.is_active(): - configured_sites[site_name] = True - - return configured_sites - - def _get_default_site_configs(self): - """ - Returns skeleton settings for 'studio' and user's local site - """ - default_config = {'provider': 'local_drive'} - all_sites = {self.DEFAULT_SITE: default_config, - get_local_site_id(): default_config} - return all_sites - - def get_provider_for_site(self, project_name, site): - """ - Return provider name for site. - """ - site_preset = self.get_sync_project_setting(project_name)["sites"].\ - get(site) - if site_preset: - return site_preset["provider"] - - return "NA" - - @time_function - def get_sync_representations(self, collection, active_site, remote_site): - """ - Get representations that should be synced, these could be - recognised by presence of document in 'files.sites', where key is - a provider (GDrive, S3) and value is empty document or document - without 'created_dt' field. (Don't put null to 'created_dt'!). - - Querying of 'to-be-synched' files is offloaded to Mongod for - better performance. Goal is to get as few representations as - possible. - Args: - collection (string): name of collection (in most cases matches - project name - active_site (string): identifier of current active site (could be - 'local_0' when working from home, 'studio' when working in the - studio (default) - remote_site (string): identifier of remote site I want to sync to - - Returns: - (list) of dictionaries - """ - log.debug("Check representations for : {}".format(collection)) - self.connection.Session["AVALON_PROJECT"] = collection - # retry_cnt - number of attempts to sync specific file before giving up - retries_arr = self._get_retries_arr(collection) - query = { - "type": "representation", - "$or": [ - {"$and": [ - { - "files.sites": { - "$elemMatch": { - "name": active_site, - "created_dt": {"$exists": True} - } - }}, { - "files.sites": { - "$elemMatch": { - "name": {"$in": [remote_site]}, - "created_dt": {"$exists": False}, - "tries": {"$in": retries_arr} - } - } - }]}, - {"$and": [ - { - "files.sites": { - "$elemMatch": { - "name": active_site, - "created_dt": {"$exists": False}, - "tries": {"$in": retries_arr} - } - }}, { - "files.sites": { - "$elemMatch": { - "name": {"$in": [remote_site]}, - "created_dt": {"$exists": True} - } - } - } - ]} - ] - } - log.debug("active_site:{} - remote_site:{}".format(active_site, - remote_site)) - log.debug("query: {}".format(query)) - representations = self.connection.find(query) - - return representations - - def check_status(self, file, local_site, remote_site, config_preset): - """ - Check synchronization status for single 'file' of single - 'representation' by single 'provider'. - (Eg. check if 'scene.ma' of lookdev.v10 should be synced to GDrive - - Always is comparing local record, eg. site with - 'name' == self.presets[PROJECT_NAME]['config']["active_site"] - - Args: - file (dictionary): of file from representation in Mongo - local_site (string): - local side of compare (usually 'studio') - remote_site (string): - gdrive etc. - config_preset (dict): config about active site, retries - Returns: - (string) - one of SyncStatus - """ - sites = file.get("sites") or [] - # if isinstance(sites, list): # temporary, old format of 'sites' - # return SyncStatus.DO_NOTHING - _, remote_rec = self._get_site_rec(sites, remote_site) or {} - if remote_rec: # sync remote target - created_dt = remote_rec.get("created_dt") - if not created_dt: - tries = self._get_tries_count_from_rec(remote_rec) - # file will be skipped if unsuccessfully tried over threshold - # error metadata needs to be purged manually in DB to reset - if tries < int(config_preset["retry_cnt"]): - return SyncStatus.DO_UPLOAD - else: - _, local_rec = self._get_site_rec(sites, local_site) or {} - if not local_rec or not local_rec.get("created_dt"): - tries = self._get_tries_count_from_rec(local_rec) - # file will be skipped if unsuccessfully tried over - # threshold times, error metadata needs to be purged - # manually in DB to reset - if tries < int(config_preset["retry_cnt"]): - return SyncStatus.DO_DOWNLOAD - - return SyncStatus.DO_NOTHING - - async def upload(self, collection, file, representation, provider_name, - remote_site_name, tree=None, preset=None): - """ - Upload single 'file' of a 'representation' to 'provider'. - Source url is taken from 'file' portion, where {root} placeholder - is replaced by 'representation.Context.root' - Provider could be one of implemented in provider.py. - - Updates MongoDB, fills in id of file from provider (ie. file_id - from GDrive), 'created_dt' - time of upload - - 'provider_name' doesn't have to match to 'site_name', single - provider (GDrive) might have multiple sites ('projectA', - 'projectB') - - Args: - collection (str): source collection - file (dictionary): of file from representation in Mongo - representation (dictionary): of representation - provider_name (string): gdrive, gdc etc. - site_name (string): site on provider, single provider(gdrive) could - have multiple sites (different accounts, credentials) - tree (dictionary): injected memory structure for performance - preset (dictionary): site config ('credentials_url', 'root'...) - - """ - # create ids sequentially, upload file in parallel later - with self.lock: - # this part modifies structure on 'remote_site', only single - # thread can do that at a time, upload/download to prepared - # structure should be run in parallel - remote_handler = lib.factory.get_provider(provider_name, - remote_site_name, - tree=tree, - presets=preset) - - file_path = file.get("path", "") - local_file_path, remote_file_path = self._resolve_paths( - file_path, collection, remote_site_name, remote_handler - ) - - target_folder = os.path.dirname(remote_file_path) - folder_id = remote_handler.create_folder(target_folder) - - if not folder_id: - err = "Folder {} wasn't created. Check permissions.".\ - format(target_folder) - raise NotADirectoryError(err) - - loop = asyncio.get_running_loop() - file_id = await loop.run_in_executor(None, - remote_handler.upload_file, - local_file_path, - remote_file_path, - self, - collection, - file, - representation, - remote_site_name, - True - ) - return file_id - - async def download(self, collection, file, representation, provider_name, - remote_site_name, tree=None, preset=None): - """ - Downloads file to local folder denoted in representation.Context. - - Args: - collection (str): source collection - file (dictionary) : info about processed file - representation (dictionary): repr that 'file' belongs to - provider_name (string): 'gdrive' etc - site_name (string): site on provider, single provider(gdrive) could - have multiple sites (different accounts, credentials) - tree (dictionary): injected memory structure for performance - preset (dictionary): site config ('credentials_url', 'root'...) - - Returns: - (string) - 'name' of local file - """ - with self.lock: - remote_handler = lib.factory.get_provider(provider_name, - remote_site_name, - tree=tree, - presets=preset) - - file_path = file.get("path", "") - local_file_path, remote_file_path = self._resolve_paths( - file_path, collection, remote_site_name, remote_handler - ) - - local_folder = os.path.dirname(local_file_path) - os.makedirs(local_folder, exist_ok=True) - - local_site = self.get_active_site(collection) - - loop = asyncio.get_running_loop() - file_id = await loop.run_in_executor(None, - remote_handler.download_file, - remote_file_path, - local_file_path, - self, - collection, - file, - representation, - local_site, - True - ) - return file_id - - def update_db(self, collection, new_file_id, file, representation, - site, error=None, progress=None): - """ - Update 'provider' portion of records in DB with success (file_id) - or error (exception) - - Args: - collection (string): name of project - force to db connection as - each file might come from different collection - new_file_id (string): - file (dictionary): info about processed file (pulled from DB) - representation (dictionary): parent repr of file (from DB) - site (string): label ('gdrive', 'S3') - error (string): exception message - progress (float): 0-1 of progress of upload/download - - Returns: - None - """ - representation_id = representation.get("_id") - file_id = file.get("_id") - query = { - "_id": representation_id - } - - update = {} - if new_file_id: - update["$set"] = self._get_success_dict(new_file_id) - # reset previous errors if any - update["$unset"] = self._get_error_dict("", "", "") - elif progress is not None: - update["$set"] = self._get_progress_dict(progress) - else: - tries = self._get_tries_count(file, site) - tries += 1 - - update["$set"] = self._get_error_dict(error, tries) - - arr_filter = [ - {'s.name': site}, - {'f._id': ObjectId(file_id)} - ] - - self.connection.database[collection].update_one( - query, - update, - upsert=True, - array_filters=arr_filter - ) - - if progress is not None: - return - - status = 'failed' - error_str = 'with error {}'.format(error) - if new_file_id: - status = 'succeeded with id {}'.format(new_file_id) - error_str = '' - - source_file = file.get("path", "") - log.debug("File for {} - {source_file} process {status} {error_str}". - format(representation_id, - status=status, - source_file=source_file, - error_str=error_str)) - - def _get_file_info(self, files, _id): - """ - Return record from list of records which name matches to 'provider' - Could be possibly refactored with '_get_provider_rec' together. - - Args: - files (list): of dictionaries with info about published files - _id (string): _id of specific file - - Returns: - (int, dictionary): index from list and record with metadata - about site (if/when created, errors..) - OR (-1, None) if not present - """ - for index, rec in enumerate(files): - if rec.get("_id") == _id: - return index, rec - - return -1, None - - def _get_site_rec(self, sites, site_name): - """ - Return record from list of records which name matches to - 'remote_site_name' - - Args: - sites (list): of dictionaries - site_name (string): 'local_XXX', 'gdrive' - - Returns: - (int, dictionary): index from list and record with metadata - about site (if/when created, errors..) - OR (-1, None) if not present - """ - for index, rec in enumerate(sites): - if rec.get("name") == site_name: - return index, rec - - return -1, None - - def reset_provider_for_file(self, collection, representation_id, - side=None, file_id=None, site_name=None, - remove=False, pause=None): - """ - Reset information about synchronization for particular 'file_id' - and provider. - Useful for testing or forcing file to be reuploaded. - - 'side' and 'site_name' are disjunctive. - - 'side' is used for resetting local or remote side for - current user for repre. - - 'site_name' is used to set synchronization for particular site. - Should be used when repre should be synced to new site. - - Args: - collection (string): name of project (eg. collection) in DB - representation_id(string): _id of representation - file_id (string): file _id in representation - side (string): local or remote side - site_name (string): for adding new site - remove (bool): if True remove site altogether - pause (bool or None): if True - pause, False - unpause - - Returns: - throws ValueError - """ - query = { - "_id": ObjectId(representation_id) - } - - representation = list(self.connection.database[collection].find(query)) - if not representation: - raise ValueError("Representation {} not found in {}". - format(representation_id, collection)) - if side and site_name: - raise ValueError("Misconfiguration, only one of side and " + - "site_name arguments should be passed.") - - local_site = self.get_active_site(collection) - remote_site = self.get_remote_site(collection) - - if side: - if side == 'local': - site_name = local_site - else: - site_name = remote_site - - elem = {"name": site_name} - - if file_id: # reset site for particular file - self._reset_site_for_file(collection, query, - elem, file_id, site_name) - elif side: # reset site for whole representation - self._reset_site(collection, query, elem, site_name) - elif remove: # remove site for whole representation - self._remove_site(collection, query, representation, site_name) - elif pause is not None: - self._pause_unpause_site(collection, query, - representation, site_name, pause) - else: # add new site to all files for representation - self._add_site(collection, query, representation, elem, site_name) - - def _update_site(self, collection, query, update, arr_filter): - """ - Auxiliary method to call update_one function on DB - - Used for refactoring ugly reset_provider_for_file - """ - self.connection.database[collection].update_one( - query, - update, - upsert=True, - array_filters=arr_filter - ) - - def _reset_site_for_file(self, collection, query, - elem, file_id, site_name): - """ - Resets 'site_name' for 'file_id' on representation in 'query' on - 'collection' - """ - update = { - "$set": {"files.$[f].sites.$[s]": elem} - } - arr_filter = [ - {'s.name': site_name}, - {'f._id': ObjectId(file_id)} - ] - - self._update_site(collection, query, update, arr_filter) - - def _reset_site(self, collection, query, elem, site_name): - """ - Resets 'site_name' for all files of representation in 'query' - """ - update = { - "$set": {"files.$[].sites.$[s]": elem} - } - - arr_filter = [ - {'s.name': site_name} - ] - - self._update_site(collection, query, update, arr_filter) - - def _remove_site(self, collection, query, representation, site_name): - """ - Removes 'site_name' for 'representation' in 'query' - - Throws ValueError if 'site_name' not found on 'representation' - """ - found = False - for file in representation.pop().get("files"): - for site in file.get("sites"): - if site["name"] == site_name: - found = True - break - if not found: - msg = "Site {} not found".format(site_name) - log.info(msg) - raise ValueError(msg) - - update = { - "$pull": {"files.$[].sites": {"name": site_name}} - } - arr_filter = [] - - self._update_site(collection, query, update, arr_filter) - - def _pause_unpause_site(self, collection, query, - representation, site_name, pause): - """ - Pauses/unpauses all files for 'representation' based on 'pause' - - Throws ValueError if 'site_name' not found on 'representation' - """ - found = False - site = None - for file in representation.pop().get("files"): - for site in file.get("sites"): - if site["name"] == site_name: - found = True - break - if not found: - msg = "Site {} not found".format(site_name) - log.info(msg) - raise ValueError(msg) - - if pause: - site['paused'] = pause - else: - if site.get('paused'): - site.pop('paused') - - update = { - "$set": {"files.$[].sites.$[s]": site} - } - - arr_filter = [ - {'s.name': site_name} - ] - - self._update_site(collection, query, update, arr_filter) - - def _add_site(self, collection, query, representation, elem, site_name): - """ - Adds 'site_name' to 'representation' on 'collection' - - Throws ValueError if already present - """ - for file in representation.pop().get("files"): - for site in file.get("sites"): - if site["name"] == site_name: - msg = "Site {} already present".format(site_name) - log.info(msg) - raise ValueError(msg) - - update = { - "$push": {"files.$[].sites": elem} - } - - arr_filter = [] - - self._update_site(collection, query, update, arr_filter) - - def _remove_local_file(self, collection, representation_id, site_name): - """ - Removes all local files for 'site_name' of 'representation_id' - - Args: - collection (string): project name (must match DB) - representation_id (string): MongoDB _id value - site_name (string): name of configured and active site - - Returns: - only logs, catches IndexError and OSError - """ - my_local_site = get_local_site_id() - if my_local_site != site_name: - self.log.warning("Cannot remove non local file for {}". - format(site_name)) - return - - provider_name = self.get_provider_for_site(collection, site_name) - handler = lib.factory.get_provider(provider_name, site_name) - - if handler and isinstance(handler, LocalDriveHandler): - query = { - "_id": ObjectId(representation_id) - } - - representation = list( - self.connection.database[collection].find(query)) - if not representation: - self.log.debug("No repre {} found".format( - representation_id)) - return - - representation = representation.pop() - local_file_path = '' - for file in representation.get("files"): - local_file_path, _ = self._resolve_paths(file.get("path", ""), - collection - ) - try: - self.log.debug("Removing {}".format(local_file_path)) - os.remove(local_file_path) - except IndexError: - msg = "No file set for {}".format(representation_id) - self.log.debug(msg) - raise ValueError(msg) - except OSError: - msg = "File {} cannot be removed".format(file["path"]) - self.log.warning(msg) - raise ValueError(msg) - - try: - folder = os.path.dirname(local_file_path) - os.rmdir(folder) - except OSError: - msg = "folder {} cannot be removed".format(folder) - self.log.warning(msg) - raise ValueError(msg) - - def get_loop_delay(self, project_name): - """ - Return count of seconds before next synchronization loop starts - after finish of previous loop. - Returns: - (int): in seconds - """ - ld = self.sync_project_settings[project_name]["config"]["loop_delay"] - return int(ld) - - def show_widget(self): - """Show dialog to enter credentials""" - self.widget.show() - - def _get_success_dict(self, new_file_id): - """ - Provide success metadata ("id", "created_dt") to be stored in Db. - Used in $set: "DICT" part of query. - Sites are array inside of array(file), so real indexes for both - file and site are needed for upgrade in DB. - Args: - new_file_id: id of created file - Returns: - (dictionary) - """ - val = {"files.$[f].sites.$[s].id": new_file_id, - "files.$[f].sites.$[s].created_dt": datetime.now()} - return val - - def _get_error_dict(self, error="", tries="", progress=""): - """ - Provide error metadata to be stored in Db. - Used for set (error and tries provided) or unset mode. - Args: - error: (string) - message - tries: how many times failed - Returns: - (dictionary) - """ - val = {"files.$[f].sites.$[s].last_failed_dt": datetime.now(), - "files.$[f].sites.$[s].error": error, - "files.$[f].sites.$[s].tries": tries, - "files.$[f].sites.$[s].progress": progress - } - return val - - def _get_tries_count_from_rec(self, rec): - """ - Get number of failed attempts to sync from site record - Args: - rec (dictionary): info about specific site record - Returns: - (int) - number of failed attempts - """ - if not rec: - return 0 - return rec.get("tries", 0) - - def _get_tries_count(self, file, provider): - """ - Get number of failed attempts to sync - Args: - file (dictionary): info about specific file - provider (string): name of site ('gdrive' or specific user site) - Returns: - (int) - number of failed attempts - """ - _, rec = self._get_site_rec(file.get("sites", []), provider) - return rec.get("tries", 0) - - def _get_progress_dict(self, progress): - """ - Provide progress metadata to be stored in Db. - Used during upload/download for GUI to show. - Args: - progress: (float) - 0-1 progress of upload/download - Returns: - (dictionary) - """ - val = {"files.$[f].sites.$[s].progress": progress} - return val - - def _resolve_paths(self, file_path, collection, - remote_site_name=None, remote_handler=None): - """ - Returns tuple of local and remote file paths with {root} - placeholders replaced with proper values from Settings or Anatomy - - Args: - file_path(string): path with {root} - collection(string): project name - remote_site_name(string): remote site - remote_handler(AbstractProvider): implementation - Returns: - (string, string) - proper absolute paths - """ - remote_file_path = '' - if remote_handler: - root_configs = self._get_roots_config(self.sync_project_settings, - collection, - remote_site_name) - - remote_file_path = remote_handler.resolve_path(file_path, - root_configs) - - local_handler = lib.factory.get_provider( - 'local_drive', self.get_active_site(collection)) - local_file_path = local_handler.resolve_path( - file_path, None, self.get_anatomy(collection)) - - return local_file_path, remote_file_path - - def _get_retries_arr(self, project_name): - """ - Returns array with allowed values in 'tries' field. If repre - contains these values, it means it was tried to be synchronized - but failed. We try up to 'self.presets["retry_cnt"]' times before - giving up and skipping representation. - Returns: - (list) - """ - retry_cnt = self.sync_project_settings[project_name].\ - get("config")["retry_cnt"] - arr = [i for i in range(int(retry_cnt))] - arr.append(None) - - return arr - - def _get_roots_config(self, presets, project_name, site_name): - """ - Returns configured root(s) for 'project_name' and 'site_name' from - settings ('presets') - """ - return presets[project_name]['sites'][site_name]['root'] - + initiated_handlers = {} + configured_sites = {} + all_sites = module._get_default_site_configs() + all_sites.update(project_setting.get("sites")) + for site_name, config in all_sites.items(): + handler = initiated_handlers. \ + get((config["provider"], site_name)) + if not handler: + handler = lib.factory.get_provider(config["provider"], + project_name, + site_name, + presets=config) + initiated_handlers[(config["provider"], site_name)] = \ + handler + + if handler.is_active(): + configured_sites[site_name] = True + + return configured_sites class SyncServerThread(threading.Thread): """ @@ -1437,7 +271,7 @@ class SyncServerThread(threading.Thread): import time start_time = None self.module.set_sync_project_settings() # clean cache - for collection, preset in self.module.get_sync_project_settings().\ + for collection, preset in self.module.sync_project_settings.\ items(): start_time = time.time() local_site, remote_site = self._working_sites(collection) @@ -1462,6 +296,7 @@ class SyncServerThread(threading.Thread): site_preset = preset.get('sites')[remote_site] remote_provider = site_preset['provider'] handler = lib.factory.get_provider(remote_provider, + collection, remote_site, presets=site_preset) limit = lib.factory.get_provider_batch_limit( @@ -1491,13 +326,14 @@ class SyncServerThread(threading.Thread): tree = handler.get_tree() limit -= 1 task = asyncio.create_task( - self.module.upload(collection, - file, - sync, - remote_provider, - remote_site, - tree, - site_preset)) + upload(self.module, + collection, + file, + sync, + remote_provider, + remote_site, + tree, + site_preset)) task_files_to_process.append(task) # store info for exception handlingy files_processed_info.append((file, @@ -1510,13 +346,14 @@ class SyncServerThread(threading.Thread): tree = handler.get_tree() limit -= 1 task = asyncio.create_task( - self.module.download(collection, - file, - sync, - remote_provider, - remote_site, - tree, - site_preset)) + download(self.module, + collection, + file, + sync, + remote_provider, + remote_site, + tree, + site_preset)) task_files_to_process.append(task) files_processed_info.append((file, @@ -1592,8 +429,8 @@ class SyncServerThread(threading.Thread): remote_site)) return None, None - if not all([self.module.site_is_working(collection, local_site), - self.module.site_is_working(collection, remote_site)]): + if not all([site_is_working(self.module, collection, local_site), + site_is_working(self.module, collection, remote_site)]): log.debug("Some of the sites {} - {} is not ".format(local_site, remote_site) + "working properly") diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py new file mode 100644 index 0000000000..4b4b3517ee --- /dev/null +++ b/openpype/modules/sync_server/sync_server_module.py @@ -0,0 +1,1194 @@ +import os +from bson.objectid import ObjectId +from datetime import datetime +import threading + +from avalon.api import AvalonMongoDB + +from .. import PypeModule, ITrayModule +from openpype.api import ( + Anatomy, + get_project_settings, + get_local_site_id) +from openpype.lib import PypeLogger + +from .providers.local_drive import LocalDriveHandler + +from .utils import time_function, SyncStatus + + +log = PypeLogger().get_logger("SyncServer") + + +class SyncServerModule(PypeModule, ITrayModule): + """ + Synchronization server that is syncing published files from local to + any of implemented providers (like GDrive, S3 etc.) + Runs in the background and checks all representations, looks for files + that are marked to be in different location than 'studio' (temporary), + checks if 'created_dt' field is present denoting successful sync + with provider destination. + Sites structure is created during publish OR by calling 'add_site' + method. + + By default it will always contain 1 record with + "name" == self.presets["active_site"] and + filled "created_dt" AND 1 or multiple records for all defined + remote sites, where "created_dt" is not present. + This highlights that file should be uploaded to + remote destination + + ''' - example of synced file test_Cylinder_lookMain_v010.ma to GDrive + "files" : [ + { + "path" : "{root}/Test/Assets/Cylinder/publish/look/lookMain/v010/ + test_Cylinder_lookMain_v010.ma", + "_id" : ObjectId("5eeb25e411e06a16209ab78f"), + "hash" : "test_Cylinder_lookMain_v010,ma|1592468963,24|4822", + "size" : NumberLong(4822), + "sites" : [ + { + "name": "john_local_XD4345", + "created_dt" : ISODate("2020-05-22T08:05:44.000Z") + }, + { + "id" : ObjectId("5eeb25e411e06a16209ab78f"), + "name": "gdrive", + "created_dt" : ISODate("2020-05-55T08:54:35.833Z") + ] + } + }, + ''' + Each Tray app has assigned its own self.presets["local_id"] + used in sites as a name. + Tray is searching only for records where name matches its + self.presets["active_site"] + self.presets["remote_site"]. + "active_site" could be storage in studio ('studio'), or specific + "local_id" when user is working disconnected from home. + If the local record has its "created_dt" filled, it is a source and + process will try to upload the file to all defined remote sites. + + Remote files "id" is real id that could be used in appropriate API. + Local files have "id" too, for conformity, contains just file name. + It is expected that multiple providers will be implemented in separate + classes and registered in 'providers.py'. + + """ + # limit querying DB to look for X number of representations that should + # be sync, we try to run more loops with less records + # actual number of files synced could be lower as providers can have + # different limits imposed by its API + # set 0 to no limit + REPRESENTATION_LIMIT = 100 + DEFAULT_SITE = 'studio' + LOCAL_SITE = 'local' + LOG_PROGRESS_SEC = 5 # how often log progress to DB + + name = "sync_server" + label = "Sync Server" + + def initialize(self, module_settings): + """ + Called during Module Manager creation. + + Collects needed data, checks asyncio presence. + Sets 'enabled' according to global settings for the module. + Shouldnt be doing any initialization, thats a job for 'tray_init' + """ + self.enabled = module_settings[self.name]["enabled"] + + # some parts of code need to run sequentially, not in async + self.lock = None + # settings for all enabled projects for sync + self._sync_project_settings = None + self.sync_server_thread = None # asyncio requires new thread + + self.action_show_widget = None + self._paused = False + self._paused_projects = set() + self._paused_representations = set() + self._anatomies = {} + + self._connection = None + + """ Start of Public API """ + def add_site(self, collection, representation_id, site_name=None, + force=False): + """ + Adds new site to representation to be synced. + + 'collection' must have synchronization enabled (globally or + project only) + + Used as a API endpoint from outside applications (Loader etc) + + Args: + collection (string): project name (must match DB) + representation_id (string): MongoDB _id value + site_name (string): name of configured and active site + force (bool): reset site if exists + + Returns: + throws ValueError if any issue + """ + if not self.get_sync_project_setting(collection): + raise ValueError("Project not configured") + + if not site_name: + site_name = self.DEFAULT_SITE + + self.reset_provider_for_file(collection, + representation_id, + site_name=site_name, force=force) + + # public facing API + def remove_site(self, collection, representation_id, site_name, + remove_local_files=False): + """ + Removes 'site_name' for particular 'representation_id' on + 'collection' + + Args: + collection (string): project name (must match DB) + representation_id (string): MongoDB _id value + site_name (string): name of configured and active site + remove_local_files (bool): remove only files for 'local_id' + site + + Returns: + throws ValueError if any issue + """ + if not self.get_sync_project_setting(collection): + raise ValueError("Project not configured") + + self.reset_provider_for_file(collection, + representation_id, + site_name=site_name, + remove=True) + if remove_local_files: + self._remove_local_file(collection, representation_id, site_name) + + def clear_project(self, collection, site_name): + """ + Clear 'collection' of 'site_name' and its local files + + Works only on real local sites, not on 'studio' + """ + query = { + "type": "representation", + "files.sites.name": site_name + } + + representations = list( + self.connection.database[collection].find(query)) + if not representations: + self.log.debug("No repre found") + return + + for repre in representations: + self.remove_site(collection, repre.get("_id"), site_name, True) + + def pause_representation(self, collection, representation_id, site_name): + """ + Sets 'representation_id' as paused, eg. no syncing should be + happening on it. + + Args: + collection (string): project name + representation_id (string): MongoDB objectId value + site_name (string): 'gdrive', 'studio' etc. + """ + log.info("Pausing SyncServer for {}".format(representation_id)) + self._paused_representations.add(representation_id) + self.reset_provider_for_file(collection, representation_id, + site_name=site_name, pause=True) + + def unpause_representation(self, collection, representation_id, site_name): + """ + Sets 'representation_id' as unpaused. + + Does not fail or warn if repre wasn't paused. + + Args: + collection (string): project name + representation_id (string): MongoDB objectId value + site_name (string): 'gdrive', 'studio' etc. + """ + log.info("Unpausing SyncServer for {}".format(representation_id)) + try: + self._paused_representations.remove(representation_id) + except KeyError: + pass + # self.paused_representations is not persistent + self.reset_provider_for_file(collection, representation_id, + site_name=site_name, pause=False) + + def is_representation_paused(self, representation_id, + check_parents=False, project_name=None): + """ + Returns if 'representation_id' is paused or not. + + Args: + representation_id (string): MongoDB objectId value + check_parents (bool): check if parent project or server itself + are not paused + project_name (string): project to check if paused + + if 'check_parents', 'project_name' should be set too + Returns: + (bool) + """ + condition = representation_id in self._paused_representations + if check_parents and project_name: + condition = condition or \ + self.is_project_paused(project_name) or \ + self.is_paused() + return condition + + def pause_project(self, project_name): + """ + Sets 'project_name' as paused, eg. no syncing should be + happening on all representation inside. + + Args: + project_name (string): collection name + """ + log.info("Pausing SyncServer for {}".format(project_name)) + self._paused_projects.add(project_name) + + def unpause_project(self, project_name): + """ + Sets 'project_name' as unpaused + + Does not fail or warn if project wasn't paused. + + Args: + project_name (string): collection name + """ + log.info("Unpausing SyncServer for {}".format(project_name)) + try: + self._paused_projects.remove(project_name) + except KeyError: + pass + + def is_project_paused(self, project_name, check_parents=False): + """ + Returns if 'project_name' is paused or not. + + Args: + project_name (string): collection name + check_parents (bool): check if server itself + is not paused + Returns: + (bool) + """ + condition = project_name in self._paused_projects + if check_parents: + condition = condition or self.is_paused() + return condition + + def pause_server(self): + """ + Pause sync server + + It won't check anything, not uploading/downloading... + """ + log.info("Pausing SyncServer") + self._paused = True + + def unpause_server(self): + """ + Unpause server + """ + log.info("Unpausing SyncServer") + self._paused = False + + def is_paused(self): + """ Is server paused """ + return self._paused + + def get_active_sites(self, project_name): + """ + Returns list of active sites for 'project_name'. + + By default it returns ['studio'], this site is default + and always present even if SyncServer is not enabled. (for publish) + + Used mainly for Local settings for user override. + + Args: + project_name (string): + + Returns: + (list) of strings + """ + return self.get_active_sites_from_settings( + get_project_settings(project_name)) + + def get_active_sites_from_settings(self, settings): + """ + List available active sites from incoming 'settings'. Used for + returning 'default' values for Local Settings + + Args: + settings (dict): full settings (global + project) + Returns: + (list) of strings + """ + sync_settings = self._parse_sync_settings_from_settings(settings) + + return self._get_enabled_sites_from_settings(sync_settings) + + def get_configurable_items_for_site(self, project_name, site_name): + """ + Returns list of items that should be configurable by User + + Returns: + (list of dict) + [{key:"root", label:"root", value:"valueFromSettings"}] + """ + # if project_name is None: ..for get_default_project_settings + # return handler.get_configurable_items() + pass + + def get_active_site(self, project_name): + """ + Returns active (mine) site for 'project_name' from settings + + Returns: + (string) + """ + active_site = self.get_sync_project_setting( + project_name)['config']['active_site'] + if active_site == self.LOCAL_SITE: + return get_local_site_id() + return active_site + + # remote sites + def get_remote_sites(self, project_name): + """ + Returns all remote sites configured on 'project_name'. + + If 'project_name' is not enabled for syncing returns []. + + Used by Local setting to allow user choose remote site. + + Args: + project_name (string): + + Returns: + (list) of strings + """ + return self.get_remote_sites_from_settings( + get_project_settings(project_name)) + + def get_remote_sites_from_settings(self, settings): + """ + Get remote sites for returning 'default' values for Local Settings + """ + sync_settings = self._parse_sync_settings_from_settings(settings) + + return self._get_remote_sites_from_settings(sync_settings) + + def get_remote_site(self, project_name): + """ + Returns remote (theirs) site for 'project_name' from settings + """ + remote_site = self.get_sync_project_setting( + project_name)['config']['remote_site'] + if remote_site == self.LOCAL_SITE: + return get_local_site_id() + + return remote_site + + """ End of Public API """ + + def get_local_file_path(self, collection, site_name, file_path): + """ + Externalized for app + """ + handler = LocalDriveHandler(collection, site_name) + local_file_path = handler.resolve_path(file_path) + + return local_file_path + + def _get_remote_sites_from_settings(self, sync_settings): + if not self.enabled or not sync_settings['enabled']: + return [] + + remote_sites = [self.DEFAULT_SITE, self.LOCAL_SITE] + if sync_settings: + remote_sites.extend(sync_settings.get("sites").keys()) + + return list(set(remote_sites)) + + def _get_enabled_sites_from_settings(self, sync_settings): + sites = [self.DEFAULT_SITE] + if self.enabled and sync_settings['enabled']: + sites.append(self.LOCAL_SITE) + + return sites + + def connect_with_modules(self, *_a, **kw): + return + + def tray_init(self): + """ + Actual initialization of Sync Server. + + Called when tray is initialized, it checks if module should be + enabled. If not, no initialization necessary. + """ + # import only in tray, because of Python2 hosts + from .sync_server import SyncServerThread + + if not self.enabled: + return + + self.lock = threading.Lock() + + try: + self.sync_server_thread = SyncServerThread(self) + from .tray.app import SyncServerWindow + self.widget = SyncServerWindow(self) + except ValueError: + log.info("No system setting for sync. Not syncing.", exc_info=True) + self.enabled = False + except KeyError: + log.info(( + "There are not set presets for SyncServer OR " + "Credentials provided are invalid, " + "no syncing possible"). + format(str(self.sync_project_settings)), exc_info=True) + self.enabled = False + + def tray_start(self): + """ + Triggered when Tray is started. + + Checks if configuration presets are available and if there is + any provider ('gdrive', 'S3') that is activated + (eg. has valid credentials). + + Returns: + None + """ + if self.sync_project_settings and self.enabled: + self.sync_server_thread.start() + else: + log.info("No presets or active providers. " + + "Synchronization not possible.") + + def tray_exit(self): + """ + Stops sync thread if running. + + Called from Module Manager + """ + if not self.sync_server_thread: + return + + if not self.is_running: + return + try: + log.info("Stopping sync server server") + self.sync_server_thread.is_running = False + self.sync_server_thread.stop() + except Exception: + log.warning( + "Error has happened during Killing sync server", + exc_info=True + ) + + def tray_menu(self, parent_menu): + if not self.enabled: + return + + from Qt import QtWidgets + """Add menu or action to Tray(or parent)'s menu""" + action = QtWidgets.QAction("SyncServer", parent_menu) + action.triggered.connect(self.show_widget) + parent_menu.addAction(action) + parent_menu.addSeparator() + + self.action_show_widget = action + + @property + def is_running(self): + return self.sync_server_thread.is_running + + def get_anatomy(self, project_name): + """ + Get already created or newly created anatomy for project + + Args: + project_name (string): + + Return: + (Anatomy) + """ + return self._anatomies.get('project_name') or Anatomy(project_name) + + @property + def connection(self): + if self._connection is None: + self._connection = AvalonMongoDB() + + return self._connection + + @property + def sync_project_settings(self): + if self._sync_project_settings is None: + self.set_sync_project_settings() + + return self._sync_project_settings + + def set_sync_project_settings(self): + """ + Set sync_project_settings for all projects (caching) + + For performance + """ + sync_project_settings = {} + + for collection in self.connection.database.collection_names(False): + sync_settings = self._parse_sync_settings_from_settings( + get_project_settings(collection)) + if sync_settings: + default_sites = self._get_default_site_configs() + sync_settings['sites'].update(default_sites) + sync_project_settings[collection] = sync_settings + + if not sync_project_settings: + log.info("No enabled and configured projects for sync.") + + self._sync_project_settings = sync_project_settings + + def get_sync_project_setting(self, project_name): + """ Handles pulling sync_server's settings for enabled 'project_name' + + Args: + project_name (str): used in project settings + Returns: + (dict): settings dictionary for the enabled project, + empty if no settings or sync is disabled + """ + # presets set already, do not call again and again + # self.log.debug("project preset {}".format(self.presets)) + if self.sync_project_settings and \ + self.sync_project_settings.get(project_name): + return self.sync_project_settings.get(project_name) + + settings = get_project_settings(project_name) + return self._parse_sync_settings_from_settings(settings) + + def _parse_sync_settings_from_settings(self, settings): + """ settings from api.get_project_settings, TOOD rename """ + sync_settings = settings.get("global").get("sync_server") + if not sync_settings: + log.info("No project setting not syncing.") + return {} + if sync_settings.get("enabled"): + return sync_settings + + return {} + + def _get_default_site_configs(self): + """ + Returns skeleton settings for 'studio' and user's local site + """ + default_config = {'provider': 'local_drive'} + all_sites = {self.DEFAULT_SITE: default_config, + get_local_site_id(): default_config} + return all_sites + + def get_provider_for_site(self, project_name, site): + """ + Return provider name for site. + """ + site_preset = self.get_sync_project_setting(project_name)["sites"].\ + get(site) + if site_preset: + return site_preset["provider"] + + return "NA" + + @time_function + def get_sync_representations(self, collection, active_site, remote_site): + """ + Get representations that should be synced, these could be + recognised by presence of document in 'files.sites', where key is + a provider (GDrive, S3) and value is empty document or document + without 'created_dt' field. (Don't put null to 'created_dt'!). + + Querying of 'to-be-synched' files is offloaded to Mongod for + better performance. Goal is to get as few representations as + possible. + Args: + collection (string): name of collection (in most cases matches + project name + active_site (string): identifier of current active site (could be + 'local_0' when working from home, 'studio' when working in the + studio (default) + remote_site (string): identifier of remote site I want to sync to + + Returns: + (list) of dictionaries + """ + log.debug("Check representations for : {}".format(collection)) + self.connection.Session["AVALON_PROJECT"] = collection + # retry_cnt - number of attempts to sync specific file before giving up + retries_arr = self._get_retries_arr(collection) + query = { + "type": "representation", + "$or": [ + {"$and": [ + { + "files.sites": { + "$elemMatch": { + "name": active_site, + "created_dt": {"$exists": True} + } + }}, { + "files.sites": { + "$elemMatch": { + "name": {"$in": [remote_site]}, + "created_dt": {"$exists": False}, + "tries": {"$in": retries_arr} + } + } + }]}, + {"$and": [ + { + "files.sites": { + "$elemMatch": { + "name": active_site, + "created_dt": {"$exists": False}, + "tries": {"$in": retries_arr} + } + }}, { + "files.sites": { + "$elemMatch": { + "name": {"$in": [remote_site]}, + "created_dt": {"$exists": True} + } + } + } + ]} + ] + } + log.debug("active_site:{} - remote_site:{}".format(active_site, + remote_site)) + log.debug("query: {}".format(query)) + representations = self.connection.find(query) + + return representations + + def check_status(self, file, local_site, remote_site, config_preset): + """ + Check synchronization status for single 'file' of single + 'representation' by single 'provider'. + (Eg. check if 'scene.ma' of lookdev.v10 should be synced to GDrive + + Always is comparing local record, eg. site with + 'name' == self.presets[PROJECT_NAME]['config']["active_site"] + + Args: + file (dictionary): of file from representation in Mongo + local_site (string): - local side of compare (usually 'studio') + remote_site (string): - gdrive etc. + config_preset (dict): config about active site, retries + Returns: + (string) - one of SyncStatus + """ + sites = file.get("sites") or [] + # if isinstance(sites, list): # temporary, old format of 'sites' + # return SyncStatus.DO_NOTHING + _, remote_rec = self._get_site_rec(sites, remote_site) or {} + if remote_rec: # sync remote target + created_dt = remote_rec.get("created_dt") + if not created_dt: + tries = self._get_tries_count_from_rec(remote_rec) + # file will be skipped if unsuccessfully tried over threshold + # error metadata needs to be purged manually in DB to reset + if tries < int(config_preset["retry_cnt"]): + return SyncStatus.DO_UPLOAD + else: + _, local_rec = self._get_site_rec(sites, local_site) or {} + if not local_rec or not local_rec.get("created_dt"): + tries = self._get_tries_count_from_rec(local_rec) + # file will be skipped if unsuccessfully tried over + # threshold times, error metadata needs to be purged + # manually in DB to reset + if tries < int(config_preset["retry_cnt"]): + return SyncStatus.DO_DOWNLOAD + + return SyncStatus.DO_NOTHING + + def update_db(self, collection, new_file_id, file, representation, + site, error=None, progress=None): + """ + Update 'provider' portion of records in DB with success (file_id) + or error (exception) + + Args: + collection (string): name of project - force to db connection as + each file might come from different collection + new_file_id (string): + file (dictionary): info about processed file (pulled from DB) + representation (dictionary): parent repr of file (from DB) + site (string): label ('gdrive', 'S3') + error (string): exception message + progress (float): 0-1 of progress of upload/download + + Returns: + None + """ + representation_id = representation.get("_id") + file_id = file.get("_id") + query = { + "_id": representation_id + } + + update = {} + if new_file_id: + update["$set"] = self._get_success_dict(new_file_id) + # reset previous errors if any + update["$unset"] = self._get_error_dict("", "", "") + elif progress is not None: + update["$set"] = self._get_progress_dict(progress) + else: + tries = self._get_tries_count(file, site) + tries += 1 + + update["$set"] = self._get_error_dict(error, tries) + + arr_filter = [ + {'s.name': site}, + {'f._id': ObjectId(file_id)} + ] + + self.connection.database[collection].update_one( + query, + update, + upsert=True, + array_filters=arr_filter + ) + + if progress is not None: + return + + status = 'failed' + error_str = 'with error {}'.format(error) + if new_file_id: + status = 'succeeded with id {}'.format(new_file_id) + error_str = '' + + source_file = file.get("path", "") + log.debug("File for {} - {source_file} process {status} {error_str}". + format(representation_id, + status=status, + source_file=source_file, + error_str=error_str)) + + def _get_file_info(self, files, _id): + """ + Return record from list of records which name matches to 'provider' + Could be possibly refactored with '_get_provider_rec' together. + + Args: + files (list): of dictionaries with info about published files + _id (string): _id of specific file + + Returns: + (int, dictionary): index from list and record with metadata + about site (if/when created, errors..) + OR (-1, None) if not present + """ + for index, rec in enumerate(files): + if rec.get("_id") == _id: + return index, rec + + return -1, None + + def _get_site_rec(self, sites, site_name): + """ + Return record from list of records which name matches to + 'remote_site_name' + + Args: + sites (list): of dictionaries + site_name (string): 'local_XXX', 'gdrive' + + Returns: + (int, dictionary): index from list and record with metadata + about site (if/when created, errors..) + OR (-1, None) if not present + """ + for index, rec in enumerate(sites): + if rec.get("name") == site_name: + return index, rec + + return -1, None + + def reset_provider_for_file(self, collection, representation_id, + side=None, file_id=None, site_name=None, + remove=False, pause=None, force=False): + """ + Reset information about synchronization for particular 'file_id' + and provider. + Useful for testing or forcing file to be reuploaded. + + 'side' and 'site_name' are disjunctive. + + 'side' is used for resetting local or remote side for + current user for repre. + + 'site_name' is used to set synchronization for particular site. + Should be used when repre should be synced to new site. + + Args: + collection (string): name of project (eg. collection) in DB + representation_id(string): _id of representation + file_id (string): file _id in representation + side (string): local or remote side + site_name (string): for adding new site + remove (bool): if True remove site altogether + pause (bool or None): if True - pause, False - unpause + force (bool): hard reset - currently only for add_site + + Returns: + throws ValueError + """ + query = { + "_id": ObjectId(representation_id) + } + + representation = list(self.connection.database[collection].find(query)) + if not representation: + raise ValueError("Representation {} not found in {}". + format(representation_id, collection)) + if side and site_name: + raise ValueError("Misconfiguration, only one of side and " + + "site_name arguments should be passed.") + + local_site = self.get_active_site(collection) + remote_site = self.get_remote_site(collection) + + if side: + if side == 'local': + site_name = local_site + else: + site_name = remote_site + + elem = {"name": site_name} + + if file_id: # reset site for particular file + self._reset_site_for_file(collection, query, + elem, file_id, site_name) + elif side: # reset site for whole representation + self._reset_site(collection, query, elem, site_name) + elif remove: # remove site for whole representation + self._remove_site(collection, query, representation, site_name) + elif pause is not None: + self._pause_unpause_site(collection, query, + representation, site_name, pause) + else: # add new site to all files for representation + self._add_site(collection, query, representation, elem, site_name, + force) + + def _update_site(self, collection, query, update, arr_filter): + """ + Auxiliary method to call update_one function on DB + + Used for refactoring ugly reset_provider_for_file + """ + self.connection.database[collection].update_one( + query, + update, + upsert=True, + array_filters=arr_filter + ) + + def _reset_site_for_file(self, collection, query, + elem, file_id, site_name): + """ + Resets 'site_name' for 'file_id' on representation in 'query' on + 'collection' + """ + update = { + "$set": {"files.$[f].sites.$[s]": elem} + } + arr_filter = [ + {'s.name': site_name}, + {'f._id': ObjectId(file_id)} + ] + + self._update_site(collection, query, update, arr_filter) + + def _reset_site(self, collection, query, elem, site_name): + """ + Resets 'site_name' for all files of representation in 'query' + """ + update = { + "$set": {"files.$[].sites.$[s]": elem} + } + + arr_filter = [ + {'s.name': site_name} + ] + + self._update_site(collection, query, update, arr_filter) + + def _remove_site(self, collection, query, representation, site_name): + """ + Removes 'site_name' for 'representation' in 'query' + + Throws ValueError if 'site_name' not found on 'representation' + """ + found = False + for repre_file in representation.pop().get("files"): + for site in repre_file.get("sites"): + if site["name"] == site_name: + found = True + break + if not found: + msg = "Site {} not found".format(site_name) + log.info(msg) + raise ValueError(msg) + + update = { + "$pull": {"files.$[].sites": {"name": site_name}} + } + arr_filter = [] + + self._update_site(collection, query, update, arr_filter) + + def _pause_unpause_site(self, collection, query, + representation, site_name, pause): + """ + Pauses/unpauses all files for 'representation' based on 'pause' + + Throws ValueError if 'site_name' not found on 'representation' + """ + found = False + site = None + for repre_file in representation.pop().get("files"): + for site in repre_file.get("sites"): + if site["name"] == site_name: + found = True + break + if not found: + msg = "Site {} not found".format(site_name) + log.info(msg) + raise ValueError(msg) + + if pause: + site['paused'] = pause + else: + if site.get('paused'): + site.pop('paused') + + update = { + "$set": {"files.$[].sites.$[s]": site} + } + + arr_filter = [ + {'s.name': site_name} + ] + + self._update_site(collection, query, update, arr_filter) + + def _add_site(self, collection, query, representation, elem, site_name, + force=False): + """ + Adds 'site_name' to 'representation' on 'collection' + + Use 'force' to remove existing or raises ValueError + """ + for repre_file in representation.pop().get("files"): + for site in repre_file.get("sites"): + if site["name"] == site_name: + if force: + self._reset_site_for_file(collection, query, + elem, repre_file["_id"], + site_name) + return + else: + msg = "Site {} already present".format(site_name) + log.info(msg) + raise ValueError(msg) + + update = { + "$push": {"files.$[].sites": elem} + } + + arr_filter = [] + + self._update_site(collection, query, update, arr_filter) + + def _remove_local_file(self, collection, representation_id, site_name): + """ + Removes all local files for 'site_name' of 'representation_id' + + Args: + collection (string): project name (must match DB) + representation_id (string): MongoDB _id value + site_name (string): name of configured and active site + + Returns: + only logs, catches IndexError and OSError + """ + my_local_site = get_local_site_id() + if my_local_site != site_name: + self.log.warning("Cannot remove non local file for {}". + format(site_name)) + return + + provider_name = self.get_provider_for_site(collection, site_name) + + if provider_name == 'local_drive': + handler = LocalDriveHandler(collection, site_name) + query = { + "_id": ObjectId(representation_id) + } + + representation = list( + self.connection.database[collection].find(query)) + if not representation: + self.log.debug("No repre {} found".format( + representation_id)) + return + + representation = representation.pop() + local_file_path = '' + for file in representation.get("files"): + local_file_path = self.get_local_file_path(collection, + site_name, + file.get("path", "") + ) + try: + self.log.debug("Removing {}".format(local_file_path)) + os.remove(local_file_path) + except IndexError: + msg = "No file set for {}".format(representation_id) + self.log.debug(msg) + raise ValueError(msg) + except OSError: + msg = "File {} cannot be removed".format(file["path"]) + self.log.warning(msg) + raise ValueError(msg) + + folder = None + try: + folder = os.path.dirname(local_file_path) + os.rmdir(folder) + except OSError: + msg = "folder {} cannot be removed".format(folder) + self.log.warning(msg) + raise ValueError(msg) + + def get_loop_delay(self, project_name): + """ + Return count of seconds before next synchronization loop starts + after finish of previous loop. + Returns: + (int): in seconds + """ + ld = self.sync_project_settings[project_name]["config"]["loop_delay"] + return int(ld) + + def show_widget(self): + """Show dialog to enter credentials""" + self.widget.show() + + def _get_success_dict(self, new_file_id): + """ + Provide success metadata ("id", "created_dt") to be stored in Db. + Used in $set: "DICT" part of query. + Sites are array inside of array(file), so real indexes for both + file and site are needed for upgrade in DB. + Args: + new_file_id: id of created file + Returns: + (dictionary) + """ + val = {"files.$[f].sites.$[s].id": new_file_id, + "files.$[f].sites.$[s].created_dt": datetime.now()} + return val + + def _get_error_dict(self, error="", tries="", progress=""): + """ + Provide error metadata to be stored in Db. + Used for set (error and tries provided) or unset mode. + Args: + error: (string) - message + tries: how many times failed + Returns: + (dictionary) + """ + val = {"files.$[f].sites.$[s].last_failed_dt": datetime.now(), + "files.$[f].sites.$[s].error": error, + "files.$[f].sites.$[s].tries": tries, + "files.$[f].sites.$[s].progress": progress + } + return val + + def _get_tries_count_from_rec(self, rec): + """ + Get number of failed attempts to sync from site record + Args: + rec (dictionary): info about specific site record + Returns: + (int) - number of failed attempts + """ + if not rec: + return 0 + return rec.get("tries", 0) + + def _get_tries_count(self, file, provider): + """ + Get number of failed attempts to sync + Args: + file (dictionary): info about specific file + provider (string): name of site ('gdrive' or specific user site) + Returns: + (int) - number of failed attempts + """ + _, rec = self._get_site_rec(file.get("sites", []), provider) + return rec.get("tries", 0) + + def _get_progress_dict(self, progress): + """ + Provide progress metadata to be stored in Db. + Used during upload/download for GUI to show. + Args: + progress: (float) - 0-1 progress of upload/download + Returns: + (dictionary) + """ + val = {"files.$[f].sites.$[s].progress": progress} + return val + + def _get_retries_arr(self, project_name): + """ + Returns array with allowed values in 'tries' field. If repre + contains these values, it means it was tried to be synchronized + but failed. We try up to 'self.presets["retry_cnt"]' times before + giving up and skipping representation. + Returns: + (list) + """ + retry_cnt = self.sync_project_settings[project_name].\ + get("config")["retry_cnt"] + arr = [i for i in range(int(retry_cnt))] + arr.append(None) + + return arr + + def _get_roots_config(self, presets, project_name, site_name): + """ + Returns configured root(s) for 'project_name' and 'site_name' from + settings ('presets') + """ + return presets[project_name]['sites'][site_name]['root'] diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 476e9d16e8..41a0f84afb 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -159,7 +159,7 @@ class SyncProjectListWidget(ProjectListWidget): model.clear() project_name = None - for project_name in self.sync_server.get_sync_project_settings().\ + for project_name in self.sync_server.sync_project_settings.\ keys(): if self.sync_server.is_paused() or \ self.sync_server.is_project_paused(project_name): @@ -169,7 +169,7 @@ class SyncProjectListWidget(ProjectListWidget): model.appendRow(QtGui.QStandardItem(icon, project_name)) - if len(self.sync_server.get_sync_project_settings().keys()) == 0: + if len(self.sync_server.sync_project_settings.keys()) == 0: model.appendRow(QtGui.QStandardItem(DUMMY_PROJECT)) self.current_project = self.project_list.currentIndex().data( @@ -271,15 +271,29 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("subset", 190), ("version", 10), ("representation", 90), - ("created_dt", 100), - ("sync_dt", 100), - ("local_site", 60), - ("remote_site", 70), - ("files_count", 70), - ("files_size", 70), + ("created_dt", 105), + ("sync_dt", 105), + ("local_site", 80), + ("remote_site", 80), + ("files_count", 50), + ("files_size", 60), ("priority", 20), ("state", 50) ) + column_labels = ( + ("asset", "Asset"), + ("subset", "Subset"), + ("version", "Version"), + ("representation", "Representation"), + ("created_dt", "Created"), + ("sync_dt", "Synced"), + ("local_site", "Active site"), + ("remote_site", "Remote site"), + ("files_count", "Files"), + ("files_size", "Size"), + ("priority", "Priority"), + ("state", "Status") + ) def __init__(self, sync_server, project=None, parent=None): super(SyncRepresentationWidget, self).__init__(parent) @@ -298,8 +312,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] + header_labels = [item[1] for item in self.column_labels] - model = SyncRepresentationModel(sync_server, headers, project) + model = SyncRepresentationModel(sync_server, headers, + project, header_labels) self.table_view.setModel(model) self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.table_view.setSelectionMode( @@ -376,7 +392,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): """ _id = self.table_view.model().data(index, Qt.UserRole) detail_window = SyncServerDetailWindow( - self.sync_server, _id, self.table_view.model()._project) + self.sync_server, _id, self.table_view.model().project) detail_window.exec() def _on_context_menu(self, point): @@ -394,15 +410,28 @@ class SyncRepresentationWidget(QtWidgets.QWidget): menu = QtWidgets.QMenu() actions_mapping = {} + actions_kwargs_mapping = {} - action = QtWidgets.QAction("Open in explorer") - actions_mapping[action] = self._open_in_explorer - menu.addAction(action) + local_site = self.item.local_site + local_progress = self.item.local_progress + remote_site = self.item.remote_site + remote_progress = self.item.remote_progress - local_site, local_progress = self.item.local_site.split() - remote_site, remote_progress = self.item.remote_site.split() - local_progress = float(local_progress) - remote_progress = float(remote_progress) + for site, progress in {local_site: local_progress, + remote_site: remote_progress}.items(): + project = self.table_view.model().project + provider = self.sync_server.get_provider_for_site(project, + site) + if provider == 'local_drive': + if 'studio' in site: + txt = " studio version" + else: + txt = " local version" + action = QtWidgets.QAction("Open in explorer" + txt) + if progress == 1.0: + actions_mapping[action] = self._open_in_explorer + actions_kwargs_mapping[action] = {'site': site} + menu.addAction(action) # progress smaller then 1.0 --> in progress or queued if local_progress < 1.0: @@ -452,13 +481,14 @@ class SyncRepresentationWidget(QtWidgets.QWidget): result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] + to_run_kwargs = actions_kwargs_mapping.get(result, {}) if to_run: - to_run() + to_run(**to_run_kwargs) self.table_view.model().refresh() def _pause(self): - self.sync_server.pause_representation(self.table_view.model()._project, + self.sync_server.pause_representation(self.table_view.model().project, self.representation_id, self.site_name) self.site_name = None @@ -466,7 +496,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): def _unpause(self): self.sync_server.unpause_representation( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, self.site_name) self.site_name = None @@ -476,7 +506,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): # temporary here for testing, will be removed TODO def _add_site(self): log.info(self.representation_id) - project_name = self.table_view.model()._project + project_name = self.table_view.model().project local_site_name = self.sync_server.get_my_local_site() try: self.sync_server.add_site( @@ -504,7 +534,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): try: local_site = get_local_site_id() self.sync_server.remove_site( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, local_site, True @@ -519,7 +549,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'local' ) @@ -530,18 +560,20 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'remote' ) - def _open_in_explorer(self): + def _open_in_explorer(self, site): if not self.item: return fpath = self.item.path - project = self.table_view.model()._project - fpath = self.sync_server.get_local_file_path(project, fpath) + project = self.table_view.model().project + fpath = self.sync_server.get_local_file_path(project, + site, + fpath) fpath = os.path.normpath(os.path.dirname(fpath)) if os.path.isdir(fpath): @@ -556,6 +588,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): raise OSError('unsupported xdg-open call??') +ProviderRole = QtCore.Qt.UserRole + 2 +ProgressRole = QtCore.Qt.UserRole + 4 + + class SyncRepresentationModel(QtCore.QAbstractTableModel): """ Model for summary of representations. @@ -612,15 +648,20 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): sync_dt = attr.ib(default=None) local_site = attr.ib(default=None) remote_site = attr.ib(default=None) + local_provider = attr.ib(default=None) + remote_provider = attr.ib(default=None) + local_progress = attr.ib(default=None) + remote_progress = attr.ib(default=None) files_count = attr.ib(default=None) files_size = attr.ib(default=None) priority = attr.ib(default=None) state = attr.ib(default=None) path = attr.ib(default=None) - def __init__(self, sync_server, header, project=None): + def __init__(self, sync_server, header, project=None, header_labels=None): super(SyncRepresentationModel, self).__init__() self._header = header + self._header_labels = header_labels self._data = [] self._project = project self._rec_loaded = 0 @@ -634,8 +675,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self.sync_server = sync_server # TODO think about admin mode # this is for regular user, always only single local and single remote - self.local_site = self.sync_server.get_active_site(self._project) - self.remote_site = self.sync_server.get_remote_site(self._project) + self.local_site = self.sync_server.get_active_site(self.project) + self.remote_site = self.sync_server.get_remote_site(self.project) self.projection = self.get_default_projection() @@ -659,26 +700,46 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): All queries should go through this (because of collection). """ - return self.sync_server.connection.database[self._project] + return self.sync_server.connection.database[self.project] + + @property + def project(self): + """Returns project""" + return self._project def data(self, index, role): item = self._data[index.row()] + if role == ProviderRole: + if self._header[index.column()] == 'local_site': + return item.local_provider + if self._header[index.column()] == 'remote_site': + return item.remote_provider + + if role == ProgressRole: + if self._header[index.column()] == 'local_site': + return item.local_progress + if self._header[index.column()] == 'remote_site': + return item.remote_progress + if role == Qt.DisplayRole: return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id - def rowCount(self, index): + def rowCount(self, _index): return len(self._data) - def columnCount(self, index): + def columnCount(self, _index): return len(self._header) def headerData(self, section, orientation, role): if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - return str(self._header[section]) + if self._header_labels: + return str(self._header_labels[section]) + else: + return str(self._header[section]) def tick(self): """ @@ -718,7 +779,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): than single page of records) """ if self.sync_server.is_paused() or \ - self.sync_server.is_project_paused(self._project): + self.sync_server.is_project_paused(self.project): return self.beginResetModel() @@ -751,10 +812,10 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self._total_records = count local_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, local_site) remote_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, remote_site) for repre in result.get("paginatedResults"): @@ -784,7 +845,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): if context.get("version"): version = "v{:0>3d}".format(context.get("version")) else: - version = "hero" + version = "master" item = self.SyncRepresentation( repre.get("_id"), @@ -794,8 +855,12 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): context.get("representation"), local_updated, remote_updated, - '{} {}'.format(local_provider, avg_progress_local), - '{} {}'.format(remote_provider, avg_progress_remote), + local_site, + remote_site, + local_provider, + remote_provider, + avg_progress_local, + avg_progress_remote, repre.get("files_count", 1), repre.get("files_size", 0), 1, @@ -806,7 +871,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self._data.append(item) self._rec_loaded += 1 - def canFetchMore(self, index): + def canFetchMore(self, _index): """ Check if there are more records than currently loaded """ @@ -858,7 +923,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self.sort = {self.SORT_BY_COLUMN[index]: order, '_id': 1} self.query = self.get_default_query() # import json - # log.debug(json.dumps(self.query, indent=4).replace('False', 'false').\ + # log.debug(json.dumps(self.query, indent=4).\ + # replace('False', 'false').\ # replace('True', 'true').replace('None', 'null')) representations = self.dbcon.aggregate(self.query) @@ -883,8 +949,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): """ self._project = project self.sync_server.set_sync_project_settings() - self.local_site = self.sync_server.get_active_site(self._project) - self.remote_site = self.sync_server.get_remote_site(self._project) + self.local_site = self.sync_server.get_active_site(self.project) + self.remote_site = self.sync_server.get_remote_site(self.project) self.refresh() def get_index(self, id): @@ -1206,15 +1272,26 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): default_widths = ( ("file", 290), - ("created_dt", 120), - ("sync_dt", 120), - ("local_site", 60), - ("remote_site", 60), + ("created_dt", 105), + ("sync_dt", 105), + ("local_site", 80), + ("remote_site", 80), ("size", 60), ("priority", 20), ("state", 90) ) + column_labels = ( + ("file", "File name"), + ("created_dt", "Created"), + ("sync_dt", "Synced"), + ("local_site", "Active site"), + ("remote_site", "Remote site"), + ("files_size", "Size"), + ("priority", "Priority"), + ("state", "Status") + ) + def __init__(self, sync_server, _id=None, project=None, parent=None): super(SyncRepresentationDetailWidget, self).__init__(parent) @@ -1235,9 +1312,10 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] + header_labels = [item[1] for item in self.column_labels] model = SyncRepresentationDetailModel(sync_server, headers, _id, - project) + project, header_labels) self.table_view.setModel(model) self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.table_view.setSelectionMode( @@ -1330,23 +1408,39 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): menu = QtWidgets.QMenu() actions_mapping = {} + actions_kwargs_mapping = {} - action = QtWidgets.QAction("Open in explorer") - actions_mapping[action] = self._open_in_explorer - menu.addAction(action) + local_site = self.item.local_site + local_progress = self.item.local_progress + remote_site = self.item.remote_site + remote_progress = self.item.remote_progress + + for site, progress in {local_site: local_progress, + remote_site: remote_progress}.items(): + project = self.table_view.model().project + provider = self.sync_server.get_provider_for_site(project, + site) + if provider == 'local_drive': + if 'studio' in site: + txt = " studio version" + else: + txt = " local version" + action = QtWidgets.QAction("Open in explorer" + txt) + if progress == 1: + actions_mapping[action] = self._open_in_explorer + actions_kwargs_mapping[action] = {'site': site} + menu.addAction(action) if self.item.state == STATUS[1]: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail menu.addAction(action) - remote_site, remote_progress = self.item.remote_site.split() if float(remote_progress) == 1.0: action = QtWidgets.QAction("Reset local site") actions_mapping[action] = self._reset_local_site menu.addAction(action) - local_site, local_progress = self.item.local_site.split() if float(local_progress) == 1.0: action = QtWidgets.QAction("Reset remote site") actions_mapping[action] = self._reset_remote_site @@ -1360,8 +1454,9 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] + to_run_kwargs = actions_kwargs_mapping.get(result, {}) if to_run: - to_run() + to_run(**to_run_kwargs) def _reset_local_site(self): """ @@ -1369,7 +1464,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'local', self.item._id) @@ -1381,19 +1476,19 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'remote', self.item._id) self.table_view.model().refresh() - def _open_in_explorer(self): + def _open_in_explorer(self, site): if not self.item: return fpath = self.item.path - project = self.table_view.model()._project - fpath = self.sync_server.get_local_file_path(project, fpath) + project = self.project + fpath = self.sync_server.get_local_file_path(project, site, fpath) fpath = os.path.normpath(os.path.dirname(fpath)) if os.path.isdir(fpath): @@ -1415,6 +1510,8 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): Used in detail window accessible after clicking on single repre in the summary. + TODO refactor - merge with SyncRepresentationModel if possible + Args: sync_server (SyncServer) - object to call server operations (update db status, set site status...) @@ -1424,7 +1521,6 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): a specific collection """ PAGE_SIZE = 30 - # TODO add filter filename DEFAULT_SORT = { "files.path": 1 } @@ -1452,6 +1548,10 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): sync_dt = attr.ib(default=None) local_site = attr.ib(default=None) remote_site = attr.ib(default=None) + local_provider = attr.ib(default=None) + remote_provider = attr.ib(default=None) + local_progress = attr.ib(default=None) + remote_progress = attr.ib(default=None) size = attr.ib(default=None) priority = attr.ib(default=None) state = attr.ib(default=None) @@ -1459,9 +1559,11 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): error = attr.ib(default=None) path = attr.ib(default=None) - def __init__(self, sync_server, header, _id, project=None): + def __init__(self, sync_server, header, _id, + project=None, header_labels=None): super(SyncRepresentationDetailModel, self).__init__() self._header = header + self._header_labels = header_labels self._data = [] self._project = project self._rec_loaded = 0 @@ -1473,8 +1575,8 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self.sync_server = sync_server # TODO think about admin mode # this is for regular user, always only single local and single remote - self.local_site = self.sync_server.get_active_site(self._project) - self.remote_site = self.sync_server.get_remote_site(self._project) + self.local_site = self.sync_server.get_active_site(self.project) + self.remote_site = self.sync_server.get_remote_site(self.project) self.sort = self.DEFAULT_SORT @@ -1491,9 +1593,26 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): @property def dbcon(self): - return self.sync_server.connection.database[self._project] + """ + Database object with preselected project (collection) to run DB + operations (find, aggregate). + + All queries should go through this (because of collection). + """ + return self.sync_server.connection.database[self.project] + + @property + def project(self): + """Returns project""" + return self.project def tick(self): + """ + Triggers refresh of model. + + Because of pagination, prepared (sorting, filtering) query needs + to be run on DB every X seconds. + """ self.refresh(representations=None, load_records=self._rec_loaded) self.timer.start(SyncRepresentationModel.REFRESH_SEC) @@ -1510,21 +1629,37 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): def data(self, index, role): item = self._data[index.row()] + + if role == ProviderRole: + if self._header[index.column()] == 'local_site': + return item.local_provider + if self._header[index.column()] == 'remote_site': + return item.remote_provider + + if role == ProgressRole: + if self._header[index.column()] == 'local_site': + return item.local_progress + if self._header[index.column()] == 'remote_site': + return item.remote_progress + if role == Qt.DisplayRole: return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id - def rowCount(self, index): + def rowCount(self, _index): return len(self._data) - def columnCount(self, index): + def columnCount(self, _index): return len(self._header) def headerData(self, section, orientation, role): if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - return str(self._header[section]) + if self._header_labels: + return str(self._header_labels[section]) + else: + return str(self._header[section]) def refresh(self, representations=None, load_records=0): if self.sync_server.is_paused(): @@ -1561,10 +1696,10 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self._total_records = count local_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, local_site) remote_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, remote_site) for repre in result.get("paginatedResults"): @@ -1585,9 +1720,9 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): repre.get('updated_dt_remote').strftime( "%Y%m%dT%H%M%SZ") - progress_remote = _convert_progress( + remote_progress = _convert_progress( repre.get('progress_remote', '0')) - progress_local = _convert_progress( + local_progress = _convert_progress( repre.get('progress_local', '0')) errors = [] @@ -1601,8 +1736,12 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): os.path.basename(file["path"]), local_updated, remote_updated, - '{} {}'.format(local_provider, progress_local), - '{} {}'.format(remote_provider, progress_remote), + local_site, + remote_site, + local_provider, + remote_provider, + local_progress, + remote_progress, file.get('size', 0), 1, STATUS[repre.get("status", -1)], @@ -1614,7 +1753,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self._data.append(item) self._rec_loaded += 1 - def canFetchMore(self, index): + def canFetchMore(self, _index): """ Check if there are more records than currently loaded """ @@ -1918,11 +2057,8 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): option.palette.highlight()) painter.setOpacity(1) - d = index.data(QtCore.Qt.DisplayRole) - if d: - provider, value = d.split() - else: - return + provider = index.data(ProviderRole) + value = index.data(ProgressRole) if not self.icons.get(provider): resource_path = os.path.dirname(__file__) @@ -2008,7 +2144,7 @@ class SizeDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, parent=None): super(SizeDelegate, self).__init__(parent) - def displayText(self, value, locale): + def displayText(self, value, _locale): if value is None: # Ignore None value return diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index 0762766783..36f3444399 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -1,8 +1,14 @@ import time -from openpype.api import Logger +from openpype.api import Logger log = Logger().get_logger("SyncServer") +class SyncStatus: + DO_NOTHING = 0 + DO_UPLOAD = 1 + DO_DOWNLOAD = 2 + + def time_function(method): """ Decorator to print how much time function took. For debugging. diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py new file mode 100644 index 0000000000..09448d553c --- /dev/null +++ b/openpype/plugins/load/add_site.py @@ -0,0 +1,33 @@ +from avalon import api +from openpype.modules import ModulesManager + + +class AddSyncSite(api.Loader): + """Add sync site to representation""" + representations = ["*"] + families = ["*"] + + label = "Add Sync Site" + order = 2 # lower means better + icon = "download" + color = "#999999" + + def load(self, context, name=None, namespace=None, data=None): + self.log.info("Adding {} to representation: {}".format( + data["site_name"], data["_id"])) + self.add_site_to_representation(data["project_name"], + data["_id"], + data["site_name"]) + self.log.debug("Site added.") + + @staticmethod + def add_site_to_representation(project_name, representation_id, site_name): + """Adds new site to representation_id, resets if exists""" + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + sync_server.add_site(project_name, representation_id, site_name, + force=True) + + def filepath_from_context(self, context): + """No real file loading""" + return "" diff --git a/openpype/plugins/load/delete_old_versions.py b/openpype/plugins/load/delete_old_versions.py index e5132e0f8a..8e3999e9c4 100644 --- a/openpype/plugins/load/delete_old_versions.py +++ b/openpype/plugins/load/delete_old_versions.py @@ -15,11 +15,12 @@ from openpype.api import Anatomy class DeleteOldVersions(api.Loader): - + """Deletes specific number of old version""" representations = ["*"] families = ["*"] label = "Delete Old Versions" + order = 35 icon = "trash" color = "#d8d8d8" @@ -421,8 +422,9 @@ class DeleteOldVersions(api.Loader): class CalculateOldVersions(DeleteOldVersions): - + """Calculate file size of old versions""" label = "Calculate Old Versions" + order = 30 options = [ qargparse.Integer( diff --git a/openpype/plugins/load/remove_site.py b/openpype/plugins/load/remove_site.py new file mode 100644 index 0000000000..aedb5d1f2f --- /dev/null +++ b/openpype/plugins/load/remove_site.py @@ -0,0 +1,33 @@ +from avalon import api +from openpype.modules import ModulesManager + + +class RemoveSyncSite(api.Loader): + """Remove sync site and its files on representation""" + representations = ["*"] + families = ["*"] + + label = "Remove Sync Site" + order = 4 + icon = "download" + color = "#999999" + + def load(self, context, name=None, namespace=None, data=None): + self.log.info("Removing {} on representation: {}".format( + data["site_name"], data["_id"])) + self.remove_site_on_representation(data["project_name"], + data["_id"], + data["site_name"]) + self.log.debug("Site added.") + + @staticmethod + def remove_site_on_representation(project_name, representation_id, + site_name): + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + sync_server.remove_site(project_name, representation_id, + site_name, True) + + def filepath_from_context(self, context): + """No real file loading""" + return "" From 79e5a55719e1eabf15bedd29968429af63638941 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 6 Apr 2021 12:41:04 +0200 Subject: [PATCH 027/515] Merge after rebranding --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index bbba8765c4..c3dc49184a 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit bbba8765c431ee124590e4f12d2e56db4d62eacd +Subproject commit c3dc49184ab14e2590a51dde55695cf27ab23510 From 40fe1ba76a1487f2e09a1294516baf5247b66dee Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 6 Apr 2021 13:32:00 +0200 Subject: [PATCH 028/515] Hiero: Solving Merge conflicts --- {pype => openpype}/hosts/hiero/api/plugin.py | 16 +- .../hiero/plugins/create/create_shot_clip.py | 4 +- .../hiero/plugins/publish/collect_review.py | 2 +- .../publish/extract_review_preparation.py | 6 +- .../plugins/publish/precollect_instances.py | 13 +- .../publish/extract_hierarchy_avalon.py | 12 +- .../hiero/plugins/publish/collect_review.py | 261 -------------- .../publish/extract_review_preparation.py | 334 ------------------ .../plugins/publish/precollect_instances.py | 224 ------------ pype/modules/ftrack/python2_vendor/arrow | 1 - .../ftrack/python2_vendor/ftrack-python-api | 1 - .../publish/extract_hierarchy_avalon.py | 202 ----------- 12 files changed, 31 insertions(+), 1045 deletions(-) rename {pype => openpype}/hosts/hiero/api/plugin.py (98%) delete mode 100644 pype/hosts/hiero/plugins/publish/collect_review.py delete mode 100644 pype/hosts/hiero/plugins/publish/extract_review_preparation.py delete mode 100644 pype/hosts/hiero/plugins/publish/precollect_instances.py delete mode 160000 pype/modules/ftrack/python2_vendor/arrow delete mode 160000 pype/modules/ftrack/python2_vendor/ftrack-python-api delete mode 100644 pype/plugins/publish/extract_hierarchy_avalon.py diff --git a/pype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py similarity index 98% rename from pype/hosts/hiero/api/plugin.py rename to openpype/hosts/hiero/api/plugin.py index c2af4a011c..b356c9b6ce 100644 --- a/pype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -4,10 +4,10 @@ import hiero from Qt import QtWidgets, QtCore from avalon.vendor import qargparse import avalon.api as avalon -import pype.api as pype +import openpype.api as openpype from . import lib -log = pype.Logger().get_logger(__name__) +log = openpype.Logger().get_logger(__name__) def load_stylesheet(): @@ -477,7 +477,7 @@ class ClipLoader: """ asset_name = self.context["representation"]["context"]["asset"] - self.data["assetData"] = pype.get_asset(asset_name)["data"] + self.data["assetData"] = openpype.get_asset(asset_name)["data"] def _make_track_item(self, source_bin_item, audio=False): """ Create track item with """ @@ -593,16 +593,16 @@ class ClipLoader: return track_item -class Creator(pype.Creator): +class Creator(openpype.Creator): """Creator class wrapper """ clip_color = "Purple" rename_index = None def __init__(self, *args, **kwargs): - import pype.hosts.hiero.api as phiero + import openpype.hosts.hiero.api as phiero super(Creator, self).__init__(*args, **kwargs) - self.presets = pype.get_current_project_settings()[ + self.presets = openpype.get_current_project_settings()[ "hiero"]["create"].get(self.__class__.__name__, {}) # adding basic current context resolve objects @@ -774,8 +774,8 @@ class PublishClip: _spl = text.split("#") _len = (len(_spl) - 1) _repl = "{{{0}:0>{1}}}".format(name, _len) - new_text = text.replace(("#" * _len), _repl) - return new_text + return text.replace(("#" * _len), _repl) + def _convert_to_tag_data(self): """ Convert internal data to tag data. diff --git a/openpype/hosts/hiero/plugins/create/create_shot_clip.py b/openpype/hosts/hiero/plugins/create/create_shot_clip.py index 07b7a62b2a..25be9f090b 100644 --- a/openpype/hosts/hiero/plugins/create/create_shot_clip.py +++ b/openpype/hosts/hiero/plugins/create/create_shot_clip.py @@ -120,9 +120,9 @@ class CreateShotClip(phiero.Creator): "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", - "label": "Master track", + "label": "Hero track", "target": "ui", - "toolTip": "Select driving track name which should be mastering all others", # noqa + "toolTip": "Select driving track name which should be hero for all others", # noqa "order": 1} } }, diff --git a/openpype/hosts/hiero/plugins/publish/collect_review.py b/openpype/hosts/hiero/plugins/publish/collect_review.py index a0ab00b355..b1d97a71d7 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_review.py +++ b/openpype/hosts/hiero/plugins/publish/collect_review.py @@ -29,7 +29,7 @@ class CollectReview(api.InstancePlugin): Exception: description """ - review_track = instance.data.get("review") + review_track = instance.data.get("reviewTrack") video_tracks = instance.context.data["videoTracks"] for track in video_tracks: if review_track not in track.name(): diff --git a/openpype/hosts/hiero/plugins/publish/extract_review_preparation.py b/openpype/hosts/hiero/plugins/publish/extract_review_preparation.py index 5456ddc3c4..aac476e27a 100644 --- a/openpype/hosts/hiero/plugins/publish/extract_review_preparation.py +++ b/openpype/hosts/hiero/plugins/publish/extract_review_preparation.py @@ -132,7 +132,7 @@ class ExtractReviewPreparation(openpype.api.Extractor): ).format(**locals()) self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) - audio_check_output = openpype.api.subprocess(ffprob_cmd) + audio_check_output = openpype.api.run_subprocess(ffprob_cmd) self.log.debug( "audio_check_output: {}".format(audio_check_output)) @@ -167,7 +167,7 @@ class ExtractReviewPreparation(openpype.api.Extractor): # try to get video native resolution data try: - resolution_output = openpype.api.subprocess(( + resolution_output = openpype.api.run_subprocess(( "\"{ffprobe_path}\" -i \"{full_input_path}\"" " -v error " "-select_streams v:0 -show_entries " @@ -280,7 +280,7 @@ class ExtractReviewPreparation(openpype.api.Extractor): # run subprocess self.log.debug("Executing: {}".format(subprcs_cmd)) - output = openpype.api.subprocess(subprcs_cmd) + output = openpype.api.run_subprocess(subprcs_cmd) self.log.debug("Output: {}".format(output)) repre_new = { diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index bdf007de06..2b769afee1 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -14,6 +14,7 @@ class PreCollectInstances(api.ContextPlugin): label = "Pre-collect Instances" hosts = ["hiero"] + def process(self, context): track_items = phiero.get_track_items( selected=True, check_tagged=True, check_enabled=True) @@ -34,7 +35,7 @@ class PreCollectInstances(api.ContextPlugin): "Processing enabled track items: {}".format(len(track_items))) for _ti in track_items: - data = dict() + data = {} clip = _ti.source() # get clips subtracks and anotations @@ -60,7 +61,8 @@ class PreCollectInstances(api.ContextPlugin): asset = tag_parsed_data["asset"] subset = tag_parsed_data["subset"] - review = tag_parsed_data.get("review") + review_track = tag_parsed_data.get("reviewTrack") + hiero_track = tag_parsed_data.get("heroTrack") audio = tag_parsed_data.get("audio") # remove audio attribute from data @@ -78,8 +80,8 @@ class PreCollectInstances(api.ContextPlugin): file_info = media_source.fileinfos().pop() source_first_frame = int(file_info.startFrame()) - # apply only for feview and master track instance - if review: + # apply only for review and master track instance + if review_track and hiero_track: families += ["review", "ftrack"] data.update({ @@ -94,6 +96,7 @@ class PreCollectInstances(api.ContextPlugin): # track item attributes "track": track.name(), "trackItem": track, + "reviewTrack": review_track, # version data "versionData": { @@ -113,7 +116,7 @@ class PreCollectInstances(api.ContextPlugin): instance = context.create_instance(**data) - self.log.info("Creating instance: {}".format(instance)) + self.log.info("Creating instance.data: {}".format(instance.data)) if audio: a_data = dict() diff --git a/openpype/plugins/publish/extract_hierarchy_avalon.py b/openpype/plugins/publish/extract_hierarchy_avalon.py index dd1f09bafa..e263edd931 100644 --- a/openpype/plugins/publish/extract_hierarchy_avalon.py +++ b/openpype/plugins/publish/extract_hierarchy_avalon.py @@ -2,7 +2,6 @@ import pyblish.api from avalon import io from copy import deepcopy - class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): """Create entities in Avalon based on collected data.""" @@ -100,13 +99,20 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): if entity: # Do not override data, only update cur_entity_data = entity.get("data") or {} + entity_tasks = cur_entity_data["tasks"] or {} + + # create tasks as dict by default + if not entity_tasks: + cur_entity_data["tasks"] = entity_tasks + new_tasks = data.pop("tasks", {}) if "tasks" not in cur_entity_data and not new_tasks: continue for task_name in new_tasks: - if task_name in cur_entity_data["tasks"].keys(): + if task_name in entity_tasks.keys(): continue - cur_entity_data["tasks"][task_name] = new_tasks[task_name] + cur_entity_data["tasks"][task_name] = new_tasks[ + task_name] cur_entity_data.update(data) data = cur_entity_data else: diff --git a/pype/hosts/hiero/plugins/publish/collect_review.py b/pype/hosts/hiero/plugins/publish/collect_review.py deleted file mode 100644 index 4df490ab70..0000000000 --- a/pype/hosts/hiero/plugins/publish/collect_review.py +++ /dev/null @@ -1,261 +0,0 @@ -from pyblish import api -import os -import clique -from pype.hosts.hiero.api import ( - is_overlapping, get_sequence_pattern_and_padding) - - -class CollectReview(api.InstancePlugin): - """Collect review representation. - """ - - # Run just before CollectSubsets - order = api.CollectorOrder + 0.1022 - label = "Collect Review" - hosts = ["hiero"] - families = ["review"] - - def get_review_item(self, instance): - """ - Get review clip track item from review track name - - Args: - instance (obj): publishing instance - - Returns: - hiero.core.TrackItem: corresponding track item - - Raises: - Exception: description - - """ - review_track = instance.data.get("reviewTrack") - video_tracks = instance.context.data["videoTracks"] - for track in video_tracks: - if review_track not in track.name(): - continue - for item in track.items(): - self.log.debug(item) - if is_overlapping(item, self.main_clip): - self.log.debug("Winner is: {}".format(item)) - break - - # validate the clip is fully converted with review clip - assert is_overlapping( - item, self.main_clip, strict=True), ( - "Review clip not cowering fully " - "the clip `{}`").format(self.main_clip.name()) - - return item - - def process(self, instance): - tags = ["review", "ftrackreview"] - - # get reviewable item from `review` instance.data attribute - self.main_clip = instance.data.get("item") - self.rw_clip = self.get_review_item(instance) - - # let user know there is missing review clip and convert instance - # back as not reviewable - assert self.rw_clip, "Missing reviewable clip for '{}'".format( - self.main_clip.name() - ) - - # add to representations - if not instance.data.get("representations"): - instance.data["representations"] = list() - - # get review media main info - rw_source = self.rw_clip.source().mediaSource() - rw_source_duration = int(rw_source.duration()) - self.rw_source_path = rw_source.firstpath() - rw_source_file_info = rw_source.fileinfos().pop() - - # define if review media is sequence - is_sequence = bool(not rw_source.singleFile()) - self.log.debug("is_sequence: {}".format(is_sequence)) - - # get handles - handle_start = instance.data["handleStart"] - handle_end = instance.data["handleEnd"] - - # review timeline and source frame ranges - rw_clip_in = int(self.rw_clip.timelineIn()) - rw_clip_out = int(self.rw_clip.timelineOut()) - self.rw_clip_source_in = int(self.rw_clip.sourceIn()) - self.rw_clip_source_out = int(self.rw_clip.sourceOut()) - rw_source_first = int(rw_source_file_info.startFrame()) - - # calculate delivery source_in and source_out - # main_clip_timeline_in - review_item_timeline_in + 1 - main_clip_in = self.main_clip.timelineIn() - main_clip_out = self.main_clip.timelineOut() - - source_in_diff = main_clip_in - rw_clip_in - source_out_diff = main_clip_out - rw_clip_out - - if source_in_diff: - self.rw_clip_source_in += source_in_diff - if source_out_diff: - self.rw_clip_source_out += source_out_diff - - # review clip durations - rw_clip_duration = ( - self.rw_clip_source_out - self.rw_clip_source_in) + 1 - rw_clip_duration_h = rw_clip_duration + ( - handle_start + handle_end) - - # add created data to review item data - instance.data["reviewItemData"] = { - "mediaDuration": rw_source_duration - } - - file_dir = os.path.dirname(self.rw_source_path) - file = os.path.basename(self.rw_source_path) - ext = os.path.splitext(file)[-1] - - # detect if sequence - if not is_sequence: - # is video file - files = file - else: - files = list() - spliter, padding = get_sequence_pattern_and_padding(file) - self.log.debug("_ spliter, padding: {}, {}".format( - spliter, padding)) - base_name = file.split(spliter)[0] - - # define collection and calculate frame range - collection = clique.Collection(base_name, ext, padding, set(range( - int(rw_source_first + int( - self.rw_clip_source_in - handle_start)), - int(rw_source_first + int( - self.rw_clip_source_out + handle_end) + 1)))) - self.log.debug("_ collection: {}".format(collection)) - - real_files = os.listdir(file_dir) - self.log.debug("_ real_files: {}".format(real_files)) - - # collect frames to repre files list - for item in collection: - if item not in real_files: - self.log.debug("_ item: {}".format(item)) - continue - files.append(item) - - # add prep tag - tags.extend(["prep", "delete"]) - - # change label - instance.data["label"] = "{0} - ({1})".format( - instance.data["label"], ext - ) - - self.log.debug("Instance review: {}".format(instance.data["name"])) - - # adding representation for review mov - representation = { - "files": files, - "stagingDir": file_dir, - "frameStart": rw_source_first + self.rw_clip_source_in, - "frameEnd": rw_source_first + self.rw_clip_source_out, - "frameStartFtrack": int( - self.rw_clip_source_in - handle_start), - "frameEndFtrack": int(self.rw_clip_source_out + handle_end), - "step": 1, - "fps": instance.data["fps"], - "name": "review", - "tags": tags, - "ext": ext[1:] - } - - if rw_source_duration > rw_clip_duration_h: - self.log.debug("Media duration higher: {}".format( - (rw_source_duration - rw_clip_duration_h))) - representation.update({ - "frameStart": rw_source_first + int( - self.rw_clip_source_in - handle_start), - "frameEnd": rw_source_first + int( - self.rw_clip_source_out + handle_end), - "tags": ["_cut-bigger", "prep", "delete"] - }) - elif rw_source_duration < rw_clip_duration_h: - self.log.debug("Media duration higher: {}".format( - (rw_source_duration - rw_clip_duration_h))) - representation.update({ - "frameStart": rw_source_first + int( - self.rw_clip_source_in - handle_start), - "frameEnd": rw_source_first + int( - self.rw_clip_source_out + handle_end), - "tags": ["prep", "delete"] - }) - - instance.data["representations"].append(representation) - - self.create_thumbnail(instance) - - self.log.debug( - "Added representations: {}".format( - instance.data["representations"])) - - def create_thumbnail(self, instance): - source_file = os.path.basename(self.rw_source_path) - spliter, padding = get_sequence_pattern_and_padding(source_file) - - if spliter: - head, ext = source_file.split(spliter) - else: - head, ext = os.path.splitext(source_file) - - # staging dir creation - staging_dir = os.path.dirname( - self.rw_source_path) - - # get thumbnail frame from the middle - thumb_frame = int(self.rw_clip_source_in + ( - (self.rw_clip_source_out - self.rw_clip_source_in) / 2)) - - thumb_file = "{}thumbnail{}{}".format(head, thumb_frame, ".png") - thumb_path = os.path.join(staging_dir, thumb_file) - - thumbnail = self.rw_clip.thumbnail(thumb_frame).save( - thumb_path, - format='png' - ) - self.log.debug( - "__ thumbnail: `{}`, frame: `{}`".format(thumbnail, thumb_frame)) - - self.log.debug("__ thumbnail: {}".format(thumbnail)) - thumb_representation = { - 'files': thumb_file, - 'stagingDir': staging_dir, - 'name': "thumbnail", - 'thumbnail': True, - 'ext': "png" - } - instance.data["representations"].append( - thumb_representation) - - def version_data(self, instance): - transfer_data = [ - "handleStart", "handleEnd", "sourceIn", "sourceOut", - "frameStart", "frameEnd", "sourceInH", "sourceOutH", - "clipIn", "clipOut", "clipInH", "clipOutH", "asset", - "track" - ] - - version_data = dict() - # pass data to version - version_data.update({k: instance.data[k] for k in transfer_data}) - - if 'version' in instance.data: - version_data["version"] = instance.data["version"] - - # add to data of representation - version_data.update({ - "colorspace": self.rw_clip.sourceMediaColourTransform(), - "families": instance.data["families"], - "subset": instance.data["subset"], - "fps": instance.data["fps"] - }) - instance.data["versionData"] = version_data diff --git a/pype/hosts/hiero/plugins/publish/extract_review_preparation.py b/pype/hosts/hiero/plugins/publish/extract_review_preparation.py deleted file mode 100644 index 3fdc946f3c..0000000000 --- a/pype/hosts/hiero/plugins/publish/extract_review_preparation.py +++ /dev/null @@ -1,334 +0,0 @@ -import os -import sys -import six -import errno -from pyblish import api -import pype -import clique -from avalon.vendor import filelink - - -class ExtractReviewPreparation(pype.api.Extractor): - """Cut up clips from long video file""" - - order = api.ExtractorOrder - label = "Extract Review Preparation" - hosts = ["hiero"] - families = ["review"] - - # presets - tags_addition = [] - - def process(self, instance): - inst_data = instance.data - asset = inst_data["asset"] - review_item_data = instance.data.get("reviewItemData") - - # get representation and loop them - representations = inst_data["representations"] - - # get resolution default - resolution_width = inst_data["resolutionWidth"] - resolution_height = inst_data["resolutionHeight"] - - # frame range data - media_duration = review_item_data["mediaDuration"] - - ffmpeg_path = pype.lib.get_ffmpeg_tool_path("ffmpeg") - ffprobe_path = pype.lib.get_ffmpeg_tool_path("ffprobe") - - # filter out mov and img sequences - representations_new = representations[:] - for repre in representations: - input_args = list() - output_args = list() - - tags = repre.get("tags", []) - - # check if supported tags are in representation for activation - filter_tag = False - for tag in ["_cut-bigger", "prep"]: - if tag in tags: - filter_tag = True - break - if not filter_tag: - continue - - self.log.debug("__ repre: {}".format(repre)) - - files = repre.get("files") - staging_dir = repre.get("stagingDir") - fps = repre.get("fps") - ext = repre.get("ext") - - # make paths - full_output_dir = os.path.join( - staging_dir, "cuts") - - if isinstance(files, list): - new_files = list() - - # frame range delivery included handles - frame_start = ( - inst_data["frameStart"] - inst_data["handleStart"]) - frame_end = ( - inst_data["frameEnd"] + inst_data["handleEnd"]) - self.log.debug("_ frame_start: {}".format(frame_start)) - self.log.debug("_ frame_end: {}".format(frame_end)) - - # make collection from input files list - collections, remainder = clique.assemble(files) - collection = collections.pop() - self.log.debug("_ collection: {}".format(collection)) - - # name components - head = collection.format("{head}") - padding = collection.format("{padding}") - tail = collection.format("{tail}") - self.log.debug("_ head: {}".format(head)) - self.log.debug("_ padding: {}".format(padding)) - self.log.debug("_ tail: {}".format(tail)) - - # make destination file with instance data - # frame start and end range - index = 0 - for image in collection: - dst_file_num = frame_start + index - dst_file_name = head + str(padding % dst_file_num) + tail - src = os.path.join(staging_dir, image) - dst = os.path.join(full_output_dir, dst_file_name) - self.log.info("Creating temp hardlinks: {}".format(dst)) - self.hardlink_file(src, dst) - new_files.append(dst_file_name) - index += 1 - - self.log.debug("_ new_files: {}".format(new_files)) - - else: - # ffmpeg when single file - new_files = "{}_{}".format(asset, files) - - # frame range - frame_start = repre.get("frameStart") - frame_end = repre.get("frameEnd") - - full_input_path = os.path.join( - staging_dir, files) - - os.path.isdir(full_output_dir) or os.makedirs(full_output_dir) - - full_output_path = os.path.join( - full_output_dir, new_files) - - self.log.debug( - "__ full_input_path: {}".format(full_input_path)) - self.log.debug( - "__ full_output_path: {}".format(full_output_path)) - - # check if audio stream is in input video file - ffprob_cmd = ( - "\"{ffprobe_path}\" -i \"{full_input_path}\" -show_streams" - " -select_streams a -loglevel error" - ).format(**locals()) - - self.log.debug("ffprob_cmd: {}".format(ffprob_cmd)) - audio_check_output = pype.api.run_subprocess(ffprob_cmd) - self.log.debug( - "audio_check_output: {}".format(audio_check_output)) - - # Fix one frame difference - """ TODO: this is just work-around for issue: - https://github.com/pypeclub/pype/issues/659 - """ - frame_duration_extend = 1 - if audio_check_output and ("audio" in inst_data["families"]): - frame_duration_extend = 0 - - # translate frame to sec - start_sec = float(frame_start) / fps - duration_sec = float( - (frame_end - frame_start) + frame_duration_extend) / fps - - empty_add = None - - # check if not missing frames at start - if (start_sec < 0) or (media_duration < frame_end): - # for later swithing off `-c:v copy` output arg - empty_add = True - - # init empty variables - video_empty_start = video_layer_start = "" - audio_empty_start = audio_layer_start = "" - video_empty_end = video_layer_end = "" - audio_empty_end = audio_layer_end = "" - audio_input = audio_output = "" - v_inp_idx = 0 - concat_n = 1 - - # try to get video native resolution data - try: - resolution_output = pype.api.run_subprocess(( - "\"{ffprobe_path}\" -i \"{full_input_path}\"" - " -v error " - "-select_streams v:0 -show_entries " - "stream=width,height -of csv=s=x:p=0" - ).format(**locals())) - - x, y = resolution_output.split("x") - resolution_width = int(x) - resolution_height = int(y) - except Exception as _ex: - self.log.warning( - "Video native resolution is untracable: {}".format( - _ex)) - - if audio_check_output: - # adding input for empty audio - input_args.append("-f lavfi -i anullsrc") - - # define audio empty concat variables - audio_input = "[1:a]" - audio_output = ":a=1" - v_inp_idx = 1 - - # adding input for video black frame - input_args.append(( - "-f lavfi -i \"color=c=black:" - "s={resolution_width}x{resolution_height}:r={fps}\"" - ).format(**locals())) - - if (start_sec < 0): - # recalculate input video timing - empty_start_dur = abs(start_sec) - start_sec = 0 - duration_sec = float(frame_end - ( - frame_start + (empty_start_dur * fps)) + 1) / fps - - # define starting empty video concat variables - video_empty_start = ( - "[{v_inp_idx}]trim=duration={empty_start_dur}[gv0];" # noqa - ).format(**locals()) - video_layer_start = "[gv0]" - - if audio_check_output: - # define starting empty audio concat variables - audio_empty_start = ( - "[0]atrim=duration={empty_start_dur}[ga0];" - ).format(**locals()) - audio_layer_start = "[ga0]" - - # alter concat number of clips - concat_n += 1 - - # check if not missing frames at the end - if (media_duration < frame_end): - # recalculate timing - empty_end_dur = float( - frame_end - media_duration + 1) / fps - duration_sec = float( - media_duration - frame_start) / fps - - # define ending empty video concat variables - video_empty_end = ( - "[{v_inp_idx}]trim=duration={empty_end_dur}[gv1];" - ).format(**locals()) - video_layer_end = "[gv1]" - - if audio_check_output: - # define ending empty audio concat variables - audio_empty_end = ( - "[0]atrim=duration={empty_end_dur}[ga1];" - ).format(**locals()) - audio_layer_end = "[ga0]" - - # alter concat number of clips - concat_n += 1 - - # concatting black frame togather - output_args.append(( - "-filter_complex \"" - "{audio_empty_start}" - "{video_empty_start}" - "{audio_empty_end}" - "{video_empty_end}" - "{video_layer_start}{audio_layer_start}[1:v]{audio_input}" # noqa - "{video_layer_end}{audio_layer_end}" - "concat=n={concat_n}:v=1{audio_output}\"" - ).format(**locals())) - - # append ffmpeg input video clip - input_args.append("-ss {}".format(start_sec)) - input_args.append("-t {}".format(duration_sec)) - input_args.append("-i \"{}\"".format(full_input_path)) - - # add copy audio video codec if only shortening clip - if ("_cut-bigger" in tags) and (not empty_add): - output_args.append("-c:v copy") - - # make sure it is having no frame to frame comprassion - output_args.append("-intra") - - # output filename - output_args.append("-y \"{}\"".format(full_output_path)) - - mov_args = [ - "\"{}\"".format(ffmpeg_path), - " ".join(input_args), - " ".join(output_args) - ] - subprcs_cmd = " ".join(mov_args) - - # run subprocess - self.log.debug("Executing: {}".format(subprcs_cmd)) - output = pype.api.run_subprocess(subprcs_cmd) - self.log.debug("Output: {}".format(output)) - - repre_new = { - "files": new_files, - "stagingDir": full_output_dir, - "frameStart": frame_start, - "frameEnd": frame_end, - "frameStartFtrack": frame_start, - "frameEndFtrack": frame_end, - "step": 1, - "fps": fps, - "name": "cut_up_preview", - "tags": [ - "review", "ftrackreview", "delete"] + self.tags_addition, - "ext": ext, - "anatomy_template": "publish" - } - - representations_new.append(repre_new) - - for repre in representations_new: - if ("delete" in repre.get("tags", [])) and ( - "cut_up_preview" not in repre["name"]): - representations_new.remove(repre) - - self.log.debug( - "Representations: {}".format(representations_new)) - instance.data["representations"] = representations_new - - def hardlink_file(self, src, dst): - dirname = os.path.dirname(dst) - - # make sure the destination folder exist - try: - os.makedirs(dirname) - except OSError as e: - if e.errno == errno.EEXIST: - pass - else: - self.log.critical("An unexpected error occurred.") - six.reraise(*sys.exc_info()) - - # create hardlined file - try: - filelink.create(src, dst, filelink.HARDLINK) - except OSError as e: - if e.errno == errno.EEXIST: - pass - else: - self.log.critical("An unexpected error occurred.") - six.reraise(*sys.exc_info()) diff --git a/pype/hosts/hiero/plugins/publish/precollect_instances.py b/pype/hosts/hiero/plugins/publish/precollect_instances.py deleted file mode 100644 index 51378b422e..0000000000 --- a/pype/hosts/hiero/plugins/publish/precollect_instances.py +++ /dev/null @@ -1,224 +0,0 @@ -from compiler.ast import flatten -from pyblish import api -from pype.hosts.hiero import api as phiero -import hiero -# from pype.hosts.hiero.api import lib -# reload(lib) -# reload(phiero) - - -class PreCollectInstances(api.ContextPlugin): - """Collect all Track items selection.""" - - order = api.CollectorOrder - 0.509 - label = "Pre-collect Instances" - hosts = ["hiero"] - - - def process(self, context): - track_items = phiero.get_track_items( - selected=True, check_tagged=True, check_enabled=True) - # only return enabled track items - if not track_items: - track_items = phiero.get_track_items( - check_enabled=True, check_tagged=True) - # get sequence and video tracks - sequence = context.data["activeSequence"] - tracks = sequence.videoTracks() - - # add collection to context - tracks_effect_items = self.collect_sub_track_items(tracks) - - context.data["tracksEffectItems"] = tracks_effect_items - - self.log.info( - "Processing enabled track items: {}".format(len(track_items))) - - for _ti in track_items: - data = {} - clip = _ti.source() - - # get clips subtracks and anotations - annotations = self.clip_annotations(clip) - subtracks = self.clip_subtrack(_ti) - self.log.debug("Annotations: {}".format(annotations)) - self.log.debug(">> Subtracks: {}".format(subtracks)) - - # get pype tag data - tag_parsed_data = phiero.get_track_item_pype_data(_ti) - # self.log.debug(pformat(tag_parsed_data)) - - if not tag_parsed_data: - continue - - if tag_parsed_data.get("id") != "pyblish.avalon.instance": - continue - # add tag data to instance data - data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) - - asset = tag_parsed_data["asset"] - subset = tag_parsed_data["subset"] - review_track = tag_parsed_data.get("reviewTrack") - hiero_track = tag_parsed_data.get("heroTrack") - audio = tag_parsed_data.get("audio") - - # remove audio attribute from data - data.pop("audio") - - # insert family into families - family = tag_parsed_data["family"] - families = [str(f) for f in tag_parsed_data["families"]] - families.insert(0, str(family)) - - track = _ti.parent() - media_source = _ti.source().mediaSource() - source_path = media_source.firstpath() - file_head = media_source.filenameHead() - file_info = media_source.fileinfos().pop() - source_first_frame = int(file_info.startFrame()) - - # apply only for review and master track instance - if review_track and hiero_track: - families += ["review", "ftrack"] - - data.update({ - "name": "{} {} {}".format(asset, subset, families), - "asset": asset, - "item": _ti, - "families": families, - - # tags - "tags": _ti.tags(), - - # track item attributes - "track": track.name(), - "trackItem": track, - "reviewTrack": review_track, - - # version data - "versionData": { - "colorspace": _ti.sourceMediaColourTransform() - }, - - # source attribute - "source": source_path, - "sourceMedia": media_source, - "sourcePath": source_path, - "sourceFileHead": file_head, - "sourceFirst": source_first_frame, - - # clip's effect - "clipEffectItems": subtracks - }) - - instance = context.create_instance(**data) - - self.log.info("Creating instance.data: {}".format(instance.data)) - - if audio: - a_data = dict() - - # add tag data to instance data - a_data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) - - # create main attributes - subset = "audioMain" - family = "audio" - families = ["clip", "ftrack"] - families.insert(0, str(family)) - - name = "{} {} {}".format(asset, subset, families) - - a_data.update({ - "name": name, - "subset": subset, - "asset": asset, - "family": family, - "families": families, - "item": _ti, - - # tags - "tags": _ti.tags(), - }) - - a_instance = context.create_instance(**a_data) - self.log.info("Creating audio instance: {}".format(a_instance)) - - @staticmethod - def clip_annotations(clip): - """ - Returns list of Clip's hiero.core.Annotation - """ - annotations = [] - subTrackItems = flatten(clip.subTrackItems()) - annotations += [item for item in subTrackItems if isinstance( - item, hiero.core.Annotation)] - return annotations - - @staticmethod - def clip_subtrack(clip): - """ - Returns list of Clip's hiero.core.SubTrackItem - """ - subtracks = [] - subTrackItems = flatten(clip.parent().subTrackItems()) - for item in subTrackItems: - # avoid all anotation - if isinstance(item, hiero.core.Annotation): - continue - # # avoid all not anaibled - if not item.isEnabled(): - continue - subtracks.append(item) - return subtracks - - @staticmethod - def collect_sub_track_items(tracks): - """ - Returns dictionary with track index as key and list of subtracks - """ - # collect all subtrack items - sub_track_items = dict() - for track in tracks: - items = track.items() - - # skip if no clips on track > need track with effect only - if items: - continue - - # skip all disabled tracks - if not track.isEnabled(): - continue - - track_index = track.trackIndex() - _sub_track_items = flatten(track.subTrackItems()) - - # continue only if any subtrack items are collected - if len(_sub_track_items) < 1: - continue - - enabled_sti = list() - # loop all found subtrack items and check if they are enabled - for _sti in _sub_track_items: - # checking if not enabled - if not _sti.isEnabled(): - continue - if isinstance(_sti, hiero.core.Annotation): - continue - # collect the subtrack item - enabled_sti.append(_sti) - - # continue only if any subtrack items are collected - if len(enabled_sti) < 1: - continue - - # add collection of subtrackitems to dict - sub_track_items[track_index] = enabled_sti - - return sub_track_items diff --git a/pype/modules/ftrack/python2_vendor/arrow b/pype/modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/pype/modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/pype/modules/ftrack/python2_vendor/ftrack-python-api b/pype/modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/pype/modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/pype/plugins/publish/extract_hierarchy_avalon.py b/pype/plugins/publish/extract_hierarchy_avalon.py deleted file mode 100644 index 5643f04a76..0000000000 --- a/pype/plugins/publish/extract_hierarchy_avalon.py +++ /dev/null @@ -1,202 +0,0 @@ -import pyblish.api -from avalon import io -from copy import deepcopy - -class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): - """Create entities in Avalon based on collected data.""" - - order = pyblish.api.ExtractorOrder - 0.01 - label = "Extract Hierarchy To Avalon" - families = ["clip", "shot"] - - def process(self, context): - # processing starts here - if "hierarchyContext" not in context.data: - self.log.info("skipping IntegrateHierarchyToAvalon") - return - hierarchy_context = deepcopy(context.data["hierarchyContext"]) - - if not io.Session: - io.install() - - active_assets = [] - # filter only the active publishing insatnces - for instance in context: - if instance.data.get("publish") is False: - continue - - if not instance.data.get("asset"): - continue - - active_assets.append(instance.data["asset"]) - - # remove duplicity in list - self.active_assets = list(set(active_assets)) - self.log.debug("__ self.active_assets: {}".format(self.active_assets)) - - hierarchy_context = self._get_assets(hierarchy_context) - - self.log.debug("__ hierarchy_context: {}".format(hierarchy_context)) - input_data = context.data["hierarchyContext"] = hierarchy_context - - self.project = None - self.import_to_avalon(input_data) - - def import_to_avalon(self, input_data, parent=None): - for name in input_data: - self.log.info("input_data[name]: {}".format(input_data[name])) - entity_data = input_data[name] - entity_type = entity_data["entity_type"] - - data = {} - data["entityType"] = entity_type - - # Custom attributes. - for k, val in entity_data.get("custom_attributes", {}).items(): - data[k] = val - - if entity_type.lower() != "project": - data["inputs"] = entity_data.get("inputs", []) - - # Tasks. - tasks = entity_data.get("tasks", {}) - if tasks is not None or len(tasks) > 0: - data["tasks"] = tasks - parents = [] - visualParent = None - # do not store project"s id as visualParent (silo asset) - if self.project is not None: - if self.project["_id"] != parent["_id"]: - visualParent = parent["_id"] - parents.extend( - parent.get("data", {}).get("parents", []) - ) - parents.append(parent["name"]) - data["visualParent"] = visualParent - data["parents"] = parents - - update_data = True - # Process project - if entity_type.lower() == "project": - entity = io.find_one({"type": "project"}) - # TODO: should be in validator? - assert (entity is not None), "Did not find project in DB" - - # get data from already existing project - cur_entity_data = entity.get("data") or {} - cur_entity_data.update(data) - data = cur_entity_data - - self.project = entity - # Raise error if project or parent are not set - elif self.project is None or parent is None: - raise AssertionError( - "Collected items are not in right order!" - ) - # Else process assset - else: - entity = io.find_one({"type": "asset", "name": name}) - if entity: - # Do not override data, only update - cur_entity_data = entity.get("data") or {} - entity_tasks = cur_entity_data["tasks"] or {} - - # create tasks as dict by default - if not entity_tasks: - cur_entity_data["tasks"] = entity_tasks - - new_tasks = data.pop("tasks", {}) - if "tasks" not in cur_entity_data and not new_tasks: - continue - for task_name in new_tasks: - if task_name in entity_tasks.keys(): - continue - cur_entity_data["tasks"][task_name] = new_tasks[ - task_name] - cur_entity_data.update(data) - data = cur_entity_data - else: - # Skip updating data - update_data = False - - archived_entities = io.find({ - "type": "archived_asset", - "name": name - }) - unarchive_entity = None - for archived_entity in archived_entities: - archived_parents = ( - archived_entity - .get("data", {}) - .get("parents") - ) - if data["parents"] == archived_parents: - unarchive_entity = archived_entity - break - - if unarchive_entity is None: - # Create entity if doesn"t exist - entity = self.create_avalon_asset(name, data) - else: - # Unarchive if entity was archived - entity = self.unarchive_entity(unarchive_entity, data) - - if update_data: - # Update entity data with input data - io.update_many( - {"_id": entity["_id"]}, - {"$set": {"data": data}} - ) - - if "childs" in entity_data: - self.import_to_avalon(entity_data["childs"], entity) - - def unarchive_entity(self, entity, data): - # Unarchived asset should not use same data - new_entity = { - "_id": entity["_id"], - "schema": "avalon-core:asset-3.0", - "name": entity["name"], - "parent": self.project["_id"], - "type": "asset", - "data": data - } - io.replace_one( - {"_id": entity["_id"]}, - new_entity - ) - return new_entity - - def create_avalon_asset(self, name, data): - item = { - "schema": "avalon-core:asset-3.0", - "name": name, - "parent": self.project["_id"], - "type": "asset", - "data": data - } - self.log.debug("Creating asset: {}".format(item)) - entity_id = io.insert_one(item).inserted_id - - return io.find_one({"_id": entity_id}) - - def _get_assets(self, input_dict): - """ Returns only asset dictionary. - Usually the last part of deep dictionary which - is not having any children - """ - input_dict_copy = deepcopy(input_dict) - - for key in input_dict.keys(): - self.log.debug("__ key: {}".format(key)) - # check if child key is available - if input_dict[key].get("childs"): - # loop deeper - input_dict_copy[key]["childs"] = self._get_assets( - input_dict[key]["childs"]) - else: - # filter out unwanted assets - if key not in self.active_assets: - input_dict_copy.pop(key, None) - - return input_dict_copy From 9228508dff48bc15a670f5b24a26302851132b92 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 6 Apr 2021 15:11:00 +0200 Subject: [PATCH 029/515] Resolve: merge openpype fixes --- openpype/hosts/resolve/api/menu.py | 52 ++++--- openpype/hosts/resolve/api/pipeline.py | 48 +++++++ openpype/hosts/resolve/api/plugin.py | 35 +++-- .../plugins/create/create_shot_clip.py | 2 +- .../plugins/publish/collect_instances.py | 129 ------------------ .../plugins/publish/collect_workfile.py | 54 -------- .../resolve/utility_scripts/OTIO_export.py | 3 +- 7 files changed, 100 insertions(+), 223 deletions(-) delete mode 100644 openpype/hosts/resolve/plugins/publish/collect_instances.py delete mode 100644 openpype/hosts/resolve/plugins/publish/collect_workfile.py diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index 5ed7aeab34..ecd2708440 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -12,7 +12,8 @@ from avalon.tools import ( creator, loader, sceneinventory, - libraryloader + libraryloader, + subsetmanager ) @@ -59,19 +60,20 @@ class OpenPypeMenu(QtWidgets.QWidget): ) self.setWindowTitle("OpenPype") - workfiles_btn = QtWidgets.QPushButton("Workfiles ...", self) - create_btn = QtWidgets.QPushButton("Create ...", self) - publish_btn = QtWidgets.QPushButton("Publish ...", self) - load_btn = QtWidgets.QPushButton("Load ...", self) - inventory_btn = QtWidgets.QPushButton("Inventory ...", self) - libload_btn = QtWidgets.QPushButton("Library ...", self) - # rename_btn = QtWidgets.QPushButton("Rename ...", self) - # set_colorspace_btn = QtWidgets.QPushButton( - # "Set colorspace from presets", self - # ) - # reset_resolution_btn = QtWidgets.QPushButton( - # "Reset Resolution from peresets", self - # ) + workfiles_btn = QtWidgets.QPushButton("Workfiles", self) + create_btn = QtWidgets.QPushButton("Create", self) + publish_btn = QtWidgets.QPushButton("Publish", self) + load_btn = QtWidgets.QPushButton("Load", self) + inventory_btn = QtWidgets.QPushButton("Inventory", self) + subsetm_btn = QtWidgets.QPushButton("Subset Manager", self) + libload_btn = QtWidgets.QPushButton("Library", self) + rename_btn = QtWidgets.QPushButton("Rename", self) + set_colorspace_btn = QtWidgets.QPushButton( + "Set colorspace from presets", self + ) + reset_resolution_btn = QtWidgets.QPushButton( + "Reset Resolution from peresets", self + ) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) @@ -81,19 +83,20 @@ class OpenPypeMenu(QtWidgets.QWidget): layout.addWidget(publish_btn) layout.addWidget(load_btn) layout.addWidget(inventory_btn) + layout.addWidget(subsetm_btn) layout.addWidget(Spacer(15, self)) layout.addWidget(libload_btn) - # layout.addWidget(Spacer(15, self)) + layout.addWidget(Spacer(15, self)) - # layout.addWidget(rename_btn) + layout.addWidget(rename_btn) - # layout.addWidget(Spacer(15, self)) + layout.addWidget(Spacer(15, self)) - # layout.addWidget(set_colorspace_btn) - # layout.addWidget(reset_resolution_btn) + layout.addWidget(set_colorspace_btn) + layout.addWidget(reset_resolution_btn) self.setLayout(layout) @@ -102,10 +105,11 @@ class OpenPypeMenu(QtWidgets.QWidget): publish_btn.clicked.connect(self.on_publish_clicked) load_btn.clicked.connect(self.on_load_clicked) inventory_btn.clicked.connect(self.on_inventory_clicked) + subsetm_btn.clicked.connect(self.on_subsetm_clicked) libload_btn.clicked.connect(self.on_libload_clicked) - # rename_btn.clicked.connect(self.on_rename_clicked) - # set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) - # reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked) + rename_btn.clicked.connect(self.on_rename_clicked) + set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) + reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked) def on_workfile_clicked(self): print("Clicked Workfile") @@ -127,6 +131,10 @@ class OpenPypeMenu(QtWidgets.QWidget): print("Clicked Inventory") sceneinventory.show() + def on_subsetm_clicked(self): + print("Clicked Subset Manager") + subsetmanager.show() + def on_libload_clicked(self): print("Clicked Library") libraryloader.show() diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index d4d928a7d9..0e6d5aff4d 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -258,3 +258,51 @@ def on_pyblish_instance_toggled(instance, old_value, new_value): # Whether instances should be passthrough based on new value timeline_item = instance.data["item"] set_publish_attribute(timeline_item, new_value) + + +def remove_instance(instance): + """Remove instance marker from track item.""" + instance_id = instance.get("uuid") + + selected_timeline_items = lib.get_current_timeline_items( + filter=True, selecting_color=lib.publish_clip_color) + + found_ti = None + for timeline_item_data in selected_timeline_items: + timeline_item = timeline_item_data["clip"]["item"] + + # get openpype tag data + tag_data = lib.get_timeline_item_pype_tag(timeline_item) + _ti_id = tag_data.get("uuid") + if _ti_id == instance_id: + found_ti = timeline_item + break + + if found_ti is None: + return + + # removing instance by marker color + print(f"Removing instance: {found_ti.GetName()}") + found_ti.DeleteMarkersByColor(lib.pype_marker_color) + + +def list_instances(): + """List all created instances from current workfile.""" + listed_instances = [] + selected_timeline_items = lib.get_current_timeline_items( + filter=True, selecting_color=lib.publish_clip_color) + + for timeline_item_data in selected_timeline_items: + timeline_item = timeline_item_data["clip"]["item"] + ti_name = timeline_item.GetName().split(".")[0] + + # get openpype tag data + tag_data = lib.get_timeline_item_pype_tag(timeline_item) + + if tag_data: + asset = tag_data.get("asset") + subset = tag_data.get("subset") + tag_data["label"] = f"{ti_name} [{asset}-{subset}]" + listed_instances.append(tag_data) + + return listed_instances diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 3833795b96..4712d0a8b9 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -1,4 +1,5 @@ import re +import uuid from avalon import api import openpype.api as pype from openpype.hosts import resolve @@ -697,13 +698,13 @@ class PublishClip: Populating the tag data into internal variable self.tag_data """ # define vertical sync attributes - master_layer = True + hero_track = True self.review_layer = "" if self.vertical_sync: # check if track name is not in driving layer if self.track_name not in self.driving_layer: # if it is not then define vertical sync as None - master_layer = False + hero_track = False # increasing steps by index of rename iteration self.count_steps *= self.rename_index @@ -717,7 +718,7 @@ class PublishClip: self.tag_data[_k] = _v["value"] # driving layer is set as positive match - if master_layer or self.vertical_sync: + if hero_track or self.vertical_sync: # mark review layer if self.review_track and ( self.review_track not in self.review_track_default): @@ -751,35 +752,39 @@ class PublishClip: hierarchy_formating_data ) - tag_hierarchy_data.update({"masterLayer": True}) - if master_layer and self.vertical_sync: - # tag_hierarchy_data.update({"masterLayer": True}) + tag_hierarchy_data.update({"heroTrack": True}) + if hero_track and self.vertical_sync: self.vertical_clip_match.update({ (self.clip_in, self.clip_out): tag_hierarchy_data }) - if not master_layer and self.vertical_sync: + if not hero_track and self.vertical_sync: # driving layer is set as negative match - for (_in, _out), master_data in self.vertical_clip_match.items(): - master_data.update({"masterLayer": False}) + for (_in, _out), hero_data in self.vertical_clip_match.items(): + hero_data.update({"heroTrack": False}) if _in == self.clip_in and _out == self.clip_out: - data_subset = master_data["subset"] - # add track index in case duplicity of names in master data + data_subset = hero_data["subset"] + # add track index in case duplicity of names in hero data if self.subset in data_subset: - master_data["subset"] = self.subset + str( + hero_data["subset"] = self.subset + str( self.track_index) # in case track name and subset name is the same then add if self.subset_name == self.track_name: - master_data["subset"] = self.subset + hero_data["subset"] = self.subset # assing data to return hierarchy data to tag - tag_hierarchy_data = master_data + tag_hierarchy_data = hero_data # add data to return data dict self.tag_data.update(tag_hierarchy_data) - if master_layer and self.review_layer: + # add uuid to tag data + self.tag_data["uuid"] = str(uuid.uuid4()) + + # add review track only to hero track + if hero_track and self.review_layer: self.tag_data.update({"reviewTrack": self.review_layer}) + def _solve_tag_hierarchy_data(self, hierarchy_formating_data): """ Solve tag data from hierarchy data and templates. """ # fill up clip name and hierarchy keys diff --git a/openpype/hosts/resolve/plugins/create/create_shot_clip.py b/openpype/hosts/resolve/plugins/create/create_shot_clip.py index 2916a52298..41fdbf5c61 100644 --- a/openpype/hosts/resolve/plugins/create/create_shot_clip.py +++ b/openpype/hosts/resolve/plugins/create/create_shot_clip.py @@ -117,7 +117,7 @@ class CreateShotClip(resolve.Creator): "vSyncTrack": { "value": gui_tracks, # noqa "type": "QComboBox", - "label": "Master track", + "label": "Hero track", "target": "ui", "toolTip": "Select driving track name which should be mastering all others", # noqa "order": 1} diff --git a/openpype/hosts/resolve/plugins/publish/collect_instances.py b/openpype/hosts/resolve/plugins/publish/collect_instances.py deleted file mode 100644 index f4eeb39754..0000000000 --- a/openpype/hosts/resolve/plugins/publish/collect_instances.py +++ /dev/null @@ -1,129 +0,0 @@ -import pyblish -from openpype.hosts import resolve - -# # developer reload modules -from pprint import pformat - - -class CollectInstances(pyblish.api.ContextPlugin): - """Collect all Track items selection.""" - - order = pyblish.api.CollectorOrder - 0.59 - label = "Collect Instances" - hosts = ["resolve"] - - def process(self, context): - otio_timeline = context.data["otioTimeline"] - selected_timeline_items = resolve.get_current_timeline_items( - filter=True, selecting_color=resolve.publish_clip_color) - - self.log.info( - "Processing enabled track items: {}".format( - len(selected_timeline_items))) - - for timeline_item_data in selected_timeline_items: - - data = dict() - timeline_item = timeline_item_data["clip"]["item"] - - # get openpype tag data - tag_data = resolve.get_timeline_item_pype_tag(timeline_item) - self.log.debug(f"__ tag_data: {pformat(tag_data)}") - - if not tag_data: - continue - - if tag_data.get("id") != "pyblish.avalon.instance": - continue - - media_pool_item = timeline_item.GetMediaPoolItem() - clip_property = media_pool_item.GetClipProperty() - self.log.debug(f"clip_property: {clip_property}") - - # add tag data to instance data - data.update({ - k: v for k, v in tag_data.items() - if k not in ("id", "applieswhole", "label") - }) - - asset = tag_data["asset"] - subset = tag_data["subset"] - - # insert family into families - family = tag_data["family"] - families = [str(f) for f in tag_data["families"]] - families.insert(0, str(family)) - - data.update({ - "name": "{} {} {}".format(asset, subset, families), - "asset": asset, - "item": timeline_item, - "families": families, - "publish": resolve.get_publish_attribute(timeline_item), - "fps": context.data["fps"] - }) - - # otio clip data - otio_data = resolve.get_otio_clip_instance_data( - otio_timeline, timeline_item_data) or {} - data.update(otio_data) - - # add resolution - self.get_resolution_to_data(data, context) - - # create instance - instance = context.create_instance(**data) - - # create shot instance for shot attributes create/update - self.create_shot_instance(context, timeline_item, **data) - - self.log.info("Creating instance: {}".format(instance)) - self.log.debug( - "_ instance.data: {}".format(pformat(instance.data))) - - def get_resolution_to_data(self, data, context): - assert data.get("otioClip"), "Missing `otioClip` data" - - # solve source resolution option - if data.get("sourceResolution", None): - otio_clip_metadata = data[ - "otioClip"].media_reference.metadata - data.update({ - "resolutionWidth": otio_clip_metadata["width"], - "resolutionHeight": otio_clip_metadata["height"], - "pixelAspect": otio_clip_metadata["pixelAspect"] - }) - else: - otio_tl_metadata = context.data["otioTimeline"].metadata - data.update({ - "resolutionWidth": otio_tl_metadata["width"], - "resolutionHeight": otio_tl_metadata["height"], - "pixelAspect": otio_tl_metadata["pixelAspect"] - }) - - def create_shot_instance(self, context, timeline_item, **data): - master_layer = data.get("masterLayer") - hierarchy_data = data.get("hierarchyData") - - if not master_layer: - return - - if not hierarchy_data: - return - - asset = data["asset"] - subset = "shotMain" - - # insert family into families - family = "shot" - - data.update({ - "name": "{} {} {}".format(asset, subset, family), - "subset": subset, - "asset": asset, - "family": family, - "families": [], - "publish": resolve.get_publish_attribute(timeline_item) - }) - - context.create_instance(**data) diff --git a/openpype/hosts/resolve/plugins/publish/collect_workfile.py b/openpype/hosts/resolve/plugins/publish/collect_workfile.py deleted file mode 100644 index a66284ed02..0000000000 --- a/openpype/hosts/resolve/plugins/publish/collect_workfile.py +++ /dev/null @@ -1,54 +0,0 @@ -import pyblish.api -from openpype.hosts import resolve -from avalon import api as avalon -from pprint import pformat - -# dev -from importlib import reload -from openpype.hosts.resolve.otio import davinci_export -reload(davinci_export) - - -class CollectWorkfile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" - - label = "Collect Workfile" - order = pyblish.api.CollectorOrder - 0.6 - - def process(self, context): - - asset = avalon.Session["AVALON_ASSET"] - subset = "workfile" - project = resolve.get_current_project() - fps = project.GetSetting("timelineFrameRate") - - active_timeline = resolve.get_current_timeline() - video_tracks = resolve.get_video_track_names() - - # adding otio timeline to context - otio_timeline = davinci_export.create_otio_timeline(project) - - instance_data = { - "name": "{}_{}".format(asset, subset), - "asset": asset, - "subset": "{}{}".format(asset, subset.capitalize()), - "item": project, - "family": "workfile" - } - - # create instance with workfile - instance = context.create_instance(**instance_data) - - # update context with main project attributes - context_data = { - "activeProject": project, - "otioTimeline": otio_timeline, - "videoTracks": video_tracks, - "currentFile": project.GetName(), - "fps": fps, - } - context.data.update(context_data) - - self.log.info("Creating instance: {}".format(instance)) - self.log.debug("__ instance.data: {}".format(pformat(instance.data))) - self.log.debug("__ context_data: {}".format(pformat(context_data))) diff --git a/openpype/hosts/resolve/utility_scripts/OTIO_export.py b/openpype/hosts/resolve/utility_scripts/OTIO_export.py index 91bc2c5700..0431eb7daa 100644 --- a/openpype/hosts/resolve/utility_scripts/OTIO_export.py +++ b/openpype/hosts/resolve/utility_scripts/OTIO_export.py @@ -58,9 +58,8 @@ def _close_window(event): def _export_button(event): pm = resolve.GetProjectManager() project = pm.GetCurrentProject() - fps = project.GetSetting("timelineFrameRate") timeline = project.GetCurrentTimeline() - otio_timeline = otio_export.create_otio_timeline(timeline, fps) + otio_timeline = otio_export.create_otio_timeline(project) otio_path = os.path.join( itm["exportfilebttn"].Text, timeline.GetName() + ".otio") From e46c3a9fa7730e71e679138e4e7861ae3ec14afa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 6 Apr 2021 15:13:03 +0200 Subject: [PATCH 030/515] Resolve: updating menu to disable unwritten features --- openpype/hosts/resolve/api/menu.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index ecd2708440..c0471ebfbe 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -67,13 +67,13 @@ class OpenPypeMenu(QtWidgets.QWidget): inventory_btn = QtWidgets.QPushButton("Inventory", self) subsetm_btn = QtWidgets.QPushButton("Subset Manager", self) libload_btn = QtWidgets.QPushButton("Library", self) - rename_btn = QtWidgets.QPushButton("Rename", self) - set_colorspace_btn = QtWidgets.QPushButton( - "Set colorspace from presets", self - ) - reset_resolution_btn = QtWidgets.QPushButton( - "Reset Resolution from peresets", self - ) + # rename_btn = QtWidgets.QPushButton("Rename", self) + # set_colorspace_btn = QtWidgets.QPushButton( + # "Set colorspace from presets", self + # ) + # reset_resolution_btn = QtWidgets.QPushButton( + # "Reset Resolution from peresets", self + # ) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(10, 20, 10, 20) @@ -91,12 +91,12 @@ class OpenPypeMenu(QtWidgets.QWidget): layout.addWidget(Spacer(15, self)) - layout.addWidget(rename_btn) + # layout.addWidget(rename_btn) - layout.addWidget(Spacer(15, self)) + # layout.addWidget(Spacer(15, self)) - layout.addWidget(set_colorspace_btn) - layout.addWidget(reset_resolution_btn) + # layout.addWidget(set_colorspace_btn) + # layout.addWidget(reset_resolution_btn) self.setLayout(layout) @@ -107,9 +107,9 @@ class OpenPypeMenu(QtWidgets.QWidget): inventory_btn.clicked.connect(self.on_inventory_clicked) subsetm_btn.clicked.connect(self.on_subsetm_clicked) libload_btn.clicked.connect(self.on_libload_clicked) - rename_btn.clicked.connect(self.on_rename_clicked) - set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) - reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked) + # rename_btn.clicked.connect(self.on_rename_clicked) + # set_colorspace_btn.clicked.connect(self.on_set_colorspace_clicked) + # reset_resolution_btn.clicked.connect(self.on_reset_resolution_clicked) def on_workfile_clicked(self): print("Clicked Workfile") From 9a0f20ef95e18767699a624a575c84e834fd1fa1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 6 Apr 2021 15:15:02 +0200 Subject: [PATCH 031/515] Resolve: adding correct ux convention to menu --- openpype/hosts/resolve/api/menu.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/resolve/api/menu.py b/openpype/hosts/resolve/api/menu.py index c0471ebfbe..e7be3fc963 100644 --- a/openpype/hosts/resolve/api/menu.py +++ b/openpype/hosts/resolve/api/menu.py @@ -60,13 +60,13 @@ class OpenPypeMenu(QtWidgets.QWidget): ) self.setWindowTitle("OpenPype") - workfiles_btn = QtWidgets.QPushButton("Workfiles", self) - create_btn = QtWidgets.QPushButton("Create", self) - publish_btn = QtWidgets.QPushButton("Publish", self) - load_btn = QtWidgets.QPushButton("Load", self) - inventory_btn = QtWidgets.QPushButton("Inventory", self) - subsetm_btn = QtWidgets.QPushButton("Subset Manager", self) - libload_btn = QtWidgets.QPushButton("Library", self) + workfiles_btn = QtWidgets.QPushButton("Workfiles ...", self) + create_btn = QtWidgets.QPushButton("Create ...", self) + publish_btn = QtWidgets.QPushButton("Publish ...", self) + load_btn = QtWidgets.QPushButton("Load ...", self) + inventory_btn = QtWidgets.QPushButton("Inventory ...", self) + subsetm_btn = QtWidgets.QPushButton("Subset Manager ...", self) + libload_btn = QtWidgets.QPushButton("Library ...", self) # rename_btn = QtWidgets.QPushButton("Rename", self) # set_colorspace_btn = QtWidgets.QPushButton( # "Set colorspace from presets", self @@ -89,7 +89,7 @@ class OpenPypeMenu(QtWidgets.QWidget): layout.addWidget(libload_btn) - layout.addWidget(Spacer(15, self)) + # layout.addWidget(Spacer(15, self)) # layout.addWidget(rename_btn) From e3ef24098522b2c56953e558ea85b4c41df7d834 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Apr 2021 18:59:06 +0200 Subject: [PATCH 032/515] rebrand to OpenPype --- .../maya/plugins/publish/collect_look.py | 108 ++++++++++---- .../maya/plugins/publish/extract_look.py | 140 +++++++++++++----- 2 files changed, 185 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index acc6d8f128..c51b00c523 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Maya look collector.""" import re import os import glob @@ -16,6 +18,11 @@ SHAPE_ATTRS = ["castsShadows", "doubleSided", "opposite"] +RENDERER_NODE_TYPES = [ + # redshift + "RedshiftMeshParameters" +] + SHAPE_ATTRS = set(SHAPE_ATTRS) @@ -219,7 +226,6 @@ class CollectLook(pyblish.api.InstancePlugin): with lib.renderlayer(instance.data["renderlayer"]): self.collect(instance) - def collect(self, instance): self.log.info("Looking for look associations " @@ -228,6 +234,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Discover related object sets self.log.info("Gathering sets..") sets = self.collect_sets(instance) + render_nodes = [] # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) @@ -235,48 +242,91 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Gathering set relations..") # Ensure iteration happen in a list so we can remove keys from the # dict within the loop - for objset in list(sets): - self.log.debug("From %s.." % objset) + + # skipped types of attribute on render specific nodes + disabled_types = ["message", "TdataCompound"] + + for obj_set in list(sets): + self.log.debug("From {}".format(obj_set)) + + # if node is specified as renderer node type, it will be + # serialized with its attributes. + if cmds.nodeType(obj_set) in RENDERER_NODE_TYPES: + self.log.info("- {} is {}".format( + obj_set, cmds.nodeType(obj_set))) + + node_attrs = [] + + # serialize its attributes so they can be recreated on look + # load. + for attr in cmds.listAttr(obj_set): + # skip publishedNodeInfo attributes as they break + # getAttr() and we don't need them anyway + if attr.startswith("publishedNodeInfo"): + continue + + # skip attributes types defined in 'disabled_type' list + if cmds.getAttr("{}.{}".format(obj_set, attr), type=True) in disabled_types: # noqa + continue + + # self.log.debug("{}: {}".format(attr, cmds.getAttr("{}.{}".format(obj_set, attr), type=True))) # noqa + node_attrs.append(( + attr, + cmds.getAttr("{}.{}".format(obj_set, attr)) + )) + + render_nodes.append( + { + "name": obj_set, + "type": cmds.nodeType(obj_set), + "members": cmds.ls(cmds.sets( + obj_set, query=True), long=True), + "attributes": node_attrs + } + ) # Get all nodes of the current objectSet (shadingEngine) - for member in cmds.ls(cmds.sets(objset, query=True), long=True): + for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) if not member_data: continue # Add information of the node to the members list - sets[objset]["members"].append(member_data) + sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. - self.log.info("objset {}".format(sets[objset])) - if not sets[objset]["members"] or (not objset.endswith("SG")): - self.log.info("Removing redundant set information: " - "%s" % objset) - sets.pop(objset, None) + self.log.info("obj_set {}".format(sets[obj_set])) + if not sets[obj_set]["members"] or (not obj_set.endswith("SG")): + self.log.info( + "Removing redundant set information: {}".format(obj_set)) + sets.pop(obj_set, None) self.log.info("Gathering attribute changes to instance members..") attributes = self.collect_attributes_changed(instance) # Store data on the instance - instance.data["lookData"] = {"attributes": attributes, - "relationships": sets} + instance.data["lookData"] = { + "attributes": attributes, + "relationships": sets, + "render_nodes": render_nodes + } # Collect file nodes used by shading engines (if we have any) - files = list() - looksets = sets.keys() - shaderAttrs = [ - "surfaceShader", - "volumeShader", - "displacementShader", - "aiSurfaceShader", - "aiVolumeShader"] - materials = list() + files = [] + look_sets = sets.keys() + shader_attrs = [ + "surfaceShader", + "volumeShader", + "displacementShader", + "aiSurfaceShader", + "aiVolumeShader"] + if look_sets: + materials = [] - if looksets: - for look in looksets: - for at in shaderAttrs: + for look in look_sets: + for at in shader_attrs: try: con = cmds.listConnections("{}.{}".format(look, at)) except ValueError: @@ -289,10 +339,10 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Found materials:\n{}".format(materials)) - self.log.info("Found the following sets:\n{}".format(looksets)) + self.log.info("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets - # history = cmds.listHistory(looksets) - history = list() + # history = cmds.listHistory(look_sets) + history = [] for material in materials: history.extend(cmds.listHistory(material)) files = cmds.ls(history, type="file", long=True) @@ -313,7 +363,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Ensure unique shader sets # Add shader sets to the instance for unify ID validation - instance.extend(shader for shader in looksets if shader + instance.extend(shader for shader in look_sets if shader not in instance_lookup) self.log.info("Collected look for %s" % instance) @@ -331,7 +381,7 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ - sets = dict() + sets = {} for node in instance: related_sets = lib.get_related_sets(node) if not related_sets: diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 79488a372c..bdd061578e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -1,13 +1,14 @@ +# -*- coding: utf-8 -*- +"""Maya look extractor.""" import os import sys import json -import copy import tempfile import contextlib import subprocess from collections import OrderedDict -from maya import cmds +from maya import cmds # noqa import pyblish.api import avalon.maya @@ -22,23 +23,38 @@ HARDLINK = 2 def find_paths_by_hash(texture_hash): - # Find the texture hash key in the dictionary and all paths that - # originate from it. + """Find the texture hash key in the dictionary. + + All paths that originate from it. + + Args: + texture_hash (str): Hash of the texture. + + Return: + str: path to texture if found. + + """ key = "data.sourceHashes.{0}".format(texture_hash) return io.distinct(key, {"type": "version"}) def maketx(source, destination, *args): - """Make .tx using maketx with some default settings. + """Make `.tx` using `maketx` with some default settings. + The settings are based on default as used in Arnold's txManager in the scene. This function requires the `maketx` executable to be on the `PATH`. + Args: source (str): Path to source file. destination (str): Writing destination path. - """ + *args: Additional arguments for `maketx`. + Returns: + str: Output of `maketx` command. + + """ cmd = [ "maketx", "-v", # verbose @@ -56,7 +72,7 @@ def maketx(source, destination, *args): cmd = " ".join(cmd) - CREATE_NO_WINDOW = 0x08000000 + CREATE_NO_WINDOW = 0x08000000 # noqa kwargs = dict(args=cmd, stderr=subprocess.STDOUT) if sys.platform == "win32": @@ -118,12 +134,58 @@ class ExtractLook(openpype.api.Extractor): hosts = ["maya"] families = ["look"] order = pyblish.api.ExtractorOrder + 0.2 + scene_type = "ma" + + @staticmethod + def get_renderer_name(): + """Get renderer name from Maya. + + Returns: + str: Renderer name. + + """ + renderer = cmds.getAttr( + "defaultRenderGlobals.currentRenderer" + ).lower() + # handle various renderman names + if renderer.startswith("renderman"): + renderer = "renderman" + return renderer + + def get_maya_scene_type(self, instance): + """Get Maya scene type from settings. + + Args: + instance (pyblish.api.Instance): Instance with collected + project settings. + + """ + ext_mapping = ( + instance.context.data["project_settings"]["maya"]["ext_mapping"] + ) + if ext_mapping: + self.log.info("Looking in settings 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 KeyError: + # no preset found + pass def process(self, instance): + """Plugin entry point. + Args: + instance: Instance to process. + + """ # Define extract output file path dir_path = self.staging_dir(instance) - maya_fname = "{0}.ma".format(instance.name) + maya_fname = "{0}.{1}".format(instance.name, self.scene_type) json_fname = "{0}.json".format(instance.name) # Make texture dump folder @@ -148,7 +210,7 @@ class ExtractLook(openpype.api.Extractor): # Collect all unique files used in the resources files = set() - files_metadata = dict() + files_metadata = {} for resource in resources: # Preserve color space values (force value after filepath change) # This will also trigger in the same order at end of context to @@ -162,35 +224,33 @@ class ExtractLook(openpype.api.Extractor): # files.update(os.path.normpath(f)) # Process the resource files - transfers = list() - hardlinks = list() - hashes = dict() - forceCopy = instance.data.get("forceCopy", False) + transfers = [] + hardlinks = [] + hashes = {} + force_copy = instance.data.get("forceCopy", False) self.log.info(files) for filepath in files_metadata: - cspace = files_metadata[filepath]["color_space"] - linearise = False - if do_maketx: - if cspace == "sRGB": - linearise = True - # set its file node to 'raw' as tx will be linearized - files_metadata[filepath]["color_space"] = "raw" + linearize = False + if do_maketx and files_metadata[filepath]["color_space"] == "sRGB": # noqa: E501 + linearize = True + # set its file node to 'raw' as tx will be linearized + files_metadata[filepath]["color_space"] = "raw" - source, mode, hash = self._process_texture( + source, mode, texture_hash = self._process_texture( filepath, do_maketx, staging=dir_path, - linearise=linearise, - force=forceCopy + linearize=linearize, + force=force_copy ) destination = self.resource_destination(instance, source, do_maketx) # Force copy is specified. - if forceCopy: + if force_copy: mode = COPY if mode == COPY: @@ -202,10 +262,10 @@ class ExtractLook(openpype.api.Extractor): # Store the hashes from hash to destination to include in the # database - hashes[hash] = destination + hashes[texture_hash] = destination # Remap the resources to the destination path (change node attributes) - destinations = dict() + destinations = {} remap = OrderedDict() # needs to be ordered, see color space values for resource in resources: source = os.path.normpath(resource["source"]) @@ -222,7 +282,7 @@ class ExtractLook(openpype.api.Extractor): color_space_attr = resource["node"] + ".colorSpace" color_space = cmds.getAttr(color_space_attr) if files_metadata[source]["color_space"] == "raw": - # set colorpsace to raw if we linearized it + # set color space to raw if we linearized it color_space = "Raw" # Remap file node filename to destination attr = resource["attribute"] @@ -267,11 +327,11 @@ class ExtractLook(openpype.api.Extractor): json.dump(data, f) if "files" not in instance.data: - instance.data["files"] = list() + instance.data["files"] = [] if "hardlinks" not in instance.data: - instance.data["hardlinks"] = list() + instance.data["hardlinks"] = [] if "transfers" not in instance.data: - instance.data["transfers"] = list() + instance.data["transfers"] = [] instance.data["files"].append(maya_fname) instance.data["files"].append(json_fname) @@ -311,14 +371,26 @@ class ExtractLook(openpype.api.Extractor): maya_path)) def resource_destination(self, instance, filepath, do_maketx): - anatomy = instance.context.data["anatomy"] + """Get resource destination path. + This is utility function to change path if resource file name is + changed by some external tool like `maketx`. + + Args: + instance: Current Instance. + filepath (str): Resource path + do_maketx (bool): Flag if resource is processed by `maketx`. + + Returns: + str: Path to resource file + + """ resources_dir = instance.data["resourcesDir"] # Compute destination location basename, ext = os.path.splitext(os.path.basename(filepath)) - # If maketx then the texture will always end with .tx + # If `maketx` then the texture will always end with .tx if do_maketx: ext = ".tx" @@ -326,7 +398,7 @@ class ExtractLook(openpype.api.Extractor): resources_dir, basename + ext ) - def _process_texture(self, filepath, do_maketx, staging, linearise, force): + def _process_texture(self, filepath, do_maketx, staging, linearize, force): """Process a single texture file on disk for publishing. This will: 1. Check whether it's already published, if so it will do hardlink @@ -363,7 +435,7 @@ class ExtractLook(openpype.api.Extractor): # Produce .tx file in staging if source file is not .tx converted = os.path.join(staging, "resources", fname + ".tx") - if linearise: + if linearize: self.log.info("tx: converting sRGB -> linear") colorconvert = "--colorconvert sRGB linear" else: From d503395818ec8113bb0c402535e1c44aac13d61a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Apr 2021 10:43:36 +0200 Subject: [PATCH 033/515] resolve: fix transition merge --- openpype/hosts/resolve/hooks/pre_resolve_setup.py | 2 +- .../hosts/resolve/plugins/publish/precollect_instances.py | 2 +- openpype/hosts/resolve/plugins/publish/precollect_workfile.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/resolve/hooks/pre_resolve_setup.py b/openpype/hosts/resolve/hooks/pre_resolve_setup.py index 0ee55d3790..bcb27e24fc 100644 --- a/openpype/hosts/resolve/hooks/pre_resolve_setup.py +++ b/openpype/hosts/resolve/hooks/pre_resolve_setup.py @@ -44,7 +44,7 @@ class ResolvePrelaunch(PreLaunchHook): self.launch_context.env["PRE_PYTHON_SCRIPT"] = pre_py_sc self.log.debug(f"-- pre_py_sc: `{pre_py_sc}`...") try: - __import__("pype.hosts.resolve") + __import__("openpype.hosts.resolve") __import__("pyblish") except ImportError: diff --git a/openpype/hosts/resolve/plugins/publish/precollect_instances.py b/openpype/hosts/resolve/plugins/publish/precollect_instances.py index 4fabe2b8a0..c38cbc4f73 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_instances.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_instances.py @@ -1,5 +1,5 @@ import pyblish -from pype.hosts import resolve +from openpype.hosts import resolve # # developer reload modules from pprint import pformat diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py index 3e9a7f26b9..ee05fb6f13 100644 --- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py @@ -1,11 +1,11 @@ import pyblish.api -from pype.hosts import resolve +from openpype.hosts import resolve from avalon import api as avalon from pprint import pformat # dev from importlib import reload -from pype.hosts.resolve.otio import davinci_export +from openpype.hosts.resolve.otio import davinci_export reload(davinci_export) From 8dbc9d1e5732f6dc1a249ec52d9e575ff61388f4 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Wed, 7 Apr 2021 10:46:11 +0200 Subject: [PATCH 034/515] add ftrack documentation --- website/docs/artist_ftrack.md | 79 +++++++--- website/docs/assets/ftrack/ftrack-api.gif | Bin 0 -> 8153 bytes website/docs/assets/ftrack/ftrack-api.png | Bin 0 -> 9410 bytes website/docs/assets/ftrack/ftrack-api2.png | Bin 0 -> 18743 bytes website/docs/assets/ftrack/ftrack-api3.png | Bin 0 -> 9694 bytes website/docs/assets/ftrack/ftrack-api4.png | Bin 0 -> 13927 bytes .../assets/ftrack/ftrack-delivery-icon.png | Bin 0 -> 3772 bytes .../docs/assets/ftrack/ftrack-login-api.png | Bin 0 -> 5389 bytes website/docs/assets/ftrack/ftrack-login_1.png | Bin 6706 -> 5942 bytes .../docs/assets/ftrack/ftrack-login_50.png | Bin 7943 -> 9002 bytes .../docs/assets/ftrack/ftrack-login_81.png | Bin 61877 -> 5222 bytes website/docs/manager_ftrack.md | 2 +- website/docs/manager_ftrack_actions.md | 136 +++++++++-------- website/docs/module_ftrack.md | 143 ++++++++++-------- 14 files changed, 218 insertions(+), 142 deletions(-) create mode 100644 website/docs/assets/ftrack/ftrack-api.gif create mode 100644 website/docs/assets/ftrack/ftrack-api.png create mode 100644 website/docs/assets/ftrack/ftrack-api2.png create mode 100644 website/docs/assets/ftrack/ftrack-api3.png create mode 100644 website/docs/assets/ftrack/ftrack-api4.png create mode 100644 website/docs/assets/ftrack/ftrack-delivery-icon.png create mode 100644 website/docs/assets/ftrack/ftrack-login-api.png diff --git a/website/docs/artist_ftrack.md b/website/docs/artist_ftrack.md index e42136fa89..df2a7236b3 100644 --- a/website/docs/artist_ftrack.md +++ b/website/docs/artist_ftrack.md @@ -6,55 +6,94 @@ sidebar_label: Artist # How to use Ftrack in OpenPype -## Login to Ftrack module in OpenPype tray (best case scenario) -1. Launch OpenPype tray if not launched yet +## Login to Ftrack module in OpenPype (best case scenario) +1. Launch OpenPype and go to systray OpenPype icon. 2. *Ftrack login* window pop up on start - or press **login** in **Ftrack menu** to pop up *Ftrack login* window ![ftrack-login-2](assets/ftrack/ftrack-login_50.png) -3. Press `Ftrack` button -![Login widget](assets/ftrack/ftrack-login_1.png) -4. Web browser opens -5. Sign in Ftrack if you're requested + - Press `Ftrack` button + + ![Login widget](assets/ftrack/ftrack-login_1.png) + - Web browser opens + + - Sign in Ftrack if you're requested. If you are already sign up to Ftrack via web browser, you can jump to point 6. -![ftrack-login-2](assets/ftrack/ftrack-login_2.png) -6. Message is shown + ![ftrack-login-2](assets/ftrack/ftrack-login_2.png) + +3. Message is shown ![ftrack-login-3](assets/ftrack/ftrack-login_3.png) -7. Close message and you're ready to use actions - continue with [Application launch](#application-launch-best-case-scenario) + +4. Close message and you're ready to use actions - continue with [Application launch](#application-launch-best-case-scenario) + --- + ## Application launch (best case scenario) -1. Make sure OpenPype is running and you passed [Login to Ftrack](#login-to-ftrack-module-in-pype-tray-best-case-scenario) guide +1. Make sure OpenPype is running and you passed [Login to Ftrack](#login-to-ftrack-module-in-openpype-best-case-scenario) guide + 2. Open web browser and go to your studio Ftrack web page *(e.g. https://mystudio.ftrackapp.com/)* + 3. Locate the task on which you want to run the application + 4. Display actions for the task ![ftrack-login-3](assets/ftrack/ftrack-login_60.png) + 5. Select application you want to launch - - application versions may be grouped to one action in that case press the action to reveal versions to choose *(like Maya in the picture)* + - application versions may be grouped to one action in that case press the action to reveal versions to choose *(like Maya in the picture)*, only applications permitted to the particular project are appeared. ![ftrack-login-3](assets/ftrack/ftrack-login_71-small.png) + 6. Work --- + ## Change Ftrack user 1. Log out the previous user from Ftrack Web app *(skip if new is already logged)* -![ftrack-login-3](assets/ftrack/ftrack-login_80-small.png) + ![ftrack-login-3](assets/ftrack/ftrack-login_80-small.png) + 2. Log out the previous user from Ftrack module in tray -![ftrack-login-3](assets/ftrack/ftrack-login_81.png) -3. Follow [Login to Ftrack](#login-to-ftrack-module-in-pype-tray-best-case-scenario) guide + ![ftrack-login-3](assets/ftrack/ftrack-login_81.png) + +3. Follow [Login to Ftrack](#login-to-ftrack-module-in-openpype-best-case-scenario) guide + +--- + +## Where to find API key +- Your API key can be found in Ftrack. In the upper right corner of Ftrack click on the avatar, choose System settings. + + ![ftrack-api](assets/ftrack/ftrack-api.png) + +- Scroll down in left panel and select `API keys`. Then pick `Create` button. + + ![ftrack-api](assets/ftrack/ftrack-api2.png) + +- New window will pop up. Choose the `API role` and press `Save` + + ![ftrack-api](assets/ftrack/ftrack-api3.png) + +- Then your new API will be created. + + ![ftrack-api](assets/ftrack/ftrack-api4.png) + +- Copy them and put it into the Ftrack login window. + + ![ftrack-api](assets/ftrack/ftrack-login-api.png) + --- ## What if... ### Ftrack login window didn't pop up and Ftrack menu is not in tray -**1. possibility - OpenPype tray didn't load properly** +**1. possibility - OpenPype didn't load properly** - try to restart OpenPype **2. possibility - Ftrack is not set in OpenPype** - inform your administrator +- The Ftrack URL can be changed in OpenPype Settings → System → Modules → Ftrack ### Web browser did not open @@ -63,20 +102,22 @@ sidebar_label: Artist **2. possibility - Ftrack URL is not set or is not right** - Check **Ftrack URL** value in *Ftrack login* window -- Inform your administrator if URL is incorrect and launch tray again when administrator fix it +- Inform your administrator if URL is incorrect and launch OpenPype again when administrator fix it +- The Ftrack URL can be changed in OpenPype Settings → System → Modules → Ftrack **3. possibility - Ftrack Web app can't be reached the way OpenPype use it** - Enter your **Username** and [API key](#where-to-find-api-key) in *Ftrack login* window and press **Login** button + ![ftrack-api](assets/ftrack/ftrack-api.gif) ### Ftrack action menu is empty **1. possibility - OpenPype is not running** -- launch OpenPype +- launch OpenPype and check if it is running in systray **2. possibility - You didn't go through Login to Ftrack guide** -- please go through [Login to Ftrack](#login-to-ftrack-module-in-pype-tray-best-case-scenario) guide +- please go through [Login to Ftrack](#login-to-ftrack-module-in-openpype-best-case-scenario) guide **3. possibility - User logged to Ftrack Web is not the same as user logged to Ftrack module in tray** -- Follow [Change user](#change-user) guide +- Follow [Change user](#change-ftrack-user) guide **4. possibility - Project don't have set applications** - ask your Project Manager to check if he set applications for the project diff --git a/website/docs/assets/ftrack/ftrack-api.gif b/website/docs/assets/ftrack/ftrack-api.gif new file mode 100644 index 0000000000000000000000000000000000000000..b2caf6ca98da8ce36cf4cbe4dea255689c71b8a7 GIT binary patch literal 8153 zcmbW+XHZjLw=nP!T1W!Yt4dH11*EEkqS6ToC{02W5kg0L7c}%292%SRi zXc`--tLkgYN=nnx0H~Kv=m3wXQG$8`{w#|E0?*LXv4R*mAW&{*7JfltIhddzOz`%F z3!;}UU6GcOl9Q8z-?*Wmpa2uJkd{K-x~&Gg?JF&Xkwyip2nt&KIc}q*r2K9RVx**0 z1T9cfD3lZiBZcz2ZGn-(1f!(X)zpwWIw(}Im6M;9%M%PLI2eVocJ_Pd900ROmd2!6 zSR||X5>dfq3tu7zljh(Z=I9-cq5e%ywn!#o($WH=bNqgDJ@t zlr#(_Eto>|rI3Tu)6&w()Pp+GsAogt^OR&tdK!g9qNGrg(FQjCMBml zOHE79$jr*d;d65H@(T!sMa3nhW#vQ?xuUYFx~8_SzM-+HxuvzOz2o_d&X<&~?pHmp zd;9ta28Z4ZkNop?^xfF_#N^cU%-t6~TWFv9(nd;Q4@?ay$C{JhNY6!B4 zJdt8BruSm7NqnNRW^?Tw4mG^Cp)Gq{7BzD$^U=7F zYr6IL=&b( zUlln}n)J|`(Cx&2DQ4D*B(#=GYe#0K*D7n%xx`SVnQh>$m>M;mD{!4*-7CZ#77@kA zq_{@#W5GM(lEsciY~l+qUvwJpYPNdj%)Ecj%%s|S5|$rGW0$_=l$rJD#kqY8ufE7n zqGiLL*$jnz6ScY3A&Qf^geN*vXOn#Gh-?M*!1W@22V(+|E9%)N`l6v5;u36A_6M$w zUOTa$E1#aj(t6yYXF@vWnqS&9%cw#*^NRW?#-!)GSna-FVNYudf2kq(v_bwmgt>z?VhnQuIs__?^e8APMu3@D#Fg9)JKiQ-0GVm*}}Z&W|9VrOQJTWT=Mz$m4Gno zwQx>#Kl$!|jy|KzBSh5ZS{WWJItbNW(y&>CKWfM48O^DBIani==p^4p>A4g|zp1E_d(emY%;ro|Tr$1F*-#z_xTye@w z3t9Z~@~602-)&(v3Xp#n3(_S3A?%bV@ZC_>1OlBXq6=)k8^-&Fz#!k>#S*j|4uckg zv=QCx_}z%hx`kkLe>ZpgZlrWVA=Cl!ihp)D3jU^$#lQd6`R}{YD$pXKaRh zy+}wss|%Tp*EFqyJ$Q!UG9Gu4t0N$pg743xpCLELxrd80mBBXjzM-jfc-Dx0;bp~{ znLGZHfY-aaUzODn$&YGvGL2LXm<{#W9cmxUR2suOy?b5h`7( zXJp|#UZ}`*OXZY{T)wjm6!*v|(+qPqxWdOhaxk3jg_}ZtGab~<^E0^#BK%tDhW& z7Pl93PF$&(PB@62J!c2Q88_s7=e#xkf;r8^ueYNYTAF=L@=guEpiXzj^FvM-Lv%9` zxA(2fI78wz>=3k6#{%!p72bN-1Q6kIq|y1xC2}FM@w{MqU2a3w99R7A2N!OamSwIp zQI6n-95;iUtVfcz0_ODXLTM8ILvsm_##$^sv=$OlJ)Vpr-tGoFgHwMd`S%8X6hWN; zwQtBq&71LvAC3;KbnTg!Y_G7bq-2Miss`nTuDMj3#U^2ista$R3M&~S9#%cva^Dkf zt|TReADDpI3V=s8DGikkfAv?*fhkr}1wb!|oLm;vwsZe$3@lBhIXT6#+ zjDb$tyziROtw0fVJ-((~eu2hD?~AUlh87rT-*x`<5%(t9-%V@hM9%t>?uQB_$r_VJ zKPFkuoHE%7wRj_pdPOl=kKd-wauh6UTvlI|7$tB^7FpQi>KE|(p2-}N>PdSF6}XXG z=Q{@C`iEq2dEqRa7Lu1g*vmr{_4r!GUOPMbN&R#=54_k>t}Pa!At142H)!5DcI%VC zv+sO4hVbtEolW#a?sA^ldkx0kAN+oQXWBZg92O(VflKIna%Ni!D`{U_VJG& z@3&?JdK$hz|9mMV*sLQ?L|=)PyOfRROV2rot*JXANhzyyV)svak2lKRw@=gspiTOgt!{h`d>5K;UpUpw>c!%AcfE7k;(YU`sc{L> zjvwz&oltwp7A?MuH{*P};##(PhObVGZfj0W%4e{ywtf)E=V=;h+KzjAO}@#=gJCEk zMZy7Y!1U}})-Qj1@|P=@l!_PQhPFCg_GqFX9xdhIw3`Ppq(P>cSp%NQPKGl|7vP-} zr!EHA&PA8#ODu()p$pR7 z5t^aA*`fTcp=YN<&u^%^MME==Y)><_gj&HNEoVecq3kKnCF6z%55wBHAy2zIJD|%>Ce+pKibYDLQP|4}D~MDna>Vao2sI+pv$*q_O1weU5K zFKD$b46vL!2y8=`^&^5?(0Zi@ajo>01&neCYty10v4DoaHn{(6fZs%b(SdYy?fR*; zve83HF0A*xB(}*I<$EDNeM3xJ?7y)KG&;&vXp0q3c}{23jP(V&$t3;U2pUHVpG@2A zrbIdLdaTRa*?UH&i^?1qn;8fNKQf$0cbI0n1G0XA`%nHC|1+`@b&tyG0$-?qwhbd%7m}N%=W+UimWIbpTW=^w{PO?+@ zaH$Ho^eZ@7!tK6VfDjCrJA=zV!4dfIMGE*5GdzJ0$ZY_^JK-y5@I*5vSry<*EKt^( zS%?+b?~xNl$SI@bqtkXKLUIQ&-6B8u>X+({Fg#&!|IMwr>z|rHSKOmtlFof+CCUbkG@5MG zKkbd$6~6n~0mM4!bq?*e;8)6XTmsgPkroXp+?Ty3A1fB^tyJu7fh-XKpMOZ7WTN>X zHsGOc#y$1b=!EeyPa^T3o?z0rfX`qMX3Fn5-2T2vsi~r7PD@C7U(~k_;EGRoj!4e^op&QAPe=8aSG&@b5j7GjU2U#jYt;B+VaaC8@B?=ws z^4Z63T&BJn;GQp$yZPR?V{-apE+3P=xidB(Jyk@Z483W`0tc>S$T=~18yice=S0@~dA1rb6@@}Mj{m?i}QNBc)?#sAHr8Kv&D)p#m6GdO-^ST+0Cf0gSG&=f-@X zf8hv}x^b6)06GEcfOCLIMkC9c{LjmsA6Ukx zMqC1>U=e62IwtO3IQabeq9OJHObqjdL3@>JK>ryD(G<&PL(z0A$Xb`{DN#mF^aoCv-$CJF^#nl#EJ zk|(BSOrCPJOGeFY#x51L)y`~@*i0J@wZ3$}pv4il2gWxDBJO$BOd8pDIXkelBKFUF zuNF4w8DB6wp(~tM(qz@Z*7|vb-Hs=Xl&Zt4kvf{wcqU*VHc!vRq{hZmKLXp+Q&cMp z{pIk;RHj^Vk=~9wd7UT@09Eq&_<{j6=3}Oxk@Ns)t<8Xq%;OuC`E|}S%}vGJ;t3tz zuRK1BeUmhoeq3{OxMc!~yzE;6z4Tl&j1H)dN8~3613nB%F-Vy1!~f>b<-hr(0D#gI z0ivj~24&`v4#x=#8w_%bxFlCd!xOVf;1S6$mdsD}jN^Cbx z+>w%kXk|f^q@=;6OH^X}PhZoK#ONqvOq8j<=3%1zZ(>7I*V~$)ZFSJ@CP-Ubq^&#J z-4^Zc{+Hf7OmwL9hOyPbxSM#`>Ug-DVBBpn?(S5K^KiHIaCfI-9F^gy2KN`>s5wk! zGpe{zt&OT|RAOs{K&Zq<#Wkv|jW9BfFfolXF`WVce|_x_u2E%;YHCzaOQTTjES*B8 z!Wo%DPNz`Ff8dO&W`E+s;;V(l#s80#{y)RopN0Po*Z7fo{{z?Zbomqh;93kyi|&8m zTDumCM8&lTz0Uu{wFbS{@5U1P{`nWKHI0rhEQU>1J(&N4Yj2mf$5!Z8H>tSx=ELV> z(wDu%U%yZ5cUA>VKXcv2f;a{_95+H!Si{9s22@z*Z$eb4jRGSSC#L*5!Al>umH~jmO5YLS(X}p(0%99Y-we{LKz{#^Oiq zF(Exw@aO#oU}kc!RiLT?77)hCz*70Pi<>r-LH=|Wug9j!m{@ObkZrwHI6>E}`2B*4 zYG@68XVv0h=>VK?Ay-9o$3v00!9agr1QUA!;XNguC61mU%s9Oz6<@V|vca`Y@V6bx zzrIMQshFW-A+}-(ks#9-?{8#3T%6ec5V8c;insU{+57hX3@4q!$2*dO{u+lQ{Km!v zYvIwn+V_r4M%5dR3%@zoT1`(iz@L#{#Nb8`Gy1sPkzwsyTqZ?EoJ*Dc+Jtbc1o3vIaQRI71$+P>m* z^Ss~IRS}di;&bcAOqZRujk1cJ_U-1aosQjJ@h{I0CSAU~I9{vx()sgfs|J`9EWS&j z<8j>;DgJ5EFE)B({!JtJ<=Sq~6-^15)7QM;>Rq4r*>B5S6?iqb*C+gveZS*kmgWAy zPFdxCAAu!QIN9LWcwRWT-?d^$%3;2Y&PbPyA651K4f`3n;z93!I9n9>QpRrz_p_>6 zfvjSbEQTO1? zk&99{_g-BLzi49MS?0hq{V-rm9RXn%J@qGYhO-0k#L^iy>0ci#qee9 zeoe~hZ#$alsb7F`!C!R8f<1I8PmeAVPIu*1=wxfLp@9S`evOh*mEOk&Lq=>*!kPqy zmJp;M&A4552w>EK`^6_?uvufbN#k3ItNIV2W^i?$NmjUga3PDkWA54S`cZkCLaa{k z9@FRg-m%$GA*_#TooWe?!1gXB^Ib;MFLJc|*LUtOE&45uARmT z->X(FN|Q`t9ky6t7@@s6Y>?=SvE8u}>buohml$k$5lH4d+r&8{6Psd=f)o1Rg?zcF zIEbA=WHp>4?ZjCn#1}(%H9>Cmr)dv0ia`=Eq+!&%bgY2g6?7odVE%g?cpCw!INm8F6-FDOfJcuZg8~ds)LiW#t4N z=uOotXD5T>FI^<+nRt5NUy>-V9U)cwkiF5{x>-sK3KaoGLuLm(ygd^3RpBRJ3joZH zm%8oh!uSseAU&cQ&*0lA#e+iDM52Z$ax~8Tpon*vs3ku*niO#RMDv!hp%XQ>b z`|M$Lz;K1tx525-?}s&5=1OZu)O0uhQEiM~r47%}bg$x3U5X|TT`(YG(EO-Ad$`hG ze#i`VjHOY%PXQrRX5ZnD8i{&U&N@nA^t@_~6^T`@I*YVCDG7Ion-nTR`mhBx}RuRc8pM zua0A;yOEs346_!E`}fz>{q}S1diyfeg!#%)+zW3gTI;dI8J}^^+Nr2({H#HeYRg*# zkGuSwzFa3MmFyfOvF>eTY0dVe{f-T2M{hDfOyk0id`4zD3YnYGJ*8TWDd3Pbx{g2* zkKb~R`2C(w8goJa+*b2U&d76s1(tQJG~f#L-j|E%hxRYvF!^(q^GUlUqMWk(Mny#> zk8UfqMfdwXYw7MHni@i?Zbihi(O~I77Z$G4dk7J4#HeHy^(+Dc3i_%*06NM7_yLK+ zMANLwI?oAOmxLdMfB+FoOKj**Js0b_#0oB~jDr+plz;E>oE5ltxgcsVx@%t=N?Evl zPT{(ZWFihyye8Nec-Qw9b3V_jbrK-xZOvm#$1~aH$Ono6IV@j-j{%Ht;@Zc3!Sh)@ zA*>J1Fw$P0o2k#qYXE}6XQ$3HNGk`I*XC8tJIu{aCHd_Hh^oT=_IR&TiysLqz)b)R zfDM5CS4USFNKegxIQ{?H=KTBq-(n0%pCCwxGx*ni@juW1*#;>9gaHwMyTH`?^l#gj z+U=#*q5p2|Qfo$PulB#yiGPd4KUE>MB>dB3{ojf~`crGHFvcn0kzCEh4)B1&Vq6yj zs#mV%Z&L`YM;%8(P%(6T^QcG(bG4u~^5ODZ7wOhy#lS!Xuuf*a}QJd*?xauQ1O z0~wP%06^x)GFW*!{wj~CC?J#}3c$h^;^drX9;47*T~hPnWlh&B4=`7RLeq`PH<=@y zv;z4pZ7-Kh2-2_n=ASR{aN}xnoQwN6Oex42x6$tdW+$oJ;ZwKv{rzSl|I0U1`VZfJzxn_G literal 0 HcmV?d00001 diff --git a/website/docs/assets/ftrack/ftrack-api.png b/website/docs/assets/ftrack/ftrack-api.png new file mode 100644 index 0000000000000000000000000000000000000000..c69967d78e3b0c34b5efc46fa17a8ccf64994dc5 GIT binary patch literal 9410 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 JRLEI`{68}r0;>Q3 literal 0 HcmV?d00001 diff --git a/website/docs/assets/ftrack/ftrack-api2.png b/website/docs/assets/ftrack/ftrack-api2.png new file mode 100644 index 0000000000000000000000000000000000000000..1653f8f603540d1c57faf8ba74e4041c6d4154e7 GIT binary patch literal 18743 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
6VR74G;-|%Mh z^@}_+-8np6vQ&&k+%Ax`0DQqz^P+Z5#+!~Yy|?;x2Q0Qu$im~D&Qs7!`U7vv%Q;kCv;4*8$e& zoC5oYS(N->DyBDgr8}6bav?dQ1!c((pJV68XEtsnZ#{@KkV`WLrw!T2TkJYm@@LuE zV7^m}<%7K*MLd9=&r^9 ztTv{B#mv+c=P=5LQn$xE7*?W2C0iC^&D}Ks5w&BgR0=x5ne8bIJ}!>0+MSdG6vG%R zgnNLA#l>Hk>GBN?x2*iqvC}7E%=)rr{SD|B5tnI^IqTs>jW$RQ-!O)|PL*(WY+?R+ zht_Z{{^e+aMjb35U|>$Gm1cH+PW5RZNDJoOmo|;N$1s<(azp{NB}j8Em#rW}+v#j?A7(Y2KcTX_S3><1xW-My5;f zzY&&z_sbg8zWsfVxV;C}HO|;kpQD174Uh64^0*2;-y}wqJP534nm(p@VglB*Y(6m6*eun|^n0-F z;T}soZPVJ_Ga0-xxVWdj86FfZveKU;V9-4xNjvA~3c9bTT$Ta7s(r}Xy^zUik`?n% z>Z0>O{jnjw@!%}i@N+3i;oJKXk2sKIf3)fS0Vu(6R|qfW33+VIE9Yt}0+2xzFIOah ziw}ByKj_A=cLqRV2Zu&@#P?Or=Z0A9Tbo!ARukbc({ z8OAE+mG!>mm!@)1*ry)HqIWH}d-qLB?W&8%n9Xo7-32v9HO2w`!Sqlw<9kIXa#WI8 z4O%j?RH6~V4XjU`K7=qnoQpQJ;e8oJOLf($aQ7v8LG^YTIb3ZrlWjMm!Lpbe#HuYbGzEtqKG`Lcwesk673V+f1E+(pb*uF)NG+<=mT1YxHQXy)*2GAj{M$w3hG$9^=6|wa%|wMv{BDAurTH zArhNbRk5j6srF#6p%0w0pY^DE=Ggsbozl{$g7(;gyN3ao<{wLJ$GdsVb(3Y_G|*z6 z?P|H_+VY3rF3aKkMRX&F=J36*QL8CL-qN*Gi#Man7M>|F%@WbiU8>>WRKH2d zdBzv&8n$fkwU{zEmr%65n5zU z#~=}X5wfvoF2S!4TMUdPESm*>e|T3pPe_+yW#c)b%|wrL^s5 zM=f<>MLHZwAiH@%=bItef`i`G>UGmRv^B9ZsOL!B2|iX7*&x6;xQ_>Y9z4ciiTJ0V zU_0u4l;pW>(piXfFM@{gB(xT?isAjDeEnm>yG0>FpkSJOR%8N`3XcUzi z63l`QOYJq3Y}{X$x93F$+?-obXD?N^tFThsEVEQ4l=ua#>c)FqSwxU20RT3Hqpwl* zbVuvY$wHBPF)fKL^jPlS9jgY}PnHObte@yEDaXPT-;-aOA%G}Y)GtM$7mH4g>;cT| zxKy=3tb2ifz{U|(X-VZheys1mQ*YOur9fv@HXMnRlbc!i{}_O)pOd(iFjlmmucA81wV3M zJ4F}J^w1W@Z1c@bA)}WMzaLokBuwrz!`Zg}7J;Zio51cjc?u66DEZJn6c&9{S|{GB zd|CVr2Ft}=of4YoC>j#P5-~g2^!r9T)XU0+({0^Ouh8q z9ko}0_FyVjjdV6sUWP$gZhi9GH!cbT*?C_N_^~pe;UG+R~@$H`f-hD!!W5n@b#J%>HAKCxzv!G^E z_xCdC?o*T8BbCbf{b{G|gc0ukX^;4s3!HEb_y6oqyWSkoj(y zdDH%?xQL7h000oTxpw(B0I;W?f85=_kALU9hm9@&XHW3$tCoP0o|82G#a_=#c9#Hv zvINoXheG`815dBr3kCoVw){Hwvao;X!E{%NES=yh#l}Uz; zH%Oq@L+tE;z+O$AZUzB@P_Lttf(qetqX;_b&Thu;me)~XK-1>iCPBc5ixYbQH%>7D zfOCgn0Kn1xrhsP$_R0gA_6kS<9tcDU12m)lqsOAN1sGZF=%;Dbz7Sy*mHVw(R^x-q zBV$RCCz9$S+&SN;fpz8LvTh>>(^Zn~KCsG7LbqY-h8}B!e*UbMgvY28h9Nbsu2tiZ zrNJ|vV;5&dQ5Wpg1Od8%6l}w32=dE2kDmpB6L?mqWc5ShV}p>H{7;)LE494_ORM$4 z50^F$_-{u?eom2E?5?H;a@Q5EiTetTFNLiI&zWttVV#`$0T)!)6w4fp=DZN)5C9mj}kwT+6h>hXZ+S@%~M2(0N~zZk^r)h^=;e?#s_(Fv-335g^2tWV+$2ZkTLc2UH3Y%qG^hDhc@869=$d< z2Bf{@O`MZD2}{Ru)~D}_WWbQAk$9aJztQ?uv+W^;;JMx4AF}4y=DYWL8+ZuKR$y;!Qk zlzea$oVo<2=rUPt>fl-xR|B~mOVGR((bz)OxpZ}nD{*43_g>J5&f{L?g%)AJ`&W+^ z$R0yB-=Z+qu2GnY*YlWL7wgU3ANSPDLs@HCpZA%fOZ#A+gG-;nw3dMnj||$1*7w=I z2F8Ml+A;F(A5gkKcZ7q~`^|Y>YyPc;WTO$AeQ3+;-|$|9lqI;{fqG1vX<7wPs8+zv ztmsQJP%8J z_?!0-dF17MD|R~(irVD)7^^Wuyq*rsI0w(!{3tJT2B*zE?oLOgJH0#qMl$*#dXVB_ z?dC#OY&YYsZ_4{dT=mR~GWeaOpl$ZU&ItcVsCLi}`R zL}XWO&V*x3@ox#m7(ni=P@-S;ZK=M>|ro zVmeUP#_q%RK)Lo~5r=#b?8M5q&FU+wdzv;ahjESI$@k5`UQCt_@)W8TO* zO2IbaLD^c3ek0Htu0q6F6X5A=%>s4iPxj0}zPZm}vf~!+?F@B>>^MQ|JuVqi_qo8M zWxdGNpzlL^vnxuA*v0~6l48D!qO_*9@OAs~&jo=YM`Qf8Uuw+f+K8I!k?51rWktGP zli8qn|7RW2Fp=k23+3;gJI$>HZnLr@P*#*Q9qb$>O~q#0eyw|7C~^2lhQNh}H>uW_ z>L2v~9{I%!>L+kf**4bg0~nR%oYUkWSRCtOkntDv_ACEViRoakC?)R&Dc#ldBo~bv zMOx0&3VDusvM%dOyvf8o8Sad@mWYf{;Lo4ARY(1~3Ca&ruTgTaofQTi{7yYPCI7palWW5sW*txj;HcyG|z~; z(M=8#w-WS-)CDioFTD)m%}qBrYc=YSJTz|?azIZlI^>?dyuppzhv+cYZtZ^46JgXR zk%EMD!MKg!t>zl(u@bZJ?ZDTTptUJT-VMYUrKl=_vgcWwW$o7sM5heHFl|PLd~_c% zZU=_6BN;OLo`yi5xQ&&zobQv+-o41v4)No5GZ=fp51lYl-h6x>E}>>zeH`EJTD7W+Q_a- zGY@v^(FQzQb1oJPSW78h>1cejN>p>H3evFuG{Y|@3uu`&-`rW*fwX|e>2o*WMwMcK zr#nT&Ceamf}FgTosGrs)IS@>HZjK_3?pSHZiH zri~?fGre{N#!68b+qhXA6t`m)Tl*)+VnQQ)e0SoE`PsQH*NK$kyR>xkE3(#T$HMX?gihbLTf%z3vHMRsnY!~`FKi73T<+R4ynL#iWY3w=LI z6cmq)zMJH1G`ZG1Dv-e49G`xR^e0p;CktE|`GNNg=-GZW;Wtu7ELG@~_NXeh{Rnq+ zoG6ek1YzKatfi8}oVsNb=vI@xzcaySR5PsW$#L&|@`IwuxAV^PSEb#S{Fh0&x3&`p zJtoMk8X0#9Mm42+RovIK)SSHjNm3R-#zRrn+m(#4SX$k7?SYaVb^xMuJ>_kB(7fk( zLXTTOuRS=-Pj&a(?QbqitNPLWB_!<9dP^cG{uF4_0|ga+N$JTVyb{r5=jZi)n8lEb z9GI;SPXy@rA+?#!i#O#h)#~j<>#^>CaFVOsgkV2E^lSbULeJ40N|0f~5+|lKZWVmn zSiOR(8PUdzBYu}Ng^NwAMLe#m7*bhU zQq5t`pY2jGeDW?sL(R2veQi28&yRk0pvU>-Z<7cbsWOcpd47nN;UfJ;e*_{_fo_-o zXM${KWP`tdWN74xANKj~j*RNg#2$29$?!6Ev9CV>w<)E@+iVQqV0LYu_*CGWwq$J4 z!K#O|=;_M{@5sWO9SRm6Yuxr)1J~TBU_cEj(nzQ-on=*IA)?(Wz4ne4NiHDL^uTtO zl@7!8aay5sLq?imbaS{Qa)pJ&u(7*bPdkL1Bz&xn(={Ba&BVI#Zh%sU!S$s#$M2>m zHHC+_-Jts14!CB<@0+K7o^{J6Dc*3izH?2*cK{9}3xUexFb@h}%gaYmV2;wTEycz; zZrkR3w@ZzQw)jh#3&j}l#0H9$w=Q=-Ews93H*K?(QtllRqHIsdFJo*VF2>Bdi&Pwv zsoNeree`nHg_?%t?h-P@v%Ru1p)WIk^7gfz`e6+o_W9JhJbqgVsg?tew!LAO240bs zCfgE?_vwW^9K%k(D#%)Tue^IJFN$1t{tecw7_-kc-jhgB=MYrx8b4~DZntRs%izPq zh7@t}yv`P*qkcNe5j6ZCg1~srtXePV2r-mVQvqK85Mn+h4>}182MQfv!M+`5_h^Xaz1D5i+&qSsDsBK@)Phb#+G0tR1D+lM3mX$DCmk*KV56b8jSzElz6g* z7_z9pEFw}5`=aJ27SMqslygO+FqF~;{o;Y+=r$3@s{*BgL=6mb*!u0sk5Wi2@>8!1 z))`%T#lWvJR|mEqO($-%)K#2;)&y3!?sqS7&(s;rF;;))_Q|caUJ~f+ttt@NqI+ z+aS73r(pUSvg3$TCYVUCtN4cgILOTol52dfkIbmFkGK()BL47C20jVfR79UYH(t`l zBgwulmHAw|(w1h5A2jN#B?fxAa1SAb%Z&CBByNB=aBG1k3MZ-Vnc=mP=0?iuG2(I&wxV)GqA5#_~y)+KxTN85N z=2}wx>m7b(cxwOjl}s`nnMHnm3qU(E`N!ci+i-qgs#el47#9Wiv(qpcnTXwIb|HuN z1VPqPGo7AwJZA8@WBOHYdeO#7ue6S7icxo&=nY}ez-~ALE`NjJb`58Z7FrSm$xk6T~kYcVwc<7Kc|_6xPNC(wgmT-tvi#$ zm0T=7ojEYCc*6(J3{Lv;0^Vx|X9x1OIKAV(M;w55_@xazMUm3CXwNkgOXPZYc}v0K zZCo@bw=v+AM`w2E2b|5`(u zJbG|>5Fgj5TWmX#irIy%=*|`V#7;}Jf6mZ|v9Gx-r;*0MA1zkc<%imlyh_kU?aOM} z&MWO-GRQOGd7#WThGl~Nlsvd0gI2gsK7JT>-fL^tY5Kh{=`2_yy=XQu z3T0aIK}M5(?T!T5+1rmDA^IU&hWnJJBQHt}ahz90YOsvueQaf0 zd?=GuOLeQ8{c*2n_%TYo(;mTqemFR_`h$s%L45X8;@1r;%l#l;DS#q=Hr3t)>F3Hw zdT!02UP0|>uH*$8s=1DZW7zB{#ci?H`779XY!mUrL&IbEgsrga`_ONdC>c#sktdXp)j)EGnovBmZH^TYNfNJXNx^X-4qWc#BZf5{B=K<**CVw z;K9VHU8wt{^{Wr?JucP|vYqUtKOsUKl?S0YKuC(`ilL5@Pdl2DW&&G1e&oqO$yQKp z70nQ<>q97YDDB+w;?lKDc!6iWN5q)U8rf?9w1IJG@}|i_omtA*kUl%2l@SA4G$V@3 z@A!9iAw&F|%j*tUx!bGYrc#}Z=C8)%;ni74`f3as0%1UJ5TZFQl3zv4lXLlYsiWt; z7i~H2G#6jU9;i1M9k;}=8rtN=QB~EeKcG0HhjD8R$P0@GBEpS$q~omx_vuwfy&V>^-(E$TsADLImVG zSLb=#IjY)%Oe8VsQiy8gw(!J7+vJ zmUu5BAfc!Rt|4J+_T6i6oZmi?BW5WC ziqDYdZuIMnD+EV2BBX5QsQ%Q*HssADw;#)6riW*@|3SNdHlr$wS0wKOV&CQ^MqvUb zoe(U9AN=+k&|;Z8TqF*yp0n&dw!t03#z#<3?2;qC%Vx1LK zsX?MeizafSOkOnC(!;3jZgyzv!o#Dl&9LJg~F0^p^X^;!x2}gE% z_^QtNmfG5>)C%{ft81E$d0SEgEyRjry~xFu;Vt@}zX}`I3*1XrO!h`?Sx;nrE_g_O z5y#%k-LO?pqH1T(HtU>XPfRR1Oz33gF+~=I;Of7hriMG+m}u>dzw=!=LRUV2(~Ei) zwH->2h{8M|HcZd|7F$-maqehw>2x1*S^|&_mplUN*m@@zxJEg{8;96zEOqC-FTX)Pj7WE}AdfIkGy|0fyJl3}*l~zkzL^La2 zWHU)4f3(vD+y;qb$s)$_WW|i@8hX6(ib7D_vXDr0gjqekA;Dfn2*gSceTa@`jB;tM z-MrfUD5$%anWUf2U| z-=C*OFzz{)wp0lS*_g&+0_(ow3mX#D>ksOt9B&}nvm7tG0HZzwMBN|%u;1u0i*Y#K z;&AV-b7^HH*{amBWIW(>6z1#O_LJn`!A**x*AJsUZVp?6x6^hkv*A|~?TB@B4-zpJ zy1pTA6xlx2mxUBle?u!M8yt(-+=02AzO}$XJF<;^RrgP3Gs10gwmQq)14gowHM-0QpnnLadBvb`Ara&lNfMvf zzVh$G`9BA{|L3TK0RPYJW0kA!IWAc(Sjo>&XFv0P0~qeME?@LJ{9obr|IkvdJEaxX z)oGOaTpi0g zb9Jb4`c8Z&TsAC;gv#{Ifp-h3ByWwErOV|!PDL-yPab)8G$ zmJ3CKY3933(cQ>;``ag<09mq0I_dfvAcKEiguNi}ac>&Ksnl&ATwT{toMsYvDQ_M+ zsygzuAF@cMpcDletuK=zG*%bN-2HiN3Ms_@lfOh6ULJUl$CDA89nLE`Nf{eV(KRWU z!b$E>qda9^uNtu`&)K{RuB+`;D+-)Y9%2R1gLgZ7xQk=%W6t1+*)}s~!cskKY~%Q| zwXu!W>9toVGYVao3144kRQXd^&NYOrqqd4^I@)Fp+SE0l{_^BBoq(ut=%&aoj$;&M zz-Qpwx&3@J`xnZ~$W3N>pdvxSrkk=JQh>EhFdyc%RPm>HlFCMpfJ2AkM(AE?Lb5y7^%%uHk4_TvwQisq?z-#hoqGEL91nMtsZTQ`;5VZWfTZY)OrgXLoC zpuqi1mwYEJpSv^r~p zuLRmh&K-*f&#r$dVNObSPBo?JZrD{DIF!6gTP00p3ji$_l$Z1`4Bsy>{pSh1ialsj zP$7?22djK)R&0O70PZ^|w<36Zw>=6&$ULq4f={ltgXnVh8CnFOG~7ze_YbSHUfgW6 z71B2jPti7J{Hl@#qyi0^B1pm}sUHJ+YB&iIGskv&y}?et6EW!Y9U7$kU*44%D`rwa zv`y`;*BsAJV>9T#*{=v`lWeB;w1Sjp;t^R6r`a{8P_D2B#Y3!J>f0{BSITx_~Ymfh4c ztYun^vCUpKfvgeOa+*UxpB?_*uCtbPVC2`UOuW(={j?(SMznC>r98!IAE48>T)H@<89E|r#0JB8~O_f8KUBcVbfdnbD^cC3k{o`>Xnhw-Wnb1 zr@(e-Mv#UC*O~rv{`P&&@BHSWOGTFnf_uDDrB;rN@!31lWl#QBKKCEFPC%>jhbaP$ zEHrFv*j9%OxLBCYqM@Us^C^xlCFXxYG>%0HH+`{up2c|ft-*lPCF<@#kh(YmUFl+N zz2%~*&5E{Tdc^@ukD|dZ5Jp$(^7_)q{9AdGx%H*7dFwY@FEkwPs&`(H9dyNqrsE39 zkP_F@h7#WvYl~gxpE(VlC)ORPQPqCm{5h-P8FgL~T_ty_CK#$?o(d6@;Rw$LUV=&4 z{QGc^jSy%^yA4%TjL;49F5|KyEubC&0qjx>)15N#{77jd#N-&Gd-$<=;Mi+^52#u6 z?g8wlhWluhoQYe4$yRPk^>SZ_IBwpYx+ojLxmL~3gy9SQN)f|9%Gu^(QopNY@T;q9 zLr}6txX;#`8(|;8V2|Pv?An_21(&5cwfi$Je91lhPx?J`zH}~omN|$2I{MGGf1xL7 z1)R{`s~kR^W#r!xt+!J?-yB#pI0&kJ@3=9lVVAD8UNdi$5Kbcj-JCWy~yKhPzAGEY87DwCxygRrwO}{ zTkxvwkMr$YpbYr4MFam3_jcg-l$VwezA!M{B&>OeR_TqZz+`CNt!Miis%?d&n@ zO#OhdIb$dz-Nk%s@+#s`x?$Krx-sGh82v^N7d)+VCI1jHRV#Yt5MX~PpXpEvL|ts$q02VKhDpvNCQG>VI` zW0wEs)t4Ukpch~GEquQ<93~SitLq!B;LcqGXRz1M$n`L2W_I?ECgLJY3`i8Y6qDx< zKj02aE#)ojL&ff|hwfm{n}ka5T)vc5Imfm`Zu_;Ej39>FKAMbDz!6E}reibNX$~_J zZ8^hih`a6a7GizmxB-#6SwJEGtC9Dio3G+)tl}g*cMk@%Cn;4CQ#6De!8rLq@a4U)HAaUoEG)0ZxP2WaS98Q#qod&lRDOn77OckH{w*z^!( z2aj38dSqnv>v4tG|wAhgfx$qX^}*oBwJD!BP^*aYFCh z-?8($NmcH@cUok#raH>sdY-NPa)$;BxxZSk>uwCZA)eB9?O(I0w651&%O$hywV1b8 z+ocpdQ}vx8YA^d7xLQ8^xioQq_5tI^&N)tlw zgr+2b5L)O}5(0!!Qh-!$^gZX^@7%x6bD!^?KXzvCooDu*S+izl&HBxLbJxsh|K8(! zckSA>-}u&z`@44WQF!NzBD;CNe-%CX$V>RZ_l>UaD(RJ8;#~;3>6_~B+Eo@W%61as zUGMR|Wdq)|>p=VOgAeVK@3L#xRkraB{RhF0>{8YB~6EP&yZIAq( zbNf2>kM5ql2g|H)Oxsd&#Z9v&FHdT2=^QeD;JVP2tZ4OwQ25P(wE_y8bKEHlYmKp7 zj8|QCtR0DAIb(gk0lA^n+L10lq=QyT-yP(}S$9oOAuS@NBoF-4?{fU(ra`BQR0%Qn z$<#3EzpY52(tlSdev<}mcjqo7|Jp)w@^;woR9+msE;4v3LYtSSUf6?|e{rzwg|VC% zFFkXIKSV_S#RaFs7z19qCrMBCx`_OpR6z0g}do8cp2^)V_<-)zF0g6W9Hl7@{1yLy2GOgy#g=}w{%fGivS z>&-_8F>Tu2E!+|FRXFL3YFcMT&|FZxbKK5&VSR5+uXY0q?{>iBL$P|LL0D#ung9Jqg)^--CBvp~f|nRknNMo{ZVvk`r^nJ~y3@64RX|Z_UMD z?T)zQl0av>ehiRneuOrhD#(KsiS>r<`~Vhp*+xj0rZ}(xO$${p%?RxX=-a3RjDVmY zn!BfYSXb+6({<+X7WHnZ9S*nCS1~ooNb(XUbgbO-sa$jLX}w3Y24cDcy5n$s8M+?a z+mIFZ1JD?HChveQ8AK}WK*P;i@GmB-pUh{CpL&ymTdB-(+(G)<(r(jmuw%(IC{Cc zrE}(5m8ur!ZAB=nF1wwZ=F{Y*hN7-WQVHi~w41PlMn=%jwCd&%E6^()8mM|7!-Vr) zWkC^U{-+r-Ad0CYa;qK#wxfM|z0N+7@AyT~GAZ`sGB930sY4<{Z2_Zyt&6-@`ny=` zn6QkN3hJ}2!t09s`pd8Q8FNOmI0qOx@bZ`|zeWQ*@_XI^tsL`ag4f!jcQdtQweMuy z%HZOuS|~Bcq<)7<*_ocfY@6;%IMaO9_r2fXyg*UQ+8SS<`nV-?9SguYgmLzB+?1cQ z-QwA8-=v$t&e;J%3_G9ZrmZFlYMbE9Y{~VLBo%r7I1rgbyDI@oGC#h34383)wZ4i1 zFt+ul0@Ww4c@(w90_MXeN6gZze%zxjPq<}FP5Dodr_kKFBSp)~kFlw$#x)D-o3l~? z1-7Zq)aOfUsc4EspBpnSopN1dBJKFk5v|wluXg*3TS0m=!!g;>TMY)iRYfKG3!})) zzKp=tkRpn-xQ%$bf!Ivlt;3IOuE0|41T=az*N-5UgEIDhFMTTK7V#Cqwdv@b*?tn# zF-H9ME>8#YiuOiopX^SU#uAgJ*LQ%g7{zXJV^$JEdPy_eA5h53PNUu13mgFRmPNG? z1$T!1izhux+ZAGU19O-9f@oomB2HnskGN9`h?P00m99jP+LZ+A*-90Z3N|MQ zcXcGLt8#QJ3b5+43tH3DyZ+e%0(2J|C!Xe)jph&1tE88}w$eIVPDHR140I>fPGPlK zlMk!QeZkH-l`RlvOYB}PJs2FD7|>LAXefI>R$!&w3=1yZ0~&N20I|EuIQvD_Rd6-R z`4f9{v`rIz7U;<>M$b{c$29eQG2lXsT)X;-yllFAg;Vr91P&Ym<8@G=2S7xtTXBTL6{BkmoR2 zOr50sF<)?-X_wB{^vFc^tbn!(a4S-jg^f1X;Y>uu7dtJws`~^vf(npQc@GJS&wvW> z9@6G2ecR}0ueBV=<*oaaA^9WGgf`5G)qzg0hDL;jmS1!Mn}B%eQ>Qt=A>>6YaLZnd zh_CxuQc8$`jv2ZQhlDu z2}58?!QA94ki<+#Fn-miNlR(G@nLUl>rFxbM~Bexm6N4Z%Pab}g!L{f&PHKY`~KY! z>TA0Y<)?0ra(OWR_v|QS@Wu5A;UM6FGYF|uI3Y;7cJZ-#r|W=L6Q3YwEgy?etr%MJ z><`n3@YAuET;4mH$hqySi=fmD>#KteG>K!Eu_^d|P_RcnZ{VtywVN7wp;NhBNSS75xq69`Q-`VnZkNYxle@ z)D*G9Ru0DrOTwBZHKt{bu`;Wf!Dxq|9%cl96!e`)KN=3@E~;*U%G%WA&c0KS@z{fJ z2`99lp_|O?$@9EL)DBx<+}8~5Ph3kn8T4HOHA?K3WkjBYtuw0Egg4iG^v)XTstY2v zX-J^^9(hmm*B($3amg)HlJob+NJa^KvbsA$`)4$kESW!y=Huy`YJm2SiJj*H!cn!e zJ5Lu2W^Ik-W(2ppj>d=RsPg2Hwb)^d`Z1231;+#XTk+`d{+0{2ZzGmL)tCh5c%QD{ z+Nt68|Ex;-w`;SC)d+SHLgQNlZ$zmdS>=3v!gxX%Sqn7}+G^U`G{Vj(K@>nrT_FWH2CVeN#i4cioq; zl?`-*0*d9bMZ1g*h}Ye?f#42*bc^4OgiUYMOvS3t3~;8xW_B9O#k;$9hD|`jGQS0% zZfK7Oi}sfQV-BHP$fDM;aZ0dG*OVa8SWAp^Ge>tEMr(<3TdLZPu4yF8Ol2EMk10is zBLyiS0#ZUs$I4deFh-I!vDWB;3tTS?-@dUHQ`1acv+6X6~ao;ap%8;RPiH?86oyVo{wqxkBz^56`f4D2blY(9($Pu6u! zouKkF6uGlF*oA9670A5Oy2_iomVMZ#D;)AY`XG;|nU$EUO-DT@Gh1E>QnXgVBV4(s@odrkTFOHVq<9ZT-wV zxIQ$SU?8@(TDOH-{biy`LL7sx7347;L`6jd`#SjzfaVe?=*q@rST6!K7duroGSk=n zP)6{bD5@g2=E%fT*6&FzrBfH7xw1Z8y%oNA@^S_mPJe+S&x8{In@hTIvJ_J-lx4++ ztiiT|d{}8g#r4;$<$5)@8dqDR0jrtp=`AJ`4I_VF&5j0qWV`^%$G}G4G>y{96_7YS z*I(~_GN(z|(mMy=E{QqkakE&n=CCK0L>_u(ri6|!vbVzwh-{&NG@&{J3p&nx0<$1OO3YhA(h*n)$DNaUKW>O#~)A@t0mD(8dG zG4qb4oaB%fW>7L;i06T8n3=5>fvKZbph2uU&AxO#=gY!LFLLenJDX3MveqaFl)DzCMnvG6#75C4biTBpxHY%5k+NS>bSFCO6V4&1t%*TTu zoeey&>G!y|+O)%kQ=Z-Tbt$#>tn+R2MZSifZTG&{PuUTWv)H&SRz0{SHJ>t@Bq8$%%-pHvkvv zT^KZ1tr0cQ($=QEy^;u89NbR!Bc|)+T>Fb62g70|7rXY{8a)l@1N3OE=#L2N`s;x@0(eol3 zwVl`L7Um>sUECGR(JpPd+Fyuzl&`0dzYFKMP{*6{Y=)*!#&J9K4$rSDocBF(v`axQ zGbjMtx4C1^d^tA;<-1BwfD?+i+ac5>DXRtGJg*^NEqq{Sq3&Z_pA@%#a!zk}yUswL zG4Y@L>+wl*MO)2Ro(5l;ij`vtyxFzWLFbvuEXljJ4D0sj%DGgR6(vdhuwY~rhGlr; zQ_Nsc+vMD(vE^04{33LC&S|t6;^n6XFLA$s!nf76MIF1h#5O%HLWvDqWQpBzg~G+g zs}jOQh4A^bit^*H-**s4XItZ;kVCgcDPKTK+1#_{cg4~I%p8(63^){2sl`I1!!V>H z!l8t}+ZrZr%Kw*o#OTo8y`%cRMf$aoX7rT#6F`q1BdDIQO#tvxc&gD)g@H;KPvif@ z6MMYI+b!8Inwud5BW`fC(R(Yvqngarp)~pNU!h32XT&}&`+yBILyvDyqdcN6Qb^I| z*%v+NH7~JN0dI>f3`iZ?X$2-yfh!T^EI_bta>nuSFDxKZa6Sk{gke2|ZK+9q&I&qL z)H@ag$cL`&DL)S`4N;DmlpMVHMCuX_l-=_cdb9TiPAG@(^iJ-MuWxhd;Vj*tq5jJu zW#S=cBF0PT5uH6QC*utXW5i6mm*Q^y19Pw&(#l$bsxV~0DBU_!@*K}Dc=Q9@9Lwg(He5f{s?r|>0Ii4Pq zpPBF$=XPiTb0BroyPhx%-$(+se6_S&Ssp!C7M5s65BllWWdYAHagT2_TsT>Jts+KW zS5zJRH!u>m_+&nRqR9eb5E{s{p*Q(UJX=1k3Iw)&+d z_S*+aW;4(lB&vWIa7c&BS;I1i23q|vYF)PT$1|}XcKf%=NCYjh0KT(@?B9eND#Qvp zmHR2Y&VY|hSsCo$yQHLp6!Q`SUm!I4K$_yGx1~&VRFToI)c}%W%z`eM& zb$QeSoqIueWca)tQ|dv7d5H6NZEpBS8OB|^l{0m{q+Tyil;u?EEjff6wQtnG1euv| zRyj5NXS*u|pI)=jF$#Uk_>E4sk9Tu!8KF}mO<9Kv;87}VgvwrrRx2e?CHFQ+chNuE zHys=~u@-OBgmMYcFPB;%oQA0xNFwyBfH6+lNzxL#RXvr;Ut58EgMPNP8P0^he<`iL zAF=Z* z3y(iCFeiFfC20&bOrCT7*<4k@8$x^HwqK-3pCgv3KV$5wefH(8#?PxAZXfaCzZJFW ziHq>}N8Z=kJZ!ox6BLf%v)d?>rC$b4Zs#kbZ1bug0G^KMo>VXmM=y9ZsW~O;^)ga> zhBmHZo-Gm1yw~n@Zg5PXVll>-SAR+l19#r0Z{m}NC-sPZ{{U(?QkGlhIvIS5yS2vB zn^9Y{UO!-Lf==~P4$qbfs|&gBINGMyYxhn2>tAksl ziO;erlb566)2K69LWadH-yGp--_@ zQ;YM{9(9_ZvmTJVI3dw8LBM->(uOtFuc5@N7drlcU7$8Xvi8p^`ypPkpB<;9|A}$= zvoogpL-RHp;R7foSvijW(3AZT^)lflG!fn5H2ii9_s4AiL4@upiQmt+Cevh`QgK9k25PKx}DMnLk|t>yR!>-@MUJGHk(oaT8Z zsAz=){NQ6nehqa!1Ksl#iN1r$fC8RXP>~&xzLrX<5<^+Gj3{nHQ^R+)ScTJYA_41k zxustKfh;Kv*q0Yfre_keYu&*!+{So@LD`Y9uHVW&laP-(KB4P7+S&;w=U>|jnr z@25u}Vizgn8DdG5@byQF!tz71{?MJ!MOKCZ3|xAoxe|_vq*&U~0WPzO?9YC)WExXU zT{~f3*xPKoOKis3tLmbf!sjDriAAnMKOj{Rv05xD8;~qnWHi<{N@A^Zw zQJ|DKh7_;3Zk_mJF=Gu5Y8L5b&FvN!*)dXLx-r@70wBP^iLRU&boViZ(-1Ioy=9JI#wqEu#xNB9AY_XZa>18293`0qmCD;*L8`f;)NQN<)G| zoT!9sfVN!x#zQ~>aVNbk+&-%E@{qa_q#BNSoUaT|HlB~t6}hwRFmGfFHtiGZI2P_7 zWfA`UbM?kXl$d1tk%4m}-zZvM9*$tI3vus?_Ti+Y!k~*gw05LotM$bv$IaLEdh0sg zZua*C&x;N$K9F(-pLtOl_)R0Bq7=6iFS-}M@&a1+B-^RRFl$)ljoi;19CPkfJyo9h29VugT(5^LCNc|u)?~Kx0CC43VIA;xRwIC|d`bH?D zT4F!G7+$rWcEWSZ;dBhJI$yb;xfT0-zhE#&U4w1Ox-PZt_$)gMUACJtE=v&PW9KlH z8+{Hy4oj~edbTnoX0xv?j+oGAgQjU)&d<=T-aSBa@IMzAT6zoTektw>%MJry5U*E-vgjdP=&$0Ro2lH1bE#I8mgO&0@# z^Q$dVE>)fG;CHDi4m|m_-at3_?!=d-!YFiE71kLBF3e~deWyDtj&d5o*gpy~m2GD9 zSjh8(=|?!Tx*soggihY!T1ypc$iuGhWdEJFw-D)Pu+_(wDnj>@7{^K#7j6ayvHqj% z5sj;HFr(hM7$L|?wA6F8W$)Xi>oPUvT3d$6Y9wNIj>=u<()PJm{7vKJ8ptVUEef3- z5$ztfKc9cXHh!|mi)Pqh-6`T7_X@tARc!*JWtr3M-qL#tAd&nGYrFRw*GIls*^ePE z2la(7Kbu+B4TIR+MT|DxFNp_rj6dz!qbj^`fqcSb932Z3>uD|vg&zpq7ks<4fpgGY z#0flBs<(=;N?N$=4_q749dL^{(|7KIKfbIGUdw0gVz+Y#L{Z9Lc}K$puLz4Q^IsJc zzZFo1QN-$={C;MqOpyOFXX5p5Ku3Pq@8irGDe4{Il!|bUFlImr#qPU)O1v8F`Sm+Q$<+JLYh?poQ1V5ons$eZ$sZ@K^hfYphY$Y*DPuNxnf?iC ziqBt!*!&a8RPJzIDgUPmc5l(6uK&Ee`dEBf_q#taPS1<7&MBw=1U5$>|6dQ@FNTkX zt%^=rnCnhWfoC{VJmt$$LVzh}4Hk5GOXe{48-;o|M40zckk%5ZC_ujbUWo5tPfdV? zW57nCwu>xud(II#5HqST%Nzodvkf+JkXIDDK8dxokqtGOs`$6#?#U5bA^lv-j5dxV z>9L1$(dM9fXBe;+$!DXA^fC!5CRT^(79oT}JJ{^H+&D*qRFy!v-}JSj{n&Qd8guwF zLUuFLAPXHRKSJ39ey+Xl(K1n>R_)Sh&^(R|yg@I(UjgFW3>XHt!ZRd}yLH)ZCYftp zHK$Cg%ZD(G89+-ayQJ|p?wl>v>#ZSsjzP6ng$)Q)nJ@YSs`X?6vO#Tc8nk^95|vAD z$&8~{-SawnSa?`5*t7UI@}_(60kmu*S(~e+>AXlK@%Smk9sImD!8nD)m%c3UC3N)> zF2haOi}9VnTeq(>9f7+y>vTRzS9y1f>7~&+S)Fy}Dsi?yT}YwZ4hbDk?&|8n3Mk5@ zk~R)!ajH1k6b18gcMI+@sc1`pUC6+j@XuC^nwbzQ)is8)Eo9bQ?2@!GYxHb<<<=N| z*Jj~94e`T1SbDHdHd8JvYy5RORyj+i#)Rum=rkWf`#dzK_aDRjDzZgWJ(|l5oWEZD z;Pu%KqA`jD4grgQtQll=b819lw%e)1vLYrPkkT$zeAVTi?R*qBqGLvLr24y`j#*?mNtN1E=hg8pZ=uZ}qAHXM8ord(kVRy5av{fSAJ9d;|+R;Pc| zbIS;uE`0gyp4NNSKBKe&3-UdwPd~kNA&FlbzceSCJ9dj1BpEUa^R&KS?ga*Aa}QJz zIS(?K?froV=*a61TZFq4pK;YS1WHv;q1{vaNyn#E%46LnwdG>`%UWIuyJ@vBN0l&r zO$X%oud^Cs1WgQ>6IY|Gu@=n|x)y}!`#NqlL3z0lmwD$_-cx!m0Zf^Kuf*3zjQr}K1moYsV2@t-Gp zo5AGGVMSaXTRwj=!fa|zw^2Ebt`d~dc`6R(*quLLy~|53+L3M=jDpT4ij91e}!!eY^SVkmEi%jF2L*qJP_iwMRs1G>8ufEr+ z%a+=)5jq{${X1@a;JEtI817h^%_v%IauU8Wr zGxLaJ_W~=&+dC{<-T^5I{Wk!g?C*N{Fq|;zdTFbOP?RiOfz0X0-@NI%sQ2L#^Sn)G zk`?-OsI!D#RS!Kl?Y)!evoXPk9R>CJ%+A5uggg|}+Al6WMRA6ul3=J#zMTms7z4^H zv(rvwL%u{qI&(8wLJq*`n7Hh29wOuT^xT#EA6JUud0rIPhLL*!ku%S~O%y`hCosX8 z6UF{(c>%YEeSj|mG!{3w`91Q@VGj60GbE9rVn;79?My5Q!2zTLQL;?I5MW&4+ND{q z9FxF=pbU2bjaWoi{>UfwNKN>Z)kM0x0(tv**5=5`o8N?ZBX#++2 zQYA+|568#vQ}Y$Qvd*+$#L4!(wC=`?*rRF7lj&IAXP$f z#GK)W4OIMj9w&0KEF(!CDNdQfWh|m8<32uOrDGHF#lSEP=aRD1NSxJjZkl1FKR$xD zaREH(IW1=*g}I$)&~P5y+|^qzQWu{fbKD@Mm@e&;l-rwO30q0_Df$L&iA(n%KZxnu zO?*c;#F72N-w`>(!3D5oCRCC?ge;p(^m;cl@faRdH+(G=7OmZN4GkF~2A(n+OVu5= z1I)&U_JnH1)fnqAa=ME4;}e3W45+&kibhIn=F10iAs)vYVJrG6o>WvV`|ew7QQoxW zY9XK@^Cai#qe+coeLm@eh-VrVSLJ5O!?aWx%sVSpMQGy_L2R|`MU-uxjE|t zX_6ynlh2SQfK$K@T~dQ@rnaAR@1|UtSG6;?c%{S$-FK&+`TPpulPN9Q%CvWCjd#oQeLBTXQ=fxR_a7m8-S*D>4N@Sfw#^!pyhdD1GP_&<1rV})4;13e zSX}obU}7D5@^ly-q(T_&muS^Jj?uLB|AC1Z;CN&#qg{gmyztH{b2nC@HNl{1>eGkm z4>Gd?(~qAXjtFRoZTWmJ?6Vz=ud4{47aG`FwqBO&pMePmNGz!mt8_XQK86TZmaYw&|hlMMbTNG-vNW9!FCR>;6FNdruFXSka`W8*nst@A?! zvXxH~SbSf{AYsUjum}Ipl2rigJkuxrbSVH(`)&Lgy(bW3RIDAFw0xe|9cxZTd6z9CK@W>B!kTl($cEWu1E; z;7|oPN~>m)9?WRsV;N(E)4BsVVdwc}O^=xu0l5_)l$V3LJRCgh%d#!*&ajMi17hE8 zsyTT71ds~i+3pqH#@M@(bb>D4x*bK_|f%S5$`drt7^oX^iDR zQgh_cR26f|c@)0r?I3Dl@}#K0#3=~XFA}y}&>JLS7oknP`p@3EEB{q(cE1w#mrbse z)jz*8NmaF{j~SEtD`jmgRvF5)*I$=6pi6TV#~knH$AjM^k%m5~%QdkC?Rg}+CGBLe} zJcmurckv<mZ7|8M4qrlMJeNiFD#549G3b$I?qL<$0Ue$s&>>L+oO^{th)s0Li)~; za_A6!kXwOQ%}Q>@xTT31LrHB~qSsC|!3CD>S#^3**lYMlc%qty3iFpp;MCXbM)|OW zhXA&;ZOlc&fCCwS>(hp%3?cFBY7Y3uX7xByv}z(d)Uj71T|{Zv;={U?zEn9odTn#xoLbaMl23xbRvIav<1f`OFIALIdhTA+eGxEubIJ36 zSFXzqZ+{16z0ct@eyMry(qnwS=(}7MylrnC{+gAoYmSOwme8XW1hwLzT&|%=q4I$c z+m>71@VKEwRa>>c>o2tCcn!p<+D?P|2UH6)E}TN5aA1&&gKXo>P$En-FZP339Cbk? zB(hp3__>S2v{!QchF!OM73lLu_lUVp@iPgcbV`|&Eu%d4(!hIxUQW9%?C}To z$~6QylJkKFKTpkgBS|j`4Iq9rXx!p#KG@sw0ouzVJV1E_{w9PY>QwPZARfkWB8x@$ z89DgY*9P=k^d6GLCcRBzMe3uv9tbBm`wgG?3RRV=-(C`+SL*R;R<7471tY4qeZK>J za>o}dX{zI2NvA=gjV^Xve_l<7)XLqvu~V`sNdLv8@X>P?@nC{{o|;Bp$%s`QanG`| zfM&9S%=-deWxLLi*aYFgxS=GaKx6QGFL$HBp|2D9{@5edOTsU42VLM?fRe zfSgroaBl{ zxUtxn$dF+)2~^o*YdPT;UY!rQ-YwgCI|VUcRDV}AB{#iVoS}cHtAGT~x@sg?|FA@R zX*M0zr$*j4ws&Y`JF0uQysU1CU;v4a&FuhfhJm2o+jS$t}Oymykhl}eFzm7YC4ioi3(cR6cDsg=|i-8d=?=Ct#zLIDf}XkhdSP1oN*?5 z2~rnlNWMw5_Dzy0Z^lV5aCDyUR=09jgS_47v9tk9f@s-Rb`j}SU6+iWg9GLapi9*P z)i_`*!P$-s5=o2YIRRspdcB52p)Lf86wpfgN#qry=FwXgW-x+Z$&Z(6*NE1tkCul; zKY=Oshe-eAWPQACk&+Fmi;xnD_5ml6OeYYZy8D2Mc3zgFs4}YJM}WtDvUty(6scLm76sk z#`G4}T`zD`;N+TMZc(MAXs@d%yY5}b$(I;#q?qhu%`fSr1;|#)vl62yj zXE=g@fI1Gp)}%1anErx!u1PsgmC`BJz=_|Q zt*!d&QMtSqTVD+SVCZG4c+G6jnn}!`ww(B6>6?CdIz{}C@mPJec=p=qMpflMD0_O? zw)));2HF0xA4i8PBj?|*qbhlkG9GJ-p9!JQ&He8;4(^TXrr@Wrw8}2EJi*UP(ycIGw>hp^1N+p*$H~aNEMds9&7jb4#X-jWfcrz?>iOV4>Hf$b+Zx6-SSLg;B zgCLzLNHUz#j_8HZusWMjt0cg+JS6pJ6qZhTuLB_^abhEDF8&upEWNeo<5tl-+exdF z2C7~k!sNnf9~2q@+6?olX8@gMEv}FGS`@P)p6v`sMbuIYR_CM;i-fbRUWt-rETDF? z%tj^>=rvV0&;7`~_^$|-`D0zh=vqsc4~+LKS;^^c=(s_2p-hC8kP~E1m)mO2$}(-? zc8cwMX<%cds^_Tb4$Ww%{Jfr4%qk{1@A>pvZ*!=@ zjs~-sSfv||i2hfaRL@eq_}5N@>L4G`G46t{LjDvs)f;w`8*k1o6{9p>LC1JuHa4~R zG8>~y!l9PYmt6XaAszpv;NpS=VXH_Qc84xeQPaD#^l@hbMw*u>*^3u+iiE4S+mRHt zXkf91Z>lFBS*-EcVYo#NXBqA^(QJj`n@HU?Zy>3}X2SWuO+$;ef8o9K{~PZQgmKDx zc4-LxOCMTzsJc^Oyc_J<0r^jQ|2ID?f|G?*$BV+|VjL%%X4N;B9ZUV$bZ+fP@BH3o zZW~CZb8thqaotc6Em0M(xU!0-^M&7$Z!$x}*-6v+v|iG;L@vRvdq7~*SY^rdHj;4` zvPCPYSY$Ta$)x!O{7~m?D{{FMrro);A4Ho*xZl_*A; HN0I*v`XP(n literal 0 HcmV?d00001 diff --git a/website/docs/assets/ftrack/ftrack-delivery-icon.png b/website/docs/assets/ftrack/ftrack-delivery-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..30775c9a5060f0c6ecf8ac8c20f16deae819ef4d GIT binary patch literal 3772 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 literal 6706 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 Ucf8oS`WHl`t^x*@C|kV!H-vIR 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 M;D}r4zSGzL2Lq2k4gdfE literal 7943 zcmcIpbzD>7yGP=Olpu{rNrSX7l@=9nq{Qecsia7Z+6bjV8bm-uHfn^U88Jc{1O`Z# zgbZm0$fyhYyZz6-pU=I2oOsT8-}A)x`#kS+&WY61QKzB2K}keJM5C$k@ae_zqgGjT)0*yx{W2fJaedm5=LB37c_-Dy@ zcpv)i4{i6!3^X{%9Q!)_EZ%7QPv+*k=a0-rsjP?gu1r!)JJ`F5}jgVVb$oupps3uSDGxtb#spKt7K5*+4WYYr$) zaWRYX3C90iR-|45&b4_;nFM3>pYqTZ#zD5qbnlPq=VK2S7PSwfYq;h>v7x`vi`gX( zir%~E`|4OYtwVmbbEXP`XSN<6)|8db`920a*0i=JBFb21T5+xgp)@f){~*b@sj%}Z zlZ#i2(B1&SQN`;Mf_n}+gMISBZSk;caJRrVn^!Ze>=I$@62`&!6VfvAV`DE&i?feF zOL}BsQbtBnvk)vKE*H;%$;f#GpF1&uY`&M8_CftX{LC{IR&4=QR<>lRT02_nr<`dd4E^b2voEUa=OCGYpY!wc z+`d5ur~L;Y{h;`>^zh^>9EeI(m*7W2%kndIl^lP|&6Z)zlECzhGU+U8kSBjnK0Q3n z;Y^J=t?4$>&~IuNhsGnLMungO^Ft{UJ`311M9P`d-{&sgg1)pMl-J_P{u6&LW?mr1 zoHU!-&L(_u_HRQH&Hczt=+c_5R-fI2L+C9Cw`%eH?zrf9O~P9YY;6j2*a;?d2`Zm% zf=>nqFpKzHx4?m-2c^0GyC@ii%pPsO@-FUp_WY1GU@7n|u~&*#PbLx(gAd%>4?Io6 z{O&oLJA?WL9*n^j)i8u#NJ?C(Q|G}(H+gOKw4BU&*Acmr_s;K534Wn_fx9PSHQzkf zvXD73c<|9m;K``LT;c_lmCw}!&+3#dELRnHD_6VKy~d{Rd*Cg>(!JpGLZBO=4*`OG z-nS}fZUjr)!7d+G;ea<+A~9`=vf8#HwG8k4dkPtSgCSz-692ddcK zD<7r%qg6-zKCVvHYL0#S0bF6IN?fxYn_^E?cI2B5;$m$ zsvm(8@%>>@@VS}qTDO5iQcs3kf8XvD}7!(B*OKNJ^R9+#4c|9n0wvnYRVIJkLZgUajEUMkIa(g?b{1J41LaJ3)sU* zBotlp_E8TSv~Tz|CG@YwS|^WFA*i|2Swv?bKytn&6?FW#+Qwz+uz9IUBRiEM4d#_} z;aA@YavhY~5MA|a?jw$s3ufp0TO`mAMcB@+ZrFp|#|~tlI7_4Ao;cTs#JjAV6f)$08)L4XsL21Yq>@37 zN#L-OTNM_G0fK{UaR%r>n&;eSV|1lt7saU?OFRFaZR2o@{{-DnQ05{J5)lyT|3i^M^L)c0Q-Ke-W!v!w z79IOP{{FjYhQplw=OMhjgtSRv8~i}!-d{E(3`sOv2rgp7n-K%*<#^sU2%+U7pD*08 zrJlx3>E_^$=SLmy1k4(ovLSc!$Eb_$LGL#Oo;qqnV*X%v+QljkJ@IW#4uyB*`sK=? zN^me+v%P0A@_#($VoRIAZTRqdk;6J18-^uzRqfum-Cg;o3X2SUPuPSfOG^Fhe6CpF z$-h1Ew0Th*L}9NHf9eGmH(w~Zz|!WB1UI#{*s;lFR=VC5atqtp5G6c zSKj38+4hT_%u33UYX5^IFZ}3Ji0j!T0+iRZ!Fq<=reYE2ODFw9z_^$%-LwSq;xTb( zG$+#i7*L)Ft3A9R;r&Yz8j}7b@42CQ6UI0K`sM0*YmGC!RV6v{SI26bMS>M55%4#L zBFwS_e8z+CZ;);RzgI>9Xv1cBCUH!$o#A!gdq5y*(!by_rKKog4MzxTfmqW8sk?Lc zglQ6+w5xL;6+jQOV8xd#(P$bs%uU#Jge!Z2i(j*=V*%^|ht)@Ht_`m)i_z)oRl!7j z@hvS==hDQSN(s~9ks3MLRHdcP`8JDic8R~{nAzHS8r`q%F)kegIXNa%%R>wYa#6MT z9CJl!7klbR`1!XA9nM4qSaY?o;A7GqW#hlllI3I*bq~F``4Vy70r}{)d!aAtlM;Q3 zVn3MY|32aV7s&oI(FqCHPf50VPcrgWv!lQQ>96=@ZNmRhRQ(_4PwJ!IoU5Jfuf4)C z-Ig0eKuTk5aq5rxp!*9q*B<6dwIR4dfLj)9-PpJoLjpm~?ZG>^FnfqcKl##~fyWM& zl!O5wiQv0`%`=iXK(wY?IILaU?$&8!1=LKPp$({~y!p>m`^xTNgSo?T8~V%XScZpV zIm{dB6EWoTBH&+h?!`it1}%yp-soY+2UM13PMED;+ipq9+h@AzcB|G4(uII93z`xV zq{biEAxYxiDtoO3CL zo*KZG(U(IrUmr*mvXR|kJ2H-smwkhroUCT#`c|E3%Q-1{Te*`uh& z<6Co_!&B+xk%~L{kxU2BuVB2!NO}|dwciC}0V97i?@}WVRD0aliNp8m`r3UO)Q=gJ z;Wn4z*C_V#VZk_c0mnILK398HCcH+mer{6b7Sw#&d0Y*_^-4`B_w3bTC=i2pGS!rD zO3=NM;jSeD^-5OuFIAj251#2{^=V_)QP6x1rv_Nn;(?eBOTUelp;5o?Gjnj>YmlED z?h|09fct~h)O29mZ^Tqa4Ao@_mc1I>ssDQr|4U5&CnA+aH&!ByGV|x#vrv8$nS0P@ zr=_MQHG7{XY3hG|MQ8Q&UD*kn3ID3?|C551vgKPHP5lEZcF3UsQ{bZS5!_8;vIS-D$%Fim6#M<(#c?k!3GUn} zXL*g26>m19`h6

Xs|3y?JomHiC1`IpY>6<|Lxvw`9$;&f^wUhLaqgfxRZc!SnWr zTGro`V!0}^J83Rt4E+P*KO}4F0^dLNa><}&ZwPVB?|faAC+~h4ocq{cl5L!2#oy-kK_;<^OR8O>cull1r>=zE{1^g9t;!!d@B95X`N3#I#hCX`&2+mzlHxofiVWtDu+DYd@XQex8~4mW#5C)mdyqi3T;t*sl{m4&@>)?t zx@$c8fNLYK9rTKAw2oiv2b=kFt)4Z}3siI2R^)4^&UR~wHd<_L?(mfMl>&|@w^oGQ zrWz)=q+{O&sn8P-Y?Gurdp;wwclazUMurxw;m;nCOwdmhT^42+0vNz@izYtRmjy+x!XN#64{pwBLGM3l8*(|Q}k-HE3BGe8~ zBq{2Nd_&U#^o$Ar8A68l>U;8T7f-9+8WwDr!5u3Bh;W%tX1>Kwp+c3uDaQ0eFT_!Z>j} zTj^&_zYQooX^uXt}IDzQLMILJxx=HAqSfAC?YE#o$_`x~H%-G+WeljUtis`cbu8_lX_ zx)Kqx0cwVq6e_63D0H7)glT2!736hqfXL`_9I}g75v53<=QI9018kOI3=tzvp1pRRA=7!QC{dh%X`sm44=x!`%3zd%Ci6uB( zHvv8#FNs)`r(>WS=9etKy}3sIXnOR`*M48Ivvtj~);QvMDm730iL@KQxK)A13$Chn z5aHS)^N;ZSO7R0qU4^GXd3nze*e7wnOy2Fpo?dB)8<1lb>Ms1?rXW;3r*9n>Yc}Tn z!tRSPidZ4HikR%?vme459YVf&L7TcXgGN^S=urf+VM(!LiVElIST!7NAD2BaZrbtJg8 zHB(-;=XBA8sCQU*W~@DbQL|@syAk*9)KM^>R_M)etEXUY>6fHNI7n$WW|SCZM(kN5 zZk&+wNZy(W8SBL8lq_8(1lvUo$0-o3C|REtE6fB-T=(8ueLCyv#30Np5+PHbY18Pd zD_>Ou87`gB_Fs|PQmphkT!+92+pQV?RBr318=|z@q1oXJ0cC)y%MPv`o;JPFftf^f z`eZqdOzOm~3h97?PXsgpCZ9t(^!vUvT1e>(%91TM+g~?{(Or|C$)ihBI2vb&5ZzCy zQ~(EAXCPE)BV7xK;ugR66u?J4Rj>FfOf@9#!<3=h5_1rIYxujSlaI6cv#*zsTk?ca zun#V@W@c|cyWU}8WFa&=3+!Xywm{Pen3>_+NkVY3y+MrCg~ctTE*J@EoiGJXoW2v~(`!D@$` z?*sQ|!kMk)Yql8_cM@z=37;JZ^L{=xH%>7zhVJz(V+@l?13hqF#c>^pqPy#xVOc91 zSM;I-O+uJGM-zFf#hjDXtvch3-?!H{M z{>7Iw5;)5cCavVOT25(@d?uH->7FVb(8f2pyi}43C!$BAi8hYU%=(~sM&Z?OIJM>L zb$u?X*}A{gK5vTg$8R7)3l_b-EIZX;4QHW*o#gm)-j@rUudb`%Ob`5JvDI64w{p8@ zBJ}v$+1$dLy_)YG_4xLeh?+fWG%^}*H0UeuWRgg1(BpZ1oOjhMR+57vqOejPpC-Fy z(UvFPp7K1|m?7izj3Uvvd4aFH;3>p_dZ%C)o#8lQlx}uhMEW@?d6RF!Yj1Dsp3?cp zw0`A%^la-dTFZbJ2Q$aCYo|!pcK?aX3QnE+ac;JtB*ZTcP+31!ZxVc(Gj%msg)aKR zO`Fz#fQRRm>hMOYxd6$BYEe1dKI$+_D=+@hK{!z8`I`Ey&>P^q-PNlI--p(+dgH+QsehN zwo!kbCBO0V_dy1!W6DF`AMb0qKhg`P=sRn&KeZ}p*Q$Ik-cFxIV?OgchR=47$?AEw zE8HQDCAE@$R(a0+?jaU@l+gAx6Z4uRDs*Hl`O+y*cuhs@2EM$`k9y69S{_{(hmYAs zvJW0w?LLP`oKN@kuu3QED~hGh8}DTf79si6JVR&V`Eu!d6G?l_RTBLsic?wWDU*ld z^_(l3Pgb0HADDXS(ooPe9*u^xD8cYzwEY!@i;cwoEJ&@toumsmZIc-!m`RY#W!`6^ zvgkaXSKpVv8T^zKbte_2ohH6dY51)Dfn}mQjRP&2C!2M0{Y%3`!SrdpuMAgk=}v5} zFH$^5Z#70BSbPSOJGqoz@6^2GNT*3%Y}C{({_~-t1n;~T@eKyfadio}O<3~uVU@HJ zQ+s|V)oshDi@Yzk-q_93RU=G4F~;m^a8GlTCcO+_dn6MctdRWxAXwn`bc5hiR;oyS zW5#TUq4F0vQ7HJZaMQXeTGTc zL&+O+Nkp>US1BJR*6>)}f?4(wS?1{X87C5ZEn*@+iTk#wq)A>BMce zdPCt3ES2*HD6^yX82njCf5=)hi@FWmSP_LA3BsL*@ww3`HWVXK*{_C7eWw0*Y}D zedKPLx+jzl##hCdNu~2WemEQvGh-k$DCA}HSb*c8AS|8n|9>>ZfULb0Q8$#^I$^c$G?G#IZQc)YtfE(6KE-|AT91J77k>y=3 zSkX^GCb^W(;=#L84Nnw$t!|~W{-gmu{X~rxmBSqcHy13~gULCGh>fR(b$+^;md}CM zLcSF8LbmJc4?(g<(^qqh=e&Liu#u&r^v!r!N~)gZs91WW)l2p45sh3W3#p*-6}9Qo z>#$0WaRr9ySB>}4S+>kQZ1(488x?hoS~8B=%xfY(IKH6z)89Xb$8sqn*vhSUDOIpG z4Vq=fnxn58C=NiSKu^1MpJC5^qE+=^bhw<>z-+DW3K{!>l`?7(&0f>G_q}Mwdd5|H z3F-4|pJA#DTwk*0>O>q>qo#@W~bM>C&YMkC*$e;BkWAGVkSj z@0H3Y2eh#V?w*l@+l1`46DM-xln$!9S+?YNIb0qtT+sC!wc}X7CMs!DF~CE(aV(`4Pf#5o!HO4 zPl)*Jin)IospphtSA5CVLp&8J4tB$s>2P954n}I{oZqSx6MTzMz zhoLz$dnaD?R$pKPdt+O!xZbzr`}JzCvmU2RLrvuSR!W+z@)c{ey z#LLRvv!8A5Cj}(%V18BC(Vp$r#ZEbNy@(>dzEV21chWtRGoSWtw(?ZR3tam=Tl%>t zl+{J#7u@b@tEYCMh}gB6qPvV*j;iQ(_Eu0=e7maOEQX#A>$SR+#K~`DpF6o5Ey^Av zW%V0QU41d$y;%Tf^kRoLL}+lfj9cZ4?@CzVlv>Nk26K41He^~7L8x!e({i3sM(vBQ zG!=Y!&}5%v)}KXT!07zFPp1Zp_wepNgDC?>XJ97`85CD%^N@wpW&?Nsv+Ff9e_K#%Cr88@v-PbOHE z9QeS(4eUa89G3wmF`J%89Z2)QMPBwVN#A!zZ-nYoj&vwr=`wf|8Y2J2MKl87waNY7 z8UXrlt?NqY_+(0pKUvsg*)-{?^_L7dq^%Fd2LAqr(AD%OH>#}Pz*3Eaz1XmURm9fB z+L`gB^oR`d(J4kn|I{&1Z(5GZa0-vTunL=(m`!d2vsy@GD*Syz`vchp@+`_^x4{rC zSGW3G#p4I@4((kg)ShB$#c-ytzG`epQ)R`&^MmqJ)dPvCor^!&M4FFu9+p0M7Va| z$gpj$SUKJEp^@w}l?D?Azjw&2{{7|XbK7WFr}ZqH zoY1i5lZCWHiXW0Kq^r(koV2!`nD%Jk-fYlKk<4xJDtat<>-P_Xms%`#%U_Tm2?`-j z6eFV^2R|5r#2<#EjtLM% zwFGL8Km@$h(E_d4z=C(uvkrV!_%9eV+YoA9TVN!tPgjh{Orl5&%=JFq0+x|vbQq%8 z*^1ZG8{m=F=5TupACNMz`v5)|~3z+Aa9Y54h*|=M^NVs%YaUS-mxC=-}j^ ziF_-tvdig^Rs7!j=y-^70oy0}Dt&#w$>U|vvz@gnvx}oRK#5a`H#wODG`|?ZJUdnT zK^pUxvbOhv5?aSO*mmE`0*UY<>2!76J9kj=$ld4sIMfzB@ zdL|-`GD?bE?K5}Sb|^8m=G%=@n0`!Rws_CyTgDeQbLYixe?pd>mp=wUQs#_@o;6Y9MlzzDVc}+T64uu{K4nku)QwxXlLN zmTzu&C4K(()&~PuA8JbsfQK=|TO^dPq*sySJXaEITa_$s5({QsTYMweTkYJ58D`Kb zM`-x^er{2FFr#8#WgLU|EiI?1HbO!3!QzGV?}=3g+FAeB&tgS04wfsbnvf`&#%? zUqlEbo}bn92b%b#q|w&4OMew?ndt~;zf^!km3F|&VmzP`ns~BT;@6d+k$Gy5a>%J^ zzi-MGHY1bF02;Mnf?9RM*!Bs_DsoC}bK5v&@bKRH>aQE>gOMSgiM;Y+Gq3cA+BlJ`+FUe0r%@jYDS_bo5!DLnO(dvN3BwT*tvDJB1!F4S*ZiP*qTzw z>VGb~#Cm>ZORf)x2lg)x1Eakg7z1C3zCT(tJ2f}21bjFwgu)1uG(#a*%^o5WS?llm@&x6s)HBHm9PIVGb`yBp_3bflmb znL(tl_QFvr5Jp0U(D_etisPA?6IO-FEo+W*H%zaIR@CDLKEcqs=9cZ2eYD#S)R<3X z8A$6d@!)Fp?wk6=AB-Ts(`rrMf!@va+eAi;ud;VmS%YdE z@U$vg6r;j_n+JeAgrTNJ4?|{*{#SG~b^Fq2w0wYOU1VUhocr|fzwv|bXKrdLYU%wj z^29W$^6|gbKLb0AT^pja4NlG78*l!A6YGBDK;B+)a}!c)wqny$;@@(KtbP+e;*fsK z#^z>WfWIlzW=Lr-1Mg~pi0v>Jq%Vx) zsZ8Ybry|zj-;OId+jcQ^bWGU84IAHCChVc7I`=PR=R8~t8w^%%_HFk*781FV+GqBF zwH-S_F9n32|qzAghwaC0M01gsijwr_2@(V8}`*-c(5kg>Zr zsiSB_Sj0BO(akE=dx9U7z+W~6cAt9qX5G{_ z7!nMa!bLV|sL2NTYy567bK2t^S~zkI?kb77Rhm0;nKEXgmvFocP?5VkG{blU-)!eO z71?}LRY@l7`fUc=f2;~a-#GW+JZdUwOcgyRfBk%2p+N)SdjY5Lyja?VuEwFG;hx7K zl)vioo%`XgqL_N1_tz+U?nSafM?s2#JKh{-DJry2rD%(^J(_uNeLBim@Yc34Ct+j zRv1g?&g%18SnDSM1&_Jd<;>gEC*6-!8%v-#!?|{$LqM=+mU^QR6DB&eRdf7j|Nq_a28kD58?S z3B7uj{HToA`zzGbN2KsFcx==S{1Jxme4Lot0^pdkX&bjs8T=q?mi~>0eKA2ihQFsG z>3IWtwYS{Af)luMSHa#Ie8?F9rCMFMy5`|d_Py#wuDk*sd#Viiz&QDT%aD~a4kru- z8{aqhJyN~0f2hIbe-NNSc($jCq~>ZZn-uBfY5gui;x$Bm3=&CJ`d3v1Mzp!{k*<2Fq-CJjjBj}oT^BBj z$yfWC2$qGUO>RfH$3D|D(qI|Us)8y}K3J@O3vJvDtTCd7U)RW7;!!u4Vr|QXbG%j0 zvqmaI+PWlQ0FTVfX5|Ma!0-74y78=KD1A3l)eBK=UDw?bb{oEBQOf^X`Fbomrn$%Ii!~J%Xdyb$ zHZ~SgyJFsnXiW&J^EBx^f?m5+6kYVrz%7tm=RJ)7s!W?qT_tqlaAmZdIeA~B_n&$3 zyfd5k3D=I|0tVCK;>biZ2r`FS!}gz6EU*UKh`t-F2coVVSF!PXn&h-^@Z(2EMwoA~ zHx@vu+DHXq+XbH&p2bVm^#G$A{|rA2^MR{Z7DgD{bIOW+e0AQwnhdRFXG?Z?9EH55 z;W8h0>6_m`4;w~T#TrasV_%@Pel&s!jn8TBM4T*k2eZqlUkuo_HpQoAh~e@Ygs}B{ zoCGG}&GA~iHo;=eaoVRx7o`0FQ>|a~trsMc@!7wAKMJ-5&;I2Z@J6_)p-ywBdoVcP zxb$vVIXmAkl9UU0OGy?SvO~+UMWPqq1d%6ZvO@_D02; zncYyFVWS55W?W-MgbV%6OEp>OlYQX~BHb>O=GZFVeqKH_j|!HlsMPdeR5*}UYt2lH+; z*p8I@0|1GWL;Gf%u=GC@q%a=$ywcb<${f@D$`R^e^S?5STz6V4V69t@7VmE^1uFoD zC3$hRv4*%M;q20QZ=xe?Wjd5op7}B2EopO}w0=ahsc|^!O}&+0P7eD5r*#6_RgKYE z$YE+ch^KSc0*EvGTuhoaaAj*|n5~iP_imGB$Quea=8iS@;b$jfA|!+J2uN}#O#ZBp3yO7$DIw+N!ZG5 z`Z9bz(?fKs)k3E*wrEIB*LFl4?y=C?aJHMfuwAbb`MmDQk1qE?6^3QE$d$=Mm{RBo z@6S(F^v&bH)z008fM*GM22|$$I9iDk5*5Fvxt4VxUQn(1e137~OZOK^|7s64!@K-GRgTf|X)ADKR+Ux!ojZpSZ30BSs&-eA zf2;p{J@HQufd9uK*0bAvkXOICSzC!*RPWZ3&GmqN@*vyMcEl zwIO+kZxwi?gj!PZ4_2RjWq+Tq3$u&!)}3WVViGbejrkt6LX`Q==qCwV&27jD`^r%e z*uP>Z;!lPBn3Jo1#f4e_vbquzcf1<&8|>Mcmh9yBYpzkxo6A@i*3n;UYeVeai?=~_V*HqAQl=nWqziQdGWb|*_&UkfzMj=qvXj$_7;owXJaq%y=HFZDL`CNSqE@( zKV1zy;e!C@v=*ST2v2E$yKfu|0N#rzDa_Is4NCH>98Ifje00u%@xyh~#d7~U+u!)S z^-+gt>FM`>c$&bH1^DaEb)vg4&a#^8`JWiI!5U}YA(>;YQ?|0aT|PG`U~~1xl^T%W GqkjM)R4(oS literal 61877 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 Hu0mjflPm$m diff --git a/website/docs/manager_ftrack.md b/website/docs/manager_ftrack.md index 69faf6ae9d..511f5c04c0 100644 --- a/website/docs/manager_ftrack.md +++ b/website/docs/manager_ftrack.md @@ -31,7 +31,7 @@ This process is how data from Ftrack will get into Avalon database. ### How to synchronize You can do synchronization with [Sync To Avalon](manager_ftrack_actions#sync-to-avalon) action. -Synchronization can be automated with OpenPype's [event server](#event-server) and synchronization events. If your Ftrack is [prepared for OpenPype](#prepare-ftrack-for-pype), the project should have custom attribute `Avalon auto-sync`. Check the custom attribute to allow auto-updates with event server. +Synchronization can be automated with OpenPype's [event server](#event-server) and synchronization events. If your Ftrack is [prepared for OpenPype](#prepare-ftrack-for-openpype), the project should have custom attribute `Avalon auto-sync`. Check the custom attribute to allow auto-updates with event server. :::important Always use `Sync To Avalon` action before you enable `Avalon auto-sync`! diff --git a/website/docs/manager_ftrack_actions.md b/website/docs/manager_ftrack_actions.md index 6349d4357f..ce1c0466b9 100644 --- a/website/docs/manager_ftrack_actions.md +++ b/website/docs/manager_ftrack_actions.md @@ -28,6 +28,9 @@ In most cases actions filtered by entity type: So if you do not see action you need to use check if action is available for selected *entity type* or ask *administrator* to check if you have permissions to use it. + +Actions can be highly customized according to specific client's requests. + :::important Filtering can be more complicated for example a lot of actions can be shown only when one particular entity is selected. ::: @@ -39,33 +42,12 @@ Filtering can be more complicated for example a lot of actions can be shown only * Entity types: Task * User roles: All -These actions *launch application with OpenPype initiated* and *start timer* for the selected Task. We recommend you to launch application this way. +These actions *launch application with OpenPype * and *start timer* for the selected Task. We recommend you to launch application this way. :::important -Project Manager or Supervisor must set project's applications during project preparation otherwise you won't see them. +Project Manager or Supervisor must set project's applications during project preparation otherwise you won't see them. Applications can be added even if the project is in progress. ::: -### RV -* Entity types: All -* User roles: All - -You can launch RV player with playable components from selected entities. You can choose which components will be played. - -:::important -You must have RV player installed and licensed and have correct RV environments set to be able use this action. -::: - -### DJV View -* Entity types: Task, Asset Version -* User roles: All - -You can launch DJV View with one playable component from selected entities. You can choose which component will be played. - -:::important -You must have DJV View installed and configured in studio-config to be able use this action. -::: - ----

@@ -79,11 +61,11 @@ You must have DJV View installed and configured in studio-config to be able use A group of actions that are used for OpenPype Administration. -### Create/Update Avalon Attributes +### Create Update Avalon Attributes * Entity types: All * User roles: Pypeclub, Administrator -Action creates and updates Ftrack's Custom Attributes that are needed to manage and run OpenPype within Ftrack. Most of custom attribute configurations are stored in OpenPype presets (*Pype Settings → Project → Anatomy → Attributes*). It is not recommended to modify values stored in the file unless your studio used completely custom configuration. +Action creates and updates Ftrack's Custom Attributes that are needed to manage and run OpenPype within Ftrack. Most of custom attribute configurations are stored in OpenPype settings (*click on the systray OpenPype icon → Settings → Project → Anatomy → Attributes*). It is not recommended to modify values stored in the file unless your studio used completely custom configuration. ### Sync to Avalon * Entity types: Project, Typed Context @@ -91,8 +73,8 @@ Action creates and updates Ftrack's Custom Attributes that are needed to manage Synchronization to Avalon is key process to keep OpenPype data updated. Action updates selected entities (Project, Shot, Sequence, etc.) and all nested entities to Avalon database. If action is successfully finished [Sync Hier Attrs](#sync-hier-attrs) action is triggered. -There are 2 versions of **Sync to Avalon** first labeled as **server** second as **local**. -* **server** version will be processed with [event server](admin_ftrack#event-server) +There are 2 versions of **Sync to Avalon**, first labeled as **server** second as **local**. +* **server** version will be processed with [event server](module_ftrack#event-server) * **local** version will be processed with user's OpenPype tray application It is recommended to use **local** version if possible to avoid unnecessary deceleration of event server. @@ -101,11 +83,11 @@ It is recommended to use **local** version if possible to avoid unnecessary dece * Entity types: Project, Typed Context * User roles: Pypeclub, Administrator, Project manager -Synchronization to Avalon of Ftrack's hierarchical Custom attributes is a bit complicated so we decided to split synchronization process into 2 actions. This action updates hierarchical Custom attributes of selected entities (Project, Shot, Sequence, etc.) and all their nested entities to pipeline database. This action is also triggered automatically after successfully finished [Sync To Avalon](#sync-to-avalon) action. +Synchronization to Avalon of Ftrack's hierarchical Custom attributes is a bit complicated so we decided to split synchronization process into 2 actions. This action updates hierarchical Custom attributes of selected entities (Project, Shot, Sequence, etc.) and all their nested entities to pipeline database. This action is also triggered automatically after successfully finished [Sync To Avalon](#sync-to-avalon) action. There are 2 versions of **Sync Hier Attrs** first labeled as **server** second as **local**. -* **server** version will be processed with [event server](admin_ftrack#event-server) -* **local** version will be processed with user's OpenPype tray application +* **server** version will be processed with [event server](module_ftrack#event-server) +* **local** version will be processed with user's OpenPype application It is recommended to use **local** version if possible to avoid unnecessary deceleration of event server. @@ -127,26 +109,6 @@ With this action it's possible to delete up to 15 entities at once from active p
-## Thumbnail -
-
- -![thumbnail-icon](assets/ftrack/ftrack-thumbnail-icon.png) -
-
- -A group of actions for thumbnail management. - -### Thumbnail to Parent -Propagates the thumbnail of the selected entity to its parent. - -### Thumbnail to Children -Propagates the thumbnail of the selected entity to its first direct children entities. - ---- -
-
- ## Prepare Project
@@ -158,7 +120,7 @@ Propagates the thumbnail of the selected entity to its first direct children ent * Entity types: Project * User roles: Pypeclub, Administrator, Project manager -Allows project managers and coordinator to *set basic project attributes* needed for OpenPype to operate, *Create project folders* if you want and especially *prepare Project specific [anatomy](admin_config#anatomy) and [presets](admin_config#presets)* files for you. +Allows project managers and coordinator to *set basic project attributes* needed for OpenPype to operate, *Create project folders* if you want and especially prepare project specific [anatomy](admin_settings_project_anatomy) or [settings](admin_settings_project). :::tip It is possible to use this action during the lifetime of a project but we recommend using it only once at the start of the project. @@ -220,12 +182,30 @@ You should use this action if you need to delete Entities or Asset Versions othe *Create Project Structure* helps to create basic folder structure and may create the main ftrack entities for the project. -Structure is loaded from [presets](admin_config#presets) *(Pype Settings → Project → Global → Project Folder Structure)*. You should examine that preset to see how it works. Preset may contain dictionaries of nested dictionaries where each key represents a folder name. Key and all it's parents will be also created in Ftrack if the key ends with `[ftrack]`. Default Ftrack entity type is *Folder* but entity type can be specified using `[ftrack.{entity type}]`. To create *Sequence* with name *Seq_001* key should look like `Seq_001[ftrack.Sequence]`. +Structure is loaded from settings *(OpenPype Settings → Project → Global → Project Folder Structure)*. You should examine these settings to see how it works. Settings may contain dictionaries of nested dictionaries where each key represents a folder name. Key and all it's parents will be also created in Ftrack if the key ends with `[ftrack]`. Default Ftrack entity type is *Folder* but entity type can be specified using `[ftrack.{entity type}]`. To create *Sequence* with name *Seq_001* key should look like `Seq_001[ftrack.Sequence]`. :::note Please keep in mind this action is meant to make your project setup faster at the very beginning, but it does not create folders for each shot and asset. For creating asset folder refer to `Create Folders` Action ::: +--- +
+
+ +## Delivery +
+
+ +![ftrack-delivery-icon](assets/ftrack/ftrack-delivery-icon.png) +
+
+ +* Entity types: Task +* User roles: Pypeclub, Project manager, Administrator + +Collects approved hires files and copy them into a folder. It usually creates h.264 files for preview and mov for editorial. All files are then copied according to predefined naming convention to a specific folder. + + ---
@@ -241,21 +221,55 @@ Please keep in mind this action is meant to make your project setup faster at th * Entity types: Typed Context, Task * User roles: All -Creates folders for a selected asset in based on project templates. - It is usually not necessary to launch this action because folders are created automatically every time you start working on a task. However it can be handy if you need to create folders before any work begins or you want to use applications that don't have pipeline implementation. - - - ---
+## Thumbnail +
+
+ +![thumbnail-icon](assets/ftrack/ftrack-thumbnail-icon.png) +
+
+ +A group of actions for thumbnail management. + +### Thumbnail to Parent +Propagates the thumbnail of the selected entity to its parent. + +### Thumbnail to Children +Propagates the thumbnail of the selected entity to its first direct children entities. + +--- +### RV +* Entity types: All +* User roles: All + +You can launch RV player with playable components from selected entities. You can choose which components will be played. + +:::important +You must have RV player installed and licensed and have correct RV environments set to be able use this action. +::: + +--- +### DJV View +* Entity types: Task, Asset Version +* User roles: All + +You can launch DJV View with one playable component from selected entities. You can choose which component will be played. + +:::important +You must have DJV View installed and configured in studio-config to be able use this action. +::: + + +
+
+ +--- ## Open File
diff --git a/website/docs/module_ftrack.md b/website/docs/module_ftrack.md index 436594b64c..9f31eac21e 100644 --- a/website/docs/module_ftrack.md +++ b/website/docs/module_ftrack.md @@ -8,38 +8,33 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -Ftrack is currently the main project management option for Pype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](http://ftrack.rtd.ftrack.com/en/stable/). +Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](http://ftrack.rtd.ftrack.com/en/stable/). ## Prepare Ftrack for OpenPype ### Server URL -If you want to connect Ftrack to OpenPype you might need to make few changes in Ftrack settings. These changes would take a long time to do manually, so we prepared a few Ftrack actions to help you out. First, you'll need to launch OpenPype settings, enable [Ftrack module](admin_settings_system#Ftrack), and enter the address to your ftrack server. +If you want to connect Ftrack to OpenPype you might need to make few changes in Ftrack settings. These changes would take a long time to do manually, so we prepared a few Ftrack actions to help you out. First, you'll need to launch OpenPype settings, enable [Ftrack module](admin_settings_system#Ftrack), and enter the address to your Ftrack server. ### Login -Once your server is configured, restart OpenPype and you should be prompted to enter your [Ftrack credentials](#credentials) to be able to run our Ftrack actions. If you are already logged in to ftrack in your browser, it is enough to press `ftrack login` and it will connect automatically. +Once your server is configured, restart OpenPype and you should be prompted to enter your [Ftrack credentials](artist_ftrack#How-to-use-Ftrack-in-OpenPype) to be able to run our Ftrack actions. If you are already logged in to Ftrack in your browser, it is enough to press `Ftrack login` and it will connect automatically. -For more details step by step on how to login to ftrack in OpenPype to go [artist ftrack login](#artist_ftrack#first-use-best-case-scenario) documentation. +For more details step by step on how to login to Ftrack in OpenPype to go [artist Ftrack login](artist_ftrack#How-to-use-Ftrack-in-OpenPype) documentation. -You can only use our Ftrack Actions and publish to ftrack if each artist is logged in. +You can only use our Ftrack Actions and publish to Ftrack if each artist is logged in. ### Custom Attributes -After successfully connecting OpenPype with you ftrack, you can right on any project in ftrack and you should see a bunch of actions available. The most important one is called `OpenPype Admin` and contains multiple options inside. - -To prepare ftrack for working with OpenPype you'll need to run [OpenPype Admin - Create/Update Avalon Attributes](manager_ftrack_actions#create-update-avalon-attributes), which creates and sets the Custom Attributes necessary for OpenPype to function. - - +After successfully connecting OpenPype with you Ftrack, you can right on any project in Ftrack and you should see a bunch of actions available. The most important one is called `OpenPype Admin` and contains multiple options inside. +To prepare Ftrack for working with OpenPype you'll need to run [OpenPype Admin - Create/Update Avalon Attributes](manager_ftrack_actions#create-update-avalon-attributes), which creates and sets the Custom Attributes necessary for OpenPype to function. ## Event Server - Ftrack Event Server is the key to automation of many tasks like _status change_, _thumbnail update_, _automatic synchronization to Avalon database_ and many more. Event server should run at all times to perform the required processing as it is not possible to catch some of them retrospectively with enough certainty. ### Running event server - -There are specific launch arguments for event server. With `openpype eventserver` you can launch event server but without prior preparation it will terminate immediately. The reason is that event server requires 3 pieces of information: _Ftrack server url_, _paths to events_ and _Credentials (Username and API key)_. Ftrack server URL and Event path are set from OpenPype's environments by default, but the credentials must be done separatelly for security reasons. +There are specific launch arguments for event server. With `openpype eventserver` you can launch event server but without prior preparation it will terminate immediately. The reason is that event server requires 3 pieces of information: _Ftrack server url_, _paths to events_ and _credentials (Username and API key)_. Ftrack server URL and Event path are set from OpenPype's environments by default, but the credentials must be done separatelly for security reasons. @@ -61,7 +56,7 @@ There are specific launch arguments for event server. With `openpype eventserver - `--ftrack-url "https://yourdomain.ftrackapp.com/"` : Ftrack server URL _(it is not needed to enter if you have set `FTRACK_SERVER` in OpenPype' environments)_ - `--ftrack-events-path "//Paths/To/Events/"` : Paths to events folder. May contain multiple paths separated by `;`. _(it is not needed to enter if you have set `FTRACK_EVENTS_PATH` in OpenPype' environments)_ -So if you want to use OpenPype's environments then you can launch event server for first time with these arguments `$PYPE_SETUP/pype eventserver --ftrack-user "my.username" --ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee" --store-credentials`. Since that time, if everything was entered correctly, you can launch event server with `$PYPE_SETUP/pype eventserver`. +So if you want to use OpenPype's environments then you can launch event server for first time with these arguments `$OPENPYPE_SETUP/openpype eventserver --ftrack-user "my.username" --ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee" --store-credentials`. Since that time, if everything was entered correctly, you can launch event server with `$OPENPYPE_SETUP/openpype eventserver`. @@ -77,12 +72,12 @@ So if you want to use OpenPype's environments then you can launch event server f ::: :::caution -We do not recommend setting your ftrack user and api key environments in a persistent way, for security reasons. Option 1. passing them as arguments is substantially safer. +We do not recommend setting your Ftrack user and api key environments in a persistent way, for security reasons. Option 1. passing them as arguments is substantially safer. ::: ### Where to run event server -We recommend you to run event server on stable server machine with ability to connect to Avalon database and Ftrack web server. Best practice we recommend is to run event server as service. +We recommend you to run event server on stable server machine with ability to connect to Avalon database and Ftrack web server. Best practice we recommend is to run event server as service. It can be Windows or Linux. :::important Event server should **not** run more than once! It may cause major issues. @@ -91,34 +86,40 @@ Event server should **not** run more than once! It may cause major issues. ### Which user to use - must have at least `Administrator` role -- same user should not be used by an artist +- the same user should not be used by an artist -### Run Linux service - step by step -1. create file: +:::note How to create Eventserver service + + + + +- create file: `sudo vi /opt/OpenPype/run_event_server.sh` - -2. add content to the file: - +- add content to the file: ```sh -export PYPE_DEBUG=3 -pushd /mnt/pipeline/prod/pype-setup -. pype eventserver --ftrack-user --ftrack-api-key +#!\usr\bin\env +export OPENPYPE_DEBUG=3 +pushd /mnt/pipeline/prod/openpype-setup +. openpype eventserver --ftrack-user --ftrack-api-key ``` - -3. create service file: +- create service file: `sudo vi /etc/systemd/system/openpype-ftrack-event-server.service` - -4. add content to the service file +- add content to the service file ```toml [Unit] -Description=Run Pype Ftrack Event Server Service +Description=Run OpenPype Ftrack Event Server Service After=network.target [Service] Type=idle -ExecStart=/opt/pype/run_event_server.sh +ExecStart=/opt/openpype/run_event_server.sh Restart=on-failure RestartSec=10s @@ -126,34 +127,42 @@ RestartSec=10s WantedBy=multi-user.target ``` -5. change file permission: +- change file permission: `sudo chmod 0755 /etc/systemd/system/openpype-ftrack-event-server.service` -6. enable service: +- enable service: `sudo systemctl enable openpype-ftrack-event-server` -7. start service: +- start service: `sudo systemctl start openpype-ftrack-event-server` + + + +- create service file: `openpype-ftrack-eventserver.bat` +- add content to the service file: +```sh +@echo off +set OPENPYPE_DEBUG=3 +pushd \\path\to\file\ +call openpype.bat eventserver --ftrack-user --ftrack-api-key +``` +- download and install `nssm.cc` +- create Windows service according to nssm.cc manual +- you can also run eventserver as a standard Schedule task +- be aware of using UNC path + + + +::: + * * * ## Ftrack events -Events are helpers for automation. They react to Ftrack Web Server events like change entity attribute, create of entity, etc. . +Events are helpers for automation. They react to Ftrack Web Server events like change entity attribute, create of entity, etc. -### Delete Avalon ID from new entity _(DelAvalonIdFromNew)_ - -Is used to remove value from `Avalon/Mongo Id` Custom Attribute when entity is created. - -`Avalon/Mongo Id` Custom Attribute stores id of synchronized entities in pipeline database. When user _Copy -> Paste_ selection of entities to create similar hierarchy entities, values from Custom Attributes are copied too. That causes issues during synchronization because there are multiple entities with same value of `Avalon/Mongo Id`. To avoid this error we preventively remove these values when entity is created. - -### Next Task update _(NextTaskUpdate)_ - -Change status of next task from `Not started` to `Ready` when previous task is approved. - -Multiple detailed rules for next task update can be configured in the presets. - -### Synchronization to Avalon database _(Sync_to_Avalon)_ +### Sync to Avalon Automatic [synchronization to pipeline database](manager_ftrack#synchronization-to-avalon-database). @@ -163,33 +172,45 @@ This event updates entities on their changes Ftrack. When new entity is created Deleting an entity by Ftrack's default is not processed for security reasons _(to delete entity use [Delete Asset/Subset action](manager_ftrack_actions#delete-asset-subset))_. ::: -### Synchronize hierarchical attributes _(SyncHierarchicalAttrs)_ +### Synchronize Hierarchical and Entity Attributes Auto-synchronization of hierarchical attributes from Ftrack entities. -Related to [Synchronize to Avalon database](#synchronization-to-avalon-database) event _(without it, it makes no sense to use this event)_. Hierarchical attributes must be synchronized with special way so we needed to split synchronization into 2 parts. There are [synchronization rules](manager_ftrack#synchronization-rules) for hierarchical attributes that must be met otherwise interface with messages about not meeting conditions is shown to user. +Related to [Synchronize to Avalon database](manager_ftrack#synchronization-to-avalon-database) event _(without it, it makes no sense to use this event)_. Hierarchical attributes must be synchronized with special way so we needed to split synchronization into 2 parts. There are [synchronization rules](manager_ftrack#synchronization-rules) for hierarchical attributes that must be met otherwise interface with messages about not meeting conditions is shown to user. -### Thumbnails update _(ThumbnailEvents)_ +### Update Hierarchy thumbnails -Updates thumbnail of Task and it's parent when new Asset Version with thumbnail is created. +Push thumbnails from version, up through multiple hierarchy levels -This is normally done by Ftrack Web server when Asset Version is created with Drag&Drop but not when created with Ftrack API. +### Update status on task action -### Version to Task status _(VersionToTaskStatus)_ +Change status of next task from `Not started` to `Ready` when previous task is approved. -Updates Task status based on status changes on it's `AssetVersion`. +Multiple detailed rules for next task update can be configured in the presets. + +### Delete Avalon ID from new entity + +Is used to remove value from `Avalon/Mongo Id` Custom Attribute when entity is created. + +`Avalon/Mongo Id` Custom Attribute stores id of synchronized entities in pipeline database. When user _Copy → Paste_ selection of entities to create similar hierarchy entities, values from Custom Attributes are copied too. That causes issues during synchronization because there are multiple entities with same value of `Avalon/Mongo Id`. To avoid this error we preventively remove these values when entity is created. + +### Sync status from Task to Parent + +List of parent boject types where this is triggered ("Shot", "Asset build", etc. Skipped if it is empty) + +### Sync status from Version to Task + +Updates Task status based on status changes on its Asset Version. The issue this solves is when Asset version's status is changed but the artist assigned to Task is looking at the task status, thus not noticing the review. This event makes sure statuses Asset Version get synced to it's task. After changing a status on version, this event first tries to set identical status to version's parent (usually task). But this behavior can be tweaked in settings. +### Sync status on first created version -### Update First Version status _(FirstVersionStatus)_ - -This event handler allows setting of different status to a first created Asset Version in ftrack. +This event handler allows setting of different status to a first created Asset Version in Ftrack. This is usefull for example if first version publish doesn't contain any actual reviewable work, but is only used for roundtrip conform check, in which case this version could receive status `pending conform` instead of standard `pending review` -Behavior can be filtered by `name` or `type` of the task assigned to the Asset Version. Configuration can be found in [ftrack presets](admin_presets_ftrack#first_version_status-dict) - -* * * +### Update status on next task +Change status on next task by task types order when task status state changed to "Done". All tasks with the same Task mapping of next task status changes From → To. Some status can be ignored. From 92ae62004d06efd48f87d368a33190cd2ff2f9a9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Apr 2021 11:12:18 +0200 Subject: [PATCH 035/515] Hiero, Global: fixing after transition merge --- openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py | 2 +- openpype/hosts/hiero/startup/Python/Startup/Startup.py | 2 +- openpype/lib/plugin_tools.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py b/openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py index 39387578d2..21e12e89fa 100644 --- a/openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py +++ b/openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py @@ -5,7 +5,7 @@ class CollectFrameRanges(pyblish.api.InstancePlugin): """ Collect all framranges. """ - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder - 0.1 label = "Collect Frame Ranges" hosts = ["hiero"] families = ["clip", "effect"] diff --git a/openpype/hosts/hiero/startup/Python/Startup/Startup.py b/openpype/hosts/hiero/startup/Python/Startup/Startup.py index 8de2dc2d11..21c21cd7c3 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/Startup.py +++ b/openpype/hosts/hiero/startup/Python/Startup/Startup.py @@ -6,7 +6,7 @@ import openpype.hosts.hiero.api as phiero avalon.api.install(phiero) try: - __import__("pype.hosts.hiero.api") + __import__("openpype.hosts.hiero.api") __import__("pyblish") except ImportError as e: diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index eb024383d3..5c52088493 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -127,7 +127,7 @@ def filter_pyblish_plugins(plugins): plugin_kind = file.split(os.path.sep)[-2:-1][0] # TODO: change after all plugins are moved one level up - if host_from_file == "pype": + if host_from_file == "openpype": host_from_file = "global" try: From 8b157de9c3d1b13458593f1337940f053e89a4f7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Apr 2021 12:17:36 +0200 Subject: [PATCH 036/515] Hiero: fixing AttributeError when nothing selected or wrong context of selection --- openpype/hosts/hiero/api/lib.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index b74e70cae3..286aba13e9 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -150,15 +150,27 @@ def get_track_items( # get selected track items or all in active sequence if selected: - selected_items = list(hiero.selection) - for item in selected_items: - if track_name and track_name in item.parent().name(): - # filter only items fitting input track name - track_items.append(item) - elif not track_name: - # or add all if no track_name was defined - track_items.append(item) - else: + try: + selected_items = list(hiero.selection) + for item in selected_items: + if track_name and track_name in item.parent().name(): + # filter only items fitting input track name + track_items.append(item) + elif not track_name: + # or add all if no track_name was defined + track_items.append(item) + except AttributeError: + pass + + # check if any collected track items are + # `core.Hiero.Python.TrackItem` instance + if track_items: + any_track_item = track_items.pop() + if not isinstance(any_track_item, hiero.core.TrackItem): + selected_items = [] + + # collect all available active sequence track items + if not track_items: sequence = get_current_sequence(name=sequence_name) # get all available tracks from sequence tracks = list(sequence.audioTracks()) + list(sequence.videoTracks()) From f3c24ac0d271816f904b6466e5fb91171177c941 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 7 Apr 2021 13:10:29 +0200 Subject: [PATCH 037/515] Hiero: Creator minimum value to 0 --- openpype/hosts/hiero/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/api/plugin.py b/openpype/hosts/hiero/api/plugin.py index b356c9b6ce..9b3bc25d80 100644 --- a/openpype/hosts/hiero/api/plugin.py +++ b/openpype/hosts/hiero/api/plugin.py @@ -266,7 +266,7 @@ class CreatorWidget(QtWidgets.QDialog): elif v["type"] == "QSpinBox": data[k]["value"] = self.create_row( content_layout, "QSpinBox", v["label"], - setValue=v["value"], setMinimum=1, + setValue=v["value"], setMinimum=0, setMaximum=100000, setToolTip=tool_tip) return data From e06ab4b7091be854aad9e006bccf9033cceb9d82 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 7 Apr 2021 14:46:18 +0200 Subject: [PATCH 038/515] some small grammar fixes --- website/docs/artist_ftrack.md | 36 ++++++++++++------- website/docs/assets/ftrack/ftrack_logout.gif | Bin 0 -> 41717 bytes website/docs/manager_ftrack.md | 8 ++--- 3 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 website/docs/assets/ftrack/ftrack_logout.gif diff --git a/website/docs/artist_ftrack.md b/website/docs/artist_ftrack.md index df2a7236b3..44d8b0c007 100644 --- a/website/docs/artist_ftrack.md +++ b/website/docs/artist_ftrack.md @@ -8,8 +8,7 @@ sidebar_label: Artist ## Login to Ftrack module in OpenPype (best case scenario) 1. Launch OpenPype and go to systray OpenPype icon. -2. *Ftrack login* window pop up on start - - or press **login** in **Ftrack menu** to pop up *Ftrack login* window +2. *Ftrack login* window pop up on start or press **login** in **Ftrack menu** to pop up *Ftrack login* window ![ftrack-login-2](assets/ftrack/ftrack-login_50.png) @@ -18,7 +17,7 @@ sidebar_label: Artist ![Login widget](assets/ftrack/ftrack-login_1.png) - Web browser opens - - Sign in Ftrack if you're requested. If you are already sign up to Ftrack via web browser, you can jump to point 6. + - Sign into Ftrack if requested. If you are already signed in to Ftrack via web browser, you can jump to [Application launch](#application-launch-best-case-scenario) ![ftrack-login-2](assets/ftrack/ftrack-login_2.png) @@ -34,18 +33,18 @@ sidebar_label: Artist ## Application launch (best case scenario) 1. Make sure OpenPype is running and you passed [Login to Ftrack](#login-to-ftrack-module-in-openpype-best-case-scenario) guide -2. Open web browser and go to your studio Ftrack web page *(e.g. https://mystudio.ftrackapp.com/)* +2. Open Web browser and go to your studio Ftrack web page *(e.g. https://mystudio.ftrackapp.com/)* -3. Locate the task on which you want to run the application +3. Locate the task to run the application on. 4. Display actions for the task ![ftrack-login-3](assets/ftrack/ftrack-login_60.png) 5. Select application you want to launch - - application versions may be grouped to one action in that case press the action to reveal versions to choose *(like Maya in the picture)*, only applications permitted to the particular project are appeared. + - application versions may be grouped to one action. In that case, press the action to reveal versions to choose from *(like Maya in the picture)*, only applications permitted on the particular project will appear. ![ftrack-login-3](assets/ftrack/ftrack-login_71-small.png) -6. Work +6. Start working ;) --- @@ -54,9 +53,22 @@ sidebar_label: Artist ![ftrack-login-3](assets/ftrack/ftrack-login_80-small.png) -2. Log out the previous user from Ftrack module in tray +2. Log out the previous user from Ftrack module in OpenPype tray - ![ftrack-login-3](assets/ftrack/ftrack-login_81.png) +
+
+ +![ftrack-login-3](assets/ftrack/ftrack_logout.gif) + +
+
+ +![ftrack-login-3](assets/ftrack/ftrack-login_81.png) + +
+
+ +

3. Follow [Login to Ftrack](#login-to-ftrack-module-in-openpype-best-case-scenario) guide @@ -64,6 +76,7 @@ sidebar_label: Artist ## Where to find API key - Your API key can be found in Ftrack. In the upper right corner of Ftrack click on the avatar, choose System settings. +- You shouldn't need to use your personal API key if previous steps went through correctly ![ftrack-api](assets/ftrack/ftrack-api.png) @@ -92,8 +105,7 @@ sidebar_label: Artist - try to restart OpenPype **2. possibility - Ftrack is not set in OpenPype** -- inform your administrator -- The Ftrack URL can be changed in OpenPype Settings → System → Modules → Ftrack +- inform your administrator or supervisor ### Web browser did not open @@ -119,5 +131,5 @@ sidebar_label: Artist **3. possibility - User logged to Ftrack Web is not the same as user logged to Ftrack module in tray** - Follow [Change user](#change-ftrack-user) guide -**4. possibility - Project don't have set applications** +**4. possibility - Project doesn't have applications set correctly** - ask your Project Manager to check if he set applications for the project diff --git a/website/docs/assets/ftrack/ftrack_logout.gif b/website/docs/assets/ftrack/ftrack_logout.gif new file mode 100644 index 0000000000000000000000000000000000000000..81088075c4af1bb829948366cc179a61879587b8 GIT binary patch literal 41717 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 Date: Wed, 7 Apr 2021 14:46:36 +0200 Subject: [PATCH 039/515] change action icon to new format (example) --- website/docs/manager_ftrack_actions.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/website/docs/manager_ftrack_actions.md b/website/docs/manager_ftrack_actions.md index ce1c0466b9..ad5bfe3a9c 100644 --- a/website/docs/manager_ftrack_actions.md +++ b/website/docs/manager_ftrack_actions.md @@ -48,18 +48,22 @@ These actions *launch application with OpenPype * and *start timer* for the sele Project Manager or Supervisor must set project's applications during project preparation otherwise you won't see them. Applications can be added even if the project is in progress. ::: -
-
-## OpenPype Admin -
-
+
+
![pype_admin-icon](assets/ftrack/ftrack-pype_admin-icon.png) + +
+
+ +## OpenPype Admin +
-A group of actions that are used for OpenPype Administration. + +#### A group of actions that are used for OpenPype Administration. ### Create Update Avalon Attributes * Entity types: All From 403a3149cc03c00f9dbe8e4ffec12c5412a9c68a Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 7 Apr 2021 14:51:22 +0200 Subject: [PATCH 040/515] flip title and icon --- website/docs/manager_ftrack_actions.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/docs/manager_ftrack_actions.md b/website/docs/manager_ftrack_actions.md index ad5bfe3a9c..968ad170ad 100644 --- a/website/docs/manager_ftrack_actions.md +++ b/website/docs/manager_ftrack_actions.md @@ -50,15 +50,15 @@ Project Manager or Supervisor must set project's applications during project pre
-
- -![pype_admin-icon](assets/ftrack/ftrack-pype_admin-icon.png) - -
-
+
## OpenPype Admin +
+
+ +![pype_admin-icon](assets/ftrack/ftrack-pype_admin-icon.png) +
From d1a55e39230590e729ec71370e63c21b05d0e680 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 7 Apr 2021 15:31:59 +0200 Subject: [PATCH 041/515] add openpype_root to pythonpath --- start.py | 1 + 1 file changed, 1 insertion(+) diff --git a/start.py b/start.py index 1f946a705c..23f019ab76 100644 --- a/start.py +++ b/start.py @@ -326,6 +326,7 @@ def _initialize_environment(openpype_version: OpenPypeVersion) -> None: # TODO move additional paths to `boot` part when OPENPYPE_ROOT will point # to same hierarchy from code and from frozen OpenPype additional_paths = [ + os.environ["OPENPYPE_ROOT"], # add OpenPype tools os.path.join(os.environ["OPENPYPE_ROOT"], "openpype", "tools"), # add common OpenPype vendor From 9e3d02ca8f00c0aa38d0b42e5045967df39c46aa Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 7 Apr 2021 15:36:42 +0200 Subject: [PATCH 042/515] remove comma from build workflow --- .github/workflows/test_build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_build.yml b/.github/workflows/test_build.yml index e17d75c453..6e1e38d0b2 100644 --- a/.github/workflows/test_build.yml +++ b/.github/workflows/test_build.yml @@ -8,7 +8,7 @@ on: branches: [develop] types: [review_requested, ready_for_review] paths-ignore: - - 'docs/**', + - 'docs/**' - 'website/**' - 'vendor/**' From 2e1548b0b37705322a648af46f37d5681f0df726 Mon Sep 17 00:00:00 2001 From: mkolar Date: Wed, 7 Apr 2021 14:40:05 +0000 Subject: [PATCH 043/515] Create draft PR for #1173 From e0eeec5e456cdc43621e405a62e03eca7aa12e87 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 16:44:57 +0200 Subject: [PATCH 044/515] SyncServer - fix typo resulting in infinitive recursion --- openpype/modules/sync_server/tray/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 41a0f84afb..63103a6f0b 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -1604,7 +1604,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): @property def project(self): """Returns project""" - return self.project + return self._project def tick(self): """ From 1c4d1f0346fe190c8b3c3deae13723d7e66ba0e9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:04:12 +0200 Subject: [PATCH 045/515] SyncServer - fix wrong method for local site --- openpype/modules/sync_server/tray/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 63103a6f0b..a73d674b0c 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -507,7 +507,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): def _add_site(self): log.info(self.representation_id) project_name = self.table_view.model().project - local_site_name = self.sync_server.get_my_local_site() + local_site_name = get_local_site_id() try: self.sync_server.add_site( project_name, From 264025554eb440f9f0b7cc8569632a00bda384be Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:14:53 +0200 Subject: [PATCH 046/515] SyncServer - fix label from local to active --- openpype/modules/sync_server/tray/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index a73d674b0c..8a8ddc014a 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -1437,7 +1437,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): menu.addAction(action) if float(remote_progress) == 1.0: - action = QtWidgets.QAction("Reset local site") + action = QtWidgets.QAction("Reset active site") actions_mapping[action] = self._reset_local_site menu.addAction(action) From 8a341996f69fccc0b4ca3a7c009fa6517215de0b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:17:33 +0200 Subject: [PATCH 047/515] SyncServer - fix wrong commit in avalon-core --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index c3dc49184a..83f545b6b5 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit c3dc49184ab14e2590a51dde55695cf27ab23510 +Subproject commit 83f545b6b551c018f03e1a2c7abe3284ba843610 From cc832d0156bb5101c10fc2be31288030af70a5e0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:39:58 +0200 Subject: [PATCH 048/515] SyncServer - fix roots must have root key --- openpype/modules/sync_server/providers/abstract_provider.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 35dca87acf..2e6e97ebf9 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -145,10 +145,11 @@ class AbstractProvider: Returns: (string): proper url """ + if not root_config: + root_config = self.get_roots_config(anatomy) + if root_config and not root_config.get("root"): root_config = {"root": root_config} - else: - root_config = self.get_roots_config(anatomy) try: if not root_config: From 5a1889e413273bf3dbdbf2ee2d4eb4f080f397e2 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 7 Apr 2021 16:49:27 +0100 Subject: [PATCH 049/515] Fix for the workflow between Blender and Unreal for OpenPype 3.0 --- openpype/hooks/pre_python2_vendor.py | 2 +- openpype/hosts/blender/api/__init__.py | 13 +++++++++++-- openpype/hosts/blender/plugins/load/load_layout.py | 5 ++++- openpype/hosts/unreal/__init__.py | 0 .../hosts/unreal/hooks/pre_workfile_preparation.py | 8 ++++---- openpype/lib/avalon_context.py | 1 + .../ftrack/launch_hooks/pre_python2_vendor.py | 2 +- 7 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 openpype/hosts/unreal/__init__.py diff --git a/openpype/hooks/pre_python2_vendor.py b/openpype/hooks/pre_python2_vendor.py index 7aaf713dec..815682fef8 100644 --- a/openpype/hooks/pre_python2_vendor.py +++ b/openpype/hooks/pre_python2_vendor.py @@ -6,7 +6,7 @@ class PrePython2Vendor(PreLaunchHook): """Prepend python 2 dependencies for py2 hosts.""" # WARNING This hook will probably be deprecated in OpenPype 3 - kept for test order = 10 - app_groups = ["hiero", "nuke", "nukex"] + app_groups = ["hiero", "nuke", "nukex", "unreal"] def execute(self): # Prepare vendor dir path diff --git a/openpype/hosts/blender/api/__init__.py b/openpype/hosts/blender/api/__init__.py index 55c5b63f60..c5b0a44072 100644 --- a/openpype/hosts/blender/api/__init__.py +++ b/openpype/hosts/blender/api/__init__.py @@ -51,9 +51,18 @@ def set_start_end_frames(): "name": asset_name }) - bpy.context.scene.frame_start = asset_doc["data"]["frameStart"] - bpy.context.scene.frame_end = asset_doc["data"]["frameEnd"] + # Default frame start/end + frameStart = 0 + frameEnd = 100 + # Check if frameStart/frameEnd are set + if asset_doc["data"]["frameStart"]: + frameStart = asset_doc["data"]["frameStart"] + if asset_doc["data"]["frameEnd"]: + frameEnd = asset_doc["data"]["frameEnd"] + + bpy.context.scene.frame_start = frameStart + bpy.context.scene.frame_end = frameEnd def on_new(arg1, arg2): set_start_end_frames() diff --git a/openpype/hosts/blender/plugins/load/load_layout.py b/openpype/hosts/blender/plugins/load/load_layout.py index 73b12d8c25..f1f2fdcddd 100644 --- a/openpype/hosts/blender/plugins/load/load_layout.py +++ b/openpype/hosts/blender/plugins/load/load_layout.py @@ -292,6 +292,9 @@ class UnrealLayoutLoader(plugin.AssetLoader): icon = "code-fork" color = "orange" + animation_creator_name = "CreateAnimation" + setdress_creator_name = "CreateSetDress" + def _remove_objects(self, objects): for obj in list(objects): if obj.type == 'ARMATURE': @@ -368,7 +371,7 @@ class UnrealLayoutLoader(plugin.AssetLoader): location.get('z') ) obj.rotation_euler = ( - rotation.get('x'), + rotation.get('x') + math.pi / 2, -rotation.get('y'), -rotation.get('z') ) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 5945d0486b..c698be63de 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -23,8 +23,8 @@ class UnrealPrelaunchHook(PreLaunchHook): def execute(self): asset_name = self.data["asset_name"] task_name = self.data["task_name"] - workdir = self.env["AVALON_WORKDIR"] - engine_version = self.app_name.split("_")[-1] + workdir = self.launch_context.env["AVALON_WORKDIR"] + engine_version = self.app_name.split("_")[-1].replace("-", ".") unreal_project_name = f"{asset_name}_{task_name}" # Unreal is sensitive about project names longer then 20 chars @@ -81,8 +81,8 @@ class UnrealPrelaunchHook(PreLaunchHook): # Set "AVALON_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` env_key = "AVALON_UNREAL_PLUGIN" - if self.env.get(env_key): - os.environ[env_key] = self.env[env_key] + if self.launch_context.env.get(env_key): + os.environ[env_key] = self.launch_context.env[env_key] unreal_lib.create_unreal_project( unreal_project_name, diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 1f7c693b85..2d8726352a 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1123,6 +1123,7 @@ class BuildWorkfile: return output +@with_avalon def get_creator_by_name(creator_name, case_sensitive=False): """Find creator plugin by name. diff --git a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py index 7826d833ac..f14857bc98 100644 --- a/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py +++ b/openpype/modules/ftrack/launch_hooks/pre_python2_vendor.py @@ -9,7 +9,7 @@ class PrePython2Support(PreLaunchHook): Path to vendor modules is added to the beggining of PYTHONPATH. """ # There will be needed more granular filtering in future - app_groups = ["maya", "nuke", "nukex", "hiero", "nukestudio"] + app_groups = ["maya", "nuke", "nukex", "hiero", "nukestudio", "unreal"] def execute(self): # Prepare vendor dir path From 3627c91b8ae84bc87e206bd3f1c74f94fb28fecb Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Wed, 7 Apr 2021 22:25:10 +0200 Subject: [PATCH 050/515] Column width adjustment --- website/docs/manager_ftrack_actions.md | 73 ++++++++++++++++---------- 1 file changed, 44 insertions(+), 29 deletions(-) diff --git a/website/docs/manager_ftrack_actions.md b/website/docs/manager_ftrack_actions.md index 968ad170ad..950f667f9a 100644 --- a/website/docs/manager_ftrack_actions.md +++ b/website/docs/manager_ftrack_actions.md @@ -48,7 +48,7 @@ These actions *launch application with OpenPype * and *start timer* for the sele Project Manager or Supervisor must set project's applications during project preparation otherwise you won't see them. Applications can be added even if the project is in progress. ::: - +---
@@ -110,14 +110,16 @@ This action gives ability to *stop running jobs*. When action is triggered, an i With this action it's possible to delete up to 15 entities at once from active project in pipeline database. Entered names must match exactly the names stored in database. These entities also must not have children entities *(Sequence must not have Shots but Task is not entity)*. --- -
-
+
+
## Prepare Project +
-
+
![prepare_project-icon](assets/ftrack/ftrack-prepare_project-icon.png) +
@@ -133,14 +135,16 @@ It is possible to use this action during the lifetime of a project but we recomm ![prepare_project_1](assets/ftrack/ftrack-prepare_project_1-small.png) --- -
-
+
+
## Multiple Notes +
-
+
![multiple_notes-icon](assets/ftrack/ftrack-multiple_notes-icon.png) +
@@ -151,14 +155,16 @@ You can add same note to multiple Asset Versions at once with this action. ![multiple_notes_1](assets/ftrack/ftrack-multiple_notes_1-small.png) --- -
-
+
+
## Delete Asset/Subset +
-
+
![delete_asset-icon](assets/ftrack/ftrack-delete_asset-icon.png) +
@@ -170,14 +176,16 @@ Action deletes Entities and Asset Versions from Ftrack and Avalon database. You should use this action if you need to delete Entities or Asset Versions otherwise deletion will not take effect in Avalon database. Currently the action allows to only delete one entity at the time. Entity also must not have any children. --- -
-
+
+
## Create Project Structure +
-
+
![create_project_folders-icon](assets/ftrack/ftrack-create_project_folders-icon.png) +
@@ -193,14 +201,16 @@ Please keep in mind this action is meant to make your project setup faster at th ::: --- -
-
+
+
## Delivery +
-
+
![ftrack-delivery-icon](assets/ftrack/ftrack-delivery-icon.png) +
@@ -211,14 +221,16 @@ Collects approved hires files and copy them into a folder. It usually creates h. --- -
-
+
+
## Create Folders +
-
+
![create_folders-icon](assets/ftrack/ftrack-create_folders-icon.png) +
@@ -228,14 +240,16 @@ Collects approved hires files and copy them into a folder. It usually creates h. It is usually not necessary to launch this action because folders are created automatically every time you start working on a task. However it can be handy if you need to create folders before any work begins or you want to use applications that don't have pipeline implementation. --- -
-
+
+
## Thumbnail +
-
+
![thumbnail-icon](assets/ftrack/ftrack-thumbnail-icon.png) +
@@ -248,7 +262,7 @@ Propagates the thumbnail of the selected entity to its parent. Propagates the thumbnail of the selected entity to its first direct children entities. --- -### RV +## RV * Entity types: All * User roles: All @@ -259,7 +273,7 @@ You must have RV player installed and licensed and have correct RV environments ::: --- -### DJV View +## DJV View * Entity types: Task, Asset Version * User roles: All @@ -269,16 +283,17 @@ You can launch DJV View with one playable component from selected entities. You You must have DJV View installed and configured in studio-config to be able use this action. ::: - -
-
- --- +
+
+ ## Open File +
-
+
![component_open-icon](assets/ftrack/ftrack-component_open-icon.png) +
From 73abae2f265f604bce9c7ffa6b405eed3796beef Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 09:59:58 +0200 Subject: [PATCH 051/515] removed unused function --- openpype/modules/ftrack/lib/credentials.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 16b1fb25fb..6ca9b730da 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -113,13 +113,6 @@ def set_env(ft_user=None, ft_api_key=None): os.environ["FTRACK_API_KEY"] = ft_api_key or "" -def get_env_credentials(): - return ( - os.environ.get("FTRACK_API_USER"), - os.environ.get("FTRACK_API_KEY") - ) - - def check_credentials(ft_user, ft_api_key, ftrack_server=None): if not ftrack_server: ftrack_server = os.environ["FTRACK_SERVER"] From d1e047d40c1d604db61db0022d280db897961ca2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 10:32:46 +0200 Subject: [PATCH 052/515] moved one method from credentials.py to ftrack module --- openpype/modules/ftrack/ftrack_module.py | 4 ++++ openpype/modules/ftrack/lib/credentials.py | 5 ----- openpype/modules/ftrack/tray/ftrack_tray.py | 6 +++--- openpype/modules/ftrack/tray/login_dialog.py | 6 ++++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py index e1af9c15a7..cd383cbdc6 100644 --- a/openpype/modules/ftrack/ftrack_module.py +++ b/openpype/modules/ftrack/ftrack_module.py @@ -210,3 +210,7 @@ class FtrackModule( def tray_exit(self): return self.tray_module.stop_action_server() + + def set_credentials_to_env(self, username, api_key): + os.environ["FTRACK_API_USER"] = username or "" + os.environ["FTRACK_API_KEY"] = api_key or "" diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 6ca9b730da..3d9aa75e84 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -108,11 +108,6 @@ def clear_credentials(ft_user=None, ftrack_server=None, user=None): file.write(json.dumps(content_json)) -def set_env(ft_user=None, ft_api_key=None): - os.environ["FTRACK_API_USER"] = ft_user or "" - os.environ["FTRACK_API_KEY"] = ft_api_key or "" - - def check_credentials(ft_user, ft_api_key, ftrack_server=None): if not ftrack_server: ftrack_server = os.environ["FTRACK_SERVER"] diff --git a/openpype/modules/ftrack/tray/ftrack_tray.py b/openpype/modules/ftrack/tray/ftrack_tray.py index 9da5db835b..ee27d8b730 100644 --- a/openpype/modules/ftrack/tray/ftrack_tray.py +++ b/openpype/modules/ftrack/tray/ftrack_tray.py @@ -30,7 +30,7 @@ class FtrackTrayWrapper: self.bool_action_thread_running = False self.bool_timer_event = False - self.widget_login = login_dialog.CredentialsDialog() + self.widget_login = login_dialog.CredentialsDialog(module) self.widget_login.login_changed.connect(self.on_login_change) self.widget_login.logout_signal.connect(self.on_logout) @@ -56,7 +56,7 @@ class FtrackTrayWrapper: validation = credentials.check_credentials(ft_user, ft_api_key) if validation: self.widget_login.set_credentials(ft_user, ft_api_key) - credentials.set_env(ft_user, ft_api_key) + self.module.set_credentials_to_env(ft_user, ft_api_key) log.info("Connected to Ftrack successfully") self.on_login_change() @@ -337,7 +337,7 @@ class FtrackTrayWrapper: def changed_user(self): self.stop_action_server() - credentials.set_env() + self.module.set_credentials_to_env(None, None) self.validate() def start_timer_manager(self, data): diff --git a/openpype/modules/ftrack/tray/login_dialog.py b/openpype/modules/ftrack/tray/login_dialog.py index ca409ebcaa..ce91c6d012 100644 --- a/openpype/modules/ftrack/tray/login_dialog.py +++ b/openpype/modules/ftrack/tray/login_dialog.py @@ -14,11 +14,13 @@ class CredentialsDialog(QtWidgets.QDialog): login_changed = QtCore.Signal() logout_signal = QtCore.Signal() - def __init__(self, parent=None): + def __init__(self, module, parent=None): super(CredentialsDialog, self).__init__(parent) self.setWindowTitle("OpenPype - Ftrack Login") + self._module = module + self._login_server_thread = None self._is_logged = False self._in_advance_mode = False @@ -268,7 +270,7 @@ class CredentialsDialog(QtWidgets.QDialog): verification = credentials.check_credentials(username, api_key) if verification: credentials.save_credentials(username, api_key, False) - credentials.set_env(username, api_key) + self._module.set_credentials_to_env(username, api_key) self.set_credentials(username, api_key) self.login_changed.emit() return verification From 6c27152eebc918d7a1824b79a70ea66bbe1849aa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Apr 2021 10:52:15 +0200 Subject: [PATCH 053/515] Hiero: otio-workflow wip --- openpype/hosts/hiero/otio/__init__.py | 0 openpype/hosts/hiero/otio/hiero_export.py | 386 +++++++++++++ openpype/hosts/hiero/otio/hiero_import.py | 529 ++++++++++++++++++ .../Startup/otioexporter/OTIOExportTask.py | 324 +---------- .../Startup/otioexporter/OTIOExportUI.py | 10 +- .../Python/Startup/otioexporter/__init__.py | 22 - .../Python/StartupUI/otioimporter/__init__.py | 140 ++++- test_localsystem.txt | 1 + 8 files changed, 1045 insertions(+), 367 deletions(-) create mode 100644 openpype/hosts/hiero/otio/__init__.py create mode 100644 openpype/hosts/hiero/otio/hiero_export.py create mode 100644 openpype/hosts/hiero/otio/hiero_import.py create mode 100644 test_localsystem.txt diff --git a/openpype/hosts/hiero/otio/__init__.py b/openpype/hosts/hiero/otio/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py new file mode 100644 index 0000000000..8e19b26741 --- /dev/null +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__author__ = "Daniel Flehner Heen" +__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] + +import os +import sys +import re +import hiero.core +import hiero.ui +import opentimelineio as otio + + +# build modul class +self = sys.modules[__name__] + +self.marker_color_map = { + "magenta": otio.schema.MarkerColor.MAGENTA, + "red": otio.schema.MarkerColor.RED, + "yellow": otio.schema.MarkerColor.YELLOW, + "green": otio.schema.MarkerColor.GREEN, + "cyan": otio.schema.MarkerColor.CYAN, + "blue": otio.schema.MarkerColor.BLUE, +} +self.hiero_sequence = None +self.include_tags = None + + +def get_rate(item): + if not hasattr(item, 'framerate'): + item = item.sequence() + + num, den = item.framerate().toRational() + rate = float(num) / float(den) + + if rate.is_integer(): + return rate + + return round(rate, 2) + + +def get_clip_ranges(trackitem): + # Get rate from source or sequence + if trackitem.source().mediaSource().hasVideo(): + rate_item = trackitem.source() + + else: + rate_item = trackitem.sequence() + + source_rate = get_rate(rate_item) + + # Reversed video/audio + if trackitem.playbackSpeed() < 0: + start = trackitem.sourceOut() + + else: + start = trackitem.sourceIn() + + source_start_time = otio.opentime.RationalTime( + start, + source_rate + ) + source_duration = otio.opentime.RationalTime( + trackitem.duration(), + source_rate + ) + + source_range = otio.opentime.TimeRange( + start_time=source_start_time, + duration=source_duration + ) + + hiero_clip = trackitem.source() + + available_range = None + if hiero_clip.mediaSource().isMediaPresent(): + start_time = otio.opentime.RationalTime( + hiero_clip.mediaSource().startTime(), + source_rate + ) + duration = otio.opentime.RationalTime( + hiero_clip.mediaSource().duration(), + source_rate + ) + available_range = otio.opentime.TimeRange( + start_time=start_time, + duration=duration + ) + + return source_range, available_range + + +def add_gap(trackitem, otio_track, prev_out): + gap_length = trackitem.timelineIn() - prev_out + if prev_out != 0: + gap_length -= 1 + + rate = get_rate(trackitem.sequence()) + gap = otio.opentime.TimeRange( + duration=otio.opentime.RationalTime( + gap_length, + rate + ) + ) + otio_gap = otio.schema.Gap(source_range=gap) + otio_track.append(otio_gap) + + +def get_marker_color(tag): + icon = tag.icon() + pat = r'icons:Tag(?P\w+)\.\w+' + + res = re.search(pat, icon) + if res: + color = res.groupdict().get('color') + if color.lower() in self.marker_color_map: + return self.marker_color_map[color.lower()] + + return otio.schema.MarkerColor.RED + + +def add_markers(hiero_item, otio_item): + for tag in hiero_item.tags(): + if not tag.visible(): + continue + + if tag.name() == 'Copy': + # Hiero adds this tag to a lot of clips + continue + + frame_rate = get_rate(hiero_item) + + marked_range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + tag.inTime(), + frame_rate + ), + duration=otio.opentime.RationalTime( + int(tag.metadata().dict().get('tag.length', '0')), + frame_rate + ) + ) + + metadata = dict( + Hiero=tag.metadata().dict() + ) + # Store the source item for future import assignment + metadata['Hiero']['source_type'] = hiero_item.__class__.__name__ + + marker = otio.schema.Marker( + name=tag.name(), + color=get_marker_color(tag), + marked_range=marked_range, + metadata=metadata + ) + + otio_item.markers.append(marker) + + +def add_clip(trackitem, otio_track, itemindex): + hiero_clip = trackitem.source() + + # Add Gap if needed + if itemindex == 0: + prev_item = trackitem + + else: + prev_item = trackitem.parent().items()[itemindex - 1] + + clip_diff = trackitem.timelineIn() - prev_item.timelineOut() + + if itemindex == 0 and trackitem.timelineIn() > 0: + add_gap(trackitem, otio_track, 0) + + elif itemindex and clip_diff != 1: + add_gap(trackitem, otio_track, prev_item.timelineOut()) + + # Create Clip + source_range, available_range = get_clip_ranges(trackitem) + + otio_clip = otio.schema.Clip( + name=trackitem.name(), + source_range=source_range + ) + + media_reference = create_otio_reference(hiero_clip) + + otio_clip.media_reference = media_reference + + # Add Time Effects + playbackspeed = trackitem.playbackSpeed() + if playbackspeed != 1: + if playbackspeed == 0: + time_effect = otio.schema.FreezeFrame() + + else: + time_effect = otio.schema.LinearTimeWarp( + time_scalar=playbackspeed + ) + otio_clip.effects.append(time_effect) + + # Add tags as markers + if self.include_tags: + add_markers(trackitem, otio_clip) + add_markers(trackitem.source(), otio_clip) + + otio_track.append(otio_clip) + + # Add Transition if needed + if trackitem.inTransition() or trackitem.outTransition(): + add_transition(trackitem, otio_track) + +def _get_metadata(hiero_object): + metadata = hiero_object.metadata() + return {key: value for key, value in metadata.items()} + +def create_otio_reference(hiero_clip): + metadata = _get_metadata(hiero_clip) + mp_clip_property = media_pool_item.GetClipProperty() + path = mp_clip_property["File Path"] + reformat_path = utils.get_reformated_path(path, padded=True) + padding = utils.get_padding_from_path(path) + + if padding: + metadata.update({ + "isSequence": True, + "padding": padding + }) + + # get clip property regarding to type + mp_clip_property = media_pool_item.GetClipProperty() + fps = float(mp_clip_property["FPS"]) + if mp_clip_property["Type"] == "Video": + frame_start = int(mp_clip_property["Start"]) + frame_duration = int(mp_clip_property["Frames"]) + else: + audio_duration = str(mp_clip_property["Duration"]) + frame_start = 0 + frame_duration = int(utils.timecode_to_frames( + audio_duration, float(fps))) + + otio_ex_ref_item = None + + if padding: + # if it is file sequence try to create `ImageSequenceReference` + # the OTIO might not be compatible so return nothing and do it old way + try: + dirname, filename = os.path.split(path) + collection = clique.parse(filename, '{head}[{ranges}]{tail}') + padding_num = len(re.findall("(\\d+)(?=-)", filename).pop()) + otio_ex_ref_item = otio.schema.ImageSequenceReference( + target_url_base=dirname + os.sep, + name_prefix=collection.format("{head}"), + name_suffix=collection.format("{tail}"), + start_frame=frame_start, + frame_zero_padding=padding_num, + rate=fps, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + except AttributeError: + pass + + if not otio_ex_ref_item: + # in case old OTIO or video file create `ExternalReference` + otio_ex_ref_item = otio.schema.ExternalReference( + target_url=reformat_path, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + + # add metadata to otio item + add_otio_metadata(otio_ex_ref_item, hiero_clip, **metadata) + + return otio_ex_ref_item + + +def add_otio_metadata(otio_item, hiero_clip, **kwargs): + mp_metadata = hiero_clip.GetMetadata() + # add additional metadata from kwargs + if kwargs: + mp_metadata.update(kwargs) + + # add metadata to otio item metadata + for key, value in mp_metadata.items(): + otio_item.metadata.update({key: value}) + +def add_transition(trackitem, otio_track): + transitions = [] + + if trackitem.inTransition(): + if trackitem.inTransition().alignment().name == 'kFadeIn': + transitions.append(trackitem.inTransition()) + + if trackitem.outTransition(): + transitions.append(trackitem.outTransition()) + + for transition in transitions: + alignment = transition.alignment().name + + if alignment == 'kFadeIn': + in_offset_frames = 0 + out_offset_frames = ( + transition.timelineOut() - transition.timelineIn() + ) + 1 + + elif alignment == 'kFadeOut': + in_offset_frames = ( + trackitem.timelineOut() - transition.timelineIn() + ) + 1 + out_offset_frames = 0 + + elif alignment == 'kDissolve': + in_offset_frames = ( + transition.inTrackItem().timelineOut() - + transition.timelineIn() + ) + out_offset_frames = ( + transition.timelineOut() - + transition.outTrackItem().timelineIn() + ) + + else: + # kUnknown transition is ignored + continue + + rate = trackitem.source().framerate().toFloat() + in_time = otio.opentime.RationalTime(in_offset_frames, rate) + out_time = otio.opentime.RationalTime(out_offset_frames, rate) + + otio_transition = otio.schema.Transition( + name=alignment, # Consider placing Hiero name in metadata + transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve, + in_offset=in_time, + out_offset=out_time + ) + + if alignment == 'kFadeIn': + otio_track.insert(-1, otio_transition) + + else: + otio_track.append(otio_transition) + + +def add_tracks(): + for track in self.hiero_sequence.items(): + if isinstance(track, hiero.core.AudioTrack): + kind = otio.schema.TrackKind.Audio + + else: + kind = otio.schema.TrackKind.Video + + otio_track = otio.schema.Track(name=track.name(), kind=kind) + + for itemindex, trackitem in enumerate(track): + if isinstance(trackitem.source(), hiero.core.Clip): + add_clip(trackitem, otio_track, itemindex) + + self.otio_timeline.tracks.append(otio_track) + + # Add tags as markers + if self.include_tags: + add_markers(self.hiero_sequence, self.otio_timeline.tracks) + + +def create_OTIO(sequence=None): + self.hiero_sequence = sequence or hiero.ui.activeSequence() + self.otio_timeline = otio.schema.Timeline() + + # Set global start time based on sequence + self.otio_timeline.global_start_time = otio.opentime.RationalTime( + self.hiero_sequence.timecodeStart(), + self.hiero_sequence.framerate().toFloat() + ) + self.otio_timeline.name = self.hiero_sequence.name() + + add_tracks() + + return self.otio_timeline diff --git a/openpype/hosts/hiero/otio/hiero_import.py b/openpype/hosts/hiero/otio/hiero_import.py new file mode 100644 index 0000000000..c5c72984bc --- /dev/null +++ b/openpype/hosts/hiero/otio/hiero_import.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__author__ = "Daniel Flehner Heen" +__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] + + +import os +import hiero.core +import hiero.ui + +import PySide2.QtWidgets as qw + +try: + from urllib import unquote + +except ImportError: + from urllib.parse import unquote # lint:ok + +import opentimelineio as otio + + +def inform(messages): + if isinstance(messages, type('')): + messages = [messages] + + qw.QMessageBox.information( + hiero.ui.mainWindow(), + 'OTIO Import', + '\n'.join(messages), + qw.QMessageBox.StandardButton.Ok + ) + + +def get_transition_type(otio_item, otio_track): + _in, _out = otio_track.neighbors_of(otio_item) + + if isinstance(_in, otio.schema.Gap): + _in = None + + if isinstance(_out, otio.schema.Gap): + _out = None + + if _in and _out: + return 'dissolve' + + elif _in and not _out: + return 'fade_out' + + elif not _in and _out: + return 'fade_in' + + else: + return 'unknown' + + +def find_trackitem(otio_clip, hiero_track): + for item in hiero_track.items(): + if item.timelineIn() == otio_clip.range_in_parent().start_time.value: + if item.name() == otio_clip.name: + return item + + return None + + +def get_neighboring_trackitems(otio_item, otio_track, hiero_track): + _in, _out = otio_track.neighbors_of(otio_item) + trackitem_in = None + trackitem_out = None + + if _in: + trackitem_in = find_trackitem(_in, hiero_track) + + if _out: + trackitem_out = find_trackitem(_out, hiero_track) + + return trackitem_in, trackitem_out + + +def apply_transition(otio_track, otio_item, track): + warning = None + + # Figure out type of transition + transition_type = get_transition_type(otio_item, otio_track) + + # Figure out track kind for getattr below + kind = '' + if isinstance(track, hiero.core.AudioTrack): + kind = 'Audio' + + # Gather TrackItems involved in trasition + item_in, item_out = get_neighboring_trackitems( + otio_item, + otio_track, + track + ) + + # Create transition object + if transition_type == 'dissolve': + transition_func = getattr( + hiero.core.Transition, + 'create{kind}DissolveTransition'.format(kind=kind) + ) + + try: + transition = transition_func( + item_in, + item_out, + otio_item.in_offset.value, + otio_item.out_offset.value + ) + + # Catch error raised if transition is bigger than TrackItem source + except RuntimeError as e: + transition = None + warning = \ + 'Unable to apply transition "{t.name}": {e} ' \ + 'Ignoring the transition.' \ + .format(t=otio_item, e=e.message) + + elif transition_type == 'fade_in': + transition_func = getattr( + hiero.core.Transition, + 'create{kind}FadeInTransition'.format(kind=kind) + ) + + # Warn user if part of fade is outside of clip + if otio_item.in_offset.value: + warning = \ + 'Fist half of transition "{t.name}" is outside of clip and ' \ + 'not valid in Hiero. Only applied second half.' \ + .format(t=otio_item) + + transition = transition_func( + item_out, + otio_item.out_offset.value + ) + + elif transition_type == 'fade_out': + transition_func = getattr( + hiero.core.Transition, + 'create{kind}FadeOutTransition'.format(kind=kind) + ) + transition = transition_func( + item_in, + otio_item.in_offset.value + ) + + # Warn user if part of fade is outside of clip + if otio_item.out_offset.value: + warning = \ + 'Second half of transition "{t.name}" is outside of clip ' \ + 'and not valid in Hiero. Only applied first half.' \ + .format(t=otio_item) + + else: + # Unknown transition + return + + # Apply transition to track + if transition: + track.addTransition(transition) + + # Inform user about missing or adjusted transitions + return warning + + +def prep_url(url_in): + url = unquote(url_in) + + if url.startswith('file://localhost/'): + return url + + url = 'file://localhost{sep}{url}'.format( + sep=url.startswith(os.sep) and '' or os.sep, + url=url.startswith(os.sep) and url[1:] or url + ) + + return url + + +def create_offline_mediasource(otio_clip, path=None): + hiero_rate = hiero.core.TimeBase( + otio_clip.source_range.start_time.rate + ) + + legal_media_refs = ( + otio.schema.ExternalReference, + otio.schema.ImageSequenceReference + ) + if isinstance(otio_clip.media_reference, legal_media_refs): + source_range = otio_clip.available_range() + + else: + source_range = otio_clip.source_range + + if path is None: + path = otio_clip.name + + media = hiero.core.MediaSource.createOfflineVideoMediaSource( + prep_url(path), + source_range.start_time.value, + source_range.duration.value, + hiero_rate, + source_range.start_time.value + ) + + return media + + +def load_otio(otio_file, project=None, sequence=None): + otio_timeline = otio.adapters.read_from_file(otio_file) + build_sequence(otio_timeline, project=project, sequence=sequence) + + +marker_color_map = { + "PINK": "Magenta", + "RED": "Red", + "ORANGE": "Yellow", + "YELLOW": "Yellow", + "GREEN": "Green", + "CYAN": "Cyan", + "BLUE": "Blue", + "PURPLE": "Magenta", + "MAGENTA": "Magenta", + "BLACK": "Blue", + "WHITE": "Green" +} + + +def get_tag(tagname, tagsbin): + for tag in tagsbin.items(): + if tag.name() == tagname: + return tag + + if isinstance(tag, hiero.core.Bin): + tag = get_tag(tagname, tag) + + if tag is not None: + return tag + + return None + + +def add_metadata(metadata, hiero_item): + for key, value in metadata.get('Hiero', dict()).items(): + if key == 'source_type': + # Only used internally to reassign tag to correct Hiero item + continue + + if isinstance(value, dict): + add_metadata(value, hiero_item) + continue + + if value is not None: + if not key.startswith('tag.'): + key = 'tag.' + key + + hiero_item.metadata().setValue(key, str(value)) + + +def add_markers(otio_item, hiero_item, tagsbin): + if isinstance(otio_item, (otio.schema.Stack, otio.schema.Clip)): + markers = otio_item.markers + + elif isinstance(otio_item, otio.schema.Timeline): + markers = otio_item.tracks.markers + + else: + markers = [] + + for marker in markers: + meta = marker.metadata.get('Hiero', dict()) + if 'source_type' in meta: + if hiero_item.__class__.__name__ != meta.get('source_type'): + continue + + marker_color = marker.color + + _tag = get_tag(marker.name, tagsbin) + if _tag is None: + _tag = get_tag(marker_color_map[marker_color], tagsbin) + + if _tag is None: + _tag = hiero.core.Tag(marker_color_map[marker.color]) + + start = marker.marked_range.start_time.value + end = ( + marker.marked_range.start_time.value + + marker.marked_range.duration.value + ) + + if hasattr(hiero_item, 'addTagToRange'): + tag = hiero_item.addTagToRange(_tag, start, end) + + else: + tag = hiero_item.addTag(_tag) + + tag.setName(marker.name or marker_color_map[marker_color]) + # tag.setNote(meta.get('tag.note', '')) + + # Add metadata + add_metadata(marker.metadata, tag) + + +def create_track(otio_track, tracknum, track_kind): + if track_kind is None and hasattr(otio_track, 'kind'): + track_kind = otio_track.kind + + # Create a Track + if track_kind == otio.schema.TrackKind.Video: + track = hiero.core.VideoTrack( + otio_track.name or 'Video{n}'.format(n=tracknum) + ) + + else: + track = hiero.core.AudioTrack( + otio_track.name or 'Audio{n}'.format(n=tracknum) + ) + + return track + + +def create_clip(otio_clip, tagsbin, sequencebin): + # Create MediaSource + url = None + media = None + otio_media = otio_clip.media_reference + + if isinstance(otio_media, otio.schema.ExternalReference): + url = prep_url(otio_media.target_url) + media = hiero.core.MediaSource(url) + + elif isinstance(otio_media, otio.schema.ImageSequenceReference): + url = prep_url(otio_media.abstract_target_url('#')) + media = hiero.core.MediaSource(url) + + if media is None or media.isOffline(): + media = create_offline_mediasource(otio_clip, url) + + # Reuse previous clip if possible + clip = None + for item in sequencebin.clips(): + if item.activeItem().mediaSource() == media: + clip = item.activeItem() + break + + if not clip: + # Create new Clip + clip = hiero.core.Clip(media) + + # Add Clip to a Bin + sequencebin.addItem(hiero.core.BinItem(clip)) + + # Add markers + add_markers(otio_clip, clip, tagsbin) + + return clip + + +def create_trackitem(playhead, track, otio_clip, clip): + source_range = otio_clip.source_range + + trackitem = track.createTrackItem(otio_clip.name) + trackitem.setPlaybackSpeed(source_range.start_time.rate) + trackitem.setSource(clip) + + time_scalar = 1. + + # Check for speed effects and adjust playback speed accordingly + for effect in otio_clip.effects: + if isinstance(effect, otio.schema.LinearTimeWarp): + time_scalar = effect.time_scalar + # Only reverse effect can be applied here + if abs(time_scalar) == 1.: + trackitem.setPlaybackSpeed(trackitem.playbackSpeed() * time_scalar) + + elif isinstance(effect, otio.schema.FreezeFrame): + # For freeze frame, playback speed must be set after range + time_scalar = 0. + + # If reverse playback speed swap source in and out + if trackitem.playbackSpeed() < 0: + source_out = source_range.start_time.value + source_in = source_range.end_time_inclusive().value + + timeline_in = playhead + source_out + timeline_out = ( + timeline_in + + source_range.duration.value + ) - 1 + else: + # Normal playback speed + source_in = source_range.start_time.value + source_out = source_range.end_time_inclusive().value + + timeline_in = playhead + timeline_out = ( + timeline_in + + source_range.duration.value + ) - 1 + + # Set source and timeline in/out points + trackitem.setTimes( + timeline_in, + timeline_out, + source_in, + source_out + + ) + + # Apply playback speed for freeze frames + if abs(time_scalar) != 1.: + trackitem.setPlaybackSpeed(trackitem.playbackSpeed() * time_scalar) + + # Link audio to video when possible + if isinstance(track, hiero.core.AudioTrack): + for other in track.parent().trackItemsAt(playhead): + if other.source() == clip: + trackitem.link(other) + + return trackitem + + +def build_sequence(otio_timeline, project=None, sequence=None, track_kind=None): + if project is None: + if sequence: + project = sequence.project() + + else: + # Per version 12.1v2 there is no way of getting active project + project = hiero.core.projects(hiero.core.Project.kUserProjects)[-1] + + projectbin = project.clipsBin() + + if not sequence: + # Create a Sequence + sequence = hiero.core.Sequence(otio_timeline.name or 'OTIOSequence') + + # Set sequence settings from otio timeline if available + if hasattr(otio_timeline, 'global_start_time'): + if otio_timeline.global_start_time: + start_time = otio_timeline.global_start_time + sequence.setFramerate(start_time.rate) + sequence.setTimecodeStart(start_time.value) + + # Create a Bin to hold clips + projectbin.addItem(hiero.core.BinItem(sequence)) + + sequencebin = hiero.core.Bin(sequence.name()) + projectbin.addItem(sequencebin) + + else: + sequencebin = projectbin + + # Get tagsBin + tagsbin = hiero.core.project("Tag Presets").tagsBin() + + # Add timeline markers + add_markers(otio_timeline, sequence, tagsbin) + + if isinstance(otio_timeline, otio.schema.Timeline): + tracks = otio_timeline.tracks + + else: + tracks = [otio_timeline] + + for tracknum, otio_track in enumerate(tracks): + playhead = 0 + _transitions = [] + + # Add track to sequence + track = create_track(otio_track, tracknum, track_kind) + sequence.addTrack(track) + + # iterate over items in track + for itemnum, otio_clip in enumerate(otio_track): + if isinstance(otio_clip, (otio.schema.Track, otio.schema.Stack)): + inform('Nested sequences/tracks are created separately.') + + # Add gap where the nested sequence would have been + playhead += otio_clip.source_range.duration.value + + # Process nested sequence + build_sequence( + otio_clip, + project=project, + track_kind=otio_track.kind + ) + + elif isinstance(otio_clip, otio.schema.Clip): + # Create a Clip + clip = create_clip(otio_clip, tagsbin, sequencebin) + + # Create TrackItem + trackitem = create_trackitem( + playhead, + track, + otio_clip, + clip + ) + + # Add markers + add_markers(otio_clip, trackitem, tagsbin) + + # Add trackitem to track + track.addTrackItem(trackitem) + + # Update playhead + playhead = trackitem.timelineOut() + 1 + + elif isinstance(otio_clip, otio.schema.Transition): + # Store transitions for when all clips in the track are created + _transitions.append((otio_track, otio_clip)) + + elif isinstance(otio_clip, otio.schema.Gap): + # Hiero has no fillers, slugs or blanks at the moment + playhead += otio_clip.source_range.duration.value + + # Apply transitions we stored earlier now that all clips are present + warnings = list() + for otio_track, otio_item in _transitions: + # Catch warnings form transitions in case of unsupported transitions + warning = apply_transition(otio_track, otio_item, track) + if warning: + warnings.append(warning) + + if warnings: + inform(warnings) diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py index 90504ccd18..734213a05d 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py +++ b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py @@ -1,42 +1,15 @@ -# MIT License -# -# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. +#!/usr/bin/env python +# -*- coding: utf-8 -*- +__author__ = "Daniel Flehner Heen" +__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] import os -import re import hiero.core from hiero.core import util import opentimelineio as otio - - -marker_color_map = { - "magenta": otio.schema.MarkerColor.MAGENTA, - "red": otio.schema.MarkerColor.RED, - "yellow": otio.schema.MarkerColor.YELLOW, - "green": otio.schema.MarkerColor.GREEN, - "cyan": otio.schema.MarkerColor.CYAN, - "blue": otio.schema.MarkerColor.BLUE, -} +from pype.hosts.hiero.otio.hiero_export import create_OTIO class OTIOExportTask(hiero.core.TaskBase): @@ -44,295 +17,14 @@ class OTIOExportTask(hiero.core.TaskBase): def __init__(self, initDict): """Initialize""" hiero.core.TaskBase.__init__(self, initDict) + self.otio_timeline = None def name(self): return str(type(self)) - def get_rate(self, item): - if not hasattr(item, 'framerate'): - item = item.sequence() - - num, den = item.framerate().toRational() - rate = float(num) / float(den) - - if rate.is_integer(): - return rate - - return round(rate, 2) - - def get_clip_ranges(self, trackitem): - # Get rate from source or sequence - if trackitem.source().mediaSource().hasVideo(): - rate_item = trackitem.source() - - else: - rate_item = trackitem.sequence() - - source_rate = self.get_rate(rate_item) - - # Reversed video/audio - if trackitem.playbackSpeed() < 0: - start = trackitem.sourceOut() - - else: - start = trackitem.sourceIn() - - source_start_time = otio.opentime.RationalTime( - start, - source_rate - ) - source_duration = otio.opentime.RationalTime( - trackitem.duration(), - source_rate - ) - - source_range = otio.opentime.TimeRange( - start_time=source_start_time, - duration=source_duration - ) - - hiero_clip = trackitem.source() - - available_range = None - if hiero_clip.mediaSource().isMediaPresent(): - start_time = otio.opentime.RationalTime( - hiero_clip.mediaSource().startTime(), - source_rate - ) - duration = otio.opentime.RationalTime( - hiero_clip.mediaSource().duration(), - source_rate - ) - available_range = otio.opentime.TimeRange( - start_time=start_time, - duration=duration - ) - - return source_range, available_range - - def add_gap(self, trackitem, otio_track, prev_out): - gap_length = trackitem.timelineIn() - prev_out - if prev_out != 0: - gap_length -= 1 - - rate = self.get_rate(trackitem.sequence()) - gap = otio.opentime.TimeRange( - duration=otio.opentime.RationalTime( - gap_length, - rate - ) - ) - otio_gap = otio.schema.Gap(source_range=gap) - otio_track.append(otio_gap) - - def get_marker_color(self, tag): - icon = tag.icon() - pat = r'icons:Tag(?P\w+)\.\w+' - - res = re.search(pat, icon) - if res: - color = res.groupdict().get('color') - if color.lower() in marker_color_map: - return marker_color_map[color.lower()] - - return otio.schema.MarkerColor.RED - - def add_markers(self, hiero_item, otio_item): - for tag in hiero_item.tags(): - if not tag.visible(): - continue - - if tag.name() == 'Copy': - # Hiero adds this tag to a lot of clips - continue - - frame_rate = self.get_rate(hiero_item) - - marked_range = otio.opentime.TimeRange( - start_time=otio.opentime.RationalTime( - tag.inTime(), - frame_rate - ), - duration=otio.opentime.RationalTime( - int(tag.metadata().dict().get('tag.length', '0')), - frame_rate - ) - ) - - metadata = dict( - Hiero=tag.metadata().dict() - ) - # Store the source item for future import assignment - metadata['Hiero']['source_type'] = hiero_item.__class__.__name__ - - marker = otio.schema.Marker( - name=tag.name(), - color=self.get_marker_color(tag), - marked_range=marked_range, - metadata=metadata - ) - - otio_item.markers.append(marker) - - def add_clip(self, trackitem, otio_track, itemindex): - hiero_clip = trackitem.source() - - # Add Gap if needed - if itemindex == 0: - prev_item = trackitem - - else: - prev_item = trackitem.parent().items()[itemindex - 1] - - clip_diff = trackitem.timelineIn() - prev_item.timelineOut() - - if itemindex == 0 and trackitem.timelineIn() > 0: - self.add_gap(trackitem, otio_track, 0) - - elif itemindex and clip_diff != 1: - self.add_gap(trackitem, otio_track, prev_item.timelineOut()) - - # Create Clip - source_range, available_range = self.get_clip_ranges(trackitem) - - otio_clip = otio.schema.Clip( - name=trackitem.name(), - source_range=source_range - ) - - # Add media reference - media_reference = otio.schema.MissingReference() - if hiero_clip.mediaSource().isMediaPresent(): - source = hiero_clip.mediaSource() - first_file = source.fileinfos()[0] - path = first_file.filename() - - if "%" in path: - path = re.sub(r"%\d+d", "%d", path) - if "#" in path: - path = re.sub(r"#+", "%d", path) - - media_reference = otio.schema.ExternalReference( - target_url=u'{}'.format(path), - available_range=available_range - ) - - otio_clip.media_reference = media_reference - - # Add Time Effects - playbackspeed = trackitem.playbackSpeed() - if playbackspeed != 1: - if playbackspeed == 0: - time_effect = otio.schema.FreezeFrame() - - else: - time_effect = otio.schema.LinearTimeWarp( - time_scalar=playbackspeed - ) - otio_clip.effects.append(time_effect) - - # Add tags as markers - if self._preset.properties()["includeTags"]: - self.add_markers(trackitem, otio_clip) - self.add_markers(trackitem.source(), otio_clip) - - otio_track.append(otio_clip) - - # Add Transition if needed - if trackitem.inTransition() or trackitem.outTransition(): - self.add_transition(trackitem, otio_track) - - def add_transition(self, trackitem, otio_track): - transitions = [] - - if trackitem.inTransition(): - if trackitem.inTransition().alignment().name == 'kFadeIn': - transitions.append(trackitem.inTransition()) - - if trackitem.outTransition(): - transitions.append(trackitem.outTransition()) - - for transition in transitions: - alignment = transition.alignment().name - - if alignment == 'kFadeIn': - in_offset_frames = 0 - out_offset_frames = ( - transition.timelineOut() - transition.timelineIn() - ) + 1 - - elif alignment == 'kFadeOut': - in_offset_frames = ( - trackitem.timelineOut() - transition.timelineIn() - ) + 1 - out_offset_frames = 0 - - elif alignment == 'kDissolve': - in_offset_frames = ( - transition.inTrackItem().timelineOut() - - transition.timelineIn() - ) - out_offset_frames = ( - transition.timelineOut() - - transition.outTrackItem().timelineIn() - ) - - else: - # kUnknown transition is ignored - continue - - rate = trackitem.source().framerate().toFloat() - in_time = otio.opentime.RationalTime(in_offset_frames, rate) - out_time = otio.opentime.RationalTime(out_offset_frames, rate) - - otio_transition = otio.schema.Transition( - name=alignment, # Consider placing Hiero name in metadata - transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve, - in_offset=in_time, - out_offset=out_time - ) - - if alignment == 'kFadeIn': - otio_track.insert(-1, otio_transition) - - else: - otio_track.append(otio_transition) - - - def add_tracks(self): - for track in self._sequence.items(): - if isinstance(track, hiero.core.AudioTrack): - kind = otio.schema.TrackKind.Audio - - else: - kind = otio.schema.TrackKind.Video - - otio_track = otio.schema.Track(name=track.name(), kind=kind) - - for itemindex, trackitem in enumerate(track): - if isinstance(trackitem.source(), hiero.core.Clip): - self.add_clip(trackitem, otio_track, itemindex) - - self.otio_timeline.tracks.append(otio_track) - - # Add tags as markers - if self._preset.properties()["includeTags"]: - self.add_markers(self._sequence, self.otio_timeline.tracks) - - def create_OTIO(self): - self.otio_timeline = otio.schema.Timeline() - - # Set global start time based on sequence - self.otio_timeline.global_start_time = otio.opentime.RationalTime( - self._sequence.timecodeStart(), - self._sequence.framerate().toFloat() - ) - self.otio_timeline.name = self._sequence.name() - - self.add_tracks() - def startTask(self): - self.create_OTIO() + self.otio_timeline = create_OTIO( + self._sequence) def taskStep(self): return False diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py index 887ff05ec8..7f11de074d 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py +++ b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py @@ -1,3 +1,9 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__author__ = "Daniel Flehner Heen" +__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] + import hiero.ui import OTIOExportTask @@ -14,6 +20,8 @@ except ImportError: FormLayout = QFormLayout # lint:ok +from pype.hosts.hiero.otio import hiero_export + class OTIOExportUI(hiero.ui.TaskUIBase): def __init__(self, preset): @@ -27,7 +35,7 @@ class OTIOExportUI(hiero.ui.TaskUIBase): def includeMarkersCheckboxChanged(self, state): # Slot to handle change of checkbox state - self._preset.properties()["includeTags"] = state == QtCore.Qt.Checked + hiero_export.hiero_sequence = state == QtCore.Qt.Checked def populateUI(self, widget, exportTemplate): layout = widget.layout() diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py index 67e6e78d35..3c09655f01 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py +++ b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/__init__.py @@ -1,25 +1,3 @@ -# MIT License -# -# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - from OTIOExportTask import OTIOExportTask from OTIOExportUI import OTIOExportUI diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py b/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py index 1503a9e9ac..a778d558b2 100644 --- a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py +++ b/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py @@ -1,42 +1,91 @@ -# MIT License -# -# Copyright (c) 2018 Daniel Flehner Heen (Storm Studios) -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__author__ = "Daniel Flehner Heen" +__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] import hiero.ui import hiero.core -from otioimporter.OTIOImport import load_otio +import PySide2.QtWidgets as qw + +from pype.hosts.hiero.otio.hiero_import import load_otio + + +class OTIOProjectSelect(qw.QDialog): + + def __init__(self, projects, *args, **kwargs): + super(OTIOProjectSelect, self).__init__(*args, **kwargs) + self.setWindowTitle('Please select active project') + self.layout = qw.QVBoxLayout() + + self.label = qw.QLabel( + 'Unable to determine which project to import sequence to.\n' + 'Please select one.' + ) + self.layout.addWidget(self.label) + + self.projects = qw.QComboBox() + self.projects.addItems(map(lambda p: p.name(), projects)) + self.layout.addWidget(self.projects) + + QBtn = qw.QDialogButtonBox.Ok | qw.QDialogButtonBox.Cancel + self.buttonBox = qw.QDialogButtonBox(QBtn) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + + self.layout.addWidget(self.buttonBox) + self.setLayout(self.layout) + + +def get_sequence(view): + sequence = None + if isinstance(view, hiero.ui.TimelineEditor): + sequence = view.sequence() + + elif isinstance(view, hiero.ui.BinView): + for item in view.selection(): + if not hasattr(item, 'acitveItem'): + continue + + if isinstance(item.activeItem(), hiero.core.Sequence): + sequence = item.activeItem() + + return sequence def OTIO_menu_action(event): - otio_action = hiero.ui.createMenuAction( - 'Import OTIO', + # Menu actions + otio_import_action = hiero.ui.createMenuAction( + 'Import OTIO...', open_otio_file, icon=None ) - hiero.ui.registerAction(otio_action) + + otio_add_track_action = hiero.ui.createMenuAction( + 'New Track(s) from OTIO...', + open_otio_file, + icon=None + ) + otio_add_track_action.setEnabled(False) + + hiero.ui.registerAction(otio_import_action) + hiero.ui.registerAction(otio_add_track_action) + + view = hiero.ui.currentContextMenuView() + + if view: + sequence = get_sequence(view) + if sequence: + otio_add_track_action.setEnabled(True) + for action in event.menu.actions(): if action.text() == 'Import': - action.menu().addAction(otio_action) - break + action.menu().addAction(otio_import_action) + action.menu().addAction(otio_add_track_action) + + elif action.text() == 'New Track': + action.menu().addAction(otio_add_track_action) def open_otio_file(): @@ -45,8 +94,39 @@ def open_otio_file(): pattern='*.otio', requiredExtension='.otio' ) + + selection = None + sequence = None + + view = hiero.ui.currentContextMenuView() + if view: + sequence = get_sequence(view) + selection = view.selection() + + if sequence: + project = sequence.project() + + elif selection: + project = selection[0].project() + + elif len(hiero.core.projects()) > 1: + dialog = OTIOProjectSelect(hiero.core.projects()) + if dialog.exec_(): + project = hiero.core.projects()[dialog.projects.currentIndex()] + + else: + bar = hiero.ui.mainWindow().statusBar() + bar.showMessage( + 'OTIO Import aborted by user', + timeout=3000 + ) + return + + else: + project = hiero.core.projects()[-1] + for otio_file in files: - load_otio(otio_file) + load_otio(otio_file, project, sequence) # HieroPlayer is quite limited and can't create transitions etc. @@ -55,3 +135,7 @@ if not hiero.core.isHieroPlayer(): "kShowContextMenu/kBin", OTIO_menu_action ) + hiero.core.events.registerInterest( + "kShowContextMenu/kTimeline", + OTIO_menu_action + ) diff --git a/test_localsystem.txt b/test_localsystem.txt new file mode 100644 index 0000000000..dde7986af8 --- /dev/null +++ b/test_localsystem.txt @@ -0,0 +1 @@ +I have run From 6339ed1b308eed1820f6a33935f674e554e3b244 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 8 Apr 2021 11:30:21 +0200 Subject: [PATCH 054/515] update packages to include coolname --- poetry.lock | 322 +++++++++++++++++++++++++------------------------ pyproject.toml | 1 + 2 files changed, 167 insertions(+), 156 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6695a7bcca..767aeee500 100644 --- a/poetry.lock +++ b/poetry.lock @@ -80,7 +80,7 @@ python-dateutil = ">=2.7.0" [[package]] name = "astroid" -version = "2.5.1" +version = "2.5.2" description = "An abstract syntax tree for Python with inference support." category = "dev" optional = false @@ -123,14 +123,14 @@ tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (> [[package]] name = "autopep8" -version = "1.5.5" +version = "1.5.6" description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" category = "dev" optional = false python-versions = "*" [package.dependencies] -pycodestyle = ">=2.6.0" +pycodestyle = ">=2.7.0" toml = "*" [[package]] @@ -232,6 +232,14 @@ python-versions = "*" [package.extras] test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] +[[package]] +name = "coolname" +version = "1.1.0" +description = "Random name and slug generator" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "coverage" version = "5.5" @@ -245,7 +253,7 @@ toml = ["toml"] [[package]] name = "cryptography" -version = "3.4.6" +version = "3.4.7" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." category = "main" optional = false @@ -290,7 +298,7 @@ trio = ["trio (>=0.14.0)", "sniffio (>=1.1)"] [[package]] name = "docutils" -version = "0.16" +version = "0.17" description = "Docutils -- Python Documentation Utilities" category = "dev" optional = false @@ -306,17 +314,17 @@ python-versions = "*" [[package]] name = "flake8" -version = "3.8.4" +version = "3.9.0" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.6.0a1,<2.7.0" -pyflakes = ">=2.2.0,<2.3.0" +pycodestyle = ">=2.7.0,<2.8.0" +pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "ftrack-python-api" @@ -346,7 +354,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "google-api-core" -version = "1.26.1" +version = "1.26.3" description = "Google API client core library" category = "main" optional = false @@ -384,7 +392,7 @@ uritemplate = ">=3.0.0,<4dev" [[package]] name = "google-auth" -version = "1.27.1" +version = "1.28.0" description = "Google Authentication Library" category = "main" optional = false @@ -429,7 +437,7 @@ grpc = ["grpcio (>=1.0.0)"] [[package]] name = "httplib2" -version = "0.19.0" +version = "0.19.1" description = "A comprehensive HTTP client library." category = "main" optional = false @@ -456,7 +464,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "3.7.2" +version = "3.10.0" description = "Read metadata from Python packages" category = "main" optional = false @@ -468,7 +476,7 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=3.5,!=3.7.3)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -480,7 +488,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.7.0" +version = "5.8.0" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -579,11 +587,11 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [[package]] name = "lazy-object-proxy" -version = "1.5.2" +version = "1.6.0" description = "A fast and thorough lazy object proxy." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "log4mongo" @@ -653,7 +661,7 @@ pyparsing = ">=2.0.2" [[package]] name = "parso" -version = "0.8.1" +version = "0.8.2" description = "A Python Parser" category = "dev" optional = false @@ -676,7 +684,7 @@ six = "*" [[package]] name = "pillow" -version = "8.1.2" +version = "8.2.0" description = "Python Imaging Library (Fork)" category = "main" optional = false @@ -698,7 +706,7 @@ dev = ["pre-commit", "tox"] [[package]] name = "protobuf" -version = "3.15.6" +version = "3.15.7" description = "Protocol Buffers" category = "main" optional = false @@ -752,7 +760,7 @@ python-versions = "*" [[package]] name = "pycodestyle" -version = "2.6.0" +version = "2.7.0" description = "Python style guide checker" category = "dev" optional = false @@ -780,7 +788,7 @@ snowballstemmer = "*" [[package]] name = "pyflakes" -version = "2.2.0" +version = "2.3.1" description = "passive checker of Python programs" category = "dev" optional = false @@ -796,14 +804,14 @@ python-versions = ">=3.5" [[package]] name = "pylint" -version = "2.7.2" +version = "2.7.4" description = "python code static checker" category = "dev" optional = false python-versions = "~=3.6" [package.dependencies] -astroid = ">=2.5.1,<2.6" +astroid = ">=2.5.2,<2.7" colorama = {version = "*", markers = "sys_platform == \"win32\""} isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.7" @@ -921,7 +929,7 @@ python-versions = ">=3.5" [[package]] name = "pytest" -version = "6.2.2" +version = "6.2.3" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -1112,7 +1120,7 @@ python-versions = "*" [[package]] name = "sphinx" -version = "3.5.2" +version = "3.5.3" description = "Python documentation generator" category = "dev" optional = false @@ -1271,7 +1279,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "tqdm" -version = "4.59.0" +version = "4.60.0" description = "Fast, Extensible Progress Meter" category = "dev" optional = false @@ -1308,16 +1316,16 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "urllib3" -version = "1.26.3" +version = "1.26.4" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] -brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "wcwidth" @@ -1348,7 +1356,7 @@ python-versions = "*" [[package]] name = "wsrpc-aiohttp" -version = "3.1.1" +version = "3.1.2" description = "WSRPC is the RPC over WebSocket for aiohttp" category = "main" optional = false @@ -1391,7 +1399,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "4905515073ad2bf2a8517d513d68e48669b6a829f24e540b2dd60bc70cbea26b" +content-hash = "a8c9915ce3096b74b9328a632911a759780844d368fa1d6d0fbd7c5d7d4536cf" [metadata.files] acre = [] @@ -1455,8 +1463,8 @@ arrow = [ {file = "arrow-0.17.0.tar.gz", hash = "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"}, ] astroid = [ - {file = "astroid-2.5.1-py3-none-any.whl", hash = "sha256:21d735aab248253531bb0f1e1e6d068f0ee23533e18ae8a6171ff892b98297cf"}, - {file = "astroid-2.5.1.tar.gz", hash = "sha256:cfc35498ee64017be059ceffab0a25bedf7548ab76f2bea691c5565896e7128d"}, + {file = "astroid-2.5.2-py3-none-any.whl", hash = "sha256:cd80bf957c49765dce6d92c43163ff9d2abc43132ce64d4b1b47717c6d2522df"}, + {file = "astroid-2.5.2.tar.gz", hash = "sha256:6b0ed1af831570e500e2437625979eaa3b36011f66ddfc4ce930128610258ca9"}, ] async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, @@ -1471,8 +1479,8 @@ attrs = [ {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, ] autopep8 = [ - {file = "autopep8-1.5.5-py2.py3-none-any.whl", hash = "sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea"}, - {file = "autopep8-1.5.5.tar.gz", hash = "sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443"}, + {file = "autopep8-1.5.6-py2.py3-none-any.whl", hash = "sha256:f01b06a6808bc31698db907761e5890eb2295e287af53f6693b39ce55454034a"}, + {file = "autopep8-1.5.6.tar.gz", hash = "sha256:5454e6e9a3d02aae38f866eec0d9a7de4ab9f93c10a273fb0340f3d6d09f7514"}, ] babel = [ {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, @@ -1549,6 +1557,10 @@ commonmark = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, ] +coolname = [ + {file = "coolname-1.1.0-py2.py3-none-any.whl", hash = "sha256:e6a83a0ac88640f4f3d2070438dbe112fe80cfebc119c93bd402976ec84c0978"}, + {file = "coolname-1.1.0.tar.gz", hash = "sha256:410fe6ea9999bf96f2856ef0c726d5f38782bbefb7bb1aca0e91e0dc98ed09e3"}, +] coverage = [ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, @@ -1604,18 +1616,18 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] cryptography = [ - {file = "cryptography-3.4.6-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:57ad77d32917bc55299b16d3b996ffa42a1c73c6cfa829b14043c561288d2799"}, - {file = "cryptography-3.4.6-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:4169a27b818de4a1860720108b55a2801f32b6ae79e7f99c00d79f2a2822eeb7"}, - {file = "cryptography-3.4.6-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:93cfe5b7ff006de13e1e89830810ecbd014791b042cbe5eec253be11ac2b28f3"}, - {file = "cryptography-3.4.6-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:5ecf2bcb34d17415e89b546dbb44e73080f747e504273e4d4987630493cded1b"}, - {file = "cryptography-3.4.6-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:fec7fb46b10da10d9e1d078d1ff8ed9e05ae14f431fdbd11145edd0550b9a964"}, - {file = "cryptography-3.4.6-cp36-abi3-win32.whl", hash = "sha256:df186fcbf86dc1ce56305becb8434e4b6b7504bc724b71ad7a3239e0c9d14ef2"}, - {file = "cryptography-3.4.6-cp36-abi3-win_amd64.whl", hash = "sha256:66b57a9ca4b3221d51b237094b0303843b914b7d5afd4349970bb26518e350b0"}, - {file = "cryptography-3.4.6-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:066bc53f052dfeda2f2d7c195cf16fb3e5ff13e1b6b7415b468514b40b381a5b"}, - {file = "cryptography-3.4.6-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:600cf9bfe75e96d965509a4c0b2b183f74a4fa6f5331dcb40fb7b77b7c2484df"}, - {file = "cryptography-3.4.6-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:0923ba600d00718d63a3976f23cab19aef10c1765038945628cd9be047ad0336"}, - {file = "cryptography-3.4.6-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:9e98b452132963678e3ac6c73f7010fe53adf72209a32854d55690acac3f6724"}, - {file = "cryptography-3.4.6.tar.gz", hash = "sha256:2d32223e5b0ee02943f32b19245b61a62db83a882f0e76cc564e1cec60d48f87"}, + {file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"}, + {file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"}, + {file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"}, + {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, + {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, + {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, + {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, ] cx-freeze = [ {file = "cx_Freeze-6.5.3-cp36-cp36m-win32.whl", hash = "sha256:0a1babae574546b622303da53e1a9829aa3a7e53e62b41eb260250220f83164b"}, @@ -1633,15 +1645,15 @@ dnspython = [ {file = "dnspython-2.1.0.zip", hash = "sha256:e4a87f0b573201a0f3727fa18a516b055fd1107e0e5477cded4a2de497df1dd4"}, ] docutils = [ - {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, - {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, + {file = "docutils-0.17-py2.py3-none-any.whl", hash = "sha256:a71042bb7207c03d5647f280427f14bfbd1a65c9eb84f4b341d85fafb6bb4bdf"}, + {file = "docutils-0.17.tar.gz", hash = "sha256:e2ffeea817964356ba4470efba7c2f42b6b0de0b04e66378507e3e2504bbff4c"}, ] evdev = [ {file = "evdev-1.4.0.tar.gz", hash = "sha256:8782740eb1a86b187334c07feb5127d3faa0b236e113206dfe3ae8f77fb1aaf1"}, ] flake8 = [ - {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, - {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, + {file = "flake8-3.9.0-py2.py3-none-any.whl", hash = "sha256:12d05ab02614b6aee8df7c36b97d1a3b2372761222b19b58621355e82acddcff"}, + {file = "flake8-3.9.0.tar.gz", hash = "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"}, ] ftrack-python-api = [ {file = "ftrack-python-api-2.0.0.tar.gz", hash = "sha256:dd6f02c31daf5a10078196dc9eac4671e4297c762fbbf4df98de668ac12281d9"}, @@ -1651,16 +1663,16 @@ future = [ {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, ] google-api-core = [ - {file = "google-api-core-1.26.1.tar.gz", hash = "sha256:23b0df512c4cc8729793f8992edb350e3211f5fd0ec007afb1599864b421beef"}, - {file = "google_api_core-1.26.1-py2.py3-none-any.whl", hash = "sha256:c383206f0f87545d3e658c4f8dc3b18a8457610fdbd791a15757c5b42d1e0e7f"}, + {file = "google-api-core-1.26.3.tar.gz", hash = "sha256:b914345c7ea23861162693a27703bab804a55504f7e6e9abcaff174d80df32ac"}, + {file = "google_api_core-1.26.3-py2.py3-none-any.whl", hash = "sha256:099762d4b4018cd536bcf85136bf337957da438807572db52f21dc61251be089"}, ] google-api-python-client = [ {file = "google-api-python-client-1.12.8.tar.gz", hash = "sha256:f3b9684442eec2cfe9f9bb48e796ef919456b82142c7528c5fd527e5224f08bb"}, {file = "google_api_python_client-1.12.8-py2.py3-none-any.whl", hash = "sha256:3c4c4ca46b5c21196bec7ee93453443e477d82cbfa79234d1ce0645f81170eaf"}, ] google-auth = [ - {file = "google-auth-1.27.1.tar.gz", hash = "sha256:d8958af6968e4ecd599f82357ebcfeb126f826ed0656126ad68416f810f7531e"}, - {file = "google_auth-1.27.1-py2.py3-none-any.whl", hash = "sha256:63a5636d7eacfe6ef5b7e36e112b3149fa1c5b5ad77dd6df54910459bcd6b89f"}, + {file = "google-auth-1.28.0.tar.gz", hash = "sha256:9bd436d19ab047001a1340720d2b629eb96dd503258c524921ec2af3ee88a80e"}, + {file = "google_auth-1.28.0-py2.py3-none-any.whl", hash = "sha256:dcaba3aa9d4e0e96fd945bf25a86b6f878fcb05770b67adbeb50a63ca4d28a5e"}, ] google-auth-httplib2 = [ {file = "google-auth-httplib2-0.1.0.tar.gz", hash = "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac"}, @@ -1671,8 +1683,8 @@ googleapis-common-protos = [ {file = "googleapis_common_protos-1.53.0-py2.py3-none-any.whl", hash = "sha256:f6d561ab8fb16b30020b940e2dd01cd80082f4762fa9f3ee670f4419b4b8dbd0"}, ] httplib2 = [ - {file = "httplib2-0.19.0-py3-none-any.whl", hash = "sha256:749c32603f9bf16c1277f59531d502e8f1c2ca19901ae653b49c4ed698f0820e"}, - {file = "httplib2-0.19.0.tar.gz", hash = "sha256:e0d428dad43c72dbce7d163b7753ffc7a39c097e6788ef10f4198db69b92f08e"}, + {file = "httplib2-0.19.1-py3-none-any.whl", hash = "sha256:2ad195faf9faf079723f6714926e9a9061f694d07724b846658ce08d40f522b4"}, + {file = "httplib2-0.19.1.tar.gz", hash = "sha256:0b12617eeca7433d4c396a100eaecfa4b08ee99aa881e6df6e257a7aad5d533d"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, @@ -1683,16 +1695,16 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-3.7.2-py3-none-any.whl", hash = "sha256:407d13f55dc6f2a844e62325d18ad7019a436c4bfcaee34cda35f2be6e7c3e34"}, - {file = "importlib_metadata-3.7.2.tar.gz", hash = "sha256:18d5ff601069f98d5d605b6a4b50c18a34811d655c55548adc833e687289acde"}, + {file = "importlib_metadata-3.10.0-py3-none-any.whl", hash = "sha256:d2d46ef77ffc85cbf7dac7e81dd663fde71c45326131bea8033b9bad42268ebe"}, + {file = "importlib_metadata-3.10.0.tar.gz", hash = "sha256:c9db46394197244adf2f0b08ec5bc3cf16757e9590b02af1fca085c16c0d600a"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.7.0-py3-none-any.whl", hash = "sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc"}, - {file = "isort-5.7.0.tar.gz", hash = "sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e"}, + {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, + {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, ] jedi = [ {file = "jedi-0.13.3-py2.py3-none-any.whl", hash = "sha256:2c6bcd9545c7d6440951b12b44d373479bf18123a401a52025cf98563fbd826c"}, @@ -1719,30 +1731,28 @@ keyring = [ {file = "keyring-22.4.0.tar.gz", hash = "sha256:d981e02d134cc3d636a716fbc3ca967bc9609bae5dc21b0063e4409355993ddf"}, ] lazy-object-proxy = [ - {file = "lazy-object-proxy-1.5.2.tar.gz", hash = "sha256:5944a9b95e97de1980c65f03b79b356f30a43de48682b8bdd90aa5089f0ec1f4"}, - {file = "lazy_object_proxy-1.5.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:e960e8be509e8d6d618300a6c189555c24efde63e85acaf0b14b2cd1ac743315"}, - {file = "lazy_object_proxy-1.5.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:522b7c94b524389f4a4094c4bf04c2b02228454ddd17c1a9b2801fac1d754871"}, - {file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:3782931963dc89e0e9a0ae4348b44762e868ea280e4f8c233b537852a8996ab9"}, - {file = "lazy_object_proxy-1.5.2-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:429c4d1862f3fc37cd56304d880f2eae5bd0da83bdef889f3bd66458aac49128"}, - {file = "lazy_object_proxy-1.5.2-cp35-cp35m-win32.whl", hash = "sha256:cd1bdace1a8762534e9a36c073cd54e97d517a17d69a17985961265be6d22847"}, - {file = "lazy_object_proxy-1.5.2-cp35-cp35m-win_amd64.whl", hash = "sha256:ddbdcd10eb999d7ab292677f588b658372aadb9a52790f82484a37127a390108"}, - {file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ecb5dd5990cec6e7f5c9c1124a37cb2c710c6d69b0c1a5c4aa4b35eba0ada068"}, - {file = "lazy_object_proxy-1.5.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b6577f15d5516d7d209c1a8cde23062c0f10625f19e8dc9fb59268859778d7d7"}, - {file = "lazy_object_proxy-1.5.2-cp36-cp36m-win32.whl", hash = "sha256:c8fe2d6ff0ff583784039d0255ea7da076efd08507f2be6f68583b0da32e3afb"}, - {file = "lazy_object_proxy-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:fa5b2dee0e231fa4ad117be114251bdfe6afe39213bd629d43deb117b6a6c40a"}, - {file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1d33d6f789697f401b75ce08e73b1de567b947740f768376631079290118ad39"}, - {file = "lazy_object_proxy-1.5.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:57fb5c5504ddd45ed420b5b6461a78f58cbb0c1b0cbd9cd5a43ad30a4a3ee4d0"}, - {file = "lazy_object_proxy-1.5.2-cp37-cp37m-win32.whl", hash = "sha256:e7273c64bccfd9310e9601b8f4511d84730239516bada26a0c9846c9697617ef"}, - {file = "lazy_object_proxy-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f4e5e68b7af950ed7fdb594b3f19a0014a3ace0fedb86acb896e140ffb24302"}, - {file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cadfa2c2cf54d35d13dc8d231253b7985b97d629ab9ca6e7d672c35539d38163"}, - {file = "lazy_object_proxy-1.5.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e7428977763150b4cf83255625a80a23dfdc94d43be7791ce90799d446b4e26f"}, - {file = "lazy_object_proxy-1.5.2-cp38-cp38-win32.whl", hash = "sha256:2f2de8f8ac0be3e40d17730e0600619d35c78c13a099ea91ef7fb4ad944ce694"}, - {file = "lazy_object_proxy-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:38c3865bd220bd983fcaa9aa11462619e84a71233bafd9c880f7b1cb753ca7fa"}, - {file = "lazy_object_proxy-1.5.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:8a44e9901c0555f95ac401377032f6e6af66d8fc1fbfad77a7a8b1a826e0b93c"}, - {file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:fa7fb7973c622b9e725bee1db569d2c2ee64d2f9a089201c5e8185d482c7352d"}, - {file = "lazy_object_proxy-1.5.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:71a1ef23f22fa8437974b2d60fedb947c99a957ad625f83f43fd3de70f77f458"}, - {file = "lazy_object_proxy-1.5.2-cp39-cp39-win32.whl", hash = "sha256:ef3f5e288aa57b73b034ce9c1f1ac753d968f9069cd0742d1d69c698a0167166"}, - {file = "lazy_object_proxy-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:37d9c34b96cca6787fe014aeb651217944a967a5b165e2cacb6b858d2997ab84"}, + {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"}, + {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"}, + {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"}, + {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"}, + {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"}, + {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"}, ] log4mongo = [ {file = "log4mongo-1.7.0.tar.gz", hash = "sha256:dc374617206162a0b14167fbb5feac01dbef587539a235dadba6200362984a68"}, @@ -1850,73 +1860,73 @@ packaging = [ {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, ] parso = [ - {file = "parso-0.8.1-py2.py3-none-any.whl", hash = "sha256:15b00182f472319383252c18d5913b69269590616c947747bc50bf4ac768f410"}, - {file = "parso-0.8.1.tar.gz", hash = "sha256:8519430ad07087d4c997fda3a7918f7cfa27cb58972a8c89c2a0295a1c940e9e"}, + {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"}, + {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"}, ] pathlib2 = [ {file = "pathlib2-2.3.5-py2.py3-none-any.whl", hash = "sha256:0ec8205a157c80d7acc301c0b18fbd5d44fe655968f5d947b6ecef5290fc35db"}, {file = "pathlib2-2.3.5.tar.gz", hash = "sha256:6cd9a47b597b37cc57de1c05e56fb1a1c9cc9fab04fe78c29acd090418529868"}, ] pillow = [ - {file = "Pillow-8.1.2-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:5cf03b9534aca63b192856aa601c68d0764810857786ea5da652581f3a44c2b0"}, - {file = "Pillow-8.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f91b50ad88048d795c0ad004abbe1390aa1882073b1dca10bfd55d0b8cf18ec5"}, - {file = "Pillow-8.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5762ebb4436f46b566fc6351d67a9b5386b5e5de4e58fdaa18a1c83e0e20f1a8"}, - {file = "Pillow-8.1.2-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e2cd8ac157c1e5ae88b6dd790648ee5d2777e76f1e5c7d184eaddb2938594f34"}, - {file = "Pillow-8.1.2-cp36-cp36m-win32.whl", hash = "sha256:72027ebf682abc9bafd93b43edc44279f641e8996fb2945104471419113cfc71"}, - {file = "Pillow-8.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:d1d6bca39bb6dd94fba23cdb3eeaea5e30c7717c5343004d900e2a63b132c341"}, - {file = "Pillow-8.1.2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:90882c6f084ef68b71bba190209a734bf90abb82ab5e8f64444c71d5974008c6"}, - {file = "Pillow-8.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:89e4c757a91b8c55d97c91fa09c69b3677c227b942fa749e9a66eef602f59c28"}, - {file = "Pillow-8.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:8c4e32218c764bc27fe49b7328195579581aa419920edcc321c4cb877c65258d"}, - {file = "Pillow-8.1.2-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:a01da2c266d9868c4f91a9c6faf47a251f23b9a862dce81d2ff583135206f5be"}, - {file = "Pillow-8.1.2-cp37-cp37m-win32.whl", hash = "sha256:30d33a1a6400132e6f521640dd3f64578ac9bfb79a619416d7e8802b4ce1dd55"}, - {file = "Pillow-8.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:71b01ee69e7df527439d7752a2ce8fb89e19a32df484a308eca3e81f673d3a03"}, - {file = "Pillow-8.1.2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:5a2d957eb4aba9d48170b8fe6538ec1fbc2119ffe6373782c03d8acad3323f2e"}, - {file = "Pillow-8.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:87f42c976f91ca2fc21a3293e25bd3cd895918597db1b95b93cbd949f7d019ce"}, - {file = "Pillow-8.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:15306d71a1e96d7e271fd2a0737038b5a92ca2978d2e38b6ced7966583e3d5af"}, - {file = "Pillow-8.1.2-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:71f31ee4df3d5e0b366dd362007740106d3210fb6a56ec4b581a5324ba254f06"}, - {file = "Pillow-8.1.2-cp38-cp38-win32.whl", hash = "sha256:98afcac3205d31ab6a10c5006b0cf040d0026a68ec051edd3517b776c1d78b09"}, - {file = "Pillow-8.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:328240f7dddf77783e72d5ed79899a6b48bc6681f8d1f6001f55933cb4905060"}, - {file = "Pillow-8.1.2-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:bead24c0ae3f1f6afcb915a057943ccf65fc755d11a1410a909c1fefb6c06ad1"}, - {file = "Pillow-8.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:81b3716cc9744ffdf76b39afb6247eae754186838cedad0b0ac63b2571253fe6"}, - {file = "Pillow-8.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:63cd413ac52ee3f67057223d363f4f82ce966e64906aea046daf46695e3c8238"}, - {file = "Pillow-8.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:8565355a29655b28fdc2c666fd9a3890fe5edc6639d128814fafecfae2d70910"}, - {file = "Pillow-8.1.2-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:1940fc4d361f9cc7e558d6f56ff38d7351b53052fd7911f4b60cd7bc091ea3b1"}, - {file = "Pillow-8.1.2-cp39-cp39-win32.whl", hash = "sha256:46c2bcf8e1e75d154e78417b3e3c64e96def738c2a25435e74909e127a8cba5e"}, - {file = "Pillow-8.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:aeab4cd016e11e7aa5cfc49dcff8e51561fa64818a0be86efa82c7038e9369d0"}, - {file = "Pillow-8.1.2-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:74cd9aa648ed6dd25e572453eb09b08817a1e3d9f8d1bd4d8403d99e42ea790b"}, - {file = "Pillow-8.1.2-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:e5739ae63636a52b706a0facec77b2b58e485637e1638202556156e424a02dc2"}, - {file = "Pillow-8.1.2-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:903293320efe2466c1ab3509a33d6b866dc850cfd0c5d9cc92632014cec185fb"}, - {file = "Pillow-8.1.2-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:5daba2b40782c1c5157a788ec4454067c6616f5a0c1b70e26ac326a880c2d328"}, - {file = "Pillow-8.1.2-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:1f93f2fe211f1ef75e6f589327f4d4f8545d5c8e826231b042b483d8383e8a7c"}, - {file = "Pillow-8.1.2-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:6efac40344d8f668b6c4533ae02a48d52fd852ef0654cc6f19f6ac146399c733"}, - {file = "Pillow-8.1.2-pp37-pypy37_pp73-win32.whl", hash = "sha256:f36c3ff63d6fc509ce599a2f5b0d0732189eed653420e7294c039d342c6e204a"}, - {file = "Pillow-8.1.2.tar.gz", hash = "sha256:b07c660e014852d98a00a91adfbe25033898a9d90a8f39beb2437d22a203fc44"}, + {file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"}, + {file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"}, + {file = "Pillow-8.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b"}, + {file = "Pillow-8.2.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9"}, + {file = "Pillow-8.2.0-cp36-cp36m-win32.whl", hash = "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727"}, + {file = "Pillow-8.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f"}, + {file = "Pillow-8.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d"}, + {file = "Pillow-8.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a"}, + {file = "Pillow-8.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9"}, + {file = "Pillow-8.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388"}, + {file = "Pillow-8.2.0-cp37-cp37m-win32.whl", hash = "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5"}, + {file = "Pillow-8.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2"}, + {file = "Pillow-8.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4"}, + {file = "Pillow-8.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812"}, + {file = "Pillow-8.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178"}, + {file = "Pillow-8.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb"}, + {file = "Pillow-8.2.0-cp38-cp38-win32.whl", hash = "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232"}, + {file = "Pillow-8.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797"}, + {file = "Pillow-8.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5"}, + {file = "Pillow-8.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484"}, + {file = "Pillow-8.2.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602"}, + {file = "Pillow-8.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"}, + {file = "Pillow-8.2.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef"}, + {file = "Pillow-8.2.0-cp39-cp39-win32.whl", hash = "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713"}, + {file = "Pillow-8.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c"}, + {file = "Pillow-8.2.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9"}, + {file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9"}, + {file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c"}, + {file = "Pillow-8.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b"}, + {file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4"}, + {file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120"}, + {file = "Pillow-8.2.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e"}, + {file = "Pillow-8.2.0.tar.gz", hash = "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] protobuf = [ - {file = "protobuf-3.15.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1771ef20e88759c4d81db213e89b7a1fc53937968e12af6603c658ee4bcbfa38"}, - {file = "protobuf-3.15.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1a66261a402d05c8ad8c1fde8631837307bf8d7e7740a4f3941fc3277c2e1528"}, - {file = "protobuf-3.15.6-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:eac23a3e56175b710f3da9a9e8e2aa571891fbec60e0c5a06db1c7b1613b5cfd"}, - {file = "protobuf-3.15.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9ec220d90eda8bb7a7a1434a8aed4fe26d7e648c1a051c2885f3f5725b6aa71a"}, - {file = "protobuf-3.15.6-cp35-cp35m-win32.whl", hash = "sha256:88d8f21d1ac205eedb6dea943f8204ed08201b081dba2a966ab5612788b9bb1e"}, - {file = "protobuf-3.15.6-cp35-cp35m-win_amd64.whl", hash = "sha256:eaada29bbf087dea7d8bce4d1d604fc768749e8809e9c295922accd7c8fce4d5"}, - {file = "protobuf-3.15.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:256c0b2e338c1f3228d3280707606fe5531fde85ab9d704cde6fdeb55112531f"}, - {file = "protobuf-3.15.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b9069e45b6e78412fba4a314ea38b4a478686060acf470d2b131b3a2c50484ec"}, - {file = "protobuf-3.15.6-cp36-cp36m-win32.whl", hash = "sha256:24f4697f57b8520c897a401b7f9a5ae45c369e22c572e305dfaf8053ecb49687"}, - {file = "protobuf-3.15.6-cp36-cp36m-win_amd64.whl", hash = "sha256:d9ed0955b794f1e5f367e27f8a8ff25501eabe34573f003f06639c366ca75f73"}, - {file = "protobuf-3.15.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:822ac7f87fc2fb9b24edd2db390538b60ef50256e421ca30d65250fad5a3d477"}, - {file = "protobuf-3.15.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:74ac159989e2b02d761188a2b6f4601ff5e494d9b9d863f5ad6e98e5e0c54328"}, - {file = "protobuf-3.15.6-cp37-cp37m-win32.whl", hash = "sha256:30fe4249a364576f9594180589c3f9c4771952014b5f77f0372923fc7bafbbe2"}, - {file = "protobuf-3.15.6-cp37-cp37m-win_amd64.whl", hash = "sha256:45a91fc6f9aa86d3effdeda6751882b02de628519ba06d7160daffde0c889ff8"}, - {file = "protobuf-3.15.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83c7c7534f050cb25383bb817159416601d1cc46c40bc5e851ec8bbddfc34a2f"}, - {file = "protobuf-3.15.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9ec20a6ded7d0888e767ad029dbb126e604e18db744ac0a428cf746e040ccecd"}, - {file = "protobuf-3.15.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0f2da2fcc4102b6c3b57f03c9d8d5e37c63f8bc74deaa6cb54e0cc4524a77247"}, - {file = "protobuf-3.15.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:70054ae1ce5dea7dec7357db931fcf487f40ea45b02cb719ee6af07eb1e906fb"}, - {file = "protobuf-3.15.6-py2.py3-none-any.whl", hash = "sha256:1655fc0ba7402560d749de13edbfca1ac45d1753d8f4e5292989f18f5a00c215"}, - {file = "protobuf-3.15.6.tar.gz", hash = "sha256:2b974519a2ae83aa1e31cff9018c70bbe0e303a46a598f982943c49ae1d4fcd3"}, + {file = "protobuf-3.15.7-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a14141d5c967362d2eedff8825d2b69cc36a5b3ed6b1f618557a04e58a3cf787"}, + {file = "protobuf-3.15.7-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d54d78f621852ec4fdd1484d1263ca04d4bf5ffdf7abffdbb939e444b6ff3385"}, + {file = "protobuf-3.15.7-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:462085acdb410b06335315fe7e63cb281a1902856e0f4657f341c283cedc1d56"}, + {file = "protobuf-3.15.7-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:849c92ce112e1ef648705c29ce044248e350f71d9d54a2026830623198f0bd38"}, + {file = "protobuf-3.15.7-cp35-cp35m-win32.whl", hash = "sha256:1f6083382f7714700deadf3014e921711e2f807de7f27e40c32b744701ae5b99"}, + {file = "protobuf-3.15.7-cp35-cp35m-win_amd64.whl", hash = "sha256:e17f60f00081adcb32068ee0bb51e418f6474acf83424244ff3512ffd2166385"}, + {file = "protobuf-3.15.7-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c75e563c6fb2ca5b8f21dd75c15659aa2c4a0025b9da3a7711ae661cd6a488d"}, + {file = "protobuf-3.15.7-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d939f41b4108350841c4790ebbadb61729e1363522fdb8434eb4e6f2065d0db1"}, + {file = "protobuf-3.15.7-cp36-cp36m-win32.whl", hash = "sha256:24f14c09d4c0a3641f1b0e9b552d026361de65b01686fdd3e5fdf8f9512cd79b"}, + {file = "protobuf-3.15.7-cp36-cp36m-win_amd64.whl", hash = "sha256:1247170191bcb2a8d978d11a58afe391004ec6c2184e4d961baf8102d43ff500"}, + {file = "protobuf-3.15.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:364cadaeec0756afdc099cbd88cb5659bd1bb7d547168d063abcb0272ccbb2f6"}, + {file = "protobuf-3.15.7-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0c3a6941b1e6e6e22d812a8e5c46bfe83082ea60d262a46f2cfb22d9b9fb17db"}, + {file = "protobuf-3.15.7-cp37-cp37m-win32.whl", hash = "sha256:eb5668f3f6a83b6603ca2e09be5b20de89521ea5914aabe032cce981e4129cc8"}, + {file = "protobuf-3.15.7-cp37-cp37m-win_amd64.whl", hash = "sha256:1001e671cf8476edce7fb72778358d026390649cc35a79d47b2a291684ccfbb2"}, + {file = "protobuf-3.15.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a5ba7dd6f97964655aa7b234c95d80886425a31b7010764f042cdeb985314d18"}, + {file = "protobuf-3.15.7-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:46674bd6fcf8c63b4b9869ba579685db67cf51ae966443dd6bd9a8fa00fcef62"}, + {file = "protobuf-3.15.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4c4399156fb27e3768313b7a59352c861a893252bda6fb9f3643beb3ebb7047e"}, + {file = "protobuf-3.15.7-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:85cd29faf056036167d87445d5a5059034c298881c044e71a73d3b61a4be1c23"}, + {file = "protobuf-3.15.7-py2.py3-none-any.whl", hash = "sha256:22054432b923c0086f9cf1e1c0c52d39bf3c6e31014ea42eec2dabc22ee26d78"}, + {file = "protobuf-3.15.7.tar.gz", hash = "sha256:2d03fc2591543cd2456d0b72230b50c4519546a8d379ac6fd3ecd84c6df61e5d"}, ] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, @@ -1960,8 +1970,8 @@ pyblish-base = [ {file = "pyblish_base-1.8.8-py2.py3-none-any.whl", hash = "sha256:67ea253a05d007ab4a175e44e778928ea7bdb0e9707573e1100417bbf0451a53"}, ] pycodestyle = [ - {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, - {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, + {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, + {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pycparser = [ {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, @@ -1973,16 +1983,16 @@ pydocstyle = [ {file = "pydocstyle-3.0.0.tar.gz", hash = "sha256:5741c85e408f9e0ddf873611085e819b809fca90b619f5fd7f34bd4959da3dd4"}, ] pyflakes = [ - {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, - {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, ] pylint = [ - {file = "pylint-2.7.2-py3-none-any.whl", hash = "sha256:d09b0b07ba06bcdff463958f53f23df25e740ecd81895f7d2699ec04bbd8dc3b"}, - {file = "pylint-2.7.2.tar.gz", hash = "sha256:0e21d3b80b96740909d77206d741aa3ce0b06b41be375d92e1f3244a274c1f8a"}, + {file = "pylint-2.7.4-py3-none-any.whl", hash = "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a"}, + {file = "pylint-2.7.4.tar.gz", hash = "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"}, ] pymongo = [ {file = "pymongo-3.11.3-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:4d959e929cec805c2bf391418b1121590b4e7d5cb00af7b1ba521443d45a0918"}, @@ -2123,8 +2133,8 @@ pyrsistent = [ {file = "pyrsistent-0.17.3.tar.gz", hash = "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"}, ] pytest = [ - {file = "pytest-6.2.2-py3-none-any.whl", hash = "sha256:b574b57423e818210672e07ca1fa90aaf194a4f63f3ab909a2c67ebb22913839"}, - {file = "pytest-6.2.2.tar.gz", hash = "sha256:9d1edf9e7d0b84d72ea3dbcdfd22b35fb543a5e8f2a60092dd578936bf63d7f9"}, + {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, + {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, ] pytest-cov = [ {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, @@ -2198,8 +2208,8 @@ speedcopy = [ {file = "speedcopy-2.1.0.tar.gz", hash = "sha256:8bb1a6c735900b83901a7be84ba2175ed3887c13c6786f97dea48f2ea7d504c2"}, ] sphinx = [ - {file = "Sphinx-3.5.2-py3-none-any.whl", hash = "sha256:ef64a814576f46ec7de06adf11b433a0d6049be007fefe7fd0d183d28b581fac"}, - {file = "Sphinx-3.5.2.tar.gz", hash = "sha256:672cfcc24b6b69235c97c750cb190a44ecd72696b4452acaf75c2d9cc78ca5ff"}, + {file = "Sphinx-3.5.3-py3-none-any.whl", hash = "sha256:3f01732296465648da43dec8fb40dc451ba79eb3e2cc5c6d79005fd98197107d"}, + {file = "Sphinx-3.5.3.tar.gz", hash = "sha256:ce9c228456131bab09a3d7d10ae58474de562a6f79abb3dc811ae401cf8c1abc"}, ] sphinx-qt-documentation = [ {file = "sphinx_qt_documentation-0.3-py3-none-any.whl", hash = "sha256:bee247cb9e4fc03fc496d07adfdb943100e1103320c3e5e820e0cfa7c790d9b6"}, @@ -2245,8 +2255,8 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] tqdm = [ - {file = "tqdm-4.59.0-py2.py3-none-any.whl", hash = "sha256:9fdf349068d047d4cfbe24862c425883af1db29bcddf4b0eeb2524f6fbdb23c7"}, - {file = "tqdm-4.59.0.tar.gz", hash = "sha256:d666ae29164da3e517fcf125e41d4fe96e5bb375cd87ff9763f6b38b5592fe33"}, + {file = "tqdm-4.60.0-py2.py3-none-any.whl", hash = "sha256:daec693491c52e9498632dfbe9ccfc4882a557f5fa08982db1b4d3adbe0887c3"}, + {file = "tqdm-4.60.0.tar.gz", hash = "sha256:ebdebdb95e3477ceea267decfc0784859aa3df3e27e22d23b83e9b272bf157ae"}, ] typed-ast = [ {file = "typed_ast-1.4.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:7703620125e4fb79b64aa52427ec192822e9f45d37d4b6625ab37ef403e1df70"}, @@ -2290,8 +2300,8 @@ uritemplate = [ {file = "uritemplate-3.0.1.tar.gz", hash = "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae"}, ] urllib3 = [ - {file = "urllib3-1.26.3-py2.py3-none-any.whl", hash = "sha256:1b465e494e3e0d8939b50680403e3aedaa2bc434b7d5af64dfd3c958d7f5ae80"}, - {file = "urllib3-1.26.3.tar.gz", hash = "sha256:de3eedaad74a2683334e282005cd8d7f22f4d55fa690a2a1020a416cb0a47e73"}, + {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, + {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, @@ -2305,8 +2315,8 @@ wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, ] wsrpc-aiohttp = [ - {file = "wsrpc-aiohttp-3.1.1.tar.gz", hash = "sha256:a17e1d91624a437e759d4f276b73de1db2071b1681e992cade025e91d31b2a9f"}, - {file = "wsrpc_aiohttp-3.1.1-py3-none-any.whl", hash = "sha256:f3f1ee31aed5145a7fafe8d6c778b914b7e6ec131500395c9c85b0d8676f7302"}, + {file = "wsrpc-aiohttp-3.1.2.tar.gz", hash = "sha256:891164dfe06a8d8d846b485d04b1e56b2c397ff1b46ef0348e6f62bd8efb1693"}, + {file = "wsrpc_aiohttp-3.1.2-py3-none-any.whl", hash = "sha256:4ba64e02b12dcbc09d02544f35bceba49bd04cbc496db47aa8559ae4609ada8e"}, ] yarl = [ {file = "yarl-1.6.3-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434"}, diff --git a/pyproject.toml b/pyproject.toml index ec2d9c7e3b..6df6db5a18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ acre = { git = "https://github.com/pypeclub/acre.git" } opentimelineio = { version = "0.14.0.dev1", source = "openpype" } appdirs = "^1.4.3" blessed = "^1.17" # openpype terminal formatting +coolname = "*" clique = "1.5.*" Click = "^7" dnspython = "^2.1.0" From cf2dc1f70e5507f466998e610e0faf76f5882916 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 11:31:04 +0200 Subject: [PATCH 055/515] renamed PypeSettingsRegistry to OpenPypeSettingsRegistry --- openpype/lib/__init__.py | 4 ++-- openpype/lib/local_settings.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 554c0d8ec3..7b2f533921 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -104,7 +104,7 @@ from .plugin_tools import ( from .local_settings import ( IniSettingRegistry, JSONSettingRegistry, - PypeSettingsRegistry, + OpenPypeSettingsRegistry, get_local_site_id, change_openpype_mongo_url ) @@ -217,7 +217,7 @@ __all__ = [ "IniSettingRegistry", "JSONSettingRegistry", - "PypeSettingsRegistry", + "OpenPypeSettingsRegistry", "get_local_site_id", "change_openpype_mongo_url", diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 82507cb0c0..2095f1253e 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -452,7 +452,7 @@ class JSONSettingRegistry(ASettingRegistry): json.dump(data, cfg, indent=4) -class PypeSettingsRegistry(JSONSettingRegistry): +class OpenPypeSettingsRegistry(JSONSettingRegistry): """Class handling Pype general settings registry. Attributes: @@ -463,9 +463,9 @@ class PypeSettingsRegistry(JSONSettingRegistry): def __init__(self): self.vendor = "pypeclub" - self.product = "pype" + self.product = "openpype" path = appdirs.user_data_dir(self.product, self.vendor) - super(PypeSettingsRegistry, self).__init__("pype_settings", path) + super(OpenPypeSettingsRegistry, self).__init__("openpype_settings", path) def _create_local_site_id(registry=None): @@ -473,7 +473,7 @@ def _create_local_site_id(registry=None): from uuid import uuid4 if registry is None: - registry = PypeSettingsRegistry() + registry = OpenPypeSettingsRegistry() new_id = str(uuid4()) @@ -489,7 +489,7 @@ def get_local_site_id(): Identifier is created if does not exists yet. """ - registry = PypeSettingsRegistry() + registry = OpenPypeSettingsRegistry() try: return registry.get_item("localId") except ValueError: @@ -504,5 +504,5 @@ def change_openpype_mongo_url(new_mongo_url): """ validate_mongo_connection(new_mongo_url) - registry = PypeSettingsRegistry() - registry.set_secure_item("pypeMongo", new_mongo_url) + registry = OpenPypeSettingsRegistry() + registry.set_secure_item("openPypeMongo", new_mongo_url) From a6ac99c7558273f093a7c41a95fc68b37778beeb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 11:31:41 +0200 Subject: [PATCH 056/515] added ability to define name of registry --- igniter/user_settings.py | 7 ++++--- openpype/lib/local_settings.py | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/igniter/user_settings.py b/igniter/user_settings.py index 77fb8b5ae5..f9bbad4e5e 100644 --- a/igniter/user_settings.py +++ b/igniter/user_settings.py @@ -459,9 +459,10 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): """ - def __init__(self): + def __init__(self, name=None): self.vendor = "pypeclub" self.product = "openpype" + if name is None: + name = "openpype_settings" path = appdirs.user_data_dir(self.product, self.vendor) - super(OpenPypeSettingsRegistry, self).__init__( - "openpype_settings", path) + super(OpenPypeSettingsRegistry, self).__init__(name, path) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 2095f1253e..6386a69026 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -461,11 +461,13 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): """ - def __init__(self): + def __init__(self, name=None): self.vendor = "pypeclub" self.product = "openpype" + if name is None: + name = "openpype_settings" path = appdirs.user_data_dir(self.product, self.vendor) - super(OpenPypeSettingsRegistry, self).__init__("openpype_settings", path) + super(OpenPypeSettingsRegistry, self).__init__(name, path) def _create_local_site_id(registry=None): From 11fe828809c7399f2aeba8a97a68736db2b41ae5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 11:52:59 +0200 Subject: [PATCH 057/515] fix tests --- tests/igniter/test_bootstrap_repos.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/igniter/test_bootstrap_repos.py b/tests/igniter/test_bootstrap_repos.py index 75996b4026..6c70380ab6 100644 --- a/tests/igniter/test_bootstrap_repos.py +++ b/tests/igniter/test_bootstrap_repos.py @@ -11,7 +11,7 @@ import pytest from igniter.bootstrap_repos import BootstrapRepos from igniter.bootstrap_repos import PypeVersion -from pype.lib import PypeSettingsRegistry +from pype.lib import OpenPypeSettingsRegistry @pytest.fixture @@ -348,7 +348,7 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): return d_path.as_posix() monkeypatch.setattr(appdirs, "user_data_dir", mock_user_data_dir) - fix_bootstrap.registry = PypeSettingsRegistry() + fix_bootstrap.registry = OpenPypeSettingsRegistry() fix_bootstrap.registry.set_item("pypePath", d_path.as_posix()) result = fix_bootstrap.find_pype(include_zips=True) From b742f3a9c56000d4d23693b05600267c9c5adb91 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 12:42:53 +0200 Subject: [PATCH 058/515] credentials are not stored per user --- openpype/modules/ftrack/lib/credentials.py | 29 +++++----------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 3d9aa75e84..c2812083ba 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -17,8 +17,6 @@ CREDENTIALS_FOLDER = os.path.dirname(CREDENTIALS_PATH) if not os.path.isdir(CREDENTIALS_FOLDER): os.makedirs(CREDENTIALS_FOLDER) -USER_GETTER = None - def get_ftrack_hostname(ftrack_server=None): if not ftrack_server: @@ -30,13 +28,7 @@ def get_ftrack_hostname(ftrack_server=None): return urlparse(ftrack_server).hostname -def get_user(): - if USER_GETTER: - return USER_GETTER() - return getpass.getuser() - - -def get_credentials(ftrack_server=None, user=None): +def get_credentials(ftrack_server=None): credentials = {} if not os.path.exists(CREDENTIALS_PATH): with open(CREDENTIALS_PATH, "w") as file: @@ -48,28 +40,22 @@ def get_credentials(ftrack_server=None, user=None): content = file.read() hostname = get_ftrack_hostname(ftrack_server) - if not user: - user = get_user() content_json = json.loads(content or "{}") - credentials = content_json.get(hostname, {}).get(user) or {} + credentials = content_json.get(hostname) or {} return credentials -def save_credentials(ft_user, ft_api_key, ftrack_server=None, user=None): +def save_credentials(ft_user, ft_api_key, ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) - if not user: - user = get_user() with open(CREDENTIALS_PATH, "r") as file: content = file.read() content_json = json.loads(content or "{}") - if hostname not in content_json: - content_json[hostname] = {} - content_json[hostname][user] = { + content_json[hostname] = { "username": ft_user, "api_key": ft_api_key } @@ -84,7 +70,7 @@ def save_credentials(ft_user, ft_api_key, ftrack_server=None, user=None): file.write(json.dumps(content_json, indent=4)) -def clear_credentials(ft_user=None, ftrack_server=None, user=None): +def clear_credentials(ft_user=None, ftrack_server=None): if not ft_user: ft_user = os.environ.get("FTRACK_API_USER") @@ -92,9 +78,6 @@ def clear_credentials(ft_user=None, ftrack_server=None, user=None): return hostname = get_ftrack_hostname(ftrack_server) - if not user: - user = get_user() - with open(CREDENTIALS_PATH, "r") as file: content = file.read() @@ -102,7 +85,7 @@ def clear_credentials(ft_user=None, ftrack_server=None, user=None): if hostname not in content_json: content_json[hostname] = {} - content_json[hostname].pop(user, None) + content_json.pop(hostname, None) with open(CREDENTIALS_PATH, "w") as file: file.write(json.dumps(content_json)) From 5a7d19337cdc8efee1e60f6f59fb72c603206f41 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 12:43:27 +0200 Subject: [PATCH 059/515] added helper function to get secure item key the same way --- openpype/modules/ftrack/lib/credentials.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index c2812083ba..7a0d93f120 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -28,6 +28,11 @@ def get_ftrack_hostname(ftrack_server=None): return urlparse(ftrack_server).hostname +def _get_ftrack_secure_key(hostname): + """Secure item key for entered hostname.""" + return "/".join(("ftrack", hostname)) + + def get_credentials(ftrack_server=None): credentials = {} if not os.path.exists(CREDENTIALS_PATH): From 153160f85d1ae2a994462eabfdc0cb9f224f9ee6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 12:58:45 +0200 Subject: [PATCH 060/515] use OpenPypeRegistry to set, get and remove ftrack credentials --- openpype/modules/ftrack/lib/credentials.py | 94 +++++++--------------- 1 file changed, 27 insertions(+), 67 deletions(-) diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 7a0d93f120..05a74c0875 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -1,21 +1,16 @@ import os -import json import ftrack_api -import appdirs -import getpass + try: from urllib.parse import urlparse except ImportError: from urlparse import urlparse -CONFIG_PATH = os.path.normpath(appdirs.user_data_dir("pype-app", "pype")) -CREDENTIALS_FILE_NAME = "ftrack_cred.json" -CREDENTIALS_PATH = os.path.join(CONFIG_PATH, CREDENTIALS_FILE_NAME) -CREDENTIALS_FOLDER = os.path.dirname(CREDENTIALS_PATH) +from openpype.lib import OpenPypeSettingsRegistry -if not os.path.isdir(CREDENTIALS_FOLDER): - os.makedirs(CREDENTIALS_FOLDER) +USERNAME_KEY = "username" +API_KEY_KEY = "api_key" def get_ftrack_hostname(ftrack_server=None): @@ -34,84 +29,49 @@ def _get_ftrack_secure_key(hostname): def get_credentials(ftrack_server=None): - credentials = {} - if not os.path.exists(CREDENTIALS_PATH): - with open(CREDENTIALS_PATH, "w") as file: - file.write(json.dumps(credentials)) - file.close() - return credentials - - with open(CREDENTIALS_PATH, "r") as file: - content = file.read() - hostname = get_ftrack_hostname(ftrack_server) + secure_key = _get_ftrack_secure_key(hostname) - content_json = json.loads(content or "{}") - credentials = content_json.get(hostname) or {} - - return credentials - - -def save_credentials(ft_user, ft_api_key, ftrack_server=None): - hostname = get_ftrack_hostname(ftrack_server) - - with open(CREDENTIALS_PATH, "r") as file: - content = file.read() - - content_json = json.loads(content or "{}") - - content_json[hostname] = { - "username": ft_user, - "api_key": ft_api_key + registry = OpenPypeSettingsRegistry(secure_key) + return { + USERNAME_KEY: registry.get_secure_item(USERNAME_KEY, None), + API_KEY_KEY: registry.get_secure_item(API_KEY_KEY, None) } - # Deprecated keys - if "username" in content_json: - content_json.pop("username") - if "apiKey" in content_json: - content_json.pop("apiKey") - - with open(CREDENTIALS_PATH, "w") as file: - file.write(json.dumps(content_json, indent=4)) - - -def clear_credentials(ft_user=None, ftrack_server=None): - if not ft_user: - ft_user = os.environ.get("FTRACK_API_USER") - - if not ft_user: - return +def save_credentials(username, api_key, ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) - with open(CREDENTIALS_PATH, "r") as file: - content = file.read() + secure_key = _get_ftrack_secure_key(hostname) - content_json = json.loads(content or "{}") - if hostname not in content_json: - content_json[hostname] = {} - - content_json.pop(hostname, None) - - with open(CREDENTIALS_PATH, "w") as file: - file.write(json.dumps(content_json)) + registry = OpenPypeSettingsRegistry(secure_key) + registry.set_secure_item(USERNAME_KEY, username) + registry.set_secure_item(API_KEY_KEY, api_key) -def check_credentials(ft_user, ft_api_key, ftrack_server=None): +def clear_credentials(ftrack_server=None): + hostname = get_ftrack_hostname(ftrack_server) + secure_key = _get_ftrack_secure_key(hostname) + + registry = OpenPypeSettingsRegistry(secure_key) + registry.delete_secure_item(USERNAME_KEY) + registry.delete_secure_item(API_KEY_KEY) + + +def check_credentials(username, api_key, ftrack_server=None): if not ftrack_server: ftrack_server = os.environ["FTRACK_SERVER"] - if not ft_user or not ft_api_key: + if not username or not api_key: return False try: session = ftrack_api.Session( server_url=ftrack_server, - api_key=ft_api_key, - api_user=ft_user + api_key=api_key, + api_user=username ) session.close() except Exception: return False - return True From 983b9264ec38c024fcfaab47b8b416a5c1b551f1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 14:11:22 +0200 Subject: [PATCH 061/515] clockify is using keyring to store and receive api key --- openpype/modules/clockify/clockify_api.py | 26 +++++++++-------------- openpype/modules/clockify/constants.py | 9 ++------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/openpype/modules/clockify/clockify_api.py b/openpype/modules/clockify/clockify_api.py index d88b2ef8df..e2de726f39 100644 --- a/openpype/modules/clockify/clockify_api.py +++ b/openpype/modules/clockify/clockify_api.py @@ -1,13 +1,17 @@ import os import re import time -import requests import json import datetime +import requests from .constants import ( - CLOCKIFY_ENDPOINT, ADMIN_PERMISSION_NAMES, CREDENTIALS_JSON_PATH + CLOCKIFY_ENDPOINT, + ADMIN_PERMISSION_NAMES ) +# from openpype.lib import OpenPypeSettingsRegistry +from openpype.lib.local_settings import OpenPypeSecureRegistry as OpenPypeSettingsRegistry + def time_check(obj): if obj.request_counter < 10: @@ -31,6 +35,8 @@ class ClockifyAPI: self.request_counter = 0 self.request_time = time.time() + self.secure_registry = OpenPypeSettingsRegistry("clockify") + @property def headers(self): return {"X-Api-Key": self.api_key} @@ -129,22 +135,10 @@ class ClockifyAPI: return False def get_api_key(self): - api_key = None - try: - file = open(CREDENTIALS_JSON_PATH, 'r') - api_key = json.load(file).get('api_key', None) - if api_key == '': - api_key = None - except Exception: - file = open(CREDENTIALS_JSON_PATH, 'w') - file.close() - return api_key + return self.secure_registry.get_secure_item("api_key", None) def save_api_key(self, api_key): - data = {'api_key': api_key} - file = open(CREDENTIALS_JSON_PATH, 'w') - file.write(json.dumps(data)) - file.close() + self.secure_registry.set_secure_item("api_key", api_key) def get_workspaces(self): action_url = 'workspaces/' diff --git a/openpype/modules/clockify/constants.py b/openpype/modules/clockify/constants.py index 38ad4b64cf..66f6cb899a 100644 --- a/openpype/modules/clockify/constants.py +++ b/openpype/modules/clockify/constants.py @@ -1,17 +1,12 @@ import os -import appdirs CLOCKIFY_FTRACK_SERVER_PATH = os.path.join( - os.path.dirname(__file__), "ftrack", "server" + os.path.dirname(os.path.abspath(__file__)), "ftrack", "server" ) CLOCKIFY_FTRACK_USER_PATH = os.path.join( - os.path.dirname(__file__), "ftrack", "user" + os.path.dirname(os.path.abspath(__file__)), "ftrack", "user" ) -CREDENTIALS_JSON_PATH = os.path.normpath(os.path.join( - appdirs.user_data_dir("pype-app", "pype"), - "clockify.json" -)) ADMIN_PERMISSION_NAMES = ["WORKSPACE_OWN", "WORKSPACE_ADMIN"] CLOCKIFY_ENDPOINT = "https://api.clockify.me/api/" From 72e0c425434fd278d81ff343be6b3680d59fec68 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 8 Apr 2021 14:27:30 +0200 Subject: [PATCH 062/515] small refactors --- .../hosts/maya/plugins/publish/collect_look.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index c51b00c523..bd8d2f78d1 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -4,7 +4,7 @@ import re import os import glob -from maya import cmds +from maya import cmds # noqa import pyblish.api from openpype.hosts.maya.api import lib @@ -36,7 +36,6 @@ def get_look_attrs(node): list: Attribute names to extract """ - # When referenced get only attributes that are "changed since file open" # which includes any reference edits, otherwise take *all* user defined # attributes @@ -227,7 +226,12 @@ class CollectLook(pyblish.api.InstancePlugin): self.collect(instance) def collect(self, instance): + """Collect looks. + Args: + instance: Instance to collect. + + """ self.log.info("Looking for look associations " "for %s" % instance.data['name']) @@ -477,6 +481,11 @@ class CollectLook(pyblish.api.InstancePlugin): """ self.log.debug("processing: {}".format(node)) + if cmds.nodeType(node) not in ["file", "aiImage"]: + self.log.error( + "Unsupported file node: {}".format(cmds.nodeType(node))) + raise AssertionError("Unsupported file node") + if cmds.nodeType(node) == 'file': self.log.debug(" - file node") attribute = "{}.fileTextureName".format(node) @@ -485,6 +494,7 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.debug("aiImage node") attribute = "{}.filename".format(node) computed_attribute = attribute + source = cmds.getAttr(attribute) self.log.info(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) From 331b80d6fe2cd2da3a7db725439f3dac51caf4df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 8 Apr 2021 15:15:47 +0200 Subject: [PATCH 063/515] SyncServer - fix scrolling --- openpype/modules/sync_server/tray/app.py | 47 ++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 8a8ddc014a..66ba58ae63 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -307,6 +307,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.filter = QtWidgets.QLineEdit() self.filter.setPlaceholderText("Filter representations..") + self._scrollbar_pos = None + top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.filter) @@ -361,6 +363,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view.customContextMenuRequested.connect( self._on_context_menu) + model.refresh_started.connect(self._save_scrollbar) + model.refresh_finished.connect(self._set_scrollbar) self.table_view.model().modelReset.connect(self._set_selection) self.selection_model = self.table_view.selectionModel() @@ -542,6 +546,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.message_generated.emit("Site {} removed".format(local_site)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _reset_local_site(self): """ @@ -553,6 +559,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.representation_id, 'local' ) + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _reset_remote_site(self): """ @@ -564,6 +572,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.representation_id, 'remote' ) + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _open_in_explorer(self, site): if not self.item: @@ -587,6 +597,13 @@ class SyncRepresentationWidget(QtWidgets.QWidget): except OSError: raise OSError('unsupported xdg-open call??') + def _save_scrollbar(self): + self._scrollbar_pos = self.table_view.verticalScrollBar().value() + + def _set_scrollbar(self): + if self._scrollbar_pos: + self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + ProviderRole = QtCore.Qt.UserRole + 2 ProgressRole = QtCore.Qt.UserRole + 4 @@ -632,6 +649,9 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): "status" # state ] + refresh_started = QtCore.Signal() + refresh_finished = QtCore.Signal() + @attr.s class SyncRepresentation: """ @@ -781,7 +801,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): if self.sync_server.is_paused() or \ self.sync_server.is_project_paused(self.project): return - + self.refresh_started.emit() self.beginResetModel() self._data = [] self._rec_loaded = 0 @@ -793,6 +813,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self._add_page_records(self.local_site, self.remote_site, representations) self.endResetModel() + self.refresh_finished.emit() def _add_page_records(self, local_site, remote_site, representations): """ @@ -1307,6 +1328,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.filter = QtWidgets.QLineEdit() self.filter.setPlaceholderText("Filter representation..") + self._scrollbar_pos = None + top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.filter) @@ -1360,6 +1383,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.customContextMenuRequested.connect( self._on_context_menu) + model.refresh_started.connect(self._save_scrollbar) + model.refresh_finished.connect(self._set_scrollbar) self.table_view.model().modelReset.connect(self._set_selection) self.selection_model = self.table_view.selectionModel() @@ -1377,7 +1402,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): """ if self._selected_id: index = self.table_view.model().get_index(self._selected_id) - if index.isValid(): + if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows self.selection_model.setCurrentIndex(index, mode) @@ -1468,7 +1493,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.representation_id, 'local', self.item._id) - self.table_view.model().refresh() + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _reset_remote_site(self): """ @@ -1480,7 +1506,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.representation_id, 'remote', self.item._id) - self.table_view.model().refresh() + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _open_in_explorer(self, site): if not self.item: @@ -1502,6 +1529,13 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): except OSError: raise OSError('unsupported xdg-open call??') + def _save_scrollbar(self): + self._scrollbar_pos = self.table_view.verticalScrollBar().value() + + def _set_scrollbar(self): + if self._scrollbar_pos: + self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): """ @@ -1535,6 +1569,9 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): "status" # state ] + refresh_started = QtCore.Signal() + refresh_finished = QtCore.Signal() + @attr.s class SyncRepresentationDetail: """ @@ -1665,6 +1702,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): if self.sync_server.is_paused(): return + self.refresh_started.emit() self.beginResetModel() self._data = [] self._rec_loaded = 0 @@ -1676,6 +1714,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self._add_page_records(self.local_site, self.remote_site, representations) self.endResetModel() + self.refresh_finished.emit() def _add_page_records(self, local_site, remote_site, representations): """ From 68fe4c8a934ffac8df4307d2cbd3768bb3feb6ae Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 15:32:32 +0200 Subject: [PATCH 064/515] implemented OpenPypeSecureRegistry handling keyring information --- igniter/user_settings.py | 93 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/igniter/user_settings.py b/igniter/user_settings.py index f9bbad4e5e..1d4fcd04eb 100644 --- a/igniter/user_settings.py +++ b/igniter/user_settings.py @@ -28,6 +28,99 @@ import platform import appdirs import six +_PLACEHOLDER = object() + + +class OpenPypeSecureRegistry: + def __init__(self, name): + try: + import keyring + + except Exception: + raise NotImplementedError( + "Python module `keyring` is not available." + ) + + # hack for cx_freeze and Windows keyring backend + if platform.system().lower() == "windows": + from keyring.backends import Windows + + keyring.set_keyring(Windows.WinVaultKeyring()) + + # Force "OpenPype" prefix + self._name = "/".join(("OpenPype", name)) + + def set_item(self, name, value): + # type: (str, str) -> None + """Set sensitive item into system's keyring. + + This uses `Keyring module`_ to save sensitive stuff into system's + keyring. + + Args: + name (str): Name of the item. + value (str): Value of the item. + + .. _Keyring module: + https://github.com/jaraco/keyring + + """ + import keyring + + keyring.set_password(self._name, name, value) + + @lru_cache(maxsize=32) + def get_item(self, name, default=_PLACEHOLDER): + """Get value of sensitive item from system's keyring. + + See also `Keyring module`_ + + Args: + name (str): Name of the item. + default (Any): Default value if item is not available. + + Returns: + value (str): Value of the item. + + Raises: + ValueError: If item doesn't exist and default is not defined. + + .. _Keyring module: + https://github.com/jaraco/keyring + + """ + import keyring + + value = keyring.get_password(self._name, name) + if value: + return value + + if default is not _PLACEHOLDER: + return default + + # NOTE Should raise `KeyError` + raise ValueError( + "Item {}:{} does not exist in keyring.".format(self._name, name) + ) + + def delete_item(self, name): + # type: (str) -> None + """Delete value stored in system's keyring. + + See also `Keyring module`_ + + Args: + name (str): Name of the item to be deleted. + + .. _Keyring module: + https://github.com/jaraco/keyring + + """ + import keyring + + self.get_item.cache_clear() + keyring.delete_password(self._name, name) + @six.add_metaclass(ABCMeta) class ASettingRegistry(): From d08fde832a2b4c7d5fccee8772c3646cbc3be47a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 15:34:30 +0200 Subject: [PATCH 065/515] bootstrap repos also has secure_registry which is used for mongo url storage --- igniter/bootstrap_repos.py | 6 +++++- igniter/install_thread.py | 6 +++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 2f305e24e3..51dec7f51e 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -14,7 +14,10 @@ from zipfile import ZipFile, BadZipFile from appdirs import user_data_dir from speedcopy import copyfile -from .user_settings import OpenPypeSettingsRegistry +from .user_settings import ( + OpenPypeSecureRegistry, + OpenPypeSettingsRegistry +) from .tools import get_openpype_path_from_db @@ -239,6 +242,7 @@ class BootstrapRepos: self._app = "openpype" self._log = log.getLogger(str(__class__)) self.data_dir = Path(user_data_dir(self._app, self._vendor)) + self.secure_registry = OpenPypeSecureRegistry("Settings") self.registry = OpenPypeSettingsRegistry() self.zip_filter = [".pyc", "__pycache__"] self.openpype_filter = [ diff --git a/igniter/install_thread.py b/igniter/install_thread.py index bf5d541056..df8b830209 100644 --- a/igniter/install_thread.py +++ b/igniter/install_thread.py @@ -71,7 +71,7 @@ class InstallThread(QThread): if not os.getenv("OPENPYPE_MONGO"): # try to get it from settings registry try: - self._mongo = bs.registry.get_secure_item( + self._mongo = bs.secure_registry.get_item( "openPypeMongo") except ValueError: self.message.emit( @@ -82,7 +82,7 @@ class InstallThread(QThread): self._mongo = os.getenv("OPENPYPE_MONGO") else: self.message.emit("Saving mongo connection string ...", False) - bs.registry.set_secure_item("openPypeMongo", self._mongo) + bs.secure_registry.set_item("openPypeMongo", self._mongo) os.environ["OPENPYPE_MONGO"] = self._mongo @@ -169,7 +169,7 @@ class InstallThread(QThread): f"!!! invalid mongo url {self._mongo}", True) self.finished.emit(InstallResult(-1)) return - bs.registry.set_secure_item("openPypeMongo", self._mongo) + bs.secure_registry.set_item("openPypeMongo", self._mongo) os.environ["OPENPYPE_MONGO"] = self._mongo self.message.emit(f"processing {self._path}", True) From 41beb27a693e7d808da72595c83c7ca003d98309 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 15:35:15 +0200 Subject: [PATCH 066/515] secure registry is also used in install dialog --- igniter/install_dialog.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index 2cc0ed8448..dab00079a5 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -13,7 +13,7 @@ from .tools import ( validate_mongo_connection, get_openpype_path_from_db ) -from .user_settings import OpenPypeSettingsRegistry +from .user_settings import OpenPypeSecureRegistry from .version import __version__ @@ -42,13 +42,13 @@ class InstallDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(InstallDialog, self).__init__(parent) - self.registry = OpenPypeSettingsRegistry() + self.secure_registry = OpenPypeSecureRegistry("Settings") self.mongo_url = "" try: self.mongo_url = ( os.getenv("OPENPYPE_MONGO", "") - or self.registry.get_secure_item("openPypeMongo") + or self.secure_registry.get_item("openPypeMongo") ) except ValueError: pass From 3cc8599684a7374c98a9cd375fce588e382d91aa Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 15:36:22 +0200 Subject: [PATCH 067/515] removed secure getter and setters from ASettingRegistry --- igniter/user_settings.py | 83 +--------------------------------------- 1 file changed, 2 insertions(+), 81 deletions(-) diff --git a/igniter/user_settings.py b/igniter/user_settings.py index 1d4fcd04eb..bb5336a9ab 100644 --- a/igniter/user_settings.py +++ b/igniter/user_settings.py @@ -25,8 +25,8 @@ except ImportError: import platform -import appdirs import six +import appdirs _PLACEHOLDER = object() @@ -139,13 +139,6 @@ class ASettingRegistry(): # type: (str) -> ASettingRegistry super(ASettingRegistry, self).__init__() - if six.PY3: - import keyring - # hack for cx_freeze and Windows keyring backend - if platform.system() == "Windows": - from keyring.backends import Windows - keyring.set_keyring(Windows.WinVaultKeyring()) - self._name = name self._items = {} @@ -220,78 +213,6 @@ class ASettingRegistry(): del self._items[name] self._delete_item(name) - def set_secure_item(self, name, value): - # type: (str, str) -> None - """Set sensitive item into system's keyring. - - This uses `Keyring module`_ to save sensitive stuff into system's - keyring. - - Args: - name (str): Name of the item. - value (str): Value of the item. - - .. _Keyring module: - https://github.com/jaraco/keyring - - """ - if six.PY2: - raise NotImplementedError( - "Keyring not available on Python 2 hosts") - import keyring - keyring.set_password(self._name, name, value) - - @lru_cache(maxsize=32) - def get_secure_item(self, name): - # type: (str) -> str - """Get value of sensitive item from system's keyring. - - See also `Keyring module`_ - - Args: - name (str): Name of the item. - - Returns: - value (str): Value of the item. - - Raises: - ValueError: If item doesn't exist. - - .. _Keyring module: - https://github.com/jaraco/keyring - - """ - if six.PY2: - raise NotImplementedError( - "Keyring not available on Python 2 hosts") - import keyring - value = keyring.get_password(self._name, name) - if not value: - raise ValueError( - "Item {}:{} does not exist in keyring.".format( - self._name, name)) - return value - - def delete_secure_item(self, name): - # type: (str) -> None - """Delete value stored in system's keyring. - - See also `Keyring module`_ - - Args: - name (str): Name of the item to be deleted. - - .. _Keyring module: - https://github.com/jaraco/keyring - - """ - if six.PY2: - raise NotImplementedError( - "Keyring not available on Python 2 hosts") - import keyring - self.get_secure_item.cache_clear() - keyring.delete_password(self._name, name) - class IniSettingRegistry(ASettingRegistry): """Class using :mod:`configparser`. @@ -555,7 +476,7 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): def __init__(self, name=None): self.vendor = "pypeclub" self.product = "openpype" - if name is None: + if not name: name = "openpype_settings" path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) From 454bd9d1b51b4b4782bff4e2afbaba2d7102aad4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 15:40:21 +0200 Subject: [PATCH 068/515] copy pasted secure registry logic from igniter to pype lib --- openpype/lib/local_settings.py | 195 ++++++++++++++++++--------------- 1 file changed, 105 insertions(+), 90 deletions(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 6386a69026..ec76b57cfd 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -5,6 +5,7 @@ from datetime import datetime from abc import ABCMeta, abstractmethod import json +# TODO Use pype igniter logic instead of using duplicated code # disable lru cache in Python 2 try: from functools import lru_cache @@ -25,11 +26,104 @@ except ImportError: import platform -import appdirs import six +import appdirs from .import validate_mongo_connection +_PLACEHOLDER = object() + + +class OpenPypeSecureRegistry: + def __init__(self, name): + try: + import keyring + + except Exception: + raise NotImplementedError( + "Python module `keyring` is not available." + ) + + # hack for cx_freeze and Windows keyring backend + if platform.system().lower() == "windows": + from keyring.backends import Windows + + keyring.set_keyring(Windows.WinVaultKeyring()) + + # Force "OpenPype" prefix + self._name = "/".join(("OpenPype", name)) + + def set_item(self, name, value): + # type: (str, str) -> None + """Set sensitive item into system's keyring. + + This uses `Keyring module`_ to save sensitive stuff into system's + keyring. + + Args: + name (str): Name of the item. + value (str): Value of the item. + + .. _Keyring module: + https://github.com/jaraco/keyring + + """ + import keyring + + keyring.set_password(self._name, name, value) + + @lru_cache(maxsize=32) + def get_item(self, name, default=_PLACEHOLDER): + """Get value of sensitive item from system's keyring. + + See also `Keyring module`_ + + Args: + name (str): Name of the item. + default (Any): Default value if item is not available. + + Returns: + value (str): Value of the item. + + Raises: + ValueError: If item doesn't exist and default is not defined. + + .. _Keyring module: + https://github.com/jaraco/keyring + + """ + import keyring + + value = keyring.get_password(self._name, name) + if value: + return value + + if default is not _PLACEHOLDER: + return default + + # NOTE Should raise `KeyError` + raise ValueError( + "Item {}:{} does not exist in keyring.".format(self._name, name) + ) + + def delete_item(self, name): + # type: (str) -> None + """Delete value stored in system's keyring. + + See also `Keyring module`_ + + Args: + name (str): Name of the item to be deleted. + + .. _Keyring module: + https://github.com/jaraco/keyring + + """ + import keyring + + self.get_item.cache_clear() + keyring.delete_password(self._name, name) + @six.add_metaclass(ABCMeta) class ASettingRegistry(): @@ -48,13 +142,6 @@ class ASettingRegistry(): # type: (str) -> ASettingRegistry super(ASettingRegistry, self).__init__() - if six.PY3: - import keyring - # hack for cx_freeze and Windows keyring backend - if platform.system() == "Windows": - from keyring.backends import Windows - keyring.set_keyring(Windows.WinVaultKeyring()) - self._name = name self._items = {} @@ -120,7 +207,7 @@ class ASettingRegistry(): """Delete item from settings. Note: - see :meth:`pype.lib.local_settings.ARegistrySettings.delete_item` + see :meth:`openpype.lib.user_settings.ARegistrySettings.delete_item` """ pass @@ -129,78 +216,6 @@ class ASettingRegistry(): del self._items[name] self._delete_item(name) - def set_secure_item(self, name, value): - # type: (str, str) -> None - """Set sensitive item into system's keyring. - - This uses `Keyring module`_ to save sensitive stuff into system's - keyring. - - Args: - name (str): Name of the item. - value (str): Value of the item. - - .. _Keyring module: - https://github.com/jaraco/keyring - - """ - if six.PY2: - raise NotImplementedError( - "Keyring not available on Python 2 hosts") - import keyring - keyring.set_password(self._name, name, value) - - @lru_cache(maxsize=32) - def get_secure_item(self, name): - # type: (str) -> str - """Get value of sensitive item from system's keyring. - - See also `Keyring module`_ - - Args: - name (str): Name of the item. - - Returns: - value (str): Value of the item. - - Raises: - ValueError: If item doesn't exist. - - .. _Keyring module: - https://github.com/jaraco/keyring - - """ - if six.PY2: - raise NotImplementedError( - "Keyring not available on Python 2 hosts") - import keyring - value = keyring.get_password(self._name, name) - if not value: - raise ValueError( - "Item {}:{} does not exist in keyring.".format( - self._name, name)) - return value - - def delete_secure_item(self, name): - # type: (str) -> None - """Delete value stored in system's keyring. - - See also `Keyring module`_ - - Args: - name (str): Name of the item to be deleted. - - .. _Keyring module: - https://github.com/jaraco/keyring - - """ - if six.PY2: - raise NotImplementedError( - "Keyring not available on Python 2 hosts") - import keyring - self.get_secure_item.cache_clear() - keyring.delete_password(self._name, name) - class IniSettingRegistry(ASettingRegistry): """Class using :mod:`configparser`. @@ -218,7 +233,7 @@ class IniSettingRegistry(ASettingRegistry): if not os.path.exists(self._registry_file): with open(self._registry_file, mode="w") as cfg: print("# Settings registry", cfg) - print("# Generated by Pype {}".format(version), cfg) + print("# Generated by OpenPype {}".format(version), cfg) now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") print("# {}".format(now), cfg) @@ -352,7 +367,7 @@ class IniSettingRegistry(ASettingRegistry): """Delete item from default section. Note: - See :meth:`~pype.lib.IniSettingsRegistry.delete_item_from_section` + See :meth:`~openpype.lib.IniSettingsRegistry.delete_item_from_section` """ self.delete_item_from_section("MAIN", name) @@ -369,7 +384,7 @@ class JSONSettingRegistry(ASettingRegistry): now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") header = { "__metadata__": { - "pype-version": os.getenv("OPENPYPE_VERSION", "N/A"), + "openpype-version": os.getenv("OPENPYPE_VERSION", "N/A"), "generated": now }, "registry": {} @@ -387,7 +402,7 @@ class JSONSettingRegistry(ASettingRegistry): """Get item value from registry json. Note: - See :meth:`pype.lib.JSONSettingRegistry.get_item` + See :meth:`openpype.lib.JSONSettingRegistry.get_item` """ with open(self._registry_file, mode="r") as cfg: @@ -420,7 +435,7 @@ class JSONSettingRegistry(ASettingRegistry): """Set item value to registry json. Note: - See :meth:`pype.lib.JSONSettingRegistry.set_item` + See :meth:`openpype.lib.JSONSettingRegistry.set_item` """ with open(self._registry_file, "r+") as cfg: @@ -453,7 +468,7 @@ class JSONSettingRegistry(ASettingRegistry): class OpenPypeSettingsRegistry(JSONSettingRegistry): - """Class handling Pype general settings registry. + """Class handling OpenPype general settings registry. Attributes: vendor (str): Name used for path construction. @@ -464,7 +479,7 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): def __init__(self, name=None): self.vendor = "pypeclub" self.product = "openpype" - if name is None: + if not name: name = "openpype_settings" path = appdirs.user_data_dir(self.product, self.vendor) super(OpenPypeSettingsRegistry, self).__init__(name, path) @@ -506,5 +521,5 @@ def change_openpype_mongo_url(new_mongo_url): """ validate_mongo_connection(new_mongo_url) - registry = OpenPypeSettingsRegistry() - registry.set_secure_item("openPypeMongo", new_mongo_url) + registry = OpenPypeSecureRegistry("Settings") + registry.set_item("openPypeMongo", new_mongo_url) From 89a49d9d1279f0b71c921b29e84285d7b3069362 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 8 Apr 2021 15:59:22 +0200 Subject: [PATCH 069/515] Settings cleaned up icons, replaced by standard from resources --- openpype/modules/sync_server/tray/app.py | 5 ++++- .../tools/settings/settings/style/__init__.py | 3 ++- .../tools/settings/settings/style/pype_icon.png | Bin 3793 -> 0 bytes 3 files changed, 6 insertions(+), 2 deletions(-) delete mode 100644 openpype/tools/settings/settings/style/pype_icon.png diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 66ba58ae63..e0b7c20631 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -16,6 +16,7 @@ from bson.objectid import ObjectId from openpype.lib import PypeLogger from openpype.api import get_local_site_id +from openpype import resources log = PypeLogger().get_logger("SyncServer") @@ -44,7 +45,7 @@ class SyncServerWindow(QtWidgets.QDialog): self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet(style.load_stylesheet()) - self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) self.resize(1400, 800) self.timer = QtCore.QTimer() @@ -327,6 +328,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view.horizontalHeader().setSortIndicator( -1, Qt.AscendingOrder) self.table_view.setSortingEnabled(True) + self.table_view.horizontalHeader().setSortIndicatorShown(True) self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() @@ -1348,6 +1350,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) self.table_view.setSortingEnabled(True) + self.table_view.horizontalHeader().setSortIndicatorShown(True) self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() diff --git a/openpype/tools/settings/settings/style/__init__.py b/openpype/tools/settings/settings/style/__init__.py index 9bb5e851b4..5a57642ee1 100644 --- a/openpype/tools/settings/settings/style/__init__.py +++ b/openpype/tools/settings/settings/style/__init__.py @@ -1,4 +1,5 @@ import os +from openpype import resources def load_stylesheet(): @@ -9,4 +10,4 @@ def load_stylesheet(): def app_icon_path(): - return os.path.join(os.path.dirname(__file__), "openpype_icon.png") + return resources.pype_icon_filepath() diff --git a/openpype/tools/settings/settings/style/pype_icon.png b/openpype/tools/settings/settings/style/pype_icon.png deleted file mode 100644 index bfacf6eeedc753db0d77e661843a78b0eb13ce38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3793 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 b;3u>qRjE!lxug$XMKJrL$8D;P_+R@UX<7IA From 2890a5fcbc31207bc86ee0d7245460b6de3c5e58 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Apr 2021 16:30:13 +0200 Subject: [PATCH 070/515] Hiero: otio publishing workflow wip --- openpype/hosts/hiero/otio/hiero_export_.py | 312 +++++++++++++++++++++ openpype/hosts/hiero/otio/utils.py | 83 ++++++ 2 files changed, 395 insertions(+) create mode 100644 openpype/hosts/hiero/otio/hiero_export_.py create mode 100644 openpype/hosts/hiero/otio/utils.py diff --git a/openpype/hosts/hiero/otio/hiero_export_.py b/openpype/hosts/hiero/otio/hiero_export_.py new file mode 100644 index 0000000000..b5a96a9414 --- /dev/null +++ b/openpype/hosts/hiero/otio/hiero_export_.py @@ -0,0 +1,312 @@ +""" compatibility OpenTimelineIO 0.12.0 and older +""" + +import os +import re +import sys +import opentimelineio as otio +from . import utils +import hiero.core +import hiero.ui + +self = sys.modules[__name__] +self.track_types = { + hiero.core.VideoTrack: otio.schema.TrackKind.Video, + hiero.core.AudioTrack: otio.schema.TrackKind.Audio +} +self.project_fps = None +self.marker_color_map = { + "magenta": otio.schema.MarkerColor.MAGENTA, + "red": otio.schema.MarkerColor.RED, + "yellow": otio.schema.MarkerColor.YELLOW, + "green": otio.schema.MarkerColor.GREEN, + "cyan": otio.schema.MarkerColor.CYAN, + "blue": otio.schema.MarkerColor.BLUE, +} +self.timeline = None +self.include_tags = None + + +def create_otio_rational_time(frame, fps): + return otio.opentime.RationalTime( + float(frame), + float(fps) + ) + + +def create_otio_time_range(start_frame, frame_duration, fps): + return otio.opentime.TimeRange( + start_time=create_otio_rational_time(start_frame, fps), + duration=create_otio_rational_time(frame_duration, fps) + ) + + +def _get_metadata(item): + if hasattr(item, 'metadata'): + return {key: value for key, value in item.metadata().items()} + return {} + + +def create_otio_reference(clip): + metadata = _get_metadata(clip) + media_source = clip.mediaSource() + + # get file info for path and start frame + file_info = media_source.fileinfos().pop() + frame_start = file_info.startFrame() + path = file_info.filename() + + # get padding and other file infos + padding = media_source.filenamePadding() + file_head = media_source.filenameHead() + is_sequence = not media_source.singleFile() + frame_duration = media_source.duration() + fps = utils.get_rate(clip) + extension = os.path.splitext(path)[-1] + + if is_sequence: + metadata.update({ + "isSequence": True, + "padding": padding + }) + + otio_ex_ref_item = None + + if is_sequence: + # if it is file sequence try to create `ImageSequenceReference` + # the OTIO might not be compatible so return nothing and do it old way + try: + dirname = os.path.dirname(path) + otio_ex_ref_item = otio.schema.ImageSequenceReference( + target_url_base=dirname + os.sep, + name_prefix=file_head, + name_suffix=extension, + start_frame=frame_start, + frame_zero_padding=padding, + rate=fps, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + except AttributeError: + pass + + if not otio_ex_ref_item: + reformat_path = utils.get_reformated_path(path, padded=False) + # in case old OTIO or video file create `ExternalReference` + otio_ex_ref_item = otio.schema.ExternalReference( + target_url=reformat_path, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + + # add metadata to otio item + add_otio_metadata(otio_ex_ref_item, media_source, **metadata) + + return otio_ex_ref_item + + +def get_marker_color(tag): + icon = tag.icon() + pat = r'icons:Tag(?P\w+)\.\w+' + + res = re.search(pat, icon) + if res: + color = res.groupdict().get('color') + if color.lower() in self.marker_color_map: + return self.marker_color_map[color.lower()] + + return otio.schema.MarkerColor.RED + + +def create_otio_markers(otio_item, track_item): + for tag in track_item.tags(): + if not tag.visible(): + continue + + if tag.name() == 'Copy': + # Hiero adds this tag to a lot of clips + continue + + frame_rate = utils.get_rate(track_item) + + marked_range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + tag.inTime(), + frame_rate + ), + duration=otio.opentime.RationalTime( + int(tag.metadata().dict().get('tag.length', '0')), + frame_rate + ) + ) + + metadata = dict( + Hiero=tag.metadata().dict() + ) + # Store the source item for future import assignment + metadata['Hiero']['source_type'] = track_item.__class__.__name__ + + marker = otio.schema.Marker( + name=tag.name(), + color=get_marker_color(tag), + marked_range=marked_range, + metadata=metadata + ) + + otio_item.markers.append(marker) + + +def create_otio_clip(track_item): + clip = track_item.source() + source_in = track_item.sourceIn() + duration = track_item.sourceDuration() + fps = utils.get_rate(track_item) + name = track_item.name() + + media_reference = create_otio_reference(clip) + source_range = create_otio_time_range( + int(source_in), + int(duration), + fps + ) + + otio_clip = otio.schema.Clip( + name=name, + source_range=source_range, + media_reference=media_reference + ) + create_otio_markers(otio_clip, track_item) + + return otio_clip + + +def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): + return otio.schema.Gap( + source_range=create_otio_time_range( + gap_start, + (clip_start - tl_start_frame) - gap_start, + fps + ) + ) + + +def _create_otio_timeline(): + metadata = _get_metadata(self.timeline) + start_time = create_otio_rational_time( + self.timeline.timecodeStart(), self.project_fps) + + return otio.schema.Timeline( + name=self.timeline.name(), + global_start_time=start_time, + metadata=metadata + ) + + +def _get_metadata_media_pool_item(media_pool_item): + data = dict() + data.update({k: v for k, v in media_pool_item.GetMetadata().items()}) + property = media_pool_item.GetClipProperty() or {} + for name, value in property.items(): + if "Resolution" in name and "" != value: + width, height = value.split("x") + data.update({ + "width": int(width), + "height": int(height) + }) + if "PAR" in name and "" != value: + try: + data.update({"pixelAspect": float(value)}) + except ValueError: + if "Square" in value: + data.update({"pixelAspect": float(1)}) + else: + data.update({"pixelAspect": float(1)}) + + return data + + +def create_otio_track(track_type, track_name): + return otio.schema.Track( + name=track_name, + kind=self.track_types[track_type] + ) + + +def add_otio_gap(clip_start, otio_track, item_start_frame): + # if gap between track start and clip start + if clip_start > otio_track.available_range().duration.value: + # create gap and add it to track + otio_track.append( + create_otio_gap( + otio_track.available_range().duration.value, + item_start_frame, + self.timeline.timecodeStart(), + self.project_fps + ) + ) + + +def add_otio_metadata(otio_item, media_source, **kwargs): + metadata = _get_metadata(media_source) + + # add additional metadata from kwargs + if kwargs: + metadata.update(kwargs) + + # add metadata to otio item metadata + for key, value in metadata.items(): + otio_item.metadata.update({key: value}) + + +def create_otio_timeline(): + + # get current timeline + self.timeline = hiero.ui.activeSequence() + self.project_fps = self.timeline.framerate().toFloat() + + # convert timeline to otio + otio_timeline = _create_otio_timeline() + + # loop all defined track types + for track in self.hiero_sequence.items(): + # skip if track is disabled + if not track.isEnabled(): + continue + + # convert track to otio + otio_track = create_otio_track( + type(track), track.name()) + + for itemindex, track_item in enumerate(track): + # skip offline track items + if not track_item.isMediaPresent(): + continue + + # skip if track item is disabled + if not track_item.isEnabled(): + continue + + # calculate real clip start + clip_start = track_item.timelineIn() + + add_otio_gap( + clip_start, otio_track, clip_start) + + # create otio clip and add it to track + otio_clip = create_otio_clip(track_item) + otio_track.append(otio_clip) + + # add track to otio timeline + otio_timeline.tracks.append(otio_track) + + return otio_timeline + + +def write_to_file(otio_timeline, path): + otio.adapters.write_to_file(otio_timeline, path) diff --git a/openpype/hosts/hiero/otio/utils.py b/openpype/hosts/hiero/otio/utils.py new file mode 100644 index 0000000000..12f963fe97 --- /dev/null +++ b/openpype/hosts/hiero/otio/utils.py @@ -0,0 +1,83 @@ +import re +import opentimelineio as otio + + +def timecode_to_frames(timecode, framerate): + rt = otio.opentime.from_timecode(timecode, 24) + return int(otio.opentime.to_frames(rt)) + + +def frames_to_timecode(frames, framerate): + rt = otio.opentime.from_frames(frames, framerate) + return otio.opentime.to_timecode(rt) + + +def frames_to_secons(frames, framerate): + rt = otio.opentime.from_frames(frames, framerate) + return otio.opentime.to_seconds(rt) + + +def get_reformated_path(path, padded=True, first=False): + """ + Return fixed python expression path + + Args: + path (str): path url or simple file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.[0001-1008].exr") > plate.%04d.exr + + """ + num_pattern = r"(\[\d+\-\d+\])" + padding_pattern = r"(\d+)(?=-)" + first_frame_pattern = re.compile(r"\[(\d+)\-\d+\]") + + if "[" in path: + padding = len(re.findall(padding_pattern, path).pop()) + if padded: + path = re.sub(num_pattern, f"%0{padding}d", path) + elif first: + first_frame = re.findall(first_frame_pattern, path, flags=0) + if len(first_frame) >= 1: + first_frame = first_frame[0] + path = re.sub(num_pattern, first_frame, path) + else: + path = re.sub(num_pattern, "%d", path) + return path + + +def get_padding_from_path(path): + """ + Return padding number from DaVinci Resolve sequence path style + + Args: + path (str): path url or simple file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.[0001-1008].exr") > 4 + + """ + padding_pattern = "(\\d+)(?=-)" + if "[" in path: + return len(re.findall(padding_pattern, path).pop()) + + return None + + +def get_rate(item): + if not hasattr(item, 'framerate'): + item = item.sequence() + + num, den = item.framerate().toRational() + rate = float(num) / float(den) + + if rate.is_integer(): + return rate + + return round(rate, 4) From 3785e9b53c5fc90bcfb32ea6dc39afb965b9e130 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 16:54:03 +0200 Subject: [PATCH 071/515] fix tests --- tests/pype/lib/test_user_settings.py | 32 ++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/pype/lib/test_user_settings.py b/tests/pype/lib/test_user_settings.py index 7f0f400f59..02342abbc9 100644 --- a/tests/pype/lib/test_user_settings.py +++ b/tests/pype/lib/test_user_settings.py @@ -1,10 +1,20 @@ import pytest -from pype.lib import IniSettingRegistry -from pype.lib import JSONSettingRegistry +from pype.lib import ( + IniSettingRegistry, + JSONSettingRegistry, + OpenPypeSecureRegistry +) from uuid import uuid4 import configparser +@pytest.fixture +def secure_registry(tmpdir): + name = "pypetest_{}".format(str(uuid4())) + r = OpenPypeSecureRegistry(name, tmpdir) + yield r + + @pytest.fixture def json_registry(tmpdir): name = "pypetest_{}".format(str(uuid4())) @@ -19,21 +29,21 @@ def ini_registry(tmpdir): yield r -def test_keyring(json_registry): - json_registry.set_secure_item("item1", "foo") - json_registry.set_secure_item("item2", "bar") - result1 = json_registry.get_secure_item("item1") - result2 = json_registry.get_secure_item("item2") +def test_keyring(secure_registry): + secure_registry.set_item("item1", "foo") + secure_registry.set_item("item2", "bar") + result1 = secure_registry.get_item("item1") + result2 = secure_registry.get_item("item2") assert result1 == "foo" assert result2 == "bar" - json_registry.delete_secure_item("item1") - json_registry.delete_secure_item("item2") + secure_registry.delete_item("item1") + secure_registry.delete_item("item2") with pytest.raises(ValueError): - json_registry.get_secure_item("item1") - json_registry.get_secure_item("item2") + secure_registry.get_item("item1") + secure_registry.get_item("item2") def test_ini_registry(ini_registry): From ee93c229fef4ed0b3cde55b6384bd8b704b7deac Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 16:54:32 +0200 Subject: [PATCH 072/515] added some docstring to OpenPypeSecureRegistry --- igniter/user_settings.py | 11 +++++++++++ openpype/lib/local_settings.py | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/igniter/user_settings.py b/igniter/user_settings.py index bb5336a9ab..2a406f83dd 100644 --- a/igniter/user_settings.py +++ b/igniter/user_settings.py @@ -32,6 +32,17 @@ _PLACEHOLDER = object() class OpenPypeSecureRegistry: + """Store information using keyring. + + Registry should be used for private data that should be available only for + user. + + All passed registry names will have added prefix `OpenPype/` to easier + identify which data were created by OpenPype. + + Args: + name(str): Name of registry used as identifier for data. + """ def __init__(self, name): try: import keyring diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index ec76b57cfd..1bd8540acb 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -35,6 +35,17 @@ _PLACEHOLDER = object() class OpenPypeSecureRegistry: + """Store information using keyring. + + Registry should be used for private data that should be available only for + user. + + All passed registry names will have added prefix `OpenPype/` to easier + identify which data were created by OpenPype. + + Args: + name(str): Name of registry used as identifier for data. + """ def __init__(self, name): try: import keyring From e81c9dd5b5ad7020d1da8ebfe39ab86854e2aafb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 16:54:55 +0200 Subject: [PATCH 073/515] fix bootstrap attribute access --- start.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/start.py b/start.py index 1f946a705c..36e4c36d21 100644 --- a/start.py +++ b/start.py @@ -296,7 +296,7 @@ def _determine_mongodb() -> str: if not openpype_mongo: # try system keyring try: - openpype_mongo = bootstrap.registry.get_secure_item( + openpype_mongo = bootstrap.secure_registry.get_item( "openPypeMongo") except ValueError: print("*** No DB connection string specified.") @@ -305,7 +305,7 @@ def _determine_mongodb() -> str: igniter.open_dialog() try: - openpype_mongo = bootstrap.registry.get_secure_item( + openpype_mongo = bootstrap.secure_registry.get_item( "openPypeMongo") except ValueError: raise RuntimeError("missing mongodb url") From 787d037ca02a1a7e295a446bd459b7c7b6ec8e5b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 17:08:04 +0200 Subject: [PATCH 074/515] imported OpenPypeSecureRegistry in pype.lib.__init__ --- openpype/lib/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 7b2f533921..ce8f8ec2b6 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -104,6 +104,7 @@ from .plugin_tools import ( from .local_settings import ( IniSettingRegistry, JSONSettingRegistry, + OpenPypeSecureRegistry, OpenPypeSettingsRegistry, get_local_site_id, change_openpype_mongo_url @@ -217,6 +218,7 @@ __all__ = [ "IniSettingRegistry", "JSONSettingRegistry", + "OpenPypeSecureRegistry", "OpenPypeSettingsRegistry", "get_local_site_id", "change_openpype_mongo_url", From 261e1db2c0751b29df870c190e9ce75cda9eeadf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 17:15:26 +0200 Subject: [PATCH 075/515] changed keyring key of mongodb data to OpenPype/mongodb --- igniter/bootstrap_repos.py | 2 +- igniter/install_dialog.py | 2 +- openpype/lib/local_settings.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 51dec7f51e..f624b96125 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -242,7 +242,7 @@ class BootstrapRepos: self._app = "openpype" self._log = log.getLogger(str(__class__)) self.data_dir = Path(user_data_dir(self._app, self._vendor)) - self.secure_registry = OpenPypeSecureRegistry("Settings") + self.secure_registry = OpenPypeSecureRegistry("mongodb") self.registry = OpenPypeSettingsRegistry() self.zip_filter = [".pyc", "__pycache__"] self.openpype_filter = [ diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py index dab00079a5..27b2d1fe37 100644 --- a/igniter/install_dialog.py +++ b/igniter/install_dialog.py @@ -42,7 +42,7 @@ class InstallDialog(QtWidgets.QDialog): def __init__(self, parent=None): super(InstallDialog, self).__init__(parent) - self.secure_registry = OpenPypeSecureRegistry("Settings") + self.secure_registry = OpenPypeSecureRegistry("mongodb") self.mongo_url = "" try: diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 1bd8540acb..c043a2f837 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -532,5 +532,5 @@ def change_openpype_mongo_url(new_mongo_url): """ validate_mongo_connection(new_mongo_url) - registry = OpenPypeSecureRegistry("Settings") + registry = OpenPypeSecureRegistry("mongodb") registry.set_item("openPypeMongo", new_mongo_url) From d5e4c1ee0c13a255f9e13d3419c5642d8a37457d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Apr 2021 18:56:02 +0200 Subject: [PATCH 076/515] Hiero: otio export finishing --- openpype/hosts/hiero/otio/hiero_export.py | 524 ++++++++---------- openpype/hosts/hiero/otio/hiero_export_.py | 312 ----------- openpype/hosts/hiero/otio/hiero_export__.py | 386 +++++++++++++ .../Startup/otioexporter/OTIOExportTask.py | 8 +- 4 files changed, 629 insertions(+), 601 deletions(-) delete mode 100644 openpype/hosts/hiero/otio/hiero_export_.py create mode 100644 openpype/hosts/hiero/otio/hiero_export__.py diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py index 8e19b26741..65c4ae13e1 100644 --- a/openpype/hosts/hiero/otio/hiero_export.py +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -1,20 +1,20 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -__author__ = "Daniel Flehner Heen" -__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] +""" compatibility OpenTimelineIO 0.12.0 and newer +""" import os -import sys import re +import sys +import opentimelineio as otio +from . import utils import hiero.core import hiero.ui -import opentimelineio as otio - -# build modul class self = sys.modules[__name__] - +self.track_types = { + hiero.core.VideoTrack: otio.schema.TrackKind.Video, + hiero.core.AudioTrack: otio.schema.TrackKind.Audio +} +self.project_fps = None self.marker_color_map = { "magenta": otio.schema.MarkerColor.MAGENTA, "red": otio.schema.MarkerColor.RED, @@ -23,88 +23,92 @@ self.marker_color_map = { "cyan": otio.schema.MarkerColor.CYAN, "blue": otio.schema.MarkerColor.BLUE, } -self.hiero_sequence = None +self.timeline = None self.include_tags = None -def get_rate(item): - if not hasattr(item, 'framerate'): - item = item.sequence() - - num, den = item.framerate().toRational() - rate = float(num) / float(den) - - if rate.is_integer(): - return rate - - return round(rate, 2) - - -def get_clip_ranges(trackitem): - # Get rate from source or sequence - if trackitem.source().mediaSource().hasVideo(): - rate_item = trackitem.source() - - else: - rate_item = trackitem.sequence() - - source_rate = get_rate(rate_item) - - # Reversed video/audio - if trackitem.playbackSpeed() < 0: - start = trackitem.sourceOut() - - else: - start = trackitem.sourceIn() - - source_start_time = otio.opentime.RationalTime( - start, - source_rate - ) - source_duration = otio.opentime.RationalTime( - trackitem.duration(), - source_rate +def create_otio_rational_time(frame, fps): + return otio.opentime.RationalTime( + float(frame), + float(fps) ) - source_range = otio.opentime.TimeRange( - start_time=source_start_time, - duration=source_duration + +def create_otio_time_range(start_frame, frame_duration, fps): + return otio.opentime.TimeRange( + start_time=create_otio_rational_time(start_frame, fps), + duration=create_otio_rational_time(frame_duration, fps) ) - hiero_clip = trackitem.source() - available_range = None - if hiero_clip.mediaSource().isMediaPresent(): - start_time = otio.opentime.RationalTime( - hiero_clip.mediaSource().startTime(), - source_rate - ) - duration = otio.opentime.RationalTime( - hiero_clip.mediaSource().duration(), - source_rate - ) - available_range = otio.opentime.TimeRange( - start_time=start_time, - duration=duration +def _get_metadata(item): + if hasattr(item, 'metadata'): + return {key: value for key, value in item.metadata().items()} + return {} + + +def create_otio_reference(clip): + metadata = _get_metadata(clip) + media_source = clip.mediaSource() + + # get file info for path and start frame + file_info = media_source.fileinfos().pop() + frame_start = file_info.startFrame() + path = file_info.filename() + + # get padding and other file infos + padding = media_source.filenamePadding() + file_head = media_source.filenameHead() + is_sequence = not media_source.singleFile() + frame_duration = media_source.duration() + fps = utils.get_rate(clip) + extension = os.path.splitext(path)[-1] + + if is_sequence: + metadata.update({ + "isSequence": True, + "padding": padding + }) + + otio_ex_ref_item = None + + if is_sequence: + # if it is file sequence try to create `ImageSequenceReference` + # the OTIO might not be compatible so return nothing and do it old way + try: + dirname = os.path.dirname(path) + otio_ex_ref_item = otio.schema.ImageSequenceReference( + target_url_base=dirname + os.sep, + name_prefix=file_head, + name_suffix=extension, + start_frame=frame_start, + frame_zero_padding=padding, + rate=fps, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + except AttributeError: + pass + + if not otio_ex_ref_item: + reformat_path = utils.get_reformated_path(path, padded=False) + # in case old OTIO or video file create `ExternalReference` + otio_ex_ref_item = otio.schema.ExternalReference( + target_url=reformat_path, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) ) - return source_range, available_range + # add metadata to otio item + add_otio_metadata(otio_ex_ref_item, media_source, **metadata) - -def add_gap(trackitem, otio_track, prev_out): - gap_length = trackitem.timelineIn() - prev_out - if prev_out != 0: - gap_length -= 1 - - rate = get_rate(trackitem.sequence()) - gap = otio.opentime.TimeRange( - duration=otio.opentime.RationalTime( - gap_length, - rate - ) - ) - otio_gap = otio.schema.Gap(source_range=gap) - otio_track.append(otio_gap) + return otio_ex_ref_item def get_marker_color(tag): @@ -120,8 +124,8 @@ def get_marker_color(tag): return otio.schema.MarkerColor.RED -def add_markers(hiero_item, otio_item): - for tag in hiero_item.tags(): +def create_otio_markers(otio_item, item): + for tag in item.tags(): if not tag.visible(): continue @@ -129,7 +133,7 @@ def add_markers(hiero_item, otio_item): # Hiero adds this tag to a lot of clips continue - frame_rate = get_rate(hiero_item) + frame_rate = utils.get_rate(item) marked_range = otio.opentime.TimeRange( start_time=otio.opentime.RationalTime( @@ -146,7 +150,7 @@ def add_markers(hiero_item, otio_item): Hiero=tag.metadata().dict() ) # Store the source item for future import assignment - metadata['Hiero']['source_type'] = hiero_item.__class__.__name__ + metadata['Hiero']['source_type'] = item.__class__.__name__ marker = otio.schema.Marker( name=tag.name(), @@ -158,229 +162,181 @@ def add_markers(hiero_item, otio_item): otio_item.markers.append(marker) -def add_clip(trackitem, otio_track, itemindex): - hiero_clip = trackitem.source() +def create_otio_clip(track_item): + clip = track_item.source() + source_in = track_item.sourceIn() + duration = track_item.sourceDuration() + fps = utils.get_rate(track_item) + name = track_item.name() - # Add Gap if needed - if itemindex == 0: - prev_item = trackitem - - else: - prev_item = trackitem.parent().items()[itemindex - 1] - - clip_diff = trackitem.timelineIn() - prev_item.timelineOut() - - if itemindex == 0 and trackitem.timelineIn() > 0: - add_gap(trackitem, otio_track, 0) - - elif itemindex and clip_diff != 1: - add_gap(trackitem, otio_track, prev_item.timelineOut()) - - # Create Clip - source_range, available_range = get_clip_ranges(trackitem) + media_reference = create_otio_reference(clip) + source_range = create_otio_time_range( + int(source_in), + int(duration), + fps + ) otio_clip = otio.schema.Clip( - name=trackitem.name(), - source_range=source_range + name=name, + source_range=source_range, + media_reference=media_reference ) - media_reference = create_otio_reference(hiero_clip) - - otio_clip.media_reference = media_reference - - # Add Time Effects - playbackspeed = trackitem.playbackSpeed() - if playbackspeed != 1: - if playbackspeed == 0: - time_effect = otio.schema.FreezeFrame() - - else: - time_effect = otio.schema.LinearTimeWarp( - time_scalar=playbackspeed - ) - otio_clip.effects.append(time_effect) - # Add tags as markers if self.include_tags: - add_markers(trackitem, otio_clip) - add_markers(trackitem.source(), otio_clip) + create_otio_markers(otio_clip, track_item) + create_otio_markers(otio_clip, track_item.source()) - otio_track.append(otio_clip) + return otio_clip - # Add Transition if needed - if trackitem.inTransition() or trackitem.outTransition(): - add_transition(trackitem, otio_track) -def _get_metadata(hiero_object): - metadata = hiero_object.metadata() - return {key: value for key, value in metadata.items()} - -def create_otio_reference(hiero_clip): - metadata = _get_metadata(hiero_clip) - mp_clip_property = media_pool_item.GetClipProperty() - path = mp_clip_property["File Path"] - reformat_path = utils.get_reformated_path(path, padded=True) - padding = utils.get_padding_from_path(path) - - if padding: - metadata.update({ - "isSequence": True, - "padding": padding - }) - - # get clip property regarding to type - mp_clip_property = media_pool_item.GetClipProperty() - fps = float(mp_clip_property["FPS"]) - if mp_clip_property["Type"] == "Video": - frame_start = int(mp_clip_property["Start"]) - frame_duration = int(mp_clip_property["Frames"]) - else: - audio_duration = str(mp_clip_property["Duration"]) - frame_start = 0 - frame_duration = int(utils.timecode_to_frames( - audio_duration, float(fps))) - - otio_ex_ref_item = None - - if padding: - # if it is file sequence try to create `ImageSequenceReference` - # the OTIO might not be compatible so return nothing and do it old way - try: - dirname, filename = os.path.split(path) - collection = clique.parse(filename, '{head}[{ranges}]{tail}') - padding_num = len(re.findall("(\\d+)(?=-)", filename).pop()) - otio_ex_ref_item = otio.schema.ImageSequenceReference( - target_url_base=dirname + os.sep, - name_prefix=collection.format("{head}"), - name_suffix=collection.format("{tail}"), - start_frame=frame_start, - frame_zero_padding=padding_num, - rate=fps, - available_range=create_otio_time_range( - frame_start, - frame_duration, - fps - ) - ) - except AttributeError: - pass - - if not otio_ex_ref_item: - # in case old OTIO or video file create `ExternalReference` - otio_ex_ref_item = otio.schema.ExternalReference( - target_url=reformat_path, - available_range=create_otio_time_range( - frame_start, - frame_duration, - fps - ) +def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): + return otio.schema.Gap( + source_range=create_otio_time_range( + gap_start, + (clip_start - tl_start_frame) - gap_start, + fps ) - - # add metadata to otio item - add_otio_metadata(otio_ex_ref_item, hiero_clip, **metadata) - - return otio_ex_ref_item + ) -def add_otio_metadata(otio_item, hiero_clip, **kwargs): - mp_metadata = hiero_clip.GetMetadata() +def _create_otio_timeline(): + metadata = _get_metadata(self.timeline) + start_time = create_otio_rational_time( + self.timeline.timecodeStart(), self.project_fps) + + return otio.schema.Timeline( + name=self.timeline.name(), + global_start_time=start_time, + metadata=metadata + ) + + +def _get_metadata_media_pool_item(media_pool_item): + data = dict() + data.update({k: v for k, v in media_pool_item.GetMetadata().items()}) + property = media_pool_item.GetClipProperty() or {} + for name, value in property.items(): + if "Resolution" in name and "" != value: + width, height = value.split("x") + data.update({ + "width": int(width), + "height": int(height) + }) + if "PAR" in name and "" != value: + try: + data.update({"pixelAspect": float(value)}) + except ValueError: + if "Square" in value: + data.update({"pixelAspect": float(1)}) + else: + data.update({"pixelAspect": float(1)}) + + return data + + +def create_otio_track(track_type, track_name): + return otio.schema.Track( + name=track_name, + kind=self.track_types[track_type] + ) + + +def add_otio_gap(track_item, otio_track, prev_out): + gap_length = track_item.timelineIn() - prev_out + if prev_out != 0: + gap_length -= 1 + + gap = otio.opentime.TimeRange( + duration=otio.opentime.RationalTime( + gap_length, + self.project_fps + ) + ) + otio_gap = otio.schema.Gap(source_range=gap) + otio_track.append(otio_gap) + + +def add_otio_metadata(otio_item, media_source, **kwargs): + metadata = _get_metadata(media_source) + # add additional metadata from kwargs if kwargs: - mp_metadata.update(kwargs) + metadata.update(kwargs) # add metadata to otio item metadata - for key, value in mp_metadata.items(): + for key, value in metadata.items(): otio_item.metadata.update({key: value}) -def add_transition(trackitem, otio_track): - transitions = [] - if trackitem.inTransition(): - if trackitem.inTransition().alignment().name == 'kFadeIn': - transitions.append(trackitem.inTransition()) +def create_otio_timeline(): - if trackitem.outTransition(): - transitions.append(trackitem.outTransition()) + # get current timeline + self.timeline = hiero.ui.activeSequence() + self.project_fps = self.timeline.framerate().toFloat() - for transition in transitions: - alignment = transition.alignment().name - - if alignment == 'kFadeIn': - in_offset_frames = 0 - out_offset_frames = ( - transition.timelineOut() - transition.timelineIn() - ) + 1 - - elif alignment == 'kFadeOut': - in_offset_frames = ( - trackitem.timelineOut() - transition.timelineIn() - ) + 1 - out_offset_frames = 0 - - elif alignment == 'kDissolve': - in_offset_frames = ( - transition.inTrackItem().timelineOut() - - transition.timelineIn() - ) - out_offset_frames = ( - transition.timelineOut() - - transition.outTrackItem().timelineIn() - ) - - else: - # kUnknown transition is ignored - continue - - rate = trackitem.source().framerate().toFloat() - in_time = otio.opentime.RationalTime(in_offset_frames, rate) - out_time = otio.opentime.RationalTime(out_offset_frames, rate) - - otio_transition = otio.schema.Transition( - name=alignment, # Consider placing Hiero name in metadata - transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve, - in_offset=in_time, - out_offset=out_time - ) - - if alignment == 'kFadeIn': - otio_track.insert(-1, otio_transition) - - else: - otio_track.append(otio_transition) - - -def add_tracks(): - for track in self.hiero_sequence.items(): - if isinstance(track, hiero.core.AudioTrack): - kind = otio.schema.TrackKind.Audio - - else: - kind = otio.schema.TrackKind.Video - - otio_track = otio.schema.Track(name=track.name(), kind=kind) - - for itemindex, trackitem in enumerate(track): - if isinstance(trackitem.source(), hiero.core.Clip): - add_clip(trackitem, otio_track, itemindex) - - self.otio_timeline.tracks.append(otio_track) + # convert timeline to otio + otio_timeline = _create_otio_timeline() # Add tags as markers if self.include_tags: - add_markers(self.hiero_sequence, self.otio_timeline.tracks) + create_otio_markers(otio_timeline, self.timeline) + + # loop all defined track types + for track in self.hiero_sequence.items(): + # skip if track is disabled + if not track.isEnabled(): + continue + + # convert track to otio + otio_track = create_otio_track( + type(track), track.name()) + + for itemindex, track_item in enumerate(track): + # skip offline track items + if not track_item.isMediaPresent(): + continue + + # skip if track item is disabled + if not track_item.isEnabled(): + continue + + # Add Gap if needed + if itemindex == 0: + # if it is first track item at track then add + # it to previouse item + prev_item = track_item + + else: + # get previouse item + prev_item = track_item.parent().items()[itemindex - 1] + + # calculate clip frame range difference from each other + clip_diff = track_item.timelineIn() - prev_item.timelineOut() + + # add gap if first track item is not starting + # at first timeline frame + if itemindex == 0 and track_item.timelineIn() > 0: + add_otio_gap(track_item, otio_track, 0) + + # or add gap if following track items are having + # frame range differences from each other + elif itemindex and clip_diff != 1: + add_otio_gap(track_item, otio_track, prev_item.timelineOut()) + + # create otio clip and add it to track + otio_clip = create_otio_clip(track_item) + otio_track.append(otio_clip) + + # Add tags as markers + if self.include_tags: + create_otio_markers(otio_track, track) + + # add track to otio timeline + otio_timeline.tracks.append(otio_track) + + return otio_timeline -def create_OTIO(sequence=None): - self.hiero_sequence = sequence or hiero.ui.activeSequence() - self.otio_timeline = otio.schema.Timeline() - - # Set global start time based on sequence - self.otio_timeline.global_start_time = otio.opentime.RationalTime( - self.hiero_sequence.timecodeStart(), - self.hiero_sequence.framerate().toFloat() - ) - self.otio_timeline.name = self.hiero_sequence.name() - - add_tracks() - - return self.otio_timeline +def write_to_file(otio_timeline, path): + otio.adapters.write_to_file(otio_timeline, path) diff --git a/openpype/hosts/hiero/otio/hiero_export_.py b/openpype/hosts/hiero/otio/hiero_export_.py deleted file mode 100644 index b5a96a9414..0000000000 --- a/openpype/hosts/hiero/otio/hiero_export_.py +++ /dev/null @@ -1,312 +0,0 @@ -""" compatibility OpenTimelineIO 0.12.0 and older -""" - -import os -import re -import sys -import opentimelineio as otio -from . import utils -import hiero.core -import hiero.ui - -self = sys.modules[__name__] -self.track_types = { - hiero.core.VideoTrack: otio.schema.TrackKind.Video, - hiero.core.AudioTrack: otio.schema.TrackKind.Audio -} -self.project_fps = None -self.marker_color_map = { - "magenta": otio.schema.MarkerColor.MAGENTA, - "red": otio.schema.MarkerColor.RED, - "yellow": otio.schema.MarkerColor.YELLOW, - "green": otio.schema.MarkerColor.GREEN, - "cyan": otio.schema.MarkerColor.CYAN, - "blue": otio.schema.MarkerColor.BLUE, -} -self.timeline = None -self.include_tags = None - - -def create_otio_rational_time(frame, fps): - return otio.opentime.RationalTime( - float(frame), - float(fps) - ) - - -def create_otio_time_range(start_frame, frame_duration, fps): - return otio.opentime.TimeRange( - start_time=create_otio_rational_time(start_frame, fps), - duration=create_otio_rational_time(frame_duration, fps) - ) - - -def _get_metadata(item): - if hasattr(item, 'metadata'): - return {key: value for key, value in item.metadata().items()} - return {} - - -def create_otio_reference(clip): - metadata = _get_metadata(clip) - media_source = clip.mediaSource() - - # get file info for path and start frame - file_info = media_source.fileinfos().pop() - frame_start = file_info.startFrame() - path = file_info.filename() - - # get padding and other file infos - padding = media_source.filenamePadding() - file_head = media_source.filenameHead() - is_sequence = not media_source.singleFile() - frame_duration = media_source.duration() - fps = utils.get_rate(clip) - extension = os.path.splitext(path)[-1] - - if is_sequence: - metadata.update({ - "isSequence": True, - "padding": padding - }) - - otio_ex_ref_item = None - - if is_sequence: - # if it is file sequence try to create `ImageSequenceReference` - # the OTIO might not be compatible so return nothing and do it old way - try: - dirname = os.path.dirname(path) - otio_ex_ref_item = otio.schema.ImageSequenceReference( - target_url_base=dirname + os.sep, - name_prefix=file_head, - name_suffix=extension, - start_frame=frame_start, - frame_zero_padding=padding, - rate=fps, - available_range=create_otio_time_range( - frame_start, - frame_duration, - fps - ) - ) - except AttributeError: - pass - - if not otio_ex_ref_item: - reformat_path = utils.get_reformated_path(path, padded=False) - # in case old OTIO or video file create `ExternalReference` - otio_ex_ref_item = otio.schema.ExternalReference( - target_url=reformat_path, - available_range=create_otio_time_range( - frame_start, - frame_duration, - fps - ) - ) - - # add metadata to otio item - add_otio_metadata(otio_ex_ref_item, media_source, **metadata) - - return otio_ex_ref_item - - -def get_marker_color(tag): - icon = tag.icon() - pat = r'icons:Tag(?P\w+)\.\w+' - - res = re.search(pat, icon) - if res: - color = res.groupdict().get('color') - if color.lower() in self.marker_color_map: - return self.marker_color_map[color.lower()] - - return otio.schema.MarkerColor.RED - - -def create_otio_markers(otio_item, track_item): - for tag in track_item.tags(): - if not tag.visible(): - continue - - if tag.name() == 'Copy': - # Hiero adds this tag to a lot of clips - continue - - frame_rate = utils.get_rate(track_item) - - marked_range = otio.opentime.TimeRange( - start_time=otio.opentime.RationalTime( - tag.inTime(), - frame_rate - ), - duration=otio.opentime.RationalTime( - int(tag.metadata().dict().get('tag.length', '0')), - frame_rate - ) - ) - - metadata = dict( - Hiero=tag.metadata().dict() - ) - # Store the source item for future import assignment - metadata['Hiero']['source_type'] = track_item.__class__.__name__ - - marker = otio.schema.Marker( - name=tag.name(), - color=get_marker_color(tag), - marked_range=marked_range, - metadata=metadata - ) - - otio_item.markers.append(marker) - - -def create_otio_clip(track_item): - clip = track_item.source() - source_in = track_item.sourceIn() - duration = track_item.sourceDuration() - fps = utils.get_rate(track_item) - name = track_item.name() - - media_reference = create_otio_reference(clip) - source_range = create_otio_time_range( - int(source_in), - int(duration), - fps - ) - - otio_clip = otio.schema.Clip( - name=name, - source_range=source_range, - media_reference=media_reference - ) - create_otio_markers(otio_clip, track_item) - - return otio_clip - - -def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): - return otio.schema.Gap( - source_range=create_otio_time_range( - gap_start, - (clip_start - tl_start_frame) - gap_start, - fps - ) - ) - - -def _create_otio_timeline(): - metadata = _get_metadata(self.timeline) - start_time = create_otio_rational_time( - self.timeline.timecodeStart(), self.project_fps) - - return otio.schema.Timeline( - name=self.timeline.name(), - global_start_time=start_time, - metadata=metadata - ) - - -def _get_metadata_media_pool_item(media_pool_item): - data = dict() - data.update({k: v for k, v in media_pool_item.GetMetadata().items()}) - property = media_pool_item.GetClipProperty() or {} - for name, value in property.items(): - if "Resolution" in name and "" != value: - width, height = value.split("x") - data.update({ - "width": int(width), - "height": int(height) - }) - if "PAR" in name and "" != value: - try: - data.update({"pixelAspect": float(value)}) - except ValueError: - if "Square" in value: - data.update({"pixelAspect": float(1)}) - else: - data.update({"pixelAspect": float(1)}) - - return data - - -def create_otio_track(track_type, track_name): - return otio.schema.Track( - name=track_name, - kind=self.track_types[track_type] - ) - - -def add_otio_gap(clip_start, otio_track, item_start_frame): - # if gap between track start and clip start - if clip_start > otio_track.available_range().duration.value: - # create gap and add it to track - otio_track.append( - create_otio_gap( - otio_track.available_range().duration.value, - item_start_frame, - self.timeline.timecodeStart(), - self.project_fps - ) - ) - - -def add_otio_metadata(otio_item, media_source, **kwargs): - metadata = _get_metadata(media_source) - - # add additional metadata from kwargs - if kwargs: - metadata.update(kwargs) - - # add metadata to otio item metadata - for key, value in metadata.items(): - otio_item.metadata.update({key: value}) - - -def create_otio_timeline(): - - # get current timeline - self.timeline = hiero.ui.activeSequence() - self.project_fps = self.timeline.framerate().toFloat() - - # convert timeline to otio - otio_timeline = _create_otio_timeline() - - # loop all defined track types - for track in self.hiero_sequence.items(): - # skip if track is disabled - if not track.isEnabled(): - continue - - # convert track to otio - otio_track = create_otio_track( - type(track), track.name()) - - for itemindex, track_item in enumerate(track): - # skip offline track items - if not track_item.isMediaPresent(): - continue - - # skip if track item is disabled - if not track_item.isEnabled(): - continue - - # calculate real clip start - clip_start = track_item.timelineIn() - - add_otio_gap( - clip_start, otio_track, clip_start) - - # create otio clip and add it to track - otio_clip = create_otio_clip(track_item) - otio_track.append(otio_clip) - - # add track to otio timeline - otio_timeline.tracks.append(otio_track) - - return otio_timeline - - -def write_to_file(otio_timeline, path): - otio.adapters.write_to_file(otio_timeline, path) diff --git a/openpype/hosts/hiero/otio/hiero_export__.py b/openpype/hosts/hiero/otio/hiero_export__.py new file mode 100644 index 0000000000..8e19b26741 --- /dev/null +++ b/openpype/hosts/hiero/otio/hiero_export__.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__author__ = "Daniel Flehner Heen" +__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] + +import os +import sys +import re +import hiero.core +import hiero.ui +import opentimelineio as otio + + +# build modul class +self = sys.modules[__name__] + +self.marker_color_map = { + "magenta": otio.schema.MarkerColor.MAGENTA, + "red": otio.schema.MarkerColor.RED, + "yellow": otio.schema.MarkerColor.YELLOW, + "green": otio.schema.MarkerColor.GREEN, + "cyan": otio.schema.MarkerColor.CYAN, + "blue": otio.schema.MarkerColor.BLUE, +} +self.hiero_sequence = None +self.include_tags = None + + +def get_rate(item): + if not hasattr(item, 'framerate'): + item = item.sequence() + + num, den = item.framerate().toRational() + rate = float(num) / float(den) + + if rate.is_integer(): + return rate + + return round(rate, 2) + + +def get_clip_ranges(trackitem): + # Get rate from source or sequence + if trackitem.source().mediaSource().hasVideo(): + rate_item = trackitem.source() + + else: + rate_item = trackitem.sequence() + + source_rate = get_rate(rate_item) + + # Reversed video/audio + if trackitem.playbackSpeed() < 0: + start = trackitem.sourceOut() + + else: + start = trackitem.sourceIn() + + source_start_time = otio.opentime.RationalTime( + start, + source_rate + ) + source_duration = otio.opentime.RationalTime( + trackitem.duration(), + source_rate + ) + + source_range = otio.opentime.TimeRange( + start_time=source_start_time, + duration=source_duration + ) + + hiero_clip = trackitem.source() + + available_range = None + if hiero_clip.mediaSource().isMediaPresent(): + start_time = otio.opentime.RationalTime( + hiero_clip.mediaSource().startTime(), + source_rate + ) + duration = otio.opentime.RationalTime( + hiero_clip.mediaSource().duration(), + source_rate + ) + available_range = otio.opentime.TimeRange( + start_time=start_time, + duration=duration + ) + + return source_range, available_range + + +def add_gap(trackitem, otio_track, prev_out): + gap_length = trackitem.timelineIn() - prev_out + if prev_out != 0: + gap_length -= 1 + + rate = get_rate(trackitem.sequence()) + gap = otio.opentime.TimeRange( + duration=otio.opentime.RationalTime( + gap_length, + rate + ) + ) + otio_gap = otio.schema.Gap(source_range=gap) + otio_track.append(otio_gap) + + +def get_marker_color(tag): + icon = tag.icon() + pat = r'icons:Tag(?P\w+)\.\w+' + + res = re.search(pat, icon) + if res: + color = res.groupdict().get('color') + if color.lower() in self.marker_color_map: + return self.marker_color_map[color.lower()] + + return otio.schema.MarkerColor.RED + + +def add_markers(hiero_item, otio_item): + for tag in hiero_item.tags(): + if not tag.visible(): + continue + + if tag.name() == 'Copy': + # Hiero adds this tag to a lot of clips + continue + + frame_rate = get_rate(hiero_item) + + marked_range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + tag.inTime(), + frame_rate + ), + duration=otio.opentime.RationalTime( + int(tag.metadata().dict().get('tag.length', '0')), + frame_rate + ) + ) + + metadata = dict( + Hiero=tag.metadata().dict() + ) + # Store the source item for future import assignment + metadata['Hiero']['source_type'] = hiero_item.__class__.__name__ + + marker = otio.schema.Marker( + name=tag.name(), + color=get_marker_color(tag), + marked_range=marked_range, + metadata=metadata + ) + + otio_item.markers.append(marker) + + +def add_clip(trackitem, otio_track, itemindex): + hiero_clip = trackitem.source() + + # Add Gap if needed + if itemindex == 0: + prev_item = trackitem + + else: + prev_item = trackitem.parent().items()[itemindex - 1] + + clip_diff = trackitem.timelineIn() - prev_item.timelineOut() + + if itemindex == 0 and trackitem.timelineIn() > 0: + add_gap(trackitem, otio_track, 0) + + elif itemindex and clip_diff != 1: + add_gap(trackitem, otio_track, prev_item.timelineOut()) + + # Create Clip + source_range, available_range = get_clip_ranges(trackitem) + + otio_clip = otio.schema.Clip( + name=trackitem.name(), + source_range=source_range + ) + + media_reference = create_otio_reference(hiero_clip) + + otio_clip.media_reference = media_reference + + # Add Time Effects + playbackspeed = trackitem.playbackSpeed() + if playbackspeed != 1: + if playbackspeed == 0: + time_effect = otio.schema.FreezeFrame() + + else: + time_effect = otio.schema.LinearTimeWarp( + time_scalar=playbackspeed + ) + otio_clip.effects.append(time_effect) + + # Add tags as markers + if self.include_tags: + add_markers(trackitem, otio_clip) + add_markers(trackitem.source(), otio_clip) + + otio_track.append(otio_clip) + + # Add Transition if needed + if trackitem.inTransition() or trackitem.outTransition(): + add_transition(trackitem, otio_track) + +def _get_metadata(hiero_object): + metadata = hiero_object.metadata() + return {key: value for key, value in metadata.items()} + +def create_otio_reference(hiero_clip): + metadata = _get_metadata(hiero_clip) + mp_clip_property = media_pool_item.GetClipProperty() + path = mp_clip_property["File Path"] + reformat_path = utils.get_reformated_path(path, padded=True) + padding = utils.get_padding_from_path(path) + + if padding: + metadata.update({ + "isSequence": True, + "padding": padding + }) + + # get clip property regarding to type + mp_clip_property = media_pool_item.GetClipProperty() + fps = float(mp_clip_property["FPS"]) + if mp_clip_property["Type"] == "Video": + frame_start = int(mp_clip_property["Start"]) + frame_duration = int(mp_clip_property["Frames"]) + else: + audio_duration = str(mp_clip_property["Duration"]) + frame_start = 0 + frame_duration = int(utils.timecode_to_frames( + audio_duration, float(fps))) + + otio_ex_ref_item = None + + if padding: + # if it is file sequence try to create `ImageSequenceReference` + # the OTIO might not be compatible so return nothing and do it old way + try: + dirname, filename = os.path.split(path) + collection = clique.parse(filename, '{head}[{ranges}]{tail}') + padding_num = len(re.findall("(\\d+)(?=-)", filename).pop()) + otio_ex_ref_item = otio.schema.ImageSequenceReference( + target_url_base=dirname + os.sep, + name_prefix=collection.format("{head}"), + name_suffix=collection.format("{tail}"), + start_frame=frame_start, + frame_zero_padding=padding_num, + rate=fps, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + except AttributeError: + pass + + if not otio_ex_ref_item: + # in case old OTIO or video file create `ExternalReference` + otio_ex_ref_item = otio.schema.ExternalReference( + target_url=reformat_path, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + + # add metadata to otio item + add_otio_metadata(otio_ex_ref_item, hiero_clip, **metadata) + + return otio_ex_ref_item + + +def add_otio_metadata(otio_item, hiero_clip, **kwargs): + mp_metadata = hiero_clip.GetMetadata() + # add additional metadata from kwargs + if kwargs: + mp_metadata.update(kwargs) + + # add metadata to otio item metadata + for key, value in mp_metadata.items(): + otio_item.metadata.update({key: value}) + +def add_transition(trackitem, otio_track): + transitions = [] + + if trackitem.inTransition(): + if trackitem.inTransition().alignment().name == 'kFadeIn': + transitions.append(trackitem.inTransition()) + + if trackitem.outTransition(): + transitions.append(trackitem.outTransition()) + + for transition in transitions: + alignment = transition.alignment().name + + if alignment == 'kFadeIn': + in_offset_frames = 0 + out_offset_frames = ( + transition.timelineOut() - transition.timelineIn() + ) + 1 + + elif alignment == 'kFadeOut': + in_offset_frames = ( + trackitem.timelineOut() - transition.timelineIn() + ) + 1 + out_offset_frames = 0 + + elif alignment == 'kDissolve': + in_offset_frames = ( + transition.inTrackItem().timelineOut() - + transition.timelineIn() + ) + out_offset_frames = ( + transition.timelineOut() - + transition.outTrackItem().timelineIn() + ) + + else: + # kUnknown transition is ignored + continue + + rate = trackitem.source().framerate().toFloat() + in_time = otio.opentime.RationalTime(in_offset_frames, rate) + out_time = otio.opentime.RationalTime(out_offset_frames, rate) + + otio_transition = otio.schema.Transition( + name=alignment, # Consider placing Hiero name in metadata + transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve, + in_offset=in_time, + out_offset=out_time + ) + + if alignment == 'kFadeIn': + otio_track.insert(-1, otio_transition) + + else: + otio_track.append(otio_transition) + + +def add_tracks(): + for track in self.hiero_sequence.items(): + if isinstance(track, hiero.core.AudioTrack): + kind = otio.schema.TrackKind.Audio + + else: + kind = otio.schema.TrackKind.Video + + otio_track = otio.schema.Track(name=track.name(), kind=kind) + + for itemindex, trackitem in enumerate(track): + if isinstance(trackitem.source(), hiero.core.Clip): + add_clip(trackitem, otio_track, itemindex) + + self.otio_timeline.tracks.append(otio_track) + + # Add tags as markers + if self.include_tags: + add_markers(self.hiero_sequence, self.otio_timeline.tracks) + + +def create_OTIO(sequence=None): + self.hiero_sequence = sequence or hiero.ui.activeSequence() + self.otio_timeline = otio.schema.Timeline() + + # Set global start time based on sequence + self.otio_timeline.global_start_time = otio.opentime.RationalTime( + self.hiero_sequence.timecodeStart(), + self.hiero_sequence.framerate().toFloat() + ) + self.otio_timeline.name = self.hiero_sequence.name() + + add_tracks() + + return self.otio_timeline diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py index 734213a05d..51265a3daf 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py +++ b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py @@ -9,8 +9,7 @@ import hiero.core from hiero.core import util import opentimelineio as otio -from pype.hosts.hiero.otio.hiero_export import create_OTIO - +from openpype.hosts.hiero.otio import hiero_export class OTIOExportTask(hiero.core.TaskBase): @@ -23,8 +22,7 @@ class OTIOExportTask(hiero.core.TaskBase): return str(type(self)) def startTask(self): - self.otio_timeline = create_OTIO( - self._sequence) + self.otio_timeline = hiero_export.create_otio_timeline() def taskStep(self): return False @@ -42,7 +40,7 @@ class OTIOExportTask(hiero.core.TaskBase): util.filesystem.makeDirs(dirname) # write otio file - otio.adapters.write_to_file(self.otio_timeline, exportPath) + hiero_export.write_to_file(self.otio_timeline, exportPath) # Catch all exceptions and log error except Exception as e: From cbd40cd73b1dfc86f3d35a9920ba4c715b60dc41 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 19:20:56 +0200 Subject: [PATCH 077/515] old mongo value is removed before change --- openpype/lib/local_settings.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index c043a2f837..5d2955532a 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -106,7 +106,7 @@ class OpenPypeSecureRegistry: import keyring value = keyring.get_password(self._name, name) - if value: + if value is not None: return value if default is not _PLACEHOLDER: @@ -532,5 +532,9 @@ def change_openpype_mongo_url(new_mongo_url): """ validate_mongo_connection(new_mongo_url) + key = "openPypeMongo" registry = OpenPypeSecureRegistry("mongodb") - registry.set_item("openPypeMongo", new_mongo_url) + existing_value = registry.get_item(key, None) + if existing_value is not None: + registry.delete_item(key) + registry.set_item(key, new_mongo_url) From 0693d13fbd4258cb255b5cbabb879daf9a9a7b83 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 8 Apr 2021 19:21:28 +0200 Subject: [PATCH 078/515] SyncServer fix duplication of document skeleton if same sites MongoDB contained duplicated skeletons --- openpype/plugins/publish/integrate_new.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 0d36828ccf..ea90f284b2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -976,6 +976,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): local_site = local_site_id remote_site = sync_server_presets["config"].get("remote_site") + if remote_site == local_site: + remote_site = None + if remote_site == 'local': remote_site = local_site_id From 864e8b882cf881b9675bba439fc3f3b67d5796a6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 19:42:31 +0200 Subject: [PATCH 079/515] idle manager is always enabled and starts thread only if has registered callbacks --- openpype/modules/idle_manager/idle_module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/idle_manager/idle_module.py b/openpype/modules/idle_manager/idle_module.py index ddccf07f6a..c06dbed78c 100644 --- a/openpype/modules/idle_manager/idle_module.py +++ b/openpype/modules/idle_manager/idle_module.py @@ -40,8 +40,7 @@ class IdleManager(PypeModule, ITrayService): name = "idle_manager" def initialize(self, module_settings): - idle_man_settings = module_settings[self.name] - self.enabled = idle_man_settings["enabled"] + self.enabled = True self.time_callbacks = collections.defaultdict(list) self.idle_thread = None @@ -50,7 +49,8 @@ class IdleManager(PypeModule, ITrayService): return def tray_start(self): - self.start_thread() + if self.time_callbacks: + self.start_thread() def tray_exit(self): self.stop_thread() From f0b47e1c7ac493ae2f958a70685e21c1dc1589d7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 19:42:52 +0200 Subject: [PATCH 080/515] removed idle manager from settings schema --- .../settings/defaults/system_settings/modules.json | 3 --- .../schemas/system_schema/schema_modules.json | 14 -------------- 2 files changed, 17 deletions(-) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 00e98aa8de..3453bf72ec 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -165,8 +165,5 @@ }, "standalonepublish_tool": { "enabled": true - }, - "idle_manager": { - "enabled": true } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 8bfb0e90dc..ad58411c9e 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -176,20 +176,6 @@ "label": "Enabled" } ] - }, - { - "type": "dict", - "key": "idle_manager", - "label": "Idle Manager", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - } - ] } ] } From 84fb977558f66ac1ef80145d3ad0b05d513baa55 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 8 Apr 2021 19:45:59 +0200 Subject: [PATCH 081/515] added auto stop attribute to timers manager --- openpype/modules/timers_manager/timers_manager.py | 5 +++++ openpype/settings/defaults/system_settings/modules.json | 1 + .../entities/schemas/system_schema/schema_modules.json | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/openpype/modules/timers_manager/timers_manager.py b/openpype/modules/timers_manager/timers_manager.py index a8ea5799e6..92edd5aeaa 100644 --- a/openpype/modules/timers_manager/timers_manager.py +++ b/openpype/modules/timers_manager/timers_manager.py @@ -45,11 +45,13 @@ class TimersManager(PypeModule, ITrayService, IIdleManager, IWebServerRoutes): timers_settings = modules_settings[self.name] self.enabled = timers_settings["enabled"] + auto_stop = timers_settings["auto_stop"] # When timer will stop if idle manager is running (minutes) full_time = int(timers_settings["full_time"] * 60) # How many minutes before the timer is stopped will popup the message message_time = int(timers_settings["message_time"] * 60) + self.auto_stop = auto_stop self.time_show_message = full_time - message_time self.time_stop_timer = full_time @@ -160,6 +162,9 @@ class TimersManager(PypeModule, ITrayService, IIdleManager, IWebServerRoutes): def callbacks_by_idle_time(self): """Implementation of IIdleManager interface.""" # Time when message is shown + if not self.auto_stop: + return {} + callbacks = collections.defaultdict(list) callbacks[self.time_show_message].append(lambda: self.time_callback(0)) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 3453bf72ec..b3065058a1 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -126,6 +126,7 @@ }, "timers_manager": { "enabled": true, + "auto_stop": true, "full_time": 15.0, "message_time": 0.5 }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index ad58411c9e..8512514ff3 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -42,6 +42,11 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "boolean", + "key": "auto_stop", + "label": "Auto stop timer" + }, { "type": "number", "decimal": 2, From 2db6bb534b1a04a5762a6d201c9d003a62ec2c17 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Apr 2021 20:57:47 +0200 Subject: [PATCH 082/515] Hiero: final changes to otio import export modules --- openpype/hosts/hiero/otio/hiero_export.py | 65 ++- openpype/hosts/hiero/otio/hiero_export__.py | 386 ------------------ openpype/hosts/hiero/otio/hiero_import.py | 25 +- openpype/hosts/hiero/otio/utils.py | 21 +- .../Startup/otioexporter/OTIOExportTask.py | 2 +- .../Startup/otioexporter/OTIOExportUI.py | 5 +- .../Python/StartupUI/otioimporter/__init__.py | 2 +- 7 files changed, 58 insertions(+), 448 deletions(-) delete mode 100644 openpype/hosts/hiero/otio/hiero_export__.py diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py index 65c4ae13e1..b2847ff6cb 100644 --- a/openpype/hosts/hiero/otio/hiero_export.py +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -4,10 +4,12 @@ import os import re import sys +import ast import opentimelineio as otio from . import utils import hiero.core import hiero.ui +reload(utils) self = sys.modules[__name__] self.track_types = { @@ -43,7 +45,7 @@ def create_otio_time_range(start_frame, frame_duration, fps): def _get_metadata(item): if hasattr(item, 'metadata'): - return {key: value for key, value in item.metadata().items()} + return {key: value for key, value in dict(item.metadata()).items()} return {} @@ -61,7 +63,7 @@ def create_otio_reference(clip): file_head = media_source.filenameHead() is_sequence = not media_source.singleFile() frame_duration = media_source.duration() - fps = utils.get_rate(clip) + fps = utils.get_rate(clip) or self.project_fps extension = os.path.splitext(path)[-1] if is_sequence: @@ -70,6 +72,13 @@ def create_otio_reference(clip): "padding": padding }) + # add resolution metadata + metadata.update({ + "width": int(media_source.width()), + "height": int(media_source.height()), + "pixelAspect": float(media_source.pixelAspect()) + }) + otio_ex_ref_item = None if is_sequence: @@ -133,7 +142,7 @@ def create_otio_markers(otio_item, item): # Hiero adds this tag to a lot of clips continue - frame_rate = utils.get_rate(item) + frame_rate = utils.get_rate(item) or self.project_fps marked_range = otio.opentime.TimeRange( start_time=otio.opentime.RationalTime( @@ -145,12 +154,22 @@ def create_otio_markers(otio_item, item): frame_rate ) ) + # add tag metadata but remove "tag." string + metadata = {} + + for key, value in tag.metadata().dict().items(): + _key = key.replace("tag.", "") + + try: + # capture exceptions which are related to strings only + _value = ast.literal_eval(value) + except (ValueError, SyntaxError): + _value = value + + metadata.update({_key: _value}) - metadata = dict( - Hiero=tag.metadata().dict() - ) # Store the source item for future import assignment - metadata['Hiero']['source_type'] = item.__class__.__name__ + metadata['hiero_source_type'] = item.__class__.__name__ marker = otio.schema.Marker( name=tag.name(), @@ -166,7 +185,7 @@ def create_otio_clip(track_item): clip = track_item.source() source_in = track_item.sourceIn() duration = track_item.sourceDuration() - fps = utils.get_rate(track_item) + fps = utils.get_rate(track_item) or self.project_fps name = track_item.name() media_reference = create_otio_reference(clip) @@ -212,29 +231,6 @@ def _create_otio_timeline(): ) -def _get_metadata_media_pool_item(media_pool_item): - data = dict() - data.update({k: v for k, v in media_pool_item.GetMetadata().items()}) - property = media_pool_item.GetClipProperty() or {} - for name, value in property.items(): - if "Resolution" in name and "" != value: - width, height = value.split("x") - data.update({ - "width": int(width), - "height": int(height) - }) - if "PAR" in name and "" != value: - try: - data.update({"pixelAspect": float(value)}) - except ValueError: - if "Square" in value: - data.update({"pixelAspect": float(1)}) - else: - data.update({"pixelAspect": float(1)}) - - return data - - def create_otio_track(track_type, track_name): return otio.schema.Track( name=track_name, @@ -271,6 +267,7 @@ def add_otio_metadata(otio_item, media_source, **kwargs): def create_otio_timeline(): + print(">>>>>> self.include_tags: {}".format(self.include_tags)) # get current timeline self.timeline = hiero.ui.activeSequence() self.project_fps = self.timeline.framerate().toFloat() @@ -278,12 +275,8 @@ def create_otio_timeline(): # convert timeline to otio otio_timeline = _create_otio_timeline() - # Add tags as markers - if self.include_tags: - create_otio_markers(otio_timeline, self.timeline) - # loop all defined track types - for track in self.hiero_sequence.items(): + for track in self.timeline.items(): # skip if track is disabled if not track.isEnabled(): continue diff --git a/openpype/hosts/hiero/otio/hiero_export__.py b/openpype/hosts/hiero/otio/hiero_export__.py deleted file mode 100644 index 8e19b26741..0000000000 --- a/openpype/hosts/hiero/otio/hiero_export__.py +++ /dev/null @@ -1,386 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -__author__ = "Daniel Flehner Heen" -__credits__ = ["Jakub Jezek", "Daniel Flehner Heen"] - -import os -import sys -import re -import hiero.core -import hiero.ui -import opentimelineio as otio - - -# build modul class -self = sys.modules[__name__] - -self.marker_color_map = { - "magenta": otio.schema.MarkerColor.MAGENTA, - "red": otio.schema.MarkerColor.RED, - "yellow": otio.schema.MarkerColor.YELLOW, - "green": otio.schema.MarkerColor.GREEN, - "cyan": otio.schema.MarkerColor.CYAN, - "blue": otio.schema.MarkerColor.BLUE, -} -self.hiero_sequence = None -self.include_tags = None - - -def get_rate(item): - if not hasattr(item, 'framerate'): - item = item.sequence() - - num, den = item.framerate().toRational() - rate = float(num) / float(den) - - if rate.is_integer(): - return rate - - return round(rate, 2) - - -def get_clip_ranges(trackitem): - # Get rate from source or sequence - if trackitem.source().mediaSource().hasVideo(): - rate_item = trackitem.source() - - else: - rate_item = trackitem.sequence() - - source_rate = get_rate(rate_item) - - # Reversed video/audio - if trackitem.playbackSpeed() < 0: - start = trackitem.sourceOut() - - else: - start = trackitem.sourceIn() - - source_start_time = otio.opentime.RationalTime( - start, - source_rate - ) - source_duration = otio.opentime.RationalTime( - trackitem.duration(), - source_rate - ) - - source_range = otio.opentime.TimeRange( - start_time=source_start_time, - duration=source_duration - ) - - hiero_clip = trackitem.source() - - available_range = None - if hiero_clip.mediaSource().isMediaPresent(): - start_time = otio.opentime.RationalTime( - hiero_clip.mediaSource().startTime(), - source_rate - ) - duration = otio.opentime.RationalTime( - hiero_clip.mediaSource().duration(), - source_rate - ) - available_range = otio.opentime.TimeRange( - start_time=start_time, - duration=duration - ) - - return source_range, available_range - - -def add_gap(trackitem, otio_track, prev_out): - gap_length = trackitem.timelineIn() - prev_out - if prev_out != 0: - gap_length -= 1 - - rate = get_rate(trackitem.sequence()) - gap = otio.opentime.TimeRange( - duration=otio.opentime.RationalTime( - gap_length, - rate - ) - ) - otio_gap = otio.schema.Gap(source_range=gap) - otio_track.append(otio_gap) - - -def get_marker_color(tag): - icon = tag.icon() - pat = r'icons:Tag(?P\w+)\.\w+' - - res = re.search(pat, icon) - if res: - color = res.groupdict().get('color') - if color.lower() in self.marker_color_map: - return self.marker_color_map[color.lower()] - - return otio.schema.MarkerColor.RED - - -def add_markers(hiero_item, otio_item): - for tag in hiero_item.tags(): - if not tag.visible(): - continue - - if tag.name() == 'Copy': - # Hiero adds this tag to a lot of clips - continue - - frame_rate = get_rate(hiero_item) - - marked_range = otio.opentime.TimeRange( - start_time=otio.opentime.RationalTime( - tag.inTime(), - frame_rate - ), - duration=otio.opentime.RationalTime( - int(tag.metadata().dict().get('tag.length', '0')), - frame_rate - ) - ) - - metadata = dict( - Hiero=tag.metadata().dict() - ) - # Store the source item for future import assignment - metadata['Hiero']['source_type'] = hiero_item.__class__.__name__ - - marker = otio.schema.Marker( - name=tag.name(), - color=get_marker_color(tag), - marked_range=marked_range, - metadata=metadata - ) - - otio_item.markers.append(marker) - - -def add_clip(trackitem, otio_track, itemindex): - hiero_clip = trackitem.source() - - # Add Gap if needed - if itemindex == 0: - prev_item = trackitem - - else: - prev_item = trackitem.parent().items()[itemindex - 1] - - clip_diff = trackitem.timelineIn() - prev_item.timelineOut() - - if itemindex == 0 and trackitem.timelineIn() > 0: - add_gap(trackitem, otio_track, 0) - - elif itemindex and clip_diff != 1: - add_gap(trackitem, otio_track, prev_item.timelineOut()) - - # Create Clip - source_range, available_range = get_clip_ranges(trackitem) - - otio_clip = otio.schema.Clip( - name=trackitem.name(), - source_range=source_range - ) - - media_reference = create_otio_reference(hiero_clip) - - otio_clip.media_reference = media_reference - - # Add Time Effects - playbackspeed = trackitem.playbackSpeed() - if playbackspeed != 1: - if playbackspeed == 0: - time_effect = otio.schema.FreezeFrame() - - else: - time_effect = otio.schema.LinearTimeWarp( - time_scalar=playbackspeed - ) - otio_clip.effects.append(time_effect) - - # Add tags as markers - if self.include_tags: - add_markers(trackitem, otio_clip) - add_markers(trackitem.source(), otio_clip) - - otio_track.append(otio_clip) - - # Add Transition if needed - if trackitem.inTransition() or trackitem.outTransition(): - add_transition(trackitem, otio_track) - -def _get_metadata(hiero_object): - metadata = hiero_object.metadata() - return {key: value for key, value in metadata.items()} - -def create_otio_reference(hiero_clip): - metadata = _get_metadata(hiero_clip) - mp_clip_property = media_pool_item.GetClipProperty() - path = mp_clip_property["File Path"] - reformat_path = utils.get_reformated_path(path, padded=True) - padding = utils.get_padding_from_path(path) - - if padding: - metadata.update({ - "isSequence": True, - "padding": padding - }) - - # get clip property regarding to type - mp_clip_property = media_pool_item.GetClipProperty() - fps = float(mp_clip_property["FPS"]) - if mp_clip_property["Type"] == "Video": - frame_start = int(mp_clip_property["Start"]) - frame_duration = int(mp_clip_property["Frames"]) - else: - audio_duration = str(mp_clip_property["Duration"]) - frame_start = 0 - frame_duration = int(utils.timecode_to_frames( - audio_duration, float(fps))) - - otio_ex_ref_item = None - - if padding: - # if it is file sequence try to create `ImageSequenceReference` - # the OTIO might not be compatible so return nothing and do it old way - try: - dirname, filename = os.path.split(path) - collection = clique.parse(filename, '{head}[{ranges}]{tail}') - padding_num = len(re.findall("(\\d+)(?=-)", filename).pop()) - otio_ex_ref_item = otio.schema.ImageSequenceReference( - target_url_base=dirname + os.sep, - name_prefix=collection.format("{head}"), - name_suffix=collection.format("{tail}"), - start_frame=frame_start, - frame_zero_padding=padding_num, - rate=fps, - available_range=create_otio_time_range( - frame_start, - frame_duration, - fps - ) - ) - except AttributeError: - pass - - if not otio_ex_ref_item: - # in case old OTIO or video file create `ExternalReference` - otio_ex_ref_item = otio.schema.ExternalReference( - target_url=reformat_path, - available_range=create_otio_time_range( - frame_start, - frame_duration, - fps - ) - ) - - # add metadata to otio item - add_otio_metadata(otio_ex_ref_item, hiero_clip, **metadata) - - return otio_ex_ref_item - - -def add_otio_metadata(otio_item, hiero_clip, **kwargs): - mp_metadata = hiero_clip.GetMetadata() - # add additional metadata from kwargs - if kwargs: - mp_metadata.update(kwargs) - - # add metadata to otio item metadata - for key, value in mp_metadata.items(): - otio_item.metadata.update({key: value}) - -def add_transition(trackitem, otio_track): - transitions = [] - - if trackitem.inTransition(): - if trackitem.inTransition().alignment().name == 'kFadeIn': - transitions.append(trackitem.inTransition()) - - if trackitem.outTransition(): - transitions.append(trackitem.outTransition()) - - for transition in transitions: - alignment = transition.alignment().name - - if alignment == 'kFadeIn': - in_offset_frames = 0 - out_offset_frames = ( - transition.timelineOut() - transition.timelineIn() - ) + 1 - - elif alignment == 'kFadeOut': - in_offset_frames = ( - trackitem.timelineOut() - transition.timelineIn() - ) + 1 - out_offset_frames = 0 - - elif alignment == 'kDissolve': - in_offset_frames = ( - transition.inTrackItem().timelineOut() - - transition.timelineIn() - ) - out_offset_frames = ( - transition.timelineOut() - - transition.outTrackItem().timelineIn() - ) - - else: - # kUnknown transition is ignored - continue - - rate = trackitem.source().framerate().toFloat() - in_time = otio.opentime.RationalTime(in_offset_frames, rate) - out_time = otio.opentime.RationalTime(out_offset_frames, rate) - - otio_transition = otio.schema.Transition( - name=alignment, # Consider placing Hiero name in metadata - transition_type=otio.schema.TransitionTypes.SMPTE_Dissolve, - in_offset=in_time, - out_offset=out_time - ) - - if alignment == 'kFadeIn': - otio_track.insert(-1, otio_transition) - - else: - otio_track.append(otio_transition) - - -def add_tracks(): - for track in self.hiero_sequence.items(): - if isinstance(track, hiero.core.AudioTrack): - kind = otio.schema.TrackKind.Audio - - else: - kind = otio.schema.TrackKind.Video - - otio_track = otio.schema.Track(name=track.name(), kind=kind) - - for itemindex, trackitem in enumerate(track): - if isinstance(trackitem.source(), hiero.core.Clip): - add_clip(trackitem, otio_track, itemindex) - - self.otio_timeline.tracks.append(otio_track) - - # Add tags as markers - if self.include_tags: - add_markers(self.hiero_sequence, self.otio_timeline.tracks) - - -def create_OTIO(sequence=None): - self.hiero_sequence = sequence or hiero.ui.activeSequence() - self.otio_timeline = otio.schema.Timeline() - - # Set global start time based on sequence - self.otio_timeline.global_start_time = otio.opentime.RationalTime( - self.hiero_sequence.timecodeStart(), - self.hiero_sequence.framerate().toFloat() - ) - self.otio_timeline.name = self.hiero_sequence.name() - - add_tracks() - - return self.otio_timeline diff --git a/openpype/hosts/hiero/otio/hiero_import.py b/openpype/hosts/hiero/otio/hiero_import.py index c5c72984bc..db9ebdfc90 100644 --- a/openpype/hosts/hiero/otio/hiero_import.py +++ b/openpype/hosts/hiero/otio/hiero_import.py @@ -19,6 +19,7 @@ except ImportError: import opentimelineio as otio +_otio_old = False def inform(messages): if isinstance(messages, type('')): @@ -180,14 +181,23 @@ def prep_url(url_in): def create_offline_mediasource(otio_clip, path=None): + global _otio_old + hiero_rate = hiero.core.TimeBase( otio_clip.source_range.start_time.rate ) - legal_media_refs = ( - otio.schema.ExternalReference, - otio.schema.ImageSequenceReference - ) + try: + legal_media_refs = ( + otio.schema.ExternalReference, + otio.schema.ImageSequenceReference + ) + except AttributeError: + _otio_old = True + legal_media_refs = ( + otio.schema.ExternalReference + ) + if isinstance(otio_clip.media_reference, legal_media_refs): source_range = otio_clip.available_range() @@ -331,9 +341,10 @@ def create_clip(otio_clip, tagsbin, sequencebin): url = prep_url(otio_media.target_url) media = hiero.core.MediaSource(url) - elif isinstance(otio_media, otio.schema.ImageSequenceReference): - url = prep_url(otio_media.abstract_target_url('#')) - media = hiero.core.MediaSource(url) + elif not _otio_old: + if isinstance(otio_media, otio.schema.ImageSequenceReference): + url = prep_url(otio_media.abstract_target_url('#')) + media = hiero.core.MediaSource(url) if media is None or media.isOffline(): media = create_offline_mediasource(otio_clip, url) diff --git a/openpype/hosts/hiero/otio/utils.py b/openpype/hosts/hiero/otio/utils.py index 12f963fe97..f882a5d1f2 100644 --- a/openpype/hosts/hiero/otio/utils.py +++ b/openpype/hosts/hiero/otio/utils.py @@ -17,7 +17,7 @@ def frames_to_secons(frames, framerate): return otio.opentime.to_seconds(rt) -def get_reformated_path(path, padded=True, first=False): +def get_reformated_path(path, padded=True): """ Return fixed python expression path @@ -31,19 +31,12 @@ def get_reformated_path(path, padded=True, first=False): get_reformated_path("plate.[0001-1008].exr") > plate.%04d.exr """ - num_pattern = r"(\[\d+\-\d+\])" - padding_pattern = r"(\d+)(?=-)" - first_frame_pattern = re.compile(r"\[(\d+)\-\d+\]") - - if "[" in path: - padding = len(re.findall(padding_pattern, path).pop()) + if "%" in path: + padding_pattern = r"(\d+)" + padding = int(re.findall(padding_pattern, path).pop()) + num_pattern = r"(%\d+d)" if padded: - path = re.sub(num_pattern, f"%0{padding}d", path) - elif first: - first_frame = re.findall(first_frame_pattern, path, flags=0) - if len(first_frame) >= 1: - first_frame = first_frame[0] - path = re.sub(num_pattern, first_frame, path) + path = re.sub(num_pattern, "%0{}d".format(padding), path) else: path = re.sub(num_pattern, "%d", path) return path @@ -72,7 +65,7 @@ def get_padding_from_path(path): def get_rate(item): if not hasattr(item, 'framerate'): - item = item.sequence() + return None num, den = item.framerate().toRational() rate = float(num) / float(den) diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py index 51265a3daf..7e1a8df2dc 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py +++ b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportTask.py @@ -60,7 +60,7 @@ class OTIOExportPreset(hiero.core.TaskPresetBase): """Initialise presets to default values""" hiero.core.TaskPresetBase.__init__(self, OTIOExportTask, name) - self.properties()["includeTags"] = True + self.properties()["includeTags"] = hiero_export.include_tags = True self.properties().update(properties) def supportedItems(self): diff --git a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py index 7f11de074d..9b83eefedf 100644 --- a/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py +++ b/openpype/hosts/hiero/startup/Python/Startup/otioexporter/OTIOExportUI.py @@ -20,8 +20,7 @@ except ImportError: FormLayout = QFormLayout # lint:ok -from pype.hosts.hiero.otio import hiero_export - +from openpype.hosts.hiero.otio import hiero_export class OTIOExportUI(hiero.ui.TaskUIBase): def __init__(self, preset): @@ -35,7 +34,7 @@ class OTIOExportUI(hiero.ui.TaskUIBase): def includeMarkersCheckboxChanged(self, state): # Slot to handle change of checkbox state - hiero_export.hiero_sequence = state == QtCore.Qt.Checked + hiero_export.include_tags = state == QtCore.Qt.Checked def populateUI(self, widget, exportTemplate): layout = widget.layout() diff --git a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py b/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py index a778d558b2..0f0a643909 100644 --- a/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py +++ b/openpype/hosts/hiero/startup/Python/StartupUI/otioimporter/__init__.py @@ -9,7 +9,7 @@ import hiero.core import PySide2.QtWidgets as qw -from pype.hosts.hiero.otio.hiero_import import load_otio +from openpype.hosts.hiero.otio.hiero_import import load_otio class OTIOProjectSelect(qw.QDialog): From 61eb1cf20063f927ea73b686fe8ea7d5d48b7950 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 8 Apr 2021 21:04:29 +0200 Subject: [PATCH 083/515] hiero: hound suggestions --- openpype/hosts/hiero/otio/hiero_export.py | 1 - openpype/hosts/hiero/otio/hiero_import.py | 33 +++++++++++++---------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py index b2847ff6cb..f95dc7a025 100644 --- a/openpype/hosts/hiero/otio/hiero_export.py +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -9,7 +9,6 @@ import opentimelineio as otio from . import utils import hiero.core import hiero.ui -reload(utils) self = sys.modules[__name__] self.track_types = { diff --git a/openpype/hosts/hiero/otio/hiero_import.py b/openpype/hosts/hiero/otio/hiero_import.py index db9ebdfc90..b5040a32c7 100644 --- a/openpype/hosts/hiero/otio/hiero_import.py +++ b/openpype/hosts/hiero/otio/hiero_import.py @@ -21,6 +21,7 @@ import opentimelineio as otio _otio_old = False + def inform(messages): if isinstance(messages, type('')): messages = [messages] @@ -114,10 +115,9 @@ def apply_transition(otio_track, otio_item, track): # Catch error raised if transition is bigger than TrackItem source except RuntimeError as e: transition = None - warning = \ - 'Unable to apply transition "{t.name}": {e} ' \ - 'Ignoring the transition.' \ - .format(t=otio_item, e=e.message) + warning = ( + "Unable to apply transition \"{t.name}\": {e} " + "Ignoring the transition.").format(t=otio_item, e=e.message) elif transition_type == 'fade_in': transition_func = getattr( @@ -384,7 +384,8 @@ def create_trackitem(playhead, track, otio_clip, clip): time_scalar = effect.time_scalar # Only reverse effect can be applied here if abs(time_scalar) == 1.: - trackitem.setPlaybackSpeed(trackitem.playbackSpeed() * time_scalar) + trackitem.setPlaybackSpeed( + trackitem.playbackSpeed() * time_scalar) elif isinstance(effect, otio.schema.FreezeFrame): # For freeze frame, playback speed must be set after range @@ -433,7 +434,8 @@ def create_trackitem(playhead, track, otio_clip, clip): return trackitem -def build_sequence(otio_timeline, project=None, sequence=None, track_kind=None): +def build_sequence( + otio_timeline, project=None, sequence=None, track_kind=None): if project is None: if sequence: project = sequence.project() @@ -449,11 +451,13 @@ def build_sequence(otio_timeline, project=None, sequence=None, track_kind=None): sequence = hiero.core.Sequence(otio_timeline.name or 'OTIOSequence') # Set sequence settings from otio timeline if available - if hasattr(otio_timeline, 'global_start_time'): - if otio_timeline.global_start_time: - start_time = otio_timeline.global_start_time - sequence.setFramerate(start_time.rate) - sequence.setTimecodeStart(start_time.value) + if ( + hasattr(otio_timeline, 'global_start_time') + and otio_timeline.global_start_time + ): + start_time = otio_timeline.global_start_time + sequence.setFramerate(start_time.rate) + sequence.setTimecodeStart(start_time.value) # Create a Bin to hold clips projectbin.addItem(hiero.core.BinItem(sequence)) @@ -485,7 +489,7 @@ def build_sequence(otio_timeline, project=None, sequence=None, track_kind=None): sequence.addTrack(track) # iterate over items in track - for itemnum, otio_clip in enumerate(otio_track): + for _itemnum, otio_clip in enumerate(otio_track): if isinstance(otio_clip, (otio.schema.Track, otio.schema.Stack)): inform('Nested sequences/tracks are created separately.') @@ -529,9 +533,10 @@ def build_sequence(otio_timeline, project=None, sequence=None, track_kind=None): playhead += otio_clip.source_range.duration.value # Apply transitions we stored earlier now that all clips are present - warnings = list() + warnings = [] for otio_track, otio_item in _transitions: - # Catch warnings form transitions in case of unsupported transitions + # Catch warnings form transitions in case + # of unsupported transitions warning = apply_transition(otio_track, otio_item, track) if warning: warnings.append(warning) From 5af4b1682816e45a51a86f5dc00349f624257208 Mon Sep 17 00:00:00 2001 From: Petr Dvorak Date: Thu, 8 Apr 2021 21:15:21 +0200 Subject: [PATCH 084/515] some text correction --- website/docs/artist_ftrack.md | 1 - website/docs/manager_ftrack_actions.md | 15 +++++---------- website/docs/module_ftrack.md | 6 +++--- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/website/docs/artist_ftrack.md b/website/docs/artist_ftrack.md index 44d8b0c007..2210615160 100644 --- a/website/docs/artist_ftrack.md +++ b/website/docs/artist_ftrack.md @@ -115,7 +115,6 @@ sidebar_label: Artist **2. possibility - Ftrack URL is not set or is not right** - Check **Ftrack URL** value in *Ftrack login* window - Inform your administrator if URL is incorrect and launch OpenPype again when administrator fix it -- The Ftrack URL can be changed in OpenPype Settings → System → Modules → Ftrack **3. possibility - Ftrack Web app can't be reached the way OpenPype use it** - Enter your **Username** and [API key](#where-to-find-api-key) in *Ftrack login* window and press **Login** button diff --git a/website/docs/manager_ftrack_actions.md b/website/docs/manager_ftrack_actions.md index 950f667f9a..aa4c554614 100644 --- a/website/docs/manager_ftrack_actions.md +++ b/website/docs/manager_ftrack_actions.md @@ -28,8 +28,9 @@ In most cases actions filtered by entity type: So if you do not see action you need to use check if action is available for selected *entity type* or ask *administrator* to check if you have permissions to use it. - -Actions can be highly customized according to specific client's requests. +:::note +Actions can be heavily customised by your studio, so this guide might not fit 100 %. +::: :::important Filtering can be more complicated for example a lot of actions can be shown only when one particular entity is selected. @@ -65,12 +66,6 @@ Project Manager or Supervisor must set project's applications during project pre #### A group of actions that are used for OpenPype Administration. -### Create Update Avalon Attributes -* Entity types: All -* User roles: Pypeclub, Administrator - -Action creates and updates Ftrack's Custom Attributes that are needed to manage and run OpenPype within Ftrack. Most of custom attribute configurations are stored in OpenPype settings (*click on the systray OpenPype icon → Settings → Project → Anatomy → Attributes*). It is not recommended to modify values stored in the file unless your studio used completely custom configuration. - ### Sync to Avalon * Entity types: Project, Typed Context * User roles: Pypeclub, Administrator, Project manager @@ -126,7 +121,7 @@ With this action it's possible to delete up to 15 entities at once from active p * Entity types: Project * User roles: Pypeclub, Administrator, Project manager -Allows project managers and coordinator to *set basic project attributes* needed for OpenPype to operate, *Create project folders* if you want and especially prepare project specific [anatomy](admin_settings_project_anatomy) or [settings](admin_settings_project). +Allows project managers and coordinator to *set basic project attributes* needed for OpenPype to operate, *Create project folders* if you want and especially prepare project specific [settings](admin_settings_project). :::tip It is possible to use this action during the lifetime of a project but we recommend using it only once at the start of the project. @@ -217,7 +212,7 @@ Please keep in mind this action is meant to make your project setup faster at th * Entity types: Task * User roles: Pypeclub, Project manager, Administrator -Collects approved hires files and copy them into a folder. It usually creates h.264 files for preview and mov for editorial. All files are then copied according to predefined naming convention to a specific folder. +Collects approved hires files and copy them into a folder. It takes any components of any versions and copies and renames them correctly. --- diff --git a/website/docs/module_ftrack.md b/website/docs/module_ftrack.md index 9f31eac21e..bd0dbaef4f 100644 --- a/website/docs/module_ftrack.md +++ b/website/docs/module_ftrack.md @@ -24,7 +24,7 @@ You can only use our Ftrack Actions and publish to Ftrack if each artist is logg ### Custom Attributes -After successfully connecting OpenPype with you Ftrack, you can right on any project in Ftrack and you should see a bunch of actions available. The most important one is called `OpenPype Admin` and contains multiple options inside. +After successfully connecting OpenPype with you Ftrack, you can right click on any project in Ftrack and you should see a bunch of actions available. The most important one is called `OpenPype Admin` and contains multiple options inside. To prepare Ftrack for working with OpenPype you'll need to run [OpenPype Admin - Create/Update Avalon Attributes](manager_ftrack_actions#create-update-avalon-attributes), which creates and sets the Custom Attributes necessary for OpenPype to function. @@ -56,7 +56,7 @@ There are specific launch arguments for event server. With `openpype eventserver - `--ftrack-url "https://yourdomain.ftrackapp.com/"` : Ftrack server URL _(it is not needed to enter if you have set `FTRACK_SERVER` in OpenPype' environments)_ - `--ftrack-events-path "//Paths/To/Events/"` : Paths to events folder. May contain multiple paths separated by `;`. _(it is not needed to enter if you have set `FTRACK_EVENTS_PATH` in OpenPype' environments)_ -So if you want to use OpenPype's environments then you can launch event server for first time with these arguments `$OPENPYPE_SETUP/openpype eventserver --ftrack-user "my.username" --ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee" --store-credentials`. Since that time, if everything was entered correctly, you can launch event server with `$OPENPYPE_SETUP/openpype eventserver`. +So if you want to use OpenPype's environments then you can launch event server for first time with these arguments `openpype.exe eventserver --ftrack-user "my.username" --ftrack-api-key "00000aaa-11bb-22cc-33dd-444444eeeee" --store-credentials`. Since that time, if everything was entered correctly, you can launch event server with `openpype.exe eventserver`. @@ -186,7 +186,7 @@ Push thumbnails from version, up through multiple hierarchy levels Change status of next task from `Not started` to `Ready` when previous task is approved. -Multiple detailed rules for next task update can be configured in the presets. +Multiple detailed rules for next task update can be configured in the settings. ### Delete Avalon ID from new entity From 96eec587dbb48fb93b44d17a48481cc9db109cc8 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 9 Apr 2021 09:54:07 +0200 Subject: [PATCH 085/515] change ID generation to coolname --- openpype/lib/local_settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index 5d2955532a..56bdd047c9 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -498,12 +498,12 @@ class OpenPypeSettingsRegistry(JSONSettingRegistry): def _create_local_site_id(registry=None): """Create a local site identifier.""" - from uuid import uuid4 + from coolname import generate_slug if registry is None: registry = OpenPypeSettingsRegistry() - new_id = str(uuid4()) + new_id = generate_slug(3) print("Created local site id \"{}\"".format(new_id)) From 31bd9616ec631d2bf88a81514331d3d8e7bb77f9 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 9 Apr 2021 10:05:16 +0200 Subject: [PATCH 086/515] extra space in info widget --- openpype/tools/tray/pype_info_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/tray/pype_info_widget.py b/openpype/tools/tray/pype_info_widget.py index dbff36eca7..a70a360378 100644 --- a/openpype/tools/tray/pype_info_widget.py +++ b/openpype/tools/tray/pype_info_widget.py @@ -363,7 +363,7 @@ class PypeInfoWidget(QtWidgets.QWidget): "version_value": "OpenPype version:", "executable": "OpenPype executable:", "pype_root": "OpenPype location:", - "mongo_url": "OpenPype Mongo URL:" + "mongo_url": "OpenPype Mongo URL:" } # Prepare keys order keys_order = ["version_value", "executable", "pype_root", "mongo_url"] From a9857ee49262d767d0f8c032088a05baaf301391 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 9 Apr 2021 10:30:21 +0200 Subject: [PATCH 087/515] remove local site label from local settings --- .../settings/local_settings/general_widget.py | 22 +++++-------------- .../tools/settings/local_settings/window.py | 13 ++++++----- 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/openpype/tools/settings/local_settings/general_widget.py b/openpype/tools/settings/local_settings/general_widget.py index 7732157122..e820d8ab8b 100644 --- a/openpype/tools/settings/local_settings/general_widget.py +++ b/openpype/tools/settings/local_settings/general_widget.py @@ -5,28 +5,16 @@ class LocalGeneralWidgets(QtWidgets.QWidget): def __init__(self, parent): super(LocalGeneralWidgets, self).__init__(parent) - local_site_name_input = QtWidgets.QLineEdit(self) - - layout = QtWidgets.QFormLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - layout.addRow("Local site label", local_site_name_input) - - self.local_site_name_input = local_site_name_input def update_local_settings(self, value): - site_label = "" - if value: - site_label = value.get("site_label", site_label) - self.local_site_name_input.setText(site_label) + return + + # RETURNING EARLY TO HIDE WIDGET WITHOUT CONTENT def settings_value(self): # Add changed # If these have changed then output = {} - local_site_name = self.local_site_name_input.text() - if local_site_name: - output["site_label"] = local_site_name - # Do not return output yet since we don't have mechanism to save or - # load these data through api calls + # TEMPORARILY EMPTY AS THERE IS NOTHING TO PUT HERE + return output diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py index b6ca56d348..a12a2289b5 100644 --- a/openpype/tools/settings/local_settings/window.py +++ b/openpype/tools/settings/local_settings/window.py @@ -80,6 +80,7 @@ class LocalSettingsWidget(QtWidgets.QWidget): general_widget = LocalGeneralWidgets(general_content) general_layout.addWidget(general_widget) + general_expand_widget.hide() self.main_layout.addWidget(general_expand_widget) @@ -126,9 +127,9 @@ class LocalSettingsWidget(QtWidgets.QWidget): self.system_settings.reset() self.project_settings.reset() - self.general_widget.update_local_settings( - value.get(LOCAL_GENERAL_KEY) - ) + # self.general_widget.update_local_settings( + # value.get(LOCAL_GENERAL_KEY) + # ) self.app_widget.update_local_settings( value.get(LOCAL_APPS_KEY) ) @@ -138,9 +139,9 @@ class LocalSettingsWidget(QtWidgets.QWidget): def settings_value(self): output = {} - general_value = self.general_widget.settings_value() - if general_value: - output[LOCAL_GENERAL_KEY] = general_value + # general_value = self.general_widget.settings_value() + # if general_value: + # output[LOCAL_GENERAL_KEY] = general_value app_value = self.app_widget.settings_value() if app_value: From 065b64d1e08868ad063dcf6b2308cbfafff9d9c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 9 Apr 2021 10:36:20 +0200 Subject: [PATCH 088/515] use OpenPypeSecureRegistry --- openpype/modules/clockify/clockify_api.py | 9 ++-- openpype/modules/ftrack/lib/credentials.py | 48 +++++++++++++++------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/openpype/modules/clockify/clockify_api.py b/openpype/modules/clockify/clockify_api.py index e2de726f39..29de5de0c9 100644 --- a/openpype/modules/clockify/clockify_api.py +++ b/openpype/modules/clockify/clockify_api.py @@ -9,8 +9,7 @@ from .constants import ( ADMIN_PERMISSION_NAMES ) -# from openpype.lib import OpenPypeSettingsRegistry -from openpype.lib.local_settings import OpenPypeSecureRegistry as OpenPypeSettingsRegistry +from openpype.lib.local_settings import OpenPypeSecureRegistry def time_check(obj): @@ -35,7 +34,7 @@ class ClockifyAPI: self.request_counter = 0 self.request_time = time.time() - self.secure_registry = OpenPypeSettingsRegistry("clockify") + self.secure_registry = OpenPypeSecureRegistry("clockify") @property def headers(self): @@ -135,10 +134,10 @@ class ClockifyAPI: return False def get_api_key(self): - return self.secure_registry.get_secure_item("api_key", None) + return self.secure_registry.get_item("api_key", None) def save_api_key(self, api_key): - self.secure_registry.set_secure_item("api_key", api_key) + self.secure_registry.set_item("api_key", api_key) def get_workspaces(self): action_url = 'workspaces/' diff --git a/openpype/modules/ftrack/lib/credentials.py b/openpype/modules/ftrack/lib/credentials.py index 05a74c0875..2d719347e7 100644 --- a/openpype/modules/ftrack/lib/credentials.py +++ b/openpype/modules/ftrack/lib/credentials.py @@ -7,7 +7,7 @@ except ImportError: from urlparse import urlparse -from openpype.lib import OpenPypeSettingsRegistry +from openpype.lib import OpenPypeSecureRegistry USERNAME_KEY = "username" API_KEY_KEY = "api_key" @@ -23,38 +23,56 @@ def get_ftrack_hostname(ftrack_server=None): return urlparse(ftrack_server).hostname -def _get_ftrack_secure_key(hostname): +def _get_ftrack_secure_key(hostname, key): """Secure item key for entered hostname.""" - return "/".join(("ftrack", hostname)) + return "/".join(("ftrack", hostname, key)) def get_credentials(ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) - secure_key = _get_ftrack_secure_key(hostname) + username_name = _get_ftrack_secure_key(hostname, USERNAME_KEY) + api_key_name = _get_ftrack_secure_key(hostname, API_KEY_KEY) + + username_registry = OpenPypeSecureRegistry(username_name) + api_key_registry = OpenPypeSecureRegistry(api_key_name) - registry = OpenPypeSettingsRegistry(secure_key) return { - USERNAME_KEY: registry.get_secure_item(USERNAME_KEY, None), - API_KEY_KEY: registry.get_secure_item(API_KEY_KEY, None) + USERNAME_KEY: username_registry.get_item(USERNAME_KEY, None), + API_KEY_KEY: api_key_registry.get_item(API_KEY_KEY, None) } def save_credentials(username, api_key, ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) - secure_key = _get_ftrack_secure_key(hostname) + username_name = _get_ftrack_secure_key(hostname, USERNAME_KEY) + api_key_name = _get_ftrack_secure_key(hostname, API_KEY_KEY) - registry = OpenPypeSettingsRegistry(secure_key) - registry.set_secure_item(USERNAME_KEY, username) - registry.set_secure_item(API_KEY_KEY, api_key) + # Clear credentials + clear_credentials(ftrack_server) + + username_registry = OpenPypeSecureRegistry(username_name) + api_key_registry = OpenPypeSecureRegistry(api_key_name) + + username_registry.set_item(USERNAME_KEY, username) + api_key_registry.set_item(API_KEY_KEY, api_key) def clear_credentials(ftrack_server=None): hostname = get_ftrack_hostname(ftrack_server) - secure_key = _get_ftrack_secure_key(hostname) + username_name = _get_ftrack_secure_key(hostname, USERNAME_KEY) + api_key_name = _get_ftrack_secure_key(hostname, API_KEY_KEY) - registry = OpenPypeSettingsRegistry(secure_key) - registry.delete_secure_item(USERNAME_KEY) - registry.delete_secure_item(API_KEY_KEY) + username_registry = OpenPypeSecureRegistry(username_name) + api_key_registry = OpenPypeSecureRegistry(api_key_name) + + current_username = username_registry.get_item(USERNAME_KEY, None) + current_api_key = api_key_registry.get_item(API_KEY_KEY, None) + + if current_username is not None: + username_registry.delete_item(USERNAME_KEY) + + if current_api_key is not None: + api_key_registry.delete_item(API_KEY_KEY) def check_credentials(username, api_key, ftrack_server=None): From 21719047943b7f56c4b1ed828b79af57d6ffe64c Mon Sep 17 00:00:00 2001 From: kalisp Date: Fri, 9 Apr 2021 10:00:31 +0000 Subject: [PATCH 089/515] Create draft PR for #1294 From ae7aeab2f75933a152c0c7499d65c420f31f88b7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:13:05 +0200 Subject: [PATCH 090/515] SyncServer - renamed label in Tray --- openpype/modules/sync_server/sync_server_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 4b4b3517ee..177544723e 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -85,7 +85,7 @@ class SyncServerModule(PypeModule, ITrayModule): LOG_PROGRESS_SEC = 5 # how often log progress to DB name = "sync_server" - label = "Sync Server" + label = "Sync Queue" def initialize(self, module_settings): """ From 0a7ca6bed48762bde218e726d6135155305528af Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:20:37 +0200 Subject: [PATCH 091/515] SyncServer - changed order in Status --- openpype/modules/sync_server/tray/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index e0b7c20631..c8f0c78906 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -22,8 +22,8 @@ log = PypeLogger().get_logger("SyncServer") STATUS = { 0: 'In Progress', - 1: 'Failed', - 2: 'Queued', + 1: 'Queued', + 2: 'Failed', 3: 'Paused', 4: 'Synced OK', -1: 'Not available' @@ -445,7 +445,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): else: self.site_name = remote_site - if self.item.state in [STATUS[0], STATUS[2]]: + if self.item.state in [STATUS[0], STATUS[1]]: action = QtWidgets.QAction("Pause") actions_mapping[action] = self._pause menu.addAction(action) @@ -1212,12 +1212,12 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): {'$gte': ['$failed_local_tries', 3]}, {'$gte': ['$failed_remote_tries', 3]} ]}, - 'then': 1}, + 'then': 2}, # Failed { 'case': { '$or': [{'$eq': ['$avg_progress_remote', 0]}, {'$eq': ['$avg_progress_local', 0]}]}, - 'then': 2 # Queued + 'then': 1 # Queued }, { 'case': {'$or': [{'$and': [ @@ -1459,7 +1459,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): actions_kwargs_mapping[action] = {'site': site} menu.addAction(action) - if self.item.state == STATUS[1]: + if self.item.state == STATUS[2]: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail menu.addAction(action) From 69f8d670bb5595fdaae1048dee635ab7a770f8bf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:25:35 +0200 Subject: [PATCH 092/515] SyncServer - resize columns, window --- openpype/modules/sync_server/tray/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index c8f0c78906..6418afbf16 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -46,7 +46,7 @@ class SyncServerWindow(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) - self.resize(1400, 800) + self.resize(1450, 700) self.timer = QtCore.QTimer() self.timer.timeout.connect(self._hide_message) @@ -270,8 +270,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): default_widths = ( ("asset", 210), ("subset", 190), - ("version", 10), - ("representation", 90), + ("version", 15), + ("representation", 95), ("created_dt", 105), ("sync_dt", 105), ("local_site", 80), @@ -279,7 +279,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("files_count", 50), ("files_size", 60), ("priority", 20), - ("state", 50) + ("state", 110) ) column_labels = ( ("asset", "Asset"), @@ -1300,8 +1300,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): ("local_site", 80), ("remote_site", 80), ("size", 60), - ("priority", 20), - ("state", 90) + ("priority", 25), + ("state", 110) ) column_labels = ( From c5e2fcfb1ba51602c7ac2b083bff6493f67e0fda Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Apr 2021 12:26:57 +0200 Subject: [PATCH 093/515] Hiero: transition to openpype and add arg to api --- openpype/hosts/hiero/api/__init__.py | 2 ++ openpype/hosts/hiero/api/lib.py | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/hiero/api/__init__.py b/openpype/hosts/hiero/api/__init__.py index fcb1d50ea8..8d0105ae5f 100644 --- a/openpype/hosts/hiero/api/__init__.py +++ b/openpype/hosts/hiero/api/__init__.py @@ -22,6 +22,7 @@ from .pipeline import ( ) from .lib import ( + pype_tag_name, get_track_items, get_current_project, get_current_sequence, @@ -73,6 +74,7 @@ __all__ = [ "work_root", # Lib functions + "pype_tag_name", "get_track_items", "get_current_project", "get_current_sequence", diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 286aba13e9..d7bac7a3cc 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -30,9 +30,9 @@ self = sys.modules[__name__] self._has_been_setup = False self._has_menu = False self._registered_gui = None -self.pype_tag_name = "Pype Data" -self.default_sequence_name = "PypeSequence" -self.default_bin_name = "PypeBin" +self.pype_tag_name = "openpypeData" +self.default_sequence_name = "openpypeSequence" +self.default_bin_name = "openpypeBin" AVALON_CONFIG = os.getenv("AVALON_CONFIG", "pype") @@ -165,7 +165,7 @@ def get_track_items( # check if any collected track items are # `core.Hiero.Python.TrackItem` instance if track_items: - any_track_item = track_items.pop() + any_track_item = track_items[0] if not isinstance(any_track_item, hiero.core.TrackItem): selected_items = [] @@ -252,7 +252,7 @@ def set_track_item_pype_tag(track_item, data=None): # basic Tag's attribute tag_data = { "editable": "0", - "note": "Pype data holder", + "note": "OpenPype data container", "icon": "openpype_icon.png", "metadata": {k: v for k, v in data.items()} } From cd5daa030ddea393960056aa4839f96732640de9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Apr 2021 12:27:31 +0200 Subject: [PATCH 094/515] Hiero: adding openpype metadata resolution and colorspace to otio --- openpype/hosts/hiero/otio/hiero_export.py | 42 ++++++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/hiero/otio/hiero_export.py b/openpype/hosts/hiero/otio/hiero_export.py index f95dc7a025..93ddee7b38 100644 --- a/openpype/hosts/hiero/otio/hiero_export.py +++ b/openpype/hosts/hiero/otio/hiero_export.py @@ -5,6 +5,7 @@ import os import re import sys import ast +from compiler.ast import flatten import opentimelineio as otio from . import utils import hiero.core @@ -25,7 +26,20 @@ self.marker_color_map = { "blue": otio.schema.MarkerColor.BLUE, } self.timeline = None -self.include_tags = None +self.include_tags = True + + +def get_current_hiero_project(remove_untitled=False): + projects = flatten(hiero.core.projects()) + if not remove_untitled: + return next(iter(projects)) + + # if remove_untitled + for proj in projects: + if "Untitled" in proj.name(): + proj.close() + else: + return proj def create_otio_rational_time(frame, fps): @@ -73,9 +87,10 @@ def create_otio_reference(clip): # add resolution metadata metadata.update({ - "width": int(media_source.width()), - "height": int(media_source.height()), - "pixelAspect": float(media_source.pixelAspect()) + "openpype.source.colourtransform": clip.sourceMediaColourTransform(), + "openpype.source.width": int(media_source.width()), + "openpype.source.height": int(media_source.height()), + "openpype.source.pixelAspect": float(media_source.pixelAspect()) }) otio_ex_ref_item = None @@ -219,7 +234,25 @@ def create_otio_gap(gap_start, clip_start, tl_start_frame, fps): def _create_otio_timeline(): + project = get_current_hiero_project(remove_untitled=False) metadata = _get_metadata(self.timeline) + + metadata.update({ + "openpype.timeline.width": int(self.timeline.format().width()), + "openpype.timeline.height": int(self.timeline.format().height()), + "openpype.timeline.pixelAspect": int(self.timeline.format().pixelAspect()), # noqa + "openpype.project.useOCIOEnvironmentOverride": project.useOCIOEnvironmentOverride(), # noqa + "openpype.project.lutSetting16Bit": project.lutSetting16Bit(), + "openpype.project.lutSetting8Bit": project.lutSetting8Bit(), + "openpype.project.lutSettingFloat": project.lutSettingFloat(), + "openpype.project.lutSettingLog": project.lutSettingLog(), + "openpype.project.lutSettingViewer": project.lutSettingViewer(), + "openpype.project.lutSettingWorkingSpace": project.lutSettingWorkingSpace(), # noqa + "openpype.project.lutUseOCIOForExport": project.lutUseOCIOForExport(), + "openpype.project.ocioConfigName": project.ocioConfigName(), + "openpype.project.ocioConfigPath": project.ocioConfigPath() + }) + start_time = create_otio_rational_time( self.timeline.timecodeStart(), self.project_fps) @@ -266,7 +299,6 @@ def add_otio_metadata(otio_item, media_source, **kwargs): def create_otio_timeline(): - print(">>>>>> self.include_tags: {}".format(self.include_tags)) # get current timeline self.timeline = hiero.ui.activeSequence() self.project_fps = self.timeline.framerate().toFloat() From f419ed655ab0fbc76bbe8965d65c718dff2a7543 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Apr 2021 12:28:08 +0200 Subject: [PATCH 095/515] Hiero: adding new precollect otio plugins --- .../hiero/plugins/publish/extract_workfile.py | 50 +++ .../plugins/publish/precollect_instances.py | 316 ++++++++---------- .../plugins/publish/precollect_workfile.py | 76 ++--- 3 files changed, 210 insertions(+), 232 deletions(-) create mode 100644 openpype/hosts/hiero/plugins/publish/extract_workfile.py diff --git a/openpype/hosts/hiero/plugins/publish/extract_workfile.py b/openpype/hosts/hiero/plugins/publish/extract_workfile.py new file mode 100644 index 0000000000..e3d60465a2 --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish/extract_workfile.py @@ -0,0 +1,50 @@ +import os +import pyblish.api +import openpype.api +from openpype.hosts import resolve + + +class ExtractWorkfile(openpype.api.Extractor): + """ + Extractor export DRP workfile file representation + """ + + label = "Extract Workfile" + order = pyblish.api.ExtractorOrder + families = ["workfile"] + hosts = ["resolve"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + project = instance.context.data["activeProject"] + staging_dir = self.staging_dir(instance) + + resolve_workfile_ext = ".drp" + drp_file_name = name + resolve_workfile_ext + drp_file_path = os.path.normpath( + os.path.join(staging_dir, drp_file_name)) + + # write out the drp workfile + resolve.get_project_manager().ExportProject( + project.GetName(), drp_file_path) + + # create drp workfile representation + representation_drp = { + 'name': resolve_workfile_ext[1:], + 'ext': resolve_workfile_ext[1:], + 'files': drp_file_name, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation_drp) + + # add sourcePath attribute to instance + if not instance.data.get("sourcePath"): + instance.data["sourcePath"] = drp_file_path + + self.log.info("Added Resolve file representation: {}".format( + representation_drp)) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py index 2b769afee1..242cfed254 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py @@ -1,224 +1,178 @@ -from compiler.ast import flatten -from pyblish import api +import pyblish +import openpype from openpype.hosts.hiero import api as phiero -import hiero -# from openpype.hosts.hiero.api import lib -# reload(lib) -# reload(phiero) +from openpype.hosts.hiero.api import lib +from openpype.hosts.hiero.otio import hiero_export + +# # developer reload modules +from pprint import pformat +reload(lib) +reload(phiero) -class PreCollectInstances(api.ContextPlugin): +class PrecollectInstances(pyblish.api.ContextPlugin): """Collect all Track items selection.""" - order = api.CollectorOrder - 0.509 - label = "Pre-collect Instances" + order = pyblish.api.CollectorOrder - 0.59 + label = "Precollect Instances" hosts = ["hiero"] - def process(self, context): - track_items = phiero.get_track_items( - selected=True, check_tagged=True, check_enabled=True) - # only return enabled track items - if not track_items: - track_items = phiero.get_track_items( - check_enabled=True, check_tagged=True) - # get sequence and video tracks - sequence = context.data["activeSequence"] - tracks = sequence.videoTracks() - - # add collection to context - tracks_effect_items = self.collect_sub_track_items(tracks) - - context.data["tracksEffectItems"] = tracks_effect_items - + otio_timeline = context.data["otioTimeline"] + selected_timeline_items = phiero.get_track_items( + selected=True, check_enabled=True, check_tagged=True) self.log.info( - "Processing enabled track items: {}".format(len(track_items))) + "Processing enabled track items: {}".format( + selected_timeline_items)) - for _ti in track_items: - data = {} - clip = _ti.source() + for track_item in selected_timeline_items: - # get clips subtracks and anotations - annotations = self.clip_annotations(clip) - subtracks = self.clip_subtrack(_ti) - self.log.debug("Annotations: {}".format(annotations)) - self.log.debug(">> Subtracks: {}".format(subtracks)) + data = dict() - # get pype tag data - tag_parsed_data = phiero.get_track_item_pype_data(_ti) - # self.log.debug(pformat(tag_parsed_data)) + # get openpype tag data + tag_data = phiero.get_track_item_pype_data(track_item) + self.log.debug("__ tag_data: {}".format(pformat(tag_data))) - if not tag_parsed_data: + if not tag_data: continue - if tag_parsed_data.get("id") != "pyblish.avalon.instance": + if tag_data.get("id") != "pyblish.avalon.instance": continue + + clip = track_item.source() + # add tag data to instance data data.update({ - k: v for k, v in tag_parsed_data.items() + k: v for k, v in tag_data.items() if k not in ("id", "applieswhole", "label") }) - asset = tag_parsed_data["asset"] - subset = tag_parsed_data["subset"] - review_track = tag_parsed_data.get("reviewTrack") - hiero_track = tag_parsed_data.get("heroTrack") - audio = tag_parsed_data.get("audio") - - # remove audio attribute from data - data.pop("audio") + asset = tag_data["asset"] + subset = tag_data["subset"] # insert family into families - family = tag_parsed_data["family"] - families = [str(f) for f in tag_parsed_data["families"]] + family = tag_data["family"] + families = [str(f) for f in tag_data["families"]] families.insert(0, str(family)) - track = _ti.parent() - media_source = _ti.source().mediaSource() - source_path = media_source.firstpath() - file_head = media_source.filenameHead() - file_info = media_source.fileinfos().pop() - source_first_frame = int(file_info.startFrame()) - - # apply only for review and master track instance - if review_track and hiero_track: - families += ["review", "ftrack"] - data.update({ "name": "{} {} {}".format(asset, subset, families), "asset": asset, - "item": _ti, + "item": track_item, "families": families, - - # tags - "tags": _ti.tags(), - - # track item attributes - "track": track.name(), - "trackItem": track, - "reviewTrack": review_track, - - # version data - "versionData": { - "colorspace": _ti.sourceMediaColourTransform() - }, - - # source attribute - "source": source_path, - "sourceMedia": media_source, - "sourcePath": source_path, - "sourceFileHead": file_head, - "sourceFirst": source_first_frame, - - # clip's effect - "clipEffectItems": subtracks + "publish": tag_data["publish"], + "fps": context.data["fps"] }) + # otio clip data + otio_data = self.get_otio_clip_instance_data( + otio_timeline, track_item) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + data.update(otio_data) + self.log.debug("__ data: {}".format(pformat(data))) + + # add resolution + self.get_resolution_to_data(data, context) + + # create instance instance = context.create_instance(**data) - self.log.info("Creating instance.data: {}".format(instance.data)) + # create shot instance for shot attributes create/update + self.create_shot_instance(context, **data) - if audio: - a_data = dict() + self.log.info("Creating instance: {}".format(instance)) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) - # add tag data to instance data - a_data.update({ - k: v for k, v in tag_parsed_data.items() - if k not in ("id", "applieswhole", "label") - }) + def get_resolution_to_data(self, data, context): + assert data.get("otioClip"), "Missing `otioClip` data" - # create main attributes - subset = "audioMain" - family = "audio" - families = ["clip", "ftrack"] - families.insert(0, str(family)) + # solve source resolution option + if data.get("sourceResolution", None): + otio_clip_metadata = data[ + "otioClip"].media_reference.metadata + data.update({ + "resolutionWidth": otio_clip_metadata[ + "openpype.source.width"], + "resolutionHeight": otio_clip_metadata[ + "openpype.source.height"], + "pixelAspect": otio_clip_metadata[ + "openpype.source.pixelAspect"] + }) + else: + otio_tl_metadata = context.data["otioTimeline"].metadata + data.update({ + "resolutionWidth": otio_tl_metadata["openpype.timeline.width"], + "resolutionHeight": otio_tl_metadata["openpype.timeline.height"], + "pixelAspect": otio_tl_metadata["openpype.timeline.pixelAspect"] + }) - name = "{} {} {}".format(asset, subset, families) + def create_shot_instance(self, context, **data): + master_layer = data.get("heroTrack") + hierarchy_data = data.get("hierarchyData") - a_data.update({ - "name": name, - "subset": subset, - "asset": asset, - "family": family, - "families": families, - "item": _ti, + if not master_layer: + return - # tags - "tags": _ti.tags(), - }) + if not hierarchy_data: + return - a_instance = context.create_instance(**a_data) - self.log.info("Creating audio instance: {}".format(a_instance)) + asset = data["asset"] + subset = "shotMain" + + # insert family into families + family = "shot" + + data.update({ + "name": "{} {} {}".format(asset, subset, family), + "subset": subset, + "asset": asset, + "family": family, + "families": [] + }) + + context.create_instance(**data) + + def get_otio_clip_instance_data(self, otio_timeline, track_item): + """ + Return otio objects for timeline, track and clip + + Args: + timeline_item_data (dict): timeline_item_data from list returned by + resolve.get_current_timeline_items() + otio_timeline (otio.schema.Timeline): otio object + + Returns: + dict: otio clip object + + """ + track_name = track_item.parent().name() + timeline_range = self.create_otio_time_range_from_timeline_item_data( + track_item) + for otio_clip in otio_timeline.each_clip(): + track_name = otio_clip.parent().name + parent_range = otio_clip.range_in_parent() + if track_name not in track_name: + continue + if otio_clip.name not in track_item.name(): + continue + if openpype.lib.is_overlapping_otio_ranges( + parent_range, timeline_range, strict=True): + + # add pypedata marker to otio_clip metadata + for marker in otio_clip.markers: + if phiero.pype_tag_name in marker.name: + otio_clip.metadata.update(marker.metadata) + return {"otioClip": otio_clip} + + return None @staticmethod - def clip_annotations(clip): - """ - Returns list of Clip's hiero.core.Annotation - """ - annotations = [] - subTrackItems = flatten(clip.subTrackItems()) - annotations += [item for item in subTrackItems if isinstance( - item, hiero.core.Annotation)] - return annotations + def create_otio_time_range_from_timeline_item_data(track_item): + timeline = phiero.get_current_sequence() + frame_start = int(track_item.timelineIn()) + frame_duration = int(track_item.sourceDuration()) + fps = timeline.framerate().toFloat() - @staticmethod - def clip_subtrack(clip): - """ - Returns list of Clip's hiero.core.SubTrackItem - """ - subtracks = [] - subTrackItems = flatten(clip.parent().subTrackItems()) - for item in subTrackItems: - # avoid all anotation - if isinstance(item, hiero.core.Annotation): - continue - # # avoid all not anaibled - if not item.isEnabled(): - continue - subtracks.append(item) - return subtracks - - @staticmethod - def collect_sub_track_items(tracks): - """ - Returns dictionary with track index as key and list of subtracks - """ - # collect all subtrack items - sub_track_items = dict() - for track in tracks: - items = track.items() - - # skip if no clips on track > need track with effect only - if items: - continue - - # skip all disabled tracks - if not track.isEnabled(): - continue - - track_index = track.trackIndex() - _sub_track_items = flatten(track.subTrackItems()) - - # continue only if any subtrack items are collected - if len(_sub_track_items) < 1: - continue - - enabled_sti = list() - # loop all found subtrack items and check if they are enabled - for _sti in _sub_track_items: - # checking if not enabled - if not _sti.isEnabled(): - continue - if isinstance(_sti, hiero.core.Annotation): - continue - # collect the subtrack item - enabled_sti.append(_sti) - - # continue only if any subtrack items are collected - if len(enabled_sti) < 1: - continue - - # add collection of subtrackitems to dict - sub_track_items[track_index] = enabled_sti - - return sub_track_items + return hiero_export.create_otio_time_range( + frame_start, frame_duration, fps) diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py index ef7d07421b..22201cafe3 100644 --- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py +++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py @@ -1,74 +1,48 @@ -import os import pyblish.api +import hiero.ui from openpype.hosts.hiero import api as phiero from avalon import api as avalon +from pprint import pformat +from openpype.hosts.hiero.otio import hiero_export -class PreCollectWorkfile(pyblish.api.ContextPlugin): +class PrecollectWorkfile(pyblish.api.ContextPlugin): """Inject the current working file into context""" - label = "Pre-collect Workfile" - order = pyblish.api.CollectorOrder - 0.51 + label = "Precollect Workfile" + order = pyblish.api.CollectorOrder - 0.6 def process(self, context): + asset = avalon.Session["AVALON_ASSET"] subset = "workfile" - project = phiero.get_current_project() - active_sequence = phiero.get_current_sequence() - video_tracks = active_sequence.videoTracks() - audio_tracks = active_sequence.audioTracks() - current_file = project.path() - staging_dir = os.path.dirname(current_file) - base_name = os.path.basename(current_file) + active_timeline = hiero.ui.activeSequence() + fps = active_timeline.framerate().toFloat() - # get workfile's colorspace properties - _clrs = {} - _clrs["useOCIOEnvironmentOverride"] = project.useOCIOEnvironmentOverride() # noqa - _clrs["lutSetting16Bit"] = project.lutSetting16Bit() - _clrs["lutSetting8Bit"] = project.lutSetting8Bit() - _clrs["lutSettingFloat"] = project.lutSettingFloat() - _clrs["lutSettingLog"] = project.lutSettingLog() - _clrs["lutSettingViewer"] = project.lutSettingViewer() - _clrs["lutSettingWorkingSpace"] = project.lutSettingWorkingSpace() - _clrs["lutUseOCIOForExport"] = project.lutUseOCIOForExport() - _clrs["ocioConfigName"] = project.ocioConfigName() - _clrs["ocioConfigPath"] = project.ocioConfigPath() - - # set main project attributes to context - context.data["activeProject"] = project - context.data["activeSequence"] = active_sequence - context.data["videoTracks"] = video_tracks - context.data["audioTracks"] = audio_tracks - context.data["currentFile"] = current_file - context.data["colorspace"] = _clrs - - self.log.info("currentFile: {}".format(current_file)) - - # creating workfile representation - representation = { - 'name': 'hrox', - 'ext': 'hrox', - 'files': base_name, - "stagingDir": staging_dir, - } + # adding otio timeline to context + otio_timeline = hiero_export.create_otio_timeline() instance_data = { "name": "{}_{}".format(asset, subset), "asset": asset, "subset": "{}{}".format(asset, subset.capitalize()), "item": project, - "family": "workfile", - - # version data - "versionData": { - "colorspace": _clrs - }, - - # source attribute - "sourcePath": current_file, - "representations": [representation] + "family": "workfile" } + # create instance with workfile instance = context.create_instance(**instance_data) + + # update context with main project attributes + context_data = { + "activeProject": project, + "otioTimeline": otio_timeline, + "currentFile": project.path(), + "fps": fps, + } + context.data.update(context_data) + self.log.info("Creating instance: {}".format(instance)) + self.log.debug("__ instance.data: {}".format(pformat(instance.data))) + self.log.debug("__ context_data: {}".format(pformat(context_data))) From a9f9757c08a1bad0cdd365ff053357fbcffd8eb9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Apr 2021 12:29:15 +0200 Subject: [PATCH 096/515] Hiero: moving old workflow plugins to temp folder --- .../{publish => publish_old_workflow}/collect_assetbuilds.py | 0 .../{publish => publish_old_workflow}/collect_clip_resolution.py | 0 .../{publish => publish_old_workflow}/collect_frame_ranges.py | 0 .../collect_hierarchy_context.py | 0 .../{publish => publish_old_workflow}/collect_host_version.py | 0 .../plugins/{publish => publish_old_workflow}/collect_plates.py | 0 .../plugins/{publish => publish_old_workflow}/collect_review.py | 0 .../{publish => publish_old_workflow}/collect_tag_tasks.py | 0 .../plugins/{publish => publish_old_workflow}/extract_audio.py | 0 .../{publish => publish_old_workflow}/extract_clip_effects.py | 0 .../extract_review_preparation.py | 0 .../{publish => publish_old_workflow}/precollect_clip_effects.py | 0 .../plugins/{publish => publish_old_workflow}/validate_audio.py | 0 .../{publish => publish_old_workflow}/validate_hierarchy.py | 0 .../plugins/{publish => publish_old_workflow}/validate_names.py | 0 .../{publish => publish_old_workflow}/version_up_workfile.py | 0 16 files changed, 0 insertions(+), 0 deletions(-) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_assetbuilds.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_clip_resolution.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_frame_ranges.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_hierarchy_context.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_host_version.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_plates.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_review.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/collect_tag_tasks.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/extract_audio.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/extract_clip_effects.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/extract_review_preparation.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/precollect_clip_effects.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/validate_audio.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/validate_hierarchy.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/validate_names.py (100%) rename openpype/hosts/hiero/plugins/{publish => publish_old_workflow}/version_up_workfile.py (100%) diff --git a/openpype/hosts/hiero/plugins/publish/collect_assetbuilds.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_assetbuilds.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_assetbuilds.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_clip_resolution.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_clip_resolution.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_clip_resolution.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_frame_ranges.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_frame_ranges.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_frame_ranges.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_hierarchy_context.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_hierarchy_context.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_hierarchy_context.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_hierarchy_context.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_host_version.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_host_version.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_host_version.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_plates.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_plates.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_plates.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_plates.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_review.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_review.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_review.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_review.py diff --git a/openpype/hosts/hiero/plugins/publish/collect_tag_tasks.py b/openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_tasks.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/collect_tag_tasks.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/collect_tag_tasks.py diff --git a/openpype/hosts/hiero/plugins/publish/extract_audio.py b/openpype/hosts/hiero/plugins/publish_old_workflow/extract_audio.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/extract_audio.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/extract_audio.py diff --git a/openpype/hosts/hiero/plugins/publish/extract_clip_effects.py b/openpype/hosts/hiero/plugins/publish_old_workflow/extract_clip_effects.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/extract_clip_effects.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/extract_clip_effects.py diff --git a/openpype/hosts/hiero/plugins/publish/extract_review_preparation.py b/openpype/hosts/hiero/plugins/publish_old_workflow/extract_review_preparation.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/extract_review_preparation.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/extract_review_preparation.py diff --git a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_clip_effects.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/precollect_clip_effects.py diff --git a/openpype/hosts/hiero/plugins/publish/validate_audio.py b/openpype/hosts/hiero/plugins/publish_old_workflow/validate_audio.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/validate_audio.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/validate_audio.py diff --git a/openpype/hosts/hiero/plugins/publish/validate_hierarchy.py b/openpype/hosts/hiero/plugins/publish_old_workflow/validate_hierarchy.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/validate_hierarchy.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/validate_hierarchy.py diff --git a/openpype/hosts/hiero/plugins/publish/validate_names.py b/openpype/hosts/hiero/plugins/publish_old_workflow/validate_names.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/validate_names.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/validate_names.py diff --git a/openpype/hosts/hiero/plugins/publish/version_up_workfile.py b/openpype/hosts/hiero/plugins/publish_old_workflow/version_up_workfile.py similarity index 100% rename from openpype/hosts/hiero/plugins/publish/version_up_workfile.py rename to openpype/hosts/hiero/plugins/publish_old_workflow/version_up_workfile.py From ead1da640e5271ab54d4825462ea378a00d80016 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Apr 2021 12:30:16 +0200 Subject: [PATCH 097/515] Hiero: old workfow precollectors for reference to temp folder --- .../precollect_instances.py | 224 ++++++++++++++++++ .../precollect_workfile.py | 74 ++++++ 2 files changed, 298 insertions(+) create mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py create mode 100644 openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py new file mode 100644 index 0000000000..2b769afee1 --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_instances.py @@ -0,0 +1,224 @@ +from compiler.ast import flatten +from pyblish import api +from openpype.hosts.hiero import api as phiero +import hiero +# from openpype.hosts.hiero.api import lib +# reload(lib) +# reload(phiero) + + +class PreCollectInstances(api.ContextPlugin): + """Collect all Track items selection.""" + + order = api.CollectorOrder - 0.509 + label = "Pre-collect Instances" + hosts = ["hiero"] + + + def process(self, context): + track_items = phiero.get_track_items( + selected=True, check_tagged=True, check_enabled=True) + # only return enabled track items + if not track_items: + track_items = phiero.get_track_items( + check_enabled=True, check_tagged=True) + # get sequence and video tracks + sequence = context.data["activeSequence"] + tracks = sequence.videoTracks() + + # add collection to context + tracks_effect_items = self.collect_sub_track_items(tracks) + + context.data["tracksEffectItems"] = tracks_effect_items + + self.log.info( + "Processing enabled track items: {}".format(len(track_items))) + + for _ti in track_items: + data = {} + clip = _ti.source() + + # get clips subtracks and anotations + annotations = self.clip_annotations(clip) + subtracks = self.clip_subtrack(_ti) + self.log.debug("Annotations: {}".format(annotations)) + self.log.debug(">> Subtracks: {}".format(subtracks)) + + # get pype tag data + tag_parsed_data = phiero.get_track_item_pype_data(_ti) + # self.log.debug(pformat(tag_parsed_data)) + + if not tag_parsed_data: + continue + + if tag_parsed_data.get("id") != "pyblish.avalon.instance": + continue + # add tag data to instance data + data.update({ + k: v for k, v in tag_parsed_data.items() + if k not in ("id", "applieswhole", "label") + }) + + asset = tag_parsed_data["asset"] + subset = tag_parsed_data["subset"] + review_track = tag_parsed_data.get("reviewTrack") + hiero_track = tag_parsed_data.get("heroTrack") + audio = tag_parsed_data.get("audio") + + # remove audio attribute from data + data.pop("audio") + + # insert family into families + family = tag_parsed_data["family"] + families = [str(f) for f in tag_parsed_data["families"]] + families.insert(0, str(family)) + + track = _ti.parent() + media_source = _ti.source().mediaSource() + source_path = media_source.firstpath() + file_head = media_source.filenameHead() + file_info = media_source.fileinfos().pop() + source_first_frame = int(file_info.startFrame()) + + # apply only for review and master track instance + if review_track and hiero_track: + families += ["review", "ftrack"] + + data.update({ + "name": "{} {} {}".format(asset, subset, families), + "asset": asset, + "item": _ti, + "families": families, + + # tags + "tags": _ti.tags(), + + # track item attributes + "track": track.name(), + "trackItem": track, + "reviewTrack": review_track, + + # version data + "versionData": { + "colorspace": _ti.sourceMediaColourTransform() + }, + + # source attribute + "source": source_path, + "sourceMedia": media_source, + "sourcePath": source_path, + "sourceFileHead": file_head, + "sourceFirst": source_first_frame, + + # clip's effect + "clipEffectItems": subtracks + }) + + instance = context.create_instance(**data) + + self.log.info("Creating instance.data: {}".format(instance.data)) + + if audio: + a_data = dict() + + # add tag data to instance data + a_data.update({ + k: v for k, v in tag_parsed_data.items() + if k not in ("id", "applieswhole", "label") + }) + + # create main attributes + subset = "audioMain" + family = "audio" + families = ["clip", "ftrack"] + families.insert(0, str(family)) + + name = "{} {} {}".format(asset, subset, families) + + a_data.update({ + "name": name, + "subset": subset, + "asset": asset, + "family": family, + "families": families, + "item": _ti, + + # tags + "tags": _ti.tags(), + }) + + a_instance = context.create_instance(**a_data) + self.log.info("Creating audio instance: {}".format(a_instance)) + + @staticmethod + def clip_annotations(clip): + """ + Returns list of Clip's hiero.core.Annotation + """ + annotations = [] + subTrackItems = flatten(clip.subTrackItems()) + annotations += [item for item in subTrackItems if isinstance( + item, hiero.core.Annotation)] + return annotations + + @staticmethod + def clip_subtrack(clip): + """ + Returns list of Clip's hiero.core.SubTrackItem + """ + subtracks = [] + subTrackItems = flatten(clip.parent().subTrackItems()) + for item in subTrackItems: + # avoid all anotation + if isinstance(item, hiero.core.Annotation): + continue + # # avoid all not anaibled + if not item.isEnabled(): + continue + subtracks.append(item) + return subtracks + + @staticmethod + def collect_sub_track_items(tracks): + """ + Returns dictionary with track index as key and list of subtracks + """ + # collect all subtrack items + sub_track_items = dict() + for track in tracks: + items = track.items() + + # skip if no clips on track > need track with effect only + if items: + continue + + # skip all disabled tracks + if not track.isEnabled(): + continue + + track_index = track.trackIndex() + _sub_track_items = flatten(track.subTrackItems()) + + # continue only if any subtrack items are collected + if len(_sub_track_items) < 1: + continue + + enabled_sti = list() + # loop all found subtrack items and check if they are enabled + for _sti in _sub_track_items: + # checking if not enabled + if not _sti.isEnabled(): + continue + if isinstance(_sti, hiero.core.Annotation): + continue + # collect the subtrack item + enabled_sti.append(_sti) + + # continue only if any subtrack items are collected + if len(enabled_sti) < 1: + continue + + # add collection of subtrackitems to dict + sub_track_items[track_index] = enabled_sti + + return sub_track_items diff --git a/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py new file mode 100644 index 0000000000..ef7d07421b --- /dev/null +++ b/openpype/hosts/hiero/plugins/publish_old_workflow/precollect_workfile.py @@ -0,0 +1,74 @@ +import os +import pyblish.api +from openpype.hosts.hiero import api as phiero +from avalon import api as avalon + + +class PreCollectWorkfile(pyblish.api.ContextPlugin): + """Inject the current working file into context""" + + label = "Pre-collect Workfile" + order = pyblish.api.CollectorOrder - 0.51 + + def process(self, context): + asset = avalon.Session["AVALON_ASSET"] + subset = "workfile" + + project = phiero.get_current_project() + active_sequence = phiero.get_current_sequence() + video_tracks = active_sequence.videoTracks() + audio_tracks = active_sequence.audioTracks() + current_file = project.path() + staging_dir = os.path.dirname(current_file) + base_name = os.path.basename(current_file) + + # get workfile's colorspace properties + _clrs = {} + _clrs["useOCIOEnvironmentOverride"] = project.useOCIOEnvironmentOverride() # noqa + _clrs["lutSetting16Bit"] = project.lutSetting16Bit() + _clrs["lutSetting8Bit"] = project.lutSetting8Bit() + _clrs["lutSettingFloat"] = project.lutSettingFloat() + _clrs["lutSettingLog"] = project.lutSettingLog() + _clrs["lutSettingViewer"] = project.lutSettingViewer() + _clrs["lutSettingWorkingSpace"] = project.lutSettingWorkingSpace() + _clrs["lutUseOCIOForExport"] = project.lutUseOCIOForExport() + _clrs["ocioConfigName"] = project.ocioConfigName() + _clrs["ocioConfigPath"] = project.ocioConfigPath() + + # set main project attributes to context + context.data["activeProject"] = project + context.data["activeSequence"] = active_sequence + context.data["videoTracks"] = video_tracks + context.data["audioTracks"] = audio_tracks + context.data["currentFile"] = current_file + context.data["colorspace"] = _clrs + + self.log.info("currentFile: {}".format(current_file)) + + # creating workfile representation + representation = { + 'name': 'hrox', + 'ext': 'hrox', + 'files': base_name, + "stagingDir": staging_dir, + } + + instance_data = { + "name": "{}_{}".format(asset, subset), + "asset": asset, + "subset": "{}{}".format(asset, subset.capitalize()), + "item": project, + "family": "workfile", + + # version data + "versionData": { + "colorspace": _clrs + }, + + # source attribute + "sourcePath": current_file, + "representations": [representation] + } + + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) From cbf39cea7c8bf842d1c5626a57bd2d3d73f9e8fc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 9 Apr 2021 12:30:42 +0200 Subject: [PATCH 098/515] Global: adding Hiero to hosts --- openpype/plugins/publish/collect_otio_frame_ranges.py | 2 +- openpype/plugins/publish/collect_otio_review.py | 2 +- openpype/plugins/publish/collect_otio_subset_resources.py | 2 +- openpype/plugins/publish/extract_otio_file.py | 2 +- openpype/plugins/publish/extract_otio_review.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index 53cc249033..e1b8b95a46 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -20,7 +20,7 @@ class CollectOcioFrameRanges(pyblish.api.InstancePlugin): label = "Collect OTIO Frame Ranges" order = pyblish.api.CollectorOrder - 0.58 families = ["shot", "clip"] - hosts = ["resolve"] + hosts = ["resolve", "hiero"] def process(self, instance): # get basic variables diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index 0c7eeaea44..de85ef5f7d 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -22,7 +22,7 @@ class CollectOcioReview(pyblish.api.InstancePlugin): label = "Collect OTIO Review" order = pyblish.api.CollectorOrder - 0.57 families = ["clip"] - hosts = ["resolve"] + hosts = ["resolve", "hiero"] def process(self, instance): # get basic variables diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index a0c6b9339b..47cb0a21a8 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -19,7 +19,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin): label = "Collect OTIO Subset Resources" order = pyblish.api.CollectorOrder - 0.57 families = ["clip"] - hosts = ["resolve"] + hosts = ["resolve", "hiero"] def process(self, instance): if not instance.data.get("representations"): diff --git a/openpype/plugins/publish/extract_otio_file.py b/openpype/plugins/publish/extract_otio_file.py index 146f3b88ec..3bd217d5d4 100644 --- a/openpype/plugins/publish/extract_otio_file.py +++ b/openpype/plugins/publish/extract_otio_file.py @@ -12,7 +12,7 @@ class ExtractOTIOFile(openpype.api.Extractor): label = "Extract OTIO file" order = pyblish.api.ExtractorOrder - 0.45 families = ["workfile"] - hosts = ["resolve"] + hosts = ["resolve", "hiero"] def process(self, instance): # create representation data diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 91a680ddb0..85e1ea7aef 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -40,8 +40,8 @@ class ExtractOTIOReview(openpype.api.Extractor): order = api.ExtractorOrder - 0.45 label = "Extract OTIO review" - hosts = ["resolve"] families = ["review"] + hosts = ["resolve", "hiero"] # plugin default attributes temp_file_head = "tempFile." From 93c2c2b9fe7aa6e85a1dff14ed899a24e800b72f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:40:01 +0200 Subject: [PATCH 099/515] SyncServer - changed labels in context menu --- openpype/modules/sync_server/tray/app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 6418afbf16..82a6935471 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -203,6 +203,7 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} if self.sync_server.is_project_paused(self.project_name): @@ -415,6 +416,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): format(self.representation_id)) menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -461,12 +463,12 @@ class SyncRepresentationWidget(QtWidgets.QWidget): # menu.addAction(action) if remote_progress == 1.0: - action = QtWidgets.QAction("Reset local site") + action = QtWidgets.QAction("Re-sync Active site") actions_mapping[action] = self._reset_local_site menu.addAction(action) if local_progress == 1.0: - action = QtWidgets.QAction("Reset remote site") + action = QtWidgets.QAction("Re-sync Remote site") actions_mapping[action] = self._reset_remote_site menu.addAction(action) @@ -1435,6 +1437,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.item = self.table_view.model()._data[point_index.row()] menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} From 86a2ee47834d0c20b56fcfdeabcd3365cfbe6218 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 14:53:56 +0200 Subject: [PATCH 100/515] SyncServer - merged created_dt and progress columns --- openpype/modules/sync_server/tray/app.py | 158 +++++++++++------------ 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 82a6935471..b6550b78ba 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -273,10 +273,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("subset", 190), ("version", 15), ("representation", 95), - ("created_dt", 105), - ("sync_dt", 105), - ("local_site", 80), - ("remote_site", 80), + ("local_site", 185), + ("remote_site", 185), ("files_count", 50), ("files_size", 60), ("priority", 20), @@ -287,8 +285,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("subset", "Subset"), ("version", "Version"), ("representation", "Representation"), - ("created_dt", "Created"), - ("sync_dt", "Synced"), ("local_site", "Active site"), ("remote_site", "Remote site"), ("files_count", "Files"), @@ -333,11 +329,11 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() - time_delegate = PrettyTimeDelegate(self) - column = self.table_view.model().get_header_index("created_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) - column = self.table_view.model().get_header_index("sync_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) + # time_delegate = PrettyTimeDelegate(self) + # column = self.table_view.model().get_header_index("created_dt") + # self.table_view.setItemDelegateForColumn(column, time_delegate) + # column = self.table_view.model().get_header_index("sync_dt") + # self.table_view.setItemDelegateForColumn(column, time_delegate) column = self.table_view.model().get_header_index("local_site") delegate = ImageDelegate(self) @@ -347,10 +343,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("files_size") - delegate = SizeDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) - for column_name, width in self.default_widths: idx = model.get_header_index(column_name) self.table_view.setColumnWidth(idx, width) @@ -611,6 +603,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ProviderRole = QtCore.Qt.UserRole + 2 ProgressRole = QtCore.Qt.UserRole + 4 +DateRole = QtCore.Qt.UserRole + 6 +FailedRole = QtCore.Qt.UserRole + 8 class SyncRepresentationModel(QtCore.QAbstractTableModel): @@ -645,8 +639,6 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): "context.representation", # representation "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt - "avg_progress_local", # local progress - "avg_progress_remote", # remote progress "files_count", # count of files "files_size", # file size of all files "context.asset", # priority TODO @@ -734,19 +726,38 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): def data(self, index, role): item = self._data[index.row()] + header_value = self._header[index.column()] if role == ProviderRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_provider - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_provider if role == ProgressRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_progress - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_progress + if role == DateRole: + if header_value == 'local_site': + if item.created_dt: + return pretty_timestamp(item.created_dt) + if header_value == 'remote_site': + if item.sync_dt: + return pretty_timestamp(item.sync_dt) + + if role == FailedRole: + if header_value == 'local_site': + return item.state == STATUS[2] and item.local_progress < 1 + if header_value == 'remote_site': + return item.state == STATUS[2] and item.remote_progress < 1 + if role == Qt.DisplayRole: + # because of ImageDelegate + if header_value in ['remote_site', 'local_site']: + return "" + return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id @@ -887,7 +898,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): avg_progress_local, avg_progress_remote, repre.get("files_count", 1), - repre.get("files_size", 0), + _pretty_size(repre.get("files_size", 0)), 1, STATUS[repre.get("status", -1)], files[0].get('path') @@ -1297,10 +1308,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): default_widths = ( ("file", 290), - ("created_dt", 105), - ("sync_dt", 105), - ("local_site", 80), - ("remote_site", 80), + ("local_site", 185), + ("remote_site", 185), ("size", 60), ("priority", 25), ("state", 110) @@ -1308,8 +1317,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): column_labels = ( ("file", "File name"), - ("created_dt", "Created"), - ("sync_dt", "Synced"), ("local_site", "Active site"), ("remote_site", "Remote site"), ("files_size", "Size"), @@ -1356,12 +1363,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() - time_delegate = PrettyTimeDelegate(self) - column = self.table_view.model().get_header_index("created_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) - column = self.table_view.model().get_header_index("sync_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) - column = self.table_view.model().get_header_index("local_site") delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) @@ -1370,10 +1371,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("size") - delegate = SizeDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) - for column_name, width in self.default_widths: idx = model.get_header_index(column_name) self.table_view.setColumnWidth(idx, width) @@ -1568,8 +1565,6 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): "files.path", "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt - "progress_local", # local progress - "progress_remote", # remote progress "size", # remote progress "context.asset", # priority TODO "status" # state @@ -1673,19 +1668,37 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): def data(self, index, role): item = self._data[index.row()] + header_value = self._header[index.column()] if role == ProviderRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_provider - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_provider if role == ProgressRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_progress - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_progress + if role == DateRole: + if header_value == 'local_site': + if item.created_dt: + return pretty_timestamp(item.created_dt) + if header_value == 'remote_site': + if item.sync_dt: + return pretty_timestamp(item.sync_dt) + + if role == FailedRole: + if header_value == 'local_site': + return item.state == STATUS[2] and item.local_progress < 1 + if header_value == 'remote_site': + return item.state == STATUS[2] and item.remote_progress < 1 + if role == Qt.DisplayRole: + # because of ImageDelegate + if header_value in ['remote_site', 'local_site']: + return "" return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id @@ -1787,7 +1800,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): remote_provider, local_progress, remote_progress, - file.get('size', 0), + _pretty_size(file.get('size', 0)), 1, STATUS[repre.get("status", -1)], repre.get("tries"), @@ -2092,18 +2105,14 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): self.icons = {} def paint(self, painter, option, index): + super(ImageDelegate, self).paint(painter, option, index) option = QtWidgets.QStyleOptionViewItem(option) option.showDecorationSelected = True - if (option.showDecorationSelected and - (option.state & QtWidgets.QStyle.State_Selected)): - painter.setOpacity(0.20) # highlight color is a bit off - painter.fillRect(option.rect, - option.palette.highlight()) - painter.setOpacity(1) - provider = index.data(ProviderRole) value = index.data(ProgressRole) + date_value = index.data(DateRole) + is_failed = index.data(FailedRole) if not self.icons.get(provider): resource_path = os.path.dirname(__file__) @@ -2115,18 +2124,24 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): else: pixmap = self.icons[provider] - point = QtCore.QPoint(option.rect.x() + - (option.rect.width() - pixmap.width()) / 2, + padding = 10 + point = QtCore.QPoint(option.rect.x() + padding, option.rect.y() + (option.rect.height() - pixmap.height()) / 2) painter.drawPixmap(point, pixmap) - painter.setOpacity(0.5) - overlay_rect = option.rect + overlay_rect = option.rect.translated(0, 0) overlay_rect.setHeight(overlay_rect.height() * (1.0 - float(value))) painter.fillRect(overlay_rect, - QtGui.QBrush(QtGui.QColor(0, 0, 0, 200))) - painter.setOpacity(1) + QtGui.QBrush(QtGui.QColor(0, 0, 0, 100))) + painter.drawText(option.rect, + QtCore.Qt.AlignCenter, + date_value) + + if is_failed: + overlay_rect = option.rect.translated(0, 0) + painter.fillRect(overlay_rect, + QtGui.QBrush(QtGui.QColor(255, 0, 0, 35))) class SyncRepresentationErrorWindow(QtWidgets.QDialog): @@ -2181,27 +2196,12 @@ class SyncRepresentationErrorWidget(QtWidgets.QWidget): QtWidgets.QLabel(msg)) -class SizeDelegate(QtWidgets.QStyledItemDelegate): - """ - Pretty print for file size - """ - - def __init__(self, parent=None): - super(SizeDelegate, self).__init__(parent) - - def displayText(self, value, _locale): - if value is None: - # Ignore None value - return - - return self._pretty_size(value) - - def _pretty_size(self, value, suffix='B'): - for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: - if abs(value) < 1024.0: - return "%3.1f%s%s" % (value, unit, suffix) - value /= 1024.0 - return "%.1f%s%s" % (value, 'Yi', suffix) +def _pretty_size(value, suffix='B'): + for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: + if abs(value) < 1024.0: + return "%3.1f%s%s" % (value, unit, suffix) + value /= 1024.0 + return "%.1f%s%s" % (value, 'Yi', suffix) def _convert_progress(value): From e98d313eb287b9b875d8c6fa6994ea8565c0e143 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 14:59:45 +0200 Subject: [PATCH 101/515] Hound --- openpype/modules/sync_server/providers/abstract_provider.py | 5 +++-- openpype/modules/sync_server/providers/gdrive.py | 2 +- openpype/modules/sync_server/sync_server_module.py | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 2e6e97ebf9..a60595ba93 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -1,5 +1,6 @@ -import abc, six -from openpype.api import Anatomy, Logger +import abc +import six +from openpype.api import Logger log = Logger().get_logger("SyncServer") diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index b6ece5263b..f1ea24f601 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -6,7 +6,7 @@ from googleapiclient import errors from .abstract_provider import AbstractProvider from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload from openpype.api import Logger -from openpype.api import get_system_settings, Anatomy +from openpype.api import get_system_settings from ..utils import time_function import time diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 177544723e..b8820c8dd9 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -1048,7 +1048,6 @@ class SyncServerModule(PypeModule, ITrayModule): provider_name = self.get_provider_for_site(collection, site_name) if provider_name == 'local_drive': - handler = LocalDriveHandler(collection, site_name) query = { "_id": ObjectId(representation_id) } From 8e183b05e982f2d7063493610cbc25f4e8a280fc Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 9 Apr 2021 15:29:28 +0200 Subject: [PATCH 102/515] added default task type mapping settings to ftrack module --- .../defaults/system_settings/modules.json | 16 ++++++++++++++++ .../module_settings/schema_ftrack.json | 6 ++++++ 2 files changed, 22 insertions(+) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index b3065058a1..09198a3ad9 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -21,6 +21,22 @@ }, "default": "-" }, + "task_short_names": { + "Generic": "gener", + "Art": "art", + "Modeling": "mdl", + "Texture": "tex", + "Lookdev": "look", + "Rigging": "rig", + "Edit": "edit", + "Layout": "lay", + "Setdress": "dress", + "Animation": "anim", + "FX": "fx", + "Lighting": "lgt", + "Paint": "paint", + "Compositing": "comp" + }, "custom_attributes": { "show": { "avalon_auto_sync": { diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json index 50ec330a11..2ddb3be795 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json @@ -66,6 +66,12 @@ } ] }, + { + "type": "dict-modifiable", + "key": "task_short_names", + "label": "Default task short names (by Task type)", + "object_type": "text" + }, { "key": "custom_attributes", "label": "Custom Attributes", From 3eab325ca83956c71993f7f21448e958c588b363 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 9 Apr 2021 15:30:41 +0200 Subject: [PATCH 103/515] use short code mapping during synchronization --- openpype/modules/ftrack/lib/avalon_sync.py | 15 +++++++++++---- openpype/modules/ftrack/lib/settings.py | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 7511c2627b..8a453f8935 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -26,6 +26,8 @@ from pymongo import UpdateOne import ftrack_api from openpype.lib import ApplicationManager +from .settings import get_ftrack_settings + log = Logger.get_logger(__name__) @@ -1221,6 +1223,12 @@ class SyncEntitiesFactory: def prepare_ftrack_ent_data(self): not_set_ids = [] + # Prepare short task type mapping + _task_short_names = get_ftrack_settings()["task_short_names"] + task_short_names = { + key.lower(): value + for key, value in _task_short_names.items() + } for id, entity_dict in self.entities_dict.items(): entity = entity_dict["entity"] if entity is None: @@ -1259,11 +1267,10 @@ class SyncEntitiesFactory: tasks = {} for task_type in task_types: task_type_name = task_type["name"] - # Set short name to empty string - # QUESTION Maybe better would be to lower and remove spaces - # from task type name. + # Set short name to mapping from settings or empty string + short_name = task_short_names.get(task_type_name.lower()) tasks[task_type_name] = { - "short_name": "" + "short_name": short_name or "" } current_project_anatomy_data = get_anatomy_settings( diff --git a/openpype/modules/ftrack/lib/settings.py b/openpype/modules/ftrack/lib/settings.py index f6967411db..027356edc6 100644 --- a/openpype/modules/ftrack/lib/settings.py +++ b/openpype/modules/ftrack/lib/settings.py @@ -1,6 +1,7 @@ import os from openpype.api import get_system_settings + def get_ftrack_settings(): return get_system_settings()["modules"]["ftrack"] @@ -10,7 +11,6 @@ def get_ftrack_url_from_settings(): def get_ftrack_event_mongo_info(): - ftrack_settings = get_ftrack_settings() database_name = os.environ["OPENPYPE_DATABASE_NAME"] collection_name = "ftrack_events" return database_name, collection_name From e9b14490eaf25ae74b61f97ffc0b5b5881f61be3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 16:13:30 +0200 Subject: [PATCH 104/515] SyncServer - fix label in Tray --- openpype/modules/sync_server/sync_server_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index b8820c8dd9..59c3787789 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -506,7 +506,7 @@ class SyncServerModule(PypeModule, ITrayModule): from Qt import QtWidgets """Add menu or action to Tray(or parent)'s menu""" - action = QtWidgets.QAction("SyncServer", parent_menu) + action = QtWidgets.QAction(self.label, parent_menu) action.triggered.connect(self.show_widget) parent_menu.addAction(action) parent_menu.addSeparator() From 1023f82139996f6a58dd0a586d717a83e4704038 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 9 Apr 2021 16:22:04 +0200 Subject: [PATCH 105/515] removed added settings --- .../defaults/system_settings/modules.json | 16 ---------------- .../module_settings/schema_ftrack.json | 6 ------ 2 files changed, 22 deletions(-) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 09198a3ad9..b3065058a1 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -21,22 +21,6 @@ }, "default": "-" }, - "task_short_names": { - "Generic": "gener", - "Art": "art", - "Modeling": "mdl", - "Texture": "tex", - "Lookdev": "look", - "Rigging": "rig", - "Edit": "edit", - "Layout": "lay", - "Setdress": "dress", - "Animation": "anim", - "FX": "fx", - "Lighting": "lgt", - "Paint": "paint", - "Compositing": "comp" - }, "custom_attributes": { "show": { "avalon_auto_sync": { diff --git a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json index 2ddb3be795..50ec330a11 100644 --- a/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json +++ b/openpype/settings/entities/schemas/system_schema/module_settings/schema_ftrack.json @@ -66,12 +66,6 @@ } ] }, - { - "type": "dict-modifiable", - "key": "task_short_names", - "label": "Default task short names (by Task type)", - "object_type": "text" - }, { "key": "custom_attributes", "label": "Custom Attributes", From 1359109e44dd6f0caa8c67ebcb8ca87c5e48ed5e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 9 Apr 2021 16:59:49 +0200 Subject: [PATCH 106/515] Use default anatomy task types instead of empty on project sync --- openpype/modules/ftrack/lib/avalon_sync.py | 25 ++++++++++------------ 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 8a453f8935..b9702c1560 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -1223,12 +1223,6 @@ class SyncEntitiesFactory: def prepare_ftrack_ent_data(self): not_set_ids = [] - # Prepare short task type mapping - _task_short_names = get_ftrack_settings()["task_short_names"] - task_short_names = { - key.lower(): value - for key, value in _task_short_names.items() - } for id, entity_dict in self.entities_dict.items(): entity = entity_dict["entity"] if entity is None: @@ -1264,18 +1258,21 @@ class SyncEntitiesFactory: if not msg or not items: continue self.report_items["warning"][msg] = items - tasks = {} - for task_type in task_types: - task_type_name = task_type["name"] - # Set short name to mapping from settings or empty string - short_name = task_short_names.get(task_type_name.lower()) - tasks[task_type_name] = { - "short_name": short_name or "" - } current_project_anatomy_data = get_anatomy_settings( project_name, exclude_locals=True ) + anatomy_tasks = current_project_anatomy_data["tasks"] + tasks = {} + default_type_data = { + "short_name": "" + } + for task_type in task_types: + task_type_name = task_type["name"] + tasks[task_type_name] = copy.deepcopy( + anatomy_tasks.get(task_type_name) + or default_type_data + ) project_config = { "tasks": tasks, From 656a432c103bdfd9c02cd283b4c60df0f2f55335 Mon Sep 17 00:00:00 2001 From: jezscha Date: Fri, 9 Apr 2021 15:28:17 +0000 Subject: [PATCH 107/515] Create draft PR for #1300 From 667b977fca277d11afd70e4121bfa5f3d72fec21 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 9 Apr 2021 19:05:23 +0200 Subject: [PATCH 108/515] removed unused import --- openpype/modules/ftrack/lib/avalon_sync.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index b9702c1560..fbe65efb35 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -26,8 +26,6 @@ from pymongo import UpdateOne import ftrack_api from openpype.lib import ApplicationManager -from .settings import get_ftrack_settings - log = Logger.get_logger(__name__) From cf45201670e205f95762f515931ab177d553be79 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 12 Apr 2021 11:03:08 +0100 Subject: [PATCH 109/515] Added documentation on Modelling and Setting Scene Data --- website/docs/artist_hosts_blender.md | 153 ++++++++++++++++++ .../assets/blender-model_create_instance.jpg | Bin 0 -> 25916 bytes .../assets/blender-model_error_details.jpg | Bin 0 -> 42731 bytes website/docs/assets/blender-model_example.jpg | Bin 0 -> 48903 bytes .../docs/assets/blender-model_pre_publish.jpg | Bin 0 -> 38852 bytes .../assets/blender-model_publish_error.jpg | Bin 0 -> 36965 bytes .../assets/blender-save_modelling_file.jpg | Bin 0 -> 57937 bytes 7 files changed, 153 insertions(+) create mode 100644 website/docs/artist_hosts_blender.md create mode 100644 website/docs/assets/blender-model_create_instance.jpg create mode 100644 website/docs/assets/blender-model_error_details.jpg create mode 100644 website/docs/assets/blender-model_example.jpg create mode 100644 website/docs/assets/blender-model_pre_publish.jpg create mode 100644 website/docs/assets/blender-model_publish_error.jpg create mode 100644 website/docs/assets/blender-save_modelling_file.jpg diff --git a/website/docs/artist_hosts_blender.md b/website/docs/artist_hosts_blender.md new file mode 100644 index 0000000000..b319a4c61f --- /dev/null +++ b/website/docs/artist_hosts_blender.md @@ -0,0 +1,153 @@ +--- +id: artist_hosts_blender +title: Blender +sidebar_label: Blender +--- + +## OpenPype global tools + +- [Set Context](artist_tools.md#set-context) +- [Work Files](artist_tools.md#workfiles) +- [Create](artist_tools.md#creator) +- [Load](artist_tools.md#loader) +- [Manage (Inventory)](artist_tools.md#inventory) +- [Publish](artist_tools.md#publisher) +- [Library Loader](artist_tools.md#library-loader) + +## Working with OpenPype in Blender + +OpenPype is here to ease you the burden of working on project with lots of +collaborators, worrying about naming, setting stuff, browsing through endless +directories, loading and exporting and so on. To achieve that, OpenPype is using +concept of being _"data driven"_. This means that what happens when publishing +is influenced by data in scene. This can by slightly confusing so let's get to +it with few examples. + +## Publishing models + +### Intro + +Publishing models in Blender is pretty straightforward. Create your model as you +need. You might need to adhere to specifications of your studio that can be different +between studios and projects but by default your geometry does not need any +other convention. + +![Model example](assets/blender-model_example.jpg) + +### Creating instance + +Now create **Model instance** from it to let OpenPype know what in the scene you want to +publish. Go **OpenPype → Create... → Model**. + +![Model create instance](assets/blender-model_create_instance.jpg) + +`Asset` field is a name of asset you are working on - it should be already filled +with correct name as you've started Blender or switched context to specific asset. You +can edit that field to change it to different asset (but that one must already exists). + +`Subset` field is a name you can decide on. It should describe what kind of data you +have in the model. For example, you can name it `Proxy` to indicate that this is +low resolution stuff. See [Subset](artist_concepts#subset). + + + +Read-only field just under it show final subset name, adding subset field to +name of the group you have selected. + +`Use selection` checkbox will use whatever you have selected in Outliner to be +wrapped in Model instance. This is usually what you want. Click on **Create** button. + +You'll notice then after you've created new Model instance, there is a new +collection in Outliner called after your asset and subset, in our case it is +`character1_modelDefault`. The assets selected when creating the Model instance +are linked in the new collection. + +And that's it, you have your first model ready to publish. + +Now save your scene (if you didn't do it already). You will notice that path +in Save dialog is already set to place where scenes related to modeling task on +your asset should reside. As in our case we are working on asset called +**character1** and on task **modeling**, path relative to your project directory will be +`project_XY/assets/character1/work/modeling`. The default name for the file will +be `project_XY_asset_task_version`, so in our case +`simonetest_character1_modeling_v001.blend`. Let's save it. + +![Model create instance](assets/blender-save_modelling_file.jpg) + +### Publishing models + +Now let's publish it. Go **OpenPype → Publish...**. You will be presented with following window: + +![Model publish](assets/blender-model_pre_publish.jpg) + +Note that content of this window can differs by your pipeline configuration. +For more detail see [Publisher](artist_tools#publisher). + +Items in left column are instances you will be publishing. You can disable them +by clicking on square next to them. White filled square indicate they are ready for +publishing, red means something went wrong either during collection phase +or publishing phase. Empty one with gray text is disabled. + +See that in this case we are publishing from the scene file +`simonetest_character1_modeling_v001.blend` the Blender model named +`character1_modelDefault`. + +Right column lists all tasks that are run during collection, validation, +extraction and integration phase. White items are optional and you can disable +them by clicking on them. + +Lets do dry-run on publishing to see if we pass all validators. Click on flask +icon at the bottom. Validators are run. Ideally you will end up with everything +green in validator section. + +### Fixing problems + +For the sake of demonstration, I intentionally kept the model in Edit Mode, to +trigger the validator designed to check just this. + +![Failed Model Validator](assets/blender-model_publish_error.jpg) + +You can see our model is now marked red in left column and in right we have +red box next to `Mesh is in Object Mode` validator. + +You can click on arrow next to it to see more details: + +![Failed Model Validator details](assets/blender-model_error_details.jpg) + +From there you can see in **Records** entry that there is problem with the +object `Suzanne`. Some validators have option to fix problem for you or just +select objects that cause trouble. This is the case with our failed validator. + +In main overview you can notice little up arrow in a circle next to validator +name. Right click on it and you can see menu item `select invalid`. This +will select offending object in Maya. + +Fix is easy. Without closing Publisher window we just turn back the Object Mode. +Then we need to reset it to make it notice changes we've made. Click on arrow +circle button at the bottom and it will reset Publisher to initial state. Run +validators again (flask icon) to see if everything is ok. + +It should be now. Write some comment if you want and click play icon button +when ready. + +Publish process will now take its course. Depending on data you are publishing +it can take a while. You should end up with everything green and message +**Finished successfully ...** You can now close publisher window. + +To check for yourself that model is published, open +[Asset Loader](artist_tools#loader) - **OpenPype → Load...**. +There you should see your model, named `modelDefault`. + + +## Setting scene data + +Blender settings concerning framerate, resolution and frame range are handled +by OpenPype. If set correctly in Ftrack, Blender will automatically set the +values for you. diff --git a/website/docs/assets/blender-model_create_instance.jpg b/website/docs/assets/blender-model_create_instance.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d0891c5d051ce782c5808b8e84c8137c874cb8a6 GIT binary patch literal 25916 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