Add cinematic analysis writer
This commit is contained in:
parent
a5b8ed10a3
commit
0ccfd99147
|
|
@ -790,7 +790,7 @@ It writes the script only and requires an imported audio file.
|
||||||
7. [x] Add thumbnail extraction.
|
7. [x] Add thumbnail extraction.
|
||||||
8. [x] Add contact sheet generation.
|
8. [x] Add contact sheet generation.
|
||||||
9. [x] Add optional proxy generation.
|
9. [x] Add optional proxy generation.
|
||||||
10. [ ] Add `analysis.json` writing and reading.
|
10. [x] Add `analysis.json` writing and reading.
|
||||||
11. [ ] Add storyboard prompt generation from `analysis.json`.
|
11. [ ] Add storyboard prompt generation from `analysis.json`.
|
||||||
12. [ ] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
|
12. [ ] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
|
||||||
13. [ ] Add local director source-folder scheduler.
|
13. [ ] Add local director source-folder scheduler.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record EditProjectAnalysis(
|
||||||
|
String projectId,
|
||||||
|
List<ClipAnalysis> clips,
|
||||||
|
List<String> errors,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
|
import org.example.videoclips.application.BadRequestException;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class EditProjectAnalyzer {
|
||||||
|
|
||||||
|
private final EditProjectStore store;
|
||||||
|
private final EditClipDiscovery discovery;
|
||||||
|
private final FfmpegClipInspector inspector;
|
||||||
|
private final ThumbnailExtractor thumbnailExtractor;
|
||||||
|
private final ContactSheetGenerator contactSheetGenerator;
|
||||||
|
private final ProxyGenerator proxyGenerator;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public EditProjectAnalyzer(
|
||||||
|
EditProjectStore store,
|
||||||
|
EditClipDiscovery discovery,
|
||||||
|
FfmpegClipInspector inspector,
|
||||||
|
ThumbnailExtractor thumbnailExtractor,
|
||||||
|
ContactSheetGenerator contactSheetGenerator,
|
||||||
|
ProxyGenerator proxyGenerator
|
||||||
|
) {
|
||||||
|
this(store, discovery, inspector, thumbnailExtractor, contactSheetGenerator, proxyGenerator, Clock.systemUTC());
|
||||||
|
}
|
||||||
|
|
||||||
|
EditProjectAnalyzer(
|
||||||
|
EditProjectStore store,
|
||||||
|
EditClipDiscovery discovery,
|
||||||
|
FfmpegClipInspector inspector,
|
||||||
|
ThumbnailExtractor thumbnailExtractor,
|
||||||
|
ContactSheetGenerator contactSheetGenerator,
|
||||||
|
ProxyGenerator proxyGenerator,
|
||||||
|
Clock clock
|
||||||
|
) {
|
||||||
|
this.store = store;
|
||||||
|
this.discovery = discovery;
|
||||||
|
this.inspector = inspector;
|
||||||
|
this.thumbnailExtractor = thumbnailExtractor;
|
||||||
|
this.contactSheetGenerator = contactSheetGenerator;
|
||||||
|
this.proxyGenerator = proxyGenerator;
|
||||||
|
this.clock = clock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EditProjectAnalysis analyze(String projectId, Path inputDirectory) {
|
||||||
|
List<Path> candidates = discovery.discover(inputDirectory);
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
throw new BadRequestException("Edit project input directory does not contain valid video clips");
|
||||||
|
}
|
||||||
|
|
||||||
|
Path projectDirectory = store.projectDirectory(projectId);
|
||||||
|
Path thumbnailDirectory = projectDirectory.resolve("thumbnails");
|
||||||
|
Path contactSheetDirectory = projectDirectory.resolve("contact-sheets");
|
||||||
|
Path proxyDirectory = projectDirectory.resolve("proxies");
|
||||||
|
|
||||||
|
List<ClipAnalysis> clips = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
for (Path candidate : candidates) {
|
||||||
|
try {
|
||||||
|
ClipAnalysis inspected = inspector.inspect(candidate);
|
||||||
|
List<String> thumbnails = thumbnailExtractor.extract(inspected, thumbnailDirectory);
|
||||||
|
String contactSheet = contactSheetGenerator.generate(inspected, contactSheetDirectory);
|
||||||
|
String proxy = proxyGenerator.generate(inspected, proxyDirectory).orElse(null);
|
||||||
|
clips.add(enriched(inspected, thumbnails, contactSheet, proxy));
|
||||||
|
} catch (RuntimeException ex) {
|
||||||
|
errors.add(candidate.getFileName() + ": " + ex.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clips.isEmpty()) {
|
||||||
|
throw new BadRequestException("Edit project analysis did not find any usable video clips");
|
||||||
|
}
|
||||||
|
|
||||||
|
EditProjectAnalysis analysis = new EditProjectAnalysis(projectId, List.copyOf(clips), List.copyOf(errors),
|
||||||
|
Instant.now(clock));
|
||||||
|
store.writeJson(projectId, "analysis.json", analysis);
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EditProjectAnalysis readAnalysis(String projectId) {
|
||||||
|
return store.readJson(projectId, "analysis.json", EditProjectAnalysis.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClipAnalysis enriched(
|
||||||
|
ClipAnalysis inspected,
|
||||||
|
List<String> thumbnails,
|
||||||
|
String contactSheet,
|
||||||
|
String proxy
|
||||||
|
) {
|
||||||
|
return new ClipAnalysis(
|
||||||
|
inspected.clipId(),
|
||||||
|
inspected.sourcePath(),
|
||||||
|
inspected.durationSeconds(),
|
||||||
|
inspected.videoCodec(),
|
||||||
|
inspected.audioCodec(),
|
||||||
|
inspected.width(),
|
||||||
|
inspected.height(),
|
||||||
|
inspected.frameRate(),
|
||||||
|
thumbnails,
|
||||||
|
contactSheet,
|
||||||
|
proxy,
|
||||||
|
inspected.motionScore(),
|
||||||
|
inspected.brightnessScore(),
|
||||||
|
inspected.sharpnessScore()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.example.videoclips.application.BadRequestException;
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class EditProjectAnalyzerTest {
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
Path tempDir;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void writesAndReadsAnalysisJsonWithGeneratedArtifacts() throws Exception {
|
||||||
|
Path input = Files.createDirectory(tempDir.resolve("input"));
|
||||||
|
Path clip = Files.writeString(input.resolve("clip_00001.mp4"), "video");
|
||||||
|
FileSystemEditProjectStore store = store();
|
||||||
|
store.createProject("project-1");
|
||||||
|
EditClipDiscovery discovery = mock(EditClipDiscovery.class);
|
||||||
|
FfmpegClipInspector inspector = mock(FfmpegClipInspector.class);
|
||||||
|
ThumbnailExtractor thumbnailExtractor = mock(ThumbnailExtractor.class);
|
||||||
|
ContactSheetGenerator contactSheetGenerator = mock(ContactSheetGenerator.class);
|
||||||
|
ProxyGenerator proxyGenerator = mock(ProxyGenerator.class);
|
||||||
|
ClipAnalysis inspected = clipAnalysis(clip);
|
||||||
|
when(discovery.discover(input)).thenReturn(List.of(clip));
|
||||||
|
when(inspector.inspect(clip)).thenReturn(inspected);
|
||||||
|
when(thumbnailExtractor.extract(inspected, tempDir.resolve("projects/project-1/thumbnails")))
|
||||||
|
.thenReturn(List.of("thumb-1.jpg"));
|
||||||
|
when(contactSheetGenerator.generate(inspected, tempDir.resolve("projects/project-1/contact-sheets")))
|
||||||
|
.thenReturn("contact-sheet.jpg");
|
||||||
|
when(proxyGenerator.generate(inspected, tempDir.resolve("projects/project-1/proxies")))
|
||||||
|
.thenReturn(Optional.of("proxy.mp4"));
|
||||||
|
EditProjectAnalyzer analyzer = analyzer(store, discovery, inspector, thumbnailExtractor,
|
||||||
|
contactSheetGenerator, proxyGenerator);
|
||||||
|
|
||||||
|
EditProjectAnalysis analysis = analyzer.analyze("project-1", input);
|
||||||
|
|
||||||
|
assertThat(analysis.projectId()).isEqualTo("project-1");
|
||||||
|
assertThat(analysis.createdAt()).isEqualTo(Instant.parse("2026-07-10T10:00:00Z"));
|
||||||
|
assertThat(analysis.errors()).isEmpty();
|
||||||
|
assertThat(analysis.clips()).hasSize(1);
|
||||||
|
assertThat(analysis.clips().getFirst().thumbnails()).containsExactly("thumb-1.jpg");
|
||||||
|
assertThat(analysis.clips().getFirst().contactSheet()).isEqualTo("contact-sheet.jpg");
|
||||||
|
assertThat(analysis.clips().getFirst().proxyPath()).isEqualTo("proxy.mp4");
|
||||||
|
assertThat(Files.exists(tempDir.resolve("projects/project-1/analysis.json"))).isTrue();
|
||||||
|
assertThat(analyzer.readAnalysis("project-1")).isEqualTo(analysis);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordsInvalidClipErrorsAndKeepsUsableClips() throws Exception {
|
||||||
|
Path input = Files.createDirectory(tempDir.resolve("input"));
|
||||||
|
Path validClip = Files.writeString(input.resolve("valid.mp4"), "video");
|
||||||
|
Path invalidClip = Files.writeString(input.resolve("invalid.mp4"), "video");
|
||||||
|
FileSystemEditProjectStore store = store();
|
||||||
|
store.createProject("project-1");
|
||||||
|
EditClipDiscovery discovery = mock(EditClipDiscovery.class);
|
||||||
|
FfmpegClipInspector inspector = mock(FfmpegClipInspector.class);
|
||||||
|
ThumbnailExtractor thumbnailExtractor = mock(ThumbnailExtractor.class);
|
||||||
|
ContactSheetGenerator contactSheetGenerator = mock(ContactSheetGenerator.class);
|
||||||
|
ProxyGenerator proxyGenerator = mock(ProxyGenerator.class);
|
||||||
|
ClipAnalysis inspected = clipAnalysis(validClip);
|
||||||
|
when(discovery.discover(input)).thenReturn(List.of(validClip, invalidClip));
|
||||||
|
when(inspector.inspect(validClip)).thenReturn(inspected);
|
||||||
|
when(inspector.inspect(invalidClip)).thenThrow(new IllegalStateException("bad clip"));
|
||||||
|
when(thumbnailExtractor.extract(inspected, tempDir.resolve("projects/project-1/thumbnails")))
|
||||||
|
.thenReturn(List.of("thumb.jpg"));
|
||||||
|
when(contactSheetGenerator.generate(inspected, tempDir.resolve("projects/project-1/contact-sheets")))
|
||||||
|
.thenReturn("contact.jpg");
|
||||||
|
when(proxyGenerator.generate(inspected, tempDir.resolve("projects/project-1/proxies")))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
EditProjectAnalyzer analyzer = analyzer(store, discovery, inspector, thumbnailExtractor,
|
||||||
|
contactSheetGenerator, proxyGenerator);
|
||||||
|
|
||||||
|
EditProjectAnalysis analysis = analyzer.analyze("project-1", input);
|
||||||
|
|
||||||
|
assertThat(analysis.clips()).hasSize(1);
|
||||||
|
assertThat(analysis.errors()).containsExactly("invalid.mp4: IllegalStateException");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsEmptyOrFullyInvalidProjects() throws Exception {
|
||||||
|
Path input = Files.createDirectory(tempDir.resolve("input"));
|
||||||
|
Path invalidClip = Files.writeString(input.resolve("invalid.mp4"), "video");
|
||||||
|
FileSystemEditProjectStore store = store();
|
||||||
|
store.createProject("project-1");
|
||||||
|
EditClipDiscovery discovery = mock(EditClipDiscovery.class);
|
||||||
|
FfmpegClipInspector inspector = mock(FfmpegClipInspector.class);
|
||||||
|
EditProjectAnalyzer emptyAnalyzer = analyzer(store, discovery, mock(ThumbnailExtractor.class),
|
||||||
|
mock(ContactSheetGenerator.class), mock(ProxyGenerator.class));
|
||||||
|
|
||||||
|
when(discovery.discover(input)).thenReturn(List.of());
|
||||||
|
assertThrows(BadRequestException.class, () -> emptyAnalyzer.analyze("project-1", input));
|
||||||
|
|
||||||
|
when(discovery.discover(input)).thenReturn(List.of(invalidClip));
|
||||||
|
when(inspector.inspect(invalidClip)).thenThrow(new IllegalStateException("bad clip"));
|
||||||
|
EditProjectAnalyzer invalidAnalyzer = analyzer(store, discovery, inspector, mock(ThumbnailExtractor.class),
|
||||||
|
mock(ContactSheetGenerator.class), mock(ProxyGenerator.class));
|
||||||
|
assertThrows(BadRequestException.class, () -> invalidAnalyzer.analyze("project-1", input));
|
||||||
|
}
|
||||||
|
|
||||||
|
private EditProjectAnalyzer analyzer(
|
||||||
|
FileSystemEditProjectStore store,
|
||||||
|
EditClipDiscovery discovery,
|
||||||
|
ThumbnailExtractor thumbnailExtractor,
|
||||||
|
ContactSheetGenerator contactSheetGenerator,
|
||||||
|
ProxyGenerator proxyGenerator
|
||||||
|
) {
|
||||||
|
return analyzer(store, discovery, mock(FfmpegClipInspector.class), thumbnailExtractor, contactSheetGenerator,
|
||||||
|
proxyGenerator);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EditProjectAnalyzer analyzer(
|
||||||
|
FileSystemEditProjectStore store,
|
||||||
|
EditClipDiscovery discovery,
|
||||||
|
FfmpegClipInspector inspector,
|
||||||
|
ThumbnailExtractor thumbnailExtractor,
|
||||||
|
ContactSheetGenerator contactSheetGenerator,
|
||||||
|
ProxyGenerator proxyGenerator
|
||||||
|
) {
|
||||||
|
return new EditProjectAnalyzer(
|
||||||
|
store,
|
||||||
|
discovery,
|
||||||
|
inspector,
|
||||||
|
thumbnailExtractor,
|
||||||
|
contactSheetGenerator,
|
||||||
|
proxyGenerator,
|
||||||
|
Clock.fixed(Instant.parse("2026-07-10T10:00:00Z"), ZoneOffset.UTC)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileSystemEditProjectStore store() {
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString());
|
||||||
|
return new FileSystemEditProjectStore(properties, new ObjectMapper().findAndRegisterModules());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClipAnalysis clipAnalysis(Path clip) {
|
||||||
|
return new ClipAnalysis(
|
||||||
|
clip.getFileName().toString().replace(".mp4", ""),
|
||||||
|
clip.toString(),
|
||||||
|
8.0,
|
||||||
|
"h264",
|
||||||
|
"aac",
|
||||||
|
1920,
|
||||||
|
1080,
|
||||||
|
30.0,
|
||||||
|
List.of(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue