Live media — consuming a camera stream
How a host application consumes live video/audio from a camera with the SDK.
Everything here hangs off a bound camera:
const dev = await eufy.getDevice(sn);
const cam = dev.camera?.(); // camera controls + media (media present only when client-bound)The media methods (live, snapshotLive, openReadable, recordFragments, snapshot, record) are optional on the returned object — present only when the device is bound to a live client. Guard them (cam?.live) or assert once up front.
One pull, many consumers
Every consumer of a given camera shares one underlying media session (one live pull), fanned out to all of them. cam.live() twice, a snapshotLive() while a recordFragments() runs, a openReadable() alongside a live() — all attach to the same shared source. There is exactly one start on the wire regardless of how many consumers attach.
Consequences a host should rely on:
- Cheap re-use. The motion-thumbnail → tap-to-watch flow attaches a snapshot then a live view within seconds; the second attach re-uses the warm session, no second pull.
- Late joiners are primed instantly. The last keyframe (IDR) is cached and replayed to a new consumer on attach — no waiting a full GOP for the next keyframe before the picture appears.
- Linger, then stop. When the last consumer detaches the source lingers briefly (so a quick re-attach reuses it) and then stops the pull. You don't manage the pull; you manage your consumer.
1. Event stream (low-level)
The direct escape hatch — raw frames as they arrive.
const stream = await cam.live();
stream.on("video", (frame) => {
// frame.data Annex-B bytes (one or more start-code-prefixed NAL units)
// frame.codec "h264" | "h265" | "av1"
// frame.width, frame.height
// frame.keyframe true on an IDR (a valid resync/segment boundary)
});
stream.on("audio", (buf) => {
/* Buffer of audio payload */
});
stream.on("start", () => {});
stream.on("stop", () => {}); // upstream ended, or you called stop()
stream.on("error", (err) => {}); // includes a warm-up stall (see below)
stream.on("budget", (n) => n.extend()); // battery cameras only — see Power budget
stream.stop(); // detach this consumerstream.stop() detaches this consumer only. The shared pull stops when the last consumer detaches (after the linger window).
codec is sniffed off the parameter sets on a keyframe and carried on the delta frames that follow, so every frame carries a codec even though only keyframes have config to sniff.
2. Node Readable (pipe it)
A fresh node:stream Readable per call, over its own consumer. Default is raw Annex-B bytes; pass objectMode: true to get LiveVideoFrame objects instead.
const r = await cam.openReadable?.(); // Annex-B byte stream
r.pipe(fs.createWriteStream("out.h264"));
// ...
r.destroy(); // releases this consumer (and the pull if it was the last)Backpressure is handled per-consumer: a slow reader drops to the next keyframe rather than stalling the shared pull or any peer consumer. Destroying the Readable releases the consumer.
3. fMP4 / CMAF fragments (for HLS / MSE)
Continuous fragmented-MP4, muxed dependency-free (no ffmpeg, no native dep). An async iterable: the init segment (ftyp+moov) comes first, then a fragment per keyframe boundary (or every fragmentSeconds).
for await (const frag of cam.recordFragments!({ fragmentSeconds: 2 })) {
if (frag.init) sink.write(frag.init); // once, on the first emission
if (frag.data.length) sink.write(frag.data); // moof+mdat; frag.keyframe marks a segment boundary
}
// break / return releases the consumer.Both H.264 (avc1/avcC) and H.265 (hvc1/hvcC) are handled; Annex-B start codes are converted to AVCC length-prefixed NALs in the mdat.
Snapshots
const shot = await cam.snapshotLive?.(); // { jpeg, width, height }If the shared source already has a cached keyframe (a live view or another consumer is warm), snapshotLive decodes that keyframe directly — no second pull. Only if nothing is warm does it briefly attach, wait for a clean keyframe, decode, and detach. (The JPEG decode itself uses ffmpeg as an optional convenience sink; the raw keyframe bytes are always available dependency-free via openReadable / the event stream.)
cam.snapshot?.() is the distinct stored still (cloud/HomeBase path) and never pulls.
Power budget (battery / solar cameras)
A camera's power source is a runtime fact derived from its resolved capabilities, not its model. A battery (or solar — solar only trickle-charges) camera drains while streaming, so the SDK bounds a continuous stream to a budget; a wired/mains camera streams unbounded and never emits a budget notice.
The model sets the powered hint for you from dev.has("battery") — a host does not pass it. When the budget elapses on a battery camera the stream emits budget with an extend() handle:
stream.on("budget", (notice) => {
if (keepWatching) notice.extend(); // re-push another full budget, cancel the auto-stop
// else: do nothing → auto-stops after notice.graceMs to protect the battery
});Defaults: 45 s budget, 10 s grace. A host tunes only the timings (not the power decision):
await cam.live({ batteryBudgetMs: 8000, budgetGraceMs: 5000, keepAliveMs: 3000 });A wired camera ignores all of this and streams until you stop().
See examples/07-live-stream-battery-budget.ts for the full detect → budget → extend → auto-stop cycle. The budget bounds an active stream; when it (or you) stops the stream, the camera's P2P session idle-detaches so a battery device sleeps — see Connectivity & battery.
Reliability
- No silent hang.
live()re-issues the media-start (nudge) until the first frame arrives; if none arrives within the warm-up window the stream emits anerror(a start stall) rather than hanging forever. Handleerror. - Reconnect. On a session close the source stops and consumers get
stop/error; re-attach (cam.live()again) to rebuild the pull.
Choosing an egress
| Need | Use |
|---|---|
| Raw frames, custom pipeline | cam.live() → on("video"/"audio") |
| Pipe bytes to a file/socket/encoder | cam.openReadable() |
| Serve HLS / feed an MSE player | cam.recordFragments() |
| A single still | cam.snapshotLive() (live) or cam.snapshot() (stored) |
| Fixed-length clip buffer | cam.record(seconds) |