Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/build-rtc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
submodules: true
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.ref }}

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

# Fork PR tokens cannot push generated changes. Verify them read-only.
- name: Check generated stubs on forks
if: github.event.pull_request.head.repo.full_name != github.repository
run: git diff --exit-code -- livekit/rtc/_proto

- name: Add changes
if: github.event.pull_request.head.repo.full_name == github.repository
uses: EndBug/add-and-commit@cc9c08ba6c8df3b93a8f2db63e89b98368ae2ae8 # v11
with:
add: '["livekit-rtc/"]'
Expand Down
45 changes: 45 additions & 0 deletions livekit-rtc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,48 @@ Python SDK to integrate LiveKit's real-time video, audio, and data capabilities

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

## Publishing pre-encoded video

Use `EncodedVideoSource` when an upstream encoder already provides compressed
frames. The source uses the same native passthrough encoder as the Rust SDK;
Python does not decode or re-encode the video.

```python
from livekit import rtc

source = rtc.EncodedVideoSource(width=640, height=360)
track = rtc.LocalVideoTrack.create_video_track("camera", source)
await room.local_participant.publish_track(
track,
rtc.TrackPublishOptions(
video_codec=rtc.VideoCodec.VP9,
video_encoder=rtc.VideoEncoderBackend.ENCODER_BACKEND_PRE_ENCODED,
simulcast=False,
),
)
# Supply each complete encoded frame at its playback time.
source.capture_frame(rtc.EncodedVideoFrame(
data=encoded_vp9_frame,
width=640,
height=360,
codec=rtc.VideoCodec.VP9,
frame_type=rtc.EncodedFrameType.ENCODED_FRAME_KEY,
timestamp_us=capture_timestamp_us,
))
feedback = source.take_feedback()
# Forward feedback.keyframe_requested and feedback.rate_control to your encoder.
# At shutdown, unpublish the track and release the source:
await room.local_participant.unpublish_track(track.sid)
await source.aclose()
```

Supported codecs are H.264, H.265, VP8, VP9 and AV1, subject to receiver support.
Submit complete access units (Annex B for H.264/H.265), not WebM/MP4 container
bytes or RTP packets. A container must first be demuxed; demuxing extracts
compressed frames and does not reconstruct pixels. The codec must match the
publication and stay fixed, and simulcast must be disabled.

The caller controls pacing and handles keyframe and bitrate feedback. In
particular, a prerecorded file cannot generate a new keyframe on demand for a
late subscriber or after packet loss. WebM's auxiliary alpha data is not part of
the VP9 color access unit and is not transported by this API.
21 changes: 20 additions & 1 deletion livekit-rtc/livekit/rtc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
SimulateScenarioKind,
TrackPublishOptions,
VideoEncoding,
VideoEncoderBackend,
)
from ._proto.track_pb2 import (
FrameMetadataFeature,
Expand All @@ -43,7 +44,13 @@
TrackSource,
ParticipantTrackPermission,
)
from ._proto.video_frame_pb2 import FrameMetadata, VideoBufferType, VideoCodec, VideoRotation
from ._proto.video_frame_pb2 import (
EncodedFrameType,
FrameMetadata,
VideoBufferType,
VideoCodec,
VideoRotation,
)
from ._proto.track_publication_pb2 import VideoQuality
from .audio_frame import AudioFrame
from .audio_source import AudioSource
Expand Down Expand Up @@ -95,6 +102,12 @@
from .video_frame import (
VideoFrame,
)
from .encoded_video import (
EncodedVideoFrame,
EncodedVideoSource,
EncodedVideoSourceFeedback,
EncodedRateControl,
)
from .video_source import VideoSource
from .video_stream import VideoFrameEvent, VideoStream
from .audio_resampler import AudioResampler, AudioResamplerQuality
Expand Down Expand Up @@ -138,6 +151,12 @@
from .frame_processor import FrameProcessor

__all__ = [
"EncodedFrameType",
"EncodedVideoFrame",
"EncodedVideoSource",
"EncodedVideoSourceFeedback",
"EncodedRateControl",
"VideoEncoderBackend",
"ConnectionQuality",
"ConnectionState",
"DataPacketKind",
Expand Down
127 changes: 127 additions & 0 deletions livekit-rtc/livekit/rtc/encoded_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Copyright 2026 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

from dataclasses import dataclass

from ._ffi_client import FfiClient, FfiHandle
from ._proto import ffi_pb2 as proto_ffi
from ._proto import video_frame_pb2 as proto_video
from ._utils import get_address


@dataclass(frozen=True)
class EncodedVideoFrame:
"""One complete encoded access unit, without a container or RTP headers.

``data`` must contain a single frame in ``codec`` (Annex B for H.264/H.265).
``timestamp_us`` is its capture time in microseconds, increasing along the
stream. The codec must match the publication and remain fixed for its lifetime.
"""

data: bytes
width: int
height: int
codec: proto_video.VideoCodec.ValueType
frame_type: proto_video.EncodedFrameType.ValueType
timestamp_us: int
metadata: proto_video.FrameMetadata | None = None

def __post_init__(self) -> None:
if not isinstance(self.data, bytes):
raise TypeError("encoded frame data must be bytes")
if not self.data:
raise ValueError("encoded frame data must not be empty")
if self.width <= 0 or self.height <= 0:
raise ValueError("encoded frame dimensions must be positive")


@dataclass(frozen=True)
class EncodedRateControl:
"""The latest bitrate and frame-rate targets requested by WebRTC."""

target_bitrate_bps: int
framerate_fps: float


@dataclass(frozen=True)
class EncodedVideoSourceFeedback:
"""Encoder feedback consumed since the previous call to ``take_feedback``."""

keyframe_requested: bool
rate_control: EncodedRateControl | None


class EncodedVideoSource:
"""Publish pre-encoded video without decoding or re-encoding it.

Create a ``LocalVideoTrack`` with this source and publish it with a matching
``video_codec``, ``video_encoder=ENCODER_BACKEND_PRE_ENCODED`` and
``simulcast=False``. Feed complete access units at the intended playback rate;
this source does not pace or buffer a file for playback.

Poll ``take_feedback`` and forward keyframe and rate-control requests to the
upstream encoder. A demuxer alone cannot produce a new keyframe or adapt the
encoded bitrate. Call ``capture_frame`` from a single producer.
"""

def __init__(self, width: int, height: int) -> None:
"""Create a source with the initial encoded frame dimensions."""
if width <= 0 or height <= 0:
raise ValueError("encoded source dimensions must be positive")
req = proto_ffi.FfiRequest()
req.new_video_source.type = proto_video.VideoSourceType.VIDEO_SOURCE_ENCODED
req.new_video_source.resolution.width = width
req.new_video_source.resolution.height = height
resp = FfiClient.instance.request(req)
self._ffi_handle = FfiHandle(resp.new_video_source.source.handle.id)

def capture_frame(self, frame: EncodedVideoFrame) -> bool:
"""Submit a frame; return whether the native source accepted it.

The native implementation copies the payload before this call returns.
Acceptance does not guarantee delivery to subscribers.
"""
req = proto_ffi.FfiRequest()
capture = req.capture_encoded_video_frame
capture.source_handle = self._ffi_handle.handle
capture.buffer.data_ptr = get_address(frame.data)
capture.buffer.data_len = len(frame.data)
capture.codec = frame.codec
capture.frame_type = frame.frame_type
capture.width = frame.width
capture.height = frame.height
capture.timestamp_us = frame.timestamp_us
if frame.metadata is not None:
capture.metadata.CopyFrom(frame.metadata)
response: proto_ffi.FfiResponse = FfiClient.instance.request(req)
return response.capture_encoded_video_frame.accepted

def take_feedback(self) -> EncodedVideoSourceFeedback:
"""Consume pending feedback, including requests from late subscribers."""
req = proto_ffi.FfiRequest()
req.take_encoded_video_source_feedback.source_handle = self._ffi_handle.handle
feedback = FfiClient.instance.request(req).take_encoded_video_source_feedback
rate_control = None
if feedback.HasField("rate_control"):
rate_control = EncodedRateControl(
target_bitrate_bps=feedback.rate_control.target_bitrate_bps,
framerate_fps=feedback.rate_control.framerate_fps,
)
return EncodedVideoSourceFeedback(feedback.keyframe_requested, rate_control)

async def aclose(self) -> None:
"""Release the native source handle."""
self._ffi_handle.dispose()
5 changes: 4 additions & 1 deletion livekit-rtc/livekit/rtc/track.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .audio_stream import AudioStream
from .room import Room
from .video_source import VideoSource
from .encoded_video import EncodedVideoSource
from .platform_audio import PlatformAudioSource


Expand Down Expand Up @@ -213,7 +214,9 @@ def __init__(self, info: proto_track.OwnedTrack):
super().__init__(info)

@staticmethod
def create_video_track(name: str, source: "VideoSource") -> "LocalVideoTrack":
def create_video_track(
name: str, source: "Union[VideoSource, EncodedVideoSource]"
) -> "LocalVideoTrack":
req = proto_ffi.FfiRequest()
req.create_video_track.name = name
req.create_video_track.source_handle = source._ffi_handle.handle
Expand Down
Loading
Loading