Gravador de Vídeos de Demonstração de UI
Grave vídeos de demonstração polidos de aplicações web usando a gravação de vídeo do Playwright com um overlay de cursor injetado, ritmo natural e fluxo narrativo.
Quando Usar
- O usuário pede um "vídeo de demonstração", "gravação de tela", "passo a passo" ou "tutorial"
- O usuário quer mostrar visualmente um recurso ou fluxo de trabalho
- O usuário precisa de um vídeo para documentação, integração ou apresentação para partes interessadas
Processo de Três Fases
Cada demonstração passa por três fases: Descobrir -> Ensaia -> Gravar. Nunca pule direto para a gravação.
Fase 1: Descobrir
Antes de escrever qualquer script, explore as páginas alvo para entender o que realmente existe.
Por que
Você não pode roteirizar o que não viu. Campos podem ser <input> em vez de <textarea>, menus suspensos podem ser componentes personalizados em vez de <select>, e caixas de comentários podem suportar @menções ou #tags. Suposições quebram as gravações silenciosamente.
Como
Navegue para cada página no fluxo e despeje seus elementos interativos:
// Execute isso para cada página no fluxo ANTES de escrever o script de demonstração
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));
O que procurar
- Campos de formulário: São
<select>,<input>, menus suspensos personalizados ou comboboxes? - Opções de seleção: Despeje os valores E os textos das opções. Placeholders frequentemente têm
value="0"ouvalue=""que parecem não vazios. UseArray.from(el.options).map(o => ({ value: o.value, text: o.text })). Pule opções onde o texto inclui "Selecionar" ou o valor é"0". - Texto rico: A caixa de comentários suporta
@menções,#tags, markdown ou emoji? Verifique o texto do placeholder. - Campos obrigatórios: Quais campos bloqueiam o envio do formulário? Verifique
required,*nos rótulos e tente enviar vazio para ver erros de validação. - Conteúdo dinâmico: Os campos aparecem após outros campos serem preenchidos?
- Rótulos dos botões: Texto exato como
"Enviar","Enviar Solicitação"ou"Enviar". - Cabeçalhos de colunas da tabela: Para modais baseados em tabela, mapeie cada
input[type="number"]para seu cabeçalho de coluna em vez de assumir que todas as entradas numéricas significam a mesma coisa.
Saída
Um mapa de campos para cada página, usado para escrever seletores corretos no script. Exemplo:
/purchase-requests/new:
- Código Orçamentário: <select> (primeiro select na página, 4 opções)
- Entrega Desejada: <input type="date">
- Contexto: <textarea> (não input)
- Tabela BOM: células editáveis inline com span.cursor-pointer -> padrão input
- Enviar: <button> text="Enviar"
/purchase-requests/N (detalhe):
- Comentário: <input placeholder="Digite uma mensagem..."> suporta tags @user e #PR
- Enviar: <button> text="Enviar" (desabilitado até que o input tenha conteúdo)
Fase 2: Ensaia
Execute todas as etapas sem gravar. Verifique se cada seletor resolve.
Por que
Falhas silenciosas de seletores são a principal razão pela qual as gravações de demonstração quebram. O ensaio as pega antes que você perca uma gravação.
Como
Use ensureVisible, um wrapper que registra e falha alto:
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 = `FALHA NO ENSAIO: "${label}" não encontrado - seletor: ${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 visíveis:\n ' + found);
return false;
}
console.log(`ENSAIO OK: "${label}"`);
return true;
}
Estrutura do script de ensaio
const steps = [
{ label: 'Campo de email de login', selector: '#email' },
{ label: 'Botão de envio de login', selector: 'button[type="submit"]' },
{ label: 'Botão Nova Solicitação', selector: 'button:has-text("Nova Solicitação")' },
{ label: 'Selecionar Código Orçamentário', selector: 'select' },
{ label: 'Data de entrega', selector: 'input[type="date"]:visible' },
{ label: 'Campo de descrição', selector: 'textarea:visible' },
{ label: 'Botão Adicionar Item', selector: 'button:has-text("Adicionar Item")' },
{ label: 'Botão 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('ENSAIO FALHOU - corrija os seletores antes de gravar');
process.exit(1);
}
console.log('ENSAIO PASSOU - todos os seletores verificados');
Quando o ensaio falha
- Leia o despejo de elementos visíveis.
- Encontre o seletor correto.
- Atualize o script.
- Execute o ensaio novamente.
- Prossiga apenas quando todos os seletores passarem.
Fase 3: Gravar
Somente após a descoberta e o ensaio passarem, crie a gravação.
Princípios de Gravação
1. Fluxo Narrativo
Planeje o vídeo como uma história. Siga a ordem especificada pelo usuário ou use este padrão:
- Entrada: Faça login ou navegue até o ponto de partida
- Contexto: Passeie pelos arredores para que os espectadores se orientem
- Ação: Execute as principais etapas do fluxo de trabalho
- Variação: Mostre um recurso secundário como configurações, tema ou localização
- Resultado: Mostre o resultado, confirmação ou novo estado
2. Ritmo
- Após o login:
4s - Após a navegação:
3s - Após clicar em um botão:
2s - Entre etapas principais:
1.5-2s - Após a ação final:
3s - Atraso de digitação:
25-40mspor caractere
3. Overlay de Cursor
Injete um cursor de seta SVG que segue os movimentos do mouse:
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';
});
});
}
Chame injectCursor(page) após cada navegação de página, pois o overlay é destruído ao navegar.
4. Movimento do Mouse
Nunca teletransporte o cursor. Mova-se para o alvo antes de clicar:
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 pulado - "${label}" não visível`);
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 falhou em "${label}": ${e.message}`);
return false;
}
await page.waitForTimeout(postClickDelay);
return true;
}
Cada chamada deve incluir um label descritivo para depuração.
5. Digitação
Digite visivelmente, não preencha instantaneamente:
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 pulado - "${label}" não visível`);
return false;
}
await moveAndClick(page, el, label);
await el.fill('');
await el.pressSequentially(text, { delay: charDelay });
await page.waitForTimeout(500);
return true;
}
6. Rolagem
Use rolagem suave em vez de saltos:
await page.evaluate(() => window.scrollTo({ top: 400, behavior: 'smooth' }));
await page.waitForTimeout(1500);
7. Passeio pelo Painel
Ao mostrar um painel ou página de visão geral, mova o cursor pelos elementos principais:
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 pulou elemento ${i} (seletor: "${selector}"): ${e.message}`);
}
}
}
8. Legendas
Injete uma barra de legenda na parte inferior da janela de visualização:
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);
}
Chame injectSubtitleBar(page) junto com injectCursor(page) após cada navegação.
Padrão de uso:
await showSubtitle(page, 'Passo 1 - Fazendo login');
await showSubtitle(page, 'Passo 2 - Visão geral do painel');
await showSubtitle(page, '');
Diretrizes:
- Mantenha o texto da legenda curto, idealmente abaixo de 60 caracteres.
- Use o formato
Passo N - Açãopara consistência. - Limpe a legenda durante pausas longas onde a UI pode falar por si mesma.
Modelo 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-FUNCIONALIDADE.webm';
const REHEARSAL = process.argv.includes('--rehearse');
// Cole injectCursor, injectSubtitleBar, showSubtitle, moveAndClick,
// typeSlowly, ensureVisible e panElements aqui.
(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();
// Navegue pelo fluxo e execute ensureVisible para cada seletor.
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, 'Passo 1 - Fazendo login');
// ações de login
await page.goto(`${BASE_URL}/dashboard`);
await injectCursor(page);
await injectSubtitleBar(page);
await showSubtitle(page, 'Passo 2 - Visão geral do painel');
// passear pelo painel
await showSubtitle(page, 'Passo 3 - Fluxo de trabalho principal');
// sequência de ações
await showSubtitle(page, 'Passo 4 - Resultado');
// revelação final
await showSubtitle(page, '');
} catch (err) {
console.error('ERRO NA DEMONSTRAÇÃO:', 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('Vídeo salvo:', dest);
} catch (e) {
console.error('ERRO: Falha ao copiar vídeo:', e.message);
console.error(' Origem:', src);
console.error(' Destino:', dest);
}
}
await browser.close();
}
})();
Uso:
# Fase 2: Ensaia
node demo-script.cjs --rehearse
# Fase 3: Gravar
node demo-script.cjs
Lista de Verificação Antes de Gravar
- Fase de descoberta concluída
- Ensaio passa com todos os seletores OK
- Modo headless ativado
- Resolução configurada para
1280x720 - Overlays de cursor e legenda reinjetados após cada navegação
-
showSubtitle(page, 'Passo N - ...')usado nas principais transições -
moveAndClickusado para todos os cliques com rótulos descritivos -
typeSlowlyusado para entrada visível - Nenhum catch silencioso;