Reels: clips play through the beat instead of freezing early

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) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-06-13 11:00:01 -04:00
parent f5581edf5e
commit b30c8c16d0
2 changed files with 57 additions and 9 deletions
+1 -1
View File
@@ -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;
+56 -8
View File
@@ -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<f64>, 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.