Skip to content

Commit 4758964

Browse files
committed
[cli] Add new save-otio command
Verified works with DaVinci Resolve. #497
1 parent 8e05684 commit 4758964

File tree

6 files changed

+413
-5
lines changed

6 files changed

+413
-5
lines changed

docs/cli.rst

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@ Options
644644
``save-images``
645645
========================================================================
646646

647-
Extract images from each detected scene.
647+
Save images from each detected scene.
648648

649649

650650
Examples
@@ -721,6 +721,40 @@ Options
721721
Width (pixels) of images.
722722

723723

724+
.. _command-save-otio:
725+
726+
.. program:: scenedetect save-otio
727+
728+
729+
``save-otio``
730+
========================================================================
731+
732+
Save cuts as an OTIO timeline.
733+
734+
Uses the Timeline.1 schema. OTIO (OpenTimelineIO) timelines can be imported by many video editors.
735+
736+
737+
Options
738+
------------------------------------------------------------------------
739+
740+
741+
.. option:: -f NAME, --filename NAME
742+
743+
Filename format to use.
744+
745+
Default: ``$VIDEO_NAME.otio``
746+
747+
.. option:: -n NAME, --name NAME
748+
749+
Name of timeline to use.
750+
751+
Default: ``"$VIDEO_NAME (PySceneDetect)"``
752+
753+
.. option:: -o DIR, --output DIR
754+
755+
Output directory to save OTIO file to. Overrides global option :option:`-o/--output <scenedetect -o>`.
756+
757+
724758
.. _command-save-qp:
725759

726760
.. program:: scenedetect save-qp

scenedetect.cfg

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,19 @@
314314
#reel = AX
315315

316316
# Title to use for the EDL information. Can use $VIDEO_NAME macro.
317-
#title = $VIDEO_NAME
317+
#title = $VIDEO_NAME (PySceneDetect)
318+
319+
320+
[save-otio]
321+
322+
# Filename format of OTIO file. Can use $VIDEO_NAME macro.
323+
#filename = $VIDEO_NAME.otio
324+
325+
# Folder to output OTIO file to. Overrides [global] output option.
326+
#output = /usr/tmp/images
327+
328+
# Name to use for the OTIO timeline. Can use $VIDEO_NAME macro.
329+
#title = $VIDEO_NAME (PySceneDetect)
318330

319331

320332
[save-qp]

scenedetect/_cli/__init__.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1327,7 +1327,7 @@ def split_video_command(
13271327
ctx.add_command(cli_commands.split_video, split_video_args)
13281328

13291329

1330-
SAVE_IMAGES_HELP = """Extract images from each detected scene.
1330+
SAVE_IMAGES_HELP = """Save images from each detected scene.
13311331
13321332
Examples:
13331333
@@ -1675,6 +1675,54 @@ def save_xml_command(
16751675
ctx.add_command(cli_commands.save_xml, save_xml_args)
16761676

16771677

1678+
SAVE_OTIO_HELP = """Save cuts as an OTIO timeline.
1679+
1680+
Uses the Timeline.1 schema. OTIO (OpenTimelineIO) timelines can be imported by many video editors."""
1681+
1682+
1683+
@click.command("save-otio", cls=Command, help=SAVE_OTIO_HELP)
1684+
@click.option(
1685+
"--filename",
1686+
"-f",
1687+
metavar="NAME",
1688+
default=None,
1689+
type=click.STRING,
1690+
help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-otio", "filename")),
1691+
)
1692+
@click.option(
1693+
"--name",
1694+
"-n",
1695+
metavar="NAME",
1696+
default=None,
1697+
type=click.STRING,
1698+
help="Name of timeline to use.%s" % (USER_CONFIG.get_help_string("save-otio", "name")),
1699+
)
1700+
@click.option(
1701+
"--output",
1702+
"-o",
1703+
metavar="DIR",
1704+
type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False),
1705+
help="Output directory to save OTIO file to. Overrides global option -o/--output.%s"
1706+
% (USER_CONFIG.get_help_string("save-otio", "output", show_default=False)),
1707+
)
1708+
@click.pass_context
1709+
def save_otio_command(
1710+
ctx: click.Context,
1711+
filename: ty.Optional[ty.AnyStr],
1712+
name: ty.Optional[ty.AnyStr],
1713+
output: ty.Optional[ty.AnyStr],
1714+
):
1715+
ctx = ctx.obj
1716+
assert isinstance(ctx, CliContext)
1717+
1718+
save_otio_args = {
1719+
"filename": ctx.config.get_value("save-otio", "filename", filename),
1720+
"name": ctx.config.get_value("save-otio", "name", name),
1721+
"output": ctx.config.get_value("save-otio", "output", output),
1722+
}
1723+
ctx.add_command(cli_commands.save_otio, save_otio_args)
1724+
1725+
16781726
# ----------------------------------------------------------------------
16791727
# CLI Sub-Command Registration
16801728
# ----------------------------------------------------------------------
@@ -1702,6 +1750,7 @@ def save_xml_command(
17021750
scenedetect.add_command(save_images_command)
17031751
scenedetect.add_command(save_qp_command)
17041752
scenedetect.add_command(save_xml_command)
1753+
scenedetect.add_command(save_otio_command)
17051754
scenedetect.add_command(split_video_command)
17061755

17071756
# Deprecated Commands (Hidden From Help Output)

scenedetect/_cli/commands.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
current command-line context, as well as the processing result (scenes and cuts).
1616
"""
1717

18+
import json
1819
import logging
20+
import os.path
1921
import typing as ty
2022
import webbrowser
2123
from datetime import datetime
@@ -401,12 +403,12 @@ def _save_xml_fcp(
401403

402404
rate = ElementTree.SubElement(sequence, "rate")
403405
ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate)
404-
ElementTree.SubElement(rate, "ntsc").text = "FALSE"
406+
ElementTree.SubElement(rate, "ntsc").text = "False"
405407

406408
timecode = ElementTree.SubElement(sequence, "timecode")
407409
tc_rate = ElementTree.SubElement(timecode, "rate")
408410
ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate)
409-
ElementTree.SubElement(tc_rate, "ntsc").text = "FALSE"
411+
ElementTree.SubElement(tc_rate, "ntsc").text = "False"
410412
ElementTree.SubElement(timecode, "frame").text = "0"
411413
ElementTree.SubElement(timecode, "displayformat").text = "NDF"
412414

@@ -478,3 +480,100 @@ def save_xml(
478480
_save_xml_fcp(context, scenes, filename, output)
479481
else:
480482
logger.error(f"Unknown format: {format}")
483+
484+
485+
def save_otio(
486+
context: CliContext,
487+
scenes: SceneList,
488+
cuts: CutList,
489+
filename: str,
490+
output: str,
491+
name: str,
492+
):
493+
"""Saves scenes in OTIO format."""
494+
495+
del cuts # We only use scene information
496+
497+
video_name = context.video_stream.name
498+
video_path = os.path.abspath(context.video_stream.path)
499+
video_base_name = os.path.basename(context.video_stream.path)
500+
frame_rate = context.video_stream.frame_rate
501+
502+
# List of track mapping to resource type.
503+
# TODO(#497): Allow exporting without an audio track.
504+
track_list = {"Video 1": "Video", "Audio 1": "Audio"}
505+
506+
otio = {
507+
"OTIO_SCHEMA": "Timeline.1",
508+
"name": Template(name).safe_substitute(VIDEO_NAME=video_name),
509+
"global_start_time": {
510+
"OTIO_SCHEMA": "RationalTime.1",
511+
"rate": frame_rate,
512+
"value": 0.0,
513+
},
514+
"tracks": {
515+
"OTIO_SCHEMA": "Stack.1",
516+
"enabled": True,
517+
"children": [
518+
{
519+
"OTIO_SCHEMA": "Track.1",
520+
"name": track_name,
521+
"enabled": True,
522+
"children": [
523+
{
524+
"OTIO_SCHEMA": "Clip.2",
525+
"name": video_base_name,
526+
"source_range": {
527+
"OTIO_SCHEMA": "TimeRange.1",
528+
"duration": {
529+
"OTIO_SCHEMA": "RationalTime.1",
530+
"rate": frame_rate,
531+
"value": float((end - start).get_frames()),
532+
},
533+
"start_time": {
534+
"OTIO_SCHEMA": "RationalTime.1",
535+
"rate": frame_rate,
536+
"value": float(start.get_frames()),
537+
},
538+
},
539+
"enabled": True,
540+
"media_references": {
541+
"DEFAULT_MEDIA": {
542+
"OTIO_SCHEMA": "ExternalReference.1",
543+
"name": video_base_name,
544+
"available_range": {
545+
"OTIO_SCHEMA": "TimeRange.1",
546+
"duration": {
547+
"OTIO_SCHEMA": "RationalTime.1",
548+
"rate": frame_rate,
549+
"value": 1980.0,
550+
},
551+
"start_time": {
552+
"OTIO_SCHEMA": "RationalTime.1",
553+
"rate": frame_rate,
554+
"value": 0.0,
555+
},
556+
},
557+
"available_image_bounds": None,
558+
"target_url": video_path,
559+
}
560+
},
561+
"active_media_reference_key": "DEFAULT_MEDIA",
562+
}
563+
for (start, end) in scenes
564+
],
565+
"kind": track_type,
566+
}
567+
for (track_name, track_type) in track_list.items()
568+
],
569+
},
570+
}
571+
572+
otio_path = get_and_create_path(
573+
Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name),
574+
output,
575+
)
576+
logger.info(f"Writing scenes in OTIO format to {otio_path}")
577+
with open(otio_path, "w") as f:
578+
json.dump(otio, f, indent=4)
579+
f.write("\n")

scenedetect/_cli/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,11 @@ class XmlFormat(Enum):
424424
"threading": True,
425425
"width": 0,
426426
},
427+
"save-otio": {
428+
"filename": "$VIDEO_NAME.otio",
429+
"name": "$VIDEO_NAME (PySceneDetect)",
430+
"output": None,
431+
},
427432
"save-qp": {
428433
"disable-shift": False,
429434
"filename": "$VIDEO_NAME.qp",

0 commit comments

Comments
 (0)