Compare commits

..
Author SHA1 Message Date
Josh Hawkins 4f82c68f64 keep audio skip consistent with playback
Clips were skipped even when the range would be served video-only, and seeking into a skipped span landed on later footage. The skip now only applies when every row in the range carries audio, and seeks inside a zero-duration span land on the next clip.
2026-10-25 15:58:01 -05:00
Josh Hawkins 0ed5561553 skip vod clips that start after their audio ends
When a camera's audio dropped out, a recording segment could end its audio early. If a playback range started past that point in the segment, nginx-vod saw no audio track in the first clip, failed the whole range with a track count mismatch, and left the player spinning for up to an hour of footage. Recording now stores each segment's audio length alongside its keyframe index, from the same ffprobe pass, and plan_clip skips a clip whose window starts at or after its audio ends when the range has other clips. A range made of only that clip still plays video-only, and legacy rows without the new column are unchanged.
2026-10-25 15:37:28 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7738f022e7 Update openai requirement from ==3.35.* to ==3.36.* in /docker/main (#24784)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Updates the requirements on [openai](https://github.com/openai/openai-python) to permit the latest version.
- [Release notes](https://github.com/openai/openai-python/releases)
- [Changelog](https://github.com/openai/openai-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-python/compare/v3.35.0...v3.36.1)

---
updated-dependencies:
- dependency-name: openai
  dependency-version: 3.36.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 08:14:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ba6e3717f4 Update google-genai requirement (#24779)
Updates the requirements on [google-genai](https://github.com/googleapis/python-genai) to permit the latest version.

Updates `google-genai` to 2.32.0
- [Release notes](https://github.com/googleapis/python-genai/releases)
- [Changelog](https://github.com/googleapis/python-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/python-genai/compare/v2.31.0...v2.32.0)

---
updated-dependencies:
- dependency-name: google-genai
  dependency-version: 2.32.0
  dependency-type: direct:production
  dependency-group: pydantic-genai
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 08:14:12 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
197ed252d2 Update onnxruntime requirement from ==1.30.* to ==1.31.* in /docker/main (#24781)
Updates the requirements on [onnxruntime](https://github.com/microsoft/onnxruntime) to permit the latest version.
- [Release notes](https://github.com/microsoft/onnxruntime/releases)
- [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md)
- [Commits](https://github.com/microsoft/onnxruntime/compare/v1.30.0...v1.31.0)

---
updated-dependencies:
- dependency-name: onnxruntime
  dependency-version: 1.31.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 08:14:01 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
14ddaf9df3 Bump tzlocal in /docker/main in the pip-main-minor-patch group (#24780)
Bumps the pip-main-minor-patch group in /docker/main with 1 update: [tzlocal](https://github.com/regebro/tzlocal).


Updates `tzlocal` from 5.4.4 to 5.4.5
- [Changelog](https://github.com/regebro/tzlocal/blob/master/CHANGES.txt)
- [Commits](https://github.com/regebro/tzlocal/compare/5.4.4...5.4.5)

---
updated-dependencies:
- dependency-name: tzlocal
  dependency-version: 5.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: pip-main-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 08:12:21 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Josh HawkinsClaude Opus 5.5
113d499a61 Update fastapi requirement from ==0.153.* to ==0.159.* in /docker/main (#24783)
* Update fastapi requirement from ==0.153.* to ==0.159.* in /docker/main

Updates the requirements on [fastapi](https://github.com/fastapi/fastapi) to permit the latest version.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.153.0...0.159.1)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.159.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Move enum param hints to json_schema_extra for fastapi 0.159

FastAPI 0.159 no longer accepts extra keyword arguments on Path() and
Query(), so passing enum= raised a TypeError on import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 08:09:44 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ccb58bdff4 Bump actions/setup-python (#24782)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
Bumps the actions group with 1 update in the / directory: [actions/setup-python](https://github.com/actions/setup-python).


Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v7.0.0...v7.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 08:03:37 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
68acbe98b2 Bump the web-minor-patch group in /web with 8 updates (#24785)
Bumps the web-minor-patch group in /web with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [apexcharts](https://github.com/apexcharts/apexcharts.js) | `8.3.1` | `8.4.1` |
| [framer-motion](https://github.com/motiondivision/motion) | `14.4.0` | `14.6.0` |
| [i18next](https://github.com/i18next/i18next) | `26.5.3` | `26.5.5` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.60.0` | `1.61.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.1.0` | `17.1.1` |
| [virtua](https://github.com/inokawa/virtua) | `0.53.5` | `0.53.6` |
| [@types/lodash](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/lodash) | `4.17.26` | `4.17.27` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.4` | `8.3.5` |


Updates `apexcharts` from 8.3.1 to 8.4.1
- [Release notes](https://github.com/apexcharts/apexcharts.js/releases)
- [Commits](https://github.com/apexcharts/apexcharts.js/compare/v8.3.1...v8.4.1)

Updates `framer-motion` from 14.4.0 to 14.6.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v14.4.0...v14.6.0)

Updates `i18next` from 26.5.3 to 26.5.5
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.5.3...v26.5.5)

Updates `lucide-react` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.61.0/packages/lucide-react)

Updates `react-i18next` from 17.1.0 to 17.1.1
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.1.0...v17.1.1)

Updates `virtua` from 0.53.5 to 0.53.6
- [Release notes](https://github.com/inokawa/virtua/releases)
- [Commits](https://github.com/inokawa/virtua/compare/0.53.5...0.53.6)

Updates `@types/lodash` from 4.17.26 to 4.17.27
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/lodash)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: apexcharts
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: framer-motion
  dependency-version: 14.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: i18next
  dependency-version: 26.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web-minor-patch
- dependency-name: react-i18next
  dependency-version: 17.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: virtua
  dependency-version: 0.53.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: "@types/lodash"
  dependency-version: 4.17.27
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-25 08:03:32 -05:00
14 changed files with 403 additions and 171 deletions
+5 -5
View File
@@ -4,7 +4,7 @@ click == 8.5.*
aiohttp == 3.14.*
starlette == 1.9.*
starlette-context == 0.5.*
fastapi[standard-no-fastapi-cloud-cli] == 0.153.*
fastapi[standard-no-fastapi-cloud-cli] == 0.159.*
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.4
tzlocal == 5.4.5
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.30.*
onnxruntime == 1.31.*
# Embeddings
transformers == 5.20.*
# Generative AI
google-genai == 2.31.*
google-genai == 2.32.*
ollama == 0.6.*
openai == 3.35.*
openai == 3.36.*
# push notifications
py-vapid == 1.9.6
pywebpush == 2.5.*
+1 -1
View File
@@ -1315,7 +1315,7 @@ def nvinfo():
dependencies=[Depends(require_role(["admin"]))],
)
async def logs(
service: str = Path(enum=["frigate", "nginx", "go2rtc"]),
service: str = Path(json_schema_extra={"enum": ["frigate", "nginx", "go2rtc"]}),
download: str | None = None,
stream: bool | None = False,
start: int | None = 0,
+8 -6
View File
@@ -64,6 +64,7 @@ from frigate.util.recording_coverage import (
plan_clip,
recording_overlap,
resolve_coverage,
sequence_needs_audio,
)
from frigate.util.time import get_normalized_tz_name
@@ -337,7 +338,7 @@ async def get_snapshot_from_recording(
request: Request,
camera_name: str,
frame_time: float,
format: str = Path(enum=["png", "jpg"]),
format: str = Path(json_schema_extra={"enum": ["png", "jpg"]}),
height: int = None,
):
if camera_name not in request.app.frigate_config.cameras:
@@ -624,14 +625,14 @@ async def recording_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:
"""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)
plan = plan_clip(row, start, end, needs_audio)
if plan.skipped:
return None
@@ -690,6 +691,7 @@ async def _vod_response(
null_audio_glitches(resolve_coverage(camera_name, start_ts, end_ts)),
stream_preference,
)
needs_audio = sequence_needs_audio(spans)
durations: list[int] = []
clips: list[dict[str, Any]] = []
@@ -707,7 +709,7 @@ async def _vod_response(
row.end_time,
row.duration,
)
built = _build_vod_clip(row, span_start, span_end)
built = _build_vod_clip(row, span_start, span_end, needs_audio)
if built is None:
continue
@@ -1061,7 +1063,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", enum=["ios", "android"]),
format: str = Query(default="ios", json_schema_extra={"enum": ["ios", "android"]}),
):
thumbnail_bytes = None
event_complete = False
@@ -1858,7 +1860,7 @@ async def preview_mp4(
async def review_preview(
request: Request,
event_id: str,
format: str = Query(default="gif", enum=["gif", "mp4"]),
format: str = Query(default="gif", json_schema_extra={"enum": ["gif", "mp4"]}),
):
try:
review: ReviewSegment = ReviewSegment.get(ReviewSegment.id == event_id)
+1
View File
@@ -85,6 +85,7 @@ 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):
+10 -3
View File
@@ -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_keyframe_offsets
from frigate.util.media import get_playback_index
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_codec, video_codec, keyframes, audio_duration)
self.end_time_cache: dict[
str,
tuple[
@@ -199,6 +199,7 @@ 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
@@ -619,6 +620,7 @@ 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
@@ -652,7 +654,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 = await get_keyframe_offsets(cache_path)
keyframes, audio_duration = await get_playback_index(cache_path)
if previous_start is not None:
await previous_start.wait()
@@ -669,6 +671,7 @@ 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
@@ -763,6 +766,7 @@ 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
@@ -815,6 +819,7 @@ class RecordingMaintainer(threading.Thread):
audio_codec,
video_codec,
keyframes,
audio_duration,
)
else:
self.drop_segment(cache_path)
@@ -960,6 +965,7 @@ 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
@@ -1053,6 +1059,7 @@ 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}")
+4 -4
View File
@@ -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)
cache_path: (end_time, 10.0, None, 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, [])
cache_path: (end_time, 10.0, False, None, 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, [])
cache_path: (end_time, 10.0, False, None, 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, [])
cache_path: (end_time, 10.0, False, None, None, None, [], None)
}
# Metadata has only reached partway into the segment.
maintainer.object_recordings_info["test_cam"] = [
+8 -7
View File
@@ -77,8 +77,8 @@ async def _validate_segment(
with (
patch("frigate.record.maintainer.get_video_properties", probe),
patch(
"frigate.record.maintainer.get_keyframe_offsets",
AsyncMock(return_value=[0]),
"frigate.record.maintainer.get_playback_index",
AsyncMock(return_value=([0], 10.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)
cache_path: (end_time, 10.0, None, 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_keyframe_offsets",
AsyncMock(return_value=[0, 2000]),
"frigate.record.maintainer.get_playback_index",
AsyncMock(return_value=([0, 2000], 9.5)),
),
):
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[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
@@ -558,8 +559,8 @@ class TestSegmentStartChaining(unittest.IsolatedAsyncioTestCase):
with (
patch("frigate.record.maintainer.get_video_properties", probe),
patch(
"frigate.record.maintainer.get_keyframe_offsets",
AsyncMock(return_value=[0]),
"frigate.record.maintainer.get_playback_index",
AsyncMock(return_value=([0], 10.0)),
),
patch(
"frigate.record.maintainer.os.path.getmtime",
+132
View File
@@ -10,6 +10,7 @@ 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,
@@ -47,6 +48,7 @@ class CoverageDbTestCase(unittest.TestCase):
has_audio=None,
segment_size=0,
keyframes=None,
audio_duration=None,
):
Recordings.create(
id=id,
@@ -62,6 +64,7 @@ class CoverageDbTestCase(unittest.TestCase):
has_audio=has_audio,
segment_size=segment_size,
keyframes=keyframes,
audio_duration=audio_duration,
)
@@ -419,3 +422,132 @@ 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
+36 -18
View File
@@ -938,21 +938,22 @@ def sync_all_media(
return results
async def get_keyframe_offsets(path: str) -> list[int] | None:
"""Get every video keyframe offset (ms from segment start) in an mp4.
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.
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.
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.
"""
proc = None
try:
proc = await asyncio.create_subprocess_exec(
FFPROBE_PATH,
"-select_streams",
"v:0",
"-show_entries",
"packet=pts_time,flags",
"packet=codec_type,stream_index,pts_time,duration_time,flags",
"-of",
"csv=p=0",
"-loglevel",
@@ -968,22 +969,39 @@ async def get_keyframe_offsets(path: str) -> list[int] | None:
proc.kill()
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(proc.communicate(), timeout=2)
return None
return None, None
if proc.returncode != 0:
return None
return None, None
offsets: list[int] = []
for line in stdout.decode().strip().splitlines():
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():
parts = line.strip().split(",")
if len(parts) != 2:
continue
ts_str, flags = parts
if "K" not in flags:
if len(parts) != 5:
continue
codec_type, index_str, ts_str, dur_str, flags = parts
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:
continue
return offsets
return (
keyframes[min(keyframes)] if keyframes else [],
audio[min(audio)] if audio else 0.0,
)
+36 -3
View File
@@ -49,6 +49,7 @@ 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)
@@ -340,13 +341,26 @@ class ClipPlan:
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).
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
@@ -397,6 +411,22 @@ def plan_clip(row: Any, start: float, end: float) -> ClipPlan:
)
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)
@@ -445,14 +475,17 @@ 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 build_spans(intervals, stream)
for plan in (plan_clip(row, span_start, span_end),)
for row, span_start, span_end, _ in spans
for plan in (plan_clip(row, span_start, span_end, needs_audio),)
]
@@ -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
+134 -116
View File
@@ -37,7 +37,7 @@
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
"@rjsf/validator-ajv8": "^6.10.1",
"apexcharts": "^8.3.1",
"apexcharts": "^8.4.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.4.0",
"framer-motion": "^14.6.0",
"hls.js": "^1.7.3",
"i18next": "^26.5.3",
"i18next": "^26.5.5",
"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.60.0",
"lucide-react": "^1.61.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.0",
"react-i18next": "^17.1.1",
"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.5",
"virtua": "^0.53.6",
"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.26",
"@types/lodash": "^4.17.27",
"@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.4"
"vite": "^8.3.5"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -1695,9 +1695,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.153.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
"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==",
"dev": true,
"license": "MIT",
"funding": {
@@ -3022,9 +3022,9 @@
}
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"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==",
"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==",
"cpu": [
"arm"
],
@@ -3039,9 +3039,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -3056,9 +3056,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"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==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
@@ -3073,9 +3073,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -3090,9 +3090,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -3107,9 +3107,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"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==",
"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==",
"cpu": [
"arm"
],
@@ -3124,13 +3124,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"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==",
"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==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3141,13 +3144,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"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==",
"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==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3158,13 +3164,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"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==",
"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==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3175,13 +3184,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"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==",
"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==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3192,13 +3204,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3209,13 +3224,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"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==",
"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==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3226,9 +3244,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -3243,9 +3261,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -3260,9 +3278,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -3589,9 +3607,9 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.26",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz",
"integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==",
"version": "4.17.27",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.27.tgz",
"integrity": "sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew==",
"dev": true,
"license": "MIT"
},
@@ -4061,18 +4079,18 @@
}
},
"node_modules/apex-commons": {
"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"
"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"
},
"node_modules/apexcharts": {
"version": "8.3.1",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.3.1.tgz",
"integrity": "sha512-Z0LX1rPyXBe85082t9EqUWzikOdLy3Z184cvL+4uYS7J4kyLAzK50JBtAGKH5OU8Y6EOPqiH8BqjxjMIBF2+gw==",
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-8.4.1.tgz",
"integrity": "sha512-29IbZ1rn3NRkDvete5EzZXy6yDSW7mjNXkytLpvaYHk0XbfsR+FvC7U78L3wMvJa699NBmix2z6r3Qm5YDvw0A==",
"license": "SEE LICENSE IN LICENSE.md",
"dependencies": {
"apex-commons": "^0.8.0"
"apex-commons": "^0.8.3"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
@@ -5592,13 +5610,13 @@
}
},
"node_modules/framer-motion": {
"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==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-14.6.0.tgz",
"integrity": "sha512-MzBgZxGgoHPfSLEXmo4o5XlZvb+nVRFdHtGjVhm6LMIEJKTt/8hNO023euL5mYyzc8llV/hQTfjnN68tNRHalw==",
"license": "MIT",
"dependencies": {
"motion-dom": "14.4.0",
"motion-utils": "14.1.1",
"motion-dom": "14.6.0",
"motion-utils": "14.6.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -6001,9 +6019,9 @@
}
},
"node_modules/i18next": {
"version": "26.5.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.3.tgz",
"integrity": "sha512-6pRwOwJ6lBCBB66fqNZaSoUmvsALHMord7FL6GsDhubT1CrPabTMEJIqddm0nn2YEbKofdI/3VAtGrQPuztY1Q==",
"version": "26.5.5",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.5.tgz",
"integrity": "sha512-txdaNOlnZCdiTMSlEzl5hUJU8TC9R4J/RCR1G7XrDPbWT2t3mR142QPz84ZuZeXDt3yq+9zmsdPiakxYtmp3fw==",
"funding": [
{
"type": "individual",
@@ -6988,9 +7006,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.60.0.tgz",
"integrity": "sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==",
"version": "1.61.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
@@ -8065,18 +8083,18 @@
}
},
"node_modules/motion-dom": {
"version": "14.4.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.4.0.tgz",
"integrity": "sha512-1q8RyHSdr5COpzrv3JgJ9XRjOO88ZN9CwflpkGgyO/SR/SNRP/dD6EU8SEo092kcKY34IzHC5diJPKrQRMA8lQ==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-14.6.0.tgz",
"integrity": "sha512-lIojTl9aYSPIkeOOSzBliy1nNvpdO3JtVzW2WgTXQlOP4ZMuJhtWO3fP7OD4TVFwlRucdvxlDSMkL+fEQnQ52A==",
"license": "MIT",
"dependencies": {
"motion-utils": "14.1.1"
"motion-utils": "14.6.0"
}
},
"node_modules/motion-utils": {
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.1.1.tgz",
"integrity": "sha512-3VvN5Uvf8B6qG+OfOui6Nht5EWp+vLuU3IqcAj7ItZGKqtmTjCebl5sGZF7/08gEHRnEl1AP/fIwbcEyVrcQGQ==",
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-14.6.0.tgz",
"integrity": "sha512-bZJo2AmQ2aOFkaato6VXrTbkPG0XhMzivWUWSaI59pxyllj8QMQUFZ84MYRQnPkK+7dr4KldyJwF2+RM0sdq/w==",
"license": "MIT"
},
"node_modules/ms": {
@@ -9069,9 +9087,9 @@
}
},
"node_modules/react-i18next": {
"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==",
"version": "17.1.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.1.tgz",
"integrity": "sha512-Q76xpzxrZNcqhB7M31Ns7isulGOlmhURqRsJOGlYgJJCyoy1pMq6tRl8KQycGNNY1vPaxbIdrogrqY9nkb+SEA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
@@ -9487,13 +9505,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.153.0",
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -9503,21 +9521,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@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"
"@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"
}
},
"node_modules/run-async": {
@@ -10374,9 +10392,9 @@
}
},
"node_modules/virtua": {
"version": "0.53.5",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.5.tgz",
"integrity": "sha512-hhtrEli6LkMzKXhqhuStnaU5DECVLjxwr/FbrGcnkId2gyKQJ/5E6LE/T2F4OzyghEOsOCl/g+0DyxJUmkah8Q==",
"version": "0.53.6",
"resolved": "https://registry.npmjs.org/virtua/-/virtua-0.53.6.tgz",
"integrity": "sha512-/vQtSFNDFcp3Yz4klbpe8Z88eQeThSw49cFlgJQdsqh3SUnRlW4jLi6ZYt4YzdwerYGlP6KCgAOXNVO+PmUg7w==",
"license": "MIT",
"peerDependencies": {
"@angular/common": ">=20.0.0",
@@ -10412,16 +10430,16 @@
}
},
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -10438,7 +10456,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
+8 -8
View File
@@ -49,7 +49,7 @@
"@rjsf/shadcn": "^6.10.1",
"@rjsf/utils": "^6.10.1",
"@rjsf/validator-ajv8": "^6.10.1",
"apexcharts": "^8.3.1",
"apexcharts": "^8.4.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.4.0",
"framer-motion": "^14.6.0",
"hls.js": "^1.7.3",
"i18next": "^26.5.3",
"i18next": "^26.5.5",
"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.60.0",
"lucide-react": "^1.61.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.0",
"react-i18next": "^17.1.1",
"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.5",
"virtua": "^0.53.6",
"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.26",
"@types/lodash": "^4.17.27",
"@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.4"
"vite": "^8.3.5"
},
"overrides": {
"@radix-ui/react-compose-refs": "1.1.2",
+6
View File
@@ -61,6 +61,12 @@ export function calculateSeekPosition(
return false;
}
// skipped clips have no playlist media, so a seek inside one lands on
// the next clip that plays
if (segment.duration === 0) {
return true;
}
// playlist duration exceeds wall length when the clip carries
// keyframe back-snap lead-in
const wallLength = segment.end_time - segment.start_time;