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.
- package/LICENSE +21 -21
- package/README.md +2 -2
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +60 -60
- package/dist/editor.esm.js.map +4 -4
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- package/dist/image-to-pixel.bundle.esm.js +16 -16
- package/dist/image-to-pixel.bundle.esm.js.map +3 -3
- package/dist/image-to-pixel.cjs +2 -2
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +2 -2
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +2 -2
- 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 +4 -4
- package/lib/core/color.js +69 -69
- package/lib/core/cpu/pipeline.js +21 -21
- package/lib/core/cpu/stages.js +227 -227
- package/lib/core/dither/bluenoise-data.js +3 -3
- package/lib/core/dither/kernels.js +27 -27
- package/lib/core/dither/matrices.js +95 -95
- package/lib/core/engine.js +158 -158
- package/lib/core/export/canvas.js +22 -22
- package/lib/core/export/crc32.js +15 -15
- package/lib/core/export/gif.js +153 -153
- package/lib/core/export/png.js +94 -94
- package/lib/core/export/scale.js +103 -103
- package/lib/core/export/sheet.js +95 -95
- package/lib/core/export/zip.js +55 -55
- package/lib/core/gpu/backend.js +159 -159
- package/lib/core/gpu/shaders.js +258 -258
- package/lib/core/palette/builtin.js +36 -36
- package/lib/core/palette/extract.js +69 -69
- package/lib/core/palette/lospec.js +24 -24
- package/lib/core/palette/parse.js +112 -112
- package/lib/core/plan.js +58 -58
- package/lib/core/presets.js +42 -42
- package/lib/core/settings.js +274 -274
- package/lib/core/source.js +89 -89
- package/lib/core/video/audio.js +159 -0
- package/lib/core/video/frames.js +38 -0
- package/lib/core/video/mux-mp4.js +213 -24
- package/lib/core/video/mux-webm.js +63 -22
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +43 -3
- package/lib/editor/icons.js +4 -0
- package/lib/editor/index.js +218 -9
- package/lib/editor/inspector.js +19 -4
- package/lib/editor/library.js +8 -2
- package/lib/editor/persist.js +18 -18
- package/lib/editor/samples.js +90 -90
- package/lib/editor/stage.js +239 -239
- package/lib/editor/store.js +80 -80
- package/lib/editor/styles/editor.css +34 -1
- package/lib/editor/styles/fonts/README.md +6 -6
- package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
- package/lib/editor/styles/tokens.css +89 -89
- package/lib/editor/ui.js +97 -97
- package/lib/editor/workspace.js +62 -0
- package/lib/index.js +31 -29
- package/lib/video.js +37 -7
- package/package.json +1 -1
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +61 -5
package/lib/editor/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
package/lib/editor/inspector.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
|
package/lib/editor/library.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/lib/editor/persist.js
CHANGED
|
@@ -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
|
+
}
|