diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 6cc58af..1b04a8e 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -796,7 +796,7 @@ It writes the script only and requires an imported audio file. 13. [x] Add local director source-folder scheduler. 14. [x] Add project `inbox/` plan pickup. 15. [x] Add strict JSON edit plan schema and validation. -16. [ ] Add `PUT /v1/edit-projects/{projectId}/plan`. +16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`. 17. [ ] Add simple segment rendering without transitions. 18. [ ] Add concat-based final timeline rendering. 19. [ ] Add music and voiceover audio mixing. diff --git a/src/main/java/org/example/videoclips/api/EditProjectController.java b/src/main/java/org/example/videoclips/api/EditProjectController.java index 6907148..005a1f6 100644 --- a/src/main/java/org/example/videoclips/api/EditProjectController.java +++ b/src/main/java/org/example/videoclips/api/EditProjectController.java @@ -2,6 +2,8 @@ package org.example.videoclips.api; import jakarta.validation.Valid; import org.example.videoclips.api.dto.CreateEditProjectRequest; +import org.example.videoclips.api.dto.SaveEditPlanRequest; +import org.example.videoclips.editing.EditPlanService; import org.example.videoclips.editing.EditProjectService; import org.example.videoclips.editing.StoryboardPromptGenerator; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -11,6 +13,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -21,11 +24,14 @@ public class EditProjectController { private final EditProjectService editProjectService; private final StoryboardPromptGenerator storyboardPromptGenerator; + private final EditPlanService editPlanService; public EditProjectController(EditProjectService editProjectService, - StoryboardPromptGenerator storyboardPromptGenerator) { + StoryboardPromptGenerator storyboardPromptGenerator, + EditPlanService editPlanService) { this.editProjectService = editProjectService; this.storyboardPromptGenerator = storyboardPromptGenerator; + this.editPlanService = editPlanService; } @PostMapping @@ -43,4 +49,9 @@ public class EditProjectController { public Object generateStoryboardPrompt(@PathVariable String projectId) { return storyboardPromptGenerator.generate(projectId); } + + @PutMapping("/{projectId}/plan") + public Object savePlan(@PathVariable String projectId, @Valid @RequestBody SaveEditPlanRequest request) { + return editPlanService.save(projectId, request); + } } diff --git a/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java b/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java new file mode 100644 index 0000000..0fffce3 --- /dev/null +++ b/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java @@ -0,0 +1,22 @@ +package org.example.videoclips.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import org.example.videoclips.editing.AudioCue; +import org.example.videoclips.editing.EditDecision; +import org.example.videoclips.editing.VoiceoverLine; + +import java.util.List; + +public record SaveEditPlanRequest( + @NotBlank String style, + @Positive double targetDurationSeconds, + @NotEmpty List decisions, + @NotNull List audioCues, + @NotNull List voiceover, + @NotBlank String renderProfile, + String summary +) { +} diff --git a/src/main/java/org/example/videoclips/editing/EditPlanService.java b/src/main/java/org/example/videoclips/editing/EditPlanService.java new file mode 100644 index 0000000..86799ae --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditPlanService.java @@ -0,0 +1,35 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.api.dto.EditProjectResponse; +import org.example.videoclips.api.dto.SaveEditPlanRequest; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.List; + +@Service +@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) +public class EditPlanService { + + private final EditProjectStore store; + private final EditProjectService projectService; + private final EditPlanValidator validator; + + public EditPlanService(EditProjectStore store, EditProjectService projectService, EditPlanValidator validator) { + this.store = store; + this.projectService = projectService; + this.validator = validator; + } + + public EditPlan save(String projectId, SaveEditPlanRequest request) { + EditPlan plan = new EditPlan(projectId, request.style(), request.targetDurationSeconds(), + List.copyOf(request.decisions()), List.copyOf(request.audioCues()), List.copyOf(request.voiceover()), + request.renderProfile(), request.summary()); + validator.validate(projectId, plan); + store.writeJson(projectId, "edit-plan.json", plan); + EditProjectResponse project = projectService.getProject(projectId); + projectService.updateProject(projectId, EditProjectStatus.PLANNED, Path.of(project.inputDirectory()), null); + return plan; + } +} diff --git a/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java b/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java index fe7521f..3040a5a 100644 --- a/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java +++ b/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java @@ -13,6 +13,7 @@ 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.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.assertj.core.api.Assertions.assertThat; @@ -99,6 +100,44 @@ class EditProjectControllerTest { .contains("Target duration: 45 seconds"); } + @Test + void validatesAndSavesEditPlan() 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":"Plan %s","inputDirectory":"%s","targetDurationSeconds":45, + "style":"cinematic-porsche-promo"} + """.formatted(UUID.randomUUID(), inputDirectory))) + .andReturn().getResponse().getContentAsString(); + String projectId = JsonTestHelper.read(response, "$.projectId"); + Path projectDirectory = Path.of("target/edit-project-controller-test/projects", projectId); + Files.writeString(projectDirectory.resolve("analysis.json"), """ + {"projectId":"%s","clips":[{"clipId":"clip-1","sourcePath":"clip.mp4", + "durationSeconds":8,"videoCodec":"h264","audioCodec":"aac","width":1920,"height":1080, + "frameRate":30,"thumbnails":[],"motionScore":0,"brightnessScore":0,"sharpnessScore":0}], + "errors":[],"createdAt":"2026-07-10T10:00:00Z"} + """.formatted(projectId)); + + mockMvc.perform(put("/v1/edit-projects/{projectId}/plan", projectId) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"style":"cinematic-porsche-promo","targetDurationSeconds":45, + "decisions":[{"clipId":"clip-1","sourceStartSeconds":0,"sourceEndSeconds":4, + "timelineStartSeconds":0,"timelineEndSeconds":4,"transitionIn":"cut", + "transitionOut":"cut","playbackSpeed":1,"visualTreatment":"grade","reason":"hero"}], + "audioCues":[],"voiceover":[],"renderProfile":"mp4-h264-aac-1080p"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.projectId").value(projectId)); + + mockMvc.perform(get("/v1/edit-projects/{projectId}", projectId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("PLANNED")); + assertThat(projectDirectory.resolve("edit-plan.json")).exists(); + } + @Test void rejectsMissingInputDirectory() throws Exception { mockMvc.perform(post("/v1/edit-projects") diff --git a/src/test/java/org/example/videoclips/editing/EditPlanServiceTest.java b/src/test/java/org/example/videoclips/editing/EditPlanServiceTest.java new file mode 100644 index 0000000..6cb09ab --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/EditPlanServiceTest.java @@ -0,0 +1,45 @@ +package org.example.videoclips.editing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.example.videoclips.api.dto.CreateEditProjectRequest; +import org.example.videoclips.api.dto.SaveEditPlanRequest; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EditPlanServiceTest { + + @TempDir Path tempDir; + + @Test + void validatesPersistsAndUpdatesProject() throws Exception { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString()); + EditProjectStore store = new FileSystemEditProjectStore(properties, + new ObjectMapper().findAndRegisterModules()); + EditProjectService projects = new EditProjectService(store); + Path input = Files.createDirectory(tempDir.resolve("input")); + projects.createProject(new CreateEditProjectRequest("Project", input.toString(), 60, + "cinematic-porsche-promo", true, true, true)); + store.writeJson("project", "analysis.json", new EditProjectAnalysis("project", List.of( + new ClipAnalysis("clip-1", "clip.mp4", 8, "h264", "aac", 1920, 1080, 30, + List.of(), null, null, 0, 0, 0)), List.of(), Instant.now())); + EditPlanService service = new EditPlanService(store, projects, new EditPlanValidator(store)); + SaveEditPlanRequest request = new SaveEditPlanRequest("cinematic-porsche-promo", 60, + List.of(new EditDecision("clip-1", 0, 4, 0, 4, "cut", "cut", 1, "grade", "hero")), + List.of(), List.of(), "mp4-h264-aac-1080p", "summary"); + + EditPlan saved = service.save("project", request); + + assertThat(saved.projectId()).isEqualTo("project"); + assertThat(store.editPlanFile("project")).exists(); + assertThat(projects.getProject("project").status()).isEqualTo(EditProjectStatus.PLANNED); + } +}