From 5ba108f740b2a8771b45b8e20279fa47814e92f5 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Sat, 11 Jul 2026 09:42:55 +0200 Subject: [PATCH] Add highlight project folder contract --- ...uction-cinematic-highlight-editing-plan.md | 3 +- .../config/VideoClippingProperties.java | 10 ++ .../FileSystemHighlightProjectStore.java | 140 ++++++++++++++++++ .../editing/HighlightFolderContract.java | 42 ++++++ .../videoclips/editing/HighlightProject.java | 15 ++ .../editing/HighlightProjectManifest.java | 11 ++ .../editing/HighlightProjectStatus.java | 11 ++ .../editing/HighlightProjectStore.java | 28 ++++ src/main/resources/application.yml | 1 + .../config/VideoClippingPropertiesTest.java | 3 + .../FileSystemHighlightProjectStoreTest.java | 97 ++++++++++++ 11 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/example/videoclips/editing/FileSystemHighlightProjectStore.java create mode 100644 src/main/java/org/example/videoclips/editing/HighlightFolderContract.java create mode 100644 src/main/java/org/example/videoclips/editing/HighlightProject.java create mode 100644 src/main/java/org/example/videoclips/editing/HighlightProjectManifest.java create mode 100644 src/main/java/org/example/videoclips/editing/HighlightProjectStatus.java create mode 100644 src/main/java/org/example/videoclips/editing/HighlightProjectStore.java create mode 100644 src/test/java/org/example/videoclips/editing/FileSystemHighlightProjectStoreTest.java diff --git a/docs/production-cinematic-highlight-editing-plan.md b/docs/production-cinematic-highlight-editing-plan.md index 9ca9637..09cda29 100644 --- a/docs/production-cinematic-highlight-editing-plan.md +++ b/docs/production-cinematic-highlight-editing-plan.md @@ -295,7 +295,8 @@ The plan should be strict JSON so a cheaper model or deterministic renderer can ## Milestones -- [ ] Milestone 1: Define the cinematic highlight project domain model, folder contract, and persisted manifests. +- [x] Milestone 1: Define the cinematic highlight project domain model, folder contract, and persisted manifests. +- [x] Milestone 1 progress: Added a dedicated highlight project domain, standard folder contract, manifest model, configurable `output/highlight-projects` root, filesystem store, and tests for directory creation, JSON round trips, and path safety. - [ ] Milestone 2: Add a local scheduler that accepts exactly one source video at a time from the highlight source folder. - [ ] Milestone 3: Implement source probing, proxy generation, frame extraction, waveform extraction, and contact sheet creation. - [ ] Milestone 4: Implement shot and scene segmentation using FFmpeg scene detection or PySceneDetect. diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index 288ce81..849aa51 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -486,6 +486,8 @@ public class VideoClippingProperties { private String projectDirectory = "./output/edit-projects"; + private String highlightProjectDirectory = "./output/highlight-projects"; + private String ffmpegBinary = "ffmpeg"; private String ffprobeBinary = "ffprobe"; @@ -558,6 +560,14 @@ public class VideoClippingProperties { this.projectDirectory = projectDirectory; } + public String getHighlightProjectDirectory() { + return highlightProjectDirectory; + } + + public void setHighlightProjectDirectory(String highlightProjectDirectory) { + this.highlightProjectDirectory = highlightProjectDirectory; + } + public String getFfmpegBinary() { return ffmpegBinary; } diff --git a/src/main/java/org/example/videoclips/editing/FileSystemHighlightProjectStore.java b/src/main/java/org/example/videoclips/editing/FileSystemHighlightProjectStore.java new file mode 100644 index 0000000..6ae5757 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/FileSystemHighlightProjectStore.java @@ -0,0 +1,140 @@ +package org.example.videoclips.editing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.example.videoclips.config.VideoClippingProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class FileSystemHighlightProjectStore implements HighlightProjectStore { + + private static final Pattern PROJECT_ID_PATTERN = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}"); + + private final Path projectRoot; + private final ObjectMapper objectMapper; + + public FileSystemHighlightProjectStore(VideoClippingProperties properties, ObjectMapper objectMapper) { + this.projectRoot = Path.of(properties.getEditing().getHighlightProjectDirectory()).normalize(); + this.objectMapper = objectMapper; + } + + @Override + public Path createProject(HighlightProject project, HighlightProjectManifest manifest) { + if (!project.id().equals(manifest.projectId())) { + throw new IllegalArgumentException("Highlight project id does not match manifest project id"); + } + Path directory = projectDirectory(project.id()); + HighlightFolderContract contract = manifest.folderContract(); + try { + Files.createDirectories(directory); + Files.createDirectories(directory.resolve(contract.sourceDirectory())); + Files.createDirectories(directory.resolve(contract.analysisDirectory())); + Files.createDirectories(directory.resolve(contract.directorDirectory())); + Files.createDirectories(directory.resolve(contract.assetsDirectory()).resolve("voiceover")); + Files.createDirectories(directory.resolve(contract.assetsDirectory()).resolve("music")); + Files.createDirectories(directory.resolve(contract.assetsDirectory()).resolve("sfx")); + Files.createDirectories(directory.resolve(contract.assetsDirectory()).resolve("overlays")); + Files.createDirectories(directory.resolve(contract.highlightsDirectory())); + writeJson(project.id(), contract.projectFile(), project); + writeJson(project.id(), "manifest.json", manifest); + return directory; + } catch (IOException ex) { + throw new IllegalStateException("Unable to create highlight project directory: " + project.id(), ex); + } + } + + @Override + public Path projectDirectory(String projectId) { + String safeProjectId = validateProjectId(projectId); + Path directory = projectRoot.resolve(safeProjectId).normalize(); + if (!directory.startsWith(projectRoot)) { + throw new IllegalArgumentException("Invalid highlight project id: " + projectId); + } + return directory; + } + + @Override + public Path sourceDirectory(String projectId) { + return projectDirectory(projectId).resolve("source"); + } + + @Override + public Path analysisDirectory(String projectId) { + return projectDirectory(projectId).resolve("analysis"); + } + + @Override + public Path directorDirectory(String projectId) { + return projectDirectory(projectId).resolve("director"); + } + + @Override + public Path assetsDirectory(String projectId) { + return projectDirectory(projectId).resolve("assets"); + } + + @Override + public Path highlightsDirectory(String projectId) { + return projectDirectory(projectId).resolve("highlights"); + } + + @Override + public Path projectFile(String projectId) { + return projectDirectory(projectId).resolve("project.json"); + } + + @Override + public Path manifestFile(String projectId) { + return projectDirectory(projectId).resolve("manifest.json"); + } + + @Override + public void writeJson(String projectId, String relativeFileName, T value) { + Path projectDirectory = projectDirectory(projectId); + Path file = projectDirectory.resolve(validateRelativeFileName(relativeFileName)).normalize(); + if (!file.startsWith(projectDirectory)) { + throw new IllegalArgumentException("Invalid highlight project file name: " + relativeFileName); + } + try { + Files.createDirectories(file.getParent()); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), value); + } catch (IOException ex) { + throw new IllegalStateException("Unable to write highlight project JSON file: " + relativeFileName, ex); + } + } + + @Override + public T readJson(String projectId, String relativeFileName, Class type) { + Path projectDirectory = projectDirectory(projectId); + Path file = projectDirectory.resolve(validateRelativeFileName(relativeFileName)).normalize(); + if (!file.startsWith(projectDirectory)) { + throw new IllegalArgumentException("Invalid highlight project file name: " + relativeFileName); + } + try { + return objectMapper.readValue(file.toFile(), type); + } catch (IOException ex) { + throw new IllegalStateException("Unable to read highlight project JSON file: " + relativeFileName, ex); + } + } + + private String validateProjectId(String projectId) { + if (projectId == null || !PROJECT_ID_PATTERN.matcher(projectId).matches() || projectId.contains("..")) { + throw new IllegalArgumentException("Invalid highlight project id: " + projectId); + } + return projectId; + } + + private String validateRelativeFileName(String relativeFileName) { + if (relativeFileName == null || relativeFileName.isBlank() || relativeFileName.contains("\\") + || relativeFileName.contains("..") || Path.of(relativeFileName).isAbsolute()) { + throw new IllegalArgumentException("Invalid highlight project file name: " + relativeFileName); + } + return relativeFileName; + } +} diff --git a/src/main/java/org/example/videoclips/editing/HighlightFolderContract.java b/src/main/java/org/example/videoclips/editing/HighlightFolderContract.java new file mode 100644 index 0000000..e992fe1 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightFolderContract.java @@ -0,0 +1,42 @@ +package org.example.videoclips.editing; + +import java.util.List; + +public record HighlightFolderContract( + String projectFile, + String sourceDirectory, + String analysisDirectory, + String directorDirectory, + String assetsDirectory, + String highlightsDirectory, + List analysisFiles, + List directorFiles +) { + + public static HighlightFolderContract standard() { + return new HighlightFolderContract( + "project.json", + "source", + "analysis", + "director", + "assets", + "highlights", + List.of( + "ffprobe.json", + "shots.json", + "scenes.json", + "transcript.json", + "audio-events.json", + "visual-analysis.json", + "category.json", + "highlight-candidates.json" + ), + List.of( + "director-brief.md", + "director-prompt.md", + "edit-plan.json", + "storyboard.md" + ) + ); + } +} diff --git a/src/main/java/org/example/videoclips/editing/HighlightProject.java b/src/main/java/org/example/videoclips/editing/HighlightProject.java new file mode 100644 index 0000000..ca845c8 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightProject.java @@ -0,0 +1,15 @@ +package org.example.videoclips.editing; + +import java.time.Instant; + +public record HighlightProject( + String id, + String name, + HighlightProjectStatus status, + String sourceVideoFileName, + String projectDirectory, + Instant createdAt, + Instant updatedAt, + String failureMessage +) { +} diff --git a/src/main/java/org/example/videoclips/editing/HighlightProjectManifest.java b/src/main/java/org/example/videoclips/editing/HighlightProjectManifest.java new file mode 100644 index 0000000..3667b19 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightProjectManifest.java @@ -0,0 +1,11 @@ +package org.example.videoclips.editing; + +import java.time.Instant; + +public record HighlightProjectManifest( + String projectId, + String sourceVideoFileName, + HighlightFolderContract folderContract, + Instant createdAt +) { +} diff --git a/src/main/java/org/example/videoclips/editing/HighlightProjectStatus.java b/src/main/java/org/example/videoclips/editing/HighlightProjectStatus.java new file mode 100644 index 0000000..15f03d5 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightProjectStatus.java @@ -0,0 +1,11 @@ +package org.example.videoclips.editing; + +public enum HighlightProjectStatus { + CREATED, + ANALYZING, + WAITING_FOR_DIRECTOR, + PLANNED, + RENDERING, + RENDERED, + FAILED +} diff --git a/src/main/java/org/example/videoclips/editing/HighlightProjectStore.java b/src/main/java/org/example/videoclips/editing/HighlightProjectStore.java new file mode 100644 index 0000000..54c085f --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightProjectStore.java @@ -0,0 +1,28 @@ +package org.example.videoclips.editing; + +import java.nio.file.Path; + +public interface HighlightProjectStore { + + Path createProject(HighlightProject project, HighlightProjectManifest manifest); + + Path projectDirectory(String projectId); + + Path sourceDirectory(String projectId); + + Path analysisDirectory(String projectId); + + Path directorDirectory(String projectId); + + Path assetsDirectory(String projectId); + + Path highlightsDirectory(String projectId); + + Path projectFile(String projectId); + + Path manifestFile(String projectId); + + void writeJson(String projectId, String relativeFileName, T value); + + T readJson(String projectId, String relativeFileName, Class type); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6434688..fc47897 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,6 +16,7 @@ video-clipping: editing: enabled: ${VIDEO_EDITING_ENABLED:true} project-directory: ${VIDEO_EDITING_PROJECT_DIRECTORY:./output/edit-projects} + highlight-project-directory: ${VIDEO_EDITING_HIGHLIGHT_PROJECT_DIRECTORY:./output/highlight-projects} ffmpeg-binary: ${VIDEO_EDITING_FFMPEG_BINARY:ffmpeg} ffprobe-binary: ${VIDEO_EDITING_FFPROBE_BINARY:ffprobe} thumbnail-count-per-clip: ${VIDEO_EDITING_THUMBNAIL_COUNT_PER_CLIP:5} diff --git a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java index 3c1c91e..f783e4d 100644 --- a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java +++ b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java @@ -22,6 +22,7 @@ class VideoClippingPropertiesTest { assertThat(properties.getEditing().isEnabled()).isTrue(); assertThat(properties.getEditing().getProjectDirectory()).isEqualTo("./output/edit-projects"); + assertThat(properties.getEditing().getHighlightProjectDirectory()).isEqualTo("./output/highlight-projects"); assertThat(properties.getEditing().getThumbnailCountPerClip()).isEqualTo(5); assertThat(properties.getEditing().getContactSheetColumns()).isEqualTo(5); assertThat(properties.getEditing().isProxyEnabled()).isTrue(); @@ -64,6 +65,7 @@ class VideoClippingPropertiesTest { .withPropertyValues( "video-clipping.editing.enabled=false", "video-clipping.editing.project-directory=/tmp/edit-projects", + "video-clipping.editing.highlight-project-directory=/tmp/highlight-projects", "video-clipping.editing.thumbnail-count-per-clip=7", "video-clipping.editing.voiceover-provider=remote", "video-clipping.editing.loudness-target-i=-14", @@ -90,6 +92,7 @@ class VideoClippingPropertiesTest { assertThat(properties.getEditing().isEnabled()).isFalse(); assertThat(properties.getEditing().getProjectDirectory()).isEqualTo("/tmp/edit-projects"); + assertThat(properties.getEditing().getHighlightProjectDirectory()).isEqualTo("/tmp/highlight-projects"); assertThat(properties.getEditing().getThumbnailCountPerClip()).isEqualTo(7); assertThat(properties.getEditing().getVoiceoverProvider()).isEqualTo("remote"); assertThat(properties.getEditing().getLoudnessTargetI()).isEqualTo(-14.0); diff --git a/src/test/java/org/example/videoclips/editing/FileSystemHighlightProjectStoreTest.java b/src/test/java/org/example/videoclips/editing/FileSystemHighlightProjectStoreTest.java new file mode 100644 index 0000000..b4887ec --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/FileSystemHighlightProjectStoreTest.java @@ -0,0 +1,97 @@ +package org.example.videoclips.editing; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FileSystemHighlightProjectStoreTest { + + @TempDir + Path tempDir; + + @Test + void createsProductionHighlightFolderContractAndManifests() { + FileSystemHighlightProjectStore store = store(); + HighlightProject project = project(); + HighlightProjectManifest manifest = manifest(); + + Path projectDirectory = store.createProject(project, manifest); + + assertThat(projectDirectory).isEqualTo(tempDir.resolve("highlight-projects/porsche-highlight-001")); + assertThat(Files.isDirectory(store.sourceDirectory(project.id()))).isTrue(); + assertThat(Files.isDirectory(store.analysisDirectory(project.id()))).isTrue(); + assertThat(Files.isDirectory(store.directorDirectory(project.id()))).isTrue(); + assertThat(Files.isDirectory(store.assetsDirectory(project.id()).resolve("voiceover"))).isTrue(); + assertThat(Files.isDirectory(store.assetsDirectory(project.id()).resolve("music"))).isTrue(); + assertThat(Files.isDirectory(store.assetsDirectory(project.id()).resolve("sfx"))).isTrue(); + assertThat(Files.isDirectory(store.assetsDirectory(project.id()).resolve("overlays"))).isTrue(); + assertThat(Files.isDirectory(store.highlightsDirectory(project.id()))).isTrue(); + assertThat(store.projectFile(project.id())).exists(); + assertThat(store.manifestFile(project.id())).exists(); + } + + @Test + void roundTripsProjectAndNestedAnalysisJson() { + FileSystemHighlightProjectStore store = store(); + HighlightProject project = project(); + store.createProject(project, manifest()); + + store.writeJson(project.id(), "analysis/ffprobe.json", new ProbeSummary(12.5, "h264")); + + assertThat(store.readJson(project.id(), "project.json", HighlightProject.class)).isEqualTo(project); + assertThat(store.readJson(project.id(), "manifest.json", HighlightProjectManifest.class)).isEqualTo(manifest()); + assertThat(store.readJson(project.id(), "analysis/ffprobe.json", ProbeSummary.class)) + .isEqualTo(new ProbeSummary(12.5, "h264")); + } + + @Test + void rejectsUnsafeProjectIdsAndFileNames() { + FileSystemHighlightProjectStore store = store(); + + assertThrows(IllegalArgumentException.class, () -> store.projectDirectory("../escape")); + assertThrows(IllegalArgumentException.class, () -> store.projectDirectory("bad/id")); + assertThrows(IllegalArgumentException.class, () -> store.writeJson("project-1", "../escape.json", + new ProbeSummary(1, "h264"))); + assertThrows(IllegalArgumentException.class, () -> store.writeJson("project-1", "/tmp/escape.json", + new ProbeSummary(1, "h264"))); + } + + @Test + void rejectsMismatchedProjectAndManifestIds() { + FileSystemHighlightProjectStore store = store(); + HighlightProjectManifest manifest = new HighlightProjectManifest("other-project", "porsche.mp4", + HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z")); + + assertThrows(IllegalArgumentException.class, () -> store.createProject(project(), manifest)); + } + + private FileSystemHighlightProjectStore store() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString()); + ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + return new FileSystemHighlightProjectStore(properties, objectMapper); + } + + private HighlightProject project() { + Instant now = Instant.parse("2026-07-11T08:00:00Z"); + return new HighlightProject("porsche-highlight-001", "Porsche Highlight", + HighlightProjectStatus.CREATED, "porsche.mp4", + tempDir.resolve("highlight-projects/porsche-highlight-001").toString(), now, now, null); + } + + private HighlightProjectManifest manifest() { + return new HighlightProjectManifest("porsche-highlight-001", "porsche.mp4", + HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z")); + } + + record ProbeSummary(double durationSeconds, String videoCodec) { + } +}