Skip to content

Add custom video encoder factory support via SDK-owned protocols - #1109

Open
hiroshihorie wants to merge 12 commits into
mainfrom
hiroshi/video-encoder-factory-wrapper
Open

Add custom video encoder factory support via SDK-owned protocols#1109
hiroshihorie wants to merge 12 commits into
mainfrom
hiroshi/video-encoder-factory-wrapper

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Sep 4, 2026

Copy link
Copy Markdown
Member

Adds LiveKitSDK.set(videoEncoderFactory:) with SDK-owned VideoEncoderFactory / VideoEncoder protocols, so apps can supply their own encoder (e.g. a software H264 encoder) without LiveKitWebRTC types in the public API. Supersedes #1082, thanks @sergeyphi for the PR and the detailed testing notes.

Addresses the requests from that thread:

  • VideoFrame.rtpTimestamp carries the 90kHz RTP timestamp WebRTC assigns before encode, and EncodedVideoFrame.rtpTimestamp must be copied from it.
  • VideoBuffer.toI420() / VideoFrame.toI420() give access to I420 planes for NV12 camera frames. supportsNativeHandle only gates crop/scale in WebRTC, so this is the route to planar data.
  • VideoEncoderSettings has a public init for testing.
  • Encode timing and NTP fields were requested but are intentionally not exposed: FrameEncodeMetadataWriter overwrites them for every frame it matches by RTP timestamp, so encoder-provided values never reach stats or the wire.

Behavior notes, verified against the WebRTC source:

  • Custom factories can supply H264, H265 and AV1. VP8/VP9 are rejected at set time since their layer info cannot be bridged through the ObjC RTCCodecSpecificInfo types, and a generic info object makes the RTP packetizer abort.
  • Codec specific info is always built for the codec the encoder was created for, taking only the packetization mode from the frame. Frames without codecSpecificInfo follow the negotiated packetization-mode parameter, non interleaved when absent.
  • The built in VideoToolbox encoders remain the simulcast fallback, both for codecs the factory declines and when an encoder returns .fallbackSoftware.
  • resolutionAlignment is not honored yet due to a bug in the ObjC bridge (requested_resolution_alignment = resolutionAlignment > 0 ?: 1), and the bridge reports every encoder as hardware accelerated. Both documented, fork fixes to follow separately.

Swift only for now; ObjC encoders need a thin Swift shim. Compile verified on macOS and iOS Simulator, no runtime test yet.

Adds LiveKitSDK.set(videoEncoderFactory:) which accepts an implementation
of the new public VideoEncoderFactory and VideoEncoder protocols instead
of exposing LKRTCVideoEncoderFactory, keeping LiveKitWebRTC out of the
public API surface.

New public types mirror the WebRTC encoder interface: VideoCodecInfo,
VideoEncoderSettings, VideoEncoderQpThresholds, EncodedVideoFrame and
VideoEncoderStatus. Internal adapters bridge them to the RTC types, and
the custom factory is wrapped in the simulcast adapter the same way the
default factory is.

Known gaps: the optional factory surface (encoderSelector,
queryCodecSupport, implementations) is not bridged, and H265
codec-specific info falls back to generic on macOS because the prebuilt
macOS slice does not ship RTCCodecSpecificInfoH265.h.
WebRTC 150.7871.01 ships the H265 codec specific info header in the
macOS slice, so the generic fallback is no longer needed.
WebRTC assigns each frame an RTP timestamp in the 90kHz clock before it
hands the frame to an encoder, and an encoded frame has to carry that
same value back. VideoFrame only exposed the capture time in
nanoseconds, so a custom encoder had no way to read it.

Add rtpTimestamp to VideoFrame, populate it when converting from the
WebRTC type, and write it back when converting to the WebRTC type so a
frame that passes through a VideoProcessor keeps it. The existing
initializer stays as is and defaults the value to 0.
Frames arrive as NV12 CVPixelBuffers and I420VideoBuffer has no public
initializer, so an encoder that needs planar data had no way to get it.
Add toI420() on VideoBuffer and VideoFrame, which copies the pixel data
when the buffer is not already I420.

I420VideoBuffer now holds the WebRTC I420 buffer protocol rather than
its concrete class, since that is what the conversion returns, and gains
width and height so the planes can be read without consulting the frame.
Document the video buffer types and their accessors, which were public
but undocumented.
The adapter handed the encoder a new closure on every setCallback call
and captured the WebRTC block directly, so a cleared callback could
still be invoked from an encoder thread. The block now lives in a small
locked box, the encoder gets one stable forwarding closure, and
releasing the encoder clears the box first.

Frame types were built with compactMap, which silently shortened the
array and misaligned the per stream requests. They are mapped one to
one now, with anything unknown treated as a delta.

A nil qp landed on the native side as 0, which reads as a perfect
quantizer to the quality scaler, so it is now sent as -1 for unknown.
The generic codec info object is allocated once instead of per frame,
and supported codecs are computed once at factory construction.

A custom factory is now paired with the built in encoders as the
simulcast fallback, so an encoder reporting fallbackSoftware keeps the
stream alive. A frame with a buffer the SDK cannot map reports that same
status rather than an invalid parameter, which would drop every frame.
Fill in the pieces an encoder implementation needs and document the
public types that were missing docstrings.

VideoEncoderSettings gains a memberwise initializer so an encoder can be
driven from a test. EncodedVideoFrame can now report the encode start
and finish times and an NTP timestamp, which feed the encode time stats.
VideoEncoderStatus gains the two remaining WebRTC codes and prints a
readable name. VideoCodecInfo can resolve its SDP name to a VideoCodec.

Setting a factory that advertises no codecs is now rejected up front
rather than leaving video unpublishable, and the warning on the setter
explains that any use of the SDK initializes the peer connection factory.
WebRTC only consults supports_native_handle when deciding whether to
crop or scale a native frame for a simulcast layer. It never converts
frames to I420 before handing them to the encoder, so the docstring
now points encoders at toI420() instead.
WebRTC's frame encode metadata writer overwrites encode start and
finish times, NTP time and the timing flags for every encoded image it
matches to a source frame, and marks timing invalid otherwise. Values
set by a custom encoder never reach stats or the wire, so the fields
are removed rather than shipped as inert API.
The RTP packetizer for H264, VP8 and VP9 reads a typed video header
that only exists when the encoded image carries matching codec specific
info. A generic info object leaves the header empty and the packetizer
aborts on the first frame. The adapter now synthesizes non interleaved
H264 or H265 info from the codec the encoder was created for when a
frame has none, and set(videoEncoderFactory:) rejects factories that
advertise VP8 or VP9 since their layer info cannot be bridged yet.

Also documents that a custom factory only takes over the codecs it
lists, since the simulcast factory keeps advertising the built in ones.
WEBRTC_VIDEO_CODEC_TARGET_BITRATE_OVERSHOOT is 5, not -14. Adds the
positive NO_OUTPUT and OK_REQUEST_KEYFRAME codes an encode call may
return. Documents that the bridge always reports an alignment of 1 and
hardware acceleration, and that startEncode may run before setCallback.
The underlying conversion only handles NV12, 32BGRA and 32ARGB. Other
formats hit a debug assertion and return undefined pixel data in release
builds, so toI420() now returns nil for them as its docstring promises.
devin-ai-integration[bot]

This comment was marked as resolved.

The adapter forwarded whatever codec specific info an encoder returned.
An H264 encoder returning H265 info left the RTP video header empty and
the H264 packetizer aborted, the same path as the generic info case.
The info is now always built for the codec the encoder was created for,
taking only the packetization mode from the frame.

The nil default also hardcoded non interleaved packetization. It now
follows the packetization-mode parameter of the negotiated codec, so a
factory that advertises mode 0 gets single NAL unit packetization.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant