Add Fit to Frame and Upscale (print tools)

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
This commit is contained in:
Claude
2026-06-09 18:21:22 +00:00
parent d01c11f948
commit 40396b72a0
5 changed files with 779 additions and 1 deletions
+2 -1
View File
@@ -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")
+375
View File
@@ -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 72600")
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.18.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}
+10
View File
@@ -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
},
+213
View File
@@ -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: `<div style="font-size:11px;color:#888;margin:0 0 8px;">
Current image: ${W}×${H}px<br>
Crop = no AI needed. Extend = AI fills the gaps${hasRemote ? '' : ' <span style="color:#ffaa00">(no provider configured — extend will use mirror fill)</span>'}.
</div>`,
},
{
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;
+179
View File
@@ -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: `<div style="font-size:11px;color:#888;margin:0 0 8px;">
Current size: ${W}×${H}px<br>${aiNote}
</div>`,
},
{
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;