Develop

Horizon OS NSDK Virtual Camera Publisher overview

Updated: May 13, 2026
The Virtual Camera Publisher API lets your app register a custom camera that appears as a standard Android Camera2 device. Once created, applications can render content into it so Consumer apps (including recording, casting, and livestreaming) can discover and open your virtual camera without any special integration.
Use this API when your app produces a viewpoint that app users or content creators want to capture or share:
  • A drone or overhead camera that follows gameplay
  • A selfie or avatar camera that faces the player
  • A spectator camera for viewers in the same space
  • A fixed cinematic shot of the environment

    Get started

  • NSDK sample and integration guide: step-by-step integration, build setup, and a working sample with EGL/GLES rendering.
  • Unity-VirtualCameraPublisher on GitHub: Unity package with C# integration.

How it works

Your app is the producer: it registers cameras, declares renderable resolutions, and pushes frames on demand. Any app that uses the standard Android Camera2 API is a consumer, including Meta’s system recording, casting, and livestreaming. The consumer discovers your cameras automatically, with no special integration on their side.
Horizon OS calls a callback in your app when a consumer opens one of your cameras. Your app renders and delivers frames through an Android Surface. When the consumer closes the camera, Horizon OS calls a second callback and your app stops rendering to the Surface. Because rendering only happens while a consumer is active, there’s no GPU cost when no one is watching.

Requirements

RequirementDetails
Horizon OS version
v204 or later
Permission
horizonos.permission.CREATE_VIRTUAL_CAMERA declared in your AndroidManifest.xml. This is a normal-level permission. No runtime prompt is needed.
Header
#include <horizonos/virtualcamera/VirtualCamera.h>
Cameras per app
Up to 3 at the same time
Max frame rate
60 fps

Concepts

Input stream configurations

When you register a virtual camera you declare one or more input stream configurations. Each configuration describes a resolution, pixel format, and maximum frame rate that your producer can supply.
The system uses these to compute the set of output configurations advertised in CameraCharacteristics. When a consumer opens the camera and picks an output configuration, the system selects the best-matching input configuration and invokes your onStreamConfigured callback.
The supported pixel formats are AHARDWAREBUFFER_FORMAT_R8G8B8A8_UNORM (RGBA 8888) and AHARDWAREBUFFER_FORMAT_Y8Cb8Cr8_420 (YUV 420).

Multi-resolution rules

If you register inputs with different aspect ratios, the resolution with the smallest aspect ratio must have a width and height greater than or equal to every other input. This constraint allows the system to derive any requested output by center-cropping and downscaling a single input. Violating this rule causes registration to fail with HZ_VIRTUAL_CAMERA_STATUS_INVALID_ARGUMENT.

Lifecycle

A virtual camera moves through these stages:
  1. Create managerHzVirtualCameraManager_create
  2. ConfigureHzVirtualCameraConfiguration_init
  3. Register cameraHzVirtualCamera_create
  4. Wait for callbacks:
    • onStreamConfigured — A consumer opened the camera. Start rendering to the supplied ANativeWindow.
    • onProcessCaptureRequest(optional) — Generates a per-frame notification.
    • onStreamClosed — The consumer closed the camera. Stop rendering and release the window.
  5. Destroy cameraHzVirtualCamera_destroy (unregisters the camera).
  6. Destroy managerHzVirtualCameraManager_destroy (releases resources).

Surface ownership

When onStreamConfigured fires, ownership of the ANativeWindow* transfers to your app. You must:
  1. Render frames to the window whose dimensions and format match the values passed in the callback.
  2. Keep the window alive until onStreamClosed fires for the same streamId.
  3. Call ANativeWindow_release after you stop rendering.
Important: Buffer format must match. Buffers submitted to the ANativeWindow must match the width, height, and format reported by onStreamConfigured. A mismatch produces silently corrupted or black output, not a crash.
Gamma correction. If you’re rendering from a linear-space render target (common in game engines), apply gamma correction (pow(color, 1.0/2.2)) before writing to the surface. Without this, the output appears darker than expected.

Callbacks

Two callbacks are required:
  • onStreamConfigured: A consumer opened the camera. You receive the ANativeWindow*, dimensions, and format. Start rendering.
  • onStreamClosed: The consumer closed the camera. Stop rendering and call ANativeWindow_release.
An optional third callback, onProcessCaptureRequest, fires once every frame (useful for per-frame pacing or metadata).
Thread safety. All callbacks are invoked on system threads, not your app’s main or render thread. Protect shared state with a mutex or similar synchronization primitive. A complete callback implementation with thread-safe state management is available in the VirtualCamera sample on GitHub.

Initialization

You must initialize every struct with its _init macro before setting any fields. The macro zeros the struct and applies defaults (cameraSource = APP_DEFINED, lensFacing = EXTERNAL, inputTimeoutMs = 1000, and so on). Skipping initialization causes undefined behavior from garbage in unset fields.
The mandatory steps are:
  1. Create a manager with HzVirtualCameraManager_create.
  2. Initialize the configuration with HzVirtualCameraConfiguration_init.
  3. Declare at least one input stream configuration with HzVirtualCameraInputConfiguration_init (set width, height, format, maxFps).
  4. Set the required callbacks: onStreamConfigured and onStreamClosed.
  5. Register the camera with HzVirtualCamera_create.
A complete working example is available in the VirtualCamera sample on GitHub.

Optional features

Camera metadata

Set standard Camera2 metadata fields so that consumers see accurate lens information in CameraCharacteristics.
The intrinsic calibration K = [[fx, s, cx], [0, fy, cy], [0, 0, 1]] describes how 3D points project onto your camera’s image plane. For a virtual camera, derive the values from your render parameters:
  • cx, cy = principal point. Use the image center: (width/2, height/2).
  • fx, fy = focal length in pixels: fx = (width/2) / tan(hFOV/2), fy = (height/2) / tan(vFOV/2). Use fx == fy for square pixels (the common case).
  • s = axis skew. Almost always 0.
If you already have a perspective projection matrix P (OpenGL, Vulkan), pull fx = P[0][0] · width/2 and fy = P[1][1] · height/2 directly. The example below configures a 1920×1080 camera with a ~88° horizontal FOV.
// Lens facing: APP_DEFINED cameras must use EXTERNAL.
// FRONT and BACK are reserved for system cameras.
config.lensFacing = HZ_VIRTUAL_CAMERA_LENS_FACING_EXTERNAL;

// Sensor orientation in degrees (default: 0).
config.sensorOrientation = HZ_VIRTUAL_CAMERA_SENSOR_ORIENTATION_0;

// Intrinsic calibration for a 1920×1080 frame, ~88° hFOV, square pixels.
HzVirtualCameraLensIntrinsicCalibration calib = {
    .fx = 1000.0f, .fy = 1000.0f,    // focal length in pixels
    .cx = 960.0f,  .cy = 540.0f,     // principal point (image center)
    .s  = 0.0f                       // skew
};
config.intrinsicCalibration = &calib;

Lens pose

Provide the camera’s physical position and orientation. A pose reference other than UNDEFINEDmust be set when specifying a pose, otherwise registration fails.
HzPose pose = {
    .orientation = {0.0f, 0.0f, 0.0f, 1.0f},  // identity quaternion
    .position    = {0.065f, 0.0f, 0.0f}       // 65 mm right of reference
};
config.lensPose      = &pose;
config.poseReference = HZ_VIRTUAL_CAMERA_LENS_POSE_REFERENCE_GYROSCOPE;

Camera thumbnail

Expose a thumbnail image for your virtual camera by setting a Content URI. The consumer fetches the thumbnail through ContentResolver.
config.cameraThumbnailUri = "content://com.example.myapp/camera_thumbnail";
Your app must implement a ContentProvider that serves the image at this URI.

Timestamp source

By default, the acquisition timestamp is the real-time moment the frame is sampled (SAMPLING_TIME). To use the presentation timestamp attached to the buffer instead, set:
config.timestampSource =
    HZ_VIRTUAL_CAMERA_ACQUISITION_TIMESTAMP_SOURCE_SURFACE;
This is useful when your frames carry externally-sourced timestamps, such as frames from a remote feed.

Input timeout and strategies

Control how the system handles frame delivery delays:
config.inputTimeoutMs       = 2000;  // wait up to 2 seconds (default: 1000)
config.inputTimeoutStrategy =
    HZ_VIRTUAL_CAMERA_INPUT_TIMEOUT_STRATEGY_REPEAT_FRAME;
StrategyBehavior
REPEAT_FRAME_RESPECT_MIN_FPS (default)
Repeats the last frame only if waiting longer would violate the consumer’s minimum FPS constraint. Otherwise uses the configured timeout.
REPEAT_FRAME
Ignores the consumer’s FPS range. After the timeout, repeats the last frame.
FAIL_REQUEST
Ignores the consumer’s FPS range. After the timeout, fails the capture request.
Warning: Timeout risk. Setting a long timeout and using FAIL_REQUEST puts your app in full control of frame timing but risks unrecoverable timeouts in the camera framework if frames aren’t delivered promptly.

Per-frame capture request metadata

By default, the producer renders frames blindly: it sees only the stream resolution and format, not the consumer’s capture intent. Enable the per-frame metadata callback to receive the full Camera2 CaptureRequest on every frame, so the producer can react to consumer-requested settings like zoom crop region, exposure compensation, focal length, or AE/AF mode. This lets the virtual camera behave like a real, controllable camera: a spectator camera that honors the recording app’s pinch-to-zoom, an avatar camera that responds to a livestream operator’s exposure controls, or a drone camera that reframes when the viewer changes focal length.
Enable the callback during configuration:
config.perFrameCameraMetadataEnabled = true;
config.onProcessCaptureRequestWithMetadata = onCaptureRequestWithMetadata;
Then implement the callback to receive the metadata:
void onCaptureRequestWithMetadata(int32_t streamId,
                                  int32_t frameId,
                                  const uint8_t* metadata,
                                  size_t metadataSize,
                                  void* clientData) {
    if (metadata != NULL) {
        // Cast to camera_metadata* (libcamera_metadata format).
        // Valid only for the duration of this callback.
        const camera_metadata_t* camMeta =
            (const camera_metadata_t*)metadata;
        // Read consumer-requested settings (zoom, exposure, and so on).
    }
}
If both onProcessCaptureRequest and onProcessCaptureRequestWithMetadata are set, only the latter is invoked.

Consuming a virtual camera

Once the data is rendered into a Virtual Camera, it will appear as regular Camera2 device. A consumer app can then rely on the standard Android Camera2 API to access it the same as a physical device.

Discover available cameras

Use ACameraManager_getCameraIdList (NDK) or CameraManager.getCameraIdList() (Java) to enumerate all cameras, including virtual ones. Virtual cameras appear alongside physical cameras in the list.

Identify a virtual camera

Query the vendor tag com.meta.extra_metadata.camera_source on each camera’s CameraCharacteristics to determine whether it’s a virtual camera and what type of source it provides:
ValueSource type
0
Passthrough
1
Avatar
2
App-defined
3
Spatial
Additional vendor tags expose optional metadata set by the producer:
  • com.meta.extra_metadata.camera_name: Human-readable name
  • com.meta.extra_metadata.camera_thumbnail: Content URI for a thumbnail image (fetch with ContentResolver)
  • com.meta.extra_metadata.position: Left or right position on the HMD front

Open and capture

Open the camera with ACameraDevice_createCaptureSession (NDK) or CameraDevice.createCaptureSession (Java) and submit capture requests as you would for any physical camera. The virtual camera service handles routing frames from the producer’s ANativeWindow to your output surfaces.

Cleanup

When your app exits or no longer needs the camera, destroy resources in reverse creation order:
HzVirtualCamera_destroy(camera);         // unregisters the camera
HzVirtualCameraManager_destroy(manager); // releases the manager

Error handling

Status codeMeaningWhat to do
HZ_VIRTUAL_CAMERA_STATUS_OK
Success.
No action required.
HZ_VIRTUAL_CAMERA_STATUS_INVALID_ARGUMENT
A required parameter is null, a required callback is missing, or input configurations violate the multi-resolution rules.
Fix the configuration and retry.
HZ_VIRTUAL_CAMERA_STATUS_SERVICE_NOT_FOUND
The virtual camera service isn’t running.
Retry after a delay, or inform the user that virtual cameras aren’t supported on this device or build.
HZ_VIRTUAL_CAMERA_STATUS_PERMISSION_DENIED
The app doesn’t hold the required permission.
Check that horizonos.permission.CREATE_VIRTUAL_CAMERA is declared in your manifest.
HZ_VIRTUAL_CAMERA_STATUS_INTERNAL_ERROR
An unexpected error occurred, such as a duplicate camera ID.
Log the error. If you set a custom id, try registering without one to let the system assign an ID.

Limitations

  • Your app can register up to 3 cameras at the same time.
  • The maximum supported frame rate is 60 fps.
  • Testing over Meta Quest Link isn’t supported.