fix: mp4_writer close sequence, start_time correction, external motion trigger for UDT slave streams#356
fix: mp4_writer close sequence, start_time correction, external motion trigger for UDT slave streams#356origin2000 wants to merge 1 commit intoopensensor:mainfrom
Conversation
|
@matteius : sorry for the rather comprehensive PR. This my first github thingy. have now spent couple of days wading through the sources. finally decided to buy Claude for help. Tested this in my environment positively the last 24hours, hope I didn't create any side effects |
There was a problem hiding this comment.
Pull request overview
This PR fixes recording metadata accuracy and adds support for propagating ONVIF motion events to streams managed by the Unified Detection Thread (UDT), with additional start/end time corrections for MP4 segments.
Changes:
- Reorders
mp4_writer_close()to finalize the MP4 file before probing duration and updating DB metadata; addsget_mp4_file_duration_seconds(). - Adds an external-motion propagation path from ONVIF motion recording to UDT-managed “slave” streams via an atomic trigger.
- Introduces DB support for correcting
recordings.start_timeafter pre-buffer flush; resets writer timing state on segment rotation.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/video/unified_detection_thread.c | Adds external motion trigger handling and start_time correction after pre-buffer flush. |
| include/video/unified_detection_thread.h | Adds external_motion_trigger field + API for external motion notification. |
| src/video/onvif_motion_recording.c | Propagates motion events to UDT-managed slave streams. |
| src/video/mp4_writer_core.c | Reorders close sequence; probes finalized file duration; updates DB after finalization. |
| include/video/mp4_writer.h | Adds start_time_corrected field and duration-probe helper declaration. |
| src/video/mp4_writer_thread.c | Resets creation_time / start_time_corrected on rotation for segment timing. |
| src/video/mp4_segment_recorder.c | Clarifies comment about DB entry creation aligning to first keyframe. |
| src/database/db_recordings.c | Adds update_recording_start_time() to correct start_time post pre-buffer flush. |
| include/database/db_recordings.h | Declares update_recording_start_time(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/video/unified_detection_thread.c
Outdated
| if (is_keyframe && current_state != UDT_STATE_POST_BUFFER) { | ||
|
|
||
| // --- External motion trigger (e.g. ONVIF event forwarded from a master stream) --- | ||
| // Check the atomic flag that unified_detection_notify_motion() may have set. | ||
| // We consume the flag here (reset to 0) and handle it exactly like a local detection. | ||
| int ext_trigger = atomic_exchange(&ctx->external_motion_trigger, 0); | ||
|
|
There was a problem hiding this comment.
External motion triggers are only processed inside if (is_keyframe && current_state != UDT_STATE_POST_BUFFER), which means a motion "keep-alive" received while already in POST_BUFFER will never be consumed. This makes the ext_trigger == 1 && current_state == UDT_STATE_POST_BUFFER branch unreachable and can let a recording stop even though external motion is still active. Consider handling external_motion_trigger regardless of POST_BUFFER state (or move the exchange above the state guard) so POST_BUFFER can be extended/returned to RECORDING as intended.
There was a problem hiding this comment.
Confirmed and fixed. The atomic_exchange has been moved outside the != UDT_STATE_POST_BUFFER guard into its own if (is_keyframe) block. The POST_BUFFER → RECORDING branch on ext_trigger == 1 is now reachable. A note in the comment explicitly marks it as "previously unreachable".
src/video/unified_detection_thread.c
Outdated
| // --- External motion trigger (e.g. ONVIF event forwarded from a master stream) --- | ||
| // Check the atomic flag that unified_detection_notify_motion() may have set. | ||
| // We consume the flag here (reset to 0) and handle it exactly like a local detection. | ||
| int ext_trigger = atomic_exchange(&ctx->external_motion_trigger, 0); |
There was a problem hiding this comment.
The comments say the UDT thread reads external_motion_trigger "on each packet iteration", but the implementation currently checks it only when is_keyframe is true. Either update the documentation/comments to match the keyframe-driven behavior or move the check outside the keyframe condition if you intend it to react on any packet boundary.
There was a problem hiding this comment.
Accepted and applied. Comment updated in unified_detection_thread.h to reflect the actual keyframe-driven polling behavior. Per-packet handling is intentionally not used — keyframes are the natural trigger point for detection decisions.
| // The UDT main loop polls this flag on every packet and treats a rising edge as | ||
| // equivalent to a local detection event (starts/extends recording). |
There was a problem hiding this comment.
The struct comment says the UDT main loop polls external_motion_trigger "on every packet", but process_packet() currently consumes it only on video keyframes. Please align this comment with the actual behavior (keyframe-driven polling) or adjust the code if the intent is truly per-packet handling.
| // The UDT main loop polls this flag on every packet and treats a rising edge as | |
| // equivalent to a local detection event (starts/extends recording). | |
| // The UDT processing path checks this flag when handling eligible video | |
| // keyframes and treats a rising edge as equivalent to a local detection | |
| // event (starts/extends recording). |
There was a problem hiding this comment.
Accepted and applied. Comment updated in unified_detection_thread.h to reflect the actual keyframe-driven polling behavior. Per-packet handling is intentionally not used — keyframes are the natural trigger point for detection decisions. Thank you for the precise suggestion.
| if (writer->thread_ctx) { | ||
| log_info("Stopping recording thread for %s during writer close", | ||
| writer->stream_name ? writer->stream_name : "unknown"); | ||
|
|
||
| // Call the function to stop the recording thread | ||
| // This will properly clean up the thread context | ||
| mp4_writer_stop_recording_thread(writer); | ||
|
|
||
| // thread_ctx should now be NULL if the function worked correctly | ||
| // If it's not NULL, there might be a problem | ||
| if (writer->thread_ctx) { | ||
| log_warn("Thread context still exists after stopping recording thread for %s", | ||
| writer->stream_name ? writer->stream_name : "unknown"); | ||
| // Don't free it here, as it might still be in use | ||
| } | ||
| } |
There was a problem hiding this comment.
mp4_writer_stop_recording_thread() can detach the recording thread on timeout, leaving it running with a live tctx->writer pointer. mp4_writer_close() then continues and eventually free(writer), which can cause a use-after-free if the detached thread is still executing. Consider making mp4_writer_stop_recording_thread() report whether the thread was joined vs detached and, if detached, avoid freeing/closing writer resources (or block until exit) to keep the writer alive as long as the thread may access it.
src/video/mp4_writer_core.c
Outdated
| /* Derive end_time from the MP4 container duration (most accurate). * | ||
| * Fall back to wall-clock time(NULL) if the file cannot be read. */ | ||
| time_t end_time = time(NULL); | ||
| double dur = get_mp4_file_duration_seconds(writer->output_path); | ||
| if (dur >= 0.0) { | ||
| end_time = writer->creation_time + (time_t)dur; | ||
| log_info("Actual MP4 duration for %s: %.1f s → end_time=%ld", | ||
| writer->output_path, dur, (long)end_time); | ||
| } else { | ||
| log_warn("Could not read duration from '%s', using wall-clock end_time", | ||
| writer->output_path); |
There was a problem hiding this comment.
mp4_writer_close() derives end_time as writer->creation_time + duration. This assumes creation_time matches the DB start_time for current_recording_id, but in the continuous writer thread the DB start_time is set in on_segment_started_cb() (first keyframe) and creation_time is currently set at rotation time, which can differ by up to a GOP interval. To keep end_time accurate, consider baselining off the DB start_time (fetch metadata) or set writer->creation_time to the same timestamp used when the DB row is created.
| /* Derive end_time from the MP4 container duration (most accurate). * | |
| * Fall back to wall-clock time(NULL) if the file cannot be read. */ | |
| time_t end_time = time(NULL); | |
| double dur = get_mp4_file_duration_seconds(writer->output_path); | |
| if (dur >= 0.0) { | |
| end_time = writer->creation_time + (time_t)dur; | |
| log_info("Actual MP4 duration for %s: %.1f s → end_time=%ld", | |
| writer->output_path, dur, (long)end_time); | |
| } else { | |
| log_warn("Could not read duration from '%s', using wall-clock end_time", | |
| writer->output_path); | |
| /* Use the finalized file timestamp as the end_time baseline instead of | |
| * deriving it from writer->creation_time, which may not match the DB | |
| * start_time for this recording. Fall back to wall-clock time(NULL) | |
| * if stat() did not succeed. Read MP4 duration for logging only. */ | |
| time_t end_time = (size_bytes > 0 || stat(writer->output_path, &st) == 0) | |
| ? st.st_mtime | |
| : time(NULL); | |
| double dur = get_mp4_file_duration_seconds(writer->output_path); | |
| if (dur >= 0.0) { | |
| log_info("Actual MP4 duration for %s: %.1f s; using file end_time=%ld", | |
| writer->output_path, dur, (long)end_time); | |
| } else { | |
| log_warn("Could not read duration from '%s', using file/wall-clock end_time=%ld", | |
| writer->output_path, (long)end_time); |
There was a problem hiding this comment.
Accepted with a refinement. The observation is correct — creation_time is set at rotation time, while the DB start_time is recorded at the first keyframe, so creation_time + dur carries a potential GOP-interval drift.
We adopt the st.st_mtime baseline as suggested, but track stat_ok explicitly as a bool rather than reusing the size_bytes > 0 heuristic from Copilot's snippet — that heuristic would leave st uninitialized if both stat() calls failed. With stat_ok the logic is unambiguous: end_time = stat_ok ? st.st_mtime : time(NULL).
get_mp4_file_duration_seconds() is retained for diagnostic logging — it confirms the encoded duration matches expectations without influencing end_time.
src/video/mp4_writer_thread.c
Outdated
| // Reset creation_time to the rotation wall-clock time so that the | ||
| // new segment's duration is measured from the correct baseline. | ||
| // Without this, mp4_writer_close() would compute end_time relative | ||
| // to the original writer creation time, not the segment start. | ||
| thread_ctx->writer->creation_time = current_time; | ||
|
|
||
| // Reset the start_time_corrected flag for the new segment. | ||
| thread_ctx->writer->start_time_corrected = false; |
There was a problem hiding this comment.
On rotation, creation_time is reset to current_time, but the DB start_time for the new segment is created later in on_segment_started_cb() when the first keyframe arrives. If mp4_writer_close() uses creation_time as the baseline for end_time, this can still skew durations by the keyframe wait time. Consider updating writer->creation_time inside on_segment_started_cb() to match the DB start_time (or stop using creation_time for DB end_time calculation).
There was a problem hiding this comment.
This concern is resolved by the fix applied in response to the previous comment. mp4_writer_close() now derives end_time from st.st_mtime (the file's last-modified timestamp after avio_closep) rather than creation_time + dur, so the keyframe-wait skew between rotation time and on_segment_started_cb() no longer affects end_time accuracy.
The creation_time = current_time assignment on rotation is retained for diagnostics; its comment has been updated to reflect that it is no longer the end_time baseline. The start_time_corrected reset remains necessary for the pre-buffer start_time correction path in the UDT.
49fcfd1 to
00e4e77
Compare
Fix 1:
mp4_writer_close()correct close sequence (DB update beforeav_write_trailer)Problem
When a detection or motion recording was finalized, the database record was updated
with
end_time, file size, andis_complete = truebefore the MP4 container wasfully written to disk.
This caused two concrete defects:
Inaccurate duration in the database. The old helper
get_recording_end_time()called
avformat_open_input()on a file that was still open for writing. FFmpegeither failed to read the duration or returned 0, so
end_timewas computed asstart_time + 0— effectively making every detection clip appear to have zero orwrong duration in the recordings list.
Potential file corruption risk. Marking a recording as
is_complete = truewhile the
AVFormatContextwas still open meant that any consumer (UI, storagemanager) could act on an incomplete file.
Root Cause
The original
mp4_writer_close()executed in this order:get_recording_end_time()was called as part of step 1 and tried to probe thestill-open file with
avformat_open_input(), producing unreliable results.Fix
The close sequence is reordered so the file is fully finalized on disk before
any database or cache update:
A new helper
get_mp4_file_duration_seconds(const char *path)is introduced inmp4_writer_core.c(declared inmp4_writer.h) to encapsulate theavformat_open_input/avformat_find_stream_infoprobe on the closed file.The old
get_recording_end_time()static function is removed.Additionally,
mp4_writer_thread.cnow resetswriter->creation_timetocurrent_timeon each segment rotation, so that the new segment's duration ismeasured from the correct baseline rather than from the original writer creation time.
Files Changed
src/video/mp4_writer_core.cget_recording_end_time()withget_mp4_file_duration_seconds(); added#include <errno.h>include/video/mp4_writer.hget_mp4_file_duration_seconds()declaration; addedbool start_time_correctedfield tomp4_writer_tsrc/video/mp4_writer_thread.ccreation_timeandstart_time_correctedon segment rotationFix 2:
Correct
start_timein database after pre-buffer flush for detection recordingsProblem
When a detection recording is started, the database record is created immediately
via
add_recording_metadata()usingtime(NULL)asstart_time. However, theUnified Detection Thread (UDT) then flushes a circular pre-event buffer into the
MP4 file — this buffer can contain several seconds of footage that precede the
detection event.
As a result, the
start_timestored in the database was consistently later thanthe actual first frame in the file, by as much as
pre_buffer_seconds. Thiscaused:
recorded time range shown in the UI
Root Cause
udt_start_recording()calledadd_recording_metadata()withstart_time = now(the moment the detection triggered), before
flush_prebuffer_to_recording()was called. The pre-buffer flush writes packets that are up to
pre_buffer_secondsolder than
now, but the database was never updated to reflect this earlier start.Fix
After
flush_prebuffer_to_recording()completes, the actual pre-buffer durationis read via
packet_buffer_get_stats(). If the duration is non-zero, a newdatabase function
update_recording_start_time()is called to correct the storedstart_timetonow - pre_buffer_duration. Themp4_writer_t::creation_timefield is updated in parallel so that the writer's own duration tracking stays
consistent.
A new boolean field
mp4_writer_t::start_time_correctedis set after the firstcorrection to prevent a redundant update on segment rotation.
New API
Executes
UPDATE recordings SET start_time = ? WHERE id = ?— a minimal,targeted correction that does not touch any other fields.
Files Changed
include/database/db_recordings.hupdate_recording_start_time()declarationsrc/database/db_recordings.cupdate_recording_start_time()include/video/mp4_writer.hbool start_time_correctedfield tomp4_writer_tsrc/video/unified_detection_thread.cupdate_recording_start_time()after pre-buffer flush in both local detection and external trigger pathssrc/video/mp4_segment_recorder.cstarted_cbinvocationFix 3:
Propagate ONVIF motion events to Unified Detection Thread-managed slave streams
Problem
LightNVR supports cross-stream motion triggering via
motion_trigger_source: whenstream A detects motion via ONVIF, the event is propagated to any stream B that
lists A as its trigger source. This is the intended architecture for multi-lens
cameras where one lens provides ONVIF motion events and another does not.
However, the propagation in
process_motion_event()pushed the event only ontothe ONVIF motion event queue. If the slave stream was configured to use the
Unified Detection Thread (UDT) — which is the correct and only viable architecture
for a stream without its own ONVIF profile — the UDT thread never received the
event. The slave stream therefore never started recording, silently dropping all
motion-triggered recordings.
Root Cause
The ONVIF event queue (
push_event()) is consumed exclusively byevent_processor_thread_func()inonvif_motion_recording.c. The UnifiedDetection Thread reads from a completely separate code path and has no mechanism
to receive events from the ONVIF queue. Sending an ONVIF queue event to a
UDT-managed stream was silently ignored.
Affected Camera Configurations
This bug affects any multi-lens camera that:
VideoSourceConfigurationTokenvalues for all profiles,making ONVIF-level stream distinction impossible
independent ONVIF profile
In this configuration, the correct LightNVR setup is:
onvifmodel)motion_trigger_sourceThis is a documented limitation of several manufacturers' ONVIF implementations
and is not specific to any single device. Tested with TP-Link C545D (dual-lens,
wide + PTZ).
Fix
A second propagation path is added alongside the existing ONVIF queue path:
Both paths are always attempted for every matching slave stream. Whichever system
is not managing that stream silently ignores the call — there is no double-trigger
risk for streams managed by either system exclusively.
unified_detection_notify_motion()A new public function in
unified_detection_thread.c:It writes to a new
atomic_int external_motion_triggerfield on the UDT context:1= motion active (start / keep-alive)2= motion ended0= idle (initial state / reset by UDT thread after processing)The write is intentionally non-blocking — it only sets the atomic flag and returns
immediately, making it safe to call from the ONVIF event processor thread without
any locking concerns.
UDT main loop
On each keyframe, the UDT thread checks
external_motion_triggerviaatomic_exchange(..., 0)(consuming the flag atomically):UDT_STATE_BUFFERING: start recording, flush pre-buffer, correctstart_timeUDT_STATE_POST_BUFFER: return toUDT_STATE_RECORDINGUDT_STATE_RECORDING: refreshlast_detection_time(extends recording)UDT_STATE_RECORDING: transition toUDT_STATE_POST_BUFFERThis mirrors the behaviour of a locally-triggered detection event, including the
pre-buffer flush and
start_timecorrection (see related PR).Files Changed
include/video/unified_detection_thread.hatomic_int external_motion_triggerfield tounified_detection_ctx_t; declaredunified_detection_notify_motion()src/video/unified_detection_thread.cunified_detection_notify_motion(); added external trigger polling in UDT main loop; initializedexternal_motion_triggerto0in context setupsrc/video/onvif_motion_recording.c#include "video/unified_detection_thread.h"; added Path B (unified_detection_notify_motion()) call inprocess_motion_event()propagation loop