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 d0d0a346b5984..993e47a2c1307 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 8c78426c497fd..e9fe816aa7353 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,6 +20,7 @@ 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" } @@ -36,6 +38,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/easy_screenshot.rs b/crates/bevy_dev_tools/src/easy_screenshot.rs index 4bfacf995f261..bcc92b79eb02a 100644 --- a/crates/bevy_dev_tools/src/easy_screenshot.rs +++ b/crates/bevy_dev_tools/src/easy_screenshot.rs @@ -1,10 +1,24 @@ 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_ecs::prelude::*; +#[cfg(feature = "screenrecording")] +use bevy_image::Image; use bevy_input::{common_conditions::input_just_pressed, keyboard::KeyCode}; +#[cfg(feature = "screenrecording")] +use bevy_render::view::screenshot::ScreenshotCaptured; use bevy_render::view::screenshot::{save_to_disk, Screenshot}; +#[cfg(feature = "screenrecording")] +use bevy_time::Time; 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)] @@ -65,3 +79,182 @@ impl Plugin for EasyScreenshotPlugin { ); } } + +#[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