openvidu-test-e2e: add new SVC and server SDK tests

pull/907/head
pabloFuente 2026-09-03 12:04:18 +02:00
parent 73c3d581bd
commit 2528690bc0
18 changed files with 10186 additions and 15 deletions

View File

@ -10,6 +10,7 @@ import java.net.URL;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Duration;
import java.security.KeyManagementException; import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Collection; import java.util.Collection;
@ -31,13 +32,18 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.testcontainers.containers.BindMode;
import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.Network; import org.testcontainers.containers.Network;
import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.containers.wait.strategy.WaitStrategy; import org.testcontainers.containers.wait.strategy.WaitStrategy;
import org.testcontainers.utility.DockerImageName; import org.testcontainers.utility.DockerImageName;
import io.livekit.server.AccessToken;
import io.livekit.server.CanPublish;
import io.livekit.server.IngressServiceClient; import io.livekit.server.IngressServiceClient;
import io.livekit.server.RoomJoin;
import io.livekit.server.RoomName;
import io.livekit.server.RoomServiceClient; import io.livekit.server.RoomServiceClient;
import io.openvidu.test.browsers.BrowserUser; import io.openvidu.test.browsers.BrowserUser;
import io.openvidu.test.browsers.ChromeUser; import io.openvidu.test.browsers.ChromeUser;
@ -362,6 +368,151 @@ public class OpenViduTestE2e {
return "srt://" + srtServerIp + ":" + RTSP_SRT_PORT; return "srt://" + srtServerIp + ":" + RTSP_SRT_PORT;
} }
/**
* Starts a LiveKit server RTC SDK participant (a minimal program at
* src/test/resources/<sdk>-publisher) in a plain runtime container
* that joins the given room and publishes a single video track with the
* 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.
*/
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/<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 runCommand;
int startupTimeoutMinutes = 5;
Map<String, String> env = new HashMap<>();
switch (sdk) {
case "go":
image = "golang:1.26";
runCommand = "go run .";
env.put("GOMODCACHE", "/cache/gomod");
env.put("GOCACHE", "/cache/gobuild");
break;
case "node":
image = "node:22";
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.12";
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:8.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
AccessToken accessToken = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET);
accessToken.setIdentity(sdk + "-publisher");
accessToken.addGrants(new RoomJoin(true), new RoomName(roomName), new CanPublish(true));
env.put("LIVEKIT_URL", LIVEKIT_URL);
env.put("LIVEKIT_TOKEN", accessToken.toJwt());
env.put("VIDEO_CODEC", codec);
env.put("VIDEO_LAYERS", multiLayer ? "multi" : "single");
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);
Files.createDirectories(cacheDir);
GenericContainer<?> publisherContainer = new GenericContainer<>(DockerImageName.parse(image))
.withCreateContainerCmdModifier(
cmd -> cmd.withName(sdk + "-publisher-" + (int) (Math.random() * 100000)))
.withNetworkMode("host").withFileSystemBind(programDir, "/app", BindMode.READ_ONLY)
.withFileSystemBind(cacheDir.toString(), "/cache").withEnv(env)
.withCommand("sh", "-c", "mkdir -p /work && cp -r /app/. /work && cd /work && " + runCommand)
.withLogConsumer(
frame -> log.info("[{}-publisher] {}", sdk, frame.getUtf8String().stripTrailing()))
.waitingFor(Wait.forLogMessage("^.*TRACK_PUBLISHED.*$", 1)
.withStartupTimeout(Duration.ofMinutes(startupTimeoutMinutes)));
if ("go".equals(sdk)) {
// The Go SDK sends pre-encoded media: loop a real (decodable) 5s
// file generated with the host's ffmpeg (Annex-B for H264, IVF for
// VP8/VP9/AV1). The FFI-based SDKs (node, python, rust, dotnet)
// 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);
}
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<String, String> layerSizes = multiLayer
? Map.of("VIDEO_FILE_LOW", "320x240", "VIDEO_FILE_HIGH", "640x480")
: Map.of("VIDEO_FILE", "640x480");
for (Map.Entry<String, String> layer : layerSizes.entrySet()) {
Path videoFile = mediaDir
.resolve("test-video-" + layer.getValue() + "." + ("h264".equals(codec) ? "h264" : "ivf"));
commandLine.executeCommand("ffmpeg -y -f lavfi -i testsrc=size=" + layer.getValue()
+ ":rate=30 -t 5 -pix_fmt yuv420p " + encoder + " " + videoFile, 120);
if (!Files.exists(videoFile) || Files.size(videoFile) == 0) {
Assertions.fail("ffmpeg could not generate the " + codec + " test file " + videoFile);
}
publisherContainer.withEnv(layer.getKey(), "/media/" + videoFile.getFileName());
}
publisherContainer.withFileSystemBind(mediaDir.toString(), "/media", BindMode.READ_ONLY);
}
publisherContainer.start();
containers.add(publisherContainer);
}
private void waitUntilLog(GenericContainer<?> container, String regex, int secondsTimeout) { private void waitUntilLog(GenericContainer<?> container, String regex, int secondsTimeout) {
int t = secondsTimeout * 2; // We wait half a second between retries int t = secondsTimeout * 2; // We wait half a second between retries
Pattern pattern = Pattern.compile(regex); Pattern pattern = Pattern.compile(regex);

View File

@ -27,6 +27,8 @@ import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import java.util.Map; import java.util.Map;
import java.util.Map.Entry; import java.util.Map.Entry;
import java.util.concurrent.Callable; import java.util.concurrent.Callable;
@ -43,6 +45,7 @@ import org.apache.commons.lang3.tuple.ImmutablePair;
import org.apache.commons.lang3.tuple.Pair; import org.apache.commons.lang3.tuple.Pair;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Disabled;
@ -50,6 +53,10 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.openqa.selenium.By; import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.Keys; import org.openqa.selenium.Keys;
@ -79,6 +86,10 @@ import io.minio.messages.Item;
import livekit.LivekitIngress.IngressInfo; import livekit.LivekitIngress.IngressInfo;
import livekit.LivekitIngress.IngressState; import livekit.LivekitIngress.IngressState;
import livekit.LivekitModels.ConnectionQuality; import livekit.LivekitModels.ConnectionQuality;
import livekit.LivekitModels.ParticipantInfo;
import livekit.LivekitModels.TrackInfo;
import livekit.LivekitModels.TrackType;
import livekit.LivekitModels.VideoLayer;
import static org.openqa.selenium.OutputType.BASE64; import static org.openqa.selenium.OutputType.BASE64;
@ -3257,8 +3268,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
String subscriberCodec = this.getSubscriberVideoCodec(user, subscriberVideo); String subscriberCodec = this.getSubscriberVideoCodec(user, subscriberVideo);
Assertions.assertEquals("video/" + codec.toUpperCase(), subscriberCodec); Assertions.assertEquals("video/" + codec.toUpperCase(), subscriberCodec);
// Subscriber should settle in 960 this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, 960);
this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo);
changeElementSize(user, subscriberVideo, 1000, 700); changeElementSize(user, subscriberVideo, 1000, 700);
@ -3379,15 +3389,9 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
} }
@Test @Test
@DisplayName("SVC VP9 (L3T3_KEY)") @DisplayName("SVC VP9 (L1T1)")
void svcVP9L3T3_KEYTest() throws Exception { void svcVP9L1T1Test() throws Exception {
svcTest("VP9", "L3T3_KEY", false); svcTest("VP9", "L1T1", false);
}
@Test
@DisplayName("SVC AV1 (L3T3_KEY)")
void svcAV1L3T3_KEYTest() throws Exception {
svcTest("AV1", "L3T3_KEY", false);
} }
@Test @Test
@ -3396,6 +3400,30 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
svcTest("VP9", "L2T2", false); svcTest("VP9", "L2T2", false);
} }
@Test
@DisplayName("SVC VP9 (L2T2_KEY)")
void svcVP9L2T2_KEYTest() throws Exception {
svcTest("VP9", "L2T2_KEY", false);
}
@Test
@DisplayName("SVC VP9 (L3T3)")
void svcVP9L3T3Test() throws Exception {
svcTest("VP9", "L3T3", false);
}
@Test
@DisplayName("SVC VP9 (L3T3_KEY)")
void svcVP9L3T3_KEYTest() throws Exception {
svcTest("VP9", "L3T3_KEY", false);
}
@Test
@DisplayName("SVC AV1 (L1T1)")
void svcAV1L1T1Test() throws Exception {
svcTest("AV1", "L1T1", false);
}
@Test @Test
@DisplayName("SVC AV1 (L2T2)") @DisplayName("SVC AV1 (L2T2)")
void svcAV1L2T2Test() throws Exception { void svcAV1L2T2Test() throws Exception {
@ -3403,11 +3431,83 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
} }
@Test @Test
@DisplayName("SVC AV1 (L2T2) with adaptiveStream") @DisplayName("SVC AV1 (L2T2_KEY)")
void svcAV1L2T2WithAdaptiveStreamTest() throws Exception { void svcAV1L2T2_KEYTest() throws Exception {
svcTest("AV1", "L2T2_KEY", false);
}
@Test
@DisplayName("SVC AV1 (L3T3)")
void svcAV1L3T3Test() throws Exception {
svcTest("AV1", "L3T3", false);
}
@Test
@DisplayName("SVC AV1 (L3T3_KEY)")
void svcAV1L3T3_KEYTest() throws Exception {
svcTest("AV1", "L3T3_KEY", false);
}
@Test
@DisplayName("SVC VP9 (L1T1) adaptiveStream")
void svcVP9L1T1AdaptiveStreamTest() throws Exception {
svcTest("VP9", "L1T1", true);
}
@Test
@DisplayName("SVC VP9 (L2T2) adaptiveStream")
void svcVP9L2T2AdaptiveStreamTest() throws Exception {
svcTest("VP9", "L2T2", true);
}
@Test
@DisplayName("SVC VP9 (L2T2_KEY) adaptiveStream")
void svcVP9L2T2_KEYAdaptiveStreamTest() throws Exception {
svcTest("VP9", "L2T2_KEY", true);
}
@Test
@DisplayName("SVC VP9 (L3T3) adaptiveStream")
void svcVP9L3T3AdaptiveStreamTest() throws Exception {
svcTest("VP9", "L3T3", true);
}
@Test
@DisplayName("SVC VP9 (L3T3_KEY) adaptiveStream")
void svcVP9L3T3_KEYAdaptiveStreamTest() throws Exception {
svcTest("VP9", "L3T3_KEY", true);
}
@Test
@DisplayName("SVC AV1 (L1T1) adaptiveStream")
void svcAV1L1T1AdaptiveStreamTest() throws Exception {
svcTest("AV1", "L1T1", true);
}
@Test
@DisplayName("SVC AV1 (L2T2) adaptiveStream")
void svcAV1L2T2AdaptiveStreamTest() throws Exception {
svcTest("AV1", "L2T2", true); svcTest("AV1", "L2T2", true);
} }
@Test
@DisplayName("SVC AV1 (L2T2_KEY) adaptiveStream")
void svcAV1L2T2_KEYAdaptiveStreamTest() throws Exception {
svcTest("AV1", "L2T2_KEY", true);
}
@Test
@DisplayName("SVC AV1 (L3T3) adaptiveStream")
void svcAV1L3T3AdaptiveStreamTest() throws Exception {
svcTest("AV1", "L3T3", true);
}
@Test
@DisplayName("SVC AV1 (L3T3_KEY) adaptiveStream")
void svcAV1L3T3_KEYAdaptiveStreamTest() throws Exception {
svcTest("AV1", "L3T3_KEY", true);
}
private void svcTest(String codec, String scalabilityMode, boolean adaptiveStream) throws Exception { private void svcTest(String codec, String scalabilityMode, boolean adaptiveStream) throws Exception {
final String codecUpperCase = codec.toUpperCase(); final String codecUpperCase = codec.toUpperCase();
final long testStart = System.currentTimeMillis(); final long testStart = System.currentTimeMillis();
@ -3449,7 +3549,20 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click(); user.getDriver().findElement(By.cssSelector("#close-dialog-btn")).click();
Thread.sleep(300); Thread.sleep(300);
if (adaptiveStream) { this.waitUntilSubscriberBytesReceivedIncreasing(user, subscriberVideo);
this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
if (spatialLayers == 1) {
// A single spatial layer. Just ensure subscriber is properly receiving video
Assertions.assertTrue(highWidth > 0, "Expected a positive frameWidth, but got " + highWidth);
this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo);
} else if (adaptiveStream) {
// adaptiveStream picks the layer from the rendered size of the subscriber's
// video element, so drive the layer changes by resizing that element. Only
// two element sizes are used, so only the lowest and the highest spatial
// layers are observable: with 3 spatial layers the middle one is never
// visited. The default element size settles below the top layer, so the
// first switch to HIGH must increase the received width.
this.switchSubscriberSpatialLayer(user, subscriberVideo, adaptiveStream, "HIGH"); this.switchSubscriberSpatialLayer(user, subscriberVideo, adaptiveStream, "HIGH");
this.waitUntilSubscriberFrameWidthChanges(user, subscriberVideo, highWidth, true); this.waitUntilSubscriberFrameWidthChanges(user, subscriberVideo, highWidth, true);
this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo);
@ -3497,7 +3610,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
this.switchSubscriberSpatialLayer(user, subscriberVideo, adaptiveStream, "HIGH"); this.switchSubscriberSpatialLayer(user, subscriberVideo, adaptiveStream, "HIGH");
this.waitUntilSubscriberFrameWidthChanges(user, subscriberVideo, lowWidth, true); this.waitUntilSubscriberFrameWidthChanges(user, subscriberVideo, lowWidth, true);
this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo); this.waitUntilSubscriberFramesDecodedIncrease(user, subscriberVideo);
} else { } else if (spatialLayers == 2) {
// 2 spatial layers (e.g. L2T2): layers are LOW and MEDIUM only. // 2 spatial layers (e.g. L2T2): layers are LOW and MEDIUM only.
// HIGH and MEDIUM both map to the highest spatial layer (same width). // HIGH and MEDIUM both map to the highest spatial layer (same width).
// Only LOW gives a distinct lower resolution. // Only LOW gives a distinct lower resolution.
@ -3900,6 +4013,312 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
testNoSimulcast(user, subscriberVideo); testNoSimulcast(user, subscriberVideo);
} }
// 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
*/
static Stream<Arguments> serverSdkPublisherMatrix() {
List<String> layersFilter = List.of(System.getProperty("sdk.layers", "single,multi").split(","));
List<String> codecsFilter = List.of(System.getProperty("sdk.codecs", "vp8,h264,vp9,av1").split(","));
return Stream.of("single", "multi").filter(layersFilter::contains)
.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")
@MethodSource("serverSdkPublisherMatrix")
@DisplayName("Go SDK publisher to Chrome and Firefox subscribers")
void goSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("go", codec, layers);
}
@ParameterizedTest(name = "Node SDK {0} {1}-layer publisher to Chrome and Firefox subscribers")
@MethodSource("serverSdkPublisherMatrix")
@DisplayName("Node SDK publisher to Chrome and Firefox subscribers")
void nodeSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("node", codec, layers);
}
@ParameterizedTest(name = "Python SDK {0} {1}-layer publisher to Chrome and Firefox subscribers")
@MethodSource("serverSdkPublisherMatrix")
@DisplayName("Python SDK publisher to Chrome and Firefox subscribers")
void pythonSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("python", codec, layers);
}
@ParameterizedTest(name = "Rust SDK {0} {1}-layer publisher to Chrome and Firefox subscribers")
@MethodSource("serverSdkPublisherMatrix")
@DisplayName("Rust SDK publisher to Chrome and Firefox subscribers")
void rustSdkPublisherToBrowserSubscribersTest(String codec, String layers) throws Exception {
serverSdkPublisherToBrowserSubscribersAux("rust", codec, layers);
}
@ParameterizedTest(name = ".NET SDK {0} {1}-layer publisher to Chrome and Firefox subscribers")
@MethodSource("serverSdkPublisherMatrix")
@DisplayName(".NET SDK publisher to Chrome and Firefox subscribers")
void dotnetSdkPublisherToBrowserSubscribersTest(String codec, String 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.
*/
private void serverSdkPublisherToBrowserSubscribersAux(String sdk, String codec, String layers)
throws Exception {
final String expectedCodec = "video/" + codec.toUpperCase();
final String publisherIdentity = sdk + "-publisher";
final boolean multiLayer = "multi".equals(layers);
// The Go SDK forwards pre-encoded samples (no encoder), so its only
// multi-layer shape is RID simulcast — and LiveKit does not support RID
// simulcast for the SVC-class codecs (multi-layer VP9/AV1 must be SVC):
// that combination is skipped as an unsupported publish shape
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<OpenViduTestappUser> subscribers = List.of(setupBrowserAndConnectToOpenViduTestapp("chrome"),
setupBrowserAndConnectToOpenViduTestapp("firefox"));
log.info("{} SDK {} {}-layer publisher to Chrome and Firefox subscribers", sdk, codec, layers);
// Both browsers join the room as subscriber-only participants with
// adaptiveStream disabled (the received layer only changes through the
// testapp's max-video-quality selector), each with its own identity (a
// second join with the testapp's default identity would kick the first
// browser out of the room)
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);
}
this.startServerSdkPublisher(sdk, "TestRoom", codec, multiLayer);
// 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
TrackInfo trackInfo = this.getPublishedVideoTrackInfo("TestRoom", publisherIdentity);
final int expectedLayers = multiLayer ? 2 : 1;
Assertions.assertEquals(expectedLayers, trackInfo.getLayersCount(), "Expected " + expectedLayers
+ " 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
final boolean expectSimulcast = multiLayer && ("vp8".equals(codec) || "h264".equals(codec) || "go".equals(sdk));
if (!multiLayer || expectSimulcast) {
Assertions.assertEquals(expectSimulcast, trackInfo.getSimulcast(),
"Simulcast flag of the track published by the " + sdk + " SDK");
// (the server's explicit SVC verdict: an SVC publication would be
// MULTIPLE_SPATIAL_LAYERS_PER_STREAM)
Assertions.assertEquals(VideoLayer.Mode.ONE_SPATIAL_LAYER_PER_STREAM,
trackInfo.getCodecs(0).getVideoLayerMode(),
"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 (OpenViduTestappUser user : subscribers) {
gracefullyLeaveParticipants(user, 1);
}
}
/** "Chrome", "Firefox"... from the BrowserUser class of the testapp user. */
private String browserName(OpenViduTestappUser user) {
return user.getBrowserUser().getClass().getSimpleName().replace("User", "");
}
/**
* Waits until the subscriber's video bytesReceived grows between two
* consecutive samples. Unlike waitUntilSubscriberBytesReceivedIncrease (which
* compares against the very first sample) this tolerates the periodic
* inbound-rtp counter restarts Firefox shows with the mediasoup engine: a
* low-bitrate stream could otherwise never climb back above a first sample
* taken late in a counter window.
*/
private void waitUntilSubscriberBytesReceivedIncreasing(OpenViduTestappUser user, WebElement videoElement) {
final java.util.concurrent.atomic.AtomicLong previous = new java.util.concurrent.atomic.AtomicLong(
this.getSubscriberVideoBytesReceived(user, videoElement));
this.waitUntilAux(user, videoElement, () -> {
long current = this.getSubscriberVideoBytesReceived(user, videoElement);
return current > previous.getAndSet(current);
}, "Timeout waiting for the subscriber track bytesReceived to grow between consecutive samples");
}
/**
* 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.
*/
private void selectSubscriberVideoQuality(OpenViduTestappUser user, String quality) throws InterruptedException {
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.waitForBackdropAndClick(user, "mat-option.mode-" + 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.
*/
private void assertSubscriberReceivesVideo(OpenViduTestappUser user, String sdk, String publisherIdentity,
String expectedCodec, TrackInfo trackInfo) throws Exception {
final String browser = browserName(user);
user.getEventManager().waitUntilEventReaches("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");
WebElement subscriberVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.remote"));
// Media must actually reach the subscriber (with the codec-binding bug
// present the track subscribes but receives 0 bytes forever)
waitUntilVideoLayersNotEmpty(user, subscriberVideo);
this.waitUntilSubscriberBytesReceivedIncreasing(user, subscriberVideo);
this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
// 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");
// And with the layers the server reports, in the track info received by
// the subscriber
JsonArray subscriberLayers = this.getRemoteVideoTrackInfoLayers(user, 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);
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));
}
}
/**
* Selects the max video quality of the subscriber's remote track and waits
* until the received video has the frame width of that layer and decodes
* (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 {
for (int attempt = 1; attempt <= 2; attempt++) {
this.selectSubscriberVideoQuality(user, quality);
try {
this.waitUntilSubscriberFrameWidthIs(user, subscriberVideo, expectedFrameWidth);
break;
} catch (AssertionError e) {
if (attempt == 2) {
throw e;
}
}
}
this.waitUntilSubscriberFramesPerSecondNotZero(user, subscriberVideo);
}
/**
* 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.
*/
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));
}
/**
* The first video track published by the given participant, as reported by
* the LiveKit server (RoomService GetParticipant). Waits up to 10 seconds for
* it: the SDK programs log TRACK_PUBLISHED as soon as their publish call
* returns, a few milliseconds before the server registers the track.
*/
private TrackInfo getPublishedVideoTrackInfo(String roomName, String participantIdentity) throws Exception {
for (int attempt = 0; attempt < 40; attempt++) {
ParticipantInfo participant = LK.getParticipant(roomName, participantIdentity).execute().body();
if (participant != null) {
Optional<TrackInfo> videoTrack = participant.getTracksList().stream()
.filter(track -> track.getType() == TrackType.VIDEO).findFirst();
if (videoTrack.isPresent()) {
return videoTrack.get();
}
}
Thread.sleep(250);
}
throw new AssertionError(participantIdentity + " has no published video track in room " + roomName);
}
/**
* 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.
*/
private JsonArray getRemoteVideoTrackInfoLayers(OpenViduTestappUser user, String participantIdentity) {
String layers = (String) ((JavascriptExecutor) user.getDriver()).executeScript(
"var room = window['room_0'];"
+ "var participant = room.remoteParticipants.get(arguments[0]);"
+ "var publication = participant.videoTrackPublications.values().next().value;"
+ "return JSON.stringify(publication.trackInfo.layers);",
participantIdentity);
return JsonParser.parseString(layers).getAsJsonArray();
}
@Test @Test
@DisplayName("RTSP ingress H264 + OPUS") @DisplayName("RTSP ingress H264 + OPUS")
void rtspIngressH264_OPUSTest() throws Exception { void rtspIngressH264_OPUSTest() throws Exception {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
bin/
obj/

View File

@ -0,0 +1,60 @@
// 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
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 room = new Room();
await room.ConnectAsync(
Environment.GetEnvironmentVariable("LIVEKIT_URL")!,
Environment.GetEnvironmentVariable("LIVEKIT_TOKEN")!,
new RoomOptions { AutoSubscribe = false });
var videoSource = new VideoSource(width, height);
var videoTrack = LocalVideoTrack.Create("dotnet-video", videoSource);
var options = new TrackPublishOptions
{
VideoCodec = Enum.Parse<Proto.VideoCodec>(codec, ignoreCase: true),
Source = Proto.TrackSource.SourceCamera,
};
if (codec == "vp8" || codec == "h264")
{
options.Simulcast = multiLayer;
}
else
{
options.ScalabilityMode = multiLayer ? "L2T2" : "L1T1";
}
await room.LocalParticipant!.PublishTrackAsync(videoTrack, options);
// The Java test waits for this exact log line before asserting
Console.WriteLine("TRACK_PUBLISHED");
var data = new byte[width * height * 4];
byte n = 0;
while (true)
{
n += 7;
for (int i = 0; i < data.Length; i += 4)
{
data[i] = n;
data[i + 1] = (byte)(255 - n);
data[i + 2] = (byte)(n * 3);
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);
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Livekit.Rtc.Dotnet" Version="0.1.4" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,85 @@
module gopublisher
go 1.26
require (
github.com/livekit/protocol v1.49.0
github.com/livekit/server-sdk-go/v2 v2.18.1
github.com/pion/webrtc/v4 v4.2.15
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
buf.build/go/protovalidate v1.2.0 // indirect
buf.build/go/protoyaml v0.7.0 // indirect
cel.dev/expr v0.25.2 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/gammazero/deque v1.2.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/cel-go v0.28.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e // indirect
github.com/livekit/psrpc v0.7.2 // indirect
github.com/magefile/mage v1.17.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/nats.go v1.52.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pion/datachannel v1.6.0 // indirect
github.com/pion/dtls/v3 v3.1.4 // indirect
github.com/pion/ice/v4 v4.2.7 // indirect
github.com/pion/interceptor v0.1.45 // indirect
github.com/pion/logging v0.2.4 // indirect
github.com/pion/mdns/v2 v2.1.0 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.16 // indirect
github.com/pion/rtp v1.10.2 // indirect
github.com/pion/sctp v1.10.0 // indirect
github.com/pion/sdp/v3 v3.0.19 // indirect
github.com/pion/srtp/v3 v3.0.11 // indirect
github.com/pion/stun/v3 v3.1.5 // indirect
github.com/pion/transport/v4 v4.0.2 // indirect
github.com/pion/turn/v5 v5.0.9 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.68.1 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect
github.com/redis/go-redis/v9 v9.20.0 // indirect
github.com/twitchtv/twirp v8.1.3+incompatible // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,228 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0=
buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dennwc/iters v1.2.2 h1:XH2/Etihiy9ZvPOVCR+icQXeYlhbvS7k0qro4x/2qQo=
github.com/dennwc/iters v1.2.2/go.mod h1:M9KuuMBeyEXYTmB7EnI9SCyALFCmPWOIxn5W1L0CjGg=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ=
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ=
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e h1:SkgQRcG2VYEhh80Qb/zYZo8rWKJzNfJcfUQnXe6su2M=
github.com/livekit/mediatransportutil v0.0.0-20260605212259-862d4a7bcb1e/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU=
github.com/livekit/protocol v1.49.0 h1:Q5nthDO1v7c0JHiWjMhgUQTlsKmCsBL/KCKxdHVaz00=
github.com/livekit/protocol v1.49.0/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs=
github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc=
github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw=
github.com/livekit/server-sdk-go/v2 v2.18.1 h1:/u0JVII+ErGCivHnAGr6di0wl0NYsfcRvDzEtTAFovo=
github.com/livekit/server-sdk-go/v2 v2.18.1/go.mod h1:su0IvJNWTFCHVwmqpsFvxHfXWs9pn26ms+cKBR1jILU=
github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40=
github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ=
github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU=
github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM=
github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0=
github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY=
github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg=
github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU=
github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,168 @@
// 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*
package main
import (
"fmt"
"io"
"log"
"os"
"strings"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/h264reader"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
"github.com/livekit/protocol/livekit"
lksdk "github.com/livekit/server-sdk-go/v2"
)
const frameDuration = time.Second / 30
var mimeTypes = map[string]string{
"vp8": webrtc.MimeTypeVP8,
"h264": webrtc.MimeTypeH264,
"vp9": webrtc.MimeTypeVP9,
"av1": webrtc.MimeTypeAV1,
}
type simulcastLayer struct {
quality livekit.VideoQuality
width, height uint32
fileEnv string
}
var simulcastLayers = []simulcastLayer{
{livekit.VideoQuality_LOW, 320, 240, "VIDEO_FILE_LOW"},
{livekit.VideoQuality_HIGH, 640, 480, "VIDEO_FILE_HIGH"},
}
func main() {
mimeType, ok := mimeTypes[os.Getenv("VIDEO_CODEC")]
if !ok {
log.Fatalf("unknown VIDEO_CODEC %q", os.Getenv("VIDEO_CODEC"))
}
codec := webrtc.RTPCodecCapability{MimeType: mimeType, ClockRate: 90000}
room, err := lksdk.ConnectToRoomWithToken(os.Getenv("LIVEKIT_URL"), os.Getenv("LIVEKIT_TOKEN"), &lksdk.RoomCallback{})
if err != nil {
log.Fatalf("could not connect to room: %v", err)
}
defer room.Disconnect()
if os.Getenv("VIDEO_LAYERS") == "multi" {
tracks := make([]*lksdk.LocalTrack, 0, len(simulcastLayers))
for _, layer := range simulcastLayers {
track, err := lksdk.NewLocalSampleTrack(codec, lksdk.WithSimulcast("go-video",
&livekit.VideoLayer{Quality: layer.quality, Width: layer.width, Height: layer.height}))
if err != nil {
log.Fatalf("could not create local track: %v", err)
}
tracks = append(tracks, track)
}
if _, err = room.LocalParticipant.PublishSimulcastTrack(tracks, &lksdk.TrackPublicationOptions{
Name: "go-video",
VideoWidth: 640,
VideoHeight: 480,
}); 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 {
go loopVideoFile(tracks[i], os.Getenv(layer.fileEnv))
}
select {}
}
track, err := lksdk.NewLocalSampleTrack(codec)
if err != nil {
log.Fatalf("could not create local track: %v", err)
}
if _, err = room.LocalParticipant.PublishTrack(track, &lksdk.TrackPublicationOptions{
Name: "go-video",
VideoWidth: 640,
VideoHeight: 480,
}); err != nil {
log.Fatalf("could not publish track: %v", err)
}
// The Java test waits for this exact log line before asserting.
fmt.Println("TRACK_PUBLISHED")
loopVideoFile(track, os.Getenv("VIDEO_FILE"))
}
// loopVideoFile sends the frames of the file forever, paced at 30 fps.
func loopVideoFile(track *lksdk.LocalTrack, path string) {
for {
if err := writeVideoFileOnce(track, path); err != nil {
log.Fatalf("could not write video samples of %s: %v", path, err)
}
}
}
// writeVideoFileOnce sends every frame of the file once, paced at 30 fps.
func writeVideoFileOnce(track *lksdk.LocalTrack, path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
var nextFrame func() ([]byte, error)
if strings.HasSuffix(file.Name(), ".h264") {
reader, err := h264reader.NewReader(file)
if err != nil {
return err
}
nextFrame = func() ([]byte, error) {
nal, err := reader.NextNAL()
if err != nil {
return nil, err
}
return nal.Data, nil
}
} else {
reader, _, err := ivfreader.NewWith(file)
if err != nil {
return err
}
nextFrame = func() ([]byte, error) {
frame, _, err := reader.ParseNextFrame()
return frame, err
}
}
ticker := time.NewTicker(frameDuration)
defer ticker.Stop()
for {
data, err := nextFrame()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
if err := track.WriteSample(media.Sample{Data: data, Duration: frameDuration}, nil); err != nil {
return err
}
<-ticker.C
}
}

View File

@ -0,0 +1 @@
node_modules/

View File

@ -0,0 +1,62 @@
// 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
import {
Room,
LocalVideoTrack,
VideoSource,
VideoFrame,
VideoBufferType,
TrackPublishOptions,
TrackSource,
VideoCodec,
} from '@livekit/rtc-node';
const codec = process.env.VIDEO_CODEC;
const multiLayer = process.env.VIDEO_LAYERS === 'multi';
const WIDTH = 640;
const HEIGHT = 480;
const room = new Room();
await room.connect(process.env.LIVEKIT_URL, process.env.LIVEKIT_TOKEN, {
autoSubscribe: false,
dynacast: false,
});
const source = new VideoSource(WIDTH, HEIGHT);
const track = LocalVideoTrack.createVideoTrack('node-video', source);
const options = new TrackPublishOptions({
videoCodec: VideoCodec[codec.toUpperCase()],
source: TrackSource.SOURCE_CAMERA,
});
if (codec === 'vp8' || codec === 'h264') {
options.simulcast = multiLayer;
} else {
options.scalabilityMode = multiLayer ? 'L2T2' : 'L1T1';
}
await room.localParticipant.publishTrack(track, options);
// The Java test waits for this exact log line before asserting
console.log('TRACK_PUBLISHED');
const buf = new Uint8Array(WIDTH * HEIGHT * 4);
let n = 0;
setInterval(() => {
n++;
for (let y = 0; y < HEIGHT; y++) {
for (let x = 0; x < WIDTH; x++) {
const i = (y * WIDTH + x) * 4;
buf[i] = (x + n * 7) & 0xff;
buf[i + 1] = (y + n * 3) & 0xff;
buf[i + 2] = (x + y + n * 11) & 0xff;
buf[i + 3] = 0xff;
}
}
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)

View File

@ -0,0 +1,8 @@
{
"name": "node-publisher",
"private": true,
"type": "module",
"dependencies": {
"@livekit/rtc-node": "0.13.34"
}
}

View File

@ -0,0 +1,52 @@
# 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
import asyncio
import os
from livekit import rtc
MULTI_LAYER = os.environ.get("VIDEO_LAYERS") == "multi"
WIDTH = 640
HEIGHT = 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)
)
source = rtc.VideoSource(WIDTH, HEIGHT)
track = rtc.LocalVideoTrack.create_video_track("python-video", source)
options = rtc.TrackPublishOptions(
video_codec=getattr(rtc.VideoCodec, codec.upper()),
source=rtc.TrackSource.SOURCE_CAMERA,
)
if codec in ("vp8", "h264"):
options.simulcast = MULTI_LAYER
else:
options.scalability_mode = "L2T2" if MULTI_LAYER else "L1T1"
await room.local_participant.publish_track(track, options)
# The Java test waits for this exact log line before asserting
print("TRACK_PUBLISHED", flush=True)
n = 0
while True:
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
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1 @@
livekit==1.1.16

View File

@ -0,0 +1 @@
target/

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,8 @@
[package]
name = "rust-publisher"
version = "0.1.0"
edition = "2021"
[dependencies]
livekit = { version = "0.8.4", features = ["native-tls"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }

View File

@ -0,0 +1,79 @@
// 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
use std::{env, time::Duration};
use livekit::options::{TrackPublishOptions, VideoCodec};
use livekit::track::{LocalTrack, LocalVideoTrack, TrackSource};
use livekit::webrtc::video_frame::{I420Buffer, VideoFrame, VideoRotation};
use livekit::webrtc::video_source::{native::NativeVideoSource, RtcVideoSource, VideoResolution};
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 mut options = TrackPublishOptions {
source: TrackSource::Camera,
video_codec: match codec.as_str() {
"vp8" => VideoCodec::VP8,
"h264" => VideoCodec::H264,
"vp9" => VideoCodec::VP9,
"av1" => VideoCodec::AV1,
other => panic!("unknown VIDEO_CODEC {other}"),
},
..Default::default()
};
if codec == "vp8" || codec == "h264" {
options.simulcast = multi_layer;
} else {
options.scalability_mode = Some(if multi_layer { "L2T2" } else { "L1T1" }.to_string());
}
let (room, mut _events) = Room::connect(
&env::var("LIVEKIT_URL").unwrap(),
&env::var("LIVEKIT_TOKEN").unwrap(),
RoomOptions::default(),
)
.await
.expect("could not connect to room");
let source = NativeVideoSource::new(
VideoResolution { width, height },
false, // is_screencast
);
let track = LocalVideoTrack::create_video_track("rust-video", RtcVideoSource::Native(source.clone()));
room.local_participant()
.publish_track(LocalTrack::Video(track), options)
.await
.expect("could not publish track");
// The Java test waits for this exact log line before asserting
println!("TRACK_PUBLISHED");
let mut frame = VideoFrame {
rotation: VideoRotation::VideoRotation0,
timestamp_us: 0,
frame_metadata: None,
buffer: I420Buffer::new(width, height),
};
let mut n: u8 = 0;
loop {
n = n.wrapping_add(7);
let (data_y, data_u, data_v) = frame.buffer.data_mut();
data_y.fill(n);
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;
}
}