LocalCustomVideoTrack to publish video generated by your app to the channel. The division of labor is fixed:
- You produce the frames: continuously provide unencoded raw YUV (I420) data; no encoding on your side.
- The SDK handles encoding and transport: it encodes and publishes according to the preset, and remote users subscribe and play it like any other video track.
- Publishing whiteboards / canvases / custom-drawn content
- Re-publishing video decoded by a local player
- Output processed by third-party SDKs (beauty filters, AR, AI generation)
- Frame data from non-standard capture devices (external capture cards, USB devices)
1. Overall flow
- Publish only after joining (before joining,
publishLocalVideocalls backonFail(102202): channel not started). - Feed frames only after publishing succeeds. While the track isn’t published,
inputDatais silently dropped without any notice—this is the most common reason “remote users can’t see the video”. - Audience users can’t publish (
onFail(102207)). Check withrtcEngine.isAudience()first.
2. Choose a preset
The preset determines the encoding resolution, frame rate, bitrate, and track description (desc). For all fields, see Custom video preset.
- Mostly static content (whiteboards, documents, slides): 5–10 fps is enough; prioritize resolution (text clarity depends on resolution, not frame rate).
- Dynamic content (re-published video, game footage): use 15–25 fps and raise the bitrate accordingly, or motion will look noticeably blurry.
- Width and height must be even (I420 chroma planes are
width/2 × height/2).
3. Get the track and publish
- The SDK caches it as a singleton. Calling
getLocalCustomVideoTrack(preOpt)again returns the same object and overwrites the old value with the newpreOpt. So don’t alternate frames between twodescvalues to simulate “two custom tracks”; only one preset should be in effect at a time. - If you override
descwithPublishCustomOptions(desc = ...)when publishing, the SDK writes it back topreOpt.publish.desc, andinputDataautomatically locates the track by the latestdesc. You don’t need to do anything extra.
4. Prepare frame data (key)
inputData accepts only tightly packed I420. If you don’t follow the conventions in this section, remote users see corrupted video, misalignment, or green edges.
4.1 Memory layout
stride parameters as tightly packed values:
⚠️ The SDK currently computes plane offsets assuming a tightly packed layout; row strides with padding are not used for addressing. If upstream data has padding on each row (for example, Camera2’s rowStride > width), first copy the valid pixels row by row into a tightly packed array, then feed it in.
If the data length is insufficient, the SDK throws IllegalArgumentException("Invalid I420 size: ...") before encoding, which helps you quickly locate layout problems.
4.2 Generate I420 from a Bitmap / Canvas
Whiteboards and custom-drawn content are usually drawn to aBitmap (ARGB_8888) first and then converted to I420:
The above is a pure Kotlin implementation that’s easy to understand and self-test, but a per-pixel loop at 1080p is noticeably expensive on low- and mid-range devices. In production, use libyuv (ARGBToI420) or a GPU approach instead; you can also downscale to 720p before publishing. Although the SDK bundles libyuv internally, it doesn’t expose the conversion APIs, so you need to add it to your app yourself.
4.3 Generate I420 from YUV_420_888
If the data comes fromImageReader / third-party capture (YUV_420_888), handle three things before feeding it in:
rowStridemay be larger thanwidth→ copy row by row withwidth, removing the padding at the end of each row.pixelStridemay be 2 (semi-planar NV12/NV21 form) → sample by stride and split into separate U and V planes.- The U/V order must be I420 (U first, then V); NV21’s VU order needs to be swapped.
Android420ToI420 directly; hand-written loops easily go wrong with stride/pixelStride combinations.
5. Feed frames continuously
Threading and pacing:
- Don’t feed frames on the main thread.
inputDatais a synchronous call that copies, aligns/scales as needed, and submits for encoding; per-frame cost at 1080p is not negligible. Use a dedicatedHandlerThread. - Throttle to the preset frame rate. Feeding faster than
maxFpsdoesn’t improve quality; it only wastes CPU and memory bandwidth. - Keep feeding frames even for static content. If you stop feeding, remote users see the last frame frozen. If the image doesn’t change for a long time, keep sending the same frame at a minimal frame rate (for example, 1–2 fps) to keep the stream alive.
- Reuse the output array. Once
inputDatareturns, the data has been copied internally, so you can reuse the sameByteArrayimmediately and avoid GC jitter from allocating a new object per frame.
6. Stop and clean up
- Stop feeding frames first, then unpublish. If you reverse the order, a few frames are dropped (harmless, but the logs show invalid calls).
- As with the camera, when you
publish/unPublishin rapid succession, intermediate calls that get merged may not call back; rely on the callback of the last call or the final state. leave()releases the media streaming engine. The track instance still exists but its publishing state is no longer valid—after rejoining, you must callpublishLocalVideoagain.releaseSDK()also clears the local track cache; afterward you need to callgetLocalCustomVideoTrackagain.
Local preview:LocalCustomVideoTrackinherits rendering methods such asaddPlayView, but the SDK doesn’t echo frames fed throughinputDatato these views (local echo works only for the camera track). For a local preview of a custom track, display your own data source directly (for example, the whiteboard View itself); you don’t need to add render views to this track.
7. Complete example
A minimal wrapper that publishes aBitmap at a fixed frame rate:
8. Troubleshooting table
Related docs
- LocalCustomVideoTrack: full API and parameter definitions
- Custom video preset:
PreOptionCustomVideofields and built-in presets - RTCEngine:
getLocalCustomVideoTrack/publishLocalVideo/unPublishLocalVideo - Quickstart: the minimal flow for joining, publishing, and subscribing