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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -2
  3. package/dist/editor.css +1 -1
  4. package/dist/editor.css.map +2 -2
  5. package/dist/editor.esm.js +60 -60
  6. package/dist/editor.esm.js.map +4 -4
  7. package/dist/fonts/README.md +6 -6
  8. package/dist/fonts/geist-LICENSE.txt +93 -93
  9. package/dist/image-to-pixel.bundle.esm.js +16 -16
  10. package/dist/image-to-pixel.bundle.esm.js.map +3 -3
  11. package/dist/image-to-pixel.cjs +2 -2
  12. package/dist/image-to-pixel.cjs.map +2 -2
  13. package/dist/image-to-pixel.esm.js +2 -2
  14. package/dist/image-to-pixel.esm.js.map +2 -2
  15. package/dist/image-to-pixel.js +2 -2
  16. package/dist/image-to-pixel.js.map +2 -2
  17. package/dist/image-to-pixel.min.js +11 -11
  18. package/dist/image-to-pixel.min.js.map +3 -3
  19. package/dist/video.esm.js +2 -2
  20. package/dist/video.esm.js.map +4 -4
  21. package/lib/core/color.js +69 -69
  22. package/lib/core/cpu/pipeline.js +21 -21
  23. package/lib/core/cpu/stages.js +227 -227
  24. package/lib/core/dither/bluenoise-data.js +3 -3
  25. package/lib/core/dither/kernels.js +27 -27
  26. package/lib/core/dither/matrices.js +95 -95
  27. package/lib/core/engine.js +158 -158
  28. package/lib/core/export/canvas.js +22 -22
  29. package/lib/core/export/crc32.js +15 -15
  30. package/lib/core/export/gif.js +153 -153
  31. package/lib/core/export/png.js +94 -94
  32. package/lib/core/export/scale.js +103 -103
  33. package/lib/core/export/sheet.js +95 -95
  34. package/lib/core/export/zip.js +55 -55
  35. package/lib/core/gpu/backend.js +159 -159
  36. package/lib/core/gpu/shaders.js +258 -258
  37. package/lib/core/palette/builtin.js +36 -36
  38. package/lib/core/palette/extract.js +69 -69
  39. package/lib/core/palette/lospec.js +24 -24
  40. package/lib/core/palette/parse.js +112 -112
  41. package/lib/core/plan.js +58 -58
  42. package/lib/core/presets.js +42 -42
  43. package/lib/core/settings.js +274 -274
  44. package/lib/core/source.js +89 -89
  45. package/lib/core/video/audio.js +159 -0
  46. package/lib/core/video/frames.js +38 -0
  47. package/lib/core/video/mux-mp4.js +213 -24
  48. package/lib/core/video/mux-webm.js +63 -22
  49. package/lib/editor/controls.js +139 -139
  50. package/lib/editor/dom.js +65 -65
  51. package/lib/editor/exporter.js +43 -3
  52. package/lib/editor/icons.js +4 -0
  53. package/lib/editor/index.js +218 -9
  54. package/lib/editor/inspector.js +19 -4
  55. package/lib/editor/library.js +8 -2
  56. package/lib/editor/persist.js +18 -18
  57. package/lib/editor/samples.js +90 -90
  58. package/lib/editor/stage.js +239 -239
  59. package/lib/editor/store.js +80 -80
  60. package/lib/editor/styles/editor.css +34 -1
  61. package/lib/editor/styles/fonts/README.md +6 -6
  62. package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
  63. package/lib/editor/styles/tokens.css +89 -89
  64. package/lib/editor/ui.js +97 -97
  65. package/lib/editor/workspace.js +62 -0
  66. package/lib/index.js +31 -29
  67. package/lib/video.js +37 -7
  68. package/package.json +1 -1
  69. package/types/editor.d.ts +34 -34
  70. package/types/index.d.ts +196 -196
  71. package/types/video.d.ts +61 -5
@@ -1,274 +1,274 @@
1
- // The settings object is the one contract between the engine, the editor, presets,
2
- // share links and saved JSON. Everything goes through normalizeSettings().
3
-
4
- import { getPalette } from './palette/builtin.js';
5
- import { normHex, isColor } from './color.js';
6
-
7
- export const SETTINGS_VERSION = 1;
8
- export const MAX_SIZE = 2048;
9
-
10
- /** Dither methods. `kind` decides which pipeline runs them. */
11
- export const DITHER_METHODS = [
12
- { id: 'none', label: 'None', kind: 'none' },
13
- { id: 'bayer2', label: 'Bayer 2×2', kind: 'ordered' },
14
- { id: 'bayer4', label: 'Bayer 4×4', kind: 'ordered' },
15
- { id: 'bayer8', label: 'Bayer 8×8', kind: 'ordered' },
16
- { id: 'bayer16', label: 'Bayer 16×16', kind: 'ordered' },
17
- { id: 'cluster4', label: 'Clustered dot 4×4', kind: 'ordered' },
18
- { id: 'cluster8', label: 'Clustered dot 8×8', kind: 'ordered' },
19
- { id: 'lines', label: 'Horizontal lines', kind: 'ordered' },
20
- { id: 'bluenoise', label: 'Blue noise', kind: 'ordered' },
21
- { id: 'custom', label: 'Custom matrix', kind: 'ordered' },
22
- { id: 'floyd-steinberg', label: 'Floyd–Steinberg', kind: 'diffusion' },
23
- { id: 'atkinson', label: 'Atkinson', kind: 'diffusion' },
24
- { id: 'sierra-lite', label: 'Sierra Lite', kind: 'diffusion' },
25
- { id: 'sierra', label: 'Sierra', kind: 'diffusion' },
26
- { id: 'burkes', label: 'Burkes', kind: 'diffusion' },
27
- { id: 'jarvis', label: 'Jarvis–Judice–Ninke', kind: 'diffusion' },
28
- { id: 'stucki', label: 'Stucki', kind: 'diffusion' },
29
- ];
30
- export const ditherKind = id => (DITHER_METHODS.find(m => m.id === id) || { kind: 'none' }).kind;
31
-
32
- /** Names the classic image-to-pixel.js used, mapped to the new ids. */
33
- export const LEGACY_DITHER = {
34
- 'none': 'none', 'floyd-steinberg': 'floyd-steinberg', 'atkinson': 'atkinson',
35
- 'ordered': 'bayer8', '2x2 bayer': 'bayer2', '4x4 bayer': 'bayer4', 'clustered 4x4': 'cluster4',
36
- };
37
-
38
- export const DEFAULTS = Object.freeze({
39
- version: SETTINGS_VERSION,
40
- size: { mode: 'width', width: 96, height: 0, block: 8, resample: 'box' },
41
- adjust: { brightness: 0, contrast: 0, saturation: 0, hue: 0, gamma: 1 },
42
- frame: { aspect: 'original', zoom: 1, x: 0.5, y: 0.5 },
43
- palette: { source: 'builtin', id: 'endesga-32', name: 'Endesga 32', colors: [], count: 16, distance: 'oklab' },
44
- dither: { method: 'bayer4', strength: 0.6, matrix: null },
45
- alpha: { threshold: 0.5 },
46
- cleanup: { orphans: false, smooth: false },
47
- outline: { mode: 'none', color: '#000000', corners: false },
48
- export: { size: 'scale', scale: 4, indexed: true, grid: false, gridColor: '#000000' },
49
- });
50
-
51
- const clamp = (v, lo, hi, d) => {
52
- const n = Number(v);
53
- return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
54
- };
55
- const oneOf = (v, list, d) => (list.includes(v) ? v : d);
56
- const bool = (v, d) => (typeof v === 'boolean' ? v : d);
57
-
58
- /** Deep copy of the defaults (palette colours resolved). */
59
- export function defaultSettings() {
60
- return normalizeSettings({});
61
- }
62
-
63
- /**
64
- * Fill in defaults, clamp ranges, resolve built-in palette colours and drop unknown keys.
65
- * Never throws on bad values; it falls back to the default for that field.
66
- * @param {object} input partial settings
67
- */
68
- export function normalizeSettings(input = {}) {
69
- const d = DEFAULTS;
70
- const i = input || {};
71
- const s = {}; const size = i.size || {}; const adj = i.adjust || {}; const pal = i.palette || {};
72
- const dit = i.dither || {}; const al = i.alpha || {}; const cl = i.cleanup || {}; const ol = i.outline || {};
73
- const ex = i.export || {};
74
-
75
- s.version = SETTINGS_VERSION;
76
- s.size = {
77
- mode: oneOf(size.mode, ['width', 'block'], d.size.mode),
78
- width: Math.round(clamp(size.width, 1, MAX_SIZE, d.size.width)),
79
- height: Math.round(clamp(size.height, 0, MAX_SIZE, d.size.height)),
80
- block: clamp(size.block, 1, 256, d.size.block),
81
- resample: oneOf(size.resample, ['box', 'nearest'], d.size.resample),
82
- };
83
- s.adjust = {
84
- brightness: clamp(adj.brightness, -1, 1, 0),
85
- contrast: clamp(adj.contrast, -1, 1, 0),
86
- saturation: clamp(adj.saturation, -1, 1, 0),
87
- hue: clamp(adj.hue, -180, 180, 0),
88
- gamma: clamp(adj.gamma, 0.2, 5, 1),
89
- };
90
- const fr = i.frame || {};
91
- s.frame = {
92
- aspect: oneOf(fr.aspect, ASPECTS.map(a => a.id), 'original'),
93
- zoom: clamp(fr.zoom, 1, 8, 1),
94
- x: clamp(fr.x, 0, 1, 0.5),
95
- y: clamp(fr.y, 0, 1, 0.5),
96
- };
97
-
98
- const source = oneOf(pal.source, ['builtin', 'custom', 'extract', 'none'], d.palette.source);
99
- s.palette = {
100
- source,
101
- id: typeof pal.id === 'string' ? pal.id : (source === 'builtin' ? d.palette.id : ''),
102
- name: typeof pal.name === 'string' ? pal.name : '',
103
- colors: [],
104
- count: Math.round(clamp(pal.count, 2, 256, d.palette.count)),
105
- distance: oneOf(pal.distance, ['oklab', 'rgb'], d.palette.distance),
106
- };
107
- if (source === 'builtin') {
108
- const p = getPalette(s.palette.id) || getPalette(d.palette.id);
109
- s.palette.id = p.id; s.palette.name = p.name; s.palette.colors = p.colors.map(c => '#' + c);
110
- } else if (source === 'custom') {
111
- s.palette.colors = (Array.isArray(pal.colors) ? pal.colors : []).filter(isColor).map(normHex).slice(0, 256);
112
- if (!s.palette.colors.length) s.palette.colors = ['#000000', '#ffffff'];
113
- s.palette.name = s.palette.name || 'Custom';
114
- } else if (source === 'extract') {
115
- s.palette.name = `Extracted (${s.palette.count})`;
116
- } else {
117
- s.palette.name = 'None';
118
- }
119
-
120
- const method = DITHER_METHODS.some(m => m.id === dit.method) ? dit.method : d.dither.method;
121
- s.dither = {
122
- method,
123
- strength: clamp(dit.strength, 0, 1, d.dither.strength),
124
- matrix: normalizeMatrix(dit.matrix),
125
- };
126
- s.alpha = { threshold: clamp(al.threshold, 0, 1, d.alpha.threshold) };
127
- s.cleanup = { orphans: bool(cl.orphans, false), smooth: bool(cl.smooth, false) };
128
- s.outline = {
129
- mode: oneOf(ol.mode, ['none', 'outer', 'inner'], 'none'),
130
- color: isColor(ol.color) ? normHex(ol.color) : d.outline.color,
131
- corners: bool(ol.corners, false),
132
- };
133
- s.export = {
134
- size: oneOf(ex.size, ['scale', 'source'], 'scale'),
135
- scale: Math.round(clamp(ex.scale, 1, 64, d.export.scale)),
136
- indexed: bool(ex.indexed, d.export.indexed),
137
- grid: bool(ex.grid, false),
138
- gridColor: isColor(ex.gridColor) ? normHex(ex.gridColor) : d.export.gridColor,
139
- };
140
- return s;
141
- }
142
-
143
- /** A custom threshold matrix: 2D array of numbers. Ranks are derived from the order of the values. */
144
- export function normalizeMatrix(m) {
145
- if (!Array.isArray(m) || !m.length || !Array.isArray(m[0])) return null;
146
- const h = Math.min(16, m.length), w = Math.min(16, m[0].length);
147
- const out = [];
148
- for (let y = 0; y < h; y++) {
149
- const row = [];
150
- for (let x = 0; x < w; x++) { const v = Number((m[y] || [])[x]); row.push(Number.isFinite(v) ? v : 0); }
151
- out.push(row);
152
- }
153
- return out;
154
- }
155
-
156
- /** Frame aspect ratios. `ratio` is width / height; null keeps the source's. */
157
- export const ASPECTS = [
158
- { id: 'original', label: 'Original', ratio: null },
159
- { id: '1:1', label: '1:1', ratio: 1 },
160
- { id: '4:3', label: '4:3', ratio: 4 / 3 },
161
- { id: '3:4', label: '3:4', ratio: 3 / 4 },
162
- { id: '3:2', label: '3:2', ratio: 3 / 2 },
163
- { id: '2:3', label: '2:3', ratio: 2 / 3 },
164
- { id: '16:9', label: '16:9', ratio: 16 / 9 },
165
- { id: '9:16', label: '9:16', ratio: 9 / 16 },
166
- ];
167
-
168
- /**
169
- * The part of a srcW × srcH source that `frame` keeps, in whole source pixels.
170
- * The largest rectangle of the chosen aspect that fits, divided by zoom, placed by x/y (0 = left/top, 1 = right/bottom).
171
- */
172
- export function cropRect(srcW, srcH, frame = {}) {
173
- const a = (ASPECTS.find(x => x.id === frame.aspect) || ASPECTS[0]).ratio || srcW / srcH;
174
- let w = srcW, h = srcW / a;
175
- if (h > srcH) { h = srcH; w = srcH * a; }
176
- const zoom = Math.max(1, frame.zoom || 1);
177
- w = Math.max(1, Math.min(srcW, Math.round(w / zoom)));
178
- h = Math.max(1, Math.min(srcH, Math.round(h / zoom)));
179
- const fx = frame.x ?? 0.5, fy = frame.y ?? 0.5;
180
- return { x: Math.round((srcW - w) * fx), y: Math.round((srcH - h) * fy), w, h };
181
- }
182
-
183
- /** Output size in pixels for a source of srcW × srcH. */
184
- export function resolveSize(srcW, srcH, size) {
185
- let w, h;
186
- if (size.mode === 'block') {
187
- w = Math.max(1, Math.round(srcW / size.block));
188
- h = Math.max(1, Math.round(srcH / size.block));
189
- } else {
190
- w = size.width;
191
- h = size.height || Math.max(1, Math.round(w * srcH / srcW));
192
- }
193
- return { width: Math.min(MAX_SIZE, w), height: Math.min(MAX_SIZE, h) };
194
- }
195
-
196
- // ---- sharing ---------------------------------------------------------------
197
-
198
- function diff(a, b) {
199
- // keys of `a` whose value differs from `b` (one level deep per group)
200
- const out = {};
201
- for (const k of Object.keys(a)) {
202
- if (k === 'version') continue;
203
- const va = a[k], vb = b[k];
204
- if (va && typeof va === 'object' && !Array.isArray(va)) {
205
- const g = {};
206
- for (const kk of Object.keys(va)) if (JSON.stringify(va[kk]) !== JSON.stringify(vb && vb[kk])) g[kk] = va[kk];
207
- if (Object.keys(g).length) out[k] = g;
208
- } else if (JSON.stringify(va) !== JSON.stringify(vb)) out[k] = va;
209
- }
210
- return out;
211
- }
212
-
213
- const b64url = {
214
- enc(str) {
215
- const bytes = new TextEncoder().encode(str);
216
- let bin = ''; for (const b of bytes) bin += String.fromCharCode(b);
217
- return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
218
- },
219
- dec(s) {
220
- const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
221
- return new TextDecoder().decode(Uint8Array.from(bin, c => c.charCodeAt(0)));
222
- },
223
- };
224
-
225
- /** Settings → the smallest JSON that rebuilds them (only what differs from the defaults). */
226
- export function compactSettings(settings) {
227
- const s = normalizeSettings(settings);
228
- const out = diff(s, defaultSettings());
229
- if (out.palette) {
230
- if (s.palette.source !== 'custom') { delete out.palette.colors; delete out.palette.name; } // a built-in id is enough
231
- if (s.palette.source !== 'builtin') delete out.palette.id;
232
- if (s.palette.source !== 'extract') delete out.palette.count;
233
- }
234
- if (out.palette && !Object.keys(out.palette).length) delete out.palette;
235
- return out;
236
- }
237
-
238
- /** Settings → URL-safe string for `#s=…`. */
239
- export function encodeSettings(settings) {
240
- return b64url.enc(JSON.stringify({ v: SETTINGS_VERSION, ...compactSettings(settings) }));
241
- }
242
-
243
- /** `#s=…` string (or a whole URL / hash) → settings. Throws on garbage. */
244
- export function decodeSettings(str) {
245
- let s = String(str);
246
- const m = /[#&?]s=([A-Za-z0-9_-]+)/.exec(s);
247
- if (m) s = m[1];
248
- const obj = JSON.parse(b64url.dec(s));
249
- delete obj.v;
250
- return normalizeSettings(obj);
251
- }
252
-
253
- /** Settings → pretty JSON for saving to a file. */
254
- export function settingsToJSON(settings) {
255
- return JSON.stringify({ $schema: 'image-to-pixel/settings', version: SETTINGS_VERSION, ...compactSettings(settings) }, null, 2);
256
- }
257
-
258
- /** JSON text → settings. Accepts a full or partial settings object. */
259
- export function settingsFromJSON(text) {
260
- const obj = typeof text === 'string' ? JSON.parse(text) : text;
261
- delete obj.$schema;
262
- return normalizeSettings(obj);
263
- }
264
-
265
- /** Apply a partial patch on top of settings (one level deep per group). */
266
- export function mergeSettings(base, patch) {
267
- const out = JSON.parse(JSON.stringify(base));
268
- for (const k of Object.keys(patch || {})) {
269
- if (patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k]) && out[k] && typeof out[k] === 'object') {
270
- Object.assign(out[k], patch[k]);
271
- } else out[k] = patch[k];
272
- }
273
- return normalizeSettings(out);
274
- }
1
+ // The settings object is the one contract between the engine, the editor, presets,
2
+ // share links and saved JSON. Everything goes through normalizeSettings().
3
+
4
+ import { getPalette } from './palette/builtin.js';
5
+ import { normHex, isColor } from './color.js';
6
+
7
+ export const SETTINGS_VERSION = 1;
8
+ export const MAX_SIZE = 2048;
9
+
10
+ /** Dither methods. `kind` decides which pipeline runs them. */
11
+ export const DITHER_METHODS = [
12
+ { id: 'none', label: 'None', kind: 'none' },
13
+ { id: 'bayer2', label: 'Bayer 2×2', kind: 'ordered' },
14
+ { id: 'bayer4', label: 'Bayer 4×4', kind: 'ordered' },
15
+ { id: 'bayer8', label: 'Bayer 8×8', kind: 'ordered' },
16
+ { id: 'bayer16', label: 'Bayer 16×16', kind: 'ordered' },
17
+ { id: 'cluster4', label: 'Clustered dot 4×4', kind: 'ordered' },
18
+ { id: 'cluster8', label: 'Clustered dot 8×8', kind: 'ordered' },
19
+ { id: 'lines', label: 'Horizontal lines', kind: 'ordered' },
20
+ { id: 'bluenoise', label: 'Blue noise', kind: 'ordered' },
21
+ { id: 'custom', label: 'Custom matrix', kind: 'ordered' },
22
+ { id: 'floyd-steinberg', label: 'Floyd–Steinberg', kind: 'diffusion' },
23
+ { id: 'atkinson', label: 'Atkinson', kind: 'diffusion' },
24
+ { id: 'sierra-lite', label: 'Sierra Lite', kind: 'diffusion' },
25
+ { id: 'sierra', label: 'Sierra', kind: 'diffusion' },
26
+ { id: 'burkes', label: 'Burkes', kind: 'diffusion' },
27
+ { id: 'jarvis', label: 'Jarvis–Judice–Ninke', kind: 'diffusion' },
28
+ { id: 'stucki', label: 'Stucki', kind: 'diffusion' },
29
+ ];
30
+ export const ditherKind = id => (DITHER_METHODS.find(m => m.id === id) || { kind: 'none' }).kind;
31
+
32
+ /** Names the classic image-to-pixel.js used, mapped to the new ids. */
33
+ export const LEGACY_DITHER = {
34
+ 'none': 'none', 'floyd-steinberg': 'floyd-steinberg', 'atkinson': 'atkinson',
35
+ 'ordered': 'bayer8', '2x2 bayer': 'bayer2', '4x4 bayer': 'bayer4', 'clustered 4x4': 'cluster4',
36
+ };
37
+
38
+ export const DEFAULTS = Object.freeze({
39
+ version: SETTINGS_VERSION,
40
+ size: { mode: 'width', width: 96, height: 0, block: 8, resample: 'box' },
41
+ adjust: { brightness: 0, contrast: 0, saturation: 0, hue: 0, gamma: 1 },
42
+ frame: { aspect: 'original', zoom: 1, x: 0.5, y: 0.5 },
43
+ palette: { source: 'builtin', id: 'endesga-32', name: 'Endesga 32', colors: [], count: 16, distance: 'oklab' },
44
+ dither: { method: 'bayer4', strength: 0.6, matrix: null },
45
+ alpha: { threshold: 0.5 },
46
+ cleanup: { orphans: false, smooth: false },
47
+ outline: { mode: 'none', color: '#000000', corners: false },
48
+ export: { size: 'scale', scale: 4, indexed: true, grid: false, gridColor: '#000000' },
49
+ });
50
+
51
+ const clamp = (v, lo, hi, d) => {
52
+ const n = Number(v);
53
+ return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d;
54
+ };
55
+ const oneOf = (v, list, d) => (list.includes(v) ? v : d);
56
+ const bool = (v, d) => (typeof v === 'boolean' ? v : d);
57
+
58
+ /** Deep copy of the defaults (palette colours resolved). */
59
+ export function defaultSettings() {
60
+ return normalizeSettings({});
61
+ }
62
+
63
+ /**
64
+ * Fill in defaults, clamp ranges, resolve built-in palette colours and drop unknown keys.
65
+ * Never throws on bad values; it falls back to the default for that field.
66
+ * @param {object} input partial settings
67
+ */
68
+ export function normalizeSettings(input = {}) {
69
+ const d = DEFAULTS;
70
+ const i = input || {};
71
+ const s = {}; const size = i.size || {}; const adj = i.adjust || {}; const pal = i.palette || {};
72
+ const dit = i.dither || {}; const al = i.alpha || {}; const cl = i.cleanup || {}; const ol = i.outline || {};
73
+ const ex = i.export || {};
74
+
75
+ s.version = SETTINGS_VERSION;
76
+ s.size = {
77
+ mode: oneOf(size.mode, ['width', 'block'], d.size.mode),
78
+ width: Math.round(clamp(size.width, 1, MAX_SIZE, d.size.width)),
79
+ height: Math.round(clamp(size.height, 0, MAX_SIZE, d.size.height)),
80
+ block: clamp(size.block, 1, 256, d.size.block),
81
+ resample: oneOf(size.resample, ['box', 'nearest'], d.size.resample),
82
+ };
83
+ s.adjust = {
84
+ brightness: clamp(adj.brightness, -1, 1, 0),
85
+ contrast: clamp(adj.contrast, -1, 1, 0),
86
+ saturation: clamp(adj.saturation, -1, 1, 0),
87
+ hue: clamp(adj.hue, -180, 180, 0),
88
+ gamma: clamp(adj.gamma, 0.2, 5, 1),
89
+ };
90
+ const fr = i.frame || {};
91
+ s.frame = {
92
+ aspect: oneOf(fr.aspect, ASPECTS.map(a => a.id), 'original'),
93
+ zoom: clamp(fr.zoom, 1, 8, 1),
94
+ x: clamp(fr.x, 0, 1, 0.5),
95
+ y: clamp(fr.y, 0, 1, 0.5),
96
+ };
97
+
98
+ const source = oneOf(pal.source, ['builtin', 'custom', 'extract', 'none'], d.palette.source);
99
+ s.palette = {
100
+ source,
101
+ id: typeof pal.id === 'string' ? pal.id : (source === 'builtin' ? d.palette.id : ''),
102
+ name: typeof pal.name === 'string' ? pal.name : '',
103
+ colors: [],
104
+ count: Math.round(clamp(pal.count, 2, 256, d.palette.count)),
105
+ distance: oneOf(pal.distance, ['oklab', 'rgb'], d.palette.distance),
106
+ };
107
+ if (source === 'builtin') {
108
+ const p = getPalette(s.palette.id) || getPalette(d.palette.id);
109
+ s.palette.id = p.id; s.palette.name = p.name; s.palette.colors = p.colors.map(c => '#' + c);
110
+ } else if (source === 'custom') {
111
+ s.palette.colors = (Array.isArray(pal.colors) ? pal.colors : []).filter(isColor).map(normHex).slice(0, 256);
112
+ if (!s.palette.colors.length) s.palette.colors = ['#000000', '#ffffff'];
113
+ s.palette.name = s.palette.name || 'Custom';
114
+ } else if (source === 'extract') {
115
+ s.palette.name = `Extracted (${s.palette.count})`;
116
+ } else {
117
+ s.palette.name = 'None';
118
+ }
119
+
120
+ const method = DITHER_METHODS.some(m => m.id === dit.method) ? dit.method : d.dither.method;
121
+ s.dither = {
122
+ method,
123
+ strength: clamp(dit.strength, 0, 1, d.dither.strength),
124
+ matrix: normalizeMatrix(dit.matrix),
125
+ };
126
+ s.alpha = { threshold: clamp(al.threshold, 0, 1, d.alpha.threshold) };
127
+ s.cleanup = { orphans: bool(cl.orphans, false), smooth: bool(cl.smooth, false) };
128
+ s.outline = {
129
+ mode: oneOf(ol.mode, ['none', 'outer', 'inner'], 'none'),
130
+ color: isColor(ol.color) ? normHex(ol.color) : d.outline.color,
131
+ corners: bool(ol.corners, false),
132
+ };
133
+ s.export = {
134
+ size: oneOf(ex.size, ['scale', 'source'], 'scale'),
135
+ scale: Math.round(clamp(ex.scale, 1, 64, d.export.scale)),
136
+ indexed: bool(ex.indexed, d.export.indexed),
137
+ grid: bool(ex.grid, false),
138
+ gridColor: isColor(ex.gridColor) ? normHex(ex.gridColor) : d.export.gridColor,
139
+ };
140
+ return s;
141
+ }
142
+
143
+ /** A custom threshold matrix: 2D array of numbers. Ranks are derived from the order of the values. */
144
+ export function normalizeMatrix(m) {
145
+ if (!Array.isArray(m) || !m.length || !Array.isArray(m[0])) return null;
146
+ const h = Math.min(16, m.length), w = Math.min(16, m[0].length);
147
+ const out = [];
148
+ for (let y = 0; y < h; y++) {
149
+ const row = [];
150
+ for (let x = 0; x < w; x++) { const v = Number((m[y] || [])[x]); row.push(Number.isFinite(v) ? v : 0); }
151
+ out.push(row);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /** Frame aspect ratios. `ratio` is width / height; null keeps the source's. */
157
+ export const ASPECTS = [
158
+ { id: 'original', label: 'Original', ratio: null },
159
+ { id: '1:1', label: '1:1', ratio: 1 },
160
+ { id: '4:3', label: '4:3', ratio: 4 / 3 },
161
+ { id: '3:4', label: '3:4', ratio: 3 / 4 },
162
+ { id: '3:2', label: '3:2', ratio: 3 / 2 },
163
+ { id: '2:3', label: '2:3', ratio: 2 / 3 },
164
+ { id: '16:9', label: '16:9', ratio: 16 / 9 },
165
+ { id: '9:16', label: '9:16', ratio: 9 / 16 },
166
+ ];
167
+
168
+ /**
169
+ * The part of a srcW × srcH source that `frame` keeps, in whole source pixels.
170
+ * The largest rectangle of the chosen aspect that fits, divided by zoom, placed by x/y (0 = left/top, 1 = right/bottom).
171
+ */
172
+ export function cropRect(srcW, srcH, frame = {}) {
173
+ const a = (ASPECTS.find(x => x.id === frame.aspect) || ASPECTS[0]).ratio || srcW / srcH;
174
+ let w = srcW, h = srcW / a;
175
+ if (h > srcH) { h = srcH; w = srcH * a; }
176
+ const zoom = Math.max(1, frame.zoom || 1);
177
+ w = Math.max(1, Math.min(srcW, Math.round(w / zoom)));
178
+ h = Math.max(1, Math.min(srcH, Math.round(h / zoom)));
179
+ const fx = frame.x ?? 0.5, fy = frame.y ?? 0.5;
180
+ return { x: Math.round((srcW - w) * fx), y: Math.round((srcH - h) * fy), w, h };
181
+ }
182
+
183
+ /** Output size in pixels for a source of srcW × srcH. */
184
+ export function resolveSize(srcW, srcH, size) {
185
+ let w, h;
186
+ if (size.mode === 'block') {
187
+ w = Math.max(1, Math.round(srcW / size.block));
188
+ h = Math.max(1, Math.round(srcH / size.block));
189
+ } else {
190
+ w = size.width;
191
+ h = size.height || Math.max(1, Math.round(w * srcH / srcW));
192
+ }
193
+ return { width: Math.min(MAX_SIZE, w), height: Math.min(MAX_SIZE, h) };
194
+ }
195
+
196
+ // ---- sharing ---------------------------------------------------------------
197
+
198
+ function diff(a, b) {
199
+ // keys of `a` whose value differs from `b` (one level deep per group)
200
+ const out = {};
201
+ for (const k of Object.keys(a)) {
202
+ if (k === 'version') continue;
203
+ const va = a[k], vb = b[k];
204
+ if (va && typeof va === 'object' && !Array.isArray(va)) {
205
+ const g = {};
206
+ for (const kk of Object.keys(va)) if (JSON.stringify(va[kk]) !== JSON.stringify(vb && vb[kk])) g[kk] = va[kk];
207
+ if (Object.keys(g).length) out[k] = g;
208
+ } else if (JSON.stringify(va) !== JSON.stringify(vb)) out[k] = va;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ const b64url = {
214
+ enc(str) {
215
+ const bytes = new TextEncoder().encode(str);
216
+ let bin = ''; for (const b of bytes) bin += String.fromCharCode(b);
217
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
218
+ },
219
+ dec(s) {
220
+ const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
221
+ return new TextDecoder().decode(Uint8Array.from(bin, c => c.charCodeAt(0)));
222
+ },
223
+ };
224
+
225
+ /** Settings → the smallest JSON that rebuilds them (only what differs from the defaults). */
226
+ export function compactSettings(settings) {
227
+ const s = normalizeSettings(settings);
228
+ const out = diff(s, defaultSettings());
229
+ if (out.palette) {
230
+ if (s.palette.source !== 'custom') { delete out.palette.colors; delete out.palette.name; } // a built-in id is enough
231
+ if (s.palette.source !== 'builtin') delete out.palette.id;
232
+ if (s.palette.source !== 'extract') delete out.palette.count;
233
+ }
234
+ if (out.palette && !Object.keys(out.palette).length) delete out.palette;
235
+ return out;
236
+ }
237
+
238
+ /** Settings → URL-safe string for `#s=…`. */
239
+ export function encodeSettings(settings) {
240
+ return b64url.enc(JSON.stringify({ v: SETTINGS_VERSION, ...compactSettings(settings) }));
241
+ }
242
+
243
+ /** `#s=…` string (or a whole URL / hash) → settings. Throws on garbage. */
244
+ export function decodeSettings(str) {
245
+ let s = String(str);
246
+ const m = /[#&?]s=([A-Za-z0-9_-]+)/.exec(s);
247
+ if (m) s = m[1];
248
+ const obj = JSON.parse(b64url.dec(s));
249
+ delete obj.v;
250
+ return normalizeSettings(obj);
251
+ }
252
+
253
+ /** Settings → pretty JSON for saving to a file. */
254
+ export function settingsToJSON(settings) {
255
+ return JSON.stringify({ $schema: 'image-to-pixel/settings', version: SETTINGS_VERSION, ...compactSettings(settings) }, null, 2);
256
+ }
257
+
258
+ /** JSON text → settings. Accepts a full or partial settings object. */
259
+ export function settingsFromJSON(text) {
260
+ const obj = typeof text === 'string' ? JSON.parse(text) : text;
261
+ delete obj.$schema;
262
+ return normalizeSettings(obj);
263
+ }
264
+
265
+ /** Apply a partial patch on top of settings (one level deep per group). */
266
+ export function mergeSettings(base, patch) {
267
+ const out = JSON.parse(JSON.stringify(base));
268
+ for (const k of Object.keys(patch || {})) {
269
+ if (patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k]) && out[k] && typeof out[k] === 'object') {
270
+ Object.assign(out[k], patch[k]);
271
+ } else out[k] = patch[k];
272
+ }
273
+ return normalizeSettings(out);
274
+ }