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 derives a role based on whether --invite was passed; the worker then sends that role to the renderer as a JSON role message over the pipe:

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). Each camera fragment is written as one blob, then recorded in the view tagged with a session id and an incrementing fragIdx — that ordering is what lets viewers reassemble a continuous stream. getVideos attaches a blob-server link to every fragment so the renderer can fetch it:

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), request the camera and feed each dataavailable chunk into the worker. Video frames are large, so the renderer sends them as raw bytes over the worker pipe (tagged with a one-byte frame kind) rather than as JSON — only small control/event messages are JSON-encoded. Chromium reliably encodes WebM/VP8 only (video/webm; codecs="vp8"), so the example pins that mime type:

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