diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index be1777a..6cc58af 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java b/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java index 9dd8d01..5bf2195 100644 --- a/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java +++ b/src/main/java/org/example/videoclips/editing/EditPlanInboxScanner.java @@ -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 renderer; private final AtomicBoolean scanning = new AtomicBoolean(); @@ -43,9 +44,10 @@ public class EditPlanInboxScanner { ObjectMapper objectMapper, EditProjectStore store, EditProjectService projectService, + EditPlanValidator validator, ObjectProvider 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()), diff --git a/src/main/java/org/example/videoclips/editing/EditPlanValidator.java b/src/main/java/org/example/videoclips/editing/EditPlanValidator.java new file mode 100644 index 0000000..591a0b8 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditPlanValidator.java @@ -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 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 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); + } +} diff --git a/src/test/java/org/example/videoclips/editing/EditPlanInboxScannerTest.java b/src/test/java/org/example/videoclips/editing/EditPlanInboxScannerTest.java index ddf3fce..ce1354e 100644 --- a/src/test/java/org/example/videoclips/editing/EditPlanInboxScannerTest.java +++ b/src/test/java/org/example/videoclips/editing/EditPlanInboxScannerTest.java @@ -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")); } } diff --git a/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java b/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java new file mode 100644 index 0000000..aaad6b6 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/EditPlanValidatorTest.java @@ -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"); + } +}