image-to-pixel 3.0.0 → 3.0.1

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.
Files changed (68) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +69 -69
  3. package/dist/editor.css.map +1 -1
  4. package/dist/editor.esm.js +1 -1
  5. package/dist/editor.esm.js.map +1 -1
  6. package/dist/fonts/README.md +6 -6
  7. package/dist/fonts/geist-LICENSE.txt +93 -93
  8. package/dist/image-to-pixel.bundle.esm.js +13 -13
  9. package/dist/image-to-pixel.bundle.esm.js.map +2 -2
  10. package/dist/image-to-pixel.cjs +2 -2
  11. package/dist/image-to-pixel.cjs.map +2 -2
  12. package/dist/image-to-pixel.esm.js +2 -2
  13. package/dist/image-to-pixel.esm.js.map +2 -2
  14. package/dist/image-to-pixel.js +2 -2
  15. package/dist/image-to-pixel.js.map +2 -2
  16. package/dist/image-to-pixel.min.js +11 -11
  17. package/dist/image-to-pixel.min.js.map +2 -2
  18. package/dist/video.esm.js +1 -1
  19. package/dist/video.esm.js.map +1 -1
  20. package/lib/core/color.js +69 -69
  21. package/lib/core/cpu/pipeline.js +21 -21
  22. package/lib/core/cpu/stages.js +227 -227
  23. package/lib/core/dither/bluenoise-data.js +3 -3
  24. package/lib/core/dither/kernels.js +27 -27
  25. package/lib/core/dither/matrices.js +95 -95
  26. package/lib/core/engine.js +158 -158
  27. package/lib/core/export/canvas.js +22 -22
  28. package/lib/core/export/crc32.js +15 -15
  29. package/lib/core/export/gif.js +153 -153
  30. package/lib/core/export/png.js +94 -94
  31. package/lib/core/export/scale.js +103 -103
  32. package/lib/core/export/sheet.js +95 -95
  33. package/lib/core/export/zip.js +55 -55
  34. package/lib/core/gpu/backend.js +159 -159
  35. package/lib/core/gpu/shaders.js +258 -258
  36. package/lib/core/palette/builtin.js +36 -36
  37. package/lib/core/palette/extract.js +69 -69
  38. package/lib/core/palette/lospec.js +24 -24
  39. package/lib/core/palette/parse.js +112 -112
  40. package/lib/core/plan.js +58 -58
  41. package/lib/core/presets.js +42 -42
  42. package/lib/core/settings.js +274 -274
  43. package/lib/core/source.js +89 -89
  44. package/lib/core/video/frames.js +144 -144
  45. package/lib/core/video/mux-mp4.js +131 -131
  46. package/lib/core/video/mux-webm.js +127 -127
  47. package/lib/editor/controls.js +139 -139
  48. package/lib/editor/dom.js +65 -65
  49. package/lib/editor/exporter.js +185 -185
  50. package/lib/editor/icons.js +61 -61
  51. package/lib/editor/index.js +793 -793
  52. package/lib/editor/inspector.js +328 -328
  53. package/lib/editor/library.js +182 -182
  54. package/lib/editor/persist.js +18 -18
  55. package/lib/editor/samples.js +90 -90
  56. package/lib/editor/stage.js +239 -239
  57. package/lib/editor/store.js +80 -80
  58. package/lib/editor/styles/editor.css +385 -385
  59. package/lib/editor/styles/fonts/README.md +6 -6
  60. package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
  61. package/lib/editor/styles/tokens.css +89 -89
  62. package/lib/editor/ui.js +97 -97
  63. package/lib/index.js +3 -1
  64. package/lib/video.js +92 -92
  65. package/package.json +92 -92
  66. package/types/editor.d.ts +34 -34
  67. package/types/index.d.ts +196 -196
  68. package/types/video.d.ts +43 -43
@@ -1,69 +1,69 @@
1
- // Palette extraction: median cut on the image's colours, refined by a few k-means passes.
2
- // Deterministic (no randomness), so the same image always gives the same palette.
3
-
4
- import { toHex } from '../color.js';
5
-
6
- /**
7
- * @param {{ data: ArrayLike<number>, width: number, height: number }} img RGBA bytes
8
- * @param {number} count number of colours (2..256)
9
- * @param {{ maxSamples?: number, alphaThreshold?: number }} [opts]
10
- * @returns {string[]} '#rrggbb' colours sorted dark → light
11
- */
12
- export function extractPalette(img, count = 16, opts = {}) {
13
- const { maxSamples = 40000, alphaThreshold = 0.5 } = opts;
14
- const n = img.width * img.height;
15
- const step = Math.max(1, Math.floor(n / maxSamples));
16
- const pts = [];
17
- for (let i = 0; i < n; i += step) {
18
- const o = i * 4;
19
- if (img.data[o + 3] < alphaThreshold * 255) continue;
20
- pts.push([img.data[o], img.data[o + 1], img.data[o + 2]]);
21
- }
22
- if (!pts.length) return ['#000000', '#ffffff'].slice(0, Math.max(1, count));
23
-
24
- // ---- median cut
25
- let boxes = [pts];
26
- while (boxes.length < count) {
27
- let bi = -1, bestRange = -1, bestAxis = 0;
28
- boxes.forEach((b, i) => {
29
- if (b.length < 2) return;
30
- for (let a = 0; a < 3; a++) {
31
- let lo = 255, hi = 0;
32
- for (const p of b) { if (p[a] < lo) lo = p[a]; if (p[a] > hi) hi = p[a]; }
33
- // weight by population so big flat areas get split before tiny noisy ones
34
- const r = (hi - lo) * Math.sqrt(b.length);
35
- if (r > bestRange) { bestRange = r; bi = i; bestAxis = a; }
36
- }
37
- });
38
- if (bi < 0 || bestRange <= 0) break;
39
- const b = boxes[bi].slice().sort((p, q) => p[bestAxis] - q[bestAxis]);
40
- const mid = b.length >> 1;
41
- boxes.splice(bi, 1, b.slice(0, mid), b.slice(mid));
42
- }
43
- let centers = boxes.map(b => mean(b));
44
-
45
- // ---- k-means refinement
46
- for (let iter = 0; iter < 6; iter++) {
47
- const sums = centers.map(() => [0, 0, 0, 0]);
48
- for (const p of pts) {
49
- let best = 0, bd = Infinity;
50
- for (let c = 0; c < centers.length; c++) {
51
- const dr = p[0] - centers[c][0], dg = p[1] - centers[c][1], db = p[2] - centers[c][2];
52
- const d = dr * dr + dg * dg + db * db;
53
- if (d < bd) { bd = d; best = c; }
54
- }
55
- const s = sums[best]; s[0] += p[0]; s[1] += p[1]; s[2] += p[2]; s[3]++;
56
- }
57
- centers = centers.map((c, i) => (sums[i][3] ? [sums[i][0] / sums[i][3], sums[i][1] / sums[i][3], sums[i][2] / sums[i][3]] : c));
58
- }
59
-
60
- const hexes = [...new Set(centers.map(c => toHex(c)))];
61
- const lum = h => { const v = parseInt(h.slice(1), 16); return 0.2126 * (v >> 16) + 0.7152 * ((v >> 8) & 255) + 0.0722 * (v & 255); };
62
- return hexes.sort((a, b) => lum(a) - lum(b));
63
- }
64
-
65
- function mean(list) {
66
- let r = 0, g = 0, b = 0;
67
- for (const p of list) { r += p[0]; g += p[1]; b += p[2]; }
68
- return [r / list.length, g / list.length, b / list.length];
69
- }
1
+ // Palette extraction: median cut on the image's colours, refined by a few k-means passes.
2
+ // Deterministic (no randomness), so the same image always gives the same palette.
3
+
4
+ import { toHex } from '../color.js';
5
+
6
+ /**
7
+ * @param {{ data: ArrayLike<number>, width: number, height: number }} img RGBA bytes
8
+ * @param {number} count number of colours (2..256)
9
+ * @param {{ maxSamples?: number, alphaThreshold?: number }} [opts]
10
+ * @returns {string[]} '#rrggbb' colours sorted dark → light
11
+ */
12
+ export function extractPalette(img, count = 16, opts = {}) {
13
+ const { maxSamples = 40000, alphaThreshold = 0.5 } = opts;
14
+ const n = img.width * img.height;
15
+ const step = Math.max(1, Math.floor(n / maxSamples));
16
+ const pts = [];
17
+ for (let i = 0; i < n; i += step) {
18
+ const o = i * 4;
19
+ if (img.data[o + 3] < alphaThreshold * 255) continue;
20
+ pts.push([img.data[o], img.data[o + 1], img.data[o + 2]]);
21
+ }
22
+ if (!pts.length) return ['#000000', '#ffffff'].slice(0, Math.max(1, count));
23
+
24
+ // ---- median cut
25
+ let boxes = [pts];
26
+ while (boxes.length < count) {
27
+ let bi = -1, bestRange = -1, bestAxis = 0;
28
+ boxes.forEach((b, i) => {
29
+ if (b.length < 2) return;
30
+ for (let a = 0; a < 3; a++) {
31
+ let lo = 255, hi = 0;
32
+ for (const p of b) { if (p[a] < lo) lo = p[a]; if (p[a] > hi) hi = p[a]; }
33
+ // weight by population so big flat areas get split before tiny noisy ones
34
+ const r = (hi - lo) * Math.sqrt(b.length);
35
+ if (r > bestRange) { bestRange = r; bi = i; bestAxis = a; }
36
+ }
37
+ });
38
+ if (bi < 0 || bestRange <= 0) break;
39
+ const b = boxes[bi].slice().sort((p, q) => p[bestAxis] - q[bestAxis]);
40
+ const mid = b.length >> 1;
41
+ boxes.splice(bi, 1, b.slice(0, mid), b.slice(mid));
42
+ }
43
+ let centers = boxes.map(b => mean(b));
44
+
45
+ // ---- k-means refinement
46
+ for (let iter = 0; iter < 6; iter++) {
47
+ const sums = centers.map(() => [0, 0, 0, 0]);
48
+ for (const p of pts) {
49
+ let best = 0, bd = Infinity;
50
+ for (let c = 0; c < centers.length; c++) {
51
+ const dr = p[0] - centers[c][0], dg = p[1] - centers[c][1], db = p[2] - centers[c][2];
52
+ const d = dr * dr + dg * dg + db * db;
53
+ if (d < bd) { bd = d; best = c; }
54
+ }
55
+ const s = sums[best]; s[0] += p[0]; s[1] += p[1]; s[2] += p[2]; s[3]++;
56
+ }
57
+ centers = centers.map((c, i) => (sums[i][3] ? [sums[i][0] / sums[i][3], sums[i][1] / sums[i][3], sums[i][2] / sums[i][3]] : c));
58
+ }
59
+
60
+ const hexes = [...new Set(centers.map(c => toHex(c)))];
61
+ const lum = h => { const v = parseInt(h.slice(1), 16); return 0.2126 * (v >> 16) + 0.7152 * ((v >> 8) & 255) + 0.0722 * (v & 255); };
62
+ return hexes.sort((a, b) => lum(a) - lum(b));
63
+ }
64
+
65
+ function mean(list) {
66
+ let r = 0, g = 0, b = 0;
67
+ for (const p of list) { r += p[0]; g += p[1]; b += p[2]; }
68
+ return [r / list.length, g / list.length, b / list.length];
69
+ }
@@ -1,24 +1,24 @@
1
- // Lospec palette lookup by slug, e.g. 'rgbg-36' from https://lospec.com/palette-list/rgbg-36
2
-
3
- import { parseJSON } from './parse.js';
4
-
5
- /** Turn a slug or a full Lospec URL into a slug. */
6
- export function lospecSlug(input) {
7
- const s = String(input || '').trim();
8
- const m = /palette-list\/([a-z0-9-]+)/i.exec(s);
9
- return (m ? m[1] : s).toLowerCase().replace(/\.(json|hex|gpl)$/, '');
10
- }
11
-
12
- /**
13
- * Fetch a palette from Lospec. Needs network access; the editor caches results.
14
- * @returns {Promise<{ name: string, colors: string[], author?: string, slug: string }>}
15
- */
16
- export async function fetchLospec(slugOrUrl, { fetch: f = globalThis.fetch } = {}) {
17
- const slug = lospecSlug(slugOrUrl);
18
- if (!/^[a-z0-9-]+$/.test(slug)) throw new Error('That does not look like a Lospec palette slug');
19
- const res = await f(`https://lospec.com/palette-list/${slug}.json`);
20
- if (!res.ok) throw new Error(`Lospec returned ${res.status} for "${slug}"`);
21
- const json = await res.json();
22
- const p = parseJSON(json);
23
- return { name: json.name || slug, author: json.author || '', colors: p.colors, slug };
24
- }
1
+ // Lospec palette lookup by slug, e.g. 'rgbg-36' from https://lospec.com/palette-list/rgbg-36
2
+
3
+ import { parseJSON } from './parse.js';
4
+
5
+ /** Turn a slug or a full Lospec URL into a slug. */
6
+ export function lospecSlug(input) {
7
+ const s = String(input || '').trim();
8
+ const m = /palette-list\/([a-z0-9-]+)/i.exec(s);
9
+ return (m ? m[1] : s).toLowerCase().replace(/\.(json|hex|gpl)$/, '');
10
+ }
11
+
12
+ /**
13
+ * Fetch a palette from Lospec. Needs network access; the editor caches results.
14
+ * @returns {Promise<{ name: string, colors: string[], author?: string, slug: string }>}
15
+ */
16
+ export async function fetchLospec(slugOrUrl, { fetch: f = globalThis.fetch } = {}) {
17
+ const slug = lospecSlug(slugOrUrl);
18
+ if (!/^[a-z0-9-]+$/.test(slug)) throw new Error('That does not look like a Lospec palette slug');
19
+ const res = await f(`https://lospec.com/palette-list/${slug}.json`);
20
+ if (!res.ok) throw new Error(`Lospec returned ${res.status} for "${slug}"`);
21
+ const json = await res.json();
22
+ const p = parseJSON(json);
23
+ return { name: json.name || slug, author: json.author || '', colors: p.colors, slug };
24
+ }
@@ -1,112 +1,112 @@
1
- // Palette file formats. Every parser returns { name, colors: ['#rrggbb', …] }.
2
- //
3
- // .gpl GIMP / Aseprite / Krita palette
4
- // .hex one hex colour per line (Lospec "HEX file")
5
- // .txt Paint.NET palette (AARRGGBB per line, ';' comments)
6
- // .pal JASC-PAL (Paint Shop Pro, Aseprite export)
7
- // .json Lospec JSON ({ name, colors: ['rrggbb'] }), this tool's own export, or a plain array
8
- // anything else: every #hex / 6-digit token found in the text
9
-
10
- import { normHex } from '../color.js';
11
-
12
- const dedupe = list => [...new Set(list)];
13
-
14
- export function parseGPL(text) {
15
- const lines = text.split(/\r?\n/);
16
- if (!/^GIMP Palette/i.test(lines[0] || '')) throw new Error('Not a GIMP palette (missing "GIMP Palette" header)');
17
- let name = '';
18
- const colors = [];
19
- for (const raw of lines.slice(1)) {
20
- const line = raw.trim();
21
- if (!line || line.startsWith('#')) continue;
22
- const nm = /^Name:\s*(.*)$/i.exec(line);
23
- if (nm) { name = nm[1].trim(); continue; }
24
- if (/^Columns:/i.test(line)) continue;
25
- const m = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})/.exec(line);
26
- if (m) colors.push(normHex([+m[1], +m[2], +m[3]]));
27
- }
28
- return { name, colors };
29
- }
30
-
31
- export function parseHexList(text) {
32
- const colors = [];
33
- for (const raw of text.split(/\r?\n/)) {
34
- const line = raw.trim();
35
- if (!line || line.startsWith(';') || line.startsWith('//')) continue;
36
- const m = /^#?([0-9a-f]{6}|[0-9a-f]{8}|[0-9a-f]{3})\b/i.exec(line);
37
- if (!m) continue;
38
- // 8 digits in a .txt palette are AARRGGBB (Paint.NET); in a .hex file they are rare, treat the same
39
- const h = m[1].length === 8 ? m[1].slice(2) : m[1];
40
- colors.push(normHex(h));
41
- }
42
- return { name: '', colors };
43
- }
44
-
45
- export function parseJASC(text) {
46
- const lines = text.split(/\r?\n/).map(l => l.trim());
47
- if (lines[0] !== 'JASC-PAL') throw new Error('Not a JASC palette');
48
- const n = parseInt(lines[2], 10) || 0;
49
- const colors = [];
50
- for (let i = 3; i < 3 + n && i < lines.length; i++) {
51
- const m = /^(\d+)\s+(\d+)\s+(\d+)/.exec(lines[i]);
52
- if (m) colors.push(normHex([+m[1], +m[2], +m[3]]));
53
- }
54
- return { name: '', colors };
55
- }
56
-
57
- export function parseJSON(text) {
58
- const obj = typeof text === 'string' ? JSON.parse(text) : text;
59
- const list = Array.isArray(obj) ? obj : (obj.colors || obj.palette || []);
60
- const colors = list.map(c => (typeof c === 'object' && c ? (c.hex || c.color) : c)).filter(Boolean).map(normHex);
61
- return { name: (!Array.isArray(obj) && obj.name) || '', colors };
62
- }
63
-
64
- /** Pull every hex-looking token out of free text (pasted CSS, a tweet, a Lospec page). */
65
- export function parseLoose(text) {
66
- const colors = [];
67
- const re = /#?\b([0-9a-f]{6})\b/gi;
68
- let m;
69
- while ((m = re.exec(text))) colors.push(normHex(m[1]));
70
- return { name: '', colors };
71
- }
72
-
73
- /**
74
- * Parse palette text by file name / content sniffing.
75
- * @param {string} text
76
- * @param {string} [filename]
77
- * @returns {{ name: string, colors: string[] }}
78
- */
79
- export function parsePalette(text, filename = '') {
80
- const ext = (/\.([a-z0-9]+)$/i.exec(filename) || [])[1]?.toLowerCase() || '';
81
- const t = String(text).replace(/^/, '');
82
- const trimmed = t.trim();
83
- let out;
84
- if (ext === 'gpl' || /^GIMP Palette/i.test(trimmed)) out = parseGPL(trimmed);
85
- else if (ext === 'pal' || trimmed.startsWith('JASC-PAL')) out = parseJASC(trimmed);
86
- else if (ext === 'json' || trimmed.startsWith('{') || trimmed.startsWith('[')) out = parseJSON(trimmed);
87
- else if (ext === 'hex' || ext === 'txt') out = parseHexList(trimmed);
88
- else {
89
- out = parseHexList(trimmed);
90
- if (!out.colors.length) out = parseLoose(trimmed);
91
- }
92
- out.colors = dedupe(out.colors).slice(0, 256);
93
- if (!out.name && filename) out.name = filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ');
94
- if (!out.colors.length) throw new Error('No colours found in that palette');
95
- return out;
96
- }
97
-
98
- /** Palette → .gpl text. */
99
- export function toGPL(name, colors) {
100
- const rows = colors.map(c => {
101
- const h = normHex(c);
102
- const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
103
- return `${String(r).padStart(3)} ${String(g).padStart(3)} ${String(b).padStart(3)}\t${h.slice(1)}`;
104
- });
105
- return `GIMP Palette\nName: ${name || 'Palette'}\nColumns: 8\n#\n${rows.join('\n')}\n`;
106
- }
107
-
108
- /** Palette → .hex text. */
109
- export const toHexFile = colors => colors.map(c => normHex(c).slice(1)).join('\n') + '\n';
110
-
111
- /** Palette → JSON text (Lospec-compatible shape). */
112
- export const toPaletteJSON = (name, colors) => JSON.stringify({ name: name || 'Palette', colors: colors.map(c => normHex(c).slice(1)) }, null, 2);
1
+ // Palette file formats. Every parser returns { name, colors: ['#rrggbb', …] }.
2
+ //
3
+ // .gpl GIMP / Aseprite / Krita palette
4
+ // .hex one hex colour per line (Lospec "HEX file")
5
+ // .txt Paint.NET palette (AARRGGBB per line, ';' comments)
6
+ // .pal JASC-PAL (Paint Shop Pro, Aseprite export)
7
+ // .json Lospec JSON ({ name, colors: ['rrggbb'] }), this tool's own export, or a plain array
8
+ // anything else: every #hex / 6-digit token found in the text
9
+
10
+ import { normHex } from '../color.js';
11
+
12
+ const dedupe = list => [...new Set(list)];
13
+
14
+ export function parseGPL(text) {
15
+ const lines = text.split(/\r?\n/);
16
+ if (!/^GIMP Palette/i.test(lines[0] || '')) throw new Error('Not a GIMP palette (missing "GIMP Palette" header)');
17
+ let name = '';
18
+ const colors = [];
19
+ for (const raw of lines.slice(1)) {
20
+ const line = raw.trim();
21
+ if (!line || line.startsWith('#')) continue;
22
+ const nm = /^Name:\s*(.*)$/i.exec(line);
23
+ if (nm) { name = nm[1].trim(); continue; }
24
+ if (/^Columns:/i.test(line)) continue;
25
+ const m = /^(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})/.exec(line);
26
+ if (m) colors.push(normHex([+m[1], +m[2], +m[3]]));
27
+ }
28
+ return { name, colors };
29
+ }
30
+
31
+ export function parseHexList(text) {
32
+ const colors = [];
33
+ for (const raw of text.split(/\r?\n/)) {
34
+ const line = raw.trim();
35
+ if (!line || line.startsWith(';') || line.startsWith('//')) continue;
36
+ const m = /^#?([0-9a-f]{6}|[0-9a-f]{8}|[0-9a-f]{3})\b/i.exec(line);
37
+ if (!m) continue;
38
+ // 8 digits in a .txt palette are AARRGGBB (Paint.NET); in a .hex file they are rare, treat the same
39
+ const h = m[1].length === 8 ? m[1].slice(2) : m[1];
40
+ colors.push(normHex(h));
41
+ }
42
+ return { name: '', colors };
43
+ }
44
+
45
+ export function parseJASC(text) {
46
+ const lines = text.split(/\r?\n/).map(l => l.trim());
47
+ if (lines[0] !== 'JASC-PAL') throw new Error('Not a JASC palette');
48
+ const n = parseInt(lines[2], 10) || 0;
49
+ const colors = [];
50
+ for (let i = 3; i < 3 + n && i < lines.length; i++) {
51
+ const m = /^(\d+)\s+(\d+)\s+(\d+)/.exec(lines[i]);
52
+ if (m) colors.push(normHex([+m[1], +m[2], +m[3]]));
53
+ }
54
+ return { name: '', colors };
55
+ }
56
+
57
+ export function parseJSON(text) {
58
+ const obj = typeof text === 'string' ? JSON.parse(text) : text;
59
+ const list = Array.isArray(obj) ? obj : (obj.colors || obj.palette || []);
60
+ const colors = list.map(c => (typeof c === 'object' && c ? (c.hex || c.color) : c)).filter(Boolean).map(normHex);
61
+ return { name: (!Array.isArray(obj) && obj.name) || '', colors };
62
+ }
63
+
64
+ /** Pull every hex-looking token out of free text (pasted CSS, a tweet, a Lospec page). */
65
+ export function parseLoose(text) {
66
+ const colors = [];
67
+ const re = /#?\b([0-9a-f]{6})\b/gi;
68
+ let m;
69
+ while ((m = re.exec(text))) colors.push(normHex(m[1]));
70
+ return { name: '', colors };
71
+ }
72
+
73
+ /**
74
+ * Parse palette text by file name / content sniffing.
75
+ * @param {string} text
76
+ * @param {string} [filename]
77
+ * @returns {{ name: string, colors: string[] }}
78
+ */
79
+ export function parsePalette(text, filename = '') {
80
+ const ext = (/\.([a-z0-9]+)$/i.exec(filename) || [])[1]?.toLowerCase() || '';
81
+ const t = String(text).replace(/^/, '');
82
+ const trimmed = t.trim();
83
+ let out;
84
+ if (ext === 'gpl' || /^GIMP Palette/i.test(trimmed)) out = parseGPL(trimmed);
85
+ else if (ext === 'pal' || trimmed.startsWith('JASC-PAL')) out = parseJASC(trimmed);
86
+ else if (ext === 'json' || trimmed.startsWith('{') || trimmed.startsWith('[')) out = parseJSON(trimmed);
87
+ else if (ext === 'hex' || ext === 'txt') out = parseHexList(trimmed);
88
+ else {
89
+ out = parseHexList(trimmed);
90
+ if (!out.colors.length) out = parseLoose(trimmed);
91
+ }
92
+ out.colors = dedupe(out.colors).slice(0, 256);
93
+ if (!out.name && filename) out.name = filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ');
94
+ if (!out.colors.length) throw new Error('No colours found in that palette');
95
+ return out;
96
+ }
97
+
98
+ /** Palette → .gpl text. */
99
+ export function toGPL(name, colors) {
100
+ const rows = colors.map(c => {
101
+ const h = normHex(c);
102
+ const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
103
+ return `${String(r).padStart(3)} ${String(g).padStart(3)} ${String(b).padStart(3)}\t${h.slice(1)}`;
104
+ });
105
+ return `GIMP Palette\nName: ${name || 'Palette'}\nColumns: 8\n#\n${rows.join('\n')}\n`;
106
+ }
107
+
108
+ /** Palette → .hex text. */
109
+ export const toHexFile = colors => colors.map(c => normHex(c).slice(1)).join('\n') + '\n';
110
+
111
+ /** Palette → JSON text (Lospec-compatible shape). */
112
+ export const toPaletteJSON = (name, colors) => JSON.stringify({ name: name || 'Palette', colors: colors.map(c => normHex(c).slice(1)) }, null, 2);
package/lib/core/plan.js CHANGED
@@ -1,58 +1,58 @@
1
- // A "plan" is everything both backends need, resolved once from settings + source size:
2
- // output size, palette bytes, threshold map, kernel, and the numbers derived from them.
3
- // Building it in one place keeps the CPU and GPU paths in lock-step.
4
-
5
- import { normalizeSettings, resolveSize, ditherKind, cropRect } from './settings.js';
6
- import { parseColor, paletteSpread } from './color.js';
7
- import { thresholdMap } from './dither/matrices.js';
8
- import { KERNELS, kernelSkew, waveCount } from './dither/kernels.js';
9
- import { extractPalette } from './palette/extract.js';
10
- import { resample, adjust } from './cpu/stages.js';
11
-
12
- /**
13
- * @param {object} settings
14
- * @param {{ width: number, height: number, data: ArrayLike<number> }} src RGBA source (only read for palette extraction)
15
- */
16
- export function makePlan(settings, src) {
17
- const s = normalizeSettings(settings);
18
- const crop = cropRect(src.width, src.height, s.frame);
19
- const { width, height } = resolveSize(crop.w, crop.h, s.size);
20
-
21
- let colors = s.palette.colors;
22
- if (s.palette.source === 'extract') colors = extractFrom(src, s, width, height, crop);
23
- const quantize = s.palette.source !== 'none' && colors.length > 0;
24
- const palette = quantize ? colors.map(parseColor) : [];
25
-
26
- let kind = quantize ? ditherKind(s.dither.method) : 'none';
27
- if (s.dither.strength <= 0) kind = 'none';
28
-
29
- const map = kind === 'ordered' ? thresholdMap(s.dither.method, s.dither.matrix) : null;
30
- const kernel = kind === 'diffusion' ? KERNELS[s.dither.method] : null;
31
- const skew = kernel ? kernelSkew(kernel) : 1;
32
-
33
- return {
34
- settings: s,
35
- srcWidth: src.width, srcHeight: src.height,
36
- crop, // source pixels kept by the frame settings
37
- width, height,
38
- quantize, kind,
39
- palette, // [[r, g, b] bytes]
40
- paletteHex: quantize ? colors.slice() : null,
41
- distance: s.palette.distance === 'rgb' ? 1 : 0,
42
- spread: quantize ? paletteSpread(palette) : 0,
43
- map, kernel, skew,
44
- waves: kernel ? waveCount(width, height, skew) : 0,
45
- outline: { mode: s.outline.mode, color: parseColor(s.outline.color), corners: s.outline.corners },
46
- };
47
- }
48
-
49
- function extractFrom(src, s, width, height, crop) {
50
- // extract from what will actually be quantised: the resized + adjusted image (capped for speed)
51
- const k = Math.min(1, 160 / Math.max(width, height));
52
- const w = Math.max(1, Math.round(width * k)), h = Math.max(1, Math.round(height * k));
53
- const prep = resample(src, w, h, 'box', crop);
54
- adjust(prep, s.adjust);
55
- const bytes = new Uint8ClampedArray(prep.length);
56
- for (let i = 0; i < prep.length; i++) bytes[i] = Math.round(prep[i] * 255);
57
- return extractPalette({ data: bytes, width: w, height: h }, s.palette.count, { alphaThreshold: Math.max(0.01, s.alpha.threshold) });
58
- }
1
+ // A "plan" is everything both backends need, resolved once from settings + source size:
2
+ // output size, palette bytes, threshold map, kernel, and the numbers derived from them.
3
+ // Building it in one place keeps the CPU and GPU paths in lock-step.
4
+
5
+ import { normalizeSettings, resolveSize, ditherKind, cropRect } from './settings.js';
6
+ import { parseColor, paletteSpread } from './color.js';
7
+ import { thresholdMap } from './dither/matrices.js';
8
+ import { KERNELS, kernelSkew, waveCount } from './dither/kernels.js';
9
+ import { extractPalette } from './palette/extract.js';
10
+ import { resample, adjust } from './cpu/stages.js';
11
+
12
+ /**
13
+ * @param {object} settings
14
+ * @param {{ width: number, height: number, data: ArrayLike<number> }} src RGBA source (only read for palette extraction)
15
+ */
16
+ export function makePlan(settings, src) {
17
+ const s = normalizeSettings(settings);
18
+ const crop = cropRect(src.width, src.height, s.frame);
19
+ const { width, height } = resolveSize(crop.w, crop.h, s.size);
20
+
21
+ let colors = s.palette.colors;
22
+ if (s.palette.source === 'extract') colors = extractFrom(src, s, width, height, crop);
23
+ const quantize = s.palette.source !== 'none' && colors.length > 0;
24
+ const palette = quantize ? colors.map(parseColor) : [];
25
+
26
+ let kind = quantize ? ditherKind(s.dither.method) : 'none';
27
+ if (s.dither.strength <= 0) kind = 'none';
28
+
29
+ const map = kind === 'ordered' ? thresholdMap(s.dither.method, s.dither.matrix) : null;
30
+ const kernel = kind === 'diffusion' ? KERNELS[s.dither.method] : null;
31
+ const skew = kernel ? kernelSkew(kernel) : 1;
32
+
33
+ return {
34
+ settings: s,
35
+ srcWidth: src.width, srcHeight: src.height,
36
+ crop, // source pixels kept by the frame settings
37
+ width, height,
38
+ quantize, kind,
39
+ palette, // [[r, g, b] bytes]
40
+ paletteHex: quantize ? colors.slice() : null,
41
+ distance: s.palette.distance === 'rgb' ? 1 : 0,
42
+ spread: quantize ? paletteSpread(palette) : 0,
43
+ map, kernel, skew,
44
+ waves: kernel ? waveCount(width, height, skew) : 0,
45
+ outline: { mode: s.outline.mode, color: parseColor(s.outline.color), corners: s.outline.corners },
46
+ };
47
+ }
48
+
49
+ function extractFrom(src, s, width, height, crop) {
50
+ // extract from what will actually be quantised: the resized + adjusted image (capped for speed)
51
+ const k = Math.min(1, 160 / Math.max(width, height));
52
+ const w = Math.max(1, Math.round(width * k)), h = Math.max(1, Math.round(height * k));
53
+ const prep = resample(src, w, h, 'box', crop);
54
+ adjust(prep, s.adjust);
55
+ const bytes = new Uint8ClampedArray(prep.length);
56
+ for (let i = 0; i < prep.length; i++) bytes[i] = Math.round(prep[i] * 255);
57
+ return extractPalette({ data: bytes, width: w, height: h }, s.palette.count, { alphaThreshold: Math.max(0.01, s.alpha.threshold) });
58
+ }
@@ -1,42 +1,42 @@
1
- // Built-in presets: partial settings applied on top of the defaults.
2
-
3
- export const PRESETS = [
4
- {
5
- id: 'balanced', name: 'Balanced', description: 'Endesga 32, Bayer 4×4. A good first look at most photos.',
6
- settings: { size: { width: 96 }, palette: { source: 'builtin', id: 'endesga-32' }, dither: { method: 'bayer4', strength: 0.6 } },
7
- },
8
- {
9
- id: 'game-boy', name: 'Game Boy', description: 'Four greens, 160 px wide, ordered dither.',
10
- settings: { size: { width: 160 }, palette: { source: 'builtin', id: 'game-boy-dmg' }, dither: { method: 'bayer4', strength: 1 }, adjust: { contrast: 0.15 } },
11
- },
12
- {
13
- id: 'pico-8', name: 'PICO-8', description: '128 px, the PICO-8 palette, blue-noise dither.',
14
- settings: { size: { width: 128 }, palette: { source: 'builtin', id: 'pico-8' }, dither: { method: 'bluenoise', strength: 0.7 } },
15
- },
16
- {
17
- id: 'one-bit', name: '1-bit Atkinson', description: 'Classic Mac look: two colours, Atkinson diffusion.',
18
- settings: { size: { width: 192 }, palette: { source: 'builtin', id: '1-bit-monitor' }, dither: { method: 'atkinson', strength: 1 }, adjust: { contrast: 0.2 } },
19
- },
20
- {
21
- id: 'faithful', name: 'Faithful colour', description: 'Palette extracted from the image, Floyd–Steinberg.',
22
- settings: { size: { width: 128 }, palette: { source: 'extract', count: 24 }, dither: { method: 'floyd-steinberg', strength: 0.75 } },
23
- },
24
- {
25
- id: 'sprite', name: 'Clean sprite', description: 'No dither, nearest resample, orphan cleanup and a dark outline. For art with transparency.',
26
- settings: { size: { width: 48, resample: 'nearest' }, palette: { source: 'extract', count: 12 }, dither: { method: 'none' }, cleanup: { orphans: true }, outline: { mode: 'outer', color: '#1a1c2c' } },
27
- },
28
- {
29
- id: 'cga', name: 'CGA', description: '16 hard colours with an 8×8 Bayer pattern.',
30
- settings: { size: { width: 160 }, palette: { source: 'builtin', id: 'cga-16' }, dither: { method: 'bayer8', strength: 1 } },
31
- },
32
- {
33
- id: 'newsprint', name: 'Newsprint', description: 'Grey clustered-dot halftone.',
34
- settings: { size: { width: 220 }, palette: { source: 'builtin', id: 'grayscale-4' }, dither: { method: 'cluster8', strength: 1 }, adjust: { saturation: -1 } },
35
- },
36
- {
37
- id: 'soft', name: 'Soft painterly', description: 'Resurrect 64 with Sierra Lite and a light smooth.',
38
- settings: { size: { width: 112 }, palette: { source: 'builtin', id: 'resurrect-64' }, dither: { method: 'sierra-lite', strength: 0.6 }, cleanup: { smooth: true } },
39
- },
40
- ];
41
-
42
- export const getPreset = id => PRESETS.find(p => p.id === id) || null;
1
+ // Built-in presets: partial settings applied on top of the defaults.
2
+
3
+ export const PRESETS = [
4
+ {
5
+ id: 'balanced', name: 'Balanced', description: 'Endesga 32, Bayer 4×4. A good first look at most photos.',
6
+ settings: { size: { width: 96 }, palette: { source: 'builtin', id: 'endesga-32' }, dither: { method: 'bayer4', strength: 0.6 } },
7
+ },
8
+ {
9
+ id: 'game-boy', name: 'Game Boy', description: 'Four greens, 160 px wide, ordered dither.',
10
+ settings: { size: { width: 160 }, palette: { source: 'builtin', id: 'game-boy-dmg' }, dither: { method: 'bayer4', strength: 1 }, adjust: { contrast: 0.15 } },
11
+ },
12
+ {
13
+ id: 'pico-8', name: 'PICO-8', description: '128 px, the PICO-8 palette, blue-noise dither.',
14
+ settings: { size: { width: 128 }, palette: { source: 'builtin', id: 'pico-8' }, dither: { method: 'bluenoise', strength: 0.7 } },
15
+ },
16
+ {
17
+ id: 'one-bit', name: '1-bit Atkinson', description: 'Classic Mac look: two colours, Atkinson diffusion.',
18
+ settings: { size: { width: 192 }, palette: { source: 'builtin', id: '1-bit-monitor' }, dither: { method: 'atkinson', strength: 1 }, adjust: { contrast: 0.2 } },
19
+ },
20
+ {
21
+ id: 'faithful', name: 'Faithful colour', description: 'Palette extracted from the image, Floyd–Steinberg.',
22
+ settings: { size: { width: 128 }, palette: { source: 'extract', count: 24 }, dither: { method: 'floyd-steinberg', strength: 0.75 } },
23
+ },
24
+ {
25
+ id: 'sprite', name: 'Clean sprite', description: 'No dither, nearest resample, orphan cleanup and a dark outline. For art with transparency.',
26
+ settings: { size: { width: 48, resample: 'nearest' }, palette: { source: 'extract', count: 12 }, dither: { method: 'none' }, cleanup: { orphans: true }, outline: { mode: 'outer', color: '#1a1c2c' } },
27
+ },
28
+ {
29
+ id: 'cga', name: 'CGA', description: '16 hard colours with an 8×8 Bayer pattern.',
30
+ settings: { size: { width: 160 }, palette: { source: 'builtin', id: 'cga-16' }, dither: { method: 'bayer8', strength: 1 } },
31
+ },
32
+ {
33
+ id: 'newsprint', name: 'Newsprint', description: 'Grey clustered-dot halftone.',
34
+ settings: { size: { width: 220 }, palette: { source: 'builtin', id: 'grayscale-4' }, dither: { method: 'cluster8', strength: 1 }, adjust: { saturation: -1 } },
35
+ },
36
+ {
37
+ id: 'soft', name: 'Soft painterly', description: 'Resurrect 64 with Sierra Lite and a light smooth.',
38
+ settings: { size: { width: 112 }, palette: { source: 'builtin', id: 'resurrect-64' }, dither: { method: 'sierra-lite', strength: 0.6 }, cleanup: { smooth: true } },
39
+ },
40
+ ];
41
+
42
+ export const getPreset = id => PRESETS.find(p => p.id === id) || null;