| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Batch AI & GPU Upscaler (Fix Tout)</title> |
| |
| <script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.1/dist/ort.min.js"></script> |
| <style> |
| :root { |
| --bg: #0d1117; |
| --panel: #161b22; |
| --border: #30363d; |
| --text: #c9d1d9; |
| --heading: #f0f6fc; |
| --primary: #238636; |
| --primary-hover: #2ea043; |
| --accent: #58a6ff; |
| --danger: #f85149; |
| --warning: #d29922; |
| } |
| |
| * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } |
| |
| body { background-color: var(--bg); color: var(--text); padding: 2rem 1rem; display: flex; justify-content: center; min-height: 100vh; } |
| |
| .container { max-width: 950px; width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 2rem; box-shadow: 0 8px 24px rgba(0,0,0,0.5); } |
| |
| h1 { color: var(--heading); font-size: 1.5rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 8px; } |
| p.subtitle { color: #8b949e; font-size: 0.9rem; margin-bottom: 1.5rem; } |
| |
| .status-box { |
| background: #010409; |
| border: 1px solid var(--border); |
| padding: 1.2rem; |
| border-radius: 6px; |
| margin-bottom: 1.5rem; |
| display: flex; |
| flex-direction: column; |
| gap: 12px; |
| } |
| |
| .btn-group { display: flex; gap: 10px; flex-wrap: wrap; } |
| |
| .dropzone { |
| border: 2px dashed var(--border); |
| border-radius: 6px; |
| padding: 2.5rem 1rem; |
| text-align: center; |
| cursor: pointer; |
| background: rgba(22, 27, 34, 0.5); |
| margin-bottom: 1.5rem; |
| transition: all 0.2s ease; |
| } |
| .dropzone:hover, .dropzone.dragover { border-color: var(--accent); background: rgba(88, 166, 255, 0.1); } |
| |
| .btn { |
| background: var(--primary); |
| color: #fff; |
| border: none; |
| padding: 10px 18px; |
| font-size: 0.95rem; |
| font-weight: 600; |
| border-radius: 6px; |
| cursor: pointer; |
| display: inline-flex; |
| align-items: center; |
| gap: 6px; |
| transition: background 0.2s; |
| } |
| .btn:hover { background: var(--primary-hover); } |
| .btn:disabled { background: #21262d; color: #484f58; cursor: not-allowed; } |
| .btn-blue { background: #1f6feb; } |
| .btn-blue:hover { background: #388bfd; } |
| .btn-grey { background: #30363d; } |
| .btn-grey:hover { background: #484f58; } |
| |
| .controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; } |
| |
| .image-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } |
| |
| .card { background: #0d1117; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; display: flex; flex-direction: column; } |
| .preview-box { height: 180px; background: #010409; display: flex; align-items: center; justify-content: center; position: relative; } |
| .preview-box img { max-width: 100%; max-height: 100%; object-fit: contain; } |
| |
| .badge { position: absolute; top: 8px; right: 8px; padding: 4px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: bold; } |
| .status-waiting { background: #21262d; color: #8b949e; } |
| .status-processing { background: #1f6feb; color: #fff; } |
| .status-done { background: #238636; color: #fff; } |
| .status-fallback { background: #d29922; color: #fff; } |
| .status-error { background: #da3633; color: #fff; } |
| |
| .card-body { padding: 12px; display: flex; flex-direction: column; gap: 8px; } |
| .download-btn { background: #21262d; border: 1px solid var(--border); color: var(--accent); text-decoration: none; padding: 8px 12px; border-radius: 4px; font-size: 0.85rem; text-align: center; font-weight: 600; display: block; } |
| .download-btn:hover { background: #30363d; border-color: var(--accent); } |
| |
| .info-tag { font-size: 0.8rem; padding: 2px 6px; border-radius: 4px; background: rgba(88, 166, 255, 0.15); color: var(--accent); display: inline-block; width: fit-content; } |
| </style> |
| </head> |
| <body> |
|
|
| <div class="container"> |
| <h1>🚀 Batch AI & GPU Upscaler <span class="info-tag">100% Client-Side</span></h1> |
| <p class="subtitle">Upscale d'images en haute résolution via Modèle IA ONNX ou Algorithme Shader GPU WebGL local.</p> |
|
|
| <div class="status-box"> |
| <div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px;"> |
| <div> |
| <strong>État du Moteur :</strong> |
| <span id="modelStatus" style="color:#d29922; font-weight:bold;">Mode GPU WebGL prêt (IA optionnelle)</span> |
| </div> |
| <div class="btn-group"> |
| <button id="downloadModelBtn" class="btn btn-blue" onclick="loadAIModelCDN()">📥 Charger Modèle IA CDN</button> |
| <button class="btn btn-grey" onclick="document.getElementById('onnxFileInput').click()">📂 Charger .onnx Local</button> |
| </div> |
| <input type="file" id="onnxFileInput" accept=".onnx" style="display:none;" onchange="loadLocalModelFile(this.files[0])"> |
| </div> |
| </div> |
|
|
| <div class="dropzone" id="dropzone"> |
| <p style="font-size:1.1rem; font-weight:600; color:var(--heading);">Glissez-déposez vos images ici ou cliquez pour parcourir</p> |
| <p style="font-size: 0.85rem; color: #8b949e; margin-top: 8px;">PNG, JPG, WebP | Multi-imports illimités (plusieurs fichiers ou ajouts successifs)</p> |
| <input type="file" id="fileInput" multiple accept="image/*" style="display: none;"> |
| </div> |
|
|
| <div class="controls"> |
| <button id="startBtn" class="btn" onclick="startBatchProcessing()" disabled>🚀 Lancer l'Upscale Batch</button> |
| <span id="statsText" style="color: #8b949e; font-weight:500;">0 fichier(s) dans la liste</span> |
| </div> |
|
|
| <div class="image-grid" id="imageGrid"></div> |
| </div> |
|
|
| <canvas id="glCanvas" style="display:none;"></canvas> |
|
|
| <script> |
| let session = null; |
| let queue = []; |
| let isProcessing = false; |
| |
| |
| const MODEL_URL = "https://raw.githubusercontent.com/onnx/models/main/validated/vision/super_resolution/sub_pixel_cnn_2016/model/super-resolution-10.onnx"; |
| |
| const fileInput = document.getElementById('fileInput'); |
| const dropzone = document.getElementById('dropzone'); |
| const startBtn = document.getElementById('startBtn'); |
| const modelStatus = document.getElementById('modelStatus'); |
| |
| |
| |
| |
| async function loadAIModelCDN() { |
| modelStatus.innerText = "Téléchargement du modèle IA..."; |
| modelStatus.style.color = "#58a6ff"; |
| try { |
| ort.env.wasm.numThreads = 2; |
| session = await ort.InferenceSession.create(MODEL_URL, { |
| executionProviders: ['webgl', 'wasm', 'cpu'] |
| }); |
| modelStatus.innerHTML = "✅ Modèle IA ONNX prêt en mémoire (WebGL/WASM)"; |
| modelStatus.style.color = "#2ea043"; |
| updateControls(); |
| } catch (err) { |
| console.error("Erreur chargement CDN:", err); |
| modelStatus.innerHTML = "⚠️ Mode GPU WebGL actif (Chargement IA CDN bloqué par le navigateur)"; |
| modelStatus.style.color = "#d29922"; |
| updateControls(); |
| } |
| } |
| |
| async function loadLocalModelFile(file) { |
| if (!file) return; |
| modelStatus.innerText = "Chargement du fichier ONNX local..."; |
| modelStatus.style.color = "#58a6ff"; |
| try { |
| const buffer = await file.arrayBuffer(); |
| session = await ort.InferenceSession.create(buffer, { |
| executionProviders: ['webgl', 'wasm', 'cpu'] |
| }); |
| modelStatus.innerHTML = `✅ Modèle IA local prêt (${file.name})`; |
| modelStatus.style.color = "#2ea043"; |
| updateControls(); |
| } catch (err) { |
| console.error("Erreur modèle local:", err); |
| modelStatus.innerHTML = "❌ Fichier .onnx invalide"; |
| modelStatus.style.color = "#f85149"; |
| } |
| } |
| |
| |
| |
| |
| dropzone.addEventListener('click', () => fileInput.click()); |
| |
| fileInput.addEventListener('change', (e) => { |
| if (e.target.files.length > 0) { |
| handleFiles(e.target.files); |
| fileInput.value = ''; |
| } |
| }); |
| |
| ['dragenter', 'dragover'].forEach(n => dropzone.addEventListener(n, (e) => { e.preventDefault(); dropzone.classList.add('dragover'); })); |
| ['dragleave', 'drop'].forEach(n => dropzone.addEventListener(n, (e) => { e.preventDefault(); dropzone.classList.remove('dragover'); })); |
| |
| dropzone.addEventListener('drop', (e) => { |
| if (e.dataTransfer && e.dataTransfer.files.length > 0) { |
| handleFiles(e.dataTransfer.files); |
| } |
| }); |
| |
| function handleFiles(files) { |
| for (let file of files) { |
| if (!file.type.startsWith('image/')) continue; |
| const id = 'img_' + Math.random().toString(36).substr(2, 9); |
| const item = { |
| id: id, |
| file: file, |
| status: 'waiting', |
| originalUrl: URL.createObjectURL(file), |
| upscaledUrl: null |
| }; |
| queue.push(item); |
| renderCard(item); |
| } |
| updateControls(); |
| } |
| |
| function renderCard(item) { |
| const card = document.createElement('div'); |
| card.className = 'card'; |
| card.id = item.id; |
| card.innerHTML = ` |
| <div class="preview-box"> |
| <img src="${item.originalUrl}" id="img_prev_${item.id}"> |
| <span class="badge status-waiting" id="badge_${item.id}">En attente</span> |
| </div> |
| <div class="card-body"> |
| <div style="font-size:0.85rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color: var(--heading);" title="${item.file.name}">${item.file.name}</div> |
| <div id="action_${item.id}"></div> |
| </div> |
| `; |
| document.getElementById('imageGrid').appendChild(card); |
| } |
| |
| function updateControls() { |
| document.getElementById('statsText').innerText = `${queue.length} fichier(s) chargé(s)`; |
| const hasWaiting = queue.some(i => i.status === 'waiting'); |
| startBtn.disabled = !hasWaiting || isProcessing; |
| } |
| |
| |
| |
| |
| async function startBatchProcessing() { |
| if (isProcessing) return; |
| isProcessing = true; |
| startBtn.disabled = true; |
| |
| for (let item of queue) { |
| if (item.status !== 'waiting') continue; |
| |
| item.status = 'processing'; |
| const badge = document.getElementById(`badge_${item.id}`); |
| badge.innerText = session ? "Inférence IA..." : "Traitement GPU..."; |
| badge.className = "badge status-processing"; |
| |
| let upscaledBlob = null; |
| let methodUsed = "GPU WebGL 2x"; |
| |
| |
| if (session) { |
| try { |
| upscaledBlob = await processImageWithONNX(item.originalUrl); |
| methodUsed = "IA ONNX 3x"; |
| } catch (err) { |
| console.warn("L'inférence IA a échoué pour cette image, basculement automatique sur GPU WebGL:", err); |
| try { |
| upscaledBlob = await processImageWithWebGL(item.originalUrl); |
| methodUsed = "GPU WebGL (Fallback)"; |
| } catch (err2) { |
| console.error("Erreur WebGL Fallback:", err2); |
| } |
| } |
| } else { |
| |
| try { |
| upscaledBlob = await processImageWithWebGL(item.originalUrl); |
| methodUsed = "GPU Shader 2x"; |
| } catch (err) { |
| console.error("Erreur WebGL:", err); |
| } |
| } |
| |
| if (upscaledBlob) { |
| item.upscaledUrl = URL.createObjectURL(upscaledBlob); |
| item.status = 'done'; |
| |
| document.getElementById(`img_prev_${item.id}`).src = item.upscaledUrl; |
| badge.innerText = `Terminé (${methodUsed})`; |
| badge.className = methodUsed.includes("Fallback") ? "badge status-fallback" : "badge status-done"; |
| |
| const fileName = item.file.name.replace(/(\.[^/.]+)$/, "") + "_upscaled.png"; |
| document.getElementById(`action_${item.id}`).innerHTML = |
| `<a class="download-btn" href="${item.upscaledUrl}" download="${fileName}">⬇️ Télécharger HD</a>`; |
| } else { |
| item.status = 'error'; |
| badge.innerText = "Erreur de rendu"; |
| badge.className = "badge status-error"; |
| } |
| } |
| |
| isProcessing = false; |
| updateControls(); |
| } |
| |
| |
| |
| |
| function processImageWithONNX(imgUrl) { |
| return new Promise((resolve, reject) => { |
| const img = new Image(); |
| img.crossOrigin = "anonymous"; |
| |
| |
| img.onload = async () => { |
| try { |
| const width = img.naturalWidth; |
| const height = img.naturalHeight; |
| |
| const canvas = document.createElement('canvas'); |
| canvas.width = width; |
| canvas.height = height; |
| const ctx = canvas.getContext('2d'); |
| ctx.drawImage(img, 0, 0); |
| |
| const imgData = ctx.getImageData(0, 0, width, height); |
| const data = imgData.data; |
| |
| |
| const numPixels = width * height; |
| const yData = new Float32Array(numPixels); |
| |
| for (let i = 0; i < numPixels; i++) { |
| const r = data[i * 4]; |
| const g = data[i * 4 + 1]; |
| const b = data[i * 4 + 2]; |
| yData[i] = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0; |
| } |
| |
| |
| const inputName = session.inputNames[0] || 'input'; |
| const inputTensor = new ort.Tensor('float32', yData, [1, 1, height, width]); |
| |
| const feeds = {}; |
| feeds[inputName] = inputTensor; |
| |
| |
| const results = await session.run(feeds); |
| const outputName = session.outputNames[0] || Object.keys(results)[0]; |
| const outputTensor = results[outputName]; |
| |
| if (!outputTensor || !outputTensor.data) { |
| throw new Error("Sortie ONNX vide"); |
| } |
| |
| const outH = outputTensor.dims[2] || height * 3; |
| const outW = outputTensor.dims[3] || width * 3; |
| const outYData = outputTensor.data; |
| |
| |
| const chromaCanvas = document.createElement('canvas'); |
| chromaCanvas.width = outW; |
| chromaCanvas.height = outH; |
| const chromaCtx = chromaCanvas.getContext('2d'); |
| chromaCtx.imageSmoothingQuality = 'high'; |
| chromaCtx.drawImage(img, 0, 0, outW, outH); |
| const chromaData = chromaCtx.getImageData(0, 0, outW, outH).data; |
| |
| |
| const outCanvas = document.createElement('canvas'); |
| outCanvas.width = outW; |
| outCanvas.height = outH; |
| const outCtx = outCanvas.getContext('2d'); |
| const outImgData = outCtx.createImageData(outW, outH); |
| const outPixels = outImgData.data; |
| |
| for (let i = 0; i < outW * outH; i++) { |
| let yVal = outYData[i]; |
| if (yVal <= 1.0) yVal *= 255.0; |
| |
| const rOrig = chromaData[i * 4]; |
| const gOrig = chromaData[i * 4 + 1]; |
| const bOrig = chromaData[i * 4 + 2]; |
| |
| const cb = (-0.168736 * rOrig - 0.331264 * gOrig + 0.5 * bOrig) + 128; |
| const cr = (0.5 * rOrig - 0.418688 * gOrig - 0.081312 * bOrig) + 128; |
| |
| let r = yVal + 1.402 * (cr - 128); |
| let g = yVal - 0.344136 * (cb - 128) - 0.714136 * (cr - 128); |
| let b = yVal + 1.772 * (cb - 128); |
| |
| outPixels[i * 4] = Math.min(255, Math.max(0, r)); |
| outPixels[i * 4 + 1] = Math.min(255, Math.max(0, g)); |
| outPixels[i * 4 + 2] = Math.min(255, Math.max(0, b)); |
| outPixels[i * 4 + 3] = 255; |
| } |
| |
| outCtx.putImageData(outImgData, 0, 0); |
| |
| outCanvas.toBlob((blob) => { |
| if (blob) resolve(blob); |
| else reject(new Error("Erreur Blob")); |
| }, 'image/png'); |
| |
| } catch (err) { |
| reject(err); |
| } |
| }; |
| |
| img.onerror = () => reject(new Error("Erreur chargement image")); |
| |
| |
| img.src = imgUrl; |
| }); |
| } |
| |
| |
| |
| |
| function processImageWithWebGL(imgUrl) { |
| return new Promise((resolve, reject) => { |
| const img = new Image(); |
| img.crossOrigin = "anonymous"; |
| |
| img.onload = () => { |
| try { |
| const canvas = document.getElementById('glCanvas'); |
| const outW = img.naturalWidth * 2; |
| const outH = img.naturalHeight * 2; |
| canvas.width = outW; |
| canvas.height = outH; |
| |
| const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true }) || |
| canvas.getContext('experimental-webgl', { preserveDrawingBuffer: true }); |
| |
| if (!gl) { |
| const c2d = document.createElement('canvas'); |
| c2d.width = outW; |
| c2d.height = outH; |
| const ctx = c2d.getContext('2d'); |
| ctx.imageSmoothingQuality = 'high'; |
| ctx.drawImage(img, 0, 0, outW, outH); |
| c2d.toBlob(blob => resolve(blob), 'image/png'); |
| return; |
| } |
| |
| const vsSource = ` |
| attribute vec2 a_position; |
| attribute vec2 a_texCoord; |
| varying vec2 v_texCoord; |
| void main() { |
| gl_Position = vec2(a_position.x, a_position.y); |
| v_texCoord = a_texCoord; |
| } |
| `; |
| |
| const fsSource = ` |
| precision mediump float; |
| uniform sampler2D u_image; |
| uniform vec2 u_texSize; |
| varying vec2 v_texCoord; |
| |
| float getLuma(vec4 color) { |
| return dot(color.rgb, vec3(0.299, 0.587, 0.114)); |
| } |
| |
| void main() { |
| vec2 d = 1.0 / u_texSize; |
| vec4 c = texture2D(u_image, v_texCoord); |
| |
| vec4 cNW = texture2D(u_image, v_texCoord + vec2(-d.x, -d.y)); |
| vec4 cN = texture2D(u_image, v_texCoord + vec2(0.0, -d.y)); |
| vec4 cNE = texture2D(u_image, v_texCoord + vec2(d.x, -d.y)); |
| vec4 cW = texture2D(u_image, v_texCoord + vec2(-d.x, 0.0)); |
| vec4 cE = texture2D(u_image, v_texCoord + vec2(d.x, 0.0)); |
| vec4 cSW = texture2D(u_image, v_texCoord + vec2(-d.x, d.y)); |
| vec4 cS = texture2D(u_image, v_texCoord + vec2(0.0, d.y)); |
| vec4 cSE = texture2D(u_image, v_texCoord + vec2(d.x, d.y)); |
| |
| float lNW = getLuma(cNW), lN = getLuma(cN), lNE = getLuma(cNE); |
| float lW = getLuma(cW), lC = getLuma(c), lE = getLuma(cE); |
| float lSW = getLuma(cSW), lS = getLuma(cS), lSE = getLuma(cSE); |
| |
| float gx = (lNE + 2.0*lE + lSE) - (lNW + 2.0*lW + lSW); |
| float gy = (lSW + 2.0*lS + lSE) - (lNW + 2.0*lN + lNE); |
| float grad = sqrt(gx*gx + gy*gy); |
| |
| if (grad > 0.05) { |
| vec4 minColor = min(min(cN, cS), min(cW, cE)); |
| c = mix(c, minColor, 0.3); |
| } |
| |
| gl_FragColor = c; |
| } |
| `; |
| |
| const vs = gl.createShader(gl.VERTEX_SHADER); |
| gl.shaderSource(vs, vsSource); |
| gl.compileShader(vs); |
| |
| const fs = gl.createShader(gl.FRAGMENT_SHADER); |
| gl.shaderSource(fs, fsSource); |
| gl.compileShader(fs); |
| |
| const program = gl.createProgram(); |
| gl.attachShader(program, vs); |
| gl.attachShader(program, fs); |
| gl.linkProgram(program); |
| gl.useProgram(program); |
| |
| const posBuffer = gl.createBuffer(); |
| gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer); |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ |
| -1, -1, 1, -1, -1, 1, |
| -1, 1, 1, -1, 1, 1 |
| ]), gl.STATIC_DRAW); |
| |
| const posLoc = gl.getAttribLocation(program, "a_position"); |
| gl.enableVertexAttribArray(posLoc); |
| gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0); |
| |
| const texBuffer = gl.createBuffer(); |
| gl.bindBuffer(gl.ARRAY_BUFFER, texBuffer); |
| gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ |
| 0, 1, 1, 1, 0, 0, |
| 0, 0, 1, 1, 1, 0 |
| ]), gl.STATIC_DRAW); |
| |
| const texLoc = gl.getAttribLocation(program, "a_texCoord"); |
| gl.enableVertexAttribArray(texLoc); |
| gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, 0, 0); |
| |
| const texture = gl.createTexture(); |
| gl.bindTexture(gl.TEXTURE_2D, texture); |
| gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img); |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); |
| gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); |
| |
| const texSizeLoc = gl.getUniformLocation(program, "u_texSize"); |
| gl.uniform2f(texSizeLoc, outW, outH); |
| |
| gl.viewport(0, 0, outW, outH); |
| gl.drawArrays(gl.TRIANGLES, 0, 6); |
| |
| canvas.toBlob((blob) => { |
| if (blob) resolve(blob); |
| else reject(new Error("Erreur toBlob")); |
| }, 'image/png'); |
| |
| } catch (e) { |
| reject(e); |
| } |
| }; |
| |
| img.onerror = () => reject(new Error("Erreur de chargement de l'image")); |
| |
| img.src = imgUrl; |
| }); |
| } |
| </script> |
|
|
| </body> |
| </html> |
|
|