Skip to content

Commit d586965

Browse files
authored
feat: configurable framerate for smoother animations (#296)
## Summary Adds a new `--fps` option to make typing animations smoother. Perfect for demos and presentations where you want fluid, professional-looking recordings. ### The Problem The default 4 fps capture rate can make fast typing appear choppy. Multiple keystrokes within a 250ms window get compressed into a single frame, making the animation feel "jumpy". ### The Solution You can now increase the capture framerate up to 15 fps for smoother animations: ```sh # Smooth typing (10 fps, 100ms intervals) t-rec --fps 10 # Very smooth (15 fps, 66ms intervals) t-rec --fps 15 # Or use the new preset profile t-rec --profile smooth ``` ### Usage Examples | FPS | Interval | Best For | |-----|----------|----------| | 4 (default) | 250ms | Regular recordings, smaller files | | 10 | 100ms | Demos, presentations | | 15 | 66ms | Fast typing, smooth cursor movement | ### Config File Support You can save your preferred framerate in the config file: ```toml [default] fps = 10 [profiles.smooth] fps = 10 idle-pause = "2s" ``` ### Trade-offs Higher framerates produce larger files (roughly proportional to fps increase). The idle frame detection still works, so file sizes are optimized during pauses. ## Test plan - [x] `cargo build` succeeds - [x] `cargo test` passes - [x] `cargo clippy` passes - [x] Manual test: `t-rec --fps 4` (default behavior) - [x] Manual test: `t-rec --fps 10` (visibly smoother) - [x] Manual test: `t-rec --fps 15` (very smooth) - [x] Manual test: `t-rec --fps 3` errors (below minimum) - [x] Manual test: `t-rec --fps 16` errors (above maximum) - [x] Manual test: Config file with `fps = 10` works Completes #28 --------- Signed-off-by: Sven Kanoldt <sven@d34dl0ck.me>
1 parent bda1bd3 commit d586965

File tree

9 files changed

+163
-57
lines changed

9 files changed

+163
-57
lines changed

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Blazingly fast terminal recorder that generates animated gif images for the web
1818
![demo](./docs/demo.gif)
1919

2020
## Features
21-
- Screenshotting your terminal with 4 frames per second (every 250ms)
21+
- Screenshotting your terminal with configurable framerate (4-15 fps)
2222
- Generates high quality small sized animated gif images or mp4 videos
2323
- **built-in idle frames detection and optimization** (for super fluid presentations)
2424
- Applies (can be disabled) border decor effects like drop shadow
@@ -168,6 +168,8 @@ Options:
168168
-i, --idle-pause <s | ms | m> to preserve natural pauses up to a maximum duration by overriding
169169
idle detection. Can enhance readability. [default: 3s]
170170
-o, --output <file> to specify the output file (without extension) [default: t-rec]
171+
-f, --fps <4-15> Capture framerate. Higher = smoother animations but larger
172+
files [default: 4]
171173
-p, --wallpaper <wallpaper> Wallpaper background. Use 'ventura' for built-in, or provide
172174
a path to a custom image (PNG, JPEG, TGA)
173175
--wallpaper-padding <1-500> Padding in pixels around the recording when using --wallpaper
@@ -259,9 +261,24 @@ t-rec --list-profiles
259261
| `end-pause` | string | Pause at end |
260262
| `idle-pause` | string | Max idle time before optimization |
261263
| `output` | string | Output filename (without extension) |
264+
| `fps` | number | Capture framerate, 4-15 (default: 4) |
262265

263266
**Note:** CLI arguments always override config file settings.
264267

268+
### Smoother Animations
269+
270+
For smoother typing animations in demos, increase the capture framerate:
271+
272+
```sh
273+
# Smooth typing (10 fps)
274+
t-rec --fps 10
275+
276+
# Very smooth (15 fps)
277+
t-rec --fps 15
278+
```
279+
280+
**Note:** Higher framerates produce larger files. The default 4 fps is recommended for most use cases.
281+
265282
### Disable idle detection & optimization
266283

267284
If you are not happy with the idle detection and optimization, you can disable it with the `-n` or `--natural` parameter.

src/capture.rs

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,36 @@ use tempfile::TempDir;
1111
use crate::utils::{file_name_for, IMG_EXT};
1212
use crate::{ImageOnHeap, PlatformApi, WindowId};
1313

14+
/// Configuration and shared state for the capture thread.
15+
///
16+
/// Groups all parameters needed for frame capture, making the API cleaner
17+
/// and easier to extend with new options.
18+
pub struct CaptureContext {
19+
/// Window ID to capture
20+
pub win_id: WindowId,
21+
/// Shared list to store frame timestamps
22+
pub time_codes: Arc<Mutex<Vec<u128>>>,
23+
/// Directory for saving frames
24+
pub tempdir: Arc<Mutex<TempDir>>,
25+
/// If true, save all frames without idle detection
26+
pub natural: bool,
27+
/// Maximum pause duration to preserve (None = skip all identical frames)
28+
pub idle_pause: Option<Duration>,
29+
/// Capture framerate (4-15 fps)
30+
pub fps: u8,
31+
}
32+
33+
impl CaptureContext {
34+
/// Calculate frame interval from fps, this is not used in tests
35+
pub fn frame_interval(&self) -> Duration {
36+
if cfg!(test) {
37+
Duration::from_millis(10) // Fast for testing
38+
} else {
39+
Duration::from_millis(1000 / self.fps as u64)
40+
}
41+
}
42+
}
43+
1444
/// Captures screenshots periodically and decides which frames to keep.
1545
///
1646
/// Eliminates long idle periods while preserving brief pauses that aid
@@ -19,13 +49,7 @@ use crate::{ImageOnHeap, PlatformApi, WindowId};
1949
/// # Parameters
2050
/// * `rx` - Channel to receive stop signal
2151
/// * `api` - Platform API for taking screenshots
22-
/// * `win_id` - Window ID to capture
23-
/// * `time_codes` - Shared list to store frame timestamps
24-
/// * `tempdir` - Directory for saving frames
25-
/// * `force_natural` - If true, save all frames (no skipping)
26-
/// * `idle_pause` - Maximum pause duration to preserve for viewer comprehension:
27-
/// - `None`: Skip all identical frames (maximum compression)
28-
/// - `Some(duration)`: Preserve pauses up to this duration, skip beyond
52+
/// * `ctx` - Capture configuration and shared state
2953
///
3054
/// # Behavior
3155
/// When identical frames are detected:
@@ -34,19 +58,8 @@ use crate::{ImageOnHeap, PlatformApi, WindowId};
3458
///
3559
/// Example: 10-second idle with 3-second threshold → saves 3 seconds of pause,
3660
/// skips 7 seconds, playback shows exactly 3 seconds.
37-
pub fn capture_thread(
38-
rx: &Receiver<()>,
39-
api: impl PlatformApi,
40-
win_id: WindowId,
41-
time_codes: Arc<Mutex<Vec<u128>>>,
42-
tempdir: Arc<Mutex<TempDir>>,
43-
force_natural: bool,
44-
idle_pause: Option<Duration>,
45-
) -> Result<()> {
46-
#[cfg(test)]
47-
let duration = Duration::from_millis(10); // Fast for testing
48-
#[cfg(not(test))]
49-
let duration = Duration::from_millis(250); // Production speed
61+
pub fn capture_thread(rx: &Receiver<()>, api: impl PlatformApi, ctx: CaptureContext) -> Result<()> {
62+
let duration = ctx.frame_interval();
5063
let start = Instant::now();
5164

5265
// Total idle time skipped (subtracted from timestamps to prevent gaps)
@@ -68,11 +81,11 @@ pub fn capture_thread(
6881
let effective_now = now.sub(idle_duration);
6982
let tc = effective_now.saturating_duration_since(start).as_millis();
7083

71-
let image = api.capture_window_screenshot(win_id)?;
84+
let image = api.capture_window_screenshot(ctx.win_id)?;
7285
let frame_duration = now.duration_since(last_now);
7386

7487
// Check if frame is identical to previous (skip check in natural mode)
75-
let frame_unchanged = !force_natural
88+
let frame_unchanged = !ctx.natural
7689
&& last_frame
7790
.as_ref()
7891
.map(|last| image.samples.as_slice() == last.samples.as_slice())
@@ -87,7 +100,7 @@ pub fn capture_thread(
87100

88101
// Decide whether to save this frame
89102
let should_save_frame = if frame_unchanged {
90-
let should_skip_for_compression = if let Some(threshold) = idle_pause {
103+
let should_skip_for_compression = if let Some(threshold) = ctx.idle_pause {
91104
// Skip if idle exceeds threshold
92105
current_idle_period >= threshold
93106
} else {
@@ -111,12 +124,16 @@ pub fn capture_thread(
111124

112125
if should_save_frame {
113126
// Save frame and update state
114-
if let Err(e) = save_frame(&image, tc, tempdir.lock().unwrap().borrow(), file_name_for)
115-
{
127+
if let Err(e) = save_frame(
128+
&image,
129+
tc,
130+
ctx.tempdir.lock().unwrap().borrow(),
131+
file_name_for,
132+
) {
116133
eprintln!("{}", &e);
117134
return Err(e);
118135
}
119-
time_codes.lock().unwrap().push(tc);
136+
ctx.time_codes.lock().unwrap().push(tc);
120137

121138
// Store frame for next comparison
122139
last_frame = Some(image);
@@ -230,16 +247,15 @@ mod tests {
230247
let _ = stop_signal_tx.send(());
231248
});
232249

233-
let timestamps_clone = captured_timestamps.clone();
234-
capture_thread(
235-
&stop_signal_rx,
236-
test_api,
237-
0,
238-
timestamps_clone,
239-
temp_directory,
240-
natural_mode,
241-
idle_threshold,
242-
)?;
250+
let ctx = CaptureContext {
251+
win_id: 0,
252+
time_codes: captured_timestamps.clone(),
253+
tempdir: temp_directory,
254+
natural: natural_mode,
255+
idle_pause: idle_threshold,
256+
fps: 4, // Default fps for tests
257+
};
258+
capture_thread(&stop_signal_rx, test_api, ctx)?;
243259
let result = captured_timestamps.lock().unwrap().clone();
244260
Ok(result)
245261
}

src/cli.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ pub fn launch() -> ArgMatches {
138138
.default_value("t-rec")
139139
.help("to specify the output file (without extension)"),
140140
)
141+
.arg(
142+
Arg::new("fps")
143+
.value_parser(clap::value_parser!(u8).range(4..=15))
144+
.default_value("4")
145+
.required(false)
146+
.short('f')
147+
.long("fps")
148+
.help("Capture framerate, 4-15 fps. Higher = smoother animations but larger files"),
149+
)
141150
.arg(
142151
Arg::new("program")
143152
.value_name("shell or program to launch")

src/config/init.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub const STARTER_CONFIG: &str = r#"# t-rec configuration file
33
44
# Default settings applied to all recordings
55
[default]
6+
# fps = 4
67
# wallpaper = "ventura"
78
# wallpaper-padding = 60
89
# start-pause = "2s"
@@ -16,6 +17,10 @@ wallpaper-padding = 100
1617
start-pause = "5s"
1718
idle-pause = "5s"
1819
20+
[profiles.smooth]
21+
fps = 10
22+
idle-pause = "2s"
23+
1924
[profiles.quick]
2025
quiet = true
2126
idle-pause = "1s"

src/config/profile.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub struct ProfileSettings {
2121
pub start_pause: Option<String>,
2222
pub idle_pause: Option<String>,
2323
pub output: Option<String>,
24+
pub fps: Option<u8>,
2425
}
2526

2627
impl ProfileSettings {
@@ -65,6 +66,9 @@ impl ProfileSettings {
6566
if other.output.is_some() {
6667
self.output = other.output.clone();
6768
}
69+
if other.fps.is_some() {
70+
self.fps = other.fps;
71+
}
6872
}
6973

7074
/// Apply CLI arguments on top of config settings
@@ -122,6 +126,11 @@ impl ProfileSettings {
122126
self.output = Some(v.clone());
123127
}
124128
}
129+
if args.value_source("fps") == Some(clap::parser::ValueSource::CommandLine) {
130+
if let Some(v) = args.get_one::<u8>("fps") {
131+
self.fps = Some(*v);
132+
}
133+
}
125134
}
126135

127136
/// Get final values with defaults applied
@@ -155,6 +164,10 @@ impl ProfileSettings {
155164
pub fn output(&self) -> &str {
156165
self.output.as_deref().unwrap_or("t-rec")
157166
}
167+
/// Get fps value (default: 4, must be kept in sync with CLI default)
168+
pub fn fps(&self) -> u8 {
169+
self.fps.unwrap_or(4)
170+
}
158171
}
159172

160173
/// Expand $HOME in a string value (only $HOME is supported)
@@ -252,5 +265,6 @@ mod tests {
252265
assert_eq!(settings.wallpaper_padding(), 60);
253266
assert_eq!(settings.idle_pause(), "3s");
254267
assert_eq!(settings.output(), "t-rec");
268+
assert_eq!(settings.fps(), 4);
255269
}
256270
}

src/generators/gif.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub fn generate_gif_with_convert(
4545
if !frame.exists() {
4646
continue;
4747
}
48-
let mut frame_delay = (delay as f64 * 0.1) as u64;
48+
let mut frame_delay = ((delay as f64 * 0.1).round() as u64).max(1);
4949
match (i, start_pause, end_pause) {
5050
(0, Some(delay), _) => {
5151
frame_delay += delay.as_millis().div(10) as u64;

src/main.rs

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod common;
44
mod config;
55
mod decors;
66
mod generators;
7+
mod summary;
78
mod tips;
89
mod wallpapers;
910

@@ -31,13 +32,14 @@ use crate::config::{
3132
};
3233
use crate::decors::{apply_big_sur_corner_effect, apply_shadow_effect};
3334
use crate::generators::{check_for_gif, check_for_mp4, generate_gif, generate_mp4};
35+
use crate::summary::print_recording_summary;
3436
use crate::tips::show_tip;
3537
use crate::wallpapers::{
3638
apply_wallpaper_effect, get_ventura_wallpaper, is_builtin_wallpaper,
3739
load_and_validate_wallpaper,
3840
};
3941

40-
use crate::capture::capture_thread;
42+
use crate::capture::{capture_thread, CaptureContext};
4143
use crate::utils::{sub_shell_thread, target_file, DEFAULT_EXT, MOVIE_EXT};
4244
use anyhow::{bail, Context};
4345
use clap::ArgMatches;
@@ -107,14 +109,14 @@ fn main() -> Result<()> {
107109
// Validate wallpaper BEFORE recording starts
108110
let wallpaper_config = validate_wallpaper_config(&settings, &api, win_id)?;
109111

110-
let force_natural = settings.natural();
111112
let should_generate_gif = !settings.video_only();
112113
let should_generate_video = settings.video() || settings.video_only();
113114
let (start_delay, end_delay, idle_pause) = (
114115
parse_delay(settings.start_pause.as_deref(), "start-pause")?,
115116
parse_delay(settings.end_pause.as_deref(), "end-pause")?,
116117
parse_delay(Some(settings.idle_pause()), "idle-pause")?,
117118
);
119+
let fps = settings.fps();
118120

119121
if should_generate_gif {
120122
check_for_gif()?;
@@ -130,19 +132,15 @@ fn main() -> Result<()> {
130132
let time_codes = Arc::new(Mutex::new(Vec::new()));
131133
let (tx, rx) = mpsc::channel();
132134
let photograph = {
133-
let tempdir = tempdir.clone();
134-
let time_codes = time_codes.clone();
135-
thread::spawn(move || -> Result<()> {
136-
capture_thread(
137-
&rx,
138-
api,
139-
win_id,
140-
time_codes,
141-
tempdir,
142-
force_natural,
143-
idle_pause,
144-
)
145-
})
135+
let ctx = CaptureContext {
136+
win_id,
137+
time_codes: time_codes.clone(),
138+
tempdir: tempdir.clone(),
139+
natural: settings.natural(),
140+
idle_pause,
141+
fps,
142+
};
143+
thread::spawn(move || -> Result<()> { capture_thread(&rx, api, ctx) })
146144
};
147145
let interact = thread::spawn(move || -> Result<()> { sub_shell_thread(&program).map(|_| ()) });
148146

@@ -175,11 +173,11 @@ fn main() -> Result<()> {
175173
.unwrap()
176174
.context("Cannot launch the recording thread")?;
177175

176+
let frame_count = time_codes.lock().unwrap().borrow().len();
177+
print_recording_summary(&settings, frame_count);
178+
178179
println!();
179-
println!(
180-
"🎆 Applying effects to {} frames (might take a bit)",
181-
time_codes.lock().unwrap().borrow().len()
182-
);
180+
println!("🎆 Applying effects (might take a bit)");
183181
show_tip();
184182

185183
apply_big_sur_corner_effect(

0 commit comments

Comments
 (0)