diff --git a/backend/app/routers/tools.py b/backend/app/routers/tools.py
index c6eed00..00d6f92 100644
--- a/backend/app/routers/tools.py
+++ b/backend/app/routers/tools.py
@@ -203,6 +203,32 @@ async def remove_background_base64(request: RemoveBackgroundRequest):
_u2net_model = None
+async def _download_u2net_model(models_dir):
+ """Auto-download U2Net model (lightweight version ~4MB)"""
+ import urllib.request
+ from pathlib import Path
+
+ models_dir = Path(models_dir)
+ models_dir.mkdir(parents=True, exist_ok=True)
+
+ # Download lightweight u2netp model (only 4MB)
+ url = "https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2netp.onnx"
+ dest_path = models_dir / "u2netp.onnx"
+
+ print(f"Downloading U2Net model from {url}...")
+
+ def download_progress(count, block_size, total_size):
+ if total_size > 0:
+ percent = min(100, count * block_size * 100 // total_size)
+ if count % 100 == 0:
+ print(f" Download progress: {percent}%")
+
+ urllib.request.urlretrieve(url, str(dest_path), download_progress)
+ print(f"U2Net model downloaded to {dest_path}")
+
+ return dest_path
+
+
async def _remove_background_u2net(img: Image.Image) -> bytes:
"""
Remove background using U2Net model directly.
@@ -225,10 +251,25 @@ async def _remove_background_u2net(img: Image.Image) -> bytes:
u2net_path = alt_path
break
+ if not u2net_path.exists():
+ # Try to auto-download the model
+ print("U2Net model not found, attempting to download...")
+ try:
+ await _download_u2net_model(models_dir)
+ # Check again
+ for alt_name in ['u2net.onnx', 'u2netp.onnx', 'u2net.pth']:
+ alt_path = models_dir / alt_name
+ if alt_path.exists():
+ u2net_path = alt_path
+ break
+ except Exception as download_error:
+ print(f"Auto-download failed: {download_error}")
+
if not u2net_path.exists():
raise FileNotFoundError(
- f"U2Net model not found at {u2net_path}. "
- "Download from: https://github.com/xuebinqin/U-2-Net"
+ "U2Net model not found. To fix this, run:\n"
+ " docker exec -it ai-photo-edit-backend python /scripts/download_u2net_model.py\n"
+ "Or manually download from: https://github.com/danielgatis/rembg/releases"
)
# Load model if not cached
diff --git a/frontend/src/js/config.js b/frontend/src/js/config.js
index 937ab97..077c1f7 100644
--- a/frontend/src/js/config.js
+++ b/frontend/src/js/config.js
@@ -83,8 +83,10 @@ config.TOOLS = [
{
name: 'select',
title: 'Select object tool',
+ on_activate: 'on_activate',
attributes: {
auto_select: true,
+ keep_ratio: true,
},
},
{
diff --git a/frontend/src/js/core/gui/gui-details.js b/frontend/src/js/core/gui/gui-details.js
index fba4e62..f41860a 100644
--- a/frontend/src/js/core/gui/gui-details.js
+++ b/frontend/src/js/core/gui/gui-details.js
@@ -31,6 +31,7 @@ var template = `
Height:
+
@@ -124,6 +125,8 @@ class GUI_details_class {
this.Helper = new Helper_class();
this.layer_details_active = false;
this.Tools_translate = new Tools_translate_class();
+ this.aspect_locked = true; // Default to locked for image layers
+ this.aspect_ratio = 1; // Will be calculated from layer dimensions
}
render_main_details() {
@@ -139,6 +142,7 @@ class GUI_details_class {
this.render_general('y', events);
this.render_general('width', events);
this.render_general('height', events);
+ this.render_aspect_lock(events);
this.render_general('rotate', events);
this.render_general('opacity', events);
@@ -287,6 +291,69 @@ class GUI_details_class {
}
}
+ render_aspect_lock(events) {
+ var _this = this;
+ var layer = config.layer;
+ var lockBtn = document.getElementById('toggle_aspect_lock');
+
+ if (!lockBtn) return;
+
+ // Update aspect ratio from current layer dimensions
+ if (layer && layer.width && layer.height) {
+ this.aspect_ratio = layer.width / layer.height;
+ }
+
+ // Update button appearance based on lock state
+ if (this.aspect_locked) {
+ lockBtn.style.background = '#4a4';
+ lockBtn.title = 'Aspect Ratio Locked - Click to Unlock';
+ } else {
+ lockBtn.style.background = '';
+ lockBtn.title = 'Aspect Ratio Unlocked - Click to Lock';
+ }
+
+ if (events) {
+ lockBtn.addEventListener('click', function() {
+ _this.aspect_locked = !_this.aspect_locked;
+
+ // Update aspect ratio when locking
+ if (_this.aspect_locked && config.layer) {
+ _this.aspect_ratio = config.layer.width / config.layer.height;
+ }
+
+ _this.render_aspect_lock(false);
+ });
+
+ // Override width change to update height when locked
+ var widthInput = document.getElementById('detail_width');
+ var heightInput = document.getElementById('detail_height');
+
+ widthInput.addEventListener('input', function(e) {
+ if (_this.aspect_locked && config.layer) {
+ var units = _this.Tools_settings.get_setting('default_units');
+ var resolution = _this.Tools_settings.get_setting('resolution');
+ var newWidth = _this.Helper.get_internal_unit(this.value, units, resolution);
+ var newHeight = newWidth / _this.aspect_ratio;
+
+ heightInput.value = _this.Helper.get_user_unit(newHeight, units, resolution);
+ config.layer.height = newHeight;
+ }
+ });
+
+ heightInput.addEventListener('input', function(e) {
+ if (_this.aspect_locked && config.layer) {
+ var units = _this.Tools_settings.get_setting('default_units');
+ var resolution = _this.Tools_settings.get_setting('resolution');
+ var newHeight = _this.Helper.get_internal_unit(this.value, units, resolution);
+ var newWidth = newHeight * _this.aspect_ratio;
+
+ widthInput.value = _this.Helper.get_user_unit(newWidth, units, resolution);
+ config.layer.width = newWidth;
+ }
+ });
+ }
+ }
+
render_general_param(key, events) {
var layer = config.layer;
diff --git a/frontend/src/js/tools/brush_select.js b/frontend/src/js/tools/brush_select.js
index f78f69e..b1522b9 100644
--- a/frontend/src/js/tools/brush_select.js
+++ b/frontend/src/js/tools/brush_select.js
@@ -327,10 +327,14 @@ class Brush_select_class extends Base_tools_class {
canvas.width = config.layer.width_original;
canvas.height = config.layer.height_original;
var ctx = canvas.getContext('2d');
- ctx.drawImage(img, 0, 0);
+ // Scale the mask image to match the layer dimensions
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas);
};
- img.onerror = reject;
+ img.onerror = function(e) {
+ console.error('Failed to decode mask image:', e);
+ reject(e);
+ };
img.src = 'data:image/png;base64,' + maskBase64;
});
}
diff --git a/frontend/src/js/tools/select.js b/frontend/src/js/tools/select.js
index 4adee00..dbcc923 100644
--- a/frontend/src/js/tools/select.js
+++ b/frontend/src/js/tools/select.js
@@ -5,6 +5,7 @@ import Base_layers_class from './../core/base-layers.js';
import Base_selection_class from './../core/base-selection.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';
class Select_tool_class extends Base_tools_class {
@@ -37,6 +38,132 @@ class Select_tool_class extends Base_tools_class {
this.Base_selection = new Base_selection_class(ctx, sel_config, this.name);
}
+ /**
+ * Called when the Select tool is activated
+ * If there's an AI selection, offer to float it so it can be moved
+ */
+ on_activate() {
+ var _this = this;
+
+ // Check if there's an active AI selection (Smart Select, Brush Select, etc.)
+ if (window.smartSelectMask && window.smartSelectMask.canvas) {
+ // Ask user if they want to float the selection
+ alertify.confirm(
+ 'Float Selection',
+ 'You have an active selection. Would you like to copy it to a new layer so you can move and scale it?',
+ function() {
+ // Yes - float the selection
+ _this.floatSelection();
+ },
+ function() {
+ // No - just clear the selection indicator
+ alertify.message('Tip: Use Ctrl+C in selection tools to copy, or Ctrl+X to cut.');
+ }
+ ).set('labels', {ok: 'Yes, Float It', cancel: 'No, Keep Selection'});
+ }
+ }
+
+ /**
+ * Float the current selection to a new layer
+ * This copies the selected pixels to a new layer that can be moved/scaled
+ */
+ floatSelection() {
+ var maskCanvas = window.smartSelectMask?.canvas;
+ if (!maskCanvas) {
+ alertify.error('No selection to float');
+ return;
+ }
+
+ var layer = config.layer;
+ if (layer.type != 'image') {
+ alertify.error('Please select an image layer first');
+ return;
+ }
+
+ // Get mask bounds
+ var maskCtx = maskCanvas.getContext('2d');
+ var imageData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
+ var minX = maskCanvas.width, minY = maskCanvas.height;
+ var maxX = 0, maxY = 0;
+ var hasSelection = false;
+
+ for (var y = 0; y < maskCanvas.height; y++) {
+ for (var x = 0; x < maskCanvas.width; x++) {
+ var i = (y * maskCanvas.width + x) * 4;
+ if (imageData.data[i] > 128) {
+ hasSelection = true;
+ minX = Math.min(minX, x);
+ minY = Math.min(minY, y);
+ maxX = Math.max(maxX, x);
+ maxY = Math.max(maxY, y);
+ }
+ }
+ }
+
+ if (!hasSelection || maxX <= minX || maxY <= minY) {
+ alertify.error('Selection is empty or too small');
+ return;
+ }
+
+ // Create masked image
+ var maskedCanvas = document.createElement('canvas');
+ maskedCanvas.width = layer.width_original;
+ maskedCanvas.height = layer.height_original;
+ var maskedCtx = maskedCanvas.getContext('2d');
+
+ maskedCtx.drawImage(layer.link, 0, 0);
+ maskedCtx.globalCompositeOperation = 'destination-in';
+ maskedCtx.drawImage(maskCanvas, 0, 0);
+
+ // Crop to selection bounds
+ var cropWidth = maxX - minX + 1;
+ var cropHeight = maxY - minY + 1;
+
+ var croppedCanvas = document.createElement('canvas');
+ croppedCanvas.width = cropWidth;
+ croppedCanvas.height = cropHeight;
+ var croppedCtx = croppedCanvas.getContext('2d');
+
+ croppedCtx.drawImage(
+ maskedCanvas,
+ minX, minY, cropWidth, cropHeight,
+ 0, 0, cropWidth, cropHeight
+ );
+
+ // Calculate position
+ var scaleX = layer.width / layer.width_original;
+ var scaleY = layer.height / layer.height_original;
+
+ var params = {
+ x: Math.round(layer.x + minX * scaleX),
+ y: Math.round(layer.y + minY * scaleY),
+ width: Math.round(cropWidth * scaleX),
+ height: Math.round(cropHeight * scaleY),
+ width_original: cropWidth,
+ height_original: cropHeight,
+ type: 'image',
+ name: layer.name + ' (Floated)',
+ data: croppedCanvas.toDataURL('image/png')
+ };
+
+ app.State.do_action(
+ new app.Actions.Bundle_action('float_selection', 'Float Selection', [
+ new app.Actions.Insert_layer_action(params)
+ ])
+ );
+
+ // Clear the selection
+ window.smartSelectMask = null;
+
+ // Enable transparency
+ if (config.TRANSPARENCY == false) {
+ config.TRANSPARENCY = true;
+ this.Base_layers.render();
+ }
+
+ alertify.success('Selection floated to new layer! You can now move and scale it.');
+ }
+
load() {
var _this = this;
diff --git a/frontend/src/js/tools/smart_select.js b/frontend/src/js/tools/smart_select.js
index 8899e6d..643447c 100644
--- a/frontend/src/js/tools/smart_select.js
+++ b/frontend/src/js/tools/smart_select.js
@@ -191,7 +191,8 @@ class Smart_select_class extends Base_tools_class {
newMaskCanvas.width = config.layer.width_original;
newMaskCanvas.height = config.layer.height_original;
var newMaskCtx = newMaskCanvas.getContext('2d');
- newMaskCtx.drawImage(maskImage, 0, 0);
+ // Scale mask to match layer dimensions
+ newMaskCtx.drawImage(maskImage, 0, 0, newMaskCanvas.width, newMaskCanvas.height);
// If additive and we have an existing mask, combine them
if (isAdditive && _this.maskCanvas) {