image-to-pixel 3.0.1 → 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.
@@ -4,6 +4,7 @@ import { h, render } from './dom.js';
4
4
  import { icon } from './icons.js';
5
5
  import { section, slider, select, segmented, toggle, colorInput, numberInput, field } from './controls.js';
6
6
  import { exportSize } from './exporter.js';
7
+ import { isFullClipDefault, INITIAL_INTERVAL_SECONDS } from './storyboard.js';
7
8
  import { DITHER_METHODS, ditherKind, defaultSettings, ASPECTS, cropRect } from '../core/settings.js';
8
9
  import * as persist from './persist.js';
9
10
  import { PALETTES } from '../core/palette/builtin.js';
@@ -141,18 +142,43 @@ export function createInspector(app) {
141
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);
142
143
 
143
144
  // animation
145
+ let frameSelection = { active: false, complete: false, count: 0 };
144
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
+ } });
145
159
  const anFormat = select({ label: 'Format', options: [
146
160
  { value: 'gif', label: 'GIF' }, { value: 'webm', label: 'WebM video' }, { value: 'mp4', label: 'MP4 video' }, { value: 'png-seq', label: 'PNG sequence (ZIP)' },
147
161
  ], onChange: () => syncAnim() });
148
- const anFps = numberInput({ label: 'Frames per second', min: 1, max: 60, onChange: () => { } });
149
- const anStart = numberInput({ label: 'Start (s)', min: 0, max: 36000, step: 0.1, onChange: () => { } });
150
- const anEnd = numberInput({ label: 'End (s)', min: 0, max: 36000, step: 0.1, onChange: () => { } });
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');
151
177
  const anNote = h('div.help', {});
152
178
  const btnAnim = h('button.itp-btn.primary.block', { type: 'button', html: `${icon('film')}<span>Export animation</span>`, onclick: () => app.exportAnimation(animOpts()) });
153
- const secAnim = sec('animation', 'Animation', false, null, anSource.el, anFormat.el, anFps.el, h('div.itp-grid2', {}, anStart.el, anEnd.el), anNote, btnAnim);
154
- anFps.set(12); anStart.set(0); anEnd.set(0);
155
- 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 });
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 });
156
182
 
157
183
  // sprite sheet
158
184
  const shCols = numberInput({ label: 'Columns', min: 0, max: 256, help: '0 = as square as possible.', onChange: () => { } });
@@ -198,23 +224,43 @@ export function createInspector(app) {
198
224
  });
199
225
 
200
226
  function syncAnim() {
201
- const cur = app.current;
202
- const hasVideo = cur && cur.type === 'video';
227
+ const cur = app.current?.type === 'video' ? app.current : null;
228
+ const hasVideo = !!cur;
203
229
  const nImages = app.items.filter(i => i.type === 'image').length;
204
230
  if (!hasVideo && anSource.select.value === 'video' && nImages > 1) anSource.set('files');
205
231
  if (hasVideo && anSource.select.value === 'files' && nImages < 2) anSource.set('video');
206
232
  const src = anSource.select.value;
207
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;
208
238
  anStart.el.hidden = anEnd.el.hidden = src !== 'video';
209
- btnAnim.disabled = !ok; btnSheet.disabled = !ok;
210
- if (hasVideo && !Number(anEnd.input.value)) anEnd.set(Math.round((cur.duration || 0) * 10) / 10);
211
- if (hasVideo && cur.fps && !anFps.input.dataset.touched) anFps.set(Math.min(30, Math.round(cur.fps)));
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);
212
245
  const f = anFormat.select.value;
213
- anNote.textContent = !ok
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
214
255
  ? (src === 'video' ? 'Open a video to export an animation.' : 'Add two or more images to animate them.')
215
- : (f === 'mp4' || f === 'webm') && !app.videoSupport?.[f] ? `${f.toUpperCase()} encoding isn't available in this browser. GIF and PNG sequence always work.` : '';
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(' ');
216
262
  }
217
- anFps.input.addEventListener('change', () => { anFps.input.dataset.touched = '1'; });
263
+ anFps.input.addEventListener('change', syncAnim);
218
264
 
219
265
  // ---- sync from state
220
266
  function renderSwatches(colors, editable) {
@@ -323,6 +369,11 @@ export function createInspector(app) {
323
369
  statTime.parentElement.title = result.backend === 'webgl' ? 'Rendered on the GPU (WebGL 2 via ShaderMate)' : 'Rendered on the CPU';
324
370
  }
325
371
 
326
- return { el, sync, setStats, setTab, syncAnim };
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
+ } };
327
378
  }
328
379
 
@@ -1,182 +1,188 @@
1
- // Left panel: Files (the batch), Presets (live thumbnails) and Palettes.
2
-
3
- import { h, render } from './dom.js';
4
- import { menu } from './ui.js';
5
- import { icon } from './icons.js';
6
- import { PRESETS } from '../core/presets.js';
7
- import { PALETTES } from '../core/palette/builtin.js';
8
- import { mergeSettings, defaultSettings } from '../core/settings.js';
9
-
10
- export function createLibrary(app) {
11
- let tab = 'files';
12
- const tabBtn = (id, label, ic) => h('button', { type: 'button', role: 'tab', 'aria-selected': String(id === tab), html: `${icon(ic)}<span>${label}</span>`, onclick: () => setTab(id) });
13
- const tFiles = tabBtn('files', 'Files', 'files');
14
- const tPresets = tabBtn('presets', 'Presets', 'presets');
15
- const tPalettes = tabBtn('palettes', 'Palettes', 'palette');
16
- const filesCount = h('span.count', {});
17
- tFiles.append(filesCount);
18
- const tabs = h('div.itp-seg', { role: 'tablist', 'aria-label': 'Library' }, tFiles, tPresets, tPalettes);
19
-
20
- const search = h('input', { type: 'search', placeholder: 'Search', 'aria-label': 'Search the library' });
21
- const searchBox = h('div.itp-search', {}, h('span', { html: icon('search') }), search);
22
- search.addEventListener('input', () => renderTab());
23
-
24
- const body = h('div.itp-panel-body', {});
25
- const el = h('aside.itp-lib', { 'aria-label': 'Library' }, h('div.itp-panel-head', {}, tabs), searchBox, body);
26
-
27
- function setTab(t) {
28
- tab = t;
29
- for (const [b, id] of [[tFiles, 'files'], [tPresets, 'presets'], [tPalettes, 'palettes']]) b.setAttribute('aria-selected', String(id === t));
30
- search.value = '';
31
- renderTab();
32
- if (t === 'presets' && !thumbs.size) refreshThumbs();
33
- }
34
- const q = () => search.value.trim().toLowerCase();
35
-
36
- // ---- files
37
- function fileMenu(it, anchor) {
38
- const i = app.items.indexOf(it), many = app.items.length > 1;
39
- menu(anchor, [
40
- it.own
41
- ? { label: 'Use shared settings', sub: 'Drop this file\'s own settings', icon: 'link', onClick: () => app.setOwn(it, false) }
42
- : { label: 'Give it its own settings', sub: 'Edits stop affecting other files', icon: 'unlink', disabled: !many, onClick: () => app.setOwn(it, true) },
43
- 'sep',
44
- { label: 'Copy settings', icon: 'copy', onClick: () => app.copySettings(it) },
45
- { label: 'Paste settings', sub: app.clipboard ? 'Gives this file its own settings' : 'Copy settings from a file first', icon: 'clipboard', disabled: !app.clipboard, onClick: () => app.pasteSettings(it) },
46
- { label: 'Apply these settings to all files', icon: 'layers', disabled: !many, onClick: () => app.applyToAll(it) },
47
- 'sep',
48
- { label: 'Duplicate', sub: 'A copy with its own settings, to compare looks', icon: 'copy', onClick: () => app.duplicate(it) },
49
- { label: 'Move up', icon: 'arrowUp', kbd: 'Alt Shift ↑', disabled: i <= 0, onClick: () => app.move(it, -1) },
50
- { label: 'Move down', icon: 'arrowDown', kbd: 'Alt Shift ↓', disabled: i >= app.items.length - 1, onClick: () => app.move(it, 1) },
51
- 'sep',
52
- { label: 'Remove', icon: 'trash', kbd: 'Del', onClick: () => app.removeItem(it) },
53
- ], { align: 'start' });
54
- }
55
-
56
- function renderFiles() {
57
- const items = app.items.filter(i => !q() || i.name.toLowerCase().includes(q()));
58
- const list = h('ul.itp-files', { 'aria-label': 'Files' });
59
- for (const it of items) {
60
- const more = h('button.itp-btn.small.icon', { type: 'button', 'aria-label': `Options for ${it.name}`, 'aria-haspopup': 'menu', title: 'Settings, duplicate, order', html: icon('more'), onclick: e => { e.stopPropagation(); fileMenu(it, more); } });
61
- const sub = it.type === 'video' ? `Video, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`;
62
- const row = h('li.itp-file-row', { tabindex: '0', 'aria-current': String(app.current === it), 'data-own': it.own ? 'true' : null },
63
- h('img.thumb', { src: it.thumb || '', alt: '' }),
64
- h('div', { style: { minWidth: 0 } },
65
- h('div.title', { text: it.name }),
66
- h('div.sub', {}, sub, it.own ? h('span.itp-own', { title: 'This file has its own settings', html: `${icon('unlink')}<span>Own</span>` }) : null)),
67
- h('div.actions', {}, more,
68
- h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Remove ${it.name}`, title: 'Remove', html: icon('x'), onclick: e => { e.stopPropagation(); app.removeItem(it); } })));
69
- row.addEventListener('click', () => app.select(it));
70
- row.addEventListener('contextmenu', e => { e.preventDefault(); app.select(it); fileMenu(it, more); });
71
- row.addEventListener('keydown', e => {
72
- if (e.target !== row) return;
73
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); app.select(it); }
74
- if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); app.removeItem(it); }
75
- if (e.altKey) return; // Alt+arrows are global shortcuts (next file, move file)
76
- if (e.key === 'ArrowDown') { e.preventDefault(); row.nextElementSibling?.focus(); }
77
- if (e.key === 'ArrowUp') { e.preventDefault(); row.previousElementSibling?.focus(); }
78
- if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { e.preventDefault(); fileMenu(it, more); }
79
- });
80
- list.append(row);
81
- }
82
- const nOwn = app.items.filter(i => i.own).length;
83
- const summary = !app.items.length ? 'No files yet'
84
- : app.items.length === 1 ? '1 file'
85
- : nOwn ? `${app.items.length} files, ${nOwn} with ${nOwn === 1 ? 'its' : 'their'} own settings`
86
- : `${app.items.length} files, shared settings`;
87
- const head = h('div.itp-group', {}, h('span', { text: summary }),
88
- app.items.length > 1 ? h('button.itp-link', { type: 'button', text: 'Clear', onclick: () => app.clearItems() }) : null);
89
- const add = h('div', { style: { padding: '4px 12px 12px', display: 'flex', gap: '6px' } },
90
- h('button.itp-btn.outline.small', { type: 'button', html: `${icon('plus')}<span>Add files</span>`, onclick: () => app.openFiles() }),
91
- h('button.itp-btn.small', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, title: 'Paste an image from the clipboard (Ctrl+V)', onclick: () => app.pasteFromClipboard() }));
92
- render(body, head, list, add, h('div.itp-group', {}, h('span', { text: 'Drop images or videos anywhere in the window. Files share one set of settings unless you give one its own (⋯ menu or right-click).' })));
93
- }
94
-
95
- // ---- presets (thumbnails rendered from the current image)
96
- const thumbs = new Map(); // preset id → canvas
97
- let thumbGen = 0;
98
- function renderPresets() {
99
- const cur = app.store.settings;
100
- const list = PRESETS.filter(p => !q() || (p.name + p.description).toLowerCase().includes(q()));
101
- const grid = h('div.itp-presets', {});
102
- for (const p of list) {
103
- const active = app.activePreset === p.id;
104
- const pic = thumbs.get(p.id) || h('div.ph', {});
105
- const b = h('button.itp-preset', { type: 'button', 'aria-pressed': String(active), title: p.description, onclick: () => app.applyPreset(p) },
106
- pic, h('span.t', { text: p.name }), h('span.d', { text: p.description }));
107
- grid.append(b);
108
- }
109
- render(body, h('div.itp-group', {}, h('span', { text: 'Presets replace the current settings. Undo with Ctrl+Z.' })), grid);
110
- void cur;
111
- }
112
- async function refreshThumbs() {
113
- const gen = ++thumbGen;
114
- const item = app.current;
115
- if (!item || !app.engine) return;
116
- for (const p of PRESETS) {
117
- if (gen !== thumbGen) return;
118
- const s = mergeSettings(defaultSettings(), p.settings);
119
- // small, fast previews: cap the width and skip slow-on-CPU diffusion sizes
120
- s.size = { ...s.size, mode: 'width', width: Math.min(s.size.width, 72), height: 0 };
121
- try {
122
- const r = await app.engine.process(item.source, s);
123
- if (gen !== thumbGen) return;
124
- const c = r.toCanvas(1);
125
- thumbs.set(p.id, c);
126
- if (tab === 'presets') renderPresets();
127
- } catch { /* ignore a failed thumbnail */ }
128
- }
129
- }
130
-
131
- // ---- palettes
132
- function palRow(p, active, removable) {
133
- const strip = h('div.itp-strip', {}, ...p.colors.slice(0, 64).map(c => h('i', { style: { background: c.startsWith('#') ? c : '#' + c } })));
134
- const row = h('li.itp-pal', { tabindex: '0', role: 'button', 'aria-pressed': String(active) },
135
- h('div.row', {}, h('span', { text: p.name }), h('span.n', { text: String(p.colors.length), title: p.author ? `by ${p.author}` : null })), strip);
136
- const pick = () => app.usePalette(p);
137
- row.addEventListener('click', pick);
138
- row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } if (removable && (e.key === 'Delete')) app.deletePalette(p); });
139
- if (removable) {
140
- const del = h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Delete ${p.name}`, title: 'Delete', html: icon('trash'), onclick: e => { e.stopPropagation(); app.deletePalette(p); } });
141
- row.firstChild.append(del);
142
- }
143
- return row;
144
- }
145
- function renderPalettes() {
146
- const s = app.store.settings.palette;
147
- const match = p => !q() || (p.name + ' ' + (p.author || '')).toLowerCase().includes(q()) || String(p.colors.length) === q();
148
- const mine = app.userPalettes.filter(match);
149
- const built = PALETTES.filter(match);
150
- const actions = h('div', { style: { padding: '0 12px 4px', display: 'flex', gap: '6px', flexWrap: 'wrap' } },
151
- h('button.itp-btn.outline.small', { type: 'button', html: `${icon('upload')}<span>Import file</span>`, onclick: () => app.importPalette() }),
152
- h('button.itp-btn.small', { type: 'button', html: `${icon('palette')}<span>From Lospec</span>`, onclick: () => app.lospecDialog() }));
153
- const yours = mine.length ? [h('div.itp-group', {}, h('span', { text: 'Your palettes' }), h('span.itp-badge', { text: String(mine.length) })),
154
- h('ul.itp-pal-list', {}, ...mine.map(p => palRow(p, s.source === 'custom' && s.name === p.name, true)))] : [];
155
- render(body, actions, ...yours,
156
- h('div.itp-group', {}, h('span', { text: 'Built-in' }), h('span.itp-badge', { text: String(built.length) })),
157
- h('ul.itp-pal-list', {}, ...built.map(p => palRow({ ...p, colors: p.colors.map(c => '#' + c) }, s.source === 'builtin' && s.id === p.id, false))));
158
- }
159
-
160
- function renderTab() {
161
- filesCount.textContent = app.items.length ? String(app.items.length) : '';
162
- if (tab === 'files') renderFiles();
163
- else if (tab === 'presets') renderPresets();
164
- else renderPalettes();
165
- }
166
-
167
- return {
168
- el,
169
- refresh: renderTab,
170
- setTab,
171
- /** Focus a file's row (after it moved). */
172
- focusFile(item) { if (tab === 'files') body.querySelectorAll('.itp-file-row')[app.items.indexOf(item)]?.focus(); },
173
- /** The current image changed: rebuild preset thumbnails. */
174
- sourceChanged() { thumbs.clear(); thumbGen++; if (tab === 'presets') { renderTab(); refreshThumbs(); } },
175
- get tab() { return tab; },
176
- };
177
- }
178
-
179
- export function fmtTime(t = 0) {
180
- const m = Math.floor(t / 60), s = t - m * 60;
181
- return `${m}:${s.toFixed(1).padStart(4, '0')}`;
182
- }
1
+ // Left panel: Files (the batch), Presets (live thumbnails) and Palettes.
2
+
3
+ import { h, render } from './dom.js';
4
+ import { menu } from './ui.js';
5
+ import { icon } from './icons.js';
6
+ import { PRESETS } from '../core/presets.js';
7
+ import { PALETTES } from '../core/palette/builtin.js';
8
+ import { mergeSettings, defaultSettings } from '../core/settings.js';
9
+
10
+ export function createLibrary(app) {
11
+ let tab = 'files';
12
+ const tabBtn = (id, label, ic) => h('button', { type: 'button', role: 'tab', title: label, 'aria-selected': String(id === tab), html: `${icon(ic)}<span>${label}</span>`, onclick: () => setTab(id) });
13
+ const tFiles = tabBtn('files', 'Files', 'files');
14
+ const tPresets = tabBtn('presets', 'Presets', 'presets');
15
+ const tPalettes = tabBtn('palettes', 'Palettes', 'palette');
16
+ const filesCount = h('span.count', {});
17
+ tFiles.append(filesCount);
18
+ const tabs = h('div.itp-seg', { role: 'tablist', 'aria-label': 'Library' }, tFiles, tPresets, tPalettes);
19
+
20
+ const search = h('input', { type: 'search', placeholder: 'Search', 'aria-label': 'Search the library' });
21
+ const searchBox = h('div.itp-search', {}, h('span', { html: icon('search') }), search);
22
+ search.addEventListener('input', () => renderTab());
23
+
24
+ const body = h('div.itp-panel-body', {});
25
+ const el = h('aside.itp-lib', { 'aria-label': 'Library' }, h('div.itp-panel-head', {}, tabs), searchBox, body);
26
+
27
+ function setTab(t) {
28
+ tab = t;
29
+ for (const [b, id] of [[tFiles, 'files'], [tPresets, 'presets'], [tPalettes, 'palettes']]) b.setAttribute('aria-selected', String(id === t));
30
+ search.value = '';
31
+ renderTab();
32
+ if (t === 'presets' && !thumbs.size) refreshThumbs();
33
+ }
34
+ const q = () => search.value.trim().toLowerCase();
35
+
36
+ // ---- files
37
+ function fileMenu(it, anchor) {
38
+ const i = app.items.indexOf(it), many = app.items.length > 1;
39
+ menu(anchor, [
40
+ it.own
41
+ ? { label: 'Use shared settings', sub: 'Drop this file\'s own settings', icon: 'link', onClick: () => app.setOwn(it, false) }
42
+ : { label: 'Give it its own settings', sub: 'Edits stop affecting other files', icon: 'unlink', disabled: !many, onClick: () => app.setOwn(it, true) },
43
+ 'sep',
44
+ { label: 'Copy settings', icon: 'copy', onClick: () => app.copySettings(it) },
45
+ { label: 'Paste settings', sub: app.clipboard ? 'Gives this file its own settings' : 'Copy settings from a file first', icon: 'clipboard', disabled: !app.clipboard, onClick: () => app.pasteSettings(it) },
46
+ { label: 'Apply these settings to all files', icon: 'layers', disabled: !many, onClick: () => app.applyToAll(it) },
47
+ 'sep',
48
+ { label: 'Rename', sub: 'Exports are named after it', icon: 'rename', kbd: 'F2', onClick: () => app.startRename(it) },
49
+ { label: 'Duplicate', sub: 'A copy with its own settings, to compare looks', icon: 'copy', onClick: () => app.duplicate(it) },
50
+ { label: 'Move up', icon: 'arrowUp', kbd: 'Alt Shift ↑', disabled: i <= 0, onClick: () => app.move(it, -1) },
51
+ { label: 'Move down', icon: 'arrowDown', kbd: 'Alt Shift ↓', disabled: i >= app.items.length - 1, onClick: () => app.move(it, 1) },
52
+ 'sep',
53
+ { label: 'Remove', icon: 'trash', kbd: 'Del', onClick: () => app.removeItem(it) },
54
+ ], { align: 'start' });
55
+ }
56
+
57
+ function renderFiles() {
58
+ const items = app.items.filter(i => !q() || i.name.toLowerCase().includes(q()));
59
+ const list = h('ul.itp-files', { 'aria-label': 'Files' });
60
+ for (const it of items) {
61
+ const more = h('button.itp-btn.small.icon', { type: 'button', 'aria-label': `Options for ${it.name}`, 'aria-haspopup': 'menu', title: 'Settings, duplicate, order', html: icon('more'), onclick: e => { e.stopPropagation(); fileMenu(it, more); } });
62
+ const sub = it.type === 'video' ? `Video, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`;
63
+ const row = h('li.itp-file-row', { tabindex: '0', 'aria-current': String(app.current === it), 'data-own': it.own ? 'true' : null },
64
+ h('img.thumb', { src: it.thumb || '', alt: '' }),
65
+ h('div', { style: { minWidth: 0 } },
66
+ h('div.title', { text: it.name }),
67
+ h('div.sub', {}, sub, it.own ? h('span.itp-own', { title: 'This file has its own settings', html: `${icon('unlink')}<span>Own</span>` }) : null)),
68
+ h('div.actions', {}, more,
69
+ h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Remove ${it.name}`, title: 'Remove', html: icon('x'), onclick: e => { e.stopPropagation(); app.removeItem(it); } })));
70
+ row.addEventListener('click', () => app.select(it));
71
+ row.addEventListener('dblclick', e => { if (e.target.closest('.title')) app.startRename(it); });
72
+ row.addEventListener('contextmenu', e => { e.preventDefault(); app.select(it); fileMenu(it, more); });
73
+ row.addEventListener('keydown', e => {
74
+ if (e.target !== row) return;
75
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); app.select(it); }
76
+ if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); app.removeItem(it); }
77
+ if (e.altKey) return; // Alt+arrows are global shortcuts (next file, move file)
78
+ if (e.key === 'ArrowDown') { e.preventDefault(); row.nextElementSibling?.focus(); }
79
+ if (e.key === 'ArrowUp') { e.preventDefault(); row.previousElementSibling?.focus(); }
80
+ if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { e.preventDefault(); fileMenu(it, more); }
81
+ });
82
+ list.append(row);
83
+ }
84
+ const nOwn = app.items.filter(i => i.own).length;
85
+ const summary = !app.items.length ? 'No files yet'
86
+ : app.items.length === 1 ? '1 file'
87
+ : nOwn ? `${app.items.length} files, ${nOwn} with ${nOwn === 1 ? 'its' : 'their'} own settings`
88
+ : `${app.items.length} files, shared settings`;
89
+ const head = h('div.itp-group', {}, h('span', { text: summary }),
90
+ app.items.length > 1 ? h('button.itp-link', { type: 'button', text: 'Clear', onclick: () => app.clearItems() }) : null);
91
+ const add = h('div', { style: { padding: '4px 12px 12px', display: 'flex', gap: '6px' } },
92
+ h('button.itp-btn.outline.small', { type: 'button', html: `${icon('plus')}<span>Add files</span>`, onclick: () => app.openFiles() }),
93
+ h('button.itp-btn.small', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, title: 'Paste an image from the clipboard (Ctrl+V)', onclick: () => app.pasteFromClipboard() }));
94
+ const saved = app.savedWorkspace && app.savedWorkspace();
95
+ const savedNote = saved ? h('div.itp-group.itp-saved', {},
96
+ h('span', { text: `Saved in this browser: ${saved.count} file${saved.count === 1 ? '' : 's'}`, title: 'Kept in this browser\'s storage, never uploaded. Clearing the site\'s data also removes it.' }),
97
+ h('button.itp-link', { type: 'button', text: 'Forget', title: 'Remove the saved files from this browser', onclick: () => app.forgetWorkspace() })) : null;
98
+ render(body, head, list, add, savedNote, h('div.itp-group', {}, h('span', { text: 'Drop images or videos anywhere in the window. Files share one set of settings unless you give one its own (⋯ menu or right-click). Save keeps them in this browser for next time.' })));
99
+ }
100
+
101
+ // ---- presets (thumbnails rendered from the current image)
102
+ const thumbs = new Map(); // preset id → canvas
103
+ let thumbGen = 0;
104
+ function renderPresets() {
105
+ const cur = app.store.settings;
106
+ const list = PRESETS.filter(p => !q() || (p.name + p.description).toLowerCase().includes(q()));
107
+ const grid = h('div.itp-presets', {});
108
+ for (const p of list) {
109
+ const active = app.activePreset === p.id;
110
+ const pic = thumbs.get(p.id) || h('div.ph', {});
111
+ const b = h('button.itp-preset', { type: 'button', 'aria-pressed': String(active), title: p.description, onclick: () => app.applyPreset(p) },
112
+ pic, h('span.t', { text: p.name }), h('span.d', { text: p.description }));
113
+ grid.append(b);
114
+ }
115
+ render(body, h('div.itp-group', {}, h('span', { text: 'Presets replace the current settings. Undo with Ctrl+Z.' })), grid);
116
+ void cur;
117
+ }
118
+ async function refreshThumbs() {
119
+ const gen = ++thumbGen;
120
+ const item = app.current;
121
+ if (!item || !app.engine) return;
122
+ for (const p of PRESETS) {
123
+ if (gen !== thumbGen) return;
124
+ const s = mergeSettings(defaultSettings(), p.settings);
125
+ // small, fast previews: cap the width and skip slow-on-CPU diffusion sizes
126
+ s.size = { ...s.size, mode: 'width', width: Math.min(s.size.width, 72), height: 0 };
127
+ try {
128
+ const r = await app.engine.process(item.source, s);
129
+ if (gen !== thumbGen) return;
130
+ const c = r.toCanvas(1);
131
+ thumbs.set(p.id, c);
132
+ if (tab === 'presets') renderPresets();
133
+ } catch { /* ignore a failed thumbnail */ }
134
+ }
135
+ }
136
+
137
+ // ---- palettes
138
+ function palRow(p, active, removable) {
139
+ const strip = h('div.itp-strip', {}, ...p.colors.slice(0, 64).map(c => h('i', { style: { background: c.startsWith('#') ? c : '#' + c } })));
140
+ const row = h('li.itp-pal', { tabindex: '0', role: 'button', 'aria-pressed': String(active) },
141
+ h('div.row', {}, h('span', { text: p.name }), h('span.n', { text: String(p.colors.length), title: p.author ? `by ${p.author}` : null })), strip);
142
+ const pick = () => app.usePalette(p);
143
+ row.addEventListener('click', pick);
144
+ row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } if (removable && (e.key === 'Delete')) app.deletePalette(p); });
145
+ if (removable) {
146
+ const del = h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Delete ${p.name}`, title: 'Delete', html: icon('trash'), onclick: e => { e.stopPropagation(); app.deletePalette(p); } });
147
+ row.firstChild.append(del);
148
+ }
149
+ return row;
150
+ }
151
+ function renderPalettes() {
152
+ const s = app.store.settings.palette;
153
+ const match = p => !q() || (p.name + ' ' + (p.author || '')).toLowerCase().includes(q()) || String(p.colors.length) === q();
154
+ const mine = app.userPalettes.filter(match);
155
+ const built = PALETTES.filter(match);
156
+ const actions = h('div', { style: { padding: '0 12px 4px', display: 'flex', gap: '6px', flexWrap: 'wrap' } },
157
+ h('button.itp-btn.outline.small', { type: 'button', html: `${icon('upload')}<span>Import file</span>`, onclick: () => app.importPalette() }),
158
+ h('button.itp-btn.small', { type: 'button', html: `${icon('palette')}<span>From Lospec</span>`, onclick: () => app.lospecDialog() }));
159
+ const yours = mine.length ? [h('div.itp-group', {}, h('span', { text: 'Your palettes' }), h('span.itp-badge', { text: String(mine.length) })),
160
+ h('ul.itp-pal-list', {}, ...mine.map(p => palRow(p, s.source === 'custom' && s.name === p.name, true)))] : [];
161
+ render(body, actions, ...yours,
162
+ h('div.itp-group', {}, h('span', { text: 'Built-in' }), h('span.itp-badge', { text: String(built.length) })),
163
+ h('ul.itp-pal-list', {}, ...built.map(p => palRow({ ...p, colors: p.colors.map(c => '#' + c) }, s.source === 'builtin' && s.id === p.id, false))));
164
+ }
165
+
166
+ function renderTab() {
167
+ filesCount.textContent = app.items.length ? String(app.items.length) : '';
168
+ if (tab === 'files') renderFiles();
169
+ else if (tab === 'presets') renderPresets();
170
+ else renderPalettes();
171
+ }
172
+
173
+ return {
174
+ el,
175
+ refresh: renderTab,
176
+ setTab,
177
+ /** Focus a file's row (after it moved). */
178
+ focusFile(item) { if (tab === 'files') body.querySelectorAll('.itp-file-row')[app.items.indexOf(item)]?.focus(); },
179
+ /** The current image changed: rebuild preset thumbnails. */
180
+ sourceChanged() { thumbs.clear(); thumbGen++; if (tab === 'presets') { renderTab(); refreshThumbs(); } },
181
+ get tab() { return tab; },
182
+ };
183
+ }
184
+
185
+ export function fmtTime(t = 0) {
186
+ const m = Math.floor(t / 60), s = t - m * 60;
187
+ return `${m}:${s.toFixed(1).padStart(4, '0')}`;
188
+ }