diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index f0637f8..c9f1934 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -789,7 +789,7 @@ It writes the script only and requires an imported audio file. 6. [x] Add `ffprobe` analysis and validation for all input clips. 7. [x] Add thumbnail extraction. 8. [x] Add contact sheet generation. -9. [ ] Add optional proxy generation. +9. [x] Add optional proxy generation. 10. [ ] Add `analysis.json` writing and reading. 11. [ ] Add storyboard prompt generation from `analysis.json`. 12. [ ] Add local AI director prompt generation with thumbnail/contact-sheet instructions. diff --git a/src/main/java/org/example/videoclips/editing/ProxyGenerator.java b/src/main/java/org/example/videoclips/editing/ProxyGenerator.java new file mode 100644 index 0000000..b71199c --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/ProxyGenerator.java @@ -0,0 +1,82 @@ +package org.example.videoclips.editing; + +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.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class ProxyGenerator { + + private final VideoClippingProperties.Editing properties; + private final ProcessExecutor processExecutor; + + @Autowired + public ProxyGenerator(VideoClippingProperties properties) { + this(properties, ProxyGenerator::execute); + } + + ProxyGenerator(VideoClippingProperties properties, ProcessExecutor processExecutor) { + this.properties = properties.getEditing(); + this.processExecutor = processExecutor; + } + + public Optional generate(ClipAnalysis clip, Path proxyDirectory) { + if (!properties.isProxyEnabled()) { + return Optional.empty(); + } + try { + Files.createDirectories(proxyDirectory); + } catch (IOException ex) { + throw new IllegalStateException("Unable to create proxy directory", ex); + } + Path output = proxyDirectory.resolve(clip.clipId() + ".mp4"); + List command = List.of( + properties.getFfmpegBinary(), + "-hide_banner", "-y", + "-i", clip.sourcePath(), + "-vf", "scale=%d:-2".formatted(properties.getProxyWidth()), + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "28", + "-c:a", "aac", + "-b:a", "96k", + output.toString() + ); + ProcessResult result; + try { + result = processExecutor.execute(command); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while generating proxy", ex); + } catch (IOException ex) { + throw new IllegalStateException("Unable to run FFmpeg for proxy generation", ex); + } + if (result.exitCode() != 0) { + throw new IllegalStateException("FFmpeg proxy generation exited with code " + result.exitCode()); + } + return Optional.of(output.toString()); + } + + 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/ProxyGeneratorTest.java b/src/test/java/org/example/videoclips/editing/ProxyGeneratorTest.java new file mode 100644 index 0000000..a360ccf --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/ProxyGeneratorTest.java @@ -0,0 +1,104 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.config.VideoClippingProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +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 ProxyGeneratorTest { + + @TempDir + Path tempDir; + + @Test + void generatesProxyWhenEnabled() { + AtomicReference> command = new AtomicReference<>(); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setProxyWidth(720); + ProxyGenerator generator = new ProxyGenerator(properties, arguments -> { + command.set(arguments); + return new ProxyGenerator.ProcessResult(0, "ok"); + }); + + Optional proxy = generator.generate(clip(), tempDir.resolve("proxies")); + + assertThat(proxy).contains(tempDir.resolve("proxies/clip_00001.mp4").toString()); + assertCommandPair(command.get(), "-i", "./clips/clip_00001.mp4"); + assertCommandPair(command.get(), "-vf", "scale=720:-2"); + assertCommandPair(command.get(), "-c:v", "libx264"); + assertCommandPair(command.get(), "-b:a", "96k"); + } + + @Test + void skipsProxyGenerationWhenDisabled() { + AtomicInteger executions = new AtomicInteger(); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setProxyEnabled(false); + ProxyGenerator generator = new ProxyGenerator(properties, arguments -> { + executions.incrementAndGet(); + return new ProxyGenerator.ProcessResult(0, "ok"); + }); + + assertThat(generator.generate(clip(), tempDir.resolve("proxies"))).isEmpty(); + assertThat(executions).hasValue(0); + } + + @Test + void rejectsFfmpegFailures() { + ProxyGenerator generator = new ProxyGenerator(new VideoClippingProperties(), command -> + new ProxyGenerator.ProcessResult(1, "failed")); + + assertThrows(IllegalStateException.class, () -> generator.generate(clip(), tempDir.resolve("proxies"))); + } + + @Test + void reportsProcessStartupAndInterruptionFailures() { + assertThrows(IllegalStateException.class, () -> new ProxyGenerator(new VideoClippingProperties(), command -> { + throw new IOException("missing executable"); + }).generate(clip(), tempDir.resolve("io"))); + + try { + assertThrows(IllegalStateException.class, () -> new ProxyGenerator(new VideoClippingProperties(), command -> { + throw new InterruptedException("stopped"); + }).generate(clip(), tempDir.resolve("interrupted"))); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + private ClipAnalysis clip() { + return new ClipAnalysis( + "clip_00001", + "./clips/clip_00001.mp4", + 8.0, + "h264", + "aac", + 1920, + 1080, + 30.0, + List.of(), + null, + null, + 0, + 0, + 0 + ); + } + + private void assertCommandPair(List command, String option, String value) { + int index = command.indexOf(option); + assertThat(index).isGreaterThanOrEqualTo(0); + assertThat(command.get(index + 1)).isEqualTo(value); + } +}