Add cinematic ffprobe clip inspection

This commit is contained in:
JSLMPR 2026-07-10 15:06:59 +02:00
parent 28fbfadd6e
commit 67f0a0d423
3 changed files with 263 additions and 1 deletions

View File

@ -786,7 +786,7 @@ It writes the script only and requires an imported audio file.
3. [x] Add edit project domain records and status enum.
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
5. [x] Add clip discovery for project input directories.
6. [ ] Add `ffprobe` analysis and validation for all input clips.
6. [x] Add `ffprobe` analysis and validation for all input clips.
7. [ ] Add thumbnail extraction.
8. [ ] Add contact sheet generation.
9. [ ] Add optional proxy generation.

View File

@ -0,0 +1,135 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.config.VideoClippingProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class FfmpegClipInspector {
private final VideoClippingProperties.Editing properties;
private final ObjectMapper objectMapper;
private final ProcessExecutor processExecutor;
@Autowired
public FfmpegClipInspector(VideoClippingProperties properties, ObjectMapper objectMapper) {
this(properties, objectMapper, FfmpegClipInspector::execute);
}
FfmpegClipInspector(
VideoClippingProperties properties,
ObjectMapper objectMapper,
ProcessExecutor processExecutor
) {
this.properties = properties.getEditing();
this.objectMapper = objectMapper;
this.processExecutor = processExecutor;
}
public ClipAnalysis inspect(Path clip) {
List<String> command = List.of(
properties.getFfprobeBinary(),
"-v", "error",
"-show_entries", "format=duration",
"-show_entries", "stream=codec_type,codec_name,width,height,r_frame_rate",
"-of", "json",
clip.toString()
);
ProcessResult result;
try {
result = processExecutor.execute(command);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while inspecting edit clip", ex);
} catch (IOException ex) {
throw new IllegalStateException("Unable to run ffprobe for edit clip", ex);
}
if (result.exitCode() != 0) {
throw new IllegalStateException("ffprobe exited with code " + result.exitCode());
}
return analysis(clip, result.output());
}
private ClipAnalysis analysis(Path clip, String output) {
try {
JsonNode root = objectMapper.readTree(output);
JsonNode video = firstStream(root, "video");
if (video == null) {
throw new IllegalStateException("ffprobe did not report a video stream");
}
double duration = root.path("format").path("duration").asDouble(0);
int width = video.path("width").asInt(0);
int height = video.path("height").asInt(0);
if (duration <= 0 || width <= 0 || height <= 0) {
throw new IllegalStateException("ffprobe did not report valid clip duration and dimensions");
}
JsonNode audio = firstStream(root, "audio");
return new ClipAnalysis(
clipId(clip),
clip.toString(),
duration,
video.path("codec_name").asText("unknown"),
audio == null ? null : audio.path("codec_name").asText("unknown"),
width,
height,
frameRate(video.path("r_frame_rate").asText("0/1")),
List.of(),
null,
null,
0,
0,
0
);
} catch (IOException ex) {
throw new IllegalStateException("Unable to parse ffprobe output", ex);
}
}
private JsonNode firstStream(JsonNode root, String codecType) {
for (JsonNode stream : root.path("streams")) {
if (codecType.equals(stream.path("codec_type").asText())) {
return stream;
}
}
return null;
}
private double frameRate(String value) {
String[] parts = value.split("/");
if (parts.length != 2) {
return 0;
}
double numerator = Double.parseDouble(parts[0]);
double denominator = Double.parseDouble(parts[1]);
return denominator == 0 ? 0 : numerator / denominator;
}
private String clipId(Path clip) {
String fileName = clip.getFileName().toString();
int extensionSeparator = fileName.lastIndexOf('.');
return extensionSeparator > 0 ? fileName.substring(0, extensionSeparator) : fileName;
}
private static ProcessResult execute(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return new ProcessResult(process.waitFor(), output);
}
record ProcessResult(int exitCode, String output) {
}
@FunctionalInterface
interface ProcessExecutor {
ProcessResult execute(List<String> command) throws IOException, InterruptedException;
}
}

View File

@ -0,0 +1,127 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.config.VideoClippingProperties;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FfmpegClipInspectorTest {
@Test
void buildsFfprobeCommandAndParsesClipAnalysis() {
AtomicReference<List<String>> command = new AtomicReference<>();
FfmpegClipInspector inspector = inspector(arguments -> {
command.set(arguments);
return new FfmpegClipInspector.ProcessResult(0, """
{
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 1920,
"height": 1080,
"r_frame_rate": "30000/1001"
},
{
"codec_type": "audio",
"codec_name": "aac"
}
],
"format": {
"duration": "8.5"
}
}
""");
});
ClipAnalysis analysis = inspector.inspect(Path.of("clip_00001.mp4"));
assertThat(command.get()).contains("ffprobe", "-of", "json", "clip_00001.mp4");
assertThat(analysis.clipId()).isEqualTo("clip_00001");
assertThat(analysis.videoCodec()).isEqualTo("h264");
assertThat(analysis.audioCodec()).isEqualTo("aac");
assertThat(analysis.durationSeconds()).isEqualTo(8.5);
assertThat(analysis.width()).isEqualTo(1920);
assertThat(analysis.height()).isEqualTo(1080);
assertThat(analysis.frameRate()).isCloseTo(29.97, org.assertj.core.data.Offset.offset(0.01));
}
@Test
void acceptsClipsWithoutAudio() {
FfmpegClipInspector inspector = inspector(arguments -> new FfmpegClipInspector.ProcessResult(0, """
{
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 1280,
"height": 720,
"r_frame_rate": "30/1"
}
],
"format": {
"duration": "3.0"
}
}
"""));
assertThat(inspector.inspect(Path.of("silent.mp4")).audioCodec()).isNull();
}
@Test
void rejectsFfprobeFailuresAndInvalidMetadata() {
assertThrows(IllegalStateException.class, () -> inspector(arguments ->
new FfmpegClipInspector.ProcessResult(1, "bad")).inspect(Path.of("bad.mp4")));
assertThrows(IllegalStateException.class, () -> inspector(arguments ->
new FfmpegClipInspector.ProcessResult(0, """
{"streams": [], "format": {"duration": "1.0"}}
""")).inspect(Path.of("missing-video.mp4")));
assertThrows(IllegalStateException.class, () -> inspector(arguments ->
new FfmpegClipInspector.ProcessResult(0, """
{
"streams": [
{
"codec_type": "video",
"codec_name": "h264",
"width": 0,
"height": 720,
"r_frame_rate": "30/1"
}
],
"format": {
"duration": "0"
}
}
""")).inspect(Path.of("invalid-metadata.mp4")));
}
@Test
void reportsProcessStartupAndInterruptionFailures() {
assertThrows(IllegalStateException.class, () -> inspector(arguments -> {
throw new IOException("missing executable");
}).inspect(Path.of("io.mp4")));
try {
assertThrows(IllegalStateException.class, () -> inspector(arguments -> {
throw new InterruptedException("stopped");
}).inspect(Path.of("interrupted.mp4")));
assertTrue(Thread.currentThread().isInterrupted());
} finally {
Thread.interrupted();
}
}
private FfmpegClipInspector inspector(FfmpegClipInspector.ProcessExecutor executor) {
return new FfmpegClipInspector(new VideoClippingProperties(), new ObjectMapper(), executor);
}
}