diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 27320a1..a5640ee 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -792,7 +792,7 @@ It writes the script only and requires an imported audio file. 9. [x] Add optional proxy generation. 10. [x] Add `analysis.json` writing and reading. 11. [x] Add storyboard prompt generation from `analysis.json`. -12. [ ] 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. [ ] Add local director source-folder scheduler. 14. [ ] Add project `inbox/` plan pickup. 15. [ ] Add strict JSON edit plan schema and validation. diff --git a/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java b/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java new file mode 100644 index 0000000..93e7abd --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java @@ -0,0 +1,96 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.config.VideoClippingProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Component +@ConditionalOnProperty(name = "video-clipping.editing.local-director.enabled", havingValue = "true", + matchIfMissing = true) +public class AiDirectorPromptGenerator { + + private static final String README_FILE_NAME = "director-readme.md"; + + private final EditProjectStore store; + private final StoryboardPromptGenerator storyboardPromptGenerator; + private final String promptFileName; + private final String expectedPlanFileName; + + public AiDirectorPromptGenerator( + EditProjectStore store, + StoryboardPromptGenerator storyboardPromptGenerator, + VideoClippingProperties properties + ) { + this.store = store; + this.storyboardPromptGenerator = storyboardPromptGenerator; + this.promptFileName = safeFileName(properties.getEditing().getLocalDirector().getDirectorPromptFileName()); + this.expectedPlanFileName = safeFileName(properties.getEditing().getLocalDirector().getExpectedPlanFileName()); + } + + public DirectorPromptFiles generate(String projectId) { + EditProject project = store.readJson(projectId, "project.json", EditProject.class); + EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class); + Path projectDirectory = store.projectDirectory(projectId); + Path promptPath = projectDirectory.resolve(promptFileName); + Path readmePath = projectDirectory.resolve(README_FILE_NAME); + try { + Files.writeString(promptPath, directorPrompt(project, analysis)); + Files.writeString(readmePath, readme()); + } catch (IOException ex) { + throw new IllegalStateException("Unable to write AI director files for project: " + projectId, ex); + } + return new DirectorPromptFiles(promptPath.toString(), readmePath.toString()); + } + + private String directorPrompt(EditProject project, EditProjectAnalysis analysis) { + return """ + # Local AI Director Assignment + + You are the director for a premium cinematic Porsche promo. + + Work only with artifacts in this project folder: + - Read `analysis.json` for authoritative clip metadata. + - Inspect every relevant image in `contact-sheets/` and `thumbnails/` before choosing shots. + - Use `proxies/` only when motion needs closer inspection. + + Complete this task: + 1. Judge visual quality, composition, motion, continuity, and story value from the generated artifacts. + 2. Select the strongest shots and direct a clear opening hook, rising-energy middle, and final hero shot. + 3. Follow the strict JSON schema and constraints in the brief below. + 4. Write the final JSON object to `inbox/%s`. + + Do not render video. Do not run FFmpeg. Do not modify source clips or generated analysis artifacts. + Do not invent clip IDs, source ranges, durations, or media paths. + Your task is complete only when `inbox/%s` contains valid JSON with no Markdown fences. + + %s + """.formatted(expectedPlanFileName, expectedPlanFileName, + storyboardPromptGenerator.prompt(project, analysis)); + } + + private String readme() { + return """ + # Local Director Run + + Open `%s` in Codex, Claude, or another filesystem-capable AI agent. + Allow it to inspect `analysis.json`, `contact-sheets/`, `thumbnails/`, and optional `proxies/`. + The agent must write the finished plan to `inbox/%s` and must not render the video. + After validating the plan, call the render endpoint or enable automatic rendering. + """.formatted(promptFileName, expectedPlanFileName); + } + + private static String safeFileName(String fileName) { + if (fileName == null || fileName.isBlank() || !Path.of(fileName).getFileName().toString().equals(fileName) + || fileName.contains("..")) { + throw new IllegalArgumentException("Local director file name must be a plain file name: " + fileName); + } + return fileName; + } + + public record DirectorPromptFiles(String promptPath, String readmePath) { + } +} diff --git a/src/test/java/org/example/videoclips/editing/AiDirectorPromptGeneratorTest.java b/src/test/java/org/example/videoclips/editing/AiDirectorPromptGeneratorTest.java new file mode 100644 index 0000000..9864ad8 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/AiDirectorPromptGeneratorTest.java @@ -0,0 +1,93 @@ +package org.example.videoclips.editing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.example.videoclips.config.VideoClippingProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +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.assertThatIllegalArgumentException; + +class AiDirectorPromptGeneratorTest { + + @TempDir + Path tempDir; + + @Test + void writesSelfContainedDirectorPromptAndReadme() throws Exception { + VideoClippingProperties properties = properties(); + FileSystemEditProjectStore store = store(properties); + writeProjectFiles(store); + + var files = new AiDirectorPromptGenerator(store, new StoryboardPromptGenerator(store), properties) + .generate("porsche-edit"); + + String prompt = Files.readString(Path.of(files.promptPath())); + assertThat(prompt) + .contains("`analysis.json`") + .contains("`contact-sheets/`") + .contains("`thumbnails/`") + .contains("`inbox/edit-plan.json`") + .contains("Do not render video") + .contains("clipId: clip_00001") + .contains("\"decisions\""); + assertThat(Files.readString(Path.of(files.readmePath()))) + .contains("Codex, Claude") + .contains("`inbox/edit-plan.json`"); + } + + @Test + void honorsSafeConfiguredFileNames() throws Exception { + VideoClippingProperties properties = properties(); + properties.getEditing().getLocalDirector().setDirectorPromptFileName("director-task.md"); + properties.getEditing().getLocalDirector().setExpectedPlanFileName("approved-plan.json"); + FileSystemEditProjectStore store = store(properties); + writeProjectFiles(store); + + var files = new AiDirectorPromptGenerator(store, new StoryboardPromptGenerator(store), properties) + .generate("porsche-edit"); + + assertThat(files.promptPath()).endsWith("director-task.md"); + assertThat(Files.readString(Path.of(files.promptPath()))).contains("`inbox/approved-plan.json`"); + assertThat(Files.readString(Path.of(files.readmePath()))).contains("`director-task.md`"); + } + + @Test + void rejectsConfiguredPathTraversal() { + VideoClippingProperties properties = properties(); + properties.getEditing().getLocalDirector().setDirectorPromptFileName("../prompt.md"); + FileSystemEditProjectStore store = store(properties); + + assertThatIllegalArgumentException().isThrownBy(() -> + new AiDirectorPromptGenerator(store, new StoryboardPromptGenerator(store), properties)); + } + + private void writeProjectFiles(FileSystemEditProjectStore store) { + store.createProject("porsche-edit"); + Instant now = Instant.parse("2026-07-10T10:00:00Z"); + store.writeJson("porsche-edit", "project.json", new EditProject( + "porsche-edit", "Porsche Edit", EditProjectStatus.ANALYZED, "input", "output", 60, + "cinematic-porsche-promo", true, true, true, now, now, null)); + store.writeJson("porsche-edit", "analysis.json", new EditProjectAnalysis( + "porsche-edit", List.of(new ClipAnalysis( + "clip_00001", "/absolute/source.mp4", 8.0, "h264", "aac", 1920, 1080, 30.0, + List.of("/absolute/thumbnails/clip_00001_0001.jpg"), + "/absolute/contact-sheets/clip_00001.jpg", null, 0.8, 0.7, 0.9)), + List.of(), now)); + } + + private VideoClippingProperties properties() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString()); + return properties; + } + + private FileSystemEditProjectStore store(VideoClippingProperties properties) { + return new FileSystemEditProjectStore(properties, new ObjectMapper().findAndRegisterModules()); + } +}