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:
+5
-5
@@ -180,17 +180,17 @@ 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 = 2;
|
const RENDER_VERSION: u32 = 3;
|
||||||
|
|
||||||
/// Narration expressiveness — Chatterbox's `exaggeration` knob. A modest bump
|
/// Narration expressiveness — Chatterbox's `exaggeration` knob. A slight bump
|
||||||
/// over the ~0.5 default warms up otherwise-flat narration; tune via
|
/// over the ~0.5 default warms up otherwise-flat narration without over-acting;
|
||||||
/// `REEL_TTS_EXAGGERATION` (0.25–2.0).
|
/// tune via `REEL_TTS_EXAGGERATION` (0.25–2.0).
|
||||||
fn reel_tts_exaggeration() -> f32 {
|
fn reel_tts_exaggeration() -> f32 {
|
||||||
std::env::var("REEL_TTS_EXAGGERATION")
|
std::env::var("REEL_TTS_EXAGGERATION")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.trim().parse::<f32>().ok())
|
.and_then(|s| s.trim().parse::<f32>().ok())
|
||||||
.filter(|x| x.is_finite())
|
.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
|
/// Cache key over everything that determines *which* media and *how* it's
|
||||||
|
|||||||
+28
-2
@@ -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
|
// Fade-out begins one fade-length before the end; clamp so a floor-length
|
||||||
// segment still gets a valid (non-negative) start time.
|
// segment still gets a valid (non-negative) start time.
|
||||||
let fade_out_start = (duration - FADE_SECONDS).max(0.0);
|
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!(
|
format!(
|
||||||
"[0:v]split=2[bg][fg];\
|
"[0:v]split=2[bg][fg];\
|
||||||
[bg]scale={w}:{h}:force_original_aspect_ratio=increase,\
|
[bg]scale={w}:{h}:force_original_aspect_ratio=increase,\
|
||||||
crop={w}:{h},boxblur=20:2[bgb];\
|
crop={w}:{h},boxblur=20:2[bgb];\
|
||||||
[fg]scale={w}:{h}:force_original_aspect_ratio=decrease[fgs];\
|
[fg]scale={w}:{h}:force_original_aspect_ratio=decrease[fgs];\
|
||||||
[bgb][fgs]overlay=(W-w)/2:(H-h)/2,\
|
[bgb][fgs]overlay=(W-w)/2:(H-h)/2,\
|
||||||
|
fps={fps},\
|
||||||
fade=t=in:st=0:d={FADE_SECONDS},\
|
fade=t=in:st=0:d={FADE_SECONDS},\
|
||||||
fade=t=out:st={fade_out_start:.3}: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]"
|
[1:a]apad[a]"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -134,11 +139,16 @@ pub fn build_segment_args(
|
|||||||
duration: f64,
|
duration: f64,
|
||||||
opts: &SegmentOpts,
|
opts: &SegmentOpts,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
|
let fps = opts.fps.to_string();
|
||||||
let mut args: Vec<String> = vec!["-y".into()];
|
let mut args: Vec<String> = vec!["-y".into()];
|
||||||
if opts.nvenc {
|
if opts.nvenc {
|
||||||
args.extend(["-hwaccel".into(), "cuda".into()]);
|
args.extend(["-hwaccel".into(), "cuda".into()]);
|
||||||
}
|
}
|
||||||
args.extend([
|
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(),
|
"-loop".into(),
|
||||||
"1".into(),
|
"1".into(),
|
||||||
"-i".into(),
|
"-i".into(),
|
||||||
@@ -153,6 +163,10 @@ pub fn build_segment_args(
|
|||||||
"[a]".into(),
|
"[a]".into(),
|
||||||
"-t".into(),
|
"-t".into(),
|
||||||
format!("{duration:.3}"),
|
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(video_encoder_args(opts.nvenc));
|
||||||
args.extend(
|
args.extend(
|
||||||
@@ -297,6 +311,16 @@ mod tests {
|
|||||||
assert!(g.contains("fade=t=out:st=3.650:d=0.35"));
|
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]
|
#[test]
|
||||||
fn filtergraph_fade_out_start_never_negative_at_floor() {
|
fn filtergraph_fade_out_start_never_negative_at_floor() {
|
||||||
// A floor-length segment shorter than a fade still yields st >= 0.
|
// A floor-length segment shorter than a fade still yields st >= 0.
|
||||||
@@ -314,10 +338,12 @@ mod tests {
|
|||||||
&SegmentOpts::default(),
|
&SegmentOpts::default(),
|
||||||
);
|
);
|
||||||
let joined = args.join(" ");
|
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("-i /a.wav"));
|
||||||
assert!(joined.contains("apad"));
|
assert!(joined.contains("apad"));
|
||||||
assert!(joined.contains("-t 4.000"));
|
assert!(joined.contains("-t 4.000"));
|
||||||
|
// Constant frame rate forced on the output.
|
||||||
|
assert!(joined.contains("-r 30"));
|
||||||
assert!(joined.contains("libx264"));
|
assert!(joined.contains("libx264"));
|
||||||
assert!(joined.ends_with("/out.mp4"));
|
assert!(joined.ends_with("/out.mp4"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user