diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java
index a105a1a33..67825d041 100644
--- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java
+++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java
@@ -1295,6 +1295,181 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
}
}
+ @Test
+ @DisplayName("Firefox H264 simulcast BWE convergence")
+ void firefoxH264SimulcastBweConvergenceTest() throws Exception {
+ log.info("Firefox H264 simulcast BWE convergence");
+ simulcastBweConvergenceTest("h264", "firefox");
+ }
+
+ @Test
+ @DisplayName("Firefox VP8 simulcast BWE convergence")
+ void firefoxVP8SimulcastBweConvergenceTest() throws Exception {
+ log.info("Firefox VP8 simulcast BWE convergence");
+ simulcastBweConvergenceTest("vp8", "firefox");
+ }
+
+ @Test
+ @DisplayName("Chrome H264 simulcast BWE convergence")
+ void chromeH264SimulcastBweConvergenceTest() throws Exception {
+ log.info("Chrome H264 simulcast BWE convergence");
+ simulcastBweConvergenceTest("h264", "chrome");
+ }
+
+ @Test
+ @DisplayName("Chrome VP8 simulcast BWE convergence")
+ void chromeVP8SimulcastBweConvergenceTest() throws Exception {
+ log.info("Chrome VP8 simulcast BWE convergence");
+ simulcastBweConvergenceTest("vp8", "chrome");
+ }
+
+ private void simulcastBweConvergenceTest(String publisherCodec, String subscriberBrowser) throws Exception {
+ final int EXPECTED_HIGHEST_WIDTH = 1920;
+ // With mediasoup the publisher's high simulcast layer can take ~20s to activate
+ // (a separate, still-open BWE issue from the subscriber-side). Allow margin
+ // above that so the test reflects "does it converge" rather than "is it fast".
+ final int BWE_CONVERGENCE_TIMEOUT_MS = 40000;
+ final int POLL_INTERVAL_MS = 2000;
+ final String subscriberLabel = subscriberBrowser.toUpperCase() + "_SUBSCRIBER";
+ final CountDownLatch latch = new CountDownLatch(2);
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ // Wall-clock timestamps of when each side first reports the 1920 layer.
+ // Comparing them tells whether a slow convergence is publisher-uplink or
+ // subscriber-downlink.
+ final java.util.concurrent.atomic.AtomicLong publisher1920AtMs = new java.util.concurrent.atomic.AtomicLong(-1);
+ final java.util.concurrent.atomic.AtomicLong subscriber1920AtMs = new java.util.concurrent.atomic.AtomicLong(
+ -1);
+
+ Future> task1 = executor.submit(() -> {
+ try {
+ OpenViduTestappUser chromeUser = setupBrowserAndConnectToOpenViduTestapp("chrome");
+ this.addOnlyPublisherVideo(chromeUser, true, false, false);
+ WebElement participantNameInput = chromeUser.getDriver()
+ .findElement(By.id("participant-name-input-0"));
+ participantNameInput.clear();
+ participantNameInput.sendKeys("CHROME_PUBLISHER");
+ this.forceCodec(chromeUser, 0, publisherCodec);
+ this.setPublisherSimulcastLayersAndResolution(chromeUser, 0, "h360", 1920, 1080);
+ chromeUser.getDriver().findElement(By.className("connect-btn")).click();
+ chromeUser.getEventManager().waitUntilEventReaches("localTrackSubscribed", "ParticipantEvent", 1);
+
+ // Measure when the PUBLISHER itself starts actively sending the 1920 (rid
+ // "f") layer (active==true && frameWidth==1920). Compared with the
+ // subscriber's 1920 time, this disambiguates publisher-uplink vs
+ // subscriber-downlink as the convergence bottleneck.
+ WebElement publisherVideo = chromeUser.getDriver()
+ .findElement(By.cssSelector("#openvidu-instance-0 video.local"));
+ waitUntilVideoLayersNotEmpty(chromeUser, publisherVideo);
+ long pubStart = System.currentTimeMillis();
+ while (System.currentTimeMillis() - pubStart < BWE_CONVERGENCE_TIMEOUT_MS) {
+ try {
+ String rawLayers = getLayersAsString(chromeUser, publisherVideo);
+ log.info("publisher [{}] raw layers: {}", publisherCodec, rawLayers);
+ // Highest active layer width across all simulcast layers (rid-agnostic).
+ Integer maxActiveWidth = null;
+ for (JsonElement el : JsonParser.parseString(rawLayers).getAsJsonArray()) {
+ JsonObject lo = el.getAsJsonObject();
+ JsonElement wEl = lo.get("frameWidth");
+ JsonElement aEl = lo.get("active");
+ boolean active = aEl != null && !aEl.isJsonNull() && aEl.getAsBoolean();
+ if (active && wEl != null && !wEl.isJsonNull()) {
+ int w = wEl.getAsInt();
+ if (maxActiveWidth == null || w > maxActiveWidth) {
+ maxActiveWidth = w;
+ }
+ }
+ }
+ if (maxActiveWidth != null && maxActiveWidth >= EXPECTED_HIGHEST_WIDTH) {
+ publisher1920AtMs.set(System.currentTimeMillis());
+ break;
+ }
+ } catch (Exception e) {
+ log.warn("Error getting publisher 'f' layer", e);
+ }
+ Thread.sleep(POLL_INTERVAL_MS);
+ }
+ latch.countDown();
+ latch.await(60, TimeUnit.SECONDS);
+ gracefullyLeaveParticipants(chromeUser, 1);
+ } catch (Exception e) {
+ Assertions.fail("Error while setting up Chrome publisher", e);
+ }
+ });
+
+ Future> task2 = executor.submit(() -> {
+ try {
+ OpenViduTestappUser subscriberUser = setupBrowserAndConnectToOpenViduTestapp(subscriberBrowser);
+ this.addSubscriber(subscriberUser, false);
+ WebElement participantNameInput = subscriberUser.getDriver()
+ .findElement(By.id("participant-name-input-0"));
+ participantNameInput.clear();
+ participantNameInput.sendKeys(subscriberLabel);
+ subscriberUser.getDriver().findElement(By.className("connect-btn")).click();
+ subscriberUser.getEventManager().waitUntilEventReaches("trackSubscribed", "ParticipantEvent", 1);
+ subscriberUser.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("video"), 1));
+ Assertions.assertTrue(assertAllElementsHaveTracks(subscriberUser, "video", false, true),
+ "HTMLVideoElements were expected to have only one video track");
+
+ WebElement subscriberVideo = subscriberUser.getDriver()
+ .findElement(By.cssSelector("#openvidu-instance-0 video.remote"));
+ waitUntilVideoLayersNotEmpty(subscriberUser, subscriberVideo);
+
+ // Poll for BWE convergence: subscriber should reach highest layer
+ long startTime = System.currentTimeMillis();
+ int lastWidth = 0;
+ boolean converged = false;
+ while (System.currentTimeMillis() - startTime < BWE_CONVERGENCE_TIMEOUT_MS) {
+ try {
+ lastWidth = getSubscriberVideoFrameWidth(subscriberUser, subscriberVideo);
+ log.info("{} subscriber frameWidth: {}", subscriberBrowser, lastWidth);
+ if (lastWidth == EXPECTED_HIGHEST_WIDTH) {
+ converged = true;
+ subscriber1920AtMs.set(System.currentTimeMillis());
+ break;
+ }
+ } catch (Exception e) {
+ log.warn("Error getting subscriber frameWidth", e);
+ }
+ Thread.sleep(POLL_INTERVAL_MS);
+ }
+ Assertions.assertTrue(converged,
+ subscriberBrowser + " subscriber BWE did not converge to highest layer. Last frameWidth: "
+ + lastWidth + ". Expected: " + EXPECTED_HIGHEST_WIDTH);
+
+ latch.countDown();
+ latch.await(10, TimeUnit.SECONDS);
+ gracefullyLeaveParticipants(subscriberUser, 1);
+ } catch (Exception e) {
+ Assertions.fail("Error while setting up " + subscriberBrowser + " subscriber", e);
+ }
+ });
+
+ try {
+ task1.get();
+ task2.get();
+ } catch (ExecutionException ex) {
+ Assertions.fail("Error while running browsers in parallel", ex);
+ }
+
+ long pub = publisher1920AtMs.get();
+ long sub = subscriber1920AtMs.get();
+ String verdict;
+ if (pub < 0 && sub < 0) {
+ verdict = "neither publisher nor subscriber reached 1920 within timeout";
+ } else if (pub < 0) {
+ verdict = "subscriber reached 1920 but publisher never reported actively sending it (measurement gap)";
+ } else if (sub < 0) {
+ verdict = "publisher sent 1920 but subscriber never received it";
+ } else {
+ long deltaMs = sub - pub;
+ verdict = "subscriber got 1920 " + deltaMs + "ms after the publisher started sending it => "
+ + (deltaMs > 6000 ? "SUBSCRIBER-DOWNLINK is the bottleneck"
+ : "PUBLISHER-UPLINK-gated (subscriber receives 1920 ~as soon as the publisher sends it)");
+ }
+ log.info("[BWE-SIDE] codec={} subscriber={} publisher1920AtMs={} subscriber1920AtMs={} => {}", publisherCodec,
+ subscriberBrowser, pub, sub, verdict);
+ }
+
@Test
@DisplayName("Firefox toggle subscription")
void firefoxToggleSubscriptionTest() throws Exception {
@@ -2612,8 +2787,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress VP8 Simulcast Chrome")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressVP8SimulcastChromeTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
@@ -2627,8 +2800,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress VP8 Simulcast Firefox")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressVP8SimulcastFirefoxTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("firefox");
@@ -2642,8 +2813,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress H264 Simulcast Chrome")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressH264SimulcastChromeTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
@@ -2656,8 +2825,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress H264 Simulcast Firefox")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressH264SimulcastFirefoxTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("firefox");
@@ -2670,8 +2837,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress H264 Simulcast two layers Chrome")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressH264SimulcastTwoLayersChromeTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
@@ -2684,8 +2849,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress H264 Simulcast two layers Firefox")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressH264SimulcastTwoLayersFirefoxTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("firefox");
@@ -2722,8 +2885,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress H264 No Simulcast Chrome")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressH264NoSimulcastChromeTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
@@ -2736,8 +2897,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
@Test
@DisplayName("Ingress H264 No Simulcast Firefox")
- // TODO: remove tag when not forcing VP8 no-simulcast in ingress with mediasoup
- @OnlyPion
void ingressH264NoSimulcastFirefoxTest() throws Exception {
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("firefox");
@@ -2748,75 +2907,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
testNoSimulcast(user, subscriberVideo);
}
- @Test
- @DisplayName("Custom ingress")
- // TODO: remove tag when not using custom ingress image with mediasoup
- @OnlyMediasoup
- void customIngressTest() throws Exception {
- OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("firefox");
-
- // With custom ingress and mediasoup it should force VP8 no simulcast with
- // highest quality layer width, height, framerate and bitrate
- log.info("Custom ingress");
-
- this.addSubscriber(user, true);
- user.getDriver().findElement(By.className("connect-btn")).sendKeys(Keys.ENTER);
- user.getEventManager().waitUntilEventReaches("connected", "RoomEvent", 1);
-
- // Try publishing H264 with 2 layer simulcast
- createIngress(user, "H264_540P_25FPS_2_LAYERS", null, true, "HTTP", null);
-
- user.getEventManager().waitUntilEventReaches("trackSubscribed", "ParticipantEvent", 1);
- user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("video"), 1));
- int numberOfVideos = user.getDriver().findElements(By.tagName("video")).size();
- Assertions.assertEquals(1, numberOfVideos, "Wrong number of videos");
- Assertions.assertTrue(assertAllElementsHaveTracks(user, "video", false, true),
- "HTMLVideoElements were expected to have only one video track");
-
- // Should receive VP8 960x540 25 fps
- WebElement subscriberVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.remote"));
- waitUntilVideoLayersNotEmpty(user, subscriberVideo);
- Assertions.assertEquals(1, getLayersAsJsonArray(user, subscriberVideo).size());
- long bytesReceived = this.getSubscriberVideoBytesReceived(user, subscriberVideo);
- this.waitUntilSubscriberBytesReceivedIncrease(user, subscriberVideo, bytesReceived);
- this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
- JsonArray json = this.getLayersAsJsonArray(user, subscriberVideo);
- String subscriberCodec = json.get(0).getAsJsonObject().get("codec").getAsString();
- String expectedCodec = "video/VP8";
- Assertions.assertEquals(expectedCodec, subscriberCodec);
- this.waitUntilSubscriberFramesPerSecondIs(user, subscriberVideo, 25);
- this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960);
- this.waitUntilSubscriberFrameHeightIs(user, subscriberVideo, 540);
-
- this.deleteAllIngresses(LK_INGRESS);
- user.getEventManager().waitUntilEventReaches("trackUnpublished", "RoomEvent", 1);
- user.getEventManager().waitUntilEventReaches("participantDisconnected", "RoomEvent", 1);
-
- // Try publishing H264 with 3 layer simulcast
- createIngress(user, "H264_1080P_30FPS_3_LAYERS_HIGH_MOTION", null, true, "HTTP", null);
- user.getEventManager().waitUntilEventReaches("trackSubscribed", "ParticipantEvent", 1);
- user.getWaiter().until(ExpectedConditions.numberOfElementsToBe(By.tagName("video"), 1));
- numberOfVideos = user.getDriver().findElements(By.tagName("video")).size();
- Assertions.assertEquals(1, numberOfVideos, "Wrong number of videos");
- Assertions.assertTrue(assertAllElementsHaveTracks(user, "video", false, true),
- "HTMLVideoElements were expected to have only one video track");
-
- // Should receive VP8 1920x1080 30 fps
- subscriberVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.remote"));
- waitUntilVideoLayersNotEmpty(user, subscriberVideo);
- Assertions.assertEquals(1, getLayersAsJsonArray(user, subscriberVideo).size());
- bytesReceived = this.getSubscriberVideoBytesReceived(user, subscriberVideo);
- this.waitUntilSubscriberBytesReceivedIncrease(user, subscriberVideo, bytesReceived);
- this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
- json = this.getLayersAsJsonArray(user, subscriberVideo);
- subscriberCodec = json.get(0).getAsJsonObject().get("codec").getAsString();
- expectedCodec = "video/VP8";
- Assertions.assertEquals(expectedCodec, subscriberCodec);
- this.waitUntilSubscriberFramesPerSecondIs(user, subscriberVideo, 30);
- this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 1920);
- this.waitUntilSubscriberFrameHeightIs(user, subscriberVideo, 1080);
- }
-
@Test
@DisplayName("RTSP ingress H264 + OPUS")
void rtspIngressH264_OPUSTest() throws Exception {
@@ -3649,6 +3739,20 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
Thread.sleep(300);
}
+ private void setPublisherSimulcastLayersAndResolution(OpenViduTestappUser user, int numberOfUser,
+ String simulcastLayerName, Integer width, Integer height) throws InterruptedException {
+ this.waitForBackdropAndClick(user, "#room-options-btn-" + numberOfUser);
+ Thread.sleep(300);
+ this.setPublisherCustomVideoProperties(user, width, height, null);
+ user.getDriver().findElement(By.id("trackPublish-videoSimulcastLayers")).click();
+ this.waitForBackdropAndClick(user, "#mat-option-" + simulcastLayerName);
+ new org.openqa.selenium.interactions.Actions(user.getDriver())
+ .sendKeys(org.openqa.selenium.Keys.ESCAPE).perform();
+ Thread.sleep(300);
+ this.waitForBackdropAndClick(user, "#close-dialog-btn");
+ Thread.sleep(300);
+ }
+
private void setPublisherCustomVideoProperties(OpenViduTestappUser user, Integer width, Integer height,
String scalabilityMode) {
user.getDriver().findElement(By.id("video-capture-custom")).click();
diff --git a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html
index bb17ba6eb..a61c048ea 100644
--- a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html
+++ b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.html
@@ -215,6 +215,14 @@
}
+
+ videoSimulcastLayers
+
+ @for (item of videoSimulcastLayerPresets; track item.name) {
+ {{item.name}}
+ }
+
+
name
diff --git a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts
index 6ad7ed1b4..8bd6e513f 100644
--- a/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts
+++ b/openvidu-testapp/src/app/components/dialogs/options-dialog/options-dialog.component.ts
@@ -19,6 +19,8 @@ import {
Track,
TrackPublishOptions,
VideoCaptureOptions,
+ VideoPreset,
+ VideoPresets,
} from 'livekit-client';
@Component({
@@ -52,6 +54,25 @@ export class OptionsDialogComponent {
ENUMERATION_SOURCE = Object.keys(Track.Source);
+ // Selectable presets for TrackPublishOptions.videoSimulcastLayers.
+ videoSimulcastLayerPresets: { name: string; preset: VideoPreset }[] = [
+ { name: 'h90', preset: VideoPresets.h90 },
+ { name: 'h180', preset: VideoPresets.h180 },
+ { name: 'h216', preset: VideoPresets.h216 },
+ { name: 'h360', preset: VideoPresets.h360 },
+ { name: 'h540', preset: VideoPresets.h540 },
+ { name: 'h720', preset: VideoPresets.h720 },
+ { name: 'h1080', preset: VideoPresets.h1080 },
+ { name: 'h1440', preset: VideoPresets.h1440 },
+ { name: 'h2160', preset: VideoPresets.h2160 },
+ ];
+
+ // mat-select compares option values by reference by default; match presets by
+ // resolution so a preselected videoSimulcastLayers array (possibly built from a
+ // different VideoPreset instance) still highlights the right options.
+ compareVideoPresets = (a: VideoPreset, b: VideoPreset): boolean =>
+ !!a && !!b && a.width === b.width && a.height === b.height;
+
inputVideoDevices: MediaDeviceInfo[] = [];
private data = inject<{