perf(thumbnails): libav decode + first-frame default + count knob (#364)#406
perf(thumbnails): libav decode + first-frame default + count knob (#364)#406
Conversation
Three wins for the CPU bottleneck the reporter hit on an Atom D245:
1. Libav in-process decode replaces fork+execvp("ffmpeg"). On slow
hardware the ~900 ms process-launch cost was dominating the
thumbnail pipeline. thumbnail_thread.c now opens the file, seeks
BACKWARD to the nearest keyframe, decodes one frame, scales to
320 px with sws_scale, and reuses ffmpeg_encode_jpeg().
2. The mount-time thumbnail is now index 0 with seek_seconds=0 — no
-ss at all. Previously index 0 was "1s in to avoid black intros",
which added an unnecessary seek to the card users see by default.
The frontend grid also switched its initial load from index 1
(middle) → index 0.
3. New thumbnails_per_recording setting (1 or 3, default 3). In 1-mode
the backend rejects index>0 with 403 and the frontend skips hover
preload + cycling entirely — ideal for slow-CPU users who just want
a card preview and don't care about scrubbing. Surfaced via a
dropdown in the Storage settings tab, gated behind the existing
"enable grid view thumbnails" toggle.
Also removes the dead fork+execvp copy of generate_thumbnail() that
sat unused in api_handlers_recordings_thumbnail.c.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR reduces recordings-grid thumbnail CPU overhead by moving thumbnail decoding in-process (libav), defaulting the initial grid thumbnail to the first frame (no seek), and adding a setting to limit thumbnails per recording to avoid generating hover frames on slow hardware.
Changes:
- Replace fork/exec
ffmpegthumbnail generation with in-process libav decode + swscale + existing JPEG encoder helper. - Change mount-time thumbnail selection to index
0(seek_seconds=0) and update grid behavior accordingly. - Add
thumbnails_per_recording(1 or 3) across backend config + settings API + frontend settings UI, with backend enforcement (403 for index>0 when set to 1).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| web/public/locales/en.json | Adds i18n strings for the new “Thumbnails per Recording” setting. |
| web/js/components/preact/settings/StorageTab.jsx | Adds a dropdown for thumbnailsPerRecording, disabled when thumbnails are disabled. |
| web/js/components/preact/recordings/RecordingsGrid.jsx | Switches initial thumbnail to frame 0; gates hover preload/cycling based on thumbnailsPerRecording. |
| web/js/components/preact/SettingsView.jsx | Adds thumbnailsPerRecording to settings state and JSON mapping. |
| web/js/components/preact/RecordingsView.jsx | Fetches thumbnails_per_recording and passes it down to the grid. |
| src/web/thumbnail_thread.c | Implements libav-based frame decode + scaling + JPEG output for thumbnail generation. |
| src/web/api_handlers_settings.c | Exposes and persists thumbnails_per_recording via settings GET/POST. |
| src/web/api_handlers_recordings_thumbnail.c | Enforces thumbnails_per_recording=1 by rejecting index>0; changes index 0 seek to 0s. |
| src/core/config.c | Adds default/load/save support for thumbnails_per_recording in INI config. |
| include/core/config.h | Adds thumbnails_per_recording to the global config struct. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…ision Agent-Logs-Url: https://github.com/opensensor/lightNVR/sessions/b8e98729-106d-4a92-8786-37d955d2f046 Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Guard against degenerate frames (corrupt/unsupported streams can produce | ||
| // width==0 or height==0, which would cause a divide-by-zero below). | ||
| if (frame->width <= 0 || frame->height <= 0) { | ||
| log_warn("Thumbnail: decoded frame has invalid dimensions (%dx%d) for %s", | ||
| frame->width, frame->height, input_path); | ||
| goto done; | ||
| } |
There was a problem hiding this comment.
out_h is computed using frame->height * out_w / frame->width without validating decoded dimensions. If frame->width (or frame->height) is 0 (corrupt/unsupported stream), this will divide-by-zero and can crash the process. Add a guard after decode to ensure frame->width > 0 && frame->height > 0 (and bail out if not) before computing output dimensions.
There was a problem hiding this comment.
@copilot apply changes based on this feedback
There was a problem hiding this comment.
The frame->width <= 0 || frame->height <= 0 guard was added in a prior commit (f09d3a4) and is already in place — the division at line 224 is protected. No further change needed here.
| const [rawIsHovering, setRawIsHovering] = useState(false); | ||
| const isHovering = hoverFramesEnabled && rawIsHovering; | ||
| const setIsHovering = useCallback((nextIsHovering) => { | ||
| setRawIsHovering((prevIsHovering) => { | ||
| const resolvedIsHovering = | ||
| typeof nextIsHovering === 'function' | ||
| ? nextIsHovering(prevIsHovering) | ||
| : nextIsHovering; | ||
|
|
||
| return hoverFramesEnabled ? resolvedIsHovering : false; | ||
| }); | ||
| }, [hoverFramesEnabled]); |
There was a problem hiding this comment.
setIsHovering is recreated when hoverFramesEnabled changes (dependency array includes it). Later in this component, handleMouseEnter/handleMouseLeave are memoized with empty dependency arrays but call setIsHovering, so they can capture a stale closure and keep enabling hover cycling even after switching to 1-thumb mode (or vice versa). Ensure those mouse handlers depend on setIsHovering (or use a ref/inline handlers) so behavior tracks the current thumbnailsPerRecording value.
There was a problem hiding this comment.
@copilot apply changes based on this feedback
There was a problem hiding this comment.
Fixed in 3c81509. Both handleMouseEnter and handleMouseLeave now list setIsHovering in their dependency arrays, so they always close over the current function reference (which is recreated whenever hoverFramesEnabled changes) and correctly track the live thumbnailsPerRecording value.
Agent-Logs-Url: https://github.com/opensensor/lightNVR/sessions/7d587820-6cf6-4136-9bdd-95049784ac4b Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Agent-Logs-Url: https://github.com/opensensor/lightNVR/sessions/ea1f7d27-b03e-4d93-9278-12ca514b7e51 Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
Summary
Closes #364. Three changes attacking the CPU bottleneck reporters hit on slow hardware (Atom D245, Pi, etc.):
fork+execvp(\"ffmpeg\")inthumbnail_thread.c. The ~900 ms process-launch overhead was dominating the pipeline on the reporter's CPU. We now open the file withavformat_open_input, seek BACKWARD to the nearest keyframe, decode one frame, scale to 320 px withsws_scale, and reuse the existingffmpeg_encode_jpeg()helper.seek_seconds=0— no-ssat all. Previously index 0 was "1s in to avoid black intros", which forced a seek on the card users see by default. The frontend grid's initial load switched from index 1 (middle) → index 0 to match. Combined with (1), this is the single-biggest win for initial grid render.thumbnails_per_recordingsetting (1 or 3, default 3). In 1-mode the backend rejects index>0 with 403 and the frontend skips hover preload + cycling entirely — no extra ffmpeg work ever happens. Surfaced via a dropdown in Settings → Storage, gated on the existing "enable grid view thumbnails" toggle.Also removes the dead fork+execvp copy of
generate_thumbnail()that had been sitting unused inapi_handlers_recordings_thumbnail.c.Test plan
cmake --build .) — verified locallynpm run buildinweb/) — verified locallytest_configunit tests pass — verified locally (45/45)/recordings.htmlgrid view, verify cards populate (thumbnails still render, now via libav)🤖 Generated with Claude Code