# How We Built an AI-Powered Demo Video Recorder for Jodit Editor

Recording demo videos is one of those tasks that seems simple until you actually try it. You open a screen recorder, start clicking around your app, stumble over a step, re-record, edit out the mistakes, speed up the boring parts, add a cursor highlighter... and three hours later you have a 90-second video that looks "okay."

We wanted something better for [Jodit Editor](https://xdsoft.net/jodit/). So we built a tool that lets Claude AI record demo videos autonomously — with smooth cursor animations, natural typing, and intelligent pause removal. The result? YouTube-ready videos with zero manual editing.

`youtube:https://www.youtube.com/watch?v=MVVm6GeON8I`

## The Architecture

The system has three layers:

**1. MCP Server** — A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes browser automation as tool calls. Claude AI calls tools like `click`, `type_text`, `scroll`, `screenshot` to control the browser in real-time.

**2. Playwright + Video Recording** — A headless Chromium browser records everything via Playwright's native `recordVideo` API at 1280×720. Every mouse movement, every keystroke, every DOM change is captured.

**3. Video Processor** — A post-processing pipeline that reads the action log, identifies pause segments (when Claude was "thinking"), and cuts them out using ffmpeg's `filter_complex` — frame-accurate, single-pass.

The browser launches with video recording enabled from the start:

```typescript
const beforeCtx = Date.now();
this.context = await this.browser.newContext({
  viewport: { width: 1280, height: 720 },
  recordVideo: {
    dir: this.videoDir,
    size: { width: 1280, height: 720 }
  }
});
const afterCtx = Date.now();
this._videoStartTime = Math.round((beforeCtx + afterCtx) / 2);
```

## The Cursor Problem

When an AI controls the browser, there's no physical mouse — Playwright teleports the cursor instantly. The result looks robotic and jarring.

We solved this with a **custom cursor overlay** — an SVG element injected into the page that animates along a bezier arc using `requestAnimationFrame`.

First, the easing function — fast start, gentle landing:

```javascript
function easeOutQuart(t) {
  return 1 - Math.pow(1 - t, 4);
}
```

Then the cursor animation itself. It computes a quadratic bezier control point perpendicular to the movement direction, creating a natural arc instead of a straight line:

```javascript
moveCursor(x, y, durationMs) {
  return new Promise(resolve => {
    const startX = curX, startY = curY;
    const dx = x - startX, dy = y - startY;
    const dist = Math.sqrt(dx * dx + dy * dy);
    const startTime = performance.now();

    // Quadratic bezier control point — perpendicular arc offset
    const arcAmount = Math.min(dist * 0.15, 60);
    const perpX = dist > 1 ? -dy / dist : 0;
    const perpY = dist > 1 ?  dx / dist : 0;
    const sign = (dx * dy > 0) ? 1 : -1;
    const cpX = (startX + x) / 2 + perpX * arcAmount * sign;
    const cpY = (startY + y) / 2 + perpY * arcAmount * sign;

    function step(now) {
      const t = easeOutQuart(Math.min((now - startTime) / durationMs, 1));

      // Quadratic bezier: B(t) = (1-t)²·P0 + 2(1-t)t·CP + t²·P1
      const mt = 1 - t;
      curX = mt*mt*startX + 2*mt*t*cpX + t*t*x;
      curY = mt*mt*startY + 2*mt*t*cpY + t*t*y;
      cursor.style.left = curX + 'px';
      cursor.style.top  = curY + 'px';

      if (t < 1) requestAnimationFrame(step);
      else resolve();
    }
    requestAnimationFrame(step);
  });
}
```

Key design decisions:

- **Bezier arc**, not a straight line — the perpendicular offset alternates direction for visual variety
- **Distance-based duration**: 400ms for short moves, up to 1200ms for long ones
- **`easeOutQuart`**: fast acceleration, smooth deceleration — feels like a real hand
- **Click pulse**: a CSS ripple animation on click so viewers see exactly where it lands

The Playwright mouse then moves to the same coordinates for actual browser events:

```typescript
async function smoothMove(page, target) {
  // Animate overlay cursor (visual, for video)
  await page.evaluate(
    ({ x, y, ms }) => window.__auto?.moveCursor(x, y, ms),
    { x: target.x, y: target.y, ms: duration }
  );
  // Teleport real mouse (for browser interaction)
  await page.mouse.move(target.x, target.y);
}
```

## Intelligent Pause Detection

The video records continuously, but Claude spends most of its time *thinking*. We don't want that dead time in the final video.

A **DOM MutationObserver** is injected into the page and signals Node.js through Playwright's `exposeFunction`:

```javascript
// Injected into the browser page
const observer = new MutationObserver(function() {
  if (isIdle) {
    isIdle = false;
    window.__autoOnDomActivity(true);  // → Node.js: resume
  }
  clearTimeout(debounceTimer);
  debounceTimer = setTimeout(function() {
    isIdle = true;
    window.__autoOnDomActivity(false); // → Node.js: pause
  }, 500);
});

observer.observe(document.body, {
  childList: true, subtree: true,
  attributes: true, characterData: true
});
```

On the Node.js side, the callback logs pause/resume events:

```typescript
await page.exposeFunction('__autoOnDomActivity', (active: boolean) => {
  if (active && state.paused) {
    state.paused = false;
    actionLogger.logToolCall('resume_recording',
      { auto: true, source: 'dom-observer' }, { ok: true });
  } else if (!active && !state.paused) {
    state.paused = true;
    actionLogger.logToolCall('pause_recording',
      { auto: true, source: 'dom-observer' }, { ok: true });
  }
});
```

### The CSS-transform blind spot

There's a catch: cursor movement updates `element.style.left/top` — but the `MutationObserver` might not fire in time before our code logs the resume. We mark cursor tools as "visual-no-mutation" and resume recording explicitly before they execute:

```typescript
const VISUAL_NO_MUTATION = new Set([
  'scroll', 'sleep', 'click', 'double_click', 'move_to'
]);

// In the MCP tool handler:
if (VISUAL_NO_MUTATION.has(name) && state.paused) {
  state.paused = false;
  actionLogger.logToolCall('resume_recording',
    { auto: true, source: `before-${name}` }, { ok: true });
}
const result = await tool.handler(args);
```

## Video Processing Pipeline

After recording, `actions.jsonl` contains every pause/resume with timestamps. We consolidate and feed them to ffmpeg.

### Step 1: Consolidate pauses

```typescript
static consolidatePauses(pauses, minPauseDuration = 0.5, minGap = 0.5) {
  // Drop observer flicker (< 0.5s pauses)
  const significant = pauses.filter(p => p.end - p.start >= minPauseDuration);

  // Merge adjacent pauses with tiny active gaps between them
  const merged = [{ ...significant[0] }];
  for (let i = 1; i < significant.length; i++) {
    const last = merged[merged.length - 1];
    const gap = significant[i].start - last.end;
    if (gap < minGap) {
      last.end = significant[i].end; // absorb
    } else {
      merged.push({ ...significant[i] });
    }
  }
  return merged;
}
```

This reduces 90+ raw pauses to ~70 clean ones, preventing cursor-jump artifacts.

### Step 2: Single-pass ffmpeg filter

```typescript
// Build one trim per active segment, concat them all
ranges.forEach((seg, i) => {
  filterParts.push(
    `[0:v]trim=start=${seg.start}:end=${seg.end},setpts=PTS-STARTPTS[v${i}]`
  );
  concatParts.push(`[v${i}]`);
});
filterParts.push(
  `${concatParts.join('')}concat=n=${ranges.length}:v=1:a=0[outv]`
);

await ffmpeg(['-i', inputVideo, '-filter_complex', filter,
  '-map', '[outv]', '-c:v', 'libx264', '-preset', 'fast',
  '-crf', '23', '-y', outputPath]);
```

One decode, N trims, one concat. Frame-accurate and fast.

## The Timing Offset

We discovered that Playwright's video pipeline introduces a consistent **~0.8 second offset** between `Date.now()` in Node.js and the actual video PTS. Cursor animations would start during what we marked as a "pause" and get cut.

The fix: shift all pause boundaries by a compensation offset:

```typescript
// Apply offset: negative = shift boundaries earlier
for (const p of pauses) {
  p.start = Math.max(0, p.start + offset);
  p.end   = Math.max(0, p.end   + offset);
}
```

```bash
npx tsx src/process-video.ts <session-dir>           # default -0.8s
npx tsx src/process-video.ts <session-dir> --offset -1.0  # custom
```

## The Demo Script

The demo is orchestrated by Claude via MCP tool calls. A `/record-demo` command describes what to do:

> Ask AI to write an article about baobabs. Add a placeholder image, subheadings, bold key terms, Wikipedia links, dividers, rounded corners...

Claude translates this into:

```
click('[data-ref="aiAssistantPro"]')     → open AI panel
click('[data-ref="message"]')            → focus input
type_text('Write a short article...')    → type the prompt
click('.jodit-ui-button_send_message')   → send
wait_for_idle('.jodit-wysiwyg')          → wait for AI to finish
scroll({ scrollTo: 'img' })             → check result
```

Every action is recorded. Every pause is cut. The final video shows only the interesting parts.

## What We Learned

**Cursor animations matter more than you think.** Without smooth bezier movement, even perfect content looks robotic.

**DOM observers are imperfect clocks.** CSS property changes on overlay elements need explicit handling — `MutationObserver` might not fire in time.

**Video timing is not trivial.** WebM keyframes every 5.12 seconds mean `-ss` seeking loses frames. The only reliable method is `trim` in `filter_complex`. And Playwright's pipeline offset (~0.8s) must be measured and compensated.

**Less is more for formatting.** "Bold only 3-4 key terms" and "turn some into Wikipedia links" produces much cleaner results than "bold all important words."

The key insight: **let the AI be the director, not just the actor.** Claude reads the screen, decides what looks good, asks for revisions, and keeps going until the result is polished. That's what makes these demos feel natural despite being fully automated.

_Full page: https://xdsoft.net/blog/ai-powered-demo-video-recorder_
