Add AI subject replace: file-picker compositing with LAB color matching

- Backend: POST /api/image/replace-subject
  Uses rembg to extract the subject from a source photo, scales it to fit
  the selection mask bounding box (or canvas centre when no selection is
  active), applies a partial LAB color transfer (blend=0.45) so the
  subject's lighting matches the background, then composites the result.
  Also adds POST /api/image/extract-subject for standalone subject extraction.

- Frontend: "Replace with subject from file" button in the selection panel
  (selection_actions.js) — opens a native file picker so no clipboard API
  or HTTPS is required.  Calls the new endpoint with the current SAM mask.

- Frontend: Image > Replace Subject (AI)... menu entry backed by
  modules/image/replace_subject.js — a full dialog with file picker,
  thumbnail preview, and "match background lighting" toggle.  Works with
  or without a prior Smart Select; if a selection exists it confines the
  subject to that region.

https://claude.ai/code/session_01UtrvbisMp1yGu6PqeLmrFr
This commit is contained in:
Claude
2026-06-15 15:04:37 +00:00
parent ffe56c99e9
commit 142df74192
4 changed files with 483 additions and 0 deletions
+5
View File
@@ -344,6 +344,11 @@ const menuDefinition = [
ellipsis: true,
target: 'image/remove_background.remove_background'
},
{
name: 'Replace Subject (AI)...',
ellipsis: true,
target: 'image/replace_subject.replace_subject'
},
{
name: 'Prepare for Print...',
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()
));
// 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
var aiWrap = document.createElement('div');
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() {
if (!this._imageData || !this._maskData) {
alertify.error('No selection data. Make a new selection first.');
@@ -411,3 +455,12 @@ function _blobToBase64(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);
});
}