Skip to content

Commit a841943

Browse files
committed
[cli] Add new save-xml command
Supports Final Cut Pro X and 7 formats, but there are still *many* TODOs to address. The output has not yet been validated and may not be usable yet.
1 parent 3d6aedd commit a841943

File tree

5 files changed

+271
-2
lines changed

5 files changed

+271
-2
lines changed

scenedetect.cfg

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,21 @@
314314
#disable-shift = no
315315

316316

317+
318+
[save-xml]
319+
320+
# Filename format of XML file. Can use $VIDEO_NAME macro.
321+
#filename = $VIDEO_NAME.xml
322+
323+
# Format of the XML file. Must be one of:
324+
# - fcpx: Final Cut Pro X (default)
325+
# - fcp: Final Cut Pro 7
326+
#format = fcpx
327+
328+
# Folder to output XML file to. Overrides [global] output option.
329+
#output = /usr/tmp/images
330+
331+
317332
#
318333
# BACKEND OPTIONS
319334
#

scenedetect/_cli/__init__.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
CONFIG_MAP,
3636
DEFAULT_JPG_QUALITY,
3737
DEFAULT_WEBP_QUALITY,
38+
XmlFormat,
3839
)
3940
from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements
4041
from scenedetect.backends import AVAILABLE_BACKENDS
@@ -1569,6 +1570,57 @@ def save_qp_command(
15691570
ctx.add_command(cli_commands.save_qp, save_qp_args)
15701571

15711572

1573+
SAVE_XML_HELP = """Save cuts in XML format."""
1574+
1575+
1576+
@click.command("save-xml", cls=Command, help=SAVE_XML_HELP)
1577+
@click.option(
1578+
"--filename",
1579+
"-f",
1580+
metavar="NAME",
1581+
default=None,
1582+
type=click.STRING,
1583+
help="Filename format to use.%s" % (USER_CONFIG.get_help_string("save-xml", "filename")),
1584+
)
1585+
@click.option(
1586+
"--format",
1587+
metavar="TYPE",
1588+
type=click.Choice(CHOICE_MAP["save-xml"]["format"], False),
1589+
default=None,
1590+
help="Format to export. TYPE must be one of: %s.%s"
1591+
% (
1592+
", ".join(CHOICE_MAP["save-xml"]["format"]),
1593+
USER_CONFIG.get_help_string("save-xml", "format"),
1594+
),
1595+
)
1596+
@click.option(
1597+
"--output",
1598+
"-o",
1599+
metavar="DIR",
1600+
type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False),
1601+
help="Output directory to save XML file to. Overrides global option -o/--output.%s"
1602+
% (USER_CONFIG.get_help_string("save-xml", "output", show_default=False)),
1603+
)
1604+
@click.pass_context
1605+
def save_xml_command(
1606+
ctx: click.Context,
1607+
filename: ty.Optional[ty.AnyStr],
1608+
format: ty.Optional[ty.AnyStr],
1609+
output: ty.Optional[ty.AnyStr],
1610+
):
1611+
ctx = ctx.obj
1612+
assert isinstance(ctx, CliContext)
1613+
1614+
# TODO: Change config parser so get_value returns enums directly.
1615+
format = XmlFormat[ctx.config.get_value("save-xml", "format", format).upper()]
1616+
save_xml_args = {
1617+
"filename": ctx.config.get_value("save-xml", "filename", filename),
1618+
"format": format,
1619+
"output": ctx.config.get_value("save-xml", "output", output),
1620+
}
1621+
ctx.add_command(cli_commands.save_xml, save_xml_args)
1622+
1623+
15721624
# ----------------------------------------------------------------------
15731625
# CLI Sub-Command Registration
15741626
# ----------------------------------------------------------------------
@@ -1590,10 +1642,11 @@ def save_qp_command(
15901642
scenedetect.add_command(detect_threshold_command)
15911643

15921644
# Output
1593-
scenedetect.add_command(save_html_command)
1594-
scenedetect.add_command(save_qp_command)
15951645
scenedetect.add_command(list_scenes_command)
1646+
scenedetect.add_command(save_html_command)
15961647
scenedetect.add_command(save_images_command)
1648+
scenedetect.add_command(save_qp_command)
1649+
scenedetect.add_command(save_xml_command)
15971650
scenedetect.add_command(split_video_command)
15981651

15991652
# Deprecated Commands (Hidden From Help Output)

scenedetect/_cli/commands.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,13 @@
1818
import logging
1919
import typing as ty
2020
import webbrowser
21+
from datetime import datetime
22+
from pathlib import Path
2123
from string import Template
24+
from xml.dom import minidom
25+
from xml.etree import ElementTree
2226

27+
from scenedetect._cli.config import XmlFormat
2328
from scenedetect._cli.context import CliContext
2429
from scenedetect.platform import get_and_create_path
2530
from scenedetect.scene_manager import (
@@ -248,3 +253,175 @@ def split_video(
248253
)
249254
if scenes:
250255
logger.info("Video splitting completed, scenes written to disk.")
256+
257+
258+
def _save_xml_fcpx(
259+
context: CliContext,
260+
scenes: SceneList,
261+
filename: str,
262+
output: str,
263+
):
264+
"""Saves scenes in Final Cut Pro X XML format."""
265+
ASSET_ID = "asset1"
266+
FORMAT_ID = "format1"
267+
# TODO: Need to handle other video formats!
268+
VIDEO_FORMAT_TODO_HANDLE_OTHERS = "FFVideoFormat1080p24"
269+
270+
root = ElementTree.Element("fcpxml", version="1.9")
271+
resources = ElementTree.SubElement(root, "resources")
272+
ElementTree.SubElement(resources, "format", id="format1", name=VIDEO_FORMAT_TODO_HANDLE_OTHERS)
273+
274+
video_name = context.video_stream.name
275+
276+
# TODO: We should calculate duration from the scene list.
277+
duration = context.video_stream.duration
278+
duration = str(duration.get_seconds()) + "s" # TODO: Is float okay here?
279+
# TODO: This should be an absolute path, but the path types between VideoStream impls aren't
280+
# consistent. Need to make a breaking change to the API so that they return pathlib.Path types.
281+
path = context.video_stream.path
282+
ElementTree.SubElement(
283+
resources,
284+
"asset",
285+
id=ASSET_ID,
286+
name=video_name,
287+
src=path,
288+
duration=duration,
289+
hasVideo="1",
290+
hasAudio="1", # TODO: Handle case of no audio.
291+
format=FORMAT_ID,
292+
)
293+
294+
library = ElementTree.SubElement(root, "library")
295+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
296+
event = ElementTree.SubElement(library, "event", name=f"Shot Detection {now}")
297+
project = ElementTree.SubElement(
298+
event, "project", name=video_name
299+
) # TODO: Allow customizing project name.
300+
sequence = ElementTree.SubElement(project, "sequence", format=FORMAT_ID, duration=duration)
301+
spine = ElementTree.SubElement(sequence, "spine")
302+
303+
for i, (start, end) in enumerate(scenes):
304+
start_seconds = start.get_seconds()
305+
duration_seconds = (end - start).get_seconds()
306+
clip = ElementTree.SubElement(
307+
spine,
308+
"clip",
309+
name=f"Shot {i + 1}",
310+
duration=f"{duration_seconds:.3f}s",
311+
start=f"{start_seconds:.3f}s",
312+
offset=f"{start_seconds:.3f}s",
313+
)
314+
ElementTree.SubElement(
315+
clip,
316+
"asset-clip",
317+
ref=ASSET_ID,
318+
duration=f"{duration_seconds:.3f}s",
319+
start=f"{start_seconds:.3f}s",
320+
offset="0s",
321+
name=f"Shot {i + 1}",
322+
)
323+
324+
pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml(
325+
indent=" "
326+
)
327+
xml_path = get_and_create_path(
328+
Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name),
329+
output,
330+
)
331+
logger.info(f"Writing scenes in FCPX format to {xml_path}")
332+
with open(xml_path, "w") as f:
333+
f.write(pretty_xml)
334+
335+
336+
def _save_xml_fcp(
337+
context: CliContext,
338+
scenes: SceneList,
339+
filename: str,
340+
output: str,
341+
):
342+
"""Saves scenes in Final Cut Pro 7 XML format."""
343+
root = ElementTree.Element("xmeml", version="5")
344+
project = ElementTree.SubElement(root, "project")
345+
ElementTree.SubElement(project, "name").text = context.video_stream.name
346+
sequence = ElementTree.SubElement(project, "sequence")
347+
ElementTree.SubElement(sequence, "name").text = context.video_stream.name
348+
349+
# TODO: We should calculate duration from the scene list.
350+
duration = context.video_stream.duration
351+
duration = str(duration.get_seconds()) # TODO: Is float okay here?
352+
ElementTree.SubElement(sequence, "duration").text = duration
353+
354+
rate = ElementTree.SubElement(sequence, "rate")
355+
ElementTree.SubElement(rate, "timebase").text = str(context.video_stream.frame_rate)
356+
ElementTree.SubElement(rate, "ntsc").text = "FALSE"
357+
358+
timecode = ElementTree.SubElement(sequence, "timecode")
359+
tc_rate = ElementTree.SubElement(timecode, "rate")
360+
ElementTree.SubElement(tc_rate, "timebase").text = str(context.video_stream.frame_rate)
361+
ElementTree.SubElement(tc_rate, "ntsc").text = "FALSE"
362+
ElementTree.SubElement(timecode, "frame").text = "0"
363+
ElementTree.SubElement(timecode, "displayformat").text = "NDF"
364+
365+
media = ElementTree.SubElement(sequence, "media")
366+
video = ElementTree.SubElement(media, "video")
367+
format = ElementTree.SubElement(video, "format")
368+
ElementTree.SubElement(format, "samplecharacteristics")
369+
track = ElementTree.SubElement(video, "track")
370+
371+
# Add clips for each shot boundary
372+
for i, (start, end) in enumerate(scenes):
373+
clip = ElementTree.SubElement(track, "clipitem")
374+
ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}"
375+
ElementTree.SubElement(clip, "enabled").text = "TRUE"
376+
ElementTree.SubElement(clip, "rate").append(
377+
ElementTree.fromstring(f"<timebase>{context.video_stream.frame_rate}</timebase>")
378+
)
379+
# TODO: Are these supposed to be frame numbers or another format?
380+
ElementTree.SubElement(clip, "start").text = str(start.get_frames())
381+
ElementTree.SubElement(clip, "end").text = str(end.get_frames())
382+
ElementTree.SubElement(clip, "in").text = str(start.get_frames())
383+
ElementTree.SubElement(clip, "out").text = str(end.get_frames())
384+
385+
file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}")
386+
ElementTree.SubElement(file_ref, "name").text = context.video_stream.name
387+
# TODO: Turn this into absolute path, see TODO in the FCPX function for details.
388+
path = context.video_stream.path
389+
ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}"
390+
391+
media_ref = ElementTree.SubElement(file_ref, "media")
392+
video_ref = ElementTree.SubElement(media_ref, "video")
393+
ElementTree.SubElement(video_ref, "samplecharacteristics")
394+
link = ElementTree.SubElement(clip, "link")
395+
ElementTree.SubElement(link, "linkclipref").text = f"file{i + 1}"
396+
ElementTree.SubElement(link, "mediatype").text = "video"
397+
398+
pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml(
399+
indent=" "
400+
)
401+
xml_path = get_and_create_path(
402+
Template(filename).safe_substitute(VIDEO_NAME=context.video_stream.name),
403+
output,
404+
)
405+
logger.info(f"Writing scenes in FCP format to {xml_path}")
406+
with open(xml_path, "w") as f:
407+
f.write(pretty_xml)
408+
409+
410+
def save_xml(
411+
context: CliContext,
412+
scenes: SceneList,
413+
cuts: CutList,
414+
filename: str,
415+
format: XmlFormat,
416+
output: str,
417+
):
418+
"""Handles the `save-xml` command."""
419+
# We only use scene information.
420+
del cuts
421+
422+
if format == XmlFormat.FCPX:
423+
_save_xml_fcpx(context, scenes, filename, output)
424+
elif format == XmlFormat.FCP:
425+
_save_xml_fcp(context, scenes, filename, output)
426+
else:
427+
logger.error(f"Unknown format: {format}")

scenedetect/_cli/config.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,15 @@ def format(self, timecode: FrameTimecode) -> str:
305305
raise RuntimeError("Unhandled format specifier.")
306306

307307

308+
class XmlFormat(Enum):
309+
"""Format to use with the `save-xml` command."""
310+
311+
FCPX = 0
312+
"""Final Cut Pro X XML Format"""
313+
FCP = 1
314+
"""Final Cut Pro 7 XML Format"""
315+
316+
308317
ConfigValue = ty.Union[bool, int, float, str]
309318
ConfigDict = ty.Dict[str, ty.Dict[str, ConfigValue]]
310319

@@ -414,6 +423,11 @@ def format(self, timecode: FrameTimecode) -> str:
414423
"filename": "$VIDEO_NAME.qp",
415424
"output": None,
416425
},
426+
"save-xml": {
427+
"format": XmlFormat.FCPX,
428+
"filename": "$VIDEO_NAME.xml",
429+
"output": None,
430+
},
417431
"split-video": {
418432
"args": DEFAULT_FFMPEG_ARGS,
419433
"copy": False,
@@ -430,6 +444,8 @@ def format(self, timecode: FrameTimecode) -> str:
430444
The types of these values are used when decoding the configuration file. Valid choices for
431445
certain string options are stored in `CHOICE_MAP`."""
432446

447+
# TODO: Use the fact that all enums derive from the Enum class to avoid duplicating their values
448+
# here in the choice map.
433449
CHOICE_MAP: ty.Dict[str, ty.Dict[str, ty.List[str]]] = {
434450
"backend-pyav": {
435451
"threading_mode": [mode.lower() for mode in PYAV_THREADING_MODES],
@@ -456,6 +472,9 @@ def format(self, timecode: FrameTimecode) -> str:
456472
"format": ["jpeg", "png", "webp"],
457473
"scale-method": [value.name.lower() for value in Interpolation],
458474
},
475+
"save-xml": {
476+
"format": [value.name.lower() for value in XmlFormat],
477+
},
459478
"split-video": {
460479
"preset": [
461480
"ultrafast",

website/pages/changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,11 @@ Development
624624

625625
## PySceneDetect 0.6.6 (In Development)
626626

627+
### Work In Progress
628+
- [feature] New `save-xml` command supports saving scenes in Final Cut Pro format [#156](https://github.com/Breakthrough/PySceneDetect/issues/156)
629+
630+
### Complete
631+
627632
- [general] The `export-html` command is now deprecated, use `save-html` instead
628633
- [bugfix] Fix incorrect help entries for short-form arguments which suggested invalid syntax [#493](https://github.com/Breakthrough/PySceneDetect/issues/493)
629634
- [bugfix] Fix crash when using `split-video` with `-m`/`--mkvmerge` option [#473](https://github.com/Breakthrough/PySceneDetect/issues/473)

0 commit comments

Comments
 (0)