diff --git a/README.md b/README.md index b69a85d..246060d 100644 --- a/README.md +++ b/README.md @@ -84,8 +84,9 @@ Local models used (each with a provenance sidecar under `models/`): - **Licensing:** MusicGen (CC-BY-NC), AudioLDM2 (CC-BY-NC-SA) and YOLOv8 (AGPL) are **non-commercial/copyleft**. Commercial use requires swapping in commercially-licensed models/assets. - **Not production-hardened:** no Spring Security/authN, no container/K8s/deployment manifests, REST persistence - defaults to in-memory, `POST /v1/edit-projects/{projectId}:render` has no approval gate, and no-egress - operation is not yet certified. + defaults to in-memory, and no-egress operation is not yet certified. `POST /v1/edit-projects/{projectId}:render` + now requires an `approved.flag` in the project directory, but that is a basic presence gate — not yet an + authenticated, digest-bound authorization. - **VLM quality:** on distant/small subjects the small local VLM is only weakly discriminative; a stronger model or closer framing improves Tier-2 selection. - A director can only cut what was filmed — it cannot show a moment the camera never captured. diff --git a/src/main/java/org/example/videoclips/api/EditProjectController.java b/src/main/java/org/example/videoclips/api/EditProjectController.java index eab7b00..83646c3 100644 --- a/src/main/java/org/example/videoclips/api/EditProjectController.java +++ b/src/main/java/org/example/videoclips/api/EditProjectController.java @@ -3,8 +3,10 @@ package org.example.videoclips.api; import jakarta.validation.Valid; import org.example.videoclips.api.dto.CreateEditProjectRequest; import org.example.videoclips.api.dto.SaveEditPlanRequest; +import org.example.videoclips.config.VideoClippingProperties; import org.example.videoclips.editing.EditPlanService; import org.example.videoclips.editing.EditProjectService; +import org.example.videoclips.editing.EditProjectStore; import org.example.videoclips.editing.EditRenderer; import org.example.videoclips.editing.StoryboardPromptGenerator; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -17,25 +19,37 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.file.Files; +import java.nio.file.Path; @RestController @RequestMapping("/v1/edit-projects") @ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true) public class EditProjectController { + static final String APPROVAL_FILE_NAME = "approved.flag"; + private final EditProjectService editProjectService; private final StoryboardPromptGenerator storyboardPromptGenerator; private final EditPlanService editPlanService; private final EditRenderer editRenderer; + private final EditProjectStore editProjectStore; + private final VideoClippingProperties.Editing properties; public EditProjectController(EditProjectService editProjectService, StoryboardPromptGenerator storyboardPromptGenerator, EditPlanService editPlanService, - EditRenderer editRenderer) { + EditRenderer editRenderer, + EditProjectStore editProjectStore, + VideoClippingProperties properties) { this.editProjectService = editProjectService; this.storyboardPromptGenerator = storyboardPromptGenerator; this.editPlanService = editPlanService; this.editRenderer = editRenderer; + this.editProjectStore = editProjectStore; + this.properties = properties.getEditing(); } @PostMapping @@ -61,7 +75,26 @@ public class EditProjectController { @PostMapping("/{projectId}:render") public Object render(@PathVariable String projectId) { + requireRenderApproval(projectId); editRenderer.render(projectId); return editProjectService.getProject(projectId); } + + /** + * Basic approval gate: refuse to render unless an {@code approved.flag} artifact exists in the project + * directory. This closes the previously unauthenticated render trigger; it is NOT yet an authenticated, + * digest-bound authorization (that remains a production-hardening item). Disable via + * {@code video-clipping.editing.require-render-approval=false}. + */ + private void requireRenderApproval(String projectId) { + if (!properties.isRequireRenderApproval()) { + return; + } + Path approval = editProjectStore.projectDirectory(projectId).resolve(APPROVAL_FILE_NAME); + if (!Files.isRegularFile(approval)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Render requires approval: place an '" + APPROVAL_FILE_NAME + + "' file in the project directory before rendering"); + } + } } diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index 2497633..02a831d 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -484,6 +484,13 @@ public class VideoClippingProperties { public static class Editing { private boolean enabled = true; + /** + * Require an approval artifact ({@code approved.flag}) in the project directory before the REST + * render endpoint will render. Defaults on. NOTE: this is a basic presence gate, not yet an + * authenticated, digest-bound authorization — that remains a production-hardening item. + */ + private boolean requireRenderApproval = true; + private String projectDirectory = "./output/edit-projects"; private String highlightProjectDirectory = "./output/highlight-projects"; @@ -569,6 +576,14 @@ public class VideoClippingProperties { this.enabled = enabled; } + public boolean isRequireRenderApproval() { + return requireRenderApproval; + } + + public void setRequireRenderApproval(boolean requireRenderApproval) { + this.requireRenderApproval = requireRenderApproval; + } + public String getProjectDirectory() { return projectDirectory; } diff --git a/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java b/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java index db34fb3..bb9a877 100644 --- a/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java +++ b/src/test/java/org/example/videoclips/api/EditProjectControllerTest.java @@ -180,4 +180,11 @@ class EditProjectControllerTest { .andExpect(status().isNotFound()) .andExpect(jsonPath("$.type").value("https://api.example.com/problems/not-found")); } + + @Test + void rejectsRenderWithoutApproval() throws Exception { + // No approved.flag exists for this project -> the render endpoint must refuse (409), not render. + mockMvc.perform(post("/v1/edit-projects/unapproved-" + UUID.randomUUID() + ":render")) + .andExpect(status().isConflict()); + } } diff --git a/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java b/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java index 8c428f1..712addb 100644 --- a/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java +++ b/src/test/java/org/example/videoclips/api/EditProjectRenderControllerTest.java @@ -1,9 +1,11 @@ package org.example.videoclips.api; import org.example.videoclips.api.dto.EditProjectResponse; +import org.example.videoclips.config.VideoClippingProperties; import org.example.videoclips.editing.EditPlanService; import org.example.videoclips.editing.EditProjectService; import org.example.videoclips.editing.EditProjectStatus; +import org.example.videoclips.editing.EditProjectStore; import org.example.videoclips.editing.EditRenderer; import org.example.videoclips.editing.StoryboardPromptGenerator; import org.junit.jupiter.api.Test; @@ -25,8 +27,10 @@ class EditProjectRenderControllerTest { "input", "output", 60, "cinematic-porsche-promo", true, true, true, Instant.EPOCH, Instant.EPOCH, null); when(projects.getProject("project")).thenReturn(rendered); + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setRequireRenderApproval(false); // this test verifies delegation, not the gate EditProjectController controller = new EditProjectController(projects, mock(StoryboardPromptGenerator.class), - mock(EditPlanService.class), renderer); + mock(EditPlanService.class), renderer, mock(EditProjectStore.class), properties); Object response = controller.render("project");