Skip to content

Commit 71a244e

Browse files
committed
End-to-end video test
Parametrize for simulcast Higher resolution to get 3 simulcast layers Use higher tolerance Increase test timeout Clean up Revert timeout increase Clean up Check simulcasted property Format Add HEVC to test matrix Increase timeout Lower resource requirements GitHub Actions runners struggle with the old settings Receive single frame
1 parent fde0654 commit 71a244e

3 files changed

Lines changed: 268 additions & 0 deletions

File tree

livekit/tests/common/e2e/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use tokio::{
2525
};
2626

2727
pub mod audio;
28+
pub mod video;
2829

2930
struct TestEnvironment {
3031
api_key: String,

livekit/tests/common/e2e/video.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright 2025 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+
use libwebrtc::{
16+
prelude::{I420Buffer, RtcVideoSource, VideoFrame, VideoResolution, VideoRotation},
17+
video_source::native::NativeVideoSource,
18+
};
19+
use livekit::{
20+
options::{TrackPublishOptions, VideoCodec},
21+
track::{LocalTrack, LocalVideoTrack},
22+
Room, RoomResult,
23+
};
24+
use std::sync::Arc;
25+
use tokio::{sync::oneshot, task::JoinHandle, time};
26+
27+
/// Parameters for the solid-color frames generated with [`SolidColorTrack`].
28+
#[derive(Clone, Debug)]
29+
pub struct SolidColorParams {
30+
pub width: u32,
31+
pub height: u32,
32+
/// Y-plane value (0..255). U and V planes are fixed at 128 (neutral gray).
33+
pub luma: u8,
34+
}
35+
36+
/// Video track which generates and publishes solid-color I420 frames.
37+
///
38+
/// Analogous to [`super::audio::SineTrack`] for audio.
39+
pub struct SolidColorTrack {
40+
rtc_source: NativeVideoSource,
41+
params: SolidColorParams,
42+
room: Arc<Room>,
43+
handle: Option<TrackHandle>,
44+
}
45+
46+
struct TrackHandle {
47+
close_tx: oneshot::Sender<()>,
48+
track: LocalVideoTrack,
49+
task: JoinHandle<()>,
50+
}
51+
52+
impl SolidColorTrack {
53+
pub fn new(room: Arc<Room>, params: SolidColorParams) -> Self {
54+
Self {
55+
rtc_source: NativeVideoSource::new(
56+
VideoResolution { width: params.width, height: params.height },
57+
false,
58+
),
59+
params,
60+
room,
61+
handle: None,
62+
}
63+
}
64+
65+
pub async fn publish(&mut self, codec: VideoCodec, simulcast: bool) -> RoomResult<()> {
66+
let (close_tx, close_rx) = oneshot::channel();
67+
let track = LocalVideoTrack::create_video_track(
68+
"solid-color-track",
69+
RtcVideoSource::Native(self.rtc_source.clone()),
70+
);
71+
let task =
72+
tokio::spawn(Self::track_task(close_rx, self.rtc_source.clone(), self.params.clone()));
73+
self.room
74+
.local_participant()
75+
.publish_track(
76+
LocalTrack::Video(track.clone()),
77+
TrackPublishOptions { video_codec: codec, simulcast, ..Default::default() },
78+
)
79+
.await?;
80+
let handle = TrackHandle { close_tx, track, task };
81+
self.handle = Some(handle);
82+
Ok(())
83+
}
84+
85+
pub async fn unpublish(&mut self) -> RoomResult<()> {
86+
if let Some(handle) = self.handle.take() {
87+
handle.close_tx.send(()).ok();
88+
handle.task.await.ok();
89+
self.room.local_participant().unpublish_track(&handle.track.sid()).await?;
90+
}
91+
Ok(())
92+
}
93+
94+
async fn track_task(
95+
mut close_rx: oneshot::Receiver<()>,
96+
rtc_source: NativeVideoSource,
97+
params: SolidColorParams,
98+
) {
99+
let interval = std::time::Duration::from_millis(1000 / 5); // ~5 FPS
100+
loop {
101+
if close_rx.try_recv().is_ok() {
102+
break;
103+
}
104+
let mut buffer = I420Buffer::new(params.width, params.height);
105+
let (data_y, data_u, data_v) = buffer.data_mut();
106+
data_y.fill(params.luma);
107+
data_u.fill(128);
108+
data_v.fill(128);
109+
110+
let frame =
111+
VideoFrame { rotation: VideoRotation::VideoRotation0, timestamp_us: 0, buffer };
112+
rtc_source.capture_frame(&frame);
113+
time::sleep(interval).await;
114+
}
115+
}
116+
}

livekit/tests/video_test.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
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+
#[cfg(feature = "__lk-e2e-test")]
16+
use {
17+
anyhow::{anyhow, Ok, Result},
18+
common::{
19+
test_rooms,
20+
video::{SolidColorParams, SolidColorTrack},
21+
},
22+
futures_util::StreamExt,
23+
libwebrtc::video_stream::native::NativeVideoStream,
24+
livekit::{options::VideoCodec, prelude::*},
25+
std::{sync::Arc, time::Duration},
26+
tokio::time::timeout,
27+
};
28+
29+
mod common;
30+
31+
#[cfg(feature = "__lk-e2e-test")]
32+
struct VideoTestParams {
33+
codec: VideoCodec,
34+
width: u32,
35+
height: u32,
36+
simulcast: bool,
37+
}
38+
39+
#[cfg(feature = "__lk-e2e-test")]
40+
#[test_log::test(tokio::test)]
41+
async fn test_video() -> Result<()> {
42+
let test_params = [
43+
VideoTestParams { codec: VideoCodec::VP8, width: 640, height: 360, simulcast: false },
44+
VideoTestParams { codec: VideoCodec::VP8, width: 1280, height: 720, simulcast: true },
45+
VideoTestParams { codec: VideoCodec::H264, width: 640, height: 360, simulcast: false },
46+
VideoTestParams { codec: VideoCodec::H264, width: 1280, height: 720, simulcast: true },
47+
VideoTestParams { codec: VideoCodec::H265, width: 640, height: 360, simulcast: false },
48+
VideoTestParams { codec: VideoCodec::H265, width: 1280, height: 720, simulcast: true },
49+
VideoTestParams { codec: VideoCodec::VP9, width: 640, height: 360, simulcast: false },
50+
];
51+
for params in test_params {
52+
log::info!("Testing with {}", params);
53+
test_video_with(params).await?;
54+
}
55+
Ok(())
56+
}
57+
58+
/// Tests video transfer between two participants.
59+
///
60+
/// Verifies that video can be published and received correctly by publishing
61+
/// solid-color I420 frames and checking the average luminance on the subscriber end.
62+
///
63+
#[cfg(feature = "__lk-e2e-test")]
64+
async fn test_video_with(params: VideoTestParams) -> Result<()> {
65+
let mut rooms = test_rooms(2).await?;
66+
let (pub_room, _) = rooms.pop().unwrap();
67+
let (_, mut sub_room_events) = rooms.pop().unwrap();
68+
69+
const EXPECTED_LUMA: u8 = 180;
70+
const LUMA_TOLERANCE: f64 = 30.0;
71+
const FRAMES_TO_ANALYZE: usize = 1;
72+
73+
let solid_params =
74+
SolidColorParams { width: params.width, height: params.height, luma: EXPECTED_LUMA };
75+
let mut solid_track = SolidColorTrack::new(Arc::new(pub_room), solid_params);
76+
solid_track.publish(params.codec, params.simulcast).await?;
77+
78+
let track: RemoteTrack = timeout(Duration::from_secs(15), async {
79+
loop {
80+
let Some(event) = sub_room_events.recv().await else {
81+
Err(anyhow!("Never received track"))?
82+
};
83+
let RoomEvent::TrackSubscribed { track, publication, .. } = event else {
84+
continue;
85+
};
86+
assert_eq!(publication.simulcasted(), params.simulcast);
87+
break Ok(track.into());
88+
}
89+
})
90+
.await??;
91+
92+
let RemoteTrack::Video(track) = track else { Err(anyhow!("Expected video track"))? };
93+
let mut stream = NativeVideoStream::new(track.rtc_track());
94+
95+
let receive_frames = async {
96+
let mut frames_analyzed = 0;
97+
98+
while let Some(frame) = stream.next().await {
99+
log::info!("Received frame: {:?}", frame);
100+
101+
let (width, height) = (frame.buffer.width(), frame.buffer.height());
102+
assert!(width > 0 && height > 0, "Frame has zero dimensions");
103+
104+
let expected_ar = params.width as f64 / params.height as f64;
105+
let actual_ar = width as f64 / height as f64;
106+
assert!(
107+
(actual_ar - expected_ar).abs() < 0.1,
108+
"Aspect ratio mismatch: {}x{} ({:.3}) != expected {:.3}",
109+
width,
110+
height,
111+
actual_ar,
112+
expected_ar
113+
);
114+
115+
let i420 = frame.buffer.to_i420();
116+
let (data_y, _, _) = i420.data();
117+
let avg_luma = data_y.iter().map(|&b| b as f64).sum::<f64>() / data_y.len() as f64;
118+
119+
assert!(
120+
(avg_luma - EXPECTED_LUMA as f64).abs() < LUMA_TOLERANCE,
121+
"Average luma {:.1} not within {} of expected {}",
122+
avg_luma,
123+
LUMA_TOLERANCE,
124+
EXPECTED_LUMA
125+
);
126+
127+
frames_analyzed += 1;
128+
if frames_analyzed >= FRAMES_TO_ANALYZE {
129+
break;
130+
}
131+
}
132+
assert_eq!(frames_analyzed, FRAMES_TO_ANALYZE);
133+
};
134+
135+
timeout(Duration::from_secs(60), receive_frames).await?;
136+
Ok(())
137+
}
138+
139+
#[cfg(feature = "__lk-e2e-test")]
140+
impl std::fmt::Display for VideoTestParams {
141+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142+
write!(
143+
f,
144+
"{}x{}, {}, simulcast={}",
145+
self.width,
146+
self.height,
147+
self.codec.as_str(),
148+
self.simulcast
149+
)
150+
}
151+
}

0 commit comments

Comments
 (0)