diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..053fa5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +*.swp +*.pyc +build* +dist* +*.egg-info +.coverage +coverage.info +.DS_store +htmlcov +.vscode/ +xcuserdata/ +.venv/ + +# Pycharm metadata +.idea/ + +# These files are generated, don't put them into source control +docs/api +docs/_build +.tox diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..7e7d759 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +https://github.com/AcademySoftwareFoundation/OpenTimelineIO/blob/main/CODE_OF_CONDUCT.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..9e9b17e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1 @@ +https://github.com/AcademySoftwareFoundation/OpenTimelineIO/blob/main/CONTRIBUTING.md diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..9fa5ae5 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,11 @@ +This plugin is part of the OpenTimelineIO project, whose contributors are +listed at this URL: +https://github.com/AcademySoftwareFoundation/OpenTimelineIO/blob/main/CONTRIBUTORS.md + +This is a list of people who have contributed code to the +OpenTimelineIO-Unreal-Plugin project, sorted alphabetically by first name. + +If you know of anyone missing from this list, please contact us: +https://lists.aswf.io/g/otio-discussion + +* Michael Dolan ([michdolan](https://github.com/michdolan)) diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/OpenTimelineIOUtilities/Content/Python/init_unreal.py b/OpenTimelineIOUtilities/Content/Python/init_unreal.py new file mode 100644 index 0000000..504d3e2 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/init_unreal.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import traceback + +try: + import otio_unreal +except ImportError: + traceback.print_exc() + print("Failed to import otio_unreal! Is opentimelineio on UE_PYTHONPATH?") + +try: + import otio_unreal_actions +except ImportError: + traceback.print_exc() + print("Failed to import otio_unreal_actions! Is PySide2 on UE_PYTHONPATH?") diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/__init__.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/__init__.py new file mode 100644 index 0000000..388589d --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/__init__.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import os + +# Public import/export interface access +from .adapter import import_otio, export_otio +from .hooks import ( + HOOK_PRE_IMPORT, + HOOK_PRE_IMPORT_ITEM, + HOOK_POST_EXPORT, + HOOK_POST_EXPORT_ITEM, +) +from .level_sequence import LevelSequenceProxy +from .shot_section import ShotSectionProxy +from .util import ( + METADATA_KEY_UE, + METADATA_KEY_SUB_SEQ, + MARKER_COLOR_MAP, + get_level_seq_references, + get_item_frame_ranges, + get_sub_sequence_path, + set_sub_sequence_path, + get_root_level_seq_path, + load_or_create_level_seq, +) + +if os.getenv("OTIO_UE_REGISTER_UCLASSES", "1") == "1": + # Register import/export uclasses + from . import uclasses diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/adapter.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/adapter.py new file mode 100644 index 0000000..bc8e156 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/adapter.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import unreal +import opentimelineio as otio + +from .hooks import ( + run_pre_import_otio_hook, + run_pre_import_otio_item_hook, + run_post_export_otio_hook, + run_post_export_otio_clip_hook, +) +from .level_sequence import LevelSequenceProxy +from .util import ( + set_sub_sequence_path, + get_root_level_seq_path, + load_or_create_level_seq, +) + + +def import_otio( + filepath, + level_seq=None, + destination_path=None, + destination_name=None, + dry_run=False, + undo_action_text="Import OTIO Timeline", +): + """Import OTIO-supported timeline file into sequencer, creating or + updating a level sequence hierarchy. + + Args: + filepath (str): Path to an OTIO-supported timeline file + level_seq (unreal.LevelSequence, optional): Root level sequence + to import timeline into. If unspecified, the timeline's + "tracks" stack metadata will be checked for a sub-sequence + path, falling back to a sequence named after the timeline + file and located in "/Game/Sequences". + destination_path (str, optional): Asset directory path in which + to save the imported level sequence. This parameter has no + effect if the imported timeline's "tracks" stack metadata + defines a sub-sequence path. + destination_name (str, optional): Rename the imported level + sequence. If undefined, the sequence name will match the + imported timeline filename. This parameter has no effect if + the imported timeline's "tracks" stack metadata defines a + sub-sequence path. + dry_run (bool, optional): Set to True to process the timeline + to be imported without making changes in Unreal Editor. + undo_action_text (str): Text to describe import action for UE + undo menu. + + Returns: + tuple[unreal.LevelSequence, otio.schema.Timeline]: Root level + sequence and processed timeline. + """ + timeline = otio.adapters.read_from_file(filepath) + + # Implementation-defined timeline update to inject unreal metadata for + # mapping stacks and clips to sub-sequences. + timeline = run_pre_import_otio_hook(timeline) + + # Implementation-defined item update to inject unreal metadata that maps + # items (stacks and clips) to sub-sequences. + run_pre_import_otio_item_hook(timeline.tracks) + + for item in timeline.children_if(): + if isinstance(item, (otio.schema.Stack, otio.schema.Clip)): + run_pre_import_otio_item_hook(item) + + # Use transaction to allow single undo action to revert update + if not dry_run: + with unreal.ScopedEditorTransaction(undo_action_text): + + # Load or create level sequence to update + if level_seq is None: + level_seq_path = get_root_level_seq_path( + filepath, + timeline, + destination_path=destination_path, + destination_name=destination_name, + ) + level_seq = load_or_create_level_seq(level_seq_path) + + # Account for global start time when updating the root level sequence + level_seq_proxy = LevelSequenceProxy( + level_seq, global_start_time=timeline.global_start_time + ) + + # Ensure added sections are visible when opened in Sequencer + level_seq_proxy.update_from_item_ranges(timeline.tracks) + + # Update sequence from timeline + level_seq_proxy.update_from_stack(timeline.tracks) + + # Update Sequencer UI + level_seq_lib = unreal.LevelSequenceEditorBlueprintLibrary + level_seq_lib.refresh_current_level_sequence() + + return level_seq, timeline + + +def export_otio(filepath, level_seq, dry_run=False): + """Export OTIO-supported timeline file from a level sequence + hierarchy. + + Args: + filepath (str): Path to an OTIO-supported timeline file + level_seq (unreal.LevelSequence): Level sequence to export + timeline from. + dry_run (bool, optional): Set to True to process the timeline + to be exported without writing it to disk. + + Returns: + otio.schema.Timeline: Processed timeline + """ + level_seq_proxy = LevelSequenceProxy(level_seq) + + timeline = otio.schema.Timeline() + timeline.global_start_time = level_seq_proxy.get_start_time() + set_sub_sequence_path(timeline.tracks, level_seq.get_path_name()) + + # Update timeline from sequence + level_seq_proxy.update_stack(timeline.tracks) + + # Implementation-defined timeline update to inject media references that + # map unreal metadata to rendered outputs. + timeline = run_post_export_otio_hook(timeline) + + for clip in timeline.clip_if(): + # Implementation-defined clip update to inject a media reference + # that maps unreal metadata to a rendered output. + run_post_export_otio_clip_hook(clip) + + if not dry_run: + otio.adapters.write_to_file(timeline, filepath) + + return timeline diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/hooks.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/hooks.py new file mode 100644 index 0000000..e0a3813 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/hooks.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import opentimelineio as otio + + +# Plugin custom hook names +HOOK_PRE_IMPORT = "otio_ue_pre_import" +HOOK_PRE_IMPORT_ITEM = "otio_ue_pre_import_item" +HOOK_POST_EXPORT = "otio_ue_post_export" +HOOK_POST_EXPORT_ITEM = "otio_ue_post_export" + + +def run_pre_import_otio_hook(timeline): + """This hook is called to modify or replace a timeline prior to + creating or updating a level sequence hierarchy during an OTIO + import. + + Args: + timeline (otio.schema.Timeline): Timeline to process + + Returns: + otio.schema.Timeline: New or updated timeline + """ + if HOOK_PRE_IMPORT in otio.hooks.names(): + return otio.hooks.run(HOOK_PRE_IMPORT, timeline) + else: + return timeline + + +def run_pre_import_otio_item_hook(item): + """This hook is called to modify a stack or clip prior to using it + to update a shot section during an OTIO import. + + Args: + item (otio.schema.Item): Stack or clip to update in place + """ + if HOOK_PRE_IMPORT_ITEM in otio.hooks.names(): + otio.hooks.run(HOOK_PRE_IMPORT_ITEM, item) + + +def run_post_export_otio_hook(timeline): + """This hook is called to modify or replace a timeline following an + OTIO export from a level sequence hierarchy. + + Args: + timeline (otio.schema.Timeline): Timeline to process + + Returns: + otio.schema.Timeline: New or updated timeline + """ + if HOOK_POST_EXPORT in otio.hooks.names(): + return otio.hooks.run(HOOK_POST_EXPORT, timeline) + else: + return timeline + + +def run_post_export_otio_clip_hook(clip): + """This hook is called to modify a clip following it being created + from a shot section during an OTIO export. + + Args: + clip (otio.schema.Clip): Clip to update in place + """ + if HOOK_POST_EXPORT_ITEM in otio.hooks.names(): + otio.hooks.run(HOOK_POST_EXPORT_ITEM, clip) diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/level_sequence.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/level_sequence.py new file mode 100644 index 0000000..5d0b0db --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/level_sequence.py @@ -0,0 +1,573 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +from collections import defaultdict +from contextlib import contextmanager + +import unreal +import opentimelineio as otio +from opentimelineio.opentime import RationalTime, TimeRange + +from .shot_section import ShotSectionProxy +from .util import ( + MARKER_COLOR_MAP, + get_sub_sequence_path, + load_or_create_level_seq, + get_nearest_marker_color, +) + + +class LevelSequenceProxy(object): + """Level sequence wrapper.""" + + def __init__(self, level_seq, parent=None, global_start_time=None): + """ + Args: + level_seq (unreal.LevelSequence): Level sequence + parent (ShotSectionProxy, optional): Proxy for level + sequence's parent shot section. + global_start_time (otio.opentime.RationalTime, optional) If + this level sequence represents an OTIO timeline's root + "tracks" stack, provide the timeline's global start + time as a playback and section range offset for + updating UE from this stack and its immediate children. + """ + self.level_seq = level_seq + self.parent = parent + self.global_start_time = global_start_time + + @contextmanager + def writable(self): + """Context manager which makes this level sequence and its shot + sections temporarily writable. On context exit, the level + sequence's prior read-only state will be restored, and all shot + tracks will be locked to preserve the edit. + """ + # Make level sequence writable + is_read_only = self.level_seq.is_read_only() + self.level_seq.set_read_only(False) + + # Unlock shot tracks + shot_track = self.get_shot_track() + if shot_track is not None: + for section in shot_track.get_sections(): + section.set_is_locked(False) + + yield + + # Lock all shot tracks + shot_track = self.get_shot_track() + if shot_track is not None: + for section in shot_track.get_sections(): + section.set_is_locked(True) + + # Restore level sequence read-only state + self.level_seq.set_read_only(is_read_only) + + def get_shot_track(self): + """Find and return a singleton MovieSceneCinematicShotTrack. If + it exists, this level sequence contains sub-sequences and is + not itself a shot. + + Returns: + unreal.MovieSceneCinematicShotTrack: Shot track, if found, + otherwise None. + """ + shot_tracks = self.level_seq.find_master_tracks_by_exact_type( + unreal.MovieSceneCinematicShotTrack + ) + if shot_tracks: + return shot_tracks[0] + else: + return None + + def get_frame_rate(self): + """Calculate frames rate (frames per second). + + Returns: + int: Frames per second + """ + display_rate = self.level_seq.get_display_rate() + + return float(display_rate.numerator) / float(display_rate.denominator) + + def set_frame_rate(self, frame_rate): + """Set frame rate (frames per second). + + Args: + frame_rate (float): Frames per second + """ + self.level_seq.set_display_rate( + unreal.FrameRate(numerator=frame_rate, denominator=1) + ) + + def get_ticks_per_frame(self): + """Calculate ticks per frame. + + Returns: + int: Ticks per frame + """ + frame_per_second = self.get_frame_rate() + tick_resolution = self.level_seq.get_tick_resolution() + ticks_per_second = float(tick_resolution.numerator) / float( + tick_resolution.denominator + ) + + return ticks_per_second // frame_per_second + + def get_start_time(self): + """Get start time as a ``Rationaltime`` instance. + + Returns: + otio.opentime.RationalTime: Start time + """ + frame_rate = self.get_frame_rate() + start_frame = round(self.level_seq.get_playback_start_seconds() * frame_rate) + + return RationalTime(start_frame, frame_rate) + + def set_playback_range(self, start_seconds, end_seconds): + """Set playback range with inclusive start time and exclusive + end time. This is the sequence's in and out point for + playback in the parent sequence. + + Args: + start_seconds (float): Start time in seconds + end_seconds (float): End time in seconds + """ + self.level_seq.set_playback_start_seconds(start_seconds) + self.level_seq.set_playback_end_seconds(end_seconds) + + def expand_work_range(self, start_seconds, end_seconds): + """Expand work range (scrollable portion of sequence in + Sequencer) to include provided start and end times. + + Args: + start_seconds (float): Start time in seconds + end_seconds (float): End time in seconds + """ + work_start_sec = self.level_seq.get_work_range_start() + if work_start_sec > start_seconds: + self.level_seq.set_work_range_start(start_seconds) + + work_end_sec = self.level_seq.get_work_range_end() + if work_end_sec < end_seconds: + self.level_seq.set_work_range_end(end_seconds) + + def expand_view_range(self, start_seconds, end_seconds): + """Expand view range (currently visible portion of sequence in + Sequencer) to include provided start and end times. + + Args: + start_seconds (float): Start time in seconds + end_seconds (float): End time in seconds + """ + view_start_sec = self.level_seq.get_view_range_start() + if view_start_sec > start_seconds: + self.level_seq.set_view_range_start(start_seconds) + + view_end_sec = self.level_seq.get_view_range_end() + if view_end_sec < end_seconds: + self.level_seq.set_view_range_end(end_seconds) + + def get_available_range(self): + """Calculate OTIO item available range from this level sequence. + + Returns: + otio.opentime.TimeRange: Available range + """ + frame_rate = self.get_frame_rate() + + # NOTE: section.get_*_frame() methods always use floor rounding, so + # we round from float-seconds here ourselves. + start_frame = round(self.level_seq.get_playback_start_seconds() * frame_rate) + end_frame = round(self.level_seq.get_playback_end_seconds() * frame_rate) + duration = end_frame - start_frame + + return TimeRange( + start_time=RationalTime(start_frame, frame_rate), + duration=RationalTime(duration, frame_rate), + ) + + def get_source_range(self): + """Calculate OTIO item source range from this level sequence. + + Returns: + otio.opentime.TimeRange: Source range + """ + available_range = self.get_available_range() + if self.parent is None: + return available_range + + frame_rate = self.get_frame_rate() + range_in_parent = self.parent.get_range_in_parent() + + # Factor in section start frame offset + start_frame = ( + available_range.start_time.to_frames() + + self.parent.get_start_frame_offset() + ) + + # Duration should match section + duration = range_in_parent.duration.to_frames() + + return TimeRange( + start_time=RationalTime(start_frame, frame_rate), + duration=RationalTime(duration, frame_rate), + ) + + def update_from_item_ranges(self, item): + """Update playback, view, and work ranges from an OTIO item. + + Args: + item (otio.schema.Item): Item to update ranges from + """ + source_range = item.trimmed_range() + if ( + hasattr(item, "media_reference") + and item.media_reference + and item.media_reference.available_range + ): + available_range = item.media_reference.available_range + else: + available_range = source_range + + if self.global_start_time is not None: + start_sec = ( + available_range.start_time + self.global_start_time + ).to_seconds() + end_sec = start_sec + available_range.duration.to_seconds() + else: + start_sec = available_range.start_time.to_seconds() + end_sec = available_range.end_time_exclusive().to_seconds() + + # Frame rate + self.set_frame_rate(available_range.start_time.rate) + + if self.parent is not None and self.global_start_time is None: + # Calculate start frame offset from source and available range start + # frame delta. + self.parent.set_start_frame_offset( + source_range.start_time.to_frames() + - available_range.start_time.to_frames() + ) + + # Available range maps to playback range + self.set_playback_range(start_sec, end_sec) + + # Make sure sequence's range is discoverable in Sequencer GUI + self.expand_work_range(start_sec, end_sec) + self.expand_view_range(start_sec, end_sec) + + def update_markers(self, parent_item): + """Add all markers within this level sequence to a stack. + + Note: + Markers don't currently roundtrip losslessly since OTIO + supports them on stacks, tracks, clips, gaps, etc., and UE + only supports them on level sequences. The result is that a + roundtrip will push all track markers to their parent + stack, and gap markers will be lost (since a gap does not + map to a level sequence). Stack and clip markers are + preserved. + + Args: + parent_item (otio.schema.Item): Item to add markers to + """ + parent_range = parent_item.trimmed_range() + frame_rate = self.get_frame_rate() + + if parent_item.markers: + parent_item.markers.clear() + + for frame_marker in self.level_seq.get_marked_frames(): + # Convert from frame number at tick resolution + frame = frame_marker.frame_number.value // self.get_ticks_per_frame() + marked_range = TimeRange( + start_time=RationalTime(frame, frame_rate), + duration=RationalTime(1, frame_rate), + ) + + # Only add visible markers + if not ( + parent_range.contains(marked_range.start_time) + and parent_range.contains(marked_range.end_time_inclusive()) + ): + continue + + marker = otio.schema.Marker() + marker.name = frame_marker.label + marker.marked_range = marked_range + marker.color = get_nearest_marker_color( + frame_marker.get_editor_property("color") + ) + parent_item.markers.append(marker) + + def update_from_markers(self, parent_item): + """Find all markers within the given parent OTIO item (stack, + clip, etc.) and add them as frame markers to this level + sequence. + + Note: + OTIO markers support a frame range, but UE frame markers + mark a single frame. Only the first frame of each marker + range will be marked in UE. + + Args: + parent_item (otio.schema.Item): Item to add markers from + """ + # Only clear current markers if the parent_item includes at least one + if parent_item.markers: + self.level_seq.delete_marked_frames() + + if isinstance(parent_item, otio.schema.Stack): + # For Stacks, get markers from the stack and individual tracks + markers = list(parent_item.markers) + for track in parent_item: + markers.extend(track.markers) + else: + markers = parent_item.markers + + for marker in markers: + marker_frame = marker.marked_range.start_time.to_frames() + if self.global_start_time is not None: + marker_frame += self.global_start_time.to_frames() + + frame = unreal.FrameNumber( + # convert to frame number at tick resolution + marker_frame + * self.get_ticks_per_frame() + ) + color = MARKER_COLOR_MAP.get( + marker.color, + # UE default marked frame color + MARKER_COLOR_MAP[otio.schema.MarkerColor.CYAN], + ) + + marked_frame = unreal.MovieSceneMarkedFrame() + marked_frame.label = marker.name + marked_frame.frame_number = frame + marked_frame.set_editor_property("color", color) + self.level_seq.add_marked_frame(marked_frame) + + def update_stack(self, parent_stack): + """Recursively update an OTIO stack hierarchy from this level + sequence. It's assumed this is first called on a root level + sequence with a timeline's builtin ``tracks`` stack. + + Args: + parent_stack (otio.schema.Stack): Stack to update from + level sequence hierarchy. + """ + parent_shot_track = self.get_shot_track() + if parent_shot_track is None: + return + + # Get level sequence start frame and frame rate + parent_start_time = self.get_start_time() + parent_start_frame = parent_start_time.to_frames() + parent_frame_rate = parent_start_time.rate + + # Organize sections into rows + row_sections = defaultdict(list) + for section in parent_shot_track.get_sections(): + row_sections[section.get_row_index()].append(section) + + # Build video track for each row + multi_track = len(row_sections) > 1 + + for row_idx, sections in sorted(row_sections.items()): + video_track = otio.schema.Track(kind=otio.schema.track.TrackKind.Video) + + # Name track if possible + if not multi_track: + video_track.name = str(parent_shot_track.get_display_name()) + elif hasattr(parent_shot_track, "get_track_row_display_name"): + video_track.name = str( + parent_shot_track.get_track_row_display_name(row_idx) + ) + + # Video tracks are stacked in reverse in a timeline, with the lowest + # index at the bottom. + parent_stack.insert(0, video_track) + + prev_section_end_frame = parent_start_frame + + # Map sections to OTIO items + for section in sections: + section_proxy = ShotSectionProxy(section, self) + section_name = section.get_shot_display_name() + sub_seq = section.get_sequence() + + # Range in parent + section_range = section_proxy.get_range_in_parent() + section_start_frame = section_range.start_time.to_frames() + section_end_frame = section_range.end_time_exclusive().to_frames() + + # Gap: From previous clip or parent start frame + if section_start_frame > prev_section_end_frame: + gap = otio.schema.Gap( + source_range=TimeRange( + duration=RationalTime( + section_start_frame - prev_section_end_frame, + parent_frame_rate, + ) + ) + ) + video_track.append(gap) + + # Gap: No sub-sequence reference + if sub_seq is None: + gap = otio.schema.Gap(name=section_name, source_range=section_range) + video_track.append(gap) + + prev_section_end_frame = section_end_frame + continue + + sub_seq_proxy = LevelSequenceProxy(sub_seq, section_proxy) + + # Source range + source_range = sub_seq_proxy.get_source_range() + + # Stack: Nested tracks + child_shot_track = sub_seq_proxy.get_shot_track() + if child_shot_track is not None: + child_stack = otio.schema.Stack( + name=section_name, source_range=source_range + ) + section_proxy.update_effects(child_stack) + section_proxy.update_metadata(child_stack) + + # Recurse into child sequences + sub_seq_proxy.update_stack(child_stack) + + video_track.append(child_stack) + + # Clip + else: + clip = otio.schema.Clip( + name=section_name, source_range=source_range + ) + section_proxy.update_effects(clip) + section_proxy.update_metadata(clip) + + # Marked clip frames + sub_seq_proxy.update_markers(clip) + + # Available range + media_ref = otio.schema.MissingReference( + available_range=sub_seq_proxy.get_available_range() + ) + clip.media_reference = media_ref + + video_track.append(clip) + + prev_section_end_frame = section_end_frame + + # Marked stack/track frames + self.update_markers(parent_stack) + + def update_from_stack(self, parent_stack): + """Recursively update this level sequence's hierarchy from an + OTIO stack. It's assumed this is first called on a root level + sequence with a timeline's builtin ``tracks`` stack. + + While a root level sequence must exist before calling this (the + entry point for the update), sub-sequences will be created if + they don't exist, and referenced if they exist but aren't in a + shot track yet. New and existing sub-sequences are updated to + match the OTIO objects from the stack. + + Args: + parent_stack (otio.schema.Stack): Stack to update level + sequence hierarchy from. + """ + # Make sequence temporarily writable + with self.writable(): + + # Create shot track? + shot_track = self.get_shot_track() + + if shot_track is None: + shot_track = self.level_seq.add_master_track( + unreal.MovieSceneCinematicShotTrack + ) + + # Map of shots currently referenced in the track + current_sections = {} + for section in shot_track.get_sections(): + sub_seq = section.get_sequence() + if sub_seq is not None: + sub_seq_path = sub_seq.get_path_name() + if sub_seq_path not in current_sections: + current_sections[sub_seq_path] = [] + current_sections[sub_seq_path].append(section) + + # Create/update shot sections + multi_track = len(parent_stack) > 1 + + # Video tracks are stacked in reverse in a timeline, with the lowest + # index at the bottom. + for row_index, track in enumerate(reversed(parent_stack)): + if track.kind != otio.schema.track.TrackKind.Video: + continue + + # Name track if possible + if not multi_track: + shot_track.set_display_name(track.name) + elif hasattr(shot_track, "set_track_row_display_name"): + shot_track.set_track_row_display_name(track.name, row_index) + + # Add or update shots from stack, removing them from the + # current_shots map in case there are multiple instances. + for item in track: + if not isinstance(item, (otio.schema.Clip, otio.schema.Stack)): + continue + + # Clip or Stack: Update or create section + try: + sub_seq_path = get_sub_sequence_path(item) + except KeyError: + continue + + if ( + sub_seq_path in current_sections + and current_sections[sub_seq_path] + ): + section = current_sections[sub_seq_path].pop(0) + if not current_sections[sub_seq_path]: + del current_sections[sub_seq_path] + sub_seq = section.get_sequence() + else: + sub_seq = load_or_create_level_seq(sub_seq_path) + + section = shot_track.add_section() + section.set_sequence(sub_seq) + + section_proxy = ShotSectionProxy(section, self) + sub_seq_proxy = LevelSequenceProxy(sub_seq, section_proxy) + + # Set track index, supporting multi-track stacks + section.set_row_index(row_index) + + # NOTE: The order of these update methods is important + section_proxy.update_from_item_range(item) + section_proxy.update_from_effects(item) + section_proxy.update_from_metadata(item) + sub_seq_proxy.update_from_item_ranges(item) + + # Stack: Recurse into child sequences + if isinstance(item, otio.schema.Stack): + sub_seq_proxy.update_from_stack(item) + else: + # Replace clip markers if present + sub_seq_proxy.update_from_markers(item) + + # Remove unreferenced shots (which have not been removed from + # the current_shots map) + for sub_seq_path, sections in current_sections.items(): + for section in sections: + shot_track.remove_section(section) + + # Replace stack markers if present + self.update_from_markers(parent_stack) diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/shot_section.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/shot_section.py new file mode 100644 index 0000000..df342a7 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/shot_section.py @@ -0,0 +1,221 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import re + +import unreal +import opentimelineio as otio +from opentimelineio.opentime import RationalTime, TimeRange + +from .util import METADATA_KEY_UE, METADATA_KEY_SUB_SEQ + + +class ShotSectionProxy(object): + """Shot section wrapper.""" + + def __init__(self, shot_section, parent): + """ + Args: + shot_section (unreal.MovieSceneCinematicShotSection): Shot + section. + parent (LevelSequenceProxy): Proxy for section's parent + level sequence. + """ + self.section = shot_section + self.parent = parent + + def get_start_frame_offset(self): + """Calculate start frame offset for this section's + sub-sequence. + + Returns: + int: Offset in frames + """ + start_ticks_offset = self.section.parameters.start_frame_offset.value + ticks_per_frame = self.parent.get_ticks_per_frame() + + return round(float(start_ticks_offset) / float(ticks_per_frame)) + + def set_start_frame_offset(self, frames): + """Set start frame offset for this section's sub-sequence. + + Args: + frames (int): Start frame offset + """ + ticks_per_frame = self.parent.get_ticks_per_frame() + self.section.parameters.start_frame_offset.value = frames * ticks_per_frame + + def get_time_scale(self): + """Return this section's time scale, which scales the playback + range of its sub-sequence. + + Returns: + float: Time scalar + """ + return self.section.parameters.time_scale + + def set_time_scale(self, time_scale): + """Set this section's time scale, which scales the playback + range of its sub-sequence. + + Args: + time_scale (float): Time scale value + """ + self.section.parameters.time_scale = time_scale + + def get_range_in_parent(self): + """Calculate OTIO item range within its parent track. + + Returns: + otio.opentime.TimeRange: Section range + """ + frame_rate = self.parent.get_frame_rate() + + # NOTE: section.get_*_frame() methods always use floor rounding, so + # we round from float-seconds here ourselves. + parent_start_frame = round(self.section.get_start_frame_seconds() * frame_rate) + parent_end_frame = round(self.section.get_end_frame_seconds() * frame_rate) + parent_duration = parent_end_frame - parent_start_frame + + return TimeRange( + start_time=RationalTime(parent_start_frame, frame_rate), + duration=RationalTime(parent_duration, frame_rate), + ) + + def update_from_item_range(self, item): + """Update section range within its parent track from an OTIO + item. + + Args: + item (otio.schema.Item): Item to update ranges from + """ + range_in_parent = item.range_in_parent() + + if self.parent.global_start_time is not None: + start_frames = ( + range_in_parent.start_time + self.parent.global_start_time + ).to_frames() + end_frames = start_frames + range_in_parent.duration.to_frames() + else: + start_frames = range_in_parent.start_time.to_frames() + end_frames = range_in_parent.end_time_exclusive().to_frames() + + self.section.set_range(start_frames, end_frames) + + def update_effects(self, item): + """Add effects needed to represent this section to an OTIO + item. + + Args: + item (otio.schema.Item): Item to add effects to + """ + time_scale = self.get_time_scale() + if time_scale != 1.0: + item.effects.append(otio.schema.LinearTimeWarp(time_scalar=time_scale)) + + def update_from_effects(self, item): + """Update shot section properties from OTIO item effects. + + Args: + item (otio.schema.Item): item to get effects from + """ + time_scale = 1.0 + for effect in item.effects: + if isinstance(effect, otio.schema.LinearTimeWarp): + time_scale *= effect.time_scalar + self.set_time_scale(time_scale) + + def update_metadata(self, item): + """Serialize shot section properties into OTIO item metadata. + + Args: + item (otio.schema.Item): Item to set metadata on + """ + timecode_source = self.section.get_editor_property("timecode_source") + timecode_obj = timecode_source.get_editor_property("timecode") + timecode_str = "{h:02d}:{m:02d}:{s:02d}{sep}{f:02d}".format( + sep=":" if not timecode_obj.drop_frame_format else ";", + h=timecode_obj.hours, + m=timecode_obj.minutes, + s=timecode_obj.seconds, + f=timecode_obj.frames, + ) + + # NOTE: start_frame_offset and time_scale are omitted here since they + # will factor into a clip's source range and effects. + metadata = { + "timecode": timecode_str, + "is_active": self.section.is_active(), + "is_locked": self.section.is_locked(), + "pre_roll_frames": self.section.get_pre_roll_frames(), + "post_roll_frames": self.section.get_post_roll_frames(), + "can_loop": self.section.parameters.can_loop, + "end_frame_offset": self.section.parameters.end_frame_offset.value, + "first_loop_start_frame_offset": + self.section.parameters.first_loop_start_frame_offset.value, + "hierarchical_bias": self.section.parameters.hierarchical_bias, + METADATA_KEY_SUB_SEQ: self.section.get_sequence().get_path_name(), + "network_mask": self.section.get_editor_property("network_mask"), + } + + item.metadata[METADATA_KEY_UE] = metadata + + def update_from_metadata(self, item): + """Update shot section properties from deserialized OTIO item + metadata. + + Args: + item (otio.schema.Item): item to get metadata from + """ + metadata = item.metadata.get(METADATA_KEY_UE) + if not metadata: + return + + timecode_source = self.section.get_editor_property("timecode_source") + + if "timecode" in metadata: + timecode_match = re.match( + r"^" + r"(?P\d{2}):" + r"(?P\d{2}):" + r"(?P\d{2})(?P[:;])" + r"(?P\d{2})" + r"$", + metadata["timecode"], + ) + if timecode_match: + timecode_obj = unreal.Timecode( + hours=int(timecode_match.group("h")), + minutes=int(timecode_match.group("m")), + seconds=int(timecode_match.group("s")), + frames=int(timecode_match.group("f")), + drop_frame_format=timecode_match.group("sep") == ";", + ) + timecode_source.set_editor_property("timecode", timecode_obj) + + # NOTE: METADATA_KEY_SUB_SEQ is omitted here since it should have + # already been applied by the calling code. + # NOTE: start_frame_offset and time_scale are omitted here since they + # will factor into a clip's source range and effects. + if "is_active" in metadata: + self.section.set_is_active(metadata["is_active"]) + if "is_locked" in metadata: + self.section.set_is_locked(metadata["is_locked"]) + if "pre_roll_frames" in metadata: + self.section.set_pre_roll_frames(metadata["pre_roll_frames"]) + if "post_roll_frames" in metadata: + self.section.set_post_roll_frames(metadata["post_roll_frames"]) + if "can_loop" in metadata: + self.section.parameters.can_loop = metadata["can_loop"] + if "end_frame_offset" in metadata: + self.section.parameters.end_frame_offset.value = metadata[ + "end_frame_offset" + ] + if "first_loop_start_frame_offset" in metadata: + self.section.parameters.first_loop_start_frame_offset.value = metadata[ + "first_loop_start_frame_offset" + ] + if "hierarchical_bias" in metadata: + self.section.parameters.hierarchical_bias = metadata["hierarchical_bias"] + if "network_mask" in metadata: + self.section.set_editor_property("network_mask", metadata["network_mask"]) diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/uclasses.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/uclasses.py new file mode 100644 index 0000000..5bb73d8 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/uclasses.py @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import os + +import opentimelineio as otio +import unreal + +from .adapter import import_otio, export_otio + + +@unreal.uclass() +class OTIOScriptedSequenceImportFactory(unreal.Factory): + """Adds support for importing OTIO supported file formats to create + or update level sequence hierarchies via UE import interfaces. + """ + + def _post_init(self, *args): + self.create_new = False + self.edit_after_new = True + self.supported_class = unreal.LevelSequence + self.editor_import = True + self.text = False + + # Register all supported timeline import adapters. A comma-separated + # list of suffixes can be defined by the environment, or all suffixes + # will be registered. + env_suffixes = os.getenv("OTIO_UE_IMPORT_SUFFIXES") + if env_suffixes is not None: + suffixes = env_suffixes.split(",") + else: + suffixes = otio.adapters.suffixes_with_defined_adapters(read=True) + for suffix in suffixes: + if suffix.startswith("otio"): + self.formats.append(suffix + ";OpenTimelineIO files") + else: + self.formats.append(suffix + ";OpenTimelineIO supported files") + + @unreal.ufunction(override=True) + def script_factory_can_import(self, filename): + suffixes = { + s.lower() for s in otio.adapters.suffixes_with_defined_adapters(read=True) + } + return unreal.Paths.get_extension(filename).lower() in suffixes + + @unreal.ufunction(override=True) + def script_factory_create_file(self, task): + # Ok to overwrite an existing level sequence? + if task.destination_path and task.destination_name: + asset_path = "{path}/{name}".format( + path=task.destination_path.replace("\\", "/").rstrip("/"), + name=task.destination_name, + ) + level_seq_data = unreal.EditorAssetLibrary.find_asset_data(asset_path) + if level_seq_data.is_valid() and not task.replace_existing: + return False + + level_seq, _ = import_otio( + task.filename, + destination_path=task.destination_path, + destination_name=task.destination_name, + ) + task.result.append(level_seq) + return True + + +@unreal.uclass() +class OTIOScriptedSequenceExporter(unreal.Exporter): + """Adds support for exporting OTIO files from level sequence + hierarchies via UE export interfaces. + """ + + def _post_init(self, *args): + # Register one supported timeline export adapter, which can be defined + # by the environment, or fallback to the default "otio" format. + suffix = os.getenv("OTIO_UE_EXPORT_SUFFIX", "otio") + + self.format_extension = [suffix] + self.format_description = ["OpenTimelineIO file"] + self.supported_class = unreal.LevelSequence + self.text = False + + @unreal.ufunction(override=True) + def script_run_asset_export_task(self, task): + # Ok to overwrite an existing timeline file? + if not task.replace_identical and unreal.Paths.file_exists(task.filename): + return False + + export_otio(task.filename, task.object) + return True diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal/util.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal/util.py new file mode 100644 index 0000000..c2af66a --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal/util.py @@ -0,0 +1,242 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import unreal +import opentimelineio as otio +from opentimelineio.opentime import TimeRange + + +# Critical metadata keys for this adapter to work with level sequences +METADATA_KEY_UE = "unreal" +METADATA_KEY_SUB_SEQ = "sub_sequence" + +# Mapping between OTIO marker colors and UE colors +MARKER_COLOR_MAP = { + otio.schema.MarkerColor.RED: unreal.LinearColor(1.0, 0.0, 0.0, 1.0), + otio.schema.MarkerColor.PINK: unreal.LinearColor(1.0, 0.5, 0.5, 1.0), + otio.schema.MarkerColor.ORANGE: unreal.LinearColor(1.0, 0.5, 0.0, 1.0), + otio.schema.MarkerColor.YELLOW: unreal.LinearColor(1.0, 1.0, 0.0, 1.0), + otio.schema.MarkerColor.GREEN: unreal.LinearColor(0.0, 1.0, 0.0, 1.0), + otio.schema.MarkerColor.CYAN: unreal.LinearColor(0.0, 1.0, 1.0, 1.0), + otio.schema.MarkerColor.BLUE: unreal.LinearColor(0.0, 0.0, 1.0, 1.0), + otio.schema.MarkerColor.PURPLE: unreal.LinearColor(0.5, 0.0, 1.0, 1.0), + otio.schema.MarkerColor.MAGENTA: unreal.LinearColor(1.0, 0.0, 1.0, 1.0), + otio.schema.MarkerColor.WHITE: unreal.LinearColor(1.0, 1.0, 1.0, 1.0), + otio.schema.MarkerColor.BLACK: unreal.LinearColor(0.0, 0.0, 0.0, 1.0), +} + + +def get_level_seq_references(timeline, level_seq=None): + """Evaluate timeline, returning all referenced level sequence asset + paths and the OTIO item they reference. + + The intent of this function is to give insight to timeline syncing + tools being implemented in Unreal Editor which utilize this plugin. + + Args: + timeline (otio.schema.Timeline): Timeline to evaluate + level_seq (unreal.LevelSequence, optional): Root level sequence + to import timeline into. If unspecified, the timeline's + "tracks" stack metadata will be checked for a sub-sequence + path, falling back to a sequence named after the timeline + file and located in "/Game/Sequences". + + Returns: + list[tuple[str, otio.schema.Item]]: List of asset paths and + OTIO items. + """ + if level_seq is not None: + root_level_seq_path = level_seq.get_path_name() + else: + root_level_seq_path = get_sub_sequence_path(timeline.tracks) + + level_seq_refs = [(root_level_seq_path, timeline)] + + for item in timeline.children_if(): + if not isinstance(item, (otio.schema.Stack, otio.schema.Clip)): + continue + + level_seq_path = get_sub_sequence_path(item) + if level_seq_path: + level_seq_refs.append((level_seq_path, item)) + + return level_seq_refs + + +def get_item_frame_ranges(item): + """Given an OTIO item, return its frame range in parent context and + its source frame range, as (inclusive start frame, exclusive end + frame) int tuples. + + Args: + item (otio.schema.Item): Item to get ranges for + + Returns: + tuple[tuple[int, int], tuple[int, int]]: Frame range pair + """ + global_start_time = None + + if isinstance(item, otio.schema.Timeline): + global_start_time = item.global_start_time + item = item.tracks + + # Source range + source_time_range = item.trimmed_range() + source_frame_range = ( + source_time_range.start_time.to_frames(), + source_time_range.end_time_exclusive().to_frames(), + ) + + # Range in parent + time_range_in_parent = None + + if global_start_time is not None: + # Offset start time with timeline global start time for root "tracks" stack + time_range_in_parent = TimeRange( + start_time=source_time_range.start_time + global_start_time, + duration=source_time_range.duration + ) + elif item.parent(): + time_range_in_parent = item.range_in_parent() + + if time_range_in_parent is not None: + frame_range_in_parent = ( + time_range_in_parent.start_time.to_frames(), + time_range_in_parent.end_time_exclusive().to_frames(), + ) + else: + frame_range_in_parent = source_frame_range + + return frame_range_in_parent, source_frame_range + + +def get_sub_sequence_path(item): + """Try to get a sub-sequence path reference from OTIO item + metadata. + + Args: + item (otio.schema.Item): Item to search metadata + + Returns: + str|None: Sub-sequence path if found, or None + """ + try: + return str(item.metadata[METADATA_KEY_UE][METADATA_KEY_SUB_SEQ]) + except KeyError: + return None + + +def set_sub_sequence_path(item, level_seq_path): + """Set sub-sequence path reference in OTIO item metadata. + + Args: + item (otio.schema.Item): Item to set metadata + level_seq_path (str): Level sequence path to reference + """ + if METADATA_KEY_UE not in item.metadata: + item.metadata[METADATA_KEY_UE] = {} + item.metadata[METADATA_KEY_UE][METADATA_KEY_SUB_SEQ] = level_seq_path + + +def get_root_level_seq_path( + filepath, timeline, destination_path=None, destination_name=None +): + """Determine the root level sequence path to sync. This is the + parent to the level sequence hierarchy and maps to the OTIO + timeline's root "tracks" stack. + + Args: + See ``import_otio`` documentation + + Returns: + str: Level sequence asset path + """ + level_seq_path = get_sub_sequence_path(timeline.tracks) + + if not level_seq_path or not str(level_seq_path).startswith("/Game/"): + name = destination_name or unreal.Paths.get_base_filename(filepath) + if destination_path is not None and destination_path.startswith("/Game/"): + level_seq_path = destination_path.replace("\\", "/").rstrip( + "/" + ) + "/{name}.{name}".format(name=name) + else: + # Fallback import location + level_seq_path = "/Game/Sequences/{name}.{name}".format(name=name) + + return level_seq_path + + +def load_or_create_level_seq(level_seq_path): + """Load level sequence, creating it first if it doesn't exist. + + Args: + level_seq_path (str): Level sequence asset path + + Returns: + unreal.LevelSequence: Loaded level sequence + """ + level_seq_data = unreal.EditorAssetLibrary.find_asset_data(level_seq_path) + if level_seq_data.is_valid(): + level_seq = level_seq_data.get_asset() + else: + package_path, asset_name = level_seq_path.rsplit("/", 1) + asset_name = asset_name.split(".", 1)[0] + + asset_tools = unreal.AssetToolsHelpers.get_asset_tools() + level_seq = asset_tools.create_asset( + asset_name, + package_path, + unreal.LevelSequence, + unreal.LevelSequenceFactoryNew(), + ) + + return level_seq + + +def get_nearest_marker_color(target_color): + """Given a linear Unreal target color, find the nearest OTIO marker + color constant. + + Args: + target_color (unreal.LinearColor): Floating-point linear color + + Returns: + otio.schema.MarkerColor: Color constant + """ + target_h, target_s = target_color.rgb_into_hsv_components()[:2] + + # Desaturated colors map to black or white + if target_s < 0.25: + if target_color.get_luminance() < 0.18: + return otio.schema.MarkerColor.BLACK + else: + return otio.schema.MarkerColor.WHITE + + # Find saturated constant with the nearest hue + else: + # UE default marked frame color + nearest_h = 180 + nearest_marker_color = otio.schema.MarkerColor.CYAN + + for marker_color, candidate_color in MARKER_COLOR_MAP.items(): + if marker_color in ( + otio.schema.MarkerColor.BLACK, + otio.schema.MarkerColor.WHITE, + ): + continue + h = candidate_color.rgb_into_hsv_components()[0] + if h in (0.0, 360.0): + # Wrap red hue comparison + hues = [0.0, 360.0] + else: + hues = [h] + for h in hues: + if abs(h - target_h) < abs(nearest_h - target_h): + nearest_h = h + nearest_marker_color = marker_color + + # Red and pink have the same hue, so choose the nearest saturation + if nearest_marker_color == otio.schema.MarkerColor.RED and target_s < 0.75: + nearest_marker_color = otio.schema.MarkerColor.PINK + + return nearest_marker_color diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/__init__.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/__init__.py new file mode 100644 index 0000000..1420fc9 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +from . import install diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/dialog.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/dialog.py new file mode 100644 index 0000000..69c33af --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/dialog.py @@ -0,0 +1,635 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import logging +import os +import traceback + +import unreal +import opentimelineio as otio +from opentimelineview import timeline_widget +from PySide2 import QtCore, QtGui, QtWidgets + +from otio_unreal import ( + export_otio, + import_otio, + get_item_frame_ranges, + get_level_seq_references, +) + +from .filesystem_path_edit import ( + FileSystemPathEdit, + FileSystemPathDialogType, + FileSystemPathType, +) +from .icons import get_icon_path + + +logger = logging.getLogger(__name__) + + +class TimelineSyncMode(object): + """ + Enum of supported timeline sync modes. + """ + + IMPORT = "import" + EXPORT = "export" + + +class BaseTreeItem(QtWidgets.QTreeWidgetItem): + """ + Tree item with custom sort implementation and builtin global icon + caching. + """ + + TYPE = QtWidgets.QTreeWidgetItem.UserType + ICON_KEY = None + + _icon_cache = {} + + def __init__(self, parent, values): + super(BaseTreeItem, self).__init__(parent, values, type=self.TYPE) + + if self.ICON_KEY is not None: + icon = self._get_icon(self.ICON_KEY) + if icon is not None: + self.setIcon(0, icon) + + def __lt__(self, other): + # Re-implement default column sorting + tree = self.treeWidget() + if tree is not None: + sort_col = tree.sortColumn() + return self.text(sort_col) < other.text(sort_col) + + return False + + def _get_icon(self, icon_key): + """ + Load and cache an icon. Subsequent requests for the icon are + pulled from the cache, so that each icon is loaded at most + once. + + Args: + icon_key (str): Icon key + + Returns: + QtGui.QIcon: Loaded icon or None + """ + icon = self._icon_cache.get(icon_key) + if icon is None: + icon_path = get_icon_path(icon_key) + if icon_path is not None: + icon = QtGui.QIcon(icon_path) + self._icon_cache[icon_key] = icon + return icon + + +class FolderItem(BaseTreeItem): + """ + Tree item representing an Unreal Editor content browser folder. + """ + + TYPE = BaseTreeItem.TYPE + 1 + ICON_KEY = "folder-open" + + def __init__(self, parent, name): + """ + Args: + name (str): Folder name + """ + super(FolderItem, self).__init__(parent, [name, "", "", ""]) + + def update_icon(self): + """ + Call on item expand or collapse to update the folder icon (open + or closed folder) + """ + if self.isExpanded(): + icon_key = "folder-open" + else: + icon_key = "folder-closed" + + icon = self._get_icon(icon_key) + if icon is not None: + self.setIcon(0, icon) + + def __lt__(self, other): + # Always sort folders after level sequences + if other.type() == LevelSeqItem.TYPE: + return False + + return super(FolderItem, self).__lt__(other) + + +class LevelSeqItem(BaseTreeItem): + """ + Tree item representing an Unreal Engine level sequence. + """ + + TYPE = BaseTreeItem.TYPE + 2 + ICON_KEY = "level-sequence-actor" + + def __init__(self, parent, package_name, asset_path, otio_item, mode): + """ + Args: + package_name (str): Level sequence package name + asset_path (str): Level sequence asset path + otio_item (otio.schema.Item): Item associated with the + level sequence. + mode (str): Timeline sync mode + """ + # Get anticipated frame ranges from timeline + range_in_parent, source_range = get_item_frame_ranges(otio_item) + values = [ + package_name, + "", + "{:d}-{:d}".format(range_in_parent[0], range_in_parent[1]), + "{:d}-{:d}".format(source_range[0], source_range[1]), + ] + + super(LevelSeqItem, self).__init__(parent, values) + + # Choose icon based on sync mode and asset status + if mode == TimelineSyncMode.IMPORT: + self.asset_data = unreal.EditorAssetLibrary.find_asset_data(asset_path) + if self.asset_data.is_valid(): + icon_key = "edit" + else: + icon_key = "plus" + else: + icon_key = "export" + + icon = self._get_icon(icon_key) + if icon is not None: + self.setIcon(1, icon) + + self.otio_item = otio_item + + def __lt__(self, other): + # Always sort folders after level sequences + if other.type() == FolderItem.TYPE: + return True + + return super(LevelSeqItem, self).__lt__(other) + + +class TimelineDialog(QtWidgets.QWidget): + """ + Base dialog which facilitates syncing between an Unreal level + sequence hierarchy and an OTIO timeline, with a preview of the + impact of that operation before running it. + """ + + _ICON_WIDTH = 16 + + def __init__(self, mode, parent=None): + """ + Args: + mode (str): Timeline sync mode + """ + super(TimelineDialog, self).__init__(parent) + + self.setWindowFlags(self.windowFlags() | QtCore.Qt.Dialog) + self.setWindowTitle("OTIO " + mode.title()) + + self._mode = mode + self._parent = parent + self._prev_timeline_path = None + + # Widgets + self.timeline_file_edit = FileSystemPathEdit( + FileSystemPathType.FILE, + validate_path=False, + path_desc="Timeline file", + dialog_type=FileSystemPathDialogType.SAVE + if self._mode == TimelineSyncMode.EXPORT + else FileSystemPathDialogType.LOAD, + ) + if self._mode == TimelineSyncMode.EXPORT: + self.timeline_file_edit.setToolTip("Choose a OTIO file to export to.") + else: # IMPORT + self.timeline_file_edit.setToolTip("Choose a OTIO file to update from.") + self.timeline_file_edit.textChanged.connect( + self._on_timeline_source_changed + ) + + self.root_level_seq_label = QtWidgets.QLineEdit() + self.root_level_seq_label.setEnabled(False) + + self.timeline_tree = QtWidgets.QTreeWidget() + self.timeline_tree.setToolTip( + "Hierarchy of level sequences and their frame ranges which will\n" + "be created or updated by this tool." + ) + self.timeline_tree.setHeaderLabels( + ["Level Sequence", "Status", "Range in Parent", "Source Range"] + ) + self.timeline_tree.setSelectionMode(QtWidgets.QTreeWidget.SingleSelection) + self.timeline_tree.setSelectionBehavior(QtWidgets.QTreeWidget.SelectRows) + self.timeline_tree.setUniformRowHeights(True) + self.timeline_tree.setIconSize(QtCore.QSize(self._ICON_WIDTH, self._ICON_WIDTH)) + self.timeline_tree.itemExpanded.connect(self._on_timeline_tree_item_expanded) + self.timeline_tree.itemCollapsed.connect(self._on_timeline_tree_item_expanded) + self.timeline_tree.itemSelectionChanged.connect( + self._on_timeline_tree_selection_changed + ) + + # otioview component + self.timeline_view = timeline_widget.Timeline() + self.timeline_view.selection_changed.connect( + self._on_timeline_view_selection_changed + ) + + self.button_box = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.close) + + # Layout + option_layout = QtWidgets.QFormLayout() + option_layout.setLabelAlignment(QtCore.Qt.AlignRight) + option_layout.addRow("Timeline File", self.timeline_file_edit) + if self._mode == TimelineSyncMode.EXPORT: + option_layout.addRow("Root Level Sequence", self.root_level_seq_label) + + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(self.button_box) + + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical) + self.splitter.addWidget(self.timeline_tree) + self.splitter.addWidget(self.timeline_view) + + layout = QtWidgets.QVBoxLayout() + layout.addLayout(option_layout) + layout.addWidget(self.splitter) + layout.addLayout(button_layout) + + self.setLayout(layout) + + # Initialize + self.resize(500, 600) + + def showEvent(self, event): + super(TimelineDialog, self).showEvent(event) + + level_seq = self.get_current_level_seq() + if level_seq is not None: + # Set default timeline path + saved_dir = os.path.realpath(unreal.Paths.project_saved_dir()) + otio_path = os.path.join(saved_dir, str(level_seq.get_name()) + ".otio") + self.timeline_file_edit.setText(otio_path) + + # Load after showing dialog for auto-sizing + if self._mode == TimelineSyncMode.EXPORT: + self._on_timeline_source_changed() + + def accept(self): + """ + Validate and execute import or export function. + """ + # Do we have a timeline file? + timeline_path = self.timeline_file_edit.text() + if not timeline_path: + self._show_error_message("Please specify a timeline file.") + return + + # If importing timeline, does the file exist? + if self._mode == TimelineSyncMode.IMPORT and not os.path.exists(timeline_path): + self._show_error_message( + "'{}' is not a valid timeline file.".format(timeline_path) + ) + return + + # Is there an open level sequence to export or import? + level_seq = self.get_current_level_seq() + if level_seq is None: + self._show_error_message("No level sequence is loaded to " + self._mode) + self.close() + return + + # Run export/import + try: + if self._mode == TimelineSyncMode.EXPORT: + export_otio(timeline_path, level_seq) + else: # IMPORT + _, timeline = import_otio( + timeline_path, + level_seq=level_seq, + undo_action_text="OTIO Import", + ) + except Exception as exc: + traceback.print_exc() + err = "OTIO {} failed with error: {}".format(self._mode, str(exc)) + logger.error(err) + self._show_error_message(err) + else: + self.close() + if self._mode == TimelineSyncMode.IMPORT: + # Open root level sequence on success + unreal.AssetEditorSubsystem().open_editor_for_assets([level_seq]) + self._show_success_message( + "OTIO {} completed successfully.".format(self._mode) + ) + + @staticmethod + def get_current_level_seq(): + """ + Returns: + unreal.LevelSequence|None: Current level sequence, if + loaded. + """ + level_seq_lib = unreal.LevelSequenceEditorBlueprintLibrary + return level_seq_lib.get_current_level_sequence() + + def _show_message(self, title, msg, icon, parent=None): + """ + Show a QMessageBox wrapped by a Slate window. + + Args: + title (str): Window title + msg (str): Message text + icon (QtGui.QIcon): Window alert icon + parent (QtWidgets.QWidget, optional) Parent window + """ + msg_box = QtWidgets.QMessageBox( + icon, title, msg, QtWidgets.QMessageBox.Ok, parent or self + ) + unreal.parent_external_window_to_slate(msg_box.winId()) + msg_box.show() + + def _show_error_message(self, msg): + """ + Show an error message dialog. + """ + self._show_message("Error", msg, QtWidgets.QMessageBox.Critical) + + def _show_success_message(self, msg): + """ + Show a success message dialog. + """ + self._show_message( + "Success", + msg, + QtWidgets.QMessageBox.Information, + # Parent message to this widget's parent, since this widget will + # close on success. + parent=self._parent, + ) + + def _on_timeline_source_changed(self, *args, **kwargs): + """ + Slot connected to any widget signal which indicates a change to + the OTIO timeline being synced. + """ + timeline_path = self.timeline_file_edit.text() + + level_seq = self.get_current_level_seq() + if level_seq is None: + return + + if self._mode == TimelineSyncMode.EXPORT: + self.root_level_seq_label.setText( + level_seq.get_path_name().rsplit(".", 1)[0] + ) + + # Load processed timeline without exporting + timeline = export_otio(timeline_path, level_seq, dry_run=True) + + else: # IMPORT + # Does timeline exist? + if not os.path.exists(timeline_path): + return + + # Timeline already loaded? + if timeline_path == self._prev_timeline_path: + return None + + # Load processed timeline without syncing + _, timeline = import_otio(timeline_path, level_seq=level_seq, dry_run=True) + + # Update timeline tree + self.timeline_tree.clear() + + processed_path_items = {} + + def _add_asset_path( + parent_item, processed_path, remaining_path_, asset_path_, otio_item_ + ): + """ + Recursive and incremental folder hierarchy tree item + creation. + + Args: + parent_item (QtWidgets.QTreeWidgetItem): Parent tree + item. + processed_path (str): Path tokens processed so far + remaining_path_ (str): Path tokens remaining to be + processed. + asset_path_ (str): Level sequence asset path + (equivalent to processed_path + remaining_path_). + otio_item_ (otio.schema.Item): Associated OTIO item + """ + if "/" in remaining_path_: + part, remaining_path_ = remaining_path_.split("/", 1) + processed_path = "/".join([processed_path, part]) + + # Cache folder items, which may be needed by multiple level + # sequences. + if processed_path in processed_path_items: + item_ = processed_path_items[processed_path] + else: + item_ = FolderItem(parent_item, part) + processed_path_items[processed_path] = item_ + + # Increment to next directory level + _add_asset_path( + item_, processed_path, remaining_path_, asset_path_, otio_item_ + ) + + elif remaining_path_: + # Add level sequence leaf item + LevelSeqItem( + parent_item, remaining_path_, asset_path_, otio_item_, self._mode + ) + + # Build folder structure starting from a common prefix. Only show asset package + # names, with the asset name stripped, since that's what users generally see in + # Editor. + level_seq_refs = sorted( + get_level_seq_references(timeline, level_seq=level_seq), key=lambda d: d[0] + ) + asset_paths = list(filter(None, [t[0] for t in level_seq_refs])) + + if asset_paths: + # Get common prefix of all asset paths, which will be used as a top-level + # tree item to reduce folder items to only those needed to organize level + # sequences. + common_prefix = os.path.commonprefix(asset_paths) + root_item = FolderItem(self.timeline_tree, common_prefix.rstrip("/")) + + for asset_path, otio_item in level_seq_refs: + # Strip common prefix + remaining_path = asset_path[len(common_prefix) :] + # Strip asset name + remaining_path = remaining_path.rsplit(".", 1)[0] + + # Build folder/level sequence hierarchy from path tokens + # following common prefix. + _add_asset_path( + root_item, common_prefix, remaining_path, asset_path, otio_item + ) + + # Update timeline view + self.timeline_view.set_timeline(timeline) + + if self._mode == TimelineSyncMode.IMPORT: + # Store loaded timeline to prevent reloading it from repeat signals. + self._prev_timeline_path = timeline_path + + # Resize widgets for best fit + col_count = self.timeline_tree.columnCount() + fm = self.timeline_tree.fontMetrics() + max_col_width = [0] * col_count + max_depth = 0 + indentation = self.timeline_tree.indentation() + header_item = self.timeline_tree.headerItem() + + self.timeline_tree.expandAll() + self.timeline_tree.sortByColumn(0, QtCore.Qt.AscendingOrder) + + for i in range(col_count): + self.timeline_tree.resizeColumnToContents(i) + max_col_width[i] = fm.horizontalAdvance(header_item.text(i)) + + it = QtWidgets.QTreeWidgetItemIterator(self.timeline_tree) + while it.value(): + item = it.value() + + # Get item depth + item_depth = 0 + item_parent = item.parent() + while item_parent is not None: + item_parent = item_parent.parent() + item_depth += 1 + if item_depth > max_depth: + max_depth = item_depth + + # Get item width per column + for i in range(col_count): + text_width = fm.horizontalAdvance(item.text(i)) + if text_width > max_col_width[i]: + max_col_width[i] = text_width + it += 1 + + row_width = ( + (indentation * max_depth) + self._ICON_WIDTH + sum(max_col_width) + 100 + ) + if self.width() < row_width: + self.resize(row_width, self.height()) + + sizes = self.splitter.sizes() + total_size = sum(sizes) + timeline_size = int(total_size * 0.35) + self.splitter.setSizes([total_size - timeline_size, timeline_size]) + + @QtCore.Slot(QtWidgets.QTreeWidgetItem) + def _on_timeline_tree_item_expanded(self, item): + """ + Update folder tree item icon. + """ + if isinstance(item, FolderItem): + item.update_icon() + + @QtCore.Slot() + def _on_timeline_tree_selection_changed(self): + """ + Sync timeline view (otioview) item selection to level sequence + tree selection. + """ + # Prevent infinite recursion + self.timeline_view.blockSignals(True) + + selected = self.timeline_tree.selectedItems() + if not selected: + return + + item = selected[0] + if isinstance(item, LevelSeqItem): + otio_item = item.otio_item + if isinstance(otio_item, otio.schema.Timeline): + self.timeline_view.add_stack(otio_item.tracks) + else: + # Find nearest parent Stack of item, which will indicate which + # stack (tab) needs to be viewed in the timeline view to see + # item. + parent_otio_item = otio_item.parent() + while parent_otio_item and not isinstance( + parent_otio_item, otio.schema.Stack + ): + parent_otio_item = parent_otio_item.parent() + + if not parent_otio_item and self.timeline_view.timeline: + self.timeline_view.add_stack(self.timeline_view.timeline.tracks) + elif isinstance(parent_otio_item, otio.schema.Stack): + self.timeline_view.add_stack(parent_otio_item) + + # Search QGraphicsScene for item. Make it the exclusive selection and + # scroll so that it's visible in timeline view. + comp_view = self.timeline_view.currentWidget() + if comp_view: + comp_scene = comp_view.scene() + for comp_item in comp_scene.items(): + if hasattr(comp_item, "item") and comp_item.item == otio_item: + comp_scene.clearSelection() + comp_item.setSelected(True) + comp_view.ensureVisible(comp_item) + break + + self.timeline_view.blockSignals(False) + + def _on_timeline_view_selection_changed(self, otio_item): + """ + Sync level sequence tree selection to timeline view (otioview) + item selection. + """ + # Prevent infinite recursion + self.timeline_tree.blockSignals(True) + + # Search tree for level sequence item associated with selected OTIO item. Make + # it the exclusive selection and scroll so that it's visible in tree view. + it = QtWidgets.QTreeWidgetItemIterator(self.timeline_tree) + while it.value(): + item = it.value() + if isinstance(item, LevelSeqItem): + if item.otio_item == otio_item: + self.timeline_tree.selectionModel().clear() + item.setSelected(True) + self.timeline_tree.scrollToItem(item) + break + it += 1 + + self.timeline_tree.blockSignals(False) + + +def show_dialog(mode, parent=None): + """Show cinematic timeline sync dialog""" + # Only open dialog if a level sequence is loaded + level_seq = TimelineDialog.get_current_level_seq() + if level_seq is None: + unreal.EditorDialog.show_message( + unreal.Text("Error"), + unreal.Text("Please load a level sequence for OTIO {}.".format(mode)), + unreal.AppMsgType.OK, + unreal.AppReturnType.OK, + ) + return + + dialog = TimelineDialog(mode, parent=parent) + unreal.parent_external_window_to_slate(dialog.winId()) + dialog.show() + + return dialog diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/filesystem_path_edit.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/filesystem_path_edit.py new file mode 100644 index 0000000..45356bf --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/filesystem_path_edit.py @@ -0,0 +1,214 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import os + +from PySide2 import QtCore, QtGui, QtWidgets + +from .icons import get_icon_path + + +class FileSystemPathDialogType(object): + """ + Enum of filesystem path dialog types, which are used to determine + the ``QtWidgets.QFileDialog`` constructor and related behaviors. + """ + + LOAD = "load" + SAVE = "save" + + ALL = (LOAD, SAVE) + + +class FileSystemPathType(object): + """ + Enum of filesystem path types, which drive ``FileSystemPathEdit`` + behavior. + """ + + FILE = "file" + DIRECTORY = "directory" + + ALL = (FILE, DIRECTORY) + + +class FileSystemPathEdit(QtWidgets.QLineEdit): + """ + Line edit for filesystem paths with builtin path existence + validation and a browse button. + """ + + def __init__( + self, + path_type, + validate_path=False, + path_filter=None, + path_desc=None, + dialog_type=None, + parent=None, + ): + """ + :param str path_type: Type of path + :param bool validate_path: Optionally validate whether path + should exist. If True, an invalid path will trigger an + error state on the widget. + :param str path_filter: ``QDialog``-supported file type filter + :param str path_desc: Optional path description, which shows up + in placeholder text and child ``FileDialog`` instances. + :param enum.Enum dialog_type: An optional enum specifying which + type of dialog to construct. Default: + ``FileSystemPathDialogType.LOAD`` + :param QtCore.QObject parent: Optional parent object + """ + super(FileSystemPathEdit, self).__init__(parent) + self.setMouseTracking(True) + + all_path_types = FileSystemPathType.ALL + if path_type not in all_path_types: + raise ValueError( + "Invalid path type '{path_type}'. Supported path types are: " + "{supported}".format( + path_type=path_type, supported=", ".join(map(str, all_path_types)) + ) + ) + + self._dialog_type = dialog_type or FileSystemPathDialogType.LOAD + self._path_type = path_type + self._path_filter = path_filter or "" + self._validate_path = validate_path + self._error_msg = None + + # Set through property to trigger side-effects + self._path_desc = None + self.path_desc = path_desc or self._path_type + + # Path type icon + if self._path_type == FileSystemPathType.FILE: + if self._dialog_type == FileSystemPathDialogType.SAVE: + browse_icon_key = "save" + else: + browse_icon_key = "file" + else: + browse_icon_key = "folder-closed" + + # Browse button + self._browse_action = QtWidgets.QAction() + + browse_icon_path = get_icon_path(browse_icon_key) + if browse_icon_path is not None: + browse_icon = QtGui.QIcon(browse_icon_path) + self._browse_action.setIcon(browse_icon) + + self.addAction(self._browse_action, QtWidgets.QLineEdit.TrailingPosition) + + # Signals/slots + self._browse_action.triggered.connect(self._on_browse_action_triggered) + self.textChanged.connect(self._on_text_changed) + + @property + def path_type(self): + return self._path_type + + @property + def error_msg(self): + return self._error_msg or "" + + @property + def validate_path(self): + return self._validate_path + + @validate_path.setter + def validate_path(self, validate_path): + self._validate_path = validate_path + self._on_text_changed(self.text()) + + @property + def path_filter(self): + return self._path_filter + + @path_filter.setter + def path_filter(self, path_filter): + self._path_filter = path_filter + + @property + def path_desc(self): + return self._path_desc + + @path_desc.setter + def path_desc(self, path_desc): + self._path_desc = path_desc + if self._path_desc is not None: + self.setPlaceholderText(self._path_desc) + + def has_error(self): + """ + If path existence checking is enabled, check if current path + has triggered an error. + + :return: Whether path has an error + :rtype: bool + """ + return self._error_msg is not None + + @QtCore.Slot() + def _on_text_changed(self, path): + """ + Validate path during edit. + """ + self._error_msg = None + + if not path or not self._validate_path: + # No validation needed + pass + elif not os.path.exists(path): + self._error_msg = "Path '{path}' does not exist".format(path=path) + elif ( + self._path_type == FileSystemPathType.FILE and not os.path.isfile(path) + ) or ( + self._path_type == FileSystemPathType.DIRECTORY and not os.path.isdir(path) + ): + self._error_msg = "Path '{path}' is not a valid {type}".format( + path=path, type=self._path_type + ) + + if self._error_msg is not None: + self.setStyleSheet( + "QLineEdit {{ color: {hex}; }}".format( + hex=QtGui.QColor(QtCore.Qt.red).lighter(125).name() + ) + ) + self.setToolTip(self._error_msg) + else: + self.setStyleSheet("") + self.setToolTip(self._path_desc) + + @QtCore.Slot() + def _on_browse_action_triggered(self): + """ + Browse button clicked. + """ + current_path = self.text() + caption = "Choose {path_desc}".format(path_desc=self._path_desc) + + if self._path_type == FileSystemPathType.FILE: + if self._dialog_type == FileSystemPathDialogType.SAVE: + new_path, sel_filter = QtWidgets.QFileDialog.getSaveFileName( + self, caption, os.path.dirname(current_path), self._path_filter + ) + else: + if self._validate_path: + default_path = current_path + else: + default_path = os.path.dirname(current_path) + + new_path, sel_filter = QtWidgets.QFileDialog.getOpenFileName( + self, caption, default_path, self._path_filter + ) + + else: # DIRECTORY + new_path = QtWidgets.QFileDialog.getExistingDirectory( + self, caption, current_path + ) + + if new_path and new_path != current_path: + self.setText(new_path) diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/icons.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/icons.py new file mode 100644 index 0000000..b4d8ec3 --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/icons.py @@ -0,0 +1,111 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import glob +import json +import os +import tempfile + +import unreal + + +class IconCache(object): + """Icons referenced by otio_unreal_actions are standard UE Slate icons, + which are found in the current Unreal Editor build and their paths + are cached for reuse by future editor sessions. This class manages + that cache in-memory and on-disk. + """ + + # All needed Slate icons are included in the Engine/Content directory + ICON_SEARCH_DIR = os.path.abspath(unreal.Paths.engine_content_dir()) + + # Icon paths are cached to a file for future reference + CACHE_FILE_PATH = os.path.join( + tempfile.gettempdir(), "otio_unreal_actions_icons.json" + ) + + # In-memory cache data + icons = { + "edit": ["edit.svg", None], + "export": ["Export.svg", None], + "file": ["file.svg", None], + "folder-closed": ["folder-closed.svg", None], + "folder-open": ["folder-open.svg", None], + "level-sequence-actor": ["LevelSequenceActor_16.svg", None], + "plus": ["plus.svg", None], + "save": ["save.svg", None], + } + + # Has cache been loaded? + loaded = False + + @classmethod + def load(cls): + """Load icon paths from cache file or search for them in the UE + content directory. + """ + if cls.loaded: + return + + # Load previously cached icon paths + if os.path.exists(cls.CACHE_FILE_PATH): + with open(cls.CACHE_FILE_PATH, "r") as json_file: + data = json.load(json_file) + + for key, path in data.items(): + if ( + key in cls.icons + and cls.icons[key][0] == os.path.basename(path) + and os.path.exists(path) + ): + cls.icons[key][1] = path.replace("\\", "/") + + # Search for un-cached icon paths and cache them + for key in cls.icons: + if cls.icons[key][1] is None: + icon_paths = glob.glob( + os.path.join(cls.ICON_SEARCH_DIR, "**", cls.icons[key][0]), + recursive=True, + ) + if icon_paths: + cls.icons[key][1] = icon_paths[0].replace("\\", "/") + + # Update cache file for future reference + cls.save() + + # Don't load again this session + cls.loaded = True + + @classmethod + def save(cls): + """Save in-memory icon cache to disk, preventing the need to + search for icons in future sessions. + """ + data = {} + for key in cls.icons: + if cls.icons[key][1] is not None and os.path.exists(cls.icons[key][1]): + data[key] = cls.icons[key][1] + + with open(cls.CACHE_FILE_PATH, "w") as json_file: + json.dump(data, json_file, indent=4) + + +def get_icon_path(key): + """ + Get a cached icon path by key. + + Args: + key (str): Icon key + + Returns: + str|None: Icon path, or None if key is unknown or the icon + could not be found. + """ + # Cache icons on first call + if not IconCache.loaded: + IconCache.load() + + if key in IconCache.icons: + return IconCache.icons[key][1] + else: + return None diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/install.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/install.py new file mode 100644 index 0000000..bd05fff --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/install.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import unreal + +from .dialog import TimelineSyncMode, show_dialog +from .menu_helper import UnrealMenuHelper +from .qt_helper import UnrealQtHelper + + +SECTION_NAME = "OpenTimelineIO" + + +# Start QApplication in Unreal Editor +UnrealQtHelper.ensure_qapp_available() + +# Add tool actions to Unreal Editor menus +tool_menus = unreal.ToolMenus.get() + +for menu_name in ( + "LevelEditor.LevelEditorToolBar.Cinematics", + "ContentBrowser.AssetContextMenu.{cls}".format(cls=unreal.LevelSequence.__name__), +): + menu = tool_menus.extend_menu(unreal.Name(menu_name)) + menu.add_section(unreal.Name(SECTION_NAME), unreal.Text(SECTION_NAME)) + + UnrealMenuHelper.add_menu_entry( + menu, + SECTION_NAME, + lambda: show_dialog( + TimelineSyncMode.EXPORT, parent=UnrealQtHelper.get_parent_widget() + ), + "export_otio_dialog", + entry_label="Export Sequence to OTIO...", + ) + UnrealMenuHelper.add_menu_entry( + menu, + SECTION_NAME, + lambda: show_dialog( + TimelineSyncMode.IMPORT, parent=UnrealQtHelper.get_parent_widget() + ), + "import_otio_dialog", + entry_label="Update Sequence from OTIO...", + ) diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/menu_helper.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/menu_helper.py new file mode 100644 index 0000000..32c92fe --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/menu_helper.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import unreal + + +@unreal.uclass() +class PythonMenuEntry(unreal.ToolMenuEntryScript): + """ToolMenuEntryScript implementation which executes a Python + callback with supplied arguments. + """ + + def __init__(self, callback, args=None, kwargs=None): + """ + Args: + callback (callable): Python callable to execute. + args (tuple, optional): Tuple of callback positional + arguments. + kwargs (dict, optional): Dictionary of callback keyword + arguments. + """ + super(PythonMenuEntry, self).__init__() + + self._callback = callback + self._args = args or () + self._kwargs = kwargs or {} + + @unreal.ufunction(override=True) + def execute(self, context): + self._callback(*self._args, **self._kwargs) + + @unreal.ufunction(override=True) + def can_execute(self, context): + return True + + +class UnrealMenuHelper(object): + """Static helper class which manages custom Unreal Editor menus and + script entries. + """ + + # Keep track of all the menu entries that have been registered to UE. + # Without keeping these around, the Unreal GC will remove the menu objects + # and break the in-engine menu. + _menu_entries = [] + + @classmethod + def add_menu_entry( + cls, + menu, + section_name, + callback, + entry_name, + entry_label=None, + entry_tooltip="", + ): + """Add Python script entry to a UE menu. + + Args: + menu (unreal.ToolMenu): Menu to add the entry to. + section_name (str): Section to add the entry to. + callback (callable): Python callable to execute when the + entry is executed. + entry_name (str): Menu entry name. + entry_label (str, optional): Menu entry label. + entry_tooltip (str, optional): Menu entry tooltip. + """ + tool_menus = unreal.ToolMenus.get() + + # Create a register script entry + menu_entry = PythonMenuEntry(callback) + menu_entry.init_entry( + unreal.Name("otio_unreal_actions"), + unreal.Name( + "{parent_menu}.{menu}.{entry}".format( + parent_menu=menu.menu_parent, menu=menu.menu_name, entry=entry_name + ) + ), + unreal.Name(section_name), + unreal.Name(entry_name), + label=unreal.Text(entry_label or entry_name), + tool_tip=unreal.Text(entry_tooltip), + ) + + # Store entry reference prior to adding it to the menu + cls._menu_entries.append(menu_entry) + + menu.add_menu_entry_object(menu_entry) + + # Tell engine to refresh all menus + tool_menus.refresh_all_widgets() diff --git a/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/qt_helper.py b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/qt_helper.py new file mode 100644 index 0000000..3847c7f --- /dev/null +++ b/OpenTimelineIOUtilities/Content/Python/otio_unreal_actions/qt_helper.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +import sys + +import unreal +from PySide2 import QtCore, QtGui, QtWidgets + + +SLATE_PARENT_OBJECT_NAME = "qapp_slate_parent" +PACKAGE_TAG = "qapp_created_by_otio_unreal_actions" + + +class UnrealPalette(QtGui.QPalette): + """QPalette configured to closely match the UE5 Slate color + palette. + """ + + def __init__(self, palette=None): + """ + Args: + palette (QtGui.QPalette): Existing palette to inherit + from. + """ + super(UnrealPalette, self).__init__(palette=palette) + + self.setColor(QtGui.QPalette.Window, QtGui.QColor(47, 47, 47)) + self.setColor(QtGui.QPalette.WindowText, QtGui.QColor(192, 192, 192)) + self.setColor(QtGui.QPalette.Base, QtGui.QColor(26, 26, 26)) + self.setColor(QtGui.QPalette.AlternateBase, QtGui.QColor(25, 25, 25)) + self.setColor(QtGui.QPalette.Foreground, QtGui.QColor(192, 192, 192)) + self.setColor(QtGui.QPalette.ToolTipBase, QtGui.QColor(255, 255, 255)) + self.setColor(QtGui.QPalette.ToolTipText, QtGui.QColor(255, 255, 255)) + self.setColor(QtGui.QPalette.Text, QtGui.QColor(192, 192, 192)) + self.setColor(QtGui.QPalette.Button, QtGui.QColor(47, 47, 47)) + self.setColor(QtGui.QPalette.ButtonText, QtGui.QColor(192, 192, 192)) + self.setColor(QtGui.QPalette.BrightText, QtGui.QColor(255, 255, 255)) + self.setColor(QtGui.QPalette.Link, QtGui.QColor(186, 186, 186)) + self.setColor(QtGui.QPalette.Highlight, QtGui.QColor(64, 87, 111)) + self.setColor(QtGui.QPalette.HighlightedText, QtGui.QColor(255, 255, 255)) + + self.setColor( + QtGui.QPalette.Disabled, + QtGui.QPalette.Text, + QtGui.QColor(110, 110, 110), + ) + self.setColor( + QtGui.QPalette.Disabled, + QtGui.QPalette.ButtonText, + QtGui.QColor(140, 140, 140), + ) + + +class UnrealQtHelper(object): + """Static helper class which manages a QApplication instance in + the Unreal Editor. + """ + + _qapp = None + + @classmethod + def ensure_qapp_available(cls): + """Creates a QApplication if one doesn't exist and sets up + required interactions with Unreal Engine. + + Returns: + Qtwidgets.QApplication: Qt application instance. + """ + # Have we already setup an app? + if cls._qapp is not None: + return cls._qapp + + # Does an app instance exist? If not, create one. + qapp = QtWidgets.QApplication.instance() + if not qapp: + qapp = QtWidgets.QApplication(sys.argv) + # Use a property to indicate our ownership of the app + qapp.setProperty(PACKAGE_TAG, True) + + # Early out of the app was not created by this plugin + if not qapp.property(PACKAGE_TAG): + return qapp + + # Add the process events function if needed to tick the QApplication + if sys.platform != "win32": + # Win32 apparently no longer needs a process events on tick to update + # https://github.com/ue4plugins/tk-unreal/blob/master/engine.py#L117-L123 + unreal.register_slate_post_tick_callback( + lambda delta_time: qapp.processEvents + ) + + # Setup app styling + qapp.setStyle("Fusion") + qapp.setPalette(UnrealPalette(qapp.palette())) + + # Register callback to exit the QApplication on shutdown + unreal.register_python_shutdown_callback(cls.quit_qapp) + + # Store a reference to the app for future requests + cls._qapp = qapp + return cls._qapp + + @classmethod + def get_parent_widget(cls): + """ + Returns: + Qtwidgets.QWidget: Qt application parent widget. + """ + qapp = cls.ensure_qapp_available() + + parent_widget = qapp.property(SLATE_PARENT_OBJECT_NAME) + if parent_widget is None: + # Create an invisible parent widget for all UIs parented to the app's main + # window. This is so that widgets don't have to individually attach to the + # app window as top-level. + parent_widget = QtWidgets.QWidget() + parent_widget.setObjectName(SLATE_PARENT_OBJECT_NAME) + parent_widget.setWindowFlags( + QtCore.Qt.Widget | QtCore.Qt.FramelessWindowHint + ) + parent_widget.setAttribute(QtCore.Qt.WA_NoSystemBackground, True) + parent_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground, True) + parent_widget.setVisible(True) + + # Store widget on the application in a property so it is only set up once + qapp.setProperty(SLATE_PARENT_OBJECT_NAME, parent_widget) + + # Store it as a python attribute to block GC + setattr(qapp, SLATE_PARENT_OBJECT_NAME, parent_widget) + + # Parent the widget to slate + unreal.parent_external_window_to_slate( + parent_widget.winId(), unreal.SlateParentWindowSearchMethod.MAIN_WINDOW + ) + + return parent_widget + + @classmethod + def show_widget(cls, widget): + """Show widget in the Unreal Engine UI. + + Args: + widget (QtWidgets.QWidget): Widget to show. + """ + # Make non-window widgets into a window + if not widget.isWindow(): + widget.setWindowFlag(QtCore.Qt.Window, True) + + # Ensure window is parented to the Slate parent widget + widget.setParent(cls.get_parent_widget()) + widget.show() + widget.raise_() + + @classmethod + def quit_qapp(cls): + """Quit QApplication prior to UE exiting.""" + qapp = QtWidgets.QApplication.instance() + + # If a QApplication instance is present and we created it, shut it down + if qapp and qapp.property(PACKAGE_TAG): + qapp.quit() + qapp.deleteLater() diff --git a/OpenTimelineIOUtilities/OpenTimelineIOUtilities.uplugin b/OpenTimelineIOUtilities/OpenTimelineIOUtilities.uplugin new file mode 100644 index 0000000..16f5308 --- /dev/null +++ b/OpenTimelineIOUtilities/OpenTimelineIOUtilities.uplugin @@ -0,0 +1,16 @@ +{ + "FileVersion": 3, + "Version": 0.1, + "VersionName": "0.1", + "FriendlyName": "OpenTimelineIO (OTIO) Utilities", + "Description": "Utilities for integrating OpenTimelineIO into linear content pipelines.", + "Category": "Scripting", + "CreatedBy": "Contributors to the OpenTimelineIO project", + "CreatedByURL": "https://github.com/OpenTimelineIO/OpenTimelineIO-Unreal-Plugin", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "", + "CanContainContent": true, + "IsBetaVersion": true, + "Installed": false +} diff --git a/OpenTimelineIOUtilities/Resources/Icon128.png b/OpenTimelineIOUtilities/Resources/Icon128.png new file mode 100644 index 0000000..4b835c6 Binary files /dev/null and b/OpenTimelineIOUtilities/Resources/Icon128.png differ diff --git a/README.md b/README.md index 46d36b8..a533942 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,215 @@ # OpenTimelineIO-Unreal-Plugin +This `OpenTimelineIOUtilities` plugin for Unreal Engine provides a +configurable framework and actions for mapping between an OpenTimelineIO +timeline and a UE level sequence hierarchy. + +In the context of this plugin, OTIO stacks and clips map to level sequences; +stacks being interpreted as sequences containing shot tracks, and clips as +individual shot sections and their referenced sub-sequences. This approach +supports arbitrarily nested (or flat) Sequencer pipelines in UE, and leans on +implementation-defined hooks as a translation layer. + +When importing a timeline, if a referenced level sequence does not yet exist, +it will be created at the configured path prior to adding it as a sub-sequence. +This capability enables some very useful automation workflows, supporting +simultaneous timeline syncing and setup of shot scaffolding. + +The plugin can also register a UE Factory (import mechanism) and Exporter +(export mechanism) to add support for importing any OTIO-supported file format +into a level sequence hierarchy, and exporting a level sequence hierarchy to +one OTIO-supported file format (*.otio by default), through standard Unreal +import/export interfaces. In many cases though, implementors will want to use +the `otio_unreal` Python package to directly integrate these interfaces into +pipeline-specific workflows. + +In addition to these lower level components, the plugin provides a collection +of high level actions with graphical interfaces for previewing (through an +embedded `opentimelineview` instance) import and export results prior to making +changes to the Unreal project or committing timeline data to disk. These +actions serve both as intuitive tools for accelerating pipeline UX, as well as +example code for exploring other integrations. + +**NOTE** + +This plugin's import function wraps all Unreal Editor changes in a +`ScopedEditorTransaction`, making the operation revertible with a single +`Undo` action. + +## Feature Matrix + +This table outlines OTIO features which are currently supported by this plugin. +For unsupported features, contributions are welcome. + +| Feature | Supported | +|--------------------------| --------- | +| Single Track of Clips | ✔ | +| Multiple Video Tracks | ✔ | +| Audio Tracks & Clips | ✖ | +| Gap/Filler | ✔ | +| Markers | ✔ | +| Nesting | ✔ | +| Transitions | ✖ | +| Audio/Video Effects | ✖ | +| Linear Speed Effects | ✔ | +| Fancy Speed Effects | ✖ | +| Color Decision List | ✖ | +| Image Sequence Reference | ✖ | + +## Install + +This plugin requires that the Python packages referenced in [requirements.txt](requirements.txt) +are installed to a location on the `UE_PYTHONPATH` environment variable. See +the [Scripting the Unreal Editor Using Python](https://docs.unrealengine.com/5.0/en-US/scripting-the-unreal-editor-using-python/) +docs for more info. + +Once these dependencies are available to Unreal's Python environment, there are +two options for installing the plugin: + +### Unreal Engine Plugin + +To add the `OpenTimelineIOUtilities` plugin to a UE project, move its directory +to one of the two Unreal plugin search paths: + +- Engine: `//Engine/Plugins` +- Game: `//Plugins` + +To make the plugin fully self-contained, you can install Python package +dependencies to one or more platform-specific package directories within the +plugin's `Content` directory structure: +`OpenTimelineIOUtilities/Content/Python/Lib/[Win64|Linux|Mac]/site-packages/` + +After an Unreal Editor restart the `OpenTimelineIO (OTIO) Utilities` plugin can +be enabled in the UE `Plugins` dialog. Following another restart, all plugin +functionality and Python packages will be available in Unreal Editor. + +### Python Only + +To make this plugin available in Unreal Editor without installing it as a UE +plugin, simply add the directory containing `init_unreal.py` (and the +`otio_unreal` and `otio_unreal_actions` Python packages) to the `UE_PYTHONPATH` +environment variable. UE will run `init_unreal.py` on startup to register the +import and export interfaces, and make these packages available in Unreal's +Python environment. Alternatively these same paths can be added to the `Python` +plugin `Additional Paths` property in UE project settings. + +## Actions + +When PySide2 is available in the Python environment, two actions with user +interfaces are added to a new `OPENTIMELINEIO` section of these Unreal Editor +menus: + +- Level Sequence menu above the viewport (slate/clapperboard icon). +- Level Sequence context menu (right-click a Level Sequence asset in the Content + Browser). + +These actions must be triggered after a level sequence is loaded into +Sequencer. This should usually be the main sequence under which all +sequences and shots are organized so that imported and exported timelines will +reflect this structure recursively. + +### Export Sequence to OTIO... + +This tool will preview and export the current level sequence (loaded in +Sequencer) to any OTIO-supported timeline format. + +To use: launch the dialog, update the default timeline file path to the target +file, and click `OK` to export. Registered export hooks will be called to +finalize the timeline data (setting media references, etc.). + +### Update Sequence from OTIO... + +This tool will preview and update the current level sequence (loaded in +Sequencer) from any OTIO-supported timeline format. While a top-level sequence +must exist prior to running this tool, sub-sequences will be created and +referenced into a shot track where they did not exist previously. Existing +sub-sequences and shot track sections will be updated to match the imported +timeline. + +To use: launch the dialog, update the default timeline file path to the file to +import, and click `OK` to commit changes to the sequence hierarchy. In the +dialog's level sequence tree, the `Status` column indicates which sequences +will be updated (edit icon) or created (plus icon) when accepting the changes. + +**NOTE** + +A single `Undo` action will revert the committed changes, restoring the +original sequence hierarchy and asset state. + +## Hooks + +This plugin supports several custom OTIO hooks for implementing +pipeline-specific mapping between timelines and level sequences. Unless the +required `unreal` metadata is written to a timeline prior to import, at least +one import hook is required to successfully import a timeline. No hooks are +required to export a timeline, but can be used to setup media references +for rendered outputs. + +| Hook | Stage | Description | +|--------------------------| ------ |-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| otio_ue_pre_import | Import | Called to modify or replace a timeline prior to creating or updating a level sequence hierarchy during an OTIO import:
`hook_function :: otio.schema.Timeline, Optional[Dict] => otio.schema.Timeline` | +| otio_ue_pre_import_item | Import | Called to modify a stack or clip in-place prior to using it to update a level sequence or shot section during an OTIO import:
`hook_function :: otio.schema.Item, Optional[Dict] => None` | +| otio_ue_post_export | Export | Called to modify or replace a timeline following an OTIO export from a level sequence hierarchy:
`hook_function :: otio.schema.Timeline, Optional[Dict] => otio.schema.Timeline` | +| otio_ue_post_export_clip | Export | Called to modify a clip in-place following it being created from a shot section during an OTIO export:
`hook_function :: otio.schema.Clip, Optional[Dict] => None` | + +The primary goal of the import hooks are to add the following metadata to each +stack and clip in the timeline which should map to a level sequence asset path +in Unreal Engine: + +`"metadata": {"unreal": {"sub_sequence": "/Game/Path/To/Sequence.Sequence"}}` + +Conversely, the goal of the export hooks are to interpret and convert this +metadata into media references which point to rendered outputs from a movie +render queue. By default, all media references are set to `MissingReference` +on export. + +Each of these goals can be implemented at a global level (updating the timeline +once) or a granular level (updating each stack and clip in-place). + +See the [OTIO documentation](https://opentimelineio.readthedocs.io/en/latest/tutorials/write-a-hookscript.html) +for instructions on adding hooks to the OTIO environment. + +An example OTIO plugin manifest file and UE-specific hooks can be found in +[examples/hooks](examples/hooks). + +**NOTE** + +All OTIO hook functions must have the following signature and parameter names, +regardless of expected parameter type: + +``` +def hook_function(in_timeline, argument_map=None): + ... +``` + +For example, the first parameter should always be named `in_timeline`, even +though the value received in these UE-specific hooks may be a `Timeline`, +`Stack`, or `Clip` object. + +## Environment Variables + +OTIO import/export behavior in Unreal Engine can also be configured with +a number of supported environment variables. None of these are required. + +| Variable | Description | Example | +|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| +| OTIO_UE_REGISTER_UCLASSES | `1` (the default) enables OTIO uclass registration in UE, adding OTIO to UE import and export interfaces. Set to `0` to disable all registration. | `0` | +| OTIO_UE_IMPORT_SUFFIXES | Comma-separated list of OTIO adapter suffixes to register for import into UE. If undefined, all adapters are registered. | `otio,edl,xml` | +| OTIO_UE_EXPORT_SUFFIX | One OTIO adapter suffix to register for export from UE. Defaults to `otio`. | `edl` | + +## Python API + +The `otio_unreal` Python package provides an API to assist in implementing +this plugin into pipeline-specific tools and user interfaces. See the +`otio_unreal.adapter` module for the main interface documentation. + +## Known Issues + +- Registering an `unreal.Factory` via Python, as is done for the OTIO Factory + (importer), doesn't pass the current Unreal Content Browser location to the + created `unreal.AssetImportTask` `destination_path` attribute, preventing + creation of the root level sequence in the expected directory when importing + via a Content Browser context menu. If a hook defines `sub_sequence` in the + imported timeline's root "tracks" stack `unreal` metadata, the level + sequence will be created at that pipeline-defined location, otherwise it will + be created in a default `/Game/Sequences` directory. diff --git a/examples/hooks/otio_to_ue.py b/examples/hooks/otio_to_ue.py new file mode 100644 index 0000000..b4d3aee --- /dev/null +++ b/examples/hooks/otio_to_ue.py @@ -0,0 +1,31 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +""" +NOTE: This is an example `otio_ue_pre_import_item` hook, providing + a pipeline-specific OTIO-to-UE level sequence mapping + implementation. +""" + +import opentimelineio as otio +import otio_unreal + + +MAIN_SEQ_PATH = "/Game/Levels/Main_SEQ.Main_SEQ" + + +def hook_function(in_timeline, argument_map=None): + # The root "tracks" Stack needs to be mapped to the main level sequence + # (containing the top-level shot track). + if isinstance(in_timeline, otio.schema.Stack) and in_timeline.name == "tracks": + otio_unreal.set_sub_sequence_path(in_timeline, MAIN_SEQ_PATH) + + # Clips always map to a shot track section sub-sequence + elif isinstance(in_timeline, otio.schema.Clip): + shot_name = in_timeline.name + level_seq_path = ( + "/Game/Levels/shots/{shot_name}/{shot_name}.{shot_name}".format( + shot_name=shot_name + ) + ) + otio_unreal.set_sub_sequence_path(in_timeline, level_seq_path) diff --git a/examples/hooks/plugin_manifest.json b/examples/hooks/plugin_manifest.json new file mode 100644 index 0000000..9004a95 --- /dev/null +++ b/examples/hooks/plugin_manifest.json @@ -0,0 +1,21 @@ +{ + "OTIO_SCHEMA" : "PluginManifest.1", + "hook_scripts" : [ + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "otio_to_ue", + "execution_scope" : "in process", + "filepath" : "otio_to_ue.py" + }, + { + "OTIO_SCHEMA" : "HookScript.1", + "name" : "ue_to_otio", + "execution_scope" : "in process", + "filepath" : "ue_to_otio.py" + } + ], + "hooks" : { + "otio_ue_pre_import_item" : ["otio_to_ue"], + "otio_ue_post_export_clip" : ["ue_to_otio"] + } +} diff --git a/examples/hooks/ue_to_otio.py b/examples/hooks/ue_to_otio.py new file mode 100644 index 0000000..eeb87b8 --- /dev/null +++ b/examples/hooks/ue_to_otio.py @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +""" +NOTE: This is an example `otio_ue_post_export_clip` hook, providing + a pipeline-specific UE-to-OTIO media reference mapping + implementation. +""" + +import os + +import unreal +import opentimelineio as otio +import otio_unreal + + +def hook_function(in_timeline, argument_map=None): + # Get level sequence path from clip (in_timeline is a Clip here) + level_seq_path = otio_unreal.get_sub_sequence_path(in_timeline) + if level_seq_path is not None: + # Get output path components + shot_name = os.path.splitext(os.path.basename(level_seq_path))[0] + render_dir = os.path.realpath( + os.path.join(unreal.Paths.project_saved_dir(), "MovieRenders", shot_name) + ) + # Get available_range, which is the sub-sequence's playback range + available_range = in_timeline.media_reference.available_range + + # Setup anticipated MRQ output image sequence reference + media_ref = otio.schema.ImageSequenceReference( + target_url_base=render_dir.replace("\\", "/"), + name_prefix=shot_name + ".", + name_suffix=".png", + start_frame=available_range.start_time.to_frames(), + frame_step=1, + rate=available_range.start_time.rate, + frame_zero_padding=4, + available_range=available_range, + ) + in_timeline.media_reference = media_ref diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f6a8fce --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +OpenTimelineIO +PySide2 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..d7bc3b7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project diff --git a/tests/test_otio_unreal.py b/tests/test_otio_unreal.py new file mode 100644 index 0000000..8b96701 --- /dev/null +++ b/tests/test_otio_unreal.py @@ -0,0 +1,454 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project + +"""Unit tests for the otio_unreal plugin""" + +import tempfile +import unittest +from contextlib import contextmanager +from pathlib import Path + +import opentimelineio as otio +from opentimelineio.opentime import RationalTime, TimeRange + +from otio_unreal import ( + METADATA_KEY_UE, + import_otio, + export_otio, + set_sub_sequence_path, + get_sub_sequence_path, + LevelSequenceProxy, + ShotSectionProxy, +) + + +class OTIOUnrealIOTest(unittest.TestCase): + """Test case to validate interchange between OTIO and Unreal + Engine with ``otio_unreal`` Python API. + """ + + FRAME_RATE = 24.0 + + TIME_1SEC = RationalTime(24, FRAME_RATE) + TIME_2SEC = RationalTime(48, FRAME_RATE) + TIME_4SEC = RationalTime(96, FRAME_RATE) + TIME_7SEC = RationalTime(168, FRAME_RATE) + + RANGE_1SEC = TimeRange(duration=TIME_1SEC) + RANGE_2SEC = TimeRange(duration=TIME_2SEC) + RANGE_4SEC = TimeRange(duration=TIME_4SEC) + RANGE_7SEC = TimeRange(duration=TIME_7SEC) + + PROJ_ROOT = Path("/Game/Test") + SEQ_ROOT = PROJ_ROOT / "Sequences" + + @contextmanager + def no_max_diff(self): + """Temporarily remove diff limit to clearly describe JSON + differences. + """ + prev_max_diff = self.maxDiff + self.maxDiff = None + + yield + + self.maxDiff = prev_max_diff + + def build_test_timeline(self): + """Build OTIO timeline which tests core otio_unreal features. + + Returns: + otio.schema.Timeline: Test timeline + """ + # Project - nested sequences + proj_timeline = otio.schema.Timeline( + global_start_time=RationalTime(0, self.FRAME_RATE) + ) + set_sub_sequence_path( + proj_timeline.tracks, (self.PROJ_ROOT / "Test.Test").as_posix() + ) + + proj_track1 = otio.schema.Track(name="Video Track 1") + proj_timeline.tracks.append(proj_track1) + + proj_gap1 = otio.schema.Gap(source_range=self.RANGE_1SEC) + proj_track1.append(proj_gap1) + + # Sequence A - single track + seq_a_root = self.SEQ_ROOT / "A" + seq_a_shots_root = seq_a_root / "Shots" + + seq_a_stack = otio.schema.Stack(name="Test_A", source_range=self.RANGE_7SEC) + set_sub_sequence_path(seq_a_stack, (seq_a_root / "Test_A.Test_A").as_posix()) + proj_track1.append(seq_a_stack) + + seq_a_track1 = otio.schema.Track(name="Video Track 1") + seq_a_stack.append(seq_a_track1) + + # Sequence A shots + # No time warp or offset + shot_a1_clip = otio.schema.Clip(name="Test_A001", source_range=self.RANGE_1SEC) + shot_a1_clip.media_reference = otio.schema.MissingReference( + available_range=self.RANGE_1SEC + ) + set_sub_sequence_path( + shot_a1_clip, (seq_a_shots_root / "Test_A001.Test_A001").as_posix() + ) + seq_a_track1.append(shot_a1_clip) + + # Linear time warp scale up + shot_a2_clip = otio.schema.Clip( + name="Test_A002", + source_range=TimeRange(start_time=self.TIME_1SEC, duration=self.TIME_1SEC), + ) + shot_a2_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange( + start_time=self.TIME_1SEC, duration=self.TIME_2SEC + ) + ) + shot_a2_clip.effects.append(otio.schema.LinearTimeWarp(time_scalar=2.0)) + set_sub_sequence_path( + shot_a2_clip, (seq_a_shots_root / "Test_A002.Test_A002").as_posix() + ) + seq_a_track1.append(shot_a2_clip) + + # Linear time warp scale down + shot_a3_clip = otio.schema.Clip( + name="Test_A003", source_range=TimeRange(duration=self.TIME_2SEC) + ) + shot_a3_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange(duration=self.TIME_1SEC) + ) + shot_a3_clip.effects.append(otio.schema.LinearTimeWarp(time_scalar=0.5)) + set_sub_sequence_path( + shot_a3_clip, (seq_a_shots_root / "Test_A003.Test_A003").as_posix() + ) + seq_a_track1.append(shot_a3_clip) + + # Positive start frame offset + shot_a4_clip = otio.schema.Clip( + name="Test_A004", + source_range=TimeRange(start_time=self.TIME_2SEC, duration=self.TIME_1SEC), + ) + shot_a4_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange( + start_time=self.TIME_1SEC, duration=self.TIME_2SEC + ) + ) + set_sub_sequence_path( + shot_a4_clip, (seq_a_shots_root / "Test_A004.Test_A004").as_posix() + ) + seq_a_track1.append(shot_a4_clip) + + # Negative start frame offset + shot_a5_clip = otio.schema.Clip( + name="Test_A005", + source_range=TimeRange(start_time=self.TIME_1SEC, duration=self.TIME_2SEC), + ) + shot_a5_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange( + start_time=self.TIME_2SEC, duration=self.TIME_2SEC + ) + ) + set_sub_sequence_path( + shot_a5_clip, (seq_a_shots_root / "Test_A005.Test_A005").as_posix() + ) + seq_a_track1.append(shot_a5_clip) + + # Markers + for item in [seq_a_stack, shot_a2_clip]: + for i, (color, color_name) in enumerate( + [ + (otio.schema.MarkerColor.RED, "red"), + (otio.schema.MarkerColor.PINK, "pink"), + (otio.schema.MarkerColor.ORANGE, "orange"), + (otio.schema.MarkerColor.YELLOW, "yellow"), + (otio.schema.MarkerColor.GREEN, "green"), + (otio.schema.MarkerColor.CYAN, "cyan"), + (otio.schema.MarkerColor.BLUE, "blue"), + (otio.schema.MarkerColor.PURPLE, "purple"), + (otio.schema.MarkerColor.MAGENTA, "magenta"), + (otio.schema.MarkerColor.WHITE, "white"), + (otio.schema.MarkerColor.BLACK, "black"), + ] + ): + item.markers.append( + otio.schema.Marker( + name=color_name, + marked_range=TimeRange( + start_time=RationalTime( + item.source_range.start_time.to_frames() + i, + self.FRAME_RATE, + ), + duration=RationalTime(1, self.FRAME_RATE), + ), + color=color, + ) + ) + + # Sequence B - multi track + seq_b_root = self.SEQ_ROOT / "B" + seq_b_shots_root = seq_b_root / "Shots" + + seq_b_stack = otio.schema.Stack(name="Test_B", source_range=self.RANGE_4SEC) + set_sub_sequence_path(seq_b_stack, (seq_b_root / "Test_B.Test_B").as_posix()) + proj_track1.append(seq_b_stack) + + seq_b_track1 = otio.schema.Track(name="Video Track 1") + seq_b_stack.append(seq_b_track1) + seq_b_track2 = otio.schema.Track(name="Video Track 2") + seq_b_stack.append(seq_b_track2) + + # Sequence B shots + # Track 1: | gap | clip | + seq_b_gap1 = otio.schema.Gap(source_range=self.RANGE_1SEC) + seq_b_track1.append(seq_b_gap1) + + # Matching media reference range + shot_b2_clip = otio.schema.Clip( + name="Test_B002", + source_range=TimeRange(start_time=self.TIME_2SEC, duration=self.TIME_2SEC), + ) + shot_b2_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange( + start_time=self.TIME_2SEC, duration=self.TIME_2SEC + ) + ) + set_sub_sequence_path( + shot_b2_clip, (seq_b_shots_root / "Test_B002.Test_B002").as_posix() + ) + seq_b_track1.append(shot_b2_clip) + + # Track 2: | clip | gap | clip | + # Trimmed section within long media reference + shot_b1_clip = otio.schema.Clip( + name="Test_B001", + source_range=TimeRange(start_time=self.TIME_4SEC, duration=self.TIME_1SEC), + ) + shot_b1_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange(duration=self.TIME_7SEC) + ) + set_sub_sequence_path( + shot_b1_clip, (seq_b_shots_root / "Test_B001.Test_B001").as_posix() + ) + seq_b_track2.append(shot_b1_clip) + + seq_b_gap2 = otio.schema.Gap(source_range=self.RANGE_2SEC) + seq_b_track2.append(seq_b_gap2) + + # Media reference with negative start time + shot_b3_clip = otio.schema.Clip(name="Test_B003", source_range=self.RANGE_1SEC) + shot_b3_clip.media_reference = otio.schema.MissingReference( + available_range=TimeRange( + start_time=-self.TIME_2SEC, duration=self.TIME_4SEC + ) + ) + set_sub_sequence_path( + shot_b3_clip, (seq_b_shots_root / "Test_B003.Test_B003").as_posix() + ) + seq_b_track2.append(shot_b3_clip) + + return proj_timeline + + def assert_shot_section(self, item, shot_section_proxy): + """Assert that an Unreal shot section matches the expected + characteristics of its associated OTIO item. + + Args: + item (otio.schema.Item): Associated item + shot_section_proxy (ShotSectionProxy): Shot section to test + """ + # Sub-sequence exists + sub_seq = shot_section_proxy.section.get_sequence() + self.assertIsNotNone(sub_seq) + + sub_seq_proxy = LevelSequenceProxy(sub_seq, shot_section_proxy) + sub_seq_source_range = sub_seq_proxy.get_source_range() + + # Shot display name == item name + self.assertEqual(shot_section_proxy.section.get_shot_display_name(), item.name) + + # Sub-sequence path == item metadata + self.assertEqual(sub_seq.get_path_name(), get_sub_sequence_path(item)) + + # Frame rate + self.assertEqual( + sub_seq_proxy.get_frame_rate(), item.source_range.start_time.rate + ) + + # Section range == range in parent + self.assertEqual( + shot_section_proxy.get_range_in_parent(), item.range_in_parent() + ) + + # Visible playback range == source range + self.assertEqual(sub_seq_source_range, item.source_range) + + if ( + hasattr(item, "media_reference") + and item.media_reference + and item.media_reference.available_range + ): + # Playback range == media reference available range + self.assertEqual( + sub_seq_proxy.get_available_range(), + item.media_reference.available_range, + ) + + # Time offset == source range start frame - available range start frame + self.assertEqual( + shot_section_proxy.get_start_frame_offset(), + item.source_range.start_time.to_frames() + - item.media_reference.available_range.start_time.to_frames(), + ) + + else: + # Playback range == source range if no media reference + self.assertEqual( + sub_seq_proxy.get_available_range(), + item.source_range, + ) + + # Work range >= trimmed range + self.assertLessEqual( + sub_seq.get_work_range_start(), sub_seq_source_range.start_time.to_seconds() + ) + self.assertGreaterEqual( + sub_seq.get_work_range_end(), + sub_seq_source_range.end_time_exclusive().to_seconds(), + ) + + # View range >= trimmed range + self.assertLessEqual( + sub_seq.get_view_range_start(), sub_seq_source_range.start_time.to_seconds() + ) + self.assertGreaterEqual( + sub_seq.get_view_range_end(), + sub_seq_source_range.end_time_exclusive().to_seconds(), + ) + + if item.effects: + for effect in item.effects: + if isinstance(effect, otio.schema.LinearTimeWarp): + # Linear time warm == section time scale + self.assertEqual( + effect.time_scalar, shot_section_proxy.get_time_scale() + ) + + return sub_seq_proxy + + def test_roundtrip(self): + """Test that an OTIO timeline can be imported to an Unreal + level sequence hierarchy, and then exported back to an OTIO + timeline, matching the source 1:1. + + Note: + While this roundtrip must be lossless and covers most + supported OTIO -> Unreal -> OTIO features, not all OTIO + features are supported, and there are cases where supported + features may roundtrip with a lossy result due to the + limited feature matrix of this plugin. + """ + temp_dir = tempfile.TemporaryDirectory() + in_otio_path = Path(temp_dir.name) / "test_timeline_in.otio" + out_otio_path = Path(temp_dir.name) / "test_timeline_out.otio" + + # OTIO -> UE + # ---------- + in_timeline = self.build_test_timeline() + otio.adapters.write_to_file(in_timeline, str(in_otio_path)) + + proj_level_seq, in_timeline = import_otio(str(in_otio_path)) + + # Verify project + self.assertEqual( + proj_level_seq.get_path_name(), get_sub_sequence_path(in_timeline.tracks) + ) + proj_level_seq_proxy = LevelSequenceProxy(proj_level_seq) + proj_shot_track = proj_level_seq_proxy.get_shot_track() + self.assertIsNotNone(proj_shot_track) + proj_shot_sections = proj_shot_track.get_sections() + self.assertEqual(len(proj_shot_sections), 2) + + # Verify sequence A + seq_a_item = in_timeline.tracks[0][1] + seq_a_shot_section_proxy = ShotSectionProxy( + proj_shot_sections[0], proj_level_seq_proxy + ) + seq_a_level_seq_proxy = self.assert_shot_section( + seq_a_item, seq_a_shot_section_proxy + ) + seq_a_shot_track = seq_a_level_seq_proxy.get_shot_track() + self.assertIsNotNone(seq_a_shot_track) + seq_a_shot_sections = seq_a_shot_track.get_sections() + self.assertEqual(len(seq_a_shot_sections), 5) + + # Verify sequence A shots + for i in range(5): + self.assert_shot_section( + seq_a_item[0][i], + ShotSectionProxy(seq_a_shot_sections[i], seq_a_level_seq_proxy), + ) + + # Verify sequence B + seq_b_item = in_timeline.tracks[0][2] + seq_b_shot_section_proxy = ShotSectionProxy( + proj_shot_sections[1], proj_level_seq_proxy + ) + seq_b_level_seq_proxy = self.assert_shot_section( + seq_b_item, seq_b_shot_section_proxy + ) + seq_b_shot_track = seq_b_level_seq_proxy.get_shot_track() + self.assertIsNotNone(seq_b_shot_track) + seq_b_shot_sections = seq_b_shot_track.get_sections() + self.assertEqual(len(seq_b_shot_sections), 3) + + # Verify sequence B shots + self.assert_shot_section( + seq_b_item[0][1], + ShotSectionProxy(seq_b_shot_sections[2], seq_b_level_seq_proxy), + ) + self.assert_shot_section( + seq_b_item[1][0], + ShotSectionProxy(seq_b_shot_sections[0], seq_b_level_seq_proxy), + ) + self.assert_shot_section( + seq_b_item[1][2], + ShotSectionProxy(seq_b_shot_sections[1], seq_b_level_seq_proxy), + ) + + # UE -> OTIO + # ---------- + out_timeline = export_otio(str(out_otio_path), proj_level_seq) + + # Remove all metadata except sub_sequence, so we can compare with + # in_timeline. + root_level_seq_path = get_sub_sequence_path(out_timeline.tracks) + out_timeline.tracks.metadata.clear() + set_sub_sequence_path(out_timeline.tracks, root_level_seq_path) + + for item in out_timeline.children_if(): + level_seq_path = get_sub_sequence_path(item) + if level_seq_path is not None: + + # Verify expected metadata keys + ue_metadata = item.metadata[METADATA_KEY_UE] + self.assertTrue("timecode" in ue_metadata) + self.assertTrue("is_active" in ue_metadata) + self.assertTrue("is_locked" in ue_metadata) + self.assertTrue("pre_roll_frames" in ue_metadata) + self.assertTrue("post_roll_frames" in ue_metadata) + self.assertTrue("can_loop" in ue_metadata) + self.assertTrue("end_frame_offset" in ue_metadata) + self.assertTrue("first_loop_start_frame_offset" in ue_metadata) + self.assertTrue("hierarchical_bias" in ue_metadata) + self.assertTrue("network_mask" in ue_metadata) + + item.metadata.clear() + set_sub_sequence_path(item, level_seq_path) + + # JSON data should match between in and out timeline + with self.no_max_diff(): + self.assertEqual( + out_timeline.to_json_string(), in_timeline.to_json_string() + )