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:
+1
-1
@@ -207,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 = 6;
|
const RENDER_VERSION: u32 = 7;
|
||||||
|
|
||||||
/// 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;
|
||||||
|
|||||||
+56
-8
@@ -41,8 +41,10 @@ const NARRATION_TAIL_SECONDS: f64 = 0.6;
|
|||||||
const SINGLE_FADE_SECONDS: f64 = 0.35;
|
const SINGLE_FADE_SECONDS: f64 = 0.35;
|
||||||
const BURST_FADE_SECONDS: f64 = 0.12;
|
const BURST_FADE_SECONDS: f64 = 0.12;
|
||||||
|
|
||||||
/// Video-clip framing. A clip plays at most this long, with its live audio
|
/// Video-clip framing. Fallback cap on how much of a clip we read when the
|
||||||
/// ducked to `CLIP_DUCK_VOLUME` under the narration.
|
/// 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;
|
pub const CLIP_SECONDS: f64 = 5.0;
|
||||||
const CLIP_DUCK_VOLUME: f64 = 0.35;
|
const CLIP_DUCK_VOLUME: f64 = 0.35;
|
||||||
|
|
||||||
@@ -316,6 +318,28 @@ pub async fn render_beat(
|
|||||||
|
|
||||||
// --- Video-clip beats --------------------------------------------------------
|
// --- 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
|
/// 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
|
/// backdrop, same look as photos), normalize fps, hold the last frame if the
|
||||||
/// narration outlasts the clip (`tpad`), then fade. Produces `[v]`.
|
/// narration outlasts the clip (`tpad`), then fade. Produces `[v]`.
|
||||||
@@ -446,16 +470,13 @@ pub async fn render_clip_beat(
|
|||||||
opts: &SegmentOpts,
|
opts: &SegmentOpts,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let clip_str = clip_path.to_string_lossy().to_string();
|
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)
|
let source_dur = crate::video::ffmpeg::get_duration_seconds(&clip_str)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten();
|
.flatten();
|
||||||
let clip_dur = match source_dur {
|
let (clip_dur, beat_total) = clip_beat_plan(source_dur, narration_secs);
|
||||||
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 has_audio = has_audio_stream(&clip_str).await;
|
||||||
|
|
||||||
let args = build_clip_beat_args(
|
let args = build_clip_beat_args(
|
||||||
@@ -630,6 +651,33 @@ mod tests {
|
|||||||
assert!(g.contains("overlay=(W-w)/2:(H-h)/2"));
|
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]
|
#[test]
|
||||||
fn clip_filter_no_tpad_when_clip_covers_the_beat() {
|
fn clip_filter_no_tpad_when_clip_covers_the_beat() {
|
||||||
// Clip at least as long as the beat → no freeze.
|
// Clip at least as long as the beat → no freeze.
|
||||||
|
|||||||
Reference in New Issue
Block a user