XMP (PNG iTXt DerivedFrom, native chunk reader) then file-name convention links at ingest; unlinked name candidates are retried each tick. Versions are hidden from /photos (SQL for the recursive listing, hidden-row set for the walk and tag branches), /memories, reels, /photos/exif, the GPS summary, unified search and the media gauges while their original is reachable in the view; include_versions=true lists them. /photos gains the parallel arrays photo_display_paths / photo_version_counts, memories / search hits / /photos/exif rows gain display_path / version_count, all skipped when empty. GET /image/versions and PUT /image/versions/display (library optional). Tags and favorites on a version path bind to the original; faces, CLIP and duplicate grouping skip version hashes; insight generation redirects to the original. Orphan GC and the back-ref refresh cover the new tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
584 lines
21 KiB
Rust
584 lines
21 KiB
Rust
//! 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::<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)]
|
|
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<String>,
|
|
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<T: Clone>(items: &[T], max: usize) -> Vec<T> {
|
|
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<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 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<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 {
|
|
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<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,
|
|
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<Box<dyn ExifDao>>,
|
|
selector: &ReelSelector,
|
|
) -> Result<(Vec<PlannedBeat>, 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<i32> {
|
|
let mut years: Vec<i32> = 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<Box<dyn InsightDao>>,
|
|
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
|
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<i32> = (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<i32> = (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<memories::MemoryItem> = (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<memories::MemoryItem> = (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<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()));
|
|
}
|
|
}
|