Reels: burst beats + duration budget for week/month, plus step logging
Restructures a reel around beats — one narration line over one or more photos — instead of one line per photo. A single-photo beat is a held shot; a multi-photo beat is a quick burst that flashes through several moments of an event while the line is read. So a week/month reel can show everything it spans without a narrated (and timed) segment per photo. Selection (selector.rs): - Duration budget: cap the number of narrated beats to ~REEL_TARGET_SECONDS (default 90, env-tunable) so week/month reels don't run minutes long. - Event clustering by time gap; when there are more events than the beat budget, adjacent events merge so the whole span stays covered. Each beat bursts up to MAX_BURST_PHOTOS (an even spread), so a 40-shot dinner contributes a handful of quick frames, not forty narrated seconds. Render (render.rs): a beat renders its photos as a concat of per-photo fills (blurred-bg portrait, fps-before-fade) under one muxed narration; burst photos get a snappier fade. beat_durations splits the narration across the photos, stretching only if a long burst would flash too fast. Adds high-level info logs across the steps (request → script → per-beat narrate/render → join → done with elapsed) for visibility. Bumps RENDER_VERSION to re-render cached reels. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
740fc4d841
commit
6e90f24307
4 files changed
+580
-204
No files matched your search
+229
-33
@@ -13,18 +13,51 @@ use std::sync::Mutex;
|
||||
|
||||
use chrono::{DateTime, Datelike, FixedOffset};
|
||||
|
||||
use super::{PlannedSegment, ReelMeta, SegmentMedia};
|
||||
use super::{PlannedBeat, ReelMeta, SegmentMedia};
|
||||
use crate::database::{ExifDao, InsightDao};
|
||||
use crate::file_types::is_image_file;
|
||||
use crate::memories::{self, MemoriesSpan};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Default and hard caps on how many photos a reel covers. The cap bounds the
|
||||
/// LLM/TTS/ffmpeg work per reel; when a span has more, [`sample_evenly`] keeps
|
||||
/// a representative spread across the years rather than just the oldest.
|
||||
pub const DEFAULT_MAX_SEGMENTS: usize = 24;
|
||||
/// Default and hard caps on how many photos a reel covers. The default is an
|
||||
/// upper bound on the request; the effective count is usually smaller, set by
|
||||
/// the duration budget (see [`budget_segments`]). The hard cap bounds work per
|
||||
/// reel regardless.
|
||||
pub const DEFAULT_MAX_SEGMENTS: usize = 40;
|
||||
pub const HARD_MAX_SEGMENTS: usize = 40;
|
||||
|
||||
/// Target reel length. Week and especially month spans can surface hundreds of
|
||||
/// photos; at a few seconds of narration each, a naive reel runs minutes. We
|
||||
/// cap the segment count to keep the reel near this length. Tunable via
|
||||
/// `REEL_TARGET_SECONDS`.
|
||||
const DEFAULT_TARGET_REEL_SECONDS: f64 = 90.0;
|
||||
|
||||
/// Rough average wall-time per photo segment (a short narration line + the
|
||||
/// silent tail). Only used to turn the duration target into a segment count;
|
||||
/// the real per-segment time is the measured narration length.
|
||||
const EST_SECONDS_PER_SEGMENT: f64 = 5.0;
|
||||
|
||||
/// Time gap that separates one "event/moment" from the next when clustering a
|
||||
/// span's photos. Photos within a few hours are treated as the same occasion
|
||||
/// (and across years/days the gaps are far larger, so each instance clusters
|
||||
/// on its own). 4 hours splits e.g. a morning hike from an evening dinner.
|
||||
const EVENT_GAP_SECONDS: i64 = 4 * 3600;
|
||||
|
||||
fn target_reel_seconds() -> f64 {
|
||||
std::env::var("REEL_TARGET_SECONDS")
|
||||
.ok()
|
||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||
.filter(|x| x.is_finite() && *x > 0.0)
|
||||
.unwrap_or(DEFAULT_TARGET_REEL_SECONDS)
|
||||
}
|
||||
|
||||
/// How many photo segments fit the duration budget, bounded by the request's
|
||||
/// max and the hard cap. This is what keeps week/month reels from running long.
|
||||
pub fn budget_segments(requested_max: usize) -> usize {
|
||||
let by_budget = (target_reel_seconds() / EST_SECONDS_PER_SEGMENT).floor() as usize;
|
||||
by_budget.min(requested_max).clamp(1, HARD_MAX_SEGMENTS)
|
||||
}
|
||||
|
||||
/// What a reel is built from. v1 ships the memories (on this day/week/month)
|
||||
/// selector; tag and date-range variants slot in here later.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -81,6 +114,104 @@ pub fn sample_evenly<T: Clone>(items: &[T], max: usize) -> Vec<T> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Group time-sorted items into events by gap: a new event starts whenever the
|
||||
/// jump from the previous photo exceeds `gap_seconds`. Preserves order; items
|
||||
/// without a timestamp extend the current event.
|
||||
fn cluster_by_gap(
|
||||
items: &[memories::MemoryItem],
|
||||
gap_seconds: i64,
|
||||
) -> Vec<Vec<memories::MemoryItem>> {
|
||||
let mut clusters: Vec<Vec<memories::MemoryItem>> = Vec::new();
|
||||
let mut prev_ts: Option<i64> = None;
|
||||
for it in items {
|
||||
let starts_new = match (prev_ts, it.created) {
|
||||
(Some(p), Some(c)) => c - p > gap_seconds,
|
||||
_ => false,
|
||||
};
|
||||
if starts_new || clusters.is_empty() {
|
||||
clusters.push(Vec::new());
|
||||
}
|
||||
clusters.last_mut().unwrap().push(it.clone());
|
||||
if let Some(c) = it.created {
|
||||
prev_ts = Some(c);
|
||||
}
|
||||
}
|
||||
clusters
|
||||
}
|
||||
|
||||
/// Most photos a single beat will flash through. Bounds the burst so one huge
|
||||
/// event doesn't dominate, and keeps each photo on screen long enough to
|
||||
/// register at the per-beat narration length (see render's beat timing).
|
||||
pub const MAX_BURST_PHOTOS: usize = 10;
|
||||
|
||||
/// Merge a list of (time-ordered) event clusters into exactly `n` contiguous
|
||||
/// groups, so a span with more events than the beat budget still covers the
|
||||
/// whole timeline — adjacent events fold together into one beat rather than
|
||||
/// getting dropped. `n` must be ≥ 1 and ≤ clusters.len().
|
||||
fn partition_into_groups(
|
||||
clusters: Vec<Vec<memories::MemoryItem>>,
|
||||
n: usize,
|
||||
) -> Vec<Vec<memories::MemoryItem>> {
|
||||
let c = clusters.len();
|
||||
let mut clusters = clusters.into_iter();
|
||||
(0..n)
|
||||
.map(|j| {
|
||||
// Even contiguous split of c clusters into n groups.
|
||||
let start = j * c / n;
|
||||
let end = (j + 1) * c / n;
|
||||
let take = end.saturating_sub(start).max(1);
|
||||
(0..take)
|
||||
.flat_map(|_| clusters.next().into_iter().flatten())
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Turn a span's photos into `n_beats` 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)
|
||||
/// segment per photo.
|
||||
pub fn form_beats(
|
||||
items: &[memories::MemoryItem],
|
||||
n_beats: usize,
|
||||
max_burst: usize,
|
||||
) -> Vec<PlannedBeat> {
|
||||
if n_beats == 0 || items.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let clusters = cluster_by_gap(items, EVENT_GAP_SECONDS);
|
||||
// One beat per event when they fit; otherwise fold adjacent events together
|
||||
// into exactly n_beats groups.
|
||||
let groups = if clusters.len() <= n_beats {
|
||||
clusters
|
||||
} else {
|
||||
partition_into_groups(clusters, n_beats)
|
||||
};
|
||||
|
||||
groups
|
||||
.into_iter()
|
||||
.filter(|g| !g.is_empty())
|
||||
.map(|group| {
|
||||
let shown = sample_evenly(&group, max_burst);
|
||||
let date = shown.first().and_then(|it| it.created);
|
||||
PlannedBeat {
|
||||
photos: shown
|
||||
.into_iter()
|
||||
.map(|it| SegmentMedia::Photo {
|
||||
rel_path: it.path,
|
||||
library_id: it.library_id,
|
||||
})
|
||||
.collect(),
|
||||
date,
|
||||
insight_title: None,
|
||||
insight_summary: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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(
|
||||
@@ -88,7 +219,7 @@ pub fn resolve(
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
selector: &ReelSelector,
|
||||
) -> Result<(Vec<PlannedSegment>, ReelMeta), String> {
|
||||
) -> Result<(Vec<PlannedBeat>, ReelMeta), String> {
|
||||
match selector {
|
||||
ReelSelector::Memories {
|
||||
span,
|
||||
@@ -108,32 +239,23 @@ pub fn resolve(
|
||||
)?;
|
||||
|
||||
// Phase 1 is photos-only: drop videos (a clip segment type lands
|
||||
// in phase 2). Filter before sampling so the spread is over the
|
||||
// photos that will actually appear.
|
||||
// in phase 2).
|
||||
let items: Vec<memories::MemoryItem> = items
|
||||
.into_iter()
|
||||
.filter(|it| is_image_file(Path::new(&it.path)))
|
||||
.collect();
|
||||
|
||||
let cap = (*max_segments).clamp(1, HARD_MAX_SEGMENTS);
|
||||
let items = sample_evenly(&items, cap);
|
||||
|
||||
// Years are derived from the whole span (what the reel represents),
|
||||
// before the budget narrows it down to beats.
|
||||
let years = distinct_years(&items, client_tz);
|
||||
let meta = ReelMeta { span: *span, years };
|
||||
|
||||
let planned = items
|
||||
.into_iter()
|
||||
.map(|it| PlannedSegment {
|
||||
media: SegmentMedia::Photo {
|
||||
rel_path: it.path,
|
||||
library_id: it.library_id,
|
||||
},
|
||||
date: it.created,
|
||||
insight_title: None,
|
||||
insight_summary: None,
|
||||
})
|
||||
.collect();
|
||||
Ok((planned, meta))
|
||||
// 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.
|
||||
let n_beats = budget_segments(*max_segments);
|
||||
let beats = form_beats(&items, n_beats, MAX_BURST_PHOTOS);
|
||||
Ok((beats, meta))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,24 +277,24 @@ fn distinct_years(items: &[memories::MemoryItem], tz: Option<FixedOffset>) -> Ve
|
||||
years
|
||||
}
|
||||
|
||||
/// Background pass: fill each segment's cached insight (title + summary) where
|
||||
/// one exists. Best-effort — a missing or errored lookup leaves the fields
|
||||
/// `None` and the scripter narrates from the date alone.
|
||||
/// Background pass: fill each beat's cached insight (title + summary) from its
|
||||
/// lead photo, where one exists. Best-effort — a missing or errored lookup
|
||||
/// leaves the fields `None` and the scripter narrates from the date alone.
|
||||
pub fn enrich(
|
||||
insight_dao: &Mutex<Box<dyn InsightDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
planned: &mut [PlannedSegment],
|
||||
beats: &mut [PlannedBeat],
|
||||
) {
|
||||
let Ok(mut dao) = insight_dao.lock() else {
|
||||
return;
|
||||
};
|
||||
for seg in planned.iter_mut() {
|
||||
let rel_path = match &seg.media {
|
||||
SegmentMedia::Photo { rel_path, .. } => rel_path,
|
||||
for beat in beats.iter_mut() {
|
||||
let Some(SegmentMedia::Photo { rel_path, .. }) = beat.photos.first() else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(Some(insight)) = dao.get_insight(span_context, rel_path) {
|
||||
seg.insight_title = Some(insight.title);
|
||||
seg.insight_summary = Some(insight.summary);
|
||||
beat.insight_title = Some(insight.title);
|
||||
beat.insight_summary = Some(insight.summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,4 +371,78 @@ 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 {
|
||||
memories::MemoryItem {
|
||||
path: format!("{name}.jpg"),
|
||||
created: Some(ts),
|
||||
modified: None,
|
||||
library_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_segments_caps_to_duration_target() {
|
||||
// 90s / 5s ≈ 18, bounded by the request max and hard cap.
|
||||
assert_eq!(budget_segments(40), 18);
|
||||
assert_eq!(budget_segments(5), 5); // request asked for fewer
|
||||
assert_eq!(budget_segments(1000), 18); // hard cap / budget wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_by_gap_splits_on_large_jumps() {
|
||||
// Two photos minutes apart, then one a day later → two events.
|
||||
let items = vec![
|
||||
item_at(1_000_000, "a"),
|
||||
item_at(1_000_300, "b"), // +5 min → same event
|
||||
item_at(1_100_000, "c"), // +~27h → new event
|
||||
];
|
||||
let clusters = cluster_by_gap(&items, EVENT_GAP_SECONDS);
|
||||
assert_eq!(clusters.len(), 2);
|
||||
assert_eq!(clusters[0].len(), 2);
|
||||
assert_eq!(clusters[1].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_one_beat_per_event_when_they_fit() {
|
||||
// Three well-separated events, budget of 10 → three beats, each holding
|
||||
// all of its (few) photos.
|
||||
let items = vec![
|
||||
item_at(0, "a"),
|
||||
item_at(50, "b"), // same event as a
|
||||
item_at(1_000_000, "c"),
|
||||
item_at(2_000_000, "d"),
|
||||
];
|
||||
let beats = form_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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_merges_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);
|
||||
assert_eq!(beats.len(), 2);
|
||||
let shown: usize = beats.iter().map(|b| b.photos.len()).sum();
|
||||
assert_eq!(shown, 6); // all six moments still shown across two beats
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_beats_caps_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);
|
||||
assert_eq!(beats.len(), 1);
|
||||
assert_eq!(beats[0].photos.len(), MAX_BURST_PHOTOS);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user