fused-render-capture · git:20260827.2859d24 · 2026-08-27 · sha256 f78066439b2b3ce2

fused-render-capture git:20260827.2859d24A

Immutable. This exact content never changes and is served at /api/v1/blob/f78066439b2b3ce2.

---
name: fused-render-capture
description: Record the screen, record the microphone, or grab a screenshot natively from a fused-render page — fused.capture.screen/audio/screenshot/sources. Use when a page records or captures anything, or a capture option is refused on this platform.
---

# Native capture (`fused.capture`)

Three verbs: `screen()`, `audio()`, `screenshot()`. All three write a file this
machine owns, which is the whole reason to use them over `getDisplayMedia` /
`MediaRecorder`: the path is known before the recording ends, so it feeds
`fused.ai.transcribe({path})` directly, and the recording survives your page
being navigated away from (it is a download-manager job row).

Local only — a hosted/exported page has no `fused.capture`.

## Ask first, and it never prompts

What is possible differs per machine and per browser, so draw your UI off the
answer rather than off a try/catch:

```js
const src = await fused.capture.sources();
if (!src.video.available) { hideRecordButton(src.video.reason); return; }
// src.displays -> [{id, width, height, main}], src.microphones -> [{id, name, default}]
// `displays` is empty on Linux and on Wayland by design — nothing to name there.
```

## Three platforms, one API, and deliberately no field telling you which you got

Read the refusals and the `reason`s instead of sniffing the platform.

| | macOS | Windows | Linux |
|---|---|---|---|
| screen recording | native, no picker | browser share picker | browser share picker |
| survives a page reload | yes | no (file is kept) | no (file is kept) |
| `display` / `rect` / `cursor` on `screen()` | honoured | refused | refused |
| `display` / `rect` / `cursor` on `screenshot()` | honoured | honoured | `rect` only |
| `device` on `audio()` | refused | honoured | honoured |
| container | `.mov` / `.m4a` | `.mp4` or `.webm` | `.mp4` or `.webm` |

So: do not hardcode an extension when you pass `path` — let the default name the
file, or read `rec.path`. And if the recording matters, keep the page that
started it open on Windows and Linux; a reload ends it (the file is kept and the
row says so, but it is shorter than the user expected).

## Recording is a handle, not a promise that resolves at the end

```js
const rec = await fused.capture.screen({
  audio: "both",              // false | "mic" | "system" | "both" — "system" is
                              // the one a browser cannot do on macOS at all
  rect: [0, 0, 1200, 800],    // macOS only — refused where the browser's own
                              // share picker chooses the region
  path: "walkthrough.mov",    // optional; relative = beside THIS page. Omit it
                              // unless you know the container (see the table)
  maxSeconds: 600,            // default 30 min; hitting it STOPS (keeps the file)
});
// elapsed seconds come off the recording's job row — there is no onTick:
fused.watchJob(rec.jobId).watch((row) => (label.textContent = row.done + "s"));
// rec.path / rec.url are already usable here — the file is being written to them
const out = await rec.stop();          // {path, url, mime, seconds, bytes}
const words = await fused.ai.transcribe({ path: out.path, words: true });
```

`screen()` and `audio()` resolve **when the recording is running**, with a handle
`{id, jobId, path, url, state, stop(), cancel()}`. `stop()` resolves with the
finished file; `cancel()` deletes it.

- `rec.cancel()` stops **and deletes**. So does the ✕ on its download-manager
  row — that is the one control left once your page is gone, so treat a cancel
  as "the user does not want this recording", not as "stop".
- `fused.capture.audio()` records the microphone alone. On macOS it uses the
  system's current input and **refuses** a `device` (the error names the
  alternative: a screen recording's `audio: "mic"` takes one); on Windows and
  Linux a `device` from `sources().microphones` works. Those names are empty
  until the browser's microphone permission has been granted once.
- `fused.capture.list()` finds live recordings — including one your page started
  before a reload — and `attach(id)` gives the handle back. Check it on load
  instead of starting a second recording.
- `screenshot({rect, cursor, path})` has no handle and no job row. The output
  **filename** picks png or jpeg — there is no `format`, so a path and a format
  cannot disagree about what was written. Native on **every** platform, so it
  needs no readable document and raises no share prompt — which is what makes a
  cross-origin pane shootable.
- Rejections carry `.type`: `"unavailable"` (this machine cannot — show
  `.message`, it names the reason), `"bad_request"` (your arguments).

## Two rules that catch most capture bugs

- **A preview must not record.** A page rendered as a card thumbnail or listing
  peek is stamped `_preview=1`; starting a capture there records the machine of
  someone who was merely browsing. Gate boot on the flag — see "Reserved params
  and preview mode" in **`fused-render-authoring`**.
- **The recording outlives the page, so the job row is the real UI.** Progress
  and cancellation live on the download-manager row, not in your DOM. For how
  those rows work, and for reporting your own long work into them, see
  **`fused-render-jobs`**.

Related: **`fused-render-authoring`** (the rest of `window.fused`),
**`fused-render-ai`** (`fused.ai.transcribe`, the usual next call after a
recording).