Skip to main content
When the built-in camera and screen sharing capture don’t meet your needs, you can use 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.
Typical scenarios:
  • 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

Three ordering constraints you must follow:
  1. Publish only after joining (before joining, publishLocalVideo calls back onFail(102202): channel not started).
  2. Feed frames only after publishing succeeds. While the track isn’t published, inputData is silently dropped without any notice—this is the most common reason “remote users can’t see the video”.
  3. Audience users can’t publish (onFail(102207)). Check with rtcEngine.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.
To customize parameters, construct it directly. Keep the resolution the same on the capture and publish sides to avoid extra scaling in the SDK:
Tips for choosing parameters:
  • 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

Two things to note about the track instance:
  • The SDK caches it as a singleton. Calling getLocalCustomVideoTrack(preOpt) again returns the same object and overwrites the old value with the new preOpt. So don’t alternate frames between two desc values to simulate “two custom tracks”; only one preset should be in effect at a time.
  • If you override desc with PublishCustomOptions(desc = ...) when publishing, the SDK writes it back to preOpt.publish.desc, and inputData automatically locates the track by the latest desc. 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

The three segments must be contiguous with no gaps, and pass the 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 a Bitmap (ARGB_8888) first and then converted to I420:
Usage:
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 from ImageReader / third-party capture (YUV_420_888), handle three things before feeding it in:
  • rowStride may be larger than width → copy row by row with width, removing the padding at the end of each row.
  • pixelStride may 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.
Here too we recommend using libyuv’s Android420ToI420 directly; hand-written loops easily go wrong with stride/pixelStride combinations.

5. Feed frames continuously

Key parameters: Threading and pacing:
  • Don’t feed frames on the main thread. inputData is a synchronous call that copies, aligns/scales as needed, and submits for encoding; per-frame cost at 1080p is not negligible. Use a dedicated HandlerThread.
  • Throttle to the preset frame rate. Feeding faster than maxFps doesn’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 inputData returns, the data has been copied internally, so you can reuse the same ByteArray immediately 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 / unPublish in 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 call publishLocalVideo again.
  • releaseSDK() also clears the local track cache; afterward you need to call getLocalCustomVideoTrack again.
Local preview: LocalCustomVideoTrack inherits rendering methods such as addPlayView, but the SDK doesn’t echo frames fed through inputData to 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 a Bitmap at a fixed frame rate:
Usage:

8. Troubleshooting table