Skip to content

Commit 303cdd5

Browse files
authored
feat(rtc): expose pre-encoded video publishing in Python (#838)
1 parent 875ba81 commit 303cdd5

6 files changed

Lines changed: 407 additions & 2 deletions

File tree

‎.github/workflows/build-rtc.yml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
3232
with:
3333
submodules: true
34+
repository: ${{ github.event.pull_request.head.repo.full_name }}
3435
ref: ${{ github.event.pull_request.head.ref }}
3536

3637
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
@@ -48,7 +49,13 @@ jobs:
4849
- name: generate python stubs
4950
run: ./generate_proto.sh
5051

52+
# Fork PR tokens cannot push generated changes. Verify them read-only.
53+
- name: Check generated stubs on forks
54+
if: github.event.pull_request.head.repo.full_name != github.repository
55+
run: git diff --exit-code -- livekit/rtc/_proto
56+
5157
- name: Add changes
58+
if: github.event.pull_request.head.repo.full_name == github.repository
5259
uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 # v11
5360
with:
5461
add: '["livekit-rtc/"]'

‎livekit-rtc/README.md‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,48 @@ Python SDK to integrate LiveKit's real-time video, audio, and data capabilities
44

55
See https://docs.livekit.io/ for more information.
66

7+
## Publishing pre-encoded video
8+
9+
Use `EncodedVideoSource` when an upstream encoder already provides compressed
10+
frames. The source uses the same native passthrough encoder as the Rust SDK;
11+
Python does not decode or re-encode the video.
12+
13+
```python
14+
from livekit import rtc
15+
16+
source = rtc.EncodedVideoSource(width=640, height=360)
17+
track = rtc.LocalVideoTrack.create_video_track("camera", source)
18+
await room.local_participant.publish_track(
19+
track,
20+
rtc.TrackPublishOptions(
21+
video_codec=rtc.VideoCodec.VP9,
22+
video_encoder=rtc.VideoEncoderBackend.ENCODER_BACKEND_PRE_ENCODED,
23+
simulcast=False,
24+
),
25+
)
26+
# Supply each complete encoded frame at its playback time.
27+
source.capture_frame(rtc.EncodedVideoFrame(
28+
data=encoded_vp9_frame,
29+
width=640,
30+
height=360,
31+
codec=rtc.VideoCodec.VP9,
32+
frame_type=rtc.EncodedFrameType.ENCODED_FRAME_KEY,
33+
timestamp_us=capture_timestamp_us,
34+
))
35+
feedback = source.take_feedback()
36+
# Forward feedback.keyframe_requested and feedback.rate_control to your encoder.
37+
# At shutdown, unpublish the track and release the source:
38+
await room.local_participant.unpublish_track(track.sid)
39+
await source.aclose()
40+
```
41+
42+
Supported codecs are H.264, H.265, VP8, VP9 and AV1, subject to receiver support.
43+
Submit complete access units (Annex B for H.264/H.265), not WebM/MP4 container
44+
bytes or RTP packets. A container must first be demuxed; demuxing extracts
45+
compressed frames and does not reconstruct pixels. The codec must match the
46+
publication and stay fixed, and simulcast must be disabled.
47+
48+
The caller controls pacing and handles keyframe and bitrate feedback. In
49+
particular, a prerecorded file cannot generate a new keyframe on demand for a
50+
late subscriber or after packet loss. WebM's auxiliary alpha data is not part of
51+
the VP9 color access unit and is not transported by this API.

‎livekit-rtc/livekit/rtc/__init__.py‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
SimulateScenarioKind,
3636
TrackPublishOptions,
3737
VideoEncoding,
38+
VideoEncoderBackend,
3839
)
3940
from ._proto.track_pb2 import (
4041
FrameMetadataFeature,
@@ -43,7 +44,13 @@
4344
TrackSource,
4445
ParticipantTrackPermission,
4546
)
46-
from ._proto.video_frame_pb2 import FrameMetadata, VideoBufferType, VideoCodec, VideoRotation
47+
from ._proto.video_frame_pb2 import (
48+
EncodedFrameType,
49+
FrameMetadata,
50+
VideoBufferType,
51+
VideoCodec,
52+
VideoRotation,
53+
)
4754
from ._proto.track_publication_pb2 import VideoQuality
4855
from .audio_frame import AudioFrame
4956
from .audio_source import AudioSource
@@ -95,6 +102,12 @@
95102
from .video_frame import (
96103
VideoFrame,
97104
)
105+
from .encoded_video import (
106+
EncodedVideoFrame,
107+
EncodedVideoSource,
108+
EncodedVideoSourceFeedback,
109+
EncodedRateControl,
110+
)
98111
from .video_source import VideoSource
99112
from .video_stream import VideoFrameEvent, VideoStream
100113
from .audio_resampler import AudioResampler, AudioResamplerQuality
@@ -138,6 +151,12 @@
138151
from .frame_processor import FrameProcessor
139152

140153
__all__ = [
154+
"EncodedFrameType",
155+
"EncodedVideoFrame",
156+
"EncodedVideoSource",
157+
"EncodedVideoSourceFeedback",
158+
"EncodedRateControl",
159+
"VideoEncoderBackend",
141160
"ConnectionQuality",
142161
"ConnectionState",
143162
"DataPacketKind",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Copyright 2026 LiveKit, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from dataclasses import dataclass
18+
19+
from ._ffi_client import FfiClient, FfiHandle
20+
from ._proto import ffi_pb2 as proto_ffi
21+
from ._proto import video_frame_pb2 as proto_video
22+
from ._utils import get_address
23+
24+
25+
@dataclass(frozen=True)
26+
class EncodedVideoFrame:
27+
"""One complete encoded access unit, without a container or RTP headers.
28+
29+
``data`` must contain a single frame in ``codec`` (Annex B for H.264/H.265).
30+
``timestamp_us`` is its capture time in microseconds, increasing along the
31+
stream. The codec must match the publication and remain fixed for its lifetime.
32+
"""
33+
34+
data: bytes
35+
width: int
36+
height: int
37+
codec: proto_video.VideoCodec.ValueType
38+
frame_type: proto_video.EncodedFrameType.ValueType
39+
timestamp_us: int
40+
metadata: proto_video.FrameMetadata | None = None
41+
42+
def __post_init__(self) -> None:
43+
if not isinstance(self.data, bytes):
44+
raise TypeError("encoded frame data must be bytes")
45+
if not self.data:
46+
raise ValueError("encoded frame data must not be empty")
47+
if self.width <= 0 or self.height <= 0:
48+
raise ValueError("encoded frame dimensions must be positive")
49+
50+
51+
@dataclass(frozen=True)
52+
class EncodedRateControl:
53+
"""The latest bitrate and frame-rate targets requested by WebRTC."""
54+
55+
target_bitrate_bps: int
56+
framerate_fps: float
57+
58+
59+
@dataclass(frozen=True)
60+
class EncodedVideoSourceFeedback:
61+
"""Encoder feedback consumed since the previous call to ``take_feedback``."""
62+
63+
keyframe_requested: bool
64+
rate_control: EncodedRateControl | None
65+
66+
67+
class EncodedVideoSource:
68+
"""Publish pre-encoded video without decoding or re-encoding it.
69+
70+
Create a ``LocalVideoTrack`` with this source and publish it with a matching
71+
``video_codec``, ``video_encoder=ENCODER_BACKEND_PRE_ENCODED`` and
72+
``simulcast=False``. Feed complete access units at the intended playback rate;
73+
this source does not pace or buffer a file for playback.
74+
75+
Poll ``take_feedback`` and forward keyframe and rate-control requests to the
76+
upstream encoder. A demuxer alone cannot produce a new keyframe or adapt the
77+
encoded bitrate. Call ``capture_frame`` from a single producer.
78+
"""
79+
80+
def __init__(self, width: int, height: int) -> None:
81+
"""Create a source with the initial encoded frame dimensions."""
82+
if width <= 0 or height <= 0:
83+
raise ValueError("encoded source dimensions must be positive")
84+
req = proto_ffi.FfiRequest()
85+
req.new_video_source.type = proto_video.VideoSourceType.VIDEO_SOURCE_ENCODED
86+
req.new_video_source.resolution.width = width
87+
req.new_video_source.resolution.height = height
88+
resp = FfiClient.instance.request(req)
89+
self._ffi_handle = FfiHandle(resp.new_video_source.source.handle.id)
90+
91+
def capture_frame(self, frame: EncodedVideoFrame) -> bool:
92+
"""Submit a frame; return whether the native source accepted it.
93+
94+
The native implementation copies the payload before this call returns.
95+
Acceptance does not guarantee delivery to subscribers.
96+
"""
97+
req = proto_ffi.FfiRequest()
98+
capture = req.capture_encoded_video_frame
99+
capture.source_handle = self._ffi_handle.handle
100+
capture.buffer.data_ptr = get_address(frame.data)
101+
capture.buffer.data_len = len(frame.data)
102+
capture.codec = frame.codec
103+
capture.frame_type = frame.frame_type
104+
capture.width = frame.width
105+
capture.height = frame.height
106+
capture.timestamp_us = frame.timestamp_us
107+
if frame.metadata is not None:
108+
capture.metadata.CopyFrom(frame.metadata)
109+
response: proto_ffi.FfiResponse = FfiClient.instance.request(req)
110+
return response.capture_encoded_video_frame.accepted
111+
112+
def take_feedback(self) -> EncodedVideoSourceFeedback:
113+
"""Consume pending feedback, including requests from late subscribers."""
114+
req = proto_ffi.FfiRequest()
115+
req.take_encoded_video_source_feedback.source_handle = self._ffi_handle.handle
116+
feedback = FfiClient.instance.request(req).take_encoded_video_source_feedback
117+
rate_control = None
118+
if feedback.HasField("rate_control"):
119+
rate_control = EncodedRateControl(
120+
target_bitrate_bps=feedback.rate_control.target_bitrate_bps,
121+
framerate_fps=feedback.rate_control.framerate_fps,
122+
)
123+
return EncodedVideoSourceFeedback(feedback.keyframe_requested, rate_control)
124+
125+
async def aclose(self) -> None:
126+
"""Release the native source handle."""
127+
self._ffi_handle.dispose()

‎livekit-rtc/livekit/rtc/track.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .audio_stream import AudioStream
2727
from .room import Room
2828
from .video_source import VideoSource
29+
from .encoded_video import EncodedVideoSource
2930
from .platform_audio import PlatformAudioSource
3031

3132

@@ -213,7 +214,9 @@ def __init__(self, info: proto_track.OwnedTrack):
213214
super().__init__(info)
214215

215216
@staticmethod
216-
def create_video_track(name: str, source: "VideoSource") -> "LocalVideoTrack":
217+
def create_video_track(
218+
name: str, source: "Union[VideoSource, EncodedVideoSource]"
219+
) -> "LocalVideoTrack":
217220
req = proto_ffi.FfiRequest()
218221
req.create_video_track.name = name
219222
req.create_video_track.source_handle = source._ffi_handle.handle

0 commit comments

Comments
 (0)