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.
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 :
- Imagen 3 / Wan 2.1 — générer image de départ + image de fin
- Kling 3.0 — créer la vidéo de transition entre les deux images
- ffmpeg — extraire les frames individuelles
- 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)
ffmpeginstallé 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.
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 :
| Champ | Valeur |
|---|---|
| Start image | Image de départ |
| End image | Image de fin |
| Prompt | Texte généré par Claude |
| Duration | 5–6 secondes |
| Resolution | 1080p |
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
| Option | Signification |
|---|---|
-q:v 3 | Qualité JPEG (1=max, 31=min). 3 donne ~80 Ko/frame, bon compromis |
frame_%04d.jpg | Numé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>
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 fichiersframe_0001.jpgàframe_0033.jpg - Vérifier le chemin dans
img.src— en Astro,public/est la racine, donc/frames/frame_0001.jpgest 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 6au 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 queresizeCanvas()est appelé avant ou quecanvas.width/heightne sont pas à 0
Le hero scrolle trop longtemps avant d’arriver au contenu
- Réduire
heightdu.hero-scroll-container:120vhdonne 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
- Technique originale — Nate KK (EN)
- Apple — exemple de scroll-scrubbing produit
- MDN — Canvas API drawImage
- MDN — position: sticky
- WaveSpeed AI — Kling 3.0
- Google AI Studio — Imagen 3
- ffmpeg — documentation officielle
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