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,
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
+25
View File
@@ -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)
+16 -5
View File
@@ -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),)
]
+6
View File
@@ -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;