Add cinematic analysis writer

This commit is contained in:
JSLMPR 2026-07-10 15:30:37 +02:00
parent a5b8ed10a3
commit 0ccfd99147
4 changed files with 299 additions and 1 deletions

View File

@ -790,7 +790,7 @@ It writes the script only and requires an imported audio file.
7. [x] Add thumbnail extraction.
8. [x] Add contact sheet 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`.
12. [ ] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
13. [ ] Add local director source-folder scheduler.

View File

@ -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
) {
}

View File

@ -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()
);
}
}

View File

@ -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
);
}
}