1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Add gif recording prototype

This commit is contained in:
lucasmerlin
2026-04-17 18:31:47 +02:00
parent f342ab8847
commit 78c29329a4
7 changed files with 646 additions and 3 deletions

View File

@@ -26,6 +26,9 @@ wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image", "dep:wgpu", "eframe?/wgpu"
## Adds a dify-based image snapshot utility.
snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"]
## Record a test session as an animated GIF or PNG sequence.
recording = ["dep:image", "image/gif", "image/png"]
## Allows testing eframe::App
eframe = ["dep:eframe", "eframe/accesskit"]
@@ -62,6 +65,7 @@ tempfile = { workspace = true, optional = true }
egui = { workspace = true, features = ["default_fonts"] }
image = { workspace = true, features = ["png"] }
egui_extras = { workspace = true, features = ["image", "http"] }
tempfile = { workspace = true }
[lints]
workspace = true

View File

@@ -25,6 +25,14 @@ pub struct Config {
/// Default is 0.
failed_pixel_count_threshold: usize,
/// When `true`, every harness automatically records itself and writes a GIF to
/// `{output_path}/failures/{test_name}.gif` on test failure.
///
/// Requires the `recording` feature; ignored otherwise.
/// Default is `false`.
#[cfg_attr(not(feature = "recording"), allow(dead_code))]
save_gif_on_failure: bool,
windows: OsConfig,
mac: OsConfig,
linux: OsConfig,
@@ -36,6 +44,7 @@ impl Default for Config {
output_path: PathBuf::from("tests/snapshots"),
threshold: 0.6,
failed_pixel_count_threshold: 0,
save_gif_on_failure: false,
windows: Default::default(),
mac: Default::default(),
linux: Default::default(),
@@ -113,6 +122,15 @@ impl Config {
pub fn output_path(&self) -> PathBuf {
self.output_path.clone()
}
/// Whether harnesses should automatically record themselves and save a GIF on test failure.
///
/// Configurable via `kittest.toml`. Requires the `recording` feature; ignored otherwise.
/// Default is `false`.
#[cfg(feature = "recording")]
pub fn save_gif_on_failure(&self) -> bool {
self.save_gif_on_failure
}
}
#[cfg(feature = "snapshot")]

View File

@@ -14,12 +14,17 @@ pub use crate::snapshot::*;
mod app_kind;
mod config;
mod node;
#[cfg(feature = "recording")]
mod recording;
mod renderer;
#[cfg(feature = "wgpu")]
mod texture_to_image;
#[cfg(feature = "wgpu")]
pub mod wgpu;
#[cfg(feature = "recording")]
pub use crate::recording::{RecordKind, RecordingError, RecordingOptions, RecordingTrigger};
// re-exports:
pub use {
self::{builder::*, node::*, renderer::*},
@@ -87,6 +92,9 @@ pub struct Harness<'a, State = ()> {
default_snapshot_options: SnapshotOptions,
#[cfg(feature = "snapshot")]
snapshot_results: SnapshotResults,
#[cfg(feature = "recording")]
recording: Option<recording::RecordingState>,
}
impl<State> Debug for Harness<'_, State> {
@@ -174,9 +182,29 @@ impl<'a, State> Harness<'a, State> {
#[cfg(feature = "snapshot")]
snapshot_results: SnapshotResults::default(),
#[cfg(feature = "recording")]
recording: None,
};
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
harness.run_ok();
#[cfg(all(feature = "recording", feature = "snapshot"))]
{
// Env var takes precedence (always saves), then config (only saves on failure).
let auto_mode = if recording::record_env_enabled() {
Some(recording::AutoSaveMode::Always)
} else if config::config().save_gif_on_failure() {
Some(recording::AutoSaveMode::OnFailure)
} else {
None
};
if let Some(mode) = auto_mode {
let options = recording::RecordingOptions::gif(std::path::PathBuf::new(), 10.0);
harness.recording = Some(recording::RecordingState::new(options).with_auto_save(mode));
}
}
harness
}
@@ -274,6 +302,9 @@ impl<'a, State> Harness<'a, State> {
);
self.renderer.handle_delta(&output.textures_delta);
self.output = output;
#[cfg(feature = "recording")]
self.capture_frame_if_recording(false);
}
/// Calculate the rect that includes all popups and tooltips.
@@ -359,6 +390,10 @@ impl<'a, State> Harness<'a, State> {
});
}
}
#[cfg(feature = "recording")]
self.capture_frame_if_recording(true);
Ok(steps)
}
@@ -640,7 +675,7 @@ impl<'a, State> Harness<'a, State> {
///
/// # Errors
/// Returns an error if the rendering fails.
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))]
pub fn render(&mut self) -> Result<image::RgbaImage, String> {
let mut output = self.output.clone();
@@ -666,6 +701,62 @@ impl<'a, State> Harness<'a, State> {
self.renderer.render(&self.ctx, &output)
}
/// Start recording the test session.
///
/// Captures one frame per [`Self::step`] (or per [`Self::run`], depending on the
/// configured [`RecordingTrigger`]). Replaces any previously active recording.
/// Call [`Self::finish_recording`] to write the output.
///
/// Requires a renderer (e.g. enable the `wgpu` feature, or set one via
/// [`HarnessBuilder::renderer`]).
#[cfg(feature = "recording")]
pub fn start_recording(&mut self, options: RecordingOptions) {
self.recording = Some(recording::RecordingState::new(options));
}
/// Stop the active recording and write its output (GIF or PNG sequence).
///
/// # Errors
/// Returns [`RecordingError::NotRecording`] if no recording is active, or an I/O / encode
/// error if writing fails.
#[cfg(feature = "recording")]
pub fn finish_recording(&mut self) -> Result<(), RecordingError> {
let state = self.recording.take().ok_or(RecordingError::NotRecording)?;
state.save()
}
/// Whether a recording is currently active.
#[cfg(feature = "recording")]
pub fn is_recording(&self) -> bool {
self.recording.is_some()
}
/// Render the current frame and append it to the active recording according to its trigger.
/// Called from [`Self::_step`] (with `after_run = false`) and at the end of [`Self::_try_run`]
/// (with `after_run = true`).
#[cfg(feature = "recording")]
fn capture_frame_if_recording(&mut self, after_run: bool) {
let Some(state) = self.recording.as_mut() else {
return;
};
if !state.should_capture(after_run) {
return;
}
match self.render() {
Ok(image) => {
if let Some(state) = self.recording.as_mut() {
state.push_frame(image);
}
}
Err(err) => {
#[expect(clippy::print_stderr)]
{
eprintln!("egui_kittest recording: render failed, skipping frame: {err}");
}
}
}
}
/// Get the root viewport output
fn root_viewport_output(&self) -> &egui::ViewportOutput {
self.output
@@ -683,6 +774,77 @@ impl<'a, State> Harness<'a, State> {
}
}
/// Save the in-progress recording (auto-started by `save_gif_on_failure` or `KITTEST_RECORD`)
/// when the harness is dropped.
///
/// Recordings started by an explicit `start_recording` call are *not* saved here — the user
/// is expected to call `finish_recording`.
#[cfg(all(feature = "recording", feature = "snapshot"))]
#[expect(clippy::print_stderr)] // Drop path: stderr is the only signal we have.
impl<State> Drop for Harness<'_, State> {
fn drop(&mut self) {
let Some(mut state) = self.recording.take() else {
return;
};
let Some(mode) = state.auto_save_mode else {
// Explicit recording — discard if not finished.
return;
};
let should_save = match mode {
recording::AutoSaveMode::Always => true,
recording::AutoSaveMode::OnFailure => {
std::thread::panicking() || self.snapshot_results.has_errors()
}
};
if !should_save {
return;
}
let subdir = match mode {
recording::AutoSaveMode::Always => "recordings",
recording::AutoSaveMode::OnFailure => "failures",
};
let name = std::thread::current()
.name()
.map(sanitize_thread_name)
.unwrap_or_else(default_recording_name);
let resolved_path = config::config()
.output_path()
.join(subdir)
.join(format!("{name}.gif"));
// Replace the placeholder path with the resolved one.
if let recording::RecordKind::Gif { path, .. } = &mut state.options.kind {
*path = resolved_path.clone();
}
match state.save() {
Ok(()) => eprintln!("egui_kittest: saved GIF to {}", resolved_path.display()),
Err(err) => eprintln!(
"egui_kittest: failed to save GIF to {}: {err}",
resolved_path.display()
),
}
}
}
#[cfg(all(feature = "recording", feature = "snapshot"))]
fn sanitize_thread_name(name: &str) -> String {
// Test thread names look like `module::tests::name` — make that filesystem-safe.
name.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_")
}
#[cfg(all(feature = "recording", feature = "snapshot"))]
fn default_recording_name() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("recording-{ts}")
}
/// Utilities for stateless harnesses.
impl<'a> Harness<'a> {
/// Create a new Harness with the given ui closure.

View File

@@ -0,0 +1,270 @@
//! Capture a [`crate::Harness`] session as an animated GIF or a sequence of PNG files.
//!
//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`].
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use image::RgbaImage;
use image::codecs::gif::{GifEncoder, Repeat};
/// What kind of output to produce when the recording is finished.
#[derive(Debug, Clone)]
pub enum RecordKind {
/// Save an animated GIF to `path` (looping forever).
Gif {
/// Where to write the GIF.
path: PathBuf,
/// Frames per second. The GIF spec stores delays in 10 ms ticks,
/// so frame rates that aren't a divisor of 100 fps will be slightly approximated.
frame_rate: f32,
},
/// Save a sequence of PNG files (`frame_0000.png`, `frame_0001.png`, ...) into `directory`.
PngSequence {
/// Directory to write the PNG files into. It will be created if missing.
directory: PathBuf,
},
}
/// When to capture a frame during a recording session.
#[derive(Debug, Clone, Copy, Default)]
pub enum RecordingTrigger {
/// Render after every step. If the rendered frame is byte-identical to the
/// previously captured frame, drop it.
///
/// This is the default and produces the smallest recordings for typical UIs,
/// since most steps don't visibly change anything.
#[default]
DiffEveryStep,
/// Render after every step. Keep every frame, even if visually identical.
EveryStep,
/// Capture exactly one frame at the end of each [`crate::Harness::run`] call.
/// No frames are captured during plain [`crate::Harness::step`] calls.
OnRun,
/// Capture every `N`-th step. `EveryNSteps(1)` is equivalent to [`Self::EveryStep`].
EveryNSteps(u32),
}
/// Configuration for a recording session. Pass to [`crate::Harness::start_recording`].
#[derive(Debug, Clone)]
pub struct RecordingOptions {
/// What output to produce.
pub kind: RecordKind,
/// When to capture a frame. Defaults to [`RecordingTrigger::DiffEveryStep`].
pub trigger: RecordingTrigger,
}
impl RecordingOptions {
/// Record a GIF at `path` with the default trigger ([`RecordingTrigger::DiffEveryStep`])
/// and the given frame rate.
pub fn gif(path: impl Into<PathBuf>, frame_rate: f32) -> Self {
Self {
kind: RecordKind::Gif {
path: path.into(),
frame_rate,
},
trigger: RecordingTrigger::default(),
}
}
/// Record a PNG sequence into `directory` with the default trigger
/// ([`RecordingTrigger::DiffEveryStep`]).
pub fn png_sequence(directory: impl Into<PathBuf>) -> Self {
Self {
kind: RecordKind::PngSequence {
directory: directory.into(),
},
trigger: RecordingTrigger::default(),
}
}
/// Replace the trigger.
#[must_use]
pub fn with_trigger(mut self, trigger: RecordingTrigger) -> Self {
self.trigger = trigger;
self
}
}
/// Errors produced when finishing a recording.
#[derive(Debug)]
pub enum RecordingError {
/// No recording was active when [`crate::Harness::finish_recording`] was called.
NotRecording,
/// Failed to create or write to the output file/directory.
Io { path: PathBuf, err: std::io::Error },
/// Failed to encode the GIF.
Encode(image::ImageError),
}
impl std::fmt::Display for RecordingError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotRecording => write!(f, "No recording is currently active"),
Self::Io { path, err } => write!(f, "I/O error writing {}: {err}", path.display()),
Self::Encode(err) => write!(f, "Failed to encode recording: {err}"),
}
}
}
impl std::error::Error for RecordingError {}
impl From<image::ImageError> for RecordingError {
fn from(err: image::ImageError) -> Self {
Self::Encode(err)
}
}
/// How a recording auto-started by the harness should be saved on `Drop`.
#[derive(Debug, Clone, Copy)]
pub(crate) enum AutoSaveMode {
/// Save only when the test failed. Path resolved to `{output}/failures/{name}.gif`.
OnFailure,
/// Save unconditionally (e.g. driven by `KITTEST_RECORD`).
/// Path resolved to `{output}/recordings/{name}.gif`.
Always,
}
/// In-memory state of an active recording. Stored on the [`crate::Harness`].
pub(crate) struct RecordingState {
pub(crate) options: RecordingOptions,
pub(crate) frames: Vec<RgbaImage>,
pub(crate) last_frame: Option<RgbaImage>,
pub(crate) step_counter: u32,
/// Set when the recording was started automatically by the harness (config or env var)
/// rather than by an explicit `start_recording` call. Drives the `Drop` save path.
pub(crate) auto_save_mode: Option<AutoSaveMode>,
}
impl RecordingState {
pub(crate) fn new(options: RecordingOptions) -> Self {
Self {
options,
frames: Vec::new(),
last_frame: None,
step_counter: 0,
auto_save_mode: None,
}
}
pub(crate) fn with_auto_save(mut self, mode: AutoSaveMode) -> Self {
self.auto_save_mode = Some(mode);
self
}
/// Decide whether to capture a frame on this tick.
///
/// `after_run` is true when the call site is the end of [`crate::Harness::run`]
/// (used by the [`RecordingTrigger::OnRun`] trigger). Other triggers fire only on
/// per-step ticks (`after_run == false`).
pub(crate) fn should_capture(&mut self, after_run: bool) -> bool {
match self.options.trigger {
RecordingTrigger::DiffEveryStep | RecordingTrigger::EveryStep => !after_run,
RecordingTrigger::OnRun => after_run,
RecordingTrigger::EveryNSteps(n) => {
if after_run {
return false;
}
let n = n.max(1);
let counter = self.step_counter;
self.step_counter = self.step_counter.wrapping_add(1);
counter.is_multiple_of(n)
}
}
}
/// Push a freshly rendered frame, applying the configured diffing policy.
pub(crate) fn push_frame(&mut self, image: RgbaImage) {
if matches!(self.options.trigger, RecordingTrigger::DiffEveryStep) {
if let Some(prev) = &self.last_frame
&& prev.as_raw() == image.as_raw()
{
return;
}
self.last_frame = Some(image.clone());
}
self.frames.push(image);
}
pub(crate) fn save(self) -> Result<(), RecordingError> {
match self.options.kind {
RecordKind::Gif { path, frame_rate } => save_gif(&path, &self.frames, frame_rate),
RecordKind::PngSequence { directory } => save_png_sequence(&directory, &self.frames),
}
}
}
fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), RecordingError> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|err| RecordingError::Io {
path: parent.to_path_buf(),
err,
})?;
}
let file = File::create(path).map_err(|err| RecordingError::Io {
path: path.to_path_buf(),
err,
})?;
let writer = BufWriter::new(file);
let mut encoder = GifEncoder::new(writer);
encoder.set_repeat(Repeat::Infinite)?;
let denom = frame_rate.max(0.1).round().clamp(1.0, u32::MAX as f32) as u32;
let frame_delay = image::Delay::from_numer_denom_ms(1000, denom);
// Hold the final frame for a full second so the loop point is obvious.
let final_delay = image::Delay::from_numer_denom_ms(1000, 1);
let last_idx = frames.len().saturating_sub(1);
for (i, frame) in frames.iter().enumerate() {
let delay = if i == last_idx { final_delay } else { frame_delay };
let frame = image::Frame::from_parts(frame.clone(), 0, 0, delay);
encoder.encode_frame(frame)?;
}
Ok(())
}
/// Name of the environment variable that enables auto-recording for every harness in the process.
///
/// When set to `1` / `true` / `yes`, every harness records itself and saves a GIF to
/// `{snapshot_output}/recordings/{test_name}.gif` when dropped (regardless of pass/fail).
pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD";
/// Read [`RECORD_ENV_VAR`] once and cache the result.
pub(crate) fn record_env_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| match std::env::var(RECORD_ENV_VAR) {
Ok(value) => matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
),
Err(_) => false,
})
}
fn save_png_sequence(directory: &Path, frames: &[RgbaImage]) -> Result<(), RecordingError> {
std::fs::create_dir_all(directory).map_err(|err| RecordingError::Io {
path: directory.to_path_buf(),
err,
})?;
for (i, frame) in frames.iter().enumerate() {
let path = directory.join(format!("frame_{i:04}.png"));
frame.save(&path).map_err(|err| match err {
image::ImageError::IoError(io_err) => RecordingError::Io { path, err: io_err },
other => RecordingError::Encode(other),
})?;
}
Ok(())
}

View File

@@ -12,7 +12,7 @@ pub trait TestRenderer {
///
/// # Errors
/// Returns an error if the rendering fails.
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))]
fn render(
&mut self,
ctx: &egui::Context,
@@ -62,7 +62,7 @@ impl TestRenderer for LazyRenderer {
}
}
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
#[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))]
fn render(
&mut self,
ctx: &egui::Context,

View File

@@ -0,0 +1,124 @@
#![cfg(all(feature = "recording", feature = "wgpu"))]
use egui_kittest::{Harness, RecordingOptions, RecordingTrigger};
use kittest::Queryable as _;
use tempfile::tempdir;
fn counter_harness(value: &mut u32) -> Harness<'_, &mut u32> {
Harness::builder()
.with_size(egui::Vec2::new(120.0, 60.0))
.build_ui_state(
|ui, state| {
if ui.button(format!("count: {state}")).clicked() {
**state += 1;
}
},
value,
)
}
fn count_pngs(dir: &std::path::Path) -> usize {
std::fs::read_dir(dir)
.expect("png output dir exists")
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("png"))
.count()
}
#[test]
fn records_gif_with_diffing() {
let dir = tempdir().expect("tempdir");
let gif_path = dir.path().join("counter.gif");
let mut value = 0u32;
let mut harness = counter_harness(&mut value);
harness.start_recording(RecordingOptions::gif(&gif_path, 12.0));
harness.run();
harness.run();
harness.get_by_label_contains("count").click();
harness.run();
assert!(harness.is_recording());
harness.finish_recording().expect("save gif");
assert!(!harness.is_recording());
let metadata = std::fs::metadata(&gif_path).expect("gif exists");
assert!(metadata.len() > 0, "GIF should be non-empty");
}
#[test]
fn records_png_sequence() {
let dir = tempdir().expect("tempdir");
let out = dir.path().join("frames");
let mut value = 0u32;
let mut harness = counter_harness(&mut value);
harness.start_recording(
RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::EveryStep),
);
harness.run();
harness.get_by_label_contains("count").click();
harness.run();
harness.finish_recording().expect("save png sequence");
assert!(count_pngs(&out) > 0, "expected at least one frame");
}
#[test]
fn diff_every_step_dedupes_unchanged_frames() {
let dir = tempdir().expect("tempdir");
let out = dir.path().join("frames");
let mut value = 0u32;
let mut harness = counter_harness(&mut value);
harness.start_recording(
RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::DiffEveryStep),
);
for _ in 0..6 {
harness.run();
}
harness.finish_recording().expect("save png sequence");
assert_eq!(
count_pngs(&out),
1,
"DiffEveryStep should dedupe unchanged frames"
);
}
#[test]
fn on_run_trigger_captures_per_run_only() {
let dir = tempdir().expect("tempdir");
let out = dir.path().join("frames");
let mut value = 0u32;
let mut harness = counter_harness(&mut value);
harness.start_recording(
RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::OnRun),
);
harness.run();
harness.get_by_label_contains("count").click();
harness.run();
harness.run();
harness.finish_recording().expect("save png sequence");
assert_eq!(
count_pngs(&out),
3,
"OnRun should produce one frame per run() call"
);
}
#[test]
fn finish_recording_without_start_errors() {
let mut value = 0u32;
let mut harness = counter_harness(&mut value);
let err = harness.finish_recording().expect_err("not recording");
assert!(matches!(err, egui_kittest::RecordingError::NotRecording));
}

View File

@@ -0,0 +1,65 @@
//! Verifies that the `KITTEST_RECORD` env var auto-records harnesses and writes them next to
//! snapshots under `recordings/{test_name}.gif`.
//!
//! This is its own integration test binary because the env var is read once via `OnceLock`,
//! and we redirect the snapshot output path via `kittest.toml` lookup is process-global too.
#![cfg(all(feature = "recording", feature = "snapshot", feature = "wgpu"))]
#![allow(unsafe_code)] // tests need set_var / set_current_dir
use std::sync::OnceLock;
use egui_kittest::Harness;
use tempfile::TempDir;
static SETUP: OnceLock<TempDir> = OnceLock::new();
fn setup_env() -> &'static std::path::Path {
SETUP
.get_or_init(|| {
let dir = tempfile::tempdir().expect("tempdir");
// Point the snapshot output at our temp dir (used as the recording root).
std::fs::write(dir.path().join("kittest.toml"), "output_path = \".\"\n")
.expect("write kittest.toml");
// SAFETY: this OnceLock guarantees a single initialization before any harness
// reads the env var or cwd, so no concurrent env access happens.
unsafe {
std::env::set_current_dir(dir.path()).expect("chdir to tmp");
std::env::set_var("KITTEST_RECORD", "1");
}
dir
})
.path()
}
#[test]
fn env_var_records_to_recordings_dir() {
let dir = setup_env();
{
let mut harness = Harness::new_ui(|ui| {
ui.label("env-recorded");
});
harness.run();
// Drop here triggers the auto-save.
}
let recordings = dir.join("recordings");
let entries: Vec<_> = std::fs::read_dir(&recordings)
.expect("recordings dir exists")
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("gif"))
.collect();
assert!(
!entries.is_empty(),
"KITTEST_RECORD should produce a GIF in {}",
recordings.display()
);
for entry in &entries {
let len = std::fs::metadata(entry).expect("stat").len();
assert!(len > 0, "GIF {} should be non-empty", entry.display());
}
}