Add memory-reel backend: on-demand narrated photo slideshow
New POST /reels + GET /reels/{id} (+ /video) build an MP4 slideshow of a
memory span (day/week/month), narrated by the LLM in a cloned voice.
Pipeline (src/reels/): a selector resolves which photos + reel metadata,
the scripter writes one narration line per photo via a single LLM call
(reusing each photo's cached insight as context — no fresh vision calls,
so reel generation stays off the GPU's vision slot), each line is
synthesized to speech, and the renderer assembles stills + narration via
ffmpeg. Jobs run in the background (mirroring the TTS speech-job
registry) since a reel takes minutes; the finished MP4 is cached on disk
keyed by the selection so a repeat request is instant.
The segment model is media-typed (Photo today) so a video-clip segment
(phase 2) and a nightly pre-render (phase 3) slot in without reworking
the pipeline. Ken Burns motion is implemented but defaulted off pending a
visual check on the GPU box.
Supporting changes:
- memories: extract gather_memory_items() so the reel selector reuses the
exact window/exclusion/tz/sort logic behind /memories.
- ai::tts: add synthesize_serialized() so reel narration honors the same
single-GPU permit + write lease as user TTS requests.
- video::ffmpeg: make get_duration_seconds() pub for narration timing.
- AppState: reels_path (REELS_DIRECTORY, defaults beside preview clips).
Pure logic (cache key, script parsing, ffmpeg arg/filter construction,
even sampling, segment timing) is unit-tested (26 tests). The runtime
path (ffmpeg render, TTS, LLM) needs a real run on the GPU host to verify
end-to-end — not exercisable in CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98274c3301
commit
e3f731b3b2
9 files changed
+1615
-30
No files matched your search
+57
-29
@@ -349,12 +349,6 @@ pub async fn list_memories(
|
||||
opentelemetry::Context::new().with_remote_span_context(span.span_context().clone());
|
||||
|
||||
let span_mode = q.span.unwrap_or(MemoriesSpan::Day);
|
||||
let span_token = match span_mode {
|
||||
MemoriesSpan::Day => "day",
|
||||
MemoriesSpan::Week => "week",
|
||||
MemoriesSpan::Month => "month",
|
||||
};
|
||||
let years_back: i32 = DEFAULT_YEARS_BACK;
|
||||
|
||||
// The SQL filter expects a signed offset in minutes from UTC; default
|
||||
// 0 (UTC) when the client didn't send a hint. We also keep a chrono
|
||||
@@ -366,18 +360,66 @@ pub async fn list_memories(
|
||||
.timezone_offset_minutes
|
||||
.and_then(|offset_mins| FixedOffset::east_opt(offset_mins * 60));
|
||||
|
||||
debug!(
|
||||
"list_memories: span={:?} tz_offset_min={} years_back={}",
|
||||
span_mode, tz_offset_minutes, years_back
|
||||
);
|
||||
|
||||
let library = match crate::libraries::resolve_library_param(&app_state, q.library.as_deref()) {
|
||||
Ok(lib) => lib,
|
||||
let items = match gather_memory_items(
|
||||
&app_state,
|
||||
&exif_dao,
|
||||
&span_context,
|
||||
span_mode,
|
||||
tz_offset_minutes,
|
||||
client_timezone,
|
||||
q.library.as_deref(),
|
||||
) {
|
||||
Ok(items) => items,
|
||||
Err(msg) => {
|
||||
warn!("Rejecting /memories request: {}", msg);
|
||||
return HttpResponse::BadRequest().body(msg);
|
||||
}
|
||||
};
|
||||
|
||||
span.add_event(
|
||||
"memories_scanned",
|
||||
vec![
|
||||
KeyValue::new("span", format!("{:?}", span_mode)),
|
||||
KeyValue::new("years_back", DEFAULT_YEARS_BACK.to_string()),
|
||||
KeyValue::new("result_count", items.len().to_string()),
|
||||
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
|
||||
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
||||
],
|
||||
);
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
HttpResponse::Ok().json(MemoriesResponse { items })
|
||||
}
|
||||
|
||||
/// Resolve an "on this day/week/month across past years" window into an
|
||||
/// ordered list of [`MemoryItem`]s. Shared by the `/memories` handler and the
|
||||
/// memory-reel selector so both honour the same library resolution, per-library
|
||||
/// exclusions, timezone handling, and sort order. Returns `Err(message)` only
|
||||
/// when the `library` param is invalid (callers map that to 400); per-library
|
||||
/// query/lock failures are logged and skipped, matching the handler's
|
||||
/// best-effort behaviour.
|
||||
pub fn gather_memory_items(
|
||||
app_state: &AppState,
|
||||
exif_dao: &Mutex<Box<dyn ExifDao>>,
|
||||
span_context: &opentelemetry::Context,
|
||||
span_mode: MemoriesSpan,
|
||||
tz_offset_minutes: i32,
|
||||
client_timezone: Option<FixedOffset>,
|
||||
library_param: Option<&str>,
|
||||
) -> Result<Vec<MemoryItem>, String> {
|
||||
let span_token = match span_mode {
|
||||
MemoriesSpan::Day => "day",
|
||||
MemoriesSpan::Week => "week",
|
||||
MemoriesSpan::Month => "month",
|
||||
};
|
||||
let years_back: i32 = DEFAULT_YEARS_BACK;
|
||||
|
||||
debug!(
|
||||
"gather_memory_items: span={:?} tz_offset_min={} years_back={}",
|
||||
span_mode, tz_offset_minutes, years_back
|
||||
);
|
||||
|
||||
let library = crate::libraries::resolve_library_param(app_state, library_param)?;
|
||||
let libraries_to_scan: Vec<&crate::libraries::Library> = match library {
|
||||
Some(lib) => vec![lib],
|
||||
None => app_state.libraries.iter().collect(),
|
||||
@@ -394,7 +436,7 @@ pub async fn list_memories(
|
||||
|
||||
let rows = match exif_dao.lock() {
|
||||
Ok(mut dao) => match dao.get_memories_in_window(
|
||||
&span_context,
|
||||
span_context,
|
||||
lib.id,
|
||||
span_token,
|
||||
years_back,
|
||||
@@ -469,21 +511,7 @@ pub async fn list_memories(
|
||||
}
|
||||
}
|
||||
|
||||
let items: Vec<MemoryItem> = memories_with_dates.into_iter().map(|(m, _)| m).collect();
|
||||
|
||||
span.add_event(
|
||||
"memories_scanned",
|
||||
vec![
|
||||
KeyValue::new("span", format!("{:?}", span_mode)),
|
||||
KeyValue::new("years_back", years_back.to_string()),
|
||||
KeyValue::new("result_count", items.len().to_string()),
|
||||
KeyValue::new("tz_offset_minutes", tz_offset_minutes.to_string()),
|
||||
KeyValue::new("excluded_dirs", format!("{:?}", app_state.excluded_dirs)),
|
||||
],
|
||||
);
|
||||
span.set_status(Status::Ok);
|
||||
|
||||
HttpResponse::Ok().json(MemoriesResponse { items })
|
||||
Ok(memories_with_dates.into_iter().map(|(m, _)| m).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in new issue
Block a user