diff --git a/docs/USAGE.md b/docs/USAGE.md index 22c9b3a9..fc1547e6 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -296,7 +296,7 @@ agent-tty record export --format webm --json | jq -c '.result.missi # [{"codePoint":"U+0378"}] ``` -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: only recorded time ends 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. It makes a frame at most once per 16 ms of recorded time, so a progress bar that updates every 30 ms shows many of its steps and not only its last one. It makes 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), and its export time depends on its length and not on its number of 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. +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. diff --git a/src/export/README.md b/src/export/README.md index 2a30546d..3423b1a2 100644 --- a/src/export/README.md +++ b/src/export/README.md @@ -26,7 +26,7 @@ This directory converts a session's event log into recording exports. `generateA - Defaults: timing mode `recorded` and profile `reference-dark` [webm.ts:generateWebmExport]. - 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 before and after a resize (see the `ghostty-web` README). Each mode holds the final frame (1000 ms, or 500 ms for `max-speed`) [webm.ts:buildReplayTimingOptions]. +- 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]. - `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]. diff --git a/src/renderer/ghosttyWeb/README.md b/src/renderer/ghosttyWeb/README.md index d3880f4c..e031d293 100644 --- a/src/renderer/ghosttyWeb/README.md +++ b/src/renderer/ghosttyWeb/README.md @@ -31,7 +31,7 @@ This directory holds the `ghostty-web` reference renderer. `GhosttyWebBackend` s - Replay cannot rewind: `targetSeq` must be >= the last applied seq. The initial dimensions cannot change after the first replay. `input.sessionId` must match the backend [backend.ts:GhosttyWebBackend.replayTo]. - `replayWithTiming` asserts strictly increasing seqs and non-decreasing timestamps [backend.ts:GhosttyWebBackend.replayWithTiming]. - `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 only recorded time ends its flushes. A flush ends at the first event that is at least one frame hold of recorded time after the batch's first output. An event that changes no pixel does not end it: input, marker, run-complete, signal and exit events leave the batch as it is, because a flush at each of them would cost a frame per event. A resize and the end of the replay still end it. `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 a `max-speed` replay makes about one flush per frame hold of recorded time for a recording shorter than 38 s and at most about 2,400 flushes for a longer one, whatever its number of events. 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 the frame hold only after the last flush of the replay and before and after a resize, so 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. Events with the same timestamp share a flush at any hold, 0 included, because a flush needs at least 1 ms of recorded time [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]. - `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]. diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index 589dc740..f1957bb6 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -143,14 +143,37 @@ const MAX_REPLAY_BATCH_SIZE = 1000; // of timers. const MAX_TIMER_DELAY_MS = 2 ** 31 - 1; /** - * A max-speed replay makes at most about this many flushes, however long the - * recording is. A flush costs one animation frame, one page call and one scan - * of the screen: 26 ms at 80x24, 33 ms at 120x40 and 112 ms at 200x60 in the - * median of five sparse-log runs (host load 40 to 100). The limit then takes - * 1 to 4.5 minutes. The video has 25 frames per second, so this many flushes - * fill 96 s of video. + * Recorded time ends at most about this many flushes of a max-speed replay, + * however long the recording is. A resize that finds output waiting ends one + * 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. */ const MAX_SPEED_FRAME_BUDGET = 2_400; +/** + * The frames per second of the video. Playwright records 25 when the context + * sets no `recordVideo.fps`, and the context here sets this value, so the + * number does not depend on that default. A state of the page that stays on it + * for less than one video frame can be missing from the video. + */ +const VIDEO_FPS = 25; +const VIDEO_FRAME_MS = 1000 / VIDEO_FPS; +// waitForDelay takes whole milliseconds, and the wait before a resize uses this. +invariant( + Number.isInteger(VIDEO_FRAME_MS), + 'VIDEO_FPS must divide 1000 so that a video frame is a whole number of ms', +); const RAF_TIMEOUT_MS = 5_000; let servedAssetsPromise: Promise< @@ -726,8 +749,10 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { // Output shows once the next event is this far after the batch's first // output. It is at least 1 ms: events with one timestamp share a flush. // Max-speed has no schedule that bounds its replay time, so it also - // spreads a fixed number of flushes over the recorded span: a sparse - // recording then costs the same number of frames as a dense one. + // spreads a fixed number of its flushes over the recorded span: a sparse + // recording then costs about the same number of frames as a dense one. A + // resize that finds output waiting ends one more flush, and the budget + // does not count it. const minSplitMs = Math.max( 1, resolvedMinFrameHoldMs, @@ -745,8 +770,22 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { let pendingSinceTimestampMs: number | null = null; let scheduleStartedAtMs = 0; let scheduledElapsedMs = 0; - - const flushOutputBatch = async (holdFrame = true): Promise => { + // Max-speed has no pauses, so a screen that stays on the page for less + // than one video frame before a resize can be missing from the video. A + // frame hold of 16 ms is shorter than that, and the recorder misses the + // screen in some runs. Recorded and accelerated replays keep their pauses + // and their hold. + const preResizeHoldMs = + timing.mode === 'max-speed' + ? Math.max(resolvedMinFrameHoldMs, VIDEO_FRAME_MS) + : resolvedMinFrameHoldMs; + // True after a flush that no hold followed. The next resize pays that hold. + let lastFlushUnheld = false; + + const flushOutputBatch = async ( + holdFrame = true, + holdMs = resolvedMinFrameHoldMs, + ): Promise => { if (pendingOutputChunks.length === 0) { return; } @@ -763,7 +802,14 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { pendingOutputChunks = []; pendingSinceTimestampMs = null; if (holdFrame) { - await waitForDelay(resolvedMinFrameHoldMs); + await waitForDelay(holdMs); + } + lastFlushUnheld = !holdFrame; + }; + const holdAfterUnheldFlush = async (): Promise => { + if (lastFlushUnheld) { + lastFlushUnheld = false; + await waitForDelay(preResizeHoldMs); } }; @@ -817,7 +863,8 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { // The frame wait of such a flush is already its frame, so max-speed // holds only after a flush that ends the replay or comes before a // resize (a resize is followed by a hold too): a hold after every - // flush would export 30 ms steps slower than they ran. + // flush would export 30 ms steps slower than they ran. The hold + // before a resize lasts one video frame, see preResizeHoldMs. if ( pendingSinceTimestampMs !== null && eventTimestampMs - pendingSinceTimestampMs >= minSplitMs && @@ -852,7 +899,10 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { break; } case 'resize': { - await flushOutputBatch(); + // The flush here holds before the resize. A flush that the timestamp + // check made earlier has no hold, and the resize pays it now, once. + await flushOutputBatch(true, preResizeHoldMs); + await holdAfterUnheldFlush(); assertPositiveInteger( event.payload.cols, 'resize event cols must be a positive integer', @@ -1359,6 +1409,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { recordVideo: { dir: this.videoOptions.outputDir, size: this.videoOptions.size, + fps: VIDEO_FPS, }, } : {}), diff --git a/test/unit/renderer/ghosttyWebBackend.test.ts b/test/unit/renderer/ghosttyWebBackend.test.ts index 4f0e66a6..339b06b7 100644 --- a/test/unit/renderer/ghosttyWebBackend.test.ts +++ b/test/unit/renderer/ghosttyWebBackend.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { chromium, type Browser } from 'playwright'; import type { ReplayProgress, @@ -1072,6 +1073,43 @@ describe('GhosttyWebBackend unit guards', () => { } }, 45_000); + // The max-speed hold before a resize lasts one video frame, which is 40 ms + // only at 25 frames per second. The context sets that rate, so a change of + // the Playwright default does not move the hold. + it('records the video at 25 frames per second', async () => { + const outputDir = await mkdtemp(join(tmpdir(), 'agent-tty-video-fps-')); + const size = { width: 760, height: 536 }; + const newContext = vi.fn(() => + Promise.reject(new Error('stop at the context options')), + ); + const launch = vi.spyOn(chromium, 'launch').mockResolvedValue({ + newContext, + on: vi.fn(), + close: vi.fn(() => Promise.resolve()), + } as unknown as Browser); + const backend = new GhosttyWebBackend('renderer-unit-session', PROFILE, { + outputDir, + size, + }); + + try { + await expect(backend.boot()).rejects.toThrow( + 'stop at the context options', + ); + + expect(newContext).toHaveBeenCalledTimes(1); + expect(newContext.mock.calls[0]).toEqual([ + expect.objectContaining({ + recordVideo: { dir: outputDir, size, fps: 25 }, + }), + ]); + } finally { + launch.mockRestore(); + await backend.dispose(); + await rm(outputDir, { recursive: true, force: true }); + } + }); + it('times out screenshot paint waits after 5 seconds', async () => { vi.useFakeTimers(); const backend = createBackend(); @@ -1916,9 +1954,10 @@ describe('GhosttyWebBackend replayWithTiming output pacing', () => { ]); }); - // The page paints a flush on its next animation frame. The hold gives that - // paint time to reach the video before the resize changes the canvas. - it('holds one frame hold between the last flush and a resize', async () => { + // The page paints a flush on its next animation frame, and the video has 25 + // frames per second. The hold gives that paint one video frame (40 ms) to + // reach the video before the resize changes the canvas. + it('holds one video frame between the last flush and a resize', async () => { const timeline = await replayAndRecordTimeline( [ outputEvent(0, 0), @@ -1938,9 +1977,149 @@ describe('GhosttyWebBackend replayWithTiming output pacing', () => { ]); const atMs = (step: string): number | undefined => timeline.find((entry) => entry.step === step)?.atMs; - expect((atMs('resize 100') ?? 0) - (atMs('flush out0') ?? 0)).toBe(16); + expect((atMs('resize 100') ?? 0) - (atMs('flush out0') ?? 0)).toBe(40); + }); + + // The timestamp check ends a max-speed flush without a hold. When that flush + // comes before a resize, the resize finds nothing pending, so the hold of the + // resize case is the only one left. Without it the resize changes the canvas + // before the video has recorded the screen before the resize. + it.each([ + { + label: 'the resize itself ends the flush', + events: [ + outputEvent(0, 0), + resizeEvent(1, 20), + outputEvent(2, 40), + exitEvent(3, 42), + ], + }, + { + label: 'an input before the resize ends the flush', + events: [ + outputEvent(0, 0), + inputEvent(1, 20), + resizeEvent(2, 25), + outputEvent(3, 40), + exitEvent(4, 42), + ], + }, + ])( + 'holds one video frame between the last flush and a resize when $label', + async ({ events }) => { + const timeline = await replayAndRecordTimeline(events, { + mode: 'max-speed', + minFrameHoldMs: 16, + finalFrameHoldMs: 0, + }); + + expect(timeline.map((entry) => entry.step)).toEqual([ + 'flush out0', + 'resize 100', + 'note', + 'flush out' + String(events.length - 2), + 'snapshot', + ]); + const atMs = (step: string): number => + timeline.find((entry) => entry.step === step)?.atMs ?? Number.NaN; + expect(atMs('resize 100') - atMs('flush out0')).toBe(40); + }, + ); + + it('holds that frame once, for the first of two resizes that follow the flush', async () => { + const timeline = await replayAndRecordTimeline( + [ + outputEvent(0, 0), + resizeEvent(1, 20), + resizeEvent(2, 21), + exitEvent(3, 22), + ], + { mode: 'max-speed', minFrameHoldMs: 16, finalFrameHoldMs: 0 }, + ); + + const resizes = timeline.filter((entry) => entry.step === 'resize 100'); + expect(resizes).toHaveLength(2); + // The hold after the first resize is the only time between the two. + expect((resizes[1]?.atMs ?? 0) - (resizes[0]?.atMs ?? 0)).toBe(16); + expect((resizes[0]?.atMs ?? 0) - (timeline[0]?.atMs ?? 0)).toBe(40); }); + // A timed flush has no hold, then output arrives and waits for the resize. + // The resize case flushes that output, and the one hold that follows is all + // the screen before the resize needs. A second hold would show that screen + // for two video frames instead of one. + it('holds one video frame, not two, when a timed flush is followed by pending output and a resize', async () => { + const timeline = await replayAndRecordTimeline( + [ + outputEvent(0, 0), + outputEvent(1, 100), + resizeEvent(2, 101), + exitEvent(3, 102), + ], + { mode: 'max-speed', minFrameHoldMs: 16, finalFrameHoldMs: 0 }, + ); + + expect(timeline.map((entry) => entry.step)).toEqual([ + 'flush out0', + 'flush out1', + 'resize 100', + 'note', + 'snapshot', + ]); + const atMs = (step: string): number => + timeline.find((entry) => entry.step === step)?.atMs ?? Number.NaN; + // The timed flush has no hold, so the next flush follows at once. + expect(atMs('flush out1') - atMs('flush out0')).toBe(0); + // One hold sits between the last flush and the resize. + expect(atMs('resize 100') - atMs('flush out1')).toBe(40); + }); + + // A frame hold longer than a video frame already gives the screen before the + // resize more than one video frame. + it('holds a max-speed frame hold that is longer than a video frame before a resize', async () => { + const timeline = await replayAndRecordTimeline( + [outputEvent(0, 0), resizeEvent(1, 5), exitEvent(2, 6)], + { mode: 'max-speed', minFrameHoldMs: 100, finalFrameHoldMs: 0 }, + ); + + const atMs = (step: string): number => + timeline.find((entry) => entry.step === step)?.atMs ?? Number.NaN; + expect(atMs('resize 100') - atMs('flush out0')).toBe(100); + }); + + // Recorded and accelerated replays keep their pauses, so the screen before a + // resize is on the page for as long as the recording shows it. Their frame + // hold before a resize does not change. + it.each([ + [ + 'recorded', + { mode: 'recorded', finalFrameHoldMs: 0 } satisfies ReplayTimingOptions, + 16, + ], + [ + 'accelerated', + { + mode: 'accelerated', + maxGapMs: 400, + minFrameHoldMs: 100, + finalFrameHoldMs: 0, + } satisfies ReplayTimingOptions, + 100, + ], + ])( + 'holds the frame hold of %s mode before a resize', + async (_name, timing, holdMs) => { + const timeline = await replayAndRecordTimeline( + [outputEvent(0, 0), resizeEvent(1, 5), exitEvent(2, 6)], + timing, + ); + + const atMs = (step: string): number => + timeline.find((entry) => entry.step === step)?.atMs ?? Number.NaN; + expect(atMs('resize 100') - atMs('flush out0')).toBe(holdMs); + }, + ); + it.each([ ['recorded', RECORDED], ['max-speed', MAX_SPEED],