forked from jsl/video_editing_poc
Add ffprobe video validation
This commit is contained in:
parent
45092e23b0
commit
146c37b091
|
|
@ -23,7 +23,7 @@ Note: the local machine's filesystem root is read-only in this workspace, so abs
|
||||||
3. [x] Create startup directory initialization for input, output, working, processed, and rejected folders.
|
3. [x] Create startup directory initialization for input, output, working, processed, and rejected folders.
|
||||||
4. [x] Add deterministic scanner that picks one candidate video at a time.
|
4. [x] Add deterministic scanner that picks one candidate video at a time.
|
||||||
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. [ ] Add valid-video detection using extension filtering and `ffprobe`.
|
6. [x] Add valid-video detection using extension filtering and `ffprobe`.
|
||||||
7. [ ] Add invalid-file handling that logs the reason and moves files to rejected storage.
|
7. [ ] 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. [ ] 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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
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.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class FolderVideoValidator {
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_EXTENSIONS = Set.of("mp4", "mov", "m4v", "webm", "mkv");
|
||||||
|
|
||||||
|
private final VideoClippingProperties.FolderScheduler properties;
|
||||||
|
private final ProcessExecutor processExecutor;
|
||||||
|
|
||||||
|
public FolderVideoValidator(VideoClippingProperties videoClippingProperties) {
|
||||||
|
this(videoClippingProperties, FolderVideoValidator::execute);
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderVideoValidator(VideoClippingProperties videoClippingProperties, ProcessExecutor processExecutor) {
|
||||||
|
this.properties = videoClippingProperties.getFolderScheduler();
|
||||||
|
this.processExecutor = processExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationResult validate(Path videoFile) {
|
||||||
|
String fileName = videoFile.getFileName().toString();
|
||||||
|
int extensionSeparator = fileName.lastIndexOf('.');
|
||||||
|
String extension = extensionSeparator >= 0
|
||||||
|
? fileName.substring(extensionSeparator + 1).toLowerCase(Locale.ROOT)
|
||||||
|
: "";
|
||||||
|
if (!ALLOWED_EXTENSIONS.contains(extension)) {
|
||||||
|
return ValidationResult.invalid("unsupported file extension");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> command = List.of(
|
||||||
|
properties.getFfprobeBinary(),
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=width,height:format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1",
|
||||||
|
videoFile.toString()
|
||||||
|
);
|
||||||
|
ProcessResult result;
|
||||||
|
try {
|
||||||
|
result = processExecutor.execute(command);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("Interrupted while validating video file", ex);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to run ffprobe", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.exitCode() != 0) {
|
||||||
|
return ValidationResult.invalid("ffprobe exited with code " + result.exitCode());
|
||||||
|
}
|
||||||
|
if (!hasPositiveValue(result.output(), "width")
|
||||||
|
|| !hasPositiveValue(result.output(), "height")
|
||||||
|
|| !hasPositiveValue(result.output(), "duration")) {
|
||||||
|
return ValidationResult.invalid("ffprobe did not report valid video dimensions and duration");
|
||||||
|
}
|
||||||
|
return ValidationResult.accepted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasPositiveValue(String output, String key) {
|
||||||
|
Matcher matcher = Pattern.compile("(?m)^" + key + "=([0-9]+(?:\\.[0-9]+)?)$").matcher(output);
|
||||||
|
return matcher.find() && Double.parseDouble(matcher.group(1)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ValidationResult(boolean valid, String reason) {
|
||||||
|
static ValidationResult accepted() {
|
||||||
|
return new ValidationResult(true, "valid video");
|
||||||
|
}
|
||||||
|
|
||||||
|
static ValidationResult invalid(String reason) {
|
||||||
|
return new ValidationResult(false, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
record ProcessResult(int exitCode, String output) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface ProcessExecutor {
|
||||||
|
ProcessResult execute(List<String> command) throws IOException, InterruptedException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
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.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class FolderVideoValidatorTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsAllowedExtensionAndValidProbeMetadata() {
|
||||||
|
AtomicReference<List<String>> command = new AtomicReference<>();
|
||||||
|
FolderVideoValidator validator = validator(arguments -> {
|
||||||
|
command.set(arguments);
|
||||||
|
return new FolderVideoValidator.ProcessResult(0, "width=1920\nheight=1080\nduration=8.500000\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
FolderVideoValidator.ValidationResult result = validator.validate(Path.of("movie.MP4"));
|
||||||
|
|
||||||
|
assertTrue(result.valid());
|
||||||
|
assertEquals("valid video", result.reason());
|
||||||
|
assertEquals("ffprobe", command.get().getFirst());
|
||||||
|
assertEquals("movie.MP4", command.get().getLast());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnsupportedAndMissingExtensionsWithoutProbing() {
|
||||||
|
FolderVideoValidator validator = validator(arguments -> {
|
||||||
|
throw new AssertionError("ffprobe must not be called");
|
||||||
|
});
|
||||||
|
|
||||||
|
assertFalse(validator.validate(Path.of("notes.txt")).valid());
|
||||||
|
assertFalse(validator.validate(Path.of("video")).valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsEveryConfiguredVideoExtension() {
|
||||||
|
FolderVideoValidator validator = validator(arguments ->
|
||||||
|
new FolderVideoValidator.ProcessResult(0, "width=1\nheight=1\nduration=1\n"));
|
||||||
|
|
||||||
|
for (String extension : List.of("mp4", "mov", "m4v", "webm", "mkv")) {
|
||||||
|
assertTrue(validator.validate(Path.of("video." + extension)).valid());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsProbeFailure() {
|
||||||
|
FolderVideoValidator.ValidationResult result = validator(arguments ->
|
||||||
|
new FolderVideoValidator.ProcessResult(12, "invalid data")).validate(Path.of("bad.mp4"));
|
||||||
|
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertEquals("ffprobe exited with code 12", result.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingOrNonPositiveMetadata() {
|
||||||
|
assertInvalidMetadata("height=1\nduration=1\n");
|
||||||
|
assertInvalidMetadata("width=1\nduration=1\n");
|
||||||
|
assertInvalidMetadata("width=1\nheight=1\n");
|
||||||
|
assertInvalidMetadata("width=0\nheight=1\nduration=1\n");
|
||||||
|
assertInvalidMetadata("width=1\nheight=0\nduration=1\n");
|
||||||
|
assertInvalidMetadata("width=1\nheight=1\nduration=0\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsProcessStartupFailure() {
|
||||||
|
FolderVideoValidator validator = validator(arguments -> {
|
||||||
|
throw new IOException("missing executable");
|
||||||
|
});
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> validator.validate(Path.of("video.mp4")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoresInterruptFlagWhenProbeIsInterrupted() {
|
||||||
|
FolderVideoValidator validator = validator(arguments -> {
|
||||||
|
throw new InterruptedException("stopped");
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertThrows(IllegalStateException.class, () -> validator.validate(Path.of("video.mp4")));
|
||||||
|
assertTrue(Thread.currentThread().isInterrupted());
|
||||||
|
} finally {
|
||||||
|
Thread.interrupted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void defaultExecutorRunsConfiguredProbeBinary() throws Exception {
|
||||||
|
Path probe = tempDir.resolve("fake-ffprobe");
|
||||||
|
Files.writeString(probe, "#!/bin/sh\nprintf 'width=2\\nheight=2\\nduration=2\\n'\n");
|
||||||
|
assertTrue(probe.toFile().setExecutable(true));
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getFolderScheduler().setFfprobeBinary(probe.toString());
|
||||||
|
|
||||||
|
assertTrue(new FolderVideoValidator(properties).validate(Path.of("video.mp4")).valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertInvalidMetadata(String output) {
|
||||||
|
FolderVideoValidator.ValidationResult result = validator(arguments ->
|
||||||
|
new FolderVideoValidator.ProcessResult(0, output)).validate(Path.of("bad.mp4"));
|
||||||
|
assertFalse(result.valid());
|
||||||
|
assertEquals("ffprobe did not report valid video dimensions and duration", result.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private FolderVideoValidator validator(FolderVideoValidator.ProcessExecutor executor) {
|
||||||
|
return new FolderVideoValidator(new VideoClippingProperties(), executor);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue