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 0000000..e0a0ed8 Binary files /dev/null and b/crates/shell-use/assets/jetbrains-mono/JetBrainsMono-Regular.ttf differ 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"); +}