Add normalized edit segment rendering

This commit is contained in:
JSLMPR 2026-07-10 16:08:08 +02:00
parent 2b4c5992a6
commit 21e5cef45d
3 changed files with 143 additions and 1 deletions

View File

@ -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.

View File

@ -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;
}
}

View File

@ -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");
}
}