Skip to content

Commit 20936b0

Browse files
matteiusclaude
andcommitted
feat(go2rtc): codec-aware registration + live re-register on codec change (#374)
Reolink 810A (and anything else that streams H.265) couldn't negotiate WebRTC: go2rtc offered H.265 video, browsers only accept VP8/VP9/H.264/AV1, SDP failed with "codecs not matched". go2rtc's answer is a multi-source stream with an ffmpeg H.264 fallback, but lightNVR was registering a single raw-RTSP source and never consulting the codec field it already had in stream_config_t. A/B hybrid: let the user declare the codec up-front, but still auto- correct and re-register live if they (or we) got it wrong. Backend - go2rtc_stream.h / .c: thread `const char *codec` through go2rtc_stream_register(). When codec is anything other than "h264" (case-insensitive) — including empty/unknown — append `ffmpeg:<id>#video=h264#hardware` to the source list. Unknown is treated as "possibly H.265" since detection only populates the codec field after the first packet; the cost is an idle ffmpeg entry that go2rtc never activates unless a consumer actually asks for H.264. The composition now stacks cleanly: primary RTSP + optional AAC (for MP4 audio muxing) + optional H.264 (for WebRTC). - All nine call sites threaded: ensure_stream_registered_with_go2rtc, register_all_configured_streams main+sub, sync_db_streams main+sub, reload_stream_config, the helper in go2rtc_integration.c that runs on stream start, and the sub-stream path in api_handlers_streams_modify.c. - go2rtc_disabled.c stub updated to match the new signature. - api_handlers_streams_modify.c: accept `codec` on both POST (create, takes as initial hint) and PUT (update, triggers a stream restart so re-registration fires immediately instead of waiting for detection). - unified_detection_thread.c: snapshot the stored codec before calling update_stream_video_params, and if the detected codec differs (e.g. user declared H.264 but camera is actually HEVC), call go2rtc_integration_reload_stream_config() with all-defaults to re-register with the freshly-written value. This is the "users are going to want it corrected without a program restart" piece. Frontend - StreamConfigModal.jsx: codec field is now an editable select (Auto-detect / H.264 / H.265) instead of a read-only span. - StreamsView.jsx: include lower-cased `codec` in the outbound stream payload so the user's choice reaches the backend. - en.json: two new keys (streamsConfig.codecAutoDetect, streamsConfig.codecHelp) describing what the field actually does. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 50697f7 commit 20936b0

9 files changed

Lines changed: 166 additions & 65 deletions

File tree

include/video/go2rtc/go2rtc_stream.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,21 @@ bool go2rtc_stream_init(const char *binary_path, const char *config_dir, int api
3030
* @param backchannel_enabled Whether to enable two-way audio (backchannel) support
3131
* @param protocol Stream protocol (TCP or UDP) - used to set transport parameter for go2rtc
3232
* @param record_audio Whether audio recording is enabled - if true, adds FFmpeg AAC transcoding source
33+
* @param codec Detected or user-declared source video codec (e.g. "h264", "hevc").
34+
* May be NULL or empty when unknown. Used to decide whether to add
35+
* an `ffmpeg:<id>#video=h264#hardware` fallback source so that
36+
* WebRTC clients (which only accept H.264/VP8/VP9/AV1) can consume
37+
* H.265 streams via on-demand transcoding. The fallback is added
38+
* for anything that isn't explicitly "h264" — unknown codecs are
39+
* treated as "might be H.265" to be safe; once the detection
40+
* thread learns the real codec it re-registers via
41+
* go2rtc_integration_reregister_stream().
3342
* @return true if stream was registered successfully, false otherwise
3443
*/
3544
bool go2rtc_stream_register(const char *stream_id, const char *stream_url,
3645
const char *username, const char *password,
3746
bool backchannel_enabled, stream_protocol_t protocol,
38-
bool record_audio);
47+
bool record_audio, const char *codec);
3948

4049
/**
4150
* @brief Unregister a stream from go2rtc

src/video/go2rtc/go2rtc_disabled.c

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ bool go2rtc_stream_init(const char *binary_path, const char *config_dir, int api
7878
bool go2rtc_stream_register(const char *stream_id, const char *stream_url,
7979
const char *username, const char *password,
8080
bool backchannel_enabled, stream_protocol_t protocol,
81-
bool record_audio) {
81+
bool record_audio, const char *codec) {
8282
UNUSED(stream_id); UNUSED(stream_url); UNUSED(username); UNUSED(password);
83-
UNUSED(backchannel_enabled); UNUSED(protocol); UNUSED(record_audio); return true;
83+
UNUSED(backchannel_enabled); UNUSED(protocol); UNUSED(record_audio);
84+
UNUSED(codec); return true;
8485
}
8586
bool go2rtc_stream_unregister(const char *stream_id) { UNUSED(stream_id); return true; }
8687
bool go2rtc_stream_get_webrtc_url(const char *stream_id, char *buffer, size_t buffer_size) {

src/video/go2rtc/go2rtc_integration.c

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -528,7 +528,7 @@ static bool ensure_stream_registered_with_go2rtc(const char *stream_name) {
528528
config.onvif_username[0] != '\0' ? config.onvif_username : NULL,
529529
config.onvif_password[0] != '\0' ? config.onvif_password : NULL,
530530
config.backchannel_enabled, config.protocol,
531-
config.record_audio)) {
531+
config.record_audio, config.codec)) {
532532
log_error("Failed to register stream %s with go2rtc", stream_name);
533533
return false;
534534
}
@@ -1398,7 +1398,7 @@ bool go2rtc_integration_register_all_streams(void) {
13981398
streams[i].onvif_username[0] != '\0' ? streams[i].onvif_username : NULL,
13991399
streams[i].onvif_password[0] != '\0' ? streams[i].onvif_password : NULL,
14001400
streams[i].backchannel_enabled, streams[i].protocol,
1401-
streams[i].record_audio)) {
1401+
streams[i].record_audio, streams[i].codec)) {
14021402
log_error("Failed to register stream %s with go2rtc", streams[i].name);
14031403
all_success = false;
14041404
} else {
@@ -1415,7 +1415,8 @@ bool go2rtc_integration_register_all_streams(void) {
14151415
if (!go2rtc_stream_register(sub_name, streams[i].sub_stream_url,
14161416
streams[i].onvif_username[0] != '\0' ? streams[i].onvif_username : NULL,
14171417
streams[i].onvif_password[0] != '\0' ? streams[i].onvif_password : NULL,
1418-
false, streams[i].protocol, false)) {
1418+
false, streams[i].protocol, false,
1419+
streams[i].codec)) {
14191420
log_warn("Failed to register sub-stream %s with go2rtc", sub_name);
14201421
}
14211422
}
@@ -1510,7 +1511,7 @@ bool go2rtc_sync_streams_from_database(void) {
15101511
if (!go2rtc_stream_register(db_streams[i].name, db_streams[i].url,
15111512
username, password,
15121513
db_streams[i].backchannel_enabled, db_streams[i].protocol,
1513-
db_streams[i].record_audio)) {
1514+
db_streams[i].record_audio, db_streams[i].codec)) {
15141515
log_error("Failed to register stream %s with go2rtc", db_streams[i].name);
15151516
all_success = false;
15161517
failed++;
@@ -1529,7 +1530,8 @@ bool go2rtc_sync_streams_from_database(void) {
15291530
log_info("Registering missing sub-stream %s with go2rtc", sub_name);
15301531
go2rtc_stream_register(sub_name, db_streams[i].sub_stream_url,
15311532
username, password,
1532-
false, db_streams[i].protocol, false);
1533+
false, db_streams[i].protocol, false,
1534+
db_streams[i].codec);
15331535
}
15341536
}
15351537
}
@@ -1762,8 +1764,10 @@ bool go2rtc_integration_reload_stream_config(const char *stream_name,
17621764
// Wait a moment for go2rtc to clean up
17631765
usleep(500000); // 500ms
17641766

1765-
// Re-register with new configuration
1766-
if (!go2rtc_stream_register(stream_name, url, username, password, backchannel, protocol, record_audio)) {
1767+
// Re-register with new configuration (passing the known codec so the
1768+
// H.264 transcoding fallback is added/omitted appropriately — #374/WebRTC)
1769+
const char *codec = have_config ? config.codec : NULL;
1770+
if (!go2rtc_stream_register(stream_name, url, username, password, backchannel, protocol, record_audio, codec)) {
17671771
log_error("Failed to re-register stream %s with go2rtc", stream_name);
17681772
return false;
17691773
}
@@ -1895,7 +1899,7 @@ bool go2rtc_integration_register_stream(const char *stream_name) {
18951899
username[0] != '\0' ? username : NULL,
18961900
password[0] != '\0' ? password : NULL,
18971901
config.backchannel_enabled, config.protocol,
1898-
config.record_audio)) {
1902+
config.record_audio, config.codec)) {
18991903
log_info("Successfully registered stream %s with go2rtc", stream_name);
19001904
} else {
19011905
log_warn("Failed to register stream %s with go2rtc", stream_name);
@@ -1912,7 +1916,8 @@ bool go2rtc_integration_register_stream(const char *stream_name) {
19121916
go2rtc_stream_register(sub_name, config.sub_stream_url,
19131917
username[0] != '\0' ? username : NULL,
19141918
password[0] != '\0' ? password : NULL,
1915-
false, config.protocol, false);
1919+
false, config.protocol, false,
1920+
config.codec);
19161921
}
19171922

19181923
return main_ok || skip_main;

src/video/go2rtc/go2rtc_stream.c

Lines changed: 62 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ bool go2rtc_stream_init(const char *binary_path, const char *config_dir, int api
9393
bool go2rtc_stream_register(const char *stream_id, const char *stream_url,
9494
const char *username, const char *password,
9595
bool backchannel_enabled, stream_protocol_t protocol,
96-
bool record_audio) {
96+
bool record_audio, const char *codec) {
9797
if (!g_initialized) {
9898
log_error("go2rtc stream integration not initialized");
9999
return false;
@@ -185,58 +185,74 @@ bool go2rtc_stream_register(const char *stream_id, const char *stream_url,
185185

186186
log_info("Prepared go2rtc source URL for stream registration of %s: %s", stream_id, modified_url);
187187

188-
bool result;
189-
190-
// Register the stream with go2rtc.
191-
//
192-
// Audio strategy: rely on go2rtc's built-in on-demand transcoding rather
193-
// than spawning persistent ffmpeg producer processes. When a WebRTC viewer
194-
// connects, go2rtc transcodes audio to OPUS in-process on the fly; no
195-
// long-lived ffmpeg process is needed just to keep OPUS available.
196-
//
197-
// When audio recording is enabled we still need a persistent AAC producer
198-
// so that the MP4 recording muxer gets an AAC audio track. In that case
199-
// we register two sources: primary RTSP + ffmpeg AAC. go2rtc will
200-
// transcode to OPUS for WebRTC viewers on demand from the AAC feed.
201-
//
202-
// NOTE: The FFmpeg AAC source is audio-only. Do NOT add #video=copy —
203-
// that would open a second connection to the camera, creating a
204-
// duplicate video producer and RTSP 404 errors. Video is served
205-
// directly from the primary RTSP source.
188+
/*
189+
* Compose the multi-source list for go2rtc.
190+
*
191+
* Source 0 is always the primary RTSP URL. We optionally append:
192+
*
193+
* - ffmpeg:<id>#audio=aac when record_audio is true, so the MP4
194+
* muxer has a persistent AAC producer. go2rtc still transcodes to
195+
* OPUS on demand for WebRTC viewers without a second ffmpeg.
196+
*
197+
* - ffmpeg:<id>#video=h264#hardware when the source codec is
198+
* anything other than "h264" (including unknown/empty). Browsers'
199+
* WebRTC stacks don't accept H.265, so go2rtc needs a transcoded
200+
* fallback to negotiate with them — this one only spawns its
201+
* ffmpeg process when a consumer actually asks for H.264 and the
202+
* primary doesn't supply it (H.264 sources leave it idle).
203+
* #hardware lets go2rtc pick VAAPI/NVENC/v4l2m2m if the host has
204+
* it and fall back to libx264.
205+
* (Neither #video=copy on the AAC source nor transcoding from
206+
* the AAC feed — each ffmpeg: entry opens its own producer.)
207+
*
208+
* codec may be NULL/empty on first registration since the detection
209+
* thread only populates stream_config_t.codec once it has read a
210+
* packet. Treat unknown as "might be H.265" — safer to pay the idle
211+
* cost of an unused ffmpeg entry than to ship a stream that breaks
212+
* WebRTC silently. When the detection thread later learns the real
213+
* codec, go2rtc_integration_reregister_stream() re-issues this call
214+
* with the corrected value.
215+
*/
216+
const char *sources[3];
217+
int num_sources = 0;
218+
sources[num_sources++] = modified_url;
219+
220+
char ffmpeg_aac_source[URL_BUFFER_SIZE];
206221
if (record_audio) {
207-
log_info("Audio recording enabled for stream %s, adding FFmpeg AAC source for MP4 recording", stream_id);
208-
209-
// Build the FFmpeg AAC transcoding source URL for recording.
210-
// Audio-only: FFmpeg reads from go2rtc's internal RTSP, transcodes to
211-
// AAC, and publishes back. go2rtc will transcode to OPUS for WebRTC
212-
// on demand without a separate persistent ffmpeg process.
213-
char ffmpeg_aac_source[URL_BUFFER_SIZE];
214-
snprintf(ffmpeg_aac_source, URL_BUFFER_SIZE, "ffmpeg:%s#audio=aac", encoded_stream_id);
215-
216-
const char *sources[2] = { modified_url, ffmpeg_aac_source };
217-
result = go2rtc_api_add_stream_multi(encoded_stream_id, sources, 2);
222+
snprintf(ffmpeg_aac_source, sizeof(ffmpeg_aac_source),
223+
"ffmpeg:%s#audio=aac", encoded_stream_id);
224+
sources[num_sources++] = ffmpeg_aac_source;
225+
}
226+
227+
bool is_h264 = (codec && codec[0] != '\0' && strcasecmp(codec, "h264") == 0);
228+
char ffmpeg_h264_source[URL_BUFFER_SIZE];
229+
if (!is_h264) {
230+
snprintf(ffmpeg_h264_source, sizeof(ffmpeg_h264_source),
231+
"ffmpeg:%s#video=h264#hardware", encoded_stream_id);
232+
sources[num_sources++] = ffmpeg_h264_source;
233+
log_info("Stream %s codec=%s; adding ffmpeg H.264 fallback source for WebRTC",
234+
stream_id, (codec && codec[0]) ? codec : "unknown");
235+
} else {
236+
log_info("Stream %s codec=h264; no transcoding fallback needed", stream_id);
237+
}
218238

219-
if (result) {
220-
log_info("Successfully registered stream with go2rtc (with AAC audio for recording): %s", encoded_stream_id);
221-
} else {
222-
log_error("Failed to register stream with go2rtc (with AAC audio): %s", encoded_stream_id);
223-
// Fall back to primary RTSP source only
224-
log_warn("Falling back to single source registration without audio transcoding");
239+
bool result;
240+
if (num_sources > 1) {
241+
result = go2rtc_api_add_stream_multi(encoded_stream_id, sources, num_sources);
242+
if (!result) {
243+
log_error("Failed to register stream %s with go2rtc (%d sources); falling back to primary-only",
244+
encoded_stream_id, num_sources);
225245
result = go2rtc_api_add_stream(encoded_stream_id, modified_url);
226246
}
227247
} else {
228-
// No audio recording: register only the primary RTSP source.
229-
// go2rtc transcodes audio to OPUS for WebRTC viewers on demand
230-
// without requiring a persistent ffmpeg process.
231-
log_info("Registering stream %s with primary RTSP source only (on-demand OPUS transcoding by go2rtc)", stream_id);
232-
233248
result = go2rtc_api_add_stream(encoded_stream_id, modified_url);
249+
}
234250

235-
if (result) {
236-
log_info("Successfully registered stream with go2rtc: %s", encoded_stream_id);
237-
} else {
238-
log_error("Failed to register stream with go2rtc: %s", encoded_stream_id);
239-
}
251+
if (result) {
252+
log_info("Successfully registered stream %s with go2rtc (%d source%s)",
253+
encoded_stream_id, num_sources, num_sources == 1 ? "" : "s");
254+
} else {
255+
log_error("Failed to register stream %s with go2rtc", encoded_stream_id);
240256
}
241257

242258
// Intentionally do NOT preload here.

src/video/unified_detection_thread.c

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
#include "video/streams.h"
5555
#include "video/go2rtc/go2rtc_stream.h"
5656
#include "video/go2rtc/go2rtc_snapshot.h"
57+
#include "video/go2rtc/go2rtc_integration.h"
5758
#include "database/db_recordings.h"
5859
#include "database/db_detections.h"
5960
#include "database/db_streams.h"
@@ -1184,9 +1185,34 @@ static void udt_update_stream_video_params(unified_detection_ctx_t *ctx,
11841185
}
11851186
pthread_mutex_unlock(&ctx->mutex);
11861187

1188+
/* Snapshot the previously-stored codec before writing the new row so we
1189+
* can detect a codec transition and re-register the stream with go2rtc.
1190+
* This covers the case where the user declared (or we guessed) H.264 at
1191+
* stream-add time but the camera is actually delivering H.265 — without
1192+
* the re-register, go2rtc keeps its original H.264-only source list and
1193+
* WebRTC clients can't negotiate (#374). */
1194+
char prev_codec[16] = {0};
1195+
{
1196+
stream_config_t prev = {0};
1197+
if (get_stream_config_by_name(ctx->stream_name, &prev) == 0) {
1198+
safe_strcpy(prev_codec, prev.codec, sizeof(prev_codec), 0);
1199+
}
1200+
}
1201+
11871202
// Persist the (possibly provisional) values to the database
11881203
update_stream_video_params(ctx->stream_name, det_width, det_height,
11891204
det_fps, det_codec);
1205+
1206+
if (det_codec && det_codec[0] != '\0' &&
1207+
strcasecmp(prev_codec, det_codec) != 0) {
1208+
log_info("[%s] Source codec transitioned %s -> %s; re-registering with go2rtc",
1209+
ctx->stream_name,
1210+
prev_codec[0] ? prev_codec : "(unknown)", det_codec);
1211+
/* Pass all defaults so reload_stream_config reads the freshly-written
1212+
* codec out of the DB itself. */
1213+
go2rtc_integration_reload_stream_config(ctx->stream_name,
1214+
NULL, NULL, NULL, -1, -1, -1);
1215+
}
11901216
}
11911217

11921218
/**

src/web/api_handlers_streams_modify.c

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,8 @@ static void put_stream_worker(put_stream_task_t *task) {
384384
go2rtc_stream_register(sub_name, task->config.sub_stream_url,
385385
task->config.onvif_username[0] != '\0' ? task->config.onvif_username : NULL,
386386
task->config.onvif_password[0] != '\0' ? task->config.onvif_password : NULL,
387-
false, task->config.protocol, false);
387+
false, task->config.protocol, false,
388+
task->config.codec);
388389
} else {
389390
log_info("Sub-stream %s removed from go2rtc", sub_name);
390391
}
@@ -578,6 +579,16 @@ void handle_post_stream(const http_request_t *req, http_response_t *res) {
578579
config.protocol = (stream_protocol_t)protocol->valueint;
579580
}
580581

582+
/* codec is user-settable as a hint (select Auto / H.264 / H.265) so
583+
* lightNVR can make the right call on the initial go2rtc registration —
584+
* specifically whether to pre-add the ffmpeg H.264 fallback for WebRTC
585+
* (#374). The detection thread overwrites this with the real codec once
586+
* it reads a packet, and re-registers with go2rtc if the user was wrong. */
587+
cJSON *codec_hint = cJSON_GetObjectItem(stream_json, "codec");
588+
if (codec_hint && cJSON_IsString(codec_hint)) {
589+
safe_strcpy(config.codec, codec_hint->valuestring, sizeof(config.codec), 0);
590+
}
591+
581592
cJSON *record_audio = cJSON_GetObjectItem(stream_json, "record_audio");
582593
if (record_audio && cJSON_IsBool(record_audio)) {
583594
config.record_audio = cJSON_IsTrue(record_audio);
@@ -1330,6 +1341,24 @@ void handle_put_stream(const http_request_t *req, http_response_t *res) {
13301341
}
13311342
}
13321343

1344+
/* codec hint (#374): lets the user declare H.264 vs H.265 so the initial
1345+
* go2rtc registration picks the right source list. If the value changes,
1346+
* require a stream restart so the go2rtc re-registration picks it up
1347+
* immediately rather than waiting for the detection thread's next run. */
1348+
cJSON *codec_put = cJSON_GetObjectItem(stream_json, "codec");
1349+
if (codec_put && cJSON_IsString(codec_put)) {
1350+
char new_codec[sizeof(config.codec)];
1351+
safe_strcpy(new_codec, codec_put->valuestring, sizeof(new_codec), 0);
1352+
if (strcasecmp(config.codec, new_codec) != 0) {
1353+
safe_strcpy(config.codec, new_codec, sizeof(config.codec), 0);
1354+
config_changed = true;
1355+
non_dynamic_config_changed = true;
1356+
requires_restart = true;
1357+
log_info("Codec hint changed to '%s' for stream %s - restart required",
1358+
config.codec, config.name);
1359+
}
1360+
}
1361+
13331362
// Parse tags setting (metadata only, no restart needed)
13341363
cJSON *tags_put = cJSON_GetObjectItem(stream_json, "tags");
13351364
if (tags_put && cJSON_IsString(tags_put)) {

0 commit comments

Comments
 (0)