keep audio skip consistent with playback

Clips were skipped even when the range would be served video-only, and seeking into a skipped span landed on later footage. The skip now only applies when every row in the range carries audio, and seeks inside a zero-duration span land on the next clip.
This commit is contained in:
Josh Hawkins
2026-10-25 15:58:01 -05:00
parent 0ed5561553
commit 4f82c68f64
4 changed files with 52 additions and 8 deletions
+5 -3
View File
@@ -64,6 +64,7 @@ from frigate.util.recording_coverage import (
plan_clip, plan_clip,
recording_overlap, recording_overlap,
resolve_coverage, resolve_coverage,
sequence_needs_audio,
) )
from frigate.util.time import get_normalized_tz_name from frigate.util.time import get_normalized_tz_name
@@ -624,14 +625,14 @@ async def recording_clip(
def _build_vod_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: ) -> tuple[dict[str, Any], int] | None:
"""Build one nginx-vod clip dict + duration (ms) for a recording row trimmed to [start, end). """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 Realization comes entirely from the shared plan_clip, so the coverage
endpoint's realized timelines match this manifest by construction. 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: if plan.skipped:
return None return None
@@ -690,6 +691,7 @@ async def _vod_response(
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)), null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference, stream_preference,
) )
needs_audio = sequence_needs_audio(spans)
durations: list[int] = [] durations: list[int] = []
clips: list[dict[str, Any]] = [] clips: list[dict[str, Any]] = []
@@ -707,7 +709,7 @@ async def _vod_response(
row.end_time, row.end_time,
row.duration, 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: if built is None:
continue continue
+25
View File
@@ -477,6 +477,31 @@ class TestVodAudioCutShort(CoverageDbTestCase):
clips = self._clips(1005.0, 1020.0) clips = self._clips(1005.0, 1020.0)
assert len(clips) == 2 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): def test_sole_clip_is_kept(self):
# alone in its sequence the clip plays video-only, so keep it # alone in its sequence the clip plays video-only, so keep it
self._insert_pair(2.112) self._insert_pair(2.112)
+16 -5
View File
@@ -341,7 +341,18 @@ class ClipPlan:
first_key_frame_offset_ms: int = 0 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). """Plan one nginx-vod clip for a recording row trimmed to [start, end).
The single source of truth for clip realization: the vod mapping 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 to the frontend, so the playhead model matches the playlist by
construction rather than accumulating drift at each hand-off. 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 min_duration_ms = 100 # Minimum 100ms to ensure at least one video frame
max_duration_ms = MAX_SEGMENT_DURATION * 1000 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 # nginx-vod then counts no audio track in this clip and fails the whole
# sequence on the track count mismatch # sequence on the track count mismatch
if ( if (
shared needs_audio
and row.has_audio and row.has_audio
and row.audio_duration is not None and row.audio_duration is not None
and (clip_from or 0) >= int(row.audio_duration * 1000) and (clip_from or 0) >= int(row.audio_duration * 1000)
@@ -465,7 +476,7 @@ def realized_timeline(
clip is skipped reports duration 0. clip is skipped reports duration 0.
""" """
spans = build_spans(intervals, stream) spans = build_spans(intervals, stream)
shared = len(spans) > 1 needs_audio = sequence_needs_audio(spans)
return [ return [
{ {
@@ -474,7 +485,7 @@ def realized_timeline(
"duration": plan.duration_ms, "duration": plan.duration_ms,
} }
for row, span_start, span_end, _ in spans 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),)
] ]
+6
View File
@@ -61,6 +61,12 @@ export function calculateSeekPosition(
return false; 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 // playlist duration exceeds wall length when the clip carries
// keyframe back-snap lead-in // keyframe back-snap lead-in
const wallLength = segment.end_time - segment.start_time; const wallLength = segment.end_time - segment.start_time;