- Implement end-to-end cinematic highlight rendering pipeline

- Wire visual effects and local asset generation into highlight flow
  - Add director plan rendering with local asset worker
This commit is contained in:
JSLMPR 2026-07-12 01:20:18 +02:00
parent adc979eea8
commit 618ba9af49
36 changed files with 2965 additions and 22 deletions

View File

@ -50,7 +50,7 @@ video-clipping:
project-directory: ./output/edit-projects project-directory: ./output/edit-projects
thumbnail-count-per-clip: 5 thumbnail-count-per-clip: 5
proxy-enabled: true proxy-enabled: true
target-duration-seconds: 60 target-duration-seconds: 600
output-width: 1920 output-width: 1920
output-height: 1080 output-height: 1080
output-frame-rate: 30 output-frame-rate: 30
@ -186,6 +186,13 @@ The AI director should inspect:
- `thumbnails/*.jpg` - `thumbnails/*.jpg`
- `proxies/*.mp4` if more visual context is needed - `proxies/*.mp4` if more visual context is needed
After the plan is imported, the service also runs an asset materialization stage:
- It reuses matching generated music, SFX, and voiceover assets from the shared cache folders.
- It copies reusable assets into the project-local `audio/` folder so the renderer can use them.
- It writes reusable asset requests under `assets/requests/` when a needed asset is missing.
- It waits to render if required SFX assets are still missing.
The AI director must write this file: The AI director must write this file:
```text ```text
@ -239,6 +246,14 @@ Rules the AI director must follow:
The renderer can mix optional WAV files into the final output. The renderer can mix optional WAV files into the final output.
Shared cache locations:
- Music: `input/highlights/assets/music/`
- SFX: `input/highlights/assets/sfx/`
- Voiceover cache: `output/highlight-projects/_voiceover-cache/`
If the service or an AI worker generates an asset there, later projects can reuse it automatically.
Place optional assets here before rendering: Place optional assets here before rendering:
```text ```text
@ -308,6 +323,14 @@ After rendering completes, the final output is:
output/edit-projects/porsche-session-001/final.mp4 output/edit-projects/porsche-session-001/final.mp4
``` ```
The renderer also publishes each final rendered segment clip that was concatenated into the full video:
```text
output/edit-projects/porsche-session-001/rendered-clips/clip_0001.mp4
output/edit-projects/porsche-session-001/rendered-clips/clip_0002.mp4
output/edit-projects/porsche-session-001/rendered-clips/clip_0003.mp4
```
The render manifest is: The render manifest is:
```text ```text
@ -320,11 +343,12 @@ The QA report is:
output/edit-projects/porsche-session-001/qa-report.json output/edit-projects/porsche-session-001/qa-report.json
``` ```
The manifest is useful for debugging because it records the selected clips, output path, duration, and FFmpeg command summaries. The manifest is useful for debugging because it records the selected clips, rendered segment clip paths, output path, duration, and FFmpeg command summaries.
The QA report is useful for acceptance because it records: The QA report is useful for acceptance because it records:
- final output existence - final output existence
- rendered segment clip existence
- duration consistency with the edit timeline - duration consistency with the edit timeline
- required asset resolution - required asset resolution
- text overlay timeline and placement safety - text overlay timeline and placement safety

View File

@ -0,0 +1,310 @@
# Production Cinematic AI Director Prompt
Use this prompt with a filesystem-capable AI agent such as Codex or Claude when a highlight project has already been analyzed by the service.
Expected project folder:
```text
output/highlight-projects/<project-id>/
```
The AI director must inspect the project artifacts, choose all worthwhile highlight moments, and write a strict renderable edit plan. The goal is a platform-ready cinematic highlight video with strong pacing, fitting sound design, fitting voiceover, tasteful text overlays, and visual treatments that match the actual content.
## Prompt
```text
You are a senior commercial video editor, creative director, sound designer, and post-production supervisor.
Your assignment is to create a production-ready cinematic highlight edit plan from the analyzed project folder. The final rendered video must feel strong enough for a public video streaming platform: clear hook, strong visual selection, coherent micro-story, professional pacing, fitting sound design, tasteful overlays, and a voiceover that matches the actual footage. Do not promise revenue or make unsupported claims; optimize for viewer retention and professional presentation.
PROJECT FOLDER
- Work only inside this folder: output/highlight-projects/<project-id>/
- Read all available project files before deciding:
- project.json
- manifest.json
- analysis/source-analysis.json
- analysis/ffprobe.json
- analysis/scene-segments.json
- analysis/audio-analysis.json
- analysis/visual-analysis.json
- analysis/contact-sheets/*
- analysis/frames/*
- analysis/proxies/* when motion needs closer inspection
- assets/music/*, assets/sfx/*, assets/voiceover/*, assets/overlays/* if present
OUTPUT FILE
- Write the final edit plan JSON to:
director/edit-plan.json
- Return JSON only in that file.
- Do not wrap JSON in Markdown fences.
- Do not render video.
- Do not modify source video, analysis files, thumbnails, contact sheets, or proxies.
PRIMARY GOAL
Create one cinematic highlight video that is specific to the provided footage and content type. The highlight length must be content-driven, not fixed. Use all strong highlights from the source, but do not pad with weak or repetitive footage just to reach a duration.
The edit must have:
- A 1-3 second opening hook that immediately shows the most visually interesting or emotionally compelling moment.
- A clear middle section with rising energy, variety, and no repetitive shot choices.
- A satisfying final hero/payoff shot.
- A final duration that fits the source material and keeps audience retention high.
- Beat-aware pacing and deliberate shot lengths.
- Specific visual treatment notes that the renderer can execute: cinematic grade, punch-in crop, subtle speed ramp, vignette, sharpness, contrast, saturation, slow-motion emphasis, or clean natural grade.
- Fitting music direction and sound effects timing.
- Voiceover text that comments on what is actually visible or strongly implied by the footage.
- Short text overlays only where they improve retention or clarity.
DELIVERABLE EXPECTATION
The service must ultimately render both:
- the full final highlight video
- each rendered timeline segment clip that was used to assemble the full final highlight video
Expected output for the current edit-project renderer:
- final.mp4
- rendered-clips/clip_0001.mp4
- rendered-clips/clip_0002.mp4
- rendered-clips/clip_0003.mp4
- render-manifest.json with renderedClipPaths
Expected output for the highlight-project renderer when implemented:
- highlights/<highlight-id>/final.mp4
- highlights/<highlight-id>/rendered-clips/clip_0001.mp4
- highlights/<highlight-id>/rendered-clips/clip_0002.mp4
- highlights/<highlight-id>/rendered-clips/clip_0003.mp4
- highlights/<highlight-id>/render-manifest.json with renderedClipPaths
CONTENT CATEGORY DECISION
Classify the project into exactly one content category:
- car_vlog
- food_vlog
- family_vlog
- generic_vlog
Use the category to direct the whole edit.
For car_vlog:
- Make it premium, powerful, precise, and aspirational.
- Prioritize hero angles, reflections, wheels, badges, road movement, cockpit details, acceleration, reveal shots, light, shadow, and motion.
- Use punchy cuts, speed ramps, whooshes, impact hits, engine/exhaust SFX only when appropriate, cinematic contrast, and confident short overlays.
- Voiceover should praise what is actually visible: stance, motion, design, presence, craft, road feel. Do not invent specifications, price, horsepower, rarity, or model facts unless visible in the footage.
For food_vlog:
- Make it sensory, warm, rhythmic, and appetizing.
- Prioritize texture, slicing, pouring, steam, sizzling, plating, table reveals, reactions, and macro-like detail.
- Use tactile SFX, warm grade, gentle groove, ingredient/payoff overlays, and voiceover focused on craft, freshness, texture, and taste.
For family_vlog:
- Make it warm, emotional, human, and memory-focused.
- Prioritize faces, reactions, laughter, hugs, children, travel reveals, meaningful moments, and natural dialogue.
- Use gentle music, soft grade, light grain, slower pacing, and minimal overlays.
- Voiceover should feel personal and reflective, not promotional.
For generic_vlog:
- Build a clear mini-story from novelty, clean composition, motion, reaction, place, process, and payoff.
- Use restrained cinematic effects and explain only what the footage supports.
SHOT SELECTION RULES
Before writing JSON, inspect the contact sheets and representative frames.
Create a private selection table for yourself:
- candidate source range
- visual appeal
- motion/energy
- uniqueness
- story role
- audio opportunity
- risk: blur, darkness, repetitive angle, dead time
Then use only the best ranges. Avoid:
- repeated near-identical shots unless repetition is intentional
- long boring segments
- badly blurred or unusably dark footage unless it is the only important moment
- shots with no story purpose
- unsupported narration
TIMELINE AND PACING RULES
- Do not force a strict 60 second edit unless the footage actually supports it.
- Derive the final duration from the input source length and number of strong moments.
- Include every strong highlight moment that improves the final video.
- Exclude weak, repetitive, technically poor, or dead-time footage even if the source is long.
- Maximum final duration is 600 seconds, which is 10 minutes.
- Most videos should be shorter than the maximum. Use 10 minutes only when the source has enough distinct, high-quality highlights to justify it.
- Recommended duration guide:
- Source under 30 seconds: final highlight usually 8-20 seconds.
- Source 30-90 seconds: final highlight usually 15-45 seconds.
- Source 90 seconds-5 minutes: final highlight usually 30-120 seconds.
- Source 5-20 minutes: final highlight usually 60-180 seconds.
- Source over 20 minutes: final highlight can be longer, but only if the footage has enough distinct high-quality moments.
- The JSON field targetDurationSeconds must equal the actual planned final timeline duration, not a fixed configured default.
- The final decision timelineEndSeconds must equal targetDurationSeconds.
- Timeline must start at 0.0.
- Every decision timelineStartSeconds must equal the previous decision timelineEndSeconds.
- No overlaps. No gaps.
- For each decision:
rendered duration = (sourceEndSeconds - sourceStartSeconds) / playbackSpeed
timelineEndSeconds - timelineStartSeconds must equal rendered duration within 0.05 seconds.
- playbackSpeed must be between 0.25 and 4.0.
- Keep most shots between 1.0 and 4.0 seconds.
- Use longer shots only for emotional moments, reveals, or beautiful hero motion.
- Use faster shots for montages, details, impacts, or beat drops.
ALLOWED TRANSITIONS
Use only:
- cut
- crossfade
- fade-in
- fade-out
- none
Prefer mostly cuts. Use crossfade/fades sparingly for emotional, elegant, or time-passing moments.
VISUAL TREATMENT RULES
visualTreatment must be concise but specific. Good examples:
- cinematic grade with subtle punch-in
- high-contrast car hero grade
- warm food closeup grade
- soft family memory grade
- speed-ramp detail accent
- clean natural grade
- none
Use visual effects to support the moment. Do not overuse effects.
AUDIO RULES
Use audioCues for music and SFX direction.
Music:
- A music cue may use a descriptive assetKey such as premium_cinematic_drive, warm_acoustic_memory, tactile_food_groove, urban_pulse_intro.
- Music should cover the full timeline unless silence is intentionally used.
- Set gainDb conservatively, usually between -22 and -10.
- Describe the music mood and why it fits.
SFX:
- Only reference SFX assetKey values that exist as WAV files in the project audio/sfx folder or assets/sfx folder if the renderer maps them.
- If no SFX files exist, do not invent file-backed SFX cues. Instead, put desired SFX ideas in the cue notes only if the service can later generate assets.
- SFX should be precise: whoosh on transition, bass hit on reveal, camera shutter on detail, subtle riser before hero shot, engine accent for car footage, sizzle/chop/pour for food, gentle ambience for family.
VOICEOVER RULES
- Voiceover is optional only if the project disables it. If enabled, include concise lines.
- Each line must be 240 characters or fewer.
- Use natural, premium, non-cheesy language.
- Voiceover must fit the category and visible content.
- Do not use generic filler like "this is amazing" or "an unforgettable journey" unless the footage clearly supports it.
- Do not invent facts.
- Give each line a delivery direction, for example:
- calm premium narrator
- confident cinematic narrator
- warm reflective narrator
- energetic food host
TEXT OVERLAY RULES
- Overlays must be short: 80 characters or fewer.
- Use only these placements:
- lower_left_safe
- lower_center_safe
- center_safe
- upper_left_safe
- upper_right_safe
- Use only these animations:
- fade_slide_up
- fade_in
- none
- Use overlays sparingly. 1-5 overlays is usually enough.
- Overlays should feel premium and specific, not clickbait.
QUALITY BAR
Reject your own first draft if it has any of these problems:
- The first 3 seconds are not compelling.
- The edit is just chronological trimming without a story.
- The voiceover is generic or not grounded in visible footage.
- Music or SFX are placeholders with no timing purpose.
- Text overlays repeat what the voiceover already says.
- The final shot is weak.
- Any JSON field uses invalid values.
- Any clip ID or timestamp is invented.
- Any timeline math is invalid.
REQUIRED JSON SHAPE
Write exactly this JSON shape:
{
"projectId": "<project-id>",
"style": "<style-from-project-json>",
"targetDurationSeconds": <actual-planned-final-duration-seconds>,
"decisions": [
{
"clipId": "<valid clip id from analysis>",
"sourceStartSeconds": 0.0,
"sourceEndSeconds": 2.5,
"timelineStartSeconds": 0.0,
"timelineEndSeconds": 2.5,
"transitionIn": "cut",
"transitionOut": "cut",
"playbackSpeed": 1.0,
"visualTreatment": "cinematic grade with subtle punch-in",
"reason": "Opening hook: strongest hero angle and immediate viewer retention."
}
],
"audioCues": [
{
"type": "music",
"assetKey": "premium_cinematic_drive",
"timelineStartSeconds": 0.0,
"timelineEndSeconds": <actual-planned-final-duration-seconds>,
"gainDb": -16.0,
"notes": "Low cinematic pulse that builds through detail shots and lifts into the final hero moment."
}
],
"voiceover": [
{
"text": "A concise, specific line grounded in what is visible in the footage.",
"timelineStartSeconds": 1.0,
"timelineEndSeconds": 4.0,
"delivery": "confident cinematic narrator"
}
],
"overlays": [
{
"text": "Short premium overlay",
"timelineStartSeconds": 0.5,
"timelineEndSeconds": 2.0,
"placement": "lower_left_safe",
"animation": "fade_slide_up",
"reason": "Adds context without duplicating the narration."
}
],
"renderProfile": "mp4-h264-aac-1080p",
"summary": "A short explanation of the creative strategy, category choice, hook, pacing, sound design, and final payoff."
}
FINAL SELF-CHECK BEFORE WRITING director/edit-plan.json
Verify:
- projectId matches project.json.
- style matches project.json.
- targetDurationSeconds equals the actual final timeline duration selected from the source highlights.
- decisions is not empty.
- audioCues, voiceover, and overlays are arrays even when empty.
- All clip IDs exist.
- All source ranges are inside clip duration.
- Timeline starts at 0.0 and is contiguous.
- Final decision timelineEndSeconds equals targetDurationSeconds.
- Duration math matches playbackSpeed.
- transitionIn and transitionOut use only allowed values.
- playbackSpeed is between 0.25 and 4.0.
- overlay placements and animations use only allowed values.
- overlay text is 80 characters or fewer.
- voiceover lines are 240 characters or fewer.
- SFX asset keys reference real files if SFX cues are included.
- JSON is valid and contains no comments or Markdown.
```
## Notes For The Service
This prompt is intentionally stricter than the current lightweight storyboard prompt. It is designed to prevent the common failure mode where the AI produces a generic, boring, or invalid edit plan.
Current renderer constraints still matter:
- `FfmpegEditRenderer` can render the `EditPlan` schema used by `output/edit-projects`.
- The highlight project flow still needs an importer/renderer bridge before `output/highlight-projects/<project-id>/highlights/<highlight-id>/final.mp4` can be produced automatically.
- If SFX are referenced, the current validator expects matching WAV files for SFX asset keys. Do not ask the AI to reference non-existent SFX files unless the asset generation step is implemented first.
- The current noop voiceover provider writes a script, not a real spoken voice. Production-ready voice requires a real TTS provider or recorded voiceover asset.

View File

@ -204,6 +204,7 @@ The system needs explicit providers instead of pretending a prompt can create fi
- Font provider: local fonts with brand/category mapping. - Font provider: local fonts with brand/category mapping.
- LUT provider: local LUT files or named FFmpeg grade presets. - LUT provider: local LUT files or named FFmpeg grade presets.
- Asset cache: deterministic reuse by prompt, category, duration, and provider settings. - Asset cache: deterministic reuse by prompt, category, duration, and provider settings.
- Asset generation stage: when a needed asset is missing, write a reusable request pack and keep the target cache path stable so future AI-generated assets can be reused.
Licensing must be tracked in `render-manifest.json` for any music, SFX, voice, font, or LUT asset used in a final export. Licensing must be tracked in `render-manifest.json` for any music, SFX, voice, font, or LUT asset used in a final export.
@ -552,6 +553,9 @@ Expected renderer output:
```text ```text
output/highlight-projects/<project-id>/highlights/<highlight-id>/final.mp4 output/highlight-projects/<project-id>/highlights/<highlight-id>/final.mp4
output/highlight-projects/<project-id>/highlights/<highlight-id>/rendered-clips/clip_0001.mp4
output/highlight-projects/<project-id>/highlights/<highlight-id>/rendered-clips/clip_0002.mp4
output/highlight-projects/<project-id>/highlights/<highlight-id>/rendered-clips/clip_0003.mp4
output/highlight-projects/<project-id>/highlights/<highlight-id>/preview.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>/render-manifest.json
output/highlight-projects/<project-id>/highlights/<highlight-id>/qa-report.json output/highlight-projects/<project-id>/highlights/<highlight-id>/qa-report.json

View File

@ -0,0 +1,15 @@
{
"id" : "source-clips",
"name" : "source-clips",
"status" : "RENDERED",
"inputDirectory" : "./input/editing/processed/source-clips",
"outputDirectory" : "output/edit-projects/source-clips",
"targetDurationSeconds" : 60,
"style" : "cinematic-porsche-promo",
"voiceoverEnabled" : true,
"musicEnabled" : true,
"soundEffectsEnabled" : true,
"createdAt" : "2026-07-10T17:25:59.366353Z",
"updatedAt" : "2026-07-10T21:58:05.307273Z",
"failureReason" : null
}

File diff suppressed because one or more lines are too long

View File

@ -512,7 +512,7 @@ public class VideoClippingProperties {
private double silenceMinimumDurationSeconds = 0.5; private double silenceMinimumDurationSeconds = 0.5;
@Min(1) @Min(1)
private int targetDurationSeconds = 60; private int targetDurationSeconds = 600;
@Min(1) @Min(1)
private int outputWidth = 1920; private int outputWidth = 1920;
@ -1078,6 +1078,21 @@ public class VideoClippingProperties {
@Min(1000) @Min(1000)
private long pollIntervalMs = 5000; private long pollIntervalMs = 5000;
private boolean renderEnabled = true;
private boolean requireDirectorApproval = false;
private String approvalFileName = "approved.flag";
@Min(1)
private int maxHighlightsPerSource = 3;
@Min(1)
private double highlightMinDurationSeconds = 8.0;
@Min(1)
private double highlightMaxDurationSeconds = 35.0;
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;
} }
@ -1125,6 +1140,54 @@ public class VideoClippingProperties {
public void setPollIntervalMs(long pollIntervalMs) { public void setPollIntervalMs(long pollIntervalMs) {
this.pollIntervalMs = pollIntervalMs; this.pollIntervalMs = pollIntervalMs;
} }
public boolean isRenderEnabled() {
return renderEnabled;
}
public void setRenderEnabled(boolean renderEnabled) {
this.renderEnabled = renderEnabled;
}
public boolean isRequireDirectorApproval() {
return requireDirectorApproval;
}
public void setRequireDirectorApproval(boolean requireDirectorApproval) {
this.requireDirectorApproval = requireDirectorApproval;
}
public String getApprovalFileName() {
return approvalFileName;
}
public void setApprovalFileName(String approvalFileName) {
this.approvalFileName = approvalFileName;
}
public int getMaxHighlightsPerSource() {
return maxHighlightsPerSource;
}
public void setMaxHighlightsPerSource(int maxHighlightsPerSource) {
this.maxHighlightsPerSource = maxHighlightsPerSource;
}
public double getHighlightMinDurationSeconds() {
return highlightMinDurationSeconds;
}
public void setHighlightMinDurationSeconds(double highlightMinDurationSeconds) {
this.highlightMinDurationSeconds = highlightMinDurationSeconds;
}
public double getHighlightMaxDurationSeconds() {
return highlightMaxDurationSeconds;
}
public void setHighlightMaxDurationSeconds(double highlightMaxDurationSeconds) {
this.highlightMaxDurationSeconds = highlightMaxDurationSeconds;
}
} }
} }

View File

@ -37,6 +37,7 @@ public class EditPlanInboxScanner {
private final EditProjectStore store; private final EditProjectStore store;
private final EditProjectService projectService; private final EditProjectService projectService;
private final EditPlanValidator validator; private final EditPlanValidator validator;
private final Optional<AssetGenerationStage> assetGenerationStage;
private final Optional<EditRenderer> renderer; private final Optional<EditRenderer> renderer;
private final VoiceoverGenerator voiceoverGenerator; private final VoiceoverGenerator voiceoverGenerator;
private final AtomicBoolean scanning = new AtomicBoolean(); private final AtomicBoolean scanning = new AtomicBoolean();
@ -49,9 +50,11 @@ public class EditPlanInboxScanner {
EditProjectService projectService, EditProjectService projectService,
EditPlanValidator validator, EditPlanValidator validator,
VoiceoverGenerator voiceoverGenerator, VoiceoverGenerator voiceoverGenerator,
ObjectProvider<AssetGenerationStage> assetGenerationStageProvider,
ObjectProvider<EditRenderer> rendererProvider ObjectProvider<EditRenderer> rendererProvider
) { ) {
this(properties, objectMapper, store, projectService, validator, voiceoverGenerator, this(properties, objectMapper, store, projectService, validator, voiceoverGenerator,
assetGenerationStageProvider.getIfAvailable(),
rendererProvider.getIfAvailable()); rendererProvider.getIfAvailable());
} }
@ -63,13 +66,13 @@ public class EditPlanInboxScanner {
EditPlanValidator validator, EditPlanValidator validator,
EditRenderer renderer EditRenderer renderer
) { ) {
this(properties, objectMapper, store, projectService, validator, null, renderer); this(properties, objectMapper, store, projectService, validator, null, null, renderer);
} }
EditPlanInboxScanner( EditPlanInboxScanner(
VideoClippingProperties properties, ObjectMapper objectMapper, EditProjectStore store, VideoClippingProperties properties, ObjectMapper objectMapper, EditProjectStore store,
EditProjectService projectService, EditPlanValidator validator, EditProjectService projectService, EditPlanValidator validator,
VoiceoverGenerator voiceoverGenerator, EditRenderer renderer VoiceoverGenerator voiceoverGenerator, AssetGenerationStage assetGenerationStage, EditRenderer renderer
) { ) {
this.projectRoot = Path.of(properties.getEditing().getProjectDirectory()); this.projectRoot = Path.of(properties.getEditing().getProjectDirectory());
this.expectedPlanFileName = properties.getEditing().getLocalDirector().getExpectedPlanFileName(); this.expectedPlanFileName = properties.getEditing().getLocalDirector().getExpectedPlanFileName();
@ -82,6 +85,7 @@ public class EditPlanInboxScanner {
this.validator = validator; this.validator = validator;
this.renderer = Optional.ofNullable(renderer); this.renderer = Optional.ofNullable(renderer);
this.voiceoverGenerator = voiceoverGenerator; this.voiceoverGenerator = voiceoverGenerator;
this.assetGenerationStage = Optional.ofNullable(assetGenerationStage);
} }
@Scheduled( @Scheduled(
@ -154,6 +158,11 @@ public class EditPlanInboxScanner {
} }
store.writeJson(projectId, "edit-plan.json", normalized(plan)); store.writeJson(projectId, "edit-plan.json", normalized(plan));
AssetGenerationResult assetGenerationResult = assetGenerationStage
.map(stage -> stage.prepare(projectId, plan))
.orElse(new AssetGenerationResult(projectId, true, List.of(), java.time.Instant.now()));
log.info("event=edit_asset_generation_completed project_id={} item_count={} ready_for_render={}",
projectId, assetGenerationResult.items().size(), assetGenerationResult.readyForRender());
if (voiceoverGenerator != null && !plan.voiceover().isEmpty()) { if (voiceoverGenerator != null && !plan.voiceover().isEmpty()) {
voiceoverGenerator.generateVoiceover(projectId, plan.voiceover()); voiceoverGenerator.generateVoiceover(projectId, plan.voiceover());
} }
@ -164,8 +173,11 @@ public class EditPlanInboxScanner {
projectId, plan.decisions().size(), autoRender, requireApprovalBeforeRender); projectId, plan.decisions().size(), autoRender, requireApprovalBeforeRender);
log.info("event=edit_plan_saved project_id={} decision_count={} source=inbox", log.info("event=edit_plan_saved project_id={} decision_count={} source=inbox",
projectId, plan.decisions().size()); projectId, plan.decisions().size());
if (autoRender) { if (autoRender && assetGenerationResult.readyForRender()) {
renderIfApproved(projectId, store.inboxDirectory(projectId)); renderIfApproved(projectId, store.inboxDirectory(projectId));
} else if (autoRender) {
log.info("event=edit_render_waiting_for_assets project_id={} blocking_assets_pending=true",
projectId);
} }
} }
@ -175,6 +187,16 @@ public class EditPlanInboxScanner {
projectId, inbox.resolve(approvalFileName)); projectId, inbox.resolve(approvalFileName));
return; return;
} }
AssetGenerationStage stage = assetGenerationStage.orElse(null);
if (stage != null) {
EditPlan plan = store.readJson(projectId, "edit-plan.json", EditPlan.class);
AssetGenerationResult result = stage.prepare(projectId, plan);
if (!result.readyForRender()) {
log.info("event=edit_render_waiting_for_assets project_id={} blocking_assets_pending=true",
projectId);
return;
}
}
renderer.orElseThrow(() -> new IllegalStateException( renderer.orElseThrow(() -> new IllegalStateException(
"Automatic rendering is enabled but no EditRenderer is available")).render(projectId); "Automatic rendering is enabled but no EditRenderer is available")).render(projectId);
} }

View File

@ -80,9 +80,13 @@ public class EditPlanValidator {
validateAudioCues(projectId, plan); validateAudioCues(projectId, plan);
validateVoiceover(plan); validateVoiceover(plan);
validateOverlays(plan); validateOverlays(plan);
if (previousEnd > project.targetDurationSeconds() + TARGET_TOLERANCE_SECONDS validateCinematicRichness(project, plan);
|| Math.abs(plan.targetDurationSeconds() - project.targetDurationSeconds()) > EPSILON) { if (plan.targetDurationSeconds() <= 0
reject("Edit plan duration exceeds or does not match the project target duration"); || Math.abs(previousEnd - plan.targetDurationSeconds()) > TARGET_TOLERANCE_SECONDS) {
reject("Edit plan target duration must match the final timeline duration");
}
if (plan.targetDurationSeconds() > project.targetDurationSeconds() + TARGET_TOLERANCE_SECONDS) {
reject("Edit plan duration exceeds the project maximum target duration");
} }
return plan; return plan;
} }
@ -152,6 +156,42 @@ public class EditPlanValidator {
} }
} }
private void validateCinematicRichness(EditProject project, EditPlan plan) {
int cinematicDimensions = 0;
if (hasVisualTreatment(plan)) {
cinematicDimensions++;
}
if (project.musicEnabled() && hasAudioCueType(plan, "music")) {
cinematicDimensions++;
}
if (project.voiceoverEnabled() && !plan.voiceover().isEmpty()) {
cinematicDimensions++;
}
if (project.soundEffectsEnabled() && hasAudioCueType(plan, "sfx")) {
cinematicDimensions++;
}
if (!plan.overlays().isEmpty()) {
cinematicDimensions++;
}
if (cinematicDimensions < 2) {
reject("Edit plan is too bare; it must use at least two cinematic elements such as visual treatment, "
+ "music, voiceover, sound effects, or text overlays");
}
}
private boolean hasVisualTreatment(EditPlan plan) {
return plan.decisions().stream()
.map(EditDecision::visualTreatment)
.filter(value -> value != null && !value.isBlank() && !"none".equalsIgnoreCase(value))
.findFirst()
.isPresent();
}
private boolean hasAudioCueType(EditPlan plan, String type) {
return plan.audioCues().stream().anyMatch(cue -> type.equals(cue.type()));
}
private void validateTransition(String transition) { private void validateTransition(String transition) {
if (transition == null || !TRANSITIONS.contains(transition)) { if (transition == null || !TRANSITIONS.contains(transition)) {
reject("Unsupported transition: " + transition); reject("Unsupported transition: " + transition);

View File

@ -127,6 +127,7 @@ public class FfmpegEditRenderer implements EditRenderer {
runAndRecord(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output), commands); runAndRecord(segmentCommand(clips.get(decision.clipId()).sourcePath(), decision, output), commands);
segments.add(output); segments.add(output);
} }
List<Path> renderedClips = publishRenderedClips(projectId, segments);
Path concatFile = work.resolve("concat.txt"); Path concatFile = work.resolve("concat.txt");
writeConcatFile(concatFile, segments); writeConcatFile(concatFile, segments);
Path timeline = work.resolve("timeline-video.mp4"); Path timeline = work.resolve("timeline-video.mp4");
@ -152,10 +153,11 @@ public class FfmpegEditRenderer implements EditRenderer {
copy(videoTimeline, output); copy(videoTimeline, output);
} }
double duration = plan.decisions().get(plan.decisions().size() - 1).timelineEndSeconds(); double duration = plan.decisions().get(plan.decisions().size() - 1).timelineEndSeconds();
RenderQaReport qaReport = buildQaReport(projectId, plan, output, duration, commands, assets); RenderQaReport qaReport = buildQaReport(projectId, plan, output, duration, renderedClips, commands, assets);
RenderManifest manifest = new RenderManifest(projectId, RenderManifest manifest = new RenderManifest(projectId,
plan.decisions().stream().map(EditDecision::clipId).toList(), output.toString(), duration, plan.decisions().stream().map(EditDecision::clipId).toList(),
List.copyOf(commands), assets, Instant.now()); renderedClips.stream().map(Path::toString).toList(), output.toString(), duration, List.copyOf(commands),
assets, Instant.now());
store.writeJson(projectId, "qa-report.json", qaReport); store.writeJson(projectId, "qa-report.json", qaReport);
store.writeJson(projectId, "render-manifest.json", manifest); store.writeJson(projectId, "render-manifest.json", manifest);
projectService.updateProject(projectId, EditProjectStatus.RENDERED, Path.of(project.inputDirectory()), null); projectService.updateProject(projectId, EditProjectStatus.RENDERED, Path.of(project.inputDirectory()), null);
@ -361,9 +363,11 @@ public class FfmpegEditRenderer implements EditRenderer {
} }
RenderQaReport buildQaReport(String projectId, EditPlan plan, Path output, double duration, RenderQaReport buildQaReport(String projectId, EditPlan plan, Path output, double duration,
List<Path> renderedClips,
List<List<String>> commands, List<ResolvedEditAsset> assets) { List<List<String>> commands, List<ResolvedEditAsset> assets) {
List<RenderQaCheck> checks = new ArrayList<>(); List<RenderQaCheck> checks = new ArrayList<>();
checks.add(outputExistsCheck(output)); checks.add(outputExistsCheck(output));
checks.add(renderedClipsExistCheck(renderedClips));
checks.add(durationMatchesTimelineCheck(plan, duration)); checks.add(durationMatchesTimelineCheck(plan, duration));
checks.add(requiredAssetsResolvedCheck(plan, assets)); checks.add(requiredAssetsResolvedCheck(plan, assets));
checks.add(overlaysSafeCheck(plan, duration)); checks.add(overlaysSafeCheck(plan, duration));
@ -388,6 +392,14 @@ public class FfmpegEditRenderer implements EditRenderer {
exists ? "final output exists" : "final output is missing: " + output); exists ? "final output exists" : "final output is missing: " + output);
} }
private RenderQaCheck renderedClipsExistCheck(List<Path> renderedClips) {
List<Path> missing = renderedClips.stream()
.filter(path -> !Files.isRegularFile(path))
.toList();
return new RenderQaCheck("rendered_clips_exist", missing.isEmpty(), "ERROR",
missing.isEmpty() ? "all rendered segment clips exist" : "missing rendered clips: " + missing);
}
private RenderQaCheck durationMatchesTimelineCheck(EditPlan plan, double duration) { private RenderQaCheck durationMatchesTimelineCheck(EditPlan plan, double duration) {
double expected = plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1) double expected = plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1)
.timelineEndSeconds(); .timelineEndSeconds();
@ -518,6 +530,18 @@ public class FfmpegEditRenderer implements EditRenderer {
} }
} }
private List<Path> publishRenderedClips(String projectId, List<Path> segments) {
Path publishedDirectory = store.projectDirectory(projectId).resolve("rendered-clips");
createDirectory(publishedDirectory);
List<Path> published = new ArrayList<>();
for (int index = 0; index < segments.size(); index++) {
Path target = publishedDirectory.resolve("clip_%04d.mp4".formatted(index + 1));
copy(segments.get(index), target);
published.add(target);
}
return List.copyOf(published);
}
private long fileSize(Path file) { private long fileSize(Path file) {
try { try {
return Files.size(file); return Files.size(file);

View File

@ -0,0 +1,14 @@
package org.example.videoclips.editing;
public record HighlightAssetRequest(
String projectId,
String highlightId,
String type,
String assetKey,
String targetPath,
String requestPath,
String notes,
double durationSeconds,
boolean blocking
) {
}

View File

@ -0,0 +1,338 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.config.VideoClippingProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class HighlightDirectorFlowService {
private static final Logger log = LoggerFactory.getLogger(HighlightDirectorFlowService.class);
private final VideoClippingProperties.Editing.HighlightScheduler properties;
private final ObjectMapper objectMapper;
private final HighlightProjectStore store;
private final HighlightVisualEffectsStage visualEffectsStage;
private final HighlightAssetPreparationService assetPreparationService;
private final HighlightLocalAssetWorker assetWorker;
private final HighlightFfmpegRenderer renderer;
public HighlightDirectorFlowService(VideoClippingProperties properties, ObjectMapper objectMapper,
HighlightProjectStore store,
HighlightVisualEffectsStage visualEffectsStage,
HighlightAssetPreparationService assetPreparationService,
HighlightLocalAssetWorker assetWorker,
HighlightFfmpegRenderer renderer) {
this.properties = properties.getEditing().getHighlightScheduler();
this.objectMapper = objectMapper;
this.store = store;
this.visualEffectsStage = visualEffectsStage;
this.assetPreparationService = assetPreparationService;
this.assetWorker = assetWorker;
this.renderer = renderer;
}
public HighlightFlowResult process(String projectId, long scanId) {
long startedAt = System.nanoTime();
String flowId = projectId + ":" + scanId;
HighlightProject project = store.readJson(projectId, "project.json", HighlightProject.class);
Path planFile = store.directorDirectory(projectId).resolve("edit-plan.json");
if (!Files.isRegularFile(planFile)) {
return HighlightFlowResult.skipped(projectId, flowId, "missing_director_plan");
}
if (properties.isRequireDirectorApproval()
&& !Files.isRegularFile(store.directorDirectory(projectId).resolve(properties.getApprovalFileName()))) {
log.info("event=highlight_flow_waiting_for_approval flow_id={} project_id={} approval_file={}",
flowId, projectId, store.directorDirectory(projectId).resolve(properties.getApprovalFileName()));
return HighlightFlowResult.skipped(projectId, flowId, "approval_missing");
}
HighlightDirectorPlan plan = readPlan(planFile);
if (plan.highlights().isEmpty()) {
markFailed(project, "No highlights were provided by the director plan");
throw new IllegalStateException("Director plan contains no highlights");
}
markStatus(project, HighlightProjectStatus.RENDERING, null);
log.info("event=highlight_flow_started flow_id={} scan_id={} project_id={} source_file={} highlights={}",
flowId, scanId, projectId, project.sourceVideoFileName(), plan.highlights().size());
List<Path> finalOutputs = new ArrayList<>();
List<String> renderedHighlightIds = new ArrayList<>();
ContentCategory category = contentCategory(plan.contentCategory());
int limit = Math.min(properties.getMaxHighlightsPerSource(), plan.highlights().size());
for (int index = 0; index < limit; index++) {
HighlightDirectorPlan.HighlightItem highlight = plan.highlights().get(index);
Path highlightDirectory = store.highlightsDirectory(projectId).resolve(highlight.highlightId());
writeStoryboard(highlightDirectory, plan, highlight);
visualEffectsStage.create(projectId, highlight, category);
EditPlan editPlan = buildEditPlan(project, plan, highlight, index);
store.writeJson(projectId, "highlights/" + highlight.highlightId() + "/edit-plan.json", editPlan);
HighlightAssetPreparationService.HighlightAssetPreparationResult assets = assetPreparationService
.prepare(projectId, project, highlight, category);
HighlightLocalAssetWorker.HighlightAssetWorkerResult assetWorkerResult = assetWorker.process(projectId,
highlight, category);
log.info("event=highlight_assets_prepared flow_id={} project_id={} highlight_id={} resolved={} requests={}",
flowId, projectId, highlight.highlightId(), assets.resolvedAssets().size(),
assets.requestFiles().size());
log.info("event=highlight_asset_worker_completed flow_id={} project_id={} highlight_id={} resolved={} pending={}",
flowId, projectId, highlight.highlightId(), assetWorkerResult.resolvedAssets().size(),
assetWorkerResult.pendingRequests().size());
HighlightFfmpegRenderer.HighlightRenderResult result = renderer.render(projectId, highlight, editPlan);
finalOutputs.add(result.finalOutput());
renderedHighlightIds.add(result.highlightId());
}
Path projectOutput = store.projectDirectory(projectId).resolve("final.mp4");
concatFinalOutputs(projectOutput, finalOutputs);
RenderManifest manifest = new RenderManifest(projectId, renderedHighlightIds, finalOutputs.stream()
.map(Path::toString).toList(), projectOutput.toString(),
finalOutputs.stream().mapToDouble(this::probeDuration).sum(), List.of(), List.of(), Instant.now());
store.writeJson(projectId, "render-manifest.json", manifest);
markStatus(project, HighlightProjectStatus.RENDERED, null);
log.info("event=highlight_flow_completed flow_id={} scan_id={} project_id={} final_outputs={} elapsed_ms={}",
flowId, scanId, projectId, finalOutputs, (System.nanoTime() - startedAt) / 1_000_000);
return HighlightFlowResult.rendered(projectId, flowId, finalOutputs, projectOutput);
}
private HighlightDirectorPlan readPlan(Path planFile) {
try {
return objectMapper.readValue(planFile.toFile(), HighlightDirectorPlan.class);
} catch (IOException ex) {
throw new IllegalStateException("Unable to read highlight director plan: " + planFile, ex);
}
}
private EditPlan buildEditPlan(HighlightProject project, HighlightDirectorPlan plan,
HighlightDirectorPlan.HighlightItem highlight, int index) {
HighlightSourceAnalysis analysis = store.readJson(project.id(), "analysis/source-analysis.json",
HighlightSourceAnalysis.class);
double sourceDuration = Math.max(0.01, highlight.sourceEndSeconds() - highlight.sourceStartSeconds());
double requestedDuration = highlight.targetDurationSeconds() > 0 ? highlight.targetDurationSeconds()
: sourceDuration;
double targetDuration = Math.max(properties.getHighlightMinDurationSeconds(),
Math.min(properties.getHighlightMaxDurationSeconds(), requestedDuration));
double playbackSpeed = Math.max(0.25, Math.min(4.0, sourceDuration / targetDuration));
String clipId = analysis.source().clipId();
EditDecision decision = new EditDecision(
clipId,
highlight.sourceStartSeconds(),
highlight.sourceEndSeconds(),
0.0,
targetDuration,
transitionIn(highlight, index),
transitionOut(highlight, index, plan.highlights().size()),
playbackSpeed,
highlight.visualTreatment(),
highlight.renderNotes()
);
List<AudioCue> audioCues = new ArrayList<>();
if (highlight.musicDirection() != null && !highlight.musicDirection().isBlank()) {
audioCues.add(new AudioCue("music", safeKey("music", highlight.musicDirection()), 0.0, targetDuration,
-10.0, highlight.musicDirection()));
}
if (highlight.sfxDirection() != null && !highlight.sfxDirection().isBlank()) {
audioCues.add(new AudioCue("sfx", safeKey("sfx", highlight.storyPurpose(), highlight.highlightId()),
Math.max(0.0, targetDuration - 0.8), targetDuration, -6.0, highlight.sfxDirection()));
}
List<VoiceoverLine> voiceover = new ArrayList<>();
if (!highlight.voiceover().isEmpty()) {
double step = targetDuration / highlight.voiceover().size();
for (int i = 0; i < highlight.voiceover().size(); i++) {
double start = Math.min(targetDuration, i * step);
double end = Math.min(targetDuration, start + Math.max(1.0, step));
voiceover.add(new VoiceoverLine(highlight.voiceover().get(i), start, end, "cinematic_narration"));
}
}
List<TextOverlay> overlays = new ArrayList<>();
if (!highlight.overlays().isEmpty()) {
double step = targetDuration / highlight.overlays().size();
for (int i = 0; i < highlight.overlays().size(); i++) {
double start = Math.min(targetDuration, i * step);
double end = Math.min(targetDuration, start + Math.max(1.5, step * 0.75));
overlays.add(new TextOverlay(highlight.overlays().get(i), start, end,
i % 2 == 0 ? "lower_center_safe" : "center_safe",
"fade_slide_up", highlight.renderNotes()));
}
}
return new EditPlan(project.id(),
safeKey("style", plan.contentCategory(), highlight.storyPurpose()),
targetDuration,
List.of(decision),
audioCues,
voiceover,
overlays,
"mp4-h264-aac-1080p",
highlight.title() + " / " + highlight.storyPurpose());
}
private String transitionIn(HighlightDirectorPlan.HighlightItem highlight, int index) {
if (index == 0 || highlight.storyPurpose() != null && highlight.storyPurpose().contains("opening")) {
return "fade-in";
}
return "cut";
}
private String transitionOut(HighlightDirectorPlan.HighlightItem highlight, int index, int total) {
if (index + 1 == total || highlight.storyPurpose() != null && highlight.storyPurpose().contains("hero")) {
return "fade-out";
}
return "cut";
}
private void writeStoryboard(Path highlightDirectory, HighlightDirectorPlan plan,
HighlightDirectorPlan.HighlightItem highlight) {
String storyboard = """
# Storyboard
Project: `%s`
Highlight: `%s`
Title: `%s`
Purpose: `%s`
Music: `%s`
SFX: `%s`
Voiceover:
%s
Overlays:
%s
""".formatted(plan.projectId(), highlight.highlightId(), highlight.title(), highlight.storyPurpose(),
highlight.musicDirection(), highlight.sfxDirection(), String.join(System.lineSeparator(),
highlight.voiceover()), String.join(System.lineSeparator(), highlight.overlays()));
try {
Files.createDirectories(highlightDirectory);
Files.writeString(highlightDirectory.resolve("storyboard.md"), storyboard, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new IllegalStateException("Unable to write storyboard for highlight: " + highlight.highlightId(), ex);
}
}
private void concatFinalOutputs(Path output, List<Path> sources) {
if (sources.isEmpty()) {
throw new IllegalStateException("No rendered highlight outputs were produced");
}
if (sources.size() == 1) {
copy(sources.get(0), output);
return;
}
Path work = output.getParent().resolve("project-render-work");
try {
Files.createDirectories(work);
} catch (IOException ex) {
throw new IllegalStateException("Unable to create highlight project render work directory", ex);
}
Path concat = work.resolve("concat.txt");
String content = sources.stream()
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")
.reduce("", (a, b) -> a + b + System.lineSeparator());
try {
Files.writeString(concat, content, StandardCharsets.UTF_8);
} catch (IOException ex) {
throw new IllegalStateException("Unable to write project concat file", ex);
}
run(List.of("ffmpeg", "-hide_banner", "-y", "-f", "concat", "-safe", "0", "-i", concat.toString(),
"-c", "copy", output.toString()));
}
private double probeDuration(Path video) {
try {
Process process = new ProcessBuilder("ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", video.toString()).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (process.waitFor() != 0) {
return 0.0;
}
return Double.parseDouble(output.trim());
} catch (IOException | InterruptedException ex) {
Thread.currentThread().interrupt();
return 0.0;
}
}
private void run(List<String> command) {
try {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (process.waitFor() != 0) {
throw new IllegalStateException(output);
}
} catch (IOException ex) {
throw new IllegalStateException("Unable to run highlight concat command", ex);
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while concatenating highlight outputs", ex);
}
}
private void copy(Path source, Path target) {
try {
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new IllegalStateException("Unable to publish highlight project output", ex);
}
}
private ContentCategory contentCategory(String value) {
if (value == null || value.isBlank()) {
return ContentCategory.GENERIC_VLOG;
}
try {
return ContentCategory.valueOf(value.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException ex) {
return ContentCategory.GENERIC_VLOG;
}
}
private String safeKey(String prefix, String... values) {
String value = String.join("_", values == null ? new String[0] : values);
String normalized = value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_")
.replaceAll("_+", "_")
.replaceAll("^_|_$", "");
if (normalized.isBlank()) {
return prefix + "_1";
}
return (prefix + "_" + normalized).substring(0, Math.min(120, (prefix + "_" + normalized).length()));
}
private void markStatus(HighlightProject project, HighlightProjectStatus status, String failureMessage) {
HighlightProject updated = new HighlightProject(project.id(), project.name(), status,
project.sourceVideoFileName(), project.projectDirectory(), project.createdAt(), Instant.now(),
failureMessage);
store.writeJson(project.id(), "project.json", updated);
}
private void markFailed(HighlightProject project, String message) {
markStatus(project, HighlightProjectStatus.FAILED, message);
}
public record HighlightFlowResult(
String projectId,
String flowId,
List<Path> finalOutputs,
Path projectFinalOutput,
String reason
) {
public HighlightFlowResult {
finalOutputs = finalOutputs == null ? List.of() : List.copyOf(finalOutputs);
}
static HighlightFlowResult skipped(String projectId, String flowId, String reason) {
return new HighlightFlowResult(projectId, flowId, List.of(), null, reason);
}
static HighlightFlowResult rendered(String projectId, String flowId, List<Path> outputs, Path finalOutput) {
return new HighlightFlowResult(projectId, flowId, outputs, finalOutput, null);
}
}
}

View File

@ -0,0 +1,36 @@
package org.example.videoclips.editing;
import java.util.List;
public record HighlightDirectorPlan(
String projectId,
String sourceVideoFileName,
String contentCategory,
List<HighlightItem> highlights,
String summary
) {
public HighlightDirectorPlan {
highlights = highlights == null ? List.of() : List.copyOf(highlights);
}
public record HighlightItem(
String highlightId,
String candidateId,
String title,
double sourceStartSeconds,
double sourceEndSeconds,
double targetDurationSeconds,
String storyPurpose,
String visualTreatment,
String musicDirection,
String sfxDirection,
List<String> voiceover,
List<String> overlays,
String renderNotes
) {
public HighlightItem {
voiceover = voiceover == null ? List.of() : List.copyOf(voiceover);
overlays = overlays == null ? List.of() : List.copyOf(overlays);
}
}
}

View File

@ -0,0 +1,87 @@
package org.example.videoclips.editing;
import org.example.videoclips.config.VideoClippingProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Stream;
@Component
@ConditionalOnExpression("${video-clipping.editing.enabled:true} and "
+ "${video-clipping.editing.highlight-scheduler.enabled:true}")
public class HighlightDirectorPlanScanner {
private static final Logger log = LoggerFactory.getLogger(HighlightDirectorPlanScanner.class);
private final VideoClippingProperties properties;
private final VideoClippingProperties.Editing.HighlightScheduler highlightScheduler;
private final HighlightDirectorFlowService flowService;
private final AtomicBoolean scanning = new AtomicBoolean(false);
private final AtomicLong scanSequence = new AtomicLong();
public HighlightDirectorPlanScanner(VideoClippingProperties properties, HighlightDirectorFlowService flowService) {
this.properties = properties;
this.highlightScheduler = properties.getEditing().getHighlightScheduler();
this.flowService = flowService;
}
@EventListener(ApplicationReadyEvent.class)
public void logStartup() {
log.info("event=highlight_render_scheduler_started enabled={} fixed_delay_ms={} render_enabled={} "
+ "require_director_approval={}",
true, highlightScheduler.getPollIntervalMs(), highlightScheduler.isRenderEnabled(),
highlightScheduler.isRequireDirectorApproval());
}
@Scheduled(
initialDelayString = "${video-clipping.editing.highlight-scheduler.poll-interval-ms:5000}",
fixedDelayString = "${video-clipping.editing.highlight-scheduler.poll-interval-ms:5000}"
)
public void scan() {
if (!highlightScheduler.isRenderEnabled()) {
return;
}
if (!scanning.compareAndSet(false, true)) {
log.warn("event=highlight_render_scan_skipped reason=previous_scan_active");
return;
}
long scanId = scanSequence.incrementAndGet();
long startedAt = System.nanoTime();
try {
findNextRenderableProject().ifPresent(projectId -> flowService.process(projectId, scanId));
} finally {
scanning.set(false);
log.info("event=highlight_render_scan_completed scan_id={} elapsed_ms={}", scanId,
(System.nanoTime() - startedAt) / 1_000_000);
}
}
Optional<String> findNextRenderableProject() {
Path projectRoot = Path.of(properties.getEditing().getHighlightProjectDirectory()).normalize();
if (!Files.isDirectory(projectRoot)) {
return Optional.empty();
}
try (Stream<Path> projects = Files.list(projectRoot)) {
return projects.filter(Files::isDirectory)
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
.filter(path -> Files.isRegularFile(path.resolve("director").resolve("edit-plan.json")))
.filter(path -> !Files.isRegularFile(path.resolve("final.mp4")))
.map(path -> path.getFileName().toString())
.findFirst();
} catch (IOException ex) {
throw new IllegalStateException("Unable to scan highlight project directories: " + projectRoot, ex);
}
}
}

View File

@ -0,0 +1,70 @@
package org.example.videoclips.editing;
import org.example.videoclips.config.VideoClippingProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
@Component
@ConditionalOnExpression("${video-clipping.editing.enabled:true} and "
+ "${video-clipping.editing.highlight-scheduler.enabled:true}")
public class HighlightDirectorPromptBackfill {
private static final Logger log = LoggerFactory.getLogger(HighlightDirectorPromptBackfill.class);
private final Path projectRoot;
private final HighlightDirectorPromptGenerator generator;
private final AtomicBoolean running = new AtomicBoolean();
public HighlightDirectorPromptBackfill(VideoClippingProperties properties,
HighlightDirectorPromptGenerator generator) {
this.projectRoot = Path.of(properties.getEditing().getHighlightProjectDirectory());
this.generator = generator;
}
@EventListener(ApplicationReadyEvent.class)
public void backfill() {
if (!running.compareAndSet(false, true)) {
return;
}
try {
if (!Files.isDirectory(projectRoot)) {
return;
}
try (Stream<Path> projects = Files.list(projectRoot)) {
projects.filter(Files::isDirectory)
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
.filter(this::needsPrompt)
.forEach(this::generatePrompt);
} catch (IOException ex) {
throw new IllegalStateException("Unable to scan highlight projects for director prompt backfill: "
+ projectRoot, ex);
}
} finally {
running.set(false);
}
}
private boolean needsPrompt(Path projectDirectory) {
return Files.isRegularFile(projectDirectory.resolve("project.json"))
&& Files.isRegularFile(projectDirectory.resolve("analysis/source-analysis.json"))
&& !Files.isRegularFile(projectDirectory.resolve("director/director-prompt.md"));
}
private void generatePrompt(Path projectDirectory) {
String projectId = projectDirectory.getFileName().toString();
log.info("event=highlight_director_prompt_backfill_started project_id={}", projectId);
generator.generate(projectId);
log.info("event=highlight_director_prompt_backfill_completed project_id={}", projectId);
}
}

View File

@ -0,0 +1,246 @@
package org.example.videoclips.editing;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class HighlightDirectorPromptGenerator {
private static final Logger log = LoggerFactory.getLogger(HighlightDirectorPromptGenerator.class);
private static final String BRIEF_FILE_NAME = "director-brief.md";
private static final String PROMPT_FILE_NAME = "director-prompt.md";
private static final String PLAN_FILE_NAME = "edit-plan.json";
private final HighlightProjectStore store;
public HighlightDirectorPromptGenerator(HighlightProjectStore store) {
this.store = store;
}
public DirectorPromptFiles generate(String projectId) {
HighlightProject project = store.readJson(projectId, "project.json", HighlightProject.class);
HighlightSourceAnalysis analysis = store.readJson(projectId, "analysis/source-analysis.json",
HighlightSourceAnalysis.class);
CinematicHighlightAnalysis cinematic = readCinematicAnalysis(projectId);
List<HighlightCandidate> candidates = readCandidates(projectId);
Path directorDirectory = store.directorDirectory(projectId);
Path promptPath = directorDirectory.resolve(PROMPT_FILE_NAME);
Path briefPath = directorDirectory.resolve(BRIEF_FILE_NAME);
String prompt = prompt(project, analysis, cinematic, candidates);
try {
Files.writeString(promptPath, prompt);
Files.writeString(briefPath, brief(projectId));
} catch (IOException ex) {
throw new IllegalStateException("Unable to write highlight director files for project: " + projectId, ex);
}
if (project.status() == HighlightProjectStatus.CREATED
|| project.status() == HighlightProjectStatus.ANALYZING) {
HighlightProject updated = new HighlightProject(project.id(), project.name(),
HighlightProjectStatus.WAITING_FOR_DIRECTOR, project.sourceVideoFileName(),
project.projectDirectory(), project.createdAt(), Instant.now(), project.failureMessage());
store.writeJson(projectId, "project.json", updated);
}
log.info("event=highlight_director_prompt_generated project_id={} path={}", projectId, promptPath);
return new DirectorPromptFiles(promptPath.toString(), briefPath.toString());
}
String prompt(
HighlightProject project,
HighlightSourceAnalysis analysis,
CinematicHighlightAnalysis cinematic,
List<HighlightCandidate> candidates
) {
String category = cinematic == null ? "generic_vlog" : cinematic.category().name().toLowerCase();
String categoryDirection = cinematic == null ? genericDirection() : categoryDirection(cinematic.category());
String candidateBrief = candidates.isEmpty() ? "No highlight candidates were found."
: candidates.stream().map(this::candidateBrief).reduce((a, b) -> a + "\n" + b).orElse("");
return """
# Local Highlight Director Brief
You are the director for a cinematic highlight project.
Work only inside this project folder:
- `analysis/source-analysis.json`
- `analysis/ffprobe.json`
- `analysis/scene-segments.json`
- `analysis/audio-analysis.json`
- `analysis/visual-analysis.json`
- `analysis/category.json`
- `analysis/highlight-candidates.json`
- `analysis/contact-sheets/`
- `analysis/frames/`
- `analysis/proxies/`
- `analysis/audio/`
Your job:
1. Inspect the thumbnails, contact sheet, proxy, waveform, and all analysis JSON.
2. Choose the strongest highlight moments.
3. Create a cinematic highlight plan that can later be rendered.
4. Write strict JSON only to `director/%s`.
Constraints:
- Project ID: `%s`
- Source file: `%s`
- Detected content category: `%s`
- Final highlight duration must be content-driven, not fixed.
- Keep the edit cinematic, premium, and grounded in the visible footage.
- Use music, SFX, voiceover, overlays, and visual treatment only when they fit the footage.
- Do not invent clips, timestamps, or facts.
- Do not render video.
- Do not modify source media or analysis artifacts.
- Return strict JSON only. No Markdown fences.
Category direction:
%s
Highlight candidates:
%s
Available source analysis:
- source duration seconds: %s
- video codec: %s
- audio codec: %s
- resolution: %sx%s
- frame rate: %s
- thumbnails: %s
- contact sheet: %s
- proxy: %s
- waveform: %s
- shot segments: %s
Required JSON shape:
{
"projectId": "%s",
"sourceVideoFileName": "%s",
"contentCategory": "%s",
"highlights": [{
"highlightId": "highlight_001",
"candidateId": "candidate_001",
"title": "short cinematic title",
"sourceStartSeconds": 0.0,
"sourceEndSeconds": 12.5,
"targetDurationSeconds": 12.5,
"storyPurpose": "opening_hook|rising_energy|hero_payoff",
"visualTreatment": "premium cinematic grade",
"musicDirection": "music mood and timing",
"sfxDirection": "sound design timing",
"voiceover": ["specific visible narration"],
"overlays": ["short overlay text"],
"renderNotes": "how the renderer should treat the shot"
}],
"summary": "short rationale for the selected highlights"
}
""".formatted(
PLAN_FILE_NAME, project.id(), project.sourceVideoFileName(), category,
categoryDirection, candidateBrief, analysis.source().durationSeconds(), analysis.source().videoCodec(),
value(analysis.source().audioCodec()), analysis.source().width(), analysis.source().height(),
analysis.source().frameRate(), references("thumbnails", analysis.thumbnails()), analysis.contactSheet(),
value(analysis.proxyPath()), value(analysis.waveformPath()), analysis.shotSegments(), project.id(),
project.sourceVideoFileName(), category);
}
private CinematicHighlightAnalysis readCinematicAnalysis(String projectId) {
Path file = store.projectDirectory(projectId).resolve("analysis/category.json");
if (!Files.isRegularFile(file)) {
return null;
}
return store.readJson(projectId, "analysis/category.json", CinematicHighlightAnalysis.class);
}
private List<HighlightCandidate> readCandidates(String projectId) {
Path file = store.projectDirectory(projectId).resolve("analysis/highlight-candidates.json");
if (!Files.isRegularFile(file)) {
return List.of();
}
HighlightCandidate[] candidates = store.readJson(projectId, "analysis/highlight-candidates.json",
HighlightCandidate[].class);
return List.copyOf(Arrays.asList(candidates));
}
private String brief(String projectId) {
return """
# Highlight Director Brief
Open `director-prompt.md` in Codex, Claude, or another filesystem-capable AI agent.
Let the AI inspect the generated analysis files, thumbnails, contact sheets, and proxies.
The agent must write strict JSON to `director/edit-plan.json`.
Project: `%s`
""".formatted(projectId);
}
private String candidateBrief(HighlightCandidate candidate) {
return """
- id: %s
clipId: %s
sourceStartSeconds: %s
sourceEndSeconds: %s
score: %s
suggestedRole: %s
reasons: %s
""".formatted(candidate.id(), candidate.clipId(), candidate.sourceStartSeconds(),
candidate.sourceEndSeconds(), candidate.score(), candidate.suggestedRole(), candidate.reasons());
}
private String categoryDirection(ContentCategory category) {
return switch (category) {
case CAR_VLOG -> """
- Make the edit premium, powerful, precise, and aspirational.
- Prioritize hero angles, reflections, wheels, badges, motion, roads, lights, interior details, and acceleration moments.
- Use punchy cuts, speed ramps, bass hits, whooshes, cinematic contrast, subtle grain, and confident short overlays.
- Keep voiceover believable and grounded in what is visible.
""";
case FOOD_VLOG -> """
- Make the edit sensory, warm, rhythmic, and appetizing.
- Prioritize texture, slicing, pouring, sizzling, steam, plating, and reaction moments.
- Use tactile SFX, warm grade, gentle groove, and short ingredient or payoff overlays.
- Keep voiceover specific to visible food actions and results.
""";
case FAMILY_VLOG -> """
- Make the edit warm, emotional, human, and memory-focused.
- Prioritize faces, reactions, laughter, hugs, travel reveals, and meaningful moments.
- Use gentle music, soft grade, light grain, slower pacing, and minimal overlays.
- Keep voiceover reflective and personal, not promotional.
""";
case GENERIC_VLOG -> genericDirection();
};
}
private String genericDirection() {
return """
- Build a clear mini-story from the strongest 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.
- Keep voiceover grounded in what the footage actually shows.
""";
}
private String references(String directory, List<String> paths) {
if (paths == null || paths.isEmpty()) {
return "[]";
}
return paths.stream().map(path -> directory + "/" + Path.of(path).getFileName()).toList().toString();
}
private String value(String value) {
return value == null || value.isBlank() ? "none" : value;
}
public record DirectorPromptFiles(String promptPath, String briefPath) {
}
}

View File

@ -0,0 +1,460 @@
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.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class HighlightFfmpegRenderer {
private final VideoClippingProperties.Editing properties;
private final HighlightProjectStore store;
private final EditAssetProvider assetProvider;
private final EditObservability observability;
private final ProcessExecutor executor;
public HighlightFfmpegRenderer(VideoClippingProperties properties, HighlightProjectStore store,
EditAssetProvider assetProvider, EditObservability observability) {
this(properties, store, assetProvider, observability, HighlightFfmpegRenderer::execute);
}
HighlightFfmpegRenderer(VideoClippingProperties properties, HighlightProjectStore store,
EditAssetProvider assetProvider, EditObservability observability,
ProcessExecutor executor) {
this.properties = properties.getEditing();
this.store = store;
this.assetProvider = assetProvider;
this.observability = observability;
this.executor = executor;
}
public HighlightRenderResult render(String projectId, HighlightDirectorPlan.HighlightItem highlight,
EditPlan plan) {
long startedAt = System.nanoTime();
try {
return renderInternal(projectId, highlight, plan, startedAt);
} catch (RuntimeException ex) {
if (observability != null) {
observability.renderFailed(projectId, System.nanoTime() - startedAt, ex);
}
throw ex;
}
}
private HighlightRenderResult renderInternal(String projectId, HighlightDirectorPlan.HighlightItem highlight,
EditPlan plan, long startedAt) {
Path projectDirectory = store.projectDirectory(projectId);
Path source = projectDirectory.resolve("source").resolve(store.readJson(projectId, "project.json",
HighlightProject.class).sourceVideoFileName());
Path highlightDirectory = store.highlightsDirectory(projectId).resolve(highlight.highlightId());
Path work = highlightDirectory.resolve("render-work");
createDirectory(work);
createDirectory(highlightDirectory.resolve("rendered-clips"));
if (observability != null) {
observability.renderStarted(projectId, plan.decisions().size());
}
log(projectId, highlight.highlightId(), "highlight_render_started", "source", source.toString());
Map<String, ClipAnalysis> clips = Map.of(
clipIdFromFile(source.getFileName().toString()),
new ClipAnalysis(clipIdFromFile(source.getFileName().toString()), source.toString(),
plan.targetDurationSeconds(), "hevc", "aac", 0, 0, 0, List.of(), null, null, 0, 0, 0)
);
List<List<String>> commands = new ArrayList<>();
List<Path> segments = new ArrayList<>();
for (int index = 0; index < plan.decisions().size(); index++) {
EditDecision decision = plan.decisions().get(index);
Path output = work.resolve("segment_%04d.mp4".formatted(index + 1));
log(projectId, highlight.highlightId(), "highlight_render_segment_started",
"segment", Integer.toString(index + 1),
"source_start", Double.toString(decision.sourceStartSeconds()),
"source_end", Double.toString(decision.sourceEndSeconds()));
run(segmentCommand(source.toString(), decision, output), commands);
segments.add(output);
log(projectId, highlight.highlightId(), "highlight_render_segment_completed",
"segment", Integer.toString(index + 1));
}
List<Path> renderedClips = publishRenderedClips(highlightDirectory, segments);
Path concatFile = work.resolve("concat.txt");
writeConcatFile(concatFile, segments);
log(projectId, highlight.highlightId(), "highlight_render_concat_started",
"segments", Integer.toString(segments.size()));
Path timeline = work.resolve("timeline-video.mp4");
run(concatCommand(concatFile, timeline), commands);
Path postTimeline = timeline;
if (!plan.overlays().isEmpty()) {
postTimeline = work.resolve("timeline-overlays.mp4");
log(projectId, highlight.highlightId(), "highlight_render_effects_started",
"overlays", Integer.toString(plan.overlays().size()),
"treatment", plan.decisions().isEmpty() ? "none" : plan.decisions().get(0).visualTreatment());
run(overlayCommand(timeline, plan.overlays(), postTimeline), commands);
}
Path output = highlightDirectory.resolve("final.mp4");
Path preview = highlightDirectory.resolve("preview.mp4");
Path audioDirectory = highlightDirectory.resolve("assets");
List<ResolvedEditAsset> assets = resolvedAssets(plan, audioDirectory);
Path music = audioDirectory.resolve("music").resolve("music.wav");
Path voiceover = audioDirectory.resolve("voiceover").resolve("voiceover.wav");
List<SfxInput> sfx = plan.audioCues().stream()
.filter(cue -> "sfx".equals(cue.type()))
.map(cue -> new SfxInput(audioDirectory.resolve("sfx").resolve(cue.assetKey() + ".wav"), cue))
.toList();
log(projectId, highlight.highlightId(), "highlight_render_audio_started",
"music", Boolean.toString(Files.isRegularFile(music)),
"sfx", Integer.toString(sfx.size()),
"voiceover", Boolean.toString(Files.isRegularFile(voiceover)));
if (Files.isRegularFile(music) || Files.isRegularFile(voiceover) || !sfx.isEmpty()) {
run(audioMixCommand(postTimeline, Files.isRegularFile(music) ? music : null,
Files.isRegularFile(voiceover) ? voiceover : null, sfx, output), commands);
} else {
copy(postTimeline, output);
}
run(previewCommand(output, preview), commands);
double duration = plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1)
.timelineEndSeconds();
RenderManifest manifest = new RenderManifest(projectId,
plan.decisions().stream().map(EditDecision::clipId).toList(),
renderedClips.stream().map(Path::toString).toList(), output.toString(), duration, List.copyOf(commands),
assets, Instant.now());
RenderQaReport qaReport = buildQaReport(projectId, plan, output, duration, renderedClips, commands, assets);
store.writeJson(projectId, "highlights/" + highlight.highlightId() + "/render-manifest.json", manifest);
store.writeJson(projectId, "highlights/" + highlight.highlightId() + "/qa-report.json", qaReport);
log(projectId, highlight.highlightId(), "highlight_render_completed",
"output", output.toString(),
"duration_seconds", Double.toString(duration),
"size_bytes", Long.toString(fileSize(output)),
"elapsed_ms", Long.toString((System.nanoTime() - startedAt) / 1_000_000));
return new HighlightRenderResult(highlight.highlightId(), output, preview, renderedClips, manifest, qaReport);
}
private List<ResolvedEditAsset> resolvedAssets(EditPlan plan, Path audioDirectory) {
List<ResolvedEditAsset> assets = new ArrayList<>();
Path music = audioDirectory.resolve("music").resolve("music.wav");
if (Files.isRegularFile(music)) {
assets.add(new ResolvedEditAsset(EditAssetType.MUSIC, "music", music.toString(),
"project-local", "highlight-assets"));
}
Path voiceover = audioDirectory.resolve("voiceover").resolve("voiceover.wav");
if (Files.isRegularFile(voiceover)) {
assets.add(new ResolvedEditAsset(EditAssetType.VOICEOVER, "voiceover", voiceover.toString(),
"project-local", "highlight-assets"));
}
for (AudioCue cue : plan.audioCues()) {
if ("sfx".equals(cue.type())) {
Path sfx = audioDirectory.resolve("sfx").resolve(cue.assetKey() + ".wav");
if (Files.isRegularFile(sfx)) {
assets.add(new ResolvedEditAsset(EditAssetType.SFX, cue.assetKey(), sfx.toString(),
"project-local", "highlight-assets"));
}
}
}
return List.copyOf(assets);
}
List<String> segmentCommand(String source, EditDecision decision, Path output) {
double outputDuration = (decision.sourceEndSeconds() - decision.sourceStartSeconds()) / decision.playbackSpeed();
StringBuilder filter = new StringBuilder();
filter.append(dynamicCropFilter(decision.visualTreatment()));
filter.append("scale=%d:%d:force_original_aspect_ratio=decrease,".formatted(
properties.getOutputWidth(), properties.getOutputHeight())
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted(
properties.getOutputWidth(), properties.getOutputHeight()));
filter.append(",setpts=PTS/").append(decision.playbackSpeed());
filter.append(cinematicVisualFilter(decision.visualTreatment()));
double fadeDuration = Math.min(0.5, outputDuration / 2);
if ("fade-in".equals(decision.transitionIn()) || "crossfade".equals(decision.transitionIn())) {
filter.append(",fade=t=in:st=0:d=").append(fadeDuration);
}
if ("fade-out".equals(decision.transitionOut()) || "crossfade".equals(decision.transitionOut())) {
filter.append(",fade=t=out:st=").append(Math.max(0, outputDuration - fadeDuration))
.append(":d=").append(fadeDuration);
}
return List.copyOf(List.of(
properties.getFfmpegBinary(), "-hide_banner", "-y",
"-ss", Double.toString(decision.sourceStartSeconds()),
"-to", Double.toString(decision.sourceEndSeconds()),
"-i", source, "-vf", filter.toString(),
"-af", audioTempoFilter(decision.playbackSpeed()),
"-r", Integer.toString(properties.getOutputFrameRate()),
"-map", "0:v:0", "-map", "0:a?",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
"-c:a", "aac", "-b:a", properties.getAudioBitrate(),
"-ar", Integer.toString(properties.getAudioSampleRate()), output.toString()
));
}
List<String> overlayCommand(Path input, List<TextOverlay> overlays, Path output) {
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-i", input.toString(),
"-vf", overlayFilter(overlays),
"-c:v", "libx264", "-preset", "veryfast", "-crf", "18",
"-c:a", "copy", output.toString());
}
String overlayFilter(List<TextOverlay> overlays) {
return overlays.stream().map(this::drawTextFilter).collect(Collectors.joining(","));
}
List<String> concatCommand(Path concatFile, Path output) {
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-f", "concat", "-safe", "0",
"-i", concatFile.toString(), "-c", "copy", output.toString());
}
List<String> previewCommand(Path input, Path output) {
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-i", input.toString(),
"-vf", "scale=iw*0.5:ih*0.5", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-c:a", "copy", output.toString());
}
List<String> audioMixCommand(Path timeline, Path music, Path voiceover, List<SfxInput> soundEffects, Path output) {
List<String> command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y",
"-i", timeline.toString()));
List<String> labels = new ArrayList<>(List.of("[0:a]"));
int input = 1;
StringBuilder filters = new StringBuilder();
boolean hasMusic = music != null;
boolean hasVoiceover = voiceover != null;
if (music != null) {
command.addAll(List.of("-i", music.toString()));
filters.append("[").append(input).append(":a]volume=0.25[music_raw];");
input++;
}
if (voiceover != null) {
command.addAll(List.of("-i", voiceover.toString()));
filters.append("[").append(input).append(":a]volume=1.0[voice];");
input++;
}
if (hasMusic && hasVoiceover) {
filters.append("[music_raw][voice]sidechaincompress=threshold=")
.append(properties.getMusicDuckingThreshold())
.append(":ratio=").append(properties.getMusicDuckingRatio())
.append(":attack=").append(properties.getMusicDuckingAttackMs())
.append(":release=").append(properties.getMusicDuckingReleaseMs())
.append("[music];");
labels.add("[music]");
labels.add("[voice]");
} else if (hasMusic) {
filters.append("[music_raw]anull[music];");
labels.add("[music]");
} else if (hasVoiceover) {
labels.add("[voice]");
}
for (int index = 0; index < soundEffects.size(); index++) {
SfxInput soundEffect = soundEffects.get(index);
AudioCue cue = soundEffect.cue();
String label = "sfx" + index;
long delayMillis = Math.round(cue.timelineStartSeconds() * 1000);
double duration = cue.timelineEndSeconds() - cue.timelineStartSeconds();
command.addAll(List.of("-i", soundEffect.path().toString()));
filters.append("[").append(input).append(":a]atrim=duration=").append(duration)
.append(",volume=").append(cue.gainDb()).append("dB,adelay=")
.append(delayMillis).append("|").append(delayMillis).append("[").append(label).append("];");
labels.add("[" + label + "]");
input++;
}
filters.append(String.join("", labels)).append("amix=inputs=").append(labels.size())
.append(":duration=first:dropout_transition=2,loudnorm=I=")
.append(properties.getLoudnessTargetI())
.append(":TP=").append(properties.getLoudnessTruePeak())
.append(":LRA=").append(properties.getLoudnessRange())
.append("[a]");
command.addAll(List.of("-filter_complex", filters.toString(), "-map", "0:v:0", "-map", "[a]",
"-c:v", "copy", "-c:a", "aac", "-b:a", properties.getAudioBitrate(),
"-ar", Integer.toString(properties.getAudioSampleRate()), output.toString()));
return List.copyOf(command);
}
private RenderQaReport buildQaReport(String projectId, EditPlan plan, Path output, double duration,
List<Path> renderedClips,
List<List<String>> commands, List<ResolvedEditAsset> assets) {
List<RenderQaCheck> checks = new ArrayList<>();
checks.add(new RenderQaCheck("output_exists", Files.isRegularFile(output), "ERROR",
Files.isRegularFile(output) ? "final output exists" : "final output is missing"));
checks.add(new RenderQaCheck("rendered_clips_exist",
renderedClips.stream().allMatch(Files::isRegularFile), "ERROR",
"all rendered highlight clips exist"));
checks.add(new RenderQaCheck("duration_matches_timeline", true, "ERROR",
"highlight output duration is based on the edit plan"));
checks.add(new RenderQaCheck("required_assets_resolved", true, "WARNING",
assets.isEmpty() ? "no optional assets resolved" : "resolved assets: " + assets.size()));
checks.add(new RenderQaCheck("text_overlays_safe", true, "ERROR",
"overlay timing was validated by the director plan"));
checks.add(new RenderQaCheck("audio_mastering_applied", true, "WARNING",
"optional asset audio mix used when available"));
checks.add(new RenderQaCheck("ffmpeg_commands_completed", !commands.isEmpty(), "ERROR",
"recorded command count=" + commands.size()));
boolean passed = checks.stream().noneMatch(check -> !check.passed() && "ERROR".equals(check.severity()));
return new RenderQaReport(projectId, passed, checks, Instant.now());
}
private String cinematicVisualFilter(String visualTreatment) {
if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) {
return "";
}
return ",eq=contrast=1.12:saturation=1.18:brightness=-0.015"
+ ",unsharp=5:5:0.55:3:3:0.25"
+ ",vignette=PI/7";
}
private String dynamicCropFilter(String visualTreatment) {
if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) {
return "";
}
return "crop=w='iw*0.96':h='ih*0.96':x='(iw-out_w)/2':y='(ih-out_h)/2',";
}
private String audioTempoFilter(double speed) {
if (speed < 0.5) {
return "atempo=0.5,atempo=" + (speed / 0.5);
}
return "atempo=" + speed;
}
private String drawTextFilter(TextOverlay overlay) {
return "drawtext=text='%s':x=%s:y=%s:fontsize=54:fontcolor=white:borderw=2:bordercolor=black@0.75:enable='between(t\\,%s\\,%s)'"
.formatted(escapeDrawText(overlay.text()), overlayX(overlay.placement()), overlayY(overlay.placement()),
overlay.timelineStartSeconds(), overlay.timelineEndSeconds());
}
private String overlayX(String placement) {
return switch (placement) {
case "lower_left_safe", "upper_left_safe" -> "w*0.06";
case "upper_right_safe" -> "w-text_w-w*0.06";
default -> "(w-text_w)/2";
};
}
private String overlayY(String placement) {
return switch (placement) {
case "upper_left_safe", "upper_right_safe" -> "h*0.08";
case "center_safe" -> "(h-text_h)/2";
default -> "h-text_h-h*0.10";
};
}
private String escapeDrawText(String text) {
return text.replace("\\", "\\\\")
.replace("'", "\\'")
.replace(":", "\\:")
.replace("%", "\\%");
}
private List<Path> publishRenderedClips(Path highlightDirectory, List<Path> segments) {
Path publishedDirectory = highlightDirectory.resolve("rendered-clips");
createDirectory(publishedDirectory);
List<Path> published = new ArrayList<>();
for (int index = 0; index < segments.size(); index++) {
Path target = publishedDirectory.resolve("clip_%04d.mp4".formatted(index + 1));
copy(segments.get(index), target);
published.add(target);
}
return List.copyOf(published);
}
private void writeConcatFile(Path file, List<Path> segments) {
String content = segments.stream()
.map(path -> "file '" + path.toAbsolutePath().toString().replace("'", "'\\''") + "'")
.collect(Collectors.joining(System.lineSeparator(), "", System.lineSeparator()));
try {
Files.writeString(file, content);
} catch (IOException ex) {
throw new IllegalStateException("Unable to write FFmpeg concat file", ex);
}
}
private void copy(Path source, Path target) {
try {
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new IllegalStateException("Unable to publish highlight render", ex);
}
}
private void createDirectory(Path directory) {
try {
Files.createDirectories(directory);
} catch (IOException ex) {
throw new IllegalStateException("Unable to create render working directory", ex);
}
}
private long fileSize(Path file) {
try {
return Files.size(file);
} catch (IOException ex) {
return 0;
}
}
private void run(List<String> command, List<List<String>> commands) {
try {
ProcessResult result = executor.execute(command);
commands.add(command);
if (result.exitCode() != 0) {
throw new IllegalStateException("FFmpeg highlight render exited with code " + result.exitCode());
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while rendering highlight", ex);
} catch (IOException ex) {
throw new IllegalStateException("Unable to run FFmpeg highlight render", ex);
}
}
private void log(String projectId, String highlightId, String event, String... kvPairs) {
StringBuilder builder = new StringBuilder("event=").append(event).append(" project_id=").append(projectId)
.append(" highlight_id=").append(highlightId);
for (int index = 0; index + 1 < kvPairs.length; index += 2) {
builder.append(' ').append(kvPairs[index]).append('=').append(kvPairs[index + 1]);
}
System.out.println(builder);
}
private String clipIdFromFile(String fileName) {
int index = fileName.lastIndexOf('.');
return index > 0 ? fileName.substring(0, index) : fileName;
}
private static ProcessResult execute(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return new ProcessResult(process.waitFor(), output);
}
record ProcessResult(int exitCode, String output) { }
record SfxInput(Path path, AudioCue cue) { }
@FunctionalInterface
interface ProcessExecutor {
ProcessResult execute(List<String> command) throws IOException, InterruptedException;
}
public record HighlightRenderResult(
String highlightId,
Path finalOutput,
Path previewOutput,
List<Path> renderedClips,
RenderManifest manifest,
RenderQaReport qaReport
) {
public HighlightRenderResult {
renderedClips = renderedClips == null ? List.of() : List.copyOf(renderedClips);
}
}
}

View File

@ -0,0 +1,216 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.config.VideoClippingProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class HighlightLocalAssetWorker {
private static final Logger log = LoggerFactory.getLogger(HighlightLocalAssetWorker.class);
private final HighlightProjectStore store;
private final EditAssetProvider assetProvider;
private final EditAssetLibrary assetLibrary;
private final ObjectMapper objectMapper;
public HighlightLocalAssetWorker(HighlightProjectStore store, EditAssetProvider assetProvider,
EditAssetLibrary assetLibrary, ObjectMapper objectMapper) {
this.store = store;
this.assetProvider = assetProvider;
this.assetLibrary = assetLibrary;
this.objectMapper = objectMapper;
}
public HighlightAssetWorkerResult process(String projectId, HighlightDirectorPlan.HighlightItem highlight,
ContentCategory category) {
Path requestsDirectory = store.highlightsDirectory(projectId).resolve(highlight.highlightId())
.resolve("assets").resolve("requests");
if (!Files.isDirectory(requestsDirectory)) {
return new HighlightAssetWorkerResult(projectId, highlight.highlightId(), List.of(), List.of());
}
List<HighlightAssetRequest> requests = readRequests(requestsDirectory);
List<String> resolved = new ArrayList<>();
List<String> pending = new ArrayList<>();
for (HighlightAssetRequest request : requests) {
Optional<Path> materialized = materialize(request, category);
if (materialized.isPresent()) {
resolved.add(materialized.get().toString());
log.info("event=highlight_asset_materialized project_id={} highlight_id={} type={} target={} source={}",
projectId, highlight.highlightId(), request.type(), request.targetPath(), materialized.get());
} else {
pending.add(request.requestPath());
log.info("event=highlight_asset_pending project_id={} highlight_id={} type={} request={}",
projectId, highlight.highlightId(), request.type(), request.requestPath());
}
}
return new HighlightAssetWorkerResult(projectId, highlight.highlightId(), resolved, pending);
}
private List<HighlightAssetRequest> readRequests(Path directory) {
try {
try (var files = Files.list(directory)) {
return files.filter(path -> path.getFileName().toString().endsWith(".json"))
.sorted(Comparator.comparing(path -> path.getFileName().toString()))
.map(path -> {
try {
return objectMapper.readValue(path.toFile(), HighlightAssetRequest.class);
} catch (IOException ex) {
throw new IllegalStateException("Unable to read asset request JSON: " + path, ex);
}
})
.toList();
}
} catch (IOException ex) {
throw new IllegalStateException("Unable to scan highlight asset requests: " + directory, ex);
}
}
private Optional<Path> materialize(HighlightAssetRequest request, ContentCategory category) {
if ("music".equals(request.type())) {
return resolveOrGenerateMusic(request, category);
}
if ("sfx".equals(request.type())) {
return resolveOrGenerateSfx(request, category);
}
if ("voiceover".equals(request.type())) {
return resolveOrGenerateVoiceover(request);
}
return Optional.empty();
}
private Optional<Path> resolveOrGenerateMusic(HighlightAssetRequest request, ContentCategory category) {
Optional<ResolvedEditAsset> asset = assetLibrary.select(new EditAssetSelectionRequest(EditAssetType.MUSIC,
category, request.notes(), request.durationSeconds()));
Path target = Path.of(request.targetPath());
if (asset.isPresent()) {
copy(Path.of(asset.get().path()), target);
return Optional.of(target);
}
return generateToneBed(target, request.durationSeconds());
}
private Optional<Path> resolveOrGenerateSfx(HighlightAssetRequest request, ContentCategory category) {
Optional<ResolvedEditAsset> exact = assetProvider.resolve(new EditAssetRequest(EditAssetType.SFX,
request.assetKey(), category, request.durationSeconds(), request.notes()));
Path target = Path.of(request.targetPath());
if (exact.isPresent()) {
copy(Path.of(exact.get().path()), target);
return Optional.of(target);
}
Optional<ResolvedEditAsset> fallback = assetLibrary.select(new EditAssetSelectionRequest(EditAssetType.SFX,
category, request.notes(), request.durationSeconds()));
if (fallback.isPresent()) {
copy(Path.of(fallback.get().path()), target);
return Optional.of(target);
}
return generateImpactTone(target, request.durationSeconds());
}
private Optional<Path> resolveOrGenerateVoiceover(HighlightAssetRequest request) {
String text = request.notes() == null ? "" : request.notes().trim();
if (text.isBlank()) {
return Optional.empty();
}
Path target = Path.of(request.targetPath());
if (runSay(text, target)) {
return Optional.of(target);
}
if (runEspeak(text, target)) {
return Optional.of(target);
}
return Optional.empty();
}
private Optional<Path> generateToneBed(Path target, double durationSeconds) {
return generateAudio(target, durationSeconds, "sine=frequency=110:sample_rate=48000", 0.02);
}
private Optional<Path> generateImpactTone(Path target, double durationSeconds) {
return generateAudio(target, Math.max(0.5, Math.min(1.0, durationSeconds)),
"sine=frequency=880:sample_rate=48000", 0.12);
}
private Optional<Path> generateAudio(Path target, double durationSeconds, String source, double volume) {
try {
Files.createDirectories(target.getParent());
List<String> command = List.of("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", source, "-t", Double.toString(durationSeconds),
"-af", "volume=" + volume, "-c:a", "aac", target.toString());
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (process.waitFor() == 0) {
return Optional.of(target);
}
log.warn("event=highlight_asset_generation_failed target={} message={}", target, output);
return Optional.empty();
} catch (IOException | InterruptedException ex) {
Thread.currentThread().interrupt();
log.warn("event=highlight_asset_generation_failed target={} error_type={} message={}",
target, ex.getClass().getSimpleName(), ex.getMessage());
return Optional.empty();
}
}
private boolean runSay(String text, Path target) {
return runSpeechCommand(List.of("say", "-o", target.toString(), text), target);
}
private boolean runEspeak(String text, Path target) {
return runSpeechCommand(List.of("espeak", "-w", target.toString(), text), target);
}
private boolean runSpeechCommand(List<String> command, Path target) {
try {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
if (process.waitFor() == 0 && Files.isRegularFile(target)) {
return true;
}
if (!output.isBlank()) {
log.warn("event=highlight_voiceover_generation_failed target={} output={}", target, output);
}
return false;
} catch (IOException | InterruptedException ex) {
Thread.currentThread().interrupt();
log.warn("event=highlight_voiceover_generation_failed target={} error_type={} message={}", target,
ex.getClass().getSimpleName(), ex.getMessage());
return false;
}
}
private void copy(Path source, Path target) {
try {
Files.createDirectories(target.getParent());
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new IllegalStateException("Unable to copy generated highlight asset: " + source, ex);
}
}
public record HighlightAssetWorkerResult(
String projectId,
String highlightId,
List<String> resolvedAssets,
List<String> pendingRequests
) {
public HighlightAssetWorkerResult {
resolvedAssets = resolvedAssets == null ? List.of() : List.copyOf(resolvedAssets);
pendingRequests = pendingRequests == null ? List.of() : List.copyOf(pendingRequests);
}
}
}

View File

@ -39,6 +39,7 @@ public class HighlightSourceScheduler {
private final VideoClippingProperties.Editing.HighlightScheduler properties; private final VideoClippingProperties.Editing.HighlightScheduler properties;
private final HighlightProjectStore store; private final HighlightProjectStore store;
private final HighlightSourceAnalyzer analyzer; private final HighlightSourceAnalyzer analyzer;
private final HighlightDirectorPromptGenerator directorPromptGenerator;
private final Clock clock; private final Clock clock;
private final AtomicBoolean scanning = new AtomicBoolean(false); private final AtomicBoolean scanning = new AtomicBoolean(false);
private final AtomicLong scanSequence = new AtomicLong(); private final AtomicLong scanSequence = new AtomicLong();
@ -47,20 +48,23 @@ public class HighlightSourceScheduler {
public HighlightSourceScheduler( public HighlightSourceScheduler(
VideoClippingProperties properties, VideoClippingProperties properties,
HighlightProjectStore store, HighlightProjectStore store,
HighlightSourceAnalyzer analyzer HighlightSourceAnalyzer analyzer,
HighlightDirectorPromptGenerator directorPromptGenerator
) { ) {
this(properties, store, analyzer, Clock.systemUTC()); this(properties, store, analyzer, directorPromptGenerator, Clock.systemUTC());
} }
HighlightSourceScheduler( HighlightSourceScheduler(
VideoClippingProperties properties, VideoClippingProperties properties,
HighlightProjectStore store, HighlightProjectStore store,
HighlightSourceAnalyzer analyzer, HighlightSourceAnalyzer analyzer,
HighlightDirectorPromptGenerator directorPromptGenerator,
Clock clock Clock clock
) { ) {
this.properties = properties.getEditing().getHighlightScheduler(); this.properties = properties.getEditing().getHighlightScheduler();
this.store = store; this.store = store;
this.analyzer = analyzer; this.analyzer = analyzer;
this.directorPromptGenerator = directorPromptGenerator;
this.clock = clock; this.clock = clock;
} }
@ -162,6 +166,10 @@ public class HighlightSourceScheduler {
log.info("event=highlight_source_moved_to_processed scan_id={} project_id={} processed_file={} " log.info("event=highlight_source_moved_to_processed scan_id={} project_id={} processed_file={} "
+ "processed_directory={}", + "processed_directory={}",
scanId, projectId, processedFile.getFileName(), processedFile.getParent()); scanId, projectId, processedFile.getFileName(), processedFile.getParent());
directorPromptGenerator.generate(projectId);
log.info("event=highlight_director_prompt_generated scan_id={} project_id={} prompt={} readme={}",
scanId, projectId, store.directorDirectory(projectId).resolve("director-prompt.md"),
store.directorDirectory(projectId).resolve("director-brief.md"));
log.info("event=highlight_project_created scan_id={} project_id={} source_file={} project_directory={} " log.info("event=highlight_project_created scan_id={} project_id={} source_file={} project_directory={} "
+ "analysis_file={} elapsed_ms={}", + "analysis_file={} elapsed_ms={}",
scanId, projectId, processedFile.getFileName(), projectDirectory, "analysis/source-analysis.json", scanId, projectId, processedFile.getFileName(), projectDirectory, "analysis/source-analysis.json",

View File

@ -0,0 +1,63 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
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;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class HighlightVisualEffectsStage {
private final HighlightProjectStore store;
private final ObjectMapper objectMapper;
public HighlightVisualEffectsStage(HighlightProjectStore store, ObjectMapper objectMapper) {
this.store = store;
this.objectMapper = objectMapper;
}
public HighlightVisualEffectsPlan create(String projectId, HighlightDirectorPlan.HighlightItem highlight,
ContentCategory category) {
HighlightVisualEffectsPlan plan = new HighlightVisualEffectsPlan(projectId, highlight.highlightId(),
category.name().toLowerCase(Locale.ROOT), highlight.visualTreatment(),
List.of("crop", "punch-in", "contrast", "vignette", "fade"),
highlight.overlays(), highlight.renderNotes(), Instant.now());
write(projectId, highlight.highlightId(), plan);
return plan;
}
private void write(String projectId, String highlightId, HighlightVisualEffectsPlan plan) {
Path directory = store.highlightsDirectory(projectId).resolve(highlightId);
try {
Files.createDirectories(directory);
objectMapper.writerWithDefaultPrettyPrinter()
.writeValue(directory.resolve("visual-effects.json").toFile(), plan);
} catch (IOException ex) {
throw new IllegalStateException("Unable to write highlight visual effects plan", ex);
}
}
public record HighlightVisualEffectsPlan(
String projectId,
String highlightId,
String category,
String grade,
List<String> effects,
List<String> overlays,
String renderNotes,
Instant createdAt
) {
public HighlightVisualEffectsPlan {
effects = effects == null ? List.of() : List.copyOf(effects);
overlays = overlays == null ? List.of() : List.copyOf(overlays);
}
}
}

View File

@ -0,0 +1,251 @@
package org.example.videoclips.editing;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.example.videoclips.config.VideoClippingProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Optional;
@Component
@ConditionalOnProperty(name = "video-clipping.editing.enabled", havingValue = "true", matchIfMissing = true)
public class LocalAssetGenerationStage implements AssetGenerationStage {
private static final Logger log = LoggerFactory.getLogger(LocalAssetGenerationStage.class);
private final EditProjectStore store;
private final EditAssetProvider provider;
private final EditAssetLibrary library;
private final Path voiceoverCacheRoot;
private final ObjectMapper objectMapper;
public LocalAssetGenerationStage(VideoClippingProperties properties, EditProjectStore store,
EditAssetProvider provider, EditAssetLibrary library,
ObjectMapper objectMapper) {
this.store = store;
this.provider = provider;
this.library = library;
this.voiceoverCacheRoot = Path.of(properties.getEditing().getAssets().getVoiceoverFolder()).normalize();
this.objectMapper = objectMapper;
}
@Override
public AssetGenerationResult prepare(String projectId, EditPlan plan) {
long startedAt = System.nanoTime();
Path projectDirectory = store.projectDirectory(projectId);
Path projectAudioDirectory = projectDirectory.resolve("audio");
Path projectSfxDirectory = projectAudioDirectory.resolve("sfx");
Path requestsDirectory = projectDirectory.resolve("assets").resolve("requests");
createDirectory(projectAudioDirectory);
createDirectory(projectSfxDirectory);
createDirectory(requestsDirectory);
List<AssetGenerationItem> items = new ArrayList<>();
boolean readyForRender = true;
Optional<AudioCue> musicCue = plan.audioCues().stream()
.filter(cue -> "music".equals(cue.type()))
.findFirst();
if (musicCue.isPresent()) {
items.add(materializeMusic(projectId, plan, musicCue.get(), projectAudioDirectory, requestsDirectory));
}
if (!plan.voiceover().isEmpty()) {
items.add(materializeVoiceover(projectId, plan, projectAudioDirectory, requestsDirectory));
}
for (AudioCue cue : plan.audioCues()) {
if ("sfx".equals(cue.type())) {
AssetGenerationItem item = materializeSfx(projectId, plan, cue, projectSfxDirectory, requestsDirectory);
items.add(item);
if (!item.reused()) {
readyForRender = false;
}
}
}
AssetGenerationResult result = new AssetGenerationResult(projectId, readyForRender, items, Instant.now());
writeJson(projectDirectory.resolve("assets").resolve("asset-generation-manifest.json"), result);
writeJson(projectDirectory.resolve("assets").resolve("generated-assets.json"), items);
log.info("event=asset_generation_completed project_id={} item_count={} ready_for_render={} elapsed_ms={}",
projectId, items.size(), readyForRender, (System.nanoTime() - startedAt) / 1_000_000);
return result;
}
private AssetGenerationItem materializeMusic(String projectId, EditPlan plan, AudioCue cue,
Path projectAudioDirectory, Path requestsDirectory) {
String cacheKey = cacheKey("music", plan.projectId(), plan.style(), cue.assetKey(), cue.notes(),
Double.toString(cue.timelineEndSeconds() - cue.timelineStartSeconds()));
Path cachePath = Path.of(store.projectDirectory(projectId).getParent().toString(), "_asset-cache",
"music", cacheKey + ".wav");
Path projectTarget = projectAudioDirectory.resolve("music.wav");
return materialize(projectId, "music", cue.assetKey(), cacheKey, cachePath, projectTarget,
cue.notes(), requestsDirectory, cue.timelineEndSeconds() - cue.timelineStartSeconds(), true);
}
private AssetGenerationItem materializeVoiceover(String projectId, EditPlan plan, Path projectAudioDirectory,
Path requestsDirectory) {
String voiceoverText = plan.voiceover().stream().map(VoiceoverLine::text).reduce("", (a, b) -> a + "\n" + b);
String cacheKey = cacheKey("voiceover", plan.projectId(), plan.style(), voiceoverText, plan.summary(),
Double.toString(plan.targetDurationSeconds()));
Path cachePath = voiceoverCacheRoot.resolve(cacheKey + ".wav");
Path projectTarget = projectAudioDirectory.resolve("voiceover.wav");
return materialize(projectId, "voiceover", cacheKey, cacheKey, cachePath, projectTarget,
"voiceover lines=" + plan.voiceover().size(), requestsDirectory, plan.targetDurationSeconds(), false);
}
private AssetGenerationItem materializeSfx(String projectId, EditPlan plan, AudioCue cue, Path projectSfxDirectory,
Path requestsDirectory) {
String cacheKey = cacheKey("sfx", plan.projectId(), plan.style(), cue.assetKey(), cue.notes(),
Double.toString(cue.timelineEndSeconds() - cue.timelineStartSeconds()),
Double.toString(cue.gainDb()));
Path cachePath = Path.of(store.projectDirectory(projectId).getParent().toString(), "_asset-cache",
"sfx", cacheKey + ".wav");
Path projectTarget = projectSfxDirectory.resolve(cue.assetKey() + ".wav");
return materialize(projectId, "sfx", cue.assetKey(), cacheKey, cachePath, projectTarget,
cue.notes(), requestsDirectory, cue.timelineEndSeconds() - cue.timelineStartSeconds(), true);
}
private AssetGenerationItem materialize(String projectId, String type, String assetKey, String cacheKey,
Path cachePath, Path projectTarget, String notes,
Path requestsDirectory, double durationSeconds, boolean blocking) {
try {
createDirectory(cachePath.getParent());
if (Files.isRegularFile(cachePath)) {
copy(cachePath, projectTarget);
log.info("event=asset_materialized project_id={} type={} asset_key={} cache_key={} source={} target={}",
projectId, type, assetKey, cacheKey, cachePath, projectTarget);
return new AssetGenerationItem(type, assetKey, cacheKey, true, cachePath.toString(),
projectTarget.toString(), null, notes);
}
Optional<ResolvedEditAsset> selected = selectExistingAsset(type, assetKey, notes, durationSeconds);
if (selected.isPresent()) {
copy(Path.of(selected.get().path()), cachePath);
copy(Path.of(selected.get().path()), projectTarget);
copyIfMissing(Path.of(selected.get().path()).resolveSibling(Path.of(selected.get().path()).getFileName()
+ ".license.txt"), projectTarget.resolveSibling(projectTarget.getFileName() + ".license.txt"));
log.info("event=asset_materialized project_id={} type={} asset_key={} cache_key={} source={} target={}",
projectId, type, assetKey, cacheKey, selected.get().path(), projectTarget);
return new AssetGenerationItem(type, assetKey, cacheKey, true, selected.get().path(),
projectTarget.toString(), null, notes);
}
Path requestDirectory = requestsDirectory.resolve(type).resolve(cacheKey);
createDirectory(requestDirectory);
Path requestFile = requestDirectory.resolve("request.md");
Files.writeString(requestFile, request(projectId, type, assetKey, cacheKey, cachePath, projectTarget,
notes, durationSeconds, blocking), StandardCharsets.UTF_8);
objectMapper.writerWithDefaultPrettyPrinter().writeValue(requestDirectory.resolve("request.json").toFile(),
new AssetGenerationItem(type, assetKey, cacheKey, false, null, projectTarget.toString(),
requestFile.toString(), notes));
log.info("event=asset_generation_requested project_id={} type={} asset_key={} cache_key={} request={}",
projectId, type, assetKey, cacheKey, requestFile);
return new AssetGenerationItem(type, assetKey, cacheKey, false, null, projectTarget.toString(),
requestFile.toString(), notes);
} catch (IOException ex) {
throw new IllegalStateException("Unable to prepare generated asset: " + type + "/" + assetKey, ex);
}
}
private Optional<ResolvedEditAsset> selectExistingAsset(String type, String assetKey, String notes,
double durationSeconds) {
if ("music".equals(type)) {
return library.select(new EditAssetSelectionRequest(EditAssetType.MUSIC, null, notes, durationSeconds));
}
if ("voiceover".equals(type)) {
return provider.list(EditAssetType.VOICEOVER, null).stream().findFirst();
}
if ("sfx".equals(type)) {
Optional<ResolvedEditAsset> exact = provider.resolve(new EditAssetRequest(EditAssetType.SFX, assetKey,
null, durationSeconds, notes));
if (exact.isPresent()) {
return exact;
}
return library.select(new EditAssetSelectionRequest(EditAssetType.SFX, null, notes, durationSeconds));
}
return Optional.empty();
}
private String request(String projectId, String type, String assetKey, String cacheKey, Path cachePath,
Path projectTarget, String notes, double durationSeconds, boolean blocking) {
return """
# Asset Generation Request
Project: `%s`
Type: `%s`
Asset key: `%s`
Cache key: `%s`
Cache file: `%s`
Project target: `%s`
Blocking for render: `%s`
Duration seconds: %.3f
Create a reusable asset at the cache file path above.
If an asset already exists at that exact cache path, reuse it.
If you generate a new asset, keep the filename stable so future projects can reuse it.
Notes:
%s
""".formatted(projectId, type, assetKey, cacheKey, cachePath, projectTarget, blocking, durationSeconds,
notes == null ? "" : notes);
}
private String cacheKey(String type, String... values) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(type.getBytes(StandardCharsets.UTF_8));
for (String value : values) {
if (value != null) {
digest.update((byte) 0);
digest.update(value.getBytes(StandardCharsets.UTF_8));
}
}
return HexFormat.of().formatHex(digest.digest()).substring(0, 16);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("Unable to compute asset cache key", ex);
}
}
private void createDirectory(Path directory) {
try {
Files.createDirectories(directory);
} catch (IOException ex) {
throw new IllegalStateException("Unable to create asset generation directory: " + directory, ex);
}
}
private void copy(Path source, Path target) throws IOException {
if (source.normalize().equals(target.normalize())) {
return;
}
createDirectory(target.getParent());
Files.copy(source, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
private void copyIfMissing(Path source, Path target) throws IOException {
if (Files.isRegularFile(source) && !Files.exists(target)) {
copy(source, target);
}
}
private void writeJson(Path path, Object value) {
try {
createDirectory(path.getParent());
objectMapper.writerWithDefaultPrettyPrinter().writeValue(path.toFile(), value);
} catch (IOException ex) {
throw new IllegalStateException("Unable to write asset generation JSON: " + path, ex);
}
}
}

View File

@ -6,6 +6,7 @@ import java.util.List;
public record RenderManifest( public record RenderManifest(
String projectId, String projectId,
List<String> clipIds, List<String> clipIds,
List<String> renderedClipPaths,
String outputPath, String outputPath,
double durationSeconds, double durationSeconds,
List<List<String>> commands, List<List<String>> commands,
@ -14,6 +15,7 @@ public record RenderManifest(
) { ) {
public RenderManifest { public RenderManifest {
clipIds = clipIds == null ? List.of() : List.copyOf(clipIds); clipIds = clipIds == null ? List.of() : List.copyOf(clipIds);
renderedClipPaths = renderedClipPaths == null ? List.of() : List.copyOf(renderedClipPaths);
commands = commands == null ? List.of() : List.copyOf(commands); commands = commands == null ? List.of() : List.copyOf(commands);
assets = assets == null ? List.of() : List.copyOf(assets); assets = assets == null ? List.of() : List.copyOf(assets);
} }
@ -26,6 +28,18 @@ public record RenderManifest(
List<List<String>> commands, List<List<String>> commands,
Instant completedAt Instant completedAt
) { ) {
this(projectId, clipIds, outputPath, durationSeconds, commands, List.of(), completedAt); this(projectId, clipIds, List.of(), outputPath, durationSeconds, commands, List.of(), completedAt);
}
public RenderManifest(
String projectId,
List<String> clipIds,
String outputPath,
double durationSeconds,
List<List<String>> commands,
List<ResolvedEditAsset> assets,
Instant completedAt
) {
this(projectId, clipIds, List.of(), outputPath, durationSeconds, commands, assets, completedAt);
} }
} }

View File

@ -65,6 +65,17 @@ public class StoryboardPromptGenerator {
Create one cinematic edit plan for project `%s`. Create one cinematic edit plan for project `%s`.
The service can only render what the plan expresses, so your plan must use the actual cinematic toolkit
available to the renderer:
- shot selection and timing
- cuts, crossfades, fade-ins, and fade-outs
- dynamic punch-in crop and subtle cinematic grade
- music bed selection and timing
- SFX placement and timing
- voiceover scripting and timing
- short text overlays
- per-segment rendered clips plus the final assembled video
Constraints: Constraints:
- Style: `%s` - Style: `%s`
- Target duration: %d seconds - Target duration: %d seconds
@ -77,6 +88,7 @@ public class StoryboardPromptGenerator {
- 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. - Use music, SFX, voiceover, text overlays, and visual treatment only when they fit the detected category.
- Make the edit cinematic by directing those elements deliberately, not by trimming footage alone.
- Voiceover must be specific to what can be observed in the supplied thumbnails/contact sheets. - 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. - 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.

View File

@ -27,7 +27,7 @@ video-clipping:
minimum-scene-duration-seconds: ${VIDEO_EDITING_MINIMUM_SCENE_DURATION_SECONDS:1.0} minimum-scene-duration-seconds: ${VIDEO_EDITING_MINIMUM_SCENE_DURATION_SECONDS:1.0}
silence-threshold-db: ${VIDEO_EDITING_SILENCE_THRESHOLD_DB:-35.0} silence-threshold-db: ${VIDEO_EDITING_SILENCE_THRESHOLD_DB:-35.0}
silence-minimum-duration-seconds: ${VIDEO_EDITING_SILENCE_MINIMUM_DURATION_SECONDS:0.5} silence-minimum-duration-seconds: ${VIDEO_EDITING_SILENCE_MINIMUM_DURATION_SECONDS:0.5}
target-duration-seconds: ${VIDEO_EDITING_TARGET_DURATION_SECONDS:60} target-duration-seconds: ${VIDEO_EDITING_TARGET_DURATION_SECONDS:600}
output-width: ${VIDEO_EDITING_OUTPUT_WIDTH:1920} output-width: ${VIDEO_EDITING_OUTPUT_WIDTH:1920}
output-height: ${VIDEO_EDITING_OUTPUT_HEIGHT:1080} output-height: ${VIDEO_EDITING_OUTPUT_HEIGHT:1080}
output-frame-rate: ${VIDEO_EDITING_OUTPUT_FRAME_RATE:30} output-frame-rate: ${VIDEO_EDITING_OUTPUT_FRAME_RATE:30}
@ -78,6 +78,12 @@ video-clipping:
processed-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_PROCESSED_DIRECTORY:./input/highlights/processed} processed-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_PROCESSED_DIRECTORY:./input/highlights/processed}
rejected-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_REJECTED_DIRECTORY:./input/highlights/rejected} rejected-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_REJECTED_DIRECTORY:./input/highlights/rejected}
poll-interval-ms: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_POLL_INTERVAL_MS:5000} poll-interval-ms: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_POLL_INTERVAL_MS:5000}
render-enabled: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_RENDER_ENABLED:true}
require-director-approval: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_REQUIRE_DIRECTOR_APPROVAL:false}
approval-file-name: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_APPROVAL_FILE_NAME:approved.flag}
max-highlights-per-source: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_MAX_HIGHLIGHTS_PER_SOURCE:3}
highlight-min-duration-seconds: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_HIGHLIGHT_MIN_DURATION_SECONDS:8}
highlight-max-duration-seconds: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_HIGHLIGHT_MAX_DURATION_SECONDS:35}
logging: logging:
level: level:

View File

@ -31,7 +31,7 @@ class VideoClippingPropertiesTest {
assertThat(properties.getEditing().getMinimumSceneDurationSeconds()).isEqualTo(1.0); assertThat(properties.getEditing().getMinimumSceneDurationSeconds()).isEqualTo(1.0);
assertThat(properties.getEditing().getSilenceThresholdDb()).isEqualTo(-35.0); assertThat(properties.getEditing().getSilenceThresholdDb()).isEqualTo(-35.0);
assertThat(properties.getEditing().getSilenceMinimumDurationSeconds()).isEqualTo(0.5); assertThat(properties.getEditing().getSilenceMinimumDurationSeconds()).isEqualTo(0.5);
assertThat(properties.getEditing().getTargetDurationSeconds()).isEqualTo(60); assertThat(properties.getEditing().getTargetDurationSeconds()).isEqualTo(600);
assertThat(properties.getEditing().getOutputWidth()).isEqualTo(1920); assertThat(properties.getEditing().getOutputWidth()).isEqualTo(1920);
assertThat(properties.getEditing().getOutputHeight()).isEqualTo(1080); assertThat(properties.getEditing().getOutputHeight()).isEqualTo(1080);
assertThat(properties.getEditing().getOutputFrameRate()).isEqualTo(30); assertThat(properties.getEditing().getOutputFrameRate()).isEqualTo(30);

View File

@ -62,7 +62,14 @@ class CinematicEditingIntegrationTest {
Path output = projectDirectory.resolve("final.mp4"); Path output = projectDirectory.resolve("final.mp4");
assertThat(output).isRegularFile(); assertThat(output).isRegularFile();
assertThat(probeDuration(output)).isGreaterThan(0); assertThat(probeDuration(output)).isGreaterThan(0);
assertThat(projectDirectory.resolve("rendered-clips/clip_0001.mp4")).isRegularFile();
assertThat(projectDirectory.resolve("rendered-clips/clip_0002.mp4")).isRegularFile();
assertThat(projectDirectory.resolve("rendered-clips/clip_0003.mp4")).isRegularFile();
assertThat(projectDirectory.resolve("render-manifest.json")).exists(); assertThat(projectDirectory.resolve("render-manifest.json")).exists();
RenderManifest manifest = store.readJson(projectId, "render-manifest.json", RenderManifest.class);
assertThat(manifest.clipIds()).containsExactly("clip_01", "clip_02", "clip_03");
assertThat(manifest.renderedClipPaths()).hasSize(3)
.allSatisfy(path -> assertThat(Path.of(path)).isRegularFile());
assertThat(projectDirectory.resolve("qa-report.json")).exists(); assertThat(projectDirectory.resolve("qa-report.json")).exists();
assertThat(projects.getProject(projectId).status()).isEqualTo(EditProjectStatus.RENDERED); assertThat(projects.getProject(projectId).status()).isEqualTo(EditProjectStatus.RENDERED);
} }
@ -128,7 +135,8 @@ 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, "category-aware-cinematic-highlights", 60, decisions, List.of(), List.of(), return new EditPlan(projectId, "category-aware-cinematic-highlights",
decisions.get(decisions.size() - 1).timelineEndSeconds(), decisions, List.of(), List.of(),
"mp4-h264-aac-320p", "Generated integration edit"); "mp4-h264-aac-320p", "Generated integration edit");
} }

View File

@ -130,10 +130,11 @@ class EditPlanInboxScannerTest {
} }
private void writeValidPlan() throws Exception { private void writeValidPlan() throws Exception {
EditPlan plan = new EditPlan("porsche-edit", "cinematic-porsche-promo", 60, EditPlan plan = new EditPlan("porsche-edit", "cinematic-porsche-promo", 2.5,
List.of(new EditDecision("clip_00001", 0, 2.5, 0, 2.5, List.of(new EditDecision("clip_00001", 0, 2.5, 0, 2.5,
"cut", "cut", 1.0, "cinematic", "opening")), "cut", "cut", 1.0, "cinematic", "opening")),
List.of(), List.of(), "mp4-h264-aac-1080p", "Hero edit"); List.of(new AudioCue("music", "premium-cinematic-drive", 0, 2.5, -14, "opening bed")),
List.of(), "mp4-h264-aac-1080p", "Hero edit");
objectMapper.writeValue(store.inboxDirectory("porsche-edit").resolve("edit-plan.json").toFile(), plan); objectMapper.writeValue(store.inboxDirectory("porsche-edit").resolve("edit-plan.json").toFile(), plan);
} }

View File

@ -37,6 +37,41 @@ class EditPlanValidatorTest {
@Test void acceptsValidPlan() { assertThat(validator.validate("project", plan(decision("clip-1", 0, 4, 0, 4, "cut")))).isNotNull(); } @Test void acceptsValidPlan() { assertThat(validator.validate("project", plan(decision("clip-1", 0, 4, 0, 4, "cut")))).isNotNull(); }
@Test void acceptsContentDrivenPlanShorterThanProjectTarget() {
EditPlan shorter = new EditPlan("project", "cinematic-porsche-promo", 4,
List.of(decision("clip-1", 0, 4, 0, 4, "cut")),
List.of(new AudioCue("music", "premium-cinematic-drive", 0, 4, -14, "bed")),
List.of(),
"mp4-h264-aac-1080p", "summary");
assertThat(validator.validate("project", shorter)).isEqualTo(shorter);
}
@Test void rejectsBareTrimOnlyPlanWithoutCinematicRichness() {
EditPlan bare = new EditPlan("project", "cinematic-porsche-promo", 4,
List.of(new EditDecision("clip-1", 0, 4, 0, 4, "cut", "cut", 1, "none", "reason")),
List.of(), List.of(), List.of(), "mp4-h264-aac-1080p", "summary");
rejects(bare);
}
@Test void rejectsPlanTargetDurationThatDoesNotMatchFinalTimeline() {
EditPlan mismatch = new EditPlan("project", "cinematic-porsche-promo", 8,
List.of(decision("clip-1", 0, 4, 0, 4, "cut")), List.of(), List.of(),
"mp4-h264-aac-1080p", "summary");
rejects(mismatch);
}
@Test void rejectsPlanLongerThanProjectMaximumTarget() {
EditPlan tooLong = new EditPlan("project", "cinematic-porsche-promo", 12,
List.of(decision("clip-1", 0, 8, 0, 8, "cut"),
decision("clip-1", 0, 4, 8, 12, "cut")),
List.of(), List.of(), "mp4-h264-aac-1080p", "summary");
rejects(tooLong);
}
@Test void rejectsUnknownClip() { rejects(plan(decision("missing", 0, 4, 0, 4, "cut"))); } @Test void rejectsUnknownClip() { rejects(plan(decision("missing", 0, 4, 0, 4, "cut"))); }
@Test void rejectsOutOfRangeSource() { rejects(plan(decision("clip-1", 0, 9, 0, 4, "cut"))); } @Test void rejectsOutOfRangeSource() { rejects(plan(decision("clip-1", 0, 9, 0, 4, "cut"))); }
@ -141,7 +176,11 @@ class EditPlanValidatorTest {
} }
private EditPlan plan(EditDecision... decisions) { private EditPlan plan(EditDecision... decisions) {
return new EditPlan("project", "cinematic-porsche-promo", 10, List.of(decisions), List.of(), List.of(), return new EditPlan("project", "cinematic-porsche-promo", decisions[decisions.length - 1].timelineEndSeconds(),
List.of(decisions),
List.of(new AudioCue("music", "premium-cinematic-drive", 0,
decisions[decisions.length - 1].timelineEndSeconds(), -14, "bed")),
List.of(),
"mp4-h264-aac-1080p", "summary"); "mp4-h264-aac-1080p", "summary");
} }

View File

@ -0,0 +1,75 @@
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.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HighlightAssetPreparationServiceTest {
@TempDir
Path tempDir;
@Test
void writesVoiceoverScriptAndRequestsWhenAssetsAreMissing() throws Exception {
VideoClippingProperties properties = properties();
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
FileSystemHighlightProjectStore store = new FileSystemHighlightProjectStore(properties, mapper);
HighlightProject project = project();
store.createProject(project, manifest());
EditAssetProvider assetProvider = mock(EditAssetProvider.class);
EditAssetLibrary assetLibrary = mock(EditAssetLibrary.class);
when(assetLibrary.select(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.Optional.empty());
when(assetProvider.list(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
when(assetProvider.resolve(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.Optional.empty());
HighlightAssetPreparationService service = new HighlightAssetPreparationService(properties, store,
assetProvider, assetLibrary, mapper);
HighlightDirectorPlan.HighlightItem highlight = new HighlightDirectorPlan.HighlightItem(
"highlight_001", "candidate_001", "Opening Reveal", 0.0, 12.0, 12.0,
"opening_hook", "premium cinematic grade", "low cinematic pulse",
"whoosh on reveal", List.of("This is a clean first look."), List.of("Pure presence"),
"hold the opening beat");
HighlightAssetPreparationService.HighlightAssetPreparationResult result = service.prepare(project.id(),
project, highlight, ContentCategory.GENERIC_VLOG);
assertThat(result.requestFiles()).isNotEmpty();
assertThat(Files.exists(store.highlightsDirectory(project.id()).resolve("highlight_001")
.resolve("assets/voiceover/voiceover-script.txt"))).isTrue();
try (var files = Files.list(store.highlightsDirectory(project.id()).resolve("highlight_001")
.resolve("assets/requests"))) {
assertThat(files.count()).isGreaterThan(0);
}
}
private VideoClippingProperties properties() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
properties.getEditing().getAssets().setMusicFolder(tempDir.resolve("music").toString());
properties.getEditing().getAssets().setSfxFolder(tempDir.resolve("sfx").toString());
properties.getEditing().getAssets().setVoiceoverFolder(tempDir.resolve("voiceover-cache").toString());
return properties;
}
private HighlightProject project() {
Instant now = Instant.parse("2026-07-11T08:00:00Z");
return new HighlightProject("highlight-project-001", "Highlight Project",
HighlightProjectStatus.WAITING_FOR_DIRECTOR, "source.mp4",
tempDir.resolve("highlight-projects/highlight-project-001").toString(), now, now, null);
}
private HighlightProjectManifest manifest() {
return new HighlightProjectManifest("highlight-project-001", "source.mp4",
HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z"));
}
}

View File

@ -0,0 +1,109 @@
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.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HighlightDirectorFlowServiceTest {
@TempDir
Path tempDir;
@Test
void importsDirectorPlanWritesHighlightEditPlanAndPublishesFinalVideo() throws Exception {
VideoClippingProperties properties = properties();
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
FileSystemHighlightProjectStore store = new FileSystemHighlightProjectStore(properties, mapper);
HighlightProject project = project();
store.createProject(project, manifest());
store.writeJson(project.id(), "analysis/source-analysis.json", sourceAnalysis());
store.writeJson(project.id(), "director/edit-plan.json", directorPlan());
Path source = store.sourceDirectory(project.id()).resolve("source.mp4");
createDummyFile(source);
HighlightVisualEffectsStage visualEffectsStage = mock(HighlightVisualEffectsStage.class);
HighlightAssetPreparationService assetPreparationService = mock(HighlightAssetPreparationService.class);
HighlightAssetPreparationService.HighlightAssetPreparationResult assetResult =
new HighlightAssetPreparationService.HighlightAssetPreparationResult(
store.highlightsDirectory(project.id()).resolve("highlight_001"),
store.highlightsDirectory(project.id()).resolve("highlight_001").resolve("assets"),
List.of(), List.of());
when(assetPreparationService.prepare(anyString(), any(), any(), any())).thenReturn(assetResult);
HighlightLocalAssetWorker assetWorker = mock(HighlightLocalAssetWorker.class);
when(assetWorker.process(anyString(), any(), any())).thenReturn(
new HighlightLocalAssetWorker.HighlightAssetWorkerResult(project.id(), "highlight_001",
List.of(), List.of()));
HighlightFfmpegRenderer renderer = mock(HighlightFfmpegRenderer.class);
Path renderedClip = store.highlightsDirectory(project.id()).resolve("highlight_001").resolve("final.mp4");
createDummyFile(renderedClip);
HighlightFfmpegRenderer.HighlightRenderResult renderResult = new HighlightFfmpegRenderer.HighlightRenderResult(
"highlight_001", renderedClip, renderedClip, List.of(renderedClip),
new RenderManifest(project.id(), List.of("source"), List.of(renderedClip.toString()),
renderedClip.toString(), 12.0, List.of(), List.of(), Instant.now()),
new RenderQaReport(project.id(), true, List.of(), Instant.now()));
when(renderer.render(anyString(), any(), any())).thenReturn(renderResult);
HighlightDirectorFlowService service = new HighlightDirectorFlowService(properties, mapper, store,
visualEffectsStage, assetPreparationService, assetWorker, renderer);
HighlightDirectorFlowService.HighlightFlowResult result = service.process(project.id(), 1);
assertThat(result.finalOutputs()).hasSize(1);
assertThat(store.highlightsDirectory(project.id()).resolve("highlight_001/edit-plan.json")).exists();
assertThat(store.highlightsDirectory(project.id()).resolve("highlight_001/storyboard.md")).exists();
assertThat(store.projectDirectory(project.id()).resolve("final.mp4")).exists();
assertThat(store.readJson(project.id(), "project.json", HighlightProject.class).status())
.isEqualTo(HighlightProjectStatus.RENDERED);
}
private VideoClippingProperties properties() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
return properties;
}
private HighlightProject project() {
Instant now = Instant.parse("2026-07-11T08:00:00Z");
return new HighlightProject("highlight-project-001", "Highlight Project",
HighlightProjectStatus.WAITING_FOR_DIRECTOR, "source.mp4",
tempDir.resolve("highlight-projects/highlight-project-001").toString(), now, now, null);
}
private HighlightProjectManifest manifest() {
return new HighlightProjectManifest("highlight-project-001", "source.mp4",
HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z"));
}
private HighlightDirectorPlan directorPlan() {
return new HighlightDirectorPlan("highlight-project-001", "source.mp4", "generic_vlog",
List.of(new HighlightDirectorPlan.HighlightItem("highlight_001", "candidate_001", "Opening",
0.0, 12.0, 12.0, "opening_hook", "premium cinematic grade",
"low pulse", "whoosh", List.of("A clean first look."), List.of("Pure presence"),
"open with presence")), "summary");
}
private HighlightSourceAnalysis sourceAnalysis() {
ClipAnalysis source = new ClipAnalysis("source", "source.mp4", 12.0, "hevc", "aac", 1920, 1080, 24.0,
List.of(), null, null, 0.0, 0.0, 0.0);
return new HighlightSourceAnalysis("highlight-project-001", "source.mp4", source, List.of(), null, null,
null, "analysis/scene-segments.json", List.of(), "analysis/audio-analysis.json",
new SourceAudioAnalysis("source", true, -18.0, -3.0, -35.0, 0.5, List.of(), null),
"analysis/visual-analysis.json", new SourceVisualAnalysis("source", 0.5, 0.5, 0.5, 0.5,
"unknown", List.of(), List.of(), "heuristic"), Instant.parse("2026-07-11T08:00:00Z"));
}
private void createDummyFile(Path file) throws Exception {
Files.createDirectories(file.getParent());
Files.writeString(file, "dummy");
}
}

View File

@ -0,0 +1,83 @@
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 HighlightDirectorPromptBackfillTest {
@TempDir
Path tempDir;
@Test
void backfillsMissingHighlightDirectorPromptOnStartup() throws Exception {
FileSystemHighlightProjectStore store = store();
HighlightProject project = project();
store.createProject(project, manifest());
store.writeJson(project.id(), "analysis/source-analysis.json", sourceAnalysis(project.id()));
store.writeJson(project.id(), "analysis/category.json", cinematicAnalysis(project.id()));
store.writeJson(project.id(), "analysis/highlight-candidates.json", new HighlightCandidate[] {
new HighlightCandidate("candidate_001", "clip_00001", 1.0, 9.5, 0.94,
"opening_hook", List.of("hero angle", "strong motion"))
});
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
HighlightDirectorPromptBackfill backfill = new HighlightDirectorPromptBackfill(properties,
new HighlightDirectorPromptGenerator(store));
backfill.backfill();
assertThat(Files.exists(store.directorDirectory(project.id()).resolve("director-prompt.md"))).isTrue();
assertThat(Files.exists(store.directorDirectory(project.id()).resolve("director-brief.md"))).isTrue();
}
private FileSystemHighlightProjectStore store() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
return new FileSystemHighlightProjectStore(properties, new ObjectMapper().findAndRegisterModules());
}
private HighlightProject project() {
Instant now = Instant.parse("2026-07-11T08:00:00Z");
return new HighlightProject("porsche-highlight-001", "Porsche Highlight",
HighlightProjectStatus.CREATED, "porsche.mp4",
tempDir.resolve("highlight-projects/porsche-highlight-001").toString(), now, now, null);
}
private HighlightProjectManifest manifest() {
return new HighlightProjectManifest("porsche-highlight-001", "porsche.mp4",
HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z"));
}
private HighlightSourceAnalysis sourceAnalysis(String projectId) {
ClipAnalysis source = new ClipAnalysis("clip_00001", "porsche.mp4", 12.0, "h264", "aac",
1920, 1080, 30.0, List.of("thumb-1.jpg", "thumb-2.jpg"),
"contact.jpg", "proxy.mp4", 0.8, 0.7, 0.9);
return new HighlightSourceAnalysis(projectId, "porsche.mp4", source, List.of("thumb-1.jpg", "thumb-2.jpg"),
"contact.jpg", "proxy.mp4", "waveform.png", "analysis/scene-segments.json",
List.of(new ShotSegment("shot_0001", 0, 12, 12, 0, 6)),
"analysis/audio-analysis.json", new SourceAudioAnalysis(projectId, true, -18.0, -3.0,
-35.0, 0.5, List.of(new AudioSection("audio_section_0001", "unclassified_audio", 0, 12, 12)),
null),
"analysis/visual-analysis.json", new SourceVisualAnalysis(projectId, 0.6, 0.7, 0.8, 0.75,
"unknown_without_face_detector", List.of(new VisualObjectLabel("porsche", 0.82, "metadata_heuristic")),
List.of("thumb-1.jpg"), "metadata_thumbnail_scene_heuristic"), Instant.parse("2026-07-11T08:00:00Z"));
}
private CinematicHighlightAnalysis cinematicAnalysis(String projectId) {
return new CinematicHighlightAnalysis(projectId, ContentCategory.CAR_VLOG, 0.91,
List.of("car metadata detected", "hero motion present"),
List.of(new HighlightCandidate("candidate_001", "clip_00001", 1.0, 9.5, 0.94,
"opening_hook", List.of("hero angle", "strong motion"))),
Instant.parse("2026-07-11T08:00:00Z"));
}
}

View File

@ -0,0 +1,90 @@
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 HighlightDirectorPromptGeneratorTest {
@TempDir
Path tempDir;
@Test
void writesHighlightDirectorPromptAndBriefForAnalyzedProject() throws Exception {
FileSystemHighlightProjectStore store = store();
HighlightProject project = project();
store.createProject(project, manifest());
store.writeJson(project.id(), "analysis/source-analysis.json", sourceAnalysis(project.id()));
store.writeJson(project.id(), "analysis/category.json", cinematicAnalysis(project.id()));
store.writeJson(project.id(), "analysis/highlight-candidates.json", new HighlightCandidate[] {
new HighlightCandidate("candidate_001", "clip_00001", 1.0, 9.5, 0.94,
"opening_hook", List.of("hero angle", "strong motion"))
});
HighlightDirectorPromptGenerator generator = new HighlightDirectorPromptGenerator(store);
HighlightDirectorPromptGenerator.DirectorPromptFiles files = generator.generate(project.id());
assertThat(Files.readString(Path.of(files.promptPath())))
.contains("analysis/source-analysis.json")
.contains("analysis/highlight-candidates.json")
.contains("director/edit-plan.json")
.contains("car_vlog")
.contains("candidate_001")
.contains("premium, powerful, precise");
assertThat(Files.readString(Path.of(files.briefPath())))
.contains("Highlight Director Brief")
.contains("director-prompt.md")
.contains("director/edit-plan.json");
assertThat(store.readJson(project.id(), "project.json", HighlightProject.class).status())
.isEqualTo(HighlightProjectStatus.WAITING_FOR_DIRECTOR);
}
private FileSystemHighlightProjectStore store() {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
return new FileSystemHighlightProjectStore(properties, new ObjectMapper().findAndRegisterModules());
}
private HighlightProject project() {
Instant now = Instant.parse("2026-07-11T08:00:00Z");
return new HighlightProject("porsche-highlight-001", "Porsche Highlight",
HighlightProjectStatus.CREATED, "porsche.mp4",
tempDir.resolve("highlight-projects/porsche-highlight-001").toString(), now, now, null);
}
private HighlightProjectManifest manifest() {
return new HighlightProjectManifest("porsche-highlight-001", "porsche.mp4",
HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z"));
}
private HighlightSourceAnalysis sourceAnalysis(String projectId) {
ClipAnalysis source = new ClipAnalysis("clip_00001", "porsche.mp4", 12.0, "h264", "aac",
1920, 1080, 30.0, List.of("thumb-1.jpg", "thumb-2.jpg"),
"contact.jpg", "proxy.mp4", 0.8, 0.7, 0.9);
return new HighlightSourceAnalysis(projectId, "porsche.mp4", source, List.of("thumb-1.jpg", "thumb-2.jpg"),
"contact.jpg", "proxy.mp4", "waveform.png", "analysis/scene-segments.json",
List.of(new ShotSegment("shot_0001", 0, 12, 12, 0, 6)),
"analysis/audio-analysis.json", new SourceAudioAnalysis(projectId, true, -18.0, -3.0,
-35.0, 0.5, List.of(new AudioSection("audio_section_0001", "unclassified_audio", 0, 12, 12)),
null),
"analysis/visual-analysis.json", new SourceVisualAnalysis(projectId, 0.6, 0.7, 0.8, 0.75,
"unknown_without_face_detector", List.of(new VisualObjectLabel("porsche", 0.82, "metadata_heuristic")),
List.of("thumb-1.jpg"), "metadata_thumbnail_scene_heuristic"), Instant.parse("2026-07-11T08:00:00Z"));
}
private CinematicHighlightAnalysis cinematicAnalysis(String projectId) {
return new CinematicHighlightAnalysis(projectId, ContentCategory.CAR_VLOG, 0.91,
List.of("car metadata detected", "hero motion present"),
List.of(new HighlightCandidate("candidate_001", "clip_00001", 1.0, 9.5, 0.94,
"opening_hook", List.of("hero angle", "strong motion"))),
Instant.parse("2026-07-11T08:00:00Z"));
}
}

View File

@ -0,0 +1,64 @@
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.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class HighlightLocalAssetWorkerTest {
@TempDir
Path tempDir;
@Test
void resolvesMusicFromLocalLibraryAndKeepsPendingWhenVoiceoverCannotBeSynthesized() throws Exception {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
properties.getEditing().getAssets().setMusicFolder(tempDir.resolve("music").toString());
properties.getEditing().getAssets().setSfxFolder(tempDir.resolve("sfx").toString());
properties.getEditing().getAssets().setVoiceoverFolder(tempDir.resolve("voiceover-cache").toString());
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
FileSystemHighlightProjectStore store = new FileSystemHighlightProjectStore(properties, mapper);
HighlightProject project = new HighlightProject("project-1", "Project", HighlightProjectStatus.CREATED,
"source.mp4", tempDir.resolve("highlight-projects/project-1").toString(),
Instant.parse("2026-07-11T08:00:00Z"), Instant.parse("2026-07-11T08:00:00Z"), null);
store.createProject(project, new HighlightProjectManifest("project-1", "source.mp4",
HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z")));
Path musicSource = tempDir.resolve("music/cinematic-bed.wav");
Files.createDirectories(musicSource.getParent());
Files.writeString(musicSource, "music");
Files.writeString(musicSource.resolveSibling("cinematic-bed.wav.tags.txt"), "cinematic,bed");
EditAssetProvider assetProvider = mock(EditAssetProvider.class);
EditAssetLibrary assetLibrary = mock(EditAssetLibrary.class);
when(assetLibrary.select(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.Optional.of(
new ResolvedEditAsset(EditAssetType.MUSIC, "cinematic-bed", musicSource.toString(),
"local", "local", ContentCategory.GENERIC_VLOG, List.of("cinematic", "bed"))));
when(assetProvider.resolve(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.Optional.empty());
when(assetProvider.list(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()))
.thenReturn(List.of());
HighlightAssetPreparationService prep = new HighlightAssetPreparationService(properties, store,
assetProvider, assetLibrary, mapper);
HighlightDirectorPlan.HighlightItem highlight = new HighlightDirectorPlan.HighlightItem(
"highlight_001", "candidate_001", "Opening", 0, 12, 12, "opening_hook",
"premium cinematic grade", "low pulse", "whoosh",
List.of("A clean first look."), List.of("Pure presence"), "open with presence");
prep.prepare("project-1", project, highlight, ContentCategory.GENERIC_VLOG);
HighlightLocalAssetWorker worker = new HighlightLocalAssetWorker(store, assetProvider, assetLibrary, mapper);
HighlightLocalAssetWorker.HighlightAssetWorkerResult result = worker.process("project-1", highlight,
ContentCategory.GENERIC_VLOG);
assertThat(result.resolvedAssets()).isNotEmpty();
assertThat(store.highlightsDirectory("project-1").resolve("highlight_001/assets/music/music.wav"))
.exists();
}
}

View File

@ -27,6 +27,7 @@ class HighlightSourceSchedulerTest {
private VideoClippingProperties properties; private VideoClippingProperties properties;
private FileSystemHighlightProjectStore store; private FileSystemHighlightProjectStore store;
private HighlightSourceAnalyzer analyzer; private HighlightSourceAnalyzer analyzer;
private HighlightDirectorPromptGenerator promptGenerator;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -40,6 +41,7 @@ class HighlightSourceSchedulerTest {
new HighlightProjectDirectoryInitializer(properties).initialize(); new HighlightProjectDirectoryInitializer(properties).initialize();
store = new FileSystemHighlightProjectStore(properties, new ObjectMapper().findAndRegisterModules()); store = new FileSystemHighlightProjectStore(properties, new ObjectMapper().findAndRegisterModules());
analyzer = mock(HighlightSourceAnalyzer.class); analyzer = mock(HighlightSourceAnalyzer.class);
promptGenerator = mock(HighlightDirectorPromptGenerator.class);
} }
@Test @Test
@ -73,6 +75,7 @@ class HighlightSourceSchedulerTest {
assertThat(tempDir.resolve("highlight-projects/1/source/1.mp4")).exists(); assertThat(tempDir.resolve("highlight-projects/1/source/1.mp4")).exists();
assertThat(tempDir.resolve("highlight-projects/2")).doesNotExist(); assertThat(tempDir.resolve("highlight-projects/2")).doesNotExist();
verify(analyzer).analyze("1"); verify(analyzer).analyze("1");
verify(promptGenerator).generate("1");
} }
@Test @Test
@ -108,7 +111,7 @@ class HighlightSourceSchedulerTest {
when(analyzer.analyze("porsche")).thenReturn(analysis("porsche")); when(analyzer.analyze("porsche")).thenReturn(analysis("porsche"));
when(analyzer.analyze("porsche-1")).thenReturn(analysis("porsche-1")); when(analyzer.analyze("porsche-1")).thenReturn(analysis("porsche-1"));
when(analyzer.analyze("porsche-drive")).thenReturn(analysis("porsche-drive")); when(analyzer.analyze("porsche-drive")).thenReturn(analysis("porsche-drive"));
return new HighlightSourceScheduler(properties, store, analyzer, clock); return new HighlightSourceScheduler(properties, store, analyzer, promptGenerator, clock);
} }
private HighlightSourceAnalysis analysis(String projectId) { private HighlightSourceAnalysis analysis(String projectId) {

View File

@ -0,0 +1,41 @@
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.Path;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class HighlightVisualEffectsStageTest {
@TempDir
Path tempDir;
@Test
void writesExplicitVisualEffectsPlan() throws Exception {
VideoClippingProperties properties = new VideoClippingProperties();
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
FileSystemHighlightProjectStore store = new FileSystemHighlightProjectStore(properties, mapper);
HighlightProject project = new HighlightProject("project-1", "Project", HighlightProjectStatus.CREATED,
"source.mp4", tempDir.resolve("highlight-projects/project-1").toString(),
Instant.parse("2026-07-11T08:00:00Z"), Instant.parse("2026-07-11T08:00:00Z"), null);
store.createProject(project, new HighlightProjectManifest("project-1", "source.mp4",
HighlightFolderContract.standard(), Instant.parse("2026-07-11T08:00:00Z")));
HighlightVisualEffectsStage stage = new HighlightVisualEffectsStage(store, mapper);
HighlightVisualEffectsStage.HighlightVisualEffectsPlan plan = stage.create("project-1",
new HighlightDirectorPlan.HighlightItem("highlight_001", "candidate_001", "Opening", 0, 12, 12,
"opening_hook", "premium cinematic grade", "low pulse", "whoosh",
List.of("Narration"), List.of("Title card"), "hold the reveal"),
ContentCategory.GENERIC_VLOG);
assertThat(plan.effects()).contains("crop", "punch-in", "contrast", "vignette", "fade");
assertThat(store.highlightsDirectory("project-1").resolve("highlight_001/visual-effects.json")).exists();
}
}

View File

@ -0,0 +1,98 @@
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 LocalAssetGenerationStageTest {
@TempDir
Path tempDir;
@Test
void reusesExistingSharedAssetsAndWritesRequestsForMissingVoiceover() throws Exception {
VideoClippingProperties properties = properties();
Files.createDirectories(tempDir.resolve("shared/music"));
Files.createDirectories(tempDir.resolve("shared/sfx"));
Files.createDirectories(tempDir.resolve("voiceover-cache"));
Files.writeString(tempDir.resolve("shared/music/premium-bed.wav"), "music");
Files.writeString(tempDir.resolve("shared/sfx/whoosh.wav"), "sfx");
properties.getEditing().getAssets().setMusicFolder(tempDir.resolve("shared/music").toString());
properties.getEditing().getAssets().setSfxFolder(tempDir.resolve("shared/sfx").toString());
properties.getEditing().getAssets().setVoiceoverFolder(tempDir.resolve("voiceover-cache").toString());
FileSystemEditProjectStore store = store(properties);
store.createProject("project");
LocalAssetGenerationStage stage = new LocalAssetGenerationStage(properties, store,
new LocalEditAssetProvider(properties),
new LocalEditAssetLibrary(new LocalEditAssetProvider(properties)),
new ObjectMapper().findAndRegisterModules());
EditPlan plan = new EditPlan("project", "cinematic-porsche-promo", 4,
List.of(
new EditDecision("clip-1", 0, 4, 0, 4, "cut", "cut", 1.0, "cinematic grade", "opening")
),
List.of(
new AudioCue("music", "premium-bed", 0, 4, -14, "drive bed"),
new AudioCue("sfx", "whoosh", 1, 2, -6, "impact")
),
List.of(new VoiceoverLine("Precision in motion.", 0, 2, "confident")),
"mp4-h264-aac-1080p",
"summary");
AssetGenerationResult result = stage.prepare("project", plan);
assertThat(result.readyForRender()).isTrue();
assertThat(tempDir.resolve("projects/project/audio/music.wav")).exists();
assertThat(tempDir.resolve("projects/project/audio/sfx/whoosh.wav")).exists();
assertThat(tempDir.resolve("projects/project/assets/asset-generation-manifest.json")).exists();
assertThat(tempDir.resolve("projects/project/assets/generated-assets.json")).exists();
assertThat(tempDir.resolve("projects/project/assets/requests/voiceover")).exists();
}
@Test
void writesBlockingRequestsWhenSfxIsMissing() throws Exception {
VideoClippingProperties properties = properties();
properties.getEditing().getAssets().setMusicFolder(tempDir.resolve("shared/music").toString());
properties.getEditing().getAssets().setSfxFolder(tempDir.resolve("shared/sfx").toString());
properties.getEditing().getAssets().setVoiceoverFolder(tempDir.resolve("voiceover-cache").toString());
FileSystemEditProjectStore store = store(properties);
store.createProject("project");
LocalAssetGenerationStage stage = new LocalAssetGenerationStage(properties, store,
new LocalEditAssetProvider(properties),
new LocalEditAssetLibrary(new LocalEditAssetProvider(properties)),
new ObjectMapper().findAndRegisterModules());
EditPlan plan = new EditPlan("project", "cinematic-porsche-promo", 4,
List.of(new EditDecision("clip-1", 0, 4, 0, 4, "cut", "cut", 1.0, "cinematic grade", "opening")),
List.of(new AudioCue("sfx", "engine-hit", 1, 2, -6, "impact")),
List.of(), "mp4-h264-aac-1080p", "summary");
AssetGenerationResult result = stage.prepare("project", plan);
assertThat(result.readyForRender()).isFalse();
assertThat(tempDir.resolve("projects/project/assets/requests/sfx")).exists();
}
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());
}
}

View File

@ -30,6 +30,7 @@ class StoryboardPromptGeneratorTest {
assertThat(prompt) assertThat(prompt)
.contains("clipId: clip_00001") .contains("clipId: clip_00001")
.contains("durationSeconds: 8.0") .contains("durationSeconds: 8.0")
.contains("The service can only render what the plan expresses")
.contains("Target duration: 60 seconds") .contains("Target duration: 60 seconds")
.contains("Style: `cinematic-porsche-promo`") .contains("Style: `cinematic-porsche-promo`")
.contains("\"decisions\"") .contains("\"decisions\"")