drupal-demo-recorder
Produce demo-ready-for-a-team-meeting videos and screenshots of a Drupal site
running under DDEV. This skill is the methodology + quality bar layer on top of
the playwright-cli skill (which documents the raw CLI: open, goto, run-code,
video-start/chapter/stop, screenshot, sessions). Read that skill for CLI
mechanics; read this one for how to make the output good and prove it is.
The bar: a colleague watching in a meeting immediately understands what is happening,
sees every click/keystroke, sees real content (not empty boxes), and never waits on
dead air. If you would be embarrassed to play it in a meeting, it is not done.
The one rule that drives everything
Drive the REAL UI, never shortcuts. Use page.mouse.move/down/up,
locator.click(), locator.dragTo(), and type with locator.pressSequentially() /
page.keyboard.type(). Do not use locator.fill() or page.evaluate(() => el.click())
or firing the app's AJAX directly. Two reasons:
- The visible-input overlay only renders a cursor/keystroke for real events — shortcuts show nothing on screen.
- A demo must show the genuine workflow a user would perform. "It works, trust me (via AJAX)" is exactly the kind of unconvincing capture this skill exists to prevent.
If a native interaction seems hard (HTML5 drag-and-drop, a modal in an iframe), spike it (see Phase 2) — it is almost always drivable. page.mouse.down/move/up fires real dragstart/dragover/drop; iframes are reachable via page.frameLocator(...).
Setup — run first (auto-installs missing tooling)
From the DDEV project root, run the idempotent bootstrap. It installs anything missing
and restarts DDEV once if needed — safe to run before every recording:
bash <skill-dir>/scripts/ensure-tooling.sh
It ensures (announcing each project-modifying step):
e0ipso/ddev-playwright-cli — the recorder CLI (also provides the playwright-cli skill this builds on). Installed via ddev add-on get; chromium is built into the web image on restart.
ffmpeg/ffprobe — added persistently via .ddev/web-build/Dockerfile.ffmpeg.
.playwright/cli.config.json — copied from assets/cli.config.example.json (1920×1080 + ignoreHTTPSErrors for DDEV's self-signed cert) if absent.
It modifies .ddev/ and triggers one ddev restart (brief downtime) only when the add-on
or ffmpeg are missing; on an already-equipped project it is a no-op. If you'd rather not
touch .ddev/, run the steps printed by the script by hand instead.
Then you still need to know / decide:
- Which user/role to record as. Log in with a one-time link:
LOGIN=$(ddev drush --uri=<site-url> uli --name=<user>), then goto it. The link is single-use — regenerate it every recording run.
- The overlay's container path for
addInitScript. ddev-playwright-cli runs inside the container (repo mounted at /var/www/html), so reference this skill's overlay by its container path, e.g. /var/www/html/.claude/skills/drupal-demo-recorder/assets/input-overlay.js.
Non-negotiable quality requirements
| Requirement |
How |
| 1080p desktop frame |
cli.config.json viewport 1920×1080; also await page.setViewportSize({width:1920,height:1080}) at the top of each script. |
| Visible mouse + keyboard |
await page.addInitScript({ path: '<container path>/assets/input-overlay.js' }) BEFORE the first goto. Drive via real events (the one rule). Move the mouse in steps between targets (page.mouse.move(x,y,{steps:8})) so the cursor visibly travels; nudge it once after entering a view so it is on-screen. |
| Step narration |
Show a chapter title card per step: await page.evaluate((t)=>window.__demoTitle(t,2600), 'Add a component'). Also add video-chapter "..." markers. |
| Scroll the subject into view |
Before acting on / capturing an element: await locator.scrollIntoViewIfNeeded() or window.scrollTo({top,behavior:'smooth'}), then a short pause. Never act on or screenshot something off-screen. |
| Real content |
Show genuine content that renders (real images, real text), not empty placeholders or Lorem. Decide its source in Phase 0. If a component renders blank, STOP and find out why before recording (commonly a broken text format, a missing field value, or an empty reference). |
| Natural, even pacing |
Control pacing AT RECORD TIME (see Pacing below): dwell after each change, type with a per-key delay, move the mouse in steps. A light post-process slow is optional polish — not the primary lever, and never slowMo. |
| No dead air |
A click that triggers a navigation/heavy re-render makes Playwright auto-wait; the frame freezes. After such a click wait only ~2s then video-stop, and/or trim the tail. Never ship >3s of a static screen. |
| Screenshots = viewport, scrolled |
Capture the viewport with the relevant part scrolled in — NOT --full-page (full-page captures overlap fixed toolbars/palettes and look broken). 1080p stills pulled from the finished video are ideal. |
Pacing — make it readable without making it sluggish
In order of preference:
- Record-time pacing (primary). Make each step readable as it happens:
await page.waitForTimeout(800–1500) after each meaningful change (the "let it land" beat).
- Type at human cadence:
locator.pressSequentially(text, { delay: 70 }) (never fill()).
- Move the mouse in visible steps:
page.mouse.move(x, y, { steps: 10 }); for drags, many small steps with brief pauses so the motion is followable.
This produces natural, intentional pacing and needs no post-processing.
- Light global slow in post (optional polish).
scripts/process-video.sh ... 1.5
(ffmpeg setpts) uniformly slows the finished video — handy to ease fast bits like AJAX
swaps. Caveat: it ALSO stretches unavoidable reload/auto-wait pauses into dead air, so
keep it modest (≤1.5×) and short-wait-then-stop / trim the tail. Pass 1.0 for
transcode-only when you already paced at record time.
slowMo — avoid for final output. Playwright's slowMo only inserts a fixed delay
before each action → uneven snap-pause-snap motion; it does NOT slow page animations/AJAX
or the cursor overlay, and it lengthens the live run (more timeout/flake risk). Debugging
aid only.
Workflow
Run Setup (above) first so the toolchain is present, then work the phases in order.
Phase 0 — Content: use existing, or create? (ask the user first)
Demos live or die on showing real content. Before storyboarding, ask the user
(use AskUserQuestion):
- "Does the site already have content we should demo with, or should I create demo content?"
- Use existing → ask which specific items/pages, then resolve their concrete IDs / URLs /
titles with drush and use those. Confirm they render with real text + images.
- Create it → continue to Q2.
- If content must be created, ask "Should I model the demo content on the site's existing
content, or build it around a theme you give me?"
- Model on existing → inspect real examples of the relevant content type(s) — fields,
structure, length, tone, which images/references they use — and create content that
mirrors them so it looks authentic and on-brand.
- Provide a theme → ask for the theme/topic (plus any tone/brand notes) and generate
fitting content for it.
- Either way: after choosing/creating content, render it and confirm it actually displays
(real text + images, no empty placeholders, no broken-format blanks) BEFORE recording.
Created demo content is disposable — note it in the README and clean it up if the
environment is not throwaway.
Phase 1 — Storyboard
Write the ordered list of steps and the chapter title for each, the exact node/page/URL,
the user/role, and the specific real content to use (sourced in Phase 0; resolve concrete
IDs/titles with drush). Decide endings (Save vs Discard — Discard keeps a live page pristine
while still demonstrating the edit).
Phase 2 — Spike risky interactions FIRST
Before recording a full take, prove each non-obvious interaction in isolation and
screenshot + look at the result: native drag-and-drop, modal/iframe flows, anything
that re-renders. Capture the working selectors and the exact event sequence. This is what
prevents recording a 90-second take that fails at second 70. (Drag pattern: mouse.move
to the handle → mouse.down() → small mouse.move to start the native drag → read the
drop target's rect → mouse.move over it and PAUSE so dragover fires → mouse.up().)
Phase 3 — Record
One continuous run-code session per video (named session, e.g. -s=rec). Inject the
overlay, set viewport, log in, navigate ({waitUntil:'domcontentloaded'} — NEVER 'load'
on heavy/edit pages, it can hang), then perform the storyboard with real input + title
cards + scrolling. Save the raw .webm to the project root.
Phase 4 — Process
Transcode to a shareable H.264 mp4: scripts/process-video.sh <raw>.webm demos/.../<name> [speed].
If you paced at record time, pass 1.0 (transcode only); use 1.5 only for the optional light
slow (see Pacing). Never add a scale filter (you recorded at final resolution).
Phase 5 — VALIDATE (mandatory — see below). Iterate until it passes.
Validation loop — you MUST look at the actual output
A recording you have not watched is not done. Reports of success are not evidence.
scripts/extract-frames.sh demos/.../<name>.mp4 <tmp-dir> 8 to dump evenly-spaced frames, and hand-grab the key moments (mid-drag, modal open, the embed/result, a keystroke instant): ddev exec ffmpeg -ss <t> -i /var/www/html/<video> -frames:v 1 /var/www/html/<dir>/m.png.
- Open every frame (Read the PNG) and check each item:
- Confirm duration and resolution with
ffprobe.
- Re-record anything that fails. Then re-validate. Do not hand off until every box is checked on the final files.
- Verify the environment is left clean (any temporary config like a front-page swap, draft node, or test content is reverted; the live site still responds 200).
Common failure modes (seen repeatedly — design them out)
- Empty / placeholder / Lorem content → looks broken. Use real content; verify it renders BEFORE recording. Blank field output is often a broken text format (a CKEditor format with no working filter renders empty) or an unselected entity reference.
- Too fast → pace at record time (dwells + keystroke
delay + mouse steps); optional light ≤1.5× post-slow. Not slowMo (uneven snap-pause).
- Didn't scroll →
scrollIntoViewIfNeeded before each action and capture.
- Full-page screenshots → use viewport stills scrolled to the subject (or frames from the video).
- Dead air at the end → the Save/submit click auto-waits for a heavy reload; short-wait then stop, or trim.
- Invisible mouse/keyboard → you used
fill()/AJAX instead of real events. Drive the real UI.
- Two cursors → the overlay was injected into an iframe without single-cursor forwarding; use the bundled
input-overlay.js (it forwards child-frame events to one top-frame cursor).
- Page hangs on load / Edit surface won't open → don't use
waitUntil:'load'; use domcontentloaded and waitForFunction on a real readiness signal. Heavy/content-rich pages may be slow or hit limits — profile and fix, or pick a lighter representative page, but say so.
- Recorded as the wrong user → log in (drush uli) as the intended role; permissions change what's visible.
- Edited PHP/templates and nothing changed → reload the workers:
ddev exec bash -c "killall -USR2 php-fpm".
Deliverables & housekeeping
- Put videos/screenshots under a
demos/<feature>/ dir with a short README: what each file shows, the demo node/URL/user, and any environment caveats.
- Keep
.mp4 (shareable) alongside .webm.
- Note in the README anything non-default you set up to make it work, and whether it was reverted.
Optional — subagent orchestration for big jobs
For multi-video jobs you can delegate to subagents, but: the DDEV browser is a shared,
serial resource — do not run two browser-driving subagents at once (concurrent edit
renders go flaky). Spike in one agent, record serially, and always do an independent
validation pass yourself (open the frames) rather than trusting a subagent's self-report.
1---2name: drupal-demo-recorder3description: Use when asked to record demo videos or screenshots of a Drupal site (running under DDEV) to share with people — e.g. "make a demo video of <feature>", "record a walkthrough", "capture screenshots of this flow for the team", "show this working". Produces 1080p, narrated, validated, presentable captures driven through the real UI with e0ipso/ddev-playwright-cli. Do NOT use for writing automated/CI Playwright tests (use the playwright-cli skill) or for non-Drupal / non-DDEV recording.4---56# drupal-demo-recorder78Produce **demo-ready-for-a-team-meeting** videos and screenshots of a Drupal site9running under DDEV. This skill is the *methodology + quality bar* layer on top of10the `playwright-cli` skill (which documents the raw CLI: `open`, `goto`, `run-code`,11`video-start/chapter/stop`, `screenshot`, sessions). Read that skill for CLI12mechanics; read this one for **how to make the output good and prove it is**.1314The bar: a colleague watching in a meeting immediately understands what is happening,15sees every click/keystroke, sees real content (not empty boxes), and never waits on16dead air. If you would be embarrassed to play it in a meeting, it is not done.1718## The one rule that drives everything1920**Drive the REAL UI, never shortcuts.** Use `page.mouse.move/down/up`,21`locator.click()`, `locator.dragTo()`, and type with `locator.pressSequentially()` /22`page.keyboard.type()`. Do **not** use `locator.fill()` or `page.evaluate(() => el.click())`23or firing the app's AJAX directly. Two reasons:241. The visible-input overlay only renders a cursor/keystroke for **real** events — shortcuts show nothing on screen.252. A demo must show the **genuine workflow** a user would perform. "It works, trust me (via AJAX)" is exactly the kind of unconvincing capture this skill exists to prevent.2627If a native interaction seems hard (HTML5 drag-and-drop, a modal in an iframe), **spike it** (see Phase 2) — it is almost always drivable. `page.mouse.down/move/up` fires real `dragstart/dragover/drop`; iframes are reachable via `page.frameLocator(...)`.2829## Setup — run first (auto-installs missing tooling)3031From the DDEV project root, run the idempotent bootstrap. It installs anything missing32and restarts DDEV once if needed — safe to run before every recording:3334```bash35bash <skill-dir>/scripts/ensure-tooling.sh36```3738It ensures (announcing each project-modifying step):39- **`e0ipso/ddev-playwright-cli`** — the recorder CLI (also provides the `playwright-cli` skill this builds on). Installed via `ddev add-on get`; chromium is built into the web image on restart.40- **`ffmpeg`/`ffprobe`** — added persistently via `.ddev/web-build/Dockerfile.ffmpeg`.41- **`.playwright/cli.config.json`** — copied from `assets/cli.config.example.json` (1920×1080 + `ignoreHTTPSErrors` for DDEV's self-signed cert) if absent.4243> It modifies `.ddev/` and triggers one `ddev restart` (brief downtime) only when the add-on44> or ffmpeg are missing; on an already-equipped project it is a no-op. If you'd rather not45> touch `.ddev/`, run the steps printed by the script by hand instead.4647Then you still need to know / decide:48- **Which user/role** to record as. Log in with a one-time link: `LOGIN=$(ddev drush --uri=<site-url> uli --name=<user>)`, then `goto` it. The link is single-use — **regenerate it every recording run**.49- The overlay's container path for `addInitScript`. ddev-playwright-cli runs **inside the container** (repo mounted at `/var/www/html`), so reference this skill's overlay by its container path, e.g. `/var/www/html/.claude/skills/drupal-demo-recorder/assets/input-overlay.js`.5051## Non-negotiable quality requirements5253| Requirement | How |54| --- | --- |55| **1080p desktop frame** | `cli.config.json` viewport 1920×1080; also `await page.setViewportSize({width:1920,height:1080})` at the top of each script. |56| **Visible mouse + keyboard** | `await page.addInitScript({ path: '<container path>/assets/input-overlay.js' })` BEFORE the first `goto`. Drive via real events (the one rule). Move the mouse in steps between targets (`page.mouse.move(x,y,{steps:8})`) so the cursor visibly travels; nudge it once after entering a view so it is on-screen. |57| **Step narration** | Show a chapter title card per step: `await page.evaluate((t)=>window.__demoTitle(t,2600), 'Add a component')`. Also add `video-chapter "..."` markers. |58| **Scroll the subject into view** | Before acting on / capturing an element: `await locator.scrollIntoViewIfNeeded()` or `window.scrollTo({top,behavior:'smooth'})`, then a short pause. Never act on or screenshot something off-screen. |59| **Real content** | Show genuine content that renders (real images, real text), not empty placeholders or Lorem. Decide its source in Phase 0. If a component renders blank, STOP and find out why before recording (commonly a broken text format, a missing field value, or an empty reference). |60| **Natural, even pacing** | Control pacing AT RECORD TIME (see *Pacing* below): dwell after each change, type with a per-key delay, move the mouse in steps. A light post-process slow is optional polish — not the primary lever, and never `slowMo`. |61| **No dead air** | A click that triggers a navigation/heavy re-render makes Playwright auto-wait; the frame freezes. After such a click wait only ~2s then `video-stop`, and/or trim the tail. Never ship >3s of a static screen. |62| **Screenshots = viewport, scrolled** | Capture the viewport with the relevant part scrolled in — NOT `--full-page` (full-page captures overlap fixed toolbars/palettes and look broken). 1080p stills pulled from the finished video are ideal. |6364## Pacing — make it readable without making it sluggish6566In order of preference:67681. **Record-time pacing (primary).** Make each step readable as it happens:69 - `await page.waitForTimeout(800–1500)` after each meaningful change (the "let it land" beat).70 - Type at human cadence: `locator.pressSequentially(text, { delay: 70 })` (never `fill()`).71 - Move the mouse in visible steps: `page.mouse.move(x, y, { steps: 10 })`; for drags, many small steps with brief pauses so the motion is followable.72 This produces natural, intentional pacing and needs no post-processing.732. **Light global slow in post (optional polish).** `scripts/process-video.sh ... 1.5`74 (ffmpeg `setpts`) uniformly slows the finished video — handy to ease fast bits like AJAX75 swaps. Caveat: it ALSO stretches unavoidable reload/auto-wait pauses into dead air, so76 keep it modest (≤1.5×) and short-wait-then-stop / trim the tail. Pass `1.0` for77 transcode-only when you already paced at record time.783. **`slowMo` — avoid for final output.** Playwright's `slowMo` only inserts a fixed delay79 *before each action* → uneven snap-pause-snap motion; it does NOT slow page animations/AJAX80 or the cursor overlay, and it lengthens the live run (more timeout/flake risk). Debugging81 aid only.8283## Workflow8485Run **Setup** (above) first so the toolchain is present, then work the phases in order.8687### Phase 0 — Content: use existing, or create? (ask the user first)8889Demos live or die on showing *real* content. Before storyboarding, **ask the user**90(use AskUserQuestion):91921. **"Does the site already have content we should demo with, or should I create demo content?"**93 - *Use existing* → ask which specific items/pages, then resolve their concrete IDs / URLs /94 titles with drush and use those. Confirm they render with real text + images.95 - *Create it* → continue to Q2.962. If content must be created, ask **"Should I model the demo content on the site's existing97 content, or build it around a theme you give me?"**98 - *Model on existing* → inspect real examples of the relevant content type(s) — fields,99 structure, length, tone, which images/references they use — and create content that100 mirrors them so it looks authentic and on-brand.101 - *Provide a theme* → ask for the theme/topic (plus any tone/brand notes) and generate102 fitting content for it.1033. Either way: after choosing/creating content, **render it and confirm it actually displays**104 (real text + images, no empty placeholders, no broken-format blanks) BEFORE recording.105 Created demo content is disposable — note it in the README and clean it up if the106 environment is not throwaway.107108### Phase 1 — Storyboard109Write the ordered list of steps and the chapter title for each, the exact node/page/URL,110the user/role, and the *specific real content* to use (sourced in Phase 0; resolve concrete111IDs/titles with drush). Decide endings (Save vs Discard — Discard keeps a live page pristine112while still demonstrating the edit).113114### Phase 2 — Spike risky interactions FIRST115Before recording a full take, prove each non-obvious interaction in isolation and116**screenshot + look at the result**: native drag-and-drop, modal/iframe flows, anything117that re-renders. Capture the working selectors and the exact event sequence. This is what118prevents recording a 90-second take that fails at second 70. (Drag pattern: `mouse.move`119to the handle → `mouse.down()` → small `mouse.move` to start the native drag → read the120drop target's rect → `mouse.move` over it and PAUSE so `dragover` fires → `mouse.up()`.)121122### Phase 3 — Record123One continuous `run-code` session per video (named session, e.g. `-s=rec`). Inject the124overlay, set viewport, log in, navigate (`{waitUntil:'domcontentloaded'}` — NEVER `'load'`125on heavy/edit pages, it can hang), then perform the storyboard with real input + title126cards + scrolling. Save the raw `.webm` to the project root.127128### Phase 4 — Process129Transcode to a shareable H.264 mp4: `scripts/process-video.sh <raw>.webm demos/.../<name> [speed]`.130If you paced at record time, pass `1.0` (transcode only); use `1.5` only for the optional light131slow (see *Pacing*). Never add a scale filter (you recorded at final resolution).132133### Phase 5 — VALIDATE (mandatory — see below). Iterate until it passes.134135## Validation loop — you MUST look at the actual output136137A recording you have not watched is not done. Reports of success are not evidence.1381391. `scripts/extract-frames.sh demos/.../<name>.mp4 <tmp-dir> 8` to dump evenly-spaced frames, **and** hand-grab the key moments (mid-drag, modal open, the embed/result, a keystroke instant): `ddev exec ffmpeg -ss <t> -i /var/www/html/<video> -frames:v 1 /var/www/html/<dir>/m.png`.1402. **Open every frame** (Read the PNG) and check each item:141 - [ ] Exactly **one** cursor visible (watch modals/iframes — a stray second cursor is the classic bug).142 - [ ] **Key badges** appear while typing (proves real keyboard input, not `fill()`).143 - [ ] The **subject is in view** for each step (nothing important off-screen / cut off).144 - [ ] **Real content rendered** — images load, text is genuine, no empty/placeholder boxes, no error/"Status:" noise.145 - [ ] **No dead-air** tail or >3s static stretch (AJAX re-renders that keep content on screen are fine).146 - [ ] Title card present and readable for each step.147 - [ ] Clean **1080p** layout — no overlapping chrome, no clipping, correct resolution (`ffprobe`).1483. Confirm **duration and resolution** with `ffprobe`.1494. **Re-record anything that fails.** Then re-validate. Do not hand off until every box is checked on the final files.1505. Verify the environment is left clean (any temporary config like a front-page swap, draft node, or test content is reverted; the live site still responds 200).151152## Common failure modes (seen repeatedly — design them out)153154- **Empty / placeholder / Lorem content** → looks broken. Use real content; verify it renders BEFORE recording. Blank field output is often a broken text format (a CKEditor format with no working filter renders empty) or an unselected entity reference.155- **Too fast** → pace at record time (dwells + keystroke `delay` + mouse `steps`); optional light ≤1.5× post-slow. Not `slowMo` (uneven snap-pause).156- **Didn't scroll** → `scrollIntoViewIfNeeded` before each action and capture.157- **Full-page screenshots** → use viewport stills scrolled to the subject (or frames from the video).158- **Dead air at the end** → the Save/submit click auto-waits for a heavy reload; short-wait then stop, or trim.159- **Invisible mouse/keyboard** → you used `fill()`/AJAX instead of real events. Drive the real UI.160- **Two cursors** → the overlay was injected into an iframe without single-cursor forwarding; use the bundled `input-overlay.js` (it forwards child-frame events to one top-frame cursor).161- **Page hangs on load / Edit surface won't open** → don't use `waitUntil:'load'`; use `domcontentloaded` and `waitForFunction` on a real readiness signal. Heavy/content-rich pages may be slow or hit limits — profile and fix, or pick a lighter representative page, but say so.162- **Recorded as the wrong user** → log in (drush uli) as the intended role; permissions change what's visible.163- **Edited PHP/templates and nothing changed** → reload the workers: `ddev exec bash -c "killall -USR2 php-fpm"`.164165## Deliverables & housekeeping166- Put videos/screenshots under a `demos/<feature>/` dir with a short README: what each file shows, the demo node/URL/user, and any environment caveats.167- Keep `.mp4` (shareable) alongside `.webm`.168- Note in the README anything non-default you set up to make it work, and whether it was reverted.169170## Optional — subagent orchestration for big jobs171For multi-video jobs you can delegate to subagents, but: the DDEV browser is a shared,172serial resource — **do not run two browser-driving subagents at once** (concurrent edit173renders go flaky). Spike in one agent, record serially, and always do an **independent**174validation pass yourself (open the frames) rather than trusting a subagent's self-report.