From b2a12c356f66df6c9e1fd0a66318bd462aba0d20 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:54:44 +0000 Subject: [PATCH] Add generative panels, provider settings UI, and credits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend: - tools/ai_replace_selection.js: use any selection → remote inpaint with prompt - modules/generate/text_to_image.js: Text → Image dialog (new layer or replace canvas) - modules/generate/outpaint.js: Expand Canvas in any direction via remote provider - modules/tools/ai_provider_settings.js: in-app provider config (OpenAI / InvokeAI / ComfyUI / Replicate); persists to localStorage, pushes to POST /api/config at runtime - config.js: register ai_replace_selection tool - config-menu.js: add Generate menu (Text→Image, Outpaint); AI Provider Settings under Tools - modules/help/about.js: updated credits (LaMa, rembg, SAM, InvokeAI, ComfyUI, OpenAI) - api/capabilities.js: add refreshCapabilities() for post-save cache invalidation Backend: - routers/ai_tools.py: POST /api/config — apply provider settings at runtime without restart (session-scoped, non-persistent; .env for permanence) https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN --- backend/app/routers/ai_tools.py | 43 ++++ frontend/src/js/api/capabilities.js | 11 +- frontend/src/js/config-menu.js | 23 ++ frontend/src/js/config.js | 6 + frontend/src/js/modules/generate/outpaint.js | 142 ++++++++++++ .../src/js/modules/generate/text_to_image.js | 174 ++++++++++++++ frontend/src/js/modules/help/about.js | 20 +- .../js/modules/tools/ai_provider_settings.js | 163 +++++++++++++ frontend/src/js/tools/ai_replace_selection.js | 218 ++++++++++++++++++ 9 files changed, 791 insertions(+), 9 deletions(-) create mode 100644 frontend/src/js/modules/generate/outpaint.js create mode 100644 frontend/src/js/modules/generate/text_to_image.js create mode 100644 frontend/src/js/modules/tools/ai_provider_settings.js create mode 100644 frontend/src/js/tools/ai_replace_selection.js diff --git a/backend/app/routers/ai_tools.py b/backend/app/routers/ai_tools.py index edaa896..0f040aa 100644 --- a/backend/app/routers/ai_tools.py +++ b/backend/app/routers/ai_tools.py @@ -244,6 +244,49 @@ async def outpaint(req: OutpaintRequest): # ─── Config / capabilities ──────────────────────────────────────────────────── +class ConfigUpdateRequest(BaseModel): + ai_provider: Optional[str] = None + openai_api_key: Optional[str] = None + openai_model: Optional[str] = None + invokeai_url: Optional[str] = None + invokeai_default_model: Optional[str] = None + comfyui_url: Optional[str] = None + comfyui_default_model: Optional[str] = None + replicate_api_key: Optional[str] = None + stability_api_key: Optional[str] = None + + +@router.post("/config") +async def update_config(req: ConfigUpdateRequest): + """ + Apply runtime provider settings (no restart needed). + Values are applied to the live settings object in-process. + They do NOT persist across restarts — set them in .env for permanence. + """ + from app.config import settings + + if req.ai_provider is not None: + settings.ai_provider = req.ai_provider + if req.openai_api_key: + settings.openai_api_key = req.openai_api_key + if req.openai_model: + settings.openai_model = req.openai_model + if req.invokeai_url is not None: + settings.invokeai_url = req.invokeai_url + if req.invokeai_default_model: + settings.invokeai_default_model = req.invokeai_default_model + if req.comfyui_url is not None: + settings.comfyui_url = req.comfyui_url + if req.comfyui_default_model: + settings.comfyui_default_model = req.comfyui_default_model + if req.replicate_api_key: + settings.replicate_api_key = req.replicate_api_key + if req.stability_api_key: + settings.stability_api_key = req.stability_api_key + + return {"status": "ok", "ai_provider": settings.ai_provider} + + @router.get("/config") async def get_config(): """ diff --git a/frontend/src/js/api/capabilities.js b/frontend/src/js/api/capabilities.js index feb6307..b49b423 100644 --- a/frontend/src/js/api/capabilities.js +++ b/frontend/src/js/api/capabilities.js @@ -48,9 +48,18 @@ export function hasRemote() { return !!(_caps?.remote?.healthy); } +/** + * Invalidate cache and re-fetch (call after saving provider settings). + */ +export async function refreshCapabilities() { + _caps = null; + _fetchPromise = null; + return getCapabilities(); +} + /** * Kick off the fetch immediately at module load time so it's ready when tools need it. */ getCapabilities(); -export default { getCapabilities, getCachedCapabilities, hasRemote }; +export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities }; diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js index e99b763..2ab5d44 100644 --- a/frontend/src/js/config-menu.js +++ b/frontend/src/js/config-menu.js @@ -816,9 +816,32 @@ const menuDefinition = [ name: 'Settings', ellipsis: true, target: 'tools/settings.settings' + }, + { + divider: true + }, + { + name: 'AI Provider Settings', + ellipsis: true, + target: 'tools/ai_provider_settings.ai_provider_settings' } ] }, + { + name: 'Generate', + children: [ + { + name: 'Text → Image', + ellipsis: true, + target: 'generate/text_to_image.text_to_image' + }, + { + name: 'Expand Canvas (Outpaint)', + ellipsis: true, + target: 'generate/outpaint.outpaint' + }, + ] + }, { name: 'Help', children: [ diff --git a/frontend/src/js/config.js b/frontend/src/js/config.js index a2756fc..466112c 100644 --- a/frontend/src/js/config.js +++ b/frontend/src/js/config.js @@ -133,6 +133,12 @@ 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)', diff --git a/frontend/src/js/modules/generate/outpaint.js b/frontend/src/js/modules/generate/outpaint.js new file mode 100644 index 0000000..cd7165a --- /dev/null +++ b/frontend/src/js/modules/generate/outpaint.js @@ -0,0 +1,142 @@ +/** + * Outpaint / Expand Canvas — remote provider fills the new region. + * Menu target: generate/outpaint.outpaint + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.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'; + +var instance = null; + +class Generate_outpaint_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async outpaint() { + var caps = await getCapabilities(); + if (!caps.remote || !caps.remote.healthy) { + alertify.error( + 'Expand Canvas requires a remote AI provider. ' + + 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' + ); + return; + } + + var _this = this; + + this.Dialog.show({ + title: 'Expand Canvas (Outpaint)', + params: [ + { + name: 'direction', + title: 'Expand direction:', + value: 'right', + values: ['right', 'left', 'bottom', 'top'], + }, + { + name: 'size', + title: 'Pixels to add:', + type: 'range', + value: 256, + range: [64, 1024], + step: 64, + }, + { + name: 'prompt', + title: 'Describe the expansion (optional):', + value: '', + placeholder: "e.g. 'continue the landscape', 'more sky and clouds'", + }, + ], + on_finish: async function (params) { + await _this._run(params); + }, + }); + } + + async _run(params) { + if (this.isProcessing) return; + if (config.layer.type !== 'image') { + alertify.error('Current layer must be an image.'); + return; + } + + this.isProcessing = true; + alertify.message('Expanding canvas... 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 response = await fetch( + (window.API_BASE_URL || '') + '/api/generate/outpaint', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + direction: params.direction, + size: params.size || 256, + prompt: params.prompt || '', + }), + } + ); + if (!response.ok) { + var err = await response.json().catch(() => ({ detail: 'Unknown error' })); + throw new Error(err.detail || 'Outpaint failed'); + } + var result = await response.json(); + + var img = new Image(); + img.onload = () => { + var newW = img.naturalWidth; + var newH = img.naturalHeight; + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = newW; + resultCanvas.height = newH; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + // Update canvas dimensions and replace layer + config.WIDTH = newW; + config.HEIGHT = newH; + app.State.do_action( + new app.Actions.Bundle_action('outpaint', 'Expand Canvas', [ + new app.Actions.Resize_canvas_action(newW, newH), + new app.Actions.Update_layer_image_action(resultCanvas), + ]) + ); + + alertify.dismissAll(); + alertify.success('Canvas expanded!'); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load expanded image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Outpaint failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Generate_outpaint_class; diff --git a/frontend/src/js/modules/generate/text_to_image.js b/frontend/src/js/modules/generate/text_to_image.js new file mode 100644 index 0000000..d23ddca --- /dev/null +++ b/frontend/src/js/modules/generate/text_to_image.js @@ -0,0 +1,174 @@ +/** + * Text → Image — opens a sidebar-style dialog, generates via remote provider, + * pastes result as a new layer on the current canvas. + * + * Menu target: generate/text_to_image.text_to_image + */ + +import app from './../../app.js'; +import config from './../../config.js'; +import Base_layers_class from './../../core/base-layers.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'; + +var instance = null; + +class Generate_text_to_image_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async text_to_image() { + var caps = await getCapabilities(); + if (!caps.remote || !caps.remote.healthy) { + alertify.error( + 'Text → Image requires a remote AI provider. ' + + 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' + ); + return; + } + + var _this = this; + var canvasW = config.WIDTH || 1024; + var canvasH = config.HEIGHT || 1024; + + this.Dialog.show({ + title: 'Text → Image', + params: [ + { + name: 'prompt', + title: 'Describe your image:', + type: 'textarea', + value: '', + placeholder: "e.g. 'a serene mountain lake at sunset, cinematic lighting'", + }, + { + name: 'negative_prompt', + title: 'Avoid (optional):', + value: '', + placeholder: 'blurry, distorted, watermark', + }, + { + name: 'width', + title: 'Width (px):', + value: Math.min(canvasW, 1024), + range: [256, 2048], + step: 64, + type: 'range', + }, + { + name: 'height', + title: 'Height (px):', + value: Math.min(canvasH, 1024), + range: [256, 2048], + step: 64, + type: 'range', + }, + { + name: 'placement', + title: 'Add as:', + value: 'new_layer', + values: ['new_layer', 'replace_canvas'], + }, + { + name: 'steps', + title: 'Steps:', + type: 'range', + value: 30, + range: [10, 60], + step: 5, + }, + { + name: 'seed', + title: 'Seed (0 = random):', + value: 0, + range: [0, 2147483647], + step: 1, + type: 'range', + }, + ], + on_finish: async function (params) { + if (!params.prompt || !params.prompt.trim()) { + alertify.warning('Please enter a description.'); + return; + } + await _this._generate(params); + }, + }); + } + + async _generate(params) { + if (this.isProcessing) return; + this.isProcessing = true; + alertify.message('Generating image... please wait', 0); + + try { + var result = await apiService.textToImage(params.prompt, { + width: params.width || 1024, + height: params.height || 1024, + negativePrompt: params.negative_prompt || '', + steps: params.steps || 30, + seed: params.seed || 0, + }); + + var img = new Image(); + img.onload = () => { + if (params.placement === 'replace_canvas') { + // Resize canvas and replace bottom layer + config.WIDTH = img.naturalWidth; + config.HEIGHT = img.naturalHeight; + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + app.State.do_action( + new app.Actions.Bundle_action('txt2img_replace', 'Text → Image', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + } else { + // Add as new layer on top + var dataURL = img.src; + app.State.do_action( + new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [ + new app.Actions.Insert_layer_action({ + name: params.prompt.slice(0, 30), + type: 'image', + data: dataURL, + x: 0, + y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + width_original: img.naturalWidth, + height_original: img.naturalHeight, + }) + ]) + ); + } + alertify.dismissAll(); + alertify.success('Image generated!'); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load generated image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Generation failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Generate_text_to_image_class; diff --git a/frontend/src/js/modules/help/about.js b/frontend/src/js/modules/help/about.js index 192984a..405f1f3 100644 --- a/frontend/src/js/modules/help/about.js +++ b/frontend/src/js/modules/help/about.js @@ -9,19 +9,23 @@ class Help_about_class { //about about() { - var email = 'www.viliusl@gmail.com'; - + var email = 'www.viliusl@gmail.com'; + var settings = { title: 'About', params: [ {title: "", html: ''}, - {title: "Name:", html: 'miniPaint'}, + {title: "Name:", html: 'PaintPlus'}, {title: "Version:", value: VERSION}, - {title: "Description:", value: "Online image editor."}, - {title: "Author:", value: 'ViliusL'}, - {title: "Email:", html: '' + email + ''}, - {title: "GitHub:", html: 'https://github.com/viliusle/miniPaint'}, - {title: "Website:", html: 'https://viliusle.github.io/miniPaint/'}, + {title: "Description:", value: "Layer-based image editor with AI tools."}, + {title: "", html: '
'}, + {title: "Base:", html: 'miniPaint by ViliusL'}, + {title: "AI Erase:", html: 'LaMa (Samsung Research) via simple-lama-inpainting'}, + {title: "Bg Removal:", html: 'rembg / U2Net / OpenCV'}, + {title: "Smart Select:", html: 'SAM (Meta AI)'}, + {title: "Remote AI:", html: 'InvokeAI · ComfyUI · OpenAI (user-configured)'}, + {title: "", html: '
'}, + {title: "GitHub:", html: 'outis1one/EditmaskwithAI'}, ], }; this.POP.show(settings); diff --git a/frontend/src/js/modules/tools/ai_provider_settings.js b/frontend/src/js/modules/tools/ai_provider_settings.js new file mode 100644 index 0000000..088029a --- /dev/null +++ b/frontend/src/js/modules/tools/ai_provider_settings.js @@ -0,0 +1,163 @@ +/** + * AI Provider Settings — configure remote AI provider in-app without editing .env manually. + * Settings are persisted to localStorage and sent to the backend config endpoint. + * Menu target: tools/ai_provider_settings.ai_provider_settings + */ + +import Dialog_class from './../../libs/popup.js'; +import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js'; +import { getCapabilities } from './../../api/capabilities.js'; + +// localStorage key prefix +const LS = 'paintplus_ai_'; + +function ls_get(key, def = '') { + return localStorage.getItem(LS + key) ?? def; +} +function ls_set(key, val) { + localStorage.setItem(LS + key, val); +} + +var instance = null; + +class Tools_ai_provider_settings_class { + + constructor() { + if (instance) return instance; + instance = this; + this.POP = new Dialog_class(); + } + + async ai_provider_settings() { + var _this = this; + var caps = await getCapabilities(); + var remote = caps.remote || {}; + var statusHtml = remote.provider + ? (remote.healthy + ? `● ${remote.provider} — connected` + : `● ${remote.provider} — unreachable`) + : 'No remote provider configured'; + + this.POP.show({ + title: 'AI Provider Settings', + params: [ + { + title: 'Status:', + html: `
${statusHtml}
`, + }, + { + name: 'provider', + title: 'Remote provider:', + value: ls_get('provider', remote.provider || ''), + values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'], + type: 'select', + }, + // ── OpenAI ──────────────────────────────────────────────── + { + name: 'openai_key', + title: 'OpenAI API key:', + value: ls_get('openai_key'), + placeholder: 'sk-...', + }, + { + name: 'openai_model', + title: 'OpenAI model:', + value: ls_get('openai_model', 'dall-e-3'), + values: ['dall-e-3', 'dall-e-2'], + type: 'select', + }, + // ── InvokeAI ────────────────────────────────────────────── + { + name: 'invokeai_url', + title: 'InvokeAI URL:', + value: ls_get('invokeai_url'), + placeholder: 'http://192.168.1.x:9090', + }, + { + name: 'invokeai_model', + title: 'InvokeAI default model:', + value: ls_get('invokeai_model', 'flux-dev'), + placeholder: 'flux-dev', + }, + // ── ComfyUI ─────────────────────────────────────────────── + { + name: 'comfyui_url', + title: 'ComfyUI URL:', + value: ls_get('comfyui_url'), + placeholder: 'http://192.168.1.x:8188', + }, + { + name: 'comfyui_model', + title: 'ComfyUI default checkpoint:', + value: ls_get('comfyui_model', 'v1-5-pruned-emaonly.ckpt'), + placeholder: 'v1-5-pruned-emaonly.ckpt', + }, + // ── Replicate ───────────────────────────────────────────── + { + name: 'replicate_key', + title: 'Replicate API key:', + value: ls_get('replicate_key'), + placeholder: 'r8_...', + }, + ], + on_finish: async function (params) { + await _this._save(params); + }, + }); + } + + async _save(params) { + // Persist to localStorage + ls_set('provider', params.provider || ''); + ls_set('openai_key', params.openai_key || ''); + ls_set('openai_model', params.openai_model || 'dall-e-3'); + ls_set('invokeai_url', params.invokeai_url || ''); + ls_set('invokeai_model', params.invokeai_model || 'flux-dev'); + ls_set('comfyui_url', params.comfyui_url || ''); + ls_set('comfyui_model', params.comfyui_model || 'v1-5-pruned-emaonly.ckpt'); + ls_set('replicate_key', params.replicate_key || ''); + + // Push to backend (requires a running server that accepts runtime config) + try { + var payload = { + ai_provider: params.provider || '', + openai_api_key: params.openai_key || '', + openai_model: params.openai_model || 'dall-e-3', + invokeai_url: params.invokeai_url || '', + invokeai_default_model: params.invokeai_model || 'flux-dev', + comfyui_url: params.comfyui_url || '', + comfyui_default_model: params.comfyui_model || '', + replicate_api_key: params.replicate_key || '', + }; + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (r.ok) { + alertify.success('AI provider settings saved. Testing connection...'); + var { refreshCapabilities } = await import('./../../api/capabilities.js'); + var caps = await refreshCapabilities(); + if (caps?.remote?.healthy) { + alertify.success(`Connected to ${caps.remote.provider}!`); + } else if (params.provider) { + alertify.warning('Settings saved but provider is not reachable. Check URL/key.'); + } + } else { + // Server-side config update not supported — inform user to set .env + alertify.warning( + 'Settings saved locally. To make them permanent, ' + + 'set these values in your .env file and restart the server.' + ); + } + } catch { + alertify.warning( + 'Settings saved locally. Set AI_PROVIDER and related keys in .env to make permanent.' + ); + } + } +} + +export default Tools_ai_provider_settings_class; diff --git a/frontend/src/js/tools/ai_replace_selection.js b/frontend/src/js/tools/ai_replace_selection.js new file mode 100644 index 0000000..f601ac0 --- /dev/null +++ b/frontend/src/js/tools/ai_replace_selection.js @@ -0,0 +1,218 @@ +/** + * AI Replace Selection — pick any selection (Smart Select, Magic Wand, Lasso, Brush Select), + * describe what should go there, remote provider fills it in. + * + * Requires a configured remote provider (InvokeAI / ComfyUI / OpenAI). + * Registered as tool name: "ai_replace_selection" + */ + +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 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_replace_selection_class extends Base_tools_class { + + constructor(ctx) { + super(); + this.Base_layers = new Base_layers_class(); + this.POP = new Dialog_class(); + this.ctx = ctx; + this.name = 'ai_replace_selection'; + this.isProcessing = false; + } + + load() {} + + async on_activate() { + var caps = await getCapabilities(); + if (!caps.remote || !caps.remote.healthy) { + alertify.error( + 'Replace Selection requires a remote AI provider. ' + + 'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.' + ); + return; + } + + var hasMask = window.smartSelectMask?.canvas != null; + var hasRect = this._getRectSelection() != null; + + if (!hasMask && !hasRect) { + alertify.warning( + 'No selection found. Use Smart Select, Magic Wand, Lasso, ' + + 'Ellipse Select, or Brush Select first, then activate this tool.' + ); + return; + } + + this._showDialog(caps.remote.provider); + } + + // ── Private ────────────────────────────────────────────────────────────── + + _getRectSelection() { + if (!config.layer) return null; + var sel = config.layer.selection; + if (!sel) return null; + var { x, y, width, height } = sel; + if (!width || !height) return null; + return { x, y, width, height }; + } + + _showDialog(providerName) { + var _this = this; + + this.POP.show({ + title: 'AI Replace Selection', + params: [ + { + name: 'prompt', + title: 'Describe what to place here:', + type: 'textarea', + value: '', + placeholder: "e.g. 'a blooming red rose', 'dark polished wood', 'a smiling golden retriever'", + }, + { + name: 'negative_prompt', + title: 'Avoid (optional):', + value: '', + placeholder: 'blurry, distorted, low quality', + }, + { + name: 'steps', + title: 'Steps:', + type: 'range', + value: 30, + range: [10, 60], + step: 5, + }, + { + name: 'cfg_scale', + title: 'Prompt strength:', + type: 'range', + value: 75, + range: [10, 100], + step: 5, + }, + ], + on_finish: function (params) { + if (!params.prompt || !params.prompt.trim()) { + alertify.warning('Please enter a description.'); + return; + } + _this._run(params); + }, + }); + } + + async _run(params) { + if (this.isProcessing) return; + if (config.layer.type !== 'image') { + alertify.error('Current layer must be an image.'); + return; + } + + this.isProcessing = true; + alertify.message('Replacing selection... please wait', 0); + + try { + // Build mask canvas from current selection + var maskCanvas = await this._buildMaskCanvas(); + if (!maskCanvas) { + alertify.dismissAll(); + alertify.error('Could not build selection mask.'); + this.isProcessing = false; + return; + } + + // Get layer as PNG + 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 = maskCanvas.toDataURL('image/png').split(',')[1]; + + var result = await apiService.remoteInpaint( + imageB64, maskB64, + params.prompt, + { + negativePrompt: params.negative_prompt || '', + steps: params.steps || 30, + cfgScale: (params.cfg_scale || 75) / 10, + } + ); + + 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_replace_selection', 'AI Replace Selection', [ + 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 image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Replace failed: ' + (err.message || err)); + this.isProcessing = false; + } + } + + async _buildMaskCanvas() { + var w = config.layer.width_original; + var h = config.layer.height_original; + var canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + var ctx = canvas.getContext('2d'); + + // Prefer smartSelectMask (all selection tools write here) + if (window.smartSelectMask?.canvas) { + ctx.drawImage(window.smartSelectMask.canvas, 0, 0, w, h); + // Ensure pure B&W + var d = ctx.getImageData(0, 0, w, h); + for (var i = 0; i < d.data.length; i += 4) { + var v = d.data[i] > 128 ? 255 : 0; + d.data[i] = d.data[i+1] = d.data[i+2] = v; + d.data[i+3] = 255; + } + ctx.putImageData(d, 0, 0); + return canvas; + } + + // Fall back to rectangular selection + var sel = this._getRectSelection(); + if (sel) { + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = '#fff'; + ctx.fillRect(sel.x, sel.y, sel.width, sel.height); + return canvas; + } + + return null; + } +} + +export default Ai_replace_selection_class;