Add edit plan API
This commit is contained in:
parent
4979e4da25
commit
2b4c5992a6
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EditDecision> decisions,
|
||||
@NotNull List<AudioCue> audioCues,
|
||||
@NotNull List<VoiceoverLine> voiceover,
|
||||
@NotBlank String renderProfile,
|
||||
String summary
|
||||
) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue