Add category-aware cinematic highlight planning

This commit is contained in:
JSLMPR 2026-07-11 00:50:12 +02:00
parent 5d889b00dc
commit 5006dfd50b
15 changed files with 1096 additions and 14 deletions

View File

@ -0,0 +1,636 @@
# Production Cinematic Highlight Editing Plan
## Goal
Build a local-first workflow where a user drops one source video into a source folder and the service produces multiple production-ready cinematic highlight edits. Each highlight must be adapted to the detected content type, such as family vlog, food vlog, car vlog, or a generic fallback category.
The target output is not just a technically valid MP4. The output must feel intentionally directed: strong shot selection, a clear mini-story, pacing matched to music, fitting voiceover, sound effects, cinematic visual treatment, tasteful text overlays, and a final audio mix that sounds finished.
## Research Findings
- Recent highlight-detection research points toward multimodal narrative understanding, not simple duration slicing. A production system should combine shot segmentation, visual analysis, speech/transcript analysis, audio events, pacing, and narrative role detection.
- Audio matters for highlight detection. Interesting moments are often signaled by speech emphasis, engine sound, laughter, applause, cooking sounds, silence, music changes, or loudness peaks.
- Shot and scene detection should be treated as a core primitive. The service should first split the source video into meaningful temporal units before asking an AI director to choose edits.
- Modern editing tools increasingly use transcript search, visual search, beat detection, montage generation, auto-cropping, and music synchronization. The service should expose similar building blocks internally instead of relying on a prompt alone.
- FFmpeg is suitable as the deterministic render backend, but the current renderer must be expanded. Production-level edits need real filter graphs for speed ramps, transitions, stabilization, text, color, grain, vignettes, audio ducking, loudness normalization, SFX placement, and final mastering.
Research sources:
- https://arxiv.org/abs/2507.10495
- https://arxiv.org/abs/2401.10075
- https://arxiv.org/abs/2107.09612
- https://arxiv.org/abs/2306.11918
- https://en.wikipedia.org/wiki/Shot_transition_detection
- https://en.wikipedia.org/wiki/Video_content_analysis
- https://en.wikipedia.org/wiki/Film_editing
- https://en.wikipedia.org/wiki/FFmpeg
- https://www.digitalcameraworld.com/cameras/video-cameras/these-are-the-new-ai-powered-features-coming-to-final-cut-pro-and-imovie
## Target Workflow
1. User places one source video in the configured local source folder.
2. Scheduler detects the video and creates a highlight project.
3. Service probes the source with `ffprobe` and creates stable low-resolution proxy media.
4. Service extracts thumbnails, contact sheets, audio waveform data, audio stems when possible, and transcript data.
5. Service performs shot and scene segmentation.
6. Service classifies the content category.
7. Service generates highlight candidates from visual, audio, transcript, and motion signals.
8. AI director reviews the compact analysis artifacts and creates a strict edit plan.
9. Asset providers create or select voiceover, music, SFX, captions, and overlay text.
10. Renderer executes the edit plan with FFmpeg and produces final highlight MP4s.
11. QA checks validate technical quality and creative completeness.
12. Final clips, manifests, storyboards, and logs are written to the output folder.
## Proposed Folder Contract
Input:
```text
input/highlights/source/<source-video-file>
```
Output:
```text
output/highlight-projects/<project-id>/
project.json
source/
analysis/
ffprobe.json
shots.json
scenes.json
transcript.json
audio-events.json
visual-analysis.json
category.json
highlight-candidates.json
director/
director-brief.md
director-prompt.md
edit-plan.json
storyboard.md
assets/
voiceover/
music/
sfx/
overlays/
highlights/
<highlight-id>/
final.mp4
preview.mp4
render-manifest.json
qa-report.json
```
## Content Categories
### Family Vlog
Creative intent:
- Warm, emotional, human, memory-focused.
- Preserve natural faces, voices, laughter, and real moments.
- Avoid over-editing or aggressive transitions.
- Use gentle music, soft cinematic color, light film grain, and meaningful text overlays.
Highlight signals:
- Smiles, hugs, reactions, children, group moments, travel reveal moments, laughter.
- Speech with emotional words or important context.
- Stable shots with faces and clear composition.
Voiceover style:
- Personal, warm, reflective, short.
- Should sound like a memory being narrated, not an advertisement.
### Food Vlog
Creative intent:
- Sensory, rhythmic, appetizing, tactile.
- Make ingredients, textures, steam, plating, and reactions feel premium.
- Sync cuts to cooking sounds and music beats.
Highlight signals:
- Close-ups of ingredients, cutting, sizzling, steam, pouring, plating, first bite, reaction.
- Strong texture, color, macro framing, hand movement, kitchen action.
Voiceover style:
- Descriptive and sensory.
- Mention texture, aroma, craft, freshness, and payoff.
### Car Vlog
Creative intent:
- Heroic, powerful, premium, dynamic.
- Emphasize stance, motion, engine sound, details, acceleration, road presence, reflections, wheels, badges, lights, interior, and reveal shots.
- Use speed ramps, bass hits, whooshes, cinematic grade, controlled text overlays, and strong music.
Highlight signals:
- Car fully visible, front/rear three-quarter angles, wheels turning, badge/detail shots, engine/exhaust audio, road motion, camera movement, dramatic lighting.
- Avoid repeated static hood shots unless used as setup.
Voiceover style:
- Confident, cinematic, concise.
- Should praise the vehicle specifically based on observed details, not use generic supercar phrases.
### Generic Vlog Fallback
Creative intent:
- Clear story arc, clean pacing, tasteful grade, and context-aware overlays.
- Use the best available moments even when the category is uncertain.
Highlight signals:
- High motion, strong composition, faces, speech peaks, laughter, visual novelty, scene changes.
## AI Director Responsibilities
The AI director should not render the video. It should produce a validated edit decision plan that the renderer can execute.
Required AI director inputs:
- Source metadata: duration, resolution, frame rate, codec, audio streams.
- Shot list with timestamps, representative thumbnails, motion score, blur score, brightness score, face/object labels, and transcript references.
- Audio analysis: speech segments, silence, peaks, loudness, detected audio events, music/noise/speech classification.
- Category classification with confidence and reasons.
- Highlight candidates with ranked evidence.
- Available music, SFX, voice, font, LUT, and transition capabilities.
Required AI director output:
- Highlight list with target duration and category.
- Timeline edit decisions with exact source timestamps.
- Music mood, energy curve, and beat-sync points.
- SFX cues with timestamps and purpose.
- Voiceover script split into timed lines.
- Text overlay copy, timing, animation, placement, and safe-area rules.
- Visual treatment: grade, transitions, speed ramps, stabilization, crop, punch-ins, grain, vignette, glow, blur, and overlays.
- Render profile: aspect ratio, resolution, FPS policy, codec, CRF or bitrate mode, loudness target.
## Renderer Requirements
The renderer must support these production features before the output can be called cinematic:
- Precise trims using source timestamps.
- Crossfades, match cuts, hard cuts, whip transitions, fade to black, and dip transitions.
- Speed ramps and short impact pauses.
- Dynamic crop and punch-in keyframes for static shots.
- Stabilization option for shaky footage.
- Category-specific color grade chains or LUT support.
- Film grain, vignette, sharpening, glow, blur, and selective contrast.
- Text overlays with font, position, animation timing, stroke, shadow, and safe area.
- Caption and subtitle rendering.
- Music bed placement with fade in/out.
- Voiceover placement with sidechain ducking.
- SFX placement for whooshes, impacts, risers, camera clicks, engine hits, cooking sounds, and ambient layers.
- Audio loudness normalization to a configured target.
- Final render manifest with all commands, source segments, assets, and durations.
## Asset Provider Requirements
The system needs explicit providers instead of pretending a prompt can create finished audio by itself.
- Voiceover provider: local TTS or remote TTS, configurable voice per category, output WAV.
- Music provider: licensed local library first, optional generated music provider later.
- SFX provider: licensed local SFX library with tags and category mapping.
- Font provider: local fonts with brand/category mapping.
- LUT provider: local LUT files or named FFmpeg grade presets.
- Asset cache: deterministic reuse by prompt, category, duration, and provider settings.
Licensing must be tracked in `render-manifest.json` for any music, SFX, voice, font, or LUT asset used in a final export.
## Highlight Scoring Model
Each candidate segment should be scored with a weighted model:
```text
score =
visual_interest
+ motion_energy
+ audio_interest
+ transcript_importance
+ category_relevance
+ composition_quality
+ novelty
- blur_penalty
- darkness_penalty
- repetition_penalty
- unusable_audio_penalty
```
Category-specific examples:
- Family vlog should boost faces, laughter, dialogue, emotional reactions, and group moments.
- Food vlog should boost close-ups, texture, cooking actions, plating, steam, and bite reactions.
- Car vlog should boost full-car hero shots, motion, wheels, engine audio, badges, roads, and dramatic lighting.
## Edit Plan Schema
The plan should be strict JSON so a cheaper model or deterministic renderer can execute it.
```json
{
"projectId": "example-project",
"sourceVideo": "input/highlights/source/example.mp4",
"contentCategory": "car_vlog",
"highlights": [
{
"id": "hero-reveal-001",
"title": "The Reveal",
"targetDurationSeconds": 35,
"aspectRatio": "16:9",
"renderProfile": "cinematic_4k_h264",
"creativeIntent": "Premium Porsche reveal with dynamic pacing and confident narration.",
"timeline": [
{
"type": "video",
"sourceStart": "00:01:12.400",
"sourceEnd": "00:01:16.900",
"timelineStart": "00:00:00.000",
"effects": ["stabilize", "cinematic_warm_contrast", "slow_push_in"],
"transitionOut": "bass_hit_cut"
}
],
"voiceover": [
{
"text": "This is not just a drive. It is precision, presence, and control.",
"timelineStart": "00:00:04.200",
"voice": "confident_cinematic_male",
"delivery": "calm premium"
}
],
"music": {
"mood": "premium cinematic electronic",
"energyCurve": "slow_build_to_impact",
"assetId": "music/premium-drive-90bpm.wav"
},
"sfx": [
{
"assetId": "sfx/bass-hit-01.wav",
"timelineStart": "00:00:07.800",
"gainDb": -6
}
],
"overlays": [
{
"text": "PRECISION IN MOTION",
"timelineStart": "00:00:08.000",
"durationSeconds": 2.5,
"placement": "lower_left_safe",
"animation": "fade_slide_up"
}
]
}
]
}
```
## Milestones
- [ ] Milestone 1: Define the cinematic highlight project domain model, folder contract, and persisted manifests.
- [ ] Milestone 2: Add a local scheduler that accepts exactly one source video at a time from the highlight source folder.
- [ ] Milestone 3: Implement source probing, proxy generation, frame extraction, waveform extraction, and contact sheet creation.
- [ ] Milestone 4: Implement shot and scene segmentation using FFmpeg scene detection or PySceneDetect.
- [ ] Milestone 5: Implement audio analysis for loudness, silence, peaks, speech/music/noise sections, and optional ASR transcript.
- [ ] Milestone 6: Implement visual analysis for blur, exposure, motion, faces, object labels, composition quality, and representative thumbnails.
- [x] Milestone 7: Implement content category classification for family vlog, food vlog, car vlog, and generic fallback.
- [x] Milestone 8: Implement category-specific highlight candidate scoring.
- [x] Milestone 9: Implement strict AI director prompts and JSON schemas for category-aware highlight edit plans.
- [ ] Milestone 10: Implement edit plan validation so impossible timestamps, missing assets, invalid overlays, and unsupported effects fail before render.
- [ ] Milestone 11: Implement asset provider interfaces for voiceover, music, SFX, fonts, and LUTs.
- [ ] Milestone 12: Add a local licensed asset library with category tags and deterministic asset selection.
- [ ] Milestone 13: Implement renderer v2 with transitions, speed ramps, dynamic crops, overlays, visual effects, audio ducking, loudness normalization, and render manifests.
- [ ] Milestone 14: Implement QA checks for black frames, silence, clipping, missing assets, duration mismatch, unsafe text placement, and failed FFmpeg filters.
- [ ] Milestone 15: Add an operator runbook for placing a video, running the service locally, choosing a model, reviewing the director plan, and finding final clips.
- [ ] Milestone 16: Add integration tests with small fixture videos for family, food, car, and generic content.
- [ ] Milestone 17: Add benchmark metrics for analysis time, render time, token usage, asset generation cost, and final output size.
- [ ] Milestone 18: Add a human review mode where the user can approve or edit the AI director plan before rendering.
## Testing Strategy
- Unit tests for category scoring, candidate ranking, schema validation, folder discovery, and manifest writing.
- Integration tests with short fixture videos that verify each category can produce at least one valid highlight plan.
- Renderer tests that verify FFmpeg commands include expected filters for transitions, overlays, color treatment, music, SFX, and voiceover.
- Golden-file tests for AI prompt inputs and expected JSON schema shape.
- End-to-end local test that places one video in the source folder and verifies final clips, manifests, storyboards, and QA reports are created.
- Negative tests for corrupt input video, unsupported codec, empty video, missing audio, missing asset, invalid timestamp, and renderer failure.
## Step-By-Step Local How-To Guide
This guide describes the intended operator workflow after the milestones are implemented.
### 1. Prepare The Machine
Install and verify required binaries:
```bash
ffmpeg -version
ffprobe -version
```
Optional but recommended tools:
```bash
python3 --version
```
Recommended future optional components:
- PySceneDetect for higher-quality scene detection.
- Whisper or another ASR provider for transcript-based editing.
- A configured TTS provider for voiceover.
- A licensed local music and SFX library.
### 2. Configure The Application
Set the local cinematic highlight folders in `application.yml`:
```yaml
video-clipping:
cinematic-highlights:
enabled: true
source-folder: input/highlights/source
output-folder: output/highlight-projects
scheduler-interval: 10s
max-active-projects: 1
default-aspect-ratio: "16:9"
default-render-profile: cinematic_1080p_h264
```
Configure the AI director model separately from the renderer:
```yaml
video-clipping:
cinematic-highlights:
director:
enabled: true
model: gpt-5
allow-cheaper-model: true
cheaper-model: gpt-5-mini
require-human-approval-before-render: true
```
Configure local assets:
```yaml
video-clipping:
cinematic-highlights:
assets:
music-folder: input/highlights/assets/music
sfx-folder: input/highlights/assets/sfx
fonts-folder: input/highlights/assets/fonts
luts-folder: input/highlights/assets/luts
voiceover-folder: output/highlight-projects/_voiceover-cache
```
### 3. Create Folders
Create the required local folders:
```bash
mkdir -p input/highlights/source
mkdir -p input/highlights/assets/music
mkdir -p input/highlights/assets/sfx
mkdir -p input/highlights/assets/fonts
mkdir -p input/highlights/assets/luts
mkdir -p output/highlight-projects
```
### 4. Add The Source Video
Place exactly one source video in the source folder:
```text
input/highlights/source/my-porsche-drive.mp4
```
The scheduler should process one source video at a time. If multiple videos are present, it should process the first valid file, finish it, then move to the next one.
Invalid files should be ignored and logged with the reason.
### 5. Start The Application
Run the Spring Boot service:
```bash
./mvnw spring-boot:run
```
Expected startup logs:
```text
Cinematic highlight scheduler started
Watching source folder: input/highlights/source
Output folder: output/highlight-projects
Scheduler interval: 10s
Max active projects: 1
```
### 6. Verify Project Creation
After the scheduler picks up the source file, verify a project folder exists:
```text
output/highlight-projects/<project-id>/
```
Expected early files:
```text
project.json
analysis/ffprobe.json
analysis/shots.json
analysis/scenes.json
analysis/category.json
director/director-brief.md
director/director-prompt.md
```
Expected logs:
```text
Started cinematic highlight project projectId=<project-id> source=my-porsche-drive.mp4
Generated proxy video
Extracted representative thumbnails
Detected shots and scenes
Classified content category category=car_vlog confidence=...
Generated highlight candidates count=...
```
### 7. Review The AI Director Prompt
Open the director prompt:
```text
output/highlight-projects/<project-id>/director/director-prompt.md
```
Check that it includes:
- Source metadata.
- Category classification.
- Contact sheet or thumbnail references.
- Candidate segments with timestamps.
- Available renderer effects.
- Available music, SFX, voice, font, and LUT assets.
- Strict JSON schema requirements.
- Category-specific creative direction.
If the prompt is generic, the final edit will probably be generic. The prompt must force concrete choices: exact cuts, specific overlays, voiceover lines, SFX timing, music mood, and visual treatment.
### 8. Execute The AI Director
Run the configured AI director against the prompt. The director must output:
```text
output/highlight-projects/<project-id>/director/edit-plan.json
```
If human approval is enabled, inspect the edit plan before rendering.
Minimum review checklist:
- The selected shots are not repetitive.
- Each highlight has a clear beginning, middle, and ending.
- The edit uses the correct category style.
- Voiceover text is specific to the observed video.
- Music and SFX cues are not placeholders.
- Text overlays are tasteful and not overused.
- All referenced timestamps are inside the source duration.
- All referenced assets exist.
### 9. Approve Or Correct The Edit Plan
If the plan is weak, edit or regenerate it before rendering.
Common fixes:
- Replace generic voiceover with concrete lines about the actual footage.
- Remove boring shots or repeated angles.
- Add stronger openers and endings.
- Add music beat cues.
- Add SFX only where they support a cut or reveal.
- Add overlay text only where it improves the scene.
- Adjust target duration if the highlight feels too slow.
When approved, write:
```text
output/highlight-projects/<project-id>/director/approved.flag
```
### 10. Render The Highlights
The renderer consumes:
```text
output/highlight-projects/<project-id>/director/edit-plan.json
```
Expected renderer output:
```text
output/highlight-projects/<project-id>/highlights/<highlight-id>/final.mp4
output/highlight-projects/<project-id>/highlights/<highlight-id>/preview.mp4
output/highlight-projects/<project-id>/highlights/<highlight-id>/render-manifest.json
output/highlight-projects/<project-id>/highlights/<highlight-id>/qa-report.json
```
Expected logs:
```text
Rendering highlight projectId=<project-id> highlightId=<highlight-id>
Applying video trims count=...
Applying transitions count=...
Applying visual effects count=...
Mixing music asset=...
Mixing voiceover lines=...
Mixing SFX count=...
Completed highlight render duration=... output=...
```
### 11. Review The Final Clips
For each `final.mp4`, verify:
- The first three seconds are interesting.
- The edit has intentional pacing.
- The selected shots fit the content type.
- Music fits the scene and is audible.
- Voiceover is clear and not robotic or generic.
- SFX support the edit without becoming noisy.
- Text overlays are readable and not cheap-looking.
- Color grade improves the footage without crushing detail.
- Audio is not clipping and does not have long silent sections.
If the final video is boring, inspect in this order:
1. `analysis/highlight-candidates.json`: weak source moments or wrong scoring.
2. `director/edit-plan.json`: weak AI choices, generic voiceover, no effects.
3. `render-manifest.json`: renderer ignored effects or missing assets.
4. `qa-report.json`: technical failures or missing audio/effects.
### 12. Find The Final Result
Final highlight videos are stored here:
```text
output/highlight-projects/<project-id>/highlights/<highlight-id>/final.mp4
```
The full audit trail is stored beside the final video:
```text
render-manifest.json
qa-report.json
```
The creative plan is stored here:
```text
output/highlight-projects/<project-id>/director/storyboard.md
output/highlight-projects/<project-id>/director/edit-plan.json
```
### 13. Clean Up After A Run
After confirming the output is good, move the source video out of the source folder or let the scheduler move it to a processed folder:
```text
input/highlights/processed/<source-video-file>
```
Failed inputs should move to:
```text
input/highlights/failed/<source-video-file>
```
The scheduler must not repeatedly process the same source video.
## Definition Of Done
A highlight export is production-ready only when:
- The selected shots match the detected content category.
- The edit has a clear beginning, middle, and ending.
- Music is present, fitting, and timed to key cuts.
- Voiceover is present when requested and sounds appropriate for the category.
- SFX are present where they add impact and are not randomly overused.
- Text overlays are readable, visually appealing, correctly timed, and safe-area compliant.
- Visual treatment is visible but not destructive.
- Audio is normalized and free of clipping.
- Output includes a render manifest, edit plan, storyboard, and QA report.
- The final MP4 can be played independently without relying on temporary files.
## Key Risk
The previous boring output is expected if the system only creates a weak prompt and then renders simple trims. Cinematic output requires three things working together: richer multimodal analysis, a strict category-aware AI director plan, and a renderer that can actually execute music, SFX, voiceover, overlays, transitions, and visual effects.

View File

@ -54,21 +54,23 @@ public class AiDirectorPromptGenerator {
return """ return """
# Local AI Director Assignment # Local AI Director Assignment
You are the director for a premium cinematic Porsche promo. You are the director for a production-ready cinematic highlight edit.
Work only with artifacts in this project folder: Work only with artifacts in this project folder:
- Read `analysis.json` for authoritative clip metadata. - Read `analysis.json` for authoritative clip metadata.
- Read `cinematic-highlight-analysis.json`, `category.json`, and `highlight-candidates.json` when present.
- Inspect every relevant image in `contact-sheets/` and `thumbnails/` before choosing shots. - Inspect every relevant image in `contact-sheets/` and `thumbnails/` before choosing shots.
- Use `proxies/` only when motion needs closer inspection. - Use `proxies/` only when motion needs closer inspection.
Complete this task: Complete this task:
1. Judge visual quality, composition, motion, continuity, and story value from the generated artifacts. 1. Judge visual quality, composition, motion, continuity, audio opportunity, 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. 2. Select the strongest shots and direct a clear opening hook, rising-energy middle, and final memorable shot.
3. Follow the strict JSON schema and constraints in the brief below. 3. Follow the strict JSON schema and constraints in the brief below.
4. Write the final JSON object to `inbox/%s`. 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 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. Do not invent clip IDs, source ranges, durations, or media paths.
Do not write generic voiceover. The narration, text overlays, music mood, SFX, and visual effects must fit the detected content category.
Your task is complete only when `inbox/%s` contains valid JSON with no Markdown fences. Your task is complete only when `inbox/%s` contains valid JSON with no Markdown fences.
%s %s

View File

@ -0,0 +1,14 @@
package org.example.videoclips.editing;
import java.time.Instant;
import java.util.List;
public record CinematicHighlightAnalysis(
String projectId,
ContentCategory category,
double categoryConfidence,
List<String> categoryReasons,
List<HighlightCandidate> candidates,
Instant createdAt
) {
}

View File

@ -0,0 +1,157 @@
package org.example.videoclips.editing;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class CinematicHighlightAnalyzer {
private static final double DEFAULT_CANDIDATE_SECONDS = 8.0;
private static final int MAX_CANDIDATES_PER_CLIP = 12;
private final Clock clock;
public CinematicHighlightAnalyzer() {
this(Clock.systemUTC());
}
CinematicHighlightAnalyzer(Clock clock) {
this.clock = clock;
}
public CinematicHighlightAnalysis analyze(EditProject project, EditProjectAnalysis analysis) {
CategoryDecision category = classify(project, analysis);
List<HighlightCandidate> candidates = analysis.clips().stream()
.flatMap(clip -> candidatesForClip(clip, category.category()).stream())
.sorted(Comparator.comparingDouble(HighlightCandidate::score).reversed()
.thenComparing(HighlightCandidate::clipId)
.thenComparingDouble(HighlightCandidate::sourceStartSeconds))
.toList();
return new CinematicHighlightAnalysis(project.id(), category.category(), category.confidence(),
category.reasons(), candidates, Instant.now(clock));
}
private CategoryDecision classify(EditProject project, EditProjectAnalysis analysis) {
String haystack = (project.name() + " " + project.style() + " "
+ analysis.clips().stream()
.map(clip -> clip.clipId() + " " + clip.sourcePath())
.reduce("", (left, right) -> left + " " + right))
.toLowerCase(Locale.ROOT);
if (containsAny(haystack, "porsche", "car", "auto", "vehicle", "drive", "driving", "engine", "wheel")) {
return new CategoryDecision(ContentCategory.CAR_VLOG, 0.78,
List.of("Project metadata suggests vehicle or driving content."));
}
if (containsAny(haystack, "food", "cook", "cooking", "recipe", "kitchen", "restaurant", "meal", "dish")) {
return new CategoryDecision(ContentCategory.FOOD_VLOG, 0.74,
List.of("Project metadata suggests food, cooking, or restaurant content."));
}
if (containsAny(haystack, "family", "kids", "child", "children", "birthday", "holiday", "vacation", "vlog")) {
return new CategoryDecision(ContentCategory.FAMILY_VLOG, 0.68,
List.of("Project metadata suggests personal or family vlog content."));
}
return new CategoryDecision(ContentCategory.GENERIC_VLOG, 0.45,
List.of("No strong category-specific metadata was available; using generic vlog direction."));
}
private boolean containsAny(String haystack, String... needles) {
for (String needle : needles) {
if (haystack.contains(needle)) {
return true;
}
}
return false;
}
private List<HighlightCandidate> candidatesForClip(ClipAnalysis clip, ContentCategory category) {
double duration = clip.durationSeconds();
if (duration <= 0) {
return List.of();
}
double segmentSeconds = Math.min(DEFAULT_CANDIDATE_SECONDS, duration);
int segmentCount = Math.max(1, (int) Math.ceil(duration / segmentSeconds));
int cappedCount = Math.min(segmentCount, MAX_CANDIDATES_PER_CLIP);
double step = segmentCount <= MAX_CANDIDATES_PER_CLIP
? segmentSeconds
: Math.max(1.0, (duration - segmentSeconds) / (MAX_CANDIDATES_PER_CLIP - 1));
List<HighlightCandidate> candidates = new ArrayList<>();
for (int index = 0; index < cappedCount; index++) {
double start = Math.min(duration - segmentSeconds, index * step);
double end = Math.min(duration, start + segmentSeconds);
candidates.add(new HighlightCandidate(
"%s_candidate_%02d".formatted(clip.clipId(), index + 1),
clip.clipId(),
round(start),
round(end),
round(score(clip, category, index, cappedCount)),
role(index, cappedCount),
reasons(clip, category, index, cappedCount)
));
}
return candidates;
}
private double score(ClipAnalysis clip, ContentCategory category, int index, int count) {
double quality = Math.max(0.1, clip.sharpnessScore()) + Math.max(0.1, clip.brightnessScore())
+ Math.max(0.1, clip.motionScore());
double position = switch (role(index, count)) {
case "opening_hook" -> 0.25;
case "final_hero" -> 0.22;
case "rising_action" -> 0.16;
default -> 0.1;
};
double categoryBoost = switch (category) {
case CAR_VLOG -> 0.2;
case FOOD_VLOG -> 0.16;
case FAMILY_VLOG -> 0.14;
case GENERIC_VLOG -> 0.08;
};
return Math.min(1.0, 0.35 + quality * 0.12 + position + categoryBoost);
}
private String role(int index, int count) {
if (index == 0) {
return "opening_hook";
}
if (index == count - 1) {
return "final_hero";
}
if (index <= Math.max(1, count / 3)) {
return "rising_action";
}
return "detail_or_bridge";
}
private List<String> reasons(ClipAnalysis clip, ContentCategory category, int index, int count) {
List<String> reasons = new ArrayList<>();
reasons.add("Suggested as " + role(index, count).replace('_', ' ') + " based on source position.");
reasons.add("Duration window is short enough for fast cinematic pacing.");
if (clip.motionScore() > 0) {
reasons.add("Motion score is available for director ranking.");
}
reasons.add(switch (category) {
case CAR_VLOG -> "For car content, inspect this window for hero angles, wheels, badges, engine sound, or road motion.";
case FOOD_VLOG -> "For food content, inspect this window for texture, preparation, plating, steam, or bite reactions.";
case FAMILY_VLOG -> "For family content, inspect this window for faces, reactions, laughter, hugs, or emotional dialogue.";
case GENERIC_VLOG -> "For generic vlog content, inspect this window for visual novelty, clear action, or useful story context.";
});
return List.copyOf(reasons);
}
private double round(double value) {
return Math.round(value * 1000.0) / 1000.0;
}
private record CategoryDecision(ContentCategory category, double confidence, List<String> reasons) {
}
}

View File

@ -0,0 +1,8 @@
package org.example.videoclips.editing;
public enum ContentCategory {
FAMILY_VLOG,
FOOD_VLOG,
CAR_VLOG,
GENERIC_VLOG
}

View File

@ -21,6 +21,7 @@ public class EditProjectAnalyzer {
private final ThumbnailExtractor thumbnailExtractor; private final ThumbnailExtractor thumbnailExtractor;
private final ContactSheetGenerator contactSheetGenerator; private final ContactSheetGenerator contactSheetGenerator;
private final ProxyGenerator proxyGenerator; private final ProxyGenerator proxyGenerator;
private final CinematicHighlightAnalyzer cinematicHighlightAnalyzer;
private final Clock clock; private final Clock clock;
private final EditObservability observability; private final EditObservability observability;
@ -32,10 +33,11 @@ public class EditProjectAnalyzer {
ThumbnailExtractor thumbnailExtractor, ThumbnailExtractor thumbnailExtractor,
ContactSheetGenerator contactSheetGenerator, ContactSheetGenerator contactSheetGenerator,
ProxyGenerator proxyGenerator, ProxyGenerator proxyGenerator,
CinematicHighlightAnalyzer cinematicHighlightAnalyzer,
EditObservability observability EditObservability observability
) { ) {
this(store, discovery, inspector, thumbnailExtractor, contactSheetGenerator, proxyGenerator, this(store, discovery, inspector, thumbnailExtractor, contactSheetGenerator, proxyGenerator,
Clock.systemUTC(), observability); cinematicHighlightAnalyzer, Clock.systemUTC(), observability);
} }
EditProjectAnalyzer( EditProjectAnalyzer(
@ -47,13 +49,14 @@ public class EditProjectAnalyzer {
ProxyGenerator proxyGenerator, ProxyGenerator proxyGenerator,
Clock clock Clock clock
) { ) {
this(store, discovery, inspector, thumbnailExtractor, contactSheetGenerator, proxyGenerator, clock, null); this(store, discovery, inspector, thumbnailExtractor, contactSheetGenerator, proxyGenerator, null, clock, null);
} }
EditProjectAnalyzer( EditProjectAnalyzer(
EditProjectStore store, EditClipDiscovery discovery, FfmpegClipInspector inspector, EditProjectStore store, EditClipDiscovery discovery, FfmpegClipInspector inspector,
ThumbnailExtractor thumbnailExtractor, ContactSheetGenerator contactSheetGenerator, ThumbnailExtractor thumbnailExtractor, ContactSheetGenerator contactSheetGenerator,
ProxyGenerator proxyGenerator, Clock clock, EditObservability observability ProxyGenerator proxyGenerator, CinematicHighlightAnalyzer cinematicHighlightAnalyzer, Clock clock,
EditObservability observability
) { ) {
this.store = store; this.store = store;
this.discovery = discovery; this.discovery = discovery;
@ -61,6 +64,7 @@ public class EditProjectAnalyzer {
this.thumbnailExtractor = thumbnailExtractor; this.thumbnailExtractor = thumbnailExtractor;
this.contactSheetGenerator = contactSheetGenerator; this.contactSheetGenerator = contactSheetGenerator;
this.proxyGenerator = proxyGenerator; this.proxyGenerator = proxyGenerator;
this.cinematicHighlightAnalyzer = cinematicHighlightAnalyzer;
this.clock = clock; this.clock = clock;
this.observability = observability; this.observability = observability;
} }
@ -100,6 +104,7 @@ public class EditProjectAnalyzer {
EditProjectAnalysis analysis = new EditProjectAnalysis(projectId, List.copyOf(clips), List.copyOf(errors), EditProjectAnalysis analysis = new EditProjectAnalysis(projectId, List.copyOf(clips), List.copyOf(errors),
Instant.now(clock)); Instant.now(clock));
store.writeJson(projectId, "analysis.json", analysis); store.writeJson(projectId, "analysis.json", analysis);
writeCinematicHighlightArtifacts(projectId, analysis);
if (observability != null) { if (observability != null) {
observability.analysisCompleted(projectId, System.nanoTime() - startedAt, clips.size(), errors.size()); observability.analysisCompleted(projectId, System.nanoTime() - startedAt, clips.size(), errors.size());
} }
@ -133,4 +138,15 @@ public class EditProjectAnalyzer {
inspected.sharpnessScore() inspected.sharpnessScore()
); );
} }
private void writeCinematicHighlightArtifacts(String projectId, EditProjectAnalysis analysis) {
if (cinematicHighlightAnalyzer == null) {
return;
}
EditProject project = store.readJson(projectId, "project.json", EditProject.class);
CinematicHighlightAnalysis cinematic = cinematicHighlightAnalyzer.analyze(project, analysis);
store.writeJson(projectId, "cinematic-highlight-analysis.json", cinematic);
store.writeJson(projectId, "category.json", cinematic);
store.writeJson(projectId, "highlight-candidates.json", cinematic.candidates());
}
} }

View File

@ -0,0 +1,14 @@
package org.example.videoclips.editing;
import java.util.List;
public record HighlightCandidate(
String id,
String clipId,
double sourceStartSeconds,
double sourceEndSeconds,
double score,
String suggestedRole,
List<String> reasons
) {
}

View File

@ -160,7 +160,7 @@ public class LocalDirectorScheduler {
try { try {
EditProjectResponse project = projectService.createProject(new CreateEditProjectRequest( EditProjectResponse project = projectService.createProject(new CreateEditProjectRequest(
working.getFileName().toString(), working.toString(), editing.getTargetDurationSeconds(), working.getFileName().toString(), working.toString(), editing.getTargetDurationSeconds(),
"cinematic-porsche-promo", true, true, true)); "category-aware-cinematic-highlights", true, true, true));
projectId = project.projectId(); projectId = project.projectId();
projectService.updateProject(projectId, EditProjectStatus.ANALYZING, working, null); projectService.updateProject(projectId, EditProjectStatus.ANALYZING, working, null);
EditProjectAnalysis analysis = analyzer.analyze(projectId, working); EditProjectAnalysis analysis = analyzer.analyze(projectId, working);

View File

@ -43,6 +43,7 @@ public class StoryboardPromptGenerator {
} }
String prompt(EditProject project, EditProjectAnalysis analysis) { String prompt(EditProject project, EditProjectAnalysis analysis) {
CinematicHighlightAnalysis cinematic = readCinematicAnalysis(project.id());
StringBuilder clips = new StringBuilder(); StringBuilder clips = new StringBuilder();
for (ClipAnalysis clip : analysis.clips()) { for (ClipAnalysis clip : analysis.clips()) {
clips.append("- clipId: ").append(clip.clipId()).append('\n') clips.append("- clipId: ").append(clip.clipId()).append('\n')
@ -54,6 +55,10 @@ public class StoryboardPromptGenerator {
.append(" contactSheet: ").append(reference("contact-sheets", clip.contactSheet())).append('\n') .append(" contactSheet: ").append(reference("contact-sheets", clip.contactSheet())).append('\n')
.append(" proxy: ").append(reference("proxies", clip.proxyPath())).append('\n'); .append(" proxy: ").append(reference("proxies", clip.proxyPath())).append('\n');
} }
String category = cinematic == null ? "unknown" : cinematic.category().name().toLowerCase();
String candidates = cinematic == null ? "No precomputed highlight candidates. Inspect all clips carefully."
: candidateBrief(cinematic);
String categoryDirection = cinematic == null ? genericDirection() : categoryDirection(cinematic.category());
return """ return """
# Cinematic Storyboard Director Brief # Cinematic Storyboard Director Brief
@ -66,18 +71,29 @@ public class StoryboardPromptGenerator {
- Voiceover enabled: %s - Voiceover enabled: %s
- Music enabled: %s - Music enabled: %s
- Sound effects enabled: %s - Sound effects enabled: %s
- Detected content category: `%s`
- Use only the clip IDs and source ranges listed below. - Use only the clip IDs and source ranges listed below.
- Keep every source range within its clip duration. - Keep every source range within its clip duration.
- Timeline ranges must be chronological and must not overlap. - Timeline ranges must be chronological and must not overlap.
- Prefer an intentional opening hook, escalating middle, and memorable final hero shot. - Prefer an intentional opening hook, escalating middle, and memorable final hero shot.
- Use music, SFX, voiceover, text overlays, and visual treatment only when they fit the detected category.
- Voiceover must be specific to what can be observed in the supplied thumbnails/contact sheets.
- Do not write generic praise, generic lifestyle language, or placeholder asset names.
- Return strict JSON only. Do not use Markdown fences or add commentary. - Return strict JSON only. Do not use Markdown fences or add commentary.
Category-specific direction:
%s
Ranked highlight candidates:
%s
Available clips and project-relative visual references: Available clips and project-relative visual references:
%s %s
Required JSON object shape: Required JSON object shape:
{ {
"projectId": "%s", "projectId": "%s",
"style": "%s", "style": "%s",
"contentCategory": "%s",
"targetDurationSeconds": %d, "targetDurationSeconds": %d,
"decisions": [{ "decisions": [{
"clipId": "clip_00001", "clipId": "clip_00001",
@ -93,25 +109,90 @@ public class StoryboardPromptGenerator {
}], }],
"audioCues": [{ "audioCues": [{
"type": "music|sfx", "type": "music|sfx",
"assetKey": "descriptive asset name", "assetKey": "concrete music or sfx asset description",
"timelineStartSeconds": 0.0, "timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5, "timelineEndSeconds": 2.5,
"gainDb": -12.0, "gainDb": -12.0,
"notes": "story purpose" "notes": "story purpose"
}], }],
"voiceover": [{ "voiceover": [{
"text": "narration", "text": "specific narration based on visible footage",
"timelineStartSeconds": 0.0, "timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5, "timelineEndSeconds": 2.5,
"delivery": "confident cinematic narrator" "delivery": "confident cinematic narrator"
}], }],
"overlays": [{
"text": "short premium overlay text",
"timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5,
"placement": "lower_left_safe|center_safe|lower_center_safe",
"animation": "fade_slide_up|fade_in|none",
"reason": "why this text improves the scene"
}],
"renderProfile": "mp4-h264-aac-1080p", "renderProfile": "mp4-h264-aac-1080p",
"summary": "short creative rationale" "summary": "short creative rationale"
} }
""".formatted( """.formatted(
project.id(), project.style(), project.targetDurationSeconds(), project.id(), project.style(), project.targetDurationSeconds(),
project.voiceoverEnabled(), project.musicEnabled(), project.soundEffectsEnabled(), project.voiceoverEnabled(), project.musicEnabled(), project.soundEffectsEnabled(), category,
clips.toString().stripTrailing(), project.id(), project.style(), project.targetDurationSeconds()); categoryDirection, candidates, clips.toString().stripTrailing(), project.id(), project.style(),
category, project.targetDurationSeconds());
}
private CinematicHighlightAnalysis readCinematicAnalysis(String projectId) {
if (!Files.exists(store.projectDirectory(projectId).resolve("cinematic-highlight-analysis.json"))) {
return null;
}
return store.readJson(projectId, "cinematic-highlight-analysis.json", CinematicHighlightAnalysis.class);
}
private String candidateBrief(CinematicHighlightAnalysis cinematic) {
StringBuilder builder = new StringBuilder();
builder.append("- categoryConfidence: ").append(cinematic.categoryConfidence()).append('\n');
builder.append("- categoryReasons: ").append(cinematic.categoryReasons()).append('\n');
for (HighlightCandidate candidate : cinematic.candidates()) {
builder.append("- candidateId: ").append(candidate.id()).append('\n')
.append(" clipId: ").append(candidate.clipId()).append('\n')
.append(" sourceStartSeconds: ").append(candidate.sourceStartSeconds()).append('\n')
.append(" sourceEndSeconds: ").append(candidate.sourceEndSeconds()).append('\n')
.append(" score: ").append(candidate.score()).append('\n')
.append(" suggestedRole: ").append(candidate.suggestedRole()).append('\n')
.append(" reasons: ").append(candidate.reasons()).append('\n');
}
return builder.toString().stripTrailing();
}
private String categoryDirection(ContentCategory category) {
return switch (category) {
case CAR_VLOG -> """
- Make the edit feel premium, powerful, and precise.
- Prioritize hero angles, wheels, badges, motion, reflections, roads, lights, interior details, and engine or exhaust moments.
- Use speed ramps, bass-hit cuts, whooshes, cinematic contrast, subtle grain, and confident short overlay text when the footage supports it.
- Voiceover should praise the specific car footage, not make unsupported claims.
""";
case FOOD_VLOG -> """
- Make the edit sensory, warm, rhythmic, and appetizing.
- Prioritize texture, slicing, pouring, sizzling, steam, plating, table reveals, and first-bite reactions.
- Use music with light groove, tactile SFX, warm grade, macro-feeling punch-ins, and short ingredient or payoff overlays.
- Voiceover should describe texture, craft, freshness, and payoff without sounding like a generic restaurant ad.
""";
case FAMILY_VLOG -> """
- Make the edit warm, human, emotional, and memory-focused.
- Prioritize faces, reactions, laughter, hugs, children, travel reveals, and meaningful dialogue.
- Use gentle music, soft grade, light grain, natural pacing, and minimal text overlays.
- Voiceover should feel personal and reflective, not promotional.
""";
case GENERIC_VLOG -> genericDirection();
};
}
private String genericDirection() {
return """
- Build a clear mini-story from the strongest available moments.
- Prioritize visual novelty, clean composition, usable audio, expressive reactions, and strong scene changes.
- Use tasteful music, limited SFX, cinematic grade, and overlays only when they clarify the story.
- Voiceover must explain what is actually happening in the footage.
""";
} }
private String references(String directory, List<String> paths) { private String references(String directory, List<String> paths) {

View File

@ -5,7 +5,7 @@ video-clipping:
enabled: true enabled: true
local-director: local-director:
enabled: true enabled: true
auto-render-when-plan-appears: true auto-render-when-plan-appears: false
logging: logging:
level: level:

View File

@ -30,12 +30,15 @@ class AiDirectorPromptGeneratorTest {
String prompt = Files.readString(Path.of(files.promptPath())); String prompt = Files.readString(Path.of(files.promptPath()));
assertThat(prompt) assertThat(prompt)
.contains("`analysis.json`") .contains("`analysis.json`")
.contains("`cinematic-highlight-analysis.json`")
.contains("`contact-sheets/`") .contains("`contact-sheets/`")
.contains("`thumbnails/`") .contains("`thumbnails/`")
.contains("`inbox/edit-plan.json`") .contains("`inbox/edit-plan.json`")
.contains("Do not render video") .contains("Do not render video")
.contains("production-ready cinematic highlight edit")
.contains("clipId: clip_00001") .contains("clipId: clip_00001")
.contains("\"decisions\""); .contains("\"decisions\"")
.doesNotContain("premium cinematic Porsche promo");
assertThat(Files.readString(Path.of(files.readmePath()))) assertThat(Files.readString(Path.of(files.readmePath())))
.contains("Codex, Claude") .contains("Codex, Claude")
.contains("`inbox/edit-plan.json`"); .contains("`inbox/edit-plan.json`");

View File

@ -87,7 +87,7 @@ class CinematicEditingIntegrationTest {
index * 2.0, (index + 1) * 2.0, "cut", "cut", 1, index * 2.0, (index + 1) * 2.0, "cut", "cut", 1,
"cinematic grade", "integration sequence")) "cinematic grade", "integration sequence"))
.toList(); .toList();
return new EditPlan(projectId, "cinematic-porsche-promo", 60, decisions, List.of(), List.of(), return new EditPlan(projectId, "category-aware-cinematic-highlights", 60, decisions, List.of(), List.of(),
"mp4-h264-aac-320p", "Generated integration edit"); "mp4-h264-aac-320p", "Generated integration edit");
} }

View File

@ -0,0 +1,74 @@
package org.example.videoclips.editing;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class CinematicHighlightAnalyzerTest {
private final CinematicHighlightAnalyzer analyzer = new CinematicHighlightAnalyzer(
Clock.fixed(Instant.parse("2026-07-10T10:00:00Z"), ZoneOffset.UTC));
@Test
void classifiesCarContentAndCreatesRankedCandidates() {
EditProject project = project("Porsche mountain drive", "category-aware-cinematic-highlights");
EditProjectAnalysis source = analysis(clip("porsche-drive.mp4", 45.0));
CinematicHighlightAnalysis result = analyzer.analyze(project, source);
assertThat(result.projectId()).isEqualTo("project-1");
assertThat(result.category()).isEqualTo(ContentCategory.CAR_VLOG);
assertThat(result.categoryConfidence()).isGreaterThan(0.7);
assertThat(result.categoryReasons()).isNotEmpty();
assertThat(result.candidates()).hasSizeGreaterThan(1);
assertThat(result.candidates().get(0).score()).isGreaterThanOrEqualTo(result.candidates().get(1).score());
assertThat(result.candidates()).allSatisfy(candidate -> {
assertThat(candidate.sourceStartSeconds()).isGreaterThanOrEqualTo(0);
assertThat(candidate.sourceEndSeconds()).isLessThanOrEqualTo(45.0);
assertThat(candidate.reasons()).anyMatch(reason -> reason.contains("car content"));
});
assertThat(result.createdAt()).isEqualTo(Instant.parse("2026-07-10T10:00:00Z"));
}
@Test
void classifiesFoodAndFamilyFromProjectMetadata() {
assertThat(analyzer.analyze(project("Street food recipe", "cinematic"), analysis(clip("clip.mp4", 8)))
.category()).isEqualTo(ContentCategory.FOOD_VLOG);
assertThat(analyzer.analyze(project("Family birthday vlog", "cinematic"), analysis(clip("clip.mp4", 8)))
.category()).isEqualTo(ContentCategory.FAMILY_VLOG);
}
@Test
void fallsBackToGenericWhenCategoryIsUnknown() {
CinematicHighlightAnalysis result = analyzer.analyze(project("Untitled", "cinematic"),
analysis(clip("scene.mp4", 5.0)));
assertThat(result.category()).isEqualTo(ContentCategory.GENERIC_VLOG);
assertThat(result.candidates()).singleElement().satisfies(candidate -> {
assertThat(candidate.sourceStartSeconds()).isEqualTo(0.0);
assertThat(candidate.sourceEndSeconds()).isEqualTo(5.0);
assertThat(candidate.suggestedRole()).isEqualTo("opening_hook");
});
}
private EditProject project(String name, String style) {
Instant now = Instant.parse("2026-07-10T10:00:00Z");
return new EditProject("project-1", name, EditProjectStatus.ANALYZED, "input", "output",
60, style, true, true, true, now, now, null);
}
private EditProjectAnalysis analysis(ClipAnalysis clip) {
return new EditProjectAnalysis("project-1", List.of(clip), List.of(),
Instant.parse("2026-07-10T10:00:00Z"));
}
private ClipAnalysis clip(String sourcePath, double durationSeconds) {
return new ClipAnalysis("clip_00001", sourcePath, durationSeconds, "h264", "aac",
1920, 1080, 30.0, List.of(), null, null, 0.7, 0.8, 0.9);
}
}

View File

@ -60,6 +60,49 @@ class EditProjectAnalyzerTest {
assertThat(analyzer.readAnalysis("project-1")).isEqualTo(analysis); assertThat(analyzer.readAnalysis("project-1")).isEqualTo(analysis);
} }
@Test
void writesCinematicHighlightArtifactsWhenEnabled() throws Exception {
Path input = Files.createDirectory(tempDir.resolve("porsche-input"));
Path clip = Files.writeString(input.resolve("porsche-drive.mp4"), "video");
FileSystemEditProjectStore store = store();
store.createProject("project-1");
writeProject(store, input, "Porsche Drive");
EditClipDiscovery discovery = mock(EditClipDiscovery.class);
FfmpegClipInspector inspector = mock(FfmpegClipInspector.class);
ThumbnailExtractor thumbnailExtractor = mock(ThumbnailExtractor.class);
ContactSheetGenerator contactSheetGenerator = mock(ContactSheetGenerator.class);
ProxyGenerator proxyGenerator = mock(ProxyGenerator.class);
ClipAnalysis inspected = clipAnalysis(clip);
when(discovery.discover(input)).thenReturn(List.of(clip));
when(inspector.inspect(clip)).thenReturn(inspected);
when(thumbnailExtractor.extract(inspected, tempDir.resolve("projects/project-1/thumbnails")))
.thenReturn(List.of("thumb-1.jpg"));
when(contactSheetGenerator.generate(inspected, tempDir.resolve("projects/project-1/contact-sheets")))
.thenReturn("contact-sheet.jpg");
when(proxyGenerator.generate(inspected, tempDir.resolve("projects/project-1/proxies")))
.thenReturn(Optional.empty());
EditProjectAnalyzer analyzer = new EditProjectAnalyzer(
store,
discovery,
inspector,
thumbnailExtractor,
contactSheetGenerator,
proxyGenerator,
new CinematicHighlightAnalyzer(Clock.fixed(Instant.parse("2026-07-10T10:00:00Z"), ZoneOffset.UTC)),
Clock.fixed(Instant.parse("2026-07-10T10:00:00Z"), ZoneOffset.UTC),
null
);
analyzer.analyze("project-1", input);
CinematicHighlightAnalysis cinematic = store.readJson("project-1", "cinematic-highlight-analysis.json",
CinematicHighlightAnalysis.class);
assertThat(cinematic.category()).isEqualTo(ContentCategory.CAR_VLOG);
assertThat(cinematic.candidates()).isNotEmpty();
assertThat(Files.exists(tempDir.resolve("projects/project-1/category.json"))).isTrue();
assertThat(Files.exists(tempDir.resolve("projects/project-1/highlight-candidates.json"))).isTrue();
}
@Test @Test
void recordsInvalidClipErrorsAndKeepsUsableClips() throws Exception { void recordsInvalidClipErrorsAndKeepsUsableClips() throws Exception {
Path input = Files.createDirectory(tempDir.resolve("input")); Path input = Files.createDirectory(tempDir.resolve("input"));
@ -148,6 +191,13 @@ class EditProjectAnalyzerTest {
return new FileSystemEditProjectStore(properties, new ObjectMapper().findAndRegisterModules()); return new FileSystemEditProjectStore(properties, new ObjectMapper().findAndRegisterModules());
} }
private void writeProject(FileSystemEditProjectStore store, Path input, String name) {
Instant now = Instant.parse("2026-07-10T10:00:00Z");
store.writeJson("project-1", "project.json", new EditProject(
"project-1", name, EditProjectStatus.ANALYZING, input.toString(), "output",
60, "category-aware-cinematic-highlights", true, true, true, now, now, null));
}
private ClipAnalysis clipAnalysis(Path clip) { private ClipAnalysis clipAnalysis(Path clip) {
return new ClipAnalysis( return new ClipAnalysis(
clip.getFileName().toString().replace(".mp4", ""), clip.getFileName().toString().replace(".mp4", ""),

View File

@ -46,6 +46,33 @@ class StoryboardPromptGeneratorTest {
assertThat(response.promptPath()).endsWith("storyboard-prompt.md"); assertThat(response.promptPath()).endsWith("storyboard-prompt.md");
} }
@Test
void includesCategorySpecificHighlightCandidatesWhenAvailable() {
FileSystemEditProjectStore store = store();
store.createProject("porsche-edit");
store.writeJson("porsche-edit", "project.json", project());
store.writeJson("porsche-edit", "analysis.json", analysis());
store.writeJson("porsche-edit", "cinematic-highlight-analysis.json", new CinematicHighlightAnalysis(
"porsche-edit",
ContentCategory.CAR_VLOG,
0.82,
List.of("Project metadata suggests vehicle content."),
List.of(new HighlightCandidate("clip_00001_candidate_01", "clip_00001",
0.0, 8.0, 0.91, "opening_hook", List.of("Inspect for hero car angle."))),
Instant.parse("2026-07-10T10:01:00Z")
));
String prompt = new StoryboardPromptGenerator(store).prompt(project(), analysis());
assertThat(prompt)
.contains("Detected content category: `car_vlog`")
.contains("Ranked highlight candidates")
.contains("candidateId: clip_00001_candidate_01")
.contains("Make the edit feel premium, powerful, and precise")
.contains("\"contentCategory\": \"car_vlog\"")
.contains("\"overlays\"");
}
private FileSystemEditProjectStore store() { private FileSystemEditProjectStore store() {
VideoClippingProperties properties = new VideoClippingProperties(); VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString()); properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString());