Add cinematic edit project store

This commit is contained in:
JSLMPR 2026-07-10 14:41:52 +02:00
parent 754bf35cbe
commit fdd8309311
4 changed files with 223 additions and 1 deletions

View File

@ -782,7 +782,7 @@ It writes the script only and requires an imported audio file.
## Milestones
1. [x] Add editing configuration under `video-clipping.editing`.
2. [ ] Add filesystem project store for `project.json`, `analysis.json`, `edit-plan.json`, and render output.
2. [x] Add filesystem project store for `project.json`, `analysis.json`, `edit-plan.json`, and render output.
3. [ ] Add edit project domain records and status enum.
4. [ ] Add `POST /v1/edit-projects` and `GET /v1/edit-projects/{projectId}`.
5. [ ] Add clip discovery for project input directories.

View File

@ -0,0 +1,24 @@
package org.example.videoclips.editing;
import java.nio.file.Path;
public interface EditProjectStore {
Path createProject(String projectId);
Path projectDirectory(String projectId);
Path projectFile(String projectId);
Path analysisFile(String projectId);
Path editPlanFile(String projectId);
Path renderManifestFile(String projectId);
Path inboxDirectory(String projectId);
<T> void writeJson(String projectId, String fileName, T value);
<T> T readJson(String projectId, String fileName, Class<T> type);
}

View File

@ -0,0 +1,120 @@
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 FileSystemEditProjectStore implements EditProjectStore {
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 FileSystemEditProjectStore(VideoClippingProperties properties, ObjectMapper objectMapper) {
this.projectRoot = Path.of(properties.getEditing().getProjectDirectory()).normalize();
this.objectMapper = objectMapper;
}
@Override
public Path createProject(String projectId) {
Path directory = projectDirectory(projectId);
try {
Files.createDirectories(directory);
Files.createDirectories(inboxDirectory(projectId));
Files.createDirectories(directory.resolve("thumbnails"));
Files.createDirectories(directory.resolve("contact-sheets"));
Files.createDirectories(directory.resolve("proxies"));
Files.createDirectories(directory.resolve("audio"));
return directory;
} catch (IOException ex) {
throw new IllegalStateException("Unable to create edit project directory: " + projectId, 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 edit project id: " + projectId);
}
return directory;
}
@Override
public Path projectFile(String projectId) {
return projectDirectory(projectId).resolve("project.json");
}
@Override
public Path analysisFile(String projectId) {
return projectDirectory(projectId).resolve("analysis.json");
}
@Override
public Path editPlanFile(String projectId) {
return projectDirectory(projectId).resolve("edit-plan.json");
}
@Override
public Path renderManifestFile(String projectId) {
return projectDirectory(projectId).resolve("render-manifest.json");
}
@Override
public Path inboxDirectory(String projectId) {
return projectDirectory(projectId).resolve("inbox");
}
@Override
public <T> void writeJson(String projectId, String fileName, T value) {
Path file = projectDirectory(projectId).resolve(validateFileName(fileName)).normalize();
if (!file.startsWith(projectDirectory(projectId))) {
throw new IllegalArgumentException("Invalid edit project file name: " + fileName);
}
try {
Files.createDirectories(file.getParent());
objectMapper.writerWithDefaultPrettyPrinter().writeValue(file.toFile(), value);
} catch (IOException ex) {
throw new IllegalStateException("Unable to write edit project JSON file: " + fileName, ex);
}
}
@Override
public <T> T readJson(String projectId, String fileName, Class<T> type) {
Path file = projectDirectory(projectId).resolve(validateFileName(fileName)).normalize();
if (!file.startsWith(projectDirectory(projectId))) {
throw new IllegalArgumentException("Invalid edit project file name: " + fileName);
}
try {
return objectMapper.readValue(file.toFile(), type);
} catch (IOException ex) {
throw new IllegalStateException("Unable to read edit project JSON file: " + fileName, ex);
}
}
private String validateProjectId(String projectId) {
if (projectId == null || !PROJECT_ID_PATTERN.matcher(projectId).matches()
|| projectId.contains("..")) {
throw new IllegalArgumentException("Invalid edit project id: " + projectId);
}
return projectId;
}
private String validateFileName(String fileName) {
if (fileName == null || fileName.isBlank() || fileName.contains("/") || fileName.contains("\\")
|| fileName.contains("..")) {
throw new IllegalArgumentException("Invalid edit project file name: " + fileName);
}
return fileName;
}
}

View File

@ -0,0 +1,78 @@
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 static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class FileSystemEditProjectStoreTest {
@TempDir
Path tempDir;
@Test
void createsExpectedProjectDirectories() {
FileSystemEditProjectStore store = store();
Path projectDirectory = store.createProject("porsche-session-001");
assertThat(projectDirectory).isEqualTo(tempDir.resolve("edit-projects/porsche-session-001"));
assertThat(Files.isDirectory(projectDirectory)).isTrue();
assertThat(Files.isDirectory(projectDirectory.resolve("inbox"))).isTrue();
assertThat(Files.isDirectory(projectDirectory.resolve("thumbnails"))).isTrue();
assertThat(Files.isDirectory(projectDirectory.resolve("contact-sheets"))).isTrue();
assertThat(Files.isDirectory(projectDirectory.resolve("proxies"))).isTrue();
assertThat(Files.isDirectory(projectDirectory.resolve("audio"))).isTrue();
}
@Test
void exposesKnownProjectArtifactPaths() {
FileSystemEditProjectStore store = store();
assertThat(store.projectFile("project-1")).isEqualTo(tempDir.resolve("edit-projects/project-1/project.json"));
assertThat(store.analysisFile("project-1")).isEqualTo(tempDir.resolve("edit-projects/project-1/analysis.json"));
assertThat(store.editPlanFile("project-1")).isEqualTo(tempDir.resolve("edit-projects/project-1/edit-plan.json"));
assertThat(store.renderManifestFile("project-1"))
.isEqualTo(tempDir.resolve("edit-projects/project-1/render-manifest.json"));
assertThat(store.inboxDirectory("project-1")).isEqualTo(tempDir.resolve("edit-projects/project-1/inbox"));
}
@Test
void roundTripsJsonFiles() {
FileSystemEditProjectStore store = store();
store.createProject("project-1");
store.writeJson("project-1", "project.json", new SampleProject("project-1", "Porsche"));
assertThat(store.readJson("project-1", "project.json", SampleProject.class))
.isEqualTo(new SampleProject("project-1", "Porsche"));
}
@Test
void rejectsUnsafeProjectIdsAndFileNames() {
FileSystemEditProjectStore store = store();
assertThrows(IllegalArgumentException.class, () -> store.projectDirectory("../escape"));
assertThrows(IllegalArgumentException.class, () -> store.projectDirectory("bad/id"));
assertThrows(IllegalArgumentException.class, () -> store.projectDirectory(""));
assertThrows(IllegalArgumentException.class, () -> store.writeJson("project-1", "../escape.json",
new SampleProject("project-1", "Porsche")));
assertThrows(IllegalArgumentException.class, () -> store.readJson("project-1", "nested/file.json",
SampleProject.class));
}
private FileSystemEditProjectStore store() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.resolve("edit-projects").toString());
return new FileSystemEditProjectStore(properties, new ObjectMapper());
}
record SampleProject(String id, String name) {
}
}