Reels: mixed-media (video clip beats) + faster burst fade

Videos in a span now appear as clip beats: the first few seconds of the
video (capped at CLIP_SECONDS=5, and to the source length) filled to the
portrait canvas like photos, with its live audio ducked under the
narration (amix at 0.35). If the narration outlasts the clip, the last
frame is held (tpad); clips with no audio track just play under narration.

Selection splits the beat budget between photo beats and clip beats —
clips get up to half (≥1 when present), photos the rest — then merges
both back into chronological order. SegmentMedia gains a Clip variant;
beats carry `media` (photos or one clip) and the cache key tags P/C so a
path used as a still vs a clip differ.

Also drops the burst fade from 0.15s to 0.08s so a quick burst reads
clearly differently from a held shot. Bumps RENDER_VERSION.

The clip filtergraph (fill + duck-mix + last-frame hold) is unit-tested
but, like the rest of the ffmpeg path, wants a real render check on the
GPU host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-06-13 00:02:51 -04:00
parent 299e32b014
commit 65793a2dda
4 changed files with 479 additions and 91 deletions
+17 -4
View File
@@ -54,8 +54,10 @@ pub fn build_script_messages(meta: &ReelMeta, beats: &[PlannedBeat]) -> (String,
if let Some(date) = beat.date_label() {
user.push_str(&format!(" {date}"));
}
if beat.photos.len() > 1 {
user.push_str(&format!(" (a burst of {} photos)", beat.photos.len()));
if beat.is_clip() {
user.push_str(" (a video clip)");
} else if beat.media.len() > 1 {
user.push_str(&format!(" (a burst of {} photos)", beat.media.len()));
}
user.push('\n');
match (&beat.insight_title, &beat.insight_summary) {
@@ -211,7 +213,7 @@ mod tests {
fn planned(n: usize) -> Vec<PlannedBeat> {
(0..n)
.map(|i| PlannedBeat {
photos: vec![super::super::SegmentMedia::Photo {
media: vec![super::super::SegmentMedia::Photo {
rel_path: format!("p{i}.jpg"),
library_id: 1,
}],
@@ -236,7 +238,7 @@ mod tests {
#[test]
fn prompt_notes_burst_photo_count() {
let mut p = planned(1);
p[0].photos = vec![
p[0].media = vec![
super::super::SegmentMedia::Photo {
rel_path: "a.jpg".into(),
library_id: 1,
@@ -254,6 +256,17 @@ mod tests {
assert!(user.contains("a burst of 3 photos"));
}
#[test]
fn prompt_marks_clip_beats() {
let mut p = planned(1);
p[0].media = vec![super::super::SegmentMedia::Clip {
rel_path: "v.mp4".into(),
library_id: 1,
}];
let (_sys, user) = build_script_messages(&meta(), &p);
assert!(user.contains("a video clip"));
}
#[test]
fn prompt_includes_insight_context_when_present() {
let mut p = planned(1);