Save Ring's detection-time snapshot and log per-capture latency - #10
Conversation
Motion clips were starting after subjects had already walked past, so recordings showed people leaving rather than approaching. Measured on a battery Spotlight Cam Plus over 10 real motion events, the gap between Ring detecting motion and the first frame reaching disk is ~8.5s median: Ring detection -> push arrives here ~2.3s (Ring's) trigger -> live stream negotiated ~3.7s (WebRTC signaling + wake) stream open -> first bytes on disk ~2.5s (ffmpeg analysis) At walking pace that is ~12 m of approach never recorded. Almost all of it is Ring-side. A live stream opened after an event also cannot contain footage from before it, so no amount of tuning here produces pre-roll. Confirmed the cloud route is closed without a subscription: getEvents() reports every motion event as recorded/ready, but getRecordingUrl() returns "Source video not available" and videoSearch() returns empty. What is available is the still Ring captures at detection time. Its uuid rides along on the motion push (img.snapshot_uuid), and it predates the first video frame by the whole window above — so it is the only view of the approach obtainable without Ring Protect. It costs one REST fetch and does not wake the camera. - detection.ts: parse the push into a detection context (Ring's own detection timestamp, ding id, subtype, snapshot uuid) and compute the latency segments. Pure, so the arithmetic is testable without an account, camera or clock. - events.ts: subscribe to onNewNotification alongside the existing boolean motion stream, and on trigger fetch the snapshot *in parallel* with the recording rather than after it — Ring expires these, so waiting out a 30s clip risks losing the one frame showing the approach. - recorder.ts: capture triggerAt / streamOpenAt / firstFrameAt. First-frame is detected by polling the output for non-zero size; that is time-to-first-bytes (the fMP4 header flushes just ahead of the first fragment), which is within a frame interval of first-frame and far below the latency being measured. - files.ts: appendJsonLine, which reports failure rather than throwing. Every part of this is diagnostic bookkeeping attached to a recording that already succeeded, so no failure in it can turn a saved clip into a reported one: a missing uuid, an expired snapshot, an unwritable log and a camera with no notification stream all degrade to a null field. Covered by tests. New config: detectionSnapshots and timingLog, both default true. Latency rows land in <outputDir>/timing.jsonl, one per capture, so the effect of a motion zone or sensitivity change can be measured against the 8.5s baseline instead of eyeballed. (A future captures.jsonl manifest, backlog #4, can supersede this.) Also corrects the README, which claimed the pre-roll loss was "~1-2s". Measured local latency alone is ~4-6s and the full window ~8.5s; that line is what set the wrong expectation. Verified: 31 tests pass. Service starts, subscribes and shuts down cleanly against the live API, and a real capture returns populated timings (4.35s stream setup, 7.01s to first bytes).
PR Review SummaryThe changes in this PR look well-designed and thoroughly tested. Latency accounting, parallel detection snapshot retrieval, and timing log appending are implemented with careful error handling so diagnostic metadata collection will never interrupt core clip recording. Key Highlights
Recommendations
🤖 Reviewed by codereviewbot.ai - Catch bugs before your team does. |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
| const path = detectionSnapshotPath(result.path); | ||
| try { | ||
| writeFileSync(path, snapshot); | ||
| snapshotFile = path.split('/').pop() ?? null; |
There was a problem hiding this comment.
Using .split('/') to extract the file name from a path will fail on Windows systems where path separators are \. Consider using basename from node:path for cross-platform compatibility.
| snapshotFile = path.split('/').pop() ?? null; | |
| import { basename, join } from 'node:path'; | |
| // ... | |
| snapshotFile = basename(path); | |
| // ... | |
| clipFile: basename(result.path), |



Motion clips were starting after subjects had already walked past — recordings showed people leaving rather than approaching.
The measurement
Median over 10 real motion events on a battery Spotlight Cam Plus, pairing Ring's own event timestamps against clip timestamps:
~12 m of approach at walking pace, never recorded. Almost all of it is Ring-side.
This can't be fixed with pre-roll here. A stream opened after an event cannot contain footage from before it. I also confirmed the cloud route is closed without a subscription:
getEvents()reports every motion event asrecorded=true, recording_status=ready, butgetRecordingUrl()returns"Source video not available"andvideoSearch()returns[].What this adds
The one thing that does predate the first video frame: the still Ring captures at detection time. Its uuid rides along on the motion push (
img.snapshot_uuid), so it's obtainable without Ring Protect, costs one REST fetch, and doesn't wake the camera.detection.ts— parses the push into a detection context (Ring's detection timestamp, ding id, subtype, snapshot uuid) and computes the latency segments. Pure, so the arithmetic is testable without an account, camera, or clock.events.ts— subscribes toonNewNotificationalongside the existing boolean motion stream. Fetches the snapshot in parallel with the recording, not after: Ring expires these, so waiting out a 30s clip risks losing the one frame showing the approach.recorder.ts— capturestriggerAt/streamOpenAt/firstFrameAt. First-frame is detected by polling for non-zero file size, i.e. time-to-first-bytes (the fMP4 header flushes just ahead of the first fragment) — within a frame interval of first-frame, and far below the multi-second latency being measured.files.ts—appendJsonLine, which reports failure rather than throwing.Failure isolation. All of this is diagnostic bookkeeping hung off a recording that already succeeded, so none of it can turn a saved clip into a reported failure. A missing uuid, an expired snapshot, an unwritable log, and a camera exposing no notification stream all degrade to a null field. Each case has a test.
Config
detectionSnapshotsandtimingLog, both defaulttrue. Rows land in<outputDir>/timing.jsonl, one per capture:{"camera":"Front","clip":"Front_….mp4","detectionSnapshot":"Front_….detection.jpg", "subtype":"motion","ringEventAt":"…","firstFrameAt":"…", "pushDelaySec":2.27,"streamSetupSec":3.75,"firstFrameSec":2.45,"blindWindowSec":8.5}blindWindowSecis the headline number, so the effect of a motion-zone or sensitivity change can be measured against the 8.5s baseline rather than eyeballed. Backlog #4'scaptures.jsonlmanifest can supersede this file later.Also
Corrects the README, which claimed the pre-roll loss was "~1-2s". Measured local latency alone is ~4-6s and the full window ~8.5s — that line is what set the wrong expectation in the first place.
Verification
secs()was returning milliseconds rather than seconds, and a wiring test mixed a real-dated push with fake timings. Both fixed.Not yet confirmed: whether
img.snapshot_uuidis actually populated for this camera. It's optional in Ring's payload and needs a live motion event to verify — which is precisely what this instrumentation will tell us. Theno snapshot uuidpath is tested and harmless.🤖 Generated with Claude Code