Skip to main content
The Realtime API transforms live camera video with minimal latency. Signaling is a Decart-owned WebSocket; media flows through a LiveKit room that the SDK joins for you. Perfect for camera effects, video restyling apps, AR experiences, and interactive live streaming.

Quick Start

import ai.decart.sdk.DecartClient
import ai.decart.sdk.DecartClientConfig
import ai.decart.sdk.RealtimeModels
import ai.decart.sdk.realtime.ConnectOptions
import ai.decart.sdk.realtime.FacingMode
import ai.decart.sdk.realtime.InitialPrompt
import ai.decart.sdk.realtime.MirrorMode

val client = DecartClient(context, DecartClientConfig(apiKey = "your-api-key"))
val realtime = client.realtime

// Connect and let the SDK open the camera. The remote (transformed) stream
// is delivered via onRemoteStream.
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_RESTYLE_2,
        initialPrompt = InitialPrompt("a cyberpunk cityscape"),
        facing = FacingMode.FRONT,
        mirror = MirrorMode.AUTO,
        publishCamera = true,
        onRemoteStream = { stream ->
            // Render stream.videoTrack with a LiveKit renderer (see below).
        },
    ),
)

// Change the style mid-session — suspends until the server acks.
realtime.setPrompt("a sunny beach scene", enhance = true)

realtime.disconnect()
client.release()

Client-Side Authentication

For production Android apps, use ephemeral keys instead of embedding your permanent API key in the APK. Ephemeral keys are short-lived tokens safe to include in client applications.
Learn more about client tokens and why they’re important for security.

Fetching an Ephemeral Key

Your app should fetch an ephemeral key from your backend server before connecting:
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject

suspend fun fetchEphemeralKey(): String = withContext(Dispatchers.IO) {
    val client = OkHttpClient()
    val request = Request.Builder()
        .url("https://your-backend.com/api/realtime-token")
        .post("".toRequestBody())
        // Add any auth headers your backend requires
        // .addHeader("Authorization", "Bearer $userToken")
        .build()

    val response = client.newCall(request).execute()
    if (!response.isSuccessful) {
        throw Exception("Failed to fetch token: ${response.code}")
    }

    val body = response.body?.string()
        ?: throw Exception("Empty response body")
    JSONObject(body).getString("apiKey")
}

Connecting with an Ephemeral Key

val ephemeralKey = fetchEphemeralKey()

val client = DecartClient(
    context = applicationContext,
    config = DecartClientConfig(apiKey = ephemeralKey)
)

// Connect as usual
client.realtime.connect(options)
Never hardcode your permanent API key in Android apps. APKs can be decompiled, exposing embedded secrets. Always use ephemeral keys from your backend.

Connecting

Camera ownership

You have two options for the local stream:
  • SDK-owned camera (simplest). Pass publishCamera = true to ConnectOptions and the SDK opens the camera, joins the LiveKit room, and publishes for you.
  • Caller-owned preview. Call realtime.createLocalVideoStream(model, facing, mirror) ahead of time to drive a preview UI before connecting, then pass the resulting RealtimeMediaStream to connect(...). Preview and publish share the same LiveKit Room. Dispose the stream when you’re done.
val model = RealtimeModels.LUCY_2_1

// Caller-owned preview — sized from the model's capture resolution.
val localStream = realtime.createLocalVideoStream(
    model = model,
    facing = FacingMode.FRONT,
    mirror = MirrorMode.AUTO,
)

// Later, when the user hits Connect:
realtime.connect(
    options = ConnectOptions(
        model = model,
        publishCamera = true,
        onRemoteStream = { stream -> /* render stream.videoTrack */ },
    ),
    localStream = localStream,
)
There’s also a static RealTimeClient.createLocalVideoStream(context, model, ...) for building a preview before you’ve decided which API key to use.
Camera capture requires a real Android device. The emulator does not support camera-driven realtime sessions.

Rendering with LiveKit

Realtime media tracks are LiveKit tracks. Use a LiveKit renderer such as TextureViewRenderer or SurfaceViewRenderer and initialize it with the Room’s EglBase context:
import io.livekit.android.renderer.SurfaceViewRenderer
import io.livekit.android.room.track.VideoTrack

// In your composable or view, given a RealtimeMediaStream `stream`:
val room = stream.room ?: return
val renderer = SurfaceViewRenderer(context)
renderer.init(room.lkObjects.eglBase.eglBaseContext, null)
stream.videoTrack?.addRenderer(renderer)
You can react to remote-stream changes (initial frame, auto-reconnect rebinds) via realtime.remoteStreamUpdates:
lifecycleScope.launch {
    realtime.remoteStreamUpdates.collect { stream ->
        previousRenderer?.let { stream.videoTrack?.removeRenderer(it) }
        stream.videoTrack?.addRenderer(remoteRenderer)
    }
}

Front-camera mirroring

mirror pre-flips the captured frames before they’re published, so server-baked pixels (watermarks, overlays) remain readable. Render both local previews and remote streams as-is — do not also set renderer-level mirroring or you’ll double-flip.
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        facing = FacingMode.FRONT,
        mirror = MirrorMode.AUTO, // default: front mirrored, back as-is
    )
)
MirrorMode values:
  • OFF — never mirror.
  • ON — always mirror.
  • AUTO (default) — mirror only when facing == FacingMode.FRONT.
The same mirror argument is available on createLocalVideoStream(...).

Output resolution

Opt into 1080p output from supported models; otherwise the server defaults to 720p:
import ai.decart.sdk.realtime.Resolution

realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        resolution = Resolution.P1080,
        onRemoteStream = { /* ... */ },
    )
)

Reference images

Send a reference image at connect time via initialImage (base64-encoded):
val characterBase64 = Base64.encodeToString(characterBytes, Base64.NO_WRAP)

realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        initialImage = characterBase64,
        initialPrompt = InitialPrompt(
            text = "Substitute the character in the video with the person in the reference image.",
            enhance = true,
        ),
        onRemoteStream = { /* ... */ },
    )
)
Set initialImage and/or initialPrompt so the first frame is already transformed — otherwise viewers briefly see the raw camera feed.

Tuning the LiveKit publisher

Override codec, bitrate, or framerate via RealtimeConfiguration:
import ai.decart.sdk.realtime.RealtimeConfiguration

realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        realtimeConfiguration = RealtimeConfiguration(
            media = RealtimeConfiguration.MediaConfig(
                video = RealtimeConfiguration.VideoConfig(
                    preferredCodec = "H264", // default: "VP8"
                    maxBitrate = 2_500_000,
                    maxFramerate = 30,
                )
            )
        ),
    )
)
Defaults: codec VP8, max bitrate 2,000,000 bps, max framerate 30 fps, simulcast on.

Realtime audio

The Android LiveKit publisher is video-only in 0.7. publishMicrophone, includeMicrophone, and RealtimeMediaStream.audioTrack are retained for source compatibility but are deprecated and ignored — SDK-created streams always expose audioTrack = null. Use the JavaScript or Swift realtime SDKs if you need audio.

Managing Prompts

setPrompt is a suspend function that waits for the server ack and throws on ack failure, timeout, or websocket disconnect. Always call it from a coroutine.
lifecycleScope.launch {
    try {
        realtime.setPrompt("Anime style", enhance = true)
    } catch (e: Exception) {
        showError("Prompt rejected: ${e.message}")
    }
}
Parameters:
  • prompt: String (required) - Style description
  • enhance: Boolean (optional) - Auto-enhance the prompt (default: true)
  • timeoutMs: Long (optional) - Ack timeout in milliseconds (default: 15_000)
If you’d rather fire-and-forget and observe the ack later, use setPromptAsync(...) which returns Deferred<Unit>:
val ack = realtime.setPromptAsync("Cyberpunk city")
// ... later
try { ack.await() } catch (e: Exception) { /* ack failed */ }
Prompt enhancement uses Decart’s AI to expand simple prompts for better results. Disable it if you want full control over the exact prompt.

Reference Images

Send a reference image (and optionally a prompt) for image-guided models. Also a suspend function with a Deferred-returning sibling.
lifecycleScope.launch {
    // Set image with prompt
    realtime.setImage(
        imageBase64 = base64EncodedImage,
        prompt = "Transform into this character",
        enhance = true,
    )

    // Clear image
    realtime.setImage(imageBase64 = null)
}

// Or, fire-and-forget with a wait handle:
val ack = realtime.setImageAsync(imageBase64 = base64EncodedImage, prompt = "...")
ack.await()
Parameters:
  • imageBase64: String? (required) - Base64-encoded image, or null to clear
  • prompt: String? (optional) - Text prompt to send with the image
  • enhance: Boolean? (optional) - Whether to enhance the prompt
  • timeout: Long (optional) - Ack timeout in milliseconds (default: 30_000)

Connection State

Monitor connection state changes using a Kotlin Flow:
import ai.decart.sdk.ConnectionState

lifecycleScope.launch {
    realtime.connectionState.collect { state ->
        when (state) {
            ConnectionState.DISCONNECTED -> showReconnectButton()
            ConnectionState.CONNECTING -> showLoadingIndicator()
            ConnectionState.CONNECTED -> hideLoadingIndicator()
            ConnectionState.GENERATING -> showGeneratingUI()
            ConnectionState.RECONNECTING -> showReconnectingBanner()
        }
    }
}

val isConnected = realtime.isConnected()
Connection States:
  • DISCONNECTED - Not connected (initial state, after disconnect(), or after reconnect failure)
  • CONNECTING - Initial connection in progress
  • CONNECTED - Connected and ready to send prompts
  • GENERATING - Actively generating transformed video (sticky until disconnected)
  • RECONNECTING - Connection lost unexpectedly; the SDK is automatically retrying
The SDK automatically reconnects when an unexpected disconnection occurs (e.g., network interruption). During auto-reconnect, the state transitions to RECONNECTING while the SDK retries. If all retries fail, the state moves to DISCONNECTED and an error is emitted. A new RealtimeMediaStream is delivered via remoteStreamUpdates after each successful rebind — re-attach your renderer.

Error Handling

Errors are emitted via the errors SharedFlow:
import ai.decart.sdk.DecartError
import ai.decart.sdk.ErrorCodes

lifecycleScope.launch {
    realtime.errors.collect { error ->
        when (error.code) {
            ErrorCodes.INVALID_API_KEY ->
                showError("Invalid API key. Check your credentials.")
            ErrorCodes.WEBRTC_TIMEOUT_ERROR ->
                showError("Connection timed out. Check your network.")
            ErrorCodes.WEBRTC_ICE_ERROR ->
                showError("ICE negotiation failed.")
            ErrorCodes.WEBRTC_WEBSOCKET_ERROR ->
                showError("WebSocket connection error.")
            ErrorCodes.WEBRTC_SERVER_ERROR ->
                showError("Server error. Try again later.")
            ErrorCodes.WEBRTC_SIGNALING_ERROR ->
                showError("Signaling error.")
            else ->
                showError("Error: ${error.message}")
        }
    }
}
Error Codes:
  • INVALID_API_KEY - API key is invalid or missing
  • WEBRTC_TIMEOUT_ERROR - Connection timed out
  • WEBRTC_ICE_ERROR - ICE negotiation failed
  • WEBRTC_WEBSOCKET_ERROR - WebSocket connection error
  • WEBRTC_SERVER_ERROR - Server-side error
  • WEBRTC_SIGNALING_ERROR - Signaling protocol error

Publisher Stats

The stats SharedFlow emits outbound (publisher) video metrics roughly once per second:
lifecycleScope.launch {
    realtime.stats.collect { event ->
        println("Encoded ${event.deltaFrames} frames (${event.frameWidth}x${event.frameHeight})")
        println("Sent ${event.deltaBytes} bytes")
        event.qualityLimitationReason?.let { println("Quality limited by: $it") }
    }
}
PublishStatsEvent fields:
  • bytesSent, deltaBytes - Cumulative and per-sample bytes sent
  • framesEncoded, deltaFrames - Cumulative and per-sample frames encoded
  • frameWidth, frameHeight - Current encoded frame size
  • encoderImplementation - Codec implementation reported by libwebrtc
  • qualityLimitationReason - "cpu", "bandwidth", etc. when the encoder is throttling

Generation Ticks

Track session duration for billing and usage display:
lifecycleScope.launch {
    realtime.generationTicks.collect { tick ->
        println("Generation running for ${tick.seconds} seconds")
        updateBillingUI(tick.seconds)
    }
}

Diagnostics

Monitor detailed connection diagnostics for debugging:
import ai.decart.sdk.realtime.DiagnosticEvent

lifecycleScope.launch {
    realtime.diagnostics.collect { event ->
        when (event) {
            is DiagnosticEvent.PhaseTiming ->
                println("${event.data.phase}: ${event.data.durationMs}ms")
            is DiagnosticEvent.FirstFrame ->
                println("First remote frame in ${event.data.timeSinceConnectMs}ms")
            is DiagnosticEvent.Reconnect ->
                println("Reconnect attempt ${event.data.attempt}/${event.data.maxAttempts}")
            is DiagnosticEvent.VideoStall ->
                println("Video stall: ${if (event.data.stalled) "detected" else "recovered"}")
            is DiagnosticEvent.PublishStats ->
                { /* see Publisher Stats above — same payload also delivered here */ }
            else -> { /* ICE, peer connection, signaling state changes */ }
        }
    }
}

Connection Quality

Two layers report network health on a shared GOOD | FAIR | POOR | CRITICAL scale: a preflight check before connecting, and an in-session signal while connected.

Preflight

A fast, network-only reachability check. No session, no cost:
import ai.decart.sdk.realtime.ConnectionQuality

val report = realtime.checkConnectivity() // suspend
// report.metrics: transport (UDP | RELAY | FAILED), rttMs
if (report.quality == ConnectionQuality.CRITICAL) showFallbackUi(report.reasons)

In-session quality

While connected, the SDK derives a smoothed verdict from live connection stats (latency, packet loss, upstream bandwidth, frame rate) and tells you the limiting factor. Level is debounced; metrics refresh each stats sample (~few seconds):
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        onConnectionQuality = { report ->
            // report.limitingFactor: BANDWIDTH | LATENCY | LOSS | STALL | CPU | NONE
            // report.metrics: rttMs, fps, packetLoss, availableUpstreamKbps, ...
            updateBadge(report.quality)
        },
        onRemoteStream = { /* ... */ },
    ),
)

// also a Flow + a getter:
realtime.connectionQuality.collect { /* ConnectionQualityReport? */ }
realtime.getConnectionQuality() // latest, or null before the first sample

Glass-to-glass latency (opt-in)

Network RTT alone doesn’t reflect the latency users actually feel — a session can read GOOD while still feeling laggy. Set debugQuality = true to measure the real camera→display latency: the SDK stamps a pixel marker into each outgoing frame and reads it back off the rendered output, surfacing startup (ttffMs), steady-state (g2gMs), and end-to-end drops (g2gDropRatio). When present, glass-to-glass drives the latency verdict instead of RTT.
val stream = realtime.createLocalVideoStream(
    model = RealtimeModels.LUCY_2_1,
    debugQuality = true,
)
realtime.connect(
    ConnectOptions(
        model = RealtimeModels.LUCY_2_1,
        debugQuality = true,
        onConnectionQuality = { report ->
            // report.metrics.ttffMs / g2gMs / g2gDropRatio
        },
    ),
    localStream = stream,
)
Diagnostic only. The marker is visible (bottom-left of the published + rendered video) and adds per-frame pixel work — don’t enable it for production / end-user sessions. With a caller-provided stream, build it via createLocalVideoStream(..., debugQuality = true) so the same flag is set on both the stream and connect().

Deep preflight

For a measured verdict before connecting, use the deep probe — it briefly opens a real session with a synthetic source, measures glass-to-glass, then tears it down. Requires a model and costs a short session:
import ai.decart.sdk.realtime.CheckConnectivityOptions

val probe = realtime.checkConnectivity(
    CheckConnectivityOptions(deep = true, model = RealtimeModels.LUCY_2_1),
)
// probe.metrics.g2gMs / ttffMs / g2gDropRatio

Session Identifiers

Once a session is established the SDK surfaces the server-side session id and a subscribe token:
val sessionId = realtime.sessionId        // for logging / analytics
val subscribeToken = realtime.subscribeToken // share with viewers for read-only watching
Both come from the sessionStarted: StateFlow<SessionStarted?> — collect it if you need to react when they appear.

Cleanup

Always tear down the session and release native resources when you’re done:
// If you own the local stream, dispose it after the client teardown.
realtime.disconnect()
localStream?.dispose()
client.release()
disconnect() ends the current session but leaves the client reusable. release() cancels the SDK’s coroutine scope and shuts down the underlying LiveKit room ownership; call it when the client is no longer needed.
Forgetting to dispose() a caller-owned RealtimeMediaStream leaks the LiveKit Room (and its capturer + native PeerConnectionFactory). Tie disposal to your lifecycle (onCleared, DisposableEffect, etc.).

Complete Jetpack Compose Example

A full Jetpack Compose tab with a caller-owned preview and the LiveKit renderer, modeled on the sample app on GitHub:
import ai.decart.sdk.*
import ai.decart.sdk.realtime.*
import android.Manifest
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.track.VideoTrack
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import livekit.org.webrtc.RendererCommon

@Composable
fun RealtimeScreen(apiKey: String) {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()

    var connectionState by remember { mutableStateOf(ConnectionState.DISCONNECTED) }
    var prompt by remember { mutableStateOf("Turn into a fantasy figure") }
    val selectedModel = RealtimeModels.LUCY_2_1

    // Caller-owned preview, created lazily and torn down on dispose.
    var localStream by remember { mutableStateOf<RealtimeMediaStream?>(null) }
    var remoteStream by remember { mutableStateOf<RealtimeMediaStream?>(null) }
    var client by remember { mutableStateOf<DecartClient?>(null) }

    LaunchedEffect(selectedModel) {
        localStream?.dispose()
        localStream = null
        val tmp = DecartClient(context, DecartClientConfig(apiKey = apiKey))
        localStream = tmp.realtime.createLocalVideoStream(
            model = selectedModel,
            facing = FacingMode.FRONT,
            mirror = MirrorMode.AUTO,
        )
        tmp.release() // we only used it to build the preview
    }

    LaunchedEffect(client) {
        val c = client ?: return@LaunchedEffect
        launch { c.realtime.connectionState.collect { connectionState = it } }
        launch {
            c.realtime.remoteStreamUpdates.collectLatest { remoteStream = it }
        }
        launch {
            c.realtime.errors.collect { Log.e("Decart", it.message ?: "error") }
        }
    }

    DisposableEffect(Unit) {
        onDispose {
            client?.release()
            client = null
            localStream?.dispose()
            localStream = null
        }
    }

    val isConnected = connectionState == ConnectionState.CONNECTED ||
        connectionState == ConnectionState.GENERATING

    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (!granted) return@rememberLauncherForActivityResult
        val preview = localStream ?: return@rememberLauncherForActivityResult
        val newClient = DecartClient(context, DecartClientConfig(apiKey = apiKey))
        client = newClient
        coroutineScope.launch {
            try {
                newClient.realtime.connect(
                    options = ConnectOptions(
                        model = selectedModel,
                        initialPrompt = InitialPrompt(prompt, enhance = true),
                        facing = FacingMode.FRONT,
                        publishCamera = true,
                    ),
                    localStream = preview,
                )
            } catch (e: Exception) {
                Log.e("Decart", "connect failed", e)
            }
        }
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        // Remote video
        Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
            LiveKitVideoView(
                track = remoteStream?.videoTrack,
                room = remoteStream?.room ?: localStream?.room,
                modifier = Modifier.fillMaxSize(),
            )
        }

        Text("Status: $connectionState")

        Row(modifier = Modifier.fillMaxWidth()) {
            OutlinedTextField(
                value = prompt,
                onValueChange = { prompt = it },
                label = { Text("Style prompt") },
                modifier = Modifier.weight(1f),
            )
            Spacer(modifier = Modifier.width(8.dp))
            Button(
                onClick = {
                    coroutineScope.launch {
                        try {
                            client?.realtime?.setPrompt(prompt, enhance = true)
                        } catch (e: Exception) {
                            Log.e("Decart", "setPrompt failed", e)
                        }
                    }
                },
                enabled = isConnected,
            ) { Text("Send") }
        }

        Button(
            onClick = {
                if (isConnected) {
                    client?.realtime?.disconnect()
                    client?.release()
                    client = null
                } else {
                    permissionLauncher.launch(Manifest.permission.CAMERA)
                }
            },
            modifier = Modifier.fillMaxWidth(),
        ) { Text(if (isConnected) "Disconnect" else "Connect") }
    }
}

@Composable
fun LiveKitVideoView(
    track: VideoTrack?,
    room: Room?,
    modifier: Modifier = Modifier,
) {
    if (room == null) return
    val rendererRef = remember { mutableStateOf<TextureViewRenderer?>(null) }
    val boundTrack = remember { mutableStateOf<VideoTrack?>(null) }

    AndroidView(
        modifier = modifier,
        factory = { ctx ->
            TextureViewRenderer(ctx).also { r ->
                r.init(room.lkObjects.eglBase.eglBaseContext, null)
                r.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
                rendererRef.value = r
                track?.addRenderer(r)
                boundTrack.value = track
            }
        },
        update = { r ->
            if (boundTrack.value !== track) {
                boundTrack.value?.removeRenderer(r)
                track?.addRenderer(r)
                boundTrack.value = track
            }
        },
    )

    DisposableEffect(Unit) {
        onDispose {
            boundTrack.value?.removeRenderer(rendererRef.value ?: return@onDispose)
            try { rendererRef.value?.release() } catch (_: Exception) {}
        }
    }
}

Best Practices

Use realtime.createLocalVideoStream(model, ...) (or the static factory) so capture dimensions match the model exactly. Lucy 2.1 / VTON models capture at 1088x624; Lucy Restyle 2 at 1280x704.
setPrompt and setImage are suspend functions that wait for the server ack and throw on ack failure, timeout, or disconnect. Always call them from a coroutine (lifecycleScope, viewModelScope, rememberCoroutineScope()) and handle exceptions. Reach for setPromptAsync / setImageAsync only when you need a Deferred handle.
Keep enhance = true (default) so Decart’s AI expands simple prompts. Disable it only when you need exact prompt control.
remoteStreamUpdates emits a fresh RealtimeMediaStream after each reconnect. Remove the previous track from your renderer and add the new one — failing to do so leaves the renderer bound to a dead track.
If you call createLocalVideoStream yourself, dispose the returned RealtimeMediaStream from your lifecycle hook (onCleared, DisposableEffect.onDispose). The Room owns native resources that don’t get freed otherwise.
Always test camera features on real Android devices. The emulator does not support camera capture for realtime sessions.
Request CAMERA permission at runtime before attempting to connect. (Realtime audio is not currently supported, so RECORD_AUDIO isn’t required.) Handle permission denials gracefully in your UI.

API Reference

DecartClient(context, config)

Top-level entry point. Exposes realtime: RealTimeClient and queue: QueueClient. Use DecartClientConfig(apiKey, baseUrl?, httpBaseUrl?, logLevel?). Call client.release() to tear down both sub-clients.

realtime.createLocalVideoStream(model, facing, mirror, configuration?, includeMicrophone?)

Build a caller-owned preview RealtimeMediaStream sized to model.width × model.height. Pass it to connect(..., localStream = ...) so preview and publish share a LiveKit Room. Caller must dispose() the stream. Parameters:
  • model: RealtimeModel - Model to size the capture for
  • facing: FacingMode - FRONT (default) or BACK
  • mirror: MirrorMode - AUTO (default), OFF, or ON
  • configuration: RealtimeConfiguration (optional) - Codec / bitrate / framerate overrides
  • includeMicrophone: Boolean - Ignored; audio is not yet supported
Overload: createLocalVideoStream(width, height, facing, ...) takes explicit dimensions instead of a model. Static RealTimeClient.createLocalVideoStream(context, model, ...) lets you build a preview before instantiating a client.

realtime.connect(options, localStream?)

Suspend. Join the model’s LiveKit room and (by default) publish the local camera. Returns the remote RealtimeMediaStream and also emits it via onRemoteStream / remoteStreamUpdates. ConnectOptions parameters:
  • model: RealtimeModel (required) - Realtime model from RealtimeModels
  • initialPrompt: InitialPrompt? - Starting prompt (text + enhance)
  • initialImage: String? - Base64-encoded reference image
  • resolution: Resolution? - P720 or P1080. Omit for the server’s 720p default; pass P1080 to request 1080p from supported models.
  • realtimeConfiguration: RealtimeConfiguration - Codec / bitrate / framerate tuning
  • publishCamera: Boolean - Open the camera and publish (default true)
  • publishMicrophone: Boolean - Ignored in 0.7; audio is not supported
  • facing: FacingMode - FRONT (default) or BACK. Only used when the SDK opens its own camera.
  • mirror: MirrorMode - AUTO (default), OFF, or ON
  • onRemoteStream: ((RealtimeMediaStream) -> Unit)? - Callback when a (re)connected remote stream is available
Throws on signaling, ICE, or LiveKit room failures.

realtime.setPrompt(prompt, enhance, timeoutMs)

Suspend. Update the prompt and wait for the server ack. Parameters:
  • prompt: String - Style description
  • enhance: Boolean - Auto-enhance the prompt (default: true)
  • timeoutMs: Long - Ack timeout in ms (default: 15_000)
Throws IllegalStateException if not connected, or a DecartError on ack failure, timeout, or websocket disconnect.

realtime.setPromptAsync(prompt, enhance, timeoutMs)

Same parameters as setPrompt but returns Deferred<Unit>. Call await() to observe ack failure / timeout / disconnect.

realtime.setImage(imageBase64, prompt, enhance, timeout)

Suspend. Send (or clear, with null) a reference image. Parameters:
  • imageBase64: String? - Base64-encoded image, or null to clear
  • prompt: String? - Optional text prompt
  • enhance: Boolean? - Whether to enhance the prompt
  • timeout: Long - Ack timeout in ms (default: 30_000)
Throws IllegalStateException if not connected.

realtime.setImageAsync(imageBase64, prompt, enhance, timeout)

Same parameters as setImage but returns Deferred<Unit>.

realtime.disconnect() / realtime.release()

disconnect() ends the current session. release() cancels the SDK’s coroutine scope; call it when the client is no longer needed (and prefer client.release() to also clean up the queue client).

realtime.isConnected()

Returns: Boolean - Whether the current connection state is CONNECTED or GENERATING.

RealtimeMediaStream

  • videoTrack: VideoTrack? - LiveKit video track; pass to your renderer via addRenderer(...) / removeRenderer(...).
  • audioTrack: AudioTrack? - Deprecated; always null in 0.7.
  • id: String - Stream identifier (stream-local or stream-remote).
  • room: Room? - LiveKit room that owns the tracks. Use room.lkObjects.eglBase.eglBaseContext when initializing renderers.
  • dispose() - Tear down tracks and the room. Safe to call multiple times.

Observable State

PropertyTypeDescription
connectionStateStateFlow<ConnectionState>Current connection state
connectionChangeStateFlow<ConnectionState>JS-aligned alias for connectionState
errorsSharedFlow<DecartError>Error events
generationTicksSharedFlow<GenerationTickMessage>Per-second tick during generation
generationEndedSharedFlow<GenerationEndedMessage>Generation lifecycle end events
queuePositionUpdatesSharedFlow<QueuePositionMessage>Queue position updates while waiting for a server slot
localStreamUpdatesSharedFlow<RealtimeMediaStream>Local LiveKit stream updates
remoteStreamUpdatesSharedFlow<RealtimeMediaStream>Remote LiveKit stream updates (including auto-reconnect rebinds)
sessionStartedStateFlow<SessionStarted?>(sessionId, subscribeToken) once the LiveKit room info arrives
subscribeTokenString?Token to hand to viewer / subscribe clients
sessionIdString?Current session id
diagnosticsSharedFlow<DiagnosticEvent>Connection diagnostic events
statsSharedFlow<PublishStatsEvent>Publisher outbound video stats

Next Steps

Queue API

Generate and transform videos with batch processing and Flow-based progress

SDK Overview

Installation, setup, and Android SDK fundamentals