forked from jsl/video_editing_poc
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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