diff --git a/.github/actions/install-linux-deps/action.yml b/.github/actions/install-linux-deps/action.yml index c494200e5a649..401eaa76d2562 100644 --- a/.github/actions/install-linux-deps/action.yml +++ b/.github/actions/install-linux-deps/action.yml @@ -33,6 +33,10 @@ inputs: description: Install xkb (libxkbcommon-dev) required: false default: "false" + x264: + description: Install x264 (libx264-dev) + required: false + default: "false" runs: using: composite steps: @@ -47,3 +51,4 @@ runs: ${{ fromJSON(inputs.udev) && 'libudev-dev' || '' }} ${{ fromJSON(inputs.wayland) && 'libwayland-dev' || '' }} ${{ fromJSON(inputs.xkb) && 'libxkbcommon-dev' || '' }} + ${{ fromJSON(inputs.x264) && 'libx264-164 libx264-dev' || '' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d9bc2a99ba55..abb10ab2c8082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,7 @@ jobs: with: wayland: true xkb: true + x264: true - name: CI job # See tools/ci/src/main.rs for the commands this runs run: cargo run -p ci -- lints @@ -374,6 +375,7 @@ jobs: with: wayland: true xkb: true + x264: true - name: Build and check doc # See tools/ci/src/main.rs for the commands this runs run: cargo run -p ci -- doc diff --git a/crates/bevy_dev_tools/Cargo.toml b/crates/bevy_dev_tools/Cargo.toml index 14a72cf121d46..566525cc38f75 100644 --- a/crates/bevy_dev_tools/Cargo.toml +++ b/crates/bevy_dev_tools/Cargo.toml @@ -10,6 +10,7 @@ keywords = ["bevy"] [features] bevy_ci_testing = ["serde", "ron"] +screenrecording = ["x264"] [dependencies] # bevy @@ -19,12 +20,15 @@ bevy_camera = { path = "../bevy_camera", version = "0.18.0-dev" } bevy_color = { path = "../bevy_color", version = "0.18.0-dev" } bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.18.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.18.0-dev" } +bevy_image = { path = "../bevy_image", version = "0.18.0-dev" } +bevy_input = { path = "../bevy_input", version = "0.18.0-dev" } bevy_math = { path = "../bevy_math", version = "0.18.0-dev" } bevy_picking = { path = "../bevy_picking", version = "0.18.0-dev" } bevy_render = { path = "../bevy_render", version = "0.18.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.18.0-dev" } bevy_time = { path = "../bevy_time", version = "0.18.0-dev" } bevy_text = { path = "../bevy_text", version = "0.18.0-dev" } +bevy_transform = { path = "../bevy_transform", version = "0.18.0-dev" } bevy_shader = { path = "../bevy_shader", version = "0.18.0-dev" } bevy_ui = { path = "../bevy_ui", version = "0.18.0-dev" } bevy_ui_render = { path = "../bevy_ui_render", version = "0.18.0-dev" } @@ -35,6 +39,7 @@ bevy_state = { path = "../bevy_state", version = "0.18.0-dev" } serde = { version = "1.0", features = ["derive"], optional = true } ron = { version = "0.10", optional = true } tracing = { version = "0.1", default-features = false, features = ["std"] } +x264 = { version = "0.5.0", optional = true } [lints] workspace = true diff --git a/crates/bevy_dev_tools/src/ci_testing/config.rs b/crates/bevy_dev_tools/src/ci_testing/config.rs index 4e3573c49c3c2..074efae0c8608 100644 --- a/crates/bevy_dev_tools/src/ci_testing/config.rs +++ b/crates/bevy_dev_tools/src/ci_testing/config.rs @@ -1,4 +1,5 @@ use bevy_ecs::prelude::*; +use bevy_math::{Quat, Vec3}; use serde::Deserialize; /// A configuration struct for automated CI testing. @@ -6,7 +7,7 @@ use serde::Deserialize; /// It gets used when the `bevy_ci_testing` feature is enabled to automatically /// exit a Bevy app when run through the CI. This is needed because otherwise /// Bevy apps would be stuck in the game loop and wouldn't allow the CI to progress. -#[derive(Deserialize, Resource, PartialEq, Debug, Default)] +#[derive(Deserialize, Resource, PartialEq, Debug, Default, Clone)] pub struct CiTestingConfig { /// The setup for this test. #[serde(default)] @@ -17,7 +18,7 @@ pub struct CiTestingConfig { } /// Setup for a test. -#[derive(Deserialize, Default, PartialEq, Debug)] +#[derive(Deserialize, Default, PartialEq, Debug, Clone)] pub struct CiTestingSetup { /// The amount of time in seconds between frame updates. /// @@ -28,11 +29,11 @@ pub struct CiTestingSetup { } /// An event to send at a given frame, used for CI testing. -#[derive(Deserialize, PartialEq, Debug)] +#[derive(Deserialize, PartialEq, Debug, Clone)] pub struct CiTestingEventOnFrame(pub u32, pub CiTestingEvent); /// An event to send, used for CI testing. -#[derive(Deserialize, PartialEq, Debug)] +#[derive(Deserialize, PartialEq, Debug, Clone)] pub enum CiTestingEvent { /// Takes a screenshot of the entire screen, and saves the results to /// `screenshot-{current_frame}.png`. @@ -47,6 +48,17 @@ pub enum CiTestingEvent { /// /// [`AppExit::Success`]: bevy_app::AppExit::Success AppExit, + /// Starts recording the screen. + StartScreenRecording, + /// Stops recording the screen. + StopScreenRecording, + /// Smoothly moves the camera to the given position. + MoveCamera { + /// Position to move the camera to. + translation: Vec3, + /// Rotation to move the camera to. + rotation: Quat, + }, /// Sends a [`CiTestingCustomEvent`] using the given [`String`]. Custom(String), } diff --git a/crates/bevy_dev_tools/src/ci_testing/mod.rs b/crates/bevy_dev_tools/src/ci_testing/mod.rs index f763db407ccb1..34b99fb4a81cf 100644 --- a/crates/bevy_dev_tools/src/ci_testing/mod.rs +++ b/crates/bevy_dev_tools/src/ci_testing/mod.rs @@ -3,6 +3,10 @@ mod config; mod systems; +use crate::EasyCameraMovementPlugin; +#[cfg(feature = "screenrecording")] +use crate::EasyScreenRecordPlugin; + pub use self::config::*; use bevy_app::prelude::*; @@ -26,24 +30,48 @@ pub struct CiTestingPlugin; impl Plugin for CiTestingPlugin { fn build(&self, app: &mut App) { - #[cfg(not(target_arch = "wasm32"))] - let config: CiTestingConfig = { - let filename = std::env::var("CI_TESTING_CONFIG") - .unwrap_or_else(|_| "ci_testing_config.ron".to_string()); - std::fs::read_to_string(filename) - .map(|content| { - ron::from_str(&content) - .expect("error deserializing CI testing configuration file") - }) - .unwrap_or_default() - }; + let config = if !app.world().is_resource_added::() { + // Load configuration from file if not already setup + #[cfg(not(target_arch = "wasm32"))] + let config: CiTestingConfig = { + let filename = std::env::var("CI_TESTING_CONFIG") + .unwrap_or_else(|_| "ci_testing_config.ron".to_string()); + std::fs::read_to_string(filename) + .map(|content| { + ron::from_str(&content) + .expect("error deserializing CI testing configuration file") + }) + .unwrap_or_default() + }; + + #[cfg(target_arch = "wasm32")] + let config: CiTestingConfig = { + let config = include_str!("../../../../ci_testing_config.ron"); + ron::from_str(config).expect("error deserializing CI testing configuration file") + }; - #[cfg(target_arch = "wasm32")] - let config: CiTestingConfig = { - let config = include_str!("../../../../ci_testing_config.ron"); - ron::from_str(config).expect("error deserializing CI testing configuration file") + config + } else { + app.world().resource::().clone() }; + // Add the `EasyCameraMovementPlugin` to the app if it's not already added. + // To configure the movement speed, add the plugin first. + if !app.is_plugin_added::() { + app.add_plugins(EasyCameraMovementPlugin::default()); + } + // Add the `EasyScreenRecordPlugin` to the app if it's not already added and one of the event is starting screenrecording. + // To configure the recording quality, add the plugin first. + #[cfg(feature = "screenrecording")] + if !app.is_plugin_added::() + && config + .events + .iter() + .any(|e| matches!(e.1, CiTestingEvent::StartScreenRecording)) + { + app.add_plugins(EasyScreenRecordPlugin::default()); + } + // Configure a fixed frame time if specified. if let Some(fixed_frame_time) = config.setup.fixed_frame_time { app.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f32( diff --git a/crates/bevy_dev_tools/src/ci_testing/systems.rs b/crates/bevy_dev_tools/src/ci_testing/systems.rs index b9cdc3e2289fd..9df128c9ba1f3 100644 --- a/crates/bevy_dev_tools/src/ci_testing/systems.rs +++ b/crates/bevy_dev_tools/src/ci_testing/systems.rs @@ -1,5 +1,8 @@ +use crate::CameraMovement; + use super::config::*; use bevy_app::AppExit; +use bevy_camera::Camera; use bevy_ecs::prelude::*; use bevy_render::view::screenshot::{save_to_disk, Screenshot}; use tracing::{debug, info}; @@ -51,6 +54,28 @@ pub(crate) fn send_events(world: &mut World, mut current_frame: Local) { *current_frame, name ); } + CiTestingEvent::StartScreenRecording => { + info!("Started recording screen at frame {}.", *current_frame); + #[cfg(feature = "screenrecording")] + world.write_message(crate::RecordScreen::Start); + } + CiTestingEvent::StopScreenRecording => { + info!("Stopped recording screen at frame {}.", *current_frame); + #[cfg(feature = "screenrecording")] + world.write_message(crate::RecordScreen::Stop); + } + CiTestingEvent::MoveCamera { + translation, + rotation, + } => { + info!("Moved camera at frame {}.", *current_frame); + if let Ok(camera) = world.query_filtered::>().single(world) { + world.entity_mut(camera).insert(CameraMovement { + translation, + rotation, + }); + } + } // Custom events are forwarded to the world. CiTestingEvent::Custom(event_string) => { world.write_message(CiTestingCustomEvent(event_string)); diff --git a/crates/bevy_dev_tools/src/easy_screenshot.rs b/crates/bevy_dev_tools/src/easy_screenshot.rs new file mode 100644 index 0000000000000..af5412c4cd0da --- /dev/null +++ b/crates/bevy_dev_tools/src/easy_screenshot.rs @@ -0,0 +1,305 @@ +use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(feature = "screenrecording")] +use std::{fs::File, io::Write, sync::mpsc::channel}; + +use bevy_app::{App, Plugin, Update}; +use bevy_camera::Camera; +use bevy_ecs::prelude::*; +#[cfg(feature = "screenrecording")] +use bevy_image::Image; +use bevy_input::{common_conditions::input_just_pressed, keyboard::KeyCode}; +use bevy_math::{Quat, StableInterpolate, Vec3}; +#[cfg(feature = "screenrecording")] +use bevy_render::view::screenshot::ScreenshotCaptured; +use bevy_render::view::screenshot::{save_to_disk, Screenshot}; +use bevy_time::Time; +use bevy_transform::components::Transform; +use bevy_window::{PrimaryWindow, Window}; +#[cfg(feature = "screenrecording")] +use tracing::info; +#[cfg(feature = "screenrecording")] +use x264::{Colorspace, Encoder, Setup}; +#[cfg(feature = "screenrecording")] +pub use x264::{Preset, Tune}; + +/// File format the screenshot will be saved in +#[derive(Clone, Copy)] +pub enum ScreenshotFormat { + /// JPEG format + Jpeg, + /// PNG format + Png, + /// BMP format + Bmp, +} + +/// Add this plugin to your app to enable easy screenshotting. +/// +/// Add this plugin, press the key, and you have a screenshot 🎉 +pub struct EasyScreenshotPlugin { + /// Key that will trigger a screenshot + pub trigger: KeyCode, + /// Format of the screenshot + /// + /// The corresponding image format must be supported by bevy renderer + pub format: ScreenshotFormat, +} + +impl Default for EasyScreenshotPlugin { + fn default() -> Self { + EasyScreenshotPlugin { + trigger: KeyCode::PrintScreen, + format: ScreenshotFormat::Png, + } + } +} + +impl Plugin for EasyScreenshotPlugin { + fn build(&self, app: &mut App) { + let format = self.format; + app.add_systems( + Update, + (move |mut commands: Commands, window: Single<&Window, With>| { + let since_the_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should go forward"); + + commands + .spawn(Screenshot::primary_window()) + .observe(save_to_disk(format!( + "{}-{}.{}", + window.title, + since_the_epoch.as_millis(), + match format { + ScreenshotFormat::Jpeg => "jpg", + ScreenshotFormat::Png => "png", + ScreenshotFormat::Bmp => "bmp", + } + ))); + }) + .run_if(input_just_pressed(self.trigger)), + ); + } +} + +#[cfg(feature = "screenrecording")] +/// Add this plugin to your app to enable easy screen recording. +pub struct EasyScreenRecordPlugin { + /// The key to toggle recording. + pub toggle: KeyCode, + /// h264 encoder preset + pub preset: Preset, + /// h264 encoder tune + pub tune: Tune, +} + +#[cfg(feature = "screenrecording")] +impl Default for EasyScreenRecordPlugin { + fn default() -> Self { + EasyScreenRecordPlugin { + toggle: KeyCode::Space, + preset: Preset::Medium, + tune: Tune::Animation, + } + } +} + +#[cfg(feature = "screenrecording")] +#[expect( + clippy::large_enum_variant, + reason = "Large variant happens a lot more often than the others" +)] +enum RecordCommand { + Start(String, Preset, Tune), + Stop, + Frame(Image, f64), +} + +#[cfg(feature = "screenrecording")] +/// Controls screen recording +#[derive(Message)] +pub enum RecordScreen { + /// Starts screen recording + Start, + /// Stops screen recording + Stop, +} + +#[cfg(feature = "screenrecording")] +impl Plugin for EasyScreenRecordPlugin { + fn build(&self, app: &mut App) { + let (tx, rx) = channel::(); + + std::thread::spawn(move || { + let mut encoder: Option = None; + let mut setup = None; + let mut file: Option = None; + let mut first_frame_time = None; + let mut previous_pts = 0; + loop { + let Ok(next) = rx.recv() else { + break; + }; + match next { + RecordCommand::Start(name, preset, tune) => { + info!("starting recording at {}", name); + file = Some(File::create(name).unwrap()); + first_frame_time = None; + setup = Some(Setup::preset(preset, tune, false, true).high()); + } + RecordCommand::Stop => { + info!("stopping recording"); + if let Some(encoder) = encoder.take() { + let mut flush = encoder.flush(); + let mut file = file.take().unwrap(); + while let Some(result) = flush.next() { + let (data, _) = result.unwrap(); + file.write_all(data.entirety()).unwrap(); + } + } + } + RecordCommand::Frame(image, frame_time) => { + if first_frame_time.is_none() { + first_frame_time = Some(frame_time); + continue; + } + if let Some(setup) = setup.take() { + let mut new_encoder = setup + .fps((1.0 / (frame_time - first_frame_time.unwrap())) as u32, 1) + .build(Colorspace::RGB, image.width() as i32, image.height() as i32) + .unwrap(); + let headers = new_encoder.headers().unwrap(); + file.as_mut() + .unwrap() + .write_all(headers.entirety()) + .unwrap(); + encoder = Some(new_encoder); + } + if let Some(encoder) = encoder.as_mut() { + let pts = ((frame_time - first_frame_time.unwrap()) * 1000.0) as i64; + if pts == previous_pts { + continue; + } + previous_pts = pts; + + let (data, _) = encoder + .encode( + pts, + x264::Image::rgb( + image.width() as i32, + image.height() as i32, + &image.try_into_dynamic().unwrap().to_rgb8(), + ), + ) + .unwrap(); + file.as_mut().unwrap().write_all(data.entirety()).unwrap(); + } + } + } + } + }); + + app.add_message::().add_systems( + Update, + ( + (move |mut messages: MessageWriter, mut recording: Local| { + *recording = !*recording; + if *recording { + messages.write(RecordScreen::Start); + } else { + messages.write(RecordScreen::Stop); + } + }) + .run_if(input_just_pressed(self.toggle)), + { + let tx = tx.clone(); + let preset = self.preset; + let tune = self.tune; + move |mut commands: Commands, + mut recording: Local, + mut messages: MessageReader, + window: Single<&Window, With>| { + match messages.read().last() { + Some(RecordScreen::Start) => { + let since_the_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should go forward"); + let filename = format!( + "{}-{}.h264", + window.title, + since_the_epoch.as_millis(), + ); + tx.send(RecordCommand::Start(filename, preset, tune)) + .unwrap(); + *recording = true; + } + Some(RecordScreen::Stop) => { + tx.send(RecordCommand::Stop).unwrap(); + *recording = false; + } + _ => {} + } + if *recording { + let tx = tx.clone(); + commands.spawn(Screenshot::primary_window()).observe( + move |screenshot_captured: On, + time: Res