diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 7bce010..5e7b0c3 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegClipInspector.java b/src/main/java/org/example/videoclips/editing/FfmpegClipInspector.java new file mode 100644 index 0000000..131a6cb --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/FfmpegClipInspector.java @@ -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 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 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 command) throws IOException, InterruptedException; + } +} diff --git a/src/test/java/org/example/videoclips/editing/FfmpegClipInspectorTest.java b/src/test/java/org/example/videoclips/editing/FfmpegClipInspectorTest.java new file mode 100644 index 0000000..ced9780 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/FfmpegClipInspectorTest.java @@ -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> 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); + } +}