diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 54f7cea..bb51522 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java b/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java index e0dda1a..cfbc7be 100644 --- a/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java +++ b/src/main/java/org/example/videoclips/api/ApiExceptionHandler.java @@ -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); diff --git a/src/main/java/org/example/videoclips/api/EditProjectController.java b/src/main/java/org/example/videoclips/api/EditProjectController.java new file mode 100644 index 0000000..bf0b52b --- /dev/null +++ b/src/main/java/org/example/videoclips/api/EditProjectController.java @@ -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); + } +} diff --git a/src/main/java/org/example/videoclips/api/dto/CreateEditProjectRequest.java b/src/main/java/org/example/videoclips/api/dto/CreateEditProjectRequest.java new file mode 100644 index 0000000..f740e8b --- /dev/null +++ b/src/main/java/org/example/videoclips/api/dto/CreateEditProjectRequest.java @@ -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 +) { +} diff --git a/src/main/java/org/example/videoclips/api/dto/EditProjectResponse.java b/src/main/java/org/example/videoclips/api/dto/EditProjectResponse.java new file mode 100644 index 0000000..b8d9aa3 --- /dev/null +++ b/src/main/java/org/example/videoclips/api/dto/EditProjectResponse.java @@ -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 +) { +} diff --git a/src/main/java/org/example/videoclips/application/BadRequestException.java b/src/main/java/org/example/videoclips/application/BadRequestException.java new file mode 100644 index 0000000..b23f35a --- /dev/null +++ b/src/main/java/org/example/videoclips/application/BadRequestException.java @@ -0,0 +1,8 @@ +package org.example.videoclips.application; + +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/org/example/videoclips/editing/EditProjectService.java b/src/main/java/org/example/videoclips/editing/EditProjectService.java new file mode 100644 index 0000000..95c2e9f --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditProjectService.java @@ -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() + ); + } +} diff --git a/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java b/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java new file mode 100644 index 0000000..806d3da --- /dev/null +++ b/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java @@ -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")); + } +}