From b92568c2169122de8100c1d0194c9b35aebb91cc Mon Sep 17 00:00:00 2001 From: cpendery Date: Fri, 7 Aug 2026 09:16:26 -0700 Subject: [PATCH] feat: add apng/gif support Signed-off-by: cpendery --- .gitignore | 1 - Cargo.lock | 145 +++++ Cargo.toml | 7 + README.md | 87 ++- SKILL.md | 38 +- bindings/js/Cargo.toml | 2 +- bindings/js/README.md | 14 + bindings/js/native/index.d.ts | 16 + bindings/js/native/index.js | 3 +- bindings/js/native/lib.rs | 66 +- bindings/js/src/client.ts | 23 + bindings/js/src/index.ts | 5 + bindings/js/src/native.ts | 13 + bindings/js/test/conformance.test.mjs | 1 + bindings/js/test/integration.test.mjs | 56 +- bindings/js/test/native.test.mjs | 3 + bindings/python/README.md | 14 + bindings/python/native/Cargo.toml | 2 +- bindings/python/native/src/lib.rs | 66 +- bindings/python/src/shell_use/__init__.py | 3 +- bindings/python/src/shell_use/_native.pyi | 2 + bindings/python/src/shell_use/client.py | 20 +- bindings/python/src/shell_use/types.py | 1 + bindings/python/stub-gen/src/main.rs | 9 + bindings/python/tests/test_conformance.py | 1 + bindings/python/tests/test_integration.py | 52 ++ bindings/python/tests/test_native_api.py | 2 + bindings/python/tests/test_options.py | 23 + crates/shell-use-cli/Cargo.toml | 2 +- crates/shell-use-cli/src/cli.rs | 79 ++- crates/shell-use-cli/src/daemon.rs | 4 + crates/shell-use-cli/src/main.rs | 53 +- crates/shell-use-cli/src/protocol.rs | 39 +- .../shell-use-cli/tests/session_lifecycle.rs | 17 + crates/shell-use/Cargo.toml | 8 + .../jetbrains-mono/JetBrainsMono-Regular.ttf | Bin 0 -> 110216 bytes .../shell-use/assets/jetbrains-mono/OFL.txt | 93 +++ crates/shell-use/assets/jetbrains-mono/SOURCE | 4 + crates/shell-use/src/api.rs | 58 ++ crates/shell-use/src/engine.rs | 21 + crates/shell-use/src/lib.rs | 1 + crates/shell-use/src/record.rs | 344 +++++++++++ crates/shell-use/src/record/cast.rs | 439 ++++++++++++++ crates/shell-use/src/record/frames.rs | 342 +++++++++++ crates/shell-use/src/render/encode.rs | 417 +++++++++++++ crates/shell-use/src/render/font.rs | 187 ++++++ crates/shell-use/src/render/mod.rs | 7 + crates/shell-use/src/render/nerd_font.rs | 6 +- crates/shell-use/src/render/raster.rs | 574 ++++++++++++++++++ crates/shell-use/src/render/svg.rs | 61 +- crates/shell-use/src/runtime.rs | 12 + crates/shell-use/src/session.rs | 263 +++++++- crates/shell-use/src/trace/recorder.rs | 177 +----- crates/shell-use/tests/runtime.rs | 52 ++ 54 files changed, 3683 insertions(+), 252 deletions(-) create mode 100644 crates/shell-use/assets/jetbrains-mono/JetBrainsMono-Regular.ttf create mode 100644 crates/shell-use/assets/jetbrains-mono/OFL.txt create mode 100644 crates/shell-use/assets/jetbrains-mono/SOURCE create mode 100644 crates/shell-use/src/record.rs create mode 100644 crates/shell-use/src/record/cast.rs create mode 100644 crates/shell-use/src/record/frames.rs create mode 100644 crates/shell-use/src/render/encode.rs create mode 100644 crates/shell-use/src/render/font.rs create mode 100644 crates/shell-use/src/render/raster.rs diff --git a/.gitignore b/.gitignore index c172ec8..59e0ba5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /target -*.gif *.pyc *.trace __pycache__/ diff --git a/Cargo.lock b/Cargo.lock index 67d08be..5e7d8db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.7" @@ -140,6 +146,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "castaway" version = "0.2.4" @@ -211,6 +223,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -261,6 +279,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -408,6 +435,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -435,6 +471,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "log", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "futures" version = "0.3.32" @@ -544,6 +592,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "heck" version = "0.5.0" @@ -612,6 +670,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -819,6 +883,19 @@ dependencies = [ "futures-io", ] +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -1161,11 +1238,15 @@ dependencies = [ "compact_str", "dirs", "flate2", + "fontdb", + "gif", + "png", "portable-pty", "regex", "serde", "serde_json", "sha2", + "tiny-skia", "ttf-parser", "unicode-width", ] @@ -1272,6 +1353,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -1284,6 +1374,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + [[package]] name = "strsim" version = "0.11.1" @@ -1347,6 +1443,46 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.53.1" @@ -1361,6 +1497,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "typenum" @@ -1418,6 +1557,12 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "widestring" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index bc0dae1..652ec37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,14 +28,21 @@ crossterm = "0.28" dialoguer = { version = "0.11", default-features = false } dirs = "6.0.0" flate2 = "1.1.9" +fontdb = { version = "0.23.0", default-features = false, features = ["fs", "std"] } +gif = "0.14.0" interprocess = "2.4.2" +png = "0.17.16" portable-pty = "0.9.0" regex = "1.12.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" shell-use = { path = "crates/shell-use" } +<<<<<<< Updated upstream unicode-width = "0.2.2" +======= +tiny-skia = { version = "0.11.4", default-features = false, features = ["std"] } +>>>>>>> Stashed changes ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } [profile.release] diff --git a/README.md b/README.md index 0995ae5..d5354c3 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,50 @@ Each command returns a stable exit code (see [Exit codes](#exit-codes)), so an a `shell-use` python & node client libraries that drive shell-use with the same commands as the cli. The clients manage the sessions for you without a daemon. +<<<<<<< Updated upstream ### Python ([`shell-use`](bindings/python/README.md)) +======= +### Rust ([`shell-use`](https://crates.io/crates/shell-use)) + +```sh +cargo add shell-use +# Add APNG/GIF export support when the Rust application needs raster recording: +cargo add shell-use --features recording-raster +``` + +```rust +use shell_use::{OpenOptions, Operation, Session}; + +fn main() -> Result<(), Box> { + let session = Session::new(format!("rust-example-{}", std::process::id())); + session.open(OpenOptions::default())?; + session.execute(Operation::Submit { + data: Some("echo hello".into()), + })?; + session.execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + })?; + session.execute(Operation::ExpectText { + text: "hello".into(), + regex: false, + full: false, + strict: false, + not: false, + fg: None, + bg: None, + timeout_ms: Some(5_000), + })?; + session.execute(Operation::ExpectExitCode { + code: 0, + timeout_ms: Some(5_000), + })?; + session.close()?; + Ok(()) +} +``` + +### Python ([`shell-use`](https://github.com/microsoft/shell-use/blob/main/bindings/python/README.md)) +>>>>>>> Stashed changes ```sh pip install shell-use @@ -240,19 +283,45 @@ Screenshots render a snapshot of the session in the current terminal by default, ### Recording -Every session records automatically from the moment it opens, in the standard -[asciinema v2](https://docs.asciinema.org/manual/asciicast/v2/) cast format. +Record a selected part of a session directly to animated APNG (primary), GIF +(fallback), or standard +[asciinema v2](https://docs.asciinema.org/manual/asciicast/v2/) cast: -| Command | Description | -| ------------------------- | ----------------------------------------------- | -| `get-recording [session]` | Print the session's recording (cast) to stdout. | +| Command | Description | +| --- | --- | +| `record start OUT [--format apng\|gif\|cast] [--fps N] [--speed N] [--idle-time-limit SEC]` | Start recording. Format is inferred from `.png`/`.apng`, `.gif`, or `.cast`. | +| `record stop` | Stop recording and finish the output file. | +| `get-recording [session]` | Print the separate, always-on session cast to stdout. | ```sh -shell-use get-recording > demo.cast # capture the current session's recording -asciinema play demo.cast # replay it -agg demo.cast demo.gif # render a GIF +shell-use open +shell-use record start demo.png # lossless animated PNG +shell-use submit "echo hello" +shell-use wait command +shell-use record stop ``` +APNG keeps full 24/32-bit color. APNG and GIF both render at 2x pixel density +for sharper text; GIF additionally uses palette quantization for viewers that +cannot display APNG. Defaults are 30 fps, 1x speed, a 5-second idle-gap limit, +and a 3-second final hold. If a process exits before `record stop`, APNG/GIF +capture remains beside the target as `OUT.shell-use.cast`. + +Raster export uses the bundled JetBrains Mono face plus installed system fonts +for Unicode and styled fallbacks. Set +`SHELL_USE_RECORDING_FONT_FAMILIES=Family One,Family Two` to prioritize specific +installed families. Export fails with the missing code points instead of +silently substituting unsupported glyphs. + +

+ animated APNG terminal recording produced by shell-use +

+ +Every session also records automatically from open in `.cast` format. Export it +with `shell-use get-recording > demo.cast` for the wider asciicast ecosystem. +This interoperability is implemented directly from the public asciicast v2 +format and does not add or depend on GPL tooling. + ### Live monitor Watch a live session in a second terminal while an agent drives it. Both share @@ -320,7 +389,7 @@ With `--json`, failures also carry a `"kind"` field (`assertion`/`usage`/`no_ses | Testing / snapshots | ✅ `expect` text / output / exit-code / snapshot | ❌ | ❌ | | Color & per-cell attributes | ✅ fg/bg, ANSI-256/hex/rgb, `cells` | ❌ plain text (+ highlights) | via PNG | | Image screenshots | ✅ SVG | ❌ | ✅ PNG | -| Built-in recording | ✅ always-on asciinema cast + GIF | ❌ | ❌ | +| Built-in recording | ✅ APNG/GIF export + always-on asciinema cast | ❌ | ❌ | | Live monitor view | ✅ | ❌ | ✅ | | Stable exit-code taxonomy for agents | ✅ | ❌ | ❌ | | Python & JavaScript bindings | ✅ | ❌ | ❌ | diff --git a/SKILL.md b/SKILL.md index 49028fe..7ddbbde 100644 --- a/SKILL.md +++ b/SKILL.md @@ -125,7 +125,9 @@ Colors accept ansi-256 (`9`), hex (`#ff0000`), or rgb (`255,0,0`). | Command | Description | | ----------------------------------- | ---------------------------------------------------------------------------- | -| `get-recording [session]` | Print a session's asciinema v2 cast to stdout (works even after it stopped). | +| `record start OUT [options]` | Start APNG, GIF, or asciicast recording (format inferred from extension). | +| `record stop` | Finish the active recording. | +| `get-recording [session]` | Print the always-on asciinema v2 cast (works even after the session stopped).| | `monitor` | Watch the session live, full-color, in another terminal. | | `usage` / `agent-context` / `skill` | Self-documentation (see top of guide). | @@ -211,8 +213,20 @@ shell-use get-recording > demo.cast # current session's recording to stdout shell-use get-recording work > w.cast # a specific session by name (even if stopped) ``` -Play it with `asciinema play demo.cast`, or render a GIF with -`agg demo.cast demo.gif`. +Record a selected span directly to APNG (primary), GIF (fallback), or cast: + +```sh +shell-use record start demo.png +shell-use submit "echo hello" +shell-use wait command +shell-use record stop +``` + +APNG and GIF render at 2x pixel density. Use `--fps`, `--speed`, and +`--idle-time-limit` to tune playback. `.cast` output interoperates with the +asciicast ecosystem without adding any GPL dependency to shell-use. If a +process exits before `record stop`, an APNG/GIF capture remains beside the +target as `OUT.shell-use.cast`. ## Live monitor @@ -283,6 +297,7 @@ await su.expectExitCode(0); await su.close(); ``` +<<<<<<< Updated upstream Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, the dedicated `get_command` / `get_output` / @@ -293,6 +308,23 @@ Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, `close_all`, and `get_recording`; JavaScript exports `sessions`, `closeAll`, and `getRecording`. The JavaScript client otherwise uses the same names in camelCase (`waitCommand`, `expectText`, `getExitCode`, etc.). +======= +The Rust crate exposes `Session` and `SessionRegistry` for terminal ownership, +plus the `Operation` and `OperationResult` enums for the command surface. + +Python and JavaScript methods mirror the cli commands: `open` / `run`, `submit` +/ `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, +`resize`, `signal` / `kill`, `state`, `text`, `cells`, the dedicated +`get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / +`get_size` methods, `screenshot`, `start_recording` / `stop_recording`, +`wait_text` / `wait_idle` / `wait_command` / +`wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / +`expect_output` / `expect_snapshot`, and `close`. Python module-level helpers +are `sessions`, `close_all`, and `get_recording`; JavaScript exports `sessions`, +`closeAll`, and `getRecording`. The JavaScript client otherwise uses the same +names in camelCase (`startRecording`, `stopRecording`, `waitCommand`, +`expectText`, `getExitCode`, etc.). +>>>>>>> Stashed changes The constructors accept a session name plus timeout and artifact options: `ShellUse(session="default", *, timeouts=None, artifacts=None)` in Python and diff --git a/bindings/js/Cargo.toml b/bindings/js/Cargo.toml index 6e67c0e..85bf6c4 100644 --- a/bindings/js/Cargo.toml +++ b/bindings/js/Cargo.toml @@ -17,7 +17,7 @@ test = false [dependencies] napi = { version = "3", default-features = false, features = ["napi4", "dyn-symbols", "async"] } napi-derive = "3" -shell-use.workspace = true +shell-use = { workspace = true, features = ["recording-raster"] } [build-dependencies] napi-build = "2" diff --git a/bindings/js/README.md b/bindings/js/README.md index 43d7871..1cc34cb 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -49,7 +49,11 @@ All derive from `ShellUseError` and carry `kind` and `exitCode`. `waitX` and `ex ## API +<<<<<<< Updated upstream `new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize` / `getTitle`, `screenshot`, `waitText` / `waitTitle` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectTitle` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +======= +`new ShellUse(session?, { timeouts?, artifacts? })` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `getCommand` / `getOutput` / `getExitCode` / `getCwd` / `getCursor` / `getSize`, `screenshot`, `startRecording` / `stopRecording`, `waitText` / `waitIdle` / `waitCommand` / `waitExit` / `waitReady`, `expectText` / `expectExitCode` / `expectOutput` / `expectSnapshot`, `close`, and `closeQuiet`. +>>>>>>> Stashed changes Module-level helpers: `sessions()`, `closeAll()`, `getRecording()`, `uniqueSession()`. @@ -82,6 +86,16 @@ Cancelling a promise does not cancel the underlying Rust operation. Operations f Closing a session removes it from `sessions()`, but keeps its recording. `getRecording()` can read that recording for the rest of the process. The 1024 most recently closed sessions have their recordings retained. +```js +await su.startRecording("demo.png", { fps: 30, speed: 1 }); +await su.submit("echo hello"); +await su.waitCommand(); +const path = await su.stopRecording(); +``` + +`.png`/`.apng` selects lossless APNG, `.gif` selects GIF, and `.cast` selects +asciicast v2. The `format` option can override extension inference. + ## Configuration | Variable | Purpose | diff --git a/bindings/js/native/index.d.ts b/bindings/js/native/index.d.ts index 629959b..98d7fb2 100644 --- a/bindings/js/native/index.d.ts +++ b/bindings/js/native/index.d.ts @@ -41,6 +41,8 @@ export declare class NativeSession { expectOutput(text: string, regex?: boolean | undefined | null): Promise snapshot(name: string, options?: SnapshotOptions | undefined | null): Promise screenshot(options?: ScreenshotOptions | undefined | null): Promise + startRecording(options: RecordingOptions): Promise + stopRecording(): Promise panicProbe(): Promise } @@ -135,6 +137,20 @@ export interface PackedScreen { export declare function recording(name: string): Promise +export declare const enum RecordingFormat { + Apng = 'apng', + Gif = 'gif', + Cast = 'cast' +} + +export interface RecordingOptions { + path: string + format?: RecordingFormat + fps?: number + speed?: number + idleTimeLimit?: number +} + export interface RunOptions { program: string args?: Array diff --git a/bindings/js/native/index.js b/bindings/js/native/index.js index 3b8798f..0975f24 100644 --- a/bindings/js/native/index.js +++ b/bindings/js/native/index.js @@ -590,11 +590,12 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { NativeSession, closeAll, closeAllSync, recording, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding +const { NativeSession, closeAll, closeAllSync, recording, RecordingFormat, sessions, Shell, SnapshotResult, UnderlineStyle } = nativeBinding export { NativeSession } export { closeAll } export { closeAllSync } export { recording } +export { RecordingFormat } export { sessions } export { Shell } export { SnapshotResult } diff --git a/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index c6ad785..f877f68 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -11,9 +11,9 @@ use shell_use::{ global_registry, Cell as CoreCell, CellColor, Cursor as CoreCursor, EffectiveTimeouts as CoreEffectiveTimeouts, ErrorKind, MouseAction, OpenOptions as CoreOpenOptions, OpenResult as CoreOpenResult, Operation, OperationResult, - RunOptions as CoreRunOptions, ScreenshotResult as CoreScreenshotResult, SessionHandle, - ShellUseError, Size as CoreSize, SnapshotResult as CoreSnapshotResult, State as CoreState, - Timeouts as CoreTimeouts, + RecordingFormat as CoreRecordingFormat, RunOptions as CoreRunOptions, + ScreenshotResult as CoreScreenshotResult, SessionHandle, ShellUseError, Size as CoreSize, + SnapshotResult as CoreSnapshotResult, State as CoreState, Timeouts as CoreTimeouts, }; const ERROR_PREFIX: &str = "__shell_use_native_error__:"; @@ -48,6 +48,23 @@ impl From for CoreShell { } } +#[napi(string_enum = "lowercase")] +pub enum RecordingFormat { + Apng, + Gif, + Cast, +} + +impl From for CoreRecordingFormat { + fn from(value: RecordingFormat) -> Self { + match value { + RecordingFormat::Apng => Self::Apng, + RecordingFormat::Gif => Self::Gif, + RecordingFormat::Cast => Self::Cast, + } + } +} + #[napi(object)] pub struct Timeouts { pub text: Option, @@ -336,6 +353,15 @@ pub struct ScreenshotOptions { pub path: Option, } +#[napi(object)] +pub struct RecordingOptions { + pub path: String, + pub format: Option, + pub fps: Option, + pub speed: Option, + pub idle_time_limit: Option, +} + #[napi(string_enum = "lowercase")] pub enum SnapshotResult { Passed, @@ -1114,6 +1140,40 @@ impl NativeSession { .await } + #[napi] + pub async fn start_recording(&self, options: RecordingOptions) -> Result<()> { + let fps = options + .fps + .map(|value| u8_value(value, "fps")) + .transpose() + .map_err(native_error)?; + self.unit( + "startRecording", + Operation::StartRecording { + path: options.path, + format: options.format.map(Into::into), + fps, + speed: options.speed, + idle_time_limit: options.idle_time_limit, + }, + ) + .await + } + + #[napi] + pub async fn stop_recording(&self) -> Result { + execute( + self.handle.clone(), + "stopRecording", + Operation::StopRecording, + |result| match result { + OperationResult::Recording(path) => Ok(path), + _ => Err(unexpected("stopRecording")), + }, + ) + .await + } + #[napi] pub async fn panic_probe(&self) -> Result<()> { blocking( diff --git a/bindings/js/src/client.ts b/bindings/js/src/client.ts index 74d466b..ec58ff1 100644 --- a/bindings/js/src/client.ts +++ b/bindings/js/src/client.ts @@ -52,6 +52,15 @@ export interface MouseButtonOptions { button?: number; } +export type RecordingFormat = "apng" | "gif" | "cast"; + +export interface RecordingOptions { + format?: RecordingFormat; + fps?: number; + speed?: number; + idleTimeLimit?: number; +} + const TERMINAL_MARKER = "Terminal content:\n"; function extractTerminalContent(message: string): string | undefined { @@ -330,6 +339,20 @@ export class ShellUse { }); } + async startRecording(path: string, opts: RecordingOptions = {}): Promise { + await this.#runtime.startRecording({ + path, + format: opts.format, + fps: opts.fps, + speed: opts.speed, + idleTimeLimit: opts.idleTimeLimit, + }); + } + + async stopRecording(): Promise { + return this.#runtime.stopRecording(); + } + async waitText(text: string, opts: WaitTextOptions = {}): Promise { await this.#guard("waitText", () => this.#runtime.waitText(text, { diff --git a/bindings/js/src/index.ts b/bindings/js/src/index.ts index 65422ab..d515a04 100644 --- a/bindings/js/src/index.ts +++ b/bindings/js/src/index.ts @@ -2,7 +2,12 @@ export { ShellUse } from "./client.js"; export type { ExpectTextOptions, MouseButtonOptions, +<<<<<<< Updated upstream TitleOptions, +======= + RecordingFormat, + RecordingOptions, +>>>>>>> Stashed changes WaitTextOptions, } from "./client.js"; export { uniqueSession } from "./ephemeral.js"; diff --git a/bindings/js/src/native.ts b/bindings/js/src/native.ts index 8864623..03af10d 100644 --- a/bindings/js/src/native.ts +++ b/bindings/js/src/native.ts @@ -8,6 +8,7 @@ import type { OpenOptions, OpenResult, PackedScreen, + RecordingOptions, RunOptions, ScreenshotOptions, Size, @@ -21,6 +22,9 @@ import type { type NativeBinding = typeof import("../native/index.js"); type NativeSessionHandle = InstanceType; type RuntimeOpenOptions = Omit & { shell?: string }; +type RuntimeRecordingOptions = Omit & { + format?: "apng" | "gif" | "cast"; +}; const ERROR_PREFIX = "__shell_use_native_error__:"; const USAGE_NAPI_CODES = new Set([ @@ -306,6 +310,14 @@ export class NativeRuntime { return this.#call((session) => session.screenshot(options)); } + startRecording(options: RuntimeRecordingOptions): Promise { + return this.#call((session) => session.startRecording(options as RecordingOptions)); + } + + stopRecording(): Promise { + return this.#call((session) => session.stopRecording()); + } + panicProbe(): Promise { return this.#call((session) => session.panicProbe()); } @@ -335,6 +347,7 @@ export type { OpenOptions as NativeOpenOptions, OpenResult as NativeOpenResult, PackedScreen as NativePackedScreen, + RecordingOptions as NativeRecordingOptions, RunOptions as NativeRunOptions, ScreenshotOptions as NativeScreenshotOptions, Size as NativeSize, diff --git a/bindings/js/test/conformance.test.mjs b/bindings/js/test/conformance.test.mjs index ae683d5..a5f909a 100644 --- a/bindings/js/test/conformance.test.mjs +++ b/bindings/js/test/conformance.test.mjs @@ -27,6 +27,7 @@ const MAPPING = { state: [["client", "state"]], text: [["client", "text"]], screenshot: [["client", "screenshot"]], + record: [["client", "startRecording"], ["client", "stopRecording"]], cells: [["client", "cells"]], get: [ ["client", "getCommand"], diff --git a/bindings/js/test/integration.test.mjs b/bindings/js/test/integration.test.mjs index 7c538a5..aa29e47 100644 --- a/bindings/js/test/integration.test.mjs +++ b/bindings/js/test/integration.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { test } from "node:test"; @@ -64,6 +64,60 @@ test("echo roundtrip drives a real session", async () => { }); }); +test("recording API writes an asciicast file", async () => { + const root = mkdtempSync(join(tmpdir(), "shell-use-recording-")); + const path = join(root, "demo.cast"); + try { + await withTerminal({ shell }, async (su) => { + await su.startRecording(path, { + format: "cast", + fps: 24, + speed: 1, + idleTimeLimit: 2, + }); + await su.submit("echo sdk-recording"); + await su.waitCommand(); + assert.equal(await su.stopRecording(), path); + }); + const cast = await readFile(path, "utf8"); + assert.match(cast, /"version":2/); + assert.match(cast, /sdk-recording/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("recording API exports styled Unicode to APNG and GIF", async () => { + const root = mkdtempSync(join(tmpdir(), "shell-use-raster-recording-")); + const command = + process.platform === "win32" + ? 'Write-Host "`e[1;3mstyled-é`e[0m"' + : "printf '\\033[1;3mstyled-é\\033[0m\\n'"; + try { + for (const [format, extension] of [ + ["apng", "png"], + ["gif", "gif"], + ]) { + const path = join(root, `styled.${extension}`); + await withTerminal({ shell, cols: 20, rows: 4 }, async (su) => { + await su.startRecording(path, { format, fps: 30 }); + await su.submit(command); + await su.waitCommand(); + assert.equal(await su.stopRecording(), path); + }); + const bytes = await readFile(path); + if (format === "apng") { + assert.deepEqual(bytes.subarray(0, 8), Buffer.from("\x89PNG\r\n\x1a\n", "latin1")); + assert.ok(bytes.includes(Buffer.from("acTL"))); + } else { + assert.equal(bytes.subarray(0, 6).toString("ascii"), "GIF89a"); + } + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test( "assertion errors include the current terminal", async () => { diff --git a/bindings/js/test/native.test.mjs b/bindings/js/test/native.test.mjs index 99c180b..cb2b153 100644 --- a/bindings/js/test/native.test.mjs +++ b/bindings/js/test/native.test.mjs @@ -17,6 +17,7 @@ test("generated native declarations expose typed operations", async () => { "Timeouts", "Cell", "PackedScreen", + "RecordingOptions", ]) { assert.match(declarations, new RegExp(`export (?:interface|type) ${type}\\b`)); } @@ -39,6 +40,8 @@ test("generated native declarations expose typed operations", async () => { "expectText", "snapshot", "screenshot", + "startRecording", + "stopRecording", "packedScreen", "panicProbe", ]) { diff --git a/bindings/python/README.md b/bindings/python/README.md index 7f6fae1..6845fdf 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -54,7 +54,11 @@ All derive from `ShellUseError`. `wait_*` and `expect_*` raise `ExpectationError ## API +<<<<<<< Updated upstream `ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size` / `get_title`, `screenshot`, `wait_text` / `wait_title` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_title` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +======= +`ShellUse(session="default", *, timeouts=None, artifacts=None)` mirrors the cli: `open` / `run`, `type` / `write`, `submit`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / `kill`, `state`, `text`, `cells`, `get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / `get_size`, `screenshot`, `start_recording` / `stop_recording`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / `expect_snapshot`, `close`, and `close_quiet`. +>>>>>>> Stashed changes Module-level helpers: `sessions()`, `close_all()`, `get_recording()`, `unique_session()`. @@ -84,6 +88,16 @@ Cancelling a task does not cancel the underlying Rust operation. Operations for Closing a session removes it from `sessions()`, but keeps its recording. `get_recording()` can read that recording for the rest of the process. The 1024 most recently closed sessions have their recordings retained. +```python +await su.start_recording("demo.png", fps=30, speed=1.0) +await su.submit("echo hello") +await su.wait_command() +path = await su.stop_recording() +``` + +`.png`/`.apng` selects lossless APNG, `.gif` selects GIF, and `.cast` selects +asciicast v2. Pass `format=` to override extension inference. + ## Configuration | Variable | Purpose | diff --git a/bindings/python/native/Cargo.toml b/bindings/python/native/Cargo.toml index 0db93c9..e458a67 100644 --- a/bindings/python/native/Cargo.toml +++ b/bindings/python/native/Cargo.toml @@ -15,7 +15,7 @@ test = false [dependencies] anyhow.workspace = true -shell-use.workspace = true +shell-use = { workspace = true, features = ["recording-raster"] } pyo3 = { version = "0.28" } pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } tokio = { version = "1", features = ["rt-multi-thread"] } diff --git a/bindings/python/native/src/lib.rs b/bindings/python/native/src/lib.rs index 09b5b12..d4b484e 100644 --- a/bindings/python/native/src/lib.rs +++ b/bindings/python/native/src/lib.rs @@ -8,8 +8,8 @@ use shell_use::runtime::global_registry; use shell_use::shell::Shell; use shell_use::{ Cell, CellColor, Cursor, ErrorKind, MouseAction, OpenOptions, OpenResult, Operation, - OperationResult, PackedScreen, RunOptions, ScreenshotResult, ShellUseError, Size, - SnapshotResult, State, Timeouts, + OperationResult, PackedScreen, RecordingFormat, RunOptions, ScreenshotResult, ShellUseError, + Size, SnapshotResult, State, Timeouts, }; pyo3::create_exception!( @@ -877,6 +877,48 @@ impl NativeSession { ) } + #[pyo3(signature = (path, format, fps, speed, idle_time_limit))] + fn start_recording<'py>( + &self, + py: Python<'py>, + path: String, + format: Option, + fps: Option>, + speed: Option, + idle_time_limit: Option, + ) -> PyResult> { + let fps = capture_optional_integer(fps); + let name = self.name.clone(); + future_blocking( + py, + move || { + execute_unit( + &name, + Operation::StartRecording { + path, + format: parse_recording_format(format.as_deref())?, + fps: fps + .as_ref() + .map(|value| integer_u8(value, "fps")) + .transpose()?, + speed, + idle_time_limit, + }, + ) + }, + unit_to_py, + ) + } + + fn stop_recording<'py>(&self, py: Python<'py>) -> PyResult> { + let name = self.name.clone(); + future_blocking( + py, + move || execute_recording(&name, Operation::StopRecording), + string_to_py, + ) + } + fn recording<'py>(&self, py: Python<'py>) -> PyResult> { let name = self.name.clone(); future_blocking( @@ -1109,6 +1151,19 @@ fn parse_shell(value: Option<&str>) -> Result, ShellUseError> { .transpose() } +fn parse_recording_format(value: Option<&str>) -> Result, ShellUseError> { + value + .map(|value| match value { + "apng" => Ok(RecordingFormat::Apng), + "gif" => Ok(RecordingFormat::Gif), + "cast" => Ok(RecordingFormat::Cast), + other => Err(ShellUseError::usage(format!( + "unknown recording format '{other}'; expected apng, gif, or cast" + ))), + }) + .transpose() +} + fn unexpected_result(expected: &str) -> ShellUseError { ShellUseError::internal(format!( "native Python binding expected {expected}, but the engine returned another result type" @@ -1220,6 +1275,13 @@ fn execute_screenshot(name: &str, operation: Operation) -> Result Result { + match global_registry().execute(name, operation)? { + OperationResult::Recording(path) => Ok(path), + _ => Err(unexpected_result("a recording path")), + } +} + fn unit_to_py(py: Python<'_>, (): ()) -> PyResult> { Ok(py.None()) } diff --git a/bindings/python/src/shell_use/__init__.py b/bindings/python/src/shell_use/__init__.py index 0247fac..c805ae8 100644 --- a/bindings/python/src/shell_use/__init__.py +++ b/bindings/python/src/shell_use/__init__.py @@ -11,7 +11,7 @@ TerminalArtifact, UsageError, ) -from .types import Cell, State, Timeouts +from .types import Cell, RecordingFormat, State, Timeouts __all__ = [ "ShellUse", @@ -26,6 +26,7 @@ "InternalError", "TerminalArtifact", "Cell", + "RecordingFormat", "State", "Timeouts", "__version__", diff --git a/bindings/python/src/shell_use/_native.pyi b/bindings/python/src/shell_use/_native.pyi index f39fe4d..cd3bc7f 100644 --- a/bindings/python/src/shell_use/_native.pyi +++ b/bindings/python/src/shell_use/_native.pyi @@ -81,6 +81,8 @@ class NativeSession: def expect_output(self, text: str, regex: bool) -> typing.Awaitable[None]: ... def snapshot(self, name: str, update: bool, include_colors: bool, include_title: bool, cwd: typing.Optional[str]) -> typing.Awaitable[str]: ... def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... + def start_recording(self, path: str, format: typing.Optional[str], fps: typing.Optional[int], speed: typing.Optional[float], idle_time_limit: typing.Optional[float]) -> typing.Awaitable[None]: ... + def stop_recording(self) -> typing.Awaitable[str]: ... def recording(self) -> typing.Awaitable[str]: ... class NativeUsageError(builtins.Exception): diff --git a/bindings/python/src/shell_use/client.py b/bindings/python/src/shell_use/client.py index 2989052..fdbecb7 100644 --- a/bindings/python/src/shell_use/client.py +++ b/bindings/python/src/shell_use/client.py @@ -27,7 +27,7 @@ TerminalArtifact, UsageError, ) -from .types import Cell, State, Timeouts +from .types import Cell, RecordingFormat, State, Timeouts _TERMINAL_MARKER = "Terminal content:\n" _TIMEOUT_CLASSES = ("text", "idle", "command", "exit", "ready") @@ -342,6 +342,24 @@ async def screenshot( ) -> str: return await self._await(self._native.screenshot(path, full)) + async def start_recording( + self, + path: str, + *, + format: Optional[RecordingFormat] = None, + fps: Optional[int] = None, + speed: Optional[float] = None, + idle_time_limit: Optional[float] = None, + ) -> None: + await self._await( + self._native.start_recording( + path, format, fps, speed, idle_time_limit + ) + ) + + async def stop_recording(self) -> str: + return await self._await(self._native.stop_recording()) + async def wait_text( self, text: str, diff --git a/bindings/python/src/shell_use/types.py b/bindings/python/src/shell_use/types.py index 38bfd1f..aae1e90 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -6,6 +6,7 @@ Color = Union[str, int] #: ``"none"`` is a value, not an absence: an un-underlined cell reports it. UnderlineStyle = Literal["none", "single", "double", "curly", "dotted", "dashed"] +RecordingFormat = Literal["apng", "gif", "cast"] @dataclass diff --git a/bindings/python/stub-gen/src/main.rs b/bindings/python/stub-gen/src/main.rs index 7dc2870..feb4932 100644 --- a/bindings/python/stub-gen/src/main.rs +++ b/bindings/python/stub-gen/src/main.rs @@ -204,6 +204,15 @@ mod stubs { cwd: typing.Optional[str], ) -> typing.Awaitable[str]: ... def screenshot(self, path: typing.Optional[str], full: bool) -> typing.Awaitable[str]: ... + def start_recording( + self, + path: str, + format: typing.Optional[str], + fps: typing.Optional[int], + speed: typing.Optional[float], + idle_time_limit: typing.Optional[float], + ) -> typing.Awaitable[None]: ... + def stop_recording(self) -> typing.Awaitable[str]: ... def recording(self) -> typing.Awaitable[str]: ... "# } diff --git a/bindings/python/tests/test_conformance.py b/bindings/python/tests/test_conformance.py index 0079f12..b4c68cf 100644 --- a/bindings/python/tests/test_conformance.py +++ b/bindings/python/tests/test_conformance.py @@ -16,6 +16,7 @@ "state": [("client", "state")], "text": [("client", "text")], "screenshot": [("client", "screenshot")], + "record": [("client", "start_recording"), ("client", "stop_recording")], "cells": [("client", "cells")], "get": [ ("client", "get_command"), diff --git a/bindings/python/tests/test_integration.py b/bindings/python/tests/test_integration.py index de60f02..df08688 100644 --- a/bindings/python/tests/test_integration.py +++ b/bindings/python/tests/test_integration.py @@ -45,6 +45,58 @@ async def scenario(): run(scenario()) + def test_recording_api_writes_an_asciicast_file(self): + async def scenario(): + with tempfile.TemporaryDirectory() as root: + path = Path(root) / "demo.cast" + async with self._client() as su: + await su.open(shell=SHELL) + await su.start_recording( + str(path), + format="cast", + fps=24, + speed=1.0, + idle_time_limit=2.0, + ) + await su.submit("echo sdk-recording") + await su.wait_command() + self.assertEqual(await su.stop_recording(), str(path)) + cast = path.read_text(encoding="utf-8") + self.assertIn('"version":2', cast) + self.assertIn("sdk-recording", cast) + + run(scenario()) + + def test_recording_api_exports_styled_unicode_to_apng_and_gif(self): + async def scenario(): + command = ( + 'Write-Host "`e[1;3mstyled-é`e[0m"' + if sys.platform == "win32" + else "printf '\\033[1;3mstyled-é\\033[0m\\n'" + ) + with tempfile.TemporaryDirectory() as root: + for format, extension in (("apng", "png"), ("gif", "gif")): + with self.subTest(format=format): + path = Path(root) / f"styled.{extension}" + async with self._client() as su: + await su.open(shell=SHELL, cols=20, rows=4) + await su.start_recording( + str(path), format=format, fps=30 + ) + await su.submit(command) + await su.wait_command() + self.assertEqual( + await su.stop_recording(), str(path) + ) + data = path.read_bytes() + if format == "apng": + self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n") + self.assertIn(b"acTL", data) + else: + self.assertEqual(data[:6], b"GIF89a") + + run(scenario()) + def test_invalid_shell_is_a_typed_usage_error(self): async def scenario(): su = self._client() diff --git a/bindings/python/tests/test_native_api.py b/bindings/python/tests/test_native_api.py index 8a1780f..517edb3 100644 --- a/bindings/python/tests/test_native_api.py +++ b/bindings/python/tests/test_native_api.py @@ -57,6 +57,8 @@ def test_native_session_has_only_typed_terminal_methods(self): "expect_output", "snapshot", "screenshot", + "start_recording", + "stop_recording", "recording", ): self.assertTrue(hasattr(session, name), name) diff --git a/bindings/python/tests/test_options.py b/bindings/python/tests/test_options.py index 8701f59..f7709c5 100644 --- a/bindings/python/tests/test_options.py +++ b/bindings/python/tests/test_options.py @@ -167,6 +167,29 @@ def test_typed_getters_use_distinct_native_methods(self): self.assertFalse(hasattr(client.ShellUse, "send")) self.assertFalse(hasattr(client.ShellUse, "get")) + def test_recording_helpers_use_typed_methods(self): + terminal = _CapturingClient("s") + run( + terminal.start_recording( + "demo.png", + format="apng", + fps=24, + speed=2.0, + idle_time_limit=3.0, + ) + ) + run(terminal.stop_recording()) + self.assertEqual( + terminal.fake.calls, + [ + ( + "start_recording", + ("demo.png", "apng", 24, 2.0, 3.0), + ), + ("stop_recording", ()), + ], + ) + class ClientTimeoutTests(unittest.TestCase): def test_unconfigured_waits_pass_none(self): diff --git a/crates/shell-use-cli/Cargo.toml b/crates/shell-use-cli/Cargo.toml index d4f56f5..d6b8e07 100644 --- a/crates/shell-use-cli/Cargo.toml +++ b/crates/shell-use-cli/Cargo.toml @@ -22,4 +22,4 @@ interprocess.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true -shell-use.workspace = true +shell-use = { workspace = true, features = ["recording-raster"] } diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index 581d1c3..b9a5e38 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -2,7 +2,7 @@ use clap::{Args, Parser, Subcommand}; use shell_use::config::{DEFAULT_COLS, DEFAULT_ROWS}; use shell_use::shell::Shell; -use shell_use::Timeouts; +use shell_use::{RecordingFormat, Timeouts}; #[derive(Clone, Copy, clap::ValueEnum)] #[clap(rename_all = "lowercase")] @@ -34,6 +34,24 @@ impl From for Shell { } } +#[derive(Clone, Copy, clap::ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum RecordingFormatArg { + Apng, + Gif, + Cast, +} + +impl From for RecordingFormat { + fn from(format: RecordingFormatArg) -> Self { + match format { + RecordingFormatArg::Apng => RecordingFormat::Apng, + RecordingFormatArg::Gif => RecordingFormat::Gif, + RecordingFormatArg::Cast => RecordingFormat::Cast, + } + } +} + /// Per-class default timeouts for a session, in milliseconds. #[derive(Args, Clone, Copy, Default)] pub struct TimeoutArgs { @@ -178,6 +196,11 @@ pub enum Command { #[arg(long)] full: bool, }, + /// Start or stop an animated terminal recording. + Record { + #[command(subcommand)] + cmd: RecordCmd, + }, /// Dump cell attributes for a region. Cells { /// Left column, 0-based. @@ -257,8 +280,7 @@ pub enum Command { }, /// Print the session's recording (asciinema v2 cast) to stdout. /// - /// Redirect to a `.cast` file, then `asciinema play` it or render a GIF - /// with `agg`. + /// Redirect to a `.cast` file for playback in the asciicast ecosystem. GetRecording { /// Session to read (defaults to --session / the default session). session: Option, @@ -286,6 +308,29 @@ pub enum Command { InternalDaemon, } +#[derive(Subcommand)] +pub enum RecordCmd { + /// Start recording terminal output to APNG, GIF, or asciicast v2. + Start { + /// Output path. The extension selects APNG (.png/.apng), GIF, or cast. + path: String, + /// Override the format inferred from the output extension. + #[arg(long, value_enum)] + format: Option, + /// Maximum animation frame rate. + #[arg(long)] + fps: Option, + /// Playback speed multiplier. + #[arg(long)] + speed: Option, + /// Clamp idle gaps to this many seconds. + #[arg(long)] + idle_time_limit: Option, + }, + /// Stop the active recording and finish its output file. + Stop, +} + /// Signals deliverable to a session's child process. #[derive(Clone, Copy, clap::ValueEnum)] #[clap(rename_all = "upper")] @@ -432,6 +477,34 @@ mod tests { assert_eq!(defaults.exit, None); } + #[test] + fn recording_start_accepts_timeline_options() { + let cli = Cli::try_parse_from([ + "shell-use", + "record", + "start", + "demo.png", + "--fps", + "24", + "--speed", + "2", + "--idle-time-limit", + "3", + ]) + .expect("parse recording start"); + assert!(matches!( + cli.command, + Some(Command::Record { + cmd: RecordCmd::Start { + fps: Some(24), + speed: Some(2.0), + idle_time_limit: Some(3.0), + .. + } + }) + )); + } + #[test] fn open_has_no_catch_all_timeout_flag() { assert!(Cli::try_parse_from(["shell-use", "open", "--timeout", "1000"]).is_err()); diff --git a/crates/shell-use-cli/src/daemon.rs b/crates/shell-use-cli/src/daemon.rs index 00347d2..3520d57 100644 --- a/crates/shell-use-cli/src/daemon.rs +++ b/crates/shell-use-cli/src/daemon.rs @@ -76,6 +76,10 @@ pub fn run(session_name: String, verbose: bool) -> anyhow::Result<()> { let mut response = match req { Request::Ping | Request::Shutdown => Response::ok(), Request::Status => status_response(&engine), + Request::FlushRecording => match engine.flush_recording() { + Ok(()) => Response::ok(), + Err(error) => Response::from_error(error), + }, operation => operation.execute(&engine), }; if let Some(status) = enrich { diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index 01b64bc..b9dc634 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -12,7 +12,7 @@ use std::time::{Duration, Instant}; use clap::{CommandFactory, Parser}; -use cli::{Cli, Command, DaemonCmd, ExpectCmd, GetArg, MouseCmd, WaitCmd}; +use cli::{Cli, Command, DaemonCmd, ExpectCmd, GetArg, MouseCmd, RecordCmd, WaitCmd}; use protocol::{GetField, MouseAction, Request, Response}; /// Long-form agent skill manifest, printed by `shell-use skill`. const SKILL_MD: &str = include_str!("../../../SKILL.md"); @@ -181,6 +181,25 @@ fn build_request(command: Command) -> anyhow::Result { full, path: out.or(path), }, + Command::Record { + cmd: + RecordCmd::Start { + path, + format, + fps, + speed, + idle_time_limit, + }, + } => Request::StartRecording { + path, + format: format.map(Into::into), + fps, + speed, + idle_time_limit, + }, + Command::Record { + cmd: RecordCmd::Stop, + } => Request::StopRecording, Command::Cells { x, y, w, h } => Request::Cells { x, y, w, h }, Command::Get { field } => Request::Get { field: map_field(field), @@ -458,6 +477,32 @@ fn spawn_detached(exe: &Path, session: &str, verbose: bool) -> anyhow::Result<() /// Stream a session's recording (asciinema v2 cast) to stdout. fn get_recording(session: String) -> i32 { + let socket = config::socket_name(&session); + if ipc::is_running(&socket) { + let response = match ipc::connect(&socket) { + Ok(connection) => match ipc::exchange(connection, &Request::FlushRecording) { + Ok(response) => response, + Err(error) => { + eprintln!("failed to flush recording: {error}"); + return 4; + } + }, + Err(error) => { + eprintln!("failed to flush recording: {error}"); + return 4; + } + }; + if !response.ok { + eprintln!( + "{}", + response + .message + .as_deref() + .unwrap_or("failed to flush recording") + ); + return response.kind.map_or(5, shell_use::ErrorKind::exit_code); + } + } let path = config::recording_file(&session); match std::fs::read(&path) { Ok(bytes) => { @@ -683,9 +728,15 @@ WAIT wait text \"T\" [--regex --full --not --timeout MS]\n\ EXPECT expect text \"T\" [--regex --full --not --fg C --bg C --timeout MS]\n\ expect title \"T\" [--regex --not --timeout MS]\n\ expect exit-code N | expect output \"T\" [--regex]\n\ +<<<<<<< Updated upstream expect snapshot NAME [-u] [--include-colors --include-title]\n\ RECORD sessions auto-record; get-recording [session] > out.cast (asciinema v2)\n\ play with `asciinema play out.cast`, render GIF with `agg out.cast out.gif`\n\ +======= + expect snapshot NAME [-u] [--include-colors]\n\ +RECORD record start OUT [--format apng|gif|cast] [--fps N] [--speed N]\n\ + record stop | get-recording [session] > out.cast (always-on asciicast v2)\n\ +>>>>>>> Stashed changes WATCH monitor (live full-color view in another terminal; q/Esc/Ctrl-C to detach)\n\ AGENT agent-context (JSON cli schema) | skill [--add] (workflow guide)\n\ GLOBAL --session NAME | --json | --verbose (log PTY traffic to ~/.shell-use/.log)\n\ diff --git a/crates/shell-use-cli/src/protocol.rs b/crates/shell-use-cli/src/protocol.rs index 233c11d..288b322 100644 --- a/crates/shell-use-cli/src/protocol.rs +++ b/crates/shell-use-cli/src/protocol.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use shell_use::{ - Engine, OpenOptions, Operation, OperationResult, RunOptions, ScreenshotResult, ShellUseError, + Engine, OpenOptions, Operation, OperationResult, RecordingFormat, RunOptions, ScreenshotResult, + ShellUseError, }; pub use shell_use::{ErrorKind, MouseAction, Timeouts}; @@ -128,6 +129,15 @@ pub enum Request { full: bool, path: Option, }, + StartRecording { + path: String, + format: Option, + fps: Option, + speed: Option, + idle_time_limit: Option, + }, + StopRecording, + FlushRecording, Monitor { cols: u16, rows: u16, @@ -277,11 +287,27 @@ impl Request { cwd, }), Request::Screenshot { full, path } => Ok(Operation::Screenshot { full, path }), - Request::Ping | Request::Status | Request::Monitor { .. } | Request::Shutdown => { - Err(ShellUseError::usage( - "daemon control request cannot execute as a terminal operation", - )) - } + Request::StartRecording { + path, + format, + fps, + speed, + idle_time_limit, + } => Ok(Operation::StartRecording { + path, + format, + fps, + speed, + idle_time_limit, + }), + Request::StopRecording => Ok(Operation::StopRecording), + Request::Ping + | Request::Status + | Request::FlushRecording + | Request::Monitor { .. } + | Request::Shutdown => Err(ShellUseError::usage( + "daemon control request cannot execute as a terminal operation", + )), } } } @@ -371,6 +397,7 @@ fn operation_data(result: OperationResult) -> Result, OperationResult::Snapshot(status) => Ok(json!({ "status": status })), OperationResult::Screenshot(ScreenshotResult::Path(path)) => Ok(json!({ "path": path })), OperationResult::Screenshot(ScreenshotResult::Text(text)) => Ok(json!({ "text": text })), + OperationResult::Recording(path) => Ok(json!({ "path": path })), } .map_err(|error| ShellUseError::internal(format!("failed to encode cli response: {error}")))?; Ok(Some(value)) diff --git a/crates/shell-use-cli/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs index c7027b8..0c95137 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -174,6 +174,23 @@ fn capturing_output_terminates_after_the_daemon_starts() { sandbox.ok(&["text"]); } +#[test] +fn get_recording_flushes_queued_output_before_reading() { + let sandbox = Sandbox::new("recording-flush"); + sandbox.ok(&["open"]); + sandbox.ok(&["submit", "echo recording-flush-marker"]); + sandbox.ok(&[ + "wait", + "text", + "recording-flush-marker", + "--timeout", + "30000", + ]); + + let recording = sandbox.ok(&["get-recording"]); + assert!(recording.contains("recording-flush-marker")); +} + #[test] fn close_is_idempotent() { let sandbox = Sandbox::new("idempotent"); diff --git a/crates/shell-use/Cargo.toml b/crates/shell-use/Cargo.toml index 3a95afb..e40bd15 100644 --- a/crates/shell-use/Cargo.toml +++ b/crates/shell-use/Cargo.toml @@ -12,6 +12,10 @@ readme = "../../README.md" name = "shell_use" path = "src/lib.rs" +[features] +default = [] +recording-raster = ["dep:fontdb", "dep:gif", "dep:png", "dep:tiny-skia"] + [dependencies] alacritty_terminal.workspace = true anyhow.workspace = true @@ -19,10 +23,14 @@ bitflags.workspace = true compact_str.workspace = true dirs.workspace = true flate2.workspace = true +fontdb = { workspace = true, optional = true } +gif = { workspace = true, optional = true } +png = { workspace = true, optional = true } portable-pty.workspace = true regex.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +tiny-skia = { workspace = true, optional = true } ttf-parser.workspace = true unicode-width.workspace = true diff --git a/crates/shell-use/assets/jetbrains-mono/JetBrainsMono-Regular.ttf b/crates/shell-use/assets/jetbrains-mono/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e0a0ed8679b96f026d5b42cc44ac46eec0fc1b7c GIT binary patch literal 110216 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 literal 0 HcmV?d00001 diff --git a/crates/shell-use/assets/jetbrains-mono/OFL.txt b/crates/shell-use/assets/jetbrains-mono/OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/crates/shell-use/assets/jetbrains-mono/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/shell-use/assets/jetbrains-mono/SOURCE b/crates/shell-use/assets/jetbrains-mono/SOURCE new file mode 100644 index 0000000..982e4e6 --- /dev/null +++ b/crates/shell-use/assets/jetbrains-mono/SOURCE @@ -0,0 +1,4 @@ +JetBrains Mono regular +commit: 19371302b95d218af43299bce79ddbddd0bc364d +source: https://github.com/JetBrains/JetBrainsMono +subset: U+0020-024F,U+2000-206F,U+20A0-20CF,U+2190-22FF,U+2500-259F,U+FFFD diff --git a/crates/shell-use/src/api.rs b/crates/shell-use/src/api.rs index 3d7f49e..f498ad1 100644 --- a/crates/shell-use/src/api.rs +++ b/crates/shell-use/src/api.rs @@ -168,6 +168,14 @@ pub enum Operation { full: bool, path: Option, }, + StartRecording { + path: String, + format: Option, + fps: Option, + speed: Option, + idle_time_limit: Option, + }, + StopRecording, } #[derive(Debug, Clone)] @@ -187,6 +195,7 @@ pub enum OperationResult { Size(Size), Snapshot(SnapshotResult), Screenshot(ScreenshotResult), + Recording(String), } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -368,6 +377,29 @@ pub enum ScreenshotResult { Text(String), } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RecordingFormat { + Apng, + Gif, + Cast, +} + +impl RecordingFormat { + pub fn infer(path: &str) -> Option { + let extension = std::path::Path::new(path) + .extension()? + .to_str()? + .to_ascii_lowercase(); + match extension.as_str() { + "png" | "apng" => Some(Self::Apng), + "gif" => Some(Self::Gif), + "cast" => Some(Self::Cast), + _ => None, + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct RuntimeStatus { pub session: String, @@ -420,3 +452,29 @@ pub enum MouseAction { amount: u16, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recording_format_is_inferred_from_supported_extensions() { + assert_eq!( + RecordingFormat::infer("demo.png"), + Some(RecordingFormat::Apng) + ); + assert_eq!( + RecordingFormat::infer("demo.APNG"), + Some(RecordingFormat::Apng) + ); + assert_eq!( + RecordingFormat::infer("demo.gif"), + Some(RecordingFormat::Gif) + ); + assert_eq!( + RecordingFormat::infer("demo.cast"), + Some(RecordingFormat::Cast) + ); + assert_eq!(RecordingFormat::infer("demo.mp4"), None); + } +} diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index e929f07..6d4110f 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -337,6 +337,16 @@ impl Engine { &self.recording_path } + pub fn flush_recording(&self) -> Result<(), ShellUseError> { + let _operation = self + .operations + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let guard = self.lock_session(); + let session = guard.as_ref().ok_or_else(ShellUseError::no_session)?; + session.flush_recording() + } + fn lock_session(&self) -> MutexGuard<'_, Option> { self.session .lock() @@ -668,6 +678,17 @@ fn dispatch( Operation::Screenshot { full, path } => Ok(OperationResult::Screenshot(screenshot( session, full, path, )?)), + Operation::StartRecording { + path, + format, + fps, + speed, + idle_time_limit, + } => { + session.start_recording(path, format, fps, speed, idle_time_limit)?; + Ok(OperationResult::Unit) + } + Operation::StopRecording => Ok(OperationResult::Recording(session.stop_recording()?)), Operation::Open(_) | Operation::Run(_) | Operation::Close => { Err(ShellUseError::internal("unsupported nested operation")) } diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs index ffa3784..c9fbd98 100644 --- a/crates/shell-use/src/lib.rs +++ b/crates/shell-use/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod engine; pub mod input; pub mod logger; +pub mod record; pub mod render; pub mod runtime; pub mod shell; diff --git a/crates/shell-use/src/record.rs b/crates/shell-use/src/record.rs new file mode 100644 index 0000000..3701e22 --- /dev/null +++ b/crates/shell-use/src/record.rs @@ -0,0 +1,344 @@ +use std::path::PathBuf; +use std::sync::{mpsc, Arc}; +use std::thread::JoinHandle; +use std::time::Instant; + +use crate::api::RecordingFormat; + +pub(crate) mod cast; +#[cfg(feature = "recording-raster")] +pub mod frames; + +#[derive(Clone)] +pub(crate) struct Capture { + sender: mpsc::Sender, +} + +pub(crate) struct Recorder { + sender: mpsc::Sender, + worker: Option>, +} + +pub(crate) struct StartRecording { + pub target_path: PathBuf, + pub capture_path: PathBuf, + pub format: RecordingFormat, + pub cols: u16, + pub rows: u16, + pub env: Vec<(String, String)>, + pub initial_output: String, + #[cfg(feature = "recording-raster")] + pub timeline: frames::TimelineOptions, +} + +pub(crate) struct StoppedRecording { + pub target_path: PathBuf, + #[cfg(feature = "recording-raster")] + pub capture_path: PathBuf, + pub format: RecordingFormat, + #[cfg(feature = "recording-raster")] + pub cols: u16, + #[cfg(feature = "recording-raster")] + pub rows: u16, + #[cfg(feature = "recording-raster")] + pub timeline: frames::TimelineOptions, +} + +#[derive(Debug)] +pub(crate) enum CaptureError { + AlreadyActive, + NotActive, + WorkerStopped, + Io(String), +} + +impl Recorder { + pub fn create( + path: PathBuf, + cols: u16, + rows: u16, + env: &[(String, String)], + logger: Arc, + ) -> Self { + let started = Instant::now(); + let writer = match cast::CastWriter::create(&path, cols, rows, env, started) { + Ok(writer) => Some(writer), + Err(error) => { + logger.event(&format!( + "automatic recording disabled; failed to create {}: {error}", + path.display() + )); + None + } + }; + let (sender, receiver) = mpsc::channel(); + let worker = std::thread::spawn(move || worker_loop(receiver, writer, logger)); + Self { + sender, + worker: Some(worker), + } + } + + pub fn capture(&self) -> Capture { + Capture { + sender: self.sender.clone(), + } + } + + pub fn start(&self, request: StartRecording) -> Result<(), CaptureError> { + let (reply, response) = mpsc::sync_channel(0); + self.sender + .send(Message::Start { + at: Instant::now(), + request, + reply, + }) + .map_err(|_| CaptureError::WorkerStopped)?; + response.recv().unwrap_or(Err(CaptureError::WorkerStopped)) + } + + pub fn stop(&self) -> Result { + let (reply, response) = mpsc::sync_channel(0); + self.sender + .send(Message::Stop { reply }) + .map_err(|_| CaptureError::WorkerStopped)?; + response.recv().unwrap_or(Err(CaptureError::WorkerStopped)) + } + + pub fn flush(&self) -> Result<(), CaptureError> { + let (reply, response) = mpsc::sync_channel(0); + self.sender + .send(Message::Flush { reply }) + .map_err(|_| CaptureError::WorkerStopped)?; + response.recv().unwrap_or(Err(CaptureError::WorkerStopped)) + } + + pub fn on_resize(&self, cols: u16, rows: u16) { + let _ = self.sender.send(Message::Resize { + at: Instant::now(), + cols, + rows, + }); + } +} + +impl Capture { + pub fn on_data(&self, data: &[u8]) { + let _ = self.sender.send(Message::Data { + at: Instant::now(), + bytes: data.to_vec(), + }); + } +} + +impl Drop for Recorder { + fn drop(&mut self) { + let _ = self.sender.send(Message::Shutdown); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } +} + +enum Message { + Data { + at: Instant, + bytes: Vec, + }, + Resize { + at: Instant, + cols: u16, + rows: u16, + }, + Start { + at: Instant, + request: StartRecording, + reply: mpsc::SyncSender>, + }, + Stop { + reply: mpsc::SyncSender>, + }, + Flush { + reply: mpsc::SyncSender>, + }, + Shutdown, +} + +struct ActiveRecording { + writer: cast::CastWriter, + request: StartRecording, + started: Instant, + error: Option, +} + +fn worker_loop( + receiver: mpsc::Receiver, + mut primary: Option, + logger: Arc, +) { + let mut decoder = cast::IncrementalDecoder::default(); + let mut active: Option = None; + + while let Ok(message) = receiver.recv() { + match message { + Message::Data { at, bytes } => { + let text = decoder.push(&bytes); + if text.is_empty() { + continue; + } + if let Some(writer) = primary.as_mut() { + if let Err(error) = writer.write_output(at, &text) { + logger.event(&format!("automatic recording failed: {error}")); + primary = None; + } + } + if let Some(recording) = active.as_mut().filter(|recording| at >= recording.started) + { + let result = recording.writer.write_output(at, &text); + remember_error(recording, result); + } + } + Message::Resize { at, cols, rows } => { + if let Some(writer) = primary.as_mut() { + if let Err(error) = writer.write_resize(at, cols, rows) { + logger.event(&format!("automatic recording failed: {error}")); + primary = None; + } + } + if let Some(recording) = active.as_mut().filter(|recording| at >= recording.started) + { + let result = recording.writer.write_resize(at, cols, rows); + remember_error(recording, result); + } + } + Message::Start { at, request, reply } => { + if active.is_some() { + let _ = reply.send(Err(CaptureError::AlreadyActive)); + continue; + } + let writer = cast::CastWriter::create( + &request.capture_path, + request.cols, + request.rows, + &request.env, + at, + ); + let mut writer = match writer { + Ok(writer) => writer, + Err(error) => { + let _ = reply.send(Err(CaptureError::Io(error.to_string()))); + continue; + } + }; + if let Err(error) = writer.write_output(at, &request.initial_output) { + let _ = reply.send(Err(CaptureError::Io(error.to_string()))); + continue; + } + active = Some(ActiveRecording { + writer, + request, + started: at, + error: None, + }); + let _ = reply.send(Ok(())); + } + Message::Stop { reply } => { + let Some(mut recording) = active.take() else { + let _ = reply.send(Err(CaptureError::NotActive)); + continue; + }; + if recording.error.is_none() { + if let Err(error) = recording.writer.flush() { + recording.error = Some(error.to_string()); + } + } + if let Some(error) = recording.error { + let _ = reply.send(Err(CaptureError::Io(error))); + continue; + } + let request = recording.request; + let _ = reply.send(Ok(StoppedRecording { + target_path: request.target_path, + #[cfg(feature = "recording-raster")] + capture_path: request.capture_path, + format: request.format, + #[cfg(feature = "recording-raster")] + cols: request.cols, + #[cfg(feature = "recording-raster")] + rows: request.rows, + #[cfg(feature = "recording-raster")] + timeline: request.timeline, + })); + } + Message::Flush { reply } => { + let result = match primary.as_mut() { + Some(writer) => writer + .flush() + .map_err(|error| CaptureError::Io(error.to_string())), + None => Err(CaptureError::Io( + "automatic recording is unavailable".to_string(), + )), + }; + if let Err(CaptureError::Io(error)) = &result { + logger.event(&format!("automatic recording flush failed: {error}")); + primary = None; + } + let _ = reply.send(result); + } + Message::Shutdown => { + if let Some(writer) = primary.as_mut() { + let _ = writer.flush(); + } + if let Some(recording) = active.as_mut() { + let _ = recording.writer.flush(); + } + break; + } + } + } +} + +fn remember_error(recording: &mut ActiveRecording, result: std::io::Result<()>) { + if recording.error.is_none() { + if let Err(error) = result { + recording.error = Some(error.to_string()); + } + } +} + +pub(crate) fn sidecar_path(target: &std::path::Path) -> PathBuf { + let mut name = target + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("recording")) + .to_os_string(); + name.push(".shell-use.cast"); + target.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flush_acknowledges_all_prior_capture_messages() { + let path = std::env::temp_dir().join(format!( + "shell-use-recorder-flush-{}.cast", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + let recorder = Recorder::create( + path.clone(), + 80, + 30, + &[], + Arc::new(crate::logger::Logger::disabled()), + ); + recorder.capture().on_data(b"flush-marker"); + recorder.flush().unwrap(); + assert!(std::fs::read_to_string(&path) + .unwrap() + .contains("flush-marker")); + drop(recorder); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/crates/shell-use/src/record/cast.rs b/crates/shell-use/src/record/cast.rs new file mode 100644 index 0000000..0803a41 --- /dev/null +++ b/crates/shell-use/src/record/cast.rs @@ -0,0 +1,439 @@ +use std::fmt::Write as _; +use std::fs::{File, OpenOptions}; +use std::io::{self, BufWriter, Write}; +#[cfg(feature = "recording-raster")] +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +#[cfg(feature = "recording-raster")] +use serde::Deserialize; + +use crate::terminal::cell::{Attrs, Color, EmuCell, UnderlineStyle, CONTINUATION}; + +const WIN32_INPUT_MODE: &[u8] = b"\x1b[?9001h"; + +#[cfg(feature = "recording-raster")] +#[derive(Debug, Clone, Copy, Deserialize)] +pub(crate) struct CastHeader { + pub version: u8, + pub width: u16, + pub height: u16, +} + +#[cfg(feature = "recording-raster")] +pub(crate) struct CastReader { + pub header: CastHeader, + reader: R, + line: String, + done: bool, +} + +#[cfg(feature = "recording-raster")] +#[derive(Debug)] +pub(crate) struct CastEvent { + pub time: f64, + pub kind: CastEventKind, +} + +#[cfg(feature = "recording-raster")] +#[derive(Debug)] +pub(crate) enum CastEventKind { + Output(String), + Resize(u16, u16), +} + +pub(crate) struct CastWriter { + start: Instant, + sink: BufWriter, +} + +impl CastWriter { + pub fn create( + path: &Path, + cols: u16, + rows: u16, + env: &[(String, String)], + start: Instant, + ) -> io::Result { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + let sink = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path)?; + let mut writer = Self { + start, + sink: BufWriter::new(sink), + }; + writer.write_header(cols, rows, env)?; + Ok(writer) + } + + pub fn write_output(&mut self, at: Instant, data: &str) -> io::Result<()> { + self.write_event(at, "o", data) + } + + pub fn write_resize(&mut self, at: Instant, cols: u16, rows: u16) -> io::Result<()> { + self.write_event(at, "r", &format!("{cols}x{rows}")) + } + + pub fn flush(&mut self) -> io::Result<()> { + self.sink.flush() + } + + fn write_header(&mut self, cols: u16, rows: u16, env: &[(String, String)]) -> io::Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let env = env + .iter() + .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone()))) + .collect::>(); + serde_json::to_writer( + &mut self.sink, + &serde_json::json!({ + "version": 2, + "width": cols, + "height": rows, + "timestamp": timestamp, + "env": env, + }), + ) + .map_err(json_error)?; + self.sink.write_all(b"\n")?; + self.sink.flush() + } + + fn write_event(&mut self, at: Instant, code: &str, data: &str) -> io::Result<()> { + let elapsed = at.saturating_duration_since(self.start).as_secs_f64(); + serde_json::to_writer(&mut self.sink, &(elapsed, code, data)).map_err(json_error)?; + self.sink.write_all(b"\n")?; + self.sink.flush() + } +} + +#[cfg(feature = "recording-raster")] +impl CastReader { + fn new(mut reader: R) -> anyhow::Result { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + anyhow::bail!("cast file is empty"); + } + let header: CastHeader = serde_json::from_str(line.trim_end())?; + if header.version != 2 { + anyhow::bail!("unsupported asciicast version {}", header.version); + } + if header.width == 0 || header.height == 0 { + anyhow::bail!("cast dimensions must be non-zero"); + } + Ok(Self { + header, + reader, + line, + done: false, + }) + } +} + +#[cfg(feature = "recording-raster")] +impl Iterator for CastReader { + type Item = anyhow::Result; + + fn next(&mut self) -> Option { + if self.done { + return None; + } + loop { + self.line.clear(); + let count = match self.reader.read_line(&mut self.line) { + Ok(count) => count, + Err(error) => { + self.done = true; + return Some(Err(error.into())); + } + }; + if count == 0 { + self.done = true; + return None; + } + let complete = self.line.ends_with('\n'); + let trimmed = self.line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + continue; + } + let event = serde_json::from_str::<(f64, String, String)>(trimmed); + let (time, code, data) = match event { + Ok(event) => event, + Err(_) if !complete => { + self.done = true; + return None; + } + Err(error) => { + self.done = true; + return Some(Err(error.into())); + } + }; + if !time.is_finite() || time < 0.0 { + self.done = true; + return Some(Err(anyhow::anyhow!( + "cast event timestamps must be finite and non-negative" + ))); + } + let kind = match code.as_str() { + "o" => CastEventKind::Output(data), + "r" => { + let Some((cols, rows)) = data.split_once('x') else { + self.done = true; + return Some(Err(anyhow::anyhow!("invalid cast resize event '{data}'"))); + }; + let cols = match cols.parse::() { + Ok(cols) => cols, + Err(error) => { + self.done = true; + return Some(Err(error.into())); + } + }; + let rows = match rows.parse::() { + Ok(rows) => rows, + Err(error) => { + self.done = true; + return Some(Err(error.into())); + } + }; + CastEventKind::Resize(cols, rows) + } + _ => continue, + }; + return Some(Ok(CastEvent { time, kind })); + } + } +} + +#[cfg(feature = "recording-raster")] +pub(crate) fn read(path: &Path) -> anyhow::Result>> { + CastReader::new(BufReader::new(File::open(path)?)) +} + +#[cfg(all(test, feature = "recording-raster"))] +fn read_bytes(bytes: &[u8]) -> anyhow::Result>> { + CastReader::new(BufReader::new(bytes)) +} + +pub(crate) fn snapshot_to_ansi(rows: &[Vec], cols: u16, cursor: (u16, u16)) -> String { + let blank = EmuCell::blank(); + let mut output = String::from("\x1b[0m\x1b[?7l\x1b[2J\x1b[H"); + for (y, row) in rows.iter().enumerate() { + for x in 0..usize::from(cols) { + let cell = row.get(x).unwrap_or(&blank); + if cell == &blank || cell.ch.as_str() == CONTINUATION { + continue; + } + let _ = write!(output, "\x1b[{};{}H", y + 1, x + 1); + write_style(&mut output, cell); + output.push_str(&cell.ch); + } + } + let _ = write!( + output, + "\x1b[0m\x1b[{};{}H\x1b[?7h", + cursor.1 + 1, + cursor.0 + 1 + ); + output +} + +fn write_style(output: &mut String, cell: &EmuCell) { + let mut codes = vec!["0".to_string()]; + for (attr, code) in [ + (Attrs::BOLD, "1"), + (Attrs::DIM, "2"), + (Attrs::ITALIC, "3"), + (Attrs::BLINK, "5"), + (Attrs::INVERSE, "7"), + (Attrs::INVISIBLE, "8"), + (Attrs::STRIKE, "9"), + ] { + if cell.has(attr) { + codes.push(code.to_string()); + } + } + let underline = match cell.underline { + UnderlineStyle::None => None, + UnderlineStyle::Single => Some("4"), + UnderlineStyle::Double => Some("4:2"), + UnderlineStyle::Curly => Some("4:3"), + UnderlineStyle::Dotted => Some("4:4"), + UnderlineStyle::Dashed => Some("4:5"), + }; + if let Some(underline) = underline { + codes.push(underline.to_string()); + } + push_color(&mut codes, cell.fg, true, ""); + push_color(&mut codes, cell.bg, false, ""); + if cell.underline.is_underlined() { + push_color(&mut codes, cell.underline_color, true, "5"); + } + let _ = write!(output, "\x1b[{}m", codes.join(";")); +} + +fn push_color(codes: &mut Vec, color: Option, foreground: bool, prefix: &str) { + let Some(color) = color else { + return; + }; + let base = if prefix.is_empty() { + if foreground { + "38" + } else { + "48" + } + } else { + "58" + }; + match color { + Color::Named(named) if prefix.is_empty() => { + let index = named.index(); + let value = if foreground { + if index < 8 { + 30 + index + } else { + 90 + index - 8 + } + } else if index < 8 { + 40 + index + } else { + 100 + index - 8 + }; + codes.push(value.to_string()); + } + Color::Named(named) => codes.push(format!("{base};5;{}", named.index())), + Color::Idx(index) => codes.push(format!("{base};5;{index}")), + Color::Rgb(red, green, blue) => { + codes.push(format!("{base};2;{red};{green};{blue}")); + } + } +} + +#[derive(Default)] +pub(crate) struct IncrementalDecoder { + pending: Vec, +} + +impl IncrementalDecoder { + pub fn push(&mut self, data: &[u8]) -> String { + let mut cleaned = Vec::with_capacity(data.len()); + strip_subsequence(data, WIN32_INPUT_MODE, &mut cleaned); + self.pending.extend_from_slice(&cleaned); + + let mut output = String::new(); + loop { + match std::str::from_utf8(&self.pending) { + Ok(text) => { + output.push_str(text); + self.pending.clear(); + break; + } + Err(error) => { + let valid = error.valid_up_to(); + if let Ok(text) = std::str::from_utf8(&self.pending[..valid]) { + output.push_str(text); + } + match error.error_len() { + Some(length) => { + output.push('\u{FFFD}'); + self.pending.drain(..valid + length); + } + None => { + self.pending.drain(..valid); + break; + } + } + } + } + } + output + } +} + +fn strip_subsequence(data: &[u8], needle: &[u8], output: &mut Vec) { + let mut index = 0; + while index < data.len() { + if data[index..].starts_with(needle) { + index += needle.len(); + } else { + output.push(data[index]); + index += 1; + } + } +} + +fn json_error(error: serde_json::Error) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, error) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incremental_decode_handles_split_multibyte() { + let mut decoder = IncrementalDecoder::default(); + assert_eq!(decoder.push(&[0xE2, 0x94]), ""); + assert_eq!(decoder.push(&[0x80, b'x']), "─x"); + } + + #[test] + fn incremental_decode_emits_replacement_for_invalid() { + let mut decoder = IncrementalDecoder::default(); + assert_eq!(decoder.push(&[b'a', 0xFF, b'b']), "a\u{FFFD}b"); + } + + #[test] + fn incremental_decode_removes_win32_input_mode() { + let mut decoder = IncrementalDecoder::default(); + assert_eq!(decoder.push(b"a\x1b[?9001hb"), "ab"); + } + + #[test] + fn snapshot_is_standard_ansi_output() { + let grid = vec![vec![EmuCell { + ch: "x".into(), + fg: Some(Color::Rgb(1, 2, 3)), + ..EmuCell::blank() + }]]; + let ansi = snapshot_to_ansi(&grid, 1, (0, 0)); + assert!(ansi.contains("\x1b[1;1H")); + assert!(ansi.contains("38;2;1;2;3")); + assert!(ansi.contains('x')); + } + + #[test] + fn snapshot_restores_the_live_cursor_before_future_output() { + use crate::terminal::alacritty::AlacrittyEmu; + use crate::terminal::emu::Emulator; + + let grid = vec![vec![EmuCell::blank(); 4]; 2]; + let mut emulator = AlacrittyEmu::new(4, 2, 0); + emulator.process(snapshot_to_ansi(&grid, 4, (2, 1)).as_bytes()); + emulator.process(b"X"); + assert_eq!(emulator.viewable_rows()[1][2].ch, "X"); + } + + #[test] + #[cfg(feature = "recording-raster")] + fn reader_ignores_an_incomplete_trailing_event() { + let bytes = b"{\"version\":2,\"width\":1,\"height\":1}\n[0.0,\"o\",\"x\"]\n[1.0"; + let mut reader = read_bytes(bytes).unwrap(); + assert!(matches!( + reader.next().unwrap().unwrap().kind, + CastEventKind::Output(ref output) if output == "x" + )); + assert!(reader.next().is_none()); + } +} diff --git a/crates/shell-use/src/record/frames.rs b/crates/shell-use/src/record/frames.rs new file mode 100644 index 0000000..2cfd5d6 --- /dev/null +++ b/crates/shell-use/src/record/frames.rs @@ -0,0 +1,342 @@ +use std::time::Duration; + +use crate::terminal::alacritty::AlacrittyEmu; +use crate::terminal::cell::EmuCell; +use crate::terminal::emu::Emulator; + +use std::io::BufRead; + +use super::cast::{CastEventKind, CastReader}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub grid: Vec>, + pub duration: Duration, +} + +#[derive(Debug, Clone)] +pub(crate) struct TimelineOptions { + pub fps: u8, + pub speed: f64, + pub idle_time_limit: Duration, + pub last_frame_duration: Duration, +} + +impl Default for TimelineOptions { + fn default() -> Self { + Self { + fps: 30, + speed: 1.0, + idle_time_limit: Duration::from_secs(5), + last_frame_duration: Duration::from_secs(3), + } + } +} + +pub(crate) fn from_cast( + mut cast: CastReader, + options: &TimelineOptions, +) -> anyhow::Result> { + validate_options(options)?; + let cols = cast.header.width; + let rows = cast.header.height; + let mut emulator = AlacrittyEmu::new(cols, rows, 0); + let mut clock = TimelineClock::default(); + let mut pending: Option = None; + let mut collector = FrameCollector::new(Duration::from_secs_f64(1.0 / f64::from(options.fps))); + + for event in &mut cast { + let event = event?; + let at = clock.advance(event.time, options)?; + if let Some(snapshot) = pending.take() { + collector.push(Frame { + grid: snapshot.grid, + duration: at.saturating_sub(snapshot.at), + }); + } + match event.kind { + CastEventKind::Output(output) => emulator.process(output.as_bytes()), + CastEventKind::Resize(width, height) => emulator.resize(width, height), + } + pending = Some(TimedGrid { + at, + grid: normalize_grid(emulator.viewable_rows(), cols, rows), + }); + } + + match pending { + Some(snapshot) => collector.push(Frame { + grid: snapshot.grid, + duration: options.last_frame_duration, + }), + None => collector.push(Frame { + grid: vec![vec![EmuCell::blank(); usize::from(cols)]; usize::from(rows)], + duration: options.last_frame_duration, + }), + } + Ok(collector.finish()) +} + +fn validate_options(options: &TimelineOptions) -> anyhow::Result<()> { + if options.fps == 0 { + anyhow::bail!("recording fps must be greater than zero"); + } + if !options.speed.is_finite() || options.speed <= 0.0 { + anyhow::bail!("recording speed must be finite and greater than zero"); + } + Ok(()) +} + +#[derive(Default)] +struct TimelineClock { + previous: Option, + adjusted: Duration, +} + +impl TimelineClock { + fn advance(&mut self, current: f64, options: &TimelineOptions) -> anyhow::Result { + let Some(previous) = self.previous.replace(current) else { + return Ok(Duration::ZERO); + }; + let gap = (current - previous) + .max(0.0) + .min(options.idle_time_limit.as_secs_f64()) + / options.speed; + let gap = Duration::try_from_secs_f64(gap) + .map_err(|_| anyhow::anyhow!("recording timeline duration is too large"))?; + self.adjusted = self.adjusted.saturating_add(gap); + Ok(self.adjusted) + } +} + +fn normalize_grid(source: Vec>, cols: u16, rows: u16) -> Vec> { + let mut output = vec![vec![EmuCell::blank(); usize::from(cols)]; usize::from(rows)]; + for (target, source) in output.iter_mut().zip(source) { + for (target, source) in target.iter_mut().zip(source) { + *target = source; + } + } + output +} + +struct TimedGrid { + at: Duration, + grid: Vec>, +} + +struct FrameCollector { + minimum: Duration, + merged: Option, + output: Vec, +} + +impl FrameCollector { + fn new(minimum: Duration) -> Self { + Self { + minimum, + merged: None, + output: Vec::new(), + } + } + + fn push(&mut self, frame: Frame) { + if let Some(previous) = self + .merged + .as_mut() + .filter(|previous| previous.grid == frame.grid) + { + previous.duration = previous.duration.saturating_add(frame.duration); + } else { + if let Some(previous) = self.merged.replace(frame) { + self.push_capped(previous); + } + } + } + + fn push_capped(&mut self, frame: Frame) { + if let Some(previous) = self + .output + .last_mut() + .filter(|previous| previous.duration < self.minimum) + { + let duration = previous.duration.saturating_add(frame.duration); + *previous = Frame { + grid: frame.grid, + duration, + }; + } else { + self.output.push(frame); + } + } + + fn finish(mut self) -> Vec { + if let Some(frame) = self.merged.take() { + self.push_capped(frame); + } + self.output + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + #[cfg(feature = "recording-raster")] + use crate::api::RecordingFormat; + #[cfg(feature = "recording-raster")] + use crate::record::cast::CastWriter; + #[cfg(feature = "recording-raster")] + use crate::render::raster::{FrameRenderer, GridRenderer}; + + fn grid(ch: &str) -> Vec> { + vec![vec![EmuCell { + ch: ch.into(), + ..EmuCell::blank() + }]] + } + + #[test] + fn idle_gaps_are_clamped_before_speed_scaling() { + let options = TimelineOptions { + speed: 2.0, + idle_time_limit: Duration::from_secs(5), + ..TimelineOptions::default() + }; + let mut clock = TimelineClock::default(); + assert_eq!(clock.advance(0.0, &options).unwrap(), Duration::ZERO); + assert_eq!( + clock.advance(10.0, &options).unwrap(), + Duration::from_millis(2500) + ); + } + + #[test] + fn identical_frames_extend_the_previous_duration() { + let frames = vec![ + Frame { + grid: grid("a"), + duration: Duration::from_millis(20), + }, + Frame { + grid: grid("a"), + duration: Duration::from_millis(30), + }, + ]; + let mut collector = FrameCollector::new(Duration::ZERO); + for frame in frames { + collector.push(frame); + } + let merged = collector.finish(); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].duration, Duration::from_millis(50)); + } + + #[test] + fn fps_cap_coalesces_short_frames_into_the_latest_grid() { + let frames = vec![ + Frame { + grid: grid("a"), + duration: Duration::from_millis(10), + }, + Frame { + grid: grid("b"), + duration: Duration::from_millis(10), + }, + Frame { + grid: grid("c"), + duration: Duration::from_millis(100), + }, + ]; + let mut collector = FrameCollector::new(Duration::from_millis(34)); + for frame in frames { + collector.push(frame); + } + let capped = collector.finish(); + assert_eq!(capped.len(), 1); + assert_eq!(capped[0].grid, grid("c")); + assert_eq!(capped[0].duration, Duration::from_millis(120)); + } + + #[test] + #[cfg(feature = "recording-raster")] + fn replay_retains_only_fps_limited_frames() { + let cast_path = temp_path("cast"); + let started = std::time::Instant::now(); + let mut writer = CastWriter::create(&cast_path, 1, 1, &[], started).unwrap(); + for index in 0..1_000 { + writer + .write_output( + started + Duration::from_millis(index), + if index % 2 == 0 { "\rA" } else { "\rB" }, + ) + .unwrap(); + } + writer.flush().unwrap(); + + let frames = from_cast( + crate::record::cast::read(&cast_path).unwrap(), + &TimelineOptions::default(), + ) + .unwrap(); + assert!(frames.len() <= 31, "retained {} frames", frames.len()); + assert_eq!( + frames.iter().map(|frame| frame.duration).sum::(), + Duration::from_millis(3_999) + ); + + std::fs::remove_file(cast_path).unwrap(); + } + + #[test] + #[cfg(feature = "recording-raster")] + fn scripted_cast_round_trips_to_apng() { + let cast_path = temp_path("cast"); + let apng_path = temp_path("png"); + let started = std::time::Instant::now(); + let mut writer = CastWriter::create(&cast_path, 2, 1, &[], started).unwrap(); + writer + .write_output(started, "\x1b[2J\x1b[H\x1b[48;2;200;10;20mA") + .unwrap(); + writer + .write_output( + started + Duration::from_millis(100), + "\x1b[48;2;10;20;200mB", + ) + .unwrap(); + writer.flush().unwrap(); + + let frames = from_cast( + crate::record::cast::read(&cast_path).unwrap(), + &TimelineOptions::default(), + ) + .unwrap(); + assert_eq!(frames.len(), 2); + assert_eq!( + frames.iter().map(|frame| frame.duration).sum::(), + Duration::from_millis(3100) + ); + + let mut renderer = GridRenderer::with_scale(2, 1, 2); + crate::render::encode::encode(&apng_path, RecordingFormat::Apng, &frames, &mut renderer, 2) + .unwrap(); + let encoded = std::fs::read(&apng_path).unwrap(); + assert_eq!(&encoded[..8], b"\x89PNG\r\n\x1a\n"); + assert!(encoded.windows(4).any(|window| window == b"acTL")); + assert_eq!(renderer.pixel_size(), (100, 148)); + + std::fs::remove_file(cast_path).unwrap(); + std::fs::remove_file(apng_path).unwrap(); + } + + #[cfg(feature = "recording-raster")] + fn temp_path(extension: &str) -> std::path::PathBuf { + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "shell-use-cast-roundtrip-{}-{}.{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed), + extension + )) + } +} diff --git a/crates/shell-use/src/render/encode.rs b/crates/shell-use/src/render/encode.rs new file mode 100644 index 0000000..9372946 --- /dev/null +++ b/crates/shell-use/src/render/encode.rs @@ -0,0 +1,417 @@ +use std::fs::File; +use std::io::{BufWriter, Write}; +use std::path::Path; +use std::time::Duration; + +use crate::api::RecordingFormat; +use crate::record::frames::Frame; + +use super::raster::FrameRenderer; + +pub(crate) fn encode( + path: &Path, + format: RecordingFormat, + frames: &[Frame], + renderer: &mut dyn FrameRenderer, + cols: u16, +) -> anyhow::Result<()> { + if frames.is_empty() { + anyhow::bail!("recording timeline contains no frames"); + } + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent)?; + } + match format { + RecordingFormat::Apng => encode_apng(path, frames, renderer, cols), + RecordingFormat::Gif => encode_gif(path, frames, renderer, cols), + RecordingFormat::Cast => anyhow::bail!("cast recordings do not require animation encoding"), + } +} + +fn encode_gif( + path: &Path, + frames: &[Frame], + renderer: &mut dyn FrameRenderer, + cols: u16, +) -> anyhow::Result<()> { + let (width, height) = renderer.pixel_size(); + let width: u16 = width.try_into()?; + let height: u16 = height.try_into()?; + let output = BufWriter::new(File::create(path)?); + let mut encoder = gif::Encoder::new(output, width, height, &[])?; + encoder.set_repeat(gif::Repeat::Infinite)?; + for step in gif_timeline(frames) { + let mut pixels = renderer.render(&frames[step.frame].grid, cols)?.into_raw(); + let mut frame = gif::Frame::from_rgba_speed(width, height, &mut pixels, 10); + frame.delay = step.delay; + encoder.write_frame(&frame)?; + } + let mut output = encoder.into_inner()?; + output.flush()?; + Ok(()) +} + +fn encode_apng( + path: &Path, + frames: &[Frame], + renderer: &mut dyn FrameRenderer, + cols: u16, +) -> anyhow::Result<()> { + let (width, height) = renderer.pixel_size(); + let output = BufWriter::new(File::create(path)?); + let mut encoder = png::Encoder::new(output, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_animated(frames.len().try_into()?, 0)?; + encoder.set_adaptive_filter(png::AdaptiveFilterType::Adaptive); + let mut writer = encoder.write_header()?; + for frame in frames { + let image = renderer.render(&frame.grid, cols)?; + let (delay_num, delay_den) = apng_delay(frame.duration); + writer.set_frame_delay(delay_num, delay_den)?; + writer.write_image_data(image.as_raw())?; + } + writer.finish()?; + Ok(()) +} + +fn apng_delay(duration: Duration) -> (u16, u16) { + let seconds = duration.as_secs_f64(); + if seconds <= 0.0 { + return (1, u16::MAX); + } + let denominator = (f64::from(u16::MAX) / seconds) + .floor() + .clamp(1.0, f64::from(u16::MAX)) as u16; + let numerator = (seconds * f64::from(denominator)) + .round() + .clamp(1.0, f64::from(u16::MAX)) as u16; + (numerator, denominator) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct GifStep { + frame: usize, + delay: u16, +} + +fn gif_timeline(frames: &[Frame]) -> Vec { + const TICK_NANOS: u128 = 10_000_000; + + let mut output: Vec = Vec::new(); + let mut elapsed = 0u128; + let mut emitted = 0u128; + for (index, frame) in frames.iter().enumerate() { + elapsed = elapsed.saturating_add(frame.duration.as_nanos()); + let rounded = elapsed.saturating_add(TICK_NANOS / 2) / TICK_NANOS; + if rounded <= emitted { + continue; + } + let mut delay = rounded - emitted; + while delay > 0 { + let chunk = delay.min(u128::from(u16::MAX)) as u16; + output.push(GifStep { + frame: index, + delay: chunk, + }); + delay -= u128::from(chunk); + } + emitted = rounded; + } + if output.is_empty() { + output.push(GifStep { + frame: frames.len().saturating_sub(1), + delay: 1, + }); + } else if output + .last() + .is_some_and(|step| step.frame + 1 < frames.len()) + { + let last_frame = frames.len() - 1; + if let Some(previous) = output.iter_mut().rev().find(|step| step.delay > 1) { + previous.delay -= 1; + output.push(GifStep { + frame: last_frame, + delay: 1, + }); + } else if let Some(previous) = output.last_mut() { + previous.frame = last_frame; + } + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::BufReader; + use std::sync::atomic::{AtomicU64, Ordering}; + + use crate::render::raster::{FrameRenderer, GridRenderer}; + use crate::terminal::cell::{Color, EmuCell}; + + #[test] + fn apng_delay_preserves_common_recording_intervals() { + for duration in [ + Duration::from_secs_f64(1.0 / 30.0), + Duration::from_millis(2500), + Duration::from_secs(5), + ] { + let (numerator, denominator) = apng_delay(duration); + let actual = f64::from(numerator) / f64::from(denominator); + assert!((actual - duration.as_secs_f64()).abs() < 0.000_1); + } + } + + #[test] + fn gif_timing_diffuses_thirty_fps_rounding_error() { + let frames = (0..30) + .map(|_| Frame { + grid: sample_grid(Color::Rgb(1, 2, 3)), + duration: Duration::from_secs_f64(1.0 / 30.0), + }) + .collect::>(); + let timeline = gif_timeline(&frames); + assert!(timeline.iter().all(|step| step.delay > 0)); + assert_eq!( + timeline + .iter() + .map(|step| u32::from(step.delay)) + .sum::(), + 100 + ); + assert!(timeline.iter().any(|step| step.delay == 4)); + } + + #[test] + fn sub_centisecond_frames_are_coalesced_without_zero_delays() { + let frames = (0..4) + .map(|index| Frame { + grid: sample_grid(Color::Rgb(index, 0, 0)), + duration: Duration::from_millis(4), + }) + .collect::>(); + let timeline = gif_timeline(&frames); + assert!(timeline.iter().all(|step| step.delay > 0)); + assert_eq!(timeline.last().unwrap().frame, frames.len() - 1); + } + + #[test] + fn a_short_frame_does_not_replace_an_already_timed_frame() { + let frames = [ + (0, Duration::from_millis(100)), + (1, Duration::from_millis(4)), + (2, Duration::from_millis(100)), + ] + .into_iter() + .map(|(red, duration)| Frame { + grid: sample_grid(Color::Rgb(red, 0, 0)), + duration, + }) + .collect::>(); + assert_eq!( + gif_timeline(&frames), + vec![ + GifStep { + frame: 0, + delay: 10 + }, + GifStep { + frame: 2, + delay: 10 + } + ] + ); + } + + #[test] + fn a_short_final_frame_gets_a_visible_tick_without_changing_total_time() { + let frames = [ + (0, Duration::from_millis(100)), + (1, Duration::from_millis(4)), + ] + .into_iter() + .map(|(red, duration)| Frame { + grid: sample_grid(Color::Rgb(red, 0, 0)), + duration, + }) + .collect::>(); + assert_eq!( + gif_timeline(&frames), + vec![ + GifStep { frame: 0, delay: 9 }, + GifStep { frame: 1, delay: 1 } + ] + ); + } + + #[test] + fn apng_and_gif_round_trip_dimensions_delays_and_color() { + for format in [RecordingFormat::Apng, RecordingFormat::Gif] { + let path = temp_path(match format { + RecordingFormat::Apng => "png", + RecordingFormat::Gif => "gif", + RecordingFormat::Cast => unreachable!(), + }); + let frames = sample_frames(); + let scale = 2; + let mut renderer = GridRenderer::with_scale(1, 1, scale); + encode(&path, format, &frames, &mut renderer, 1).unwrap(); + + match format { + RecordingFormat::Apng => { + let bytes = std::fs::read(&path).unwrap(); + let chunks = png_chunks(&bytes); + assert_eq!(png_dimensions(&chunks), renderer.pixel_size()); + assert_eq!(png_animation_frames(&chunks), 2); + assert!( + png_total_delay(&chunks).abs_diff(Duration::from_millis(400)) + <= Duration::from_micros(100) + ); + let pixel = decode_first_png_pixel(&path, 20 * scale, 48 * scale); + assert_eq!(&pixel[..3], &[200, 10, 20]); + } + RecordingFormat::Gif => { + let decoded = decode_gif(&path, 20 * scale, 48 * scale); + assert_eq!(decoded.frames, 2); + assert_eq!(decoded.dimensions, renderer.pixel_size()); + assert_eq!(decoded.delay, Duration::from_millis(400)); + for (actual, expected) in decoded.pixel[..3].iter().zip([200u8, 10, 20]) { + assert!(actual.abs_diff(expected) <= 3); + } + } + RecordingFormat::Cast => unreachable!(), + } + std::fs::remove_file(path).unwrap(); + } + } + + #[test] + fn repeated_apng_encodes_are_byte_identical() { + let first = temp_path("png"); + let second = temp_path("png"); + let frames = sample_frames(); + for path in [&first, &second] { + let mut renderer = GridRenderer::with_scale(1, 1, 2); + encode(path, RecordingFormat::Apng, &frames, &mut renderer, 1).unwrap(); + } + assert_eq!( + std::fs::read(&first).unwrap(), + std::fs::read(&second).unwrap() + ); + std::fs::remove_file(first).unwrap(); + std::fs::remove_file(second).unwrap(); + } + + fn sample_frames() -> Vec { + [ + (Color::Rgb(200, 10, 20), Duration::from_millis(100)), + (Color::Rgb(10, 20, 200), Duration::from_millis(300)), + ] + .into_iter() + .map(|(background, duration)| Frame { + grid: sample_grid(background), + duration, + }) + .collect() + } + + fn sample_grid(background: Color) -> Vec> { + vec![vec![EmuCell { + bg: Some(background), + ..EmuCell::blank() + }]] + } + + struct DecodedGif { + frames: usize, + dimensions: (u32, u32), + delay: Duration, + pixel: [u8; 4], + } + + fn decode_gif(path: &Path, x: u32, y: u32) -> DecodedGif { + let mut options = gif::DecodeOptions::new(); + options.set_color_output(gif::ColorOutput::RGBA); + let mut decoder = options.read_info(File::open(path).unwrap()).unwrap(); + let dimensions = (u32::from(decoder.width()), u32::from(decoder.height())); + let mut frames = 0; + let mut delay = Duration::ZERO; + let mut pixel = [0; 4]; + while let Some(frame) = decoder.read_next_frame().unwrap() { + if frames == 0 { + let offset = ((y * dimensions.0 + x) * 4) as usize; + pixel.copy_from_slice(&frame.buffer[offset..offset + 4]); + } + frames += 1; + delay += Duration::from_millis(u64::from(frame.delay) * 10); + } + DecodedGif { + frames, + dimensions, + delay, + pixel, + } + } + + fn decode_first_png_pixel(path: &Path, x: u32, y: u32) -> [u8; 4] { + let decoder = png::Decoder::new(BufReader::new(File::open(path).unwrap())); + let mut reader = decoder.read_info().unwrap(); + let mut buffer = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buffer).unwrap(); + let offset = ((y * info.width + x) * 4) as usize; + buffer[offset..offset + 4].try_into().unwrap() + } + + fn png_chunks(bytes: &[u8]) -> Vec<([u8; 4], &[u8])> { + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + let mut chunks = Vec::new(); + let mut offset = 8; + while offset + 12 <= bytes.len() { + let length = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + let kind = bytes[offset + 4..offset + 8].try_into().unwrap(); + let data = &bytes[offset + 8..offset + 8 + length]; + chunks.push((kind, data)); + offset += length + 12; + } + chunks + } + + fn png_dimensions(chunks: &[([u8; 4], &[u8])]) -> (u32, u32) { + let data = chunks.iter().find(|(kind, _)| kind == b"IHDR").unwrap().1; + ( + u32::from_be_bytes(data[..4].try_into().unwrap()), + u32::from_be_bytes(data[4..8].try_into().unwrap()), + ) + } + + fn png_animation_frames(chunks: &[([u8; 4], &[u8])]) -> u32 { + let data = chunks.iter().find(|(kind, _)| kind == b"acTL").unwrap().1; + u32::from_be_bytes(data[..4].try_into().unwrap()) + } + + fn png_total_delay(chunks: &[([u8; 4], &[u8])]) -> Duration { + chunks + .iter() + .filter(|(kind, _)| kind == b"fcTL") + .map(|(_, data)| { + let numerator = u16::from_be_bytes(data[20..22].try_into().unwrap()); + let denominator = u16::from_be_bytes(data[22..24].try_into().unwrap()).max(1); + Duration::from_secs_f64(f64::from(numerator) / f64::from(denominator)) + }) + .sum() + } + + fn temp_path(extension: &str) -> std::path::PathBuf { + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!( + "shell-use-animation-{}-{}.{}", + std::process::id(), + SEQUENCE.fetch_add(1, Ordering::Relaxed), + extension + )) + } +} diff --git a/crates/shell-use/src/render/font.rs b/crates/shell-use/src/render/font.rs new file mode 100644 index 0000000..ef3f855 --- /dev/null +++ b/crates/shell-use/src/render/font.rs @@ -0,0 +1,187 @@ +use std::cmp::Ordering; +use std::collections::HashSet; +use std::sync::{Arc, OnceLock}; + +pub(crate) const FAMILY: &str = "JetBrains Mono"; + +const REGULAR_FONT_DATA: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/jetbrains-mono/JetBrainsMono-Regular.ttf" +)); + +pub(crate) struct Catalog { + pub database: Arc, + candidates: [Vec; 4], + nerd_faces: Vec, +} + +impl Catalog { + pub fn candidates(&self, bold: bool, italic: bool, character: char) -> Vec { + let mut output = Vec::new(); + let mut seen = HashSet::new(); + if super::nerd_font::is_private_use(character) { + output.extend( + self.nerd_faces + .iter() + .copied() + .filter(|id| seen.insert(*id)), + ); + } + output.extend( + self.candidates[style_index(bold, italic)] + .iter() + .copied() + .filter(|id| seen.insert(*id)), + ); + output + } +} + +pub(crate) fn catalog() -> &'static Catalog { + static CATALOG: OnceLock = OnceLock::new(); + CATALOG.get_or_init(|| { + let mut database = fontdb::Database::new(); + database.load_font_data(REGULAR_FONT_DATA.to_vec()); + database.load_font_data(super::nerd_font::FONT_DATA.to_vec()); + database.load_system_fonts(); + + let preferred = preferred_families(); + let candidates = std::array::from_fn(|index| { + let bold = index & 1 != 0; + let italic = index & 2 != 0; + let mut faces = database.faces().collect::>(); + faces.sort_by(|left, right| { + face_score(left, &preferred, bold, italic) + .partial_cmp(&face_score(right, &preferred, bold, italic)) + .unwrap_or(Ordering::Equal) + }); + faces.into_iter().map(|face| face.id).collect() + }); + let nerd_faces = database + .faces() + .filter(|face| { + face.families + .iter() + .any(|(family, _)| family.contains("Nerd Font")) + }) + .map(|face| face.id) + .collect(); + Catalog { + database: Arc::new(database), + candidates, + nerd_faces, + } + }) +} + +fn style_index(bold: bool, italic: bool) -> usize { + usize::from(bold) | (usize::from(italic) << 1) +} + +fn preferred_families() -> Vec { + let configured = std::env::var("SHELL_USE_RECORDING_FONT_FAMILIES") + .ok() + .into_iter() + .flat_map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }); + configured + .chain( + [ + FAMILY, + "Cascadia Mono", + "Cascadia Code", + "Consolas", + "Menlo", + "DejaVu Sans Mono", + "Noto Sans Mono", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Sans CJK SC", + "Microsoft YaHei UI", + "Yu Gothic UI", + "Malgun Gothic", + "PingFang SC", + "Apple Color Emoji", + "Noto Color Emoji", + ] + .into_iter() + .map(str::to_string), + ) + .fold(Vec::new(), |mut families, family| { + if !families + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(&family)) + { + families.push(family); + } + families + }) +} + +fn face_score( + face: &fontdb::FaceInfo, + preferred: &[String], + bold: bool, + italic: bool, +) -> (usize, usize, u16, usize) { + let family = preferred + .iter() + .position(|preferred| { + face.families + .iter() + .any(|(family, _)| family.eq_ignore_ascii_case(preferred)) + }) + .unwrap_or(if face.monospaced { 1_000 } else { 2_000 }); + let wants_italic = italic; + let is_italic = face.style != fontdb::Style::Normal; + let style = usize::from(wants_italic != is_italic); + let desired_weight = if bold { + fontdb::Weight::BOLD.0 + } else { + fontdb::Weight::NORMAL.0 + }; + ( + family, + style, + face.weight.0.abs_diff(desired_weight), + usize::from(!face.monospaced), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_contains_the_bundled_regular_and_nerd_faces() { + let catalog = catalog(); + assert!(catalog + .database + .query(&fontdb::Query { + families: &[fontdb::Family::Name(FAMILY)], + ..fontdb::Query::default() + }) + .is_some()); + assert!(!catalog.nerd_faces.is_empty()); + } + + #[test] + fn bundled_font_includes_its_ofl_license_and_source() { + let license = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/jetbrains-mono/OFL.txt" + )); + let source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/jetbrains-mono/SOURCE" + )); + assert!(license.contains("SIL OPEN FONT LICENSE")); + assert!(source.contains("JetBrains/JetBrainsMono")); + } +} diff --git a/crates/shell-use/src/render/mod.rs b/crates/shell-use/src/render/mod.rs index 15e83ec..892566a 100644 --- a/crates/shell-use/src/render/mod.rs +++ b/crates/shell-use/src/render/mod.rs @@ -1,2 +1,9 @@ mod nerd_font; pub mod svg; + +#[cfg(feature = "recording-raster")] +pub mod encode; +#[cfg(feature = "recording-raster")] +mod font; +#[cfg(feature = "recording-raster")] +pub mod raster; diff --git a/crates/shell-use/src/render/nerd_font.rs b/crates/shell-use/src/render/nerd_font.rs index 2ce5032..a70dbaa 100644 --- a/crates/shell-use/src/render/nerd_font.rs +++ b/crates/shell-use/src/render/nerd_font.rs @@ -6,7 +6,7 @@ use ttf_parser::{Face, OutlineBuilder}; use crate::terminal::cell::EmuCell; // Nerd Fonts Symbols v3.4.0 is MIT-licensed; see the adjacent LICENSE. -const FONT_DATA: &[u8] = include_bytes!(concat!( +pub(crate) const FONT_DATA: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), "/assets/nerd-fonts/SymbolsNerdFontMono-Regular.ttf" )); @@ -194,14 +194,14 @@ impl NerdFont { } } -fn is_private_use(c: char) -> bool { +pub(crate) fn is_private_use(c: char) -> bool { matches!( c as u32, 0xe000..=0xf8ff | 0xf0000..=0xffffd | 0x100000..=0x10fffd ) } -fn is_powerline_separator(c: char) -> bool { +pub(crate) fn is_powerline_separator(c: char) -> bool { matches!(c as u32, 0xe0b0..=0xe0d4) } diff --git a/crates/shell-use/src/render/raster.rs b/crates/shell-use/src/render/raster.rs new file mode 100644 index 0000000..0c49012 --- /dev/null +++ b/crates/shell-use/src/render/raster.rs @@ -0,0 +1,574 @@ +use std::collections::{BTreeSet, HashMap}; + +use tiny_skia::{FillRule, Paint, Path, PathBuilder, Pixmap, Rect, Transform}; +use ttf_parser::{Face, OutlineBuilder}; + +use crate::terminal::cell::{EmuCell, CONTINUATION}; + +use super::{font, nerd_font, svg}; + +#[derive(Debug)] +pub struct RgbaFrame { + width: u32, + height: u32, + pixels: Vec, +} + +impl RgbaFrame { + pub fn dimensions(&self) -> (u32, u32) { + (self.width, self.height) + } + + pub fn as_raw(&self) -> &[u8] { + &self.pixels + } + + pub fn into_raw(self) -> Vec { + self.pixels + } +} + +pub trait FrameRenderer { + fn render(&mut self, grid: &[Vec], cols: u16) -> anyhow::Result; + fn pixel_size(&self) -> (u32, u32); +} + +pub struct GridRenderer { + cols: u16, + rows: usize, + scale: u32, + width: u32, + height: u32, + pixmap: Pixmap, + fonts: FontSystem, +} + +impl GridRenderer { + pub fn new(cols: u16, rows: usize) -> Self { + Self::with_scale(cols, rows, 1) + } + + pub fn with_scale(cols: u16, rows: usize, scale: u32) -> Self { + assert!(scale > 0, "recording raster scale must be non-zero"); + let (base_width, base_height) = svg::pixel_size(cols, rows); + let width = base_width + .checked_mul(scale) + .expect("recording width must fit in u32"); + let height = base_height + .checked_mul(scale) + .expect("recording height must fit in u32"); + Self { + cols, + rows, + scale, + width, + height, + pixmap: Pixmap::new(width, height) + .expect("terminal recording dimensions must fit a pixmap"), + fonts: FontSystem::new(), + } + } +} + +impl FrameRenderer for GridRenderer { + fn render(&mut self, grid: &[Vec], cols: u16) -> anyhow::Result { + if cols != self.cols || grid.len() != self.rows { + anyhow::bail!("recording frame dimensions changed during export"); + } + + let scale = self.scale as f32; + let theme = svg::Theme::default(); + self.pixmap.fill(tiny_skia::Color::from_rgba8(0, 0, 0, 0)); + fill_rounded_rect( + &mut self.pixmap, + 0.0, + 0.0, + self.width as f32, + self.height as f32, + 8.0 * scale, + theme.default_bg, + ); + for (index, color) in [(255, 95, 86), (255, 189, 46), (39, 201, 63)] + .into_iter() + .enumerate() + { + let cx = (svg::MARGIN_X + 5.0 + index as f32 * 20.0) * scale; + let cy = svg::HEADER_H / 2.0 * scale; + fill_circle(&mut self.pixmap, cx, cy, svg::DOT_R * scale, color); + } + + let blank = EmuCell::blank(); + for (y, row) in grid.iter().enumerate() { + for x in 0..usize::from(cols) { + let cell = row.get(x).unwrap_or(&blank); + let background = svg::bg_of(cell, &theme); + if background != theme.default_bg { + fill_rect( + &mut self.pixmap, + (svg::MARGIN_X + x as f32 * svg::CELL_W) * scale, + (svg::HEADER_H + y as f32 * svg::CELL_H) * scale, + svg::CELL_W * scale, + svg::CELL_H * scale, + background, + ); + } + } + } + + let mut missing = BTreeSet::new(); + let (pixmap, fonts) = (&mut self.pixmap, &mut self.fonts); + for (y, row) in grid.iter().enumerate() { + for x in 0..usize::from(cols) { + let cell = row.get(x).unwrap_or(&blank); + if cell.ch.as_str() == CONTINUATION { + continue; + } + let style = svg::style_of(cell, &theme); + if style.invisible { + continue; + } + let span = if row + .get(x + 1) + .is_some_and(|next| next.ch.as_str() == CONTINUATION) + { + 2 + } else { + 1 + }; + let origin_x = (svg::MARGIN_X + x as f32 * svg::CELL_W) * scale; + let origin_y = (svg::HEADER_H + y as f32 * svg::CELL_H) * scale; + let cell_width = svg::CELL_W * span as f32 * scale; + let cell_height = svg::CELL_H * scale; + let baseline = + (svg::HEADER_H + y as f32 * svg::CELL_H + svg::FONT_BASELINE) * scale; + + if unsupported_grapheme(cell.ch.as_str()) { + missing.insert(format_glyph_sequence(cell.ch.as_str())); + continue; + } + for character in cell.ch.chars() { + if character.is_whitespace() || is_default_ignorable(character) { + continue; + } + let key = GlyphKey { + character, + bold: style.bold, + italic: style.italic, + }; + match fonts.resolve(key) { + Some(glyph) => draw_glyph( + pixmap, + glyph, + origin_x, + origin_y, + cell_width, + cell_height, + baseline, + style.fg, + scale, + ), + None => { + missing.insert(format!("{character:?} (U+{:04X})", character as u32)); + } + } + } + + if style.underline { + fill_rect( + pixmap, + origin_x, + origin_y + cell_height - 3.0 * scale, + cell_width, + scale.max(1.0), + style.fg, + ); + } + if style.strike { + fill_rect( + pixmap, + origin_x, + baseline - svg::FONT_SIZE * 0.32 * scale, + cell_width, + scale.max(1.0), + style.fg, + ); + } + } + } + + if !missing.is_empty() { + let glyphs = missing.into_iter().collect::>().join(", "); + anyhow::bail!( + "recording rasterizer could not render glyphs: {glyphs}; install an outline font \ + containing them or set SHELL_USE_RECORDING_FONT_FAMILIES" + ); + } + + let mut pixels = self.pixmap.data().to_vec(); + for pixel in pixels.chunks_exact_mut(4) { + unpremultiply(pixel); + } + Ok(RgbaFrame { + width: self.width, + height: self.height, + pixels, + }) + } + + fn pixel_size(&self) -> (u32, u32) { + (self.width, self.height) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct GlyphKey { + character: char, + bold: bool, + italic: bool, +} + +struct GlyphOutline { + path: Path, + bounds: ttf_parser::Rect, + advance: u16, + units_per_em: u16, + synthetic_bold: bool, + synthetic_italic: bool, + powerline: bool, +} + +struct FontSystem { + catalog: &'static font::Catalog, + glyphs: HashMap>, +} + +impl FontSystem { + fn new() -> Self { + Self { + catalog: font::catalog(), + glyphs: HashMap::new(), + } + } + + fn resolve(&mut self, key: GlyphKey) -> Option<&GlyphOutline> { + if !self.glyphs.contains_key(&key) { + let glyph = self.load(key); + self.glyphs.insert(key, glyph); + } + self.glyphs.get(&key).and_then(Option::as_ref) + } + + fn load(&self, key: GlyphKey) -> Option { + for id in self.catalog.candidates(key.bold, key.italic, key.character) { + let info = self.catalog.database.face(id)?; + let weight = info.weight; + let style = info.style; + let glyph = self + .catalog + .database + .with_face_data(id, |data, index| { + let face = Face::parse(data, index).ok()?; + let glyph_id = face.glyph_index(key.character)?; + let mut builder = TinyPathBuilder::default(); + let bounds = face.outline_glyph(glyph_id, &mut builder)?; + let path = builder.finish()?; + Some(GlyphOutline { + path, + bounds, + advance: face + .glyph_hor_advance(glyph_id) + .unwrap_or(face.units_per_em()), + units_per_em: face.units_per_em(), + synthetic_bold: key.bold && weight.0 < fontdb::Weight::SEMIBOLD.0, + synthetic_italic: key.italic && style == fontdb::Style::Normal, + powerline: nerd_font::is_powerline_separator(key.character), + }) + }) + .flatten(); + if glyph.is_some() { + return glyph; + } + } + None + } +} + +#[derive(Default)] +struct TinyPathBuilder { + inner: PathBuilder, +} + +impl TinyPathBuilder { + fn finish(self) -> Option { + self.inner.finish() + } +} + +impl OutlineBuilder for TinyPathBuilder { + fn move_to(&mut self, x: f32, y: f32) { + self.inner.move_to(x, y); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.inner.line_to(x, y); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.inner.quad_to(x1, y1, x, y); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.inner.cubic_to(x1, y1, x2, y2, x, y); + } + + fn close(&mut self) { + self.inner.close(); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_glyph( + pixmap: &mut Pixmap, + glyph: &GlyphOutline, + origin_x: f32, + origin_y: f32, + cell_width: f32, + cell_height: f32, + baseline: f32, + color: (u8, u8, u8), + output_scale: f32, +) { + let bounds_width = f32::from(glyph.bounds.x_max - glyph.bounds.x_min).max(1.0); + let bounds_height = f32::from(glyph.bounds.y_max - glyph.bounds.y_min).max(1.0); + let transform = if glyph.powerline { + let scale_x = cell_width / bounds_width; + let scale_y = cell_height / bounds_height; + Transform::from_row( + scale_x, + 0.0, + 0.0, + -scale_y, + origin_x - f32::from(glyph.bounds.x_min) * scale_x, + origin_y + f32::from(glyph.bounds.y_max) * scale_y, + ) + } else { + let scale_y = svg::FONT_SIZE * output_scale / f32::from(glyph.units_per_em); + let (scale_x, x) = if glyph.advance == 0 { + let rendered_width = bounds_width * scale_y; + ( + scale_y, + origin_x + (cell_width - rendered_width) / 2.0 + - f32::from(glyph.bounds.x_min) * scale_y, + ) + } else { + (cell_width / f32::from(glyph.advance), origin_x) + }; + let shear = if glyph.synthetic_italic { + 0.2 * scale_y + } else { + 0.0 + }; + Transform::from_row(scale_x, 0.0, shear, -scale_y, x, baseline) + }; + + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, 255); + pixmap.fill_path(&glyph.path, &paint, FillRule::Winding, transform, None); + if glyph.synthetic_bold { + let mut bold = transform; + bold.tx += 0.65 * output_scale; + pixmap.fill_path(&glyph.path, &paint, FillRule::Winding, bold, None); + } +} + +fn fill_rect(pixmap: &mut Pixmap, x: f32, y: f32, width: f32, height: f32, color: (u8, u8, u8)) { + let Some(rect) = Rect::from_xywh(x, y, width, height) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, 255); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +fn fill_circle(pixmap: &mut Pixmap, x: f32, y: f32, radius: f32, color: (u8, u8, u8)) { + let Some(path) = PathBuilder::from_circle(x, y, radius) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, 255); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); +} + +#[allow(clippy::too_many_arguments)] +fn fill_rounded_rect( + pixmap: &mut Pixmap, + x: f32, + y: f32, + width: f32, + height: f32, + radius: f32, + color: (u8, u8, u8), +) { + let right = x + width; + let bottom = y + height; + let radius = radius.min(width / 2.0).min(height / 2.0); + let mut path = PathBuilder::new(); + path.move_to(x + radius, y); + path.line_to(right - radius, y); + path.quad_to(right, y, right, y + radius); + path.line_to(right, bottom - radius); + path.quad_to(right, bottom, right - radius, bottom); + path.line_to(x + radius, bottom); + path.quad_to(x, bottom, x, bottom - radius); + path.line_to(x, y + radius); + path.quad_to(x, y, x + radius, y); + path.close(); + let Some(path) = path.finish() else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, 255); + pixmap.fill_path( + &path, + &paint, + FillRule::Winding, + Transform::identity(), + None, + ); +} + +fn is_default_ignorable(character: char) -> bool { + matches!( + character as u32, + 0x00AD + | 0x034F + | 0x061C + | 0x115F..=0x1160 + | 0x17B4..=0x17B5 + | 0x180B..=0x180F + | 0x200B..=0x200F + | 0x202A..=0x202E + | 0x2060..=0x206F + | 0x3164 + | 0xFE00..=0xFE0F + | 0xFEFF + | 0xFFA0 + | 0xE0100..=0xE01EF + ) +} + +fn unsupported_grapheme(grapheme: &str) -> bool { + grapheme.chars().count() > 1 +} + +fn format_glyph_sequence(grapheme: &str) -> String { + let codepoints = grapheme + .chars() + .map(|character| format!("U+{:04X}", character as u32)) + .collect::>() + .join(" "); + format!("{grapheme:?} ({codepoints})") +} + +fn unpremultiply(pixel: &mut [u8]) { + let alpha = u16::from(pixel[3]); + if alpha == 0 { + pixel[..3].fill(0); + } else if alpha < 255 { + for channel in &mut pixel[..3] { + let value = (u16::from(*channel) * 255 + alpha / 2) / alpha; + *channel = value.min(255) as u8; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::cell::{Attrs, Color}; + + fn cell(character: &str, attrs: Attrs) -> EmuCell { + EmuCell { + ch: character.into(), + fg: Some(Color::Rgb(220, 220, 220)), + attrs, + ..EmuCell::blank() + } + } + + #[test] + fn repeated_renders_are_byte_identical() { + let grid = vec![vec![cell("x", Attrs::empty())]]; + let mut renderer = GridRenderer::new(1, 1); + let first = renderer.render(&grid, 1).unwrap(); + let second = renderer.render(&grid, 1).unwrap(); + assert_eq!(first.as_raw(), second.as_raw()); + assert_eq!(renderer.pixel_size().1 % 2, 0); + } + + #[test] + fn scaled_renderers_multiply_output_dimensions() { + let standard = GridRenderer::new(80, 30); + let hidpi = GridRenderer::with_scale(80, 30, 2); + assert_eq!( + hidpi.pixel_size(), + (standard.pixel_size().0 * 2, standard.pixel_size().1 * 2) + ); + } + + #[test] + fn bold_and_italic_change_the_rasterized_glyph() { + let mut renderer = GridRenderer::new(1, 1); + let regular = renderer + .render(&[vec![cell("M", Attrs::empty())]], 1) + .unwrap(); + let bold = renderer.render(&[vec![cell("M", Attrs::BOLD)]], 1).unwrap(); + let italic = renderer + .render(&[vec![cell("M", Attrs::ITALIC)]], 1) + .unwrap(); + assert_ne!(regular.as_raw(), bold.as_raw()); + assert_ne!(regular.as_raw(), italic.as_raw()); + } + + #[test] + fn supported_unicode_renders_and_missing_unicode_is_reported() { + let mut renderer = GridRenderer::new(1, 1); + renderer + .render(&[vec![cell("é", Attrs::empty())]], 1) + .unwrap(); + let error = renderer + .render(&[vec![cell("\u{10fffd}", Attrs::empty())]], 1) + .unwrap_err(); + assert!(error.to_string().contains("U+10FFFD")); + } + + #[test] + fn unsupported_emoji_sequences_are_reported_instead_of_misrendered() { + let mut renderer = GridRenderer::new(2, 1); + let error = renderer + .render(&[vec![cell("👩‍💻", Attrs::empty())]], 2) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("U+1F469")); + assert!(message.contains("U+200D")); + assert!(message.contains("U+1F4BB")); + } + + #[test] + fn cjk_uses_a_system_fallback_or_reports_the_missing_glyph() { + let mut renderer = GridRenderer::new(2, 1); + let grid = vec![vec![ + cell("界", Attrs::empty()), + cell(CONTINUATION, Attrs::empty()), + ]]; + if let Err(error) = renderer.render(&grid, 2) { + assert!(error.to_string().contains("U+754C")); + } + } +} diff --git a/crates/shell-use/src/render/svg.rs b/crates/shell-use/src/render/svg.rs index 2453be0..807e96f 100644 --- a/crates/shell-use/src/render/svg.rs +++ b/crates/shell-use/src/render/svg.rs @@ -13,13 +13,14 @@ use std::fmt::Write; use super::nerd_font::NerdFont; use crate::terminal::cell::{truncate_to_columns, Attrs, Color, EmuCell}; -const CELL_W: f32 = 10.0; -const CELL_H: f32 = 21.0; -const FONT_SIZE: f32 = 17.0; -const FONT_BASELINE: f32 = (CELL_H - FONT_SIZE) / 2.0 + FONT_SIZE * 0.78; -const MARGIN_X: f32 = 15.0; -const HEADER_H: f32 = 38.0; +pub(crate) const CELL_W: f32 = 10.0; +pub(crate) const CELL_H: f32 = 21.0; +pub(crate) const FONT_SIZE: f32 = 17.0; +pub(crate) const FONT_BASELINE: f32 = (CELL_H - FONT_SIZE) / 2.0 + FONT_SIZE * 0.78; +pub(crate) const MARGIN_X: f32 = 15.0; +pub(crate) const HEADER_H: f32 = 38.0; const MARGIN_BOTTOM: f32 = 14.0; +<<<<<<< Updated upstream const DOT_R: f32 = 7.0; /// Title bar text, smaller than the grid font so the chrome does not compete /// with the terminal content itself. @@ -27,13 +28,16 @@ const TITLE_FONT_SIZE: f32 = 13.0; /// Where the rightmost traffic light ends. A centred title is kept clear of /// this on both sides, so it can never be drawn over the controls. const DOTS_RIGHT: f32 = MARGIN_X + 5.0 + 2.0 * 20.0 + DOT_R; +======= +pub(crate) const DOT_R: f32 = 7.0; +>>>>>>> Stashed changes const FONT_STACK: &str = "'Cascadia Code','JetBrains Mono','Fira Code',Menlo,Consolas,'DejaVu Sans Mono',monospace"; -struct Theme { - palette: [(u8, u8, u8); 16], - default_fg: (u8, u8, u8), - default_bg: (u8, u8, u8), +pub(crate) struct Theme { + pub palette: [(u8, u8, u8); 16], + pub default_fg: (u8, u8, u8), + pub default_bg: (u8, u8, u8), } impl Default for Theme { @@ -64,7 +68,7 @@ impl Default for Theme { } impl Theme { - fn resolve(&self, color: Option, is_fg: bool) -> (u8, u8, u8) { + pub(crate) fn resolve(&self, color: Option, is_fg: bool) -> (u8, u8, u8) { match color { None => { if is_fg { @@ -96,7 +100,7 @@ fn cell_at(row: &[EmuCell], x: usize) -> &EmuCell { } /// Resolved background color for a cell (honoring inverse). -fn bg_of(cell: &EmuCell, theme: &Theme) -> (u8, u8, u8) { +pub(crate) fn bg_of(cell: &EmuCell, theme: &Theme) -> (u8, u8, u8) { let bg = theme.resolve(cell.bg, false); let fg = theme.resolve(cell.fg, true); if cell.has(Attrs::INVERSE) { @@ -107,16 +111,16 @@ fn bg_of(cell: &EmuCell, theme: &Theme) -> (u8, u8, u8) { } #[derive(PartialEq)] -struct Style { - fg: (u8, u8, u8), - bold: bool, - italic: bool, - underline: bool, - strike: bool, - invisible: bool, +pub(crate) struct Style { + pub fg: (u8, u8, u8), + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub strike: bool, + pub invisible: bool, } -fn style_of(cell: &EmuCell, theme: &Theme) -> Style { +pub(crate) fn style_of(cell: &EmuCell, theme: &Theme) -> Style { let mut fg = theme.resolve(cell.fg, true); let bg = theme.resolve(cell.bg, false); if cell.has(Attrs::INVERSE) { @@ -191,10 +195,18 @@ fn write_title(out: &mut String, title: &str, width: f32, theme: &Theme) { } /// Render a grid to a standalone SVG document. +<<<<<<< Updated upstream /// /// `title` is the window title a program set, drawn in the title bar. `None` /// leaves the bar bare, exactly as it was before titles were tracked. pub fn render_svg(rows: &[Vec], cols: u16, title: Option<&str>) -> String { +======= +pub fn render_svg(rows: &[Vec], cols: u16) -> String { + render_svg_with_font(rows, cols, FONT_STACK) +} + +pub(crate) fn render_svg_with_font(rows: &[Vec], cols: u16, font_family: &str) -> String { +>>>>>>> Stashed changes let theme = Theme::default(); let nerd_font = NerdFont::new(rows, FONT_SIZE); let cols = cols as usize; @@ -206,7 +218,7 @@ pub fn render_svg(rows: &[Vec], cols: u16, title: Option<&str>) -> Stri let mut out = String::new(); let _ = write!( out, - r#""# + r#""# ); nerd_font.write_defs(&mut out); let _ = write!( @@ -308,6 +320,13 @@ pub fn render_svg(rows: &[Vec], cols: u16, title: Option<&str>) -> Stri out } +#[cfg(feature = "recording-raster")] +pub(crate) fn pixel_size(cols: u16, rows: usize) -> (u32, u32) { + let width = (MARGIN_X * 2.0 + f32::from(cols) * CELL_W).ceil() as u32; + let height = (HEADER_H + MARGIN_BOTTOM + rows.max(1) as f32 * CELL_H).ceil() as u32; + (width + width % 2, height + height % 2) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shell-use/src/runtime.rs b/crates/shell-use/src/runtime.rs index ebc2319..41847a9 100644 --- a/crates/shell-use/src/runtime.rs +++ b/crates/shell-use/src/runtime.rs @@ -74,6 +74,9 @@ impl Session { } pub fn recording(&self) -> std::io::Result { + self.engine + .flush_recording() + .map_err(shell_error_to_io_error)?; std::fs::read_to_string(self.recording_path()) } } @@ -388,6 +391,15 @@ fn native_recording_path(name: &str) -> PathBuf { .join(format!("{}-{sequence}.cast", &digest[..16])) } +fn shell_error_to_io_error(error: ShellUseError) -> std::io::Error { + let kind = if error.kind == crate::api::ErrorKind::NoSession { + std::io::ErrorKind::NotFound + } else { + std::io::ErrorKind::Other + }; + std::io::Error::new(kind, error) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shell-use/src/session.rs b/crates/shell-use/src/session.rs index 4be0396..5376cab 100644 --- a/crates/shell-use/src/session.rs +++ b/crates/shell-use/src/session.rs @@ -8,12 +8,14 @@ use std::thread::JoinHandle; use std::time::Instant; use crate::logger::Logger; +use crate::record::{self, CaptureError, Recorder, StartRecording}; +#[cfg(feature = "recording-raster")] +use crate::render::raster::GridRenderer; use crate::shell::{self, Shell}; use crate::terminal::alacritty::AlacrittyEmu; use crate::terminal::emu::Emulator; use crate::terminal::integration::CommandTracker; use crate::terminal::pty::{Pty, SpawnOptions}; -use crate::trace::recorder::Recorder; pub struct TermState { pub emu: Box, @@ -34,7 +36,7 @@ pub struct Session { pub pty: Arc>, pub state: Arc>, pub cancelled: Arc, - recorder: Arc>, + recorder: Recorder, logger: Arc, _reader: JoinHandle<()>, } @@ -87,21 +89,16 @@ impl Session { let pty = Arc::new(Mutex::new(pty)); let cancelled = Arc::new(AtomicBool::new(false)); - let mut rec_env: Vec<(&str, String)> = vec![("TERM", "xterm-256color".to_string())]; + let mut rec_env = vec![("TERM".to_string(), "xterm-256color".to_string())]; if let Some(sh) = shell { - rec_env.push(("SHELL", sh.as_str().to_string())); + rec_env.push(("SHELL".to_string(), sh.as_str().to_string())); } - let recorder = Arc::new(Mutex::new(Recorder::create( - &recording_path, - cols, - rows, - &rec_env, - ))); + let recorder = Recorder::create(recording_path, cols, rows, &rec_env, logger.clone()); let reader_state = state.clone(); let reader_pty = pty.clone(); let reader_logger = logger.clone(); - let reader_recorder = recorder.clone(); + let reader_recorder = recorder.capture(); let mut reader = reader; let handle = std::thread::spawn(move || { use std::io::Read; @@ -111,10 +108,6 @@ impl Session { Ok(0) | Err(_) => break, Ok(n) => { reader_logger.read(&buf[..n]); - reader_recorder - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .on_data(&buf[..n]); let pending = { let mut st = reader_state .lock() @@ -122,6 +115,7 @@ impl Session { st.emu.process(&buf[..n]); st.tracker.feed(&buf[..n]); st.last_change = Instant::now(); + reader_recorder.on_data(&buf[..n]); st.emu.take_pending_writes() }; if !pending.is_empty() { @@ -189,10 +183,7 @@ impl Session { pub fn resize(&mut self, cols: u16, rows: u16) -> anyhow::Result<()> { self.logger.event(&format!("resize {cols}x{rows}")); - self.recorder - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .on_resize(cols, rows); + self.recorder.on_resize(cols, rows); self.cols = cols; self.rows = rows; let mut st = self @@ -208,6 +199,145 @@ impl Session { Ok(()) } + pub fn start_recording( + &self, + path: String, + format: Option, + fps: Option, + speed: Option, + idle_time_limit: Option, + ) -> Result<(), crate::api::ShellUseError> { + if path.trim().is_empty() { + return Err(crate::api::ShellUseError::usage( + "recording path must not be empty", + )); + } + let format = format + .or_else(|| crate::api::RecordingFormat::infer(&path)) + .ok_or_else(|| { + crate::api::ShellUseError::usage( + "cannot infer recording format; use .png, .apng, .gif, or .cast", + ) + })?; + #[cfg(not(feature = "recording-raster"))] + if format != crate::api::RecordingFormat::Cast { + return Err(crate::api::ShellUseError::usage( + "APNG and GIF recording require the shell-use 'recording-raster' feature", + )); + } + let fps = fps.unwrap_or(30); + if fps == 0 { + return Err(crate::api::ShellUseError::usage( + "recording fps must be greater than zero", + )); + } + let speed = speed.unwrap_or(1.0); + if !speed.is_finite() || speed <= 0.0 { + return Err(crate::api::ShellUseError::usage( + "recording speed must be finite and greater than zero", + )); + } + let idle_time_limit = idle_time_limit.unwrap_or(5.0); + if !idle_time_limit.is_finite() || idle_time_limit < 0.0 { + return Err(crate::api::ShellUseError::usage( + "idle time limit must be a finite, non-negative number of seconds", + )); + } + let idle_time_limit = std::time::Duration::try_from_secs_f64(idle_time_limit) + .map_err(|_| crate::api::ShellUseError::usage("idle time limit is too large"))?; + std::time::Duration::try_from_secs_f64(idle_time_limit.as_secs_f64() / speed) + .map_err(|_| crate::api::ShellUseError::usage("recording speed is too small"))?; + + let target_path = PathBuf::from(path); + let capture_path = if format == crate::api::RecordingFormat::Cast { + target_path.clone() + } else { + record::sidecar_path(&target_path) + }; + let mut env = vec![("TERM".to_string(), "xterm-256color".to_string())]; + if let Some(shell) = self.shell { + env.push(("SHELL".to_string(), shell.as_str().to_string())); + } + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (cols, rows) = state.emu.size(); + let initial_output = + record::cast::snapshot_to_ansi(&state.emu.viewable_rows(), cols, state.emu.cursor()); + let result = self.recorder.start(StartRecording { + target_path, + capture_path, + format, + cols, + rows, + env, + initial_output, + #[cfg(feature = "recording-raster")] + timeline: record::frames::TimelineOptions { + fps, + speed, + idle_time_limit, + ..record::frames::TimelineOptions::default() + }, + }); + drop(state); + result.map_err(capture_error) + } + + pub fn stop_recording(&self) -> Result { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let stopped = self.recorder.stop().map_err(capture_error)?; + drop(state); + if stopped.format == crate::api::RecordingFormat::Cast { + return Ok(stopped.target_path.to_string_lossy().into_owned()); + } + + #[cfg(not(feature = "recording-raster"))] + return Err(crate::api::ShellUseError::internal( + "raster recording was started without the 'recording-raster' feature", + )); + + #[cfg(feature = "recording-raster")] + { + let temporary_path = temporary_output_path(&stopped.target_path); + let result = (|| -> anyhow::Result<()> { + let cast = record::cast::read(&stopped.capture_path)?; + let frames = record::frames::from_cast(cast, &stopped.timeline)?; + let scale = match stopped.format { + crate::api::RecordingFormat::Apng | crate::api::RecordingFormat::Gif => 2, + crate::api::RecordingFormat::Cast => unreachable!(), + }; + let mut renderer = + GridRenderer::with_scale(stopped.cols, usize::from(stopped.rows), scale); + crate::render::encode::encode( + &temporary_path, + stopped.format, + &frames, + &mut renderer, + stopped.cols, + )?; + replace_output(&temporary_path, &stopped.target_path)?; + std::fs::remove_file(&stopped.capture_path)?; + Ok(()) + })(); + + match result { + Ok(()) => Ok(stopped.target_path.to_string_lossy().into_owned()), + Err(error) => { + let _ = std::fs::remove_file(&temporary_path); + Err(crate::api::ShellUseError::internal(format!( + "failed to export recording; captured cast retained at {}: {error}", + stopped.capture_path.display() + ))) + } + } + } + } + pub fn kill(&self) { self.cancelled.store(true, Ordering::Release); self.pty @@ -222,4 +352,99 @@ impl Session { .unwrap_or_else(std::sync::PoisonError::into_inner) .pid() } + + pub fn flush_recording(&self) -> Result<(), crate::api::ShellUseError> { + self.recorder.flush().map_err(capture_error) + } +} + +fn capture_error(error: CaptureError) -> crate::api::ShellUseError { + match error { + CaptureError::AlreadyActive => { + crate::api::ShellUseError::usage("a recording is already active") + } + CaptureError::NotActive => crate::api::ShellUseError::usage("no recording is active"), + CaptureError::WorkerStopped => { + crate::api::ShellUseError::internal("recording worker stopped unexpectedly") + } + CaptureError::Io(message) => { + crate::api::ShellUseError::internal(format!("recording capture failed: {message}")) + } + } +} + +#[cfg(feature = "recording-raster")] +fn temporary_output_path(target: &std::path::Path) -> PathBuf { + let mut name = target + .file_name() + .unwrap_or_else(|| std::ffi::OsStr::new("recording")) + .to_os_string(); + name.push(".shell-use.tmp"); + target.with_file_name(name) +} + +#[cfg(all(feature = "recording-raster", not(windows)))] +fn replace_output(source: &std::path::Path, target: &std::path::Path) -> std::io::Result<()> { + std::fs::rename(source, target) +} + +#[cfg(all(feature = "recording-raster", windows))] +fn replace_output(source: &std::path::Path, target: &std::path::Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + + const MOVEFILE_REPLACE_EXISTING: u32 = 0x0000_0001; + const MOVEFILE_WRITE_THROUGH: u32 = 0x0000_0008; + + extern "system" { + fn MoveFileExW( + existing_file_name: *const u16, + new_file_name: *const u16, + flags: u32, + ) -> i32; + } + + let source = source + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // Safety: both buffers are NUL-terminated and remain alive for the call. + let replaced = unsafe { + MoveFileExW( + source.as_ptr(), + target.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if replaced == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(all(test, feature = "recording-raster"))] +mod tests { + use super::*; + + #[test] + fn failed_replacement_preserves_the_existing_output() { + let root = + std::env::temp_dir().join(format!("shell-use-replace-output-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let target = root.join("recording.gif"); + let missing = root.join("missing.tmp"); + std::fs::write(&target, b"previous recording").unwrap(); + + assert!(replace_output(&missing, &target).is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"previous recording"); + + std::fs::remove_file(target).unwrap(); + std::fs::remove_dir(root).unwrap(); + } } diff --git a/crates/shell-use/src/trace/recorder.rs b/crates/shell-use/src/trace/recorder.rs index f7327af..0ba5e92 100644 --- a/crates/shell-use/src/trace/recorder.rs +++ b/crates/shell-use/src/trace/recorder.rs @@ -1,175 +1,4 @@ -//! Always-on session recording in the asciinema v2 cast format. -//! -//! Output (and resize) events are streamed to a `.cast` file as they arrive, so -//! the recording is always durable and readable while the session is alive. The -//! file is removed when the session is killed. Format reference: -//! . +//! Compatibility path for the recorder moved to [`crate::record`]. -use std::fs::{File, OpenOptions}; -use std::io::{BufWriter, Write}; -use std::path::Path; -use std::time::{Instant, SystemTime, UNIX_EPOCH}; - -const WIN32_INPUT_MODE: &[u8] = b"\x1b[?9001h"; - -pub struct Recorder { - start: Instant, - sink: Option>, - /// Trailing bytes of an incomplete UTF-8 sequence, carried to the next chunk - /// so multi-byte glyphs split across PTY reads aren't corrupted. - pending: Vec, -} - -impl Recorder { - /// A recorder that writes nowhere. - #[cfg(test)] - pub fn disabled() -> Self { - Recorder { - start: Instant::now(), - sink: None, - pending: Vec::new(), - } - } - - /// Create (truncating) a cast file and write the asciinema v2 header. - pub fn create(path: &Path, cols: u16, rows: u16, env: &[(&str, String)]) -> Self { - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let sink = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path) - .ok() - .map(BufWriter::new); - let mut rec = Recorder { - start: Instant::now(), - sink, - pending: Vec::new(), - }; - rec.write_header(cols, rows, env); - rec - } - - fn write_header(&mut self, cols: u16, rows: u16, env: &[(&str, String)]) { - let Some(sink) = self.sink.as_mut() else { - return; - }; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let env_obj: serde_json::Map = env - .iter() - .map(|(k, v)| ((*k).to_string(), serde_json::Value::String(v.clone()))) - .collect(); - let header = serde_json::json!({ - "version": 2, - "width": cols, - "height": rows, - "timestamp": timestamp, - "env": env_obj, - }); - let _ = writeln!(sink, "{header}"); - let _ = sink.flush(); - } - - /// Record a chunk of terminal output as an `"o"` event. - pub fn on_data(&mut self, data: &[u8]) { - let mut cleaned = Vec::with_capacity(data.len()); - strip_subsequence(data, WIN32_INPUT_MODE, &mut cleaned); - if cleaned.is_empty() { - return; - } - let text = self.decode_incremental(&cleaned); - if !text.is_empty() { - self.write_event("o", &text); - } - } - - /// Record a terminal resize as an `"r"` event (`x`). - pub fn on_resize(&mut self, cols: u16, rows: u16) { - self.write_event("r", &format!("{cols}x{rows}")); - } - - /// Decode appended bytes as UTF-8, retaining a trailing incomplete sequence - /// (≤3 bytes) for the next call. Genuinely invalid bytes become U+FFFD. - fn decode_incremental(&mut self, bytes: &[u8]) -> String { - self.pending.extend_from_slice(bytes); - let mut out = String::new(); - loop { - match std::str::from_utf8(&self.pending) { - Ok(s) => { - out.push_str(s); - self.pending.clear(); - break; - } - Err(e) => { - let valid = e.valid_up_to(); - if let Ok(s) = std::str::from_utf8(&self.pending[..valid]) { - out.push_str(s); - } - match e.error_len() { - Some(len) => { - out.push('\u{FFFD}'); - self.pending.drain(..valid + len); - } - None => { - self.pending.drain(..valid); - break; - } - } - } - } - } - out - } - - fn write_event(&mut self, code: &str, data: &str) { - let time = self.start.elapsed().as_secs_f64(); - let Some(sink) = self.sink.as_mut() else { - return; - }; - let line = serde_json::to_string(&(time, code, data)).unwrap_or_default(); - let _ = writeln!(sink, "{line}"); - let _ = sink.flush(); - } -} - -fn strip_subsequence(data: &[u8], needle: &[u8], out: &mut Vec) { - let mut i = 0; - while i < data.len() { - if data[i..].starts_with(needle) { - i += needle.len(); - } else { - out.push(data[i]); - i += 1; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn incremental_decode_handles_split_multibyte() { - let mut rec = Recorder::disabled(); - assert_eq!(rec.decode_incremental(&[0xE2, 0x94]), ""); - assert_eq!(rec.decode_incremental(&[0x80, b'x']), "─x"); - } - - #[test] - fn incremental_decode_emits_replacement_for_invalid() { - let mut rec = Recorder::disabled(); - assert_eq!(rec.decode_incremental(&[b'a', 0xFF, b'b']), "a\u{FFFD}b"); - } - - #[test] - fn strip_removes_win32_input_mode() { - let mut out = Vec::new(); - strip_subsequence(b"a\x1b[?9001hb", WIN32_INPUT_MODE, &mut out); - assert_eq!(out, b"ab"); - } -} +#[allow(unused_imports)] +pub(crate) use crate::record::Recorder; diff --git a/crates/shell-use/tests/runtime.rs b/crates/shell-use/tests/runtime.rs index 89a8bfc..a60340d 100644 --- a/crates/shell-use/tests/runtime.rs +++ b/crates/shell-use/tests/runtime.rs @@ -35,6 +35,10 @@ fn named_handles_share_a_process_local_terminal() { timeout_ms: Some(5_000), }) .expect("find command output"); + assert!(second + .recording() + .expect("read active recording") + .contains("native-runtime")); assert!(registry.sessions().contains(&name)); first.close().expect("close terminal"); @@ -147,3 +151,51 @@ fn close_all_interrupts_in_flight_waits() { assert!(start.elapsed() < Duration::from_secs(2)); assert_eq!(wait.join().unwrap().unwrap_err().kind, ErrorKind::Assertion); } + +#[test] +#[cfg(feature = "recording-raster")] +fn session_records_and_exports_an_apng() { + let registry = SessionRegistry::default(); + let session = registry.session(format!("recording-export-{}", std::process::id())); + let path = std::env::temp_dir().join(format!( + "shell-use-recording-export-{}.png", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + + session.open(OpenOptions::default()).expect("open terminal"); + session + .execute(Operation::StartRecording { + path: path.to_string_lossy().into_owned(), + format: None, + fps: Some(30), + speed: Some(1.0), + idle_time_limit: Some(5.0), + }) + .expect("start recording"); + session + .execute(Operation::Submit { + data: Some("echo animated-recording".to_string()), + }) + .expect("submit command"); + session + .execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + }) + .expect("wait for command"); + let OperationResult::Recording(recorded) = session + .execute(Operation::StopRecording) + .expect("stop recording") + else { + panic!("unexpected recording result"); + }; + assert_eq!(recorded, path.to_string_lossy()); + let bytes = std::fs::read(&path).expect("read apng"); + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); + assert!(bytes.windows(4).any(|window| window == b"acTL")); + assert_eq!(u32::from_be_bytes(bytes[16..20].try_into().unwrap()), 1660); + assert_eq!(u32::from_be_bytes(bytes[20..24].try_into().unwrap()), 1364); + + session.close().expect("close terminal"); + std::fs::remove_file(path).expect("remove apng"); +}