From f94f1dc5f5e4b30a0c6ec78697ccded42cec71b7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 17 Apr 2026 11:43:11 +0700 Subject: [PATCH] =?UTF-8?q?feat(importer):=20phase=201=20=E2=80=94=20resiz?= =?UTF-8?q?e=20controls=20with=20multiple=20methods?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/image-to-colors.js | 32 ++++-- src/client/components/ImageImporter.svelte | 87 +++++++++++++--- src/lib/image-resize.js | 112 +++++++++++++++++++++ test/lib/image-resize.test.js | 98 ++++++++++++++++++ 4 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 src/lib/image-resize.js create mode 100644 test/lib/image-resize.test.js diff --git a/scripts/image-to-colors.js b/scripts/image-to-colors.js index 455973e..7fc1983 100644 --- a/scripts/image-to-colors.js +++ b/scripts/image-to-colors.js @@ -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 [-o output.json] [--alpha-threshold 128] [--dither]'); + console.error('Usage: node scripts/image-to-colors.js [-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]' : ''}`); diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte index 64fa654..03a6c95 100644 --- a/src/client/components/ImageImporter.svelte +++ b/src/client/components/ImageImporter.svelte @@ -1,6 +1,7 @@