Skip to content

AI Generated - Annotation mode never takes effect: ctx->annotation_only is assigned but never read, so duplicate detection MP4s are written alongside continuous recordings #547

Description

@niteshgmb-art

This is the AI Generated finding....

Summary

When a stream has both record=1 and detection_based_recording=1, the documented behaviour is annotation mode: detections are linked to the continuous recording and no separate MP4s are created. In practice separate detection_*.mp4 files are still written for the full duration of every motion event, duplicating footage that the continuous recorder has already captured.

The cause appears to be that annotation_only is plumbed all the way into the detection context and then never read by any non-test code.

Version

LightNVR v0.37.2
Build date: 2026-08-17
Image: ghcr.io/opensensor/lightnvr:latest

Also present in current main at the time of writing.

Documented / expected behaviour

docs/internal/PRE_DETECTION_BUFFER_IMPLEMENTATION.md:

Annotation mode: When both record=true and detection_based_recording=true, detections are stored with recording_id linking to continuous recordings (no separate MP4s created)

and its recording-mode table:

record detection_based_recording Detection Thread Behavior
false true Creates MP4s on detection (annotation_only=false)
true true Links to continuous recording (annotation_only=true)

The struct field comment says the same (include/video/unified_detection_thread.h:119-121):

// Annotation-only mode: when true, detection runs but does NOT create separate MP4 files
// Detections are stored in the database and linked to the continuous recording
bool annotation_only;

Actual behaviour

Both files are produced concurrently for the same stream and the same wall-clock period:

$ find recordings -name '*.mp4' -mmin -60 -printf '%f %s\n' \
    | awk '{split($1,a,"_"); s[a[1]]+=$2; n[a[1]]++} END {for(k in s) printf "%-10s %4d files %6.2f GB/hr\n", k, n[k], s[k]/1073741824}'
detection     19 files   4.03 GB/hr
recording    156 files  10.72 GB/hr

Per stream (Correspondence, camera encodes 720p H.264 CBR @ 2048 kbps):

recording_*.mp4   2.05 Mbps   <- matches the camera bitrate exactly
detection_*.mp4   2.76 Mbps   <- same footage, written a second time

So ~27% of total write volume is duplicate footage.

Database state agrees that annotation mode is not active — detections are almost never linked to a recording:

sqlite> select case when recording_id is null then 'NULL' else 'linked' end k, count(*)
        from detections group by k;
NULL|28398
linked|28

sqlite> select trigger_type, count(*) from recordings group by trigger_type;
continuous|240
detection|27

Under annotation mode I would expect recording_id to be populated and trigger_type='detection' rows not to be created while continuous recording is active.

Suspected cause

annotation_only is computed correctly in every caller and stored on the context:

// src/video/stream_manager.c:210
bool annotation_only = db_streams[i].record;
...
// src/video/unified_detection_thread.c:1015
ctx->annotation_only = annotation_only;

but grep -rn annotation_only src/ include/ shows the struct member is only ever written. The single read of the value in unified_detection_thread.c:1027 is of the local parameter, and only to emit a log line:

if (annotation_only) {
    log_info("[%s] Detection will annotate continuous recordings and create "
             "detection clips while continuous recording is inactive",
             stream_name);
}

The only code that reads ctx->annotation_only is tests/unit/test_external_motion_trigger.c, where the intended semantics are asserted:

if (!ctx->annotation_only) { ... }                                  // :83
if (!ctx->annotation_only && current == UDT_STATE_RECORDING) { ... } // :98
void test_annotation_only_ignores_active_trigger(void)              // :231

So the unit tests encode the correct behaviour against a local mock, while the production state machine in unified_detection_thread.c never consults the flag and transitions into UDT_STATE_RECORDING regardless.

Reproduction

  1. Configure a stream with record=1, detection_based_recording=1, detection_model=onvif, is_onvif=1.
  2. Let it run against a camera that emits ONVIF motion events.
  3. Observe both recording_*.mp4 and detection_*.mp4 being written for the same period.
  4. Observe detections.recording_id is NULL rather than linked.

Impact

On a 12-camera 720p deployment this is ~97 GB/day of duplicate footage, cutting retention on a 3.6 TB volume from ~15 days to ~11.

Reducing post_detection_buffer mitigates it (shorter clips per event) but cannot eliminate it, since the duplicate-recording path runs regardless of the flag.

Note also that detection_based_recording=0 is not a viable workaround for users who want continuous recording with motion markers on the timeline: the ONVIF pull-point subscription lives inside the unified detection thread, which is only started when detection_based_recording is true (src/core/main.c:1062, src/video/stream_manager.c:203). Disabling it stops motion events being recorded at all.

Suggested fix

Gate the recording-state transition and MP4 writer in unified_detection_thread.c on ctx->annotation_only, matching the logic already asserted in tests/unit/test_external_motion_trigger.c — when true, store the detection and link recording_id to the active continuous recording instead of opening a new MP4.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions