From 2fe3bd87f2f29c15e8cf26c0afbe9bb62b9d6e3d Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Sat, 5 Sep 2026 21:10:56 +0200 Subject: [PATCH] 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. --- .../io/openvidu/test/e2e/OpenViduTestE2e.java | 162 +++++----- .../e2e/OpenViduTestAppE2eServerSdkTest.java | 301 ++++++++++-------- .../src/test/resources/README.md | 81 +++++ .../resources/dotnet-publisher/Program.cs | 29 +- .../src/test/resources/go-publisher/main.go | 57 ++-- .../test/resources/node-publisher/main.mjs | 23 +- .../test/resources/python-publisher/main.py | 29 +- .../test/resources/rust-publisher/Cargo.lock | 4 +- .../test/resources/rust-publisher/src/main.rs | 29 +- .../video-track/video-track.component.html | 2 +- 10 files changed, 411 insertions(+), 306 deletions(-) create mode 100644 openvidu-test-e2e/src/test/resources/README.md diff --git a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java index 85248cbce..fe223854a 100644 --- a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java +++ b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java @@ -368,6 +368,10 @@ public class OpenViduTestE2e { 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 * 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 * 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 - * and runs it there, with a per-SDK dependency cache mounted at /cache so - * repeated runs skip downloads. Blocks until the participant has published - * its track (its TRACK_PUBLISHED log line). The container is stopped - * automatically on test dispose. + * The container mounts the program read-only at /app, copies it to /work and + * runs it there, with a per-SDK dependency cache mounted at /cache so + * repeated runs skip downloads. Returns once the program logs + * TRACK_PUBLISHED. The container is stopped automatically on test dispose. */ - public void startServerSdkPublisher(String sdk, String roomName, String codec) throws Exception { - startServerSdkPublisher(sdk, roomName, codec, false); - } - - /** - * Launches the minimal publisher program of the given LiveKit server RTC SDK - * (src/test/resources/-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 { + public void startServerSdkPublisher(String sdk, String roomName, String codec, int layers) throws Exception { + if (layers < 1 || layers > 3) { + throw new IllegalArgumentException("Unsupported number of video layers: " + layers); + } final String image; final String runCommand; int startupTimeoutMinutes = 5; Map env = new HashMap<>(); switch (sdk) { - case "go": - image = "golang:1.27"; - runCommand = "go run ."; - env.put("GOMODCACHE", "/cache/gomod"); - env.put("GOCACHE", "/cache/gobuild"); - break; - case "node": - image = "node:24"; - runCommand = "npm install --no-audit --no-fund --loglevel=error && node main.mjs"; - env.put("npm_config_cache", "/cache/npm"); - break; - case "python": - image = "python:3.14"; - runCommand = "pip install -q -r requirements.txt && python main.py"; - env.put("PIP_CACHE_DIR", "/cache/pip"); - break; - case "rust": - image = "rust:1"; - // webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21 - // (apt.llvm.org); the first run then compiles the whole livekit - // crate graph - 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" - + " && cargo run --release --locked"; - env.put("CARGO_HOME", "/cache/cargo"); - env.put("CARGO_TARGET_DIR", "/cache/cargo-target"); - env.put("CC", "clang-21"); - env.put("CXX", "clang++-21"); - startupTimeoutMinutes = 30; - break; - case "dotnet": - image = "mcr.microsoft.com/dotnet/sdk:10.0"; - runCommand = "dotnet run -c Release"; - env.put("NUGET_PACKAGES", "/cache/nuget"); - env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1"); - startupTimeoutMinutes = 10; - break; - default: - throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk); + case "go": + image = "golang:1.27"; + runCommand = "go run ."; + env.put("GOMODCACHE", "/cache/gomod"); + env.put("GOCACHE", "/cache/gobuild"); + break; + case "node": + image = "node:24"; + runCommand = "npm install --no-audit --no-fund --loglevel=error && node main.mjs"; + env.put("npm_config_cache", "/cache/npm"); + break; + case "python": + image = "python:3.14"; + runCommand = "pip install -q -r requirements.txt && python main.py"; + env.put("PIP_CACHE_DIR", "/cache/pip"); + break; + case "rust": + image = "rust:1"; + // webrtc-sys links a prebuilt libwebrtc that requires clang++ >= 21 + // (apt.llvm.org); the first run then compiles the whole livekit + // crate graph + 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" + + " && cargo run --release --locked"; + env.put("CARGO_HOME", "/cache/cargo"); + env.put("CARGO_TARGET_DIR", "/cache/cargo-target"); + env.put("CC", "clang-21"); + env.put("CXX", "clang++-21"); + startupTimeoutMinutes = 30; + break; + case "dotnet": + image = "mcr.microsoft.com/dotnet/sdk:10.0"; + runCommand = "dotnet run -c Release"; + env.put("NUGET_PACKAGES", "/cache/nuget"); + env.put("DOTNET_CLI_TELEMETRY_OPTOUT", "1"); + startupTimeoutMinutes = 10; + break; + default: + throw new IllegalArgumentException("Unknown server SDK publisher: " + sdk); } // Publishers receive a ready-made token: it keeps the programs shorter // (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_TOKEN", accessToken.toJwt()); 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(); 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. final String encoder; switch (codec) { - case "h264": - encoder = "-c:v libx264 -profile:v baseline -level 3.1 -x264-params keyint=30:scenecut=0 -f h264"; - break; - case "vp8": - encoder = "-c:v libvpx -g 30 -f ivf"; - break; - case "vp9": - encoder = "-c:v libvpx-vp9 -g 30 -f ivf"; - break; - case "av1": - encoder = "-c:v libaom-av1 -usage realtime -cpu-used 8 -g 30 -f ivf"; - break; - default: - throw new IllegalArgumentException("Unknown video codec: " + codec); + case "h264": + encoder = "-c:v libx264 -profile:v baseline -level 3.1 -x264-params keyint=30:scenecut=0 -f h264"; + break; + case "vp8": + encoder = "-c:v libvpx -g 30 -f ivf"; + break; + case "vp9": + encoder = "-c:v libvpx-vp9 -g 30 -f ivf"; + break; + case "av1": + encoder = "-c:v libaom-av1 -usage realtime -cpu-used 8 -g 30 -f ivf"; + break; + default: + throw new IllegalArgumentException("Unknown video codec: " + codec); } Path mediaDir = Files.createTempDirectory("go-publisher-media"); - // One file per layer: the single encoding, or the three simulcast - // layers (must match the sizes declared by go-publisher/main.go) - Map layerSizes = multiLayer - ? Map.of("VIDEO_FILE_LOW", "320x240", "VIDEO_FILE_HIGH", "640x480") - : Map.of("VIDEO_FILE", "640x480"); + // One file per layer: the single encoding or the simulcast layers + // (must match the sizes declared by go-publisher/main.go) + final Map layerSizes; + switch (layers) { + 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 layer : layerSizes.entrySet()) { Path videoFile = mediaDir .resolve("test-video-" + layer.getValue() + "." + ("h264".equals(codec) ? "h264" : "ivf")); diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java index 6cf7c1495..f773160d7 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java @@ -64,83 +64,87 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT 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. - * System properties sdk.codecs / sdk.layers (comma-separated) restrict the - * matrix, e.g. -Dsdk.layers=multi -Dsdk.codecs=vp9,av1 + * {vp8, h264, vp9, av1} x {1, 2, 3} layers, single-layer cases first. System + * properties sdk.codecs / sdk.layers (comma-separated) restrict the matrix, + * e.g. -Dsdk.layers=2,3 -Dsdk.codecs=vp9,av1 */ static Stream serverSdkPublisherMatrix() { - List layersFilter = List.of(System.getProperty("sdk.layers", "single,multi").split(",")); + List layersFilter = List.of(System.getProperty("sdk.layers", "1,2,3").split(",")); List 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) .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") - @DisplayName("Go SDK publisher to Chrome and Firefox subscribers") - void goSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + @DisplayName("Go SDK publisher to Chrome and Firefox early and late subscribers") + void goSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception { 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") - @DisplayName("Node SDK publisher to Chrome and Firefox subscribers") - void nodeSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + @DisplayName("Node SDK publisher to Chrome and Firefox early and late subscribers") + void nodeSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception { 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") - @DisplayName("Python SDK publisher to Chrome and Firefox subscribers") - void pythonSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + @DisplayName("Python SDK publisher to Chrome and Firefox early and late subscribers") + void pythonSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception { 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") - @DisplayName("Rust SDK publisher to Chrome and Firefox subscribers") - void rustSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + @DisplayName("Rust SDK publisher to Chrome and Firefox early and late subscribers") + void rustSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception { 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") - @DisplayName(".NET SDK publisher to Chrome and Firefox subscribers") - void dotnetSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception { + @DisplayName(".NET SDK publisher to Chrome and Firefox early and late subscribers") + void dotnetSdkPublisherToBrowserSubscribersTest(String codec, int layers) throws Exception { // Requires Livekit.Rtc.Dotnet >= 0.1.4 (TrackPublishOptions.VideoCodec) serverSdkPublisherToBrowserSubscribersAux("dotnet", codec, layers); } /** - * A Chrome browser and a Firefox browser join the room as subscriber-only - * participants and a LiveKit server RTC SDK participant - * (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. + * A subscriber-only participant of the room: the testapp instance (index + * within its browser page) that hosts it, and its participant name. */ - private void serverSdkPublisherToBrowserSubscribersAux(String sdk, String codec, String layers) - throws Exception { + private record Subscriber(OpenViduTestappUser user, int instance, String name) { + 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 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 // 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)), "The Go SDK cannot publish SVC, and VP9/AV1 RID simulcast is not a supported LiveKit publish shape"); - List subscribers = List.of(setupBrowserAndConnectToOpenViduTestapp("chrome"), - setupBrowserAndConnectToOpenViduTestapp("firefox")); + log.info("{} SDK {} {}-layer publisher to Chrome and Firefox early and late subscribers", sdk, codec, + 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) { - this.addSubscriber(user, false); - WebElement participantNameInput = user.getDriver().findElement(By.id("participant-name-input-0")); - 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); - } + // Early subscribers: in the room before the SDK publishes + List earlySubscribers = List.of(joinAsSubscriberOnly(chrome, "early"), + joinAsSubscriberOnly(firefox, "early")); - this.startServerSdkPublisher(sdk, "TestRoom", codec, multiLayer); + this.startServerSdkPublisher(sdk, "TestRoom", codec, layers); // The server's authoritative view of the publication (RoomService API): // the LiveKit TrackInfo of a video publication lists one layer per // simulcast layer or SVC spatial layer, so one plain encoding (no - // simulcast, no SVC / L1T1) has exactly one layer and a two-layer - // publish (simulcast or SVC L2T2) has two + // simulcast, no SVC / L1T1) has exactly one layer and a two- or + // three-layer publish (simulcast or SVC L2T2 / L3T3) has two or three TrackInfo trackInfo = this.getPublishedVideoTrackInfo("TestRoom", publisherIdentity); - final int expectedLayers = multiLayer ? 2 : 1; - Assertions.assertEquals(expectedLayers, trackInfo.getLayersCount(), "Expected " + expectedLayers + Assertions.assertEquals(layers, trackInfo.getLayersCount(), "Expected " + layers + " video layer(s) in the track published by the " + sdk + " SDK, but the server reports " + trackInfo.getLayersList()); - Assertions.assertEquals(expectedLayers, trackInfo.getCodecs(0).getLayersCount(), "Expected " + expectedLayers - + " video layer(s) for codec " + expectedCodec + " published by the " + sdk + " SDK"); - // VP8/H264 multi-layer publishes are simulcast and VP9/AV1 ones are SVC - // L2T2, except for the Go SDK: it forwards pre-encoded samples (no - // encoder, so no SVC) and publishes simulcast for every codec + Assertions.assertEquals(layers, trackInfo.getCodecs(0).getLayersCount(), + "Expected " + layers + " video layer(s) for codec " + expectedCodec + " published by the " + sdk + + " SDK"); + Assertions.assertEquals(layers, sortedLayerWidths(trackInfo).size(), + "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)); if (!multiLayer || expectSimulcast) { 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"); } - // Both browsers must receive the track, with its codec and its layers - for (OpenViduTestappUser user : subscribers) { - assertSubscriberReceivesVideo(user, sdk, publisherIdentity, expectedCodec, trackInfo); + for (Subscriber subscriber : earlySubscribers) { + assertSubscriberReceivesVideo(subscriber, sdk, publisherIdentity, expectedCodec, trackInfo); } + assertSubscribersSwitchLayers(earlySubscribers, trackInfo); - for (OpenViduTestappUser user : subscribers) { - gracefullyLeaveParticipants(user, 1); + // Late subscribers: the publisher has been streaming for a while + List 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 "--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. */ @@ -211,38 +239,43 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT /** * 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()) { user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click(); Thread.sleep(300); } - user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 #max-video-quality")).click(); - this.waitAndClick(user, "mat-option.mode-" + quality); + this.selectMatOption(user, subscriber.instanceSelector() + " #max-video-quality", quality); } /** - * The subscriber-only participant of the given browser must receive the video - * track published by the SDK participant: media actually flowing, with the - * expected codec, with the layers the server reports in trackInfo. With more - * than one layer the subscriber switches to the LOW and then to the HIGH - * layer, each recognised by its frame width. + * The subscriber-only participant must receive the video track published by + * the SDK participant: media actually flowing, with the expected codec, with + * the layers the server reports in trackInfo. */ - private void assertSubscriberReceivesVideo(OpenViduTestappUser user, String sdk, String publisherIdentity, + private void assertSubscriberReceivesVideo(Subscriber subscriber, String sdk, String publisherIdentity, 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)); - Assertions.assertTrue(assertAllElementsHaveTracks(user, "video", false, true), - browser + ": HTMLVideoElements were expected to have only one video track"); + user.getWaiter().until( + ExpectedConditions.numberOfElementsToBe(By.cssSelector(subscriber.instanceSelector() + " video"), 1)); + 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 - // 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); this.waitUntilSubscriberBytesReceivedIncreasing(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 // the wrong codec makes every subscriber negotiate that wrong codec) 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 // the subscriber - JsonArray subscriberLayers = this.getRemoteVideoTrackInfoLayers(user, publisherIdentity); + JsonArray subscriberLayers = this.getRemoteVideoTrackInfoLayers(subscriber, publisherIdentity); Assertions.assertEquals(trackInfo.getLayersCount(), subscriberLayers.size(), "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 - // only changes through the max-video-quality selector. Receiving the - // LOW layer and then the HIGH layer — each recognised by the frame - // width the publisher declared for it in the TrackInfo, and each - // actually decoding (framesPerSecond > 0: an undecodable layer still - // reports its frame size) — proves that the publisher sends every - // layer and that the SFU forwards the requested one. The declared - // widths are reliable because the multi-layer publishers capture at - // 15 fps: at 30 fps libwebrtc's CPU adaptation can scale every layer - // down under load - this.selectQualityAndAwaitLayer(user, subscriberVideo, "LOW", lowestLayerWidth(trackInfo)); - this.selectQualityAndAwaitLayer(user, subscriberVideo, "HIGH", highestLayerWidth(trackInfo)); + /** + * With more than one published layer, the given subscribers (all of them + * receiving the track already) switch together to the LOW layer, then (with + * three layers) to the MEDIUM one and then to the HIGH one, each subscriber + * recognising each layer by its frame width. With adaptiveStream disabled the + * received layer only changes through the max-video-quality selector, and + * each layer must actually decode (framesPerSecond > 0: an undecodable layer + * still reports its frame size): this proves that the publisher sends every + * layer and that the SFU forwards the requested one, also the middle spatial + * layer, which neither LOW nor HIGH can clamp to. The declared widths are + * reliable because the multi-layer publishers capture at 15 fps: at 30 fps + * libwebrtc's CPU adaptation can scale every layer down under load. + */ + private void assertSubscribersSwitchLayers(List subscribers, TrackInfo trackInfo) throws Exception { + List 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 * can take longer than one wait window to ramp back up to a higher layer. */ - private void selectQualityAndAwaitLayer(OpenViduTestappUser user, WebElement subscriberVideo, String quality, - int expectedFrameWidth) throws Exception { + private void selectQualityAndAwaitLayer(Subscriber subscriber, String quality, int expectedFrameWidth) + throws Exception { + OpenViduTestappUser user = subscriber.user(); + WebElement subscriberVideo = subscriber.remoteVideo(); for (int attempt = 1; attempt <= 2; attempt++) { - this.selectSubscriberVideoQuality(user, quality); + this.selectSubscriberVideoQuality(subscriber, quality); try { this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, expectedFrameWidth); break; @@ -298,23 +349,15 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT } /** - * Width of the lowest-quality published layer, from the server's TrackInfo. - * The layers are picked by width, not by their VideoQuality label: the SDKs - * label a two-layer publish inconsistently (simulcast: LOW + MEDIUM; SVC - * L2T2: MEDIUM + HIGH; the Go program: LOW + HIGH), while the subscriber's - * LOW/HIGH selection always clamps to the lowest/highest available layer. + * Distinct widths of the published layers, lowest first, from the server's + * TrackInfo. The layers are picked by width, not by their VideoQuality label: + * the SDKs label a two-layer publish inconsistently (simulcast: LOW + MEDIUM; + * SVC L2T2: MEDIUM + HIGH; the Go program: LOW + HIGH), while the + * 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) { - return trackInfo.getLayersList().stream().mapToInt(VideoLayer::getWidth).min() - .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)); + private List sortedLayerWidths(TrackInfo trackInfo) { + return trackInfo.getLayersList().stream().map(VideoLayer::getWidth).distinct().sorted().toList(); } /** @@ -341,11 +384,11 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT /** * Video layers (VideoLayer[] of the LiveKit TrackInfo) of the first video * 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) { - String layers = (String) ((JavascriptExecutor) user.getDriver()).executeScript( - "var room = window['room_0'];" + private JsonArray getRemoteVideoTrackInfoLayers(Subscriber subscriber, String participantIdentity) { + String layers = (String) ((JavascriptExecutor) subscriber.user().getDriver()).executeScript( + "var room = window['room_" + subscriber.instance() + "'];" + "var participant = room.remoteParticipants.get(arguments[0]);" + "var publication = participant.videoTrackPublications.values().next().value;" + "return JSON.stringify(publication.trackInfo.layers);", diff --git a/openvidu-test-e2e/src/test/resources/README.md b/openvidu-test-e2e/src/test/resources/README.md new file mode 100644 index 000000000..8d46a17df --- /dev/null +++ b/openvidu-test-e2e/src/test/resources/README.md @@ -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 `-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. diff --git a/openvidu-test-e2e/src/test/resources/dotnet-publisher/Program.cs b/openvidu-test-e2e/src/test/resources/dotnet-publisher/Program.cs index 20633b033..43fee9976 100644 --- a/openvidu-test-e2e/src/test/resources/dotnet-publisher/Program.cs +++ b/openvidu-test-e2e/src/test/resources/dotnet-publisher/Program.cs @@ -1,26 +1,19 @@ -// Minimal LiveKit .NET RTC SDK publisher used by OpenViduTestAppE2eTest. -// Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec -// 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 +// Minimal LiveKit .NET SDK publisher used by OpenViduTestAppE2eServerSdkTest. +// See ../README.md using LiveKit.Rtc; using Proto = LiveKit.Proto; var codec = Environment.GetEnvironmentVariable("VIDEO_CODEC")!; -var multiLayer = Environment.GetEnvironmentVariable("VIDEO_LAYERS") == "multi"; -int width = 640; -int height = 480; +var layers = int.Parse(Environment.GetEnvironmentVariable("VIDEO_LAYERS") ?? "1"); +// The SDK only splits a simulcast source in three layers from 960 px wide +int width = layers == 3 ? 1280 : 640; +int height = layers == 3 ? 720 : 480; var room = new Room(); await room.ConnectAsync( Environment.GetEnvironmentVariable("LIVEKIT_URL")!, Environment.GetEnvironmentVariable("LIVEKIT_TOKEN")!, - new RoomOptions { AutoSubscribe = false }); + new RoomOptions { AutoSubscribe = false, Dynacast = false }); var videoSource = new VideoSource(width, height); var videoTrack = LocalVideoTrack.Create("dotnet-video", videoSource); @@ -31,11 +24,11 @@ var options = new TrackPublishOptions }; if (codec == "vp8" || codec == "h264") { - options.Simulcast = multiLayer; + options.Simulcast = layers > 1; } else { - options.ScalabilityMode = multiLayer ? "L2T2" : "L1T1"; + options.ScalabilityMode = $"L{layers}T{layers}"; } await room.LocalParticipant!.PublishTrackAsync(videoTrack, options); @@ -55,6 +48,6 @@ while (true) data[i + 3] = 255; } videoSource.CaptureFrame(new VideoFrame(width, height, Proto.VideoBufferType.Rgba, data)); - // 15 fps in multi mode: several software encoders at 30 fps trigger CPU adaptation - await Task.Delay(multiLayer ? 66 : 33); + // 15 fps with several layers: several software encoders at 30 fps trigger CPU adaptation + await Task.Delay(layers > 1 ? 66 : 33); } diff --git a/openvidu-test-e2e/src/test/resources/go-publisher/main.go b/openvidu-test-e2e/src/test/resources/go-publisher/main.go index a2d00c353..ccbb4c5f2 100644 --- a/openvidu-test-e2e/src/test/resources/go-publisher/main.go +++ b/openvidu-test-e2e/src/test/resources/go-publisher/main.go @@ -1,21 +1,5 @@ -// Minimal LiveKit Go SDK publisher used by OpenViduTestAppE2eTest. -// -// 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* +// Minimal LiveKit Go SDK publisher used by OpenViduTestAppE2eServerSdkTest +// See ../README.md package main import ( @@ -50,9 +34,19 @@ type simulcastLayer struct { fileEnv string } -var simulcastLayers = []simulcastLayer{ - {livekit.VideoQuality_LOW, 320, 240, "VIDEO_FILE_LOW"}, - {livekit.VideoQuality_HIGH, 640, 480, "VIDEO_FILE_HIGH"}, +// Simulcast layers per VIDEO_LAYERS value, lowest first (the sizes must match +// the files generated by OpenViduTestE2e#startServerSdkPublisher). The last +// 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() { @@ -68,9 +62,17 @@ func main() { } defer room.Disconnect() - if os.Getenv("VIDEO_LAYERS") == "multi" { - tracks := make([]*lksdk.LocalTrack, 0, len(simulcastLayers)) - for _, layer := range simulcastLayers { + layers := os.Getenv("VIDEO_LAYERS") + if layers == "" { + 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", &livekit.VideoLayer{Quality: layer.quality, Width: layer.width, Height: layer.height})) if err != nil { @@ -78,16 +80,17 @@ func main() { } tracks = append(tracks, track) } + top := layerDefs[len(layerDefs)-1] if _, err = room.LocalParticipant.PublishSimulcastTrack(tracks, &lksdk.TrackPublicationOptions{ Name: "go-video", - VideoWidth: 640, - VideoHeight: 480, + VideoWidth: int(top.width), + VideoHeight: int(top.height), }); err != nil { log.Fatalf("could not publish simulcast track: %v", err) } // The Java test waits for this exact log line before asserting. fmt.Println("TRACK_PUBLISHED") - for i, layer := range simulcastLayers { + for i, layer := range layerDefs { go loopVideoFile(tracks[i], os.Getenv(layer.fileEnv)) } select {} diff --git a/openvidu-test-e2e/src/test/resources/node-publisher/main.mjs b/openvidu-test-e2e/src/test/resources/node-publisher/main.mjs index b7e4e85e3..fbbef28cf 100644 --- a/openvidu-test-e2e/src/test/resources/node-publisher/main.mjs +++ b/openvidu-test-e2e/src/test/resources/node-publisher/main.mjs @@ -1,12 +1,5 @@ -// Minimal LiveKit Node RTC SDK publisher used by OpenViduTestAppE2eTest. -// Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec -// 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 +// Minimal LiveKit Node SDK publisher used by OpenViduTestAppE2eServerSdkTest +// See ../README.md import { Room, LocalVideoTrack, @@ -19,9 +12,9 @@ import { } from '@livekit/rtc-node'; const codec = process.env.VIDEO_CODEC; -const multiLayer = process.env.VIDEO_LAYERS === 'multi'; -const WIDTH = 640; -const HEIGHT = 480; +const layers = Number(process.env.VIDEO_LAYERS || '1'); +// The SDK only splits a simulcast source in three layers from 960 px wide +const [WIDTH, HEIGHT] = layers === 3 ? [1280, 720] : [640, 480]; const room = new Room(); await room.connect(process.env.LIVEKIT_URL, process.env.LIVEKIT_TOKEN, { @@ -36,9 +29,9 @@ const options = new TrackPublishOptions({ source: TrackSource.SOURCE_CAMERA, }); if (codec === 'vp8' || codec === 'h264') { - options.simulcast = multiLayer; + options.simulcast = layers > 1; } else { - options.scalabilityMode = multiLayer ? 'L2T2' : 'L1T1'; + options.scalabilityMode = `L${layers}T${layers}`; } await room.localParticipant.publishTrack(track, options); @@ -59,4 +52,4 @@ setInterval(() => { } } 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) diff --git a/openvidu-test-e2e/src/test/resources/python-publisher/main.py b/openvidu-test-e2e/src/test/resources/python-publisher/main.py index 41b3227a9..225e814ca 100644 --- a/openvidu-test-e2e/src/test/resources/python-publisher/main.py +++ b/openvidu-test-e2e/src/test/resources/python-publisher/main.py @@ -1,28 +1,22 @@ -# Minimal LiveKit Python RTC SDK publisher used by OpenViduTestAppE2eTest. -# Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec -# 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 +# Minimal LiveKit Python SDK publisher used by OpenViduTestAppE2eServerSdkTest +# See ../README.md import asyncio import os from livekit import rtc -MULTI_LAYER = os.environ.get("VIDEO_LAYERS") == "multi" -WIDTH = 640 -HEIGHT = 480 - +LAYERS = int(os.environ.get("VIDEO_LAYERS", "1")) +# The SDK only splits a simulcast source in three layers from 960 px wide +WIDTH, HEIGHT = (1280, 720) if LAYERS == 3 else (640, 480) async def main(): codec = os.environ["VIDEO_CODEC"] room = rtc.Room() 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) @@ -32,9 +26,9 @@ async def main(): source=rtc.TrackSource.SOURCE_CAMERA, ) if codec in ("vp8", "h264"): - options.simulcast = MULTI_LAYER + options.simulcast = LAYERS > 1 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) # The Java test waits for this exact log line before asserting @@ -45,7 +39,8 @@ async def main(): n = (n + 7) % 256 data = bytes((n, 255 - n, (n * 3) % 256, 255)) * (WIDTH * HEIGHT) 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__": diff --git a/openvidu-test-e2e/src/test/resources/rust-publisher/Cargo.lock b/openvidu-test-e2e/src/test/resources/rust-publisher/Cargo.lock index 81a064a7c..5041369fe 100644 --- a/openvidu-test-e2e/src/test/resources/rust-publisher/Cargo.lock +++ b/openvidu-test-e2e/src/test/resources/rust-publisher/Cargo.lock @@ -464,9 +464,9 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-common" diff --git a/openvidu-test-e2e/src/test/resources/rust-publisher/src/main.rs b/openvidu-test-e2e/src/test/resources/rust-publisher/src/main.rs index 70ae2dd02..8e5ace91c 100644 --- a/openvidu-test-e2e/src/test/resources/rust-publisher/src/main.rs +++ b/openvidu-test-e2e/src/test/resources/rust-publisher/src/main.rs @@ -1,12 +1,5 @@ -// Minimal LiveKit Rust SDK publisher used by OpenViduTestAppE2eTest. -// Joins the room of LIVEKIT_TOKEN and publishes a single video track with codec -// 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 +// Minimal LiveKit Rust SDK publisher used by OpenViduTestAppE2eServerSdkTest +// See ../README.md use std::{env, time::Duration}; use livekit::options::{TrackPublishOptions, VideoCodec}; @@ -18,8 +11,9 @@ use livekit::{Room, RoomOptions}; #[tokio::main] async fn main() { let codec = env::var("VIDEO_CODEC").unwrap(); - let multi_layer = env::var("VIDEO_LAYERS").map(|v| v == "multi").unwrap_or(false); - let (width, height): (u32, u32) = (640, 480); + let layers: u32 = env::var("VIDEO_LAYERS").ok().and_then(|v| v.parse().ok()).unwrap_or(1); + // 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 { source: TrackSource::Camera, @@ -33,15 +27,18 @@ async fn main() { ..Default::default() }; if codec == "vp8" || codec == "h264" { - options.simulcast = multi_layer; + options.simulcast = layers > 1; } 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( &env::var("LIVEKIT_URL").unwrap(), &env::var("LIVEKIT_TOKEN").unwrap(), - RoomOptions::default(), + room_options, ) .await .expect("could not connect to room"); @@ -73,7 +70,7 @@ async fn main() { data_u.fill(255 - n); data_v.fill(n.wrapping_mul(3)); source.capture_frame(&frame); - // 15 fps in multi mode: several software encoders at 30 fps trigger CPU adaptation - tokio::time::sleep(Duration::from_millis(if multi_layer { 66 } else { 33 })).await; + // 15 fps with several layers: several software encoders at 30 fps trigger CPU adaptation + tokio::time::sleep(Duration::from_millis(if layers > 1 { 66 } else { 33 })).await; } } diff --git a/openvidu-testapp/src/app/components/video-track/video-track.component.html b/openvidu-testapp/src/app/components/video-track/video-track.component.html index 401aced44..40314f6ae 100644 --- a/openvidu-testapp/src/app/components/video-track/video-track.component.html +++ b/openvidu-testapp/src/app/components/video-track/video-track.component.html @@ -44,7 +44,7 @@ @for (q of ['LOW', 'MEDIUM', 'HIGH']; track q) { - {{q}} + {{q}} }