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,793 +1,793 @@
1
- // The editor UI. mountEditor(element) builds the whole app inside `element`.
2
- // The core engine (lib/core) has no idea this file exists.
3
-
4
- import { createPixelator } from '../core/engine.js';
5
- import { defaultSettings, decodeSettings, encodeSettings, settingsToJSON, settingsFromJSON, mergeSettings } from '../core/settings.js';
6
- import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/parse.js';
7
- import { fetchLospec } from '../core/palette/lospec.js';
8
- import { detectGrid } from '../core/export/scale.js';
9
- import { toHex } from '../core/color.js';
10
- import { estimateFps, videoReady, seekTo, sniffVideoCodec } from '../core/video/frames.js';
11
- import { downloadBlob } from '../core/export/canvas.js';
12
- import { PRESETS } from '../core/presets.js';
13
- import { h, render, pickFiles } from './dom.js';
14
- import { icon } from './icons.js';
15
- import { createStore } from './store.js';
16
- import { createStage } from './stage.js';
17
- import { createInspector } from './inspector.js';
18
- import { createLibrary, fmtTime } from './library.js';
19
- import { createExporter } from './exporter.js';
20
- import { toast, menu, closeMenu, dialog } from './ui.js';
21
- import { SAMPLES, loadSample, sampleURL } from './samples.js';
22
- import * as persist from './persist.js';
23
-
24
- const IMAGE_TYPES = /^image\//;
25
- const VIDEO_TYPES = /^video\//;
26
- const PALETTE_EXT = /\.(gpl|hex|pal|txt|json)$/i;
27
-
28
- /**
29
- * Mount the editor.
30
- * @param {HTMLElement} root
31
- * @param {{ settings?: object, persist?: boolean, samples?: boolean, homeHref?: string, docsHref?: string }} [options]
32
- */
33
- export async function mountEditor(root, options = {}) {
34
- const usePersist = options.persist !== false;
35
- const narrow = () => matchMedia('(max-width: 900px)').matches;
36
-
37
- // ---- initial settings: share link > saved > defaults
38
- let initial = options.settings || (usePersist && persist.load('settings', null)) || defaultSettings();
39
- if (/[#&]s=/.test(location.hash)) {
40
- try { initial = decodeSettings(location.hash); } catch { toast('That share link could not be read; using your last settings', { kind: 'error' }); }
41
- }
42
- const store = createStore(initial);
43
-
44
- const app = {
45
- store,
46
- engine: null,
47
- items: [],
48
- current: null,
49
- result: null,
50
- exporting: false,
51
- activePreset: null,
52
- userPalettes: usePersist ? persist.load('palettes', []) : [],
53
- /** Settings shared by every file that doesn't have its own. */
54
- shared: store.settings,
55
- /** Settings copied with "Copy settings", ready to paste onto another file. */
56
- clipboard: null,
57
- videoSupport: { mp4: false, webm: false },
58
- toast: (m, o) => toast(m, o),
59
- };
60
-
61
- // ================================================================ layout
62
- const stage = createStage({ onPick: pickAt, onViewChange: () => updateZoomLabel() });
63
- const inspector = createInspector(app);
64
- const library = createLibrary(app);
65
- const exporter = createExporter(app);
66
-
67
- const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
68
-
69
- const fileName = h('span.name', { text: 'No image' });
70
- const fileMeta = h('span.meta.num', {});
71
- const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
72
- const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
73
- const compareBtn = btn('compare', 'Compare with original (C)', () => toggleCompare(), { 'aria-pressed': 'false' });
74
- const gridBtn = btn('grid', 'Pixel grid (G)', () => toggleGrid(), { 'aria-pressed': 'false' });
75
- const zoomBtn = h('button.itp-btn.itp-zoom.num', { type: 'button', 'aria-label': 'Zoom', 'aria-haspopup': 'menu', html: `<span>Fit</span>${icon('chevronDown')}`, onclick: e => zoomMenu(e.currentTarget) });
76
- const themeBtn = btn('moon', 'Toggle theme (T)', () => toggleTheme());
77
- const libBtn = btn('sidebarLeft', 'Toggle library (\\)', () => togglePanel('lib'));
78
- const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
79
- const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
80
- const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
81
- const brand = h('a.itp-brand', { href: options.homeHref || '#', title: 'Image-to-Pixel' }, h('span.itp-mark', { 'aria-hidden': 'true' }, ...Array.from({ length: 9 }, () => h('i'))), h('span.hide-sm', { text: 'Image-to-Pixel' }));
82
- if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
83
-
84
- const top = h('header.itp-top', {},
85
- libBtn, brand, h('span.sep.hide-sm'),
86
- h('div.itp-file', {}, fileName, fileMeta),
87
- h('div.spacer'),
88
- h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
89
- compareBtn, gridBtn, zoomBtn,
90
- h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
91
- h('span', { style: { width: '6px' } }), exportBtn);
92
-
93
- // stage extras
94
- const sampleButtons = options.samples === false ? null : h('div.samples', {}, ...SAMPLES.map(s => h('button', { type: 'button', title: `Try "${s.name}"`, 'aria-label': `Open sample: ${s.name}`, onclick: () => openSample(s) }, h('img', { src: sampleURL(s), alt: '' }))));
95
- const empty = h('div.itp-empty', {}, h('div.itp-drop', {},
96
- h('h2', { text: 'Drop an image or a video' }),
97
- h('p', { text: 'PNG, JPG, WebP, GIF, SVG, MP4 or WebM. Add several files to process them as a batch. You can also paste with Ctrl+V.' }),
98
- h('div.actions', {},
99
- h('button.itp-btn.primary', { type: 'button', html: `${icon('upload')}<span>Choose files</span>`, onclick: () => openFiles() }),
100
- h('button.itp-btn.outline', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, onclick: () => pasteFromClipboard() })),
101
- sampleButtons ? h('p', { style: { fontSize: 'var(--fs-xs)', color: 'var(--faint)' }, text: 'Or start from a sample' }) : null,
102
- sampleButtons));
103
- const status = h('div.itp-float.bl', { 'aria-live': 'polite' });
104
- const busy = h('div.itp-busy.itp-float', { hidden: true, style: { padding: '6px 10px', alignItems: 'center', gap: '8px', fontSize: 'var(--fs-xs)', color: 'var(--muted)', display: 'flex' } }, h('span.itp-spinner'), h('span', { text: 'Rendering' }));
105
- const viewTools = h('div.itp-float.br', {},
106
- btn('zoomOut', 'Zoom out (-)', () => stage.zoomOut()),
107
- btn('fit', 'Fit to view (F)', () => stage.fit()),
108
- btn('zoomIn', 'Zoom in (+)', () => stage.zoomIn()));
109
- stage.el.append(empty, status, busy, viewTools);
110
-
111
- // transport
112
- const playBtn = btn('play', 'Play (Space)', () => togglePlay());
113
- const timeLabel = h('span.itp-time', {});
114
- const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
115
- const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
116
- const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
117
- btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
118
- timeLabel, scrub, fpsLabel);
119
- const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, transport);
120
-
121
- // on narrow screens the library is a drawer: a tap on the canvas closes it
122
- stageWrap.addEventListener('pointerdown', () => { if (narrow() && shell.dataset.lib === 'open') togglePanel('lib'); }, true);
123
- const shell = h('div.itp.itp-app', { 'data-lib': narrow() ? 'closed' : persist.load('lib', 'open'), 'data-insp': persist.load('insp', 'open') }, top, library.el, stageWrap, inspector.el);
124
- render(root, shell);
125
- applyTheme(persist.load('theme', null));
126
-
127
- // ================================================================ engine
128
- status.textContent = 'Starting…';
129
- try {
130
- app.engine = await createPixelator();
131
- } catch (e) {
132
- toast(`Could not start: ${e.message}`, { kind: 'error' });
133
- throw e;
134
- }
135
- inspector.el.querySelector('#itp-backend').textContent = app.engine.backend === 'webgl' ? 'GPU · ShaderMate' : 'CPU';
136
- inspector.el.querySelector('#itp-backend').title = app.engine.backend === 'webgl' ? `WebGL 2: ${app.engine.renderer}` : (app.engine.fallbackReason || 'CPU renderer');
137
- import('../video.js').then(m => m.videoSupport()).then(v => { app.videoSupport = v; }).catch(() => { });
138
-
139
- // ================================================================ rendering
140
- let dirty = false, running = false, lastSource = null;
141
- const outCanvas = document.createElement('canvas');
142
-
143
- function schedule() {
144
- dirty = true;
145
- if (!running) requestAnimationFrame(run);
146
- }
147
- app.schedule = schedule;
148
-
149
- async function run() {
150
- if (running || !dirty) return;
151
- if (app.exporting) return;
152
- dirty = false;
153
- const item = app.current;
154
- if (!item) { app.result = null; stage.clear(); updateStatus(); return; }
155
- running = true;
156
- const slow = setTimeout(() => { busy.hidden = false; }, 250);
157
- try {
158
- if (item.type === 'video') app.engine.invalidate();
159
- const res = await app.engine.process(item.source, store.settings);
160
- if (item !== app.current) return;
161
- app.result = res;
162
- res.toCanvas(1, { canvas: outCanvas });
163
- stage.show(outCanvas, item.source, res.crop);
164
- inspector.setStats(res);
165
- inspector.sync(store.settings, res);
166
- if (lastSource !== item) { lastSource = item; }
167
- updateStatus();
168
- } catch (e) {
169
- console.error(e);
170
- toast(`Rendering failed: ${e.message}`, { kind: 'error' });
171
- } finally {
172
- clearTimeout(slow); busy.hidden = true;
173
- running = false;
174
- if (dirty) requestAnimationFrame(run);
175
- }
176
- }
177
-
178
- function updateStatus() {
179
- const r = app.result, it = app.current;
180
- fileName.textContent = it ? it.name : 'No image';
181
- fileMeta.textContent = it ? (it.type === 'video' ? `${it.width} × ${it.height}, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`) : '';
182
- empty.hidden = !!it;
183
- viewTools.hidden = !it;
184
- if (!it || !r) { status.hidden = true; return; }
185
- status.hidden = false;
186
- render(status, h('span', {}, h('b', { text: `${r.width} × ${r.height}` }), ' px'), h('span', { text: `${Math.round(stage.zoom * 100) / 100}× zoom` }));
187
- undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
188
- }
189
- function updateZoomLabel() {
190
- zoomBtn.querySelector('span').textContent = !app.current || stage.fitted ? 'Fit' : stage.zoom >= 1 ? `${Math.round(stage.zoom * 10) / 10}×` : `${Math.round(stage.zoom * 100)}%`;
191
- if (app.result) updateStatus();
192
- }
193
-
194
- store.subscribe((s, why) => {
195
- // write the edit back to whatever is being edited: this file's own settings, or the shared ones
196
- const cur = app.current;
197
- if (cur && cur.own && store.target === cur.id) cur.own = s;
198
- else if (store.target === 'shared') app.shared = s;
199
- inspector.sync(s, app.result);
200
- undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
201
- if (why !== 'preset') app.activePreset = null;
202
- if (usePersist) saveSoon();
203
- if (library.tab === 'palettes') library.refresh();
204
- schedule();
205
- });
206
- let saveT = null;
207
- const saveSoon = () => { clearTimeout(saveT); saveT = setTimeout(() => persist.save('settings', app.shared), 300); };
208
-
209
- // ================================================================ files
210
- async function makeThumb(drawable, w, hgt) {
211
- const c = document.createElement('canvas');
212
- const k = 72 / Math.max(w, hgt);
213
- c.width = Math.max(1, Math.round(w * k)); c.height = Math.max(1, Math.round(hgt * k));
214
- try { c.getContext('2d').drawImage(drawable, 0, 0, c.width, c.height); } catch { /* not ready */ }
215
- return c.toDataURL();
216
- }
217
-
218
- async function addImage(name, drawable, file = null) {
219
- const w = drawable.naturalWidth || drawable.width, hh = drawable.naturalHeight || drawable.height;
220
- const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'image', source: drawable, width: w, height: hh, file };
221
- item.thumb = await makeThumb(drawable, w, hh);
222
- app.items.push(item);
223
- return item;
224
- }
225
-
226
- async function addVideo(name, file) {
227
- const video = h('video', { muted: true, playsInline: true, loop: true, preload: 'auto', crossOrigin: 'anonymous' });
228
- video.muted = true;
229
- video.src = URL.createObjectURL(file);
230
- try {
231
- await videoReady(video);
232
- } catch (e) {
233
- URL.revokeObjectURL(video.src);
234
- if (e.code === 3 || e.code === 4) {
235
- const codec = await sniffVideoCodec(file).catch(() => '');
236
- const alt = codec === 'H.264' ? 'WebM (VP9)' : codec === 'VP9' ? 'H.264 MP4' : 'H.264 MP4 or WebM (VP9)';
237
- const err = new Error(codec
238
- ? `it's ${codec}, which this browser can't play. Re-save it as ${alt}, or open the editor in a browser that plays ${codec}.`
239
- : `this browser can't play this video's format. Re-save it as ${alt}.`);
240
- err.code = e.code;
241
- throw err;
242
- }
243
- throw e;
244
- }
245
- const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'video', source: video, width: video.videoWidth, height: video.videoHeight, duration: video.duration, fps: 30, file };
246
- await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
247
- item.thumb = await makeThumb(video, item.width, item.height);
248
- app.items.push(item);
249
- estimateFps(video, 30).then(f => { item.fps = f; if (app.current === item) updateTransport(); inspector.syncAnim(); });
250
- video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
251
- video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
252
- video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
253
- video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
254
- return item;
255
- }
256
-
257
- async function addFiles(files) {
258
- const media = [], palettes = [], settingsFiles = [];
259
- for (const f of files) {
260
- if (PALETTE_EXT.test(f.name) && !/^image|^video/.test(f.type)) {
261
- if (/\.json$/i.test(f.name)) settingsFiles.push(f); else palettes.push(f);
262
- } else if (IMAGE_TYPES.test(f.type) || VIDEO_TYPES.test(f.type) || /\.(png|jpe?g|gif|webp|bmp|svg|avif|mp4|webm|mov)$/i.test(f.name)) media.push(f);
263
- }
264
- for (const f of palettes) await importPaletteFile(f);
265
- for (const f of settingsFiles) {
266
- const text = await f.text();
267
- try {
268
- const obj = JSON.parse(text);
269
- if (obj.colors || Array.isArray(obj)) await importPaletteFile(f, text);
270
- else { store.replace(settingsFromJSON(obj)); toast(`Loaded settings from ${f.name}`, { kind: 'ok' }); }
271
- } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
272
- }
273
- let first = null;
274
- const t = media.length > 2 ? toast(`Loading ${media.length} files`, { progress: true }) : null;
275
- for (let i = 0; i < media.length; i++) {
276
- const f = media[i];
277
- try {
278
- let item;
279
- if (VIDEO_TYPES.test(f.type) || /\.(mp4|webm|mov)$/i.test(f.name)) item = await addVideo(f.name, f);
280
- else {
281
- let drawable;
282
- try { drawable = await createImageBitmap(f); } catch {
283
- drawable = new Image(); drawable.src = URL.createObjectURL(f); await drawable.decode();
284
- }
285
- item = await addImage(f.name, drawable, f);
286
- }
287
- first ||= item;
288
- } catch (e) { toast(`${f.name} could not be opened: ${e.message}`, { kind: 'error' }); }
289
- t?.progress((i + 1) / media.length);
290
- }
291
- t?.close();
292
- if (first) select(first);
293
- library.refresh();
294
- if (media.length > 1) toast(`Added ${media.length} files. They share one set of settings.`, { kind: 'ok' });
295
- }
296
- app.addFiles = addFiles;
297
-
298
- async function openFiles() {
299
- const files = await pickFiles({ accept: 'image/*,video/*,.gpl,.hex,.pal,.txt,.json', multiple: true });
300
- if (files.length) addFiles(files);
301
- }
302
- app.openFiles = openFiles;
303
-
304
- async function openSample(s) {
305
- const img = await loadSample(s);
306
- const item = await addImage(`${s.name}.svg`, img);
307
- select(item);
308
- library.refresh();
309
- }
310
-
311
- async function pasteFromClipboard() {
312
- try {
313
- const items = await navigator.clipboard.read();
314
- const files = [];
315
- for (const it of items) for (const type of it.types) if (IMAGE_TYPES.test(type)) {
316
- const b = await it.getType(type);
317
- files.push(new File([b], `pasted-${new Date().toISOString().slice(11, 19).replace(/:/g, '')}.${type.split('/')[1]}`, { type }));
318
- }
319
- if (files.length) addFiles(files); else toast('The clipboard has no image', { kind: 'error' });
320
- } catch {
321
- toast('Press Ctrl+V (⌘V) to paste. The browser blocked direct clipboard access.');
322
- }
323
- }
324
- app.pasteFromClipboard = pasteFromClipboard;
325
-
326
- function select(item) {
327
- if (app.current && app.current.type === 'video' && app.current !== item) app.current.source.pause();
328
- app.current = item;
329
- editTarget(item);
330
- stageWrap.dataset.video = String(item && item.type === 'video');
331
- if (item && item.type === 'video') updateTransport();
332
- stage.clear();
333
- app.result = null;
334
- if (stage.picking) togglePicker();
335
- library.refresh();
336
- library.sourceChanged();
337
- schedule();
338
- updateStatus();
339
- }
340
- app.select = select;
341
-
342
- function removeItem(item) {
343
- const i = app.items.indexOf(item);
344
- if (i < 0) return;
345
- app.items.splice(i, 1);
346
- store.dropTarget(item.id);
347
- if (item.type === 'video') { item.source.pause(); URL.revokeObjectURL(item.source.src); }
348
- if (item.source.close && !app.items.some(i => i.source === item.source)) item.source.close(); // duplicates share a bitmap
349
- if (app.current === item) select(app.items[Math.min(i, app.items.length - 1)] || null);
350
- library.refresh();
351
- inspector.sync(store.settings, app.result);
352
- }
353
- app.removeItem = removeItem;
354
- app.clearItems = () => { for (const it of app.items.slice()) removeItem(it); };
355
-
356
- // ================================================================ per-file settings
357
- /** The settings a file renders with. */
358
- const settingsFor = item => (item === app.current ? store.settings : (item && item.own) || app.shared);
359
- app.settingsFor = settingsFor;
360
- const clone = v => JSON.parse(JSON.stringify(v));
361
-
362
- /** Point the store at the right settings for this file (its own, or the shared ones). */
363
- function editTarget(item, force = false) {
364
- const key = item && item.own ? item.id : 'shared';
365
- if (store.target === key && !force) return;
366
- store.setTarget(key, key === 'shared' ? app.shared : item.own);
367
- }
368
-
369
- /** Give a file its own settings (a copy of what it uses now), or link it back to the shared ones. */
370
- function setOwn(item, own, { quiet = false } = {}) {
371
- if (!item || !!item.own === own) return;
372
- if (own) {
373
- item.own = clone(settingsFor(item));
374
- if (item === app.current) editTarget(item);
375
- if (!quiet) toast(`${item.name} now has its own settings. Changes no longer affect other files.`);
376
- } else {
377
- const was = item.own;
378
- item.own = null;
379
- store.dropTarget(item.id);
380
- if (item === app.current) editTarget(item);
381
- if (!quiet) toast(`${item.name} uses the shared settings again`, { action: { label: 'Undo', onClick: () => { item.own = was; if (item === app.current) editTarget(item); afterScopeChange(); } } });
382
- }
383
- afterScopeChange();
384
- }
385
- function afterScopeChange() {
386
- library.refresh();
387
- inspector.sync(store.settings, app.result);
388
- if (app.result) inspector.setStats(app.result);
389
- schedule();
390
- }
391
- app.setOwn = setOwn;
392
-
393
- app.copySettings = item => {
394
- app.clipboard = clone(settingsFor(item));
395
- toast(`Copied the settings of ${item.name}`, { timeout: 1800 });
396
- library.refresh();
397
- };
398
- app.pasteSettings = item => {
399
- if (!app.clipboard) return;
400
- if (!item.own) setOwn(item, true, { quiet: true });
401
- if (item === app.current) store.replace(app.clipboard, 'paste');
402
- else item.own = clone(app.clipboard);
403
- toast(`Pasted settings onto ${item.name}`, { kind: 'ok', timeout: 1800 });
404
- afterScopeChange();
405
- };
406
- /** Make this file's settings the shared ones, and link every file to them. */
407
- app.applyToAll = item => {
408
- const snapshot = { shared: app.shared, own: new Map(app.items.map(i => [i, i.own])) };
409
- app.shared = clone(settingsFor(item));
410
- for (const it of app.items) { if (it.own) store.dropTarget(it.id); it.own = null; }
411
- store.setTarget('shared', app.shared);
412
- if (usePersist) saveSoon();
413
- toast(`Every file now uses the settings of ${item.name}`, {
414
- action: { label: 'Undo', onClick: () => {
415
- app.shared = snapshot.shared;
416
- for (const [it, own] of snapshot.own) it.own = own;
417
- editTarget(app.current, true);
418
- afterScopeChange();
419
- } },
420
- });
421
- afterScopeChange();
422
- };
423
- /** A second copy of a file with its own settings, for comparing two looks. */
424
- app.duplicate = async item => {
425
- let copy;
426
- if (item.type === 'video') copy = await addVideo(nameCopy(item.name), item.file);
427
- else copy = await addImage(nameCopy(item.name), item.source, item.file);
428
- // place it right after the original
429
- app.items.splice(app.items.indexOf(copy), 1);
430
- app.items.splice(app.items.indexOf(item) + 1, 0, copy);
431
- copy.own = clone(settingsFor(item));
432
- select(copy);
433
- toast(`Duplicated ${item.name}. The copy has its own settings.`, { timeout: 2400 });
434
- };
435
- const nameCopy = name => { const m = /^(.*?)(\.[^.]*)?$/.exec(name); return `${m[1]} copy${m[2] || ''}`; };
436
- app.move = (item, dir) => {
437
- const i = app.items.indexOf(item), j = i + dir;
438
- if (i < 0 || j < 0 || j >= app.items.length) return;
439
- [app.items[i], app.items[j]] = [app.items[j], app.items[i]];
440
- library.refresh();
441
- if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
442
- };
443
-
444
- // ================================================================ video
445
- function updateTransport() {
446
- const it = app.current;
447
- if (!it || it.type !== 'video') return;
448
- const v = it.source;
449
- playBtn.innerHTML = icon(v.paused ? 'play' : 'pause');
450
- playBtn.setAttribute('aria-label', v.paused ? 'Play (Space)' : 'Pause (Space)');
451
- const frame = Math.floor(v.currentTime * it.fps + 1e-6);
452
- timeLabel.innerHTML = `${fmtTime(v.currentTime)} <span>/ ${fmtTime(v.duration)}</span>`;
453
- timeLabel.title = `Frame ${frame}`;
454
- scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
455
- scrub.style.setProperty('--p', `${scrub.value / 10}%`);
456
- fpsLabel.textContent = `${it.fps} fps`;
457
- }
458
- function pumpVideo(item) {
459
- const v = item.source;
460
- const tick = () => {
461
- if (v.paused || app.current !== item) return;
462
- schedule();
463
- if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
464
- };
465
- tick();
466
- }
467
- function togglePlay() {
468
- const it = app.current;
469
- if (!it || it.type !== 'video') return;
470
- if (it.source.paused) it.source.play().catch(e => toast(e.message, { kind: 'error' })); else it.source.pause();
471
- }
472
- function stepFrame(dir) {
473
- const it = app.current;
474
- if (!it || it.type !== 'video') return;
475
- const v = it.source; v.pause();
476
- const frame = Math.floor(v.currentTime * it.fps + 1e-6) + dir;
477
- seekTo(v, (Math.max(0, frame) + 0.5) / it.fps).then(() => { updateTransport(); schedule(); });
478
- }
479
- scrub.addEventListener('input', () => {
480
- const it = app.current; if (!it || it.type !== 'video') return;
481
- it.source.currentTime = scrub.value / 1000 * it.source.duration;
482
- });
483
-
484
- // ================================================================ view
485
- function toggleCompare(force) {
486
- const on = force ?? !stage.compare;
487
- stage.setCompare(on); compareBtn.setAttribute('aria-pressed', String(on));
488
- }
489
- function toggleGrid(force) {
490
- const on = force ?? !stage.grid;
491
- stage.setGrid(on); gridBtn.setAttribute('aria-pressed', String(on));
492
- if (on && stage.zoom < 6 && app.result) toast('The grid shows from 6× zoom');
493
- }
494
- function zoomMenu(anchor) {
495
- menu(anchor, [
496
- { label: 'Fit to view', kbd: 'F', icon: 'fit', onClick: () => stage.fit() },
497
- 'sep',
498
- ...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 ? 'One screen pixel per pixel' : null, onClick: () => stage.zoomTo(z) })),
499
- 'sep',
500
- { label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () => stage.zoomIn() },
501
- { label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => stage.zoomOut() },
502
- ]);
503
- }
504
- function togglePanel(which) {
505
- const key = which === 'lib' ? 'lib' : 'insp';
506
- const open = shell.dataset[key] !== 'open';
507
- shell.dataset[key] = open ? 'open' : 'closed';
508
- if (!narrow() || which === 'insp') persist.save(key, shell.dataset[key]);
509
- requestAnimationFrame(() => stage.resize());
510
- }
511
- function applyTheme(t) {
512
- if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
513
- const dark = t ? t === 'dark' : !matchMedia('(prefers-color-scheme: light)').matches;
514
- themeBtn.innerHTML = icon(dark ? 'sun' : 'moon');
515
- themeBtn.setAttribute('aria-label', dark ? 'Switch to light theme (T)' : 'Switch to dark theme (T)');
516
- stage.redraw();
517
- }
518
- function toggleTheme() {
519
- const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
520
- const next = cur === 'dark' ? 'light' : 'dark';
521
- persist.save('theme', next);
522
- applyTheme(next);
523
- }
524
-
525
- // ================================================================ palettes
526
- function pixelsOf(item, maxSize = 4096) {
527
- const w = item.width, hh = item.height, k = Math.min(1, maxSize / Math.max(w, hh));
528
- const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(hh * k);
529
- const ctx = c.getContext('2d', { willReadFrequently: true });
530
- ctx.drawImage(item.source, 0, 0, c.width, c.height);
531
- return ctx.getImageData(0, 0, c.width, c.height);
532
- }
533
- function pickAt(u, v) {
534
- const it = app.current; if (!it) return;
535
- const img = pixelsOf(it, 1024);
536
- const c = app.result ? app.result.crop : { x: 0, y: 0, w: 1, h: 1 };
537
- const x = Math.min(img.width - 1, Math.floor((c.x + u * c.w) * img.width)), y = Math.min(img.height - 1, Math.floor((c.y + v * c.h) * img.height));
538
- const o = (y * img.width + x) * 4;
539
- const hex = toHex([img.data[o], img.data[o + 1], img.data[o + 2]]);
540
- const s = store.settings;
541
- const colors = s.palette.source === 'custom' ? s.palette.colors : [];
542
- if (colors.includes(hex)) return toast(`${hex} is already in the palette`);
543
- store.update({ palette: { source: 'custom', name: s.palette.source === 'custom' ? s.palette.name : 'Picked', colors: [...colors, hex] } });
544
- toast(`Added ${hex}`, { kind: 'ok', timeout: 1500 });
545
- }
546
- let pickerBtn = null;
547
- function togglePicker(button) {
548
- if (button) pickerBtn = button;
549
- const on = !stage.picking;
550
- stage.setPicking(on);
551
- pickerBtn?.setAttribute('aria-pressed', String(on));
552
- if (on) {
553
- toggleCompare(true);
554
- toast('Click the image to add colours. Press Esc when done.');
555
- }
556
- }
557
- app.togglePicker = togglePicker;
558
-
559
- function usePalette(p) {
560
- if (p.id && !p.custom) store.update({ palette: { source: 'builtin', id: p.id } });
561
- else store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
562
- }
563
- app.usePalette = usePalette;
564
-
565
- function rememberPalette(name, colors) {
566
- const list = app.userPalettes.filter(p => p.name !== name);
567
- list.unshift({ name, colors, custom: true });
568
- app.userPalettes = list.slice(0, 100);
569
- if (usePersist) persist.save('palettes', app.userPalettes);
570
- if (library.tab === 'palettes') library.refresh();
571
- }
572
- async function importPaletteFile(file, text) {
573
- try {
574
- const p = parsePalette(text ?? await file.text(), file.name);
575
- rememberPalette(p.name || 'Imported', p.colors);
576
- store.update({ palette: { source: 'custom', name: p.name || 'Imported', colors: p.colors } });
577
- toast(`Imported "${p.name}" (${p.colors.length} colours)`, { kind: 'ok' });
578
- } catch (e) { toast(`${file.name}: ${e.message}`, { kind: 'error' }); }
579
- }
580
- app.importPalette = async () => {
581
- const [f] = await pickFiles({ accept: '.gpl,.hex,.pal,.txt,.json' });
582
- if (f) importPaletteFile(f);
583
- };
584
- app.savePalette = () => {
585
- const s = store.settings;
586
- const colors = s.palette.source === 'extract' ? (app.result?.palette || []) : s.palette.colors;
587
- if (!colors.length) return toast('There is no palette to save', { kind: 'error' });
588
- const input = h('input.itp-input', { value: s.palette.source === 'custom' ? s.palette.name : `${s.palette.name} copy`, 'aria-label': 'Palette name' });
589
- const fmt = h('select.itp-select', { 'aria-label': 'Also download as' }, h('option', { value: '', text: "Don't download" }), h('option', { value: 'gpl', text: '.gpl (GIMP, Aseprite)' }), h('option', { value: 'hex', text: '.hex (Lospec)' }), h('option', { value: 'json', text: '.json' }));
590
- dialog('Save palette', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Name' })), input), h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Also download as' })), fmt),
591
- h('div.itp-strip', { style: { height: '18px' } }, ...colors.map(c => h('i', { style: { background: c } })))], {
592
- actions: [{ label: 'Cancel' }, { label: 'Save', primary: true, onClick: () => {
593
- const name = input.value.trim() || 'Palette';
594
- rememberPalette(name, colors);
595
- store.update({ palette: { source: 'custom', name, colors } });
596
- const slug = name.replace(/[^\w-]+/g, '-').toLowerCase();
597
- if (fmt.value === 'gpl') downloadBlob(toGPL(name, colors), `${slug}.gpl`, 'text/plain');
598
- if (fmt.value === 'hex') downloadBlob(toHexFile(colors), `${slug}.hex`, 'text/plain');
599
- if (fmt.value === 'json') downloadBlob(toPaletteJSON(name, colors), `${slug}.json`, 'application/json');
600
- toast(`Saved "${name}" to Your palettes`, { kind: 'ok' });
601
- } }],
602
- });
603
- input.select();
604
- };
605
- app.deletePalette = p => {
606
- app.userPalettes = app.userPalettes.filter(x => x !== p);
607
- if (usePersist) persist.save('palettes', app.userPalettes);
608
- library.refresh();
609
- toast(`Deleted "${p.name}"`, { action: { label: 'Undo', onClick: () => rememberPalette(p.name, p.colors) } });
610
- };
611
- app.lospecDialog = () => {
612
- const input = h('input.itp-input', { placeholder: 'e.g. rgbg-36 or a lospec.com palette URL', 'aria-label': 'Lospec slug or URL' });
613
- const note = h('div.help', { text: 'Looks the palette up on lospec.com. Needs an internet connection.' });
614
- const d = dialog('Palette from Lospec', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Slug or URL' })), input, note)], {
615
- actions: [{ label: 'Cancel' }, { label: 'Fetch', primary: true, onClick: async () => {
616
- note.textContent = 'Fetching…'; note.style.color = '';
617
- try {
618
- const p = await fetchLospec(input.value);
619
- rememberPalette(p.name, p.colors);
620
- store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
621
- toast(`Loaded "${p.name}" from Lospec`, { kind: 'ok' });
622
- return true;
623
- } catch (e) { note.textContent = e.message; note.style.color = 'var(--danger)'; return false; }
624
- } }],
625
- });
626
- input.addEventListener('keydown', e => { if (e.key === 'Enter') d.querySelector('footer .primary').click(); });
627
- input.focus();
628
- };
629
-
630
- app.detectGrid = () => {
631
- const it = app.current; if (!it || it.type !== 'image') return toast('Open an image first', { kind: 'error' });
632
- const b = detectGrid(pixelsOf(it));
633
- if (b <= 1) return toast('No pixel grid found. This image doesn\'t look upscaled.');
634
- store.update({ size: { mode: 'block', block: b, resample: 'nearest' } });
635
- toast(`Found ${b} px blocks: switched to pixel size ${b} with nearest sampling`, { kind: 'ok' });
636
- };
637
-
638
- // ================================================================ settings
639
- app.settingsJSON = () => settingsToJSON(store.settings);
640
- app.copyLink = async () => {
641
- const url = `${location.origin}${location.pathname}#s=${encodeSettings(store.settings)}`;
642
- try { await navigator.clipboard.writeText(url); toast('Link copied. It holds the settings, not the image.', { kind: 'ok' }); }
643
- catch { dialog('Share link', [h('textarea.itp-input', { rows: 3, readOnly: true, value: url })]); }
644
- history.replaceState(null, '', `#s=${encodeSettings(store.settings)}`);
645
- };
646
- app.saveSettingsFile = () => downloadBlob(settingsToJSON(store.settings), 'image-to-pixel-settings.json', 'application/json');
647
- app.loadSettingsFile = async () => {
648
- const [f] = await pickFiles({ accept: '.json,application/json' });
649
- if (!f) return;
650
- try { store.replace(settingsFromJSON(await f.text())); toast(`Loaded ${f.name}`, { kind: 'ok' }); } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
651
- };
652
- app.resetSettings = () => { store.replace(defaultSettings()); toast('Settings reset', { action: { label: 'Undo', onClick: () => store.undo() } }); };
653
- app.applyPreset = p => {
654
- store.replace(mergeSettings(defaultSettings(), { ...p.settings, export: store.settings.export }), 'preset');
655
- app.activePreset = p.id;
656
- library.refresh();
657
- };
658
-
659
- Object.assign(app, exporter);
660
-
661
- function exportMenu(anchor) {
662
- const it = app.current;
663
- const video = it && it.type === 'video';
664
- const images = app.items.filter(i => i.type === 'image').length;
665
- const s = store.settings;
666
- menu(anchor, [
667
- { group: 'Image' },
668
- { label: 'PNG', sub: app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first', icon: 'image', kbd: 'Ctrl S', disabled: !app.result, onClick: () => app.exportPNG() },
669
- { label: 'Copy to clipboard', icon: 'copy', disabled: !app.result, onClick: () => app.copyImage() },
670
- { label: app.items.length > 1 ? `All ${app.items.length} files as ZIP` : 'All files as ZIP', sub: app.items.length > 1 ? null : 'Add two or more files', icon: 'zip', disabled: app.items.length < 2, onClick: () => app.exportZip() },
671
- { group: 'Animation' },
672
- { label: video ? 'GIF from this video' : 'GIF from all images', icon: 'gif', disabled: !video && images < 2, onClick: () => app.exportAnimation({ ...animDefaults(), format: 'gif' }) },
673
- { label: 'More formats…', sub: 'WebM, MP4, PNG sequence, sprite sheet', icon: 'film', onClick: () => { inspector.setTab('export'); if (shell.dataset.insp !== 'open') togglePanel('insp'); } },
674
- { group: 'Other' },
675
- { label: 'Tileset', sub: '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
676
- { label: 'Copy settings link', icon: 'link', onClick: () => app.copyLink() },
677
- { label: 'Settings as JSON', icon: 'braces', onClick: () => app.saveSettingsFile() },
678
- ]);
679
- }
680
- const animDefaults = () => {
681
- const it = app.current;
682
- const video = it && it.type === 'video';
683
- return { source: video ? 'video' : 'files', fps: video ? Math.min(30, Math.round(it.fps || 12)) : 8, start: 0, end: video ? it.duration : 0 };
684
- };
685
-
686
- // ================================================================ help
687
- function showHelp() {
688
- const keys = [
689
- ['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
690
- ['Compare', 'C'], ['Pixel grid', 'G'], ['Fit', 'F'], ['Zoom in / out', '+ -'], ['Pixel width ±1', '[ ]'], ['Pixel width ±8', 'Shift [ ]'],
691
- ['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
692
- ['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
693
- ];
694
- dialog('Keyboard shortcuts', [h('div.itp-keys', {}, ...keys.map(([a, k]) => h('div', {}, h('span', { text: a }), h('span', {}, ...k.split(' ').filter(Boolean).map(x => h('kbd', { text: x }))))))]);
695
- }
696
-
697
- // ================================================================ input
698
- const typing = e => e.target.closest('input, select, textarea, [contenteditable="true"]') && !(e.target.type === 'range' || e.target.type === 'checkbox');
699
- const DITHER_ORDER = ['none', 'bayer4', 'bayer8', 'bluenoise', 'cluster4', 'floyd-steinberg', 'atkinson', 'sierra-lite', 'jarvis'];
700
- window.addEventListener('keydown', e => {
701
- if (!shell.isConnected) return;
702
- if (document.querySelector('dialog[open]')) return;
703
- const mod = e.ctrlKey || e.metaKey;
704
- const k = e.key;
705
- if (mod && k.toLowerCase() === 'z') { if (typing(e)) return; e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return; }
706
- if (mod && k.toLowerCase() === 'y') { if (typing(e)) return; e.preventDefault(); store.redo(); return; }
707
- if (mod && k.toLowerCase() === 's') { e.preventDefault(); app.exportPNG(); return; }
708
- if (mod && k.toLowerCase() === 'o') { e.preventDefault(); openFiles(); return; }
709
- if (mod || e.altKey && !['ArrowUp', 'ArrowDown'].includes(k)) return;
710
- if (typing(e)) { if (k === 'Escape') e.target.blur(); return; }
711
- const s = store.settings;
712
- switch (k) {
713
- case 'c': case 'C': toggleCompare(); break;
714
- case 'g': case 'G': toggleGrid(); break;
715
- case 'f': case 'F': stage.fit(); break;
716
- case '+': case '=': stage.zoomIn(); break;
717
- case '-': case '_': stage.zoomOut(); break;
718
- case 't': case 'T': toggleTheme(); break;
719
- case '\\': togglePanel('lib'); break;
720
- case '/': togglePanel('insp'); break;
721
- case '?': showHelp(); break;
722
- case '[': case '{': store.update({ size: { mode: 'width', width: Math.max(8, s.size.width - (e.shiftKey ? 8 : 1)) } }, { key: 'kw' }); break;
723
- case ']': case '}': store.update({ size: { mode: 'width', width: s.size.width + (e.shiftKey ? 8 : 1) } }, { key: 'kw' }); break;
724
- case 'd': case 'D': {
725
- const i = DITHER_ORDER.indexOf(s.dither.method);
726
- const next = DITHER_ORDER[(i + (e.shiftKey ? -1 : 1) + DITHER_ORDER.length) % DITHER_ORDER.length];
727
- store.update({ dither: { method: next } });
728
- toast(next === 'none' ? 'No dither' : next.replace(/-/g, ' '), { timeout: 900 });
729
- break;
730
- }
731
- case ' ': if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
732
- case ',': stepFrame(-1); break;
733
- case '.': stepFrame(1); break;
734
- case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
735
- case 'ArrowDown': case 'ArrowUp':
736
- if (e.altKey && e.shiftKey && app.current) { e.preventDefault(); app.move(app.current, k === 'ArrowDown' ? 1 : -1); break; }
737
- if (e.altKey && app.items.length > 1) {
738
- e.preventDefault();
739
- const i = app.items.indexOf(app.current);
740
- select(app.items[(i + (k === 'ArrowDown' ? 1 : -1) + app.items.length) % app.items.length]);
741
- }
742
- break;
743
- default: return;
744
- }
745
- });
746
-
747
- // drag & drop, paste
748
- let dragDepth = 0;
749
- shell.addEventListener('dragenter', e => { if ([...e.dataTransfer.types].includes('Files')) { dragDepth++; shell.classList.add('itp-dragging'); } });
750
- shell.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; shell.classList.remove('itp-dragging'); } });
751
- shell.addEventListener('dragover', e => { if ([...e.dataTransfer.types].includes('Files')) e.preventDefault(); });
752
- shell.addEventListener('drop', e => {
753
- e.preventDefault(); dragDepth = 0; shell.classList.remove('itp-dragging');
754
- if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
755
- });
756
- window.addEventListener('paste', e => {
757
- if (!shell.isConnected || typing(e)) return;
758
- const files = [...(e.clipboardData?.files || [])];
759
- if (files.length) { e.preventDefault(); addFiles(files); return; }
760
- const text = e.clipboardData?.getData('text') || '';
761
- if (/#?[0-9a-f]{6}/i.test(text) && /\n|,|\s/.test(text.trim())) {
762
- try { const p = parsePalette(text); store.update({ palette: { source: 'custom', name: 'Pasted', colors: p.colors } }); toast(`Pasted a ${p.colors.length}-colour palette`, { kind: 'ok' }); } catch { /* not a palette */ }
763
- }
764
- });
765
- window.addEventListener('hashchange', () => {
766
- if (/[#&]s=/.test(location.hash)) { try { store.replace(decodeSettings(location.hash)); } catch { /* ignore */ } }
767
- });
768
-
769
- // first paint
770
- inspector.sync(store.settings, null);
771
- library.refresh();
772
- updateStatus();
773
- undoBtn.disabled = redoBtn.disabled = true;
774
-
775
- const api = {
776
- app, store,
777
- /** Add File objects (images, videos, palettes, settings JSON). */
778
- addFiles,
779
- /** Add an image-like source directly. */
780
- async addImage(source, name = 'image') { const it = await addImage(name, source); select(it); library.refresh(); return it; },
781
- openSample: id => openSample(SAMPLES.find(s => s.id === id) || SAMPLES[0]),
782
- applyPreset: id => { const p = PRESETS.find(x => x.id === id); if (p) app.applyPreset(p); },
783
- setTheme: t => { persist.save('theme', t); applyTheme(t); },
784
- get result() { return app.result; },
785
- /** On-screen rectangle of the image in the canvas. */
786
- get view() { return stage.rect; },
787
- /** Resolves after pending renders finish. */
788
- async idle() { for (let i = 0; i < 200 && (dirty || running); i++) await new Promise(r => setTimeout(r, 25)); },
789
- };
790
- return api;
791
- }
792
-
793
- export { SAMPLES };
1
+ // The editor UI. mountEditor(element) builds the whole app inside `element`.
2
+ // The core engine (lib/core) has no idea this file exists.
3
+
4
+ import { createPixelator } from '../core/engine.js';
5
+ import { defaultSettings, decodeSettings, encodeSettings, settingsToJSON, settingsFromJSON, mergeSettings } from '../core/settings.js';
6
+ import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/parse.js';
7
+ import { fetchLospec } from '../core/palette/lospec.js';
8
+ import { detectGrid } from '../core/export/scale.js';
9
+ import { toHex } from '../core/color.js';
10
+ import { estimateFps, videoReady, seekTo, sniffVideoCodec } from '../core/video/frames.js';
11
+ import { downloadBlob } from '../core/export/canvas.js';
12
+ import { PRESETS } from '../core/presets.js';
13
+ import { h, render, pickFiles } from './dom.js';
14
+ import { icon } from './icons.js';
15
+ import { createStore } from './store.js';
16
+ import { createStage } from './stage.js';
17
+ import { createInspector } from './inspector.js';
18
+ import { createLibrary, fmtTime } from './library.js';
19
+ import { createExporter } from './exporter.js';
20
+ import { toast, menu, closeMenu, dialog } from './ui.js';
21
+ import { SAMPLES, loadSample, sampleURL } from './samples.js';
22
+ import * as persist from './persist.js';
23
+
24
+ const IMAGE_TYPES = /^image\//;
25
+ const VIDEO_TYPES = /^video\//;
26
+ const PALETTE_EXT = /\.(gpl|hex|pal|txt|json)$/i;
27
+
28
+ /**
29
+ * Mount the editor.
30
+ * @param {HTMLElement} root
31
+ * @param {{ settings?: object, persist?: boolean, samples?: boolean, homeHref?: string, docsHref?: string }} [options]
32
+ */
33
+ export async function mountEditor(root, options = {}) {
34
+ const usePersist = options.persist !== false;
35
+ const narrow = () => matchMedia('(max-width: 900px)').matches;
36
+
37
+ // ---- initial settings: share link > saved > defaults
38
+ let initial = options.settings || (usePersist && persist.load('settings', null)) || defaultSettings();
39
+ if (/[#&]s=/.test(location.hash)) {
40
+ try { initial = decodeSettings(location.hash); } catch { toast('That share link could not be read; using your last settings', { kind: 'error' }); }
41
+ }
42
+ const store = createStore(initial);
43
+
44
+ const app = {
45
+ store,
46
+ engine: null,
47
+ items: [],
48
+ current: null,
49
+ result: null,
50
+ exporting: false,
51
+ activePreset: null,
52
+ userPalettes: usePersist ? persist.load('palettes', []) : [],
53
+ /** Settings shared by every file that doesn't have its own. */
54
+ shared: store.settings,
55
+ /** Settings copied with "Copy settings", ready to paste onto another file. */
56
+ clipboard: null,
57
+ videoSupport: { mp4: false, webm: false },
58
+ toast: (m, o) => toast(m, o),
59
+ };
60
+
61
+ // ================================================================ layout
62
+ const stage = createStage({ onPick: pickAt, onViewChange: () => updateZoomLabel() });
63
+ const inspector = createInspector(app);
64
+ const library = createLibrary(app);
65
+ const exporter = createExporter(app);
66
+
67
+ const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
68
+
69
+ const fileName = h('span.name', { text: 'No image' });
70
+ const fileMeta = h('span.meta.num', {});
71
+ const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
72
+ const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
73
+ const compareBtn = btn('compare', 'Compare with original (C)', () => toggleCompare(), { 'aria-pressed': 'false' });
74
+ const gridBtn = btn('grid', 'Pixel grid (G)', () => toggleGrid(), { 'aria-pressed': 'false' });
75
+ const zoomBtn = h('button.itp-btn.itp-zoom.num', { type: 'button', 'aria-label': 'Zoom', 'aria-haspopup': 'menu', html: `<span>Fit</span>${icon('chevronDown')}`, onclick: e => zoomMenu(e.currentTarget) });
76
+ const themeBtn = btn('moon', 'Toggle theme (T)', () => toggleTheme());
77
+ const libBtn = btn('sidebarLeft', 'Toggle library (\\)', () => togglePanel('lib'));
78
+ const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
79
+ const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
80
+ const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
81
+ const brand = h('a.itp-brand', { href: options.homeHref || '#', title: 'Image-to-Pixel' }, h('span.itp-mark', { 'aria-hidden': 'true' }, ...Array.from({ length: 9 }, () => h('i'))), h('span.hide-sm', { text: 'Image-to-Pixel' }));
82
+ if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
83
+
84
+ const top = h('header.itp-top', {},
85
+ libBtn, brand, h('span.sep.hide-sm'),
86
+ h('div.itp-file', {}, fileName, fileMeta),
87
+ h('div.spacer'),
88
+ h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
89
+ compareBtn, gridBtn, zoomBtn,
90
+ h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
91
+ h('span', { style: { width: '6px' } }), exportBtn);
92
+
93
+ // stage extras
94
+ const sampleButtons = options.samples === false ? null : h('div.samples', {}, ...SAMPLES.map(s => h('button', { type: 'button', title: `Try "${s.name}"`, 'aria-label': `Open sample: ${s.name}`, onclick: () => openSample(s) }, h('img', { src: sampleURL(s), alt: '' }))));
95
+ const empty = h('div.itp-empty', {}, h('div.itp-drop', {},
96
+ h('h2', { text: 'Drop an image or a video' }),
97
+ h('p', { text: 'PNG, JPG, WebP, GIF, SVG, MP4 or WebM. Add several files to process them as a batch. You can also paste with Ctrl+V.' }),
98
+ h('div.actions', {},
99
+ h('button.itp-btn.primary', { type: 'button', html: `${icon('upload')}<span>Choose files</span>`, onclick: () => openFiles() }),
100
+ h('button.itp-btn.outline', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, onclick: () => pasteFromClipboard() })),
101
+ sampleButtons ? h('p', { style: { fontSize: 'var(--fs-xs)', color: 'var(--faint)' }, text: 'Or start from a sample' }) : null,
102
+ sampleButtons));
103
+ const status = h('div.itp-float.bl', { 'aria-live': 'polite' });
104
+ const busy = h('div.itp-busy.itp-float', { hidden: true, style: { padding: '6px 10px', alignItems: 'center', gap: '8px', fontSize: 'var(--fs-xs)', color: 'var(--muted)', display: 'flex' } }, h('span.itp-spinner'), h('span', { text: 'Rendering' }));
105
+ const viewTools = h('div.itp-float.br', {},
106
+ btn('zoomOut', 'Zoom out (-)', () => stage.zoomOut()),
107
+ btn('fit', 'Fit to view (F)', () => stage.fit()),
108
+ btn('zoomIn', 'Zoom in (+)', () => stage.zoomIn()));
109
+ stage.el.append(empty, status, busy, viewTools);
110
+
111
+ // transport
112
+ const playBtn = btn('play', 'Play (Space)', () => togglePlay());
113
+ const timeLabel = h('span.itp-time', {});
114
+ const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
115
+ const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
116
+ const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
117
+ btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
118
+ timeLabel, scrub, fpsLabel);
119
+ const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, transport);
120
+
121
+ // on narrow screens the library is a drawer: a tap on the canvas closes it
122
+ stageWrap.addEventListener('pointerdown', () => { if (narrow() && shell.dataset.lib === 'open') togglePanel('lib'); }, true);
123
+ const shell = h('div.itp.itp-app', { 'data-lib': narrow() ? 'closed' : persist.load('lib', 'open'), 'data-insp': persist.load('insp', 'open') }, top, library.el, stageWrap, inspector.el);
124
+ render(root, shell);
125
+ applyTheme(persist.load('theme', null));
126
+
127
+ // ================================================================ engine
128
+ status.textContent = 'Starting…';
129
+ try {
130
+ app.engine = await createPixelator();
131
+ } catch (e) {
132
+ toast(`Could not start: ${e.message}`, { kind: 'error' });
133
+ throw e;
134
+ }
135
+ inspector.el.querySelector('#itp-backend').textContent = app.engine.backend === 'webgl' ? 'GPU · ShaderMate' : 'CPU';
136
+ inspector.el.querySelector('#itp-backend').title = app.engine.backend === 'webgl' ? `WebGL 2: ${app.engine.renderer}` : (app.engine.fallbackReason || 'CPU renderer');
137
+ import('../video.js').then(m => m.videoSupport()).then(v => { app.videoSupport = v; }).catch(() => { });
138
+
139
+ // ================================================================ rendering
140
+ let dirty = false, running = false, lastSource = null;
141
+ const outCanvas = document.createElement('canvas');
142
+
143
+ function schedule() {
144
+ dirty = true;
145
+ if (!running) requestAnimationFrame(run);
146
+ }
147
+ app.schedule = schedule;
148
+
149
+ async function run() {
150
+ if (running || !dirty) return;
151
+ if (app.exporting) return;
152
+ dirty = false;
153
+ const item = app.current;
154
+ if (!item) { app.result = null; stage.clear(); updateStatus(); return; }
155
+ running = true;
156
+ const slow = setTimeout(() => { busy.hidden = false; }, 250);
157
+ try {
158
+ if (item.type === 'video') app.engine.invalidate();
159
+ const res = await app.engine.process(item.source, store.settings);
160
+ if (item !== app.current) return;
161
+ app.result = res;
162
+ res.toCanvas(1, { canvas: outCanvas });
163
+ stage.show(outCanvas, item.source, res.crop);
164
+ inspector.setStats(res);
165
+ inspector.sync(store.settings, res);
166
+ if (lastSource !== item) { lastSource = item; }
167
+ updateStatus();
168
+ } catch (e) {
169
+ console.error(e);
170
+ toast(`Rendering failed: ${e.message}`, { kind: 'error' });
171
+ } finally {
172
+ clearTimeout(slow); busy.hidden = true;
173
+ running = false;
174
+ if (dirty) requestAnimationFrame(run);
175
+ }
176
+ }
177
+
178
+ function updateStatus() {
179
+ const r = app.result, it = app.current;
180
+ fileName.textContent = it ? it.name : 'No image';
181
+ fileMeta.textContent = it ? (it.type === 'video' ? `${it.width} × ${it.height}, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`) : '';
182
+ empty.hidden = !!it;
183
+ viewTools.hidden = !it;
184
+ if (!it || !r) { status.hidden = true; return; }
185
+ status.hidden = false;
186
+ render(status, h('span', {}, h('b', { text: `${r.width} × ${r.height}` }), ' px'), h('span', { text: `${Math.round(stage.zoom * 100) / 100}× zoom` }));
187
+ undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
188
+ }
189
+ function updateZoomLabel() {
190
+ zoomBtn.querySelector('span').textContent = !app.current || stage.fitted ? 'Fit' : stage.zoom >= 1 ? `${Math.round(stage.zoom * 10) / 10}×` : `${Math.round(stage.zoom * 100)}%`;
191
+ if (app.result) updateStatus();
192
+ }
193
+
194
+ store.subscribe((s, why) => {
195
+ // write the edit back to whatever is being edited: this file's own settings, or the shared ones
196
+ const cur = app.current;
197
+ if (cur && cur.own && store.target === cur.id) cur.own = s;
198
+ else if (store.target === 'shared') app.shared = s;
199
+ inspector.sync(s, app.result);
200
+ undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
201
+ if (why !== 'preset') app.activePreset = null;
202
+ if (usePersist) saveSoon();
203
+ if (library.tab === 'palettes') library.refresh();
204
+ schedule();
205
+ });
206
+ let saveT = null;
207
+ const saveSoon = () => { clearTimeout(saveT); saveT = setTimeout(() => persist.save('settings', app.shared), 300); };
208
+
209
+ // ================================================================ files
210
+ async function makeThumb(drawable, w, hgt) {
211
+ const c = document.createElement('canvas');
212
+ const k = 72 / Math.max(w, hgt);
213
+ c.width = Math.max(1, Math.round(w * k)); c.height = Math.max(1, Math.round(hgt * k));
214
+ try { c.getContext('2d').drawImage(drawable, 0, 0, c.width, c.height); } catch { /* not ready */ }
215
+ return c.toDataURL();
216
+ }
217
+
218
+ async function addImage(name, drawable, file = null) {
219
+ const w = drawable.naturalWidth || drawable.width, hh = drawable.naturalHeight || drawable.height;
220
+ const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'image', source: drawable, width: w, height: hh, file };
221
+ item.thumb = await makeThumb(drawable, w, hh);
222
+ app.items.push(item);
223
+ return item;
224
+ }
225
+
226
+ async function addVideo(name, file) {
227
+ const video = h('video', { muted: true, playsInline: true, loop: true, preload: 'auto', crossOrigin: 'anonymous' });
228
+ video.muted = true;
229
+ video.src = URL.createObjectURL(file);
230
+ try {
231
+ await videoReady(video);
232
+ } catch (e) {
233
+ URL.revokeObjectURL(video.src);
234
+ if (e.code === 3 || e.code === 4) {
235
+ const codec = await sniffVideoCodec(file).catch(() => '');
236
+ const alt = codec === 'H.264' ? 'WebM (VP9)' : codec === 'VP9' ? 'H.264 MP4' : 'H.264 MP4 or WebM (VP9)';
237
+ const err = new Error(codec
238
+ ? `it's ${codec}, which this browser can't play. Re-save it as ${alt}, or open the editor in a browser that plays ${codec}.`
239
+ : `this browser can't play this video's format. Re-save it as ${alt}.`);
240
+ err.code = e.code;
241
+ throw err;
242
+ }
243
+ throw e;
244
+ }
245
+ const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'video', source: video, width: video.videoWidth, height: video.videoHeight, duration: video.duration, fps: 30, file };
246
+ await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
247
+ item.thumb = await makeThumb(video, item.width, item.height);
248
+ app.items.push(item);
249
+ estimateFps(video, 30).then(f => { item.fps = f; if (app.current === item) updateTransport(); inspector.syncAnim(); });
250
+ video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
251
+ video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
252
+ video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
253
+ video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
254
+ return item;
255
+ }
256
+
257
+ async function addFiles(files) {
258
+ const media = [], palettes = [], settingsFiles = [];
259
+ for (const f of files) {
260
+ if (PALETTE_EXT.test(f.name) && !/^image|^video/.test(f.type)) {
261
+ if (/\.json$/i.test(f.name)) settingsFiles.push(f); else palettes.push(f);
262
+ } else if (IMAGE_TYPES.test(f.type) || VIDEO_TYPES.test(f.type) || /\.(png|jpe?g|gif|webp|bmp|svg|avif|mp4|webm|mov)$/i.test(f.name)) media.push(f);
263
+ }
264
+ for (const f of palettes) await importPaletteFile(f);
265
+ for (const f of settingsFiles) {
266
+ const text = await f.text();
267
+ try {
268
+ const obj = JSON.parse(text);
269
+ if (obj.colors || Array.isArray(obj)) await importPaletteFile(f, text);
270
+ else { store.replace(settingsFromJSON(obj)); toast(`Loaded settings from ${f.name}`, { kind: 'ok' }); }
271
+ } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
272
+ }
273
+ let first = null;
274
+ const t = media.length > 2 ? toast(`Loading ${media.length} files`, { progress: true }) : null;
275
+ for (let i = 0; i < media.length; i++) {
276
+ const f = media[i];
277
+ try {
278
+ let item;
279
+ if (VIDEO_TYPES.test(f.type) || /\.(mp4|webm|mov)$/i.test(f.name)) item = await addVideo(f.name, f);
280
+ else {
281
+ let drawable;
282
+ try { drawable = await createImageBitmap(f); } catch {
283
+ drawable = new Image(); drawable.src = URL.createObjectURL(f); await drawable.decode();
284
+ }
285
+ item = await addImage(f.name, drawable, f);
286
+ }
287
+ first ||= item;
288
+ } catch (e) { toast(`${f.name} could not be opened: ${e.message}`, { kind: 'error' }); }
289
+ t?.progress((i + 1) / media.length);
290
+ }
291
+ t?.close();
292
+ if (first) select(first);
293
+ library.refresh();
294
+ if (media.length > 1) toast(`Added ${media.length} files. They share one set of settings.`, { kind: 'ok' });
295
+ }
296
+ app.addFiles = addFiles;
297
+
298
+ async function openFiles() {
299
+ const files = await pickFiles({ accept: 'image/*,video/*,.gpl,.hex,.pal,.txt,.json', multiple: true });
300
+ if (files.length) addFiles(files);
301
+ }
302
+ app.openFiles = openFiles;
303
+
304
+ async function openSample(s) {
305
+ const img = await loadSample(s);
306
+ const item = await addImage(`${s.name}.svg`, img);
307
+ select(item);
308
+ library.refresh();
309
+ }
310
+
311
+ async function pasteFromClipboard() {
312
+ try {
313
+ const items = await navigator.clipboard.read();
314
+ const files = [];
315
+ for (const it of items) for (const type of it.types) if (IMAGE_TYPES.test(type)) {
316
+ const b = await it.getType(type);
317
+ files.push(new File([b], `pasted-${new Date().toISOString().slice(11, 19).replace(/:/g, '')}.${type.split('/')[1]}`, { type }));
318
+ }
319
+ if (files.length) addFiles(files); else toast('The clipboard has no image', { kind: 'error' });
320
+ } catch {
321
+ toast('Press Ctrl+V (⌘V) to paste. The browser blocked direct clipboard access.');
322
+ }
323
+ }
324
+ app.pasteFromClipboard = pasteFromClipboard;
325
+
326
+ function select(item) {
327
+ if (app.current && app.current.type === 'video' && app.current !== item) app.current.source.pause();
328
+ app.current = item;
329
+ editTarget(item);
330
+ stageWrap.dataset.video = String(item && item.type === 'video');
331
+ if (item && item.type === 'video') updateTransport();
332
+ stage.clear();
333
+ app.result = null;
334
+ if (stage.picking) togglePicker();
335
+ library.refresh();
336
+ library.sourceChanged();
337
+ schedule();
338
+ updateStatus();
339
+ }
340
+ app.select = select;
341
+
342
+ function removeItem(item) {
343
+ const i = app.items.indexOf(item);
344
+ if (i < 0) return;
345
+ app.items.splice(i, 1);
346
+ store.dropTarget(item.id);
347
+ if (item.type === 'video') { item.source.pause(); URL.revokeObjectURL(item.source.src); }
348
+ if (item.source.close && !app.items.some(i => i.source === item.source)) item.source.close(); // duplicates share a bitmap
349
+ if (app.current === item) select(app.items[Math.min(i, app.items.length - 1)] || null);
350
+ library.refresh();
351
+ inspector.sync(store.settings, app.result);
352
+ }
353
+ app.removeItem = removeItem;
354
+ app.clearItems = () => { for (const it of app.items.slice()) removeItem(it); };
355
+
356
+ // ================================================================ per-file settings
357
+ /** The settings a file renders with. */
358
+ const settingsFor = item => (item === app.current ? store.settings : (item && item.own) || app.shared);
359
+ app.settingsFor = settingsFor;
360
+ const clone = v => JSON.parse(JSON.stringify(v));
361
+
362
+ /** Point the store at the right settings for this file (its own, or the shared ones). */
363
+ function editTarget(item, force = false) {
364
+ const key = item && item.own ? item.id : 'shared';
365
+ if (store.target === key && !force) return;
366
+ store.setTarget(key, key === 'shared' ? app.shared : item.own);
367
+ }
368
+
369
+ /** Give a file its own settings (a copy of what it uses now), or link it back to the shared ones. */
370
+ function setOwn(item, own, { quiet = false } = {}) {
371
+ if (!item || !!item.own === own) return;
372
+ if (own) {
373
+ item.own = clone(settingsFor(item));
374
+ if (item === app.current) editTarget(item);
375
+ if (!quiet) toast(`${item.name} now has its own settings. Changes no longer affect other files.`);
376
+ } else {
377
+ const was = item.own;
378
+ item.own = null;
379
+ store.dropTarget(item.id);
380
+ if (item === app.current) editTarget(item);
381
+ if (!quiet) toast(`${item.name} uses the shared settings again`, { action: { label: 'Undo', onClick: () => { item.own = was; if (item === app.current) editTarget(item); afterScopeChange(); } } });
382
+ }
383
+ afterScopeChange();
384
+ }
385
+ function afterScopeChange() {
386
+ library.refresh();
387
+ inspector.sync(store.settings, app.result);
388
+ if (app.result) inspector.setStats(app.result);
389
+ schedule();
390
+ }
391
+ app.setOwn = setOwn;
392
+
393
+ app.copySettings = item => {
394
+ app.clipboard = clone(settingsFor(item));
395
+ toast(`Copied the settings of ${item.name}`, { timeout: 1800 });
396
+ library.refresh();
397
+ };
398
+ app.pasteSettings = item => {
399
+ if (!app.clipboard) return;
400
+ if (!item.own) setOwn(item, true, { quiet: true });
401
+ if (item === app.current) store.replace(app.clipboard, 'paste');
402
+ else item.own = clone(app.clipboard);
403
+ toast(`Pasted settings onto ${item.name}`, { kind: 'ok', timeout: 1800 });
404
+ afterScopeChange();
405
+ };
406
+ /** Make this file's settings the shared ones, and link every file to them. */
407
+ app.applyToAll = item => {
408
+ const snapshot = { shared: app.shared, own: new Map(app.items.map(i => [i, i.own])) };
409
+ app.shared = clone(settingsFor(item));
410
+ for (const it of app.items) { if (it.own) store.dropTarget(it.id); it.own = null; }
411
+ store.setTarget('shared', app.shared);
412
+ if (usePersist) saveSoon();
413
+ toast(`Every file now uses the settings of ${item.name}`, {
414
+ action: { label: 'Undo', onClick: () => {
415
+ app.shared = snapshot.shared;
416
+ for (const [it, own] of snapshot.own) it.own = own;
417
+ editTarget(app.current, true);
418
+ afterScopeChange();
419
+ } },
420
+ });
421
+ afterScopeChange();
422
+ };
423
+ /** A second copy of a file with its own settings, for comparing two looks. */
424
+ app.duplicate = async item => {
425
+ let copy;
426
+ if (item.type === 'video') copy = await addVideo(nameCopy(item.name), item.file);
427
+ else copy = await addImage(nameCopy(item.name), item.source, item.file);
428
+ // place it right after the original
429
+ app.items.splice(app.items.indexOf(copy), 1);
430
+ app.items.splice(app.items.indexOf(item) + 1, 0, copy);
431
+ copy.own = clone(settingsFor(item));
432
+ select(copy);
433
+ toast(`Duplicated ${item.name}. The copy has its own settings.`, { timeout: 2400 });
434
+ };
435
+ const nameCopy = name => { const m = /^(.*?)(\.[^.]*)?$/.exec(name); return `${m[1]} copy${m[2] || ''}`; };
436
+ app.move = (item, dir) => {
437
+ const i = app.items.indexOf(item), j = i + dir;
438
+ if (i < 0 || j < 0 || j >= app.items.length) return;
439
+ [app.items[i], app.items[j]] = [app.items[j], app.items[i]];
440
+ library.refresh();
441
+ if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
442
+ };
443
+
444
+ // ================================================================ video
445
+ function updateTransport() {
446
+ const it = app.current;
447
+ if (!it || it.type !== 'video') return;
448
+ const v = it.source;
449
+ playBtn.innerHTML = icon(v.paused ? 'play' : 'pause');
450
+ playBtn.setAttribute('aria-label', v.paused ? 'Play (Space)' : 'Pause (Space)');
451
+ const frame = Math.floor(v.currentTime * it.fps + 1e-6);
452
+ timeLabel.innerHTML = `${fmtTime(v.currentTime)} <span>/ ${fmtTime(v.duration)}</span>`;
453
+ timeLabel.title = `Frame ${frame}`;
454
+ scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
455
+ scrub.style.setProperty('--p', `${scrub.value / 10}%`);
456
+ fpsLabel.textContent = `${it.fps} fps`;
457
+ }
458
+ function pumpVideo(item) {
459
+ const v = item.source;
460
+ const tick = () => {
461
+ if (v.paused || app.current !== item) return;
462
+ schedule();
463
+ if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
464
+ };
465
+ tick();
466
+ }
467
+ function togglePlay() {
468
+ const it = app.current;
469
+ if (!it || it.type !== 'video') return;
470
+ if (it.source.paused) it.source.play().catch(e => toast(e.message, { kind: 'error' })); else it.source.pause();
471
+ }
472
+ function stepFrame(dir) {
473
+ const it = app.current;
474
+ if (!it || it.type !== 'video') return;
475
+ const v = it.source; v.pause();
476
+ const frame = Math.floor(v.currentTime * it.fps + 1e-6) + dir;
477
+ seekTo(v, (Math.max(0, frame) + 0.5) / it.fps).then(() => { updateTransport(); schedule(); });
478
+ }
479
+ scrub.addEventListener('input', () => {
480
+ const it = app.current; if (!it || it.type !== 'video') return;
481
+ it.source.currentTime = scrub.value / 1000 * it.source.duration;
482
+ });
483
+
484
+ // ================================================================ view
485
+ function toggleCompare(force) {
486
+ const on = force ?? !stage.compare;
487
+ stage.setCompare(on); compareBtn.setAttribute('aria-pressed', String(on));
488
+ }
489
+ function toggleGrid(force) {
490
+ const on = force ?? !stage.grid;
491
+ stage.setGrid(on); gridBtn.setAttribute('aria-pressed', String(on));
492
+ if (on && stage.zoom < 6 && app.result) toast('The grid shows from 6× zoom');
493
+ }
494
+ function zoomMenu(anchor) {
495
+ menu(anchor, [
496
+ { label: 'Fit to view', kbd: 'F', icon: 'fit', onClick: () => stage.fit() },
497
+ 'sep',
498
+ ...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 ? 'One screen pixel per pixel' : null, onClick: () => stage.zoomTo(z) })),
499
+ 'sep',
500
+ { label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () => stage.zoomIn() },
501
+ { label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => stage.zoomOut() },
502
+ ]);
503
+ }
504
+ function togglePanel(which) {
505
+ const key = which === 'lib' ? 'lib' : 'insp';
506
+ const open = shell.dataset[key] !== 'open';
507
+ shell.dataset[key] = open ? 'open' : 'closed';
508
+ if (!narrow() || which === 'insp') persist.save(key, shell.dataset[key]);
509
+ requestAnimationFrame(() => stage.resize());
510
+ }
511
+ function applyTheme(t) {
512
+ if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
513
+ const dark = t ? t === 'dark' : !matchMedia('(prefers-color-scheme: light)').matches;
514
+ themeBtn.innerHTML = icon(dark ? 'sun' : 'moon');
515
+ themeBtn.setAttribute('aria-label', dark ? 'Switch to light theme (T)' : 'Switch to dark theme (T)');
516
+ stage.redraw();
517
+ }
518
+ function toggleTheme() {
519
+ const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
520
+ const next = cur === 'dark' ? 'light' : 'dark';
521
+ persist.save('theme', next);
522
+ applyTheme(next);
523
+ }
524
+
525
+ // ================================================================ palettes
526
+ function pixelsOf(item, maxSize = 4096) {
527
+ const w = item.width, hh = item.height, k = Math.min(1, maxSize / Math.max(w, hh));
528
+ const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(hh * k);
529
+ const ctx = c.getContext('2d', { willReadFrequently: true });
530
+ ctx.drawImage(item.source, 0, 0, c.width, c.height);
531
+ return ctx.getImageData(0, 0, c.width, c.height);
532
+ }
533
+ function pickAt(u, v) {
534
+ const it = app.current; if (!it) return;
535
+ const img = pixelsOf(it, 1024);
536
+ const c = app.result ? app.result.crop : { x: 0, y: 0, w: 1, h: 1 };
537
+ const x = Math.min(img.width - 1, Math.floor((c.x + u * c.w) * img.width)), y = Math.min(img.height - 1, Math.floor((c.y + v * c.h) * img.height));
538
+ const o = (y * img.width + x) * 4;
539
+ const hex = toHex([img.data[o], img.data[o + 1], img.data[o + 2]]);
540
+ const s = store.settings;
541
+ const colors = s.palette.source === 'custom' ? s.palette.colors : [];
542
+ if (colors.includes(hex)) return toast(`${hex} is already in the palette`);
543
+ store.update({ palette: { source: 'custom', name: s.palette.source === 'custom' ? s.palette.name : 'Picked', colors: [...colors, hex] } });
544
+ toast(`Added ${hex}`, { kind: 'ok', timeout: 1500 });
545
+ }
546
+ let pickerBtn = null;
547
+ function togglePicker(button) {
548
+ if (button) pickerBtn = button;
549
+ const on = !stage.picking;
550
+ stage.setPicking(on);
551
+ pickerBtn?.setAttribute('aria-pressed', String(on));
552
+ if (on) {
553
+ toggleCompare(true);
554
+ toast('Click the image to add colours. Press Esc when done.');
555
+ }
556
+ }
557
+ app.togglePicker = togglePicker;
558
+
559
+ function usePalette(p) {
560
+ if (p.id && !p.custom) store.update({ palette: { source: 'builtin', id: p.id } });
561
+ else store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
562
+ }
563
+ app.usePalette = usePalette;
564
+
565
+ function rememberPalette(name, colors) {
566
+ const list = app.userPalettes.filter(p => p.name !== name);
567
+ list.unshift({ name, colors, custom: true });
568
+ app.userPalettes = list.slice(0, 100);
569
+ if (usePersist) persist.save('palettes', app.userPalettes);
570
+ if (library.tab === 'palettes') library.refresh();
571
+ }
572
+ async function importPaletteFile(file, text) {
573
+ try {
574
+ const p = parsePalette(text ?? await file.text(), file.name);
575
+ rememberPalette(p.name || 'Imported', p.colors);
576
+ store.update({ palette: { source: 'custom', name: p.name || 'Imported', colors: p.colors } });
577
+ toast(`Imported "${p.name}" (${p.colors.length} colours)`, { kind: 'ok' });
578
+ } catch (e) { toast(`${file.name}: ${e.message}`, { kind: 'error' }); }
579
+ }
580
+ app.importPalette = async () => {
581
+ const [f] = await pickFiles({ accept: '.gpl,.hex,.pal,.txt,.json' });
582
+ if (f) importPaletteFile(f);
583
+ };
584
+ app.savePalette = () => {
585
+ const s = store.settings;
586
+ const colors = s.palette.source === 'extract' ? (app.result?.palette || []) : s.palette.colors;
587
+ if (!colors.length) return toast('There is no palette to save', { kind: 'error' });
588
+ const input = h('input.itp-input', { value: s.palette.source === 'custom' ? s.palette.name : `${s.palette.name} copy`, 'aria-label': 'Palette name' });
589
+ const fmt = h('select.itp-select', { 'aria-label': 'Also download as' }, h('option', { value: '', text: "Don't download" }), h('option', { value: 'gpl', text: '.gpl (GIMP, Aseprite)' }), h('option', { value: 'hex', text: '.hex (Lospec)' }), h('option', { value: 'json', text: '.json' }));
590
+ dialog('Save palette', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Name' })), input), h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Also download as' })), fmt),
591
+ h('div.itp-strip', { style: { height: '18px' } }, ...colors.map(c => h('i', { style: { background: c } })))], {
592
+ actions: [{ label: 'Cancel' }, { label: 'Save', primary: true, onClick: () => {
593
+ const name = input.value.trim() || 'Palette';
594
+ rememberPalette(name, colors);
595
+ store.update({ palette: { source: 'custom', name, colors } });
596
+ const slug = name.replace(/[^\w-]+/g, '-').toLowerCase();
597
+ if (fmt.value === 'gpl') downloadBlob(toGPL(name, colors), `${slug}.gpl`, 'text/plain');
598
+ if (fmt.value === 'hex') downloadBlob(toHexFile(colors), `${slug}.hex`, 'text/plain');
599
+ if (fmt.value === 'json') downloadBlob(toPaletteJSON(name, colors), `${slug}.json`, 'application/json');
600
+ toast(`Saved "${name}" to Your palettes`, { kind: 'ok' });
601
+ } }],
602
+ });
603
+ input.select();
604
+ };
605
+ app.deletePalette = p => {
606
+ app.userPalettes = app.userPalettes.filter(x => x !== p);
607
+ if (usePersist) persist.save('palettes', app.userPalettes);
608
+ library.refresh();
609
+ toast(`Deleted "${p.name}"`, { action: { label: 'Undo', onClick: () => rememberPalette(p.name, p.colors) } });
610
+ };
611
+ app.lospecDialog = () => {
612
+ const input = h('input.itp-input', { placeholder: 'e.g. rgbg-36 or a lospec.com palette URL', 'aria-label': 'Lospec slug or URL' });
613
+ const note = h('div.help', { text: 'Looks the palette up on lospec.com. Needs an internet connection.' });
614
+ const d = dialog('Palette from Lospec', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Slug or URL' })), input, note)], {
615
+ actions: [{ label: 'Cancel' }, { label: 'Fetch', primary: true, onClick: async () => {
616
+ note.textContent = 'Fetching…'; note.style.color = '';
617
+ try {
618
+ const p = await fetchLospec(input.value);
619
+ rememberPalette(p.name, p.colors);
620
+ store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
621
+ toast(`Loaded "${p.name}" from Lospec`, { kind: 'ok' });
622
+ return true;
623
+ } catch (e) { note.textContent = e.message; note.style.color = 'var(--danger)'; return false; }
624
+ } }],
625
+ });
626
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') d.querySelector('footer .primary').click(); });
627
+ input.focus();
628
+ };
629
+
630
+ app.detectGrid = () => {
631
+ const it = app.current; if (!it || it.type !== 'image') return toast('Open an image first', { kind: 'error' });
632
+ const b = detectGrid(pixelsOf(it));
633
+ if (b <= 1) return toast('No pixel grid found. This image doesn\'t look upscaled.');
634
+ store.update({ size: { mode: 'block', block: b, resample: 'nearest' } });
635
+ toast(`Found ${b} px blocks: switched to pixel size ${b} with nearest sampling`, { kind: 'ok' });
636
+ };
637
+
638
+ // ================================================================ settings
639
+ app.settingsJSON = () => settingsToJSON(store.settings);
640
+ app.copyLink = async () => {
641
+ const url = `${location.origin}${location.pathname}#s=${encodeSettings(store.settings)}`;
642
+ try { await navigator.clipboard.writeText(url); toast('Link copied. It holds the settings, not the image.', { kind: 'ok' }); }
643
+ catch { dialog('Share link', [h('textarea.itp-input', { rows: 3, readOnly: true, value: url })]); }
644
+ history.replaceState(null, '', `#s=${encodeSettings(store.settings)}`);
645
+ };
646
+ app.saveSettingsFile = () => downloadBlob(settingsToJSON(store.settings), 'image-to-pixel-settings.json', 'application/json');
647
+ app.loadSettingsFile = async () => {
648
+ const [f] = await pickFiles({ accept: '.json,application/json' });
649
+ if (!f) return;
650
+ try { store.replace(settingsFromJSON(await f.text())); toast(`Loaded ${f.name}`, { kind: 'ok' }); } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
651
+ };
652
+ app.resetSettings = () => { store.replace(defaultSettings()); toast('Settings reset', { action: { label: 'Undo', onClick: () => store.undo() } }); };
653
+ app.applyPreset = p => {
654
+ store.replace(mergeSettings(defaultSettings(), { ...p.settings, export: store.settings.export }), 'preset');
655
+ app.activePreset = p.id;
656
+ library.refresh();
657
+ };
658
+
659
+ Object.assign(app, exporter);
660
+
661
+ function exportMenu(anchor) {
662
+ const it = app.current;
663
+ const video = it && it.type === 'video';
664
+ const images = app.items.filter(i => i.type === 'image').length;
665
+ const s = store.settings;
666
+ menu(anchor, [
667
+ { group: 'Image' },
668
+ { label: 'PNG', sub: app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first', icon: 'image', kbd: 'Ctrl S', disabled: !app.result, onClick: () => app.exportPNG() },
669
+ { label: 'Copy to clipboard', icon: 'copy', disabled: !app.result, onClick: () => app.copyImage() },
670
+ { label: app.items.length > 1 ? `All ${app.items.length} files as ZIP` : 'All files as ZIP', sub: app.items.length > 1 ? null : 'Add two or more files', icon: 'zip', disabled: app.items.length < 2, onClick: () => app.exportZip() },
671
+ { group: 'Animation' },
672
+ { label: video ? 'GIF from this video' : 'GIF from all images', icon: 'gif', disabled: !video && images < 2, onClick: () => app.exportAnimation({ ...animDefaults(), format: 'gif' }) },
673
+ { label: 'More formats…', sub: 'WebM, MP4, PNG sequence, sprite sheet', icon: 'film', onClick: () => { inspector.setTab('export'); if (shell.dataset.insp !== 'open') togglePanel('insp'); } },
674
+ { group: 'Other' },
675
+ { label: 'Tileset', sub: '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
676
+ { label: 'Copy settings link', icon: 'link', onClick: () => app.copyLink() },
677
+ { label: 'Settings as JSON', icon: 'braces', onClick: () => app.saveSettingsFile() },
678
+ ]);
679
+ }
680
+ const animDefaults = () => {
681
+ const it = app.current;
682
+ const video = it && it.type === 'video';
683
+ return { source: video ? 'video' : 'files', fps: video ? Math.min(30, Math.round(it.fps || 12)) : 8, start: 0, end: video ? it.duration : 0 };
684
+ };
685
+
686
+ // ================================================================ help
687
+ function showHelp() {
688
+ const keys = [
689
+ ['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
690
+ ['Compare', 'C'], ['Pixel grid', 'G'], ['Fit', 'F'], ['Zoom in / out', '+ -'], ['Pixel width ±1', '[ ]'], ['Pixel width ±8', 'Shift [ ]'],
691
+ ['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
692
+ ['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
693
+ ];
694
+ dialog('Keyboard shortcuts', [h('div.itp-keys', {}, ...keys.map(([a, k]) => h('div', {}, h('span', { text: a }), h('span', {}, ...k.split(' ').filter(Boolean).map(x => h('kbd', { text: x }))))))]);
695
+ }
696
+
697
+ // ================================================================ input
698
+ const typing = e => e.target.closest('input, select, textarea, [contenteditable="true"]') && !(e.target.type === 'range' || e.target.type === 'checkbox');
699
+ const DITHER_ORDER = ['none', 'bayer4', 'bayer8', 'bluenoise', 'cluster4', 'floyd-steinberg', 'atkinson', 'sierra-lite', 'jarvis'];
700
+ window.addEventListener('keydown', e => {
701
+ if (!shell.isConnected) return;
702
+ if (document.querySelector('dialog[open]')) return;
703
+ const mod = e.ctrlKey || e.metaKey;
704
+ const k = e.key;
705
+ if (mod && k.toLowerCase() === 'z') { if (typing(e)) return; e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return; }
706
+ if (mod && k.toLowerCase() === 'y') { if (typing(e)) return; e.preventDefault(); store.redo(); return; }
707
+ if (mod && k.toLowerCase() === 's') { e.preventDefault(); app.exportPNG(); return; }
708
+ if (mod && k.toLowerCase() === 'o') { e.preventDefault(); openFiles(); return; }
709
+ if (mod || e.altKey && !['ArrowUp', 'ArrowDown'].includes(k)) return;
710
+ if (typing(e)) { if (k === 'Escape') e.target.blur(); return; }
711
+ const s = store.settings;
712
+ switch (k) {
713
+ case 'c': case 'C': toggleCompare(); break;
714
+ case 'g': case 'G': toggleGrid(); break;
715
+ case 'f': case 'F': stage.fit(); break;
716
+ case '+': case '=': stage.zoomIn(); break;
717
+ case '-': case '_': stage.zoomOut(); break;
718
+ case 't': case 'T': toggleTheme(); break;
719
+ case '\\': togglePanel('lib'); break;
720
+ case '/': togglePanel('insp'); break;
721
+ case '?': showHelp(); break;
722
+ case '[': case '{': store.update({ size: { mode: 'width', width: Math.max(8, s.size.width - (e.shiftKey ? 8 : 1)) } }, { key: 'kw' }); break;
723
+ case ']': case '}': store.update({ size: { mode: 'width', width: s.size.width + (e.shiftKey ? 8 : 1) } }, { key: 'kw' }); break;
724
+ case 'd': case 'D': {
725
+ const i = DITHER_ORDER.indexOf(s.dither.method);
726
+ const next = DITHER_ORDER[(i + (e.shiftKey ? -1 : 1) + DITHER_ORDER.length) % DITHER_ORDER.length];
727
+ store.update({ dither: { method: next } });
728
+ toast(next === 'none' ? 'No dither' : next.replace(/-/g, ' '), { timeout: 900 });
729
+ break;
730
+ }
731
+ case ' ': if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
732
+ case ',': stepFrame(-1); break;
733
+ case '.': stepFrame(1); break;
734
+ case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
735
+ case 'ArrowDown': case 'ArrowUp':
736
+ if (e.altKey && e.shiftKey && app.current) { e.preventDefault(); app.move(app.current, k === 'ArrowDown' ? 1 : -1); break; }
737
+ if (e.altKey && app.items.length > 1) {
738
+ e.preventDefault();
739
+ const i = app.items.indexOf(app.current);
740
+ select(app.items[(i + (k === 'ArrowDown' ? 1 : -1) + app.items.length) % app.items.length]);
741
+ }
742
+ break;
743
+ default: return;
744
+ }
745
+ });
746
+
747
+ // drag & drop, paste
748
+ let dragDepth = 0;
749
+ shell.addEventListener('dragenter', e => { if ([...e.dataTransfer.types].includes('Files')) { dragDepth++; shell.classList.add('itp-dragging'); } });
750
+ shell.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; shell.classList.remove('itp-dragging'); } });
751
+ shell.addEventListener('dragover', e => { if ([...e.dataTransfer.types].includes('Files')) e.preventDefault(); });
752
+ shell.addEventListener('drop', e => {
753
+ e.preventDefault(); dragDepth = 0; shell.classList.remove('itp-dragging');
754
+ if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
755
+ });
756
+ window.addEventListener('paste', e => {
757
+ if (!shell.isConnected || typing(e)) return;
758
+ const files = [...(e.clipboardData?.files || [])];
759
+ if (files.length) { e.preventDefault(); addFiles(files); return; }
760
+ const text = e.clipboardData?.getData('text') || '';
761
+ if (/#?[0-9a-f]{6}/i.test(text) && /\n|,|\s/.test(text.trim())) {
762
+ try { const p = parsePalette(text); store.update({ palette: { source: 'custom', name: 'Pasted', colors: p.colors } }); toast(`Pasted a ${p.colors.length}-colour palette`, { kind: 'ok' }); } catch { /* not a palette */ }
763
+ }
764
+ });
765
+ window.addEventListener('hashchange', () => {
766
+ if (/[#&]s=/.test(location.hash)) { try { store.replace(decodeSettings(location.hash)); } catch { /* ignore */ } }
767
+ });
768
+
769
+ // first paint
770
+ inspector.sync(store.settings, null);
771
+ library.refresh();
772
+ updateStatus();
773
+ undoBtn.disabled = redoBtn.disabled = true;
774
+
775
+ const api = {
776
+ app, store,
777
+ /** Add File objects (images, videos, palettes, settings JSON). */
778
+ addFiles,
779
+ /** Add an image-like source directly. */
780
+ async addImage(source, name = 'image') { const it = await addImage(name, source); select(it); library.refresh(); return it; },
781
+ openSample: id => openSample(SAMPLES.find(s => s.id === id) || SAMPLES[0]),
782
+ applyPreset: id => { const p = PRESETS.find(x => x.id === id); if (p) app.applyPreset(p); },
783
+ setTheme: t => { persist.save('theme', t); applyTheme(t); },
784
+ get result() { return app.result; },
785
+ /** On-screen rectangle of the image in the canvas. */
786
+ get view() { return stage.rect; },
787
+ /** Resolves after pending renders finish. */
788
+ async idle() { for (let i = 0; i < 200 && (dirty || running); i++) await new Promise(r => setTimeout(r, 25)); },
789
+ };
790
+ return api;
791
+ }
792
+
793
+ export { SAMPLES };