mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-26 08:24:42 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f82c68f64 | ||
|
|
0ed5561553 |
@@ -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
|
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)
|
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)
|
built = _build_vod_clip(row, span_start, span_end, needs_audio)
|
||||||
|
|
||||||
if built is None:
|
if built is None:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class Recordings(Model):
|
|||||||
audio_rate = IntegerField(null=True) # Hz; NULL = unknown (legacy rows)
|
audio_rate = IntegerField(null=True) # Hz; NULL = unknown (legacy rows)
|
||||||
audio_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows)
|
audio_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows)
|
||||||
video_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows)
|
video_codec = CharField(null=True, max_length=20) # NULL = unknown (legacy rows)
|
||||||
|
audio_duration = FloatField(null=True) # seconds of audio samples; NULL = unknown
|
||||||
|
|
||||||
|
|
||||||
class ExportCase(Model):
|
class ExportCase(Model):
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ from frigate.const import (
|
|||||||
)
|
)
|
||||||
from frigate.models import Recordings, ReviewSegment
|
from frigate.models import Recordings, ReviewSegment
|
||||||
from frigate.review.types import SeverityEnum
|
from frigate.review.types import SeverityEnum
|
||||||
from frigate.util.media import get_keyframe_offsets
|
from frigate.util.media import get_playback_index
|
||||||
from frigate.util.ownership import chown_to_runtime
|
from frigate.util.ownership import chown_to_runtime
|
||||||
from frigate.util.services import get_video_properties
|
from frigate.util.services import get_video_properties
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
self.object_recordings_info: dict[str, list] = defaultdict(list)
|
self.object_recordings_info: dict[str, list] = defaultdict(list)
|
||||||
self.audio_recordings_info: dict[str, list] = defaultdict(list)
|
self.audio_recordings_info: dict[str, list] = defaultdict(list)
|
||||||
# cache_path -> (end_time, duration, has_audio, audio_rate,
|
# cache_path -> (end_time, duration, has_audio, audio_rate,
|
||||||
# audio_codec, video_codec, keyframes)
|
# audio_codec, video_codec, keyframes, audio_duration)
|
||||||
self.end_time_cache: dict[
|
self.end_time_cache: dict[
|
||||||
str,
|
str,
|
||||||
tuple[
|
tuple[
|
||||||
@@ -199,6 +199,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
str | None,
|
str | None,
|
||||||
str | None,
|
str | None,
|
||||||
list[int] | None,
|
list[int] | None,
|
||||||
|
float | None,
|
||||||
],
|
],
|
||||||
] = {}
|
] = {}
|
||||||
# last known capture end per (camera, stream_type); 0.0 marks a key
|
# last known capture end per (camera, stream_type); 0.0 marks a key
|
||||||
@@ -619,6 +620,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
audio_codec,
|
audio_codec,
|
||||||
video_codec,
|
video_codec,
|
||||||
keyframes,
|
keyframes,
|
||||||
|
audio_duration,
|
||||||
) = self.end_time_cache[cache_path]
|
) = self.end_time_cache[cache_path]
|
||||||
# recover the resolved start rather than reusing the truncated
|
# recover the resolved start rather than reusing the truncated
|
||||||
# filename timestamp
|
# filename timestamp
|
||||||
@@ -652,7 +654,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
# playback snaps mid-file entry points against these offsets
|
# playback snaps mid-file entry points against these offsets
|
||||||
# instead of probing files on demand
|
# instead of probing files on demand
|
||||||
async with self.probe_semaphore:
|
async with self.probe_semaphore:
|
||||||
keyframes = await get_keyframe_offsets(cache_path)
|
keyframes, audio_duration = await get_playback_index(cache_path)
|
||||||
|
|
||||||
if previous_start is not None:
|
if previous_start is not None:
|
||||||
await previous_start.wait()
|
await previous_start.wait()
|
||||||
@@ -669,6 +671,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
audio_codec,
|
audio_codec,
|
||||||
video_codec,
|
video_codec,
|
||||||
keyframes,
|
keyframes,
|
||||||
|
audio_duration,
|
||||||
)
|
)
|
||||||
# segments later discarded by retention still advance the
|
# segments later discarded by retention still advance the
|
||||||
# chain for the next kept segment
|
# chain for the next kept segment
|
||||||
@@ -763,6 +766,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
audio_codec,
|
audio_codec,
|
||||||
video_codec,
|
video_codec,
|
||||||
keyframes,
|
keyframes,
|
||||||
|
audio_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
# we fell through the continuous / motion check, so we need to check the review items
|
# we fell through the continuous / motion check, so we need to check the review items
|
||||||
@@ -815,6 +819,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
audio_codec,
|
audio_codec,
|
||||||
video_codec,
|
video_codec,
|
||||||
keyframes,
|
keyframes,
|
||||||
|
audio_duration,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.drop_segment(cache_path)
|
self.drop_segment(cache_path)
|
||||||
@@ -960,6 +965,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
audio_codec: str | None = None,
|
audio_codec: str | None = None,
|
||||||
video_codec: str | None = None,
|
video_codec: str | None = None,
|
||||||
keyframes: list[int] | None = None,
|
keyframes: list[int] | None = None,
|
||||||
|
audio_duration: float | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
path_time = segment_path_time(cache_path) or start_time
|
path_time = segment_path_time(cache_path) or start_time
|
||||||
|
|
||||||
@@ -1053,6 +1059,7 @@ class RecordingMaintainer(threading.Thread):
|
|||||||
Recordings.audio_codec.name: audio_codec,
|
Recordings.audio_codec.name: audio_codec,
|
||||||
Recordings.video_codec.name: video_codec,
|
Recordings.video_codec.name: video_codec,
|
||||||
Recordings.keyframes.name: keyframes,
|
Recordings.keyframes.name: keyframes,
|
||||||
|
Recordings.audio_duration.name: audio_duration,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Unable to store recording segment {cache_path}")
|
logger.exception(f"Unable to store recording segment {cache_path}")
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||||
|
|
||||||
maintainer.end_time_cache = {
|
maintainer.end_time_cache = {
|
||||||
cache_path: (end_time, 10.0, None, None, None, None, None)
|
cache_path: (end_time, 10.0, None, None, None, None, None, None)
|
||||||
}
|
}
|
||||||
# Single processed frame well past end_time with no motion/objects.
|
# Single processed frame well past end_time with no motion/objects.
|
||||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||||
@@ -199,7 +199,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||||
|
|
||||||
maintainer.end_time_cache = {
|
maintainer.end_time_cache = {
|
||||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
cache_path: (end_time, 10.0, False, None, None, None, [], None)
|
||||||
}
|
}
|
||||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||||
maintainer.audio_recordings_info["test_cam"] = []
|
maintainer.audio_recordings_info["test_cam"] = []
|
||||||
@@ -255,7 +255,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||||
|
|
||||||
maintainer.end_time_cache = {
|
maintainer.end_time_cache = {
|
||||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
cache_path: (end_time, 10.0, False, None, None, None, [], None)
|
||||||
}
|
}
|
||||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||||
maintainer.audio_recordings_info["test_cam"] = []
|
maintainer.audio_recordings_info["test_cam"] = []
|
||||||
@@ -313,7 +313,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
|||||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||||
|
|
||||||
maintainer.end_time_cache = {
|
maintainer.end_time_cache = {
|
||||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
cache_path: (end_time, 10.0, False, None, None, None, [], None)
|
||||||
}
|
}
|
||||||
# Metadata has only reached partway into the segment.
|
# Metadata has only reached partway into the segment.
|
||||||
maintainer.object_recordings_info["test_cam"] = [
|
maintainer.object_recordings_info["test_cam"] = [
|
||||||
|
|||||||
@@ -77,8 +77,8 @@ async def _validate_segment(
|
|||||||
with (
|
with (
|
||||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||||
patch(
|
patch(
|
||||||
"frigate.record.maintainer.get_keyframe_offsets",
|
"frigate.record.maintainer.get_playback_index",
|
||||||
AsyncMock(return_value=[0]),
|
AsyncMock(return_value=([0], 10.0)),
|
||||||
),
|
),
|
||||||
patch("frigate.record.maintainer.os.path.getmtime", getmtime),
|
patch("frigate.record.maintainer.os.path.getmtime", getmtime),
|
||||||
):
|
):
|
||||||
@@ -178,7 +178,7 @@ class TestValidateAndMoveSubSegment(unittest.IsolatedAsyncioTestCase):
|
|||||||
# pre-fill the end time cache so no ffprobe is attempted; the
|
# pre-fill the end time cache so no ffprobe is attempted; the
|
||||||
# audio and codec fields stay unknown without one
|
# audio and codec fields stay unknown without one
|
||||||
maintainer.end_time_cache = {
|
maintainer.end_time_cache = {
|
||||||
cache_path: (end_time, 10.0, None, None, None, None, None)
|
cache_path: (end_time, 10.0, None, None, None, None, None, None)
|
||||||
}
|
}
|
||||||
# a processed frame past end_time so the segment is considered ready
|
# a processed frame past end_time so the segment is considered ready
|
||||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||||
@@ -264,8 +264,8 @@ class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase):
|
|||||||
with (
|
with (
|
||||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||||
patch(
|
patch(
|
||||||
"frigate.record.maintainer.get_keyframe_offsets",
|
"frigate.record.maintainer.get_playback_index",
|
||||||
AsyncMock(return_value=[0, 2000]),
|
AsyncMock(return_value=([0, 2000], 9.5)),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await maintainer.validate_and_move_segment(
|
await maintainer.validate_and_move_segment(
|
||||||
@@ -284,6 +284,7 @@ class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(call_args[8], audio_rate)
|
self.assertEqual(call_args[8], audio_rate)
|
||||||
self.assertEqual(call_args[9], audio_codec)
|
self.assertEqual(call_args[9], audio_codec)
|
||||||
self.assertEqual(call_args[11], [0, 2000])
|
self.assertEqual(call_args[11], [0, 2000])
|
||||||
|
self.assertEqual(call_args[12], 9.5)
|
||||||
self.assertEqual(call_args[10], video_codec)
|
self.assertEqual(call_args[10], video_codec)
|
||||||
# the probe result is cached alongside the end time so the
|
# the probe result is cached alongside the end time so the
|
||||||
# cached path stays as informed as the probed path
|
# cached path stays as informed as the probed path
|
||||||
@@ -558,8 +559,8 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
|||||||
with (
|
with (
|
||||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||||
patch(
|
patch(
|
||||||
"frigate.record.maintainer.get_keyframe_offsets",
|
"frigate.record.maintainer.get_playback_index",
|
||||||
AsyncMock(return_value=[0]),
|
AsyncMock(return_value=([0], 10.0)),
|
||||||
),
|
),
|
||||||
patch(
|
patch(
|
||||||
"frigate.record.maintainer.os.path.getmtime",
|
"frigate.record.maintainer.os.path.getmtime",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from peewee import SqliteDatabase
|
|||||||
from frigate.api.media import _vod_response
|
from frigate.api.media import _vod_response
|
||||||
from frigate.const import MAX_SEGMENT_DURATION
|
from frigate.const import MAX_SEGMENT_DURATION
|
||||||
from frigate.models import Recordings
|
from frigate.models import Recordings
|
||||||
|
from frigate.util.media import parse_playback_index
|
||||||
from frigate.util.recording_coverage import (
|
from frigate.util.recording_coverage import (
|
||||||
_rows_query,
|
_rows_query,
|
||||||
coverage_spans,
|
coverage_spans,
|
||||||
@@ -47,6 +48,7 @@ class CoverageDbTestCase(unittest.TestCase):
|
|||||||
has_audio=None,
|
has_audio=None,
|
||||||
segment_size=0,
|
segment_size=0,
|
||||||
keyframes=None,
|
keyframes=None,
|
||||||
|
audio_duration=None,
|
||||||
):
|
):
|
||||||
Recordings.create(
|
Recordings.create(
|
||||||
id=id,
|
id=id,
|
||||||
@@ -62,6 +64,7 @@ class CoverageDbTestCase(unittest.TestCase):
|
|||||||
has_audio=has_audio,
|
has_audio=has_audio,
|
||||||
segment_size=segment_size,
|
segment_size=segment_size,
|
||||||
keyframes=keyframes,
|
keyframes=keyframes,
|
||||||
|
audio_duration=audio_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -419,3 +422,132 @@ class TestVodManifestKeyFrames(CoverageDbTestCase):
|
|||||||
self._insert("m1", 1000.0, 1010.0, "main")
|
self._insert("m1", 1000.0, 1010.0, "main")
|
||||||
clips = self._clips(1000.0, 1010.0)
|
clips = self._clips(1000.0, 1010.0)
|
||||||
assert clips[0]["keyFrameDurations"] == [10000]
|
assert clips[0]["keyFrameDurations"] == [10000]
|
||||||
|
|
||||||
|
|
||||||
|
class TestVodAudioCutShort(CoverageDbTestCase):
|
||||||
|
"""A clip whose audio ends before its clipFrom has no audio track to nginx-vod."""
|
||||||
|
|
||||||
|
KEYFRAMES = [0, 1000, 2000, 3000, 4000, 5000, 6000, 7000, 8000, 9000]
|
||||||
|
|
||||||
|
def _insert_pair(self, first_audio_duration):
|
||||||
|
# the first row's audio dropped out; the range starts 5s into it
|
||||||
|
for id, start, audio_duration in (
|
||||||
|
("m1", 1000.0, first_audio_duration),
|
||||||
|
("m2", 1010.0, 10.0),
|
||||||
|
):
|
||||||
|
self._insert(
|
||||||
|
id,
|
||||||
|
start,
|
||||||
|
start + 10.0,
|
||||||
|
"main",
|
||||||
|
has_audio=True,
|
||||||
|
audio_codec="aac",
|
||||||
|
audio_rate=16000,
|
||||||
|
keyframes=self.KEYFRAMES,
|
||||||
|
audio_duration=audio_duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _clips(self, start, end):
|
||||||
|
response = asyncio.run(_vod_response("front_door", start, end))
|
||||||
|
return json.loads(response.body)["sequences"][0]["clips"]
|
||||||
|
|
||||||
|
def test_clip_past_audio_end_is_skipped(self):
|
||||||
|
self._insert_pair(2.112)
|
||||||
|
clips = self._clips(1005.0, 1020.0)
|
||||||
|
assert [c["path"] for c in clips] == ["/tmp/m2.mp4"]
|
||||||
|
timeline = realized_timeline(
|
||||||
|
resolve_coverage("front_door", 1005.0, 1020.0), None
|
||||||
|
)
|
||||||
|
assert [t["duration"] for t in timeline] == [0, 10000]
|
||||||
|
|
||||||
|
def test_clip_with_audio_left_is_kept(self):
|
||||||
|
# nginx-vod keeps the track while any sample lies past clipFrom
|
||||||
|
self._insert_pair(5.001)
|
||||||
|
clips = self._clips(1005.0, 1020.0)
|
||||||
|
assert clips[0]["clipFrom"] == 5000
|
||||||
|
assert len(clips) == 2
|
||||||
|
|
||||||
|
def test_clip_starting_on_audio_end_is_skipped(self):
|
||||||
|
self._insert_pair(5.0)
|
||||||
|
clips = self._clips(1005.0, 1020.0)
|
||||||
|
assert len(clips) == 1
|
||||||
|
|
||||||
|
def test_legacy_row_is_kept(self):
|
||||||
|
self._insert_pair(None)
|
||||||
|
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)
|
||||||
|
clips = self._clips(1005.0, 1009.0)
|
||||||
|
assert clips[0]["path"] == "/tmp/m1.mp4"
|
||||||
|
timeline = realized_timeline(
|
||||||
|
resolve_coverage("front_door", 1005.0, 1009.0), None
|
||||||
|
)
|
||||||
|
assert timeline[0]["duration"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePlaybackIndex(unittest.TestCase):
|
||||||
|
def test_splits_video_keyframes_from_audio_duration(self):
|
||||||
|
output = "\n".join(
|
||||||
|
[
|
||||||
|
"video,0,0.000000,0.040000,K__",
|
||||||
|
"audio,1,0.000000,0.064000,K__",
|
||||||
|
"video,0,0.040000,0.040000,___",
|
||||||
|
"audio,1,0.064000,0.064000,K__",
|
||||||
|
"video,0,1.000000,0.040000,K__",
|
||||||
|
"audio,1,0.128000,N/A,K__",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
keyframes, audio_duration = parse_playback_index(output)
|
||||||
|
assert keyframes == [0, 1000]
|
||||||
|
assert audio_duration == 0.128
|
||||||
|
|
||||||
|
def test_no_audio_packets_reports_zero(self):
|
||||||
|
keyframes, audio_duration = parse_playback_index(
|
||||||
|
"video,0,0.000000,0.040000,K__"
|
||||||
|
)
|
||||||
|
assert keyframes == [0]
|
||||||
|
assert audio_duration == 0.0
|
||||||
|
|
||||||
|
def test_only_first_video_and_audio_streams_count(self):
|
||||||
|
output = "\n".join(
|
||||||
|
[
|
||||||
|
"video,1,0.000000,0.100000,K__",
|
||||||
|
"video,0,0.000000,0.040000,K__",
|
||||||
|
"audio,3,0.000000,0.064000,K__",
|
||||||
|
"audio,2,0.000000,0.064000,K__",
|
||||||
|
"video,1,2.500000,0.100000,K__",
|
||||||
|
"video,0,1.000000,0.040000,K__",
|
||||||
|
"audio,3,0.064000,0.064000,K__",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
keyframes, audio_duration = parse_playback_index(output)
|
||||||
|
assert keyframes == [0, 1000]
|
||||||
|
assert audio_duration == 0.064
|
||||||
|
|||||||
+36
-18
@@ -938,21 +938,22 @@ def sync_all_media(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
async def get_keyframe_offsets(path: str) -> list[int] | None:
|
async def get_playback_index(path: str) -> tuple[list[int] | None, float | None]:
|
||||||
"""Get every video keyframe offset (ms from segment start) in an mp4.
|
"""Get the video keyframe offsets (ms from segment start) and audio length of an mp4.
|
||||||
|
|
||||||
Runs at record time so playback never needs to probe. Returns None if
|
Runs at record time so playback never needs to probe. The audio length
|
||||||
ffprobe fails, so the caller stores NULL and playback falls back to
|
is the summed duration of the audio samples. nginx-vod ignores edit
|
||||||
serving whole files.
|
lists, so it plays late-starting audio from zero and also counts the
|
||||||
|
AAC priming frame ffprobe drops, which puts this sum at or one frame
|
||||||
|
below its measure. Returns (None, None) if ffprobe fails, so the caller
|
||||||
|
stores NULL and playback falls back to serving whole files.
|
||||||
"""
|
"""
|
||||||
proc = None
|
proc = None
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
FFPROBE_PATH,
|
FFPROBE_PATH,
|
||||||
"-select_streams",
|
|
||||||
"v:0",
|
|
||||||
"-show_entries",
|
"-show_entries",
|
||||||
"packet=pts_time,flags",
|
"packet=codec_type,stream_index,pts_time,duration_time,flags",
|
||||||
"-of",
|
"-of",
|
||||||
"csv=p=0",
|
"csv=p=0",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
@@ -968,22 +969,39 @@ async def get_keyframe_offsets(path: str) -> list[int] | None:
|
|||||||
proc.kill()
|
proc.kill()
|
||||||
with contextlib.suppress(TimeoutError):
|
with contextlib.suppress(TimeoutError):
|
||||||
await asyncio.wait_for(proc.communicate(), timeout=2)
|
await asyncio.wait_for(proc.communicate(), timeout=2)
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
return None
|
return None, None
|
||||||
|
|
||||||
offsets: list[int] = []
|
return parse_playback_index(stdout.decode())
|
||||||
for line in stdout.decode().strip().splitlines():
|
|
||||||
|
|
||||||
|
def parse_playback_index(output: str) -> tuple[list[int], float]:
|
||||||
|
"""Parse ffprobe packet CSV (codec_type,stream_index,pts_time,duration_time,flags).
|
||||||
|
|
||||||
|
Only the first video and first audio stream count, matching the tracks
|
||||||
|
nginx-vod serves.
|
||||||
|
"""
|
||||||
|
keyframes: dict[int, list[int]] = {}
|
||||||
|
audio: dict[int, float] = {}
|
||||||
|
for line in output.strip().splitlines():
|
||||||
parts = line.strip().split(",")
|
parts = line.strip().split(",")
|
||||||
if len(parts) != 2:
|
if len(parts) != 5:
|
||||||
continue
|
|
||||||
ts_str, flags = parts
|
|
||||||
if "K" not in flags:
|
|
||||||
continue
|
continue
|
||||||
|
codec_type, index_str, ts_str, dur_str, flags = parts
|
||||||
try:
|
try:
|
||||||
offsets.append(int(float(ts_str) * 1000))
|
index = int(index_str)
|
||||||
|
if codec_type == "video":
|
||||||
|
stream = keyframes.setdefault(index, [])
|
||||||
|
if "K" in flags:
|
||||||
|
stream.append(int(float(ts_str) * 1000))
|
||||||
|
elif codec_type == "audio":
|
||||||
|
audio[index] = audio.get(index, 0.0) + float(dur_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return offsets
|
return (
|
||||||
|
keyframes[min(keyframes)] if keyframes else [],
|
||||||
|
audio[min(audio)] if audio else 0.0,
|
||||||
|
)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def _rows_query(camera: str, after: float, before: float, stream_type: str) -> A
|
|||||||
Recordings.video_codec,
|
Recordings.video_codec,
|
||||||
Recordings.segment_size,
|
Recordings.segment_size,
|
||||||
Recordings.keyframes,
|
Recordings.keyframes,
|
||||||
|
Recordings.audio_duration,
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
(Recordings.camera == camera)
|
(Recordings.camera == camera)
|
||||||
@@ -340,13 +341,26 @@ 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) -> 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
|
||||||
builder emits exactly this plan and the coverage timelines report it
|
builder emits exactly this plan and the coverage timelines report it
|
||||||
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.
|
||||||
|
|
||||||
|
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
|
||||||
@@ -397,6 +411,22 @@ def plan_clip(row: Any, start: float, end: float) -> ClipPlan:
|
|||||||
)
|
)
|
||||||
return ClipPlan(None, 0, True)
|
return ClipPlan(None, 0, True)
|
||||||
|
|
||||||
|
# audio cut short by a camera dropout can end before the clip starts.
|
||||||
|
# nginx-vod then counts no audio track in this clip and fails the whole
|
||||||
|
# sequence on the track count mismatch
|
||||||
|
if (
|
||||||
|
needs_audio
|
||||||
|
and row.has_audio
|
||||||
|
and row.audio_duration is not None
|
||||||
|
and (clip_from or 0) >= int(row.audio_duration * 1000)
|
||||||
|
):
|
||||||
|
logger.debug(
|
||||||
|
"VOD: skipping recording %s - no audio after %sms",
|
||||||
|
row.path,
|
||||||
|
clip_from or 0,
|
||||||
|
)
|
||||||
|
return ClipPlan(None, 0, True)
|
||||||
|
|
||||||
if duration >= max_duration_ms:
|
if duration >= max_duration_ms:
|
||||||
logger.warning(f"Recording clip is missing or empty: {row.path}")
|
logger.warning(f"Recording clip is missing or empty: {row.path}")
|
||||||
return ClipPlan(None, 0, True)
|
return ClipPlan(None, 0, True)
|
||||||
@@ -445,14 +475,17 @@ def realized_timeline(
|
|||||||
so summing durations reproduces playlist time exactly. A span whose
|
so summing durations reproduces playlist time exactly. A span whose
|
||||||
clip is skipped reports duration 0.
|
clip is skipped reports duration 0.
|
||||||
"""
|
"""
|
||||||
|
spans = build_spans(intervals, stream)
|
||||||
|
needs_audio = sequence_needs_audio(spans)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"start_time": span_start,
|
"start_time": span_start,
|
||||||
"end_time": span_end,
|
"end_time": span_end,
|
||||||
"duration": plan.duration_ms,
|
"duration": plan.duration_ms,
|
||||||
}
|
}
|
||||||
for row, span_start, span_end, _ in build_spans(intervals, stream)
|
for row, span_start, span_end, _ in spans
|
||||||
for plan in (plan_clip(row, span_start, span_end),)
|
for plan in (plan_clip(row, span_start, span_end, needs_audio),)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import random
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime
|
||||||
from multiprocessing import Queue
|
from multiprocessing import Queue
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -256,9 +255,7 @@ def process_frames(
|
|||||||
region_grid: list[list[dict[str, Any]]],
|
region_grid: list[list[dict[str, Any]]],
|
||||||
exit_on_empty: bool = False,
|
exit_on_empty: bool = False,
|
||||||
):
|
):
|
||||||
# cameras share one dispatcher, so stagger the daily grid rebuilds
|
next_region_update = get_tomorrow_at_time(2)
|
||||||
region_update_offset = timedelta(seconds=random.randrange(3600))
|
|
||||||
next_region_update = get_tomorrow_at_time(2) + region_update_offset
|
|
||||||
config_subscriber = CameraConfigUpdateSubscriber(
|
config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
None,
|
None,
|
||||||
{camera_config.name: camera_config},
|
{camera_config.name: camera_config},
|
||||||
@@ -347,7 +344,7 @@ def process_frames(
|
|||||||
if updated_grid:
|
if updated_grid:
|
||||||
region_grid = updated_grid
|
region_grid = updated_grid
|
||||||
|
|
||||||
next_region_update = get_tomorrow_at_time(2) + region_update_offset
|
next_region_update = get_tomorrow_at_time(2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if exit_on_empty:
|
if exit_on_empty:
|
||||||
|
|||||||
@@ -474,10 +474,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
if not self.capture_thread.is_alive():
|
if not self.capture_thread.is_alive():
|
||||||
self.detect_status.send("offline", now)
|
self.detect_status.send("offline", now)
|
||||||
self.camera_fps.value = 0
|
self.camera_fps.value = 0
|
||||||
|
self.logger.error(
|
||||||
|
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||||
|
)
|
||||||
if can_restart:
|
if can_restart:
|
||||||
self.logger.error(
|
|
||||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
|
||||||
)
|
|
||||||
self.reset_capture_thread(terminate=False)
|
self.reset_capture_thread(terminate=False)
|
||||||
last_restart_time = now
|
last_restart_time = now
|
||||||
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
||||||
@@ -498,10 +498,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
elif now - self.capture_thread.current_frame.value > 20:
|
elif now - self.capture_thread.current_frame.value > 20:
|
||||||
self.detect_status.send("offline", now)
|
self.detect_status.send("offline", now)
|
||||||
self.camera_fps.value = 0
|
self.camera_fps.value = 0
|
||||||
|
self.logger.info(
|
||||||
|
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||||
|
)
|
||||||
if can_restart:
|
if can_restart:
|
||||||
self.logger.info(
|
|
||||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
|
||||||
)
|
|
||||||
self.reset_capture_thread()
|
self.reset_capture_thread()
|
||||||
last_restart_time = now
|
last_restart_time = now
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Peewee migrations -- 040_add_recordings_audio_duration.py."""
|
||||||
|
|
||||||
|
import peewee as pw
|
||||||
|
|
||||||
|
SQL = pw.SQL
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(migrator, database, fake=False, **kwargs):
|
||||||
|
# nullable so legacy rows stay NULL, meaning unknown
|
||||||
|
migrator.sql('ALTER TABLE "recordings" ADD COLUMN "audio_duration" REAL')
|
||||||
|
|
||||||
|
|
||||||
|
def rollback(migrator, database, fake=False, **kwargs):
|
||||||
|
pass
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Add-camera wizard Step 1 validation -- MEDIUM tier.
|
|
||||||
*
|
|
||||||
* Continue validates every field, so an invalid ONVIF port or a non-RTSP
|
|
||||||
* custom URL must show its translated message and keep the wizard on Step 1.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../../fixtures/frigate-test";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
|
|
||||||
const PORT_ERROR = "ONVIF port must be a whole number from 1 to 65535";
|
|
||||||
|
|
||||||
async function openWizard(page: Page) {
|
|
||||||
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
|
||||||
const dialog = page.getByRole("dialog");
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
await dialog.getByPlaceholder(/front_door/i).fill("step1_camera");
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Camera wizard step 1 validation @medium @mobile", () => {
|
|
||||||
test.beforeEach(async ({ frigateApp }) => {
|
|
||||||
// not in the default mock; unmocked it 500s and trips the error collector
|
|
||||||
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
|
||||||
route.fulfill({ json: {} }),
|
|
||||||
);
|
|
||||||
await frigateApp.page.route("**/api/onvif/probe**", (route) =>
|
|
||||||
route.fulfill({ json: { success: false } }),
|
|
||||||
);
|
|
||||||
await frigateApp.goto("/settings?page=cameraManagement");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an invalid ONVIF port blocks Continue with a translated message", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
const dialog = await openWizard(frigateApp.page);
|
|
||||||
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
|
||||||
const port = dialog.getByPlaceholder("80", { exact: true });
|
|
||||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
|
||||||
|
|
||||||
for (const value of ["abc", "0", "70000", "80.5"]) {
|
|
||||||
await port.fill(value);
|
|
||||||
await next.click();
|
|
||||||
await expect(dialog.getByText(PORT_ERROR)).toBeVisible();
|
|
||||||
await expect(port).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
await port.fill("8080");
|
|
||||||
await next.click();
|
|
||||||
await expect(port).toBeHidden();
|
|
||||||
await expect(dialog.getByText(PORT_ERROR)).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a custom URL must use RTSP", async ({ frigateApp }) => {
|
|
||||||
const dialog = await openWizard(frigateApp.page);
|
|
||||||
await dialog.getByText("Manual selection", { exact: true }).click();
|
|
||||||
await dialog.getByRole("combobox").click();
|
|
||||||
await frigateApp.page.getByRole("option", { name: "Other" }).click();
|
|
||||||
const url = dialog.getByPlaceholder(
|
|
||||||
"rtsp://username:password@host:port/path",
|
|
||||||
);
|
|
||||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
|
||||||
|
|
||||||
await url.fill("http://192.168.1.100/stream");
|
|
||||||
await next.click();
|
|
||||||
await expect(
|
|
||||||
dialog.getByText(/Custom URLs must begin with "rtsp:\/\/"/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await url.fill("rtsp://192.168.1.100/stream");
|
|
||||||
await next.click();
|
|
||||||
await expect(url).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -121,14 +121,9 @@ async function confirmImport(page: Page) {
|
|||||||
// route change, so #pageRoot is already present and waiting for it
|
// route change, so #pageRoot is already present and waiting for it
|
||||||
// alone can race the navigation. Wait for the "load" event first so
|
// alone can race the navigation. Wait for the "load" event first so
|
||||||
// the evaluate() calls below run against the post-reload document.
|
// the evaluate() calls below run against the post-reload document.
|
||||||
// scoped to the dialog: until it opens, a page-wide "Import" match is the
|
|
||||||
// Import Settings button, which only reopens the file chooser
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForEvent("load"),
|
page.waitForEvent("load"),
|
||||||
page
|
page.getByRole("button", { name: "Import" }).click(),
|
||||||
.getByRole("dialog")
|
|
||||||
.getByRole("button", { name: "Import", exact: true })
|
|
||||||
.click(),
|
|
||||||
]);
|
]);
|
||||||
await page.waitForSelector("#pageRoot");
|
await page.waitForSelector("#pageRoot");
|
||||||
}
|
}
|
||||||
@@ -275,9 +270,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
// naturalAspectLayout preference must not override that.
|
// naturalAspectLayout preference must not override that.
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
||||||
// the view reads the setting once on mount, so load it again after the
|
|
||||||
// write
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await chooseImportFile(
|
await chooseImportFile(
|
||||||
frigateApp.page,
|
frigateApp.page,
|
||||||
|
|||||||
@@ -248,37 +248,4 @@ test.describe("zone rename and delete @medium @mobile", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("inertia below 1 blocks saving and a valid value saves as a number", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
const requests = await installRoutes(frigateApp.page);
|
|
||||||
await frigateApp.goto(SETTINGS_URL);
|
|
||||||
|
|
||||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
|
||||||
const inertia = frigateApp.page.getByRole("textbox", {
|
|
||||||
name: "Inertia",
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
|
||||||
|
|
||||||
await inertia.fill("0");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Inertia must be above 0."),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(save).toBeDisabled();
|
|
||||||
|
|
||||||
await inertia.fill("5");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Inertia must be above 0."),
|
|
||||||
).toBeHidden();
|
|
||||||
await save.click();
|
|
||||||
|
|
||||||
await expect.poll(() => requests.length).toBe(1);
|
|
||||||
expect(requests[0].body).toMatchObject({
|
|
||||||
config_data: {
|
|
||||||
cameras: { front_door: { zones: { driveway: { inertia: 5 } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+32
-4
@@ -83,7 +83,7 @@
|
|||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.53.6",
|
"virtua": "^0.53.6",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -3131,6 +3131,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3148,6 +3151,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3165,6 +3171,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3182,6 +3191,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3199,6 +3211,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3216,6 +3231,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5097,6 +5115,16 @@
|
|||||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-react-hooks/node_modules/zod": {
|
||||||
|
"version": "4.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||||
|
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
|
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||||
@@ -10627,9 +10655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.6.7",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
+1
-1
@@ -95,7 +95,7 @@
|
|||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.53.6",
|
"virtua": "^0.53.6",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -367,8 +367,7 @@
|
|||||||
"nameLength": "Camera name must be 64 characters or less",
|
"nameLength": "Camera name must be 64 characters or less",
|
||||||
"invalidCharacters": "Camera name contains invalid characters",
|
"invalidCharacters": "Camera name contains invalid characters",
|
||||||
"nameExists": "Camera name already exists",
|
"nameExists": "Camera name already exists",
|
||||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams.",
|
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams."
|
||||||
"onvifPortInvalid": "ONVIF port must be a whole number from 1 to 65535"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
const sharedFields = {
|
const sharedFields = {
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
saveAttempts: z.coerce
|
saveAttempts: z.coerce
|
||||||
.number<number>({ message: t("edit.errors.saveAttemptsInvalid") })
|
.number({ message: t("edit.errors.saveAttemptsInvalid") })
|
||||||
.int(t("edit.errors.saveAttemptsInvalid"))
|
.int(t("edit.errors.saveAttemptsInvalid"))
|
||||||
.min(0, t("edit.errors.saveAttemptsInvalid")),
|
.min(0, t("edit.errors.saveAttemptsInvalid")),
|
||||||
};
|
};
|
||||||
@@ -136,7 +136,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
(val) =>
|
(val) =>
|
||||||
val === "" || val === null || val === undefined ? undefined : val,
|
val === "" || val === null || val === undefined ? undefined : val,
|
||||||
z.coerce
|
z.coerce
|
||||||
.number<number>({ message: t("edit.errors.intervalInvalid") })
|
.number({ message: t("edit.errors.intervalInvalid") })
|
||||||
.int(t("edit.errors.intervalInvalid"))
|
.int(t("edit.errors.intervalInvalid"))
|
||||||
.positive(t("edit.errors.intervalInvalid"))
|
.positive(t("edit.errors.intervalInvalid"))
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -171,6 +171,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
formSchema as z.ZodType<
|
formSchema as z.ZodType<
|
||||||
ObjectFormData | StateFormData,
|
ObjectFormData | StateFormData,
|
||||||
|
z.ZodTypeDef,
|
||||||
ObjectFormData | StateFormData
|
ObjectFormData | StateFormData
|
||||||
>,
|
>,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -332,9 +332,7 @@ function PreviewVideoPlayer({
|
|||||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{firstLoad && (
|
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||||
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -557,9 +555,7 @@ function PreviewFramesPlayer({
|
|||||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{firstLoad && (
|
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||||
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,13 +200,13 @@ export default function ZoneEditPane({
|
|||||||
),
|
),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
inertia: z.coerce
|
inertia: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(1, {
|
.min(1, {
|
||||||
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
|
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
|
||||||
})
|
})
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
loitering_time: z.coerce
|
loitering_time: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0, {
|
.min(0, {
|
||||||
message: t(
|
message: t(
|
||||||
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
|
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
|
||||||
@@ -222,35 +222,35 @@ export default function ZoneEditPane({
|
|||||||
review_detections: z.boolean().default(false).optional(),
|
review_detections: z.boolean().default(false).optional(),
|
||||||
speedEstimation: z.boolean().default(false),
|
speedEstimation: z.boolean().default(false),
|
||||||
lineA: z.coerce
|
lineA: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
lineB: z.coerce
|
lineB: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
lineC: z.coerce
|
lineC: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
lineD: z.coerce
|
lineD: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
speed_threshold: z.coerce
|
speed_threshold: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
|
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -81,14 +81,7 @@ export default function Step1NameCamera({
|
|||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||||
onvifPort: z.coerce
|
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||||
.number<number>({
|
|
||||||
message: t("cameraWizard.step1.errors.onvifPortInvalid"),
|
|
||||||
})
|
|
||||||
.int(t("cameraWizard.step1.errors.onvifPortInvalid"))
|
|
||||||
.min(1, t("cameraWizard.step1.errors.onvifPortInvalid"))
|
|
||||||
.max(65535, t("cameraWizard.step1.errors.onvifPortInvalid"))
|
|
||||||
.optional(),
|
|
||||||
useDigestAuth: z.boolean().optional(),
|
useDigestAuth: z.boolean().optional(),
|
||||||
customUrl: z
|
customUrl: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user