2020import os .path
2121import typing as ty
2222import webbrowser
23- from datetime import datetime
23+ from fractions import Fraction
2424from pathlib import Path
2525from string import Template
2626from 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+
314330def _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 (
0 commit comments