I came across this post on social media about how a user was using OpenClaw to generate tiktok content for their iOS app automatically. This made me realize that we could be using LLMs to generate product demo videos of the features we work on.

I worked with Claude in order to generate on for a flagd UI I was playing with, slapped some royalty free music on it and here was the result.

How it works#

This uses playwright to script using a web UI, especially the ability to record videos. There are three problems with playwright videos: they don’t show mouse movement, they aren’t a presentation format, and they don’t record in a format that’s embed friendly.

The basic playwright test#

The test itself is normal playwright, with some useful utilities. You perform some action (navigate to a URL, click a button) and wait on some selector to become available.

// [... snip ...]
console.log("Opening flag list...");
await page.goto(BASE);
await page.waitForSelector(".flag-table");
await injectCursor(page);
await sleep(1500);

// Annotation: flag count
await showAnnotation(page, {
text: "16 flags loaded from a local git checkout",
near: ".flag-count",
position: "right",
duration: 2500,
});
await sleep(500);

// --- Search for "checkout" ---
console.log("Searching for 'checkout'...");
await showCursor(page);
await glideAndType(page, ".search-input", "checkout");
await sleep(1500);

// --- Click into new-checkout-flow ---
console.log("Opening new-checkout-flow detail...");
await glideAndClick(page, '.flag-table a[href*="new-checkout-flow"]');
await sleep(1500);
// [... snip ...]

Showing mouse movement#

Headless chromium doesn’t render a mouse. To fake it, I have a little ‘cursor’ represented by a semi-opaque circle injected into the page via page.evaluate(). I use css animations to move it around the page like we might a cursor. It uses easing to make it feel a little more natural.

Presentation format#

Just showing the UI isn’t enough though. We want things like title slides and annotations. For this, we’re just using boring web things. This function (which an LLM can help you with the definition of) will do a page.setContent() with a block of HTML to render whatever sort of cards you’d like to see.

  await showTitleCard(page, {
    lines: [{ text: "The problem", size: "1.8rem", color: THEME.textMuted }],
    subtitle:
      "Teams manage flagd by editing YAML and committing to git.<br>There\u2019s no dashboard, no search, no visibility.",
  });

Embed formats#

The resulting video is webm format. Solid for shooting to a colleague, but convering it to an mp4 makes it embed into slack nicely, etc. This addition to the playwright script will output the video in the format you want.

 // Playwright saves video as .webm in the raw dir — grab the only file
  const { execSync } = await import("child_process");
  const files = fs.readdirSync(rawDir).filter((f) => f.endsWith(".webm"));
  if (files.length > 0) {
    // Sort by mtime descending so we always pick the newest
    files.sort((a, b) => {
      return fs.statSync(path.join(rawDir, b)).mtimeMs - fs.statSync(path.join(rawDir, a)).mtimeMs;
    });
    const webmSrc = path.join(rawDir, files[0]);

    // Keep the .webm copy
    const webmOut = OUTPUT.replace(/\.mp4$/, ".webm");
    fs.copyFileSync(webmSrc, webmOut);
    console.log(`WebM saved to ${webmOut}`);

    // Convert to .mp4 (H.264) with background music for Slack/browser inline playback.
    // Music: fade in 2s, low volume (15%), fade out over last 3s of the video.
    const mp4Out = OUTPUT.replace(/\.webm$/, ".mp4");
    const musicSrc = path.join(path.dirname(webmSrc), "..", "bg-music.mp3");
    console.log("Converting to mp4 with background music...");
    if (fs.existsSync(musicSrc)) {
      execSync(
        [
          "ffmpeg -y",
          `-i ${JSON.stringify(webmSrc)}`,
          `-i ${JSON.stringify(musicSrc)}`,
          `-filter_complex "[1:a]atrim=0:duration=55,afade=t=in:st=0:d=2,afade=t=out:st=49:d=4,volume=0.15[music];[music]apad[aout]"`,
          `-map 0:v -map "[aout]"`,
          `-c:v libx264 -pix_fmt yuv420p -c:a aac -shortest`,
          JSON.stringify(mp4Out),
        ].join(" "),
        { stdio: "inherit" }
      );
    } else {
      console.log("No bg-music.mp3 found, encoding without music.");
      execSync(
        `ffmpeg -y -i ${JSON.stringify(webmSrc)} -c:v libx264 -pix_fmt yuv420p -c:a aac ${JSON.stringify(mp4Out)}`,
        { stdio: "inherit" }
      );
    }
    console.log(`MP4 saved to ${mp4Out}`);
  }