steep-fireman-8880
03/02/2026, 2:00 PMtall-airline-16756
03/02/2026, 2:00 PMtall-airline-16756
03/02/2026, 2:01 PMsteep-fireman-8880
03/02/2026, 2:05 PMAndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
// A. Create our custom capturer
val cameraKitCapturer = CameraKitSurfaceCapturer()
// 2. Create the LiveKit track from the capturer. This initializes the
// SurfaceTextureHelper inside the capturer.
localParticipant = room.localParticipant
imageProcessorScope.launch {
track = localParticipant?.createVideoTrack("camera-kit", cameraKitCapturer) as LocalVideoTrack
room.localParticipant.publishVideoTrack(track!!)
// track?.startCapture() // Start capture to ensure the surface is ready
}
/* val track = localParticipant?.createVideoTrack("camera-kit", cameraKitVideoCapturer) as LocalVideoTrack
scope.launch {
room.localParticipant.publishVideoTrack(track)
}*/
// Inflate the layout for the preview
LayoutInflater.from(ctx).inflate(R.layout.camera_layout, null).apply {
val viewStub = findViewById<ViewStub>(R.id.camera_kit_stub)
cameraKitSession = Session(context = ctx) {
imageProcessorSource(imageProcessorSource)
attachTo(viewStub)
}.apply {
lenses.repository.observe(
LensesComponent.Repository.QueryCriteria.ById(lensId, lensGroupId)
) { result ->
result.whenHasFirst { requestedLens ->
lenses.processor.apply(requestedLens)
}
}
}
// E. THE CORRECT METHOD:
// Create an Image output that wraps our capturer and connect it.
cameraKitCapturer.surface?.let { safeSurface ->
val liveKitOutput = LiveKitOutput(safeSurface)
// The API returns a Closeable that must be managed.
scope.launch {
closable = processor.connectOutput(liveKitOutput)
}
} ?: run {
Log.e("CameraKitLiveKit", "Capturer surface was null.")
}
}
},
update = {
// E. Apply lenses when lensId changes
cameraKitSession?.lenses?.repository?.observe(
LensesComponent.Repository.QueryCriteria.ById(lensId, lensGroupId)
) { result ->
result.whenHasFirst { requestedLens ->
cameraKitSession?.lenses?.processor?.apply(requestedLens)
}
}
}
)tall-airline-16756
03/02/2026, 2:06 PMprocessor.connectOutput(liveKitOutput) or from createVideoTrack / publishVideoTrack?
While you reply, a few focused checks and tips you can try immediately:
• Ensure surface is non-null before connecting:
◦ The capturer’s Surface may be created asynchronously. If it’s null at factory time, attach a listener or delay connecting until the Surface is ready.
• Start the LiveKit track capture lifecycle:
◦ After creating and publishing the LocalVideoTrack, ensure capture is started if your capturer requires an explicit start (you commented out track?.startCapture() — try enabling it if required).
• Threading and scope:
◦ You call createVideoTrack inside imageProcessorScope.launch but also access `localParticipant`/`room` from other scopes. Ensure these references are initialized and calls to publish happen on appropriate threads (LiveKit SDK calls often expect main thread).
• Manage the Closeable:
◦ closable = processor.connectOutput(liveKitOutput) returns a Closeable. Ensure you call closable.close() when the view is destroyed to avoid leaks and to stop frames.
• Verify frame format and size compatibility:
◦ Confirm LiveKitOutput accepts the Surface/format Snap provides. If frames are not recognized, you might need to convert pixel format or use a SurfaceTexture-backed path LiveKit expects.
If you tell me the specific issue or paste error logs, I’ll provide: 1) A targeted fix (with code adjustments) that waits for Surface readiness and starts capture correctly. 2) A small sample of the corrected integration flow (where to start capture, manage lifecycle, and close the output). 3) Performance notes (threading, encoding, and avoiding UI freezes).
Which problem should I tackle first?steep-fireman-8880
03/02/2026, 2:08 PMtrack = localParticipant?.createVideoTrack("camera-kit", cameraKitCapturer) as LocalVideoTrack
room.localParticipant.publishVideoTrack(track!!)
this causes cpu to high and app is not respondingtall-airline-16756
03/02/2026, 2:08 PMcreateVideoTrack("camera-kit", cameraKitCapturer) and immediately publishing it causes high CPU and the app to become unresponsive.
What’s likely going wrong (short)
• The capturer/camera pipeline is being run on the main thread or without proper lifecycle/thread management, so continuous frame processing (Snap lenses + forwarding frames to LiveKit) overloads the UI thread.
• The code also bypasses the LiveKit CameraKit integration which manages threading, rate limiting, and lifecycle hooks.
What you can do now (concrete steps)
1. Use the LiveKit CameraKit plugin (recommended)
◦ Add the CameraKit plugin dependency:
implementation "io.livekit:camerakit:1.0.0"
• Create a CameraKit-backed LocalVideoTrack that handles threading and lifecycle:
val cameraKitCapturer: CameraKitCapturer = // initialize CameraKit capturer
val localVideoTrack = LocalVideoTrack.createCameraTrack(
context = context,
name = "camera-kit",
capturer = cameraKitCapturer
)
localParticipant?.publishVideoTrack(localVideoTrack, VideoTrackPublishOptions.Builder().build())
• Start/stop capture in lifecycle callbacks to avoid unnecessary work in background:
override fun onResume() {
super.onResume()
cameraKitCapturer.startCapture()
}
override fun onPause() {
cameraKitCapturer.stopCapture()
super.onPause()
}
1. Ensure the Snap processor output is connected only after the Surface is ready
◦ The Surface may be null at view creation. Wait for the capturer/Surface to be initialized, then call processor.connectOutput(liveKitOutput) so you don’t attempt frame flows prematurely.
1. Don’t run capture or heavy processing on the main thread
◦ Ensure frame capture, Snap processing, and the LiveKit output connection run on background threads. The CameraKit plugin does this for you; if you manage it manually, use a dedicated coroutine dispatcher (e.g., Dispatchers.Default) or a background HandlerThread for frame processing.
1. Limit work per frame: FPS and resolution
◦ Lower the capture resolution and target FPS while developing or on low-end devices. This reduces CPU load from lens processing and encoding.
◦ Example: request 720p or lower and 15–20 fps instead of 30+ while testing.
1. Properly manage the processor Closeable
◦ Store the Closeable returned by processor.connectOutput(liveKitOutput) and call close() when the view is destroyed or you stop publishing. This prevents background work from continuing after UI teardown.
1. Start capture after publishing (or as required)
◦ If your capturer requires explicit start, call cameraKitCapturer.startCapture() (or track.startCapture() if using that API) after the track is created and the processor output is connected, so capture begins when everything is ready.
If you want, I can provide a short corrected code sample that:
• Uses the CameraKit plugin to create the track,
• Waits for Surface readiness and connects Snap processor output on a background thread,
• Starts/stops capture in lifecycle methods,
• Demonstrates closing the processor output on destroy.
Which would be most helpful: a minimal code snippet replacing your current factory block, or a full lifecycle-aware Activity/Compose example? Also tell me the Android API level and whether you’re using Kotlin coroutines or plain threads so I tailor the example.
Sources: client-sdk-android | livekit/livekit_composite | DeepWikisteep-fireman-8880
03/02/2026, 2:10 PMclass CameraKitSurfaceCapturer : VideoCapturer {
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var capturerObserver: CapturerObserver? = null
private var isCapturing = false
/**
* The Surface that Camera Kit will render its processed frames to.
* This is retrieved from the SurfaceTextureHelper provided by LiveKit.
*/
val surface: Surface?
get() = surfaceTextureHelper?.surfaceTexture?.let { Surface(it) }
override fun initialize(
surfaceTextureHelper: SurfaceTextureHelper?,
applicationContext: Context,
capturerObserver: CapturerObserver
) {
this.surfaceTextureHelper = surfaceTextureHelper
this.capturerObserver = capturerObserver
}
override fun startCapture(width: Int, height: Int, framerate: Int) {
if (isCapturing) {
return
}
isCapturing = true
// The helper listens for frames rendered to its Surface and automatically
// converts them to VideoFrames for LiveKit.
surfaceTextureHelper?.startListening { videoFrame ->
capturerObserver?.onFrameCaptured(videoFrame)
}
}
override fun stopCapture() {
if (!isCapturing) {
return
}
isCapturing = false
surfaceTextureHelper?.stopListening()
}
override fun dispose() {
surfaceTextureHelper?.dispose()
}
// Unused methods
override fun changeCaptureFormat(width: Int, height: Int, framerate: Int) {}
override fun isScreencast(): Boolean = false
}tall-airline-16756
03/02/2026, 2:11 PMCameraKitSurfaceCapturer and the performance symptoms you described. You’re on the right track, but a few important fixes will stop leaks, avoid running work on the main thread, and prevent the high CPU / ANR you’re seeing.
What I’m responding to
• Your posted capturer class and the crash/high CPU when creating + publishing the track.
Summary — key problems in the current class
• You create a new Surface every time surface is accessed; that can leak native resources.
• SurfaceTextureHelper must be created and used on a background thread/handler — currently it’s likely being used from the main thread.
• You never call capturerObserver.onCapturerStarted(true) to notify the pipeline the capturer is ready.
• Lifecycle and cleanup are incomplete (you need to stop listening and release resources reliably).
• Heavy processing or blocking on the main thread in frame callbacks will spike CPU / UI freezes.
Recommended fixes (brief)
• Expose the underlying SurfaceTexture rather than creating Surface objects on demand.
• Create SurfaceTextureHelper on a dedicated background thread / HandlerThread.
• Call capturerObserver.onCapturerStarted(true) after initialization.
• Ensure startListening / stopListening happen on the helper’s thread and that the onFrameCaptured callback does minimal work.
• Dispose and nullify references in dispose() to avoid continuing background work.
Corrected, lifecycle-safe capturer (concise, ready-to-adopt)
• The implementation below addresses the items above. Create the helper on its own HandlerThread, expose the SurfaceTexture, notify the observer, and ensure proper cleanup.
class CameraKitSurfaceCapturer private constructor() : VideoCapturer {
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var capturerObserver: CapturerObserver? = null
@Volatile private var isCapturing = false
private var handlerThread: HandlerThread? = null
// Expose the SurfaceTexture; do NOT create Surface() each time.
val surfaceTexture: SurfaceTexture?
get() = surfaceTextureHelper?.surfaceTexture
companion object {
fun create(context: Context, name: String = "CameraKitCapturer"): CameraKitSurfaceCapturer {
val capturer = CameraKitSurfaceCapturer()
val thread = HandlerThread("$name-Thread").apply { start() }
capturer.handlerThread = thread
val handler = Handler(thread.looper)
capturer.surfaceTextureHelper = SurfaceTextureHelper.create(name, context, handler)
return capturer
}
}
override fun initialize(
surfaceTextureHelper: SurfaceTextureHelper?,
applicationContext: Context,
capturerObserver: CapturerObserver
) {
// Use companion factory to create helper; still accept init from SDK if needed.
if (this.surfaceTextureHelper == null && surfaceTextureHelper != null) {
this.surfaceTextureHelper = surfaceTextureHelper
}
this.capturerObserver = capturerObserver
// Notify the pipeline the capturer is ready
capturerObserver.onCapturerStarted(true)
}
override fun startCapture(width: Int, height: Int, framerate: Int) {
if (isCapturing) return
isCapturing = true
// Ensure listening happens on the helper's thread; callback should be lightweight.
surfaceTextureHelper?.startListening { videoFrame ->
// Do minimal work here. If you need heavy processing, offload it to another worker thread.
capturerObserver?.onFrameCaptured(videoFrame)
}
}
override fun stopCapture() {
if (!isCapturing) return
isCapturing = false
surfaceTextureHelper?.stopListening()
}
override fun dispose() {
stopCapture()
surfaceTextureHelper?.dispose()
surfaceTextureHelper = null
capturerObserver = null
handlerThread?.quitSafely()
handlerThread = null
}
override fun changeCaptureFormat(width: Int, height: Int, framerate: Int) {
// Optional: if you want to honor format changes, reconfigure here.
}
override fun isScreencast(): Boolean = false
}
Integration tips with Snap Camera Kit and LiveKit
• Pass capturer.surfaceTexture (not a Surface) to your Snap processor if it supports SurfaceTexture. Avoid wrapping it in Surface() unless required.
• Only connect the Snap processor output to LiveKit once surfaceTexture is non-null and the helper thread is ready.
• Use the LiveKit CameraKit plugin if possible — it handles threading, lifecycle, and optimizations. If you use the plugin, create the LiveKit track with the plugin’s capturer APIs and manage start/stop in lifecycle callbacks.
• Start capture after the processor output is connected and after publishing (or as recommended by your setup). Stop capture in onPause and dispose in onDestroy to reduce background work.
• Reduce resolution / fps for testing (e.g., 720p @ 15–20 fps) to confirm performance improvements before increasing quality.
Performance checklist to avoid high CPU / ANR
• No heavy processing on the main thread.
• Frame callback is minimal; offload work that isn’t strictly required for the immediate frame.
• Create and run SurfaceTextureHelper on a background HandlerThread.
• Throttle incoming frames if the processor pushes more frames than the encoder/network stack can handle.
• Close any Closeables returned by Snap’s connectOutput when not needed.
Would you like:
• A short Compose/Activity example that wires this capturer into your existing factory code and demonstrates starting/stopping capture in lifecycle methods, or
• A version that uses the LiveKit CameraKit plugin APIs instead (recommended for easier lifecycle/thread handling)?
Tell me which example you prefer and your Android API level + whether you’re using Kotlin coroutines — I’ll provide the tailored sample.
Sources: client-sdk-android | livekit/livekit_composite | DeepWikisteep-fireman-8880
03/02/2026, 2:21 PMpublic static SurfaceTextureHelper create(final String threadName, final EglBase.Context sharedContext, boolean alignTimestamps) {
return create(threadName, sharedContext, alignTimestamps, new YuvConverter(), (FrameRefMonitor)null);
}
this is SurfaceTextureHelper func. your
SurfaceTextureHelper.create
seems not corecttall-airline-16756
03/02/2026, 2:21 PMSurfaceTextureHelper.create signature that accepts an EglBase.Context and alignTimestamps (and not a Handler). I agree: do not rely on a non-existent overload that takes a Handler.
What to do instead
• Use the real SurfaceTextureHelper.create(threadName, sharedContext, alignTimestamps) overload.
• Provide an EglBase.Context for sharedContext. You can create an EglBase (and obtain its context) or reuse an existing EGL context from your rendering stack if you have one.
• Let WebRTC’s helper create and manage its internal thread — you do not pass a Handler into create.
How this fits into the capturer lifecycle
• Create the SurfaceTextureHelper with the correct overload before you start capturing:
a. Create or obtain an EglBase.Context (see note below).
b. Call SurfaceTextureHelper.create("CameraKitCaptureThread", eglBaseContext, /*alignTimestamps=*/ true or false).
c. Store the returned helper and expose its surfaceTexture to Snap Camera Kit (do not create new Surface objects repeatedly).
d. In initialize(...), set capturerObserver and call capturerObserver.onCapturerStarted(true) when ready.
e. Start listening on the helper (it already runs on its own thread) and forward frames quickly to capturerObserver.onFrameCaptured(videoFrame).
Example sketch using the correct create overload
// 1. Create/obtain EglBase and its context
val eglBase = EglBase.create() // creates its own display/context
val eglContext: EglBase.Context = eglBase.eglBaseContext
// 2. Create SurfaceTextureHelper correctly
val surfaceTextureHelper = SurfaceTextureHelper.create(
"CameraKitCaptureThread",
eglContext,
/* alignTimestamps = */ true
)
// 3. Use the helper in your capturer
// Expose helper.surfaceTexture to Camera Kit; listen for frames:
surfaceTextureHelper.startListening { videoFrame ->
capturerObserver?.onFrameCaptured(videoFrame)
}
Notes and best practices
• EglBase lifecycle: Create an EglBase on app/renderer init and reuse its context for all helpers if you need a shared EGL context; call eglBase.release() when you’re done.
• alignTimestamps: Set this to true if you want the helper to align with system timestamps; otherwise false; pick whichever fits your pipeline timing needs.
• Do not wrap the SurfaceTexture in a new Surface unless a library specifically requires a Surface. Passing the SurfaceTexture is lighter and avoids needing explicit Surface.release() calls.
• Threading: You do not need to pass a Handler. The helper creates its own background thread named by the threadName you provide.
• Call capturerObserver.onCapturerStarted(true) after initialization so WebRTC knows the capturer is ready.
• Ensure `startListening`/`stopListening` and heavy processing are not done on the main thread. The helper callback runs on the helper’s thread.
If you’d like, I can:
• Provide a corrected Kotlin implementation of your CameraKitSurfaceCapturer that uses SurfaceTextureHelper.create(...) exactly as above and demonstrates where to pass the surfaceTexture to Snap Camera Kit, or
• Show a short lifecycle-aware Compose/Activity example that wires the helper, starts/stops capture, and disposes the EGL context and helper cleanly.
Which example would help you most?
Sources: client-sdk-android | livekit/livekit_composite | DeepWikisteep-fireman-8880
03/02/2026, 2:42 PMCameraKitSurfaceCapturer that uses SurfaceTextureHelper.create(...) exactly as above and demonstrates where to pass the surfaceTexture to Snap Camera Kittall-airline-16756
03/02/2026, 2:43 PMCameraKitSurfaceCapturer that:
• Creates SurfaceTextureHelper with the correct SurfaceTextureHelper.create(threadName, eglContext, alignTimestamps) overload.
• Exposes the SurfaceTexture (not a repeatedly created Surface) so you can pass it to Snap Camera Kit.
• Manages lifecycle: initialize, startCapture, stopCapture, dispose.
• Ensures frame callbacks run on the helper’s thread and forward frames quickly to LiveKit via capturerObserver.onFrameCaptured(videoFrame).
Copy this into your project and adapt names/paths where needed.
Corrected Kotlin implementation (concise)
import android.content.Context
import android.opengl.EGL14
import android.util.Log
import org.webrtc.* // ensure your Gradle has the right WebRTC dependency
import java.util.concurrent.atomic.AtomicBoolean
class CameraKitSurfaceCapturer private constructor(
private val context: Context,
private val threadName: String = "CameraKitCaptureThread",
private val alignTimestamps: Boolean = true
) : VideoCapturer {
private var surfaceTextureHelper: SurfaceTextureHelper? = null
private var capturerObserver: CapturerObserver? = null
private val isCapturing = AtomicBoolean(false)
private var eglBase: EglBase? = null
// Expose the SurfaceTexture so external renderers (e.g., Snap Camera Kit) can attach.
val surfaceTexture: SurfaceTexture?
get() = surfaceTextureHelper?.surfaceTexture
companion object {
/**
* Factory - creates EglBase and SurfaceTextureHelper using the correct overload.
* Call this from a non-blocking context (e.g., not on a heavy UI operation).
*/
fun create(context: Context, threadName: String = "CameraKitCaptureThread", alignTimestamps: Boolean = true): CameraKitSurfaceCapturer {
val capturer = CameraKitSurfaceCapturer(context, threadName, alignTimestamps)
// Create an EglBase to share GL context between helper and any renderer if needed.
// You can also reuse an existing EglBase if you have one in your app.
capturer.eglBase = EglBase.create() // default config
val eglContext = capturer.eglBase!!.eglBaseContext
// Use the correct create overload: (threadName, sharedContext, alignTimestamps)
capturer.surfaceTextureHelper = SurfaceTextureHelper.create(threadName, eglContext, alignTimestamps)
return capturer
}
}
// Called by the WebRTC/LiveKit pipeline to hand us the helper/observer if created externally.
override fun initialize(
surfaceTextureHelper: SurfaceTextureHelper?,
applicationContext: Context?,
capturerObserver: CapturerObserver
) {
// If an external helper is provided, adopt it; otherwise we already created one in factory.
if (surfaceTextureHelper != null && this.surfaceTextureHelper == null) {
this.surfaceTextureHelper = surfaceTextureHelper
}
this.capturerObserver = capturerObserver
// Notify that initialization succeeded
capturerObserver.onCapturerStarted(true)
}
override fun startCapture(width: Int, height: Int, framerate: Int) {
if (!isCapturing.compareAndSet(false, true)) return
// Inform pipeline of expected output format (optional)
capturerObserver?.onOutputFormatRequest(VideoFrame.Buffer.Type.TEXTURE)
// Ensure helper exists
val helper = surfaceTextureHelper
if (helper == null) {
capturerObserver?.onCapturerError("SurfaceTextureHelper is null in startCapture()")
isCapturing.set(false)
return
}
// Start listening for frames. This callback runs on helper's internal thread.
helper.startListening { videoFrame ->
// Do minimal work here. Forward frame to the LiveKit/WebRTC pipeline.
try {
capturerObserver?.onFrameCaptured(videoFrame)
} catch (t: Throwable) {
Log.e("CameraKitCapturer", "Error forwarding frame", t)
// Optionally notify error
capturerObserver?.onCapturerError(t.message ?: "frame forward error")
} finally {
// WebRTC manages VideoFrame memory; do not release here unless you own it.
}
}
}
override fun stopCapture() {
if (!isCapturing.compareAndSet(true, false)) return
surfaceTextureHelper?.stopListening()
}
override fun changeCaptureFormat(width: Int, height: Int, framerate: Int) {
// Optionally handle reconfiguration if your pipeline supports it.
}
override fun isScreencast(): Boolean = false
override fun dispose() {
// Stop capture and free helper + EGL
try {
stopCapture()
} catch (_: Throwable) { }
surfaceTextureHelper?.dispose()
surfaceTextureHelper = null
capturerObserver = null
try {
eglBase?.release()
} catch (_: Throwable) { }
eglBase = null
}
}
Where to pass the SurfaceTexture to Snap Camera Kit
• Snap Camera Kit typically accepts either a SurfaceTexture or a Surface depending on the API. You should pass capturer.surfaceTexture directly when the Snap API accepts a SurfaceTexture. If it requires a Surface, only create one once and release it when done.
• Example usage in your setup code (after creating the capturer):
val capturer = CameraKitSurfaceCapturer.create(appContext)
val snapSession = /* create your Snap Session/processor as per Snap SDK */
// Wait until capturer.surfaceTexture is non-null (helper created in factory so usually immediate)
capturer.surfaceTexture?.let { st ->
// Example: if Snap API accepts a SurfaceTexture:
snapSession.setInputSurfaceTexture(st)
// If Snap expects a Surface instead (less ideal), create one once:
// val surface = Surface(st)
// snapSession.setInputSurface(surface)
// remember to release surface when done: surface.release()
} ?: run {
Log.e("CameraKitCapturer", "surfaceTexture is null; ensure helper created before connecting Snap")
}
Notes and best practices
• Do NOT create a new Surface repeatedly each time you read surface — that leaks native resources. Create one Surface once if your Snap API absolutely requires it, and release it during dispose().
• SurfaceTextureHelper.create(threadName, eglContext, alignTimestamps) creates and manages its own background thread. You do not pass a Handler.
• The surfaceTexture becomes available as soon as the helper is created. If you create the helper asynchronously, wait for it before connecting Snap.
• Keep the startListening callback very lightweight — forward frames immediately. Any heavy processing should be done off the helper thread (e.g., use a bounded worker pool) or, ideally, handled by Snap’s own GL pipeline if you can attach the SurfaceTexture directly to Snap.
• Start/stop capture according to Activity/Compose lifecycle (e.g., start in onResume/start of composable, stop in onPause/onDispose) to reduce CPU while backgrounded.
• If you see very high CPU, lower resolution/fps and confirm Snap rendering is HW accelerated (GL) rather than CPU-bound.
If you want, I can:
• Provide a short Compose factory replacement that wires this capturer into LiveKit’s createVideoTrack(...) flow and shows where to publish, or
• Show how to safely create a single Surface from the surfaceTexture and release it in dispose() if your Snap API requires a Surface.
Which would you prefer?steep-fireman-8880
03/02/2026, 3:48 PMImageProcessor.Output.Purpose
enum class Purpose {
PREVIEW,
RECORDING,
SNAPSHOT
}tall-airline-16756
03/02/2026, 3:48 PMprocessor.connectOutput(LiveKitOutput(surfaceTexture), ImageProcessor.Output.Purpose.PREVIEW)
(or the equivalent in your Snap API)
Notes
• Use RECORDING if you need frames optimized for high-quality recording (higher bitrate/resolution, different buffering) rather than real-time streaming.
• Use SNAPSHOT only for single-frame captures (photos).
• If you notice quality or latency issues, you can tune processor settings (resolution, fps) but keep the purpose as PREVIEW for the live track.
If you want, I can show the exact placement in your capturer wiring or a small code snippet that connects the processor output to LiveKit with PREVIEW and manages lifecycle cleanup.