feat(importer): phase 3 — flip / rotate transforms

Pure-function transformRgba in src/lib/image-transform.js supports flip H,
flip V, and rotation in 90° steps (fixed application order: rotate → flipH
→ flipV). Wired into the importer pipeline before resize with buttons for
each op; ±90° rotations auto-swap the resize dims so output aspect tracks
the rotation. CLI gains --flip-h, --flip-v, --rotate flags using the same
shared module.

8 new unit tests cover identity, each flip, 90/180/270 rotation, the
flipH+flipV ≡ 180° identity, and invalid rotation rejection.
This commit is contained in:
tiennm99 committed 2026-04-17 11:49:54 +07:00
1 parent 75c1023554
commit fc0121fd48
6 files changed
+271 -20

No files matched your search

@@ -2,7 +2,7 @@
## Overview
- **Priority:** P2 (medium)
- **Status:** Todo
- **Status:** Done
- Let the user flip horizontally, flip vertically, and rotate in 90° increments without re-exporting the source.
## Requirements
@@ -33,10 +33,10 @@ srcRgba → transformRgba → resizeRgba → rgbaToPalette
4. CLI flags + doc update.
## Todo
- [ ] `image-transform.js` + tests
- [ ] Importer buttons + state
- [ ] CLI flags
- [ ] Verify preview, overlay (Phase 2), and upload agree
- [x] `image-transform.js` + tests (8 tests)
- [x] Importer buttons + state (flip-H, flip-V, rotate CW/CCW, reset; auto-swaps resize dims on 90°)
- [x] CLI flags (`--flip-h`, `--flip-v`, `--rotate`)
- [x] Verified preview, overlay, and upload agree on transforms
## Success Criteria
- Flip/rotate buttons produce visually correct preview.
@@ -17,7 +17,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t
|---|---|---|---|
| 1 | [Resize controls](phase-01-resize-controls.md) | Done | Must-have — user explicitly asked |
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits |
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Todo | Medium — quick fixes without re-editing source |
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Done | Medium — quick fixes without re-editing source |
| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Todo | Medium — different looks per source |
| 5 | [Color correction sliders](phase-05-color-correction.md) | Todo | Medium — photos benefit most |
| 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Todo | Low — easy win, often useful for logos |
+25 -6
View File
@@ -15,6 +15,7 @@ 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';
import { transformRgba } from '../src/lib/image-transform.js';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
@@ -25,12 +26,15 @@ const { values, positionals } = parseArgs({
width: { type: 'string' },
height: { type: 'string' },
method: { type: 'string', default: 'nearest' }, // nearest | bilinear | box
'flip-h': { type: 'boolean', default: false },
'flip-v': { type: 'boolean', default: false },
rotate: { type: 'string', default: '0' }, // 0 | 90 | 180 | 270 (CW)
},
allowPositionals: true,
});
if (positionals.length < 1) {
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]');
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] [--flip-h] [--flip-v] [--rotate 0|90|180|270]');
process.exit(1);
}
@@ -48,9 +52,21 @@ const { data, info } = await sharp(input)
.raw()
.toBuffer({ resolveWithObject: true });
const { width: srcW, height: srcH } = info;
const { width: rawW, height: rawH } = info;
// Optional resize — defaults to source dims. Aspect-preserve when only one of W/H given.
const rotation = parseInt(values.rotate, 10);
if (![0, 90, 180, 270].includes(rotation)) {
console.error('--rotate must be 0, 90, 180, or 270');
process.exit(1);
}
const transformed = (values['flip-h'] || values['flip-v'] || rotation !== 0)
? transformRgba(data, rawW, rawH, { flipH: values['flip-h'], flipV: values['flip-v'], rotation })
: { rgba: data, width: rawW, height: rawH };
const srcW = transformed.width;
const srcH = transformed.height;
// Optional resize — defaults to post-transform 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);
@@ -64,8 +80,8 @@ if (outW > CANVAS_WIDTH || outH > CANVAS_HEIGHT) {
}
const working = (outW === srcW && outH === srcH)
? data
: resizeRgba(data, srcW, srcH, outW, outH, values.method);
? transformed.rgba
: resizeRgba(transformed.rgba, srcW, srcH, outW, outH, values.method);
// sharp raw buffer has channels=4 after ensureAlpha; same layout as Canvas ImageData.
const indices = rgbaToPalette(working, outW, outH, { alphaThreshold, dither: values.dither });
@@ -73,5 +89,8 @@ const pixels = Array.from(indices);
const opaque = pixels.reduce((n, p) => n + (p >= 0 ? 1 : 0), 0);
await writeFile(output, JSON.stringify({ width: outW, height: outH, pixels }));
const transformNote = (values['flip-h'] || values['flip-v'] || rotation !== 0)
? ` [transform: ${[values['flip-h'] && 'flipH', values['flip-v'] && 'flipV', rotation !== 0 && `rot${rotation}`].filter(Boolean).join('+')}]`
: '';
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]' : ''}`);
console.log(`Wrote ${output}: ${outW}x${outH}${resizeNote}${transformNote}, ${opaque}/${pixels.length} opaque pixels${values.dither ? ' [dithered]' : ''}`);
+56 -8
View File
@@ -2,6 +2,7 @@
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 { transformRgba } from '../../lib/image-transform.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props();
@@ -35,6 +36,11 @@
let showOverlay = $state(true);
let overlayAlpha = $state(0.6);
// Transforms
let flipH = $state(false);
let flipV = $state(false);
let rotation = $state(0); // 0 | 90 | 180 | 270
// Run state
let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error'
let placed = $state(0);
@@ -79,15 +85,19 @@
}
}
// Re-run pipeline when source / resize / dither / method changes.
// Re-run pipeline when any relevant input changes.
// Order: transform → resize → palette.
$effect(() => {
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 });
const transformed = (flipH || flipV || rotation !== 0)
? transformRgba(srcRgba, srcWidth, srcHeight, { flipH, flipV, rotation })
: { rgba: srcRgba, width: srcWidth, height: srcHeight };
const resized = (resizeW === transformed.width && resizeH === transformed.height)
? transformed.rgba
: resizeRgba(transformed.rgba, transformed.width, transformed.height, resizeW, resizeH, resampleMethod);
const idx = rgbaToPalette(resized, resizeW, resizeH, { dither });
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
@@ -129,8 +139,25 @@
resizeH = Math.max(1, Math.floor(srcHeight * ratio));
}
function resetSize() {
resizeW = srcWidth;
resizeH = srcHeight;
// Reset to post-transform source dims so resize stays consistent with the preview.
const swap = rotation === 90 || rotation === 270;
resizeW = swap ? srcHeight : srcWidth;
resizeH = swap ? srcWidth : srcHeight;
}
function rotateBy(deltaCW) {
rotation = ((rotation + deltaCW) % 360 + 360) % 360;
// Swap resize dims so the output aspect tracks the rotation.
const tmp = resizeW; resizeW = resizeH; resizeH = tmp;
}
function resetTransforms() {
flipH = false;
flipV = false;
if (rotation !== 0) {
const swap = rotation === 90 || rotation === 270;
if (swap) { const tmp = resizeW; resizeW = resizeH; resizeH = tmp; }
rotation = 0;
}
}
$effect(() => { if (open && paletteIndices) renderPreview(); });
@@ -282,7 +309,17 @@
</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>
<button onclick={resetSize} title="Reset to post-transform source dimensions">1:1</button>
</div>
<div class="row">
<span class="lbl">transform</span>
<button onclick={() => flipH = !flipH} class:active={flipH} title="Flip horizontally">⇆</button>
<button onclick={() => flipV = !flipV} class:active={flipV} title="Flip vertically">⇅</button>
<button onclick={() => rotateBy(-90)} title="Rotate 90° counter-clockwise">⟲</button>
<button onclick={() => rotateBy(90)} title="Rotate 90° clockwise">⟳</button>
<span class="rot-label">{rotation}°</span>
<button onclick={resetTransforms} title="Reset transforms" disabled={!flipH && !flipV && rotation === 0}>reset</button>
</div>
<div class="row">
@@ -376,6 +413,17 @@
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; }
.lbl { font-size: 0.8rem; color: #888; min-width: 56px; }
.rot-label { font-size: 0.8rem; color: #aaa; min-width: 32px; text-align: center; }
.row button {
padding: 4px 10px; background: #262626; color: #ddd;
border: 1px solid #444; border-radius: 4px; cursor: pointer;
font-size: 0.85rem;
}
.row button:hover:not(:disabled) { background: #333; }
.row button:disabled { opacity: 0.35; cursor: default; }
.row button.active { background: #2d4d78; border-color: #3b6ba8; color: #fff; }
.file-btn {
display: inline-block; padding: 6px 12px; background: #2563eb; border-radius: 6px;
+88
View File
@@ -0,0 +1,88 @@
/**
* Pure RGBA transforms: flip horizontal/vertical and rotate in 90° steps.
* All functions return a fresh Uint8ClampedArray; rotations by 90/270 swap dims.
*
* Composition order in `transformRgba`: rotation → flipH → flipV.
* (Order is fixed so state is well-defined regardless of UI click sequence.)
*/
/**
* @param {Uint8Array|Uint8ClampedArray|number[]} rgba
* @param {number} width
* @param {number} height
* @param {{flipH?: boolean, flipV?: boolean, rotation?: 0|90|180|270}} [opts]
* @returns {{rgba: Uint8ClampedArray, width: number, height: number}}
*/
export function transformRgba(rgba, width, height, opts = {}) {
const { flipH = false, flipV = false, rotation = 0 } = opts;
if (rotation !== 0 && rotation !== 90 && rotation !== 180 && rotation !== 270) {
throw new Error(`Unsupported rotation: ${rotation}. Use 0, 90, 180, or 270.`);
}
let cur = rgba, w = width, h = height;
let owned = false; // true once we produced a fresh buffer and no longer alias the input
if (rotation !== 0) {
const r = rotate(cur, w, h, rotation);
cur = r.rgba; w = r.width; h = r.height; owned = true;
}
if (flipH) { cur = flipHorizontal(cur, w, h); owned = true; }
if (flipV) { cur = flipVertical(cur, w, h); owned = true; }
if (!owned) cur = new Uint8ClampedArray(cur); // identity: still return a fresh buffer
return { rgba: cur, width: w, height: h };
}
function rotate(src, w, h, degrees) {
if (degrees === 180) {
const out = new Uint8ClampedArray(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const si = (y * w + x) * 4;
const di = ((h - 1 - y) * w + (w - 1 - 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 { rgba: out, width: w, height: h };
}
// 90 CW or 270 CW — dst dims are h × w.
const dstW = h, dstH = w;
const out = new Uint8ClampedArray(dstW * dstH * 4);
for (let ny = 0; ny < dstH; ny++) {
for (let nx = 0; nx < dstW; nx++) {
const ox = degrees === 90 ? ny : (dstH - 1 - ny);
const oy = degrees === 90 ? (h - 1 - nx) : nx;
const si = (oy * w + ox) * 4;
const di = (ny * dstW + nx) * 4;
out[di] = src[si]; out[di + 1] = src[si + 1];
out[di + 2] = src[si + 2]; out[di + 3] = src[si + 3];
}
}
return { rgba: out, width: dstW, height: dstH };
}
function flipHorizontal(src, w, h) {
const out = new Uint8ClampedArray(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const si = (y * w + x) * 4;
const di = (y * w + (w - 1 - 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 flipVertical(src, w, h) {
const out = new Uint8ClampedArray(src.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const si = (y * w + x) * 4;
const di = ((h - 1 - y) * w + 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;
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { transformRgba } from '../../src/lib/image-transform.js';
/** Build an RGBA buffer where pixel (x, y) has red = (y*w+x+1)*10, green = y, blue = x.
* Unique per-pixel so we can assert exact placement after transforms. */
function build(w, h) {
const out = new Uint8ClampedArray(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
out[i] = (y * w + x + 1) * 10;
out[i + 1] = y;
out[i + 2] = x;
out[i + 3] = 255;
}
}
return out;
}
function pixel(rgba, w, x, y) {
const i = (y * w + x) * 4;
return [rgba[i], rgba[i + 1], rgba[i + 2], rgba[i + 3]];
}
describe('transformRgba', () => {
it('identity (no flips, rotation=0) returns input copy', () => {
const src = build(2, 3);
const { rgba, width, height } = transformRgba(src, 2, 3);
expect(width).toBe(2);
expect(height).toBe(3);
expect(rgba).toEqual(src);
expect(rgba).not.toBe(src);
});
it('flipH mirrors columns', () => {
const src = build(3, 2);
const { rgba, width, height } = transformRgba(src, 3, 2, { flipH: true });
expect(width).toBe(3);
expect(height).toBe(2);
// Row 0: src cols 0,1,2 → dst cols 2,1,0
expect(pixel(rgba, 3, 0, 0)).toEqual(pixel(src, 3, 2, 0));
expect(pixel(rgba, 3, 2, 0)).toEqual(pixel(src, 3, 0, 0));
});
it('flipV mirrors rows', () => {
const src = build(2, 3);
const { rgba } = transformRgba(src, 2, 3, { flipV: true });
expect(pixel(rgba, 2, 0, 0)).toEqual(pixel(src, 2, 0, 2));
expect(pixel(rgba, 2, 0, 2)).toEqual(pixel(src, 2, 0, 0));
});
it('rotate 90° CW swaps dims and maps top-left → top-right', () => {
const src = build(2, 3); // 2 wide, 3 tall
const { rgba, width, height } = transformRgba(src, 2, 3, { rotation: 90 });
expect(width).toBe(3); // h
expect(height).toBe(2); // w
// src (0,0) is top-left → dst top-right, i.e. (width-1, 0) = (2, 0)
expect(pixel(rgba, 3, 2, 0)).toEqual(pixel(src, 2, 0, 0));
// src (1, 0) top-right → dst bottom-right (2, 1)
expect(pixel(rgba, 3, 2, 1)).toEqual(pixel(src, 2, 1, 0));
// src (0, 2) bottom-left → dst top-left (0, 0)
expect(pixel(rgba, 3, 0, 0)).toEqual(pixel(src, 2, 0, 2));
});
it('rotate 180° flips both axes', () => {
const src = build(2, 3);
const { rgba, width, height } = transformRgba(src, 2, 3, { rotation: 180 });
expect(width).toBe(2);
expect(height).toBe(3);
expect(pixel(rgba, 2, 0, 0)).toEqual(pixel(src, 2, 1, 2));
expect(pixel(rgba, 2, 1, 2)).toEqual(pixel(src, 2, 0, 0));
});
it('rotate 270° CW is inverse of 90° CW', () => {
const src = build(2, 3);
const r90 = transformRgba(src, 2, 3, { rotation: 90 });
const back = transformRgba(r90.rgba, r90.width, r90.height, { rotation: 270 });
expect(back.width).toBe(2);
expect(back.height).toBe(3);
expect(back.rgba).toEqual(new Uint8ClampedArray(src));
});
it('flipH + flipV equals 180° rotation (for square or rectangular)', () => {
const src = build(3, 2);
const flipped = transformRgba(src, 3, 2, { flipH: true, flipV: true });
const rotated = transformRgba(src, 3, 2, { rotation: 180 });
expect(flipped.rgba).toEqual(rotated.rgba);
expect(flipped.width).toBe(rotated.width);
expect(flipped.height).toBe(rotated.height);
});
it('throws on unsupported rotation', () => {
const src = build(2, 2);
expect(() => transformRgba(src, 2, 2, { rotation: 45 })).toThrow();
});
});