internettoolbox
← Volver a las herramientas

Guía

Extraer los fotogramas de un vídeo de IA para una animación de canvas con scroll

Una animación de canvas controlada por el scroll es una secuencia de imágenes numeradas más unas sesenta líneas de JavaScript. Esta guía toma un clip de Sora, Runway, Kling o Veo (o cualquier MP4, WebM, GIF o WebP animado), lo convierte en los archivos de frame_0001.jpg a frame_0240.jpg y enlaza esos fotogramas con la posición del scroll, como hacen las páginas de producto de Apple.

Qué es una animación de canvas con scroll y por qué necesita fotogramas en lugar de una etiqueta video

En una página de producto de Apple, la imagen principal no se reproduce sola: sigue el scroll. Bajas y la esfera de un reloj gira, un iPhone se despliega, unos auriculares giran sobre sí mismos, y el movimiento sigue exactamente tu dedo. Subes y va hacia atrás a la misma velocidad. En la página no hay ningún elemento video. Hay un <canvas>, unos cientos de JPG ya renderizados y un gestor del scroll que elige cuál dibujar.

La alternativa más obvia, asignar video.currentTime según la posición del scroll, en la práctica no funciona. Un seek en un vídeo comprimido tiene que encontrar el fotograma clave más cercano y decodificar hacia delante desde ahí, así que el navegador responde a la mayoría de los seeks tarde y desordenados. El resultado son bloqueos en iOS, una cola de decodificación que va un segundo entero por detrás de la barra de desplazamiento y fotogramas perdidos en cuanto dos seeks caen en el mismo tick. Los códecs de vídeo están hechos para reproducir hacia delante a velocidad constante, no para que los consulten en puntos aleatorios 60 veces por segundo.

Una secuencia de fotogramas no tiene ninguno de estos problemas. El fotograma 137 es un mapa de bits ya decodificado en memoria, y dibujarlo es una sola llamada a drawImage que cuesta mucho menos de un milisegundo. El paso de la posición del scroll al índice del fotograma es determinista: el mismo scroll dibuja siempre la misma imagen, en cualquier navegador y en ambas direcciones. Lo pagas en bytes, y por eso gran parte de esta guía trata de cómo mantener ligera la secuencia.

Paso 1: consigue un clip limpio de Sora, Runway, Kling o Veo

La secuencia vale lo que vale el clip de partida, y las restricciones son más estrictas que las de un vídeo que solo se va a reproducir.

  • Duración: de 3 a 8 segundos. Las animaciones con scroll son cortas por naturaleza, porque quien visita la página tiene que recorrerlas enteras. Ocho segundos repartidos en 500vh de página ya son una sección fijada larga. La mayoría de los generadores de IA crean por defecto clips de 5 o 10 segundos, así que un clip de 5 segundos ni siquiera hay que recortarlo.
  • Frecuencia de fotogramas constante. Algunas exportaciones de IA tienen una frecuencia variable, con lo que el fotograma N cae en un instante impredecible. Pedirle al extractor 24 o 30 fps fijos remuestrea el clip sobre una cuadrícula regular, que es justo lo que presupone el paso lineal de scroll a índice.
  • Resolución: genera en grande, exporta a 1920. Genera a la resolución más alta que ofrezca el modelo y deja que el extractor reduzca. 1920 px de ancho es el valor por defecto y coincide con lo que usa Apple (sus secuencias rondan entre 1500 y 2000 px). Pasarte de ahí engorda la descarga sin ganancia visible en ninguna pantalla que la vaya a mostrar en la cabecera de una página.
  • Con 24 o 30 fps basta. Los modelos de IA generan a 24 o 30 fps, así que por encima no hay nada que capturar. Pedir 60 fps a una fuente de 30 fps te da 60 archivos por segundo de los que uno de cada dos es un duplicado: el doble de peso y cero movimiento extra.
  • Mantén fija la cámara o el sujeto. Una secuencia se lee como un único objeto continuo cuando la cámara o el sujeto se quedan quietos. Los clips en los que se mueven ambos parecen un vídeo al que alguien ha ido dando adelante y atrás, que es justo el efecto que quieres evitar.

Paso 2: extrae los fotogramas en una secuencia numerada

Arrastra el clip al extractor de este sitio. Decodifica en local con WebCodecs, así que el archivo no sale nunca de tu equipo, y te devuelve un ZIP de imágenes numeradas.

Abrir el Video Frame Extractor →Entra un MP4, WebM, MOV, GIF o WebP animado y sale un ZIP con frame_0001.jpg en adelante. Funciona en el navegador, no se sube ningún archivo.

Los cinco ajustes que importan y cómo configurarlos:

  • FPS (control deslizante, de 1 a 60, por defecto 24). Pon 24 para un efecto cinematográfico y 30 cuando el movimiento es tan rápido que a 24 se ve a saltos. Es, con diferencia, lo que más pesa en el total.
  • Ancho de salida (original, 1920, 1440, 1080, 720 o un valor personalizado). 1920 para una cabecera en escritorio, 1080 o 720 para una secuencia solo para móvil. Se mantienen las proporciones originales.
  • Formato (JPG o WebP). WebP para la web, JPG cuando algo más adelante en la cadena lo exija.
  • Calidad (control deslizante, de 0,10 a 1,00, por defecto 0,85). 0,85 es el punto de equilibrio para imágenes fotográficas. Como cada fotograma está en pantalla una fracción de segundo, normalmente puedes bajar a 0,75 antes de que nadie lo note.
  • Salida ZIP y nombres de archivo. Una sola descarga, frames.zip, que contiene frame_0001.jpg, frame_0002.jpg y así sucesivamente, con ceros a la izquierda hasta cuatro cifras. Ese relleno es lo que permite que el bucle de más abajo construya una URL a partir de un índice con String(i + 1).padStart(4, "0").

Antes de iniciar la extracción, la herramienta muestra el número de fotogramas que obtendrás y una estimación del peso del ZIP. Para un clip de 8 segundos son 192 fotogramas a 24 fps o 240 a 30 fps, el mismo orden de magnitud que la secuencia de los AirPods Max de Apple. La herramienta te avisa por encima de 1.000 fotogramas y se niega a pasar de 2.500, porque JSZip lo mantiene todo en memoria hasta que descargas.

Descomprime en public/frames/ (o en tu carpeta de archivos estáticos) y la parte de vídeo está hecha.

Paso 3: el HTML, el CSS y el JavaScript de un canvas controlado por el scroll

Tres piezas: un contenedor alto que aporta la distancia de scroll, una pista sticky que mantiene fijo el canvas dentro de él y un gestor que convierte la posición del contenedor en la ventana en un índice de fotograma.

index.html

<section class="scroll-stage">
  <div class="scroll-track">
    <canvas id="hero" class="scroll-canvas"></canvas>
  </div>
</section>

style.css

/* 500vh of scroll drives 100vh of sticky canvas. */
.scroll-stage {
  position: relative;
  height: 500vh;
}

.scroll-track {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.scroll-canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* No scroll hijacking for anyone who asked for less motion:
   the stage collapses to one screen and shows a single frame. */
@media (prefers-reduced-motion: reduce) {
  .scroll-stage { height: 100vh; }
}

scroll-frames.js

const FRAME_COUNT = 240;               // 8 s of source at 30 fps
const PRELOAD = 20;                    // frames that must decode before first paint

const stage = document.querySelector(".scroll-stage");
const canvas = document.getElementById("hero");
const ctx = canvas.getContext("2d", { alpha: false });

const images = new Array(FRAME_COUNT);

function frameUrl(i) {
  // Matches the extractor's output: frame_0001.jpg ... frame_0240.jpg
  return `/frames/frame_${String(i + 1).padStart(4, "0")}.jpg`;
}

function load(i) {
  if (!images[i]) {
    const img = new Image();
    img.src = frameUrl(i);
    images[i] = img;
  }
  return images[i];
}

// Match the backing store to the CSS box at the device pixel ratio.
// Capped at 2: a 3x phone would quadruple the fill cost for nothing.
function resize() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const rect = canvas.getBoundingClientRect();
  canvas.width = Math.round(rect.width * dpr);
  canvas.height = Math.round(rect.height * dpr);
  draw(current);
}

// cover-fit: fill the canvas, crop the overflow, never stretch.
function draw(i) {
  const img = images[i];
  if (!img || !img.complete || img.naturalWidth === 0) return;
  const scale = Math.max(
    canvas.width / img.naturalWidth,
    canvas.height / img.naturalHeight,
  );
  const w = img.naturalWidth * scale;
  const h = img.naturalHeight * scale;
  ctx.drawImage(img, (canvas.width - w) / 2, (canvas.height - h) / 2, w, h);
}

let current = 0;
let ticking = false;

function frameIndexFromScroll() {
  const rect = stage.getBoundingClientRect();
  const scrollable = rect.height - window.innerHeight;
  const progress = scrollable > 0 ? -rect.top / scrollable : 0;
  const clamped = Math.min(Math.max(progress, 0), 1);
  return Math.round(clamped * (FRAME_COUNT - 1));
}

// One draw per animation frame, never one per scroll event.
function onScroll() {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const next = frameIndexFromScroll();
    if (next !== current) {
      current = next;
      draw(current);
    }
    ticking = false;
  });
}

async function start() {
  await Promise.all(
    Array.from({ length: PRELOAD }, (_, i) =>
      load(i).decode().catch(() => {}),
    ),
  );

  current = frameIndexFromScroll();   // survives a reload mid-page
  resize();

  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
    draw(0);
    return;                           // static poster frame, no scroll handler
  }

  for (let i = PRELOAD; i < FRAME_COUNT; i++) load(i);
  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", resize);
}

start();

Ahí dentro hay cuatro detalles que la gente suele hacer mal. El throttle: el evento scroll se dispara con mucha más frecuencia de la que se refresca la pantalla, así que el gestor activa un indicador y hace el trabajo real dentro de requestAnimationFrame, como mucho una vez por fotograma dibujado. El ajuste de cobertura: drawImage con cuatro argumentos de destino deforma el fotograma sin avisar, así que la escala es la mayor de las dos proporciones y el resultado se centra. El índice inicial: leer la posición del scroll antes del primer dibujo evita que la secuencia vuelva al fotograma 1 cuando alguien recarga la página a mitad de recorrido. La rama de movimiento reducido: con prefers-reduced-motion: reduce el contenedor se reduce a una sola pantalla y la página dibuja un único fotograma, sin ningún gestor de scroll conectado.

¿Y CSS scroll-timeline?

animation-timeline: scroll() y view() enlazan una animación CSS con la posición del scroll sin JavaScript, y son la herramienta adecuada para textos fijados, capas con paralaje y efectos de aparición al entrar:

@keyframes rise {
  from { opacity: 0; transform: translateY(40px); }
  to   { opacity: 1; transform: none; }
}

.caption {
  animation: rise linear both;
  animation-timeline: view();
  animation-range: entry 0% cover 40%;
}

Pero no sustituyen al canvas. Cambiar el mapa de bits que se dibuja no es una propiedad CSS animable, así que una secuencia de fotogramas sigue necesitando el gestor del scroll de arriba. Combínalos: líneas de tiempo CSS para los pies de texto que acompañan a la página y JavaScript para los fotogramas.

Paso 4: rendimiento, o cómo mantener una secuencia de fotogramas por debajo de unos pocos megabytes

  • WebP en lugar de JPG. Entre un 25 y un 35 % más ligero con la misma calidad percibida. En 240 fotogramas a 1920 px son unos 8 MB en lugar de 12 MB, cambiando una sola línea en frameUrl().
  • El número de fotogramas pesa más que la calidad. Reducir a la mitad los fotogramas reduce a la mitad los bytes y te cuesta fluidez solo por debajo de 24 fps. Bajar la calidad de 0,85 a 0,60 ahorra menos y se nota como una papilla en la imagen fija. Recorta el clip y baja los fps antes que nada.
  • Decodifica antes de dibujar. img.decode() se resuelve cuando el mapa de bits está realmente listo. Esperarlo en el primer grupo hace que el primer drawImage encuentre una imagen decodificada en lugar de no dibujar nada en silencio, que es la causa más habitual de una cabecera vacía al cargar.
  • Precarga 20 y carga el resto en streaming. Veinte fotogramas son aproximadamente un segundo de scroll, suficiente para empezar. Lanzar justo después las peticiones restantes mantiene ocupada la red sin bloquear el primer dibujo. No esperes a los 240.
  • Sirve los fotogramas desde una CDN con una caché larga. La secuencia son 240 archivos inmutables que se piden de golpe. Pon un hash en el nombre de la carpeta, configura Cache-Control: public, max-age=31536000, immutable y colócala detrás de una CDN para que las peticiones salgan en paralelo y cerca del usuario. Aquí HTTP/2 o HTTP/3 marcan la diferencia: 240 peticiones en HTTP/1.1 hacen cola de seis en seis.
  • Publica una secuencia más pequeña para móvil. Exporta una segunda pasada a 720 o 1080 px y elige la carpeta según window.innerWidth antes de que empiece la precarga.

Problemas habituales con las secuencias de fotogramas para animaciones con scroll

  • Los fotogramas se ven borrosos o suaves. El búfer del canvas está en píxeles CSS mientras que la pantalla está a 2x o 3x. Asigna a canvas.width y canvas.height el rectángulo del elemento multiplicado por devicePixelRatio (con un máximo de 2) y deja que el CSS mantenga el elemento al 100 %, exactamente como hace resize() más arriba. Redimensionar el canvas lo vacía, así que vuelve a dibujar de inmediato.
  • La animación parpadea o se queda en blanco al hacer scroll. Estás dibujando fotogramas que aún no se han decodificado. Una Image incompleta no dibuja nada en absoluto. Comprueba img.complete, espera decode() en el grupo precargado e inicia la carga en segundo plano con la suficiente antelación para que un scroll rápido no la adelante.
  • Da un salto al cargar. El índice inicial estaba fijado en 0 mientras el navegador restauraba una posición de scroll a mitad del contenedor. Calcula el índice a partir del scroll actual antes del primer dibujo.
  • La página pesa 40 MB. Demasiados fotogramas, demasiado anchos o con demasiada calidad. Un clip de 5 segundos a 24 fps, 1920 px y calidad 0,85 en WebP pesa entre 4 y 6 MB. Si estás muy por encima, vuelve a extraer con menos fps antes de tocar nada más.
  • El último fotograma no aparece nunca. El contenedor es más bajo que height + 100vh, así que el progreso del scroll nunca llega a 1. Dale al contenedor la altura suficiente para que rect.height - window.innerHeight sea una distancia cómoda: 500vh para 240 fotogramas es un buen punto de partida.

Preguntas frecuentes

¿Cuántos fotogramas hacen falta para una animación con scroll?

Unos 240 para 8 segundos de scroll a 30 fps, o 192 a 24 fps. La página de los AirPods Max de Apple usa más o menos 240 fotogramas; la secuencia del iPhone plegable es de 24 fps. Por debajo de 24 fps el movimiento se ve a saltos cuando alguien hace scroll despacio, y por encima de 30 fps duplicas la descarga por un movimiento que nadie nota. Elige primero la duración del clip, multiplícala por los fps y ya tienes el número de fotogramas.

¿Puedo usar un vídeo de Sora o Runway?

Sí. Sora, Runway, Kling, Veo y Pika exportan en MP4 o WebM, que es justo lo que lee el extractor. Los clips de IA son cortos (normalmente de 4 a 10 segundos) y a veces tienen una frecuencia de fotogramas rara o variable: fija los fps de destino en 24 o 30 y el extractor remuestrea el clip a ese valor.

¿JPG o WebP para una secuencia de fotogramas?

WebP, salvo que tengas un motivo para evitarlo. Pesa entre un 25 y un 35 % menos que el JPG con la misma calidad percibida y lo decodifican todos los navegadores publicados desde 2020. En una secuencia de 240 fotogramas es la diferencia entre unos 12 MB y unos 8 MB. Usa JPG si tienes que admitir versiones muy antiguas de Safari o si los fotogramas también alimentan un flujo de trabajo antiguo.

¿Una animación de canvas con scroll funciona en el móvil?

Sí, pero reduce el presupuesto a la mitad. Sirve una segunda secuencia de 720 o 1080 px de ancho y limita la relación de píxeles del dispositivo a 2; si no, el teléfono descarga fotogramas de tamaño escritorio con una conexión móvil. Safari en iOS lanza eventos de scroll también durante el desplazamiento por inercia, así que el throttle con requestAnimationFrame del ejemplo de esta guía es lo que mantiene estable la frecuencia de dibujo, no una optimización que puedas omitir.

¿Necesito GSAP o ScrollTrigger?

No. Bastan unas 60 líneas de JavaScript puro: un canvas sticky, un array de imágenes precargadas y un gestor del scroll que convierte el avance en un índice de fotograma. GSAP ScrollTrigger compensa cuando tienes que coordinar varias secciones fijadas entre sí, no para una única secuencia de fotogramas.

¿El vídeo se sube a algún sitio?

No. El extractor decodifica, redimensiona y comprime en ZIP los fotogramas dentro de la pestaña del navegador, con WebCodecs y JSZip. Abre DevTools y mira la pestaña Network mientras inicias la extracción: no sale ninguna petición. El clip se queda en tu equipo.

¿Por qué parpadea mi animación de canvas al hacer scroll?

Porque el fotograma que estás dibujando aún no se ha decodificado. Un elemento Image que todavía se está cargando no dibuja nada, así que el canvas conserva lo que había antes o se queda en blanco. Espera decode() en el primer grupo de fotogramas antes del primer dibujo y sigue cargando el resto de la secuencia en segundo plano, para que un scroll rápido no adelante nunca a la red.

¿Puedo hacerlo con CSS scroll-timeline?

Solo para las partes que puedas expresar como una animación CSS. animation-timeline: scroll() y view() animan transformaciones, opacidad y color según la posición del scroll sin JavaScript, y cubren textos fijados y paralaje. Cambiar la imagen que se dibuja en un canvas no es una propiedad CSS animable, así que una secuencia de fotogramas sigue necesitando el gestor del scroll que describe esta guía.

Herramientas relacionadas

Cambiar de herramienta

Busca y abre cualquier herramienta