Traitement d’images avec Claude Code : Sharp, Canvas, WebP/AVIF et validation
Implémentez un traitement d’images sûr avec Claude Code : Sharp, Canvas, EXIF, WebP/AVIF, jobs et tests.
Le traitement d’images paraît souvent simple dans une issue produit : l’utilisateur envoie une photo, l’application crée une miniature, la page devient plus rapide. En production, c’est une frontière technique complète. Il faut valider l’upload, éviter les noms de fichiers dangereux, supprimer l’EXIF, redimensionner, compresser, choisir WebP ou AVIF, déplacer les traitements lourds en arrière-plan, protéger la vie privée et tester.
Claude Code est utile parce que cette fonctionnalité touche plusieurs fichiers à la fois. Le risque vient d’une demande trop vague. “Optimise les images uploadées” peut générer du code qui fait confiance à file.type, publie le nom original, conserve les métadonnées, convertit tout en AVIF dans la requête et ne teste jamais une photo de téléphone tournée.
Pour relire l’implémentation, partez des sources officielles : documentation Claude Code, Sharp resize API, Sharp output API, MDN File API, MDN Canvas toBlob et OWASP File Upload Cheat Sheet. Si le navigateur commence à faire trop de calculs, lisez aussi le guide interne Web Worker avec Claude Code.
Fixer la frontière de traitement
Ne commencez pas par le format. Commencez par décider où se trouve chaque responsabilité.
| Emplacement | Bon usage | À éviter |
|---|---|---|
| Navigateur | Aperçu, léger resize, baisse du trafic upload | Validation fiable, AVIF massif, décisions de confidentialité |
| Serveur synchrone | MIME, magic bytes, dimensions, EXIF, petite miniature | Nombreuses variantes, encodage AVIF lent |
| Job arrière-plan | Catalogue produit, régénération CMS, migration | Réponse d’upload immédiate |
Le modèle robuste est : le navigateur améliore l’expérience, le serveur revalide toujours, les variantes coûteuses partent en job. accept="image/*" et file.type aident l’interface, mais ne sont pas une limite de sécurité.
flowchart LR
Browser["Browser preview / optional resize"]
Upload["Upload endpoint"]
Validate["Magic bytes, size, dimensions"]
Store["Private raw storage"]
Job["Background variants"]
Public["Public WebP/JPEG/AVIF"]
Browser --> Upload
Upload --> Validate
Validate --> Store
Store --> Job
Job --> Public
Dans le prompt Claude Code, écrivez les règles : valider MIME et magic bytes côté serveur, ne jamais utiliser le nom original dans une URL publique, appliquer rotate(), ne pas appeler .withMetadata() sans raison précise, rendre AVIF optionnel. Ces limites évitent beaucoup de code plausible mais fragile.
Cas d’usage produit
Premier cas : ecommerce ou marketplace. Les vendeurs envoient de grandes photos depuis un mobile. Il faut une miniature carrée, une image de carte, une image de détail et parfois une image sociale. Une compression excessive abîme la confiance, car l’acheteur veut inspecter la matière, la couleur, l’étiquette ou l’état. WebP est souvent le point de départ ; AVIF arrive après mesure.
Deuxième cas : avatar ou photo d’équipe. Les points importants sont le recadrage carré, l’URL sûre et la confidentialité. Un fichier nommé client-contrat-final.png ne doit pas devenir un chemin public. Même si le navigateur a déjà réduit l’image, la sortie serveur doit supprimer l’EXIF.
Troisième cas : captures d’écran pour blog, support ou formation. Le texte doit rester lisible. Une capture plus légère mais illisible est une mauvaise optimisation. Si vos images alimentent aussi des documents, consultez la génération de PDF avec Claude Code.
Quatrième cas : pièces jointes privées SaaS, comme factures, vérifications, captures de support ou documents d’admin. Elles ne doivent pas être placées dans public/uploads. Il faut stockage privé, contrôle d’accès, règle de suppression et journal d’audit.
Installation
Les exemples supposent Node.js 20 ou plus. Les mêmes modules peuvent être reliés à Next.js, Express, Hono, Astro API routes ou un worker de file.
npm i sharp file-type p-limit
npm i -D tsx typescript @types/node
mkdir -p src public/uploads
Ajoutez une commande dédiée pour lancer ces tests en CI.
{
"scripts": {
"test:images": "node --import tsx --test src/**/*.test.ts"
}
}
Validation d’upload et noms sûrs
Ce module est la frontière de confiance. Il ne croit pas l’extension, inspecte les magic bytes, lit les dimensions avec Sharp, refuse les fichiers trop lourds et bloque les images animées ou multipages pour ce flux.
// src/image-policy.ts
import { randomUUID } from "node:crypto";
import { fileTypeFromBuffer } from "file-type";
import sharp from "sharp";
const MAX_BYTES = 6 * 1024 * 1024;
const MAX_PIXELS = 24_000_000;
const EXTENSION_BY_MIME = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/webp": ".webp",
"image/avif": ".avif",
} as const;
export type MimeType = keyof typeof EXTENSION_BY_MIME;
export type ImageUploadInfo = {
mime: MimeType;
extension: string;
width: number;
height: number;
bytes: number;
originalName: string;
};
function isAllowedMime(mime: string): mime is MimeType {
return mime in EXTENSION_BY_MIME;
}
export async function assertImageUpload(
buffer: Buffer,
originalName = "upload",
): Promise<ImageUploadInfo> {
if (buffer.byteLength === 0) {
throw new Error("Empty file");
}
if (buffer.byteLength > MAX_BYTES) {
throw new Error("Image must be 6 MB or smaller");
}
const detected = await fileTypeFromBuffer(buffer);
if (!detected || !isAllowedMime(detected.mime)) {
throw new Error("Unsupported image type");
}
const metadata = await sharp(buffer, { failOn: "error" }).metadata();
if (!metadata.width || !metadata.height) {
throw new Error("Image dimensions could not be read");
}
if (metadata.pages && metadata.pages > 1) {
throw new Error("Animated images are not allowed here");
}
const pixels = metadata.width * metadata.height;
if (pixels > MAX_PIXELS) {
throw new Error("Image dimensions are too large");
}
return {
mime: detected.mime,
extension: EXTENSION_BY_MIME[detected.mime],
width: metadata.width,
height: metadata.height,
bytes: buffer.byteLength,
originalName,
};
}
export function safeImageName(mime: MimeType): string {
return `${randomUUID()}${EXTENSION_BY_MIME[mime]}`;
}
Le nom original peut être stocké comme valeur privée d’affichage, mais pas comme URL publique. Les noms aléatoires évitent aussi les collisions et les surprises Unicode.
Sharp pour resize, compression et EXIF
Sharp est un choix solide côté serveur Node.js. Le détail à ne pas manquer : appliquez l’orientation avec rotate(), puis évitez .withMetadata() sauf besoin explicite. Pour des images web publiques, ne pas conserver les métadonnées est généralement plus sûr.
// src/optimize-image.ts
import { mkdir } from "node:fs/promises";
import path from "node:path";
import sharp from "sharp";
type Variant = {
kind: "thumb" | "card" | "hero";
width: number;
height?: number;
};
const VARIANTS: Variant[] = [
{ kind: "thumb", width: 320, height: 320 },
{ kind: "card", width: 640 },
{ kind: "hero", width: 1280 },
];
export type OptimizedImage = {
src: string;
width: number;
height: number;
bytes: number;
format: "webp" | "avif";
};
export async function optimizeImage(
buffer: Buffer,
outputDir: string,
baseName: string,
makeAvif = false,
): Promise<OptimizedImage[]> {
await mkdir(outputDir, { recursive: true });
const results: OptimizedImage[] = [];
for (const variant of VARIANTS) {
const resized = sharp(buffer)
.rotate()
.resize({
width: variant.width,
height: variant.height,
fit: variant.height ? "cover" : "inside",
withoutEnlargement: true,
});
const webpName = `${baseName}-${variant.kind}.webp`;
const webpInfo = await resized
.clone()
.webp({ quality: 78, effort: 4 })
.toFile(path.join(outputDir, webpName));
results.push({
src: `/uploads/${webpName}`,
width: webpInfo.width,
height: webpInfo.height,
bytes: webpInfo.size,
format: "webp",
});
if (makeAvif) {
const avifName = `${baseName}-${variant.kind}.avif`;
const avifInfo = await resized
.clone()
.avif({ quality: 45, effort: 4 })
.toFile(path.join(outputDir, avifName));
results.push({
src: `/uploads/${avifName}`,
width: avifInfo.width,
height: avifInfo.height,
bytes: avifInfo.size,
format: "avif",
});
}
}
return results;
}
AVIF peut être très efficace, mais ce n’est pas une obligation. L’encodage est plus coûteux et le gain réel dépend des images. Pour un catalogue, mesurez avant de convertir tout le stock.
Endpoint Next.js
Voici un endpoint minimal App Router. En production, remplacez public/uploads par un stockage privé si les images ne sont pas publiques.
// app/api/images/route.ts
import path from "node:path";
import { NextResponse } from "next/server";
import { assertImageUpload, safeImageName } from "@/src/image-policy";
import { optimizeImage } from "@/src/optimize-image";
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("image");
if (!(file instanceof File)) {
return NextResponse.json(
{ error: "image field is required" },
{ status: 400 },
);
}
const buffer = Buffer.from(await file.arrayBuffer());
const upload = await assertImageUpload(buffer, file.name);
const storedName = safeImageName(upload.mime);
const baseName = storedName.replace(/\.[^.]+$/, "");
const variants = await optimizeImage(
buffer,
path.join(process.cwd(), "public", "uploads"),
baseName,
false,
);
return NextResponse.json({
original: {
width: upload.width,
height: upload.height,
bytes: upload.bytes,
},
variants,
});
}
Cet exemple convient aux médias publics. Les factures, documents clients et pièces de support exigent un modèle d’accès séparé.
Resize dans le navigateur
Le resize côté navigateur réduit le trafic et accélère l’aperçu. Il ne remplace pas la validation serveur.
// src/resize-in-browser.ts
export async function resizeInBrowser(
file: File,
maxSide = 1600,
): Promise<File> {
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, maxSide / Math.max(bitmap.width, bitmap.height));
const width = Math.round(bitmap.width * scale);
const height = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Canvas 2D context is not available");
}
context.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(result) => {
if (result) resolve(result);
else reject(new Error("Canvas export failed"));
},
"image/webp",
0.82,
);
});
const outputName = file.name.replace(/\.[^.]+$/, ".webp");
return new File([blob], outputName, {
type: blob.type || "image/webp",
lastModified: Date.now(),
});
}
La réexportation Canvas supprime souvent beaucoup de métadonnées, mais la confidentialité doit être vérifiée côté serveur. La génération AVIF dans le navigateur reste trop dépendante de l’environnement.
Jobs et budget de performance
Une requête d’upload doit rester courte : validation, stockage, miniature nécessaire à la réponse. Les images de détail, OGP, AVIF et régénérations massives doivent partir en tâche arrière-plan.
Comme budget initial, visez 80 Ko pour un avatar 320x320, 120 Ko pour une carte de largeur 640 et 250 Ko pour un hero de largeur 1280. Les valeurs changent selon le produit, mais un budget empêche Claude Code de choisir des réglages trop lourds.
// src/batch-optimize.ts
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import pLimit from "p-limit";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";
export async function batchOptimize(inputDir: string, outputDir: string) {
const files = await readdir(inputDir);
const limit = pLimit(3);
const jobs = files.map((file) =>
limit(async () => {
const sourcePath = path.join(inputDir, file);
const buffer = await readFile(sourcePath);
const upload = await assertImageUpload(buffer, file);
const baseName = safeImageName(upload.mime).replace(/\.[^.]+$/, "");
const variants = await optimizeImage(buffer, outputDir, baseName, true);
return {
file,
variants: variants.length,
};
}),
);
return Promise.allSettled(jobs);
}
Avec une vraie file, enregistrez job ID, image source, variant, raison d’échec, nombre de reprises et chemins générés. Sinon, les fichiers finissent par ne plus correspondre à aucune ligne de base de données.
Échecs fréquents
Les erreurs sont concrètes : faire confiance à file.type, publier le nom original, oublier l’orientation EXIF, conserver les métadonnées avec .withMetadata(), générer AVIF dans la requête, ou dégrader une capture de tutoriel au point que le texte ne se lit plus.
Il existe aussi un échec business : optimiser l’image tout en cassant la conversion. Si une capture de cours est floue, le CTA vers un modèle payant perd en crédibilité. Si l’image produit arrive trop tard, l’utilisateur voit le bouton d’achat sans comprendre le produit. Pour suivre chargement image, CTA et achat ensemble, consultez analytics avec Claude Code.
Tester le comportement
Générez une image dans le test pour éviter les fixtures fragiles.
// src/image-policy.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import sharp from "sharp";
import { assertImageUpload, safeImageName } from "./image-policy";
import { optimizeImage } from "./optimize-image";
test("validates and optimizes a generated image", async () => {
const input = await sharp({
create: {
width: 1200,
height: 800,
channels: 3,
background: "#38bdf8",
},
})
.jpeg()
.toBuffer();
const info = await assertImageUpload(input, "masa-profile.jpg");
assert.equal(info.mime, "image/jpeg");
assert.equal(info.width, 1200);
const safeName = safeImageName(info.mime);
assert.match(safeName, /^[a-f0-9-]+\.jpg$/);
const outDir = await mkdtemp(path.join(tmpdir(), "images-"));
const baseName = safeName.replace(/\.[^.]+$/, "");
const variants = await optimizeImage(input, outDir, baseName, false);
assert.equal(variants.length, 3);
assert.ok(variants.every((item) => item.bytes > 0));
const thumb = await sharp(
path.join(outDir, `${baseName}-thumb.webp`),
).metadata();
assert.equal(thumb.width, 320);
assert.equal(thumb.height, 320);
assert.equal(thumb.exif, undefined);
});
La vérification manuelle doit couvrir mobile, réseau lent, fichier cassé, fichier géant, photo verticale, PNG transparent et capture avec petit texte. Demandez ensuite à Claude Code une revue limitée à validation, noms, métadonnées, CPU, fallback et tests manquants.
CTA et note de vérification
Le traitement d’images protège la monétisation quand il aide l’utilisateur à croire ce qu’il voit : produit rapide, capture lisible, avatar sans fuite, OGP stable. Commencez avec la fiche gratuite Claude Code, utilisez les produits ClaudeCodeLab pour les prompts et modèles réutilisables, puis passez à training / consultation si votre équipe veut intégrer règles d’upload, revues et scripts de vérification dans un vrai dépôt.
Le 2 juin 2026, Masa a testé ce flux dans un petit projet Next.js. Le meilleur résultat est venu d’un prompt qui fixait d’abord la frontière : resize navigateur optionnel, validation serveur obligatoire, AVIF optionnel, nom original jamais public. Le prompt vague “crée un upload d’images” produisait une validation file.type, des noms publics originaux, pas de gestion d’orientation et de l’AVIF synchrone. Les budgets et les cas d’échec donnés avant le code ont nettement amélioré la qualité.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.