From 85d33a8fe05640f64760cf8afcd9743362708670 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 27 May 2026 16:09:16 +0000 Subject: [PATCH 1/5] Preserve asciicast timeline events --- src/asciicast.rs | 13 +++- src/asciicast/v1.rs | 42 +++++++++--- src/asciicast/v2.rs | 135 +++++++++++++++++++++++++++++++++++---- src/asciicast/v3.rs | 151 ++++++++++++++++++++++++++++++++++++++------ src/events.rs | 2 +- src/lib.rs | 8 ++- 6 files changed, 308 insertions(+), 43 deletions(-) diff --git a/src/asciicast.rs b/src/asciicast.rs index a2c3757..c606af0 100644 --- a/src/asciicast.rs +++ b/src/asciicast.rs @@ -10,7 +10,7 @@ use crate::theme::Theme; pub struct Asciicast<'a> { pub header: Header, - pub events: Box> + 'a>, + pub events: Box> + 'a>, } pub struct Header { @@ -20,7 +20,16 @@ pub struct Header { pub idle_time_limit: Option, } -pub type OutputEvent = (f64, String); +/// A single recording event. Every parsed event is preserved in file order so +/// timing transforms, selection, and frame generation use the same timeline. +/// `Other` covers input, resize, exit, and unknown events: their payload is not +/// modeled, only their timestamp. +#[derive(Debug, Clone, PartialEq)] +pub enum Event { + Output { time: f64, data: String }, + Marker { time: f64, label: String }, + Other { time: f64 }, +} impl Default for Header { fn default() -> Self { diff --git a/src/asciicast/v1.rs b/src/asciicast/v1.rs index 0198d4c..7057b6e 100644 --- a/src/asciicast/v1.rs +++ b/src/asciicast/v1.rs @@ -1,7 +1,7 @@ use anyhow::{bail, Result}; use serde::Deserialize; -use super::{Asciicast, Header}; +use super::{Asciicast, Event, Header}; #[derive(Deserialize)] struct V1 { @@ -34,7 +34,10 @@ pub fn load(json: String) -> Result> { let time = *prev_time + event.time; *prev_time = time; - Some(Ok((time, event.data))) + Some(Ok(Event::Output { + time, + data: event.data, + })) })); Ok(Asciicast { header, events }) @@ -63,9 +66,18 @@ mod tests { assert_eq!( events, vec![ - (0.5, "a".to_string()), - (1.5, "b".to_string()), - (3.0, "c".to_string()), + Event::Output { + time: 0.5, + data: "a".to_string() + }, + Event::Output { + time: 1.5, + data: "b".to_string() + }, + Event::Output { + time: 3.0, + data: "c".to_string() + }, ] ); } @@ -90,10 +102,22 @@ mod tests { assert_eq!( events, vec![ - (0.0, "a".to_string()), - (0.5, "b".to_string()), - (0.5, "c".to_string()), - (1.0, "d".to_string()), + Event::Output { + time: 0.0, + data: "a".to_string() + }, + Event::Output { + time: 0.5, + data: "b".to_string() + }, + Event::Output { + time: 0.5, + data: "c".to_string() + }, + Event::Output { + time: 1.0, + data: "d".to_string() + }, ] ); } diff --git a/src/asciicast/v2.rs b/src/asciicast/v2.rs index 9ad0e3e..c9df4b1 100644 --- a/src/asciicast/v2.rs +++ b/src/asciicast/v2.rs @@ -3,7 +3,7 @@ use std::io; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Deserializer}; -use super::{Asciicast, Header, Theme}; +use super::{Asciicast, Event, Header, Theme}; #[derive(Deserialize)] struct V2Header { @@ -35,7 +35,7 @@ struct V2Event { time: f64, #[serde(deserialize_with = "deserialize_code")] code: V2EventCode, - data: String, + data: serde_json::Value, } #[derive(PartialEq, Debug)] @@ -47,6 +47,33 @@ enum V2EventCode { Other(char), } +impl V2EventCode { + fn into_event(self, time: f64, data: serde_json::Value) -> Result { + match self { + V2EventCode::Output => Ok(Event::Output { + time, + data: require_string(data, "output")?, + }), + + V2EventCode::Marker => Ok(Event::Marker { + time, + label: require_string(data, "marker")?, + }), + + // Ignored events carry no domain payload, so a non-string value is + // tolerated rather than rejected during parsing. + _ => Ok(Event::Other { time }), + } + } +} + +fn require_string(data: serde_json::Value, kind: &str) -> Result { + match data { + serde_json::Value::String(s) => Ok(s), + _ => bail!("{kind} event data must be a string"), + } +} + pub struct Parser(V2Header); pub fn open(header_line: &str) -> Result { @@ -76,13 +103,13 @@ impl Parser { } } -fn parse_line(line: io::Result) -> Option> { +fn parse_line(line: io::Result) -> Option> { match line { Ok(line) => { if line.is_empty() { None } else { - parse_event(line).transpose() + Some(parse_event(line)) } } @@ -90,16 +117,10 @@ fn parse_line(line: io::Result) -> Option> { } } -fn parse_event(line: String) -> Result> { +fn parse_event(line: String) -> Result { let event = serde_json::from_str::(&line).context("asciicast parse error")?; - let output = if let V2EventCode::Output = event.code { - Some((event.time, event.data)) - } else { - None - }; - - Ok(output) + event.code.into_event(event.time, event.data) } fn deserialize_code<'de, D>(deserializer: D) -> Result @@ -174,6 +195,96 @@ impl From<&V2Theme> for Theme { mod tests { use super::*; + fn ok_lines(lines: Vec<&str>) -> Vec> { + lines.into_iter().map(|s| Ok(s.to_string())).collect() + } + + #[test] + fn preserves_events_with_absolute_times() { + let parser = open(r#"{"version":2,"width":80,"height":24}"#).unwrap(); + + let lines = ok_lines(vec![ + r#"[0.5,"o","a"]"#, + r#"[1.0,"m","label"]"#, + r#"[1.5,"o","b"]"#, + r#"[2.0,"r","100x40"]"#, + ]); + + let events = parser + .parse(lines.into_iter()) + .events + .collect::>>() + .unwrap(); + + assert_eq!( + events, + vec![ + Event::Output { + time: 0.5, + data: "a".to_string() + }, + Event::Marker { + time: 1.0, + label: "label".to_string() + }, + Event::Output { + time: 1.5, + data: "b".to_string() + }, + Event::Other { time: 2.0 }, + ] + ); + } + + #[test] + fn tolerates_non_string_payload_on_ignored_events() { + let parser = open(r#"{"version":2,"width":80,"height":24}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.5,"r",0]"#, r#"[1.0,"o","a"]"#]); + + let events = parser + .parse(lines.into_iter()) + .events + .collect::>>() + .unwrap(); + + assert_eq!( + events, + vec![ + Event::Other { time: 0.5 }, + Event::Output { + time: 1.0, + data: "a".to_string() + }, + ] + ); + } + + #[test] + fn rejects_non_string_output_data() { + let parser = open(r#"{"version":2,"width":80,"height":24}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.1,"o",123]"#]); + + let result = parser + .parse(lines.into_iter()) + .events + .collect::>>(); + + assert!(result.is_err()); + } + + #[test] + fn rejects_non_string_marker_label() { + let parser = open(r#"{"version":2,"width":80,"height":24}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.1,"m",123]"#]); + + let result = parser + .parse(lines.into_iter()) + .events + .collect::>>(); + + assert!(result.is_err()); + } + fn header_with_palette(colors: &[&str]) -> String { let palette = colors.join(":"); diff --git a/src/asciicast/v3.rs b/src/asciicast/v3.rs index 743a2a6..25ec4a8 100644 --- a/src/asciicast/v3.rs +++ b/src/asciicast/v3.rs @@ -3,7 +3,7 @@ use std::io; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Deserializer}; -use super::{Asciicast, Header, Theme}; +use super::{Asciicast, Event, Header, Theme}; #[derive(Deserialize)] struct V3Header { @@ -40,7 +40,7 @@ struct V3Event { time: f64, #[serde(deserialize_with = "deserialize_code")] code: V3EventCode, - data: String, + data: serde_json::Value, } #[derive(PartialEq, Debug)] @@ -53,6 +53,33 @@ enum V3EventCode { Other(char), } +impl V3EventCode { + fn into_event(self, time: f64, data: serde_json::Value) -> Result { + match self { + V3EventCode::Output => Ok(Event::Output { + time, + data: require_string(data, "output")?, + }), + + V3EventCode::Marker => Ok(Event::Marker { + time, + label: require_string(data, "marker")?, + }), + + // Ignored events carry no domain payload, so a non-string value is + // tolerated rather than rejected during parsing. + _ => Ok(Event::Other { time }), + } + } +} + +fn require_string(data: serde_json::Value, kind: &str) -> Result { + match data { + serde_json::Value::String(s) => Ok(s), + _ => bail!("{kind} event data must be a string"), + } +} + pub struct Parser { header: V3Header, prev_time: f64, @@ -90,13 +117,13 @@ impl Parser { Asciicast { header, events } } - fn parse_line(&mut self, line: io::Result) -> Option> { + fn parse_line(&mut self, line: io::Result) -> Option> { match line { Ok(line) => { if line.is_empty() || line.starts_with('#') { None } else { - self.parse_event(line).transpose() + Some(self.parse_event(line)) } } @@ -104,19 +131,14 @@ impl Parser { } } - fn parse_event(&mut self, line: String) -> Result> { + fn parse_event(&mut self, line: String) -> Result { let event = serde_json::from_str::(&line).context("asciicast parse error")?; + // v3 timestamps are intervals; accumulate into an absolute timeline. let time = self.prev_time + event.time; self.prev_time = time; - let output = if let V3EventCode::Output = event.code { - Some((time, event.data)) - } else { - None - }; - - Ok(output) + event.code.into_event(time, event.data) } } @@ -216,21 +238,31 @@ mod tests { assert_eq!( events, vec![ - (0.5, "a".to_string()), - (1.5, "b".to_string()), - (3.0, "c".to_string()), + Event::Output { + time: 0.5, + data: "a".to_string() + }, + Event::Output { + time: 1.5, + data: "b".to_string() + }, + Event::Output { + time: 3.0, + data: "c".to_string() + }, ] ); } #[test] - fn non_output_events_advance_clock_but_are_filtered() { + fn preserves_non_output_events_on_the_timeline() { let parser = open(r#"{"version":3,"term":{"cols":80,"rows":24}}"#).unwrap(); let lines = ok_lines(vec![ r#"[0.5,"o","a"]"#, r#"[1.0,"m","label"]"#, - r#"[1.5,"o","b"]"#, + r#"[0.25,"r","100x40"]"#, + r#"[0.25,"o","b"]"#, ]); let events = parser @@ -241,10 +273,93 @@ mod tests { assert_eq!( events, - vec![(0.5, "a".to_string()), (3.0, "b".to_string()),] + vec![ + Event::Output { + time: 0.5, + data: "a".to_string() + }, + Event::Marker { + time: 1.5, + label: "label".to_string() + }, + Event::Other { time: 1.75 }, + Event::Output { + time: 2.0, + data: "b".to_string() + }, + ] + ); + } + + #[test] + fn tolerates_non_string_payload_on_ignored_events() { + let parser = open(r#"{"version":3,"term":{"cols":80,"rows":24}}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.5,"x",0]"#, r#"[0.5,"o","a"]"#]); + + let events = parser + .parse(lines.into_iter()) + .events + .collect::>>() + .unwrap(); + + assert_eq!( + events, + vec![ + Event::Other { time: 0.5 }, + Event::Output { + time: 1.0, + data: "a".to_string() + }, + ] ); } + #[test] + fn unlabeled_marker_has_empty_label() { + let parser = open(r#"{"version":3,"term":{"cols":80,"rows":24}}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.5,"m",""]"#]); + + let events = parser + .parse(lines.into_iter()) + .events + .collect::>>() + .unwrap(); + + assert_eq!( + events, + vec![Event::Marker { + time: 0.5, + label: "".to_string() + }] + ); + } + + #[test] + fn rejects_non_string_output_data() { + let parser = open(r#"{"version":3,"term":{"cols":80,"rows":24}}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.1,"o",123]"#]); + + let result = parser + .parse(lines.into_iter()) + .events + .collect::>>(); + + assert!(result.is_err()); + } + + #[test] + fn rejects_non_string_marker_label() { + let parser = open(r#"{"version":3,"term":{"cols":80,"rows":24}}"#).unwrap(); + let lines = ok_lines(vec![r#"[0.1,"m",123]"#]); + + let result = parser + .parse(lines.into_iter()) + .events + .collect::>>(); + + assert!(result.is_err()); + } + #[test] fn empty_event_code_errors() { let parser = open(r#"{"version":3,"term":{"cols":80,"rows":24}}"#).unwrap(); diff --git a/src/events.rs b/src/events.rs index 08ae6ea..fb556b9 100644 --- a/src/events.rs +++ b/src/events.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use crate::asciicast::OutputEvent; +type OutputEvent = (f64, String); struct Batch where diff --git a/src/lib.rs b/src/lib.rs index dc2940a..fca1228 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,7 @@ use anyhow::{anyhow, Result}; use clap::ValueEnum; use log::{info, warn}; -use crate::asciicast::Asciicast; +use crate::asciicast::{Asciicast, Event}; pub const DEFAULT_BOLD_IS_BRIGHT: bool = false; pub const DEFAULT_TEXT_FONT_FAMILY: &str = @@ -160,6 +160,12 @@ pub fn run(input: I, output: O, config: Config) -> .or(header.idle_time_limit) .unwrap_or(DEFAULT_IDLE_TIME_LIMIT); + let events = events.filter_map(|event| match event { + Ok(Event::Output { time, data }) => Some(Ok((time, data))), + Ok(_) => None, + Err(e) => Some(Err(e)), + }); + let events = iter::once(Ok((0.0, "".to_owned()))).chain(events); let events = events::limit_idle_time(events, itl); let events = events::accelerate(events, config.speed); From 00b7a547786e7b7c52d2bc4e400f40bfde3bcd20 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 27 May 2026 16:10:20 +0000 Subject: [PATCH 2/5] Move event timing transforms to timeline --- src/asciicast.rs | 18 +++++++++ src/events.rs | 68 ------------------------------- src/lib.rs | 6 ++- src/timeline.rs | 103 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 70 deletions(-) create mode 100644 src/timeline.rs diff --git a/src/asciicast.rs b/src/asciicast.rs index c606af0..e1dcafc 100644 --- a/src/asciicast.rs +++ b/src/asciicast.rs @@ -31,6 +31,24 @@ pub enum Event { Other { time: f64 }, } +impl Event { + pub fn time(&self) -> f64 { + match self { + Event::Output { time, .. } | Event::Marker { time, .. } | Event::Other { time } => { + *time + } + } + } + + pub fn with_time(self, time: f64) -> Event { + match self { + Event::Output { data, .. } => Event::Output { time, data }, + Event::Marker { label, .. } => Event::Marker { time, label }, + Event::Other { .. } => Event::Other { time }, + } + } +} + impl Default for Header { fn default() -> Self { Self { diff --git a/src/events.rs b/src/events.rs index fb556b9..ef38fa1 100644 --- a/src/events.rs +++ b/src/events.rs @@ -64,57 +64,10 @@ pub fn batch( } } -pub fn accelerate( - events: impl Iterator>, - speed: f64, -) -> impl Iterator> { - events.map(move |event| event.map(|(time, data)| (time / speed, data))) -} - -pub fn limit_idle_time( - events: impl Iterator>, - limit: f64, -) -> impl Iterator> { - let mut prev_time = 0.0; - let mut offset = 0.0; - - events.map(move |event| { - event.map(|(time, data)| { - let delay = time - prev_time; - let excess = delay - limit; - - if excess > 0.0 { - offset += excess; - } - - prev_time = time; - - (time - offset, data) - }) - }) -} - #[cfg(test)] mod tests { use anyhow::Result; - #[test] - fn accelerate() { - let stdout = [ - (0.0, "foo".to_owned()), - (1.0, "bar".to_owned()), - (2.0, "baz".to_owned()), - ]; - - let stdout = super::accelerate(stdout.into_iter().map(Ok), 2.0) - .collect::>>() - .unwrap(); - - assert_eq!(&stdout[0], &(0.0, "foo".to_owned())); - assert_eq!(&stdout[1], &(0.5, "bar".to_owned())); - assert_eq!(&stdout[2], &(1.0, "baz".to_owned())); - } - #[test] fn batch() { let stdout = [ @@ -160,25 +113,4 @@ mod tests { assert_eq!(&stdout[1], &(1.0, "foo".to_owned())); assert_eq!(&stdout[2], &(2.0, "bar".to_owned())); } - - #[test] - fn limit_idle_time() { - let stdout = [ - (0.0, "foo".to_owned()), - (1.0, "bar".to_owned()), - (3.5, "baz".to_owned()), - (4.0, "qux".to_owned()), - (7.5, "quux".to_owned()), - ]; - - let stdout = super::limit_idle_time(stdout.into_iter().map(Ok), 2.0) - .collect::>>() - .unwrap(); - - assert_eq!(&stdout[0], &(0.0, "foo".to_owned())); - assert_eq!(&stdout[1], &(1.0, "bar".to_owned())); - assert_eq!(&stdout[2], &(3.0, "baz".to_owned())); - assert_eq!(&stdout[3], &(3.5, "qux".to_owned())); - assert_eq!(&stdout[4], &(5.5, "quux".to_owned())); - } } diff --git a/src/lib.rs b/src/lib.rs index fca1228..b9d6e74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod events; mod fonts; mod renderer; mod theme; +mod timeline; mod vt; use std::fmt::{Debug, Display}; @@ -160,6 +161,9 @@ pub fn run(input: I, output: O, config: Config) -> .or(header.idle_time_limit) .unwrap_or(DEFAULT_IDLE_TIME_LIMIT); + let events = timeline::limit_idle_time(events, itl); + let events = timeline::accelerate(events, config.speed); + let events = events.filter_map(|event| match event { Ok(Event::Output { time, data }) => Some(Ok((time, data))), Ok(_) => None, @@ -167,8 +171,6 @@ pub fn run(input: I, output: O, config: Config) -> }); let events = iter::once(Ok((0.0, "".to_owned()))).chain(events); - let events = events::limit_idle_time(events, itl); - let events = events::accelerate(events, config.speed); let events = events::batch(events, config.fps_cap); let events = events.collect::>(); let count = events.len() as u64; diff --git a/src/timeline.rs b/src/timeline.rs new file mode 100644 index 0000000..91f9db4 --- /dev/null +++ b/src/timeline.rs @@ -0,0 +1,103 @@ +use anyhow::Result; + +use crate::asciicast::Event; + +pub fn accelerate( + events: impl Iterator>, + speed: f64, +) -> impl Iterator> { + events.map(move |event| { + event.map(|e| { + let time = e.time(); + e.with_time(time / speed) + }) + }) +} + +pub fn limit_idle_time( + events: impl Iterator>, + limit: f64, +) -> impl Iterator> { + let mut prev_time = 0.0; + let mut offset = 0.0; + + events.map(move |event| { + event.map(|e| { + let time = e.time(); + let excess = (time - prev_time) - limit; + + if excess > 0.0 { + offset += excess; + } + + prev_time = time; + + e.with_time(time - offset) + }) + }) +} + +#[cfg(test)] +mod tests { + use crate::asciicast::Event; + use anyhow::Result; + + fn output(time: f64) -> Event { + Event::Output { + time, + data: "x".to_owned(), + } + } + + fn times(events: Vec) -> Vec { + events.into_iter().map(|e| e.time()).collect() + } + + #[test] + fn accelerate_scales_event_timestamps() { + let events = [output(0.0), output(1.0), output(2.0)]; + + let events = super::accelerate(events.into_iter().map(Ok), 2.0) + .collect::>>() + .unwrap(); + + assert_eq!(times(events), vec![0.0, 0.5, 1.0]); + } + + #[test] + fn limit_idle_time_collapses_long_gaps() { + let events = [ + output(0.0), + output(1.0), + output(3.5), + output(4.0), + output(7.5), + ]; + + let events = super::limit_idle_time(events.into_iter().map(Ok), 2.0) + .collect::>>() + .unwrap(); + + assert_eq!(times(events), vec![0.0, 1.0, 3.0, 3.5, 5.5]); + } + + #[test] + fn limit_idle_time_collapses_gaps_around_non_output_events() { + // The marker sits inside a long idle gap and participates in idle-gap + // calculation, so the gap is collapsed on both sides of it. + let events = [ + output(1.0), + Event::Marker { + time: 10.0, + label: "m".to_owned(), + }, + output(20.0), + ]; + + let events = super::limit_idle_time(events.into_iter().map(Ok), 2.0) + .collect::>>() + .unwrap(); + + assert_eq!(times(events), vec![1.0, 3.0, 5.0]); + } +} From 624e986e892bc180204e83b4194b1c6e5c46bb91 Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 27 May 2026 15:51:10 +0000 Subject: [PATCH 3/5] Represent terminal states as snapshots --- src/lib.rs | 5 +-- src/renderer.rs | 75 ++++++++++++++++++++++++++++--------------- src/renderer/resvg.rs | 5 +-- src/renderer/swash.rs | 7 ++-- src/terminal.rs | 31 ++++++++++++++++++ src/vt.rs | 45 +++++++++++++------------- 6 files changed, 112 insertions(+), 56 deletions(-) create mode 100644 src/terminal.rs diff --git a/src/lib.rs b/src/lib.rs index b9d6e74..7ee009b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod asciicast; mod events; mod fonts; mod renderer; +mod terminal; mod theme; mod timeline; mod vt; @@ -266,8 +267,8 @@ pub fn run(input: I, output: O, config: Config) -> }); for (i, frame) in frames.enumerate() { - let (time, lines, cursor) = frame?; - let image = renderer.render(&lines, cursor); + let (time, snapshot) = frame?; + let image = renderer.render(&snapshot); let time = if i == 0 { 0.0 } else { time }; collector.add_frame_rgba(i, image, time + config.last_frame_duration)?; } diff --git a/src/renderer.rs b/src/renderer.rs index f199c43..f3723bd 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -4,10 +4,11 @@ mod swash; use imgref::ImgVec; use rgb::{RGB8, RGBA8}; +use crate::terminal::Snapshot; use crate::theme::Theme; pub trait Renderer { - fn render(&mut self, lines: &[avt::Line], cursor: Option<(usize, usize)>) -> ImgVec; + fn render(&mut self, snapshot: &Snapshot) -> ImgVec; fn pixel_size(&self) -> (usize, usize); } @@ -204,12 +205,12 @@ mod tests { let mut renderer = resvg(settings(false)); let lines = vt_lines(); - let mut render = |cur| renderer.render(&lines, cur); + let mut render_frame = |cur| render(&mut renderer, lines.clone(), cur); // First render: cursor over (0, 5) — covers every assertion except // the two alternate-cursor-position cases (cursor-over-plain-colored, // cursor-over-reverse-colored) which need their own renders below. - let image = render(Some((0, 5))); + let image = render_frame(Some((0, 5))); // ── color paths ── // Cube/grayscale solid fills use threshold 0 (tighter than the resvg @@ -282,12 +283,12 @@ mod tests { assert_rgb_close(cell_center(&image, 2, 11), FG, 3); // Second render: cursor over the plain colored cell — swap. - let image = render(Some((4, 5))); + let image = render_frame(Some((4, 5))); assert_rgb_close(cell_center(&image, 4, 5), PALETTE[BLUE], 3); // Third render: cursor over the reverse colored cell — XOR cancels, // cell renders with its original (un-swapped) bg color. - let image = render(Some((6, 5))); + let image = render_frame(Some((6, 5))); assert_rgb_close(cell_center(&image, 6, 5), PALETTE[YELLOW], 3); } @@ -296,8 +297,8 @@ mod tests { let mut renderer = swash(settings(false)); let lines = vt_lines(); - let mut render = |cur| renderer.render(&lines, cur); - let image = render(Some((0, 5))); + let mut render_frame = |cur| render(&mut renderer, lines.clone(), cur); + let image = render_frame(Some((0, 5))); // ── color paths ── assert_rgb_close(cell_center(&image, 38, 0), BG, 0); @@ -353,10 +354,10 @@ mod tests { assert_rgb_close(cell_center(&image, 0, 11), PALETTE[RED], 4); assert_rgb_close(cell_center(&image, 2, 11), FG, 4); - let image = render(Some((4, 5))); + let image = render_frame(Some((4, 5))); assert_rgb_close(cell_center(&image, 4, 5), PALETTE[BLUE], 0); - let image = render(Some((6, 5))); + let image = render_frame(Some((6, 5))); assert_rgb_close(cell_center(&image, 6, 5), PALETTE[YELLOW], 0); } @@ -382,7 +383,7 @@ mod tests { "\r\n", "\x1b[38;5;3m┡┩┳┴╇\x1b[39m", ); - let image = renderer.render(&lines_for(input), None); + let image = render(&mut renderer, lines_for(input), None); // Heavy vertical and half-lines use centered crisp strokes. assert_rgb_close(cell_pixel(&image, 0, 0, 0.5, 0.5), PALETTE[GREEN], 0); @@ -496,8 +497,9 @@ mod tests { fn swash_renders_powerline_symbols_as_cell_geometry() { let mut renderer = swash(settings_without_symbol_fallback()); - let image = renderer.render( - &lines_for(concat!( + let image = render( + &mut renderer, + lines_for(concat!( "\x1b[38;5;2m", "\u{e0b0}\u{e0b1}\u{e0b2}\u{e0b3}", "\u{e0b4}\u{e0b5}\u{e0b6}\u{e0b7}", @@ -567,7 +569,7 @@ mod tests { let mut settings = settings(false); settings.font_size = 40; let mut renderer = swash(settings); - let image = renderer.render(&lines_for("\x1b[38;5;2m┏\x1b[39m"), None); + let image = render(&mut renderer, lines_for("\x1b[38;5;2m┏\x1b[39m"), None); assert_rgb_close(cell_pixel(&image, 0, 0, 0.42, 0.5), PALETTE[GREEN], 0); assert_rgb_close(cell_pixel(&image, 0, 0, 0.5, 0.46), PALETTE[GREEN], 0); @@ -577,7 +579,11 @@ mod tests { #[test] fn resvg_renders_nerd_font_symbols() { let mut renderer = resvg(settings(false)); - let image = renderer.render(&lines_for("\x1b[38;5;2m\u{f43a}\x1b[39m"), None); + let image = render( + &mut renderer, + lines_for("\x1b[38;5;2m\u{f43a}\x1b[39m"), + None, + ); assert_nerd_font_symbol_rendered(&image, 3); } @@ -607,7 +613,11 @@ mod tests { }; let mut renderer = resvg(settings); - let image = renderer.render(&lines_for("\x1b[38;5;2m\u{f43a}\x1b[39m"), None); + let image = render( + &mut renderer, + lines_for("\x1b[38;5;2m\u{f43a}\x1b[39m"), + None, + ); assert_nerd_font_symbol_rendered(&image, 3); } @@ -615,7 +625,11 @@ mod tests { #[test] fn swash_renders_nerd_font_symbols() { let mut renderer = swash(settings(false)); - let image = renderer.render(&lines_for("\x1b[38;5;2m\u{f43a}\x1b[39m"), None); + let image = render( + &mut renderer, + lines_for("\x1b[38;5;2m\u{f43a}\x1b[39m"), + None, + ); assert_nerd_font_symbol_rendered(&image, 0); } @@ -624,8 +638,9 @@ mod tests { fn swash_preserves_nerd_font_symbol_overhang() { let mut renderer = swash(settings(false)); - let image = renderer.render( - &lines_for("\x1b[38;5;2m\u{f03d}\x1b[39m\x1b[48;5;3m \x1b[49m"), + let image = render( + &mut renderer, + lines_for("\x1b[38;5;2m\u{f03d}\x1b[39m\x1b[48;5;3m \x1b[49m"), None, ); @@ -650,7 +665,7 @@ mod tests { }; let mut renderer = swash(settings); - let image = renderer.render(&lines_for("\x1b[48;5;3m日\x1b[49m"), None); + let image = render(&mut renderer, lines_for("\x1b[48;5;3m日\x1b[49m"), None); assert_closer_to(cell_pixel(&image, 1, 0, 0.3, 0.5), FG, PALETTE[YELLOW]); } @@ -677,13 +692,13 @@ mod tests { }; let mut fallback_renderer = swash(settings(make_db(), vec![])); - let fallback_image = fallback_renderer.render(&lines_for("M"), None); + let fallback_image = render(&mut fallback_renderer, lines_for("M"), None); let mut regular_renderer = swash(settings(make_db(), vec![FONT_FAMILY.to_owned()])); - let regular_image = regular_renderer.render(&lines_for("M"), None); + let regular_image = render(&mut regular_renderer, lines_for("M"), None); let mut italic_renderer = swash(settings(make_db(), vec![FONT_FAMILY.to_owned()])); - let italic_image = italic_renderer.render(&lines_for("\x1b[3mM"), None); + let italic_image = render(&mut italic_renderer, lines_for("\x1b[3mM"), None); assert_images_equal(&fallback_image, ®ular_image); assert_images_differ(&fallback_image, &italic_image); @@ -696,7 +711,7 @@ mod tests { fn resvg_bold_is_bright_brightens() { let mut renderer = resvg(settings(true)); let lines = vt_lines(); - let image = renderer.render(&lines, None); + let image = render(&mut renderer, lines.clone(), None); assert_rgb_close(cell_center(&image, 0, 11), PALETTE[BRIGHT_RED], 3); assert_rgb_close(cell_center(&image, 2, 11), PALETTE[BRIGHT_WHITE], 3); } @@ -705,7 +720,7 @@ mod tests { fn swash_bold_is_bright_brightens() { let mut renderer = swash(settings(true)); let lines = vt_lines(); - let image = renderer.render(&lines, None); + let image = render(&mut renderer, lines.clone(), None); assert_rgb_close(cell_center(&image, 0, 11), PALETTE[BRIGHT_RED], 4); assert_rgb_close(cell_center(&image, 2, 11), PALETTE[BRIGHT_WHITE], 4); } @@ -737,7 +752,7 @@ mod tests { }; let mut renderer = swash(settings); - let image = renderer.render(&lines_for("😀"), None); + let image = render(&mut renderer, lines_for("😀"), None); assert_cell_has_ink(&image, 0, 0, 2); } @@ -769,11 +784,19 @@ mod tests { let mut renderer = swash(settings); let lines = lines_for("⭐"); - let image = renderer.render(&lines, None); + let image = render(&mut renderer, lines.clone(), None); assert_color_emoji_rendered(&image, 0, 0, 2); } + fn render( + renderer: &mut R, + lines: Vec, + cursor: Option<(usize, usize)>, + ) -> ImgVec { + renderer.render(&Snapshot { lines, cursor }) + } + fn settings(bold_is_bright: bool) -> Settings { let mut font_db = fontdb::Database::new(); font_db.load_font_data(include_bytes!("../fonts/JetBrainsMono-Regular.ttf").to_vec()); diff --git a/src/renderer/resvg.rs b/src/renderer/resvg.rs index fc9991f..819ee8d 100644 --- a/src/renderer/resvg.rs +++ b/src/renderer/resvg.rs @@ -4,6 +4,7 @@ use imgref::ImgVec; use rgb::{FromSlice, RGBA8}; use super::{color_to_rgb, text_attrs, Renderer, Settings, TextAttrs}; +use crate::terminal::Snapshot; use crate::theme::Theme; pub struct ResvgRenderer<'a> { @@ -392,8 +393,8 @@ impl<'a> ResvgRenderer<'a> { } impl<'a> Renderer for ResvgRenderer<'a> { - fn render(&mut self, lines: &[avt::Line], cursor: Option<(usize, usize)>) -> ImgVec { - let svg = self.svg_for_frame(lines, cursor); + fn render(&mut self, snapshot: &Snapshot) -> ImgVec { + let svg = self.svg_for_frame(&snapshot.lines, snapshot.cursor); let tree = usvg::Tree::from_str(&svg, &self.options).unwrap(); let mut pixmap = diff --git a/src/renderer/swash.rs b/src/renderer/swash.rs index 12dac55..5f30aad 100644 --- a/src/renderer/swash.rs +++ b/src/renderer/swash.rs @@ -10,6 +10,7 @@ use swash::scale::{Render, ScaleContext, Source, StrikeWith}; use swash::FontRef; use crate::renderer::{color_to_rgb, text_attrs, Renderer, Settings, TextAttrs}; +use crate::terminal::Snapshot; use crate::theme::Theme; type CharVariant = (char, bool, bool); @@ -1216,13 +1217,13 @@ fn sextant_mask(cp: u32) -> Option { } impl Renderer for SwashRenderer { - fn render(&mut self, lines: &[avt::Line], cursor: Option<(usize, usize)>) -> ImgVec { + fn render(&mut self, snapshot: &Snapshot) -> ImgVec { let mut buf = self.new_frame(); let margin_l = self.col_width; let margin_t = (self.row_height / 2.0).round() as usize; let mut cells = Vec::new(); - for (row, line) in lines.iter().enumerate() { + for (row, line) in snapshot.lines.iter().enumerate() { let mut col = 0; for cell in line.cells() { @@ -1232,7 +1233,7 @@ impl Renderer for SwashRenderer { let attrs = text_attrs( cell.pen(), - &cursor, + &snapshot.cursor, col, row, &self.theme, diff --git a/src/terminal.rs b/src/terminal.rs new file mode 100644 index 0000000..1b79436 --- /dev/null +++ b/src/terminal.rs @@ -0,0 +1,31 @@ +use avt::Vt; + +pub fn build(terminal_size: (usize, usize)) -> Vt { + Vt::builder() + .size(terminal_size.0, terminal_size.1) + .scrollback_limit(0) + .build() +} + +pub fn feed_str(vt: &mut Vt, data: &str) { + vt.feed_str(data); +} + +#[derive(Clone)] +pub struct Snapshot { + pub lines: Vec, + pub cursor: Option<(usize, usize)>, +} + +impl Snapshot { + pub fn from_vt(vt: &Vt) -> Self { + Snapshot { + lines: vt.view().cloned().collect(), + cursor: vt.cursor().into(), + } + } + + pub fn same_visual(&self, other: &Snapshot) -> bool { + self.lines == other.lines && self.cursor == other.cursor + } +} diff --git a/src/vt.rs b/src/vt.rs index a8faa8d..5efdeec 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -1,32 +1,31 @@ use anyhow::Result; use log::debug; -type Frame = (f64, Vec, Option<(usize, usize)>); +use crate::terminal::{self, Snapshot}; + +type Frame = (f64, Snapshot); pub fn frames( stdout: impl Iterator>, terminal_size: (usize, usize), ) -> impl Iterator> { - let mut vt = avt::Vt::builder() - .size(terminal_size.0, terminal_size.1) - .scrollback_limit(0) - .build(); - - let mut prev_cursor = None; + let mut vt = terminal::build(terminal_size); + let mut prev_snapshot: Option = None; stdout.filter_map(move |event| { event .map(|(time, data)| { - let changed_lines = vt.feed_str(&data).lines; - let cursor: Option<(usize, usize)> = vt.cursor().into(); + terminal::feed_str(&mut vt, &data); + let snapshot = Snapshot::from_vt(&vt); - if !changed_lines.is_empty() || cursor != prev_cursor { - prev_cursor = cursor; - let lines = vt.view().cloned().collect(); + if prev_snapshot + .as_ref() + .is_none_or(|prev| !prev.same_visual(&snapshot)) + { + prev_snapshot = Some(snapshot.clone()); - Some((time, lines, cursor)) + Some((time, snapshot)) } else { - prev_cursor = cursor; debug!("skipping frame with no visual changes: {:?}", data); None @@ -55,27 +54,27 @@ mod tests { assert_eq!(fs.len(), 3); - let (time, lines, cursor) = &fs[0]; - let lines: Vec = lines.iter().map(|l| l.text()).collect(); + let (time, snapshot) = &fs[0]; + let lines: Vec = snapshot.lines.iter().map(|l| l.text()).collect(); assert_eq!(*time, 0.0); - assert_eq!(*cursor, Some((3, 0))); + assert_eq!(snapshot.cursor, Some((3, 0))); assert_eq!(lines[0], "foo "); assert_eq!(lines[1], " "); - let (time, lines, cursor) = &fs[1]; - let lines: Vec = lines.iter().map(|l| l.text()).collect(); + let (time, snapshot) = &fs[1]; + let lines: Vec = snapshot.lines.iter().map(|l| l.text()).collect(); assert_eq!(*time, 2.0); - assert_eq!(*cursor, Some((2, 1))); + assert_eq!(snapshot.cursor, Some((2, 1))); assert_eq!(lines[0], "foob"); assert_eq!(lines[1], "ar "); - let (time, lines, cursor) = &fs[2]; - let lines: Vec = lines.iter().map(|l| l.text()).collect(); + let (time, snapshot) = &fs[2]; + let lines: Vec = snapshot.lines.iter().map(|l| l.text()).collect(); assert_eq!(*time, 3.0); - assert_eq!(*cursor, Some((3, 1))); + assert_eq!(snapshot.cursor, Some((3, 1))); assert_eq!(lines[0], "foob"); assert_eq!(lines[1], "ar! "); } From 64659b711c40012c25f4d2bcf899d990c4af540a Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 27 May 2026 16:11:44 +0000 Subject: [PATCH 4/5] Use generated frames for GIF output --- src/events.rs | 116 ------------------------- src/frames.rs | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 33 +++----- src/output.rs | 128 ++++++++++++++++++++++++++++ src/vt.rs | 81 ------------------ 5 files changed, 369 insertions(+), 217 deletions(-) delete mode 100644 src/events.rs create mode 100644 src/frames.rs create mode 100644 src/output.rs delete mode 100644 src/vt.rs diff --git a/src/events.rs b/src/events.rs deleted file mode 100644 index ef38fa1..0000000 --- a/src/events.rs +++ /dev/null @@ -1,116 +0,0 @@ -use anyhow::Result; - -type OutputEvent = (f64, String); - -struct Batch -where - I: Iterator>, -{ - iter: I, - prev_time: f64, - prev_data: String, - max_frame_time: f64, -} - -impl>> Iterator for Batch { - type Item = Result; - - fn next(&mut self) -> Option { - match self.iter.next() { - Some(Ok((time, data))) => { - if time - self.prev_time < self.max_frame_time { - self.prev_data.push_str(&data); - - self.next() - } else if !self.prev_data.is_empty() || self.prev_time == 0.0 { - let prev_time = self.prev_time; - self.prev_time = time; - let prev_data = std::mem::replace(&mut self.prev_data, data); - - Some(Ok((prev_time, prev_data))) - } else { - self.prev_time = time; - self.prev_data = data; - - self.next() - } - } - - Some(Err(e)) => Some(Err(e)), - - None => { - if !self.prev_data.is_empty() { - let prev_time = self.prev_time; - let prev_data = std::mem::replace(&mut self.prev_data, "".to_owned()); - - Some(Ok((prev_time, prev_data))) - } else { - None - } - } - } - } -} - -pub fn batch( - iter: impl Iterator>, - fps_cap: u8, -) -> impl Iterator> { - Batch { - iter, - prev_data: "".to_owned(), - prev_time: 0.0, - max_frame_time: 1.0 / (fps_cap as f64), - } -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - - #[test] - fn batch() { - let stdout = [ - (0.0, "foo".to_owned()), - (1.0, "bar".to_owned()), - (2.0, "baz".to_owned()), - ]; - - let stdout = super::batch(stdout.into_iter().map(Ok), 30) - .collect::>>() - .unwrap(); - - assert_eq!(&stdout[0], &(0.0, "foo".to_owned())); - assert_eq!(&stdout[1], &(1.0, "bar".to_owned())); - assert_eq!(&stdout[2], &(2.0, "baz".to_owned())); - - let stdout = [ - (0.0, "foo".to_owned()), - (0.033, "bar".to_owned()), - (0.066, "baz".to_owned()), - (1.0, "qux".to_owned()), - ]; - - let stdout = super::batch(stdout.into_iter().map(Ok), 30) - .collect::>>() - .unwrap(); - - assert_eq!(&stdout[0], &(0.0, "foobar".to_owned())); - assert_eq!(&stdout[1], &(0.066, "baz".to_owned())); - assert_eq!(&stdout[2], &(1.0, "qux".to_owned())); - - let stdout = [ - (0.0, "".to_owned()), - (1.0, "foo".to_owned()), - (2.0, "bar".to_owned()), - ]; - - let stdout = super::batch(stdout.into_iter().map(Ok), 30) - .collect::>>() - .unwrap(); - - assert_eq!(&stdout[0], &(0.0, "".to_owned())); - assert_eq!(&stdout[1], &(1.0, "foo".to_owned())); - assert_eq!(&stdout[2], &(2.0, "bar".to_owned())); - } -} diff --git a/src/frames.rs b/src/frames.rs new file mode 100644 index 0000000..49b1bd8 --- /dev/null +++ b/src/frames.rs @@ -0,0 +1,228 @@ +//! Terminal frame generation. +//! +//! Frame production runs as a single forward pass over the adjusted event +//! timeline. A [`FrameEmitter`] is shown one candidate frame per event and +//! decides which frames end up in the output. + +use crate::asciicast::Event; +use crate::terminal::{self, Snapshot}; + +/// A terminal state at a point in time. Holds terminal cells, not rendered +/// pixels. +#[derive(Clone)] +pub struct Frame { + pub time: f64, + pub snapshot: Snapshot, +} + +impl Frame { + fn from_vt(time: f64, vt: &avt::Vt) -> Frame { + Frame { + time, + snapshot: Snapshot::from_vt(vt), + } + } + + pub fn same_visual(&self, other: &Frame) -> bool { + self.snapshot.same_visual(&other.snapshot) + } +} + +trait FrameEmitter { + fn emit(&mut self, frame: Frame, event: &Event) -> Vec; + + fn finish(&mut self) -> Vec { + Vec::new() + } +} + +/// Generate frames for a contiguous time range. `start`/`end` are absolute +/// timeline timestamps; `None` means open-ended. +pub fn from_range( + events: &[Event], + terminal_size: (usize, usize), + start: Option, + end: Option, +) -> Vec { + let mut vt = terminal::build(terminal_size); + let blank = Frame::from_vt(0.0, &vt); + + generate_with(&mut vt, events, RangeEmitter::new(start, end, blank)) +} + +/// Replay the timeline through `vt`, feeding the emitter one candidate frame +/// per event. Only output events mutate the terminal; marker and `Other` events +/// still produce a candidate frame so the emitter can detect crossings. +fn generate_with( + vt: &mut avt::Vt, + events: &[Event], + mut emitter: E, +) -> Vec { + let mut selected = Vec::new(); + + for event in events { + if let Event::Output { data, .. } = event { + terminal::feed_str(vt, data); + } + + let frame = Frame::from_vt(event.time(), vt); + selected.extend(emitter.emit(frame, event)); + } + + selected.extend(emitter.finish()); + + selected +} + +struct RangeEmitter { + start: f64, + end: Option, + /// Latest frame at or before `start`, the source for a synthetic range-start + /// frame. Initialized to the blank frame and updated while replay is still + /// before the range. + saved: Option, + started: bool, +} + +impl RangeEmitter { + fn new(start: Option, end: Option, blank: Frame) -> Self { + RangeEmitter { + start: start.unwrap_or(0.0), + end, + saved: Some(blank), + started: false, + } + } +} + +impl FrameEmitter for RangeEmitter { + fn emit(&mut self, frame: Frame, event: &Event) -> Vec { + let time = frame.time; + let mut out = Vec::new(); + + if !self.started { + if time < self.start { + self.saved = Some(frame); + return out; + } + + // First event at or past the range start: enter the range. Unless an + // output event lands exactly on the start, emit a synthetic frame + // carrying the pre-start terminal state, retimed to the start. + self.started = true; + let output_at_start = time == self.start && matches!(event, Event::Output { .. }); + + if !output_at_start { + if let Some(mut saved) = self.saved.take() { + saved.time = self.start; + out.push(saved); + } + } + } + + if self.end.is_none_or(|end| time <= end) && matches!(event, Event::Output { .. }) { + out.push(frame); + } + + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn output(time: f64, data: &str) -> Event { + Event::Output { + time, + data: data.to_owned(), + } + } + + fn marker(time: f64) -> Event { + Event::Marker { + time, + label: "m".to_owned(), + } + } + + fn times(frames: &[Frame]) -> Vec { + frames.iter().map(|f| f.time).collect() + } + + fn texts(frames: &[Frame]) -> Vec { + frames + .iter() + .map(|f| { + f.snapshot + .lines + .iter() + .map(|l| l.text()) + .collect::() + }) + .collect() + } + + #[test] + fn default_range_emits_blank_then_output_frames() { + let events = [output(0.5, "a"), output(1.0, "b")]; + let frames = from_range(&events, (4, 1), None, None); + + assert_eq!(times(&frames), vec![0.0, 0.5, 1.0]); + assert_eq!(texts(&frames), vec![" ", "a ", "ab "]); + } + + #[test] + fn range_ignores_marker_candidates() { + let events = [output(0.5, "a"), marker(0.7), output(1.0, "b")]; + let frames = from_range(&events, (4, 1), None, None); + + assert_eq!(times(&frames), vec![0.0, 0.5, 1.0]); + assert_eq!(texts(&frames), vec![" ", "a ", "ab "]); + } + + #[test] + fn open_start_range_synthesizes_start_frame_from_prior_state() { + let events = [output(3.0, "a"), output(8.0, "b")]; + let frames = from_range(&events, (4, 1), Some(5.0), None); + + assert_eq!(times(&frames), vec![5.0, 8.0]); + assert_eq!(texts(&frames), vec!["a ", "ab "]); + } + + #[test] + fn open_start_range_uses_blank_when_no_prior_event() { + let events = [output(8.0, "b")]; + let frames = from_range(&events, (4, 1), Some(5.0), None); + + assert_eq!(times(&frames), vec![5.0, 8.0]); + assert_eq!(texts(&frames), vec![" ", "b "]); + } + + #[test] + fn output_exactly_at_start_is_not_duplicated_by_a_synthetic_frame() { + let events = [output(1.0, "a"), output(2.0, "b"), output(3.0, "c")]; + let frames = from_range(&events, (4, 1), Some(2.0), Some(2.0)); + + assert_eq!(times(&frames), vec![2.0]); + assert_eq!(texts(&frames), vec!["ab "]); + } + + #[test] + fn startless_range_ending_at_zero_includes_output_at_zero() { + let events = [output(0.0, "a"), output(1.0, "b")]; + let frames = from_range(&events, (4, 1), None, Some(0.0)); + + assert_eq!(times(&frames), vec![0.0]); + assert_eq!(texts(&frames), vec!["a "]); + } + + #[test] + fn range_end_is_inclusive_for_exact_matches() { + let events = [output(1.0, "a"), output(2.0, "b"), output(3.0, "c")]; + let frames = from_range(&events, (4, 1), None, Some(2.0)); + + assert_eq!(times(&frames), vec![0.0, 1.0, 2.0]); + assert_eq!(texts(&frames), vec![" ", "a ", "ab "]); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7ee009b..b23fcb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,21 @@ mod asciicast; -mod events; mod fonts; +mod frames; +mod output; mod renderer; mod terminal; mod theme; mod timeline; -mod vt; use std::fmt::{Debug, Display}; use std::io::{BufRead, Write}; -use std::{iter, thread, time::Instant}; +use std::{thread, time::Instant}; use anyhow::{anyhow, Result}; use clap::ValueEnum; use log::{info, warn}; -use crate::asciicast::{Asciicast, Event}; +use crate::asciicast::Asciicast; pub const DEFAULT_BOLD_IS_BRIGHT: bool = false; pub const DEFAULT_TEXT_FONT_FAMILY: &str = @@ -164,18 +164,13 @@ pub fn run(input: I, output: O, config: Config) -> let events = timeline::limit_idle_time(events, itl); let events = timeline::accelerate(events, config.speed); + let events = events.collect::>>()?; - let events = events.filter_map(|event| match event { - Ok(Event::Output { time, data }) => Some(Ok((time, data))), - Ok(_) => None, - Err(e) => Some(Err(e)), - }); - - let events = iter::once(Ok((0.0, "".to_owned()))).chain(events); - let events = events::batch(events, config.fps_cap); - let events = events.collect::>(); - let count = events.len() as u64; - let frames = vt::frames(events.into_iter(), terminal_size); + let frames = frames::from_range(&events, terminal_size, None, None); + let frames = output::dedupe_visual_changes(frames); + let frames = output::adjust_timeline_timestamps(frames); + let frames = output::cap_fps(frames, config.fps_cap); + let count = frames.len() as u64; info!( "recording terminal size: {}x{}", @@ -266,11 +261,9 @@ pub fn run(input: I, output: O, config: Config) -> } }); - for (i, frame) in frames.enumerate() { - let (time, snapshot) = frame?; - let image = renderer.render(&snapshot); - let time = if i == 0 { 0.0 } else { time }; - collector.add_frame_rgba(i, image, time + config.last_frame_duration)?; + for (i, frame) in frames.into_iter().enumerate() { + let image = renderer.render(&frame.snapshot); + collector.add_frame_rgba(i, image, frame.time + config.last_frame_duration)?; } drop(collector); diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..907bfb6 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,128 @@ +//! Output frame preparation. + +use crate::frames::Frame; + +/// Drop frames whose terminal state matches the previously kept frame. Kept +/// frames keep their original timestamps, so the delay to the next change is +/// preserved. +pub fn dedupe_visual_changes(frames: Vec) -> Vec { + let mut out: Vec = Vec::new(); + + for frame in frames { + if out.last().is_none_or(|last| !last.same_visual(&frame)) { + out.push(frame); + } + } + + out +} + +/// Shift timestamps so the first selected frame starts at `0`, preserving the +/// spacing between later frames. +pub fn adjust_timeline_timestamps(mut frames: Vec) -> Vec { + if let Some(offset) = frames.first().map(|f| f.time) { + for frame in &mut frames { + frame.time -= offset; + } + } + + frames +} + +/// Reduce frames to at most one per `1/fps_cap` interval. Each window keeps the +/// latest terminal state, timestamped at the window's start. +pub fn cap_fps(frames: Vec, fps_cap: u8) -> Vec { + let max_frame_time = 1.0 / (fps_cap as f64); + let mut out = Vec::new(); + let mut window: Option = None; + + for frame in frames { + match &mut window { + None => window = Some(frame), + + Some(window_frame) if frame.time - window_frame.time < max_frame_time => { + window_frame.snapshot = frame.snapshot; + } + + Some(_) => out.push(window.replace(frame).unwrap()), + } + } + + out.extend(window); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::Snapshot; + + /// A frame tagged via its cursor, for time/order assertions where terminal + /// content is irrelevant. + fn tagged(time: f64, tag: usize) -> Frame { + Frame { + time, + snapshot: Snapshot { + lines: Vec::new(), + cursor: Some((tag, 0)), + }, + } + } + + fn times(frames: &[Frame]) -> Vec { + frames.iter().map(|f| f.time).collect() + } + + fn tags(frames: &[Frame]) -> Vec { + frames + .iter() + .map(|f| f.snapshot.cursor.unwrap().0) + .collect() + } + + #[test] + fn dedupe_keeps_first_of_each_visual_run() { + let frames = vec![ + tagged(0.0, 0), + tagged(1.0, 0), + tagged(2.0, 1), + tagged(3.0, 1), + ]; + let frames = dedupe_visual_changes(frames); + + assert_eq!(times(&frames), vec![0.0, 2.0]); + assert_eq!(tags(&frames), vec![0, 1]); + } + + #[test] + fn timeline_adjustment_subtracts_first_timestamp() { + let frames = vec![tagged(5.0, 0), tagged(8.0, 1), tagged(10.0, 2)]; + let frames = adjust_timeline_timestamps(frames); + + assert_eq!(times(&frames), vec![0.0, 3.0, 5.0]); + } + + #[test] + fn cap_fps_keeps_latest_state_per_interval_at_window_start() { + let frames = vec![ + tagged(0.0, 0), + tagged(0.033, 1), + tagged(0.066, 2), + tagged(1.0, 3), + ]; + let frames = cap_fps(frames, 30); + + assert_eq!(times(&frames), vec![0.0, 0.066, 1.0]); + assert_eq!(tags(&frames), vec![1, 2, 3]); + } + + #[test] + fn cap_fps_keeps_widely_spaced_frames() { + let frames = vec![tagged(0.0, 0), tagged(1.0, 1), tagged(2.0, 2)]; + let frames = cap_fps(frames, 30); + + assert_eq!(times(&frames), vec![0.0, 1.0, 2.0]); + assert_eq!(tags(&frames), vec![0, 1, 2]); + } +} diff --git a/src/vt.rs b/src/vt.rs deleted file mode 100644 index 5efdeec..0000000 --- a/src/vt.rs +++ /dev/null @@ -1,81 +0,0 @@ -use anyhow::Result; -use log::debug; - -use crate::terminal::{self, Snapshot}; - -type Frame = (f64, Snapshot); - -pub fn frames( - stdout: impl Iterator>, - terminal_size: (usize, usize), -) -> impl Iterator> { - let mut vt = terminal::build(terminal_size); - let mut prev_snapshot: Option = None; - - stdout.filter_map(move |event| { - event - .map(|(time, data)| { - terminal::feed_str(&mut vt, &data); - let snapshot = Snapshot::from_vt(&vt); - - if prev_snapshot - .as_ref() - .is_none_or(|prev| !prev.same_visual(&snapshot)) - { - prev_snapshot = Some(snapshot.clone()); - - Some((time, snapshot)) - } else { - debug!("skipping frame with no visual changes: {:?}", data); - - None - } - }) - .transpose() - }) -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - - #[test] - fn frames() { - let stdout = [ - (0.0, "foo".to_owned()), - (1.0, "\x1b[0m".to_owned()), - (2.0, "bar".to_owned()), - (3.0, "!".to_owned()), - ]; - - let fs = super::frames(stdout.into_iter().map(Ok), (4, 2)) - .collect::>>() - .unwrap(); - - assert_eq!(fs.len(), 3); - - let (time, snapshot) = &fs[0]; - let lines: Vec = snapshot.lines.iter().map(|l| l.text()).collect(); - - assert_eq!(*time, 0.0); - assert_eq!(snapshot.cursor, Some((3, 0))); - assert_eq!(lines[0], "foo "); - assert_eq!(lines[1], " "); - - let (time, snapshot) = &fs[1]; - let lines: Vec = snapshot.lines.iter().map(|l| l.text()).collect(); - - assert_eq!(*time, 2.0); - assert_eq!(snapshot.cursor, Some((2, 1))); - assert_eq!(lines[0], "foob"); - assert_eq!(lines[1], "ar "); - - let (time, snapshot) = &fs[2]; - let lines: Vec = snapshot.lines.iter().map(|l| l.text()).collect(); - - assert_eq!(*time, 3.0); - assert_eq!(snapshot.cursor, Some((3, 1))); - assert_eq!(lines[0], "foob"); - assert_eq!(lines[1], "ar! "); - } -} From fede4e6609749699f913b90e1170fdbd43e1627f Mon Sep 17 00:00:00 2001 From: Marcin Kulik Date: Wed, 27 May 2026 16:12:09 +0000 Subject: [PATCH 5/5] Add --select frame selection --- README.md | 2 + src/frames.rs | 105 +++++++++++ src/lib.rs | 30 +++- src/main.rs | 72 ++++++++ src/output.rs | 26 +++ src/selection.rs | 306 ++++++++++++++++++++++++++++++++ src/selection/spec.rs | 393 ++++++++++++++++++++++++++++++++++++++++++ src/timeline.rs | 75 ++++++++ 8 files changed, 1005 insertions(+), 4 deletions(-) create mode 100644 src/selection.rs create mode 100644 src/selection/spec.rs diff --git a/README.md b/README.md index 1eed978..0cd51aa 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Notable features: - two selectable rendering backends: `swash` (default) and `resvg`, - adjustable playback speed, - idle time limiting to skip periods of inactivity, +- frame selection by time ranges, discrete positions, markers, percentages, and + event indexes, - looped or single-pass playback, - configurable FPS cap and last-frame duration, - terminal size override (cols/rows) for re-rendering at a different geometry. diff --git a/src/frames.rs b/src/frames.rs index 49b1bd8..8f151a6 100644 --- a/src/frames.rs +++ b/src/frames.rs @@ -50,6 +50,19 @@ pub fn from_range( generate_with(&mut vt, events, RangeEmitter::new(start, end, blank)) } +/// Generate terminal states at each resolved timestamp, using player seek +/// semantics. `positions` must be sorted ascending and deduplicated. +pub fn at_positions( + events: &[Event], + terminal_size: (usize, usize), + positions: Vec, +) -> Vec { + let mut vt = terminal::build(terminal_size); + let blank = Frame::from_vt(0.0, &vt); + + generate_with(&mut vt, events, PositionEmitter::new(positions, blank)) +} + /// Replay the timeline through `vt`, feeding the emitter one candidate frame /// per event. Only output events mutate the terminal; marker and `Other` events /// still produce a candidate frame so the emitter can detect crossings. @@ -128,6 +141,55 @@ impl FrameEmitter for RangeEmitter { } } +struct PositionEmitter { + positions: Vec, + next: usize, + /// Terminal state after the last processed event, the source for the next + /// crossed position. Initialized to the blank frame. + last: Frame, +} + +impl PositionEmitter { + fn new(positions: Vec, blank: Frame) -> Self { + PositionEmitter { + positions, + next: 0, + last: blank, + } + } + + /// Emit every pending position strictly before `boundary`, each carrying the + /// current `last` state retimestamped to the requested position. + fn emit_before(&mut self, boundary: f64, out: &mut Vec) { + while self.next < self.positions.len() && self.positions[self.next] < boundary { + let mut frame = self.last.clone(); + frame.time = self.positions[self.next]; + out.push(frame); + self.next += 1; + } + } +} + +impl FrameEmitter for PositionEmitter { + fn emit(&mut self, frame: Frame, _event: &Event) -> Vec { + let mut out = Vec::new(); + + // Positions before this event's time are now final; events sharing a + // position's timestamp are applied first, so the boundary is exclusive. + self.emit_before(frame.time, &mut out); + self.last = frame; + + out + } + + fn finish(&mut self) -> Vec { + let mut out = Vec::new(); + self.emit_before(f64::INFINITY, &mut out); + + out + } +} + #[cfg(test)] mod tests { use super::*; @@ -225,4 +287,47 @@ mod tests { assert_eq!(times(&frames), vec![0.0, 1.0, 2.0]); assert_eq!(texts(&frames), vec![" ", "a ", "ab "]); } + + #[test] + fn positions_capture_seek_state_at_each_timestamp() { + let events = [output(1.0, "a"), output(2.0, "b"), output(3.0, "c")]; + + let frames = at_positions(&events, (4, 1), vec![2.0]); + assert_eq!(times(&frames), vec![2.0]); + assert_eq!(texts(&frames), vec!["ab "]); + + let frames = at_positions(&events, (4, 1), vec![3.0]); + assert_eq!(texts(&frames), vec!["abc "]); + } + + #[test] + fn position_before_first_event_emits_blank() { + let events = [output(1.0, "a")]; + let frames = at_positions(&events, (4, 1), vec![0.5]); + + assert_eq!(times(&frames), vec![0.5]); + assert_eq!(texts(&frames), vec![" "]); + } + + #[test] + fn one_event_crossing_multiple_positions_shares_state() { + let events = [output(1.0, "a"), output(3.0, "b")]; + let frames = at_positions(&events, (4, 1), vec![1.5, 2.5]); + + assert_eq!(times(&frames), vec![1.5, 2.5]); + assert_eq!(texts(&frames), vec!["a ", "a "]); + } + + #[test] + fn exact_match_applies_all_events_at_that_timestamp() { + let events = [ + output(1.0, "a"), + output(2.0, "b"), + output(2.0, "c"), + output(5.0, "d"), + ]; + let frames = at_positions(&events, (4, 1), vec![2.0]); + + assert_eq!(texts(&frames), vec!["abc "]); + } } diff --git a/src/lib.rs b/src/lib.rs index b23fcb4..655e992 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ mod fonts; mod frames; mod output; mod renderer; +mod selection; mod terminal; mod theme; mod timeline; @@ -17,6 +18,8 @@ use log::{info, warn}; use crate::asciicast::Asciicast; +pub use crate::selection::SelectionSpec; + pub const DEFAULT_BOLD_IS_BRIGHT: bool = false; pub const DEFAULT_TEXT_FONT_FAMILY: &str = "JetBrains Mono,Fira Code,SF Mono,Menlo,Consolas,DejaVu Sans Mono,Liberation Mono"; @@ -44,6 +47,7 @@ pub struct Config { pub no_loop: bool, pub renderer: Renderer, pub rows: Option, + pub selection: SelectionSpec, pub speed: f64, pub text_font_family: String, pub theme: Option, @@ -66,6 +70,7 @@ impl Default for Config { no_loop: DEFAULT_NO_LOOP, renderer: Default::default(), rows: None, + selection: SelectionSpec::default(), speed: DEFAULT_SPEED, text_font_family: String::from(DEFAULT_TEXT_FONT_FAMILY), theme: Default::default(), @@ -166,10 +171,27 @@ pub fn run(input: I, output: O, config: Config) -> let events = timeline::accelerate(events, config.speed); let events = events.collect::>>()?; - let frames = frames::from_range(&events, terminal_size, None, None); - let frames = output::dedupe_visual_changes(frames); - let frames = output::adjust_timeline_timestamps(frames); - let frames = output::cap_fps(frames, config.fps_cap); + let summary = timeline::Summary::from_events(&events); + let plan = selection::resolve(&config.selection, &summary)?; + + let frames = match plan { + // Range selections produce time-based animation frames: dedupe duplicate + // states, normalize the first frame to t=0, then cap FPS. + selection::SelectionPlan::Range { start, end } => { + let frames = frames::from_range(&events, terminal_size, start, end); + let frames = output::dedupe_visual_changes(frames); + let frames = output::adjust_timeline_timestamps(frames); + output::cap_fps(frames, config.fps_cap) + } + + // Discrete selections: keep every resolved position, with no visual + // dedupe or FPS capping, spaced by a fixed per-frame duration. + selection::SelectionPlan::Positions(positions) => { + let frames = frames::at_positions(&events, terminal_size, positions); + output::adjust_discrete_timestamps(frames, config.last_frame_duration) + } + }; + let count = frames.len() as u64; info!( diff --git a/src/main.rs b/src/main.rs index d11f194..3322c7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,50 @@ impl clap::builder::TypedValueParser for ThemeValueParser { } } +#[derive(Clone)] +pub struct SelectValueParser; + +impl clap::builder::TypedValueParser for SelectValueParser { + type Value = agg::SelectionSpec; + + fn parse_ref( + &self, + cmd: &clap::Command, + _arg: Option<&clap::Arg>, + value: &std::ffi::OsStr, + ) -> Result { + value + .to_string_lossy() + .parse::() + .map_err(|msg| { + cmd.clone() + .error(clap::error::ErrorKind::ValueValidation, msg) + }) + } +} + +const SELECT_LONG_HELP: &str = "\ +Select frames to render. + +A selector is one of: + .., POS.., ..POS, POS..POS time range + POS, POS,POS,... discrete positions + markers all marker positions + +POS may be: + 12.5, 12.5s, 1m20s, 1:20 time + 50% percent of adjusted duration + marker:build, marker:3 marker label prefix or 0-based marker index + event:100 0-based event index + +Times are on the adjusted output timeline, after --idle-time-limit and --speed. +The `markers` selector is standalone; use `marker: