Unit-tested, auto-generated screenshots

This commit adds my first take at creating a harness for a user's guide
using the new capture functionality. The example has tests that ensure
the align widget creates the expected results.
This commit is contained in:
Jonathan Johnson 2024-01-04 13:56:45 -08:00
parent eb20133116
commit a197bb5e81
No known key found for this signature in database
GPG key ID: A66D6A34D6620579
15 changed files with 496 additions and 61 deletions

14
Cargo.lock generated
View file

@ -988,6 +988,13 @@ dependencies = [
"bitflags 2.4.1",
]
[[package]]
name = "guide-examples"
version = "0.0.0"
dependencies = [
"cushy",
]
[[package]]
name = "half"
version = "2.2.1"
@ -1180,7 +1187,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kludgine"
version = "0.7.0"
source = "git+https://github.com/khonsulabs/kludgine#6b0d5fa0477daaf79f75a6f7dad819377a45e645"
source = "git+https://github.com/khonsulabs/kludgine#8017775228d22b5efce6d6b7a89e81dfc9b25961"
dependencies = [
"ahash",
"alot",
@ -1193,6 +1200,7 @@ dependencies = [
"intentional",
"justjson",
"lyon_tessellation",
"palette",
"pollster",
"smallvec",
"unicode-bidi",
@ -2332,9 +2340,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.47"
version = "2.0.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1726efe18f42ae774cc644f330953a5e7b3c3003d3edcecf18850fe9d4dd9afb"
checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
dependencies = [
"proc-macro2",
"quote",

View file

@ -1,5 +1,5 @@
[workspace]
members = ["cushy-macros"]
members = ["cushy-macros", "guide/guide-examples"]
[package]
name = "cushy"
@ -41,6 +41,7 @@ zeroize = "1.6.1"
unicode-segmentation = "1.10.1"
pollster = "0.3.0"
png = "0.17.10"
image = { version = "0.24.7", features = ["png"] }
# [patch.crates-io]
@ -60,7 +61,6 @@ opt-level = 2
[dev-dependencies]
rand = "0.8.5"
image = { version = "0.24.7", features = ["png"] }
[profile.release]
# debug = true

View file

@ -17,6 +17,7 @@ fn main() {
.unwrap();
let initial_point = Point::new(Px::new(140), Px::new(150));
recorder.set_cursor_position(initial_point);
recorder.set_cursor_visible(true);
recorder.refresh().unwrap();
let mut animation = recorder.record_animated_png(60);
animation

View file

@ -12,15 +12,7 @@ fn main() {
.size(Size::new(320, 240))
.finish()
.unwrap();
image::save_buffer_with_format(
"examples/offscreen.png",
recorder.bytes(),
recorder.window.size().width.get(),
recorder.window.size().height.get(),
image::ColorType::Rgb8,
image::ImageFormat::Png,
)
.unwrap();
recorder.image().save("examples/offscreen.png").unwrap();
// Creating a recorder with alpha makes the virtual window transparent.
let recorder = ui()
@ -29,15 +21,7 @@ fn main() {
.size(Size::new(320, 240))
.finish()
.unwrap();
image::save_buffer_with_format(
"examples/offscreen-transparent.png",
recorder.bytes(),
recorder.window.size().width.get(),
recorder.window.size().height.get(),
image::ColorType::Rgba8,
image::ImageFormat::Png,
)
.unwrap();
recorder.image().save("examples/offscreen.png").unwrap();
}
#[test]

1
guide/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
book

6
guide/book.toml Normal file
View file

@ -0,0 +1,6 @@
[book]
authors = ["Jonathan Johnson"]
language = "en"
multilingual = false
src = "src"
title = "Cushy User's Guide"

View file

@ -0,0 +1,8 @@
[package]
name = "guide-examples"
version = "0.0.0"
edition = "2021"
publish = false
[dependencies]
cushy = { version = "0.2.0", path = "../../" }

View file

@ -0,0 +1,137 @@
use cushy::figures::units::{Lp, Px};
use cushy::figures::{Point, Size};
use cushy::styles::{Edges, ThemePair};
use cushy::widget::MakeWidget;
use cushy::widgets::Space;
use guide_examples::BookExample;
fn content() -> impl MakeWidget {
Space::primary().size(Size::squared(Px::new(32)))
}
fn main() {
BookExample::new(
"align-horizontal",
"Default Behavior"
.and(content())
.and("align_left()")
.and({
// ANCHOR: align-left
content().align_left()
// ANCHOR_END: align-left
})
.and("pad_by().align_left()")
.and({
// ANCHOR: align-left-pad
content()
.pad_by(Edges::default().with_left(Lp::inches(1)))
.align_left()
// ANCHOR_END: align-left-pad
})
.and("centered()")
.and({
// ANCHOR: centered
content().centered()
// ANCHOR_END: centered
})
.and("pad_by().align_right()")
.and({
// ANCHOR: align-right-pad
content()
.pad_by(Edges::default().with_right(Lp::inches(1)))
.align_right()
// ANCHOR_END: align-right-pad
})
.and("align_right()")
.and({
// ANCHOR: align-right
content().align_right()
// ANCHOR_END: align-right
})
.into_rows(),
)
.still_frame(|recorder| {
const LEFT: u32 = 40;
const PADDING: u32 = 96;
const RIGHT: u32 = 710;
const CENTER: u32 = 375;
let container_color = ThemePair::default().dark.surface.lowest_container;
let primary = ThemePair::default().dark.primary.color;
recorder.assert_pixel_color(Point::new(LEFT, 35), container_color, "surface");
// Default fills the entire space
recorder.assert_pixel_color(Point::new(LEFT, 70), primary, "default spacer");
recorder.assert_pixel_color(Point::new(CENTER, 70), primary, "default spacer");
recorder.assert_pixel_color(Point::new(RIGHT, 70), primary, "default spacer");
// align-left
recorder.assert_pixel_color(Point::new(LEFT, 140), primary, "align-left spacer");
recorder.assert_pixel_color(
Point::new(LEFT + PADDING, 140),
container_color,
"align-left empty",
);
// align-left-pad
recorder.assert_pixel_color(
Point::new(LEFT + PADDING, 215),
primary,
"align-left-pad spacer",
);
recorder.assert_pixel_color(
Point::new(LEFT, 215),
container_color,
"align-left-pad empty before",
);
recorder.assert_pixel_color(
Point::new(CENTER, 215),
container_color,
"align-left-pad empty after",
);
// centered
recorder.assert_pixel_color(Point::new(CENTER, 295), primary, "centered spacer");
recorder.assert_pixel_color(
Point::new(LEFT + PADDING, 295),
container_color,
"centered empty before",
);
recorder.assert_pixel_color(
Point::new(RIGHT - PADDING, 295),
container_color,
"centered empty after",
);
// align-right-pad
recorder.assert_pixel_color(
Point::new(RIGHT - PADDING, 360),
primary,
"align-right-pad spacer",
);
recorder.assert_pixel_color(
Point::new(CENTER, 360),
container_color,
"align-right-pad empty before",
);
recorder.assert_pixel_color(
Point::new(RIGHT, 360),
container_color,
"align-right-pad empty after",
);
// align-right
recorder.assert_pixel_color(Point::new(RIGHT, 435), primary, "align-right spacer");
recorder.assert_pixel_color(
Point::new(RIGHT - PADDING, 435),
container_color,
"align-right empty",
);
});
}
#[test]
fn runs() {
main();
}

View file

@ -0,0 +1,69 @@
use std::panic::AssertUnwindSafe;
use std::path::PathBuf;
use cushy::figures::units::Px;
use cushy::figures::Size;
use cushy::widget::MakeWidget;
use cushy::widgets::container::ContainerShadow;
use cushy::window::{Rgba8, VirtualRecorder, VirtualRecorderBuilder};
pub struct BookExample {
name: &'static str,
recorder: VirtualRecorderBuilder<Rgba8>,
}
fn target_dir() -> PathBuf {
let target_dir = std::env::current_dir()
.expect("missing current dir")
.parent()
.expect("missing guide folder")
.join("src")
.join("examples");
assert!(
target_dir.is_dir(),
"current directory is not guide-examples"
);
target_dir
}
impl BookExample {
pub fn new(name: &'static str, interface: impl MakeWidget) -> Self {
Self {
name,
recorder: interface
.contain()
.shadow(ContainerShadow::drop(Px::new(16), Px::new(32)))
.width(Px::new(750))
.build_recorder()
.with_alpha()
.resize_to_fit()
.size(Size::new(750, 432)),
}
}
pub fn still_frame<Test>(self, test: Test)
where
Test: FnOnce(&mut VirtualRecorder<Rgba8>),
{
let mut recorder = self.recorder.finish().unwrap();
let capture = std::env::var("CAPTURE").is_ok();
let errored = std::panic::catch_unwind(AssertUnwindSafe(|| test(&mut recorder))).is_err();
if errored || capture {
let path = target_dir().join(format!("{}.png", self.name));
recorder.image().save(&path).expect("error saving file");
println!("Wrote {}", path.display());
if errored {
std::process::exit(-1);
}
}
}
// pub fn animated<Test>(self, test: Test)
// where
// Test: FnOnce(&mut AnimationRecorder<'_, Rgb8>),
// {
// }
}

3
guide/src/SUMMARY.md Normal file
View file

@ -0,0 +1,3 @@
# Summary
- [Chapter 1](./chapter_1.md)

33
guide/src/chapter_1.md Normal file
View file

@ -0,0 +1,33 @@
# Aligning Widgets
![align.rs - horizontal-align](/examples/align-horizontal.png)
## Align a widget to the left
```rust,no_run,no_playground
{{#include ../guide-examples/examples/align.rs:align-left}}
```
## Align a widget to the left, with padding
```rust,no_run,no_playground
{{#include ../guide-examples/examples/align.rs:align-left-pad}}
```
## Align a widget to the center
```rust,no_run,no_playground
{{#include ../guide-examples/examples/align.rs:centered}}
```
## Align a widget to the right, with padding
```rust,no_run,no_playground
{{#include ../guide-examples/examples/align.rs:align-right-pad}}
```
## Align a widget to the right
```rust,no_run,no_playground
{{#include ../guide-examples/examples/align.rs:align-right}}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View file

@ -1742,7 +1742,7 @@ impl FixedTheme {
///
/// The goal of this type is to allow various tones of a given hue/saturation to
/// be generated easily.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug)]
pub struct ColorSource {
/// A measurement of hue, in degees, from -180 to 180.
///
@ -1760,6 +1760,13 @@ pub struct ColorSource {
pub saturation: ZeroToOne,
}
impl PartialEq for ColorSource {
fn eq(&self, other: &Self) -> bool {
(self.hue.into_degrees() - other.hue.into_degrees()).abs() < f32::EPSILON
&& self.saturation == other.saturation
}
}
impl ColorSource {
/// Returns a new source with the given hue (in degrees) and saturation (0.0
/// - 1.0).

View file

@ -3,6 +3,8 @@ use figures::Size;
use kludgine::Color;
use crate::context::{GraphicsContext, LayoutContext};
use crate::styles::components::PrimaryColor;
use crate::styles::{DynamicComponent, IntoDynamicComponentValue};
use crate::value::{IntoValue, Value};
use crate::widget::Widget;
use crate::ConstraintLimit;
@ -10,7 +12,7 @@ use crate::ConstraintLimit;
/// A widget that occupies space, optionally filling it with a color.
#[derive(Debug, Clone)]
pub struct Space {
color: Value<Color>,
color: Value<ColorSource>,
}
impl Default for Space {
@ -24,7 +26,7 @@ impl Space {
#[must_use]
pub const fn clear() -> Self {
Self {
color: Value::Constant(Color::CLEAR_BLACK),
color: Value::Constant(ColorSource::Color(Color::CLEAR_BLACK)),
}
}
@ -32,14 +34,38 @@ impl Space {
#[must_use]
pub fn colored(color: impl IntoValue<Color>) -> Self {
Self {
color: color.into_value(),
color: color
.into_value()
.map_each(|color| ColorSource::Color(*color)),
}
}
/// Returns a spacer that fills itself with `dynamic`'s color.
pub fn dynamic(dynamic: impl IntoDynamicComponentValue) -> Self {
Self {
color: dynamic
.into_dynamic_component()
.map_each(|component| ColorSource::Dynamic(component.clone())),
}
}
/// Returns a spacer that fills itself with the value of [`PrimaryColor`].
#[must_use]
pub fn primary() -> Self {
Self::dynamic(PrimaryColor)
}
}
impl Widget for Space {
fn redraw(&mut self, context: &mut GraphicsContext<'_, '_, '_, '_>) {
let color = self.color.get_tracking_redraw(context);
let source = self.color.get_tracking_redraw(context);
let color = match source {
ColorSource::Color(color) => color,
ColorSource::Dynamic(component) => component
.resolve(context)
.and_then(|component| Color::try_from(component).ok())
.unwrap_or(Color::CLEAR_BLACK),
};
context.fill(color);
}
@ -51,3 +77,9 @@ impl Widget for Space {
Size::default()
}
}
#[derive(Debug, PartialEq, Clone)]
enum ColorSource {
Color(Color),
Dynamic(DynamicComponent),
}

View file

@ -20,6 +20,7 @@ use figures::units::{Px, UPx};
use figures::{
Fraction, IntoSigned, IntoUnsigned, Point, Ranged, Rect, Round, ScreenScale, Size, Zero,
};
use image::{DynamicImage, RgbImage, RgbaImage};
use intentional::{Assert, Cast};
use kludgine::app::winit::dpi::{PhysicalPosition, PhysicalSize};
use kludgine::app::winit::event::{
@ -728,6 +729,7 @@ pub trait WindowBehavior: Sized + 'static {
}
}
#[allow(clippy::struct_excessive_bools)]
struct CushyWindow<T> {
behavior: T,
tree: Tree,
@ -745,6 +747,7 @@ struct CushyWindow<T> {
keyboard_activated: Option<WidgetId>,
min_inner_size: Option<Size<UPx>>,
max_inner_size: Option<Size<UPx>>,
resize_to_fit: bool,
theme: Option<DynamicReader<ThemePair>>,
current_theme: ThemePair,
theme_mode: Value<ThemeMode>,
@ -1146,6 +1149,7 @@ where
keyboard_activated: None,
min_inner_size: None,
max_inner_size: None,
resize_to_fit: false,
current_theme,
theme,
theme_mode,
@ -1172,7 +1176,7 @@ where
self.tree
.new_frame(self.redraw_status.invalidations().drain());
let resizable = window.is_resizable();
let resizable = window.is_resizable() || self.resize_to_fit;
let mut window = RunningWindow::new(
window,
graphics.id(),
@ -1237,6 +1241,8 @@ where
new_size = new_size.min(max_size);
}
layout_context.request_inner_size(new_size);
} else if self.resize_to_fit && window_size != layout_size {
layout_context.request_inner_size(layout_size);
}
self.root.set_layout(Rect::from(render_size.into_signed()));
@ -1895,7 +1901,9 @@ pub(crate) mod sealed {
use std::cell::RefCell;
use figures::units::UPx;
use figures::Size;
use figures::{Point, Size};
use image::DynamicImage;
use kludgine::Color;
use crate::app::Cushy;
use crate::context::sealed::InvalidationStatus;
@ -1940,6 +1948,8 @@ pub(crate) mod sealed {
const HAS_ALPHA: bool;
fn convert_rgba(data: &mut Vec<u8>, width: u32, bytes_per_row: u32);
fn load_image(data: &[u8], size: Size<UPx>) -> DynamicImage;
fn pixel_color(location: Point<UPx>, data: &[u8], size: Size<UPx>) -> Color;
}
}
@ -2708,6 +2718,31 @@ impl sealed::CaptureFormat for Rgb8 {
retain
});
}
fn load_image(data: &[u8], size: Size<UPx>) -> DynamicImage {
DynamicImage::ImageRgb8(
RgbImage::from_vec(size.width.get(), size.height.get(), data.to_vec())
.expect("incorrect dimensions"),
)
}
fn pixel_color(location: Point<UPx>, data: &[u8], size: Size<UPx>) -> Color {
let pixel_offset = pixel_offset(data, location, size, 3);
Color::new(pixel_offset[0], pixel_offset[1], pixel_offset[2], 255)
}
}
fn pixel_offset(
data: &[u8],
location: Point<UPx>,
size: Size<UPx>,
bytes_per_component: u32,
) -> &[u8] {
assert!(location.x < size.width && location.y < size.height);
let width = size.width.get();
let index = location.y.get() * width + location.x.get();
&data[usize::try_from(index * bytes_per_component).expect("offset out of bounds")..]
}
impl CaptureFormat for Rgba8 {}
@ -2727,6 +2762,23 @@ impl sealed::CaptureFormat for Rgba8 {
});
}
}
fn load_image(data: &[u8], size: Size<UPx>) -> DynamicImage {
DynamicImage::ImageRgba8(
RgbaImage::from_vec(size.width.get(), size.height.get(), data.to_vec())
.expect("incorrect dimensions"),
)
}
fn pixel_color(location: Point<UPx>, data: &[u8], size: Size<UPx>) -> Color {
let pixel_offset = pixel_offset(data, location, size, 4);
Color::new(
pixel_offset[0],
pixel_offset[1],
pixel_offset[2],
pixel_offset[3],
)
}
}
/// A builder of a [`VirtualRecorder`].
@ -2735,6 +2787,7 @@ pub struct VirtualRecorderBuilder<Format> {
size: Size<UPx>,
scale: f32,
format: PhantomData<Format>,
resize_to_fit: bool,
}
impl VirtualRecorderBuilder<Rgb8> {
@ -2745,6 +2798,7 @@ impl VirtualRecorderBuilder<Rgb8> {
size: Size::new(UPx::new(800), UPx::new(600)),
scale: 1.0,
format: PhantomData,
resize_to_fit: false,
}
}
@ -2756,6 +2810,7 @@ impl VirtualRecorderBuilder<Rgb8> {
contents: self.contents,
size: self.size,
scale: self.scale,
resize_to_fit: self.resize_to_fit,
format: PhantomData,
}
}
@ -2787,9 +2842,17 @@ where
self
}
/// Sets this virtual recorder to allow updating its size based on the
/// contents being rendered.
#[must_use]
pub fn resize_to_fit(mut self) -> Self {
self.resize_to_fit = true;
self
}
/// Returns an initialized [`VirtualRecorder`].
pub fn finish(self) -> Result<VirtualRecorder<Format>, VirtualRecorderError> {
VirtualRecorder::new(self.size, self.scale, self.contents)
VirtualRecorder::new(self.size, self.scale, self.resize_to_fit, self.contents)
}
}
@ -2864,7 +2927,9 @@ pub struct VirtualRecorder<Format = Rgb8> {
queue: Arc<wgpu::Queue>,
capture: Option<Box<Capture>>,
data: Vec<u8>,
data_size: Size<UPx>,
cursor: Dynamic<Point<Px>>,
cursor_visible: bool,
cursor_graphic: Drawing,
format: PhantomData<Format>,
}
@ -2881,6 +2946,7 @@ where
pub fn new(
size: Size<UPx>,
scale: f32,
resize_to_fit: bool,
contents: impl MakeWidget,
) -> Result<Self, VirtualRecorderError> {
let wgpu = wgpu::Instance::default();
@ -2912,11 +2978,18 @@ where
queue: Arc::new(queue),
cursor: Dynamic::default(),
cursor_graphic: Drawing::default(),
cursor_visible: false,
capture: None,
data: Vec::new(),
data_size: Size::ZERO,
format: PhantomData,
};
recorder.window.window.resize_to_fit = resize_to_fit;
recorder.refresh()?;
if resize_to_fit && recorder.window.state.size != recorder.window.size() {
recorder.refresh()?;
}
Ok(recorder)
}
@ -2927,6 +3000,52 @@ where
&self.data
}
/// Returns the color of the pixel at `location`.
///
/// # Panics
///
/// This function will panic if location is outside of the bounds of the
/// captured image. When the window's size has been changed, this function
/// operates on the size of the window when the last call to
/// [`Self::refresh()`] was made.
pub fn pixel_color<Unit>(&self, location: Point<Unit>) -> Color
where
Unit: Into<UPx>,
{
Format::pixel_color(location.map(Into::into), self.bytes(), self.data_size)
}
/// Asserts that the color of the pixel at `location` is `expected`.
///
/// This function allows for slight color variations. This is because of how
/// colorspace corrections can lead to rounding errors.
///
/// # Panics
///
/// This function panics if the color is not the expected color.
pub fn assert_pixel_color<Unit>(&self, location: Point<Unit>, expected: Color, component: &str)
where
Unit: Into<UPx>,
{
let location = location.map(Into::into);
let color = self.pixel_color(location);
let max_delta = color
.red()
.abs_diff(expected.red())
.max(color.green().abs_diff(expected.green()))
.max(color.blue().abs_diff(expected.blue()))
.max(color.alpha().abs_diff(expected.alpha()));
assert!(
max_delta <= 1,
"assertion failed: {component} at {location:?} was {color:?}, not {expected:?}"
);
}
/// Returns the current contents as an image.
pub fn image(&self) -> DynamicImage {
Format::load_image(self.bytes(), self.data_size)
}
fn recreate_buffers_if_needed(&mut self, size: Size<UPx>, bytes: u64, bytes_per_row: u32) {
if self
.capture
@ -2967,20 +3086,28 @@ where
}
fn redraw(&mut self) {
let render_size = self.window.kludgine.size().ceil();
let mut render_size = self.window.kludgine.size().ceil();
if self.window.state.size != render_size {
let current_scale = self.window.scale();
self.window
.resize(self.window.state.size, current_scale, &self.queue);
render_size = self.window.state.size;
}
let bytes_per_row = copy_buffer_aligned_bytes_per_row(render_size.width.get() * 4);
let size = u64::from(bytes_per_row) * u64::from(render_size.height.get());
self.recreate_buffers_if_needed(render_size, size, bytes_per_row);
let capture = self.capture.as_ref().assert("always initialized above");
let mut gfx = self.window.graphics(&self.device, &self.queue);
let mut frame = self.cursor_graphic.new_frame(&mut gfx);
frame.draw_shape(
Shape::filled_circle(Px::new(4), Color::WHITE, Origin::Center)
.translate_by(self.cursor.get()),
);
drop(frame);
if self.cursor_visible {
let mut gfx = self.window.graphics(&self.device, &self.queue);
let mut frame = self.cursor_graphic.new_frame(&mut gfx);
frame.draw_shape(
Shape::filled_circle(Px::new(4), Color::WHITE, Origin::Center)
.translate_by(self.cursor.get()),
);
drop(frame);
}
self.window.prepare(&self.device, &self.queue);
@ -3001,7 +3128,7 @@ where
},
&self.device,
&self.queue,
Some(&self.cursor_graphic),
self.cursor_visible.then_some(&self.cursor_graphic),
);
}
@ -3012,6 +3139,7 @@ where
let capture = self.capture.as_ref().assert("always initialized above");
capture.map_into::<Format>(&mut self.data, &self.device, &self.queue)?;
self.data_size = capture.texture.size();
Ok(())
}
@ -3021,11 +3149,28 @@ where
self.cursor.set(position);
}
/// Enables or disables drawing of the virtual cursor.
pub fn set_cursor_visible(&mut self, visible: bool) {
self.cursor_visible = visible;
}
/// Begins recording an animated png.
pub fn record_animated_png(&mut self, target_fps: u8) -> AnimationRecorder<'_, Format> {
AnimationRecorder {
target_fps,
assembler: FrameAssembler::spawn::<Format>(self.device.clone(), self.queue.clone()),
assembler: Some(FrameAssembler::spawn::<Format>(
self.device.clone(),
self.queue.clone(),
)),
recorder: self,
}
}
/// Returns a recorder that does not store any rendered frames.
pub fn simulate_animation(&mut self) -> AnimationRecorder<'_, Format> {
AnimationRecorder {
target_fps: 0,
assembler: None,
recorder: self,
}
}
@ -3040,7 +3185,7 @@ fn copy_buffer_aligned_bytes_per_row(width: u32) -> u32 {
pub struct AnimationRecorder<'a, Format> {
recorder: &'a mut VirtualRecorder<Format>,
target_fps: u8,
assembler: FrameAssembler,
assembler: Option<FrameAssembler>,
}
impl<Format> AnimationRecorder<'_, Format>
@ -3070,6 +3215,10 @@ where
/// Waits until `time`, rendering frames as needed.
pub fn wait_until(&mut self, time: Instant) -> Result<(), VirtualRecorderError> {
let Some(assembler) = self.assembler.as_ref() else {
return Ok(());
};
let frame_duration = Duration::from_micros(1_000_000 / u64::from(self.target_fps));
let mut last_frame = Instant::now();
@ -3090,19 +3239,19 @@ where
if final_frame || next_frame == now {
// Try to reuse an existing capture instead of forcing an
// allocation.
if let Ok(capture) = self.assembler.resuable_captures.try_recv() {
if let Ok(capture) = assembler.resuable_captures.try_recv() {
self.recorder.capture = Some(capture);
}
let elapsed = now.saturating_duration_since(last_frame);
last_frame = now;
self.recorder.redraw();
let capture = self.recorder.capture.take().assert("always present");
if self.assembler.sender.send((capture, elapsed)).is_err() {
if assembler.sender.send((capture, elapsed)).is_err() {
break;
}
}
if now > time {
if final_frame {
break;
}
@ -3114,8 +3263,13 @@ where
}
/// Encodes the currently recorded frames into a new file at `path`.
///
/// If this animation was created from
/// [`VirtualRecorder::simulate_animation`], this function will do nothing.
pub fn write_to(self, path: impl AsRef<Path>) -> Result<(), VirtualRecorderError> {
let frames = self.assembler.finish()?;
let Some(frames) = self.assembler.map(FrameAssembler::finish).transpose()? else {
return Ok(());
};
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
@ -3131,28 +3285,20 @@ where
encoder.set_animated(u32::try_from(frames.len()).assert("too many frames"), 0)?;
encoder.set_compression(png::Compression::Best);
let mut current_frame_delay = frames.first().assert("always at least one frame").duration;
encoder.set_frame_delay(
current_frame_delay
.as_millis()
// TODO should be checked
.cast(),
1_000,
)?;
let mut current_frame_delay = Duration::ZERO;
let mut writer = encoder.write_header()?;
for frame in &frames {
writer.write_image_data(&frame.data)?;
if current_frame_delay != frame.duration {
current_frame_delay = frame.duration;
// This has a limitation that a single frame can't be longer
// than ~6.5 seconds, but it ensures frame timing is more
// accurate.
writer.set_frame_delay(
current_frame_delay
.as_millis()
// TODO should be checked
.cast(),
1_000,
u16::try_from(current_frame_delay.as_nanos() / 100_000).unwrap_or(u16::MAX),
10_000,
)?;
}
writer.write_image_data(&frame.data)?;
}
writer.finish()?;