Was macht demo-reel?
Verwandle einen skriptgesteuerten Durchlauf durch eine Web-App in ein Video, das wie eine polierte Bildschirmaufnahme aussieht: flüssige 60 fps, ein sichtbarer Cursor mit Halo, Klick-Wellen, Untertitel. Es steuert die echte App im Headless-Chromium — das Filmmaterial ist das Produkt, kein Mockup.
Warum das Video flüssig ist
Eine normale Bildschirmaufnahme zeichnet Frames auf, wenn der Browser zufällig malt; auf einem langsamen Rechner (oder mit Software-WebGL) sind das ca. 10–15 fps und es ruckelt. demo-reel injiziert stattdessen eine virtuelle Uhr (scripts/inject.js): requestAnimationFrame, performance.now und Date schreiten nur fort, wenn der Recorder es sagt, und CSS-/Web-Animationen werden über document.getAnimations() schrittweise ausgeführt. Jeder Frame wird um exakt 1/fps weitergeschaltet, als Screenshot erfasst und an ffmpeg übergeben. Egal wie lange das Rendern eines Frames dauert, das Ergebnis ist perfekt flüssig.
Timer (setTimeout/setInterval) bleiben bewusst echt — das Vortäuschen würde dazu führen, dass Timer-Ketten mit Nullverzögerung in Loadern und Schedulern ewig laufen (deswegen hängt Playwrights eigene page.clock auf vielen WebGL-Seiten).
Voraussetzungen
- Node 18+, ffmpeg im PATH.
- Einmalig, im Verzeichnis dieses Skills:
npm install && npx playwright install chromium.
Arbeitsablauf
- Bereitstellen der App so, wie Nutzer sie sehen — idealerweise ein Produktions-Build (
vite build && vite preview,next build && next start) gegen lokale oder Mock-Daten. Dev-Server funktionieren, sind aber langsamer und zeigen möglicherweise Dev-Overlays. Niemals echte oder sensible Daten aufnehmen — Demo-Daten einspielen. - Schreiben eines Szenarios (
*.scenario.mjs, siehe API unten). Beginne mitexamples/tasks.scenario.mjs. Plane es als Geschichte: 20–60 s, eine Idee pro Untertitel, 1–2 s Halten nach jedem Ergebnis, damit Zuschauer es lesen können. - Aufnehmen:
node scripts/record.mjs my.scenario.mjs --out demo.mp4Ein 60 s Clip in 1080p dauert grob 3–10 min (schwere WebGL-Seiten sind am langsamsten). Im Hintergrund laufen lassen. - Qualitätssicherung:
scripts/sheet.sh demo.mp4schreibt ein Kontaktblatt (ein Frame alle 2,5 s). Prüfe: Untertitel passen zum Bildschirminhalt, keine Lade-Spinner, nichts vom Untertitel verdeckt, Badge/Cursor sichtbar. Stichprobenartig die Flüssigkeit prüfen, indem man ein paar aufeinanderfolgende Frames extrahiert — jeder sollte sich während einer Bewegung leicht unterscheiden. - Ausliefern (siehe „Video verwenden").
Szenario-API
export default {
size: "1920x1080", // Viewport = Videogröße
fps: 60,
theme: { captionPosition: "bottom", haloFill: "rgba(250,204,21,.30)" }, // optional
async run(d) {
await d.open("http://localhost:4173/"); // Navigieren + abwarten (nicht aufgenommen)
d.badge("NEU", "#15803d"); // Ecken-Badge (null blendet aus)
d.caption("Das neue Dashboard"); // Untertitel (null blendet aus)
await d.hold(1500); // 1,5 s so aufnehmen
await d.click(d.page.getByRole("button", { name: "Filter" }));
d.caption("Filter leben in einem Panel"); // Untertitel NACH der Aktion setzen
await d.hold(1200);
await d.type("Stockholm"); // Tippen in menschlicher Geschwindigkeit
await d.press("Enter");
await d.scroll(600); // Sanftes Scrollen mit dem Rad
await d.step("optionaler Teil", async () => { /* Fehler werden protokolliert, Aufnahme läuft weiter */ });
},
};
| Aufruf | Wirkung |
|---|---|
d.open(url, { settle }) | Navigieren, Seite abwarten lassen (nicht aufgenommen, Standard 2500 ms). |
d.hold(ms) | Seite so aufnehmen, wie sie ist. |
d.settle(ms) | Zeit ohne Aufnahme fortschreiten lassen — Ladezustände, Lazy Chunks, Layout-Animationen überspringen, die du nicht zeigen willst. |
d.move(target, { ms }) / d.hover | Cursor (easing) zu einem Ziel gleiten lassen. |
d.click(target, { ms, button }) | Hinbewegen, klicken mit Welle. button: "right" für Kontextmenüs. |
d.doubleClick(target) | Doppelklicken. |
d.type(text, { delay }) | Zeichen für Zeichen tippen (Standard 90 ms/Zeichen). |
d.press(key) | Tastaturkürzel, z.B. "Escape", "Control+K". |
d.scroll(dy, { ms }) | Sanftes Scrollen mit dem Rad. |
d.caption(text) / d.badge(text, color) / d.cursor(bool) | Overlay-Zustand; überlebt vollständige Seitennavigationen. |
d.step(name, fn) | Benannter Schritt; Fehler werden protokolliert, nicht fatal. |
d.page, d.point(target), d.width, d.height | Notausstiege. |
Ziele können ein Playwright-Locator, ein CSS-Selektor-String, { x, y } oder eine asynchrone Funktion (page) => ({ x, y }) sein.
Canvas-gerenderte UIs (Graphen, Karten, Diagramme) haben kein DOM-Element pro Element — verwende eine Funktion, die die Bibliothek nach Bildschirmkoordinaten fragt. Beispiel für Cytoscape.js:
const node = (label) => (page) => page.evaluate((label) => {
const el = [...document.querySelectorAll("div")].find((e) => e._cyreg?.cy);
const n = el._cyreg.cy.nodes().filter((n) => n.data("label") === label)[0];
const r = el.getBoundingClientRect(), p = n.renderedPosition();
return { x: r.left + p.x, y: r.top + p.y };
}, label);
await d.click(node("Server A"));
Gute Demos schreiben
- Untertitel sagen die Wahrheit. Behaupten nur, was das Filmmaterial zeigt. Wenn ein Fehler, den du zeigen willst, im Take nicht reproduziert wird, ändere den Untertitel — behalte ihn nicht.
- Untertitel nach der Aktion, nicht davor — sonst erscheint der Untertitel für den nächsten Bildschirm über dem vorherigen.
- Zeige Ergebnisse, nicht Klicks. Halte 1–2 s auf jedem Ergebnis; schneide Ladezeiten mit
d.settleheraus. - Vorher/Nachher: Nimm zwei Szenarien mit derselben Geschichte und denselben Schritten auf, kennzeichne sie mit Badge
VORHER/NACHHER(rot/grün) und behalte sie als separate Dateien plus einer optionalen Verkettung. - Heldenmomente (eine Landing Page, eine Animation): Gib ihnen 5–10 s — Zuschauer brauchen einen Moment, um sie aufzunehmen.
- Halte den Cursor ruhig: 600–900 ms pro Bewegung, parke ihn während des Haltens abseits des Inhalts.
- Echte Timer laufen während des Renderns der Frames weiter (~50–300 ms Echtzeit pro Frame), daher können timer-gesteuerte UIs — Toasts, debounced Suche, automatisch ausblendende Tooltips — im Video schneller verschwinden als im echten Leben. Löse sie direkt vor dem gewünschten Bildschirm aus oder halte kürzer.
Video verwenden
- PowerPoint: Einfügen → Video → Dieses Gerät. Registerkarte Wiedergabe: Start: Automatisch und Bis zum Anhalten wiederholen. H.264
yuv420pMP4 (was demo-reel schreibt) läuft auf Windows und macOS. - Keynote: Ziehen; Film → Wiederholung: Schleife.
- Web:
<video src="demo.mp4" autoplay loop muted playsinline></video>. - GIF (README, Chat):
ffmpeg -i demo.mp4 -vf "fps=20,scale=960:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" demo.gif - Verketten von Clips gleicher Größe/fps:
printf "file 'a.mp4'\nfile 'b.mp4'\n" > l.txt && ffmpeg -f concat -safe 0 -i l.txt -c copy both.mp4
Fehlerbehebung
| Symptom | Behebung |
|---|---|
| Leere / „Lädt…"-Frames | Längeres settle in d.open, oder await d.settle(1500) nach einem Navigationsklick. |
| Ziel nicht sichtbar | Es ist außerhalb des Bildschirms oder in einem geschlossenen Menü — zuerst öffnen/scrollen; für Canvas-Elemente eine Punkt-Funktion verwenden. |
| WebGL/3D rendert schwarz | Die Standard-Launch-Flags behalten (--use-angle=swiftshader); es ist langsam (~0,25 s/Frame), aber korrekt. |
| Video zu kurz/lang im Vergleich zum Plan | Dauern sind exakt: Summe aus hold + Bewegungen. settle-Zeit wird nie aufgenommen. |
| Farben in PowerPoint verwaschen | Nicht ohne -pix_fmt yuv420p neu kodieren; demo-reels Ausgabe ist bereits korrekt. |
| Text leicht unscharf | --png erfasst verlustfreie Frames (langsamer, größer). |