1616"""
1717
1818import csv
19+ import functools
20+ import inspect
21+ import io
1922import json
2023import logging
2124import math
25+ import os
2226import typing as ty
2327from fractions import Fraction
2428from pathlib import Path
6468from scenedetect .output .video import (
6569 split_video_mkvmerge as split_video_mkvmerge ,
6670)
71+ from scenedetect .platform import BytePath , StrPath
6772
6873logger = logging .getLogger ("pyscenedetect" )
6974
7075
76+ _F = ty .TypeVar ("_F" , bound = ty .Callable [..., ty .Any ])
77+
78+
79+ def _open_output_file (output_format : str ) -> ty .Callable [[_F ], _F ]:
80+ """Allows a writer's first argument to be an open text file or a filesystem path.
81+
82+ File handles remain open after the writer returns. Path-based output is rendered in memory
83+ before the destination is opened, then written as UTF-8 with no newline translation.
84+ """
85+
86+ def decorator (func : _F ) -> _F :
87+ parameters = tuple (inspect .signature (func ).parameters .values ())
88+ assert parameters and parameters [0 ].kind in (
89+ inspect .Parameter .POSITIONAL_ONLY ,
90+ inspect .Parameter .POSITIONAL_OR_KEYWORD ,
91+ )
92+ parameter_name = parameters [0 ].name
93+
94+ @functools .wraps (func )
95+ def wrapper (* args : ty .Any , ** kwargs : ty .Any ) -> ty .Any :
96+ output_file = args [0 ] if args else kwargs [parameter_name ]
97+
98+ if not isinstance (output_file , (str , bytes , os .PathLike )):
99+ if not callable (getattr (output_file , "write" , None )):
100+ raise TypeError (
101+ f"{ parameter_name } must be a filesystem path or writable text file"
102+ )
103+ logger .info (
104+ "Writing scenes in %s format to %s" ,
105+ output_format ,
106+ getattr (output_file , "name" , "<file-like object>" ),
107+ )
108+ return func (* args , ** kwargs )
109+
110+ with io .StringIO (newline = "" ) as output_buffer :
111+ if args :
112+ result = func (output_buffer , * args [1 :], ** kwargs )
113+ else :
114+ kwargs [parameter_name ] = output_buffer
115+ result = func (** kwargs )
116+ contents = output_buffer .getvalue ()
117+
118+ logger .info ("Writing scenes in %s format to %s" , output_format , output_file )
119+ with open (output_file , "w" , encoding = "utf-8" , newline = "" ) as file_handle :
120+ file_handle .write (contents )
121+
122+ return result
123+
124+ return ty .cast (_F , wrapper )
125+
126+ return decorator
127+
128+
129+ @_open_output_file ("CSV" )
71130def write_scene_list (
72- output_csv_file : ty . TextIO | str | Path ,
131+ output_csv_file : StrPath | BytePath | ty . TextIO ,
73132 scene_list : SceneList ,
74133 include_cut_list : bool = True ,
75134 cut_list : CutList | None = None ,
@@ -93,18 +152,9 @@ def write_scene_list(
93152 Raises:
94153 TypeError: "delimiter" must be a 1-character string
95154 """
96- if isinstance (output_csv_file , (str , Path )):
97- with open (output_csv_file , "w" , newline = "" ) as file_handle :
98- write_scene_list (
99- file_handle ,
100- scene_list ,
101- include_cut_list = include_cut_list ,
102- cut_list = cut_list ,
103- col_separator = col_separator ,
104- row_separator = row_separator ,
105- )
106- return
107- csv_writer = csv .writer (output_csv_file , delimiter = col_separator , lineterminator = row_separator )
155+ assert not isinstance (output_csv_file , (str , bytes , os .PathLike ))
156+ output_file = output_csv_file
157+ csv_writer = csv .writer (output_file , delimiter = col_separator , lineterminator = row_separator )
108158 # If required, output the cutting list as the first row (i.e. before the header row).
109159 if include_cut_list :
110160 csv_writer .writerow (
@@ -304,8 +354,9 @@ def _parse_edl_start_timecode(value: str, frame_rate: Fraction | float) -> int:
304354 return round ((hours * 3600 + minutes * 60 + seconds ) * float (frame_rate )) + frames
305355
306356
357+ @_open_output_file ("EDL" )
307358def write_scene_list_edl (
308- output_path : str | Path ,
359+ output_path : StrPath | BytePath | ty . TextIO ,
309360 scene_list : SceneList ,
310361 title : str = "PySceneDetect" ,
311362 reel : str = "AX" ,
@@ -314,15 +365,17 @@ def write_scene_list_edl(
314365 """Writes the given list of scenes to `output_path` in CMX 3600 EDL format.
315366
316367 Arguments:
317- output_path: Path to write the EDL file to. Parent directories must exist.
368+ output_path: Open text file or path to write the EDL file to. Parent directories must
369+ exist. When a path is provided, the file is closed after writing.
318370 scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end.
319371 title: Title header written as ``TITLE:`` in the EDL.
320372 reel: Reel name used for each event. Typically 2-8 uppercase characters.
321373 start_timecode: Optional SMPTE timecode (``HH:MM:SS:FF`` or 8-digit ``HHMMSSFF``) added to
322374 every event so the EDL aligns with the source media's on-screen timecode. Applied to
323375 both source and record columns.
324376 """
325- output_path = Path (output_path )
377+ assert not isinstance (output_path , (str , bytes , os .PathLike ))
378+ output_file = output_path
326379 offset_frames = 0
327380 if start_timecode is not None and start_timecode .strip () and scene_list :
328381 frame_rate = scene_list [0 ][0 ].frame_rate
@@ -333,14 +386,12 @@ def write_scene_list_edl(
333386 in_tc = _edl_timecode (start + offset_frames )
334387 out_tc = _edl_timecode (end + offset_frames )
335388 lines .append (f"{ (i + 1 ):03d} { reel } V C { in_tc } { out_tc } { in_tc } { out_tc } " )
336- logger .info ("Writing scenes in EDL format to %s" , output_path )
337- with open (output_path , "w" ) as f :
338- # `scenedetect` is imported lazily to avoid a circular import at module load.
339- import scenedetect
389+ # `scenedetect` is imported lazily to avoid a circular import at module load.
390+ import scenedetect
340391
341- f .write (f"* CREATED WITH PYSCENEDETECT { scenedetect .__version__ } \n " )
342- f .write ("\n " .join (lines ))
343- f .write ("\n " )
392+ output_file .write (f"* CREATED WITH PYSCENEDETECT { scenedetect .__version__ } \n " )
393+ output_file .write ("\n " .join (lines ))
394+ output_file .write ("\n " )
344395
345396
346397def _rational_seconds (value : Fraction ) -> str :
@@ -359,8 +410,9 @@ def _frame_timecode_seconds(tc: FrameTimecode) -> Fraction:
359410 return Fraction (tc .pts ) * tc .time_base
360411
361412
413+ @_open_output_file ("FCPX" )
362414def write_scene_list_fcpx (
363- output_path : str | Path ,
415+ output_path : StrPath | BytePath | ty . TextIO ,
364416 scene_list : SceneList ,
365417 video_path : str | Path ,
366418 frame_rate : Fraction ,
@@ -374,7 +426,8 @@ def write_scene_list_fcpx(
374426 https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference
375427
376428 Arguments:
377- output_path: Path to write the FCPXML file to. Parent directories must exist.
429+ output_path: Open text file or path to write the FCPXML file to. Parent directories must
430+ exist. When a path is provided, the file is closed after writing.
378431 scene_list: List of scenes as pairs of FrameTimecodes. Must not be empty.
379432 video_path: Path to the source video file; written into the output as a ``file://`` URI.
380433 frame_rate: Source frame rate as a rational `Fraction` (e.g. ``Fraction(24000, 1001)``).
@@ -383,7 +436,8 @@ def write_scene_list_fcpx(
383436 of `video_path`.
384437 """
385438 assert scene_list
386- output_path = Path (output_path )
439+ assert not isinstance (output_path , (str , bytes , os .PathLike ))
440+ output_file = output_path
387441 video_path = Path (video_path )
388442 if video_name is None :
389443 video_name = video_path .stem
@@ -453,13 +507,12 @@ def write_scene_list_fcpx(
453507 pretty_xml = minidom .parseString (ElementTree .tostring (root , encoding = "unicode" )).toprettyxml (
454508 indent = " "
455509 )
456- logger .info ("Writing scenes in FCPX format to %s" , output_path )
457- with open (output_path , "w" ) as f :
458- f .write (pretty_xml )
510+ output_file .write (pretty_xml )
459511
460512
513+ @_open_output_file ("FCP" )
461514def write_scene_list_fcp7 (
462- output_path : str | Path ,
515+ output_path : StrPath | BytePath | ty . TextIO ,
463516 scene_list : SceneList ,
464517 video_path : str | Path ,
465518 frame_rate : Fraction ,
@@ -474,7 +527,8 @@ def write_scene_list_fcp7(
474527 ``pathurl`` is written as a valid ``file://`` URI per the xmeml spec.
475528
476529 Arguments:
477- output_path: Path to write the xmeml file to. Parent directories must exist.
530+ output_path: Open text file or path to write the xmeml file to. Parent directories must
531+ exist. When a path is provided, the file is closed after writing.
478532 scene_list: List of scenes as pairs of FrameTimecodes. Must not be empty.
479533 video_path: Path to the source video file; written into the output as a ``file://`` URI.
480534 frame_rate: Source frame rate as a rational `Fraction`.
@@ -486,7 +540,8 @@ def write_scene_list_fcp7(
486540 frozen. If None, falls back to the last scene's end time.
487541 """
488542 assert scene_list
489- output_path = Path (output_path )
543+ assert not isinstance (output_path , (str , bytes , os .PathLike ))
544+ output_file = output_path
490545 video_path = Path (video_path )
491546 if video_name is None :
492547 video_name = video_path .stem
@@ -570,16 +625,15 @@ def write_scene_list_fcp7(
570625 pretty_xml = minidom .parseString (ElementTree .tostring (root , encoding = "unicode" )).toprettyxml (
571626 indent = " "
572627 )
573- logger .info ("Writing scenes in FCP format to %s" , output_path )
574- with open (output_path , "w" ) as f :
575- f .write (pretty_xml )
628+ output_file .write (pretty_xml )
576629
577630
578631# TODO: We have to export framerate as a float for OTIO's current format. When OTIO supports
579632# fractional timecodes, we should export the framerate as a rational number instead.
580633# https://github.com/AcademySoftwareFoundation/OpenTimelineIO/issues/190
634+ @_open_output_file ("OTIO" )
581635def write_scene_list_otio (
582- output_path : str | Path ,
636+ output_path : StrPath | BytePath | ty . TextIO ,
583637 scene_list : SceneList ,
584638 video_path : str | Path ,
585639 frame_rate : Fraction ,
@@ -591,15 +645,17 @@ def write_scene_list_otio(
591645 OTIO (OpenTimelineIO) timelines can be imported by many video editors.
592646
593647 Arguments:
594- output_path: Path to write the OTIO file to. Parent directories must exist.
648+ output_path: Open text file or path to write the OTIO file to. Parent directories must
649+ exist. When a path is provided, the file is closed after writing.
595650 scene_list: List of scenes as pairs of FrameTimecodes.
596651 video_path: Path to the source video file; written into the output as an absolute path.
597652 frame_rate: Source frame rate as a rational `Fraction`. Exported as a float, as the
598653 current OTIO format does not support rational timings.
599654 name: Timeline name. Defaults to the stem of `video_path`.
600655 audio: If True (default), include an audio track alongside the video track.
601656 """
602- output_path = Path (output_path )
657+ assert not isinstance (output_path , (str , bytes , os .PathLike ))
658+ output_file = output_path
603659 video_path = Path (video_path )
604660 if name is None :
605661 name = video_path .stem
@@ -680,7 +736,5 @@ def write_scene_list_otio(
680736 },
681737 }
682738
683- logger .info ("Writing scenes in OTIO format to %s" , output_path )
684- with open (output_path , "w" ) as f :
685- json .dump (otio , f , indent = 4 )
686- f .write ("\n " )
739+ json .dump (otio , output_file , indent = 4 )
740+ output_file .write ("\n " )
0 commit comments