mirror of
https://github.com/emilk/egui.git
synced 2026-08-29 04:40:03 -04:00
MP4 support
This commit is contained in:
@@ -76,8 +76,8 @@ update the snapshot.
|
|||||||
|
|
||||||
## Recording
|
## Recording
|
||||||
|
|
||||||
With the `recording` feature you can record a test as an animated GIF, which is useful to see
|
With the `recording` feature you can record a test as an animated GIF or an MP4 video, which is
|
||||||
what a test actually does. Recording renders every egui pass, so it needs a renderer:
|
useful to see what a test actually does. Recording renders every egui pass, so it needs a renderer:
|
||||||
enable the `wgpu` feature too.
|
enable the `wgpu` feature too.
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
@@ -88,6 +88,7 @@ enable the `wgpu` feature too.
|
|||||||
use egui_kittest::RecordingOptions;
|
use egui_kittest::RecordingOptions;
|
||||||
|
|
||||||
harness.start_recording(RecordingOptions::gif("hello.gif", 10.0));
|
harness.start_recording(RecordingOptions::gif("hello.gif", 10.0));
|
||||||
|
// …or `RecordingOptions::mp4("hello.mp4", 10.0)`
|
||||||
harness.run();
|
harness.run();
|
||||||
harness.finish_recording().unwrap();
|
harness.finish_recording().unwrap();
|
||||||
}
|
}
|
||||||
@@ -96,10 +97,16 @@ enable the `wgpu` feature too.
|
|||||||
You can also record without touching the test:
|
You can also record without touching the test:
|
||||||
|
|
||||||
* `KITTEST_RECORD=1 cargo test` writes a GIF per test to `tests/snapshots/recordings`
|
* `KITTEST_RECORD=1 cargo test` writes a GIF per test to `tests/snapshots/recordings`
|
||||||
* `KITTEST_RECORD=open cargo test` writes each GIF to a temporary file and opens it
|
* `KITTEST_RECORD=mp4 cargo test` writes MP4s instead of GIFs
|
||||||
|
* `KITTEST_RECORD=open cargo test` writes each recording to a temporary file and opens it
|
||||||
|
(`open-mp4` for an MP4)
|
||||||
* `save_gif_on_failure = true` in `kittest.toml` writes a GIF to `tests/snapshots/failures`,
|
* `save_gif_on_failure = true` in `kittest.toml` writes a GIF to `tests/snapshots/failures`,
|
||||||
but only for tests that fail
|
but only for tests that fail
|
||||||
|
|
||||||
|
MP4 files are much smaller than GIFs, but need [`ffmpeg`](https://ffmpeg.org/) on the `PATH`;
|
||||||
|
without it we save a GIF next to the requested path instead. MP4 also has no alpha channel,
|
||||||
|
so transparent pixels turn black.
|
||||||
|
|
||||||
The recorder is an `egui::Plugin` (`RecordingPlugin`), so you can also register it on any
|
The recorder is an `egui::Plugin` (`RecordingPlugin`), so you can also register it on any
|
||||||
`egui::Context` yourself.
|
`egui::Context` yourself.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Record an egui session as an animated GIF or a sequence of PNG files.
|
//! Record an egui session as an animated GIF, an MP4 video, or a sequence of PNG files.
|
||||||
//!
|
//!
|
||||||
//! The recorder is an [`egui::Plugin`], so it can record any [`egui::Context`],
|
//! 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`]
|
//! not just a [`crate::Harness`]. It renders every pass with its own [`TestRenderer`]
|
||||||
@@ -7,8 +7,9 @@
|
|||||||
//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`].
|
//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`].
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufWriter;
|
use std::io::{BufWriter, Write as _};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
use egui::{Context, FullOutput, TexturesDelta};
|
use egui::{Context, FullOutput, TexturesDelta};
|
||||||
use image::RgbaImage;
|
use image::RgbaImage;
|
||||||
@@ -22,7 +23,9 @@ use crate::TestRenderer;
|
|||||||
/// whether the test passed or not:
|
/// whether the test passed or not:
|
||||||
///
|
///
|
||||||
/// - `KITTEST_RECORD=1` writes to `{output_path}/recordings/{test_name}.gif`
|
/// - `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
|
/// - `KITTEST_RECORD=mp4` writes an MP4 instead of a GIF
|
||||||
|
/// - `KITTEST_RECORD=open` writes to a temporary file and shows it in the default viewer
|
||||||
|
/// - `KITTEST_RECORD=open-mp4` does both
|
||||||
pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD";
|
pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD";
|
||||||
|
|
||||||
/// What to write when the recording is saved.
|
/// What to write when the recording is saved.
|
||||||
@@ -38,6 +41,20 @@ pub enum RecordKind {
|
|||||||
frame_rate: f32,
|
frame_rate: f32,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Save an H.264 MP4 to `path`.
|
||||||
|
///
|
||||||
|
/// This pipes the frames into [`ffmpeg`](https://ffmpeg.org/), which must be installed
|
||||||
|
/// and on the `PATH`. Without it we save a GIF next to `path` instead.
|
||||||
|
///
|
||||||
|
/// MP4 has no alpha channel, so transparent pixels turn black.
|
||||||
|
Mp4 {
|
||||||
|
/// Where to write the video.
|
||||||
|
path: PathBuf,
|
||||||
|
|
||||||
|
/// Frames per second.
|
||||||
|
frame_rate: f32,
|
||||||
|
},
|
||||||
|
|
||||||
/// Save a sequence of PNG files (`frame_0000.png`, `frame_0001.png`, …) into `directory`.
|
/// Save a sequence of PNG files (`frame_0000.png`, `frame_0001.png`, …) into `directory`.
|
||||||
PngSequence {
|
PngSequence {
|
||||||
/// Directory to write the PNG files into. It is created if it is missing.
|
/// Directory to write the PNG files into. It is created if it is missing.
|
||||||
@@ -88,6 +105,20 @@ impl RecordingOptions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record an MP4 to `path` at the given frame rate,
|
||||||
|
/// with the default trigger ([`RecordingTrigger::ChangedFrames`]).
|
||||||
|
///
|
||||||
|
/// Needs [`ffmpeg`](https://ffmpeg.org/) on the `PATH`; see [`RecordKind::Mp4`].
|
||||||
|
pub fn mp4(path: impl Into<PathBuf>, frame_rate: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: RecordKind::Mp4 {
|
||||||
|
path: path.into(),
|
||||||
|
frame_rate,
|
||||||
|
},
|
||||||
|
trigger: RecordingTrigger::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a PNG sequence into `directory`,
|
/// Record a PNG sequence into `directory`,
|
||||||
/// with the default trigger ([`RecordingTrigger::ChangedFrames`]).
|
/// with the default trigger ([`RecordingTrigger::ChangedFrames`]).
|
||||||
pub fn png_sequence(directory: impl Into<PathBuf>) -> Self {
|
pub fn png_sequence(directory: impl Into<PathBuf>) -> Self {
|
||||||
@@ -128,20 +159,27 @@ pub enum RecordingError {
|
|||||||
|
|
||||||
/// Failed to encode the image data.
|
/// Failed to encode the image data.
|
||||||
Encode(image::ImageError),
|
Encode(image::ImageError),
|
||||||
|
|
||||||
|
/// `ffmpeg` ran, but did not produce a video.
|
||||||
|
Ffmpeg {
|
||||||
|
/// What `ffmpeg` complained about.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for RecordingError {
|
impl core::fmt::Display for RecordingError {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::NotRecording => write!(f, "No recording is running"),
|
Self::NotRecording => write!(f, "No recording is running"),
|
||||||
Self::NoFrames => write!(f, "The recording contains no frames"),
|
Self::NoFrames => write!(f, "The recording contains no frames"),
|
||||||
Self::Io { path, err } => write!(f, "Failed to write {}: {err}", path.display()),
|
Self::Io { path, err } => write!(f, "Failed to write {}: {err}", path.display()),
|
||||||
Self::Encode(err) => write!(f, "Failed to encode the recording: {err}"),
|
Self::Encode(err) => write!(f, "Failed to encode the recording: {err}"),
|
||||||
|
Self::Ffmpeg { message } => write!(f, "ffmpeg failed: {message}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for RecordingError {}
|
impl core::error::Error for RecordingError {}
|
||||||
|
|
||||||
impl From<image::ImageError> for RecordingError {
|
impl From<image::ImageError> for RecordingError {
|
||||||
fn from(err: image::ImageError) -> Self {
|
fn from(err: image::ImageError) -> Self {
|
||||||
@@ -172,11 +210,11 @@ pub struct RecordingPlugin {
|
|||||||
uploaded_font_atlas: bool,
|
uploaded_font_atlas: bool,
|
||||||
|
|
||||||
/// Set when the harness started the recording by itself (see [`crate::Harness`]).
|
/// Set when the harness started the recording by itself (see [`crate::Harness`]).
|
||||||
pub(crate) auto_save: Option<AutoSaveMode>,
|
pub(crate) auto_save: Option<AutoSave>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for RecordingPlugin {
|
impl core::fmt::Debug for RecordingPlugin {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
f.debug_struct("RecordingPlugin")
|
f.debug_struct("RecordingPlugin")
|
||||||
.field("options", &self.options)
|
.field("options", &self.options)
|
||||||
.field("active", &self.active)
|
.field("active", &self.active)
|
||||||
@@ -261,33 +299,29 @@ impl RecordingPlugin {
|
|||||||
self.auto_save = None;
|
self.auto_save = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the captured frames to disk.
|
/// Write the captured frames to disk, and return the file or directory that was written.
|
||||||
|
///
|
||||||
|
/// The returned path is not always the one in the options: an MP4 falls back to a GIF
|
||||||
|
/// when `ffmpeg` is missing.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns an error if there are no frames, or if writing fails.
|
/// Returns an error if there are no frames, or if writing fails.
|
||||||
pub fn save(&self) -> Result<(), RecordingError> {
|
pub fn save(&self) -> Result<PathBuf, RecordingError> {
|
||||||
if self.frames.is_empty() {
|
if self.frames.is_empty() {
|
||||||
return Err(RecordingError::NoFrames);
|
return Err(RecordingError::NoFrames);
|
||||||
}
|
}
|
||||||
|
|
||||||
match &self.options.kind {
|
match &self.options.kind {
|
||||||
RecordKind::Gif { path, frame_rate } => save_gif(path, &self.frames, *frame_rate),
|
RecordKind::Gif { path, frame_rate } => save_gif(path, &self.frames, *frame_rate),
|
||||||
|
RecordKind::Mp4 { path, frame_rate } => save_mp4(path, &self.frames, *frame_rate),
|
||||||
RecordKind::PngSequence { directory } => save_png_sequence(directory, &self.frames),
|
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.
|
/// Change where the recording will be written.
|
||||||
pub(crate) fn set_output_path(&mut self, new_path: PathBuf) {
|
pub(crate) fn set_output_path(&mut self, new_path: PathBuf) {
|
||||||
match &mut self.options.kind {
|
match &mut self.options.kind {
|
||||||
RecordKind::Gif { path, .. } => *path = new_path,
|
RecordKind::Gif { path, .. } | RecordKind::Mp4 { path, .. } => *path = new_path,
|
||||||
RecordKind::PngSequence { directory } => *directory = new_path,
|
RecordKind::PngSequence { directory } => *directory = new_path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -372,31 +406,62 @@ impl egui::Plugin for RecordingPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a recording that the harness started by itself is saved when the harness is dropped.
|
/// When a recording that the harness started by itself is saved.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum AutoSaveMode {
|
pub(crate) enum AutoSaveMode {
|
||||||
/// Save only if the test failed. Written to `{output_path}/failures/{test_name}.gif`.
|
/// Save only if the test failed. Written to `{output_path}/failures/{test_name}.{ext}`.
|
||||||
OnFailure,
|
OnFailure,
|
||||||
|
|
||||||
/// Always save. Written to `{output_path}/recordings/{test_name}.gif`.
|
/// Always save. Written to `{output_path}/recordings/{test_name}.{ext}`.
|
||||||
Always,
|
Always,
|
||||||
|
|
||||||
/// Always save to a temporary file, and show it in the default image viewer.
|
/// Always save to a temporary file, and show it in the default viewer.
|
||||||
Open,
|
Open,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoSaveMode {
|
/// What such a recording is saved as.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AutoSaveFormat {
|
||||||
|
Gif,
|
||||||
|
Mp4,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoSaveFormat {
|
||||||
|
fn extension(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Gif => "gif",
|
||||||
|
Self::Mp4 => "mp4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options(self, path: PathBuf) -> RecordingOptions {
|
||||||
|
match self {
|
||||||
|
Self::Gif => RecordingOptions::gif(path, AUTO_FRAME_RATE),
|
||||||
|
Self::Mp4 => RecordingOptions::mp4(path, AUTO_FRAME_RATE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a recording that the harness started by itself is saved when the harness is dropped.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) struct AutoSave {
|
||||||
|
pub mode: AutoSaveMode,
|
||||||
|
pub format: AutoSaveFormat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoSave {
|
||||||
/// Where to write the recording of the test we are running.
|
/// Where to write the recording of the test we are running.
|
||||||
fn path(self) -> PathBuf {
|
fn path(self) -> PathBuf {
|
||||||
let name = std::thread::current()
|
let name = std::thread::current()
|
||||||
.name()
|
.name()
|
||||||
.map_or_else(|| "recording".to_owned(), sanitize_file_name);
|
.map_or_else(|| "recording".to_owned(), sanitize_file_name);
|
||||||
|
let extension = self.format.extension();
|
||||||
|
|
||||||
let subdirectory = match self {
|
let subdirectory = match self.mode {
|
||||||
Self::OnFailure => "failures",
|
AutoSaveMode::OnFailure => "failures",
|
||||||
Self::Always => "recordings",
|
AutoSaveMode::Always => "recordings",
|
||||||
Self::Open => {
|
AutoSaveMode::Open => {
|
||||||
if let Some(path) = temp_gif_path(&name) {
|
if let Some(path) = temp_recording_path(&name, extension) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
"recordings" // Fall back to a normal recording.
|
"recordings" // Fall back to a normal recording.
|
||||||
@@ -406,17 +471,17 @@ impl AutoSaveMode {
|
|||||||
crate::config::config()
|
crate::config::config()
|
||||||
.output_path()
|
.output_path()
|
||||||
.join(subdirectory)
|
.join(subdirectory)
|
||||||
.join(format!("{name}.gif"))
|
.join(format!("{name}.{extension}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A GIF in the temporary directory, which we keep after the test, so that the image
|
/// A file in the temporary directory, which we keep after the test, so that the
|
||||||
/// viewer can still read it.
|
/// viewer can still read it.
|
||||||
fn temp_gif_path(name: &str) -> Option<PathBuf> {
|
fn temp_recording_path(name: &str, extension: &str) -> Option<PathBuf> {
|
||||||
tempfile::Builder::new()
|
tempfile::Builder::new()
|
||||||
.disable_cleanup(true)
|
.disable_cleanup(true)
|
||||||
.prefix(&format!("kittest-recording-{name}-"))
|
.prefix(&format!("kittest-recording-{name}-"))
|
||||||
.suffix(".gif")
|
.suffix(&format!(".{extension}"))
|
||||||
.tempfile()
|
.tempfile()
|
||||||
.inspect_err(|err| log::error!("egui_kittest: failed to create a temporary file: {err}"))
|
.inspect_err(|err| log::error!("egui_kittest: failed to create a temporary file: {err}"))
|
||||||
.ok()
|
.ok()
|
||||||
@@ -431,20 +496,27 @@ fn sanitize_file_name(name: &str) -> String {
|
|||||||
/// What [`RECORD_ENV_VAR`] asks for.
|
/// What [`RECORD_ENV_VAR`] asks for.
|
||||||
///
|
///
|
||||||
/// Read once, then cached, so that a test cannot change it halfway through a run.
|
/// Read once, then cached, so that a test cannot change it halfway through a run.
|
||||||
pub(crate) fn record_env_var() -> Option<AutoSaveMode> {
|
pub(crate) fn record_env_var() -> Option<AutoSave> {
|
||||||
static MODE: std::sync::OnceLock<Option<AutoSaveMode>> = std::sync::OnceLock::new();
|
static MODE: std::sync::OnceLock<Option<AutoSave>> = std::sync::OnceLock::new();
|
||||||
*MODE.get_or_init(|| {
|
*MODE.get_or_init(|| {
|
||||||
let value = std::env::var(RECORD_ENV_VAR).ok()?;
|
let value = std::env::var(RECORD_ENV_VAR).ok()?;
|
||||||
|
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
let (mode, format) = match value.trim().to_ascii_lowercase().as_str() {
|
||||||
"open" => Some(AutoSaveMode::Open),
|
"1" | "true" | "yes" | "on" | "gif" => (AutoSaveMode::Always, AutoSaveFormat::Gif),
|
||||||
"1" | "true" | "yes" | "on" => Some(AutoSaveMode::Always),
|
"mp4" => (AutoSaveMode::Always, AutoSaveFormat::Mp4),
|
||||||
"" | "0" | "false" | "no" | "off" => None,
|
"open" | "open-gif" => (AutoSaveMode::Open, AutoSaveFormat::Gif),
|
||||||
|
"open-mp4" => (AutoSaveMode::Open, AutoSaveFormat::Mp4),
|
||||||
|
"" | "0" | "false" | "no" | "off" => return None,
|
||||||
other => {
|
other => {
|
||||||
log::warn!("Ignoring {RECORD_ENV_VAR}={other:?}: expected `1` or `open`");
|
log::warn!(
|
||||||
None
|
"Ignoring {RECORD_ENV_VAR}={other:?}: expected \
|
||||||
|
`1`, `mp4`, `open` or `open-mp4`"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
Some(AutoSave { mode, format })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,13 +552,13 @@ impl<State> crate::Harness<'_, State> {
|
|||||||
self.recording_auto_save = None;
|
self.recording_auto_save = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the recording and write it to disk.
|
/// Stop the recording and write it to disk, returning the path that was written.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Returns [`RecordingError::NotRecording`] if nothing was being recorded,
|
/// Returns [`RecordingError::NotRecording`] if nothing was being recorded,
|
||||||
/// [`RecordingError::NoFrames`] if no frame was captured,
|
/// [`RecordingError::NoFrames`] if no frame was captured,
|
||||||
/// or an I/O or encoding error if writing failed.
|
/// or an I/O or encoding error if writing failed.
|
||||||
pub fn finish_recording(&mut self) -> Result<(), RecordingError> {
|
pub fn finish_recording(&mut self) -> Result<PathBuf, RecordingError> {
|
||||||
self.recording_auto_save = None;
|
self.recording_auto_save = None;
|
||||||
|
|
||||||
let result = self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
|
let result = self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
@@ -517,18 +589,21 @@ impl<State> crate::Harness<'_, State> {
|
|||||||
|
|
||||||
/// Start recording if the environment variable or the `kittest.toml` asks for it.
|
/// Start recording if the environment variable or the `kittest.toml` asks for it.
|
||||||
pub(crate) fn maybe_start_auto_recording(&mut self) {
|
pub(crate) fn maybe_start_auto_recording(&mut self) {
|
||||||
let mode = if let Some(mode) = record_env_var() {
|
let auto_save = if let Some(auto_save) = record_env_var() {
|
||||||
mode
|
auto_save
|
||||||
} else if crate::config::config().save_gif_on_failure() {
|
} else if crate::config::config().save_gif_on_failure() {
|
||||||
AutoSaveMode::OnFailure
|
AutoSave {
|
||||||
|
mode: AutoSaveMode::OnFailure,
|
||||||
|
format: AutoSaveFormat::Gif,
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The file name contains the test name, which we only look up when we save,
|
// The file name contains the test name, which we only look up when we save,
|
||||||
// so record to a placeholder path for now.
|
// so record to a placeholder path for now.
|
||||||
let options = RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE);
|
let options = auto_save.format.options(PathBuf::new());
|
||||||
install(&self.ctx, options, Some(mode));
|
install(&self.ctx, options, Some(auto_save));
|
||||||
self.recording_auto_save = Some(AutoSaveOnDrop {
|
self.recording_auto_save = Some(AutoSaveOnDrop {
|
||||||
ctx: self.ctx.clone(),
|
ctx: self.ctx.clone(),
|
||||||
});
|
});
|
||||||
@@ -544,7 +619,7 @@ pub(crate) fn install_idle(ctx: &Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Register a [`RecordingPlugin`] on `ctx`, or restart the one that is already registered.
|
/// Register a [`RecordingPlugin`] on `ctx`, or restart the one that is already registered.
|
||||||
fn install(ctx: &Context, options: RecordingOptions, auto_save: Option<AutoSaveMode>) {
|
fn install(ctx: &Context, options: RecordingOptions, auto_save: Option<AutoSave>) {
|
||||||
let restarted = ctx
|
let restarted = ctx
|
||||||
.with_plugin::<RecordingPlugin, _>(|plugin| {
|
.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
plugin.restart(options.clone());
|
plugin.restart(options.clone());
|
||||||
@@ -568,29 +643,28 @@ pub(crate) struct AutoSaveOnDrop {
|
|||||||
impl Drop for AutoSaveOnDrop {
|
impl Drop for AutoSaveOnDrop {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
|
self.ctx.with_plugin::<RecordingPlugin, _>(|plugin| {
|
||||||
let Some(mode) = plugin.auto_save.take() else {
|
let Some(auto_save) = plugin.auto_save.take() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A failing test panics, either from an assert or from the snapshot results,
|
// A failing test panics, either from an assert or from the snapshot results,
|
||||||
// which are dropped before this.
|
// which are dropped before this.
|
||||||
if mode == AutoSaveMode::OnFailure && !std::thread::panicking() {
|
if auto_save.mode == AutoSaveMode::OnFailure && !std::thread::panicking() {
|
||||||
plugin.stop();
|
plugin.stop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
plugin.set_output_path(mode.path());
|
plugin.set_output_path(auto_save.path());
|
||||||
let path = plugin.output_path().to_path_buf();
|
|
||||||
|
|
||||||
match plugin.save() {
|
match plugin.save() {
|
||||||
Ok(()) => {
|
Ok(path) => {
|
||||||
eprintln!("egui_kittest: saved a recording to {}", path.display());
|
eprintln!("egui_kittest: saved a recording to {}", path.display());
|
||||||
|
|
||||||
if mode == AutoSaveMode::Open
|
if auto_save.mode == AutoSaveMode::Open
|
||||||
&& let Err(err) = open::that_detached(&path)
|
&& let Err(err) = open::that_detached(&path)
|
||||||
{
|
{
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"egui_kittest: failed to open {} in the default image viewer: {err}",
|
"egui_kittest: failed to open {} in the default viewer: {err}",
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -645,7 +719,7 @@ fn font_atlas_delta(ctx: &Context) -> TexturesDelta {
|
|||||||
impl LazyRenderer {
|
impl LazyRenderer {
|
||||||
fn handle_delta(&mut self, delta: &mut TexturesDelta) {
|
fn handle_delta(&mut self, delta: &mut TexturesDelta) {
|
||||||
match self {
|
match self {
|
||||||
Self::Uninitialized { textures_delta } => textures_delta.append(std::mem::take(delta)),
|
Self::Uninitialized { textures_delta } => textures_delta.append(core::mem::take(delta)),
|
||||||
Self::Ready(renderer) => renderer.handle_delta(delta),
|
Self::Ready(renderer) => renderer.handle_delta(delta),
|
||||||
Self::Failed => delta.clear(), // Don't panic when the delta is dropped.
|
Self::Failed => delta.clear(), // Don't panic when the delta is dropped.
|
||||||
}
|
}
|
||||||
@@ -692,7 +766,7 @@ impl Drop for LazyRenderer {
|
|||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Saving
|
// Saving
|
||||||
|
|
||||||
fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), RecordingError> {
|
fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<PathBuf, RecordingError> {
|
||||||
create_parent_dir(path)?;
|
create_parent_dir(path)?;
|
||||||
|
|
||||||
let file = File::create(path).map_err(|err| RecordingError::Io {
|
let file = File::create(path).map_err(|err| RecordingError::Io {
|
||||||
@@ -702,7 +776,7 @@ fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), Re
|
|||||||
let mut encoder = GifEncoder::new(BufWriter::new(file));
|
let mut encoder = GifEncoder::new(BufWriter::new(file));
|
||||||
encoder.set_repeat(Repeat::Infinite)?;
|
encoder.set_repeat(Repeat::Infinite)?;
|
||||||
|
|
||||||
let fps = frame_rate.clamp(1.0, 100.0).round() as u32;
|
let fps = frame_rate.clamp(1.0, MAX_FRAME_RATE).round() as u32;
|
||||||
let frame_delay = image::Delay::from_numer_denom_ms(1000, fps);
|
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.
|
// 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);
|
let last_delay = image::Delay::from_numer_denom_ms(1000, 1);
|
||||||
@@ -721,10 +795,109 @@ fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), Re
|
|||||||
encoder.encode_frame(image::Frame::from_parts(image, 0, 0, delay))?;
|
encoder.encode_frame(image::Frame::from_parts(image, 0, 0, delay))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(path.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn save_png_sequence(directory: &Path, frames: &[RgbaImage]) -> Result<(), RecordingError> {
|
/// The encoder we pipe the frames into. It must be on the `PATH`.
|
||||||
|
const FFMPEG: &str = "ffmpeg";
|
||||||
|
|
||||||
|
/// Encode the frames as an H.264 MP4, or save a GIF if `ffmpeg` is not installed.
|
||||||
|
fn save_mp4(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<PathBuf, RecordingError> {
|
||||||
|
create_parent_dir(path)?;
|
||||||
|
|
||||||
|
// All frames of a video share one size, and H.264 wants both sides to be even.
|
||||||
|
let (width, height) = max_size(frames);
|
||||||
|
let size = (round_up_to_even(width), round_up_to_even(height));
|
||||||
|
|
||||||
|
let mut child = match spawn_ffmpeg(path, size, frame_rate) {
|
||||||
|
Ok(child) => child,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
let gif_path = path.with_extension("gif");
|
||||||
|
log::warn!(
|
||||||
|
"egui_kittest: `{FFMPEG}` is not installed, so {} is saved as {} instead",
|
||||||
|
path.display(),
|
||||||
|
gif_path.display()
|
||||||
|
);
|
||||||
|
return save_gif(&gif_path, frames, frame_rate);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(RecordingError::Io {
|
||||||
|
path: PathBuf::from(FFMPEG),
|
||||||
|
err,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hold the last frame for a second, like the GIF does.
|
||||||
|
let hold = frame_rate.clamp(1.0, MAX_FRAME_RATE).round() as usize;
|
||||||
|
let mut frames = frames.iter().chain(core::iter::repeat_n(
|
||||||
|
frames.last().expect("`save` rejects empty recordings"),
|
||||||
|
hold,
|
||||||
|
));
|
||||||
|
|
||||||
|
// If ffmpeg dies early the pipe breaks; report what it said instead of the pipe error.
|
||||||
|
let mut stdin = child.stdin.take().expect("`spawn_ffmpeg` pipes stdin");
|
||||||
|
let write_result = frames.try_for_each(|frame| stdin.write_all(pad_to(frame, size).as_raw()));
|
||||||
|
drop(stdin); // Closing stdin tells ffmpeg to finish the file.
|
||||||
|
|
||||||
|
let output = child.wait_with_output().map_err(|err| RecordingError::Io {
|
||||||
|
path: PathBuf::from(FFMPEG),
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||||
|
return Err(RecordingError::Ffmpeg {
|
||||||
|
message: if message.is_empty() {
|
||||||
|
format!("{} while writing {}", output.status, path.display())
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
write_result.map_err(|err| RecordingError::Io {
|
||||||
|
path: path.to_path_buf(),
|
||||||
|
err,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(path.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start `ffmpeg`, ready to read raw RGBA frames of the given size from its stdin.
|
||||||
|
fn spawn_ffmpeg(
|
||||||
|
path: &Path,
|
||||||
|
(width, height): (u32, u32),
|
||||||
|
frame_rate: f32,
|
||||||
|
) -> std::io::Result<std::process::Child> {
|
||||||
|
Command::new(FFMPEG)
|
||||||
|
.args(["-hide_banner", "-loglevel", "error", "-y"])
|
||||||
|
// Input: what we write to stdin.
|
||||||
|
.args(["-f", "rawvideo", "-pix_fmt", "rgba"])
|
||||||
|
.args(["-s", &format!("{width}x{height}")])
|
||||||
|
.args([
|
||||||
|
"-framerate",
|
||||||
|
&frame_rate.clamp(1.0, MAX_FRAME_RATE).to_string(),
|
||||||
|
])
|
||||||
|
.args(["-i", "-"])
|
||||||
|
// Output: H.264 in an MP4 that any browser and player can show.
|
||||||
|
.args(["-c:v", "libx264", "-pix_fmt", "yuv420p"])
|
||||||
|
.args(["-movflags", "+faststart"])
|
||||||
|
.arg(path)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The highest frame rate we ask an encoder for.
|
||||||
|
const MAX_FRAME_RATE: f32 = 100.0;
|
||||||
|
|
||||||
|
fn round_up_to_even(value: u32) -> u32 {
|
||||||
|
value + value % 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_png_sequence(directory: &Path, frames: &[RgbaImage]) -> Result<PathBuf, RecordingError> {
|
||||||
std::fs::create_dir_all(directory).map_err(|err| RecordingError::Io {
|
std::fs::create_dir_all(directory).map_err(|err| RecordingError::Io {
|
||||||
path: directory.to_path_buf(),
|
path: directory.to_path_buf(),
|
||||||
err,
|
err,
|
||||||
@@ -738,7 +911,7 @@ fn save_png_sequence(directory: &Path, frames: &[RgbaImage]) -> Result<(), Recor
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(directory.to_path_buf())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_parent_dir(path: &Path) -> Result<(), RecordingError> {
|
fn create_parent_dir(path: &Path) -> Result<(), RecordingError> {
|
||||||
|
|||||||
@@ -49,6 +49,46 @@ fn records_a_gif() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Without `ffmpeg` this saves a GIF next to the requested path instead.
|
||||||
|
#[test]
|
||||||
|
fn records_an_mp4() {
|
||||||
|
let dir = tempdir().expect("tempdir");
|
||||||
|
let mp4_path = dir.path().join("counter.mp4");
|
||||||
|
|
||||||
|
let mut value = 0;
|
||||||
|
let mut harness = counter_harness(&mut value);
|
||||||
|
harness.start_recording(RecordingOptions::mp4(&mp4_path, 12.0));
|
||||||
|
|
||||||
|
harness.run();
|
||||||
|
harness.get_by_label_contains("count").click();
|
||||||
|
harness.run();
|
||||||
|
|
||||||
|
let path = harness.finish_recording().expect("save mp4");
|
||||||
|
|
||||||
|
if which_ffmpeg() {
|
||||||
|
assert_eq!(path, mp4_path);
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
path,
|
||||||
|
mp4_path.with_extension("gif"),
|
||||||
|
"without ffmpeg we should fall back to a GIF"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let size = std::fs::metadata(&path)
|
||||||
|
.expect("the recording exists")
|
||||||
|
.len();
|
||||||
|
assert!(size > 0, "the recording should not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn which_ffmpeg() -> bool {
|
||||||
|
std::process::Command::new("ffmpeg")
|
||||||
|
.arg("-version")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.status()
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn decode_gif_frames(path: &std::path::Path) -> usize {
|
fn decode_gif_frames(path: &std::path::Path) -> usize {
|
||||||
use image::AnimationDecoder as _;
|
use image::AnimationDecoder as _;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user