diff --git a/client/ayon_unreal/api/lib.py b/client/ayon_unreal/api/lib.py index 0d8da05c..84728b53 100644 --- a/client/ayon_unreal/api/lib.py +++ b/client/ayon_unreal/api/lib.py @@ -306,3 +306,34 @@ def import_animation( sequence.get_playback_end()) sec_params = section.get_editor_property('params') sec_params.set_editor_property('animation', animation) + + +def get_shot_track_names(sel_objects=None, get_name=True): + selection = [ + a for a in sel_objects + if a.get_class().get_name() == "LevelSequence" + ] + + sub_sequence_tracks = [ + track for sel in selection for track in + sel.find_master_tracks_by_type(unreal.MovieSceneSubTrack) + ] + + if get_name: + return [shot_tracks.get_display_name() for shot_tracks in + sub_sequence_tracks] + else: + return [shot_tracks for shot_tracks in sub_sequence_tracks] + + +def get_shot_tracks(members): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + selected_sequences = [ + ar.get_asset_by_object_path(member).get_asset() for member in members + ] + return get_shot_track_names(selected_sequences, get_name=False) + + +def get_screen_resolution(): + game_user_settings = unreal.GameUserSettings.get_game_user_settings() + return game_user_settings.get_screen_resolution() diff --git a/client/ayon_unreal/api/pipeline.py b/client/ayon_unreal/api/pipeline.py index b8fcbc01..66232f3b 100644 --- a/client/ayon_unreal/api/pipeline.py +++ b/client/ayon_unreal/api/pipeline.py @@ -526,6 +526,30 @@ def get_subsequences(sequence: unreal.LevelSequence): return [] +def get_movie_shot_tracks(sequence: unreal.LevelSequence): + """Get list of movie shot tracks from sequence. + + Args: + sequence (unreal.LevelSequence): Sequence + + Returns: + list(unreal.LevelSequence): List of movie shot tracks + + """ + tracks = sequence.find_master_tracks_by_type(unreal.MovieSceneSubTrack) + subscene_track = next( + ( + t + for t in tracks + if t.get_class() == unreal.MovieSceneCinematicShotTrack.static_class() + ), + None, + ) + if subscene_track is not None and subscene_track.get_sections(): + return subscene_track.get_sections() + return [] + + def set_sequence_hierarchy( seq_i, seq_j, max_frame_i, min_frame_j, max_frame_j, map_paths ): @@ -928,6 +952,38 @@ def get_sequence(files): return [os.path.basename(filename) for filename in collections[0]] +def get_sequence_for_otio(files): + """Get sequence from filename. + + This will only return files if they exist on disk as it tries + to collect the sequence using the filename pattern and searching + for them on disk. + + Supports negative frame ranges like -001, 0000, 0001 and -0001, + 0000, 0001. + + Arguments: + files (str): List of files + + Returns: + Optional[list[str]]: file sequence. + Optional[str]: file head. + + """ + base_filenames = [os.path.basename(filename) for filename in files] + collections, _remainder = clique.assemble( + base_filenames, + patterns=[clique.PATTERNS["frames"]], + minimum_items=1) + + if len(collections) > 1: + raise ValueError( + f"Multiple collections found for {collections}. " + "This is a bug.") + filename_padding = collections[0].padding + return filename_padding + + def find_camera_actors_in_camera_tracks(sequence) -> list[Any]: """Find the camera actors in the tracks from the Level Sequence diff --git a/client/ayon_unreal/api/rendering.py b/client/ayon_unreal/api/rendering.py index 2558824c..c31b10bf 100644 --- a/client/ayon_unreal/api/rendering.py +++ b/client/ayon_unreal/api/rendering.py @@ -1,5 +1,4 @@ import os - import unreal from ayon_core.settings import get_project_settings @@ -130,7 +129,7 @@ def start_rendering(): for i in instances: data = pipeline.parse_container(i.get_path_name()) - if data["productType"] == "render": + if data["productType"] == "render" or "editorial_pkg": inst_data.append(data) try: @@ -159,6 +158,8 @@ def start_rendering(): current_level_name = current_level.get_outer().get_path_name() for i in inst_data: + if i["productType"] == "editorial_pkg": + render_dir = f"{root}/{project_name}/editorial_pkg" sequence = ar.get_asset_by_object_path(i["sequence"]).get_asset() sequences = [{ @@ -176,12 +177,15 @@ def start_rendering(): for seq in sequences: subscenes = pipeline.get_subsequences(seq.get('sequence')) - if subscenes: + if subscenes and i["productType"] != "editorial_pkg": for sub_seq in subscenes: + sub_seq_obj = sub_seq.get_sequence() + if sub_seq_obj is None: + continue sequences.append({ - "sequence": sub_seq.get_sequence(), + "sequence": sub_seq_obj, "output": (f"{seq.get('output')}/" - f"{sub_seq.get_sequence().get_name()}"), + f"{sub_seq_obj.get_name()}"), "frame_range": ( sub_seq.get_start_frame(), sub_seq.get_end_frame()) }) @@ -213,7 +217,6 @@ def start_rendering(): # read in the job's OnJobFinished callback. We could, # for instance, pass the AyonPublishInstance's path to the job. # job.user_data = "" - output_dir = render_setting.get('output') shot_name = render_setting.get('sequence').get_name() diff --git a/client/ayon_unreal/hooks/pre_otio_install.py b/client/ayon_unreal/hooks/pre_otio_install.py new file mode 100644 index 00000000..7c79d192 --- /dev/null +++ b/client/ayon_unreal/hooks/pre_otio_install.py @@ -0,0 +1,237 @@ +import os +import subprocess +from platform import system +from ayon_applications import PreLaunchHook, LaunchTypes + + +class InstallOtioToBlender(PreLaunchHook): + """Install Qt binding to Unreal's python packages. + + Prelaunch hook does 2 things: + 1.) Unreal's python packages are pushed to the beginning of PYTHONPATH. + 2.) Check if Unreal has installed otio and will try to install if not. + + For pipeline implementation is required to have Qt binding installed in + Unreal's python packages. + """ + + app_groups = {"unreal"} + launch_types = {LaunchTypes.local} + + def execute(self): + # Prelaunch hook is not crucial + try: + self.inner_execute() + except Exception: + self.log.warning( + "Processing of {} crashed.".format(self.__class__.__name__), + exc_info=True + ) + + def inner_execute(self): + platform = system().lower() + executable = self.launch_context.executable.executable_path + expected_executable = "UnrealEditor" + if platform == "windows": + expected_executable += ".exe" + + if os.path.basename(executable) != expected_executable: + self.log.info(( + f"Executable does not lead to {expected_executable} file." + "Can't determine Unreal's python to check/install" + " otio binding." + )) + return + + versions_dir = self.find_parent_directory(executable) + otio_binding = "opentimelineio" + otio_binding_version = None + + python_dir = os.path.join(versions_dir, "ThirdParty", "Python3", "Win64") + python_version = "python" + + if platform == "windows": + python_executable = os.path.join(python_dir, "python.exe") + else: + python_executable = os.path.join(python_dir, python_version) + # Check for python with enabled 'pymalloc' + if not os.path.exists(python_executable): + python_executable += "m" + + if not os.path.exists(python_executable): + self.log.warning( + "Couldn't find python executable for Unreal. {}".format( + executable + ) + ) + return + + # Check if otio is installed and skip if yes + if self.is_otio_installed(python_executable, otio_binding): + self.log.debug("Unreal has already installed otio.") + return + + # Install otio in Unreal's python + if platform == "windows": + result = self.install_otio_windows( + python_executable, + otio_binding, + otio_binding_version + ) + else: + result = self.install_otio( + python_executable, + otio_binding, + otio_binding_version + ) + + if result: + self.log.info( + f"Successfully installed {otio_binding} module to Unreal." + ) + else: + self.log.warning( + f"Failed to install {otio_binding} module to Unreal." + ) + + def install_otio_windows( + self, + python_executable, + otio_binding, + otio_binding_version + ): + """Install otio python module to Unreal's python. + + Installation requires administration rights that's why it is required + to use "pywin32" module which can execute command's and ask for + administration rights. + """ + try: + import win32con + import win32process + import win32event + import pywintypes + from win32comext.shell.shell import ShellExecuteEx + from win32comext.shell import shellcon + except Exception: + self.log.warning("Couldn't import \"pywin32\" modules") + return + + + otio_binding = f"{otio_binding}==0.16.0" + + try: + # Parameters + # - use "-m pip" as module pip to install otio and argument + # "--ignore-installed" is to force install module to Unreal's + # site-packages and make sure it is binary compatible + fake_exe = "fake.exe" + args = [ + fake_exe, + "-m", + "pip", + "install", + "--ignore-installed", + otio_binding, + ] + + parameters = ( + subprocess.list2cmdline(args) + .lstrip(fake_exe) + .lstrip(" ") + ) + + # Execute command and ask for administrator's rights + process_info = ShellExecuteEx( + nShow=win32con.SW_SHOWNORMAL, + fMask=shellcon.SEE_MASK_NOCLOSEPROCESS, + lpVerb="runas", + lpFile=python_executable, + lpParameters=parameters, + lpDirectory=os.path.dirname(python_executable) + ) + process_handle = process_info["hProcess"] + win32event.WaitForSingleObject(process_handle, win32event.INFINITE) + returncode = win32process.GetExitCodeProcess(process_handle) + return returncode == 0 + except pywintypes.error: + pass + + def install_otio( + self, + python_executable, + otio_binding, + otio_binding_version, + ): + """Install Qt binding python module to Unreal's python.""" + if otio_binding_version: + otio_binding = f"{otio_binding}=={otio_binding_version}" + try: + # Parameters + # - use "-m pip" as module pip to install qt binding and argument + # "--ignore-installed" is to force install module to Unreal's + # site-packages and make sure it is binary compatible + # TODO find out if Unreal 4.x on linux/darwin does install + # qt binding to correct place. + args = [ + python_executable, + "-m", + "pip", + "install", + "--ignore-installed", + otio_binding, + ] + process = subprocess.Popen( + args, stdout=subprocess.PIPE, universal_newlines=True + ) + process.communicate() + return process.returncode == 0 + except PermissionError: + self.log.warning( + "Permission denied with command:" + "\"{}\".".format(" ".join(args)) + ) + except OSError as error: + self.log.warning(f"OS error has occurred: \"{error}\".") + except subprocess.SubprocessError: + pass + + def is_otio_installed(self, python_executable, otio_binding): + """Check if OTIO module is in Unreal's pip list. + + Check that otio is installed directly in Unreal's site-packages. + It is possible that it is installed in user's site-packages but that + may be incompatible with Unreal's python. + """ + + otio_binding_low = otio_binding.lower() + # Get pip list from Unreal's python executable + args = [python_executable, "-m", "pip", "list"] + process = subprocess.Popen(args, stdout=subprocess.PIPE) + stdout, _ = process.communicate() + lines = stdout.decode().split(os.linesep) + # Second line contain dashes that define maximum length of module name. + # Second column of dashes define maximum length of module version. + package_dashes, *_ = lines[1].split(" ") + package_len = len(package_dashes) + + # Got through printed lines starting at line 3 + for idx in range(2, len(lines)): + line = lines[idx] + if not line: + continue + package_name = line[0:package_len].strip() + if package_name.lower() == otio_binding_low: + return True + return False + + def find_parent_directory(self, file_path, target_dir="Binaries"): + # Split the path into components + path_components = file_path.split(os.sep) + + # Traverse the path components to find the target directory + for i in range(len(path_components) - 1, -1, -1): + if path_components[i] == target_dir: + # Join the components to form the target directory path + return os.sep.join(path_components[:i + 1]) + return None diff --git a/client/ayon_unreal/otio/unreal_export.py b/client/ayon_unreal/otio/unreal_export.py new file mode 100644 index 00000000..3e4451da --- /dev/null +++ b/client/ayon_unreal/otio/unreal_export.py @@ -0,0 +1,252 @@ +""" compatibility OpenTimelineIO 0.12.0 and newer +""" + +import os +import unreal +from pathlib import Path +from ayon_core.pipeline import get_current_project_name, Anatomy +from ayon_unreal.api.lib import get_shot_tracks, get_screen_resolution +from ayon_unreal.api.pipeline import get_sequence_for_otio +import opentimelineio as otio + + +TRACK_TYPES = { + "MovieSceneCinematicShotTrack": otio.schema.TrackKind.Video, + "MovieSceneCameraCutTrack": otio.schema.TrackKind.Video, + "MovieSceneAudioTrack": otio.schema.TrackKind.Audio +} + + +class CTX: + project_fps = None + timeline = None + include_tags = True + + +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 create_otio_reference(instance, section, section_number, + frame_start, frame_duration, is_sequence=False): + metadata = {} + + project = get_current_project_name() + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + track_name = section.get_shot_display_name() + render_dir = f"{root}/{project}/editorial_pkg/{instance.data.get('output')}" + render_dir = f"{render_dir}/{track_name}_{section_number + 1}" + render_path = Path(render_dir) + frames = [str(x) for x in render_path.iterdir() if x.is_file()] + # get padding and other file infos + padding = get_sequence_for_otio(frames) + published_file_path = None + for repre in instance.data["representations"]: + if repre["name"] == "intermediate": + published_file_path = _get_published_path(instance, repre) + break + published_dir = os.path.dirname(published_file_path) + file_head, extension = os.path.splitext(os.path.basename(published_file_path)) + fps = CTX.project_fps + + if is_sequence: + metadata.update({ + "isSequence": True, + "padding": padding + }) + + # add resolution metadata + resolution = get_screen_resolution() + metadata.update({ + "ayon.source.width": resolution.x, + "ayon.source.height": resolution.y, + }) + + 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: + otio_ex_ref_item = otio.schema.ImageSequenceReference( + target_url_base=published_dir + 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: + section_filepath = f"{published_dir}/{file_head}.mp4" + # in case old OTIO or video file create `ExternalReference` + otio_ex_ref_item = otio.schema.ExternalReference( + target_url=section_filepath, + available_range=create_otio_time_range( + frame_start, + frame_duration, + fps + ) + ) + + # add metadata to otio item + add_otio_metadata(otio_ex_ref_item, metadata) + + return otio_ex_ref_item + + +def create_otio_clip(instance, target_track): + for section_number, section in enumerate(target_track.get_sections()): + # flip if speed is in minus + shot_start = section.get_start_frame() + duration = int(section.get_end_frame() - section.get_start_frame()) + 1 + + fps = CTX.project_fps + name = section.get_shot_display_name() + + media_reference = create_otio_reference(instance, section, section_number, shot_start, duration) + source_range = create_otio_time_range( + int(shot_start), + int(duration), + fps + ) + + otio_clip = otio.schema.Clip( + name=name, + source_range=source_range, + media_reference=media_reference + ) + + # # only if video + # if not clip.mediaSource().hasAudio(): + # # Add effects to clips + # create_time_effects(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(): + resolution = get_screen_resolution() + metadata = { + "ayon.timeline.width": int(resolution.x), + "ayon.timeline.height": int(resolution.y), + # "ayon.project.ocioConfigName": unreal.OpenColorIOConfiguration().get_name(), + # "ayon.project.ocioConfigPath": unreal.OpenColorIOConfiguration().configuration_file + } + + start_time = create_otio_rational_time( + CTX.timeline.get_playback_start(), CTX.project_fps) + + return otio.schema.Timeline( + name=CTX.timeline.get_name(), + global_start_time=start_time, + metadata=metadata + ) + + +def create_otio_track(track_type, track_name): + return otio.schema.Track( + name=track_name, + kind=TRACK_TYPES[track_type] + ) + + +def add_otio_gap(track_section, otio_track, prev_out): + gap_length = track_section.get_start_frame() - prev_out + if prev_out != 0: + gap_length -= 1 + + gap = otio.opentime.TimeRange( + duration=otio.opentime.RationalTime( + gap_length, + CTX.project_fps + ) + ) + otio_gap = otio.schema.Gap(source_range=gap) + otio_track.append(otio_gap) + + +def add_otio_metadata(otio_item, metadata): + # add metadata to otio item metadata + for key, value in metadata.items(): + otio_item.metadata.update({key: value}) + + +def create_otio_timeline(instance): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + sequence = ar.get_asset_by_object_path( + instance.data.get('sequence')).get_asset() + # get current timeline + CTX.timeline = sequence + frame_rate_obj = CTX.timeline.get_display_rate() + frame_rate = frame_rate_obj.numerator / frame_rate_obj.denominator + CTX.project_fps = frame_rate + # convert timeline to otio + otio_timeline = _create_otio_timeline() + members = instance.data["members"] + # loop all defined track types + for target_track in get_shot_tracks(members): + # convert track to otio + otio_track = create_otio_track( + target_track.get_class().get_name(), + f"{target_track.get_display_name()}") + + # create otio clip and add it to track + otio_clip = create_otio_clip(instance, target_track) + 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): + directory = os.path.dirname(path) + os.makedirs(directory, exist_ok=True) + otio.adapters.write_to_file(otio_timeline, path) + + +def _get_published_path(instance, representation): + """Calculates expected `publish` folder""" + # determine published path from Anatomy. + template_data = instance.data.get("anatomyData") + + template_data["representation"] = representation["name"] + template_data["ext"] = representation["ext"] + template_data["comment"] = None + + anatomy = instance.context.data["anatomy"] + template_data["root"] = anatomy.roots + template = anatomy.get_template_item("publish", "default", "path") + template_filled = template.format_strict(template_data) + file_path = Path(template_filled) + return file_path.as_posix() diff --git a/client/ayon_unreal/plugins/create/create_editorial_package.py b/client/ayon_unreal/plugins/create/create_editorial_package.py new file mode 100644 index 00000000..24bd51a7 --- /dev/null +++ b/client/ayon_unreal/plugins/create/create_editorial_package.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from pathlib import Path +import unreal + +from ayon_core.pipeline import CreatorError +from ayon_unreal.api.plugin import ( + UnrealAssetCreator +) + + +class CreateEditorialPackage(UnrealAssetCreator): + """Create Editorial Package.""" + + identifier = "io.ayon.creators.unreal.editorial_pkg" + label = "Editorial Package" + product_type = "editorial_pkg" + icon = "camera" + + def create_instance( + self, instance_data, product_name, pre_create_data, + selected_asset_path, master_seq, master_lvl, seq_data + ): + instance_data["members"] = [selected_asset_path] + instance_data["sequence"] = selected_asset_path + instance_data["master_sequence"] = master_seq + instance_data["master_level"] = master_lvl + instance_data["output"] = seq_data.get('output') + instance_data["frameStart"] = seq_data.get('frame_range')[0] + instance_data["frameEnd"] = seq_data.get('frame_range')[1] + + + super(CreateEditorialPackage, self).create( + product_name, + instance_data, + pre_create_data) + + def create(self, product_name, instance_data, pre_create_data): + self.create_from_existing_sequence( + product_name, instance_data, pre_create_data) + + def create_from_existing_sequence( + self, product_name, instance_data, pre_create_data + ): + ar = unreal.AssetRegistryHelpers.get_asset_registry() + + sel_objects = unreal.EditorUtilityLibrary.get_selected_assets() + selection = [ + a.get_path_name() for a in sel_objects + if a.get_class().get_name() == "LevelSequence"] + + if len(selection) == 0: + raise CreatorError("Please select at least one Level Sequence.") + + seq_data = {} + + for sel in selection: + selected_asset = ar.get_asset_by_object_path(sel).get_asset() + selected_asset_path = selected_asset.get_path_name() + selected_asset_name = selected_asset.get_name() + search_path = Path(selected_asset_path).parent.as_posix() + package_name = f"{search_path}/{selected_asset_name}" + # Get the master sequence and the master level. + # There should be only one sequence and one level in the directory. + try: + ar_filter = unreal.ARFilter( + class_names=["LevelSequence"], + package_names=[package_name], + package_paths=[search_path], + recursive_paths=False) + sequences = ar.get_assets(ar_filter) + master_seq_obj = sequences[0].get_asset() + master_seq = master_seq_obj.get_path_name() + ar_filter = unreal.ARFilter( + class_names=["World"], + package_paths=[search_path], + recursive_paths=False) + levels = ar.get_assets(ar_filter) + master_lvl = levels[0].get_asset().get_path_name() + except IndexError: + raise RuntimeError( + "Could not find the hierarchy for the selected sequence.") + seq_data.update({ + "output": f"{selected_asset_name}", + "frame_range": ( + selected_asset.get_playback_start(), + selected_asset.get_playback_end()) + }) + self.create_instance( + instance_data, product_name, pre_create_data, + selected_asset_path, master_seq, master_lvl, seq_data) diff --git a/client/ayon_unreal/plugins/publish/collect_extract_package.py b/client/ayon_unreal/plugins/publish/collect_extract_package.py new file mode 100644 index 00000000..edc9a792 --- /dev/null +++ b/client/ayon_unreal/plugins/publish/collect_extract_package.py @@ -0,0 +1,72 @@ +import os +from pathlib import Path +from ayon_core.pipeline.publish import PublishError + +import ayon_api +import pyblish.api +import unreal + +from ayon_core.pipeline import get_current_project_name, Anatomy + + +class CollectEditorialPackage(pyblish.api.InstancePlugin): + """ + Collect neccessary data for editorial package publish + """ + + order = pyblish.api.CollectorOrder - 0.49 + hosts = ["unreal"] + families = ["editorial_pkg"] + label = "Collect Editorial Package" + + def process(self, instance): + project_name = instance.context.data["projectName"] + version = instance.data.get("version") + if version is not None: + # get version from publish data and rise it one up + version += 1 + + # make sure last version of product is higher than current + # expected current version from publish data + folder_entity = ayon_api.get_folder_by_path( + project_name=project_name, + folder_path=instance.data["folderPath"], + ) + last_version = ayon_api.get_last_version_by_product_name( + project_name=project_name, + product_name=instance.data["productName"], + folder_id=folder_entity["id"], + ) + if last_version is not None: + last_version = int(last_version["version"]) + if version <= last_version: + version = last_version + 1 + + instance.data["version"] = version + + ar = unreal.AssetRegistryHelpers.get_asset_registry() + sequence = ar.get_asset_by_object_path( + instance.data.get('sequence')).get_asset() + instance.data["frameStart"] = int(sequence.get_playback_start()) + instance.data["frameEnd"] = int(sequence.get_playback_end()) + frame_rate_obj = sequence.get_display_rate() + frame_rate = frame_rate_obj.numerator / frame_rate_obj.denominator + instance.data["fps"] = frame_rate + + try: + project = get_current_project_name() + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + except Exception as e: + raise Exception(( + "Could not find render root " + "in anatomy settings.")) from e + render_dir = f"{root}/{project}/editorial_pkg/{instance.data.get('output')}" + render_path = Path(render_dir) + if not os.path.exists(render_path): + msg = ( + f"Render directory {render_path} not found." + " Please render with the render instance" + ) + self.log.error(msg) + raise PublishError(msg, title="Render directory not found.") diff --git a/client/ayon_unreal/plugins/publish/extract_editorial_package.py b/client/ayon_unreal/plugins/publish/extract_editorial_package.py new file mode 100644 index 00000000..bf44f9ab --- /dev/null +++ b/client/ayon_unreal/plugins/publish/extract_editorial_package.py @@ -0,0 +1,159 @@ +from pathlib import Path +import unreal +import pyblish.api +import opentimelineio as otio +from ayon_core.pipeline import publish +from ayon_unreal.otio import unreal_export + + +class ExtractEditorialPackage(publish.Extractor): + """ This extractor will try to find + all the rendered frames, converting them into the mp4 file and publish it. + """ + + hosts = ["unreal"] + families = ["editorial_pkg"] + order = pyblish.api.ExtractorOrder + 0.45 + label = "Extract Editorial Package" + + def process(self, instance): + # create representation data + if "representations" not in instance.data: + instance.data["representations"] = [] + anatomy = instance.context.data["anatomy"] + folder_path = instance.data["folderPath"] + ar = unreal.AssetRegistryHelpers.get_asset_registry() + sequence = ar.get_asset_by_object_path( + instance.data.get('sequence')).get_asset() + timeline_name = sequence.get_name() + folder_path_name = folder_path.lstrip("/").replace("/", "_") + + staging_dir = Path(self.staging_dir(instance)) + subfolder_name = folder_path_name + "_" + timeline_name + + # new staging directory for each timeline + staging_dir = staging_dir / subfolder_name + self.log.info(f"Staging directory: {staging_dir}") + + # otio file path + otio_file_path = staging_dir / f"{subfolder_name}.otio" + + + # Find Intermediate file representation file name + published_file_path = None + for repre in instance.data["representations"]: + if repre["name"] == "intermediate": + published_file_path = self._get_published_path(instance, repre) + break + + if published_file_path is None: + raise ValueError("Intermediate representation not found") + # export otio representation + self.export_otio_representation(instance, otio_file_path) + frame_rate = instance.data["fps"] + timeline_start_frame = instance.data["frameStart"] + timeline_end_frame = instance.data["frameEnd"] + timeline_duration = timeline_end_frame - timeline_start_frame + 1 + self.log.info( + f"Timeline: {sequence.get_name()}, " + f"Start: {timeline_start_frame}, " + f"End: {timeline_end_frame}, " + f"Duration: {timeline_duration}, " + f"FPS: {frame_rate}" + ) + # Finding clip references and replacing them with rootless paths + # of video files + otio_timeline = otio.adapters.read_from_file(otio_file_path.as_posix()) + for track in otio_timeline.tracks: + for clip in track: + # skip transitions + if isinstance(clip, otio.schema.Transition): + continue + # skip gaps + if isinstance(clip, otio.schema.Gap): + # get duration of gap + continue + + if hasattr(clip.media_reference, "target_url"): + path_to_media = Path(published_file_path) + # remove root from path + success, rootless_path = anatomy.find_root_template_from_path( # noqa + path_to_media.as_posix() + ) + if success: + media_source_path = rootless_path + else: + media_source_path = path_to_media.as_posix() + new_media_reference = otio.schema.ExternalReference( + target_url=media_source_path, + available_range=otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=timeline_start_frame, rate=frame_rate + ), + duration=otio.opentime.RationalTime( + value=timeline_duration, rate=frame_rate + ), + ), + ) + clip.media_reference = new_media_reference + + # replace clip source range with track parent range + clip.source_range = otio.opentime.TimeRange( + start_time=otio.opentime.RationalTime( + value=( + timeline_start_frame + + clip.range_in_parent().start_time.value + ), + rate=frame_rate, + ), + duration=clip.range_in_parent().duration, + ) + # reference video representations also needs to reframe available + # frames and clip source + + # new otio file needs to be saved as new file + otio_file_path_replaced = staging_dir / f"{subfolder_name}_remap.otio" + otio.adapters.write_to_file( + otio_timeline, otio_file_path_replaced.as_posix()) + + self.log.debug( + f"OTIO file with replaced references: {otio_file_path_replaced}") + + # create drp workfile representation + representation_otio = { + "name": "editorial_pkg", + "ext": "otio", + "files": f"{subfolder_name}_remap.otio", + "stagingDir": staging_dir.as_posix(), + } + self.log.debug(f"OTIO representation: {representation_otio}") + instance.data["representations"].append(representation_otio) + + self.log.info( + "Added OTIO file representation: " + f"{otio_file_path}" + ) + + def export_otio_representation(self, instance, filepath): + otio_timeline = unreal_export.create_otio_timeline(instance) + unreal_export.write_to_file(otio_timeline, filepath.as_posix()) + + # check if file exists + if not filepath.exists(): + raise FileNotFoundError(f"OTIO file not found: {filepath}") + + def _get_published_path(self, instance, representation): + """Calculates expected `publish` folder""" + # determine published path from Anatomy. + template_data = instance.data.get("anatomyData") + + template_data["representation"] = representation["name"] + template_data["ext"] = "mp4" + template_data["comment"] = None + + anatomy = instance.context.data["anatomy"] + template_data["root"] = anatomy.roots + template = anatomy.get_template_item("publish", "default", "path") + template_filled = template.format_strict(template_data) + file_path = Path(template_filled) + return file_path.as_posix() diff --git a/client/ayon_unreal/plugins/publish/extract_intermediate_representation.py b/client/ayon_unreal/plugins/publish/extract_intermediate_representation.py new file mode 100644 index 00000000..b4d9b850 --- /dev/null +++ b/client/ayon_unreal/plugins/publish/extract_intermediate_representation.py @@ -0,0 +1,61 @@ +from pathlib import Path + +import pyblish.api +import os +from ayon_core.pipeline import get_current_project_name, Anatomy +from ayon_core.pipeline import publish +from ayon_core.pipeline.publish import PublishError +from ayon_unreal.api import pipeline + + +class ExtractIntermediateRepresentation(publish.Extractor): + """ This extractor will try to find + all the rendered frames, converting them into the mp4 file and publish it. + """ + + hosts = ["unreal"] + order = pyblish.api.ExtractorOrder - 0.45 + families = ["editorial_pkg"] + label = "Extract Intermediate Representation" + + def process(self, instance): + self.log.debug("Collecting rendered files") + data = instance.data + try: + project = get_current_project_name() + anatomy = Anatomy(project) + root = anatomy.roots['renders'] + except Exception as e: + raise Exception(( + "Could not find render root " + "in anatomy settings.")) from e + render_dir = f"{root}/{project}/editorial_pkg/{data.get('output')}" + render_path = Path(render_dir) + if not os.path.exists(render_path): + msg = ( + f"Render directory {render_path} not found." + " Please render with the render instance" + ) + self.log.error(msg) + raise PublishError(msg, title="Render directory not found.") + self.log.debug(f"Collecting render path: {render_path}") + frames = [str(x) for x in render_path.iterdir() if x.is_file()] + frames = pipeline.get_sequence(frames) + image_format = next((os.path.splitext(x)[-1].lstrip(".") + for x in frames), "exr") + + if "representations" not in instance.data: + instance.data["representations"] = [] + + instance.data["families"].append("review") + + representation = { + 'frameStart': instance.data["frameStart"], + 'frameEnd': instance.data["frameEnd"], + 'name': "intermediate", + 'ext': image_format, + 'files': frames, + 'stagingDir': render_dir, + 'tags': ['review', 'remove'] + } + instance.data["representations"].append(representation)