- 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:
parent
adc979eea8
commit
618ba9af49
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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\"")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue