Aller au contenu
Mohamed Tahiri
Miami --:--
← Tous les articles

Une vidéo est une fonction du numéro d’image

Avec Remotion, une vidéo s’écrit en React. Une fois qu’on a compris qu’elle est rendue image par image, en parallèle, par des workers qui ne se parlent pas, beaucoup de bugs s’expliquent. Avec une simulation de rendu.

Le studio de formation que j’ai construit produit des vidéos montées, avec voix et musique, à partir de quelques réponses. Sous le capot, chaque vidéo est un composant React, rendu par Remotion.

Écrire une vidéo en React a quelque chose de grisant. Des composants, des props, le rechargement à chaud, Git pour l’historique, et des tests. Mais le modèle mental n’est pas celui d’une page web, et la plupart des bugs viennent de cet écart.

Une image, un appel

Une page web vit dans le temps : un état, des effets, des animations qui avancent toutes seules. Une vidéo Remotion n’a pas de temps propre. Pour produire l’image 42, Remotion affiche le composant avec useCurrentFrame() qui renvoie 42, attend que tout soit chargé, prend une capture, puis passe à une autre image.

Une animation n’est donc pas quelque chose qui se déroule. C’est une valeur calculée à partir du numéro d’image : interpolate() pour passer d’une valeur à une autre entre deux images, spring() pour un mouvement physique avec rebond. Même image en entrée, même pixel en sortie.

Title.tsx
import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion'

export function Title({ text }: { text: string }) {
  const frame = useCurrentFrame()
  const { fps } = useVideoConfig()
  // everything on screen is derived from the frame number
  const opacity = interpolate(frame, [0, 20], [0, 1], {
    extrapolateRight: 'clamp'
  })
  const scale = spring({ frame, fps, config: { damping: 12 } })
  return <h1 style={{ opacity, transform: `scale(${scale})` }}>{text}</h1>
}

Rendu en parallèle

Pour aller vite, Remotion ne rend pas les images dans l’ordre sur une seule machine. En local, plusieurs onglets de navigateur se partagent le travail. Sur Lambda, la vidéo est découpée en tranches d’images consécutives, et chaque tranche part dans une fonction différente. Ces workers ne partagent ni mémoire, ni état, ni horloge. Chacun affiche le composant de son côté, pour les images qu’on lui a confiées.

Tout ce qui n’est pas une fonction pure du numéro d’image devient alors un bug, et souvent un bug invisible dans l’aperçu. La simulation rend 90 images avec quatre workers, un par couleur. Le point mis en évidence laisse une trace pointillée : sa trajectoire sur toute la vidéo.

Trois façons de tirer au hasard

Math.random() appelé pendant le rendu donne un nombre différent à chaque image : les éléments sautent en permanence. Celui-là se voit tout de suite, et le plugin ESLint de Remotion le signale avant même de lancer un rendu.

Le piège, c’est la deuxième version. On range le tirage dans un useState(() => Math.random()) ou un useMemo, et l’aperçu devient parfait : dans le navigateur, un seul composant reste affiché pendant toute la lecture, donc le tirage ne change jamais. Au rendu, chaque worker monte son propre composant et tire ses propres nombres. La vidéo saute à chaque changement de tranche, et rien dans l’aperçu ne permettait de le voir venir.

La bonne version passe par random(), fourni par Remotion : un nombre pseudo-aléatoire qui ne dépend que de la graine qu’on lui donne. Même graine, même nombre, sur n’importe quelle machine. Pour cinquante particules, chacune reçoit sa propre graine et garde sa place d’une image à l’autre.

Particles.tsx
import { random, useCurrentFrame } from 'remotion'

export function Particles({ count }: { count: number }) {
  const frame = useCurrentFrame()
  return Array.from({ length: count }, (_, i) => {
    // same seed, same number, on any worker
    const x = random(`x-${i}`) * 1920
    const y = random(`y-${i}`) * 1080 - frame * (1 + random(`v-${i}`))
    return <Dot key={i} x={x} y={y} />
  })
}

Les autres sources d’impureté

Le hasard est le cas le plus visible, pas le seul. Le même raisonnement s’applique à tout ce qui dépend du moment ou de la machine plutôt que du numéro d’image.

  • Date.now() dans un composant : la date se calcule une fois, au lancement du rendu, et se passe en prop.
  • Une donnée chargée en fetch : delayRender() demande à Remotion d’attendre la réponse avant de capturer, sinon certaines images partent sans elle.
  • Une animation ou une transition CSS : elle avance avec l’horloge du navigateur, pas avec la vidéo. Remotion ne peut pas la capturer de façon fiable.
  • Une police ou une image pas encore chargée : même remède, attendre explicitement avant la capture.

Pour la vidéo de présentation de la plateforme de cours, la bande-son est générée de façon procédurale, et la même règle s’applique au son : un son calculé doit dépendre du temps et de graines fixes, jamais de l’instant où le rendu tourne.

Ce que la contrainte apporte

Cette discipline a un bon côté. Une vidéo déterministe se teste : on rend l’image 120 en PNG avec renderStill(), on la compare à une image de référence, et une régression de mise en page casse un test au lieu de partir en production. Elle se rend en parallèle sans surprise, et deux rendus du même code donnent la même image.

C’est aussi ce qui rend la vidéo accessible à un agent : il peut modifier un composant, rendre quelques images clés et les comparer pour savoir s’il a cassé quelque chose. Un montage fait à la main ne se prête à rien de tout ça.

Les règles

  • Tout ce qui est à l’écran se calcule depuis useCurrentFrame().
  • Le hasard passe par random(seed), jamais par Math.random(), même rangé dans un état.
  • Pas de Date.now(), pas d’animation CSS, pas de donnée chargée sans delayRender().
  • Vérifier un vrai rendu en parallèle, pas seulement l’aperçu.
Contact

Construisons quelque chose.

Ou laissez un message