Skip to content

Commit 1b63e11

Browse files
chenosaurusCopilot
andauthored
Add local video publisher & subcriber examples (#830)
* Adding local video publisher & subcriber examples * improve ctrl-c handling * update .gitignore * lock aspect ratio * fix window resize & display some stats on video * update readme * lint * Update examples/local_video/README.md Co-authored-by: Copilot <[email protected]> * Update examples/local_video/src/subscriber.rs Co-authored-by: Copilot <[email protected]> * Revert "Update examples/local_video/src/subscriber.rs" This reverts commit 2a6aae3. * add simulcast option * move main app loop into a `run` function * refactor event handling into separate functions for readability * add script to list devices * lint --------- Co-authored-by: Copilot <[email protected]>
1 parent 41154ba commit 1b63e11

File tree

10 files changed

+1848
-4
lines changed

10 files changed

+1848
-4
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ target
33
soxr-sys/test-input.wav
44
soxr-sys/test-output.wav
55
.DS_Store
6-
.env
6+
.env
7+
.cursor

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ members = [
2020
"examples/basic_text_stream",
2121
"examples/encrypted_text_stream",
2222
"examples/local_audio",
23+
"examples/local_video",
2324
"examples/mobile",
2425
"examples/play_from_disk",
2526
"examples/rpc",

examples/local_video/Cargo.toml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
[package]
2+
name = "local_video"
3+
version = "0.1.0"
4+
edition = "2021"
5+
publish = false
6+
7+
[[bin]]
8+
name = "publisher"
9+
path = "src/publisher.rs"
10+
11+
[[bin]]
12+
name = "subscriber"
13+
path = "src/subscriber.rs"
14+
15+
[[bin]]
16+
name = "list_devices"
17+
path = "src/list_devices.rs"
18+
19+
[dependencies]
20+
tokio = { version = "1", features = ["full", "parking_lot"] }
21+
livekit = { workspace = true, features = ["rustls-tls-native-roots"] }
22+
webrtc-sys = { workspace = true }
23+
libwebrtc = { workspace = true }
24+
livekit-api = { workspace = true }
25+
yuv-sys = { workspace = true }
26+
futures = "0.3"
27+
clap = { version = "4.5", features = ["derive"] }
28+
log = "0.4"
29+
env_logger = "0.10.0"
30+
nokhwa = { version = "0.10", default-features = false, features = ["input-avfoundation", "input-v4l", "input-msmf", "output-threaded"] }
31+
image = "0.24"
32+
egui = "0.31.1"
33+
egui-wgpu = "0.31.1"
34+
eframe = { version = "0.31.1", default-features = false, features = ["default_fonts", "wgpu", "persistence"] }
35+
wgpu = "25.0"
36+
winit = { version = "0.30.11", features = ["android-native-activity"] }
37+
parking_lot = { version = "0.12.1", features = ["deadlock_detection"] }
38+
anyhow = "1"
39+
bytemuck = { version = "1.16", features = ["derive"] }
40+
41+
[target.'cfg(target_os = "macos")'.dependencies]
42+
objc2 = { version = "0.6.0", features = ["relax-sign-encoding"] }
43+
nokhwa-bindings-macos = "0.2"
44+
45+

examples/local_video/README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# local_video
2+
3+
Three examples demonstrating capturing frames from a local camera video and publishing to LiveKit, listing camera capabilities, and subscribing to render video in a window.
4+
5+
- list_devices: enumerate available cameras and their capabilities
6+
- publisher: capture from a selected camera and publish a video track
7+
- subscriber: connect to a room, subscribe to video tracks, and display in a window
8+
9+
LiveKit connection can be provided via flags or environment variables:
10+
- `--url` or `LIVEKIT_URL`
11+
- `--api-key` or `LIVEKIT_API_KEY`
12+
- `--api-secret` or `LIVEKIT_API_SECRET`
13+
14+
Publisher usage:
15+
```
16+
cargo run -p local_video --bin publisher -- --list-cameras
17+
cargo run -p local_video --bin publisher -- --camera-index 0 --room-name demo --identity cam-1
18+
19+
# with explicit LiveKit connection flags
20+
cargo run -p local_video --bin publisher -- \
21+
--camera-index 0 \
22+
--room-name demo \
23+
--identity cam-1 \
24+
--simulcast \
25+
--h265 \
26+
--max-bitrate 1500000 \
27+
--url https://your.livekit.server \
28+
--api-key YOUR_KEY \
29+
--api-secret YOUR_SECRET
30+
```
31+
32+
List devices usage:
33+
```
34+
cargo run -p local_video --bin list_devices
35+
```
36+
37+
Publisher flags (in addition to the common connection flags above):
38+
- `--h265`: Use H.265/HEVC encoding if supported (falls back to H.264 on failure).
39+
- `--simulcast`: Publish simulcast video (multiple layers when the resolution is large enough).
40+
- `--max-bitrate <bps>`: Max video bitrate for the main (highest) layer in bits per second (e.g. `1500000`).
41+
42+
Subscriber usage:
43+
```
44+
# relies on env vars LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET
45+
cargo run -p local_video --bin subscriber -- --room-name demo --identity viewer-1
46+
47+
# or pass credentials via flags
48+
cargo run -p local_video --bin subscriber -- \
49+
--room-name demo \
50+
--identity viewer-1 \
51+
--url https://your.livekit.server \
52+
--api-key YOUR_KEY \
53+
--api-secret YOUR_SECRET
54+
55+
# subscribe to a specific participant's video only
56+
cargo run -p local_video --bin subscriber -- \
57+
--room-name demo \
58+
--identity viewer-1 \
59+
--participant alice
60+
```
61+
62+
Notes:
63+
- `--participant` limits subscription to video tracks from the specified participant identity.
64+
- If the active video track is unsubscribed or unpublished, the app clears its state and will automatically attach to the next matching video track when it appears.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use anyhow::Result;
2+
use nokhwa::pixel_format::RgbFormat;
3+
use nokhwa::utils::{
4+
ApiBackend, CameraFormat, CameraInfo, FrameFormat, RequestedFormat, RequestedFormatType,
5+
Resolution,
6+
};
7+
use nokhwa::Camera;
8+
use std::collections::BTreeMap;
9+
10+
#[cfg(target_os = "macos")]
11+
use nokhwa_bindings_macos::AVCaptureDevice;
12+
13+
fn main() -> Result<()> {
14+
let cameras = nokhwa::query(ApiBackend::Auto)?;
15+
if cameras.is_empty() {
16+
println!("No cameras detected.");
17+
return Ok(());
18+
}
19+
20+
println!("Available cameras and capabilities:");
21+
for (idx, info) in cameras.iter().enumerate() {
22+
println!();
23+
println!("{}. {}", idx, info.human_name());
24+
match enumerate_capabilities(info) {
25+
Ok(formats) => print_capabilities(&formats),
26+
Err(err) => println!(" Capabilities: unavailable ({})", err),
27+
}
28+
}
29+
30+
Ok(())
31+
}
32+
33+
#[cfg(target_os = "macos")]
34+
fn enumerate_capabilities(
35+
info: &CameraInfo,
36+
) -> Result<BTreeMap<FrameFormat, BTreeMap<Resolution, Vec<u32>>>> {
37+
let device = AVCaptureDevice::new(info.index())?;
38+
let formats = device.supported_formats()?;
39+
Ok(capabilities_from_formats(formats))
40+
}
41+
42+
#[cfg(not(target_os = "macos"))]
43+
fn enumerate_capabilities(
44+
info: &CameraInfo,
45+
) -> Result<BTreeMap<FrameFormat, BTreeMap<Resolution, Vec<u32>>>> {
46+
let requested = RequestedFormat::new::<RgbFormat>(RequestedFormatType::None);
47+
let mut camera = Camera::new(info.index().clone(), requested)?;
48+
let mut capabilities = BTreeMap::new();
49+
if let Ok(mut fourccs) = camera.compatible_fourcc() {
50+
fourccs.sort();
51+
for fourcc in fourccs {
52+
let mut res_map = camera.compatible_list_by_resolution(fourcc)?;
53+
let mut res_sorted = BTreeMap::new();
54+
for (res, mut fps_list) in res_map.drain() {
55+
fps_list.sort();
56+
res_sorted.insert(res, fps_list);
57+
}
58+
capabilities.insert(fourcc, res_sorted);
59+
}
60+
} else {
61+
let formats = camera.compatible_camera_formats()?;
62+
capabilities = capabilities_from_formats(formats);
63+
}
64+
65+
Ok(capabilities)
66+
}
67+
68+
fn capabilities_from_formats(
69+
formats: Vec<CameraFormat>,
70+
) -> BTreeMap<FrameFormat, BTreeMap<Resolution, Vec<u32>>> {
71+
let mut capabilities = BTreeMap::new();
72+
for fmt in formats {
73+
let res_map = capabilities.entry(fmt.format()).or_insert_with(BTreeMap::new);
74+
let fps_list = res_map.entry(fmt.resolution()).or_insert_with(Vec::new);
75+
fps_list.push(fmt.frame_rate());
76+
}
77+
for res_map in capabilities.values_mut() {
78+
for fps_list in res_map.values_mut() {
79+
fps_list.sort();
80+
fps_list.dedup();
81+
}
82+
}
83+
capabilities
84+
}
85+
86+
fn print_capabilities(capabilities: &BTreeMap<FrameFormat, BTreeMap<Resolution, Vec<u32>>>) {
87+
if capabilities.is_empty() {
88+
println!(" Capabilities: none reported");
89+
return;
90+
}
91+
92+
println!(" Capabilities:");
93+
for (format, resolutions) in capabilities {
94+
println!(" - Format: {}", format);
95+
if resolutions.is_empty() {
96+
println!(" (no resolutions reported)");
97+
continue;
98+
}
99+
for (resolution, fps_list) in resolutions {
100+
let fps_text = if fps_list.is_empty() {
101+
"unknown".to_string()
102+
} else {
103+
fps_list.iter().map(|fps| fps.to_string()).collect::<Vec<String>>().join(", ")
104+
};
105+
println!(" {} @ {} fps", resolution, fps_text);
106+
}
107+
}
108+
}

0 commit comments

Comments
 (0)