From 9528c0a090ace906d857ab3eccddba07ad043c0d Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Fri, 6 May 2022 10:46:43 -0700 Subject: [PATCH 01/18] Added otiotool.py Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 618 ++++++++++++++++++ 1 file changed, 618 insertions(+) create mode 100755 src/py-opentimelineio/opentimelineio/console/otiotool.py diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py new file mode 100755 index 000000000..dda9a6f92 --- /dev/null +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -0,0 +1,618 @@ +#!/usr/bin/env python +# +# SPDX-License-Identifier: Apache-2.0 +# Copyright Contributors to the OpenTimelineIO project +# +# otiotool is a multipurpose command line tool for inspecting, +# modifying, combining, and splitting OTIO files. +# +# Each of the many operations it can perform is provided by a +# small, simple utility function. These functions also serve +# as concise examples of how OTIO can be used to perform common +# workflow tasks. + +import argparse +import os +import re +import sys +import urllib.request + +from copy import deepcopy + +import opentimelineio as otio + + +def main(): + """otiotool main program. + This function is resposible for executing the steps specified + by all of the command line arguments in the right order. + """ + + args = parse_arguments() + + # Most of this function will operate on this list of timelines. + # Often there will be just one, but this tool in general enough + # to operate on several. This is essential when the --stack or + # --concatenate arguments are used. + timelines = read_inputs(args.input) + + # Phase 1: Remove things... + + if args.video_only: + for timeline in timelines: + keep_only_video_tracks(timeline) + + if args.audio_only: + for timeline in timelines: + keep_only_audio_tracks(timeline) + + if args.remove_transitions: + timelines = filter_transitions(timelines) + + if args.only_tracks_with_name or args.only_tracks_with_index: + timelines = filter_tracks( + args.only_tracks_with_name, + args.only_tracks_with_index, + timelines + ) + + if args.only_clips_with_name or args.only_clips_with_name_regex: + timelines = filter_clips( + args.only_clips_with_name, + args.only_clips_with_name_regex, + timelines + ) + + if args.trim: + for timeline in timelines: + trim_timeline(args.trim[0], args.trim[1], timeline) + + # Phase 2: Combine timelines + + if args.stack: + timelines = [ stack_timelines(timelines) ] + + if args.concat: + timelines = [ concatenate_timelines(timelines) ] + + # Phase 3: Combine (or add) tracks + + if args.flatten: + for timeline in timelines: + flatten_timeline( + timeline, + which_tracks=args.flatten, + keep=args.keep_flattened_tracks + ) + + # Phase 4: Relinking media + + if args.copy_media_to_folder: + for timeline in timelines: + copy_media_to_folder(timeline, args.copy_media_to_folder) + + # Phase 5: Redaction + + if args.redact: + for timeline in timelines: + redact_timeline(timeline) + + # Phase 6: Inspection + + if args.stats: + for timeline in timelines: + print_timeline_stats(timeline) + + if args.inspect: + for timeline in timelines: + inspect_timelines(args.inspect, timeline) + + if args.list_clips or args.list_media or args.list_tracks or args.list_markers: + for timeline in timelines: + summarize_timeline( + args.list_tracks, + args.list_clips, + args.list_media, + args.list_markers, + timeline) + + # Final Phase: Output + + if args.output: + # Gather all of the timelines under one OTIO object + # in preparation for final output + if len(timelines) > 1: + output = otio.schema.SerializableCollection() + output.extend(timelines) + else: + output = timelines[0] + + write_output(args.output, output) + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description="Multi-purpose command line utility for working with OpenTimelineIO." + ) + parser.add_argument( + "-i", + "--input", + type=str, + nargs='+', + required=True, + help="Input file path(s)" + ) + parser.add_argument( + "-o", + "--output", + type=str, + help="Output file" + ) + parser.add_argument( + "-v", + "--video-only", + action='store_true', + help="Output only video tracks" + ) + parser.add_argument( + "-a", + "--audio-only", + action='store_true', + help="Output only audio tracks" + ) + parser.add_argument( + "--only-tracks-with-name", + type=str, + nargs='*', + help="Output tracks with these name(s)" + ) + parser.add_argument( + "--only-tracks-with-index", + type=int, + nargs='*', + help="Output tracks with these indexes (1 based, in same order as --list-tracks)" + ) + parser.add_argument( + "--only-clips-with-name", + type=str, + nargs='*', + help="Output only clips with these name(s)" + ) + parser.add_argument( + "--only-clips-with-name-regex", + type=str, + nargs='*', + help="Output only clips with names matching the given regex" + ) + parser.add_argument( + "--remove-transitions", + action='store_true', + help="Remove all transitions" + ) + parser.add_argument( + "-f", + "--flatten", + choices=['video', 'audio', 'all'], + help="Flatten multiple tracks into one." + ) + parser.add_argument( + "--keep-flattened-tracks", + action='store_true', + help="When used with --flatten, the new flat track is added above the others instead of replacing them." + ) + parser.add_argument( + "-s", + "--stack", + action='store_true', + help="Stack multiple input files into one timeline" + ) + parser.add_argument( + "-t", + "--trim", + type=str, + nargs=2, + help="Trim from to as HH:MM:SS:FF timecode or seconds" + ) + parser.add_argument( + "-c", + "--concat", + action='store_true', + help="Concatenate multiple input files end-to-end into one timeline" + ) + parser.add_argument( + "--stats", + action='store_true', + help="List statistics about the result, including start, end, and duration" + ) + parser.add_argument( + "--list-clips", + action='store_true', + help="List each clip's name" + ) + parser.add_argument( + "--list-tracks", + action='store_true', + help="List each track's name" + ) + parser.add_argument( + "--list-media", + action='store_true', + help="List each referenced media URL" + ) + parser.add_argument( + "--list-markers", + action='store_true', + help="List summary of all markers" + ) + parser.add_argument( + "--inspect", + type=str, + nargs='*', + help="Inspect details of clips with names matching the given regex" + ) + parser.add_argument( + "--redact", + action='store_true', + help="Remove all metadata, names, etc. leaving only the timeline structure" + ) + parser.add_argument( + "--copy-media-to-folder", + type=str, + help="Copy or download all linked media to the specified folder and relink all media references to the copies" + ) + + args = parser.parse_args() + + # Some options cannot be combined. + + if args.video_only and args.audio_only: + parser.error("Cannot use --video-only and --audio-only at the same time.") + + if args.stack and args.concat: + parser.error("Cannot use --stack and --concat at the same time.") + + if args.keep_flattened_tracks and not args.flatten: + parser.error("Cannot use --keep-flattened-tracks without also using --flatten.") + + return args + + +def read_inputs(input_paths): + """Read one or more timlines from the list of file paths given. + If a file path is '-' then a timeline is read from stdin. + """ + timelines = [] + for input_path in input_paths: + if input_path == '-': + text = sys.stdin.read() + timeline = otio.adapters.read_from_string(text, 'otio_json') + else: + timeline = otio.adapters.read_from_file(input_path) + timelines.append(timeline) + return timelines + + +def keep_only_video_tracks(timeline): + """Remove all tracks except for video tracks from a timeline.""" + timeline.tracks[:] = timeline.video_tracks() + + +def keep_only_audio_tracks(timeline): + """Remove all tracks except for audio tracks from a timeline.""" + timeline.tracks[:] = timeline.audio_tracks() + + +def filter_transitions(timelines): + """Return a copy of the input timelines with all transitions removed. + The overall duration of the timelines should not be affected.""" + def _f(item): + if isinstance(item, otio.schema.Transition): + return None + return item + return [otio.algorithms.filtered_composition(t, _f) for t in timelines] + + +def _filter(item, names, patterns): + """This is a helper function that returns the input item if + its name matches the list of names given (if any), or matches any of the + patterns given (if any). If the item's name does not match any of the + given names or patterns, then None is returned.""" + if names and item.name in names: + return item + if patterns: + for pattern in patterns: + if re.search(pattern, item.name): + return item + return None + + # TODO: Should this return a same-duration Gap instead? + # gap = otio.schema.Gap(source_range=item.trimmed_range()) + # return gap + + +def filter_tracks(only_tracks_with_name, only_tracks_with_index, timelines): + """Return a copy of the input timelines with only tracks that match + either the list of names given, or the list of track indexes given.""" + + # Use a variable saved within this function so that the closure + # below can modify it. + # See: https://stackoverflow.com/questions/21959985/why-cant-python-increment-variable-in-closure + filter_tracks.index = 0 + + def _f(item): + if not isinstance(item, otio.schema.Track): + return item + filter_tracks.index = filter_tracks.index + 1 + if only_tracks_with_index and filter_tracks.index not in only_tracks_with_index: + return None + if only_tracks_with_name and item.name not in only_tracks_with_name: + return None + return item + + return [otio.algorithms.filtered_composition(t, _f) for t in timelines] + + +def filter_clips(only_clips_with_name, only_clips_with_name_regex, timelines): + """Return a copy of the input timelines with only clips with names + that match either the given list of names, or regular expression patterns.""" + + def _f(item): + if not isinstance(item, otio.schema.Clip): + return item + return _filter(item, only_clips_with_name, only_clips_with_name_regex) + + return [otio.algorithms.filtered_composition(t, _f) for t in timelines] + + +def stack_timelines(timelines): + """Return a single timeline with all of the tracks from all of the input + timelines stacked on top of each other. The resulting timeline should be + as long as the longest input timeline.""" + stacked_timeline = otio.schema.Timeline() + for timeline in timelines: + stacked_timeline.tracks.extend(deepcopy(timeline.tracks[:])) + return stacked_timeline + + +def concatenate_timelines(timelines): + """Return a single timeline with all of the input timelines concatenated + end-to-end. The resulting timeline should be as long as the sum of the + durations of the input timelines.""" + concatenated_track = otio.schema.Track() + for timeline in timelines: + concatenated_track.append(deepcopy(timeline.tracks)) + concatenated_timeline = otio.schema.Timeline(tracks=[concatenated_track]) + return concatenated_timeline + + +def flatten_timeline(timeline, which_tracks='video', keep=False): + """Replace the tracks of this timeline with a single track by flattening. + If which_tracks is specified, you may choose 'video', 'audio', or 'all'. + If keep is True, then the old tracks are retained and the new one is added + above them instead of replacing them. This can be useful to see and + understand how flattening works.""" + + # Make two lists: tracks_to_flatten and other_tracks + # Note: that we take care to NOT assume that there are only two kinds + # of tracks. + if which_tracks == 'all': + tracks_to_flatten = timeline.tracks + other_tracks = [] + kind = tracks_to_flatten[0].kind + elif which_tracks == 'video': + tracks_to_flatten = timeline.video_tracks() + other_tracks = [t for t in timeline.tracks if t not in tracks_to_flatten] + kind = otio.schema.TrackKind.Video + elif which_tracks == 'audio': + tracks_to_flatten = timeline.audio_tracks() + other_tracks = [t for t in timeline.tracks if t not in tracks_to_flatten] + kind = otio.schema.TrackKind.Audio + else: + raise ValueError( + "Invalid choice {} for which_tracks argument" + " to flatten_timeline.".format(which_tracks) + ) + + flat_track = otio.algorithms.flatten_stack(tracks_to_flatten[:]) + flat_track.kind = kind + + if keep: + timeline.tracks.append(flat_track) + else: + timeline.tracks[:] = other_tracks + [flat_track] + + +def time_from_string(text, rate): + """This helper function turns a string into a RationalTime. It accepts + either a timecode string (e.g. "HH:MM:SS:FF") or a string with a floating + point value measured in seconds. The second argument to this function + specifies the rate for the returned RationalTime.""" + if ":" in text: + return otio.opentime.from_timecode(text, rate) + else: + return otio.opentime.from_seconds(float(text), rate) + + +def trim_timeline(start, end, timeline): + """Return a copy of the input timeline trimmed to the start and end + times given. Each of the start and end times can be specified as either + a timecode string (e.g. "HH:MM:SS:FF") or a string with a floating + point value measured in seconds.""" + rate = timeline.global_start_time.rate + try: + start_time = time_from_string(start, rate) + end_time = time_from_string(end, rate) + except: + raise ValueError("Start and end arguments to --trim must be " + "either HH:MM:SS:FF or a floating point number of seconds," + " not '{}' and '{}'".format(start, end)) + trim_range = otio.opentime.range_from_start_end_time(start_time, end_time) + timeline.tracks[:] = [otio.algorithms.track_trimmed_to_range(t, trim_range) for t in timeline.tracks] + + +__counters = {} +def _counter(name): + """This is a helper function for returning redacted names, based on a name.""" + counter = __counters.get(name, 0) + counter += 1 + __counters[name] = counter + return counter + +def redact_timeline(timeline): + """Remove all metadata, names, or other identifying information from this timeline. Only the + structure, schema and timing will remain.""" + + counter = _counter(timeline.schema_name()) + timeline.name = "{} #{}".format(timeline.schema_name(), counter) + timeline.metadata.clear() + + for child in [timeline.tracks] + list(timeline.each_child()): + counter = _counter(child.schema_name()) + child.name = "{} #{}".format(child.schema_name(), counter) + child.metadata.clear() + if hasattr(child, 'markers'): + for marker in child.markers: + counter = _counter(marker.schema_name()) + marker.name = "{} #{}".format(marker.schema_name(), counter) + marker.metadata.clear() + if hasattr(child, 'effects'): + for effect in child.effects: + counter = _counter(effect.schema_name()) + effect.name = "{} #{}".format(effect.schema_name(), counter) + effect.metadata.clear() + if hasattr(child, 'media_reference'): + media_reference = child.media_reference + if media_reference: + counter = _counter(media_reference.schema_name()) + if hasattr(media_reference, 'target_url') and media_reference.target_url: + media_reference.target_url = "URL #{}".format(counter) + media_reference.metadata.clear() + + +def copy_media(url, destination_path): + if url.startswith("/"): + print("COPYING: {}".format(url)) + data = open(url, "rb").read() + else: + print("DOWNLOADING: {}".format(url)) + data = urllib.request.urlopen(url).read() + open(destination_path, "wb").write(data) + return destination_path + + +def copy_media_to_folder(timeline, folder): + """Copy or download all referenced media to this folder, and relink media references to the copies.""" + + # @TODO: Add an option to allow mkdir + # if not os.path.exists(folder): + # os.mkdir(folder) + + copied_files = set() + for clip in timeline.each_clip(): + media_reference = clip.media_reference + if media_reference and hasattr(media_reference, 'target_url') and media_reference.target_url: + source_url = media_reference.target_url + filename = os.path.basename(source_url) + # @TODO: This is prone to name collisions if the basename is not unique + # We probably need to hash the url, or turn the whole url into a filename. + destination_path = os.path.join(folder, filename) + already_copied_this = destination_path in copied_files + file_exists = os.path.exists(destination_url) + if already_copied_this: + media_reference.target_url = destination_path + else: + if file_exists: + print("WARNING: Relinking clip {} to existing file (instead of overwriting it): {}",format( + clip.name, destination_path + )) + media_reference.target_url = destination_path + already_copied_this.add(destination_path) + else: + try: + copy_media(source_url, destination_path) + media_reference.target_url = destination_path + already_copied_this.add(destination_path) + except Exception as ex: + print("ERROR: Problem copying/downloading media {}".format(ex)) + # don't relink this one, since the copy failed + + +def print_timeline_stats(timeline): + """Print some statistics about the given timeline.""" + print("Name: {}", timeline.name) + trimmed_range = timeline.tracks.trimmed_range() + print("Start: {}\nEnd: {}\nDuration: {}".format( + otio.opentime.to_timecode(trimmed_range.start_time), + otio.opentime.to_timecode(trimmed_range.end_time_exclusive()), + otio.opentime.to_timecode(trimmed_range.duration), + )) + + +def inspect_timelines(name_regex, timeline): + """Print some detailed information about the item(s) in the timeline with names + that match the given regular expression.""" + print("TIMELINE:", timeline.name) + items_to_inspect = [_filter(item, [], name_regex) for item in timeline.each_child()] + items_to_inspect = list(filter(None, items_to_inspect)) + for item in items_to_inspect: + print(" ITEM: {} ({})".format(item.name, type(item))) + print(" source_range:", item.source_range) + print(" trimmed_range:", item.trimmed_range()) + print(" visible_range:", item.visible_range()) + try: + print(" available_range:", item.available_range()) + except: + pass + print(" range_in_parent:", item.range_in_parent()) + print(" trimmed range in timeline:", item.transformed_time_range(item.trimmed_range(), timeline.tracks)) + print(" visible range in timeline:", item.transformed_time_range(item.visible_range(), timeline.tracks)) + ancestor = item.parent() + while ancestor != None: + print(" range in {} ({}): {}".format(ancestor.name, type(ancestor), item.transformed_time_range(item.trimmed_range(), ancestor))) + ancestor = ancestor.parent() + + +def summarize_timeline(list_tracks, list_clips, list_media, list_markers, timeline): + """Print a summary of a timeline, optionally listing the tracks, clips, media, + and/or markers inside it.""" + print("TIMELINE:", timeline.name) + for child in [timeline.tracks] + list(timeline.each_child()): + if isinstance(child, otio.schema.Track): + if list_tracks: + print("TRACK: {} ({})".format(child.name, child.kind)) + if isinstance(child, otio.schema.Clip): + if list_clips: + print(" CLIP:", child.name) + if list_media: + try: + url = child.media_reference.target_url + except: + url = None + print(" MEDIA:", url) + if list_markers and hasattr(child, 'markers'): + top_level = child + while top_level.parent() is not None: + top_level = top_level.parent() + for marker in child.markers: + print(" MARKER: global: {} local: {} duration: {} color: {} name: {}".format( + otio.opentime.to_timecode(child.transformed_time(marker.marked_range.start_time, top_level)), + otio.opentime.to_timecode(marker.marked_range.start_time), + marker.marked_range.duration.value, + marker.color, + marker.name + )) + + +def write_output(output_path, output): + """Write the given OTIO object to a file path. If the file path given is + the string '-' then the output is written to stdout instead.""" + if output_path == '-': + result = otio.adapters.write_to_string(output) + print(result) + else: + otio.adapters.write_to_file(output, output_path) + + +if __name__ == '__main__': + main() + From 487b95ff08f863e076360a37ce982b19ec1b4851 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 17 May 2022 09:09:20 -0700 Subject: [PATCH 02/18] Added otiotool to console init Signed-off-by: Joshua Minor --- src/py-opentimelineio/opentimelineio/console/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py-opentimelineio/opentimelineio/console/__init__.py b/src/py-opentimelineio/opentimelineio/console/__init__.py index cbe7af5a5..46208305b 100644 --- a/src/py-opentimelineio/opentimelineio/console/__init__.py +++ b/src/py-opentimelineio/opentimelineio/console/__init__.py @@ -13,6 +13,7 @@ otioconvert, otiocat, otiostat, + otiotool, console_utils, autogen_serialized_datamodel, otiopluginfo, From c026636a714abd3d7d0ff69a2cbd68580b079641 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 26 Jul 2022 08:52:54 -0700 Subject: [PATCH 03/18] Added --verify-media argument Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index dda9a6f92..585f74a19 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -107,13 +107,14 @@ def main(): for timeline in timelines: inspect_timelines(args.inspect, timeline) - if args.list_clips or args.list_media or args.list_tracks or args.list_markers: + if args.list_clips or args.list_media or args.verify_media or args.list_tracks or args.list_markers: for timeline in timelines: summarize_timeline( args.list_tracks, args.list_clips, - args.list_media, - args.list_markers, + args.list_media, + args.verify_media, + args.list_markers, timeline) # Final Phase: Output @@ -239,6 +240,11 @@ def parse_arguments(): action='store_true', help="List each referenced media URL" ) + parser.add_argument( + "--verify-media", + action='store_true', + help="Verify that each referenced media URL exists (for local media only)" + ) parser.add_argument( "--list-markers", action='store_true', @@ -572,7 +578,7 @@ def inspect_timelines(name_regex, timeline): ancestor = ancestor.parent() -def summarize_timeline(list_tracks, list_clips, list_media, list_markers, timeline): +def summarize_timeline(list_tracks, list_clips, list_media, verify_media, list_markers, timeline): """Print a summary of a timeline, optionally listing the tracks, clips, media, and/or markers inside it.""" print("TIMELINE:", timeline.name) @@ -583,12 +589,19 @@ def summarize_timeline(list_tracks, list_clips, list_media, list_markers, timeli if isinstance(child, otio.schema.Clip): if list_clips: print(" CLIP:", child.name) - if list_media: + if list_media or verify_media: try: url = child.media_reference.target_url except: url = None - print(" MEDIA:", url) + detail = "" + if verify_media and url: + if os.path.exists(url): + detail = " EXISTS" + else: + detail = " NOT FOUND" + print(" MEDIA{}: {}".format(detail, url)) + if list_markers and hasattr(child, 'markers'): top_level = child while top_level.parent() is not None: From 140bd1d0613ad136d26935a3182773b98bbf7cbd Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 8 Aug 2022 11:08:12 -0700 Subject: [PATCH 04/18] Better usage statement with description of phases and some examples. Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 128 ++++++++++++++---- 1 file changed, 99 insertions(+), 29 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index 585f74a19..7caf66d61 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -30,13 +30,15 @@ def main(): args = parse_arguments() + # Phase 1: Input... + # Most of this function will operate on this list of timelines. # Often there will be just one, but this tool in general enough # to operate on several. This is essential when the --stack or # --concatenate arguments are used. timelines = read_inputs(args.input) - # Phase 1: Remove things... + # Phase 2: Filter (remove stuff)... if args.video_only: for timeline in timelines: @@ -67,7 +69,7 @@ def main(): for timeline in timelines: trim_timeline(args.trim[0], args.trim[1], timeline) - # Phase 2: Combine timelines + # Phase 3: Combine timelines if args.stack: timelines = [ stack_timelines(timelines) ] @@ -75,7 +77,7 @@ def main(): if args.concat: timelines = [ concatenate_timelines(timelines) ] - # Phase 3: Combine (or add) tracks + # Phase 4: Combine (or add) tracks if args.flatten: for timeline in timelines: @@ -85,19 +87,19 @@ def main(): keep=args.keep_flattened_tracks ) - # Phase 4: Relinking media + # Phase 5: Relinking media if args.copy_media_to_folder: for timeline in timelines: copy_media_to_folder(timeline, args.copy_media_to_folder) - # Phase 5: Redaction + # Phase 6: Redaction if args.redact: for timeline in timelines: redact_timeline(timeline) - # Phase 6: Inspection + # Phase 7: Inspection if args.stats: for timeline in timelines: @@ -133,22 +135,73 @@ def main(): def parse_arguments(): parser = argparse.ArgumentParser( - description="Multi-purpose command line utility for working with OpenTimelineIO." + description="""otiotool = a multi-purpose command line utility for working with OpenTimelineIO. + +This tool works in phases, as follows: +1. Input + Input files provided by the "--input " argument(s) are read into + memory. Files may be OTIO format, or any format supported by adapter + plugins. + +2. Filtering + Options such as --video-only, --audio-only, --only-tracks-with-name, + -only-tracks-with-index, --only-clips-with-name, + --only-clips-with-name-regex, --remove-transitions, and --trim will remove + content. Only the tracks, clips, etc. that pass all of the filtering options + provided are passed to the next phase. + +3. Combine + If specified, the --stack, --concat, and --flatten operations are + performed (in that order) to combine all of the input timeline(s) into one. + +4. Relink + If specified, the --copy-media-to-folder option, will copy or download + all linked media, and relink the OTIO to reference the local copies. + +5. Redact + If specified, the --redact option, will remove all metadata and rename all + objects in the OTIO with generic names (e.g. "Track 1", "Clip 17", etc.) + +6. Inspect + Options such as --stats, --list-clips, --list-tracks, --list-media, + --verify-media, --list-markers, and --inspect will examine the OTIO and + print information to standard output. + +7. Output + Finally, if the "--output " option is specified, the resulting + OTIO will be written to the specified file. The extension of the output + filename is used to determine the format of the output (e.g. OTIO or any + format supported by the adapter plugins.) +""", + epilog="""Examples: + +Combine multiple files into one, by joining them end-to-end: +otiotool -i titles.otio -i feature.otio -i credits.otio --concat -o full.otio + +Layer multiple files on top of each other in a stack: +otiotool -i background.otio -i foreground.otio --stack -o composite.otio + +Verify that all referenced media files are accessible: +otiotool -i playlist.otio --verify-media + +Inspect specific audio clips in detail: +otiotool -i playlist.otio --only-audio --list-tracks --inspect "Interview" +""", + formatter_class=argparse.RawDescriptionHelpFormatter ) + + # Input... parser.add_argument( "-i", "--input", type=str, nargs='+', required=True, - help="Input file path(s)" - ) - parser.add_argument( - "-o", - "--output", - type=str, - help="Output file" + help="""Input file path(s). All formats supported by adapter plugins + are supported. Use '-' to read OTIO from standard input.""" ) + + # Filter... parser.add_argument( "-v", "--video-only", @@ -190,6 +243,15 @@ def parse_arguments(): action='store_true', help="Remove all transitions" ) + parser.add_argument( + "-t", + "--trim", + type=str, + nargs=2, + help="Trim from to as HH:MM:SS:FF timecode or seconds" + ) + + # Combine... parser.add_argument( "-f", "--flatten", @@ -207,19 +269,28 @@ def parse_arguments(): action='store_true', help="Stack multiple input files into one timeline" ) - parser.add_argument( - "-t", - "--trim", - type=str, - nargs=2, - help="Trim from to as HH:MM:SS:FF timecode or seconds" - ) parser.add_argument( "-c", "--concat", action='store_true', help="Concatenate multiple input files end-to-end into one timeline" ) + + # Relink + parser.add_argument( + "--copy-media-to-folder", + type=str, + help="Copy or download all linked media to the specified folder and relink all media references to the copies" + ) + + # Redact + parser.add_argument( + "--redact", + action='store_true', + help="Remove all metadata, names, etc. leaving only the timeline structure" + ) + + # Inspect... parser.add_argument( "--stats", action='store_true', @@ -256,16 +327,15 @@ def parse_arguments(): nargs='*', help="Inspect details of clips with names matching the given regex" ) + + # Output... parser.add_argument( - "--redact", - action='store_true', - help="Remove all metadata, names, etc. leaving only the timeline structure" - ) - parser.add_argument( - "--copy-media-to-folder", + "-o", + "--output", type=str, - help="Copy or download all linked media to the specified folder and relink all media references to the copies" - ) + help="""Output file. All formats supported by adapter plugins + are supported. Use '-' to write OTIO to standard output.""" + ) args = parser.parse_args() From 544f77a66069ccc882de40572176f3905e00a64b Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 8 Aug 2022 12:10:11 -0700 Subject: [PATCH 05/18] Lint Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 132 ++++++++++++------ 1 file changed, 89 insertions(+), 43 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index 7caf66d61..fc46f6a9f 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -72,10 +72,10 @@ def main(): # Phase 3: Combine timelines if args.stack: - timelines = [ stack_timelines(timelines) ] + timelines = [stack_timelines(timelines)] if args.concat: - timelines = [ concatenate_timelines(timelines) ] + timelines = [concatenate_timelines(timelines)] # Phase 4: Combine (or add) tracks @@ -109,11 +109,16 @@ def main(): for timeline in timelines: inspect_timelines(args.inspect, timeline) - if args.list_clips or args.list_media or args.verify_media or args.list_tracks or args.list_markers: + should_summarize = (args.list_clips or + args.list_media or + args.verify_media or + args.list_tracks or + args.list_markers) + if should_summarize: for timeline in timelines: summarize_timeline( - args.list_tracks, - args.list_clips, + args.list_tracks, + args.list_clips, args.list_media, args.verify_media, args.list_markers, @@ -135,7 +140,8 @@ def main(): def parse_arguments(): parser = argparse.ArgumentParser( - description="""otiotool = a multi-purpose command line utility for working with OpenTimelineIO. + description=""" +otiotool = a multi-purpose command line utility for working with OpenTimelineIO. This tool works in phases, as follows: 1. Input @@ -153,7 +159,7 @@ def parse_arguments(): 3. Combine If specified, the --stack, --concat, and --flatten operations are performed (in that order) to combine all of the input timeline(s) into one. - + 4. Relink If specified, the --copy-media-to-folder option, will copy or download all linked media, and relink the OTIO to reference the local copies. @@ -172,7 +178,7 @@ def parse_arguments(): OTIO will be written to the specified file. The extension of the output filename is used to determine the format of the output (e.g. OTIO or any format supported by the adapter plugins.) -""", +""".strip(), epilog="""Examples: Combine multiple files into one, by joining them end-to-end: @@ -199,7 +205,7 @@ def parse_arguments(): required=True, help="""Input file path(s). All formats supported by adapter plugins are supported. Use '-' to read OTIO from standard input.""" - ) + ) # Filter... parser.add_argument( @@ -224,7 +230,8 @@ def parse_arguments(): "--only-tracks-with-index", type=int, nargs='*', - help="Output tracks with these indexes (1 based, in same order as --list-tracks)" + help="Output tracks with these indexes" + " (1 based, in same order as --list-tracks)" ) parser.add_argument( "--only-clips-with-name", @@ -261,7 +268,8 @@ def parse_arguments(): parser.add_argument( "--keep-flattened-tracks", action='store_true', - help="When used with --flatten, the new flat track is added above the others instead of replacing them." + help="""When used with --flatten, the new flat track is added above the + others instead of replacing them.""" ) parser.add_argument( "-s", @@ -280,21 +288,24 @@ def parse_arguments(): parser.add_argument( "--copy-media-to-folder", type=str, - help="Copy or download all linked media to the specified folder and relink all media references to the copies" + help="""Copy or download all linked media to the specified folder and + relink all media references to the copies""" ) # Redact parser.add_argument( "--redact", action='store_true', - help="Remove all metadata, names, etc. leaving only the timeline structure" + help="""Remove all metadata, names, etc. leaving only the timeline + structure""" ) # Inspect... parser.add_argument( "--stats", action='store_true', - help="List statistics about the result, including start, end, and duration" + help="""List statistics about the result, including start, end, and + duration""" ) parser.add_argument( "--list-clips", @@ -314,7 +325,8 @@ def parse_arguments(): parser.add_argument( "--verify-media", action='store_true', - help="Verify that each referenced media URL exists (for local media only)" + help="""Verify that each referenced media URL exists (for local media + only)""" ) parser.add_argument( "--list-markers", @@ -335,7 +347,7 @@ def parse_arguments(): type=str, help="""Output file. All formats supported by adapter plugins are supported. Use '-' to write OTIO to standard output.""" - ) + ) args = parser.parse_args() @@ -412,7 +424,7 @@ def filter_tracks(only_tracks_with_name, only_tracks_with_index, timelines): # Use a variable saved within this function so that the closure # below can modify it. - # See: https://stackoverflow.com/questions/21959985/why-cant-python-increment-variable-in-closure + # See: https://stackoverflow.com/questions/21959985/why-cant-python-increment-variable-in-closure # noqa: E501 filter_tracks.index = 0 def _f(item): @@ -465,7 +477,7 @@ def flatten_timeline(timeline, which_tracks='video', keep=False): """Replace the tracks of this timeline with a single track by flattening. If which_tracks is specified, you may choose 'video', 'audio', or 'all'. If keep is True, then the old tracks are retained and the new one is added - above them instead of replacing them. This can be useful to see and + above them instead of replacing them. This can be useful to see and understand how flattening works.""" # Make two lists: tracks_to_flatten and other_tracks @@ -487,7 +499,7 @@ def flatten_timeline(timeline, which_tracks='video', keep=False): raise ValueError( "Invalid choice {} for which_tracks argument" " to flatten_timeline.".format(which_tracks) - ) + ) flat_track = otio.algorithms.flatten_stack(tracks_to_flatten[:]) flat_track.kind = kind @@ -518,15 +530,21 @@ def trim_timeline(start, end, timeline): try: start_time = time_from_string(start, rate) end_time = time_from_string(end, rate) - except: + except Exception: raise ValueError("Start and end arguments to --trim must be " - "either HH:MM:SS:FF or a floating point number of seconds," - " not '{}' and '{}'".format(start, end)) + "either HH:MM:SS:FF or a floating point number of" + " seconds, not '{}' and '{}'".format(start, end)) trim_range = otio.opentime.range_from_start_end_time(start_time, end_time) - timeline.tracks[:] = [otio.algorithms.track_trimmed_to_range(t, trim_range) for t in timeline.tracks] + timeline.tracks[:] = [ + otio.algorithms.track_trimmed_to_range(t, trim_range) + for t in timeline.tracks + ] +# Used only within _counter() to keep track of object indexes __counters = {} + + def _counter(name): """This is a helper function for returning redacted names, based on a name.""" counter = __counters.get(name, 0) @@ -534,9 +552,10 @@ def _counter(name): __counters[name] = counter return counter + def redact_timeline(timeline): - """Remove all metadata, names, or other identifying information from this timeline. Only the - structure, schema and timing will remain.""" + """Remove all metadata, names, or other identifying information from this + timeline. Only the structure, schema and timing will remain.""" counter = _counter(timeline.schema_name()) timeline.name = "{} #{}".format(timeline.schema_name(), counter) @@ -560,7 +579,8 @@ def redact_timeline(timeline): media_reference = child.media_reference if media_reference: counter = _counter(media_reference.schema_name()) - if hasattr(media_reference, 'target_url') and media_reference.target_url: + has_target_url = hasattr(media_reference, 'target_url') + if has_target_url and media_reference.target_url: media_reference.target_url = "URL #{}".format(counter) media_reference.metadata.clear() @@ -577,8 +597,9 @@ def copy_media(url, destination_path): def copy_media_to_folder(timeline, folder): - """Copy or download all referenced media to this folder, and relink media references to the copies.""" - + """Copy or download all referenced media to this folder, and relink media + references to the copies.""" + # @TODO: Add an option to allow mkdir # if not os.path.exists(folder): # os.mkdir(folder) @@ -586,21 +607,27 @@ def copy_media_to_folder(timeline, folder): copied_files = set() for clip in timeline.each_clip(): media_reference = clip.media_reference - if media_reference and hasattr(media_reference, 'target_url') and media_reference.target_url: + has_actual_url = (media_reference and + hasattr(media_reference, 'target_url') and + media_reference.target_url) + if has_actual_url: source_url = media_reference.target_url filename = os.path.basename(source_url) # @TODO: This is prone to name collisions if the basename is not unique # We probably need to hash the url, or turn the whole url into a filename. destination_path = os.path.join(folder, filename) already_copied_this = destination_path in copied_files - file_exists = os.path.exists(destination_url) + file_exists = os.path.exists(destination_path) if already_copied_this: media_reference.target_url = destination_path else: if file_exists: - print("WARNING: Relinking clip {} to existing file (instead of overwriting it): {}",format( - clip.name, destination_path - )) + print( + "WARNING: Relinking clip {} to existing file" + " (instead of overwriting it): {}".format( + clip.name, destination_path + ) + ) media_reference.target_url = destination_path already_copied_this.add(destination_path) else: @@ -637,18 +664,35 @@ def inspect_timelines(name_regex, timeline): print(" visible_range:", item.visible_range()) try: print(" available_range:", item.available_range()) - except: + except Exception: pass print(" range_in_parent:", item.range_in_parent()) - print(" trimmed range in timeline:", item.transformed_time_range(item.trimmed_range(), timeline.tracks)) - print(" visible range in timeline:", item.transformed_time_range(item.visible_range(), timeline.tracks)) + print( + " trimmed range in timeline:", + item.transformed_time_range( + item.trimmed_range(), timeline.tracks + ) + ) + print( + " visible range in timeline:", + item.transformed_time_range( + item.visible_range(), timeline.tracks + ) + ) ancestor = item.parent() - while ancestor != None: - print(" range in {} ({}): {}".format(ancestor.name, type(ancestor), item.transformed_time_range(item.trimmed_range(), ancestor))) + while ancestor is not None: + print( + " range in {} ({}): {}".format( + ancestor.name, + type(ancestor), + item.transformed_time_range(item.trimmed_range(), ancestor) + ) + ) ancestor = ancestor.parent() -def summarize_timeline(list_tracks, list_clips, list_media, verify_media, list_markers, timeline): +def summarize_timeline(list_tracks, list_clips, list_media, verify_media, + list_markers, timeline): """Print a summary of a timeline, optionally listing the tracks, clips, media, and/or markers inside it.""" print("TIMELINE:", timeline.name) @@ -662,7 +706,7 @@ def summarize_timeline(list_tracks, list_clips, list_media, verify_media, list_m if list_media or verify_media: try: url = child.media_reference.target_url - except: + except Exception: url = None detail = "" if verify_media and url: @@ -677,8 +721,11 @@ def summarize_timeline(list_tracks, list_clips, list_media, verify_media, list_m while top_level.parent() is not None: top_level = top_level.parent() for marker in child.markers: - print(" MARKER: global: {} local: {} duration: {} color: {} name: {}".format( - otio.opentime.to_timecode(child.transformed_time(marker.marked_range.start_time, top_level)), + template = " MARKER: global: {} local: {} duration: {} color: {} name: {}" # noqa: E501 + print(template.format( + otio.opentime.to_timecode(child.transformed_time( + marker.marked_range.start_time, + top_level)), otio.opentime.to_timecode(marker.marked_range.start_time), marker.marked_range.duration.value, marker.color, @@ -698,4 +745,3 @@ def write_output(output_path, output): if __name__ == '__main__': main() - From f1c4deec187b43d8226dea99da5fdca39c72b393 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 8 Aug 2022 12:11:22 -0700 Subject: [PATCH 06/18] Docstring Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index fc46f6a9f..7eae27e7b 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -2,14 +2,14 @@ # # SPDX-License-Identifier: Apache-2.0 # Copyright Contributors to the OpenTimelineIO project -# -# otiotool is a multipurpose command line tool for inspecting, -# modifying, combining, and splitting OTIO files. -# -# Each of the many operations it can perform is provided by a -# small, simple utility function. These functions also serve -# as concise examples of how OTIO can be used to perform common -# workflow tasks. + +"""otiotool is a multipurpose command line tool for inspecting, +modifying, combining, and splitting OTIO files. + +Each of the many operations it can perform is provided by a +small, simple utility function. These functions also serve +as concise examples of how OTIO can be used to perform common +workflow tasks.""" import argparse import os From 7f169dde4187a29aca54a7fb6c52bbdf02e486c3 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 12 Sep 2022 07:57:55 -0700 Subject: [PATCH 07/18] Add support for Python 2 Co-authored-by: apetrynet Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index 7eae27e7b..f5822391c 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -15,7 +15,13 @@ import os import re import sys -import urllib.request + +try: + from urllib.request import urlopen + +except ImportError: + # Python2 + from urllib2 import urlopen from copy import deepcopy @@ -591,7 +597,7 @@ def copy_media(url, destination_path): data = open(url, "rb").read() else: print("DOWNLOADING: {}".format(url)) - data = urllib.request.urlopen(url).read() + data = urlopen(url).read() open(destination_path, "wb").write(data) return destination_path From 66b8891a7fa52ed74d81fc56c25970f3149136c8 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 12 Sep 2022 08:15:16 -0700 Subject: [PATCH 08/18] Lint. Signed-off-by: Joshua Minor --- src/py-opentimelineio/opentimelineio/console/otiotool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index f5822391c..ccca7e61e 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -17,7 +17,7 @@ import sys try: - from urllib.request import urlopen + from urllib.request import urlopen except ImportError: # Python2 From 37171653d8d25f1ef825ca41d93d32b2b7dd21b7 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 12 Sep 2022 08:51:11 -0700 Subject: [PATCH 09/18] Added some tests for otiotool Signed-off-by: Joshua Minor --- tests/test_console.py | 183 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/tests/test_console.py b/tests/test_console.py index 33c2626c0..bd18ab58a 100755 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -32,6 +32,8 @@ SAMPLE_DATA_DIR = os.path.join(os.path.dirname(__file__), "sample_data") SCREENING_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "screening_example.edl") +PREMIERE_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "premiere_example.xml") +MULTITRACK_PATH = os.path.join(SAMPLE_DATA_DIR, "multitrack.otio") def CreateShelloutTest(cl): @@ -310,5 +312,186 @@ def test_basic(self): OTIOPlugInfoTest_ShellOut = CreateShelloutTest(OTIOStatTest) +class OTIOToolTest(ConsoleTester, unittest.TestCase): + test_module = otio_console.otiotool + + def test_list_clips(self): + sys.argv = [ + 'otiotool', + '-i', SCREENING_EXAMPLE_PATH, + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: Example_Screening.01 + CLIP: ZZ100_501 (LAY3) + CLIP: ZZ100_502A (LAY3) + CLIP: ZZ100_503A (LAY1) + CLIP: ZZ100_504C (LAY1) + CLIP: ZZ100_504B (LAY1) + CLIP: ZZ100_507C (LAY2) + CLIP: ZZ100_508 (LAY2) + CLIP: ZZ100_510 (LAY1) + CLIP: ZZ100_510B (LAY1) +""", sys.stdout.getvalue()) + + def test_list_tracks(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--list-tracks' + ] + self.run_test() + self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 +TRACK: Sequence (Video) +TRACK: Sequence 2 (Video) +TRACK: Sequence 3 (Video) +""", sys.stdout.getvalue()) + + def test_list_tracks_and_clips(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--list-tracks', + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 +TRACK: Sequence (Video) + CLIP: tech.fux (loop)-HD.mp4 + CLIP: out-b (loop)-HD.mp4 + CLIP: brokchrd (loop)-HD.mp4 +TRACK: Sequence 2 (Video) + CLIP: t-hawk (loop)-HD.mp4 +TRACK: Sequence 3 (Video) + CLIP: KOLL-HD.mp4 +""", sys.stdout.getvalue()) + + def test_video_only(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--video-only', + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: sc01_sh010_layerA + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh020_anim.mov + CLIP: sc01_sh030_anim.mov + CLIP: test_title + CLIP: sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov +""", sys.stdout.getvalue()) + + def test_audio_only(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--audio-only', + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: sc01_sh010_layerA + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov + CLIP: sc01_placeholder.wav + CLIP: track_08.wav + CLIP: sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov +""", sys.stdout.getvalue()) + + def test_only_tracks_with_name(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--only-tracks-with-name', 'Sequence 3', + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 + CLIP: KOLL-HD.mp4 +""", sys.stdout.getvalue()) + + def test_only_tracks_with_index(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--only-tracks-with-index', 3, + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 + CLIP: KOLL-HD.mp4 +""", sys.stdout.getvalue()) + + def test_only_tracks_with_index2(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--only-tracks-with-index', 2, 3, + '--list-clips' + ] + self.run_test() + self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 + CLIP: t-hawk (loop)-HD.mp4 + CLIP: KOLL-HD.mp4 +""", sys.stdout.getvalue()) + + def test_only_clips_with_name(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--list-clips', + '--list-tracks', + '--only-clips-with-name', 'sc01_sh010_anim.mov' + ] + self.run_test() + self.assertEqual("""TIMELINE: sc01_sh010_layerA +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Video) +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) +TRACK: (Audio) +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov +""", sys.stdout.getvalue()) + + def test_only_clips_with_regex(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--list-clips', + '--list-tracks', + '--only-clips-with-name-regex', 'anim' + ] + self.run_test() + self.assertEqual("""TIMELINE: sc01_sh010_layerA +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Video) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh020_anim.mov + CLIP: sc01_sh030_anim.mov +TRACK: (Video) +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) +TRACK: (Audio) +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov +""", sys.stdout.getvalue()) + + if __name__ == '__main__': unittest.main() From 0dedb3b6fa42017340f9d23ed126ae86ba8c5ce9 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 12 Sep 2022 12:12:49 -0700 Subject: [PATCH 10/18] Generate a name for stacked and concatenated timelines. Signed-off-by: Joshua Minor --- src/py-opentimelineio/opentimelineio/console/otiotool.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index ccca7e61e..5bd874b37 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -462,7 +462,8 @@ def stack_timelines(timelines): """Return a single timeline with all of the tracks from all of the input timelines stacked on top of each other. The resulting timeline should be as long as the longest input timeline.""" - stacked_timeline = otio.schema.Timeline() + name = "Stacked {} Timelines".format(len(timelines)) + stacked_timeline = otio.schema.Timeline(name) for timeline in timelines: stacked_timeline.tracks.extend(deepcopy(timeline.tracks[:])) return stacked_timeline @@ -472,10 +473,14 @@ def concatenate_timelines(timelines): """Return a single timeline with all of the input timelines concatenated end-to-end. The resulting timeline should be as long as the sum of the durations of the input timelines.""" + name = "Concatenated {} Timelines".format(len(timelines)) concatenated_track = otio.schema.Track() for timeline in timelines: concatenated_track.append(deepcopy(timeline.tracks)) - concatenated_timeline = otio.schema.Timeline(tracks=[concatenated_track]) + concatenated_timeline = otio.schema.Timeline( + name=name, + tracks=[concatenated_track] + ) return concatenated_timeline From 406307eeae335aadf58142d45b11760b738c5300 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 12 Sep 2022 12:13:20 -0700 Subject: [PATCH 11/18] Trim now works even when there is no global_start_time. Signed-off-by: Joshua Minor --- src/py-opentimelineio/opentimelineio/console/otiotool.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index 5bd874b37..b9d3c0c09 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -537,7 +537,10 @@ def trim_timeline(start, end, timeline): times given. Each of the start and end times can be specified as either a timecode string (e.g. "HH:MM:SS:FF") or a string with a floating point value measured in seconds.""" - rate = timeline.global_start_time.rate + if timeline.global_start_time is not None: + rate = timeline.global_start_time.rate + else: + rate = timeline.duration().rate try: start_time = time_from_string(start, rate) end_time = time_from_string(end, rate) @@ -653,7 +656,7 @@ def copy_media_to_folder(timeline, folder): def print_timeline_stats(timeline): """Print some statistics about the given timeline.""" - print("Name: {}", timeline.name) + print("Name: {}".format(timeline.name)) trimmed_range = timeline.tracks.trimmed_range() print("Start: {}\nEnd: {}\nDuration: {}".format( otio.opentime.to_timecode(trimmed_range.start_time), From db4c1777d6514278d921af37b690c5494ab21dd1 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Mon, 12 Sep 2022 12:14:01 -0700 Subject: [PATCH 12/18] Lots more tests for otiotool. Signed-off-by: Joshua Minor --- tests/test_console.py | 415 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 409 insertions(+), 6 deletions(-) diff --git a/tests/test_console.py b/tests/test_console.py index bd18ab58a..dd27751db 100755 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -34,6 +34,7 @@ SCREENING_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "screening_example.edl") PREMIERE_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "premiere_example.xml") MULTITRACK_PATH = os.path.join(SAMPLE_DATA_DIR, "multitrack.otio") +TRANSITION_PATH = os.path.join(SAMPLE_DATA_DIR, "transition.otio") def CreateShelloutTest(cl): @@ -315,6 +316,19 @@ def test_basic(self): class OTIOToolTest(ConsoleTester, unittest.TestCase): test_module = otio_console.otiotool + def test_list_tracks(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--list-tracks' + ] + self.run_test() + self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 +TRACK: Sequence (Video) +TRACK: Sequence 2 (Video) +TRACK: Sequence 3 (Video) +""", sys.stdout.getvalue()) + def test_list_clips(self): sys.argv = [ 'otiotool', @@ -334,36 +348,163 @@ def test_list_clips(self): CLIP: ZZ100_510B (LAY1) """, sys.stdout.getvalue()) - def test_list_tracks(self): + def test_list_markers(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--list-markers' + ] + self.run_test() + self.maxDiff = None + self.assertEqual( + ("TIMELINE: sc01_sh010_layerA\n" + " MARKER: global: 00:00:03:23 local: 00:00:03:23 duration: 0.0 color: RED name: My MArker 1\n" # noqa: E501 line too long + " MARKER: global: 00:00:16:12 local: 00:00:16:12 duration: 0.0 color: RED name: dsf\n" # noqa: E501 line too long + " MARKER: global: 00:00:09:28 local: 00:00:09:28 duration: 0.0 color: RED name: \n" # noqa: E501 line too long + " MARKER: global: 00:00:13:05 local: 00:00:02:13 duration: 0.0 color: RED name: \n"), # noqa: E501 line too long + sys.stdout.getvalue()) + + def test_list_tracks_and_clips(self): sys.argv = [ 'otiotool', '-i', MULTITRACK_PATH, - '--list-tracks' + '--list-tracks', + '--list-clips' ] self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 TRACK: Sequence (Video) + CLIP: tech.fux (loop)-HD.mp4 + CLIP: out-b (loop)-HD.mp4 + CLIP: brokchrd (loop)-HD.mp4 TRACK: Sequence 2 (Video) + CLIP: t-hawk (loop)-HD.mp4 TRACK: Sequence 3 (Video) + CLIP: KOLL-HD.mp4 """, sys.stdout.getvalue()) - def test_list_tracks_and_clips(self): + def test_list_tracks_and_clips_and_media(self): sys.argv = [ 'otiotool', '-i', MULTITRACK_PATH, '--list-tracks', - '--list-clips' + '--list-clips', + '--list-media' ] self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 TRACK: Sequence (Video) CLIP: tech.fux (loop)-HD.mp4 + MEDIA: None CLIP: out-b (loop)-HD.mp4 + MEDIA: None CLIP: brokchrd (loop)-HD.mp4 + MEDIA: None TRACK: Sequence 2 (Video) CLIP: t-hawk (loop)-HD.mp4 + MEDIA: None TRACK: Sequence 3 (Video) CLIP: KOLL-HD.mp4 + MEDIA: None +""", sys.stdout.getvalue()) + + def test_list_tracks_and_clips_and_media_and_markers(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--list-tracks', + '--list-clips', + '--list-media', + '--list-markers' + ] + self.run_test() + self.assertEqual( + ("TIMELINE: sc01_sh010_layerA\n" + " MARKER: global: 00:00:03:23 local: 00:00:03:23 duration: 0.0 color: RED name: My MArker 1\n" # noqa E501 line too long + " MARKER: global: 00:00:16:12 local: 00:00:16:12 duration: 0.0 color: RED name: dsf\n" # noqa E501 line too long + " MARKER: global: 00:00:09:28 local: 00:00:09:28 duration: 0.0 color: RED name: \n" # noqa E501 line too long + "TRACK: (Video)\n" + " CLIP: sc01_sh010_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n" + "TRACK: (Video)\n" + " CLIP: sc01_sh010_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n" + " CLIP: sc01_sh020_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh020_anim.mov\n" + " CLIP: sc01_sh030_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh030_anim.mov\n" + " MARKER: global: 00:00:13:05 local: 00:00:02:13 duration: 0.0 color: RED name: \n" # noqa E501 line too long + "TRACK: (Video)\n" + " CLIP: test_title\n" + " MEDIA: None\n" + "TRACK: (Video)\n" + " CLIP: sc01_master_layerA_sh030_temp.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_master_layerA_sh030_temp.mov\n" # noqa E501 line too long + " CLIP: sc01_sh010_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n" + "TRACK: (Audio)\n" + " CLIP: sc01_sh010_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n" + " CLIP: sc01_sh010_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n" + "TRACK: (Audio)\n" + " CLIP: sc01_placeholder.wav\n" + " MEDIA: file://localhost/D%3a/media/sc01_placeholder.wav\n" + "TRACK: (Audio)\n" + " CLIP: track_08.wav\n" + " MEDIA: file://localhost/D%3a/media/track_08.wav\n" + "TRACK: (Audio)\n" + " CLIP: sc01_master_layerA_sh030_temp.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_master_layerA_sh030_temp.mov\n" # noqa E501 line too long + " CLIP: sc01_sh010_anim.mov\n" + " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n"), + sys.stdout.getvalue()) + + def test_verify_media(self): + sys.argv = [ + 'otiotool', + '-i', PREMIERE_EXAMPLE_PATH, + '--list-tracks', + '--list-clips', + '--list-media', + '--verify-media' + ] + self.run_test() + self.assertEqual("""TIMELINE: sc01_sh010_layerA +TRACK: (Video) + CLIP: sc01_sh010_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov +TRACK: (Video) + CLIP: sc01_sh010_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov + CLIP: sc01_sh020_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh020_anim.mov + CLIP: sc01_sh030_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh030_anim.mov +TRACK: (Video) + CLIP: test_title + MEDIA: None +TRACK: (Video) + CLIP: sc01_master_layerA_sh030_temp.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_placeholder.wav + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_placeholder.wav +TRACK: (Audio) + CLIP: track_08.wav + MEDIA NOT FOUND: file://localhost/D%3a/media/track_08.wav +TRACK: (Audio) + CLIP: sc01_master_layerA_sh030_temp.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov + MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov """, sys.stdout.getvalue()) def test_video_only(self): @@ -417,7 +558,7 @@ def test_only_tracks_with_index(self): sys.argv = [ 'otiotool', '-i', MULTITRACK_PATH, - '--only-tracks-with-index', 3, + '--only-tracks-with-index', '3', '--list-clips' ] self.run_test() @@ -429,7 +570,7 @@ def test_only_tracks_with_index2(self): sys.argv = [ 'otiotool', '-i', MULTITRACK_PATH, - '--only-tracks-with-index', 2, 3, + '--only-tracks-with-index', '2', '3', '--list-clips' ] self.run_test() @@ -492,6 +633,268 @@ def test_only_clips_with_regex(self): CLIP: sc01_sh010_anim.mov """, sys.stdout.getvalue()) + def test_remote_transition(self): + sys.argv = [ + 'otiotool', + '-i', TRANSITION_PATH, + '-o', '-', + '--remove-transitions' + ] + self.run_test() + self.assertNotIn('"OTIO_SCHEMA": "Transition.', sys.stdout.getvalue()) + + def test_trim(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--trim', '20', '40', + '--list-clips', + '--inspect', 't-hawk' + ] + self.run_test() + self.assertEqual( + ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" + " ITEM: t-hawk (loop)-HD.mp4 ()\n" # noqa E501 line too long + " source_range: TimeRange(RationalTime(0, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " trimmed_range: TimeRange(RationalTime(0, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " visible_range: TimeRange(RationalTime(0, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " range_in_parent: TimeRange(RationalTime(2, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " trimmed range in timeline: TimeRange(RationalTime(2, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " visible range in timeline: TimeRange(RationalTime(2, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " range in Sequence 2 (): TimeRange(RationalTime(2, 24), RationalTime(478, 24))\n" # noqa E501 line too long + " range in NestedScope (): TimeRange(RationalTime(2, 24), RationalTime(478, 24))\n" # noqa E501 line too long + "TIMELINE: OTIO TEST - multitrack.Exported.01\n" + " CLIP: tech.fux (loop)-HD.mp4\n" + " CLIP: out-b (loop)-HD.mp4\n" + " CLIP: t-hawk (loop)-HD.mp4\n"), + sys.stdout.getvalue()) + + def test_flatten(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--flatten', 'video', + '--list-clips', + '--list-tracks', + '--inspect', 'out-b' + ] + self.run_test() + self.assertEqual( + ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" + " ITEM: out-b (loop)-HD.mp4 ()\n" # noqa E501 line too long + " source_range: TimeRange(RationalTime(159, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " trimmed_range: TimeRange(RationalTime(159, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " visible_range: TimeRange(RationalTime(159, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " range_in_parent: TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " trimmed range in timeline: TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " visible range in timeline: TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " range in Flattened (): TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " range in NestedScope (): TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + "TIMELINE: OTIO TEST - multitrack.Exported.01\n" + "TRACK: Flattened (Video)\n" + " CLIP: tech.fux (loop)-HD.mp4\n" + " CLIP: t-hawk (loop)-HD.mp4\n" + " CLIP: out-b (loop)-HD.mp4\n" + " CLIP: KOLL-HD.mp4\n" + " CLIP: brokchrd (loop)-HD.mp4\n"), + sys.stdout.getvalue()) + + def test_keep_flattened_tracks(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--flatten', 'video', + '--keep-flattened-tracks', + '--list-clips', + '--list-tracks', + '--inspect', 'out-b' + ] + self.run_test() + self.assertEqual( + ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" + " ITEM: out-b (loop)-HD.mp4 ()\n" # noqa E501 line too long + " source_range: TimeRange(RationalTime(0, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " trimmed_range: TimeRange(RationalTime(0, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " visible_range: TimeRange(RationalTime(0, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " range_in_parent: TimeRange(RationalTime(803, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " trimmed range in timeline: TimeRange(RationalTime(803, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " visible range in timeline: TimeRange(RationalTime(803, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " range in Sequence (): TimeRange(RationalTime(803, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " range in NestedScope (): TimeRange(RationalTime(803, 24), RationalTime(722, 24))\n" # noqa E501 line too long + " ITEM: out-b (loop)-HD.mp4 ()\n" # noqa E501 line too long + " source_range: TimeRange(RationalTime(159, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " trimmed_range: TimeRange(RationalTime(159, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " visible_range: TimeRange(RationalTime(159, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " range_in_parent: TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " trimmed range in timeline: TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " visible range in timeline: TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " range in Flattened (): TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + " range in NestedScope (): TimeRange(RationalTime(962, 24), RationalTime(236, 24))\n" # noqa E501 line too long + "TIMELINE: OTIO TEST - multitrack.Exported.01\n" + "TRACK: Sequence (Video)\n" + " CLIP: tech.fux (loop)-HD.mp4\n" + " CLIP: out-b (loop)-HD.mp4\n" + " CLIP: brokchrd (loop)-HD.mp4\n" + "TRACK: Sequence 2 (Video)\n" + " CLIP: t-hawk (loop)-HD.mp4\n" + "TRACK: Sequence 3 (Video)\n" + " CLIP: KOLL-HD.mp4\n" + "TRACK: Flattened (Video)\n" + " CLIP: tech.fux (loop)-HD.mp4\n" + " CLIP: t-hawk (loop)-HD.mp4\n" + " CLIP: out-b (loop)-HD.mp4\n" + " CLIP: KOLL-HD.mp4\n" + " CLIP: brokchrd (loop)-HD.mp4\n"), + sys.stdout.getvalue()) + + def test_stack(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, PREMIERE_EXAMPLE_PATH, + '--stack', + '--list-clips', + '--list-tracks', + '--stats' + ] + self.run_test() + self.maxDiff = None + self.assertEqual("""Name: Stacked 2 Timelines +Start: 00:00:00:00 +End: 00:02:16:18 +Duration: 00:02:16:18 +TIMELINE: Stacked 2 Timelines +TRACK: Sequence (Video) + CLIP: tech.fux (loop)-HD.mp4 + CLIP: out-b (loop)-HD.mp4 + CLIP: brokchrd (loop)-HD.mp4 +TRACK: Sequence 2 (Video) + CLIP: t-hawk (loop)-HD.mp4 +TRACK: Sequence 3 (Video) + CLIP: KOLL-HD.mp4 +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Video) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh020_anim.mov + CLIP: sc01_sh030_anim.mov +TRACK: (Video) + CLIP: test_title +TRACK: (Video) + CLIP: sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_placeholder.wav +TRACK: (Audio) + CLIP: track_08.wav +TRACK: (Audio) + CLIP: sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov +""", sys.stdout.getvalue()) + + def test_concat(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, PREMIERE_EXAMPLE_PATH, + '--concat', + '--list-clips', + '--list-tracks', + '--stats' + ] + self.run_test() + self.maxDiff = None + self.assertEqual("""Name: Concatenated 2 Timelines +Start: 00:00:00:00 +End: 00:02:59:03 +Duration: 00:02:59:03 +TIMELINE: Concatenated 2 Timelines +TRACK: (Video) +TRACK: Sequence (Video) + CLIP: tech.fux (loop)-HD.mp4 + CLIP: out-b (loop)-HD.mp4 + CLIP: brokchrd (loop)-HD.mp4 +TRACK: Sequence 2 (Video) + CLIP: t-hawk (loop)-HD.mp4 +TRACK: Sequence 3 (Video) + CLIP: KOLL-HD.mp4 +TRACK: (Video) + CLIP: sc01_sh010_anim.mov +TRACK: (Video) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh020_anim.mov + CLIP: sc01_sh030_anim.mov +TRACK: (Video) + CLIP: test_title +TRACK: (Video) + CLIP: sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_sh010_anim.mov + CLIP: sc01_sh010_anim.mov +TRACK: (Audio) + CLIP: sc01_placeholder.wav +TRACK: (Audio) + CLIP: track_08.wav +TRACK: (Audio) + CLIP: sc01_master_layerA_sh030_temp.mov + CLIP: sc01_sh010_anim.mov +""", sys.stdout.getvalue()) + + def test_redact(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--redact', + '--list-clips', + '--list-tracks' + ] + self.run_test() + self.assertEqual("""TIMELINE: Timeline #1 +TRACK: Track #1 (Video) + CLIP: Clip #1 + CLIP: Clip #2 + CLIP: Clip #3 +TRACK: Track #2 (Video) + CLIP: Clip #4 +TRACK: Track #3 (Video) + CLIP: Clip #5 +""", sys.stdout.getvalue()) + + def test_stats(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--stats' + ] + self.run_test() + self.assertEqual("""Name: OTIO TEST - multitrack.Exported.01 +Start: 00:00:00:00 +End: 00:02:16:18 +Duration: 00:02:16:18 +""", sys.stdout.getvalue()) + + def test_inspect(self): + sys.argv = [ + 'otiotool', + '-i', MULTITRACK_PATH, + '--inspect', 'KOLL' + ] + self.run_test() + self.assertEqual( + ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" + " ITEM: KOLL-HD.mp4 ()\n" + " source_range: TimeRange(RationalTime(0, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " trimmed_range: TimeRange(RationalTime(0, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " visible_range: TimeRange(RationalTime(0, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " range_in_parent: TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " trimmed range in timeline: TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " visible range in timeline: TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " range in Sequence 3 (): TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n" # noqa E501 line too long + " range in NestedScope (): TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n"), # noqa E501 line too long + sys.stdout.getvalue()) + if __name__ == '__main__': unittest.main() From 23768c6004f59d85b7289daed7e238fa9f581d6a Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 13 Sep 2022 07:20:07 -0700 Subject: [PATCH 13/18] Install otiotool as a console script along with otiocat and friends. Signed-off-by: Joshua Minor --- setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index a61b76dec..21dad61cd 100755 --- a/setup.py +++ b/setup.py @@ -345,11 +345,12 @@ def test_otio(): ], entry_points={ 'console_scripts': [ - 'otioview = opentimelineview.console:main', 'otiocat = opentimelineio.console.otiocat:main', 'otioconvert = opentimelineio.console.otioconvert:main', - 'otiostat = opentimelineio.console.otiostat:main', 'otiopluginfo = opentimelineio.console.otiopluginfo:main', + 'otiostat = opentimelineio.console.otiostat:main', + 'otiotool = opentimelineio.console.otiotool:main', + 'otioview = opentimelineview.console:main', ( 'otioautogen_serialized_schema_docs = ' 'opentimelineio.console.autogen_serialized_datamodel:main' From 7d6c023578be4877428a77ee6ddf8ef310088ab7 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 13 Sep 2022 07:20:52 -0700 Subject: [PATCH 14/18] Added OTIOToolTest_ShellOut Signed-off-by: Joshua Minor --- tests/test_console.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_console.py b/tests/test_console.py index dd27751db..1a5950240 100755 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -896,5 +896,8 @@ def test_inspect(self): sys.stdout.getvalue()) +OTIOToolTest_ShellOut = CreateShelloutTest(OTIOToolTest) + + if __name__ == '__main__': unittest.main() From f3d8d63d44afe6dcf1199408a2ad8adbda2102e4 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 13 Sep 2022 08:09:15 -0700 Subject: [PATCH 15/18] Python 2 print_function Signed-off-by: Joshua Minor --- src/py-opentimelineio/opentimelineio/console/otiotool.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index b9d3c0c09..a0e70f271 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -11,6 +11,7 @@ as concise examples of how OTIO can be used to perform common workflow tasks.""" +from __future__ import print_function import argparse import os import re From 4dea1b0c99d5300e9d9a1c23035bed893c16b5bc Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 13 Sep 2022 08:45:07 -0700 Subject: [PATCH 16/18] Deal with different line-endings on Windows. Signed-off-by: Joshua Minor --- tests/test_console.py | 100 ++++++++++++++++++++++-------------------- 1 file changed, 53 insertions(+), 47 deletions(-) diff --git a/tests/test_console.py b/tests/test_console.py index 1a5950240..7a942ec79 100755 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -97,6 +97,13 @@ def run_test(self): else: self.test_module.main() + if platform.system() == 'Windows': + # Normalize line-endings for assertEqual(expected, actual) + out = sys.stdout.getvalue().replace('\r\n', '\n') + err = sys.stderr.getvalue().replace('\r\n', '\n') + + return out, err + def tearDown(self): sys.stdout = self.old_stdout sys.stderr = self.old_stderr @@ -322,12 +329,12 @@ def test_list_tracks(self): '-i', MULTITRACK_PATH, '--list-tracks' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 TRACK: Sequence (Video) TRACK: Sequence 2 (Video) TRACK: Sequence 3 (Video) -""", sys.stdout.getvalue()) +""", out) def test_list_clips(self): sys.argv = [ @@ -335,7 +342,7 @@ def test_list_clips(self): '-i', SCREENING_EXAMPLE_PATH, '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: Example_Screening.01 CLIP: ZZ100_501 (LAY3) CLIP: ZZ100_502A (LAY3) @@ -346,7 +353,7 @@ def test_list_clips(self): CLIP: ZZ100_508 (LAY2) CLIP: ZZ100_510 (LAY1) CLIP: ZZ100_510B (LAY1) -""", sys.stdout.getvalue()) +""", out) def test_list_markers(self): sys.argv = [ @@ -354,15 +361,14 @@ def test_list_markers(self): '-i', PREMIERE_EXAMPLE_PATH, '--list-markers' ] - self.run_test() - self.maxDiff = None + out, err = self.run_test() self.assertEqual( ("TIMELINE: sc01_sh010_layerA\n" " MARKER: global: 00:00:03:23 local: 00:00:03:23 duration: 0.0 color: RED name: My MArker 1\n" # noqa: E501 line too long " MARKER: global: 00:00:16:12 local: 00:00:16:12 duration: 0.0 color: RED name: dsf\n" # noqa: E501 line too long " MARKER: global: 00:00:09:28 local: 00:00:09:28 duration: 0.0 color: RED name: \n" # noqa: E501 line too long " MARKER: global: 00:00:13:05 local: 00:00:02:13 duration: 0.0 color: RED name: \n"), # noqa: E501 line too long - sys.stdout.getvalue()) + out) def test_list_tracks_and_clips(self): sys.argv = [ @@ -371,7 +377,7 @@ def test_list_tracks_and_clips(self): '--list-tracks', '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 TRACK: Sequence (Video) CLIP: tech.fux (loop)-HD.mp4 @@ -381,7 +387,7 @@ def test_list_tracks_and_clips(self): CLIP: t-hawk (loop)-HD.mp4 TRACK: Sequence 3 (Video) CLIP: KOLL-HD.mp4 -""", sys.stdout.getvalue()) +""", out) def test_list_tracks_and_clips_and_media(self): sys.argv = [ @@ -391,7 +397,7 @@ def test_list_tracks_and_clips_and_media(self): '--list-clips', '--list-media' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 TRACK: Sequence (Video) CLIP: tech.fux (loop)-HD.mp4 @@ -406,7 +412,7 @@ def test_list_tracks_and_clips_and_media(self): TRACK: Sequence 3 (Video) CLIP: KOLL-HD.mp4 MEDIA: None -""", sys.stdout.getvalue()) +""", out) def test_list_tracks_and_clips_and_media_and_markers(self): sys.argv = [ @@ -417,7 +423,7 @@ def test_list_tracks_and_clips_and_media_and_markers(self): '--list-media', '--list-markers' ] - self.run_test() + out, err = self.run_test() self.assertEqual( ("TIMELINE: sc01_sh010_layerA\n" " MARKER: global: 00:00:03:23 local: 00:00:03:23 duration: 0.0 color: RED name: My MArker 1\n" # noqa E501 line too long @@ -458,7 +464,7 @@ def test_list_tracks_and_clips_and_media_and_markers(self): " MEDIA: file://localhost/D%3a/media/sc01_master_layerA_sh030_temp.mov\n" # noqa E501 line too long " CLIP: sc01_sh010_anim.mov\n" " MEDIA: file://localhost/D%3a/media/sc01_sh010_anim.mov\n"), - sys.stdout.getvalue()) + out) def test_verify_media(self): sys.argv = [ @@ -469,7 +475,7 @@ def test_verify_media(self): '--list-media', '--verify-media' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: sc01_sh010_layerA TRACK: (Video) CLIP: sc01_sh010_anim.mov @@ -505,7 +511,7 @@ def test_verify_media(self): MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_master_layerA_sh030_temp.mov CLIP: sc01_sh010_anim.mov MEDIA NOT FOUND: file://localhost/D%3a/media/sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_video_only(self): sys.argv = [ @@ -514,7 +520,7 @@ def test_video_only(self): '--video-only', '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: sc01_sh010_layerA CLIP: sc01_sh010_anim.mov CLIP: sc01_sh010_anim.mov @@ -523,7 +529,7 @@ def test_video_only(self): CLIP: test_title CLIP: sc01_master_layerA_sh030_temp.mov CLIP: sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_audio_only(self): sys.argv = [ @@ -532,7 +538,7 @@ def test_audio_only(self): '--audio-only', '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: sc01_sh010_layerA CLIP: sc01_sh010_anim.mov CLIP: sc01_sh010_anim.mov @@ -540,7 +546,7 @@ def test_audio_only(self): CLIP: track_08.wav CLIP: sc01_master_layerA_sh030_temp.mov CLIP: sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_only_tracks_with_name(self): sys.argv = [ @@ -549,10 +555,10 @@ def test_only_tracks_with_name(self): '--only-tracks-with-name', 'Sequence 3', '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 CLIP: KOLL-HD.mp4 -""", sys.stdout.getvalue()) +""", out) def test_only_tracks_with_index(self): sys.argv = [ @@ -561,10 +567,10 @@ def test_only_tracks_with_index(self): '--only-tracks-with-index', '3', '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 CLIP: KOLL-HD.mp4 -""", sys.stdout.getvalue()) +""", out) def test_only_tracks_with_index2(self): sys.argv = [ @@ -573,11 +579,11 @@ def test_only_tracks_with_index2(self): '--only-tracks-with-index', '2', '3', '--list-clips' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: OTIO TEST - multitrack.Exported.01 CLIP: t-hawk (loop)-HD.mp4 CLIP: KOLL-HD.mp4 -""", sys.stdout.getvalue()) +""", out) def test_only_clips_with_name(self): sys.argv = [ @@ -587,7 +593,7 @@ def test_only_clips_with_name(self): '--list-tracks', '--only-clips-with-name', 'sc01_sh010_anim.mov' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: sc01_sh010_layerA TRACK: (Video) CLIP: sc01_sh010_anim.mov @@ -603,7 +609,7 @@ def test_only_clips_with_name(self): TRACK: (Audio) TRACK: (Audio) CLIP: sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_only_clips_with_regex(self): sys.argv = [ @@ -613,7 +619,7 @@ def test_only_clips_with_regex(self): '--list-tracks', '--only-clips-with-name-regex', 'anim' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: sc01_sh010_layerA TRACK: (Video) CLIP: sc01_sh010_anim.mov @@ -631,7 +637,7 @@ def test_only_clips_with_regex(self): TRACK: (Audio) TRACK: (Audio) CLIP: sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_remote_transition(self): sys.argv = [ @@ -640,8 +646,8 @@ def test_remote_transition(self): '-o', '-', '--remove-transitions' ] - self.run_test() - self.assertNotIn('"OTIO_SCHEMA": "Transition.', sys.stdout.getvalue()) + out, err = self.run_test() + self.assertNotIn('"OTIO_SCHEMA": "Transition.', out) def test_trim(self): sys.argv = [ @@ -651,7 +657,7 @@ def test_trim(self): '--list-clips', '--inspect', 't-hawk' ] - self.run_test() + out, err = self.run_test() self.assertEqual( ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" " ITEM: t-hawk (loop)-HD.mp4 ()\n" # noqa E501 line too long @@ -667,7 +673,7 @@ def test_trim(self): " CLIP: tech.fux (loop)-HD.mp4\n" " CLIP: out-b (loop)-HD.mp4\n" " CLIP: t-hawk (loop)-HD.mp4\n"), - sys.stdout.getvalue()) + out) def test_flatten(self): sys.argv = [ @@ -678,7 +684,7 @@ def test_flatten(self): '--list-tracks', '--inspect', 'out-b' ] - self.run_test() + out, err = self.run_test() self.assertEqual( ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" " ITEM: out-b (loop)-HD.mp4 ()\n" # noqa E501 line too long @@ -697,7 +703,7 @@ def test_flatten(self): " CLIP: out-b (loop)-HD.mp4\n" " CLIP: KOLL-HD.mp4\n" " CLIP: brokchrd (loop)-HD.mp4\n"), - sys.stdout.getvalue()) + out) def test_keep_flattened_tracks(self): sys.argv = [ @@ -709,7 +715,7 @@ def test_keep_flattened_tracks(self): '--list-tracks', '--inspect', 'out-b' ] - self.run_test() + out, err = self.run_test() self.assertEqual( ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" " ITEM: out-b (loop)-HD.mp4 ()\n" # noqa E501 line too long @@ -745,7 +751,7 @@ def test_keep_flattened_tracks(self): " CLIP: out-b (loop)-HD.mp4\n" " CLIP: KOLL-HD.mp4\n" " CLIP: brokchrd (loop)-HD.mp4\n"), - sys.stdout.getvalue()) + out) def test_stack(self): sys.argv = [ @@ -756,7 +762,7 @@ def test_stack(self): '--list-tracks', '--stats' ] - self.run_test() + out, err = self.run_test() self.maxDiff = None self.assertEqual("""Name: Stacked 2 Timelines Start: 00:00:00:00 @@ -792,7 +798,7 @@ def test_stack(self): TRACK: (Audio) CLIP: sc01_master_layerA_sh030_temp.mov CLIP: sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_concat(self): sys.argv = [ @@ -803,7 +809,7 @@ def test_concat(self): '--list-tracks', '--stats' ] - self.run_test() + out, err = self.run_test() self.maxDiff = None self.assertEqual("""Name: Concatenated 2 Timelines Start: 00:00:00:00 @@ -840,7 +846,7 @@ def test_concat(self): TRACK: (Audio) CLIP: sc01_master_layerA_sh030_temp.mov CLIP: sc01_sh010_anim.mov -""", sys.stdout.getvalue()) +""", out) def test_redact(self): sys.argv = [ @@ -850,7 +856,7 @@ def test_redact(self): '--list-clips', '--list-tracks' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""TIMELINE: Timeline #1 TRACK: Track #1 (Video) CLIP: Clip #1 @@ -860,7 +866,7 @@ def test_redact(self): CLIP: Clip #4 TRACK: Track #3 (Video) CLIP: Clip #5 -""", sys.stdout.getvalue()) +""", out) def test_stats(self): sys.argv = [ @@ -868,12 +874,12 @@ def test_stats(self): '-i', MULTITRACK_PATH, '--stats' ] - self.run_test() + out, err = self.run_test() self.assertEqual("""Name: OTIO TEST - multitrack.Exported.01 Start: 00:00:00:00 End: 00:02:16:18 Duration: 00:02:16:18 -""", sys.stdout.getvalue()) +""", out) def test_inspect(self): sys.argv = [ @@ -881,7 +887,7 @@ def test_inspect(self): '-i', MULTITRACK_PATH, '--inspect', 'KOLL' ] - self.run_test() + out, err = self.run_test() self.assertEqual( ("TIMELINE: OTIO TEST - multitrack.Exported.01\n" " ITEM: KOLL-HD.mp4 ()\n" @@ -893,7 +899,7 @@ def test_inspect(self): " visible range in timeline: TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n" # noqa E501 line too long " range in Sequence 3 (): TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n" # noqa E501 line too long " range in NestedScope (): TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n"), # noqa E501 line too long - sys.stdout.getvalue()) + out) OTIOToolTest_ShellOut = CreateShelloutTest(OTIOToolTest) From 0dbbf824d5dc099346429b88bff7176c41e3d529 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 13 Sep 2022 08:52:02 -0700 Subject: [PATCH 17/18] Oops Signed-off-by: Joshua Minor --- tests/test_console.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_console.py b/tests/test_console.py index 7a942ec79..e24c0bc60 100755 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -97,12 +97,16 @@ def run_test(self): else: self.test_module.main() + # pre-fetch these strings for easy access + stdout = sys.stdout.getvalue() + stderr = sys.stderr.getvalue() + if platform.system() == 'Windows': # Normalize line-endings for assertEqual(expected, actual) - out = sys.stdout.getvalue().replace('\r\n', '\n') - err = sys.stderr.getvalue().replace('\r\n', '\n') + stdout = stdout.replace('\r\n', '\n') + stderr = stderr.replace('\r\n', '\n') - return out, err + return stdout, stderr def tearDown(self): sys.stdout = self.old_stdout From 1c3ab66ebe464e5cef7f7a42c87abb4b43d4a0b1 Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Tue, 13 Sep 2022 09:54:26 -0700 Subject: [PATCH 18/18] Redact media reference metadata even when there's no target_url Signed-off-by: Joshua Minor --- src/py-opentimelineio/opentimelineio/console/otiotool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index a0e70f271..2b8625e99 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -597,7 +597,7 @@ def redact_timeline(timeline): has_target_url = hasattr(media_reference, 'target_url') if has_target_url and media_reference.target_url: media_reference.target_url = "URL #{}".format(counter) - media_reference.metadata.clear() + media_reference.metadata.clear() def copy_media(url, destination_path):