Runbooks OrbitRunbook — Générations et rendus

Runbook — Générations et rendus


title: Runbook — Générations et rendus description: Exploitation du Studio : variables, diagnostic d’un rendu, balayage, rétention, télémétrie et coûts.

Pour qui : le fondateur, et quiconque met le studio en ligne ou lit un rendu en échec. Conception : Architecture de l’application, section 7.

Statut (migration Orbit, 2026-10) : runbook de Creative Studio dans Orbit (https://orbit.easyconnector.app/studio), repris de l'ancien dépôt Studio. « Vercel (Orbit) » désigne le projet Vercel d'Orbit. « La plateforme » désigne l'ancienne application externe dont une partie du code est issue : ce n'est ni le parent d'Orbit, ni une dépendance, ni une intégration. Les anciens ponts vers elle et leurs variables sont retirés ; une comparaison « comme sur la plateforme » est de la provenance.

Ordre de diagnostic

Avant d'ouvrir une section précise ci-dessous, le premier tri :

Génération refusée avant provider

Vérifier scope, validation, permission, solde et plafonds. Aucun appel provider ne doit avoir eu lieu.

Job async bloqué

Vérifier l'état journalisé, l'identifiant de job, puis l'arrivée du webhook ou la capacité du polling/sweep à le fermer.

Provider terminé mais débit en attente

Ne pas transformer le résultat livré en échec. Le sweep doit pouvoir réconcilier le settlement depuis le coût mesuré conservé.

Render sans callback

Distinguer un runner jamais démarré, un quota CI, un callback perdu et un artefact déjà publié.

Rétention

Un fichier supprimé par politique ne doit pas faire disparaître l'historique financier ou la ligne de génération.

Les secrets, endpoints d'exploitation et valeurs d'environnement restent dans les systèmes de secrets et runbooks protégés. Ne jamais les recopier dans une page publique.

Bascule des anciens noms STUDIO_* vers ORBIT_*

Quatre variables ont été renommées avec le passage à Orbit (https://orbit.easyconnector.app) : STUDIO_PUBLIC_URL → ORBIT_PUBLIC_URL, STUDIO_RENDER_DISPATCH_TOKEN → ORBIT_RENDER_DISPATCH_TOKEN, STUDIO_SPEND_ALERT_USD → ORBIT_SPEND_ALERT_USD, STUDIO_PROVIDERS → ORBIT_PROVIDERS. Le code lit le nom ORBIT_*, et relit encore l'ancien nom en lecture seule tant que le nouveau est absent ou vide (RENAMED_ENV, apps/web/src/env/schema.ts) ; toute nouvelle configuration utilise ORBIT_*. Côté Vercel, pour chaque variable, poser le nouveau nom, redéployer, vérifier, puis supprimer l'ancien. Pour l'origine publique, dans cet ordre :

  1. Ajouter ORBIT_PUBLIC_URL=https://orbit.easyconnector.app (Production, et Preview si le webhook vidéo y est voulu).
  2. Mettre NEXTAUTH_URL sur https://orbit.easyconnector.app et le secret GitHub CREATIVE_RENDER_CALLBACK_URL sur https://orbit.easyconnector.app/api/webhooks/render.
  3. Redéployer, vérifier une génération vidéo (params.webhook à true).
  4. Supprimer STUDIO_PUBLIC_URL, redéployer.

Ce qu'il faut poser

OùVariable ou secretRôle
Vercel (Orbit)ORBIT_PROVIDERSauto ou live en production (jamais fixtures, refusé en production)
Vercel (Orbit)AI_GATEWAY_API_KEYImages, vidéos, lipsync, pictos. Sans elle, le studio s'authentifie par OIDC sur son projet Vercel ; une clé budgétée (vercel ai-gateway api-keys create --budget ... --refresh-period monthly) est la façon de donner au studio son propre plafond, si son projet partage une équipe Vercel avec d'autres projets (décision du fondateur)
Vercel (Orbit)ORBIT_PUBLIC_URLL'origine publique du studio (https://orbit.easyconnector.app), envoyée à la Gateway comme attribution (http-referer, avec x-title: Orbit Creative Studio). Sans elle, l'origine de NEXTAUTH_URL. En HTTPS, c'est aussi l'adresse du webhook vidéo : chaque job vidéo enregistre https://orbit.easyconnector.app/api/webhooks/video/<génération> ; sans elle (ou en HTTP), aucun webhook, et une vidéo se ferme par lecture (plus lentement, sans perte)
Vercel (Orbit)ELEVENLABS_API_KEY (commence par sk_), ELEVENLABS_VOICE_IDVoix, et le bureau de la voix (bibliothèque et quota du compte)
Vercel (Orbit) et secrets GitHub du dépôt de renduBLOB_READ_WRITE_TOKENLe Blob du studio. Son hôte (<store>.public.blob.vercel-storage.com) est dérivé du jeton : c'est le seul hôte Blob que le studio accepte d'un runner, lit comme référence et autorise dans sa CSP
Local, E2EBLOB_STORE_IDNomme le store sans jeton (le jeton gagne s'il est posé) : pour qu'un rappel de runner soit accepté sur un poste qui écrit dans .blob/. La suite E2E pose e2e123
Vercel (Orbit)ORBIT_RENDER_DISPATCH_TOKENactions:write sur CREATIVE_RENDER_REPO. Ancien nom relu : STUDIO_RENDER_DISPATCH_TOKEN
Vercel (Orbit)CREATIVE_RENDER_REPOLe dépôt dont le workflow render.yml est déclenché, owner/name : BoostEcom/orbit.easyconnector.app. Sans défaut dans Orbit : vide, un rendu répond not_configured
Vercel (Orbit) et secrets GitHubCREATIVE_RENDER_CALLBACK_SECRETLa signature du rappel, la même valeur des deux côtés
Secrets GitHubCREATIVE_RENDER_CALLBACK_URLhttps://orbit.easyconnector.app/api/webhooks/render
Vercel (Orbit)CRON_SECRETLa porte des deux crons, generations-sweep et retention (Vercel l'envoie en Authorization: Bearer). Sans elle, les deux refusent tout, et la rétention ne tourne pas
(aucun)PRODUCT_REPOS_READ_TOKEN, PLATFORM_*, CAPTURE_BRIDGE_SECRET, CINEMA_EXCHANGE_ORIGINRetirées du schéma dans Orbit (anciens ponts et garde croisée design-drift.yml, non portés). À ne pas poser ; à supprimer de Vercel si elles y traînent

Aucune de ces valeurs n'est partagée avec une autre application (ni l'ancienne plateforme, ni easyconnector.app, produit distinct).

Lire un rendu

GET /api/generations (le journal, filtré) et GET /api/generations/<id> (une ligne) rendent un status, son libellé et, pour un échec, la phrase à lire (message) :

StatutVeut direDébit
runningLe runner travaille (ou n'a pas encore rappelé)Réserve ouverte
doneFichiers publiés, ligne régléeMinutes réelles au forfait
failedLe run a échoué, n'a rien publié, ou a publié hors de son dossier ; pour une vidéo, le job a échoué (provider-failure), ignoré un réglage facturé (setting-dropped) ou dépassé deux heures (video_timeout)Remboursé
quotaBlackout du quota GitHub Actions : échec en quelques secondes sans une étape. Ce n'est pas le codeRemboursé ; relancer quand le quota revient
no-callbackAucun rappel 100 minutes après le dispatch. Le fichier a peut-être été publié : regarder le Blob du produitRemboursé
cancelledRefusé avant tout appel (solde, plafond)Aucun
deletedFichiers supprimés par deleteRender, par la rétention à 30 jours ou par la purge d'un produit retiré ; ligne conservéeInchangé

Le quota Actions est celui du compte GitHub qui porte le dépôt de rendu, et peut être partagé avec ses autres dépôts : un blackout les bloque tous. Ne pas relancer en boucle, il revient seul.

Une ligne encore en cours qui porte déjà un fichier est affichée done : le fichier est livré, seul son débit attend le balayage (voir plus bas). Un rendu lancé porte l'id de son run GitHub (params.workflowRunId) et sa page (params.runUrl, rendue aussi par renderTemplate en runUrl).

Un rendu dont un champ enfreint une règle de copie (tiret long, emoji, construction bannie, gabarit non rempli, et, pour un produit dont la Knowledge du Brain déclare des faits, un chiffre sans id de fait) est refusé avant toute ligne, réserve ou appel à GitHub : invalid, detail: "copy", et problems nomme chaque champ avec la phrase de sa règle. Pour un chiffre, écrire l'id du fait (F22, T4) dans un champ du même rendu, par exemple source d'une stat-card. Les ids connus sont ceux de la Knowledge du produit, lus en base (packages/engine/src/render/facts.ts) : les modifier, c'est modifier le Brain du produit, pas un fichier du dépôt.

Une vidéo en cours

Une vidéo ou un lipsync est un job asynchrone (ADR 0014) : la demande répond running en une seconde, avec la réserve tenue (heldUsd) et l'adresse à lire (pollUrl, GET /api/generations/<id>). Sur la ligne :

ColonneVeut dire
providerJobIdL'id du job à la Gateway (vjob_…). Absent sur une ligne SUBMITTED de vidéo : le départ a réussi mais son inscription s'est perdue (audit generation.submit_pending)
providerOperationL'opération opaque que la Gateway relit ; ne pas la modifier
providerWebhookSecretLe secret de signature du job, présent seulement si un webhook a été enregistré ; effacé dès que la ligne se ferme. Ne jamais le copier dans un ticket ou un journal
params.webhooktrue si un webhook a été enregistré (ORBIT_PUBLIC_URL en HTTPS au départ)
params.startWarningsCe que le départ a signalé ; un réglage facturé ignoré fera échouer le job à la fermeture (setting-dropped), remboursé

Qui la ferme : le webhook de la Gateway en quelques minutes ; sinon une lecture de GET /api/generations/<id> (dès 15 s après le départ, une lecture par appel) ; sinon le cron après 5 minutes. Les trois passent par le même fermeur et un seul règle. Une vidéo encore running au bout d'une heure alors que ORBIT_PUBLIC_URL est posée : vérifier que le webhook répond (journaux [video-webhook] : rejected: signature veut dire un secret ou une horloge qui ne concordent pas), puis que le cron tourne.

Une vidéo toujours pas finie deux heures après son départ (la vie d'une réserve) est passée par le cron en FAILED video_timeout, remboursée, avec la phrase « La vidéo n'était pas terminée au bout de deux heures ». La Gateway borne ses jobs bien en deçà : un video_timeout veut dire un job perdu chez le fournisseur, ou un cron arrêté plus d'une heure. Si le fournisseur livre plus tard, le clip n'est plus récupéré ; comparer avec la facture de la Gateway par l'id du job.

Lire une vidéo par moteur (lot 7)

params.case dit le cas (veo, best-quality, cinematic, seedance-2, seedance-2-5, omni, nova, replace, motion), params.engine le moteur, modelId le modèle réellement appelé (4k sur un cas 2.5 tourne sur bytedance/seedance-2.0, params.seedance le dit). params.pricing est la base sur laquelle la fermeture règle : per-second (Veo, lipsync), seedance (palier, tarif « avec vidéo » ou non, inputMs : les millisecondes des clips d'entrée, facturées en plus de la sortie), source-clip (Kling : sourceMs, la durée du clip source, seule facturée). Une ligne Kling est de sorte MOTION, son asset VIDEO. references.roles dit quel fichier a servi à quoi (image de départ, de fin, personnage, clips avec leur durée mesurée) ; params.endFrameDropped signale une image de fin ignorée sous une liste de plans (comme sur la plateforme). Un lipsync porte params.voiceMs (la voix, à la milliseconde) et references.voiceGenerationId (la génération qui a produit la voix). Les refus avant la ligne ne laissent rien : invalid (detail : durationSeconds, frames-and-references, clip, character...), reference-refused (clip-too-long, clip-too-short, clip-unreadable, not-a-clip...), voice-length.

Le rapport du cron compte videoSettled (jobs fermés et réglés par le cron), videoFailed (jobs en échec, remboursés), videoTimeout et videoPending (jobs lus, toujours en cours). Un videoSettled élevé en production veut dire que les webhooks n'arrivent pas.

Débit en attente, lignes réconciliées

Le fournisseur répond, le fichier est stocké, puis la base tombe au moment d'écrire le débit : la génération n'est pas un échec. Le membre reçoit son fichier avec chargePending: true et la phrase fr.generation.chargePending ; la ligne reste SUBMITTED, avec son artefact et params.pendingCostMicros (le coût mesuré, en micro-dollars). Le cron generations-sweep la règle à ce coût au premier passage après 15 minutes, et la passe en COMPLETED.

De même, si le débit est écrit mais que la clôture de la ligne se perd, le balayage passe la ligne en COMPLETED au montant de la ligne SETTLE du ledger : il ne rembourse jamais une réserve déjà réglée.

Le rapport JSON du cron compte ces cas : settled (débit en attente réglé), reconciled (réserve déjà réglée, ligne rattrapée), errors (ligne qui n'a pas pu être fermée : sa prise est rendue et elle est reprise au passage suivant). Un errors qui ne redescend pas veut dire une base qui refuse les écritures : regarder les journaux de la fonction ([generations-sweep]).

Références et téléversements

Conception : overview §7.5. Ce qu'il faut savoir pour exploiter :

LimiteValeurOù
Références par image6 (MAX_REFERENCE_IMAGES) ; 1 pour une image de départreferences.ts
Taille d'une référence lue8 Mo, comptés sur le fluxMAX_REFERENCE_BYTES
Temps pour lire une référence20 s, redirections et corps comprisPROVIDER_BUDGET_MS.referenceFetch
Redirections suivies3, chacune revérifiéeMAX_REDIRECTS (safe-fetch.ts)
Image téléversée (action serveur)4 Mo ; PNG, JPEG, WebP, GIF par ses octetsMAX_IMAGE_UPLOAD_BYTES, next.config.ts (bodySizeLimit 4,5 Mo)
Rush ou piste (téléversement direct)50 Mo ; mp4, mov, webm, mp3, wav, m4a ; jeton valable 15 minutesMAX_CLIP_UPLOAD_BYTES, UPLOAD_TOKEN_TTL_MS

La liste des hôtes d'une référence par URL : le store du studio (sous le dossier du produit ou de libre/ seulement), cdn.shopify.com, et les boutiques déclarées d'un produit. Ajouter une boutique, c'est une ligne au registre, revue en PR, jamais une variable :

"references": { "shopDomains": ["orbit-demo.myshopify.com"] }

(hôtes exacts en minuscules, dix au plus, ni joker, ni cdn.shopify.com, ni un hôte Blob ; products/src/index.ts les refuse). Chaque ajout élargit ce que le studio va chercher pour un membre : c'est une décision, pas un réglage.

Un refus reference-refused ne débite rien et ne laisse aucune ligne de journal. Le motif est dans detail, la référence en cause dans subject, la phrase à lire dans message. host-not-allowed sur une URL Blob veut dire l'objet d'un autre store (une ligne importée de l'ancienne application et non copiée, par exemple) : le copier dans le store du studio (carte, Q10), jamais élargir la liste.

Le rappel de fin de téléversement en local. Le store rappelle <ORBIT_PUBLIC_URL>/api/references/upload quand ORBIT_PUBLIC_URL est en HTTPS ; sur Vercel sans elle, le SDK déduit l'adresse du déploiement. Un poste de développement n'a pas d'adresse publique : le rappel ne vient pas, et le cockpit appelle confirmReferenceUpload({ productId, url }) après l'envoi. Les deux peuvent arriver : l'enregistrement est idempotent sur le chemin. Pour exercer le vrai rappel en local, exposer le poste par un tunnel HTTPS et poser ORBIT_PUBLIC_URL sur son origine. Un rappel non signé ou mal signé répond 401 et n'enregistre rien.

Moteurs d'image : cas, couches, composition

Conception : overview §7.1. Ce qu'il faut savoir pour exploiter :

  • Ce qu'une image a reçu se lit sur sa ligne : params.prompt est le prompt empilé tel qu'envoyé (système du cas, skills, contexte du produit, brief composé), params.brief ce que le membre a écrit, params.case, params.skills, et params.ignoredLayers ce que le cockpit a envoyé et que le serveur a ignoré (un system libre, des règles, des connecteurs, un skill inconnu). Un ignoredLayers.system qui revient souvent veut dire que l'adaptateur envoie encore la ligne system du cockpit d'origine : sans effet, mais à retirer.

  • Le ton et les promesses d'un produit s'écrivent au registre, revus en PR, jamais dans une variable. Sans ce bloc, une image ne porte aucune promesse que l'instruction ne donne pas mot pour mot :

    "brand": { "tone": "Sec, précis, sans superlatif", "promises": ["Sans engagement", "Annulable en un clic"] }
    

    (un ton de 300 caractères au plus, douze promesses de 200 au plus ; products/src/index.ts les refuse sinon). C'est une décision du propriétaire : aucune n'est écrite aujourd'hui.

  • GPT Image « précis » : params.costSource dit sur quoi l'image a été réglée (gateway, usage pour les jetons de sortie rapportés, formula sinon) et params.outputTokens ce que la réponse a rapporté. La suite réelle (live.test.ts) a un cas « précis » à 1K low pour confirmer que la Gateway transmet la taille et la qualité et rend l'usage.

  • Une composition refusée composition-unavailable veut dire que sharp (et sa bibliothèque native libvips) ne charge pas sur ce déploiement : le détail est dans detail. Rien n'est débité et le reste du studio tourne ; vérifier que le paquet sharp de la bonne plateforme est installé dans la fonction.

  • Une composition composition-failed : le masque rendu n'isolait pas le produit (couverture hors de 0,1 % à 95 %) ou la vérification des pixels a échoué ; réserve remboursée, perte auditée. Une source avec un vrai fond transparent évite le masque (une seule image, et aucun masque à rater).

Rétention (30 jours)

Chaque nuit à 03:40 UTC, GET /api/cron/retention supprime les fichiers que personne n'a gardés. Conception : overview 7.6.

  • Ce qui part : un fichier encore GENERATED (jamais passé en QC) ou REJECTED, créé il y a plus de 30 jours ; un téléversement que ni un membre ni une génération n'a touché depuis 30 jours ; et tout objet du store déposé il y a plus de 30 jours qu'aucune ligne ne nomme (la passe des orphelins, voir plus bas).
  • Ce qui reste, toujours : un fichier approuvé ou livré ; un fichier retenu par un avatar, une leçon ou un concept ; un fichier dont une variante, un ascendant ou un descendant est approuvé ou livré ; ce qu'une génération en cours utilise ; une ligne importée de l'ancienne application (ses octets sont sur un store qu'Orbit ne gère pas) ; et tous les fichiers d'un rendu dont un est approuvé.
  • Ce qui ne bouge jamais : la ligne du journal, son coût, ses paramètres et ses lignes de ledger. Elle se lit deleted (« Fichier supprimé »).
  • Les orphelins : un objet écrit sans ligne ne serait jamais atteint par une passe qui part des lignes. C'est le cas d'un rendu dont le rappel est refusé ou échoue après publication (sans store configuré sur le webhook), d'un clip stocké par un fermeur mort avant son asset puis abandonné en video_timeout, d'un téléversement du navigateur jamais confirmé ou refusé, d'un appel synchrone tué entre l'écriture Blob et son asset, d'une suppression dont le store a échoué. La passe liste donc le store (libre/, products/), ne regarde que les dossiers de génération et references/ (jamais avatars/), et supprime un objet déposé il y a plus de 30 jours qu'aucune ligne ne nomme : ni asset, ni téléversement, ni avatar (chemin, URL, photo source), ni rush de capture, ni artefact de génération, ni génération en cours (son dossier entier est gardé). Audit generation.files_expired, by: "orphan-scan".
  • Un rendu fermé FAILED n'attend pas 30 jours : le rappel efface tout de suite ce que le runner avait écrit dans le dossier de sa ligne, et rien hors de ce dossier (generation.files_discarded).

Pour garder un fichier au-delà de 30 jours : l'approuver en QC, le rattacher à un concept, ou en faire un avatar. Le rapport du cron (JSON) compte assets.expired, assets.kept par motif (recent, status, avatar, learning, concept, lineage, in-flight, sibling, foreign-store, changed), uploads, files.removed, files.failed, orphans (scanned, expired, failed) et complete : complete: false veut dire que la passe a épuisé son temps (quatre minutes), la nuit suivante reprend. La relancer à la main :

curl -sS -H "Authorization: Bearer $CRON_SECRET" https://orbit.easyconnector.app/api/cron/retention

Sans BLOB_READ_WRITE_TOKEN, la passe répond {"skipped":"no-blob-store"} et ne touche à rien.

Purger un produit retiré

Quand un produit sort de products/products.json (ou n'y est plus registered), ses fichiers restent sur le store. Le fondateur les supprime par l'action purgeProductBlobs(productId) (permission storage.purge, lui seul) :

  1. retirer le produit du registre et déployer : la purge refuse un produit encore registered (product-registered), et libre (invalid) ;
  2. lancer la purge une fois : les générations du produit gardent leur ligne et leur coût, ses assets, avatars et téléversements sont supprimés, puis tout ce qui est sous products/<id>/ sur le store ; rien d'autre ;
  3. lire l'audit product.blobs_purged. S'il dit complete: false, relancer : la deuxième purge liste ce qui reste et finit.

C'est irréversible : aucun fichier ne revient, le journal seul reste.

Où sont les traces

Chaque requête du studio est une trace (lot 10, G2-20 ; conception : overview §8, « Traces »).

  • En production : l'onglet Observability du projet Vercel du studio (traces des fonctions), ou le drain de traces s'il en est configuré un. Rien à poser : @vercel/otel exporte vers le collecteur de Vercel. La première vérification sur un vrai déploiement est une action du fondateur : lancer une image, puis retrouver sa trace.
  • En local : pnpm otel:local (un récepteur OTLP/HTTP JSON, sur 127.0.0.1:4318 seulement, qui imprime chaque span et les ajoute à .otel/spans.jsonl), puis lancer le studio avec OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 et OTEL_EXPORTER_OTLP_PROTOCOL=http/json. Sans ces deux variables, rien n'est exporté. Elles ne sont pas des clés du studio (@vercel/otel les lit lui-même) : ne pas les poser sur Vercel.

Ce qu'une génération dessine, sous le span de la route Next (POST /api/… ou l'action serveur) :

SpanAttributs à lire
studio.generationstudio.generation.id, studio.member.id, studio.product.id (libre sinon), studio.generation.kind, studio.outcome (delivered, running, replayed ou le code du refus)
studio.provider.image, .start_video, .video_status, .voice, .vector, .voice_category, .voices, .quotastudio.provider.mode (live ou fixtures), studio.capability, studio.model, studio.gateway.cost_reported (la Gateway a-t-elle dit son coût), studio.gateway.generation_id ; statut ERROR et l'exception si l'appel a échoué
invoke_agent <modèle>, step 1, chat <modèle> (AI SDK, portée gen_ai)Sous studio.provider.image (Nano Banana) et studio.provider.vector (picto) : gen_ai.agent.name = la capacité, ai.settings.context.generationId et .memberId, gen_ai.usage.input_tokens et output_tokens
studio.video.settlestudio.generation.id, studio.video.trigger (webhook, pull, sweep), studio.outcome (completed, pending, failed, retry…)
studio.render.launch, studio.render.callbackl'id, le gabarit, l'issue (dispatched, COMPLETED, already-settled, le refus)
studio.sweep, studio.retentionles comptes du rapport (studio.sweep.refunded, studio.sweep.errors, studio.retention.files.removed…)

Aucun prompt, aucune référence, aucun fichier, aucun secret n'est un attribut. Pour relier une ligne de journal à sa trace : chaque ligne JSON de src/lib/log.ts porte traceId et spanId.

Les événements du journal à surveiller : cron.sweep.done et cron.retention.done en warn (des erreurs ou des fichiers non effacés), cron.rejected (un appel de cron sans le bon CRON_SECRET : le secret de Vercel a-t-il changé ?), render.callback.rejected et video.webhook.rejected (signature refusée : le secret du dépôt de rendu, ou un appel forgé), auth.otp.failed.

Preuve locale (lot 10) : la suite E2E lancée avec le récepteur a exporté 1 243 spans, dont 9 studio.generation (images livrées, rejouées, refusées), leurs studio.provider.image en enfants, studio.render.callback, studio.sweep et studio.retention, et chaque ligne de journal portait son traceId.

Les audits à connaître

Action (StudioAuditLog)Veut direÀ faire
generation.settle_pendingFichier livré, débit pas encore écrit (erreur jointe)Rien si generation.reconciled suit dans l'heure ; sinon vérifier la base
generation.close_pendingDébit écrit, clôture de la ligne perdueRien : le balayage la réconcilie
generation.reconciledLe balayage a fermé une ligne à partir du ledger (from : settle-line, pending-cost ou estimate)estimate : le coût réel n'a pas pu être mesuré, comparer avec la facture du fournisseur
generation.submit_pendingRunner lancé, mais la ligne n'a pas pu passer SUBMITTED ; ou job vidéo démarré (providerJobId dans l'audit) dont l'inscription sur la ligne s'est perdueRendu : rien, le rappel la règle ; sans rappel, le balayage la ferme après 100 minutes. Vidéo : la ligne ne peut pas être relue, le balayage la rembourse en orphaned après 15 minutes ; le job sera facturé par la Gateway, compter la perte
generation.charge_missingRendu terminé sans aucune réserve trouvée : rien n'a été débitéAnormal : chercher pourquoi la réserve manque
generation.paid_not_deliveredFournisseur payé, fichier non stocké ou refusé (reason: "svg-unsafe", truncated: true pour un picto coupé au plafond de 2 000 jetons ; reason: "setting-dropped" quand le fournisseur a ignoré un réglage facturé, dropped en donne la liste ; reason: "composition-failed" quand une composition n'a pas pu garder les pixels du produit, reason: "step-failed" quand son masque a échoué après la scène payée) : non facturé au membreCompter la perte ; vérifier le Blob ; un picto tronqué qui revient souvent demande un prompt plus simple ; un setting-dropped qui revient veut dire qu'un modèle ne sait plus faire une durée, un ratio ou une résolution : corriger son réglage dans run.ts avant de relancer
avatar.saved, avatar.deletedUn membre a gardé ou oublié un visage (assetId en métadonnée). Oublier ne supprime aucun fichierRien : c'est la trace. Aucun débit
generation.files_delete_failedDes fichiers devaient partir (par deleteRender, par la rétention by: "retention" ou "orphan-scan", ou par le rappel d'un rendu échoué by: "render-callback"), mais le stockage a refusé d'effacer leurs octets : les pathnames de l'audit ne sont plus montrés par aucune ligneRien d'urgent : la passe des orphelins les reprend la nuit où ils ont plus de 30 jours. Pour les effacer tout de suite : vercel blob del <url> dans le store Blob du studio
generation.files_expiredLa rétention a supprimé les fichiers d'une génération (ou d'un asset sans génération) de plus de 30 jours que rien ne retenait : assetIds, pathnames. Avec by: "orphan-scan" : des objets de plus de 30 jours qu'aucune ligne ne nommait (folder, pathnames), un audit par dossier de génération ou de téléversementRien : c'est la trace. La ligne et son coût restent
generation.files_discardedUn rendu fermé FAILED (artefacts refusés, run en échec ou annulé, rien publié) : ce que le runner avait écrit dans le dossier de SA ligne est supprimé aussitôt (by: "render-callback", reason = le code d'échec, pathnames)Rien : c'est la trace. La réserve est remboursée, aucun asset n'a été créé
reference.expiredLa rétention a supprimé un téléversement que personne n'avait lu depuis 30 jours (pathname)Rien. À re-téléverser si on en a encore besoin
product.blobs_purgedLe fondateur a purgé les fichiers d'un produit retiré (prefix, removedObjects, removedBytes, clearedGenerations, deletedAssets, deletedAvatars, deletedReferences, clearedCaptures) ; complete: false et error si le store a échoué en routecomplete: false : relancer la purge, elle finit ce qui reste
reference.uploadedUn membre a téléversé une référence (via : server pour une image, client pour le rappel du store, confirm pour la confirmation du cockpit), avec son chemin, son type, sa taille et sa durée mesuréeRien : c'est la trace. Aucun débit
generation.cost_divergenceLa Gateway a facturé plus de 20 % au-dessus ou au-dessous de la formule du studio (gatewayCostUsd, formulaCostUsd, gatewayGenerationId). Le membre est débité du chiffre de la GatewayUn prix de prices.ts a vieilli, ou un modèle a changé de palier : corriger le prix après avoir lu la génération (gateway.getGenerationInfo)

Une génération refusée pour internal-error a été remboursée ; le détail technique est dans errorMessage de la ligne, jamais sur la page.

Une génération dont le fournisseur n'a pas répondu dans son budget (image 120 s, picto 60 s, voix 60 s, départ d'un job vidéo 60 s) finit FAILED provider-failure, remboursée dans la requête, avec la phrase « n'a pas répondu à temps » ; elle n'attend pas le balayage. Si cela revient pour un médium, c'est le fournisseur qui est lent, pas le studio : ne pas allonger le budget au-delà de la limite de la fonction (300 s).

Rapprocher le rapport de la Gateway et le ledger

Chaque appel payant de l'AI Gateway porte l'utilisateur (l'id du StudioMember) et trois étiquettes : studio, la capacité (generateImage, generateVideo, generateLipsync, generateVector) et le produit (ou libre). Le rapport d'usage de la Gateway (plans Pro et Enterprise) se lit donc dans les mêmes axes que le ledger :

# Par membre, sur le mois
curl "https://ai-gateway.vercel.sh/v1/report?start_date=2026-10-01&end_date=2026-10-31&group_by=user" \
  -H "Authorization: Bearer $AI_GATEWAY_API_KEY"
# Un membre, une capacité
curl "https://ai-gateway.vercel.sh/v1/report?start_date=2026-10-01&end_date=2026-10-31&user_id=<memberId>&tags=generateVideo" \
  -H "Authorization: Bearer $AI_GATEWAY_API_KEY"

La somme d'un membre doit égaler la somme de ses lignes SETTLE du mois, hors voix (ElevenLabs n'est pas sur la Gateway, ADR 0013 : se rapprocher de son tableau de bord), hors minutes de runner et hors GIF (aucun fournisseur). Pour une génération précise, StudioGeneration.gatewayGenerationId est l'id à passer à gateway.getGenerationInfo, et params.costSource dit si le débit vient de la Gateway (gateway) ou de la formule (formula). Un écart qui ne s'explique pas par ces exclusions est d'abord un generation.cost_divergence ou un generation.paid_not_delivered du mois.

Une voix refusée voice-unverified n'a laissé ni ligne ni réserve : ElevenLabs n'a pas dit d'où vient la voix (clé invalide, voix supprimée, panne, plus de 10 s). Vérifier ELEVENLABS_API_KEY et que la voix existe dans la bibliothèque du compte ; un membre qui a le consentement signé de la personne peut aussi le joindre, la voix passe alors comme une voix de personne.

Le bureau de la voix

GET /api/voice-desk (permission generate) est ce que le composer Voix lit avant la première touche. En lire la réponse suffit à diagnostiquer la voix :

ChampVeut direÀ faire
ready: false, missingLa voix ne peut pas être lue ici. Le fondateur voit la variable (ORBIT_PROVIDERS si les fournisseurs ne sont pas servis, ELEVENLABS_API_KEY si la clé ne commence pas par sk_, ELEVENLABS_VOICE_ID, BLOB_READ_WRITE_TOKEN) ; un membre voit une phrasePoser la variable nommée, dans cet ordre. Avec les réponses enregistrées (ORBIT_PROVIDERS=fixtures), aucune clé n'est demandée
listError (et errorDetail.list, fondateur seul)La bibliothèque n'a pas pu être lue (GET /v1/voices) : la voix par défaut reste utilisable401 : la clé est fausse ou révoquée ; autre chose : ElevenLabs est lent ou en panne, la liste revient au prochain bureau (un échec n'est jamais gardé)
quotaLes caractères du compte ElevenLabs ce mois-ci (used, limit, remaining, resetsAt) : le plafond du fournisseurQuand remaining approche de zéro, monter le palier ElevenLabs ou attendre resetsAt : au-delà, ElevenLabs refuse et la voix échoue en provider-failure, remboursée
quotaErrorLe quota n'a pas pu être luComme listError

La bibliothèque est gardée dix minutes par instance : une voix clonée à l'instant apparaît au plus tard dix minutes après. Le quota est lu à chaque bureau.

Trois plafonds, trois portées. Le plafond d'un membre est son ledger (solde, plafond par génération, plafond du mois : /cout), et c'est le seul que le web applique. Le quota du compte ElevenLabs est celui du fournisseur, montré ici, jamais contourné. ELEVENLABS_MONTHLY_CAP est le plafond mensuel de caractères du pipeline CLI de voix off (packages/remotion/pipeline/tts.mjs, pipeline/lib/ledger.mjs) : le web ne le lit pas et ne doit pas le lire, un membre étant déjà borné par son ledger.

Essayer sans clé

Hors production, sans AI_GATEWAY_API_KEY ni VERCEL_OIDC_TOKEN, ORBIT_PROVIDERS=auto sert les réponses enregistrées (packages/engine/src/generation/fixtures) et le Blob local (.blob/ à la racine) : toute la chaîne tourne, ledger compris, sans rien payer. POST /api/dev/generate (connecté, permission generate) l'expose en HTTP.

Avec l'une des deux, auto appelle les vrais fournisseurs : vercel env pull écrit VERCEL_OIDC_TOKEN (il expire au bout d'environ douze heures, relancer la commande), et getGenerationContext dit gatewayAuth: "oidc" (ou "key").

La suite réelle, à budget plafonné (elle dit, pour chaque cas, si la Gateway a rendu un coût et un id de génération) :

STUDIO_LIVE_E2E=1 LIVE_USD_BUDGET=0.5 AI_GATEWAY_API_KEY=... ELEVENLABS_API_KEY=sk_... ELEVENLABS_VOICE_ID=... \
  pnpm vitest run --project engine src/generation/live.test.ts

Cas réels par moteur

Les cas vidéo de la suite réelle ne tournent que nommés (LIVE_VIDEO_ENGINES, séparés par des virgules ; LIVE_VIDEO=1 seul veut dire veo) et que si le budget couvre leur pire cas, compté avant l'appel. Avec le budget par défaut (0,50 $), tous sautent : on relève le budget exprès.

MoteurPire casCe qu'il faut en plusCe qu'il prouve
veo0,60 $ (4 s)rienun clip de Veo a sa piste son (mp4HasSoundTrack), aucun réglage ignoré, le coût et l'id de la Gateway (lignes [live])
veo-probe0,60 $rienG2-10 : resolution: "1280x720" et generateAudio au premier niveau acceptés sans avertissement unsupported, et le clip garde sa piste son. Tant que ce cas n'est pas passé, le studio n'épingle pas la résolution de Veo et garde le son sur providerOptions.vertex
veo-refs1,20 $une image fixe (fixtures/image.png)Revue phase 4 : les images de style partent en providerOptions.vertex.referenceImages: [{ bytesBase64Encoded }], la forme de GoogleVertexVideoModelOptions (la plateforme envoyait des chaînes nues). Deux clips de 4 s, avec et sans l'image : aucun avertissement unsupported, et des octets différents. Tant que ce cas n'est pas passé, rien ne prouve que Veo lit les références
seedance0,61 $ (4 s, 720p, Seedance 2.0)rienresolution en LxH acceptée, durée mesurée
kling0,126 $ par seconde du clip (30 s par défaut : 3,78 $)LIVE_MOTION_CLIP_URL (un clip public de 3 à 30 s) et LIVE_MOTION_CLIP_SECONDS (ses secondes entières, pour réserver juste)l'appel documenté du motion control
lipsync0,50 $ (5 s)LIVE_LIPSYNC_FACE_URL et LIVE_LIPSYNC_AUDIO_URL (un visage et une voix de 3 à 30 s, publics)720p épinglé et generateAudio: false acceptés
STUDIO_LIVE_E2E=1 LIVE_USD_BUDGET=2.5 LIVE_VIDEO_ENGINES=veo,veo-probe,seedance AI_GATEWAY_API_KEY=... \
  pnpm vitest run --project engine src/generation/live.test.ts

Après veo-probe vert : le noter dans la feuille de route (G2-10), puis épingler 1280x720 / 720x1280 et déplacer generateAudio au premier niveau dans videoCallOptions (providers.ts), en changeant ensemble le test de transport qui les fige.

Garde croisée du design (retirée)

L'ancien dépôt avait un workflow design-drift.yml qui comparait chaque semaine le design dérivé de son produit et le catalogue des gabarits à la plateforme d'origine. Il n'est pas porté dans Orbit : le produit de démonstration orbit a un products/orbit/design/orbit.DESIGN.json sans dériveur, et Orbit n'a pas de plateforme parente. Le hash du catalogue des gabarits reste figé par packages/engine/src/render/render.test.ts ; une divergence voulue s'y déclare. Si un dériveur Orbit apparaît, écrire un contrôle de dérive propre à Orbit plutôt que restaurer l'ancien workflow.