1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00
Files
egui/crates/egui_kittest/src/recording.rs
2026-08-11 15:59:34 +02:00

775 lines
25 KiB
Rust

//! Record an egui session as an animated GIF or a sequence of PNG files.
//!
//! The recorder is an [`egui::Plugin`], so it can record any [`egui::Context`],
//! not just a [`crate::Harness`]. It renders every pass with its own [`TestRenderer`]
//! and keeps the frames in memory until you save them.
//!
//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`].
use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use egui::{Context, FullOutput, TexturesDelta};
use image::RgbaImage;
use image::codecs::gif::{GifEncoder, Repeat};
use crate::TestRenderer;
/// Name of the environment variable that records every [`crate::Harness`] in the process.
///
/// Every harness records itself and saves a GIF when it is dropped,
/// whether the test passed or not:
///
/// - `KITTEST_RECORD=1` writes to `{output_path}/recordings/{test_name}.gif`
/// - `KITTEST_RECORD=open` writes to a temporary file and shows it in the default image viewer
pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD";
/// What to write when the recording is saved.
#[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 format stores delays in 10 ms ticks,
/// so a frame rate that is not a divisor of 100 is 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 is created if it is missing.
directory: PathBuf,
},
}
/// Which passes to capture.
///
/// Passes that egui discards (see [`egui::Context::request_discard`]) are never captured,
/// since they are never shown to the user either.
#[derive(Debug, Clone, Copy, Default)]
pub enum RecordingTrigger {
/// Capture every pass, but drop a frame if it looks exactly like the frame before it.
///
/// This is the default. It gives the smallest recordings, because most passes
/// change nothing on screen.
#[default]
ChangedFrames,
/// Capture every pass, even if nothing changed.
EveryFrame,
/// Capture every `N`-th pass. `EveryNthFrame(1)` is the same as [`Self::EveryFrame`].
EveryNthFrame(u32),
}
/// How to record. Pass this to [`crate::Harness::start_recording`] or [`RecordingPlugin::new`].
#[derive(Debug, Clone)]
pub struct RecordingOptions {
/// What to write when the recording is saved.
pub kind: RecordKind,
/// Which passes to capture. Defaults to [`RecordingTrigger::ChangedFrames`].
pub trigger: RecordingTrigger,
}
impl RecordingOptions {
/// Record a GIF to `path` at the given frame rate,
/// with the default trigger ([`RecordingTrigger::ChangedFrames`]).
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::ChangedFrames`]).
pub fn png_sequence(directory: impl Into<PathBuf>) -> Self {
Self {
kind: RecordKind::PngSequence {
directory: directory.into(),
},
trigger: RecordingTrigger::default(),
}
}
/// Replace the trigger.
#[inline]
#[must_use]
pub fn with_trigger(mut self, trigger: RecordingTrigger) -> Self {
self.trigger = trigger;
self
}
}
/// What went wrong when saving a recording.
#[derive(Debug)]
pub enum RecordingError {
/// No recording was running.
NotRecording,
/// The recording did not capture a single frame.
NoFrames,
/// Failed to create or write the output file or directory.
Io {
/// The file or directory we failed to write.
path: PathBuf,
/// The underlying error.
err: std::io::Error,
},
/// Failed to encode the image data.
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 running"),
Self::NoFrames => write!(f, "The recording contains no frames"),
Self::Io { path, err } => write!(f, "Failed to write {}: {err}", path.display()),
Self::Encode(err) => write!(f, "Failed to encode the recording: {err}"),
}
}
}
impl std::error::Error for RecordingError {}
impl From<image::ImageError> for RecordingError {
fn from(err: image::ImageError) -> Self {
Self::Encode(err)
}
}
/// Records an [`egui::Context`] by rendering each pass to an image.
///
/// Register it with [`egui::Context::add_plugin`], or let [`crate::Harness::start_recording`]
/// do it for you.
///
/// The plugin renders with its own [`TestRenderer`] (a `wgpu` one by default), so it does not
/// interfere with the renderer of the harness.
pub struct RecordingPlugin {
options: RecordingOptions,
renderer: LazyRenderer,
frames: Vec<RgbaImage>,
pass_nr: u32,
/// While `false` the plugin still tracks textures, but captures no frames.
active: bool,
/// Did we give our renderer the whole font atlas?
///
/// A plugin that is registered after the first pass never saw the font texture being
/// allocated, only the partial updates that follow it.
uploaded_font_atlas: bool,
/// Set when the harness started the recording by itself (see [`crate::Harness`]).
pub(crate) auto_save: Option<AutoSaveMode>,
}
impl std::fmt::Debug for RecordingPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RecordingPlugin")
.field("options", &self.options)
.field("active", &self.active)
.field("frames", &self.frames.len())
.finish_non_exhaustive()
}
}
impl RecordingPlugin {
/// Create a plugin that starts recording right away.
pub fn new(options: RecordingOptions) -> Self {
Self {
options,
active: true,
..Self::idle()
}
}
/// Create a plugin that captures nothing until [`Self::restart`] is called.
///
/// It still follows the textures of the [`egui::Context`], so that it can render
/// correctly once it starts.
pub fn idle() -> Self {
Self {
options: RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE),
renderer: LazyRenderer::default(),
frames: Vec::new(),
pass_nr: 0,
active: false,
uploaded_font_atlas: false,
auto_save: None,
}
}
/// Render with this renderer instead of the default `wgpu` one.
///
/// The renderer must be `Send + Sync`, because [`egui::Plugin`] requires it.
#[inline]
#[must_use]
pub fn with_renderer(mut self, renderer: impl TestRenderer + Send + Sync + 'static) -> Self {
self.renderer = LazyRenderer::Ready(Box::new(renderer));
self
}
/// The options this recording uses.
#[inline]
pub fn options(&self) -> &RecordingOptions {
&self.options
}
/// The options this recording uses, mutably.
#[inline]
pub fn options_mut(&mut self) -> &mut RecordingOptions {
&mut self.options
}
/// Is the plugin capturing frames?
#[inline]
pub fn is_active(&self) -> bool {
self.active
}
/// The frames captured so far.
#[inline]
pub fn frames(&self) -> &[RgbaImage] {
&self.frames
}
/// Start capturing again, with new options. Any earlier frames are dropped.
pub fn restart(&mut self, options: RecordingOptions) {
self.options = options;
self.frames.clear();
self.pass_nr = 0;
self.active = true;
self.auto_save = None;
}
/// Stop capturing and drop all frames.
pub fn stop(&mut self) {
self.frames.clear();
self.active = false;
self.auto_save = None;
}
/// Write the captured frames to disk.
///
/// # Errors
/// Returns an error if there are no frames, or if writing fails.
pub fn save(&self) -> Result<(), RecordingError> {
if self.frames.is_empty() {
return Err(RecordingError::NoFrames);
}
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),
}
}
/// Where the recording will be written.
pub(crate) fn output_path(&self) -> &Path {
match &self.options.kind {
RecordKind::Gif { path, .. } => path,
RecordKind::PngSequence { directory } => directory,
}
}
/// Change where the recording will be written.
pub(crate) fn set_output_path(&mut self, new_path: PathBuf) {
match &mut self.options.kind {
RecordKind::Gif { path, .. } => *path = new_path,
RecordKind::PngSequence { directory } => *directory = new_path,
}
}
/// Should we capture this pass?
fn should_capture(&mut self) -> bool {
let pass_nr = self.pass_nr;
self.pass_nr = self.pass_nr.wrapping_add(1);
match self.options.trigger {
RecordingTrigger::ChangedFrames | RecordingTrigger::EveryFrame => true,
RecordingTrigger::EveryNthFrame(n) => pass_nr.is_multiple_of(n.max(1)),
}
}
/// Add a frame, dropping it if the trigger says it is a duplicate.
fn push_frame(&mut self, image: RgbaImage) {
if matches!(self.options.trigger, RecordingTrigger::ChangedFrames)
&& let Some(previous) = self.frames.last()
&& previous.as_raw() == image.as_raw()
{
return;
}
self.frames.push(image);
}
}
impl egui::Plugin for RecordingPlugin {
fn debug_name(&self) -> &'static str {
"egui_kittest::RecordingPlugin"
}
fn output_hook(&mut self, ctx: &Context, output: &mut FullOutput) {
if !self.uploaded_font_atlas {
self.uploaded_font_atlas = true;
self.renderer.handle_delta(&mut font_atlas_delta(ctx));
}
// Our renderer needs the same textures as the renderer of the integration,
// so apply a copy of the deltas. Do this even while inactive, so that we can
// start recording at any time.
let mut textures_delta = output.textures_delta.clone();
self.renderer.handle_delta(&mut textures_delta);
if !self.active {
return;
}
if output.platform_output.requested_discard() {
// This pass is thrown away and never shown, so don't record it.
return;
}
if !self.should_capture() {
return;
}
// `FullOutput` cannot be cloned without cloning the texture deltas
// (which panic if they are dropped unapplied), so build the render input by hand.
// Renderers only need the shapes.
let mut shapes = output.shapes.clone();
crate::push_cursor_shape(ctx, &mut shapes);
let render_output = FullOutput {
shapes,
pixels_per_point: output.pixels_per_point,
viewport_output: output.viewport_output.clone(),
..Default::default()
};
match self.renderer.render(ctx, &render_output) {
Ok(image) => self.push_frame(image),
Err(err) => {
log::error!("egui_kittest recording: failed to render a frame: {err}");
if self.renderer.is_failed() {
// Nothing will ever render, so stop instead of complaining every pass.
self.active = false;
}
}
}
}
}
/// How a recording that the harness started by itself is saved when the harness is dropped.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoSaveMode {
/// Save only if the test failed. Written to `{output_path}/failures/{test_name}.gif`.
OnFailure,
/// Always save. Written to `{output_path}/recordings/{test_name}.gif`.
Always,
/// Always save to a temporary file, and show it in the default image viewer.
Open,
}
impl AutoSaveMode {
/// Where to write the recording of the test we are running.
fn path(self) -> PathBuf {
let name = std::thread::current()
.name()
.map_or_else(|| "recording".to_owned(), sanitize_file_name);
let subdirectory = match self {
Self::OnFailure => "failures",
Self::Always => "recordings",
Self::Open => {
if let Some(path) = temp_gif_path(&name) {
return path;
}
"recordings" // Fall back to a normal recording.
}
};
crate::config::config()
.output_path()
.join(subdirectory)
.join(format!("{name}.gif"))
}
}
/// A GIF in the temporary directory, which we keep after the test, so that the image
/// viewer can still read it.
fn temp_gif_path(name: &str) -> Option<PathBuf> {
tempfile::Builder::new()
.disable_cleanup(true)
.prefix(&format!("kittest-recording-{name}-"))
.suffix(".gif")
.tempfile()
.inspect_err(|err| log::error!("egui_kittest: failed to create a temporary file: {err}"))
.ok()
.map(|file| file.path().to_path_buf())
}
/// Test threads are named after the test (e.g. `menu::tests::close_on_click`).
fn sanitize_file_name(name: &str) -> String {
name.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_")
}
/// What [`RECORD_ENV_VAR`] asks for.
///
/// Read once, then cached, so that a test cannot change it halfway through a run.
pub(crate) fn record_env_var() -> Option<AutoSaveMode> {
static MODE: std::sync::OnceLock<Option<AutoSaveMode>> = std::sync::OnceLock::new();
*MODE.get_or_init(|| {
let value = std::env::var(RECORD_ENV_VAR).ok()?;
match value.trim().to_ascii_lowercase().as_str() {
"open" => Some(AutoSaveMode::Open),
"1" | "true" | "yes" | "on" => Some(AutoSaveMode::Always),
"" | "0" | "false" | "no" | "off" => None,
other => {
log::warn!("Ignoring {RECORD_ENV_VAR}={other:?}: expected `1` or `open`");
None
}
}
})
}
// ----------------------------------------------------------------------------
// Harness integration
/// Frame rate of recordings that the harness starts by itself.
const AUTO_FRAME_RATE: f32 = 10.0;
/// A [`crate::Harness`] can record itself.
impl<State> crate::Harness<'_, State> {
/// Record the rest of this test session.
///
/// One frame is captured per egui pass, as configured by [`RecordingOptions::trigger`].
/// Call [`Self::finish_recording`] to write the result.
///
/// This registers a [`RecordingPlugin`] on the [`egui::Context`] of the harness, and
/// restarts the recording if there already is one.
///
/// The recording renders with its own renderer, which by default needs the `wgpu` feature.
///
/// ```no_run
/// # use egui_kittest::{Harness, RecordingOptions};
/// let mut harness = Harness::new_ui(|ui| {
/// ui.label("Hello!");
/// });
/// harness.start_recording(RecordingOptions::gif("hello.gif", 10.0));
/// harness.run();
/// harness.finish_recording().unwrap();
/// ```
pub fn start_recording(&mut self, options: RecordingOptions) {
install(&self.ctx, options, None);
self.recording_auto_save = None;
}
/// Stop the recording and write it to disk.
///
/// # Errors
/// Returns [`RecordingError::NotRecording`] if nothing was being recorded,
/// [`RecordingError::NoFrames`] if no frame was captured,
/// or an I/O or encoding error if writing failed.
pub fn finish_recording(&mut self) -> Result<(), RecordingError> {
self.recording_auto_save = None;
let result = self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
if !plugin.is_active() {
return Err(RecordingError::NotRecording);
}
let result = plugin.save();
plugin.stop();
result
});
result.unwrap_or(Err(RecordingError::NotRecording))
}
/// Is the harness recording?
pub fn is_recording(&self) -> bool {
self.ctx
.with_plugin::<RecordingPlugin, _>(|plugin| plugin.is_active())
.unwrap_or(false)
}
/// Access the [`RecordingPlugin`], e.g. to read the captured frames.
///
/// Returns `None` if the harness never recorded anything.
pub fn with_recording<R>(&self, f: impl FnOnce(&mut RecordingPlugin) -> R) -> Option<R> {
self.ctx.with_plugin::<RecordingPlugin, _>(f)
}
/// Start recording if the environment variable or the `kittest.toml` asks for it.
pub(crate) fn maybe_start_auto_recording(&mut self) {
let mode = if let Some(mode) = record_env_var() {
mode
} else if crate::config::config().save_gif_on_failure() {
AutoSaveMode::OnFailure
} else {
return;
};
// The file name contains the test name, which we only look up when we save,
// so record to a placeholder path for now.
let options = RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE);
install(&self.ctx, options, Some(mode));
self.recording_auto_save = Some(AutoSaveOnDrop {
ctx: self.ctx.clone(),
});
}
}
/// Register an idle [`RecordingPlugin`], if there is none yet.
///
/// The harness does this before the first pass, so that the plugin sees every texture that
/// egui allocates, no matter when the recording starts.
pub(crate) fn install_idle(ctx: &Context) {
ctx.add_plugin(RecordingPlugin::idle());
}
/// Register a [`RecordingPlugin`] on `ctx`, or restart the one that is already registered.
fn install(ctx: &Context, options: RecordingOptions, auto_save: Option<AutoSaveMode>) {
let restarted = ctx
.with_plugin::<RecordingPlugin, _>(|plugin| {
plugin.restart(options.clone());
plugin.auto_save = auto_save;
})
.is_some();
if !restarted {
let mut plugin = RecordingPlugin::new(options);
plugin.auto_save = auto_save;
ctx.add_plugin(plugin);
}
}
/// Saves a recording that the harness started by itself, when the harness is dropped.
pub(crate) struct AutoSaveOnDrop {
pub ctx: Context,
}
#[expect(clippy::print_stderr)] // We are (probably) in a panic, so logging may not be shown.
impl Drop for AutoSaveOnDrop {
fn drop(&mut self) {
self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
let Some(mode) = plugin.auto_save.take() else {
return;
};
// A failing test panics, either from an assert or from the snapshot results,
// which are dropped before this.
if mode == AutoSaveMode::OnFailure && !std::thread::panicking() {
plugin.stop();
return;
}
plugin.set_output_path(mode.path());
let path = plugin.output_path().to_path_buf();
match plugin.save() {
Ok(()) => {
eprintln!("egui_kittest: saved a recording to {}", path.display());
if mode == AutoSaveMode::Open
&& let Err(err) = open::that_detached(&path)
{
eprintln!(
"egui_kittest: failed to open {} in the default image viewer: {err}",
path.display()
);
}
}
Err(RecordingError::NoFrames) => {}
Err(err) => eprintln!("egui_kittest: failed to save the recording: {err}"),
}
plugin.stop();
});
}
}
// ----------------------------------------------------------------------------
// Renderer
/// A [`TestRenderer`] that is created when it is first used.
///
/// This mirrors [`crate::LazyRenderer`], but is `Send + Sync`, as [`egui::Plugin`] requires.
enum LazyRenderer {
Uninitialized {
textures_delta: TexturesDelta,
},
Ready(Box<dyn TestRenderer + Send + Sync>),
/// We failed to create a renderer, and already told the user about it.
#[cfg_attr(feature = "wgpu", expect(dead_code))]
// Only reachable without the `wgpu` feature.
Failed,
}
impl Default for LazyRenderer {
fn default() -> Self {
Self::Uninitialized {
textures_delta: TexturesDelta::default(),
}
}
}
/// A delta that sets the whole font atlas, as it looks right now.
fn font_atlas_delta(ctx: &Context) -> TexturesDelta {
let image = ctx.fonts(|fonts| fonts.image());
let mut delta = TexturesDelta::default();
delta.push(
egui::TextureId::default(), // The font atlas is always the first texture.
egui::epaint::ImageDelta::full(image, egui::TextureOptions::default()),
);
delta
}
impl LazyRenderer {
fn handle_delta(&mut self, delta: &mut TexturesDelta) {
match self {
Self::Uninitialized { textures_delta } => textures_delta.append(std::mem::take(delta)),
Self::Ready(renderer) => renderer.handle_delta(delta),
Self::Failed => delta.clear(), // Don't panic when the delta is dropped.
}
}
fn render(&mut self, ctx: &Context, output: &FullOutput) -> Result<RgbaImage, String> {
if let Self::Uninitialized { textures_delta } = self {
#[cfg(feature = "wgpu")]
{
let mut renderer = crate::wgpu::WgpuTestRenderer::new();
renderer.handle_delta(textures_delta);
*self = Self::Ready(Box::new(renderer));
}
#[cfg(not(feature = "wgpu"))]
{
textures_delta.clear(); // Don't panic when the deltas are dropped.
*self = Self::Failed;
}
}
match self {
Self::Ready(renderer) => renderer.render(ctx, output),
Self::Uninitialized { .. } | Self::Failed => Err("A recording needs a renderer. \
Enable the `wgpu` feature, or pass one to `RecordingPlugin::with_renderer`."
.to_owned()),
}
}
/// Will this renderer never render anything?
fn is_failed(&self) -> bool {
matches!(self, Self::Failed)
}
}
impl Drop for LazyRenderer {
fn drop(&mut self) {
if let Self::Uninitialized { textures_delta } = self {
textures_delta.clear(); // Don't panic when dropping unapplied deltas.
}
}
}
// ----------------------------------------------------------------------------
// Saving
fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), RecordingError> {
create_parent_dir(path)?;
let file = File::create(path).map_err(|err| RecordingError::Io {
path: path.to_path_buf(),
err,
})?;
let mut encoder = GifEncoder::new(BufWriter::new(file));
encoder.set_repeat(Repeat::Infinite)?;
let fps = frame_rate.clamp(1.0, 100.0).round() as u32;
let frame_delay = image::Delay::from_numer_denom_ms(1000, fps);
// Hold the last frame for a second, so it is obvious where the loop restarts.
let last_delay = image::Delay::from_numer_denom_ms(1000, 1);
// All frames of a GIF share one canvas, so grow the smaller ones to fit.
let size = max_size(frames);
let last_index = frames.len() - 1;
for (i, frame) in frames.iter().enumerate() {
let delay = if i == last_index {
last_delay
} else {
frame_delay
};
let image = pad_to(frame, size);
encoder.encode_frame(image::Frame::from_parts(image, 0, 0, delay))?;
}
Ok(())
}
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(err) => RecordingError::Io { path, err },
err => RecordingError::Encode(err),
})?;
}
Ok(())
}
fn create_parent_dir(path: &Path) -> 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,
})?;
}
Ok(())
}
/// The size of the largest frame, per axis.
fn max_size(frames: &[RgbaImage]) -> (u32, u32) {
frames.iter().fold((1, 1), |(w, h), frame| {
(w.max(frame.width()), h.max(frame.height()))
})
}
/// Copy `image` into the top-left corner of a transparent image of the given size.
fn pad_to(image: &RgbaImage, (width, height): (u32, u32)) -> RgbaImage {
if image.dimensions() == (width, height) {
return image.clone();
}
let mut padded = RgbaImage::new(width, height);
for (x, y, pixel) in image.enumerate_pixels() {
padded.put_pixel(x, y, *pixel);
}
padded
}