Repository navigation
Clamp clip duration to the footage remaining after skip_time - #200
Conversation
|
Still need readability approvals from:
|
7007e5a to
df9986a
Compare
SM-7: Fix missing duration clamp in add_video. Neither the duration > 0 nor duration <= 0 branch accounted for skip_time, causing video stream to EOF earlier than audio (which apad padded to clean_duration), producing A/V desync and destroying crossfade transitions in xfade (dropping 6.5 s of clip footage). Both branches now clamp against available footage (properties['duration'] - skip_time) and round to whole frames, keeping at least 1 frame. SM-16: Reject negative start_time, skip_time, and transition_overlap values in _require_finite_number validation to avoid hard crashes (e.g. adelay exit 234) and silent transition drops. Also reject skip_time >= source duration. SM-15: Deterministically re-quantize video input durations when target_fps increases mid-loop so clip insertion order does not affect frame counts or duration. add_image duration contract: Kept explicit validation requiring strictly positive duration (> 0) with a clear error message. Unlike audio or video which have intrinsic file durations discovered via ffprobe fallback, images have no duration so <= 0 is meaningless and failing fast prevents degenerate zero/negative overlays. add_audio duration fallback: Confirmed add_audio permits duration <= 0 (e.g. -1), falling back to get_media_duration(path) as relied on by shipped audio arrangements. Reachability: Not reachable from the shipped UI (CombineVideoArrangement and CombineScenesArrangement declare duration required and emit sites populate it); reachable via hand-authored arrangement JSON supported in actions/combine_video.py. Correctness fix on a supported-but-unexercised input path. Added regression test verifying all shipped workflow_examples arrangements are accepted. Measured A/V deltas (10.0 s source clips): - Before: s1 (skip3, dur=-1) : video=7.0s, audio=10.0s, A/V DELTA +3.000s s3b(skip3, dur=10) : video=7.0s, audio=10.0s, A/V DELTA +3.000s s2 (skip0, dur=-1) : video=10.0s, audio=10.0s, A/V DELTA +0.000s s3 (skip3, dur=7) : video=7.0s, audio=7.0s, A/V DELTA +0.000s s4_concat : video=17.0s, audio=20.0s, A/V DELTA +3.000s s5_xfade : video=8.0s, audio=19.5s, A/V DELTA +11.500s (6.5s destroyed) - After: s1 (skip3, dur=-1) : video=7.0s, audio=7.0s, A/V DELTA +0.000s s3b(skip3, dur=10) : video=7.0s, audio=7.0s, A/V DELTA +0.000s s2 (skip0, dur=-1) : video=10.0s, audio=10.0s, A/V DELTA +0.000s s3 (skip3, dur=7) : video=7.0s, audio=7.0s, A/V DELTA +0.000s s4_concat : video=17.0s, audio=17.0s, A/V DELTA +0.000s s5_xfade : video=14.5s, audio=14.5s, A/V DELTA +0.000s
df9986a to
13972f8
Compare
|
Still need readability approvals from:
|
|
Still need readability approvals from:
|
|
Still need readability approvals from:
|
|
Still need readability approvals from:
|
Review: needs rework — the new guard hard-fails footage that renders todayMulti-agent review (reviewer → independent critique agent re-verifying each claim against the code). The critique pass rejected the reviewer's aggressive test-slimming proposal; details at the bottom. The clamp itself is correct and well-targeted. It's applied once, at input registration, so every downstream consumer ( Caution Blocker: a container with no
|
Consolidated ReviewVerdict: Merge after one small fix. This note reconciles the 19:50 comment on this PR against head Do before merge
Sequencing note: once this lands, re-check whether Optional, does not block
Rejected or superseded, do not re-litigate
Evidence
Way forward
|
|
Still need readability approvals from:
|
Follow-up on Consolidated ReviewI addressed the unknown-duration blocker from the Consolidated Review on head
Verification: the FFmpeg suite passed with 16 tests, including local FFmpeg render and audio/video-sync checks. The unknown-duration regression failed before the fix and passed afterward; an independent 24-to-60-fps probe also preserved the requested duration. The unknown metadata case was mocked, and no deployed container was validated. Current-head GitHub CI is complete: Python 3.11/3.12/3.13, UI build/lint/tests, deploy checks and security scans passed. Conditional zizmor jobs were skipped. |
| if duration > 0: | ||
| effective_duration = ( | ||
| min(duration, available_duration) | ||
| if source_duration > 0 | ||
| else duration | ||
| ) | ||
| else: | ||
| effective_duration = min(source_duration, available_duration) |
There was a problem hiding this comment.
When source_duration <= 0 (unprobeable container duration) and the caller passes duration <= 0 (such as the -1 default in actions/combine_video.py meaning "use the rest of the clip"), _clean_duration enters the else branch (effective_duration = min(0.0, 0.0) = 0.0) and max(1, round(0.0 / frame_duration)) evaluates to 1 frame (1.0 / target_fps, ~0.033s). Instead of failing fast, combine() then passes duration=0.0333... to trim and atrim, silently truncating the entire clip to a single frame.
Raising a ValueError when both duration <= 0 and source_duration <= 0 ensures unprobeable clips require an explicit positive duration rather than silently rendering 1 frame.
| if duration > 0: | |
| effective_duration = ( | |
| min(duration, available_duration) | |
| if source_duration > 0 | |
| else duration | |
| ) | |
| else: | |
| effective_duration = min(source_duration, available_duration) | |
| if duration > 0: | |
| effective_duration = ( | |
| min(duration, available_duration) | |
| if source_duration > 0 | |
| else duration | |
| ) | |
| elif source_duration > 0: | |
| effective_duration = min(source_duration, available_duration) | |
| else: | |
| raise ValueError( | |
| 'Explicit positive duration is required when video source duration is' | |
| ' unknown' | |
| ) |
There was a problem hiding this comment.
Agreed, applied in 453ca1e (also added a Raises: section to the docstring).
This was worse than a silent truncation relative to main, which I checked with real ffmpeg on a 3 s clip. On main, this path produced clean_duration = 0, and ffmpeg treats trim=duration=0 as no limit, so the output was the full 3.000 s. With the max(1, ...) in this PR, it became trim=duration=0.0333 → 0.033 s output. So the PR had turned "whole clip" into "one frame". Failing fast is the right call, since a 0 duration would also corrupt the xfade offset arithmetic.
New test test_unknown_source_duration_requires_explicit_duration: it fails against the previous commit and passes now, and it also asserts nothing was appended to inputs.
| # Control 2: skip_time=3, duration=7 is unchanged (7.0 s) | ||
| ffmpeg4 = FFMPEG() | ||
| ffmpeg4.add_video( | ||
| path='clip.mp4', | ||
| skip_time=3.0, | ||
| duration=7.0, | ||
| transition=None, | ||
| transition_overlap=0, | ||
| ) | ||
| self.assertEqual(ffmpeg4.inputs[0]['duration'], 7.0) |
There was a problem hiding this comment.
With mock_get_props returning duration: 10.0 and skip_time=3.0, available_duration is 7.0. Because Control 2 passes duration=7.0, all four cases in test_add_video_duration_clamp_and_controls assert duration == available_duration (7.0, 7.0, 10.0, 7.0). Passing a duration strictly less than available_duration (e.g. duration=4.0) in Control 2 verifies that an explicit positive duration within the remaining footage is preserved rather than clamped to available_duration.
| # Control 2: skip_time=3, duration=7 is unchanged (7.0 s) | |
| ffmpeg4 = FFMPEG() | |
| ffmpeg4.add_video( | |
| path='clip.mp4', | |
| skip_time=3.0, | |
| duration=7.0, | |
| transition=None, | |
| transition_overlap=0, | |
| ) | |
| self.assertEqual(ffmpeg4.inputs[0]['duration'], 7.0) | |
| # Control 2: skip_time=3, duration=4 (< available 7 s) is unchanged (4.0 s) | |
| ffmpeg4 = FFMPEG() | |
| ffmpeg4.add_video( | |
| path='clip.mp4', | |
| skip_time=3.0, | |
| duration=4.0, | |
| transition=None, | |
| transition_overlap=0, | |
| ) | |
| self.assertEqual(ffmpeg4.inputs[0]['duration'], 4.0) |
There was a problem hiding this comment.
Good catch, applied in 453ca1e. Control 2 now uses duration=4.0 and asserts 4.0.
Mutation check: replacing min(duration, available_duration) with available_duration (i.e. always clamping to the remaining footage) now fails this test. With the old duration=7.0 control it passed.
| v_dur, a_dur = None, None | ||
| for s in probe_data.get('streams', []): | ||
| if s.get('codec_type') == 'video': | ||
| v_dur = float(s.get('duration', probe_data['format']['duration'])) | ||
| elif s.get('codec_type') == 'audio': | ||
| a_dur = float(s.get('duration', probe_data['format']['duration'])) |
There was a problem hiding this comment.
Falling back to probe_data['format']['duration'] if duration is absent from a stream dict can mask A/V stream desync because format.duration is a single shared container value (max(v_dur, a_dur)), which would set v_dur == a_dur and make abs(a_dur - v_dur) == 0.0 pass automatically. Indexing s['duration'] directly ensures the test asserts each stream's independent duration.
| v_dur, a_dur = None, None | |
| for s in probe_data.get('streams', []): | |
| if s.get('codec_type') == 'video': | |
| v_dur = float(s.get('duration', probe_data['format']['duration'])) | |
| elif s.get('codec_type') == 'audio': | |
| a_dur = float(s.get('duration', probe_data['format']['duration'])) | |
| v_dur, a_dur = None, None | |
| for s in probe_data.get('streams', []): | |
| if s.get('codec_type') == 'video': | |
| v_dur = float(s['duration']) | |
| elif s.get('codec_type') == 'audio': | |
| a_dur = float(s['duration']) |
There was a problem hiding this comment.
Agreed, applied in 453ca1e. The same format.duration fallback was also in the crossfade half of this test (the out_xfade probe), so I removed it there too; it had the same masking problem.
I checked that ffprobe reports per-stream duration for these libx264/aac MP4 outputs (video,3.000000 / audio,3.000000), so indexing s['duration'] directly is safe and not flaky. Full suite: 715 passed.
When ffprobe cannot read the container duration and the caller asks for the rest of the clip (duration <= 0), fail fast with a ValueError instead of rendering a single frame. Tighten the clamp control test to a duration below the remaining footage, and assert per-stream ffprobe durations without falling back to the shared container duration.
Summary
Clamp video duration to footage remaining after
skip_time, then align it to the target frame rate. Recalculate earlier clips when a later clip raises the target frame rate, preserving insertion-order-independent durations. Addresses SM-7 and SM-15.An unknown probed duration (
0) does not reject or clamp a positive caller-supplied duration. Known durations still reject seeks at or beyond the end. Negative time values are rejected, images require an explicit positive duration, and omitted audio duration retains its media-duration fallback (SM-16).Validation
git diff --check: passed.The unknown-duration case uses mocked probe metadata; no real asset with an unprobeable duration or deployed container was validated in this follow-up.