forked from jsl/video_editing_poc
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.
|
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`.
|
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.
|
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.
|
9. [ ] Add success handling that moves processed source files to processed storage.
|
||||||
10. [ ] Add failure handling for FFmpeg or filesystem errors.
|
10. [ ] Add failure handling for FFmpeg or filesystem errors.
|
||||||
11. [ ] Add unit tests for ordering, invalid-file handling, repeat prevention, and state transitions.
|
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
|
/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
|
```text
|
||||||
/output/clips/1
|
/output/clips/1
|
||||||
/output/clips/1-20260710T141500Z
|
/output/clips/1-1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Valid Video Detection
|
## 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 VideoClippingProperties.FolderScheduler properties;
|
||||||
private final FolderVideoValidator validator;
|
private final FolderVideoValidator validator;
|
||||||
|
private final FolderFfmpegClipper clipper;
|
||||||
private final AtomicBoolean scanning = new AtomicBoolean(false);
|
private final AtomicBoolean scanning = new AtomicBoolean(false);
|
||||||
|
|
||||||
public FolderVideoScanScheduler(
|
public FolderVideoScanScheduler(
|
||||||
VideoClippingProperties videoClippingProperties,
|
VideoClippingProperties videoClippingProperties,
|
||||||
FolderVideoValidator validator
|
FolderVideoValidator validator,
|
||||||
|
FolderFfmpegClipper clipper
|
||||||
) {
|
) {
|
||||||
this.properties = videoClippingProperties.getFolderScheduler();
|
this.properties = videoClippingProperties.getFolderScheduler();
|
||||||
this.validator = validator;
|
this.validator = validator;
|
||||||
|
this.clipper = clipper;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Scheduled(fixedDelayString = "${video-clipping.folder-scheduler.poll-interval-ms:5000}")
|
@Scheduled(fixedDelayString = "${video-clipping.folder-scheduler.poll-interval-ms:5000}")
|
||||||
|
|
@ -60,6 +63,8 @@ public class FolderVideoScanScheduler {
|
||||||
FolderVideoValidator.ValidationResult result = validator.validate(workingFile);
|
FolderVideoValidator.ValidationResult result = validator.validate(workingFile);
|
||||||
if (result.valid()) {
|
if (result.valid()) {
|
||||||
log.info("Validated folder video candidate: {}", workingFile.getFileName());
|
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;
|
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) {
|
private FolderVideoScanScheduler scheduler(Path inputDirectory) {
|
||||||
FolderVideoValidator validator = mock(FolderVideoValidator.class);
|
FolderVideoValidator validator = mock(FolderVideoValidator.class);
|
||||||
when(validator.validate(any())).thenReturn(FolderVideoValidator.ValidationResult.accepted());
|
when(validator.validate(any())).thenReturn(FolderVideoValidator.ValidationResult.accepted());
|
||||||
return scheduler(inputDirectory, validator);
|
return scheduler(inputDirectory, validator, successfulClipper());
|
||||||
}
|
}
|
||||||
|
|
||||||
private FolderVideoScanScheduler scheduler(Path inputDirectory, FolderVideoValidator validator) {
|
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();
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
properties.getFolderScheduler().setInputDirectory(inputDirectory.toString());
|
properties.getFolderScheduler().setInputDirectory(inputDirectory.toString());
|
||||||
Path workingDirectory = tempDir.resolve("working");
|
Path workingDirectory = tempDir.resolve("working");
|
||||||
|
|
@ -181,6 +189,12 @@ class FolderVideoScanSchedulerTest {
|
||||||
throw new IllegalStateException(ex);
|
throw new IllegalStateException(ex);
|
||||||
}
|
}
|
||||||
properties.getFolderScheduler().setRejectedDirectory(rejectedDirectory.toString());
|
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