Skip to content

Commit c092f28

Browse files
matteiusclaudeCopilotCopilot
authored
perf(thumbnails): libav decode + first-frame default + count knob (#364) (#406)
* perf(thumbnails): libav decode + first-frame default + count knob (#364) 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> * Update src/web/thumbnail_thread.c Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/web/thumbnail_thread.c Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/web/thumbnail_thread.c Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update web/js/components/preact/recordings/RecordingsGrid.jsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(thumbnail): guard frame->width/height > 0 before aspect-ratio division Agent-Logs-Url: https://github.com/opensensor/lightNVR/sessions/b8e98729-106d-4a92-8786-37d955d2f046 Co-authored-by: matteius <479892+matteius@users.noreply.github.com> * fix(thumbnail): add av_strerror() to all libav failure log messages Agent-Logs-Url: https://github.com/opensensor/lightNVR/sessions/7d587820-6cf6-4136-9bdd-95049784ac4b Co-authored-by: matteius <479892+matteius@users.noreply.github.com> * fix(recordings-grid): add setIsHovering to mouse handler dep arrays Agent-Logs-Url: https://github.com/opensensor/lightNVR/sessions/ea1f7d27-b03e-4d93-9278-12ca514b7e51 Co-authored-by: matteius <479892+matteius@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: matteius <479892+matteius@users.noreply.github.com>
1 parent 4bbfdab commit c092f28

10 files changed

Lines changed: 303 additions & 163 deletions

File tree

include/core/config.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ typedef struct {
140140

141141
// Thumbnail/grid view settings
142142
bool generate_thumbnails; // Enable grid view with thumbnail previews on recordings page
143+
int thumbnails_per_recording; // 1 = single mount-time thumb only (CPU-save, #364); 3 = start+middle+end with hover cycling
143144

144145
// New recording format options
145146
bool record_mp4_directly; // Record directly to MP4 alongside HLS

src/core/config.c

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ void load_default_config(config_t *config) {
326326

327327
// Thumbnail/grid view settings
328328
config->generate_thumbnails = true;
329+
config->thumbnails_per_recording = 3;
329330

330331
// MP4 recording settings
331332
config->record_mp4_directly = false;
@@ -653,6 +654,9 @@ static int config_ini_handler(void* user, const char* section, const char* name,
653654
config->mp4_retention_days = safe_atoi(value, 0);
654655
} else if (strcmp(name, "generate_thumbnails") == 0) {
655656
config->generate_thumbnails = (strcmp(value, "true") == 0 || strcmp(value, "1") == 0);
657+
} else if (strcmp(name, "thumbnails_per_recording") == 0) {
658+
int n = safe_atoi(value, 3);
659+
config->thumbnails_per_recording = (n <= 1) ? 1 : 3;
656660
}
657661
}
658662
// Models settings
@@ -1584,7 +1588,9 @@ int save_config(const config_t *config, const char *path) {
15841588

15851589
// Write thumbnail/grid view settings
15861590
fprintf(file, "; Thumbnail preview settings\n");
1587-
fprintf(file, "generate_thumbnails = %s\n\n", config->generate_thumbnails ? "true" : "false");
1591+
fprintf(file, "generate_thumbnails = %s\n", config->generate_thumbnails ? "true" : "false");
1592+
fprintf(file, "; Thumbnails per recording: 1 = single first-frame thumb (saves CPU on slow hardware); 3 = start/middle/end with hover cycling\n");
1593+
fprintf(file, "thumbnails_per_recording = %d\n\n", config->thumbnails_per_recording);
15881594

15891595
// Write models settings
15901596
fprintf(file, "[models]\n");

src/web/api_handlers_recordings_thumbnail.c

Lines changed: 14 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,9 @@
1313
#include <stdlib.h>
1414
#include <string.h>
1515
#include <sys/stat.h>
16-
#include <sys/wait.h>
1716
#include <unistd.h>
18-
#include <fcntl.h>
1917
#include <time.h>
2018
#include <errno.h>
21-
#include <signal.h>
2219

2320
#include "web/api_handlers_recordings_thumbnail.h"
2421
#include "web/request_response.h"
@@ -80,89 +77,6 @@ static void thumbnail_complete_callback(deferred_action_handle_t handle,
8077
}
8178
}
8279

83-
/**
84-
* @brief Generate a thumbnail using ffmpeg
85-
*/
86-
static int generate_thumbnail(const char *input_path, const char *output_path,
87-
double seek_seconds) {
88-
// Clamp seek time to 0 minimum
89-
if (seek_seconds < 0) seek_seconds = 0;
90-
91-
char seek_str[32];
92-
snprintf(seek_str, sizeof(seek_str), "%.2f", seek_seconds);
93-
94-
log_debug("Generating thumbnail: ffmpeg -ss %s -i \"%s\" -> \"%s\"",
95-
seek_str, input_path, output_path);
96-
97-
// Use fork/execvp instead of system() to avoid spawning a shell
98-
pid_t pid = fork();
99-
if (pid < 0) {
100-
log_warn("fork() failed for thumbnail generation: %s", strerror(errno));
101-
return -1;
102-
}
103-
104-
if (pid == 0) {
105-
// Child process: redirect stderr to /dev/null, then exec ffmpeg
106-
int devnull = open("/dev/null", O_WRONLY);
107-
if (devnull >= 0) {
108-
dup2(devnull, STDERR_FILENO);
109-
close(devnull);
110-
}
111-
const char *args[] = {
112-
"ffmpeg", "-ss", seek_str,
113-
"-i", input_path,
114-
"-frames:v", "1",
115-
"-vf", "scale=320:-1",
116-
"-q:v", "8",
117-
"-y", output_path,
118-
NULL
119-
};
120-
execvp("ffmpeg", (char *const *)args);
121-
_exit(127);
122-
}
123-
124-
// Parent: wait up to 5 seconds for child to complete
125-
int ret = -1;
126-
time_t deadline = time(NULL) + 5;
127-
while (time(NULL) < deadline) {
128-
int status = 0;
129-
pid_t result = waitpid(pid, &status, WNOHANG);
130-
if (result == pid) {
131-
if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {
132-
ret = 0;
133-
}
134-
pid = -1;
135-
break;
136-
}
137-
if (result < 0) {
138-
pid = -1;
139-
break;
140-
}
141-
usleep(50000);
142-
}
143-
if (pid > 0) {
144-
kill(pid, SIGKILL);
145-
waitpid(pid, NULL, 0);
146-
}
147-
148-
if (ret != 0) {
149-
log_warn("ffmpeg thumbnail generation failed for: %s", input_path);
150-
return -1;
151-
}
152-
153-
// Verify the file was created
154-
struct stat st;
155-
if (stat(output_path, &st) != 0 || st.st_size == 0) {
156-
log_warn("Thumbnail file not created or empty: %s", output_path);
157-
unlink(output_path); // Clean up empty file
158-
return -1;
159-
}
160-
161-
log_debug("Generated thumbnail: %s (%lld bytes)", output_path,
162-
(long long)st.st_size);
163-
return 0;
164-
}
165-
16680
void handle_recordings_thumbnail(const http_request_t *req, http_response_t *res) {
16781
if (!req || !res) {
16882
log_error("Invalid parameters for handle_recordings_thumbnail");
@@ -220,6 +134,14 @@ void handle_recordings_thumbnail(const http_request_t *req, http_response_t *res
220134
http_response_set_json_error(res, 400, "Invalid thumbnail index (must be 0, 1, or 2)");
221135
return;
222136
}
137+
// When thumbnails_per_recording is 1 (CPU-save mode, #364) the client
138+
// shouldn't be requesting hover frames at all. Reject cleanly so a
139+
// stale frontend can't force extra ffmpeg work.
140+
if (index > 0 && g_config.thumbnails_per_recording <= 1) {
141+
http_response_set_json_error(res, 403,
142+
"Hover frames disabled (thumbnails_per_recording=1)");
143+
return;
144+
}
223145

224146
// Build thumbnail path
225147
char thumb_path[MAX_PATH_LENGTH];
@@ -267,7 +189,12 @@ void handle_recordings_thumbnail(const http_request_t *req, http_response_t *res
267189
double seek_seconds;
268190
switch (index) {
269191
case 0:
270-
seek_seconds = 1.0; // 1 second in (avoid black frames at start)
192+
// First frame — no seek at all. On slow CPUs (#364) the seek
193+
// dominates the cost, and the earlier "1s in to dodge black
194+
// frames" heuristic isn't worth it: modern IP cameras don't
195+
// emit black intros, and the mount-time thumbnail is the one
196+
// users see by default, so it must be the cheapest path.
197+
seek_seconds = 0.0;
271198
break;
272199
case 1:
273200
seek_seconds = duration / 2.0;

src/web/api_handlers_settings.c

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ void handle_get_settings(const http_request_t *req, http_response_t *res) {
418418
cJSON_AddNumberToObject(settings, "retention_days", g_config.retention_days);
419419
cJSON_AddBoolToObject(settings, "auto_delete_oldest", g_config.auto_delete_oldest);
420420
cJSON_AddBoolToObject(settings, "generate_thumbnails", g_config.generate_thumbnails);
421+
cJSON_AddNumberToObject(settings, "thumbnails_per_recording", g_config.thumbnails_per_recording);
421422
cJSON_AddNumberToObject(settings, "max_streams", g_config.max_streams);
422423
cJSON_AddNumberToObject(settings, "max_streams_ceiling", MAX_STREAMS);
423424
cJSON_AddStringToObject(settings, "log_file", g_config.log_file);
@@ -913,6 +914,16 @@ void handle_post_settings(const http_request_t *req, http_response_t *res) {
913914
log_info("Updated generate_thumbnails: %s", g_config.generate_thumbnails ? "true" : "false");
914915
}
915916

917+
// Thumbnails per recording (1 or 3). Anything else is coerced to 3 — we
918+
// don't want a typo turning the whole grid into dead cards.
919+
cJSON *tpr = cJSON_GetObjectItem(settings, "thumbnails_per_recording");
920+
if (tpr && cJSON_IsNumber(tpr)) {
921+
int n = tpr->valueint;
922+
g_config.thumbnails_per_recording = (n <= 1) ? 1 : 3;
923+
settings_changed = true;
924+
log_info("Updated thumbnails_per_recording: %d", g_config.thumbnails_per_recording);
925+
}
926+
916927
// Models path
917928
cJSON *models_path = cJSON_GetObjectItem(settings, "models_path");
918929
if (models_path && cJSON_IsString(models_path)) {

0 commit comments

Comments
 (0)