Repository navigation
Add article: Driving FFmpeg from Java Without Deadlocks or Broken Files #434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
markksantos
wants to merge
2
commits into
foojayio:main
Choose a base branch
from
markksantos:article/driving-ffmpeg-from-java
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| title: "Mark Santos" | ||
| avatar: "mark-santos.jpg" | ||
| avatarFull: "mark-santos-full.jpg" | ||
| bio: "Mark Santos is a video editor. His studio, Mark Studios, edits YouTube videos, Shorts and talking-head content." | ||
| description: "" | ||
| bluesky: "" | ||
| mastodon: "" | ||
| linkedin: "" | ||
| github: "https://github.com/markksantos" | ||
| gitlab: "" | ||
| youtube: "" | ||
| twitter: "" | ||
| website: "https://www.markstudios.com" | ||
| aliases: [] | ||
| --- |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| import java.nio.file.Path; | ||
| import java.time.Duration; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.Future; | ||
| import java.util.concurrent.Semaphore; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| public class Batch { | ||
| static int waitOrStop(Process p, Duration limit) throws InterruptedException { | ||
| if (p.waitFor(limit.toMillis(), TimeUnit.MILLISECONDS)) return p.exitValue(); | ||
| p.destroy(); // SIGTERM: ffmpeg still finalizes the file | ||
| if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly(); | ||
| return p.waitFor(); | ||
| } | ||
|
|
||
| static int encode(String in, String out) throws Exception { | ||
| Process p = new ProcessBuilder("ffmpeg", "-hide_banner", "-nostdin", "-y", | ||
| "-i", in, "-c:v", "libx264", "-preset", "medium", "-c:a", "aac", out) | ||
| .redirectError(Path.of(out + ".log").toFile()) | ||
| .start(); | ||
| return waitOrStop(p, Duration.ofMinutes(10)); | ||
| } | ||
|
|
||
| static double runAll(List<String> inputs, int limit) throws Exception { | ||
| if (limit < 1) throw new IllegalArgumentException("limit must be at least 1, got " + limit); | ||
| var slots = new Semaphore(limit); | ||
| long start = System.nanoTime(); | ||
| try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { | ||
| List<Future<Integer>> jobs = new ArrayList<>(); | ||
| for (int i = 0; i < inputs.size(); i++) { | ||
| String in = inputs.get(i), out = "out/batch-" + i + ".mp4"; | ||
| jobs.add(pool.submit(() -> { | ||
| slots.acquire(); | ||
| try { | ||
| return encode(in, out); | ||
| } finally { | ||
| slots.release(); | ||
| } | ||
| })); | ||
| } | ||
| for (var job : jobs) { | ||
| if (job.get() != 0) throw new IllegalStateException("an encode failed"); | ||
| } | ||
| } | ||
| return (System.nanoTime() - start) / 1e9; | ||
| } | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| List<String> inputs = List.of(args).subList(1, args.length); | ||
| System.out.printf("%d cores reported, %d files%n", | ||
| Runtime.getRuntime().availableProcessors(), inputs.size()); | ||
| for (String limit : args[0].split(",")) { | ||
| int n = Integer.parseInt(limit); | ||
| System.out.printf("%2d at a time: %5.1f s%n", n, runAll(inputs, n)); | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import java.nio.file.Files; | ||
| import java.nio.file.Path; | ||
| import java.util.List; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| public class Cancel { | ||
| enum Stop { QUIT_KEY, DESTROY, DESTROY_FORCIBLY } | ||
|
|
||
| static Process start(String in, String out, Path log) throws Exception { | ||
| return new ProcessBuilder("ffmpeg", "-hide_banner", "-y", "-i", in, | ||
| "-c:v", "libx264", "-preset", "medium", "-c:a", "aac", out) | ||
| .redirectError(log.toFile()) | ||
| .start(); | ||
| } | ||
|
|
||
| static void stop(Process p, Stop how) throws Exception { | ||
| switch (how) { | ||
| case QUIT_KEY -> { // what pressing q in a terminal does | ||
| p.getOutputStream().write('q'); | ||
| p.getOutputStream().flush(); | ||
| } | ||
| case DESTROY -> p.destroy(); // SIGTERM on macOS and Linux | ||
| case DESTROY_FORCIBLY -> p.destroyForcibly(); // SIGKILL | ||
| } | ||
| } | ||
|
|
||
| // How many video frames can still be read back from what was left on disk. | ||
| static String check(String file) throws Exception { | ||
| Process p = new ProcessBuilder("ffprobe", "-v", "error", "-count_frames", | ||
| "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", | ||
| "-of", "csv=p=0", file) | ||
| .redirectErrorStream(true).start(); | ||
| String out = new String(p.getInputStream().readAllBytes()).strip(); | ||
| String first = out.lines().findFirst().orElse(""); | ||
| return p.waitFor() == 0 ? out.lines().reduce((a, b) -> b).orElse("") + " frames" | ||
| : "unreadable (" + first.replaceAll("^\\[[^]]*\\] ", "") + ")"; | ||
| } | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| String in = args[0]; | ||
| System.out.println("supportsNormalTermination: " | ||
| + new ProcessBuilder("true").start().supportsNormalTermination()); | ||
| for (String ext : List.of("mp4", "mkv")) { | ||
| for (Stop how : Stop.values()) { | ||
| String out = "out/cancel-" + how.name().toLowerCase() + "." + ext; | ||
| Path log = Path.of(out + ".log"); | ||
| Process p = start(in, out, log); | ||
| Thread.sleep(3000); | ||
| stop(p, how); | ||
| if (!p.waitFor(10, TimeUnit.SECONDS)) { | ||
| p.destroyForcibly(); | ||
| p.waitFor(); | ||
| } | ||
| String last = Files.readAllLines(log).stream() | ||
| .filter(l -> l.contains("signal") || l.contains("Exiting")) | ||
| .findFirst().orElse("-"); | ||
| System.out.printf("%-4s %-16s exit %3d %-36s %s%n", | ||
| ext, how, p.exitValue(), check(out), last.strip()); | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import java.util.concurrent.Executors; | ||
|
|
||
| // Counts platform threads while one virtual thread per child process waits on it. | ||
| public class Carriers { | ||
| public static void main(String[] args) throws Exception { | ||
| int jobs = Integer.parseInt(args[0]); | ||
| boolean read = args[1].equals("read"); | ||
| try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { | ||
| for (int i = 0; i < jobs; i++) { | ||
| pool.submit(() -> { | ||
| Process p = new ProcessBuilder("sleep", "3").start(); | ||
| if (read) p.getInputStream().readAllBytes(); | ||
| return p.waitFor(); | ||
| }); | ||
| } | ||
| Thread.sleep(1500); | ||
| var threads = Thread.getAllStackTraces().keySet(); | ||
| long carriers = threads.stream() | ||
| .filter(t -> t.getName().startsWith("ForkJoinPool-1-worker")).count(); | ||
| long reapers = threads.stream() | ||
| .filter(t -> t.getName().startsWith("process reaper")).count(); | ||
| System.out.printf("%d %d jobs (%s): %d carrier threads, %d process reaper threads, " | ||
| + "%d platform threads total%n", Runtime.version().feature(), jobs, args[1], | ||
| carriers, reapers, threads.size()); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| // JDK 26+: Process is Closeable. Leaving the try block closes the pipes and then | ||
| // waits for ffmpeg to exit; it does not stop the encode. | ||
| public class CloseTest { | ||
| public static void main(String[] args) throws Exception { | ||
| long start = System.nanoTime(); | ||
| Process p; | ||
| try (Process running = new ProcessBuilder("ffmpeg", "-hide_banner", "-y", "-i", args[0], | ||
| "-c:v", "libx264", "-preset", "medium", "-c:a", "aac", args[1]).start()) { | ||
| p = running; | ||
| Thread.sleep(1000); | ||
| System.out.println("leaving the try block, alive=" + running.isAlive()); | ||
| } | ||
| System.out.printf("close() returned after %.1f s, exit %d%n", | ||
| (System.nanoTime() - start) / 1e9, p.exitValue()); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import java.io.OutputStream; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| public class Deadlock { | ||
| public static void main(String[] args) throws Exception { | ||
| long start = System.nanoTime(); | ||
| Process p = new ProcessBuilder( | ||
| "ffmpeg", "-hide_banner", "-nostdin", "-i", args[0], | ||
| "-af", "ebur128", "-f", "null", "-") | ||
| .start(); | ||
|
|
||
| // The mistake: wait for the exit first, read the log afterwards. | ||
| if (p.waitFor(30, TimeUnit.SECONDS)) { | ||
| String log = new String(p.getErrorStream().readAllBytes()); | ||
| System.out.printf("exit %d after %.1f s, %d bytes of log%n", | ||
| p.exitValue(), seconds(start), log.length()); | ||
| return; | ||
| } | ||
| System.out.printf("still running after %.1f s, %d bytes sitting in the stderr pipe%n", | ||
| seconds(start), p.getErrorStream().available()); | ||
|
|
||
| // Proof that it was blocked rather than slow: read the pipe and it finishes. | ||
| long drained = p.getErrorStream().transferTo(OutputStream.nullOutputStream()); | ||
| System.out.printf("drained %d bytes, exit %d after %.1f s%n", | ||
| drained, p.waitFor(), seconds(start)); | ||
| } | ||
|
|
||
| static double seconds(long start) { | ||
| return (System.nanoTime() - start) / 1e9; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import java.util.List; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.Future; | ||
|
|
||
| public class Drained { | ||
| record Result(int exit, String stdout, String stderr) {} | ||
|
|
||
| static Result run(List<String> command) throws Exception { | ||
| Process p = new ProcessBuilder(command).start(); | ||
| try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { | ||
| Future<String> err = pool.submit(() -> new String(p.getErrorStream().readAllBytes())); | ||
| String out = new String(p.getInputStream().readAllBytes()); | ||
| return new Result(p.waitFor(), out, err.get()); | ||
| } | ||
| } | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| long start = System.nanoTime(); | ||
| Result r = run(List.of("ffmpeg", "-hide_banner", "-nostdin", "-i", args[0], | ||
| "-af", "ebur128", "-f", "null", "-")); | ||
| String summary = r.stderr().substring(r.stderr().lastIndexOf("Summary:")); | ||
| System.out.printf("exit %d after %.1f s, %d bytes of log%n%s", | ||
| r.exit(), (System.nanoTime() - start) / 1e9, r.stderr().length(), summary); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| ///usr/bin/env jbang "$0" "$@" ; exit $? | ||
| //JAVA 21+ | ||
| //DEPS tools.jackson.core:jackson-databind:3.2.3 | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.concurrent.Executors; | ||
| import java.util.concurrent.Future; | ||
| import tools.jackson.databind.DeserializationFeature; | ||
| import tools.jackson.databind.PropertyNamingStrategies; | ||
| import tools.jackson.databind.json.JsonMapper; | ||
|
|
||
| public class Loudnorm { | ||
| // loudnorm prints every number as a JSON string; keeping them as strings means | ||
| // they go back into the second pass exactly as ffmpeg wrote them. | ||
| record Stats(String inputI, String inputTp, String inputLra, String inputThresh, | ||
| String outputI, String outputTp, String normalizationType, String targetOffset) {} | ||
|
|
||
| static final String TARGET = "I=-14:TP=-1:LRA=11"; | ||
|
|
||
| // One ffmpeg run with a loudnorm filter; the stats are the last {...} in its log. | ||
| static Stats loudnorm(String in, String options, List<String> output) throws Exception { | ||
| var cmd = new ArrayList<>(List.of("ffmpeg", "-hide_banner", "-nostdin", "-y", "-i", in, | ||
| "-af", "loudnorm=" + options + ":print_format=json")); | ||
| cmd.addAll(output); | ||
| Result r = run(cmd); | ||
| if (r.exit() != 0) throw new IllegalStateException(r.stderr()); | ||
| String log = r.stderr(); | ||
| return JSON.readValue(log.substring(log.lastIndexOf('{'), log.lastIndexOf('}') + 1), | ||
| Stats.class); | ||
| } | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| String in = args[0], out = args[1]; | ||
| Stats first = loudnorm(in, TARGET, List.of("-vn", "-f", "null", "-")); | ||
| System.out.printf("pass 1: %s LUFS, true peak %s dBTP, LRA %s LU%n", | ||
| first.inputI(), first.inputTp(), first.inputLra()); | ||
|
|
||
| String measured = "%s:measured_I=%s:measured_TP=%s:measured_LRA=%s:measured_thresh=%s" | ||
| .formatted(TARGET, first.inputI(), first.inputTp(), first.inputLra(), | ||
| first.inputThresh()) | ||
| + ":offset=" + first.targetOffset() + ":linear=true"; | ||
| Stats second = loudnorm(in, measured, | ||
| List.of("-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", out)); | ||
| System.out.printf("pass 2: %s, output %s LUFS, true peak %s dBTP%n", | ||
| second.normalizationType(), second.outputI(), second.outputTp()); | ||
| } | ||
|
|
||
| static final JsonMapper JSON = JsonMapper.builder() | ||
| .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) | ||
| .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) | ||
| .build(); | ||
|
|
||
| record Result(int exit, String stdout, String stderr) {} | ||
|
|
||
| static Result run(List<String> command) throws Exception { | ||
| Process p = new ProcessBuilder(command).start(); | ||
| try (var pool = Executors.newVirtualThreadPerTaskExecutor()) { | ||
| Future<String> err = pool.submit(() -> new String(p.getErrorStream().readAllBytes())); | ||
| String out = new String(p.getInputStream().readAllBytes()); | ||
| return new Result(p.waitFor(), out, err.get()); | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| ///usr/bin/env jbang "$0" "$@" ; exit $? | ||
| //JAVA 21+ | ||
| //DEPS tools.jackson.core:jackson-databind:3.2.3 | ||
|
|
||
| import java.util.List; | ||
| import tools.jackson.databind.DeserializationFeature; | ||
| import tools.jackson.databind.PropertyNamingStrategies; | ||
| import tools.jackson.databind.json.JsonMapper; | ||
|
|
||
| public class Probe { | ||
| record MediaInfo(List<Stream> streams, Format format) {} | ||
|
|
||
| record Format(String formatName, double duration, long bitRate) {} | ||
|
|
||
| record Stream(int index, String codecType, String codecName, | ||
| Integer width, Integer height, String avgFrameRate, | ||
| Integer sampleRate, Integer channels) { | ||
| double fps() { | ||
| String[] f = avgFrameRate.split("/"); | ||
| return Double.parseDouble(f[0]) / Double.parseDouble(f[1]); | ||
| } | ||
| } | ||
|
|
||
| static final JsonMapper JSON = JsonMapper.builder() | ||
| .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) | ||
| .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) | ||
| .build(); | ||
|
|
||
| static MediaInfo probe(String file) throws Exception { | ||
| Process p = new ProcessBuilder("ffprobe", "-v", "error", "-print_format", "json", | ||
| "-show_format", "-show_streams", file) | ||
| .redirectError(ProcessBuilder.Redirect.INHERIT) | ||
| .start(); | ||
| MediaInfo info = JSON.readValue(p.getInputStream(), MediaInfo.class); | ||
| if (p.waitFor() != 0) throw new IllegalStateException("ffprobe failed: " + file); | ||
| return info; | ||
| } | ||
|
|
||
| public static void main(String[] args) throws Exception { | ||
| for (String file : args) { | ||
| MediaInfo info = probe(file); | ||
| System.out.printf("%s: %s, %.3f s, %d kb/s%n", file, info.format().formatName(), | ||
| info.format().duration(), info.format().bitRate() / 1000); | ||
| for (Stream s : info.streams()) { | ||
| if (s.codecType().equals("video")) { | ||
| System.out.printf(" #%d video %s %dx%d %.3f fps%n", | ||
| s.index(), s.codecName(), s.width(), s.height(), s.fps()); | ||
| } else if (s.codecType().equals("audio")) { | ||
| System.out.printf(" #%d audio %s %d Hz, %d ch%n", | ||
| s.index(), s.codecName(), s.sampleRate(), s.channels()); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.