forked from jsl/video_editing_poc
Add cinematic proxy generation
This commit is contained in:
parent
d7712bbc01
commit
a5b8ed10a3
|
|
@ -789,7 +789,7 @@ It writes the script only and requires an imported audio file.
|
|||
6. [x] Add `ffprobe` analysis and validation for all input clips.
|
||||
7. [x] Add thumbnail extraction.
|
||||
8. [x] Add contact sheet generation.
|
||||
9. [ ] Add optional proxy generation.
|
||||
9. [x] Add optional proxy generation.
|
||||
10. [ ] Add `analysis.json` writing and reading.
|
||||
11. [ ] Add storyboard prompt generation from `analysis.json`.
|
||||
12. [ ] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import org.example.videoclips.config.VideoClippingProperties;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
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.Optional;
|
||||
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||
public class ProxyGenerator {
|
||||
|
||||
private final VideoClippingProperties.Editing properties;
|
||||
private final ProcessExecutor processExecutor;
|
||||
|
||||
@Autowired
|
||||
public ProxyGenerator(VideoClippingProperties properties) {
|
||||
this(properties, ProxyGenerator::execute);
|
||||
}
|
||||
|
||||
ProxyGenerator(VideoClippingProperties properties, ProcessExecutor processExecutor) {
|
||||
this.properties = properties.getEditing();
|
||||
this.processExecutor = processExecutor;
|
||||
}
|
||||
|
||||
public Optional<String> generate(ClipAnalysis clip, Path proxyDirectory) {
|
||||
if (!properties.isProxyEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(proxyDirectory);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to create proxy directory", ex);
|
||||
}
|
||||
Path output = proxyDirectory.resolve(clip.clipId() + ".mp4");
|
||||
List<String> command = List.of(
|
||||
properties.getFfmpegBinary(),
|
||||
"-hide_banner", "-y",
|
||||
"-i", clip.sourcePath(),
|
||||
"-vf", "scale=%d:-2".formatted(properties.getProxyWidth()),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "28",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "96k",
|
||||
output.toString()
|
||||
);
|
||||
ProcessResult result;
|
||||
try {
|
||||
result = processExecutor.execute(command);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while generating proxy", ex);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to run FFmpeg for proxy generation", ex);
|
||||
}
|
||||
if (result.exitCode() != 0) {
|
||||
throw new IllegalStateException("FFmpeg proxy generation exited with code " + result.exitCode());
|
||||
}
|
||||
return Optional.of(output.toString());
|
||||
}
|
||||
|
||||
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 ProcessResult(int exitCode, String output) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface ProcessExecutor {
|
||||
ProcessResult execute(List<String> command) throws IOException, InterruptedException;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
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.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ProxyGeneratorTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void generatesProxyWhenEnabled() {
|
||||
AtomicReference<List<String>> command = new AtomicReference<>();
|
||||
VideoClippingProperties properties = new VideoClippingProperties();
|
||||
properties.getEditing().setProxyWidth(720);
|
||||
ProxyGenerator generator = new ProxyGenerator(properties, arguments -> {
|
||||
command.set(arguments);
|
||||
return new ProxyGenerator.ProcessResult(0, "ok");
|
||||
});
|
||||
|
||||
Optional<String> proxy = generator.generate(clip(), tempDir.resolve("proxies"));
|
||||
|
||||
assertThat(proxy).contains(tempDir.resolve("proxies/clip_00001.mp4").toString());
|
||||
assertCommandPair(command.get(), "-i", "./clips/clip_00001.mp4");
|
||||
assertCommandPair(command.get(), "-vf", "scale=720:-2");
|
||||
assertCommandPair(command.get(), "-c:v", "libx264");
|
||||
assertCommandPair(command.get(), "-b:a", "96k");
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsProxyGenerationWhenDisabled() {
|
||||
AtomicInteger executions = new AtomicInteger();
|
||||
VideoClippingProperties properties = new VideoClippingProperties();
|
||||
properties.getEditing().setProxyEnabled(false);
|
||||
ProxyGenerator generator = new ProxyGenerator(properties, arguments -> {
|
||||
executions.incrementAndGet();
|
||||
return new ProxyGenerator.ProcessResult(0, "ok");
|
||||
});
|
||||
|
||||
assertThat(generator.generate(clip(), tempDir.resolve("proxies"))).isEmpty();
|
||||
assertThat(executions).hasValue(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsFfmpegFailures() {
|
||||
ProxyGenerator generator = new ProxyGenerator(new VideoClippingProperties(), command ->
|
||||
new ProxyGenerator.ProcessResult(1, "failed"));
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> generator.generate(clip(), tempDir.resolve("proxies")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsProcessStartupAndInterruptionFailures() {
|
||||
assertThrows(IllegalStateException.class, () -> new ProxyGenerator(new VideoClippingProperties(), command -> {
|
||||
throw new IOException("missing executable");
|
||||
}).generate(clip(), tempDir.resolve("io")));
|
||||
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, () -> new ProxyGenerator(new VideoClippingProperties(), command -> {
|
||||
throw new InterruptedException("stopped");
|
||||
}).generate(clip(), tempDir.resolve("interrupted")));
|
||||
assertTrue(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
|
||||
private ClipAnalysis clip() {
|
||||
return new ClipAnalysis(
|
||||
"clip_00001",
|
||||
"./clips/clip_00001.mp4",
|
||||
8.0,
|
||||
"h264",
|
||||
"aac",
|
||||
1920,
|
||||
1080,
|
||||
30.0,
|
||||
List.of(),
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private void assertCommandPair(List<String> command, String option, String value) {
|
||||
int index = command.indexOf(option);
|
||||
assertThat(index).isGreaterThanOrEqualTo(0);
|
||||
assertThat(command.get(index + 1)).isEqualTo(value);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue