/** * Misc AI-tool wiring — the three AI tools that don't share the * inpaint pipeline: * * Harmonize: Reinhard color transfer on a body mask (no AI redraw) * + an optional narrow inpaint on a seam mask if the * "Seam fix" slider > 0. * Canvas 2×/4× Upscale: in-browser bicubic resampling, no server. * AI Upscale: Real-ESRGAN via /api/image/upscale-local. * Style Transfer: img2img via /api/gallery/style-transfer. * * Plus the small `_addEmptyLayer` helper and its toolbar wiring, * since it lived next to these. * * @param {{ * apiBase: string, * buildLayerBodyMask: (featherPx: number) => string | null, * buildSeamMask: (featherPx: number) => string | null, * applyImageTool: (endpoint, payload, layerName, btn, opts?) => Promise, * flatten: () => HTMLCanvasElement, * saveState: (label?: string) => void, * fitZoom: () => void, * composite: () => void, * createLayer: (name, w, h) => object, * renderLayerPanel: () => void, * spinnerModule: object, * uiModule: object, * }} deps * * @returns {{ addEmptyLayer: () => void }} */ import { state } from './state.js'; export function wireAIToolsMisc({ apiBase, buildLayerBodyMask, buildSeamMask, applyImageTool, flatten, saveState, fitZoom, composite, createLayer, renderLayerPanel, spinnerModule, uiModule, }) { // ── Harmonize sliders — Color match + Seam fix ── const harmColorPrev = document.getElementById('ge-harmonize-color-preview'); const harmSeamPrev = document.getElementById('ge-harmonize-seam-preview'); document.getElementById('ge-harmonize-color')?.addEventListener('input', (e) => { document.getElementById('ge-harmonize-color-label').textContent = (e.target.value / 100).toFixed(2); if (harmColorPrev) harmColorPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2); }); document.getElementById('ge-harmonize-seam')?.addEventListener('input', (e) => { document.getElementById('ge-harmonize-seam-label').textContent = (e.target.value / 100).toFixed(2); if (harmSeamPrev) harmSeamPrev.style.opacity = (parseInt(e.target.value, 10) / 100).toFixed(2); }); // Harmonize button — two-stage: // 1) Reinhard color transfer on body mask (no AI redraw) // 2) Optional narrow inpaint on seam mask (if seam_fix > 0) document.getElementById('ge-harmonize-run')?.addEventListener('click', () => { const prompt = document.getElementById('ge-harmonize-prompt')?.value?.trim() || 'photorealistic, natural lighting, seamless blend'; const color_match = (parseInt(document.getElementById('ge-harmonize-color')?.value || '65')) / 100; const seam_fix = (parseInt(document.getElementById('ge-harmonize-seam')?.value || '0')) / 100; const bodyFeather = Math.max(6, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.012)); const seamFeather = Math.max(8, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.015)); const body_mask = buildLayerBodyMask(bodyFeather); const seam_mask = seam_fix > 0.01 ? buildSeamMask(seamFeather) : null; // Harmonize needs a non-base layer to color-match against the // background. Without one, the server would fall back to legacy // whole-image img2img — i.e. regenerate the whole photo. Block // that and tell the user what's missing. if (!body_mask) { if (uiModule) uiModule.showToast('Harmonize needs a second layer pasted/imported over the base photo — nothing to color-match against.', 6000); return; } const payload = { prompt, color_match, seam_fix, body_mask }; if (seam_mask) payload.seam_mask = seam_mask; applyImageTool('/api/image/harmonize', payload, 'Harmonized', document.getElementById('ge-harmonize-run')); }); // ── Canvas upscale (bicubic) ── function canvasUpscale(factor) { saveState(`Upscale ${factor}×`); const newW = state.imgWidth * factor; const newH = state.imgHeight * factor; state.layers.forEach(l => { const tmp = document.createElement('canvas'); tmp.width = newW; tmp.height = newH; const tCtx = tmp.getContext('2d'); tCtx.imageSmoothingEnabled = true; tCtx.imageSmoothingQuality = 'high'; tCtx.drawImage(l.canvas, 0, 0, newW, newH); l.canvas.width = newW; l.canvas.height = newH; l.ctx.drawImage(tmp, 0, 0); }); if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; } state.imgWidth = newW; state.imgHeight = newH; state.mainCanvas.width = newW; state.mainCanvas.height = newH; const sizeLabel = document.getElementById('ge-canvas-size'); if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`; fitZoom(); composite(); uiModule.showToast(`Upscaled ${factor}× to ${newW}×${newH}`); } document.getElementById('ge-upscale-2x')?.addEventListener('click', () => canvasUpscale(2)); document.getElementById('ge-upscale-4x')?.addEventListener('click', () => canvasUpscale(4)); // ── AI upscale (Real-ESRGAN, no diffusion server required) ── document.getElementById('ge-upscale-ai')?.addEventListener('click', async () => { const btn = document.getElementById('ge-upscale-ai'); const origHTML = btn.innerHTML; btn.disabled = true; let upWp = null; try { upWp = spinnerModule.createWhirlpool(14); upWp.element.style.cssText = 'display:inline-block;vertical-align:middle;position:relative;top:1px;margin-right:6px;width:14px;height:14px;'; btn.innerHTML = ''; btn.appendChild(upWp.element); const lbl = document.createElement('span'); lbl.textContent = 'Upscaling…'; btn.appendChild(lbl); } catch (_) { btn.textContent = 'Upscaling…'; } try { const flat = flatten(); const imageB64 = flat.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/upscale-local', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: imageB64, scale: 2 }), }); if (!res.ok) throw new Error('Server returned ' + res.status); const data = await res.json(); if (data.image) { const img = new Image(); img.onload = () => { if (!state.editorOpen) return; saveState(); const newW = img.width, newH = img.height; const layer = createLayer('AI Upscaled', newW, newH); layer.ctx.drawImage(img, 0, 0); state.layers.push(layer); state.activeLayerId = layer.id; state.imgWidth = newW; state.imgHeight = newH; state.mainCanvas.width = newW; state.mainCanvas.height = newH; if (state.maskCanvas) { state.maskCanvas.width = newW; state.maskCanvas.height = newH; } const sizeLabel = document.getElementById('ge-canvas-size'); if (sizeLabel) sizeLabel.textContent = `${newW}×${newH}`; fitZoom(); composite(); renderLayerPanel(); uiModule.showToast(`AI upscaled to ${newW}×${newH}`); }; img.src = 'data:image/png;base64,' + data.image; } else { throw new Error(data.error || 'No image returned'); } } catch (e) { uiModule.showToast('AI upscale failed: ' + e.message); } try { upWp?.destroy(); } catch (_) {} btn.disabled = false; btn.innerHTML = origHTML; }); // ── Style transfer ── document.getElementById('ge-style-strength')?.addEventListener('input', (e) => { document.getElementById('ge-style-strength-label').textContent = (parseInt(e.target.value) / 100).toFixed(2); }); document.getElementById('ge-style-run')?.addEventListener('click', async () => { const btn = document.getElementById('ge-style-run'); const prompt = document.getElementById('ge-style-prompt').value.trim(); if (!prompt) { uiModule.showToast('Enter a style prompt'); return; } const strength = parseInt(document.getElementById('ge-style-strength').value) / 100; btn.disabled = true; btn.textContent = 'Applying...'; try { const flat = flatten(); const blob = await new Promise(r => flat.toBlob(r, 'image/png')); const fd = new FormData(); fd.append('image', blob, 'style.png'); fd.append('prompt', prompt); fd.append('strength', String(strength)); const res = await fetch(`${apiBase}/api/gallery/style-transfer`, { method: 'POST', credentials: 'same-origin', body: fd }); if (!res.ok) throw new Error('Server returned ' + res.status); const data = await res.json(); if (data.image) { const img = new Image(); img.onload = () => { if (!state.editorOpen) return; saveState(); const layer = createLayer('Styled: ' + prompt.substring(0, 20), state.imgWidth, state.imgHeight); layer.ctx.drawImage(img, 0, 0, state.imgWidth, state.imgHeight); state.layers.push(layer); state.activeLayerId = layer.id; composite(); renderLayerPanel(); uiModule.showToast('Style applied'); }; img.src = 'data:image/png;base64,' + data.image; } else { throw new Error(data.error || 'No image returned'); } } catch (e) { uiModule.showToast('Style transfer failed: ' + e.message); } btn.disabled = false; btn.textContent = 'Apply Style'; }); // ── Add empty layer (used by the layer-panel header button + the // Ctrl+Alt+J keyboard shortcut). Returned so keyboard-shortcuts.js // can call it through the same path. ── function addEmptyLayer() { saveState('Add layer'); const layer = createLayer('Layer ' + state.layers.length, state.imgWidth, state.imgHeight); state.layers.push(layer); state.activeLayerId = layer.id; renderLayerPanel(); composite(); } document.getElementById('ge-add-layer')?.addEventListener('click', addEmptyLayer); return { addEmptyLayer }; }