diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index c96f4e0..f0637f8 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -788,7 +788,7 @@ It writes the script only and requires an imported audio file. 5. [x] Add clip discovery for project input directories. 6. [x] Add `ffprobe` analysis and validation for all input clips. 7. [x] Add thumbnail extraction. -8. [ ] Add contact sheet generation. +8. [x] Add contact sheet generation. 9. [ ] Add optional proxy generation. 10. [ ] Add `analysis.json` writing and reading. 11. [ ] Add storyboard prompt generation from `analysis.json`. diff --git a/src/main/java/org/example/videoclips/editing/ContactSheetGenerator.java b/src/main/java/org/example/videoclips/editing/ContactSheetGenerator.java new file mode 100644 index 0000000..fbe4b5f --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/ContactSheetGenerator.java @@ -0,0 +1,75 @@ +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; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class ContactSheetGenerator { + + private final VideoClippingProperties.Editing properties; + private final ProcessExecutor processExecutor; + + @Autowired + public ContactSheetGenerator(VideoClippingProperties properties) { + this(properties, ContactSheetGenerator::execute); + } + + ContactSheetGenerator(VideoClippingProperties properties, ProcessExecutor processExecutor) { + this.properties = properties.getEditing(); + this.processExecutor = processExecutor; + } + + public String generate(ClipAnalysis clip, Path contactSheetDirectory) { + try { + Files.createDirectories(contactSheetDirectory); + } catch (IOException ex) { + throw new IllegalStateException("Unable to create contact sheet directory", ex); + } + Path output = contactSheetDirectory.resolve(clip.clipId() + ".jpg"); + int columns = Math.max(1, properties.getContactSheetColumns()); + List command = List.of( + properties.getFfmpegBinary(), + "-hide_banner", "-y", + "-i", clip.sourcePath(), + "-vf", "fps=1/2,scale=320:-1,tile=%dx%d".formatted(columns, columns), + "-frames:v", "1", + output.toString() + ); + ProcessResult result; + try { + result = processExecutor.execute(command); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while generating contact sheet", ex); + } catch (IOException ex) { + throw new IllegalStateException("Unable to run FFmpeg for contact sheet generation", ex); + } + if (result.exitCode() != 0) { + throw new IllegalStateException("FFmpeg contact sheet generation exited with code " + result.exitCode()); + } + return 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/ContactSheetGeneratorTest.java b/src/test/java/org/example/videoclips/editing/ContactSheetGeneratorTest.java new file mode 100644 index 0000000..1b66653 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/ContactSheetGeneratorTest.java @@ -0,0 +1,88 @@ +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.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 ContactSheetGeneratorTest { + + @TempDir + Path tempDir; + + @Test + void generatesContactSheetWithConfiguredTileColumns() { + AtomicReference> command = new AtomicReference<>(); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setContactSheetColumns(4); + ContactSheetGenerator generator = new ContactSheetGenerator(properties, arguments -> { + command.set(arguments); + return new ContactSheetGenerator.ProcessResult(0, "ok"); + }); + + String contactSheet = generator.generate(clip(), tempDir.resolve("contact-sheets")); + + assertThat(contactSheet).isEqualTo(tempDir.resolve("contact-sheets/clip_00001.jpg").toString()); + assertCommandPair(command.get(), "-i", "./clips/clip_00001.mp4"); + assertCommandPair(command.get(), "-vf", "fps=1/2,scale=320:-1,tile=4x4"); + assertCommandPair(command.get(), "-frames:v", "1"); + assertThat(command.get().getLast()).endsWith("clip_00001.jpg"); + } + + @Test + void rejectsFfmpegFailures() { + ContactSheetGenerator generator = new ContactSheetGenerator(new VideoClippingProperties(), command -> + new ContactSheetGenerator.ProcessResult(1, "failed")); + + assertThrows(IllegalStateException.class, () -> generator.generate(clip(), tempDir.resolve("contact-sheets"))); + } + + @Test + void reportsProcessStartupAndInterruptionFailures() { + assertThrows(IllegalStateException.class, () -> new ContactSheetGenerator(new VideoClippingProperties(), command -> { + throw new IOException("missing executable"); + }).generate(clip(), tempDir.resolve("io"))); + + try { + assertThrows(IllegalStateException.class, () -> new ContactSheetGenerator(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); + } +}