/** * AI inpaint subsystem — Generate, Remove, and Outpaint variants * all share a single `runInpaint` core; only the prompt, strength, * and button-target differ. Returns a wireInpaintButtons() function * to attach handlers to the three buttons (#ge-inpaint-run, * #ge-inpaint-remove, #ge-inpaint-outpaint). * * runInpaint: * - Build a union mask from every visible mask sub-layer (across * all parent layers) — the model sees the COMBINED region, * not just the currently-active mask. * - Dilate the mask ~padPx so the model fills a buffer zone the * post-gen Feather/Edge slider can fade into. * - POST flattened canvas + dilated mask to /api/image/inpaint. * - Drop the result as a new layer, snapshot the AI image + hard * mask on the layer for live edge tuning, hide every * contributing mask sub-layer, reveal the post-gen Feather + * Edge Stroke sliders capped at ±padPx. * * Remove: detects OpenAI vs SDXL backend and swaps the prompt * (gpt-image-1 follows "remove …" semantically; SDXL has to be * prompted with a fill description + strength 0.99). * * Outpaint: auto-generates a mask covering empty (transparent) * regions of the flattened composite, dilates it 12px inward * so the model sees adjacent opaque pixels as context, runs * inpaint, then restores the user's previous mask drawing. * * @param {{ * buildMergedMaskCanvas: () => HTMLCanvasElement | null, * dilateMask: (src: HTMLCanvasElement, px: number) => HTMLCanvasElement, * applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void, * getSelectedAIEndpoint: (type: string) => { endpoint?: string, model?: string }, * ensureActiveMaskLayer: () => object | null, * saveState: (label?: string) => void, * createLayer: (name: string, w: number, h: number) => object, * composite: () => void, * renderLayerPanel: () => void, * spinnerModule: object, * uiModule: object | null, * }} deps */ import { state } from './state.js'; export function wireInpaintButtons({ buildMergedMaskCanvas, dilateMask, applyInpaintFeather, getSelectedAIEndpoint, ensureActiveMaskLayer, saveState, createLayer, composite, renderLayerPanel, spinnerModule, uiModule, }) { // Shared inpaint runner — used by Generate, Remove, and Outpaint. async function runInpaint({ prompt, strength, btnId, labelId, idleLabel, busyLabel }) { // Pre-check: build the union mask the AI will receive and verify // at least one pixel is painted. const preMerged = buildMergedMaskCanvas(); if (!preMerged) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; } const pmCtx = preMerged.getContext('2d'); const maskData = pmCtx.getImageData(0, 0, preMerged.width, preMerged.height).data; let hasMask = false; for (let i = 3; i < maskData.length; i += 4) { if (maskData[i] > 0) { hasMask = true; break; } } if (!hasMask) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; } const btn = document.getElementById(btnId); const btnLabel = labelId ? document.getElementById(labelId) : null; btn.disabled = true; if (btnLabel) btnLabel.textContent = busyLabel; let runWp = null; try { runWp = spinnerModule.createWhirlpool(14); runWp.element.style.cssText = 'margin:0;flex-shrink:0;'; btn.appendChild(runWp.element); } catch (_) { /* spinner is optional */ } // Canvas-overlay whirlpool — visual feedback right where the // user's working, since the run button lives in the side panel // and may be out of view at high zoom. Positioned over the // mask's centroid in viewport coords. let canvasWp = null; let canvasWpEl = null; try { const area = state.container && state.container.querySelector('.ge-canvas-area'); const mainRect = state.mainCanvas.getBoundingClientRect(); if (area && mainRect.width && mainRect.height) { // Find the mask's bbox so we can centre the whirlpool over it. let cx = state.imgWidth / 2, cy = state.imgHeight / 2; try { const merged = buildMergedMaskCanvas(); if (merged) { const d = merged.getContext('2d').getImageData(0, 0, merged.width, merged.height).data; let minX = merged.width, maxX = 0, minY = merged.height, maxY = 0; for (let y = 0; y < merged.height; y += 4) { for (let x = 0; x < merged.width; x += 4) { if (d[(y * merged.width + x) * 4 + 3] > 0) { if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } } if (maxX >= minX) { cx = (minX + maxX) / 2; cy = (minY + maxY) / 2; } } } catch {} const scaleX = mainRect.width / state.mainCanvas.width; const scaleY = mainRect.height / state.mainCanvas.height; const vpX = mainRect.left + cx * scaleX; const vpY = mainRect.top + cy * scaleY; canvasWp = spinnerModule.create('', 'clean', 'whirlpool'); canvasWpEl = canvasWp.createElement(); canvasWpEl.style.cssText = `position:fixed;left:${vpX}px;top:${vpY}px;transform:translate(-50%,-50%);z-index:12;pointer-events:none;`; document.body.appendChild(canvasWpEl); canvasWp.start(); } } catch (_) { /* overlay is decorative */ } try { // Flatten current image. const flatCanvas = document.createElement('canvas'); flatCanvas.width = state.imgWidth; flatCanvas.height = state.imgHeight; const flatCtx = flatCanvas.getContext('2d'); for (const layer of state.layers) { if (!layer.visible) continue; flatCtx.globalAlpha = layer.opacity; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; flatCtx.drawImage(layer.canvas, off.x, off.y); } flatCtx.globalAlpha = 1; // Dilate the user's brush mask before sending to the model. // The AI fills a small buffer zone around the brush, so the // post-gen Edge feather slider has AI content to fade INTO // instead of fading straight to the original. The ORIGINAL // (un-dilated) mask is cached on the layer — the feather blur // expands outward from that boundary into the dilated AI region. const padPx = Math.min(80, Math.max(20, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.04))); // Merge every visible mask sub-layer (across all parent // layers) into a single union mask before sending to the AI. // This way, if the user built up the inpaint region across // multiple masks, the final generation sees the combined // region instead of just the currently-active mask. const mergedMask = buildMergedMaskCanvas() || state.maskCanvas; const dilatedMask = dilateMask(mergedMask, padPx); const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1]; const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1]; const res = await fetch('/api/image/inpaint', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify((() => { const sel = getSelectedAIEndpoint('inpaint'); return { image: imageB64, mask: maskB64, prompt, width: state.imgWidth, height: state.imgHeight, strength, feather: 0, _endpoint: sel.endpoint, _model: sel.model }; })()), }); if (!res.ok) { let errDetail = res.statusText; try { const errBody = await res.json(); errDetail = errBody.detail || errBody.error || errDetail; } catch {} throw new Error(errDetail); } const data = await res.json(); if (data.error) throw new Error(data.error); if (!data.image) throw new Error('No image returned from inpaint endpoint'); // Load result as a new layer and clip with the user-drawn mask // so only the inpainted region is visible. Cache the // unfeathered (AI image + hard mask) on the layer so the live // Feather slider can re-derive the alpha on each input event // without re-running the model. const resultImg = new Image(); resultImg.onload = () => { if (!state.editorOpen) return; // user closed mid-decode try { saveState('Inpaint result'); // OpenAI returns at one of its allowed sizes (1024², // 1024×1536, 1536×1024) which often differs from our // canvas. Scale to canvas size with smoothing so the // inpaint blends in regardless of source dims. const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40); const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result'; const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight); resultLayer.ctx.imageSmoothingEnabled = true; resultLayer.ctx.imageSmoothingQuality = 'high'; resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight); // Snapshot the AI result + hard mask used for this run. const aiSnap = document.createElement('canvas'); aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight; aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0); const maskSnap = document.createElement('canvas'); maskSnap.width = state.maskCanvas.width; maskSnap.height = state.maskCanvas.height; maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0); resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx }; // Apply initial alpha = hard mask (no feather, no edge shift). applyInpaintFeather(resultLayer, 0, 0); state.layers.push(resultLayer); state.activeLayerId = resultLayer.id; state.lastInpaintLayerId = resultLayer.id; // Hide every mask sub-layer that contributed to the // generation so the red overlay doesn't cover the result — // but KEEP the mask pixels intact, and reflect "hidden" // on each sub-row's eye icon. for (const ly of state.layers) { if (!ly.masks || !ly.masks.length) continue; for (const mk of ly.masks) mk.visible = false; } composite(); renderLayerPanel(); // Reveal post-generation Feather + Edge Stroke sliders. // Cap Edge Stroke at ±padPx so the slider can't ask for // more AI buffer than we generated. const fRow = document.getElementById('ge-inpaint-postfeather-row'); const fSlider = document.getElementById('ge-feather-slider'); const fLabel = document.getElementById('ge-feather-label'); // Divider + heading are always visible; once Generate // succeeds we hide the "Available after Generate" hint. const divEl = document.getElementById('ge-inpaint-postedge-divider'); const titleEl = document.getElementById('ge-inpaint-postedge-title'); const hintEl = document.getElementById('ge-inpaint-postedge-hint'); if (divEl) divEl.style.display = ''; if (titleEl) titleEl.style.display = ''; if (hintEl) hintEl.style.display = 'none'; if (fRow) fRow.style.display = ''; if (fSlider) fSlider.value = '0'; if (fLabel) fLabel.textContent = '0px'; const eRow = document.getElementById('ge-inpaint-edgestroke-row'); const eSlider = document.getElementById('ge-edgestroke-slider'); const eLabel = document.getElementById('ge-edgestroke-label'); if (eRow) eRow.style.display = ''; if (eSlider) { eSlider.max = String(padPx); eSlider.min = String(-padPx); eSlider.value = '0'; } if (eLabel) eLabel.textContent = '0px'; if (uiModule) uiModule.showToast('Inpaint complete — drag Edge feather / Edge stroke to blend', 5000); } catch (renderErr) { console.error('[inpaint] render error', renderErr); if (uiModule) uiModule.showToast('Inpaint render failed: ' + (renderErr.message || renderErr), 6000); } }; resultImg.onerror = (e) => { console.error('[inpaint] base64 decode failed', e); if (uiModule) uiModule.showToast('Inpaint result failed to decode', 6000); }; resultImg.src = 'data:image/png;base64,' + data.image; } catch (e) { if (uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000); } finally { btn.disabled = false; if (btnLabel) btnLabel.textContent = idleLabel; if (runWp) { try { runWp.destroy(); } catch (_) {} } if (canvasWp) { try { canvasWp.destroy(); } catch (_) {} } if (canvasWpEl) { try { canvasWpEl.remove(); } catch (_) {} } window.dispatchEvent(new CustomEvent('ge:inpaint-done')); } } // Generate. document.getElementById('ge-inpaint-run').addEventListener('click', async () => { const prompt = document.getElementById('ge-inpaint-prompt')?.value?.trim(); if (!prompt) { if (uiModule) uiModule.showToast('Enter a prompt for inpainting'); return; } const strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100; await runInpaint({ prompt, strength, btnId: 'ge-inpaint-run', labelId: 'ge-inpaint-run-label', idleLabel: 'Generate', busyLabel: 'Generating', }); }); // Remove — detects backend type and substitutes a content-aware // fill prompt. gpt-image-1 understands "remove …" semantically; // SDXL inpaint pipelines literally try to draw the prompt, so we // send a generic surroundings-matching prompt and crank strength. document.getElementById('ge-inpaint-remove').addEventListener('click', async () => { const sel = getSelectedAIEndpoint('inpaint'); const ep = (sel.endpoint || '').toLowerCase(); const isOpenAI = ep.includes('api.openai.com'); let prompt, strength; if (isOpenAI) { const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim(); prompt = userP ? `Remove ${userP}. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.` : 'Remove the masked area. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.'; strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100; } else { // SDXL inpaint: describe the surroundings, not what's there. // Crank strength to ensure the model fully overwrites the // masked region — at low strength it would denoise toward // what was there. prompt = 'seamless natural background, photorealistic, continuation of surrounding scene, empty area, no objects, no people, no text, clean'; strength = 0.99; } await runInpaint({ prompt, strength, btnId: 'ge-inpaint-remove', labelId: 'ge-inpaint-remove-label', idleLabel: 'Remove', busyLabel: 'Removing', }); }); // Outpaint — auto-generate a mask covering empty (transparent) // areas of the flattened composite, then run inpaint to fill them // seamlessly. Mask is dilated ~12px so the AI sees adjacent // opaque pixels as context. Ignores the user's drawn mask. document.getElementById('ge-inpaint-outpaint').addEventListener('click', async () => { // 1) Flatten visible layers to detect alpha=0 (empty) regions. const flat = document.createElement('canvas'); flat.width = state.imgWidth; flat.height = state.imgHeight; const fctx = flat.getContext('2d'); for (const layer of state.layers) { if (!layer.visible) continue; fctx.globalAlpha = layer.opacity; const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; fctx.drawImage(layer.canvas, off.x, off.y); } fctx.globalAlpha = 1; const flatData = fctx.getImageData(0, 0, state.imgWidth, state.imgHeight).data; // 2) White wherever the composite is transparent. const maskRaw = document.createElement('canvas'); maskRaw.width = state.imgWidth; maskRaw.height = state.imgHeight; const mrCtx = maskRaw.getContext('2d'); const mrImg = mrCtx.createImageData(state.imgWidth, state.imgHeight); let emptyCount = 0; for (let i = 0; i < flatData.length; i += 4) { if (flatData[i + 3] === 0) { mrImg.data[i] = 255; mrImg.data[i + 1] = 255; mrImg.data[i + 2] = 255; mrImg.data[i + 3] = 255; emptyCount++; } } if (emptyCount === 0) { if (uiModule) uiModule.showToast('No empty areas to outpaint — canvas is fully covered.'); return; } mrCtx.putImageData(mrImg, 0, 0); // 3) Dilate the mask outward 12px so it overlaps a band of // opaque pixels — context for the model to blend cleanly. const expanded = document.createElement('canvas'); expanded.width = state.imgWidth; expanded.height = state.imgHeight; const ectx = expanded.getContext('2d'); ectx.filter = 'blur(12px)'; ectx.drawImage(maskRaw, 0, 0); ectx.filter = 'none'; const expData = ectx.getImageData(0, 0, state.imgWidth, state.imgHeight); for (let i = 0; i < expData.data.length; i += 4) { const a = expData.data[i + 3]; const v = a > 6 ? 255 : 0; expData.data[i] = v; expData.data[i + 1] = v; expData.data[i + 2] = v; expData.data[i + 3] = v; } ectx.putImageData(expData, 0, 0); // 4) Temporarily replace the active mask sub-layer with the // outpaint mask. Snapshot the previous so we can restore. const mask = ensureActiveMaskLayer(); if (!mask) { if (uiModule) uiModule.showToast('No active layer for outpaint'); return; } const savedMask = mask.ctx.getImageData(0, 0, mask.canvas.width, mask.canvas.height); mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height); mask.ctx.drawImage(expanded, 0, 0); // 5) Prompt: prefer user input, else a generic fill. const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim(); const prompt = userP || 'seamless natural continuation of the surrounding image, photorealistic, matching style, no objects, no people, no text'; const strength = 0.99; try { await runInpaint({ prompt, strength, btnId: 'ge-inpaint-outpaint', labelId: 'ge-inpaint-outpaint-label', idleLabel: 'Outpaint', busyLabel: 'Outpainting', }); } finally { // Restore the user's previous mask drawing so subsequent // Generate/Remove operates on what they actually drew. mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height); mask.ctx.putImageData(savedMask, 0, 0); composite(); } }); }