Skip to content

Commit a3ffe29

Browse files
authored
Merge branch 'main' into fix/issue-408
2 parents 019631a + 56ab41b commit a3ffe29

4 files changed

Lines changed: 194 additions & 69 deletions

File tree

‎scenedetect/output/__init__.py‎

Lines changed: 96 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,13 @@
1616
"""
1717

1818
import csv
19+
import functools
20+
import inspect
21+
import io
1922
import json
2023
import logging
2124
import math
25+
import os
2226
import typing as ty
2327
from fractions import Fraction
2428
from pathlib import Path
@@ -64,12 +68,67 @@
6468
from scenedetect.output.video import (
6569
split_video_mkvmerge as split_video_mkvmerge,
6670
)
71+
from scenedetect.platform import BytePath, StrPath
6772

6873
logger = 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")
71130
def 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")
307358
def 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

346397
def _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")
362414
def 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")
461514
def 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")
581635
def 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")

‎scenedetect/platform.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
"""Type hint for filesystem paths. Accepts a `str` or any object implementing :class:`os.PathLike`
3232
(e.g. :class:`pathlib.Path`)."""
3333

34+
BytePath = bytes | os.PathLike[bytes]
35+
"""Type hint for byte-based filesystem paths. Accepts `bytes` or any object implementing
36+
:class:`os.PathLike` that returns `bytes`."""
37+
3438
DEBUG_MODE: bool = os.environ.get("SCENEDETECT_DEBUG", "").strip().lower() not in (
3539
"",
3640
"0",

0 commit comments

Comments
 (0)