| Header | Link |
|---|---|
| Purpose | Purpose |
| Context + imports | Context + imports |
| Capture config | Capture config |
| Script example | Script example |
| Sources | Sources |
| Size + framing | Size + framing |
| Timing | Timing |
| Lifecycle + state | Lifecycle + state |
| Replay actions | Replay actions |
| Output + errors | Output + errors |
| Host helpers | Host helpers |
| API reference | API reference |
ctx.run.Capture() starts and observes a bounded capture session from script or
QA code. The app owns GPU readback, RGBA preparation, encoding, safe stop, and
final output commit. Script code supplies capture policy and asks the app to
stop at a frame boundary.
Use this module for director actions, QA clips, offline replay, and scripted
capture. Use perro capture for a CLI run that
builds and launches the graphics runner for you.
For export settings and video-editor handoff, see the
capture video workflow.
The module lives on ctx.run. Public capture types come from the perro_api
prelude; Duration comes from the Rust standard library:
use perro_api::prelude::*;
use std::time::Duration;ScriptContext borrows ctx.run for one callback. Create the module, call it,
and let its borrow end before another runtime operation.
Build a CaptureConfig, set output, then call ctx.run.Capture().start(config).
The normal API resolves project source dimensions and creates staging under the
output's parent .perro-capture directory.
| Field | Values | Default / rule |
|---|---|---|
source |
MainWindow, Camera2D, Camera3D, UISubView, RenderTarget |
MainWindow |
width, height |
Option<u32> |
Omit both to keep source size; set one to derive the other from aspect_ratio |
aspect_ratio |
AspectRatio::Preserve or AspectRatio::Ratio { width, height } |
Preserve |
framing |
Fit, Crop, Expand, Stretch |
Fit |
transparent |
bool |
false |
supersample |
u32 |
2; must be > 0 and fit the render-size multiplication |
fps |
u32 |
60; used when frame_rate stays None |
frame_rate |
Option<FrameRate> |
Exact reduced rational rate when set |
mode |
Realtime or Offline |
Realtime |
duration |
Option<Duration> |
Optional schedule source |
frame_count |
Option<u64> |
Overrides duration schedule when set |
output |
Option<OutputSpec> |
Required by normal stop() |
parallel_workers |
usize |
2; core clamps worker count to 1..=32 |
CaptureConfig::default() sets supersample: 2, so the renderer produces 2x
the final width and height. The capture core downsamples to final dimensions
before output. Use supersample: 1 when speed matters more than extra sampling;
2x width and height mean 4x as many render pixels.
Keep capture control in one state owner. stop() only requests a safe app
boundary; poll state() or last_output() on later updates.
use perro_api::prelude::*;
#[derive(Default, Variant)]
struct CaptureState {
started: bool,
stop_requested: bool,
finished: bool,
started_at: f32,
}
#[State]
struct GameState {
capture: CaptureState,
}
lifecycle!({
fn on_init(&self, ctx: &mut ScriptContext<'_, API>) {
let started_at = elapsed_time!(ctx.run);
let config = CaptureConfig {
mode: CaptureMode::Realtime,
fps: 30,
output: Some(OutputSpec::new(
".output/director.gif",
OutputFormat::Gif,
)),
..CaptureConfig::default()
};
match ctx.run.Capture().start(config) {
Ok(()) => {
with_state_mut!(ctx.run, GameState, ctx.id, |state| {
state.capture.started = true;
state.capture.started_at = started_at;
});
}
Err(error) => log_error!("capture start fail: {error}"),
}
}
fn on_update(&self, ctx: &mut ScriptContext<'_, API>) {
let elapsed = elapsed_time!(ctx.run);
let stop = with_state_mut!(ctx.run, GameState, ctx.id, |state| {
if state.capture.started
&& !state.capture.stop_requested
&& elapsed - state.capture.started_at >= 2.0
{
state.capture.stop_requested = true;
true
} else {
false
}
}).unwrap_or(false);
if stop {
self.request_stop(ctx);
}
if ctx.run.Capture().last_output().is_some() {
with_state_mut!(ctx.run, GameState, ctx.id, |state| {
if !state.capture.finished {
state.capture.finished = true;
log_info!("capture GIF ready");
}
});
}
}
});
methods!({
fn request_stop(&self, ctx: &mut ScriptContext<'_, API>) {
if let Err(error) = ctx.run.Capture().stop() {
log_error!("capture stop request fail: {error}");
}
}
});OutputFormat::Gif uses the native encoder and needs no external process. It
uses one palette for the full clip, keeps exact colors when they fit, applies a
fixed alpha cutoff, and varies GIF delays to keep the requested average rate.
This avoids per-frame palette shimmer and transparent-frame trails. GIF still
has 256-color and one-bit-alpha format limits. OutputFormat::AnimatedWebP
uses ffmpeg lossless BGRA output with full alpha and infinite looping.
OutputSpec::new accepts a file path for GIF and encoded animation formats;
PNG sequence output uses a directory path.
Use these constructors from the prelude:
| Source | Constructor | Route |
|---|---|---|
| Main compositor | CaptureSource::main_window() |
Final scene + UI compositor |
| 2D camera | CaptureSource::camera_2d("Camera2D") |
Named Camera2D node |
| 3D camera | CaptureSource::camera_3d("Camera3D") |
Named Camera3D node |
| 2D camera by ID | CaptureSource::camera_2d_node(camera_id) |
Exact live NodeID; no name lookup |
| 3D camera by ID | CaptureSource::camera_3d_node(camera_id) |
Exact live NodeID; no name lookup |
| UI sub-view | CaptureSource::ui_sub_view("Hud") |
Named UiSubView node |
| Render target | CaptureSource::render_target("Viewport") |
Existing raster target |
Names resolve against live scene nodes. Empty names, missing nodes, and wrong
node types return an error from start. A render target already contains
raster pixels; Framing::Expand rejects it because no extra view exists.
Pass a live NodeID when duplicate node names or stale name routes create risk.
The runtime validates generation + type, and owns a capture stream sized for
supersampling. Set GIF output from a caller-selected path:
use std::path::PathBuf;
use perro_api::prelude::*;
methods!({
fn capture_camera_gif(
&self,
ctx: &mut ScriptContext<'_, API>,
camera_id: NodeID,
output_path: PathBuf,
) -> Result<(), String> {
let config = CaptureConfig {
source: CaptureSource::camera_2d_node(camera_id),
output: Some(OutputSpec::new(output_path, OutputFormat::Gif)),
..CaptureConfig::default()
};
ctx.run.Capture().start(config)
}
});Use CaptureSource::camera_3d_node(camera_id) for a Camera3D node. A stale
ID or wrong camera kind fails at start; runtime never falls back to a name.
Supersampling never changes layout, scale, or look. Main-window capture keeps the
actual window as its logical viewport, just like perro dev. UI ratios, fonts,
nine-slice margins, pixel snapping, and camera aspect/FOV resolve at that size.
Only raster density changes: 3D, 2D, and UI all use the same uniform
raster_pixels / logical_pixels scale. The live window shows the complete frame
at window size throughout capture.
Example: a 1920x1080 window captured at width 3840 and supersample 2 keeps 1920x1080 layout, rasterizes 7680x4320, and saves 3840x2160. Changing supersample 1/2/4 or output size changes sharpness, not relative positions or sizes.
width and height describe final output size. Set both for exact dimensions,
set one to derive the other from aspect_ratio, or omit both to keep source
dimensions. render_size() reports the pre-downsample dimensions:
render_width = output_width * supersample
render_height = output_height * supersample
Framing applies when source and output aspects differ:
Fitkeeps all source pixels and letterboxes as needed.Cropfills output and crops excess source pixels.Stretchscales each axis independently.Expandchanges a camera view to fill output. Main-window and UI sources useFit; raster targets reject it.
Except for explicit camera Expand, layout and projection keep the source
aspect. Framing follows scene rasterization; the unframed raster may differ
from render_size(), which reports the framed, pre-downsample dimensions.
Transparent capture clears background alpha to zero. World and IBL lighting still shade 3D objects; opaque environment background stays out of the alpha output.
FrameRate stores a reduced exact rational. Build one with
FrameRate::new(30000, 1001) or parse decimal / num/den text with
FrameRate::parse.
Offline mode uses a fixed schedule. duration * frame_rate yields its frame
count, and frame timestamps start at 0 with a step of 1 / rate. Set
frame_count to provide an exact count directly. Replay actions apply before
the target fixed tick.
Realtime mode samples elapsed wall time at the requested rate. Missed sample slots reuse the latest completed frame; metadata records the duplicate count. The app bounds submissions per present and the capture core bounds encoder queue capacity.
Normal script flow:
- Set
config.outputand callstart(config). - Read
state(),progress(),source_route(), andrender_size()while active. - Add director events with
record_action(...)when needed. - Call
stop()to request a safe app-boundary stop. - Poll
last_output()after the boundary.
state() reports the active session state. App-boundary stop drains and packs
on a background worker; state() reports Draining until the app observes
completion. A new capture cannot start during this phase. After commit,
state() returns None and last_output() holds the committed output.
Raw host finish_raw() still waits for drain and commit.
progress() reports submitted frames, completed processed frames, and the
expected schedule count. For streamed video, completed frames have entered the
encoder pipe; the media file is ready only after Draining ends and
last_output() reports the committed output.
record_action(timestamp, action, payload) inserts a timestamped event into the
ordered action timeline. The capture metadata stores the timeline for replay.
Supported runtime action names include:
| Action | Payload |
|---|---|
input:key_down / input:key_up |
Key name, such as Space |
input:mouse_down / input:mouse_up |
Mouse button name |
input:text |
Text payload |
signal / signal:<name> |
Signal name |
OutputFormat::PngSequence keeps numbered PNG files in an output directory.
Gif uses the native global-palette encoder. WebM, Mp4, and
AnimatedWebP invoke ffmpeg from PATH; use
OutputSpec::with_ffmpeg(path) for an explicit executable. Animated WebP uses
lossless BGRA encoding and infinite looping.
Normal CLI and Capture().start(config) sessions stream WebM, MP4, and animated
WebP frames into ffmpeg during capture, so they do not write a raw frame file
per video frame. PNG sequences and GIF use staged frames. Raw host start_raw()
keeps staged frames so finish_raw() can select a different output format.
Streaming sessions may change the destination at stop, but must keep the format
and FFmpeg executable selected at start. Final media and metadata use temporary
sibling files and atomic rename. Successful commit removes staging. Failed or
abandoned sessions remove intermediate data after workers stop; failed packs
remove temporary output. Final PNG sequence frames remain in the requested
output directory. Cleanup on drop is best effort if the filesystem rejects
removal. No output commit means last_output() stays None for that session.
start, record_action, submit_rgba, drain, stop, and raw host methods
return Result for immediate validation or request errors. Source lookup,
invalid dimensions, zero supersample, invalid rate, schedule overflow, output
existence, encoder absence, and encoder failure surface as errors. Renderer or
async pack errors also print through the app/CLI capture error path; the public
module has no separate last_error() accessor.
The CLI limits --supersample to integer 1..=8. The public config only
requires a positive value and rejects multiplication overflow. Keep worker
counts bounded; the core clamps parallel_workers to 1..=32.
Renderer hosts that own source dimensions and staging paths use the explicit raw surface:
capture.start_raw(config, source_size, staging_root)?;
let size = capture.render_size().expect("active capture");
capture.submit_rgba(frame_index, size.width, size.height, rgba)?;
capture.drain()?;
let result = capture.finish_raw(output)?;Raw helpers bypass the normal app bridge. Do not use them for ordinary gameplay scripts; the graphics runner owns callback setup and readback draining.
| Method | Signature | Behavior |
|---|---|---|
start |
fn start(&mut self, config: CaptureConfig) -> Result<(), String> |
Resolve project source/staging and start session |
start_raw |
fn start_raw(&mut self, config: CaptureConfig, source_size: OutputSize, staging_root: &str) -> Result<(), String> |
Start with host-owned integration values |
state |
fn state(&self) -> Option<CaptureSessionState> |
Read lifecycle state |
progress |
fn progress(&self) -> Option<CaptureProgress> |
Read queue progress |
last_output |
fn last_output(&self) -> Option<FinalizedCapture> |
Read committed paths and dimensions |
source |
fn source(&self) -> Option<CaptureSource> |
Read active source |
source_route |
fn source_route(&self) -> Option<CaptureSourceRoute> |
Read resolved node route and source size |
render_size |
fn render_size(&self) -> Option<OutputSize> |
Read pre-downsample size |
completed_frames |
fn completed_frames(&self) -> u64 |
Read processed frame count; wait for last_output() before use |
record_action |
fn record_action(&mut self, timestamp: Duration, action: impl Into<String>, payload: Option<String>) -> Result<(), String> |
Add replay event |
stop |
fn stop(&mut self) -> Result<(), String> |
Request configured output stop at app boundary |
request_stop |
fn request_stop(&mut self, output: OutputSpec) |
Request output override stop |
submit_rgba |
fn submit_rgba(&mut self, frame_index: u64, width: u32, height: u32, rgba: &[u8]) -> Result<u64, String> |
Raw host frame submit |
drain |
fn drain(&self) -> Result<(), String> |
Raw host encode wait |
finish_raw |
fn finish_raw(&mut self, output: OutputSpec) -> Result<FinalizedCapture, String> |
Raw host drain + commit |
Offline capture uses a bounded three-frame lookahead to overlap rendering, GPU readback, and encoding. It pauses simulation at queue pressure and drains every accepted frame at stop. Readback buffers use a 256 MiB in-flight budget, allowing one larger frame when necessary. GPU limits fail explicitly; output dimensions are never silently clamped.