image-to-pixel 3.1.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +54 -54
- package/dist/editor.esm.js.map +4 -4
- package/dist/image-to-pixel.bundle.esm.js +13 -13
- package/dist/image-to-pixel.bundle.esm.js.map +3 -3
- package/dist/image-to-pixel.cjs +13 -9
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +13 -9
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +13 -9
- package/dist/image-to-pixel.js.map +2 -2
- package/dist/image-to-pixel.min.js +11 -11
- package/dist/image-to-pixel.min.js.map +3 -3
- package/dist/video.esm.js +2 -2
- package/dist/video.esm.js.map +3 -3
- package/lib/core/video/frames.js +189 -182
- package/lib/editor/exporter.js +267 -225
- package/lib/editor/index.js +1051 -1002
- package/lib/editor/inspector.js +379 -343
- package/lib/editor/storyboard.js +566 -0
- package/lib/editor/styles/editor.css +455 -418
- package/lib/editor/timeline-preview.js +142 -0
- package/package.json +1 -1
- package/types/index.d.ts +2 -0
package/lib/editor/inspector.js
CHANGED
|
@@ -1,343 +1,379 @@
|
|
|
1
|
-
// Right-hand inspector: Setup (everything that changes the image) and Export.
|
|
2
|
-
|
|
3
|
-
import { h, render } from './dom.js';
|
|
4
|
-
import { icon } from './icons.js';
|
|
5
|
-
import { section, slider, select, segmented, toggle, colorInput, numberInput, field } from './controls.js';
|
|
6
|
-
import { exportSize } from './exporter.js';
|
|
7
|
-
import {
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
h('div.itp-stat', {}, h('div.k', { text: '
|
|
39
|
-
h('div.itp-stat', {}, h('div.k', { text: '
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
value: 'shared',
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
height
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{ value: '
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
h('button.itp-btn.small.outline', { type: 'button', html: `${icon('
|
|
95
|
-
h('button.itp-btn.small.outline', { type: 'button', html: `${icon('
|
|
96
|
-
h('button.itp-btn.small', { type: 'button', html: `${icon('
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
140
|
-
const
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
1
|
+
// Right-hand inspector: Setup (everything that changes the image) and Export.
|
|
2
|
+
|
|
3
|
+
import { h, render } from './dom.js';
|
|
4
|
+
import { icon } from './icons.js';
|
|
5
|
+
import { section, slider, select, segmented, toggle, colorInput, numberInput, field } from './controls.js';
|
|
6
|
+
import { exportSize } from './exporter.js';
|
|
7
|
+
import { isFullClipDefault, INITIAL_INTERVAL_SECONDS } from './storyboard.js';
|
|
8
|
+
import { DITHER_METHODS, ditherKind, defaultSettings, ASPECTS, cropRect } from '../core/settings.js';
|
|
9
|
+
import * as persist from './persist.js';
|
|
10
|
+
import { PALETTES } from '../core/palette/builtin.js';
|
|
11
|
+
import { parseMatrix } from '../core/dither/matrices.js';
|
|
12
|
+
|
|
13
|
+
export function createInspector(app) {
|
|
14
|
+
const { store } = app;
|
|
15
|
+
const up = (patch, key) => store.update(patch, { key });
|
|
16
|
+
const commit = () => store.commit();
|
|
17
|
+
const S = () => store.settings;
|
|
18
|
+
const DEF = defaultSettings();
|
|
19
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
20
|
+
|
|
21
|
+
// Sections start collapsed except the first of each tab; the person's choice is remembered.
|
|
22
|
+
const openState = persist.load('sections', {});
|
|
23
|
+
const sec = (key, title, defaultOpen, groups, ...children) => section(title, {
|
|
24
|
+
open: openState[key] ?? defaultOpen,
|
|
25
|
+
onToggle: open => { openState[key] = open; persist.save('sections', openState); },
|
|
26
|
+
onReset: groups ? () => {
|
|
27
|
+
const patch = {};
|
|
28
|
+
for (const g of groups) patch[g] = JSON.parse(JSON.stringify(DEF[g]));
|
|
29
|
+
store.update(patch);
|
|
30
|
+
} : null,
|
|
31
|
+
}, ...children);
|
|
32
|
+
|
|
33
|
+
// ---- head: stats + tabs
|
|
34
|
+
const statSize = h('div.v', { text: '–' });
|
|
35
|
+
const statColors = h('div.v', { text: '–' });
|
|
36
|
+
const statTime = h('div.v', { text: '–' });
|
|
37
|
+
const stats = h('div.itp-stats', { 'aria-live': 'polite' },
|
|
38
|
+
h('div.itp-stat', {}, h('div.k', { text: 'Output' }), statSize),
|
|
39
|
+
h('div.itp-stat', {}, h('div.k', { text: 'Colours' }), statColors),
|
|
40
|
+
h('div.itp-stat', {}, h('div.k', { text: 'Render' }), statTime));
|
|
41
|
+
|
|
42
|
+
const tabSetup = h('button', { type: 'button', role: 'tab', 'aria-selected': 'true', id: 'itp-tab-setup', 'aria-controls': 'itp-pane-setup', html: `${icon('sliders')}<span>Setup</span>` });
|
|
43
|
+
const tabExport = h('button', { type: 'button', role: 'tab', 'aria-selected': 'false', id: 'itp-tab-export', 'aria-controls': 'itp-pane-export', html: `${icon('download')}<span>Export</span>` });
|
|
44
|
+
const tabs = h('div.itp-seg', { role: 'tablist', 'aria-label': 'Inspector' }, tabSetup, tabExport);
|
|
45
|
+
|
|
46
|
+
// ---- which settings am I editing? (only shown with two or more files)
|
|
47
|
+
const scope = segmented({
|
|
48
|
+
label: 'Settings apply to', bare: true,
|
|
49
|
+
options: [{ value: 'shared', label: 'All files', icon: 'layers' }, { value: 'own', label: 'This file', icon: 'unlink' }],
|
|
50
|
+
value: 'shared',
|
|
51
|
+
onChange: v => app.current && app.setOwn(app.current, v === 'own'),
|
|
52
|
+
});
|
|
53
|
+
const scopeNote = h('span.itp-scope-note', {});
|
|
54
|
+
const scopeRow = h('div.itp-scope', { hidden: true }, h('div.lab', {}, h('span', { text: 'Settings apply to' }), scopeNote), scope.el);
|
|
55
|
+
|
|
56
|
+
// =========================================================== SETUP
|
|
57
|
+
// ---- size
|
|
58
|
+
const sizeMode = segmented({ label: 'Size by', options: [{ value: 'width', label: 'Width' }, { value: 'block', label: 'Pixel size' }], value: 'width', onChange: v => up({ size: { mode: v } }) });
|
|
59
|
+
const width = slider({ label: 'Pixel width', min: 8, max: 512, step: 1, value: 96, reset: 96, onInput: v => up({ size: { width: v } }, 'width'), onCommit: commit });
|
|
60
|
+
const height = numberInput({ label: 'Height', min: 0, max: 2048, help: 'Leave empty to keep the aspect ratio.', onChange: v => up({ size: { height: v } }) });
|
|
61
|
+
height.input.placeholder = 'Auto';
|
|
62
|
+
const block = slider({ label: 'Pixel size', min: 1, max: 64, step: 1, value: 8, reset: 8, help: 'How many source pixels become one output pixel.', onInput: v => up({ size: { block: v } }, 'block'), onCommit: commit });
|
|
63
|
+
const resample = segmented({ label: 'Resample', options: [{ value: 'box', label: 'Average' }, { value: 'nearest', label: 'Nearest' }], value: 'box', help: 'Nearest keeps hard edges; use it for art that is already pixelated.', onChange: v => up({ size: { resample: v } }) });
|
|
64
|
+
const detect = h('button.itp-btn.small.outline', { type: 'button', html: `${icon('grid')}<span>Detect grid</span>`, title: 'Find the pixel size of upscaled pixel art', onclick: () => app.detectGrid() });
|
|
65
|
+
const widthBox = h('div', { style: { display: 'contents' } }, width.el, height.el);
|
|
66
|
+
const blockBox = h('div', { style: { display: 'contents' } }, block.el);
|
|
67
|
+
const secSize = sec('size', 'Size', true, ['size'], sizeMode.el, widthBox, blockBox, resample.el, h('div.itp-row', {}, detect));
|
|
68
|
+
|
|
69
|
+
// ---- frame (crop to an aspect ratio, zoom in, choose the part)
|
|
70
|
+
const aspectBtns = ASPECTS.map(a => {
|
|
71
|
+
const r = a.ratio || 4 / 3;
|
|
72
|
+
const glyph = h('i', { style: { width: `${Math.round(r >= 1 ? 16 : 16 * r)}px`, height: `${Math.round(r >= 1 ? 16 / r : 16)}px` } });
|
|
73
|
+
if (!a.ratio) glyph.classList.add('orig');
|
|
74
|
+
return h('button', { type: 'button', 'aria-pressed': 'false', title: a.id === 'original' ? 'The source\'s own shape' : `Crop to ${a.label}`, onclick: () => up({ frame: { aspect: a.id } }) }, glyph, h('span', { text: a.label }));
|
|
75
|
+
});
|
|
76
|
+
const aspects = h('div.itp-aspects', { role: 'group', 'aria-label': 'Aspect ratio' }, ...aspectBtns);
|
|
77
|
+
const fZoom = slider({ label: 'Zoom', min: 1, max: 8, step: 0.05, value: 1, reset: 1, help: 'Crop into the middle of the frame.', onInput: v => up({ frame: { zoom: v } }, 'fz'), onCommit: commit });
|
|
78
|
+
const fX = slider({ label: 'Horizontal position', min: 0, max: 1, step: 0.01, scale: 100, value: 0.5, reset: 0.5, onInput: v => up({ frame: { x: v } }, 'fx'), onCommit: commit });
|
|
79
|
+
const fY = slider({ label: 'Vertical position', min: 0, max: 1, step: 0.01, scale: 100, value: 0.5, reset: 0.5, onInput: v => up({ frame: { y: v } }, 'fy'), onCommit: commit });
|
|
80
|
+
const secFrame = sec('frame', 'Frame', false, ['frame'], field('Aspect ratio', aspects), fZoom.el, fX.el, fY.el);
|
|
81
|
+
|
|
82
|
+
// ---- palette
|
|
83
|
+
const palSource = select({ label: 'Source', options: [
|
|
84
|
+
{ value: 'builtin', label: 'Built-in palette' }, { value: 'custom', label: 'Custom colours' },
|
|
85
|
+
{ value: 'extract', label: 'From the image' }, { value: 'none', label: 'None (keep colours)' },
|
|
86
|
+
], onChange: v => up({ palette: { source: v } }) });
|
|
87
|
+
const palBuiltin = select({ label: 'Palette', options: PALETTES.map(p => ({ value: p.id, label: `${p.name} (${p.colors.length})` })), onChange: v => up({ palette: { source: 'builtin', id: v } }) });
|
|
88
|
+
const palCount = slider({ label: 'Number of colours', min: 2, max: 64, step: 1, value: 16, reset: 16, onInput: v => up({ palette: { count: v } }, 'count'), onCommit: commit });
|
|
89
|
+
const swatches = h('div.itp-swatches', { role: 'list', 'aria-label': 'Palette colours' });
|
|
90
|
+
const swCount = h('span.val.num', {});
|
|
91
|
+
const swatchField = field('Colours', swatches, { value: swCount });
|
|
92
|
+
const palDistance = segmented({ label: 'Colour matching', options: [{ value: 'oklab', label: 'Perceptual' }, { value: 'rgb', label: 'RGB' }], value: 'oklab', onChange: v => up({ palette: { distance: v } }) });
|
|
93
|
+
const palActions = h('div.itp-row', { style: { flexWrap: 'wrap' } },
|
|
94
|
+
h('button.itp-btn.small.outline', { type: 'button', html: `${icon('upload')}<span>Import</span>`, title: '.gpl, .hex, .pal, .txt or .json', onclick: () => app.importPalette() }),
|
|
95
|
+
h('button.itp-btn.small.outline', { type: 'button', html: `${icon('palette')}<span>Lospec</span>`, onclick: () => app.lospecDialog() }),
|
|
96
|
+
h('button.itp-btn.small.outline', { type: 'button', html: `${icon('pipette')}<span>Pick</span>`, title: 'Add colours by clicking the image', 'aria-pressed': 'false', onclick: e => app.togglePicker(e.currentTarget) }),
|
|
97
|
+
h('button.itp-btn.small', { type: 'button', html: `${icon('download')}<span>Save</span>`, title: 'Save to Your palettes', onclick: () => app.savePalette() }));
|
|
98
|
+
const secPal = sec('palette', 'Palette', false, ['palette'], palSource.el, palBuiltin.el, palCount.el, swatchField, palDistance.el, palActions);
|
|
99
|
+
|
|
100
|
+
// ---- dither
|
|
101
|
+
const groups = { none: 'None', ordered: 'Ordered', diffusion: 'Error diffusion' };
|
|
102
|
+
const dMethod = select({ label: 'Method', options: DITHER_METHODS.map(m => ({ value: m.id, label: m.label, group: groups[m.kind] })), onChange: v => up({ dither: { method: v } }) });
|
|
103
|
+
const dStrength = slider({ label: 'Strength', min: 0, max: 1, step: 0.01, scale: 100, value: 0.6, reset: 0.6, onInput: v => up({ dither: { strength: v } }, 'strength'), onCommit: commit });
|
|
104
|
+
const matrixText = h('textarea.itp-input', { rows: 4, spellcheck: false, 'aria-label': 'Custom threshold matrix', placeholder: '0 8 2 10\n12 4 14 6\n3 11 1 9\n15 7 13 5' });
|
|
105
|
+
const matrixHelp = h('div.help', { text: 'Rows of numbers, up to 16×16. Only their order matters.' });
|
|
106
|
+
matrixText.addEventListener('change', () => {
|
|
107
|
+
try { up({ dither: { method: 'custom', matrix: parseMatrix(matrixText.value) } }); matrixHelp.textContent = 'Rows of numbers, up to 16×16. Only their order matters.'; matrixHelp.style.color = ''; }
|
|
108
|
+
catch (e) { matrixHelp.textContent = e.message; matrixHelp.style.color = 'var(--danger)'; }
|
|
109
|
+
});
|
|
110
|
+
const matrixBox = h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Matrix' })), matrixText, matrixHelp);
|
|
111
|
+
const secDither = sec('dither', 'Dither', false, ['dither'], dMethod.el, dStrength.el, matrixBox);
|
|
112
|
+
|
|
113
|
+
// ---- adjust
|
|
114
|
+
const pct = { min: -1, max: 1, step: 0.01, scale: 100, value: 0, reset: 0 };
|
|
115
|
+
const aBright = slider({ label: 'Brightness', ...pct, onInput: v => up({ adjust: { brightness: v } }, 'br'), onCommit: commit });
|
|
116
|
+
const aContrast = slider({ label: 'Contrast', ...pct, onInput: v => up({ adjust: { contrast: v } }, 'co'), onCommit: commit });
|
|
117
|
+
const aSat = slider({ label: 'Saturation', ...pct, onInput: v => up({ adjust: { saturation: v } }, 'sa'), onCommit: commit });
|
|
118
|
+
const aHue = slider({ label: 'Hue', min: -180, max: 180, step: 1, value: 0, reset: 0, help: 'Rotates every colour around the colour wheel; greys stay grey.', onInput: v => up({ adjust: { hue: v } }, 'hu'), onCommit: commit });
|
|
119
|
+
const aGamma = slider({ label: 'Gamma', min: 0.2, max: 3, step: 0.05, value: 1, reset: 1, onInput: v => up({ adjust: { gamma: v } }, 'ga'), onCommit: commit });
|
|
120
|
+
const secAdjust = sec('adjust', 'Adjust', false, ['adjust'], aBright.el, aContrast.el, aSat.el, aHue.el, aGamma.el);
|
|
121
|
+
|
|
122
|
+
// ---- cleanup & outline
|
|
123
|
+
const alphaCut = slider({ label: 'Alpha cutoff', min: 0, max: 1, step: 0.01, scale: 100, value: 0.5, reset: 0.5, help: 'Pixels less opaque than this become transparent.', onInput: v => up({ alpha: { threshold: v } }, 'alpha'), onCommit: commit });
|
|
124
|
+
const orphans = toggle({ label: 'Remove stray pixels', onChange: v => up({ cleanup: { orphans: v } }) });
|
|
125
|
+
const smooth = toggle({ label: 'Smooth small specks', onChange: v => up({ cleanup: { smooth: v } }) });
|
|
126
|
+
const olMode = segmented({ label: 'Outline', options: [{ value: 'none', label: 'None' }, { value: 'outer', label: 'Outside' }, { value: 'inner', label: 'Inside' }], value: 'none', help: 'Follows transparent edges.', onChange: v => up({ outline: { mode: v } }) });
|
|
127
|
+
const olColor = colorInput({ label: 'Outline colour', onChange: v => up({ outline: { color: v } }, 'olc') });
|
|
128
|
+
const olCorners = toggle({ label: 'Include diagonals', onChange: v => up({ outline: { corners: v } }) });
|
|
129
|
+
const secClean = sec('cleanup', 'Cleanup & outline', false, ['cleanup', 'outline', 'alpha'], orphans.el, smooth.el, alphaCut.el, olMode.el, olColor.el, olCorners.el);
|
|
130
|
+
|
|
131
|
+
const setupPane = h('div', { id: 'itp-pane-setup', role: 'tabpanel', 'aria-labelledby': 'itp-tab-setup' }, secSize.el, secFrame.el, secPal.el, secDither.el, secAdjust.el, secClean.el);
|
|
132
|
+
|
|
133
|
+
// =========================================================== EXPORT
|
|
134
|
+
const exMode = segmented({ label: 'Size', options: [{ value: 'scale', label: 'Scale' }, { value: 'source', label: 'Source size' }], value: 'scale', help: '', onChange: v => up({ export: { size: v } }) });
|
|
135
|
+
const exScale = slider({ label: 'Scale', min: 1, max: 32, step: 1, value: 4, reset: 4, help: 'Whole-number upscale keeps every pixel square.', onInput: v => up({ export: { scale: v } }, 'scale'), onCommit: commit });
|
|
136
|
+
const exSizeOut = h('span.val.num', {});
|
|
137
|
+
const exGrid = toggle({ label: 'Grid lines', onChange: v => up({ export: { grid: v } }) });
|
|
138
|
+
const exIndexed = toggle({ label: 'Indexed PNG', help: 'Smaller files; up to 256 colours.', onChange: v => up({ export: { indexed: v } }) });
|
|
139
|
+
const btnPNG = h('button.itp-btn.primary.block', { type: 'button', html: `${icon('download')}<span>Download PNG</span>`, onclick: () => app.exportPNG() });
|
|
140
|
+
const btnCopy = h('button.itp-btn.outline.block', { type: 'button', html: `${icon('copy')}<span>Copy image</span>`, onclick: () => app.copyImage() });
|
|
141
|
+
const btnZip = h('button.itp-btn.outline.block', { type: 'button', html: `${icon('zip')}<span>Download all as ZIP</span>`, onclick: () => app.exportZip() });
|
|
142
|
+
const secImage = sec('image', 'Image', true, ['export'], field('Output size', h('div'), { value: exSizeOut }), exMode.el, exScale.el, exGrid.el, exIndexed.el, h('div.itp-grid2', {}, btnPNG, btnCopy), btnZip);
|
|
143
|
+
|
|
144
|
+
// animation
|
|
145
|
+
let frameSelection = { active: false, complete: false, count: 0 };
|
|
146
|
+
const anSource = select({ label: 'Frames from', options: [{ value: 'video', label: 'Current video' }, { value: 'files', label: 'All files, in order' }], onChange: () => syncAnim() });
|
|
147
|
+
const anSelect = toggle({ label: 'Frame selection', help: 'Choose individual video frames on the canvas for animation and sprite sheet exports.', onChange: enabled => {
|
|
148
|
+
if (enabled) {
|
|
149
|
+
anSource.set('video');
|
|
150
|
+
// The untouched Animation default is the full clip; start long clips at three seconds.
|
|
151
|
+
const cur = app.current?.type === 'video' ? app.current : null;
|
|
152
|
+
const untouched = cur && !anStart.input.dataset.touched && !anEnd.input.dataset.touched;
|
|
153
|
+
if (untouched && isFullClipDefault(Number(anEnd.input.value) || 0, cur.duration)) {
|
|
154
|
+
anStart.set(0); anEnd.set(Math.min(cur.duration, INITIAL_INTERVAL_SECONDS));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
app.setFrameSelection(enabled);
|
|
158
|
+
} });
|
|
159
|
+
const anFormat = select({ label: 'Format', options: [
|
|
160
|
+
{ value: 'gif', label: 'GIF' }, { value: 'webm', label: 'WebM video' }, { value: 'mp4', label: 'MP4 video' }, { value: 'png-seq', label: 'PNG sequence (ZIP)' },
|
|
161
|
+
], onChange: () => syncAnim() });
|
|
162
|
+
const anFps = numberInput({ label: 'Frames per second', min: 1, max: 60, onChange: v => {
|
|
163
|
+
anFps.input.dataset.touched = '1'; app.setAnimationFps?.(v);
|
|
164
|
+
} });
|
|
165
|
+
const changeAnimRange = () => {
|
|
166
|
+
anStart.input.dataset.touched = '1'; anEnd.input.dataset.touched = '1';
|
|
167
|
+
app.setFrameRange?.(Number(anStart.input.value) || 0, Number(anEnd.input.value) || 0);
|
|
168
|
+
};
|
|
169
|
+
const anStart = numberInput({ label: 'Start (s)', min: 0, max: 36000, step: 0.001, onChange: changeAnimRange });
|
|
170
|
+
const anEnd = numberInput({ label: 'End (s)', min: 0, max: 36000, step: 0.001, onChange: changeAnimRange });
|
|
171
|
+
const anSize = select({ label: 'Video size', options: [
|
|
172
|
+
{ value: '1080', label: 'Full HD (sharp in any player)' }, { value: '720', label: '720p' }, { value: '2160', label: '4K' }, { value: 'scale', label: 'Same scale as PNG' },
|
|
173
|
+
], onChange: () => { } });
|
|
174
|
+
anSize.set('1080');
|
|
175
|
+
const anAudio = toggle({ label: 'Keep audio', help: "The video's soundtrack, cut to the same start and end. MP4 and WebM only.", onChange: () => syncAnim() });
|
|
176
|
+
const anAudioInput = anAudio.el.querySelector('input');
|
|
177
|
+
const anNote = h('div.help', {});
|
|
178
|
+
const btnAnim = h('button.itp-btn.primary.block', { type: 'button', html: `${icon('film')}<span>Export animation</span>`, onclick: () => app.exportAnimation(animOpts()) });
|
|
179
|
+
const secAnim = sec('animation', 'Animation', false, null, anSource.el, anSelect.el, anFormat.el, anFps.el, h('div.itp-grid2', {}, anStart.el, anEnd.el), anSize.el, anAudio.el, anNote, btnAnim);
|
|
180
|
+
anFps.set(12); anStart.set(0); anEnd.set(0); anAudio.set(true);
|
|
181
|
+
const animOpts = () => ({ source: anSource.select.value, format: anFormat.select.value, fps: Number(anFps.input.value) || 12, start: Number(anStart.input.value) || 0, end: Number(anEnd.input.value) || 0, rangeTouched: !!(anStart.input.dataset.touched || anEnd.input.dataset.touched), audio: anAudioInput.checked, videoSize: anSize.select.value });
|
|
182
|
+
|
|
183
|
+
// sprite sheet
|
|
184
|
+
const shCols = numberInput({ label: 'Columns', min: 0, max: 256, help: '0 = as square as possible.', onChange: () => { } });
|
|
185
|
+
const shPad = numberInput({ label: 'Padding', min: 0, max: 64, onChange: () => { } });
|
|
186
|
+
shCols.set(0); shPad.set(0);
|
|
187
|
+
const btnSheet = h('button.itp-btn.outline.block', { type: 'button', html: `${icon('sheet')}<span>Download sprite sheet</span>`, onclick: () => app.exportSheet({ ...animOpts(), columns: Number(shCols.input.value) || 0, padding: Number(shPad.input.value) || 0 }) });
|
|
188
|
+
const secSheet = sec('sheet', 'Sprite sheet', false, null, h('div.help', { text: 'Packs the frames chosen under Animation into one PNG with a JSON atlas (TexturePacker hash format).' }), h('div.itp-grid2', {}, shCols.el, shPad.el), btnSheet);
|
|
189
|
+
|
|
190
|
+
// tileset
|
|
191
|
+
const tsSize = select({ label: 'Tile size', options: [8, 12, 16, 24, 32, 48, 64].map(n => ({ value: String(n), label: `${n} × ${n}` })), onChange: () => { } });
|
|
192
|
+
tsSize.set('16');
|
|
193
|
+
const tsFlips = toggle({ label: 'Count mirrored tiles as duplicates', onChange: () => { } });
|
|
194
|
+
const btnTiles = h('button.itp-btn.outline.block', { type: 'button', html: `${icon('tiles')}<span>Download tileset</span>`, onclick: () => app.exportTileset({ tile: Number(tsSize.select.value), flips: tsFlips.el.querySelector('input').checked }) });
|
|
195
|
+
const secTiles = sec('tileset', 'Tileset', false, null, h('div.help', { text: 'Cuts the current result into tiles, keeps one copy of each and writes a tile map.' }), tsSize.el, tsFlips.el, btnTiles);
|
|
196
|
+
|
|
197
|
+
// settings
|
|
198
|
+
const secSettings = sec('settings', 'Settings', false, null,
|
|
199
|
+
h('div.itp-grid2', {},
|
|
200
|
+
h('button.itp-btn.outline', { type: 'button', html: `${icon('link')}<span>Copy link</span>`, onclick: () => app.copyLink() }),
|
|
201
|
+
h('button.itp-btn.outline', { type: 'button', html: `${icon('braces')}<span>Save JSON</span>`, onclick: () => app.saveSettingsFile() })),
|
|
202
|
+
h('button.itp-btn.outline.block', { type: 'button', html: `${icon('upload')}<span>Load settings file</span>`, onclick: () => app.loadSettingsFile() }));
|
|
203
|
+
|
|
204
|
+
const exportPane = h('div', { id: 'itp-pane-export', role: 'tabpanel', 'aria-labelledby': 'itp-tab-export', hidden: true }, secImage.el, secAnim.el, secSheet.el, secTiles.el, secSettings.el);
|
|
205
|
+
|
|
206
|
+
// ---- footer
|
|
207
|
+
const foot = h('div.itp-insp-foot', {},
|
|
208
|
+
h('button.itp-link', { type: 'button', text: 'Reset to defaults', onclick: () => app.resetSettings() }),
|
|
209
|
+
h('span.muted', { style: { fontSize: 'var(--fs-xs)' }, id: 'itp-backend' }));
|
|
210
|
+
|
|
211
|
+
const body = h('div.itp-panel-body', {}, setupPane, exportPane);
|
|
212
|
+
const el = h('aside.itp-insp', { 'aria-label': 'Inspector' }, h('div.itp-insp-head', {}, stats, scopeRow, tabs), h('div', { style: { height: '8px' } }), body, foot);
|
|
213
|
+
|
|
214
|
+
function setTab(t) {
|
|
215
|
+
const setup = t === 'setup';
|
|
216
|
+
tabSetup.setAttribute('aria-selected', String(setup)); tabExport.setAttribute('aria-selected', String(!setup));
|
|
217
|
+
setupPane.hidden = !setup; exportPane.hidden = setup;
|
|
218
|
+
if (!setup) syncAnim();
|
|
219
|
+
}
|
|
220
|
+
tabSetup.onclick = () => setTab('setup');
|
|
221
|
+
tabExport.onclick = () => setTab('export');
|
|
222
|
+
tabs.addEventListener('keydown', e => {
|
|
223
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { const next = tabSetup.getAttribute('aria-selected') === 'true' ? tabExport : tabSetup; next.click(); next.focus(); }
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
function syncAnim() {
|
|
227
|
+
const cur = app.current?.type === 'video' ? app.current : null;
|
|
228
|
+
const hasVideo = !!cur;
|
|
229
|
+
const nImages = app.items.filter(i => i.type === 'image').length;
|
|
230
|
+
if (!hasVideo && anSource.select.value === 'video' && nImages > 1) anSource.set('files');
|
|
231
|
+
if (hasVideo && anSource.select.value === 'files' && nImages < 2) anSource.set('video');
|
|
232
|
+
const src = anSource.select.value;
|
|
233
|
+
const ok = src === 'video' ? hasVideo : nImages > 1;
|
|
234
|
+
const selecting = frameSelection.active;
|
|
235
|
+
anSelect.el.hidden = !cur;
|
|
236
|
+
anSelect.set(selecting);
|
|
237
|
+
anSource.select.disabled = selecting;
|
|
238
|
+
anStart.el.hidden = anEnd.el.hidden = src !== 'video';
|
|
239
|
+
btnAnim.disabled = btnSheet.disabled = !ok || (selecting && (!frameSelection.complete || !frameSelection.count));
|
|
240
|
+
// The tile canvas is the preview while selecting, so these would export a stale frame.
|
|
241
|
+
btnPNG.disabled = btnCopy.disabled = btnTiles.disabled = selecting;
|
|
242
|
+
if (cur && !Number(anEnd.input.value)) anEnd.set(Math.round((cur.duration || 0) * 10) / 10);
|
|
243
|
+
if (cur && cur.fps && !anFps.input.dataset.touched) anFps.set(Math.min(30, Math.round(cur.fps)));
|
|
244
|
+
app.setAnimationFps?.(Number(anFps.input.value) || 12);
|
|
245
|
+
const f = anFormat.select.value;
|
|
246
|
+
const isVideo = f === 'mp4' || f === 'webm';
|
|
247
|
+
anAudio.el.hidden = selecting || !(src === 'video' && isVideo);
|
|
248
|
+
anSize.el.hidden = !isVideo;
|
|
249
|
+
const vs = app.videoSupport;
|
|
250
|
+
const noAudio = vs && 'opus' in vs && anAudioInput.checked && !anAudio.el.hidden && !(f === 'mp4' ? vs.aac || vs.opus : vs.opus);
|
|
251
|
+
const selectionNote = selecting ? !frameSelection.complete ? 'Loading video frames...'
|
|
252
|
+
: !frameSelection.count ? 'Select frames on the canvas to enable export.'
|
|
253
|
+
: `${frameSelection.count} selected frames, played at ${Number(anFps.input.value) || 12} fps.${isVideo ? ' Selected frames export without source audio.' : ''} Turn off frame selection to use PNG, Copy or Tileset.` : '';
|
|
254
|
+
const formatNote = !ok
|
|
255
|
+
? (src === 'video' ? 'Open a video to export an animation.' : 'Add two or more images to animate them.')
|
|
256
|
+
: isVideo && !vs?.[f] ? `${f.toUpperCase()} encoding isn't available in this browser. GIF and PNG sequence always work.`
|
|
257
|
+
: noAudio ? "This browser can't encode audio, so the video will be silent."
|
|
258
|
+
: f === 'mp4' && vs && 'aac' in vs && !vs.aac && vs.opus && anAudioInput.checked && !anAudio.el.hidden
|
|
259
|
+
? "This browser can't encode AAC, so the MP4's sound will be Opus, which Windows Media Player and QuickTime may not play. WebM, or Chrome/Edge on Windows or macOS, avoids that."
|
|
260
|
+
: '';
|
|
261
|
+
anNote.textContent = [selectionNote, formatNote].filter(Boolean).join(' ');
|
|
262
|
+
}
|
|
263
|
+
anFps.input.addEventListener('change', syncAnim);
|
|
264
|
+
|
|
265
|
+
// ---- sync from state
|
|
266
|
+
function renderSwatches(colors, editable) {
|
|
267
|
+
render(swatches, ...colors.map((c, i) => {
|
|
268
|
+
const b = h('button.itp-swatch', { type: 'button', role: 'listitem', title: editable ? `${c}. Click to change, Alt+click to remove` : c, style: { background: c } });
|
|
269
|
+
b.setAttribute('aria-label', editable ? `${c}, edit` : c);
|
|
270
|
+
if (editable) {
|
|
271
|
+
b.addEventListener('click', e => {
|
|
272
|
+
if (e.altKey) { const next = S().palette.colors.filter((_, j) => j !== i); if (next.length) up({ palette: { colors: next } }); return; }
|
|
273
|
+
const inp = h('input', { type: 'color', value: c, style: { position: 'fixed', opacity: 0, pointerEvents: 'none' } });
|
|
274
|
+
document.body.append(inp);
|
|
275
|
+
inp.addEventListener('input', () => { const next = S().palette.colors.slice(); next[i] = inp.value; up({ palette: { colors: next } }, 'sw' + i); });
|
|
276
|
+
inp.addEventListener('change', () => inp.remove());
|
|
277
|
+
inp.click();
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
b.addEventListener('click', () => { up({ palette: { source: 'custom', colors: S().palette.colors.slice(), name: `${S().palette.name} (edited)` } }); app.toast('Copied to a custom palette you can edit'); });
|
|
281
|
+
}
|
|
282
|
+
return b;
|
|
283
|
+
}), editable ? h('button.itp-swatch.add', { type: 'button', 'aria-label': 'Add colour', title: 'Add colour', html: icon('plus'), onclick: () => up({ palette: { colors: [...S().palette.colors, '#808080'] } }) }) : null);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
let lastPaletteKey = '';
|
|
287
|
+
function sync(s, result) {
|
|
288
|
+
sizeMode.set(s.size.mode);
|
|
289
|
+
widthBox.hidden = s.size.mode !== 'width'; blockBox.hidden = s.size.mode !== 'block';
|
|
290
|
+
width.set(s.size.width); height.set(s.size.height || ''); block.set(s.size.block); resample.set(s.size.resample);
|
|
291
|
+
secSize.setHint(result ? `${result.width} × ${result.height}` : '');
|
|
292
|
+
|
|
293
|
+
palSource.set(s.palette.source); palBuiltin.set(s.palette.id);
|
|
294
|
+
palBuiltin.el.hidden = s.palette.source !== 'builtin';
|
|
295
|
+
palCount.el.hidden = s.palette.source !== 'extract';
|
|
296
|
+
palCount.set(s.palette.count);
|
|
297
|
+
palDistance.set(s.palette.distance);
|
|
298
|
+
palDistance.el.hidden = s.palette.source === 'none';
|
|
299
|
+
swatchField.hidden = s.palette.source === 'none';
|
|
300
|
+
const shown = s.palette.source === 'extract' ? (result && result.palette) || [] : s.palette.colors;
|
|
301
|
+
const key = s.palette.source + shown.join();
|
|
302
|
+
if (key !== lastPaletteKey) { lastPaletteKey = key; renderSwatches(shown, s.palette.source === 'custom'); }
|
|
303
|
+
swCount.textContent = String(shown.length);
|
|
304
|
+
secPal.setHint(s.palette.source === 'none' ? 'None' : s.palette.name);
|
|
305
|
+
|
|
306
|
+
dMethod.set(s.dither.method); dStrength.set(s.dither.strength);
|
|
307
|
+
const kind = ditherKind(s.dither.method);
|
|
308
|
+
dStrength.el.hidden = kind === 'none';
|
|
309
|
+
matrixBox.hidden = s.dither.method !== 'custom';
|
|
310
|
+
if (s.dither.method === 'custom' && document.activeElement !== matrixText) matrixText.value = (s.dither.matrix || []).map(r => r.join(' ')).join('\n');
|
|
311
|
+
secDither.setHint(s.palette.source === 'none' ? 'Needs a palette' : DITHER_METHODS.find(m => m.id === s.dither.method).label);
|
|
312
|
+
secDither.el.querySelector('select').disabled = s.palette.source === 'none';
|
|
313
|
+
|
|
314
|
+
aBright.set(s.adjust.brightness); aContrast.set(s.adjust.contrast); aSat.set(s.adjust.saturation); aHue.set(s.adjust.hue); aGamma.set(s.adjust.gamma);
|
|
315
|
+
const adjusted = s.adjust.brightness || s.adjust.contrast || s.adjust.saturation || s.adjust.hue || s.adjust.gamma !== 1;
|
|
316
|
+
|
|
317
|
+
// frame
|
|
318
|
+
aspectBtns.forEach((b, i) => b.setAttribute('aria-pressed', String(ASPECTS[i].id === s.frame.aspect)));
|
|
319
|
+
fZoom.set(s.frame.zoom); fX.set(s.frame.x); fY.set(s.frame.y);
|
|
320
|
+
const cur = app.current;
|
|
321
|
+
const crop = cur ? cropRect(cur.width, cur.height, s.frame) : null;
|
|
322
|
+
// position sliders only matter when the frame leaves room to move in that direction
|
|
323
|
+
fX.el.hidden = !crop || crop.w >= cur.width;
|
|
324
|
+
fY.el.hidden = !crop || crop.h >= cur.height;
|
|
325
|
+
const framed = s.frame.aspect !== 'original' || s.frame.zoom > 1;
|
|
326
|
+
secFrame.setHint(!framed ? '' : [s.frame.aspect !== 'original' ? s.frame.aspect : '', s.frame.zoom > 1 ? `${Math.round(s.frame.zoom * 100) / 100}×` : ''].filter(Boolean).join(', '));
|
|
327
|
+
secFrame.setChanged(!same(s.frame, DEF.frame));
|
|
328
|
+
|
|
329
|
+
// scope
|
|
330
|
+
const many = app.items.length > 1;
|
|
331
|
+
scopeRow.hidden = !(cur && (many || cur.own));
|
|
332
|
+
scope.set(cur && cur.own ? 'own' : 'shared');
|
|
333
|
+
const nShared = app.items.filter(i => !i.own).length;
|
|
334
|
+
scopeNote.textContent = cur && cur.own ? 'Only this file' : `${nShared} ${nShared === 1 ? 'file' : 'files'}`;
|
|
335
|
+
el.dataset.scope = cur && cur.own ? 'own' : 'shared';
|
|
336
|
+
secAdjust.setHint(adjusted ? 'Changed' : '');
|
|
337
|
+
|
|
338
|
+
alphaCut.set(s.alpha.threshold); orphans.set(s.cleanup.orphans); smooth.set(s.cleanup.smooth);
|
|
339
|
+
olMode.set(s.outline.mode); olColor.set(s.outline.color); olCorners.set(s.outline.corners);
|
|
340
|
+
olColor.el.hidden = olCorners.el.hidden = s.outline.mode === 'none';
|
|
341
|
+
const cl = [s.cleanup.orphans && 'strays', s.cleanup.smooth && 'smooth', s.outline.mode !== 'none' && 'outline'].filter(Boolean);
|
|
342
|
+
secClean.setHint(cl.join(', '));
|
|
343
|
+
|
|
344
|
+
// reset icons
|
|
345
|
+
secSize.setChanged(!same(s.size, DEF.size));
|
|
346
|
+
secPal.setChanged(!same(s.palette, DEF.palette));
|
|
347
|
+
secDither.setChanged(!same(s.dither, DEF.dither));
|
|
348
|
+
secAdjust.setChanged(!same(s.adjust, DEF.adjust));
|
|
349
|
+
secClean.setChanged(!same([s.cleanup, s.outline, s.alpha], [DEF.cleanup, DEF.outline, DEF.alpha]));
|
|
350
|
+
secImage.setChanged(!same(s.export, DEF.export));
|
|
351
|
+
|
|
352
|
+
exScale.set(s.export.scale); exGrid.set(s.export.grid); exIndexed.set(s.export.indexed); exMode.set(s.export.size);
|
|
353
|
+
exScale.el.hidden = exGrid.el.hidden = s.export.size === 'source';
|
|
354
|
+
const out = exportSize(s, result, app.current);
|
|
355
|
+
exSizeOut.textContent = result ? `${out.width} × ${out.height} px` : '–';
|
|
356
|
+
exMode.el.querySelector('.help')?.remove();
|
|
357
|
+
if (s.export.size === 'source') exMode.el.append(h('div.help', { text: 'The size of the framed part of the source, like a full-resolution download. Pixels can differ by one in size when the ratio isn\'t a whole number.' }));
|
|
358
|
+
btnZip.hidden = app.items.length < 2;
|
|
359
|
+
btnZip.querySelector('span').textContent = `Download all ${app.items.length} as ZIP`;
|
|
360
|
+
syncAnim();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function setStats(result) {
|
|
364
|
+
if (!result) { statSize.textContent = statColors.textContent = statTime.textContent = '–'; return; }
|
|
365
|
+
statSize.innerHTML = `${result.width}<small>× ${result.height}</small>`;
|
|
366
|
+
const used = result.usedColors().length;
|
|
367
|
+
statColors.innerHTML = result.palette ? `${used}<small>/ ${result.palette.length}</small>` : `${used}`;
|
|
368
|
+
statTime.innerHTML = `${result.ms < 10 ? result.ms.toFixed(1) : Math.round(result.ms)}<small>ms</small>`;
|
|
369
|
+
statTime.parentElement.title = result.backend === 'webgl' ? 'Rendered on the GPU (WebGL 2 via ShaderMate)' : 'Rendered on the CPU';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { el, sync, setStats, setTab, syncAnim, animationOptions: animOpts,
|
|
373
|
+
setFrameSelectionState(state) { frameSelection = state; syncAnim(); },
|
|
374
|
+
setAnimationRange({ start, end, user = false }) {
|
|
375
|
+
anStart.set(Number(start.toFixed(3))); anEnd.set(Number(end.toFixed(3)));
|
|
376
|
+
if (user) { anStart.input.dataset.touched = '1'; anEnd.input.dataset.touched = '1'; }
|
|
377
|
+
} };
|
|
378
|
+
}
|
|
379
|
+
|