ArchitectureCreative Media OS — HISTORICAL (état de septembre 2026)

Creative Media OS — HISTORICAL (état de septembre 2026)

Archive de conception, pas contrat d'exécution actuel. Ce document décrit l'architecture du Media OS telle qu'elle était envisagée en septembre. Les anciens chemins creative/ et /ops/creative/*, les scripts Postiz…

Archive de conception, pas contrat d'exécution actuel. Ce document décrit l'architecture du Media OS telle qu'elle était envisagée en septembre. Les anciens chemins creative/ et /ops/creative/*, les scripts Postiz locaux et les étapes « suivantes » de ce texte ont évolué ou ont été retirés. Pour les opérations actuelles, lire d'abord Creative Studio — frontière de dépôts et Médias Shopify. Le code Remotion canonique réside dans Ecosystem, et les capacités médias Shopify restent dans BoostEcom ; ce document n'autorise ni leur duplication ni une intervention sur Orbit Studio.

ADR lié : 0017. Items : 2679 (done, #1216) → 2680 (done, #1219) → 2681–2682 (+ suites 2683 Media desk, 2684 Remotion expand).

Statut : PR1+PR2 sur main (2026-09-14). Motion.so / Higgsfield retirés. Remotion local (Reel9x16 + 4 primitives) livré. Train vidéo suivant = 2681 (Playwright / ElevenLabs / Postiz). Ops immédiate : §14 (spine Growth texte), pas d'attendre la VO.


1. Problème qu'on résout

On ne cherche pas « un outil qui génère des vidéos ». On construit un Creative Agent / Media OS interne au SaaS :

  • motion design déterministe (UI réelle, kinetic type, cards, charts) — pas du text-to-video type Runway/Kling/Veo pour l'UI produit ;
  • une bibliothèque de primitives React composées par un agent ;
  • des captures Playwright du vrai produit (allowlist) ;
  • de la voix-off ElevenLabs alignée sur le script facts ;
  • une distribution Postiz en draft ;
  • une frontière claire avec le Studio marchand (plane B).

Référence visuelle : film produit SaaS (~98 s, motion UI + typo), pas un clip génératif.


2. Deux planes (non négociable)

PlaneNomPour quiMarqueSortieCode / surface
AGrowthMarketing BoostEcom / ChristopherBoostEcom (facts.md, DESIGN dérivé)Postiz draftscreative/ + boostecom-content / boostecom-creative + /admin/content/growth
BStudio produitUsers SaaS via chatMarque du store / orgStudioAsset + créditssrc/features/studio + tools generateImage / Veo

Le plane C (Agency desk) est retiré depuis le 2026-09-26 (ADR 0043, qui remplace l'ADR 0004) : BoostEcom n'a ni agence ni client externe. Sont partis le Studio d'organisation /[orgSlug]/~/studio/**, /ops/creative/{pipeline,clients}, la livraison /drop/[token], le cron creative-drop-tick, le job open-creative-drop, les kits creative/brand/clients/ et les modèles StudioDrop, Prospect, ProspectEvent. Restent, au service du plane A : la QC /ops/creative/qc (revue des assets Growth avant Postiz) et les concepts /ops/creative/concepts (briefs d'angle Growth).

Note opérateur. Retirer un modèle du schéma ne supprime pas sa table : le schema guard est additif. Les tables StudioDrop, Prospect, ProspectEvent (et les colonnes retirées de Store : cadence et gate de production) restent orphelines en base. Leur DROP est une action opérateur ultérieure, délibérée, après sauvegarde (pnpm db:deploy), jamais un effet de bord d'une PR. Suivi : backlog/data-platform/3073, procédure dans docs/ops/database-index-maintenance.md.

Règles dures

  1. Un render A n'écrit jamais un StudioAsset marchand.
  2. (retirée avec le plane C : il n'y a plus de render client.)
  3. Le chat B ne lit jamais facts.md / plan Q4 Christopher comme vérité pub. Le mécanisme a été substitué, la règle non (ai-platform/2826, ADR 0024 §4). facts.md est monté comme source de connaissance de la boutique du tenant Fondateur, par le chemin qu'un marchand emprunte pour les siennes (knowledgeSources). Ce qui protège n'est donc plus une consigne de prompt mais le scope : platformKnowledgeFor(storeId) rend un tableau vide pour toute autre boutique, et pour toute organisation non désignée par INTERNAL_ORG_SLUGS. Un chat B ne peut pas le lire parce qu'il ne lui est jamais remis — pas parce qu'on lui demande de ne pas le lire. Le fichier du skill reste la source unique : il est lu, jamais copié, donc un fait ajouté atteint le tenant sans intervention. Garde : src/test/the-founder-tenant-knows-its-own-product.test.ts. Corollaire (growth-web/2827) : puisque les ids de faits sont désormais lisibles, la question humaine « chaque chiffre vient-il d'un fait vérifié » devient une règle (fact.unsourced dans copy-rules.ts) — mais seulement là où le corpus est monté. Ailleurs knownFactIds est vide et la règle ne tourne pas : une règle qui se croit active et ne vérifie rien serait pire que la question qu'elle remplace.
  4. ADR 0013 (agency vs studio permissions) reste pour sa moitié encore vivante (agency.qc.review, agency.economics.read) ; A est la croissance de la marque BoostEcom, pas le Studio vendu.

Voir aussi : docs/architecture/studio-agency-os.md (historique du plane C), docs/architecture/acces-delegue-et-frontiere-studio.md.


3. Stack retenu

PrioritéPièceRôle
1Skills + rules repo (boostecom-content, boostecom-creative)Cerveau + garde-fous
2Remotion + remotion-dev/skillsMoteur vidéo/image programmable
3Playwright (allowlist)Yeux sur le SaaS réel → captures
4ElevenLabs TTS + forced alignmentVoix-off A (pas le chat live)
5Postiz (déjà MCP + reconcile)Distribution A
6/api/og + social-cards.mjsStills / carousels
7ffmpegEncode / GIF dérivé
bonusVeo / génératifB-roll abstrait rare seulement ; jamais l'UI

Explicitement écarté

OptionPourquoi
Motion.so (Mosaic)Crédits opaques, pas d'estimate, UI non déterministe ; owner stop 2026-09-14
HiggsfieldOwner : non retenu (déjà 2678)
Creatomate / Banuba comme centreMoins intelligent qu'un agent + codebase + Playwright
Rive comme moteur exportUtile dans le produit UI, pas pour exporter le calendrier Growth
Kling / Runway / Veo pour dashboardsPerte d'exactitude brand / UI
Dupliquer un monolithe /content-engine + .ai/*.md parallèleOn a déjà content skill + Growth admin + DESIGN dérivé → un seul creative/

Hume vs ElevenLabs

Hume EVI (existant)ElevenLabs
JobVoix conversationnelle chatTTS / VO contenus A
RemplacementChantier ai-platform séparéCanon Media OS
Day-1 Media OSNe pas toucherBrancher en PR3

Alternatives Hume plus tard : OpenAI Realtime, Gemini Live, ElevenLabs Conversational, LiveKit Agents, Cartesia.


4. Admin : combler, ne pas reconstruire

Surfaces existantes à étendre :

  • /admin/content/growth — units, renders, import plan, handoff
  • /admin/content/bulletin, cms, …
  • /ops/creative/qc et /ops/creative/concepts — revue et briefs du plane A Ajouts prévus (pas une 2ᵉ app) :
/admin/content/growth
  ├── Units (existe)
  ├── Renders (existe)     ← artifacts Remotion + audio + état Postiz
  ├── Media desk (NEW)     ← file specs, render, ledger, go humain```

---

## 5. Arborescence cible `creative/`

creative/ README.md # renvoie ici references/ ops.md # licence Remotion, 1 render, determinism hooks.md # rétention 1.3–1.7s, frame-0 text elevenlabs.md # modèles FR, alignment, caps, consent formats.md # 1:1 / 4:5 / 9:16 / 16:9 + safe zones postiz-video.md # matrices TikTok/IG/YT/X motion-language.md # langage visuel (référence → règles) hume-exit.md # note non bloquante ai-platform brand/ boostecom/ # plane A — dérivé DESIGN / assets allowlistprimitives/ # 30–50 à terme ; 8–12 en PR2 ProductWindow.tsx # cinq depuis 3036 : les cinq autres ne servaient que Reel9x16 / Landscape169 Chart.tsx BrandBackground.tsx Glow.tsx CTA.tsx scenes/ # Hook, Problem, Demo, Proof, CTA compositions/ # Templated (une par toile), UiShot (rush cinéma) ; Reel9x16 / Landscape169 supprimées (3036) captures/ # Playwright outputs (git LFS ou artifacts CI) campaigns/ # un brief → multi-renders pipeline/ plan.mjs | capture.mjs | tts.mjs | render.mjs | postiz.mjs | ledger.json out/ # gitignored package.json # Remotion isolé si possible (limite hot lockfile root)


Skills :

- `$HOME/.claude/skills/boostecom-content/` — rédaction (inchangé spine)
- `.claude/skills/boostecom-creative/` → évolue en **`boostecom-creative`**
  (orchestre `creative/`, refuse Motion/Higgsfield)
- Brancher **remotion-dev/skills** pour l'agent de rendu

---

## 6. Langage motion (mesurable)

Figé dans `creative/references/motion-language.md` :

- Fond dark high-contrast ; accent brand **rare**
- Soft radial glow derrière l'UI
- UI produit en **perspective** (mock fenêtre), pas de mock générique stock
- Typo grotesk oversized ; **peu de texte par scène**
- Entrées blur/scale rapides ; caméra lente sur product shots
- Alternance scènes UI denses / typo sparse
- Chapter cards chiffrées
- **Hook text frame 0**, opacity 100 %, **zéro fade-in**
- Safe zone 9:16 : clear top **14 %** / bottom **35 %** / sides **6 %**
- Pas de logo inventé ; pas d'UI Shopify/Claude imitée ; pas de stock people
- Voix-off : script = facts verbatim FR (plane A)

---

## 7. Formats & Postiz (ops)

### Formats Remotion

| id | Ratio | px | Usage |
|----|-------|-----|--------|
| `reel-9x16` | 9:16 | 1080×1920 | TikTok / Reels / Shorts / Stories ads |
| `feed-4x5` | 4:5 | 1080×1350 | Feed Meta mobile |
| `square-1x1` | 1:1 | 1080×1080 | Carousel / Marketplace |
| `landscape-16x9` | 16:9 | 1920×1080 | YouTube / in-stream / launch |
| stills | via même composition `durationInFrames=1` ou `/api/og` | OG, LinkedIn, X |

### Postiz vidéo

| Réseau | AI disclosure | Piège |
|--------|---------------|--------|
| TikTok | `video_made_with_ai` + **`DIRECT_POST`** | `UPLOAD` = inbox 24 h, settings droppés (sauf title) ; API peut dire OK |
| Instagram | pas de champ AI Postiz | 1 vidéo = Reel |
| YouTube | pas de `made_with_ai` API | title 2–100, visibility, made-for-kids ; label AI en description / process YT |
| X | `made_with_ai` optionnel | — |

Toujours : **draft d'abord**, reconcile ledger, une série à la fois.

### ElevenLabs

- Modèles : `eleven_multilingual_v2` (stable) ou `eleven_v3` (expressif, limite chars plus basse)
- Captions : **forced alignment** (texte = vérité) → timings mots ; fallback Whisper.cpp + `t_dtw`
- Chunks VO **≤ 45–60 s**
- Voix A (BoostEcom) unique : le plane C (voix client) est retiré
- Consent écrit avant clone ; `ELEVENLABS_MONTHLY_CAP` + ledger

### Remotion ops

- Automation = licence **Automators** (ou Company) si >3 personnes
- **Un render à la fois** sur une machine
- Captions = JSON figé ; animer avec `frame/fps`, jamais timers runtime
- Determinism : mêmes `inputProps` → même artifact

### Remotion est une CAPACITÉ du registre, plus seulement une page

Depuis `ai-platform/2823` (ADR 0024 §4), `renderComposition` est un outil
du registre, famille `studio-composition`, étape `studio`. Avant, les dix
templates de `templates.json` n'étaient atteignables que par le
formulaire `/ops/creative/generate` : `render-dispatch.ts` avait sept
importeurs, tous des pages ou des actions de `/ops`, et le board du
cockpit portait un lien **sortant** pour les lancer.

Ce qui n'a pas changé, et ne doit pas :

- **un seul dispatch.** La capacité appelle `launchTemplateRender`, qui
  re-valide tout ce que le formulaire valide, depuis les mêmes fonctions
  de `template-catalog.ts`. Aucun appel direct à l'API GitHub ;
- **un seul catalogue.** La description servie au modèle est **dérivée**
  de `CREATIVE_TEMPLATES`. Aucun identifiant de template n'est écrit dans
  la capacité, et la garde `remotion-is-a-capability-not-a-page.test.ts`
  le vérifie template par template ;
- **`/ops/creative/generate` garde son URL et sa porte.** Elle cesse
  d'être le SEUL chemin, elle ne cesse pas d'être un chemin ;
- **l'ADR 0017 tient.** Remotion reste le moteur déterministe et la
  hiérarchie « vraie UI > composants réels > captures > imitation
  générative » n'est pas négociée. Les deux moteurs sont des capacités du
  même registre plutôt que deux produits — c'est ce qui permet de choisir
  le bon sans changer d'outil.

Trois bornes explicites :

| Borne | Pourquoi |
|---|---|
| Refus sans boutique, avant toute dépense | Un rendu se dépose dans une bibliothèque (ADR 0024 §5) |
| Moteur non configuré = une phrase, pas un échec | C'est le cas normal sur un clone : `isCreativeRenderConfigured()` est lu avant d'ouvrir la ligne |
| **Aucun scope MCP** | Un rendu dépense des minutes de runner. « Un accord OAuth durable peut-il dépenser sans qu'un humain regarde » est la question que `integrations/0714` pose au propriétaire, et aucun agent n'y répond à sa place |

**Depuis `data-platform/2828`, un rendu lancé par la capacité atterrit
sous le préfixe de la boutique qui l'a demandé** (`stores/<id>/studio/`),
pas dans `creative/library/`. La chaîne a quatre maillons — la capacité
nomme la destination, `render-dispatch` la transmet, le workflow la
déclare, `publish-artifact.mjs` l'applique — et le défaut vide laisse le
**formulaire** de `/ops/creative/generate` et un lancement à la main
inchangés.

Deux raisons qui n'en font qu'une : le balayeur de rétention ne connaît
que le préfixe d'un store (`data-platform/2791`), donc un fichier rangé
ailleurs n'a **aucun** balayeur ; et un second inventaire qui grossit est
un second inventaire permanent. `creative/library/` reste **lisible** —
rien n'est supprimé, aucun lien déjà partagé ne casse — mais il se nomme
désormais une archive dans son propre en-tête, et
`src/test/one-inventory-of-renders.test.ts` refuse qu'il redevienne « la
bibliothèque ».

**Et depuis `platform-ops/2830`, la ligne se règle.** Le run rappelle
`/api/webhooks/creative-render`, signé HMAC-SHA256 sur le corps brut :
livré avec son fichier, ou échoué avec la conclusion du run. Le rappel
part **même quand le run échoue** (`always()`) — c'est justement ce
run-là dont la ligne resterait ouverte pour toujours — et le script sort
toujours 0, parce que faire échouer un rendu publié pour un rappel raté
serait remplacer un défaut discret par un défaut bruyant et faux.

Trois refus délibérés : le rappel **n'interroge pas GitHub** pour
confirmer ce que le run vient de dire (une seconde source de vérité sur
l'état d'un run diverge de la première) ; une ligne déjà réglée est
laissée telle quelle, donc une double livraison ne peut pas la rouvrir ;
et un run qui réussit **sans publier un fichier** est réglé `FAILED`,
parce que l'opérateur n'a rien à ouvrir.

Le filet est `settle-stale-renders`, horaire : une ligne `PROCESSING`
plus vieille que la durée maximale d'un run est réglée `FAILED` avec sa
raison — personne n'a rappelé — et renvoie vers la bibliothèque, où le
fichier est peut-être bien là. Il ne devine aucune issue. C'est ce qui
rend **borné** le cas où `CREATIVE_RENDER_CALLBACK_SECRET` manque d'un
côté : à poser aux DEUX endroits, l'environnement Vercel et un secret
GitHub Actions du même nom.

La ligne `GenerationRequest` s'arrête à `PROCESSING`, et il faut le
savoir : `creative-render.yml` téléverse puis se tait, personne ne
rappelle. C'est déjà la différence entre « le run GitHub est la seule
trace » et « la boutique sait qu'elle a demandé un rendu », et c'est un
état qui ment à son tour si personne ne le termine. Le rappel signé et
son filet sont `platform-ops/2830`.

### La voix est une CAPACITÉ, plus un bouton d'une page

Depuis `ai-platform/2824`, `generateVoiceover` est un outil du registre,
famille `studio-voice`, étape `studio`. Avant, `voiceover.ts` avait trois
importeurs hors de lui-même et **un seul** appelait la synthèse : l'action
de `/ops/content`. Les deux autres — la page et le board média du cockpit
— ne lisaient que le quota. Le cockpit savait combien de caractères il
restait et ne pouvait pas poser une voix.

Deux choses réparées ensemble :

- **la porte.** Le chat et le canvas la voient par dérivation.
  `/ops/content` garde son bouton et sa garde : elle cesse d'être le seul
  chemin ;
- **le troisième inventaire.** Le fichier partait sous
  `creative/voiceover/`, hors du préfixe d'une boutique et sans ligne en
  base. Une voix demandée **pour** une boutique atterrit désormais sous
  `stores/<id>/studio/voiceover/` — même préfixe qu'une image ou une
  vidéo, donc même rétention et même suppression propagée
  (`data-platform/2791`) — et ouvre sa ligne `GenerationRequest`. Une
  voix off de la **plateforme** reste sous `creative/voiceover/` : elle
  n'appartient à aucune boutique, et lui en inventer une serait pire que
  le préfixe partagé.

Le coût d'une voix se compte en **caractères**, et il était entièrement
hors ledger. Il est maintenant sur la ligne, même tant qu'aucun barème ne
le convertit en dollars. Le quota, lui, reste **lu** par
`readVoiceoverQuota` et jamais recalculé : une capacité qui court après
un quota qu'une page lit ailleurs, ce sont deux vérités qui divergent le
jour où l'une se trompe.

`ELEVENLABS_*` absent est le cas **normal** sur un clone : la capacité le
dit en une phrase avant d'ouvrir quoi que ce soit, comme l'ADR 0024 §3
l'exige. La liste des moteurs reste fermée — aucun fournisseur de voix
n'est ajouté.

### Hooks (rétention)

- Décision swipe ~**1.3–1.7 s**
- Mute-first : texte on-screen frame 0
- A/B : seul le hook change ; métrique hold 3 s / 8 s

---

## 8. Playwright (captures)

Autorisé :

- pages listées dans allowlist (`assets.json` + routes marketing publiques
  + **compte démo seed** si dashboard)
- screenshots élément / full-page haute rés
- sortie versionnée sous `creative/captures/<route>/<hash>.png`

Interdit :

- stores clients réels / PII
- routes hors allowlist
- « explorer prod » sans seed

---

## 9. Workflow agent (cible)

brief ou feat mergée → lire facts + git + PRODUCT (dérivé) → (opt) Playwright capture états utiles → composer scenes via primitives (pas inventer de nouveaux composants) → TTS ElevenLabs + alignment → Remotion render multi-format → Postiz draft (A) → ledger + Growth Renders


L'IA **compose** 30–50 primitives validées ; elle n'invente pas un After Effects libre.

---

## 10. Plan d'exécution (PRs)

| PR | Backlog | Contenu | État |
|----|---------|---------|------|
| **PR1** | `2679` | ADR 0017 + ce doc + skeleton `creative/` + references/* + kill Motion + `boostecom-creative` | **DONE** [#1216](https://github.com/BoostEcom/boostecom.app/pull/1216) |
| **PR2** | `2680` | Remotion + Reel9x16 + SafeZone/HookText/KineticText/ProductWindow + render u18 **muet** | **DONE** [#1219](https://github.com/BoostEcom/boostecom.app/pull/1219) |
| **PR3** | `2681` | Playwright + ElevenLabs pipeline + Postiz draft vidéo (code) | **DONE** [#1221](https://github.com/BoostEcom/boostecom.app/pull/1221) |
| **PR4** | `2682` | `campaigns/<slug>/` multi-format + wire Renders Growth (le stub `brand/clients/` est abandonné avec le plane C) | AFTER live VO / media desk |

Suites découpées (gaps + ops post-livraison) :

| Item | Rôle | État |
|------|------|------|
| `2685` | Postiz **live** : drafts `utm_content=cmu…` + 1 `AttributionEvent` | **DONE** (T2 live + AttributionEvent u06 ; fill rest via `postiz-push-utm.mjs`) |
| `2686` | Premier MP4+VO live (ElevenLabs key + mux + draft vidéo) | **ready** (ops) |
| `2683` | Media desk — liste artifacts `creative/out/` sur `/admin/content/growth` | **DONE** [#1225] |
| `2684` | Landscape169 + primitives 8–12 + remotion-dev/skills | **ready** |

Parallèle (hors train vidéo, même pilier) :

| Item | Rôle | État |
|------|------|------|
| **Ops §14** | Spine texte mesurée | **OK** pour Done when `2685` (série cuid + AttributionEvent) ; fill Postiz restant = re-run script |
| `2660` | Cockpit objectifs lancement | **DONE** [#1217](https://github.com/BoostEcom/boostecom.app/pull/1217) |
| `2657` | Sortie Postiz depuis un `ContentRender` | AFTER renders earned |
| `0363` | DataFast visitors / events admin | decision P3 (cockpit « Not observable ») |

Hors train (ne bloque pas) :

- `ai-platform` : sortie Hume EVI
- Remotion Lambda (après local stable)
- Plane B consomme le moteur Remotion avec kit store (crédits)

### Prérequis owner

1. `sudo apt install -y ffmpeg` (WSL)
2. Débrancher Motion (+ Higgsfield) sur claude.ai
3. `ELEVENLABS_API_KEY` dans `~/.env.boostecom` avant PR3
4. Consent VO si clone de voix réelle
5. Compte / seed démo pour captures dashboard (PR3)

### Hot files

- `.claude/skills/**` → trailer `Hot-file`
- `package.json` / lockfile si Remotion au root → préférer `creative/package.json`

Pilier : **growth-web**. Cross-pillar si touche `src/features/studio` (plane B) : trailer + relecture ai-platform.

---

## 11. Inventaire repo (constat post-#1219)

Déjà là :

- `boostecom-content` (facts, voice, plans, Postiz)
- `boostecom-creative` (**Motion retired**, DESIGN dérivé, gardes ledger)
- `creative/` Remotion isolé : 4 primitives, `Reel9x16`, `render:u18`, `references/*` (les deux derniers supprimés depuis par `3036`)
- `/api/og`, `scripts/social-cards.mjs`
- Studio B : `generateImage` / Veo → `StudioAsset`
- QC Growth : `/ops/creative/qc` (le plane C, `~/studio` et ses drops, est retiré)
- Growth admin : `/admin/content/growth` (import, derive, handoff, **cockpit** `2660`)
- Playwright / Browserbase (scans — pas encore pipeline marketing)
- Runbook spine : §14

Absents (comblés par `2681`+ / suites) :

- Pipeline capture / TTS / Postiz vidéo (`2681`), Media desk artifacts (`2683`)
- Landscape169 + primitives 8–12 + skills Remotion (`2684`)
- ffmpeg local (prérequis owner), `ELEVENLABS_API_KEY`
- Write Postiz depuis admin (`2657`)

---

## 12. Ce qu'on a volontairement fusionné d'une autre session

Repris :

- Creative System = primitives + agent, pas TTV
- Playwright comme yeux
- Campagne multi-format depuis un seul code
- Mémoire = Git + fichiers + app, pas le chat
- remotion-dev/skills

Non repris :

- Monolithe `/content-engine` parallèle
- « Cursor = cerveau produit » comme architecture runtime
- Oubli Postiz / 3 planes / ElevenLabs / Growth admin

---

## 13. Critère de succès global

1. u18 (ou unité plan équivalente) → MP4 9:16 (+ VO optionnelle) → draft Postiz
2. Zéro appel `motion.so` / Higgsfield dans le skill
3. Growth admin montre l'artifact
4. Plane B et C non croisés avec facts Growth
5. Nouveau brief « launch feature X » peut s'appuyer sur primitives + captures sans réinventer le langage motion

---

## 14. Runbook — activer la spine Growth (ops)

> **NOW, pas la VO.** La spine texte → Postiz → mesure existe déjà.
> Remotion muet est livré (`2680`) ; Media desk / VO / Postiz vidéo =
> `2681`+ / `2683`. Ne pas attendre la VO pour importer, dériver, draft
> Postiz et vérifier l'attribution.
>
> Mesure détaillée : [`measurement-stack.md`](./measurement-stack.md)
> (UTM, DataFast, `AttributionEvent`). UTM Postiz :
> `$HOME/.claude/skills/boostecom-content/references/postiz.md` §4.

### Ce qu'on active

plan Q4 (skill) → Import GrowthUnit /admin/content/growth → Derive ContentRender même console, bouton Derive → growth-unit-ids.json coller les ids → rebuild + check-plan → UNE série Postiz draft utm_content = GrowthUnit.id → reconcile → DataFast goals + cron growth-attribution + AttributionEvent


### Prérequis

- Admin plateforme (console derrière `requireAdmin`)
- Manifeste déployé : `src/services/growth/plan-2026-q4-units.json`
  (produit par `plan-src/build.mjs` depuis le skill)
- `POSTIZ_API_KEY` pour reconcile / MCP
- Cookie consent accepté sur le parcours de test (sinon DataFast +
  `bec_acq` muets)

### Étapes ops (ordre fixe)

1. **Importer le plan** — `/admin/content/growth`, section Import.
   Action `importContentPlan` → `services/growth/plan-import.ts`,
   manifeste `plan-2026-q4-units.json`. Idempotent
   (`dedupeKey = plan:<campaign>:<unit>`). Second clic = « already
   present ». Si le manifeste a bougé **après** un import : **Resync
   plan units** (réécrit thesis/facts/sources, **ne re-id pas**).
   Jamais delete + reimport : les ids sont déjà dans les liens.

2. **Dériver les renders** — sur chaque unité importée (ou la série
   pilote), bouton **Derive** → `deriveUnitRenders` →
   `services/growth/derive.ts`. Crée les `ContentRender` mérités
   (score + evidence). Idempotent sur `(unit, channel)`. Sans cette
   étape : table Renders vide, `utmFor` n'a aucun appelant vivant.

3. **Coller les ids** — l'écran Import affiche `{ "u01": "clx…" }`.
   Copier dans
   `$HOME/.claude/skills/boostecom-content/references/growth-unit-ids.json`.
   `build.mjs` refuse une clé hors plan et une valeur qui n'a pas la
   forme d'un id.

4. **Rebuild + garde plan** (depuis la racine plateforme) :

   ```bash
   node $HOME/.claude/skills/boostecom-content/scripts/plan-src/build.mjs
   node $HOME/.claude/skills/boostecom-content/scripts/check-plan.mjs \
     $HOME/.claude/skills/boostecom-content/references/plan-2026-q4.json

Le warning « slug in utm_content » doit baisser : chaque post relié porte désormais l'id d'unité, pas le slug de pilier.

  1. Émettre UNE série Postiz en draft — jamais le plan entier (MCP ne sait ni éditer ni supprimer en masse proprement) :

    node $HOME/.claude/skills/boostecom-content/scripts/check-plan.mjs \
      $HOME/.claude/skills/boostecom-content/references/plan-2026-q4.json \
      --emit <SERIES>
    # puis integrationSchedulePostTool avec type: "draft" uniquement
    

    Draft-only. type: "draft" obligatoire jusqu'à go humain explicite sur le texte exact (skill social STEP 0). Pas de schedule / published depuis l'agent.

  2. Reconcile avant et après le batch :

    POSTIZ_API_KEY=… node $HOME/.claude/skills/boostecom-content/scripts/postiz-reconcile.mjs
    

    Stop si ORPHANS / MISSING / DUPLICATES ≠ 0. Après regen de corps : --delete-orphans retire les drafts stale (jamais un PUBLISHED).

UTM (contrat non négociable)

Constructeur unique : utmFor(channel, unitId, campaign) dans src/features/growth/growth-kinds.ts.

ParamValeur
utm_sourcecanal (x, linkedin, …) — pas boostecom
utm_mediumsocial / community / …
utm_campaigncampagne plan (ex. launch-2026-q4)
utm_contentGrowthUnit.id (cuid), pas le slug pilier

Seul appelant produit : services/growth/derive.ts (garde src/test/utm-convention-reach.test.ts). Les drafts Postiz du skill doivent coller la même formule via growth-unit-ids.json + build.mjs. Un utm_content encore en slug (bridge-ai, spy, …) passe le CAC (utm:x) mais ne résout aucune unité dans services/growth/attribution.ts.

Vérifier la mesure

SurfaceQuoi lire
DataFastGoal auth_signup (pas signup, retiré) filtré utm_source ; funnel Social → Auth → Signup → Onboarding
Crongrowth-attribution — src/app/api/cron/growth-attribution/route.ts, daily ~06:00 UTC (vercel.json) : reconcile subscriptions + deliveries → AttributionEvent
Admin/admin/content/growth — stats attribution observée (pas d'impressions)

Sans clic consenté + cookie datafast_visitor_id / bec_acq : DataFast reste à zéro alors que la spine Postiz est saine. Relire measurement-stack.md (consent + goals API).

Modes d'échec (symptôme → cause)

SymptômeCause probable
ContentRender vide / Derive « none »Import jamais fait, evidence trop faible, ou score sous le plancher de distribution
DataFast 0 sur un clic réelConsent refusé, ou cookie datafast_visitor_id absent (goals API)
AttributionEvent vide après signuputm_content encore slug pilier ; cron pas tourné ; token non résolu (unresolvedTokens dans la réponse cron)
CAC ok, unité muetteutm_source correct mais utm_content ≠ GrowthUnit.id
Doublons / orphelins PostizBatch multi-séries ou regenerate sans reconcile + --delete-orphans
Lien Instagram « perdu »Pas de lien cliquable post ; bio seulement (utm_campaign=bio)

Frontière train Remotion

Maintenant (cette section)Ensuite (2681–2684)
Import + Derive + Postiz texte draftAttach vidéo aux drafts (2681) / Media desk (2683)
utm_content = unit idVO ElevenLabs, Playwright captures
DataFast + AttributionEventLandscape169 + primitives (2684)
Remotion local muet (2680)Campagne multi-format (2682)

Un agent qui propose Motion.so / Higgsfield ou « attendre Remotion pour poster » lit mal ce runbook.


14 bis. Le générateur : la plateforme se remplit à la demande

Livré le 2026-09-17, sur une directive : pouvoir demander une génération à tout moment, sur tout sujet, pour alimenter toute la plateforme en motion, GIF, image, vidéo et picto.

Ce qui bloquait

Le moteur savait rendre un sujet : un fichier de brief uNN-<format>.json écrit à la main dans le skill, une composition par mise en page, et une seule sortie, le MP4 vertical. La plateforme ne pouvait donc se remplir qu'au rythme où quelqu'un committait un fichier — et rien ne produisait une carte OG, un pictogramme ou un GIF.

Le modèle

Une forme × une toile × un type de fichier. Les trois sont des listes, et les trois vivent au même endroit : src/services/creative/templates.json.

Ce que c'estAujourd'hui
Forme (template)une mise en scène, pas un sujet10 : hook, étapes, feature, graphique, chiffre, citation, avant/après, annonce, carte OG, pictogramme
Toile (format)des dimensions et une zone sûre6 : 9:16, 16:9, 1:1, 4:5, OG 1200×630, tuile 512
Fichier (output)ce qui est écrit sur le disque4 : MP4, GIF, WebM, PNG

Le sujet arrive dans fields, typé (text, lines, chips, color…), depuis le formulaire admin ou depuis un drapeau de la CLI. C'est ce qui fait qu'une composition sert tous les sujets.

Un catalogue, deux lecteurs, aucune copie

Le JSON est sous src/ et importé par les deux côtés : src/services/creative/template-catalog.ts (le lecteur typé + les règles) et creative/templates/load.ts (Remotion et les scripts). Pas de jumeau généré, donc pas de garde de fraîcheur à tenir : une seconde copie est précisément ce que cette disposition évite.

Pourquoi sous src/ et pas sous creative/ : le formulaire est bundlé par Next, et Vercel n'embarque pas forcément les fichiers hors src/ dans une fonction serverless — le défaut que media-desk.ts documente déjà, où une lecture locale rend tout en dev et rien en production.

Le trajet

/admin/creative/generate   formulaire dérivé du catalogue
  → workflow_dispatch      creative-render.yml (Chromium sur le runner)
  → scripts/render.mjs     le MÊME script qu'en local, formats × sorties
  → Vercel Blob            creative/library/<template>/<slug>/<fichier>
  → /admin/creative/library planche de contact + URL publique à copier

Trois refus écrits dans le moteur, pas dans une relecture :

  • une sortie animée sur une toile fixe est sautée et nommée (une carte OG en MP4 est un fichier inutile là où vont les cartes OG) ;
  • un PNG est pris sur la dernière image, là où toutes les entrées se sont posées — c'est le contrat que les formes « still » respectent ;
  • le script sort en erreur si un fichier demandé a échoué, donc une étape de workflow ne peut pas annoncer un succès sur un job à moitié écrit.

ffmpeg n'est plus une porte

Remotion 4 embarque son encodeur : h264, vp8 et gif se recousent sans ffmpeg système (vérifié sur une machine qui n'en a aucun). Le rendu appelait pourtant check-ffmpeg.mjs et refusait de démarrer : une barrière devant une porte ouverte. ffmpeg reste requis pour le mux de la voix off, qui l'appelle vraiment.

ffprobe non plus (3071) : le chemin UiShot lit le rush avec FFPROBE_PATH, puis le binaire système, puis celui que Remotion livre à côté de son ffmpeg (creative/pipeline/lib/ffprobe.mjs), et le dit par son nom (RENDER_FFPROBE_MISSING) quand il n'y en a aucun. Ce binaire embarqué arrive sans bit d'exécution (npm ci le dépose en 0644 ; seuls ffmpeg et remotion l'ont dans l'archive), et Remotion le rend exécutable lui-même avant de l'appeler : le résolveur fait le même geste (makeExecutable), sans quoi le repli échouait en EACCES sur toute machine sans ffprobe système. Le nombre d'images retombe sur un comptage décodé puis sur la durée, jamais sur NaN ; une cadence autre que 30 ou 60 est refusée (RENDER_RUSH_FPS) au lieu d'être rendue en 60.

Le verrou de rendu dépend du codec

remotion.config.ts posait le master H.264 (CRF 18, x264 slow, AAC 192k, bt709) sur TOUT rendu, et Remotion refuse un preset x264 ou une piste AAC hors H.264 : chaque webm et chaque gif sortait en code 1, et le job entier était déclaré raté. Depuis 3071 le fichier lit le codec de la commande (creative/pipeline/lib/render-lock.mjs) : H.264 garde tout le master, vp8 garde bt709 et les défauts de Remotion (CRF, Opus), un gif n'a ni CRF, ni audio, ni espace colorimétrique (Remotion le pose en filtre zscale, que ffmpeg refuse à côté du graphe de palette). Preuve : REMOTION_CODECS_E2E=1 npm run pipeline:test rend mp4, webm, gif et png d'un même job et vérifie leurs codecs, et que la durée déclarée du gabarit shell (119,27 s) est celle du film BoostEcom qu'il rend : déclarée à 22 s, son PNG tombait à 3,5 s au lieu de 16 % du film.

Le Studio reste la main gauche

pnpm creative:studio ouvre les mêmes compositions. La barre latérale liste une composition par toile ; la forme et le sujet sont des props JSON éditables à chaud. On y va pour dessiner une forme, jamais pour en remplir une de plus. Runbook : docs/ops/creative-studio.md.

Réparé le 2026-09-26 (growth-web/3034). Cette phrase était fausse depuis le film Home Shell : creative/src/Root.tsx n'enregistrait plus que BoostEcom, et creative-catalogue-is-renderable.test.ts exigeait l'absence de FORMATS.map(. Le formulaire offrait dix formes, le workflow les acceptait, et chaque rendu mourait sur « Could not find composition with ID <toile> ». Root enregistre de nouveau une composition par toile (Templated, durée lue du template par calculateMetadata) à côté du film, et le garde exige désormais l'inverse. Dans le même item : les dépendances du film déclarées dans creative/package.json, la palette creative/src/tokens.ts lue depuis boostecom.DESIGN.json au lieu d'hex recopiés, et l'audio non versionné qui manque rend un film muet au lieu d'un rendu en échec (détail : creative/README.md).

15. Capacités — maintenant vs après X

Capable maintenant (post-2680 / #1219)

CapacitéComment
Rédiger un post facts-onlyboostecom-content + check-plan.mjs
Carte / still OG/api/og + social-cards.mjs
Importer le plan Q4 en DB/admin/content/growth Import
Dériver des ContentRenderbouton Derive
Draft Postiz texte (une série)MCP + §14 ; utm_content = unit id
Reconcile Postiz ↔ planpostiz-reconcile.mjs
Mesurer signup / funnel produitDataFast + UserMilestone
Attribuer une unité (si UTM correct)cron growth-attribution → AttributionEvent
Refuser Motion / HiggsfieldGUARD:motion-retired, TOOLS = {}
Lire la doctrine 3 planesce fichier + ADR 0017
Composer Hook / Demo / CTAcreative/scenes/* + creative/primitives/* + Remotion Studio
Rendre un 9:16 muetnode creative/scripts/render.mjs --template <id> --formats reel-9x16 --outputs mp4 (render:u18 supprimé par 3036)
Voir objectifs vague vs observécockpit 2660 sur /admin/content/growth

Pas capable maintenant

ManqueDébloqué par
VO / captions alignées2681 + ELEVENLABS_API_KEY
Captures marketing allowlist versionnées2681
Pack multi-format d'une campagne2682
Savoir quel hook a gagnéexperiment model — LATER (audit)
Générer une image depuis un modèle d'IA (illustration, photo)hors périmètre : ce moteur compose des formes, il ne synthétise pas de pixels
VO depuis l'adminle workflow la mux quand ELEVENLABS_API_KEY / ELEVENLABS_VOICE_ID sont des secrets GitHub du dépôt (2686)

Capable depuis le 2026-09-17 (2657 / 2684 / rendu en un clic)

CapacitéComment
Rendre depuis l'admin, sans poste ni ffmpegbandeau Opérations → « Lancer un rendu » (template=legacy, unités du plan Q4) : retiré par 3039, ce chemin n'avait plus rien à rendre depuis 3036. Rendre depuis la plateforme passe désormais par la capacité renderComposition (gabarits) ou le bouton Filmer de la section Cinéma (cf. plus bas). Les MP4 déjà publiés sous creative/out/<unit>/ restent listés par le Media desk
Lister les MP4 depuis l'admin GrowthMedia desk = Blob (workflow) + creative/out/ local (services/growth/media-desk.ts)
Draft Postiz vidéo (MP4 attaché)Media desk → « Draft in Postiz » sur un artefact Blob : Postiz héberge une copie (POST /upload), brouillon TikTok DIRECT_POST + IA / YouTube privé / X / Instagram reel (buildVideoDraft). Enregistré dans AdminAuditLog
Poster un rendu texte depuis l'admin (sans copier-coller)table Rendus → « Draft in Postiz » sur un rendu Ready (linkedin / x / facebook / instagram) → scheduled ; « Relire Postiz » → published quand Postiz rapporte l'URL publique. Détail : measurement-stack.md « Le trajet render → Postiz → URL observée »
Landscape 16:9toile landscape-16x9 (Templated). La composition Landscape169 est supprimée (3036)
5 primitivesProductWindow, BrandBackground, Glow, Chart, CTA (les cinq autres ne servaient que les compositions supprimées par 3036)
Skills Remotion sans quitter creative/Ecosystem/projects/creative-runtime/creative/skills/remotion/ (markdown vendorisé de remotion-dev/skills, entrée remotion-best-practices/SKILL.md)

Capable depuis le 2026-09-26 (3033, cinéma : rush UI)

CapacitéComment
Filmer une scène cinéma image par imagecreative/pipeline/capture-video.mjs : Chromium (playwright-core, PLAYWRIGHT_CHROMIUM_PATH), colorScheme: dark, deviceScaleFactor: 2, attente des polices et images, puis pour chaque instant de frameTimes(durationMs, fps) (dernier instant compris) un seek() sur window.__BOOSTECOM_CINEMA__ et une capture PNG de [data-cinema-scene], pipée à ffmpeg (système, ou le binaire de Remotion). Même verrou que remotion.config.ts : H.264, yuv420p, CRF 18, slow, bt709, +faststart. Sortie creative/public/rushes/<sceneId>-<fps>fps.mp4, jamais écrasée (dossier temporaire + link sans écrasement), nettoyée sur SIGINT, erreurs codées CAPTURE_VIDEO_*
La seule exception à l'allowlist/ops/creative/cinema/<sceneId>?capture=1 en loopback http ou sur l'hôte de production, avec une session lue dans l'environnement (creative/references/captures.md). Depuis 3039, en production, c'est la session du compte de service de relecture, obtenue par le runner lui-même (cf. section suivante)
Cadrer un rushcomposition UiShot : <OffthreadVideo src={staticFile("rushes/…")}> dans ProductWindow, titre et légende optionnels, 9:16 ou 16:9, fps et durée lus du rush par render.mjs --composition UiShot
Prouver le tuyau sans l'appnpm run pipeline:video:fixture : une page statique à faux pont, servie en 127.0.0.1, puis ffprobe (codec, nombre d'images)

L'option UiShot du workflow creative-render.yml est fusionnée (platform-ops/3035).

Capable depuis le 2026-09-26 (3039, « Filmer » depuis la plateforme)

Un membre de l'équipe sans terminal produit une vidéo Growth depuis la section Cinéma du cockpit. Un clic, un run, un film dans la galerie de la marque. Aucun second chemin : le même dispatch privé de services/creative/render-dispatch.ts, le même workflow, la même ligne GenerationRequest (capacité renderComposition, params.kind = "cinema"), le même rappel signé. Garde : src/test/a-film-is-a-render.test.ts.

ÉtapeOùComment
GestefilmCinemaSceneFromCockpit (components/studio/ops-actions.ts)platform.creative.review, règles de copie sur titre et légende, AdminAuditLog studio.cinema.film_launched
Ligne puis ticket puis dispatchservices/creative/cinema-film.tsla ligne s'ouvre d'abord ; issueCaptureTicket (services/creative/capture-ticket.ts) range le SHA-256 d'un nonce de 256 bits dans params.captureTicket et signe cct1.<id>.<nonce>.<exp>.<sig> (HMAC d'une clé dérivée, séparée par domaine, de CREATIVE_RENDER_CALLBACK_SECRET, 30 min) ; launchCinemaRender envoie template=cinema + ticket. Un refus annule la ligne
ÉchangePOST /api/creative/capture-sessionforme, signature, échéance et liaison vérifiées sans base, puis consommation atomique (UPDATE ... WHERE usedAt absent, statut SUBMITTED ou PROCESSING). Rend une session NextAuth de 15 min du compte de service (mintReviewServiceSession, auditée au nom du demandeur). Tout refus : 401 invalid_ticket, motif au journal serveur seulement. Débit limité par IP
Capture + renducreative/pipeline/film.mjs (pas « Film the scene »)lit ses entrées dans $GITHUB_EVENT_PATH (jamais ${{ }}, jamais env:), ::add-mask:: du ticket puis du jeton, origine d'échange fixée à la production (redirect: "error"), cookie passé à captureVideo dans un objet d'environnement jamais exporté, puis render.mjs --composition UiShot sans le cookie
Publication + rappelpublish-artifact.mjs --template ui-shot puis callback.mjssous stores/<storeId>/studio/ui-shot/<scène>-<fps>fps-<requestId>/, contentType transmis au rappel pour que la galerie range le film en vidéo. L'id de la demande est dans le label depuis 3071 (renderLabel) : deux films d'une même scène étaient UN blob, que le second écrasait sous toutes les lignes qui le pointaient. film.mjs refuse un label qui ne finit pas par l'id
Concurrencecreative-render.ymlun groupe PAR DEMANDE (creative-render-<journal ou run_id>), depuis 3071. GitHub ne garde qu'un run en attente par groupe : le groupe <template>-<label>, commun à toutes les boutiques, faisait annuler le deuxième lancement par le troisième, et sa ligne restait PROCESSING jusqu'au balayeur
Installationcreative-render.ymlpnpm install --frozen-lockfile --ignore-scripts sur creative/pnpm-lock.yaml (pnpm seulement, node-linker=hoisted dans creative/.npmrc pour garder le compositor Remotion sous node_modules/@remotion/), versionné depuis 3071, versions exactes (playwright-core = celle de la racine). Le fichier d'évènement contient déjà le ticket quand ce pas tourne : aucun script d'installation d'une dépendance ne s'exécute, aucune plage ^ ne tire une version non relue
ProgressionreadCinemaFilmFromCockpitle panneau relit la ligne toutes les 8 s : en cours, livré (lien), échoué (motif)

Sécurité, en une phrase par verrou : aucun secret durable ne quitte Vercel (le runner n'a que le secret de rappel qu'il avait déjà, et il ne suffit pas à forger un ticket accepté : le nonce n'existe qu'en base, haché) ; le ticket est à usage unique, lié à sa ligne, perimé en 30 min ; la session ne vaut que 15 min, que pour l'identité de service, qui ne tient que platform.creative.review ; couper l'identité (DELETE /api/security/service-account) éteint une session en cours à la requête suivante. Limite connue : l'entrée ticket est visible dans la charge de l'évènement workflow_dispatch pour qui lit le dépôt ; elle est bornée par l'usage unique et la course est perdue d'avance pour un tiers (le runner l'échange en premier et un échange refusé fait échouer le run bruyamment).

Preuves locales : capture-ticket.test.ts, cinema-film.test.ts, render-dispatch.test.ts (charge du dispatch contre les entrées déclarées du workflow), capture-session/route.test.ts, pipeline.test.mjs (film.mjs), et FILM_E2E=1 npm run pipeline:test : échange sur un serveur local, capture qui n'ouvre le plateau qu'avec le cookie, rendu UiShot réel. Non prouvé tant qu'un vrai run n'a pas tourné : le Chrome du runner ubuntu-latest, l'échange contre la production, le rappel.

La capture en local, seul geste qui demande encore une session fabriquée à la main. La commande « Capture en local (terminal) » de la section Cinéma sert au développeur qui filme sur son poste. Sa session vient d'un admin, par POST /api/security/service-account ({"action":"session"}, 1 à 240 minutes) : le runbook admin content/runbooks/fr/ops-session-capture.mdx (/admin/docs/ops-session-capture) en est la procédure. Il a été fusionné avec l'ancienne procédure (utilisateur e2e- créé à la main, session signée avec NEXTAUTH_SECRET) alors que 3035 était déjà fusionné, et renvoyait au tutoriel vidéo qui renvoyait à lui ; growth-web/3073 l'a réécrit pour le trajet actuel. Garde : src/test/academy-tutorials-match-the-product.test.ts.

Prouvé de bout en bout le 2026-09-26 (3036)

PreuveComment
Rush → UiShot → MP4, 9:16 et 16:9UISHOT_RENDER_E2E=1 npm run pipeline:test (dans creative/) : capture de la fixture dans public/rushes/, rendu des deux toiles par render.mjs, ffprobe (H.264, yuv420p, 1080×1920 / 1920×1080, 60 fps, autant d'images que le rush, trois étiquettes bt709), puis une image à mi-plan doit contenir la barre de la fixture. Opt-in comme CINEMA_CAPTURE_E2E
Verrou couleur tenuremotion.config.ts réécrit la VUI H.264 au stitch (h264_metadata) : avant, primaries et transfer sortaient unknown sur tout rendu Remotion
Une seule règle d'imagesframeTimes vit dans src/lib/motion/cinema/frame-times.mjs, importé par l'app et par capture-video.mjs ; frame-times.test.ts et pipeline.test.mjs exigent le même objet fonction

Après 2681

  • MP4 + VO + draft Postiz vidéo (TikTok DIRECT_POST + video_made_with_ai)
  • Captures produit dans les scènes

Après 2683 / 2684 (livrés)

  • Media desk : go humain sur artifacts sans quitter l'admin, rendu et brouillon Postiz vidéo en un clic depuis la même table
  • Landscape169, dix primitives, skills Remotion vendorisés (Landscape169 et cinq primitives supprimées depuis par 3036)

Après 2682

  • Une campagne → 9:16 + 4:5 + 1:1 + stills

Après spine §14 vivante (+ cockpit déjà livré)

  • Lundi : avance / retard par vague et attribution unité mesurée
  • Décisions scale basées sur signup/activation, pas sur vanity seule

16. Multi-produit (growth-web/3047, PR 1 de la série)

Le système créatif (creative/ Remotion, le moteur cinéma, le Filmer) était câblé sur BoostEcom. Le besoin : le même système, au comportement inchangé, pour d'autres produits : l'extension Chrome « BoostEcom Spy », l'app « Easy Connector », un thème Shopify, une app Shopify. Chacun a son propre dépôt et ses propres règles de design. La règle : chaque produit possède son système, le Studio s'en nourrit.

Le registre

creative/products.json est la seule liste des produits servis. Données pures, lues par deux lecteurs du même fichier :

  • creative/pipeline/lib/products.mjs (Node : pipeline, dériveur de design), qui valide la forme (validateRegistry) et expose productFor(id) ;
  • creative/src/products.ts (TypeScript, bundle Remotion), typé.

Un identifiant inconnu est une ERREUR dans les deux lecteurs, jamais un repli silencieux sur BoostEcom. Forme d'une entrée :

ChampRôle
idslug (boostecom)
namenom affiché
repoowner/name du dépôt qui possède le code et le design du produit
design.json (+ md, assets, deriver)le DESIGN.json dérivé du code du produit, et le script qui le dérive
capture.productionOrigin, capture.stagePathl'origine https de production et la forme du chemin du plateau (<scene>)
galleryoù les rendus du produit sont listés. kind: "placeholder" tant que la PR 2 ne l'a pas décidé
postiz.notesoptionnel : les contraintes de publication du produit

Seul boostecom est enregistré, et c'est le défaut. Ses valeurs capture sont celles que allowlist.mjs applique aujourd'hui (le test creative-products.test.ts vérifie l'égalité) ; l'allowlist ne les lit pas encore.

Ce que la PR 1 change, et ce qu'elle ne change pas

  • creative/src/tokens.ts choisit son DESIGN.json par produit : tokensFor(productId) (défaut boostecom), et tokens suit le produit que nomme l'environnement (productFromEnv). Le bundle Remotion ne reçoit que les variables REMOTION_* : jusqu'à 3071, CREATIVE_PRODUCT=spy npx remotion render rendait BoostEcom sans rien dire. remotion.config.ts la transmet désormais en REMOTION_CREATIVE_PRODUCT, donc un produit inconnu fait échouer le rendu, et deux valeurs différentes sont un refus. Les valeurs BoostEcom sont identiques à l'octet près (vérifié avant/après). L'import d'un DESIGN.json reste statique (Remotion bundle le fichier) : un produit ajoute son import dans DESIGNS, et le test refuse une entrée sans import.
  • derive-design-md.mjs --product <id> : le dériveur BoostEcom refuse un produit qu'il ne possède pas (autre design.deriver) au lieu d'écrire un design BoostEcom sous un autre nom. Sa sortie BoostEcom est inchangée.
  • Aucun changement visible : ni rendu, ni capture, ni page.

La suite

  • PR 2 : capture, workflow et galerie lus depuis le registre (allowlist.mjs lit capture, la galerie par produit remplace le placeholder).
  • PR 3 : un paquet cinéma partagé et un plateau HTML générique, pour les produits qui ne sont pas une app Next (extension, thème).

Ajouter un produit

  1. Dans le dépôt du produit, un dériveur qui lit SON code et écrit <id>.DESIGN.json (mêmes sections que BoostEcom : social_card, themes.dark, pillars, fonts, motion, radius).
  2. Une entrée dans creative/products.json (candidats : spy, easy-connector, theme, shopify-app), avec son origine https et son chemin de plateau.
  3. L'import de son DESIGN.json dans DESIGNS (creative/src/tokens.ts).
  4. creative-products.test.ts doit rester vert.

Dernière mise à jour : 2026-09-26 — 3071 (un film par demande, verrou de rendu par codec, lockfile creative/). Avant : 2026-09-14, closeout 2680 / 2660.