forked from jsl/video_editing_poc
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.
|
13. [x] Add local director source-folder scheduler.
|
||||||
14. [x] Add project `inbox/` plan pickup.
|
14. [x] Add project `inbox/` plan pickup.
|
||||||
15. [x] Add strict JSON edit plan schema and validation.
|
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.
|
17. [ ] Add simple segment rendering without transitions.
|
||||||
18. [ ] Add concat-based final timeline rendering.
|
18. [ ] Add concat-based final timeline rendering.
|
||||||
19. [ ] Add music and voiceover audio mixing.
|
19. [ ] Add music and voiceover audio mixing.
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package org.example.videoclips.api;
|
||||||
|
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.example.videoclips.api.dto.CreateEditProjectRequest;
|
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.EditProjectService;
|
||||||
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.ResponseStatus;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
|
@ -21,11 +24,14 @@ public class EditProjectController {
|
||||||
|
|
||||||
private final EditProjectService editProjectService;
|
private final EditProjectService editProjectService;
|
||||||
private final StoryboardPromptGenerator storyboardPromptGenerator;
|
private final StoryboardPromptGenerator storyboardPromptGenerator;
|
||||||
|
private final EditPlanService editPlanService;
|
||||||
|
|
||||||
public EditProjectController(EditProjectService editProjectService,
|
public EditProjectController(EditProjectService editProjectService,
|
||||||
StoryboardPromptGenerator storyboardPromptGenerator) {
|
StoryboardPromptGenerator storyboardPromptGenerator,
|
||||||
|
EditPlanService editPlanService) {
|
||||||
this.editProjectService = editProjectService;
|
this.editProjectService = editProjectService;
|
||||||
this.storyboardPromptGenerator = storyboardPromptGenerator;
|
this.storyboardPromptGenerator = storyboardPromptGenerator;
|
||||||
|
this.editPlanService = editPlanService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|
@ -43,4 +49,9 @@ public class EditProjectController {
|
||||||
public Object generateStoryboardPrompt(@PathVariable String projectId) {
|
public Object generateStoryboardPrompt(@PathVariable String projectId) {
|
||||||
return storyboardPromptGenerator.generate(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.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
@ -99,6 +100,44 @@ class EditProjectControllerTest {
|
||||||
.contains("Target duration: 45 seconds");
|
.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
|
@Test
|
||||||
void rejectsMissingInputDirectory() throws Exception {
|
void rejectsMissingInputDirectory() throws Exception {
|
||||||
mockMvc.perform(post("/v1/edit-projects")
|
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