diff --git a/backend/app/routers/print_tools.py b/backend/app/routers/print_tools.py index 52fe476..71f8046 100644 --- a/backend/app/routers/print_tools.py +++ b/backend/app/routers/print_tools.py @@ -21,6 +21,7 @@ FRAME_SIZES = { "8x10": (8, 10), "11x14": (11, 14), "16x20": (16, 20), + "18x24": (18, 24), "20x24": (20, 24), "24x36": (24, 36), # Square @@ -65,6 +66,16 @@ class UpscaleRequest(BaseModel): method: str = "auto" +class PrepareRequest(BaseModel): + image: str # base64 + frame: str # e.g. "8x10" + orientation: Literal["auto", "portrait", "landscape"] = "auto" + target_dpi: int = 300 + upscale_method: str = "auto" # auto / realesrgan_pytorch / realesrgan_ncnn / lanczos + mode: Literal["crop", "extend", "smart"] = "smart" + prompt: Optional[str] = "" + + # ── Frame sizes endpoint ─────────────────────────────────────────────────── @router.get("/frame-sizes") @@ -335,6 +346,105 @@ def upscale_install_status(): return status +@router.post("/prepare") +async def prepare_for_print(req: PrepareRequest): + """ + One-shot Prepare for Print: AI upscale to reach target DPI, then fit to frame. + + Steps: + 1. Resolve target pixel dimensions (frame × target_dpi, orientation-adjusted) + 2. Calculate needed upscale factor so the image meets the target resolution + 3. Run Real-ESRGAN if scale > 1.05 (else skip — already large enough) + 4. Run frame-fit (crop / extend / smart) to exact target dimensions + 5. Return the print-ready image and a quality report + """ + 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.target_dpi <= 600): + raise HTTPException(status_code=400, detail="target_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] + img_w, img_h = image.size + + # Resolve orientation (same logic as frame_fit) + 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: + if img_landscape and not frame_landscape: + fw, fh = fh, fw + elif not img_landscape and frame_landscape: + fw, fh = fh, fw + + target_w = fw * req.target_dpi + target_h = fh * req.target_dpi + + # Scale factor needed so the shorter dimension fills the frame + scale_w = target_w / img_w + scale_h = target_h / img_h + needed_scale = min(scale_w, scale_h) # fill-to-fit (extend) baseline + # For crop mode we need max; use the larger to be safe and let frame-fit crop + needed_scale_crop = max(scale_w, scale_h) + + # Use the smaller (extend) scale as the upscale target; frame-fit handles the rest + upscale_factor = max(1.0, needed_scale) + upscale_applied = False + method_used = "none" + + upscaled = image + if upscale_factor > 1.05: + # Cap per-pass at 4× (Real-ESRGAN works best at 2–4×) + remaining = upscale_factor + while remaining > 1.05: + pass_scale = min(remaining, 4.0) + # Round to one decimal to keep scale in 1.1–8.0 range accepted by upscale service + pass_scale = round(pass_scale, 1) + if pass_scale < 1.1: + break + from app.services.upscale import upscale_image + result_bytes, method_used = await upscale_image(upscaled, pass_scale, req.upscale_method) + upscaled = Image.open(BytesIO(result_bytes)).convert("RGB") + remaining /= pass_scale + upscale_applied = True + + # Encode upscaled image and run frame-fit + upscaled_b64 = _encode(_to_png(upscaled)) + + fit_req = FrameFitRequest( + image=upscaled_b64, + frame=req.frame, + orientation=req.orientation, + mode=req.mode, + dpi=req.target_dpi, + prompt=req.prompt or "", + ) + # Re-use the existing frame_fit logic inline + fit_response = await frame_fit(fit_req) + + return { + "result": fit_response["result"], + "frame": req.frame, + "orientation": fit_response["orientation"], + "output_pixels": fit_response["output_pixels"], + "output_inches": fit_response["output_inches"], + "dpi": req.target_dpi, + "mode_used": fit_response["mode_used"], + "upscale_applied": upscale_applied, + "upscale_factor": round(upscale_factor, 2), + "upscale_method": method_used, + "summary": fit_response["summary"], + } + + @router.post("/upscale") async def upscale(req: UpscaleRequest): """ diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js index 9f592ed..3efb940 100644 --- a/frontend/src/js/config-menu.js +++ b/frontend/src/js/config-menu.js @@ -344,6 +344,11 @@ const menuDefinition = [ ellipsis: true, target: 'image/remove_background.remove_background' }, + { + name: 'Prepare for Print...', + ellipsis: true, + target: 'image/print_prepare.print_prepare' + }, { name: 'Fit to Frame...', ellipsis: true, diff --git a/frontend/src/js/modules/image/frame_fit.js b/frontend/src/js/modules/image/frame_fit.js index 81ac049..f967164 100644 --- a/frontend/src/js/modules/image/frame_fit.js +++ b/frontend/src/js/modules/image/frame_fit.js @@ -19,7 +19,7 @@ import { getCapabilities } from './../../api/capabilities.js'; var instance = null; const FRAME_SIZES = [ - '4x6', '5x7', '8x10', '11x14', '16x20', '20x24', '24x36', + '4x6', '5x7', '8x10', '11x14', '16x20', '18x24', '20x24', '24x36', '4x4', '8x8', '12x12', ]; @@ -27,8 +27,8 @@ const FRAME_SIZES = [ const FRAME_PX = { '4x6': [1200, 1800], '5x7': [1500, 2100], '8x10': [2400, 3000], '11x14': [3300, 4200], - '16x20': [4800, 6000], '20x24': [6000, 7200], - '24x36': [7200, 10800], + '16x20': [4800, 6000], '18x24': [5400, 7200], + '20x24': [6000, 7200], '24x36': [7200, 10800], '4x4': [1200, 1200], '8x8': [2400, 2400], '12x12': [3600, 3600], }; @@ -96,7 +96,7 @@ class Image_frame_fit_class { name: 'dpi', title: 'Output DPI:', value: '300', - values: ['72', '150', '300'], + values: ['72', '150', '200', '300'], type: 'select', }, { diff --git a/frontend/src/js/modules/image/print_prepare.js b/frontend/src/js/modules/image/print_prepare.js new file mode 100644 index 0000000..38b8735 --- /dev/null +++ b/frontend/src/js/modules/image/print_prepare.js @@ -0,0 +1,285 @@ +/** + * Prepare for Print — one-click AI upscale + frame fit. + * + * Shows a quality assessment (current effective DPI, needed upscale factor, + * AI vs Lanczos note) then chains AI upscale → frame-fit in a single backend call. + * + * Menu target: image/print_prepare.print_prepare + */ + +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'; + +const FRAME_SIZES = [ + '5x7', '8x10', '11x14', '18x24', '16x20', '20x24', '24x36', +]; + +// Portrait pixels at 300 DPI (label use only) +const FRAME_PX = { + '5x7': [1500, 2100], '8x10': [2400, 3000], + '11x14': [3300, 4200], '18x24': [5400, 7200], + '16x20': [4800, 6000], '20x24': [6000, 7200], + '24x36': [7200, 10800], +}; + +// Actual frame inches (portrait w, h) +const FRAME_IN = { + '5x7': [5, 7], '8x10': [8, 10], '11x14': [11, 14], + '18x24': [18, 24], '16x20': [16, 20], '20x24': [20, 24], + '24x36': [24, 36], +}; + +var instance = null; + +class Image_print_prepare_class { + + constructor() { + if (instance) return instance; + instance = this; + this.Base_layers = new Base_layers_class(); + this.Dialog = new Dialog_class(); + this.isProcessing = false; + } + + async print_prepare() { + if (!config.layer || config.layer.type !== 'image') { + alertify.error('Select an image layer first.'); + return; + } + + var caps = await getCapabilities(); + var hasAI = (caps.remote && caps.remote.healthy) || (caps.local && caps.local.local_gpu_available); + + var W = config.layer.width_original; + var H = config.layer.height_original; + + var qualityHtml = _buildQualityHtml(W, H, hasAI); + + var frameLabels = FRAME_SIZES.map(s => { + var px = FRAME_PX[s] || [0, 0]; + return `${s}" (${px[0]}×${px[1]}px @ 300dpi)`; + }); + + var _this = this; + this.Dialog.show({ + title: 'Prepare for Print', + params: [ + { + title: '', + html: qualityHtml, + }, + { + name: 'frame', + title: 'Target frame size:', + value: frameLabels[0], + values: frameLabels, + type: 'select', + }, + { + name: 'orientation', + title: 'Orientation:', + value: 'auto', + values: ['auto', 'portrait', 'landscape'], + type: 'select', + }, + { + name: 'target_dpi', + title: 'Target DPI:', + value: '300', + values: ['200', '300'], + type: 'select', + comment: '200 dpi is fine for 18×24" and larger (viewed from a distance)', + }, + { + name: 'mode', + title: 'Fit mode:', + value: 'smart', + values: ['smart', 'crop', 'extend'], + type: 'select', + comment: 'smart = extend if gap <15%, else crop', + }, + { + name: 'upscale_method', + title: 'Upscale engine:', + value: 'auto', + values: ['auto', 'realesrgan_pytorch', 'realesrgan_ncnn', 'lanczos'], + type: 'select', + comment: hasAI ? 'auto picks Real-ESRGAN — genuinely adds detail' : 'auto picks Real-ESRGAN if available, else Lanczos', + }, + { + name: 'prompt', + title: 'Extend prompt (optional):', + value: '', + placeholder: 'e.g. "natural background continuation" — 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]; + await _this._run(frameKey, params, W, H); + }, + }); + } + + async _run(frameKey, params, origW, origH) { + if (this.isProcessing) return; + this.isProcessing = true; + + var dpi = parseInt(params.target_dpi) || 300; + var inches = FRAME_IN[frameKey] || [8, 10]; + var targetW = inches[0] * dpi; + var targetH = inches[1] * dpi; + + // Orientation swap for display + var orient = params.orientation || 'auto'; + var imgLandscape = origW >= origH; + var frameLandscape = inches[0] >= inches[1]; + if (orient === 'landscape' || (orient === 'auto' && imgLandscape && !frameLandscape)) { + targetW = Math.max(inches[0], inches[1]) * dpi; + targetH = Math.min(inches[0], inches[1]) * dpi; + } else if (orient === 'portrait' || (orient === 'auto' && !imgLandscape && frameLandscape)) { + targetW = Math.min(inches[0], inches[1]) * dpi; + targetH = Math.max(inches[0], inches[1]) * dpi; + } + + var neededScale = Math.max(targetW / origW, targetH / origH); + var willUpscale = neededScale > 1.05; + + alertify.message( + willUpscale + ? `Upscaling ${neededScale.toFixed(1)}× with AI, then fitting to frame… this may take a minute` + : 'Fitting to frame…', + 0 + ); + + try { + var layerCanvas = document.createElement('canvas'); + layerCanvas.width = origW; + layerCanvas.height = origH; + 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/prepare`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + image: imageB64, + frame: frameKey, + orientation: orient, + target_dpi: dpi, + upscale_method: params.upscale_method || 'auto', + mode: params.mode || 'smart', + prompt: params.prompt || '', + }), + }); + + if (!r.ok) { + var err = await r.json().catch(() => ({ detail: 'Server error' })); + throw new Error(err.detail || 'Prepare 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); + + var fitW = img.naturalWidth; + var fitH = img.naturalHeight; + + if (params.new_layer) { + app.State.do_action( + new app.Actions.Bundle_action('print_prepare_layer', 'Prepare for Print', [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }), + new app.Actions.Insert_layer_action({ + name: `${frameKey} ${dpi}dpi`, + type: 'image', + data: img.src, + x: 0, y: 0, + width: fitW, height: fitH, + width_original: fitW, height_original: fitH, + }), + new app.Actions.Prepare_canvas_action('do'), + ]) + ); + } else { + app.State.do_action( + new app.Actions.Bundle_action('print_prepare', 'Prepare for Print', [ + new app.Actions.Prepare_canvas_action('undo'), + new app.Actions.Update_config_action({ WIDTH: fitW, HEIGHT: fitH }), + new app.Actions.Update_layer_image_action(resultCanvas), + new app.Actions.Prepare_canvas_action('do'), + ]) + ); + } + + alertify.dismissAll(); + var upscaleNote = result.upscale_applied + ? ` · ${result.upscale_factor}× ${result.upscale_method}` + : ' · no upscale needed'; + alertify.success( + `Print-ready! ${fitW}×${fitH}px @ ${dpi} DPI (${frameKey}")${upscaleNote}` + ); + 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('Prepare for Print failed: ' + (err.message || err)); + this.isProcessing = false; + } + } +} + +function _buildQualityHtml(W, H, hasAI) { + var rows = FRAME_SIZES.map(key => { + var inches = FRAME_IN[key]; + // Effective DPI: smaller of the two dimensions (limiting factor) + var effDpi = Math.round(Math.min(W / inches[0], H / inches[1])); + var quality = effDpi >= 300 ? '✓ excellent' + : effDpi >= 200 ? '✓ good for large format' + : effDpi >= 150 ? '~ acceptable' + : '✗ needs upscaling'; + var color = effDpi >= 300 ? '#44cc44' + : effDpi >= 200 ? '#88cc44' + : effDpi >= 150 ? '#ffaa44' + : '#ff6644'; + var neededScale = Math.max(1, Math.ceil((300 / effDpi) * 10) / 10); + var scaleNote = effDpi >= 300 ? '' : ` → need ~${neededScale.toFixed(1)}× upscale`; + return ` + ${key}" + ${effDpi} DPI + ${quality}${scaleNote} + `; + }).join(''); + + var aiNote = hasAI + ? 'Real-ESRGAN available — will add genuine sharpness (AI reconstructs detail)' + : 'No AI provider — will use Lanczos (resizes but doesn\'t add detail)'; + + return `
+
Current image: ${W}×${H}px · ${aiNote}
+ ${rows}
+
200 DPI is fine for 18×24" and larger prints viewed from 2+ feet.
+
`; +} + +export default Image_print_prepare_class; diff --git a/frontend/src/js/modules/image/size.js b/frontend/src/js/modules/image/size.js index 39cbc34..f942250 100644 --- a/frontend/src/js/modules/image/size.js +++ b/frontend/src/js/modules/image/size.js @@ -16,6 +16,10 @@ const PRINT_SIZES = [ [3000, 2400, '8x10" Landscape'], [3300, 4200, '11x14" Portrait'], [4200, 3300, '11x14" Landscape'], + [3600, 4800, '18x24" Portrait 200dpi'], + [4800, 3600, '18x24" Landscape 200dpi'], + [5400, 7200, '18x24" Portrait 300dpi'], + [7200, 5400, '18x24" Landscape 300dpi'], ]; class Image_size_class {