Add local AI director prompt generation

This commit is contained in:
JSLMPR 2026-07-10 15:38:16 +02:00
parent 8a1dd0ac3a
commit f8d0bc874e
3 changed files with 190 additions and 1 deletions

View File

@ -792,7 +792,7 @@ It writes the script only and requires an imported audio file.
9. [x] Add optional proxy generation. 9. [x] Add optional proxy generation.
10. [x] Add `analysis.json` writing and reading. 10. [x] Add `analysis.json` writing and reading.
11. [x] Add storyboard prompt generation from `analysis.json`. 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. 13. [ ] Add local director source-folder scheduler.
14. [ ] Add project `inbox/` plan pickup. 14. [ ] Add project `inbox/` plan pickup.
15. [ ] Add strict JSON edit plan schema and validation. 15. [ ] Add strict JSON edit plan schema and validation.

View File

@ -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) {
}
}

View File

@ -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());
}
}