Add voiceover generation hook

This commit is contained in:
JSLMPR 2026-07-10 18:53:34 +02:00
parent 1c3db181a3
commit c3904b75c2
6 changed files with 115 additions and 3 deletions

View File

@ -803,7 +803,7 @@ It writes the script only and requires an imported audio file.
20. [x] Add render manifest with command metadata and output details. 20. [x] Add render manifest with command metadata and output details.
21. [x] Add basic transition support: cut, crossfade, fade in, fade out. 21. [x] Add basic transition support: cut, crossfade, fade in, fade out.
22. [x] Add SFX cue support. 22. [x] Add SFX cue support.
23. [ ] Add optional generated voiceover adapter interface. 23. [x] Add optional generated voiceover adapter interface.
24. [ ] Add structured logs and metrics for analysis, planning, and rendering durations. 24. [ ] Add structured logs and metrics for analysis, planning, and rendering durations.
25. [ ] Add integration test with generated fixture clips and a short edit plan. 25. [ ] 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. [ ] Add documentation for running a local cinematic edit with Codex or Claude as director.

View File

@ -27,20 +27,34 @@ public class FfmpegEditRenderer implements EditRenderer {
private final EditProjectService projectService; private final EditProjectService projectService;
private final EditPlanValidator validator; private final EditPlanValidator validator;
private final ProcessExecutor executor; private final ProcessExecutor executor;
private final VoiceoverGenerator voiceoverGenerator;
public FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store,
EditProjectService projectService, EditPlanValidator validator) {
this(properties, store, projectService, validator, null, FfmpegEditRenderer::execute);
}
@Autowired @Autowired
public FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store, public FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store,
EditProjectService projectService, EditPlanValidator validator) { EditProjectService projectService, EditPlanValidator validator,
this(properties, store, projectService, validator, FfmpegEditRenderer::execute); VoiceoverGenerator voiceoverGenerator) {
this(properties, store, projectService, validator, voiceoverGenerator, FfmpegEditRenderer::execute);
} }
FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store, FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store,
EditProjectService projectService, EditPlanValidator validator, ProcessExecutor executor) { EditProjectService projectService, EditPlanValidator validator, ProcessExecutor executor) {
this(properties, store, projectService, validator, null, executor);
}
FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store,
EditProjectService projectService, EditPlanValidator validator,
VoiceoverGenerator voiceoverGenerator, ProcessExecutor executor) {
this.properties = properties.getEditing(); this.properties = properties.getEditing();
this.store = store; this.store = store;
this.projectService = projectService; this.projectService = projectService;
this.validator = validator; this.validator = validator;
this.executor = executor; this.executor = executor;
this.voiceoverGenerator = voiceoverGenerator;
} }
@Override @Override
@ -48,6 +62,9 @@ public class FfmpegEditRenderer implements EditRenderer {
EditPlan plan = validator.validate(projectId, store.readJson(projectId, "edit-plan.json", EditPlan.class)); EditPlan plan = validator.validate(projectId, store.readJson(projectId, "edit-plan.json", EditPlan.class));
EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class); EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class);
EditProjectResponse project = projectService.getProject(projectId); EditProjectResponse project = projectService.getProject(projectId);
if (voiceoverGenerator != null && !plan.voiceover().isEmpty()) {
voiceoverGenerator.generateVoiceover(projectId, plan.voiceover());
}
projectService.updateProject(projectId, EditProjectStatus.RENDERING, Path.of(project.inputDirectory()), null); projectService.updateProject(projectId, EditProjectStatus.RENDERING, Path.of(project.inputDirectory()), null);
Path work = store.projectDirectory(projectId).resolve("render-work"); Path work = store.projectDirectory(projectId).resolve("render-work");
createDirectory(work); createDirectory(work);

View File

@ -0,0 +1,37 @@
package org.example.videoclips.editing;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@Component
@ConditionalOnExpression("${video-clipping.editing.enabled:true} and "
+ "'${video-clipping.editing.voiceover-provider:noop}' == 'noop'")
public class NoopVoiceoverGenerator implements VoiceoverGenerator {
private final EditProjectStore store;
public NoopVoiceoverGenerator(EditProjectStore store) {
this.store = store;
}
@Override
public Path generateVoiceover(String projectId, List<VoiceoverLine> lines) {
Path script = store.projectDirectory(projectId).resolve("voiceover-script.txt");
StringBuilder content = new StringBuilder("Voiceover script for project ").append(projectId).append('\n');
for (VoiceoverLine line : lines) {
content.append("[%.3f-%.3f] %s%nDelivery: %s%n%n".formatted(
line.timelineStartSeconds(), line.timelineEndSeconds(), line.text(), line.delivery()));
}
try {
Files.writeString(script, content.toString());
} catch (IOException ex) {
throw new IllegalStateException("Unable to write voiceover script for project: " + projectId, ex);
}
return script;
}
}

View File

@ -0,0 +1,9 @@
package org.example.videoclips.editing;
import java.nio.file.Path;
import java.util.List;
public interface VoiceoverGenerator {
Path generateVoiceover(String projectId, List<VoiceoverLine> lines);
}

View File

@ -29,6 +29,7 @@ video-clipping:
audio-sample-rate: ${VIDEO_EDITING_AUDIO_SAMPLE_RATE:48000} audio-sample-rate: ${VIDEO_EDITING_AUDIO_SAMPLE_RATE:48000}
video-bitrate: ${VIDEO_EDITING_VIDEO_BITRATE:12000k} video-bitrate: ${VIDEO_EDITING_VIDEO_BITRATE:12000k}
audio-bitrate: ${VIDEO_EDITING_AUDIO_BITRATE:192k} audio-bitrate: ${VIDEO_EDITING_AUDIO_BITRATE:192k}
voiceover-provider: ${VIDEO_EDITING_VOICEOVER_PROVIDER:noop}
local-director: local-director:
enabled: ${VIDEO_EDITING_LOCAL_DIRECTOR_ENABLED:true} enabled: ${VIDEO_EDITING_LOCAL_DIRECTOR_ENABLED:true}
source-directory: ${VIDEO_EDITING_LOCAL_DIRECTOR_SOURCE_DIRECTORY:./input/editing/source} source-directory: ${VIDEO_EDITING_LOCAL_DIRECTOR_SOURCE_DIRECTORY:./input/editing/source}

View File

@ -0,0 +1,48 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
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.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class NoopVoiceoverGeneratorTest {
@TempDir Path tempDir;
@Test
void writesTimedScriptWithoutCallingExternalTts() throws Exception {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.toString());
EditProjectStore store = new FileSystemEditProjectStore(properties,
new ObjectMapper().findAndRegisterModules());
store.createProject("project");
NoopVoiceoverGenerator generator = new NoopVoiceoverGenerator(store);
Path script = generator.generateVoiceover("project", List.of(
new VoiceoverLine("Precision, shaped into motion.", 1, 3.5, "confident and restrained")));
assertThat(script).isEqualTo(tempDir.resolve("project/voiceover-script.txt"));
assertThat(Files.readString(script))
.contains("[1.000-3.500] Precision, shaped into motion.")
.contains("Delivery: confident and restrained");
}
@Test
void supportsAnEmptyScriptWhenVoiceoverIsDisabled() throws Exception {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.toString());
EditProjectStore store = new FileSystemEditProjectStore(properties,
new ObjectMapper().findAndRegisterModules());
store.createProject("project");
Path script = new NoopVoiceoverGenerator(store).generateVoiceover("project", List.of());
assertThat(Files.readString(script)).contains("Voiceover script for project project");
}
}