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:
1 parent
299e32b014
commit
65793a2dda
4 files changed
+479
-91
No files matched your search
+134
-36
@@ -15,7 +15,7 @@ use chrono::{DateTime, Datelike, FixedOffset};
|
||||
|
||||
use super::{PlannedBeat, ReelMeta, SegmentMedia};
|
||||
use crate::database::{ExifDao, InsightDao};
|
||||
use crate::file_types::is_image_file;
|
||||
use crate::file_types::{is_image_file, is_video_file};
|
||||
use crate::memories::{self, MemoriesSpan};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -167,13 +167,13 @@ fn partition_into_groups(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn a span's photos into `n_beats` beats. Clusters photos into events by
|
||||
/// Turn photo items into `n_beats` photo beats. Clusters photos into events by
|
||||
/// time gap; if there are more events than beats, adjacent events are merged so
|
||||
/// the whole span is still covered. Each beat then flashes up to
|
||||
/// `max_burst` photos (an even spread of its group) under one narration line —
|
||||
/// so a week/month reel *shows* all its moments without a narrated (and timed)
|
||||
/// the whole span is still covered. Each beat then flashes up to `max_burst`
|
||||
/// photos (an even spread of its group) under one narration line — so a
|
||||
/// week/month reel *shows* all its moments without a narrated (and timed)
|
||||
/// segment per photo.
|
||||
pub fn form_beats(
|
||||
fn form_photo_beats(
|
||||
items: &[memories::MemoryItem],
|
||||
n_beats: usize,
|
||||
max_burst: usize,
|
||||
@@ -197,7 +197,7 @@ pub fn form_beats(
|
||||
let shown = sample_evenly(&group, max_burst);
|
||||
let date = shown.first().and_then(|it| it.created);
|
||||
PlannedBeat {
|
||||
photos: shown
|
||||
media: shown
|
||||
.into_iter()
|
||||
.map(|it| SegmentMedia::Photo {
|
||||
rel_path: it.path,
|
||||
@@ -212,6 +212,62 @@ pub fn form_beats(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Split the beat budget between photo beats and video-clip beats. Clips are
|
||||
/// individually valuable (motion + live audio) so they get up to half the
|
||||
/// budget (at least one if any exist); photos take the rest. With only one
|
||||
/// kind present, it gets the whole budget.
|
||||
fn split_beat_budget(n_photos: usize, n_videos: usize, n_beats: usize) -> (usize, usize) {
|
||||
if n_videos == 0 {
|
||||
return (n_beats, 0);
|
||||
}
|
||||
if n_photos == 0 {
|
||||
return (0, n_beats.min(n_videos));
|
||||
}
|
||||
let clip_beats = n_videos.min((n_beats / 2).max(1));
|
||||
let photo_beats = n_beats.saturating_sub(clip_beats);
|
||||
(photo_beats, clip_beats)
|
||||
}
|
||||
|
||||
/// Build the reel's beats from a span's photos and videos under a beat budget.
|
||||
/// Videos become one-clip beats (sampled across time if there are more than the
|
||||
/// clip budget); photos cluster into burst beats. The two are merged back into
|
||||
/// chronological order so the reel reads as the span unfolded.
|
||||
pub fn form_beats(
|
||||
photos: &[memories::MemoryItem],
|
||||
videos: &[memories::MemoryItem],
|
||||
n_beats: usize,
|
||||
max_burst: usize,
|
||||
) -> Vec<PlannedBeat> {
|
||||
if n_beats == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let (photo_budget, clip_budget) = split_beat_budget(photos.len(), videos.len(), n_beats);
|
||||
|
||||
let mut beats = form_photo_beats(photos, photo_budget, max_burst);
|
||||
|
||||
// One clip beat per chosen video, spread across the span's videos.
|
||||
for v in sample_evenly(videos, clip_budget) {
|
||||
beats.push(PlannedBeat {
|
||||
media: vec![SegmentMedia::Clip {
|
||||
rel_path: v.path,
|
||||
library_id: v.library_id,
|
||||
}],
|
||||
date: v.created,
|
||||
insight_title: None,
|
||||
insight_summary: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Merge photo and clip beats back into chronological order (undated last).
|
||||
beats.sort_by(|a, b| match (a.date, b.date) {
|
||||
(Some(x), Some(y)) => x.cmp(&y),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
});
|
||||
beats
|
||||
}
|
||||
|
||||
/// Cheap pass: resolve the selector into an ordered list of media (no insight
|
||||
/// lookups yet) plus reel metadata. `Err` only on an invalid library param.
|
||||
pub fn resolve(
|
||||
@@ -238,23 +294,24 @@ pub fn resolve(
|
||||
library.as_deref(),
|
||||
)?;
|
||||
|
||||
// Phase 1 is photos-only: drop videos (a clip segment type lands
|
||||
// in phase 2).
|
||||
let items: Vec<memories::MemoryItem> = items
|
||||
.into_iter()
|
||||
.filter(|it| is_image_file(Path::new(&it.path)))
|
||||
.collect();
|
||||
|
||||
// Years are derived from the whole span (what the reel represents),
|
||||
// before the budget narrows it down to beats.
|
||||
// Split into photos and video clips; anything that's neither is
|
||||
// dropped. Years span both, computed before the budget narrows it.
|
||||
let years = distinct_years(&items, client_tz);
|
||||
let meta = ReelMeta { span: *span, years };
|
||||
|
||||
let (photos, videos): (Vec<_>, Vec<_>) = items
|
||||
.into_iter()
|
||||
.filter(|it| {
|
||||
is_image_file(Path::new(&it.path)) || is_video_file(Path::new(&it.path))
|
||||
})
|
||||
.partition(|it| is_image_file(Path::new(&it.path)));
|
||||
|
||||
// The budget caps the number of narrated beats (≈ reel length);
|
||||
// each beat then bursts through several photos, so the reel covers
|
||||
// the span's moments without running minutes long.
|
||||
// photo beats then burst through several photos and video beats
|
||||
// play a short clip, so the reel covers the span without running
|
||||
// minutes long.
|
||||
let n_beats = budget_segments(*max_segments);
|
||||
let beats = form_beats(&items, n_beats, MAX_BURST_PHOTOS);
|
||||
let beats = form_beats(&photos, &videos, n_beats, MAX_BURST_PHOTOS);
|
||||
Ok((beats, meta))
|
||||
}
|
||||
}
|
||||
@@ -289,10 +346,13 @@ pub fn enrich(
|
||||
return;
|
||||
};
|
||||
for beat in beats.iter_mut() {
|
||||
let Some(SegmentMedia::Photo { rel_path, .. }) = beat.photos.first() else {
|
||||
continue;
|
||||
let rel_path = match beat.media.first() {
|
||||
Some(SegmentMedia::Photo { rel_path, .. } | SegmentMedia::Clip { rel_path, .. }) => {
|
||||
rel_path.clone()
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
if let Ok(Some(insight)) = dao.get_insight(span_context, rel_path) {
|
||||
if let Ok(Some(insight)) = dao.get_insight(span_context, &rel_path) {
|
||||
beat.insight_title = Some(insight.title);
|
||||
beat.insight_summary = Some(insight.summary);
|
||||
}
|
||||
@@ -372,15 +432,18 @@ mod tests {
|
||||
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
|
||||
}
|
||||
|
||||
// Build an item at a given unix timestamp (seconds).
|
||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||
// Build an item at a given unix timestamp (seconds) with a chosen extension.
|
||||
fn item_ext(ts: i64, name: &str, ext: &str) -> memories::MemoryItem {
|
||||
memories::MemoryItem {
|
||||
path: format!("{name}.jpg"),
|
||||
path: format!("{name}.{ext}"),
|
||||
created: Some(ts),
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
}
|
||||
}
|
||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||
item_ext(ts, name, "jpg")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_segments_caps_to_duration_target() {
|
||||
@@ -405,7 +468,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_one_beat_per_event_when_they_fit() {
|
||||
fn photo_beats_one_per_event_when_they_fit() {
|
||||
// Three well-separated events, budget of 10 → three beats, each holding
|
||||
// all of its (few) photos.
|
||||
let items = vec![
|
||||
@@ -414,35 +477,70 @@ mod tests {
|
||||
item_at(1_000_000, "c"),
|
||||
item_at(2_000_000, "d"),
|
||||
];
|
||||
let beats = form_beats(&items, 10, MAX_BURST_PHOTOS);
|
||||
let beats = form_photo_beats(&items, 10, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert_eq!(beats[0].photos.len(), 2); // burst of the first event
|
||||
assert_eq!(beats[1].photos.len(), 1);
|
||||
assert_eq!(beats[2].photos.len(), 1);
|
||||
assert_eq!(beats[0].media.len(), 2); // burst of the first event
|
||||
assert_eq!(beats[1].media.len(), 1);
|
||||
assert_eq!(beats[2].media.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_merges_events_when_over_budget() {
|
||||
fn photo_beats_merge_events_when_over_budget() {
|
||||
// Six distinct events but only two beats → adjacent events fold in, and
|
||||
// every event's photos still appear (capped by the burst max).
|
||||
let items: Vec<memories::MemoryItem> = (0..6)
|
||||
.map(|i| item_at(i as i64 * 1_000_000, &format!("e{i}")))
|
||||
.collect();
|
||||
let beats = form_beats(&items, 2, MAX_BURST_PHOTOS);
|
||||
let beats = form_photo_beats(&items, 2, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 2);
|
||||
let shown: usize = beats.iter().map(|b| b.photos.len()).sum();
|
||||
let shown: usize = beats.iter().map(|b| b.media.len()).sum();
|
||||
assert_eq!(shown, 6); // all six moments still shown across two beats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_caps_burst_to_max() {
|
||||
fn photo_beats_cap_burst_to_max() {
|
||||
// One dense event of 30 photos, generous budget → a single beat that
|
||||
// bursts at most MAX_BURST_PHOTOS, not all 30.
|
||||
let items: Vec<memories::MemoryItem> = (0..30)
|
||||
.map(|i| item_at(i as i64, &format!("p{i}")))
|
||||
.collect();
|
||||
let beats = form_beats(&items, 18, MAX_BURST_PHOTOS);
|
||||
let beats = form_photo_beats(&items, 18, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 1);
|
||||
assert_eq!(beats[0].photos.len(), MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats[0].media.len(), MAX_BURST_PHOTOS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_beat_budget_handles_each_mix() {
|
||||
// Only photos / only videos → that kind gets the whole budget.
|
||||
assert_eq!(split_beat_budget(10, 0, 18), (18, 0));
|
||||
assert_eq!(split_beat_budget(0, 10, 18), (0, 10)); // capped at n_videos
|
||||
assert_eq!(split_beat_budget(0, 30, 18), (0, 18)); // capped at budget
|
||||
// Mixed → clips up to half (≥1), photos the rest.
|
||||
assert_eq!(split_beat_budget(100, 100, 18), (9, 9));
|
||||
assert_eq!(split_beat_budget(100, 1, 18), (17, 1)); // few videos
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_mixes_clip_and_photo_beats_in_time_order() {
|
||||
let photos = vec![item_at(0, "p0"), item_at(2_000_000, "p1")];
|
||||
// A video between the two photo events (in time).
|
||||
let videos = vec![item_ext(1_000_000, "v0", "mp4")];
|
||||
let beats = form_beats(&photos, &videos, 10, MAX_BURST_PHOTOS);
|
||||
// Two photo events + one clip = three beats, chronological.
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert!(!beats[0].is_clip()); // p0 @ t=0
|
||||
assert!(beats[1].is_clip()); // v0 @ t=1e6
|
||||
assert!(!beats[2].is_clip()); // p1 @ t=2e6
|
||||
assert!(matches!(beats[1].media[0], SegmentMedia::Clip { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_videos_only_become_clip_beats() {
|
||||
let videos: Vec<memories::MemoryItem> = (0..3)
|
||||
.map(|i| item_ext(i as i64 * 1_000_000, &format!("v{i}"), "mov"))
|
||||
.collect();
|
||||
let beats = form_beats(&[], &videos, 10, MAX_BURST_PHOTOS);
|
||||
assert_eq!(beats.len(), 3);
|
||||
assert!(beats.iter().all(|b| b.is_clip()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user