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 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 8: Implement category-specific highlight candidate scoring.
- [x] Milestone 9: Implement strict AI director prompts and JSON schemas for category-aware highlight edit plans. - [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 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 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. - [ ] 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 jakarta.validation.constraints.Positive;
import org.example.videoclips.editing.AudioCue; import org.example.videoclips.editing.AudioCue;
import org.example.videoclips.editing.EditDecision; import org.example.videoclips.editing.EditDecision;
import org.example.videoclips.editing.TextOverlay;
import org.example.videoclips.editing.VoiceoverLine; import org.example.videoclips.editing.VoiceoverLine;
import java.util.List; import java.util.List;
@ -16,7 +17,19 @@ public record SaveEditPlanRequest(
@NotEmpty List<EditDecision> decisions, @NotEmpty List<EditDecision> decisions,
@NotNull List<AudioCue> audioCues, @NotNull List<AudioCue> audioCues,
@NotNull List<VoiceoverLine> voiceover, @NotNull List<VoiceoverLine> voiceover,
List<TextOverlay> overlays,
@NotBlank String renderProfile, @NotBlank String renderProfile,
String summary 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<EditDecision> decisions,
List<AudioCue> audioCues, List<AudioCue> audioCues,
List<VoiceoverLine> voiceover, List<VoiceoverLine> voiceover,
List<TextOverlay> overlays,
String renderProfile, String renderProfile,
String summary 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) { private EditPlan normalized(EditPlan plan) {
return new EditPlan(plan.projectId(), plan.style(), plan.targetDurationSeconds(), return new EditPlan(plan.projectId(), plan.style(), plan.targetDurationSeconds(),
List.copyOf(plan.decisions()), List.copyOf(plan.audioCues()), List.copyOf(plan.voiceover()), 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) { private void move(Path source, Path target) {

View File

@ -33,7 +33,8 @@ public class EditPlanService {
public EditPlan save(String projectId, SaveEditPlanRequest request) { public EditPlan save(String projectId, SaveEditPlanRequest request) {
EditPlan plan = new EditPlan(projectId, request.style(), request.targetDurationSeconds(), EditPlan plan = new EditPlan(projectId, request.style(), request.targetDurationSeconds(),
List.copyOf(request.decisions()), List.copyOf(request.audioCues()), List.copyOf(request.voiceover()), 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); validator.validate(projectId, plan);
store.writeJson(projectId, "edit-plan.json", plan); store.writeJson(projectId, "edit-plan.json", plan);
EditProjectResponse project = projectService.getProject(projectId); EditProjectResponse project = projectService.getProject(projectId);

View File

@ -17,9 +17,14 @@ import java.util.stream.Collectors;
public class EditPlanValidator { public class EditPlanValidator {
private static final Set<String> TRANSITIONS = Set.of("cut", "crossfade", "fade-in", "fade-out", "none"); 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 EPSILON = 0.001;
private static final double TARGET_TOLERANCE_SECONDS = 1.0; 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 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; private final EditProjectStore store;
@ -34,8 +39,11 @@ public class EditPlanValidator {
reject("Edit plan projectId and style must match the project"); reject("Edit plan projectId and style must match the project");
} }
if (plan.decisions() == null || plan.decisions().isEmpty() if (plan.decisions() == null || plan.decisions().isEmpty()
|| plan.audioCues() == null || plan.voiceover() == null) { || plan.audioCues() == null || plan.voiceover() == null || plan.overlays() == null) {
reject("Edit plan decisions, audioCues, and voiceover are required"); 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() Map<String, ClipAnalysis> clips = analysis.clips().stream()
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity())); .collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
@ -70,6 +78,8 @@ public class EditPlanValidator {
previousEnd = decision.timelineEndSeconds(); previousEnd = decision.timelineEndSeconds();
} }
validateAudioCues(projectId, plan); validateAudioCues(projectId, plan);
validateVoiceover(plan);
validateOverlays(plan);
if (previousEnd > project.targetDurationSeconds() + TARGET_TOLERANCE_SECONDS if (previousEnd > project.targetDurationSeconds() + TARGET_TOLERANCE_SECONDS
|| Math.abs(plan.targetDurationSeconds() - project.targetDurationSeconds()) > EPSILON) { || Math.abs(plan.targetDurationSeconds() - project.targetDurationSeconds()) > EPSILON) {
reject("Edit plan duration exceeds or does not match the project target duration"); 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) { || cue.timelineEndSeconds() > plan.targetDurationSeconds() + EPSILON) {
reject("Audio cue has an invalid type or timeline range"); 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())) { if (!Set.of("music", "sfx").contains(cue.type())) {
reject("Unsupported audio cue type: " + 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; continue;
} }
if (cue.assetKey() == null || !ASSET_KEY.matcher(cue.assetKey()).matches() if (!isSafeToken(cue.assetKey(), ASSET_KEY)) {
|| cue.assetKey().contains("..")) {
reject("SFX assetKey must be a safe plain file name"); reject("SFX assetKey must be a safe plain file name");
} }
Path asset = store.projectDirectory(projectId).resolve("audio/sfx") 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) { private void validateTransition(String transition) {
if (transition == null || !TRANSITIONS.contains(transition)) { if (transition == null || !TRANSITIONS.contains(transition)) {
reject("Unsupported transition: " + transition); reject("Unsupported transition: " + transition);
@ -111,4 +161,8 @@ public class EditPlanValidator {
private void reject(String message) { private void reject(String message) {
throw new BadRequestException(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 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, List.of(new VoiceoverLine("This Porsche turns precision into emotion.", 3.0, 6.0,
"confident cinematic narrator")), "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", "mp4-h264-aac-1080p",
"Cinematic Porsche promo edit." "Cinematic Porsche promo edit."
); );
@ -93,6 +95,8 @@ class EditDomainJsonTest {
assertThat(json).contains("\"targetDurationSeconds\""); assertThat(json).contains("\"targetDurationSeconds\"");
assertThat(json).contains("\"visualTreatment\""); assertThat(json).contains("\"visualTreatment\"");
assertThat(json).contains("\"voiceover\""); assertThat(json).contains("\"voiceover\"");
assertThat(json).contains("\"overlays\"");
assertThat(json).contains("PRECISION IN MOTION");
assertThat(objectMapper.readValue(json, EditPlan.class)).isEqualTo(plan); assertThat(objectMapper.readValue(json, EditPlan.class)).isEqualTo(plan);
} }
} }

View File

@ -60,6 +60,62 @@ class EditPlanValidatorTest {
rejects(plan(d)); 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() { @Test void rejectsMissingSfxFile() {
EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut")); EditPlan original = plan(decision("clip-1", 0, 4, 0, 4, "cut"));
EditPlan withSfx = new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(), EditPlan withSfx = new EditPlan(original.projectId(), original.style(), original.targetDurationSeconds(),