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:
@@ -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
|
||||
},
|
||||
|
||||
@@ -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