Replace scattered AI tools with single unified AI Edit tool

Before: 4 separate tools (ai_inpaint, ai_lama_erase, ai_smart_inpaint,
ai_replace_selection) with no clear relationship, buried in toolbar.

After: one "AI Edit" brush tool. Paint mask → floating action bar appears:
  Erase    — LaMa removes masked content, no prompt needed
  Replace  — inline prompt reveals in-place ("make her smile" / "replace
             with a wolf") → calls /api/inpaint/remote
  Upscale  — delegates to existing upscale module
  Expand   — delegates to existing outpaint module
  Clear    — wipe mask and start over

Brush size controlled by tool attributes (5–200px).
Red overlay gives immediate visual feedback while painting.
Floating panel appears as soon as first brush stroke is made.

https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN
This commit is contained in:
Claude
2026-06-10 16:42:10 +00:00
parent 717f482987
commit 1132656370
2 changed files with 393 additions and 25 deletions
+2 -25
View File
@@ -105,25 +105,8 @@ config.TOOLS = [
attributes: {},
},
{
name: 'ai_inpaint',
title: 'AI Inpaint',
on_activate: 'on_activate',
attributes: {},
},
{
name: 'ai_lama_erase',
title: 'AI Magic Erase (LaMa) - Paint over to erase',
attributes: {
size: {
value: 30,
min: 5,
max: 200,
},
},
},
{
name: 'ai_smart_inpaint',
title: 'AI Smart Inpaint - Paint + describe replacement',
name: 'ai_edit',
title: 'AI Edit — paint mask, then Erase / Replace / Upscale / Expand',
on_activate: 'on_activate',
attributes: {
size: {
@@ -133,12 +116,6 @@ config.TOOLS = [
},
},
},
{
name: 'ai_replace_selection',
title: 'AI Replace Selection - Use any selection tool first',
on_activate: 'on_activate',
attributes: {},
},
{
name: 'magic_wand',
title: 'Magic Wand (Color Select)',
+391
View File
@@ -0,0 +1,391 @@
/**
* AI Edit — unified inpainting tool.
*
* Workflow:
* 1. Brush over the area you want to change (red overlay)
* 2. Floating action bar appears: Erase | Replace | Upscale | Expand | Clear
* 3. Erase → LaMa-removes masked content
* Replace → inline prompt → AI replaces masked area
* Upscale → opens upscale dialog (whole image)
* Expand → opens outpaint/expand dialog (whole image)
* Clear → wipe the mask and start over
*
* Tool target: tools/ai_edit (auto-registered by webpack require.context)
*/
import app from './../app.js';
import config from './../config.js';
import Base_layers_class from './../core/base-layers.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
var instance = null;
// ── constants ──────────────────────────────────────────────────────────────
const BRUSH_COLOR = 'rgba(255, 60, 60, 0.55)';
const BRUSH_DEFAULT = 30;
class Tools_ai_edit_class {
constructor() {
if (instance) return instance;
instance = this;
this.Base_layers = new Base_layers_class();
this.name = 'ai_edit';
this.title = 'AI Edit';
// brush state
this._painting = false;
this._maskCanvas = null; // same size as layer original
this._maskCtx = null;
this._overlayEl = null; // red overlay <canvas> on top of main canvas
this._panel = null; // floating action bar DOM element
this._hasMask = false;
this._isRunning = false;
}
// ── Tool lifecycle ───────────────────────────────────────────────────────
on_activate() {
if (!config.layer || config.layer.type !== 'image') {
alertify.error('Select an image layer first.');
return;
}
this._initMask();
this._mountOverlay();
this._mountPanel();
}
on_leave() {
this._removeOverlay();
this._removePanel();
this._painting = false;
}
// ── Mouse / touch ────────────────────────────────────────────────────────
mousedown(e) {
if (!config.layer || config.layer.type !== 'image') return;
this._painting = true;
this._paint(e);
}
mousemove(e) {
if (!this._painting) return;
this._paint(e);
}
mouseup() {
this._painting = false;
if (this._hasMask) this._showPanel();
}
// ── Mask painting ────────────────────────────────────────────────────────
_initMask() {
const w = config.layer.width_original;
const h = config.layer.height_original;
this._maskCanvas = document.createElement('canvas');
this._maskCanvas.width = w;
this._maskCanvas.height = h;
this._maskCtx = this._maskCanvas.getContext('2d');
this._hasMask = false;
}
_paint(e) {
if (!this._maskCtx || !this._overlayEl) return;
// Map screen coords → original image coords
const canvasEl = document.getElementById('canvas_minipaint') || document.querySelector('canvas');
if (!canvasEl) return;
const rect = canvasEl.getBoundingClientRect();
const scaleX = config.layer.width_original / (config.WIDTH * config.ZOOM);
const scaleY = config.layer.height_original / (config.HEIGHT * config.ZOOM);
const x = ((e.clientX - rect.left) - config.layer.x * config.ZOOM) * scaleX;
const y = ((e.clientY - rect.top) - config.layer.y * config.ZOOM) * scaleY;
const r = (config.tools[this.name]?.size ?? BRUSH_DEFAULT) / 2;
// Draw on mask (white = area to process)
this._maskCtx.fillStyle = '#ffffff';
this._maskCtx.beginPath();
this._maskCtx.arc(x, y, r, 0, Math.PI * 2);
this._maskCtx.fill();
// Mirror onto overlay canvas (red tint for user feedback)
const oc = this._overlayEl;
const oct = oc.getContext('2d');
oct.fillStyle = BRUSH_COLOR;
// Map back: overlay is sized to the visible canvas area
const ox = (x / scaleX) + config.layer.x * config.ZOOM;
const oy = (y / scaleY) + config.layer.y * config.ZOOM;
const or_ = r / scaleX;
oct.beginPath();
oct.arc(ox, oy, or_, 0, Math.PI * 2);
oct.fill();
this._hasMask = true;
}
// ── Overlay canvas (red mask feedback) ───────────────────────────────────
_mountOverlay() {
this._removeOverlay();
const base = document.getElementById('canvas_minipaint') || document.querySelector('canvas');
if (!base) return;
const oc = document.createElement('canvas');
oc.id = 'ai_edit_overlay';
oc.width = base.offsetWidth;
oc.height = base.offsetHeight;
Object.assign(oc.style, {
position: 'absolute',
top: base.offsetTop + 'px',
left: base.offsetLeft + 'px',
pointerEvents: 'none',
zIndex: '50',
});
base.parentElement.appendChild(oc);
this._overlayEl = oc;
}
_removeOverlay() {
if (this._overlayEl) { this._overlayEl.remove(); this._overlayEl = null; }
}
// ── Floating action panel ─────────────────────────────────────────────────
_mountPanel() {
this._removePanel();
const panel = document.createElement('div');
panel.id = 'ai_edit_panel';
Object.assign(panel.style, {
position: 'fixed',
bottom: '80px',
left: '50%',
transform: 'translateX(-50%)',
background: '#1e1e1e',
border: '1px solid #444',
borderRadius: '10px',
padding: '10px 14px',
display: 'flex',
alignItems: 'center',
gap: '8px',
zIndex: '9999',
boxShadow: '0 4px 20px rgba(0,0,0,0.5)',
fontFamily: 'sans-serif',
fontSize: '13px',
color: '#eee',
userSelect: 'none',
flexWrap: 'wrap',
maxWidth: '600px',
});
panel.innerHTML = this._panelHTML();
document.body.appendChild(panel);
this._panel = panel;
this._wirePanel();
}
_panelHTML() {
return `
<span style="color:#888;font-size:11px;white-space:nowrap;">Paint mask, then:</span>
<button data-ai-action="erase" class="ai-panel-btn">✕ Erase</button>
<div id="ai_replace_wrap" style="display:flex;align-items:center;gap:6px;">
<button data-ai-action="replace" class="ai-panel-btn ai-panel-btn--primary">✦ Replace</button>
<input id="ai_replace_prompt" type="text" placeholder="make her smile / replace with a wolf…"
style="display:none;width:280px;padding:5px 8px;border-radius:6px;border:1px solid #555;
background:#2a2a2a;color:#eee;font-size:13px;outline:none;" />
<button id="ai_replace_go" style="display:none;" class="ai-panel-btn ai-panel-btn--primary">Go</button>
</div>
<button data-ai-action="upscale" class="ai-panel-btn">⬆ Upscale</button>
<button data-ai-action="expand" class="ai-panel-btn">↔ Expand</button>
<button data-ai-action="clear" class="ai-panel-btn ai-panel-btn--danger">↺ Clear</button>
<style>
.ai-panel-btn {
padding:5px 11px;border-radius:6px;border:1px solid #555;
background:#2a2a2a;color:#ddd;cursor:pointer;font-size:13px;
transition:background .15s;white-space:nowrap;
}
.ai-panel-btn:hover { background:#3a3a3a; }
.ai-panel-btn--primary { background:#2563eb;border-color:#3b82f6;color:#fff; }
.ai-panel-btn--primary:hover { background:#1d4ed8; }
.ai-panel-btn--danger { border-color:#7f1d1d;color:#f87171; }
.ai-panel-btn--danger:hover { background:#3a1a1a; }
</style>`;
}
_showPanel() {
if (this._panel) this._panel.style.opacity = '1';
}
_wirePanel() {
if (!this._panel) return;
const _this = this;
// Erase / Upscale / Expand / Clear buttons
this._panel.querySelectorAll('[data-ai-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.aiAction;
if (action === 'erase') _this._doErase();
if (action === 'upscale') _this._doUpscale();
if (action === 'expand') _this._doExpand();
if (action === 'clear') _this._doClear();
if (action === 'replace') _this._toggleReplaceInput();
});
});
// Replace → Go
const goBtn = this._panel.querySelector('#ai_replace_go');
const promptEl = this._panel.querySelector('#ai_replace_prompt');
if (goBtn && promptEl) {
goBtn.addEventListener('click', () => _this._doReplace(promptEl.value.trim()));
promptEl.addEventListener('keydown', e => {
if (e.key === 'Enter') _this._doReplace(promptEl.value.trim());
});
}
}
_toggleReplaceInput() {
const promptEl = this._panel && this._panel.querySelector('#ai_replace_prompt');
const goBtn = this._panel && this._panel.querySelector('#ai_replace_go');
if (!promptEl || !goBtn) return;
const shown = promptEl.style.display !== 'none';
promptEl.style.display = shown ? 'none' : 'inline-block';
goBtn.style.display = shown ? 'none' : 'inline-block';
if (!shown) setTimeout(() => promptEl.focus(), 50);
}
_removePanel() {
if (this._panel) { this._panel.remove(); this._panel = null; }
}
// ── Helpers ───────────────────────────────────────────────────────────────
_requireMask() {
if (!this._hasMask) {
alertify.error('Paint over the area you want to change first.');
return false;
}
return true;
}
/** Returns { imageB64, maskB64 } from current layer + painted mask. */
_getImageAndMask() {
const layer = config.layer;
const w = layer.width_original;
const h = layer.height_original;
// Image
const imgCanvas = document.createElement('canvas');
imgCanvas.width = w; imgCanvas.height = h;
imgCanvas.getContext('2d').drawImage(layer.link, 0, 0);
const imageB64 = imgCanvas.toDataURL('image/png').split(',')[1];
// Mask (white = selected, black = keep)
const maskB64 = this._maskCanvas.toDataURL('image/png').split(',')[1];
return { imageB64, maskB64 };
}
_applyResult(resultB64, actionLabel) {
const img = new Image();
img.onload = () => {
const rc = document.createElement('canvas');
rc.width = img.naturalWidth; rc.height = img.naturalHeight;
rc.getContext('2d').drawImage(img, 0, 0);
app.State.do_action(
new app.Actions.Bundle_action('ai_edit', actionLabel, [
new app.Actions.Update_layer_image_action(rc)
])
);
alertify.dismissAll();
alertify.success(`${actionLabel} applied.`);
this._isRunning = false;
this._doClear();
};
img.onerror = () => {
alertify.dismissAll();
alertify.error('Failed to load result image.');
this._isRunning = false;
};
img.src = 'data:image/png;base64,' + resultB64;
}
// ── Actions ───────────────────────────────────────────────────────────────
async _doErase() {
if (!this._requireMask() || this._isRunning) return;
this._isRunning = true;
alertify.message('Erasing…', 0);
try {
const { imageB64, maskB64 } = this._getImageAndMask();
const base = window.API_BASE_URL || '';
const r = await fetch(`${base}/api/erase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageB64, mask: maskB64 }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Erase failed');
const data = await r.json();
this._applyResult(data.result, 'Erase');
} catch (err) {
alertify.dismissAll();
alertify.error('Erase failed: ' + (err.message || err));
this._isRunning = false;
}
}
async _doReplace(prompt) {
if (!this._requireMask() || this._isRunning) return;
if (!prompt) { alertify.error('Type what you want to put there.'); return; }
this._isRunning = true;
alertify.message(`Replacing: "${prompt}"…`, 0);
try {
const { imageB64, maskB64 } = this._getImageAndMask();
const base = window.API_BASE_URL || '';
const r = await fetch(`${base}/api/inpaint/remote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageB64, mask: maskB64, prompt }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || 'Replace failed');
const data = await r.json();
this._applyResult(data.result, `Replace: ${prompt}`);
} catch (err) {
alertify.dismissAll();
alertify.error('Replace failed: ' + (err.message || err));
this._isRunning = false;
}
}
_doUpscale() {
// Delegate to the existing Upscale module
import('./../modules/image/upscale.js').then(m => {
const cls = m.default;
new cls().upscale();
});
}
_doExpand() {
import('./../modules/generate/outpaint.js').then(m => {
const cls = m.default;
new cls().outpaint();
});
}
_doClear() {
if (this._maskCtx) {
this._maskCtx.clearRect(0, 0, this._maskCanvas.width, this._maskCanvas.height);
}
if (this._overlayEl) {
this._overlayEl.getContext('2d').clearRect(
0, 0, this._overlayEl.width, this._overlayEl.height
);
}
// Hide the replace input
const promptEl = this._panel && this._panel.querySelector('#ai_replace_prompt');
const goBtn = this._panel && this._panel.querySelector('#ai_replace_go');
if (promptEl) { promptEl.style.display = 'none'; promptEl.value = ''; }
if (goBtn) goBtn.style.display = 'none';
this._hasMask = false;
}
}
export default Tools_ai_edit_class;