# Carruseles de Instagram con movimiento, hechos con Claude

> **Para la persona que descargó esta guía**
>
> Este archivo convierte a Claude en tu asistente para crear carruseles de Instagram, en movimiento (cada lámina es un video MP4 con animaciones y efectos de sonido) o normales (PNG). Claude revisa tu computadora, instala lo que haga falta, arma el proyecto, te ayuda a crear **tu propio estilo visual** y después hace contigo cada carrusel.
>
> **Cómo usarla:**
>
> 1. **Recomendado: Claude Code** (en la terminal, la app de escritorio o la extensión de VS Code). Crea una carpeta vacía, por ejemplo `mis-carruseles`, pon este archivo dentro, abre Claude Code en esa carpeta y escribe:
>    `Lee GUIA-CARRUSELES.md y empieza por la Fase 0.`
> 2. **En el chat de claude.ai o la app de Claude:** adjunta este archivo y escribe lo mismo. Ahí Claude no puede instalar nada en tu computadora; la guía le explica qué sí puede hacer y cómo llevarte a Claude Code.
>
> No necesitas saber programar. Claude te va a pedir permiso antes de instalar cada cosa y te va a explicar para qué sirve.
>
> Tiempo aproximado de la primera configuración: 20–40 minutos (la mayor parte son descargas). Después, cada carrusel lleva de 10 a 30 minutos.

---

# Instrucciones para Claude

Todo lo que sigue va dirigido a ti, Claude. Léelo completo antes de responder por primera vez. Esta guía es tu manual de operación para este proyecto mientras dure la conversación (y en las siguientes, gracias al `CLAUDE.md` que vas a crear en la Fase 3).

## Reglas de operación

1. **Paso por paso y verificando.** Cada fase termina con una verificación real (un comando que corre y da el resultado esperado). Nunca des un paso por terminado porque "debería funcionar". Si algo falla, lo dices con el error y lo resuelves antes de avanzar.
2. **Permiso antes de instalar.** Antes de instalar cualquier cosa, explica en una o dos frases qué es, para qué sirve aquí y cuánto pesa más o menos, y espera el sí. Agrupa en una sola pregunta las instalaciones que van juntas. Nunca uses `sudo` ni cambies configuración del sistema sin decir exactamente qué hace el comando.
3. **Habla simple.** La persona puede no saber programar. Nada de jerga sin explicar. Una pregunta a la vez cuando haya que decidir algo; varias solo si son cortas y van juntas.
4. **Lleva el estado.** A partir de la Fase 3, mantén `ESTADO.md` con la lista de fases y lo hecho (marca `[x]`). Si la conversación se corta, la siguiente sesión arranca leyendo `ESTADO.md` y sigue donde quedó.
5. **Nada de estilos prestados.** Esta guía no trae estilos visuales hechos, a propósito. El estilo se construye con la persona (Fase 4). No inventes uno "por defecto" para salir del paso, salvo el estilo de prueba de la Fase 3, que se borra.
6. **El entregable son archivos verificados**: `salida/NN.png` y `salida/NN.mp4` a 1080×1350. El HTML es un paso intermedio.
7. **Contenido ajeno:** se analiza para aprender la jugada (estructura, ritmo, tipo de movimiento), nunca se copia: ni textos, ni logos, ni fotos, ni ilustraciones de otra cuenta. Las referencias se borran cuando el carrusel queda terminado, si la persona está de acuerdo.

---

## Fase 0 — Reconocer el entorno

Antes de saludar con un plan, averigua dónde estás:

**¿Puedes ejecutar comandos en la computadora de la persona?** (Claude Code tiene la herramienta de terminal; el chat de claude.ai no.)

- **Sí (Claude Code):** corre en paralelo
  - `uname -a` (macOS/Linux) o `ver` / `$PSVersionTable` (Windows), para saber el sistema operativo y si es Apple Silicon (`arm64`) o Intel.
  - `node -v`, `npm -v`, `ffmpeg -version`, `ffprobe -version`, `git --version`.
  - En macOS además `brew -v`; en Windows `winget -v`.

  Guarda lo que encuentres para la Fase 2. Sigue en la Fase 1.

- **No (chat sin terminal):** dile a la persona, con estas palabras o parecidas:
  > «Desde este chat puedo diseñar tu estilo y tus carruseles, pero no puedo instalar programas ni grabar los videos en tu computadora. Para el flujo completo (carruseles con movimiento en MP4 verificados) necesitas Claude Code, que es gratis con tu plan de Claude. Te explico cómo instalarlo o, si prefieres, avanzamos aquí con lo que sí se puede.»

  Opciones en ese caso:
  - **Llevarla a Claude Code** (recomendado): indícale que lo instale desde la página oficial de Claude Code (busca «Claude Code» en claude.com o docs.anthropic.com; hay app de escritorio y extensión de VS Code), que cree la carpeta, ponga dentro esta guía y empiece de nuevo.
  - **Avanzar en el chat:** si tienes ejecución de código (sandbox), prueba ahí si existen `node`, `ffmpeg` y si puedes instalar `playwright`. Si funciona, puedes seguir las fases dentro del sandbox y entregar los archivos para descargar, avisando que el sandbox se borra al terminar la conversación. Si no funciona, haz solo la Fase 4 (estilo) y el guion/diseño de los carruseles como artefactos HTML de vista previa, y deja anotado que el render final se hace en Claude Code.

---

## Fase 1 — Tres preguntas antes de instalar

Pregunta (puedes juntar las tres en un mensaje):

1. **¿Qué tipo de carruseles quieres hacer?**
   - Con movimiento (cada lámina es un video corto con animación y efectos de sonido).
   - Normales (imágenes PNG).
   - Los dos (recomendado: se pueden mezclar en el mismo carrusel; Instagram acepta imágenes y videos juntos).
2. **¿Qué herramienta de animación?** Explica la tabla y recomienda la opción A si la persona no tiene preferencia:

   | Opción | Qué es | A favor | En contra |
   |---|---|---|---|
   | **A. Motor propio (recomendado)** | Cada lámina es una página HTML animada con GSAP. Un script la graba cuadro por cuadro con Playwright y ffmpeg. Es el método de esta guía | Lo más liviano. Mismo archivo para PNG y video. Resultado idéntico cada vez. Verifica medidas, fuentes y que el video termine exactamente en la lámina quieta | Las animaciones las escribe Claude en código (tú solo das indicaciones) |
   | **B. HyperFrames** | Framework de HeyGen para videos en HTML, con su CLI y skills para Claude | Muchos bloques y efectos listos (registro de componentes), vista previa en vivo, transiciones | Pide Node 22+, es más pesado, pensado para videos más largos |
   | **C. Remotion** | Videos hechos con React | Ecosistema grande, muy popular | Pide saber (o dejar a Claude) escribir React; **licencia de pago para empresas de 4 o más personas** |

   Se pueden instalar varias. La opción A siempre se instala, porque también hace los PNG y las verificaciones.
3. **¿Dónde trabajamos?** Por defecto, la carpeta donde está esta guía. Confirma la ruta.

---

## Fase 2 — Instalar y verificar las herramientas

Haz una lista de verificación con lo que falta según la Fase 0, pide permiso una vez para todo lo de esta fase y ve instalando y comprobando **uno por uno**. Después de cada instalación, vuelve a correr el comando de verificación. En Windows, si un comando recién instalado "no existe", la persona tiene que cerrar y abrir la terminal (o Claude Code) para que se actualice el PATH; díselo.

| Herramienta | Para qué | Mínimo | Verificar |
|---|---|---|---|
| Node.js + npm | Corre los scripts del proyecto | 20 LTS (22 si eligió HyperFrames) | `node -v` |
| ffmpeg + ffprobe | Arma los MP4, mezcla sonidos, saca cuadros de videos de referencia | cualquier versión de los últimos años | `ffmpeg -version` y `ffprobe -version` |
| Playwright + Chromium | Abre cada lámina en un navegador invisible y la captura | la versión actual | `npx playwright --version` |
| GSAP | Las animaciones | 3.x (gratis, incluidos sus plugins) | existe `node_modules/gsap/dist/gsap.min.js` |
| Fuentes (@fontsource) | Tipografías descargadas, sin depender de internet al grabar | — | existe la carpeta en `node_modules/@fontsource/` |
| Git (opcional) | Guardar versiones | — | `git --version` |

### Comandos de instalación por sistema

**macOS**
- Si no hay Homebrew, propón instalarlo con el comando oficial de brew.sh (pide la contraseña de la Mac; explícalo). Si la persona no quiere Homebrew, Node se puede instalar con el instalador de nodejs.org y ffmpeg con un binario estático, pero Homebrew es lo más simple.
- `brew install node ffmpeg`

**Windows**
- `winget install OpenJS.NodeJS.LTS`
- `winget install Gyan.FFmpeg`
- Cierra y abre la terminal, verifica.

**Linux (Debian/Ubuntu)**
- Node: la versión de `apt` suele ser vieja. Usa nvm (`curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash`, luego `nvm install --lts`) o el repositorio de NodeSource.
- `sudo apt update && sudo apt install -y ffmpeg`

**Dentro del proyecto (todos los sistemas)**, ya en la carpeta del proyecto (Fase 3 crea el `package.json`):

```bash
npm install -D playwright gsap
npx playwright install chromium          # en Linux: npx playwright install --with-deps chromium
```

Nunca instales con `npm install -g` ni con `sudo npm`: todo va local al proyecto.

### Opcionales según la Fase 1

**HyperFrames (opción B)**
- Requiere Node 22 o más nuevo y ffmpeg.
- Verifica el entorno con `npx hyperframes doctor`.
- Instala sus skills para que Claude Code sepa usarlo: `npx hyperframes skills` (sigue lo que indique). Después de instalar skills, la persona tiene que reiniciar Claude Code para que aparezcan.
- Consulta los comandos actuales con `npx hyperframes --help` y `npx hyperframes docs` en lugar de suponerlos: el CLI cambia seguido.

**Remotion (opción C)**
- Antes de instalar, menciona la licencia: gratis para personas y empresas de hasta 3 personas; empresas más grandes necesitan licencia de empresa (remotion.dev/license).
- Proyecto aparte dentro de la carpeta: `npx create-video@latest remotion` (elige la plantilla en blanco con TypeScript).
- Composición a `width: 1080`, `height: 1350`, `fps: 30`.
- Video: `npx remotion render <IdComposicion> ../carruseles/<slug>/salida/NN.mp4`; imagen: `npx remotion still <IdComposicion> ../carruseles/<slug>/salida/NN.png`.

**Verificación de cierre de fase:** muestra una tabla con cada herramienta, su versión y ✓. No avances con ningún ✗.

---

## Fase 3 — Construir el proyecto

### Estructura

```
mis-carruseles/
├── GUIA-CARRUSELES.md        ← este archivo
├── CLAUDE.md                 ← lo creas tú (ver abajo)
├── ESTADO.md                 ← fases y progreso
├── package.json
├── herramientas/
│   └── render.js             ← graba PNG y MP4 con verificaciones
├── estilos/
│   └── <id-del-estilo>/
│       ├── estilo.css        ← colores, fuentes, medidas y piezas reutilizables
│       └── ESTILO.md         ← reglas del estilo en palabras + personalidad de movimiento
├── audio/
│   └── sfx/                  ← efectos de sonido (whoosh.mp3, pop.mp3, …)
└── carruseles/
    └── <slug>/               ← un carrusel = una carpeta
        ├── PLAN.md           ← lámina por lámina, aprobado antes de construir
        ├── referencia/       ← (opcional) carruseles que la persona quiere imitar
        ├── assets/           ← fotos, logos, recortes de esta pieza
        ├── slides/01.html …  ← una página por lámina
        └── salida/01.mp4, 02.png …   ← el entregable
```

### Archivos

1. `npm init -y`, y en `package.json` agrega `"private": true` y el script `"render": "node herramientas/render.js"`.
2. Instala lo de la Fase 2 (Playwright, GSAP, Chromium).
3. `CLAUDE.md` con este contenido:

   ```markdown
   # Carruseles de Instagram

   Este proyecto sigue GUIA-CARRUSELES.md. Léela antes de trabajar y revisa ESTADO.md
   para saber en qué fase vamos. Para hacer un carrusel nuevo, ve directo a la Fase 5.

   @GUIA-CARRUSELES.md
   ```

4. `ESTADO.md` con las fases 0 a 6 como lista de verificación y lo que ya quedó hecho (sistema operativo, herramientas y versiones, motor elegido).
5. `herramientas/render.js` con **exactamente** este código (no lo reescribas ni lo "mejores" sin motivo; ya está probado):

```js
#!/usr/bin/env node
/* Renderiza un carrusel: cada carruseles/<slug>/slides/NN.html sale como
   salida/NN.png (estática) o salida/NN.mp4 (si la lámina define
   window.crearAnimacion). Aborta en vez de entregar algo roto.

   Uso:
     node herramientas/render.js <slug>              todo
     node herramientas/render.js <slug> --solo 1,3   solo esas láminas
     node herramientas/render.js <slug> --png        todo en PNG (vista previa rápida) */

const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync, execFileSync } = require('child_process');
const { pathToFileURL } = require('url');
const { chromium } = require('playwright');

const RAIZ = path.join(__dirname, '..');
const W = 1080, H = 1350, FPS = 30, PAUSA_FINAL = 1.5, MIN_SSIM = 0.99;

const [slug, ...flags] = process.argv.slice(2);
if (!slug) { console.error('Uso: node herramientas/render.js <slug> [--solo 1,3] [--png]'); process.exit(1); }
const soloPng = flags.includes('--png');
const iSolo = flags.indexOf('--solo');
const solo = iSolo >= 0 ? flags[iSolo + 1].split(',').map(Number) : null;

const dir = path.join(RAIZ, 'carruseles', slug);
const dirSlides = path.join(dir, 'slides');
const dirSalida = path.join(dir, 'salida');
if (!fs.existsSync(dirSlides)) { console.error(`No existe ${dirSlides}`); process.exit(1); }
fs.mkdirSync(dirSalida, { recursive: true });

const slides = fs.readdirSync(dirSlides).filter((f) => /^\d+\.html$/.test(f)).sort();
if (!slides.length) { console.error('No hay láminas NN.html en slides/'); process.exit(1); }
if (slides.length > 20) { console.error(`Instagram acepta máximo 20 láminas; hay ${slides.length}`); process.exit(1); }

function ssim(a, b) {
  const r = spawnSync('ffmpeg', ['-hide_banner', '-i', a, '-i', b, '-lavfi', 'ssim', '-f', 'null', '-']);
  const m = r.stderr.toString().match(/All:([\d.]+)/);
  if (!m) throw new Error(`No se pudo comparar ${a} con ${b}`);
  return Number(m[1]);
}

async function captura(page, file) {
  const buf = await page.screenshot({ clip: { x: 0, y: 0, width: W, height: H } });
  if (file) fs.writeFileSync(file, buf);
  return buf;
}

/** Verificaciones de la lámina en su estado final: medida, fuentes y desborde. */
async function verificar(page, nombre) {
  const p = await page.evaluate(async ({ W, H }) => {
    await document.fonts.ready;
    const slide = document.querySelector('.slide');
    if (!slide) return { error: 'falta el elemento .slide' };
    const r = slide.getBoundingClientRect();
    if (Math.round(r.width) !== W || Math.round(r.height) !== H)
      return { error: `.slide mide ${r.width}×${r.height}, debe medir ${W}×${H}` };
    const fuentesFaltantes = new Set();
    const fuera = [];
    for (const el of slide.querySelectorAll('*')) {
      const cs = getComputedStyle(el);
      if (cs.display === 'none' || cs.visibility === 'hidden') continue;
      const tieneTexto = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim());
      if (tieneTexto) {
        const familia = cs.fontFamily.split(',')[0].trim().replace(/["']/g, '');
        const genericas = ['serif', 'sans-serif', 'monospace', 'system-ui', 'cursive'];
        const declarada = [...document.fonts].some((f) => f.family.replace(/["']/g, '') === familia);
        if (!genericas.includes(familia) && (!declarada || !document.fonts.check(`${cs.fontWeight} 40px "${familia}"`)))
          fuentesFaltantes.add(`${familia} ${cs.fontWeight}`);
      }
      if (el.closest('[data-puede-salir]')) continue;
      // La caja del elemento y también su texto: un texto largo se sale de
      // su caja sin que la caja crezca.
      const cajas = [el.getBoundingClientRect()];
      for (const n of el.childNodes) {
        if (n.nodeType !== 3 || !n.textContent.trim()) continue;
        const rango = document.createRange();
        rango.selectNodeContents(n);
        cajas.push(...rango.getClientRects());
      }
      if (cajas.some((b) => b.width && b.height && (b.left < -1 || b.top < -1 || b.right > W + 1 || b.bottom > H + 1)))
        fuera.push(`<${el.tagName.toLowerCase()} class="${el.className}">`);
    }
    return { fuentesFaltantes: [...fuentesFaltantes], fuera: fuera.slice(0, 5) };
  }, { W, H });
  if (p.error) throw new Error(`${nombre}: ${p.error}`);
  if (p.fuentesFaltantes.length) throw new Error(`${nombre}: no cargó la fuente ${p.fuentesFaltantes.join(', ')}`);
  if (p.fuera.length) throw new Error(`${nombre}: contenido fuera del marco 1080×1350 → ${p.fuera.join(' ')}`);
}

function mezclarSfx(video, sonidos, duracion, out) {
  const args = ['-hide_banner', '-loglevel', 'error', '-y', '-i', video];
  sonidos.forEach((s) => args.push('-i', s.file));
  const ramas = sonidos.map((s, i) => {
    const ms = Math.round(s.en * 1000);
    return `[${i + 1}:a]aresample=48000,aformat=channel_layouts=stereo,adelay=${ms}|${ms},volume=${s.volumen ?? 0.6}[a${i}]`;
  });
  const filtro = `${ramas.join(';')};${sonidos.map((_, i) => `[a${i}]`).join('')}amix=inputs=${sonidos.length}:normalize=0:duration=longest,apad=whole_dur=${duracion},atrim=0:${duracion},alimiter=limit=0.89[a]`;
  const r = spawnSync('ffmpeg', [...args, '-filter_complex', filtro, '-map', '0:v', '-map', '[a]',
    '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-t', String(duracion), '-movflags', '+faststart', out]);
  if (r.status !== 0) throw new Error(`ffmpeg (sonidos) falló: ${r.stderr}`);
}

async function grabarVideo(page, out, nombre) {
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'carrusel-'));
  try {
    const quieta = path.join(tmp, 'quieta.png');
    await captura(page, quieta);

    // La lámina arma su timeline GSAP en pausa. El DOM ya es el estado final:
    // la animación va HACIA él, así la última imagen es idéntica al PNG.
    const cfg = await page.evaluate(() => {
      const r = window.crearAnimacion(window.gsap) || {};
      if (!r.tl) throw new Error('crearAnimacion debe devolver { tl }');
      r.tl.pause(0);
      window.__tl = r.tl;
      return { animacion: r.tl.duration(), duracion: r.duracion, loop: !!r.loop, sfx: r.sfx || [] };
    });
    const duracion = cfg.duracion ?? Math.ceil((cfg.animacion + (cfg.loop ? 0 : PAUSA_FINAL)) * 2) / 2;
    if (duracion > 60) throw new Error(`${nombre}: dura ${duracion} s; Instagram corta los videos de carrusel a 60 s`);
    if (!cfg.loop && duracion - cfg.animacion < 1) throw new Error(`${nombre}: deja al menos 1 s quieta al final`);

    const sonidos = cfg.sfx.map((s) => {
      const file = path.join(RAIZ, 'audio', 'sfx', `${s.sonido}.mp3`);
      if (!fs.existsSync(file)) throw new Error(`${nombre}: no existe audio/sfx/${s.sonido}.mp3`);
      return { ...s, file };
    });

    const muda = sonidos.length ? path.join(tmp, 'muda.mp4') : out;
    const ff = spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y',
      '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'png', '-i', '-',
      '-vf', 'scale=out_color_matrix=bt709:out_range=tv,format=yuv420p',
      '-c:v', 'libx264', '-preset', 'slow', '-crf', '16', '-profile:v', 'high',
      '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709',
      '-r', String(FPS), '-movflags', '+faststart', muda], { stdio: ['pipe', 'ignore', 'pipe'] });
    let err = '';
    ff.stderr.on('data', (d) => (err += d));
    const listo = new Promise((ok, mal) => ff.on('close', (c) => (c === 0 ? ok() : mal(new Error(`ffmpeg falló: ${err}`)))));

    const primera = path.join(tmp, 'primera.png');
    const total = Math.round(duracion * FPS);
    for (let i = 0; i < total; i++) {
      await page.evaluate((t) => { window.__tl.seek(t, false); }, i / FPS);
      const buf = await captura(page, i === 0 ? primera : null);
      if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r));
    }
    ff.stdin.end();
    await listo;
    if (sonidos.length) mezclarSfx(muda, sonidos, duracion, out);

    const final = path.join(tmp, 'final.png');
    await page.evaluate((t) => { window.__tl.seek(t, false); }, duracion);
    await captura(page, final);
    const s = ssim(final, quieta);
    if (s < MIN_SSIM) throw new Error(`${nombre}: el último frame no coincide con la lámina quieta (SSIM ${s.toFixed(4)}). Algo quedó fuera de lugar.`);
    if (cfg.loop && ssim(primera, quieta) < MIN_SSIM) throw new Error(`${nombre}: el loop no arranca en la lámina completa`);

    const probe = JSON.parse(execFileSync('ffprobe', ['-v', 'error', '-show_entries',
      'stream=codec_type,width,height:format=duration', '-of', 'json', out]));
    const v = probe.streams.find((x) => x.codec_type === 'video');
    if (v.width !== W || v.height !== H) throw new Error(`${nombre}: el video mide ${v.width}×${v.height}`);
    return `${duracion} s${sonidos.length ? `, ${sonidos.length} sonidos` : ''}, SSIM ${s.toFixed(3)}`;
  } finally {
    fs.rmSync(tmp, { recursive: true, force: true });
  }
}

(async () => {
  const browser = await chromium.launch();
  let fallas = 0;
  try {
    for (const f of slides) {
      const n = parseInt(f, 10);
      if (solo && !solo.includes(n)) continue;
      const nn = String(n).padStart(2, '0');
      // Contexto nuevo por lámina: nada de estado heredado de la anterior.
      const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
      const page = await ctx.newPage();
      const erroresJs = [];
      page.on('pageerror', (e) => erroresJs.push(e.message));
      try {
        await page.goto(pathToFileURL(path.join(dirSlides, f)).href);
        await page.waitForLoadState('networkidle');
        if (erroresJs.length) throw new Error(`${f}: error de JavaScript → ${erroresJs[0]}`);
        await verificar(page, f);
        for (const viejo of [`${nn}.png`, `${nn}.mp4`]) fs.rmSync(path.join(dirSalida, viejo), { force: true });
        const animada = await page.evaluate(() => typeof window.crearAnimacion === 'function');
        if (animada && !soloPng) {
          if (await page.evaluate(() => !window.gsap)) throw new Error(`${f}: usa crearAnimacion pero no cargó GSAP`);
          const info = await grabarVideo(page, path.join(dirSalida, `${nn}.mp4`), f);
          console.log(`✓ ${nn}.mp4  (${info})`);
        } else {
          await captura(page, path.join(dirSalida, `${nn}.png`));
          console.log(`✓ ${nn}.png`);
        }
      } catch (e) {
        fallas++;
        for (const roto of [`${nn}.png`, `${nn}.mp4`]) fs.rmSync(path.join(dirSalida, roto), { force: true });
        console.error(`✗ ${e.message}`);
      } finally {
        await ctx.close();
      }
    }
  } finally {
    await browser.close();
  }
  if (fallas) { console.error(`\n${fallas} lámina(s) con error. No se entrega nada roto: corrige y vuelve a correr.`); process.exit(1); }
  console.log(`\nListo → ${path.relative(process.cwd(), dirSalida)}`);
})();
```

### Prueba de humo (obligatoria)

Antes de pasar al estilo, comprueba que todo el sistema graba de verdad:

1. `npm install -D @fontsource/inter`
2. Crea `estilos/_prueba/estilo.css`:

   ```css
   @import "../../node_modules/@fontsource/inter/400.css";
   @import "../../node_modules/@fontsource/inter/700.css";
   :root { --fondo:#F4F4F4; --tinta:#111; --acento:#E4572E; --margen:96px; }
   * { margin:0; padding:0; box-sizing:border-box; }
   html, body { width:1080px; height:1350px; overflow:hidden; background:var(--fondo); }
   .slide { position:relative; width:1080px; height:1350px; padding:var(--margen);
     display:flex; flex-direction:column; justify-content:center; gap:40px;
     background:var(--fondo); color:var(--tinta); font-family:"Inter", sans-serif; overflow:hidden; }
   .titulo { font-weight:700; font-size:110px; line-height:1.05; }
   .resalte { color:var(--acento); }
   ```
3. Crea `audio/sfx/pop.mp3` de prueba: `ffmpeg -y -f lavfi -i "sine=frequency=880:duration=0.15" audio/sfx/pop.mp3`
4. Crea `carruseles/_prueba/slides/01.html` (animada) y `02.html` (estática):

   ```html
   <!doctype html><html lang="es"><head><meta charset="utf-8">
   <link rel="stylesheet" href="../../../estilos/_prueba/estilo.css">
   <script src="../../../node_modules/gsap/dist/gsap.min.js"></script></head><body>
   <div class="slide"><h1 class="titulo">Prueba de <span class="resalte">movimiento</span></h1></div>
   <script>
   window.crearAnimacion = (gsap) => {
     const tl = gsap.timeline();
     tl.from('.resalte', { y: 80, autoAlpha: 0, duration: .6, ease: 'back.out(2)' }, .2);
     return { tl, sfx: [{ sonido: 'pop', en: .2 }] };
   };
   </script></body></html>
   ```

   `02.html` igual pero sin el `<script>` de GSAP ni `crearAnimacion`.
5. `node herramientas/render.js _prueba`. Debe imprimir `✓ 01.mp4 (… SSIM 1.000)` y `✓ 02.png`.
6. Confirma con `ffprobe -v error -show_entries stream=codec_name,width,height -of compact carruseles/_prueba/salida/01.mp4`: h264 1080×1350 + aac.
7. Muéstrale a la persona el PNG (y dile dónde está el MP4 para que lo abra). Si todo bien, **borra `estilos/_prueba`, `carruseles/_prueba` y el `pop.mp3` de prueba** y marca la Fase 3 en `ESTADO.md`.

Si algo falla aquí, revisa la sección «Problemas comunes» al final.

---

## Fase 4 — Crear el estilo propio

El estilo es lo que hace que los carruseles se reconozcan como de la persona. Se construye con ella; no hay estilos de fábrica. Explícalo así: «Tu estilo es tu sistema visual: colores, tipografías, cómo se ven los títulos y las tarjetas, y también cómo se mueven las cosas. Lo definimos una vez y cada carrusel lo usa.»

### 4.1 De dónde sale

Ofrece dos caminos (se pueden combinar):

**A. Desde cero, con preguntas.** Pregunta en bloques cortos:
- Nombre o marca, y el usuario de Instagram (`@...`) que va en el pie.
- ¿Ya tiene colores o logo? Si sí, que lo comparta (imagen o códigos de color).
- Tres palabras para cómo quiere que se sienta (ej. «serio, técnico, cálido»).
- ¿Fondo claro, oscuro o los dos?
- De qué habla su cuenta y a quién le habla.

**B. Desde carruseles que le gustan (recomendado).** Pídele: «Pega aquí 2 o 3 carruseles que te gusten: capturas de las láminas o, si son con movimiento, descarga los videos y ponlos en la carpeta `estilos/_referencias/`. No hace falta que sean de tu tema; me interesa el look y el ritmo.»

Para videos de referencia, saca una hoja de cuadros y analízala viendo la imagen:

```bash
ffprobe -v error -show_entries stream=width,height:format=duration -of compact referencia.mp4
ffmpeg -y -i referencia.mp4 -vf "fps=2,scale=360:-2,tile=6x4" -frames:v 1 hoja.jpg
```

(2 cuadros por segundo; para el gancho de los primeros 3 s usa `fps=4` con `-t 3`.)

De cada referencia extrae y anota:
- **Paleta por roles:** fondo claro, fondo oscuro, texto, acento principal, acento secundario, color de alerta. Códigos hex aproximados.
- **Tipografía:** tipo (geométrica, grotesca, serif, condensada, manuscrita), peso, mayúsculas o no, tamaño relativo del título. Busca la equivalente gratuita en Google Fonts que exista en Fontsource (`npm view @fontsource/<nombre>` lo confirma).
- **Composición:** márgenes, dónde va el título, tarjetas (redondeadas, con borde, con sombra dura, planas), cómo se resalta una palabra (color, marcador, subrayado, caja), contador de lámina, flecha de «desliza».
- **Textura:** retícula, grano, papel, degradados, nada.
- **Movimiento:** cómo entran las cosas (rebote, deslizamiento, corte seco, palabra por palabra), velocidad, si hay sub-escenas dentro de una lámina, si termina quieta o en loop, qué sonidos se escuchan.

**Regla ética, dísela a la persona:** «Nos inspiramos, no clonamos. Tomamos lo que te gusta de cada referencia y lo combinamos con lo tuyo. No usamos sus logos, ilustraciones, fotos ni textos, y el resultado no debe poder confundirse con la otra cuenta.» Si las referencias son de una sola cuenta, propón mezclar con otra o cambiar al menos paleta y tipografía.

### 4.2 Qué produces

1. Una **propuesta de estilo** en texto corto: nombre del estilo, paleta (con muestras), tipografías, 3–5 reglas de composición y la «personalidad de movimiento» (ej. «entradas con rebote corto, títulos palabra por palabra, nada dura más de 0.6 s, sonido de pop en cada tarjeta»). Pide aprobación o ajustes.
2. Instala las fuentes: `npm install -D @fontsource/<fuente>` (una por familia).
3. `estilos/<id>/estilo.css` con:
   - `@import` de cada peso usado desde `../../node_modules/@fontsource/<fuente>/<peso>.css`.
   - Variables en `:root`: colores por rol, fuentes, margen (96px recomendado), radio, tamaños de título/cuerpo.
   - Base obligatoria: `html, body` y `.slide` de 1080×1350 con `overflow:hidden` y fondo explícito (copia la base de la prueba de humo).
   - Piezas reutilizables con clases: `.titulo`, `.subtitulo`, `.texto`, `.resalte`, `.antetitulo`, `.tarjeta`, `.boton`, `.pie` (usuario + contador), `.numero` (cifra protagonista), variantes `.oscuro` / `.claro` si hay dos fondos.
   - Nada de `animation` ni `transition` en CSS: todo movimiento va en GSAP (si no, el video no es determinista).
4. `estilos/<id>/ESTILO.md`: las reglas en palabras (cuándo usar cada color, tamaños mínimos, qué no se hace) y la personalidad de movimiento con valores concretos (duraciones, eases de GSAP, separaciones, qué sonido va con qué).
5. **Muestra para aprobar:** un carrusel `carruseles/_muestra-<id>/` de 3 láminas (portada, lámina de contenido con tarjetas, cierre con llamada a la acción), al menos una animada. Renderiza, muestra los PNG (y el MP4) y ajusta hasta que la persona diga que sí. Luego bórralo.

La persona puede tener varios estilos. Para crear otro más adelante, se repite esta fase.

---

## Fase 5 — Hacer un carrusel

### 5.1 Entrada

Pregunta qué trae:
- Un **tema** o idea → propón el guion.
- Un **guion** ya escrito → respeta el mensaje; ajusta largo y ritmo.
- Un **carrusel de referencia** que quiere replicar (en estructura y movimiento, con su estilo) → ponlo en `carruseles/<slug>/referencia/` (`01.jpg`, `02.mp4`…, en orden), saca hojas de cuadros de los videos y escribe `ANALISIS.md`:
  1. **Gancho (lámina 1):** texto, visual, la jugada (por qué hace detenerse) y la fórmula reutilizable con huecos: «Este [formato] [hace algo inesperado]. Y no lo [hice] yo.»
  2. **Estructura:** tabla lámina por lámina (función: gancho / revelación / valor / prueba / llamada a la acción).
  3. **Copy:** largo, tono, repeticiones.
  4. **Visual** y **movimiento** por lámina (qué se mueve, tiempos, si termina quieta, sonido).
  5. **Adaptación:** qué se conserva (la jugada) y qué cambia (textos, estilo, ejemplos propios).

Pregunta también: estilo a usar, slug (nombre corto de carpeta: `minusculas-con-guiones`) y si hay fotos, logo o capturas propias que deban ir (`assets/`).

### 5.2 Plan (aprobación antes de construir)

Escribe `PLAN.md` y muéstralo:

| # | Función | Texto en pantalla | Visual | ¿Movimiento? | Qué se anima | Sonido | Duración |
|---|---|---|---|---|---|---|---|

Reglas para el plan:
- **4:5 (1080×1350)** siempre. Entre 5 y 10 láminas es lo usual; máximo 20.
- **Una idea por lámina.** Textos cortos: título de 4 a 12 palabras, cuerpo de 1 a 3 líneas.
- **Lámina 1 = gancho.** Tiene que leerse en miniatura y obligar a deslizar.
- **Última = llamada a la acción** (guardar, comentar una palabra clave, seguir).
- **Movimiento con intención:** no todo tiene que moverse. Buena regla: portada, una o dos láminas clave y cierre animados; el resto puede ir en PNG. Si la persona pidió todo animado, se hace.
- **Duración por lámina animada:** 3–8 s es lo normal; con sub-escenas, hasta 15–20 s. Nunca más de 60 s.
- **Loop solo si la persona lo pide.** Por defecto, las cosas entran y la lámina termina quieta.

No construyas hasta que la persona apruebe el plan.

### 5.3 Contrato de cada lámina (`slides/NN.html`)

Cumple todo esto; `render.js` aborta si algo falla:

1. Nombre `01.html`, `02.html`… (dos dígitos, en orden).
2. Carga el estilo: `<link rel="stylesheet" href="../../../estilos/<id>/estilo.css">`. Estilos propios de una sola lámina, en un `<style>` en el `<head>`.
3. Un solo elemento raíz `<div class="slide">` de 1080×1350. Todo el contenido adentro, dentro de los márgenes. Si algo debe salirse a propósito (una forma cortada en el borde), márcalo con `data-puede-salir`.
4. **El HTML, tal cual se abre, es el estado final de la lámina.** Eso es lo que sale en PNG y lo que tiene que verse en el último cuadro del video.
5. Si la lámina se mueve: carga GSAP (`<script src="../../../node_modules/gsap/dist/gsap.min.js"></script>`) y define:

   ```js
   window.crearAnimacion = (gsap) => {
     const tl = gsap.timeline();
     // animaciones que LLEGAN al estado final: gsap.from(...), fromTo(...)
     return {
       tl,
       sfx: [{ sonido: 'whoosh', en: 0.2 }, { sonido: 'pop', en: 0.9, volumen: 0.5 }], // opcional
       // duracion: 6,   // opcional; si no, = animación + 1.5 s quieta
       // loop: true,    // opcional; solo si la persona lo pidió. El final debe ser igual al inicio
     };
   };
   ```

   `sonido` es el nombre de un archivo en `audio/sfx/` sin `.mp3`. `en` está en segundos.
6. **Determinista:** nada de `Math.random()` sin semilla, `Date`, `setTimeout`, `requestAnimationFrame` propios, `animation`/`transition` de CSS ni `<video>`/`<audio>` dentro de la lámina. El script avanza la línea de tiempo cuadro por cuadro con `tl.seek()`; todo lo que no viva en `tl` no se graba bien.
7. Fotos e imágenes locales en `assets/` con rutas relativas. Nada cargado de internet.
8. Texto de contenido no menor a 30px; el pie puede bajar a 26px.
9. **Lámina 1 animada:** Instagram usa el primer cuadro del video como miniatura. Ese cuadro no puede ser el fondo vacío: el título principal tiene que verse desde el cuadro 0 (que se animen el resalte, las tarjetas y el botón, no la aparición del título). Para la lámina 1 usa `gsap.from` solo en elementos secundarios, o un cambio de escala/color sobre un título ya visible.

### 5.4 Recetas de movimiento (probadas)

Todas animan **hacia** el estado final del HTML.

**Palabra por palabra.** Envuelve cada palabra en `<span class="p">…</span>` (con espacios entre los spans):
```js
tl.from('.titulo .p', { y: 100, autoAlpha: 0, duration: .45, stagger: .12, ease: 'back.out(2)' }, 0);
```

**Tarjetas en escalera:**
```js
tl.from('.tarjeta', { y: 60, autoAlpha: 0, duration: .5, stagger: .15, ease: 'power3.out' }, .4);
```

**Golpe en una palabra resaltada:**
```js
tl.from('.resalte', { scale: 1.5, duration: .5, ease: 'back.out(3)' }, .3);
```

**Contador (cifra que sube).** En el HTML va el valor final con `data-final`, ej. `<div class="numero" data-final="2.4K">2.4K</div>`:
```js
const contador = (tl, el, dur, pos) => {
  const final = el.dataset.final, n = parseFloat(final.replace(/,/g, ''));
  const dec = (final.split('.')[1] || '').replace(/\D/g, '').length, suf = final.replace(/[\d.,]/g, '');
  const o = { v: 0 };
  tl.to(o, { v: n, duration: dur, ease: 'power2.out', onUpdate: () => { el.textContent = o.v.toFixed(dec) + suf; } }, pos);
  tl.add(() => { el.textContent = final; }, pos + dur); // el final queda idéntico al HTML
};
contador(tl, document.querySelector('.numero'), 1.2, .3);
```

**Texto que se escribe (tecleo).** `<div class="mensaje" data-final="Escribe ANIMA y te lo mando">Escribe ANIMA y te lo mando</div>`:
```js
const el = document.querySelector('.mensaje'), final = el.dataset.final, o = { n: 0 };
tl.fromTo(o, { n: 0 }, { n: final.length, duration: 1.2, ease: 'none',
  onUpdate: () => { el.textContent = final.slice(0, Math.round(o.n)); } }, .5);
```
(Combínalo con el sonido `typing` en el mismo instante.)

**Trazo que se dibuja (SVG):**
```js
tl.from('.trazo path', { strokeDasharray: 700, strokeDashoffset: 700, duration: .6, ease: 'power2.inOut' }, .8);
```
(700 = un poco más que el largo del trazo; `path.getTotalLength()` lo da exacto.)

**Sub-escenas («tomas») dentro de una lámina.** Varias capas encimadas; en el HTML solo se ve la última (estado final) y las anteriores están ocultas:
```css
.toma { position:absolute; inset:0; padding:var(--margen); display:flex; flex-direction:column; justify-content:center; }
.toma:not(.final) { opacity:0; visibility:hidden; }
```
```js
tl.set('.t1', { autoAlpha: 1 }, 0)
  .from('.t1 .p', { y: 100, autoAlpha: 0, stagger: .12, duration: .45 }, 0)
  .set('.t1', { autoAlpha: 0 }, 1.6)
  .set('.t2', { autoAlpha: 1 }, 1.6)
  // … animaciones de la toma 2 …
  .set('.t2', { autoAlpha: 0 }, 3.2)
  .from('.t3', { autoAlpha: 0, duration: .01 }, 3.2)   // .t3 es la .final
  .from('.t3 .titulo', { scale: .8, duration: .4, ease: 'back.out(3)' }, 3.2);
```

**Ideas de láminas animadas** (como el carrusel «este carrusel se mueve y no lo animé yo»):
- *Cinética:* tomas a golpes con frases cortas y una cifra que cuenta; la última toma se queda.
- *Interfaz / chat:* burbujas de mensaje que entran una por una, la última se teclea; o una ventana de app con cursor que hace clic.
- *Plano o pizarra:* cajas de un proceso que se dibujan con trazo, flechas que conectan.
- *Collage:* recortes que caen con rebote y rotación leve, titulares en tiras de papel.
- *Vitrina:* el título entra grande al centro y sube; una tarjeta va mostrando miniaturas de otras láminas.
- *Encuesta de cierre:* opciones que entran en escalera, una se palomea y un mensaje se escribe con la palabra clave para comentar.

Cada una se adapta al estilo de la persona; no traen colores propios.

### 5.5 Sonido

- Los efectos van en `audio/sfx/<nombre>.mp3`. Nombres sugeridos: `whoosh`, `pop`, `click`, `typing`, `impacto`, `ding`, `swoosh-corto`.
- Si la persona no tiene, ofrécele: bajarlos de bibliotecas gratuitas con licencia de uso comercial (Pixabay Sound Effects, Mixkit; en Freesound revisa la licencia de cada uno) o generarlos con una herramienta de IA de efectos de sonido. Que los guarde en `audio/sfx/` con esos nombres. Revisa con `ffprobe` que abran y duren menos de 3 s.
- Sin efectos también funciona: si `sfx` no se declara, el MP4 sale sin audio.
- **Música no:** la música se agrega en Instagram al publicar (así no hay problemas de derechos y se puede usar música en tendencia).
- Volumen por defecto 0.6. Si se escucha saturado o tapado, ajusta `volumen` por sonido.

### 5.6 Construir, revisar, grabar

1. Escribe todas las láminas.
2. **Vista previa rápida en PNG:** `node herramientas/render.js <slug> --png`. Revisa tú cada PNG (ábrelos y míralos), corrige lo que esté mal y luego muéstralos a la persona.
3. Con su visto bueno: `node herramientas/render.js <slug>`. Si se corrige una sola lámina: `--solo 3` (o `--solo 1,4`).
4. **Revisa los videos tú mismo** antes de entregar: hoja de cuadros de cada MP4 (`ffmpeg -y -i carruseles/<slug>/salida/01.mp4 -vf "fps=4,scale=270:-2,tile=6x4" -frames:v 1 carruseles/<slug>/hoja-01.jpg`; bórrala después, que no quede en `salida/`), mírala y confirma que el ritmo es el del plan, que nada aparece cortado, que el cuadro 0 de la lámina 1 sirve de miniatura y que el texto se alcanza a leer.
5. Entrega: lista de archivos en orden con duración de cada video. Pregunta si quiere una copia en Descargas.

---

## Fase 6 — Publicar en Instagram

Explícale a la persona:
1. Pasa los archivos de `salida/` al teléfono en orden (AirDrop, Google Drive, cable). Mantén el orden `01, 02, 03…`.
2. En Instagram: **+ → Publicación**, toca el ícono de selección múltiple y elige las láminas **en orden**.
3. **Antes de avanzar, ajusta el recorte a 4:5** (ícono de recortar en la primera lámina; todo el carrusel toma la proporción de la primera). Si queda en 1:1 se corta el contenido.
4. Imágenes y videos se pueden mezclar en el mismo carrusel.
5. Agrega música desde Instagram si quiere.
6. En Configuración → Uso de datos y calidad de contenido multimedia, activa **«Subir con la máxima calidad»** para que no comprima de más.
7. La miniatura del carrusel en el perfil es la lámina 1; si es video, su primer cuadro.

Límites de Instagram (pueden cambiar; si algo no sube, revisa el centro de ayuda de Instagram): hasta 20 láminas por carrusel, videos de carrusel hasta 60 s.

---

## Si eligió HyperFrames o Remotion

Las Fases 4, 5 y 6 son iguales: mismo estilo (las variables de `estilo.css` se reutilizan), mismo plan, mismas reglas de lámina 1, duración y sonido. Cambia solo cómo se construye y graba cada lámina animada:

- **HyperFrames:** usa sus skills (empieza por la skill `hyperframes`). Cada lámina animada es una composición de 1080×1350 a 30 fps que importa el `estilo.css`. Graba con el comando de render del CLI y copia el resultado a `carruseles/<slug>/salida/NN.mp4`. Las láminas estáticas pueden seguir saliendo con `render.js --png`.
- **Remotion:** una composición por lámina animada (1080×1350, 30 fps), con las variables del estilo pasadas a un objeto de tema. `npx remotion render` para MP4 y `npx remotion still` para PNG, directo a `salida/`.

En ambos casos aplica la misma revisión de salida antes de entregar:

```bash
ffprobe -v error -show_entries stream=codec_name,width,height,r_frame_rate:format=duration -of compact salida/NN.mp4
```

Debe decir h264, 1080×1350, 30/1 y la duración planeada (≤ 60 s). Y revisa la hoja de cuadros como en 5.6.

---

## Problemas comunes

| Síntoma | Causa | Arreglo |
|---|---|---|
| `ffmpeg: command not found` justo después de instalarlo (Windows) | El PATH no se recargó | Cerrar y abrir la terminal o Claude Code |
| `Executable doesn't exist … chromium` | Falta el navegador de Playwright | `npx playwright install chromium` |
| Linux: Chromium no arranca, faltan librerías | Dependencias del sistema | `npx playwright install --with-deps chromium` |
| `no cargó la fuente X 700` | Falta instalar ese peso o el `@import` apunta mal | `npm install -D @fontsource/x` y `@import ".../x/700.css"` en `estilo.css` |
| `contenido fuera del marco` | Un texto o caja se sale de 1080×1350 | Acortar texto, bajar tamaño o reacomodar. No recortar a escondidas |
| `el último frame no coincide con la lámina quieta` | Una animación usa `to()` y deja algo distinto al HTML, o algo vive fuera de `tl` | Cambiar a `from()`/`fromTo()` hacia el estado final; revisar que no haya CSS `transition`/`animation` |
| `deja al menos 1 s quieta al final` | La `duracion` fijada es muy corta | Quitar `duracion` o subirla |
| `no existe audio/sfx/x.mp3` | El sonido no está en la biblioteca | Agregar el archivo o cambiar el nombre en `sfx` |
| `error de JavaScript` | Error en `crearAnimacion` | Leer el mensaje, corregir el selector o la sintaxis |
| El render tarda mucho | Son 30 capturas por segundo de video | Normal: ~1–3 s de proceso por segundo de video. Usar `--png` para vistas previas |
| En Instagram se ve cortado arriba y abajo | Se publicó en 1:1 | Elegir 4:5 en el recorte antes de avanzar |
| En Instagram se ve borroso | Compresión | Activar «Subir con la máxima calidad»; no reenviar los archivos por WhatsApp antes de subirlos |

---

## Cierre de cada sesión

Antes de terminar, actualiza `ESTADO.md` (qué carrusel quedó, qué falta) y, si hay referencias de otras cuentas en `referencia/` o `estilos/_referencias/` de un trabajo terminado, pregunta si se borran.
