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};
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 },
Failed(String),
}
#[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>,
) -> 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 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>| {
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 }
}
Err(e) => CaptureState::Failed(format!("could not write {p}: {e}")),
},
None => CaptureState::Done { png, width, height },
},
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 }) => Ok(json!({
"status": "done",
"width": width,
"height": height,
"png_base64": base64_encode(png),
})),
Some(CaptureState::Failed(error)) => Ok(json!({ "status": "failed", "error": error })),
}
}
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 = ((rgb.width() as f32) * zoom).round().max(1.0) as u32;
let height = ((rgb.height() as f32) * zoom).round().max(1.0) as u32;
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))
}
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::*;
#[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 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())));
}
}