#![allow(deprecated)]
use std::ops::Range;
use std::path::Path;
use std::time::Duration;
use super::encoders::Quality;
use super::stream::{FrameCapture, VideoConfig, VideoStream};
use super::tick::{Tick, TickGenerator};
use crate::core::{Plot, Result};
pub trait IntoFrameCount {
fn into_frame_count(self, framerate: u32) -> usize;
}
impl IntoFrameCount for usize {
#[inline]
fn into_frame_count(self, _framerate: u32) -> usize {
self
}
}
impl IntoFrameCount for u32 {
#[inline]
fn into_frame_count(self, _framerate: u32) -> usize {
self as usize
}
}
impl IntoFrameCount for i32 {
#[inline]
fn into_frame_count(self, _framerate: u32) -> usize {
self.max(0) as usize
}
}
impl IntoFrameCount for Range<usize> {
#[inline]
fn into_frame_count(self, _framerate: u32) -> usize {
self.len()
}
}
impl IntoFrameCount for Duration {
#[inline]
fn into_frame_count(self, framerate: u32) -> usize {
(self.as_secs_f64() * framerate as f64).ceil() as usize
}
}
pub trait DurationExt {
fn secs(self) -> Duration;
}
impl DurationExt for f64 {
#[inline]
fn secs(self) -> Duration {
Duration::from_secs_f64(self)
}
}
impl DurationExt for f32 {
#[inline]
fn secs(self) -> Duration {
Duration::from_secs_f64(self as f64)
}
}
impl DurationExt for u64 {
#[inline]
fn secs(self) -> Duration {
Duration::from_secs(self)
}
}
impl DurationExt for i32 {
#[inline]
fn secs(self) -> Duration {
Duration::from_secs(self.max(0) as u64)
}
}
pub const DEFAULT_FIGURE_WIDTH: f32 = 6.4;
pub const DEFAULT_FIGURE_HEIGHT: f32 = 4.8;
#[derive(Clone, Debug)]
pub struct RecordConfig {
pub width: u32,
pub height: u32,
pub framerate: u32,
pub quality: Quality,
pub progress: bool,
pub update_limits: bool,
pub preserve_figure: bool,
pub figure_width: f32,
pub figure_height: f32,
}
impl Default for RecordConfig {
fn default() -> Self {
Self {
width: 800,
height: 600,
framerate: 30,
quality: Quality::Medium,
progress: false,
update_limits: false,
preserve_figure: false,
figure_width: DEFAULT_FIGURE_WIDTH,
figure_height: DEFAULT_FIGURE_HEIGHT,
}
}
}
impl RecordConfig {
pub fn new() -> Self {
Self::default()
}
pub fn dimensions(mut self, width: u32, height: u32) -> Self {
self.width = width;
self.height = height;
self
}
pub fn framerate(mut self, fps: u32) -> Self {
self.framerate = fps;
self
}
pub fn quality(mut self, quality: Quality) -> Self {
self.quality = quality;
self
}
pub fn with_progress(mut self) -> Self {
self.progress = true;
self
}
pub fn with_auto_limits(mut self) -> Self {
self.update_limits = true;
self
}
pub fn max_resolution(mut self, max_width: u32, max_height: u32) -> Self {
let aspect = self.figure_width / self.figure_height;
let by_width = (max_width, (max_width as f32 / aspect).round() as u32);
let by_height = ((max_height as f32 * aspect).round() as u32, max_height);
let (width, height) = if by_width.1 <= max_height {
by_width } else {
by_height };
self.width = width;
self.height = height;
self.preserve_figure = true;
self
}
pub fn preserve_figure_size(mut self) -> Self {
self.preserve_figure = true;
self
}
pub fn figure_size(mut self, width: f32, height: f32) -> Self {
self.figure_width = width.max(1.0);
self.figure_height = height.max(1.0);
self
}
fn calculate_dpi(&self) -> u32 {
(self.width as f32 / self.figure_width)
.max(self.height as f32 / self.figure_height)
.ceil() as u32
}
fn actual_dimensions(&self) -> (u32, u32) {
if self.preserve_figure {
let dpi = self.calculate_dpi() as f32;
let actual_width = (self.figure_width * dpi) as u32;
let actual_height = (self.figure_height * dpi) as u32;
(actual_width, actual_height)
} else {
(self.width, self.height)
}
}
fn figure_params(&self) -> Option<(f32, f32, u32)> {
if self.preserve_figure {
Some((self.figure_width, self.figure_height, self.calculate_dpi()))
} else {
None
}
}
fn to_video_config(&self) -> VideoConfig {
let (width, height) = self.actual_dimensions();
VideoConfig {
width,
height,
framerate: self.framerate,
quality: self.quality,
..Default::default()
}
}
}
#[deprecated(
since = "0.9.0",
note = "Use the record! macro instead: `record!(path, frames, |t| plot)`"
)]
pub fn record<P, I, F, R>(path: P, frames: I, mut frame_fn: F) -> Result<()>
where
P: AsRef<Path>,
I: IntoIterator,
F: FnMut(I::Item, &Tick) -> R,
R: Into<Plot>,
{
let config = RecordConfig::default();
record_with_config(path, frames, config, frame_fn)
}
#[deprecated(
since = "0.9.0",
note = "Use the record! macro instead: `record!(path, frames, config: cfg, |t| plot)`"
)]
pub fn record_with_config<P, I, F, R>(
path: P,
frames: I,
config: RecordConfig,
mut frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
I: IntoIterator,
F: FnMut(I::Item, &Tick) -> R,
R: Into<Plot>,
{
let video_config = config.to_video_config();
let mut stream = VideoStream::new(&path, video_config)?;
let (actual_width, actual_height) = config.actual_dimensions();
let mut capture = FrameCapture::new(actual_width, actual_height);
let mut ticker = TickGenerator::new(config.framerate as f64);
let figure_size = config.figure_params();
for item in frames {
let tick = ticker.tick_immediate();
let plot: Plot = frame_fn(item, &tick).into();
let frame_data = capture.capture_with_figure(&plot, figure_size)?;
stream.record_frame(frame_data, &tick)?;
}
stream.save()
}
#[deprecated(
since = "0.9.0",
note = "Use the record! macro instead: `record!(path, duration secs, |t| plot)`"
)]
pub fn record_duration<P, F, R>(
path: P,
duration_secs: f64,
framerate: u32,
mut frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let total_frames = (duration_secs * framerate as f64).ceil() as usize;
record(path, 0..total_frames, |_, tick| frame_fn(tick))
}
#[deprecated(
since = "0.9.0",
note = "Use the record! macro instead: `record!(path, duration secs, config: cfg, |t| plot)`"
)]
pub fn record_duration_with_config<P, F, R>(
path: P,
duration_secs: f64,
config: RecordConfig,
mut frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let total_frames = (duration_secs * config.framerate as f64).ceil() as usize;
record_with_config(path, 0..total_frames, config, |_, tick| frame_fn(tick))
}
#[deprecated(
since = "0.9.0",
note = "Use record_simple() or the record! macro with Signal-based animations instead"
)]
pub fn record_animated<'a, P, F, R>(
path: P,
animations: &super::observable_ext::AnimationGroup<'a>,
max_frames: usize,
frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let config = RecordConfig::default();
record_animated_with_config(path, animations, max_frames, config, frame_fn)
}
#[deprecated(
since = "0.9.0",
note = "Use record_simple_with_config() or the record! macro with Signal-based animations instead"
)]
pub fn record_animated_with_config<'a, P, F, R>(
path: P,
animations: &super::observable_ext::AnimationGroup<'a>,
max_frames: usize,
config: RecordConfig,
mut frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let video_config = config.to_video_config();
let mut stream = VideoStream::new(&path, video_config)?;
let (actual_width, actual_height) = config.actual_dimensions();
let mut capture = FrameCapture::new(actual_width, actual_height);
let mut ticker = TickGenerator::new(config.framerate as f64);
let figure_size = config.figure_params();
let delta_time = 1.0 / config.framerate as f64;
let mut frame_count = 0;
loop {
if frame_count >= max_frames {
break;
}
let still_animating = animations.tick(delta_time);
let tick = ticker.tick_immediate();
let plot: Plot = frame_fn(&tick).into();
let frame_data = capture.capture_with_figure(&plot, figure_size)?;
stream.record_frame(frame_data, &tick)?;
frame_count += 1;
if !still_animating {
break;
}
}
stream.save()
}
#[deprecated(
since = "0.9.0",
note = "Use the record! macro instead: `record!(path, duration secs, |t| plot)`"
)]
pub fn record_simple<P, D, F, R>(path: P, frames: D, mut frame_fn: F) -> Result<()>
where
P: AsRef<Path>,
D: IntoFrameCount,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let config = RecordConfig::default();
record_simple_with_config(path, frames, config, frame_fn)
}
#[deprecated(
since = "0.9.0",
note = "Use the record! macro instead: `record!(path, duration secs, config: cfg, |t| plot)`"
)]
pub fn record_simple_with_config<P, D, F, R>(
path: P,
frames: D,
config: RecordConfig,
mut frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
D: IntoFrameCount,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let total_frames = frames.into_frame_count(config.framerate);
let video_config = config.to_video_config();
let mut stream = VideoStream::new(&path, video_config)?;
let (actual_width, actual_height) = config.actual_dimensions();
let mut capture = FrameCapture::new(actual_width, actual_height);
let mut ticker = TickGenerator::new(config.framerate as f64);
let figure_size = config.figure_params();
for _ in 0..total_frames {
let tick = ticker.tick_immediate();
let plot: Plot = frame_fn(&tick).into();
let frame_data = capture.capture_with_figure(&plot, figure_size)?;
stream.record_frame(frame_data, &tick)?;
}
stream.save()
}
pub fn record_plot<P: AsRef<Path>>(path: P, plot: &Plot, duration: f64, fps: u32) -> Result<()> {
let config = RecordConfig::default().framerate(fps);
record_plot_with_config(path, plot, duration, config)
}
pub fn record_plot_with_config<P: AsRef<Path>>(
path: P,
plot: &Plot,
duration: f64,
config: RecordConfig,
) -> Result<()> {
let frame_count = (duration * config.framerate as f64).ceil() as usize;
if frame_count == 0 {
return Err(crate::core::PlottingError::InvalidInput(
"Duration too short for any frames".to_string(),
));
}
let frame_duration = 1.0 / config.framerate as f64;
let video_config = config.to_video_config();
let mut stream = VideoStream::new(&path, video_config)?;
let (width, height) = (config.width, config.height);
let mut capture = FrameCapture::new(width, height);
let mut ticker = TickGenerator::new(config.framerate as f64);
for frame in 0..frame_count {
let time = frame as f64 * frame_duration;
let tick = ticker.tick_immediate();
let sized_plot = frame_plot_for_config(plot, &config);
let image = sized_plot.render_at(time)?;
stream.record_frame_sized(&image.pixels, width, height, &tick)?;
}
stream.save()
}
fn frame_plot_for_config(plot: &Plot, config: &RecordConfig) -> Plot {
let (width, height) = (config.width, config.height);
if config.preserve_figure {
let dpi = (width as f32 / config.figure_width).max(height as f32 / config.figure_height);
plot.clone()
.size(config.figure_width, config.figure_height)
.dpi(dpi as u32)
} else {
plot.clone().set_output_pixels(width, height)
}
}
#[doc(hidden)]
pub fn _record_frames<P, F, R>(path: P, frames: impl IntoFrameCount, frame_fn: F) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
record_simple(path, frames, frame_fn)
}
#[doc(hidden)]
pub fn _record_duration<P, F, R>(path: P, secs: f64, frame_fn: F) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let config = RecordConfig::default();
let frames = (secs * config.framerate as f64).ceil() as usize;
record_simple(path, frames, frame_fn)
}
#[doc(hidden)]
pub fn _record_duration_fps<P, F, R>(path: P, secs: f64, fps: u32, frame_fn: F) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
let config = RecordConfig::default().framerate(fps);
let frames = (secs * fps as f64).ceil() as usize;
record_simple_with_config(path, frames, config, frame_fn)
}
#[doc(hidden)]
pub fn _record_frames_config<P, F, R>(
path: P,
frames: impl IntoFrameCount,
config: RecordConfig,
frame_fn: F,
) -> Result<()>
where
P: AsRef<Path>,
F: FnMut(&Tick) -> R,
R: Into<Plot>,
{
record_simple_with_config(path, frames, config, frame_fn)
}
#[doc(hidden)]
pub fn _record_reactive<P: AsRef<Path>>(path: P, plot: &Plot, secs: f64, fps: u32) -> Result<()> {
record_plot(path, plot, secs, fps)
}
#[doc(hidden)]
pub fn _record_reactive_config<P: AsRef<Path>>(
path: P,
plot: &Plot,
secs: f64,
config: RecordConfig,
) -> Result<()> {
record_plot_with_config(path, plot, secs, config)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_record_config_default() {
let config = RecordConfig::default();
assert_eq!(config.width, 800);
assert_eq!(config.height, 600);
assert_eq!(config.framerate, 30);
}
#[test]
fn test_record_config_builder() {
let config = RecordConfig::new()
.dimensions(1920, 1080)
.framerate(60)
.quality(Quality::High)
.with_progress()
.with_auto_limits();
assert_eq!(config.width, 1920);
assert_eq!(config.height, 1080);
assert_eq!(config.framerate, 60);
assert!(config.progress);
assert!(config.update_limits);
}
#[test]
fn test_record_config_max_resolution_height_constrained() {
let config = RecordConfig::new().max_resolution(1920, 1080);
assert_eq!(config.width, 1440);
assert_eq!(config.height, 1080);
assert!(config.preserve_figure);
}
#[test]
fn test_record_config_max_resolution_width_constrained() {
let config = RecordConfig::new().max_resolution(800, 800);
assert_eq!(config.width, 800);
assert_eq!(config.height, 600);
assert!(config.preserve_figure);
}
#[test]
fn test_record_config_max_resolution_exact_fit() {
let config = RecordConfig::new().max_resolution(1024, 768);
assert_eq!(config.width, 1024);
assert_eq!(config.height, 768);
assert!(config.preserve_figure);
}
#[test]
fn test_record_config_max_resolution_custom_figure() {
let config = RecordConfig::new()
.figure_size(16.0, 9.0)
.max_resolution(1920, 1080);
assert_eq!(config.width, 1920);
assert_eq!(config.height, 1080);
assert!(config.preserve_figure);
}
#[test]
fn test_frame_plot_for_config_uses_plot_dpi_when_not_preserving_figure() {
let plot = crate::core::Plot::new().dpi(300);
let config = RecordConfig::new().dimensions(800, 600);
let sized_plot = frame_plot_for_config(&plot, &config);
let sized_config = sized_plot.get_config();
assert_eq!(sized_config.canvas_size(), (800, 600));
assert!((sized_config.figure.width - (800.0 / 300.0)).abs() < 0.001);
assert!((sized_config.figure.height - (600.0 / 300.0)).abs() < 0.001);
assert!((sized_config.figure.dpi - 300.0).abs() < 0.001);
}
#[test]
fn test_record_config_preserve_figure_size() {
let config = RecordConfig::new()
.dimensions(1920, 1080)
.preserve_figure_size();
assert_eq!(config.width, 1920);
assert_eq!(config.height, 1080);
assert!(config.preserve_figure);
assert!((config.figure_width - 6.4).abs() < 0.001);
assert!((config.figure_height - 4.8).abs() < 0.001);
}
#[test]
fn test_record_config_figure_size() {
let config = RecordConfig::new().figure_size(16.0, 9.0);
assert!((config.figure_width - 16.0).abs() < 0.001);
assert!((config.figure_height - 9.0).abs() < 0.001);
}
#[test]
fn test_frame_plot_for_config_preserve_figure_uses_derived_dpi() {
let plot = Plot::new().line(&[0.0, 1.0], &[1.0, 2.0]).into();
let config = RecordConfig::new()
.dimensions(1920, 1080)
.preserve_figure_size();
let sized_plot = frame_plot_for_config(&plot, &config);
assert!((sized_plot.get_config().figure.width - config.figure_width).abs() < 0.001);
assert!((sized_plot.get_config().figure.height - config.figure_height).abs() < 0.001);
assert!((sized_plot.get_config().figure.dpi - 300.0).abs() < 1.0);
}
#[test]
fn test_frame_plot_for_config_non_preserve_reuses_plot_dpi() {
let plot = Plot::new()
.size(6.4, 4.8)
.dpi(200)
.line(&[0.0, 1.0], &[1.0, 2.0])
.into();
let config = RecordConfig::new().dimensions(800, 600);
let sized_plot = frame_plot_for_config(&plot, &config);
assert!((sized_plot.get_config().figure.dpi - 200.0).abs() < f32::EPSILON);
assert!((sized_plot.get_config().figure.width - 4.0).abs() < f32::EPSILON);
assert!((sized_plot.get_config().figure.height - 3.0).abs() < f32::EPSILON);
}
#[test]
fn test_record_basic() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.gif");
let result = record(&path, 0..3, |frame, _tick| {
#[allow(deprecated)]
Plot::new()
.line(&[0.0, 1.0, 2.0], &[0.0, 1.0, 0.5])
.end_series()
.title(format!("Frame {}", frame))
});
assert!(result.is_ok(), "Recording failed: {:?}", result.err());
assert!(path.exists(), "Output file not created");
}
#[test]
fn test_record_with_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("test_config.gif");
let config = RecordConfig::new()
.dimensions(200, 150)
.framerate(10)
.quality(Quality::Low);
let result = record_with_config(&path, 0..2, config, |_, _| {
#[allow(deprecated)]
Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).end_series()
});
assert!(result.is_ok());
assert!(path.exists());
}
#[test]
fn test_record_duration() {
let dir = tempdir().unwrap();
let path = dir.path().join("test_duration.gif");
let result = record_duration(&path, 0.1, 10, |tick| {
#[allow(deprecated)]
Plot::new()
.line(&[0.0, tick.time], &[0.0, tick.time])
.end_series()
});
assert!(result.is_ok());
assert!(path.exists());
}
#[test]
fn test_record_empty_frames() {
let dir = tempdir().unwrap();
let path = dir.path().join("test_empty.gif");
let result = record(&path, 0..0, |_, _| {
#[allow(deprecated)]
Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).end_series()
});
assert!(result.is_err());
}
#[test]
fn test_tick_values_in_record() {
let dir = tempdir().unwrap();
let path = dir.path().join("test_ticks.gif");
let mut observed_ticks = Vec::new();
let _ = record(&path, 0..5, |frame, tick| {
observed_ticks.push((frame, tick.count, tick.time));
#[allow(deprecated)]
Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).end_series()
});
assert_eq!(observed_ticks.len(), 5);
for (i, (frame, count, _time)) in observed_ticks.iter().enumerate() {
assert_eq!(*frame, i);
assert_eq!(*count, i as u64);
}
}
#[test]
fn test_into_frame_count_usize() {
assert_eq!(60_usize.into_frame_count(30), 60);
}
#[test]
fn test_into_frame_count_u32() {
assert_eq!(60_u32.into_frame_count(30), 60);
}
#[test]
fn test_into_frame_count_range() {
assert_eq!((0..60_usize).into_frame_count(30), 60);
}
#[test]
fn test_into_frame_count_duration() {
use std::time::Duration;
assert_eq!(Duration::from_secs(2).into_frame_count(30), 60);
assert_eq!(Duration::from_secs_f64(2.5).into_frame_count(30), 75);
}
#[test]
fn test_duration_ext_f64() {
let d = 2.5.secs();
assert!((d.as_secs_f64() - 2.5).abs() < 1e-10);
}
#[test]
fn test_duration_ext_u64() {
let d = 3_u64.secs();
assert_eq!(d.as_secs(), 3);
}
#[test]
fn test_record_simple_with_frame_count() {
let dir = tempdir().unwrap();
let path = dir.path().join("test_simple.gif");
let mut frame_count = 0;
let result = record_simple(&path, 5_usize, |_tick| {
frame_count += 1;
#[allow(deprecated)]
Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).end_series()
});
assert!(result.is_ok());
assert!(path.exists());
assert_eq!(frame_count, 5);
}
#[test]
fn test_record_simple_with_duration() {
use std::time::Duration;
let dir = tempdir().unwrap();
let path = dir.path().join("test_simple_duration.gif");
let config = RecordConfig::new().framerate(10);
let mut frame_count = 0;
let result =
record_simple_with_config(&path, Duration::from_secs_f64(0.3), config, |_tick| {
frame_count += 1;
#[allow(deprecated)]
Plot::new().line(&[0.0, 1.0], &[0.0, 1.0]).end_series()
});
assert!(result.is_ok());
assert!(path.exists());
assert_eq!(frame_count, 3);
}
#[test]
fn test_record_simple_with_tick_helpers() {
let dir = tempdir().unwrap();
let path = dir.path().join("test_simple_helpers.gif");
let config = RecordConfig::new().framerate(10);
let mut values = Vec::new();
let result = record_simple_with_config(&path, 10_usize, config, |tick| {
let x = tick.lerp_over(0.0, 100.0, 1.0);
values.push(x);
#[allow(deprecated)]
Plot::new().scatter(&[x], &[0.0]).end_series()
});
assert!(result.is_ok());
assert_eq!(values.len(), 10);
assert!((values[0] - 0.0).abs() < 1e-10);
assert!((values[5] - 50.0).abs() < 1e-10);
}
}