feat: real-world selection actions — scale %, AI edit, clipboard paste
Backend (3 new endpoints under /api/image/): - POST /api/image/scale-selection — scale selected object by any % in-place; LaMa/OpenCV fills the exposed gap so the scene looks natural - POST /api/image/ai-edit-region — AI redraws the masked region via the configured inpaint provider (local_gpu / InvokeAI / ComfyUI / OpenAI) - POST /api/image/paste-into-selection — scales clipboard image to fit the selection bounding box, masks it to the selection shape, composites result Frontend (selection_actions.js + tool integration): - New SelectionActions panel: fixed bottom-center HUD that appears automatically after every SAM selection (click or paint) - Panel actions: Scale by % (default 3%), Make less symmetrical (AI), custom AI Edit prompt, Replace with clipboard, Copy/Cut to layer, Erase - Both smart_select.js and brush_select.js updated to show the panel, add updateLayerWithResult(), and hide panel on clearSelection/on_leave - brush_select: offerFloatSelection() replaced with richer action panel Real-world workflows now supported in one click after painting over object: "Make this 3% bigger" → scale-selection (LaMa fills gap) "Make this less symmetrical" → ai-edit-region with asymmetry prompt "Replace this with what I copied" → paste-into-selection https://claude.ai/code/session_01WVDg7amsy1TTtxvpku7bcM
This commit is contained in:
@@ -367,6 +367,164 @@ async def get_config():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Selection image operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ScaleSelectionRequest(BaseModel):
|
||||||
|
image: str # base64 full canvas
|
||||||
|
mask: str # base64 selection mask (white = object)
|
||||||
|
scale_pct: float = 103.0 # 103 = 3% bigger, 95 = 5% smaller
|
||||||
|
|
||||||
|
|
||||||
|
class AiEditRegionRequest(BaseModel):
|
||||||
|
image: str
|
||||||
|
mask: str
|
||||||
|
instruction: str
|
||||||
|
negative_prompt: str = ""
|
||||||
|
steps: int = 30
|
||||||
|
cfg_scale: float = 7.5
|
||||||
|
|
||||||
|
|
||||||
|
class PasteIntoSelectionRequest(BaseModel):
|
||||||
|
image: str # base64 target canvas
|
||||||
|
mask: str # base64 selection mask
|
||||||
|
paste_image: str # base64 image to paste
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/scale-selection")
|
||||||
|
async def scale_selection(req: ScaleSelectionRequest):
|
||||||
|
"""
|
||||||
|
Scale the object selected by mask by scale_pct%, AI-fill the exposed gap.
|
||||||
|
Works purely with local tools (LaMa/OpenCV) — no remote provider needed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="PIL/numpy not available")
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(_decode(req.image))).convert("RGB")
|
||||||
|
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
|
||||||
|
if img.size != mask.size:
|
||||||
|
mask = mask.resize(img.size, Image.LANCZOS)
|
||||||
|
|
||||||
|
mask_arr = np.array(mask)
|
||||||
|
ys, xs = np.where(mask_arr > 128)
|
||||||
|
if len(xs) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty mask — nothing to scale")
|
||||||
|
|
||||||
|
minx, maxx = int(xs.min()), int(xs.max())
|
||||||
|
miny, maxy = int(ys.min()), int(ys.max())
|
||||||
|
cx, cy = (minx + maxx) / 2.0, (miny + maxy) / 2.0
|
||||||
|
obj_w, obj_h = maxx - minx + 1, maxy - miny + 1
|
||||||
|
|
||||||
|
scale = req.scale_pct / 100.0
|
||||||
|
new_w = max(1, round(obj_w * scale))
|
||||||
|
new_h = max(1, round(obj_h * scale))
|
||||||
|
|
||||||
|
# Extract masked object crop (RGBA with mask as alpha)
|
||||||
|
img_rgba = img.convert("RGBA")
|
||||||
|
obj_crop = img_rgba.crop((minx, miny, maxx + 1, maxy + 1))
|
||||||
|
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
|
||||||
|
r, g, b, _ = obj_crop.split()
|
||||||
|
obj_masked = Image.merge("RGBA", (r, g, b, mask_crop))
|
||||||
|
scaled_obj = obj_masked.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
|
||||||
|
# AI-fill the original mask area (gap) with LaMa/OpenCV
|
||||||
|
gap_mask = mask.filter(ImageFilter.MaxFilter(9)) # expand ~4px for clean seam
|
||||||
|
gap_bytes = BytesIO()
|
||||||
|
img.save(gap_bytes, format="PNG")
|
||||||
|
gap_mask_bytes = BytesIO()
|
||||||
|
gap_mask.save(gap_mask_bytes, format="PNG")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if lama_available():
|
||||||
|
filled_bytes = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, lama_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
filled_bytes = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, opencv_inpaint, gap_bytes.getvalue(), gap_mask_bytes.getvalue()
|
||||||
|
)
|
||||||
|
filled = Image.open(BytesIO(filled_bytes)).convert("RGBA")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[scale-selection] fill fallback: {exc}")
|
||||||
|
filled = img.convert("RGBA")
|
||||||
|
|
||||||
|
# Paste scaled object centered on original centroid
|
||||||
|
px = round(cx - new_w / 2)
|
||||||
|
py = round(cy - new_h / 2)
|
||||||
|
result = filled.copy()
|
||||||
|
result.paste(scaled_obj, (px, py), scaled_obj.split()[3])
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
result.convert("RGB").save(out, format="PNG")
|
||||||
|
return {"result": _encode(out.getvalue())}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/ai-edit-region")
|
||||||
|
async def ai_edit_region(req: AiEditRegionRequest):
|
||||||
|
"""
|
||||||
|
AI-edit the selected region using the configured inpaint provider.
|
||||||
|
Works with local_gpu, InvokeAI, ComfyUI, or OpenAI.
|
||||||
|
"""
|
||||||
|
provider = _require_remote("inpaint")
|
||||||
|
result_bytes = await provider.inpaint(
|
||||||
|
_decode(req.image),
|
||||||
|
_decode(req.mask),
|
||||||
|
req.instruction,
|
||||||
|
{"negative_prompt": req.negative_prompt, "steps": req.steps, "cfg_scale": req.cfg_scale},
|
||||||
|
)
|
||||||
|
return {"result": _encode(result_bytes)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/paste-into-selection")
|
||||||
|
async def paste_into_selection(req: PasteIntoSelectionRequest):
|
||||||
|
"""
|
||||||
|
Scale a clipboard image to the selection bounding box, mask it to the
|
||||||
|
selection shape, and composite it over the original canvas.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
except ImportError:
|
||||||
|
raise HTTPException(status_code=500, detail="PIL/numpy not available")
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(_decode(req.image))).convert("RGBA")
|
||||||
|
mask = Image.open(BytesIO(_decode(req.mask))).convert("L")
|
||||||
|
paste_img = Image.open(BytesIO(_decode(req.paste_image))).convert("RGBA")
|
||||||
|
|
||||||
|
if img.size != mask.size:
|
||||||
|
mask = mask.resize(img.size, Image.LANCZOS)
|
||||||
|
|
||||||
|
mask_arr = np.array(mask)
|
||||||
|
ys, xs = np.where(mask_arr > 128)
|
||||||
|
if len(xs) == 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Empty mask")
|
||||||
|
|
||||||
|
minx, maxx = int(xs.min()), int(xs.max())
|
||||||
|
miny, maxy = int(ys.min()), int(ys.max())
|
||||||
|
target_w = maxx - minx + 1
|
||||||
|
target_h = maxy - miny + 1
|
||||||
|
|
||||||
|
# Scale clipboard image to fit the selection bounding box
|
||||||
|
paste_scaled = paste_img.resize((target_w, target_h), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Clip paste to selection shape using mask
|
||||||
|
mask_crop = mask.crop((minx, miny, maxx + 1, maxy + 1))
|
||||||
|
r, g, b, a = paste_scaled.split()
|
||||||
|
mask_np = np.array(mask_crop)
|
||||||
|
alpha_np = np.array(a)
|
||||||
|
combined = (alpha_np.astype(np.uint16) * mask_np.astype(np.uint16) // 255).astype(np.uint8)
|
||||||
|
paste_final = Image.merge("RGBA", (r, g, b, Image.fromarray(combined)))
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
result.paste(paste_final, (minx, miny), paste_final.split()[3])
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
result.convert("RGB").save(out, format="PNG")
|
||||||
|
return {"result": _encode(out.getvalue())}
|
||||||
|
|
||||||
|
|
||||||
# ─── SAM (Segment Anything) ──────────────────────────────────────────────────
|
# ─── SAM (Segment Anything) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
class SegmentPointRequest(BaseModel):
|
class SegmentPointRequest(BaseModel):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import Base_layers_class from './../core/base-layers.js';
|
|||||||
import Helper_class from './../libs/helpers.js';
|
import Helper_class from './../libs/helpers.js';
|
||||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import apiService from './../services/api.js';
|
import apiService from './../services/api.js';
|
||||||
|
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
|
||||||
|
|
||||||
class Brush_select_class extends Base_tools_class {
|
class Brush_select_class extends Base_tools_class {
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
|
|
||||||
// Processing state
|
// Processing state
|
||||||
this.isProcessing = false;
|
this.isProcessing = false;
|
||||||
|
|
||||||
|
// Quick-action panel shown after selection
|
||||||
|
this.selectionActions = new SelectionActions(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
@@ -300,31 +304,23 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer to float the selection to a new layer for manipulation (Canva-like workflow)
|
* Show quick-action panel after selection (AI operations, scale, clipboard paste, etc.)
|
||||||
*/
|
*/
|
||||||
offerFloatSelection() {
|
offerFloatSelection() {
|
||||||
var _this = this;
|
var imageData = this.getLayerImageData();
|
||||||
|
var maskData = this.maskCanvas
|
||||||
|
? this.maskCanvas.toDataURL('image/png').split(',')[1]
|
||||||
|
: null;
|
||||||
|
if (maskData) {
|
||||||
|
this.selectionActions.show(imageData, maskData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
alertify.confirm(
|
/**
|
||||||
'Selection Complete',
|
* Update the current layer canvas with a base64 result from a backend operation.
|
||||||
'Would you like to move/scale this selection? This will copy it to a new layer.',
|
*/
|
||||||
function() {
|
updateLayerWithResult(base64) {
|
||||||
// Yes - copy to layer and switch to Select tool
|
updateLayerWithResult(base64, this);
|
||||||
_this.copyToLayer();
|
|
||||||
|
|
||||||
// Switch to Select tool
|
|
||||||
setTimeout(function() {
|
|
||||||
var selectTool = document.querySelector('.sidebar_left .item[data-tool="select"]');
|
|
||||||
if (selectTool) {
|
|
||||||
selectTool.click();
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
function() {
|
|
||||||
// No - just keep the selection
|
|
||||||
alertify.message('Tip: Use Ctrl+C to copy or Ctrl+X to cut the selection.');
|
|
||||||
}
|
|
||||||
).set('labels', {ok: 'Yes, Move/Scale', cancel: 'Keep Selection'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -781,6 +777,7 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.selectionActions.hide();
|
||||||
this.currentMask = null;
|
this.currentMask = null;
|
||||||
this.maskCanvas = null;
|
this.maskCanvas = null;
|
||||||
this.edgeCanvas = null;
|
this.edgeCanvas = null;
|
||||||
@@ -793,8 +790,9 @@ class Brush_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
on_leave() {
|
on_leave() {
|
||||||
|
this.selectionActions.hide();
|
||||||
this.isDrawing = false;
|
this.isDrawing = false;
|
||||||
this.isProcessing = false; // Reset processing state when leaving tool
|
this.isProcessing = false;
|
||||||
this.brushPath = [];
|
this.brushPath = [];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
/**
|
||||||
|
* SelectionActions — floating quick-action panel that appears after a SAM selection.
|
||||||
|
*
|
||||||
|
* Surfaces high-value real-world workflows directly in the UI:
|
||||||
|
* • Scale by % — make object 3% (or any %) bigger/smaller, gap AI-filled
|
||||||
|
* • Make less symmetrical — AI redraws the region with organic variation
|
||||||
|
* • Replace with clipboard — paste clipboard image into the selection shape
|
||||||
|
* • Copy / Cut to layer — classic Photoshop workflow
|
||||||
|
* • AI Edit (custom prompt) — full inpaint with user text
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* this.selectionActions = new SelectionActions(this);
|
||||||
|
* // after successful selection:
|
||||||
|
* this.selectionActions.show(imageBase64, maskBase64);
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const BASE = window.API_BASE_URL || '';
|
||||||
|
|
||||||
|
export class SelectionActions {
|
||||||
|
constructor(tool) {
|
||||||
|
this.tool = tool;
|
||||||
|
this.Base_layers = new Base_layers_class();
|
||||||
|
this._panel = null;
|
||||||
|
this._imageData = null;
|
||||||
|
this._maskData = null;
|
||||||
|
this._escHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
show(imageBase64, maskBase64) {
|
||||||
|
this.hide();
|
||||||
|
this._imageData = imageBase64;
|
||||||
|
this._maskData = maskBase64;
|
||||||
|
|
||||||
|
var panel = document.createElement('div');
|
||||||
|
panel.id = 'sel-actions-panel';
|
||||||
|
panel.style.cssText = [
|
||||||
|
'position:fixed',
|
||||||
|
'bottom:80px',
|
||||||
|
'left:50%',
|
||||||
|
'transform:translateX(-50%)',
|
||||||
|
'background:#1a1a2e',
|
||||||
|
'border:1px solid #3a3a6a',
|
||||||
|
'border-radius:12px',
|
||||||
|
'padding:14px 16px',
|
||||||
|
'z-index:10000',
|
||||||
|
'font-family:sans-serif',
|
||||||
|
'font-size:12px',
|
||||||
|
'color:#d0d0e0',
|
||||||
|
'min-width:340px',
|
||||||
|
'box-shadow:0 8px 32px rgba(0,0,0,0.7)',
|
||||||
|
'display:flex',
|
||||||
|
'flex-direction:column',
|
||||||
|
'gap:6px',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// ── Title row ────────────────────────────────────────────────────────
|
||||||
|
var titleRow = document.createElement('div');
|
||||||
|
titleRow.style.cssText = 'display:flex;align-items:center;justify-content:space-between;margin-bottom:4px';
|
||||||
|
var title = document.createElement('span');
|
||||||
|
title.textContent = 'Selection Actions';
|
||||||
|
title.style.cssText = 'font-size:13px;font-weight:bold;color:#aaaaff';
|
||||||
|
var closeX = document.createElement('button');
|
||||||
|
closeX.textContent = '✕';
|
||||||
|
closeX.style.cssText = 'background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0;line-height:1';
|
||||||
|
closeX.title = 'Close panel (keep selection)';
|
||||||
|
closeX.onclick = () => this.hide();
|
||||||
|
titleRow.appendChild(title);
|
||||||
|
titleRow.appendChild(closeX);
|
||||||
|
panel.appendChild(titleRow);
|
||||||
|
|
||||||
|
// ── Scale by % ───────────────────────────────────────────────────────
|
||||||
|
var scaleRow = document.createElement('div');
|
||||||
|
scaleRow.style.cssText = 'display:flex;align-items:center;gap:6px;background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
|
var scaleLabel = document.createElement('span');
|
||||||
|
scaleLabel.textContent = 'Scale by';
|
||||||
|
scaleLabel.style.color = '#aaa';
|
||||||
|
var scaleInput = document.createElement('input');
|
||||||
|
scaleInput.type = 'number';
|
||||||
|
scaleInput.value = '103';
|
||||||
|
scaleInput.min = '1';
|
||||||
|
scaleInput.max = '500';
|
||||||
|
scaleInput.title = '103 = 3% bigger · 95 = 5% smaller';
|
||||||
|
scaleInput.style.cssText = 'width:52px;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:2px 5px;font-size:12px';
|
||||||
|
var scaleUnit = document.createElement('span');
|
||||||
|
scaleUnit.textContent = '%';
|
||||||
|
scaleUnit.style.color = '#888';
|
||||||
|
var scaleBtn = _btn('Apply', '#1a2a4a', '#8aacff');
|
||||||
|
scaleBtn.style.marginLeft = 'auto';
|
||||||
|
scaleBtn.onclick = () => {
|
||||||
|
var pct = parseFloat(scaleInput.value) || 103;
|
||||||
|
this._scaleSelection(pct);
|
||||||
|
};
|
||||||
|
scaleRow.appendChild(scaleLabel);
|
||||||
|
scaleRow.appendChild(scaleInput);
|
||||||
|
scaleRow.appendChild(scaleUnit);
|
||||||
|
scaleRow.appendChild(scaleBtn);
|
||||||
|
panel.appendChild(scaleRow);
|
||||||
|
|
||||||
|
// ── AI actions ───────────────────────────────────────────────────────
|
||||||
|
panel.appendChild(
|
||||||
|
_actionBtn('Make less symmetrical', '#1c1a2e', '#cc99ff',
|
||||||
|
'⟳ AI redraws the region with natural, organic asymmetry',
|
||||||
|
() => this._makeAsymmetric())
|
||||||
|
);
|
||||||
|
panel.appendChild(
|
||||||
|
_actionBtn('Replace with clipboard', '#1a2a1a', '#88dd88',
|
||||||
|
'📋 Scales your clipboard image into the selection shape',
|
||||||
|
() => this._pasteFromClipboard())
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Custom AI edit prompt ─────────────────────────────────────────────
|
||||||
|
var aiRow = document.createElement('div');
|
||||||
|
aiRow.style.cssText = 'display:flex;align-items:center;gap:6px;background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
|
var aiInput = document.createElement('input');
|
||||||
|
aiInput.type = 'text';
|
||||||
|
aiInput.placeholder = 'AI edit: "add a scar", "make it look aged", …';
|
||||||
|
aiInput.style.cssText = 'flex:1;background:#0f0f1a;color:#fff;border:1px solid #4a4a8a;border-radius:4px;padding:3px 7px;font-size:11px';
|
||||||
|
var aiBtn = _btn('Edit', '#1a2a4a', '#8aacff');
|
||||||
|
aiBtn.onclick = () => {
|
||||||
|
var instruction = aiInput.value.trim();
|
||||||
|
if (!instruction) { alertify.warning('Enter an AI edit instruction first.'); return; }
|
||||||
|
this._aiEditRegion(instruction);
|
||||||
|
};
|
||||||
|
aiRow.appendChild(aiInput);
|
||||||
|
aiRow.appendChild(aiBtn);
|
||||||
|
panel.appendChild(aiRow);
|
||||||
|
|
||||||
|
// ── Divider ──────────────────────────────────────────────────────────
|
||||||
|
var hr = document.createElement('div');
|
||||||
|
hr.style.cssText = 'border-top:1px solid #2a2a4a;margin:2px 0';
|
||||||
|
panel.appendChild(hr);
|
||||||
|
|
||||||
|
// ── Classic selection ops ─────────────────────────────────────────────
|
||||||
|
var classicRow = document.createElement('div');
|
||||||
|
classicRow.style.cssText = 'display:flex;gap:6px';
|
||||||
|
var copyBtn = _btn('Copy to layer', '#1a2a1a', '#88cc88');
|
||||||
|
copyBtn.style.flex = '1';
|
||||||
|
copyBtn.title = 'Ctrl+C';
|
||||||
|
copyBtn.onclick = () => { this.tool.copyToLayer(); this.hide(); };
|
||||||
|
var cutBtn = _btn('Cut to layer', '#2a1a1a', '#cc8888');
|
||||||
|
cutBtn.style.flex = '1';
|
||||||
|
cutBtn.title = 'Ctrl+X';
|
||||||
|
cutBtn.onclick = () => { this.tool.cutToLayer(); this.hide(); };
|
||||||
|
var delBtn = _btn('Erase', '#2a1a1a', '#ff7766');
|
||||||
|
delBtn.style.flex = '0 0 auto';
|
||||||
|
delBtn.title = 'Delete key';
|
||||||
|
delBtn.onclick = () => { this.tool.deleteSelection(); this.hide(); };
|
||||||
|
classicRow.appendChild(copyBtn);
|
||||||
|
classicRow.appendChild(cutBtn);
|
||||||
|
classicRow.appendChild(delBtn);
|
||||||
|
panel.appendChild(classicRow);
|
||||||
|
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
this._panel = panel;
|
||||||
|
|
||||||
|
this._escHandler = (e) => { if (e.key === 'Escape') this.hide(); };
|
||||||
|
document.addEventListener('keydown', this._escHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
if (this._panel) { this._panel.remove(); this._panel = null; }
|
||||||
|
if (this._escHandler) {
|
||||||
|
document.removeEventListener('keydown', this._escHandler);
|
||||||
|
this._escHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Actions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async _scaleSelection(scalePct) {
|
||||||
|
if (!this._check()) return;
|
||||||
|
this.hide();
|
||||||
|
alertify.message('Scaling object and filling gap…');
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/scale-selection', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
scale_pct: scalePct,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
alertify.success('Scaled by ' + scalePct + '%!');
|
||||||
|
} catch (e) {
|
||||||
|
alertify.error('Scale failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _makeAsymmetric() {
|
||||||
|
if (!this._check()) return;
|
||||||
|
this.hide();
|
||||||
|
alertify.message('AI is adding natural asymmetry…');
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/ai-edit-region', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
instruction: 'natural asymmetry, slight organic variation, realistic, subtle imperfection',
|
||||||
|
negative_prompt:'perfectly symmetric, mirror image, artificial, identical halves',
|
||||||
|
steps: 30,
|
||||||
|
cfg_scale: 7.5,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
alertify.success('Made less symmetrical!');
|
||||||
|
} catch (e) {
|
||||||
|
alertify.error('AI edit failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _aiEditRegion(instruction) {
|
||||||
|
if (!this._check()) return;
|
||||||
|
this.hide();
|
||||||
|
alertify.message('AI is editing the region…');
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/ai-edit-region', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
instruction: instruction,
|
||||||
|
steps: 30,
|
||||||
|
cfg_scale: 7.5,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
alertify.success('Done!');
|
||||||
|
} catch (e) {
|
||||||
|
alertify.error('AI edit failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _pasteFromClipboard() {
|
||||||
|
if (!this._check()) return;
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.read) {
|
||||||
|
alertify.error('Clipboard API not available. Use HTTPS or enable clipboard permissions.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var items = await navigator.clipboard.read();
|
||||||
|
var clipBlob = null;
|
||||||
|
for (var item of items) {
|
||||||
|
for (var type of item.types) {
|
||||||
|
if (type.startsWith('image/')) {
|
||||||
|
clipBlob = await item.getType(type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clipBlob) break;
|
||||||
|
}
|
||||||
|
if (!clipBlob) {
|
||||||
|
alertify.error('No image in clipboard. Copy an image first (e.g., right-click → Copy image).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clipBase64 = await _blobToBase64(clipBlob);
|
||||||
|
this.hide();
|
||||||
|
alertify.message('Pasting clipboard into selection…');
|
||||||
|
|
||||||
|
var res = await _post('/api/image/paste-into-selection', {
|
||||||
|
image: this._imageData,
|
||||||
|
mask: this._maskData,
|
||||||
|
paste_image: clipBase64,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
alertify.success('Clipboard pasted into selection!');
|
||||||
|
} catch (e) {
|
||||||
|
alertify.error('Paste failed: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_check() {
|
||||||
|
if (!this._imageData || !this._maskData) {
|
||||||
|
alertify.error('No selection data. Make a new selection first.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared method: patch into both smart_select and brush_select instances ───
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the active layer canvas with a base64 result image from the backend.
|
||||||
|
* Call as `this.updateLayerWithResult(base64)` on any tool that extends Base_tools_class.
|
||||||
|
*/
|
||||||
|
export function updateLayerWithResult(base64, tool) {
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = function () {
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
canvas.getContext('2d').drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
app.State.do_action(
|
||||||
|
new app.Actions.Bundle_action('ai_transform', 'AI Transform', [
|
||||||
|
new app.Actions.Update_layer_image_action(canvas, config.layer.id)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
// Trigger re-render
|
||||||
|
config.need_render = true;
|
||||||
|
};
|
||||||
|
img.src = 'data:image/png;base64,' + base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _btn(text, bg, color) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.textContent = text;
|
||||||
|
b.style.cssText = 'background:' + bg + ';color:' + color + ';border:1px solid #3a3a6a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;white-space:nowrap';
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _actionBtn(text, bg, color, tooltip, handler) {
|
||||||
|
var b = _btn(text, bg, color);
|
||||||
|
b.style.cssText += ';display:block;width:100%;text-align:left;padding:7px 10px;border-radius:7px;font-size:12px';
|
||||||
|
if (tooltip) b.title = tooltip;
|
||||||
|
b.onclick = handler;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _post(path, body) {
|
||||||
|
var r = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
var err = await r.json().catch(() => ({ detail: r.statusText }));
|
||||||
|
throw new Error(err.detail || 'Request failed');
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _blobToBase64(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target.result.split(',')[1]);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import Helper_class from './../libs/helpers.js';
|
|||||||
import Dialog_class from './../libs/popup.js';
|
import Dialog_class from './../libs/popup.js';
|
||||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||||
import apiService from './../services/api.js';
|
import apiService from './../services/api.js';
|
||||||
|
import { SelectionActions, updateLayerWithResult } from './selection_actions.js';
|
||||||
|
|
||||||
class Smart_select_class extends Base_tools_class {
|
class Smart_select_class extends Base_tools_class {
|
||||||
|
|
||||||
@@ -35,6 +36,9 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
|
|
||||||
// Edge canvas for drawing the mask outline
|
// Edge canvas for drawing the mask outline
|
||||||
this.edgeCanvas = null;
|
this.edgeCanvas = null;
|
||||||
|
|
||||||
|
// Quick-action panel shown after selection
|
||||||
|
this.selectionActions = new SelectionActions(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
@@ -146,7 +150,7 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
if (isAdditive && this.currentMask) {
|
if (isAdditive && this.currentMask) {
|
||||||
alertify.success('Added to selection! Shift+Click to add more.');
|
alertify.success('Added to selection! Shift+Click to add more.');
|
||||||
} else {
|
} else {
|
||||||
alertify.success('Selection complete! Shift+Click to add more, Ctrl+C to copy, Ctrl+X to cut.');
|
this._showActionPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -633,10 +637,31 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
alertify.success('Selection deleted!');
|
alertify.success('Selection deleted!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the quick-action panel for the current selection.
|
||||||
|
*/
|
||||||
|
_showActionPanel() {
|
||||||
|
var imageData = this.getLayerImageData();
|
||||||
|
var maskData = this.maskCanvas
|
||||||
|
? this.maskCanvas.toDataURL('image/png').split(',')[1]
|
||||||
|
: null;
|
||||||
|
if (maskData) {
|
||||||
|
this.selectionActions.show(imageData, maskData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the current layer canvas with a base64 result from a backend operation.
|
||||||
|
*/
|
||||||
|
updateLayerWithResult(base64) {
|
||||||
|
updateLayerWithResult(base64, this);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the current selection
|
* Clear the current selection
|
||||||
*/
|
*/
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
|
this.selectionActions.hide();
|
||||||
this.currentMask = null;
|
this.currentMask = null;
|
||||||
this.maskCanvas = null;
|
this.maskCanvas = null;
|
||||||
this.edgeCanvas = null;
|
this.edgeCanvas = null;
|
||||||
@@ -647,7 +672,7 @@ class Smart_select_class extends Base_tools_class {
|
|||||||
}
|
}
|
||||||
|
|
||||||
on_leave() {
|
on_leave() {
|
||||||
// Don't clear mask when switching tools - AI inpaint needs it
|
this.selectionActions.hide();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user