#!/usr/bin/env node /** * capture-readme-gifs.mjs * * Deterministic README GIFs — no real server, Claude CLI, or tmux. Reuses the * mock-injection pipeline from capture-readme-screenshots.mjs (static file * server + page.route mocks), drives a scripted timeline in the page, records * it with Playwright video, and converts to GIF via ffmpeg palette encoding. * * Scenes: * 1. subagent-demo.gif — terminal spawns 3 parallel agents; floating agent * windows open one by one and stream tool-call activity live (driven * through the real _onSubagentDiscovered/_onSubagentToolCall handlers). * 2. zerolag-demo.gif — side-by-side typing: instant local echo (zerolag) * vs bursty ~350 ms server echo, rendered with the vendored xterm. * * Usage: node scripts/capture-readme-gifs.mjs * SCREENSHOT_OUT_DIR=/path/to/review node scripts/capture-readme-gifs.mjs * Output: docs/images/ (or flat into SCREENSHOT_OUT_DIR) * Requires: ffmpeg */ import { chromium } from 'playwright'; import { execSync } from 'child_process'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { PORT, SESSION_IDS, STANDARD_SESSIONS, buildInitPayload, startStaticServer, setupRoutes, injectState, outPath, RST, GRN, YEL, MAG, CYN, GRY, BOLD, } from './capture-readme-screenshots.mjs'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const GIF_COLORS = 192; // ─── ffmpeg conversion (palette recipe from capture-subagent-gif.mjs) ──────── function webmToGif(videoPath, gifPath, { ss, duration, width, fps }) { // One GLOBAL palette (default stats_mode=full) + ordered dither: per-frame // palettes (stats_mode=single:new=1) make dirty rectangles visibly mismatch // on flat dark UI, and error-diffusion dither shimmers between frames. const filters = `fps=${fps},scale=${width}:-1:flags=lanczos`; execSync( `ffmpeg -y -loglevel error -ss ${ss.toFixed(2)} -t ${duration} -i "${videoPath}" ` + `-vf "${filters},split[s0][s1];[s0]palettegen=max_colors=${GIF_COLORS}:reserve_transparent=0[p];` + `[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" "${gifPath}"`, { stdio: 'inherit' } ); } // ─── Scene 1: subagent demo ────────────────────────────────────────────────── const SUBAGENT_VIEWPORT = { width: 1440, height: 810 }; // Terminal content visible before the agents spawn const TERMINAL_PRESPAWN = [ '', `${GRN}●${RST} Working on ${CYN}/home/arkon/codeman-cases/testcase${RST} - I'll use the ${BOLD}Task tool${RST} to spawn parallel agents.`, '', `${GRN}●${RST} ${BOLD}Read${RST}(/home/arkon/codeman-cases/testcase/CLAUDE.md)`, ` ${GRY}░${RST} Read ${BOLD}127${RST} lines ${GRY}│${RST} ${CYN}1.2KB${RST}`, '', `${GRN}●${RST} ${BOLD}Bash${RST}(find . -name "*.ts" -not -path "*/node_modules/*" | head -20)`, ` ${GRY}░${RST} ./src/index.ts`, ` ${GRY}░${RST} ./src/session.ts`, ` ${GRY}░${RST} ./src/web/server.ts`, ` ${GRY}░${RST} ${GRY}... (17 more)${RST}`, '', `${GRN}●${RST} I'll spawn 3 parallel research agents to analyze different parts of the codebase simultaneously.`, '', ].join('\r\n'); function makeAgent(agentId, description, startedOffsetMs) { return { agentId, sessionId: 'claude-sess-w1-0001', projectHash: 'abc123', filePath: `/tmp/${agentId}.jsonl`, startedAt: new Date(Date.now() - startedOffsetMs).toISOString(), lastActivityAt: Date.now(), status: 'active', toolCallCount: 0, entryCount: 0, fileSize: 4000, description, model: 'claude-haiku-4-5-20251001', modelShort: 'haiku', totalInputTokens: 0, totalOutputTokens: 0, parentSessionId: SESSION_IDS.w1, }; } // Timeline events: t (ms from scene start) + kind // term — write raw data to the session terminal // discover — register subagent + open + position its floating window // tool — stream a tool call into an agent window // msg — stream an assistant message into an agent window // complete — flip an agent to completed function buildSubagentTimeline() { const T = (lines) => lines.join('\r\n') + '\r\n'; const tool = (t, agentId, name, input) => ({ t, kind: 'tool', agentId, tool: name, input }); const msg = (t, agentId, text) => ({ t, kind: 'msg', agentId, text }); return [ { t: 600, kind: 'term', data: T([ `${GRN}●${RST} ${BOLD}Task${RST}(Find and document all API endpoints in src/)`, ` ${GRY}░${RST} Spawned ${CYN}agent-001${RST} ${GRY}(haiku)${RST}`, '', ]), }, { t: 1000, kind: 'discover', agent: makeAgent('agent-001', 'Find and document all API endpoints in src/', 2000), x: 440, y: 45, }, tool(1500, 'agent-001', 'Glob', { pattern: 'src/**/*.ts' }), { t: 2000, kind: 'term', data: T([ `${GRN}●${RST} ${BOLD}Task${RST}(Explore and understand test structure in test/)`, ` ${GRY}░${RST} Spawned ${CYN}agent-002${RST} ${GRY}(haiku)${RST}`, '', ]), }, tool(2200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/server.ts' }), { t: 2500, kind: 'discover', agent: makeAgent('agent-002', 'Explore and understand test structure in test/', 1200), x: 880, y: 45, }, tool(3000, 'agent-002', 'Glob', { pattern: 'test/**/*.test.ts' }), { t: 3300, kind: 'term', data: T([ `${GRN}●${RST} ${BOLD}Task${RST}(Analyze TypeScript type definitions in src/types.ts)`, ` ${GRY}░${RST} Spawned ${CYN}agent-003${RST} ${GRY}(haiku)${RST}`, '', ]), }, tool(3500, 'agent-001', 'Grep', { pattern: 'app\\.get|app\\.post|app\\.delete', path: 'src/' }), { t: 3800, kind: 'discover', agent: makeAgent('agent-003', 'Analyze TypeScript type definitions in src/types.ts', 400), x: 660, y: 400, }, tool(4100, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/respawn-test-utils.ts' }), { t: 4500, kind: 'term', data: T([ `${MAG}✻${RST} ${YEL}Waiting for agents...${RST} ${GRY}(${BOLD}esc${RST}${GRY} to interrupt · 32s · ↓ 1.7k tokens · thinking)${RST}`, '', ]), }, tool(4700, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types.ts' }), tool(5200, 'agent-001', 'Read', { file_path: '/home/arkon/codeman/src/web/schemas.ts' }), tool(5600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/config/vitest.config.ts' }), tool(6100, 'agent-003', 'Grep', { pattern: 'export (interface|type)', path: 'src/types/' }), msg(6700, 'agent-001', 'Found 47 API endpoints across server.ts. Documenting REST paths...'), tool(7100, 'agent-002', 'Grep', { pattern: 'const PORT =', path: 'test/' }), msg(7700, 'agent-002', 'Analyzing test patterns: MockSession, unique ports, fileParallelism: false...'), tool(8100, 'agent-003', 'Read', { file_path: '/home/arkon/codeman/src/types/index.ts' }), msg(8700, 'agent-003', 'Mapped 38 exported interfaces across 15 domain files. Building summary...'), { t: 9300, kind: 'term', data: T([ `${GRN}●${RST} ${CYN}agent-001${RST}: ${GRY}12 tool calls — Glob, Read(server.ts), Grep(endpoints)...${RST}`, `${GRN}●${RST} ${CYN}agent-002${RST}: ${GRY}8 tool calls — Glob, Read(test-utils), Read(vitest.config)...${RST}`, `${GRN}●${RST} ${CYN}agent-003${RST}: ${GRY}7 tool calls — Read(types.ts), Grep(interface)...${RST}`, '', ]), }, tool(10100, 'agent-001', 'Glob', { pattern: 'src/web/routes/*.ts' }), tool(10600, 'agent-002', 'Read', { file_path: '/home/arkon/codeman/test/setup.ts' }), tool(11100, 'agent-003', 'Grep', { pattern: 'assertNever', path: 'src/' }), { t: 11600, kind: 'term', data: T([`${GRN}●${RST} ${GRY}171.8k, 13s${RST} ${GRY}│${RST} ${GRY}1.7k tokens${RST} ${GRY}│${RST} ${GRY}thinking${RST}`, '']), }, ]; } const SUBAGENT_TAIL_HOLD = 2500; // hold the final frame async function recordSubagentScene(browser, videoDir) { console.log('\n1/2 Recording subagent-demo...'); const context = await browser.newContext({ viewport: SUBAGENT_VIEWPORT, deviceScaleFactor: 1, recordVideo: { dir: videoDir, size: SUBAGENT_VIEWPORT }, }); const recStart = Date.now(); const page = await context.newPage(); page.setDefaultTimeout(30000); // Start with NO subagents — they appear during the recording const initPayload = buildInitPayload(STANDARD_SESSIONS); await setupRoutes(page, initPayload, TERMINAL_PRESPAWN); await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' }); await injectState(page, initPayload, TERMINAL_PRESPAWN, SESSION_IDS.w1); await page.evaluate(() => { try { window.app?.fitAddon?.fit(); } catch {} window.app?.terminal?.scrollToBottom(); }); await sleep(500); const timeline = buildSubagentTimeline(); const totalMs = Math.max(...timeline.map((e) => e.t)) + SUBAGENT_TAIL_HOLD; const sceneStart = Date.now(); // Run the whole timeline inside the page so events interleave naturally await page.evaluate((events) => { const app = window.app; for (const ev of events) { setTimeout(() => { try { if (ev.kind === 'term') { app.terminal.write(ev.data); app.terminal.scrollToBottom(); } else if (ev.kind === 'discover') { app._onSubagentDiscovered(ev.agent); app.openSubagentWindow(ev.agent.agentId); // The spawn animation (400ms) lands on the auto-grid; glide to our tile after it setTimeout(() => { const win = app.subagentWindows.get(ev.agent.agentId); if (win?.element) { win.element.style.transition = 'left 0.25s ease, top 0.25s ease'; win.element.style.left = `${ev.x}px`; win.element.style.top = `${ev.y}px`; } }, 520); setTimeout(() => { const win = app.subagentWindows.get(ev.agent.agentId); if (win?.element) win.element.style.transition = ''; app.updateConnectionLines(); }, 850); } else if (ev.kind === 'tool') { app._onSubagentToolCall({ agentId: ev.agentId, tool: ev.tool, input: ev.input, timestamp: new Date().toISOString(), }); } else if (ev.kind === 'msg') { app._onSubagentMessage({ agentId: ev.agentId, role: 'assistant', text: ev.text, timestamp: new Date().toISOString(), }); } else if (ev.kind === 'complete') { app._onSubagentCompleted({ agentId: ev.agentId, timestamp: new Date().toISOString() }); } } catch (err) { console.error('timeline event failed', ev, err); } }, ev.t); } }, timeline); await sleep(totalMs + 500); await page.close(); const videoPath = await page.video().path(); await context.close(); return { videoPath, ss: (sceneStart - recStart) / 1000 - 0.4, duration: (totalMs + 400) / 1000, }; } // ─── Scene 2: zerolag typing comparison ────────────────────────────────────── const ZEROLAG_VIEWPORT = { width: 1280, height: 470 }; const TYPED_TEXT = 'echo "zero lag typing from anywhere"'; const TYPE_INTERVAL_MS = 110; const REMOTE_FLUSH_MS = 350; // server-echo pane flushes queued chars in bursts const ZEROLAG_TAIL_HOLD = 1800; const ZEROLAG_HTML = `