PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/editor/ai-inpaint.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:01:20 UTC 19,087 bytes, 374 lines download raw bytes

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.