← RETOUR À L'INDEX
/tutoriels/ claude-code / animation-scroll-scrubbing-canvas-pilotée-par-ia-générative.md

Animation scroll-scrubbing canvas pilotée par IA générative

Créer un hero animé au scroll (style Apple) depuis une vidéo IA : Imagen 3 → Kling/Wan → ffmpeg → canvas HTML. Zéro bibliothèque, 33 frames, 4 Ko de JS.

CAT · CLAUDE CODE LECTURE · 10 min PUBLIÉ · 2026-04-12

Temps estimé : 2–3h (génération vidéo incluse)

Résultat final : Un hero plein-écran où une animation IA avance frame par frame au scroll, identique à la technique des pages produit Apple/Nike.


Objectif

Reproduire l’effet “scroll-scrubbing” des grandes marques : une vidéo générée par IA (transition entre deux états d’un visuel) se déroule en fonction du scroll, donnant l’impression que le produit/sujet se déploie sous les yeux de l’utilisateur.

La technique :

  1. Imagen 3 / Wan 2.1 — générer image de départ + image de fin
  2. Kling 3.0 — créer la vidéo de transition entre les deux images
  3. ffmpeg — extraire les frames individuelles
  4. Canvas HTML + scroll event — afficher la bonne frame selon la position de scroll

Aucune bibliothèque JS nécessaire.


Prérequis

  • Accès à Google AI Studio (aistudio.google.com) ou WaveSpeed AI pour Imagen 3
  • Accès à Kling 3.0 via WaveSpeed AI (quelques centimes par vidéo)
  • ffmpeg installé localement (brew install ffmpeg / apt install ffmpeg)
  • Un site statique (Astro, HTML pur, Next.js, etc.)
  • Claude Code pour le scaffolding si besoin

Étape 1 : Générer les deux images clés avec Imagen 3

L’animation repose sur une image de départ et une image de fin. La vidéo fera la transition entre les deux.

1.1 Image de départ

Sur Google AI Studio, sélectionner le modèle Imagen 3 (ou Imagen 3 Pro), régler :

  • Aspect ratio : 16/9 (format paysage pour le web)
  • Resolution : 4K si disponible
  • Reasoning : High

Prompter en anglais — les modèles de génération d’image répondent mieux en anglais.

Exemple de prompt pour un cerveau neuronal :

A luminous neural brain rendered in glowing blue wireframe nodes and connecting
filaments, perfectly contained against pure black. Cold blue bioluminescent glow,
soft bloom on each node. Ultra high definition, Apple keynote aesthetic.
Photorealistic volumetric rendering. 16:9 landscape format.
⚠️ Sécurité

Les images générées via API Google sont soumises aux conditions d’utilisation de Google. Ne pas utiliser pour des contenus à caractère commercial sans vérifier les droits d’utilisation de Imagen 3.

1.2 Image de fin (état “déployé”)

Dans la même conversation, prompter l’état suivant du sujet — par exemple, le cerveau en expansion :

Same neural brain, same style, same background, but now the filaments extend
outward from the surface like slow electrical discharges. New nodes emerge at
their tips, the entire network expanding radially from within. Full bloom,
nodes at their outermost positions.

⚠️ Reproductibilité : Rester dans la même conversation garantit la cohérence de style entre les deux images. Une nouvelle conversation risque de produire un style différent.

Sauvegarder les deux images en 16/9, format PNG.


Étape 2 : Générer la vidéo de transition avec Kling 3.0

2.1 Préparer le prompt vidéo avec Claude

Claude connaît Kling et peut générer un prompt optimisé. Envoyer les deux images à Claude.ai avec cette instruction :

Aide-moi à générer un prompt pour le modèle vidéo Kling 3.0 (image-to-video).
Je veux une vidéo ultra lisse qui fait une transition professionnelle de
l'image n°1 vers l'image n°2. La transition doit être imperceptiblement lente
au début, accélérant vers la fin. Style Apple keynote, pas de mouvement de caméra.

2.2 Générer la vidéo sur WaveSpeed AI

Sur WaveSpeed AI, naviguer vers Kling 3.0 Pro — Image to Video.

Paramètres :

ChampValeur
Start imageImage de départ
End imageImage de fin
PromptTexte généré par Claude
Duration5–6 secondes
Resolution1080p

Coût estimé : ~0,50–0,70 € pour 6 secondes en 1080p.

⚠️ Budget : Chaque génération est facturée même si le résultat n’est pas satisfaisant. Tester d’abord en 3 secondes pour valider la direction, puis générer en 6 secondes.

Télécharger la vidéo en .mp4.


Étape 3 : Extraire les frames avec ffmpeg

3.1 Vérifier la vidéo

ffprobe -v error -select_streams v:0 \
  -show_entries stream=duration,r_frame_rate,width,height \
  -of default video.mp4

Exemple de sortie :

width=1920
height=1080
r_frame_rate=16/1
duration=2.062500

Une vidéo de 2s à 16 fps = 33 frames.

3.2 Extraire toutes les frames en JPEG

mkdir -p public/frames

ffmpeg -i video.mp4 -q:v 3 public/frames/frame_%04d.jpg
OptionSignification
-q:v 3Qualité JPEG (1=max, 31=min). 3 donne ~80 Ko/frame, bon compromis
frame_%04d.jpgNumérotation sur 4 chiffres : frame_0001.jpg … frame_0033.jpg

⚠️ Poids : 33 frames × ~80 Ko = ~2,6 Mo à servir. Sur un CDN (Cloudflare Pages, Vercel), c’est négligeable. Sur un hébergement mutualisé avec quota de bande passante, précacher les frames avec un Service Worker.

Vérification :

ls public/frames/ | wc -l   # doit afficher 33
du -sh public/frames/        # doit afficher ~3 Mo

Étape 4 : Construire le hero scroll-scrubbing

4.1 Structure HTML

Le principe : un conteneur haut (ex. 130vh) crée de l’espace de scroll, avec un enfant sticky (100vh) qui reste en vue. Le canvas dessine la bonne frame.

<!-- Conteneur de scroll : crée la zone de défilement -->
<div class="hero-scroll-container" id="heroContainer">

  <!-- Sticky : reste dans le viewport pendant tout le scroll -->
  <div class="hero-sticky">
    <canvas id="heroCanvas" class="hero-canvas"></canvas>

    <!-- Texte en overlay -->
    <div class="hero-overlay">
      <div class="hero-text">
        <p class="hero-eyebrow">Mon projet</p>
        <h1>Titre<br /><span class="hero-gradient">accrocheur.</span></h1>
        <p class="hero-sub">Sous-titre court.</p>
      </div>
    </div>

    <!-- Indicateur de scroll -->
    <div class="hero-scroll-hint" aria-hidden="true">↓</div>
  </div>

</div>

<!-- Contenu normal en-dessous -->
<main>...</main>

4.2 CSS

/* Zone de scroll — plus haute que 100vh pour permettre l'animation */
.hero-scroll-container {
  height: 130vh;   /* 30vh d'animation = un léger geste de scroll */
  position: relative;
}

/* Section sticky plein écran */
.hero-sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

/* Canvas plein fond */
.hero-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Overlay avec gradient pour lisibilité du texte */
.hero-overlay {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    105deg,
    rgba(0, 0, 0, 0.72) 0%,
    rgba(0, 0, 0, 0.35) 55%,
    rgba(0, 0, 0, 0.05) 100%
  );
  display: flex;
  align-items: center;
  padding: 80px 4rem 0;   /* 80px = hauteur de la nav fixe */
}

.hero-text { max-width: 640px; }

.hero-text h1 {
  font-size: clamp(3rem, 8vw, 5.5rem);
  line-height: 1.0;
  letter-spacing: -0.04em;
  color: #fff;
}

.hero-gradient {
  background: linear-gradient(135deg, #7eb3ff 0%, #a78bfa 60%, #60a5fa 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

/* Flèche animée */
.hero-scroll-hint {
  position: absolute;
  bottom: 2rem;
  left: 50%;
  transform: translateX(-50%);
  color: rgba(255, 255, 255, 0.4);
  font-size: 1.5rem;
  animation: bobDown 1.6s ease-in-out infinite;
}

@keyframes bobDown {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50%       { transform: translateX(-50%) translateY(6px); }
}

4.3 JavaScript — le cœur du scroll-scrubbing

const FRAME_COUNT = 33; // adapter au nombre de frames extrait
const heroContainer = document.getElementById('heroContainer');
const canvas = document.getElementById('heroCanvas');
const ctx = canvas.getContext('2d');

const frames = [];
let loaded = 0;

// Dessiner une frame sur le canvas en mode "cover"
function drawFrame(index) {
  if (!frames[index]?.complete) return;

  const cw = canvas.width, ch = canvas.height;
  const fw = frames[index].naturalWidth, fh = frames[index].naturalHeight;

  // Calculer l'échelle pour couvrir tout le canvas (object-fit: cover)
  const scale = Math.max(cw / fw, ch / fh);
  const dw = fw * scale, dh = fh * scale;
  const dx = (cw - dw) / 2, dy = (ch - dh) / 2;

  ctx.clearRect(0, 0, cw, ch);
  ctx.drawImage(frames[index], dx, dy, dw, dh);
}

// Calculer et afficher la frame correspondant au scroll
function scrub() {
  if (!heroContainer || loaded < FRAME_COUNT) return;

  const scrolled = -heroContainer.getBoundingClientRect().top;
  const total = heroContainer.offsetHeight - window.innerHeight;
  const progress = Math.max(0, Math.min(1, scrolled / total));
  const index = Math.min(FRAME_COUNT - 1, Math.floor(progress * FRAME_COUNT));

  drawFrame(index);
}

// Redimensionner le canvas à la taille du viewport
function resizeCanvas() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  scrub();
}

// Précharger toutes les frames
for (let i = 1; i <= FRAME_COUNT; i++) {
  const img = new Image();
  // Adapter le chemin selon votre structure
  img.src = `/frames/frame_${String(i).padStart(4, '0')}.jpg`;
  img.onload = () => {
    loaded++;
    if (loaded === 1) drawFrame(0); // afficher frame 0 immédiatement
    if (loaded === FRAME_COUNT) {
      window.addEventListener('scroll', scrub, { passive: true });
      scrub();
    }
  };
  frames[i - 1] = img;
}

resizeCanvas();
window.addEventListener('resize', resizeCanvas, { passive: true });

⚠️ Performance : Utiliser { passive: true } sur le scroll listener est obligatoire — sans ça, le navigateur attend la fin du handler avant de rendre le scroll, causant des saccades visibles.


Étape 5 : Intégration dans Astro (optionnel)

Si le site est en Astro, les balises <script> dans les .astro sont automatiquement scopées et bundlées. Placer le script dans un <script> sans is:inline pour bénéficier du TypeScript :

---
const FRAME_COUNT = 33;
---

<div class="hero-scroll-container" id="heroContainer">
  ...
</div>

<script>
  const FRAME_COUNT = 33;
  const heroContainer = document.getElementById('heroContainer') as HTMLElement | null;
  const canvas = document.getElementById('heroCanvas') as HTMLCanvasElement | null;
  const ctx = canvas?.getContext('2d') ?? null;
  // ... reste du JS
</script>
⚠️ Sécurité

Ne jamais mettre de clés API ou de tokens dans des scripts côté client — ils sont visibles dans la source. Les URLs des frames sont publiques par nature (images dans /public).


Dépannage

Les frames ne chargent pas

  • Vérifier que public/frames/ contient bien les fichiers frame_0001.jpg à frame_0033.jpg
  • Vérifier le chemin dans img.src — en Astro, public/ est la racine, donc /frames/frame_0001.jpg est correct
  • Ouvrir la console navigateur : les 404 apparaissent dans l’onglet Network

L’animation saccade

  • Vérifier que { passive: true } est présent sur le scroll listener
  • Réduire la qualité JPEG si les frames sont trop lourdes : -q:v 6 au lieu de -q:v 3
  • Sur mobile, le scroll est géré différemment — tester sur un vrai appareil

Le canvas est noir au chargement

  • drawFrame(0) est appelé dès que la première frame charge — si le canvas reste noir, vérifier que resizeCanvas() est appelé avant ou que canvas.width/height ne sont pas à 0

Le hero scrolle trop longtemps avant d’arriver au contenu

  • Réduire height du .hero-scroll-container : 120vh donne environ 20vh de scroll pour l’animation
  • En dessous de 110vh, l’animation devient trop rapide pour être perçue

La vidéo fait X frames mais j’en veux moins

Sous-échantillonner avec ffmpeg :

# Extraire 1 frame sur 2 (fps/2)
ffmpeg -i video.mp4 -vf fps=8 -q:v 3 public/frames/frame_%04d.jpg

Références

// MA SOLUTION À DATE

Stack retenue : Google AI Studio (Imagen 3 via Wan 2.1 ComfyUI) pour la vidéo, ffmpeg pour l'extraction des frames, canvas natif + scroll event pour le scrubbing. Aucune lib JS. Le résultat tourne sur ce portfolio.

VOIR AUSSI

Vidéo originale de la technique par Nate KK · Google AI Studio (Imagen 3) · WaveSpeed AI (Kling 3.0 image-to-video) · Wan 2.1 FLF2V — ComfyUI tutorial

VR · 2026-04-12 · vraffin.dev FIN DU DOCUMENT