Skip to content

Commit dab8b26

Browse files
committed
[feat] Add start TC shift to save-edl functionality #515
1 parent 1691c6d commit dab8b26

6 files changed

Lines changed: 133 additions & 2 deletions

File tree

scenedetect/_cli/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,13 +1597,26 @@ def save_images_command(
15971597
USER_CONFIG.get_help_string("save-edl", "output", show_default=False)
15981598
),
15991599
)
1600+
@click.option(
1601+
"--start-timecode",
1602+
"-s",
1603+
metavar="TIMECODE",
1604+
default=None,
1605+
type=click.STRING,
1606+
help=(
1607+
"Start timecode added to every event so the EDL aligns with the source media's "
1608+
"on-screen timecode. Accepts SMPTE HH:MM:SS:FF or 8 digits (HHMMSSFF, e.g. 01000000)."
1609+
"{}"
1610+
).format(USER_CONFIG.get_help_string("save-edl", "start-timecode", show_default=False)),
1611+
)
16001612
@click.pass_context
16011613
def save_edl_command(
16021614
ctx: click.Context,
16031615
filename: str | None,
16041616
title: str | None,
16051617
reel: str | None,
16061618
output: str | None,
1619+
start_timecode: str | None,
16071620
):
16081621
ctx = ctx.obj
16091622
assert isinstance(ctx, CliContext)
@@ -1613,6 +1626,7 @@ def save_edl_command(
16131626
"title": ctx.config.get_value("save-edl", "title", title),
16141627
"reel": ctx.config.get_value("save-edl", "reel", reel),
16151628
"output": ctx.config.get_value("save-edl", "output", output),
1629+
"start_timecode": ctx.config.get_value("save-edl", "start-timecode", start_timecode),
16161630
}
16171631
ctx.add_command(cli_commands.save_edl, save_edl_args)
16181632

scenedetect/_cli/commands.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ def save_edl(
263263
output: str,
264264
title: str,
265265
reel: str,
266+
start_timecode: str | None,
266267
):
267268
"""Handles the `save-edl` command. Outputs in CMX 3600 format."""
268269
del cuts # We only use scene information.
@@ -277,6 +278,7 @@ def save_edl(
277278
scene_list=scenes,
278279
title=Template(title).safe_substitute(VIDEO_NAME=video_name),
279280
reel=reel,
281+
start_timecode=start_timecode,
280282
)
281283

282284

scenedetect/_cli/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ class FcpFormat(Enum):
424424
"filename": "$VIDEO_NAME.edl",
425425
"output": None,
426426
"reel": "AX",
427+
"start-timecode": None,
427428
"title": "$VIDEO_NAME",
428429
},
429430
"save-html": {

scenedetect/output/__init__.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import csv
1919
import json
2020
import logging
21+
import math
2122
import typing as ty
2223
from fractions import Fraction
2324
from pathlib import Path
@@ -253,11 +254,36 @@ def _edl_timecode(timecode: FrameTimecode) -> str:
253254
return f"{hours:02d}:{minutes:02d}:{seconds:02d}:{frames_part:02d}"
254255

255256

257+
def _parse_edl_start_timecode(value: str, framerate: Fraction | float) -> int:
258+
"""Parse a SMPTE ``HH:MM:SS:FF`` (or 8-digit ``HHMMSSFF``) start timecode into a frame count."""
259+
stripped = value.strip()
260+
if ":" in stripped:
261+
parts = stripped.split(":")
262+
elif stripped.isdigit() and len(stripped) == 8:
263+
parts = [stripped[0:2], stripped[2:4], stripped[4:6], stripped[6:8]]
264+
else:
265+
raise ValueError(
266+
f"Invalid start timecode {value!r}: expected HH:MM:SS:FF or 8 digits (HHMMSSFF)."
267+
)
268+
if len(parts) != 4 or not all(p.isdigit() for p in parts):
269+
raise ValueError(
270+
f"Invalid start timecode {value!r}: expected HH:MM:SS:FF or 8 digits (HHMMSSFF)."
271+
)
272+
hours, minutes, seconds, frames = (int(p) for p in parts)
273+
max_frames = math.ceil(float(framerate))
274+
if minutes >= 60 or seconds >= 60 or frames >= max_frames:
275+
raise ValueError(
276+
f"Invalid start timecode {value!r}: MM<60, SS<60, FF<{max_frames} required."
277+
)
278+
return round((hours * 3600 + minutes * 60 + seconds) * float(framerate)) + frames
279+
280+
256281
def write_scene_list_edl(
257282
output_path: str | Path,
258283
scene_list: SceneList,
259284
title: str = "PySceneDetect",
260285
reel: str = "AX",
286+
start_timecode: str | None = None,
261287
):
262288
"""Writes the given list of scenes to `output_path` in CMX 3600 EDL format.
263289
@@ -266,12 +292,20 @@ def write_scene_list_edl(
266292
scene_list: List of scenes as pairs of FrameTimecodes denoting each scene's start/end.
267293
title: Title header written as ``TITLE:`` in the EDL.
268294
reel: Reel name used for each event. Typically 2-8 uppercase characters.
295+
start_timecode: Optional SMPTE timecode (``HH:MM:SS:FF`` or 8-digit ``HHMMSSFF``) added to
296+
every event so the EDL aligns with the source media's on-screen timecode. Applied to
297+
both source and record columns.
269298
"""
270299
output_path = Path(output_path)
300+
offset_frames = 0
301+
if start_timecode is not None and start_timecode.strip() and scene_list:
302+
framerate = scene_list[0][0].framerate
303+
assert framerate is not None
304+
offset_frames = _parse_edl_start_timecode(start_timecode, framerate)
271305
lines = [f"TITLE: {title}", "FCM: NON-DROP FRAME", ""]
272306
for i, (start, end) in enumerate(scene_list):
273-
in_tc = _edl_timecode(start)
274-
out_tc = _edl_timecode(end)
307+
in_tc = _edl_timecode(start + offset_frames)
308+
out_tc = _edl_timecode(end + offset_frames)
275309
lines.append(f"{(i + 1):03d} {reel} V C {in_tc} {out_tc} {in_tc} {out_tc}")
276310
logger.info("Writing scenes in EDL format to %s", output_path)
277311
with open(output_path, "w") as f:

tests/test_output.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,84 @@ def test_write_scene_list_edl_accepts_str_path(tmp_path: Path):
269269
assert output_path.exists()
270270

271271

272+
def test_write_scene_list_edl_with_start_timecode_smpte(tmp_path: Path):
273+
"""`start_timecode` shifts every event by the supplied SMPTE offset (source + record)."""
274+
scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)])
275+
output_path = tmp_path / "scenes.edl"
276+
write_scene_list_edl(output_path, scenes, start_timecode="01:00:00:00")
277+
278+
content = output_path.read_text()
279+
assert "001 AX V C 01:00:00:00 01:00:01:00 01:00:00:00 01:00:01:00" in content
280+
assert "002 AX V C 01:00:01:00 01:00:02:00 01:00:01:00 01:00:02:00" in content
281+
282+
283+
def test_write_scene_list_edl_with_start_timecode_digits(tmp_path: Path):
284+
"""8-digit form (numpad-friendly) yields the same output as the colon-separated form."""
285+
scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)])
286+
smpte_path = tmp_path / "smpte.edl"
287+
digits_path = tmp_path / "digits.edl"
288+
write_scene_list_edl(smpte_path, scenes, start_timecode="01:00:00:00")
289+
write_scene_list_edl(digits_path, scenes, start_timecode="01000000")
290+
291+
assert smpte_path.read_text() == digits_path.read_text()
292+
293+
294+
def test_write_scene_list_edl_with_start_timecode_subsecond(tmp_path: Path):
295+
"""A sub-second frame offset (FF component) is added to every event."""
296+
scenes = _fake_scenes(_FPS_CFR, [(0, 30)])
297+
output_path = tmp_path / "scenes.edl"
298+
write_scene_list_edl(output_path, scenes, start_timecode="00:00:00:15")
299+
300+
content = output_path.read_text()
301+
assert "001 AX V C 00:00:00:15 00:00:01:15 00:00:00:15 00:00:01:15" in content
302+
303+
304+
def test_write_scene_list_edl_default_no_offset(tmp_path: Path):
305+
"""Omitting `start_timecode` (or passing ``None``/empty) preserves the existing baseline."""
306+
scenes = _fake_scenes(_FPS_CFR, [(0, 30), (30, 60)])
307+
baseline = tmp_path / "baseline.edl"
308+
explicit_none = tmp_path / "none.edl"
309+
explicit_empty = tmp_path / "empty.edl"
310+
write_scene_list_edl(baseline, scenes)
311+
write_scene_list_edl(explicit_none, scenes, start_timecode=None)
312+
write_scene_list_edl(explicit_empty, scenes, start_timecode=" ")
313+
314+
assert baseline.read_text() == explicit_none.read_text() == explicit_empty.read_text()
315+
316+
317+
@pytest.mark.parametrize(
318+
"bad_value",
319+
[
320+
"bogus",
321+
"00:00:00", # 3 segments, not 4
322+
"00:00:00:00:00", # 5 segments
323+
"1234567", # 7 digits
324+
"123456789", # 9 digits
325+
"ab:cd:ef:gh", # non-numeric
326+
],
327+
)
328+
def test_write_scene_list_edl_with_start_timecode_invalid_format(tmp_path: Path, bad_value: str):
329+
"""Malformed start timecodes raise ValueError before writing."""
330+
scenes = _fake_scenes(_FPS_CFR, [(0, 30)])
331+
with pytest.raises(ValueError):
332+
write_scene_list_edl(tmp_path / "scenes.edl", scenes, start_timecode=bad_value)
333+
334+
335+
@pytest.mark.parametrize(
336+
"bad_value",
337+
[
338+
"00:60:00:00", # MM=60
339+
"00:00:60:00", # SS=60
340+
"00:00:00:99", # FF beyond ceil(30 fps)
341+
],
342+
)
343+
def test_write_scene_list_edl_with_start_timecode_out_of_range(tmp_path: Path, bad_value: str):
344+
"""Out-of-range SMPTE components raise ValueError."""
345+
scenes = _fake_scenes(_FPS_CFR, [(0, 30)])
346+
with pytest.raises(ValueError):
347+
write_scene_list_edl(tmp_path / "scenes.edl", scenes, start_timecode=bad_value)
348+
349+
272350
def test_write_scene_list_fcpx(tmp_path: Path):
273351
"""FCPXML output declares version 1.9, rational time strings, and an asset-clip per scene."""
274352
scenes = _fake_scenes(_FPS_NTSC, [(48, 96), (96, 144)])

website/pages/changelog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@ Although there have been minimal changes to most API examples, there are several
678678
- [feature] VFR videos are handled correctly by the OpenCV and PyAV backends, and should work correctly with default parameters
679679
- [feature] New `save-fcp` command allows exporting in Final Cut Pro format (FCP7/FCPX) [#156](https://github.com/Breakthrough/PySceneDetect/issues/156)
680680
- [feature] `--min-scene-len`/`-m` and `save-images --frame-margin`/`-m` now accept seconds (e.g. `0.6s`) and timecodes (e.g. `00:00:00.600`) in addition to a frame count [#531](https://github.com/Breakthrough/PySceneDetect/issues/531)
681+
- [feature] `save-edl` accepts a new `--start-timecode`/`-s` flag (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) to stamp every event with a custom start timecode so generated EDLs align with the source media's on-screen timecode [#515](https://github.com/Breakthrough/PySceneDetect/issues/515)
681682
- [bugfix] Fix floating-point precision error in `save-otio` output where frame values near integer boundaries (e.g. `90.00000000000001`) were serialized with spurious precision
682683
- [bugfix] Add mitigation for transient `OSError` in the MoviePy backend as it is susceptible to subprocess pipe races on slow or heavily loaded systems [#496](https://github.com/Breakthrough/PySceneDetect/issues/496)
683684
- [refactor] Remove deprecated `-d`/`--min-delta-hsv` option from `detect-adaptive` command
@@ -687,6 +688,7 @@ Although there have been minimal changes to most API examples, there are several
687688
**VFR & Timestamp Overhaul:**
688689

689690
* Add `write_scene_list_edl`, `write_scene_list_fcpx`, `write_scene_list_fcp7`, and `write_scene_list_otio` to the `scenedetect.output` module so `save-edl`, `save-fcp`, and `save-otio` can be invoked directly from Python (previously CLI-only)
691+
* `write_scene_list_edl` accepts an optional `start_timecode` parameter (SMPTE `HH:MM:SS:FF` or 8-digit `HHMMSSFF`) that is added to every event's source and record columns [#515](https://github.com/Breakthrough/PySceneDetect/issues/515)
690692
* Add new `Timecode` type to represent frame timings in terms of the video's source timebase
691693
* Add `time_base` and `pts` properties to `FrameTimecode` for more accurate timing information
692694
* All backends (PyAV, OpenCV, MoviePy) now return PTS-backed timestamps from `VideoStream.position`

0 commit comments

Comments
 (0)