Add cinematic edit project API

This commit is contained in:
JSLMPR 2026-07-10 14:53:02 +02:00
parent 0d4752897d
commit 8d8180dc61
8 changed files with 280 additions and 1 deletions

View File

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

View File

@ -2,6 +2,7 @@ package org.example.videoclips.api;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.example.videoclips.application.BadRequestException;
import org.example.videoclips.application.ForbiddenException;
import org.example.videoclips.application.NotFoundException;
import org.example.videoclips.application.PayloadTooLargeException;
@ -35,6 +36,11 @@ public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
return problem(HttpStatus.NOT_FOUND, "not-found", ex.getMessage(), request.getRequestURI(), null);
}
@ExceptionHandler(BadRequestException.class)
ProblemDetail handleBadRequest(BadRequestException ex, HttpServletRequest request) {
return problem(HttpStatus.BAD_REQUEST, "bad-request", ex.getMessage(), request.getRequestURI(), null);
}
@ExceptionHandler(ForbiddenException.class)
ProblemDetail handleForbidden(ForbiddenException ex, HttpServletRequest request) {
return problem(HttpStatus.FORBIDDEN, "forbidden", ex.getMessage(), request.getRequestURI(), null);

View File

@ -0,0 +1,37 @@
package org.example.videoclips.api;
import jakarta.validation.Valid;
import org.example.videoclips.api.dto.CreateEditProjectRequest;
import org.example.videoclips.editing.EditProjectService;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/v1/edit-projects")
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class EditProjectController {
private final EditProjectService editProjectService;
public EditProjectController(EditProjectService editProjectService) {
this.editProjectService = editProjectService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Object createProject(@Valid @RequestBody CreateEditProjectRequest request) {
return editProjectService.createProject(request);
}
@GetMapping("/{projectId}")
public Object getProject(@PathVariable String projectId) {
return editProjectService.getProject(projectId);
}
}

View File

@ -0,0 +1,16 @@
package org.example.videoclips.api.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
public record CreateEditProjectRequest(
@NotBlank String name,
@NotBlank String inputDirectory,
@Min(15) @Max(600) Integer targetDurationSeconds,
@NotBlank String style,
Boolean voiceoverEnabled,
Boolean musicEnabled,
Boolean soundEffectsEnabled
) {
}

View File

@ -0,0 +1,17 @@
package org.example.videoclips.api.dto;
import org.example.videoclips.editing.EditProjectStatus;
import java.time.Instant;
public record EditProjectResponse(
String projectId,
String name,
EditProjectStatus status,
String inputDirectory,
String outputDirectory,
Instant createdAt,
Instant updatedAt,
String failureReason
) {
}

View File

@ -0,0 +1,8 @@
package org.example.videoclips.application;
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}

View File

@ -0,0 +1,93 @@
package org.example.videoclips.editing;
import org.example.videoclips.api.dto.CreateEditProjectRequest;
import org.example.videoclips.api.dto.EditProjectResponse;
import org.example.videoclips.application.BadRequestException;
import org.example.videoclips.application.NotFoundException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.util.Locale;
@Service
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class EditProjectService {
private final EditProjectStore store;
private final Clock clock;
@Autowired
public EditProjectService(EditProjectStore store) {
this(store, Clock.systemUTC());
}
EditProjectService(EditProjectStore store, Clock clock) {
this.store = store;
this.clock = clock;
}
public EditProjectResponse createProject(CreateEditProjectRequest request) {
Path inputDirectory = Path.of(request.inputDirectory()).normalize();
if (!Files.isDirectory(inputDirectory)) {
throw new BadRequestException("Edit project input directory does not exist: " + request.inputDirectory());
}
String projectId = nextProjectId(slug(request.name()));
Path outputDirectory = store.createProject(projectId);
Instant now = Instant.now(clock);
EditProject project = new EditProject(
projectId,
request.name(),
EditProjectStatus.CREATED,
inputDirectory.toString(),
outputDirectory.toString(),
now,
now,
null
);
store.writeJson(projectId, "project.json", project);
return response(project);
}
public EditProjectResponse getProject(String projectId) {
if (!Files.exists(store.projectFile(projectId))) {
throw new NotFoundException("Edit project not found: " + projectId);
}
return response(store.readJson(projectId, "project.json", EditProject.class));
}
private String nextProjectId(String baseProjectId) {
String candidate = baseProjectId;
int suffix = 1;
while (Files.exists(store.projectDirectory(candidate))) {
candidate = baseProjectId + "-" + suffix++;
}
return candidate;
}
private String slug(String name) {
String normalized = name.toLowerCase(Locale.ROOT)
.replaceAll("[^a-z0-9._-]+", "-")
.replaceAll("^-+", "")
.replaceAll("-+$", "");
return normalized.isBlank() ? "edit-project" : normalized;
}
private EditProjectResponse response(EditProject project) {
return new EditProjectResponse(
project.id(),
project.name(),
project.status(),
project.inputDirectory(),
project.outputDirectory(),
project.createdAt(),
project.updatedAt(),
project.failureReason()
);
}
}

View File

@ -0,0 +1,102 @@
package org.example.videoclips.api;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(properties = {
"video-clipping.editing.project-directory=target/edit-project-controller-test/projects"
})
@AutoConfigureMockMvc
class EditProjectControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void createsAndReadsEditProject() throws Exception {
Path inputDirectory = Files.createDirectories(Path.of("target/edit-project-controller-test/input-"
+ UUID.randomUUID()));
String response = mockMvc.perform(post("/v1/edit-projects")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Porsche Session %s",
"inputDirectory": "%s",
"targetDurationSeconds": 60,
"style": "cinematic-porsche-promo",
"voiceoverEnabled": true,
"musicEnabled": true,
"soundEffectsEnabled": true
}
""".formatted(UUID.randomUUID(), inputDirectory)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.projectId").exists())
.andExpect(jsonPath("$.status").value("CREATED"))
.andExpect(jsonPath("$.inputDirectory").value(inputDirectory.toString()))
.andReturn()
.getResponse()
.getContentAsString();
String projectId = JsonTestHelper.read(response, "$.projectId");
mockMvc.perform(get("/v1/edit-projects/{projectId}", projectId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.projectId").value(projectId))
.andExpect(jsonPath("$.status").value("CREATED"))
.andExpect(jsonPath("$.outputDirectory").value("target/edit-project-controller-test/projects/"
+ projectId));
}
@Test
void rejectsMissingInputDirectory() throws Exception {
mockMvc.perform(post("/v1/edit-projects")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Missing Input",
"inputDirectory": "target/edit-project-controller-test/missing",
"targetDurationSeconds": 60,
"style": "cinematic-porsche-promo"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/bad-request"));
}
@Test
void rejectsInvalidCreateRequest() throws Exception {
mockMvc.perform(post("/v1/edit-projects")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "",
"inputDirectory": "",
"targetDurationSeconds": 10,
"style": ""
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"));
}
@Test
void returnsNotFoundForMissingProject() throws Exception {
mockMvc.perform(get("/v1/edit-projects/{projectId}", "missing-project"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found"));
}
}