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
pull/915/head
pabloFuente 2026-10-09 11:42:50 +02:00
parent 0e8628189d
commit 14fedb3962
7 changed files with 162 additions and 34 deletions

View File

@ -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<String> 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<String> 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<String> mounts(List<String> args) {
Set<String> 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;
}
}

View File

@ -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();
}

View File

@ -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<String, String> layer : layerSizes.entrySet()) {
Path videoFile = mediaDir
.resolve("test-video-" + layer.getValue() + "." + ("h264".equals(codec) ? "h264" : "ivf"));
commandLine.executeCommand("ffmpeg -y -f lavfi -i testsrc=size=" + layer.getValue()
+ ":rate=30 -t 5 -pix_fmt yuv420p " + encoder + " " + videoFile, 120);
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);
}

View File

@ -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<String[]> packets = runMediaCommand("ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "packet=dts_time,flags", "-of", "csv=p=0", file.toString())[0].lines()
List<String[]> 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<String> errors = decodeLog.lines().filter(l -> l.contains("[error]") || l.contains("[fatal]"))

View File

@ -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

View File

@ -50,6 +50,7 @@ public class OpenViduTestAppE2eServerSdkTest extends AbstractOpenViduTestappE2eT
@BeforeAll()
protected static void setupAll() throws Exception {
pullFfmpegImage();
loadEnvironmentVariables();
setUpLiveKitClient();
CompletableFuture.runAsync(OpenViduTestAppE2eServerSdkTest::pullRemoteBrowserImages);

View File

@ -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);