MCP rate limiting : comment ajouter des limites de débit à un serveur MCP
Un plan concret pour le rate limiting MCP en TypeScript. Construisez un token bucket, identifiez les appelants par jeton ou identifiant client, renvoyez 429 et Retry-After en HTTP, pondérez les outils selon leur coût, passez à l’échelle avec Redis et testez chaque limite avec des faux timers avant qu’un agent ne trouve les failles.
Un agent IA ne se lasse jamais. Lancez-en un sur votre serveur MCP avec une tâche floue et il peut enchaîner 40 appels d’outils en dix secondes, relancer chaque échec instantanément et déclencher des requêtes parallèles que personne n’avait prévues. C’est formidable pour la productivité et désastreux pour votre facture. Le rate limiting MCP permet à un serveur Model Context Protocol de rester utilisable sous une telle pression : chaque appelant reçoit un budget équitable, les outils coûteux consomment davantage que les outils bon marché, et quiconque a épuisé son budget reçoit un message clair indiquant quand revenir.
Cet article montre comment ajouter des limites de débit à un serveur MCP en TypeScript, depuis un token bucket simple jusqu’à un limiteur adossé à Redis qui fonctionne sur plusieurs instances. Vous verrez où placer les contrôles, comment renvoyer des erreurs qu’un agent peut exploiter, et comment tout tester sans attendre une minute réelle.
Pourquoi les serveurs MCP ont besoin de limites de débit
Une limite de débit est une promesse de capacité : cet appelant peut utiliser cette quantité, par unité de temps, et pas davantage. Les notes de sécurité sur les outils de la spécification Model Context Protocol listent la limitation des invocations d’outils comme une exigence pour les serveurs, juste à côté de la validation des entrées et du contrôle d’accès. Les SDK officiels gèrent les transports et les schémas, mais laissent le limiteur à votre charge : chaque auteur de serveur finit donc par en écrire un.
Les agents réessaient sans se fatiguer
Une personne qui clique sur un bouton est lente et facile à prévoir. Une boucle d’agent n’est ni l’un ni l’autre. Le modèle appelle un outil, lit le résultat et décide de l’appel suivant, souvent en quelques millisecondes. Trois schémas reviennent sans cesse :
Tempêtes de relances. Un outil échoue, le modèle réessaie, échoue à nouveau et recommence jusqu’à épuisement de son contexte ou de son budget.
Fan-out parallèle. Les clients peuvent envoyer plusieurs appels d’outils à la fois, si bien qu’un seul prompt peut se transformer en une douzaine de requêtes simultanées.
Boucles incontrôlées. Une tâche floue combinée à un outil qui ne signale jamais « terminé » produit des centaines d’appels depuis une seule session.
Il existe aussi un volet sécurité. Une page web ou un document lu par l’agent peut cacher des instructions lui demandant d’appeler un outil encore et encore. Vous ne pouvez pas toujours empêcher l’injection, mais une limite de débit en plafonne les dégâts.
Les outils enveloppent des API payantes
La plupart des outils MCP sont de fines enveloppes autour de quelque chose qui coûte de l’argent ou dispose de son propre quota : un grand modèle de langage, un générateur d’images, une API de recherche, une base de données. Un limiteur protège trois choses à la fois :
Votre budget, car une session bruyante ne doit pas consommer une journée de dépenses.
Vos quotas amont, car les fournisseurs répondent aux abus par des réponses 429 qui touchent tous les utilisateurs de votre serveur.
La latence des autres utilisateurs, car un client gourmand qui sature vos workers ralentit tout le monde.
💡 Les serveurs stdio locaux ont aussi besoin de limites. Même lorsqu’une seule personne fait tourner le serveur sur son ordinateur portable, une boucle d’agent peut vider l’API payante qui se trouve derrière. Un budget par outil ne coûte rien à ajouter et évite une surprise très désagréable.
Choisir le bon algorithme
Six conceptions couvrent presque tous les cas. Voici leur comportement une fois que le trafic des agents les frappe :
Algorithme
Comportement en rafale
Mémoire par appelant
Idéal pour
Fenêtre fixe
Autorise jusqu’à 2x aux bords de fenêtre
Un compteur
Quotas simples comme les plafonds journaliers
Journal de fenêtre glissante
Exact, pas de rafales aux bords
Un horodatage par requête
Faible volume, limites strictes
Compteur de fenêtre glissante
Proche de l’exact
Deux compteurs
Points d’accès HTTP à fort volume
Token bucket
Rafales contrôlées, recharge régulière
Deux nombres
Appels d’outils par des agents
Leaky bucket
Pas de rafales, sortie régulière
Une file d’attente
Alimenter des services amont fragiles
Limite de concurrence
Plafonne les tâches parallèles
Un compteur
Outils à exécution longue
Fenêtres fixes et glissantes
Un compteur à fenêtre fixe est la conception la plus simple : on compte les requêtes par minute et on remet à zéro au début de la minute. Il est peu coûteux et facile à expliquer, mais un appelant peut envoyer un quota complet à 12:00:59 puis un autre quota complet à 12:01:00, ce qui double la rafale que voit votre serveur.
Une fenêtre glissante supprime ce problème de bord en regardant les 60 dernières secondes à partir de l’instant présent. Elle stocke soit chaque horodatage (exact, mais gourmand en mémoire), soit pondère le compteur de la fenêtre précédente (assez proche et peu coûteux). Tournez-vous vers les fenêtres lorsque vous voulez des quotas simples, comme 1 000 appels par jour, où le moment exact de la recharge n’a pas d’importance.
Pourquoi le token bucket l’emporte généralement
Le trafic des agents est par rafales : rien pendant dix secondes, puis six appels d’outils d’un coup, puis le silence. Un token bucket épouse parfaitement cette forme. Chaque appelant possède un seau doté d’une capacité (la plus grande rafale autorisée) et d’un débit de recharge (le rythme soutenu). Un appel retire des tokens, et le temps les remet. Un seau de 60 jetons rechargé à raison d’un par seconde autorise une rafale de 60 appels, puis un appel par seconde, ce qui se décrit facilement aux utilisateurs par « 60 tout de suite, puis 60 par minute ensuite ».
Deux propriétés le rendent idéal pour MCP :
Recharge paresseuse. Vous calculez la recharge à l’arrivée d’une requête, donc il n’y a aucun minuteur à gérer.
Prise en compte des coûts. Un outil vidéo peut prendre 20 jetons tandis qu’une recherche n’en prend qu’un, le tout sur le même budget.
Construire le limiteur en TypeScript
Le limiteur ci-dessous fonctionne dans tout serveur MCP en TypeScript construit sur @modelcontextprotocol/sdk. Il n’a aucune dépendance et conserve son état en mémoire.
La classe du limiteur
// rate-limit.ts
export type Decision = {
allowed: boolean;
remaining: number;
retryAfterMs: number;
};
type Bucket = { tokens: number; updatedAt: number };
export class TokenBucket {
private buckets = new Map<string, Bucket>();
constructor(
private readonly capacity: number,
private readonly refillPerSecond: number,
) {}
take(id: string, cost = 1): Decision {
if (cost > this.capacity) {
throw new RangeError(`Cost ${cost} is larger than the bucket (${this.capacity})`);
}
const now = Date.now();
const bucket = this.buckets.get(id) ?? { tokens: this.capacity, updatedAt: now };
const elapsedSeconds = (now - bucket.updatedAt) / 1000;
bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
bucket.updatedAt = now;
this.buckets.set(id, bucket);
if (bucket.tokens >= cost) {
bucket.tokens -= cost;
return { allowed: true, remaining: Math.floor(bucket.tokens), retryAfterMs: 0 };
}
const missing = cost - bucket.tokens;
return {
allowed: false,
remaining: 0,
retryAfterMs: Math.ceil((missing / this.refillPerSecond) * 1000),
};
}
// Drop idle buckets so the map cannot grow forever.
sweep(maxIdleMs = 10 * 60_000) {
const cutoff = Date.now() - maxIdleMs;
for (const [id, bucket] of this.buckets) {
if (bucket.updatedAt < cutoff) this.buckets.delete(id);
}
}
}
export const budget = new TokenBucket(60, 1); // burst of 60, refills one per second
setInterval(() => budget.sweep(), 60_000).unref();
Trois détails méritent un second regard. La recharge se calcule à partir du temps écoulé, donc il n’y a pas de setInterval par appelant. L’argument cost permet à un seul limiteur de servir à la fois les outils bon marché et les outils coûteux. Et retryAfterMs indique exactement combien de temps il faut pour que le seau contienne assez de jetons, ce qui est le nombre que vous montrez à l’agent.
Envelopper chaque gestionnaire d’outil
Placez le contrôle dans un seul wrapper pour qu’aucun outil ne puisse l’oublier :
import { z } from "zod";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { budget } from "./rate-limit.js";
type Extra = { authInfo?: { clientId?: string }; sessionId?: string };
export function limited<Args>(
tool: string,
cost: number,
handler: (args: Args, extra: Extra) => Promise<CallToolResult>,
) {
return async (args: Args, extra: Extra): Promise<CallToolResult> => {
const caller = extra.authInfo?.clientId ?? "anonymous";
const decision = budget.take(caller, cost);
if (!decision.allowed) {
const seconds = Math.ceil(decision.retryAfterMs / 1000);
return {
isError: true,
content: [
{
type: "text",
text: `Rate limit reached for ${tool}. Wait ${seconds} seconds before calling it again.`,
},
],
};
}
return handler(args, extra);
};
}
server.registerTool(
"generate_image",
{ description: "Generate an image from a prompt", inputSchema: { prompt: z.string().max(4000) } },
limited("generate_image", 5, async ({ prompt }) => {
const url = await createImage(prompt);
return { content: [{ type: "text", text: url }] };
}),
);
💡 Renvoyez le bloc comme résultat d’outil, pas comme erreur levée. La spécification distingue les erreurs de protocole des erreurs d’exécution d’outil. Un résultat contenant isError: true reste dans le contexte du modèle : l’agent lit « attendez 12 secondes » et s’ajuste. Une exception levée devient une erreur JSON-RPC que de nombreux clients affichent comme un simple échec, sans plus.
Identifier les appelants et protéger le point d’accès
Une limite n’est aussi équitable que la façon dont vous identifiez l’appelant. Si vous vous trompez, vous ralentissez tout le monde ensemble ou vous laissez un client contourner la limite en se reconnectant.
Choisir la bonne identité
Transport et authentification
Identité à utiliser
Points de vigilance
stdio
Un budget partagé par outil
Un processus ne dessert qu’un client, donc il n’y a aucun appelant à distinguer
HTTP streamable avec OAuth
L’identifiant du client ou de l’utilisateur issu de authInfo
La meilleure option, car elle survit aux reconnexions
HTTP streamable avec un jeton bearer statique
Un hachage du jeton
Faites tourner les jetons et hachez-les avant de les stocker
HTTP anonyme
Adresse IP
Les bureaux partagés et les réseaux mobiles apparaissent comme un seul appelant
Résistez à la tentation d’utiliser l’en-tête Mcp-Session-Id comme identité principale. Le serveur le délivre, et un client peut simplement initialiser une nouvelle session pour obtenir un seau neuf. Utilisez les sessions comme limite secondaire, par exemple pour plafonner les tâches en cours par session, et gardez le budget lié à quelque chose qui survit aux reconnexions.
Renvoyer un 429 avec Retry-After
Placez une limite grossière à la couche HTTP et une limite précise à l’intérieur des outils. La couche HTTP est peu coûteuse et s’exécute avant l’analyse de tout JSON ou la création de toute session, ce qui la protège des inondations. Elle ne peut pas distinguer un tools/list bon marché d’un tools/call coûteux sans lire le corps de la requête : gardez-la donc généreuse et laissez la couche outil faire le comptage précis.
import { createHash } from "node:crypto";
import type { NextFunction, Request, Response } from "express";
import { TokenBucket } from "./rate-limit.js";
const httpBudget = new TokenBucket(120, 2); // 120 burst, 2 per second sustained
function callerId(req: Request): string {
const auth = req.header("authorization");
if (auth) return "tok:" + createHash("sha256").update(auth).digest("hex").slice(0, 16);
return "ip:" + req.ip;
}
export function limitHttp(req: Request, res: Response, next: NextFunction) {
const decision = httpBudget.take(callerId(req));
res.setHeader("RateLimit-Remaining", String(decision.remaining));
if (decision.allowed) return next();
res.setHeader("Retry-After", String(Math.ceil(decision.retryAfterMs / 1000)));
res.status(429).json({
jsonrpc: "2.0",
error: { code: -32000, message: "Too many requests. Retry after the delay in Retry-After." },
id: null,
});
}
// app.set("trust proxy", 1);
// app.post("/mcp", limitHttp, handleMcp);
Hachez le jeton bearer avant de l’utiliser comme identifiant, afin que les identifiants bruts ne se retrouvent jamais dans une Map ni dans une ligne de journal. Envoyez Retry-After en secondes entières, car les clients HTTP dotés d’une logique de relance le lisent. Et derrière un proxy, définissez trust proxy, sinon chaque appelant anonyme partage l’adresse du proxy.
Pondérer les outils selon leur coût
Entrez dans la cuisine d’un restaurant animé et vous verrez des tickets de commande de tailles très différentes accrochés à la même barre. Une salade d’accompagnement et un braisé mijoté ne demandent pas le même effort, et une bonne cuisine ne les traite pas de la même façon. Les appels d’outils fonctionnent de la même manière.
Type d’outil
Exemple
Coût en tokens
Garde-fou supplémentaire
Consultation en lecture seule
list_articles, get_article
1
Aucun
Écriture ou publication
save_article
2
Contrôle d’idempotence
Génération de texte
Résumés produits par un grand modèle de langage
3
Plafonner la longueur de sortie
Génération d’images
generate_image
5
2 simultanées par appelant
Génération de vidéos
generate_image_to_video
20
1 simultanée, soumissions espacées
Avec un seau de 60 jetons rechargé à raison d’un par seconde, un appelant peut lancer 60 consultations en rafale, ou 12 générations d’images, ou 3 tâches vidéo, et le budget se recharge entièrement en une minute.
Poids de coût par outil
Gardez les poids à un seul endroit et transmettez-les au wrapper de la section précédente :
Commencez avec des poids proportionnels à ce que chaque appel coûte en argent ou en secondes amont, puis ajustez-les à partir du trafic réel.
Plafonner les tâches et temporiser côté amont
Un budget de jetons limite la fréquence à laquelle un appelant lance un travail. Il ne limite pas la quantité de travail exécuté au même moment. Les outils longs ont besoin d’un plafond de concurrence, et les files d’attente amont partagées nécessitent parfois un espacement entre les soumissions. Les deux sont courts :
const inFlight = new Map<string, number>();
export async function withConcurrency<T>(
caller: string,
max: number,
job: () => Promise<T>,
): Promise<T | "busy"> {
const current = inFlight.get(caller) ?? 0;
if (current >= max) return "busy";
inFlight.set(caller, current + 1);
try {
return await job();
} finally {
const left = (inFlight.get(caller) ?? 1) - 1;
if (left <= 0) inFlight.delete(caller);
else inFlight.set(caller, left);
}
}
// One submission per slot: concurrent callers queue behind each other.
let nextSlot = 0;
export async function waitForSlot(minGapMs = 30_000) {
const now = Date.now();
const start = Math.max(now, nextSlot);
nextSlot = start + minGapMs;
await new Promise((resolve) => setTimeout(resolve, start - now));
}
// When the upstream API answers 429 or 5xx, wait and retry with jitter.
export async function fetchWithBackoff(
send: () => Promise<Response>,
maxAttempts = 4,
): Promise<Response> {
for (let attempt = 1; ; attempt++) {
const response = await send();
const retryable = response.status === 429 || response.status >= 500;
if (!retryable || attempt >= maxAttempts) return response;
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, delayMs + Math.random() * 250));
}
}
Renvoyez "busy" à l’agent comme erreur d’outil normale, indiquant qu’une tâche est déjà en cours et suggérant d’interroger son statut. La gigue dans fetchWithBackoff compte : sans elle, chaque instance qui a reçu un 429 se réveillerait à la même seconde et frapperait à nouveau l’amont tous ensemble.
💡 Des limites réelles, publiées. L’API de PicassoIA documente 5 prédictions simultanées par compte, partagées entre les jetons d’API et les connexions MCP, ainsi que des prompts de 4 000 caractères, des corps de requête de 10 Mo et un délai d’expiration de 3 heures. Ses outils d’image et de vidéo renvoient aussi un predict_id et une indication next_poll_in_seconds, si bien que le client n’a jamais à deviner à quelle fréquence interroger. Reprenez cette idée : une limite assortie d’un chiffre publié et d’une indication d’interrogation est une limite que les agents peuvent respecter.
Passer au-delà d’un seul processus
Le limiteur en mémoire a un défaut : sa mémoire appartient à un seul processus. Faites tourner trois instances derrière un répartiteur de charge et chacune conserve ses propres compteurs, si bien qu’un appelant obtient en pratique trois fois la limite. Les plateformes serverless sont pires, car chaque démarrage à froid commence avec des seaux vides. La solution consiste à déplacer les compteurs dans un stockage partagé, et Redis est le choix habituel parce que ses opérations sont atomiques et rapides.
Compteurs partagés avec Redis
import Redis from "ioredis";
import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
import type { Decision } from "./rate-limit.js";
const redis = new Redis(process.env.REDIS_URL!);
const FAIL_OPEN = process.env.RATE_LIMIT_FAIL_OPEN === "true";
const limiter = new RateLimiterRedis({
storeClient: redis,
points: 60, // budget per window
duration: 60, // window length in seconds
});
export async function takeShared(caller: string, cost: number): Promise<Decision> {
try {
const res = await limiter.consume(caller, cost);
return { allowed: true, remaining: res.remainingPoints, retryAfterMs: 0 };
} catch (rejection) {
if (rejection instanceof RateLimiterRes) {
return { allowed: false, remaining: 0, retryAfterMs: rejection.msBeforeNext };
}
// Redis itself failed, so apply the configured failure policy.
return { allowed: FAIL_OPEN, remaining: 0, retryAfterMs: 5_000 };
}
}
Cette bibliothèque compte en fenêtres fixes, donc la rafale de bord du tableau des algorithmes s’applique. Pour la plupart des serveurs MCP, ce compromis est acceptable. Si vous avez besoin d’un vrai token bucket réparti entre instances, stockez les deux nombres dans un hash Redis et exécutez la recharge et la prise de jetons dans un seul script Lua, car une lecture suivie d’une écriture depuis deux instances constitue une condition de course.
Bloquer ou laisser passer en cas de panne
Redis finira par tomber en panne, et votre limiteur doit choisir son camp :
Bloquer en cas de panne pour les outils qui coûtent de l’argent. Une courte panne coûte moins cher qu’une file vidéo sans limite.
Laisser passer en cas de panne pour les lectures peu coûteuses, où bloquer tout le monde ferait plus de mal qu’une rafale de consultations.
Dégrader, ne pas désactiver. La bibliothèque prend en charge un insuranceLimiter en mémoire comme solution de repli. Les limites s’appliquent alors par instance, ce qui reste bien mieux que rien.
Quel que soit votre choix, journalisez chaque panne du limiteur de façon bien visible, car un laisser-passer silencieux est la façon dont une limite cesse discrètement d’exister.
Tester et surveiller les limites
Un limiteur qui n’a jamais rejeté quoi que ce soit dans un test est un limiteur auquel vous ne pouvez pas faire confiance.
Tester avec des faux timers
Les faux timers permettent à un test unitaire de parcourir une minute entière en une seule milliseconde :
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TokenBucket } from "./rate-limit";
describe("TokenBucket", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("allows a burst, then blocks with a wait time", () => {
const bucket = new TokenBucket(3, 1);
for (let i = 0; i < 3; i++) expect(bucket.take("a").allowed).toBe(true);
const blocked = bucket.take("a");
expect(blocked.allowed).toBe(false);
expect(blocked.retryAfterMs).toBeGreaterThan(0);
});
it("refills as time passes", () => {
const bucket = new TokenBucket(1, 1);
bucket.take("a");
expect(bucket.take("a").allowed).toBe(false);
vi.advanceTimersByTime(1000);
expect(bucket.take("a").allowed).toBe(true);
});
it("keeps callers apart", () => {
const bucket = new TokenBucket(1, 1);
bucket.take("a");
expect(bucket.take("b").allowed).toBe(true);
});
});
Après les tests unitaires, lancez le vrai serveur dans le MCP Inspector (npx @modelcontextprotocol/inspector) et appelez un outil en boucle. Les premiers appels devraient réussir et les suivants devraient renvoyer le message de limite de débit avec un temps d’attente. Pour tester la couche HTTP, envoyez une rafale avec curl et comptez les codes de statut :
for i in $(seq 1 150); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
done | sort | uniq -c
Les premières réponses dépendent de la configuration de votre session, mais une fois le seau vide, chaque réponse doit être un 429.
Métriques à surveiller
Comptez chaque rejet avec le nom de l’outil et un seau d’appelant (jamais l’identité brute). Ces quelques chiffres montrent si les limites sont équitables :
Métrique
Ce qu’elle indique
Alerte lorsque
Taux de rejet par outil
Limites trop serrées, ou un appelant bruyant
Au-dessus de 5 % pendant 10 minutes
Principaux appelants par rejets
Un agent en boucle ou abusif
Un appelant en totalise plus de la moitié
Retry-After, 95e percentile
Le temps réellement attendu par les agents
Au-dessus de 60 secondes
Tâches en cours
Saturation de la concurrence
Au plafond pendant 5 minutes
Erreurs du limiteur
Santé de Redis
Toute erreur
Erreurs fréquentes que l’on retrouve dans les vrais serveurs :
Utiliser l’identifiant de session comme seule identité, si bien qu’une reconnexion réinitialise la limite.
Partager un seul seau global, si bien qu’un appelant gourmand prive tout le monde.
Abandonner ou bloquer silencieusement des requêtes au lieu de renvoyer une erreur avec un temps d’attente.
Fixer les limites une fois pour toutes et ne plus jamais relire les chiffres de rejet.
Essayer sur PicassoIA
Le limiteur ci-dessus tient en moins d’une centaine de lignes, ce qui en fait une bonne tâche pour un grand modèle de langage : bien spécifiée, facile à tester et rapide à relire. PicassoIA réunit plusieurs modèles capables de coder au même endroit, afin que vous puissiez rédiger, comparer et corriger sans jongler avec plusieurs comptes.
Comment utiliser Claude Sonnet 5
Ouvrez le modèle. Rendez-vous sur Claude Sonnet 5 sur PicassoIA.
Collez un prompt précis. Nommez le SDK, le transport, l’algorithme, le budget, le coût de chaque outil et le texte exact de l’erreur. Par exemple :
Write a TypeScript token bucket limiter for an MCP server built on
@modelcontextprotocol/sdk. Budget: 60 tokens, refill 1 per second.
Costs: list_articles 1, generate_image 5, generate_image_to_video 20.
Identify callers by authInfo.clientId, fall back to "anonymous".
When blocked, return isError: true with the wait time in seconds.
Include vitest tests that use fake timers.
Demandez d’abord des tests. La lecture des tests montre quel comportement le modèle a supposé avant que vous ne lisiez une seule ligne d’implémentation.
Exécutez-les et renvoyez les échecs. Collez le message d’erreur exact dans la même conversation et demandez une correction.
Demandez une relecture. Terminez par « Relisez ce limiteur à la recherche de conditions de course et de croissance mémoire » et lisez la réponse d’un œil critique.
Limitez chaque requête à une seule préoccupation, et donnez au modèle vos vrais chiffres plutôt que des « valeurs par défaut raisonnables ». D’autres modèles valent un second avis :
Une fois votre serveur protégé, mettez-le au travail. Chaque photographie de cet article est partie d’un simple prompt textuel écrit pour P-Image, et vous pouvez lancer ce même prompt sur Flux 2 Pro pour comparer les résultats. Ouvrez Picasso IA, décrivez une scène en une ou deux phrases et générez votre première image. Changez l’objectif, la lumière ou l’angle, relancez, puis animez votre résultat préféré en une courte vidéo. Le moyen le plus rapide de découvrir ce que la plateforme peut faire est d’expérimenter avec vos propres idées.