Generate exact FFmpeg folder clips
This commit is contained in:
parent
cf28898ecf
commit
aaacd7f592
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue