Communitygithub.com

Vidéos de démonstration pilotées par ElevenLabs

Construisez une vidéo de démonstration / visite guidée narrative d'une application web en cours d'exécution — Playwright enregistre l'écran, ElevenLabs synthétise la voix off et ffmpeg superpose la narration sur un fond musical doux (avec ducking automatique de la musique sous la voix).

Qu'est-ce que Vidéos de démonstration pilotées par ElevenLabs ?

Cette compétence fournit une recette reproductible pour transformer une application web en une vidéo de démonstration (mp4) professionnelle et narrée, sans éditeur vidéo ni réglage manuel. Le pipeline utilise Playwright pour enregistrer l'écran d'une visite scriptée, ElevenLabs pour synthétiser la voix off ligne par ligne (avec mise en cache pour économiser les crédits), et ffmpeg pour assembler le tout en ajoutant un fond musical ambiant (avec ducking par sidechain compression). L'idée clé est que chaque scène dure exactement le temps de sa ligne de narration, ce qui évite toute synchronisation manuelle. La compétence est conçue pour Node.js (18+) avec Playwright, ffmpeg/ffprobe et une clé API ElevenLabs. Son utilisation implique de copier un modèle de script, d'écrire les lignes de narration (environ 2,3 mots/seconde), de chorégraphier les actions de chaque scène (défilement, clics, etc.), puis d'exécuter le script en premier plan pour les démos interactives. Elle inclut de nombreuses astuces pour gagner du temps : mise en cache de la narration, résilience aux quotas (avec repli sur vidéo + musique), découpage du temps de chargement initial, choix de la netteté d'enregistrement, gestion robuste des champs de formulaire React, et bien plus encore.

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

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Que fait Vidéos de démonstration pilotées par ElevenLabs ?

Une recette reproductible pour transformer une application web en une vidéo de démonstration narrée et soignée au format mp4. Un seul script Node.js autonome par démo fait tout : synthétiser la voix off, enregistrer l'écran d'une visite chorégraphiée de l'application, et mixer la voix + la musique sous les images. Pas d'éditeur vidéo, pas de réglage manuel.

Le pipeline

ElevenLabs TTS ──► fichier .wav de narration par ligne (mis en cache)
                                   │
Playwright  ──► enregistre une visite scriptée (défilements, clics) de l'application live (.webm)
                                   │
ffmpeg      ──► coupe le temps de chargement initial, place chaque ligne de narration à son repère de scène,
               génère un fond musical ambiant, duck la musique sous la voix (sidechain compression),
               encode le fichier .mp4 final

L'idée clé : chaque scène dure exactement le temps de sa ligne de narration, de sorte que la voix tombe toujours sur la bonne image sans synchronisation manuelle.

Prérequis

  • Node 18+ et npm i -D playwright (utilise Chrome installé via channel: "chrome", ou remplacez par chromium intégré).
  • ffmpeg et ffprobe dans le PATH.
  • Une clé API ElevenLabs avec l'autorisation text-to-speech (les clés les moins chères n'ont souvent pas user_read, ne comptez donc pas sur le point de terminaison du quota — essayez simplement le TTS).
  • L'application qui tourne en local (par ex. npm run dev sur :3000) ; passez BASE_URL pour la remplacer.

Stockez la clé en dehors de git (par exemple un fichier demo/.eleven.key ignoré par git). Les fichiers .mp4 rendus sont volumineux — ignorez-les dans git et distribuez-les via un partage (Drive, etc.), pas le dépôt.

Comment l'utiliser

  1. Un script par démo. Copiez le modèle ci-dessous dans demo/build-<nom>.mjs. Chaque script est indépendant et écrit ses fichiers de travail dans demo/<nom>-work/.
  2. Écrivez la narration sous forme de tableau LINES — textuellement, une entrée par scène. Visez environ 2,3 mots/seconde lors du budget de temps.
  3. Chorégraphiez les scènes dans actions — une entrée par ligne, dans le même ordre. Une fonction asynchrone simple compose un plan fixe ; un objet {action, hold} compose le plan (action) puis effectue un panoramique/des clics pendant que la narration est jouée (hold).
  4. Exécutez-le en PREMIER PLAN pour toute démo qui pilote l'application (clics, remplissages de formulaires, changements d'identité/route) : node demo/build-<nom>.mjs. Les visites statiques avec seulement du défilement peuvent être exécutées en arrière-plan, mais les visites interactives ne peuvent souvent pas atteindre le serveur de développement ou piloter des entrées contrôlées depuis un sandbox en arrière-plan — donc en cas de doute, exécutez en premier plan.
  5. Ré-enregistrez après des changements d'interface. Les sélecteurs sont liés au DOM ; une refonte les cassra. Réexécutez le script affecté et vérifiez une image (ffmpeg -ss <t> -i out.mp4 -frames:v 1 frame.jpg).

Leçons qui font gagner des heures

  • Cachez la narration par index. Chaque ligne est l<i>.wav ; réutilisez-la si elle est présente afin que les réenregistrements ne dépensent pas de crédits supplémentaires. Pour modifier une ligne, éditez LINES[i] et supprimez ce l<i>.wav. Si vous insérez une ligne au milieu de la liste, décalez les wav mis en cache d'un cran (mv l7.wav l8.wav; mv l6.wav l7.wav; …) de sorte que seule la nouvelle ligne soit synthétisée — la boucle met en cache par index et ne vérifie pas le texte.
  • Résilience aux quotas. Si un appel TTS échoue, revenez à vidéo + musique uniquement et estimez la durée de chaque ligne à partir du nombre de mots, de sorte que la construction produise toujours un mp4.
  • Coupez le temps de chargement initial. Enregistrez le goto/seed/hydrate, marquez contentStart une fois que le premier plan est stable, et retirez -ss contentStart du début pour que la vidéo s'ouvre sur une image calme. Les repères de narration sont décalés de contentStart lors du mixage.
  • Hold = max(narration, panoramique). Pour les holds actifs, ajoutez du temps au moins égal à la durée de la ligne pour que la voix ne soit jamais coupée ; si l'interaction dure plus longtemps, laissez-la faire.
  • Enregistrez en net. Utilisez une grande fenêtre (par ex. 1920×1200) et appliquez document.documentElement.style.zoom (par ex. 1.5) via un script d'initialisation, de sorte que le calcul de la mise en page logique reste à votre taille de conception mais que l'image soit rendue plus nette.
  • Éliminez les défilements saccadés. Les superpositions fixes en plein écran avec mix-blend-* (grain de film, dégradés) forcent une recomposition complète à chaque image de défilement. Cachez-les pendant l'enregistrement : page.addStyleTag({content: ".grain{display:none!important}"}). Cachez également tout CTA/pied de page toujours visible que vous ne voulez pas dans l'image.
  • Pilotez les entrées contrôlées de manière robuste. Un <select> contrôlé par React ignore un simple selectOption. Définissez-le avec le setter natif + un événement change qui bouillonne, et réessayez jusqu'à ce qu'un localisateur de confirmation apparaisse :
    const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set;
    setter.call(el, value); el.dispatchEvent(new Event("change", { bubbles: true }));
    
  • Faites correspondre les guillemets typographiques dans les sélecteurs. Si les titres rendent des guillemets courbes (', ', "), une regex avec un ' droit ne correspondra pas. Utilisez . pour l'apostrophe : /Tell us who you.re looking for/i.
  • Localisez les questions/champs par leur étiquette, puis agissez à l'intérieur du bloc. Robuste aux changements de mise en page : page.locator("label", { hasText: q }).first().locator("xpath=..") puis .getByRole("radio"/"checkbox", { name }) à l'intérieur.
  • Fond musical + ducking. Synthétisez un pad ambiant en boucle à partir d'ondes sinusoïdales d'accords avec ffmpeg aevalsrc, puis sidechaincompress la musique sous la narration pour que la voix soit toujours claire. Gardez la musique ~0,4 et laissez le compresseur la faire baisser.

Modèle (demo/build-<nom>.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, "nom-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, "nom.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 ligne par scène, dans l'ordre des scènes.
const LINES = [
  "Première ligne — l'accroche, prononcée sur la première image stable.",
  "Deuxième ligne — narration pendant que la caméra panoramique sur la section suivante.",
  "Dernière ligne — une conclusion calme.",
];
const TAILS = LINES.map(() => 1.4); // temps de respiration après chaque ligne

// ── 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()));
}

// ── Fond musical ambiant (accord Lam–Fa–Do–Sol) ─────────────────────────────
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]);
}

// ── Aides à la chorégraphie ────────────────────────────────────────────────────
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) synthétiser la narration (mise en cache), mesurer les durées ────────────
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) enregistrer, en maintenant chaque plan pour la durée de sa ligne ───────
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}" }); // cacher les superpositions blend
await sleep(1500);
const contentStart = (Date.now() - t0) / 1000; // couper tout ce qui précède

const actions = [
  async () => { await sleep(300); },                                  // accroche fixe
  { action: async () => { await scrollToText(page, "Un Titre"); await sleep(300); },
    hold:   async () => { await smoothTo(page, 1600, Math.max(4000, clips[1].dur*1000)); } }, // panoramique pendant la ligne
  async () => { await smoothTo(page, 0, 2500); await sleep(500); },   // conclusion
];

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) mixer : couper le début, placer la narration aux repères, duck la musique ──
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(`TERMINÉ : ${OUT}  (${outDur.toFixed(1)}s)`);

Adaptation

  • Nouvelle voix : modifiez le filtre de nom dans voiceId(), ou codez en dur un voice_id.
  • Visites interactives : ajoutez des aides comme answerChoice/clickTab qui se limitent à un bloc par étiquette, en réessayant jusqu'à ce qu'un localisateur de confirmation apparaisse.

Skills associés