mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
1 parent
9254fec8c9
commit
f94f1dc5f5
4 files changed
+310
-19
No files matched your search
@@ -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]' : ''}`);
|
||||
@@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
|
||||
import { rgbaToPalette, paletteToRgba } from '../../lib/image-to-palette.js';
|
||||
import { resizeRgba } from '../../lib/image-resize.js';
|
||||
import { createImageUploader } from '../../lib/image-uploader.js';
|
||||
|
||||
let { open, cursorPos, getCommittedColor, onClose, onCredits } = $props();
|
||||
@@ -24,6 +25,12 @@
|
||||
let skipMatching = $state(true);
|
||||
let dither = $state(false);
|
||||
|
||||
// Resize controls — init to source dims on file load
|
||||
let resizeW = $state(0);
|
||||
let resizeH = $state(0);
|
||||
let lockAspect = $state(true);
|
||||
let resampleMethod = $state('nearest'); // 'nearest' | 'bilinear' | 'box'
|
||||
|
||||
// Run state
|
||||
let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error'
|
||||
let placed = $state(0);
|
||||
@@ -57,6 +64,8 @@
|
||||
srcRgba = data;
|
||||
srcWidth = w;
|
||||
srcHeight = h;
|
||||
resizeW = w;
|
||||
resizeH = h;
|
||||
// paletteIndices recomputed reactively via $effect below.
|
||||
} catch (err) {
|
||||
errorText = `Failed to load image: ${err.message || err}`;
|
||||
@@ -66,10 +75,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Re-quantize when source or dither mode changes.
|
||||
// Re-run pipeline when source / resize / dither / method changes.
|
||||
$effect(() => {
|
||||
if (!srcRgba) { paletteIndices = null; opaqueCount = 0; return; }
|
||||
const idx = rgbaToPalette(srcRgba, srcWidth, srcHeight, { dither });
|
||||
if (!srcRgba || resizeW <= 0 || resizeH <= 0) {
|
||||
paletteIndices = null; opaqueCount = 0; return;
|
||||
}
|
||||
const working = (resizeW === srcWidth && resizeH === srcHeight)
|
||||
? srcRgba
|
||||
: resizeRgba(srcRgba, srcWidth, srcHeight, resizeW, resizeH, resampleMethod);
|
||||
const idx = rgbaToPalette(working, resizeW, resizeH, { dither });
|
||||
paletteIndices = idx;
|
||||
let count = 0;
|
||||
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
|
||||
@@ -79,11 +93,40 @@
|
||||
|
||||
function renderPreview() {
|
||||
if (!previewEl || !paletteIndices) return;
|
||||
const rgba = paletteToRgba(paletteIndices, srcWidth, srcHeight);
|
||||
previewEl.width = srcWidth;
|
||||
previewEl.height = srcHeight;
|
||||
const rgba = paletteToRgba(paletteIndices, resizeW, resizeH);
|
||||
previewEl.width = resizeW;
|
||||
previewEl.height = resizeH;
|
||||
const ctx = previewEl.getContext('2d');
|
||||
ctx.putImageData(new ImageData(rgba, srcWidth, srcHeight), 0, 0);
|
||||
ctx.putImageData(new ImageData(rgba, resizeW, resizeH), 0, 0);
|
||||
}
|
||||
|
||||
function onResizeWInput(e) {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v) || v < 1) return;
|
||||
resizeW = Math.min(CANVAS_WIDTH, v);
|
||||
if (lockAspect && srcWidth > 0) {
|
||||
resizeH = Math.max(1, Math.min(CANVAS_HEIGHT, Math.round(srcHeight * resizeW / srcWidth)));
|
||||
}
|
||||
}
|
||||
function onResizeHInput(e) {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(v) || v < 1) return;
|
||||
resizeH = Math.min(CANVAS_HEIGHT, v);
|
||||
if (lockAspect && srcHeight > 0) {
|
||||
resizeW = Math.max(1, Math.min(CANVAS_WIDTH, Math.round(srcWidth * resizeH / srcHeight)));
|
||||
}
|
||||
}
|
||||
function fitToCanvas() {
|
||||
if (!srcWidth || !srcHeight) return;
|
||||
const maxW = Math.max(1, CANVAS_WIDTH - originX);
|
||||
const maxH = Math.max(1, CANVAS_HEIGHT - originY);
|
||||
const ratio = Math.min(maxW / srcWidth, maxH / srcHeight, 1);
|
||||
resizeW = Math.max(1, Math.floor(srcWidth * ratio));
|
||||
resizeH = Math.max(1, Math.floor(srcHeight * ratio));
|
||||
}
|
||||
function resetSize() {
|
||||
resizeW = srcWidth;
|
||||
resizeH = srcHeight;
|
||||
}
|
||||
|
||||
$effect(() => { if (open && paletteIndices) renderPreview(); });
|
||||
@@ -97,7 +140,7 @@
|
||||
if (!paletteIndices) return 'No image loaded.';
|
||||
if (!Number.isInteger(originX) || !Number.isInteger(originY)) return 'X and Y must be integers.';
|
||||
if (originX < 0 || originY < 0) return 'X and Y must be ≥ 0.';
|
||||
if (originX + srcWidth > CANVAS_WIDTH || originY + srcHeight > CANVAS_HEIGHT) {
|
||||
if (originX + resizeW > CANVAS_WIDTH || originY + resizeH > CANVAS_HEIGHT) {
|
||||
return `Image would overflow canvas (${CANVAS_WIDTH}x${CANVAS_HEIGHT}).`;
|
||||
}
|
||||
return null;
|
||||
@@ -108,8 +151,8 @@
|
||||
for (let i = 0; i < paletteIndices.length; i++) {
|
||||
const color = paletteIndices[i];
|
||||
if (color < 0) continue;
|
||||
const lx = i % srcWidth;
|
||||
const ly = (i / srcWidth) | 0;
|
||||
const lx = i % resizeW;
|
||||
const ly = (i / resizeW) | 0;
|
||||
const x = originX + lx;
|
||||
const y = originY + ly;
|
||||
if (skipMatching && getCommittedColor?.(x, y) === color) continue;
|
||||
@@ -197,11 +240,29 @@
|
||||
<canvas bind:this={previewEl} class="preview"></canvas>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>{srcWidth} × {srcHeight}</div>
|
||||
<div>{opaqueCount} / {srcWidth * srcHeight} opaque</div>
|
||||
<div>source: {srcWidth} × {srcHeight}</div>
|
||||
<div>output: {resizeW} × {resizeH}</div>
|
||||
<div>{opaqueCount} / {resizeW * resizeH} opaque</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>W <input type="number" min="1" max={CANVAS_WIDTH} value={resizeW} oninput={onResizeWInput} /></label>
|
||||
<label>H <input type="number" min="1" max={CANVAS_HEIGHT} value={resizeH} oninput={onResizeHInput} /></label>
|
||||
<label class="chk" title="Lock aspect ratio to source"><input type="checkbox" bind:checked={lockAspect} /> lock</label>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Method
|
||||
<select bind:value={resampleMethod}>
|
||||
<option value="nearest">nearest</option>
|
||||
<option value="bilinear">bilinear</option>
|
||||
<option value="box">box</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onclick={fitToCanvas} title="Fit within canvas at current origin">Fit</button>
|
||||
<button onclick={resetSize} title="Reset to source dimensions">1:1</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>X <input type="number" min="0" max={CANVAS_WIDTH - 1} bind:value={originX} /></label>
|
||||
<label>Y <input type="number" min="0" max={CANVAS_HEIGHT - 1} bind:value={originY} /></label>
|
||||
@@ -279,6 +340,8 @@
|
||||
|
||||
label { display: flex; align-items: center; gap: 6px; }
|
||||
input[type="number"] { width: 70px; padding: 4px 6px; background: #1a1a1a; border: 1px solid #444; color: #eee; border-radius: 4px; }
|
||||
select { padding: 4px 6px; background: #1a1a1a; border: 1px solid #444; color: #eee; border-radius: 4px; }
|
||||
.chk { font-size: 0.8rem; color: #aaa; cursor: pointer; }
|
||||
|
||||
.file-btn {
|
||||
display: inline-block; padding: 6px 12px; background: #2563eb; border-radius: 6px;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Pure RGBA buffer resizers. No DOM dependency — usable in both the browser
|
||||
* importer and the Node CLI (with a sharp-provided RGBA input).
|
||||
*
|
||||
* All methods return a fresh Uint8ClampedArray of length dstW*dstH*4.
|
||||
* If src dimensions match dst, returns a copy (identity).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Uint8Array|Uint8ClampedArray|number[]} rgba - src RGBA (length = srcW*srcH*4)
|
||||
* @param {number} srcW
|
||||
* @param {number} srcH
|
||||
* @param {number} dstW
|
||||
* @param {number} dstH
|
||||
* @param {'nearest'|'bilinear'|'box'} [method='nearest']
|
||||
* @returns {Uint8ClampedArray}
|
||||
*/
|
||||
export function resizeRgba(rgba, srcW, srcH, dstW, dstH, method = 'nearest') {
|
||||
if (dstW <= 0 || dstH <= 0) throw new Error('dstW and dstH must be > 0');
|
||||
if (srcW === dstW && srcH === dstH) return new Uint8ClampedArray(rgba);
|
||||
|
||||
switch (method) {
|
||||
case 'bilinear': return resampleBilinear(rgba, srcW, srcH, dstW, dstH);
|
||||
case 'box': return resampleBox(rgba, srcW, srcH, dstW, dstH);
|
||||
case 'nearest':
|
||||
default: return resampleNearest(rgba, srcW, srcH, dstW, dstH);
|
||||
}
|
||||
}
|
||||
|
||||
function resampleNearest(src, sw, sh, dw, dh) {
|
||||
const out = new Uint8ClampedArray(dw * dh * 4);
|
||||
const xRatio = sw / dw;
|
||||
const yRatio = sh / dh;
|
||||
for (let y = 0; y < dh; y++) {
|
||||
const sy = Math.min(sh - 1, (y * yRatio) | 0);
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const sx = Math.min(sw - 1, (x * xRatio) | 0);
|
||||
const si = (sy * sw + sx) * 4;
|
||||
const di = (y * dw + x) * 4;
|
||||
out[di] = src[si];
|
||||
out[di + 1] = src[si + 1];
|
||||
out[di + 2] = src[si + 2];
|
||||
out[di + 3] = src[si + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function resampleBilinear(src, sw, sh, dw, dh) {
|
||||
const out = new Uint8ClampedArray(dw * dh * 4);
|
||||
const xRatio = dw > 1 ? (sw - 1) / (dw - 1) : 0;
|
||||
const yRatio = dh > 1 ? (sh - 1) / (dh - 1) : 0;
|
||||
for (let y = 0; y < dh; y++) {
|
||||
const fy = y * yRatio;
|
||||
const y0 = fy | 0;
|
||||
const y1 = Math.min(sh - 1, y0 + 1);
|
||||
const wy = fy - y0;
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const fx = x * xRatio;
|
||||
const x0 = fx | 0;
|
||||
const x1 = Math.min(sw - 1, x0 + 1);
|
||||
const wx = fx - x0;
|
||||
|
||||
const i00 = (y0 * sw + x0) * 4;
|
||||
const i01 = (y0 * sw + x1) * 4;
|
||||
const i10 = (y1 * sw + x0) * 4;
|
||||
const i11 = (y1 * sw + x1) * 4;
|
||||
const di = (y * dw + x) * 4;
|
||||
|
||||
for (let c = 0; c < 4; c++) {
|
||||
const top = src[i00 + c] * (1 - wx) + src[i01 + c] * wx;
|
||||
const bottom = src[i10 + c] * (1 - wx) + src[i11 + c] * wx;
|
||||
out[di + c] = top * (1 - wy) + bottom * wy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Box filter: average pixels in each destination cell's source footprint.
|
||||
* Good for downscales; degrades to nearest for upscales (footprint < 1 pixel). */
|
||||
function resampleBox(src, sw, sh, dw, dh) {
|
||||
if (dw >= sw || dh >= sh) return resampleBilinear(src, sw, sh, dw, dh);
|
||||
const out = new Uint8ClampedArray(dw * dh * 4);
|
||||
const xRatio = sw / dw;
|
||||
const yRatio = sh / dh;
|
||||
for (let y = 0; y < dh; y++) {
|
||||
const sy0 = Math.floor(y * yRatio);
|
||||
const sy1 = Math.min(sh, Math.ceil((y + 1) * yRatio));
|
||||
for (let x = 0; x < dw; x++) {
|
||||
const sx0 = Math.floor(x * xRatio);
|
||||
const sx1 = Math.min(sw, Math.ceil((x + 1) * xRatio));
|
||||
let r = 0, g = 0, b = 0, a = 0, n = 0;
|
||||
for (let yy = sy0; yy < sy1; yy++) {
|
||||
for (let xx = sx0; xx < sx1; xx++) {
|
||||
const si = (yy * sw + xx) * 4;
|
||||
r += src[si];
|
||||
g += src[si + 1];
|
||||
b += src[si + 2];
|
||||
a += src[si + 3];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
const di = (y * dw + x) * 4;
|
||||
out[di] = r / n;
|
||||
out[di + 1] = g / n;
|
||||
out[di + 2] = b / n;
|
||||
out[di + 3] = a / n;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resizeRgba } from '../../src/lib/image-resize.js';
|
||||
|
||||
/** Build an RGBA buffer from a function (x,y) → [r,g,b,a]. */
|
||||
function build(w, h, fn) {
|
||||
const out = new Uint8ClampedArray(w * h * 4);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const [r, g, b, a] = fn(x, y);
|
||||
const i = (y * w + x) * 4;
|
||||
out[i] = r; out[i + 1] = g; out[i + 2] = b; out[i + 3] = a;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('resizeRgba', () => {
|
||||
it('identity (same dims) returns copy with same contents', () => {
|
||||
const src = build(3, 2, (x, y) => [x * 10, y * 20, 0, 255]);
|
||||
const out = resizeRgba(src, 3, 2, 3, 2);
|
||||
expect(out).toEqual(new Uint8ClampedArray(src));
|
||||
expect(out).not.toBe(src); // must be a fresh buffer
|
||||
});
|
||||
|
||||
it('throws on zero or negative destination dims', () => {
|
||||
const src = new Uint8ClampedArray(4);
|
||||
expect(() => resizeRgba(src, 1, 1, 0, 1)).toThrow();
|
||||
expect(() => resizeRgba(src, 1, 1, 1, -1)).toThrow();
|
||||
});
|
||||
|
||||
it('nearest 2x downscale picks one of each 2x2 block (integer factor)', () => {
|
||||
// 4x4 where each 2x2 block has a unique color
|
||||
const src = build(4, 4, (x, y) => {
|
||||
const bx = x >> 1, by = y >> 1;
|
||||
return [bx * 80, by * 80, 0, 255];
|
||||
});
|
||||
const out = resizeRgba(src, 4, 4, 2, 2, 'nearest');
|
||||
expect(out.length).toBe(2 * 2 * 4);
|
||||
// Each output pixel must equal one of the four pixels in its source block.
|
||||
// For default nearest (floor), 2x downscale picks the top-left of the block.
|
||||
expect([out[0], out[1], out[2]]).toEqual([0, 0, 0]);
|
||||
expect([out[4], out[5], out[6]]).toEqual([80, 0, 0]);
|
||||
expect([out[8], out[9], out[10]]).toEqual([0, 80, 0]);
|
||||
expect([out[12], out[13], out[14]]).toEqual([80, 80, 0]);
|
||||
});
|
||||
|
||||
it('nearest 2x upscale duplicates pixels', () => {
|
||||
const src = build(2, 1, (x) => [x * 255, 0, 0, 255]);
|
||||
const out = resizeRgba(src, 2, 1, 4, 2, 'nearest');
|
||||
// Each source pixel appears in a 2x2 block
|
||||
expect([out[0], out[4]]).toEqual([0, 0]);
|
||||
expect([out[8], out[12]]).toEqual([255, 255]);
|
||||
expect([out[16], out[20]]).toEqual([0, 0]);
|
||||
expect([out[24], out[28]]).toEqual([255, 255]);
|
||||
});
|
||||
|
||||
it('bilinear produces an in-between value at the midpoint', () => {
|
||||
// Two pixels: black (0) and white (255). Midpoint should blend toward 127/128.
|
||||
const src = build(2, 1, (x) => [x * 255, x * 255, x * 255, 255]);
|
||||
const out = resizeRgba(src, 2, 1, 3, 1, 'bilinear');
|
||||
// out[1] is the middle pixel — must be strictly between the endpoints.
|
||||
const mid = out[4];
|
||||
expect(mid).toBeGreaterThan(0);
|
||||
expect(mid).toBeLessThan(255);
|
||||
// Endpoints are preserved
|
||||
expect(out[0]).toBe(0);
|
||||
expect(out[8]).toBe(255);
|
||||
});
|
||||
|
||||
it('box averages a 2x2 block on 2x downscale', () => {
|
||||
const src = build(2, 2, (x, y) => {
|
||||
if (x === 0 && y === 0) return [0, 0, 0, 255];
|
||||
if (x === 1 && y === 0) return [100, 0, 0, 255];
|
||||
if (x === 0 && y === 1) return [0, 100, 0, 255];
|
||||
return [100, 100, 0, 255];
|
||||
});
|
||||
const out = resizeRgba(src, 2, 2, 1, 1, 'box');
|
||||
expect(out.length).toBe(4);
|
||||
expect(out[0]).toBe(50); // avg of 0, 100, 0, 100
|
||||
expect(out[1]).toBe(50);
|
||||
expect(out[2]).toBe(0);
|
||||
expect(out[3]).toBe(255);
|
||||
});
|
||||
|
||||
it('unknown method falls back to nearest', () => {
|
||||
const src = build(2, 1, (x) => [x * 255, 0, 0, 255]);
|
||||
const out = resizeRgba(src, 2, 1, 4, 1, /** @type any */ ('mystery'));
|
||||
// Should be nearest-neighbor output: 0,0,255,255
|
||||
expect([out[0], out[4], out[8], out[12]]).toEqual([0, 0, 255, 255]);
|
||||
});
|
||||
|
||||
it('preserves alpha channel', () => {
|
||||
const src = build(2, 1, (x) => [255, 0, 0, x === 0 ? 0 : 128]);
|
||||
const out = resizeRgba(src, 2, 1, 2, 1, 'nearest');
|
||||
expect(out[3]).toBe(0);
|
||||
expect(out[7]).toBe(128);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user