> ## Content Index
> Fetch the complete content index at: https://www.sfeir.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Monter une vidéo en React : Claude Code pilote Remotion
- URL: https://www.sfeir.dev/monter-une-video-en-react-claude-code-pilote-remotion/
- Published: 2026-11-09T09:24:40.000Z
- Updated: 2026-11-09T09:24:40.000Z
- Description: Monter une vidéo en React : Claude Code pilote Remotion. Le secret n'est pas un logiciel de montage plus rapide. C'est qu'il n'y a plus de logiciel de montage du tout : la vidéo est un projet Remotion, donc du React, et c'est Claude Code qui écrit et fait évoluer les composants.
- Author: Thomas Zuk
- Tags: Agent IA, Claude, react, vidéo

Et si votre prochain logiciel de montage vidéo était… un projet React ? 🎬

Au fur et à mesure de mes productions de vidéos (sur de la vulgarisation historique), il fallait absolument que j'augmente mon rythme de production car, pour un format long de 8 à 10 minutes, le montage manuel me prenait **3 jours** : caler chaque image sur la voix-off, animer, synchroniser les sous-titres mot à mot. Aujourd'hui, ce montage me prend **30 minutes**.

Le secret n'est pas un logiciel de montage plus rapide. C'est qu'il n'y a plus de logiciel de montage du tout : la vidéo est un projet **Remotion**, donc du React, et c'est **Claude Code** qui écrit et fait évoluer les composants.

Dans cet article, je vous montre comment fonctionne ce pipeline ainsi que les problèmes rencontrés en chemin.

## Pourquoi monter une vidéo en React ?

[Remotion](https://www.remotion.dev/?ref=sfeir.dev) est un framework qui transforme des composants React en vidéo. Si vous êtes développeur Front-end, vous connaissez déjà 80 % de l'outil. La différence tient en une idée : ***le temps devient une prop comme une autre***.

💡

Une ****prop** est une **property* en React. Il s'agit d'un argument que l'on transmet à un composant. 

Concrètement :

- `**useCurrentFrame()**` renvoie le numéro de l'image en cours de rendu. Toute animation se calcule à partir de lui, souvent avec `interpolate()`.
- **`<Sequence>`** joue le rôle de la timeline : un composant apparaît à partir d'une image donnée, pour une durée donnée.
- **Le rendu** est fait par un navigateur headless, image par image, puis encodé en MP4.

Une conséquence surprend au début : les transitions et animations CSS ne fonctionnent pas, car le navigateur ne « joue » jamais la vidéo en temps réel. Tout doit découler de la frame courante. En échange, le rendu est ***déterministe*** : les mêmes entrées produisent toujours la même vidéo.

Et c'est là que l'IA entre en jeu. Écrire ces composants à la main serait plus long qu'un montage classique. Mais du code React, Claude Code sait très bien en écrire, surtout équipé des **skills Remotion**, qui lui apportent les bonnes pratiques et l'API à jour du framework. Bonus non négligeable : une fois le code écrit, chaque nouveau montage ne coûte ***plus aucun appel d'API*** 💰, contrairement aux outils qui génèrent la vidéo elle-même.

## Quelles entrées donner à Claude ?

Il n'y a pas de prompt magique, mais uniquement des **entrées structurées**. Claude ne devine rien : il reçoit tout ce dont il a besoin, dans un format qu'il ne peut pas mal interpréter.

Le pipeline est le suivant, de bout en bout :

1. **Le script** est rédigé et découpé chapitre par chapitre, en JSON.
2. **La voix-off** est générée avec ElevenLabs, qui fournit aussi le **timing de chaque mot**.
3. **Les images** sont préparées dans un dossier prêt à l'emploi.
4. **Chaque plan** reçoit une image, un intervalle de temps et une animation, définis à l'avance.
5. **Remotion** assemble le tout, et Claude Code écrit et fait évoluer les composants.

Côté code, tout part d'un contrat très simple :

```ts
export type Animation = "zoom-in" | "zoom-out" | "pan-left" | "pan-right";

export type Shot = {
  image: string;
  startMs: number;
  endMs: number;
  animation: Animation;
};

export type Chapter = {
  title: string;
  voiceover: string;
  timing: string;
  shots: Shot[];
};

export type TimedWord = {
  word: string;
  start: number;
  end: number;
};
```

`src/types.ts` : le contrat entre les données et les composants.

Et voici à quoi ressemble un chapitre :

```json
{
  "title": "L'épave de 1901",
  "voiceover": "chapitre-1/voiceover.mp3",
  "timing": "chapitre-1/timing.json",
  "shots": [
    { "image": "images/epave.jpg", "startMs": 0, "endMs": 4200, "animation": "zoom-in" },
    { "image": "images/plongeurs.jpg", "startMs": 4200, "endMs": 9800, "animation": "pan-left" },
    { "image": "images/fragment.jpg", "startMs": 9800, "endMs": 15000, "animation": "zoom-out" }
  ]
}
```

`public/chapitre-1/chapter.json` : un chapitre, sa voix-off, et un plan par image.

Le schéma est classique : des données d'un côté, des composants qui les affichent de l'autre. La vidéo n'est qu'un rendu de ces données.

> Plus les entrées sont structurées, moins l'IA a besoin d'improviser.

## Du JSON aux composants : à quoi ressemble le code ?

Le code ci-dessous est une version simplifiée, reconstruite pour l'article. Il est typé et rendu avec Remotion 4.0.

### La composition : la durée se déduit des données

Premier réflexe à adopter : ne jamais coder la durée de la vidéo en dur. `calculateMetadata` la calcule avant le rendu, à partir du dernier plan. C'est aussi là que le timing de la voix-off est chargé et converti en sous-titres.

```tsx
import type { Caption } from "@remotion/captions";
import { Audio } from "@remotion/media";
import { AbsoluteFill, Sequence, staticFile, useVideoConfig, type CalculateMetadataFunction } from "remotion";
import { toCaptions } from "./captions";
import { ShotView } from "./Shot";
import { Subtitles } from "./Subtitles";
import type { Chapter, TimedWord } from "./types";

type Props = { chapter: Chapter; captions?: Caption[] };

export const calculateMetadata: CalculateMetadataFunction<Props> = async ({ props }) => {
  const words: TimedWord[] = await fetch(staticFile(props.chapter.timing)).then((res) => res.json());
  const lastShotEndMs = Math.max(...props.chapter.shots.map((shot) => shot.endMs));
  return {
    durationInFrames: Math.ceil((lastShotEndMs / 1000) * 30),
    props: { ...props, captions: toCaptions(words) },
  };
};

export const ChapterVideo: React.FC<Props> = ({ chapter, captions = [] }) => {
  const { fps } = useVideoConfig();
  const toFrame = (ms: number) => Math.round((ms / 1000) * fps);

  return (
    <AbsoluteFill style={{ backgroundColor: "black" }}>
      {chapter.shots.map((shot) => {
        const durationInFrames = toFrame(shot.endMs) - toFrame(shot.startMs);
        return (
          <Sequence key={shot.startMs} from={toFrame(shot.startMs)} durationInFrames={durationInFrames}>
            <ShotView shot={shot} durationInFrames={durationInFrames} />
          </Sequence>
        );
      })}
      <Subtitles captions={captions} />
      <Audio src={staticFile(chapter.voiceover)} />
    </AbsoluteFill>
  );
};
```

`src/ChapterVideo.tsx` : un `<Sequence>` par plan, les sous-titres et la voix-off par-dessus.

### Un plan : l'animation est une fonction de la frame

Chaque animation du JSON correspond à un mouvement de caméra : zoom avant, zoom arrière, panoramique. Le composant calcule sa progression à partir de `useCurrentFrame()`, puis en déduit l'échelle et la translation.

```tsx
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame } from "remotion";
import type { Animation, Shot } from "./types";

const MOTIONS: Record<Animation, { scale: [number, number]; x: [number, number] }> = {
  "zoom-in": { scale: [1, 1.15], x: [0, 0] },
  "zoom-out": { scale: [1.15, 1], x: [0, 0] },
  "pan-left": { scale: [1.1, 1.1], x: [3, -3] },
  "pan-right": { scale: [1.1, 1.1], x: [-3, 3] },
};

const fill = { width: "100%", height: "100%" } as const;

export const ShotView: React.FC<{ shot: Shot; durationInFrames: number }> = ({ shot, durationInFrames }) => {
  const frame = useCurrentFrame();
  const motion = MOTIONS[shot.animation];
  const progress = interpolate(frame, [0, durationInFrames], [0, 1], {
    extrapolateLeft: "clamp",
    extrapolateRight: "clamp",
  });
  const src = staticFile(shot.image);

  return (
    <AbsoluteFill style={{ backgroundColor: "black", overflow: "hidden" }}>
      <Img src={src} style={{ ...fill, objectFit: "cover", filter: "blur(40px)", opacity: 0.5 }} />
      <AbsoluteFill
        style={{
          scale: String(interpolate(progress, [0, 1], motion.scale)),
          translate: `${interpolate(progress, [0, 1], motion.x)}% 0`,
        }}
      >
        <Img src={src} style={{ ...fill, objectFit: "contain" }} />
      </AbsoluteFill>
    </AbsoluteFill>
  );
};
```

`src/Shot.tsx` est une table d'animations plutôt qu'une série de `if`, pour qu'ajouter un mouvement reste trivial.

Cette table est un bon exemple de ce que Claude Code fait très bien : quand je lui demande un nouveau mouvement, il ajoute une ligne, et le type `Animation` garantit que le JSON et le code restent cohérents. 📐

## Les problèmes rencontrés ⚠️

Tout n'a pas fonctionné du premier coup. Trois problèmes sont revenus régulièrement, et chacun s'est résolu de la même façon : en donnant **plus de structure** à Claude, jamais en espérant qu'il devine.

### Des transitions qui perdent en dynamisme

Au fil des itérations, les enchaînements devenaient plus lents et plus répétitifs. J'ai renforcé les consignes sur ce point précis et ajouté une meilleure accroche pour relancer le rythme. C'est d'ailleurs l'intérêt de la table d'animations : la variété se décide dans le JSON, plan par plan, au lieu d'être laissée à l'inspiration du modèle. La créativité reste à la main de l'auteur tandis que la technique est exécutée par le skill Remotion.

### Des sous-titres qui écorchent les noms propres

Les mots complexes, souvent des noms de lieux ou de personnages historiques, étaient mal orthographiés dans les sous-titres. La solution : un ***dictionnaire*** des mots difficiles, appliqué au moment de convertir le timing en sous-titres au format `Caption` de `@remotion/captions`.

```ts
import type { Caption } from "@remotion/captions";
import type { TimedWord } from "./types";

const DICTIONARY: Record<string, string> = {
  toutankhamon: "Toutânkhamon",
  "qin-shi-huang": "Qin Shi Huang",
  champollion: "Champollion",
};

const fixSpelling = (word: string): string => {
  const [, before, core, after] = word.match(/^(\W*)(.*?)(\W*)$/u) ?? ["", "", word, ""];
  return before + (DICTIONARY[core.toLowerCase()] ?? core) + after;
};

export const toCaptions = (words: TimedWord[]): Caption[] =>
  words.map(({ word, start, end }) => ({
    text: ` ${fixSpelling(word)}`,
    startMs: start * 1000,
    endMs: end * 1000,
    timestampMs: ((start + end) / 2) * 1000,
    confidence: null,
  }));
```

`src/captions.ts` : la ponctuation autour du mot est conservée, seul le mot lui-même est corrigé.

Les sous-titres sont ensuite affichés par petites pages, avec le mot en cours de lecture mis en couleur :

```tsx
import { createTikTokStyleCaptions, type Caption } from "@remotion/captions";
import { useMemo } from "react";
import { AbsoluteFill, Sequence, useCurrentFrame, useVideoConfig } from "remotion";

const WORDS_PER_PAGE_MS = 1200;

const Page: React.FC<{ tokens: { text: string; fromMs: number; toMs: number }[]; startMs: number }> = ({
  tokens,
  startMs,
}) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const nowMs = startMs + (frame / fps) * 1000;

  return (
    <AbsoluteFill style={{ justifyContent: "flex-end", alignItems: "center", paddingBottom: 160 }}>
      <div style={{ fontFamily: "sans-serif", fontSize: 72, fontWeight: 800, color: "white", textAlign: "center", whiteSpace: "pre" }}>
        {tokens.map((token) => (
          <span key={token.fromMs} style={{ color: nowMs >= token.fromMs && nowMs < token.toMs ? "#FFD23F" : "white" }}>
            {token.text}
          </span>
        ))}
      </div>
    </AbsoluteFill>
  );
};

export const Subtitles: React.FC<{ captions: Caption[] }> = ({ captions }) => {
  const { fps } = useVideoConfig();
  const { pages } = useMemo(
    () => createTikTokStyleCaptions({ captions, combineTokensWithinMilliseconds: WORDS_PER_PAGE_MS }),
    [captions],
  );

  return (
    <>
      {pages.map((page, index) => {
        const nextStartMs = pages[index + 1]?.startMs ?? page.startMs + page.durationMs;
        const from = Math.round((page.startMs / 1000) * fps);
        const durationInFrames = Math.round((nextStartMs / 1000) * fps) - from;
        return durationInFrames > 0 ? (
          <Sequence key={page.startMs} from={from} durationInFrames={durationInFrames}>
            <Page tokens={page.tokens} startMs={page.startMs} />
          </Sequence>
        ) : null;
      })}
    </>
  );
};
```

`src/Subtitles.tsx` est une page de mots par `<Sequence>`, le mot courant en jaune.

### Des plans mal cadrés sur les petites images

Quand une image était plus petite que le format de la vidéo, ou en portrait, le centrage et l'animation se décalaient. J'ai demandé à Claude Code de revoir le code de cadrage, puis renforcé les consignes pour couvrir ce cas limite.

Le correctif tient en deux couches, visibles dans `ShotView` plus haut :

- **En arrière-plan**, la même image en `objectFit: "cover"`, floutée, qui remplit tout l'écran.
- **Au premier plan**, l'image en `objectFit: "contain"`, toujours entière et centrée, avec l'animation appliquée sur son conteneur plutôt que sur l'image elle-même.

Ainsi, une image en portrait reste centrée, sans bandes noires, et le zoom part bien du centre. C'est typiquement le genre de limite qu'on ne voit qu'en production, et que l'IA ne prévoit pas seule. 🔍

## Qu'est-ce que cela change concrètement ?

Les chiffres parlent d'eux-mêmes :

| Montage manuel          | Remotion piloté par Claude Code |                   |
| ----------------------- | ------------------------------- | ----------------- |
| Vidéo de 8 à 10 minutes | 3 jours                         | 30 minutes        |
| Coût par montage        | Mon temps                       | Aucun appel d'API |

Pour lancer le rendu d'un chapitre, une seule commande suffit :

```bash
npx remotion render src/index.ts Chapitre1 out/chapitre-1.mp4
```

## Quelles sont les limites ?

- **Les 30 minutes ne comptent que le montage.** Le script, la voix-off et les images restent à préparer en amont.
- **Le code initial demande un investissement.** Le gain n'apparaît qu'à partir de la deuxième vidéo, quand les composants sont réutilisés.
- **Le code peut être correct et la vidéo ratée.** Un rythme qui s'essouffle ou un cadrage maladroit ne se détectent qu'en regardant le rendu. L'œil humain reste indispensable.

Pour les plus curieux d'entre vous, voici un exemple de ce que cela peut rendre, une fois animée : 

## Que retenir de cette expérience ?

La leçon principale est simple : Claude ne monte pas la vidéo à ma place. Il exécute un ***système que j'ai conçu***. Et ce système, c'est du Front-end tout ce qu'il y a de plus classique : des données typées, des composants purs, un rendu déterministe.

Si vous voulez vous lancer, voici les principes que j'appliquerais dès le départ :

- **Structurez vos entrées** : script, timing et plans en JSON plutôt qu'en texte libre.
- **Typez le contrat** entre les données et les composants, pour que l'IA ne puisse pas inventer une animation qui n'existe pas.
- **Installez les skills Remotion** pour que Claude Code utilise l'API à jour du framework.
- **Donnez du vocabulaire à l'IA** : un dictionnaire des termes difficiles de votre niche évite bien des fautes.
- **Testez les cas limites** : images en portrait, plans très courts ou très longs.
- **Regardez le rendu** : c'est la seule revue de code qui compte vraiment en vidéo.

J'espère que ce retour d'expérience vous donnera envie de sortir React du navigateur, je vous dis à bientôt ! 👋

## Sources

- [Documentation de Remotion](https://www.remotion.dev/docs/?ref=sfeir.dev)
- [@remotion/captions](https://www.remotion.dev/docs/captions?ref=sfeir.dev)