diff --git a/docs/USAGE.md b/docs/USAGE.md index fc1547e6..db94babb 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -298,7 +298,7 @@ agent-tty record export --format webm --json | jq -c '.result.missi WebM export replays with recorded wall-clock timing by default. Pass `--timing accelerated` (idle gaps clamped to 400ms) or `--timing max-speed` for a time-compressed video. A frame of the video shows the screen after the output that arrived within one frame hold of the first output of that frame: 16 ms in `recorded` and `max-speed` mode, 100 ms in `accelerated` mode. Output events that close together share one frame, so a burst of output does not cost one frame per event, and a state that the next output of the frame replaces is not shown. In `recorded` and `accelerated` mode, an event that changes no pixel (an input, a marker, a run-complete, a signal or the exit) also ends a frame. In `max-speed` mode it does not: recorded time, a resize and the end of the recording end a frame, so output on both sides of an input event that is less than 16 ms apart shares one frame. `max-speed` removes the idle time. Recorded time ends a frame at most once per 16 ms, so a progress bar that updates every 30 ms shows many of its steps and not only its last one. Recorded time ends at most about 2,400 frames, so a recording longer than 38 s gets one frame per 1/2,400 of its recorded time instead (a 1-hour recording: one per 1.5 s). A resize that finds output waiting ends one more frame, and the limits above do not count it: 100,000 outputs that each have a resize 1 ms after them make 100,000 frames. A resize adds at most one frame, and none when no output is waiting. So the export time of a recording follows its length and its number of resizes, and not its number of output events. `max-speed` waits for one animation frame after each frame but holds no further time between two frames, so two frames that follow each other can end up in one video frame. The video has 25 frames per second, so it can skip a state that lasts less than one video frame (40 ms). A step of 30 ms is shorter than that, and the number of steps that survive depends on how fast the host runs the export. The screen before a resize is the exception: `max-speed` keeps it on the page for one video frame (40 ms) before the resize, so that the video shows it. The video can still miss it in rare cases. -With recorded timing the replay waits out every recorded gap, so an export takes at least as long as the recording. It takes longer when the recording is dense or resizes a large terminal, because each frame costs time in the browser and each resize of a large terminal costs seconds. On a shared host, a recording of 7.4 s with 7,401 events took 473 s at `--timing accelerated`, and a recording of 0.8 s with 400 resizes of a 600x150 terminal took 910 s at recorded timing. The export has no total time limit. It stops only when the renderer makes no progress for 5 minutes, and then fails with `EXPORT_ERROR`. The message names the window and the last event that the replay reached, and `details` holds `stallWindowMs`, `lastSeq` and `timingMode`. Waiting out a recorded gap is not a stall. +With recorded timing the replay waits out every recorded gap, so an export takes at least as long as the recording. It takes longer when the recording is dense or resizes a large terminal, because each frame costs time in the browser and each resize of a large terminal costs seconds. On a shared host, exporting a recording of 7.4 s with 7,401 events at `--timing accelerated` took 477 s, of which the replay took 477 s. Exporting a recording of 0.8 s with 400 resizes of a 600x150 terminal at recorded timing took 595 s and 2,582 s in two runs, depending on the load of the host. The replay took 592 s and 985 s of that. The second export spent the other 1,597 s outside the replay, in the browser start, the glyph check or the video finalize. The replay is the stage that the stall window below watches. The export has no total time limit. It stops only when the renderer makes no progress for 5 minutes, and then fails with `EXPORT_ERROR`. The message names the window and the last event that the replay reached, and `details` holds `stallWindowMs`, `lastSeq` and `timingMode`. Waiting out a recorded gap is not a stall. SVG export renders styled grid frames from the event log through the native `libghostty-vt` backend with no browser and no ffmpeg, so it requires the optional `@coder/libghostty-vt-node` package (there is no `ghostty-web` fallback). The output is deterministic: exporting the same session twice produces byte-identical, diffable SVG. `--format svg` writes a still image of the final screen and ignores event timing, so it also works on a log whose clock stepped backward (`--animate` refuses such a log and names the event where the clock stepped back); add `--animate` for an animated SVG of de-duplicated frames replayed with recorded event-log timing (`--timing` is not supported with SVG). diff --git a/src/export/README.md b/src/export/README.md index 3423b1a2..bfdf34fd 100644 --- a/src/export/README.md +++ b/src/export/README.md @@ -27,7 +27,7 @@ This directory converts a session's event log into recording exports. `generateA - A requested `libghostty-vt` renderer is replaced with `ghostty-web`. A code comment says `libghostty-vt` is semantic-only [webm.ts:generateWebmExport]. - The default backend factory accepts only `ghostty-web` [webm.ts:createDefaultBackend]. - Timing presets: `accelerated` caps gaps at 400 ms and holds frames at least 100 ms. `max-speed` uses a frame hold of 16 ms, but holds it only after the last flush of the replay and after a resize, and holds one video frame (40 ms) before a resize (see the `ghostty-web` README). Each mode holds the final frame (1000 ms, or 500 ms for `max-speed`) [webm.ts:buildReplayTimingOptions]. -- The timed replay has no total time limit. It fails with `WebmReplayStallError` only when the renderer makes no progress for `REPLAY_STALL_WINDOW_MS` (5 minutes). The backend reports progress through the `onProgress` argument of `replayWithTiming`: each report restarts the window. A report that comes before a wait on the timing schedule carries the length of that wait, and the window starts after it, so a long recorded gap is not a stall. A stretch without a report is one page call or a fixed hold, never the whole replay: a call carries at most 1000 chunks, and a report follows it. The stretches are the start to the first report (the replay sizes the terminal and holds one frame), one page call (the call that ends a flush also writes the chunks that the earlier calls of the flush held, so the event log limit of 50 MiB bounds it), and the last report to the end (the final frame hold of 1000 ms, or 500 ms at `max-speed`, with the glyph probes inside it, and then a read of the screen). Examples that I measured at 600x150 on a shared host under load: the start took 1.3 to 2.0 s, the end 1.8 to 2.8 s, a flush of 11,000 events of 4 KB 8.4 s, and one stretch inside the loop 4.4 s. These are examples and not bounds. The longest, 8.4 s, is under 3% of the window. No timer runs longer than 2^31-1 ms (the longest delay that a timer runs): a longer span is a chain of timers. The watchdog starts no timer once the replay has ended, failed or stalled. Boot and finalize run outside the watchdog [webm.ts:replayWithStallWatchdog, webm.ts:REPLAY_STALL_WINDOW_MS]. +- The timed replay has no total time limit. It fails with `WebmReplayStallError` only when the renderer makes no progress for `REPLAY_STALL_WINDOW_MS` (5 minutes). The backend reports progress through the `onProgress` argument of `replayWithTiming`: each report restarts the window. A report that comes before a wait on the timing schedule carries the length of that wait, and the window starts after it, so a long recorded gap is not a stall. A stretch without a report is one page call or a fixed hold, never the whole replay: a call carries at most 1000 chunks, and a report follows it. The stretches are the start to the first report (the replay sizes the terminal and holds one frame), one page call (the call that ends a flush also writes the chunks that the earlier calls of the flush held, so the event log limit of 50 MiB bounds it), and the last report to the end (the final frame hold of 1000 ms, or 500 ms at `max-speed`, with the glyph probes inside it, and then a read of the screen). Examples measured at 600x150 on a shared host under load (host CPU pressure 34 to 57 %): the start took 1.2 to 1.9 s and the end 1.1 to 1.4 s. One flush of 520 full-screen repaints of 93 KB (48.6 MB) had a longest stretch of 15.9 to 20.7 s in three runs, 5.3 to 6.9 % of the window. One flush of 11,000 events of 4 KB (45 MB) had a longest stretch of 3.5 s and 5.8 s in two runs. These are examples and not bounds: a stretch depends on the output in one flush, the terminal size and the load of the host. No timer runs longer than 2^31-1 ms (the longest delay that a timer runs): a longer span is a chain of timers. The watchdog starts no timer once the replay has ended, failed or stalled. Boot and finalize run outside the watchdog [webm.ts:replayWithStallWatchdog, webm.ts:REPLAY_STALL_WINDOW_MS]. - `renderGridFramesToSvg` needs the profile font size `SVG_FONT_SIZE` (14) and draws fixed 8.4x18 px cells, so equal input gives byte-identical output [svg.ts:renderGridFramesToSvg]. It always embeds the JetBrains Mono subset, and embeds each other bundled face only when a glyph needs it [svg.ts:embeddedFontAssets]. Output past `defaultMaxSvgOutputLength` of the V8 heap limit (the heap limit divided by 8 bytes, at most `MAX_SVG_OUTPUT_LENGTH`) fails with `EXPORT_ERROR` [svg.ts:defaultMaxSvgOutputLength]. ## Side Effects diff --git a/src/export/webm.ts b/src/export/webm.ts index f9f7e5cf..775d3820 100644 --- a/src/export/webm.ts +++ b/src/export/webm.ts @@ -33,13 +33,16 @@ import { invariant, unreachable } from '../util/assert.js'; // frame), one page call (the call that ends a flush also writes the chunks that // the earlier calls of the flush held, so the event log limit of 50 MiB bounds // it), and the last report to the end (the final frame hold, which also covers -// the glyph probes, and then a read of the screen). Examples that I measured at -// 600x150 on a shared host under load: the start took 1.3 to 2.0 s, the end 1.8 -// to 2.8 s, a flush of 11,000 events of 4 KB 8.4 s, and one stretch inside the -// loop 4.4 s. These are examples and not bounds. The longest, 8.4 s, is under -// 3% of the window. The window is the old fixed limit on the whole replay, so -// an export that fit before still fits. Boot and finalize run outside the -// watchdog. +// the glyph probes, and then a read of the screen). Examples measured at +// 600x150 on a shared host under load (host CPU pressure 34 to 57 %): the +// start took 1.2 to 1.9 s and the end 1.1 to 1.4 s. One flush of 520 +// full-screen repaints of 93 KB (48.6 MB) had a longest stretch of 15.9 to +// 20.7 s in three runs, 5.3 to 6.9 % of the window. One flush of 11,000 +// events of 4 KB (45 MB) had a longest stretch of 3.5 s and 5.8 s in two +// runs. These are examples and not bounds: a stretch depends on the output +// in one flush, the terminal size and the load of the host. The window is +// the old fixed limit on the whole replay, so an export that fit before +// still fits. Boot and finalize run outside the watchdog. const REPLAY_STALL_WINDOW_MS = 5 * 60 * 1000; // Node runs a timer with a longer delay after 1 ms. const MAX_TIMER_DELAY_MS = 2 ** 31 - 1; diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index f1957bb6..1f008fea 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -148,17 +148,20 @@ const MAX_TIMER_DELAY_MS = 2 ** 31 - 1; * more flush, and this limit does not count it, so a log with many resizes * makes more (at most one more for each resize). A flush costs one * animation frame, one page call and one scan of the screen, and that cost - * grows with the terminal size and the load of the host. At 80x24, 400 - * outputs 20 ms apart took 18 to 20 ms per flush at host CPU pressure 4 to - * 22 %, and 400 outputs 40 ms apart took 27 to 54 ms per flush at pressure 38 - * to 70 %. A worst-case log of 2,400 full-screen repaints 17 ms apart at - * 200x60 took 287 s (120 ms per flush) in two runs at pressure 15 to 44 %, and - * 368 s to 405 s (153 to 169 ms per flush) in three runs of a verifier at - * pressure 23 to 45 %. A WebM export has no limit on the length of its replay, - * so a loaded host makes it slower and does not fail it. The export fails only - * when the renderer makes no progress for 5 minutes (REPLAY_STALL_WINDOW_MS in - * webm.ts). The video has 25 frames per second and records the replay as it - * runs, so this many flushes of 40 ms or more make a video of 96 s or more. + * grows with the terminal size and the load of the host. The scan of a full + * screen took 1 ms at 80x24, 5 ms at 200x60 and about 90 ms at 600x150 (median + * of 25 scans, at host CPU pressure 36 to 44 %: `some avg10` in + * /proc/pressure/cpu). The cost per flush below is the replay time divided by + * the number of flushes. At 80x24, 400 full-screen repaints 20 ms apart took 18 + * to 29 ms per flush at pressure 7 to 32 % (five runs) and 35 to 72 ms at + * pressure 45 to 79 % (four runs). A worst-case log of 2,400 full-screen + * repaints 17 ms apart at 200x60 took 377 s (157 ms per flush) at pressure 21 + * to 60 % and 459 s (191 ms per flush) at pressure 41 to 56 %. A WebM export + * has no limit on the length of its replay, so a loaded host makes it slower + * and does not fail it. The export fails only when the renderer makes no + * progress for 5 minutes (REPLAY_STALL_WINDOW_MS in webm.ts). The video has 25 + * frames per second and records the replay as it runs, so this many flushes of + * 40 ms or more make a video of 96 s or more. */ const MAX_SPEED_FRAME_BUDGET = 2_400; /** diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts index 5a9be5b6..e24049aa 100644 --- a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -408,8 +408,11 @@ export const EMBEDDED_HARNESS_HTML = ` // read each cell once per row. The pool that getViewport() returns never // shrinks, so it can hold more cells than the screen: the width of the // screen sets the row stride, not the length of the pool. No update() is - // needed first: a read in the task of a write or a resize sees the new - // screen. + // needed first after a write: a read in the task of a write sees the new + // screen. After a resize it holds only because the harness resizes with + // Terminal.resize of ghostty-web, which renders at once, and the render + // calls getCursor(), which calls update(): after a direct + // wasmTerm.resize(), getViewport() reads the old screen until update(). function readViewportCells(wasmTerm, cols, rows) { const cells = wasmTerm.getViewport(); invariant(