1
0
mirror of https://github.com/emilk/egui.git synced 2026-09-02 06:40:06 -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

@@ -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,