diff --git a/src/reels/mod.rs b/src/reels/mod.rs index 9956984..4cfe24b 100644 --- a/src/reels/mod.rs +++ b/src/reels/mod.rs @@ -180,17 +180,17 @@ 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 = 2; +const RENDER_VERSION: u32 = 3; -/// Narration expressiveness — Chatterbox's `exaggeration` knob. A modest bump -/// over the ~0.5 default warms up otherwise-flat narration; tune via -/// `REEL_TTS_EXAGGERATION` (0.25–2.0). +/// Narration expressiveness — Chatterbox's `exaggeration` knob. A slight bump +/// over the ~0.5 default warms up otherwise-flat narration without over-acting; +/// tune via `REEL_TTS_EXAGGERATION` (0.25–2.0). fn reel_tts_exaggeration() -> f32 { std::env::var("REEL_TTS_EXAGGERATION") .ok() .and_then(|s| s.trim().parse::().ok()) .filter(|x| x.is_finite()) - .unwrap_or(0.7) + .unwrap_or(0.6) } /// Cache key over everything that determines *which* media and *how* it's diff --git a/src/reels/render.rs b/src/reels/render.rs index e40fc3d..3cca6ac 100644 --- a/src/reels/render.rs +++ b/src/reels/render.rs @@ -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 { + let fps = opts.fps.to_string(); let mut args: Vec = 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")); }