Generate exact FFmpeg folder clips

This commit is contained in:
JSLMPR 2026-07-10 08:35:39 +02:00
parent cf28898ecf
commit aaacd7f592
5 changed files with 289 additions and 6 deletions

View File

@ -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/<video-name>/` using 8-second MP4/H.264/AAC segments.
8. [x] Add FFmpeg clipping into `/output/clips/<video-name>/` 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

View File

@ -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<String> 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<Path> 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<String> 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<String> command) throws IOException, InterruptedException;
}
}

View File

@ -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;
}

View File

@ -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<List<String>> 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<String> command, String option, String value) {
int optionIndex = command.indexOf(option);
assertTrue(optionIndex >= 0);
assertEquals(value, command.get(optionIndex + 1));
}
}

View File

@ -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;
}
}