Add strict edit plan validation

This commit is contained in:
JSLMPR 2026-07-10 15:55:59 +02:00
parent a3317cfb43
commit 4979e4da25
5 changed files with 162 additions and 14 deletions

View File

@ -795,7 +795,7 @@ It writes the script only and requires an imported audio file.
12. [x] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
13. [x] Add local director source-folder scheduler.
14. [x] Add project `inbox/` plan pickup.
15. [ ] Add strict JSON edit plan schema and validation.
15. [x] Add strict JSON edit plan schema and validation.
16. [ ] Add `PUT /v1/edit-projects/{projectId}/plan`.
17. [ ] Add simple segment rendering without transitions.
18. [ ] Add concat-based final timeline rendering.

View File

@ -34,6 +34,7 @@ public class EditPlanInboxScanner {
private final ObjectMapper objectMapper;
private final EditProjectStore store;
private final EditProjectService projectService;
private final EditPlanValidator validator;
private final Optional<EditRenderer> renderer;
private final AtomicBoolean scanning = new AtomicBoolean();
@ -43,9 +44,10 @@ public class EditPlanInboxScanner {
ObjectMapper objectMapper,
EditProjectStore store,
EditProjectService projectService,
EditPlanValidator validator,
ObjectProvider<EditRenderer> rendererProvider
) {
this(properties, objectMapper, store, projectService, rendererProvider.getIfAvailable());
this(properties, objectMapper, store, projectService, validator, rendererProvider.getIfAvailable());
}
EditPlanInboxScanner(
@ -53,6 +55,7 @@ public class EditPlanInboxScanner {
ObjectMapper objectMapper,
EditProjectStore store,
EditProjectService projectService,
EditPlanValidator validator,
EditRenderer renderer
) {
this.projectRoot = Path.of(properties.getEditing().getProjectDirectory());
@ -61,6 +64,7 @@ public class EditPlanInboxScanner {
this.objectMapper = objectMapper;
this.store = store;
this.projectService = projectService;
this.validator = validator;
this.renderer = Optional.ofNullable(renderer);
}
@ -101,7 +105,7 @@ public class EditPlanInboxScanner {
EditPlan plan;
try {
plan = objectMapper.readValue(inboxPlan.toFile(), EditPlan.class);
validateStructure(projectId, plan);
validator.validate(projectId, plan);
} catch (RuntimeException | IOException ex) {
log.warn("event=edit_plan_rejected project_id={} error_type={} message={}",
projectId, ex.getClass().getSimpleName(), ex.getMessage());
@ -123,16 +127,6 @@ public class EditPlanInboxScanner {
}
}
private void validateStructure(String projectId, EditPlan plan) {
if (plan.projectId() == null || !projectId.equals(plan.projectId())) {
throw new IllegalArgumentException("Edit plan projectId must match its project inbox");
}
if (plan.style() == null || plan.style().isBlank() || plan.targetDurationSeconds() <= 0
|| plan.decisions() == null || plan.audioCues() == null || plan.voiceover() == null) {
throw new IllegalArgumentException("Edit plan is missing required fields");
}
}
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()),

View File

@ -0,0 +1,77 @@
package org.example.videoclips.editing;
import org.example.videoclips.application.BadRequestException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class EditPlanValidator {
private static final Set<String> TRANSITIONS = Set.of("cut", "crossfade", "fade-in", "fade-out", "none");
private static final double EPSILON = 0.001;
private static final double TARGET_TOLERANCE_SECONDS = 1.0;
private final EditProjectStore store;
public EditPlanValidator(EditProjectStore store) {
this.store = store;
}
public EditPlan validate(String projectId, EditPlan plan) {
EditProject project = store.readJson(projectId, "project.json", EditProject.class);
EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class);
if (!projectId.equals(plan.projectId()) || !project.style().equals(plan.style())) {
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");
}
Map<String, ClipAnalysis> clips = analysis.clips().stream()
.collect(Collectors.toMap(ClipAnalysis::clipId, Function.identity()));
double previousEnd = 0;
for (EditDecision decision : plan.decisions()) {
ClipAnalysis clip = clips.get(decision.clipId());
if (clip == null) {
reject("Unknown clipId: " + decision.clipId());
}
if (decision.sourceStartSeconds() < 0
|| decision.sourceEndSeconds() <= decision.sourceStartSeconds()
|| decision.sourceEndSeconds() > clip.durationSeconds() + EPSILON) {
reject("Invalid source range for clipId: " + decision.clipId());
}
if (decision.timelineStartSeconds() < 0
|| decision.timelineEndSeconds() <= decision.timelineStartSeconds()
|| decision.timelineStartSeconds() + EPSILON < previousEnd) {
reject("Invalid or overlapping timeline range for clipId: " + decision.clipId());
}
if (decision.playbackSpeed() < 0.25 || decision.playbackSpeed() > 4.0) {
reject("Playback speed must be between 0.25 and 4.0");
}
validateTransition(decision.transitionIn());
validateTransition(decision.transitionOut());
previousEnd = decision.timelineEndSeconds();
}
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");
}
return plan;
}
private void validateTransition(String transition) {
if (transition == null || !TRANSITIONS.contains(transition)) {
reject("Unsupported transition: " + transition);
}
}
private void reject(String message) {
throw new BadRequestException(message);
}
}

View File

@ -40,6 +40,7 @@ class EditPlanInboxScannerTest {
Path input = Files.createDirectories(tempDir.resolve("input"));
projectService.createProject(new CreateEditProjectRequest(
"Porsche Edit", input.toString(), 60, "cinematic-porsche-promo", true, true, true));
store.writeJson("porsche-edit", "analysis.json", analysis());
}
@Test
@ -104,6 +105,13 @@ class EditPlanInboxScannerTest {
private EditPlanInboxScanner scanner(boolean autoRender, EditRenderer renderer) {
properties.getEditing().getLocalDirector().setAutoRenderWhenPlanAppears(autoRender);
return new EditPlanInboxScanner(properties, objectMapper, store, projectService, renderer);
return new EditPlanInboxScanner(properties, objectMapper, store, projectService,
new EditPlanValidator(store), renderer);
}
private EditProjectAnalysis analysis() {
return new EditProjectAnalysis("porsche-edit", List.of(new ClipAnalysis(
"clip_00001", "clip.mp4", 8.0, "h264", "aac", 1920, 1080, 30,
List.of(), null, null, 0, 0, 0)), List.of(), Instant.parse("2026-07-10T10:00:00Z"));
}
}

View File

@ -0,0 +1,69 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.application.BadRequestException;
import org.example.videoclips.config.VideoClippingProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EditPlanValidatorTest {
@TempDir Path tempDir;
private EditPlanValidator validator;
@BeforeEach
void setUp() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.toString());
EditProjectStore store = new FileSystemEditProjectStore(properties,
new ObjectMapper().findAndRegisterModules());
store.createProject("project");
Instant now = Instant.parse("2026-07-10T10:00:00Z");
store.writeJson("project", "project.json", new EditProject("project", "Project", EditProjectStatus.ANALYZED,
"input", "output", 10, "cinematic-porsche-promo", true, true, true, now, now, null));
store.writeJson("project", "analysis.json", new EditProjectAnalysis("project", List.of(
new ClipAnalysis("clip-1", "1.mp4", 8, "h264", "aac", 1920, 1080, 30,
List.of(), null, null, 0, 0, 0)), List.of(), now));
validator = new EditPlanValidator(store);
}
@Test void acceptsValidPlan() { assertThat(validator.validate("project", plan(decision("clip-1", 0, 4, 0, 4, "cut")))).isNotNull(); }
@Test void rejectsUnknownClip() { rejects(plan(decision("missing", 0, 4, 0, 4, "cut"))); }
@Test void rejectsOutOfRangeSource() { rejects(plan(decision("clip-1", 0, 9, 0, 4, "cut"))); }
@Test void rejectsOverlappingTimeline() {
rejects(plan(decision("clip-1", 0, 4, 0, 4, "cut"), decision("clip-1", 4, 8, 3, 7, "cut")));
}
@Test void rejectsUnsupportedTransition() { rejects(plan(decision("clip-1", 0, 4, 0, 4, "wipe"))); }
@Test void rejectsInvalidPlaybackSpeed() {
EditDecision d = new EditDecision("clip-1", 0, 4, 0, 4, "cut", "cut", 5, "grade", "reason");
rejects(plan(d));
}
private void rejects(EditPlan plan) {
assertThatThrownBy(() -> validator.validate("project", plan)).isInstanceOf(BadRequestException.class);
}
private EditPlan plan(EditDecision... decisions) {
return new EditPlan("project", "cinematic-porsche-promo", 10, List.of(decisions), List.of(), List.of(),
"mp4-h264-aac-1080p", "summary");
}
private EditDecision decision(String clip, double sourceStart, double sourceEnd,
double timelineStart, double timelineEnd, String transition) {
return new EditDecision(clip, sourceStart, sourceEnd, timelineStart, timelineEnd,
transition, "cut", 1, "grade", "reason");
}
}