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 :
- Ajouter
ORBIT_PUBLIC_URL=https://orbit.easyconnector.app(Production, et Preview si le webhook vidéo y est voulu). - Mettre
NEXTAUTH_URLsurhttps://orbit.easyconnector.appet le secret GitHubCREATIVE_RENDER_CALLBACK_URLsurhttps://orbit.easyconnector.app/api/webhooks/render. - Redéployer, vérifier une génération vidéo (
params.webhookàtrue). - Supprimer
STUDIO_PUBLIC_URL, redéployer.
Ce qu'il faut poser
| Où | Variable ou secret | Rôle |
|---|---|---|
| Vercel (Orbit) | ORBIT_PROVIDERS | auto ou live en production (jamais fixtures, refusé en production) |
| Vercel (Orbit) | AI_GATEWAY_API_KEY | Images, 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_URL | L'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_ID | Voix, et le bureau de la voix (bibliothèque et quota du compte) |
| Vercel (Orbit) et secrets GitHub du dépôt de rendu | BLOB_READ_WRITE_TOKEN | Le 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, E2E | BLOB_STORE_ID | Nomme 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_TOKEN | actions:write sur CREATIVE_RENDER_REPO. Ancien nom relu : STUDIO_RENDER_DISPATCH_TOKEN |
| Vercel (Orbit) | CREATIVE_RENDER_REPO | Le 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 GitHub | CREATIVE_RENDER_CALLBACK_SECRET | La signature du rappel, la même valeur des deux côtés |
| Secrets GitHub | CREATIVE_RENDER_CALLBACK_URL | https://orbit.easyconnector.app/api/webhooks/render |
| Vercel (Orbit) | CRON_SECRET | La 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_ORIGIN | Retiré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) :
| Statut | Veut dire | Débit |
|---|---|---|
running | Le runner travaille (ou n'a pas encore rappelé) | Réserve ouverte |
done | Fichiers publiés, ligne réglée | Minutes réelles au forfait |
failed | Le 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é |
quota | Blackout du quota GitHub Actions : échec en quelques secondes sans une étape. Ce n'est pas le code | Remboursé ; relancer quand le quota revient |
no-callback | Aucun rappel 100 minutes après le dispatch. Le fichier a peut-être été publié : regarder le Blob du produit | Remboursé |
cancelled | Refusé avant tout appel (solde, plafond) | Aucun |
deleted | Fichiers supprimés par deleteRender, par la rétention à 30 jours ou par la purge d'un produit retiré ; ligne conservée | Inchangé |
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 :
| Colonne | Veut dire |
|---|---|
providerJobId | L'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) |
providerOperation | L'opération opaque que la Gateway relit ; ne pas la modifier |
providerWebhookSecret | Le 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.webhook | true si un webhook a été enregistré (ORBIT_PUBLIC_URL en HTTPS au départ) |
params.startWarnings | Ce 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 :
| Limite | Valeur | Où |
|---|---|---|
| Références par image | 6 (MAX_REFERENCE_IMAGES) ; 1 pour une image de départ | references.ts |
| Taille d'une référence lue | 8 Mo, comptés sur le flux | MAX_REFERENCE_BYTES |
| Temps pour lire une référence | 20 s, redirections et corps compris | PROVIDER_BUDGET_MS.referenceFetch |
| Redirections suivies | 3, chacune revérifiée | MAX_REDIRECTS (safe-fetch.ts) |
| Image téléversée (action serveur) | 4 Mo ; PNG, JPEG, WebP, GIF par ses octets | MAX_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 minutes | MAX_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.promptest le prompt empilé tel qu'envoyé (système du cas, skills, contexte du produit, brief composé),params.briefce que le membre a écrit,params.case,params.skills, etparams.ignoredLayersce que le cockpit a envoyé et que le serveur a ignoré (unsystemlibre, des règles, des connecteurs, un skill inconnu). UnignoredLayers.systemqui revient souvent veut dire que l'adaptateur envoie encore la lignesystemdu 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.tsles refuse sinon). C'est une décision du propriétaire : aucune n'est écrite aujourd'hui. -
GPT Image « précis » :
params.costSourcedit sur quoi l'image a été réglée (gateway,usagepour les jetons de sortie rapportés,formulasinon) etparams.outputTokensce 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-unavailableveut dire quesharp(et sa bibliothèque nativelibvips) ne charge pas sur ce déploiement : le détail est dansdetail. Rien n'est débité et le reste du studio tourne ; vérifier que le paquetsharpde 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) ouREJECTED, 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 etreferences/(jamaisavatars/), 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é). Auditgeneration.files_expired,by: "orphan-scan". - Un rendu fermé
FAILEDn'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) :
- retirer le produit du registre et déployer : la purge refuse un produit
encore
registered(product-registered), etlibre(invalid) ; - 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 ; - lire l'audit
product.blobs_purged. S'il ditcomplete: 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/otelexporte 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, sur127.0.0.1:4318seulement, qui imprime chaque span et les ajoute à.otel/spans.jsonl), puis lancer le studio avecOTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318etOTEL_EXPORTER_OTLP_PROTOCOL=http/json. Sans ces deux variables, rien n'est exporté. Elles ne sont pas des clés du studio (@vercel/otelles 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) :
| Span | Attributs à lire |
|---|---|
studio.generation | studio.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, .quota | studio.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.settle | studio.generation.id, studio.video.trigger (webhook, pull, sweep), studio.outcome (completed, pending, failed, retry…) |
studio.render.launch, studio.render.callback | l'id, le gabarit, l'issue (dispatched, COMPLETED, already-settled, le refus) |
studio.sweep, studio.retention | les 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_pending | Fichier livré, débit pas encore écrit (erreur jointe) | Rien si generation.reconciled suit dans l'heure ; sinon vérifier la base |
generation.close_pending | Débit écrit, clôture de la ligne perdue | Rien : le balayage la réconcilie |
generation.reconciled | Le 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_pending | Runner 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 perdue | Rendu : 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_missing | Rendu terminé sans aucune réserve trouvée : rien n'a été débité | Anormal : chercher pourquoi la réserve manque |
generation.paid_not_delivered | Fournisseur 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 membre | Compter 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.deleted | Un membre a gardé ou oublié un visage (assetId en métadonnée). Oublier ne supprime aucun fichier | Rien : c'est la trace. Aucun débit |
generation.files_delete_failed | Des 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 ligne | Rien 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_expired | La 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éversement | Rien : c'est la trace. La ligne et son coût restent |
generation.files_discarded | Un 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.expired | La 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_purged | Le 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 route | complete: false : relancer la purge, elle finit ce qui reste |
reference.uploaded | Un 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ée | Rien : c'est la trace. Aucun débit |
generation.cost_divergence | La 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 Gateway | Un 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 :
| Champ | Veut dire | À faire |
|---|---|---|
ready: false, missing | La 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 phrase | Poser 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 utilisable | 401 : 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é) |
quota | Les caractères du compte ElevenLabs ce mois-ci (used, limit, remaining, resetsAt) : le plafond du fournisseur | Quand 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 |
quotaError | Le quota n'a pas pu être lu | Comme 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.
| Moteur | Pire cas | Ce qu'il faut en plus | Ce qu'il prouve |
|---|---|---|---|
veo | 0,60 $ (4 s) | rien | un clip de Veo a sa piste son (mp4HasSoundTrack), aucun réglage ignoré, le coût et l'id de la Gateway (lignes [live]) |
veo-probe | 0,60 $ | rien | G2-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-refs | 1,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 |
seedance | 0,61 $ (4 s, 720p, Seedance 2.0) | rien | resolution en LxH acceptée, durée mesurée |
kling | 0,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 |
lipsync | 0,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.