openvidu-test-e2e: server SDK publishers with 1, 2 and 3 layers to early and late browser subscribers

Each SDK (go, node, python, rust, dotnet) publishes each codec with one plain
encoding, two layers (simulcast / SVC L2T2) and three layers (simulcast / SVC
L3T3) to a Chrome and a Firefox browser, each hosting a subscriber that joined
before the publish and one that joins afterwards. Subscribers switch between
the LOW, MEDIUM and HIGH layers.
pull/907/head
pabloFuente 2026-09-05 21:10:56 +02:00
parent 6e78fc0ec1
commit 2fe3bd87f2
10 changed files with 411 additions and 306 deletions

View File

@ -368,6 +368,10 @@ public class OpenViduTestE2e {
return "srt://" + srtServerIp + ":" + RTSP_SRT_PORT; return "srt://" + srtServerIp + ":" + RTSP_SRT_PORT;
} }
public void startServerSdkPublisher(String sdk, String roomName, String codec) throws Exception {
startServerSdkPublisher(sdk, roomName, codec, 1);
}
/** /**
* Starts a LiveKit server RTC SDK participant (a minimal program at * Starts a LiveKit server RTC SDK participant (a minimal program at
* src/test/resources/<sdk>-publisher) in a plain runtime container * src/test/resources/<sdk>-publisher) in a plain runtime container
@ -375,73 +379,59 @@ public class OpenViduTestE2e {
* given codec (vp8, h264, vp9 or av1) as one plain RTP encoding: without * given codec (vp8, h264, vp9 or av1) as one plain RTP encoding: without
* simulcast for VP8/H264 and without SVC (scalabilityMode L1T1) for VP9/AV1. * simulcast for VP8/H264 and without SVC (scalabilityMode L1T1) for VP9/AV1.
* *
* The container mounts the program read-only at /app, copies it to /work * The container mounts the program read-only at /app, copies it to /work and
* and runs it there, with a per-SDK dependency cache mounted at /cache so * runs it there, with a per-SDK dependency cache mounted at /cache so
* repeated runs skip downloads. Blocks until the participant has published * repeated runs skip downloads. Returns once the program logs
* its track (its TRACK_PUBLISHED log line). The container is stopped * TRACK_PUBLISHED. The container is stopped automatically on test dispose.
* automatically on test dispose.
*/ */
public void startServerSdkPublisher(String sdk, String roomName, String codec) throws Exception { public void startServerSdkPublisher(String sdk, String roomName, String codec, int layers) throws Exception {
startServerSdkPublisher(sdk, roomName, codec, false); if (layers < 1 || layers > 3) {
} throw new IllegalArgumentException("Unsupported number of video layers: " + layers);
}
/**
* Launches the minimal publisher program of the given LiveKit server RTC SDK
* (src/test/resources/<sdk>-publisher) in a plain runtime container, joining
* roomName and publishing one video track of the given codec. multiLayer=false
* publishes one plain RTP encoding (no simulcast, no SVC); multiLayer=true
* publishes two layers from the same 640x480 source: simulcast (480x360 +
* 640x480, the SDKs' absolute presets) for VP8/H264 and SVC L2T2 (320x240 +
* 640x480) for VP9/AV1 except the Go SDK, which forwards pre-encoded
* samples and publishes two simulcast files (320x240 + 640x480). Returns once
* the program logs TRACK_PUBLISHED.
*/
public void startServerSdkPublisher(String sdk, String roomName, String codec, boolean multiLayer)
throws Exception {
final String image; final String image;
final String runCommand; final String runCommand;
int startupTimeoutMinutes = 5; int startupTimeoutMinutes = 5;
Map<String, String> env = new HashMap<>(); Map<String, String> env = new HashMap<>();
switch (sdk) { switch (sdk) {
case "go": case "go":
image = "golang:1.27"; image = "golang:1.27";
runCommand = "go run ."; runCommand = "go run .";
env.put("GOMODCACHE", "/cache/gomod"); env.put("GOMODCACHE", "/cache/gomod");
env.put("GOCACHE", "/cache/gobuild"); env.put("GOCACHE", "/cache/gobuild");
break; break;
case "node": case "node":
image = "node:24"; image = "node:24";
runCommand = "npm install --no-audit --no-fund --loglevel=error && node main.mjs"; runCommand = "npm install --no-audit --no-fund --loglevel=error && node main.mjs";
env.put("npm_config_cache", "/cache/npm"); env.put("npm_config_cache", "/cache/npm");
break; break;
case "python": case "python":
image = "python:3.14"; image = "python:3.14";
runCommand = "pip install -q -r requirements.txt && python main.py"; runCommand = "pip install -q -r requirements.txt && python main.py";
env.put("PIP_CACHE_DIR", "/cache/pip"); env.put("PIP_CACHE_DIR", "/cache/pip");
break; break;
case "rust": case "rust":
image = "rust:1"; image = "rust:1";
// webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21 // webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21
// (apt.llvm.org); the first run then compiles the whole livekit // (apt.llvm.org); the first run then compiles the whole livekit
// crate graph // crate graph
runCommand = "apt-get update -qq >/dev/null && apt-get install -y -qq lsb-release gnupg >/dev/null" runCommand = "apt-get update -qq >/dev/null && apt-get install -y -qq lsb-release gnupg >/dev/null"
+ " && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && bash /tmp/llvm.sh 21 >/dev/null 2>&1" + " && wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh && bash /tmp/llvm.sh 21 >/dev/null 2>&1"
+ " && cargo run --release --locked"; + " && cargo run --release --locked";
env.put("CARGO_HOME", "/cache/cargo"); env.put("CARGO_HOME", "/cache/cargo");
env.put("CARGO_TARGET_DIR", "/cache/cargo-target"); env.put("CARGO_TARGET_DIR", "/cache/cargo-target");
env.put("CC", "clang-21"); env.put("CC", "clang-21");
env.put("CXX", "clang++-21"); env.put("CXX", "clang++-21");
startupTimeoutMinutes = 30; startupTimeoutMinutes = 30;
break; break;
case "dotnet": case "dotnet":
image = "mcr.microsoft.com/dotnet/sdk:10.0"; image = "mcr.microsoft.com/dotnet/sdk:10.0";
runCommand = "dotnet run -c Release"; runCommand = "dotnet run -c Release";
env.put("NUGET_PACKAGES", "/cache/nuget"); env.put("NUGET_PACKAGES", "/cache/nuget");
env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1"); env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1");
startupTimeoutMinutes = 10; startupTimeoutMinutes = 10;
break; break;
default: default:
throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk); throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk);
} }
// Publishers receive a ready-made token: it keeps the programs shorter // Publishers receive a ready-made token: it keeps the programs shorter
// (no per-SDK token dependency) and works with any api secret length // (no per-SDK token dependency) and works with any api secret length
@ -451,7 +441,7 @@ public class OpenViduTestE2e {
env.put("LIVEKIT_URL", LIVEKIT_URL); env.put("LIVEKIT_URL", LIVEKIT_URL);
env.put("LIVEKIT_TOKEN", accessToken.toJwt()); env.put("LIVEKIT_TOKEN", accessToken.toJwt());
env.put("VIDEO_CODEC", codec); env.put("VIDEO_CODEC", codec);
env.put("VIDEO_LAYERS", multiLayer ? "multi" : "single"); env.put("VIDEO_LAYERS", String.valueOf(layers));
String programDir = Paths.get("src/test/resources/" + sdk + "-publisher").toAbsolutePath().toString(); String programDir = Paths.get("src/test/resources/" + sdk + "-publisher").toAbsolutePath().toString();
Path cacheDir = Paths.get(System.getProperty("java.io.tmpdir"), "openvidu-e2e-sdk-cache", sdk); Path cacheDir = Paths.get(System.getProperty("java.io.tmpdir"), "openvidu-e2e-sdk-cache", sdk);
@ -475,27 +465,37 @@ public class OpenViduTestE2e {
// encode raw frames themselves. // encode raw frames themselves.
final String encoder; final String encoder;
switch (codec) { switch (codec) {
case "h264": case "h264":
encoder = "-c:v libx264 -profile:v baseline -level 3.1 -x264-params keyint=30:scenecut=0 -f h264"; encoder = "-c:v libx264 -profile:v baseline -level 3.1 -x264-params keyint=30:scenecut=0 -f h264";
break; break;
case "vp8": case "vp8":
encoder = "-c:v libvpx -g 30 -f ivf"; encoder = "-c:v libvpx -g 30 -f ivf";
break; break;
case "vp9": case "vp9":
encoder = "-c:v libvpx-vp9 -g 30 -f ivf"; encoder = "-c:v libvpx-vp9 -g 30 -f ivf";
break; break;
case "av1": case "av1":
encoder = "-c:v libaom-av1 -usage realtime -cpu-used 8 -g 30 -f ivf"; encoder = "-c:v libaom-av1 -usage realtime -cpu-used 8 -g 30 -f ivf";
break; break;
default: default:
throw new IllegalArgumentException("Unknown video codec: " + codec); throw new IllegalArgumentException("Unknown video codec: " + codec);
} }
Path mediaDir = Files.createTempDirectory("go-publisher-media"); Path mediaDir = Files.createTempDirectory("go-publisher-media");
// One file per layer: the single encoding, or the three simulcast // One file per layer: the single encoding or the simulcast layers
// layers (must match the sizes declared by go-publisher/main.go) // (must match the sizes declared by go-publisher/main.go)
Map<String, String> layerSizes = multiLayer final Map<String, String> layerSizes;
? Map.of("VIDEO_FILE_LOW", "320x240", "VIDEO_FILE_HIGH", "640x480") switch (layers) {
: Map.of("VIDEO_FILE", "640x480"); case 1:
layerSizes = Map.of("VIDEO_FILE", "640x480");
break;
case 2:
layerSizes = Map.of("VIDEO_FILE_LOW", "320x240", "VIDEO_FILE_HIGH", "640x480");
break;
default:
layerSizes = Map.of("VIDEO_FILE_LOW", "320x180", "VIDEO_FILE_MEDIUM", "640x360", "VIDEO_FILE_HIGH",
"1280x720");
break;
}
for (Map.Entry<String, String> layer : layerSizes.entrySet()) { for (Map.Entry<String, String> layer : layerSizes.entrySet()) {
Path videoFile = mediaDir Path videoFile = mediaDir
.resolve("test-video-" + layer.getValue() + "." + ("h264".equals(codec) ? "h264" : "ivf")); .resolve("test-video-" + layer.getValue() + "." + ("h264".equals(codec) ? "h264" : "ivf"));

View File

@ -64,83 +64,87 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
this.closeAllRooms(LK); this.closeAllRooms(LK);
} }
// Server RTC SDK publishers matrix: each SDK publishes each codec first as
// one plain RTP encoding (no simulcast for VP8/H264, no SVC for VP9/AV1)
// and then as two layers (high and low quality: simulcast for VP8/H264, SVC
// L2T2 for VP9/AV1), and a Chrome subscriber and a Firefox subscriber must
// both receive it — for two layers, switching to the LOW and then to the
// HIGH layer. The Go SDK single-layer H264 lane reproduces the mediasoup
// Producer codec-binding bug: Go SDK publishers declare no codec in their
// AddTrackRequest and prefer an H264 variant the server does not support,
// so the server's answer ends up VP8 first, the Producer is bound to VP8,
// the worker discards every incoming packet and the subscribers receive no
// media at all. See MEDIASOUP_CODEC_BINDING_BUG.md
/** /**
* {vp8, h264, vp9, av1} x {single, multi} layers, single-layer cases first. * {vp8, h264, vp9, av1} x {1, 2, 3} layers, single-layer cases first. System
* System properties sdk.codecs / sdk.layers (comma-separated) restrict the * properties sdk.codecs / sdk.layers (comma-separated) restrict the matrix,
* matrix, e.g. -Dsdk.layers=multi -Dsdk.codecs=vp9,av1 * e.g. -Dsdk.layers=2,3 -Dsdk.codecs=vp9,av1
*/ */
static Stream<Arguments> serverSdkPublisherMatrix() { static Stream<Arguments> serverSdkPublisherMatrix() {
List<String> layersFilter = List.of(System.getProperty("sdk.layers", "single,multi").split(",")); List<String> layersFilter = List.of(System.getProperty("sdk.layers", "1,2,3").split(","));
List<String> codecsFilter = List.of(System.getProperty("sdk.codecs", "vp8,h264,vp9,av1").split(",")); List<String> codecsFilter = List.of(System.getProperty("sdk.codecs", "vp8,h264,vp9,av1").split(","));
return Stream.of("single", "multi").filter(layersFilter::contains) return Stream.of(1, 2, 3).filter(layers -> layersFilter.contains(String.valueOf(layers)))
.flatMap(layers -> Stream.of("vp8", "h264", "vp9", "av1").filter(codecsFilter::contains) .flatMap(layers -> Stream.of("vp8", "h264", "vp9", "av1").filter(codecsFilter::contains)
.map(codec -> Arguments.of(codec, layers))); .map(codec -> Arguments.of(codec, layers)));
} }
@ParameterizedTest(name = "Go SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") @ParameterizedTest(name = "Go SDK {0} {1}-layer publisher to Chrome and Firefox early and late subscribers")
@MethodSource("serverSdkPublisherMatrix") @MethodSource("serverSdkPublisherMatrix")
@DisplayName("Go SDK publisher to Chrome and Firefox subscribers") @DisplayName("Go SDK publisher to Chrome and Firefox early and late subscribers")
void goSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { void goSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("go", codec, layers); serverSdkPublisherToBrowserSubscribersAux("go", codec, layers);
} }
@ParameterizedTest(name = "Node SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") @ParameterizedTest(name = "Node SDK {0} {1}-layer publisher to Chrome and Firefox early and late subscribers")
@MethodSource("serverSdkPublisherMatrix") @MethodSource("serverSdkPublisherMatrix")
@DisplayName("Node SDK publisher to Chrome and Firefox subscribers") @DisplayName("Node SDK publisher to Chrome and Firefox early and late subscribers")
void nodeSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { void nodeSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("node", codec, layers); serverSdkPublisherToBrowserSubscribersAux("node", codec, layers);
} }
@ParameterizedTest(name = "Python SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") @ParameterizedTest(name = "Python SDK {0} {1}-layer publisher to Chrome and Firefox early and late subscribers")
@MethodSource("serverSdkPublisherMatrix") @MethodSource("serverSdkPublisherMatrix")
@DisplayName("Python SDK publisher to Chrome and Firefox subscribers") @DisplayName("Python SDK publisher to Chrome and Firefox early and late subscribers")
void pythonSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { void pythonSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("python", codec, layers); serverSdkPublisherToBrowserSubscribersAux("python", codec, layers);
} }
@ParameterizedTest(name = "Rust SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") @ParameterizedTest(name = "Rust SDK {0} {1}-layer publisher to Chrome and Firefox early and late subscribers")
@MethodSource("serverSdkPublisherMatrix") @MethodSource("serverSdkPublisherMatrix")
@DisplayName("Rust SDK publisher to Chrome and Firefox subscribers") @DisplayName("Rust SDK publisher to Chrome and Firefox early and late subscribers")
void rustSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { void rustSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("rust", codec, layers); serverSdkPublisherToBrowserSubscribersAux("rust", codec, layers);
} }
@ParameterizedTest(name = ".NET SDK {0} {1}-layer publisher to Chrome and Firefox subscribers") @ParameterizedTest(name = ".NET SDK {0} {1}-layer publisher to Chrome and Firefox early and late subscribers")
@MethodSource("serverSdkPublisherMatrix") @MethodSource("serverSdkPublisherMatrix")
@DisplayName(".NET SDK publisher to Chrome and Firefox subscribers") @DisplayName(".NET SDK publisher to Chrome and Firefox early and late subscribers")
void dotnetSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { void dotnetSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception {
// Requires Livekit.Rtc.Dotnet >= 0.1.4 (TrackPublishOptions.VideoCodec) // Requires Livekit.Rtc.Dotnet >= 0.1.4 (TrackPublishOptions.VideoCodec)
serverSdkPublisherToBrowserSubscribersAux("dotnet", codec, layers); serverSdkPublisherToBrowserSubscribersAux("dotnet", codec, layers);
} }
/** /**
* A Chrome browser and a Firefox browser join the room as subscriber-only * A subscriber-only participant of the room: the testapp instance (index
* participants and a LiveKit server RTC SDK participant * within its browser page) that hosts it, and its participant name.
* (startServerSdkPublisher) joins the same room ("TestRoom" is the testapp
* default) publishing a single video track with the given codec: as one plain
* RTP encoding (layers "single") or as two layers (layers "multi": high and
* low quality simulcast for VP8/H264, SVC L2T2 for VP9/AV1). Both
* subscribers must receive the track's media with that codec, going through
* exactly the same steps; with two layers they also switch to the LOW and
* then to the HIGH layer.
*/ */
private void serverSdkPublisherToBrowserSubscribersAux(String sdk, String codec, String layers) private record Subscriber(OpenViduTestappUser user, int instance, String name) {
throws Exception { String instanceSelector() {
return "#openvidu-instance-" + instance;
}
WebElement remoteVideo() {
return user.getDriver().findElement(By.cssSelector(instanceSelector() + " video.remote"));
}
}
/**
* A Chrome browser and a Firefox browser each connect one subscriber-only
* participant to the room; then a LiveKit server RTC SDK participant joins it,
* publishing a single video track with the given codec and number of layers:
* one plain RTP encoding (1), or two or three layers (simulcast for VP8/H264,
* SVC L2T2 / L3T3 for VP9/AV1). Both early subscribers, already subscribed when
* the track appears, must receive it with that codec and those layers; with
* several layers they switch to the LOW, (MEDIUM) and HIGH layers. Then each
* browser adds a second subscriber-only participant as a late subscriber (the
* publisher has been streaming for a while, so its first frame depends on the
* SFU requesting a keyframe) that goes through the same media and layer checks
* while the early subscribers keep the HIGH layer. The room ends up with 5
* participants, and both join orders are covered in both browsers.
*/
private void serverSdkPublisherToBrowserSubscribersAux(String sdk, String codec, int layers) throws Exception {
final String expectedCodec = "video/" + codec.toUpperCase(); final String expectedCodec = "video/" + codec.toUpperCase();
final String publisherIdentity = sdk + "-publisher"; final String publisherIdentity = sdk + "-publisher";
final boolean multiLayer = "multi".equals(layers); final boolean multiLayer = layers > 1;
// The Go SDK forwards pre-encoded samples (no encoder), so its only // The Go SDK forwards pre-encoded samples (no encoder), so its only
// multi-layer shape is RID simulcast — and LiveKit does not support RID // multi-layer shape is RID simulcast — and LiveKit does not support RID
@ -149,40 +153,36 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
Assumptions.assumeFalse(multiLayer && "go".equals(sdk) && ("vp9".equals(codec) || "av1".equals(codec)), Assumptions.assumeFalse(multiLayer && "go".equals(sdk) && ("vp9".equals(codec) || "av1".equals(codec)),
"The Go SDK cannot publish SVC, and VP9/AV1 RID simulcast is not a supported LiveKit publish shape"); "The Go SDK cannot publish SVC, and VP9/AV1 RID simulcast is not a supported LiveKit publish shape");
List<OpenViduTestappUser> subscribers = List.of(setupBrowserAndConnectToOpenViduTestapp("chrome"), log.info("{} SDK {} {}-layer publisher to Chrome and Firefox early and late subscribers", sdk, codec,
setupBrowserAndConnectToOpenViduTestapp("firefox")); layers);
log.info("{} SDK {} {}-layer publisher to Chrome and Firefox subscribers", sdk, codec, layers); OpenViduTestappUser chrome = setupBrowserAndConnectToOpenViduTestapp("chrome");
OpenViduTestappUser firefox = setupBrowserAndConnectToOpenViduTestapp("firefox");
for (OpenViduTestappUser user : subscribers) { // Early subscribers: in the room before the SDK publishes
this.addSubscriber(user, false); List<Subscriber> earlySubscribers = List.of(joinAsSubscriberOnly(chrome, "early"),
WebElement participantNameInput = user.getDriver().findElement(By.id("participant-name-input-0")); joinAsSubscriberOnly(firefox, "early"));
participantNameInput.clear();
participantNameInput.sendKeys(browserName(user) + "-subscriber");
user.getDriver().findElements(By.className("connect-btn")).forEach(el -> el.sendKeys(Keys.ENTER));
user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 1);
}
for (OpenViduTestappUser user : subscribers) {
user.getEventManager().waitUntilEventReaches("active", "ParticipantEvent", 1);
}
this.startServerSdkPublisher(sdk, "TestRoom", codec, multiLayer); this.startServerSdkPublisher(sdk, "TestRoom", codec, layers);
// The server's authoritative view of the publication (RoomService API): // The server's authoritative view of the publication (RoomService API):
// the LiveKit TrackInfo of a video publication lists one layer per // the LiveKit TrackInfo of a video publication lists one layer per
// simulcast layer or SVC spatial layer, so one plain encoding (no // simulcast layer or SVC spatial layer, so one plain encoding (no
// simulcast, no SVC / L1T1) has exactly one layer and a two-layer // simulcast, no SVC / L1T1) has exactly one layer and a two- or
// publish (simulcast or SVC L2T2) has two // three-layer publish (simulcast or SVC L2T2 / L3T3) has two or three
TrackInfo trackInfo = this.getPublishedVideoTrackInfo("TestRoom", publisherIdentity); TrackInfo trackInfo = this.getPublishedVideoTrackInfo("TestRoom", publisherIdentity);
final int expectedLayers = multiLayer ? 2 : 1; Assertions.assertEquals(layers, trackInfo.getLayersCount(), "Expected " + layers
Assertions.assertEquals(expectedLayers, trackInfo.getLayersCount(), "Expected " + expectedLayers
+ " video layer(s) in the track published by the " + sdk + " SDK, but the server reports " + " video layer(s) in the track published by the " + sdk + " SDK, but the server reports "
+ trackInfo.getLayersList()); + trackInfo.getLayersList());
Assertions.assertEquals(expectedLayers, trackInfo.getCodecs(0).getLayersCount(), "Expected " + expectedLayers Assertions.assertEquals(layers, trackInfo.getCodecs(0).getLayersCount(),
+ " video layer(s) for codec " + expectedCodec + " published by the " + sdk + " SDK"); "Expected " + layers + " video layer(s) for codec " + expectedCodec + " published by the " + sdk
// VP8/H264 multi-layer publishes are simulcast and VP9/AV1 ones are SVC + " SDK");
// L2T2, except for the Go SDK: it forwards pre-encoded samples (no Assertions.assertEquals(layers, sortedLayerWidths(trackInfo).size(),
// encoder, so no SVC) and publishes simulcast for every codec "Expected " + layers + " video layers of distinct widths published by the " + sdk + " SDK, but got "
+ trackInfo.getLayersList());
// VP8/H264 multi-layer publishes are simulcast and VP9/AV1 ones are SVC,
// except for the Go SDK: it forwards pre-encoded samples (no encoder, so
// no SVC) and publishes simulcast for every codec
final boolean expectSimulcast = multiLayer && ("vp8".equals(codec) || "h264".equals(codec) || "go".equals(sdk)); final boolean expectSimulcast = multiLayer && ("vp8".equals(codec) || "h264".equals(codec) || "go".equals(sdk));
if (!multiLayer || expectSimulcast) { if (!multiLayer || expectSimulcast) {
Assertions.assertEquals(expectSimulcast, trackInfo.getSimulcast(), Assertions.assertEquals(expectSimulcast, trackInfo.getSimulcast(),
@ -194,14 +194,42 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
"The track published by the " + sdk + " SDK should not be SVC"); "The track published by the " + sdk + " SDK should not be SVC");
} }
// Both browsers must receive the track, with its codec and its layers for (Subscriber subscriber : earlySubscribers) {
for (OpenViduTestappUser user : subscribers) { assertSubscriberReceivesVideo(subscriber, sdk, publisherIdentity, expectedCodec, trackInfo);
assertSubscriberReceivesVideo(user, sdk, publisherIdentity, expectedCodec, trackInfo);
} }
assertSubscribersSwitchLayers(earlySubscribers, trackInfo);
for (OpenViduTestappUser user : subscribers) { // Late subscribers: the publisher has been streaming for a while
gracefullyLeaveParticipants(user, 1); List<Subscriber> lateSubscribers = List.of(joinAsSubscriberOnly(chrome, "late"),
joinAsSubscriberOnly(firefox, "late"));
for (Subscriber subscriber : lateSubscribers) {
assertSubscriberReceivesVideo(subscriber, sdk, publisherIdentity, expectedCodec, trackInfo);
} }
assertSubscribersSwitchLayers(lateSubscribers, trackInfo);
gracefullyLeaveParticipants(chrome, 2);
gracefullyLeaveParticipants(firefox, 2);
}
/**
* Adds a testapp instance to the browser page and connects it to the default
* room as a subscriber-only participant named "<Browser>-<role>-subscriber"
* (e.g. "Chrome-early-subscriber"), with adaptiveStream disabled, and waits
* until it is active.
*/
private Subscriber joinAsSubscriberOnly(OpenViduTestappUser user, String role) throws Exception {
// The new instance takes the next index
final int instance = user.getDriver().findElements(By.cssSelector("app-openvidu-instance")).size();
final String name = browserName(user) + "-" + role + "-subscriber";
this.addSubscriber(user, false);
WebElement participantNameInput = user.getDriver().findElement(By.id("participant-name-input-" + instance));
participantNameInput.clear();
participantNameInput.sendKeys(name);
user.getDriver().findElement(By.cssSelector("#openvidu-instance-" + instance + " .connect-btn"))
.sendKeys(Keys.ENTER);
user.getEventManager().waitUntilEventReaches(instance, "connected", "RoomEvent", 1);
user.getEventManager().waitUntilEventReaches(instance, "active", "ParticipantEvent", 1);
return new Subscriber(user, instance, name);
} }
/** "Chrome", "Firefox"... from the BrowserUser class of the testapp user. */ /** "Chrome", "Firefox"... from the BrowserUser class of the testapp user. */
@ -211,38 +239,43 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
/** /**
* Selects the max video quality (LOW, MEDIUM or HIGH) of the remote track of * Selects the max video quality (LOW, MEDIUM or HIGH) of the remote track of
* the first testapp instance, closing the track info dialog if it is open. * the subscriber's testapp instance, closing the track info dialog if it is
* open. The selection is verified (selectMatOption): a click that only opened
* or closed the mat-select panel is retried instead of leaving the panel open
* over the page, which would block the later clicks on the video controls.
*/ */
private void selectSubscriberVideoQuality(OpenViduTestappUser user, String quality) throws InterruptedException { private void selectSubscriberVideoQuality(Subscriber subscriber, String quality) throws InterruptedException {
OpenViduTestappUser user = subscriber.user();
if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) { if (!user.getDriver().findElements(By.cssSelector("app-info-dialog")).isEmpty()) {
user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click(); user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click();
Thread.sleep(300); Thread.sleep(300);
} }
user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); this.selectMatOption(user, subscriber.instanceSelector() + " #max-video-quality", quality);
this.waitAndClick(user, "mat-option.mode-" + quality);
} }
/** /**
* The subscriber-only participant of the given browser must receive the video * The subscriber-only participant must receive the video track published by
* track published by the SDK participant: media actually flowing, with the * the SDK participant: media actually flowing, with the expected codec, with
* expected codec, with the layers the server reports in trackInfo. With more * the layers the server reports in trackInfo.
* than one layer the subscriber switches to the LOW and then to the HIGH
* layer, each recognised by its frame width.
*/ */
private void assertSubscriberReceivesVideo(OpenViduTestappUser user, String sdk, String publisherIdentity, private void assertSubscriberReceivesVideo(Subscriber subscriber, String sdk, String publisherIdentity,
String expectedCodec, TrackInfo trackInfo) throws Exception { String expectedCodec, TrackInfo trackInfo) throws Exception {
final String browser = browserName(user); final OpenViduTestappUser user = subscriber.user();
final String name = subscriber.name();
user.getEventManager().waitUntilEventReaches("trackSubscribed", "ParticipantEvent", 1); user.getEventManager().waitUntilEventReaches(subscriber.instance(), "trackSubscribed", "ParticipantEvent",
1);
user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("video"), 1)); user.getWaiter().until(
Assertions.assertTrue(assertAllElementsHaveTracks(user, "video", false, true), ExpectedConditions.numberOfElementsToBe(By.cssSelector(subscriber.instanceSelector() + " video"), 1));
browser + ": HTMLVideoElements were expected to have only one video track"); Assertions.assertTrue(assertAllElementsHaveTracks(user, subscriber.instanceSelector() + " video", false, true),
name + ": HTMLVideoElements were expected to have only one video track");
WebElement subscriberVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.remote")); WebElement subscriberVideo = subscriber.remoteVideo();
// Media must actually reach the subscriber (with the codec-binding bug // Media must actually reach the subscriber (with the codec-binding bug
// present the track subscribes but receives 0 bytes forever) // present the track subscribes but receives 0 bytes forever; a late
// subscriber additionally depends on the SFU requesting a keyframe)
waitUntilVideoLayersNotEmpty(user, subscriberVideo); waitUntilVideoLayersNotEmpty(user, subscriberVideo);
this.waitUntilSubscriberBytesReceivedIncreasing(user, subscriberVideo); this.waitUntilSubscriberBytesReceivedIncreasing(user, subscriberVideo);
this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo); this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
@ -250,28 +283,44 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
// And with the codec the publisher actually sends (a Producer bound to // And with the codec the publisher actually sends (a Producer bound to
// the wrong codec makes every subscriber negotiate that wrong codec) // the wrong codec makes every subscriber negotiate that wrong codec)
Assertions.assertEquals(expectedCodec, this.getSubscriberVideoCodec(user, subscriberVideo), Assertions.assertEquals(expectedCodec, this.getSubscriberVideoCodec(user, subscriberVideo),
browser + " subscriber should negotiate the codec the " + sdk + " SDK publisher sends"); name + " should negotiate the codec the " + sdk + " SDK publisher sends");
// And with the layers the server reports, in the track info received by // And with the layers the server reports, in the track info received by
// the subscriber // the subscriber
JsonArray subscriberLayers = this.getRemoteVideoTrackInfoLayers(user, publisherIdentity); JsonArray subscriberLayers = this.getRemoteVideoTrackInfoLayers(subscriber, publisherIdentity);
Assertions.assertEquals(trackInfo.getLayersCount(), subscriberLayers.size(), Assertions.assertEquals(trackInfo.getLayersCount(), subscriberLayers.size(),
"Expected " + trackInfo.getLayersCount() + " video layer(s) in the track published by the " + sdk "Expected " + trackInfo.getLayersCount() + " video layer(s) in the track published by the " + sdk
+ " SDK, but the " + browser + " subscriber sees " + subscriberLayers); + " SDK, but " + name + " sees " + subscriberLayers);
}
if (trackInfo.getLayersCount() > 1) { /**
// Multiple layers: with adaptiveStream disabled the received layer * With more than one published layer, the given subscribers (all of them
// only changes through the max-video-quality selector. Receiving the * receiving the track already) switch together to the LOW layer, then (with
// LOW layer and then the HIGH layer — each recognised by the frame * three layers) to the MEDIUM one and then to the HIGH one, each subscriber
// width the publisher declared for it in the TrackInfo, and each * recognising each layer by its frame width. With adaptiveStream disabled the
// actually decoding (framesPerSecond > 0: an undecodable layer still * received layer only changes through the max-video-quality selector, and
// reports its frame size) — proves that the publisher sends every * each layer must actually decode (framesPerSecond > 0: an undecodable layer
// layer and that the SFU forwards the requested one. The declared * still reports its frame size): this proves that the publisher sends every
// widths are reliable because the multi-layer publishers capture at * layer and that the SFU forwards the requested one, also the middle spatial
// 15 fps: at 30 fps libwebrtc's CPU adaptation can scale every layer * layer, which neither LOW nor HIGH can clamp to. The declared widths are
// down under load * reliable because the multi-layer publishers capture at 15 fps: at 30 fps
this.selectQualityAndAwaitLayer(user, subscriberVideo, "LOW", lowestLayerWidth(trackInfo)); * libwebrtc's CPU adaptation can scale every layer down under load.
this.selectQualityAndAwaitLayer(user, subscriberVideo, "HIGH", highestLayerWidth(trackInfo)); */
private void assertSubscribersSwitchLayers(List<Subscriber> subscribers, TrackInfo trackInfo) throws Exception {
List<Integer> layerWidths = sortedLayerWidths(trackInfo);
if (layerWidths.size() < 2) {
return;
}
for (Subscriber subscriber : subscribers) {
this.selectQualityAndAwaitLayer(subscriber, "LOW", layerWidths.get(0));
}
if (layerWidths.size() > 2) {
for (Subscriber subscriber : subscribers) {
this.selectQualityAndAwaitLayer(subscriber, "MEDIUM", layerWidths.get(1));
}
}
for (Subscriber subscriber : subscribers) {
this.selectQualityAndAwaitLayer(subscriber, "HIGH", layerWidths.get(layerWidths.size() - 1));
} }
} }
@ -281,10 +330,12 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
* (framesPerSecond > 0). Retries the selection once: under CPU load the SFU * (framesPerSecond > 0). Retries the selection once: under CPU load the SFU
* can take longer than one wait window to ramp back up to a higher layer. * can take longer than one wait window to ramp back up to a higher layer.
*/ */
private void selectQualityAndAwaitLayer(OpenViduTestappUser user, WebElement subscriberVideo, String quality, private void selectQualityAndAwaitLayer(Subscriber subscriber, String quality, int expectedFrameWidth)
int expectedFrameWidth) throws Exception { throws Exception {
OpenViduTestappUser user = subscriber.user();
WebElement subscriberVideo = subscriber.remoteVideo();
for (int attempt = 1; attempt <= 2; attempt++) { for (int attempt = 1; attempt <= 2; attempt++) {
this.selectSubscriberVideoQuality(user, quality); this.selectSubscriberVideoQuality(subscriber, quality);
try { try {
this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, expectedFrameWidth); this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, expectedFrameWidth);
break; break;
@ -298,23 +349,15 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
} }
/** /**
* Width of the lowest-quality published layer, from the server's TrackInfo. * Distinct widths of the published layers, lowest first, from the server's
* The layers are picked by width, not by their VideoQuality label: the SDKs * TrackInfo. The layers are picked by width, not by their VideoQuality label:
* label a two-layer publish inconsistently (simulcast: LOW + MEDIUM; SVC * the SDKs label a two-layer publish inconsistently (simulcast: LOW + MEDIUM;
* L2T2: MEDIUM + HIGH; the Go program: LOW + HIGH), while the subscriber's * SVC L2T2: MEDIUM + HIGH; the Go program: LOW + HIGH), while the
* LOW/HIGH selection always clamps to the lowest/highest available layer. * subscriber's LOW/HIGH selection always clamps to the lowest/highest
* available layer and MEDIUM, only used with three layers, is the middle one.
*/ */
private int lowestLayerWidth(TrackInfo trackInfo) { private List<Integer> sortedLayerWidths(TrackInfo trackInfo) {
return trackInfo.getLayersList().stream().mapToInt(VideoLayer::getWidth).min() return trackInfo.getLayersList().stream().map(VideoLayer::getWidth).distinct().sorted().toList();
.orElseThrow(() -> new AssertionError("No layers in " + trackInfo));
}
/**
* Width of the highest-quality published layer, from the server's TrackInfo.
*/
private int highestLayerWidth(TrackInfo trackInfo) {
return trackInfo.getLayersList().stream().mapToInt(VideoLayer::getWidth).max()
.orElseThrow(() -> new AssertionError("No layers in " + trackInfo));
} }
/** /**
@ -341,11 +384,11 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
/** /**
* Video layers (VideoLayer[] of the LiveKit TrackInfo) of the first video * Video layers (VideoLayer[] of the LiveKit TrackInfo) of the first video
* track published by the remote participant with the given identity, as seen * track published by the remote participant with the given identity, as seen
* by the local participant of the first testapp instance. * by the local participant of the subscriber's testapp instance.
*/ */
private JsonArray getRemoteVideoTrackInfoLayers(OpenViduTestappUser user, String participantIdentity) { private JsonArray getRemoteVideoTrackInfoLayers(Subscriber subscriber, String participantIdentity) {
String layers = (String) ((JavascriptExecutor) user.getDriver()).executeScript( String layers = (String) ((JavascriptExecutor) subscriber.user().getDriver()).executeScript(
"var room = window['room_0'];" "var room = window['room_" + subscriber.instance() + "'];"
+ "var participant = room.remoteParticipants.get(arguments[0]);" + "var participant = room.remoteParticipants.get(arguments[0]);"
+ "var publication = participant.videoTrackPublications.values().next().value;" + "var publication = participant.videoTrackPublications.values().next().value;"
+ "return JSON.stringify(publication.trackInfo.layers);", + "return JSON.stringify(publication.trackInfo.layers);",

View File

@ -0,0 +1,81 @@
# Server SDK publishers
Minimal LiveKit server RTC SDK publisher programs used by
`OpenViduTestAppE2eServerSdkTest`, one per SDK:
| Directory | SDK | Video source |
| ------------------- | ----------------------------------------- | ----------------------- |
| `go-publisher/` | `github.com/livekit/server-sdk-go/v2` | pre-encoded files |
| `node-publisher/` | `@livekit/rtc-node` | synthetic frames (FFI) |
| `python-publisher/` | `livekit` (Python `rtc`) | synthetic frames (FFI) |
| `rust-publisher/` | `livekit` crate | synthetic frames (FFI) |
| `dotnet-publisher/` | `Livekit.Rtc.Dotnet` | synthetic frames (FFI) |
Each program is launched by `OpenViduTestE2e#startServerSdkPublisher` inside a
plain runtime container of its language (see that method for the images, run
commands and per-SDK dependency caches). The directory is mounted read-only at
`/app`, copied to `/work` and run there. The program must print the exact line
`TRACK_PUBLISHED` once the track is published: the Java test waits for it before
asserting, and stops the container when the test ends, so the programs never
exit on their own.
## Behaviour
Every publisher joins the room of `LIVEKIT_TOKEN` with dynacast disabled and
publishes a single video track with codec `VIDEO_CODEC` (`vp8`, `h264`, `vp9`
or `av1`) and `VIDEO_LAYERS` layers (`1`, `2` or `3`):
- `VIDEO_LAYERS=1` (default): one plain RTP encoding. No simulcast for
VP8/H264, no SVC (scalability mode `L1T1`) for VP9/AV1.
- `VIDEO_LAYERS=2` and `3`: simulcast for VP8/H264 and SVC `L2T2` / `L3T3` for
VP9/AV1.
### FFI-based SDKs (node, python, rust, dotnet)
They encode raw frames themselves and push synthetic animated frames forever.
The source is 640x480 for one or two layers and 1280x720 for three layers,
because the SDKs only split a simulcast source in three layers from 960 px
wide. The SDKs derive the simulcast layers from the source: 480x360 + 640x480
from the 640x480 source (4:3 presets) and 320x180 + 640x360 + 1280x720 from the
1280x720 one (16:9 presets).
### Go SDK
The Go SDK forwards pre-encoded samples and has no encoder. The publisher loops
forever the files generated by the Java test with the host's `ffmpeg` (Annex-B
for H264, IVF for VP8/VP9/AV1), one file per layer:
- `VIDEO_LAYERS=1`: one plain RTP encoding from `VIDEO_FILE` (640x480). This is
the publish shape of every Go SDK client (`lk` CLI, `lk load-test`, Go
agents): the `AddTrackRequest` declares no codec, the exact configuration that
triggered the mediasoup Producer codec-binding bug.
- `VIDEO_LAYERS=2`: two RID simulcast layers (LOW 320x240, HIGH 640x480) from
`VIDEO_FILE_LOW` / `VIDEO_FILE_HIGH`.
- `VIDEO_LAYERS=3`: three RID simulcast layers (LOW 320x180, MEDIUM 640x360,
HIGH 1280x720) from `VIDEO_FILE_LOW` / `VIDEO_FILE_MEDIUM` /
`VIDEO_FILE_HIGH`.
Because it cannot produce SVC streams, its VP9/AV1 multi-layer cases are
skipped by the Java test (RID simulcast of SVC-class codecs is not a supported
publish shape). The layer sizes in `go-publisher/main.go` must match the files
generated by `startServerSdkPublisher`.
## Environment variables
| Variable | Publishers | Meaning |
| -------------- | ---------- | ---------------------------------------------------------- |
| `LIVEKIT_URL` | all | LiveKit server URL |
| `LIVEKIT_TOKEN`| all | Ready-made join token (identity `<sdk>-publisher`) |
| `VIDEO_CODEC` | all | `vp8`, `h264`, `vp9` or `av1` |
| `VIDEO_LAYERS` | all | `1` (default), `2` or `3` |
| `VIDEO_FILE` | go | Pre-encoded 640x480 file for one layer |
| `VIDEO_FILE_LOW`, `VIDEO_FILE_MEDIUM`, `VIDEO_FILE_HIGH` | go | Pre-encoded file per simulcast layer |
The token is generated by the Java test so the programs stay short (no per-SDK
token dependency) and work with any API secret length.
## Other files
- `logback.xml`: Logback configuration picked up from the test classpath.
- `ScreenCapturing.crx`: packaged Chrome screen-capture extension. Not
referenced by name from the Java code.

View File

@ -1,26 +1,19 @@
// Minimal LiveKit .NET RTC SDK publisher used by OpenViduTestAppE2eTest. // Minimal LiveKit .NET SDK publisher used by OpenViduTestAppE2eServerSdkTest.
// Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec // See ../README.md
// VIDEO_CODEC (vp8, h264, vp9 or av1). VIDEO_LAYERS=single (default): one plain
// RTP encoding (no simulcast for VP8/H264, no SVC = ScalabilityMode L1T1 for
// VP9/AV1). VIDEO_LAYERS=multi: two layers, simulcast for VP8/H264 (the SDK
// derives 480x360 + 640x480 from the 640x480 source) and SVC L2T2 for
// VP9/AV1. Pushes synthetic animated frames forever (the Java test stops the
// container).
// Requires Livekit.Rtc.Dotnet >= 0.1.4 (TrackPublishOptions.VideoCodec).
// Env: LIVEKIT_URL, LIVEKIT_TOKEN, VIDEO_CODEC, VIDEO_LAYERS
using LiveKit.Rtc; using LiveKit.Rtc;
using Proto = LiveKit.Proto; using Proto = LiveKit.Proto;
var codec = Environment.GetEnvironmentVariable("VIDEO_CODEC")!; var codec = Environment.GetEnvironmentVariable("VIDEO_CODEC")!;
var multiLayer = Environment.GetEnvironmentVariable("VIDEO_LAYERS") == "multi"; var layers = int.Parse(Environment.GetEnvironmentVariable("VIDEO_LAYERS") ?? "1");
int width = 640; // The SDK only splits a simulcast source in three layers from 960 px wide
int height = 480; int width = layers == 3 ? 1280 : 640;
int height = layers == 3 ? 720 : 480;
var room = new Room(); var room = new Room();
await room.ConnectAsync( await room.ConnectAsync(
Environment.GetEnvironmentVariable("LIVEKIT_URL")!, Environment.GetEnvironmentVariable("LIVEKIT_URL")!,
Environment.GetEnvironmentVariable("LIVEKIT_TOKEN")!, Environment.GetEnvironmentVariable("LIVEKIT_TOKEN")!,
new RoomOptions { AutoSubscribe = false }); new RoomOptions { AutoSubscribe = false, Dynacast = false });
var videoSource = new VideoSource(width, height); var videoSource = new VideoSource(width, height);
var videoTrack = LocalVideoTrack.Create("dotnet-video", videoSource); var videoTrack = LocalVideoTrack.Create("dotnet-video", videoSource);
@ -31,11 +24,11 @@ var options = new TrackPublishOptions
}; };
if (codec == "vp8" || codec == "h264") if (codec == "vp8" || codec == "h264")
{ {
options.Simulcast = multiLayer; options.Simulcast = layers > 1;
} }
else else
{ {
options.ScalabilityMode = multiLayer ? "L2T2" : "L1T1"; options.ScalabilityMode = $"L{layers}T{layers}";
} }
await room.LocalParticipant!.PublishTrackAsync(videoTrack, options); await room.LocalParticipant!.PublishTrackAsync(videoTrack, options);
@ -55,6 +48,6 @@ while (true)
data[i + 3] = 255; data[i + 3] = 255;
} }
videoSource.CaptureFrame(new VideoFrame(width, height, Proto.VideoBufferType.Rgba, data)); videoSource.CaptureFrame(new VideoFrame(width, height, Proto.VideoBufferType.Rgba, data));
// 15 fps in multi mode: several software encoders at 30 fps trigger CPU adaptation // 15 fps with several layers: several software encoders at 30 fps trigger CPU adaptation
await Task.Delay(multiLayer ? 66 : 33); await Task.Delay(layers > 1 ? 66 : 33);
} }

View File

@ -1,21 +1,5 @@
// Minimal LiveKit Go SDK publisher used by OpenViduTestAppE2eTest. // Minimal LiveKit Go SDK publisher used by OpenViduTestAppE2eServerSdkTest
// // See ../README.md
// It joins the room of LIVEKIT_TOKEN and publishes one video track with codec
// VIDEO_CODEC (vp8, h264, vp9 or av1), looping pre-encoded files forever
// (Annex-B for H264, IVF otherwise; the Java test stops the container):
//
// - VIDEO_LAYERS=single (default): one plain RTP encoding, no simulcast,
// from VIDEO_FILE. This is the publish shape of every Go SDK client (lk
// CLI, lk load-test, Go agents): the AddTrackRequest declares no codec —
// the exact configuration that triggered the mediasoup Producer
// codec-binding bug (see MEDIASOUP_CODEC_BINDING_BUG.md).
// - VIDEO_LAYERS=multi: two RID simulcast layers (LOW 320x240, HIGH
// 640x480) from VIDEO_FILE_LOW / VIDEO_FILE_HIGH. The Go SDK forwards
// pre-encoded samples and has no encoder, so it cannot produce SVC
// streams; its VP9/AV1 multi-layer cases are skipped by the Java test
// (RID simulcast of SVC-class codecs is not a supported publish shape).
//
// Env: LIVEKIT_URL, LIVEKIT_TOKEN, VIDEO_CODEC, VIDEO_LAYERS, VIDEO_FILE*
package main package main
import ( import (
@ -50,9 +34,19 @@ type simulcastLayer struct {
fileEnv string fileEnv string
} }
var simulcastLayers = []simulcastLayer{ // Simulcast layers per VIDEO_LAYERS value, lowest first (the sizes must match
{livekit.VideoQuality_LOW, 320, 240, "VIDEO_FILE_LOW"}, // the files generated by OpenViduTestE2e#startServerSdkPublisher). The last
{livekit.VideoQuality_HIGH, 640, 480, "VIDEO_FILE_HIGH"}, // layer is the source size declared in the publication.
var simulcastLayers = map[string][]simulcastLayer{
"2": {
{livekit.VideoQuality_LOW, 320, 240, "VIDEO_FILE_LOW"},
{livekit.VideoQuality_HIGH, 640, 480, "VIDEO_FILE_HIGH"},
},
"3": {
{livekit.VideoQuality_LOW, 320, 180, "VIDEO_FILE_LOW"},
{livekit.VideoQuality_MEDIUM, 640, 360, "VIDEO_FILE_MEDIUM"},
{livekit.VideoQuality_HIGH, 1280, 720, "VIDEO_FILE_HIGH"},
},
} }
func main() { func main() {
@ -68,9 +62,17 @@ func main() {
} }
defer room.Disconnect() defer room.Disconnect()
if os.Getenv("VIDEO_LAYERS") == "multi" { layers := os.Getenv("VIDEO_LAYERS")
tracks := make([]*lksdk.LocalTrack, 0, len(simulcastLayers)) if layers == "" {
for _, layer := range simulcastLayers { layers = "1"
}
if layers != "1" {
layerDefs, ok := simulcastLayers[layers]
if !ok {
log.Fatalf("unsupported VIDEO_LAYERS %q", layers)
}
tracks := make([]*lksdk.LocalTrack, 0, len(layerDefs))
for _, layer := range layerDefs {
track, err := lksdk.NewLocalSampleTrack(codec, lksdk.WithSimulcast("go-video", track, err := lksdk.NewLocalSampleTrack(codec, lksdk.WithSimulcast("go-video",
&livekit.VideoLayer{Quality: layer.quality, Width: layer.width, Height: layer.height})) &livekit.VideoLayer{Quality: layer.quality, Width: layer.width, Height: layer.height}))
if err != nil { if err != nil {
@ -78,16 +80,17 @@ func main() {
} }
tracks = append(tracks, track) tracks = append(tracks, track)
} }
top := layerDefs[len(layerDefs)-1]
if _, err = room.LocalParticipant.PublishSimulcastTrack(tracks, &lksdk.TrackPublicationOptions{ if _, err = room.LocalParticipant.PublishSimulcastTrack(tracks, &lksdk.TrackPublicationOptions{
Name: "go-video", Name: "go-video",
VideoWidth: 640, VideoWidth: int(top.width),
VideoHeight: 480, VideoHeight: int(top.height),
}); err != nil { }); err != nil {
log.Fatalf("could not publish simulcast track: %v", err) log.Fatalf("could not publish simulcast track: %v", err)
} }
// The Java test waits for this exact log line before asserting. // The Java test waits for this exact log line before asserting.
fmt.Println("TRACK_PUBLISHED") fmt.Println("TRACK_PUBLISHED")
for i, layer := range simulcastLayers { for i, layer := range layerDefs {
go loopVideoFile(tracks[i], os.Getenv(layer.fileEnv)) go loopVideoFile(tracks[i], os.Getenv(layer.fileEnv))
} }
select {} select {}

View File

@ -1,12 +1,5 @@
// Minimal LiveKit Node RTC SDK publisher used by OpenViduTestAppE2eTest. // Minimal LiveKit Node SDK publisher used by OpenViduTestAppE2eServerSdkTest
// Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec // See ../README.md
// VIDEO_CODEC (vp8, h264, vp9 or av1). VIDEO_LAYERS=single (default): one plain
// RTP encoding (no simulcast for VP8/H264, no SVC = scalabilityMode L1T1 for
// VP9/AV1). VIDEO_LAYERS=multi: two layers, simulcast for VP8/H264 (the SDK
// derives 480x360 + 640x480 from the 640x480 source) and SVC L2T2 for
// VP9/AV1. Pushes synthetic animated frames forever (the Java test stops the
// container).
// Env: LIVEKIT_URL, LIVEKIT_TOKEN, VIDEO_CODEC, VIDEO_LAYERS
import { import {
Room, Room,
LocalVideoTrack, LocalVideoTrack,
@ -19,9 +12,9 @@ import {
} from '@livekit/rtc-node'; } from '@livekit/rtc-node';
const codec = process.env.VIDEO_CODEC; const codec = process.env.VIDEO_CODEC;
const multiLayer = process.env.VIDEO_LAYERS === 'multi'; const layers = Number(process.env.VIDEO_LAYERS || '1');
const WIDTH = 640; // The SDK only splits a simulcast source in three layers from 960 px wide
const HEIGHT = 480; const [WIDTH, HEIGHT] = layers === 3 ? [1280, 720] : [640, 480];
const room = new Room(); const room = new Room();
await room.connect(process.env.LIVEKIT_URL, process.env.LIVEKIT_TOKEN, { await room.connect(process.env.LIVEKIT_URL, process.env.LIVEKIT_TOKEN, {
@ -36,9 +29,9 @@ const options = new TrackPublishOptions({
source: TrackSource.SOURCE_CAMERA, source: TrackSource.SOURCE_CAMERA,
}); });
if (codec === 'vp8' || codec === 'h264') { if (codec === 'vp8' || codec === 'h264') {
options.simulcast = multiLayer; options.simulcast = layers > 1;
} else { } else {
options.scalabilityMode = multiLayer ? 'L2T2' : 'L1T1'; options.scalabilityMode = `L${layers}T${layers}`;
} }
await room.localParticipant.publishTrack(track, options); await room.localParticipant.publishTrack(track, options);
@ -59,4 +52,4 @@ setInterval(() => {
} }
} }
source.captureFrame(new VideoFrame(buf, WIDTH, HEIGHT, VideoBufferType.RGBA)); source.captureFrame(new VideoFrame(buf, WIDTH, HEIGHT, VideoBufferType.RGBA));
}, multiLayer ? 66 : 33); // 15 fps in multi mode: several software encoders at 30 fps trigger libwebrtc CPU adaptation (layer sizes shrink) }, layers > 1 ? 66 : 33); // 15 fps with several layers: several software encoders at 30 fps trigger libwebrtc CPU adaptation (layer sizes shrink)

View File

@ -1,28 +1,22 @@
# Minimal LiveKit Python RTC SDK publisher used by OpenViduTestAppE2eTest. # Minimal LiveKit Python SDK publisher used by OpenViduTestAppE2eServerSdkTest
# Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec # See ../README.md
# VIDEO_CODEC (vp8, h264, vp9 or av1). VIDEO_LAYERS=single (default): one plain
# RTP encoding (no simulcast for VP8/H264, no SVC = scalability_mode L1T1 for
# VP9/AV1). VIDEO_LAYERS=multi: two layers, simulcast for VP8/H264 (the SDK
# derives 480x360 + 640x480 from the 640x480 source) and SVC L2T2 for
# VP9/AV1. Pushes synthetic animated frames forever (the Java test stops the
# container).
# Env: LIVEKIT_URL, LIVEKIT_TOKEN, VIDEO_CODEC, VIDEO_LAYERS
import asyncio import asyncio
import os import os
from livekit import rtc from livekit import rtc
MULTI_LAYER = os.environ.get("VIDEO_LAYERS") == "multi" LAYERS = int(os.environ.get("VIDEO_LAYERS", "1"))
WIDTH = 640 # The SDK only splits a simulcast source in three layers from 960 px wide
HEIGHT = 480 WIDTH, HEIGHT = (1280, 720) if LAYERS == 3 else (640, 480)
async def main(): async def main():
codec = os.environ["VIDEO_CODEC"] codec = os.environ["VIDEO_CODEC"]
room = rtc.Room() room = rtc.Room()
await room.connect( await room.connect(
os.environ["LIVEKIT_URL"], os.environ["LIVEKIT_TOKEN"], options=rtc.RoomOptions(auto_subscribe=False) os.environ["LIVEKIT_URL"],
os.environ["LIVEKIT_TOKEN"],
options=rtc.RoomOptions(auto_subscribe=False, dynacast=False),
) )
source = rtc.VideoSource(WIDTH, HEIGHT) source = rtc.VideoSource(WIDTH, HEIGHT)
@ -32,9 +26,9 @@ async def main():
source=rtc.TrackSource.SOURCE_CAMERA, source=rtc.TrackSource.SOURCE_CAMERA,
) )
if codec in ("vp8", "h264"): if codec in ("vp8", "h264"):
options.simulcast = MULTI_LAYER options.simulcast = LAYERS > 1
else: else:
options.scalability_mode = "L2T2" if MULTI_LAYER else "L1T1" options.scalability_mode = f"L{LAYERS}T{LAYERS}"
await room.local_participant.publish_track(track, options) await room.local_participant.publish_track(track, options)
# The Java test waits for this exact log line before asserting # The Java test waits for this exact log line before asserting
@ -45,7 +39,8 @@ async def main():
n = (n + 7) % 256 n = (n + 7) % 256
data = bytes((n, 255 - n, (n * 3) % 256, 255)) * (WIDTH * HEIGHT) data = bytes((n, 255 - n, (n * 3) % 256, 255)) * (WIDTH * HEIGHT)
source.capture_frame(rtc.VideoFrame(WIDTH, HEIGHT, rtc.VideoBufferType.RGBA, data)) source.capture_frame(rtc.VideoFrame(WIDTH, HEIGHT, rtc.VideoBufferType.RGBA, data))
await asyncio.sleep(1 / 15 if MULTI_LAYER else 1 / 30) # 15 fps in multi mode: several software encoders at 30 fps trigger CPU adaptation # 15 fps with several layers: several software encoders at 30 fps trigger CPU adaptation
await asyncio.sleep(1 / 15 if LAYERS > 1 else 1 / 30)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -464,9 +464,9 @@ dependencies = [
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.22" version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"

View File

@ -1,12 +1,5 @@
// Minimal LiveKit Rust SDK publisher used by OpenViduTestAppE2eTest. // Minimal LiveKit Rust SDK publisher used by OpenViduTestAppE2eServerSdkTest
// Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec // See ../README.md
// VIDEO_CODEC (vp8, h264, vp9 or av1). VIDEO_LAYERS=single (default): one plain
// RTP encoding (no simulcast for VP8/H264, no SVC = scalability_mode L1T1 for
// VP9/AV1). VIDEO_LAYERS=multi: two layers, simulcast for VP8/H264 (the SDK
// derives 480x360 + 640x480 from the 640x480 source) and SVC L2T2 for
// VP9/AV1. Pushes synthetic animated frames forever (the Java test stops the
// container).
// Env: LIVEKIT_URL, LIVEKIT_TOKEN, VIDEO_CODEC, VIDEO_LAYERS
use std::{env, time::Duration}; use std::{env, time::Duration};
use livekit::options::{TrackPublishOptions, VideoCodec}; use livekit::options::{TrackPublishOptions, VideoCodec};
@ -18,8 +11,9 @@ use livekit::{Room, RoomOptions};
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let codec = env::var("VIDEO_CODEC").unwrap(); let codec = env::var("VIDEO_CODEC").unwrap();
let multi_layer = env::var("VIDEO_LAYERS").map(|v| v == "multi").unwrap_or(false); let layers: u32 = env::var("VIDEO_LAYERS").ok().and_then(|v| v.parse().ok()).unwrap_or(1);
let (width, height): (u32, u32) = (640, 480); // The SDK only splits a simulcast source in three layers from 960 px wide
let (width, height): (u32, u32) = if layers == 3 { (1280, 720) } else { (640, 480) };
let mut options = TrackPublishOptions { let mut options = TrackPublishOptions {
source: TrackSource::Camera, source: TrackSource::Camera,
@ -33,15 +27,18 @@ async fn main() {
..Default::default() ..Default::default()
}; };
if codec == "vp8" || codec == "h264" { if codec == "vp8" || codec == "h264" {
options.simulcast = multi_layer; options.simulcast = layers > 1;
} else { } else {
options.scalability_mode = Some(if multi_layer { "L2T2" } else { "L1T1" }.to_string()); options.scalability_mode = Some(format!("L{layers}T{layers}"));
} }
// RoomOptions is #[non_exhaustive]: it cannot be built with a struct expression
let mut room_options = RoomOptions::default();
room_options.dynacast = false;
let (room, mut _events) = Room::connect( let (room, mut _events) = Room::connect(
&env::var("LIVEKIT_URL").unwrap(), &env::var("LIVEKIT_URL").unwrap(),
&env::var("LIVEKIT_TOKEN").unwrap(), &env::var("LIVEKIT_TOKEN").unwrap(),
RoomOptions::default(), room_options,
) )
.await .await
.expect("could not connect to room"); .expect("could not connect to room");
@ -73,7 +70,7 @@ async fn main() {
data_u.fill(255 - n); data_u.fill(255 - n);
data_v.fill(n.wrapping_mul(3)); data_v.fill(n.wrapping_mul(3));
source.capture_frame(&frame); source.capture_frame(&frame);
// 15 fps in multi mode: several software encoders at 30 fps trigger CPU adaptation // 15 fps with several layers: several software encoders at 30 fps trigger CPU adaptation
tokio::time::sleep(Duration::from_millis(if multi_layer { 66 } else { 33 })).await; tokio::time::sleep(Duration::from_millis(if layers > 1 { 66 } else { 33 })).await;
} }
} }

View File

@ -44,7 +44,7 @@
<mat-form-field id="max-video-quality" class="video-btn quality-option" matTooltip="Set video quality" matTooltipClass="custom-tooltip"> <mat-form-field id="max-video-quality" class="video-btn quality-option" matTooltip="Set video quality" matTooltipClass="custom-tooltip">
<mat-select [(value)]="maxVideoQuality" (selectionChange)="onQualityChange()"> <mat-select [(value)]="maxVideoQuality" (selectionChange)="onQualityChange()">
@for (q of ['LOW', 'MEDIUM', 'HIGH']; track q) { @for (q of ['LOW', 'MEDIUM', 'HIGH']; track q) {
<mat-option [value]="q" [ngClass]="'mode-' + q">{{q}}</mat-option> <mat-option [value]="q" [id]="'mat-option-' + q" [ngClass]="'mode-' + q">{{q}}</mat-option>
} }
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>