Add voiceover generation hook
This commit is contained in:
parent
1c3db181a3
commit
c3904b75c2
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue