forked from jsl/video_editing_poc
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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