Cœur du systèmeContrat serveur du cockpit

Contrat serveur du cockpit (StudioHost)

Appel par appel, la fonction ou la route du Studio qui remplace chaque appel du cockpit d’origine, et les gardes qui tiennent ce contrat fermé.

Statut (migration Orbit, 2026-10). Contrat d'ingénierie de Creative Studio dans Orbit (BoostEcom/orbit.easyconnector.app). Le cockpit est déjà transplanté dans ce dépôt (apps/web/src/components/studio/**, branché par apps/web/src/cockpit/produce.ts) ; la colonne « Cockpit d'origine » nomme l'API du cockpit de l'ancienne plateforme, que l'adaptateur imite, pas un service dont Orbit dépend. Les lignes « pont B1 » désignent une capture par l'ancienne plateforme, retirée dans Orbit (aucune plateforme parente) ; les gardes de ce document (section « Les gardes ») acceptent encore ce libellé pour une ligne sans export. La Fabrique (production par lots) est retirée ; le rendu de gabarit reste servi par la Composition (mode Motion).

Pour qui : quiconque touche l'adaptateur du cockpit Creative Studio (apps/web/src/components/studio/**, transplanté par l'ancien chantier studio/06-design-plateforme). Ce document dit, appel par appel, quelle fonction ou quelle route du studio remplace chaque appel du cockpit d'origine, comment passer les entrées et lire les résultats. Constat de l'audit de la phase 3 : G1-27. La v1 est du lot 4 ; la table est close depuis le lot 10 : chaque ligne nomme une fonction serveur exportée ou une route servie, ou dit en toutes lettres « phase 4 » ou « pont B1 », et une garde le vérifie (section « La garde », en fin de document). Source côté plateforme : src/components/studio/actions.ts et types.ts au commit 8df697416.

La règle : les mutations sont des actions, les lectures sont des routes GET

Next exécute les fonctions serveur une à une, en POST, sans cache, et sa documentation déconseille d'y lire des données. Un cockpit qui interroge une vidéo en cours par une action attendrait derrière chaque autre action de la page. Donc (G2-07) :

  • Mutations : les fonctions serveur de apps/web/src/generation/actions.ts, et elles seules : generateImage, editImage, composeProductImage, generateVideo, generateVoiceover, generateLipsync, packLoopGif, generatePicto, generateAvatar, renderTemplate, deleteRender, uploadReference, confirmReferenceUpload, saveAvatar, deleteAvatar, purgeProductBlobs. actions-are-mutations.test.ts refuse toute autre exportation, et tout nom de lecture (get…, list…, load…).
  • Lectures : cinq routes GET, chacune derrière requireMemberApi (session relue en base, 401 sans session, 403 sans permission), réponses cache-control: no-store :
RoutePermissionRend
GET /api/generation/contextstudio.viewGenerationContext : peut-il générer, fournisseurs live ou fixtures (et gatewayAuth), stockage, rendus configurés, solde et plafonds, produits, gabarits, modèles de voix, cas image (imageCases) et vidéo (videoCases : moteur, fenêtres, entrées, réserve aux défauts)
GET /api/generations?productId=&kind=&status=&allMembers=&limit=&before=costs.view-ownRenderRow[], le journal du membre (le fondateur lit l'équipe avec allMembers=true), du plus récent au plus ancien, pagination par before (le createdAt de la dernière ligne). Une valeur hors de son ensemble répond 400 { error: { code: "invalid", field } }
GET /api/generations/[id]costs.view-own{ ok: true, generation: RenderRow }, ou 404 { ok: false, code: "not-found" } pour une ligne d'un autre membre. Ferme une vidéo en retard (section suivante)
GET /api/voice-deskgenerateVoiceDesk (lot 8) : ready, message (français), missing (la variable à poser, fondateur seul), providers, voices (la bibliothèque du compte, gardée dix minutes), defaultVoiceId, defaultModel, models (boutons et usdPer1000Chars de chaque modèle), defaultSettings, maxChars, quota (used, limit, remaining, resetsAt, tier), listError, quotaError, errorDetail (fondateur seul). Rien n'est débité
GET /api/avatars?productId=studio.viewAvatarView[] du scope (libre pour le mode libre), du plus récent au plus ancien, 60 au plus. productId absent ou mal formé : 400 ; produit inconnu : 404

La page qui lie les actions exporte maxDuration = 300 : une fonction serveur tourne sous la limite de sa page, et une génération peut durer jusqu'à la limite de la fonction (STUDIO_FUNCTION_MAX_DURATION_S, G2-08). max-duration.test.ts l'exigera dès qu'une page importe @/generation/actions.

Une vidéo se lit, elle ne s'attend pas

generateVideo et generateLipsync rendent aussitôt (ADR 0014) :

{ "ok": true, "status": "running", "generationId": "g…", "heldUsd": "1.200000",
  "pollUrl": "/api/generations/g…", "url": null, "chargedUsd": null,
  "balance": "8.800000", "notice": "La vidéo est lancée. …" }

L'adaptateur pose une carte GalleryItem en status: "pending" (le mode du compositeur, le prompt, les réglages), puis lit pollUrl :

  • toutes les 5 s pendant la première minute, puis toutes les 15 s ; la route ne demande rien à la Gateway avant 15 s de vie (VIDEO_PULL_AFTER_MS), puis une lecture par appel ;
  • generation.status passe à done : url, mediaType et chargedUsd sont remplis ; la carte devient ready avec videoSrc = url ;
  • failed : generation.message est la phrase française à afficher (rien n'a été débité) ; la carte est retirée ou marquée en échec ;
  • l'adaptateur arrête de lire après 2 h : le balayage aura fermé la ligne (video_timeout, remboursée).

Rien ne dépend de l'onglet ouvert : fermer la page ne perd pas la vidéo. Le webhook de la Gateway ou le balayage la ferment, et la galerie la retrouve par GET /api/generations.

Ce que l'adaptateur fournit à chaque mutation

  • idempotencyKey : généré par l'adaptateur une fois par geste (un clic sur « Générer » : crypto.randomUUID()), et renvoyé à l'identique si le même geste est rejoué (double clic, réseau coupé, nouvelle tentative). Le même appel avec la même clé rend replayed: true et n'appelle rien ; la même clé pour une autre demande est refusée (invalid). La plateforme n'avait pas de clé : l'adaptateur la crée, le cockpit n'a rien à changer.
  • productId : le studio n'a pas de boutique. Le storeId du cockpit devient l'id d'un produit du registre (GenerationContext.products), ou null pour le mode libre.
  • Des ids d'asset, pas des URL. Le cockpit passe des URL (une image comme visage, des images à empaqueter en GIF) ; le studio attend des ids (faceAssetId, voiceAssetId, frameAssetIds) pour ne jamais lire une adresse arbitraire. L'adaptateur traduit l'URL d'une carte en son assetId (chaque RenderRow et chaque résultat portent les deux).

Les correspondances

StudioRenderResult (plateforme) se construit depuis GenerationResult (studio) :

StudioRenderResultDepuis GenerationResult
okok
idgenerationId (la carte garde aussi assetId pour les gestes suivants)
urlurl (null tant que status vaut running : carte en attente)
mediaTypecelui de la ligne, lu par GET /api/generations/[id] ; pour une image : l'extension de url
balancebalance (texte décimal USD : Number(balance)), null s'il n'a pas pu être lu
referencesce que l'adaptateur a envoyé (referenceUrls, referenceIds, referenceAssetIds) ; la ligne les garde dans references: { assets, uploads, urls }
errormessage (la phrase française)
code: "credits"code vaut insufficient-balance, run-cap ou month-cap : le solde du membre (le studio n'a pas de facturation d'organisation)

Appel par appel :

Cockpit d'origineStudioÉtat
loadStudioHost(storeId)GET /api/generation/context (compte, crédits, coûts, produits) + GET /api/generations?productId=… (les items de la galerie, RenderRow vers GalleryItem)v1 pour le compte, le solde et la galerie. subscription, plans, billing : sans objet (le studio n'a pas d'abonnement, l'argent est le ledger du membre). links, systems, academy, gestures, voice : phase 4
generateStudioImagegenerateImage({ productId, idempotencyKey, prompt, case, aspectRatio, size, detail, angle, scenePreset, placement, skillIds, referenceUrls, referenceIds, referenceAssetIds })v1 (lot 6). engine (la ligne choisie du composer) devient case : high-quality, fast, precise, logo ; fast: true sans engine reste quality: "fast". Ratios du cas (GET /api/generation/context, imageCases) : un ratio hors du cas est refusé invalid / aspectRatio. precise (GPT Image 2.5) : size 1K, 2K ou 4K (défaut 2K), detail low, medium ou high (défaut medium), 4K high part en 4K medium, réglé sur l'usage. system : ignoré (la ligne du cas est relue sur le serveur, overview §7.1) ; l'adaptateur peut continuer à l'envoyer, la ligne le note dans params.ignoredLayers. skillIds : les puces disk:<id> passent telles quelles ; un skill de boutique (sans équivalent dans le studio), ruleIds et connectorIds sont ignorés et comptés. references (les URL de la plateforme) devient referenceUrls ; un téléversement peut aussi passer par son id (referenceIds), un asset par le sien (referenceAssetIds). Six au plus (une pour logo), du même scope, sur le store du studio, cdn.shopify.com ou une boutique déclarée (overview §7.5) ; un refus est reference-refused, motif dans detail, référence dans subject, et ne débite rien
Ligne product-set du composerune image par angle : generateImage({ …, case: "product-set", angle: 0 … 3 }), puis packLoopGif({ frameAssetIds }) si la ligne livre un GIFv1. Reste une orchestration du cockpit, comme sur la plateforme (quatre appels, puis le GIF) ; la ligne de l'angle (PRODUCT_SET_ANGLES) est lue sur le serveur par son index, jamais envoyée en texte
Ligne logo du composergenerateImage({ …, case: "logo" })v1 : 1:1, 16:9, 9:16 ; une référence au plus, suivie telle quelle (pas d'enveloppe de variation)
Ligne picto du composergeneratePicto({ productId, idempotencyKey, prompt })v1. Le studio dessine un vrai SVG (le modèle écrit le document, contrôlé par svg-safety) là où la plateforme rendait une image matricielle avec la ligne system du picto : même geste, meilleur résultat, un fichier vectoriel
editStudioImage({ src, instruction, kind }) (agrandissement, variation)editImage({ productId, idempotencyKey, sourceAssetId, kind, instruction? })v1 (lot 6). src (une URL) devient sourceAssetId : l'adaptateur garde l'assetId de la carte. instruction est facultative : l'agrandissement a sa propre enveloppe (composition, sujet, couleurs, cadrage gardés, température par défaut), la variation reprend la phrase de la galerie par défaut (enveloppe de la plateforme, température 1,2). Prix d'une image Pro. Le résultat est une nouvelle carte ; son asset porte variantOfId et variantAxis. Une source d'un autre produit ou du mode libre : asset-not-found, aucune ligne
composeProductImage (outil du chat de la plateforme)composeProductImage({ productId, idempotencyKey, prompt, source: { assetId | referenceId | url }, aspectRatio, position })v1 (lot 6). Exactement une source ; une image Pro si elle a un alpha utilisable, deux sinon (scène et masque) ; la réserve et le règlement suivent. Refus propres : composition-failed (pixels non gardés, remboursé), composition-unavailable (sharp absent, rien d'écrit). La preuve de fidélité est dans params.fidelity de la ligne (GET /api/generations/[id])
generateStudioVideogenerateVideo({ productId, idempotencyKey, prompt?, case?, durationSeconds?, aspectRatio?, resolution?, audio?, scenes?, shots?, placement?, cameraMotion?, spokenLine?, skillIds?, startFrame?, endFrame?, referenceAssetIds?, referenceIds?, referenceUrls?, clipAssetIds?, clipReferenceIds?, character? }), puis GET /api/generations/[id]v1, asynchrone, tous les moteurs (lot 7, overview §7.4). L'adaptateur traduit : engine → case (absent : veo ; les onglets Nova et Replace du compositeur, que sa galerie envoie en engine: "nova" / "replace", deviennent case: "nova" et case: "replace", tous deux sur Seedance 2.5 comme sur la plateforme (revue phase 4) ; uncensored est refusé). nova : 4 à 30 s, 4 s par défaut, cadres ou références comme seedance-2-5. replace : l'adaptateur n'envoie plus la phrase fixe de la plateforme à la place du prompt (elle est la ligne du cas, relue sur le serveur ; les mots du membre, s'il y en a, la suivent), le clip source (videos[0]) en clipReferenceIds: [id] (obligatoire, un clip, pas une piste), les photos du personnage en referenceUrls / referenceIds (1 à 9, au moins une), 9:16 par défaut, facturé comme tout Seedance avec vidéo : les secondes demandées plus les secondes mesurées du clip source ; resolution telle quelle (720p, 1080p, 4k) ; frames[0] → startFrame, frames[1] → endFrame ; references (images) → referenceUrls, ou mieux referenceAssetIds / referenceIds ; videos (clips et pistes) → clipAssetIds / clipReferenceIds par id (une URL de clip n'est pas acceptée : le studio mesure les octets qu'il a stockés) ; pour motion, l'image du personnage → character, le clip → clipReferenceIds: [id] ; audio, scenes, shots tels quels. system, ruleIds, connectorIds peuvent être envoyés : ignorés et notés. Les fenêtres de chaque cas sont dans GET /api/generation/context (videoCases) ; un refus porte detail et la phrase française
Lipsync (chat de la plateforme)generateLipsync({ productId, idempotencyKey, faceAssetId, voiceAssetId, prompt?, durationSeconds?: 5 | 10, aspectRatio? }) ou, le geste unique de la plateforme, generateLipsync({ productId, idempotencyKey, faceAssetId, line, voice?: { model?, voiceId?, language?, consent? }, prompt?, durationSeconds?, aspectRatio? }), puis GET /api/generations/[id]v1, asynchrone, parité (lot 7) : 5 ou 10 s, 720p, generateAudio: false, voix de 3 à 30 s (voice-length sinon, avant tout appel vidéo). Avec line, la réponse est un LipsyncResult : elle porte aussi voice (la génération de la voix, livrée et débitée à part)
generateStudioSpeechgenerateVoiceover({ productId, idempotencyKey, text, model, voiceId, language, consent, settings })v1 (lot 8). settings (stability, similarity, style, speed, speakerBoost) tel que le popover de la plateforme l'envoie : borné sur le serveur, envoyé avec les seuls boutons du modèle (eleven_v3 : la stabilité seule, arrondie à 0, 0,5 ou 1), gardé dans params.settings. skillIds, ruleIds, connectorIds : sans objet pour une voix (la plateforme non plus ne lit pas une instruction à voix haute). La voix d'une personne exige consent (refus consent-required ou voice-unverified sinon)
loadStudioVoiceDesk(storeId)GET /api/voice-deskv1 (lot 8). Le { ok: false, reason: "engine", message } de la plateforme devient ready: false avec message ; reason: "permission" devient le 403 de la route. voices, defaultVoiceId, defaultModel, listError gardent leur nom ; en plus : les boutons et le prix de chaque modèle, et le quota ElevenLabs que la plateforme lisait ailleurs (readVoiceoverQuota)
layVoiceoverFromCockpit({ storeId, text, slug }) (modale de détail, /ops)generateVoiceover({ productId, idempotencyKey, text })v1 (lot 8). Le slug (dossier de la plateforme) n'a plus d'objet : le fichier est rangé sous <scope>voices/<id de génération>/. Même porte que toute voix (generate), journalisé et débité au caractère, là où la plateforme passait par platform.content.operate et logAdminAction : le journal de la génération est l'audit
assembleStudioLoop({ urls })packLoopGif({ productId, idempotencyKey, frameAssetIds }) (URL vers id d'asset)v1. Journalisé et débité au forfait (il était gratuit et sans trace)
Picto (chat de la plateforme)generatePicto({ productId, idempotencyKey, prompt })v1
uploadStudioReference(storeId, form)uploadReference(productId, form) : même champ file, mêmes limites que la plateforme (4 Mo ; JPEG, PNG, WebP, GIF, mais lus dans les octets, plus dans file.type). Rend { ok, reference: ReferenceView, created } ; l'adaptateur passe reference.url en referenceUrls (comme la plateforme passait url) ou reference.id en referenceIdsv1, sans débit. La page qui lie l'action dépend de serverActions.bodySizeLimit (4,5 Mo, next.config.ts)
Rush, piste de voix (nouveau)upload(pathname, file, { access: "public", handleUploadUrl: "/api/references/upload", clientPayload: JSON.stringify({ productId }), multipart }) de @vercel/blob/client, avec pathname = <scope>references/<id>/<nom>.<ext> (products/<id>/… ou libre/…, <id> en minuscules et chiffres, 8 à 40 caractères, <nom> en minuscules), puis confirmReferenceUpload({ productId, url })v1, sans débit. mp4, mov, webm, mp3, wav, m4a ; 50 Mo. Le store ajoute un suffixe aléatoire au nom : lire l'url rendue, jamais la reconstruire. confirmReferenceUpload est idempotent : l'appeler toujours après l'envoi, le rappel du store peut l'avoir devancé
deleteStudioGeneration({ storeId, id })deleteRender(generationId)v1. Supprime les fichiers, garde la ligne ; refusé en cours (in-flight) et pour un fichier approuvé ou livré (in-use)
Rendu de gabarit (Composition, mode Motion ; la Fabrique est retirée)renderTemplate({ productId, idempotencyKey, templateId, fields, formats, outputs, label }), puis GET /api/generations/[id]v1 (runner GitHub, rappel signé). Un champ refusé (obligatoire manquant, ou règle de copie, lot 9 : detail: "copy") revient en invalid avec problems: [{ key, message }], une phrase par champ, avant toute ligne ou réserve : l'adaptateur l'affiche sous le champ, comme le composer de la plateforme affichait refusal.message
(aucun)purgeProductBlobs(productId)v1 (lot 9, fondateur seul, storage.purge). Supprime les fichiers d'un produit retiré du registre ; refus forbidden, product-registered, invalid (libre ou id illisible), storage-unavailable. Irréversible : une confirmation dans l'interface
generateStudioAvatar({ storeId, traits, sourceUrl?, note? })generateAvatar({ productId, idempotencyKey, traits, note?, source?: { assetId | referenceId | url } })v1 (lot 8). Une image Pro 1:1 (cas high-quality), journalisée et débitée comme toute image ; le prompt est construit sur le serveur depuis le catalogue (AVATAR_TRAITS, porté tel quel pour les pastilles), trait inconnu ignoré, note coupée à 400 caractères. sourceUrl devient source : l'id du téléversement (referenceId) ou de l'asset, ou l'URL admise
listStudioAvatars(storeId)GET /api/avatars?productId=v1 (lot 8). AvatarView garde id, name, url, sourceUrl, traits, createdAt (ISO) et ajoute productId et assetId
saveStudioAvatar({ storeId, name, url, sourceUrl?, traits })saveAvatar({ productId, assetId, name, traits? })v1 (lot 8). url devient assetId (l'adaptateur garde l'assetId de la carte) : un asset IMAGE du même scope, jamais une URL (la plateforme acceptait toute URL qui contenait stores/<id>/). sourceUrl et traits sont relus dans le journal de la génération quand l'image est un avatar généré. Sans débit. Refus : asset-not-found, invalid (name, avatar-image)
deleteStudioAvatar({ storeId, id })deleteAvatar({ productId, id })v1 (lot 8). Oublie le visage, garde le fichier (la plateforme supprimait les octets, ceux du rendu). Tant qu'un avatar retient un asset, deleteRender le refuse (in-use, motif avatar). Refus : avatar-not-found
loadStudioProduction, loadStudioBasis, loadStudioOverlay, loadStudioSystem, loadOpsBoard, loadWatchBoard, loadAcademyDoc, loadDeveloperSurfaceaucune fonction du studio : ce sont les surfaces de la phase 4phase 4
Le geste « Filmer » (Cinéma)aucune fonction du studio : le pont B1 (côté plateforme) est retiré dans Orbit ; le film de produit viendra de la fondation Visual Context (ADR 0019) et de la surface Cinéma de la phase 4pont B1 (retiré), phase 4

Ce qui change pour le cockpit

  1. La CSP du studio autorise l'hôte exact de son store Blob dans img-src et media-src, et https://vercel.com/api/blob/ dans connect-src (overview §7.5). Sans elle, le cockpit transplanté ne pourrait ni afficher une image ou un clip stockés, ni téléverser un rush. Une image d'un autre hôte (le CDN d'une boutique) ne s'affiche pas telle quelle : la montrer après l'avoir passée en référence, ou la téléverser.

  2. Une vidéo ne rend plus d'URL dans la réponse : lire pollUrl.

  3. Chaque résultat donne le solde après l'appel (balance) : le cockpit n'a plus à recharger loadStudioHost pour l'afficher.

  4. L'argent est du texte décimal USD ("0.067000"), jamais un nombre flottant côté serveur ; l'adaptateur convertit pour l'affichage (formatSpend).

  5. Les refus portent un code stable et la phrase française (message) ; le cockpit affiche message et branche sur code.

Les gardes

Ce contrat est tenu par trois gardes, dont aucune ne dépend d’un autre dépôt cloné à côté pour les tests de l’application :

  • Dans ce dépôt (node scripts/audit-docs.mjs) : la structure du tableau « Appel par appel » et de la règle des mutations. Chaque ligne cite une fonction (`nom(`) ou une route (/api/…), ou dit « phase 4 » ou « pont B1 » ; une ligne qui ne cite rien ne s’annonce pas v1 ; la liste en prose des mutations est exactement l’ensemble des fonctions citées ; la table des lectures compte cinq lignes, chacune une route GET.
  • Contre le code de l’application (node scripts/audit-orbit-claims.mjs --orbit <clone>, CI orbit-claims) : chaque fonction citée est exportée par apps/web/src/generation/actions.ts, chaque route citée a son route.ts et sert GET quand la ligne la lit en GET, la liste des mutations est exactement celle des exports, et chaque type de résultat nommé ici (GenerationResult, GenerationContext, RenderRow, VoiceDesk, AvatarView, ReferenceView, LipsyncResult) est exporté par apps/web/src/generation/types.ts.
  • Dans le dépôt applicatif (Vitest, côté code) : actions-are-mutations.test.ts tient les exports de actions.ts à la liste des mutations et les cinq lectures à GET seul ; studiohost-surface.test.ts tient les types de résultat exportés, les cas vidéo nova et replace exécutés par le serveur, et l’existence de upload de @vercel/blob/client.

Ce que ces gardes ne font pas : exercer chaque appel du cockpit avec ses réponses enregistrées. Ce test-là lit gallery-context.tsx et n’existe qu’une fois le cockpit transplanté (l’autre chantier, phase 4, reporté par le plan).