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 whilerecord.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 withinrecord.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:recordingstays0even withrecord.enabled=1or 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_errorfieldsrecord_get_status()uses for a write failure, soGET /control'srecordobject explains it without an SSH session:"write_errors"increments and"last_error"readsmin_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 singlestatvfs, 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:
pathmust live under/tmp/and contain no... Since v1.9.28 no component may be a symlink (the path is walked withopenat(O_NOFOLLOW), missing directories created); before, only the last component was checked, so a planted/tmp/x -> /etcredirected the write.secondsis 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 toseconds + 5s. Since v1.9.28 the capture runs after that lock is released, and a busy request comes backrejected.) 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 + 5if 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:
- Subscribes to the JPEG source and waits up to 1.5s for a fresh JPEG packet.
- 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.
- 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.