diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 2940a11c..478ff1dc 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -387,4 +387,5 @@ def save_otio( frame_rate=video_stream.frame_rate, name=Template(name).safe_substitute(VIDEO_NAME=video_name), audio=audio, + source_duration=video_stream.duration, ) diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 69495cfe..02f109cd 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -656,6 +656,7 @@ def write_scene_list_otio( frame_rate: Fraction, name: str | None = None, audio: bool = True, + source_duration: FrameTimecode | None = None, ): """Writes the given list of scenes to `output_path` as an OTIO Timeline.1 JSON document. @@ -670,6 +671,9 @@ def write_scene_list_otio( current OTIO format does not support rational timings. name: Timeline name. Defaults to the stem of `video_path`. audio: If True (default), include an audio track alongside the video track. + source_duration: Total duration of the source media. Written as the media reference's + available range so NLEs can seek into the source. If None, the available range is + left unspecified rather than inferred from the selected scenes. """ # `_open_output_file` replaces filesystem paths with writable text streams. assert isinstance(output_path, _SupportsWrite) @@ -681,6 +685,21 @@ def write_scene_list_otio( video_base_name = video_path.name video_abs_path = str(video_path.absolute()) fps = float(frame_rate) + available_range = None + if source_duration is not None: + available_range = { + "OTIO_SCHEMA": "TimeRange.1", + "duration": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": round(source_duration.seconds * fps, 6), + }, + "start_time": { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 0.0, + }, + } # List of track mapping to resource type. # TODO(https://scenedetect.com/issues/497): Allow OTIO export without an audio track. @@ -726,19 +745,7 @@ def write_scene_list_otio( "DEFAULT_MEDIA": { "OTIO_SCHEMA": "ExternalReference.1", "name": video_base_name, - "available_range": { - "OTIO_SCHEMA": "TimeRange.1", - "duration": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": fps, - "value": 1980.0, - }, - "start_time": { - "OTIO_SCHEMA": "RationalTime.1", - "rate": fps, - "value": 0.0, - }, - }, + "available_range": available_range, "available_image_bounds": None, "target_url": video_abs_path, } diff --git a/tests/test_output.py b/tests/test_output.py index 13d1c738..da7a23d2 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -654,3 +654,73 @@ def test_write_scene_list_otio_output_target(tmp_path: Path, target_kind: str): assert "\r\n" not in text doc = json.loads(text) assert doc["OTIO_SCHEMA"] == "Timeline.1" + + +@pytest.mark.parametrize("frame_rate", [_FPS_CFR, _FPS_NTSC]) +@pytest.mark.parametrize("audio", [False, True]) +@pytest.mark.parametrize("ranges", [[], [(0, 24)], [(2400, 4800)], [(24, 72), (3000, 3600)]]) +def test_write_scene_list_otio_unknown_available_range(tmp_path, frame_rate, audio, ranges): + """Scene ranges do not reveal the source video's full available duration.""" + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path, + scene_list=_fake_scenes(frame_rate, ranges), + video_path=tmp_path / "source.mp4", + frame_rate=frame_rate, + audio=audio, + ) + + doc = json.loads(output_path.read_text()) + tracks = doc["tracks"]["children"] + assert len(tracks) == (2 if audio else 1) + for track in tracks: + assert len(track["children"]) == len(ranges) + for clip, (start, end) in zip(track["children"], ranges, strict=True): + ref = clip["media_references"]["DEFAULT_MEDIA"] + assert ref["available_range"] is None + assert clip["source_range"]["start_time"]["value"] == pytest.approx(start) + assert clip["source_range"]["duration"]["value"] == pytest.approx(end - start) + + +@pytest.mark.parametrize("frame_rate", [_FPS_CFR, _FPS_NTSC]) +@pytest.mark.parametrize("source_frame_rate", [_FPS_CFR, _FPS_NTSC]) +@pytest.mark.parametrize("source_frames", [0, 6000]) +@pytest.mark.parametrize("audio", [False, True]) +def test_write_scene_list_otio_source_duration( + tmp_path, frame_rate, source_frame_rate, source_frames, audio +): + """Available media duration uses the full source, not the selected scenes.""" + source_duration = FrameTimecode(source_frames, fps=source_frame_rate) + ranges = [(24, 72), (120, 180)] if source_frames else [(0, 0)] + output_path = tmp_path / "scenes.otio" + write_scene_list_otio( + output_path, + scene_list=_fake_scenes(frame_rate, ranges), + video_path=tmp_path / "source.mp4", + frame_rate=frame_rate, + audio=audio, + source_duration=source_duration, + ) + + doc = json.loads(output_path.read_text()) + fps = float(frame_rate) + expected_duration = round(source_duration.seconds * fps, 6) + tracks = doc["tracks"]["children"] + assert len(tracks) == (2 if audio else 1) + for track in tracks: + assert len(track["children"]) == len(ranges) + for clip, (start, end) in zip(track["children"], ranges, strict=True): + available = clip["media_references"]["DEFAULT_MEDIA"]["available_range"] + assert available["OTIO_SCHEMA"] == "TimeRange.1" + assert available["start_time"] == { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": 0.0, + } + assert available["duration"] == { + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": expected_duration, + } + assert clip["source_range"]["start_time"]["value"] == pytest.approx(start) + assert clip["source_range"]["duration"]["value"] == pytest.approx(end - start) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1610808e..a1c6fbbe 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -3,6 +3,10 @@ ## PySceneDetect 0.7 +### Unreleased + + - [bugfix] OTIO exports use the full source duration when provided, including from the CLI, instead of declaring a fixed 1,980-frame duration. The available range remains unspecified when the duration is unknown. Scene source ranges are unchanged. [#580](https://github.com/Breakthrough/PySceneDetect/pull/580) + ### PySceneDetect 0.7.2 (October 11, 2026) PySceneDetect 0.7.2 ships improved defaults for the histogram and perceptual hash detectors, adds PyAV 19 support, and fixes several timing and output bugs. **Default output of `detect-hist` and `detect-hash` will change in this release due to improved default values**.