mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-26 16:34:43 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df7f4f9697 |
@@ -4,7 +4,7 @@ click == 8.5.*
|
||||
aiohttp == 3.14.*
|
||||
starlette == 1.9.*
|
||||
starlette-context == 0.5.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.159.*
|
||||
fastapi[standard-no-fastapi-cloud-cli] == 0.153.*
|
||||
uvicorn == 0.54.*
|
||||
slowapi == 0.1.*
|
||||
joserfc == 1.7.*
|
||||
@@ -28,7 +28,7 @@ git+https://github.com/fbcotter/py3nvml#egg=py3nvml
|
||||
tzdata == 2026.5
|
||||
pyzmq == 27.2.*
|
||||
ruamel.yaml == 0.19.*
|
||||
tzlocal == 5.4.5
|
||||
tzlocal == 5.4.4
|
||||
requests == 2.34.*
|
||||
types-requests == 2.33.*
|
||||
norfair == 2.3.*
|
||||
@@ -43,13 +43,13 @@ opencv-contrib-python == 4.11.0.*
|
||||
scipy == 1.17.*
|
||||
# OpenVino & ONNX
|
||||
openvino == 2026.4.*
|
||||
onnxruntime == 1.31.*
|
||||
onnxruntime == 1.30.*
|
||||
# Embeddings
|
||||
transformers == 5.20.*
|
||||
# Generative AI
|
||||
google-genai == 2.32.*
|
||||
google-genai == 2.31.*
|
||||
ollama == 0.6.*
|
||||
openai == 3.36.*
|
||||
openai == 3.35.*
|
||||
# push notifications
|
||||
py-vapid == 1.9.6
|
||||
pywebpush == 2.5.*
|
||||
|
||||
+1
-1
@@ -1315,7 +1315,7 @@ def nvinfo():
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
async def logs(
|
||||
service: str = Path(json_schema_extra={"enum": ["frigate", "nginx", "go2rtc"]}),
|
||||
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
|
||||
download: str | None = None,
|
||||
stream: bool | None = False,
|
||||
start: int | None = 0,
|
||||
|
||||
@@ -64,7 +64,6 @@ from frigate.util.recording_coverage import (
|
||||
plan_clip,
|
||||
recording_overlap,
|
||||
resolve_coverage,
|
||||
sequence_needs_audio,
|
||||
)
|
||||
from frigate.util.time import get_normalized_tz_name
|
||||
|
||||
@@ -338,7 +337,7 @@ async def get_snapshot_from_recording(
|
||||
request: Request,
|
||||
camera_name: str,
|
||||
frame_time: float,
|
||||
format: str = Path(json_schema_extra={"enum": ["png", "jpg"]}),
|
||||
format: str = Path(enum=["png", "jpg"]),
|
||||
height: int = None,
|
||||
):
|
||||
if camera_name not in request.app.frigate_config.cameras:
|
||||
@@ -625,14 +624,14 @@ async def recording_clip(
|
||||
|
||||
|
||||
def _build_vod_clip(
|
||||
row: Any, start: float, end: float, needs_audio: bool
|
||||
row: Any, start: float, end: float
|
||||
) -> 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, needs_audio)
|
||||
plan = plan_clip(row, start, end)
|
||||
|
||||
if plan.skipped:
|
||||
return None
|
||||
@@ -691,7 +690,6 @@ 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]] = []
|
||||
@@ -709,7 +707,7 @@ async def _vod_response(
|
||||
row.end_time,
|
||||
row.duration,
|
||||
)
|
||||
built = _build_vod_clip(row, span_start, span_end, needs_audio)
|
||||
built = _build_vod_clip(row, span_start, span_end)
|
||||
|
||||
if built is None:
|
||||
continue
|
||||
@@ -1063,7 +1061,7 @@ async def event_thumbnail(
|
||||
max_cache_age: int = Query(
|
||||
2592000, description="Max cache age in seconds. Default 30 days in seconds."
|
||||
),
|
||||
format: str = Query(default="ios", json_schema_extra={"enum": ["ios", "android"]}),
|
||||
format: str = Query(default="ios", enum=["ios", "android"]),
|
||||
):
|
||||
thumbnail_bytes = None
|
||||
event_complete = False
|
||||
@@ -1860,7 +1858,7 @@ async def preview_mp4(
|
||||
async def review_preview(
|
||||
request: Request,
|
||||
event_id: str,
|
||||
format: str = Query(default="gif", json_schema_extra={"enum": ["gif", "mp4"]}),
|
||||
format: str = Query(default="gif", enum=["gif", "mp4"]),
|
||||
):
|
||||
try:
|
||||
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id)
|
||||
|
||||
@@ -85,7 +85,6 @@ class Recordings(Model):
|
||||
audio_rate = IntegerField(null=True) # Hz; 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)
|
||||
audio_duration = FloatField(null=True) # seconds of audio samples; NULL = unknown
|
||||
|
||||
|
||||
class ExportCase(Model):
|
||||
|
||||
@@ -42,7 +42,7 @@ from frigate.const import (
|
||||
)
|
||||
from frigate.models import Recordings, ReviewSegment
|
||||
from frigate.review.types import SeverityEnum
|
||||
from frigate.util.media import get_playback_index
|
||||
from frigate.util.media import get_keyframe_offsets
|
||||
from frigate.util.ownership import chown_to_runtime
|
||||
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.audio_recordings_info: dict[str, list] = defaultdict(list)
|
||||
# cache_path -> (end_time, duration, has_audio, audio_rate,
|
||||
# audio_codec, video_codec, keyframes, audio_duration)
|
||||
# audio_codec, video_codec, keyframes)
|
||||
self.end_time_cache: dict[
|
||||
str,
|
||||
tuple[
|
||||
@@ -199,7 +199,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
str | None,
|
||||
str | None,
|
||||
list[int] | None,
|
||||
float | None,
|
||||
],
|
||||
] = {}
|
||||
# last known capture end per (camera, stream_type); 0.0 marks a key
|
||||
@@ -620,7 +619,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
audio_duration,
|
||||
) = self.end_time_cache[cache_path]
|
||||
# recover the resolved start rather than reusing the truncated
|
||||
# filename timestamp
|
||||
@@ -654,7 +652,7 @@ class RecordingMaintainer(threading.Thread):
|
||||
# playback snaps mid-file entry points against these offsets
|
||||
# instead of probing files on demand
|
||||
async with self.probe_semaphore:
|
||||
keyframes, audio_duration = await get_playback_index(cache_path)
|
||||
keyframes = await get_keyframe_offsets(cache_path)
|
||||
|
||||
if previous_start is not None:
|
||||
await previous_start.wait()
|
||||
@@ -671,7 +669,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
audio_duration,
|
||||
)
|
||||
# segments later discarded by retention still advance the
|
||||
# chain for the next kept segment
|
||||
@@ -766,7 +763,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
audio_duration,
|
||||
)
|
||||
|
||||
# we fell through the continuous / motion check, so we need to check the review items
|
||||
@@ -819,7 +815,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
audio_codec,
|
||||
video_codec,
|
||||
keyframes,
|
||||
audio_duration,
|
||||
)
|
||||
else:
|
||||
self.drop_segment(cache_path)
|
||||
@@ -965,7 +960,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
audio_codec: str | None = None,
|
||||
video_codec: str | None = None,
|
||||
keyframes: list[int] | None = None,
|
||||
audio_duration: float | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
path_time = segment_path_time(cache_path) or start_time
|
||||
|
||||
@@ -1059,7 +1053,6 @@ class RecordingMaintainer(threading.Thread):
|
||||
Recordings.audio_codec.name: audio_codec,
|
||||
Recordings.video_codec.name: video_codec,
|
||||
Recordings.keyframes.name: keyframes,
|
||||
Recordings.audio_duration.name: audio_duration,
|
||||
}
|
||||
except Exception:
|
||||
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"
|
||||
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, None, None, None, None, None, None)
|
||||
cache_path: (end_time, 10.0, None, None, None, None, None)
|
||||
}
|
||||
# Single processed frame well past end_time with no motion/objects.
|
||||
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"
|
||||
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [], None)
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
||||
}
|
||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||
maintainer.audio_recordings_info["test_cam"] = []
|
||||
@@ -255,7 +255,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [], None)
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
||||
}
|
||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||
maintainer.audio_recordings_info["test_cam"] = []
|
||||
@@ -313,7 +313,7 @@ class TestMaintainer(unittest.IsolatedAsyncioTestCase):
|
||||
cache_path = "/tmp/cache/test_cam@20260417150000+0000.mp4"
|
||||
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [], None)
|
||||
cache_path: (end_time, 10.0, False, None, None, None, [])
|
||||
}
|
||||
# Metadata has only reached partway into the segment.
|
||||
maintainer.object_recordings_info["test_cam"] = [
|
||||
|
||||
@@ -77,8 +77,8 @@ async def _validate_segment(
|
||||
with (
|
||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||
patch(
|
||||
"frigate.record.maintainer.get_playback_index",
|
||||
AsyncMock(return_value=([0], 10.0)),
|
||||
"frigate.record.maintainer.get_keyframe_offsets",
|
||||
AsyncMock(return_value=[0]),
|
||||
),
|
||||
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
|
||||
# audio and codec fields stay unknown without one
|
||||
maintainer.end_time_cache = {
|
||||
cache_path: (end_time, 10.0, None, None, None, None, None, None)
|
||||
cache_path: (end_time, 10.0, None, None, None, None, None)
|
||||
}
|
||||
# a processed frame past end_time so the segment is considered ready
|
||||
maintainer.object_recordings_info["test_cam"] = [(now.timestamp(), [], [], [])]
|
||||
@@ -264,8 +264,8 @@ class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||
patch(
|
||||
"frigate.record.maintainer.get_playback_index",
|
||||
AsyncMock(return_value=([0, 2000], 9.5)),
|
||||
"frigate.record.maintainer.get_keyframe_offsets",
|
||||
AsyncMock(return_value=[0, 2000]),
|
||||
),
|
||||
):
|
||||
await maintainer.validate_and_move_segment(
|
||||
@@ -284,7 +284,6 @@ class TestSegmentAudioPresence(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(call_args[8], audio_rate)
|
||||
self.assertEqual(call_args[9], audio_codec)
|
||||
self.assertEqual(call_args[11], [0, 2000])
|
||||
self.assertEqual(call_args[12], 9.5)
|
||||
self.assertEqual(call_args[10], video_codec)
|
||||
# the probe result is cached alongside the end time so the
|
||||
# cached path stays as informed as the probed path
|
||||
@@ -559,8 +558,8 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch("frigate.record.maintainer.get_video_properties", probe),
|
||||
patch(
|
||||
"frigate.record.maintainer.get_playback_index",
|
||||
AsyncMock(return_value=([0], 10.0)),
|
||||
"frigate.record.maintainer.get_keyframe_offsets",
|
||||
AsyncMock(return_value=[0]),
|
||||
),
|
||||
patch(
|
||||
"frigate.record.maintainer.os.path.getmtime",
|
||||
|
||||
@@ -10,7 +10,6 @@ from peewee import SqliteDatabase
|
||||
from frigate.api.media import _vod_response
|
||||
from frigate.const import MAX_SEGMENT_DURATION
|
||||
from frigate.models import Recordings
|
||||
from frigate.util.media import parse_playback_index
|
||||
from frigate.util.recording_coverage import (
|
||||
_rows_query,
|
||||
coverage_spans,
|
||||
@@ -48,7 +47,6 @@ class CoverageDbTestCase(unittest.TestCase):
|
||||
has_audio=None,
|
||||
segment_size=0,
|
||||
keyframes=None,
|
||||
audio_duration=None,
|
||||
):
|
||||
Recordings.create(
|
||||
id=id,
|
||||
@@ -64,7 +62,6 @@ class CoverageDbTestCase(unittest.TestCase):
|
||||
has_audio=has_audio,
|
||||
segment_size=segment_size,
|
||||
keyframes=keyframes,
|
||||
audio_duration=audio_duration,
|
||||
)
|
||||
|
||||
|
||||
@@ -422,132 +419,3 @@ class TestVodManifestKeyFrames(CoverageDbTestCase):
|
||||
self._insert("m1", 1000.0, 1010.0, "main")
|
||||
clips = self._clips(1000.0, 1010.0)
|
||||
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
|
||||
|
||||
+18
-36
@@ -938,22 +938,21 @@ def sync_all_media(
|
||||
return results
|
||||
|
||||
|
||||
async def get_playback_index(path: str) -> tuple[list[int] | None, float | None]:
|
||||
"""Get the video keyframe offsets (ms from segment start) and audio length of an mp4.
|
||||
async def get_keyframe_offsets(path: str) -> list[int] | None:
|
||||
"""Get every video keyframe offset (ms from segment start) in an mp4.
|
||||
|
||||
Runs at record time so playback never needs to probe. The audio length
|
||||
is the summed duration of the audio samples. nginx-vod ignores edit
|
||||
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.
|
||||
Runs at record time so playback never needs to probe. Returns None if
|
||||
ffprobe fails, so the caller stores NULL and playback falls back to
|
||||
serving whole files.
|
||||
"""
|
||||
proc = None
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
FFPROBE_PATH,
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"packet=codec_type,stream_index,pts_time,duration_time,flags",
|
||||
"packet=pts_time,flags",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
"-loglevel",
|
||||
@@ -969,39 +968,22 @@ async def get_playback_index(path: str) -> tuple[list[int] | None, float | None]
|
||||
proc.kill()
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(proc.communicate(), timeout=2)
|
||||
return None, None
|
||||
return None
|
||||
|
||||
if proc.returncode != 0:
|
||||
return None, None
|
||||
return None
|
||||
|
||||
return parse_playback_index(stdout.decode())
|
||||
|
||||
|
||||
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():
|
||||
offsets: list[int] = []
|
||||
for line in stdout.decode().strip().splitlines():
|
||||
parts = line.strip().split(",")
|
||||
if len(parts) != 5:
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
ts_str, flags = parts
|
||||
if "K" not in flags:
|
||||
continue
|
||||
codec_type, index_str, ts_str, dur_str, flags = parts
|
||||
try:
|
||||
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)
|
||||
offsets.append(int(float(ts_str) * 1000))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return (
|
||||
keyframes[min(keyframes)] if keyframes else [],
|
||||
audio[min(audio)] if audio else 0.0,
|
||||
)
|
||||
return offsets
|
||||
|
||||
@@ -49,7 +49,6 @@ def _rows_query(camera: str, after: float, before: float, stream_type: str) -> A
|
||||
Recordings.video_codec,
|
||||
Recordings.segment_size,
|
||||
Recordings.keyframes,
|
||||
Recordings.audio_duration,
|
||||
)
|
||||
.where(
|
||||
(Recordings.camera == camera)
|
||||
@@ -341,26 +340,13 @@ class ClipPlan:
|
||||
first_key_frame_offset_ms: int = 0
|
||||
|
||||
|
||||
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:
|
||||
def plan_clip(row: Any, start: float, end: float) -> 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
|
||||
builder emits exactly this plan and the coverage timelines report it
|
||||
to the frontend, so the playhead model matches the playlist by
|
||||
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
|
||||
max_duration_ms = MAX_SEGMENT_DURATION * 1000
|
||||
@@ -411,22 +397,6 @@ def plan_clip(
|
||||
)
|
||||
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:
|
||||
logger.warning(f"Recording clip is missing or empty: {row.path}")
|
||||
return ClipPlan(None, 0, True)
|
||||
@@ -475,17 +445,14 @@ def realized_timeline(
|
||||
so summing durations reproduces playlist time exactly. A span whose
|
||||
clip is skipped reports duration 0.
|
||||
"""
|
||||
spans = build_spans(intervals, stream)
|
||||
needs_audio = sequence_needs_audio(spans)
|
||||
|
||||
return [
|
||||
{
|
||||
"start_time": span_start,
|
||||
"end_time": span_end,
|
||||
"duration": plan.duration_ms,
|
||||
}
|
||||
for row, span_start, span_end, _ in spans
|
||||
for plan in (plan_clip(row, span_start, span_end, needs_audio),)
|
||||
for row, span_start, span_end, _ in build_spans(intervals, stream)
|
||||
for plan in (plan_clip(row, span_start, span_end),)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
"""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
|
||||
Generated
+116
-134
@@ -37,7 +37,7 @@
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"apexcharts": "^8.4.1",
|
||||
"apexcharts": "^8.3.1",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -45,15 +45,15 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.6.0",
|
||||
"framer-motion": "^14.4.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.5.5",
|
||||
"i18next": "^26.5.3",
|
||||
"i18next-http-backend": "^4.0.4",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"konva": "^10.9.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"lucide-react": "^1.60.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-dropzone": "^20.1.4",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-i18next": "^17.1.1",
|
||||
"react-i18next": "^17.1.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.53.6",
|
||||
"virtua": "^0.53.5",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -89,7 +89,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.27",
|
||||
"@types/lodash": "^4.17.26",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.72.0",
|
||||
"vite": "^8.3.5"
|
||||
"vite": "^8.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@aashutoshrathi/word-wrap": {
|
||||
@@ -1695,9 +1695,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
||||
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3022,9 +3022,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
||||
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3039,9 +3039,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3056,9 +3056,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3073,9 +3073,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3090,9 +3090,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3107,9 +3107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
|
||||
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3124,16 +3124,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3144,16 +3141,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3164,16 +3158,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3184,16 +3175,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3204,16 +3192,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3224,16 +3209,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3244,9 +3226,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3261,9 +3243,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3278,9 +3260,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3607,9 +3589,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.27.tgz",
|
||||
"integrity": "sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==",
|
||||
"version": "4.17.26",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz",
|
||||
"integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4079,18 +4061,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/apex-commons": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/apex-commons/-/apex-commons-0.8.3.tgz",
|
||||
"integrity": "sha512-WdU5lblyL1ByI/nkFOJkSgJYenCsHmay0P3UIcnSeUiCyF1b30SvPSZLbyxOCUOry8IlL7M0/3GvsGbZTMm8FA==",
|
||||
"license": "SEE LICENSE IN LICENSE.md"
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/apex-commons/-/apex-commons-0.8.1.tgz",
|
||||
"integrity": "sha512-F7fqJVh0smeTu1d86uuRSNNZvaM/l11GNQMbu6oAiSvuXp+K8KCufg0oZTy2VQBEEipRLAdFqkHnphlVpTG0Gw==",
|
||||
"license": "SEE LICENSE IN LICENSE"
|
||||
},
|
||||
"node_modules/apexcharts": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.4.1.tgz",
|
||||
"integrity": "sha512-29IbZ1rn3NRkDvete5EzZXy6yDSW7mjNXkytLpvaYHk0XbfsR+FvC7U78L3wMvJa699NBmix2z6r3Qm5YDvw0A==",
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.3.1.tgz",
|
||||
"integrity": "sha512-Z0LX1rPyXBe85082t9EqUWzikOdLy3Z184cvL+4uYS7J4kyLAzK50JBtAGKH5OU8Y6EOPqiH8BqjxjMIBF2+gw==",
|
||||
"license": "SEE LICENSE IN LICENSE.md",
|
||||
"dependencies": {
|
||||
"apex-commons": "^0.8.3"
|
||||
"apex-commons": "^0.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
@@ -5610,13 +5592,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "14.6.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz",
|
||||
"integrity": "sha512-MzBgZxGgoHPfSLEXmo4o5XlZvb+nVRFdHtGjVhm6LMIEJKTt/8hNO023euL5mYyzc8llV/hQTfjnN68tNRHalw==",
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.4.0.tgz",
|
||||
"integrity": "sha512-HtO1fxord2rnXYRyr1laG4uY7+OtgwDc9Szcg1wlMq/LF7BO3/S7a+KEo691U7pSi+i0ueRuRqaD1UaLEmZHuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "14.6.0",
|
||||
"motion-utils": "14.6.0",
|
||||
"motion-dom": "14.4.0",
|
||||
"motion-utils": "14.1.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -6019,9 +6001,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.5.5",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.5.tgz",
|
||||
"integrity": "sha512-txdaNOlnZCdiTMSlEzl5hUJU8TC9R4J/RCR1G7XrDPbWT2t3mR142QPz84ZuZeXDt3yq+9zmsdPiakxYtmp3fw==",
|
||||
"version": "26.5.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.3.tgz",
|
||||
"integrity": "sha512-6pRwOwJ6lBCBB66fqNZaSoUmvsALHMord7FL6GsDhubT1CrPabTMEJIqddm0nn2YEbKofdI/3VAtGrQPuztY1Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -7006,9 +6988,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.60.0.tgz",
|
||||
"integrity": "sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -8083,18 +8065,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "14.6.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz",
|
||||
"integrity": "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A==",
|
||||
"version": "14.4.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.4.0.tgz",
|
||||
"integrity": "sha512-1q8RyHSdr5COpzrv3JgJ9XRjOO88ZN9CwflpkGgyO/SR/SNRP/dD6EU8SEo092kcKY34IzHC5diJPKrQRMA8lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "14.6.0"
|
||||
"motion-utils": "14.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "14.6.0",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.6.0.tgz",
|
||||
"integrity": "sha512-bZJo2AmQ2aOFkaato6VXrTbkPG0XhMzivWUWSaI59pxyllj8QMQUFZ84MYRQnPkK+7dr4KldyJwF2+RM0sdq/w==",
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.1.1.tgz",
|
||||
"integrity": "sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
@@ -9087,9 +9069,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.1.tgz",
|
||||
"integrity": "sha512-Q76xpzxrZNcqhB7M31Ns7isulGOlmhURqRsJOGlYgJJCyoy1pMq6tRl8KQycGNNY1vPaxbIdrogrqY9nkb+SEA==",
|
||||
"version": "17.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz",
|
||||
"integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
@@ -9505,13 +9487,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
|
||||
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@oxc-project/types": "=0.153.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -9521,21 +9503,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.13",
|
||||
"@rolldown/binding-android-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-x64": "1.2.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.13"
|
||||
}
|
||||
},
|
||||
"node_modules/run-async": {
|
||||
@@ -10392,9 +10374,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/virtua": {
|
||||
"version": "0.53.6",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.6.tgz",
|
||||
"integrity": "sha512-/vQtSFNDFcp3Yz4klbpe8Z88eQeThSw49cFlgJQdsqh3SUnRlW4jLi6ZYt4YzdwerYGlP6KCgAOXNVO+PmUg7w==",
|
||||
"version": "0.53.5",
|
||||
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.5.tgz",
|
||||
"integrity": "sha512-hhtrEli6LkMzKXhqhuStnaU5DECVLjxwr/FbrGcnkId2gyKQJ/5E6LE/T2F4OzyghEOsOCl/g+0DyxJUmkah8Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=20.0.0",
|
||||
@@ -10430,16 +10412,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -10456,7 +10438,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
+8
-8
@@ -49,7 +49,7 @@
|
||||
"@rjsf/shadcn": "^6.10.1",
|
||||
"@rjsf/utils": "^6.10.1",
|
||||
"@rjsf/validator-ajv8": "^6.10.1",
|
||||
"apexcharts": "^8.4.1",
|
||||
"apexcharts": "^8.3.1",
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -57,15 +57,15 @@
|
||||
"copy-to-clipboard": "^4.0.2",
|
||||
"date-fns": "^4.4.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"framer-motion": "^14.6.0",
|
||||
"framer-motion": "^14.4.0",
|
||||
"hls.js": "^1.7.3",
|
||||
"i18next": "^26.5.5",
|
||||
"i18next": "^26.5.3",
|
||||
"i18next-http-backend": "^4.0.4",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"konva": "^10.9.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"lucide-react": "^1.60.0",
|
||||
"monaco-yaml": "^5.5.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"nosleep.js": "^0.12.0",
|
||||
@@ -77,7 +77,7 @@
|
||||
"react-dropzone": "^20.1.4",
|
||||
"react-grid-layout": "^2.3.0",
|
||||
"react-hook-form": "^7.90.0",
|
||||
"react-i18next": "^17.1.1",
|
||||
"react-i18next": "^17.1.0",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-konva": "^19.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-long-press": "^3.3.0",
|
||||
"vaul": "^1.1.2",
|
||||
"virtua": "^0.53.6",
|
||||
"virtua": "^0.53.5",
|
||||
"vite-plugin-monaco-editor": "^1.1.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
@@ -101,7 +101,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.27",
|
||||
"@types/lodash": "^4.17.26",
|
||||
"@types/node": "^26.6.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
@@ -123,7 +123,7 @@
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.72.0",
|
||||
"vite": "^8.3.5"
|
||||
"vite": "^8.3.4"
|
||||
},
|
||||
"overrides": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
|
||||
@@ -61,12 +61,6 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user