image-to-pixel 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -2
  3. package/dist/editor.css +1 -1
  4. package/dist/editor.css.map +2 -2
  5. package/dist/editor.esm.js +60 -60
  6. package/dist/editor.esm.js.map +4 -4
  7. package/dist/fonts/README.md +6 -6
  8. package/dist/fonts/geist-LICENSE.txt +93 -93
  9. package/dist/image-to-pixel.bundle.esm.js +16 -16
  10. package/dist/image-to-pixel.bundle.esm.js.map +3 -3
  11. package/dist/image-to-pixel.cjs +2 -2
  12. package/dist/image-to-pixel.cjs.map +2 -2
  13. package/dist/image-to-pixel.esm.js +2 -2
  14. package/dist/image-to-pixel.esm.js.map +2 -2
  15. package/dist/image-to-pixel.js +2 -2
  16. package/dist/image-to-pixel.js.map +2 -2
  17. package/dist/image-to-pixel.min.js +11 -11
  18. package/dist/image-to-pixel.min.js.map +3 -3
  19. package/dist/video.esm.js +2 -2
  20. package/dist/video.esm.js.map +4 -4
  21. package/lib/core/color.js +69 -69
  22. package/lib/core/cpu/pipeline.js +21 -21
  23. package/lib/core/cpu/stages.js +227 -227
  24. package/lib/core/dither/bluenoise-data.js +3 -3
  25. package/lib/core/dither/kernels.js +27 -27
  26. package/lib/core/dither/matrices.js +95 -95
  27. package/lib/core/engine.js +158 -158
  28. package/lib/core/export/canvas.js +22 -22
  29. package/lib/core/export/crc32.js +15 -15
  30. package/lib/core/export/gif.js +153 -153
  31. package/lib/core/export/png.js +94 -94
  32. package/lib/core/export/scale.js +103 -103
  33. package/lib/core/export/sheet.js +95 -95
  34. package/lib/core/export/zip.js +55 -55
  35. package/lib/core/gpu/backend.js +159 -159
  36. package/lib/core/gpu/shaders.js +258 -258
  37. package/lib/core/palette/builtin.js +36 -36
  38. package/lib/core/palette/extract.js +69 -69
  39. package/lib/core/palette/lospec.js +24 -24
  40. package/lib/core/palette/parse.js +112 -112
  41. package/lib/core/plan.js +58 -58
  42. package/lib/core/presets.js +42 -42
  43. package/lib/core/settings.js +274 -274
  44. package/lib/core/source.js +89 -89
  45. package/lib/core/video/audio.js +159 -0
  46. package/lib/core/video/frames.js +38 -0
  47. package/lib/core/video/mux-mp4.js +213 -24
  48. package/lib/core/video/mux-webm.js +63 -22
  49. package/lib/editor/controls.js +139 -139
  50. package/lib/editor/dom.js +65 -65
  51. package/lib/editor/exporter.js +43 -3
  52. package/lib/editor/icons.js +4 -0
  53. package/lib/editor/index.js +218 -9
  54. package/lib/editor/inspector.js +19 -4
  55. package/lib/editor/library.js +8 -2
  56. package/lib/editor/persist.js +18 -18
  57. package/lib/editor/samples.js +90 -90
  58. package/lib/editor/stage.js +239 -239
  59. package/lib/editor/store.js +80 -80
  60. package/lib/editor/styles/editor.css +34 -1
  61. package/lib/editor/styles/fonts/README.md +6 -6
  62. package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
  63. package/lib/editor/styles/tokens.css +89 -89
  64. package/lib/editor/ui.js +97 -97
  65. package/lib/editor/workspace.js +62 -0
  66. package/lib/index.js +31 -29
  67. package/lib/video.js +37 -7
  68. package/package.json +1 -1
  69. package/types/editor.d.ts +34 -34
  70. package/types/index.d.ts +196 -196
  71. package/types/video.d.ts +61 -5
@@ -7,7 +7,7 @@ import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/p
7
7
  import { fetchLospec } from '../core/palette/lospec.js';
8
8
  import { detectGrid } from '../core/export/scale.js';
9
9
  import { toHex } from '../core/color.js';
10
- import { estimateFps, videoReady, seekTo, sniffVideoCodec } from '../core/video/frames.js';
10
+ import { estimateFps, videoReady, seekTo, sniffVideoCodec, hasAudioTrack } from '../core/video/frames.js';
11
11
  import { downloadBlob } from '../core/export/canvas.js';
12
12
  import { PRESETS } from '../core/presets.js';
13
13
  import { h, render, pickFiles } from './dom.js';
@@ -20,6 +20,7 @@ import { createExporter } from './exporter.js';
20
20
  import { toast, menu, closeMenu, dialog } from './ui.js';
21
21
  import { SAMPLES, loadSample, sampleURL } from './samples.js';
22
22
  import * as persist from './persist.js';
23
+ import { saveWorkspace, loadWorkspace, forgetWorkspace, isQuotaError, fmtBytes } from './workspace.js';
23
24
 
24
25
  const IMAGE_TYPES = /^image\//;
25
26
  const VIDEO_TYPES = /^video\//;
@@ -67,6 +68,8 @@ export async function mountEditor(root, options = {}) {
67
68
  const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
68
69
 
69
70
  const fileName = h('span.name', { text: 'No image' });
71
+ // click the name to rename (F2); the extension stays as it was
72
+ fileName.addEventListener('click', () => { if (app.current) startRename(app.current); });
70
73
  const fileMeta = h('span.meta.num', {});
71
74
  const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
72
75
  const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
@@ -78,6 +81,8 @@ export async function mountEditor(root, options = {}) {
78
81
  const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
79
82
  const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
80
83
  const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
84
+ const saveBtn = h('button.itp-btn.outline', { type: 'button', title: 'Save these files and their settings in this browser', html: `${icon('save')}<span class="hide-sm">Save</span>`, onclick: () => saveNow() });
85
+ saveBtn.hidden = !usePersist;
81
86
  const brand = h('a.itp-brand', { href: options.homeHref || '#', title: 'Image-to-Pixel' }, h('span.itp-mark', { 'aria-hidden': 'true' }, ...Array.from({ length: 9 }, () => h('i'))), h('span.hide-sm', { text: 'Image-to-Pixel' }));
82
87
  if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
83
88
 
@@ -88,7 +93,7 @@ export async function mountEditor(root, options = {}) {
88
93
  h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
89
94
  compareBtn, gridBtn, zoomBtn,
90
95
  h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
91
- h('span', { style: { width: '6px' } }), exportBtn);
96
+ h('span', { style: { width: '6px' } }), saveBtn, exportBtn);
92
97
 
93
98
  // stage extras
94
99
  const sampleButtons = options.samples === false ? null : h('div.samples', {}, ...SAMPLES.map(s => h('button', { type: 'button', title: `Try "${s.name}"`, 'aria-label': `Open sample: ${s.name}`, onclick: () => openSample(s) }, h('img', { src: sampleURL(s), alt: '' }))));
@@ -113,9 +118,49 @@ export async function mountEditor(root, options = {}) {
113
118
  const timeLabel = h('span.itp-time', {});
114
119
  const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
115
120
  const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
121
+ // sound: muted by default; the choice (and volume) is remembered and applies to every video
122
+ const sound = { muted: persist.load('muted', true) !== false, volume: Number(persist.load('volume', 1)) };
123
+ if (!(sound.volume >= 0 && sound.volume <= 1)) sound.volume = 1;
124
+ // YouTube-style: the speaker button opens a vertical volume slider; all the way down is mute.
125
+ const muteBtn = btn('volumeOff', 'Volume (M mutes)', () => toggleVolPop(), { 'aria-haspopup': 'true', 'aria-expanded': 'false' });
126
+ const volFill = h('div.fill');
127
+ const volThumb = h('div.thumb');
128
+ const volSlider = h('div.itp-vslider', { role: 'slider', tabindex: '0', 'aria-label': 'Volume', 'aria-orientation': 'vertical', 'aria-valuemin': '0', 'aria-valuemax': '100' }, h('div.track', {}, volFill, volThumb));
129
+ const volValue = h('span.num', {});
130
+ const volPop = h('div.itp-volpop', { hidden: true }, volValue, volSlider);
131
+ const soundBox = h('div.itp-sound', { hidden: true }, volPop, muteBtn);
132
+ const setLevel = v => setSound(v <= 0.005 ? { muted: true } : { muted: false, volume: Math.min(1, v) });
133
+ const levelAt = e => { const r = volSlider.querySelector('.track').getBoundingClientRect(); return Math.max(0, Math.min(1, (r.bottom - e.clientY) / r.height)); };
134
+ volSlider.addEventListener('pointerdown', e => {
135
+ e.preventDefault(); volSlider.focus(); volSlider.setPointerCapture(e.pointerId);
136
+ const before = sound.muted ? 0 : sound.volume;
137
+ setLevel(levelAt(e));
138
+ const move = ev => setLevel(levelAt(ev));
139
+ const up = () => {
140
+ volSlider.removeEventListener('pointermove', move); volSlider.removeEventListener('pointerup', up); volSlider.removeEventListener('pointercancel', up);
141
+ // dragged down to mute: M brings back the volume it had before the drag
142
+ if (sound.muted && before > 0) setSound({ muted: true, volume: before });
143
+ };
144
+ volSlider.addEventListener('pointermove', move); volSlider.addEventListener('pointerup', up); volSlider.addEventListener('pointercancel', up);
145
+ });
146
+ volSlider.addEventListener('keydown', e => {
147
+ const cur = sound.muted ? 0 : sound.volume;
148
+ const next = { ArrowUp: cur + 0.05, ArrowRight: cur + 0.05, ArrowDown: cur - 0.05, ArrowLeft: cur - 0.05, PageUp: cur + 0.2, PageDown: cur - 0.2, Home: 0, End: 1 }[e.key];
149
+ if (next === undefined) { if (e.key === 'Escape') { e.preventDefault(); toggleVolPop(false); muteBtn.focus(); } return; }
150
+ e.preventDefault(); e.stopPropagation(); setLevel(next);
151
+ });
152
+ volSlider.addEventListener('wheel', e => { e.preventDefault(); setLevel((sound.muted ? 0 : sound.volume) - Math.sign(e.deltaY) * 0.05); }, { passive: false });
153
+ function toggleVolPop(open = volPop.hidden) {
154
+ volPop.hidden = !open;
155
+ muteBtn.setAttribute('aria-expanded', String(open));
156
+ if (open) {
157
+ const close = e => { if (!soundBox.contains(e.target)) { toggleVolPop(false); document.removeEventListener('pointerdown', close, true); } };
158
+ document.addEventListener('pointerdown', close, true);
159
+ }
160
+ }
116
161
  const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
117
162
  btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
118
- timeLabel, scrub, fpsLabel);
163
+ timeLabel, scrub, soundBox, fpsLabel);
119
164
  const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, transport);
120
165
 
121
166
  // on narrow screens the library is a drawer: a tap on the canvas closes it
@@ -224,7 +269,9 @@ export async function mountEditor(root, options = {}) {
224
269
  }
225
270
 
226
271
  async function addVideo(name, file) {
227
- const video = h('video', { muted: true, playsInline: true, loop: true, preload: 'auto', crossOrigin: 'anonymous' });
272
+ // No `loop` attribute: looping by hand on 'ended' is more reliable (some MOV/MP4 files stalled at the
273
+ // end and lost their sound after Chrome's built-in loop).
274
+ const video = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
228
275
  video.muted = true;
229
276
  video.src = URL.createObjectURL(file);
230
277
  try {
@@ -246,11 +293,19 @@ export async function mountEditor(root, options = {}) {
246
293
  await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
247
294
  item.thumb = await makeThumb(video, item.width, item.height);
248
295
  app.items.push(item);
249
- estimateFps(video, 30).then(f => { item.fps = f; if (app.current === item) updateTransport(); inspector.syncAnim(); });
296
+ // measuring plays the video muted for a moment; Play waits for it so the two don't fight
297
+ item.fpsReady = estimateFps(video, 30).catch(() => 30).then(f => { item.fps = f; item.fpsReady = null; if (app.current === item) updateTransport(); inspector.syncAnim(); });
298
+ // null = can't tell from the container: the sound controls stay, just in case
299
+ hasAudioTrack(file).then(a => { item.hasAudio = a; if (app.current === item) updateTransport(); });
250
300
  video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
251
301
  video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
252
302
  video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
253
303
  video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
304
+ video.addEventListener('ended', () => {
305
+ if (app.current !== item || app.exporting || item.fpsReady) return;
306
+ video.currentTime = 0;
307
+ video.play().catch(() => { });
308
+ });
254
309
  return item;
255
310
  }
256
311
 
@@ -303,7 +358,7 @@ export async function mountEditor(root, options = {}) {
303
358
 
304
359
  async function openSample(s) {
305
360
  const img = await loadSample(s);
306
- const item = await addImage(`${s.name}.svg`, img);
361
+ const item = await addImage(`${s.name}.svg`, img, new File([s.svg], `${s.name}.svg`, { type: 'image/svg+xml' }));
307
362
  select(item);
308
363
  library.refresh();
309
364
  }
@@ -441,6 +496,127 @@ export async function mountEditor(root, options = {}) {
441
496
  if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
442
497
  };
443
498
 
499
+ // ================================================================ rename
500
+ const splitExt = name => { const m = /^(.*?)(\.[^.\s]{1,5})?$/.exec(name); return [m[1], m[2] || '']; };
501
+ /** Rename a file. The extension is kept: exports are named after it ("<name>@4x.png"). */
502
+ app.rename = (item, next) => {
503
+ const [, ext] = splitExt(item.name);
504
+ let base = String(next || '').trim().replace(/[\\/:*?"<>|]+/g, '-');
505
+ if (ext && base.toLowerCase().endsWith(ext.toLowerCase())) base = base.slice(0, -ext.length);
506
+ if (!base || base + ext === item.name) return;
507
+ item.name = base + ext;
508
+ library.refresh();
509
+ updateStatus();
510
+ };
511
+ /** Edit the name in place in the header, with the name (not the extension) selected. */
512
+ function startRename(item) {
513
+ if (!item || fileName.hidden) return;
514
+ if (app.current !== item) select(item);
515
+ const [base] = splitExt(item.name);
516
+ const input = h('input.itp-name-input', { type: 'text', value: item.name, 'aria-label': 'File name', spellcheck: false });
517
+ input.style.width = `${Math.min(40, Math.max(12, item.name.length + 2))}ch`;
518
+ fileName.hidden = true;
519
+ fileName.after(input);
520
+ input.focus();
521
+ input.setSelectionRange(0, base.length);
522
+ let done = false;
523
+ const finish = commit => {
524
+ if (done) return;
525
+ done = true;
526
+ if (commit) app.rename(item, input.value);
527
+ input.remove();
528
+ fileName.hidden = false;
529
+ };
530
+ input.addEventListener('keydown', e => {
531
+ e.stopPropagation();
532
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
533
+ if (e.key === 'Escape') { e.preventDefault(); finish(false); }
534
+ });
535
+ input.addEventListener('blur', () => finish(true));
536
+ }
537
+ app.startRename = startRename;
538
+
539
+ // ================================================================ saved workspace
540
+ // Files (their bytes), names, per-file settings and the shared settings, in this browser's IndexedDB.
541
+ let savedInfo = null; // { count, bytes, savedAt } of what is saved, or null
542
+ app.savedWorkspace = () => savedInfo;
543
+
544
+ /** A Blob for a file, for saving: the original bytes, or a PNG of what was added without a file. */
545
+ async function bytesOf(item) {
546
+ if (item.file) return item.file;
547
+ const c = document.createElement('canvas');
548
+ c.width = item.width; c.height = item.height;
549
+ c.getContext('2d').drawImage(item.source, 0, 0);
550
+ return new Promise(res => c.toBlob(b => res(b), 'image/png'));
551
+ }
552
+
553
+ async function saveNow() {
554
+ if (!usePersist) return;
555
+ if (!app.items.length) {
556
+ if (savedInfo) { await forgetSaved(); return; }
557
+ toast('Add files first, then Save keeps them here for next time.');
558
+ return;
559
+ }
560
+ saveBtn.disabled = true;
561
+ try {
562
+ const items = [];
563
+ for (const it of app.items) items.push({ name: it.name, type: it.type, file: await bytesOf(it), own: it.own ? clone(it.own) : null });
564
+ const bytes = await saveWorkspace({ shared: clone(app.shared), current: Math.max(0, app.items.indexOf(app.current)), items });
565
+ const first = !savedInfo;
566
+ savedInfo = { count: items.length, bytes, savedAt: Date.now() };
567
+ library.refresh();
568
+ toast(first
569
+ ? `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)}) in this browser only. They reopen next time; remove them with Forget in the Files list.`
570
+ : `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)})`, { kind: 'ok', timeout: first ? 7000 : 2200 });
571
+ } catch (e) {
572
+ console.error(e);
573
+ toast(isQuotaError(e) ? "Not enough browser storage to save these files. Remove some (videos are large) and try again." : `Couldn't save: ${e.message}`, { kind: 'error' });
574
+ } finally {
575
+ saveBtn.disabled = false;
576
+ }
577
+ }
578
+ app.saveWorkspace = saveNow;
579
+
580
+ async function forgetSaved() {
581
+ try { await forgetWorkspace(); } catch (e) { toast(`Couldn't remove the saved files: ${e.message}`, { kind: 'error' }); return; }
582
+ savedInfo = null;
583
+ library.refresh();
584
+ toast('Removed the saved files from this browser. Files open now stay open.');
585
+ }
586
+ app.forgetWorkspace = forgetSaved;
587
+
588
+ /** Reopen the saved workspace (on start, when nothing else was opened). */
589
+ async function restoreSaved() {
590
+ const ws = await loadWorkspace();
591
+ if (!ws) return;
592
+ savedInfo = { count: ws.items.length, bytes: ws.items.reduce((a, it) => a + (it.file ? it.file.size : 0), 0), savedAt: ws.savedAt };
593
+ if (app.items.length || !ws.items.length) { library.refresh(); return; }
594
+ if (ws.shared && !/[#&]s=/.test(location.hash)) store.setTarget('shared', ws.shared); // a share link's settings win
595
+ const t = ws.items.length > 2 ? toast(`Opening ${ws.items.length} saved files`, { progress: true }) : null;
596
+ const added = [];
597
+ for (let i = 0; i < ws.items.length; i++) {
598
+ const s = ws.items[i];
599
+ try {
600
+ const file = s.file instanceof File ? s.file : new File([s.file], s.name, { type: s.file.type });
601
+ let item;
602
+ if (s.type === 'video') item = await addVideo(s.name, file);
603
+ else {
604
+ let drawable;
605
+ try { drawable = await createImageBitmap(file); } catch { drawable = new Image(); drawable.src = URL.createObjectURL(file); await drawable.decode(); }
606
+ item = await addImage(s.name, drawable, file);
607
+ }
608
+ item.own = s.own || null;
609
+ added.push(item);
610
+ } catch (e) { console.warn('[workspace] skipped', s.name, e); }
611
+ t?.progress((i + 1) / ws.items.length);
612
+ }
613
+ t?.close();
614
+ const cur = added[Math.min(ws.current || 0, added.length - 1)];
615
+ if (cur) select(cur);
616
+ library.refresh();
617
+ if (added.length) toast(`Reopened your saved files (${added.length})`, { timeout: 2200 });
618
+ }
619
+
444
620
  // ================================================================ video
445
621
  function updateTransport() {
446
622
  const it = app.current;
@@ -454,6 +630,31 @@ export async function mountEditor(root, options = {}) {
454
630
  scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
455
631
  scrub.style.setProperty('--p', `${scrub.value / 10}%`);
456
632
  fpsLabel.textContent = `${it.fps} fps`;
633
+ soundBox.hidden = it.hasAudio === false;
634
+ applySound(v);
635
+ }
636
+ /** Mute/volume on the current video and in the controls. */
637
+ function applySound(v) {
638
+ if (v && !app.exporting && !app.current?.fpsReady) {
639
+ if (v.muted !== sound.muted) v.muted = sound.muted;
640
+ if (v.volume !== sound.volume) v.volume = sound.volume;
641
+ }
642
+ const level = sound.muted ? 0 : sound.volume;
643
+ const pct = Math.round(level * 100);
644
+ muteBtn.innerHTML = icon(level ? 'volume' : 'volumeOff');
645
+ const label = level ? `Volume ${pct}% (M mutes)` : 'Muted (M unmutes)';
646
+ muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
647
+ volSlider.setAttribute('aria-valuenow', String(pct));
648
+ volSlider.setAttribute('aria-valuetext', level ? `${pct}%` : 'Muted');
649
+ volSlider.style.setProperty('--v', `${pct}%`);
650
+ volValue.textContent = level ? String(pct) : 'Off';
651
+ }
652
+ function setSound(patch) {
653
+ Object.assign(sound, patch);
654
+ // unmuting at zero volume brings the volume back up
655
+ if (patch.muted === false && !sound.volume) sound.volume = 1;
656
+ persist.save('muted', sound.muted); persist.save('volume', sound.volume);
657
+ applySound(app.current && app.current.type === 'video' ? app.current.source : null);
457
658
  }
458
659
  function pumpVideo(item) {
459
660
  const v = item.source;
@@ -464,10 +665,15 @@ export async function mountEditor(root, options = {}) {
464
665
  };
465
666
  tick();
466
667
  }
467
- function togglePlay() {
668
+ async function togglePlay() {
468
669
  const it = app.current;
469
670
  if (!it || it.type !== 'video') return;
470
- if (it.source.paused) it.source.play().catch(e => toast(e.message, { kind: 'error' })); else it.source.pause();
671
+ if (!it.source.paused) { it.source.pause(); return; }
672
+ if (it.fpsReady) await it.fpsReady; // still measuring the frame rate (plays muted for a moment)
673
+ if (app.current !== it) return;
674
+ applySound(it.source);
675
+ if (it.source.ended) it.source.currentTime = 0;
676
+ it.source.play().catch(e => toast(e.message, { kind: 'error' }));
471
677
  }
472
678
  function stepFrame(dir) {
473
679
  const it = app.current;
@@ -688,7 +894,7 @@ export async function mountEditor(root, options = {}) {
688
894
  const keys = [
689
895
  ['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
690
896
  ['Compare', 'C'], ['Pixel grid', 'G'], ['Fit', 'F'], ['Zoom in / out', '+ -'], ['Pixel width ±1', '[ ]'], ['Pixel width ±8', 'Shift [ ]'],
691
- ['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
897
+ ['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Mute / unmute video', 'M'], ['Rename file', 'F2'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
692
898
  ['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
693
899
  ];
694
900
  dialog('Keyboard shortcuts', [h('div.itp-keys', {}, ...keys.map(([a, k]) => h('div', {}, h('span', { text: a }), h('span', {}, ...k.split(' ').filter(Boolean).map(x => h('kbd', { text: x }))))))]);
@@ -729,6 +935,8 @@ export async function mountEditor(root, options = {}) {
729
935
  break;
730
936
  }
731
937
  case ' ': if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
938
+ case 'F2': if (app.current) { e.preventDefault(); startRename(app.current); } break;
939
+ case 'm': case 'M': if (app.current?.type === 'video' && !soundBox.hidden) { e.preventDefault(); setSound({ muted: !sound.muted }); } break;
732
940
  case ',': stepFrame(-1); break;
733
941
  case '.': stepFrame(1); break;
734
942
  case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
@@ -771,6 +979,7 @@ export async function mountEditor(root, options = {}) {
771
979
  library.refresh();
772
980
  updateStatus();
773
981
  undoBtn.disabled = redoBtn.disabled = true;
982
+ if (usePersist && options.restore !== false) restoreSaved().catch(e => console.warn('[workspace]', e));
774
983
 
775
984
  const api = {
776
985
  app, store,
@@ -148,11 +148,17 @@ export function createInspector(app) {
148
148
  const anFps = numberInput({ label: 'Frames per second', min: 1, max: 60, onChange: () => { } });
149
149
  const anStart = numberInput({ label: 'Start (s)', min: 0, max: 36000, step: 0.1, onChange: () => { } });
150
150
  const anEnd = numberInput({ label: 'End (s)', min: 0, max: 36000, step: 0.1, onChange: () => { } });
151
+ const anSize = select({ label: 'Video size', options: [
152
+ { value: '1080', label: 'Full HD (sharp in any player)' }, { value: '720', label: '720p' }, { value: '2160', label: '4K' }, { value: 'scale', label: 'Same scale as PNG' },
153
+ ], onChange: () => { } });
154
+ anSize.set('1080');
155
+ const anAudio = toggle({ label: 'Keep audio', help: "The video's soundtrack, cut to the same start and end. MP4 and WebM only.", onChange: () => syncAnim() });
156
+ const anAudioInput = anAudio.el.querySelector('input');
151
157
  const anNote = h('div.help', {});
152
158
  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 });
159
+ const secAnim = sec('animation', 'Animation', false, null, anSource.el, anFormat.el, anFps.el, h('div.itp-grid2', {}, anStart.el, anEnd.el), anSize.el, anAudio.el, anNote, btnAnim);
160
+ anFps.set(12); anStart.set(0); anEnd.set(0); anAudio.set(true);
161
+ 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, audio: anAudioInput.checked, videoSize: anSize.select.value });
156
162
 
157
163
  // sprite sheet
158
164
  const shCols = numberInput({ label: 'Columns', min: 0, max: 256, help: '0 = as square as possible.', onChange: () => { } });
@@ -210,9 +216,18 @@ export function createInspector(app) {
210
216
  if (hasVideo && !Number(anEnd.input.value)) anEnd.set(Math.round((cur.duration || 0) * 10) / 10);
211
217
  if (hasVideo && cur.fps && !anFps.input.dataset.touched) anFps.set(Math.min(30, Math.round(cur.fps)));
212
218
  const f = anFormat.select.value;
219
+ const isVideo = f === 'mp4' || f === 'webm';
220
+ anAudio.el.hidden = !(src === 'video' && isVideo);
221
+ anSize.el.hidden = !isVideo;
222
+ const vs = app.videoSupport;
223
+ const noAudio = vs && 'opus' in vs && anAudioInput.checked && !anAudio.el.hidden && !(f === 'mp4' ? vs.aac || vs.opus : vs.opus);
213
224
  anNote.textContent = !ok
214
225
  ? (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.` : '';
226
+ : isVideo && !vs?.[f] ? `${f.toUpperCase()} encoding isn't available in this browser. GIF and PNG sequence always work.`
227
+ : noAudio ? "This browser can't encode audio, so the video will be silent."
228
+ : f === 'mp4' && vs && 'aac' in vs && !vs.aac && vs.opus && anAudioInput.checked && !anAudio.el.hidden
229
+ ? "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."
230
+ : '';
216
231
  }
217
232
  anFps.input.addEventListener('change', () => { anFps.input.dataset.touched = '1'; });
218
233
 
@@ -9,7 +9,7 @@ import { mergeSettings, defaultSettings } from '../core/settings.js';
9
9
 
10
10
  export function createLibrary(app) {
11
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) });
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
13
  const tFiles = tabBtn('files', 'Files', 'files');
14
14
  const tPresets = tabBtn('presets', 'Presets', 'presets');
15
15
  const tPalettes = tabBtn('palettes', 'Palettes', 'palette');
@@ -45,6 +45,7 @@ export function createLibrary(app) {
45
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
46
  { label: 'Apply these settings to all files', icon: 'layers', disabled: !many, onClick: () => app.applyToAll(it) },
47
47
  'sep',
48
+ { label: 'Rename', sub: 'Exports are named after it', icon: 'rename', kbd: 'F2', onClick: () => app.startRename(it) },
48
49
  { label: 'Duplicate', sub: 'A copy with its own settings, to compare looks', icon: 'copy', onClick: () => app.duplicate(it) },
49
50
  { label: 'Move up', icon: 'arrowUp', kbd: 'Alt Shift ↑', disabled: i <= 0, onClick: () => app.move(it, -1) },
50
51
  { label: 'Move down', icon: 'arrowDown', kbd: 'Alt Shift ↓', disabled: i >= app.items.length - 1, onClick: () => app.move(it, 1) },
@@ -67,6 +68,7 @@ export function createLibrary(app) {
67
68
  h('div.actions', {}, more,
68
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); } })));
69
70
  row.addEventListener('click', () => app.select(it));
71
+ row.addEventListener('dblclick', e => { if (e.target.closest('.title')) app.startRename(it); });
70
72
  row.addEventListener('contextmenu', e => { e.preventDefault(); app.select(it); fileMenu(it, more); });
71
73
  row.addEventListener('keydown', e => {
72
74
  if (e.target !== row) return;
@@ -89,7 +91,11 @@ export function createLibrary(app) {
89
91
  const add = h('div', { style: { padding: '4px 12px 12px', display: 'flex', gap: '6px' } },
90
92
  h('button.itp-btn.outline.small', { type: 'button', html: `${icon('plus')}<span>Add files</span>`, onclick: () => app.openFiles() }),
91
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() }));
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).' })));
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.' })));
93
99
  }
94
100
 
95
101
  // ---- presets (thumbnails rendered from the current image)
@@ -1,18 +1,18 @@
1
- // localStorage wrappers. Every access is guarded: storage may be disabled or full.
2
-
3
- const PREFIX = 'itp:';
4
-
5
- export function load(key, fallback) {
6
- try {
7
- const v = localStorage.getItem(PREFIX + key);
8
- return v == null ? fallback : JSON.parse(v);
9
- } catch { return fallback; }
10
- }
11
-
12
- export function save(key, value) {
13
- try { localStorage.setItem(PREFIX + key, JSON.stringify(value)); return true; } catch { return false; }
14
- }
15
-
16
- export function remove(key) {
17
- try { localStorage.removeItem(PREFIX + key); } catch { /* ignore */ }
18
- }
1
+ // localStorage wrappers. Every access is guarded: storage may be disabled or full.
2
+
3
+ const PREFIX = 'itp:';
4
+
5
+ export function load(key, fallback) {
6
+ try {
7
+ const v = localStorage.getItem(PREFIX + key);
8
+ return v == null ? fallback : JSON.parse(v);
9
+ } catch { return fallback; }
10
+ }
11
+
12
+ export function save(key, value) {
13
+ try { localStorage.setItem(PREFIX + key, JSON.stringify(value)); return true; } catch { return false; }
14
+ }
15
+
16
+ export function remove(key) {
17
+ try { localStorage.removeItem(PREFIX + key); } catch { /* ignore */ }
18
+ }