From 33e0d1a5185ef49f0077bb02d6066275afc35cfb Mon Sep 17 00:00:00 2001 From: Joshua Minor Date: Wed, 23 Nov 2022 13:18:35 -0800 Subject: [PATCH] Added --relink-by-name feature to otiotool (#1475) Signed-off-by: Joshua Minor --- .../opentimelineio/console/otiotool.py | 76 +++++++++++++++++-- tests/test_console.py | 31 +++++++- 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/py-opentimelineio/opentimelineio/console/otiotool.py b/src/py-opentimelineio/opentimelineio/console/otiotool.py index 8f27201e0..8a3ee8193 100755 --- a/src/py-opentimelineio/opentimelineio/console/otiotool.py +++ b/src/py-opentimelineio/opentimelineio/console/otiotool.py @@ -13,6 +13,7 @@ import argparse import os +import pathlib import re import sys @@ -90,6 +91,11 @@ def main(): # Phase 5: Relinking media + if args.relink_by_name: + for timeline in timelines: + for folder in args.relink_by_name: + relink_by_name(timeline, folder) + if args.copy_media_to_folder: for timeline in timelines: copy_media_to_folder(timeline, args.copy_media_to_folder) @@ -162,6 +168,10 @@ def parse_arguments(): performed (in that order) to combine all of the input timeline(s) into one. 4. Relink + The --relink-by-name option, will scan the specified folder(s) looking for + files which match the name of each clip in the input timeline(s). + If matching files are found, clips will be relinked to those files (using + file:// URLs). Clip names are matched to filenames ignoring file extension. If specified, the --copy-media-to-folder option, will copy or download all linked media, and relink the OTIO to reference the local copies. @@ -204,6 +214,7 @@ def parse_arguments(): type=str, nargs='+', required=True, + metavar='PATH(s)', help="""Input file path(s). All formats supported by adapter plugins are supported. Use '-' to read OTIO from standard input.""" ) @@ -224,26 +235,30 @@ def parse_arguments(): parser.add_argument( "--only-tracks-with-name", type=str, - nargs='*', + nargs='+', + metavar='NAME(s)', help="Output tracks with these name(s)" ) parser.add_argument( "--only-tracks-with-index", type=int, - nargs='*', + nargs='+', + metavar='INDEX(es)', help="Output tracks with these indexes" " (1 based, in same order as --list-tracks)" ) parser.add_argument( "--only-clips-with-name", type=str, - nargs='*', + nargs='+', + metavar='NAME(s)', help="Output only clips with these name(s)" ) parser.add_argument( "--only-clips-with-name-regex", type=str, - nargs='*', + nargs='+', + metavar='REGEX(es)', help="Output only clips with names matching the given regex" ) parser.add_argument( @@ -256,6 +271,7 @@ def parse_arguments(): "--trim", type=str, nargs=2, + metavar=('START', 'END'), help="Trim from to as HH:MM:SS:FF timecode or seconds" ) @@ -264,6 +280,7 @@ def parse_arguments(): "-f", "--flatten", choices=['video', 'audio', 'all'], + metavar='TYPE', help="Flatten multiple tracks into one." ) parser.add_argument( @@ -286,9 +303,18 @@ def parse_arguments(): ) # Relink + parser.add_argument( + "--relink-by-name", + type=str, + nargs='+', + metavar='FOLDER(s)', + help="""Scan the specified folder looking for filenames which match + each clip's name. If found, clips are relinked to those files.""" + ) parser.add_argument( "--copy-media-to-folder", type=str, + metavar='FOLDER', help="""Copy or download all linked media to the specified folder and relink all media references to the copies""" ) @@ -337,7 +363,8 @@ def parse_arguments(): parser.add_argument( "--inspect", type=str, - nargs='*', + nargs='+', + metavar='NAME(s)', help="Inspect details of clips with names matching the given regex" ) @@ -346,6 +373,7 @@ def parse_arguments(): "-o", "--output", type=str, + metavar='PATH', help="""Output file. All formats supported by adapter plugins are supported. Use '-' to write OTIO to standard output.""" ) @@ -605,6 +633,44 @@ def copy_media(url, destination_path): return destination_path +def relink_by_name(timeline, path): + """Relink clips in the timeline to media files discovered at the + given folder path.""" + + def _conform_path(p): + # Turn absolute paths into file:// URIs + if os.path.isabs(p): + return pathlib.Path(p).as_uri() + else: + # Leave relative paths as-is + return p + + count = 0 + if os.path.isdir(path): + name_to_url = dict([ + ( + os.path.splitext(x)[0], + _conform_path(os.path.join(path, x)) + ) + for x in os.listdir(path) + ]) + elif os.path.isfile(path): + print((f"ERROR: Cannot relink to '{path}':" + " Please specify a folder instead of a file.")) + return + else: + print(f"ERROR: Cannot relink to '{path}': No such file or folder.") + return + + for clip in timeline.each_clip(): + url = name_to_url.get(clip.name) + if url is not None: + clip.media_reference = otio.schema.ExternalReference(target_url=url) + count += 1 + + print(f"Relinked {count} clips to files in folder {path}") + + def copy_media_to_folder(timeline, folder): """Copy or download all referenced media to this folder, and relink media references to the copies.""" diff --git a/tests/test_console.py b/tests/test_console.py index 6f6d5e11a..a1e2b71c2 100755 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -8,6 +8,7 @@ import os import subprocess import sysconfig +import pathlib import platform import io @@ -20,9 +21,11 @@ import opentimelineio.console as otio_console 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") +PREMIERE_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "premiere_example.xml") +SCREENING_EXAMPLE_PATH = os.path.join(SAMPLE_DATA_DIR, "screening_example.edl") +SIMPLE_CUT_PATH = os.path.join(SAMPLE_DATA_DIR, "simple_cut.otio") TRANSITION_PATH = os.path.join(SAMPLE_DATA_DIR, "transition.otio") @@ -894,6 +897,30 @@ def test_inspect(self): " range in NestedScope (): TimeRange(RationalTime(1198, 24), RationalTime(640, 24))\n"), # noqa E501 line too long out) + def test_relink(self): + with tempfile.TemporaryDirectory() as temp_dir: + temp_file1 = os.path.join(temp_dir, "Clip-001.empty") + temp_file2 = os.path.join(temp_dir, "Clip-003.empty") + open(temp_file1, "w").write("A") + open(temp_file2, "w").write("B") + + temp_url = pathlib.Path(temp_dir).as_uri() + + sys.argv = [ + 'otiotool', + '-i', SIMPLE_CUT_PATH, + '--relink-by-name', temp_dir, + '--list-media' + ] + out, err = self.run_test() + self.assertIn( + ("TIMELINE: Figure 1 - Simple Cut List\n" + f" MEDIA: {temp_url}/Clip-001.empty\n" + " MEDIA: file:///folder/wind-up.mov\n" + f" MEDIA: {temp_url}/Clip-003.empty\n" + " MEDIA: file:///folder/credits.mov\n"), + out) + OTIOToolTest_ShellOut = CreateShelloutTest(OTIOToolTest)