1/** 2 * AI inpaint subsystem â Generate, Remove, and Outpaint variants 3 * all share a single `runInpaint` core; only the prompt, strength, 4 * and button-target differ. Returns a wireInpaintButtons() function 5 * to attach handlers to the three buttons (#ge-inpaint-run, 6 * #ge-inpaint-remove, #ge-inpaint-outpaint). 7 * 8 * runInpaint: 9 * - Build a union mask from every visible mask sub-layer (across 10 * all parent layers) â the model sees the COMBINED region, 11 * not just the currently-active mask. 12 * - Dilate the mask ~padPx so the model fills a buffer zone the 13 * post-gen Feather/Edge slider can fade into. 14 * - POST flattened canvas + dilated mask to /api/image/inpaint. 15 * - Drop the result as a new layer, snapshot the AI image + hard 16 * mask on the layer for live edge tuning, hide every 17 * contributing mask sub-layer, reveal the post-gen Feather + 18 * Edge Stroke sliders capped at ±padPx. 19 * 20 * Remove: detects OpenAI vs SDXL backend and swaps the prompt 21 * (gpt-image-1 follows "remove â¦" semantically; SDXL has to be 22 * prompted with a fill description + strength 0.99). 23 * 24 * Outpaint: auto-generates a mask covering empty (transparent) 25 * regions of the flattened composite, dilates it 12px inward 26 * so the model sees adjacent opaque pixels as context, runs 27 * inpaint, then restores the user's previous mask drawing. 28 * 29 * @param {{ 30 * buildMergedMaskCanvas: () => HTMLCanvasElement | null, 31 * dilateMask: (src: HTMLCanvasElement, px: number) => HTMLCanvasElement, 32 * applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void, 33 * getSelectedAIEndpoint: (type: string) => { endpoint?: string, model?: string }, 34 * ensureActiveMaskLayer: () => object | null, 35 * saveState: (label?: string) => void, 36 * createLayer: (name: string, w: number, h: number) => object, 37 * composite: () => void, 38 * renderLayerPanel: () => void, 39 * spinnerModule: object, 40 * uiModule: object | null, 41 * }} deps 42 */ 43import { state } from './state.js'; 44 45export function wireInpaintButtons({ 46 buildMergedMaskCanvas, dilateMask, applyInpaintFeather, 47 getSelectedAIEndpoint, ensureActiveMaskLayer, 48 saveState, createLayer, composite, renderLayerPanel, 49 spinnerModule, uiModule, 50}) { 51 // Shared inpaint runner â used by Generate, Remove, and Outpaint. 52 async function runInpaint({ prompt, strength, btnId, labelId, idleLabel, busyLabel }) { 53 // Pre-check: build the union mask the AI will receive and verify 54 // at least one pixel is painted. 55 const preMerged = buildMergedMaskCanvas(); 56 if (!preMerged) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; } 57 const pmCtx = preMerged.getContext('2d'); 58 const maskData = pmCtx.getImageData(0, 0, preMerged.width, preMerged.height).data; 59 let hasMask = false; 60 for (let i = 3; i < maskData.length; i += 4) { if (maskData[i] > 0) { hasMask = true; break; } } 61 if (!hasMask) { if (uiModule) uiModule.showToast('Draw the area you want to inpaint first'); return; } 62 const btn = document.getElementById(btnId); 63 const btnLabel = labelId ? document.getElementById(labelId) : null; 64 btn.disabled = true; 65 if (btnLabel) btnLabel.textContent = busyLabel; 66 let runWp = null; 67 try { 68 runWp = spinnerModule.createWhirlpool(14); 69 runWp.element.style.cssText = 'margin:0;flex-shrink:0;'; 70 btn.appendChild(runWp.element); 71 } catch (_) { /* spinner is optional */ } 72 // Canvas-overlay whirlpool â visual feedback right where the 73 // user's working, since the run button lives in the side panel 74 // and may be out of view at high zoom. Positioned over the 75 // mask's centroid in viewport coords. 76 let canvasWp = null; 77 let canvasWpEl = null; 78 try { 79 const area = state.container && state.container.querySelector('.ge-canvas-area'); 80 const mainRect = state.mainCanvas.getBoundingClientRect(); 81 if (area && mainRect.width && mainRect.height) { 82 // Find the mask's bbox so we can centre the whirlpool over it. 83 let cx = state.imgWidth / 2, cy = state.imgHeight / 2; 84 try { 85 const merged = buildMergedMaskCanvas(); 86 if (merged) { 87 const d = merged.getContext('2d').getImageData(0, 0, merged.width, merged.height).data; 88 let minX = merged.width, maxX = 0, minY = merged.height, maxY = 0; 89 for (let y = 0; y < merged.height; y += 4) { 90 for (let x = 0; x < merged.width; x += 4) { 91 if (d[(y * merged.width + x) * 4 + 3] > 0) { 92 if (x < minX) minX = x; if (x > maxX) maxX = x; 93 if (y < minY) minY = y; if (y > maxY) maxY = y; 94 } 95 } 96 } 97 if (maxX >= minX) { cx = (minX + maxX) / 2; cy = (minY + maxY) / 2; } 98 } 99 } catch {} 100 const scaleX = mainRect.width / state.mainCanvas.width; 101 const scaleY = mainRect.height / state.mainCanvas.height; 102 const vpX = mainRect.left + cx * scaleX; 103 const vpY = mainRect.top + cy * scaleY; 104 canvasWp = spinnerModule.create('', 'clean', 'whirlpool'); 105 canvasWpEl = canvasWp.createElement(); 106 canvasWpEl.style.cssText = `position:fixed;left:${vpX}px;top:${vpY}px;transform:translate(-50%,-50%);z-index:12;pointer-events:none;`; 107 document.body.appendChild(canvasWpEl); 108 canvasWp.start(); 109 } 110 } catch (_) { /* overlay is decorative */ } 111 try { 112 // Flatten current image. 113 const flatCanvas = document.createElement('canvas'); 114 flatCanvas.width = state.imgWidth; flatCanvas.height = state.imgHeight; 115 const flatCtx = flatCanvas.getContext('2d'); 116 for (const layer of state.layers) { 117 if (!layer.visible) continue; 118 flatCtx.globalAlpha = layer.opacity; 119 const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; 120 flatCtx.drawImage(layer.canvas, off.x, off.y); 121 } 122 flatCtx.globalAlpha = 1; 123 // Dilate the user's brush mask before sending to the model. 124 // The AI fills a small buffer zone around the brush, so the 125 // post-gen Edge feather slider has AI content to fade INTO 126 // instead of fading straight to the original. The ORIGINAL 127 // (un-dilated) mask is cached on the layer â the feather blur 128 // expands outward from that boundary into the dilated AI regi
128on. 129 const padPx = Math.min(80, Math.max(20, Math.round(Math.min(state.imgWidth, state.imgHeight) * 0.04))); 130 // Merge every visible mask sub-layer (across all parent 131 // layers) into a single union mask before sending to the AI. 132 // This way, if the user built up the inpaint region across 133 // multiple masks, the final generation sees the combined 134 // region instead of just the currently-active mask. 135 const mergedMask = buildMergedMaskCanvas() || state.maskCanvas; 136 const dilatedMask = dilateMask(mergedMask, padPx); 137 const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1]; 138 const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1]; 139 const res = await fetch('/api/image/inpaint', { 140 method: 'POST', credentials: 'same-origin', 141 headers: { 'Content-Type': 'application/json' }, 142 body: JSON.stringify((() => { 143 const sel = getSelectedAIEndpoint('inpaint'); 144 return { image: imageB64, mask: maskB64, prompt, width: state.imgWidth, height: state.imgHeight, strength, feather: 0, _endpoint: sel.endpoint, _model: sel.model }; 145 })()), 146 }); 147 if (!res.ok) { 148 let errDetail = res.statusText; 149 try { const errBody = await res.json(); errDetail = errBody.detail || errBody.error || errDetail; } catch {} 150 throw new Error(errDetail); 151 } 152 const data = await res.json(); 153 if (data.error) throw new Error(data.error); 154 if (!data.image) throw new Error('No image returned from inpaint endpoint'); 155 // Load result as a new layer and clip with the user-drawn mask 156 // so only the inpainted region is visible. Cache the 157 // unfeathered (AI image + hard mask) on the layer so the live 158 // Feather slider can re-derive the alpha on each input event 159 // without re-running the model. 160 const resultImg = new Image(); 161 resultImg.onload = () => { 162 if (!state.editorOpen) return; // user closed mid-decode 163 try { 164 saveState('Inpaint result'); 165 // OpenAI returns at one of its allowed sizes (1024², 166 // 1024Ã1536, 1536Ã1024) which often differs from our 167 // canvas. Scale to canvas size with smoothing so the 168 // inpaint blends in regardless of source dims. 169 const shortPrompt = (prompt || '').trim().replace(/\s+/g, ' ').slice(0, 40); 170 const layerName = shortPrompt ? `Inpaint: ${shortPrompt}` : 'Inpaint Result'; 171 const resultLayer = createLayer(layerName, state.imgWidth, state.imgHeight); 172 resultLayer.ctx.imageSmoothingEnabled = true; 173 resultLayer.ctx.imageSmoothingQuality = 'high'; 174 resultLayer.ctx.drawImage(resultImg, 0, 0, state.imgWidth, state.imgHeight); 175 // Snapshot the AI result + hard mask used for this run. 176 const aiSnap = document.createElement('canvas'); 177 aiSnap.width = state.imgWidth; aiSnap.height = state.imgHeight; 178 aiSnap.getContext('2d').drawImage(resultLayer.canvas, 0, 0); 179 const maskSnap = document.createElement('canvas'); 180 maskSnap.width = state.maskCanvas.width; 181 maskSnap.height = state.maskCanvas.height; 182 maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0); 183 resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx }; 184 // Apply initial alpha = hard mask (no feather, no edge shift). 185 applyInpaintFeather(resultLayer, 0, 0); 186 state.layers.push(resultLayer); 187 state.activeLayerId = resultLayer.id; 188 state.lastInpaintLayerId = resultLayer.id; 189 // Hide every mask sub-layer that contributed to the 190 // generation so the red overlay doesn't cover the result â 191 // but KEEP the mask pixels intact, and reflect "hidden" 192 // on each sub-row's eye icon. 193 for (const ly of state.layers) { 194 if (!ly.masks || !ly.masks.length) continue; 195 for (const mk of ly.masks) mk.visible = false; 196 } 197 composite(); 198 renderLayerPanel(); 199 // Reveal post-generation Feather + Edge Stroke sliders. 200 // Cap Edge Stroke at ±padPx so the slider can't ask for 201 // more AI buffer than we generated. 202 const fRow = document.getElementById('ge-inpaint-postfeather-row'); 203 const fSlider = document.getElementById('ge-feather-slider'); 204 const fLabel = document.getElementById('ge-feather-label'); 205 // Divider + heading are always visible; once Generate 206 // succeeds we hide the "Available after Generate" hint. 207 const divEl = document.getElementById('ge-inpaint-postedge-divider'); 208 const titleEl = document.getElementById('ge-inpaint-postedge-title'); 209 const hintEl = document.getElementById('ge-inpaint-postedge-hint'); 210 if (divEl) divEl.style.display = ''; 211 if (titleEl) titleEl.style.display = ''; 212 if (hintEl) hintEl.style.display = 'none'; 213 if (fRow) fRow.style.display = ''; 214 if (fSlider) fSlider.value = '0'; 215 if (fLabel) fLabel.textContent = '0px'; 216 const eRow = document.getElementById('ge-inpaint-edgestroke-row'); 217 const eSlider = document.getElementById('ge-edgestroke-slider'); 218 const eLabel = document.getElementById('ge-edgestroke-label'); 219 if (eRow) eRow.style.display = ''; 220 if (eSlider) { 221 eSlider.max = String(padPx); 222 eSlider.min = String(-padPx); 223 eSlider.value = '0'; 224 } 225 if (eLabel) eLabel.textContent = '0px'; 226 if (uiModule) uiModule.showToast('Inpaint complete â drag Edge feather / Edge stroke to blend', 5000); 227 } catch (renderErr) { 228 console.error('[inpaint] render error', renderErr); 229 if (uiModule) uiModule.showToast('Inpaint render failed: ' + (renderErr.message || renderErr), 6000); 230 } 231 }; 232 resultImg.onerror = (e) => { 233 console.error('[inpaint] base64 decode failed', e); 234 if (uiModule) uiModule.showToast('Inpaint result failed to decode', 6000); 235 }; 236 resultImg.src = 'data:image/png;base64,' + data.image; 237 } catch (e) { 238 if (uiModule) uiModule.showToast('Inpaint failed: ' + e.message, 6000); 239 } finally { 240 btn.disabled = false;
241 if (btnLabel) btnLabel.textContent = idleLabel; 242 if (runWp) { try { runWp.destroy(); } catch (_) {} } 243 if (canvasWp) { try { canvasWp.destroy(); } catch (_) {} } 244 if (canvasWpEl) { try { canvasWpEl.remove(); } catch (_) {} } 245 window.dispatchEvent(new CustomEvent('ge:inpaint-done')); 246 } 247 } 248 249 // Generate. 250 document.getElementById('ge-inpaint-run').addEventListener('click', async () => { 251 const prompt = document.getElementById('ge-inpaint-prompt')?.value?.trim(); 252 if (!prompt) { if (uiModule) uiModule.showToast('Enter a prompt for inpainting'); return; } 253 const strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100; 254 await runInpaint({ 255 prompt, strength, 256 btnId: 'ge-inpaint-run', 257 labelId: 'ge-inpaint-run-label', 258 idleLabel: 'Generate', busyLabel: 'Generating', 259 }); 260 }); 261 262 // Remove â detects backend type and substitutes a content-aware 263 // fill prompt. gpt-image-1 understands "remove â¦" semantically; 264 // SDXL inpaint pipelines literally try to draw the prompt, so we 265 // send a generic surroundings-matching prompt and crank strength. 266 document.getElementById('ge-inpaint-remove').addEventListener('click', async () => { 267 const sel = getSelectedAIEndpoint('inpaint'); 268 const ep = (sel.endpoint || '').toLowerCase(); 269 const isOpenAI = ep.includes('api.openai.com'); 270 let prompt, strength; 271 if (isOpenAI) { 272 const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim(); 273 prompt = userP 274 ? `Remove ${userP}. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.` 275 : 'Remove the masked area. Fill seamlessly with the surrounding background, photorealistic, no objects, no people.'; 276 strength = (parseInt(document.getElementById('ge-strength-slider')?.value || '75')) / 100; 277 } else { 278 // SDXL inpaint: describe the surroundings, not what's there. 279 // Crank strength to ensure the model fully overwrites the 280 // masked region â at low strength it would denoise toward 281 // what was there. 282 prompt = 'seamless natural background, photorealistic, continuation of surrounding scene, empty area, no objects, no people, no text, clean'; 283 strength = 0.99; 284 } 285 await runInpaint({ 286 prompt, strength, 287 btnId: 'ge-inpaint-remove', 288 labelId: 'ge-inpaint-remove-label', 289 idleLabel: 'Remove', busyLabel: 'Removing', 290 }); 291 }); 292 293 // Outpaint â auto-generate a mask covering empty (transparent) 294 // areas of the flattened composite, then run inpaint to fill them 295 // seamlessly. Mask is dilated ~12px so the AI sees adjacent 296 // opaque pixels as context. Ignores the user's drawn mask. 297 document.getElementById('ge-inpaint-outpaint').addEventListener('click', async () => { 298 // 1) Flatten visible layers to detect alpha=0 (empty) regions. 299 const flat = document.createElement('canvas'); 300 flat.width = state.imgWidth; flat.height = state.imgHeight; 301 const fctx = flat.getContext('2d'); 302 for (const layer of state.layers) { 303 if (!layer.visible) continue; 304 fctx.globalAlpha = layer.opacity; 305 const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 }; 306 fctx.drawImage(layer.canvas, off.x, off.y); 307 } 308 fctx.globalAlpha = 1; 309 const flatData = fctx.getImageData(0, 0, state.imgWidth, state.imgHeight).data; 310 // 2) White wherever the composite is transparent. 311 const maskRaw = document.createElement('canvas'); 312 maskRaw.width = state.imgWidth; maskRaw.height = state.imgHeight; 313 const mrCtx = maskRaw.getContext('2d'); 314 const mrImg = mrCtx.createImageData(state.imgWidth, state.imgHeight); 315 let emptyCount = 0; 316 for (let i = 0; i < flatData.length; i += 4) { 317 if (flatData[i + 3] === 0) { 318 mrImg.data[i] = 255; 319 mrImg.data[i + 1] = 255; 320 mrImg.data[i + 2] = 255; 321 mrImg.data[i + 3] = 255; 322 emptyCount++; 323 } 324 } 325 if (emptyCount === 0) { 326 if (uiModule) uiModule.showToast('No empty areas to outpaint â canvas is fully covered.'); 327 return; 328 } 329 mrCtx.putImageData(mrImg, 0, 0); 330 // 3) Dilate the mask outward 12px so it overlaps a band of 331 // opaque pixels â context for the model to blend cleanly. 332 const expanded = document.createElement('canvas'); 333 expanded.width = state.imgWidth; expanded.height = state.imgHeight; 334 const ectx = expanded.getContext('2d'); 335 ectx.filter = 'blur(12px)'; 336 ectx.drawImage(maskRaw, 0, 0); 337 ectx.filter = 'none'; 338 const expData = ectx.getImageData(0, 0, state.imgWidth, state.imgHeight); 339 for (let i = 0; i < expData.data.length; i += 4) { 340 const a = expData.data[i + 3]; 341 const v = a > 6 ? 255 : 0; 342 expData.data[i] = v; 343 expData.data[i + 1] = v; 344 expData.data[i + 2] = v; 345 expData.data[i + 3] = v; 346 } 347 ectx.putImageData(expData, 0, 0); 348 // 4) Temporarily replace the active mask sub-layer with the 349 // outpaint mask. Snapshot the previous so we can restore. 350 const mask = ensureActiveMaskLayer(); 351 if (!mask) { if (uiModule) uiModule.showToast('No active layer for outpaint'); return; } 352 const savedMask = mask.ctx.getImageData(0, 0, mask.canvas.width, mask.canvas.height); 353 mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height); 354 mask.ctx.drawImage(expanded, 0, 0); 355 // 5) Prompt: prefer user input, else a generic fill. 356 const userP = document.getElementById('ge-inpaint-prompt')?.value?.trim(); 357 const prompt = userP || 'seamless natural continuation of the surrounding image, photorealistic, matching style, no objects, no people, no text'; 358 const strength = 0.99; 359 try { 360 await runInpaint({ 361 prompt, strength, 362 btnId: 'ge-inpaint-outpaint', 363 labelId: 'ge-inpaint-outpaint-label', 364 idleLabel: 'Outpaint', busyLabel: 'Outpainting', 365 }); 366 } finally { 367 // Restore the user's previous mask drawing so subsequent 368 // Generate/Remove operates on what they actually drew. 369 mask.ctx.clearRect(0, 0, mask.canvas.width, mask.canvas.height); 370 mask.ctx.putImageData(savedMask, 0, 0); 371 composite(); 372 } 373 }); 374}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.