From 3348ae8e5d1e9957c86c6407eda5ac7dc4126cd8 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Sat, 11 Jul 2026 00:54:52 +0200 Subject: [PATCH] Validate cinematic edit plans --- ...uction-cinematic-highlight-editing-plan.md | 2 +- .../api/dto/SaveEditPlanRequest.java | 13 ++++ .../example/videoclips/editing/EditPlan.java | 21 ++++++ .../editing/EditPlanInboxScanner.java | 2 +- .../videoclips/editing/EditPlanService.java | 3 +- .../videoclips/editing/EditPlanValidator.java | 64 +++++++++++++++++-- .../videoclips/editing/TextOverlay.java | 11 ++++ .../editing/EditDomainJsonTest.java | 4 ++ .../editing/EditPlanValidatorTest.java | 56 ++++++++++++++++ 9 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/example/videoclips/editing/TextOverlay.java diff --git a/docs/production-cinematic-highlight-editing-plan.md b/docs/production-cinematic-highlight-editing-plan.md index eaa32de..3fa98e6 100644 --- a/docs/production-cinematic-highlight-editing-plan.md +++ b/docs/production-cinematic-highlight-editing-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java b/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java index 0fffce3..12deff9 100644 --- a/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java +++ b/src/main/java/org/example/videoclips/api/dto/SaveEditPlanRequest.java @@ -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 decisions, @NotNull List audioCues, @NotNull List voiceover, + List overlays, @NotBlank String renderProfile, String summary ) { + public SaveEditPlanRequest( + String style, + double targetDurationSeconds, + List decisions, + List audioCues, + List voiceover, + String renderProfile, + String summary + ) { + this(style, targetDurationSeconds, decisions, audioCues, voiceover, List.of(), renderProfile, summary); + } } diff --git a/src/main/java/org/example/videoclips/editing/EditPlan.java b/src/main/java/org/example/videoclips/editing/EditPlan.java index 3c4333d..54f4c2f 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlan.java +++ b/src/main/java/org/example/videoclips/editing/EditPlan.java @@ -9,7 +9,28 @@ public record EditPlan( List decisions, List audioCues, List voiceover, + List 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 decisions, + List audioCues, + List voiceover, + String renderProfile, + String summary + ) { + this(projectId, style, targetDurationSeconds, decisions, audioCues, voiceover, List.of(), renderProfile, + summary); + } } diff --git a/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java b/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java index ecdcd1d..d19e7fb 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java +++ b/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java @@ -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) { diff --git a/src/main/java/org/example/videoclips/editing/EditPlanService.java b/src/main/java/org/example/videoclips/editing/EditPlanService.java index 9e4e163..7f4ca25 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlanService.java +++ b/src/main/java/org/example/videoclips/editing/EditPlanService.java @@ -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); diff --git a/src/main/java/org/example/videoclips/editing/EditPlanValidator.java b/src/main/java/org/example/videoclips/editing/EditPlanValidator.java index 6380d67..108120f 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlanValidator.java +++ b/src/main/java/org/example/videoclips/editing/EditPlanValidator.java @@ -17,9 +17,14 @@ import java.util.stream.Collectors; public class EditPlanValidator { private static final Set TRANSITIONS = Set.of("cut", "crossfade", "fade-in", "fade-out", "none"); + private static final Set OVERLAY_PLACEMENTS = Set.of( + "lower_left_safe", "lower_center_safe", "center_safe", "upper_left_safe", "upper_right_safe"); + private static final Set 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 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(".."); + } } diff --git a/src/main/java/org/example/videoclips/editing/TextOverlay.java b/src/main/java/org/example/videoclips/editing/TextOverlay.java new file mode 100644 index 0000000..9dfef7e --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/TextOverlay.java @@ -0,0 +1,11 @@ +package org.example.videoclips.editing; + +public record TextOverlay( + String text, + double timelineStartSeconds, + double timelineEndSeconds, + String placement, + String animation, + String reason +) { +} diff --git a/src/test/java/org/example/videoclips/editing/EditDomainJsonTest.java b/src/test/java/org/example/videoclips/editing/EditDomainJsonTest.java index 811b083..8a06875 100644 --- a/src/test/java/org/example/videoclips/editing/EditDomainJsonTest.java +++ b/src/test/java/org/example/videoclips/editing/EditDomainJsonTest.java @@ -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); } } diff --git a/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java b/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java index 0712d13..588fe9a 100644 --- a/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java +++ b/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java @@ -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(),