---
name: record-demo-video
description: Use when recording a demo video for a feature, PR, ticket, or QA walkthrough. Also use when asked to create a video, screen recording, or visual demo of a feature. Triggered by "record video", "demo video", "screen recording", "walkthrough video".
---

# Record Demo Video

Record professional demo videos of features using Playwright system tests with visual effects (scene titles, highlights, banners) and optional TTS narration. Videos are auto-saved as 1080p mp4.

## Voice Configuration

Before generating narration, check for a voice preference in `~/.claude/skills/record-demo-video/config.yml`:

```yaml
voice: en-GB-RyanNeural
```

If the file does not exist, use `en-GB-RyanNeural` as the default and create the file.

If the user says anything like "use a different voice", "change the voice", "switch to female", or names a voice — update `config.yml` with the new voice before generating clips.

When the user asks what voices are available, run:
```bash
edge-tts --list-voices | grep "en-" | awk -F'|' '{print $1, $2}' | sort
```

**Recommended voices for Admissions+ (UK product):**

| Voice | Gender | Character |
|---|---|---|
| `en-GB-RyanNeural` | Male | Professional (default) |
| `en-GB-ThomasNeural` | Male | Alternative British male |
| `en-GB-SoniaNeural` | Female | Professional British female |
| `en-GB-LibbyNeural` | Female | Friendly British female |
| `en-US-AndrewNeural` | Male | Warm, confident American |
| `en-US-EmmaNeural` | Female | Cheerful, clear American |
| `en-AU-NatashaNeural` | Female | Australian female |
| `en-IE-ConnorNeural` | Male | Irish male |

**To preview a voice before using it:**
```bash
edge-tts --voice en-GB-SoniaNeural --text "Welcome to Admissions Plus." --write-media /tmp/preview.mp3 && afplay /tmp/preview.mp3
```

---

## When to Use

- Recording a demo for a Jira ticket, PR, or SpecHub story
- Creating a QA walkthrough video
- Producing visual evidence of a feature working
- User asks to "record a video", "make a demo", "screen recording"

## Architecture

Uses Rails system tests (Minitest + Capybara + Playwright) with `VIDEO=true`. This approach:
- Handles auth via Devise `sign_in` (no password issues with dev/test databases)
- Spins up its own Puma server **from the current branch code** (not the dev server)
- Records via Playwright's built-in `recordVideo`
- Auto-converts to mp4 via ffmpeg

**Why not standalone Playwright scripts:** Rails AJAX login forms (`remote: true`) don't work reliably with standalone Playwright — the session cookie from `fetch`/`page.evaluate` doesn't persist across page navigations, and AJAX form responses do JS redirects that Playwright can't follow. System tests bypass this entirely via Devise test helpers.

**Why not the dev server:** The `bin/dev` server runs from whatever branch is checked out in the main repo, which may not have your feature changes. System tests spin up their own Puma from the current worktree.

## Quick Reference

| Command | Purpose |
|---------|---------|
| `VIDEO=true bundle exec rails test test/system/demos/TICKET_demo_test.rb -v` | Record (headless) |
| `VIDEO=true HEADLESS=false bundle exec rails test test/system/demos/TICKET_demo_test.rb -v` | Record + watch live |
| `SCREENSHOT=true bundle exec rails test test/system/demos/TICKET_demo_test.rb -v` | Screenshots only |

Videos saved to: `tmp/videos/`
Screenshots saved to: `tmp/screenshots/`

## Full Pipeline (Video + Narration)

```
Step 1: Plan scenes and narration text
Step 2: Generate narration clips (edge-tts) → get clip durations
Step 3: Write system test using clip durations for sleep timing
Step 4: Record video (VIDEO=true) → test logs real timestamps
Step 5: Merge narration at real timestamps (ffmpeg)
```

## Step 1: Plan Scenes

Before writing any code, plan the video as numbered scenes with narration text:

```
Scene 1: [Setup] — "Staff impersonates a student account"
Scene 2: [Action] — "Open the profile edit form and observe the changes"
Scene 3: [Result] — "The email was updated successfully without requiring a password"
Scene 4: [Comparison] — "Student self-service still requires the current password"
```

## Step 2: Generate Narration Clips

Use `edge-tts` (free Microsoft TTS, no API key needed). Install: `pip3 install --break-system-packages edge-tts`

Generate clips and measure durations with `scripts/add_narration.py generate`. This outputs a `narration_timing.json` with each clip's duration.

**Voice options:** `edge-tts --list-voices | grep "en-" | grep "Male"` (or Female)

Good voices:
- `en-GB-RyanNeural` — British male (professional)
- `en-GB-ThomasNeural` — British male (alternative)
- `en-US-GuyNeural` — American male
- `en-GB-SoniaNeural` — British female

**Speed:** Use `+30%` for 1.3x speed. Adjust `RATE` in the script.

**Volume:** Controlled by ffmpeg during merge. Adjust `volume=2.0` filter in the merge script. Default 2.0x (TTS audio is quiet by default).

## Step 3: Write the System Test

Create test at `test/system/demos/TICKET_demo_test.rb`. Use the shared `DemoVideoHelpers` module at `test/support/demo_video_helpers.rb`.

### Critical: Narration-Driven Timing

**The narration controls the pacing, not arbitrary sleep values.** Each visual element stays on screen for exactly as long as its narration clip lasts.

```ruby
# Load clip durations from narration_timing.json
@clip_durations = load_clip_durations

# Scene title stays visible for its narration duration
narrate("intro")
scene("Feature Title", "Subtitle", duration: clip_dur("intro"))

# Highlight stays visible for its narration duration
narrate("scene3_gdpr")
highlight(".alert-danger", "GDPR Warning")
sleep clip_dur("scene3_gdpr") + 0.3
clear_highlight
```

**The `narrate()` method logs the real wall-clock timestamp** when each narration point is reached. These timestamps are saved to `scene_timestamps.json` and used by the merge script to place narration clips at the exact right moment.

### Timestamp Logging Pattern

```ruby
setup do
  @timestamps = []
  @start_time = nil
  @clip_durations = load_clip_durations
end

teardown do
  File.write(TIMESTAMP_FILE, JSON.pretty_generate(@timestamps))
end

def narrate(clip_id)
  elapsed = Time.now.to_f - @start_time
  @timestamps << { id: clip_id, timestamp: elapsed.round(2) }
end

def clip_dur(clip_id)
  @clip_durations[clip_id] || 3
end
```

### Loading Transitions

Wrap page navigations in `loading_transition` to hide blank pages during loading:

```ruby
loading_transition { visit "/users/edit" }
loading_transition { demo_stop_impersonating }
loading_transition do
  sign_in(@student)
  visit "/users/edit"
end
```

This shows a dark overlay with a spinner during navigation, preventing blank white frames in the video.

### Test Template

```ruby
require "application_system_test_case"
require_relative "../../support/demo_video_helpers"
require "json"

class Demos::TicketDemoTest < ApplicationSystemTestCase
  include DemoVideoHelpers

  TIMING_FILE = Rails.root.join("tmp/videos/narration_timing.json")
  TIMESTAMP_FILE = Rails.root.join("tmp/videos/scene_timestamps.json")

  setup do
    setup_teacher_base
    @student = Student.find_by(email: "demostudent@ulas.co.uk")
    @original_email = @student.email
    @timestamps = []
    @start_time = nil
    @clip_durations = load_clip_durations
  end

  teardown do
    @student.reload
    @student.update_columns(email: @original_email) if @student.email != @original_email
    File.write(TIMESTAMP_FILE, JSON.pretty_generate(@timestamps))
  end

  test "demo: TICKET feature walkthrough" do
    @start_time = Time.now.to_f

    narrate("intro")
    scene("Feature Title", "Subtitle", duration: clip_dur("intro"))

    narrate("scene1")
    scene("Scene 1", "Description", duration: clip_dur("scene1"))
    # ... actions ...

    narrate("scene1_result")
    banner("Result", "#27ae60")
  end

  private

  def load_clip_durations
    return {} unless File.exist?(TIMING_FILE)
    JSON.parse(File.read(TIMING_FILE)).each_with_object({}) { |e, h| h[e["id"]] = e["duration"] }
  end

  def clip_dur(id)
    @clip_durations[id] || 3
  end

  def narrate(id)
    elapsed = Time.now.to_f - @start_time
    @timestamps << { id: id, timestamp: elapsed.round(2) }
  end
end
```

## Step 4: Record

```bash
which ffmpeg || brew install ffmpeg
RAILS_ENV=test rails db:test:prepare
VIDEO=true bundle exec rails test test/system/demos/TICKET_demo_test.rb -v
```

Copy the output: `cp tmp/videos/ClassName_test_name_TIMESTAMP.mp4 tmp/videos/TICKET-demo-final.mp4`

## Step 5: Merge Narration

```bash
python3 scripts/add_narration.py merge
```

This reads `scene_timestamps.json` (real timestamps from the test run) and places each narration clip at the exact moment it was triggered in the video. Output: `tmp/videos/TICKET-demo-narrated.mp4`

## HD Video Setup

Ensure `record_video_size` is set in `test/application_system_test_case.rb`:

```ruby
Capybara.register_driver :playwright_chromium do |app|
  Capybara::Playwright::Driver.new(
    app,
    browser_type: :chromium,
    headless: ENV["HEADLESS"] != "false",
    screen: { width: 1920, height: 2160 },
    record_video_dir: (Rails.root.join("tmp/videos").to_s if ENV["VIDEO"] == "true"),
    record_video_size: (ENV["VIDEO"] == "true" ? { width: 1920, height: 1080 } : nil)
  )
end
```

Without `record_video_size`, Playwright defaults to a scaled-down resolution (~800x450).

## Visual Effects Reference

| Helper | Purpose | Duration |
|--------|---------|----------|
| `scene(title, subtitle, duration:)` | Full-screen title card with dark overlay | `duration` param (default 2.5s) + 0.6s fade |
| `highlight(selector, label)` | Red border + label on element, dims rest of page | Stays until `clear_highlight` |
| `clear_highlight` | Remove highlight | Instant |
| `banner(text, color)` | Bottom-center notification banner | 2.5s visible + 0.6s fade |
| `slow_type(selector, text)` | Type text character by character | ~0.05s per char |
| `demo_impersonate_user(user)` | POST impersonation form via JS | 2s wait |
| `demo_stop_impersonating` | Stop impersonation | Instant |
| `loading_transition { block }` | Dark overlay during navigation | Hides blank pages |
| `clear_overlays` | Remove all visual effects | Instant |

### Banner Colors

| Color | Hex | Use for |
|-------|-----|---------|
| Green | `#27ae60` | Success, confirmation |
| Red | `#e74c3c` | Warning, blocked action |
| Blue | `#3498db` | Info, status change |
| Orange | `#f39c12` | Caution, attention |

## Lessons Learned / Common Mistakes

### Voice and scene timing overlap
**Problem:** Narration clips are longer than the visual element's display time, so voices overlap.
**Fix:** Let narration durations drive the sleep timing. Use `clip_dur("clip_id")` as the sleep value, not arbitrary numbers. Generate clips first, then write the test.

### Blank white pages during navigation
**Problem:** `visit "/path"` shows a blank page while the server responds.
**Fix:** Wrap navigations in `loading_transition { ... }` which shows a dark overlay with a spinner.

### Narration drifts from visuals
**Problem:** Narration timestamps are calculated sequentially, but actual video has variable page-load times.
**Fix:** Log real wall-clock timestamps during the test run with `narrate()`, then use those in the merge script. Never hardcode narration timestamps.

### Low resolution video (800x450)
**Problem:** Playwright's `recordVideo` defaults to a small resolution.
**Fix:** Add `record_video_size: { width: 1920, height: 1080 }` to the Playwright driver config.

### Dev server has different code than current branch
**Problem:** `bin/dev` runs from the main repo checkout, not your worktree. Video shows old behavior.
**Fix:** System tests spin up their own Puma with the current branch code. Never use standalone scripts against the dev server for feature demos.

### AJAX login forms don't work in standalone Playwright
**Problem:** Rails `remote: true` forms submit via AJAX. The JS response does `window.location.href = "/"`. Playwright can't follow this redirect, and `fetch()` inside `page.evaluate` doesn't share session cookies with page navigation.
**Fix:** Use system tests with Devise `sign_in` helper. Avoid standalone Playwright scripts for Rails apps with AJAX auth.

### Test timeout with long narration waits
**Problem:** Adding `wait_for_narration` calls everywhere makes the test too long, hitting Capybara timeouts.
**Fix:** Use `scene(duration: clip_dur("id"))` so the scene title display time IS the narration wait. Don't double-sleep with both `scene()` and `wait_for_narration()` — use one or the other.

## Troubleshooting

| Issue | Fix |
|-------|-----|
| No video output | Ensure `VIDEO=true` env var is set |
| Video is webm not mp4 | Install ffmpeg: `brew install ffmpeg` |
| Auth fails | System tests use Devise `sign_in`, not the login form |
| Element not found | Add `sleep` before interaction, check selector with `HEADLESS=false` |
| Video too fast | Use `clip_dur()` for sleep values, or increase narration pad |
| Narration overlaps | Check `python3 add_narration.py merge` output for overlap warnings |
| Test times out | Reduce unnecessary sleeps, use `scene(duration:)` instead of separate `wait_for_narration` |
| Narration too quiet | Increase `volume` filter in merge script (default 2.0x). TTS audio is quiet by default |
