Add cinematic storyboard prompt generation
This commit is contained in:
parent
0ccfd99147
commit
8a1dd0ac3a
|
|
@ -791,7 +791,7 @@ It writes the script only and requires an imported audio file.
|
|||
8. [x] Add contact sheet generation.
|
||||
9. [x] Add optional proxy generation.
|
||||
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.
|
||||
13. [ ] Add local director source-folder scheduler.
|
||||
14. [ ] Add project `inbox/` plan pickup.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package org.example.videoclips.api;
|
|||
import jakarta.validation.Valid;
|
||||
import org.example.videoclips.api.dto.CreateEditProjectRequest;
|
||||
import org.example.videoclips.editing.EditProjectService;
|
||||
import org.example.videoclips.editing.StoryboardPromptGenerator;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
|
@ -19,9 +20,12 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
public class EditProjectController {
|
||||
|
||||
private final EditProjectService editProjectService;
|
||||
private final StoryboardPromptGenerator storyboardPromptGenerator;
|
||||
|
||||
public EditProjectController(EditProjectService editProjectService) {
|
||||
public EditProjectController(EditProjectService editProjectService,
|
||||
StoryboardPromptGenerator storyboardPromptGenerator) {
|
||||
this.editProjectService = editProjectService;
|
||||
this.storyboardPromptGenerator = storyboardPromptGenerator;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
|
|
@ -34,4 +38,9 @@ public class EditProjectController {
|
|||
public Object getProject(@PathVariable String projectId) {
|
||||
return editProjectService.getProject(projectId);
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}:storyboard-prompt")
|
||||
public Object generateStoryboardPrompt(@PathVariable String projectId) {
|
||||
return storyboardPromptGenerator.generate(projectId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ public record EditProjectResponse(
|
|||
EditProjectStatus status,
|
||||
String inputDirectory,
|
||||
String outputDirectory,
|
||||
int targetDurationSeconds,
|
||||
String style,
|
||||
boolean voiceoverEnabled,
|
||||
boolean musicEnabled,
|
||||
boolean soundEffectsEnabled,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
String failureReason
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package org.example.videoclips.api.dto;
|
||||
|
||||
public record StoryboardPromptResponse(
|
||||
String projectId,
|
||||
String promptPath
|
||||
) {
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ public record EditProject(
|
|||
EditProjectStatus status,
|
||||
String inputDirectory,
|
||||
String outputDirectory,
|
||||
int targetDurationSeconds,
|
||||
String style,
|
||||
boolean voiceoverEnabled,
|
||||
boolean musicEnabled,
|
||||
boolean soundEffectsEnabled,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
String failureReason
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ public class EditProjectService {
|
|||
EditProjectStatus.CREATED,
|
||||
inputDirectory.toString(),
|
||||
outputDirectory.toString(),
|
||||
request.targetDurationSeconds(),
|
||||
request.style(),
|
||||
Boolean.TRUE.equals(request.voiceoverEnabled()),
|
||||
Boolean.TRUE.equals(request.musicEnabled()),
|
||||
Boolean.TRUE.equals(request.soundEffectsEnabled()),
|
||||
now,
|
||||
now,
|
||||
null
|
||||
|
|
@ -85,6 +90,11 @@ public class EditProjectService {
|
|||
project.status(),
|
||||
project.inputDirectory(),
|
||||
project.outputDirectory(),
|
||||
project.targetDurationSeconds(),
|
||||
project.style(),
|
||||
project.voiceoverEnabled(),
|
||||
project.musicEnabled(),
|
||||
project.soundEffectsEnabled(),
|
||||
project.createdAt(),
|
||||
project.updatedAt(),
|
||||
project.failureReason()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@SpringBootTest(properties = {
|
||||
"video-clipping.editing.project-directory=target/edit-project-controller-test/projects"
|
||||
|
|
@ -47,6 +48,8 @@ class EditProjectControllerTest {
|
|||
.andExpect(jsonPath("$.projectId").exists())
|
||||
.andExpect(jsonPath("$.status").value("CREATED"))
|
||||
.andExpect(jsonPath("$.inputDirectory").value(inputDirectory.toString()))
|
||||
.andExpect(jsonPath("$.targetDurationSeconds").value(60))
|
||||
.andExpect(jsonPath("$.style").value("cinematic-porsche-promo"))
|
||||
.andReturn()
|
||||
.getResponse()
|
||||
.getContentAsString();
|
||||
|
|
@ -61,6 +64,41 @@ class EditProjectControllerTest {
|
|||
+ 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
|
||||
void rejectsMissingInputDirectory() throws Exception {
|
||||
mockMvc.perform(post("/v1/edit-projects")
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ class EditDomainJsonTest {
|
|||
EditProjectStatus.WAITING_FOR_DIRECTOR,
|
||||
"./input/editing/processed/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:01:00Z"),
|
||||
null
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue