Document local AI director workflow

This commit is contained in:
JSLMPR 2026-07-10 19:05:19 +02:00
parent 051f4e56d0
commit e5a86257d1
6 changed files with 114 additions and 3 deletions

View File

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

View File

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

View File

@ -36,6 +36,7 @@ public class EditPlanInboxScanner {
private final EditProjectService projectService;
private final EditPlanValidator validator;
private final Optional<EditRenderer> 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<EditRenderer> 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);

View File

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

View File

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

View File

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