Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ agent-tty record export <session-id> --format asciicast --out ./session.cast --j
agent-tty record export <session-id> --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
Expand Down
2 changes: 1 addition & 1 deletion src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,7 @@ async function main(): Promise<void> {
.option('--profile <name>', 'Render profile name')
.option(
'--timing <mode>',
'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(
Expand Down
13 changes: 8 additions & 5 deletions src/export/webm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -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':
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 5 additions & 5 deletions test/unit/commands/record-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -557,7 +557,7 @@ describe('record export command', () => {
cols: 80,
rows: 24,
profileName: 'reference-dark',
timingMode: 'accelerated',
timingMode: 'recorded',
rendererBackend: 'ghostty-web',
};
},
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
30 changes: 14 additions & 16 deletions test/unit/export/webm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,9 +250,7 @@ describe('generateWebmExport', () => {
targetSeq: 1,
},
{
mode: 'accelerated',
maxGapMs: 100,
minFrameHoldMs: 50,
mode: 'recorded',
finalFrameHoldMs: 1_000,
},
);
Expand All @@ -269,7 +267,7 @@ describe('generateWebmExport', () => {
cols: 80,
rows: 24,
profileName: 'reference-dark',
timingMode: 'accelerated',
timingMode: 'recorded',
rendererBackend: 'ghostty-web',
});
});
Expand Down Expand Up @@ -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(
Expand All @@ -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 () => {
Expand All @@ -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(
Expand All @@ -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 () => {
Expand Down
Loading