Automating Demo Videos with Puppeteer, Xvfb, and FFmpeg: A Zero-Manual-Recording Pipeline

Posted on June 07, 2026

Category: Technology

Tags: puppeteer, xvfb, ffmpeg, headless-browser, mediapipe, webgl, swiftshader, demo-video, marketing-automation, roblox, wsl2, node-js, browser-automation

Views: 338

Automating Demo Videos with Puppeteer, Xvfb, and FFmpeg: A Zero-Manual-Recording Pipeline

I needed a demo video for my Roblox Studio plugin (Video2Anim) — a tool that converts MP4 video into R15 character animation using AI body tracking. The typical approach would be to screen-record the browser and Studio manually, but I wanted a fully automated pipeline that I could re-run whenever the product changes.

This post walks through the problems I hit, the debugging process, and the final working solution — all running from WSL2 with zero GUI interaction.


The Goal

Capture a 20-second demo video showing:

  1. A Roblox Studio plugin widget (simulated via HTML mockup)
  2. A real browser session on the production site — uploading a video, running AI body tracking, and playing back a 3D R15 animation preview

The output: an MP4 and a GIF suitable for Twitter and DevForum.


Attempt 1: Headless Chrome (Failed)

The first attempt was straightforward — Puppeteer with headless: 'new' and --use-gl=swiftshader:

    const browser = await puppeteer.launch({
      headless: 'new',
      args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl'],
    });

The landing page rendered perfectly. But when MediaPipe's PoseLandmarker tried to initialize, it crashed:

    Cannot read properties of undefined (reading 'activeTexture')

MediaPipe's WASM module calls emscripten_webgl_create_context() internally — even when configured with delegate: 'CPU'. Headless Chrome's WebGL implementation wasn't sufficient.


Attempt 2: Xvfb Virtual Display (Still Failed)

Xvfb creates a virtual X11 framebuffer, giving Chrome a "real" display without a physical monitor. I installed it and ran Chrome in non-headless mode:

    xvfb-run --auto-servernum node capture.js

With headless: false, Chrome opens inside the virtual display. But now a different error appeared:

    emscripten_webgl_create_context() returned error 0

Xvfb provides a display but no GPU and no WebGL. MediaPipe's internal graph runner requires a working WebGL context to initialize — the kGpuService is mandatory even for CPU inference.


Attempt 3: Forced CPU Delegate via Request Interception (Still Failed)

I tried intercepting the converter.js network request and patching delegate: 'GPU' to delegate: 'CPU' on the fly:

    await page.setRequestInterception(true);
    page.on('request', async req => {
      if (req.url().includes('converter') && req.url().endsWith('.js')) {
        const res = await fetch(req.url());
        let body = await res.text();
        body = body.replace(/delegate:\s*['"]GPU['"]/g, "delegate: 'CPU'");
        req.respond({ status: 200, contentType: 'application/javascript', body });
      } else {
        req.continue();
      }
    });

Same error. Turns out MediaPipe's WASM binary has its own internal WebGL context creation that ignores the JavaScript-level delegate setting. It needs a working emscripten_webgl_create_context() regardless.


The Root Cause

The error chain made the problem clear:

The solution: combine both. Xvfb for the display, plus a software GL renderer for WebGL.


The Fix: Xvfb + ANGLE SwiftShader

I tested three GL backend configurations systematically:

Config Chrome Flag Result
SwiftShader direct --use-gl=swiftshader Failed
ANGLE + SwiftShader --use-gl=angle --use-angle=swiftshader-webgl Success
ANGLE + GLES --use-gl=angle --use-angle=gles Not tested (stopped early)

The winning combination:

    const browser = await puppeteer.launch({
      headless: false,
      args: [
        '--no-sandbox',
        '--use-gl=angle',
        '--use-angle=swiftshader-webgl',
        '--enable-webgl',
      ],
    });

Run with Xvfb:

    xvfb-run --auto-servernum node demo_capture.js

ANGLE (Almost Native Graphics Layer Engine) translates WebGL calls to the SwiftShader software renderer, which provides a complete WebGL implementation without hardware GPU. Combined with Xvfb's virtual display, Chrome gets everything it needs — a display context plus working WebGL.

The conversion ran in about 90 seconds (CPU-only, significantly slower than GPU), but it completed successfully.


Capturing the Demo

With the browser environment working, the capture script follows a simple pattern — take screenshots at intervals, then stitch them into video with FFmpeg.

Screenshot Sequence

    let frame = 0;
    async function snap() {
      const file = `frames/frame_${String(frame).padStart(4, '0')}.png`;
      await page.screenshot({ path: file });
      frame++;
    }

The script walks through each scene: landing page, clicking the sample button, waiting for conversion progress, scrolling to the 3D preview, clicking Play, and capturing the animation frames at 100ms intervals.

Assembling with FFmpeg

    ffmpeg -framerate 10 -i frames/frame_%04d.png \
      -c:v libx264 -pix_fmt yuv420p demo.mp4

Adding Text Overlays

FFmpeg's drawtext filter with time-based enable conditions:

    ffmpeg -i demo.mp4 -vf "
      drawtext=fontfile=/path/to/font.ttf:
        text='AI tracks body pose':
        fontsize=22:fontcolor=white:borderw=2:bordercolor=black:
        x=(w-text_w)/2:y=15:
        enable='between(t,0.8,4.4)'
    " -c:v libx264 output.mp4

Each scene gets its own overlay with time windows matching the frame ranges.


Studio Plugin UI: HTML Mockup

The Roblox Studio plugin can't be automated headlessly — it requires GUI login and the Studio runtime. But the plugin widget is just a panel with buttons and text, so I recreated it as an HTML page using the exact same colors and layout from the Lua source code:

Then Puppeteer drives the mockup through each state — initial, URL generated, keyframes received, preview playing, inserted — using page.evaluate() to toggle element visibility and update text. Same screenshot-sequence capture, same FFmpeg assembly.

The result is visually indistinguishable from the real plugin.


Final Pipeline

    # 1. Capture browser demo (real site, real AI conversion)
    xvfb-run --auto-servernum node demo_capture.js

    # 2. Capture Studio mockup
    xvfb-run --auto-servernum node demo_studio.js

    # 3. Add overlays and normalize to square format
    ffmpeg -i studio_raw.mp4 -vf "scale..,drawtext..." studio_ready.mp4
    ffmpeg -i browser_frames -vf "crop...,drawtext..." browser_ready.mp4

    # 4. Concatenate
    ffmpeg -f concat -i list.txt -c copy demo_final.mp4

    # 5. Convert to GIF for social media
    ffmpeg -i demo_final.mp4 -vf "fps=10,scale=480:-1,palettegen/paletteuse" demo.gif

Total output: a 21-second square-format demo with text overlays — 186KB MP4, 475KB GIF. Zero manual recording.


Key Takeaways


References


Disclaimer: This blog post was created with assistance from Claude, an AI developed by Anthropic, under my direct supervision and guidance to ensure accuracy and alignment with my vision for the content.