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.
@@ -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
- const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, transport);
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
- inspector.setStats(res);
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
- zoomBtn.querySelector('span').textContent = !app.current || stage.fitted ? 'Fit' : stage.zoom >= 1 ? `${Math.round(stage.zoom * 10) / 10}×` : `${Math.round(stage.zoom * 100)}%`;
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, app.result);
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
- const video = h('video', { muted: true, playsInline: true, loop: true, preload: 'auto', crossOrigin: 'anonymous' });
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
- estimateFps(video, 30).then(f => { item.fps = f; if (app.current === item) updateTransport(); inspector.syncAnim(); });
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, app.result);
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
- inspector.sync(store.settings, app.result);
388
- if (app.result) inspector.setStats(app.result);
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 (it.source.paused) it.source.play().catch(e => toast(e.message, { kind: 'error' })); else it.source.pause();
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: () => stage.fit() },
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: () => stage.zoomTo(z) })),
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: () => stage.zoomIn() },
501
- { label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => stage.zoomOut() },
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,