Skip to main content

En bref

Les assets « lourds » d’une scène (modèles .glb, textures, images de template, vidéos, PDF) ne sont pas embarqués en dur dans l’app open. Ils sont servis depuis un CDN Cloudflare (R2 public), résolus au runtime par le helper asset() de @wrappy/core. Deux familles d’assets, à ne pas confondre : Cette page couvre surtout la première famille ; l’UGC a sa section, Uploads de l’émetteur.

Le schéma de versionning

L’URL d’un asset template suit toujours cette forme :
  • namespace = la scène (ou le composant partagé) propriétaire de l’asset. C’est le slug de la scène : fete-des-laurens, web-form, 3d-book, gift-physics…
  • version = v1, v2… versionnée par namespace (voir plus bas). Chaque scène évolue à son rythme.
  • chemin = le chemin de l’asset dans son dossier (/models/x.glb, /images/ticket.png).
Exemple concret :
Le versionning est par namespace, pas global. Changer un asset de web-form bumpe web-form en v2 sans toucher fete-des-laurens, qui reste en v1.

Pourquoi versionner

Les dossiers publiés sont immuables : on ne réécrit jamais un vN déjà en ligne. Une invitation déjà envoyée a été buildée avec des URL pointant sur une version donnée — si on écrasait les assets, elle changerait d’apparence après coup. Une révision d’asset = nouveau dossier v2, l’ancien continue de vivre.

Comment ça marche

Le helper asset()

packages/contracts/src/assets.ts (@wrappy/contracts/assets) :
  • VITE_CDN_ASSETS_URL = l’origine du CDN des assets, sans chemin ni version (ex: https://cdn.wrappy.app). Le dossier templates/scenes est SCENES_PATH, la version vit dans ASSET_NAMESPACES. Côté API et pipeline d’assets, la même variable sans préfixe : CDN_ASSETS_URL.
  • ASSET_NAMESPACES = la table qui fixe la version courante et les textures GPU de chaque namespace. C’est le seul endroit à toucher pour bumper. Un namespace absent est en v1.
  • Elle ne vit pas dans le manifeste d’un wrappy : un namespace est un dossier, pas un wrappy (card lit celui de letter, stamps n’appartient à aucun).

Dev vs prod : même structure

En dev, VITE_CDN_ASSETS_URL peut rester vide → CDN_ROOT retombe sur /templates/scenes, servi statiquement par Vite depuis apps/open/public/. Le dossier public/ est organisé exactement comme le CDN : dev et prod résolvent le même chemin, aucune divergence.

Développer une scène : utiliser asset()

1

Ranger les fichiers dans public/

Place tes assets sous apps/open/public/templates/scenes/<ns>/v1/…, en gardant une arborescence lisible (/models, /images, /videos).
2

Déclarer le namespace dans VERSIONS

Ajoute une entrée dans VERSIONS (packages/core/src/lib/assets.ts) :
Si le namespace est absent de VERSIONS, asset() retombe sur v1 par défaut — mais déclare-le explicitement pour pouvoir le bumper plus tard.
3

Référencer via asset() dans la scène

Ne mets jamais de chemin d’asset en dur. Passe toujours par asset(namespace, path) :
asset prend deux arguments. ["/a.png"].map(asset) ne marche pas (.map passe l’index en 2ᵉ argument) — utilise .map((p) => asset("ma-scene", p)).
4

Tester en dev

bun run dev et vérifie que l’asset charge (onglet Network → 200 sur /templates/scenes/ma-scene/v1/…). Comme public/ reflète le CDN, ce qui marche en dev marchera en prod.

Assets partagés entre scènes

Si un composant est réutilisé par plusieurs scènes (ex: le livre 3D 3DBook, embarqué par fete-des-laurens et web-form), ses assets vivent sous son propre namespace (3d-book), pas dupliqués sous chaque scène :

Bumper un asset (nouvelle version)

Quand tu modifies un asset déjà publié en prod :
1

Créer le nouveau dossier de version

Copie les assets du namespace sous …/<ns>/v2/… dans public/templates/scenes/. Ne touche pas au dossier v1.
2

Bumper ASSET_NAMESPACES

Tous les asset("ma-scene", …) pointent maintenant sur v2. Aucun autre changement de code.
3

Publier sur le CDN

Voir la section suivante. Le v1 reste en ligne pour les invitations déjà buildées.

Publier sur le CDN

Deux chemins selon l’asset :
  • Les designs de lettre (templates de faces) se composent depuis le studio, page /admin/templates. Un template est un dossier du catalogue, letter/<version>/templates/<id>/, sur le bucket de contenu de l’environnement (R2_CONTENT_BUCKET : public en prod, public-dev en dev), qui porte son manifeste template.json (schéma letterTemplateSpecSchema de @wrappy/core/letter-templates, discriminé par kind : un classique a un libellé et, par face, le design à poser, ses finitions optionnelles — relief, masque à trois niveaux (noir gaufré en creux, gris papier, blanc embossé) dont la scène tire une carte de hauteur aux épaules mesurées en mètres (buildReliefCanvas), et foil, masque de dorure, deux images en niveaux de gris à part du design, ignorées là où elles ne sont pas en ligne — et la boîte d’écriture en fractions de la face, commune ou propre à un format (formats.<format>, qui remplace writing et signature à ce format : une carte postale n’écrit pas où écrit une carte, faceBoxes) ; un collage a sa disposition — pleine face, duo, trio — et compose la face des photos de l’émetteur sans aucun design, le liseré blanc étant le choix de l’émetteur) et, pour un classique, ses images <format>/<nom>.webp. Rien n’est déclaré dans le code : l’API (/scene-templates) sert le catalogue des manifestes en ligne (public : c’est ce que le wizard propose), écrit ou remplace un manifeste (PUT /letter/:id), supprime un dossier (DELETE /letter/:id), liste les designs en ligne et en publie un (/letter/files). Un design arrive en PNG, JPEG ou WebP au ratio de la face : l’API le rend en WebP aux pixels exacts (cardFaceSize, via Bun.Image), le pose à la clé exacte que catalogAsset("letter", …) lira, refuse un autre ratio (422 : remettre à l’échelle déplacerait les zones) et refuse d’écraser une clé existante sans demande explicite (409) ; le manifeste, lui, se réécrit librement, c’est l’objet de l’admin. Un template n’a pas à couvrir tous les formats : le catalogue joint les designs en ligne par format (online) et le wizard ne propose un template que là où templateCovers le dit — chaque design des faces du type de carte présent pour le format (la feuille a ses propres designs, TEMPLATE_FORMATS l’exclut). Le manifeste porte aussi un rank optionnel : le catalogue est trié par rang, les templates sans rang ensuite, puis par identifiant (compareTemplates), et le premier qui couvre le format choisi est le design que le wizard pose d’office, dès le lancement (LetterTemplateDefault). Dans l’admin, un dépôt en vrac range chaque fichier par le ratio de l’image (format) et son nom (design : cover.png, cover-card.png). La scène lit le manifeste sur le CDN au rendu (useLetterTemplate, cache: "no-cache" pour que le navigateur revalide) et retombe sur des faces nues s’il manque. Les écritures sont réservées aux comptes de rôle admin (plugin admin de Better Auth, colonne user.role).
  • Le décor de la lettre (letter/<version>/decor/) : les cartes PBR du bureau et du mur (*-diff, *-nor, *-arm : occlusion en R, rugosité en G) et les tirages punaisés, statiques, publiés comme le reste :
  • Le reste (modèles, vidéos, images de scène) part avec le pipeline d’assets, qui n’envoie que ce qui n’est pas encore en ligne, sous la même clé que dans public/ (voir Pipeline d’assets 3D) :
Les dossiers de version sont immuables : on ne republie que les nouveaux vN. Côté build de open, poser :
VITE_CDN_ASSETS_URL est une variable build-time de Vite (préfixe VITE_), injectée via un ARG/ENV dans apps/open/Dockerfile. Elle est figée au build.
Le catalogue (templates de lettre, timbres) a son propre CDN, VITE_CDN_CONTENT_URL, lu par catalogAsset() : chaque environnement l’écrit depuis son admin, sur son bucket (R2_CONTENT_BUCKET, servi sous CDN_CONTENT_URL côté API, même valeur), et un design testé en dev n’entre jamais au catalogue de la prod. En dev : https://cdn-dev.wrappy.app. Côté front, absent, il retombe sur VITE_CDN_ASSETS_URL : le build de prod n’a rien à poser. Côté API, R2_CONTENT_BUCKET et CDN_CONTENT_URL sont obligatoires, prod comprise (public, https://cdn.wrappy.app) : une API mal réglée ne démarre pas plutôt que d’écrire sur la prod.

Ce qui reste hors CDN

Les petits assets non liés à une scène restent servis par l’app depuis public/ (à la racine, pas sous templates/scenes/) : logos, favicon, manifest.json, sons courts (page-flip.mp3), image OG de fallback. Trop petits pour justifier le détour CDN.

Uploads de l’émetteur (UGC)

Ce que l’émetteur téléverse (photos, PDF, timbre, pièces jointes) part dans le bucket R2 privé ugc (ugc-dev hors prod), jamais sur le CDN.
  • Chemin : presign authentifié (POST /storage/presign), PUT direct navigateur → R2, clé <userId>/<uuid>-<nom> posée dans le payload. Une clé R2, jamais une URL : les URLs expirent.
  • Props de scène : les chemins d’upload sont dérivés de l’UI du contrat (assetPathsOf). L’API vérifie à la création que chaque clé appartient à l’émetteur, et y substitue une URL signée d’une heure à la lecture. Le schéma de props lit donc les deux formes : c’est assetRef (borne d’URL, 2048), pas assetKey (300, champs de noyau). À 300, une URL signée (~430 caractères) était rejetée et la scène perdait l’asset sans erreur.
  • Aperçu de partage : ogImageKey, sinon la première pièce jointe image. Une lettre n’a pas de pièces jointes (ses médias sont sur sa feuille) : sans ogImageKey, elle partage l’aperçu de sa scène.
  • Envoi programmé : scheduledAt date l’e-mail, pas l’expérience. Le lien est actif dès le paiement ; le planificateur de l’API (wrappy.scheduler.ts) fait partir l’e-mail à la date.

Réduction des photos

Toute image d’un champ upload est réduite dans le studio avant de partir (apps/studio/src/lib/reduce-image.ts, lib browser-image-compression dans un worker) : 2048 px de côté long, WebP qualité 0,85, orientation EXIF appliquée. Le fichier réduit est celui qui part et celui de l’aperçu. Une image déjà plus petite, un GIF ou un SVG partent tels quels. Le PDF n’est pas touché : c’est un document, pas des pixels. Un champ cutout garde son propre traitement (détourage), sans réduction en plus.

CORS du bucket

Les scènes chargent les images avec crossOrigin = "anonymous" (sinon le canvas est teinté) et pdf.js envoie des requêtes Range. La politique CORS du bucket ugc (dashboard Cloudflare → R2 → ugc → Settings → CORS) doit donc autoriser l’origine de open et du studio en GET, avec l’en-tête range. Vérifier :

Vérification à l’entrée

Un PUT présigné ne borne ni la taille ni le type réellement envoyés. L’API les vérifie sur l’objet (StorageService.assertUploaded) quand une clé entre dans une expérience : création d’un wrappy, dépôt de livre d’or. Un objet absent est refusé, ce qui évite un cadeau cassé. Un objet trop lourd ou d’un type refusé est supprimé sur-le-champ. Les presigns sont limités en débit : 60 par minute pour l’UGC, 10 pour les avatars.

Photos de profil

Une photo importée part dans le bucket public, sous avatars/u/<userId>/<uuid>. Le hook Better Auth user.update.before vérifie l’objet quand user.image change : dans le dossier du compte, image de 2 Mo au plus. Une photo Google n’est pas chez nous : Better Auth pose l’URL googleusercontent.com dans user.image, rien ne passe par R2.

Suppression et orphelins

  • Suppression d’une expérience (WrappyService.deleteById) : la ligne d’abord, puis ses objets (storedAssetKeys : pièces jointes hors lien, aperçu, props du contrat) et, pour un livre d’or, le préfixe guestbook/<id>/. Un objet en échec est logué, jamais remonté au client.
  • Archivage, mot masqué : rien ne part, les deux sont réversibles.
  • Brouillon jamais payé : supprimé 30 jours après sa dernière modification (wrappys.updated_at). Ses fichiers deviennent orphelins.
  • Suppression d’un compte : ses expériences partent en cascade avec lui (wrappys.sender_id), mots reçus compris. Ses commandes restent, user_id à NULL. Ses fichiers et sa photo de profil deviennent orphelins.
  • Orphelins UGC (photo remplacée ou retirée dans le wizard, brouillon abandonné, compte supprimé, candidat au catalogue décidé) : ce qui n’est référencé ni par un payload, ni par un dépôt de livre d’or, ni par un candidat encore à trier, et a plus de 7 jours. Une clé compte comme référencée si le contrat de la scène la déclare ou si elle apparaît n’importe où dans le payload : une scène retirée ne fait pas partir ses photos.
  • Orphelins avatars : sous avatars/u/, ce qu’aucun user.image ne porte, au-delà d’un jour. Le reste du bucket public n’est jamais réconcilié.
  • Ménage nocturne (storage.scheduler.ts) : à 2 h UTC, brouillons impayés, puis orphelins UGC, puis avatars. Une étape en échec n’arrête pas les autres et logue nightly cleanup failed, que suit une alerte. Allumé par UGC_RECONCILE_ENABLED=true, en prod seulement. À la main : bun run storage:reconcile dans apps/api simule, --apply supprime.
  • Coupe-circuit : plus de la moitié d’un bucket orpheline arrête son passage, sans rien supprimer. C’est le signe d’une base qui n’est pas celle du bucket, pas d’un ménage ordinaire. --apply --force le passe à la main, après lecture de la simulation.
  • Un bucket UGC par environnement (R2_UGC_BUCKET : ugc en prod, ugc-dev ailleurs) et un jeton R2 par environnement, limité à ses buckets. Le jeton de la prod ne quitte pas Dokploy ; celui de dev écrit dans ugc-dev et ne fait que lire public. Le bucket public reste partagé, d’où le drapeau éteint hors prod.
  • Pourquoi pas une lifecycle rule R2 : elle expire par âge ou par préfixe, sans savoir si une clé est encore référencée. Elle supprimerait les photos d’un cadeau vivant.
  • À l’échelle : un passage lit tous les payloads (par pages de 500) et liste tout le bucket. Au-delà de quelques dizaines de milliers de wrappys, passer à un registre des uploads en base (ugc_objects), où un orphelin se trouve par une requête indexée.

Curation des designs candidats

Les designs récupérés (musées, Commons) ou générés passent par un tri avant d’entrer au catalogue : page /admin/curation du studio, table template_candidates, module API template-candidates.
  • Import : bun run templates:import <dossier> --kind card|stamp dans apps/api lit manifest.json et designs/<thème>/<id>/, rend chaque export <format>.png en WebP aux pixels de la face, et le dépose en staging dans le bucket ugc, sous template-candidates/<id>/, avec une vignette de 512 px. Simulation par défaut, --apply pour importer. Un item déjà décidé n’est jamais réimporté. Les calques de dentelure (stamp-frame) sont ignorés, car l’engine des timbres les peint.
  • Garder (K) publie une carte comme un template classic : le design est posé en cover sur la face avant, l’écriture sur les faces nues. Les fichiers passent du staging à la clé de la scène, puis le manifeste. Le template se règle ensuite dans /admin/templates.
  • Quatrième de couverture d’une pliable : elle reste au papier seul, quel que soit le template (BLANK_FACES) — ni design, ni écriture, ni repli sur default. Le mot ne s’écrit qu’à l’intérieur droit, et le wizard n’exige pas de design pour cette face.
  • Dos par défaut : une carte gardée n’a qu’une couverture. Ses faces d’écriture (dos, intérieur droit), comme celles d’un collage ou d’une lettre sans template, reprennent la face du template réservé default (DEFAULT_TEMPLATE_ID, faceOwner) : design templates/default/<format>/writing.webp, finitions et boîte d’écriture. Il se règle dans /admin/templates comme un autre et n’est jamais proposé à l’émetteur. Une face d’écriture qui a son propre design le garde.
  • Garder un timbre pose son image en stamps/<version>/<slug>.webp, à 480 × 600 px (STAMP_IMAGE_PX, la taille des modèles dessinés), puis l’ajoute à stamps/<version>/catalog.json. Ce catalogue est la liste que l’étagère du wizard lit directement sur le CDN. Tant qu’il n’existe pas, ou s’il est illisible, ce sont les quatre modèles dessinés (BUILTIN_STAMPS). Le contrat n’accepte qu’un slug, sans connaître le catalogue. Un export est au ratio 4:5. Le timbre fait 20 × 26 mm, et l’engine pose l’illustration en cover dans sa fenêtre (environ 1:1,35), ce qui rogne quelques pixels de chaque côté. Il peint lui-même la dentelure, la marge et les inscriptions (FRANCE, WRAPPY, LETTRE DIGITALE) : l’image n’en porte aucune.
  • Recherches : bun run templates:search --source cma|wmc|si --query "…" --theme <thème> [--kind stamp] [--apply] dans apps/api interroge Cleveland, Commons ou le Smithsonian (clé SMITHSONIAN_API_KEY). Le Met est fermé (410) et Rijksmuseum attend. bun run templates:import-pool <catalogue>/.work [--apply] importe le vivier d’une recherche Cowork (candidates.json), en reprenant ses vignettes et ses originaux déjà téléchargés. Simulation par défaut dans les deux cas.
  • Licence, bloquante (template-candidates.license.ts) : CC0 ou domaine public prouvé au niveau de l’item (champ d’API, ou modèle du wikitext Commons : PD-old*, PD-anon-expired, cc-zero), puis filtre UE vie + 70 ans. Sont refusés une double licence avec CC BY (scans BHL), PD-US-expired seul, et un vrai timbre-poste comme modèle de timbre. Les doublons sont écartés par item et par URL d’image.
  • Cadrage : un candidat issu d’une recherche est en crop. Il n’a que son original, et seuls les formats atteignables sans agrandissement lui sont proposés (@wrappy/core/design-crop). Dans le détail, on règle un point focal commun ou propre à un format, un pré-recadrage et le contain d’un cadre transparent, ou on exclut un format. À K, le studio charge l’original, que l’API copie en staging depuis la source à la première demande puisque Cleveland n’envoie pas de CORS. Il rend chaque format sur canvas, avec la même fonction que les aperçus, et l’API vérifie les pixels avant de publier.
  • Rejeter (X) ne fait que changer le statut. La ligne reste, pour qu’un item ne soit jamais reproposé, et la réconciliation purge ses fichiers.
  • Annuler (Z) remet le candidat à trier et retire du CDN le template qu’il avait publié, tant que la réconciliation n’a pas purgé son staging.

Récap

  • Un asset de scène = asset(namespace, path), jamais un chemin en dur.
  • namespace = slug de la scène ; path = chemin dans son dossier.
  • La version vit dans ASSET_NAMESPACES (packages/contracts/src/assets.ts), par namespace.
  • public/templates/scenes/ reflète le CDN à l’identique → dev == prod.
  • Bump = nouveau dossier vN + mise à jour de ASSET_NAMESPACES, jamais d’écrasement.
  • Publication = bun run asset publish <ns>.