Aller au contenu

Skills Celuga

celuga-media-react

Collection médias, images responsives, LCP, proxy `/media`, réencodage, éco-score.

Quand : Collection médias, images responsives, LCP, proxy /media, réencodage, éco-score.

Portée : machine (~/.agents/skills) · Fichiers : 3

Copier le bloc et le coller dans un terminal. Il recrée tous les fichiers du skill.

dir="$HOME/.agents/skills/celuga-media-react"
mkdir -p "$dir"
cat > "$dir/SKILL.md" <<'CELUGA_SKILL_EOF'
---
name: celuga-media-react
description: Pipeline médias Celuga Payload + Astro/React, avec priorité à l'éco-score (ÉcoIndex) du rendu front. Use when changing the media collection, responsive CMS images, the Media component, hero LCP, inline icons, the /media proxy, Open Graph images, favicon selection, re-encoding or media regeneration.
---
# Médias Celuga
Lire [`references/media-pipeline.md`](references/media-pipeline.md) avant de modifier le pipeline. Les valeurs ci-dessous sont la cible ; lire la collection et le helper du projet avant de conclure à un écart.
## Priorité : l'éco-score du rendu front
**Chaque arbitrage média se tranche en faveur de la page servie** (nœuds DOM, requêtes, octets transférés), mesurée par ÉcoIndex : fenêtre 1920 × 1080, **DPR 1**. Un palier de plus coûte un fichier stocké une fois ; un palier manquant se paie à chaque affichage. L'objectif ne porte que sur la page d'accueil, sauf demande contraire.
Leçon à ne pas répéter : réduire les paliers et ajouter `<picture>` + secours WebP a déjà fait perdre 4 points (55 nœuds de plus, photos plus lourdes que leur emplacement, logos sortis du HTML).
Mais la netteté prime sur quelques octets : une photo floue vient le plus souvent d'un `sizes` trop petit sur un `object-cover`, avant la qualité d'encodage. Corriger `sizes` d'abord.
## Contrat
**Rendu : un seul `<img>` AVIF par image** (`src` + `srcSet` + `sizes`), **sans `<picture>` ni `<source>`**. Exception possible : un hero à direction artistique, avec une seule `<source media>`.
| Dérivé | Largeur | Emplacements visés |
| --- | --- | --- |
| `icon_avif` | 160 | pictos, logos, avatars ; inline data-URI |
| `thumbnail_avif` | 320 | vignettes, logo d'en-tête |
| `compact_avif` | 480 | cartes en trois ou quatre colonnes |
| `card_avif` | 640 | deux colonnes, slots de 520 à 640 px |
| `medium_avif` | 960 | texte riche, 50vw |
| `large_avif` | 1400 | image principale, grands visuels |
| `xlarge_avif` | 1920 | seulement s'il existe un visuel plein écran 100vw |
| `large_webp` | ≤ 1400, `fit: inside`, sans agrandissement | `src` d'un média **sans** AVIF |
| `og_webp` | 1200 × 630 recadré | partage social uniquement |
- Master raster AVIF : qualité 85, effort 4, grand côté ≤ 2560 px, sans agrandissement ; inclus dans le srcSet. Dérivés AVIF : **qualité 75**, effort 4 (à 60, photos et schémas se dégradent visiblement). WebP : qualité 82.
- Payload 4 : formats dans `variants`, via `sharpTransformer` et `mediaSharp`. Le groupe `sizes` peut subsister, caché, pour les fichiers historiques. Le helper lit les deux.
- Annoncer dans le srcSet la **largeur réelle** du fichier, jamais celle suggérée par son nom. Ne pas plafonner à 600 px : cela exclut `card_avif`.
- Avant d'ajouter ou retirer un palier, calculer pour chaque `sizes` du projet le candidat retenu à 1920 px et DPR 1. Un écart supérieur à ~30 % de pixels sur un slot de l'accueil justifie un palier.
- **WebP : pas de secours navigateur dans les pages.** Il reste pour OG (les robots sociaux ne lisent pas l'AVIF) et pour un média sans AVIF, alors sans srcSet.
- Inline : petites images en data-URI jusqu'à **16 Ko réels**, car une requête pèse plus que le tiers ajouté par le base64. La taille déclarée sert seulement à écarter les fichiers au-dessus de 64 Ko. Ne pas retirer ce mécanisme pour gagner un point Lighthouse sans décision explicite.
- SVG et ICO restent tels quels. URLs publiques via `/media/…?v=<version>`. Pièces de formulaire : collection privée, jamais par ce proxy.
## Chargement
- `priority` (eager + `fetchpriority=high`) seulement pour l'image LCP réelle ; `eager` (eager + `fetchpriority=low`) pour une image visible qui n'est pas le LCP ; sinon `lazy`.
- Une grosse photo prioritaire fait osciller le LCP simulé par Lighthouse : mesurer plusieurs fois.
- Respecter le point focal (`object-position`).
## Modifier ou intégrer
1. Pour un changement de profil, aligner dans la même modification : collection → types → migration si une colonne change → helper → composants → inline → tests → documentation. Garder les anciens noms en lecture jusqu'au retraitement.
2. Choisir `size` d'après le slot et `sizes` d'après sa largeur CSS réelle.
3. Préserver les médias sans variantes et vérifier que la relation est peuplée.
4. **Mesurer avant de conclure** : compter `<img>`, `<picture>`, `<source>` et data-URI dans le HTML servi, lister les fichiers chargés au DPR 1, lancer la mesure ÉcoIndex du dépôt.
5. Changer le profil ne régénère pas les fichiers. Le gain sur les octets n'existe qu'après retraitement : le dire explicitement.
## Réencoder sur place
Les URL `/media/*?v=` sont en cache `immutable` un an. Tout réencodage sans changement de `updatedAt` impose de **changer la constante de version** du helper (`encodingVersion`), sinon les visiteurs gardent l'ancien fichier. Si des médias versionnés sont copiés au démarrage sans écraser l'existant, déclarer les anciennes empreintes (`superseded.sha256`) pour autoriser leur remplacement.
Mesurer une image par les dimensions réelles du fichier : `naturalWidth` est corrigé par la densité du srcset.
## Partage et favicon
`resolveOpenGraphImageUrl` essaie `og_webp`, puis le master WebP/AVIF, puis la même chaîne sur le média de repli, puis un WebP statique. L'URL est absolue. Charger **celuga-seo** pour les balises.
`resolveFavicon` accepte un original GIF/ICO/JPEG/PNG, sinon un PNG statique. `/favicon.ico` renvoie une 302 vers lui.
## Régénération
Aucune régénération de masse ne se lance par défaut. Sauvegarder base et stockage, puis remplacer les uploads depuis l'administration sur la cible choisie, ou utiliser le script du projet après lecture de sa sélection des sources et de son nettoyage. Une demande d'audit, de modification du skill ou de changement de profil n'autorise pas son exécution. Un script écrit pour Payload 3 (`sizes`) doit être porté avant usage. Ne jamais supprimer de fichiers par préfixe sans inventaire.
## Vérifier
Tests de rendu et, avec une base locale jetable `*_test`, tests de traitement réels Payload/Sharp : paysage, portrait, petite source, ICO. Contrôler : liste des dérivés, aucun `<picture>`/`<source>` hors exception, `src` AVIF, master dans le srcSet, média sans dérivés, média WebP seul, inline, OG, `currentSrc` par emplacement. Puis la vérification complète du dépôt.
CELUGA_SKILL_EOF
mkdir -p "$dir/agents"
cat > "$dir/agents/openai.yaml" <<'CELUGA_SKILL_EOF'
interface:
display_name: "Celuga Media React"
short_description: "Médias Payload AVIF en un seul <img>, priorité éco-score, proxy /media"
default_prompt: "Utilise $celuga-media-react pour mettre en place ou auditer le pipeline médias Payload de ce projet."
CELUGA_SKILL_EOF
mkdir -p "$dir/references"
cat > "$dir/references/media-pipeline.md" <<'CELUGA_SKILL_EOF'
# Pipeline médias — profil, rendu et éco-score
## Fichiers dans la disposition cible
| Rôle | Fichier |
| --- | --- |
| Collections, profil Sharp, hooks | `apps/cms/src/collections/media.ts` |
| Usages, refus de suppression | `apps/cms/src/lib/media-usages.ts` |
| URLs, candidats, dimensions, focal, favicon, version | `apps/web/src/lib/media.ts` |
| Rendu image | `apps/web/src/components/Media/index.tsx` |
| Inline serveur | `apps/web/src/lib/inline-media.server.ts` |
| Proxys | `apps/web/src/lib/proxy.ts`, `pages/media/[...path].ts`, `pages/documents/[...path].ts` |
| Image de partage | `packages/site-contracts/src/seo.ts` |
| Mesure | `scripts/ecoindex.mjs` |
## Méthode
ÉcoIndex mesure la page à 1920 × 1080, DPR 1, à partir de trois grandeurs : nœuds DOM, requêtes et octets. Autour de 70 points : 55 nœuds ≈ 2,25 pts, 180 Ko ≈ 1 pt, 3 requêtes ≈ 0,5 pt (1 requête ≈ 34 Ko). Le score affiché est arrondi.
Résultats d'un site public : `curl -s "https://bff.ecoindex.fr/api/results/?url=<url>"`. La page `ecoindex.fr/resultat/?id=` est rendue côté client.
1. Lister les `sizes` de chaque emplacement (`rg "sizes=" apps/web/src`).
2. Pour chacun, calculer la largeur CSS à 1920 px et le candidat retenu (le plus petit ≥ largeur, DPR 1).
3. Ajuster pour que les emplacements de l'accueil tombent à moins de ~30 % de pixels au-dessus du besoin.
4. Vérifier sur le HTML servi : nombre de `<picture`, `<source`, `<img`, `data:image`, puis les fichiers réellement chargés.
Les `sizes` suivent les breakpoints, espaces et paddings du CSS. Sur un `object-cover`, ils décrivent la largeur **rendue après recadrage**, souvent plus grande que la colonne.
## Profil
```ts
const sizes = [
["icon", 160],
["thumbnail", 320],
["compact", 480],
["card", 640],
["medium", 960],
["large", 1400],
["xlarge", 1920], // seulement avec un visuel réellement à 100vw
] as const;
export const mediaSharp: SharpCollectionConfig = {
resizeOptions: { width: 2560, height: 2560, fit: "inside", withoutEnlargement: true },
formatOptions: { format: "avif", options: { quality: 85, effort: 4 } },
variants: [
...sizes.map(([name, width]) => ({
name: `${name}_avif`,
width,
formatOptions: { format: "avif" as const, options: { quality: 75, effort: 4 } },
})),
{
name: "large_webp",
width: 1400,
fit: "inside",
withoutEnlargement: true,
formatOptions: { format: "webp", options: { quality: 82 } },
},
{
name: "og_webp",
width: 1200,
height: 630,
position: "centre",
formatOptions: { format: "webp", options: { quality: 82 } },
},
],
};
```
Branchement : `upload.transformers: [sharpTransformer({ sharp, collections: { media: mediaSharp } })]`. Payload omet les dérivés plus larges que la source ; ne pas les remplir avec des copies du master.
Un hook `beforeChange` vide les anciens champs quand le fichier est régénéré (nouvel upload, recadrage, point focal) : Payload supprime les fichiers, leurs métadonnées historiques doivent suivre.
## Rendu
Le helper construit les candidats triés et dédupliqués par largeur réelle, master AVIF compris. Sans AVIF : `large_webp`, anciens WebP, puis original, sans srcSet.
```tsx
<img src={p.src} srcSet={p.srcSet} sizes={p.srcSet ? sizes : undefined} alt={decorative ? "" : alt}
width={p.width} height={p.height} loading={eager ? "eager" : "lazy"} decoding="async"
fetchPriority={priority ? "high" : eager ? "low" : "auto"} />
```
`sizes="auto, …"` n'existe que pour le lazy : sur une image eager, le navigateur retiendrait 100vw et le plus grand fichier, donc retirer `auto`.
| Usage | Sélection |
| --- | --- |
| Pictos, avatars, petits logos | `size="icon"`, inline si ≤ 16 Ko réels |
| Logo d'en-tête | `thumbnail`, prioritaire |
| Cartes | srcSet plafonné à 960 |
| Image principale, hero | `sizes` réels, `priority` seulement si c'est le LCP |
| Média sans AVIF | `large_webp`, sinon original, sans srcSet |
| Open Graph | `og_webp` |
Un fond LCP peut être embarqué dans la feuille de style : la photo du hero n'est alors plus le LCP et ne doit pas le concurrencer.
## Inline
Deux mécanismes, décidés côté serveur dans le composant Astro qui prépare les blocs :
- `inlineSmallMedia` : `icon_avif`, sinon `thumbnail_avif`, dans le `src` si le fichier réel fait au plus 16 Ko. Le composant omet alors `srcSet` et `sizes`.
- `inlineSlotCandidate` : seul le candidat qu'un écran 1x choisit est embarqué, les écrans plus denses chargent leur fichier. Revers connu : sur téléphone, ces logos sont payés deux fois.
Délai de 2 s, téléchargement borné, cache court ; une erreur conserve l'URL normale. Ces octets se répètent dans le HTML de chaque page au lieu d'être mis en cache : c'est accepté. Au-delà de ~30 Ko, le gain s'annule. Un réencodage qui fait passer un fichier au-dessus de 16 Ko le ramène à une requête : surveiller l'effet sur le score.
## Proxy `/media`
GET/HEAD vers `/api/<collection>/file/<nom>` : un seul segment, `/`, `\` et `..` refusés. Query conservée. Transmet range et requêtes conditionnelles ; demande `accept-encoding: identity`. Payload ne gère pas HEAD sur ses fichiers : passer par GET et retirer le corps pour HEAD et 304. Recopier seulement type, disposition, etag, last-modified, ranges et longueur si le flux n'est pas compressé.
Cache : URL versionnée `public, max-age=31536000, immutable` ; sans version `public, max-age=300, must-revalidate` ; statut ≥ 400 `no-store`. Version : `updatedAt` + constante d'encodage.
## Stockage
Fichiers dans le volume du CMS (`apps/cms/storage`), jamais montés côté Web : le Web passe par le proxy. Privé sous `storage/private`. Un média envoyé depuis un CMS local sur une base partagée n'arrive pas seul sur le serveur : soit il est téléversé sur le serveur, soit le projet le versionne (dossier de seed copié au démarrage sans écraser). Purge quotidienne des fichiers orphelins privés.
La suppression d'un média utilisé est refusée (409) ; le bloc « Utilisé dans » et ce refus parcourent la configuration sans liste en dur, et prennent en compte tout nouveau champ upload ou relationship.
## OG et favicon
`resolveOpenGraphImageUrl(media, siteURL, fallback)` : `og_webp` du média → master WebP/AVIF → même chaîne sur le repli → `/brand/og-default.webp`. Dimensions 1200 × 630 annoncées.
`resolveFavicon` : extension GIF/ICO/JPEG/JPG/PNG, sinon PNG statique de 48 px. Le layout émet l'icône directe et `apple-touch-icon` si PNG ; `/favicon.ico` fait une 302. Les règles Google sont distinctes : **celuga-seo**.
## Transition
Une migration de métadonnées ne recrée aucun fichier. Les colonnes conservées gardent leurs anciens fichiers et leur largeur réelle ; les nouvelles restent vides jusqu'au retraitement. Le gain DOM est immédiat, le gain sur les octets arrive après.
## Vérification
- Config : noms et largeurs, `large_webp` non recadré, SVG/ICO préservés.
- Traitement réel Payload/Sharp sur base jetable : paysage, portrait, petite source, sans agrandissement.
- Rendu SSR : un seul `<img>`, `src` AVIF, srcSet avec master, `sizes` seulement avec un srcSet, média WebP seul sans srcSet, inline en `src`.
- Navigateur au DPR 1 et 1920 px : `currentSrc` par emplacement, nombre de requêtes et de nœuds.
- Score comparé sur les mêmes contenus, en distinguant gain DOM immédiat et gain de fichiers après retraitement.
CELUGA_SKILL_EOF
mkdir -p "$HOME/.claude/skills" "$HOME/.codex/skills"
[ -e "$HOME/.claude/skills/celuga-media-react" ] || ln -s "$dir" "$HOME/.claude/skills/celuga-media-react"
[ -e "$HOME/.codex/skills/celuga-media-react" ] || ln -s "$dir" "$HOME/.codex/skills/celuga-media-react"
SKILL.md
---
name: celuga-media-react
description: Pipeline médias Celuga Payload + Astro/React, avec priorité à l'éco-score (ÉcoIndex) du rendu front. Use when changing the media collection, responsive CMS images, the Media component, hero LCP, inline icons, the /media proxy, Open Graph images, favicon selection, re-encoding or media regeneration.
---
# Médias Celuga
Lire [`references/media-pipeline.md`](references/media-pipeline.md) avant de modifier le pipeline. Les valeurs ci-dessous sont la cible ; lire la collection et le helper du projet avant de conclure à un écart.
## Priorité : l'éco-score du rendu front
**Chaque arbitrage média se tranche en faveur de la page servie** (nœuds DOM, requêtes, octets transférés), mesurée par ÉcoIndex : fenêtre 1920 × 1080, **DPR 1**. Un palier de plus coûte un fichier stocké une fois ; un palier manquant se paie à chaque affichage. L'objectif ne porte que sur la page d'accueil, sauf demande contraire.
Leçon à ne pas répéter : réduire les paliers et ajouter `<picture>` + secours WebP a déjà fait perdre 4 points (55 nœuds de plus, photos plus lourdes que leur emplacement, logos sortis du HTML).
Mais la netteté prime sur quelques octets : une photo floue vient le plus souvent d'un `sizes` trop petit sur un `object-cover`, avant la qualité d'encodage. Corriger `sizes` d'abord.
## Contrat
**Rendu : un seul `<img>` AVIF par image** (`src` + `srcSet` + `sizes`), **sans `<picture>` ni `<source>`**. Exception possible : un hero à direction artistique, avec une seule `<source media>`.
| Dérivé | Largeur | Emplacements visés |
| --- | --- | --- |
| `icon_avif` | 160 | pictos, logos, avatars ; inline data-URI |
| `thumbnail_avif` | 320 | vignettes, logo d'en-tête |
| `compact_avif` | 480 | cartes en trois ou quatre colonnes |
| `card_avif` | 640 | deux colonnes, slots de 520 à 640 px |
| `medium_avif` | 960 | texte riche, 50vw |
| `large_avif` | 1400 | image principale, grands visuels |
| `xlarge_avif` | 1920 | seulement s'il existe un visuel plein écran 100vw |
| `large_webp` | ≤ 1400, `fit: inside`, sans agrandissement | `src` d'un média **sans** AVIF |
| `og_webp` | 1200 × 630 recadré | partage social uniquement |
- Master raster AVIF : qualité 85, effort 4, grand côté ≤ 2560 px, sans agrandissement ; inclus dans le srcSet. Dérivés AVIF : **qualité 75**, effort 4 (à 60, photos et schémas se dégradent visiblement). WebP : qualité 82.
- Payload 4 : formats dans `variants`, via `sharpTransformer` et `mediaSharp`. Le groupe `sizes` peut subsister, caché, pour les fichiers historiques. Le helper lit les deux.
- Annoncer dans le srcSet la **largeur réelle** du fichier, jamais celle suggérée par son nom. Ne pas plafonner à 600 px : cela exclut `card_avif`.
- Avant d'ajouter ou retirer un palier, calculer pour chaque `sizes` du projet le candidat retenu à 1920 px et DPR 1. Un écart supérieur à ~30 % de pixels sur un slot de l'accueil justifie un palier.
- **WebP : pas de secours navigateur dans les pages.** Il reste pour OG (les robots sociaux ne lisent pas l'AVIF) et pour un média sans AVIF, alors sans srcSet.
- Inline : petites images en data-URI jusqu'à **16 Ko réels**, car une requête pèse plus que le tiers ajouté par le base64. La taille déclarée sert seulement à écarter les fichiers au-dessus de 64 Ko. Ne pas retirer ce mécanisme pour gagner un point Lighthouse sans décision explicite.
- SVG et ICO restent tels quels. URLs publiques via `/media/…?v=<version>`. Pièces de formulaire : collection privée, jamais par ce proxy.
## Chargement
- `priority` (eager + `fetchpriority=high`) seulement pour l'image LCP réelle ; `eager` (eager + `fetchpriority=low`) pour une image visible qui n'est pas le LCP ; sinon `lazy`.
- Une grosse photo prioritaire fait osciller le LCP simulé par Lighthouse : mesurer plusieurs fois.
- Respecter le point focal (`object-position`).
## Modifier ou intégrer
1. Pour un changement de profil, aligner dans la même modification : collection → types → migration si une colonne change → helper → composants → inline → tests → documentation. Garder les anciens noms en lecture jusqu'au retraitement.
2. Choisir `size` d'après le slot et `sizes` d'après sa largeur CSS réelle.
3. Préserver les médias sans variantes et vérifier que la relation est peuplée.
4. **Mesurer avant de conclure** : compter `<img>`, `<picture>`, `<source>` et data-URI dans le HTML servi, lister les fichiers chargés au DPR 1, lancer la mesure ÉcoIndex du dépôt.
5. Changer le profil ne régénère pas les fichiers. Le gain sur les octets n'existe qu'après retraitement : le dire explicitement.
## Réencoder sur place
Les URL `/media/*?v=` sont en cache `immutable` un an. Tout réencodage sans changement de `updatedAt` impose de **changer la constante de version** du helper (`encodingVersion`), sinon les visiteurs gardent l'ancien fichier. Si des médias versionnés sont copiés au démarrage sans écraser l'existant, déclarer les anciennes empreintes (`superseded.sha256`) pour autoriser leur remplacement.
Mesurer une image par les dimensions réelles du fichier : `naturalWidth` est corrigé par la densité du srcset.
## Partage et favicon
`resolveOpenGraphImageUrl` essaie `og_webp`, puis le master WebP/AVIF, puis la même chaîne sur le média de repli, puis un WebP statique. L'URL est absolue. Charger **celuga-seo** pour les balises.
`resolveFavicon` accepte un original GIF/ICO/JPEG/PNG, sinon un PNG statique. `/favicon.ico` renvoie une 302 vers lui.
## Régénération
Aucune régénération de masse ne se lance par défaut. Sauvegarder base et stockage, puis remplacer les uploads depuis l'administration sur la cible choisie, ou utiliser le script du projet après lecture de sa sélection des sources et de son nettoyage. Une demande d'audit, de modification du skill ou de changement de profil n'autorise pas son exécution. Un script écrit pour Payload 3 (`sizes`) doit être porté avant usage. Ne jamais supprimer de fichiers par préfixe sans inventaire.
## Vérifier
Tests de rendu et, avec une base locale jetable `*_test`, tests de traitement réels Payload/Sharp : paysage, portrait, petite source, ICO. Contrôler : liste des dérivés, aucun `<picture>`/`<source>` hors exception, `src` AVIF, master dans le srcSet, média sans dérivés, média WebP seul, inline, OG, `currentSrc` par emplacement. Puis la vérification complète du dépôt.
agents/openai.yaml
interface:
display_name: "Celuga Media React"
short_description: "Médias Payload AVIF en un seul <img>, priorité éco-score, proxy /media"
default_prompt: "Utilise $celuga-media-react pour mettre en place ou auditer le pipeline médias Payload de ce projet."
references/media-pipeline.md
# Pipeline médias — profil, rendu et éco-score
## Fichiers dans la disposition cible
| Rôle | Fichier |
| --- | --- |
| Collections, profil Sharp, hooks | `apps/cms/src/collections/media.ts` |
| Usages, refus de suppression | `apps/cms/src/lib/media-usages.ts` |
| URLs, candidats, dimensions, focal, favicon, version | `apps/web/src/lib/media.ts` |
| Rendu image | `apps/web/src/components/Media/index.tsx` |
| Inline serveur | `apps/web/src/lib/inline-media.server.ts` |
| Proxys | `apps/web/src/lib/proxy.ts`, `pages/media/[...path].ts`, `pages/documents/[...path].ts` |
| Image de partage | `packages/site-contracts/src/seo.ts` |
| Mesure | `scripts/ecoindex.mjs` |
## Méthode
ÉcoIndex mesure la page à 1920 × 1080, DPR 1, à partir de trois grandeurs : nœuds DOM, requêtes et octets. Autour de 70 points : 55 nœuds ≈ 2,25 pts, 180 Ko ≈ 1 pt, 3 requêtes ≈ 0,5 pt (1 requête ≈ 34 Ko). Le score affiché est arrondi.
Résultats d'un site public : `curl -s "https://bff.ecoindex.fr/api/results/?url=<url>"`. La page `ecoindex.fr/resultat/?id=` est rendue côté client.
1. Lister les `sizes` de chaque emplacement (`rg "sizes=" apps/web/src`).
2. Pour chacun, calculer la largeur CSS à 1920 px et le candidat retenu (le plus petit ≥ largeur, DPR 1).
3. Ajuster pour que les emplacements de l'accueil tombent à moins de ~30 % de pixels au-dessus du besoin.
4. Vérifier sur le HTML servi : nombre de `<picture`, `<source`, `<img`, `data:image`, puis les fichiers réellement chargés.
Les `sizes` suivent les breakpoints, espaces et paddings du CSS. Sur un `object-cover`, ils décrivent la largeur **rendue après recadrage**, souvent plus grande que la colonne.
## Profil
```ts
const sizes = [
["icon", 160],
["thumbnail", 320],
["compact", 480],
["card", 640],
["medium", 960],
["large", 1400],
["xlarge", 1920], // seulement avec un visuel réellement à 100vw
] as const;
export const mediaSharp: SharpCollectionConfig = {
resizeOptions: { width: 2560, height: 2560, fit: "inside", withoutEnlargement: true },
formatOptions: { format: "avif", options: { quality: 85, effort: 4 } },
variants: [
...sizes.map(([name, width]) => ({
name: `${name}_avif`,
width,
formatOptions: { format: "avif" as const, options: { quality: 75, effort: 4 } },
})),
{
name: "large_webp",
width: 1400,
fit: "inside",
withoutEnlargement: true,
formatOptions: { format: "webp", options: { quality: 82 } },
},
{
name: "og_webp",
width: 1200,
height: 630,
position: "centre",
formatOptions: { format: "webp", options: { quality: 82 } },
},
],
};
```
Branchement : `upload.transformers: [sharpTransformer({ sharp, collections: { media: mediaSharp } })]`. Payload omet les dérivés plus larges que la source ; ne pas les remplir avec des copies du master.
Un hook `beforeChange` vide les anciens champs quand le fichier est régénéré (nouvel upload, recadrage, point focal) : Payload supprime les fichiers, leurs métadonnées historiques doivent suivre.
## Rendu
Le helper construit les candidats triés et dédupliqués par largeur réelle, master AVIF compris. Sans AVIF : `large_webp`, anciens WebP, puis original, sans srcSet.
```tsx
<img src={p.src} srcSet={p.srcSet} sizes={p.srcSet ? sizes : undefined} alt={decorative ? "" : alt}
width={p.width} height={p.height} loading={eager ? "eager" : "lazy"} decoding="async"
fetchPriority={priority ? "high" : eager ? "low" : "auto"} />
```
`sizes="auto, …"` n'existe que pour le lazy : sur une image eager, le navigateur retiendrait 100vw et le plus grand fichier, donc retirer `auto`.
| Usage | Sélection |
| --- | --- |
| Pictos, avatars, petits logos | `size="icon"`, inline si ≤ 16 Ko réels |
| Logo d'en-tête | `thumbnail`, prioritaire |
| Cartes | srcSet plafonné à 960 |
| Image principale, hero | `sizes` réels, `priority` seulement si c'est le LCP |
| Média sans AVIF | `large_webp`, sinon original, sans srcSet |
| Open Graph | `og_webp` |
Un fond LCP peut être embarqué dans la feuille de style : la photo du hero n'est alors plus le LCP et ne doit pas le concurrencer.
## Inline
Deux mécanismes, décidés côté serveur dans le composant Astro qui prépare les blocs :
- `inlineSmallMedia` : `icon_avif`, sinon `thumbnail_avif`, dans le `src` si le fichier réel fait au plus 16 Ko. Le composant omet alors `srcSet` et `sizes`.
- `inlineSlotCandidate` : seul le candidat qu'un écran 1x choisit est embarqué, les écrans plus denses chargent leur fichier. Revers connu : sur téléphone, ces logos sont payés deux fois.
Délai de 2 s, téléchargement borné, cache court ; une erreur conserve l'URL normale. Ces octets se répètent dans le HTML de chaque page au lieu d'être mis en cache : c'est accepté. Au-delà de ~30 Ko, le gain s'annule. Un réencodage qui fait passer un fichier au-dessus de 16 Ko le ramène à une requête : surveiller l'effet sur le score.
## Proxy `/media`
GET/HEAD vers `/api/<collection>/file/<nom>` : un seul segment, `/`, `\` et `..` refusés. Query conservée. Transmet range et requêtes conditionnelles ; demande `accept-encoding: identity`. Payload ne gère pas HEAD sur ses fichiers : passer par GET et retirer le corps pour HEAD et 304. Recopier seulement type, disposition, etag, last-modified, ranges et longueur si le flux n'est pas compressé.
Cache : URL versionnée `public, max-age=31536000, immutable` ; sans version `public, max-age=300, must-revalidate` ; statut ≥ 400 `no-store`. Version : `updatedAt` + constante d'encodage.
## Stockage
Fichiers dans le volume du CMS (`apps/cms/storage`), jamais montés côté Web : le Web passe par le proxy. Privé sous `storage/private`. Un média envoyé depuis un CMS local sur une base partagée n'arrive pas seul sur le serveur : soit il est téléversé sur le serveur, soit le projet le versionne (dossier de seed copié au démarrage sans écraser). Purge quotidienne des fichiers orphelins privés.
La suppression d'un média utilisé est refusée (409) ; le bloc « Utilisé dans » et ce refus parcourent la configuration sans liste en dur, et prennent en compte tout nouveau champ upload ou relationship.
## OG et favicon
`resolveOpenGraphImageUrl(media, siteURL, fallback)` : `og_webp` du média → master WebP/AVIF → même chaîne sur le repli → `/brand/og-default.webp`. Dimensions 1200 × 630 annoncées.
`resolveFavicon` : extension GIF/ICO/JPEG/JPG/PNG, sinon PNG statique de 48 px. Le layout émet l'icône directe et `apple-touch-icon` si PNG ; `/favicon.ico` fait une 302. Les règles Google sont distinctes : **celuga-seo**.
## Transition
Une migration de métadonnées ne recrée aucun fichier. Les colonnes conservées gardent leurs anciens fichiers et leur largeur réelle ; les nouvelles restent vides jusqu'au retraitement. Le gain DOM est immédiat, le gain sur les octets arrive après.
## Vérification
- Config : noms et largeurs, `large_webp` non recadré, SVG/ICO préservés.
- Traitement réel Payload/Sharp sur base jetable : paysage, portrait, petite source, sans agrandissement.
- Rendu SSR : un seul `<img>`, `src` AVIF, srcSet avec master, `sizes` seulement avec un srcSet, média WebP seul sans srcSet, inline en `src`.
- Navigateur au DPR 1 et 1920 px : `currentSrc` par emplacement, nombre de requêtes et de nœuds.
- Score comparé sur les mêmes contenus, en distinguant gain DOM immédiat et gain de fichiers après retraitement.