Add local AI director prompt generation
This commit is contained in:
parent
8a1dd0ac3a
commit
f8d0bc874e
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue