diff --git a/aube-lock.yaml b/aube-lock.yaml index e16de45c..8be2bd9c 100644 --- a/aube-lock.yaml +++ b/aube-lock.yaml @@ -5,8 +5,11 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 esbuild: 0.28.1 + js-yaml: 4.3.1 + nanoid: 3.3.18 + postcss: 8.5.18 vite: 8.0.16 ws: 8.21.0 @@ -101,7 +104,7 @@ time: balanced-match@4.0.4: 2026-02-22T11:38:25.951Z before-after-hook@2.2.3: 2022-10-04T00:19:26.570Z boolbase@1.0.0: 2014-02-15T14:44:50.620Z - brace-expansion@5.0.6: 2026-05-08T05:41:43.205Z + brace-expansion@5.0.9: 2026-07-30T10:00:32.762Z camelcase-keys@6.2.2: 2020-04-03T03:51:03.816Z camelcase@5.3.1: 2019-04-03T13:34:32.701Z chai@6.2.2: 2025-12-22T21:26:03.989Z @@ -181,7 +184,7 @@ time: is-obj@2.0.0: 2019-04-19T15:37:37.870Z is-plain-obj@1.1.0: 2015-11-05T09:31:58.189Z js-tokens@4.0.0: 2018-01-28T11:58:58.170Z - js-yaml@4.2.0: 2026-05-31T22:17:13.783Z + js-yaml@4.3.1: 2026-07-31T17:39:51.183Z jsep@1.4.0: 2024-11-05T14:49:55.640Z json-parse-even-better-errors@2.3.1: 2020-09-02T16:37:58.371Z json-stringify-safe@5.0.1: 2015-05-19T01:42:09.719Z @@ -212,7 +215,7 @@ time: minipass@7.1.3: 2026-02-19T00:34:33.886Z modify-values@1.0.1: 2018-03-23T07:35:47.388Z ms@2.1.3: 2020-12-08T13:54:35.223Z - nanoid@3.3.12: 2026-04-30T22:04:14.515Z + nanoid@3.3.18: 2026-08-07T16:41:05.696Z neo-async@2.6.2: 2020-07-09T18:23:53.065Z node-addon-api@7.1.1: 2024-07-12T10:15:07.595Z node-addon-api@8.7.0: 2026-03-26T01:10:33.995Z @@ -243,7 +246,7 @@ time: picomatch@4.0.4: 2026-03-23T20:39:47.960Z playwright-core@1.60.0: 2026-05-11T19:09:40.047Z playwright@1.60.0: 2026-05-11T19:09:33.114Z - postcss@8.5.15: 2026-05-19T09:51:29.843Z + postcss@8.5.18: 2026-07-12T20:38:40.936Z quick-lru@4.0.1: 2019-05-29T17:21:30.565Z react-reconciler@0.33.0: 2025-10-01T21:39:00.081Z react@19.2.7: 2026-06-01T18:00:48.323Z @@ -895,9 +898,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} camelcase-keys@6.2.2: resolution: {integrity: sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==} @@ -1220,8 +1223,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsep@1.4.0: @@ -1374,8 +1377,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1495,8 +1498,8 @@ packages: engines: {node: '>=18'} hasBin: true - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} quick-lru@4.0.1: @@ -1933,7 +1936,7 @@ snapshots: dependencies: '@octokit/rest': 20.1.2(@octokit/core@5.2.2) '@octokit/types': 13.10.0 - js-yaml: 4.2.0 + js-yaml: 4.3.1 minimatch: 10.2.5 '@iarna/toml@3.0.0': {} @@ -2174,7 +2177,7 @@ snapshots: boolbase@1.0.0: {} - brace-expansion@5.0.6: + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -2468,7 +2471,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.2.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -2554,7 +2557,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.9 minimist-options@4.1.0: dependencies: @@ -2570,7 +2573,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.12: {} + nanoid@3.3.18: {} neo-async@2.6.2: {} @@ -2695,9 +2698,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - postcss@8.5.15: + postcss@8.5.18: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2749,7 +2752,7 @@ snapshots: figures: 3.2.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.2.0 + js-yaml: 4.3.1 jsonpath-plus: 10.4.0(jsep@1.4.0) node-html-parser: 6.1.13 parse-github-repo-url: 1.4.1 @@ -2954,7 +2957,7 @@ snapshots: esbuild: 0.28.1 lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.15 + postcss: 8.5.18 rolldown: 1.0.3 tinyglobby: 0.2.17(picomatch@4.0.4) tsx: 4.21.0 diff --git a/docs/USAGE.md b/docs/USAGE.md index 429fb145..596b1993 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -60,6 +60,7 @@ agent-tty --home snapshot --format text --json agent-tty --home screenshot --json agent-tty --home record export --format asciicast --json agent-tty --home record export --format webm --json +agent-tty --home record export --format svg --json ``` ## `run` @@ -198,10 +199,18 @@ agent-tty screenshot --profile reference-dark --json agent-tty screenshot --show-cursor --json agent-tty record export --format asciicast --out ./session.cast --json agent-tty record export --format webm --timing accelerated --out ./session.webm --json +agent-tty record export --format svg --json +agent-tty record export --format svg --animate --out ./session.svg --json ``` 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. +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; add `--animate` for an animated SVG of de-duplicated frames replayed with recorded event-log timing (`--timing` is not supported with SVG). + +Animated SVG capture retains every distinct frame's styled grid in memory, so it is bounded by a total-cell budget (20M cells, roughly 10,000 distinct 80x24 frames, adapting to terminal size); recordings that exceed it fail with a clear error suggesting a still SVG or WebM export instead. + +SVG exports always embed the pinned JetBrains Mono latin subset and additionally embed the Symbols Nerd Font Mono face when the rendered content needs it. Glyphs outside both faces (notably CJK and most emoji) render via the viewer's monospace fallback, mirroring the reference renderer's own system-font fallback for the same glyphs. Text content and layout metrics stay deterministic even when fallback glyph shapes vary: every text run is pinned to the terminal grid via `textLength`, so columns never shift. + Use `--renderer ghostty-web`, `AGENT_TTY_RENDERER=ghostty-web`, or Home `config.json` `{ "defaultRenderer": "ghostty-web" }` to force legacy all-browser rendering. Use `--renderer libghostty-vt` only when you intentionally want semantic and screenshot requests routed through the native backend; WebM requests still record `ghostty-web` as the actual video producer. `ghostty-web` provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals. diff --git a/mise.lock b/mise.lock index 13dfd9dd..2a746205 100644 --- a/mise.lock +++ b/mise.lock @@ -985,39 +985,33 @@ backend = "github:endevco/aube" [tools.aube."platforms.linux-arm64"] checksum = "sha256:45dc6d46e69f1da58360fa8ac353d83c2b84e1ca2e31edb9be912a7729c839c1" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417134849" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417134849" [tools.aube."platforms.linux-arm64-musl"] checksum = "sha256:8094e14b0906ecb2fcdb573700e7c9536abdeedc203a15051723b4fe6247237e" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417112128" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417112128" [tools.aube."platforms.linux-x64"] checksum = "sha256:234b5d01ab5818937740ebc773709595e18eb926f2c543273e36670c5c416851" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-gnu.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417115596" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-gnu.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417115596" [tools.aube."platforms.linux-x64-musl"] checksum = "sha256:c6bc24fa4a06f13cc8fa7fdb18bd15c55ec17b2f9ebce478c559b9664bc5abde" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417115736" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417115736" [tools.aube."platforms.macos-arm64"] checksum = "sha256:963d276cef7f039a5750bf18d30af7c2457f6d5804fa24bd784458e22a4d1a14" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417151945" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417151945" [tools.aube."platforms.windows-x64"] checksum = "sha256:631566d6802cbae88c35a54d8746ac36f56d563a0a680b6c5d882db8b9d495d5" -url = "https://github.com/endevco/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-pc-windows-msvc.zip" -url_api = "https://api.github.com/repos/endevco/aube/releases/assets/417123778" -provenance = "github-attestations" +url = "https://github.com/jdx/aube/releases/download/v1.10.4/aube-v1.10.4-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/jdx/aube/releases/assets/417123778" [[tools.communique]] version = "1.1.3" diff --git a/package.json b/package.json index b802f0ab..8841fcf9 100644 --- a/package.json +++ b/package.json @@ -100,8 +100,11 @@ } }, "overrides": { - "brace-expansion": "5.0.6", + "brace-expansion": "5.0.9", "esbuild": "0.28.1", + "js-yaml": "4.3.1", + "nanoid": "3.3.18", + "postcss": "8.5.18", "vite": "8.0.16", "ws": "8.21.0" } diff --git a/src/cli/commands/record-export.ts b/src/cli/commands/record-export.ts index afa0d19a..877e3878 100644 --- a/src/cli/commands/record-export.ts +++ b/src/cli/commands/record-export.ts @@ -10,10 +10,12 @@ import type { CommandContext } from '../context.js'; import { emitSuccess } from '../output.js'; import { generateAsciicast } from '../../export/asciicast.js'; +import { renderGridFramesToSvg } from '../../export/svg.js'; import { generateWebmExport, type WebmExportResult, } from '../../export/webm.js'; +import { captureGridFrames } from '../../replay/gridFrames.js'; import { readEventLogRecords } from '../../storage/eventLogCodec.js'; import { hashProfile, resolveProfile } from '../../renderer/profiles.js'; import { CliError } from '../errors.js'; @@ -48,7 +50,7 @@ import { import { invariant } from '../../util/assert.js'; import { loadPackageMetadata } from '../../util/packageMetadata.js'; -const RecordExportFormatSchema = z.enum(['asciicast', 'webm']); +const RecordExportFormatSchema = z.enum(['asciicast', 'webm', 'svg']); type RecordExportFormat = z.infer; @@ -62,6 +64,7 @@ interface CommandOptions { out?: string; profile?: string; timing?: string; + animate?: boolean; } function resolveRecordExportFormat( @@ -71,7 +74,7 @@ function resolveRecordExportFormat( if (!formatResult.success) { throw makeCliError(ERROR_CODES.INVALID_INPUT, { - message: 'Record export format must be one of: asciicast, webm.', + message: 'Record export format must be one of: asciicast, webm, svg.', details: { format, }, @@ -105,7 +108,7 @@ function resolveReplayTimingMode( return timingResult.data; } -function resolveWebmProfileName( +function resolveRenderProfileName( commandProfile: string | undefined, contextProfileDefault: string | undefined, ): string | undefined { @@ -137,12 +140,13 @@ async function resolveOutputPath( capturedAtSeq: number, format: RecordExportFormat, outputPath: string | undefined, + filenameVariant?: string, ): Promise { if (outputPath === undefined) { await ensureArtifactsDir(sessionDirectory); return artifactPath( sessionDirectory, - recordingFilename(capturedAtSeq, format), + recordingFilename(capturedAtSeq, format, filenameVariant), ); } @@ -201,6 +205,29 @@ export async function runRecordExportCommand( options: CommandOptions, ): Promise { const format = resolveRecordExportFormat(options.format); + + if (options.animate === true && format !== 'svg') { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: '--animate is only supported with --format svg.', + details: { + format, + }, + }); + } + + // Animated SVG always replays with recorded event-log timing; there is no + // timing mode to choose. + if (format === 'svg' && options.timing !== undefined) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: + '--timing is not supported with --format svg; animated SVG always uses recorded timing.', + details: { + format, + timing: options.timing, + }, + }); + } + const timingMode = resolveReplayTimingMode(options.timing); const home = options.context.home; let sessionDirectory: string; @@ -234,11 +261,28 @@ export async function runRecordExportCommand( const eventsFile = eventLogPath(sessionDirectory); const events = await readEventLogRecords(eventsFile); const defaultCapturedAtSeq = resolveCapturedAtSeq(events); + // SVG exports at the same seq produce different content per render + // profile and animation mode, so those must be part of the default + // filename to keep exports from overwriting each other. + const svgProfileName = + format === 'svg' + ? (resolveRenderProfileName( + options.profile, + options.context.profileDefault, + ) ?? 'reference-dark') + : undefined; + const filenameVariant = + svgProfileName === undefined + ? undefined + : options.animate === true + ? `${svgProfileName}-animated` + : svgProfileName; const artifactOutputPath = await resolveOutputPath( sessionDirectory, defaultCapturedAtSeq, format, options.out, + filenameVariant, ); invariant( @@ -305,8 +349,78 @@ export async function runRecordExportCommand( bytes = contentsBuffer.byteLength; invariant(bytes > 0, 'asciicast export artifact must not be empty'); sha256 = createHash('sha256').update(contentsBuffer).digest('hex'); + } else if (format === 'svg') { + invariant( + svgProfileName !== undefined, + 'svg profile name must be resolved before the svg export branch', + ); + const resolvedProfile = resolveProfile(svgProfileName); + const renderProfileHash = hashProfile(resolvedProfile); + const animate = options.animate === true; + + // An empty event log (running-but-silent session) is valid: it exports + // the manifest-defined blank initial grid. + const capture = await captureGridFrames({ + sessionId: options.sessionId, + manifest, + events, + profile: resolvedProfile, + mode: animate ? 'timeline' : 'final', + }); + const svgContents = renderGridFramesToSvg({ + profile: resolvedProfile, + frames: capture.frames, + animate, + }); + const contentsBuffer = Buffer.from(svgContents, 'utf8'); + + capturedAtSeq = capture.capturedAtSeq; + durationMs = animate ? capture.timelineDurationMs : 0; + artifactKind = 'recording'; + artifactMetadata = { + format, + outputPath: artifactOutputPath, + width: capture.cols, + height: capture.rows, + profileName: svgProfileName, + renderProfileHash, + rendererBackend: capture.rendererBackend, + animated: animate, + frameCount: capture.frames.length, + outputEventCount: capture.outputEventCount, + resizeEventCount: capture.resizeEventCount, + }; + resultMetadata = { + width: capture.cols, + height: capture.rows, + profileName: svgProfileName, + renderProfileHash, + rendererBackend: capture.rendererBackend, + animated: animate, + frameCount: capture.frames.length, + outputEventCount: capture.outputEventCount, + resizeEventCount: capture.resizeEventCount, + }; + + if (options.out === undefined) { + invariant( + capturedAtSeq === defaultCapturedAtSeq, + 'default svg artifact path seq must match exported seq', + ); + } + + await writeTextFileAtomic({ + path: artifactOutputPath, + pathLabel: 'record export path', + contents: svgContents, + writeErrorMessage: `Failed to write record export artifact at ${artifactOutputPath}.`, + }); + + bytes = contentsBuffer.byteLength; + invariant(bytes > 0, 'svg export artifact must not be empty'); + sha256 = createHash('sha256').update(contentsBuffer).digest('hex'); } else { - const webmProfileName = resolveWebmProfileName( + const webmProfileName = resolveRenderProfileName( options.profile, options.context.profileDefault, ); diff --git a/src/cli/main.ts b/src/cli/main.ts index 665dc828..af2bf182 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -803,13 +803,21 @@ async function main(): Promise { recordCommand .command('export ') .description('Export a recorded session artifact') - .requiredOption('--format ', "Export format: 'asciicast' or 'webm'") + .requiredOption( + '--format ', + "Export format: 'asciicast', 'webm', or 'svg'", + ) .option('--out ', 'Explicit output path') .option('--profile ', 'Render profile name') .option( '--timing ', 'Replay timing mode for WebM: recorded (default), accelerated, max-speed', ) + .option( + '--animate', + 'Animate the SVG export with recorded timing (only with --format svg)', + false, + ) .option('--json', 'Emit a JSON command envelope', false) .action( wrapAction( @@ -821,6 +829,7 @@ async function main(): Promise { out?: string; profile?: string; timing?: string; + animate: boolean; json: boolean; }, context: CommandContext, @@ -830,6 +839,7 @@ async function main(): Promise { json: options.json, sessionId, format: options.format, + animate: options.animate, ...(options.out !== undefined ? { out: options.out } : {}), ...(options.profile !== undefined ? { profile: options.profile } diff --git a/src/export/svg.ts b/src/export/svg.ts new file mode 100644 index 00000000..d729b2b4 --- /dev/null +++ b/src/export/svg.ts @@ -0,0 +1,680 @@ +import type { SnapshotCell } from '../protocol/schemas.js'; +import type { RenderProfileConfig } from '../renderer/types.js'; +import { + BUNDLED_PRIMARY_FONT_ASSET, + BUNDLED_SYMBOLS_FONT_ASSET, + type BundledFontAsset, +} from '../renderer/bundledFont.js'; +import { invariant } from '../util/assert.js'; + +/** + * Fixed reference cell metrics for SVG export. The builtin render profiles pin + * fontSize 14; the cell box is 8.4x18 px (a 0.6 advance-width ratio and a + * ~1.29 line height, matching common monospace metrics). Text runs are pinned + * to the grid with `textLength`, so viewers with different fonts still align + * glyphs to these columns. Constants (not measured fonts) keep the output + * deterministic. + */ +export const SVG_FONT_SIZE = 14; +export const SVG_CELL_WIDTH = 8.4; +export const SVG_CELL_HEIGHT = 18; +/** Text baseline offset from the top of a cell row. */ +export const SVG_BASELINE_OFFSET = 14; +/** Block cursor is drawn as a translucent overlay so the glyph stays legible. */ +export const SVG_CURSOR_FILL_OPACITY = 0.35; + +export interface SvgGridFrame { + cols: number; + rows: number; + cursorRow: number; + cursorCol: number; + /** + * Whether the cursor block should be drawn (DECTCEM). Defaults to visible. + * The libghostty-vt native snapshot does not currently expose cursor + * visibility, so captured frames leave this unset; the renderer honors it + * when a producer can supply it. + */ + cursorVisible?: boolean; + /** + * Dense visible grid: `lines[row]` lists cells by column. Rows and trailing + * columns without content may be shorter than `rows`/`cols`. + */ + lines: ReadonlyArray>; + /** How long this frame stays visible on the animated timeline. */ + holdMs: number; +} + +export interface SvgRenderOptions { + profile: RenderProfileConfig; + frames: readonly SvgGridFrame[]; + animate: boolean; +} + +type GlyphFace = 'latin' | 'symbols' | 'system'; + +interface StyleRun { + startCol: number; + cellCount: number; + text: string; + fg: string | undefined; + bg: string | undefined; + bold: boolean; + italic: boolean; + underline: boolean; + face: GlyphFace; +} + +/** + * Both bundled fonts are licensed under the SIL Open Font License 1.1 (see + * src/renderer/ghosttyWeb/assets/FONT-LICENSE.txt); embedded SVGs carry this + * attribution as an XML comment. + */ +const FONT_LICENSE_COMMENT = + ''; + +/** + * Exact cmap coverage of the pinned JetBrainsMono-Regular-latin.woff2 asset + * (dumped offline from the checked-in file's format-4 cmap: 229 code points). + * The asset is hash-pinned via bundledFont.ts, so these ranges are stable. + * + * Explicit boundary: glyphs outside BOTH bundled faces — notably CJK and + * most emoji — render via the viewer's monospace fallback. This is kept + * deliberately: the pinned render profiles bundle no CJK face (a suitable + * one is tens of MB), and converting glyphs to paths would require a + * font-shaping dependency. The reference ghostty-web PNG/WebM path has the + * same boundary (same two faces, then Chromium/system-font fallback). Layout + * stays deterministic regardless: text runs are pinned to the terminal grid + * via textLength, so fallback glyph shapes vary but columns never shift. + */ +const PRIMARY_LATIN_SUBSET_RANGES: ReadonlyArray = [ + [0x0d, 0x0d], + [0x20, 0x7e], + [0xa0, 0xff], + [0x102, 0x102], + [0x131, 0x131], + [0x152, 0x153], + [0x2bc, 0x2bc], + [0x2c6, 0x2c6], + [0x2da, 0x2da], + [0x2dc, 0x2dc], + [0x300, 0x301], + [0x303, 0x304], + [0x308, 0x309], + [0x323, 0x323], + [0x2013, 0x2014], + [0x2018, 0x201a], + [0x201c, 0x201e], + [0x2022, 0x2022], + [0x2026, 0x2026], + [0x2032, 0x2033], + [0x2039, 0x203a], + [0x2044, 0x2044], + [0x20ac, 0x20ac], + [0x2122, 0x2122], + [0x2191, 0x2191], + [0x2193, 0x2193], + [0x2212, 0x2212], + [0x2215, 0x2215], + [0xfeff, 0xfeff], +]; + +function isCoveredByPrimaryLatinSubset(codePoint: number): boolean { + for (const [start, end] of PRIMARY_LATIN_SUBSET_RANGES) { + if (codePoint >= start && codePoint <= end) { + return true; + } + } + return false; +} + +/** + * Exact cmap coverage of the pinned SymbolsNerdFontMono-Regular.ttf asset + * (dumped offline from the checked-in file's format-12 cmap: 10,410 code + * points in 28 contiguous ranges). The asset is hash-pinned via + * bundledFont.ts, so these ranges are stable. To regenerate: parse the TTF + * table directory for the `cmap` table, pick the format-12 subtable, and + * collapse its sequential map groups into [start, end] pairs. + */ +const SYMBOLS_FACE_RANGES: ReadonlyArray = [ + [0x23fb, 0x23fe], + [0x2630, 0x2630], + [0x2665, 0x2665], + [0x26a1, 0x26a1], + [0x276c, 0x2771], + [0x2b58, 0x2b58], + [0xe000, 0xe00a], + [0xe0a0, 0xe0a3], + [0xe0b0, 0xe0c8], + [0xe0ca, 0xe0ca], + [0xe0cc, 0xe0d2], + [0xe0d4, 0xe0d4], + [0xe0d6, 0xe0d7], + [0xe200, 0xe2a9], + [0xe300, 0xe3e3], + [0xe5fa, 0xe6b8], + [0xe700, 0xe8ef], + [0xea60, 0xea88], + [0xea8a, 0xea8c], + [0xea8f, 0xeac7], + [0xeac9, 0xeac9], + [0xeacc, 0xeb09], + [0xeb0b, 0xeb4e], + [0xeb50, 0xec1e], + [0xed00, 0xefce], + [0xf000, 0xf381], + [0xf400, 0xf533], + [0xf0001, 0xf1af0], +]; + +function isCoveredBySymbolsFace(codePoint: number): boolean { + for (const [start, end] of SYMBOLS_FACE_RANGES) { + if (codePoint >= start && codePoint <= end) { + return true; + } + } + return false; +} + +/** + * Classify which face actually renders a glyph: the primary latin subset, + * the embedded symbols face, or the viewer's system monospace fallback + * (glyphs covered by neither bundled face, e.g. CJK and most emoji). The + * three resolve to different real fonts with different natural advances, so + * run grouping must not mix them. A cluster containing any system-fallback + * code point classifies as 'system' since the fallback font shapes it. + */ +function classifyGlyphFace(char: string): GlyphFace { + let sawSymbolsGlyph = false; + for (const character of char) { + const codePoint = character.codePointAt(0); + invariant(codePoint !== undefined, 'iterated character must exist'); + if (isCoveredByPrimaryLatinSubset(codePoint)) { + continue; + } + if (isCoveredBySymbolsFace(codePoint)) { + sawSymbolsGlyph = true; + continue; + } + return 'system'; + } + return sawSymbolsGlyph ? 'symbols' : 'latin'; +} + +/** + * The symbols face is embedded only when a rendered glyph actually resolves + * to it; 'system' glyphs (covered by neither bundled face) gain nothing from + * embedding, so they no longer trigger the 2.5 MB payload. + */ +function framesContainSymbolsGlyph(frames: readonly SvgGridFrame[]): boolean { + for (const frame of frames) { + for (const cells of frame.lines) { + for (const cell of cells) { + if (classifyGlyphFace(cell.char) === 'symbols') { + return true; + } + } + } + } + return false; +} + +function renderFontFace(asset: BundledFontAsset, format: string): string { + invariant(asset.buffer.byteLength > 0, 'bundled font asset must have bytes'); + const dataUri = `data:${asset.contentType};base64,${asset.buffer.toString('base64')}`; + return `@font-face{font-family:"${asset.family}";src:url(${dataUri}) format("${format}");font-weight:${asset.weight};font-style:${asset.style};}`; +} + +/** + * Standalone SVGs must carry their fonts so glyphs render identically without + * locally installed fonts. The 21 KB JetBrains Mono latin subset is always + * embedded; the 2.5 MB Symbols Nerd Font is embedded only when a rendered + * frame contains a glyph the symbols face actually covers, keeping ordinary + * exports small. Both are base64 of checked-in asset bytes, so output stays + * deterministic. + */ +function renderFontStyleElement(frames: readonly SvgGridFrame[]): string { + const fontFaces = [renderFontFace(BUNDLED_PRIMARY_FONT_ASSET, 'woff2')]; + if (framesContainSymbolsGlyph(frames)) { + fontFaces.push(renderFontFace(BUNDLED_SYMBOLS_FONT_ASSET, 'truetype')); + } + return ``; +} + +const XML_ESCAPES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', +}; + +// XML 1.0 forbids raw C0 control characters (except tab/newline/CR, which +// never appear inside a terminal grid cell) and DEL; strip them defensively. +function stripXmlDisallowed(value: string): string { + let stripped = ''; + for (const character of value) { + const codePoint = character.codePointAt(0); + invariant(codePoint !== undefined, 'iterated character must exist'); + if (codePoint < 0x20 || codePoint === 0x7f) { + continue; + } + stripped += character; + } + return stripped; +} + +function escapeXml(value: string): string { + return stripXmlDisallowed( + value.replace(/[&<>"']/gu, (match) => { + const escaped = XML_ESCAPES[match]; + invariant(escaped !== undefined, 'xml escape table must cover match'); + return escaped; + }), + ); +} + +function formatSvgNumber(value: number): string { + invariant(Number.isFinite(value), 'svg number must be finite'); + const fixed = value.toFixed(2); + const trimmed = fixed.replace(/0+$/u, '').replace(/\.$/u, ''); + return trimmed === '-0' ? '0' : trimmed; +} + +/** + * Serialize a keyTime with enough precision that a 1 ms hold stays distinct: + * 12 decimals resolve millisecond boundaries for timelines up to 10^12 ms + * (~31 years), far beyond any recordable session. Trailing zeros are trimmed + * so output stays canonical and deterministic. + */ +function formatKeyTime(offsetMs: number, totalMs: number): string { + invariant(totalMs > 0, 'animation total duration must be positive'); + invariant( + offsetMs >= 0 && offsetMs <= totalMs, + 'animation key time offset must lie within the total duration', + ); + const fixed = (offsetMs / totalMs).toFixed(12); + const trimmed = fixed.replace(/0+$/u, '').replace(/\.$/u, ''); + return trimmed.length > 0 ? trimmed : '0'; +} + +function styleMatches(run: StyleRun, cell: SnapshotCell): boolean { + return ( + run.fg === cell.fg && + run.bg === cell.bg && + run.bold === (cell.bold ?? false) && + run.italic === (cell.italic ?? false) && + run.underline === (cell.underline ?? false) + ); +} + +/** + * Group one row's cells into consecutive same-style TEXT runs of HOMOGENEOUS + * cell width. Width-1 glyphs with matching style merge. A wide glyph (leading + * cell's `width` >= 2) always forms its own single-glyph run whose cell count + * spans its trailing spacer cells (`char: ''`), regardless of the spacers' + * styling. Mixed-width runs are deliberately split: `textLength` scaling with + * `lengthAdjust="spacingAndGlyphs"` distributes advances uniformly, so a + * fallback font whose wide-glyph advance ratio differs would shift interior + * glyphs off their columns — within a homogeneous-width run a monospace + * fallback has equal natural advances and every glyph lands exactly on its + * column. Runs also split at FONT-FACE boundaries (see classifyGlyphFace): + * different faces have different natural advances, so a mixed-face run would + * drift interior glyphs exactly like a mixed-width one. Guarantee: every run + * is homogeneous in (style, width, face), and system-fallback glyphs are + * ALWAYS singleton runs — their face identity is viewer-dependent (adjacent + * scripts can resolve to different system fonts), so each one is pinned + * individually via its own x + textLength. Latin and symbols runs keep + * merging because each is a single known monospace face with uniform + * advances. Every OTHER empty cell — zero-style + * gap padding for untouched columns (e.g. ESC[NC) and styled empties such as + * colored field padding — breaks the current run so the next glyph + * re-anchors at its true column: merging an empty cell would stretch the + * run's glyphs across its column. Backgrounds are grouped independently (see + * groupRowIntoBackgroundRuns), so a styled empty cell still shades its + * column. + */ +export function groupRowIntoStyleRuns( + cells: readonly SnapshotCell[], +): StyleRun[] { + const runs: StyleRun[] = []; + let current: StyleRun | null = null; + let pendingSpacers = 0; + + for (const [col, cell] of cells.entries()) { + if (pendingSpacers > 0) { + // Column covered by the preceding wide glyph: extend that glyph's run + // whatever the spacer's styling looks like. + pendingSpacers -= 1; + invariant( + cell.char === '', + 'wide-glyph trailing cell must be an empty spacer', + ); + invariant(current !== null, 'wide glyph run must exist for its spacers'); + current.cellCount += 1; + if (pendingSpacers === 0) { + // The wide glyph's single-glyph run is complete; the next glyph must + // start its own run. + current = null; + } + continue; + } + if (cell.char === '') { + current = null; + continue; + } + const width = cell.width ?? 1; + invariant( + Number.isInteger(width) && width >= 1, + 'snapshot cell width must be a positive integer when provided', + ); + const face = classifyGlyphFace(cell.char); + if ( + width === 1 && + face !== 'system' && + current !== null && + current.face === face && + styleMatches(current, cell) + ) { + current.cellCount += 1; + current.text += cell.char; + } else { + current = { + startCol: col, + cellCount: 1, + text: cell.char, + fg: cell.fg, + bg: cell.bg, + bold: cell.bold ?? false, + italic: cell.italic ?? false, + underline: cell.underline ?? false, + face, + }; + runs.push(current); + } + pendingSpacers = width - 1; + if (face === 'system' && pendingSpacers === 0) { + // System-fallback glyphs are singleton runs: close immediately so the + // next glyph starts fresh (wide system glyphs are closed by the spacer + // branch once their trailing columns are consumed). + current = null; + } + } + + let previousEndCol = 0; + for (const run of runs) { + invariant( + run.startCol >= previousEndCol, + 'style runs must cover strictly advancing, non-overlapping columns', + ); + previousEndCol = run.startCol + run.cellCount; + } + invariant( + previousEndCol <= cells.length, + 'style runs must not extend past the row cells', + ); + + return runs; +} + +interface BackgroundRun { + startCol: number; + cellCount: number; + bg: string; +} + +/** + * Group one row's cells into consecutive same-background runs, independent of + * text-run grouping: styled empty cells (colored field padding, wide-glyph + * spacers) shade their columns even though they never join a text run, and + * adjacent cells sharing a background merge into one rect regardless of any + * other styling differences. + */ +export function groupRowIntoBackgroundRuns( + cells: readonly SnapshotCell[], +): BackgroundRun[] { + const runs: BackgroundRun[] = []; + let current: BackgroundRun | null = null; + + for (const [col, cell] of cells.entries()) { + if (cell.bg === undefined) { + current = null; + continue; + } + if (current !== null && current.bg === cell.bg) { + current.cellCount += 1; + continue; + } + current = { startCol: col, cellCount: 1, bg: cell.bg }; + runs.push(current); + } + + return runs; +} + +function renderRunBackground(run: BackgroundRun, row: number): string { + const x = formatSvgNumber(run.startCol * SVG_CELL_WIDTH); + const y = formatSvgNumber(row * SVG_CELL_HEIGHT); + const width = formatSvgNumber(run.cellCount * SVG_CELL_WIDTH); + const height = formatSvgNumber(SVG_CELL_HEIGHT); + return ``; +} + +function renderRunText(run: StyleRun, row: number): string | null { + const hasVisibleGlyphs = run.text.trim().length > 0; + const hasUnderlinedSpan = run.underline && run.text.length > 0; + if (!hasVisibleGlyphs && !hasUnderlinedSpan) { + return null; + } + + const attributes = [ + `x="${formatSvgNumber(run.startCol * SVG_CELL_WIDTH)}"`, + `y="${formatSvgNumber(row * SVG_CELL_HEIGHT + SVG_BASELINE_OFFSET)}"`, + `textLength="${formatSvgNumber(run.cellCount * SVG_CELL_WIDTH)}"`, + 'lengthAdjust="spacingAndGlyphs"', + 'xml:space="preserve"', + ]; + if (run.fg !== undefined) { + attributes.push(`fill="${escapeXml(run.fg)}"`); + } + if (run.bold) { + attributes.push('font-weight="bold"'); + } + if (run.italic) { + attributes.push('font-style="italic"'); + } + if (run.underline) { + attributes.push('text-decoration="underline"'); + } + + return `${escapeXml(run.text)}`; +} + +function renderCursor( + frame: SvgGridFrame, + profile: RenderProfileConfig, +): string { + invariant( + frame.cursorRow >= 0 && frame.cursorRow < frame.rows, + 'frame cursorRow must be within rows', + ); + invariant( + frame.cursorCol >= 0 && frame.cursorCol < frame.cols, + 'frame cursorCol must be within cols', + ); + const x = formatSvgNumber(frame.cursorCol * SVG_CELL_WIDTH); + const y = formatSvgNumber(frame.cursorRow * SVG_CELL_HEIGHT); + const width = formatSvgNumber(SVG_CELL_WIDTH); + const height = formatSvgNumber(SVG_CELL_HEIGHT); + const opacity = formatSvgNumber(SVG_CURSOR_FILL_OPACITY); + return ``; +} + +function renderFrameElements( + frame: SvgGridFrame, + profile: RenderProfileConfig, +): string[] { + invariant(frame.cols > 0, 'frame cols must be positive'); + invariant(frame.rows > 0, 'frame rows must be positive'); + invariant( + frame.lines.length <= frame.rows, + 'frame lines must fit within frame rows', + ); + + const backgrounds: string[] = []; + const texts: string[] = []; + for (const [row, cells] of frame.lines.entries()) { + invariant( + cells.length <= frame.cols, + 'frame row cells must fit within frame cols', + ); + for (const backgroundRun of groupRowIntoBackgroundRuns(cells)) { + backgrounds.push(renderRunBackground(backgroundRun, row)); + } + for (const run of groupRowIntoStyleRuns(cells)) { + const text = renderRunText(run, row); + if (text !== null) { + texts.push(text); + } + } + } + + // Honor DECTCEM when the frame producer supplies visibility; unset means + // visible (the native snapshot does not expose cursor visibility yet). + const cursor = + frame.cursorVisible === false ? [] : [renderCursor(frame, profile)]; + return [...backgrounds, ...texts, ...cursor]; +} + +function renderVisibilityAnimation( + frameIndex: number, + frameCount: number, + startMs: number, + endMs: number, + totalMs: number, +): string { + invariant( + frameIndex >= 0 && frameIndex < frameCount, + 'frame index must be within the frame count', + ); + invariant(startMs < endMs, 'frame window must be non-empty'); + + // A positive hold must survive serialization: rounded keyTimes that + // collapse to the same value would give the frame a zero-length interval + // and silently drop a recorded transition. + const startKeyTime = formatKeyTime(startMs, totalMs); + const endKeyTime = formatKeyTime(endMs, totalMs); + invariant( + Number(startKeyTime) < Number(endKeyTime), + 'serialized frame keyTimes must be strictly increasing for positive holds', + ); + + let values: string; + let keyTimes: string; + if (frameIndex === 0) { + values = 'visible;hidden'; + invariant( + Number(endKeyTime) > 0, + 'first frame end keyTime must be positive', + ); + keyTimes = `0;${endKeyTime}`; + } else if (frameIndex === frameCount - 1) { + values = 'hidden;visible'; + invariant( + Number(startKeyTime) > 0, + 'last frame start keyTime must be positive', + ); + keyTimes = `0;${startKeyTime}`; + } else { + values = 'hidden;visible;hidden'; + invariant( + Number(startKeyTime) > 0, + 'interior frame start keyTime must be positive', + ); + keyTimes = `0;${startKeyTime};${endKeyTime}`; + } + + return ``; +} + +/** + * Render de-duplicated grid frames as deterministic SVG text. Still exports + * (`animate: false`) render only the final frame; animated exports emit one + * `` per frame toggled by SMIL discrete visibility animations whose key + * times derive purely from the frames' recorded `holdMs` values. The output + * contains no wall-clock timestamps, random identifiers, or other + * environment-dependent content, so repeated exports of the same session are + * byte-identical. + */ +export function renderGridFramesToSvg(options: SvgRenderOptions): string { + const { profile, frames, animate } = options; + invariant(frames.length > 0, 'svg rendering requires at least one frame'); + invariant( + profile.fontSize === SVG_FONT_SIZE, + 'svg cell metrics are pinned to the reference profile font size', + ); + for (const frame of frames) { + invariant( + Number.isInteger(frame.holdMs) && frame.holdMs >= 0, + 'frame holdMs must be a non-negative integer', + ); + } + + // Loop instead of Math.max(...spread): argument-spreading a large frame + // array (long recordings) exceeds V8's argument limit and throws. + let canvasCols = 0; + let canvasRows = 0; + for (const frame of frames) { + canvasCols = Math.max(canvasCols, frame.cols); + canvasRows = Math.max(canvasRows, frame.rows); + } + invariant( + canvasCols > 0 && canvasRows > 0, + 'svg canvas dimensions must be positive', + ); + const width = formatSvgNumber(canvasCols * SVG_CELL_WIDTH); + const height = formatSvgNumber(canvasRows * SVG_CELL_HEIGHT); + + const lines: string[] = [ + ``, + FONT_LICENSE_COMMENT, + renderFontStyleElement(frames), + ``, + ]; + + // Element lists are appended one-by-one rather than push(...spread): a + // dense frame can produce enough elements to exceed V8's argument limit. + if (!animate || frames.length === 1) { + const finalFrame = frames.at(-1); + invariant(finalFrame !== undefined, 'final frame must exist'); + for (const element of renderFrameElements(finalFrame, profile)) { + lines.push(element); + } + } else { + const totalMs = frames.reduce((sum, frame) => sum + frame.holdMs, 0); + invariant(totalMs > 0, 'animated svg requires a positive total duration'); + + let offsetMs = 0; + for (const [frameIndex, frame] of frames.entries()) { + invariant(frame.holdMs > 0, 'animated svg frames must hold for >0 ms'); + const endMs = offsetMs + frame.holdMs; + lines.push( + '', + renderVisibilityAnimation( + frameIndex, + frames.length, + offsetMs, + endMs, + totalMs, + ), + ); + for (const element of renderFrameElements(frame, profile)) { + lines.push(element); + } + lines.push(''); + offsetMs = endMs; + } + } + + lines.push(''); + return `${lines.join('\n')}\n`; +} diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index 70dca889..f2e2b904 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -307,6 +307,12 @@ export const SnapshotCellSchema = z italic: z.boolean().optional(), underline: z.boolean().optional(), strikethrough: z.boolean().optional(), + /** + * Terminal cell span of this glyph. Only present on the leading cell of a + * wide glyph (width >= 2); the trailing column(s) follow as `char: ''` + * spacer cells without a width. + */ + width: z.number().int().min(2).optional(), }) .strict(); export type SnapshotCell = z.infer; @@ -463,7 +469,7 @@ export const WaitForRenderResultSchema = z export const RecordExportResultSchema = z .object({ sessionId: NonEmptyStringSchema, - format: z.enum(['asciicast', 'webm']), + format: z.enum(['asciicast', 'webm', 'svg']), artifactPath: NonEmptyStringSchema, bytes: PositiveIntSchema, sha256: NonEmptyStringSchema, diff --git a/src/renderer/libghosttyVt/backend.ts b/src/renderer/libghosttyVt/backend.ts index a33b62d1..bd5ac95a 100644 --- a/src/renderer/libghosttyVt/backend.ts +++ b/src/renderer/libghosttyVt/backend.ts @@ -293,6 +293,9 @@ function toStyledCell(cell: NativeSnapshotCell): SnapshotCell { ...(cell.bold === undefined ? {} : { bold: cell.bold }), ...(cell.italic === undefined ? {} : { italic: cell.italic }), ...(cell.underline === undefined ? {} : { underline: cell.underline }), + // Preserve the native wide-glyph span on the leading cell so consumers + // can distinguish trailing spacers from untouched-column gap padding. + ...(cell.width > 1 ? { width: cell.width } : {}), }; } @@ -336,9 +339,11 @@ function mapNativeCells( cells.push(styled); // A wide glyph covers its trailing column(s): emit an empty spacer // carrying the glyph's styling so the trailing half shades correctly - // and the array index stays aligned with the terminal column. + // and the array index stays aligned with the terminal column. The + // spacer carries no `width` of its own — only the leading cell does. for (let span = 1; span < cell.width; span += 1) { - cells.push({ ...styled, char: '' }); + const { width: _leadingWidth, ...spacerStyle } = styled; + cells.push({ ...spacerStyle, char: '' }); } } return { lineNumber, cells }; diff --git a/src/replay/gridFrames.ts b/src/replay/gridFrames.ts new file mode 100644 index 00000000..a920e581 --- /dev/null +++ b/src/replay/gridFrames.ts @@ -0,0 +1,457 @@ +import { buildReplayInput } from '../host/replay.js'; +import type { + EventRecord, + SessionRecord, + SnapshotCell, +} from '../protocol/schemas.js'; +import { ERROR_CODES, makeCliError } from '../protocol/errors.js'; +import type { RendererBackend } from '../renderer/backend.js'; +import { createRendererBackend } from '../renderer/registry.js'; +import type { + RenderProfileConfig, + ReplayInput, + SemanticSnapshot, +} from '../renderer/types.js'; +import { invariant } from '../util/assert.js'; + +/** Matches the recorded-timing WebM export's final frame hold. */ +const DEFAULT_FINAL_FRAME_HOLD_MS = 1_000; + +/** + * Upper bound on the total styled cells retained across all captured frames + * (cols x rows summed per distinct frame). Animated capture keeps every + * distinct frame's dense grid in memory until rendering, so an unbounded + * recording — the 50 MB event-log limit permits far more distinct frames + * than fit on a reasonable heap — must fail fast instead of exhausting + * memory. 20M cells is ~10k distinct 80x24 frames and adapts to terminal + * size; exceeding it raises EXPORT_ERROR rather than silently dropping + * frames, because exported artifacts are evidence. + */ +export const MAX_ANIMATED_GRID_CELLS = 20_000_000; + +export type GridFrameMode = 'final' | 'timeline'; + +export interface GridFrame { + capturedAtSeq: number; + cols: number; + rows: number; + cursorRow: number; + cursorCol: number; + /** Dense visible grid: `lines[row]` lists styled cells by column. */ + lines: SnapshotCell[][]; + holdMs: number; +} + +export interface CaptureGridFramesOptions { + sessionId: string; + manifest: SessionRecord; + events: readonly EventRecord[]; + profile: RenderProfileConfig; + /** `final` captures only the last frame; `timeline` captures every boundary. */ + mode: GridFrameMode; + finalFrameHoldMs?: number; + /** Retained-cell budget override; defaults to MAX_ANIMATED_GRID_CELLS. */ + maxGridCells?: number; +} + +export interface GridFrameCapture { + frames: GridFrame[]; + capturedAtSeq: number; + /** + * Canvas dimensions: per-frame maxima of cols/rows across all captured + * frames, matching the viewBox the SVG renderer sizes from. For a + * recording that resizes down, these exceed the final frame's grid. + */ + cols: number; + rows: number; + rendererBackend: string; + outputEventCount: number; + resizeEventCount: number; + /** + * Sum of frame holds; in timeline mode this is the full recorded event-log + * span (first event ts to last event ts, any event type) plus the final + * viewing hold. + */ + timelineDurationMs: number; +} + +export interface GridFrameDeps { + backendFactory?: ( + sessionId: string, + profile: RenderProfileConfig, + ) => RendererBackend | Promise; +} + +interface FrameBoundary { + seq: number; + tsMs: number; +} + +function parseTimestamp(value: string, label: string): number { + const timestamp = Date.parse(value); + invariant( + Number.isFinite(timestamp), + `${label} must be a valid ISO timestamp`, + ); + return timestamp; +} + +/** + * Collect the seq boundaries where the visible grid may have changed: only + * `output` and `resize` events mutate the screen, and events sharing one + * timestamp are coalesced into the last seq of that instant so the frame count + * stays bounded by the number of distinct recorded instants. + */ +function collectFrameBoundaries(input: ReplayInput): { + boundaries: FrameBoundary[]; + /** Index into `input.events` of the first visual event, or null if none. */ + firstVisualEventIndex: number | null; + outputEventCount: number; + resizeEventCount: number; +} { + const boundaries: FrameBoundary[] = []; + let firstVisualEventIndex: number | null = null; + let outputEventCount = 0; + let resizeEventCount = 0; + + for (const [eventIndex, event] of input.events.entries()) { + if (event.seq > input.targetSeq) { + break; + } + if (event.type !== 'output' && event.type !== 'resize') { + continue; + } + firstVisualEventIndex ??= eventIndex; + if (event.type === 'output') { + outputEventCount += 1; + } else { + resizeEventCount += 1; + } + + const tsMs = parseTimestamp( + event.ts, + `event ${String(event.seq)} timestamp`, + ); + const lastBoundary = boundaries.at(-1); + if (lastBoundary !== undefined) { + invariant( + tsMs >= lastBoundary.tsMs, + 'event timestamps must be non-decreasing', + ); + if (tsMs === lastBoundary.tsMs) { + lastBoundary.seq = event.seq; + continue; + } + } + boundaries.push({ seq: event.seq, tsMs }); + } + + return { + boundaries, + firstVisualEventIndex, + outputEventCount, + resizeEventCount, + }; +} + +// Captured frames omit cursorVisible because the native snapshot exposes no +// DECTCEM visibility field; the renderer's hidden-cursor branch is exercised +// only by direct frame producers today. +function toGridFrame(snapshot: SemanticSnapshot, holdMs: number): GridFrame { + invariant( + snapshot.cells !== undefined, + 'grid frame snapshot must include styled cells', + ); + + const lines: SnapshotCell[][] = Array.from( + { length: snapshot.rows }, + () => [], + ); + for (const line of snapshot.cells) { + invariant( + line.lineNumber < snapshot.rows, + 'snapshot cell line must be within rows', + ); + lines[line.lineNumber] = line.cells; + } + + return { + capturedAtSeq: snapshot.capturedAtSeq, + cols: snapshot.cols, + rows: snapshot.rows, + cursorRow: snapshot.cursorRow, + cursorCol: snapshot.cursorCol, + lines, + holdMs, + }; +} + +/** Canonical frame content key (ignores seq and hold) used for de-duplication. */ +function frameContentKey(frame: GridFrame): string { + return JSON.stringify([ + frame.cols, + frame.rows, + frame.cursorRow, + frame.cursorCol, + frame.lines.map((cells) => + cells.map((cell) => [ + cell.char, + cell.fg ?? null, + cell.bg ?? null, + cell.bold ?? false, + cell.italic ?? false, + cell.underline ?? false, + cell.width ?? 1, + ]), + ), + ]); +} + +async function createLibghosttyVtBackend( + sessionId: string, + profile: RenderProfileConfig, + deps?: GridFrameDeps, +): Promise { + const backendFactory = + deps?.backendFactory ?? + ((factorySessionId: string, factoryProfile: RenderProfileConfig) => + createRendererBackend('libghostty-vt', factorySessionId, factoryProfile)); + return await backendFactory(sessionId, profile); +} + +/** + * Replay a session's event log offline through the libghostty-vt backend and + * capture de-duplicated styled grid frames with recorded hold durations. This + * intentionally never falls back to the browser renderer: when the optional + * native package is unavailable the export fails with a clear error instead. + */ +export async function captureGridFrames( + options: CaptureGridFramesOptions, + deps?: GridFrameDeps, +): Promise { + invariant(options.sessionId.length > 0, 'sessionId is required'); + const finalFrameHoldMs = + options.finalFrameHoldMs ?? DEFAULT_FINAL_FRAME_HOLD_MS; + invariant( + Number.isInteger(finalFrameHoldMs) && finalFrameHoldMs > 0, + 'finalFrameHoldMs must be a positive integer', + ); + const maxGridCells = options.maxGridCells ?? MAX_ANIMATED_GRID_CELLS; + invariant( + Number.isInteger(maxGridCells) && maxGridCells > 0, + 'maxGridCells must be a positive integer', + ); + + const replayInput = buildReplayInput( + options.sessionId, + options.manifest, + options.events, + ); + + // A running-but-silent session has a valid empty event log. The manifest's + // initial dimensions define its reproducible blank grid, so synthesize a + // single blank frame directly — renderer backends reject snapshot() before + // the first replayed event, so no backend is booted. capturedAtSeq follows + // the record-export empty-log convention (0, same as asciicast). + if (replayInput.targetSeq < 0) { + const blankFrame: GridFrame = { + capturedAtSeq: 0, + cols: replayInput.initialCols, + rows: replayInput.initialRows, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: finalFrameHoldMs, + }; + return { + frames: [blankFrame], + capturedAtSeq: 0, + cols: blankFrame.cols, + rows: blankFrame.rows, + rendererBackend: 'none', + outputEventCount: 0, + resizeEventCount: 0, + timelineDurationMs: finalFrameHoldMs, + }; + } + + const { + boundaries, + firstVisualEventIndex, + outputEventCount, + resizeEventCount, + } = collectFrameBoundaries(replayInput); + + // Timing model: the animated timeline spans the FULL event log — from the + // first event's timestamp to the last event's timestamp, whatever the event + // types — plus the final viewing hold. A leading gap before the first + // visual event (e.g. an input_run followed by silence) is represented by a + // pre-visual frame (the still-blank grid) held for that gap; a trailing gap + // after the last visual event extends the final frame's hold to the last + // event's timestamp. Both derive purely from recorded timestamps, so output + // stays deterministic. + const firstEvent = replayInput.events[0]; + const lastEvent = replayInput.events.at(-1); + invariant( + firstEvent !== undefined && lastEvent !== undefined, + 'grid capture requires >=1 event', + ); + const timelineStartMs = parseTimestamp(firstEvent.ts, 'events[0].ts'); + const timelineEndMs = parseTimestamp(lastEvent.ts, 'events[last].ts'); + invariant( + timelineEndMs >= timelineStartMs, + 'last event timestamp must not precede the first event timestamp', + ); + + let captureBoundaries: FrameBoundary[]; + if (options.mode === 'timeline' && boundaries.length > 0) { + captureBoundaries = boundaries; + const firstBoundary = boundaries[0]; + invariant(firstBoundary !== undefined, 'first boundary must exist'); + if ( + firstBoundary.tsMs > timelineStartMs && + firstVisualEventIndex !== null && + firstVisualEventIndex > 0 + ) { + const preVisualEvent = replayInput.events[firstVisualEventIndex - 1]; + invariant( + preVisualEvent !== undefined, + 'pre-visual event must exist before the first visual event', + ); + captureBoundaries = [ + { seq: preVisualEvent.seq, tsMs: timelineStartMs }, + ...boundaries, + ]; + } + } else { + // Sessions without visual events still have a (blank) grid; capture it at + // the final target seq. In timeline mode the blank frame spans the whole + // recorded range via the trailing-gap extension below; in final mode only + // the final viewing hold applies. + captureBoundaries = [ + { + seq: replayInput.targetSeq, + tsMs: options.mode === 'timeline' ? timelineStartMs : timelineEndMs, + }, + ]; + } + + const backend = await createLibghosttyVtBackend( + options.sessionId, + options.profile, + deps, + ); + + try { + try { + await backend.boot(); + } catch (error) { + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { + message: + 'SVG export requires the libghostty-vt renderer. Install the optional @coder/libghostty-vt-node package and retry.', + details: { rendererBackend: 'libghostty-vt' }, + cause: error, + }); + } + + const frames: GridFrame[] = []; + let retainedGridCells = 0; + let previousContentKey: string | null = null; + // Event cursor: each boundary replay feeds only the not-yet-applied event + // suffix. Passing the full validated array per boundary would rescan from + // index 0 every time (O(boundaries x events)). The backend skips events at + // or below its last applied seq, so a contiguous suffix slice of the + // already-validated events is equivalent. + let nextEventIndex = 0; + const sliceEventsThrough = (targetSeq: number) => { + const startIndex = nextEventIndex; + while (nextEventIndex < replayInput.events.length) { + const event = replayInput.events[nextEventIndex]; + invariant(event !== undefined, 'event cursor index must be in range'); + if (event.seq > targetSeq) { + break; + } + nextEventIndex += 1; + } + return replayInput.events.slice(startIndex, nextEventIndex); + }; + + for (const [index, boundary] of captureBoundaries.entries()) { + const nextBoundary = captureBoundaries[index + 1]; + // The final frame holds for the trailing recorded gap (time between the + // last visual boundary and the last event of any type) plus the final + // viewing hold. + const holdMs = + nextBoundary === undefined + ? timelineEndMs - boundary.tsMs + finalFrameHoldMs + : nextBoundary.tsMs - boundary.tsMs; + invariant(holdMs > 0, 'frame hold duration must be positive'); + + await backend.replayTo({ + ...replayInput, + events: sliceEventsThrough(boundary.seq), + targetSeq: boundary.seq, + }); + const snapshot = await backend.snapshot({ includeCells: true }); + const frame = toGridFrame(snapshot, holdMs); + const contentKey = frameContentKey(frame); + if (contentKey === previousContentKey) { + const previousFrame = frames.at(-1); + invariant(previousFrame !== undefined, 'previous frame must exist'); + previousFrame.holdMs += holdMs; + continue; + } + retainedGridCells += frame.cols * frame.rows; + if (retainedGridCells > maxGridCells) { + throw makeCliError(ERROR_CODES.EXPORT_ERROR, { + message: + 'Recording has too many distinct frames for animated SVG export. Use a still SVG export (drop --animate) or a WebM export instead.', + details: { + maxGridCells, + capturedFrames: frames.length, + }, + }); + } + frames.push(frame); + previousContentKey = contentKey; + } + + // Apply any trailing non-visual events so capturedAtSeq covers the log. + if (replayInput.targetSeq > (captureBoundaries.at(-1)?.seq ?? -1)) { + await backend.replayTo({ + ...replayInput, + events: sliceEventsThrough(replayInput.targetSeq), + targetSeq: replayInput.targetSeq, + }); + } + + const finalFrame = frames.at(-1); + invariant(finalFrame !== undefined, 'grid capture must produce >=1 frame'); + + // Report the same per-frame maxima the SVG renderer sizes its canvas + // from, so CLI JSON / manifest width+height match the rendered viewBox. + let maxCols = 0; + let maxRows = 0; + for (const frame of frames) { + maxCols = Math.max(maxCols, frame.cols); + maxRows = Math.max(maxRows, frame.rows); + } + invariant( + maxCols > 0 && maxRows > 0, + 'capture dimensions must be positive', + ); + + return { + frames, + capturedAtSeq: replayInput.targetSeq, + cols: maxCols, + rows: maxRows, + rendererBackend: backend.rendererBackend, + outputEventCount, + resizeEventCount, + timelineDurationMs: frames.reduce((sum, frame) => sum + frame.holdMs, 0), + }; + } finally { + await backend.dispose(); + } +} diff --git a/src/storage/artifactPaths.ts b/src/storage/artifactPaths.ts index ceeb80af..4d50ff56 100644 --- a/src/storage/artifactPaths.ts +++ b/src/storage/artifactPaths.ts @@ -79,17 +79,28 @@ export function snapshotFilename( function recordingExtension(format: string): string { invariant( - format === 'asciicast' || format === 'webm', + format === 'asciicast' || format === 'webm' || format === 'svg', `unsupported recording format: ${format}`, ); - return format === 'asciicast' ? 'cast' : 'webm'; + return format === 'asciicast' ? 'cast' : format; } -export function recordingFilename(seq: number, format: string): string { +export function recordingFilename( + seq: number, + format: string, + variant?: string, +): string { assertNonNegativeInteger(seq, 'seq'); const sanitizedFormat = sanitizeFilenameComponent(format, 'format'); const extension = recordingExtension(sanitizedFormat); - return `recording-${String(seq)}-${sanitizedFormat}.${extension}`; + // A variant label distinguishes default filenames for exports of the same + // format and seq that produce different content (e.g. animated vs still + // SVG), so one cannot silently overwrite the other's artifact file. + const variantSuffix = + variant === undefined + ? '' + : `-${sanitizeFilenameComponent(variant, 'variant')}`; + return `recording-${String(seq)}-${sanitizedFormat}${variantSuffix}.${extension}`; } export function videoFilename(seq: number, profileName: string): string { diff --git a/src/tools/review-bundle.ts b/src/tools/review-bundle.ts index 8c42be9f..49b902f9 100644 --- a/src/tools/review-bundle.ts +++ b/src/tools/review-bundle.ts @@ -354,7 +354,7 @@ export function classifyBundlePath(relativePath: string): ArtifactKind { if (SCRIPT_FILE_NAMES.has(fileName)) { return 'script'; } - if (lowerFileName.endsWith('.png')) { + if (lowerFileName.endsWith('.png') || lowerFileName.endsWith('.svg')) { return 'screenshot'; } if (lowerFileName.endsWith('.webm')) { diff --git a/test/integration/record-export.test.ts b/test/integration/record-export.test.ts index b8b554b3..33f6f34f 100644 --- a/test/integration/record-export.test.ts +++ b/test/integration/record-export.test.ts @@ -14,6 +14,17 @@ import { type WaitResult, } from '../helpers.js'; +// SVG export requires the optional native libghostty-vt package; skip the +// svg happy-path cases cleanly when it is unavailable. +let nativeAvailable = false; +try { + await import('@coder/libghostty-vt-node'); + nativeAvailable = true; +} catch { + nativeAvailable = false; +} +const maybeIt = nativeAvailable ? it : it.skip; + interface ErrorEnvelope { ok: false; command: string; @@ -27,7 +38,7 @@ interface ErrorEnvelope { interface RecordExportResult { sessionId: string; - format: 'asciicast' | 'webm'; + format: 'asciicast' | 'webm' | 'svg'; artifactPath: string; bytes: number; sha256: string; @@ -361,6 +372,359 @@ describe('record export integration', { timeout: 120_000 }, () => { ); }); + maybeIt( + 'exports deterministic svg artifacts with expected visible text', + async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'ready svg\\n'; exec cat", + ]); + + waitForIdle(testHome, sessionId); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'svg', '--json'], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const envelope = JSON.parse( + exportResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.command).toBe('record export'); + expect(envelope.result.sessionId).toBe(sessionId); + expect(envelope.result.format).toBe('svg'); + expect(envelope.result.artifactPath.endsWith('.svg')).toBe(true); + expect(envelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + expect(envelope.result.durationMs).toBe(0); + expect(envelope.result.metadata).toMatchObject({ + profileName: 'reference-dark', + rendererBackend: 'libghostty-vt', + animated: false, + frameCount: 1, + }); + + const contents = await readFile(envelope.result.artifactPath, 'utf8'); + expect( + contents.startsWith('; + }>(join(testHome, 'sessions', sessionId, 'artifacts', 'manifest.json')); + const svgEntries = artifactManifest.artifacts.filter((entry) => + entry.filename.endsWith('.svg'), + ); + expect(svgEntries.length).toBeGreaterThanOrEqual(1); + expect(svgEntries[0]).toEqual( + expect.objectContaining({ + kind: 'recording', + filename: basename(envelope.result.artifactPath), + sha256: envelope.result.sha256, + bytes: envelope.result.bytes, + capturedAtSeq: envelope.result.capturedAtSeq, + }), + ); + + destroySession(testHome, sessionId); + }, + ); + + maybeIt( + 'writes distinct default files for still and animated svg exports', + async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'collide check\\n'; sleep 0.3; printf 'second\\n'; exit 0", + ]); + + waitForExit(testHome, sessionId); + + const stillResult = runCli( + ['record', 'export', sessionId, '--format', 'svg', '--json'], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(stillResult.status).toBe(0); + const stillEnvelope = JSON.parse( + stillResult.stdout, + ) as SuccessEnvelope; + + const animatedResult = runCli( + [ + 'record', + 'export', + sessionId, + '--format', + 'svg', + '--animate', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(animatedResult.status).toBe(0); + const animatedEnvelope = JSON.parse( + animatedResult.stdout, + ) as SuccessEnvelope; + + // Distinct default filenames: the animated export must not overwrite + // the still artifact file. + expect(animatedEnvelope.result.artifactPath).not.toBe( + stillEnvelope.result.artifactPath, + ); + expect( + basename(animatedEnvelope.result.artifactPath).includes('animated'), + ).toBe(true); + + const stillContents = await readFile(stillEnvelope.result.artifactPath); + const animatedContents = await readFile( + animatedEnvelope.result.artifactPath, + ); + expect(createHash('sha256').update(stillContents).digest('hex')).toBe( + stillEnvelope.result.sha256, + ); + expect(createHash('sha256').update(animatedContents).digest('hex')).toBe( + animatedEnvelope.result.sha256, + ); + + const artifactManifest = await readJsonFile<{ + artifacts: Array<{ + kind: string; + filename: string; + sha256?: string; + bytes?: number; + metadata: Record; + }>; + }>(join(testHome, 'sessions', sessionId, 'artifacts', 'manifest.json')); + const svgEntries = artifactManifest.artifacts.filter((entry) => + entry.filename.endsWith('.svg'), + ); + expect(svgEntries).toHaveLength(2); + + const stillEntry = svgEntries.find( + (entry) => + entry.filename === basename(stillEnvelope.result.artifactPath), + ); + const animatedEntry = svgEntries.find( + (entry) => + entry.filename === basename(animatedEnvelope.result.artifactPath), + ); + expect(stillEntry).toMatchObject({ + sha256: stillEnvelope.result.sha256, + bytes: stillEnvelope.result.bytes, + metadata: expect.objectContaining({ animated: false }) as unknown, + }); + expect(animatedEntry).toMatchObject({ + sha256: animatedEnvelope.result.sha256, + bytes: animatedEnvelope.result.bytes, + metadata: expect.objectContaining({ animated: true }) as unknown, + }); + }, + ); + + maybeIt( + 'writes distinct default files per render profile for svg exports', + async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'profile collide check\\n'; exit 0", + ]); + + waitForExit(testHome, sessionId); + + const envelopes: Array> = []; + for (const profile of ['reference-dark', 'reference-light']) { + const result = runCli( + [ + 'record', + 'export', + sessionId, + '--format', + 'svg', + '--profile', + profile, + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(result.status).toBe(0); + envelopes.push( + JSON.parse(result.stdout) as SuccessEnvelope, + ); + } + const [darkEnvelope, lightEnvelope] = envelopes; + expect(darkEnvelope).toBeDefined(); + expect(lightEnvelope).toBeDefined(); + + // Distinct default filenames per profile: neither overwrites the other. + expect(lightEnvelope?.result.artifactPath).not.toBe( + darkEnvelope?.result.artifactPath, + ); + expect( + basename(darkEnvelope?.result.artifactPath ?? '').includes( + 'reference-dark', + ), + ).toBe(true); + expect( + basename(lightEnvelope?.result.artifactPath ?? '').includes( + 'reference-light', + ), + ).toBe(true); + + const artifactManifest = await readJsonFile<{ + artifacts: Array<{ + filename: string; + sha256?: string; + bytes?: number; + metadata: Record; + }>; + }>(join(testHome, 'sessions', sessionId, 'artifacts', 'manifest.json')); + const entriesByFilename = new Map( + artifactManifest.artifacts.map((entry) => [entry.filename, entry]), + ); + + const hashes: unknown[] = []; + for (const envelope of envelopes) { + const artifactFilePath = envelope.result.artifactPath; + const contents = await readFile(artifactFilePath); + expect(createHash('sha256').update(contents).digest('hex')).toBe( + envelope.result.sha256, + ); + const entry = entriesByFilename.get(basename(artifactFilePath)); + expect(entry).toMatchObject({ + sha256: envelope.result.sha256, + bytes: envelope.result.bytes, + metadata: expect.objectContaining({ + profileName: envelope.result.metadata.profileName, + renderProfileHash: envelope.result.metadata.renderProfileHash, + }) as unknown, + }); + hashes.push(envelope.result.metadata.renderProfileHash); + } + // Different profiles produce different render profile hashes. + expect(hashes[0]).toBeTruthy(); + expect(hashes[0]).not.toBe(hashes[1]); + }, + ); + + maybeIt('exports animated svg with recorded timing', async () => { + const sessionId = createSession(testHome, [ + '/bin/sh', + '-c', + "printf 'first\\n'; sleep 0.3; printf 'second\\n'; exit 0", + ]); + + waitForExit(testHome, sessionId); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'svg', '--animate', '--json'], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const envelope = JSON.parse( + exportResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.format).toBe('svg'); + expect(envelope.result.metadata.animated).toBe(true); + expect( + envelope.result.metadata.frameCount as number, + ).toBeGreaterThanOrEqual(2); + expect(envelope.result.durationMs ?? 0).toBeGreaterThan(0); + + const contents = await readFile(envelope.result.artifactPath, 'utf8'); + expect(contents).toContain(' { + // /bin/sleep produces no output, so the event log stays empty. The blank + // initial grid is synthesized from the manifest without booting a + // renderer backend, so this needs no native gating. + const sessionId = createSession(testHome, ['/bin/sleep', '60']); + + const exportResult = runCli( + ['record', 'export', sessionId, '--format', 'svg', '--json'], + { AGENT_TTY_HOME: testHome }, + 30_000, + ); + expect(exportResult.status).toBe(0); + expect(exportResult.stderr).toBe(''); + + const envelope = JSON.parse( + exportResult.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.format).toBe('svg'); + expect(envelope.result.capturedAtSeq).toBe(0); + expect(envelope.result.metadata).toMatchObject({ + frameCount: 1, + animated: false, + outputEventCount: 0, + resizeEventCount: 0, + }); + + const contents = await readFile(envelope.result.artifactPath, 'utf8'); + expect(contents.startsWith('', + ); + + destroySession(testHome, sessionId); + }); + it('rejects invalid export formats', () => { const result = runCli( ['record', 'export', 'session-01', '--format', 'bogus', '--json'], @@ -375,4 +739,53 @@ describe('record export integration', { timeout: 120_000 }, () => { expect(envelope.command).toBe('record export'); expect(envelope.error.code).toBe('INVALID_INPUT'); }); + + it('rejects --animate with non-svg formats', () => { + const result = runCli( + [ + 'record', + 'export', + 'session-01', + '--format', + 'asciicast', + '--animate', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('record export'); + expect(envelope.error.code).toBe('INVALID_INPUT'); + expect(envelope.error.message).toContain('--animate'); + }); + + it('rejects --timing with svg exports', () => { + const result = runCli( + [ + 'record', + 'export', + 'session-01', + '--format', + 'svg', + '--timing', + 'recorded', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 15_000, + ); + + expect(result.status).not.toBe(0); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as ErrorEnvelope; + expect(envelope.ok).toBe(false); + expect(envelope.command).toBe('record export'); + expect(envelope.error.code).toBe('INVALID_INPUT'); + expect(envelope.error.message).toContain('--timing'); + }); }); diff --git a/test/unit/commands/golden-envelopes.test.ts b/test/unit/commands/golden-envelopes.test.ts index b0dbf6fb..b99ae4ed 100644 --- a/test/unit/commands/golden-envelopes.test.ts +++ b/test/unit/commands/golden-envelopes.test.ts @@ -1162,6 +1162,47 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ profile: 'default', }, }, + { + name: 'record export (svg)', + command: 'record export', + schema: RecordExportResultSchema, + validResult: { + sessionId: '01J0000000TEST000000000000', + format: 'svg', + artifactPath: '/tmp/test.svg', + bytes: 4096, + sha256: 'fed789', + capturedAtSeq: 42, + durationMs: 1500, + metadata: { + width: 80, + height: 24, + profileName: 'reference-dark', + rendererBackend: 'libghostty-vt', + animated: true, + frameCount: 3, + }, + }, + invalidResult: { + sessionId: '01J0000000TEST000000000000', + format: 'svg', + artifactPath: '/tmp/test.svg', + bytes: 0, + sha256: 'fed789', + capturedAtSeq: 42, + metadata: {}, + }, + extraFieldResult: { + sessionId: '01J0000000TEST000000000000', + format: 'svg', + artifactPath: '/tmp/test.svg', + bytes: 4096, + sha256: 'fed789', + capturedAtSeq: 42, + metadata: {}, + animate: true, + }, + }, { name: 'destroy', command: 'destroy', diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index f80304d0..31b846a7 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -35,6 +35,8 @@ const mocks = vi.hoisted(() => ({ artifactPath: vi.fn(), recordingFilename: vi.fn(), generateWebmExport: vi.fn(), + captureGridFrames: vi.fn(), + renderGridFramesToSvg: vi.fn(), loadPackageMetadata: vi.fn(), readFile: vi.fn(), stat: vi.fn(), @@ -82,6 +84,14 @@ vi.mock('../../../src/export/webm.js', () => ({ generateWebmExport: mocks.generateWebmExport, })); +vi.mock('../../../src/replay/gridFrames.js', () => ({ + captureGridFrames: mocks.captureGridFrames, +})); + +vi.mock('../../../src/export/svg.js', () => ({ + renderGridFramesToSvg: mocks.renderGridFramesToSvg, +})); + vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { @@ -481,6 +491,295 @@ describe('record export command', () => { expect(emitSuccessArgs.result.durationMs).toBe(1_500); }); + it('exports still svg artifacts via captureGridFrames', async () => { + mocks.recordingFilename.mockReturnValue('recording-1-svg.svg'); + + const svgContents = 'still\n'; + const svgSha256 = createHash('sha256') + .update(Buffer.from(svgContents, 'utf8')) + .digest('hex'); + const expectedRenderProfileHash = hashProfile( + resolveProfile('reference-dark'), + ); + const gridFrame = { + capturedAtSeq: 1, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [[{ char: 'h' }]], + holdMs: 1_000, + }; + + mocks.captureGridFrames.mockResolvedValue({ + frames: [gridFrame], + capturedAtSeq: 1, + cols: 80, + rows: 24, + rendererBackend: 'libghostty-vt', + outputEventCount: 1, + resizeEventCount: 1, + timelineDurationMs: 2_500, + }); + mocks.renderGridFramesToSvg.mockReturnValue(svgContents); + + await runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + }); + + expect(mocks.captureGridFrames).toHaveBeenCalledTimes(1); + const captureCall = mocks.captureGridFrames.mock.calls[0] as [ + { + sessionId: string; + manifest: ReturnType; + events: unknown[]; + profile: { name: string }; + mode: string; + }, + ]; + const [captureArgs] = captureCall; + + expect(captureArgs.sessionId).toBe('session-01'); + expect(captureArgs.manifest).toEqual(createSessionRecord()); + expect(captureArgs.events).toHaveLength(2); + expect(captureArgs.profile.name).toBe('reference-dark'); + expect(captureArgs.mode).toBe('final'); + // Still svg carries the resolved profile in its default filename. + expect(mocks.recordingFilename).toHaveBeenCalledWith( + 1, + 'svg', + 'reference-dark', + ); + + expect(mocks.renderGridFramesToSvg).toHaveBeenCalledWith({ + profile: expect.objectContaining({ name: 'reference-dark' }) as unknown, + frames: [gridFrame], + animate: false, + }); + expect(mocks.writeTextFileAtomic).toHaveBeenCalledWith({ + path: '/tmp/agent-tty/sessions/session-01/artifacts/recording-1-svg.svg', + pathLabel: 'record export path', + contents: svgContents, + writeErrorMessage: + 'Failed to write record export artifact at /tmp/agent-tty/sessions/session-01/artifacts/recording-1-svg.svg.', + }); + + expect(mocks.createArtifactEntry).toHaveBeenCalledWith({ + kind: 'recording', + filename: 'recording-1-svg.svg', + sessionId: 'session-01', + capturedAtSeq: 1, + sha256: svgSha256, + bytes: Buffer.byteLength(svgContents, 'utf8'), + metadata: { + format: 'svg', + outputPath: + '/tmp/agent-tty/sessions/session-01/artifacts/recording-1-svg.svg', + width: 80, + height: 24, + profileName: 'reference-dark', + renderProfileHash: expectedRenderProfileHash, + rendererBackend: 'libghostty-vt', + animated: false, + frameCount: 1, + outputEventCount: 1, + resizeEventCount: 1, + }, + }); + + expect(mocks.emitSuccess).toHaveBeenCalledTimes(1); + const emitSuccessCall = mocks.emitSuccess.mock.calls[0] as [ + { + result: { + format: string; + bytes: number; + sha256: string; + capturedAtSeq: number; + durationMs?: number; + metadata: Record; + }; + }, + ]; + const [emitSuccessArgs] = emitSuccessCall; + + expect(emitSuccessArgs.result.format).toBe('svg'); + expect(emitSuccessArgs.result.bytes).toBe( + Buffer.byteLength(svgContents, 'utf8'), + ); + expect(emitSuccessArgs.result.sha256).toBe(svgSha256); + expect(emitSuccessArgs.result.capturedAtSeq).toBe(1); + // Still exports represent an instant, not a playback timeline. + expect(emitSuccessArgs.result.durationMs).toBe(0); + expect(emitSuccessArgs.result.metadata.animated).toBe(false); + expect(mocks.generateWebmExport).not.toHaveBeenCalled(); + }); + + it('exports animated svg with the recorded timeline duration', async () => { + mocks.recordingFilename.mockReturnValue('recording-1-svg.svg'); + mocks.captureGridFrames.mockResolvedValue({ + frames: [ + { + capturedAtSeq: 0, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 1_500, + }, + { + capturedAtSeq: 1, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 1_000, + }, + ], + capturedAtSeq: 1, + cols: 80, + rows: 24, + rendererBackend: 'libghostty-vt', + outputEventCount: 1, + resizeEventCount: 1, + timelineDurationMs: 2_500, + }); + mocks.renderGridFramesToSvg.mockReturnValue('animated\n'); + + await runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + animate: true, + }); + + const captureCall = mocks.captureGridFrames.mock.calls[0] as [ + { mode: string }, + ]; + expect(captureCall[0].mode).toBe('timeline'); + expect(mocks.renderGridFramesToSvg).toHaveBeenCalledWith( + expect.objectContaining({ animate: true }), + ); + // Animated svg gets a distinct profile-bearing default filename so it + // cannot overwrite the still export at the same seq. + expect(mocks.recordingFilename).toHaveBeenCalledWith( + 1, + 'svg', + 'reference-dark-animated', + ); + + const emitSuccessCall = mocks.emitSuccess.mock.calls[0] as [ + { + result: { + durationMs?: number; + metadata: Record; + }; + }, + ]; + const [emitSuccessArgs] = emitSuccessCall; + expect(emitSuccessArgs.result.durationMs).toBe(2_500); + expect(emitSuccessArgs.result.metadata.animated).toBe(true); + expect(emitSuccessArgs.result.metadata.frameCount).toBe(2); + }); + + it('exports blank svg artifacts for silent sessions with empty event logs', async () => { + mocks.recordingFilename.mockReturnValue('recording-0-svg.svg'); + mocks.readEventLogRecords.mockResolvedValue([]); + + const blankFrame = { + capturedAtSeq: 0, + cols: 80, + rows: 24, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 1_000, + }; + mocks.captureGridFrames.mockResolvedValue({ + frames: [blankFrame], + capturedAtSeq: 0, + cols: 80, + rows: 24, + rendererBackend: 'none', + outputEventCount: 0, + resizeEventCount: 0, + timelineDurationMs: 1_000, + }); + mocks.renderGridFramesToSvg.mockReturnValue('blank\n'); + + await runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + }); + + const captureCall = mocks.captureGridFrames.mock.calls[0] as [ + { events: unknown[]; mode: string }, + ]; + expect(captureCall[0].events).toEqual([]); + expect(captureCall[0].mode).toBe('final'); + + const emitSuccessCall = mocks.emitSuccess.mock.calls[0] as [ + { + result: { + format: string; + capturedAtSeq: number; + metadata: Record; + }; + }, + ]; + const [emitSuccessArgs] = emitSuccessCall; + expect(emitSuccessArgs.result.format).toBe('svg'); + expect(emitSuccessArgs.result.capturedAtSeq).toBe(0); + expect(emitSuccessArgs.result.metadata).toMatchObject({ + frameCount: 1, + animated: false, + outputEventCount: 0, + resizeEventCount: 0, + }); + }); + + it('rejects --animate for non-svg formats', async () => { + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'asciicast', + animate: true, + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + message: '--animate is only supported with --format svg.', + }); + expect(mocks.captureGridFrames).not.toHaveBeenCalled(); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + + it('rejects --timing for svg exports', async () => { + await expect( + runRecordExportCommand({ + context: TEST_CONTEXT, + json: true, + sessionId: 'session-01', + format: 'svg', + timing: 'recorded', + }), + ).rejects.toMatchObject({ + code: ERROR_CODES.INVALID_INPUT, + message: + '--timing is not supported with --format svg; animated SVG always uses recorded timing.', + }); + expect(mocks.captureGridFrames).not.toHaveBeenCalled(); + expect(mocks.emitSuccess).not.toHaveBeenCalled(); + }); + it('requests rollback for default asciicast artifacts when manifest append fails', async () => { const sessionDirectory = await createTemporaryDirectory( 'agent-tty-record-export-append-asciicast-', diff --git a/test/unit/export/svg.test.ts b/test/unit/export/svg.test.ts new file mode 100644 index 00000000..41d74bc3 --- /dev/null +++ b/test/unit/export/svg.test.ts @@ -0,0 +1,540 @@ +import { describe, expect, it } from 'vitest'; + +import type { SvgGridFrame } from '../../../src/export/svg.js'; +import type { SnapshotCell } from '../../../src/protocol/schemas.js'; + +import { renderGridFramesToSvg } from '../../../src/export/svg.js'; +import { resolveProfile } from '../../../src/renderer/profiles.js'; + +const PROFILE = resolveProfile('reference-dark'); + +function cell(char: string, style: Partial = {}): SnapshotCell { + return { char, ...style }; +} + +function makeFrame(overrides: Partial = {}): SvgGridFrame { + return { + cols: 10, + rows: 3, + cursorRow: 0, + cursorCol: 0, + lines: [], + holdMs: 100, + ...overrides, + }; +} + +function countOccurrences(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +/** Drop the embedded font payload (base64 can contain any digits/letters). */ +function stripFontAssets(svg: string): string { + return svg.replace(/