diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index c9f1934..6f9db98 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/editing/EditProjectAnalysis.java b/src/main/java/org/example/videoclips/editing/EditProjectAnalysis.java new file mode 100644 index 0000000..3ad20cc --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditProjectAnalysis.java @@ -0,0 +1,12 @@ +package org.example.videoclips.editing; + +import java.time.Instant; +import java.util.List; + +public record EditProjectAnalysis( + String projectId, + List clips, + List errors, + Instant createdAt +) { +} diff --git a/src/main/java/org/example/videoclips/editing/EditProjectAnalyzer.java b/src/main/java/org/example/videoclips/editing/EditProjectAnalyzer.java new file mode 100644 index 0000000..f67f009 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditProjectAnalyzer.java @@ -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 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 clips = new ArrayList<>(); + List errors = new ArrayList<>(); + for (Path candidate : candidates) { + try { + ClipAnalysis inspected = inspector.inspect(candidate); + List 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 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() + ); + } +} diff --git a/src/test/java/org/example/videoclips/editing/EditProjectAnalyzerTest.java b/src/test/java/org/example/videoclips/editing/EditProjectAnalyzerTest.java new file mode 100644 index 0000000..3e56243 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/EditProjectAnalyzerTest.java @@ -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 + ); + } +}