From 21e5cef45d9ff1abbb81a1d77ea89e4df04b6759 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 16:08:08 +0200 Subject: [PATCH] Add normalized edit segment rendering --- docs/cinematic-video-editing-service-plan.md | 2 +- .../editing/FfmpegEditRenderer.java | 113 ++++++++++++++++++ .../editing/FfmpegEditRendererTest.java | 29 +++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java create mode 100644 src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 1b04a8e..409cda6 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -797,7 +797,7 @@ It writes the script only and requires an imported audio file. 14. [x] Add project `inbox/` plan pickup. 15. [x] Add strict JSON edit plan schema and validation. 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. 19. [ ] Add music and voiceover audio mixing. 20. [ ] Add render manifest with command metadata and output details. diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java new file mode 100644 index 0000000..064b62b --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -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 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 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 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 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 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 command) throws IOException, InterruptedException; + } +} diff --git a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java new file mode 100644 index 0000000..995c454 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java @@ -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"); + } +}