---
title: Capture video
description: Record a bounded video clip from the Meta Ray-Ban camera with glasses.camera.captureVideo(), stop it cleanly with stopVideo(), or subscribe to the continuous videoFrames stream for on-device ML and live preview.
type: guide
platform: all
vendor: meta
related:
  - /docs/guides/photo-capture
  - /docs/guides/capture-button
  - /docs/guides/audio-streaming
  - /docs/guides/voice-triggers
  - /docs/reference/errors
---

<Callout type="warn">
  **Camera needs the Meta vendor module.** `com.extentos:glasses` carries no
  vendor SDK, so add `implementation("com.extentos:glasses-meta")` alongside it —
  see [install](/docs/sdk/android/install). Without it your build still succeeds
  and voice still works, but `capabilities.camera` is `false` and every capture
  returns an error. The SDK logs a warning at startup when it spots that
  combination.
</Callout>


Two camera surfaces give you motion: `glasses.camera.captureVideo()` records a phone-side-encoded clip and returns a `VideoClip`, and `glasses.camera.videoFrames(...)` is a continuous `Flow` of camera frames for on-device ML or live preview. This page covers both, the clean stop pattern, and the cross-platform save gotcha.

> Kotlin leads the snippets here and is the source of truth. The iOS `VideoConfig` matches it (`includeAudio` defaults `true`, `frameRate` defaults `24`); the gap is the bundled helpers — there are no `Videos.*` helpers on iOS. See the inline notes.

## Record a clip

```kotlin
import com.extentos.glasses.core.VideoConfig
import com.extentos.glasses.core.VideoFormat
import com.extentos.glasses.core.Videos
import com.extentos.glasses.core.valueOrNull
import java.io.File

suspend fun record(glasses: ExtentosGlasses, context: Context) {
    val clip = glasses.camera.captureVideo(
        VideoConfig(
            maxDurationSeconds = 60,        // Int? — null (the default) means no time cap
            includeAudio = true,            // forces the HFP route while capturing (see below)
            format = VideoFormat.MP4_HEVC,  // MP4_HEVC | MP4_H264 | MOV — prefer HEVC
        ),
    ).valueOrNull() ?: return  // ExtentosResult.Err — handle the same CaptureError variants as photo

    val uri = clip.uri ?: return          // VideoClip.uri is String? (nullable, transport-shaped)
    val dst = File(context.filesDir, "clip-${System.currentTimeMillis()}.mp4")
    if (!Videos.copyToFile(uri, dst)) return  // save to your library dir
}
```

The Kotlin `VideoConfig` has five fields: `maxDurationSeconds: Int?` (null = no cap), `includeAudio: Boolean = true`, `format: VideoFormat = MP4_HEVC`, `resolution: Resolution = MEDIUM`, and `frameRate: Int = 24`. The `resolution` / `frameRate` pair takes effect only when the recording is the **first** camera use of the session — video and `videoFrames` share one warm camera stream that can't be reconfigured live. There is **no `stopConditions` field** — older spec metadata advertised one, but it was never wired; the stop primitive is `stopVideo()` (below).

`captureVideo()` returns `ExtentosResult<VideoClip, CaptureError>` — the failure variants are the same as [photo capture](/docs/guides/photo-capture#handling-failures) (`DisabledByUser` carries the gating toggle, `CoexistenceBlocked` on a camera conflict, etc. — see the [error reference](/docs/reference/errors#capture-errors)). Like `Photo.uri`, `VideoClip.uri` is a nullable `String?` whose scheme is `data:` in the simulator and `file:` on hardware; the `Videos` helpers (`copyToFile`, `loadBytes`, `loadBase64`, `mediaTypeFromUri`) bridge both — and like `Photos`, they never throw. **`Videos` is Android-only today** (iOS parity queued); on iOS read `clip.uri` (a `String?`) via `URL(string:)` + `Data(contentsOf:)` for `file://`, or parse the `data:` scheme by hand.

### Codec: treat `format` as a label, not a guarantee

On Android today the clip is written by the phone-side encoder as H.264 in an MP4 container — on real glasses and in the simulator alike — regardless of the `VideoFormat` you request; the `VideoClip.format` field there is a container hint, not a codec promise. On iOS the writer honors the requested format (`MP4_H264` → H.264, otherwise HEVC). Dev/prod parity is on the clip *shape*.

### `includeAudio` forces HFP coexistence

When `includeAudio = true`, audio captures over the glasses-as-mic Bluetooth HFP profile, which suspends A2DP playback for the duration — any music drops from hi-fi A2DP to voice-band HFP mono until the clip finishes. If your app plays music while recording, set `includeAudio = false` — it defaults to `true` on both platforms, so opt out explicitly. See [audio streaming](/docs/guides/audio-streaming#a2dp-vs-hfp-coexistence) for the coexistence model.

## Stop a recording cleanly

A clip with `maxDurationSeconds = null` runs until you stop it. The clean primitive is `glasses.camera.stopVideo()` — the in-flight `captureVideo()` then **resumes with `Ok` holding the partial clip** recorded up to that point. No coroutine cancellation, no exception, no separate result channel:

```kotlin
val capture = scope.async { glasses.camera.captureVideo(VideoConfig(includeAudio = true)) }
// ...later, on the user's "stop" intent:
glasses.camera.stopVideo()
val clip = capture.await().valueOrNull()   // natural await — returns the partial clip
```

`stopVideo()` is idempotent (a no-op when nothing is recording).

> **Don't** cancel the wrapping `async` Deferred to stop a recording. Once a Deferred is cancelled its state is sticky-Cancelled and `await()` throws even though the body returned a value — you'd lose the clip. Use `stopVideo()`.

### The voice-stop pattern and `NonCancellable`

A common shape is a wake phrase that starts recording and a `stops` phrase that ends it. When you stop via the `stops`-cancellation path (rather than `stopVideo()`), the library still recovers the partial: it sends an abort to the transport and drains the partial `video_result` for **up to 10 s** under `NonCancellable`, so `captureVideo()` returns `Ok(VideoClip)` instead of throwing. But the handler coroutine has *already been cancellation-requested* by the time the clip comes back — so your save I/O must run under `withContext(NonCancellable) { ... }` or it throws `CancellationException` and silently drops the clip:

```kotlin
glasses.voice.onPhrase(
    phrase = "start recording",
    stops = listOf("stop recording"),
) {
    val clip = glasses.camera.captureVideo(
        VideoConfig(maxDurationSeconds = 60, includeAudio = true),
    ).valueOrNull() ?: return@onPhrase
    val uri = clip.uri ?: return@onPhrase

    // Post-stop I/O MUST survive the surrounding handler cancellation:
    withContext(NonCancellable) {
        Videos.copyToFile(uri, dst)
        repo.insertRecording(/* ... */)
    }
}
```

On iOS the equivalent is running the post-stop save in a `Task.detached`. The drain ceiling is 10 s — real-hardware `MediaRecorder.stop` → encode can take 3–5 s on a 20-second clip. If the encode exceeds 10 s (very large clips / slow devices) the original `CancellationException` rethrows and the partial is lost; bound your clip lengths, or use a `videoFrames` + `MediaMuxer` custom encoder for clips over ~30 s.

## Continuous frame stream

For anything that needs more than one frame per second — on-device object detection / scene classification, a live preview — subscribe to `videoFrames`:

```kotlin
import com.extentos.glasses.core.Resolution
import com.extentos.glasses.core.VideoFrameConfig

glasses.camera.videoFrames(
    VideoFrameConfig(
        resolution = Resolution.LOW,  // LOW is plenty for most ML
        frameRate = 2,                // 2 fps for ML; 7–15 for preview
    ),
).collect { frame ->
    // frame.data is encoded image bytes (the simulator sends JPEG —
    // BitmapFactory.decodeByteArray before use). Also frame.width / height / timestampMs.
}
```

The Kotlin `VideoFrameConfig` is intentionally minimal — `resolution` and `frameRate` only. (iOS additionally exposes `codec` and `backpressure`; Kotlin handles backpressure internally.) Frame rate drives battery hard: 2 fps for ML drains negligibly, 7–15 fps for preview lasts ~1 hr, 24–30 fps drains in ~30 min. Pair any high-fps stream with a thermal listener that downgrades it (see [hardware events](/docs/guides/sensor-streams)). `battery_save_mode = true` clamps the stream to `LOW` + 2 fps regardless of config and emits a `camera_stream_clamped` event so the downgrade is observable. `videoFrames` (and `captureVideo`) require `FOREGROUND_SERVICE` on Android 14+ — see `getPermissions`.

### If the camera is paused when you start

The wearer can pause the camera with the glasses' capture button — a single temple tap (tap again to resume; tap-and-hold stops). If you **start** `videoFrames` while it's paused, the flow throws `CameraStreamPausedException` at collection — the streaming analogue of the `CaptureError.StreamPaused` that `capturePhoto`/`captureVideo` return. Catch it and prompt the wearer to tap the temple, then start again:

```kotlin
import com.extentos.glasses.core.CameraStreamPausedException

try {
    glasses.camera.videoFrames(config).collect { render(it) }
} catch (e: CameraStreamPausedException) {
    glasses.audio.speak("The camera is paused — tap the right temple to resume.")
}
```

A pause that happens **mid-stream** is not an error — the frames simply stop (your last-rendered frame freezes on screen) and resume when the wearer taps. Meta's DAT has no app-callable resume, so never try to force it with a teardown-and-reopen; that drops the whole connection.

### A pause during a recording skips footage

A `captureVideo` recording is *made of* the stream, so a mid-recording temple tap doesn't end it — the recording stays alive but **captures no footage while paused**. End it while paused and the clip stops at the pause point; let the wearer resume and keep recording, and the final clip contains everything *except* the paused window, spliced seamlessly. The clip's `duration_ms` reflects footage, not wall clock. A tap-and-hold (stop) while recording ends it with whatever footage was captured so far. The simulator reproduces all of this — drive the capture button from the sim page's Hardware-buttons panel or `injectHardwareButton` and watch the REC pill flip to "paused — no footage".

## Verify it in the simulator

`captureVideo()` emits a `capture_video_returned` event when it resolves — `getEventLog(filter: "all")` shows (the `*_returned` row classifies under the `custom` chip) `success`, `duration_ms`, `clip_duration_ms`, and `via`. The `via` value tells you which path produced the clip: `cancelled_with_partial` means a stop fired and the drain recovered a partial; `cancelled` means the drain produced nothing. The simulator is designed to behave like the glasses, but codec and HFP timing are substrate deltas under active validation — confirm long-clip and music-coexistence behavior on real hardware.

## Related

- [Capture a photo](/docs/guides/photo-capture) — one-shot stills, the `CaptureError` variants, and the URI-helper model the snippets above reuse.
- [Audio streaming](/docs/guides/audio-streaming) — the A2DP/HFP coexistence that `includeAudio` triggers.
- [Voice triggers](/docs/guides/voice-triggers) — the `onPhrase` + `stops` shape used for voice-controlled recording.
- [Error reference](/docs/reference/errors#capture-errors) — every `CaptureError` variant.
- `getCapabilityGuide(feature: "capture_video")` / `getCapabilityGuide(feature: "video_frames")` — minimal call shapes + full gotchas.
