use crate::export::{self, GifRecorder};
use std::env;
fn var(name: &str) -> Option<String> {
env::var(name).ok().filter(|v| !v.trim().is_empty())
}
fn flag(name: &str) -> bool {
matches!(var(name).as_deref(), Some("1") | Some("true") | Some("yes"))
}
pub(crate) fn hidden_window() -> bool {
flag("PROOF_HIDDEN")
}
pub(crate) fn fixed_dt() -> Option<f32> {
let fps: f32 = var("PROOF_FIXED_DT")?.parse().ok()?;
(fps > 0.0).then(|| 1.0 / fps)
}
pub(crate) fn window_size() -> Option<(u32, u32)> {
let v = var("PROOF_WINDOW")?;
let (w, h) = v.to_ascii_lowercase().split_once('x').map(|(a, b)| (a.to_string(), b.to_string()))?;
let (w, h) = (w.trim().parse().ok()?, h.trim().parse().ok()?);
(w > 0 && h > 0).then_some((w, h))
}
pub(crate) struct Capture {
path: String,
at: u64,
count: u32,
every: u64,
exit: bool,
frame: u64,
taken: u32,
width: Option<u32>,
frame_secs: f32,
gif: Option<GifRecorder>,
}
impl Capture {
pub(crate) fn from_env() -> Option<Self> {
let mut c = Self::new(
var("PROOF_SHOT")?,
var("PROOF_SHOT_AT").and_then(|v| v.parse().ok()).unwrap_or(120),
var("PROOF_SHOT_COUNT").and_then(|v| v.parse().ok()).unwrap_or(1),
var("PROOF_SHOT_EVERY").and_then(|v| v.parse().ok()).unwrap_or(1),
!flag("PROOF_SHOT_KEEP"),
);
c.width = var("PROOF_SHOT_WIDTH").and_then(|v| v.parse().ok()).filter(|&w: &u32| w > 0);
c.frame_secs = fixed_dt().unwrap_or(1.0 / 60.0) * c.every as f32;
Some(c)
}
pub(crate) fn new(path: String, at: u64, count: u32, every: u64, exit: bool) -> Self {
let every = every.max(1);
Self {
path, at, count: count.max(1), every, exit, frame: 0, taken: 0,
width: None, frame_secs: every as f32 / 60.0, gif: None,
}
}
pub(crate) fn animated_gif(&self) -> bool {
self.count > 1
&& !self.path.contains("{n}")
&& self.path.to_ascii_lowercase().ends_with(".gif")
}
pub(crate) fn path_for(&self, n: u32) -> String {
self.path.replace("{n}", &format!("{n:04}"))
}
pub(crate) fn tick(&mut self) -> Option<String> {
let f = self.frame;
self.frame += 1;
if self.taken >= self.count || f < self.at || (f - self.at) % self.every != 0 {
return None;
}
let p = self.path_for(self.taken);
self.taken += 1;
Some(p)
}
pub(crate) fn finished(&self) -> bool {
self.exit && self.taken >= self.count
}
pub(crate) fn after_draw(&mut self, engine: &crate::ProofEngine) -> bool {
self.after_draw_with(|| engine.frame_pixels())
}
pub(crate) fn after_draw_with(
&mut self,
grab: impl FnOnce() -> Option<(u32, u32, Vec<u8>)>,
) -> bool {
if let Some(path) = self.tick() {
match self.write(&path, grab) {
Ok(()) => log::info!("captured frame {} to {path}", self.frame - 1),
Err(e) => eprintln!("proof-engine: could not write {path}: {e}"),
}
}
self.finished()
}
fn write(
&mut self,
path: &str,
grab: impl FnOnce() -> Option<(u32, u32, Vec<u8>)>,
) -> std::io::Result<()> {
let (w, h, px) = grab().ok_or_else(|| std::io::Error::other("no frame to read"))?;
let (w, h, px) = match self.width {
Some(width) => export::scale_to_width(w, h, &px, width)?,
None => (w, h, px),
};
if !self.animated_gif() {
return export::save_opaque(path, w, h, &px);
}
if self.gif.is_none() {
self.gif = Some(GifRecorder::create(path, self.frame_secs)?);
}
self.gif.as_mut().expect("just created").push(w, h, &px)?;
if self.taken >= self.count {
let n = self.gif.take().expect("open").finish()?;
log::info!("wrote {n} frame animated GIF to {path}");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_capture_fires_once_at_the_requested_frame() {
let mut c = Capture::new("a.bmp".into(), 3, 1, 1, true);
let hits: Vec<_> = (0..10).map(|_| c.tick()).collect();
assert_eq!(hits.iter().filter(|h| h.is_some()).count(), 1);
assert_eq!(hits[3].as_deref(), Some("a.bmp"));
assert!(c.finished());
}
#[test]
fn sequences_number_their_files_and_respect_the_stride() {
let mut c = Capture::new("f_{n}.bmp".into(), 2, 3, 2, true);
let hits: Vec<_> = (0..12).filter_map(|_| c.tick()).collect();
assert_eq!(hits, vec!["f_0000.bmp", "f_0001.bmp", "f_0002.bmp"]);
}
fn solid(shade: u8) -> Option<(u32, u32, Vec<u8>)> {
Some((8, 4, (0..32).flat_map(|_| [shade, shade, shade, 0]).collect()))
}
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join("proof_engine_capture_tests");
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().into_owned()
}
#[test]
fn only_multi_frame_gif_paths_without_a_counter_animate() {
assert!(Capture::new("a.gif".into(), 0, 5, 1, true).animated_gif());
assert!(Capture::new("A.GIF".into(), 0, 5, 1, true).animated_gif());
assert!(!Capture::new("a.gif".into(), 0, 1, 1, true).animated_gif());
assert!(!Capture::new("a_{n}.gif".into(), 0, 5, 1, true).animated_gif());
assert!(!Capture::new("a.png".into(), 0, 5, 1, true).animated_gif());
}
#[test]
fn a_png_sequence_writes_one_opaque_file_per_capture() {
let pattern = tmp("seq_{n}.png");
let mut c = Capture::new(pattern.clone(), 1, 2, 1, true);
let mut grabs = 0;
let mut done = false;
for _ in 0..5 {
done = c.after_draw_with(|| { grabs += 1; solid(200) });
}
assert!(done);
assert_eq!(grabs, 2, "frames are only read back when captured");
for n in 0..2 {
let bytes = std::fs::read(c.path_for(n)).unwrap();
let (w, h, px) = export::load_rgba(&bytes).unwrap();
assert_eq!((w, h), (8, 4));
assert_eq!(&px[..4], &[200, 200, 200, 255]);
}
}
#[test]
fn a_gif_capture_becomes_one_animation_and_honours_width() {
use image::AnimationDecoder;
let path = tmp("capture.gif");
let mut c = Capture::new(path.clone(), 0, 4, 2, true);
c.width = Some(4);
c.frame_secs = 2.0 / 50.0;
let mut shade = 0u8;
while !c.after_draw_with(|| { shade += 60; solid(shade) }) {}
assert!(c.gif.is_none(), "recorder closed after the last frame");
let bytes = std::fs::read(&path).unwrap();
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap();
let frames = dec.into_frames().collect_frames().unwrap();
assert_eq!(frames.len(), 4);
assert_eq!(frames[0].buffer().dimensions(), (4, 2));
let (num, den) = frames[0].delay().numer_denom_ms();
assert_eq!(num / den, 40);
}
#[test]
fn keep_running_never_finishes() {
let mut c = Capture::new("a.bmp".into(), 0, 1, 1, false);
c.tick();
assert!(!c.finished());
}
}