Guides

Capture video

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.

Camera needs the Meta vendor module. com.extentos:glasses carries no vendor SDK, so add implementation("com.extentos:glasses-meta") alongside it — see 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.

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

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 (DisabledByUser carries the gating toggle, CoexistenceBlocked on a camera conflict, etc. — see the error reference). 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 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:

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:

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:

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). 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:

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.

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