Communitygithub.com

monogramdesign/skills

Crea un video narrado de demostración/recorrido de una aplicación web en ejecución — Playwright graba la pantalla de un tour guiado, ElevenLabs sintetiza la voz en off y ffmpeg superpone la narración sobre una suave base musical (con ducking de la música bajo la voz). Útil cuando se necesita un video demo con voz de IA, una grabación de pantalla con narración, un recorrido/ reel de producto, o para regrabar una demo existente tras cambios en la UI.

¿Qué es skills?

Esta skill proporciona una receta repetible para convertir una aplicación web en ejecución en un video demo narrado en MP4, todo sin editor de video ni sincronización manual. El proceso completo se ejecuta desde un único script de Node.js que coordina tres herramientas clave: ElevenLabs para síntesis de voz (TTS), Playwright para grabación de pantalla con un recorrido orquestado (desplazamientos, clics) y ffmpeg para la postproducción (recorte del inicio, posicionamiento de cada línea de narración en su escena correspondiente, generación de una base musical ambiental con ducking automático bajo la voz). La idea central es que cada escena dura exactamente lo que dura su línea de narración, por lo que la voz siempre cae en el fotograma correcto sin necesidad de sincronización manual. Es compatible con Node 18+, requiere Chrome (a través de Playwright), ffmpeg/ffprobe en el PATH y una clave API de ElevenLabs. El script incluye características robustas como caché de narración por índice (para no gastar créditos en regrabaciones), tolerancia a fallos de TTS (si falla, produce el video solo con música), y plantillas para ayudantes de coreografía (scroll suave, interacción con inputs controlados por React, etc.). Ideal para desarrolladores que necesitan crear demos de producto de alta calidad de forma automatizada y repetible.

Compatible con~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/monogramdesign/skills/tree/HEAD/skills/elevenlabs-demo-video

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Videos demo con ElevenLabs

Una receta repetible para convertir una aplicación web en ejecución en un video mp4 demo narrado y pulido. Un único script de Node por demo lo hace todo: sintetizar la voz en off, grabar la pantalla de un tour coreografiado de la aplicación y combinar la voz + música bajo el metraje. Sin editor de video, sin tiempos manuales.

El pipeline

ElevenLabs TTS ──► archivo .wav de narración por línea (en caché)
                                   │
Playwright  ──► graba un recorrido con scroll/clics guiado de la app en vivo (.webm)
                                   │
ffmpeg      ──► recorta el inicio, coloca cada línea de narración en su escena
               correspondiente, genera una base musical ambiental, aplica ducking
               a la música bajo la voz (compresión sidechain), codifica el .mp4 final

La idea clave: cada escena dura exactamente lo que dura su línea de narración, así que la voz siempre cae en el fotograma correcto sin necesidad de sincronización manual.

Prerrequisitos

  • Node 18+ y npm i -D playwright (usa Chrome instalado a través de channel: "chrome", o cámbialo al chromium empaquetado).
  • ffmpeg y ffprobe en PATH.
  • Una clave API de ElevenLabs con permiso de texto a voz (las claves más baratas a menudo carecen de user_read, así que no dependas del endpoint de cuota; solo prueba TTS).
  • La aplicación ejecutándose localmente (ej. npm run dev en :3000); pasa BASE_URL para anular.

Guarda la clave fuera de git (ej. un archivo demo/.eleven.key ignorado por git). Los archivos .mp4 generados son grandes — ignóralos con git y distribúyelos mediante un compartido (Drive, etc.), no en el repositorio.

Cómo usarlo

  1. Un script por demo. Copia la plantilla de abajo a demo/build-<nombre>.mjs. Cada script es independiente y escribe sus archivos temporales en demo/<nombre>-work/.
  2. Escribe la narración como el arreglo LINES — textual, una entrada por escena. Apunta a ~2.3 palabras/segundo al presupuestar el tiempo.
  3. Coreografía las escenas en actions — una entrada por línea, en el mismo orden. Una función asíncrona simple compone una toma estática; un objeto {action, hold} compone la toma (action) y luego realiza un paneo/clics mientras se reproduce la narración (hold).
  4. Ejecútalo en PRIMER PLANO para cualquier demo que maneje la aplicación (clics, llenado de formularios, cambios de identidad/ruta): node demo/build-<nombre>.mjs. Los tours estáticos de solo desplazamiento pueden ejecutarse en segundo plano, pero los interactivos a menudo no pueden alcanzar el servidor de desarrollo o manejar entradas controladas desde un sandbox en segundo plano — así que, en caso de duda, ejecuta en primer plano.
  5. Regraba después de cambios en la UI. Los selectores están vinculados al DOM; un rediseño los romperá. Vuelve a ejecutar el script afectado y verifica un fotograma (ffmpeg -ss <t> -i out.mp4 -frames:v 1 frame.jpg).

Lecciones que ahorran horas

  • Cachea la narración por índice. Cada línea es l<i>.wav; reutilízala si existe, así las regrabaciones no gastan créditos. Para cambiar una línea, edita LINES[i] y elimina ese l<i>.wav. Si insertas una línea en medio de la lista, desplaza los wavs cacheados hacia arriba en uno (mv l7.wav l8.wav; mv l6.wav l7.wav; …) así solo la nueva línea se sintetiza — el bucle almacena en caché por índice y no verifica el texto.
  • Resiliencia de cuota. Si falla alguna llamada TTS, recurre a solo metraje + música y estima la duración de cada línea a partir del recuento de palabras, para que la compilación siempre produzca un mp4.
  • Recorta el inicio. Graba el goto/hidratación inicial, marca contentStart una vez que la primera toma está estable, y quita -ss contentStart del frente para que el video abra en un fotograma tranquilo. Las señales de narración se desplazan por contentStart al momento de la combinación.
  • Hold = máximo(narración, paneo). Para holds activos, alarga al menos la duración de la línea para que la voz nunca se corte; si la interacción dura más, déjalo.
  • Graba nítido. Usa un viewport grande (ej. 1920×1200) y aplica document.documentElement.style.zoom (ej. 1.5) mediante un script de inicio, para que el diseño lógico se mantenga en tu tamaño de diseño pero el fotograma se renderice más nítido.
  • Elimina el scroll entrecortado. Las superposiciones fijas de viewport completo con mix-blend-* (grano de película, degradados) fuerzan una recomposición completa en cada fotograma de scroll. Ocúltalos durante la grabación: page.addStyleTag({content: ".grain{display:none!important}"}). También oculta cualquier CTA/pie de página fijo que no quieras en el fotograma.
  • Maneja entradas controladas de forma robusta. Un <select> controlado por React ignora un selectOption simple. Establécelo con el setter nativo + un evento change que burbujee, y reintenta hasta que aparezca un localizador de confirmación:
    const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set;
    setter.call(el, value); el.dispatchEvent(new Event("change", { bubbles: true }));
    
  • Empareja comillas tipográficas en los selectores. Si los encabezados renderizan comillas curvas (', ', "), una expresión regular con una ' recta no coincidirá. Usa . para el apóstrofe: /Tell us who you.re looking for/i.
  • Localiza preguntas/campos por su etiqueta, luego actúa dentro del bloque. Robusto a cambios de diseño: page.locator("label", { hasText: q }).first().locator("xpath=..") luego .getByRole("radio"/"checkbox", { name }) dentro de él.
  • Base musical + ducking. Sintetiza un pad ambiental en bucle a partir de ondas seno de acordes con ffmpeg aevalsrc, luego sidechaincompress la música bajo la narración para que la voz siempre se lea con claridad. Mantén la música ~0.4 y deja que el compresor la baje.

Plantilla (demo/build-<nombre>.mjs)

import { chromium } from "playwright";
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";

const DIR = path.resolve("demo");
const WORK = path.join(DIR, "nombre-work");
fs.mkdirSync(WORK, { recursive: true });
const KEY = fs.readFileSync(path.join(DIR, ".eleven.key"), "utf8").trim();
const BASE = process.env.BASE_URL || "http://localhost:3000";
const OUT = path.join(DIR, "nombre.mp4");
const W = 1920, H = 1200, ZOOM = 1.5;

const sh = (c, a) => execFileSync(c, a, { stdio: ["ignore", "pipe", "pipe"] }).toString();
const probe = (f) => parseFloat(sh("ffprobe", ["-v","error","-show_entries","format=duration","-of","default=noprint_wrappers=1:nokey=1", f]).trim());
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 1 línea por escena, en orden de escena.
const LINES = [
  "Línea de apertura — el gancho, hablado sobre el primer fotograma estable.",
  "Segunda línea — narra mientras la cámara panea a través de la siguiente sección.",
  "Línea de cierre — un cierre tranquilo.",
];
const TAILS = LINES.map(() => 1.4); // espacio de respiro después de cada línea

// ── ElevenLabs ──────────────────────────────────────────────────────────────
async function voiceId() {
  const r = await fetch("https://api.elevenlabs.io/v1/voices", { headers: { "xi-api-key": KEY } });
  const { voices } = await r.json();
  return (voices.find((v) => v.name.toLowerCase().startsWith("jessica")) || voices[0]).voice_id;
}
async function tts(id, text, out) {
  const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${id}?output_format=mp3_44100_128`, {
    method: "POST", headers: { "xi-api-key": KEY, "content-type": "application/json" },
    body: JSON.stringify({ text, model_id: "eleven_multilingual_v2",
      voice_settings: { stability: 0.55, similarity_boost: 0.8, style: 0, use_speaker_boost: true } }),
  });
  if (!r.ok) throw new Error(`tts ${r.status} ${await r.text()}`);
  fs.writeFileSync(out, Buffer.from(await r.arrayBuffer()));
}

// ── Base musical ambiental (pad Am–F–C–G) ───────────────────────────────────
function buildMusic(total, out) {
  const chords = [[220,261.63,329.63],[174.61,220,261.63],[261.63,329.63,392],[196,246.94,293.66]];
  const CH = 6;
  const files = chords.map((freqs, i) => {
    const expr = freqs.map((f) => `0.17*sin(2*PI*${f}*t)`).join("+");
    const f = path.join(WORK, `chord${i}.wav`);
    sh("ffmpeg", ["-y","-f","lavfi","-i",`aevalsrc=${expr}:duration=${CH}:sample_rate=44100`,
      "-af", `afade=t=in:d=1.4,afade=t=out:st=${CH-1.4}:d=1.4`, f]);
    return f;
  });
  const list = path.join(WORK, "chords.txt");
  fs.writeFileSync(list, files.map((f) => `file '${f}'`).join("\n"));
  const loop = path.join(WORK, "loop.wav");
  sh("ffmpeg", ["-y","-f","concat","-safe","0","-i",list,"-c","copy", loop]);
  const loops = Math.ceil(total / (chords.length * CH)) + 1;
  sh("ffmpeg", ["-y","-stream_loop",String(loops),"-i",loop,"-af",
    ["aecho=0.8:0.88:70|130:0.25|0.18","lowpass=f=2200","tremolo=f=0.12:d=0.25",
     "aformat=channel_layouts=stereo","afade=t=in:d=0.5",
     `afade=t=out:st=${Math.max(0,total-3)}:d=3`,`atrim=0:${total}`].join(","), out]);
}

// ── Ayudantes de coreografía ────────────────────────────────────────────────
async function smoothTo(page, y, ms) {
  await page.evaluate(({ y, ms }) => new Promise((res) => {
    const s = window.scrollY, d = y - s, t0 = performance.now();
    const e = (t) => (t < 0.5 ? 2*t*t : -1 + (4-2*t)*t);
    const f = (n) => { const p = Math.min(1, (n-t0)/ms); window.scrollTo(0, s + d*e(p)); p < 1 ? requestAnimationFrame(f) : res(); };
    requestAnimationFrame(f);
  }), { y, ms });
}
async function scrollToText(page, text, ms = 1500, offset = 150) {
  const y = await page.evaluate(({ text, offset }) => {
    const all = [...document.querySelectorAll("h1,h2,h3,h4,p,span,button,a,li")].filter((e) => e.getClientRects().length);
    let el = all.find((e) => (e.textContent || "").trim() === text)
          || all.filter((e) => (e.textContent || "").includes(text)).sort((a,b)=>a.textContent.length-b.textContent.length)[0];
    return el ? el.getBoundingClientRect().top + window.scrollY - offset : null;
  }, { text, offset });
  if (y != null) await smoothTo(page, Math.max(0, y), ms);
}

// ── 1) sintetizar narración (en caché), medir duraciones ────────────────────
const clips = []; let vid = null, narrationOk = true;
for (let i = 0; i < LINES.length; i++) {
  const mp3 = path.join(WORK, `l${i}.mp3`), wav = path.join(WORK, `l${i}.wav`);
  if (fs.existsSync(wav)) { clips.push({ wav, dur: probe(wav) }); continue; }
  try {
    if (!vid) vid = await voiceId();
    await tts(vid, LINES[i], mp3);
    sh("ffmpeg", ["-y","-i",mp3,"-ar","44100","-ac","2", wav]);
    clips.push({ wav, dur: probe(wav) });
  } catch (e) {
    narrationOk = false;
    clips.push({ wav: null, dur: LINES[i].trim().split(/\s+/).length / 2.3 });
  }
}

// ── 2) grabar, manteniendo cada toma durante su línea ───────────────────────
const browser = await chromium.launch({ channel: "chrome", headless: true });
const context = await browser.newContext({ viewport: { width: W, height: H }, recordVideo: { dir: WORK, size: { width: W, height: H } } });
await context.addInitScript((z) => {
  const apply = () => { try { document.documentElement.style.zoom = String(z); } catch {} };
  apply(); document.addEventListener("DOMContentLoaded", apply);
}, ZOOM);
const page = await context.newPage();
page.setDefaultTimeout(20000);
const video = page.video();
const t0 = Date.now();

await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" });
await page.evaluate(() => (document.fonts ? document.fonts.ready : null));
await page.addStyleTag({ content: ".grain{display:none !important}" }); // ocultar superposiciones con blend
await sleep(1500);
const contentStart = (Date.now() - t0) / 1000; // recortar todo antes de esto

const actions = [
  async () => { await sleep(300); },                                  // gancho estático
  { action: async () => { await scrollToText(page, "Algún Encabezado"); await sleep(300); },
    hold:   async () => { await smoothTo(page, 1600, Math.max(4000, clips[1].dur*1000)); } }, // paneo durante la línea
  async () => { await smoothTo(page, 0, 2500); await sleep(500); },   // cierre
];

const cues = [];
for (let i = 0; i < actions.length; i++) {
  const sc = actions[i];
  const act = typeof sc === "function" ? sc : sc.action;
  const hold = typeof sc === "function" ? null : sc.hold;
  await act();
  cues.push((Date.now() - t0) / 1000);
  if (hold) {
    const start = Date.now();
    await hold();
    const min = clips[i].dur + TAILS[i], elapsed = (Date.now() - start) / 1000;
    if (elapsed < min) await sleep((min - elapsed) * 1000);
  } else {
    await sleep((clips[i].dur + TAILS[i]) * 1000);
  }
}
await sleep(400);
await context.close();
await browser.close();

// ── 3) combinación: recortar inicio, colocar narración en señales, ducking a la música ──────────────────
const raw = await video.path();
const outDur = probe(raw) - contentStart;
const music = path.join(WORK, "music.wav");
buildMusic(outDur, music);

if (!narrationOk) {
  sh("ffmpeg", ["-y","-ss",String(contentStart),"-i",raw,"-i",music,
    "-filter_complex","[1:a]volume=0.7[a]","-map","0:v","-map","[a]",
    "-c:v","libx264","-preset","medium","-crf","18","-pix_fmt","yuv420p",
    "-c:a","aac","-b:a","192k","-movflags","+faststart","-t",String(outDur), OUT]);
} else {
  const lead = 0.35, narr = path.join(WORK, "narration.wav");
  const inputs = clips.flatMap((c) => ["-i", c.wav]);
  const fc =
    clips.map((c, i) => { const at = Math.max(0, Math.round((cues[i]-contentStart+lead)*1000)); return `[${i}:a]adelay=${at}|${at}[d${i}]`; }).join(";") +
    ";" + clips.map((_, i) => `[d${i}]`).join("") +
    `amix=inputs=${clips.length}:normalize=0:dropout_transition=0[mix];[mix]apad=whole_dur=${outDur},volume=1.25[a]`;
  sh("ffmpeg", ["-y", ...inputs, "-filter_complex", fc, "-map","[a]", narr]);
  sh("ffmpeg", ["-y","-ss",String(contentStart),"-i",raw,"-i",music,"-i",narr,
    "-filter_complex",[
      "[1:a]volume=0.42[m]","[2:a]asplit=2[k][n]",
      "[m][k]sidechaincompress=threshold=0.035:ratio=9:attack=15:release=350[md]",
      "[md][n]amix=inputs=2:normalize=0:duration=longest[a]",
    ].join(";"),
    "-map","0:v","-map","[a]",
    "-c:v","libx264","-preset","medium","-crf","18","-pix_fmt","yuv420p",
    "-c:a","aac","-b:a","192k","-movflags","+faststart","-t",String(outDur), OUT]);
}
console.log(`LISTO: ${OUT}  (${outDur.toFixed(1)}s)`);

Adaptarlo

  • Nueva voz: cambia el filtro de nombre en voiceId(), o codifica un voice_id.
  • Tours interactivos: añade ayudantes como answerChoice/clickTab que se delimiten a un bloque por etiqueta, reintentando hasta que aparezca un confirmador.

Créditos

Skill creada por Monogram.

Skills relacionados