feat: ZIP import, rotation, feather slider, lightbox + config déploiement

- Import ZIP produits (HEIC/JPG/PNG, structure dossiers conservée)
- Rotation des images ±90° avec aperçu CSS et export canvas
- Slider récupération des bords (feather 0–5 px)
- Lightbox plein écran (clic miniature, Échap, fond noir)
- Fix timeout HEIC 30s → 120s
- Fix MIME type blobs JSZip pour heic2any
- Dockerfile nginx:alpine + docker-compose.yml pour déploiement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:18:18 +02:00
parent 7f6ca4c3de
commit 86d9b351b1
9 changed files with 368 additions and 81 deletions
+50 -22
View File
@@ -1,13 +1,11 @@
/**
* Suppression de fond par IA — @imgly/background-removal (WebAssembly).
* Le modèle (~40 Mo) est téléchargé depuis le CDN au premier lancement
* puis mis en cache automatiquement dans le navigateur.
* Le modèle est téléchargé depuis staticimgly.com au premier lancement.
*/
const CDN_VERSION = '1.7.0';
const CDN = `https://cdn.jsdelivr.net/npm/@imgly/background-removal@${CDN_VERSION}/dist/`;
// Chargement paresseux de la librairie (évite de bloquer le rendu initial)
let _removeBg = null;
async function loadLib() {
@@ -18,18 +16,17 @@ async function loadLib() {
}
/**
* Supprime le fond d'un Blob image via IA.
* Retourne un nouveau Blob JPEG avec fond blanc pur.
* Supprime le fond d'un Blob image via IA puis composite sur blanc.
* @param {Blob} blob — image source (JPEG)
* @param {function} [onProgress] — callback({ key, percent })
* @param {number} [feather=2] — récupération de bords en px (0 = strict, 5 = doux)
* @returns {Promise<Blob>} Blob JPEG fond blanc
*/
export async function removeBackground(blob, onProgress) {
export async function removeBackground(blob, onProgress, feather = 2) {
const removeBg = await loadLib();
// Détourage IA → PNG avec transparence
const pngBlob = await removeBg(blob, {
// publicPath omis → utilise le CDN par défaut de img.ly (staticimgly.com)
// publicPath omis → utilise staticimgly.com par défaut
output: {
format: 'image/png',
type: 'foreground',
@@ -42,32 +39,63 @@ export async function removeBackground(blob, onProgress) {
},
});
// Composite sur fond blanc → JPEG
return compositeOnWhite(pngBlob);
return compositeOnWhite(pngBlob, feather);
}
/**
* Composite un PNG transparent sur fond blanc, retourne un Blob JPEG.
* Composite un PNG transparent sur fond blanc.
* Si feather > 0, dilate légèrement le contour du sujet (récupère les bords
* que l'IA a pu couper trop agressivement).
* @param {Blob} pngBlob
* @returns {Promise<Blob>}
* @param {number} feather — rayon de dilation en pixels (05)
* @returns {Promise<Blob>} JPEG fond blanc
*/
async function compositeOnWhite(pngBlob) {
async function compositeOnWhite(pngBlob, feather) {
const objectUrl = URL.createObjectURL(pngBlob);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
const { width, height } = img;
// ── Canvas intermédiaire : premier plan avec bords optionnels ──
const fgCanvas = document.createElement('canvas');
fgCanvas.width = width;
fgCanvas.height = height;
const fgCtx = fgCanvas.getContext('2d');
// 1. Dessin net du premier plan
fgCtx.drawImage(img, 0, 0);
if (feather > 0) {
// 2. On dessine une version floutée DERRIÈRE le dessin net
// (destination-over = "derrière le contenu existant")
// → les zones transparentes proches des bords du sujet deviennent
// semi-transparentes, récupérant les détails coupés par l'IA.
fgCtx.globalCompositeOperation = 'destination-over';
fgCtx.filter = `blur(${feather}px)`;
fgCtx.drawImage(img, 0, 0);
fgCtx.filter = 'none';
fgCtx.globalCompositeOperation = 'source-over';
}
// ── Canvas final : fond blanc + premier plan ───────────────────
const out = document.createElement('canvas');
out.width = width;
out.height = height;
const ctx = out.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
canvas.toBlob(b => {
URL.revokeObjectURL(img.src);
ctx.fillRect(0, 0, width, height);
ctx.drawImage(fgCanvas, 0, 0);
URL.revokeObjectURL(objectUrl);
out.toBlob(b => {
b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'));
}, 'image/jpeg', 0.92);
};
img.onerror = () => reject(new Error('Impossible de charger le résultat IA'));
img.src = URL.createObjectURL(pngBlob);
img.src = objectUrl;
});
}