mirror of https://github.com/OpenVidu/openvidu.git
openvidu-test-e2e: AV1 in Participant Passthrough
egress-pro's Participant Passthrough records AV1 now. The two tests that expected it to fail (av1FromTheStartNotSupportedTest, av1PublishedLaterNotSupportedTest) become eight that expect a valid recording: audio and video from the start (SVC L3T3_KEY), without SVC (constant size), WebM, video published later, unpublished and published again, published again as AV1 after VP8 (republishVideoWithAnotherCodec, the VP8 -> H264 test's body as a helper), packet loss from the SFU and a congested uplink (the SFU's spatial layer switches). The analysis needs no change: the pinned ffmpeg decodes AV1 with libdav1d, the highest spatial layer present in each unit. Downloads go to target/e2e-downloads/<prefix><random>/ instead of /tmp: a JUnit TestWatcher deletes a passing test's downloads and logs where a failed one kept them, so the directory holds what a failure needs and the egress-pro CI workflow can upload it. Verified against the local deployment with the AV1 egress-pro image: the 8 tests and av1TrackEgressTest pass on mediasoup; the whole class, 71 tests, passes on pion. Assisted-by: claude-code - Anthropic - Claude Fable 5.1pull/918/head
parent
5fd4502a1d
commit
32ed2b1096
|
|
@ -6,6 +6,7 @@ import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Iterator;
|
import java.util.Iterator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
|
|
@ -15,9 +16,13 @@ import java.util.concurrent.Callable;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
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.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||||
|
import org.junit.jupiter.api.extension.TestWatcher;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
import org.openqa.selenium.Keys;
|
import org.openqa.selenium.Keys;
|
||||||
import org.openqa.selenium.TakesScreenshot;
|
import org.openqa.selenium.TakesScreenshot;
|
||||||
|
|
@ -39,10 +44,20 @@ import io.openvidu.test.browsers.utils.Ffmpeg;
|
||||||
|
|
||||||
import static org.openqa.selenium.OutputType.BASE64;
|
import static org.openqa.selenium.OutputType.BASE64;
|
||||||
|
|
||||||
|
@ExtendWith(AbstractOpenViduTestappE2eTest.DeleteDownloadsOfPassedTests.class)
|
||||||
public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the tests download the recordings they analyse. A test that passes
|
||||||
|
* deletes its downloads; one that fails keeps them, and the egress-pro CI
|
||||||
|
* workflow uploads this directory when the tests fail.
|
||||||
|
*/
|
||||||
|
protected static final Path DOWNLOADS_DIR = Path.of("target", "e2e-downloads").toAbsolutePath();
|
||||||
|
|
||||||
protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
|
protected Collection<OpenViduTestappUser> testappUsers = new HashSet<>();
|
||||||
|
|
||||||
|
private final List<Path> downloadDirs = new ArrayList<>();
|
||||||
|
|
||||||
protected String getLivekitWssUrlFromReadyCheckContainer() {
|
protected String getLivekitWssUrlFromReadyCheckContainer() {
|
||||||
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
|
String logs = commandLine.executeCommand("docker logs ready-check 2>&1", 30);
|
||||||
if (logs != null && !logs.isBlank()) {
|
if (logs != null && !logs.isBlank()) {
|
||||||
|
|
@ -1157,7 +1172,7 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
||||||
*/
|
*/
|
||||||
protected Path downloadRecording(JsonObject egress) throws Exception {
|
protected Path downloadRecording(JsonObject egress) throws Exception {
|
||||||
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
|
String filename = egress.get("file").getAsJsonObject().get("filename").getAsString();
|
||||||
Path file = Files.createTempDirectory("egress").resolve(Path.of(filename).getFileName());
|
Path file = newDownloadDir("egress").resolve(Path.of(filename).getFileName());
|
||||||
if (isMinioAvailable()) {
|
if (isMinioAvailable()) {
|
||||||
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
|
MinioClient.builder().endpoint("localhost", 9000, false).credentials("minioadmin", "minioadmin").build()
|
||||||
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
|
.downloadObject(DownloadObjectArgs.builder().bucket("openvidu-appdata").object(filename)
|
||||||
|
|
@ -1275,4 +1290,37 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e {
|
||||||
Files.deleteIfExists(err);
|
Files.deleteIfExists(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A new directory under {@link #DOWNLOADS_DIR} for what the running test downloads. */
|
||||||
|
protected Path newDownloadDir(String prefix) throws IOException {
|
||||||
|
Path dir = Files.createTempDirectory(Files.createDirectories(DOWNLOADS_DIR), prefix);
|
||||||
|
downloadDirs.add(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the downloads of a test that passed, and logs where a failed one kept them. */
|
||||||
|
public static class DeleteDownloadsOfPassedTests implements TestWatcher {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void testSuccessful(ExtensionContext context) {
|
||||||
|
context.getTestInstance().filter(AbstractOpenViduTestappE2eTest.class::isInstance)
|
||||||
|
.map(AbstractOpenViduTestappE2eTest.class::cast).ifPresent(test -> {
|
||||||
|
for (Path dir : test.downloadDirs) {
|
||||||
|
try (Stream<Path> files = Files.walk(dir)) {
|
||||||
|
files.sorted(Comparator.reverseOrder()).forEach(f -> f.toFile().delete());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Could not delete the downloads in {}: {}", dir, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void testFailed(ExtensionContext context, Throwable cause) {
|
||||||
|
context.getTestInstance().filter(AbstractOpenViduTestappE2eTest.class::isInstance)
|
||||||
|
.map(AbstractOpenViduTestappE2eTest.class::cast).filter(test -> !test.downloadDirs.isEmpty())
|
||||||
|
.ifPresent(test -> log.info("Downloads of failed test {} kept in {}", context.getDisplayName(),
|
||||||
|
test.downloadDirs));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ import okhttp3.Response;
|
||||||
* timeline (OpenVidu egress fork: the PASSTHROUGH preset extended from one
|
* timeline (OpenVidu egress fork: the PASSTHROUGH preset extended from one
|
||||||
* track to an audio track plus a video track). It is requested with
|
* track to an audio track plus a video track). It is requested with
|
||||||
* StartParticipantEgress, or with StartEgress and a MediaSource, and preset
|
* StartParticipantEgress, or with StartEgress and a MediaSource, and preset
|
||||||
* PASSTHROUGH.
|
* PASSTHROUGH. Video VP8, VP9 and AV1 (SVC included) and H.264, audio Opus.
|
||||||
*
|
*
|
||||||
* Publishers are openvidu-testapp instances whose camera, screen and microphone
|
* Publishers are openvidu-testapp instances whose camera, screen and microphone
|
||||||
* are replaced (src/test/resources/participant-passthrough-sync-media.js) by
|
* are replaced (src/test/resources/participant-passthrough-sync-media.js) by
|
||||||
|
|
@ -333,40 +333,27 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AV1 from the start is not supported")
|
@DisplayName("AV1 audio and video from the start")
|
||||||
void av1FromTheStartNotSupportedTest() throws Exception {
|
void av1AudioAndVideoTest() throws Exception {
|
||||||
log.info("Participant Passthrough: AV1 from the start is not supported");
|
log.info("Participant Passthrough: AV1 audio and video from the start");
|
||||||
String room = uniqueRoom("av1-start");
|
// SVC (L3T3_KEY, livekit-client's default for AV1): the egress may start on a
|
||||||
joinPublisher("chrome", room, "alice", "av1", true, true, true);
|
// low layer and moves up, as with simulcast
|
||||||
|
audioAndVideoFromTheStart("chrome", "av1", true, "mkv");
|
||||||
// like every other egress, it fails on a track it cannot handle when started
|
|
||||||
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
|
|
||||||
JsonObject info = waitUntilEgressEnds(egressId, 60);
|
|
||||||
Assertions.assertEquals("EGRESS_FAILED", info.get("status").getAsString(), "Unexpected egress end: " + info);
|
|
||||||
String error = info.has("error") ? info.get("error").getAsString() : "";
|
|
||||||
Assertions.assertTrue(error.toLowerCase(Locale.ROOT).contains("av1"),
|
|
||||||
"The egress error must name the AV1 codec: " + info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("AV1 published later is not supported")
|
@DisplayName("AV1 without SVC")
|
||||||
void av1PublishedLaterNotSupportedTest() throws Exception {
|
void av1WithoutSvcTest() throws Exception {
|
||||||
log.info("Participant Passthrough: AV1 published later is not supported");
|
log.info("Participant Passthrough: AV1 without SVC");
|
||||||
String room = uniqueRoom("av1-later");
|
Recording recording = audioAndVideoFromTheStart("chrome", "av1", false, "mkv");
|
||||||
Publisher alice = joinPublisher("chrome", room, "alice", "av1", true, true, false);
|
recording.video(0).assertConstantSize(CAMERA_WIDTH, CAMERA_HEIGHT);
|
||||||
|
}
|
||||||
|
|
||||||
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
|
@Test
|
||||||
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20);
|
@DisplayName("WebM file with AV1")
|
||||||
Thread.sleep(4000);
|
void webmAv1Test() throws Exception {
|
||||||
alice.addVideo();
|
log.info("Participant Passthrough: WebM file with AV1");
|
||||||
|
audioAndVideoFromTheStart("chrome", "av1", true, "webm");
|
||||||
// like every other egress, it fails on a track it cannot handle, whenever it
|
|
||||||
// is published
|
|
||||||
JsonObject info = waitUntilEgressEnds(egressId, 30);
|
|
||||||
Assertions.assertEquals("EGRESS_FAILED", info.get("status").getAsString(), "Unexpected egress end: " + info);
|
|
||||||
String error = info.has("error") ? info.get("error").getAsString() : "";
|
|
||||||
Assertions.assertTrue(error.toLowerCase(Locale.ROOT).contains("av1"),
|
|
||||||
"The egress error must name the AV1 codec: " + info);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------------
|
// ------------------------------------------------------------------------
|
||||||
|
|
@ -437,6 +424,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
audioFirstThenVideo("h264");
|
audioFirstThenVideo("h264");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Audio first, AV1 video added later")
|
||||||
|
void audioFirstThenAv1VideoTest() throws Exception {
|
||||||
|
log.info("Participant Passthrough: audio first, AV1 video added later");
|
||||||
|
audioFirstThenVideo("av1");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Video first, audio added later")
|
@DisplayName("Video first, audio added later")
|
||||||
void videoFirstThenAudioTest() throws Exception {
|
void videoFirstThenAudioTest() throws Exception {
|
||||||
|
|
@ -793,6 +787,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
unpublishRepublishVideo("h264");
|
unpublishRepublishVideo("h264");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Unpublish and publish again AV1 video")
|
||||||
|
void unpublishRepublishAv1VideoTest() throws Exception {
|
||||||
|
log.info("Participant Passthrough: unpublish and publish again AV1 video");
|
||||||
|
unpublishRepublishVideo("av1");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Unpublish and publish again audio")
|
@DisplayName("Unpublish and publish again audio")
|
||||||
void unpublishRepublishAudioTest() throws Exception {
|
void unpublishRepublishAudioTest() throws Exception {
|
||||||
|
|
@ -876,31 +877,43 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
@DisplayName("Publish video again with another codec")
|
@DisplayName("Publish video again with another codec")
|
||||||
void republishVideoWithAnotherCodecTest() throws Exception {
|
void republishVideoWithAnotherCodecTest() throws Exception {
|
||||||
log.info("Participant Passthrough: publish video again with another codec");
|
log.info("Participant Passthrough: publish video again with another codec");
|
||||||
String room = uniqueRoom("republish-codec");
|
republishVideoWithAnotherCodec("vp8", "h264");
|
||||||
Publisher alice = joinPublisher("chrome", room, "alice", "vp8", true, true, true);
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Publish video again as AV1")
|
||||||
|
void republishVideoAsAv1Test() throws Exception {
|
||||||
|
log.info("Participant Passthrough: publish video again as AV1");
|
||||||
|
republishVideoWithAnotherCodec("vp8", "av1");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void republishVideoWithAnotherCodec(String first, String second) throws Exception {
|
||||||
|
String room = uniqueRoom("republish-codec-" + second);
|
||||||
|
Publisher alice = joinPublisher("chrome", room, "alice", first, true, true, true);
|
||||||
|
|
||||||
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
|
String egressId = startParticipantRecording(room, "alice", filepath(room, "alice", "mkv"));
|
||||||
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20);
|
waitUntilEgressStatus(egressId, "EGRESS_ACTIVE", 20);
|
||||||
Thread.sleep(5000);
|
Thread.sleep(5000);
|
||||||
alice.unpublishVideo();
|
alice.unpublishVideo();
|
||||||
alice.setVideoCodec("h264");
|
alice.setVideoCodec(second);
|
||||||
alice.addVideo();
|
alice.addVideo();
|
||||||
Thread.sleep(7000);
|
Thread.sleep(7000);
|
||||||
|
|
||||||
// a VP8 track cannot continue as H264: the file gets a second video track
|
// a track of one codec cannot continue as another: the file gets a second
|
||||||
|
// video track
|
||||||
Recording recording = stopAndGetRecording(egressId);
|
Recording recording = stopAndGetRecording(egressId);
|
||||||
recording.assertFinalized();
|
recording.assertFinalized();
|
||||||
recording.assertTracks("audio:opus", "video:vp8", "video:h264");
|
recording.assertTracks("audio:opus", "video:" + first, "video:" + second);
|
||||||
recording.assertHealthy();
|
recording.assertHealthy();
|
||||||
recording.assertStartTogether();
|
recording.assertStartTogether();
|
||||||
recording.assertAvSync(8);
|
recording.assertAvSync(8);
|
||||||
FileTrack vp8 = recording.videoWithCodec("vp8");
|
FileTrack before = recording.videoWithCodec(first);
|
||||||
FileTrack h264 = recording.videoWithCodec("h264");
|
FileTrack after = recording.videoWithCodec(second);
|
||||||
Assertions.assertTrue(vp8.lastPacketTime() <= h264.firstPacketTime(),
|
Assertions.assertTrue(before.lastPacketTime() <= after.firstPacketTime(),
|
||||||
"The H264 track must follow the VP8 track: " + vp8 + " / " + h264);
|
"The " + second + " track must follow the " + first + " track: " + before + " / " + after);
|
||||||
vp8.assertMarkers(3);
|
before.assertMarkers(3);
|
||||||
h264.assertMarkers(4);
|
after.assertMarkers(4);
|
||||||
h264.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT);
|
after.assertReachedSize(CAMERA_WIDTH, CAMERA_HEIGHT);
|
||||||
Assertions.assertEquals(1, recording.ofType("video").stream().filter(s -> s.isDefault).count(),
|
Assertions.assertEquals(1, recording.ofType("video").stream().filter(s -> s.isDefault).count(),
|
||||||
"Exactly one video track must be the default one: " + recording.streams);
|
"Exactly one video track must be the default one: " + recording.streams);
|
||||||
}
|
}
|
||||||
|
|
@ -1175,6 +1188,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
networkTrouble(Trouble.EGRESS_LOSS, "h264");
|
networkTrouble(Trouble.EGRESS_LOSS, "h264");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Packet loss from the SFU to the egress AV1")
|
||||||
|
void egressPacketLossAv1Test() throws Exception {
|
||||||
|
log.info("Participant Passthrough: packet loss from the SFU to the egress AV1");
|
||||||
|
networkTrouble(Trouble.EGRESS_LOSS, "av1");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Packet loss on the publisher's uplink VP8")
|
@DisplayName("Packet loss on the publisher's uplink VP8")
|
||||||
void publisherPacketLossVp8Test() throws Exception {
|
void publisherPacketLossVp8Test() throws Exception {
|
||||||
|
|
@ -1210,6 +1230,13 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
networkTrouble(Trouble.PUBLISHER_CONGESTION, "h264");
|
networkTrouble(Trouble.PUBLISHER_CONGESTION, "h264");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Publisher's uplink congested AV1")
|
||||||
|
void publisherCongestionAv1Test() throws Exception {
|
||||||
|
log.info("Participant Passthrough: publisher's uplink congested AV1");
|
||||||
|
networkTrouble(Trouble.PUBLISHER_CONGESTION, "av1");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Jitter on the publisher's uplink VP8")
|
@DisplayName("Jitter on the publisher's uplink VP8")
|
||||||
void publisherJitterVp8Test() throws Exception {
|
void publisherJitterVp8Test() throws Exception {
|
||||||
|
|
@ -2520,7 +2547,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
*/
|
*/
|
||||||
private Recording getRecording(JsonObject egressInfo) throws Exception {
|
private Recording getRecording(JsonObject egressInfo) throws Exception {
|
||||||
String key = fileKey(egressInfo);
|
String key = fileKey(egressInfo);
|
||||||
Path local = Files.createTempDirectory("participant-passthrough").resolve(Paths.get(key).getFileName());
|
Path local = newDownloadDir("participant-passthrough").resolve(Paths.get(key).getFileName());
|
||||||
minio.downloadObject(DownloadObjectArgs.builder().bucket(MINIO_BUCKET).object(key)
|
minio.downloadObject(DownloadObjectArgs.builder().bucket(MINIO_BUCKET).object(key)
|
||||||
.filename(local.toString()).build());
|
.filename(local.toString()).build());
|
||||||
return Recording.analyze(local);
|
return Recording.analyze(local);
|
||||||
|
|
@ -2539,7 +2566,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT
|
||||||
*/
|
*/
|
||||||
private Recording snapshotRecordingInProgress(String egressId) throws Exception {
|
private Recording snapshotRecordingInProgress(String egressId) throws Exception {
|
||||||
String remote = fileBeingRecorded(egressId);
|
String remote = fileBeingRecorded(egressId);
|
||||||
Path local = Files.createTempDirectory("participant-passthrough-live").resolve(Paths.get(remote).getFileName());
|
Path local = newDownloadDir("participant-passthrough-live").resolve(Paths.get(remote).getFileName());
|
||||||
Exec copy = Exec.run(60, "docker", "cp", EGRESS_CONTAINER + ":" + remote, local.toString());
|
Exec copy = Exec.run(60, "docker", "cp", EGRESS_CONTAINER + ":" + remote, local.toString());
|
||||||
Assertions.assertEquals(0, copy.exitCode, "Could not copy the file being recorded: " + copy.stderr);
|
Assertions.assertEquals(0, copy.exitCode, "Could not copy the file being recorded: " + copy.stderr);
|
||||||
return Recording.analyze(local);
|
return Recording.analyze(local);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue