//! Reel selectors: resolve "what goes in the reel" into an ordered media set //! plus the metadata the scripter needs. The renderer and scripter are //! selector-agnostic, so adding tag- or date-range-based reels later means //! adding a variant here, not touching the pipeline. //! //! Resolution is split in two so the handler can compute a cache key (and //! short-circuit on a cache hit) without the per-photo insight lookups: //! [`resolve`] is the cheap media-set pass; [`enrich`] adds cached insights and //! runs in the background job. use std::path::Path; use std::str::FromStr; use std::sync::Mutex; use chrono::{Datelike, TimeZone}; use super::{PlannedBeat, ReelMeta, SegmentMedia}; use crate::database::{ExifDao, InsightDao}; use crate::file_types::{is_image_file, is_video_file}; use crate::memories::{self, MemoriesSpan}; use crate::state::AppState; /// 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::().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)] pub enum ReelSelector { Memories { span: MemoriesSpan, /// Owner's display zone as an IANA name (ADR-0001) — grouping is /// zone-anchored, not device-offset-anchored, so DST shifts are /// absorbed instead of silently re-bucketing the reel. display_tz: String, library: Option, max_segments: usize, }, } impl ReelSelector { /// Stable string identity for the cache key. Captures everything that /// changes *which* media is selected (but not the non-deterministic /// narration, which can't be part of a pre-render key). pub fn descriptor(&self) -> String { match self { ReelSelector::Memories { span, display_tz, library, max_segments, } => format!( "memories:span={:?}:tz={}:lib={}:max={}", span, display_tz, library.as_deref().unwrap_or("all"), max_segments ), } } } /// Pick at most `max` items spread evenly across the input, always keeping the /// first and last. Returns the input unchanged when it already fits. pub fn sample_evenly(items: &[T], max: usize) -> Vec { if max == 0 { return Vec::new(); } if items.len() <= max { return items.to_vec(); } if max == 1 { return vec![items[0].clone()]; } let last = items.len() - 1; (0..max) .map(|i| { // Spread indices 0..=last across max picks, endpoints included. let idx = (i * last + (max - 1) / 2) / (max - 1); items[idx.min(last)].clone() }) .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> { let mut clusters: Vec> = Vec::new(); let mut prev_ts: Option = 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>, n: usize, ) -> Vec> { 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 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) /// segment per photo. fn form_photo_beats( items: &[memories::MemoryItem], n_beats: usize, max_burst: usize, ) -> Vec { 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 { media: shown .into_iter() .map(|it| SegmentMedia::Photo { rel_path: it.path, library_id: it.library_id, }) .collect(), date, insight_title: None, insight_summary: None, gps: None, } }) .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 { 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, gps: 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( app_state: &AppState, exif_dao: &Mutex>, selector: &ReelSelector, ) -> Result<(Vec, ReelMeta), String> { match selector { ReelSelector::Memories { span, display_tz, library, max_segments, } => { let zone = chrono_tz::Tz::from_str(display_tz).unwrap_or(chrono_tz::Tz::America__New_York); let items = memories::gather_memory_items( app_state, exif_dao, *span, zone, library.as_deref(), // Reels are always "now" (MVP): no reference date. None, )?; // 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, zone); 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); // 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(&photos, &videos, n_beats, MAX_BURST_PHOTOS); Ok((beats, meta)) } } } /// Distinct calendar years represented by the selected media, in the client's /// timezone, ascending. Used to tell the scripter how far back the reel reaches. fn distinct_years(items: &[memories::MemoryItem], zone: chrono_tz::Tz) -> Vec { let mut years: Vec = items .iter() .filter_map(|it| it.created) .filter_map(|ts| zone.timestamp_opt(ts, 0).single()) .map(|dt| dt.year()) .collect(); years.sort_unstable(); years.dedup(); years } /// Background pass: fill each beat's cached insight (title + summary) and /// GPS coordinates 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>, exif_dao: &Mutex>, beats: &mut [PlannedBeat], ) { let Ok(mut insight_dao) = insight_dao.lock() else { return; }; let Ok(mut exif_dao) = exif_dao.lock() else { return; }; for beat in beats.iter_mut() { 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)) = insight_dao.get_insight(&rel_path) { beat.insight_title = Some(insight.title); beat.insight_summary = Some(insight.summary); } // Enrich GPS from EXIF when the lead media is a photo. if let Some(SegmentMedia::Photo { .. }) = beat.media.first() && let Ok(Some(exif)) = exif_dao.get_exif(&rel_path) && let (Some(lat), Some(lon)) = (exif.gps_latitude, exif.gps_longitude) { beat.gps = Some((lat as f64, lon as f64)); } } } #[cfg(test)] mod tests { use super::*; #[test] fn sample_evenly_returns_all_when_under_cap() { let v = vec![1, 2, 3]; assert_eq!(sample_evenly(&v, 5), vec![1, 2, 3]); assert_eq!(sample_evenly(&v, 3), vec![1, 2, 3]); } #[test] fn sample_evenly_keeps_endpoints_and_spreads() { let v: Vec = (0..100).collect(); let picked = sample_evenly(&v, 5); assert_eq!(picked.len(), 5); assert_eq!(picked[0], 0); // first kept assert_eq!(*picked.last().unwrap(), 99); // last kept // Strictly increasing, no dupes. assert!(picked.windows(2).all(|w| w[0] < w[1])); } #[test] fn sample_evenly_handles_one_and_zero() { let v: Vec = (0..10).collect(); assert_eq!(sample_evenly(&v, 1), vec![0]); assert!(sample_evenly(&v, 0).is_empty()); } #[test] fn descriptor_is_stable_and_distinguishes_inputs() { let a = ReelSelector::Memories { span: MemoriesSpan::Day, display_tz: "America/New_York".to_string(), library: None, max_segments: 24, }; let b = ReelSelector::Memories { span: MemoriesSpan::Week, display_tz: "America/New_York".to_string(), library: None, max_segments: 24, }; assert_eq!(a.descriptor(), a.clone().descriptor()); assert_ne!(a.descriptor(), b.descriptor()); assert!(a.descriptor().contains("lib=all")); } #[test] fn distinct_years_dedupes_and_sorts() { let items = vec![ memories::MemoryItem { path: "a".into(), created: Some(1_560_000_000), // 2019 modified: None, library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, versions: Default::default(), }, memories::MemoryItem { path: "b".into(), created: Some(1_560_086_400), // 2019 modified: None, library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, versions: Default::default(), }, memories::MemoryItem { path: "c".into(), created: Some(1_623_000_000), // 2021 modified: None, library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, versions: Default::default(), }, ]; assert_eq!( distinct_years(&items, chrono_tz::Tz::America__New_York), vec![2019, 2021] ); } // 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}.{ext}"), created: Some(ts), modified: None, library_id: 1, date_utc: None, camera_offset_minutes: None, date_taken_tz_source: None, wall_clock: None, versions: Default::default(), } } fn item_at(ts: i64, name: &str) -> memories::MemoryItem { item_ext(ts, name, "jpg") } #[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 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![ 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_photo_beats(&items, 10, MAX_BURST_PHOTOS); assert_eq!(beats.len(), 3); 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 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 = (0..6) .map(|i| item_at(i as i64 * 1_000_000, &format!("e{i}"))) .collect(); let beats = form_photo_beats(&items, 2, MAX_BURST_PHOTOS); assert_eq!(beats.len(), 2); 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 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 = (0..30) .map(|i| item_at(i as i64, &format!("p{i}"))) .collect(); let beats = form_photo_beats(&items, 18, MAX_BURST_PHOTOS); assert_eq!(beats.len(), 1); 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 = (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())); } }