Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions content/authors/mark-santos/_index.md
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: []
---
Binary file added content/authors/mark-santos/mark-santos-full.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added content/authors/mark-santos/mark-santos.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions draft/driving-ffmpeg-from-java/Batch.java
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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));
}
}
}
62 changes: 62 additions & 0 deletions draft/driving-ffmpeg-from-java/Cancel.java
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());
}
}
}
}
27 changes: 27 additions & 0 deletions draft/driving-ffmpeg-from-java/Carriers.java
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());
}
}
}
16 changes: 16 additions & 0 deletions draft/driving-ffmpeg-from-java/CloseTest.java
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());
}
}
31 changes: 31 additions & 0 deletions draft/driving-ffmpeg-from-java/Deadlock.java
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;
}
}
25 changes: 25 additions & 0 deletions draft/driving-ffmpeg-from-java/Drained.java
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);
}
}
64 changes: 64 additions & 0 deletions draft/driving-ffmpeg-from-java/Loudnorm.java
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());
}
}
}
55 changes: 55 additions & 0 deletions draft/driving-ffmpeg-from-java/Probe.java
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());
}
}
}
}
}
Loading
Loading