Add highlight project folder contract

This commit is contained in:
JSLMPR 2026-07-11 09:42:55 +02:00
parent 9536928054
commit 5ba108f740
11 changed files with 360 additions and 1 deletions

View File

@ -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.

View File

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

View File

@ -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 <T> 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> T readJson(String projectId, String relativeFileName, Class<T> 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;
}
}

View File

@ -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<String> analysisFiles,
List<String> 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"
)
);
}
}

View File

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

View File

@ -0,0 +1,11 @@
package org.example.videoclips.editing;
import java.time.Instant;
public record HighlightProjectManifest(
String projectId,
String sourceVideoFileName,
HighlightFolderContract folderContract,
Instant createdAt
) {
}

View File

@ -0,0 +1,11 @@
package org.example.videoclips.editing;
public enum HighlightProjectStatus {
CREATED,
ANALYZING,
WAITING_FOR_DIRECTOR,
PLANNED,
RENDERING,
RENDERED,
FAILED
}

View File

@ -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);
<T> void writeJson(String projectId, String relativeFileName, T value);
<T> T readJson(String projectId, String relativeFileName, Class<T> type);
}

View File

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

View File

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

View File

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