forked from jsl/video_editing_poc
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.
|
12. [x] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
|
||||||
13. [x] Add local director source-folder scheduler.
|
13. [x] Add local director source-folder scheduler.
|
||||||
14. [x] Add project `inbox/` plan pickup.
|
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`.
|
16. [ ] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
||||||
17. [ ] Add simple segment rendering without transitions.
|
17. [ ] Add simple segment rendering without transitions.
|
||||||
18. [ ] Add concat-based final timeline rendering.
|
18. [ ] Add concat-based final timeline rendering.
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ public class EditPlanInboxScanner {
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final EditProjectStore store;
|
private final EditProjectStore store;
|
||||||
private final EditProjectService projectService;
|
private final EditProjectService projectService;
|
||||||
|
private final EditPlanValidator validator;
|
||||||
private final Optional<EditRenderer> renderer;
|
private final Optional<EditRenderer> renderer;
|
||||||
private final AtomicBoolean scanning = new AtomicBoolean();
|
private final AtomicBoolean scanning = new AtomicBoolean();
|
||||||
|
|
||||||
|
|
@ -43,9 +44,10 @@ public class EditPlanInboxScanner {
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
EditProjectStore store,
|
EditProjectStore store,
|
||||||
EditProjectService projectService,
|
EditProjectService projectService,
|
||||||
|
EditPlanValidator validator,
|
||||||
ObjectProvider<EditRenderer> rendererProvider
|
ObjectProvider<EditRenderer> rendererProvider
|
||||||
) {
|
) {
|
||||||
this(properties, objectMapper, store, projectService, rendererProvider.getIfAvailable());
|
this(properties, objectMapper, store, projectService, validator, rendererProvider.getIfAvailable());
|
||||||
}
|
}
|
||||||
|
|
||||||
EditPlanInboxScanner(
|
EditPlanInboxScanner(
|
||||||
|
|
@ -53,6 +55,7 @@ public class EditPlanInboxScanner {
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
EditProjectStore store,
|
EditProjectStore store,
|
||||||
EditProjectService projectService,
|
EditProjectService projectService,
|
||||||
|
EditPlanValidator validator,
|
||||||
EditRenderer renderer
|
EditRenderer renderer
|
||||||
) {
|
) {
|
||||||
this.projectRoot = Path.of(properties.getEditing().getProjectDirectory());
|
this.projectRoot = Path.of(properties.getEditing().getProjectDirectory());
|
||||||
|
|
@ -61,6 +64,7 @@ public class EditPlanInboxScanner {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.projectService = projectService;
|
this.projectService = projectService;
|
||||||
|
this.validator = validator;
|
||||||
this.renderer = Optional.ofNullable(renderer);
|
this.renderer = Optional.ofNullable(renderer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +105,7 @@ public class EditPlanInboxScanner {
|
||||||
EditPlan plan;
|
EditPlan plan;
|
||||||
try {
|
try {
|
||||||
plan = objectMapper.readValue(inboxPlan.toFile(), EditPlan.class);
|
plan = objectMapper.readValue(inboxPlan.toFile(), EditPlan.class);
|
||||||
validateStructure(projectId, plan);
|
validator.validate(projectId, plan);
|
||||||
} catch (RuntimeException | IOException ex) {
|
} catch (RuntimeException | IOException ex) {
|
||||||
log.warn("event=edit_plan_rejected project_id={} error_type={} message={}",
|
log.warn("event=edit_plan_rejected project_id={} error_type={} message={}",
|
||||||
projectId, ex.getClass().getSimpleName(), ex.getMessage());
|
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) {
|
private EditPlan normalized(EditPlan plan) {
|
||||||
return new EditPlan(plan.projectId(), plan.style(), plan.targetDurationSeconds(),
|
return new EditPlan(plan.projectId(), plan.style(), plan.targetDurationSeconds(),
|
||||||
List.copyOf(plan.decisions()), List.copyOf(plan.audioCues()), List.copyOf(plan.voiceover()),
|
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"));
|
Path input = Files.createDirectories(tempDir.resolve("input"));
|
||||||
projectService.createProject(new CreateEditProjectRequest(
|
projectService.createProject(new CreateEditProjectRequest(
|
||||||
"Porsche Edit", input.toString(), 60, "cinematic-porsche-promo", true, true, true));
|
"Porsche Edit", input.toString(), 60, "cinematic-porsche-promo", true, true, true));
|
||||||
|
store.writeJson("porsche-edit", "analysis.json", analysis());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -104,6 +105,13 @@ class EditPlanInboxScannerTest {
|
||||||
|
|
||||||
private EditPlanInboxScanner scanner(boolean autoRender, EditRenderer renderer) {
|
private EditPlanInboxScanner scanner(boolean autoRender, EditRenderer renderer) {
|
||||||
properties.getEditing().getLocalDirector().setAutoRenderWhenPlanAppears(autoRender);
|
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