An app can save its own rendered output as PNG screenshots, PNG sequences, animated GIFs, or WebM video, and read pixels back. With a hidden window and scripted input, an app can produce images or demo videos by itself, with nobody at the keyboard.
Because the app records its own frames, a recording shows exactly what the app drew, with no screen-recording tool involved. With fixed timing, the same scripted run produces the same frames every time (see Timing and repeatable recordings), which is useful for demos and documentation, and for comparing one run with another. This chapter assumes [app-model] and [tasks].
Every capture path is relative to the app’s output directory, which is the working directory unless the config says otherwise:
config = App.default.with_output_dir("captures")Captures are the app’s own output, so writing them needs no declaration. In
return, the directory is restricted: it must be relative to the working
directory, with no .., and the app refuses to start with one that is not.
Every capture path is inside it, and a path that would leave it is refused
rather than rewritten. The directory is created on first use.
io.capture().screenshot!(path) writes one PNG of the next presented frame.
It waits for that frame to be drawn, read back, and written, so it is legal
only in a task. It is not legal even in init!, which runs before any frame
exists. To capture the first frame, spawn the task on the first cycle:
if input.time.cycle_count == 0 {
Task.spawn!(input, || Shot(io.capture().screenshot!("frame0.png")))
}Here Shot is a variant of the app’s Msg that holds the result, and io
is the App.Io that update! received; the task captures it.
Only one screenshot can be pending at a time. Asking for a second one while
the first is waiting returns AlreadyPending. A headless run has no
framebuffer, so a screenshot there returns Ok({}) without writing anything.
io.capture().screenshot_texture!(target, path) exports an offscreen
Draw.RenderTexture instead (see [drawing]), at the texture’s own size.
This is useful for an image larger than the window. It keeps transparency, and it is
legal in init! and tasks. Postcard
Studio exports at twice the window size.
A recording is a description built from Capture.default: a 25 frames per
second, half-scale GIF of at most 300 frames, with FixedStep timing. Change
it with its with_* methods:
recording = Capture.default
.with_path("demo.webm")
.with_format(WebM)
.with_fps(30)
.with_timing(FixedStep)| Setting | Choices |
|---|---|
|
|
|
|
|
|
|
How long to record, and how many frames to skip |
|
|
|
GIF palette effort: |
Start and stop it with the capture service, from init!, update!, or a
task:
_ = io.capture().start!(recording)
# ...later
_ = io.capture().stop!()App.default.with_recording(recording) stores a recording in the config, but
only as a description: starting it is always an explicit start!.
The outcome arrives in input.capture on the following cycles: Active with
the frames recorded so far, then Finished with the frame count and file size,
or Failed with a reason. When nothing is recording it is Idle. A recording
also finishes when it reaches its frame limit or the app exits.
Reading each frame back from the graphics card takes time, so a recorded run
is slower and less even than a normal one. With RealTime timing, that
unevenness appears in the recording, because input.time.elapsed_seconds reports the
real, uneven time between cycles.
With FixedStep timing, while the recording is active every cycle reports
exactly 1/fps seconds as its elapsed time (divided by every_nth when
frames are skipped), however long the frame really took. The app advances by
the same amount every cycle, so the recording is smooth. It is also
repeatable: run the same app with the same random seed, the same scripted
input, and no task results that depend on the outside world, and it records
the same frames every time. Real input from a person, the wall clock, and
network or file replies are not controlled by FixedStep, so a recording that
depends on them can differ between runs. [input-time] explains
elapsed_seconds.
Hidden windows
App.default.with_visible(Bool.False) opens the window hidden. It still
renders on the GPU, so capture works exactly as with a visible window, and an
app can render a chart or a clip to a file without a window appearing. A
hidden window still needs a display server; on a machine without one, wrap the
run in xvfb-run. Capture Plot is a
hidden-window WebM batch render.
Keys.set_source!, Keys.set_text!, and Mouse.set_source! let an app drive
its own keyboard, typed text, and pointer. Scripted input goes through the
same path as real input, so a recorded demo runs the app’s ordinary code:
Keys.set_source!(Keys.holding([KeyRight]))
Keys.set_text!(Keys.typing("hello"))While a script drives a device, real input from that device is ignored.
Capture UI Demo records a
reproducible interface walkthrough this way. The host’s --host-keys and
--host-text switches script input from the command line instead; see
[testing].
Capture.pixel_at!(source, { x, y }) returns one pixel, and
Capture.read_region!(source, region) a rectangle of them as RGBA bytes,
without writing a file. The source is Screen, the last frame shown, or
Target(render_texture). Both are legal in init!, update!, and tasks, not
in render!.
The host only keeps a copy of the screen once an app starts reading it, so the
first Screen read of a run returns Unavailable and later reads succeed. A
headless run has no pixels and always returns Unavailable.
Input Inspector uses one as an
eyedropper.
-
The
Capture,Keys, andMousepages of the API reference. -
Capture Screenshot and Breakout, which can record its own demo.