diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 2ae5495..e1f7769 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -806,7 +806,7 @@ It writes the script only and requires an imported audio file. 23. [x] Add optional generated voiceover adapter interface. 24. [x] Add structured logs and metrics for analysis, planning, and rendering durations. 25. [x] Add integration test with generated fixture clips and a short edit plan. -26. [ ] Add documentation for running a local cinematic edit with Codex or Claude as director. +26. [x] Add documentation for running a local cinematic edit with Codex or Claude as director. 27. [ ] Run `mvn verify` and update this checklist. ## Milestone Details @@ -1358,6 +1358,18 @@ output/edit-projects/porsche-session-001/inbox/edit-plan.json ### Validate And Render +Optional imported audio assets must be placed before rendering: + +```text +output/edit-projects/porsche-session-001/audio/music.wav +output/edit-projects/porsche-session-001/audio/voiceover.wav +output/edit-projects/porsche-session-001/audio/sfx/engine-rev.wav +``` + +SFX plan entries use the filename without `.wav` as `assetKey`. When an accepted plan contains voiceover lines, +the default `noop` provider writes `voiceover-script.txt`; record or generate that script as +`audio/voiceover.wav` before rendering if narration should be included in the final mix. + If `auto-render-when-plan-appears=false`, trigger rendering manually: ```bash diff --git a/src/main/java/org/example/videoclips/api/EditProjectController.java b/src/main/java/org/example/videoclips/api/EditProjectController.java index 005a1f6..eab7b00 100644 --- a/src/main/java/org/example/videoclips/api/EditProjectController.java +++ b/src/main/java/org/example/videoclips/api/EditProjectController.java @@ -5,6 +5,7 @@ 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.EditRenderer; import org.example.videoclips.editing.StoryboardPromptGenerator; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; @@ -25,13 +26,16 @@ public class EditProjectController { private final EditProjectService editProjectService; private final StoryboardPromptGenerator storyboardPromptGenerator; private final EditPlanService editPlanService; + private final EditRenderer editRenderer; public EditProjectController(EditProjectService editProjectService, StoryboardPromptGenerator storyboardPromptGenerator, - EditPlanService editPlanService) { + EditPlanService editPlanService, + EditRenderer editRenderer) { this.editProjectService = editProjectService; this.storyboardPromptGenerator = storyboardPromptGenerator; this.editPlanService = editPlanService; + this.editRenderer = editRenderer; } @PostMapping @@ -54,4 +58,10 @@ public class EditProjectController { public Object savePlan(@PathVariable String projectId, @Valid @RequestBody SaveEditPlanRequest request) { return editPlanService.save(projectId, request); } + + @PostMapping("/{projectId}:render") + public Object render(@PathVariable String projectId) { + editRenderer.render(projectId); + return editProjectService.getProject(projectId); + } } diff --git a/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java b/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java index bcd55b3..ecdcd1d 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java +++ b/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java @@ -36,6 +36,7 @@ public class EditPlanInboxScanner { private final EditProjectService projectService; private final EditPlanValidator validator; private final Optional renderer; + private final VoiceoverGenerator voiceoverGenerator; private final AtomicBoolean scanning = new AtomicBoolean(); @Autowired @@ -45,9 +46,11 @@ public class EditPlanInboxScanner { EditProjectStore store, EditProjectService projectService, EditPlanValidator validator, + VoiceoverGenerator voiceoverGenerator, ObjectProvider rendererProvider ) { - this(properties, objectMapper, store, projectService, validator, rendererProvider.getIfAvailable()); + this(properties, objectMapper, store, projectService, validator, voiceoverGenerator, + rendererProvider.getIfAvailable()); } EditPlanInboxScanner( @@ -57,6 +60,14 @@ public class EditPlanInboxScanner { EditProjectService projectService, EditPlanValidator validator, EditRenderer renderer + ) { + this(properties, objectMapper, store, projectService, validator, null, renderer); + } + + EditPlanInboxScanner( + VideoClippingProperties properties, ObjectMapper objectMapper, EditProjectStore store, + EditProjectService projectService, EditPlanValidator validator, + VoiceoverGenerator voiceoverGenerator, EditRenderer renderer ) { this.projectRoot = Path.of(properties.getEditing().getProjectDirectory()); this.expectedPlanFileName = properties.getEditing().getLocalDirector().getExpectedPlanFileName(); @@ -66,6 +77,7 @@ public class EditPlanInboxScanner { this.projectService = projectService; this.validator = validator; this.renderer = Optional.ofNullable(renderer); + this.voiceoverGenerator = voiceoverGenerator; } @Scheduled( @@ -116,6 +128,9 @@ public class EditPlanInboxScanner { } store.writeJson(projectId, "edit-plan.json", normalized(plan)); + if (voiceoverGenerator != null && !plan.voiceover().isEmpty()) { + voiceoverGenerator.generateVoiceover(projectId, plan.voiceover()); + } move(inboxPlan, inboxPlan.resolveSibling(expectedPlanFileName + ".accepted")); EditProjectResponse project = projectService.getProject(projectId); projectService.updateProject(projectId, EditProjectStatus.PLANNED, Path.of(project.inputDirectory()), null); diff --git a/src/main/resources/application-cinematic-editing-local.yml b/src/main/resources/application-cinematic-editing-local.yml new file mode 100644 index 0000000..5bf4be4 --- /dev/null +++ b/src/main/resources/application-cinematic-editing-local.yml @@ -0,0 +1,12 @@ +video-clipping: + folder-scheduler: + enabled: false + editing: + enabled: true + local-director: + enabled: true + auto-render-when-plan-appears: false + +logging: + level: + org.example.videoclips.editing: INFO diff --git a/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java b/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java new file mode 100644 index 0000000..8c428f1 --- /dev/null +++ b/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java @@ -0,0 +1,36 @@ +package org.example.videoclips.api; + +import org.example.videoclips.api.dto.EditProjectResponse; +import org.example.videoclips.editing.EditPlanService; +import org.example.videoclips.editing.EditProjectService; +import org.example.videoclips.editing.EditProjectStatus; +import org.example.videoclips.editing.EditRenderer; +import org.example.videoclips.editing.StoryboardPromptGenerator; +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class EditProjectRenderControllerTest { + + @Test + void delegatesRenderAndReturnsUpdatedProject() { + EditProjectService projects = mock(EditProjectService.class); + EditRenderer renderer = mock(EditRenderer.class); + EditProjectResponse rendered = new EditProjectResponse("project", "Project", EditProjectStatus.RENDERED, + "input", "output", 60, "cinematic-porsche-promo", true, true, true, + Instant.EPOCH, Instant.EPOCH, null); + when(projects.getProject("project")).thenReturn(rendered); + EditProjectController controller = new EditProjectController(projects, mock(StoryboardPromptGenerator.class), + mock(EditPlanService.class), renderer); + + Object response = controller.render("project"); + + verify(renderer).render("project"); + assertThat(response).isEqualTo(rendered); + } +} diff --git a/src/test/java/org/example/videoclips/editing/CinematicEditingLocalProfileTest.java b/src/test/java/org/example/videoclips/editing/CinematicEditingLocalProfileTest.java new file mode 100644 index 0000000..8435ed4 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/CinematicEditingLocalProfileTest.java @@ -0,0 +1,26 @@ +package org.example.videoclips.editing; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.config.YamlPropertiesFactoryBean; +import org.springframework.core.io.ClassPathResource; + +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +class CinematicEditingLocalProfileTest { + + @Test + void enablesLocalDirectorAndDisablesEightSecondFolderScheduler() { + YamlPropertiesFactoryBean factory = new YamlPropertiesFactoryBean(); + factory.setResources(new ClassPathResource("application-cinematic-editing-local.yml")); + Properties properties = factory.getObject(); + + assertThat(properties).isNotNull(); + assertThat(properties.getProperty("video-clipping.folder-scheduler.enabled")).isEqualTo("false"); + assertThat(properties.getProperty("video-clipping.editing.enabled")).isEqualTo("true"); + assertThat(properties.getProperty("video-clipping.editing.local-director.enabled")).isEqualTo("true"); + assertThat(properties.getProperty("video-clipping.editing.local-director.auto-render-when-plan-appears")) + .isEqualTo("false"); + } +}