image-to-pixel 3.0.1 → 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.
@@ -1,185 +1,225 @@
1
- // Every export the editor offers. Long ones show a progress toast that can be cancelled.
2
-
3
- import { encodePNG } from '../core/export/png.js';
4
- import { encodeGIF } from '../core/export/gif.js';
5
- import { createZip } from '../core/export/zip.js';
6
- import { packSpriteSheet, buildTileset } from '../core/export/sheet.js';
7
- import { downloadBlob } from '../core/export/canvas.js';
8
- import { forEachFrame } from '../core/video/frames.js';
9
- import { mergeSettings, settingsToJSON } from '../core/settings.js';
10
- import { toast } from './ui.js';
11
-
12
- const base = name => (name || 'image').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'image';
13
- const pngOpts = s => ({ scale: s.export.scale, grid: s.export.grid, gridColor: s.export.gridColor, indexed: s.export.indexed });
14
-
15
- /** Pixel size of an exported image: whole-number scale, or the framed part of the source at full resolution. */
16
- export function exportSize(s, r, item) {
17
- if (s.export.size === 'source' && r && item && r.crop) return { width: Math.max(1, Math.round(r.crop.w * item.width)), height: Math.max(1, Math.round(r.crop.h * item.height)) };
18
- return { width: r ? r.width * s.export.scale : 0, height: r ? r.height * s.export.scale : 0 };
19
- }
20
- /** PNG options for one exported image. */
21
- const imageOpts = (s, r, item) => (s.export.size === 'source' && item ? { ...exportSize(s, r, item), indexed: s.export.indexed } : pngOpts(s));
22
-
23
- export function createExporter(app) {
24
- const S = () => app.store.settings;
25
-
26
- async function exportPNG() {
27
- if (!app.result) return toast('Open an image first', { kind: 'error' });
28
- const s = S();
29
- const bytes = await encodePNG(app.result, imageOpts(s, app.result, app.current));
30
- const out = exportSize(s, app.result, app.current);
31
- const suffix = s.export.size === 'source' ? `-${out.width}x${out.height}` : s.export.scale > 1 ? `@${s.export.scale}x` : '';
32
- downloadBlob(bytes, `${base(app.current.name)}-${app.result.width}x${app.result.height}${suffix}.png`, 'image/png');
33
- toast(`Saved ${out.width} × ${out.height} PNG`, { kind: 'ok' });
34
- }
35
-
36
- async function copyImage() {
37
- if (!app.result) return;
38
- try {
39
- const bytes = await encodePNG(app.result, { ...imageOpts(S(), app.result, app.current), indexed: false });
40
- await navigator.clipboard.write([new ClipboardItem({ 'image/png': new Blob([bytes], { type: 'image/png' }) })]);
41
- toast('Image copied', { kind: 'ok' });
42
- } catch (e) { toast(`Couldn't copy: ${e.message}`, { kind: 'error' }); }
43
- }
44
-
45
- /** Run `fn` with a cancellable progress toast. */
46
- async function task(label, fn) {
47
- const ctrl = new AbortController();
48
- const t = toast(label, { progress: true, onCancel: () => ctrl.abort() });
49
- app.exporting = true;
50
- try {
51
- const msg = await fn(ctrl.signal, (p, m) => { t.progress(p); if (m) t.update(m); });
52
- t.done(msg);
53
- } catch (e) {
54
- t.close();
55
- if (e.name !== 'AbortError') { toast(`Export failed: ${e.message}`, { kind: 'error' }); console.error(e); }
56
- else toast('Export cancelled');
57
- } finally {
58
- app.exporting = false;
59
- app.schedule();
60
- }
61
- }
62
-
63
- async function exportZip() {
64
- const items = app.items.slice();
65
- if (!items.length) return;
66
- await task(`Processing ${items.length} files`, async (signal, progress) => {
67
- const entries = [];
68
- const own = [];
69
- for (let i = 0; i < items.length; i++) {
70
- if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
71
- const it = items[i];
72
- const s = app.settingsFor(it); // each file with its own settings, if it has them
73
- if (it.type === 'video') app.engine.invalidate();
74
- const r = await app.engine.process(it.source, s);
75
- entries.push({ name: `${base(it.name)}.png`, data: await encodePNG(r, imageOpts(s, r, it)) });
76
- if (it.own) own.push({ name: `${base(it.name)}.settings.json`, data: settingsToJSON(s) });
77
- progress((i + 1) / items.length, `Processed ${i + 1} of ${items.length}`);
78
- }
79
- entries.push({ name: 'settings.json', data: settingsToJSON(app.shared) }, ...own);
80
- downloadBlob(createZip(entries), 'pixel-art.zip', 'application/zip');
81
- return `Saved ${items.length} images as a ZIP`;
82
- });
83
- }
84
-
85
- /** Process the frames chosen in the Animation section; calls onFrame(result, i, total). */
86
- async function frames(opts, signal, progress, onFrame) {
87
- const s = S();
88
- if (opts.source === 'files') {
89
- const items = app.items.filter(i => i.type === 'image');
90
- let size = null;
91
- for (let i = 0; i < items.length; i++) {
92
- if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
93
- // each file keeps its own look, but every frame must share the first frame's size
94
- const own = app.settingsFor(items[i]);
95
- const r = await app.engine.process(items[i].source, size ? mergeSettings(own, { size }) : own);
96
- if (!size) size = { mode: 'width', width: r.width, height: r.height };
97
- await onFrame(r, i, items.length, items[i].name);
98
- progress((i + 1) / items.length, `Frame ${i + 1} of ${items.length}`);
99
- }
100
- return items.length;
101
- }
102
- const item = app.current;
103
- if (!item || item.type !== 'video') throw new Error('Open a video first');
104
- const video = item.source;
105
- const end = opts.end > opts.start ? opts.end : video.duration;
106
- return forEachFrame(video, {
107
- fps: opts.fps, start: opts.start, end, signal,
108
- onFrame: async ({ index, total }) => {
109
- app.engine.invalidate();
110
- const r = await app.engine.process(video, s);
111
- await onFrame(r, index, total, `${base(item.name)}-${String(index).padStart(4, '0')}`);
112
- progress((index + 1) / total, `Frame ${index + 1} of ${total}`);
113
- },
114
- });
115
- }
116
-
117
- async function exportAnimation(opts) {
118
- const name = opts.source === 'files' ? 'animation' : base(app.current?.name);
119
- const s = S();
120
- const delay = 1000 / opts.fps;
121
- if (opts.format === 'gif' || opts.format === 'png-seq') {
122
- await task('Rendering frames', async (signal, progress) => {
123
- const list = [];
124
- await frames(opts, signal, progress, async (r, i, total, frameName) => {
125
- if (opts.format === 'gif') list.push({ width: r.width, height: r.height, data: r.data, delay });
126
- else list.push({ name: `${frameName}.png`, data: await encodePNG(r, pngOpts(s)) });
127
- });
128
- if (opts.format === 'gif') {
129
- progress(1, 'Encoding GIF');
130
- await new Promise(r => setTimeout(r, 0));
131
- const bytes = encodeGIF(list, { scale: s.export.scale, loop: 0 });
132
- downloadBlob(bytes, `${name}.gif`, 'image/gif');
133
- return `Saved GIF (${list.length} frames, ${(bytes.length / 1024).toFixed(0)} KB)`;
134
- }
135
- downloadBlob(createZip(list), `${name}-frames.zip`, 'application/zip');
136
- return `Saved ${list.length} PNG frames`;
137
- });
138
- return;
139
- }
140
- // MP4 / WebM through WebCodecs
141
- await task(`Encoding ${opts.format.toUpperCase()}`, async (signal, progress) => {
142
- const { createVideoEncoder } = await import('../video.js');
143
- let enc = null;
144
- await frames(opts, signal, progress, async r => {
145
- enc ||= await createVideoEncoder({ format: opts.format, fps: opts.fps, width: r.width, height: r.height, scale: Math.max(s.export.scale, Math.ceil(240 / r.height)) });
146
- await enc.add(r);
147
- });
148
- const blob = await enc.finish();
149
- downloadBlob(blob, `${name}.${opts.format}`);
150
- return `Saved ${opts.format.toUpperCase()} (${(blob.size / 1024 / 1024).toFixed(1)} MB)`;
151
- });
152
- }
153
-
154
- async function exportSheet(opts) {
155
- const name = opts.source === 'files' ? 'sheet' : `${base(app.current?.name)}-sheet`;
156
- const s = S();
157
- await task('Building sprite sheet', async (signal, progress) => {
158
- const list = [];
159
- await frames(opts, signal, progress, (r, i, total, frameName) => { list.push({ width: r.width, height: r.height, data: r.data, name: frameName ? base(frameName) : undefined, delay: 1000 / opts.fps }); });
160
- const { image, atlas } = packSpriteSheet(list, { columns: opts.columns, padding: opts.padding, name });
161
- if (s.export.scale > 1) {
162
- // scale the atlas coordinates with the image
163
- const k = s.export.scale;
164
- for (const f of Object.values(atlas.frames)) { for (const key of ['x', 'y', 'w', 'h']) f.frame[key] *= k; f.sourceSize.w *= k; f.sourceSize.h *= k; }
165
- atlas.meta.size.w *= k; atlas.meta.size.h *= k; atlas.meta.scale = String(k);
166
- }
167
- const png = await encodePNG(image, pngOpts(s));
168
- downloadBlob(createZip([{ name: `${name}.png`, data: png }, { name: `${name}.json`, data: JSON.stringify(atlas, null, 2) }]), `${name}.zip`, 'application/zip');
169
- return `Saved a ${list.length}-frame sprite sheet`;
170
- });
171
- }
172
-
173
- async function exportTileset({ tile, flips }) {
174
- if (!app.result) return toast('Open an image first', { kind: 'error' });
175
- const s = S();
176
- const ts = buildTileset(app.result, { tileWidth: tile, tileHeight: tile, flips });
177
- const name = `${base(app.current.name)}-tiles`;
178
- const png = await encodePNG(ts.image, pngOpts(s));
179
- const map = { ...ts.map, image: `${name}.png`, note: 'tiles[] holds one tile index per cell, row by row' + (flips ? '; flips[] is 0 none, 1 horizontal, 2 vertical, 3 both' : '') };
180
- downloadBlob(createZip([{ name: `${name}.png`, data: png }, { name: `${name}.json`, data: JSON.stringify(map, null, 2) }]), `${name}.zip`, 'application/zip');
181
- toast(`Saved ${ts.count} unique tiles (${ts.map.width} × ${ts.map.height} map)`, { kind: 'ok' });
182
- }
183
-
184
- return { exportPNG, copyImage, exportZip, exportAnimation, exportSheet, exportTileset };
185
- }
1
+ // Every export the editor offers. Long ones show a progress toast that can be cancelled.
2
+
3
+ import { encodePNG } from '../core/export/png.js';
4
+ import { encodeGIF } from '../core/export/gif.js';
5
+ import { createZip } from '../core/export/zip.js';
6
+ import { packSpriteSheet, buildTileset } from '../core/export/sheet.js';
7
+ import { downloadBlob } from '../core/export/canvas.js';
8
+ import { forEachFrame } from '../core/video/frames.js';
9
+ import { mergeSettings, settingsToJSON } from '../core/settings.js';
10
+ import { toast } from './ui.js';
11
+
12
+ const base = name => (name || 'image').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'image';
13
+ const pngOpts = s => ({ scale: s.export.scale, grid: s.export.grid, gridColor: s.export.gridColor, indexed: s.export.indexed });
14
+
15
+ /** Pixel size of an exported image: whole-number scale, or the framed part of the source at full resolution. */
16
+ export function exportSize(s, r, item) {
17
+ if (s.export.size === 'source' && r && item && r.crop) return { width: Math.max(1, Math.round(r.crop.w * item.width)), height: Math.max(1, Math.round(r.crop.h * item.height)) };
18
+ return { width: r ? r.width * s.export.scale : 0, height: r ? r.height * s.export.scale : 0 };
19
+ }
20
+ /** PNG options for one exported image. */
21
+ const imageOpts = (s, r, item) => (s.export.size === 'source' && item ? { ...exportSize(s, r, item), indexed: s.export.indexed } : pngOpts(s));
22
+
23
+ /**
24
+ * Whole-number scales to try for a video, best first. 'scale' = the PNG export scale; otherwise a target
25
+ * height ('1080', '720', '2160', measured on the short side), falling back to smaller sizes if the
26
+ * browser's encoder refuses the big one. Never below 240 px tall.
27
+ */
28
+ export function videoScales(size, r, exportScale = 4) {
29
+ const min = Math.max(1, Math.ceil(240 / Math.min(r.width, r.height)));
30
+ const fit = target => {
31
+ const long = Math.round(target * 16 / 9), portrait = r.height > r.width;
32
+ const W = portrait ? target : long, H = portrait ? long : target;
33
+ return Math.max(min, Math.min(Math.floor(W / r.width), Math.floor(H / r.height)));
34
+ };
35
+ const first = size === 'scale' ? Math.max(min, exportScale) : fit(Number(size) || 1080);
36
+ const list = [first, fit(1080), fit(720), min].filter((v, i, a) => v <= first && a.indexOf(v) === i);
37
+ return list.sort((a, b) => b - a);
38
+ }
39
+
40
+ export function createExporter(app) {
41
+ const S = () => app.store.settings;
42
+
43
+ async function exportPNG() {
44
+ if (!app.result) return toast('Open an image first', { kind: 'error' });
45
+ const s = S();
46
+ const bytes = await encodePNG(app.result, imageOpts(s, app.result, app.current));
47
+ const out = exportSize(s, app.result, app.current);
48
+ const suffix = s.export.size === 'source' ? `-${out.width}x${out.height}` : s.export.scale > 1 ? `@${s.export.scale}x` : '';
49
+ downloadBlob(bytes, `${base(app.current.name)}-${app.result.width}x${app.result.height}${suffix}.png`, 'image/png');
50
+ toast(`Saved ${out.width} × ${out.height} PNG`, { kind: 'ok' });
51
+ }
52
+
53
+ async function copyImage() {
54
+ if (!app.result) return;
55
+ try {
56
+ const bytes = await encodePNG(app.result, { ...imageOpts(S(), app.result, app.current), indexed: false });
57
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': new Blob([bytes], { type: 'image/png' }) })]);
58
+ toast('Image copied', { kind: 'ok' });
59
+ } catch (e) { toast(`Couldn't copy: ${e.message}`, { kind: 'error' }); }
60
+ }
61
+
62
+ /** Run `fn` with a cancellable progress toast. */
63
+ async function task(label, fn) {
64
+ const ctrl = new AbortController();
65
+ const t = toast(label, { progress: true, onCancel: () => ctrl.abort() });
66
+ app.exporting = true;
67
+ try {
68
+ const msg = await fn(ctrl.signal, (p, m) => { t.progress(p); if (m) t.update(m); });
69
+ t.done(msg);
70
+ } catch (e) {
71
+ t.close();
72
+ if (e.name !== 'AbortError') { toast(`Export failed: ${e.message}`, { kind: 'error' }); console.error(e); }
73
+ else toast('Export cancelled');
74
+ } finally {
75
+ app.exporting = false;
76
+ app.schedule();
77
+ }
78
+ }
79
+
80
+ async function exportZip() {
81
+ const items = app.items.slice();
82
+ if (!items.length) return;
83
+ await task(`Processing ${items.length} files`, async (signal, progress) => {
84
+ const entries = [];
85
+ const own = [];
86
+ for (let i = 0; i < items.length; i++) {
87
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
88
+ const it = items[i];
89
+ const s = app.settingsFor(it); // each file with its own settings, if it has them
90
+ if (it.type === 'video') app.engine.invalidate();
91
+ const r = await app.engine.process(it.source, s);
92
+ entries.push({ name: `${base(it.name)}.png`, data: await encodePNG(r, imageOpts(s, r, it)) });
93
+ if (it.own) own.push({ name: `${base(it.name)}.settings.json`, data: settingsToJSON(s) });
94
+ progress((i + 1) / items.length, `Processed ${i + 1} of ${items.length}`);
95
+ }
96
+ entries.push({ name: 'settings.json', data: settingsToJSON(app.shared) }, ...own);
97
+ downloadBlob(createZip(entries), 'pixel-art.zip', 'application/zip');
98
+ return `Saved ${items.length} images as a ZIP`;
99
+ });
100
+ }
101
+
102
+ /** Process the frames chosen in the Animation section; calls onFrame(result, i, total). */
103
+ async function frames(opts, signal, progress, onFrame) {
104
+ const s = S();
105
+ if (opts.source === 'files') {
106
+ const items = app.items.filter(i => i.type === 'image');
107
+ let size = null;
108
+ for (let i = 0; i < items.length; i++) {
109
+ if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
110
+ // each file keeps its own look, but every frame must share the first frame's size
111
+ const own = app.settingsFor(items[i]);
112
+ const r = await app.engine.process(items[i].source, size ? mergeSettings(own, { size }) : own);
113
+ if (!size) size = { mode: 'width', width: r.width, height: r.height };
114
+ await onFrame(r, i, items.length, items[i].name);
115
+ progress((i + 1) / items.length, `Frame ${i + 1} of ${items.length}`);
116
+ }
117
+ return items.length;
118
+ }
119
+ const item = app.current;
120
+ if (!item || item.type !== 'video') throw new Error('Open a video first');
121
+ const video = item.source;
122
+ const end = opts.end > opts.start ? opts.end : video.duration;
123
+ return forEachFrame(video, {
124
+ fps: opts.fps, start: opts.start, end, signal,
125
+ onFrame: async ({ index, total }) => {
126
+ app.engine.invalidate();
127
+ const r = await app.engine.process(video, s);
128
+ await onFrame(r, index, total, `${base(item.name)}-${String(index).padStart(4, '0')}`);
129
+ progress((index + 1) / total, `Frame ${index + 1} of ${total}`);
130
+ },
131
+ });
132
+ }
133
+
134
+ async function exportAnimation(opts) {
135
+ const name = opts.source === 'files' ? 'animation' : base(app.current?.name);
136
+ const s = S();
137
+ const delay = 1000 / opts.fps;
138
+ if (opts.format === 'gif' || opts.format === 'png-seq') {
139
+ await task('Rendering frames', async (signal, progress) => {
140
+ const list = [];
141
+ await frames(opts, signal, progress, async (r, i, total, frameName) => {
142
+ if (opts.format === 'gif') list.push({ width: r.width, height: r.height, data: r.data, delay });
143
+ else list.push({ name: `${frameName}.png`, data: await encodePNG(r, pngOpts(s)) });
144
+ });
145
+ if (opts.format === 'gif') {
146
+ progress(1, 'Encoding GIF');
147
+ await new Promise(r => setTimeout(r, 0));
148
+ const bytes = encodeGIF(list, { scale: s.export.scale, loop: 0 });
149
+ downloadBlob(bytes, `${name}.gif`, 'image/gif');
150
+ return `Saved GIF (${list.length} frames, ${(bytes.length / 1024).toFixed(0)} KB)`;
151
+ }
152
+ downloadBlob(createZip(list), `${name}-frames.zip`, 'application/zip');
153
+ return `Saved ${list.length} PNG frames`;
154
+ });
155
+ return;
156
+ }
157
+ // MP4 / WebM through WebCodecs
158
+ await task(`Encoding ${opts.format.toUpperCase()}`, async (signal, progress) => {
159
+ const { createVideoEncoder, decodeAudio } = await import('../video.js');
160
+ // Keep audio: the video's own soundtrack, cut to the exported range (null when it has none)
161
+ const item = app.current;
162
+ let audio = null;
163
+ if (opts.audio && opts.source === 'video' && item && item.type === 'video' && item.file) {
164
+ progress(0, 'Reading audio');
165
+ const end = opts.end > opts.start ? opts.end : item.source.duration;
166
+ audio = await decodeAudio(item.file, { start: opts.start, end }).catch(e => { console.warn('[export] audio', e); return null; });
167
+ }
168
+ let enc = null;
169
+ await frames(opts, signal, progress, async r => {
170
+ if (!enc) {
171
+ // Players stretch small videos with smoothing, which blurs pixel art. So the frames are upscaled
172
+ // by a whole number to about the chosen size first (and H.264's colour subsampling stays inside each pixel).
173
+ let tries = videoScales(opts.videoSize, r, s.export.scale), err = null;
174
+ for (const scale of tries) {
175
+ try { enc = await createVideoEncoder({ format: opts.format, fps: opts.fps, width: r.width, height: r.height, scale, audio }); break; }
176
+ catch (e) { err = e; if (!/encoder available/.test(e.message)) throw e; }
177
+ }
178
+ if (!enc) throw err;
179
+ }
180
+ await enc.add(r);
181
+ });
182
+ if (audio && enc.audioCodec) progress(1, 'Encoding audio');
183
+ const blob = await enc.finish();
184
+ downloadBlob(blob, `${name}.${opts.format}`);
185
+ const sound = !opts.audio || opts.source !== 'video' ? ''
186
+ : enc.audioCodec === 'opus' && opts.format === 'mp4' ? ', with Opus audio: VLC and browsers play it, Windows Media Player and QuickTime may not'
187
+ : enc.audioCodec ? ', with audio'
188
+ : audio ? ", without audio (this browser can't encode it)"
189
+ : ', no audio track in the source';
190
+ return `Saved ${opts.format.toUpperCase()} (${(blob.size / 1024 / 1024).toFixed(1)} MB${sound})`;
191
+ });
192
+ }
193
+
194
+ async function exportSheet(opts) {
195
+ const name = opts.source === 'files' ? 'sheet' : `${base(app.current?.name)}-sheet`;
196
+ const s = S();
197
+ await task('Building sprite sheet', async (signal, progress) => {
198
+ const list = [];
199
+ await frames(opts, signal, progress, (r, i, total, frameName) => { list.push({ width: r.width, height: r.height, data: r.data, name: frameName ? base(frameName) : undefined, delay: 1000 / opts.fps }); });
200
+ const { image, atlas } = packSpriteSheet(list, { columns: opts.columns, padding: opts.padding, name });
201
+ if (s.export.scale > 1) {
202
+ // scale the atlas coordinates with the image
203
+ const k = s.export.scale;
204
+ for (const f of Object.values(atlas.frames)) { for (const key of ['x', 'y', 'w', 'h']) f.frame[key] *= k; f.sourceSize.w *= k; f.sourceSize.h *= k; }
205
+ atlas.meta.size.w *= k; atlas.meta.size.h *= k; atlas.meta.scale = String(k);
206
+ }
207
+ const png = await encodePNG(image, pngOpts(s));
208
+ downloadBlob(createZip([{ name: `${name}.png`, data: png }, { name: `${name}.json`, data: JSON.stringify(atlas, null, 2) }]), `${name}.zip`, 'application/zip');
209
+ return `Saved a ${list.length}-frame sprite sheet`;
210
+ });
211
+ }
212
+
213
+ async function exportTileset({ tile, flips }) {
214
+ if (!app.result) return toast('Open an image first', { kind: 'error' });
215
+ const s = S();
216
+ const ts = buildTileset(app.result, { tileWidth: tile, tileHeight: tile, flips });
217
+ const name = `${base(app.current.name)}-tiles`;
218
+ const png = await encodePNG(ts.image, pngOpts(s));
219
+ const map = { ...ts.map, image: `${name}.png`, note: 'tiles[] holds one tile index per cell, row by row' + (flips ? '; flips[] is 0 none, 1 horizontal, 2 vertical, 3 both' : '') };
220
+ downloadBlob(createZip([{ name: `${name}.png`, data: png }, { name: `${name}.json`, data: JSON.stringify(map, null, 2) }]), `${name}.zip`, 'application/zip');
221
+ toast(`Saved ${ts.count} unique tiles (${ts.map.width} × ${ts.map.height} map)`, { kind: 'ok' });
222
+ }
223
+
224
+ return { exportPNG, copyImage, exportZip, exportAnimation, exportSheet, exportTileset };
225
+ }
@@ -1,61 +1,65 @@
1
- // Thin line icons drawn for this project on a 24×24 grid (stroke 1.5, round caps).
2
-
3
- const P = {
4
- upload: '<path d="M12 15V4m0 0-4 4m4-4 4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>',
5
- download: '<path d="M12 4v11m0 0-4-4m4 4 4-4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>',
6
- image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m4 17 5-4.5 4 3.5 3-2.5 4 3.5"/>',
7
- film: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M7.5 4.5v15M16.5 4.5v15M3.5 9h4M3.5 15h4M16.5 9h4M16.5 15h4"/>',
8
- files: '<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4H15l4 4v8.5a1.5 1.5 0 0 1-1.5 1.5H16"/><rect x="4.5" y="7" width="11.5" height="13" rx="1.5"/>',
9
- presets: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><path d="M16.5 13.5v6M13.5 16.5h6"/>',
10
- palette: '<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.2 0 1.8-.9 1.4-2l-.3-.8c-.4-1.2.5-2.2 1.7-2.2h1.7a4 4 0 0 0 4-4c0-4.4-3.8-8-8.5-8Z"/><circle cx="8" cy="11" r="1"/><circle cx="11" cy="7.5" r="1"/><circle cx="15.5" cy="8.5" r="1"/>',
11
- search: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/>',
12
- x: '<path d="M6 6l12 12M18 6 6 18"/>',
13
- plus: '<path d="M12 5v14M5 12h14"/>',
14
- minus: '<path d="M5 12h14"/>',
15
- chevron: '<path d="m9.5 6 6 6-6 6"/>',
16
- chevronDown: '<path d="m6 9.5 6 6 6-6"/>',
17
- undo: '<path d="M9 7 4.5 11.5 9 16"/><path d="M5 11.5h9.5a5 5 0 0 1 0 10H11"/>',
18
- redo: '<path d="m15 7 4.5 4.5L15 16"/><path d="M19 11.5H9.5a5 5 0 0 0 0 10H13"/>',
19
- compare: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M12 2.5v19"/><path d="M15 9.5l2 2.5-2 2.5"/><path d="M9 9.5 7 12l2 2.5"/>',
20
- grid: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M9.2 3.5v17M14.8 3.5v17M3.5 9.2h17M3.5 14.8h17"/>',
21
- fit: '<path d="M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15"/>',
22
- zoomIn: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5M11 8.5v5M8.5 11h5"/>',
23
- zoomOut: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5M8.5 11h5"/>',
24
- sun: '<circle cx="12" cy="12" r="3.5"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"/>',
25
- moon: '<path d="M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10Z"/>',
26
- sidebarLeft: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M9.5 4.5v15"/>',
27
- sidebarRight: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M14.5 4.5v15"/>',
28
- play: '<path d="M8 5.5v13l10.5-6.5L8 5.5Z"/>',
29
- pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',
30
- stepBack: '<path d="M7 5.5v13M18 6l-8 6 8 6V6Z"/>',
31
- stepForward: '<path d="M17 5.5v13M6 6l8 6-8 6V6Z"/>',
32
- trash: '<path d="M4.5 7h15M9.5 7V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2M6.5 7l1 12a1.5 1.5 0 0 0 1.5 1.4h6a1.5 1.5 0 0 0 1.5-1.4l1-12"/>',
33
- link: '<path d="M10 14a4 4 0 0 0 5.7 0l2.8-2.8a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 5.7 5.7l1.3-1.3"/>',
34
- braces: '<path d="M8.5 4.5H8A2 2 0 0 0 6 6.5v3A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5v3a2 2 0 0 0 2 2h.5M15.5 4.5h.5a2 2 0 0 1 2 2v3a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5v3a2 2 0 0 1-2 2h-.5"/>',
35
- pipette: '<path d="m14.5 6.5 3 3M12 9l-7.2 7.2a2 2 0 0 0-.5 1V20h2.8a2 2 0 0 0 1-.5L15.2 12"/><path d="M13 7.5 15.8 4.7a2.3 2.3 0 0 1 3.3 3.3L16.3 10.8 13 7.5Z"/>',
36
- check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
37
- keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M8 14h8"/>',
38
- reset: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3L4.5 9"/><path d="M4.5 4.5V9H9"/>',
39
- sheet: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M3.5 12h17M12 3.5v17"/>',
40
- tiles: '<rect x="3.5" y="3.5" width="7" height="7" rx="1"/><rect x="13.5" y="3.5" width="7" height="7" rx="1"/><rect x="3.5" y="13.5" width="7" height="7" rx="1"/><path d="M13.5 17h7M17 13.5v7"/>',
41
- zip: '<path d="M6 3.5h8l4.5 4.5v11a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 19V5A1.5 1.5 0 0 1 6 3.5Z"/><path d="M10 3.5v2h2v2h-2v2h2v2h-2v3"/>',
42
- gif: '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="M10 10.5H8.5a1.5 1.5 0 0 0 0 3H10V12M13 10v4M16 14v-4h2M16 12h1.5"/>',
43
- more: '<circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/>',
44
- menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
45
- sliders: '<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/>',
46
- info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
47
- unlink: '<path d="M10 14a4 4 0 0 0 5.7 0l2.8-2.8a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 5.7 5.7l1.3-1.3"/><path d="M4 4l16 16"/>',
48
- arrowUp: '<path d="M12 19V5M6 11l6-6 6 6"/>',
49
- arrowDown: '<path d="M12 5v14M6 13l6 6 6-6"/>',
50
- layers: '<path d="m12 4 8.5 4.5L12 13 3.5 8.5 12 4Z"/><path d="m3.5 12.5 8.5 4.5 8.5-4.5"/><path d="m3.5 16.5 8.5 4.5 8.5-4.5"/>',
51
- frame: '<path d="M7 3v14a1 1 0 0 0 1 1h13"/><path d="M3 7h13a1 1 0 0 1 1 1v13"/>',
52
- copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="1.5"/><path d="M15.5 8.5V6A1.5 1.5 0 0 0 14 4.5H6A1.5 1.5 0 0 0 4.5 6v8A1.5 1.5 0 0 0 6 15.5h2.5"/>',
53
- clipboard: '<rect x="5" y="4.5" width="14" height="16" rx="2"/><path d="M9 4.5V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v.5M9 10h6M9 14h4"/>',
54
- github: '<path d="M9 19c-4 1.3-4-2-5.5-2.5M14.5 21v-3.2c0-1 .1-1.4-.5-2 2.6-.3 5.5-1.3 5.5-5.8a4.5 4.5 0 0 0-1.2-3.1 4.2 4.2 0 0 0-.1-3.1s-1-.3-3.3 1.2a11.4 11.4 0 0 0-6 0C6.6 3.5 5.6 3.8 5.6 3.8a4.2 4.2 0 0 0-.1 3.1A4.5 4.5 0 0 0 4.3 10c0 4.5 2.9 5.5 5.5 5.8-.6.6-.6 1.2-.5 2V21"/>',
55
- };
56
-
57
- /** SVG markup for an icon name. */
58
- export function icon(name, cls = '') {
59
- return `<svg class="i ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${P[name] || ''}</svg>`;
60
- }
61
- export const ICON_NAMES = Object.keys(P);
1
+ // Thin line icons drawn for this project on a 24×24 grid (stroke 1.5, round caps).
2
+
3
+ const P = {
4
+ upload: '<path d="M12 15V4m0 0-4 4m4-4 4 4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>',
5
+ download: '<path d="M12 4v11m0 0-4-4m4 4 4-4"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>',
6
+ image: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m4 17 5-4.5 4 3.5 3-2.5 4 3.5"/>',
7
+ film: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M7.5 4.5v15M16.5 4.5v15M3.5 9h4M3.5 15h4M16.5 9h4M16.5 15h4"/>',
8
+ files: '<path d="M8 7V5.5A1.5 1.5 0 0 1 9.5 4H15l4 4v8.5a1.5 1.5 0 0 1-1.5 1.5H16"/><rect x="4.5" y="7" width="11.5" height="13" rx="1.5"/>',
9
+ presets: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><path d="M16.5 13.5v6M13.5 16.5h6"/>',
10
+ palette: '<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.2 0 1.8-.9 1.4-2l-.3-.8c-.4-1.2.5-2.2 1.7-2.2h1.7a4 4 0 0 0 4-4c0-4.4-3.8-8-8.5-8Z"/><circle cx="8" cy="11" r="1"/><circle cx="11" cy="7.5" r="1"/><circle cx="15.5" cy="8.5" r="1"/>',
11
+ search: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5"/>',
12
+ x: '<path d="M6 6l12 12M18 6 6 18"/>',
13
+ plus: '<path d="M12 5v14M5 12h14"/>',
14
+ minus: '<path d="M5 12h14"/>',
15
+ chevron: '<path d="m9.5 6 6 6-6 6"/>',
16
+ chevronDown: '<path d="m6 9.5 6 6 6-6"/>',
17
+ undo: '<path d="M9 7 4.5 11.5 9 16"/><path d="M5 11.5h9.5a5 5 0 0 1 0 10H11"/>',
18
+ redo: '<path d="m15 7 4.5 4.5L15 16"/><path d="M19 11.5H9.5a5 5 0 0 0 0 10H13"/>',
19
+ compare: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M12 2.5v19"/><path d="M15 9.5l2 2.5-2 2.5"/><path d="M9 9.5 7 12l2 2.5"/>',
20
+ grid: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M9.2 3.5v17M14.8 3.5v17M3.5 9.2h17M3.5 14.8h17"/>',
21
+ fit: '<path d="M4 9V5.5A1.5 1.5 0 0 1 5.5 4H9M15 4h3.5A1.5 1.5 0 0 1 20 5.5V9M20 15v3.5a1.5 1.5 0 0 1-1.5 1.5H15M9 20H5.5A1.5 1.5 0 0 1 4 18.5V15"/>',
22
+ zoomIn: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5M11 8.5v5M8.5 11h5"/>',
23
+ zoomOut: '<circle cx="11" cy="11" r="6"/><path d="m20 20-4.5-4.5M8.5 11h5"/>',
24
+ sun: '<circle cx="12" cy="12" r="3.5"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"/>',
25
+ moon: '<path d="M19.5 14.5A7.5 7.5 0 0 1 9.5 4.5a7.5 7.5 0 1 0 10 10Z"/>',
26
+ sidebarLeft: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M9.5 4.5v15"/>',
27
+ sidebarRight: '<rect x="3.5" y="4.5" width="17" height="15" rx="2"/><path d="M14.5 4.5v15"/>',
28
+ play: '<path d="M8 5.5v13l10.5-6.5L8 5.5Z"/>',
29
+ pause: '<path d="M8.5 5.5v13M15.5 5.5v13"/>',
30
+ stepBack: '<path d="M7 5.5v13M18 6l-8 6 8 6V6Z"/>',
31
+ stepForward: '<path d="M17 5.5v13M6 6l8 6-8 6V6Z"/>',
32
+ save: '<path d="M5 4.5h11l3.5 3.5v10A1.5 1.5 0 0 1 18 19.5H6A1.5 1.5 0 0 1 4.5 18V6A1.5 1.5 0 0 1 6 4.5Z"/><path d="M8 4.5v4h7v-4M8 19.5v-6h8v6"/>',
33
+ rename: '<path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
34
+ volume: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z"/><path d="M15.5 9.5a3.5 3.5 0 0 1 0 5M18 7a7 7 0 0 1 0 10"/>',
35
+ volumeOff: '<path d="M4 9.5h3.5L12 6v12l-4.5-3.5H4z"/><path d="m16 9.5 5 5M21 9.5l-5 5"/>',
36
+ trash: '<path d="M4.5 7h15M9.5 7V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v2M6.5 7l1 12a1.5 1.5 0 0 0 1.5 1.4h6a1.5 1.5 0 0 0 1.5-1.4l1-12"/>',
37
+ link: '<path d="M10 14a4 4 0 0 0 5.7 0l2.8-2.8a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 5.7 5.7l1.3-1.3"/>',
38
+ braces: '<path d="M8.5 4.5H8A2 2 0 0 0 6 6.5v3A2.5 2.5 0 0 1 3.5 12 2.5 2.5 0 0 1 6 14.5v3a2 2 0 0 0 2 2h.5M15.5 4.5h.5a2 2 0 0 1 2 2v3a2.5 2.5 0 0 0 2.5 2.5 2.5 2.5 0 0 0-2.5 2.5v3a2 2 0 0 1-2 2h-.5"/>',
39
+ pipette: '<path d="m14.5 6.5 3 3M12 9l-7.2 7.2a2 2 0 0 0-.5 1V20h2.8a2 2 0 0 0 1-.5L15.2 12"/><path d="M13 7.5 15.8 4.7a2.3 2.3 0 0 1 3.3 3.3L16.3 10.8 13 7.5Z"/>',
40
+ check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
41
+ keyboard: '<rect x="2.5" y="6" width="19" height="12" rx="2"/><path d="M6 10h.01M9.5 10h.01M13 10h.01M16.5 10h.01M8 14h8"/>',
42
+ reset: '<path d="M4.5 12a7.5 7.5 0 1 0 2.2-5.3L4.5 9"/><path d="M4.5 4.5V9H9"/>',
43
+ sheet: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M3.5 12h17M12 3.5v17"/>',
44
+ tiles: '<rect x="3.5" y="3.5" width="7" height="7" rx="1"/><rect x="13.5" y="3.5" width="7" height="7" rx="1"/><rect x="3.5" y="13.5" width="7" height="7" rx="1"/><path d="M13.5 17h7M17 13.5v7"/>',
45
+ zip: '<path d="M6 3.5h8l4.5 4.5v11a1.5 1.5 0 0 1-1.5 1.5H6A1.5 1.5 0 0 1 4.5 19V5A1.5 1.5 0 0 1 6 3.5Z"/><path d="M10 3.5v2h2v2h-2v2h2v2h-2v3"/>',
46
+ gif: '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/><path d="M10 10.5H8.5a1.5 1.5 0 0 0 0 3H10V12M13 10v4M16 14v-4h2M16 12h1.5"/>',
47
+ more: '<circle cx="6" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="18" cy="12" r="1"/>',
48
+ menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
49
+ sliders: '<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/>',
50
+ info: '<circle cx="12" cy="12" r="8.5"/><path d="M12 11v5M12 8h.01"/>',
51
+ unlink: '<path d="M10 14a4 4 0 0 0 5.7 0l2.8-2.8a4 4 0 0 0-5.7-5.7L11.5 6.8"/><path d="M14 10a4 4 0 0 0-5.7 0l-2.8 2.8a4 4 0 0 0 5.7 5.7l1.3-1.3"/><path d="M4 4l16 16"/>',
52
+ arrowUp: '<path d="M12 19V5M6 11l6-6 6 6"/>',
53
+ arrowDown: '<path d="M12 5v14M6 13l6 6 6-6"/>',
54
+ layers: '<path d="m12 4 8.5 4.5L12 13 3.5 8.5 12 4Z"/><path d="m3.5 12.5 8.5 4.5 8.5-4.5"/><path d="m3.5 16.5 8.5 4.5 8.5-4.5"/>',
55
+ frame: '<path d="M7 3v14a1 1 0 0 0 1 1h13"/><path d="M3 7h13a1 1 0 0 1 1 1v13"/>',
56
+ copy: '<rect x="8.5" y="8.5" width="11" height="11" rx="1.5"/><path d="M15.5 8.5V6A1.5 1.5 0 0 0 14 4.5H6A1.5 1.5 0 0 0 4.5 6v8A1.5 1.5 0 0 0 6 15.5h2.5"/>',
57
+ clipboard: '<rect x="5" y="4.5" width="14" height="16" rx="2"/><path d="M9 4.5V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v.5M9 10h6M9 14h4"/>',
58
+ github: '<path d="M9 19c-4 1.3-4-2-5.5-2.5M14.5 21v-3.2c0-1 .1-1.4-.5-2 2.6-.3 5.5-1.3 5.5-5.8a4.5 4.5 0 0 0-1.2-3.1 4.2 4.2 0 0 0-.1-3.1s-1-.3-3.3 1.2a11.4 11.4 0 0 0-6 0C6.6 3.5 5.6 3.8 5.6 3.8a4.2 4.2 0 0 0-.1 3.1A4.5 4.5 0 0 0 4.3 10c0 4.5 2.9 5.5 5.5 5.8-.6.6-.6 1.2-.5 2V21"/>',
59
+ };
60
+
61
+ /** SVG markup for an icon name. */
62
+ export function icon(name, cls = '') {
63
+ return `<svg class="i ${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${P[name] || ''}</svg>`;
64
+ }
65
+ export const ICON_NAMES = Object.keys(P);