diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 5e7b0c3..c96f4e0 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -787,7 +787,7 @@ It writes the script only and requires an imported audio file. 4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`. 5. [x] Add clip discovery for project input directories. 6. [x] Add `ffprobe` analysis and validation for all input clips. -7. [ ] Add thumbnail extraction. +7. [x] Add thumbnail extraction. 8. [ ] Add contact sheet generation. 9. [ ] Add optional proxy generation. 10. [ ] Add `analysis.json` writing and reading. diff --git a/src/main/java/org/example/videoclips/editing/ThumbnailExtractor.java b/src/main/java/org/example/videoclips/editing/ThumbnailExtractor.java new file mode 100644 index 0000000..bdfa143 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/ThumbnailExtractor.java @@ -0,0 +1,87 @@ +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.ArrayList; +import java.util.List; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class ThumbnailExtractor { + + private final VideoClippingProperties.Editing properties; + private final ProcessExecutor processExecutor; + + @Autowired + public ThumbnailExtractor(VideoClippingProperties properties) { + this(properties, ThumbnailExtractor::execute); + } + + ThumbnailExtractor(VideoClippingProperties properties, ProcessExecutor processExecutor) { + this.properties = properties.getEditing(); + this.processExecutor = processExecutor; + } + + public List extract(ClipAnalysis clip, Path thumbnailDirectory) { + try { + Files.createDirectories(thumbnailDirectory); + } catch (IOException ex) { + throw new IllegalStateException("Unable to create thumbnail directory", ex); + } + + int count = Math.max(1, properties.getThumbnailCountPerClip()); + List thumbnails = new ArrayList<>(); + for (int index = 0; index < count; index++) { + double timestamp = ((index + 1) * clip.durationSeconds()) / (count + 1); + Path output = thumbnailDirectory.resolve("%s_%04d.jpg".formatted(clip.clipId(), index + 1)); + runThumbnailCommand(clip.sourcePath(), timestamp, output); + thumbnails.add(output.toString()); + } + return thumbnails; + } + + private void runThumbnailCommand(String sourcePath, double timestamp, Path output) { + List command = List.of( + properties.getFfmpegBinary(), + "-hide_banner", "-y", + "-ss", "%.3f".formatted(timestamp), + "-i", sourcePath, + "-frames:v", "1", + "-q:v", "2", + output.toString() + ); + ProcessResult result; + try { + result = processExecutor.execute(command); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while extracting thumbnail", ex); + } catch (IOException ex) { + throw new IllegalStateException("Unable to run FFmpeg for thumbnail extraction", ex); + } + if (result.exitCode() != 0) { + throw new IllegalStateException("FFmpeg thumbnail extraction exited with code " + result.exitCode()); + } + } + + 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/ThumbnailExtractorTest.java b/src/test/java/org/example/videoclips/editing/ThumbnailExtractorTest.java new file mode 100644 index 0000000..dec8843 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/ThumbnailExtractorTest.java @@ -0,0 +1,94 @@ +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.ArrayList; +import java.util.List; + +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 ThumbnailExtractorTest { + + @TempDir + Path tempDir; + + @Test + void extractsConfiguredThumbnailCountAtEvenTimestamps() { + List> commands = new ArrayList<>(); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setThumbnailCountPerClip(3); + ThumbnailExtractor extractor = new ThumbnailExtractor(properties, command -> { + commands.add(command); + return new ThumbnailExtractor.ProcessResult(0, "ok"); + }); + + List thumbnails = extractor.extract(clip(), tempDir.resolve("thumbnails")); + + assertThat(thumbnails).containsExactly( + tempDir.resolve("thumbnails/clip_00001_0001.jpg").toString(), + tempDir.resolve("thumbnails/clip_00001_0002.jpg").toString(), + tempDir.resolve("thumbnails/clip_00001_0003.jpg").toString() + ); + assertThat(commands).hasSize(3); + assertCommandPair(commands.get(0), "-ss", "2.000"); + assertCommandPair(commands.get(1), "-ss", "4.000"); + assertCommandPair(commands.get(2), "-ss", "6.000"); + assertCommandPair(commands.get(0), "-i", "./clips/clip_00001.mp4"); + assertThat(commands.get(0).getLast()).endsWith("clip_00001_0001.jpg"); + } + + @Test + void rejectsFfmpegFailures() { + ThumbnailExtractor extractor = new ThumbnailExtractor(new VideoClippingProperties(), command -> + new ThumbnailExtractor.ProcessResult(1, "failed")); + + assertThrows(IllegalStateException.class, () -> extractor.extract(clip(), tempDir.resolve("thumbnails"))); + } + + @Test + void reportsProcessStartupAndInterruptionFailures() { + assertThrows(IllegalStateException.class, () -> new ThumbnailExtractor(new VideoClippingProperties(), command -> { + throw new IOException("missing executable"); + }).extract(clip(), tempDir.resolve("io"))); + + try { + assertThrows(IllegalStateException.class, () -> new ThumbnailExtractor(new VideoClippingProperties(), command -> { + throw new InterruptedException("stopped"); + }).extract(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); + } +}