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.
- package/README.md +72 -69
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +54 -54
- package/dist/editor.esm.js.map +4 -4
- package/dist/image-to-pixel.bundle.esm.js +16 -16
- package/dist/image-to-pixel.bundle.esm.js.map +3 -3
- package/dist/image-to-pixel.cjs +13 -9
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +13 -9
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +13 -9
- package/dist/image-to-pixel.js.map +2 -2
- package/dist/image-to-pixel.min.js +11 -11
- package/dist/image-to-pixel.min.js.map +3 -3
- package/dist/video.esm.js +2 -2
- package/dist/video.esm.js.map +4 -4
- package/lib/core/video/audio.js +159 -0
- package/lib/core/video/frames.js +52 -7
- package/lib/core/video/mux-mp4.js +320 -131
- package/lib/core/video/mux-webm.js +168 -127
- package/lib/editor/exporter.js +103 -21
- package/lib/editor/icons.js +65 -61
- package/lib/editor/index.js +288 -30
- package/lib/editor/inspector.js +66 -15
- package/lib/editor/library.js +188 -182
- package/lib/editor/storyboard.js +566 -0
- package/lib/editor/styles/editor.css +71 -1
- package/lib/editor/timeline-preview.js +142 -0
- package/lib/editor/workspace.js +62 -0
- package/lib/index.js +31 -31
- package/lib/video.js +122 -92
- package/package.json +92 -92
- package/types/index.d.ts +2 -0
- package/types/video.d.ts +99 -43
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';
|
|
@@ -17,9 +17,12 @@ import { createStage } from './stage.js';
|
|
|
17
17
|
import { createInspector } from './inspector.js';
|
|
18
18
|
import { createLibrary, fmtTime } from './library.js';
|
|
19
19
|
import { createExporter } from './exporter.js';
|
|
20
|
+
import { createVideoStoryboard } from './storyboard.js';
|
|
21
|
+
import { createTimelinePreview } from './timeline-preview.js';
|
|
20
22
|
import { toast, menu, closeMenu, dialog } from './ui.js';
|
|
21
23
|
import { SAMPLES, loadSample, sampleURL } from './samples.js';
|
|
22
24
|
import * as persist from './persist.js';
|
|
25
|
+
import { saveWorkspace, loadWorkspace, forgetWorkspace, isQuotaError, fmtBytes } from './workspace.js';
|
|
23
26
|
|
|
24
27
|
const IMAGE_TYPES = /^image\//;
|
|
25
28
|
const VIDEO_TYPES = /^video\//;
|
|
@@ -67,6 +70,8 @@ export async function mountEditor(root, options = {}) {
|
|
|
67
70
|
const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
|
|
68
71
|
|
|
69
72
|
const fileName = h('span.name', { text: 'No image' });
|
|
73
|
+
// click the name to rename (F2); the extension stays as it was
|
|
74
|
+
fileName.addEventListener('click', () => { if (app.current) startRename(app.current); });
|
|
70
75
|
const fileMeta = h('span.meta.num', {});
|
|
71
76
|
const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
|
|
72
77
|
const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
|
|
@@ -78,6 +83,8 @@ export async function mountEditor(root, options = {}) {
|
|
|
78
83
|
const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
|
|
79
84
|
const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
|
|
80
85
|
const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
|
|
86
|
+
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() });
|
|
87
|
+
saveBtn.hidden = !usePersist;
|
|
81
88
|
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
89
|
if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
|
|
83
90
|
|
|
@@ -88,7 +95,7 @@ export async function mountEditor(root, options = {}) {
|
|
|
88
95
|
h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
|
|
89
96
|
compareBtn, gridBtn, zoomBtn,
|
|
90
97
|
h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
|
|
91
|
-
h('span', { style: { width: '6px' } }), exportBtn);
|
|
98
|
+
h('span', { style: { width: '6px' } }), saveBtn, exportBtn);
|
|
92
99
|
|
|
93
100
|
// stage extras
|
|
94
101
|
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,10 +120,85 @@ export async function mountEditor(root, options = {}) {
|
|
|
113
120
|
const timeLabel = h('span.itp-time', {});
|
|
114
121
|
const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
|
|
115
122
|
const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
|
|
123
|
+
// sound: muted by default; the choice (and volume) is remembered and applies to every video
|
|
124
|
+
const sound = { muted: persist.load('muted', true) !== false, volume: Number(persist.load('volume', 1)) };
|
|
125
|
+
if (!(sound.volume >= 0 && sound.volume <= 1)) sound.volume = 1;
|
|
126
|
+
// YouTube-style: the speaker button opens a vertical volume slider; all the way down is mute.
|
|
127
|
+
const muteBtn = btn('volumeOff', 'Volume (M mutes)', () => toggleVolPop(), { 'aria-haspopup': 'true', 'aria-expanded': 'false' });
|
|
128
|
+
const volFill = h('div.fill');
|
|
129
|
+
const volThumb = h('div.thumb');
|
|
130
|
+
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));
|
|
131
|
+
const volValue = h('span.num', {});
|
|
132
|
+
const volPop = h('div.itp-volpop', { hidden: true }, volValue, volSlider);
|
|
133
|
+
const soundBox = h('div.itp-sound', { hidden: true }, volPop, muteBtn);
|
|
134
|
+
const setLevel = v => setSound(v <= 0.005 ? { muted: true } : { muted: false, volume: Math.min(1, v) });
|
|
135
|
+
const levelAt = e => { const r = volSlider.querySelector('.track').getBoundingClientRect(); return Math.max(0, Math.min(1, (r.bottom - e.clientY) / r.height)); };
|
|
136
|
+
volSlider.addEventListener('pointerdown', e => {
|
|
137
|
+
e.preventDefault(); volSlider.focus(); volSlider.setPointerCapture(e.pointerId);
|
|
138
|
+
const before = sound.muted ? 0 : sound.volume;
|
|
139
|
+
setLevel(levelAt(e));
|
|
140
|
+
const move = ev => setLevel(levelAt(ev));
|
|
141
|
+
const up = () => {
|
|
142
|
+
volSlider.removeEventListener('pointermove', move); volSlider.removeEventListener('pointerup', up); volSlider.removeEventListener('pointercancel', up);
|
|
143
|
+
// dragged down to mute: M brings back the volume it had before the drag
|
|
144
|
+
if (sound.muted && before > 0) setSound({ muted: true, volume: before });
|
|
145
|
+
};
|
|
146
|
+
volSlider.addEventListener('pointermove', move); volSlider.addEventListener('pointerup', up); volSlider.addEventListener('pointercancel', up);
|
|
147
|
+
});
|
|
148
|
+
volSlider.addEventListener('keydown', e => {
|
|
149
|
+
const cur = sound.muted ? 0 : sound.volume;
|
|
150
|
+
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];
|
|
151
|
+
if (next === undefined) { if (e.key === 'Escape') { e.preventDefault(); toggleVolPop(false); muteBtn.focus(); } return; }
|
|
152
|
+
e.preventDefault(); e.stopPropagation(); setLevel(next);
|
|
153
|
+
});
|
|
154
|
+
volSlider.addEventListener('wheel', e => { e.preventDefault(); setLevel((sound.muted ? 0 : sound.volume) - Math.sign(e.deltaY) * 0.05); }, { passive: false });
|
|
155
|
+
function toggleVolPop(open = volPop.hidden) {
|
|
156
|
+
volPop.hidden = !open;
|
|
157
|
+
muteBtn.setAttribute('aria-expanded', String(open));
|
|
158
|
+
if (open) {
|
|
159
|
+
const close = e => { if (!soundBox.contains(e.target)) { toggleVolPop(false); document.removeEventListener('pointerdown', close, true); } };
|
|
160
|
+
document.addEventListener('pointerdown', close, true);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
116
163
|
const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
|
|
117
164
|
btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
|
|
118
|
-
timeLabel, scrub, fpsLabel);
|
|
119
|
-
|
|
165
|
+
timeLabel, scrub, soundBox, fpsLabel);
|
|
166
|
+
// The frame-selection canvas replaces the preview while it is on, so its tiles feed the stats.
|
|
167
|
+
let statsResult = null;
|
|
168
|
+
function updateStats(res) {
|
|
169
|
+
if (res === statsResult) return false;
|
|
170
|
+
statsResult = res;
|
|
171
|
+
inspector.setStats(res);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const timelinePreview = createTimelinePreview(app);
|
|
175
|
+
const storyboard = createVideoStoryboard(app, {
|
|
176
|
+
onModeChange: active => {
|
|
177
|
+
timelinePreview.hide();
|
|
178
|
+
stage.el.hidden = active;
|
|
179
|
+
transport.hidden = active;
|
|
180
|
+
compareBtn.disabled = gridBtn.disabled = active;
|
|
181
|
+
if (!active) { stage.resize(); schedule(); }
|
|
182
|
+
updateZoomLabel();
|
|
183
|
+
},
|
|
184
|
+
onZoomChange: updateZoomLabel,
|
|
185
|
+
onRangeChange: range => inspector.setAnimationRange(range),
|
|
186
|
+
onSelectionChange: state => {
|
|
187
|
+
inspector.setFrameSelectionState(state);
|
|
188
|
+
const res = state.active ? state.result : app.result;
|
|
189
|
+
if (updateStats(res) && res && state.active) inspector.sync(store.settings, res);
|
|
190
|
+
},
|
|
191
|
+
animationOptions: () => inspector.animationOptions(),
|
|
192
|
+
});
|
|
193
|
+
app.setFrameSelection = enabled => storyboard.setMode(enabled);
|
|
194
|
+
app.setFrameRange = (start, end) => storyboard.setRange(start, end);
|
|
195
|
+
app.setAnimationFps = fps => storyboard.setPlaybackFps(fps);
|
|
196
|
+
app.frameSelection = () => storyboard.exportSelection();
|
|
197
|
+
/** The result the stats and size readouts describe: the main preview, or the selection's frame. */
|
|
198
|
+
const previewResult = () => (storyboard.active ? storyboard.currentResult() : app.result);
|
|
199
|
+
const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, storyboard.el, transport, storyboard.transport, timelinePreview.el);
|
|
200
|
+
timelinePreview.bind(scrub);
|
|
201
|
+
timelinePreview.bind(storyboard.range, { getFrame: storyboard.frameCanvas });
|
|
120
202
|
|
|
121
203
|
// on narrow screens the library is a drawer: a tap on the canvas closes it
|
|
122
204
|
stageWrap.addEventListener('pointerdown', () => { if (narrow() && shell.dataset.lib === 'open') togglePanel('lib'); }, true);
|
|
@@ -148,7 +230,7 @@ export async function mountEditor(root, options = {}) {
|
|
|
148
230
|
|
|
149
231
|
async function run() {
|
|
150
232
|
if (running || !dirty) return;
|
|
151
|
-
if (app.exporting) return;
|
|
233
|
+
if (app.exporting || storyboard.active) return;
|
|
152
234
|
dirty = false;
|
|
153
235
|
const item = app.current;
|
|
154
236
|
if (!item) { app.result = null; stage.clear(); updateStatus(); return; }
|
|
@@ -157,11 +239,11 @@ export async function mountEditor(root, options = {}) {
|
|
|
157
239
|
try {
|
|
158
240
|
if (item.type === 'video') app.engine.invalidate();
|
|
159
241
|
const res = await app.engine.process(item.source, store.settings);
|
|
160
|
-
if (item !== app.current) return;
|
|
242
|
+
if (item !== app.current || storyboard.active) return;
|
|
161
243
|
app.result = res;
|
|
162
244
|
res.toCanvas(1, { canvas: outCanvas });
|
|
163
245
|
stage.show(outCanvas, item.source, res.crop);
|
|
164
|
-
|
|
246
|
+
updateStats(res);
|
|
165
247
|
inspector.sync(store.settings, res);
|
|
166
248
|
if (lastSource !== item) { lastSource = item; }
|
|
167
249
|
updateStatus();
|
|
@@ -187,7 +269,8 @@ export async function mountEditor(root, options = {}) {
|
|
|
187
269
|
undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
|
|
188
270
|
}
|
|
189
271
|
function updateZoomLabel() {
|
|
190
|
-
|
|
272
|
+
const view = storyboard.active ? storyboard : stage;
|
|
273
|
+
zoomBtn.querySelector('span').textContent = !app.current || view.fitted ? 'Fit' : view.zoom >= 1 ? `${Math.round(view.zoom * 10) / 10}×` : `${Math.round(view.zoom * 100)}%`;
|
|
191
274
|
if (app.result) updateStatus();
|
|
192
275
|
}
|
|
193
276
|
|
|
@@ -196,11 +279,13 @@ export async function mountEditor(root, options = {}) {
|
|
|
196
279
|
const cur = app.current;
|
|
197
280
|
if (cur && cur.own && store.target === cur.id) cur.own = s;
|
|
198
281
|
else if (store.target === 'shared') app.shared = s;
|
|
199
|
-
inspector.sync(s,
|
|
282
|
+
inspector.sync(s, previewResult());
|
|
200
283
|
undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
|
|
201
284
|
if (why !== 'preset') app.activePreset = null;
|
|
202
285
|
if (usePersist) saveSoon();
|
|
203
286
|
if (library.tab === 'palettes') library.refresh();
|
|
287
|
+
timelinePreview.reset();
|
|
288
|
+
storyboard.refresh();
|
|
204
289
|
schedule();
|
|
205
290
|
});
|
|
206
291
|
let saveT = null;
|
|
@@ -224,7 +309,9 @@ export async function mountEditor(root, options = {}) {
|
|
|
224
309
|
}
|
|
225
310
|
|
|
226
311
|
async function addVideo(name, file) {
|
|
227
|
-
|
|
312
|
+
// No `loop` attribute: looping by hand on 'ended' is more reliable (some MOV/MP4 files stalled at the
|
|
313
|
+
// end and lost their sound after Chrome's built-in loop).
|
|
314
|
+
const video = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
|
|
228
315
|
video.muted = true;
|
|
229
316
|
video.src = URL.createObjectURL(file);
|
|
230
317
|
try {
|
|
@@ -246,11 +333,19 @@ export async function mountEditor(root, options = {}) {
|
|
|
246
333
|
await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
|
|
247
334
|
item.thumb = await makeThumb(video, item.width, item.height);
|
|
248
335
|
app.items.push(item);
|
|
249
|
-
|
|
336
|
+
// measuring plays the video muted for a moment; Play waits for it so the two don't fight
|
|
337
|
+
item.fpsReady = estimateFps(video, 30).catch(() => 30).then(f => { item.fps = f; item.fpsReady = null; if (app.current === item) updateTransport(); inspector.syncAnim(); });
|
|
338
|
+
// null = can't tell from the container: the sound controls stay, just in case
|
|
339
|
+
hasAudioTrack(file).then(a => { item.hasAudio = a; if (app.current === item) updateTransport(); });
|
|
250
340
|
video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
|
|
251
341
|
video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
|
|
252
342
|
video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
|
|
253
343
|
video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
|
|
344
|
+
video.addEventListener('ended', () => {
|
|
345
|
+
if (app.current !== item || app.exporting || item.fpsReady || storyboard.active) return;
|
|
346
|
+
video.currentTime = 0;
|
|
347
|
+
video.play().catch(() => { });
|
|
348
|
+
});
|
|
254
349
|
return item;
|
|
255
350
|
}
|
|
256
351
|
|
|
@@ -303,7 +398,7 @@ export async function mountEditor(root, options = {}) {
|
|
|
303
398
|
|
|
304
399
|
async function openSample(s) {
|
|
305
400
|
const img = await loadSample(s);
|
|
306
|
-
const item = await addImage(`${s.name}.svg`, img);
|
|
401
|
+
const item = await addImage(`${s.name}.svg`, img, new File([s.svg], `${s.name}.svg`, { type: 'image/svg+xml' }));
|
|
307
402
|
select(item);
|
|
308
403
|
library.refresh();
|
|
309
404
|
}
|
|
@@ -326,6 +421,8 @@ export async function mountEditor(root, options = {}) {
|
|
|
326
421
|
function select(item) {
|
|
327
422
|
if (app.current && app.current.type === 'video' && app.current !== item) app.current.source.pause();
|
|
328
423
|
app.current = item;
|
|
424
|
+
timelinePreview.reset();
|
|
425
|
+
storyboard.setItem(item);
|
|
329
426
|
editTarget(item);
|
|
330
427
|
stageWrap.dataset.video = String(item && item.type === 'video');
|
|
331
428
|
if (item && item.type === 'video') updateTransport();
|
|
@@ -348,7 +445,7 @@ export async function mountEditor(root, options = {}) {
|
|
|
348
445
|
if (item.source.close && !app.items.some(i => i.source === item.source)) item.source.close(); // duplicates share a bitmap
|
|
349
446
|
if (app.current === item) select(app.items[Math.min(i, app.items.length - 1)] || null);
|
|
350
447
|
library.refresh();
|
|
351
|
-
inspector.sync(store.settings,
|
|
448
|
+
inspector.sync(store.settings, previewResult());
|
|
352
449
|
}
|
|
353
450
|
app.removeItem = removeItem;
|
|
354
451
|
app.clearItems = () => { for (const it of app.items.slice()) removeItem(it); };
|
|
@@ -384,8 +481,9 @@ export async function mountEditor(root, options = {}) {
|
|
|
384
481
|
}
|
|
385
482
|
function afterScopeChange() {
|
|
386
483
|
library.refresh();
|
|
387
|
-
|
|
388
|
-
|
|
484
|
+
const res = previewResult();
|
|
485
|
+
inspector.sync(store.settings, res);
|
|
486
|
+
if (res) updateStats(res);
|
|
389
487
|
schedule();
|
|
390
488
|
}
|
|
391
489
|
app.setOwn = setOwn;
|
|
@@ -441,6 +539,127 @@ export async function mountEditor(root, options = {}) {
|
|
|
441
539
|
if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
|
|
442
540
|
};
|
|
443
541
|
|
|
542
|
+
// ================================================================ rename
|
|
543
|
+
const splitExt = name => { const m = /^(.*?)(\.[^.\s]{1,5})?$/.exec(name); return [m[1], m[2] || '']; };
|
|
544
|
+
/** Rename a file. The extension is kept: exports are named after it ("<name>@4x.png"). */
|
|
545
|
+
app.rename = (item, next) => {
|
|
546
|
+
const [, ext] = splitExt(item.name);
|
|
547
|
+
let base = String(next || '').trim().replace(/[\\/:*?"<>|]+/g, '-');
|
|
548
|
+
if (ext && base.toLowerCase().endsWith(ext.toLowerCase())) base = base.slice(0, -ext.length);
|
|
549
|
+
if (!base || base + ext === item.name) return;
|
|
550
|
+
item.name = base + ext;
|
|
551
|
+
library.refresh();
|
|
552
|
+
updateStatus();
|
|
553
|
+
};
|
|
554
|
+
/** Edit the name in place in the header, with the name (not the extension) selected. */
|
|
555
|
+
function startRename(item) {
|
|
556
|
+
if (!item || fileName.hidden) return;
|
|
557
|
+
if (app.current !== item) select(item);
|
|
558
|
+
const [base] = splitExt(item.name);
|
|
559
|
+
const input = h('input.itp-name-input', { type: 'text', value: item.name, 'aria-label': 'File name', spellcheck: false });
|
|
560
|
+
input.style.width = `${Math.min(40, Math.max(12, item.name.length + 2))}ch`;
|
|
561
|
+
fileName.hidden = true;
|
|
562
|
+
fileName.after(input);
|
|
563
|
+
input.focus();
|
|
564
|
+
input.setSelectionRange(0, base.length);
|
|
565
|
+
let done = false;
|
|
566
|
+
const finish = commit => {
|
|
567
|
+
if (done) return;
|
|
568
|
+
done = true;
|
|
569
|
+
if (commit) app.rename(item, input.value);
|
|
570
|
+
input.remove();
|
|
571
|
+
fileName.hidden = false;
|
|
572
|
+
};
|
|
573
|
+
input.addEventListener('keydown', e => {
|
|
574
|
+
e.stopPropagation();
|
|
575
|
+
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
|
576
|
+
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
|
577
|
+
});
|
|
578
|
+
input.addEventListener('blur', () => finish(true));
|
|
579
|
+
}
|
|
580
|
+
app.startRename = startRename;
|
|
581
|
+
|
|
582
|
+
// ================================================================ saved workspace
|
|
583
|
+
// Files (their bytes), names, per-file settings and the shared settings, in this browser's IndexedDB.
|
|
584
|
+
let savedInfo = null; // { count, bytes, savedAt } of what is saved, or null
|
|
585
|
+
app.savedWorkspace = () => savedInfo;
|
|
586
|
+
|
|
587
|
+
/** A Blob for a file, for saving: the original bytes, or a PNG of what was added without a file. */
|
|
588
|
+
async function bytesOf(item) {
|
|
589
|
+
if (item.file) return item.file;
|
|
590
|
+
const c = document.createElement('canvas');
|
|
591
|
+
c.width = item.width; c.height = item.height;
|
|
592
|
+
c.getContext('2d').drawImage(item.source, 0, 0);
|
|
593
|
+
return new Promise(res => c.toBlob(b => res(b), 'image/png'));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function saveNow() {
|
|
597
|
+
if (!usePersist) return;
|
|
598
|
+
if (!app.items.length) {
|
|
599
|
+
if (savedInfo) { await forgetSaved(); return; }
|
|
600
|
+
toast('Add files first, then Save keeps them here for next time.');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
saveBtn.disabled = true;
|
|
604
|
+
try {
|
|
605
|
+
const items = [];
|
|
606
|
+
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 });
|
|
607
|
+
const bytes = await saveWorkspace({ shared: clone(app.shared), current: Math.max(0, app.items.indexOf(app.current)), items });
|
|
608
|
+
const first = !savedInfo;
|
|
609
|
+
savedInfo = { count: items.length, bytes, savedAt: Date.now() };
|
|
610
|
+
library.refresh();
|
|
611
|
+
toast(first
|
|
612
|
+
? `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.`
|
|
613
|
+
: `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)})`, { kind: 'ok', timeout: first ? 7000 : 2200 });
|
|
614
|
+
} catch (e) {
|
|
615
|
+
console.error(e);
|
|
616
|
+
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' });
|
|
617
|
+
} finally {
|
|
618
|
+
saveBtn.disabled = false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
app.saveWorkspace = saveNow;
|
|
622
|
+
|
|
623
|
+
async function forgetSaved() {
|
|
624
|
+
try { await forgetWorkspace(); } catch (e) { toast(`Couldn't remove the saved files: ${e.message}`, { kind: 'error' }); return; }
|
|
625
|
+
savedInfo = null;
|
|
626
|
+
library.refresh();
|
|
627
|
+
toast('Removed the saved files from this browser. Files open now stay open.');
|
|
628
|
+
}
|
|
629
|
+
app.forgetWorkspace = forgetSaved;
|
|
630
|
+
|
|
631
|
+
/** Reopen the saved workspace (on start, when nothing else was opened). */
|
|
632
|
+
async function restoreSaved() {
|
|
633
|
+
const ws = await loadWorkspace();
|
|
634
|
+
if (!ws) return;
|
|
635
|
+
savedInfo = { count: ws.items.length, bytes: ws.items.reduce((a, it) => a + (it.file ? it.file.size : 0), 0), savedAt: ws.savedAt };
|
|
636
|
+
if (app.items.length || !ws.items.length) { library.refresh(); return; }
|
|
637
|
+
if (ws.shared && !/[#&]s=/.test(location.hash)) store.setTarget('shared', ws.shared); // a share link's settings win
|
|
638
|
+
const t = ws.items.length > 2 ? toast(`Opening ${ws.items.length} saved files`, { progress: true }) : null;
|
|
639
|
+
const added = [];
|
|
640
|
+
for (let i = 0; i < ws.items.length; i++) {
|
|
641
|
+
const s = ws.items[i];
|
|
642
|
+
try {
|
|
643
|
+
const file = s.file instanceof File ? s.file : new File([s.file], s.name, { type: s.file.type });
|
|
644
|
+
let item;
|
|
645
|
+
if (s.type === 'video') item = await addVideo(s.name, file);
|
|
646
|
+
else {
|
|
647
|
+
let drawable;
|
|
648
|
+
try { drawable = await createImageBitmap(file); } catch { drawable = new Image(); drawable.src = URL.createObjectURL(file); await drawable.decode(); }
|
|
649
|
+
item = await addImage(s.name, drawable, file);
|
|
650
|
+
}
|
|
651
|
+
item.own = s.own || null;
|
|
652
|
+
added.push(item);
|
|
653
|
+
} catch (e) { console.warn('[workspace] skipped', s.name, e); }
|
|
654
|
+
t?.progress((i + 1) / ws.items.length);
|
|
655
|
+
}
|
|
656
|
+
t?.close();
|
|
657
|
+
const cur = added[Math.min(ws.current || 0, added.length - 1)];
|
|
658
|
+
if (cur) select(cur);
|
|
659
|
+
library.refresh();
|
|
660
|
+
if (added.length) toast(`Reopened your saved files (${added.length})`, { timeout: 2200 });
|
|
661
|
+
}
|
|
662
|
+
|
|
444
663
|
// ================================================================ video
|
|
445
664
|
function updateTransport() {
|
|
446
665
|
const it = app.current;
|
|
@@ -454,6 +673,31 @@ export async function mountEditor(root, options = {}) {
|
|
|
454
673
|
scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
|
|
455
674
|
scrub.style.setProperty('--p', `${scrub.value / 10}%`);
|
|
456
675
|
fpsLabel.textContent = `${it.fps} fps`;
|
|
676
|
+
soundBox.hidden = it.hasAudio === false;
|
|
677
|
+
applySound(v);
|
|
678
|
+
}
|
|
679
|
+
/** Mute/volume on the current video and in the controls. */
|
|
680
|
+
function applySound(v) {
|
|
681
|
+
if (v && !app.exporting && !app.current?.fpsReady) {
|
|
682
|
+
if (v.muted !== sound.muted) v.muted = sound.muted;
|
|
683
|
+
if (v.volume !== sound.volume) v.volume = sound.volume;
|
|
684
|
+
}
|
|
685
|
+
const level = sound.muted ? 0 : sound.volume;
|
|
686
|
+
const pct = Math.round(level * 100);
|
|
687
|
+
muteBtn.innerHTML = icon(level ? 'volume' : 'volumeOff');
|
|
688
|
+
const label = level ? `Volume ${pct}% (M mutes)` : 'Muted (M unmutes)';
|
|
689
|
+
muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
|
|
690
|
+
volSlider.setAttribute('aria-valuenow', String(pct));
|
|
691
|
+
volSlider.setAttribute('aria-valuetext', level ? `${pct}%` : 'Muted');
|
|
692
|
+
volSlider.style.setProperty('--v', `${pct}%`);
|
|
693
|
+
volValue.textContent = level ? String(pct) : 'Off';
|
|
694
|
+
}
|
|
695
|
+
function setSound(patch) {
|
|
696
|
+
Object.assign(sound, patch);
|
|
697
|
+
// unmuting at zero volume brings the volume back up
|
|
698
|
+
if (patch.muted === false && !sound.volume) sound.volume = 1;
|
|
699
|
+
persist.save('muted', sound.muted); persist.save('volume', sound.volume);
|
|
700
|
+
applySound(app.current && app.current.type === 'video' ? app.current.source : null);
|
|
457
701
|
}
|
|
458
702
|
function pumpVideo(item) {
|
|
459
703
|
const v = item.source;
|
|
@@ -464,14 +708,20 @@ export async function mountEditor(root, options = {}) {
|
|
|
464
708
|
};
|
|
465
709
|
tick();
|
|
466
710
|
}
|
|
467
|
-
function togglePlay() {
|
|
711
|
+
async function togglePlay() {
|
|
468
712
|
const it = app.current;
|
|
469
713
|
if (!it || it.type !== 'video') return;
|
|
470
|
-
if (
|
|
714
|
+
if (storyboard.active) { storyboard.toggleAnimation(); return; }
|
|
715
|
+
if (!it.source.paused) { it.source.pause(); return; }
|
|
716
|
+
if (it.fpsReady) await it.fpsReady; // still measuring the frame rate (plays muted for a moment)
|
|
717
|
+
if (app.current !== it || storyboard.active) return;
|
|
718
|
+
applySound(it.source);
|
|
719
|
+
if (it.source.ended) it.source.currentTime = 0;
|
|
720
|
+
it.source.play().catch(e => toast(e.message, { kind: 'error' }));
|
|
471
721
|
}
|
|
472
722
|
function stepFrame(dir) {
|
|
473
723
|
const it = app.current;
|
|
474
|
-
if (!it || it.type !== 'video') return;
|
|
724
|
+
if (!it || it.type !== 'video' || storyboard.active) return;
|
|
475
725
|
const v = it.source; v.pause();
|
|
476
726
|
const frame = Math.floor(v.currentTime * it.fps + 1e-6) + dir;
|
|
477
727
|
seekTo(v, (Math.max(0, frame) + 0.5) / it.fps).then(() => { updateTransport(); schedule(); });
|
|
@@ -483,22 +733,25 @@ export async function mountEditor(root, options = {}) {
|
|
|
483
733
|
|
|
484
734
|
// ================================================================ view
|
|
485
735
|
function toggleCompare(force) {
|
|
736
|
+
if (storyboard.active) return;
|
|
486
737
|
const on = force ?? !stage.compare;
|
|
487
738
|
stage.setCompare(on); compareBtn.setAttribute('aria-pressed', String(on));
|
|
488
739
|
}
|
|
489
740
|
function toggleGrid(force) {
|
|
741
|
+
if (storyboard.active) return;
|
|
490
742
|
const on = force ?? !stage.grid;
|
|
491
743
|
stage.setGrid(on); gridBtn.setAttribute('aria-pressed', String(on));
|
|
492
744
|
if (on && stage.zoom < 6 && app.result) toast('The grid shows from 6× zoom');
|
|
493
745
|
}
|
|
494
746
|
function zoomMenu(anchor) {
|
|
747
|
+
const view = storyboard.active ? storyboard : stage;
|
|
495
748
|
menu(anchor, [
|
|
496
|
-
{ label: 'Fit to view', kbd: 'F', icon: 'fit', onClick: () =>
|
|
749
|
+
{ label: storyboard.active ? 'Fit canvas to view' : 'Fit to view', kbd: 'F', icon: 'fit', onClick: () => view.fit() },
|
|
497
750
|
'sep',
|
|
498
|
-
...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 ? 'One screen pixel per pixel' : null, onClick: () =>
|
|
751
|
+
...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 && !storyboard.active ? 'One screen pixel per pixel' : null, onClick: () => view.zoomTo(z) })),
|
|
499
752
|
'sep',
|
|
500
|
-
{ label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () =>
|
|
501
|
-
{ label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () =>
|
|
753
|
+
{ label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () => view.zoomStep(1) },
|
|
754
|
+
{ label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => view.zoomStep(-1) },
|
|
502
755
|
]);
|
|
503
756
|
}
|
|
504
757
|
function togglePanel(which) {
|
|
@@ -663,16 +916,18 @@ export async function mountEditor(root, options = {}) {
|
|
|
663
916
|
const video = it && it.type === 'video';
|
|
664
917
|
const images = app.items.filter(i => i.type === 'image').length;
|
|
665
918
|
const s = store.settings;
|
|
919
|
+
const selecting = storyboard.active;
|
|
920
|
+
const offNote = selecting ? 'Turn off frame selection first' : null;
|
|
666
921
|
menu(anchor, [
|
|
667
922
|
{ group: 'Image' },
|
|
668
|
-
{ label: 'PNG', sub: app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first', icon: 'image', kbd: 'Ctrl S', disabled: !app.result, onClick: () => app.exportPNG() },
|
|
669
|
-
{ label: 'Copy to clipboard', icon: 'copy', disabled: !app.result, onClick: () => app.copyImage() },
|
|
923
|
+
{ label: 'PNG', sub: offNote || (app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first'), icon: 'image', kbd: 'Ctrl S', disabled: !app.result || selecting, onClick: () => app.exportPNG() },
|
|
924
|
+
{ label: 'Copy to clipboard', sub: offNote, icon: 'copy', disabled: !app.result || selecting, onClick: () => app.copyImage() },
|
|
670
925
|
{ label: app.items.length > 1 ? `All ${app.items.length} files as ZIP` : 'All files as ZIP', sub: app.items.length > 1 ? null : 'Add two or more files', icon: 'zip', disabled: app.items.length < 2, onClick: () => app.exportZip() },
|
|
671
926
|
{ group: 'Animation' },
|
|
672
927
|
{ label: video ? 'GIF from this video' : 'GIF from all images', icon: 'gif', disabled: !video && images < 2, onClick: () => app.exportAnimation({ ...animDefaults(), format: 'gif' }) },
|
|
673
928
|
{ label: 'More formats…', sub: 'WebM, MP4, PNG sequence, sprite sheet', icon: 'film', onClick: () => { inspector.setTab('export'); if (shell.dataset.insp !== 'open') togglePanel('insp'); } },
|
|
674
929
|
{ group: 'Other' },
|
|
675
|
-
{ label: 'Tileset', sub: '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
|
|
930
|
+
{ label: 'Tileset', sub: offNote || '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result || selecting, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
|
|
676
931
|
{ label: 'Copy settings link', icon: 'link', onClick: () => app.copyLink() },
|
|
677
932
|
{ label: 'Settings as JSON', icon: 'braces', onClick: () => app.saveSettingsFile() },
|
|
678
933
|
]);
|
|
@@ -688,7 +943,7 @@ export async function mountEditor(root, options = {}) {
|
|
|
688
943
|
const keys = [
|
|
689
944
|
['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
|
|
690
945
|
['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', '/'],
|
|
946
|
+
['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Mute / unmute video', 'M'], ['Rename file', 'F2'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
|
|
692
947
|
['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
|
|
693
948
|
];
|
|
694
949
|
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 }))))))]);
|
|
@@ -712,9 +967,9 @@ export async function mountEditor(root, options = {}) {
|
|
|
712
967
|
switch (k) {
|
|
713
968
|
case 'c': case 'C': toggleCompare(); break;
|
|
714
969
|
case 'g': case 'G': toggleGrid(); break;
|
|
715
|
-
case 'f': case 'F': stage.fit(); break;
|
|
716
|
-
case '+': case '=': stage.zoomIn(); break;
|
|
717
|
-
case '-': case '_': stage.zoomOut(); break;
|
|
970
|
+
case 'f': case 'F': if (storyboard.active) storyboard.fit(); else stage.fit(); break;
|
|
971
|
+
case '+': case '=': if (storyboard.active) storyboard.zoomStep(1); else stage.zoomIn(); break;
|
|
972
|
+
case '-': case '_': if (storyboard.active) storyboard.zoomStep(-1); else stage.zoomOut(); break;
|
|
718
973
|
case 't': case 'T': toggleTheme(); break;
|
|
719
974
|
case '\\': togglePanel('lib'); break;
|
|
720
975
|
case '/': togglePanel('insp'); break;
|
|
@@ -728,7 +983,9 @@ export async function mountEditor(root, options = {}) {
|
|
|
728
983
|
toast(next === 'none' ? 'No dither' : next.replace(/-/g, ' '), { timeout: 900 });
|
|
729
984
|
break;
|
|
730
985
|
}
|
|
731
|
-
case ' ': if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
|
|
986
|
+
case ' ': if (e.target.closest('button')) break; if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
|
|
987
|
+
case 'F2': if (app.current) { e.preventDefault(); startRename(app.current); } break;
|
|
988
|
+
case 'm': case 'M': if (app.current?.type === 'video' && !soundBox.hidden) { e.preventDefault(); setSound({ muted: !sound.muted }); } break;
|
|
732
989
|
case ',': stepFrame(-1); break;
|
|
733
990
|
case '.': stepFrame(1); break;
|
|
734
991
|
case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
|
|
@@ -771,6 +1028,7 @@ export async function mountEditor(root, options = {}) {
|
|
|
771
1028
|
library.refresh();
|
|
772
1029
|
updateStatus();
|
|
773
1030
|
undoBtn.disabled = redoBtn.disabled = true;
|
|
1031
|
+
if (usePersist && options.restore !== false) restoreSaved().catch(e => console.warn('[workspace]', e));
|
|
774
1032
|
|
|
775
1033
|
const api = {
|
|
776
1034
|
app, store,
|