Décisions fondatricesADR-0014 · Vidéo asynchrone

ADR-0014 — La vidéo est un job asynchrone

La vidéo et le lipsync sont des jobs asynchrones fermés par webhook, lecture ou balayage.

Statut

Proposé · 2026-09-27

Remplace ou amende : Rien. Remplace l'attente synchrone du clip, portée de la plateforme.

Origine : ADR 0004 du dépôt applicatif Orbit, renumérotée 0014 à l’intégration dans cette série (une seule série d’ADR vit désormais ici).

Statut dans Orbit (2026-10) : en vigueur. Décision reprise de l'ancien dépôt Studio. L'origine publique du webhook vidéo est ORBIT_PUBLIC_URL (https://orbit.easyconnector.app en production) ; l'ancien nom STUDIO_PUBLIC_URL n'est plus relu qu'en alias de lecture tant que le nouveau est vide (RENAMED_ENV). « La plateforme » est l'ancienne application dont le code était porté.

Répond aux constats G2-06, G1-17 et G2-08 de l'audit de la phase 3. Sources, lues le 2026-09-27 : la page vidéo de l'AI Gateway (vercel.com/docs/ai-gateway/getting-started/video et …/modalities/video-generation : doStart, doStatus, webhook signé x-ai-gateway-signature) ; les types installés de ai 7.0.116 (experimental_startVideo, experimental_getVideoStatus) et de @ai-sdk/gateway 4.0.94 (GatewayAsyncJobMetadata, route /video-model/start et /video-model/status, callbackUrl).

Contexte

La plateforme, et le studio jusqu'ici, attendaient le clip Veo (et le lipsync) dans la requête : experimental_generateVideo ouvre un flux SSE et ne rend la main qu'avec la vidéo. Trois défauts en découlaient.

  1. Un clip en retard était perdu (G1-17). Veo met souvent une à trois minutes ; passé la limite de la fonction (300 s), la fonction mourait, le balayage remboursait la réserve comme un appel orphelin, et le clip que le fournisseur rendait quand même, et facturait, n'arrivait nulle part.
  2. La requête tenait une fonction pendant toute la génération (G2-06) : 270 s de budget, un emplacement de la capacité du membre, une page qui attend, pour un travail qui se fait ailleurs.
  3. Aucune limite de fonction n'était déclarée (G2-08) : seul le cron exportait maxDuration, et rien ne tenait les budgets des fournisseurs sous cette limite.

La Gateway offre maintenant un chemin asynchrone : doStart rend une operation opaque (et, avec webhookUrl, un id de job et un secret de signature propre au job), doStatus lit cette opération depuis n'importe quel processus, et un webhook signé annonce la fin. L'AI SDK 7 l'expose à un niveau plus haut que le modèle : experimental_startVideo (qui accepte une chaîne de modèle routée par la Gateway, les en-têtes, maxRetries, abortSignal et webhookUrl) et experimental_getVideoStatus.

Décision

Une vidéo ou un lipsync est un job, jamais un appel attendu.

  • Départ (startVideoJob, packages/engine/src/generation/video-job.ts) : ligne QUEUED, réserve à l'estimation, ligne SUBMITTED avec sa réserve avant l'appel, puis experimental_startVideo avec la clé d'idempotence de la génération (un départ réessayé est un seul job), l'utilisateur et les étiquettes de la Gateway, et le webhook quand le studio a une URL publique HTTPS. L'id du job (unique), l'opération et le secret sont écrits sur la ligne ; la requête répond running aussitôt.
  • Un seul fermeur (settleVideoJob), atteint par trois chemins, tous par claimInFlight : le webhook POST /api/webhooks/video/[generationId], une lecture GET /api/generations/[id] quand la ligne tourne depuis plus de 15 s, et le balayage après 5 minutes. Il lit le statut avant de prendre la ligne ; terminé, il télécharge le clip (borné en octets et en temps), le mesure, le range sous le dossier de la ligne, crée l'asset et règle ; en échec, il rembourse. Un téléchargement ou une écriture ratés rendent la ligne au suivant, sans argent.
  • Le webhook ne fait pas foi par son contenu. La signature (t, v1 = HMAC-SHA256 de <t>.<corps brut> sous le secret du job, temps constant, 5 minutes) ouvre la porte, puis le statut est redemandé à la Gateway. Sans ORBIT_PUBLIC_URL, aucun webhook n'est enregistré et la lecture ferme le job.
  • Abandon à la vie d'une réserve : un job toujours en cours après 2 h (VIDEO_GIVE_UP_AFTER_MS, DEFAULT_HOLD_TTL_MS) est FAILED video_timeout et remboursé.
  • Une limite de fonction, STUDIO_FUNCTION_MAX_DURATION_S = 300, exportée par chaque route qui génère ou ferme, et par la page qui liera les actions (max-duration.test.ts).

Pourquoi experimental_startVideo plutôt que gateway.videoModel(id).doStart que montre la documentation de la Gateway : c'est la même requête (/video-model/start, callbackUrl), mais par la fonction publique du SDK, avec la chaîne de modèle, les tentatives et l'annulation que le reste du studio utilise déjà (ADR 0012), et sans instance de fournisseur. Pourquoi pas experimental_generateVideo avec webhook ou poll : les deux attendent dans le processus appelant, ce qui est exactement ce qu'on retire.

Conséquences

  • La réponse d'une vidéo n'a plus d'URL : le cockpit lit GET /api/generations/<id> (le contrat Contrat serveur du cockpit dit à quel rythme).
  • Trois colonnes sur StudioGeneration (migration video_async_job) : providerJobId (unique), providerOperation, providerWebhookSecret (effacé dès que la ligne se ferme).
  • Une nouvelle porte machine publique au proxy, pour la seule forme ^/api/webhooks/video/g[0-9a-f]{24}$.
  • Le balayage lit la Gateway : il reçoit les fournisseurs et le stockage des générations (SweepDeps.video).
  • Le fondateur doit poser ORBIT_PUBLIC_URL (HTTPS) en production pour que les vidéos se ferment par webhook ; sans lui, elles se ferment par lecture, plus lentement mais sans perte.
  • Seedance et Kling (WP7) passeront par le même job : c'est la fondation.

Vérification

  • packages/engine/src/generation/video-job.integration.test.ts (Postgres) : départ running avec ligne SUBMITTED, un seul appel et la réserve à l'estimation ; webhook signé qui ferme sur les millisecondes mesurées ; webhook perdu fermé par le balayage ; clip en retard récupéré ; job en échec remboursé ; deux lectures concurrentes, un seul règlement ; abandon video_timeout ; téléchargement raté puis repris ; règlement en attente réglé par le balayage ; lipsync par le même chemin.
  • packages/engine/src/generation/video-job.test.ts : signature (falsifiée, périmée, malformée, rotation), URL du webhook, téléchargement borné.
  • packages/engine/src/generation/providers.transport.test.ts : sur le fil, /video-model/start avec callbackUrl, réessayé sous la même clé, puis /video-model/status.
  • apps/web/src/app/api/webhooks/video/[generationId]/route.test.ts : une signature falsifiée ou périmée répond 401 et ne change rien.
  • apps/web/src/test/max-duration.test.ts, apps/web/e2e/phase3.spec.ts (E2E-08 par HTTP : running, puis done par la route de statut).