Merge pull request #60 from outis1one/claude/compassionate-albattani-s4lqb5
Add AI subject replace: file-picker compositing with LAB color matching
This commit is contained in:
@@ -745,6 +745,179 @@ async def enhance(req: EnhanceRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Subject replace ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ExtractSubjectRequest(BaseModel):
|
||||||
|
image: str # base64
|
||||||
|
|
||||||
|
|
||||||
|
class ReplaceSubjectRequest(BaseModel):
|
||||||
|
background_image: str # base64 — image whose background we keep
|
||||||
|
subject_image: str # base64 — image whose subject we extract
|
||||||
|
mask: Optional[str] = None # base64 — white = where the subject should land
|
||||||
|
match_colors: bool = True # blend subject color stats toward background
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_subject_bytes(image_bytes: bytes) -> bytes:
|
||||||
|
"""Remove background from image using rembg; return RGBA PNG bytes."""
|
||||||
|
if rembg_available():
|
||||||
|
return remove_background_rembg(image_bytes)
|
||||||
|
raise RuntimeError(
|
||||||
|
"rembg is not installed. Run: pip install rembg (or add it to requirements.txt)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _color_transfer_lab(subj_rgba: "Image", bg_rgb: "Image", blend: float = 0.45) -> "Image":
|
||||||
|
"""
|
||||||
|
Partial LAB color transfer: nudge subject color statistics 'blend' fraction
|
||||||
|
toward the background's statistics so it looks like it belongs in the scene.
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
src_arr = np.array(subj_rgba.convert("RGB"), dtype=np.float32)
|
||||||
|
tgt_arr = np.array(bg_rgb.convert("RGB"), dtype=np.float32)
|
||||||
|
|
||||||
|
alpha = np.array(subj_rgba.split()[3])
|
||||||
|
subject_mask = alpha > 10
|
||||||
|
|
||||||
|
if not subject_mask.any():
|
||||||
|
return subj_rgba
|
||||||
|
|
||||||
|
src_lab = cv2.cvtColor(src_arr.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
|
||||||
|
tgt_lab = cv2.cvtColor(tgt_arr.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
|
||||||
|
|
||||||
|
for ch in range(3):
|
||||||
|
src_ch = src_lab[:, :, ch]
|
||||||
|
src_pixels = src_ch[subject_mask]
|
||||||
|
tgt_pixels = tgt_lab[:, :, ch].flatten()
|
||||||
|
|
||||||
|
src_mean, src_std = float(src_pixels.mean()), float(src_pixels.std()) + 1e-6
|
||||||
|
tgt_mean, tgt_std = float(tgt_pixels.mean()), float(tgt_pixels.std()) + 1e-6
|
||||||
|
|
||||||
|
adjusted_std = src_std + blend * (tgt_std - src_std)
|
||||||
|
adjusted = (src_ch - src_mean) * (adjusted_std / src_std) + src_mean + blend * (tgt_mean - src_mean)
|
||||||
|
src_lab[:, :, ch] = np.clip(adjusted, 0, 255)
|
||||||
|
|
||||||
|
result_rgb = cv2.cvtColor(src_lab.astype(np.uint8), cv2.COLOR_LAB2RGB)
|
||||||
|
r, g, b = result_rgb[:, :, 0], result_rgb[:, :, 1], result_rgb[:, :, 2]
|
||||||
|
return Image.merge("RGBA", [
|
||||||
|
Image.fromarray(r), Image.fromarray(g),
|
||||||
|
Image.fromarray(b), Image.fromarray(alpha),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _do_replace_subject(
|
||||||
|
bg_bytes: bytes,
|
||||||
|
subj_bytes: bytes,
|
||||||
|
mask_bytes: Optional[bytes],
|
||||||
|
match_colors: bool,
|
||||||
|
) -> bytes:
|
||||||
|
"""Core compositing: extract subject → scale → color-match → paste onto background."""
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
bg_img = Image.open(BytesIO(bg_bytes)).convert("RGBA")
|
||||||
|
|
||||||
|
subj_rgba = Image.open(BytesIO(_extract_subject_bytes(subj_bytes))).convert("RGBA")
|
||||||
|
|
||||||
|
# Determine target placement bounding box from mask or full canvas
|
||||||
|
if mask_bytes:
|
||||||
|
mask_img = Image.open(BytesIO(mask_bytes)).convert("L")
|
||||||
|
if mask_img.size != bg_img.size:
|
||||||
|
mask_img = mask_img.resize(bg_img.size, Image.LANCZOS)
|
||||||
|
mask_arr = np.array(mask_img)
|
||||||
|
ys, xs = np.where(mask_arr > 128)
|
||||||
|
else:
|
||||||
|
mask_img = None
|
||||||
|
mask_arr = None
|
||||||
|
ys, xs = np.array([]), np.array([])
|
||||||
|
|
||||||
|
if len(xs) > 0:
|
||||||
|
minx, maxx = int(xs.min()), int(xs.max())
|
||||||
|
miny, maxy = int(ys.min()), int(ys.max())
|
||||||
|
else:
|
||||||
|
minx, miny = 0, 0
|
||||||
|
maxx, maxy = bg_img.width - 1, bg_img.height - 1
|
||||||
|
|
||||||
|
target_w = maxx - minx + 1
|
||||||
|
target_h = maxy - miny + 1
|
||||||
|
|
||||||
|
# Scale subject to fit target area, preserving aspect ratio
|
||||||
|
sw, sh = subj_rgba.size
|
||||||
|
scale = min(target_w / sw, target_h / sh)
|
||||||
|
new_w = max(1, round(sw * scale))
|
||||||
|
new_h = max(1, round(sh * scale))
|
||||||
|
subj_scaled = subj_rgba.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
|
||||||
|
# Optional color transfer to blend lighting/tone
|
||||||
|
if match_colors:
|
||||||
|
subj_scaled = _color_transfer_lab(subj_scaled, bg_img.convert("RGB"))
|
||||||
|
|
||||||
|
# Center in target area
|
||||||
|
px = minx + (target_w - new_w) // 2
|
||||||
|
py = miny + (target_h - new_h) // 2
|
||||||
|
|
||||||
|
result = bg_img.copy()
|
||||||
|
|
||||||
|
if mask_img is not None and len(xs) > 0:
|
||||||
|
# Build a full-canvas RGBA layer for the subject
|
||||||
|
subj_canvas = Image.new("RGBA", bg_img.size, (0, 0, 0, 0))
|
||||||
|
subj_canvas.paste(subj_scaled, (px, py), subj_scaled.split()[3])
|
||||||
|
# Clip subject's alpha to the selection mask
|
||||||
|
sc_arr = np.array(subj_canvas)
|
||||||
|
sc_arr[:, :, 3] = np.minimum(sc_arr[:, :, 3], mask_arr).astype(np.uint8)
|
||||||
|
subj_canvas = Image.fromarray(sc_arr)
|
||||||
|
result.paste(subj_canvas, (0, 0), subj_canvas.split()[3])
|
||||||
|
else:
|
||||||
|
result.paste(subj_scaled, (px, py), subj_scaled.split()[3])
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
result.convert("RGB").save(out, format="PNG")
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/extract-subject")
|
||||||
|
async def extract_subject(req: ExtractSubjectRequest):
|
||||||
|
"""
|
||||||
|
Remove background from an image and return the subject with transparency (RGBA PNG).
|
||||||
|
Uses rembg (AI-powered) when available.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None, _extract_subject_bytes, _decode(req.image)
|
||||||
|
)
|
||||||
|
return {"result": _encode(result)}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/image/replace-subject")
|
||||||
|
async def replace_subject(req: ReplaceSubjectRequest):
|
||||||
|
"""
|
||||||
|
Extract the primary subject from `subject_image` (via rembg background removal),
|
||||||
|
scale it to fit the `mask` selection on `background_image`, apply optional LAB
|
||||||
|
color transfer for lighting consistency, and composite the result.
|
||||||
|
|
||||||
|
Returns the composited image as base64 PNG.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
None,
|
||||||
|
_do_replace_subject,
|
||||||
|
_decode(req.background_image),
|
||||||
|
_decode(req.subject_image),
|
||||||
|
_decode(req.mask) if req.mask else None,
|
||||||
|
req.match_colors,
|
||||||
|
)
|
||||||
|
return {"result": _encode(result)}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback; traceback.print_exc()
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
# ─── Extract colors ───────────────────────────────────────────────────────────
|
# ─── Extract colors ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class ExtractColorsRequest(BaseModel):
|
class ExtractColorsRequest(BaseModel):
|
||||||
|
|||||||
@@ -344,6 +344,11 @@ const menuDefinition = [
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
target: 'image/remove_background.remove_background'
|
target: 'image/remove_background.remove_background'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Replace Subject (AI)...',
|
||||||
|
ellipsis: true,
|
||||||
|
target: 'image/replace_subject.replace_subject'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Prepare for Print...',
|
name: 'Prepare for Print...',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* Replace Subject — extract the primary subject from a source photo and
|
||||||
|
* composite it onto the current layer's background.
|
||||||
|
*
|
||||||
|
* Workflow:
|
||||||
|
* 1. User selects the subject area via Smart Select (optional but recommended).
|
||||||
|
* 2. Opens this module → picks a source photo.
|
||||||
|
* 3. Backend removes the background from the source photo (rembg / AI),
|
||||||
|
* scales the extracted subject to fit the selection (or the canvas center),
|
||||||
|
* applies LAB color transfer so the lighting matches the background, and
|
||||||
|
* returns the composited image.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
import { showProgress, hideProgress } from './../../libs/progress_overlay.js';
|
||||||
|
|
||||||
|
const BASE = window.API_BASE_URL || '';
|
||||||
|
|
||||||
|
var instance = null;
|
||||||
|
|
||||||
|
class Image_replace_subject_class {
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (instance) return instance;
|
||||||
|
instance = this;
|
||||||
|
this.Base_layers = new Base_layers_class();
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
replace_subject() {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
alertify.warning('Already processing… please wait');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.layer.type !== 'image') {
|
||||||
|
alertify.error('Current layer must be an image.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._showDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_showDialog() {
|
||||||
|
var hasSel = !!(window.smartSelectMask && window.smartSelectMask.canvas);
|
||||||
|
|
||||||
|
// Build a dialog manually so we can embed a file input
|
||||||
|
var overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = [
|
||||||
|
'position:fixed', 'inset:0', 'background:rgba(0,0,0,0.6)',
|
||||||
|
'z-index:20000', 'display:flex', 'align-items:center', 'justify-content:center',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.style.cssText = [
|
||||||
|
'background:#1a1a2e', 'border:1px solid #3a3a6a', 'border-radius:12px',
|
||||||
|
'padding:24px', 'min-width:380px', 'max-width:460px',
|
||||||
|
'font-family:sans-serif', 'color:#d0d0e0', 'font-size:13px',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
// Title
|
||||||
|
var title = document.createElement('div');
|
||||||
|
title.textContent = 'Replace Subject';
|
||||||
|
title.style.cssText = 'font-size:16px;font-weight:bold;color:#aaaaff;margin-bottom:6px';
|
||||||
|
box.appendChild(title);
|
||||||
|
|
||||||
|
var sub = document.createElement('div');
|
||||||
|
sub.textContent = hasSel
|
||||||
|
? 'Subject will be placed inside your current selection.'
|
||||||
|
: 'No selection active — subject will be centred on the canvas. Use Smart Select first for precise placement.';
|
||||||
|
sub.style.cssText = 'font-size:11px;color:#7777aa;margin-bottom:16px;line-height:1.4';
|
||||||
|
box.appendChild(sub);
|
||||||
|
|
||||||
|
// File picker label + preview row
|
||||||
|
var fileRow = document.createElement('div');
|
||||||
|
fileRow.style.cssText = 'display:flex;align-items:center;gap:10px;margin-bottom:12px';
|
||||||
|
var fileLabel = document.createElement('label');
|
||||||
|
fileLabel.textContent = 'Source photo:';
|
||||||
|
fileLabel.style.cssText = 'color:#aaa;width:100px;flex-shrink:0';
|
||||||
|
var fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
fileInput.style.cssText = 'flex:1;background:#0f0f1a;color:#ccc;border:1px solid #4a4a8a;border-radius:5px;padding:4px 8px;font-size:12px;cursor:pointer';
|
||||||
|
fileRow.appendChild(fileLabel);
|
||||||
|
fileRow.appendChild(fileInput);
|
||||||
|
box.appendChild(fileRow);
|
||||||
|
|
||||||
|
// Thumbnail preview
|
||||||
|
var preview = document.createElement('img');
|
||||||
|
preview.style.cssText = 'display:none;max-width:100%;max-height:160px;border-radius:6px;margin-bottom:12px;border:1px solid #3a3a6a';
|
||||||
|
box.appendChild(preview);
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
var f = fileInput.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
var url = URL.createObjectURL(f);
|
||||||
|
preview.src = url;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
preview.onload = () => URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Match colors checkbox
|
||||||
|
var colorRow = document.createElement('div');
|
||||||
|
colorRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:16px';
|
||||||
|
var colorCheck = document.createElement('input');
|
||||||
|
colorCheck.type = 'checkbox';
|
||||||
|
colorCheck.checked = true;
|
||||||
|
colorCheck.id = 'rs-match-colors';
|
||||||
|
var colorLabel = document.createElement('label');
|
||||||
|
colorLabel.htmlFor = 'rs-match-colors';
|
||||||
|
colorLabel.textContent = 'Match background lighting & color tone';
|
||||||
|
colorLabel.style.cssText = 'color:#bbb;cursor:pointer';
|
||||||
|
colorRow.appendChild(colorCheck);
|
||||||
|
colorRow.appendChild(colorLabel);
|
||||||
|
box.appendChild(colorRow);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
var btnRow = document.createElement('div');
|
||||||
|
btnRow.style.cssText = 'display:flex;gap:8px;justify-content:flex-end';
|
||||||
|
|
||||||
|
var cancelBtn = _btn('Cancel', '#2a2a4a', '#8888aa');
|
||||||
|
cancelBtn.onclick = () => { document.body.removeChild(overlay); };
|
||||||
|
|
||||||
|
var goBtn = _btn('Replace Subject', '#1a3a5a', '#88ccff');
|
||||||
|
goBtn.style.fontWeight = 'bold';
|
||||||
|
goBtn.onclick = async () => {
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
alertify.warning('Please pick a source photo first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
await this._run(file, colorCheck.checked);
|
||||||
|
};
|
||||||
|
|
||||||
|
btnRow.appendChild(cancelBtn);
|
||||||
|
btnRow.appendChild(goBtn);
|
||||||
|
box.appendChild(btnRow);
|
||||||
|
|
||||||
|
overlay.appendChild(box);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) document.body.removeChild(overlay); });
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _run(file, matchColors) {
|
||||||
|
this.isProcessing = true;
|
||||||
|
showProgress('Extracting subject and compositing…', 20);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var subjectBase64 = await _fileToBase64(file);
|
||||||
|
var bgBase64 = _getLayerBase64();
|
||||||
|
var maskBase64 = _getMaskBase64();
|
||||||
|
|
||||||
|
var res = await _post('/api/image/replace-subject', {
|
||||||
|
background_image: bgBase64,
|
||||||
|
subject_image: subjectBase64,
|
||||||
|
mask: maskBase64 || undefined,
|
||||||
|
match_colors: matchColors,
|
||||||
|
});
|
||||||
|
|
||||||
|
var img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
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('replace_subject', 'Replace Subject', [
|
||||||
|
new app.Actions.Update_layer_image_action(canvas, config.layer.id)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear selection if one was used
|
||||||
|
if (window.smartSelectMask) {
|
||||||
|
window.smartSelectMask = null;
|
||||||
|
config.need_render = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideProgress();
|
||||||
|
alertify.success('Subject replaced!');
|
||||||
|
this.isProcessing = false;
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Failed to load result image.');
|
||||||
|
this.isProcessing = false;
|
||||||
|
};
|
||||||
|
img.src = 'data:image/png;base64,' + res.result;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Replace subject failed: ' + (e.message || e));
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _getLayerBase64() {
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = config.layer.width_original;
|
||||||
|
canvas.height = config.layer.height_original;
|
||||||
|
canvas.getContext('2d').drawImage(config.layer.link, 0, 0);
|
||||||
|
return canvas.toDataURL('image/png').split(',')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getMaskBase64() {
|
||||||
|
var m = window.smartSelectMask;
|
||||||
|
if (!m || !m.canvas) return null;
|
||||||
|
var w = config.layer.width_original;
|
||||||
|
var h = config.layer.height_original;
|
||||||
|
var canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
canvas.getContext('2d').drawImage(m.canvas, 0, 0, w, h);
|
||||||
|
return canvas.toDataURL('image/png').split(',')[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target.result.split(',')[1]);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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:6px 14px;border-radius:6px;cursor:pointer;font-size:12px';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Image_replace_subject_class;
|
||||||
@@ -144,6 +144,14 @@ export class SelectionActions {
|
|||||||
() => this._pasteFromClipboard()
|
() => this._pasteFromClipboard()
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Replace subject from file
|
||||||
|
panel.appendChild(_actionCard(
|
||||||
|
'Replace subject from file',
|
||||||
|
'#1a2a2a', '#66ddcc',
|
||||||
|
'Pick any photo — AI extracts its subject and places it here, matching background lighting',
|
||||||
|
() => this._replaceSubjectFromFile()
|
||||||
|
));
|
||||||
|
|
||||||
// Custom AI edit prompt
|
// Custom AI edit prompt
|
||||||
var aiWrap = document.createElement('div');
|
var aiWrap = document.createElement('div');
|
||||||
aiWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
|
aiWrap.style.cssText = 'background:#16213e;border-radius:7px;padding:7px 10px';
|
||||||
@@ -323,6 +331,42 @@ export class SelectionActions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _replaceSubjectFromFile() {
|
||||||
|
if (!this._check()) return;
|
||||||
|
|
||||||
|
// Open a file picker — no clipboard API required
|
||||||
|
var fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.accept = 'image/*';
|
||||||
|
|
||||||
|
fileInput.onchange = async () => {
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
var subjectBase64 = await _fileToBase64(file);
|
||||||
|
this.hide();
|
||||||
|
showProgress('Extracting subject and compositing…', 15);
|
||||||
|
|
||||||
|
try {
|
||||||
|
var res = await _post('/api/image/replace-subject', {
|
||||||
|
background_image: this._imageData,
|
||||||
|
subject_image: subjectBase64,
|
||||||
|
mask: this._maskData,
|
||||||
|
match_colors: true,
|
||||||
|
});
|
||||||
|
this.tool.updateLayerWithResult(res.result);
|
||||||
|
this.tool.clearSelection();
|
||||||
|
hideProgress();
|
||||||
|
alertify.success('Subject replaced with background color matching!');
|
||||||
|
} catch (e) {
|
||||||
|
hideProgress();
|
||||||
|
alertify.error('Replace subject failed: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
_check() {
|
_check() {
|
||||||
if (!this._imageData || !this._maskData) {
|
if (!this._imageData || !this._maskData) {
|
||||||
alertify.error('No selection data. Make a new selection first.');
|
alertify.error('No selection data. Make a new selection first.');
|
||||||
@@ -411,3 +455,12 @@ function _blobToBase64(blob) {
|
|||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _fileToBase64(file) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = (e) => resolve(e.target.result.split(',')[1]);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user