feat(importer): phase 1 — resize controls with multiple methods

Add W/H inputs, aspect-ratio lock, resampling method dropdown (nearest /
bilinear / box), "Fit to canvas" (respects current origin) and "1:1" reset
buttons to the import panel. Pipeline now runs resize before palette
quantization so dither/skip-matching operate on the final dimensions.

Shared src/lib/image-resize.js is reused by scripts/image-to-colors.js,
which gains --width, --height, --method flags with aspect-preserve when
only one dimension is provided.
This commit is contained in:
tiennm99 committed 2026-04-17 11:43:11 +07:00
1 parent 9254fec8c9
commit f94f1dc5f5
4 files changed
+310 -19

No files matched your search

+25 -7
View File
@@ -14,6 +14,7 @@ import { parseArgs } from 'node:util';
import sharp from 'sharp';
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../src/lib/constants.js';
import { rgbaToPalette } from '../src/lib/image-to-palette.js';
import { resizeRgba } from '../src/lib/image-resize.js';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
@@ -21,12 +22,15 @@ const { values, positionals } = parseArgs({
output: { type: 'string', short: 'o' },
'alpha-threshold': { type: 'string', default: '128' },
dither: { type: 'boolean', default: false },
width: { type: 'string' },
height: { type: 'string' },
method: { type: 'string', default: 'nearest' }, // nearest | bilinear | box
},
allowPositionals: true,
});
if (positionals.length < 1) {
console.error('Usage: node scripts/image-to-colors.js <input.png|jpg|webp> [-o output.json] [--alpha-threshold 128] [--dither]');
console.error('Usage: node scripts/image-to-colors.js <input> [-o output.json] [--alpha-threshold 128] [--dither] [--width N] [--height N] [--method nearest|bilinear|box]');
process.exit(1);
}
@@ -44,16 +48,30 @@ const { data, info } = await sharp(input)
.raw()
.toBuffer({ resolveWithObject: true });
const { width, height } = info;
const { width: srcW, height: srcH } = info;
if (width > CANVAS_WIDTH || height > CANVAS_HEIGHT) {
console.warn(`Warning: image ${width}x${height} exceeds canvas ${CANVAS_WIDTH}x${CANVAS_HEIGHT}. Upload will fail at the boundary.`);
// Optional resize — defaults to source dims. Aspect-preserve when only one of W/H given.
const reqW = values.width ? parseInt(values.width, 10) : null;
const reqH = values.height ? parseInt(values.height, 10) : null;
let outW = reqW ?? (reqH ? Math.round(srcW * reqH / srcH) : srcW);
let outH = reqH ?? (reqW ? Math.round(srcH * reqW / srcW) : srcH);
if (outW < 1 || outH < 1) {
console.error('Resolved width/height must be >= 1');
process.exit(1);
}
if (outW > CANVAS_WIDTH || outH > CANVAS_HEIGHT) {
console.warn(`Warning: output ${outW}x${outH} exceeds canvas ${CANVAS_WIDTH}x${CANVAS_HEIGHT}. Upload will fail at the boundary.`);
}
const working = (outW === srcW && outH === srcH)
? data
: resizeRgba(data, srcW, srcH, outW, outH, values.method);
// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
const indices = rgbaToPalette(data, width, height, { alphaThreshold, dither: values.dither });
const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, dither: values.dither });
const pixels = Array.from(indices);
const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
await writeFile(output, JSON.stringify({ width, height, pixels }));
console.log(`Wrote ${output}: ${width}x${height}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`);
await writeFile(output, JSON.stringify({ width: outW, height: outH, pixels }));
const resizeNote = (outW !== srcW || outH !== srcH) ? ` (resized from ${srcW}x${srcH} via ${values.method})` : '';
console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`);