Skip to content

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:

ts
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.

ts
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 consumer

stream.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.

ts
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).

ts
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

ts
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:

ts
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):

ts
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 an error (a start stall) rather than hanging forever. Handle error.
  • 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

NeedUse
Raw frames, custom pipelinecam.live()on("video"/"audio")
Pipe bytes to a file/socket/encodercam.openReadable()
Serve HLS / feed an MSE playercam.recordFragments()
A single stillcam.snapshotLive() (live) or cam.snapshot() (stored)
Fixed-length clip buffercam.record(seconds)

Independent and unofficial. Not affiliated with, endorsed by, or sponsored by Anker Innovations or eufy. "eufy" and "Anker" are trademarks of their respective owners. Use responsibly — rapid or failed logins can trigger captcha or temporary cooldowns.