Was macht UI-Demo-Videorecorder?
Erstelle professionelle Demo-Videos von Webanwendungen mit Playwrights Videoaufzeichnung, einer eingeblendeten Mauszeiger-Überlagerung, natürlichem Tempo und einer erzählerischen Ablaufstruktur.
Verwendung
- Der Nutzer fordert ein „Demo-Video”, eine „Bildschirmaufzeichnung”, einen „Walkthrough” oder ein „Tutorial” an.
- Der Nutzer möchte eine Funktion oder einen Arbeitsablauf visuell präsentieren.
- Der Nutzer benötigt ein Video für Dokumentation, Onboarding oder eine Präsentation für Stakeholder.
Drei-Phasen-Prozess
Jede Demo durchläuft drei Phasen: Entdecken -> Probieren -> Aufnehmen. Gehe niemals direkt zur Aufnahme über.
Phase 1: Entdecken
Bevor du ein Skript schreibst, erkunde die Zielseiten, um zu verstehen, was dort tatsächlich vorhanden ist.
Warum
Du kannst kein Skript für Inhalte schreiben, die du nicht gesehen hast. Felder könnten <input> und nicht <textarea> sein, Dropdowns könnten benutzerdefinierte Komponenten und nicht <select> sein, und Kommentarfelder könnten @mentions oder #tags unterstützen. Annahmen führen zu stillen Fehlern in der Aufnahme.
Vorgehen
Navigiere zu jeder Seite im Ablauf und gib ihre interaktiven Elemente aus:
// Führe dies für jede Seite im Ablauf AUS, bevor du das Demo-Skript schreibst
const fields = await page.evaluate(() => {
const els = [];
document.querySelectorAll('input, select, textarea, button, [contenteditable]').forEach(el => {
if (el.offsetParent !== null) {
els.push({
tag: el.tagName,
type: el.type || '',
name: el.name || '',
placeholder: el.placeholder || '',
text: el.textContent?.trim().substring(0, 40) || '',
contentEditable: el.contentEditable === 'true',
role: el.getAttribute('role') || '',
});
}
});
return els;
});
console.log(JSON.stringify(fields, null, 2));
Worauf zu achten ist
- Formularfelder: Sind es
<select>,<input>, benutzerdefinierte Dropdowns oder Comboboxen? - Select-Optionen: Gib Optionswerte UND Texte aus. Platzhalter haben oft
value="0"odervalue="", was nicht leer aussieht. VerwendeArray.from(el.options).map(o => ({ value: o.value, text: o.text })). Überspringe Optionen, deren Text „Select” enthält oder deren Wert"0"ist. - Rich Text: Unterstützt das Kommentarfeld
@mentions,#tags, Markdown oder Emoji? Überprüfe den Platzhaltertext. - Pflichtfelder: Welche Felder blockieren das Absenden des Formulars? Überprüfe
required,*in Labels und versuche, das Formular leer abzusenden, um Validierungsfehler zu sehen. - Dynamische Inhalte: Erscheinen Felder erst, nachdem andere Felder ausgefüllt wurden?
- Button-Beschriftungen: Genaue Texte wie
"Submit","Submit Request"oder"Send". - Tabellenspaltenüberschriften: Ordne bei tabellenbasierten Modi jedem
input[type="number"]seine Spaltenüberschrift zu, anstatt anzunehmen, dass alle numerischen Eingaben dasselbe bedeuten.
Ausgabe
Eine Feldzuordnung für jede Seite, die verwendet wird, um korrekte Selektoren im Skript zu schreiben. Beispiel:
/purchase-requests/new:
- Budget Code: <select> (erstes Select auf der Seite, 4 Optionen)
- Desired Delivery: <input type="date">
- Context: <textarea> (kein input)
- BOM table: inline-editierbare Zellen mit span.cursor-pointer -> Eingabemuster
- Submit: <button> text="Submit"
/purchase-requests/N (Detail):
- Comment: <input placeholder="Type a message..."> unterstützt @user und #PR-Tags
- Send: <button> text="Send" (deaktiviert, bis das Eingabefeld Inhalt hat)
Phase 2: Probieren (Rehearse)
Gehe alle Schritte ohne Aufnahme durch. Verifiziere, dass jeder Selektor aufgelöst werden kann.
Warum
Stille Selektorfehler sind der Hauptgrund, warum Demo-Aufnahmen fehlschlagen. Das Probieren fängt sie ab, bevor eine Aufnahme verschwendet wird.
Vorgehen
Verwende ensureVisible, einen Wrapper, der Fehler protokolliert und laut ausgibt:
async function ensureVisible(page, locator, label) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
const msg = `PROBEN-FEHLER: "${label}" nicht gefunden - Selektor: ${typeof locator === 'string' ? locator : '(locator object)'}`;
console.error(msg);
const found = await page.evaluate(() => {
return Array.from(document.querySelectorAll('button, input, select, textarea, a'))
.filter(el => el.offsetParent !== null)
.map(el => `${el.tagName}[${el.type || ''}] "${el.textContent?.trim().substring(0, 30)}"`)
.join('\n ');
});
console.error(' Sichtbare Elemente:\n ' + found);
return false;
}
console.log(`PROBEN-ERFOLG: "${label}"`);
return true;
}
Struktur des Probenskripts
const steps = [
{ label: 'Login E-Mail-Feld', selector: '#email' },
{ label: 'Login Absenden', selector: 'button[type="submit"]' },
{ label: 'Neuer Antrag Button', selector: 'button:has-text("New Request")' },
{ label: 'Budget Code Select', selector: 'select' },
{ label: 'Lieferdatum', selector: 'input[type="date"]:visible' },
{ label: 'Beschreibungsfeld', selector: 'textarea:visible' },
{ label: 'Artikel hinzufügen Button', selector: 'button:has-text("Add Item")' },
{ label: 'Absenden Button', selector: 'button:has-text("Submit")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('PROBEN-FEHLGESCHLAGEN - Korrigiere Selektoren vor der Aufnahme');
process.exit(1);
}
console.log('PROBEN-BESTANDEN - alle Selektoren verifiziert');
Wenn das Probieren fehlschlägt
- Lies die Auflistung der sichtbaren Elemente.
- Finde den korrekten Selektor.
- Aktualisiere das Skript.
- Führe das Probieren erneut aus.
- Fahre erst fort, wenn jeder Selektor bestanden ist.
Phase 3: Aufnehmen
Erstelle die Aufnahme erst, nachdem Entdecken und Probieren erfolgreich waren.
Aufnahmeprinzipien
1. Erzählerischer Ablauf (Storytelling Flow)
Plane das Video als Geschichte. Folge der vom Nutzer angegebenen Reihenfolge oder verwende diese Standardreihenfolge:
- Einstieg: Login oder Navigation zum Startpunkt
- Kontext: Zeige das Umfeld, damit sich die Zuschauer orientieren können
- Aktion: Führe die Hauptschritte des Arbeitsablaufs durch
- Variation: Zeige eine sekundäre Funktion wie Einstellungen, Theme oder Lokalisierung
- Ergebnis: Zeige das Resultat, die Bestätigung oder den neuen Zustand
2. Tempo (Pacing)
- Nach dem Login:
4s - Nach der Navigation:
3s - Nach einem Button-Klick:
2s - Zwischen Hauptschritten:
1.5-2s - Nach der letzten Aktion:
3s - Eingabeverzögerung:
25-40mspro Zeichen
3. Mauszeiger-Überlagerung (Cursor Overlay)
Füge einen SVG-Pfeilzeiger ein, der den Mausbewegungen folgt:
async function injectCursor(page) {
await page.evaluate(() => {
if (document.getElementById('demo-cursor')) return;
const cursor = document.createElement('div');
cursor.id = 'demo-cursor';
cursor.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 3L19 12L12 13L9 20L5 3Z" fill="white" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>
</svg>`;
cursor.style.cssText = `
position: fixed; z-index: 999999; pointer-events: none;
width: 24px; height: 24px;
transition: left 0.1s, top 0.1s;
filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3));
`;
cursor.style.left = '0px';
cursor.style.top = '0px';
document.body.appendChild(cursor);
document.addEventListener('mousemove', (e) => {
cursor.style.left = e.clientX + 'px';
cursor.style.top = e.clientY + 'px';
});
});
}
Rufe injectCursor(page) nach jeder Seitennavigation auf, da die Überlagerung bei Navigation zerstört wird.
4. Mausbewegung (Mouse Movement)
Teleportiere den Zeiger niemals. Bewege ihn vor dem Klicken zum Ziel:
async function moveAndClick(page, locator, label, opts = {}) {
const { postClickDelay = 800, ...clickOpts } = opts;
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
console.error(`WARNUNG: moveAndClick übersprungen - "${label}" nicht sichtbar`);
return false;
}
try {
await el.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const box = await el.boundingBox();
if (box) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 10 });
await page.waitForTimeout(400);
}
await el.click(clickOpts);
} catch (e) {
console.error(`WARNUNG: moveAndClick fehlgeschlagen bei "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
Jeder Aufruf sollte ein beschreibendes label für Debugging-Zwecke enthalten.
5. Tippen (Typing)
Tippe sichtbar, nicht sofort ausfüllen:
async function typeSlowly(page, locator, text, label, charDelay = 35) {
const el = typeof locator === 'string' ? page.locator(locator).first() : locator;
const visible = await el.isVisible().catch(() => false);
if (!visible) {
console.error(`WARNUNG: typeSlowly übersprungen - "${label}" nicht sichtbar`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. Scrollen (Scrolling)
Verwende sanftes Scrollen anstelle von Sprüngen:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Dashboard-Panning
Wenn du eine Dashboard- oder Übersichtsseite zeigst, bewege den Zeiger über die wichtigsten Elemente:
async function panElements(page, selector, maxCount = 6) {
const elements = await page.locator(selector).all();
for (let i = 0; i < Math.min(elements.length, maxCount); i++) {
try {
const box = await elements[i].boundingBox();
if (box && box.y < 700) {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 8 });
await page.waitForTimeout(600);
}
} catch (e) {
console.warn(`WARNUNG: panElements übersprungen Element ${i} (Selektor: "${selector}"): ${e.message}`);
}
}
}
8. Untertitel (Subtitles)
Füge eine Untertitel-Leiste am unteren Rand des Viewports ein:
async function injectSubtitleBar(page) {
await page.evaluate(() => {
if (document.getElementById('demo-subtitle')) return;
const bar = document.createElement('div');
bar.id = 'demo-subtitle';
bar.style.cssText = `
position: fixed; bottom: 0; left: 0; right: 0; z-index: 999998;
text-align: center; padding: 12px 24px;
background: rgba(0, 0, 0, 0.75);
color: white; font-family: -apple-system, "Segoe UI", sans-serif;
font-size: 16px; font-weight: 500; letter-spacing: 0.3px;
transition: opacity 0.3s;
pointer-events: none;
`;
bar.textContent = '';
bar.style.opacity = '0';
document.body.appendChild(bar);
});
}
async function showSubtitle(page, text) {
await page.evaluate((t) => {
const bar = document.getElementById('demo-subtitle');
if (!bar) return;
if (t) {
bar.textContent = t;
bar.style.opacity = '1';
} else {
bar.style.opacity = '0';
}
}, text);
if (text) await page.waitForTimeout(800);
}
Rufe injectSubtitleBar(page) zusammen mit injectCursor(page) nach jeder Navigation auf.
Verwendungsmuster:
await showSubtitle(page, 'Schritt 1 - Anmelden');
await showSubtitle(page, 'Schritt 2 - Dashboard-Übersicht');
await showSubtitle(page, '');
Richtlinien:
- Halte den Untertitel kurz, idealerweise unter 60 Zeichen.
- Verwende das Format
Schritt N - Aktionfür Konsistenz. - Lösche den Untertitel während längerer Pausen, in denen die UI für sich selbst sprechen kann.
Skriptvorlage
'use strict';
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
const BASE_URL = process.env.QA_BASE_URL || 'http://localhost:3000';
const VIDEO_DIR = path.join(__dirname, 'screenshots');
const OUTPUT_NAME = 'demo-FEATURE.webm';
const REHEARSAL = process.argv.includes('--rehearse');
// Füge hier injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible und panElements ein.
(async () => {
const browser = await chromium.launch({ headless: true });
if (REHEARSAL) {
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
// Navigiere durch den Ablauf und führe ensureVisible für jeden Selektor aus.
await browser.close();
return;
}
const context = await browser.newContext({
recordVideo: { dir: VIDEO_DIR, size: { width: 1280, height: 720 } },
viewport: { width: 1280, height: 720 }
});
const page = await context.newPage();
try {
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Schritt 1 - Anmelden');
// Anmeldeaktionen
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Schritt 2 - Dashboard-Übersicht');
// Dashboard panning
await showSubtitle(page, 'Schritt 3 - Hauptarbeitsablauf');
// Aktionssequenz
await showSubtitle(page, 'Schritt 4 - Ergebnis');
// Abschließende Einblendung
await showSubtitle(page, '');
} catch (err) {
console.error('DEMO-FEHLER:', err.message);
} finally {
await context.close();
const video = page.video();
if (video) {
const src = await video.path();
const dest = path.join(VIDEO_DIR, OUTPUT_NAME);
try {
fs.copyFileSync(src, dest);
console.log('Video gespeichert:', dest);
} catch (e) {
console.error('FEHLER: Video konnte nicht kopiert werden:', e.message);
console.error(' Quelle:', src);
console.error(' Ziel:', dest);
}
}
await browser.close();
}
})();
Verwendung:
# Phase 2: Probieren (Rehearse)
node demo-script.cjs --rehearse
# Phase 3: Aufnehmen (Record)
node demo-script.cjs
Checkliste vor der Aufnahme
- Entdeckungsphase abgeschlossen
- Probieren erfolgreich, alle Selektoren OK
- Headless-Modus aktiviert
- Auflösung auf
1280x720eingestellt - Mauszeiger- und Untertitel-Overlays nach jeder Navigation erneut eingefügt
-
showSubtitle(page, 'Schritt N - ...')bei wichtigen Übergängen verwendet -
moveAndClickfür alle Klicks mit beschreibenden Labels verwendet -
typeSlowlyfür sichtbare Eingaben verwendet - Keine stillen Catches; …
… (gekürzt für Übersetzung)