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:
+114
-47
@@ -36,21 +36,40 @@ use crate::otel::extract_context_from_request;
|
|||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
use selector::ReelSelector;
|
use selector::ReelSelector;
|
||||||
|
|
||||||
/// The media behind one shot. Photos-only for now; a `Clip` variant (a section
|
/// The media behind one shot: a still photo, or a short section of a source
|
||||||
/// of a source video) is the phase-2 extension point.
|
/// video (played with its live audio ducked under the narration). Both carry
|
||||||
|
/// just the library-relative path; the renderer applies fixed clip framing
|
||||||
|
/// (start/length) from constants.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum SegmentMedia {
|
pub enum SegmentMedia {
|
||||||
Photo { rel_path: String, library_id: i32 },
|
Photo { rel_path: String, library_id: i32 },
|
||||||
|
Clip { rel_path: String, library_id: i32 },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A beat: one narration line over one or more photos. A single-photo beat is a
|
impl SegmentMedia {
|
||||||
/// held shot; a multi-photo beat is a quick burst that flashes through several
|
fn rel_path(&self) -> &str {
|
||||||
/// moments of the same event while the line is read — so a week/month reel can
|
match self {
|
||||||
/// *show* everything it spans without a narration line (and the seconds that
|
SegmentMedia::Photo { rel_path, .. } | SegmentMedia::Clip { rel_path, .. } => rel_path,
|
||||||
/// come with it) per photo.
|
}
|
||||||
|
}
|
||||||
|
fn library_id(&self) -> i32 {
|
||||||
|
match self {
|
||||||
|
SegmentMedia::Photo { library_id, .. } | SegmentMedia::Clip { library_id, .. } => {
|
||||||
|
*library_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A beat: one narration line over its media. A photo beat holds one still (a
|
||||||
|
/// held shot) or several (a quick burst that flashes through moments of an
|
||||||
|
/// event while the line is read). A clip beat holds a single video clip. Either
|
||||||
|
/// way one narration line covers the whole beat, so a week/month reel can
|
||||||
|
/// *show* everything it spans without a narration line — and the seconds that
|
||||||
|
/// come with it — per item.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PlannedBeat {
|
pub struct PlannedBeat {
|
||||||
pub photos: Vec<SegmentMedia>,
|
pub media: Vec<SegmentMedia>,
|
||||||
pub date: Option<i64>,
|
pub date: Option<i64>,
|
||||||
pub insight_title: Option<String>,
|
pub insight_title: Option<String>,
|
||||||
pub insight_summary: Option<String>,
|
pub insight_summary: Option<String>,
|
||||||
@@ -63,6 +82,11 @@ impl PlannedBeat {
|
|||||||
let dt = DateTime::from_timestamp(ts, 0)?;
|
let dt = DateTime::from_timestamp(ts, 0)?;
|
||||||
Some(dt.format("%B %-d, %Y").to_string())
|
Some(dt.format("%B %-d, %Y").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when this beat is a single video clip (vs one or more photos).
|
||||||
|
pub fn is_clip(&self) -> bool {
|
||||||
|
matches!(self.media.as_slice(), [SegmentMedia::Clip { .. }])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reel-wide metadata the scripter uses for framing.
|
/// Reel-wide metadata the scripter uses for framing.
|
||||||
@@ -183,7 +207,7 @@ fn finish_job(
|
|||||||
|
|
||||||
/// Render version: bump to invalidate every cached reel after a rendering /
|
/// Render version: bump to invalidate every cached reel after a rendering /
|
||||||
/// scripting change that should produce a fresh result.
|
/// scripting change that should produce a fresh result.
|
||||||
const RENDER_VERSION: u32 = 4;
|
const RENDER_VERSION: u32 = 5;
|
||||||
|
|
||||||
/// Narration expressiveness — Chatterbox's `exaggeration` knob. A slight bump
|
/// Narration expressiveness — Chatterbox's `exaggeration` knob. A slight bump
|
||||||
/// over the ~0.5 default warms up otherwise-flat narration without over-acting;
|
/// over the ~0.5 default warms up otherwise-flat narration without over-acting;
|
||||||
@@ -207,12 +231,13 @@ fn cache_key(selector: &ReelSelector, media: &[SegmentMedia], voice: Option<&str
|
|||||||
voice.unwrap_or("default")
|
voice.unwrap_or("default")
|
||||||
);
|
);
|
||||||
for m in media {
|
for m in media {
|
||||||
match m {
|
// Tag photo vs clip so the same path used as a still and as a video
|
||||||
SegmentMedia::Photo {
|
// clip produce different keys.
|
||||||
rel_path,
|
let tag = match m {
|
||||||
library_id,
|
SegmentMedia::Photo { .. } => 'P',
|
||||||
} => buf.push_str(&format!("{library_id}:{rel_path}|")),
|
SegmentMedia::Clip { .. } => 'C',
|
||||||
}
|
};
|
||||||
|
buf.push_str(&format!("{tag}{}:{}|", m.library_id(), m.rel_path()));
|
||||||
}
|
}
|
||||||
blake3::hash(buf.as_bytes()).to_hex().to_string()
|
blake3::hash(buf.as_bytes()).to_hex().to_string()
|
||||||
}
|
}
|
||||||
@@ -309,9 +334,9 @@ pub async fn create_reel_handler(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten every photo across beats (in order) into the cache key — the key
|
// Flatten every media item across beats (in order) into the cache key — the
|
||||||
// tracks exactly which photos appear and in what sequence.
|
// key tracks exactly which photos/clips appear and in what sequence.
|
||||||
let media: Vec<SegmentMedia> = planned.iter().flat_map(|b| b.photos.clone()).collect();
|
let media: Vec<SegmentMedia> = planned.iter().flat_map(|b| b.media.clone()).collect();
|
||||||
let voice = req.voice.clone().filter(|s| !s.is_empty());
|
let voice = req.voice.clone().filter(|s| !s.is_empty());
|
||||||
let key = cache_key(&selector, &media, voice.as_deref());
|
let key = cache_key(&selector, &media, voice.as_deref());
|
||||||
|
|
||||||
@@ -462,7 +487,7 @@ async fn run_reel_job(
|
|||||||
use anyhow::{Context, anyhow};
|
use anyhow::{Context, anyhow};
|
||||||
|
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let total_photos: usize = planned.iter().map(|b| b.photos.len()).sum();
|
let total_photos: usize = planned.iter().map(|b| b.media.len()).sum();
|
||||||
log::info!(
|
log::info!(
|
||||||
"reel {job_id}: starting — span {:?}, {} beats, {} photos, voice={}",
|
"reel {job_id}: starting — span {:?}, {} beats, {} photos, voice={}",
|
||||||
meta.span,
|
meta.span,
|
||||||
@@ -510,15 +535,15 @@ async fn run_reel_job(
|
|||||||
let beat_total = planned.len();
|
let beat_total = planned.len();
|
||||||
let mut beat_files: Vec<String> = Vec::new();
|
let mut beat_files: Vec<String> = Vec::new();
|
||||||
for (i, (beat, line)) in planned.iter().zip(script.lines.iter()).enumerate() {
|
for (i, (beat, line)) in planned.iter().zip(script.lines.iter()).enumerate() {
|
||||||
// Resolve all of the beat's photos to absolute paths; drop any that
|
// Resolve the beat's media to absolute paths; drop any that don't
|
||||||
// don't resolve. An empty beat is skipped.
|
// resolve. An empty beat is skipped.
|
||||||
let image_paths: Vec<PathBuf> = beat
|
let paths: Vec<PathBuf> = beat
|
||||||
.photos
|
.media
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|m| resolve_image_path(app_state, m))
|
.filter_map(|m| resolve_media_path(app_state, m))
|
||||||
.collect();
|
.collect();
|
||||||
if image_paths.is_empty() {
|
if paths.is_empty() {
|
||||||
log::warn!("reel {job_id}: skipping beat {i}, no image paths resolved");
|
log::warn!("reel {job_id}: skipping beat {i}, no media paths resolved");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,17 +576,26 @@ async fn run_reel_job(
|
|||||||
.unwrap_or(render::MIN_SEGMENT_SECONDS);
|
.unwrap_or(render::MIN_SEGMENT_SECONDS);
|
||||||
|
|
||||||
set_stage(job_id, "rendering");
|
set_stage(job_id, "rendering");
|
||||||
log::info!(
|
|
||||||
"reel {job_id}: beat {}/{} — {} photo(s), narration {:.1}s",
|
|
||||||
i + 1,
|
|
||||||
beat_total,
|
|
||||||
image_paths.len(),
|
|
||||||
narration_secs
|
|
||||||
);
|
|
||||||
let beat_out = work.path().join(format!("beat_{i:03}.mp4"));
|
let beat_out = work.path().join(format!("beat_{i:03}.mp4"));
|
||||||
if let Err(e) =
|
let render_result = if beat.is_clip() {
|
||||||
render::render_beat(&image_paths, &audio_path, &beat_out, narration_secs, &opts).await
|
log::info!(
|
||||||
{
|
"reel {job_id}: beat {}/{} — video clip, narration {:.1}s",
|
||||||
|
i + 1,
|
||||||
|
beat_total,
|
||||||
|
narration_secs
|
||||||
|
);
|
||||||
|
render::render_clip_beat(&paths[0], &audio_path, &beat_out, narration_secs, &opts).await
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"reel {job_id}: beat {}/{} — {} photo(s), narration {:.1}s",
|
||||||
|
i + 1,
|
||||||
|
beat_total,
|
||||||
|
paths.len(),
|
||||||
|
narration_secs
|
||||||
|
);
|
||||||
|
render::render_beat(&paths, &audio_path, &beat_out, narration_secs, &opts).await
|
||||||
|
};
|
||||||
|
if let Err(e) = render_result {
|
||||||
log::warn!("reel {job_id}: skipping beat {i}, render failed: {e}");
|
log::warn!("reel {job_id}: skipping beat {i}, render failed: {e}");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -603,15 +637,12 @@ async fn run_reel_job(
|
|||||||
Ok((script.title, final_path))
|
Ok((script.title, final_path))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a photo segment's library-relative path to a validated absolute
|
/// Resolve a media item's library-relative path to a validated absolute path
|
||||||
/// path under its library root.
|
/// under its library root (works for both photos and clips).
|
||||||
fn resolve_image_path(app_state: &AppState, media: &SegmentMedia) -> Option<PathBuf> {
|
fn resolve_media_path(app_state: &AppState, media: &SegmentMedia) -> Option<PathBuf> {
|
||||||
let SegmentMedia::Photo {
|
let lib = app_state.library_by_id(media.library_id())?;
|
||||||
rel_path,
|
let rel = media.rel_path().to_string();
|
||||||
library_id,
|
crate::files::is_valid_full_path(&lib.root_path, &rel, false)
|
||||||
} = media;
|
|
||||||
let lib = app_state.library_by_id(*library_id)?;
|
|
||||||
crate::files::is_valid_full_path(&lib.root_path, rel_path, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -625,6 +656,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clip(p: &str, lib: i32) -> SegmentMedia {
|
||||||
|
SegmentMedia::Clip {
|
||||||
|
rel_path: p.to_string(),
|
||||||
|
library_id: lib,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn day_selector() -> ReelSelector {
|
fn day_selector() -> ReelSelector {
|
||||||
ReelSelector::Memories {
|
ReelSelector::Memories {
|
||||||
span: MemoriesSpan::Day,
|
span: MemoriesSpan::Day,
|
||||||
@@ -668,6 +706,35 @@ mod tests {
|
|||||||
assert_ne!(base, cache_key(&week, &media, Some("grandma")));
|
assert_ne!(base, cache_key(&week, &media, Some("grandma")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cache_key_distinguishes_photo_from_clip() {
|
||||||
|
// Same path/library used as a still vs a video clip must differ.
|
||||||
|
let as_photo = vec![photo("v.mp4", 1)];
|
||||||
|
let as_clip = vec![clip("v.mp4", 1)];
|
||||||
|
assert_ne!(
|
||||||
|
cache_key(&day_selector(), &as_photo, None),
|
||||||
|
cache_key(&day_selector(), &as_clip, None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_clip_only_for_single_clip_beat() {
|
||||||
|
let clip_beat = PlannedBeat {
|
||||||
|
media: vec![clip("v.mp4", 1)],
|
||||||
|
date: None,
|
||||||
|
insight_title: None,
|
||||||
|
insight_summary: None,
|
||||||
|
};
|
||||||
|
let photo_beat = PlannedBeat {
|
||||||
|
media: vec![photo("a.jpg", 1), photo("b.jpg", 1)],
|
||||||
|
date: None,
|
||||||
|
insight_title: None,
|
||||||
|
insight_summary: None,
|
||||||
|
};
|
||||||
|
assert!(clip_beat.is_clip());
|
||||||
|
assert!(!photo_beat.is_clip());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn span_phrase_maps_each_span() {
|
fn span_phrase_maps_each_span() {
|
||||||
let mk = |span| ReelMeta {
|
let mk = |span| ReelMeta {
|
||||||
@@ -682,7 +749,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn date_label_formats_or_none() {
|
fn date_label_formats_or_none() {
|
||||||
let beat = PlannedBeat {
|
let beat = PlannedBeat {
|
||||||
photos: vec![photo("a.jpg", 1)],
|
media: vec![photo("a.jpg", 1)],
|
||||||
date: Some(1_560_384_000), // 2019-06-13 UTC
|
date: Some(1_560_384_000), // 2019-06-13 UTC
|
||||||
insight_title: None,
|
insight_title: None,
|
||||||
insight_summary: None,
|
insight_summary: None,
|
||||||
@@ -690,7 +757,7 @@ mod tests {
|
|||||||
assert!(beat.date_label().unwrap().contains("2019"));
|
assert!(beat.date_label().unwrap().contains("2019"));
|
||||||
|
|
||||||
let undated = PlannedBeat {
|
let undated = PlannedBeat {
|
||||||
photos: vec![photo("a.jpg", 1)],
|
media: vec![photo("a.jpg", 1)],
|
||||||
date: None,
|
date: None,
|
||||||
insight_title: None,
|
insight_title: None,
|
||||||
insight_summary: None,
|
insight_summary: None,
|
||||||
|
|||||||
+214
-4
@@ -36,9 +36,15 @@ pub const MIN_SEGMENT_SECONDS: f64 = 2.5;
|
|||||||
const NARRATION_TAIL_SECONDS: f64 = 0.6;
|
const NARRATION_TAIL_SECONDS: f64 = 0.6;
|
||||||
|
|
||||||
/// Fade durations baked into each photo. A held (single-photo) beat gets a
|
/// Fade durations baked into each photo. A held (single-photo) beat gets a
|
||||||
/// gentle dip; burst photos get a snappier fade so the montage feels quick.
|
/// gentle dip; burst photos get a much snappier fade so the difference between
|
||||||
|
/// a held shot and a quick burst is obvious.
|
||||||
const SINGLE_FADE_SECONDS: f64 = 0.35;
|
const SINGLE_FADE_SECONDS: f64 = 0.35;
|
||||||
const BURST_FADE_SECONDS: f64 = 0.15;
|
const BURST_FADE_SECONDS: f64 = 0.08;
|
||||||
|
|
||||||
|
/// Video-clip framing. A clip plays at most this long, with its live audio
|
||||||
|
/// ducked to `CLIP_DUCK_VOLUME` under the narration.
|
||||||
|
pub const CLIP_SECONDS: f64 = 5.0;
|
||||||
|
const CLIP_DUCK_VOLUME: f64 = 0.35;
|
||||||
|
|
||||||
/// Floor on how long each burst photo stays up, so a long line over many photos
|
/// Floor on how long each burst photo stays up, so a long line over many photos
|
||||||
/// doesn't flash them subliminally. If the narration is too short to give every
|
/// doesn't flash them subliminally. If the narration is too short to give every
|
||||||
@@ -308,6 +314,162 @@ pub async fn render_beat(
|
|||||||
run_ffmpeg(&args, "beat render").await
|
run_ffmpeg(&args, "beat render").await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Video-clip beats --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Video chain for a clip beat: fill the clip to the portrait canvas (blurred
|
||||||
|
/// backdrop, same look as photos), normalize fps, hold the last frame if the
|
||||||
|
/// narration outlasts the clip (`tpad`), then fade. Produces `[v]`.
|
||||||
|
fn clip_video_filter(opts: &SegmentOpts, clip_dur: f64, beat_total: f64) -> String {
|
||||||
|
let (w, h, fps) = (opts.width, opts.height, opts.fps);
|
||||||
|
let fade = SINGLE_FADE_SECONDS;
|
||||||
|
let hold = (beat_total - clip_dur).max(0.0);
|
||||||
|
let fade_out_start = (beat_total - fade).max(0.0);
|
||||||
|
// Freeze the final frame to cover narration that runs past the clip.
|
||||||
|
let tpad = if hold > 0.05 {
|
||||||
|
format!(",tpad=stop_mode=clone:stop_duration={hold:.3}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"[0:v]split=2[bg][fg];\
|
||||||
|
[bg]scale={w}:{h}:force_original_aspect_ratio=increase,\
|
||||||
|
crop={w}:{h},boxblur=20:2[bgb];\
|
||||||
|
[fg]scale={w}:{h}:force_original_aspect_ratio=decrease[fgs];\
|
||||||
|
[bgb][fgs]overlay=(W-w)/2:(H-h)/2,fps={fps}{tpad},\
|
||||||
|
fade=t=in:st=0:d={fade},fade=t=out:st={fade_out_start:.3}:d={fade},\
|
||||||
|
setsar=1,format=yuv420p[v]"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Audio chain for a clip beat. With a clip audio track, duck it under the
|
||||||
|
/// narration and mix; without one, just the narration. Produces `[a]`.
|
||||||
|
fn clip_audio_filter(has_audio: bool) -> String {
|
||||||
|
if has_audio {
|
||||||
|
format!(
|
||||||
|
"[0:a]volume={CLIP_DUCK_VOLUME}[duck];[1:a]apad[narr];\
|
||||||
|
[duck][narr]amix=inputs=2:duration=longest:normalize=0[a]"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"[1:a]apad[a]".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full `filter_complex` for a clip beat (input 0 = clip, input 1 = narration).
|
||||||
|
pub fn clip_beat_filtergraph(
|
||||||
|
opts: &SegmentOpts,
|
||||||
|
clip_dur: f64,
|
||||||
|
beat_total: f64,
|
||||||
|
has_audio: bool,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{};{}",
|
||||||
|
clip_video_filter(opts, clip_dur, beat_total),
|
||||||
|
clip_audio_filter(has_audio)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the ffmpeg args for a clip beat: the first `clip_dur` seconds of the
|
||||||
|
/// source video, filled to the portrait canvas with its live audio ducked under
|
||||||
|
/// the narration, bounded to `beat_total`.
|
||||||
|
pub fn build_clip_beat_args(
|
||||||
|
clip_path: &str,
|
||||||
|
audio_path: &str,
|
||||||
|
out_path: &str,
|
||||||
|
clip_dur: f64,
|
||||||
|
beat_total: f64,
|
||||||
|
has_audio: bool,
|
||||||
|
opts: &SegmentOpts,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let fps = opts.fps.to_string();
|
||||||
|
let mut args: Vec<String> = vec!["-y".into()];
|
||||||
|
if opts.nvenc {
|
||||||
|
args.extend(["-hwaccel".into(), "cuda".into()]);
|
||||||
|
}
|
||||||
|
args.extend([
|
||||||
|
// Input `-t` limits the clip to its window; audio has none (apad fills).
|
||||||
|
"-t".into(),
|
||||||
|
format!("{clip_dur:.3}"),
|
||||||
|
"-i".into(),
|
||||||
|
clip_path.into(),
|
||||||
|
"-i".into(),
|
||||||
|
audio_path.into(),
|
||||||
|
"-filter_complex".into(),
|
||||||
|
clip_beat_filtergraph(opts, clip_dur, beat_total, has_audio),
|
||||||
|
"-map".into(),
|
||||||
|
"[v]".into(),
|
||||||
|
"-map".into(),
|
||||||
|
"[a]".into(),
|
||||||
|
"-t".into(),
|
||||||
|
format!("{beat_total:.3}"),
|
||||||
|
"-r".into(),
|
||||||
|
fps,
|
||||||
|
]);
|
||||||
|
args.extend(video_encoder_args(opts.nvenc));
|
||||||
|
args.extend(
|
||||||
|
["-c:a", "aac", "-b:a", "160k", "-ar", "48000"]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
);
|
||||||
|
args.push(out_path.into());
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a media file has at least one audio stream (so a clip beat knows
|
||||||
|
/// whether to mix in live audio). Defaults to `false` on any probe failure.
|
||||||
|
pub async fn has_audio_stream(path: &str) -> bool {
|
||||||
|
Command::new("ffprobe")
|
||||||
|
.args([
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"a",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=index",
|
||||||
|
"-of",
|
||||||
|
"csv=p=0",
|
||||||
|
path,
|
||||||
|
])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map(|out| !out.stdout.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one clip beat: a section of `clip_path` (capped at [`CLIP_SECONDS`],
|
||||||
|
/// and to the source length) under the narration in `audio_path`. The beat
|
||||||
|
/// lasts at least the narration, freezing the clip's last frame if needed.
|
||||||
|
pub async fn render_clip_beat(
|
||||||
|
clip_path: &Path,
|
||||||
|
audio_path: &Path,
|
||||||
|
out_path: &Path,
|
||||||
|
narration_secs: f64,
|
||||||
|
opts: &SegmentOpts,
|
||||||
|
) -> Result<()> {
|
||||||
|
let clip_str = clip_path.to_string_lossy().to_string();
|
||||||
|
// Clamp the clip to its own length so a short video isn't padded to the cap.
|
||||||
|
let source_dur = crate::video::ffmpeg::get_duration_seconds(&clip_str)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
let clip_dur = match source_dur {
|
||||||
|
Some(d) if d > 0.0 && d < CLIP_SECONDS => d,
|
||||||
|
_ => CLIP_SECONDS,
|
||||||
|
};
|
||||||
|
let beat_total = clip_dur.max(segment_duration(narration_secs));
|
||||||
|
let has_audio = has_audio_stream(&clip_str).await;
|
||||||
|
|
||||||
|
let args = build_clip_beat_args(
|
||||||
|
&clip_str,
|
||||||
|
&audio_path.to_string_lossy(),
|
||||||
|
&out_path.to_string_lossy(),
|
||||||
|
clip_dur,
|
||||||
|
beat_total,
|
||||||
|
has_audio,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
run_ffmpeg(&args, "clip beat render").await
|
||||||
|
}
|
||||||
|
|
||||||
/// Join rendered segments into the final reel. Writes the concat list into the
|
/// Join rendered segments into the final reel. Writes the concat list into the
|
||||||
/// same directory as the output so relative paths and cleanup stay local.
|
/// same directory as the output so relative paths and cleanup stay local.
|
||||||
pub async fn concat_segments(segment_paths: &[String], out_path: &Path) -> Result<()> {
|
pub async fn concat_segments(segment_paths: &[String], out_path: &Path) -> Result<()> {
|
||||||
@@ -397,8 +559,8 @@ mod tests {
|
|||||||
// Concatenated in order, audio is the 4th input (index 3).
|
// Concatenated in order, audio is the 4th input (index 3).
|
||||||
assert!(g.contains("[v0][v1][v2]concat=n=3:v=1:a=0[v]"));
|
assert!(g.contains("[v0][v1][v2]concat=n=3:v=1:a=0[v]"));
|
||||||
assert!(g.contains("[3:a]apad[a]"));
|
assert!(g.contains("[3:a]apad[a]"));
|
||||||
// Burst uses the snappier fade.
|
// Burst uses the much snappier fade (vs 0.35 for a held shot).
|
||||||
assert!(g.contains("d=0.15"));
|
assert!(g.contains("d=0.08"));
|
||||||
assert!(!g.contains("d=0.35"));
|
assert!(!g.contains("d=0.35"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +617,54 @@ mod tests {
|
|||||||
assert!(!joined.contains("libx264"));
|
assert!(!joined.contains("libx264"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_filter_ducks_audio_and_holds_last_frame_when_narration_longer() {
|
||||||
|
// 5s clip, 7s beat → 2s freeze of the last frame, ducked-audio mix.
|
||||||
|
let g = clip_beat_filtergraph(&SegmentOpts::default(), 5.0, 7.0, true);
|
||||||
|
assert!(g.contains("tpad=stop_mode=clone:stop_duration=2.000"));
|
||||||
|
assert!(g.contains("volume=0.35"));
|
||||||
|
assert!(g.contains("amix=inputs=2"));
|
||||||
|
assert!(g.contains("[1:a]apad[narr]"));
|
||||||
|
// Fill applied to the clip too.
|
||||||
|
assert!(g.contains("boxblur"));
|
||||||
|
assert!(g.contains("overlay=(W-w)/2:(H-h)/2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_filter_no_tpad_when_clip_covers_the_beat() {
|
||||||
|
// Clip at least as long as the beat → no freeze.
|
||||||
|
let g = clip_beat_filtergraph(&SegmentOpts::default(), 5.0, 5.0, true);
|
||||||
|
assert!(!g.contains("tpad"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_filter_narration_only_without_clip_audio() {
|
||||||
|
let g = clip_beat_filtergraph(&SegmentOpts::default(), 5.0, 5.0, false);
|
||||||
|
assert!(!g.contains("amix"));
|
||||||
|
assert!(!g.contains("volume="));
|
||||||
|
assert!(g.contains("[1:a]apad[a]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clip_beat_args_bound_clip_and_output() {
|
||||||
|
let args = build_clip_beat_args(
|
||||||
|
"/v.mp4",
|
||||||
|
"/n.wav",
|
||||||
|
"/out.mp4",
|
||||||
|
5.0,
|
||||||
|
6.6,
|
||||||
|
true,
|
||||||
|
&SegmentOpts::default(),
|
||||||
|
);
|
||||||
|
let joined = args.join(" ");
|
||||||
|
// Input -t bounds the clip read; output -t bounds the beat.
|
||||||
|
assert!(joined.contains("-t 5.000 -i /v.mp4"));
|
||||||
|
assert!(joined.contains("-i /n.wav"));
|
||||||
|
assert!(joined.contains("-t 6.600"));
|
||||||
|
assert!(joined.contains("-r 30"));
|
||||||
|
assert!(joined.ends_with("/out.mp4"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn concat_args_stream_copy_with_faststart_and_forced_muxer() {
|
fn concat_args_stream_copy_with_faststart_and_forced_muxer() {
|
||||||
// Output goes to a .tmp path, so the muxer must be forced — ffmpeg
|
// Output goes to a .tmp path, so the muxer must be forced — ffmpeg
|
||||||
|
|||||||
+17
-4
@@ -54,8 +54,10 @@ pub fn build_script_messages(meta: &ReelMeta, beats: &[PlannedBeat]) -> (String,
|
|||||||
if let Some(date) = beat.date_label() {
|
if let Some(date) = beat.date_label() {
|
||||||
user.push_str(&format!(" {date}"));
|
user.push_str(&format!(" {date}"));
|
||||||
}
|
}
|
||||||
if beat.photos.len() > 1 {
|
if beat.is_clip() {
|
||||||
user.push_str(&format!(" (a burst of {} photos)", beat.photos.len()));
|
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');
|
user.push('\n');
|
||||||
match (&beat.insight_title, &beat.insight_summary) {
|
match (&beat.insight_title, &beat.insight_summary) {
|
||||||
@@ -211,7 +213,7 @@ mod tests {
|
|||||||
fn planned(n: usize) -> Vec<PlannedBeat> {
|
fn planned(n: usize) -> Vec<PlannedBeat> {
|
||||||
(0..n)
|
(0..n)
|
||||||
.map(|i| PlannedBeat {
|
.map(|i| PlannedBeat {
|
||||||
photos: vec![super::super::SegmentMedia::Photo {
|
media: vec![super::super::SegmentMedia::Photo {
|
||||||
rel_path: format!("p{i}.jpg"),
|
rel_path: format!("p{i}.jpg"),
|
||||||
library_id: 1,
|
library_id: 1,
|
||||||
}],
|
}],
|
||||||
@@ -236,7 +238,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn prompt_notes_burst_photo_count() {
|
fn prompt_notes_burst_photo_count() {
|
||||||
let mut p = planned(1);
|
let mut p = planned(1);
|
||||||
p[0].photos = vec![
|
p[0].media = vec![
|
||||||
super::super::SegmentMedia::Photo {
|
super::super::SegmentMedia::Photo {
|
||||||
rel_path: "a.jpg".into(),
|
rel_path: "a.jpg".into(),
|
||||||
library_id: 1,
|
library_id: 1,
|
||||||
@@ -254,6 +256,17 @@ mod tests {
|
|||||||
assert!(user.contains("a burst of 3 photos"));
|
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]
|
#[test]
|
||||||
fn prompt_includes_insight_context_when_present() {
|
fn prompt_includes_insight_context_when_present() {
|
||||||
let mut p = planned(1);
|
let mut p = planned(1);
|
||||||
|
|||||||
+134
-36
@@ -15,7 +15,7 @@ use chrono::{DateTime, Datelike, FixedOffset};
|
|||||||
|
|
||||||
use super::{PlannedBeat, ReelMeta, SegmentMedia};
|
use super::{PlannedBeat, ReelMeta, SegmentMedia};
|
||||||
use crate::database::{ExifDao, InsightDao};
|
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::memories::{self, MemoriesSpan};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -167,13 +167,13 @@ fn partition_into_groups(
|
|||||||
.collect()
|
.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
|
/// 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
|
/// the whole span is still covered. Each beat then flashes up to `max_burst`
|
||||||
/// `max_burst` photos (an even spread of its group) under one narration line —
|
/// photos (an even spread of its group) under one narration line — so a
|
||||||
/// so a week/month reel *shows* all its moments without a narrated (and timed)
|
/// week/month reel *shows* all its moments without a narrated (and timed)
|
||||||
/// segment per photo.
|
/// segment per photo.
|
||||||
pub fn form_beats(
|
fn form_photo_beats(
|
||||||
items: &[memories::MemoryItem],
|
items: &[memories::MemoryItem],
|
||||||
n_beats: usize,
|
n_beats: usize,
|
||||||
max_burst: usize,
|
max_burst: usize,
|
||||||
@@ -197,7 +197,7 @@ pub fn form_beats(
|
|||||||
let shown = sample_evenly(&group, max_burst);
|
let shown = sample_evenly(&group, max_burst);
|
||||||
let date = shown.first().and_then(|it| it.created);
|
let date = shown.first().and_then(|it| it.created);
|
||||||
PlannedBeat {
|
PlannedBeat {
|
||||||
photos: shown
|
media: shown
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|it| SegmentMedia::Photo {
|
.map(|it| SegmentMedia::Photo {
|
||||||
rel_path: it.path,
|
rel_path: it.path,
|
||||||
@@ -212,6 +212,62 @@ pub fn form_beats(
|
|||||||
.collect()
|
.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
|
/// 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.
|
/// lookups yet) plus reel metadata. `Err` only on an invalid library param.
|
||||||
pub fn resolve(
|
pub fn resolve(
|
||||||
@@ -238,23 +294,24 @@ pub fn resolve(
|
|||||||
library.as_deref(),
|
library.as_deref(),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
// Phase 1 is photos-only: drop videos (a clip segment type lands
|
// Split into photos and video clips; anything that's neither is
|
||||||
// in phase 2).
|
// dropped. Years span both, computed before the budget narrows it.
|
||||||
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.
|
|
||||||
let years = distinct_years(&items, client_tz);
|
let years = distinct_years(&items, client_tz);
|
||||||
let meta = ReelMeta { span: *span, years };
|
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);
|
// The budget caps the number of narrated beats (≈ reel length);
|
||||||
// each beat then bursts through several photos, so the reel covers
|
// photo beats then burst through several photos and video beats
|
||||||
// the span's moments without running minutes long.
|
// play a short clip, so the reel covers the span without running
|
||||||
|
// minutes long.
|
||||||
let n_beats = budget_segments(*max_segments);
|
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))
|
Ok((beats, meta))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,10 +346,13 @@ pub fn enrich(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
for beat in beats.iter_mut() {
|
for beat in beats.iter_mut() {
|
||||||
let Some(SegmentMedia::Photo { rel_path, .. }) = beat.photos.first() else {
|
let rel_path = match beat.media.first() {
|
||||||
continue;
|
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_title = Some(insight.title);
|
||||||
beat.insight_summary = Some(insight.summary);
|
beat.insight_summary = Some(insight.summary);
|
||||||
}
|
}
|
||||||
@@ -372,15 +432,18 @@ mod tests {
|
|||||||
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
|
assert_eq!(distinct_years(&items, None), vec![2019, 2021]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build an item at a given unix timestamp (seconds).
|
// Build an item at a given unix timestamp (seconds) with a chosen extension.
|
||||||
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
fn item_ext(ts: i64, name: &str, ext: &str) -> memories::MemoryItem {
|
||||||
memories::MemoryItem {
|
memories::MemoryItem {
|
||||||
path: format!("{name}.jpg"),
|
path: format!("{name}.{ext}"),
|
||||||
created: Some(ts),
|
created: Some(ts),
|
||||||
modified: None,
|
modified: None,
|
||||||
library_id: 1,
|
library_id: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fn item_at(ts: i64, name: &str) -> memories::MemoryItem {
|
||||||
|
item_ext(ts, name, "jpg")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_segments_caps_to_duration_target() {
|
fn budget_segments_caps_to_duration_target() {
|
||||||
@@ -405,7 +468,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// Three well-separated events, budget of 10 → three beats, each holding
|
||||||
// all of its (few) photos.
|
// all of its (few) photos.
|
||||||
let items = vec![
|
let items = vec![
|
||||||
@@ -414,35 +477,70 @@ mod tests {
|
|||||||
item_at(1_000_000, "c"),
|
item_at(1_000_000, "c"),
|
||||||
item_at(2_000_000, "d"),
|
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.len(), 3);
|
||||||
assert_eq!(beats[0].photos.len(), 2); // burst of the first event
|
assert_eq!(beats[0].media.len(), 2); // burst of the first event
|
||||||
assert_eq!(beats[1].photos.len(), 1);
|
assert_eq!(beats[1].media.len(), 1);
|
||||||
assert_eq!(beats[2].photos.len(), 1);
|
assert_eq!(beats[2].media.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// Six distinct events but only two beats → adjacent events fold in, and
|
||||||
// every event's photos still appear (capped by the burst max).
|
// every event's photos still appear (capped by the burst max).
|
||||||
let items: Vec<memories::MemoryItem> = (0..6)
|
let items: Vec<memories::MemoryItem> = (0..6)
|
||||||
.map(|i| item_at(i as i64 * 1_000_000, &format!("e{i}")))
|
.map(|i| item_at(i as i64 * 1_000_000, &format!("e{i}")))
|
||||||
.collect();
|
.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);
|
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
|
assert_eq!(shown, 6); // all six moments still shown across two beats
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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
|
// One dense event of 30 photos, generous budget → a single beat that
|
||||||
// bursts at most MAX_BURST_PHOTOS, not all 30.
|
// bursts at most MAX_BURST_PHOTOS, not all 30.
|
||||||
let items: Vec<memories::MemoryItem> = (0..30)
|
let items: Vec<memories::MemoryItem> = (0..30)
|
||||||
.map(|i| item_at(i as i64, &format!("p{i}")))
|
.map(|i| item_at(i as i64, &format!("p{i}")))
|
||||||
.collect();
|
.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.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