diff --git a/src/renderer/ghosttyWeb/README.md b/src/renderer/ghosttyWeb/README.md index e031d293..59e75dbd 100644 --- a/src/renderer/ghosttyWeb/README.md +++ b/src/renderer/ghosttyWeb/README.md @@ -33,6 +33,7 @@ This directory holds the `ghostty-web` reference renderer. `GhosttyWebBackend` s - `replayWithTiming` calls the optional `onProgress` with a `ReplayProgress` in four places. Before each wait on the schedule, `scheduledWaitMs` is the length of that wait. A wait longer than a timer runs (2^31-1 ms) is a chain of timers, so a recorded gap of 30 days takes 30 days, and a report that carries the rest of the wait follows each timer except the last. After each bridge call of a flush, and after the resize call of a resize event, `scheduledWaitMs` is 0. After the last event and its flush, and before the final calls to the renderer, `scheduledWaitMs` is 0 too, so a stall in those calls names the last event, also when that event changed no pixel. `seq` is the last event that the replay had reached. `generateWebmExport` uses these reports to tell a stall from a wait [backend.ts:GhosttyWebBackend.replayWithTiming]. - `replayWithTiming` waits until each event's place on a schedule built from the recorded gaps: `recorded` keeps them, `accelerated` caps each at `maxGapMs`, and `max-speed` sets them to 0. It measures that schedule with `performance.now()`, so frame holds and bridge calls use up idle time instead of adding to it. In `recorded` and `accelerated` mode it flushes pending output once the next event is at least one frame hold after the batch's first output, unless the replay runs behind the schedule, in which case the batch grows until it catches up. `max-speed` has no schedule to fall behind, so recorded time ends most of its flushes. In `max-speed` mode, a flush ends at the first event that is at least one frame hold of recorded time after the batch's first output. Also in `max-speed` mode, an event that changes no pixel (input, marker, run-complete, signal or exit) ends a flush only through that rule, because a flush at each of them would cost a frame per event. In `recorded` and `accelerated` mode such an event ends a flush directly (see below). A resize that finds output waiting and the end of the replay end a flush in every mode. `max-speed` also spreads at most `MAX_SPEED_FRAME_BUDGET` (2,400) flushes over the recorded span of the events that it applies: the distance between the first output of a flush and the event that ends it is at least the larger of the frame hold and the span divided by 2,400. So in `max-speed` mode, recorded time makes about one flush per frame hold for a recording shorter than 38 s and at most about 2,400 flushes for a longer one, whatever its number of output events. A resize that finds output waiting ends a flush too. These flushes do not count against the 2,400, so each resize adds at most one flush, and none when no output is waiting: 100,000 outputs that each have a resize 1 ms after them make 100,000 flushes. In `max-speed` mode, output that arrives less than that distance after the first output of a flush shares the flush, also when an input or a marker sits between, so a state that the next output of the flush replaces is never painted. A flush waits for one animation frame. `max-speed` holds only after the last flush of the replay and around a resize. After the last flush and after a resize it holds the frame hold. Before a resize it holds one video frame (40 ms, from `VIDEO_FPS` = 25), or the frame hold when that is longer, because a screen that stays on the page for less than one video frame can be missing from the video. Two flushes that follow each other can paint within one video frame. In `recorded` and `accelerated` mode, every event that changes no pixel ends a flush, and a hold follows every flush. Output events with the same timestamp share a flush at any hold, 0 included, because the time check needs at least 1 ms of recorded time. An event between them that ends a flush splits them anyway: a resize in every mode, and in `recorded` and `accelerated` mode also an input, a marker, a run-complete, a signal or an exit [backend.ts:GhosttyWebBackend.replayWithTiming]. - `writeBatch(chunks, endsFlush, paintedCap)` takes the chunks of one bridge call. A flush that has more than 1000 chunks needs several calls, and ghostty-web paints on every animation frame, so the page holds the chunks of a call that does not end the flush. The call that ends it (`endsFlush`, set by the backend on the last call only) writes every held chunk in one task, so no frame paints a state between two calls, and then resolves after one frame. ghostty-web calls a write callback one animation frame (about 17 ms) after the write, and waiting on every chunk (`write`) cost one frame per output event. `write()` and `resize()` throw while chunks are held. `discardHeldChunks()` drops them: the backend calls it when a flush fails, because `HostRendererManager` keeps the backend and a retry would otherwise meet them. A `paintedCap` that is not null makes the call that ends the flush add the graphemes on the screen to the set that the missing-glyph check of `replayWithTiming` probes, so a flush is one page call. The call returns the message of a failed note and not an error, because a failed note must not fail the flush. In `replayWithTiming`, the chunks of one flush share one video frame [embeddedHarnessHtml.ts:EMBEDDED_HARNESS_HTML]. +- The harness reads the visible screen once per decode, with `wasmTerm.getViewport()`: `getSnapshot`, `getVisibleText` and `getCellGraphemes` each take the text, width, colors and styles of a cell from that one read. In ghostty-web 0.4.0, `activeBuffer.getLine(row)` of a visible row re-reads the whole viewport out of the wasm module, so a loop over the rows would read each cell once per row. The scrollback decode still calls `getLine(row)` per row, because that call reads one scrollback row only [embeddedHarnessHtml.ts:EMBEDDED_HARNESS_HTML]. `test/helpers/perRowDecode.ts` holds the per-row decode that `renderer-backend.test.ts` compares the harness with. - `snapshot()` and `screenshot()` throw until `replayTo()` or `replayWithTiming()` has run once and set the initial size. Both replays accept `targetSeq` -1 for an empty Event Log. A replay over zero events sets `lastAppliedSeq` to `targetSeq` [backend.ts:GhosttyWebBackend.snapshot]. - `missingGlyphs` lists a code point only when a bundled face that lacks it drew it. Chromium draws a cluster with one font: the first stack font that covers all of it, or else the first font of the stack, which is bundled in both built-in profiles and then draws `.notdef` boxes. CDP names the face that drew a probe by its PostScript name, so a family alias or several faces under one family resolve to the right cmap. When one bundled face drew the whole grapheme, every code point that face lacks is listed. A probe with no reported font is judged against every face of the first family. When the grapheme is split over several fonts, each uncovered code point is judged by its own probe, and a mark's probe on its base is compared with a probe of the base alone. Glyphs that a host font draws are not listed. The check runs after the PNG is written, removes its probe spans, and caches each probe text's result for the page, clearing the cache when it would pass `MAX_CACHED_GLYPH_PROBES` (16384) entries. It probes at most `MAX_GLYPH_PROBES` texts and lists at most `MAX_MISSING_GLYPH_CELLS` cells (4096 each). `probeUsedFonts` sends its CDP queries in batches of 256 and stops starting batches after `GLYPH_PROBE_BUDGET_MS` (1000 ms). The result sets `missingGlyphsTruncated` when any of these stops the check early. Family names match without regard to case. A web font whose PostScript name the profile does not bundle fails the check. A failed check is logged at debug level and leaves the field out, so the screenshot still succeeds. `replayWithTiming()` records the viewport graphemes in the page after each output batch and each resize (at most `MAX_CACHED_GLYPH_PROBES`), and the final frame hold judges them once, with the hold as the probe budget, so the check does not lengthen the video. `recordedMissingGlyphs()` returns that result. A failed note or check leaves the record export fields out. A profile whose font stack does not start with a bundled font gets no `missingGlyphs` field [missingGlyphs.ts:probeUsedFonts] [backend.ts:GhosttyWebBackend.screenshot]. - `screenshot()` requires an absolute `outputPath` whose parent directory exists. It checks that the output is a non-empty PNG with a valid signature and an `IHDR` first chunk [backend.ts:GhosttyWebBackend.screenshot]. diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts index 666368d2..fc48ca64 100644 --- a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -71,7 +71,7 @@ export const EMBEDDED_HARNESS_HTML = `