feat: scaffold projet avec stubs et test runner navigateur
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
.drop-active {
|
||||
border-color: #3b82f6 !important;
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
.image-card {
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.image-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.picking-color {
|
||||
cursor: crosshair;
|
||||
outline: 3px solid #7c3aed;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
# Design — Convertisseur Images Shopify
|
||||
|
||||
**Date :** 2026-05-27
|
||||
**Statut :** Approuvé
|
||||
**Projet :** Matériaux Destock — Outil de conversion et détourage photos produits
|
||||
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
Les photos de produits sont prises en entrepôt sur fond blanc. Elles sont au format HEIC (iPhone) et doivent être converties en JPG avec un fond blanc pur et uniforme avant d'être uploadées sur Shopify. Les fichiers sont nommés selon le numéro de produit (ex: `12345.heic`).
|
||||
|
||||
---
|
||||
|
||||
## Objectif
|
||||
|
||||
Une application web client-side (aucun serveur requis) permettant de :
|
||||
1. Importer des fichiers HEIC (10–50 à la fois)
|
||||
2. Les convertir en JPG
|
||||
3. Supprimer/uniformiser le fond blanc automatiquement
|
||||
4. Permettre une sélection manuelle de fond par pipette pour les cas complexes (pots de peinture)
|
||||
5. Prévisualiser le résultat avant/après pour chaque image
|
||||
6. Télécharger toutes les images traitées en ZIP
|
||||
|
||||
---
|
||||
|
||||
## Interface utilisateur
|
||||
|
||||
### Layout — Page unique, 3 zones
|
||||
|
||||
**Zone 1 — Upload**
|
||||
- Zone de drag & drop acceptant les fichiers `.heic`
|
||||
- Bouton "Parcourir" en fallback
|
||||
- Indicateur du nombre de fichiers sélectionnés
|
||||
- Curseur de tolérance global (valeur par défaut : 30, plage : 10–80)
|
||||
|
||||
**Zone 2 — Grille de traitement**
|
||||
- Une carte par image avec :
|
||||
- Miniature "Avant" (original converti en JPG)
|
||||
- Miniature "Après" (fond supprimé)
|
||||
- Nom du fichier (numéro de produit)
|
||||
- Statut : ⏳ En cours / ✅ OK / ⚠️ À vérifier
|
||||
- Bouton 🎨 Pipette — pour sélectionner manuellement la couleur de fond
|
||||
- Curseur de tolérance individuel (hérite du global, ajustable par image)
|
||||
- Bouton 🔄 Retraiter — relance l'algorithme avec les nouveaux paramètres
|
||||
|
||||
**Zone 3 — Export**
|
||||
- Bouton "⬇️ Télécharger tout en ZIP" (actif dès qu'au moins une image est traitée)
|
||||
- Compteur : "X/Y images prêtes"
|
||||
|
||||
### Flux utilisateur
|
||||
|
||||
1. L'utilisateur dépose ses fichiers HEIC
|
||||
2. Chaque fichier est converti en JPG (en mémoire)
|
||||
3. L'algorithme de suppression de fond tourne automatiquement sur chaque image
|
||||
4. L'utilisateur inspecte les avant/après dans la grille
|
||||
5. Sur les images insatisfaisantes : clic sur 🎨 Pipette → clic sur le fond dans l'original → retraitement automatique
|
||||
6. Clic sur "Télécharger tout en ZIP" → fichiers `.jpg` avec nommage d'origine
|
||||
|
||||
---
|
||||
|
||||
## Algorithme de suppression de fond
|
||||
|
||||
### Suppression automatique (fond blanc)
|
||||
|
||||
1. **Flood-fill depuis les 4 coins** de l'image (pixels [0,0], [w-1,0], [0,h-1], [w-1,h-1])
|
||||
2. **Critère d'appartenance au fond** : distance de Chebyshev entre le pixel courant et la couleur de départ ≤ tolérance — soit `max(|R1-R2|, |G1-G2|, |B1-B2|) ≤ tolérance`
|
||||
3. **Propagation** en 4-connexité (haut, bas, gauche, droite)
|
||||
4. **Remplacement** des pixels identifiés comme fond par blanc pur (R:255, G:255, B:255)
|
||||
5. **Anti-aliasing** : léger fondu gaussien sur les pixels de bordure (1–2px) pour éviter les contours crénelés
|
||||
|
||||
### Sélection manuelle par pipette
|
||||
|
||||
- L'utilisateur clique sur le bouton 🎨 d'une image
|
||||
- **Chrome/Edge** : utilisation de l'API `window.EyeDropper` — le curseur devient une pipette, l'utilisateur clique n'importe où sur l'image originale
|
||||
- **Safari/Firefox (fallback)** : clic direct sur le canvas de l'image originale — la couleur du pixel cliqué est capturée
|
||||
- La couleur sélectionnée remplace le point de départ du flood-fill
|
||||
- Le retraitement est lancé automatiquement
|
||||
|
||||
### Paramètre de tolérance
|
||||
|
||||
- Plage : 10 (strict, pour fonds très homogènes) à 80 (permissif, pour fonds légèrement texturés)
|
||||
- Valeur par défaut : 30 (adapté aux photos d'entrepôt sur fond blanc)
|
||||
- Configurable globalement et par image
|
||||
|
||||
---
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Rôle | Technologie |
|
||||
|------|-------------|
|
||||
| Conversion HEIC → JPG | `heic2any` (CDN) |
|
||||
| Manipulation d'image | Canvas API natif (navigateur) |
|
||||
| Interface réactive | `Alpine.js` (CDN, ~15kb) |
|
||||
| Style | `Tailwind CSS` (CDN) |
|
||||
| Export ZIP | `JSZip` (CDN) |
|
||||
| Pipette couleur | `EyeDropper API` + fallback canvas |
|
||||
|
||||
Aucune dépendance serveur. L'application tourne entièrement dans le navigateur. Les images ne quittent jamais la machine de l'utilisateur.
|
||||
|
||||
**Concurrence de traitement :** les images sont traitées 3 par 3 simultanément (via `Promise.all` par lots) pour éviter de bloquer le fil principal du navigateur tout en maximisant la vitesse sur un lot de 50 fichiers.
|
||||
|
||||
---
|
||||
|
||||
## Structure des fichiers
|
||||
|
||||
```
|
||||
convertisseur-images/
|
||||
├── index.html ← Application complète (point d'entrée unique)
|
||||
├── js/
|
||||
│ ├── heicConverter.js ← Conversion HEIC → JPG via heic2any
|
||||
│ ├── bgRemover.js ← Flood-fill, pipette, anti-aliasing
|
||||
│ ├── imageProcessor.js ← Orchestration du pipeline de traitement
|
||||
│ └── zipExporter.js ← Packaging ZIP via JSZip
|
||||
└── css/
|
||||
└── app.css ← Styles complémentaires à Tailwind
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nommage des fichiers de sortie
|
||||
|
||||
- Le nom d'origine est conservé intégralement
|
||||
- Seule l'extension change : `.heic` → `.jpg`
|
||||
- Exemples : `12345.heic` → `12345.jpg`, `REF-ABC.heic` → `REF-ABC.jpg`
|
||||
|
||||
---
|
||||
|
||||
## Qualité de sortie
|
||||
|
||||
- **Format** : JPEG
|
||||
- **Qualité** : 92% (équilibre optimal taille/qualité pour Shopify)
|
||||
- **Dimensions** : conservées à l'identique (pas de redimensionnement)
|
||||
|
||||
---
|
||||
|
||||
## Compatibilité navigateurs
|
||||
|
||||
| Navigateur | Support | Notes |
|
||||
|------------|---------|-------|
|
||||
| Chrome 95+ | ✅ Complet | EyeDropper API disponible |
|
||||
| Edge 95+ | ✅ Complet | EyeDropper API disponible |
|
||||
| Safari | ⚠️ Partiel | Fallback canvas pour la pipette |
|
||||
| Firefox | ⚠️ Partiel | Fallback canvas pour la pipette |
|
||||
|
||||
**Recommandation utilisateur** : utiliser Chrome ou Edge pour l'expérience complète.
|
||||
|
||||
---
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
- **Fichier non-HEIC** : ignoré avec message "Format non supporté"
|
||||
- **Image trop grande (> 50MB)** : avertissement, traitement quand même tenté
|
||||
- **Flood-fill sans résultat** : l'image est marquée ⚠️ avec suggestion d'utiliser la pipette
|
||||
- **Erreur heic2any** : carte marquée ❌ avec message d'erreur lisible
|
||||
|
||||
---
|
||||
|
||||
## Hors périmètre (YAGNI)
|
||||
|
||||
- Redimensionnement des images
|
||||
- Upload direct vers Shopify
|
||||
- Historique des sessions
|
||||
- Compte utilisateur / sauvegarde cloud
|
||||
- Traitement par IA (remove.bg, etc.)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Convertisseur Images — Matériaux Destock</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||||
<link rel="stylesheet" href="css/app.css" />
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen font-sans">
|
||||
<div x-data="imageApp()" class="max-w-6xl mx-auto px-4 py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-6">
|
||||
Convertisseur Images Shopify
|
||||
</h1>
|
||||
|
||||
<!-- Zone 1: Upload -->
|
||||
<div id="upload-zone" class="mb-8">
|
||||
<p class="text-gray-500 text-center">Zone d'upload (à construire)</p>
|
||||
</div>
|
||||
|
||||
<!-- Zone 2: Grille -->
|
||||
<div id="grid-zone" class="mb-8">
|
||||
<p class="text-gray-500 text-center">Grille d'images (à construire)</p>
|
||||
</div>
|
||||
|
||||
<!-- Zone 3: Export -->
|
||||
<div id="export-zone">
|
||||
<p class="text-gray-500 text-center">Zone d'export (à construire)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { convertHeicToJpeg } from './js/heicConverter.js';
|
||||
import { removeBackground, pickColorFromCanvas } from './js/bgRemover.js';
|
||||
import { processBatch } from './js/imageProcessor.js';
|
||||
import { downloadAllAsZip } from './js/zipExporter.js';
|
||||
|
||||
// Helper: charge un Blob comme Image sur un Canvas, applique removeBackground, retourne un Blob JPEG
|
||||
async function processImage(blob, seedColor, tolerance) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||
try {
|
||||
removeBackground(canvas, seedColor ?? null, tolerance);
|
||||
canvas.toBlob(b => {
|
||||
URL.revokeObjectURL(img.src);
|
||||
b ? resolve(b) : reject(new Error('canvas.toBlob a retourné null'));
|
||||
}, 'image/jpeg', 0.92);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
};
|
||||
img.onerror = () => reject(new Error("Impossible de charger l'image"));
|
||||
img.src = URL.createObjectURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
window.imageApp = function () {
|
||||
return {
|
||||
files: [],
|
||||
globalTolerance: 30,
|
||||
|
||||
get readyCount() {
|
||||
return this.files.filter(f => f.status === 'done').length;
|
||||
},
|
||||
|
||||
async handleFiles(fileList) {
|
||||
// TODO: Tâche 7
|
||||
},
|
||||
|
||||
async reprocess(file) {
|
||||
// TODO: Tâche 8
|
||||
},
|
||||
|
||||
async startColorPick(file) {
|
||||
// TODO: Tâche 8
|
||||
},
|
||||
|
||||
async downloadAll() {
|
||||
// TODO: Tâche 9
|
||||
},
|
||||
};
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
export function chebyshevDistance(c1, c2) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
export function createFloodFillMask(imageData, seedColor, tolerance) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
export function applyMaskToImageData(imageData, mask, replacementColor) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
export function softEdges(imageData, mask) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
export function removeBackground(canvas, seedColor, tolerance = 30) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
export async function pickColorFromCanvas(canvas) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function convertHeicToJpeg(file) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function processBatch(items, processFn, concurrency = 3) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function downloadAllAsZip(processedFiles) {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Tests — Convertisseur Images</title>
|
||||
<style>
|
||||
body { font-family: monospace; padding: 20px; background: #f9f9f9; }
|
||||
.pass { color: green; margin: 2px 0; }
|
||||
.fail { color: red; font-weight: bold; margin: 2px 0; }
|
||||
h2 { margin-top: 20px; border-top: 1px solid #ddd; padding-top: 10px; }
|
||||
#summary { font-size: 1.2em; font-weight: bold; margin-top: 24px; padding: 12px;
|
||||
border-radius: 8px; display: inline-block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tests unitaires</h1>
|
||||
<div id="results"></div>
|
||||
<div id="summary"></div>
|
||||
|
||||
<script type="module">
|
||||
let passed = 0, failed = 0;
|
||||
|
||||
function assert(description, condition) {
|
||||
const ok = Boolean(condition);
|
||||
ok ? passed++ : failed++;
|
||||
const el = document.createElement('div');
|
||||
el.className = ok ? 'pass' : 'fail';
|
||||
el.textContent = `${ok ? '✅' : '❌'} ${description}`;
|
||||
document.getElementById('results').appendChild(el);
|
||||
}
|
||||
|
||||
function section(title) {
|
||||
const el = document.createElement('h2');
|
||||
el.textContent = title;
|
||||
document.getElementById('results').appendChild(el);
|
||||
}
|
||||
|
||||
// Helper: construit un ImageData-like depuis un tableau 2D de [R,G,B]
|
||||
function makeImageData(grid) {
|
||||
const h = grid.length, w = grid[0].length;
|
||||
const data = new Uint8ClampedArray(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 4;
|
||||
const [r, g, b] = grid[y][x];
|
||||
data[i] = r; data[i+1] = g; data[i+2] = b; data[i+3] = 255;
|
||||
}
|
||||
}
|
||||
return { data, width: w, height: h };
|
||||
}
|
||||
|
||||
// ── heicConverter ──────────────────────────────────────────────
|
||||
import { convertHeicToJpeg } from './js/heicConverter.js';
|
||||
|
||||
section('heicConverter');
|
||||
|
||||
try {
|
||||
await convertHeicToJpeg(null);
|
||||
assert('convertHeicToJpeg(null) lance TypeError', false);
|
||||
} catch (e) {
|
||||
assert('convertHeicToJpeg(null) lance TypeError', e instanceof TypeError);
|
||||
}
|
||||
|
||||
try {
|
||||
const pngFile = new File([new Uint8Array([137,80,78,71])], 'test.png', { type: 'image/png' });
|
||||
await convertHeicToJpeg(pngFile);
|
||||
assert('convertHeicToJpeg avec PNG lance TypeError', false);
|
||||
} catch (e) {
|
||||
assert('convertHeicToJpeg avec PNG lance TypeError', e instanceof TypeError);
|
||||
}
|
||||
|
||||
// ── bgRemover — chebyshevDistance ──────────────────────────────
|
||||
import { chebyshevDistance, createFloodFillMask, applyMaskToImageData } from './js/bgRemover.js';
|
||||
|
||||
section('bgRemover — chebyshevDistance');
|
||||
|
||||
assert('couleurs identiques → distance 0',
|
||||
chebyshevDistance([255,255,255], [255,255,255]) === 0);
|
||||
assert('blanc vs quasi-blanc (diff max=10) → distance 10',
|
||||
chebyshevDistance([255,255,255], [245,250,252]) === 10);
|
||||
assert('blanc vs gris 128 → distance 127',
|
||||
chebyshevDistance([255,255,255], [128,128,128]) === 127);
|
||||
assert('distance = max des écarts par canal',
|
||||
chebyshevDistance([200,100,50], [180,90,20]) === 30);
|
||||
|
||||
// ── bgRemover — createFloodFillMask ────────────────────────────
|
||||
section('bgRemover — createFloodFillMask');
|
||||
|
||||
const W = [255,255,255], R = [200,50,50], B = [50,50,200];
|
||||
|
||||
const img3x3 = makeImageData([
|
||||
[W, W, W],
|
||||
[W, R, W],
|
||||
[W, W, W],
|
||||
]);
|
||||
const mask3x3 = createFloodFillMask(img3x3, [255,255,255], 30);
|
||||
assert('3×3 : 8 pixels blancs de bordure masqués (somme=8)',
|
||||
Array.from(mask3x3).reduce((a,b) => a+b, 0) === 8);
|
||||
assert('3×3 : pixel rouge central NON masqué',
|
||||
mask3x3[1 * 3 + 1] === 0);
|
||||
|
||||
const img4x4 = makeImageData([
|
||||
[W, W, W, W],
|
||||
[W, B, B, W],
|
||||
[W, B, B, W],
|
||||
[W, W, W, W],
|
||||
]);
|
||||
const mask4x4 = createFloodFillMask(img4x4, [255,255,255], 30);
|
||||
assert('4×4 : 12 pixels blancs masqués',
|
||||
Array.from(mask4x4).reduce((a,b) => a+b, 0) === 12);
|
||||
assert('4×4 : 4 pixels bleus intérieurs non masqués',
|
||||
mask4x4[1*4+1] === 0 && mask4x4[1*4+2] === 0 &&
|
||||
mask4x4[2*4+1] === 0 && mask4x4[2*4+2] === 0);
|
||||
|
||||
// ── bgRemover — applyMaskToImageData ───────────────────────────
|
||||
section('bgRemover — applyMaskToImageData');
|
||||
|
||||
const imgApply = makeImageData([
|
||||
[[200,50,50], [50,50,200]],
|
||||
[[50,50,200], [50,50,200]],
|
||||
]);
|
||||
const maskApply = new Uint8Array([1, 0, 0, 0]);
|
||||
applyMaskToImageData(imgApply, maskApply, [255,255,255]);
|
||||
const d = imgApply.data;
|
||||
assert('pixel masqué → blanc pur',
|
||||
d[0] === 255 && d[1] === 255 && d[2] === 255);
|
||||
assert('pixel non masqué → inchangé (bleu)',
|
||||
d[4] === 50 && d[5] === 50 && d[6] === 200);
|
||||
|
||||
// ── imageProcessor — processBatch ──────────────────────────────
|
||||
import { processBatch } from './js/imageProcessor.js';
|
||||
|
||||
section('imageProcessor — processBatch');
|
||||
|
||||
const resultsA = [];
|
||||
await processBatch([1,2,3,4,5], async (item) => { resultsA.push(item * 2); }, 3);
|
||||
assert('processBatch traite les 5 éléments', resultsA.length === 5);
|
||||
assert('processBatch retourne les bons résultats',
|
||||
[2,4,6,8,10].every(v => resultsA.includes(v)));
|
||||
|
||||
let concurrent = 0, maxConcurrent = 0;
|
||||
await processBatch([1,2,3,4,5,6], async () => {
|
||||
concurrent++;
|
||||
maxConcurrent = Math.max(maxConcurrent, concurrent);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
concurrent--;
|
||||
}, 3);
|
||||
assert('processBatch ne dépasse jamais 3 en simultané', maxConcurrent <= 3);
|
||||
|
||||
// ── zipExporter — downloadAllAsZip ─────────────────────────────
|
||||
import { downloadAllAsZip } from './js/zipExporter.js';
|
||||
|
||||
section('zipExporter — downloadAllAsZip');
|
||||
|
||||
let capturedHref = null;
|
||||
const origAppend = document.body.appendChild.bind(document.body);
|
||||
const origRemove = document.body.removeChild.bind(document.body);
|
||||
document.body.appendChild = (el) => {
|
||||
if (el.tagName === 'A') { capturedHref = el.href; el.click = () => {}; return el; }
|
||||
return origAppend(el);
|
||||
};
|
||||
document.body.removeChild = (el) => { try { origRemove(el); } catch {} };
|
||||
|
||||
await downloadAllAsZip([
|
||||
{ name: '12345.jpg', blob: new Blob(['data1'], { type: 'image/jpeg' }) },
|
||||
{ name: '67890.jpg', blob: new Blob(['data2'], { type: 'image/jpeg' }) },
|
||||
]);
|
||||
document.body.appendChild = origAppend;
|
||||
document.body.removeChild = origRemove;
|
||||
|
||||
assert('downloadAllAsZip génère une URL blob:',
|
||||
capturedHref !== null && capturedHref.startsWith('blob:'));
|
||||
|
||||
// ── Résumé ─────────────────────────────────────────────────────
|
||||
const summary = document.getElementById('summary');
|
||||
summary.textContent = `${passed} passé(s), ${failed} échoué(s)`;
|
||||
summary.style.background = failed === 0 ? '#dcfce7' : '#fee2e2';
|
||||
summary.style.color = failed === 0 ? '#166534' : '#991b1b';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user