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:
+2
-1
@@ -8,7 +8,7 @@ import os
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
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
|
@asynccontextmanager
|
||||||
@@ -42,6 +42,7 @@ app.include_router(patches.router)
|
|||||||
app.include_router(generate.router)
|
app.include_router(generate.router)
|
||||||
app.include_router(tools.router)
|
app.include_router(tools.router)
|
||||||
app.include_router(ai_tools.router)
|
app.include_router(ai_tools.router)
|
||||||
|
app.include_router(print_tools.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api")
|
@app.get("/api")
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -330,6 +330,16 @@ const menuDefinition = [
|
|||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
target: 'image/remove_background.remove_background'
|
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
|
divider: true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user