diff --git a/docs/USAGE.md b/docs/USAGE.md index 7c33467e..4cdaa825 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -198,6 +198,8 @@ agent-tty record export --format asciicast --out ./session.cast --j agent-tty record export --format webm --timing accelerated --out ./session.webm --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. + `ghostty-web` provides reference visual truth for reviewable artifacts; it does not promise exact pixel parity with native terminals. ## Isolation diff --git a/src/cli/main.ts b/src/cli/main.ts index cd93f596..02fd2ec2 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -794,7 +794,7 @@ async function main(): Promise { .option('--profile ', 'Render profile name') .option( '--timing ', - 'Replay timing mode for WebM: recorded, accelerated, max-speed', + 'Replay timing mode for WebM: recorded (default), accelerated, max-speed', ) .option('--json', 'Emit a JSON command envelope', false) .action( diff --git a/src/export/webm.ts b/src/export/webm.ts index caa23cf7..507d22a5 100644 --- a/src/export/webm.ts +++ b/src/export/webm.ts @@ -25,10 +25,12 @@ import type { RenderProfileConfig, ReplayState } from '../renderer/types.js'; import { invariant, unreachable } from '../util/assert.js'; const REPLAY_TIMEOUT_MS = 5 * 60 * 1000; -const DEFAULT_ACCELERATED_TIMING: ReplayTimingOptions = Object.freeze({ +// maxGapMs/minFrameHoldMs are tuned for watchability: shorter clamps make the +// video flicker because every idle gap collapses to a near-instant cut. +const ACCELERATED_TIMING: ReplayTimingOptions = Object.freeze({ mode: 'accelerated' as const, - maxGapMs: 100, - minFrameHoldMs: 50, + maxGapMs: 400, + minFrameHoldMs: 100, finalFrameHoldMs: 1_000, }); @@ -37,7 +39,7 @@ function buildReplayTimingOptions(mode: ReplayTimingMode): ReplayTimingOptions { switch (validatedMode) { case 'accelerated': - return DEFAULT_ACCELERATED_TIMING; + return ACCELERATED_TIMING; case 'recorded': return { mode: 'recorded', finalFrameHoldMs: 1_000 }; case 'max-speed': @@ -172,8 +174,9 @@ export async function generateWebmExport( height: viewportHeight, }, }; + // Default to wall-clock timing so exported videos match the recorded pace. const resolvedTimingMode: ReplayTimingMode = - options.timingMode ?? 'accelerated'; + options.timingMode ?? 'recorded'; const timingOptions = buildReplayTimingOptions(resolvedTimingMode); const backendFactory = deps?.backendFactory ?? createDefaultBackend; const requestedRendererName = resolveRendererName( diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index 9b476781..30481ff0 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -367,7 +367,7 @@ describe('record export command', () => { cols: 80, rows: 24, profileName: 'reference-dark', - timingMode: 'accelerated', + timingMode: 'recorded', rendererBackend: 'ghostty-web', }); mocks.stat.mockResolvedValue({ size: webmBytes }); @@ -445,7 +445,7 @@ describe('record export command', () => { expectedRenderProfileHash, ); expect(artifactEntry.metadata.profileName).toBe('reference-dark'); - expect(artifactEntry.metadata.timingMode).toBe('accelerated'); + expect(artifactEntry.metadata.timingMode).toBe('recorded'); expect(artifactEntry.metadata.rendererBackend).toBe('ghostty-web'); expect(mocks.emitSuccess).toHaveBeenCalledTimes(1); @@ -557,7 +557,7 @@ describe('record export command', () => { cols: 80, rows: 24, profileName: 'reference-dark', - timingMode: 'accelerated', + timingMode: 'recorded', rendererBackend: 'ghostty-web', }; }, @@ -626,7 +626,7 @@ describe('record export command', () => { cols: 80, rows: 24, profileName: 'reference-light', - timingMode: 'accelerated', + timingMode: 'recorded', rendererBackend: 'ghostty-web', }); const webmBytes = 12_345; @@ -752,7 +752,7 @@ describe('record export command', () => { cols: 80, rows: 24, profileName: 'reference-dark', - timingMode: 'accelerated', + timingMode: 'recorded', rendererBackend: 'ghostty-web', }); const webmBytes = 12_345; diff --git a/test/unit/export/webm.test.ts b/test/unit/export/webm.test.ts index 52bccd95..266ea90c 100644 --- a/test/unit/export/webm.test.ts +++ b/test/unit/export/webm.test.ts @@ -250,9 +250,7 @@ describe('generateWebmExport', () => { targetSeq: 1, }, { - mode: 'accelerated', - maxGapMs: 100, - minFrameHoldMs: 50, + mode: 'recorded', finalFrameHoldMs: 1_000, }, ); @@ -269,7 +267,7 @@ describe('generateWebmExport', () => { cols: 80, rows: 24, profileName: 'reference-dark', - timingMode: 'accelerated', + timingMode: 'recorded', rendererBackend: 'ghostty-web', }); }); @@ -316,7 +314,7 @@ describe('generateWebmExport', () => { expect(result.rendererBackend).toBe('ghostty-web'); }); - it('passes recorded timing options when timingMode is recorded', async () => { + it('passes accelerated timing options when timingMode is accelerated', async () => { const mockBackend = createMockBackend(); const result = await generateWebmExport( @@ -326,16 +324,21 @@ describe('generateWebmExport', () => { manifest: createManifest(), events: createEvents(), outputPath: '/tmp/exports/recording-1-webm.webm', - timingMode: 'recorded', + timingMode: 'accelerated', }, { backendFactory: () => mockBackend.backend }, ); expect(mockBackend.replayWithTiming).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'session-01' }), - { mode: 'recorded', finalFrameHoldMs: 1_000 }, + { + mode: 'accelerated', + maxGapMs: 400, + minFrameHoldMs: 100, + finalFrameHoldMs: 1_000, + }, ); - expect(result.timingMode).toBe('recorded'); + expect(result.timingMode).toBe('accelerated'); }); it('passes max-speed timing options when timingMode is max-speed', async () => { @@ -360,7 +363,7 @@ describe('generateWebmExport', () => { expect(result.timingMode).toBe('max-speed'); }); - it('defaults to accelerated timing when timingMode is omitted', async () => { + it('defaults to recorded timing when timingMode is omitted', async () => { const mockBackend = createMockBackend(); const result = await generateWebmExport( @@ -376,14 +379,9 @@ describe('generateWebmExport', () => { expect(mockBackend.replayWithTiming).toHaveBeenCalledWith( expect.objectContaining({ sessionId: 'session-01' }), - { - mode: 'accelerated', - maxGapMs: 100, - minFrameHoldMs: 50, - finalFrameHoldMs: 1_000, - }, + { mode: 'recorded', finalFrameHoldMs: 1_000 }, ); - expect(result.timingMode).toBe('accelerated'); + expect(result.timingMode).toBe('recorded'); }); it('rejects empty events', async () => {