Add edit plan API

This commit is contained in:
JSLMPR 2026-07-10 16:00:49 +02:00
parent 4979e4da25
commit 2b4c5992a6
6 changed files with 154 additions and 2 deletions

View File

@ -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.

View File

@ -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);
}
}

View File

@ -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
) {
}

View File

@ -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;
}
}

View File

@ -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")

View File

@ -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);
}
}