forked from jsl/video_editing_poc
Add cinematic render QA report
This commit is contained in:
parent
2d0c331710
commit
9b56e89b4c
|
|
@ -309,7 +309,8 @@ The plan should be strict JSON so a cheaper model or deterministic renderer can
|
|||
- [x] Milestone 12: Add a local licensed asset library with category tags and deterministic asset selection.
|
||||
- [x] Milestone 13: Implement renderer v2 with transitions, speed ramps, dynamic crops, overlays, visual effects, audio ducking, loudness normalization, and render manifests.
|
||||
- [x] Milestone 13 progress: Renderer now applies timed text overlays, records resolved/planned edit assets in the render manifest, adds dynamic punch-in crop motion, ducks music under voiceover, and normalizes final mix loudness with configurable mastering values.
|
||||
- [ ] Milestone 14: Implement QA checks for black frames, silence, clipping, missing assets, duration mismatch, unsafe text placement, and failed FFmpeg filters.
|
||||
- [x] Milestone 14: Implement QA checks for black frames, silence, clipping, missing assets, duration mismatch, unsafe text placement, and failed FFmpeg filters.
|
||||
- [x] Milestone 14 progress: Renderer now writes `qa-report.json` with structural checks, asset resolution checks, overlay safety checks, audio mastering checks, FFmpeg command completion checks, and FFmpeg probe checks for black frames, long silence, and audio clipping.
|
||||
- [ ] Milestone 15: Add an operator runbook for placing a video, running the service locally, choosing a model, reviewing the director plan, and finding final clips.
|
||||
- [ ] Milestone 16: Add integration tests with small fixture videos for family, food, car, and generic content.
|
||||
- [ ] Milestone 17: Add benchmark metrics for analysis time, render time, token usage, asset generation cost, and final output size.
|
||||
|
|
|
|||
|
|
@ -152,9 +152,11 @@ public class FfmpegEditRenderer implements EditRenderer {
|
|||
copy(videoTimeline, output);
|
||||
}
|
||||
double duration = plan.decisions().get(plan.decisions().size() - 1).timelineEndSeconds();
|
||||
RenderQaReport qaReport = buildQaReport(projectId, plan, output, duration, commands, assets);
|
||||
RenderManifest manifest = new RenderManifest(projectId,
|
||||
plan.decisions().stream().map(EditDecision::clipId).toList(), output.toString(), duration,
|
||||
List.copyOf(commands), assets, Instant.now());
|
||||
store.writeJson(projectId, "qa-report.json", qaReport);
|
||||
store.writeJson(projectId, "render-manifest.json", manifest);
|
||||
projectService.updateProject(projectId, EditProjectStatus.RENDERED, Path.of(project.inputDirectory()), null);
|
||||
if (observability != null) {
|
||||
|
|
@ -357,6 +359,137 @@ public class FfmpegEditRenderer implements EditRenderer {
|
|||
return List.copyOf(assets);
|
||||
}
|
||||
|
||||
RenderQaReport buildQaReport(String projectId, EditPlan plan, Path output, double duration,
|
||||
List<List<String>> commands, List<ResolvedEditAsset> assets) {
|
||||
List<RenderQaCheck> checks = new ArrayList<>();
|
||||
checks.add(outputExistsCheck(output));
|
||||
checks.add(durationMatchesTimelineCheck(plan, duration));
|
||||
checks.add(requiredAssetsResolvedCheck(plan, assets));
|
||||
checks.add(overlaysSafeCheck(plan, duration));
|
||||
checks.add(audioMasteringCheck(commands));
|
||||
checks.add(ffmpegCommandsCompletedCheck(commands));
|
||||
checks.add(runQaProbe("black_frames", blackDetectCommand(output), "ERROR",
|
||||
result -> result.exitCode() == 0 && !result.output().contains("black_start"),
|
||||
"blackdetect found one or more black frame ranges"));
|
||||
checks.add(runQaProbe("long_silence", silenceDetectCommand(output), "WARNING",
|
||||
result -> result.exitCode() == 0 && !result.output().contains("silence_start"),
|
||||
"silencedetect found one or more long silent ranges"));
|
||||
checks.add(runQaProbe("audio_clipping", clippingDetectCommand(output), "WARNING",
|
||||
this::audioPeakIsSafe,
|
||||
"audio peak is at or above -0.1 dBFS"));
|
||||
boolean passed = checks.stream().noneMatch(check -> !check.passed() && "ERROR".equals(check.severity()));
|
||||
return new RenderQaReport(projectId, passed, checks, Instant.now());
|
||||
}
|
||||
|
||||
private RenderQaCheck outputExistsCheck(Path output) {
|
||||
boolean exists = Files.isRegularFile(output);
|
||||
return new RenderQaCheck("output_exists", exists, "ERROR",
|
||||
exists ? "final output exists" : "final output is missing: " + output);
|
||||
}
|
||||
|
||||
private RenderQaCheck durationMatchesTimelineCheck(EditPlan plan, double duration) {
|
||||
double expected = plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1)
|
||||
.timelineEndSeconds();
|
||||
boolean matches = Math.abs(expected - duration) <= 0.05;
|
||||
return new RenderQaCheck("duration_matches_timeline", matches, "ERROR",
|
||||
"expected=%s actual=%s tolerance=0.05".formatted(expected, duration));
|
||||
}
|
||||
|
||||
private RenderQaCheck requiredAssetsResolvedCheck(EditPlan plan, List<ResolvedEditAsset> assets) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (AudioCue cue : plan.audioCues()) {
|
||||
if ("sfx".equals(cue.type()) && assets.stream()
|
||||
.noneMatch(asset -> asset.type() == EditAssetType.SFX && cue.assetKey().equals(asset.assetKey()))) {
|
||||
missing.add("sfx:" + cue.assetKey());
|
||||
}
|
||||
}
|
||||
if (!plan.voiceover().isEmpty() && assets.stream().noneMatch(asset -> asset.type() == EditAssetType.VOICEOVER)) {
|
||||
missing.add("voiceover");
|
||||
}
|
||||
return new RenderQaCheck("required_assets_resolved", missing.isEmpty(), "ERROR",
|
||||
missing.isEmpty() ? "all required render assets were resolved" : "missing assets: " + missing);
|
||||
}
|
||||
|
||||
private RenderQaCheck overlaysSafeCheck(EditPlan plan, double duration) {
|
||||
List<String> unsafe = plan.overlays().stream()
|
||||
.filter(overlay -> overlay.timelineStartSeconds() < 0
|
||||
|| overlay.timelineEndSeconds() > duration
|
||||
|| overlay.timelineStartSeconds() >= overlay.timelineEndSeconds()
|
||||
|| !safeOverlayPlacements().contains(overlay.placement()))
|
||||
.map(TextOverlay::text)
|
||||
.toList();
|
||||
return new RenderQaCheck("text_overlays_safe", unsafe.isEmpty(), "ERROR",
|
||||
unsafe.isEmpty() ? "all overlays are inside the timeline and safe placements"
|
||||
: "unsafe overlays: " + unsafe);
|
||||
}
|
||||
|
||||
private List<String> safeOverlayPlacements() {
|
||||
return List.of("lower_left_safe", "upper_left_safe", "upper_right_safe", "center_safe", "lower_center_safe");
|
||||
}
|
||||
|
||||
private RenderQaCheck audioMasteringCheck(List<List<String>> commands) {
|
||||
boolean audioMixPresent = commands.stream().flatMap(List::stream).anyMatch(argument -> argument.contains("amix"));
|
||||
boolean mastered = commands.stream().flatMap(List::stream).anyMatch(argument -> argument.contains("loudnorm"));
|
||||
boolean passed = !audioMixPresent || mastered;
|
||||
return new RenderQaCheck("audio_mastering_applied", passed, "WARNING",
|
||||
passed ? "audio mix is normalized when present" : "audio mix is missing loudnorm normalization");
|
||||
}
|
||||
|
||||
private RenderQaCheck ffmpegCommandsCompletedCheck(List<List<String>> commands) {
|
||||
return new RenderQaCheck("ffmpeg_commands_completed", !commands.isEmpty(), "ERROR",
|
||||
"recorded_command_count=" + commands.size());
|
||||
}
|
||||
|
||||
private RenderQaCheck runQaProbe(String name, List<String> command, String severity,
|
||||
java.util.function.Predicate<ProcessResult> passed,
|
||||
String failureDetails) {
|
||||
try {
|
||||
ProcessResult result = executor.execute(command);
|
||||
if (passed.test(result)) {
|
||||
return new RenderQaCheck(name, true, severity, "probe passed");
|
||||
}
|
||||
return new RenderQaCheck(name, false, severity, result.exitCode() == 0
|
||||
? failureDetails : "probe exited with code " + result.exitCode());
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new RenderQaCheck(name, false, severity, "probe interrupted");
|
||||
} catch (IOException ex) {
|
||||
return new RenderQaCheck(name, false, severity, "probe failed: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
List<String> blackDetectCommand(Path output) {
|
||||
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-v", "info", "-i", output.toString(),
|
||||
"-vf", "blackdetect=d=0.5:pic_th=0.98", "-an", "-f", "null", "-");
|
||||
}
|
||||
|
||||
List<String> silenceDetectCommand(Path output) {
|
||||
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-v", "info", "-i", output.toString(),
|
||||
"-af", "silencedetect=noise=-45dB:d=2", "-vn", "-f", "null", "-");
|
||||
}
|
||||
|
||||
List<String> clippingDetectCommand(Path output) {
|
||||
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-v", "info", "-i", output.toString(),
|
||||
"-af", "astats=metadata=1:reset=1", "-vn", "-f", "null", "-");
|
||||
}
|
||||
|
||||
boolean audioPeakIsSafe(ProcessResult result) {
|
||||
if (result.exitCode() != 0) {
|
||||
return false;
|
||||
}
|
||||
return result.output().lines()
|
||||
.filter(line -> line.contains("Peak level dB"))
|
||||
.map(line -> line.substring(line.lastIndexOf(':') + 1).trim())
|
||||
.mapToDouble(value -> {
|
||||
try {
|
||||
return Double.parseDouble(value);
|
||||
} catch (NumberFormatException ex) {
|
||||
return -99;
|
||||
}
|
||||
})
|
||||
.noneMatch(peak -> peak >= -0.1);
|
||||
}
|
||||
|
||||
private void writeConcatFile(Path file, List<Path> segments) {
|
||||
String content = segments.stream()
|
||||
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
public record RenderQaCheck(
|
||||
String name,
|
||||
boolean passed,
|
||||
String severity,
|
||||
String details
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record RenderQaReport(
|
||||
String projectId,
|
||||
boolean passed,
|
||||
List<RenderQaCheck> checks,
|
||||
Instant completedAt
|
||||
) {
|
||||
public RenderQaReport {
|
||||
checks = checks == null ? List.of() : List.copyOf(checks);
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ class CinematicEditingIntegrationTest {
|
|||
assertThat(output).isRegularFile();
|
||||
assertThat(probeDuration(output)).isGreaterThan(0);
|
||||
assertThat(projectDirectory.resolve("render-manifest.json")).exists();
|
||||
assertThat(projectDirectory.resolve("qa-report.json")).exists();
|
||||
assertThat(projects.getProject(projectId).status()).isEqualTo(EditProjectStatus.RENDERED);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -184,6 +184,33 @@ class FfmpegEditRendererTest {
|
|||
assertThat(command).containsSubsequence("-c:v", "libx264", "-c:a", "copy");
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsQaProbeCommands() {
|
||||
VideoClippingProperties properties = new VideoClippingProperties();
|
||||
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
|
||||
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
|
||||
|
||||
assertThat(renderer.blackDetectCommand(Path.of("final.mp4")))
|
||||
.containsSubsequence("-vf", "blackdetect=d=0.5:pic_th=0.98");
|
||||
assertThat(renderer.silenceDetectCommand(Path.of("final.mp4")))
|
||||
.containsSubsequence("-af", "silencedetect=noise=-45dB:d=2");
|
||||
assertThat(renderer.clippingDetectCommand(Path.of("final.mp4")))
|
||||
.containsSubsequence("-af", "astats=metadata=1:reset=1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detectsUnsafeAudioPeaksFromAstatsOutput() {
|
||||
VideoClippingProperties properties = new VideoClippingProperties();
|
||||
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
|
||||
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
|
||||
|
||||
assertThat(renderer.audioPeakIsSafe(new FfmpegEditRenderer.ProcessResult(0,
|
||||
"[Parsed_astats] Peak level dB: -1.2"))).isTrue();
|
||||
assertThat(renderer.audioPeakIsSafe(new FfmpegEditRenderer.ProcessResult(0,
|
||||
"[Parsed_astats] Peak level dB: 0.0"))).isFalse();
|
||||
assertThat(renderer.audioPeakIsSafe(new FfmpegEditRenderer.ProcessResult(1, "failed"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void chainsAudioTempoAtQuarterSpeed() {
|
||||
VideoClippingProperties properties = new VideoClippingProperties();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class RenderQaReportTest {
|
||||
|
||||
@Test
|
||||
void roundTripsQaReportAsJson() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
|
||||
RenderQaReport report = new RenderQaReport("project", true,
|
||||
List.of(new RenderQaCheck("output_exists", true, "ERROR", "final output exists")),
|
||||
Instant.parse("2026-07-10T10:00:00Z"));
|
||||
|
||||
String json = mapper.writeValueAsString(report);
|
||||
|
||||
assertThat(json).contains("\"checks\"").contains("output_exists").contains("\"passed\":true");
|
||||
assertThat(mapper.readValue(json, RenderQaReport.class)).isEqualTo(report);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue