Add normalized edit segment rendering
This commit is contained in:
parent
2b4c5992a6
commit
21e5cef45d
|
|
@ -797,7 +797,7 @@ It writes the script only and requires an imported audio file.
|
||||||
14. [x] Add project `inbox/` plan pickup.
|
14. [x] Add project `inbox/` plan pickup.
|
||||||
15. [x] Add strict JSON edit plan schema and validation.
|
15. [x] Add strict JSON edit plan schema and validation.
|
||||||
16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
16. [x] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
||||||
17. [ ] Add simple segment rendering without transitions.
|
17. [x] Add simple segment rendering without transitions.
|
||||||
18. [ ] Add concat-based final timeline rendering.
|
18. [ ] Add concat-based final timeline rendering.
|
||||||
19. [ ] Add music and voiceover audio mixing.
|
19. [ ] Add music and voiceover audio mixing.
|
||||||
20. [ ] Add render manifest with command metadata and output details.
|
20. [ ] Add render manifest with command metadata and output details.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
|
import org.example.videoclips.api.dto.EditProjectResponse;
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class FfmpegEditRenderer implements EditRenderer {
|
||||||
|
|
||||||
|
private final VideoClippingProperties.Editing properties;
|
||||||
|
private final EditProjectStore store;
|
||||||
|
private final EditProjectService projectService;
|
||||||
|
private final EditPlanValidator validator;
|
||||||
|
private final ProcessExecutor executor;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store,
|
||||||
|
EditProjectService projectService, EditPlanValidator validator) {
|
||||||
|
this(properties, store, projectService, validator, FfmpegEditRenderer::execute);
|
||||||
|
}
|
||||||
|
|
||||||
|
FfmpegEditRenderer(VideoClippingProperties properties, EditProjectStore store,
|
||||||
|
EditProjectService projectService, EditPlanValidator validator, ProcessExecutor executor) {
|
||||||
|
this.properties = properties.getEditing();
|
||||||
|
this.store = store;
|
||||||
|
this.projectService = projectService;
|
||||||
|
this.validator = validator;
|
||||||
|
this.executor = executor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void render(String projectId) {
|
||||||
|
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);
|
||||||
|
projectService.updateProject(projectId, EditProjectStatus.RENDERING, Path.of(project.inputDirectory()), null);
|
||||||
|
Path work = store.projectDirectory(projectId).resolve("render-work");
|
||||||
|
createDirectory(work);
|
||||||
|
Map<String, ClipAnalysis> clips = analysis.clips().stream()
|
||||||
|
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
|
||||||
|
for (int index = 0; index < plan.decisions().size(); index++) {
|
||||||
|
EditDecision decision = plan.decisions().get(index);
|
||||||
|
Path output = work.resolve("segment_%04d.mp4".formatted(index + 1));
|
||||||
|
run(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> segmentCommand(String source, EditDecision decision, Path output) {
|
||||||
|
String filter = "scale=%d:%d:force_original_aspect_ratio=decrease,".formatted(
|
||||||
|
properties.getOutputWidth(), properties.getOutputHeight())
|
||||||
|
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted(
|
||||||
|
properties.getOutputWidth(), properties.getOutputHeight());
|
||||||
|
List<String> command = new ArrayList<>(List.of(
|
||||||
|
properties.getFfmpegBinary(), "-hide_banner", "-y",
|
||||||
|
"-ss", Double.toString(decision.sourceStartSeconds()),
|
||||||
|
"-to", Double.toString(decision.sourceEndSeconds()),
|
||||||
|
"-i", source, "-vf", filter,
|
||||||
|
"-r", Integer.toString(properties.getOutputFrameRate()),
|
||||||
|
"-map", "0:v:0", "-map", "0:a?",
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
|
||||||
|
"-c:a", "aac", "-b:a", properties.getAudioBitrate(),
|
||||||
|
"-ar", Integer.toString(properties.getAudioSampleRate()), output.toString()));
|
||||||
|
return List.copyOf(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createDirectory(Path directory) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(directory);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to create render working directory", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void run(List<String> command) {
|
||||||
|
try {
|
||||||
|
ProcessResult result = executor.execute(command);
|
||||||
|
if (result.exitCode() != 0) {
|
||||||
|
throw new IllegalStateException("FFmpeg edit render exited with code " + result.exitCode());
|
||||||
|
}
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IllegalStateException("Interrupted while rendering edit", ex);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new IllegalStateException("Unable to run FFmpeg edit render", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessResult execute(List<String> command) throws IOException, InterruptedException {
|
||||||
|
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
return new ProcessResult(process.waitFor(), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
record ProcessResult(int exitCode, String output) { }
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
interface ProcessExecutor {
|
||||||
|
ProcessResult execute(List<String> command) throws IOException, InterruptedException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
|
import org.example.videoclips.config.VideoClippingProperties;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
|
||||||
|
class FfmpegEditRendererTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildsNormalizedSegmentCommand() {
|
||||||
|
VideoClippingProperties properties = new VideoClippingProperties();
|
||||||
|
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
|
||||||
|
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
|
||||||
|
EditDecision decision = new EditDecision("clip-1", 1.25, 5.5, 0, 4.25,
|
||||||
|
"cut", "cut", 1, "grade", "reason");
|
||||||
|
|
||||||
|
var command = renderer.segmentCommand("source.mp4", decision, Path.of("segment.mp4"));
|
||||||
|
|
||||||
|
assertThat(command).containsSubsequence("-ss", "1.25", "-to", "5.5", "-i", "source.mp4");
|
||||||
|
assertThat(command).contains("scale=1920:1080:force_original_aspect_ratio=decrease,"
|
||||||
|
+ "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p");
|
||||||
|
assertThat(command).containsSubsequence("-c:v", "libx264", "-c:a", "aac");
|
||||||
|
assertThat(command).endsWith("segment.mp4");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue