From 14fedb3962c13b0f44758129b1c0d0a0cb3c9cad Mon Sep 17 00:00:00 2001 From: pabloFuente Date: Fri, 9 Oct 2026 11:42:50 +0200 Subject: [PATCH] openvidu-test-e2e: run ffmpeg and ffprobe in a pinned Docker image, never the host's Assisted-by: claude-code - Anthropic - Claude Opus 5.5 --- .../openvidu/test/browsers/utils/Ffmpeg.java | 124 ++++++++++++++++++ .../utils/MultimediaFileMetadata.java | 4 +- .../io/openvidu/test/e2e/OpenViduTestE2e.java | 19 ++- .../e2e/AbstractOpenViduTestappE2eTest.java | 11 +- .../e2e/OpenViduTestAppE2eEgressProTest.java | 30 +++-- .../e2e/OpenViduTestAppE2eServerSdkTest.java | 1 + .../test/e2e/OpenViduTestAppE2eTest.java | 7 +- 7 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/Ffmpeg.java diff --git a/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/Ffmpeg.java b/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/Ffmpeg.java new file mode 100644 index 000000000..d62a37fb1 --- /dev/null +++ b/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/Ffmpeg.java @@ -0,0 +1,124 @@ +/* + * (C) Copyright 2017-2026 OpenVidu (https://openvidu.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package io.openvidu.test.browsers.utils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Runs ffmpeg and ffprobe as a Docker container + */ +public final class Ffmpeg { + + /** + * A static ffmpeg build with libdav1d, libvpx, libx264, libaom and libopus, its + * multi-arch digest pinned: an image moved under the same tag could decode + * differently. Bump the tag and the digest together. -DFFMPEG_IMAGE overrides + * it with an image of the same layout (/ffmpeg and /ffprobe), e.g. + * mwader/static-ffmpeg:latest. + */ + public static final String IMAGE = System.getProperty("FFMPEG_IMAGE", + "mwader/static-ffmpeg:9.0.2@sha256:7d9bdaaf887f7e6ce6151f67325c344074b5ff1fb75316011c3376503e449a7b"); + + private static String user; + + private Ffmpeg() { + } + + /** + * The command line that runs {@code tool} ("ffmpeg" or "ffprobe") with + * {@code args} in a container of {@link #IMAGE}. + */ + public static String[] command(String tool, String... args) { + List command = new ArrayList<>(List.of("docker", "run", "--rm", "--quiet", "--user", user())); + for (String dir : mounts(Arrays.asList(args))) { + command.addAll(List.of("-v", dir + ":" + dir)); + } + command.addAll(List.of("--entrypoint", "/" + tool, IMAGE)); + command.addAll(Arrays.asList(args)); + return command.toArray(new String[0]); + } + + /** {@link #command(String, String...)}, with the tool first in the list. */ + public static String[] command(List toolAndArgs) { + return command(toolAndArgs.get(0), toolAndArgs.subList(1, toolAndArgs.size()).toArray(new String[0])); + } + + /** + * The same as one /bin/sh command line, for {@link CommandLineExecutor}: + * {@code args} is what would follow the tool in a shell. + */ + public static String shellCommand(String tool, String args) { + StringBuilder command = new StringBuilder("docker run --rm --quiet --user ").append(user()); + for (String dir : mounts(Arrays.asList(args.trim().split("\\s+")))) { + command.append(" -v '").append(dir).append(":").append(dir).append("'"); + } + return command.append(" --entrypoint /").append(tool).append(" ").append(IMAGE).append(" ").append(args) + .toString(); + } + + /** + * Pulls {@link #IMAGE} up front: a missing image would otherwise be pulled by + * the first command, inside its timeout. + */ + public static void pullImage() throws IOException, InterruptedException { + Process pull = new ProcessBuilder("docker", "pull", "--quiet", IMAGE).redirectErrorStream(true).start(); + String output = new String(pull.getInputStream().readAllBytes()); + if (!pull.waitFor(600, TimeUnit.SECONDS) || pull.exitValue() != 0) { + throw new IOException("docker pull " + IMAGE + " failed: " + output.trim()); + } + } + + private static Set mounts(List args) { + Set dirs = new LinkedHashSet<>(); + for (String arg : args) { + if (!arg.startsWith("/")) { + continue; + } + Path path = Path.of(arg).normalize(); + Path dir = Files.isDirectory(path) ? path : path.getParent(); + if (dir != null && dir.getParent() != null && Files.isDirectory(dir)) { + dirs.add(dir.toString()); + } + } + return dirs; + } + + private static synchronized String user() { + if (user == null) { + try { + Path probe = Files.createTempFile("ffmpeg-user", ".tmp"); + try { + user = Files.getAttribute(probe, "unix:uid") + ":" + Files.getAttribute(probe, "unix:gid"); + } finally { + Files.deleteIfExists(probe); + } + } catch (IOException e) { + throw new IllegalStateException("Could not read the current user's uid and gid", e); + } + } + return user; + } +} diff --git a/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/MultimediaFileMetadata.java b/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/MultimediaFileMetadata.java index 64fdeffcd..c6e691fe4 100644 --- a/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/MultimediaFileMetadata.java +++ b/openvidu-test-browsers/src/main/java/io/openvidu/test/browsers/utils/MultimediaFileMetadata.java @@ -151,7 +151,7 @@ public class MultimediaFileMetadata { private JsonObject executeFfprobeCommand(String filePath) { log.info("Running ffprobe command on '{}'", filePath); - String cmd = "ffprobe -v quiet -print_format json -show_format -show_streams " + filePath; + String cmd = Ffmpeg.shellCommand("ffprobe", "-v quiet -print_format json -show_format -show_streams " + filePath); return JsonParser.parseString(this.executer.executeCommand(cmd, 60)).getAsJsonObject(); } @@ -161,7 +161,7 @@ public class MultimediaFileMetadata { String pathCopy = null; pathCopy = java.nio.file.Files.move(source, source.resolveSibling("COPY." + extension)).toString(); log.warn("Fixing file '{}' with ffmpeg", filePath); - String cmd = "ffmpeg -i " + pathCopy + " -vcodec copy -acodec copy " + filePath; + String cmd = Ffmpeg.shellCommand("ffmpeg", "-i " + pathCopy + " -vcodec copy -acodec copy " + filePath); this.executer.executeCommand(cmd, 60); new File(pathCopy).delete(); } diff --git a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java index 6d32a5a18..a43bfeb5e 100644 --- a/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java +++ b/openvidu-test-e2e/src/main/java/io/openvidu/test/e2e/OpenViduTestE2e.java @@ -55,6 +55,7 @@ import io.openvidu.test.browsers.EdgeUser; import io.openvidu.test.browsers.FirefoxUser; import io.openvidu.test.browsers.utils.BrowserNames; import io.openvidu.test.browsers.utils.CommandLineExecutor; +import io.openvidu.test.browsers.utils.Ffmpeg; import livekit.LivekitIngress.IngressInfo; import livekit.LivekitModels.Room; import okhttp3.OkHttpClient; @@ -162,15 +163,13 @@ public class OpenViduTestE2e { protected static String LIVEKIT_HTTP_URL; protected static OkHttpClient LK_HTTP_CLIENT; - protected static void checkFfmpegInstallation() { - String ffmpegOutput = commandLine.executeCommand("which ffmpeg", 60); - if (ffmpegOutput == null || ffmpegOutput.isEmpty()) { - log.error("ffmpeg package is not installed in the host machine"); - Assertions.fail(); - return; - } else { - log.info("ffmpeg is installed and accesible"); + protected static void pullFfmpegImage() { + try { + Ffmpeg.pullImage(); + } catch (Exception e) { + Assertions.fail("Could not pull the ffmpeg image " + Ffmpeg.IMAGE + ": " + e.getMessage()); } + log.info("ffmpeg and ffprobe run in Docker image {}", Ffmpeg.IMAGE); } private GenericContainer chromeContainer(String image, long shmSize, int maxBrowserSessions, boolean headless) { @@ -598,8 +597,8 @@ public class OpenViduTestE2e { for (Map.Entry 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); + commandLine.executeCommand(Ffmpeg.shellCommand("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); } diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java index f2884cae4..58f4abf10 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/AbstractOpenViduTestappE2eTest.java @@ -35,6 +35,7 @@ import io.minio.BucketExistsArgs; import io.minio.DownloadObjectArgs; import io.minio.MinioClient; import io.openvidu.test.browsers.BrowserUser; +import io.openvidu.test.browsers.utils.Ffmpeg; import static org.openqa.selenium.OutputType.BASE64; @@ -1180,8 +1181,8 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { 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 probe = JsonParser.parseString(runMediaCommand(Ffmpeg.command("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); @@ -1203,8 +1204,8 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { // 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 packets = runMediaCommand("ffprobe", "-v", "error", "-select_streams", "v:0", - "-show_entries", "packet=dts_time,flags", "-of", "csv=p=0", file.toString())[0].lines() + List packets = runMediaCommand(Ffmpeg.command("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++) { @@ -1226,7 +1227,7 @@ public class AbstractOpenViduTestappE2eTest extends OpenViduTestE2e { } 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]; + String decodeLog = runMediaCommand(Ffmpeg.command(command))[1]; // Errors of the demuxer and the decoder: the null muxer's own complaints about a // repeated timestamp are covered above List errors = decodeLog.lines().filter(l -> l.contains("[error]") || l.contains("[fatal]")) diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java index bf987b9b8..ecf2ac001 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eEgressProTest.java @@ -48,6 +48,7 @@ import io.livekit.server.RoomCreate; import io.livekit.server.RoomRecord; import io.minio.DownloadObjectArgs; import io.minio.MinioClient; +import io.openvidu.test.browsers.utils.Ffmpeg; import livekit.LivekitModels.ParticipantInfo; import livekit.LivekitModels.TrackInfo; import livekit.LivekitModels.TrackSource; @@ -89,8 +90,8 @@ import okhttp3.Response; * * Needs the Compose deployment (egress + MinIO) running openvidu/egress-pro, * which exits at startup without a valid OpenVidu PRO license (the CI workflow - * "OpenVidu Pro E2E Egress Pro tests" sets both up), and ffmpeg/ffprobe on the - * host, with libvpx and libdav1d. + * "OpenVidu Pro E2E Egress Pro tests" sets both up). ffmpeg and ffprobe run in + * the Docker image of {@link Ffmpeg}, never the host's. * * @author Pablo Fuente (pablofuenteperez@gmail.com) */ @@ -235,7 +236,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT @BeforeAll() protected static void setupAll() throws Exception { - checkFfmpegInstallation(); + pullFfmpegImage(); loadEnvironmentVariables(); setUpLiveKitClient(); syncMediaScript = Files.readString(Paths.get("src/test/resources/participant-passthrough-sync-media.js")); @@ -3041,8 +3042,8 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT } private void probe() throws Exception { - Exec probe = Exec.run(60, "ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", "json", - file.toString()); + Exec probe = Exec.run(60, Ffmpeg.command("ffprobe", "-v", "error", "-show_format", "-show_streams", "-of", + "json", file.toString())); Assertions.assertEquals(0, probe.exitCode, "ffprobe failed on " + file + ": " + probe.stderr); JsonObject json = JsonParser.parseString(probe.stdout).getAsJsonObject(); JsonObject format = json.getAsJsonObject("format"); @@ -3060,8 +3061,8 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT } private void readPackets() throws Exception { - Exec packets = Exec.run(120, "ffprobe", "-v", "error", "-show_entries", "packet=stream_index,pts_time", - "-of", "csv=p=0", file.toString()); + Exec packets = Exec.run(120, Ffmpeg.command("ffprobe", "-v", "error", "-show_entries", + "packet=stream_index,pts_time", "-of", "csv=p=0", file.toString())); Assertions.assertEquals(0, packets.exitCode, "ffprobe could not read the packets: " + packets.stderr); for (String line : packets.stdout.split("\n")) { String[] fields = line.trim().split(","); @@ -3078,8 +3079,8 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT private void decode() throws Exception { // passthrough timestamps in the demuxer's time base: frames 33 +/- 1 ms apart // must not collide in a 1/30 s encoder time base - Exec decode = Exec.run(300, "ffmpeg", "-nostdin", "-v", "error", "-i", file.toString(), "-map", "0", - "-fps_mode", "passthrough", "-enc_time_base", "demux", "-f", "null", "-"); + Exec decode = Exec.run(300, Ffmpeg.command("ffmpeg", "-nostdin", "-v", "error", "-i", file.toString(), "-map", + "0", "-fps_mode", "passthrough", "-enc_time_base", "demux", "-f", "null", "-")); decodeErrors = decode.exitCode == 0 ? decode.stderr.trim() : "exit " + decode.exitCode + ": " + decode.stderr; } @@ -3103,7 +3104,7 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT command.addAll(List.of("-i", file.toString(), "-map", "0:" + s.index, "-fps_mode", "passthrough", "-vf", "crop=iw:2:0:trunc(ih/4)*2,scale=" + BAR_ROW_WIDTH + ":1:flags=area,format=gray,showinfo", "-f", "rawvideo", rows.toString())); - Exec frames = Exec.run(300, command.toArray(new String[0])); + Exec frames = Exec.run(300, Ffmpeg.command(command)); Assertions.assertEquals(0, frames.exitCode, "ffmpeg could not read the video: " + frames.stderr); Matcher pts = SHOWINFO_PTS.matcher(frames.stderr); while (pts.find()) { @@ -3145,8 +3146,9 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT } s.bar.sort((a, b) -> Double.compare(a[0], b[0])); countBrokenPictures(s, barRuns); - Exec sizes = Exec.run(300, "ffprobe", "-v", "error", "-select_streams", String.valueOf(s.index), - "-show_entries", "frame=pts_time,width,height", "-of", "csv=p=0", file.toString()); + Exec sizes = Exec.run(300, Ffmpeg.command("ffprobe", "-v", "error", "-select_streams", + String.valueOf(s.index), "-show_entries", "frame=pts_time,width,height", "-of", "csv=p=0", + file.toString())); Assertions.assertEquals(0, sizes.exitCode, "ffprobe could not read the frame sizes: " + sizes.stderr); for (String line : sizes.stdout.split("\n")) { String[] fields = line.trim().split(","); @@ -3279,8 +3281,8 @@ public class OpenViduTestAppE2eEgressProTest extends AbstractOpenViduTestappE2eT } private void readAudio(FileTrack s) throws Exception { - Exec beeps = Exec.run(300, "ffmpeg", "-nostdin", "-copyts", "-i", file.toString(), "-map", "0:" + s.index, - "-af", "silencedetect=n=-30dB:d=0.3", "-f", "null", "-"); + Exec beeps = Exec.run(300, Ffmpeg.command("ffmpeg", "-nostdin", "-copyts", "-i", file.toString(), "-map", + "0:" + s.index, "-af", "silencedetect=n=-30dB:d=0.3", "-f", "null", "-")); Assertions.assertEquals(0, beeps.exitCode, "ffmpeg could not read the audio: " + beeps.stderr); // A beep is sound between a silence_end and the next silence_start. One cut // short (the track resumed, after silence, in the middle of a beep) does not diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java index d7d3cc589..5755610dd 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eServerSdkTest.java @@ -50,6 +50,7 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT @BeforeAll() protected static void setupAll() throws Exception { + pullFfmpegImage(); loadEnvironmentVariables(); setUpLiveKitClient(); CompletableFuture.runAsync(OpenViduTestAppE2eServerSdkTest::pullRemoteBrowserImages); diff --git a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java index d79bc795d..402388671 100644 --- a/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java +++ b/openvidu-test-e2e/src/test/java/io/openvidu/test/e2e/OpenViduTestAppE2eTest.java @@ -87,6 +87,7 @@ import io.minio.errors.InvalidResponseException; import io.minio.errors.ServerException; import io.minio.errors.XmlParserException; import io.minio.messages.Item; +import io.openvidu.test.browsers.utils.Ffmpeg; import livekit.LivekitIngress.IngressInfo; import livekit.LivekitIngress.IngressState; import livekit.LivekitModels.ConnectionQuality; @@ -110,7 +111,7 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { @BeforeAll() protected static void setupAll() throws Exception { - checkFfmpegInstallation(); + pullFfmpegImage(); loadEnvironmentVariables(); setUpLiveKitClient(); CompletableFuture.runAsync(() -> { @@ -2165,11 +2166,11 @@ public class OpenViduTestAppE2eTest extends AbstractOpenViduTestappE2eTest { // Generate a WAV file with 5s tone + 5s silence (10 seconds total). // Chrome will loop this file as the fake audio capture source. String dtxAudioPath = "/opt/openvidu/dtx_test_audio.wav"; - String ffmpegCmd = "ffmpeg -y" + String ffmpegCmd = Ffmpeg.shellCommand("ffmpeg", "-y" + " -f lavfi -i sine=frequency=440:duration=5" + " -f lavfi -i anullsrc=r=48000:cl=mono" + " -filter_complex \"[1]atrim=duration=5[silence];[0][silence]concat=n=2:v=0:a=1[out]\"" - + " -map \"[out]\" -ar 48000 -ac 1 " + dtxAudioPath; + + " -map \"[out]\" -ar 48000 -ac 1 " + dtxAudioPath); String ffmpegOutput = commandLine.executeCommand(ffmpegCmd, 30); log.info("ffmpeg output: {}", ffmpegOutput); java.io.File dtxAudioFile = new java.io.File(dtxAudioPath);