Skip to content

Commit 7c6d02c

Browse files
committed
[cli] Finalize save-xml for release
Tested FCPX and FCP7 formats on DaVinci Resolve.
1 parent c0d7c3a commit 7c6d02c

6 files changed

Lines changed: 253 additions & 59 deletions

File tree

scenedetect.cfg

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,20 @@
345345
#disable-shift = no
346346

347347

348+
[save-xml]
349+
350+
# Filename format of XML file. Can use $VIDEO_NAME macro.
351+
#filename = $VIDEO_NAME.xml
352+
353+
# Format of the XML file. Must be one of:
354+
# - fcpx: Final Cut Pro X (FCPXML, default)
355+
# - fcp: Final Cut Pro 7 (xmeml)
356+
#format = fcpx
357+
358+
# Folder to output XML file to. Overrides [global] output option.
359+
#output = /usr/tmp/images
360+
361+
348362
#
349363
# BACKEND OPTIONS
350364
#

scenedetect/_cli/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,10 +1614,10 @@ def save_qp_command(
16141614
ctx.add_command(cli_commands.save_qp, save_qp_args)
16151615

16161616

1617-
SAVE_XML_HELP = """[IN DEVELOPMENT] Save cuts in XML format."""
1617+
SAVE_XML_HELP = """Save cuts in Final Cut Pro XML format (FCP7 xmeml or FCPX)."""
16181618

16191619

1620-
@click.command("save-xml", cls=Command, help=SAVE_XML_HELP, hidden=True)
1620+
@click.command("save-xml", cls=Command, help=SAVE_XML_HELP)
16211621
@click.option(
16221622
"--filename",
16231623
"-f",

scenedetect/_cli/commands.py

Lines changed: 101 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import os.path
2121
import typing as ty
2222
import webbrowser
23-
from datetime import datetime
23+
from fractions import Fraction
2424
from pathlib import Path
2525
from string import Template
2626
from xml.dom import minidom
@@ -311,68 +311,89 @@ def get_edl_timecode(timecode: FrameTimecode):
311311
f.write("\n")
312312

313313

314+
def _rational_seconds(value: Fraction) -> str:
315+
"""Format a `Fraction` as an FCPXML rational time string.
316+
317+
FCPXML expresses time as `<num>/<denom>s` (or `<int>s` for whole seconds).
318+
See https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference
319+
"""
320+
if value.denominator == 1:
321+
return f"{value.numerator}s"
322+
return f"{value.numerator}/{value.denominator}s"
323+
324+
325+
def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction:
326+
"""Exact seconds for `tc` as a `Fraction`, derived from PTS × time base."""
327+
return Fraction(tc.pts) * tc.time_base
328+
329+
314330
def _save_xml_fcpx(
315331
context: CliContext,
316332
scenes: SceneList,
317333
filename: str,
318334
output: str,
319335
):
320-
"""Saves scenes in Final Cut Pro X XML format."""
321-
ASSET_ID = "asset1"
322-
FORMAT_ID = "format1"
323-
# TODO: Need to handle other video formats!
324-
VIDEO_FORMAT_TODO_HANDLE_OTHERS = "FFVideoFormat1080p24"
336+
"""Saves scenes in Final Cut Pro X XML format (FCPXML 1.9).
337+
338+
The output follows Apple's FCPXML schema with rational-second time values and
339+
a custom `<format>` derived from the source video's frame rate and resolution.
340+
See https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference
341+
"""
342+
ASSET_ID = "r2"
343+
FORMAT_ID = "r1"
344+
345+
frame_rate = context.video_stream.frame_rate
346+
frame_duration = _rational_seconds(Fraction(frame_rate.denominator, frame_rate.numerator))
347+
width, height = context.video_stream.frame_size
348+
video_name = context.video_stream.name
349+
src_uri = Path(context.video_stream.path).absolute().as_uri()
350+
total_duration = _rational_seconds(_frame_timecode_seconds(scenes[-1][1] - scenes[0][0]))
325351

326352
root = ElementTree.Element("fcpxml", version="1.9")
327353
resources = ElementTree.SubElement(root, "resources")
328-
ElementTree.SubElement(resources, "format", id="format1", name=VIDEO_FORMAT_TODO_HANDLE_OTHERS)
329-
330-
video_name = context.video_stream.name
331-
332-
# TODO: We should calculate duration from the scene list.
333-
duration = context.video_stream.duration
334-
duration = str(duration.seconds) + "s" # TODO: Is float okay here?
335-
path = Path(context.video_stream.path).absolute()
354+
# `name` is cosmetic: Apple publishes no authoritative FFVideoFormat* list, and editors key
355+
# off frameDuration/width/height. We emit a generated name for display only.
356+
format_name = f"FFVideoFormat{height}p{round(float(frame_rate) * 100):04d}"
336357
ElementTree.SubElement(
358+
resources,
359+
"format",
360+
id=FORMAT_ID,
361+
name=format_name,
362+
frameDuration=frame_duration,
363+
width=str(width),
364+
height=str(height),
365+
)
366+
asset = ElementTree.SubElement(
337367
resources,
338368
"asset",
339369
id=ASSET_ID,
340370
name=video_name,
341-
src=str(path),
342-
duration=duration,
371+
start="0s",
372+
duration=total_duration,
343373
hasVideo="1",
344-
hasAudio="1", # TODO: Handle case of no audio.
345374
format=FORMAT_ID,
346375
)
376+
ElementTree.SubElement(asset, "media-rep", kind="original-media", src=src_uri)
347377

348378
library = ElementTree.SubElement(root, "library")
349-
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
350-
event = ElementTree.SubElement(library, "event", name=f"Shot Detection {now}")
351-
project = ElementTree.SubElement(
352-
event, "project", name=video_name
353-
) # TODO: Allow customizing project name.
354-
sequence = ElementTree.SubElement(project, "sequence", format=FORMAT_ID, duration=duration)
379+
event = ElementTree.SubElement(library, "event", name=video_name)
380+
project = ElementTree.SubElement(event, "project", name=video_name)
381+
sequence = ElementTree.SubElement(
382+
project, "sequence", format=FORMAT_ID, duration=total_duration, tcStart="0s", tcFormat="NDF"
383+
)
355384
spine = ElementTree.SubElement(sequence, "spine")
356385

357386
for i, (start, end) in enumerate(scenes):
358-
start_seconds = start.seconds
359-
duration_seconds = (end - start).seconds
360-
clip = ElementTree.SubElement(
361-
spine,
362-
"clip",
363-
name=f"Shot {i + 1}",
364-
duration=f"{duration_seconds:.3f}s",
365-
start=f"{start_seconds:.3f}s",
366-
offset=f"{start_seconds:.3f}s",
367-
)
387+
scene_start = _rational_seconds(_frame_timecode_seconds(start))
388+
scene_duration = _rational_seconds(_frame_timecode_seconds(end - start))
368389
ElementTree.SubElement(
369-
clip,
390+
spine,
370391
"asset-clip",
371-
ref=ASSET_ID,
372-
duration=f"{duration_seconds:.3f}s",
373-
start=f"{start_seconds:.3f}s",
374-
offset="0s",
375392
name=f"Shot {i + 1}",
393+
ref=ASSET_ID,
394+
offset=scene_start,
395+
start=scene_start,
396+
duration=scene_duration,
376397
)
377398

378399
pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml(
@@ -393,7 +414,11 @@ def _save_xml_fcp(
393414
filename: str,
394415
output: str,
395416
):
396-
"""Saves scenes in Final Cut Pro 7 XML format."""
417+
"""Saves scenes in Final Cut Pro 7 XML (xmeml) format.
418+
419+
See https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/
420+
for the element reference. `pathurl` must be a valid `file://` URI per the xmeml spec.
421+
"""
397422
assert scenes
398423
root = ElementTree.Element("xmeml", version="5")
399424
project = ElementTree.SubElement(root, "project")
@@ -417,39 +442,59 @@ def _save_xml_fcp(
417442
ElementTree.SubElement(timecode, "frame").text = "0"
418443
ElementTree.SubElement(timecode, "displayformat").text = "NDF"
419444

445+
width, height = context.video_stream.frame_size
420446
media = ElementTree.SubElement(sequence, "media")
421447
video = ElementTree.SubElement(media, "video")
422448
format = ElementTree.SubElement(video, "format")
423-
ElementTree.SubElement(format, "samplecharacteristics")
449+
sample_chars = ElementTree.SubElement(format, "samplecharacteristics")
450+
ElementTree.SubElement(sample_chars, "width").text = str(width)
451+
ElementTree.SubElement(sample_chars, "height").text = str(height)
424452
track = ElementTree.SubElement(video, "track")
425453

426-
# Add clips for each shot boundary
454+
path_uri = Path(context.video_stream.path).absolute().as_uri()
455+
# Source media total duration in frames at the declared timebase. Required on `<file>` so NLEs
456+
# (DaVinci Resolve, Premiere) can seek into the source — without it the clip plays frozen.
457+
source_duration_frames = (
458+
str(round(context.video_stream.duration.seconds * fps))
459+
if context.video_stream.duration is not None
460+
else str(round(scenes[-1][1].seconds * fps))
461+
)
462+
FILE_ID = "file1"
463+
427464
for i, (start, end) in enumerate(scenes):
428465
clip = ElementTree.SubElement(track, "clipitem")
429466
ElementTree.SubElement(clip, "name").text = f"Shot {i + 1}"
430467
ElementTree.SubElement(clip, "enabled").text = "TRUE"
431-
ElementTree.SubElement(clip, "rate").append(
432-
ElementTree.fromstring(f"<timebase>{round(fps)}</timebase>")
433-
)
468+
ElementTree.SubElement(clip, "duration").text = source_duration_frames
469+
clip_rate = ElementTree.SubElement(clip, "rate")
470+
ElementTree.SubElement(clip_rate, "timebase").text = str(round(fps))
471+
ElementTree.SubElement(clip_rate, "ntsc").text = ntsc
434472
# Frame numbers relative to the declared <timebase> fps, computed from PTS seconds.
435473
ElementTree.SubElement(clip, "start").text = str(round(start.seconds * fps))
436474
ElementTree.SubElement(clip, "end").text = str(round(end.seconds * fps))
437475
ElementTree.SubElement(clip, "in").text = str(round(start.seconds * fps))
438476
ElementTree.SubElement(clip, "out").text = str(round(end.seconds * fps))
439477

440-
file_ref = ElementTree.SubElement(clip, "file", id=f"file{i + 1}")
441-
ElementTree.SubElement(file_ref, "name").text = context.video_stream.name
442-
path = Path(context.video_stream.path).absolute()
443-
# TODO: Can we just use path.as_uri() here?
444-
# On Windows this should be: file://localhost/C:/Users/... according to the samples provided
445-
# from https://github.com/Breakthrough/PySceneDetect/issues/156#issuecomment-1076213412.
446-
ElementTree.SubElement(file_ref, "pathurl").text = f"file://{path}"
447-
448-
media_ref = ElementTree.SubElement(file_ref, "media")
449-
video_ref = ElementTree.SubElement(media_ref, "video")
450-
ElementTree.SubElement(video_ref, "samplecharacteristics")
478+
# xmeml allows a single full `<file>` declaration reused via `<file id="...">` on
479+
# subsequent clipitems. Emit full details on the first, then self-close on the rest.
480+
if i == 0:
481+
file_ref = ElementTree.SubElement(clip, "file", id=FILE_ID)
482+
ElementTree.SubElement(file_ref, "name").text = context.video_stream.name
483+
ElementTree.SubElement(file_ref, "pathurl").text = path_uri
484+
ElementTree.SubElement(file_ref, "duration").text = source_duration_frames
485+
file_rate = ElementTree.SubElement(file_ref, "rate")
486+
ElementTree.SubElement(file_rate, "timebase").text = str(round(fps))
487+
ElementTree.SubElement(file_rate, "ntsc").text = ntsc
488+
media_ref = ElementTree.SubElement(file_ref, "media")
489+
video_ref = ElementTree.SubElement(media_ref, "video")
490+
clip_chars = ElementTree.SubElement(video_ref, "samplecharacteristics")
491+
ElementTree.SubElement(clip_chars, "width").text = str(width)
492+
ElementTree.SubElement(clip_chars, "height").text = str(height)
493+
else:
494+
ElementTree.SubElement(clip, "file", id=FILE_ID)
495+
451496
link = ElementTree.SubElement(clip, "link")
452-
ElementTree.SubElement(link, "linkclipref").text = f"file{i + 1}"
497+
ElementTree.SubElement(link, "linkclipref").text = FILE_ID
453498
ElementTree.SubElement(link, "mediatype").text = "video"
454499

455500
pretty_xml = minidom.parseString(ElementTree.tostring(root, encoding="unicode")).toprettyxml(

tests/test_cli.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,3 +1160,112 @@ def test_cli_save_otio_no_audio(tmp_path: Path):
11601160
assert output_path.read_text() == EXPECTED_OTIO_OUTPUT.replace(
11611161
"{ABSOLUTE_PATH}", os.path.abspath(DEFAULT_VIDEO_PATH).replace("\\", "\\\\")
11621162
)
1163+
1164+
1165+
def test_cli_save_xml_fcpx(tmp_path: Path):
1166+
"""Test `save-xml --format fcpx` produces a valid FCPXML 1.9 file."""
1167+
from xml.etree import ElementTree
1168+
1169+
exit_code, _ = invoke_cli(
1170+
[
1171+
"-i",
1172+
DEFAULT_VIDEO_PATH,
1173+
"-o",
1174+
str(tmp_path),
1175+
"time",
1176+
"-s",
1177+
"2s",
1178+
"-d",
1179+
"4s",
1180+
"detect-content",
1181+
"save-xml",
1182+
]
1183+
)
1184+
assert exit_code == 0
1185+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.xml")
1186+
assert os.path.exists(output_path)
1187+
1188+
root = ElementTree.parse(output_path).getroot()
1189+
assert root.tag == "fcpxml"
1190+
assert root.attrib["version"] == "1.9"
1191+
1192+
# Format carries the rational frameDuration derived from the video's 24000/1001 fps.
1193+
fmt = root.find("resources/format")
1194+
assert fmt is not None
1195+
assert fmt.attrib["frameDuration"] == "1001/24000s"
1196+
assert fmt.attrib["width"] == "1280"
1197+
assert fmt.attrib["height"] == "544"
1198+
1199+
# Asset references the source video via a file:// URI.
1200+
media_rep = root.find("resources/asset/media-rep")
1201+
assert media_rep is not None
1202+
assert media_rep.attrib["src"].startswith("file://")
1203+
assert media_rep.attrib["src"].endswith("goldeneye.mp4")
1204+
1205+
# Spine contains one `<asset-clip>` per scene (not wrapped in `<clip>`).
1206+
asset_clips = root.findall("library/event/project/sequence/spine/asset-clip")
1207+
assert len(asset_clips) == 2
1208+
# All clip time attributes are rational strings ending in "s".
1209+
for clip in asset_clips:
1210+
for attr in ("offset", "start", "duration"):
1211+
assert clip.attrib[attr].endswith("s")
1212+
1213+
1214+
def test_cli_save_xml_fcp(tmp_path: Path):
1215+
"""Test `save-xml --format fcp` produces a valid FCP7 xmeml file."""
1216+
from xml.etree import ElementTree
1217+
1218+
exit_code, _ = invoke_cli(
1219+
[
1220+
"-i",
1221+
DEFAULT_VIDEO_PATH,
1222+
"-o",
1223+
str(tmp_path),
1224+
"time",
1225+
"-s",
1226+
"2s",
1227+
"-d",
1228+
"4s",
1229+
"detect-content",
1230+
"save-xml",
1231+
"--format",
1232+
"fcp",
1233+
]
1234+
)
1235+
assert exit_code == 0
1236+
output_path = tmp_path.joinpath(f"{DEFAULT_VIDEO_NAME}.xml")
1237+
assert os.path.exists(output_path)
1238+
1239+
root = ElementTree.parse(output_path).getroot()
1240+
assert root.tag == "xmeml"
1241+
assert root.attrib["version"] == "5"
1242+
1243+
# NTSC flag is True for the 23.976 test video.
1244+
ntsc = root.find("project/sequence/rate/ntsc")
1245+
assert ntsc is not None and ntsc.text == "True"
1246+
1247+
# samplecharacteristics carry width/height so Premiere/DaVinci can ingest.
1248+
width = root.find("project/sequence/media/video/format/samplecharacteristics/width")
1249+
height = root.find("project/sequence/media/video/format/samplecharacteristics/height")
1250+
assert width is not None and width.text == "1280"
1251+
assert height is not None and height.text == "544"
1252+
1253+
# Two clipitems produced; first carries the full <file> block, rest reference it by id.
1254+
clipitems = root.findall("project/sequence/media/video/track/clipitem")
1255+
assert len(clipitems) == 2
1256+
1257+
first_file = clipitems[0].find("file")
1258+
assert first_file is not None
1259+
assert first_file.attrib["id"] == "file1"
1260+
pathurl = first_file.find("pathurl")
1261+
assert pathurl is not None and pathurl.text is not None
1262+
assert pathurl.text.startswith("file://")
1263+
assert pathurl.text.endswith("goldeneye.mp4")
1264+
# Source duration is required for NLEs to seek into the media.
1265+
assert first_file.find("duration") is not None
1266+
1267+
# Subsequent clipitems reference the same file id without redeclaring.
1268+
second_file = clipitems[1].find("file")
1269+
assert second_file is not None
1270+
assert second_file.attrib["id"] == "file1"
1271+
assert second_file.find("pathurl") is None

0 commit comments

Comments
 (0)