Skip to content

Commit 4eed4d0

Browse files
authored
Easy screenrecording plugin (#21237)
# Objective - Followup on #21235 - See mockersf/bevy@easy-screenshots...mockersf:bevy:easy-screenrecording for what's new - Be able to record videos from Bevy in a consistent manner ## Solution - Make a new `EasyScreenRecordPlugin` in the dev tools ## Testing - Add to any example ``` .add_plugins(bevy::dev_tools::EasyScreenRecordPlugin::default()) ``` - Run the example with the feature `bevy_internal/screenrecording` enabled - press the space bar - wait for it... - press the space bar again - screen recording! 🎉 - almost... you now have a h264 file. VLC can read them, but they are not the most friendly format - `ffmpeg` is our friend! `for file in *.h264; do ffmpeg -i $file $file.mp4; done` - you now have a .mp4 file that can be shared anywhere! --- ## Showcase directly taken by Bevy https://github.com/user-attachments/assets/217f5093-9443-40e5-b2ce-33f65f6a56c6
1 parent 48efe26 commit 4eed4d0

File tree

6 files changed

+213
-0
lines changed

6 files changed

+213
-0
lines changed

.github/actions/install-linux-deps/action.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ inputs:
3333
description: Install xkb (libxkbcommon-dev)
3434
required: false
3535
default: "false"
36+
x264:
37+
description: Install x264 (libx264-dev)
38+
required: false
39+
default: "false"
3640
runs:
3741
using: composite
3842
steps:
@@ -47,3 +51,4 @@ runs:
4751
${{ fromJSON(inputs.udev) && 'libudev-dev' || '' }}
4852
${{ fromJSON(inputs.wayland) && 'libwayland-dev' || '' }}
4953
${{ fromJSON(inputs.xkb) && 'libxkbcommon-dev' || '' }}
54+
${{ fromJSON(inputs.x264) && 'libx264-164 libx264-dev' || '' }}

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ jobs:
8383
with:
8484
wayland: true
8585
xkb: true
86+
x264: true
8687
- name: CI job
8788
# See tools/ci/src/main.rs for the commands this runs
8889
run: cargo run -p ci -- lints
@@ -371,6 +372,7 @@ jobs:
371372
with:
372373
wayland: true
373374
xkb: true
375+
x264: true
374376
- name: Build and check doc
375377
# See tools/ci/src/main.rs for the commands this runs
376378
run: cargo run -p ci -- doc

crates/bevy_dev_tools/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ keywords = ["bevy"]
1010

1111
[features]
1212
bevy_ci_testing = ["serde", "ron"]
13+
screenrecording = ["x264"]
1314
webgl = ["bevy_render/webgl"]
1415
webgpu = ["bevy_render/webgpu"]
1516

@@ -21,6 +22,7 @@ bevy_camera = { path = "../bevy_camera", version = "0.18.0-dev" }
2122
bevy_color = { path = "../bevy_color", version = "0.18.0-dev" }
2223
bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.18.0-dev" }
2324
bevy_ecs = { path = "../bevy_ecs", version = "0.18.0-dev" }
25+
bevy_image = { path = "../bevy_image", version = "0.18.0-dev" }
2426
bevy_input = { path = "../bevy_input", version = "0.18.0-dev" }
2527
bevy_math = { path = "../bevy_math", version = "0.18.0-dev" }
2628
bevy_picking = { path = "../bevy_picking", version = "0.18.0-dev" }
@@ -38,6 +40,7 @@ bevy_state = { path = "../bevy_state", version = "0.18.0-dev" }
3840
serde = { version = "1.0", features = ["derive"], optional = true }
3941
ron = { version = "0.12", optional = true }
4042
tracing = { version = "0.1", default-features = false, features = ["std"] }
43+
x264 = { version = "0.5.0", optional = true }
4144

4245
[lints]
4346
workspace = true

crates/bevy_dev_tools/src/easy_screenshot.rs

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
1+
#[cfg(feature = "screenrecording")]
2+
use core::time::Duration;
13
use std::time::{SystemTime, UNIX_EPOCH};
4+
#[cfg(feature = "screenrecording")]
5+
use std::{fs::File, io::Write, sync::mpsc::channel};
26

37
use bevy_app::{App, Plugin, Update};
48
use bevy_ecs::prelude::*;
9+
#[cfg(feature = "screenrecording")]
10+
use bevy_image::Image;
511
use bevy_input::{common_conditions::input_just_pressed, keyboard::KeyCode};
12+
#[cfg(feature = "screenrecording")]
13+
use bevy_render::view::screenshot::ScreenshotCaptured;
614
use bevy_render::view::screenshot::{save_to_disk, Screenshot};
15+
#[cfg(feature = "screenrecording")]
16+
use bevy_time::Time;
717
use bevy_window::{PrimaryWindow, Window};
18+
#[cfg(feature = "screenrecording")]
19+
use tracing::info;
20+
#[cfg(feature = "screenrecording")]
21+
use x264::{Colorspace, Encoder, Setup};
22+
#[cfg(feature = "screenrecording")]
23+
pub use x264::{Preset, Tune};
824

925
/// File format the screenshot will be saved in
1026
#[derive(Clone, Copy)]
@@ -65,3 +81,179 @@ impl Plugin for EasyScreenshotPlugin {
6581
);
6682
}
6783
}
84+
85+
#[cfg(feature = "screenrecording")]
86+
/// Add this plugin to your app to enable easy screen recording.
87+
pub struct EasyScreenRecordPlugin {
88+
/// The key to toggle recording.
89+
pub toggle: KeyCode,
90+
/// h264 encoder preset
91+
pub preset: Preset,
92+
/// h264 encoder tune
93+
pub tune: Tune,
94+
/// target frame time
95+
pub frame_time: Duration,
96+
}
97+
98+
#[cfg(feature = "screenrecording")]
99+
impl Default for EasyScreenRecordPlugin {
100+
fn default() -> Self {
101+
EasyScreenRecordPlugin {
102+
toggle: KeyCode::Space,
103+
preset: Preset::Medium,
104+
tune: Tune::Animation,
105+
frame_time: Duration::from_millis(33),
106+
}
107+
}
108+
}
109+
110+
#[cfg(feature = "screenrecording")]
111+
enum RecordCommand {
112+
Start(String, Preset, Tune),
113+
Stop,
114+
Frame(Image),
115+
}
116+
117+
#[cfg(feature = "screenrecording")]
118+
/// Controls screen recording
119+
#[derive(Message)]
120+
pub enum RecordScreen {
121+
/// Starts screen recording
122+
Start,
123+
/// Stops screen recording
124+
Stop,
125+
}
126+
127+
#[cfg(feature = "screenrecording")]
128+
impl Plugin for EasyScreenRecordPlugin {
129+
fn build(&self, app: &mut App) {
130+
let (tx, rx) = channel::<RecordCommand>();
131+
132+
let frame_time = self.frame_time;
133+
134+
std::thread::spawn(move || {
135+
let mut encoder: Option<Encoder> = None;
136+
let mut setup = None;
137+
let mut file: Option<File> = None;
138+
let mut frame = 0;
139+
loop {
140+
let Ok(next) = rx.recv() else {
141+
break;
142+
};
143+
match next {
144+
RecordCommand::Start(name, preset, tune) => {
145+
info!("starting recording at {}", name);
146+
file = Some(File::create(name).unwrap());
147+
setup = Some(Setup::preset(preset, tune, false, true).high());
148+
}
149+
RecordCommand::Stop => {
150+
info!("stopping recording");
151+
if let Some(encoder) = encoder.take() {
152+
let mut flush = encoder.flush();
153+
let mut file = file.take().unwrap();
154+
while let Some(result) = flush.next() {
155+
let (data, _) = result.unwrap();
156+
file.write_all(data.entirety()).unwrap();
157+
}
158+
}
159+
}
160+
RecordCommand::Frame(image) => {
161+
if let Some(setup) = setup.take() {
162+
let mut new_encoder = setup
163+
.fps((1000 / frame_time.as_millis()) as u32, 1)
164+
.build(Colorspace::RGB, image.width() as i32, image.height() as i32)
165+
.unwrap();
166+
let headers = new_encoder.headers().unwrap();
167+
file.as_mut()
168+
.unwrap()
169+
.write_all(headers.entirety())
170+
.unwrap();
171+
encoder = Some(new_encoder);
172+
}
173+
if let Some(encoder) = encoder.as_mut() {
174+
let pts = (frame_time.as_millis() * frame) as i64;
175+
176+
frame += 1;
177+
let (data, _) = encoder
178+
.encode(
179+
pts,
180+
x264::Image::rgb(
181+
image.width() as i32,
182+
image.height() as i32,
183+
&image.try_into_dynamic().unwrap().to_rgb8(),
184+
),
185+
)
186+
.unwrap();
187+
file.as_mut().unwrap().write_all(data.entirety()).unwrap();
188+
}
189+
}
190+
}
191+
}
192+
});
193+
194+
let frame_time = self.frame_time;
195+
196+
app.add_message::<RecordScreen>().add_systems(
197+
Update,
198+
(
199+
(move |mut messages: MessageWriter<RecordScreen>, mut recording: Local<bool>| {
200+
*recording = !*recording;
201+
if *recording {
202+
messages.write(RecordScreen::Start);
203+
} else {
204+
messages.write(RecordScreen::Stop);
205+
}
206+
})
207+
.run_if(input_just_pressed(self.toggle)),
208+
{
209+
let tx = tx.clone();
210+
let preset = self.preset;
211+
let tune = self.tune;
212+
move |mut commands: Commands,
213+
mut recording: Local<bool>,
214+
mut messages: MessageReader<RecordScreen>,
215+
window: Single<&Window, With<PrimaryWindow>>,
216+
current_screenshot: Query<(), With<Screenshot>>,
217+
mut virtual_time: ResMut<Time<bevy_time::Virtual>>| {
218+
match messages.read().last() {
219+
Some(RecordScreen::Start) => {
220+
let since_the_epoch = SystemTime::now()
221+
.duration_since(UNIX_EPOCH)
222+
.expect("time should go forward");
223+
let filename = format!(
224+
"{}-{}.h264",
225+
window.title,
226+
since_the_epoch.as_millis(),
227+
);
228+
tx.send(RecordCommand::Start(filename, preset, tune))
229+
.unwrap();
230+
*recording = true;
231+
virtual_time.pause();
232+
}
233+
Some(RecordScreen::Stop) => {
234+
tx.send(RecordCommand::Stop).unwrap();
235+
*recording = false;
236+
virtual_time.unpause();
237+
}
238+
_ => {}
239+
}
240+
if *recording && current_screenshot.single().is_err() {
241+
let tx = tx.clone();
242+
commands.spawn(Screenshot::primary_window()).observe(
243+
move |screenshot_captured: On<ScreenshotCaptured>,
244+
mut virtual_time: ResMut<Time<bevy_time::Virtual>>,
245+
mut time: ResMut<Time<()>>| {
246+
let img = screenshot_captured.image.clone();
247+
tx.send(RecordCommand::Frame(img)).unwrap();
248+
virtual_time.advance_by(frame_time);
249+
*time = virtual_time.as_generic();
250+
},
251+
);
252+
}
253+
}
254+
},
255+
)
256+
.chain(),
257+
);
258+
}
259+
}

crates/bevy_internal/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,8 @@ hotpatching = ["bevy_app/hotpatching", "bevy_ecs/hotpatching"]
430430

431431
debug = ["bevy_utils/debug", "bevy_ecs/debug"]
432432

433+
screenrecording = ["bevy_dev_tools/screenrecording"]
434+
433435
[dependencies]
434436
# bevy (no_std)
435437
bevy_app = { path = "../bevy_app", version = "0.18.0-dev", default-features = false, features = [
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
title: Helpers to Produce Marketing Material
3+
authors: ["@mockersf"]
4+
pull_requests: [21235, 21237]
5+
---
6+
7+
Bevy can take a screenshot of what's rendered since 0.11. This is now easier to setup to help you create marketing material, so that you can take screenshot with consistent formatting with the new `EasyScreenshotPlugin`. With its default settings, once you add this plugin to your application, a PNG screenshot will be taken when you press the `PrintScreen` key. You can change the trigger key, or the screenshot format between PNG, JPEG or BMP.
8+
9+
It is now possible to record a movie from Bevy, with the new `EasyScreenRecordPlugin`. This plugins add a toggle key, space bar by default, that will toggle screen recording. Recording can also be started and stopped programmatically with the `RecordScreen` messages.

0 commit comments

Comments
 (0)