LogoPear Docs
How ToStream and share media

Stream a live camera in a peer-to-peer app

Chunk live camera frames into Hyperblobs and serve them with hypercore-blob-server on top of the hello-pear-electron scaffold.

This guide shows you how to stream a live camera feed peer-to-peer by adapting the hello-pear-electron scaffold to push camera frames through Hyperblobs and serve them via hypercore-blob-server. The reference implementation is pear-live-cam.

This guide is about the Pear-end, not the shell. The code below lives in the Bare worker—the peer-to-peer logic, not the user interface. Because the Pear-end never imports DOM APIs and never assumes a UI framework, the same worker is portable across desktop (Electron), mobile (React Native via Bare iOS / Bare Android), and terminal. The example apps ship an Electron shell, but only the UI half changes per platform—the logic here stays the same. See Runtime and languages for the cross-platform model and current support.

This is a delta-only how-to. The shared scaffold is explained in the Start from the hello-pear-electron template tutorial—read it first.

Before you begin

  • A working clone of hello-pear-electron (or your own app built from the getting-started path).
  • A camera the host machine can access. The renderer uses the browser getUserMedia API.

What changes

LayerChange
DependenciesAdd hyperblobs, hypercore-blob-server, and hypercore-id-encoding.
RolesDistinguish a creator (camera owner) from viewers at startup via a CLI flag and a small role message over the worker pipe.
WorkerAdd a Hyperblobs core in the worker; each camera fragment becomes a blob, and hypercore-blob-server serves a per-fragment HTTP link.
RendererCreator: capture MediaStream → MediaRecorder → tagged binary frames over the worker pipe. Viewer: feed each fragment's info.link into a MediaSource.

The Electron shell, preload bridge, packaging, and graceful teardown stay as in hello-pear-electron.

Steps

Add the dependencies

npm install hyperblobs hypercore-blob-server hypercore-id-encoding

Differentiate creator vs. viewer at startup

In workers/index.js the example fetches the room's pairing invite (L32) and derives a role: anyone who passed --invite is a viewer, otherwise they own the camera as the creator (L33). The worker then surfaces that role so the renderer can branch on it:

workers/index.js
  pipe.resume()

  const invite = await workerTask.room.getInvite()
  const role = cmd.flags.invite ? 'viewer' : 'creator'

The renderer listens for the role message and either opens the camera (creator) or plays the replicated fragments (viewer).

Build the room around Hyperblobs

workers/live-cam-room.js (LiveCamRoom) uses Autobase + pairing plumbing, and adds a Hyperblobs core plus a hypercore-blob-server (both constructed in the constructor and opened in _open). addFragment (L194–L208) writes each camera fragment as one blob (L195–L198), then appends a view entry tagged with a session id and an incrementing fragIdx (L203–L205)—that ordering is what lets viewers reassemble a continuous stream. On the read side, getVideos (L178–L192) lazily opens and joins the swarm for each fragment's blob core (L181–L186), attaches a blob-server link to every fragment (L188–L190), and returns them sorted by session then fragIdx (L191) so the renderer can fetch and replay them in order:

workers/live-cam-room.js
  async getVideos ({ limit = 100 } = {}) {
    const videos = await this.view.find('@pear-live-cam/videos', { limit }).toArray()
    for (const item of videos) {
      if (!this.blobsCores[item.blob.key]) {
        const blobsCore = this.store.get({ key: idEnc.decode(item.blob.key) })
        this.blobsCores[item.blob.key] = blobsCore
        await blobsCore.ready()
        this.swarm.join(blobsCore.discoveryKey)
      }
    }
    return videos.map(item => {
      const link = this.blobServer.getLink(item.blob.key, { blob: item.blob })
      return { ...item, info: { ...item.info, link } }
    }).sort((a, b) => (a.info.session - b.info.session) || (a.info.fragIdx - b.info.fragIdx))
  }

  async addFragment (frag) {
    const ws = this.blobs.createWriteStream()
    ws.write(frag)
    ws.end()
    await new Promise((resolve) => ws.on('close', resolve))

    const blob = { key: idEnc.normalize(this.blobs.core.key), ...ws.id }

    const id = Math.random().toString(16).slice(2)
    await this.base.append(
      LiveCamDispatch.encode('@pear-live-cam/add-video', { id, blob, info: { session: this.session, fragIdx: this.fragIdx } })
    )
    if (this.fragIdx % 30 === 0) console.log('[live-cam] fragments uploaded: ' + (this.fragIdx + 1))
    this.fragIdx += 1
  }

Pairing still uses blind-pairing—viewers join the creator's swarm via an invite code exactly as in the getting-started chat path.

Wire MediaRecorder in the renderer

In the creator path (renderer/app.js), guard on the pinned mime type (L123–L125)—Chromium reliably encodes WebM/VP8 only (video/webm; codecs="vp8")—then request the camera (L126) and start a MediaRecorder on the stream (L132), emitting a chunk every TIMESLICE_MS (L147). Video frames are large, so each dataavailable chunk is converted to raw bytes and sent over the worker pipe (L140–L141) rather than as JSON—only small control/event messages are JSON-encoded:

renderer/app.js — creator
    if (!window.MediaRecorder || !window.MediaRecorder.isTypeSupported(RECORDER_MIME)) {
      throw new Error('MediaRecorder does not support ' + RECORDER_MIME)
    }
    stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false })
    // Hidden preview keeps Chromium pumping frames into the stream.
    previewEl.srcObject = stream
    previewEl.play().catch(() => {})
    recordingEl.classList.remove('hidden')

    const recorder = new window.MediaRecorder(stream, { mimeType: RECORDER_MIME })
    let firstUpload = false
    recorder.ondataavailable = async (e) => {
      if (!e.data || e.data.size === 0) return
      if (!firstUpload) {
        console.log('[live-cam] first chunk encoded, ' + e.data.size + ' bytes')
        firstUpload = true
      }
      const buf = new Uint8Array(await e.data.arrayBuffer())
      sendFragment(buf)
    }
    recorder.onerror = (e) => {
      console.error('[live-cam] recorder error:', e?.error?.message || e)
      setCaptureError(e?.error?.message || 'recorder error')
    }
    recorder.start(TIMESLICE_MS)

In the viewer path, each replicated fragment carries an info.link from the blob server. The player appends them to a MediaSource strictly in session + fragIdx order—WebM/VP8 is one continuous stream, so a gap or out-of-order append puts the <video> element into a permanent error state.

Run it

npm run build

# creator
npm start -- --storage /tmp/cam-creator --name camera-host

Copy the invite from stdout. In a second terminal:

npm start -- --storage /tmp/cam-viewer --name viewer --invite <invite>

The creator's camera preview shows in their window; the viewer's window plays the live feed off the replicated Hyperblob.

Where to go next

On this page