mirror of https://github.com/OpenVidu/openvidu.git
openvidu-test-e2e: OpenViduTestAppE2eEgressProTest holds every test that needs openvidu/egress-pro
parent
9413108885
commit
3fbf223b45
|
|
@ -329,6 +329,28 @@ public class NetworkConditioner {
|
|||
blockOutboundPackets(targetContainer, Protocol.TCP, mediaPortRange);
|
||||
blockOutboundPackets(targetContainer, Protocol.TCP, SFU_ICE_TCP_PORT);
|
||||
blockOutboundPackets(targetContainer, Protocol.UDP, SFU_TURN_UDP_PORT);
|
||||
// No test may go on without the blackout it asked for. Checked once all the
|
||||
// rules are in: a check after each one would make the first ones last longer.
|
||||
// Split on the rule prefix: the command output comes with its lines joined
|
||||
String[] rules = nettools(targetContainer, "iptables", "-S OUTPUT").split("-A OUTPUT");
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String drop : List.of("udp " + mediaPortRange, "tcp " + mediaPortRange, "tcp " + SFU_ICE_TCP_PORT,
|
||||
"udp " + SFU_TURN_UDP_PORT)) {
|
||||
String[] protoPorts = drop.split(" ");
|
||||
String proto = "-p " + protoPorts[0] + " ";
|
||||
String dport = "--dport " + protoPorts[1].replace('-', ':') + " ";
|
||||
boolean found = false;
|
||||
for (String rule : rules) {
|
||||
found |= rule.contains(proto) && rule.contains(dport) && rule.contains("DROP");
|
||||
}
|
||||
if (!found) {
|
||||
missing.add(drop);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"The blackout of container " + targetContainer + " lacks the DROP rules for " + missing);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -397,6 +419,60 @@ public class NetworkConditioner {
|
|||
runPumba(cmd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the bandwidth of the packets leaving {@code targetContainer} towards
|
||||
* {@code remotePorts} to {@code rate} (a tc rate such as "250kbit"), during
|
||||
* {@code durationSec}. OUTBOUND only, like every netem impairment: the packets
|
||||
* beyond the rate wait in a queue, as at a congested link, so the sender's
|
||||
* bandwidth estimation sees the delay grow before any loss.
|
||||
*/
|
||||
public static void applyRateLimitToOutboundPackets(String targetContainer, String remotePorts, String rate,
|
||||
int durationSec) {
|
||||
log.info("Limiting the packets leaving container {} towards port {} to {} during {} seconds",
|
||||
targetContainer, remotePorts, rate, durationSec);
|
||||
StringBuilder opts = new StringBuilder("--duration ").append(durationSec).append("s --interface eth0")
|
||||
.append(" --tc-image ").append(NETTOOLS_IMAGE);
|
||||
final String ports = expandPorts(remotePorts);
|
||||
if (ports != null) {
|
||||
opts.append(" --ingress-port ").append(ports);
|
||||
}
|
||||
impairedContainer = targetContainer;
|
||||
runPumba(pumbaRun() + " netem " + opts + " rate --rate " + rate + " " + targetContainer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Impair the packets leaving {@code targetContainer} towards {@code remotePorts}
|
||||
* with several netem settings at once, as a poor uplink has them: {@code netem}
|
||||
* is a netem specification such as "delay 50ms 30ms loss random 5% rate 300kbit".
|
||||
* Pumba runs one netem command at a time, and two of them cannot share an
|
||||
* interface, so this installs the qdisc itself, the way Pumba does: a prio root
|
||||
* whose third band carries the netem, with one u32 filter per destination port;
|
||||
* everything else goes through the second band, untouched. Nothing reverts it
|
||||
* but {@link #clear()}, which deletes the target's root qdisc.
|
||||
*/
|
||||
public static void applyOutboundNetem(String targetContainer, String remotePorts, String netem) {
|
||||
final String ports = expandPorts(remotePorts);
|
||||
if (ports == null) {
|
||||
throw new IllegalArgumentException("applyOutboundNetem needs the destination ports to impair");
|
||||
}
|
||||
StringBuilder script = new StringBuilder("tc qdisc add dev eth0 root handle 1: prio bands 3 priomap")
|
||||
.append(" 1".repeat(16)).append(" && tc qdisc add dev eth0 parent 1:3 handle 30: netem ").append(netem);
|
||||
for (String port : ports.split(",")) {
|
||||
script.append(" && tc filter add dev eth0 parent 1: protocol ip prio 1 u32 match ip dport ").append(port)
|
||||
.append(" 0xffff flowid 1:3");
|
||||
}
|
||||
log.info("Impairing the packets leaving container {} towards port {} with netem {}", targetContainer,
|
||||
remotePorts, netem);
|
||||
impairedContainer = targetContainer;
|
||||
String out = nettools(targetContainer, "sh", "-c \"" + script + "\"", 120);
|
||||
// a command that timed out returns no output either
|
||||
String qdiscs = nettools(targetContainer, "tc", "qdisc show dev eth0");
|
||||
if (!out.isBlank() || !qdiscs.contains("netem")) {
|
||||
throw new IllegalStateException(
|
||||
"Could not install netem " + netem + " on " + targetContainer + ": " + out + " / " + qdiscs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every impairment: stop the running Pumba containers (SIGTERM, so they
|
||||
* revert the netem qdisc / iptables rules themselves) and then scrub the target's
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
package io.openvidu.test.e2e;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
|
|
@ -18,12 +24,16 @@ import org.openqa.selenium.TakesScreenshot;
|
|||
import org.openqa.selenium.TimeoutException;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.ui.ExpectedConditions;
|
||||
import org.openqa.selenium.support.ui.WebDriverWait;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.DownloadObjectArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.openvidu.test.browsers.BrowserUser;
|
||||
|
||||
import static org.openqa.selenium.OutputType.BASE64;
|
||||
|
|
@ -32,6 +42,76 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
|||
|
||||
protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
|
||||
|
||||
protected String getLivekitWssUrlFromReadyCheckContainer() {
|
||||
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
|
||||
if (logs != null && !logs.isBlank()) {
|
||||
|
||||
/*-----------------LiveKit Server API-----------------
|
||||
- Access from this machine:
|
||||
- http://localhost:7880
|
||||
- ws://localhost:7880
|
||||
- Access from other devices in your LAN:
|
||||
- https://10-10-1-203.openvidu-local.dev:7443
|
||||
- wss://10-10-1-203.openvidu-local.dev:7443 <-- We want this value
|
||||
- Credentials:
|
||||
- API Key: devkey
|
||||
- API Secret: secret
|
||||
----------------------------------------------------*/
|
||||
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile(
|
||||
"LiveKit Server API.*?Access from other devices in your LAN.*?(wss://[A-Za-z0-9.\\-]+:\\d+)",
|
||||
java.util.regex.Pattern.DOTALL)
|
||||
.matcher(logs);
|
||||
if (m.find()) {
|
||||
return m.group(1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ready to open bridged ("chromeNetwork") browsers, the only ones that
|
||||
* {@link NetworkConditioner} can impair, and return the secure LiveKit URL they
|
||||
* must connect to.
|
||||
*/
|
||||
protected String prepareNetemBrowsers() {
|
||||
String secureLivekitUrl = getLivekitWssUrlFromReadyCheckContainer();
|
||||
Assertions.assertNotNull(secureLivekitUrl,
|
||||
"Could not obtain the LiveKit wss:// URL from the 'ready-check' container log. Is openvidu-local-deployment running? ");
|
||||
log.info("Using LiveKit URL: {}", secureLivekitUrl);
|
||||
|
||||
// Bridged browsers live in their own Docker network and reach the SFU through
|
||||
// this public wildcard name: pin its address so that they never depend on the
|
||||
// runner's DNS to open the signaling WebSocket
|
||||
pinHostForNetemBrowser(secureLivekitUrl);
|
||||
|
||||
NetworkConditioner.pullImages();
|
||||
return secureLivekitUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the openvidu-testapp in a new bridged ("chromeNetwork") browser, with
|
||||
* the connection settings filled in and its events being polled.
|
||||
*/
|
||||
protected OpenViduTestappUser setupNetemBrowserUser(String secureLivekitUrl) throws Exception {
|
||||
OpenViduTestappUser user = new OpenViduTestappUser(setupBrowser("chromeNetwork"));
|
||||
this.testappUsers.add(user);
|
||||
// Connect to the openvidu-testapp through "host.docker.internal"
|
||||
user.getDriver().get(APP_URL.replace("localhost", "host.docker.internal"));
|
||||
WebElement urlInput = user.getDriver().findElement(By.id("livekit-url"));
|
||||
urlInput.clear();
|
||||
urlInput.sendKeys(secureLivekitUrl);
|
||||
WebElement keyInput = user.getDriver().findElement(By.id("livekit-api-key"));
|
||||
keyInput.clear();
|
||||
keyInput.sendKeys(LIVEKIT_API_KEY);
|
||||
WebElement secretInput = user.getDriver().findElement(By.id("livekit-api-secret"));
|
||||
secretInput.clear();
|
||||
secretInput.sendKeys(LIVEKIT_API_SECRET);
|
||||
user.getEventManager().startPolling();
|
||||
return user;
|
||||
}
|
||||
|
||||
private void connectToOpenViduTestApp(OpenViduTestappUser user) {
|
||||
try {
|
||||
user.getDriver().get(APP_URL);
|
||||
|
|
@ -914,4 +994,284 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
|||
return current > previous.getAndSet(current);
|
||||
}, "Timeout waiting for the subscriber track bytesReceived to grow between consecutive samples");
|
||||
}
|
||||
|
||||
protected void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec) throws InterruptedException {
|
||||
forceCodec(user, numberOfUser, codec, true);
|
||||
}
|
||||
|
||||
protected void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec, boolean disableBackupCodec)
|
||||
throws InterruptedException {
|
||||
String codecLowerCase = codec.toLowerCase();
|
||||
this.waitAndClick(user, "#room-options-btn-" + numberOfUser);
|
||||
Thread.sleep(300);
|
||||
if (disableBackupCodec) {
|
||||
// Enabled by default
|
||||
user.getDriver().findElement(By.id("trackPublish-backupCodec")).click();
|
||||
}
|
||||
user.getDriver().findElement(By.id("trackPublish-videoCodec")).click();
|
||||
this.waitAndClick(user, "#mat-option-" + codecLowerCase);
|
||||
this.waitAndClick(user, "#close-dialog-btn");
|
||||
Thread.sleep(300);
|
||||
}
|
||||
|
||||
protected JsonObject waitUntilEgressStatus(OpenViduTestappUser user, String egressId, String egressStatus,
|
||||
int timeoutMillis) {
|
||||
final int intervalWait = 250;
|
||||
final int MAX_ITERATIONS = timeoutMillis / intervalWait;
|
||||
int iteration = 0;
|
||||
boolean egressActive = false;
|
||||
JsonObject egressObject = null;
|
||||
|
||||
while (!egressActive && iteration < MAX_ITERATIONS) {
|
||||
iteration++;
|
||||
try {
|
||||
user.getDriver().findElement(By.cssSelector("#list-egress-api-btn")).click();
|
||||
String textareaContent = user.getDriver().findElement(By.cssSelector("#api-response-text-area"))
|
||||
.getDomProperty("value");
|
||||
JsonArray egressArray = JsonParser.parseString(textareaContent).getAsJsonArray();
|
||||
|
||||
// Find the egress object with the matching egressId
|
||||
JsonObject targetEgress = null;
|
||||
for (int i = 0; i < egressArray.size(); i++) {
|
||||
egressObject = egressArray.get(i).getAsJsonObject();
|
||||
if (egressId.equals(egressObject.get("egressId").getAsString())) {
|
||||
targetEgress = egressObject;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetEgress != null && egressStatus.equals(targetEgress.get("status").getAsString())) {
|
||||
egressActive = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Continue polling if there's an exception
|
||||
}
|
||||
|
||||
if (!egressActive) {
|
||||
try {
|
||||
Thread.sleep(intervalWait);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!egressActive) {
|
||||
Assertions.fail("Timeout waiting for egress '" + egressId + "' to reach status '" + egressStatus + "'");
|
||||
}
|
||||
return egressObject;
|
||||
}
|
||||
|
||||
protected boolean isMinioAvailable() {
|
||||
MinioClient minioClient = MinioClient.builder().endpoint("localhost", 9000, false)
|
||||
.credentials("minioadmin", "minioadmin").build();
|
||||
try {
|
||||
minioClient.bucketExists(BucketExistsArgs.builder().bucket("openvidu-appdata").build());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the video track of a Chrome publisher with Track Egress and checks
|
||||
* the file. {@code codec} is vp8, h264, vp9 or av1 (only openvidu/egress-pro
|
||||
* writes AV1); {@code layers} is "single", "simulcast" or a scalability mode.
|
||||
*/
|
||||
protected void trackEgressVideoCodec(String codec, String layers) throws Exception {
|
||||
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||
|
||||
log.info("Track egress {} {}", codec, layers);
|
||||
|
||||
// A single participant publishing a single 1280x720 video track (Chrome sends
|
||||
// fewer than 3 spatial layers below that): simulcast or not for VP8 and H264,
|
||||
// an explicit scalability mode for the SVC codecs (without one, livekit-client
|
||||
// publishes them L3T3_KEY)
|
||||
final boolean simulcast = "simulcast".equals(layers);
|
||||
final String scalabilityMode = layers.startsWith("L") ? layers : null;
|
||||
final boolean singleLayer = !simulcast && !"L3T3".equals(scalabilityMode);
|
||||
this.addPublisher(user, false, simulcast, false, false, false, true, 1280, 720, scalabilityMode);
|
||||
this.forceCodec(user, 0, codec);
|
||||
user.getDriver().findElement(By.cssSelector(".connect-btn")).sendKeys(Keys.ENTER);
|
||||
user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 1);
|
||||
|
||||
// The publisher sends the requested codec and layers
|
||||
WebElement publisherVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.local"));
|
||||
this.waitUntilVideoLayersNotEmpty(user, publisherVideo);
|
||||
Assertions.assertEquals("video/" + codec.toUpperCase(),
|
||||
getPublisherVideoLayerAttribute(user, publisherVideo, null, "codec").getAsString());
|
||||
if (scalabilityMode != null) {
|
||||
Assertions.assertEquals(scalabilityMode,
|
||||
getPublisherVideoLayerAttribute(user, publisherVideo, null, "scalabilityMode").getAsString());
|
||||
} else {
|
||||
int publishedLayers = countNumberOfPublishedLayers(user, publisherVideo);
|
||||
Assertions.assertTrue(simulcast ? publishedLayers > 1 : publishedLayers == 1,
|
||||
"Wrong number of simulcast layers: " + publishedLayers);
|
||||
}
|
||||
// And it sends its top layer before the egress starts: Chrome turns its top
|
||||
// simulcast layer on as its uplink estimate allows, which toward mediasoup
|
||||
// takes up to 20 s for H264
|
||||
this.waitUntilPublisherSendsWidth(user, publisherVideo, 1280, 40);
|
||||
this.waitAndClick(user, "#close-dialog-btn");
|
||||
Thread.sleep(300);
|
||||
|
||||
// The API dialog fills in the room and the video track
|
||||
user.getDriver().findElement(By.cssSelector("#room-api-btn-0")).click();
|
||||
Thread.sleep(300);
|
||||
user.getDriver().findElement(By.cssSelector("#start-track-egress-api-btn")).click();
|
||||
WebElement egressIdField = user.getDriver().findElement(By.id("egress-id-field"));
|
||||
new WebDriverWait(user.getDriver(), Duration.ofSeconds(10))
|
||||
.until(ExpectedConditions.textToBePresentInElementValue(egressIdField, "EG_"));
|
||||
String egressId = egressIdField.getDomProperty("value");
|
||||
|
||||
// Long enough for the egress to reach the top layer of a multi-layer track
|
||||
this.waitUntilEgressStatus(user, egressId, "EGRESS_ACTIVE", 10000);
|
||||
Thread.sleep(8000);
|
||||
user.getDriver().findElement(By.cssSelector("#stop-egress-api-btn")).click();
|
||||
JsonObject egress = this.waitUntilEgressStatus(user, egressId, "EGRESS_COMPLETE", 10000);
|
||||
|
||||
this.assertHealthyVideoRecording(this.downloadRecording(egress), codec, 5, 1280, 720, singleLayer);
|
||||
}
|
||||
|
||||
protected void waitUntilPublisherSendsWidth(OpenViduTestappUser user, WebElement publisherVideo, int width,
|
||||
int timeoutSeconds) throws InterruptedException {
|
||||
final long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
|
||||
JsonArray layers = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
layers = getLayersAsJsonArray(user, publisherVideo);
|
||||
for (JsonElement layer : layers) {
|
||||
JsonElement frameWidth = layer.getAsJsonObject().get("frameWidth");
|
||||
if (frameWidth != null && !frameWidth.isJsonNull() && frameWidth.getAsInt() == width) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(500);
|
||||
}
|
||||
Assertions.fail("The publisher did not send its " + width + " px layer in " + timeoutSeconds + " s: " + layers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the media file of a completed egress, from MinIO or, when the
|
||||
* deployment has none, from the egress container's backup folder.
|
||||
*/
|
||||
protected Path downloadRecording(JsonObject egress) throws Exception {
|
||||
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
|
||||
Path file = Files.createTempDirectory("egress").resolve(Path.of(filename).getFileName());
|
||||
if (isMinioAvailable()) {
|
||||
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
|
||||
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
|
||||
.filename(file.toString()).build());
|
||||
} else {
|
||||
runMediaCommand("docker", "cp", "egress:/home/egress/backup_storage/" + filename, file.toString());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file is a playable recording of a single video track: the codec's native
|
||||
* container holding one track of the given codec, starting with a keyframe,
|
||||
* decoding without errors, lasting at least minSeconds at a real frame rate,
|
||||
* showing a moving picture, and reaching the published width x height: in every
|
||||
* frame for a single-layer track, from the top layer on for a multi-layer one
|
||||
* (the egress starts on a low layer and moves up as its bandwidth allows).
|
||||
*/
|
||||
protected void assertHealthyVideoRecording(Path file, String codec, double minSeconds, int width, int height,
|
||||
boolean singleLayer) throws Exception {
|
||||
String container = "h264".equals(codec) ? "mp4" : "webm";
|
||||
Assertions.assertTrue(file.toString().endsWith("." + container), "Wrong file extension: " + file);
|
||||
|
||||
JsonObject probe = JsonParser.parseString(runMediaCommand("ffprobe", "-v", "error", "-show_format",
|
||||
"-show_streams", "-count_packets", "-of", "json", file.toString())[0]).getAsJsonObject();
|
||||
JsonObject format = probe.getAsJsonObject("format");
|
||||
JsonArray streams = probe.getAsJsonArray("streams");
|
||||
log.info("Track egress recording {}: {}, {}", file.getFileName(), format, streams);
|
||||
Assertions.assertTrue(format.get("format_name").getAsString().contains(container),
|
||||
"Wrong container: " + format);
|
||||
Assertions.assertEquals(1, streams.size(), "A single track expected: " + streams);
|
||||
JsonObject video = streams.get(0).getAsJsonObject();
|
||||
Assertions.assertEquals("video", video.get("codec_type").getAsString());
|
||||
Assertions.assertEquals(codec, video.get("codec_name").getAsString());
|
||||
Assertions.assertTrue(video.get("width").getAsInt() >= 16 && video.get("height").getAsInt() >= 16,
|
||||
"Wrong video size: " + video);
|
||||
|
||||
double duration = format.get("duration").getAsDouble();
|
||||
int frames = video.get("nb_read_packets").getAsInt();
|
||||
Assertions.assertTrue(duration >= minSeconds && duration < 30, "Wrong duration: " + duration + " s");
|
||||
Assertions.assertTrue(frames / duration >= 10,
|
||||
"Too few frames: " + frames + " in " + duration + " s");
|
||||
|
||||
// Starts with a keyframe, and its timestamps never go back. A simulcast layer
|
||||
// switch gives the keyframe of the new layer the timestamp of the frame it
|
||||
// replaces, so a timestamp may repeat on a keyframe of a multi-layer track only
|
||||
List<String[]> packets = runMediaCommand("ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "packet=dts_time,flags", "-of", "csv=p=0", file.toString())[0].lines()
|
||||
.map(l -> l.trim().split(",")).filter(p -> p.length == 2 && !"N/A".equals(p[0])).toList();
|
||||
Assertions.assertTrue(packets.get(0)[1].startsWith("K"), "The recording must start with a keyframe");
|
||||
for (int i = 1; i < packets.size(); i++) {
|
||||
double dts = Double.parseDouble(packets.get(i)[0]);
|
||||
double previous = Double.parseDouble(packets.get(i - 1)[0]);
|
||||
Assertions.assertTrue(dts > previous || (dts == previous && !singleLayer && packets.get(i)[1].startsWith("K")),
|
||||
"Wrong timestamp " + dts + " after " + previous + " (flags " + packets.get(i)[1] + ")");
|
||||
}
|
||||
|
||||
// Decode every frame: no errors, the picture moves (Chrome's fake camera changes
|
||||
// on every frame) and reaches the published size. VP9 goes through libvpx, the
|
||||
// decoder of browsers and GStreamer: FFmpeg's own shows only the base layer of a
|
||||
// VP9 SVC superframe. AV1 goes through dav1d: FFmpeg's own needs hardware
|
||||
List<String> command = new ArrayList<>(List.of("ffmpeg", "-nostdin", "-loglevel", "level+info"));
|
||||
if ("vp9".equals(codec)) {
|
||||
command.addAll(List.of("-c:v", "libvpx-vp9"));
|
||||
} else if ("av1".equals(codec)) {
|
||||
command.addAll(List.of("-c:v", "libdav1d"));
|
||||
}
|
||||
command.addAll(List.of("-i", file.toString(), "-map", "0:v", "-vf", "showinfo", "-fps_mode", "passthrough",
|
||||
"-enc_time_base", "demux", "-f", "null", "-"));
|
||||
String decodeLog = runMediaCommand(command.toArray(new String[0]))[1];
|
||||
// Errors of the demuxer and the decoder: the null muxer's own complaints about a
|
||||
// repeated timestamp are covered above
|
||||
List<String> errors = decodeLog.lines().filter(l -> l.contains("[error]") || l.contains("[fatal]"))
|
||||
.filter(l -> !l.startsWith("[null @")).toList();
|
||||
Assertions.assertTrue(errors.isEmpty(), "Decoding reported errors: " + errors);
|
||||
java.util.regex.Matcher frame = java.util.regex.Pattern
|
||||
.compile("Parsed_showinfo.* n: *\\d+ .* s:(\\d+x\\d+) .* checksum:([0-9A-F]+)").matcher(decodeLog);
|
||||
Map<String, Long> framesBySize = new LinkedHashMap<>();
|
||||
List<String> checksums = new ArrayList<>();
|
||||
while (frame.find()) {
|
||||
framesBySize.merge(frame.group(1), 1L, Long::sum);
|
||||
checksums.add(frame.group(2));
|
||||
}
|
||||
log.info("Track egress recording {}: decoded frames by size {}", file.getFileName(), framesBySize);
|
||||
// MP4's edit list can leave the last packet out of the presentation (flag D)
|
||||
long presented = packets.stream().filter(p -> !p[1].contains("D")).count();
|
||||
Assertions.assertEquals(presented, checksums.size(), "Every presented packet must decode to one frame");
|
||||
long distinct = checksums.stream().distinct().count();
|
||||
Assertions.assertTrue(distinct >= checksums.size() / 2,
|
||||
"The video looks frozen: " + distinct + " distinct frames out of " + checksums.size());
|
||||
|
||||
String expected = width + "x" + height;
|
||||
if (singleLayer) {
|
||||
Assertions.assertEquals(Map.of(expected, (long) checksums.size()), framesBySize,
|
||||
"Every frame of a single-layer track must be " + expected);
|
||||
} else {
|
||||
Assertions.assertTrue(framesBySize.containsKey(expected),
|
||||
"The recording must reach the top layer " + expected + ": " + framesBySize);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a command and returns its stdout and stderr; it must exit with 0. */
|
||||
protected static String[] runMediaCommand(String... command) throws IOException, InterruptedException {
|
||||
Path out = Files.createTempFile("media-command", ".out");
|
||||
Path err = Files.createTempFile("media-command", ".err");
|
||||
try {
|
||||
Process process = new ProcessBuilder(command).redirectOutput(out.toFile()).redirectError(err.toFile())
|
||||
.start();
|
||||
Assertions.assertTrue(process.waitFor(120, TimeUnit.SECONDS), "Timeout: " + String.join(" ", command));
|
||||
Assertions.assertEquals(0, process.exitValue(),
|
||||
String.join(" ", command) + " failed: " + Files.readString(err));
|
||||
return new String[] { Files.readString(out), Files.readString(err) };
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
Files.deleteIfExists(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -77,8 +77,6 @@ import com.google.gson.JsonParser;
|
|||
|
||||
import io.livekit.server.AccessToken;
|
||||
import io.livekit.server.RoomList;
|
||||
import io.minio.BucketExistsArgs;
|
||||
import io.minio.DownloadObjectArgs;
|
||||
import io.minio.ListObjectsArgs;
|
||||
import io.minio.MinioClient;
|
||||
import io.minio.Result;
|
||||
|
|
@ -823,34 +821,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
|||
}
|
||||
}
|
||||
|
||||
private String getLivekitWssUrlFromReadyCheckContainer() {
|
||||
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
|
||||
if (logs != null && !logs.isBlank()) {
|
||||
|
||||
/*-----------------LiveKit Server API-----------------
|
||||
- Access from this machine:
|
||||
- http://localhost:7880
|
||||
- ws://localhost:7880
|
||||
- Access from other devices in your LAN:
|
||||
- https://10-10-1-203.openvidu-local.dev:7443
|
||||
- wss://10-10-1-203.openvidu-local.dev:7443 <-- We want this value
|
||||
- Credentials:
|
||||
- API Key: devkey
|
||||
- API Secret: secret
|
||||
----------------------------------------------------*/
|
||||
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern
|
||||
.compile(
|
||||
"LiveKit Server API.*?Access from other devices in your LAN.*?(wss://[A-Za-z0-9.\\-]+:\\d+)",
|
||||
java.util.regex.Pattern.DOTALL)
|
||||
.matcher(logs);
|
||||
if (m.find()) {
|
||||
return m.group(1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump everything needed to understand why the bridged (netem) PunchbagUser
|
||||
* can't reach the SFU via
|
||||
|
|
@ -1077,48 +1047,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
|||
return new ImmutablePair<>(punchbagUser, regularUser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ready to open bridged ("chromeNetwork") browsers, the only ones that
|
||||
* {@link NetworkConditioner} can impair, and return the secure LiveKit URL they
|
||||
* must connect to.
|
||||
*/
|
||||
private String prepareNetemBrowsers() {
|
||||
String secureLivekitUrl = getLivekitWssUrlFromReadyCheckContainer();
|
||||
Assertions.assertNotNull(secureLivekitUrl,
|
||||
"Could not obtain the LiveKit wss:// URL from the 'ready-check' container log. Is openvidu-local-deployment running? ");
|
||||
log.info("Using LiveKit URL: {}", secureLivekitUrl);
|
||||
|
||||
// Bridged browsers live in their own Docker network and reach the SFU through
|
||||
// this public wildcard name: pin its address so that they never depend on the
|
||||
// runner's DNS to open the signaling WebSocket
|
||||
pinHostForNetemBrowser(secureLivekitUrl);
|
||||
|
||||
NetworkConditioner.pullImages();
|
||||
return secureLivekitUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the openvidu-testapp in a new bridged ("chromeNetwork") browser, with
|
||||
* the connection settings filled in and its events being polled.
|
||||
*/
|
||||
private OpenViduTestappUser setupNetemBrowserUser(String secureLivekitUrl) throws Exception {
|
||||
OpenViduTestappUser user = new OpenViduTestappUser(setupBrowser("chromeNetwork"));
|
||||
this.testappUsers.add(user);
|
||||
// Connect to the openvidu-testapp through "host.docker.internal"
|
||||
user.getDriver().get(APP_URL.replace("localhost", "host.docker.internal"));
|
||||
WebElement urlInput = user.getDriver().findElement(By.id("livekit-url"));
|
||||
urlInput.clear();
|
||||
urlInput.sendKeys(secureLivekitUrl);
|
||||
WebElement keyInput = user.getDriver().findElement(By.id("livekit-api-key"));
|
||||
keyInput.clear();
|
||||
keyInput.sendKeys(LIVEKIT_API_KEY);
|
||||
WebElement secretInput = user.getDriver().findElement(By.id("livekit-api-secret"));
|
||||
secretInput.clear();
|
||||
secretInput.sendKeys(LIVEKIT_API_SECRET);
|
||||
user.getEventManager().startPolling();
|
||||
return user;
|
||||
}
|
||||
|
||||
private void setParticipantAndRoomName(OpenViduTestappUser user, String participantName, String roomName) {
|
||||
WebElement participantNameInput = user.getDriver().findElement(By.id("participant-name-input-0"));
|
||||
participantNameInput.clear();
|
||||
|
|
@ -2888,25 +2816,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
|||
gracefullyLeaveParticipants(user, 2);
|
||||
}
|
||||
|
||||
private void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec) throws InterruptedException {
|
||||
forceCodec(user, numberOfUser, codec, true);
|
||||
}
|
||||
|
||||
private void forceCodec(OpenViduTestappUser user, int numberOfUser, String codec, boolean disableBackupCodec)
|
||||
throws InterruptedException {
|
||||
String codecLowerCase = codec.toLowerCase();
|
||||
this.waitAndClick(user, "#room-options-btn-" + numberOfUser);
|
||||
Thread.sleep(300);
|
||||
if (disableBackupCodec) {
|
||||
// Enabled by default
|
||||
user.getDriver().findElement(By.id("trackPublish-backupCodec")).click();
|
||||
}
|
||||
user.getDriver().findElement(By.id("trackPublish-videoCodec")).click();
|
||||
this.waitAndClick(user, "#mat-option-" + codecLowerCase);
|
||||
this.waitAndClick(user, "#close-dialog-btn");
|
||||
Thread.sleep(300);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Firefox subscribe to VP8")
|
||||
void firefoxSubscribeToVP8Test() throws Exception {
|
||||
|
|
@ -4716,65 +4625,6 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
|||
"RoomEvent", 2);
|
||||
}
|
||||
|
||||
private JsonObject waitUntilEgressStatus(OpenViduTestappUser user, String egressId, String egressStatus,
|
||||
int timeoutMillis) {
|
||||
final int intervalWait = 250;
|
||||
final int MAX_ITERATIONS = timeoutMillis / intervalWait;
|
||||
int iteration = 0;
|
||||
boolean egressActive = false;
|
||||
JsonObject egressObject = null;
|
||||
|
||||
while (!egressActive && iteration < MAX_ITERATIONS) {
|
||||
iteration++;
|
||||
try {
|
||||
user.getDriver().findElement(By.cssSelector("#list-egress-api-btn")).click();
|
||||
String textareaContent = user.getDriver().findElement(By.cssSelector("#api-response-text-area"))
|
||||
.getDomProperty("value");
|
||||
JsonArray egressArray = JsonParser.parseString(textareaContent).getAsJsonArray();
|
||||
|
||||
// Find the egress object with the matching egressId
|
||||
JsonObject targetEgress = null;
|
||||
for (int i = 0; i < egressArray.size(); i++) {
|
||||
egressObject = egressArray.get(i).getAsJsonObject();
|
||||
if (egressId.equals(egressObject.get("egressId").getAsString())) {
|
||||
targetEgress = egressObject;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetEgress != null && egressStatus.equals(targetEgress.get("status").getAsString())) {
|
||||
egressActive = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Continue polling if there's an exception
|
||||
}
|
||||
|
||||
if (!egressActive) {
|
||||
try {
|
||||
Thread.sleep(intervalWait);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!egressActive) {
|
||||
Assertions.fail("Timeout waiting for egress '" + egressId + "' to reach status '" + egressStatus + "'");
|
||||
}
|
||||
return egressObject;
|
||||
}
|
||||
|
||||
private boolean isMinioAvailable() {
|
||||
MinioClient minioClient = MinioClient.builder().endpoint("localhost", 9000, false)
|
||||
.credentials("minioadmin", "minioadmin").build();
|
||||
try {
|
||||
minioClient.bucketExists(BucketExistsArgs.builder().bucket("openvidu-appdata").build());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void checkRecordingInBucket(JsonObject egress) {
|
||||
MinioClient minioClient = MinioClient.builder().endpoint("localhost", 9000, false)
|
||||
.credentials("minioadmin", "minioadmin").build();
|
||||
|
|
@ -4864,209 +4714,11 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest {
|
|||
}
|
||||
}
|
||||
|
||||
// AV1 needs openvidu/egress-pro: its cases are in OpenViduTestAppE2eEgressProTest
|
||||
@ParameterizedTest(name = "Track egress {0} {1}")
|
||||
@CsvSource({ "vp8,single", "vp8,simulcast", "h264,single", "h264,simulcast", "vp9,L1T1", "vp9,L3T3",
|
||||
"av1,L1T1", "av1,L3T3" })
|
||||
@CsvSource({ "vp8,single", "vp8,simulcast", "h264,single", "h264,simulcast", "vp9,L1T1", "vp9,L3T3" })
|
||||
void trackEgressVideoCodecTest(String codec, String layers) throws Exception {
|
||||
OpenViduTestappUser user = setupBrowserAndConnectToOpenViduTestapp("chrome");
|
||||
|
||||
log.info("Track egress {} {}", codec, layers);
|
||||
|
||||
// A single participant publishing a single 1280x720 video track (Chrome sends
|
||||
// fewer than 3 spatial layers below that): simulcast or not for VP8 and H264,
|
||||
// an explicit scalability mode for the SVC codecs (without one, livekit-client
|
||||
// publishes them L3T3_KEY)
|
||||
final boolean simulcast = "simulcast".equals(layers);
|
||||
final String scalabilityMode = layers.startsWith("L") ? layers : null;
|
||||
final boolean singleLayer = !simulcast && !"L3T3".equals(scalabilityMode);
|
||||
this.addPublisher(user, false, simulcast, false, false, false, true, 1280, 720, scalabilityMode);
|
||||
this.forceCodec(user, 0, codec);
|
||||
user.getDriver().findElement(By.cssSelector(".connect-btn")).sendKeys(Keys.ENTER);
|
||||
user.getEventManager().waitUntilEventReaches("localTrackPublished", "RoomEvent", 1);
|
||||
|
||||
// The publisher sends the requested codec and layers
|
||||
WebElement publisherVideo = user.getDriver().findElement(By.cssSelector("#openvidu-instance-0 video.local"));
|
||||
this.waitUntilVideoLayersNotEmpty(user, publisherVideo);
|
||||
Assertions.assertEquals("video/" + codec.toUpperCase(),
|
||||
getPublisherVideoLayerAttribute(user, publisherVideo, null, "codec").getAsString());
|
||||
if (scalabilityMode != null) {
|
||||
Assertions.assertEquals(scalabilityMode,
|
||||
getPublisherVideoLayerAttribute(user, publisherVideo, null, "scalabilityMode").getAsString());
|
||||
} else {
|
||||
int publishedLayers = countNumberOfPublishedLayers(user, publisherVideo);
|
||||
Assertions.assertTrue(simulcast ? publishedLayers > 1 : publishedLayers == 1,
|
||||
"Wrong number of simulcast layers: " + publishedLayers);
|
||||
}
|
||||
// And it sends its top layer before the egress starts: Chrome turns its top
|
||||
// simulcast layer on as its uplink estimate allows, which toward mediasoup
|
||||
// takes up to 20 s for H264
|
||||
this.waitUntilPublisherSendsWidth(user, publisherVideo, 1280, 40);
|
||||
this.waitAndClick(user, "#close-dialog-btn");
|
||||
Thread.sleep(300);
|
||||
|
||||
// The API dialog fills in the room and the video track
|
||||
user.getDriver().findElement(By.cssSelector("#room-api-btn-0")).click();
|
||||
Thread.sleep(300);
|
||||
user.getDriver().findElement(By.cssSelector("#start-track-egress-api-btn")).click();
|
||||
WebElement egressIdField = user.getDriver().findElement(By.id("egress-id-field"));
|
||||
new WebDriverWait(user.getDriver(), Duration.ofSeconds(10))
|
||||
.until(ExpectedConditions.textToBePresentInElementValue(egressIdField, "EG_"));
|
||||
String egressId = egressIdField.getDomProperty("value");
|
||||
|
||||
if ("av1".equals(codec)) {
|
||||
// Like every egress subscribing through the server SDK, Track Egress cannot
|
||||
// write AV1: it fails as soon as it subscribes to the track
|
||||
JsonObject egress = this.waitUntilEgressStatus(user, egressId, "EGRESS_FAILED", 20000);
|
||||
Assertions.assertTrue(egress.get("error").getAsString().toLowerCase().contains("av1"),
|
||||
"The egress error must name the AV1 codec: " + egress);
|
||||
return;
|
||||
}
|
||||
|
||||
// Long enough for the egress to reach the top layer of a multi-layer track
|
||||
this.waitUntilEgressStatus(user, egressId, "EGRESS_ACTIVE", 10000);
|
||||
Thread.sleep(8000);
|
||||
user.getDriver().findElement(By.cssSelector("#stop-egress-api-btn")).click();
|
||||
JsonObject egress = this.waitUntilEgressStatus(user, egressId, "EGRESS_COMPLETE", 10000);
|
||||
|
||||
this.assertHealthyVideoRecording(this.downloadRecording(egress), codec, 5, 1280, 720, singleLayer);
|
||||
}
|
||||
|
||||
private void waitUntilPublisherSendsWidth(OpenViduTestappUser user, WebElement publisherVideo, int width,
|
||||
int timeoutSeconds) throws InterruptedException {
|
||||
final long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
|
||||
JsonArray layers = null;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
layers = getLayersAsJsonArray(user, publisherVideo);
|
||||
for (JsonElement layer : layers) {
|
||||
JsonElement frameWidth = layer.getAsJsonObject().get("frameWidth");
|
||||
if (frameWidth != null && !frameWidth.isJsonNull() && frameWidth.getAsInt() == width) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Thread.sleep(500);
|
||||
}
|
||||
Assertions.fail("The publisher did not send its " + width + " px layer in " + timeoutSeconds + " s: " + layers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the media file of a completed egress, from MinIO or, when the
|
||||
* deployment has none, from the egress container's backup folder.
|
||||
*/
|
||||
private Path downloadRecording(JsonObject egress) throws Exception {
|
||||
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
|
||||
Path file = Files.createTempDirectory("egress").resolve(Path.of(filename).getFileName());
|
||||
if (isMinioAvailable()) {
|
||||
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
|
||||
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
|
||||
.filename(file.toString()).build());
|
||||
} else {
|
||||
runMediaCommand("docker", "cp", "egress:/home/egress/backup_storage/" + filename, file.toString());
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file is a playable recording of a single video track: the codec's native
|
||||
* container holding one track of the given codec, starting with a keyframe,
|
||||
* decoding without errors, lasting at least minSeconds at a real frame rate,
|
||||
* showing a moving picture, and reaching the published width x height: in every
|
||||
* frame for a single-layer track, from the top layer on for a multi-layer one
|
||||
* (the egress starts on a low layer and moves up as its bandwidth allows).
|
||||
*/
|
||||
private void assertHealthyVideoRecording(Path file, String codec, double minSeconds, int width, int height,
|
||||
boolean singleLayer) throws Exception {
|
||||
String container = "h264".equals(codec) ? "mp4" : "webm";
|
||||
Assertions.assertTrue(file.toString().endsWith("." + container), "Wrong file extension: " + file);
|
||||
|
||||
JsonObject probe = JsonParser.parseString(runMediaCommand("ffprobe", "-v", "error", "-show_format",
|
||||
"-show_streams", "-count_frames", "-of", "json", file.toString())[0]).getAsJsonObject();
|
||||
JsonObject format = probe.getAsJsonObject("format");
|
||||
JsonArray streams = probe.getAsJsonArray("streams");
|
||||
log.info("Track egress recording {}: {}, {}", file.getFileName(), format, streams);
|
||||
Assertions.assertTrue(format.get("format_name").getAsString().contains(container),
|
||||
"Wrong container: " + format);
|
||||
Assertions.assertEquals(1, streams.size(), "A single track expected: " + streams);
|
||||
JsonObject video = streams.get(0).getAsJsonObject();
|
||||
Assertions.assertEquals("video", video.get("codec_type").getAsString());
|
||||
Assertions.assertEquals(codec, video.get("codec_name").getAsString());
|
||||
Assertions.assertTrue(video.get("width").getAsInt() >= 16 && video.get("height").getAsInt() >= 16,
|
||||
"Wrong video size: " + video);
|
||||
|
||||
double duration = format.get("duration").getAsDouble();
|
||||
int frames = video.get("nb_read_frames").getAsInt();
|
||||
Assertions.assertTrue(duration >= minSeconds && duration < 30, "Wrong duration: " + duration + " s");
|
||||
Assertions.assertTrue(frames / duration >= 10,
|
||||
"Too few frames: " + frames + " in " + duration + " s");
|
||||
|
||||
// Starts with a keyframe, and its timestamps never go back. A simulcast layer
|
||||
// switch gives the keyframe of the new layer the timestamp of the frame it
|
||||
// replaces, so a timestamp may repeat on a keyframe of a multi-layer track only
|
||||
List<String[]> packets = runMediaCommand("ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "packet=dts_time,flags", "-of", "csv=p=0", file.toString())[0].lines()
|
||||
.map(l -> l.trim().split(",")).filter(p -> p.length == 2 && !"N/A".equals(p[0])).toList();
|
||||
Assertions.assertTrue(packets.get(0)[1].startsWith("K"), "The recording must start with a keyframe");
|
||||
for (int i = 1; i < packets.size(); i++) {
|
||||
double dts = Double.parseDouble(packets.get(i)[0]);
|
||||
double previous = Double.parseDouble(packets.get(i - 1)[0]);
|
||||
Assertions.assertTrue(dts > previous || (dts == previous && !singleLayer && packets.get(i)[1].startsWith("K")),
|
||||
"Wrong timestamp " + dts + " after " + previous + " (flags " + packets.get(i)[1] + ")");
|
||||
}
|
||||
|
||||
// Decode every frame: no errors, the picture moves (Chrome's fake camera changes
|
||||
// on every frame) and reaches the published size. VP9 goes through libvpx, the
|
||||
// decoder of browsers and GStreamer: FFmpeg's own shows only the base layer of a
|
||||
// VP9 SVC superframe
|
||||
List<String> command = new ArrayList<>(List.of("ffmpeg", "-nostdin", "-loglevel", "level+info"));
|
||||
if ("vp9".equals(codec)) {
|
||||
command.addAll(List.of("-c:v", "libvpx-vp9"));
|
||||
}
|
||||
command.addAll(List.of("-i", file.toString(), "-map", "0:v", "-vf", "showinfo", "-fps_mode", "passthrough",
|
||||
"-enc_time_base", "demux", "-f", "null", "-"));
|
||||
String decodeLog = runMediaCommand(command.toArray(new String[0]))[1];
|
||||
// Errors of the demuxer and the decoder: the null muxer's own complaints about a
|
||||
// repeated timestamp are covered above
|
||||
List<String> errors = decodeLog.lines().filter(l -> l.contains("[error]") || l.contains("[fatal]"))
|
||||
.filter(l -> !l.startsWith("[null @")).toList();
|
||||
Assertions.assertTrue(errors.isEmpty(), "Decoding reported errors: " + errors);
|
||||
java.util.regex.Matcher frame = java.util.regex.Pattern
|
||||
.compile("Parsed_showinfo.* n: *\\d+ .* s:(\\d+x\\d+) .* checksum:([0-9A-F]+)").matcher(decodeLog);
|
||||
Map<String, Long> framesBySize = new LinkedHashMap<>();
|
||||
List<String> checksums = new ArrayList<>();
|
||||
while (frame.find()) {
|
||||
framesBySize.merge(frame.group(1), 1L, Long::sum);
|
||||
checksums.add(frame.group(2));
|
||||
}
|
||||
log.info("Track egress recording {}: decoded frames by size {}", file.getFileName(), framesBySize);
|
||||
Assertions.assertEquals(frames, checksums.size(), "Every frame must be decoded");
|
||||
long distinct = checksums.stream().distinct().count();
|
||||
Assertions.assertTrue(distinct >= checksums.size() / 2,
|
||||
"The video looks frozen: " + distinct + " distinct frames out of " + checksums.size());
|
||||
|
||||
String expected = width + "x" + height;
|
||||
if (singleLayer) {
|
||||
Assertions.assertEquals(Map.of(expected, (long) checksums.size()), framesBySize,
|
||||
"Every frame of a single-layer track must be " + expected);
|
||||
} else {
|
||||
Assertions.assertTrue(framesBySize.containsKey(expected),
|
||||
"The recording must reach the top layer " + expected + ": " + framesBySize);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a command and returns its stdout and stderr; it must exit with 0. */
|
||||
private static String[] runMediaCommand(String... command) throws IOException, InterruptedException {
|
||||
Path out = Files.createTempFile("media-command", ".out");
|
||||
Path err = Files.createTempFile("media-command", ".err");
|
||||
try {
|
||||
Process process = new ProcessBuilder(command).redirectOutput(out.toFile()).redirectError(err.toFile())
|
||||
.start();
|
||||
Assertions.assertTrue(process.waitFor(120, TimeUnit.SECONDS), "Timeout: " + String.join(" ", command));
|
||||
Assertions.assertEquals(0, process.exitValue(),
|
||||
String.join(" ", command) + " failed: " + Files.readString(err));
|
||||
return new String[] { Files.readString(out), Files.readString(err) };
|
||||
} finally {
|
||||
Files.deleteIfExists(out);
|
||||
Files.deleteIfExists(err);
|
||||
}
|
||||
this.trackEgressVideoCodec(codec, layers);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,150 @@
|
|||
// Synthetic camera and microphone for OpenViduTestAppE2eEgressProTest (Participant Passthrough Egress).
|
||||
//
|
||||
// Replaces navigator.mediaDevices.getUserMedia and getDisplayMedia so that every
|
||||
// track the testapp captures (at connect, when unmuting, when adding a track,
|
||||
// when sharing the screen) comes from:
|
||||
// - a 640x480 canvas (the camera) or an 800x600 canvas (the screen) that is
|
||||
// dark with a moving bar, and white during the first 100 ms of every second
|
||||
// of the page clock. The bar crosses the canvas once every BAR_PERIOD_MS of
|
||||
// that clock, whatever its width, so the left edge of the bar in any frame
|
||||
// tells the page time it was drawn at, within 2 s and to a few ms (whole
|
||||
// seconds at 0 and half the width);
|
||||
// - a WebAudio 1 kHz tone that sounds during the first 100 ms of every second
|
||||
// of the same clock, and is silent otherwise.
|
||||
// A recording keeps audio and video in sync when each white flash starts with
|
||||
// a beep, whenever each track was published.
|
||||
//
|
||||
// A camera track that livekit-client stops (it does on every mute) is kept live
|
||||
// and handed out again by the next getUserMedia. Chrome times every new canvas
|
||||
// capture track afresh, so a camera reopened as a new track sent RTP timestamps
|
||||
// 30-90 ms off its frames, which a real camera does not do (with Chrome's fake
|
||||
// capture device they stay within 1 ms across reopens): the recordings would
|
||||
// show an A/V offset that no SFU or egress introduced.
|
||||
(function () {
|
||||
if (window.__ovSyncMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
var WIDTH = 640;
|
||||
var HEIGHT = 480;
|
||||
var SCREEN_WIDTH = 800;
|
||||
var SCREEN_HEIGHT = 600;
|
||||
var MARKER_MS = 100;
|
||||
var BAR_PERIOD_MS = 2000; // a multiple of 1000: the bar also tells whole seconds
|
||||
var origin = performance.now();
|
||||
function clock() {
|
||||
return performance.now() - origin;
|
||||
}
|
||||
|
||||
function createCanvas(width, height) {
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
}
|
||||
var camera = createCanvas(WIDTH, HEIGHT);
|
||||
var screen = createCanvas(SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
function drawOn(canvas, t) {
|
||||
var ctx = canvas.getContext('2d');
|
||||
var w = canvas.width;
|
||||
var h = canvas.height;
|
||||
if (t % 1000 < MARKER_MS) {
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = '#202020';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
// something always moves, so that encoders keep producing frames
|
||||
ctx.fillStyle = '#3070c0';
|
||||
ctx.fillRect(((t % BAR_PERIOD_MS) / BAR_PERIOD_MS) * w, h / 3, 80, h / 3);
|
||||
ctx.fillStyle = '#c0c0c0';
|
||||
ctx.font = '40px sans-serif';
|
||||
ctx.fillText((t / 1000).toFixed(1), 20, 60);
|
||||
}
|
||||
function draw() {
|
||||
var t = clock();
|
||||
drawOn(camera, t);
|
||||
drawOn(screen, t);
|
||||
}
|
||||
// timers keep running (throttled) in background tabs, where requestAnimationFrame stops
|
||||
setInterval(draw, 1000 / 60);
|
||||
draw();
|
||||
var video = camera.captureStream(30).getVideoTracks()[0];
|
||||
var screenVideo = screen.captureStream(30).getVideoTracks()[0];
|
||||
|
||||
var audioContext = new AudioContext({ sampleRate: 48000 });
|
||||
var destination = audioContext.createMediaStreamDestination();
|
||||
var oscillator = audioContext.createOscillator();
|
||||
oscillator.frequency.value = 1000;
|
||||
var gain = audioContext.createGain();
|
||||
gain.gain.value = 0;
|
||||
oscillator.connect(gain).connect(destination);
|
||||
oscillator.start();
|
||||
var audio = destination.stream.getAudioTracks()[0];
|
||||
|
||||
// Beeps are scheduled on the audio clock, a few hundred ms ahead, mapping each
|
||||
// whole second of the page clock to audio time at the moment of scheduling.
|
||||
var nextBeep = Math.ceil(clock() / 1000) * 1000;
|
||||
setInterval(function () {
|
||||
var now = clock();
|
||||
var audioNow = audioContext.currentTime;
|
||||
while (nextBeep < now + 300) {
|
||||
if (nextBeep >= now) {
|
||||
var at = audioNow + (nextBeep - now) / 1000;
|
||||
gain.gain.setValueAtTime(0.5, at);
|
||||
gain.gain.setValueAtTime(0, at + MARKER_MS / 1000);
|
||||
}
|
||||
nextBeep += 1000;
|
||||
}
|
||||
}, 50);
|
||||
|
||||
// Autoplay policies keep the context suspended until a user gesture: the
|
||||
// WebDriver clicks on the testapp buttons count as one.
|
||||
function resume() {
|
||||
if (audioContext.state !== 'running') {
|
||||
audioContext.resume();
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', resume, true);
|
||||
document.addEventListener('keydown', resume, true);
|
||||
|
||||
// camera tracks stopped by the page, still live, for the next getUserMedia
|
||||
var stoppedCameras = [];
|
||||
function cameraTrack() {
|
||||
var track = stoppedCameras.pop();
|
||||
if (!track) {
|
||||
track = video.clone();
|
||||
track.stop = function () {
|
||||
if (stoppedCameras.indexOf(track) < 0) {
|
||||
stoppedCameras.push(track);
|
||||
}
|
||||
};
|
||||
}
|
||||
track.enabled = true;
|
||||
return track;
|
||||
}
|
||||
|
||||
var originalGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
||||
navigator.mediaDevices.getUserMedia = function (constraints) {
|
||||
resume();
|
||||
var tracks = [];
|
||||
if (constraints && constraints.audio) {
|
||||
tracks.push(audio.clone());
|
||||
}
|
||||
if (constraints && constraints.video) {
|
||||
tracks.push(cameraTrack());
|
||||
}
|
||||
if (tracks.length === 0) {
|
||||
return originalGetUserMedia(constraints);
|
||||
}
|
||||
return Promise.resolve(new MediaStream(tracks));
|
||||
};
|
||||
// the screen flashes with the camera: it must be in sync with the microphone too
|
||||
navigator.mediaDevices.getDisplayMedia = function () {
|
||||
resume();
|
||||
return Promise.resolve(new MediaStream([screenVideo.clone()]));
|
||||
};
|
||||
|
||||
window.__ovSyncMedia = true;
|
||||
})();
|
||||
Loading…
Reference in New Issue