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
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default defineConfig({
browser: 'chromium', // chromium with jpeg-stitch is the highest-quality path (v0.35+)
captureMode: 'jpeg-stitch', // CDP-direct paint-time capture; auto-downgrades on webkit/firefox
deviceScaleFactor: 2, // 4K supersample → lanczos downscale; auto-clamps to 1 on non-chromium
cursorHighlight: true, // pseudo-cursor ring that follows mouse movement in the recording
},
export: {
preset: 'slow', crf: 16,
Expand All @@ -146,6 +147,11 @@ export default defineConfig({
});
```

Set `video.cursorHighlight` to `true` for the default pseudo-cursor, or pass
`{ color, radius, pulse, clickRipple, opacity }` to customize it. Argo injects
the overlay when recording starts and restores it after top-level navigation,
so demo scripts do not need to call `cursorHighlight()` themselves.

> **Tip:** Use `browser: 'webkit'` for sharper video on macOS. Chromium has a [known video capture quality issue](https://github.com/microsoft/playwright/issues/31424). Set `deviceScaleFactor: 2` for retina-quality recordings (captured at 2x, downscaled with lanczos in export).

### Mobile Demos
Expand Down Expand Up @@ -333,7 +339,7 @@ import { defineConfig, demosProject, engines } from '@argo-video/cli';
| `dimAround(page, selector, opts?)` | Fade sibling elements to highlight target |
| `zoomTo(page, selector, opts?)` | Scale viewport centered on target. Pass `{ narration }` for overlay-safe ffmpeg post-export zoom (recommended). |
| `resetCamera(page)` | Clear all active camera effects |
| `cursorHighlight(page, opts?)` | Persistent cursor ring with pulse + click ripple. Options: `color`, `radius`, `pulse`, `clickRipple`, `opacity` |
| `cursorHighlight(page, opts?)` | Manually enable a persistent cursor ring with pulse + click ripple. For recording-wide automatic setup, use `video.cursorHighlight`. Options: `color`, `radius`, `pulse`, `clickRipple`, `opacity` |
| `resetCursor(page)` | Remove cursor highlight |
| `showCaption(page, scene, text, durationMs)` | Show a simple text caption |
| `withCaption(page, scene, text, action)` | Show caption during an async action |
Expand Down
2 changes: 1 addition & 1 deletion skills/argo-guide/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ All camera effects are **non-blocking by default** (fire-and-forget safe). All a
| `zoomTo(page, target, opts?)` | Scale viewport centered on target. Pass `{ narration }` for overlay-safe ffmpeg post-export zoom (recommended). Without `narration`, falls back to browser-side CSS transforms (overlays scale with the page). |
| `resetCamera(page)` | Clear all active camera effects |
| `showConfetti(page, opts?)` | Confetti burst. `spread: 'burst'` (center-top fan) or `'rain'` (full-width fall). `emoji: '🎃'` or `emoji: ['🎃', '👻']` renders emoji instead of colored rectangles. |
| `cursorHighlight(page, opts?)` | Persistent ring following cursor. Remove with `resetCursor(page)`. |
| `cursorHighlight(page, opts?)` | Manually enable a persistent ring following cursor. Remove with `resetCursor(page)`. Use `video.cursorHighlight` for recording-wide automatic setup. |

Derive camera durations from `narration.durationFor()` so effects track voiceover timing:
**Effect timing pattern**: Derive beat durations from `durationFor()` so effects stay synchronized with voiceover. Subtract any setup wait time before dividing:
Expand Down
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export function createProgram(): Command {
autoBackground: config.overlays?.autoBackground,
defaultPlacement: config.overlays?.defaultPlacement,
showActions: config.video.showActions,
cursorHighlight: config.video.cursorHighlight,
sceneThumbnails: config.video.sceneThumbnails,
headed: cmdOpts.headed,
});
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import type { TTSEngine } from './tts/engine.js';
import type { ShaderName } from './transitions/shaders/index.js';
import type { CursorHighlightOptions } from './cursor.js';
export type { TTSEngine };

// ---- Types ----
Expand Down Expand Up @@ -55,6 +56,9 @@ export interface VideoConfig {
/** Auto-annotate Playwright interactions (clicks, fills) with action labels in the recording.
* Drives `page.screencast.showActions()` from the narration fixture. Off by default. */
showActions?: boolean | ShowActionsConfig;
/** Render a pseudo-cursor highlight that follows mouse movement in the recording.
* `true` uses the default ring; an object customizes its appearance. Off by default. */
cursorHighlight?: boolean | CursorHighlightOptions;
/** Capture a JPEG thumbnail per scene at the moment `narration.mark()` fires.
* Saved to `.argo/<demo>/thumbs/<scene>.jpg`. Used by the preview scrubber for
* instant strip rendering. Default: true. */
Expand Down
1 change: 1 addition & 0 deletions src/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export default defineConfig({
fps: 30,
browser: 'webkit', // webkit > firefox > chromium for video quality on macOS
// deviceScaleFactor: 2, // 2x capture + lanczos downscale (known issue with webkit — enable after fix)
// cursorHighlight: true, // show a pseudo-cursor throughout the recording
},
export: {
preset: 'slow', // slower = smaller file, higher quality
Expand Down
56 changes: 43 additions & 13 deletions src/narration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Writable } from 'node:stream';
import { schedulePlacements, type Placement } from './tts/align.js';
import type { CameraMove } from './camera-move.js';
import { startCdpScreencast, type CdpScreencastHandle } from './cdp-screencast.js';
import { cursorHighlight, type CursorHighlightOptions } from './cursor.js';

/**
* Subset of Playwright's Page we depend on — typed structurally so we don't
Expand Down Expand Up @@ -142,6 +143,21 @@ export class NarrationTimeline {
// (record.ts only sets the env var for chromium + captureMode: jpeg-stitch).
const useCdpDirect = process.env.ARGO_USE_CDP_DIRECT === '1';

let automaticCursor: CursorHighlightOptions | null = null;
const cursorHighlightEnv = process.env.ARGO_CURSOR_HIGHLIGHT;
if (cursorHighlightEnv) {
try {
const parsed = JSON.parse(cursorHighlightEnv) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
automaticCursor = parsed as CursorHighlightOptions;
} else {
throw new Error('expected a JSON object');
}
} catch (err) {
console.warn(`Warning: invalid ARGO_CURSOR_HIGHLIGHT: ${(err as Error).message}`);
}
}

// Honor ARGO_JPEG_QUALITY for stream-encode mode; caller `options.quality` wins.
const envQuality = Number(process.env.ARGO_JPEG_QUALITY);
const quality = options.quality
Expand Down Expand Up @@ -258,19 +274,6 @@ export class NarrationTimeline {
// through; for jpeg-stitch users it's a discardable temp file.
await page.screencast.start({ path: screencastPath, size, quality, onFrame });
this._screencastStop = () => page.screencast.stop();
this._recordingPage = page;

// CDP screencast only emits frames on paint. After page.goto() lands,
// Chrome can stay paused for the inter-paint window — gap-fill repeats
// the last (pre-nav) JPEG and the new page doesn't appear in the video
// until the next natural paint (could be seconds on heavy SPAs). Force
// a paint right after navigation so CDP unsticks promptly.
const navListener = (frame: { parentFrame: () => unknown }): void => {
if (frame.parentFrame() !== null) return; // ignore subframe navs
this._triggerPaint();
};
this._navListener = navListener;
page.on('framenavigated', navListener);

// Cleanup hook for stream-encode mode — pads to wall-clock, ends stdin,
// waits for ffmpeg to flush. Called from _closeRecording().
Expand Down Expand Up @@ -316,6 +319,33 @@ export class NarrationTimeline {

} // close legacy `else` branch — showActions + timeline anchor below run for both paths.

this._recordingPage = page;

// Reinstall the pseudo-cursor after a top-level navigation because the
// browser replaces the document (and its overlay/listeners). Waiting for
// the injection before nudging paint keeps the first post-navigation frame
// from briefly appearing without the cursor overlay.
const navListener = (frame: { parentFrame: () => unknown }): void => {
if (frame.parentFrame() !== null) return; // ignore subframe navs
if (automaticCursor) {
void cursorHighlight(
page as unknown as Parameters<typeof cursorHighlight>[0],
automaticCursor,
).then(() => this._triggerPaint());
} else {
this._triggerPaint();
}
};
this._navListener = navListener;
page.on('framenavigated', navListener);

if (automaticCursor) {
await cursorHighlight(
page as unknown as Parameters<typeof cursorHighlight>[0],
automaticCursor,
);
}

// Optional auto-annotation of every Playwright interaction.
const showActionsEnv = process.env.ARGO_SHOW_ACTIONS;
if (showActionsEnv) {
Expand Down
2 changes: 2 additions & 0 deletions src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export async function runPipeline(
defaultPlacement: config.overlays.defaultPlacement,
allowRawGsap: config.overlays.allowRawGsap,
showActions: config.video.showActions,
cursorHighlight: config.video.cursorHighlight,
sceneThumbnails: config.video.sceneThumbnails,
captureMode: config.video.captureMode,
jpegQuality: config.video.jpegQuality,
Expand Down Expand Up @@ -536,6 +537,7 @@ export async function runPipeline(
defaultPlacement: config.overlays.defaultPlacement,
allowRawGsap: config.overlays.allowRawGsap,
showActions: config.video.showActions,
cursorHighlight: config.video.cursorHighlight,
sceneThumbnails: config.video.sceneThumbnails,
captureMode: config.video.captureMode,
jpegQuality: config.video.jpegQuality,
Expand Down
13 changes: 13 additions & 0 deletions src/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import { startAssetServer, type AssetServer } from './asset-server.js';
import { loadOverlayManifest, hasImageAssets } from './overlays/manifest.js';
import { normalizeDeviceScaleFactor, type BrowserEngine, type ShowActionsConfig } from './config.js';
import type { CursorHighlightOptions } from './cursor.js';

export interface RecordOptions {
demosDir: string;
Expand All @@ -22,6 +23,8 @@ export interface RecordOptions {
argoSubdir?: string;
/** Auto-annotate Playwright interactions in the recording. */
showActions?: boolean | ShowActionsConfig;
/** Render a pseudo-cursor highlight that follows mouse movement. */
cursorHighlight?: boolean | CursorHighlightOptions;
/** Capture a JPEG per scene mark for the preview scrubber. Default: true. */
sceneThumbnails?: boolean;
/** Capture all frames as JPEGs and stitch in post for higher quality. */
Expand Down Expand Up @@ -241,6 +244,15 @@ export async function record(demoName: string, options: RecordOptions): Promise<
showActionsEnv = JSON.stringify(options.showActions);
}

// `{}` selects cursorHighlight() defaults; an empty string keeps the
// pseudo-cursor disabled for backward compatibility.
let cursorHighlightEnv = '';
if (options.cursorHighlight === true) {
cursorHighlightEnv = '{}';
} else if (options.cursorHighlight && typeof options.cursorHighlight === 'object') {
cursorHighlightEnv = JSON.stringify(options.cursorHighlight);
}

// Per-scene thumbs: default ON. Pass '0' to opt out, anything else (including '') means on.
const sceneThumbsEnv = options.sceneThumbnails === false ? '0' : '1';
const thumbsDir = path.resolve(path.join(argoDir, 'thumbs'));
Expand Down Expand Up @@ -271,6 +283,7 @@ export async function record(demoName: string, options: RecordOptions): Promise<
ARGO_SCREENCAST_WIDTH: String(options.video.width * normalizeDeviceScaleFactor(options.deviceScaleFactor)),
ARGO_SCREENCAST_HEIGHT: String(options.video.height * normalizeDeviceScaleFactor(options.deviceScaleFactor)),
ARGO_SHOW_ACTIONS: showActionsEnv,
ARGO_CURSOR_HIGHLIGHT: cursorHighlightEnv,
ARGO_SCENE_THUMBS: sceneThumbsEnv,
ARGO_THUMBS_DIR: thumbsDir,
ARGO_LIVE_FRAME_PATH: path.resolve(path.join(argoDir, '.live-frame.jpg')),
Expand Down
13 changes: 13 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,19 @@ describe('CLI', () => {

expect(mockedLoadConfig).toHaveBeenCalledWith(process.cwd(), 'custom.ts');
});

it('forwards automatic cursor highlighting from video config', async () => {
mockedLoadConfig.mockResolvedValue({
...defaultConfig,
video: { ...defaultConfig.video, cursorHighlight: true },
} as any);

await run('record', 'onboarding');

expect(mockedRecord).toHaveBeenCalledWith('onboarding', expect.objectContaining({
cursorHighlight: true,
}));
});
});

describe('argo tts generate <manifest>', () => {
Expand Down
6 changes: 6 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ describe('defineConfig', () => {
expect(config.video.fps).toBe(30);
});

it('preserves automatic cursor highlight options', () => {
const cursorHighlight = { color: '#ff0000', radius: 24, clickRipple: false };
const config = defineConfig({ video: { cursorHighlight } });
expect(config.video.cursorHighlight).toEqual(cursorHighlight);
});

it('normalizes deviceScaleFactor to a positive integer', () => {
const rounded = defineConfig({ video: { deviceScaleFactor: 1.6 } });
const clamped = defineConfig({ video: { deviceScaleFactor: 0.4 } });
Expand Down
53 changes: 53 additions & 0 deletions tests/narration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,59 @@ describe('sceneDuration', () => {
});
});

describe('automatic cursor highlight', () => {
const originalScreencastPath = process.env.ARGO_SCREENCAST_PATH;
const originalCursorHighlight = process.env.ARGO_CURSOR_HIGHLIGHT;
const originalUseCdpDirect = process.env.ARGO_USE_CDP_DIRECT;
const originalStreamOut = process.env.ARGO_STREAM_OUT;

afterEach(() => {
if (originalScreencastPath === undefined) delete process.env.ARGO_SCREENCAST_PATH;
else process.env.ARGO_SCREENCAST_PATH = originalScreencastPath;
if (originalCursorHighlight === undefined) delete process.env.ARGO_CURSOR_HIGHLIGHT;
else process.env.ARGO_CURSOR_HIGHLIGHT = originalCursorHighlight;
if (originalUseCdpDirect === undefined) delete process.env.ARGO_USE_CDP_DIRECT;
else process.env.ARGO_USE_CDP_DIRECT = originalUseCdpDirect;
if (originalStreamOut === undefined) delete process.env.ARGO_STREAM_OUT;
else process.env.ARGO_STREAM_OUT = originalStreamOut;
});

it('injects the configured pseudo-cursor and restores it after top-level navigation', async () => {
process.env.ARGO_SCREENCAST_PATH = 'cursor-test.webm';
process.env.ARGO_CURSOR_HIGHLIGHT = JSON.stringify({ color: '#22c55e', radius: 18 });
delete process.env.ARGO_USE_CDP_DIRECT;
delete process.env.ARGO_STREAM_OUT;

let navigationListener: ((frame: { parentFrame: () => unknown }) => void) | undefined;
const evaluate = vi.fn().mockResolvedValue(undefined);
const page = {
screencast: {
start: vi.fn().mockResolvedValue(undefined),
stop: vi.fn().mockResolvedValue(undefined),
showActions: vi.fn().mockResolvedValue(undefined),
},
evaluate,
on: vi.fn((_event, listener) => { navigationListener = listener; }),
off: vi.fn(),
context: vi.fn(),
};

const timeline = new NarrationTimeline();
await timeline.startRecording(page as never);

const cursorCalls = () => evaluate.mock.calls.filter((call) => call[1]?.id === 'argo-cursor-highlight');
expect(cursorCalls()).toHaveLength(1);
expect(cursorCalls()[0][1]).toEqual(expect.objectContaining({ color: '#22c55e', radius: 18 }));

navigationListener?.({ parentFrame: () => null });
await vi.waitFor(() => expect(cursorCalls()).toHaveLength(2));

await timeline._closeRecording();
expect(page.screencast.stop).toHaveBeenCalledTimes(1);
expect(page.off).toHaveBeenCalledWith('framenavigated', navigationListener);
});
});

describe('_closeRecording', () => {
it('throws when stream finalization fails', async () => {
const timeline = new NarrationTimeline();
Expand Down
13 changes: 13 additions & 0 deletions tests/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ describe('runPipeline', () => {
}));
});

it('forwards automatic cursor highlighting to the recorder', async () => {
const config = {
...defaultConfig,
video: { ...defaultConfig.video, cursorHighlight: { color: '#22c55e', radius: 18 } },
};

await runPipeline(DEMO_NAME, config);

expect(mockedRecord).toHaveBeenCalledWith(DEMO_NAME, expect.objectContaining({
cursorHighlight: { color: '#22c55e', radius: 18 },
}));
});

it('passes correct options to exportVideo', async () => {
await runPipeline(DEMO_NAME, defaultConfig);
expect(mockedExportVideo).toHaveBeenCalledWith(expect.objectContaining({
Expand Down
37 changes: 37 additions & 0 deletions tests/record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe('record', () => {
ARGO_SCREENCAST_HEIGHT: '720',
// showActions defaults off, sceneThumbs defaults on
ARGO_SHOW_ACTIONS: '',
ARGO_CURSOR_HIGHLIGHT: '',
ARGO_SCENE_THUMBS: '1',
ARGO_THUMBS_DIR: resolve(join('.argo', 'demo', 'thumbs')),
ARGO_LIVE_FRAME_PATH: resolve(join('.argo', 'demo', '.live-frame.jpg')),
Expand Down Expand Up @@ -139,6 +140,42 @@ describe('record', () => {
);
});

it('serializes automatic cursor highlight options to the recording runtime', async () => {
mockSubprocessSuccess();
await record('demo', {
demosDir: 'custom-demos',
baseURL: 'http://localhost:4321',
video: { width: 1280, height: 720 },
cursorHighlight: { color: '#ff0000', radius: 24, clickRipple: false },
});
expect(execFileMock).toHaveBeenCalledWith(
'npx',
expect.any(Array),
expect.objectContaining({
env: expect.objectContaining({
ARGO_CURSOR_HIGHLIGHT: JSON.stringify({ color: '#ff0000', radius: 24, clickRipple: false }),
}),
}),
expect.any(Function),
);
});

it('passes cursorHighlight: true as default-options JSON', async () => {
mockSubprocessSuccess();
await record('demo', {
demosDir: 'custom-demos',
baseURL: 'http://localhost:4321',
video: { width: 1280, height: 720 },
cursorHighlight: true,
});
expect(execFileMock).toHaveBeenCalledWith(
'npx',
expect.any(Array),
expect.objectContaining({ env: expect.objectContaining({ ARGO_CURSOR_HIGHLIGHT: '{}' }) }),
expect.any(Function),
);
});

it('honors sceneThumbnails: false to opt out of per-scene thumbs', async () => {
mockSubprocessSuccess();
await record('demo', {
Expand Down
Loading