From 40396b72a0b6061804f2e219003c073725bc70e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 18:21:22 +0000 Subject: [PATCH] Add Fit to Frame and Upscale (print tools) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend — new /api/print/* router: - POST /api/print/frame-fit: fit image to 4x6/5x7/8x10/11x14/16x20/20x24/24x36 and square sizes (4x4/8x8/12x12) at configurable DPI. Three modes: crop — center-crop to aspect ratio, Lanczos scale to print res (no AI) extend — scale to fill one dimension, AI-inpaint the gap; mirror-fill fallback smart — auto: extend if gap < 15% of frame dimension, else crop Auto-detects orientation from image shape; respects explicit portrait/landscape. - POST /api/print/upscale: Lanczos scale (always) or Real-ESRGAN (if installed) - GET /api/print/frame-sizes: frame catalogue with pixel dimensions at 300dpi - GET /api/print/upscale/available: reports whether Real-ESRGAN is installed Frontend: - modules/image/frame_fit.js: dialog with frame size, orientation, mode, DPI, optional extend prompt; shows current image size; result as new layer option - modules/image/upscale.js: dialog with scale factor (1.5–4×), method selector (auto-hides AI option if Real-ESRGAN not available); result as new layer option - config-menu.js: Fit to Frame... and Upscale... added under Image menu https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN --- backend/app/main.py | 3 +- backend/app/routers/print_tools.py | 375 +++++++++++++++++++++ frontend/src/js/config-menu.js | 10 + frontend/src/js/modules/image/frame_fit.js | 213 ++++++++++++ frontend/src/js/modules/image/upscale.js | 179 ++++++++++ 5 files changed, 779 insertions(+), 1 deletion(-) create mode 100644 backend/app/routers/print_tools.py create mode 100644 frontend/src/js/modules/image/frame_fit.js create mode 100644 frontend/src/js/modules/image/upscale.js diff --git a/backend/app/main.py b/backend/app/main.py index bf07fe1..0d02fb8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ import os from app.config import settings from app.database import init_db -from app.routers import projects, edits, images, patches, generate, tools, ai_tools +from app.routers import projects, edits, images, patches, generate, tools, ai_tools, print_tools @asynccontextmanager @@ -42,6 +42,7 @@ app.include_router(patches.router) app.include_router(generate.router) app.include_router(tools.router) app.include_router(ai_tools.router) +app.include_router(print_tools.router) @app.get("/api") diff --git a/backend/app/routers/print_tools.py b/backend/app/routers/print_tools.py new file mode 100644 index 0000000..4486d3d --- /dev/null +++ b/backend/app/routers/print_tools.py @@ -0,0 +1,375 @@ +""" +Print / frame tools — frame fit and upscale. +All endpoints under /api/print prefix. +""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional, Literal +import base64 +import asyncio +from io import BytesIO +from PIL import Image +import numpy as np + +router = APIRouter(prefix="/api/print", tags=["print-tools"]) + +# ── Frame size catalogue (inches) ────────────────────────────────────────── +FRAME_SIZES = { + "4x6": (4, 6), + "5x7": (5, 7), + "8x10": (8, 10), + "11x14": (11, 14), + "16x20": (16, 20), + "20x24": (20, 24), + "24x36": (24, 36), + # Square + "4x4": (4, 4), + "8x8": (8, 8), + "12x12": (12, 12), +} + + +def _encode(data: bytes) -> str: + return base64.b64encode(data).decode() + + +def _decode(b64: str) -> bytes: + return base64.b64decode(b64) + + +def _to_png(img: Image.Image) -> bytes: + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +# ── Request models ───────────────────────────────────────────────────────── + +class FrameFitRequest(BaseModel): + image: str # base64 PNG/JPEG + frame: str # e.g. "8x10" + orientation: Literal["auto", "portrait", "landscape"] = "auto" + mode: Literal["crop", "extend", "smart"] = "smart" + dpi: int = 300 + # For extend mode: prompt passed to outpaint + prompt: Optional[str] = "" + # Smart mode threshold: extend if gap fraction < this, else crop + smart_threshold: float = 0.15 + + +class UpscaleRequest(BaseModel): + image: str # base64 + scale: float = 2.0 # 1.5, 2, 3, 4 + method: Literal["lanczos", "ai"] = "lanczos" + + +# ── Frame sizes endpoint ─────────────────────────────────────────────────── + +@router.get("/frame-sizes") +def list_frame_sizes(): + """Return the catalogue of supported frame sizes.""" + return { + "sizes": list(FRAME_SIZES.keys()), + "catalogue": {k: {"inches": v, "pixels_300dpi": (v[0]*300, v[1]*300)} + for k, v in FRAME_SIZES.items()}, + } + + +# ── Frame fit ────────────────────────────────────────────────────────────── + +@router.post("/frame-fit") +async def frame_fit(req: FrameFitRequest): + """ + Fit an image to a print frame size. + + Modes: + crop — center-crop to frame aspect ratio, then scale to print resolution. + extend — scale to fill one dimension, outpaint the gap with AI. + smart — extend if gap < smart_threshold of frame dimension, else crop. + + Returns the fitted image plus a summary of what was done. + """ + if req.frame not in FRAME_SIZES: + raise HTTPException(status_code=400, + detail=f"Unknown frame '{req.frame}'. Valid: {list(FRAME_SIZES.keys())}") + if not (72 <= req.dpi <= 600): + raise HTTPException(status_code=400, detail="dpi must be 72–600") + + try: + image = Image.open(BytesIO(_decode(req.image))).convert("RGB") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not decode image: {e}") + + fw, fh = FRAME_SIZES[req.frame] # frame inches (w, h in portrait) + + # Resolve orientation + img_w, img_h = image.size + img_landscape = img_w >= img_h + frame_landscape = fw >= fh + + if req.orientation == "landscape": + fw, fh = max(fw, fh), min(fw, fh) + elif req.orientation == "portrait": + fw, fh = min(fw, fh), max(fw, fh) + else: # auto — match image orientation + if img_landscape and not frame_landscape: + fw, fh = fh, fw # rotate frame to landscape + elif not img_landscape and frame_landscape: + fw, fh = fh, fw # rotate frame to portrait + + target_w = fw * req.dpi + target_h = fh * req.dpi + target_ratio = target_w / target_h + img_ratio = img_w / img_h + + # Determine actual mode + mode = req.mode + if mode == "smart": + # Scale image to fill the frame — compute gap fraction + if img_ratio > target_ratio: + # Image wider → fits on height, gap on width + scaled_h = target_h + scaled_w = round(target_h * img_ratio) + gap_frac = (scaled_w - target_w) / target_w # positive = overflow (crop) + else: + scaled_w = target_w + scaled_h = round(target_w / img_ratio) + gap_frac = (scaled_h - target_h) / target_h + + # gap_frac > 0 means we'd need to crop; < 0 means we'd need to extend + if gap_frac < 0: + # Need to extend — use extend if gap is small enough + mode = "extend" if abs(gap_frac) <= req.smart_threshold else "crop" + else: + mode = "crop" + + if mode == "crop": + result, summary = _crop_fit(image, target_w, target_h) + else: # extend + result, summary = await _extend_fit(image, target_w, target_h, req.prompt or "") + + return { + "result": _encode(_to_png(result)), + "mode_used": mode, + "frame": req.frame, + "orientation": "landscape" if fw > fh else "portrait", + "output_pixels": {"width": result.width, "height": result.height}, + "output_inches": {"width": fw, "height": fh}, + "dpi": req.dpi, + "summary": summary, + } + + +def _crop_fit(image: Image.Image, target_w: int, target_h: int): + """Center-crop image to target aspect ratio, then Lanczos scale to target size.""" + img_w, img_h = image.size + target_ratio = target_w / target_h + img_ratio = img_w / img_h + + if img_ratio > target_ratio: + # Wider than target — crop sides + new_w = round(img_h * target_ratio) + x0 = (img_w - new_w) // 2 + cropped = image.crop((x0, 0, x0 + new_w, img_h)) + else: + # Taller than target — crop top/bottom + new_h = round(img_w / target_ratio) + y0 = (img_h - new_h) // 2 + cropped = image.crop((0, y0, img_w, y0 + new_h)) + + result = cropped.resize((target_w, target_h), Image.Resampling.LANCZOS) + summary = ( + f"Cropped from {img_w}×{img_h} to {cropped.width}×{cropped.height}, " + f"scaled to {target_w}×{target_h}" + ) + return result, summary + + +async def _extend_fit(image: Image.Image, target_w: int, target_h: int, prompt: str): + """ + Scale image to fill one dimension exactly, then outpaint the gap with AI. + Falls back to content-aware mirror fill if no remote provider configured. + """ + from app.services.remote_provider import get_remote_provider + + img_w, img_h = image.size + target_ratio = target_w / target_h + img_ratio = img_w / img_h + + if img_ratio > target_ratio: + # Image wider — scale to target width, extend height + scale = target_w / img_w + scaled_w = target_w + scaled_h = round(img_h * scale) + gap_dir = "height" + gap_top = (target_h - scaled_h) // 2 + gap_bottom = target_h - scaled_h - gap_top + else: + # Image taller — scale to target height, extend width + scale = target_h / img_h + scaled_h = target_h + scaled_w = round(img_w * scale) + gap_dir = "width" + gap_left = (target_w - scaled_w) // 2 + gap_right = target_w - scaled_w - gap_left + + scaled = image.resize((scaled_w, scaled_h), Image.Resampling.LANCZOS) + + # Place scaled image on canvas + canvas = Image.new("RGB", (target_w, target_h), (128, 128, 128)) + if gap_dir == "height": + canvas.paste(scaled, (0, gap_top)) + # Build mask: top and bottom strips are white (to inpaint) + mask = Image.new("L", (target_w, target_h), 0) + if gap_top > 0: + mask.paste(Image.new("L", (target_w, gap_top), 255), (0, 0)) + if gap_bottom > 0: + mask.paste(Image.new("L", (target_w, gap_bottom), 255), (0, target_h - gap_bottom)) + else: + canvas.paste(scaled, (gap_left, 0)) + mask = Image.new("L", (target_w, target_h), 0) + if gap_left > 0: + mask.paste(Image.new("L", (gap_left, target_h), 255), (0, 0)) + if gap_right > 0: + mask.paste(Image.new("L", (gap_right, target_h), 255), (target_w - gap_right, 0)) + + # Try AI inpaint + provider = get_remote_provider("inpaint") + if provider: + try: + canvas_bytes = _to_png(canvas) + mask_bytes = _to_png(mask) + fill_prompt = prompt or "seamlessly continue the image, natural extension" + result_bytes = await provider.inpaint(canvas_bytes, mask_bytes, fill_prompt, {}) + result = Image.open(BytesIO(result_bytes)).convert("RGB") + summary = ( + f"Scaled {img_w}×{img_h} → {scaled_w}×{scaled_h}, " + f"AI-extended {gap_dir} to {target_w}×{target_h}" + ) + return result, summary + except Exception as e: + print(f"AI extend failed, using mirror fill: {e}") + + # Fallback: mirror-fill the gap (looks decent for backgrounds/landscapes) + result = _mirror_fill(canvas, mask, scaled, gap_dir, + gap_top if gap_dir == "height" else gap_left, + gap_bottom if gap_dir == "height" else gap_right, + target_w, target_h) + summary = ( + f"Scaled {img_w}×{img_h} → {scaled_w}×{scaled_h}, " + f"mirror-filled {gap_dir} to {target_w}×{target_h} (no AI provider)" + ) + return result, summary + + +def _mirror_fill(canvas, mask, scaled, gap_dir, gap_a, gap_b, target_w, target_h): + """Fill gaps by reflecting the nearest edge strip.""" + result = canvas.copy() + if gap_dir == "height": + if gap_a > 0: + strip = scaled.crop((0, 0, scaled.width, min(gap_a * 2, scaled.height))) + strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + strip = strip.resize((target_w, gap_a), Image.Resampling.LANCZOS) + result.paste(strip, (0, 0)) + if gap_b > 0: + strip = scaled.crop((0, max(0, scaled.height - gap_b * 2), scaled.width, scaled.height)) + strip = strip.transpose(Image.Transpose.FLIP_TOP_BOTTOM) + strip = strip.resize((target_w, gap_b), Image.Resampling.LANCZOS) + result.paste(strip, (0, target_h - gap_b)) + else: + if gap_a > 0: + strip = scaled.crop((0, 0, min(gap_a * 2, scaled.width), scaled.height)) + strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + strip = strip.resize((gap_a, target_h), Image.Resampling.LANCZOS) + result.paste(strip, (0, 0)) + if gap_b > 0: + strip = scaled.crop((max(0, scaled.width - gap_b * 2), 0, scaled.width, scaled.height)) + strip = strip.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + strip = strip.resize((gap_b, target_h), Image.Resampling.LANCZOS) + result.paste(strip, (target_w - gap_b, 0)) + return result + + +# ── Upscale ──────────────────────────────────────────────────────────────── + +@router.post("/upscale") +async def upscale(req: UpscaleRequest): + """ + Upscale image. + method=lanczos — always available, fast, good for clean images + method=ai — Real-ESRGAN if installed, else falls back to lanczos + """ + if not (1.1 <= req.scale <= 8.0): + raise HTTPException(status_code=400, detail="scale must be 1.1–8.0") + + try: + image = Image.open(BytesIO(_decode(req.image))).convert("RGB") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not decode image: {e}") + + orig_w, orig_h = image.size + new_w = round(orig_w * req.scale) + new_h = round(orig_h * req.scale) + + method_used = req.method + + if req.method == "ai": + try: + result_bytes = await asyncio.get_event_loop().run_in_executor( + None, _realesrgan_upscale, image, req.scale + ) + result = Image.open(BytesIO(result_bytes)).convert("RGB") + method_used = "realesrgan" + except Exception as e: + print(f"Real-ESRGAN failed, using Lanczos: {e}") + result = image.resize((new_w, new_h), Image.Resampling.LANCZOS) + method_used = "lanczos_fallback" + else: + result = image.resize((new_w, new_h), Image.Resampling.LANCZOS) + + return { + "result": _encode(_to_png(result)), + "method": method_used, + "original": {"width": orig_w, "height": orig_h}, + "output": {"width": result.width, "height": result.height}, + "scale": req.scale, + } + + +def _realesrgan_upscale(image: Image.Image, scale: float) -> bytes: + """Run Real-ESRGAN upscaling. Raises if not installed.""" + from basicsr.archs.rrdbnet_arch import RRDBNet + from realesrgan import RealESRGANer + import torch + import numpy as np + + model = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64, + num_block=23, num_grow_ch=32, scale=4) + upsampler = RealESRGANer( + scale=4, + model_path=None, # auto-download + model=model, + tile=400, + tile_pad=10, + pre_pad=0, + half=torch.cuda.is_available(), + ) + img_np = np.array(image)[:, :, ::-1] # RGB→BGR for cv2 + output, _ = upsampler.enhance(img_np, outscale=scale) + result = Image.fromarray(output[:, :, ::-1]) # BGR→RGB + buf = BytesIO() + result.save(buf, format="PNG") + return buf.getvalue() + + +@router.get("/upscale/available") +def upscale_available(): + """Check which upscale methods are available.""" + ai_available = False + try: + import realesrgan # noqa: F401 + ai_available = True + except ImportError: + pass + return {"lanczos": True, "realesrgan": ai_available} diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js index 2ab5d44..b19e667 100644 --- a/frontend/src/js/config-menu.js +++ b/frontend/src/js/config-menu.js @@ -330,6 +330,16 @@ const menuDefinition = [ ellipsis: true, target: 'image/remove_background.remove_background' }, + { + name: 'Fit to Frame...', + ellipsis: true, + target: 'image/frame_fit.frame_fit' + }, + { + name: 'Upscale...', + ellipsis: true, + target: 'image/upscale.upscale' + }, { divider: true }, diff --git a/frontend/src/js/modules/image/frame_fit.js b/frontend/src/js/modules/image/frame_fit.js new file mode 100644 index 0000000..cfcd1fa --- /dev/null +++ b/frontend/src/js/modules/image/frame_fit.js @@ -0,0 +1,213 @@ +/** + * Fit to Frame — resize/extend/crop image to a standard print frame size. + * + * Modes: + * crop — center-crop to aspect ratio, scale to print resolution (no AI needed) + * extend — scale to fill one dimension, AI-outpaint the gap (needs provider) + * smart — auto-pick: extend if gap < 15% of dimension, else crop + * + * Menu target: image/frame_fit.frame_fit + */ + +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 { getCapabilities } from './../../api/capabilities.js'; + +var instance = null; + +const FRAME_SIZES = [ + '4x6', '5x7', '8x10', '11x14', '16x20', '20x24', '24x36', + '4x4', '8x8', '12x12', +]; + +// Pixels at 300 dpi for preview labels +const FRAME_PX = { + '4x6': [1200, 1800], '5x7': [1500, 2100], + '8x10': [2400, 3000], '11x14': [3300, 4200], + '16x20': [4800, 6000], '20x24': [6000, 7200], + '24x36': [7200, 10800], + '4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600], +}; + +class Image_frame_fit_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async frame_fit() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + + var caps = await getCapabilities(); + var hasRemote = caps.remote && caps.remote.healthy; + + var _this = this; + var W = config.layer.width_original; + var H = config.layer.height_original; + + // Build display labels with pixel sizes + var sizeLabels = FRAME_SIZES.map(s => { + var px = FRAME_PX[s] || [0, 0]; + return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`; + }); + + this.Dialog.show({ + title: 'Fit to Frame', + params: [ + { + title: '', + html: `
+ Current image: ${W}×${H}px
+ Crop = no AI needed. Extend = AI fills the gaps${hasRemote ? '' : ' (no provider configured — extend will use mirror fill)'}. +
`, + }, + { + name: 'frame', + title: 'Frame size:', + value: sizeLabels[1], // default 5x7 + values: sizeLabels, + type: 'select', + }, + { + name: 'orientation', + title: 'Orientation:', + value: 'auto', + values: ['auto', 'portrait', 'landscape'], + type: 'select', + }, + { + name: 'mode', + title: 'Fit mode:', + value: 'smart', + values: ['smart', 'crop', 'extend'], + type: 'select', + }, + { + name: 'dpi', + title: 'Output DPI:', + value: '300', + values: ['72', '150', '300'], + type: 'select', + }, + { + name: 'prompt', + title: 'Extend prompt (optional):', + value: '', + placeholder: 'e.g. "continue the background naturally" — blank works well', + }, + { + name: 'new_layer', + title: 'Result as new layer (keep original):', + value: true, + }, + ], + on_finish: async function (params) { + var frameKey = params.frame.split('"')[0]; // strip label suffix back to "8x10" + await _this._run(frameKey, params); + }, + }); + } + + async _run(frameKey, params) { + if (this.isProcessing) return; + this.isProcessing = true; + + var mode = params.mode || 'smart'; + alertify.message( + mode === 'extend' + ? 'Fitting to frame with AI extension... please wait' + : 'Fitting to frame...', + 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 base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/frame-fit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + frame: frameKey, + orientation: params.orientation || 'auto', + mode: params.mode || 'smart', + dpi: parseInt(params.dpi) || 300, + prompt: params.prompt || '', + }), + }); + + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: 'Server error' })); + throw new Error(err.detail || 'Frame fit failed'); + } + var result = await r.json(); + + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + if (params.new_layer) { + var dataURL = img.src; + app.State.do_action( + new app.Actions.Bundle_action('frame_fit_layer', 'Fit to Frame', [ + new app.Actions.Insert_layer_action({ + name: `${frameKey} fit`, + type: 'image', + data: dataURL, + x: 0, y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + width_original: img.naturalWidth, + height_original: img.naturalHeight, + }) + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('frame_fit', 'Fit to Frame', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + } + + alertify.dismissAll(); + alertify.success( + `Done! ${result.output_pixels.width}×${result.output_pixels.height}px` + + ` (${result.frame} ${result.orientation}, ${result.mode_used})` + ); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load result.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Frame fit failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Image_frame_fit_class; diff --git a/frontend/src/js/modules/image/upscale.js b/frontend/src/js/modules/image/upscale.js new file mode 100644 index 0000000..fd172cf --- /dev/null +++ b/frontend/src/js/modules/image/upscale.js @@ -0,0 +1,179 @@ +/** + * Upscale — increase image resolution. + * + * Lanczos: always available, fast, good for clean/sharp images. + * AI (Real-ESRGAN): much better for photos — restores texture, sharpness. + * Requires `realesrgan-ncnn-vulkan` or `basicsr` + `realesrgan` Python packages. + * + * Menu target: image/upscale.upscale + */ + +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'; + +var instance = null; + +class Image_upscale_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + this._aiAvailable = null; + } + + async upscale() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + + var W = config.layer.width_original; + var H = config.layer.height_original; + + // Check AI availability once, cache it + if (this._aiAvailable === null) { + try { + var base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/upscale/available`); + var data = r.ok ? await r.json() : {}; + this._aiAvailable = data.realesrgan || false; + } catch { + this._aiAvailable = false; + } + } + + var aiNote = this._aiAvailable + ? 'Real-ESRGAN AI upscaling available.' + : 'AI upscaling not installed (Real-ESRGAN). Using Lanczos only.'; + + var _this = this; + + this.Dialog.show({ + title: 'Upscale Image', + params: [ + { + title: '', + html: `
+ Current size: ${W}×${H}px
${aiNote} +
`, + }, + { + name: 'scale', + title: 'Scale factor:', + value: '2×', + values: ['1.5×', '2×', '3×', '4×'], + type: 'select', + }, + { + name: 'method', + title: 'Method:', + value: this._aiAvailable ? 'ai' : 'lanczos', + values: this._aiAvailable ? ['lanczos', 'ai'] : ['lanczos'], + type: 'select', + }, + { + name: 'new_layer', + title: 'Result as new layer (keep original):', + value: false, + }, + ], + on_finish: async function (params) { + var scale = parseFloat(params.scale); + var newW = Math.round(W * scale); + var newH = Math.round(H * scale); + await _this._run(scale, params.method, params.new_layer, newW, newH); + }, + }); + } + + async _run(scale, method, newLayer, newW, newH) { + if (this.isProcessing) return; + this.isProcessing = true; + + alertify.message( + `Upscaling ${scale}× with ${method}... 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 base = window.API_BASE_URL || ''; + var r = await fetch(`${base}/api/print/upscale`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + scale: scale, + method: method, + }), + }); + + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: 'Server error' })); + throw new Error(err.detail || 'Upscale failed'); + } + var result = await r.json(); + + var img = new Image(); + img.onload = () => { + var resultCanvas = document.createElement('canvas'); + resultCanvas.width = img.naturalWidth; + resultCanvas.height = img.naturalHeight; + resultCanvas.getContext('2d').drawImage(img, 0, 0); + + if (newLayer) { + app.State.do_action( + new app.Actions.Bundle_action('upscale_layer', 'Upscale', [ + new app.Actions.Insert_layer_action({ + name: `${scale}× upscale (${result.method})`, + type: 'image', + data: img.src, + x: 0, y: 0, + width: img.naturalWidth, + height: img.naturalHeight, + width_original: img.naturalWidth, + height_original: img.naturalHeight, + }) + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('upscale', 'Upscale', [ + new app.Actions.Update_layer_image_action(resultCanvas) + ]) + ); + } + + alertify.dismissAll(); + alertify.success( + `Upscaled to ${result.output.width}×${result.output.height}px` + + ` (${result.method})` + ); + this.isProcessing = false; + }; + img.onerror = () => { + alertify.dismissAll(); + alertify.error('Failed to load upscaled image.'); + this.isProcessing = false; + }; + img.src = 'data:image/png;base64,' + result.result; + + } catch (err) { + alertify.dismissAll(); + alertify.error('Upscale failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +export default Image_upscale_class;