Skip to content

Commit 79480e8

Browse files
authored
Merge branch 'main' into duan/e2e-test-for-audio-video-pub-and-sub
2 parents f357ed3 + 9be4280 commit 79480e8

54 files changed

Lines changed: 5354 additions & 885 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# CODEOWNERS for python-sdks
2+
# These owners will be requested for review on all PRs
3+
4+
* @cloudwebrtc @lukasIO @xianshijing-lk

.github/workflows/tests.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,14 @@ jobs:
128128
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
129129
run: |
130130
source .test-venv/bin/activate
131-
pytest tests/ livekit-rtc/tests
131+
pytest tests/ livekit-rtc/tests/
132132
133133
- name: Run tests (Windows)
134134
if: runner.os == 'Windows'
135135
env:
136136
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
137137
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
138138
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
139-
run: .test-venv\Scripts\python.exe -m pytest tests/ livekit-rtc/tests
139+
run: .test-venv\Scripts\python.exe -m pytest tests/ livekit-rtc/tests/
140140
shell: pwsh
141141

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,20 @@ LiveKit is a dynamic realtime environment and calls can fail for various reasons
274274

275275
You may throw errors of the type `RpcError` with a string `message` in an RPC method handler and they will be received on the caller's side with the message intact. Other errors will not be transmitted and will instead arrive to the caller as `1500` ("Application Error"). Other built-in errors are detailed in `RpcError`.
276276

277+
## Hardware video codec support
278+
279+
The underlying Rust SDK ships with platform-specific hardware-accelerated encoders/decoders, These are used automatically when available and compatible with the runtime environment (OS, drivers, GPU, and codec).
280+
281+
| Platform | Codec(s) | Encoder | Decoder | Backend |
282+
| ------------------------------ | ---------- | ------- | ------- | -------------------------------------- |
283+
| macOS | H264, H265 ||| VideoToolbox |
284+
| Linux (AMD GPU) | H264 || | VAAPI |
285+
| Linux x64 (NVIDIA GPU) | H264, H265 ||| NVENC / NVDEC (NVIDIA Video Codec SDK) |
286+
287+
Software encoders (libvpx for VP8/VP9, libaom for AV1, OpenH264 for H264) are used as a fallback when hardware acceleration is not available.
288+
289+
> **Note:** Availability depends on the specific machine configuration, including GPU model, driver support, and runtime environment.
290+
277291
## Examples
278292

279293
- [Facelandmark](https://github.com/livekit/python-sdks/tree/main/examples/face_landmark): Use mediapipe to detect face landmarks (eyes, nose ...)

examples/local_video/README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Local Video Packet-Trailer Examples
2+
3+
These desktop examples show how to publish a local camera track and attach packet-trailer frame metadata from Python.
4+
5+
## Setup
6+
7+
```bash
8+
export LIVEKIT_URL=https://your-livekit-host
9+
export LIVEKIT_API_KEY=your-api-key
10+
export LIVEKIT_API_SECRET=your-api-secret
11+
```
12+
13+
Run these examples from the repository root with `uv run --project examples/local_video`.
14+
LiveKit connection settings can also be passed with `--url`, `--api-key`, and `--api-secret`.
15+
16+
## Publisher
17+
18+
Publish a camera track:
19+
20+
```bash
21+
uv run --project examples/local_video python examples/local_video/publisher.py \
22+
--room-name demo \
23+
--identity py-cam \
24+
--camera-index 0
25+
```
26+
27+
Attach packet-trailer metadata:
28+
29+
```bash
30+
uv run --project examples/local_video python examples/local_video/publisher.py \
31+
--room-name demo \
32+
--identity py-cam \
33+
--attach-timestamp \
34+
--attach-frame-id
35+
```
36+
37+
Useful flags:
38+
39+
- `--camera-index <n>`: OpenCV camera index to publish.
40+
- `--width <px>` / `--height <px>`: requested camera resolution.
41+
- `--fps <n>`: requested publish frame rate.
42+
- `--attach-timestamp`: attach wall-clock microseconds since Unix epoch as `FrameMetadata.user_timestamp`.
43+
- `--attach-frame-id`: attach a monotonically increasing `FrameMetadata.frame_id`.
44+
45+
## Subscriber
46+
47+
Render the first video track in the room:
48+
49+
```bash
50+
uv run --project examples/local_video python examples/local_video/subscriber.py \
51+
--room-name demo \
52+
--identity py-viewer
53+
```
54+
55+
Display packet-trailer metadata over the video:
56+
57+
```bash
58+
uv run --project examples/local_video python examples/local_video/subscriber.py \
59+
--room-name demo \
60+
--identity py-viewer \
61+
--display-timestamp
62+
```
63+
64+
Use `--participant py-cam` to only subscribe to video from a specific participant identity.
65+
The subscriber keeps running across unpublish/republish cycles and will attach to the next matching video track.
66+
67+
Press `q` in the video window or `Ctrl+C` in the terminal to exit.

examples/local_video/publisher.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import asyncio
5+
import logging
6+
import os
7+
import signal
8+
import time
9+
10+
import numpy as np
11+
from livekit import api, rtc
12+
13+
try:
14+
import cv2
15+
except ImportError as exc:
16+
raise SystemExit(
17+
"opencv-python is required to run this example. "
18+
"Run it with `uv run --project examples/local_video python examples/local_video/publisher.py`."
19+
) from exc
20+
21+
22+
def parse_args() -> argparse.Namespace:
23+
parser = argparse.ArgumentParser(
24+
description="Publish a local camera track with optional packet-trailer metadata.",
25+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
26+
)
27+
parser.add_argument("--camera-index", type=int, default=0, help="OpenCV camera index to use")
28+
parser.add_argument("--width", type=int, default=1280, help="Requested capture width")
29+
parser.add_argument("--height", type=int, default=720, help="Requested capture height")
30+
parser.add_argument("--fps", type=float, default=30.0, help="Requested publish frame rate")
31+
parser.add_argument("--room-name", default="video-room", help="LiveKit room name")
32+
parser.add_argument("--identity", default="python-camera-pub", help="Participant identity")
33+
parser.add_argument("--url", help="LiveKit server URL; falls back to LIVEKIT_URL")
34+
parser.add_argument("--api-key", help="LiveKit API key; falls back to LIVEKIT_API_KEY")
35+
parser.add_argument(
36+
"--api-secret",
37+
help="LiveKit API secret; falls back to LIVEKIT_API_SECRET",
38+
)
39+
parser.add_argument(
40+
"--attach-timestamp",
41+
action="store_true",
42+
help="Attach wall-clock microseconds in FrameMetadata.user_timestamp",
43+
)
44+
parser.add_argument(
45+
"--attach-frame-id",
46+
action="store_true",
47+
help="Attach a monotonically increasing FrameMetadata.frame_id",
48+
)
49+
return parser.parse_args()
50+
51+
52+
def _require_connection(args: argparse.Namespace) -> tuple[str, str, str]:
53+
url = args.url or os.getenv("LIVEKIT_URL")
54+
api_key = args.api_key or os.getenv("LIVEKIT_API_KEY")
55+
api_secret = args.api_secret or os.getenv("LIVEKIT_API_SECRET")
56+
57+
missing = [
58+
name
59+
for name, value in (
60+
("LIVEKIT_URL or --url", url),
61+
("LIVEKIT_API_KEY or --api-key", api_key),
62+
("LIVEKIT_API_SECRET or --api-secret", api_secret),
63+
)
64+
if not value
65+
]
66+
if missing:
67+
raise RuntimeError(f"Missing LiveKit connection settings: {', '.join(missing)}")
68+
69+
return url, api_key, api_secret
70+
71+
72+
def _create_token(args: argparse.Namespace, api_key: str, api_secret: str) -> str:
73+
return (
74+
api.AccessToken(api_key, api_secret)
75+
.with_identity(args.identity)
76+
.with_name(args.identity)
77+
.with_grants(
78+
api.VideoGrants(
79+
room_join=True,
80+
room=args.room_name,
81+
can_publish=True,
82+
can_subscribe=False,
83+
)
84+
)
85+
.to_jwt()
86+
)
87+
88+
89+
def _open_camera(args: argparse.Namespace) -> tuple[cv2.VideoCapture, int, int]:
90+
if args.fps <= 0:
91+
raise RuntimeError("--fps must be greater than zero")
92+
93+
capture = cv2.VideoCapture(args.camera_index)
94+
if not capture.isOpened():
95+
raise RuntimeError(f"Could not open camera index {args.camera_index}")
96+
97+
capture.set(cv2.CAP_PROP_FRAME_WIDTH, args.width)
98+
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height)
99+
capture.set(cv2.CAP_PROP_FPS, args.fps)
100+
101+
ok, frame = capture.read()
102+
if not ok or frame is None:
103+
capture.release()
104+
raise RuntimeError(f"Could not read from camera index {args.camera_index}")
105+
106+
height, width = frame.shape[:2]
107+
logging.info("camera opened at %sx%s", width, height)
108+
return capture, width, height
109+
110+
111+
def _packet_trailer_features(args: argparse.Namespace) -> list[int]:
112+
features = []
113+
if args.attach_timestamp:
114+
features.append(rtc.PacketTrailerFeature.PTF_USER_TIMESTAMP)
115+
if args.attach_frame_id:
116+
features.append(rtc.PacketTrailerFeature.PTF_FRAME_ID)
117+
return features
118+
119+
120+
def _metadata_for_frame(
121+
args: argparse.Namespace,
122+
*,
123+
user_timestamp: int,
124+
frame_id: int,
125+
) -> rtc.FrameMetadata | None:
126+
if not args.attach_timestamp and not args.attach_frame_id:
127+
return None
128+
129+
metadata = rtc.FrameMetadata()
130+
if args.attach_timestamp:
131+
metadata.user_timestamp = user_timestamp
132+
if args.attach_frame_id:
133+
metadata.frame_id = frame_id
134+
return metadata
135+
136+
137+
def _unix_time_us() -> int:
138+
return time.time_ns() // 1_000
139+
140+
141+
def _install_signal_handlers(stop_event: asyncio.Event) -> None:
142+
loop = asyncio.get_running_loop()
143+
for sig in (signal.SIGINT, signal.SIGTERM):
144+
try:
145+
loop.add_signal_handler(sig, stop_event.set)
146+
except (NotImplementedError, RuntimeError):
147+
pass
148+
149+
150+
async def _capture_loop(
151+
args: argparse.Namespace,
152+
capture: cv2.VideoCapture,
153+
source: rtc.VideoSource,
154+
width: int,
155+
height: int,
156+
stop_event: asyncio.Event,
157+
) -> None:
158+
interval = 1.0 / args.fps
159+
next_frame_at = time.perf_counter()
160+
started_at_ns = time.perf_counter_ns()
161+
frame_id = 1
162+
submitted = 0
163+
last_log_at = time.perf_counter()
164+
165+
while not stop_event.is_set():
166+
ok, bgr = await asyncio.to_thread(capture.read)
167+
if not ok or bgr is None:
168+
logging.warning("camera frame read failed")
169+
await asyncio.sleep(0.1)
170+
continue
171+
172+
if bgr.shape[1] != width or bgr.shape[0] != height:
173+
bgr = cv2.resize(bgr, (width, height), interpolation=cv2.INTER_AREA)
174+
175+
user_timestamp = _unix_time_us()
176+
timestamp_us = (time.perf_counter_ns() - started_at_ns) // 1_000
177+
rgba = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGBA)
178+
rgba = np.ascontiguousarray(rgba)
179+
frame = rtc.VideoFrame(width, height, rtc.VideoBufferType.RGBA, rgba.tobytes())
180+
metadata = _metadata_for_frame(
181+
args,
182+
user_timestamp=user_timestamp,
183+
frame_id=frame_id,
184+
)
185+
source.capture_frame(frame, timestamp_us=timestamp_us, metadata=metadata)
186+
187+
submitted += 1
188+
if args.attach_frame_id:
189+
frame_id = (frame_id + 1) & 0xFFFFFFFF
190+
191+
now = time.perf_counter()
192+
if now - last_log_at >= 2.0:
193+
logging.info(
194+
"published %s frames at ~%.1f fps", submitted, submitted / (now - last_log_at)
195+
)
196+
submitted = 0
197+
last_log_at = now
198+
199+
next_frame_at += interval
200+
sleep_for = next_frame_at - time.perf_counter()
201+
if sleep_for > 0:
202+
await asyncio.sleep(sleep_for)
203+
else:
204+
next_frame_at = time.perf_counter()
205+
206+
207+
async def run(args: argparse.Namespace, stop_event: asyncio.Event) -> None:
208+
url, api_key, api_secret = _require_connection(args)
209+
capture, width, height = _open_camera(args)
210+
room = rtc.Room()
211+
source: rtc.VideoSource | None = None
212+
213+
try:
214+
token = _create_token(args, api_key, api_secret)
215+
logging.info("connecting to room %s as %s", args.room_name, args.identity)
216+
await room.connect(url, token)
217+
logging.info("connected to room %s", room.name)
218+
219+
source = rtc.VideoSource(width, height)
220+
track = rtc.LocalVideoTrack.create_video_track("camera", source)
221+
options = rtc.TrackPublishOptions(
222+
source=rtc.TrackSource.SOURCE_CAMERA,
223+
video_encoding=rtc.VideoEncoding(
224+
max_framerate=args.fps,
225+
max_bitrate=3_000_000,
226+
),
227+
packet_trailer_features=_packet_trailer_features(args),
228+
)
229+
publication = await room.local_participant.publish_track(track, options)
230+
logging.info(
231+
"published camera track %s with packet trailer features %s",
232+
publication.sid,
233+
list(publication.packet_trailer_features),
234+
)
235+
236+
await _capture_loop(args, capture, source, width, height, stop_event)
237+
finally:
238+
capture.release()
239+
if source is not None:
240+
await source.aclose()
241+
await room.disconnect()
242+
243+
244+
async def main() -> None:
245+
logging.basicConfig(level=logging.INFO)
246+
args = parse_args()
247+
stop_event = asyncio.Event()
248+
_install_signal_handlers(stop_event)
249+
await run(args, stop_event)
250+
251+
252+
if __name__ == "__main__":
253+
try:
254+
asyncio.run(main())
255+
except KeyboardInterrupt:
256+
pass
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[project]
2+
name = "livekit-local-video-example"
3+
version = "0.1.0"
4+
description = "Local video packet-trailer examples for the LiveKit Python SDK"
5+
requires-python = ">=3.9"
6+
dependencies = [
7+
"livekit",
8+
"livekit-api",
9+
"numpy",
10+
"opencv-python",
11+
]
12+
13+
[tool.uv]
14+
package = false
15+
16+
[tool.uv.sources]
17+
livekit = { path = "../../livekit-rtc", editable = true }
18+
livekit-api = { path = "../../livekit-api", editable = true }
19+
livekit-protocol = { path = "../../livekit-protocol", editable = true }

0 commit comments

Comments
 (0)