diff --git a/frigate/api/media.py b/frigate/api/media.py index fb22d40906..34448bddc0 100644 --- a/frigate/api/media.py +++ b/frigate/api/media.py @@ -64,6 +64,7 @@ from frigate.util.recording_coverage import ( plan_clip, recording_overlap, resolve_coverage, + sequence_needs_audio, ) from frigate.util.time import get_normalized_tz_name @@ -624,14 +625,14 @@ async def recording_clip( def _build_vod_clip( - row: Any, start: float, end: float, shared: bool + row: Any, start: float, end: float, needs_audio: bool ) -> tuple[dict[str, Any], int] | None: """Build one nginx-vod clip dict + duration (ms) for a recording row trimmed to [start, end). Realization comes entirely from the shared plan_clip, so the coverage endpoint's realized timelines match this manifest by construction. """ - plan = plan_clip(row, start, end, shared) + plan = plan_clip(row, start, end, needs_audio) if plan.skipped: return None @@ -690,6 +691,7 @@ async def _vod_response( null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)), stream_preference, ) + needs_audio = sequence_needs_audio(spans) durations: list[int] = [] clips: list[dict[str, Any]] = [] @@ -707,7 +709,7 @@ async def _vod_response( row.end_time, row.duration, ) - built = _build_vod_clip(row, span_start, span_end, len(spans) > 1) + built = _build_vod_clip(row, span_start, span_end, needs_audio) if built is None: continue diff --git a/frigate/test/test_recording_coverage.py b/frigate/test/test_recording_coverage.py index 45545493b1..67b324f171 100644 --- a/frigate/test/test_recording_coverage.py +++ b/frigate/test/test_recording_coverage.py @@ -477,6 +477,31 @@ class TestVodAudioCutShort(CoverageDbTestCase): clips = self._clips(1005.0, 1020.0) assert len(clips) == 2 + def test_video_only_range_keeps_clip(self): + # a video-only sub row serves the whole range without audio, so + # nginx-vod never compares audio tracks + self._insert( + "m1", + 1000.0, + 1010.0, + "main", + has_audio=True, + audio_codec="aac", + audio_rate=16000, + keyframes=self.KEYFRAMES, + audio_duration=2.112, + ) + self._insert( + "s1", 1010.0, 1020.0, "sub", has_audio=False, keyframes=self.KEYFRAMES + ) + clips = self._clips(1005.0, 1020.0) + assert [c["path"] for c in clips] == ["/tmp/m1.mp4", "/tmp/s1.mp4"] + assert all(c["tracks"] == "v" for c in clips) + timeline = realized_timeline( + resolve_coverage("front_door", 1005.0, 1020.0), None + ) + assert timeline[0]["duration"] > 0 + def test_sole_clip_is_kept(self): # alone in its sequence the clip plays video-only, so keep it self._insert_pair(2.112) diff --git a/frigate/util/recording_coverage.py b/frigate/util/recording_coverage.py index 305d231d1d..34ae1c983c 100644 --- a/frigate/util/recording_coverage.py +++ b/frigate/util/recording_coverage.py @@ -341,7 +341,18 @@ class ClipPlan: first_key_frame_offset_ms: int = 0 -def plan_clip(row: Any, start: float, end: float, shared: bool = False) -> ClipPlan: +def sequence_needs_audio(spans: list[list[Any]]) -> bool: + """Whether nginx-vod will require every clip in these spans to carry audio. + + A range mixing audio-bearing and audio-less rows is served video-only, + and a single clip has nothing to mismatch against. + """ + return len(spans) > 1 and all(row.has_audio is not False for row, *_ in spans) + + +def plan_clip( + row: Any, start: float, end: float, needs_audio: bool = False +) -> ClipPlan: """Plan one nginx-vod clip for a recording row trimmed to [start, end). The single source of truth for clip realization: the vod mapping @@ -349,7 +360,7 @@ def plan_clip(row: Any, start: float, end: float, shared: bool = False) -> ClipP to the frontend, so the playhead model matches the playlist by construction rather than accumulating drift at each hand-off. - shared marks a clip that sits in a sequence with other clips. + needs_audio comes from sequence_needs_audio for the clip's range. """ min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame max_duration_ms = MAX_SEGMENT_DURATION * 1000 @@ -404,7 +415,7 @@ def plan_clip(row: Any, start: float, end: float, shared: bool = False) -> ClipP # nginx-vod then counts no audio track in this clip and fails the whole # sequence on the track count mismatch if ( - shared + needs_audio and row.has_audio and row.audio_duration is not None and (clip_from or 0) >= int(row.audio_duration * 1000) @@ -465,7 +476,7 @@ def realized_timeline( clip is skipped reports duration 0. """ spans = build_spans(intervals, stream) - shared = len(spans) > 1 + needs_audio = sequence_needs_audio(spans) return [ { @@ -474,7 +485,7 @@ def realized_timeline( "duration": plan.duration_ms, } for row, span_start, span_end, _ in spans - for plan in (plan_clip(row, span_start, span_end, shared),) + for plan in (plan_clip(row, span_start, span_end, needs_audio),) ] diff --git a/web/src/utils/videoUtil.ts b/web/src/utils/videoUtil.ts index 6b471665fe..940706adac 100644 --- a/web/src/utils/videoUtil.ts +++ b/web/src/utils/videoUtil.ts @@ -61,6 +61,12 @@ export function calculateSeekPosition( return false; } + // skipped clips have no playlist media, so a seek inside one lands on + // the next clip that plays + if (segment.duration === 0) { + return true; + } + // playlist duration exceeds wall length when the clip carries // keyframe back-snap lead-in const wallLength = segment.end_time - segment.start_time;