Add LaMa magic eraser, remote provider abstraction, and AI tool infrastructure

Backend:
- requirements.txt: add simple-lama-inpainting, rembg[gpu]; upgrade opencv to 4.10+
- app/config.py: add InvokeAI (url, model) and ComfyUI (url, model) settings; OPENAI_MODEL
- app/services/local_inpaint.py: LaMa, OpenCV, rembg wrappers (auto GPU/CPU)
- app/services/remote_provider.py: abstract RemoteAIProvider + OpenAI, InvokeAI, ComfyUI drivers
- app/routers/ai_tools.py: new /api/* endpoints — /erase, /inpaint/lama, /inpaint/fast,
  /background/remove, /inpaint/remote, /generate/txt2img, /generate/img2img,
  /generate/outpaint, GET /config (capability flags)
- app/main.py: register ai_tools router

Frontend:
- services/api.js: add erase(), textToImage(), imageToImage(), remoteInpaint(), getConfig()
- api/capabilities.js: lazy-fetch /api/config singleton; hasRemote() helper
- tools/ai_lama_erase.js: brush-paint mask → LaMa erase → apply to layer
- tools/ai_smart_inpaint.js: brush mask + dialog (Fast/Quality mode + prompt) → inpaint
- core/components/provider-badge.js: shows active provider + health in toolbar
- config.js: register ai_lama_erase and ai_smart_inpaint tools
- main.js: mount provider badge on load
- .env.example: document InvokeAI, ComfyUI, OpenAI provider settings

https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN
This commit is contained in:
Claude
2026-06-09 17:42:48 +00:00
parent d5898dd054
commit 27261c4ef4
14 changed files with 1493 additions and 13 deletions
+56
View File
@@ -0,0 +1,56 @@
/**
* Backend capabilities singleton.
* Fetched once on load from GET /api/config.
* Tools use this to decide whether to show, grey out, or show tooltips.
*
* Shape:
* {
* local: { lama, rembg, opencv, gpu_detected },
* remote: { provider, capabilities: string[], healthy }
* }
*/
import apiService from '../services/api.js';
const DEFAULT_CAPS = {
local: { lama: false, rembg: false, opencv: true, gpu_detected: false },
remote: { provider: null, capabilities: [], healthy: false },
};
let _caps = null;
let _fetchPromise = null;
/**
* Return capabilities (fetched lazily, cached thereafter).
* Always resolves — falls back to DEFAULT_CAPS on network error.
*/
export async function getCapabilities() {
if (_caps) return _caps;
if (!_fetchPromise) {
_fetchPromise = apiService.getConfig()
.then(data => { _caps = data || DEFAULT_CAPS; return _caps; })
.catch(() => { _caps = DEFAULT_CAPS; return _caps; });
}
return _fetchPromise;
}
/**
* Synchronous check — returns cached value or DEFAULT_CAPS if not yet loaded.
*/
export function getCachedCapabilities() {
return _caps || DEFAULT_CAPS;
}
/**
* True if the remote provider is configured and healthy.
*/
export function hasRemote() {
return !!(_caps?.remote?.healthy);
}
/**
* Kick off the fetch immediately at module load time so it's ready when tools need it.
*/
getCapabilities();
export default { getCapabilities, getCachedCapabilities, hasRemote };
+23
View File
@@ -110,6 +110,29 @@ config.TOOLS = [
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',
on_activate: 'on_activate',
attributes: {
size: {
value: 30,
min: 5,
max: 200,
},
},
},
{
name: 'magic_wand',
title: 'Magic Wand (Color Select)',
@@ -0,0 +1,61 @@
/**
* ProviderBadge — small DOM element showing the active AI provider.
* Inserted into the toolbar footer on app load.
*
* Green = remote provider healthy
* Yellow = provider configured but unhealthy/unreachable
* Grey = local only (LaMa + OpenCV)
*/
import { getCapabilities } from '../../api/capabilities.js';
export async function mountProviderBadge(container) {
var caps = await getCapabilities();
var badge = document.createElement('div');
badge.id = 'provider-badge';
badge.style.cssText = [
'display:inline-flex', 'align-items:center', 'gap:5px',
'padding:3px 8px', 'border-radius:10px',
'font-size:11px', 'font-family:sans-serif',
'cursor:default', 'user-select:none',
'margin:4px', 'opacity:0.85',
].join(';');
var dot = document.createElement('span');
dot.style.cssText = 'width:7px;height:7px;border-radius:50%;display:inline-block;';
var label = document.createElement('span');
var remote = caps.remote || {};
var local = caps.local || {};
if (remote.provider && remote.healthy) {
dot.style.background = '#44cc44';
badge.style.background = '#1a2a1a';
badge.style.color = '#aaffaa';
label.textContent = remote.provider + (local.gpu_detected ? ' · GPU' : ' · CPU');
badge.title = 'Remote provider: ' + remote.provider + '\nCapabilities: ' + (remote.capabilities || []).join(', ');
} else if (remote.provider && !remote.healthy) {
dot.style.background = '#ffaa00';
badge.style.background = '#2a2000';
badge.style.color = '#ffdd88';
label.textContent = remote.provider + ' (offline)';
badge.title = remote.provider + ' is configured but not reachable. Check your .env URL.';
} else {
dot.style.background = '#888888';
badge.style.background = '#1a1a1a';
badge.style.color = '#aaaaaa';
label.textContent = 'Local' + (local.lama ? ' · LaMa' : '') + (local.gpu_detected ? ' · GPU' : '');
badge.title = 'Local only. Set AI_PROVIDER in .env to enable generative tools.';
}
badge.appendChild(dot);
badge.appendChild(label);
if (container) {
container.appendChild(badge);
}
return badge;
}
+4
View File
@@ -23,6 +23,7 @@ import Base_search_class from './core/base-search.js';
import File_open_class from './modules/file/open.js';
import File_save_class from './modules/file/save.js';
import * as Actions from './actions/index.js';
import { mountProviderBadge } from './core/components/provider-badge.js';
window.addEventListener('load', function (e) {
// Initiate app
@@ -54,4 +55,7 @@ window.addEventListener('load', function (e) {
// Render all
GUI.init();
Layers.init();
// Mount provider badge in the tools panel footer
mountProviderBadge(document.getElementById('tools_container') || document.body);
}, false);
+118
View File
@@ -95,6 +95,124 @@ class ApiService {
return response.json();
}
/**
* AI erase using LaMa (local, no API key needed)
* @param {string} imageData - Base64 encoded image
* @param {string} maskData - Base64 encoded mask (white = erase)
* @returns {Promise<{result: string, method: string}>}
*/
async erase(imageData, maskData) {
const response = await fetch(`${this.baseUrl}/api/erase`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageData, mask: maskData }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `Erase request failed: ${response.status}`);
}
return response.json();
}
/**
* Text-to-image via remote provider
* @param {string} prompt
* @param {Object} options - width, height, negativePrompt, steps, cfgScale, model
* @returns {Promise<{result: string}>}
*/
async textToImage(prompt, options = {}) {
const response = await fetch(`${this.baseUrl}/api/generate/txt2img`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
width: options.width || 1024,
height: options.height || 1024,
negative_prompt: options.negativePrompt || '',
steps: options.steps || 30,
cfg_scale: options.cfgScale || 7.5,
model: options.model || null,
seed: options.seed || 0,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `Text-to-image failed: ${response.status}`);
}
return response.json();
}
/**
* Image-to-image via remote provider
* @param {string} imageData - Base64 encoded image
* @param {string} prompt
* @param {Object} options
* @returns {Promise<{result: string}>}
*/
async imageToImage(imageData, prompt, options = {}) {
const response = await fetch(`${this.baseUrl}/api/generate/img2img`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: imageData,
prompt,
strength: options.strength || 0.75,
negative_prompt: options.negativePrompt || '',
steps: options.steps || 30,
cfg_scale: options.cfgScale || 7.5,
model: options.model || null,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `Image-to-image failed: ${response.status}`);
}
return response.json();
}
/**
* Inpaint with prompt via remote provider
* @param {string} imageData - Base64
* @param {string} maskData - Base64
* @param {string} prompt
* @param {Object} options
* @returns {Promise<{result: string}>}
*/
async remoteInpaint(imageData, maskData, prompt, options = {}) {
const response = await fetch(`${this.baseUrl}/api/inpaint/remote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: imageData,
mask: maskData,
prompt,
negative_prompt: options.negativePrompt || '',
steps: options.steps || 30,
cfg_scale: options.cfgScale || 7.5,
model: options.model || null,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || `Remote inpaint failed: ${response.status}`);
}
return response.json();
}
/**
* Fetch backend capabilities (local tools available, remote provider status).
* @returns {Promise<Object>}
*/
async getConfig() {
try {
const response = await fetch(`${this.baseUrl}/api/config`);
if (!response.ok) return null;
return response.json();
} catch {
return null;
}
}
/**
* Health check for the backend
* @returns {Promise<boolean>}
+199
View File
@@ -0,0 +1,199 @@
/**
* AI Magic Eraser — paint a mask with a brush, send to LaMa backend, apply result.
* Works locally (no API key). GPU auto-detected; CPU fallback always available.
*
* Workflow:
* 1. User paints over the object to erase (red overlay shows the mask)
* 2. On mouseup, POST image + mask to /api/erase
* 3. Result replaces the current layer canvas
*
* Registered as tool name: "ai_lama_erase"
*/
import app from './../app.js';
import config from './../config.js';
import Base_tools_class from './../core/base-tools.js';
import Base_layers_class from './../core/base-layers.js';
import Helper_class from './../libs/helpers.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
import apiService from './../services/api.js';
class Ai_lama_erase_class extends Base_tools_class {
constructor(ctx) {
super();
this.Base_layers = new Base_layers_class();
this.Helper = new Helper_class();
this.ctx = ctx;
this.name = 'ai_lama_erase';
this.isDrawing = false;
this.isProcessing = false;
// Off-screen canvas used to accumulate the painted mask
this.maskCanvas = null;
this.maskCtx = null;
}
load() {
var _this = this;
document.addEventListener('mousedown', function (e) { _this.mousedown(e); });
document.addEventListener('mousemove', function (e) { _this.mousemove(e); });
document.addEventListener('mouseup', function (e) { _this.mouseup(e); });
document.addEventListener('touchstart', function (e) { _this.mousedown(e); }, { passive: false });
document.addEventListener('touchmove', function (e) { _this.mousemove(e); }, { passive: false });
document.addEventListener('touchend', function (e) { _this.mouseup(e); });
}
mousedown(e) {
var mouse = this.get_mouse_info(e);
if (!mouse.click_valid) return;
if (config.TOOL.name !== this.name) return;
if (this.isProcessing) return;
if (config.layer.type !== 'image') {
alertify.error('This layer must contain an image.');
return;
}
this._initMask();
this.isDrawing = true;
this._paint(mouse);
}
mousemove(e) {
if (!this.isDrawing) return;
if (config.TOOL.name !== this.name) return;
var mouse = this.get_mouse_info(e);
this._paint(mouse);
}
mouseup(e) {
if (!this.isDrawing) return;
this.isDrawing = false;
if (config.TOOL.name !== this.name) return;
this._applyErase();
}
// ── Private ──────────────────────────────────────────────────────────────
_initMask() {
var w = config.layer.width_original;
var h = config.layer.height_original;
if (!this.maskCanvas || this.maskCanvas.width !== w || this.maskCanvas.height !== h) {
this.maskCanvas = document.createElement('canvas');
this.maskCanvas.width = w;
this.maskCanvas.height = h;
this.maskCtx = this.maskCanvas.getContext('2d');
}
this.maskCtx.clearRect(0, 0, w, h);
}
_paint(mouse) {
var params = this.getParams();
var size = params.size || 30;
// Map screen coords → layer-original coords
var lx = Math.round(this.adaptSize(Math.round(mouse.x) - config.layer.x, 'width'));
var ly = Math.round(this.adaptSize(Math.round(mouse.y) - config.layer.y, 'height'));
this.maskCtx.beginPath();
this.maskCtx.arc(lx, ly, size / 2, 0, Math.PI * 2);
this.maskCtx.fillStyle = '#ffffff';
this.maskCtx.fill();
// Show red overlay on screen so user can see the painted area
this._renderOverlay(lx, ly, size);
}
_renderOverlay(lx, ly, size) {
// Draw a translucent red circle on the main canvas for visual feedback
var scale = config.ZOOM / 100;
var sx = config.layer.x * scale + lx * scale;
var sy = config.layer.y * scale + ly * scale;
var sRadius = (size / 2) * scale;
var mainCtx = document.getElementById('canvas_temp')
? document.getElementById('canvas_temp').getContext('2d')
: null;
if (!mainCtx) return;
mainCtx.save();
mainCtx.beginPath();
mainCtx.arc(sx, sy, sRadius, 0, Math.PI * 2);
mainCtx.fillStyle = 'rgba(255, 60, 60, 0.4)';
mainCtx.fill();
mainCtx.restore();
}
async _applyErase() {
if (this.isProcessing) return;
// Check if any mask pixels were painted
var maskData = this.maskCtx.getImageData(
0, 0, this.maskCanvas.width, this.maskCanvas.height
);
var hasPixels = maskData.data.some((v, i) => i % 4 === 3 && v > 0);
if (!hasPixels) return;
this.isProcessing = true;
alertify.message('AI erasing... please wait', 0);
try {
// Get current layer as PNG base64
var layerCanvas = document.createElement('canvas');
layerCanvas.width = config.layer.width_original;
layerCanvas.height = config.layer.height_original;
var lctx = layerCanvas.getContext('2d');
lctx.drawImage(config.layer.link, 0, 0);
var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
// Get mask as PNG base64
var maskB64 = this.maskCanvas.toDataURL('image/png').split(',')[1];
// Call backend
var result = await apiService.erase(imageB64, maskB64);
// Apply result back to layer
var img = new Image();
img.onload = () => {
var resultCanvas = document.createElement('canvas');
resultCanvas.width = config.layer.width_original;
resultCanvas.height = config.layer.height_original;
resultCanvas.getContext('2d').drawImage(img, 0, 0);
app.State.do_action(
new app.Actions.Bundle_action('ai_lama_erase', 'AI Erase', [
new app.Actions.Update_layer_image_action(resultCanvas)
])
);
alertify.dismissAll();
alertify.success('Erased! (' + result.method + ')');
this.isProcessing = false;
this._clearOverlay();
};
img.onerror = () => {
alertify.dismissAll();
alertify.error('Failed to load result image.');
this.isProcessing = false;
};
img.src = 'data:image/png;base64,' + result.result;
} catch (err) {
alertify.dismissAll();
alertify.error('AI erase failed: ' + (err.message || err));
this.isProcessing = false;
}
}
_clearOverlay() {
var canvas = document.getElementById('canvas_temp');
if (canvas) {
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
}
}
}
export default Ai_lama_erase_class;
+201
View File
@@ -0,0 +1,201 @@
/**
* AI Smart Inpaint — paint a mask, enter a prompt, choose Fast (LaMa) or Quality (remote).
*
* Fast mode: /api/erase — LaMa local, no API key, seconds
* Quality mode: /api/inpaint/remote — InvokeAI / ComfyUI / OpenAI, requires configured provider
*
* Registered as tool name: "ai_smart_inpaint"
*/
import app from './../app.js';
import config from './../config.js';
import Base_tools_class from './../core/base-tools.js';
import Base_layers_class from './../core/base-layers.js';
import Helper_class from './../libs/helpers.js';
import Dialog_class from './../libs/popup.js';
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
import apiService from './../services/api.js';
import { getCapabilities } from './../api/capabilities.js';
class Ai_smart_inpaint_class extends Base_tools_class {
constructor(ctx) {
super();
this.Base_layers = new Base_layers_class();
this.Helper = new Helper_class();
this.POP = new Dialog_class();
this.ctx = ctx;
this.name = 'ai_smart_inpaint';
this.isDrawing = false;
this.isProcessing = false;
this.maskCanvas = null;
this.maskCtx = null;
}
load() {
var _this = this;
document.addEventListener('mousedown', function (e) { _this.mousedown(e); });
document.addEventListener('mousemove', function (e) { _this.mousemove(e); });
document.addEventListener('mouseup', function (e) { _this.mouseup(e); });
document.addEventListener('touchstart', function (e) { _this.mousedown(e); }, { passive: false });
document.addEventListener('touchmove', function (e) { _this.mousemove(e); }, { passive: false });
document.addEventListener('touchend', function (e) { _this.mouseup(e); });
}
on_activate() {
// Nothing on activate — tool is drag-to-paint, then dialog on mouseup
}
mousedown(e) {
var mouse = this.get_mouse_info(e);
if (!mouse.click_valid) return;
if (config.TOOL.name !== this.name) return;
if (this.isProcessing) return;
if (config.layer.type !== 'image') {
alertify.error('This layer must contain an image.');
return;
}
this._initMask();
this.isDrawing = true;
this._paint(mouse);
}
mousemove(e) {
if (!this.isDrawing) return;
if (config.TOOL.name !== this.name) return;
this._paint(this.get_mouse_info(e));
}
mouseup(e) {
if (!this.isDrawing) return;
this.isDrawing = false;
if (config.TOOL.name !== this.name) return;
var maskData = this.maskCtx.getImageData(
0, 0, this.maskCanvas.width, this.maskCanvas.height
);
if (!maskData.data.some((v, i) => i % 4 === 3 && v > 0)) return;
this._showDialog();
}
// ── Private ──────────────────────────────────────────────────────────────
_initMask() {
var w = config.layer.width_original;
var h = config.layer.height_original;
if (!this.maskCanvas || this.maskCanvas.width !== w || this.maskCanvas.height !== h) {
this.maskCanvas = document.createElement('canvas');
this.maskCanvas.width = w;
this.maskCanvas.height = h;
this.maskCtx = this.maskCanvas.getContext('2d');
}
this.maskCtx.clearRect(0, 0, w, h);
}
_paint(mouse) {
var params = this.getParams();
var size = params.size || 30;
var lx = Math.round(this.adaptSize(Math.round(mouse.x) - config.layer.x, 'width'));
var ly = Math.round(this.adaptSize(Math.round(mouse.y) - config.layer.y, 'height'));
this.maskCtx.beginPath();
this.maskCtx.arc(lx, ly, size / 2, 0, Math.PI * 2);
this.maskCtx.fillStyle = '#ffffff';
this.maskCtx.fill();
}
async _showDialog() {
var caps = await getCapabilities();
var hasRemote = caps.remote && caps.remote.healthy;
var _this = this;
var settings = {
title: 'AI Smart Inpaint',
params: [
{
name: 'quality',
title: 'Mode:',
value: 'fast',
values: hasRemote ? ['fast', 'quality'] : ['fast'],
note: hasRemote ? 'Fast = LaMa (local). Quality = remote AI + prompt.' : 'Quality mode requires a remote provider (InvokeAI / ComfyUI / OpenAI).',
},
{
name: 'prompt',
title: 'What to put here (Quality mode only):',
type: 'textarea',
value: '',
placeholder: "e.g. 'lush green grass', 'wooden table surface', 'clear blue sky'",
},
{
name: 'negative_prompt',
title: 'Avoid (optional):',
value: '',
placeholder: 'blurry, distorted',
},
],
on_load: function (params, popup) {},
on_finish: function (params) {
_this._runInpaint(params.quality, params.prompt, params.negative_prompt);
},
};
this.POP.show(settings);
}
async _runInpaint(quality, prompt, negativePrompt) {
if (this.isProcessing) return;
this.isProcessing = true;
var modeLabel = quality === 'quality' ? 'Quality (remote)' : 'Fast (LaMa)';
alertify.message('Inpainting (' + modeLabel + ')... please wait', 0);
try {
var layerCanvas = document.createElement('canvas');
layerCanvas.width = config.layer.width_original;
layerCanvas.height = config.layer.height_original;
layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
var maskB64 = this.maskCanvas.toDataURL('image/png').split(',')[1];
var result;
if (quality === 'quality') {
result = await apiService.remoteInpaint(imageB64, maskB64, prompt || 'fill naturally', { negativePrompt });
} else {
result = await apiService.erase(imageB64, maskB64);
}
var img = new Image();
img.onload = () => {
var resultCanvas = document.createElement('canvas');
resultCanvas.width = config.layer.width_original;
resultCanvas.height = config.layer.height_original;
resultCanvas.getContext('2d').drawImage(img, 0, 0);
app.State.do_action(
new app.Actions.Bundle_action('ai_smart_inpaint', 'AI Smart Inpaint', [
new app.Actions.Update_layer_image_action(resultCanvas)
])
);
alertify.dismissAll();
alertify.success('Done!');
this.isProcessing = false;
};
img.onerror = () => {
alertify.dismissAll();
alertify.error('Failed to load result.');
this.isProcessing = false;
};
img.src = 'data:image/png;base64,' + result.result;
} catch (err) {
alertify.dismissAll();
alertify.error('Inpaint failed: ' + (err.message || err));
this.isProcessing = false;
}
}
}
export default Ai_smart_inpaint_class;