diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index dc47488dc1..308682b884 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -23,7 +23,11 @@ from .lib import ( get_sequence_segments, maintained_segment_selection, reset_segment_selection, - get_segment_attributes + get_segment_attributes, + get_clips_in_reels, + get_reformated_path, + get_frame_from_path, + get_padding_from_path ) from .utils import ( setup @@ -80,6 +84,10 @@ __all__ = [ "maintained_segment_selection", "reset_segment_selection", "get_segment_attributes", + "get_clips_in_reels", + "get_reformated_path", + "get_frame_from_path", + "get_padding_from_path", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 7788a6b3f4..f102eba060 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -16,6 +16,7 @@ from openpype.api import Logger log = Logger.get_logger(__name__) +FRAME_PATTERN = re.compile(r"[\._](\d+)[\.]") class CTX: # singleton used for passing data between api modules @@ -445,6 +446,8 @@ def get_sequence_segments(sequence, selected=False): for segment in track.segments: if segment.name.get_value() == "": continue + if segment.hidden.get_value() is True: + continue if ( selected is True and segment.selected.get_value() is not True @@ -519,7 +522,7 @@ def _get_shot_tokens_values(clip, tokens): def get_segment_attributes(segment): - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": return None # Add timeline segment to tree @@ -532,6 +535,12 @@ def get_segment_attributes(segment): "PySegment": segment } + # head and tail with forward compatibility + if segment.head: + clip_data["segment_head"] = int(segment.head) + if segment.tail: + clip_data["segment_tail"] = int(segment.tail) + # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", "", @@ -551,7 +560,7 @@ def get_segment_attributes(segment): attr = getattr(segment, attr_name) segment_attrs_data[attr] = str(attr).replace("+", ":") - if attr in ["record_in", "record_out"]: + if attr_name in ["record_in", "record_out"]: clip_data[attr_name] = attr.relative_frame else: clip_data[attr_name] = attr.frame @@ -559,3 +568,105 @@ def get_segment_attributes(segment): clip_data["segment_timecodes"] = segment_attrs_data return clip_data + + +def get_clips_in_reels(project): + output_clips = [] + project_desktop = project.current_workspace.desktop + + for reel_group in project_desktop.reel_groups: + for reel in reel_group.reels: + for clip in reel.clips: + clip_data = { + "PyClip": clip, + "fps": float(str(clip.frame_rate)[:-4]) + } + + attrs = [ + "name", "width", "height", + "ratio", "sample_rate", "bit_depth" + ] + + for attr in attrs: + val = getattr(clip, attr) + clip_data[attr] = val + + version = clip.versions[-1] + track = version.tracks[-1] + for segment in track.segments: + segment_data = get_segment_attributes(segment) + clip_data.update(segment_data) + + output_clips.append(clip_data) + + return output_clips + + +def get_reformated_path(filename, padded=True): + """ + Return fixed python expression path + + Args: + filename (str): file name + + Returns: + type: string with reformated path + + Example: + get_reformated_path("plate.1001.exr") > plate.%04d.exr + + """ + found = FRAME_PATTERN.search(filename) + + if not found: + log.info("File name is not sequence: {}".format(filename)) + return filename + + padding = get_padding_from_path(filename) + + replacement = "%0{}d".format(padding) if padded else "%d" + start_idx, end_idx = found.span(1) + + return replacement.join( + [filename[:start_idx], filename[end_idx:]] + ) + + +def get_padding_from_path(filename): + """ + Return padding number from Flame path style + + Args: + filename (str): file name + + Returns: + int: padding number + + Example: + get_padding_from_path("plate.0001.exr") > 4 + + """ + found = get_frame_from_path(filename) + + return len(found) if found else None + + +def get_frame_from_path(filename): + """ + Return sequence number from Flame path style + + Args: + filename (str): file name + + Returns: + int: sequence frame number + + Example: + def get_frame_from_path(path): + ("plate.0001.exr") > 0001 + + """ + + found = re.findall(FRAME_PATTERN, filename) + + return found.pop() if found else None diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index aea1f387e8..615904ec09 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -295,7 +295,7 @@ def create_otio_reference(clip_data): if is_sequence: metadata.update({ "isSequence": True, - "padding": padding + "padding": len(padding) }) otio_ex_ref_item = None diff --git a/openpype/hosts/flame/otio/utils.py b/openpype/hosts/flame/otio/utils.py index 229946343b..57a15d65a1 100644 --- a/openpype/hosts/flame/otio/utils.py +++ b/openpype/hosts/flame/otio/utils.py @@ -1,4 +1,5 @@ import re +import os import opentimelineio as otio import logging log = logging.getLogger(__name__) @@ -33,19 +34,21 @@ def get_reformated_path(path, padded=True): get_reformated_path("plate.1001.exr") > plate.%04d.exr """ - padding = get_padding_from_path(path) - found = get_frame_from_path(path) + basename = os.path.basename(path) + dirpath = os.path.dirname(path) + padding = get_padding_from_path(basename) + found = get_frame_from_path(basename) if not found: log.info("Path is not sequence: {}".format(path)) return path if padded: - path = path.replace(found, "%0{}d".format(padding)) + basename = basename.replace(found, "%0{}d".format(padding)) else: - path = path.replace(found, "%d") + basename = basename.replace(found, "%d") - return path + return os.path.join(dirpath, basename) def get_padding_from_path(path): diff --git a/openpype/hosts/flame/plugins/publish/collect_test_selection.py b/openpype/hosts/flame/plugins/publish/collect_test_selection.py index 73401368b1..9f982321cc 100644 --- a/openpype/hosts/flame/plugins/publish/collect_test_selection.py +++ b/openpype/hosts/flame/plugins/publish/collect_test_selection.py @@ -16,6 +16,7 @@ class CollectTestSelection(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder label = "test selection" hosts = ["flame"] + active = False def process(self, context): self.log.info( diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py new file mode 100644 index 0000000000..a223a17977 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -0,0 +1,256 @@ +import pyblish +import openpype +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export + +# # developer reload modules +from pprint import pformat + + +class CollectTimelineInstances(pyblish.api.ContextPlugin): + """Collect all Timeline segment selection.""" + + order = pyblish.api.CollectorOrder - 0.09 + label = "Collect timeline Instances" + hosts = ["flame"] + + audio_track_items = [] + + def process(self, context): + project = context.data["flameProject"] + sequence = context.data["flameSequence"] + self.otio_timeline = context.data["otioTimeline"] + self.clips_in_reels = opfapi.get_clips_in_reels(project) + self.fps = context.data["fps"] + + # process all sellected + with opfapi.maintained_segment_selection(sequence) as segments: + for segment in segments: + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format( + pformat(marker_data))) + + if not marker_data: + continue + + if marker_data.get("id") != "pyblish.avalon.instance": + continue + + # get file path + file_path = clip_data["fpath"] + + # get source clip + source_clip = self._get_reel_clip(file_path) + + first_frame = opfapi.get_frame_from_path(file_path) or 0 + + head, tail = self._get_head_tail(clip_data, first_frame) + + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) + + with_audio = bool(marker_data.pop("audio")) + + # add marker data to instance data + inst_data = dict(marker_data.items()) + + asset = marker_data["asset"] + subset = marker_data["subset"] + + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) + + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") + + inst_data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": self.fps, + "flameSourceClip": source_clip, + "sourceFirstFrame": first_frame, + "path": file_path + }) + + # get otio clip data + otio_data = self._get_otio_clip_instance_data(clip_data) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + + # add to instance data + inst_data.update(otio_data) + self.log.debug("__ inst_data: {}".format(pformat(inst_data))) + + # add resolution + self._get_resolution_to_data(inst_data, context) + + # create instance + instance = context.create_instance(**inst_data) + + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) + + # create shot instance for shot attributes create/update + self._create_shot_instance(context, clip_name, **inst_data) + + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) + + if not with_audio: + continue + + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True + + def _get_head_tail(self, clip_data, first_frame): + # calculate head and tail with forward compatibility + head = clip_data.get("segment_head") + tail = clip_data.get("segment_tail") + + if not head: + head = int(clip_data["source_in"]) - int(first_frame) + if not tail: + tail = int( + clip_data["source_duration"] - ( + head + clip_data["record_duration"] + ) + ) + return head, tail + + def _get_reel_clip(self, path): + match_reel_clip = [ + clip for clip in self.clips_in_reels + if clip["fpath"] == path + ] + if match_reel_clip: + return match_reel_clip.pop() + + 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[ + "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"] + }) + + def _create_shot_instance(self, context, clip_name, **data): + master_layer = data.get("heroTrack") + hierarchy_data = data.get("hierarchyData") + asset = data.get("asset") + + if not master_layer: + return + + if not hierarchy_data: + return + + asset = data["asset"] + subset = "shotMain" + + # insert family into families + family = "shot" + + # form label + label = asset + if asset != clip_name: + label += " ({}) ".format(clip_name) + label += " {}".format(subset) + label += " [{}]".format(family) + + data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "subset": subset, + "asset": asset, + "family": family, + "families": [] + }) + + instance = context.create_instance(**data) + self.log.info("Creating instance: {}".format(instance)) + self.log.debug( + "_ instance.data: {}".format(pformat(instance.data))) + + def _get_otio_clip_instance_data(self, clip_data): + """ + 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 + + """ + segment = clip_data["PySegment"] + s_track_name = segment.parent.name.get_value() + timeline_range = self._create_otio_time_range_from_timeline_item_data( + clip_data) + + for otio_clip in self.otio_timeline.each_clip(): + track_name = otio_clip.parent().name + parent_range = otio_clip.range_in_parent() + if s_track_name not in track_name: + continue + if otio_clip.name not in segment.name.get_value(): + 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 opfapi.MARKER_NAME in marker.name: + otio_clip.metadata.update(marker.metadata) + return {"otioClip": otio_clip} + + return None + + def _create_otio_time_range_from_timeline_item_data(self, clip_data): + frame_start = int(clip_data["record_in"]) + frame_duration = int(clip_data["record_duration"]) + + return flame_export.create_otio_time_range( + frame_start, frame_duration, self.fps) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py new file mode 100644 index 0000000000..faa5be9d68 --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py @@ -0,0 +1,57 @@ +import pyblish.api +import avalon.api as avalon +import openpype.lib as oplib +import openpype.hosts.flame.api as opfapi +from openpype.hosts.flame.otio import flame_export + + +class CollecTimelineOTIO(pyblish.api.ContextPlugin): + """Inject the current working context into publish context""" + + label = "Collect Timeline OTIO" + order = pyblish.api.CollectorOrder - 0.099 + + def process(self, context): + # plugin defined + family = "workfile" + variant = "otioTimeline" + + # main + asset_doc = context.data["assetEntity"] + task_name = avalon.Session["AVALON_TASK"] + project = opfapi.get_current_project() + sequence = opfapi.get_current_sequence(opfapi.CTX.selection) + + # create subset name + subset_name = oplib.get_subset_name_with_asset_doc( + family, + variant, + task_name, + asset_doc, + ) + + # adding otio timeline to context + with opfapi.maintained_segment_selection(sequence): + otio_timeline = flame_export.create_otio_timeline(sequence) + + instance_data = { + "name": subset_name, + "asset": asset_doc["name"], + "subset": subset_name, + "family": "workfile" + } + + # create instance with workfile + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) + + # update context with main project attributes + context.data.update({ + "flameProject": project, + "flameSequence": sequence, + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name + ), + "fps": float(str(sequence.frame_rate)[:-4]) + }) diff --git a/openpype/hosts/flame/plugins/publish/extract_otio_file.py b/openpype/hosts/flame/plugins/publish/extract_otio_file.py new file mode 100644 index 0000000000..7dd75974fc --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/extract_otio_file.py @@ -0,0 +1,43 @@ +import os +import pyblish.api +import openpype.api +import opentimelineio as otio + + +class ExtractOTIOFile(openpype.api.Extractor): + """ + Extractor export OTIO file + """ + + label = "Extract OTIO file" + order = pyblish.api.ExtractorOrder - 0.45 + families = ["workfile"] + hosts = ["flame"] + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + + name = instance.data["name"] + staging_dir = self.staging_dir(instance) + + otio_timeline = instance.context.data["otioTimeline"] + # create otio timeline representation + otio_file_name = name + ".otio" + otio_file_path = os.path.join(staging_dir, otio_file_name) + + # export otio file to temp dir + otio.adapters.write_to_file(otio_timeline, otio_file_path) + + representation_otio = { + 'name': "otio", + 'ext': "otio", + 'files': otio_file_name, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation_otio) + + self.log.info("Added OTIO file representation: {}".format( + representation_otio)) diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py index fbd64d9f70..61892240d7 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py @@ -63,7 +63,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): order = pyblish.api.IntegratorOrder - 0.04 label = 'Integrate Hierarchy To Ftrack' families = ["shot"] - hosts = ["hiero", "resolve", "standalonepublisher"] + hosts = ["hiero", "resolve", "standalonepublisher", "flame"] optional = False def process(self, context): diff --git a/openpype/plugins/publish/collect_hierarchy.py b/openpype/plugins/publish/collect_hierarchy.py index f7d1c6b4be..7f7306f73b 100644 --- a/openpype/plugins/publish/collect_hierarchy.py +++ b/openpype/plugins/publish/collect_hierarchy.py @@ -15,7 +15,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin): label = "Collect Hierarchy" order = pyblish.api.CollectorOrder - 0.47 families = ["shot"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, context): temp_context = {} diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index a35ef47e79..511ed757b3 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.48 families = ["shot", "clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] 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 10ceafdcca..6634be0671 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.47 families = ["clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] 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 571d0d56a4..d740ceb508 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -20,7 +20,7 @@ class CollectOcioSubsetResources(pyblish.api.InstancePlugin): label = "Collect OTIO Subset Resources" order = pyblish.api.CollectorOrder - 0.47 families = ["clip"] - hosts = ["resolve", "hiero"] + hosts = ["resolve", "hiero", "flame"] def process(self, instance):