Skill: xiazai77/product-demo-video (version 1.0.0)

--- name: product-demo-video description: > Create product demo videos with voiceover, text overlays, and real browser interactions. Fully automated, zero cost. Uses Puppeteer (headless Chrome), edge-tts (Microsoft Neural TTS), PIL (text overlays), and FFmpeg (video encoding). Use when: user wants a demo video, product walkthrough, launch video, Product Hunt video, app showcase, or screen recording of a web application. Triggers: "demo video", "product video", "record demo", "launch video", "walkthrough video", "showcase video", "screen recording". --- # Product Demo Video Creator Create polished demo videos with voiceover and text overlays — fully automated, zero cost. ## Stack | Tool | Purpose | Install | |------|---------|---------| | Puppeteer | Headless browser recording | `npm i -g puppeteer` | | edge-tts | Microsoft Neural TTS (free) | `pip3 install edge-tts` | | PIL/Pillow | Text overlays on frames | `pip3 install Pillow` (usually pre-installed) | | FFmpeg | Video encoding | Static build from johnvansickle.com or package manager | | Chromium | Browser engine | System package | ## Quick Start 1. Install dependencies (see `scripts/install-deps.sh`) 2. Define scenes in `scripts/record-demo.mjs` (copy template, customize) 3. Run: `node scripts/record-demo.mjs` 4. Output: MP4 with voiceover + text overlays ## Workflow ``` Define Scenes → Generate Voiceover → Record Browser → Add Text Overlays → Compile Video ↓ ↓ ↓ ↓ ↓ scenes[] edge-tts MP3 Puppeteer frames PIL drawtext FFmpeg concat ``` ### Step 1: Define Scenes Each scene has: `id`, `title`, `subtitle`, `narration`, `url`, `type`, `actions`. ```javascript { id: 'json', title: 'JSON Formatter', subtitle: 'Paste messy JSON, get formatted output', narration: 'Paste any messy JSON and get it formatted instantly.', url: 'https://example.com/json-formatter/', type: 'tool', // 'intro' | 'tool' | 'outro' actions: async (page) => { await reactSetValue(page, 'textarea', '{"key":"value"}'); await wait(1000); await clickButton(page, ['Format']); await wait(2000); } } ``` ### Step 2: Scene Types and Overlay Positioning | Type | Overlay Position | Use For | |------|-----------------|---------| | `intro` | Bottom bar (centered, large title) | Opening scene with product name | | `tool` | Bottom bar (left-aligned title + right badge) | Individual feature demos | | `outro` | Bottom bar (centered CTA) | Closing with call-to-action | **Critical: Use bottom bar, not top bar** — top overlays conflict with website navigation. ### Step 3: React App Interaction React controlled components ignore direct `.value` assignment. Use `reactSetValue()`: ```javascript async function reactSetValue(page, selector, value) { await page.evaluate((sel, val) => { const el = document.querySelector(sel); const setter = Object.getOwnPropertyDescriptor( el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype, 'value' )?.set; if (setter) setter.call(el, val); else el.value = val; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, selector, value); } ``` ### Step 4: Voiceover ```bash edge-tts --voice en-US-AndrewNeural --rate=+5% --text "Your narration" --write-media output.mp3 ``` **Recommended voices:** - `en-US-AndrewNeural` — Male, warm, confident (best for product demos) - `en-US-AriaNeural` — Female, positive, confident - `en-US-BrianNeural` — Male, approachable, casual ### Step 5: Text Overlays PIL adds text to frames. Key design rules: - **Solid dark background** (alpha ≥ 230) — semi-transparent looks cheap - **Green accent line** (2px) separating bar from content - **"100% Client-Side"** or equivalent badge in green (#4ade80) - **Font**: Noto Sans Bold for titles, Regular for subtitles ### Step 6: Video Compilation ```bash # Frames to video per scene ffmpeg -framerate 6 -i frames/frame_%05d.png -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -r 24 scene.mp4 # Add audio ffmpeg -i scene.mp4 -i narration.mp3 -c:v copy -c:a aac -b:a 128k -shortest scene_final.mp4 # Normalize for concatenation ffmpeg -i scene_final.mp4 -c:v libx264 -preset slow -crf 20 -r 24 -c:a aac -b:a 128k -ar 44100 -ac 2 scene_norm.mp4 # Concatenate ffmpeg -f concat -safe 0 -i concat.txt -c copy output.mp4 ``` ## Timing Guidelines | Action | Wait Time | |--------|-----------| | Page load | 600ms | | After setting input value | 800-1000ms | | After clicking action button | 2000-2500ms | | Between password generations | 1500ms | | QR code / image rendering | 2500ms | | Show final result | 2000-3000ms | **Scene duration** = max(8s, ceil(audio_duration) + 2s) ## Capture Settings | Setting | Value | Notes | |---------|-------|-------| | Resolution | 1280×720 | Standard for PH/social | | Capture FPS | 6 | Balance quality/performance | | Output FPS | 24 | Smooth playback | | CRF | 20 | Good quality | | JPEG quality | N/A (PNG frames) | Lossless capture | ## Troubleshooting - **Empty tool outputs**: React apps need `reactSetValue()`, not `.value =` - **Dropdown menus open**: Click `body` after interactions to dismiss - **FFmpeg "No such filter: drawtext"**: Static FFmpeg builds lack it — use PIL instead - **edge-tts fails**: Check network; it calls Microsoft servers - **Chromium won't start**: Need `--no-sandbox --disable-gpu --disable-dev-shm-usage` ## References - `references/demo-planning.md` — Demo structure, pacing, what makes demos compelling - `scripts/record-demo.mjs` — Complete working template (customize scenes for your app) - `scripts/overlay.py` — PIL text overlay processor - `scripts/install-deps.sh` — One-command dependency setup

path: _meta.json
content: slug: product-demo-video
displayName: Product Demo Video Creator
license: MIT
acceptLicenseTerms: yes

path: references/demo-planning.md
content: # Demo Planning Guide

## Demo Structure

### 1. Hook (5-10s)
Open with product name, tagline, and core value prop. Show the homepage.

### 2. Core Tools (30-60s)
Show 4-6 key features, each 6-12 seconds:
- Navigate to tool → Input data → Show output → Next
- **Always show output** — empty states kill demos

### 3. Closing CTA (5-8s)
Scroll homepage to show breadth, end with URL and tagline.

**Total: 45-75 seconds** (PH sweet spot: under 60s)

## Timing Rules

| Action | Wait (ms) |
|--------|-----------|
| Page load settle | 600 |
| After setting input | 800-1000 |
| After clicking button | 2000-2500 |
| Complex rendering (QR, charts) | 2500 |
| Show final result | 2000-3000 |

**Scene duration** = max(8s, ceil(narration_audio) + 2s)

## Narration Tips

- **One sentence per concept** — don't cram
- **Spell out abbreviations** for TTS: "S H A 256" not "SHA-256"
- **End each scene's narration before the scene ends** — silence at end is fine
- **Keep total narration under 2 min** — viewers drop off fast

## Common Mistakes

- ❌ Showing empty/placeholder outputs (tool looks broken)
- ❌ Top text overlays that overlap with website navigation
- ❌ Semi-transparent backgrounds (use alpha ≥ 230)
- ❌ Setting React input `.value` directly (use reactSetValue)
- ❌ Not waiting long enough after button clicks
- ❌ Leaving dropdown menus open when capturing
- ❌ Using FFmpeg drawtext filter with static builds (use PIL)

## Voice Selection (edge-tts)

```bash
# List all voices
edge-tts --list-voices

# Test a voice
edge-tts --voice en-US-AndrewNeural --text "Hello world" --write-media test.mp3
```

| Voice | Gender | Tone | Best For |
|-------|--------|------|----------|
| en-US-AndrewNeural | Male | Warm, confident | Product demos |
| en-US-BrianNeural | Male | Casual, sincere | Dev tool demos |
| en-US-AriaNeural | Female | Positive, confident | SaaS demos |
| en-US-AvaNeural | Female | Friendly, caring | Consumer apps |

## Overlay Design

### Color Scheme
- Bar background: `(10, 10, 10, 235)` — near-black, solid
- Accent line: `(74, 222, 128)` — green, 2px
- Title: white
- Subtitle: white @ 85% opacity
- Badge text: green `#4ade80`

### Layout
- Bottom bar height: 75px (tools), 150-170px (intro/outro)
- Title font: 36px bold (tools), 44px bold (intro)
- Subtitle: 18px regular
- Badge: 16px regular, right-aligned (tools only)


path: scripts/install-deps.sh
content: #!/bin/bash
# Install all dependencies for product demo video creation
set -e

echo "📦 Installing dependencies..."

# Puppeteer (headless Chrome automation)
npm i -g puppeteer 2>/dev/null && echo "✅ Puppeteer" || echo "⚠️ Puppeteer (may already exist)"

# edge-tts (Microsoft Neural TTS - free)
pip3 install edge-tts 2>/dev/null && echo "✅ edge-tts" || echo "⚠️ edge-tts"

# Pillow (text overlays)
pip3 install Pillow 2>/dev/null && echo "✅ Pillow" || echo "⚠️ Pillow (may already exist)"

# FFmpeg (static build if not available)
if ! command -v ffmpeg &>/dev/null; then
  echo "📥 Downloading FFmpeg static build..."
  curl -sL https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o /tmp/ffmpeg.tar.xz
  tar xf /tmp/ffmpeg.tar.xz -C /tmp/
  cp /tmp/ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/
  cp /tmp/ffmpeg-*-amd64-static/ffprobe /usr/local/bin/
  rm -rf /tmp/ffmpeg.tar.xz /tmp/ffmpeg-*-amd64-static
  echo "✅ FFmpeg"
else
  echo "✅ FFmpeg (already installed)"
fi

# Fonts for text overlays
if ! fc-list | grep -qi "noto.*sans.*bold" 2>/dev/null; then
  if command -v dnf &>/dev/null; then
    dnf install -y google-noto-sans-fonts 2>/dev/null && echo "✅ Noto Sans fonts"
  elif command -v apt-get &>/dev/null; then
    apt-get install -y fonts-noto-core 2>/dev/null && echo "✅ Noto Sans fonts"
  fi
else
  echo "✅ Noto Sans fonts (already installed)"
fi

# Chromium
if ! command -v chromium-browser &>/dev/null && ! command -v google-chrome &>/dev/null; then
  echo "⚠️ Chromium/Chrome not found. Install manually:"
  echo "   dnf install -y chromium  # RHEL/Rocky"
  echo "   apt install -y chromium  # Debian/Ubuntu"
else
  echo "✅ Chromium"
fi

echo ""
echo "🎬 All dependencies ready!"


path: scripts/record-demo.mjs
content: #!/usr/bin/env node
/**
 * Product Demo Video Recorder
 * 
 * Records browser interactions with voiceover and text overlays.
 * Customize SCENES array for your app.
 * 
 * Usage: node record-demo.mjs
 * 
 * Prerequisites: Run install-deps.sh first
 */

import { createRequire } from 'module';
const require = createRequire(import.meta.url);

// Try global puppeteer, fallback to local
let puppeteer;
try { puppeteer = require('puppeteer'); } catch(e) {
  try { puppeteer = require('/usr/lib/node_modules/puppeteer'); } catch(e2) {
    console.error('❌ Puppeteer not found. Run: npm i -g puppeteer');
    process.exit(1);
  }
}

import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';

// ==================== CONFIGURATION ====================

const CONFIG = {
  width: 1280,
  height: 720,
  captureFps: 6,         // Frames per second during capture
  outputFps: 24,         // Output video frame rate
  crf: 20,               // Video quality (lower = better, 18-23 recommended)
  voice: 'en-US-AndrewNeural',  // edge-tts voice
  voiceRate: '+5%',      // Speech rate adjustment
  chromePath: '/usr/bin/chromium-browser',  // Adjust for your system
  workDir: '/tmp/demo-video-work',
  output: './demo-video.mp4',
  fontBold: '/usr/share/fonts/google-noto/NotoSans-Bold.ttf',
  fontReg: '/usr/share/fonts/google-noto/NotoSans-Regular.ttf',
};

// ==================== SCENES ====================
// Customize this array for YOUR app

const SCENES = [
  {
    id: 'intro',
    title: 'YourApp.dev',
    subtitle: 'Your tagline here',
    badge: 'Your data never leaves your device',
    narration: 'Welcome to YourApp. A brief description of what your product does.',
    url: 'https://yourapp.dev/',
    type: 'intro',  // 'intro' | 'tool' | 'outro'
    actions: async (page) => {
      await page.waitForSelector('h1', { timeout: 10000 });
    }
  },
  {
    id: 'feature1',
    title: 'Feature Name',
    subtitle: 'What this feature does in one line',
    narration: 'Describe this feature and why it matters.',
    url: 'https://yourapp.dev/feature1/',
    type: 'tool',
    actions: async (page) => {
      // Example: type into a React input and click a button
      await reactSetValue(page, 'textarea', 'sample input data');
      await wait(1000);
      await clickButton(page, ['Submit', 'Run', 'Generate']);
      await wait(2500);
    }
  },
  {
    id: 'outro',
    title: 'Try YourApp Today',
    subtitle: 'yourapp.dev  |  Free  |  No Sign-up',
    narration: 'Visit yourapp.dev and try it yourself. Its free and always will be.',
    url: 'https://yourapp.dev/',
    type: 'outro',
    actions: async (page) => {
      for (let i = 0; i < 4; i++) {
        await page.evaluate(() => window.scrollBy(0, 300));
        await wait(400);
      }
      await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' }));
      await wait(500);
    }
  }
];

// ==================== HELPERS ====================

async function wait(ms) { return new Promise(r => setTimeout(r, ms)); }

// React-compatible value setter — triggers React's internal onChange
async function reactSetValue(page, selector, value) {
  await page.evaluate((sel, val) => {
    const el = document.querySelector(sel);
    if (!el) return;
    const setter = Object.getOwnPropertyDescriptor(
      el.tagName === 'TEXTAREA'
        ? window.HTMLTextAreaElement.prototype
        : window.HTMLInputElement.prototype,
      'value'
    )?.set;
    if (setter) setter.call(el, val);
    else el.value = val;
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }, selector, value);
}

async function clickButton(page, texts) {
  const buttons = await page.$$('button');
  for (const btn of buttons) {
    const text = await btn.evaluate(el => el.textContent?.trim());
    if (text && texts.some(t => text.includes(t))) {
      await btn.click();
      return true;
    }
  }
  return false;
}

// ==================== MAIN ====================

async function main() {
  const { workDir, output, voice, voiceRate, captureFps, outputFps, crf,
          width, height, chromePath, fontBold, fontReg } = CONFIG;
  const audioDir = `${workDir}/audio`;
  
  fs.mkdirSync(audioDir, { recursive: true });

  // ===== STEP 1: Generate voiceover =====
  console.log('🎙️  Generating voiceover...');
  for (const s of SCENES) {
    const audioPath = `${audioDir}/${s.id}.mp3`;
    execSync(`edge-tts --voice ${voice} --rate=${voiceRate} --text "${s.narration.replace(/"/g, '\\"')}" --write-media ${audioPath}`);
    const dur = parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${audioPath}`).toString().trim());
    s.audioDuration = dur;
    s.duration = Math.max(8, Math.ceil(dur) + 2);
    console.log(`  ${s.id}: audio=${dur.toFixed(1)}s, scene=${s.duration}s`);
  }

  // ===== STEP 2: Record browser scenes =====
  console.log('\n📹 Recording scenes...');
  const browser = await puppeteer.launch({
    headless: true,
    executablePath: chromePath,
    args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
  });
  const page = await browser.newPage();
  await page.setViewport({ width, height });

  for (const s of SCENES) {
    console.log(`  Recording: ${s.id} (${s.duration}s)...`);
    const dir = `${workDir}/frames_${s.id}`;
    fs.mkdirSync(dir, { recursive: true });
    try { fs.readdirSync(dir).forEach(f => fs.unlinkSync(`${dir}/${f}`)); } catch(e) {}

    await page.goto(s.url, { waitUntil: 'networkidle2', timeout: 30000 });
    await wait(600);
    await s.actions(page);
    await wait(300);

    const totalFrames = s.duration * captureFps;
    for (let f = 0; f < totalFrames; f++) {
      await page.screenshot({ path: `${dir}/frame_${String(f).padStart(5, '0')}.png` });
    }
  }
  await browser.close();

  // ===== STEP 3: Add text overlays with PIL =====
  console.log('\n✍️  Adding text overlays...');
  const pyScript = generateOverlayScript(SCENES, workDir, captureFps, fontBold, fontReg);
  fs.writeFileSync(`${workDir}/overlay.py`, pyScript);
  execSync(`python3 ${workDir}/overlay.py`, { stdio: 'inherit' });

  // ===== STEP 4: Compile each scene with audio =====
  console.log('\n🎬 Compiling scenes...');
  for (const s of SCENES) {
    execSync(`ffmpeg -y -framerate ${captureFps} -i ${workDir}/overlay_${s.id}/frame_%05d.png -c:v libx264 -preset slow -crf ${crf} -pix_fmt yuv420p -r ${outputFps} ${workDir}/${s.id}_video.mp4 2>/dev/null`);
    execSync(`ffmpeg -y -i ${workDir}/${s.id}_video.mp4 -i ${audioDir}/${s.id}.mp3 -c:v copy -c:a aac -b:a 128k -shortest ${workDir}/${s.id}_final.mp4 2>/dev/null`);
    execSync(`ffmpeg -y -i ${workDir}/${s.id}_final.mp4 -c:v libx264 -preset slow -crf ${crf} -pix_fmt yuv420p -r ${outputFps} -c:a aac -b:a 128k -ar 44100 -ac 2 ${workDir}/${s.id}_norm.mp4 2>/dev/null`);
    console.log(`  ${s.id} ✓`);
  }

  // ===== STEP 5: Concatenate =====
  console.log('\n📼 Concatenating...');
  const concatList = SCENES.map(s => `file '${workDir}/${s.id}_norm.mp4'`).join('\n');
  fs.writeFileSync(`${workDir}/concat.txt`, concatList);
  
  const outputPath = path.resolve(output);
  fs.mkdirSync(path.dirname(outputPath), { recursive: true });
  execSync(`ffmpeg -y -f concat -safe 0 -i ${workDir}/concat.txt -c copy ${outputPath} 2>/dev/null`);

  const stat = fs.statSync(outputPath);
  const totalDur = parseFloat(execSync(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 ${outputPath}`).toString().trim());
  
  console.log(`\n✅ Demo video complete!`);
  console.log(`   File: ${outputPath}`);
  console.log(`   Size: ${(stat.size / 1024 / 1024).toFixed(1)} MB`);
  console.log(`   Duration: ${totalDur.toFixed(1)}s`);
  console.log(`   Scenes: ${SCENES.length}`);
  console.log(`   Voice: ${voice}`);
}

// ==================== OVERLAY GENERATOR ====================

function generateOverlayScript(scenes, workDir, fps, fontBold, fontReg) {
  const scenesJson = JSON.stringify(scenes.map(s => ({
    id: s.id, title: s.title, subtitle: s.subtitle, 
    badge: s.badge || '', type: s.type, duration: s.duration
  })));
  
  return `
import os, glob
from PIL import Image, ImageDraw, ImageFont

FONT_BOLD = '${fontBold}'
FONT_REG = '${fontReg}'
WORK = '${workDir}'
FPS = ${fps}
GREEN = (74, 222, 128)

scenes = ${scenesJson}

def add_overlay(img, scene, frame_idx):
    draw = ImageDraw.Draw(img, 'RGBA')
    w, h = img.size
    
    try:
        font_title = ImageFont.truetype(FONT_BOLD, 44 if scene['type'] == 'intro' else 36)
        font_sub = ImageFont.truetype(FONT_REG, 20 if scene['type'] == 'intro' else 18)
        font_badge = ImageFont.truetype(FONT_REG, 16)
    except:
        font_title = ImageFont.load_default()
        font_sub = font_title
        font_badge = font_title
    
    if frame_idx < int(0.3 * FPS):
        return img
    
    if scene['type'] == 'intro':
        bar_h = 170
        draw.rectangle([(0, h-bar_h), (w, h)], fill=(10,10,10,235))
        draw.rectangle([(0, h-bar_h-2), (w, h-bar_h)], fill=(*GREEN, 200))
        bbox = draw.textbbox((0,0), scene['title'], font=font_title)
        tw = bbox[2] - bbox[0]
        draw.text(((w-tw)//2, h-bar_h+18), scene['title'], fill='white', font=font_title)
        bbox2 = draw.textbbox((0,0), scene['subtitle'], font=font_sub)
        tw2 = bbox2[2] - bbox2[0]
        draw.text(((w-tw2)//2, h-bar_h+72), scene['subtitle'], fill=(255,255,255,220), font=font_sub)
        badge = scene.get('badge', '')
        if badge and frame_idx > int(1.0 * FPS):
            bbox3 = draw.textbbox((0,0), badge, font=font_badge)
            tw3 = bbox3[2] - bbox3[0]
            draw.text(((w-tw3)//2, h-bar_h+108), badge, fill=(*GREEN, 255), font=font_badge)
    
    elif scene['type'] == 'outro':
        bar_h = 150
        draw.rectangle([(0, h-bar_h), (w, h)], fill=(10,10,10,240))
        draw.rectangle([(0, h-bar_h-2), (w, h-bar_h)], fill=(*GREEN, 200))
        bbox = draw.textbbox((0,0), scene['title'], font=font_title)
        tw = bbox[2] - bbox[0]
        draw.text(((w-tw)//2, h-bar_h+20), scene['title'], fill='white', font=font_title)
        bbox2 = draw.textbbox((0,0), scene['subtitle'], font=font_sub)
        tw2 = bbox2[2] - bbox2[0]
        draw.text(((w-tw2)//2, h-bar_h+72), scene['subtitle'], fill=(*GREEN, 255), font=font_sub)
    
    else:
        bar_h = 75
        draw.rectangle([(0, h-bar_h), (w, h)], fill=(10,10,10,235))
        draw.rectangle([(0, h-bar_h-2), (w, h-bar_h)], fill=(*GREEN, 180))
        draw.text((18, h-bar_h+10), scene['title'], fill='white', font=font_title)
        draw.text((18, h-bar_h+46), scene['subtitle'], fill=(255,255,255,210), font=font_sub)
        privacy_text = '100% Client-Side'
        bbox_p = draw.textbbox((0,0), privacy_text, font=font_badge)
        pw = bbox_p[2] - bbox_p[0]
        draw.text((w-pw-18, h-bar_h+14), privacy_text, fill=(*GREEN, 230), font=font_badge)
    
    return img

for scene in scenes:
    sid = scene['id']
    frame_dir = f'{WORK}/frames_{sid}'
    out_dir = f'{WORK}/overlay_{sid}'
    os.makedirs(out_dir, exist_ok=True)
    frames = sorted(glob.glob(f'{frame_dir}/frame_*.png'))
    for i, fp in enumerate(frames):
        img = Image.open(fp).convert('RGBA')
        img = add_overlay(img, scene, i)
        img.convert('RGB').save(f'{out_dir}/frame_{i:05d}.png')
    print(f'  {sid}: {len(frames)} frames overlaid')
print('Done')
`;
}

main().catch(console.error);


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (HIGH)