image-to-pixel 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +2 -2
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +60 -60
- package/dist/editor.esm.js.map +4 -4
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- package/dist/image-to-pixel.bundle.esm.js +16 -16
- package/dist/image-to-pixel.bundle.esm.js.map +3 -3
- package/dist/image-to-pixel.cjs +2 -2
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +2 -2
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +2 -2
- package/dist/image-to-pixel.js.map +2 -2
- package/dist/image-to-pixel.min.js +11 -11
- package/dist/image-to-pixel.min.js.map +3 -3
- package/dist/video.esm.js +2 -2
- package/dist/video.esm.js.map +4 -4
- package/lib/core/color.js +69 -69
- package/lib/core/cpu/pipeline.js +21 -21
- package/lib/core/cpu/stages.js +227 -227
- package/lib/core/dither/bluenoise-data.js +3 -3
- package/lib/core/dither/kernels.js +27 -27
- package/lib/core/dither/matrices.js +95 -95
- package/lib/core/engine.js +158 -158
- package/lib/core/export/canvas.js +22 -22
- package/lib/core/export/crc32.js +15 -15
- package/lib/core/export/gif.js +153 -153
- package/lib/core/export/png.js +94 -94
- package/lib/core/export/scale.js +103 -103
- package/lib/core/export/sheet.js +95 -95
- package/lib/core/export/zip.js +55 -55
- package/lib/core/gpu/backend.js +159 -159
- package/lib/core/gpu/shaders.js +258 -258
- package/lib/core/palette/builtin.js +36 -36
- package/lib/core/palette/extract.js +69 -69
- package/lib/core/palette/lospec.js +24 -24
- package/lib/core/palette/parse.js +112 -112
- package/lib/core/plan.js +58 -58
- package/lib/core/presets.js +42 -42
- package/lib/core/settings.js +274 -274
- package/lib/core/source.js +89 -89
- package/lib/core/video/audio.js +159 -0
- package/lib/core/video/frames.js +38 -0
- package/lib/core/video/mux-mp4.js +213 -24
- package/lib/core/video/mux-webm.js +63 -22
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +43 -3
- package/lib/editor/icons.js +4 -0
- package/lib/editor/index.js +218 -9
- package/lib/editor/inspector.js +19 -4
- package/lib/editor/library.js +8 -2
- package/lib/editor/persist.js +18 -18
- package/lib/editor/samples.js +90 -90
- package/lib/editor/stage.js +239 -239
- package/lib/editor/store.js +80 -80
- package/lib/editor/styles/editor.css +34 -1
- package/lib/editor/styles/fonts/README.md +6 -6
- package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
- package/lib/editor/styles/tokens.css +89 -89
- package/lib/editor/ui.js +97 -97
- package/lib/editor/workspace.js +62 -0
- package/lib/index.js +31 -29
- package/lib/video.js +37 -7
- package/package.json +1 -1
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +61 -5
package/lib/core/color.js
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
// Colour helpers shared by the CPU path, the GPU uniforms and the editor.
|
|
2
|
-
// All channel values are sRGB in 0..1 unless a name says otherwise.
|
|
3
|
-
|
|
4
|
-
/** Parse '#rgb', '#rrggbb', '#rrggbbaa', 'rrggbb' or [r,g,b] (0..255) into [r, g, b] bytes. */
|
|
5
|
-
export function parseColor(c) {
|
|
6
|
-
if (Array.isArray(c)) return [c[0] | 0, c[1] | 0, c[2] | 0];
|
|
7
|
-
let s = String(c).trim().replace(/^#|^0x/i, '');
|
|
8
|
-
if (s.length === 3 || s.length === 4) s = s.slice(0, 3).split('').map(ch => ch + ch).join('');
|
|
9
|
-
if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(s)) throw new Error(`Invalid colour "${c}"`);
|
|
10
|
-
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** True when `c` parses as a colour. */
|
|
14
|
-
export function isColor(c) {
|
|
15
|
-
try { parseColor(c); return true; } catch { return false; }
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** [r, g, b] bytes → '#rrggbb'. */
|
|
19
|
-
export function toHex(rgb) {
|
|
20
|
-
return '#' + rgb.slice(0, 3).map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Normalise any colour to '#rrggbb' (lower case). */
|
|
24
|
-
export const normHex = c => toHex(parseColor(c));
|
|
25
|
-
|
|
26
|
-
const lin = c => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
27
|
-
|
|
28
|
-
/** sRGB (0..1) → OKLab [L, a, b]. Same maths as the GLSL `oklab()` in gpu/shaders.js. */
|
|
29
|
-
export function oklab(r, g, b) {
|
|
30
|
-
r = lin(r); g = lin(g); b = lin(b);
|
|
31
|
-
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
32
|
-
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
33
|
-
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
34
|
-
return [
|
|
35
|
-
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
|
36
|
-
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
|
37
|
-
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
|
38
|
-
];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Relative luminance-ish weight used by saturation (Rec. 709). */
|
|
42
|
-
export const LUMA = [0.2126, 0.7152, 0.0722];
|
|
43
|
-
|
|
44
|
-
/** Perceived lightness 0..1 of a hex colour; handy for picking text colours on swatches. */
|
|
45
|
-
export function lightness(hex) {
|
|
46
|
-
const [r, g, b] = parseColor(hex);
|
|
47
|
-
return oklab(r / 255, g / 255, b / 255)[0];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Average distance (sRGB 0..1) from each palette colour to its nearest neighbour.
|
|
52
|
-
* Ordered dithering scales its threshold by this so `strength` means the same thing
|
|
53
|
-
* for a 2-colour and a 64-colour palette.
|
|
54
|
-
*/
|
|
55
|
-
export function paletteSpread(rgbs) {
|
|
56
|
-
if (rgbs.length < 2) return 1;
|
|
57
|
-
let sum = 0;
|
|
58
|
-
for (let i = 0; i < rgbs.length; i++) {
|
|
59
|
-
let best = Infinity;
|
|
60
|
-
for (let j = 0; j < rgbs.length; j++) {
|
|
61
|
-
if (i === j) continue;
|
|
62
|
-
const dr = rgbs[i][0] - rgbs[j][0], dg = rgbs[i][1] - rgbs[j][1], db = rgbs[i][2] - rgbs[j][2];
|
|
63
|
-
const d = Math.sqrt(dr * dr + dg * dg + db * db);
|
|
64
|
-
if (d < best) best = d;
|
|
65
|
-
}
|
|
66
|
-
sum += best;
|
|
67
|
-
}
|
|
68
|
-
return Math.max(0.04, Math.min(1, sum / rgbs.length / 255));
|
|
69
|
-
}
|
|
1
|
+
// Colour helpers shared by the CPU path, the GPU uniforms and the editor.
|
|
2
|
+
// All channel values are sRGB in 0..1 unless a name says otherwise.
|
|
3
|
+
|
|
4
|
+
/** Parse '#rgb', '#rrggbb', '#rrggbbaa', 'rrggbb' or [r,g,b] (0..255) into [r, g, b] bytes. */
|
|
5
|
+
export function parseColor(c) {
|
|
6
|
+
if (Array.isArray(c)) return [c[0] | 0, c[1] | 0, c[2] | 0];
|
|
7
|
+
let s = String(c).trim().replace(/^#|^0x/i, '');
|
|
8
|
+
if (s.length === 3 || s.length === 4) s = s.slice(0, 3).split('').map(ch => ch + ch).join('');
|
|
9
|
+
if (!/^[0-9a-f]{6}([0-9a-f]{2})?$/i.test(s)) throw new Error(`Invalid colour "${c}"`);
|
|
10
|
+
return [parseInt(s.slice(0, 2), 16), parseInt(s.slice(2, 4), 16), parseInt(s.slice(4, 6), 16)];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** True when `c` parses as a colour. */
|
|
14
|
+
export function isColor(c) {
|
|
15
|
+
try { parseColor(c); return true; } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** [r, g, b] bytes → '#rrggbb'. */
|
|
19
|
+
export function toHex(rgb) {
|
|
20
|
+
return '#' + rgb.slice(0, 3).map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Normalise any colour to '#rrggbb' (lower case). */
|
|
24
|
+
export const normHex = c => toHex(parseColor(c));
|
|
25
|
+
|
|
26
|
+
const lin = c => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
|
27
|
+
|
|
28
|
+
/** sRGB (0..1) → OKLab [L, a, b]. Same maths as the GLSL `oklab()` in gpu/shaders.js. */
|
|
29
|
+
export function oklab(r, g, b) {
|
|
30
|
+
r = lin(r); g = lin(g); b = lin(b);
|
|
31
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
32
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
33
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
34
|
+
return [
|
|
35
|
+
0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
|
|
36
|
+
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
|
|
37
|
+
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Relative luminance-ish weight used by saturation (Rec. 709). */
|
|
42
|
+
export const LUMA = [0.2126, 0.7152, 0.0722];
|
|
43
|
+
|
|
44
|
+
/** Perceived lightness 0..1 of a hex colour; handy for picking text colours on swatches. */
|
|
45
|
+
export function lightness(hex) {
|
|
46
|
+
const [r, g, b] = parseColor(hex);
|
|
47
|
+
return oklab(r / 255, g / 255, b / 255)[0];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Average distance (sRGB 0..1) from each palette colour to its nearest neighbour.
|
|
52
|
+
* Ordered dithering scales its threshold by this so `strength` means the same thing
|
|
53
|
+
* for a 2-colour and a 64-colour palette.
|
|
54
|
+
*/
|
|
55
|
+
export function paletteSpread(rgbs) {
|
|
56
|
+
if (rgbs.length < 2) return 1;
|
|
57
|
+
let sum = 0;
|
|
58
|
+
for (let i = 0; i < rgbs.length; i++) {
|
|
59
|
+
let best = Infinity;
|
|
60
|
+
for (let j = 0; j < rgbs.length; j++) {
|
|
61
|
+
if (i === j) continue;
|
|
62
|
+
const dr = rgbs[i][0] - rgbs[j][0], dg = rgbs[i][1] - rgbs[j][1], db = rgbs[i][2] - rgbs[j][2];
|
|
63
|
+
const d = Math.sqrt(dr * dr + dg * dg + db * db);
|
|
64
|
+
if (d < best) best = d;
|
|
65
|
+
}
|
|
66
|
+
sum += best;
|
|
67
|
+
}
|
|
68
|
+
return Math.max(0.04, Math.min(1, sum / rgbs.length / 255));
|
|
69
|
+
}
|
package/lib/core/cpu/pipeline.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
// The whole pipeline on the CPU. Used when WebGL 2 is unavailable, in Node, and as the
|
|
2
|
-
// reference the GPU output is tested against.
|
|
3
|
-
|
|
4
|
-
import { resample, adjust, alphaCut, quantizeOrdered, quantizeDiffusion, removeOrphans, smoothMajority, outline } from './stages.js';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @param {ReturnType<import('../plan.js').makePlan>} plan
|
|
8
|
-
* @param {{ width: number, height: number, data: ArrayLike<number> }} src RGBA bytes, rows top-down
|
|
9
|
-
* @returns {Uint8ClampedArray} RGBA bytes, plan.width × plan.height
|
|
10
|
-
*/
|
|
11
|
-
export function runCPU(plan, src) {
|
|
12
|
-
const { width: w, height: h, settings: s } = plan;
|
|
13
|
-
const buf = resample(src, w, h, s.size.resample, plan.crop);
|
|
14
|
-
adjust(buf, s.adjust);
|
|
15
|
-
alphaCut(buf, s.alpha.threshold);
|
|
16
|
-
const px = plan.kind === 'diffusion' ? quantizeDiffusion(buf, w, h, plan) : quantizeOrdered(buf, w, h, plan);
|
|
17
|
-
if (s.cleanup.orphans) removeOrphans(px, w, h);
|
|
18
|
-
if (s.cleanup.smooth) smoothMajority(px, w, h);
|
|
19
|
-
outline(px, w, h, plan.outline);
|
|
20
|
-
return px;
|
|
21
|
-
}
|
|
1
|
+
// The whole pipeline on the CPU. Used when WebGL 2 is unavailable, in Node, and as the
|
|
2
|
+
// reference the GPU output is tested against.
|
|
3
|
+
|
|
4
|
+
import { resample, adjust, alphaCut, quantizeOrdered, quantizeDiffusion, removeOrphans, smoothMajority, outline } from './stages.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {ReturnType<import('../plan.js').makePlan>} plan
|
|
8
|
+
* @param {{ width: number, height: number, data: ArrayLike<number> }} src RGBA bytes, rows top-down
|
|
9
|
+
* @returns {Uint8ClampedArray} RGBA bytes, plan.width × plan.height
|
|
10
|
+
*/
|
|
11
|
+
export function runCPU(plan, src) {
|
|
12
|
+
const { width: w, height: h, settings: s } = plan;
|
|
13
|
+
const buf = resample(src, w, h, s.size.resample, plan.crop);
|
|
14
|
+
adjust(buf, s.adjust);
|
|
15
|
+
alphaCut(buf, s.alpha.threshold);
|
|
16
|
+
const px = plan.kind === 'diffusion' ? quantizeDiffusion(buf, w, h, plan) : quantizeOrdered(buf, w, h, plan);
|
|
17
|
+
if (s.cleanup.orphans) removeOrphans(px, w, h);
|
|
18
|
+
if (s.cleanup.smooth) smoothMajority(px, w, h);
|
|
19
|
+
outline(px, w, h, plan.outline);
|
|
20
|
+
return px;
|
|
21
|
+
}
|
package/lib/core/cpu/stages.js
CHANGED
|
@@ -1,227 +1,227 @@
|
|
|
1
|
-
// CPU reference implementation of every pipeline stage.
|
|
2
|
-
// The GLSL in gpu/shaders.js mirrors these functions line for line; tests compare the two.
|
|
3
|
-
// Working buffers are Float32Array RGBA, rows top-down, values 0..1.
|
|
4
|
-
|
|
5
|
-
import { oklab, LUMA } from '../color.js';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Resample to w × h. 'box' averages up to 16×16 evenly spaced source texels per output pixel
|
|
9
|
-
* (alpha-weighted); 'nearest' takes the centre texel. Integer maths, identical on the GPU.
|
|
10
|
-
*/
|
|
11
|
-
export function resample(src, w, h, mode = 'box', crop = null) {
|
|
12
|
-
const ox = crop ? crop.x : 0, oy = crop ? crop.y : 0;
|
|
13
|
-
const sw = crop ? crop.w : src.width, sh = crop ? crop.h : src.height, d = src.data, stride = src.width;
|
|
14
|
-
const out = new Float32Array(w * h * 4);
|
|
15
|
-
const nx = mode === 'nearest' ? 1 : Math.min(16, Math.max(1, Math.ceil(sw / w)));
|
|
16
|
-
const ny = mode === 'nearest' ? 1 : Math.min(16, Math.max(1, Math.ceil(sh / h)));
|
|
17
|
-
const xs = new Int32Array(w * nx), ys = new Int32Array(h * ny);
|
|
18
|
-
for (let x = 0; x < w; x++) for (let i = 0; i < nx; i++) xs[x * nx + i] = ox + Math.min(sw - 1, Math.floor(((2 * (x * nx + i) + 1) * sw) / (2 * w * nx)));
|
|
19
|
-
for (let y = 0; y < h; y++) for (let j = 0; j < ny; j++) ys[y * ny + j] = oy + Math.min(sh - 1, Math.floor(((2 * (y * ny + j) + 1) * sh) / (2 * h * ny)));
|
|
20
|
-
for (let y = 0; y < h; y++) {
|
|
21
|
-
for (let x = 0; x < w; x++) {
|
|
22
|
-
let r = 0, g = 0, b = 0, a = 0;
|
|
23
|
-
for (let j = 0; j < ny; j++) {
|
|
24
|
-
const row = ys[y * ny + j] * stride;
|
|
25
|
-
for (let i = 0; i < nx; i++) {
|
|
26
|
-
const o = (row + xs[x * nx + i]) * 4;
|
|
27
|
-
const al = d[o + 3] / 255;
|
|
28
|
-
r += d[o] / 255 * al; g += d[o + 1] / 255 * al; b += d[o + 2] / 255 * al; a += al;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
const o = (y * w + x) * 4;
|
|
32
|
-
if (a > 0) { out[o] = r / a; out[o + 1] = g / a; out[o + 2] = b / a; }
|
|
33
|
-
out[o + 3] = a / (nx * ny);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Brightness, contrast, saturation, hue, gamma — in that order, in place. */
|
|
40
|
-
export function adjust(buf, { brightness = 0, contrast = 0, saturation = 0, hue = 0, gamma = 1 }) {
|
|
41
|
-
if (!brightness && !contrast && !saturation && !hue && gamma === 1) return buf;
|
|
42
|
-
const f = contrast >= 0 ? 1 / (1 - 0.99 * contrast) : 1 + contrast;
|
|
43
|
-
const ig = 1 / gamma;
|
|
44
|
-
// luminance-preserving hue rotation: the CSS hue-rotate() matrix (rows sum to 1, so greys stay grey)
|
|
45
|
-
const hk = Math.cos(hue * Math.PI / 180), hq = Math.sin(hue * Math.PI / 180);
|
|
46
|
-
const M = [
|
|
47
|
-
0.213 + 0.787 * hk - 0.213 * hq, 0.715 - 0.715 * hk - 0.715 * hq, 0.072 - 0.072 * hk + 0.928 * hq,
|
|
48
|
-
0.213 - 0.213 * hk + 0.143 * hq, 0.715 + 0.285 * hk + 0.140 * hq, 0.072 - 0.072 * hk - 0.283 * hq,
|
|
49
|
-
0.213 - 0.213 * hk - 0.787 * hq, 0.715 - 0.715 * hk + 0.715 * hq, 0.072 + 0.928 * hk + 0.072 * hq,
|
|
50
|
-
];
|
|
51
|
-
for (let o = 0; o < buf.length; o += 4) {
|
|
52
|
-
let r = buf[o] + brightness, g = buf[o + 1] + brightness, b = buf[o + 2] + brightness;
|
|
53
|
-
r = (r - 0.5) * f + 0.5; g = (g - 0.5) * f + 0.5; b = (b - 0.5) * f + 0.5;
|
|
54
|
-
const l = r * LUMA[0] + g * LUMA[1] + b * LUMA[2];
|
|
55
|
-
const k = 1 + saturation;
|
|
56
|
-
r = l + (r - l) * k; g = l + (g - l) * k; b = l + (b - l) * k;
|
|
57
|
-
if (hue) {
|
|
58
|
-
const r2 = M[0] * r + M[1] * g + M[2] * b, g2 = M[3] * r + M[4] * g + M[5] * b, b2 = M[6] * r + M[7] * g + M[8] * b;
|
|
59
|
-
r = r2; g = g2; b = b2;
|
|
60
|
-
}
|
|
61
|
-
buf[o] = Math.pow(clamp01(r), ig); buf[o + 1] = Math.pow(clamp01(g), ig); buf[o + 2] = Math.pow(clamp01(b), ig);
|
|
62
|
-
}
|
|
63
|
-
return buf;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Binary alpha: below the threshold becomes fully transparent, the rest fully opaque. */
|
|
67
|
-
export function alphaCut(buf, threshold) {
|
|
68
|
-
for (let o = 3; o < buf.length; o += 4) buf[o] = buf[o] >= threshold && buf[o] > 0 ? 1 : 0;
|
|
69
|
-
return buf;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
73
|
-
|
|
74
|
-
/** Precompute what nearest() needs for a palette. */
|
|
75
|
-
export function paletteSpace(palette, distance) {
|
|
76
|
-
const n = palette.length;
|
|
77
|
-
const coords = new Float64Array(n * 3);
|
|
78
|
-
const rgb = new Float64Array(n * 3);
|
|
79
|
-
palette.forEach((c, i) => {
|
|
80
|
-
rgb.set([c[0] / 255, c[1] / 255, c[2] / 255], i * 3);
|
|
81
|
-
coords.set(distance === 1 ? [c[0] / 255, c[1] / 255, c[2] / 255] : oklab(c[0] / 255, c[1] / 255, c[2] / 255), i * 3);
|
|
82
|
-
});
|
|
83
|
-
return { n, coords, rgb, distance };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const W_RGB = [0.299, 0.587, 0.114];
|
|
87
|
-
|
|
88
|
-
/** Index of the nearest palette colour (ties: lowest index). */
|
|
89
|
-
export function nearest(sp, r, g, b) {
|
|
90
|
-
let p0, p1, p2, w0 = 1, w1 = 1, w2 = 1;
|
|
91
|
-
if (sp.distance === 1) { p0 = r; p1 = g; p2 = b; w0 = W_RGB[0]; w1 = W_RGB[1]; w2 = W_RGB[2]; }
|
|
92
|
-
else [p0, p1, p2] = oklab(r, g, b);
|
|
93
|
-
let best = 0, bd = Infinity;
|
|
94
|
-
const c = sp.coords;
|
|
95
|
-
for (let i = 0; i < sp.n; i++) {
|
|
96
|
-
const d0 = p0 - c[i * 3], d1 = p1 - c[i * 3 + 1], d2 = p2 - c[i * 3 + 2];
|
|
97
|
-
const dist = w0 * d0 * d0 + w1 * d1 * d1 + w2 * d2 * d2;
|
|
98
|
-
if (dist < bd) { bd = dist; best = i; }
|
|
99
|
-
}
|
|
100
|
-
return best;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** No dithering / ordered dithering → RGBA bytes. */
|
|
104
|
-
export function quantizeOrdered(buf, w, h, plan) {
|
|
105
|
-
const out = new Uint8ClampedArray(w * h * 4);
|
|
106
|
-
const sp = plan.quantize ? paletteSpace(plan.palette, plan.distance) : null;
|
|
107
|
-
const map = plan.kind === 'ordered' ? plan.map : null;
|
|
108
|
-
const amt = map ? plan.spread * plan.settings.dither.strength : 0;
|
|
109
|
-
for (let y = 0; y < h; y++) {
|
|
110
|
-
for (let x = 0; x < w; x++) {
|
|
111
|
-
const o = (y * w + x) * 4;
|
|
112
|
-
if (buf[o + 3] < 0.5) continue; // transparent stays 0,0,0,0
|
|
113
|
-
let r = buf[o], g = buf[o + 1], b = buf[o + 2];
|
|
114
|
-
if (sp) {
|
|
115
|
-
if (map) {
|
|
116
|
-
const t = (map.values[(y % map.h) * map.w + (x % map.w)] + 0.5) / map.levels - 0.5;
|
|
117
|
-
r = clamp01(r + t * amt); g = clamp01(g + t * amt); b = clamp01(b + t * amt);
|
|
118
|
-
}
|
|
119
|
-
const i = nearest(sp, r, g, b);
|
|
120
|
-
out[o] = plan.palette[i][0]; out[o + 1] = plan.palette[i][1]; out[o + 2] = plan.palette[i][2];
|
|
121
|
-
} else {
|
|
122
|
-
out[o] = Math.round(r * 255); out[o + 1] = Math.round(g * 255); out[o + 2] = Math.round(b * 255);
|
|
123
|
-
}
|
|
124
|
-
out[o + 3] = 255;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return out;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Error diffusion (left → right, top → bottom) → RGBA bytes. */
|
|
131
|
-
export function quantizeDiffusion(buf, w, h, plan) {
|
|
132
|
-
const out = new Uint8ClampedArray(w * h * 4);
|
|
133
|
-
const sp = paletteSpace(plan.palette, plan.distance);
|
|
134
|
-
const acc = new Float32Array(w * h * 3);
|
|
135
|
-
const strength = plan.settings.dither.strength;
|
|
136
|
-
const kernel = plan.kernel;
|
|
137
|
-
for (let y = 0; y < h; y++) {
|
|
138
|
-
for (let x = 0; x < w; x++) {
|
|
139
|
-
const p = y * w + x, o = p * 4;
|
|
140
|
-
if (buf[o + 3] < 0.5) continue;
|
|
141
|
-
const r = clamp01(buf[o] + acc[p * 3] * strength);
|
|
142
|
-
const g = clamp01(buf[o + 1] + acc[p * 3 + 1] * strength);
|
|
143
|
-
const b = clamp01(buf[o + 2] + acc[p * 3 + 2] * strength);
|
|
144
|
-
const i = nearest(sp, r, g, b);
|
|
145
|
-
const er = r - sp.rgb[i * 3], eg = g - sp.rgb[i * 3 + 1], eb = b - sp.rgb[i * 3 + 2];
|
|
146
|
-
for (const [dx, dy, wt] of kernel) {
|
|
147
|
-
const xx = x + dx, yy = y + dy;
|
|
148
|
-
if (xx < 0 || xx >= w || yy >= h) continue;
|
|
149
|
-
const q = (yy * w + xx) * 3;
|
|
150
|
-
acc[q] += er * wt; acc[q + 1] += eg * wt; acc[q + 2] += eb * wt;
|
|
151
|
-
}
|
|
152
|
-
out[o] = plan.palette[i][0]; out[o + 1] = plan.palette[i][1]; out[o + 2] = plan.palette[i][2]; out[o + 3] = 255;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return out;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const key = (d, o) => (d[o + 3] === 0 ? -1 : (d[o] << 16) | (d[o + 1] << 8) | d[o + 2]);
|
|
159
|
-
const N4 = [[0, -1], [1, 0], [0, 1], [-1, 0]];
|
|
160
|
-
|
|
161
|
-
/** Replace pixels that share no colour with any 4-neighbour by their most common neighbour. */
|
|
162
|
-
export function removeOrphans(px, w, h) {
|
|
163
|
-
const src = px.slice();
|
|
164
|
-
for (let y = 0; y < h; y++) {
|
|
165
|
-
for (let x = 0; x < w; x++) {
|
|
166
|
-
const o = (y * w + x) * 4, c = key(src, o);
|
|
167
|
-
const ks = [], os = [];
|
|
168
|
-
for (const [dx, dy] of N4) {
|
|
169
|
-
const xx = x + dx, yy = y + dy;
|
|
170
|
-
if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
|
|
171
|
-
const oo = (yy * w + xx) * 4;
|
|
172
|
-
ks.push(key(src, oo)); os.push(oo);
|
|
173
|
-
}
|
|
174
|
-
if (ks.length < 2 || ks.includes(c)) continue;
|
|
175
|
-
let best = 0, bc = 0;
|
|
176
|
-
for (let i = 0; i < ks.length; i++) {
|
|
177
|
-
let n = 0; for (let j = 0; j < ks.length; j++) if (ks[j] === ks[i]) n++;
|
|
178
|
-
if (n > bc) { bc = n; best = i; }
|
|
179
|
-
}
|
|
180
|
-
px.set(src.subarray(os[best], os[best] + 4), o);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return px;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** 3×3 majority: a colour that fills at least 5 of the 9 cells wins. */
|
|
187
|
-
export function smoothMajority(px, w, h) {
|
|
188
|
-
const src = px.slice();
|
|
189
|
-
for (let y = 0; y < h; y++) {
|
|
190
|
-
for (let x = 0; x < w; x++) {
|
|
191
|
-
const o = (y * w + x) * 4, c = key(src, o);
|
|
192
|
-
const ks = [], os = [];
|
|
193
|
-
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
194
|
-
const xx = Math.min(w - 1, Math.max(0, x + dx)), yy = Math.min(h - 1, Math.max(0, y + dy));
|
|
195
|
-
const oo = (yy * w + xx) * 4; ks.push(key(src, oo)); os.push(oo);
|
|
196
|
-
}
|
|
197
|
-
for (let i = 0; i < 9; i++) {
|
|
198
|
-
if (ks[i] === c) continue;
|
|
199
|
-
let n = 0; for (let j = 0; j < 9; j++) if (ks[j] === ks[i]) n++;
|
|
200
|
-
if (n >= 5) { px.set(src.subarray(os[i], os[i] + 4), o); break; }
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
return px;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/** Outline around (outer) or inside (inner) the opaque shape. */
|
|
208
|
-
export function outline(px, w, h, { mode, color, corners }) {
|
|
209
|
-
if (mode === 'none') return px;
|
|
210
|
-
const src = px.slice();
|
|
211
|
-
const opaque = (x, y) => x >= 0 && y >= 0 && x < w && y < h && src[(y * w + x) * 4 + 3] > 0;
|
|
212
|
-
const inside = (x, y) => x >= 0 && y >= 0 && x < w && y < h;
|
|
213
|
-
const nb = corners ? [[0, -1], [1, 0], [0, 1], [-1, 0], [-1, -1], [1, -1], [1, 1], [-1, 1]] : N4;
|
|
214
|
-
for (let y = 0; y < h; y++) {
|
|
215
|
-
for (let x = 0; x < w; x++) {
|
|
216
|
-
const me = opaque(x, y);
|
|
217
|
-
let hit = false;
|
|
218
|
-
for (const [dx, dy] of nb) {
|
|
219
|
-
const xx = x + dx, yy = y + dy;
|
|
220
|
-
if (!inside(xx, yy)) continue;
|
|
221
|
-
if (mode === 'outer' ? (!me && opaque(xx, yy)) : (me && !opaque(xx, yy))) { hit = true; break; }
|
|
222
|
-
}
|
|
223
|
-
if (hit) { const o = (y * w + x) * 4; px[o] = color[0]; px[o + 1] = color[1]; px[o + 2] = color[2]; px[o + 3] = 255; }
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return px;
|
|
227
|
-
}
|
|
1
|
+
// CPU reference implementation of every pipeline stage.
|
|
2
|
+
// The GLSL in gpu/shaders.js mirrors these functions line for line; tests compare the two.
|
|
3
|
+
// Working buffers are Float32Array RGBA, rows top-down, values 0..1.
|
|
4
|
+
|
|
5
|
+
import { oklab, LUMA } from '../color.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resample to w × h. 'box' averages up to 16×16 evenly spaced source texels per output pixel
|
|
9
|
+
* (alpha-weighted); 'nearest' takes the centre texel. Integer maths, identical on the GPU.
|
|
10
|
+
*/
|
|
11
|
+
export function resample(src, w, h, mode = 'box', crop = null) {
|
|
12
|
+
const ox = crop ? crop.x : 0, oy = crop ? crop.y : 0;
|
|
13
|
+
const sw = crop ? crop.w : src.width, sh = crop ? crop.h : src.height, d = src.data, stride = src.width;
|
|
14
|
+
const out = new Float32Array(w * h * 4);
|
|
15
|
+
const nx = mode === 'nearest' ? 1 : Math.min(16, Math.max(1, Math.ceil(sw / w)));
|
|
16
|
+
const ny = mode === 'nearest' ? 1 : Math.min(16, Math.max(1, Math.ceil(sh / h)));
|
|
17
|
+
const xs = new Int32Array(w * nx), ys = new Int32Array(h * ny);
|
|
18
|
+
for (let x = 0; x < w; x++) for (let i = 0; i < nx; i++) xs[x * nx + i] = ox + Math.min(sw - 1, Math.floor(((2 * (x * nx + i) + 1) * sw) / (2 * w * nx)));
|
|
19
|
+
for (let y = 0; y < h; y++) for (let j = 0; j < ny; j++) ys[y * ny + j] = oy + Math.min(sh - 1, Math.floor(((2 * (y * ny + j) + 1) * sh) / (2 * h * ny)));
|
|
20
|
+
for (let y = 0; y < h; y++) {
|
|
21
|
+
for (let x = 0; x < w; x++) {
|
|
22
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
23
|
+
for (let j = 0; j < ny; j++) {
|
|
24
|
+
const row = ys[y * ny + j] * stride;
|
|
25
|
+
for (let i = 0; i < nx; i++) {
|
|
26
|
+
const o = (row + xs[x * nx + i]) * 4;
|
|
27
|
+
const al = d[o + 3] / 255;
|
|
28
|
+
r += d[o] / 255 * al; g += d[o + 1] / 255 * al; b += d[o + 2] / 255 * al; a += al;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const o = (y * w + x) * 4;
|
|
32
|
+
if (a > 0) { out[o] = r / a; out[o + 1] = g / a; out[o + 2] = b / a; }
|
|
33
|
+
out[o + 3] = a / (nx * ny);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Brightness, contrast, saturation, hue, gamma — in that order, in place. */
|
|
40
|
+
export function adjust(buf, { brightness = 0, contrast = 0, saturation = 0, hue = 0, gamma = 1 }) {
|
|
41
|
+
if (!brightness && !contrast && !saturation && !hue && gamma === 1) return buf;
|
|
42
|
+
const f = contrast >= 0 ? 1 / (1 - 0.99 * contrast) : 1 + contrast;
|
|
43
|
+
const ig = 1 / gamma;
|
|
44
|
+
// luminance-preserving hue rotation: the CSS hue-rotate() matrix (rows sum to 1, so greys stay grey)
|
|
45
|
+
const hk = Math.cos(hue * Math.PI / 180), hq = Math.sin(hue * Math.PI / 180);
|
|
46
|
+
const M = [
|
|
47
|
+
0.213 + 0.787 * hk - 0.213 * hq, 0.715 - 0.715 * hk - 0.715 * hq, 0.072 - 0.072 * hk + 0.928 * hq,
|
|
48
|
+
0.213 - 0.213 * hk + 0.143 * hq, 0.715 + 0.285 * hk + 0.140 * hq, 0.072 - 0.072 * hk - 0.283 * hq,
|
|
49
|
+
0.213 - 0.213 * hk - 0.787 * hq, 0.715 - 0.715 * hk + 0.715 * hq, 0.072 + 0.928 * hk + 0.072 * hq,
|
|
50
|
+
];
|
|
51
|
+
for (let o = 0; o < buf.length; o += 4) {
|
|
52
|
+
let r = buf[o] + brightness, g = buf[o + 1] + brightness, b = buf[o + 2] + brightness;
|
|
53
|
+
r = (r - 0.5) * f + 0.5; g = (g - 0.5) * f + 0.5; b = (b - 0.5) * f + 0.5;
|
|
54
|
+
const l = r * LUMA[0] + g * LUMA[1] + b * LUMA[2];
|
|
55
|
+
const k = 1 + saturation;
|
|
56
|
+
r = l + (r - l) * k; g = l + (g - l) * k; b = l + (b - l) * k;
|
|
57
|
+
if (hue) {
|
|
58
|
+
const r2 = M[0] * r + M[1] * g + M[2] * b, g2 = M[3] * r + M[4] * g + M[5] * b, b2 = M[6] * r + M[7] * g + M[8] * b;
|
|
59
|
+
r = r2; g = g2; b = b2;
|
|
60
|
+
}
|
|
61
|
+
buf[o] = Math.pow(clamp01(r), ig); buf[o + 1] = Math.pow(clamp01(g), ig); buf[o + 2] = Math.pow(clamp01(b), ig);
|
|
62
|
+
}
|
|
63
|
+
return buf;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Binary alpha: below the threshold becomes fully transparent, the rest fully opaque. */
|
|
67
|
+
export function alphaCut(buf, threshold) {
|
|
68
|
+
for (let o = 3; o < buf.length; o += 4) buf[o] = buf[o] >= threshold && buf[o] > 0 ? 1 : 0;
|
|
69
|
+
return buf;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
73
|
+
|
|
74
|
+
/** Precompute what nearest() needs for a palette. */
|
|
75
|
+
export function paletteSpace(palette, distance) {
|
|
76
|
+
const n = palette.length;
|
|
77
|
+
const coords = new Float64Array(n * 3);
|
|
78
|
+
const rgb = new Float64Array(n * 3);
|
|
79
|
+
palette.forEach((c, i) => {
|
|
80
|
+
rgb.set([c[0] / 255, c[1] / 255, c[2] / 255], i * 3);
|
|
81
|
+
coords.set(distance === 1 ? [c[0] / 255, c[1] / 255, c[2] / 255] : oklab(c[0] / 255, c[1] / 255, c[2] / 255), i * 3);
|
|
82
|
+
});
|
|
83
|
+
return { n, coords, rgb, distance };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const W_RGB = [0.299, 0.587, 0.114];
|
|
87
|
+
|
|
88
|
+
/** Index of the nearest palette colour (ties: lowest index). */
|
|
89
|
+
export function nearest(sp, r, g, b) {
|
|
90
|
+
let p0, p1, p2, w0 = 1, w1 = 1, w2 = 1;
|
|
91
|
+
if (sp.distance === 1) { p0 = r; p1 = g; p2 = b; w0 = W_RGB[0]; w1 = W_RGB[1]; w2 = W_RGB[2]; }
|
|
92
|
+
else [p0, p1, p2] = oklab(r, g, b);
|
|
93
|
+
let best = 0, bd = Infinity;
|
|
94
|
+
const c = sp.coords;
|
|
95
|
+
for (let i = 0; i < sp.n; i++) {
|
|
96
|
+
const d0 = p0 - c[i * 3], d1 = p1 - c[i * 3 + 1], d2 = p2 - c[i * 3 + 2];
|
|
97
|
+
const dist = w0 * d0 * d0 + w1 * d1 * d1 + w2 * d2 * d2;
|
|
98
|
+
if (dist < bd) { bd = dist; best = i; }
|
|
99
|
+
}
|
|
100
|
+
return best;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** No dithering / ordered dithering → RGBA bytes. */
|
|
104
|
+
export function quantizeOrdered(buf, w, h, plan) {
|
|
105
|
+
const out = new Uint8ClampedArray(w * h * 4);
|
|
106
|
+
const sp = plan.quantize ? paletteSpace(plan.palette, plan.distance) : null;
|
|
107
|
+
const map = plan.kind === 'ordered' ? plan.map : null;
|
|
108
|
+
const amt = map ? plan.spread * plan.settings.dither.strength : 0;
|
|
109
|
+
for (let y = 0; y < h; y++) {
|
|
110
|
+
for (let x = 0; x < w; x++) {
|
|
111
|
+
const o = (y * w + x) * 4;
|
|
112
|
+
if (buf[o + 3] < 0.5) continue; // transparent stays 0,0,0,0
|
|
113
|
+
let r = buf[o], g = buf[o + 1], b = buf[o + 2];
|
|
114
|
+
if (sp) {
|
|
115
|
+
if (map) {
|
|
116
|
+
const t = (map.values[(y % map.h) * map.w + (x % map.w)] + 0.5) / map.levels - 0.5;
|
|
117
|
+
r = clamp01(r + t * amt); g = clamp01(g + t * amt); b = clamp01(b + t * amt);
|
|
118
|
+
}
|
|
119
|
+
const i = nearest(sp, r, g, b);
|
|
120
|
+
out[o] = plan.palette[i][0]; out[o + 1] = plan.palette[i][1]; out[o + 2] = plan.palette[i][2];
|
|
121
|
+
} else {
|
|
122
|
+
out[o] = Math.round(r * 255); out[o + 1] = Math.round(g * 255); out[o + 2] = Math.round(b * 255);
|
|
123
|
+
}
|
|
124
|
+
out[o + 3] = 255;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Error diffusion (left → right, top → bottom) → RGBA bytes. */
|
|
131
|
+
export function quantizeDiffusion(buf, w, h, plan) {
|
|
132
|
+
const out = new Uint8ClampedArray(w * h * 4);
|
|
133
|
+
const sp = paletteSpace(plan.palette, plan.distance);
|
|
134
|
+
const acc = new Float32Array(w * h * 3);
|
|
135
|
+
const strength = plan.settings.dither.strength;
|
|
136
|
+
const kernel = plan.kernel;
|
|
137
|
+
for (let y = 0; y < h; y++) {
|
|
138
|
+
for (let x = 0; x < w; x++) {
|
|
139
|
+
const p = y * w + x, o = p * 4;
|
|
140
|
+
if (buf[o + 3] < 0.5) continue;
|
|
141
|
+
const r = clamp01(buf[o] + acc[p * 3] * strength);
|
|
142
|
+
const g = clamp01(buf[o + 1] + acc[p * 3 + 1] * strength);
|
|
143
|
+
const b = clamp01(buf[o + 2] + acc[p * 3 + 2] * strength);
|
|
144
|
+
const i = nearest(sp, r, g, b);
|
|
145
|
+
const er = r - sp.rgb[i * 3], eg = g - sp.rgb[i * 3 + 1], eb = b - sp.rgb[i * 3 + 2];
|
|
146
|
+
for (const [dx, dy, wt] of kernel) {
|
|
147
|
+
const xx = x + dx, yy = y + dy;
|
|
148
|
+
if (xx < 0 || xx >= w || yy >= h) continue;
|
|
149
|
+
const q = (yy * w + xx) * 3;
|
|
150
|
+
acc[q] += er * wt; acc[q + 1] += eg * wt; acc[q + 2] += eb * wt;
|
|
151
|
+
}
|
|
152
|
+
out[o] = plan.palette[i][0]; out[o + 1] = plan.palette[i][1]; out[o + 2] = plan.palette[i][2]; out[o + 3] = 255;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const key = (d, o) => (d[o + 3] === 0 ? -1 : (d[o] << 16) | (d[o + 1] << 8) | d[o + 2]);
|
|
159
|
+
const N4 = [[0, -1], [1, 0], [0, 1], [-1, 0]];
|
|
160
|
+
|
|
161
|
+
/** Replace pixels that share no colour with any 4-neighbour by their most common neighbour. */
|
|
162
|
+
export function removeOrphans(px, w, h) {
|
|
163
|
+
const src = px.slice();
|
|
164
|
+
for (let y = 0; y < h; y++) {
|
|
165
|
+
for (let x = 0; x < w; x++) {
|
|
166
|
+
const o = (y * w + x) * 4, c = key(src, o);
|
|
167
|
+
const ks = [], os = [];
|
|
168
|
+
for (const [dx, dy] of N4) {
|
|
169
|
+
const xx = x + dx, yy = y + dy;
|
|
170
|
+
if (xx < 0 || yy < 0 || xx >= w || yy >= h) continue;
|
|
171
|
+
const oo = (yy * w + xx) * 4;
|
|
172
|
+
ks.push(key(src, oo)); os.push(oo);
|
|
173
|
+
}
|
|
174
|
+
if (ks.length < 2 || ks.includes(c)) continue;
|
|
175
|
+
let best = 0, bc = 0;
|
|
176
|
+
for (let i = 0; i < ks.length; i++) {
|
|
177
|
+
let n = 0; for (let j = 0; j < ks.length; j++) if (ks[j] === ks[i]) n++;
|
|
178
|
+
if (n > bc) { bc = n; best = i; }
|
|
179
|
+
}
|
|
180
|
+
px.set(src.subarray(os[best], os[best] + 4), o);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 3×3 majority: a colour that fills at least 5 of the 9 cells wins. */
|
|
187
|
+
export function smoothMajority(px, w, h) {
|
|
188
|
+
const src = px.slice();
|
|
189
|
+
for (let y = 0; y < h; y++) {
|
|
190
|
+
for (let x = 0; x < w; x++) {
|
|
191
|
+
const o = (y * w + x) * 4, c = key(src, o);
|
|
192
|
+
const ks = [], os = [];
|
|
193
|
+
for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) {
|
|
194
|
+
const xx = Math.min(w - 1, Math.max(0, x + dx)), yy = Math.min(h - 1, Math.max(0, y + dy));
|
|
195
|
+
const oo = (yy * w + xx) * 4; ks.push(key(src, oo)); os.push(oo);
|
|
196
|
+
}
|
|
197
|
+
for (let i = 0; i < 9; i++) {
|
|
198
|
+
if (ks[i] === c) continue;
|
|
199
|
+
let n = 0; for (let j = 0; j < 9; j++) if (ks[j] === ks[i]) n++;
|
|
200
|
+
if (n >= 5) { px.set(src.subarray(os[i], os[i] + 4), o); break; }
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return px;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Outline around (outer) or inside (inner) the opaque shape. */
|
|
208
|
+
export function outline(px, w, h, { mode, color, corners }) {
|
|
209
|
+
if (mode === 'none') return px;
|
|
210
|
+
const src = px.slice();
|
|
211
|
+
const opaque = (x, y) => x >= 0 && y >= 0 && x < w && y < h && src[(y * w + x) * 4 + 3] > 0;
|
|
212
|
+
const inside = (x, y) => x >= 0 && y >= 0 && x < w && y < h;
|
|
213
|
+
const nb = corners ? [[0, -1], [1, 0], [0, 1], [-1, 0], [-1, -1], [1, -1], [1, 1], [-1, 1]] : N4;
|
|
214
|
+
for (let y = 0; y < h; y++) {
|
|
215
|
+
for (let x = 0; x < w; x++) {
|
|
216
|
+
const me = opaque(x, y);
|
|
217
|
+
let hit = false;
|
|
218
|
+
for (const [dx, dy] of nb) {
|
|
219
|
+
const xx = x + dx, yy = y + dy;
|
|
220
|
+
if (!inside(xx, yy)) continue;
|
|
221
|
+
if (mode === 'outer' ? (!me && opaque(xx, yy)) : (me && !opaque(xx, yy))) { hit = true; break; }
|
|
222
|
+
}
|
|
223
|
+
if (hit) { const o = (y * w + x) * 4; px[o] = color[0]; px[o + 1] = color[1]; px[o + 2] = color[2]; px[o + 3] = 255; }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return px;
|
|
227
|
+
}
|