use std::collections::VecDeque;
use std::io::Cursor;
use bevy::prelude::*;
use bevy::remote::{error_codes, BrpError, BrpResult};
use bevy::render::view::window::screenshot::{Screenshot, ScreenshotCaptured};
use bevy::window::PrimaryWindow;
use image::{imageops::FilterType, ImageFormat};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::plugin::ready::FrameTicks;
const KEPT: usize = 8;
#[derive(Resource, Clone)]
pub struct DebugCaptureTarget {
pub image: Handle<Image>,
}
#[derive(Debug, Deserialize)]
pub struct ScreenshotParams {
pub path: Option<String>,
pub region: Option<Region>,
#[serde(default = "one")]
pub zoom: f32,
pub source: Option<String>,
}
fn one() -> f32 {
1.0
}
#[derive(Debug, Deserialize)]
pub struct Region {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub enum CaptureState {
Pending,
Done { png: Vec<u8>, width: u32, height: u32, provenance: Provenance },
Failed(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Provenance {
pub source: &'static str,
pub frame_requested: u32,
pub frame_captured: u32,
}
#[derive(Resource, Default)]
pub struct Captures {
next_id: u64,
entries: VecDeque<(u64, CaptureState)>,
}
impl Captures {
pub(crate) fn begin(&mut self) -> u64 {
self.next_id = self.next_id.saturating_add(1);
let id = self.next_id;
self.entries.push_back((id, CaptureState::Pending));
while self.entries.len() > KEPT {
self.entries.pop_front();
}
id
}
pub(crate) fn finish(&mut self, id: u64, state: CaptureState) {
if let Some(slot) = self.entries.iter_mut().find(|(i, _)| *i == id) {
slot.1 = state;
}
}
pub(crate) fn get(&self, id: u64) -> Option<&CaptureState> {
self.entries.iter().find(|(i, _)| *i == id).map(|(_, s)| s)
}
}
#[derive(Clone, Copy)]
enum Source {
Offscreen,
Window,
}
fn invalid_params(message: String) -> BrpError {
BrpError { code: error_codes::INVALID_PARAMS, message, data: None }
}
pub fn handle_screenshot(
In(params): In<Option<Value>>,
mut commands: Commands,
target: Option<Res<DebugCaptureTarget>>,
windows: Query<(), With<PrimaryWindow>>,
mut captures: ResMut<Captures>,
ticks: Res<FrameTicks>,
) -> BrpResult {
let params: ScreenshotParams = match params {
Some(p) => serde_json::from_value(p)
.map_err(|e| invalid_params(format!("invalid screenshot params: {e}")))?,
None => ScreenshotParams { path: None, region: None, zoom: 1.0, source: None },
};
let source = match params.source.as_deref() {
None if target.is_some() => Source::Offscreen,
None => Source::Window,
Some("offscreen") => Source::Offscreen,
Some("window") => Source::Window,
Some(_) => return Err(invalid_params("source must be offscreen or window".to_string())),
};
if !params.zoom.is_finite() || params.zoom <= 0.0 {
return Err(invalid_params(format!(
"zoom must be finite and greater than zero, got {}",
params.zoom
)));
}
if let Some(r) = ¶ms.region {
if r.width == 0 || r.height == 0 {
return Err(invalid_params(
"region width and height must both be non-zero".to_string(),
));
}
}
let screenshot = match source {
Source::Offscreen => match target {
Some(target) => Screenshot::image(target.image.clone()),
None => {
return Err(BrpError {
code: error_codes::INTERNAL_ERROR,
message: "no DebugCaptureTarget resource: the host must insert one with an Image a \
camera renders to, or ask for source \"window\""
.to_string(),
data: None,
})
}
},
Source::Window => {
if windows.is_empty() {
return Err(BrpError {
code: error_codes::INTERNAL_ERROR,
message: "no primary window to capture".to_string(),
data: None,
});
}
Screenshot::primary_window()
}
};
let id = captures.begin();
let source_name = match source {
Source::Offscreen => "offscreen",
Source::Window => "window",
};
let frame_requested = ticks.0;
let path = params.path;
let reported = path.clone();
let region = params.region;
let zoom = params.zoom;
commands.spawn(screenshot).observe(move |trigger: On<ScreenshotCaptured>, mut captures: ResMut<Captures>, ticks: Res<FrameTicks>| {
let provenance = Provenance { source: source_name, frame_requested, frame_captured: ticks.0 };
let state = match encode_capture(&trigger.image, region.as_ref(), zoom) {
Ok((png, width, height)) => match path.as_deref() {
Some(p) => match std::fs::write(p, &png) {
Ok(()) => {
info!("bevy_fairy/screenshot: wrote {p}");
CaptureState::Done { png, width, height, provenance }
}
Err(e) => CaptureState::Failed(format!("could not write {p}: {e}")),
},
None => CaptureState::Done { png, width, height, provenance },
},
Err(e) => CaptureState::Failed(e),
};
if let CaptureState::Failed(why) = &state {
error!("bevy_fairy/screenshot: capture {id} failed: {why}");
}
captures.finish(id, state);
});
Ok(json!({ "id": id, "path": reported }))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureParams {
pub id: u64,
}
pub(crate) fn handle_capture(In(params): In<Option<Value>>, captures: Res<Captures>) -> BrpResult {
let params: CaptureParams = match params {
Some(p) => serde_json::from_value(p).map_err(|e| {
invalid_params(format!("bevy_fairy/capture: {e}; example: {}", json!({"id": 1})))
})?,
None => return Err(invalid_params("bevy_fairy/capture needs {\"id\": n}".to_string())),
};
match captures.get(params.id) {
None => Err(invalid_params(format!(
"no capture {} (only the last {KEPT} are kept)",
params.id
))),
Some(CaptureState::Pending) => Ok(json!({ "status": "pending" })),
Some(CaptureState::Done { png, width, height, provenance }) => Ok(json!({
"status": "done",
"width": width,
"height": height,
"source": provenance.source,
"frame_requested": provenance.frame_requested,
"frame_captured": provenance.frame_captured,
"png_base64": base64_encode(png),
})),
Some(CaptureState::Failed(error)) => Ok(json!({ "status": "failed", "error": error })),
}
}
const MAX_ZOOMED_PIXELS: f64 = 16_777_216.0;
fn encode_capture(
image: &Image,
region: Option<&Region>,
zoom: f32,
) -> Result<(Vec<u8>, u32, u32), String> {
let dynamic = image
.clone()
.try_into_dynamic()
.map_err(|e| format!("the captured image could not be converted: {e}"))?;
let mut rgb = dynamic.to_rgb8();
if let Some(r) = region {
let (w, h) = (rgb.width(), rgb.height());
if r.x >= w || r.y >= h {
return Err(format!(
"region origin ({}, {}) is outside the {w}x{h} capture",
r.x, r.y
));
}
let width = r.width.min(w - r.x);
let height = r.height.min(h - r.y);
rgb = image::imageops::crop_imm(&rgb, r.x, r.y, width, height).to_image();
}
if zoom != 1.0 {
let (width, height) = zoomed_size(rgb.width(), rgb.height(), zoom)?;
rgb = image::imageops::resize(&rgb, width, height, FilterType::Lanczos3);
}
let (width, height) = (rgb.width(), rgb.height());
let mut buf = Cursor::new(Vec::new());
rgb.write_to(&mut buf, ImageFormat::Png)
.map_err(|e| format!("could not encode the PNG: {e}"))?;
Ok((buf.into_inner(), width, height))
}
fn zoomed_size(width: u32, height: u32, zoom: f32) -> Result<(u32, u32), String> {
let scaled_width = (f64::from(width) * f64::from(zoom)).round().max(1.0);
let scaled_height = (f64::from(height) * f64::from(zoom)).round().max(1.0);
if zoom > 1.0 && scaled_width * scaled_height > MAX_ZOOMED_PIXELS {
return Err(format!(
"zoom {zoom} would make the {width}x{height} capture a {scaled_width:.0}x{scaled_height:.0} image; \
`zoom` makes at most {MAX_ZOOMED_PIXELS:.0} pixels (4096 x 4096). Pass a smaller `zoom`, or a \
`region` to zoom into"
));
}
Ok((scaled_width as u32, scaled_height as u32))
}
const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub(crate) fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk.first().copied().unwrap_or(0);
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
if i <= chunk.len() {
out.push(char::from(BASE64[((n >> shift) & 63) as usize]));
} else {
out.push('=');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::system::RunSystemOnce;
#[test]
fn base64_matches_the_rfc_4648_vectors() {
let cases: [(&str, &str); 7] = [
("", ""),
("f", "Zg=="),
("fo", "Zm8="),
("foo", "Zm9v"),
("foob", "Zm9vYg=="),
("fooba", "Zm9vYmE="),
("foobar", "Zm9vYmFy"),
];
for (input, expected) in cases {
assert_eq!(base64_encode(input.as_bytes()), expected, "input {input:?}");
}
}
#[test]
fn a_done_capture_reports_where_and_when_it_was_taken() {
let mut world = World::new();
let mut captures = Captures::default();
let id = captures.begin();
let provenance = Provenance { source: "window", frame_requested: 40, frame_captured: 43 };
captures.finish(id, CaptureState::Done { png: vec![1, 2, 3], width: 4, height: 5, provenance });
world.insert_resource(captures);
let reply = world
.run_system_once_with(handle_capture, Some(json!({ "id": id })))
.expect("system runs")
.expect("capture answers");
assert_eq!(reply["status"], "done");
assert_eq!(reply["source"], "window");
assert_eq!(reply["frame_requested"], 40);
assert_eq!(reply["frame_captured"], 43);
}
#[test]
fn captures_keep_the_last_eight() {
let mut captures = Captures::default();
let ids: Vec<u64> = (0..10).map(|_| captures.begin()).collect();
assert!(captures.get(ids[0]).is_none());
assert!(captures.get(ids[1]).is_none());
assert_eq!(captures.get(ids[2]), Some(&CaptureState::Pending));
captures.finish(ids[9], CaptureState::Failed("boom".into()));
assert_eq!(captures.get(ids[9]), Some(&CaptureState::Failed("boom".into())));
}
fn black(width: u32, height: u32) -> Image {
Image::new_target_texture(width, height, bevy::render::render_resource::TextureFormat::Rgba8UnormSrgb, None)
}
#[test]
fn a_zoom_that_would_make_a_huge_image_is_refused_before_it_is_allocated() {
encode_capture(&black(4, 4), None, 1e9).expect_err("1e9 is refused");
encode_capture(&black(4, 4), None, f32::MAX).expect_err("f32::MAX is refused");
let refusal = encode_capture(&black(4, 4), None, 2500.0).expect_err("a 10000 x 10000 image is refused");
assert!(refusal.contains("zoom") && refusal.contains("10000x10000"), "{refusal}");
assert!(refusal.contains("region"), "it says how to get the detail another way: {refusal}");
}
#[test]
fn a_zoom_that_fits_still_scales_the_cropped_region() {
let (_, width, height) = encode_capture(&black(4, 4), None, 8.0).expect("8x of 4x4");
assert_eq!((width, height), (32, 32));
let (_, width, height) = encode_capture(&black(4, 4), None, 0.5).expect("0.5x of 4x4");
assert_eq!((width, height), (2, 2));
let region = Region { x: 0, y: 0, width: 2, height: 1 };
let (_, width, height) = encode_capture(&black(4, 4), Some(®ion), 16.0).expect("16x of a 2x1 crop");
assert_eq!((width, height), (32, 16));
}
#[test]
fn a_zoom_out_is_never_refused_and_a_zoom_in_is_bounded_by_the_output() {
assert_eq!(zoomed_size(7680, 4320, 0.9), Ok((6912, 3888)));
assert_eq!(zoomed_size(7680, 4320, 0.001), Ok((8, 4)));
assert_eq!(zoomed_size(4, 4, 8.0), Ok((32, 32)));
assert_eq!(zoomed_size(1000, 1000, 4.0), Ok((4000, 4000)));
for (width, height, zoom) in [(1000, 1000, 4.5), (7680, 4320, 1.5), (4, 4, 1e9), (1, 1, f32::MAX)] {
let refusal = zoomed_size(width, height, zoom).expect_err("larger than 4096 x 4096");
assert!(refusal.contains("region"), "{refusal}");
}
}
}