forked from jsl/video_editing_poc
Validate cinematic edit plans
This commit is contained in:
parent
5006dfd50b
commit
3348ae8e5d
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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("..");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
public record TextOverlay(
|
||||
String text,
|
||||
double timelineStartSeconds,
|
||||
double timelineEndSeconds,
|
||||
String placement,
|
||||
String animation,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue