forked from jsl/video_editing_poc
Add cinematic thumbnail extraction
This commit is contained in:
parent
67f0a0d423
commit
b736df6cad
|
|
@ -787,7 +787,7 @@ It writes the script only and requires an imported audio file.
|
||||||
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
|
4. [x] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
|
||||||
5. [x] Add clip discovery for project input directories.
|
5. [x] Add clip discovery for project input directories.
|
||||||
6. [x] Add `ffprobe` analysis and validation for all input clips.
|
6. [x] Add `ffprobe` analysis and validation for all input clips.
|
||||||
7. [ ] Add thumbnail extraction.
|
7. [x] Add thumbnail extraction.
|
||||||
8. [ ] Add contact sheet generation.
|
8. [ ] Add contact sheet generation.
|
||||||
9. [ ] Add optional proxy generation.
|
9. [ ] Add optional proxy generation.
|
||||||
10. [ ] Add `analysis.json` writing and reading.
|
10. [ ] Add `analysis.json` writing and reading.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
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.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class ThumbnailExtractor {
|
||||||
|
|
||||||
|
private final VideoClippingProperties.Editing properties;
|
||||||
|
private final ProcessExecutor processExecutor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ThumbnailExtractor(VideoClippingProperties properties) {
|
||||||
|
this(properties, ThumbnailExtractor::execute);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThumbnailExtractor(VideoClippingProperties properties, ProcessExecutor processExecutor) {
|
||||||
|
this.properties = properties.getEditing();
|
||||||
|
this.processExecutor = processExecutor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> extract(ClipAnalysis clip, Path thumbnailDirectory) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(thumbnailDirectory);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to create thumbnail directory", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = Math.max(1, properties.getThumbnailCountPerClip());
|
||||||
|
List<String> thumbnails = new ArrayList<>();
|
||||||
|
for (int index = 0; index < count; index++) {
|
||||||
|
double timestamp = ((index + 1) * clip.durationSeconds()) / (count + 1);
|
||||||
|
Path output = thumbnailDirectory.resolve("%s_%04d.jpg".formatted(clip.clipId(), index + 1));
|
||||||
|
runThumbnailCommand(clip.sourcePath(), timestamp, output);
|
||||||
|
thumbnails.add(output.toString());
|
||||||
|
}
|
||||||
|
return thumbnails;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void runThumbnailCommand(String sourcePath, double timestamp, Path output) {
|
||||||
|
List<String> command = List.of(
|
||||||
|
properties.getFfmpegBinary(),
|
||||||
|
"-hide_banner", "-y",
|
||||||
|
"-ss", "%.3f".formatted(timestamp),
|
||||||
|
"-i", sourcePath,
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-q:v", "2",
|
||||||
|
output.toString()
|
||||||
|
);
|
||||||
|
ProcessResult result;
|
||||||
|
try {
|
||||||
|
result = processExecutor.execute(command);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("Interrupted while extracting thumbnail", ex);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to run FFmpeg for thumbnail extraction", ex);
|
||||||
|
}
|
||||||
|
if (result.exitCode() != 0) {
|
||||||
|
throw new IllegalStateException("FFmpeg thumbnail extraction exited with code " + result.exitCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,94 @@
|
||||||
|
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.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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 ThumbnailExtractorTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractsConfiguredThumbnailCountAtEvenTimestamps() {
|
||||||
|
List<List<String>> commands = new ArrayList<>();
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getEditing().setThumbnailCountPerClip(3);
|
||||||
|
ThumbnailExtractor extractor = new ThumbnailExtractor(properties, command -> {
|
||||||
|
commands.add(command);
|
||||||
|
return new ThumbnailExtractor.ProcessResult(0, "ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
List<String> thumbnails = extractor.extract(clip(), tempDir.resolve("thumbnails"));
|
||||||
|
|
||||||
|
assertThat(thumbnails).containsExactly(
|
||||||
|
tempDir.resolve("thumbnails/clip_00001_0001.jpg").toString(),
|
||||||
|
tempDir.resolve("thumbnails/clip_00001_0002.jpg").toString(),
|
||||||
|
tempDir.resolve("thumbnails/clip_00001_0003.jpg").toString()
|
||||||
|
);
|
||||||
|
assertThat(commands).hasSize(3);
|
||||||
|
assertCommandPair(commands.get(0), "-ss", "2.000");
|
||||||
|
assertCommandPair(commands.get(1), "-ss", "4.000");
|
||||||
|
assertCommandPair(commands.get(2), "-ss", "6.000");
|
||||||
|
assertCommandPair(commands.get(0), "-i", "./clips/clip_00001.mp4");
|
||||||
|
assertThat(commands.get(0).getLast()).endsWith("clip_00001_0001.jpg");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsFfmpegFailures() {
|
||||||
|
ThumbnailExtractor extractor = new ThumbnailExtractor(new VideoClippingProperties(), command ->
|
||||||
|
new ThumbnailExtractor.ProcessResult(1, "failed"));
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, () -> extractor.extract(clip(), tempDir.resolve("thumbnails")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportsProcessStartupAndInterruptionFailures() {
|
||||||
|
assertThrows(IllegalStateException.class, () -> new ThumbnailExtractor(new VideoClippingProperties(), command -> {
|
||||||
|
throw new IOException("missing executable");
|
||||||
|
}).extract(clip(), tempDir.resolve("io")));
|
||||||
|
|
||||||
|
try {
|
||||||
|
assertThrows(IllegalStateException.class, () -> new ThumbnailExtractor(new VideoClippingProperties(), command -> {
|
||||||
|
throw new InterruptedException("stopped");
|
||||||
|
}).extract(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