From b30c8c16d08d6081c67d8a7bf6c19ffb588ce2f7 Mon Sep 17 00:00:00 2001 From: Cameron Cordes Date: Sat, 13 Jun 2026 11:00:01 -0400 Subject: [PATCH] Reels: clips play through the beat instead of freezing early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clip beat capped playback at CLIP_SECONDS and filled the rest of the narration with a tpad freeze-frame, so a clip stopped dead on its last frame for a second or two before the transition — a glitchy pause that stills don't have. Extract clip_beat_plan: the clip now plays for as much of its beat as the source footage covers, and we freeze only when the source is genuinely shorter than the narration. Bump RENDER_VERSION. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/reels/mod.rs | 2 +- src/reels/render.rs | 64 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/reels/mod.rs b/src/reels/mod.rs index c6bfd68..32635a9 100644 --- a/src/reels/mod.rs +++ b/src/reels/mod.rs @@ -207,7 +207,7 @@ fn finish_job( /// Render version: bump to invalidate every cached reel after a rendering / /// scripting change that should produce a fresh result. -const RENDER_VERSION: u32 = 6; +const RENDER_VERSION: u32 = 7; /// Narration expressiveness — Chatterbox's `exaggeration` knob. A slight bump /// over the ~0.5 default warms up otherwise-flat narration without over-acting; diff --git a/src/reels/render.rs b/src/reels/render.rs index c8ddc04..221df5f 100644 --- a/src/reels/render.rs +++ b/src/reels/render.rs @@ -41,8 +41,10 @@ const NARRATION_TAIL_SECONDS: f64 = 0.6; const SINGLE_FADE_SECONDS: f64 = 0.35; const BURST_FADE_SECONDS: f64 = 0.12; -/// Video-clip framing. A clip plays at most this long, with its live audio -/// ducked to `CLIP_DUCK_VOLUME` under the narration. +/// Video-clip framing. Fallback cap on how much of a clip we read when the +/// source length can't be probed; with a known length, a clip instead plays for +/// as much of its beat as its footage allows (see [`clip_beat_plan`]). Its live +/// audio is ducked to `CLIP_DUCK_VOLUME` under the narration. pub const CLIP_SECONDS: f64 = 5.0; const CLIP_DUCK_VOLUME: f64 = 0.35; @@ -316,6 +318,28 @@ pub async fn render_beat( // --- Video-clip beats -------------------------------------------------------- +/// Decide how long the clip plays and how long the whole beat lasts, from the +/// source video's length (if known) and the narration length. Returns +/// `(clip_dur, beat_total)`. +/// +/// The beat always lasts long enough for the full narration. The clip plays for +/// as much of that beat as its footage covers — so the motion fills the screen +/// time rather than stopping early. We only freeze the last frame (the +/// `beat_total - clip_dur` gap, handled by `tpad` in [`clip_video_filter`]) when +/// the source video is genuinely shorter than the narration. Capping clip +/// playback at a fixed length while the narration ran longer was what produced +/// the second-or-two freeze that read as a glitchy pause before the transition. +pub fn clip_beat_plan(source_dur: Option, narration_secs: f64) -> (f64, f64) { + let want = segment_duration(narration_secs); + let clip_dur = match source_dur { + // Known length: play up to the whole beat, but never past the source. + Some(d) if d > 0.0 => d.min(want), + // Unknown length: read up to the fallback cap; tpad covers any shortfall. + _ => want.min(CLIP_SECONDS), + }; + (clip_dur, want.max(clip_dur)) +} + /// 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]`. @@ -446,16 +470,13 @@ pub async fn render_clip_beat( 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. + // Play the clip for as much of the beat as its footage covers; freeze only + // when the source is genuinely shorter than the narration (see clip_beat_plan). 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 (clip_dur, beat_total) = clip_beat_plan(source_dur, narration_secs); let has_audio = has_audio_stream(&clip_str).await; let args = build_clip_beat_args( @@ -630,6 +651,33 @@ mod tests { assert!(g.contains("overlay=(W-w)/2:(H-h)/2")); } + #[test] + fn clip_beat_plan_plays_clip_through_the_whole_beat_when_source_is_long() { + // 30s source, 4s narration → beat is narration+tail (4.6), and the clip + // plays that whole 4.6s of motion: no freeze (clip_dur == beat_total). + let (clip_dur, beat_total) = clip_beat_plan(Some(30.0), 4.0); + assert!((beat_total - 4.6).abs() < 1e-9); + assert!((clip_dur - 4.6).abs() < 1e-9); + assert!((beat_total - clip_dur).abs() < 1e-9); // no hold + } + + #[test] + fn clip_beat_plan_freezes_only_when_source_shorter_than_narration() { + // 2s source under a 4s narration → play all 2s, freeze the remainder. + let (clip_dur, beat_total) = clip_beat_plan(Some(2.0), 4.0); + assert!((clip_dur - 2.0).abs() < 1e-9); + assert!((beat_total - 4.6).abs() < 1e-9); + assert!(beat_total - clip_dur > 2.0); // unavoidable freeze gap + } + + #[test] + fn clip_beat_plan_caps_read_when_source_length_unknown() { + // Probe failed: read up to the fallback cap, beat still covers narration. + let (clip_dur, beat_total) = clip_beat_plan(None, 8.0); + assert!((clip_dur - CLIP_SECONDS).abs() < 1e-9); + assert!((beat_total - 8.6).abs() < 1e-9); + } + #[test] fn clip_filter_no_tpad_when_clip_covers_the_beat() { // Clip at least as long as the beat → no freeze.