Scrollytelling cinematográfico
Arquitectura base (no negociable)
- Un solo pin, una sola línea de tiempo. El hero (o el capítulo estrella) pinnea un contenedor que dura N viewport-heights y un progreso 0→1 dirige todo. Si hay más de una animación de scroll compitiendo, es una web normal con efectos: no es esto.
- Nunca scroll-jacking. El usuario controla la velocidad con su scroll nativo; la animación se "scrubea" (
scrub: true). Prohibido secuestrar la rueda o mover el scroll por él. - Un solo bucle de animación. Lenis integrado en el ticker de GSAP (
autoRaf: false) y ScrollTrigger sincronizado; o scroll nativo con scrub. Dos bucles compitiendo es la causa nº1 de jank y no se arregla optimizando assets. - El texto vive en DOM, superpuesto al canvas, con capas por progreso. Nunca renderizar copy dentro del canvas.
Secuencias de frames (técnica tipo Apple)
- Fuente preferida: vídeo real → frames con
ffmpeg(fps=12..24, escala a resolución de pantalla). Exportar WebP q≈68-75 y afinar mirando el resultado, no por intuición. - Máximo ~90-120 frames; en móvil servir la mitad o versión 720p.
- Carga con ventana deslizante: mantener ±6-10 frames alrededor del actual; jamás decodificar la secuencia completa (una secuencia 1080p completa mata iOS Safari).
- Precarga con
IntersectionObserver(rootMargin ~800px),requestIdleCallbackpara decode yfetchPriority="low"salvo el hero. - Canvas con
devicePixelRatiocapado a 1.5 ycover/containcalculado; dibujar solo frames decodificados; si falta el frame exacto, usar el más cercano ya cargado (nunca pintar en negro). - Caché inmutable (
Cache-Control: public, max-age=31536000, immutable) con nombre de archivo versionado. - Fallback con
prefers-reduced-motiony conexión lenta: póster estático + capítulos normales sin pin. - Alternativa más simple cuando no hace falta post-proceso: intercambiar
srcde una<img>por frame (fiable en todos los navegadores, sin canvas).
Vídeo scrubbing (si se usa <video>)
currentTimedirigido por progreso en un únicorequestAnimationFrame, con suavizado; coalescer seeks (Safari acumula cola).- Cargar como blob para que sea 100% seekable; encode con GOP corto (keyframe cada pocos frames) o el scrub tartamudea.
- Nunca autoplay: lo mueve el scroll.
Capítulos y overlays
- Beats definidos por rangos de progreso (10%, 35%, 60%, 85%...), con fade in/out y entrada alternando lados para ritmo.
- Cada capítulo mantiene su texto en el DOM en orden semántico correcto para SEO.
- Galerías horizontales: pin de la sección +
xligado al progreso; las cards se escalan hacia el centro.
Rendimiento y verificación
- Animar solo
transform,opacityyfilter;will-changepuntual y retirado al terminar. - Medir frames con
requestAnimationFrameen local y en móvil real si es posible; objetivo 60fps en portátil medio. - La captura headless puede leer el canvas negro (buffer no preservado): verificar por DOM (estilos computados, elementos, estado expuesto en
window.debug) y con el modo reducido activado. - Probar a 375/768/1280/1440; el redimensionado de un navegador de escritorio no reproduce el crash de memoria de iOS: decirlo en el informe de QA.
Estructura de datos recomendada
Un array ordenado de "planos": cada plano es { tipo: 'frames' | 'overlay' | 'galeria', ... }. Reordenar la película = reordenar la lista. Los frames se referencian por patrón (/seq/plano-01/0001.webp).