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/asciicast.rs b/src/asciicast.rs index a2c3757..e1dcafc 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,34 @@ 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 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 { 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 deleted file mode 100644 index 08ae6ea..0000000 --- a/src/events.rs +++ /dev/null @@ -1,184 +0,0 @@ -use anyhow::Result; - -use crate::asciicast::OutputEvent; - -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), - } -} - -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 = [ - (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())); - } - - #[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/frames.rs b/src/frames.rs new file mode 100644 index 0000000..8f151a6 --- /dev/null +++ b/src/frames.rs @@ -0,0 +1,333 @@ +//! 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)) +} + +/// 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. +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 + } +} + +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::*; + + 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 "]); + } + + #[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 dc2940a..655e992 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,16 @@ mod asciicast; -mod events; mod fonts; +mod frames; +mod output; mod renderer; +mod selection; +mod terminal; mod theme; -mod vt; +mod timeline; 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; @@ -15,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"; @@ -42,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, @@ -64,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(), @@ -160,13 +167,32 @@ pub fn run(input: I, output: O, config: Config) -> .or(header.idle_time_limit) .unwrap_or(DEFAULT_IDLE_TIME_LIMIT); - 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; - let frames = vt::frames(events.into_iter(), terminal_size); + let events = timeline::limit_idle_time(events, itl); + let events = timeline::accelerate(events, config.speed); + let events = events.collect::>>()?; + + 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!( "recording terminal size: {}x{}", @@ -257,11 +283,9 @@ 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 = 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/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: