Add cinematic storyboard prompt generation

This commit is contained in:
JSLMPR 2026-07-10 15:35:15 +02:00
parent 0ccfd99147
commit 8a1dd0ac3a
10 changed files with 273 additions and 2 deletions

View File

@ -791,7 +791,7 @@ It writes the script only and requires an imported audio file.
8. [x] Add contact sheet generation. 8. [x] Add contact sheet generation.
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. [ ] 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. [ ] 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.

View File

@ -3,6 +3,7 @@ package org.example.videoclips.api;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.example.videoclips.api.dto.CreateEditProjectRequest; import org.example.videoclips.api.dto.CreateEditProjectRequest;
import org.example.videoclips.editing.EditProjectService; import org.example.videoclips.editing.EditProjectService;
import org.example.videoclips.editing.StoryboardPromptGenerator;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@ -19,9 +20,12 @@ import org.springframework.web.bind.annotation.RestController;
public class EditProjectController { public class EditProjectController {
private final EditProjectService editProjectService; private final EditProjectService editProjectService;
private final StoryboardPromptGenerator storyboardPromptGenerator;
public EditProjectController(EditProjectService editProjectService) { public EditProjectController(EditProjectService editProjectService,
StoryboardPromptGenerator storyboardPromptGenerator) {
this.editProjectService = editProjectService; this.editProjectService = editProjectService;
this.storyboardPromptGenerator = storyboardPromptGenerator;
} }
@PostMapping @PostMapping
@ -34,4 +38,9 @@ public class EditProjectController {
public Object getProject(@PathVariable String projectId) { public Object getProject(@PathVariable String projectId) {
return editProjectService.getProject(projectId); return editProjectService.getProject(projectId);
} }
@PostMapping("/{projectId}:storyboard-prompt")
public Object generateStoryboardPrompt(@PathVariable String projectId) {
return storyboardPromptGenerator.generate(projectId);
}
} }

View File

@ -10,6 +10,11 @@ public record EditProjectResponse(
EditProjectStatus status, EditProjectStatus status,
String inputDirectory, String inputDirectory,
String outputDirectory, String outputDirectory,
int targetDurationSeconds,
String style,
boolean voiceoverEnabled,
boolean musicEnabled,
boolean soundEffectsEnabled,
Instant createdAt, Instant createdAt,
Instant updatedAt, Instant updatedAt,
String failureReason String failureReason

View File

@ -0,0 +1,7 @@
package org.example.videoclips.api.dto;
public record StoryboardPromptResponse(
String projectId,
String promptPath
) {
}

View File

@ -8,6 +8,11 @@ public record EditProject(
EditProjectStatus status, EditProjectStatus status,
String inputDirectory, String inputDirectory,
String outputDirectory, String outputDirectory,
int targetDurationSeconds,
String style,
boolean voiceoverEnabled,
boolean musicEnabled,
boolean soundEffectsEnabled,
Instant createdAt, Instant createdAt,
Instant updatedAt, Instant updatedAt,
String failureReason String failureReason

View File

@ -46,6 +46,11 @@ public class EditProjectService {
EditProjectStatus.CREATED, EditProjectStatus.CREATED,
inputDirectory.toString(), inputDirectory.toString(),
outputDirectory.toString(), outputDirectory.toString(),
request.targetDurationSeconds(),
request.style(),
Boolean.TRUE.equals(request.voiceoverEnabled()),
Boolean.TRUE.equals(request.musicEnabled()),
Boolean.TRUE.equals(request.soundEffectsEnabled()),
now, now,
now, now,
null null
@ -85,6 +90,11 @@ public class EditProjectService {
project.status(), project.status(),
project.inputDirectory(), project.inputDirectory(),
project.outputDirectory(), project.outputDirectory(),
project.targetDurationSeconds(),
project.style(),
project.voiceoverEnabled(),
project.musicEnabled(),
project.soundEffectsEnabled(),
project.createdAt(), project.createdAt(),
project.updatedAt(), project.updatedAt(),
project.failureReason() project.failureReason()

View File

@ -0,0 +1,123 @@
package org.example.videoclips.editing;
import org.example.videoclips.api.dto.StoryboardPromptResponse;
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;
import java.util.List;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class StoryboardPromptGenerator {
private final EditProjectStore store;
public StoryboardPromptGenerator(EditProjectStore store) {
this.store = store;
}
public StoryboardPromptResponse generate(String projectId) {
EditProject project = store.readJson(projectId, "project.json", EditProject.class);
EditProjectAnalysis analysis = store.readJson(projectId, "analysis.json", EditProjectAnalysis.class);
Path promptPath = store.projectDirectory(projectId).resolve("storyboard-prompt.md");
try {
Files.writeString(promptPath, prompt(project, analysis));
} catch (IOException ex) {
throw new IllegalStateException("Unable to write storyboard prompt for project: " + projectId, ex);
}
return new StoryboardPromptResponse(projectId, promptPath.toString());
}
String prompt(EditProject project, EditProjectAnalysis analysis) {
StringBuilder clips = new StringBuilder();
for (ClipAnalysis clip : analysis.clips()) {
clips.append("- clipId: ").append(clip.clipId()).append('\n')
.append(" durationSeconds: ").append(clip.durationSeconds()).append('\n')
.append(" video: ").append(clip.width()).append('x').append(clip.height())
.append(" ").append(clip.frameRate()).append("fps ").append(clip.videoCodec()).append('\n')
.append(" audioCodec: ").append(value(clip.audioCodec())).append('\n')
.append(" thumbnails: ").append(references("thumbnails", clip.thumbnails())).append('\n')
.append(" contactSheet: ").append(reference("contact-sheets", clip.contactSheet())).append('\n')
.append(" proxy: ").append(reference("proxies", clip.proxyPath())).append('\n');
}
return """
# Cinematic Storyboard Director Brief
Create one cinematic edit plan for project `%s`.
Constraints:
- Style: `%s`
- Target duration: %d seconds
- Voiceover enabled: %s
- Music enabled: %s
- Sound effects enabled: %s
- Use only the clip IDs and source ranges listed below.
- Keep every source range within its clip duration.
- Timeline ranges must be chronological and must not overlap.
- Prefer an intentional opening hook, escalating middle, and memorable final hero shot.
- Return strict JSON only. Do not use Markdown fences or add commentary.
Available clips and project-relative visual references:
%s
Required JSON object shape:
{
"projectId": "%s",
"style": "%s",
"targetDurationSeconds": %d,
"decisions": [{
"clipId": "clip_00001",
"sourceStartSeconds": 0.0,
"sourceEndSeconds": 2.5,
"timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5,
"transitionIn": "cut",
"transitionOut": "crossfade",
"playbackSpeed": 1.0,
"visualTreatment": "cinematic grade",
"reason": "story purpose"
}],
"audioCues": [{
"type": "music|sfx",
"asset": "descriptive asset name",
"timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5,
"gainDb": -12.0,
"reason": "story purpose"
}],
"voiceover": [{
"text": "narration",
"timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5,
"delivery": "confident cinematic narrator"
}],
"renderProfile": "mp4-h264-aac-1080p",
"summary": "short creative rationale"
}
""".formatted(
project.id(), project.style(), project.targetDurationSeconds(),
project.voiceoverEnabled(), project.musicEnabled(), project.soundEffectsEnabled(),
clips.toString().stripTrailing(), project.id(), project.style(), project.targetDurationSeconds());
}
private String references(String directory, List<String> paths) {
if (paths == null || paths.isEmpty()) {
return "[]";
}
return paths.stream().map(path -> reference(directory, path)).toList().toString();
}
private String reference(String directory, String path) {
if (path == null || path.isBlank()) {
return "none";
}
return directory + "/" + Path.of(path).getFileName();
}
private String value(String value) {
return value == null || value.isBlank() ? "none" : value;
}
}

View File

@ -15,6 +15,7 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(properties = { @SpringBootTest(properties = {
"video-clipping.editing.project-directory=target/edit-project-controller-test/projects" "video-clipping.editing.project-directory=target/edit-project-controller-test/projects"
@ -47,6 +48,8 @@ class EditProjectControllerTest {
.andExpect(jsonPath("$.projectId").exists()) .andExpect(jsonPath("$.projectId").exists())
.andExpect(jsonPath("$.status").value("CREATED")) .andExpect(jsonPath("$.status").value("CREATED"))
.andExpect(jsonPath("$.inputDirectory").value(inputDirectory.toString())) .andExpect(jsonPath("$.inputDirectory").value(inputDirectory.toString()))
.andExpect(jsonPath("$.targetDurationSeconds").value(60))
.andExpect(jsonPath("$.style").value("cinematic-porsche-promo"))
.andReturn() .andReturn()
.getResponse() .getResponse()
.getContentAsString(); .getContentAsString();
@ -61,6 +64,41 @@ class EditProjectControllerTest {
+ projectId)); + projectId));
} }
@Test
void generatesStoryboardPromptFromPersistedProjectAndAnalysis() throws Exception {
Path inputDirectory = Files.createDirectories(Path.of("target/edit-project-controller-test/input-"
+ UUID.randomUUID()));
String response = mockMvc.perform(post("/v1/edit-projects")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"name": "Storyboard %s",
"inputDirectory": "%s",
"targetDurationSeconds": 45,
"style": "cinematic-porsche-promo"
}
""".formatted(UUID.randomUUID(), inputDirectory)))
.andReturn().getResponse().getContentAsString();
String projectId = JsonTestHelper.read(response, "$.projectId");
Path projectDirectory = Path.of("target/edit-project-controller-test/projects", projectId);
Files.writeString(projectDirectory.resolve("analysis.json"), """
{
"projectId": "%s",
"clips": [],
"errors": [],
"createdAt": "2026-07-10T10:00:00Z"
}
""".formatted(projectId));
mockMvc.perform(post("/v1/edit-projects/{projectId}:storyboard-prompt", projectId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.projectId").value(projectId))
.andExpect(jsonPath("$.promptPath").value(projectDirectory.resolve("storyboard-prompt.md").toString()));
assertThat(Files.readString(projectDirectory.resolve("storyboard-prompt.md")))
.contains("Target duration: 45 seconds");
}
@Test @Test
void rejectsMissingInputDirectory() throws Exception { void rejectsMissingInputDirectory() throws Exception {
mockMvc.perform(post("/v1/edit-projects") mockMvc.perform(post("/v1/edit-projects")

View File

@ -20,6 +20,11 @@ class EditDomainJsonTest {
EditProjectStatus.WAITING_FOR_DIRECTOR, EditProjectStatus.WAITING_FOR_DIRECTOR,
"./input/editing/processed/porsche-session-001", "./input/editing/processed/porsche-session-001",
"./output/edit-projects/porsche-session-001", "./output/edit-projects/porsche-session-001",
60,
"cinematic-porsche-promo",
true,
true,
true,
Instant.parse("2026-07-10T10:00:00Z"), Instant.parse("2026-07-10T10:00:00Z"),
Instant.parse("2026-07-10T10:01:00Z"), Instant.parse("2026-07-10T10:01:00Z"),
null null

View File

@ -0,0 +1,69 @@
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;
class StoryboardPromptGeneratorTest {
@TempDir
Path tempDir;
@Test
void writesPasteReadyStrictJsonPromptWithoutAbsoluteMediaPaths() throws Exception {
FileSystemEditProjectStore store = store();
store.createProject("porsche-edit");
store.writeJson("porsche-edit", "project.json", project());
store.writeJson("porsche-edit", "analysis.json", analysis());
var response = new StoryboardPromptGenerator(store).generate("porsche-edit");
String prompt = Files.readString(Path.of(response.promptPath()));
assertThat(prompt)
.contains("clipId: clip_00001")
.contains("durationSeconds: 8.0")
.contains("Target duration: 60 seconds")
.contains("Style: `cinematic-porsche-promo`")
.contains("\"decisions\"")
.contains("\"audioCues\"")
.contains("\"voiceover\"")
.contains("Return strict JSON only")
.contains("thumbnails/clip_00001_0001.jpg")
.contains("contact-sheets/clip_00001.jpg")
.doesNotContain(tempDir.toAbsolutePath().toString())
.doesNotContain("/private/source/video.mp4");
assertThat(response.projectId()).isEqualTo("porsche-edit");
assertThat(response.promptPath()).endsWith("storyboard-prompt.md");
}
private FileSystemEditProjectStore store() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString());
return new FileSystemEditProjectStore(properties, new ObjectMapper().findAndRegisterModules());
}
private EditProject project() {
Instant now = Instant.parse("2026-07-10T10:00:00Z");
return new EditProject("porsche-edit", "Porsche Edit", EditProjectStatus.ANALYZED,
"/private/source", tempDir.resolve("projects/porsche-edit").toString(), 60,
"cinematic-porsche-promo", true, true, true, now, now, null);
}
private EditProjectAnalysis analysis() {
ClipAnalysis clip = new ClipAnalysis("clip_00001", "/private/source/video.mp4", 8.0,
"h264", "aac", 1920, 1080, 30.0,
List.of(tempDir.resolve("projects/porsche-edit/thumbnails/clip_00001_0001.jpg").toString()),
tempDir.resolve("projects/porsche-edit/contact-sheets/clip_00001.jpg").toString(),
tempDir.resolve("projects/porsche-edit/proxies/clip_00001.mp4").toString(), 0.8, 0.7, 0.9);
return new EditProjectAnalysis("porsche-edit", List.of(clip), List.of(),
Instant.parse("2026-07-10T10:01:00Z"));
}
}