forked from jsl/video_editing_poc
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.
|
3. [x] Add edit project domain records and status enum.
|
||||||
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
|
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
|
||||||
5. [x] Add clip discovery for project input directories.
|
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.
|
7. [ ] Add thumbnail extraction.
|
||||||
8. [ ] Add contact sheet generation.
|
8. [ ] Add contact sheet generation.
|
||||||
9. [ ] Add optional proxy 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