forked from jsl/video_editing_poc
Add highlight project folder contract
This commit is contained in:
parent
9536928054
commit
5ba108f740
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record HighlightProjectManifest(
|
||||
String projectId,
|
||||
String sourceVideoFileName,
|
||||
HighlightFolderContract folderContract,
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
public enum HighlightProjectStatus {
|
||||
CREATED,
|
||||
ANALYZING,
|
||||
WAITING_FOR_DIRECTOR,
|
||||
PLANNED,
|
||||
RENDERING,
|
||||
RENDERED,
|
||||
FAILED
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue