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





This guide shows you how to **stream a live camera feed peer-to-peer** by adapting the [`hello-pear-electron`](https://github.com/holepunchto/hello-pear-electron) scaffold to push camera frames through [Hyperblobs](https://www.npmjs.com/package/hyperblobs) and serve them via [`hypercore-blob-server`](https://www.npmjs.com/package/hypercore-blob-server). The reference implementation is [`pear-live-cam`](https://github.com/holepunchto/pear-docs/tree/preview/examples/how-to/stream-and-share-media/live-cam).

<Callout type="info">
  **Start from the desktop boilerplate.** This guide has a frontend, so it builds on [`hello-pear-electron`](/p2p/getting-started/from-a-template/start-from-hello-pear-electron)—the renderer, preload bridge, and Bare [worker](/pear/explanation/workers) it extends. Read [Start from the hello-pear-electron template](/p2p/getting-started/from-a-template/start-from-hello-pear-electron) first. The Pear-end logic itself is portable—only the UI is desktop-specific (see [Runtime and languages](/pear/explanation/runtime-and-languages)); the terminal template [`hello-pear-bare`](/p2p/getting-started/from-a-template/start-from-hello-pear-bare) has no UI layer. See all starting points in [Start from a template](/p2p/getting-started/from-a-template).
</Callout>

## Before you begin [#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 [#what-changes]

| Layer        | Change                                                                                                                                                                                                                            |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dependencies | Add [`hyperblobs`](https://www.npmjs.com/package/hyperblobs), [`hypercore-blob-server`](https://www.npmjs.com/package/hypercore-blob-server), and [`hypercore-id-encoding`](https://www.npmjs.com/package/hypercore-id-encoding). |
| Roles        | Distinguish a **creator** (camera owner) from **viewers** at startup via a CLI flag and a small `role` message over the worker pipe.                                                                                              |
| Worker       | Add a Hyperblobs core in the worker; each camera fragment becomes a blob, and `hypercore-blob-server` serves a per-fragment HTTP link.                                                                                            |
| Renderer     | Creator: 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 [#steps]

<Steps>
  <Step>
    ### Add the dependencies [#add-the-dependencies]

    ```bash skip="desktop-gui"
    npm install hyperblobs hypercore-blob-server hypercore-id-encoding
    ```
  </Step>

  <Step>
    ### Differentiate creator vs. viewer at startup [#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:

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/live-cam/workers/index.js#L30-L33 title="workers/index.js" lineNumbers {3-4} skip="example-import"
      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).
  </Step>

  <Step>
    ### Build the room around Hyperblobs [#build-the-room-around-hyperblobs]

    `workers/live-cam-room.js` (`LiveCamRoom`) uses [Autobase](/p2p/reference/building-blocks/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:

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/live-cam/workers/live-cam-room.js#L178-L208 title="workers/live-cam-room.js" lineNumbers {4-9,11-14,18-21,26-28} skip="example-import"
      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`](https://www.npmjs.com/package/blind-pairing)—viewers join the creator's swarm via an invite code exactly as in the getting-started chat path.
  </Step>

  <Step>
    ### Wire MediaRecorder in the renderer [#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:

    ```js file=<rootDir>/examples/how-to/stream-and-share-media/live-cam/renderer/app.js#L123-L147 title="renderer/app.js — creator" lineNumbers {1-4,10,18-19,25} skip="example-import"
        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.
  </Step>
</Steps>

## Run it [#run-it]

```bash skip="desktop-gui"
npm run build

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

Copy the invite from stdout. In a second terminal:

```bash skip="desktop-gui"
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 [#where-to-go-next]

* [Store and serve large media with Hyperblobs](/p2p/how-to/stream-and-share-media/store-and-serve-large-media-with-hyperblobs)—the Pear/Bare blob primitive behind this app, with no UI.
* [Stream stored video in a peer-to-peer app](/p2p/how-to/stream-and-share-media/stream-stored-video-in-a-peer-to-peer-app)—same blob mechanics for finished video files.
* [Back up photos in a peer-to-peer app](/p2p/how-to/stream-and-share-media/back-up-photos-in-a-peer-to-peer-app)—the same scaffold with `bare-ffmpeg`/`bare-media` for media decoding.
* [Hypercore](/p2p/reference/building-blocks/hypercore)—the append-only log that backs Hyperblobs.
