Add strict edit plan validation
This commit is contained in:
parent
a3317cfb43
commit
4979e4da25
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue