Validate cinematic edit plans

This commit is contained in:
JSLMPR 2026-07-11 00:54:52 +02:00
parent 5006dfd50b
commit 3348ae8e5d
9 changed files with 168 additions and 8 deletions

View File

@ -304,7 +304,7 @@ The plan should be strict JSON so a cheaper model or deterministic renderer can
- [x] Milestone 7: Implement content category classification for family vlog, food vlog, car vlog, and generic fallback.
- [x] Milestone 8: Implement category-specific highlight candidate scoring.
- [x] Milestone 9: Implement strict AI director prompts and JSON schemas for category-aware highlight edit plans.
- [ ] Milestone 10: Implement edit plan validation so impossible timestamps, missing assets, invalid overlays, and unsupported effects fail before render.
- [x] Milestone 10: Implement edit plan validation so impossible timestamps, missing assets, invalid overlays, and unsupported effects fail before render.
- [ ] Milestone 11: Implement asset provider interfaces for voiceover, music, SFX, fonts, and LUTs.
- [ ] Milestone 12: Add a local licensed asset library with category tags and deterministic asset selection.
- [ ] Milestone 13: Implement renderer v2 with transitions, speed ramps, dynamic crops, overlays, visual effects, audio ducking, loudness normalization, and render manifests.

View File

@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.example.videoclips.editing.AudioCue;
import org.example.videoclips.editing.EditDecision;
import org.example.videoclips.editing.TextOverlay;
import org.example.videoclips.editing.VoiceoverLine;
import java.util.List;
@ -16,7 +17,19 @@ public record SaveEditPlanRequest(
@NotEmpty List<EditDecision> decisions,
@NotNull List<AudioCue> audioCues,
@NotNull List<VoiceoverLine> voiceover,
List<TextOverlay> overlays,
@NotBlank String renderProfile,
String summary
) {
public SaveEditPlanRequest(
String style,
double targetDurationSeconds,
List<EditDecision> decisions,
List<AudioCue> audioCues,
List<VoiceoverLine> voiceover,
String renderProfile,
String summary
) {
this(style, targetDurationSeconds, decisions, audioCues, voiceover, List.of(), renderProfile, summary);
}
}

View File

@ -9,7 +9,28 @@ public record EditPlan(
List<EditDecision> decisions,
List<AudioCue> audioCues,
List<VoiceoverLine> voiceover,
List<TextOverlay> overlays,
String renderProfile,
String summary
) {
public EditPlan {
decisions = decisions == null ? List.of() : List.copyOf(decisions);
audioCues = audioCues == null ? List.of() : List.copyOf(audioCues);
voiceover = voiceover == null ? List.of() : List.copyOf(voiceover);
overlays = overlays == null ? List.of() : List.copyOf(overlays);
}
public EditPlan(
String projectId,
String style,
double targetDurationSeconds,
List<EditDecision> decisions,
List<AudioCue> audioCues,
List<VoiceoverLine> voiceover,
String renderProfile,
String summary
) {
this(projectId, style, targetDurationSeconds, decisions, audioCues, voiceover, List.of(), renderProfile,
summary);
}
}

View File

@ -147,7 +147,7 @@ public class EditPlanInboxScanner {
private EditPlan normalized(EditPlan plan) {
return new EditPlan(plan.projectId(), plan.style(), plan.targetDurationSeconds(),
List.copyOf(plan.decisions()), List.copyOf(plan.audioCues()), List.copyOf(plan.voiceover()),
plan.renderProfile(), plan.summary());
List.copyOf(plan.overlays()), plan.renderProfile(), plan.summary());
}
private void move(Path source, Path target) {

View File

@ -33,7 +33,8 @@ public class EditPlanService {
public EditPlan save(String projectId, SaveEditPlanRequest request) {
EditPlan plan = new EditPlan(projectId, request.style(), request.targetDurationSeconds(),
List.copyOf(request.decisions()), List.copyOf(request.audioCues()), List.copyOf(request.voiceover()),
request.renderProfile(), request.summary());
request.overlays() == null ? List.of() : List.copyOf(request.overlays()), request.renderProfile(),
request.summary());
validator.validate(projectId, plan);
store.writeJson(projectId, "edit-plan.json", plan);
EditProjectResponse project = projectService.getProject(projectId);

View File

@ -17,9 +17,14 @@ import java.util.stream.Collectors;
public class EditPlanValidator {
private static final Set<String> TRANSITIONS = Set.of("cut", "crossfade", "fade-in", "fade-out", "none");
private static final Set<String> OVERLAY_PLACEMENTS = Set.of(
"lower_left_safe", "lower_center_safe", "center_safe", "upper_left_safe", "upper_right_safe");
private static final Set<String> OVERLAY_ANIMATIONS = Set.of("fade_slide_up", "fade_in", "none");
private static final double EPSILON = 0.001;
private static final double TARGET_TOLERANCE_SECONDS = 1.0;
private static final Pattern ASSET_KEY = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
private static final Pattern RENDER_PROFILE = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,127}");
private static final int MAX_OVERLAY_TEXT_LENGTH = 80;
private final EditProjectStore store;
@ -34,8 +39,11 @@ public class EditPlanValidator {
reject("Edit plan projectId and style must match the project");
}
if (plan.decisions() == null || plan.decisions().isEmpty()
|| plan.audioCues() == null || plan.voiceover() == null) {
reject("Edit plan decisions, audioCues, and voiceover are required");
|| plan.audioCues() == null || plan.voiceover() == null || plan.overlays() == null) {
reject("Edit plan decisions, audioCues, voiceover, and overlays are required");
}
if (!isSafeToken(plan.renderProfile(), RENDER_PROFILE)) {
reject("Render profile must be a safe configured profile name");
}
Map<String, ClipAnalysis> clips = analysis.clips().stream()
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
@ -70,6 +78,8 @@ public class EditPlanValidator {
previousEnd = decision.timelineEndSeconds();
}
validateAudioCues(projectId, plan);
validateVoiceover(plan);
validateOverlays(plan);
if (previousEnd > project.targetDurationSeconds() + TARGET_TOLERANCE_SECONDS
|| Math.abs(plan.targetDurationSeconds() - project.targetDurationSeconds()) > EPSILON) {
reject("Edit plan duration exceeds or does not match the project target duration");
@ -84,14 +94,19 @@ public class EditPlanValidator {
|| cue.timelineEndSeconds() > plan.targetDurationSeconds() + EPSILON) {
reject("Audio cue has an invalid type or timeline range");
}
if (cue.gainDb() < -60 || cue.gainDb() > 12) {
reject("Audio cue gainDb must be between -60 and 12");
}
if (!Set.of("music", "sfx").contains(cue.type())) {
reject("Unsupported audio cue type: " + cue.type());
}
if (!"sfx".equals(cue.type())) {
if ("music".equals(cue.type())) {
if (cue.assetKey() == null || cue.assetKey().isBlank() || cue.assetKey().contains("..")) {
reject("Music assetKey must be a non-empty safe description or asset key");
}
continue;
}
if (cue.assetKey() == null || !ASSET_KEY.matcher(cue.assetKey()).matches()
|| cue.assetKey().contains("..")) {
if (!isSafeToken(cue.assetKey(), ASSET_KEY)) {
reject("SFX assetKey must be a safe plain file name");
}
Path asset = store.projectDirectory(projectId).resolve("audio/sfx")
@ -102,6 +117,41 @@ public class EditPlanValidator {
}
}
private void validateVoiceover(EditPlan plan) {
for (VoiceoverLine line : plan.voiceover()) {
if (line == null || line.text() == null || line.text().isBlank()
|| line.timelineStartSeconds() < 0
|| line.timelineEndSeconds() <= line.timelineStartSeconds()
|| line.timelineEndSeconds() > plan.targetDurationSeconds() + EPSILON) {
reject("Voiceover line has invalid text or timeline range");
}
if (line.text().length() > 240) {
reject("Voiceover line text must be 240 characters or fewer");
}
if (line.delivery() == null || line.delivery().isBlank()) {
reject("Voiceover delivery is required");
}
}
}
private void validateOverlays(EditPlan plan) {
for (TextOverlay overlay : plan.overlays()) {
if (overlay == null || overlay.text() == null || overlay.text().isBlank()
|| overlay.text().length() > MAX_OVERLAY_TEXT_LENGTH
|| overlay.timelineStartSeconds() < 0
|| overlay.timelineEndSeconds() <= overlay.timelineStartSeconds()
|| overlay.timelineEndSeconds() > plan.targetDurationSeconds() + EPSILON) {
reject("Text overlay has invalid text or timeline range");
}
if (!OVERLAY_PLACEMENTS.contains(overlay.placement())) {
reject("Unsupported text overlay placement: " + overlay.placement());
}
if (!OVERLAY_ANIMATIONS.contains(overlay.animation())) {
reject("Unsupported text overlay animation: " + overlay.animation());
}
}
}
private void validateTransition(String transition) {
if (transition == null || !TRANSITIONS.contains(transition)) {
reject("Unsupported transition: " + transition);
@ -111,4 +161,8 @@ public class EditPlanValidator {
private void reject(String message) {
throw new BadRequestException(message);
}
private boolean isSafeToken(String value, Pattern pattern) {
return value != null && pattern.matcher(value).matches() && !value.contains("..");
}
}

View File

@ -0,0 +1,11 @@
package org.example.videoclips.editing;
public record TextOverlay(
String text,
double timelineStartSeconds,
double timelineEndSeconds,
String placement,
String animation,
String reason
) {
}

View File

@ -84,6 +84,8 @@ class EditDomainJsonTest {
List.of(new AudioCue("music", "cinematic-driving-bed", 0.0, 60.0, -12.0, "build energy")),
List.of(new VoiceoverLine("This Porsche turns precision into emotion.", 3.0, 6.0,
"confident cinematic narrator")),
List.of(new TextOverlay("PRECISION IN MOTION", 7.0, 9.0, "lower_left_safe",
"fade_slide_up", "hero statement")),
"mp4-h264-aac-1080p",
"Cinematic Porsche promo edit."
);
@ -93,6 +95,8 @@ class EditDomainJsonTest {
assertThat(json).contains("\"targetDurationSeconds\"");
assertThat(json).contains("\"visualTreatment\"");
assertThat(json).contains("\"voiceover\"");
assertThat(json).contains("\"overlays\"");
assertThat(json).contains("PRECISION IN MOTION");
assertThat(objectMapper.readValue(json, EditPlan.class)).isEqualTo(plan);
}
}

View File

@ -60,6 +60,62 @@ class EditPlanValidatorTest {
rejects(plan(d));
}
@Test void rejectsUnsafeRenderProfile() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
rejects(new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), original.audioCues(), original.voiceover(), original.overlays(),
"../profile", original.summary()));
}
@Test void rejectsInvalidVoiceoverLine() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
rejects(new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), original.audioCues(),
List.of(new VoiceoverLine("", 1, 2, "cinematic")), original.overlays(),
original.renderProfile(), original.summary()));
}
@Test void rejectsMusicCueWithoutAssetKey() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
rejects(new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), List.of(new AudioCue("music", "", 0, 4, -12, "bed")),
original.voiceover(), original.overlays(), original.renderProfile(), original.summary()));
}
@Test void rejectsAudioCueGainOutsideSupportedRange() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
rejects(new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), List.of(new AudioCue("music", "warm-bed", 0, 4, 24, "too loud")),
original.voiceover(), original.overlays(), original.renderProfile(), original.summary()));
}
@Test void acceptsValidTextOverlay() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
EditPlan withOverlay = new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), original.audioCues(), original.voiceover(),
List.of(new TextOverlay("PRECISION IN MOTION", 1, 3, "lower_left_safe",
"fade_slide_up", "sets premium tone")),
original.renderProfile(), original.summary());
assertThat(validator.validate("project", withOverlay)).isEqualTo(withOverlay);
}
@Test void rejectsUnsupportedTextOverlayPlacement() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
rejects(new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), original.audioCues(), original.voiceover(),
List.of(new TextOverlay("TEXT", 1, 3, "middle-ish", "fade_in", "bad placement")),
original.renderProfile(), original.summary()));
}
@Test void rejectsUnsupportedTextOverlayAnimation() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
rejects(new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),
original.decisions(), original.audioCues(), original.voiceover(),
List.of(new TextOverlay("TEXT", 1, 3, "center_safe", "spin_3d", "bad animation")),
original.renderProfile(), original.summary()));
}
@Test void rejectsMissingSfxFile() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
EditPlan withSfx = new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),