forked from jsl/video_editing_poc
Add cinematic edit project store
This commit is contained in:
parent
754bf35cbe
commit
fdd8309311
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue