Skip to content

Recording & Timelapse

Both features write to local storage (typically the SD card) and are built only with their respective flags (USE_RECORD/USE_TIMELAPSE, both on by default — see Building); without the flag, stub functions keep call sites in main.c/control.c unconditional and GET /control reports "available":0.

Local recording (src/record.c)

Continuous or motion-triggered fragmented-MP4 recording, reusing the same fMP4 muxer (src/mp4/fmp4.c) as the live /stream.mp4 HTTP preview — this is "raptor's RMR" for timps. Configured under record.* (see Configuration Reference).

File layout

<record.dir>/<hostname>/records/<strftime(record.name)>.mp4, default dir /mnt/mmcblk0p1, default name template %Y%m%d/%H/%Y%m%dT%H%M%S. gethostname() falls back to "camera" on failure. Paths are validated against .. path components and an absolute record.name before every open, since record.dir/record.name are themselves runtime-mutable via /control and an authenticated caller could otherwise escape the records tree. Since v1.9.28 the strftime-expanded name is vetted too (a conversion that expands to nothing can still assemble a ..); an expansion that fails the check is replaced by the Unix timestamp, as a failed strftime() already was.

Since v1.9.28 a record.dir (or, while it does not exist yet, its nearest existing parent) on the root filesystem is refused: the usual case is the SD mount point with no card in it, where earlier versions silently recorded into flash. One [ERR] record.dir … is on the root filesystem (card not mounted?) - not recording per episode, also in last_error; recording resumes on its own once the card is mounted. /tmp is allowed.

Modes

  • record.mode = continuous (0) — always recording while record.enabled.
  • record.mode = motion (1, the default) — records only while "recent" motion is true: motion detection is available, enabled, and the time since its last event is within record.post_roll_s. This single check elegantly covers both "motion is happening right now" and the post-roll tail in one condition.

A manual override ({"record":{"active":1|0}} via /control, the WebUI's record button) takes precedence over both config and mode: 1 forces recording on, 0 forces it off, and omitting/negative returns to config-driven ("auto") behavior.

Pre-roll (motion mode)

A circular ring buffer (256 packet slots, plus a hard 4 MB byte backstop independent of slot count) continuously retains recent packets — refcounted, not copied — while motion mode is not actively writing. When a recording transition to "writing" happens, the ring is scanned oldest first for the first video keyframe still buffered, and everything from there forward is flushed into the new segment, so a motion-triggered clip always starts cleanly at a keyframe rather than mid-GOP — and as far back as record.pre_roll_s allows. (Scanning newest-first instead would start at the most recent keyframe and silently cap every pre-roll at roughly one GOP, whatever pre_roll_s said; that was the behaviour until dd7946a.) The ring's time trim keeps the newest keyframe at or before the pre_roll_s cutoff, since that is where the window starts decoding; until v1.9.27 the trim dropped it, so the pre-roll came out anywhere from pre_roll_s down to almost nothing. Since v1.9.28 also: a start with no keyframe buffered asks the encoder for one instead of waiting out the GOP, and while segment opens keep failing (card missing, read-only) the ring keeps rolling, so a later successful open no longer splices minutes-old pre-roll onto live frames. If the pre-roll window configured (record.pre_roll_s) exceeds what the ring can actually hold at the stream's current bitrate/fps, a one-time warning is logged (the ring silently truncates otherwise).

Queue overflow (since v1.9.19)

The recorder subscribes with its own fanqueue, so storage that cannot keep up (a full or slow card, a stalled NFS mount) makes that queue evict packets. Everything still queued up to the next keyframe then references access units that are not in the file. Up to v1.9.18 those frames were muxed anyway — w_got_key is per segment, so it did not re-arm — and the recording carried up to a full GOP of decoder residue after the hole.

The recorder now freezes the segment on a video drop and resumes at the next keyframe, the same shape http.adaptive_drop gives fMP4 clients, and asks the encoder for that keyframe (rate-limited across all consumers of the stream). The visible trade: the gap is up to one GOP longer and what follows it is clean. Audio freezes with the video so both tracks resume together; segment rotation is unaffected, since it can only fire on the keyframe that also ends the freeze; and a drop while buffering motion pre-roll clears the ring, because flush_ring() starts at the oldest buffered keyframe and would otherwise write straight across the hole. An eviction that hit only audio does none of this: the video GOP is still intact, so freezing on it would discard good video for nothing. record_clip() behaves the same (and still ends at its requested duration even while frozen). There is no key to turn it off. The first drop per subscription is a WARN (chn=0: record queue overflowed, dropping frames (storage/consumer too slow)) and sustained drops appear in the HUB 60-second summary; the count is queue_drops in GET /control.

Segment rotation

If record.segment_s > 0, a segment is closed and a new one opened as soon as the current packet is a video keyframe and the elapsed time since the segment started reaches segment_s — rotation only ever happens at a keyframe boundary, never mid-GOP. segment_s = 0 disables rotation (one continuous file for as long as recording stays active).

Free-space pruning

Before opening any new segment, if record.min_free_mb > 0 and free space on record.dir is below that threshold, the globally oldest regular files anywhere under <dir>/<hostname>/records are deleted until the threshold is met or no candidate remains — one directory walk collects the 32 oldest at a time (rather than re-walking the whole tree per deleted file, which on a full card costs dropped frames), and traversal uses lstat, never following a symlink out of the tree. "Oldest" is by mtime; since v1.9.28 an mtime before 2025 counts as newest: without an RTC, footage written after a power cut but before NTP sync carries a ~1970 mtime, and earlier versions deleted exactly that footage first.

Pruning is not unconditional. The same walk that collects candidates also totals the size of the records tree, and before deleting anything the first walk asks whether the target is reachable at all: if free_mb + everything under the records tree is still below min_free_mb, no amount of deleting gets there — the usual cause is a min_free_mb larger than the card. In that case nothing is deleted, and instead:

  • seg_open() fails, so no segment is opened at all: recording stays 0 even with record.enabled=1 or a manual {"record":{"active":1}} override — the daemon refuses to record rather than empty the archive chasing a target it can never hit;
  • one warning is logged (record.min_free_mb=<N> unreachable (<F>MB free, <M>MB max) - not recording, <M> being the free space if every existing recording were deleted), once per bad-value edge rather than once per attempted rotation;
  • the refusal is surfaced through the same write_errors / last_error fields record_get_status() uses for a write failure, so GET /control's record object explains it without an SSH session: "write_errors" increments and "last_error" reads min_free_mb=<N> unreachable (max <M>MB);
  • and the answer is re-evaluated at most once every 5 seconds. While nothing is open, seg_open() runs once per incoming packet (~31/s measured), so a retry inside that window reuses the known refusal and costs a single statvfs, not another tree walk. Space appearing by some other route (a manual delete, a config fix) is therefore noticed within 5 seconds, not immediately.

This is a deliberate behaviour change: earlier versions deleted the oldest file over and over until the archive was empty and then recorded anyway. If a camera reports "recording":0 together with a min_free_mb ... unreachable last_error, the fix is to lower record.min_free_mb (or point record.dir at larger media), not to delete recordings by hand — the daemon already knows deleting them would not be enough. A failed statvfs is not a refusal: free space simply cannot be measured, and recording proceeds as if no threshold were set.

Durability

Every ~5 seconds, an open segment gets a non-blocking fflush() + sync_file_range() (bounding data loss on a power cut to roughly that window without risking a slow SD card stalling the writer thread and dropping frames); the real blocking fsync() happens once, at segment close. Since v1.9.28 data already handed to writeback is dropped from the page cache (POSIX_FADV_DONTNEED) one tick later, and the whole segment after that closing fsync(), so recording does not fill the RAM of a small board with footage nobody reads back. A short write (SD card yanked, disk full) closes the segment immediately rather than continuing to falsely report "recording."

Boot-snapshot geometry (important detail)

Both the rotating recorder and on-demand clip capture (below) build the fMP4 container using g_cfg_boot — the codec/dimensions/fps the encoder was actually started with — never the live, possibly-edited g_cfg. This mirrors the same rule Configuration Reference documents for RTSP SDP/HTTP fMP4: those fields are restart-only, so describing them from a live edit would desync the container's declared format from the bytes the encoder is actually producing.

Audio

record.audio=1 (default) mixes AAC audio into the recording when the hub reports an AAC track; G.711 cannot be muxed into fMP4 (same limitation as the HTTP preview), so a G.711 configuration is warned about once and recordings come out video-only. Toggling record.audio via /control is reconciled live even mid-recording (an earlier version could leave a broken empty AAC track, or leak a stale subscription, when toggled while a segment was open).

On-demand clip capture

{"record":{"clip":"<path>","seconds":N}} (via /control, used by send2/Telegram-style motion notifications) captures a short fMP4 clip completely independent of the rotating SD recorder — it works even with record.enabled=0, since subscribing to the hub wakes the shared encoder on demand. Notable safety details:

  • path must live under /tmp/ and contain no ... Since v1.9.28 no component may be a symlink (the path is walked with openat(O_NOFOLLOW), missing directories created); before, only the last component was checked, so a planted /tmp/x -> /etc redirected the write.
  • seconds is clamped to 1–30 (default 6).
  • A single concurrency guard (trylock, not a queue) rejects a second concurrent clip request outright rather than stacking parallel RAM-backed captures during a burst of motion events. (Until v1.9.27 the capture ran under /control's apply lock, so a second request — and every other POST — actually waited behind the first for up to seconds + 5 s. Since v1.9.28 the capture runs after that lock is released, and a busy request comes back rejected.) The POST itself still returns only once the clip is written, which is what send2 relies on.
  • The output file is opened O_CREAT|O_EXCL|O_NOFOLLOW — never follows a pre-planted symlink, never clobbers an existing file.
  • Recording starts only once both a video keyframe and a ready parameter set (SPS/PPS) have arrived, and gives up after seconds + 5 if neither ever does.

Status

GET /control's "record" object reports available/enabled/ recording/channel/mode/bytes (current segment size)/free_mb/ file (current path, empty when idle), alongside the persisted config values so a WebUI can populate its record settings page from the same read.

It also carries the answers to "it says enabled, so why is nothing being written?": motion_gate_available/motion_gate_enabled (motion mode with the detector structurally inert, versus merely quiet), manual_off (the manual override latched off), and the write-path health triple write_errors (count since start) / last_error_age_s (seconds since the last one, -1 = never) / last_error (its text). A dying or full SD card, and the unreachable-min_free_mb refusal above, both announce themselves there rather than only in a log ring that recycles in hours.

free_mb is sampled every 10 s by the recorder thread (-1 until the first sample) — since v1.9.28; before, every status poll ran statvfs itself, and a wedged SD card or hard-mounted NFS record.dir blocked every GET /control.

Hook scripts and open files

Segments, clips, timelapse shots and snapshots are opened O_CLOEXEC (since v1.9.28), so a hook the daemon spawns (the motion hook, a day/night script) no longer inherits a write descriptor to the open SD file — which made unmounting the card fail with EBUSY until the hook exited.

Timelapse (src/timelapse.c)

Periodic native JPEG capture: every timelapse.interval_s, grabs the current frame from a stream's JPEG source and writes it to <timelapse.dir>/<hostname>/timelapses/<strftime(timelapse.name)>.jpg. Structurally similar to the recorder (own thread, gated by enabled, idles unsubscribed while off) but with no pre-roll/manual-override/ on-demand-clip equivalents. Configured under timelapse.* (see Configuration Reference).

JPEG source selection

Mirrors the same selection logic /snapshot.jpg?chn=N uses: prefer timelapse.channel's piggyback JPEG encoder if that stream is boot-enabled with jpeg_enabled=1; else the dedicated jpeg.* channel; else the first stream with any piggyback JPEG encoder; else no source (the thread idles).

Just-in-time capture (avoiding a wasted always-on pipeline)

Because timps encodes nothing while idle, subscribing to a JPEG hub source is exactly what wakes the shared encoder/framesource — so a naive "stay subscribed forever, take one frame every interval" design would otherwise keep the pipeline running 24/7 and discard nearly every encoded frame between shots. Instead, each shot:

  1. Subscribes to the JPEG source and waits up to 1.5s for a fresh JPEG packet.
  2. If nothing arrives on a piggyback source (a genuinely cold-start case), it additionally subscribes — with a tiny, immediately-discarded queue — to the parent video stream itself, exactly mirroring how an RTSP/HTTP client brings up a shared, currently-idle framesource/encoder group, then waits another 1.5s.
  3. Both subscriptions are released once the frame is captured (the helper video subscription first), letting the pipeline's normal ~2s idle-stop debounce shut it back down between shots.

Intervals shorter than roughly 5s therefore never cause the pipeline to fully stop between consecutive shots (they stay inside the idle-stop debounce window), avoiding needless start/stop churn on a fast timelapse schedule. On a failed grab, the retry backs off to 5s (or interval_s if smaller) rather than waiting out the full configured interval.

Writing and retention

Shots are written to a .tmp file first, then atomically rename()d into place — readers never see a partial JPEG. If timelapse.keep_days > 0, every successful shot triggers a recursive prune of *.jpg files older than keep_days*86400 seconds under the timelapses tree (directory-depth-bounded), removing any directory left empty afterward.

Since v1.9.28 keep_days is clamped to 0..3650, the cutoff is computed in 64 bits, and pruning is skipped while nothing can be that old yet: on 32-bit time_t a huge value (say 99999, meant as "forever") overflowed the cutoff into the future and deleted every shot. Shots with a pre-2025 (pre-NTP) mtime are never age-pruned — earlier versions removed them on the first pass. A timelapse.dir on the root filesystem is refused like record.dir ([ERR] timelapse.dir … is on the root filesystem (card not mounted?) - skipping shots).

Status

GET /control's "timelapse" object reports available/enabled/ interval_s/count (shots since daemon start)/last_t (unix time of the last shot, 0 = never)/free_mb/file (last written path). free_mb is sampled every 10 s by the timelapse thread, as for the recorder.