Add cinematic ffprobe clip inspection
This commit is contained in:
parent
28fbfadd6e
commit
67f0a0d423
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue