From aaacd7f592d269e41c703e1de597ee2532db59e7 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 08:35:39 +0200 Subject: [PATCH] Generate exact FFmpeg folder clips --- docs/input-folder-scheduler-plan.md | 6 +- .../folder/FolderFfmpegClipper.java | 117 ++++++++++++++ .../folder/FolderVideoScanScheduler.java | 7 +- .../folder/FolderFfmpegClipperTest.java | 147 ++++++++++++++++++ .../folder/FolderVideoScanSchedulerTest.java | 18 ++- 5 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/example/videoclips/folder/FolderFfmpegClipper.java create mode 100644 src/test/java/org/example/videoclips/folder/FolderFfmpegClipperTest.java diff --git a/docs/input-folder-scheduler-plan.md b/docs/input-folder-scheduler-plan.md index 9558e3f..4e58564 100644 --- a/docs/input-folder-scheduler-plan.md +++ b/docs/input-folder-scheduler-plan.md @@ -25,7 +25,7 @@ Note: the local machine's filesystem root is read-only in this workspace, so abs 5. [x] Add repeat-prevention by moving selected inputs to a working folder before processing. 6. [x] Add valid-video detection using extension filtering and `ffprobe`. 7. [x] Add invalid-file handling that logs the reason and moves files to rejected storage. -8. [ ] Add FFmpeg clipping into `/output/clips//` using 8-second MP4/H.264/AAC segments. +8. [x] Add FFmpeg clipping into `/output/clips//` using 8-second MP4/H.264/AAC segments. 9. [ ] Add success handling that moves processed source files to processed storage. 10. [ ] Add failure handling for FFmpeg or filesystem errors. 11. [ ] Add unit tests for ordering, invalid-file handling, repeat prevention, and state transitions. @@ -101,11 +101,11 @@ Use the source filename stem for the output directory: /input/source/1.mp4 -> /output/clips/1/clip_00002.mp4 ``` -If two input files share the same stem, append a stable suffix: +If two input files share the same stem, append a deterministic numeric suffix: ```text /output/clips/1 -/output/clips/1-20260710T141500Z +/output/clips/1-1 ``` ## Valid Video Detection diff --git a/src/main/java/org/example/videoclips/folder/FolderFfmpegClipper.java b/src/main/java/org/example/videoclips/folder/FolderFfmpegClipper.java new file mode 100644 index 0000000..8369feb --- /dev/null +++ b/src/main/java/org/example/videoclips/folder/FolderFfmpegClipper.java @@ -0,0 +1,117 @@ +package org.example.videoclips.folder; + +import org.example.videoclips.config.VideoClippingProperties; +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.stream.Stream; + +@Component +public class FolderFfmpegClipper { + + private final VideoClippingProperties.FolderScheduler properties; + private final ProcessExecutor processExecutor; + + public FolderFfmpegClipper(VideoClippingProperties videoClippingProperties) { + this(videoClippingProperties, FolderFfmpegClipper::execute); + } + + FolderFfmpegClipper(VideoClippingProperties videoClippingProperties, ProcessExecutor processExecutor) { + this.properties = videoClippingProperties.getFolderScheduler(); + this.processExecutor = processExecutor; + } + + ClipResult clip(Path sourceFile) { + Path outputDirectory = createOutputDirectory(sourceFile); + String segmentDuration = Integer.toString(properties.getSegmentDurationSeconds()); + Path outputPattern = outputDirectory.resolve("clip_%05d." + properties.getOutputContainer()); + List command = List.of( + properties.getFfmpegBinary(), + "-hide_banner", "-y", + "-i", sourceFile.toString(), + "-map", "0:v:0", + "-map", "0:a?", + "-c:v", "libx264", + "-preset", properties.getExactPreset(), + "-crf", "20", + "-force_key_frames", "expr:gte(t,n_forced*" + segmentDuration + ")", + "-c:a", "aac", + "-b:a", "128k", + "-f", "segment", + "-segment_time", segmentDuration, + "-segment_format_options", "movflags=+faststart", + "-reset_timestamps", "1", + outputPattern.toString() + ); + + ProcessResult processResult; + try { + processResult = processExecutor.execute(command); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while clipping video file", ex); + } catch (IOException ex) { + throw new IllegalStateException("Unable to run FFmpeg", ex); + } + if (processResult.exitCode() != 0) { + throw new IllegalStateException("FFmpeg exited with code " + processResult.exitCode()); + } + + int clipCount = countClips(outputDirectory); + if (clipCount == 0) { + throw new IllegalStateException("FFmpeg completed without creating clips"); + } + return new ClipResult(outputDirectory, clipCount); + } + + private Path createOutputDirectory(Path sourceFile) { + String fileName = sourceFile.getFileName().toString(); + int extensionSeparator = fileName.lastIndexOf('.'); + String stem = extensionSeparator > 0 ? fileName.substring(0, extensionSeparator) : fileName; + Path outputRoot = Path.of(properties.getOutputDirectory()); + Path outputDirectory = outputRoot.resolve(stem); + int suffix = 1; + while (Files.exists(outputDirectory)) { + outputDirectory = outputRoot.resolve(stem + "-" + suffix++); + } + try { + return Files.createDirectory(outputDirectory); + } catch (IOException ex) { + throw new IllegalStateException("Unable to create clip output directory for " + fileName, ex); + } + } + + private int countClips(Path outputDirectory) { + String suffix = "." + properties.getOutputContainer(); + try (Stream files = Files.list(outputDirectory)) { + return Math.toIntExact(files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().startsWith("clip_")) + .filter(path -> path.getFileName().toString().endsWith(suffix)) + .count()); + } catch (IOException ex) { + throw new IllegalStateException("Unable to count generated clips", ex); + } + } + + 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 ClipResult(Path outputDirectory, int clipCount) { + } + + record ProcessResult(int exitCode, String output) { + } + + @FunctionalInterface + interface ProcessExecutor { + ProcessResult execute(List command) throws IOException, InterruptedException; + } +} diff --git a/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java index 001a3f7..d8278f5 100644 --- a/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java +++ b/src/main/java/org/example/videoclips/folder/FolderVideoScanScheduler.java @@ -25,14 +25,17 @@ public class FolderVideoScanScheduler { private final VideoClippingProperties.FolderScheduler properties; private final FolderVideoValidator validator; + private final FolderFfmpegClipper clipper; private final AtomicBoolean scanning = new AtomicBoolean(false); public FolderVideoScanScheduler( VideoClippingProperties videoClippingProperties, - FolderVideoValidator validator + FolderVideoValidator validator, + FolderFfmpegClipper clipper ) { this.properties = videoClippingProperties.getFolderScheduler(); this.validator = validator; + this.clipper = clipper; } @Scheduled(fixedDelayString = "${video-clipping.folder-scheduler.poll-interval-ms:5000}") @@ -60,6 +63,8 @@ public class FolderVideoScanScheduler { FolderVideoValidator.ValidationResult result = validator.validate(workingFile); if (result.valid()) { log.info("Validated folder video candidate: {}", workingFile.getFileName()); + FolderFfmpegClipper.ClipResult clipResult = clipper.clip(workingFile); + log.info("Generated {} clips in {}", clipResult.clipCount(), clipResult.outputDirectory().getFileName()); return; } diff --git a/src/test/java/org/example/videoclips/folder/FolderFfmpegClipperTest.java b/src/test/java/org/example/videoclips/folder/FolderFfmpegClipperTest.java new file mode 100644 index 0000000..3600f3d --- /dev/null +++ b/src/test/java/org/example/videoclips/folder/FolderFfmpegClipperTest.java @@ -0,0 +1,147 @@ +package org.example.videoclips.folder; + +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.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FolderFfmpegClipperTest { + + @TempDir + Path tempDir; + + @Test + void buildsExactEightSecondMp4CommandAndCountsClips() throws Exception { + AtomicReference> command = new AtomicReference<>(); + FolderFfmpegClipper clipper = clipper(arguments -> { + command.set(arguments); + Path outputDirectory = Path.of(arguments.getLast()).getParent(); + Files.writeString(outputDirectory.resolve("clip_00000.mp4"), "clip"); + Files.writeString(outputDirectory.resolve("clip_00001.mp4"), "clip"); + Files.writeString(outputDirectory.resolve("ignored.txt"), "ignored"); + Files.createDirectory(outputDirectory.resolve("clip_directory.mp4")); + return new FolderFfmpegClipper.ProcessResult(0, "ok"); + }); + + FolderFfmpegClipper.ClipResult result = clipper.clip(tempDir.resolve("source/movie.mp4")); + + assertEquals(tempDir.resolve("output/movie"), result.outputDirectory()); + assertEquals(2, result.clipCount()); + assertCommandPair(command.get(), "-c:v", "libx264"); + assertCommandPair(command.get(), "-c:a", "aac"); + assertCommandPair(command.get(), "-segment_time", "8"); + assertCommandPair(command.get(), "-force_key_frames", "expr:gte(t,n_forced*8)"); + assertTrue(command.get().getLast().endsWith("clip_%05d.mp4")); + } + + @Test + void addsNumericSuffixWhenOutputStemAlreadyExists() throws Exception { + Files.createDirectory(tempDir.resolve("output")); + Files.createDirectory(tempDir.resolve("output/movie")); + Files.createDirectory(tempDir.resolve("output/movie-1")); + FolderFfmpegClipper clipper = clipper(arguments -> { + Path outputDirectory = Path.of(arguments.getLast()).getParent(); + Files.writeString(outputDirectory.resolve("clip_00000.mp4"), "clip"); + return new FolderFfmpegClipper.ProcessResult(0, "ok"); + }); + + assertEquals(tempDir.resolve("output/movie-2"), clipper.clip(Path.of("movie.mp4")).outputDirectory()); + } + + @Test + void usesWholeFilenameAsStemWhenThereIsNoExtension() throws Exception { + FolderFfmpegClipper clipper = clipper(arguments -> { + Path outputDirectory = Path.of(arguments.getLast()).getParent(); + Files.writeString(outputDirectory.resolve("clip_00000.mp4"), "clip"); + return new FolderFfmpegClipper.ProcessResult(0, "ok"); + }); + + assertEquals(tempDir.resolve("output/movie"), clipper.clip(Path.of("movie")).outputDirectory()); + } + + @Test + void rejectsFfmpegFailureAndEmptyOutput() { + assertThrows(IllegalStateException.class, () -> clipper(arguments -> + new FolderFfmpegClipper.ProcessResult(7, "failed")).clip(Path.of("failed.mp4"))); + assertThrows(IllegalStateException.class, () -> clipper(arguments -> + new FolderFfmpegClipper.ProcessResult(0, "no output")).clip(Path.of("empty.mp4"))); + } + + @Test + void reportsGeneratedClipListingFailure() { + assertThrows(IllegalStateException.class, () -> clipper(arguments -> { + Files.delete(Path.of(arguments.getLast()).getParent()); + return new FolderFfmpegClipper.ProcessResult(0, "ok"); + }).clip(Path.of("deleted-output.mp4"))); + } + + @Test + void reportsProcessStartupAndInterruptionFailures() { + assertThrows(IllegalStateException.class, () -> clipper(arguments -> { + throw new IOException("missing executable"); + }).clip(Path.of("io.mp4"))); + + try { + assertThrows(IllegalStateException.class, () -> clipper(arguments -> { + throw new InterruptedException("stopped"); + }).clip(Path.of("interrupted.mp4"))); + assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + } + + @Test + void reportsOutputDirectoryCreationFailure() throws Exception { + Path outputFile = tempDir.resolve("not-a-directory"); + Files.writeString(outputFile, "file"); + VideoClippingProperties properties = properties(); + properties.getFolderScheduler().setOutputDirectory(outputFile.toString()); + + assertThrows(IllegalStateException.class, () -> new FolderFfmpegClipper(properties, arguments -> + new FolderFfmpegClipper.ProcessResult(0, "ok")).clip(Path.of("movie.mp4"))); + } + + @Test + void defaultExecutorRunsConfiguredFfmpegBinary() throws Exception { + Path expectedClip = tempDir.resolve("output/movie/clip_00000.mp4"); + Path ffmpeg = tempDir.resolve("fake-ffmpeg"); + Files.writeString(ffmpeg, "#!/bin/sh\ntouch '" + expectedClip + "'\n"); + assertTrue(ffmpeg.toFile().setExecutable(true)); + VideoClippingProperties properties = properties(); + properties.getFolderScheduler().setFfmpegBinary(ffmpeg.toString()); + + assertEquals(1, new FolderFfmpegClipper(properties).clip(Path.of("movie.mp4")).clipCount()); + } + + private FolderFfmpegClipper clipper(FolderFfmpegClipper.ProcessExecutor executor) { + return new FolderFfmpegClipper(properties(), executor); + } + + private VideoClippingProperties properties() { + VideoClippingProperties properties = new VideoClippingProperties(); + Path output = tempDir.resolve("output"); + try { + Files.createDirectories(output); + } catch (IOException ex) { + throw new IllegalStateException(ex); + } + properties.getFolderScheduler().setOutputDirectory(output.toString()); + return properties; + } + + private void assertCommandPair(List command, String option, String value) { + int optionIndex = command.indexOf(option); + assertTrue(optionIndex >= 0); + assertEquals(value, command.get(optionIndex + 1)); + } +} diff --git a/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java index d5ebe42..4255e87 100644 --- a/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java +++ b/src/test/java/org/example/videoclips/folder/FolderVideoScanSchedulerTest.java @@ -159,10 +159,18 @@ class FolderVideoScanSchedulerTest { private FolderVideoScanScheduler scheduler(Path inputDirectory) { FolderVideoValidator validator = mock(FolderVideoValidator.class); when(validator.validate(any())).thenReturn(FolderVideoValidator.ValidationResult.accepted()); - return scheduler(inputDirectory, validator); + return scheduler(inputDirectory, validator, successfulClipper()); } private FolderVideoScanScheduler scheduler(Path inputDirectory, FolderVideoValidator validator) { + return scheduler(inputDirectory, validator, successfulClipper()); + } + + private FolderVideoScanScheduler scheduler( + Path inputDirectory, + FolderVideoValidator validator, + FolderFfmpegClipper clipper + ) { VideoClippingProperties properties = new VideoClippingProperties(); properties.getFolderScheduler().setInputDirectory(inputDirectory.toString()); Path workingDirectory = tempDir.resolve("working"); @@ -181,6 +189,12 @@ class FolderVideoScanSchedulerTest { throw new IllegalStateException(ex); } properties.getFolderScheduler().setRejectedDirectory(rejectedDirectory.toString()); - return new FolderVideoScanScheduler(properties, validator); + return new FolderVideoScanScheduler(properties, validator, clipper); + } + + private FolderFfmpegClipper successfulClipper() { + FolderFfmpegClipper clipper = mock(FolderFfmpegClipper.class); + when(clipper.clip(any())).thenReturn(new FolderFfmpegClipper.ClipResult(tempDir.resolve("output/1"), 1)); + return clipper; } }