Add cinematic contact sheet generation

This commit is contained in:
JSLMPR 2026-07-10 15:17:45 +02:00
parent b736df6cad
commit d7712bbc01
3 changed files with 164 additions and 1 deletions

View File

@ -788,7 +788,7 @@ It writes the script only and requires an imported audio file.
5. [x] Add clip discovery for project input directories.
6. [x] Add `ffprobe` analysis and validation for all input clips.
7. [x] Add thumbnail extraction.
8. [ ] Add contact sheet generation.
8. [x] Add contact sheet generation.
9. [ ] Add optional proxy generation.
10. [ ] Add `analysis.json` writing and reading.
11. [ ] Add storyboard prompt generation from `analysis.json`.

View File

@ -0,0 +1,75 @@
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;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class ContactSheetGenerator {
private final VideoClippingProperties.Editing properties;
private final ProcessExecutor processExecutor;
@Autowired
public ContactSheetGenerator(VideoClippingProperties properties) {
this(properties, ContactSheetGenerator::execute);
}
ContactSheetGenerator(VideoClippingProperties properties, ProcessExecutor processExecutor) {
this.properties = properties.getEditing();
this.processExecutor = processExecutor;
}
public String generate(ClipAnalysis clip, Path contactSheetDirectory) {
try {
Files.createDirectories(contactSheetDirectory);
} catch (IOException ex) {
throw new IllegalStateException("Unable to create contact sheet directory", ex);
}
Path output = contactSheetDirectory.resolve(clip.clipId() + ".jpg");
int columns = Math.max(1, properties.getContactSheetColumns());
List<String> command = List.of(
properties.getFfmpegBinary(),
"-hide_banner", "-y",
"-i", clip.sourcePath(),
"-vf", "fps=1/2,scale=320:-1,tile=%dx%d".formatted(columns, columns),
"-frames:v", "1",
output.toString()
);
ProcessResult result;
try {
result = processExecutor.execute(command);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while generating contact sheet", ex);
} catch (IOException ex) {
throw new IllegalStateException("Unable to run FFmpeg for contact sheet generation", ex);
}
if (result.exitCode() != 0) {
throw new IllegalStateException("FFmpeg contact sheet generation exited with code " + result.exitCode());
}
return 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;
}
}

View File

@ -0,0 +1,88 @@
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.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 ContactSheetGeneratorTest {
@TempDir
Path tempDir;
@Test
void generatesContactSheetWithConfiguredTileColumns() {
AtomicReference<List<String>> command = new AtomicReference<>();
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setContactSheetColumns(4);
ContactSheetGenerator generator = new ContactSheetGenerator(properties, arguments -> {
command.set(arguments);
return new ContactSheetGenerator.ProcessResult(0, "ok");
});
String contactSheet = generator.generate(clip(), tempDir.resolve("contact-sheets"));
assertThat(contactSheet).isEqualTo(tempDir.resolve("contact-sheets/clip_00001.jpg").toString());
assertCommandPair(command.get(), "-i", "./clips/clip_00001.mp4");
assertCommandPair(command.get(), "-vf", "fps=1/2,scale=320:-1,tile=4x4");
assertCommandPair(command.get(), "-frames:v", "1");
assertThat(command.get().getLast()).endsWith("clip_00001.jpg");
}
@Test
void rejectsFfmpegFailures() {
ContactSheetGenerator generator = new ContactSheetGenerator(new VideoClippingProperties(), command ->
new ContactSheetGenerator.ProcessResult(1, "failed"));
assertThrows(IllegalStateException.class, () -> generator.generate(clip(), tempDir.resolve("contact-sheets")));
}
@Test
void reportsProcessStartupAndInterruptionFailures() {
assertThrows(IllegalStateException.class, () -> new ContactSheetGenerator(new VideoClippingProperties(), command -> {
throw new IOException("missing executable");
}).generate(clip(), tempDir.resolve("io")));
try {
assertThrows(IllegalStateException.class, () -> new ContactSheetGenerator(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);
}
}