Reels: fix steppy fade (fps before fade) and ease the expression bump

The fade looked steppy/low-frame-rate because the filtergraph normalized
fps AFTER the fade filters: the brightness ramp was sampled at the looped
still's coarse input cadence, then duplicated up to 30fps. Move fps ahead
of the fades, pin the still's input framerate (-framerate), and force CFR
output (-r) so the dip ramps across a full 30 frames and plays steadily.

Ease narration expressiveness from 0.7 to 0.6 (still tunable via
REEL_TTS_EXAGGERATION). Bump RENDER_VERSION so existing reels re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-06-12 23:20:52 -04:00
parent 7715a7a905
commit 740fc4d841
2 changed files with 33 additions and 7 deletions
+28 -2
View File
@@ -87,15 +87,20 @@ pub fn segment_filtergraph(opts: &SegmentOpts, duration: f64) -> String {
// Fade-out begins one fade-length before the end; clamp so a floor-length
// segment still gets a valid (non-negative) start time.
let fade_out_start = (duration - FADE_SECONDS).max(0.0);
// `fps` is normalized BEFORE the fades so the brightness ramp is computed
// on a true {fps}-frame timeline. If fps came after, the fade would be
// sampled at the looped still's coarse input cadence and then duplicated up
// to {fps}, which reads as a steppy / low-frame-rate dip.
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},\
fade=t=in:st=0:d={FADE_SECONDS},\
fade=t=out:st={fade_out_start:.3}:d={FADE_SECONDS},\
setsar=1,fps={fps},format=yuv420p[v];\
setsar=1,format=yuv420p[v];\
[1:a]apad[a]"
)
}
@@ -134,11 +139,16 @@ pub fn build_segment_args(
duration: f64,
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([
// Read the looped still at the target rate so frames exist for the
// fade to ramp across (paired with the in-graph `fps` and CFR output).
"-framerate".into(),
fps.clone(),
"-loop".into(),
"1".into(),
"-i".into(),
@@ -153,6 +163,10 @@ pub fn build_segment_args(
"[a]".into(),
"-t".into(),
format!("{duration:.3}"),
// Force constant frame rate so the segment (and the concatenated reel)
// plays at a steady {fps} rather than a variable cadence.
"-r".into(),
fps,
]);
args.extend(video_encoder_args(opts.nvenc));
args.extend(
@@ -297,6 +311,16 @@ mod tests {
assert!(g.contains("fade=t=out:st=3.650:d=0.35"));
}
#[test]
fn filtergraph_normalizes_fps_before_fading() {
// The fps filter must precede the fades, else the brightness ramp is
// sampled at the still's coarse cadence and looks steppy.
let g = segment_filtergraph(&SegmentOpts::default(), 4.0);
let fps_at = g.find("fps=30").expect("fps in graph");
let fade_at = g.find("fade=t=in").expect("fade in graph");
assert!(fps_at < fade_at);
}
#[test]
fn filtergraph_fade_out_start_never_negative_at_floor() {
// A floor-length segment shorter than a fade still yields st >= 0.
@@ -314,10 +338,12 @@ mod tests {
&SegmentOpts::default(),
);
let joined = args.join(" ");
assert!(joined.contains("-loop 1 -i /img.jpg"));
assert!(joined.contains("-framerate 30 -loop 1 -i /img.jpg"));
assert!(joined.contains("-i /a.wav"));
assert!(joined.contains("apad"));
assert!(joined.contains("-t 4.000"));
// Constant frame rate forced on the output.
assert!(joined.contains("-r 30"));
assert!(joined.contains("libx264"));
assert!(joined.ends_with("/out.mp4"));
}