Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 29 additions & 2 deletions src/asciicast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::theme::Theme;

pub struct Asciicast<'a> {
pub header: Header,
pub events: Box<dyn Iterator<Item = Result<OutputEvent>> + 'a>,
pub events: Box<dyn Iterator<Item = Result<Event>> + 'a>,
}

pub struct Header {
Expand All @@ -20,7 +20,34 @@ pub struct Header {
pub idle_time_limit: Option<f64>,
}

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 {
Expand Down
42 changes: 33 additions & 9 deletions src/asciicast/v1.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use anyhow::{bail, Result};
use serde::Deserialize;

use super::{Asciicast, Header};
use super::{Asciicast, Event, Header};

#[derive(Deserialize)]
struct V1 {
Expand Down Expand Up @@ -34,7 +34,10 @@ pub fn load(json: String) -> Result<Asciicast<'static>> {
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 })
Expand Down Expand Up @@ -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()
},
]
);
}
Expand All @@ -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()
},
]
);
}
Expand Down
135 changes: 123 additions & 12 deletions src/asciicast/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -35,7 +35,7 @@ struct V2Event {
time: f64,
#[serde(deserialize_with = "deserialize_code")]
code: V2EventCode,
data: String,
data: serde_json::Value,
}

#[derive(PartialEq, Debug)]
Expand All @@ -47,6 +47,33 @@ enum V2EventCode {
Other(char),
}

impl V2EventCode {
fn into_event(self, time: f64, data: serde_json::Value) -> Result<Event> {
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<String> {
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<Parser> {
Expand Down Expand Up @@ -76,30 +103,24 @@ impl Parser {
}
}

fn parse_line(line: io::Result<String>) -> Option<Result<(f64, String)>> {
fn parse_line(line: io::Result<String>) -> Option<Result<Event>> {
match line {
Ok(line) => {
if line.is_empty() {
None
} else {
parse_event(line).transpose()
Some(parse_event(line))
}
}

Err(e) => Some(Err(e.into())),
}
}

fn parse_event(line: String) -> Result<Option<(f64, String)>> {
fn parse_event(line: String) -> Result<Event> {
let event = serde_json::from_str::<V2Event>(&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<V2EventCode, D::Error>
Expand Down Expand Up @@ -174,6 +195,96 @@ impl From<&V2Theme> for Theme {
mod tests {
use super::*;

fn ok_lines(lines: Vec<&str>) -> Vec<io::Result<String>> {
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::<Result<Vec<_>>>()
.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::<Result<Vec<_>>>()
.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::<Result<Vec<_>>>();

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::<Result<Vec<_>>>();

assert!(result.is_err());
}

fn header_with_palette(colors: &[&str]) -> String {
let palette = colors.join(":");

Expand Down
Loading