From c3904b75c208edc02eefdd930780c0ae18ab1d10 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 18:53:34 +0200 Subject: [PATCH] Add voiceover generation hook --- docs/cinematic-video-editing-service-plan.md | 2 +- .../editing/FfmpegEditRenderer.java | 21 +++++++- .../editing/NoopVoiceoverGenerator.java | 37 ++++++++++++++ .../editing/VoiceoverGenerator.java | 9 ++++ src/main/resources/application.yml | 1 + .../editing/NoopVoiceoverGeneratorTest.java | 48 +++++++++++++++++++ 6 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/example/videoclips/editing/NoopVoiceoverGenerator.java create mode 100644 src/main/java/org/example/videoclips/editing/VoiceoverGenerator.java create mode 100644 src/test/java/org/example/videoclips/editing/NoopVoiceoverGeneratorTest.java diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index fb34bb7..e9f10ad 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. 21. [x] Add basic transition support: cut, crossfade, fade in, fade out. 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. 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. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index b83ec4e..21fb9ee 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -27,20 +27,34 @@ public class FfmpegEditRenderer implements EditRenderer { private final EditProjectService projectService; private final EditPlanValidator validator; 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 public FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store, - EditProjectService projectService, EditPlanValidator validator) { - this(properties, store, projectService, validator, FfmpegEditRenderer::execute); + EditProjectService projectService, EditPlanValidator validator, + VoiceoverGenerator voiceoverGenerator) { + this(properties, store, projectService, validator, voiceoverGenerator, FfmpegEditRenderer::execute); } FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store, 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.store = store; this.projectService = projectService; this.validator = validator; this.executor = executor; + this.voiceoverGenerator = voiceoverGenerator; } @Override @@ -48,6 +62,9 @@ public class FfmpegEditRenderer implements EditRenderer { EditPlan plan = validator.validate(projectId, store.readJson(projectId, "edit-plan.json", EditPlan.class)); EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class); 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); Path work = store.projectDirectory(projectId).resolve("render-work"); createDirectory(work); diff --git a/src/main/java/org/example/videoclips/editing/NoopVoiceoverGenerator.java b/src/main/java/org/example/videoclips/editing/NoopVoiceoverGenerator.java new file mode 100644 index 0000000..42d4f19 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/NoopVoiceoverGenerator.java @@ -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 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; + } +} diff --git a/src/main/java/org/example/videoclips/editing/VoiceoverGenerator.java b/src/main/java/org/example/videoclips/editing/VoiceoverGenerator.java new file mode 100644 index 0000000..ac0fbd8 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/VoiceoverGenerator.java @@ -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 lines); +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b24b84a..3702db0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,6 +29,7 @@ video-clipping: audio-sample-rate: ${VIDEO_EDITING_AUDIO_SAMPLE_RATE:48000} video-bitrate: ${VIDEO_EDITING_VIDEO_BITRATE:12000k} audio-bitrate: ${VIDEO_EDITING_AUDIO_BITRATE:192k} + voiceover-provider: ${VIDEO_EDITING_VOICEOVER_PROVIDER:noop} local-director: enabled: ${VIDEO_EDITING_LOCAL_DIRECTOR_ENABLED:true} source-directory: ${VIDEO_EDITING_LOCAL_DIRECTOR_SOURCE_DIRECTORY:./input/editing/source} diff --git a/src/test/java/org/example/videoclips/editing/NoopVoiceoverGeneratorTest.java b/src/test/java/org/example/videoclips/editing/NoopVoiceoverGeneratorTest.java new file mode 100644 index 0000000..90b141d --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/NoopVoiceoverGeneratorTest.java @@ -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"); + } +}