Grabador de Videos de Demostración UI
Graba videos de demostración pulidos de aplicaciones web usando la grabación de video de Playwright con un cursor superpuesto inyectado, ritmo natural y flujo narrativo.
Cuándo Usarlo
- El usuario pide un "video demo", "grabación de pantalla", "tutorial" o "walkthrough"
- El usuario quiere mostrar visualmente una característica o flujo de trabajo
- El usuario necesita un video para documentación, onboarding o presentación a interesados
Proceso de Tres Fases
Cada demo pasa por tres fases: Descubrir -> Ensayar -> Grabar. Nunca saltes directamente a la grabación.
Fase 1: Descubrir
Antes de escribir cualquier script, explora las páginas de destino para entender qué hay realmente.
Por qué
No puedes escribir un script para algo que no has visto. Los campos pueden ser <input> no <textarea>, los desplegables pueden ser componentes personalizados no <select>, y las cajas de comentarios pueden soportar @menciones o #etiquetas. Las suposiciones rompen las grabaciones silenciosamente.
Cómo
Navega a cada página en el flujo y vuelca sus elementos interactivos:
// Ejecuta esto para cada página en el flujo ANTES de escribir el script demo
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));
Qué Buscar
- Campos de formulario: ¿Son
<select>,<input>, desplegables personalizados o comboboxes? - Opciones de selección: Vuelca los valores Y los textos de las opciones. Los placeholders a menudo tienen
value="0"ovalue=""que parece no vacío. UsaArray.from(el.options).map(o => ({ value: o.value, text: o.text })). Omite opciones donde el texto incluya "Seleccionar" o el valor sea"0". - Texto enriquecido: ¿La caja de comentarios soporta
@menciones,#etiquetas, markdown o emojis? Revisa el texto del placeholder. - Campos obligatorios: ¿Qué campos bloquean el envío del formulario? Revisa
required,*en las etiquetas e intenta enviar vacío para ver errores de validación. - Contenido dinámico: ¿Aparecen campos después de llenar otros?
- Etiquetas de botones: Texto exacto como
"Enviar","Enviar Solicitud"o"Mandado". - Encabezados de columna de tabla: Para modales basados en tablas, mapea cada
input[type="number"]a su encabezado de columna en lugar de asumir que todas las entradas numéricas significan lo mismo.
Salida
Un mapa de campos para cada página, usado para escribir selectores correctos en el script. Ejemplo:
/purchase-requests/new:
- Código Presupuesto: <select> (primer select en la página, 4 opciones)
- Entrega Deseada: <input type="date">
- Contexto: <textarea> (no input)
- Tabla BOM: celdas editables en línea con span.cursor-pointer -> patrón input
- Enviar: <button> texto="Enviar"
/purchase-requests/N (detalle):
- Comentario: <input placeholder="Escribe un mensaje..."> soporta @usuario y #PR etiquetas
- Enviar: <button> texto="Enviar" (deshabilitado hasta que el input tenga contenido)
Fase 2: Ensayar
Ejecuta todos los pasos sin grabar. Verifica que cada selector resuelva.
Por qué
Los fallos silenciosos de selectores son la principal razón por la que se rompen las grabaciones demo. El ensayo los detecta antes de que desperdicies una grabación.
Cómo
Usa ensureVisible, un envoltorio que registra y falla con estruendo:
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 = `ENSAYO FALLÓ: "${label}" no encontrado - selector: ${typeof locator === 'string' ? locator : '(objeto locator)'}`;
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(' Elementos visibles:\n ' + found);
return false;
}
console.log(`ENSAYO OK: "${label}"`);
return true;
}
Estructura del Script de Ensayo
const steps = [
{ label: 'Campo email de inicio de sesión', selector: '#email' },
{ label: 'Enviar inicio de sesión', selector: 'button[type="submit"]' },
{ label: 'Botón Nueva Solicitud', selector: 'button:has-text("Nueva Solicitud")' },
{ label: 'Selector Código Presupuesto', selector: 'select' },
{ label: 'Fecha de entrega', selector: 'input[type="date"]:visible' },
{ label: 'Campo Descripción', selector: 'textarea:visible' },
{ label: 'Botón Añadir Artículo', selector: 'button:has-text("Añadir Artículo")' },
{ label: 'Botón Enviar', selector: 'button:has-text("Enviar")' },
];
let allOk = true;
for (const step of steps) {
if (!await ensureVisible(page, step.selector, step.label)) {
allOk = false;
}
}
if (!allOk) {
console.error('ENSAYO FALLÓ - corrige los selectores antes de grabar');
process.exit(1);
}
console.log('ENSAYO APROBADO - todos los selectores verificados');
Cuando el Ensayo Falla
- Lee el volcado de elementos visibles.
- Encuentra el selector correcto.
- Actualiza el script.
- Re-ejecuta el ensayo.
- Solo procede cuando todos los selectores pasen.
Fase 3: Grabar
Solo después de que las fases de descubrimiento y ensayo pasen, debes crear la grabación.
Principios de Grabación
1. Flujo Narrativo
Planifica el video como una historia. Sigue el orden especificado por el usuario, o usa este predeterminado:
- Entrada: Inicia sesión o navega al punto de partida
- Contexto: Recorre los alrededores para que los espectadores se orienten
- Acción: Realiza los pasos principales del flujo de trabajo
- Variación: Muestra una característica secundaria como configuración, tema o localización
- Resultado: Muestra el resultado, confirmación o nuevo estado
2. Ritmo
- Después de inicio de sesión:
4s - Después de navegación:
3s - Después de hacer clic en un botón:
2s - Entre pasos principales:
1.5-2s - Después de la acción final:
3s - Retraso de escritura:
25-40mspor carácter
3. Superposición de Cursor
Inyecta un cursor de flecha SVG que siga los movimientos del ratón:
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';
});
});
}
Llama a injectCursor(page) después de cada navegación de página porque la superposición se destruye al navegar.
4. Movimiento del Ratón
Nunca teletransportes el cursor. Muévelo al objetivo antes de hacer clic:
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(`AVISO: moveAndClick omitido - "${label}" no visible`);
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(`AVISO: moveAndClick falló en "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
Cada llamada debe incluir un label descriptivo para depuración.
5. Escritura
Escribe visiblemente, no con llenado instantáneo:
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(`AVISO: typeSlowly omitido - "${label}" no visible`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. Desplazamiento
Usa desplazamiento suave en lugar de saltos:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Recorrido de Panel
Al mostrar un panel de control o página de resumen, mueve el cursor sobre los elementos clave:
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(`AVISO: panElements omitió elemento ${i} (selector: "${selector}"): ${e.message}`);
}
}
}
8. Subtítulos
Inyecta una barra de subtítulos en la parte inferior del viewport:
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);
}
Llama a injectSubtitleBar(page) junto con injectCursor(page) después de cada navegación.
Patrón de uso:
await showSubtitle(page, 'Paso 1 - Iniciando sesión');
await showSubtitle(page, 'Paso 2 - Resumen del panel');
await showSubtitle(page, '');
Directrices:
- Mantén el texto del subtítulo corto, idealmente menos de 60 caracteres.
- Usa el formato
Paso N - Acciónpara consistencia. - Limpia el subtítulo durante pausas largas donde la interfaz pueda hablar por sí misma.
Plantilla de Script
'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-CARACTERÍSTICA.webm';
const REHEARSAL = process.argv.includes('--rehearse');
// Pega injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible, y panElements aquí.
(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();
// Navega a través del flujo y ejecuta ensureVisible para cada selector.
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, 'Paso 1 - Iniciando sesión');
// acciones de inicio de sesión
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Paso 2 - Resumen del panel');
// recorrer panel
await showSubtitle(page, 'Paso 3 - Flujo de trabajo principal');
// secuencia de acciones
await showSubtitle(page, 'Paso 4 - Resultado');
// revelación final
await showSubtitle(page, '');
} catch (err) {
console.error('ERROR DE DEMO:', 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 guardado:', dest);
} catch (e) {
console.error('ERROR: No se pudo copiar el video:', e.message);
console.error(' Origen:', src);
console.error(' Destino:', dest);
}
}
await browser.close();
}
})();
Uso:
# Fase 2: Ensayar
node demo-script.cjs --rehearse
# Fase 3: Grabar
node demo-script.cjs
Lista de Verificación Antes de Grabar
- Fase de descubrimiento completada
- Ensayo aprobado con todos los selectores OK
- Modo headless habilitado
- Resolución configurada a
1280x720 - Superposiciones de cursor y subtítulos reinyectadas después de cada navegación
-
showSubtitle(page, 'Paso N - ...')usado en transiciones principales -
moveAndClickusado para todos los clics con etiquetas descriptivas -
typeSlowlyusado para entrada visible - [ } Sin capturas silenciosas;