vid-motion-graphics
Genera gráficos en movimiento de múltiples escenas como MP4. Renderiza animaciones HTML/CSS en Chromium headless mediante Playwright (búsqueda de fotogramas con la Web Animations API), ensambla los fotogramas PNG con FFmpeg. Sin React, sin APIs de IA, sin Python: cero dependencias nuevas más allá de la familia graphic-gif.
Solo fuentes CDN. Sin bibliotecas externas en HTML.
Reglas críticas (leer antes de cada generación)
- Usa
window.renderFrame(t)— sin@keyframesCSS para transiciones entre escenas. En Chromium, la búsqueda decurrentTimeen animaciones CSS se ignora silenciosamente para retrocesos. El enfoque renderFrame: una función JS pura calculaopacity/transformdirectamente a partir de milisegundos. Playwright la llama una vez por fotograma. Determinista, libre de condiciones de carrera. - Sin
animation-delayen NINGÚN elemento. No es necesario con renderFrame. Si te sorprendes escribiendoanimation-delay, detente: estás usando la arquitectura incorrecta. window.__videoReady = truesolo dentro dedocument.fonts.ready.then(...). Nunca lo establezcas sincrónicamente: las fuentes deben cargarse antes de que Playwright capture el fotograma 1 o el texto se renderizará con fuentes de respaldo.- Expón
window.__stopPreview(). El bucle de vista previa rAF del navegador compite con las llamadasevaluate/screenshotde Playwright.capture-frames.mjsllama a__stopPreview()antes del bucle de fotogramas. Inclúyelo siempre. - Usa
t < startMs(no<=) en las comprobaciones de límites de escena.t <= 0en el fotograma 0 deja la escena 1 en negro. La protección correcta esif (t < startMs || t >= endMs) return hidden. - El cuerpo debe tener dimensiones exactas en píxeles. El ancho y alto son números enteros (
1080px,1920px). Sin%,vw/vhni unidades responsivas. - No dos escenas visibles simultáneamente (excepto un 10% de superposición de entrada). Todas las escenas con
opacity: 0fuera de su ventana renderFrame. - Las transiciones usan solo
opacity. Sin alternardisplay, sinvisibility: la opacidad compuesta por GPU es perfecta fotograma a fotograma. - Nunca vuelques el HTML en el chat. Guárdalo en un archivo, muestra solo un resumen.
- El título expresa el mensaje, no el tema. "3 Razones por las que el Q4 Superó los Objetivos", no "Video de Rendimiento Q4 2024".
- Lee
references/scene-library.mdANTES de generar cualquier HTML. Usa la estructura HTML exacta y los nombres de clase CSS de ese archivo.
Paso 1: Recepción
Obligatorio: content_brief
Parámetros opcionales y valores predeterminados:
| Parámetro | Predeterminado | Descripción |
|---|---|---|
| content_brief | — | Texto que describe lo que comunica el video (obligatorio) |
| scenes | auto | Número de escenas (1–6). Auto = derivado del brief. |
| duration_per_scene | 3s | Duración por escena en segundos (1–8s) |
| style | kinetic-dark | kinetic-dark / editorial-light / data-pulse / bold-type / minimal-clean |
| aspect_ratio | 1:1 | 1:1 (1080×1080) / 16:9 (1920×1080) / 9:16 (1080×1920) |
| fps | 30 | Fotogramas por segundo (24, 30 o 60) |
| music | none | Ruta al archivo de audio para la pista de fondo (mp3/m4a/wav) |
| source | — | Atribución de fuente mostrada en el pie de página del fotograma final |
Si falta content_brief, pregunta exactamente:
"Para crear el video, necesito un brief de contenido: ¿qué debería comunicar el video?
Ejemplo: 'Muestra 3 razones por las que los ingresos del Q4 crecieron un 85%: nuevos acuerdos empresariales, reducción de la deserción, aumento de precios. Usa números en negrita. Estilo: data-pulse.'
Opcional: estilo (predeterminado: kinetic-dark), relación de aspecto (predeterminado: 1:1), segundos por escena (predeterminado: 3s)"
Si content_brief está presente → procede al Paso 2 inmediatamente.
Paso 2: Arquitectura interna (nunca se muestra al usuario)
1. Analiza el brief en escenas (máx. 6):
- Escena 1 = gancho o título (siempre)
- Escenas 2 a N-1 = puntos de apoyo, métricas o golpes narrativos
- Escena N = CTA o resumen final (siempre, si hay más de 1 escena)
- Una idea clave por escena: si el brief tiene 7 o más ideas, consolida las más débiles
2. Lee references/scene-library.md — elige el tipo de escena para cada escena:
- Gancho/apertura →
title-card - Métrica única →
stat-reveal - Lista de 2 a 4 puntos →
bullet-list - Antes vs después / dos valores →
split-screen - Testimonio / cita →
quote-card - Final / CTA →
cta-card
3. Lee references/style-presets.md — carga los tokens CSS y la personalidad de animación para el estilo elegido.
4. Calcula la sincronización:
totalDuration = sceneCount × duration_per_scene (segundos)
totalFrames = totalDuration × fps
Cada escena ocupa (100 / sceneCount)% del rango total de @keyframes.
| Escena | Inicio % | Fin % |
|---|---|---|
| 1 | 0% | (100/N)% |
| 2 | (100/N)% | (200/N)% |
| … | … | … |
| N | ((N-1)×100/N)% | 100% |
Dentro del rango de cada escena:
- Entrada: primer 10% del rango de la escena
- Mantenimiento: 10% a 85% del rango de la escena
- Salida: 85% a 100% del rango de la escena
5. Determina las dimensiones en píxeles:
1:1→ W=1080, H=108016:9→ W=1920, H=10809:16→ W=1080, H=1920
Paso 3: Generación HTML
Lee references/scene-library.md Y references/style-presets.md antes de escribir cualquier código.
Estructura HTML obligatoria:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
[enlace CDN de fuente desde el preset de estilo]
<style>
:root {
[todos los tokens CSS del preset de estilo]
}
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: [W]px; height: [H]px;
overflow: hidden;
background: var(--bg);
font-family: var(--font-body);
position: relative;
}
.scene {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px;
opacity: 0;
will-change: opacity, transform;
}
.scene-inner {
width: 100%;
max-width: 960px;
}
/* CSS de tipo de escena de scene-library.md */
[pega aquí el CSS del tipo de escena]
</style>
</head>
<body>
<div class="scene scene-1">
<div class="scene-inner">
[HTML de la escena 1 desde la plantilla de scene-library.md]
</div>
</div>
[repite para cada escena]
<script>
window.__videoReady = false;
window.TOTAL_DURATION_MS = [totalDuration * 1000];
// ── Funciones auxiliares de animación ─────────────────────────────────────────
function lerp(a, b, p) { return a + (b - a) * p; }
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3); }
function sceneState(t, startMs, endMs) {
if (t < startMs || t >= endMs) return { opacity: 0, ty: 0 };
const prog = (t - startMs) / (endMs - startMs);
if (prog < 0.10) {
const p = easeOutCubic(prog / 0.10);
return { opacity: p, ty: lerp(24, 0, p) };
}
if (prog < 0.85) return { opacity: 1, ty: 0 };
const p = (prog - 0.85) / 0.15;
return { opacity: 1 - p, ty: lerp(0, -12, p) };
}
function applySceneState(el, state) {
el.style.opacity = state.opacity;
el.style.transform = state.ty !== 0 ? `translateY(${state.ty.toFixed(2)}px)` : '';
}
// ── Función de renderizado principal — llamada por Playwright una vez por fotograma ──
window.renderFrame = function(t) {
// Escena 1: 0ms – [D]ms
applySceneState(document.querySelector('.scene-1'), sceneState(t, 0, [D]));
// Escena 2: [D]ms – [2D]ms
applySceneState(document.querySelector('.scene-2'), sceneState(t, [D], [2D]));
// ... repite por escena ...
};
// ── Bucle de vista previa — detenido por Playwright antes de la captura de fotogramas ──
let __previewActive = false;
let __previewRafId = null;
window.__stopPreview = function() {
__previewActive = false;
if (__previewRafId !== null) { cancelAnimationFrame(__previewRafId); __previewRafId = null; }
};
document.fonts.ready.then(() => {
window.renderFrame(0);
window.__videoReady = true;
__previewActive = true;
const startTime = performance.now();
function previewTick() {
if (!__previewActive) return;
const elapsed = performance.now() - startTime;
if (elapsed < window.TOTAL_DURATION_MS) {
window.renderFrame(elapsed);
__previewRafId = requestAnimationFrame(previewTick);
} else {
window.renderFrame(window.TOTAL_DURATION_MS - 1);
__previewActive = false;
}
}
__previewRafId = requestAnimationFrame(previewTick);
});
</script>
</body>
</html>
Reglas de calidad de diseño:
- Tamaño de fuente del titular: nunca menor de 60px: el texto debe leerse en miniatura móvil
- Números y estadísticas: siempre el elemento más grande en pantalla (120–200px)
- Espaciado entre letras ajustado en tipografía decorativa:
-0.02ema-0.04em - Un color de acento por escena: no disperses el acento en múltiples elementos
- Presets oscuros: divisores
rgba(255,255,255,0.10), nunca sólidos - Presets claros: divisores
rgba(0,0,0,0.10), nunca sólidos transform-origin: center centeren cada elemento que usetransform- Relleno dentro de
.scene: mínimo 80px: nunca dejes que el texto toque los bordes del viewport
Paso 4: Autoverificación (corrige cada fallo antes del Paso 5)
Corrección de renderFrame:
-
window.renderFrame(t)definida: función pura, sin efectos secundarios fuera de las escrituras de estilo - El límite de escena usa
t < startMs(not <= startMs) — evita el fotograma negro 0 - Cero instancias de
animation-delayo@keyframespara transiciones de escena - No hay dos escenas con ventanas
opacity: 1superpuestas (excepto 10% de superposición de entrada) -
window.__stopPreview()expuesta y el bucle rAF de vista previa comprueba__previewActive
Señal de preparación:
-
window.__videoReady = falsedeclarado antes dedocument.fonts.ready -
window.__videoReady = trueestablecido SOLO dentro dedocument.fonts.ready.then(...) -
window.renderFrame(0)llamado dentro dedocument.fonts.ready.then(...)antes de establecer__videoReady = true
Diseño:
-
html, bodyusan dimensiones exactas en píxeles ([W]px,[H]px) - Sin unidades
%,vw,vh,remenbodyancho/alto -
overflow: hiddenenhtml, body - Todas las escenas
position: absolute; inset: 0
Diseño:
- Todos los colores provienen de los tokens del preset de estilo: sin valores hexadecimales libres
- Todas las fuentes provienen del preset de estilo: sin cadenas font-family libres
- Tamaño de fuente del titular ≥ 60px
- Fuente en la escena final si se proporcionó el parámetro
source
Paso 5: Exportación
Determina el slug a partir del contenido del brief (kebab-case, ≤30 caracteres):
mkdir -p chart/[slug]
Guarda el HTML: chart/[slug]/video.html
Vista previa en el navegador:
open chart/[slug]/video.html
Ejecuta la exportación (reemplaza [skill-root] con la ruta al directorio de esta habilidad):
bash [skill-root]/scripts/export-video.sh \
chart/[slug]/video.html \
chart/[slug]/video.mp4 \
--duration [totalDuration] \
--fps [fps] \
--width [W] \
--height [H] \
[--music ruta/al/audio.mp3]
El script instala Playwright en la primera ejecución (~200 MB de Chromium), captura todos los fotogramas y luego ensambla el MP4 con FFmpeg.
Paso 6: Resumen de salida
## Video: [título del brief]
Fecha: [AAAA-MM-DD] | Escenas: [N] | Estilo: [estilo] | Relación: [ratio]
Duración: [N]s | FPS: [fps] | Fotogramas: [N]
Archivos
Fuente: chart/[slug]/video.html
Salida: chart/[slug]/video.mp4
Tamaño: [X] MB
Lista de verificación
- [ ] Todas las escenas aparecen en secuencia sin fotogramas en blanco
- [ ] Texto legible en miniatura móvil
- [ ] Transiciones suaves entre escenas (sin cortes bruscos)
- [ ] La escena final incluye CTA o mensaje de cierre
- [ ] Atribución de fuente presente en el fotograma final (si se proporcionó)
Consejos de indicaciones (mostrar cuando el usuario pida orientación)
"Proporciona un brief de contenido: viñetas de lo que debería decir cada escena."
"Nombra la idea directamente. '3 razones por las que el Q4 creció un 85%' le da una columna vertebral al video."
"Menciona el estilo si tienes preferencia: kinetic-dark (predeterminado), editorial-light, data-pulse, bold-type, minimal-clean."
"Especifica la relación de aspecto para la plataforma: 1:1 para LinkedIn/feed de Instagram, 9:16 para Stories/Reels, 16:9 para YouTube/presentaciones."
✅ Bueno: "Crea un video de 9 segundos. Los ingresos del Q4 alcanzaron $4.2M (85% de crecimiento). Impulsores: acuerdos empresariales, deserción 1.2%, aumento de precios. CTA: acme.com/q4. Estilo: data-pulse. Relación: 1:1."
❌ Malo: "haz un video sobre nuestra empresa"