image-to-pixel 3.1.0 → 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 +4 -1
- 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 +13 -13
- 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 +3 -3
- package/lib/core/video/frames.js +189 -182
- package/lib/editor/exporter.js +267 -225
- package/lib/editor/index.js +1051 -1002
- package/lib/editor/inspector.js +379 -343
- package/lib/editor/storyboard.js +566 -0
- package/lib/editor/styles/editor.css +455 -418
- package/lib/editor/timeline-preview.js +142 -0
- package/package.json +1 -1
- package/types/index.d.ts +2 -0
package/lib/editor/exporter.js
CHANGED
|
@@ -1,225 +1,267 @@
|
|
|
1
|
-
// Every export the editor offers. Long ones show a progress toast that can be cancelled.
|
|
2
|
-
|
|
3
|
-
import { encodePNG } from '../core/export/png.js';
|
|
4
|
-
import { encodeGIF } from '../core/export/gif.js';
|
|
5
|
-
import { createZip } from '../core/export/zip.js';
|
|
6
|
-
import { packSpriteSheet, buildTileset } from '../core/export/sheet.js';
|
|
7
|
-
import { downloadBlob } from '../core/export/canvas.js';
|
|
8
|
-
import { forEachFrame } from '../core/video/frames.js';
|
|
9
|
-
import { mergeSettings, settingsToJSON } from '../core/settings.js';
|
|
10
|
-
import { toast } from './ui.js';
|
|
11
|
-
|
|
12
|
-
const base = name => (name || 'image').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'image';
|
|
13
|
-
const pngOpts = s => ({ scale: s.export.scale, grid: s.export.grid, gridColor: s.export.gridColor, indexed: s.export.indexed });
|
|
14
|
-
|
|
15
|
-
/** Pixel size of an exported image: whole-number scale, or the framed part of the source at full resolution. */
|
|
16
|
-
export function exportSize(s, r, item) {
|
|
17
|
-
if (s.export.size === 'source' && r && item && r.crop) return { width: Math.max(1, Math.round(r.crop.w * item.width)), height: Math.max(1, Math.round(r.crop.h * item.height)) };
|
|
18
|
-
return { width: r ? r.width * s.export.scale : 0, height: r ? r.height * s.export.scale : 0 };
|
|
19
|
-
}
|
|
20
|
-
/** PNG options for one exported image. */
|
|
21
|
-
const imageOpts = (s, r, item) => (s.export.size === 'source' && item ? { ...exportSize(s, r, item), indexed: s.export.indexed } : pngOpts(s));
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Whole-number scales to try for a video, best first. 'scale' = the PNG export scale; otherwise a target
|
|
25
|
-
* height ('1080', '720', '2160', measured on the short side), falling back to smaller sizes if the
|
|
26
|
-
* browser's encoder refuses the big one. Never below 240 px tall.
|
|
27
|
-
*/
|
|
28
|
-
export function videoScales(size, r, exportScale = 4) {
|
|
29
|
-
const min = Math.max(1, Math.ceil(240 / Math.min(r.width, r.height)));
|
|
30
|
-
const fit = target => {
|
|
31
|
-
const long = Math.round(target * 16 / 9), portrait = r.height > r.width;
|
|
32
|
-
const W = portrait ? target : long, H = portrait ? long : target;
|
|
33
|
-
return Math.max(min, Math.min(Math.floor(W / r.width), Math.floor(H / r.height)));
|
|
34
|
-
};
|
|
35
|
-
const first = size === 'scale' ? Math.max(min, exportScale) : fit(Number(size) || 1080);
|
|
36
|
-
const list = [first, fit(1080), fit(720), min].filter((v, i, a) => v <= first && a.indexOf(v) === i);
|
|
37
|
-
return list.sort((a, b) => b - a);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (!app.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
async function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
async function
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
progress(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
await
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
await task(
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
1
|
+
// Every export the editor offers. Long ones show a progress toast that can be cancelled.
|
|
2
|
+
|
|
3
|
+
import { encodePNG } from '../core/export/png.js';
|
|
4
|
+
import { encodeGIF } from '../core/export/gif.js';
|
|
5
|
+
import { createZip } from '../core/export/zip.js';
|
|
6
|
+
import { packSpriteSheet, buildTileset } from '../core/export/sheet.js';
|
|
7
|
+
import { downloadBlob } from '../core/export/canvas.js';
|
|
8
|
+
import { forEachFrame } from '../core/video/frames.js';
|
|
9
|
+
import { mergeSettings, settingsToJSON } from '../core/settings.js';
|
|
10
|
+
import { toast } from './ui.js';
|
|
11
|
+
|
|
12
|
+
const base = name => (name || 'image').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').replace(/^-+|-+$/g, '') || 'image';
|
|
13
|
+
const pngOpts = s => ({ scale: s.export.scale, grid: s.export.grid, gridColor: s.export.gridColor, indexed: s.export.indexed });
|
|
14
|
+
|
|
15
|
+
/** Pixel size of an exported image: whole-number scale, or the framed part of the source at full resolution. */
|
|
16
|
+
export function exportSize(s, r, item) {
|
|
17
|
+
if (s.export.size === 'source' && r && item && r.crop) return { width: Math.max(1, Math.round(r.crop.w * item.width)), height: Math.max(1, Math.round(r.crop.h * item.height)) };
|
|
18
|
+
return { width: r ? r.width * s.export.scale : 0, height: r ? r.height * s.export.scale : 0 };
|
|
19
|
+
}
|
|
20
|
+
/** PNG options for one exported image. */
|
|
21
|
+
const imageOpts = (s, r, item) => (s.export.size === 'source' && item ? { ...exportSize(s, r, item), indexed: s.export.indexed } : pngOpts(s));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whole-number scales to try for a video, best first. 'scale' = the PNG export scale; otherwise a target
|
|
25
|
+
* height ('1080', '720', '2160', measured on the short side), falling back to smaller sizes if the
|
|
26
|
+
* browser's encoder refuses the big one. Never below 240 px tall.
|
|
27
|
+
*/
|
|
28
|
+
export function videoScales(size, r, exportScale = 4) {
|
|
29
|
+
const min = Math.max(1, Math.ceil(240 / Math.min(r.width, r.height)));
|
|
30
|
+
const fit = target => {
|
|
31
|
+
const long = Math.round(target * 16 / 9), portrait = r.height > r.width;
|
|
32
|
+
const W = portrait ? target : long, H = portrait ? long : target;
|
|
33
|
+
return Math.max(min, Math.min(Math.floor(W / r.width), Math.floor(H / r.height)));
|
|
34
|
+
};
|
|
35
|
+
const first = size === 'scale' ? Math.max(min, exportScale) : fit(Number(size) || 1080);
|
|
36
|
+
const list = [first, fit(1080), fit(720), min].filter((v, i, a) => v <= first && a.indexOf(v) === i);
|
|
37
|
+
return list.sort((a, b) => b - a);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Snapshot selected frames before an asynchronous export can change files or settings. */
|
|
41
|
+
export function selectedFrameOptions(opts, selection) {
|
|
42
|
+
if (!selection?.active) return opts;
|
|
43
|
+
if (!selection.complete) throw new Error('Wait for the selected interval to finish loading');
|
|
44
|
+
if (!selection.renderedFrames.length) throw new Error('Select frames on the canvas first');
|
|
45
|
+
return { ...opts, source: 'video', item: selection.item, settings: selection.settings,
|
|
46
|
+
fps: selection.fps, start: selection.start, end: selection.end, audio: false,
|
|
47
|
+
renderedFrames: selection.renderedFrames.map(frame => ({ ...frame })) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createExporter(app) {
|
|
51
|
+
const S = () => app.store.settings;
|
|
52
|
+
/** While frame selection is on, its tile canvas replaces the preview, so `app.result` is stale. */
|
|
53
|
+
const blockSelectingFrames = () => {
|
|
54
|
+
if (!app.frameSelection?.()?.active) return false;
|
|
55
|
+
toast('Turn off frame selection to export the current frame', { kind: 'error' });
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
58
|
+
const frameOptions = opts => {
|
|
59
|
+
try { return selectedFrameOptions(opts, app.frameSelection?.()); }
|
|
60
|
+
catch (e) { toast(e.message, { kind: 'error' }); return null; }
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
async function exportPNG() {
|
|
64
|
+
if (blockSelectingFrames()) return;
|
|
65
|
+
if (!app.result) return toast('Open an image first', { kind: 'error' });
|
|
66
|
+
const s = S();
|
|
67
|
+
const bytes = await encodePNG(app.result, imageOpts(s, app.result, app.current));
|
|
68
|
+
const out = exportSize(s, app.result, app.current);
|
|
69
|
+
const suffix = s.export.size === 'source' ? `-${out.width}x${out.height}` : s.export.scale > 1 ? `@${s.export.scale}x` : '';
|
|
70
|
+
downloadBlob(bytes, `${base(app.current.name)}-${app.result.width}x${app.result.height}${suffix}.png`, 'image/png');
|
|
71
|
+
toast(`Saved ${out.width} × ${out.height} PNG`, { kind: 'ok' });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function copyImage() {
|
|
75
|
+
if (blockSelectingFrames()) return;
|
|
76
|
+
if (!app.result) return;
|
|
77
|
+
try {
|
|
78
|
+
const bytes = await encodePNG(app.result, { ...imageOpts(S(), app.result, app.current), indexed: false });
|
|
79
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': new Blob([bytes], { type: 'image/png' }) })]);
|
|
80
|
+
toast('Image copied', { kind: 'ok' });
|
|
81
|
+
} catch (e) { toast(`Couldn't copy: ${e.message}`, { kind: 'error' }); }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run `fn` with a cancellable progress toast. */
|
|
85
|
+
async function task(label, fn) {
|
|
86
|
+
if (app.exporting) return toast('An export is already running');
|
|
87
|
+
const ctrl = new AbortController();
|
|
88
|
+
const t = toast(label, { progress: true, onCancel: () => ctrl.abort() });
|
|
89
|
+
app.exporting = true;
|
|
90
|
+
try {
|
|
91
|
+
const msg = await fn(ctrl.signal, (p, m) => { t.progress(p); if (m) t.update(m); });
|
|
92
|
+
t.done(msg);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
t.close();
|
|
95
|
+
if (e.name !== 'AbortError') { toast(`Export failed: ${e.message}`, { kind: 'error' }); console.error(e); }
|
|
96
|
+
else toast('Export cancelled');
|
|
97
|
+
} finally {
|
|
98
|
+
app.exporting = false;
|
|
99
|
+
app.schedule();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function exportZip() {
|
|
104
|
+
const items = app.items.slice();
|
|
105
|
+
if (!items.length) return;
|
|
106
|
+
await task(`Processing ${items.length} files`, async (signal, progress) => {
|
|
107
|
+
const entries = [];
|
|
108
|
+
const own = [];
|
|
109
|
+
for (let i = 0; i < items.length; i++) {
|
|
110
|
+
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
111
|
+
const it = items[i];
|
|
112
|
+
const s = app.settingsFor(it); // each file with its own settings, if it has them
|
|
113
|
+
if (it.type === 'video') app.engine.invalidate();
|
|
114
|
+
const r = await app.engine.process(it.source, s);
|
|
115
|
+
entries.push({ name: `${base(it.name)}.png`, data: await encodePNG(r, imageOpts(s, r, it)) });
|
|
116
|
+
if (it.own) own.push({ name: `${base(it.name)}.settings.json`, data: settingsToJSON(s) });
|
|
117
|
+
progress((i + 1) / items.length, `Processed ${i + 1} of ${items.length}`);
|
|
118
|
+
}
|
|
119
|
+
entries.push({ name: 'settings.json', data: settingsToJSON(app.shared) }, ...own);
|
|
120
|
+
downloadBlob(createZip(entries), 'pixel-art.zip', 'application/zip');
|
|
121
|
+
return `Saved ${items.length} images as a ZIP`;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Process the frames chosen in the Animation section; calls onFrame(result, i, total). */
|
|
126
|
+
async function processVideoFrames(opts, signal, progress, onFrame) {
|
|
127
|
+
const s = S();
|
|
128
|
+
const item = app.current;
|
|
129
|
+
if (!item || item.type !== 'video') throw new Error('Open a video first');
|
|
130
|
+
const video = item.source;
|
|
131
|
+
const end = opts.end > opts.start ? opts.end : video.duration;
|
|
132
|
+
return forEachFrame(video, {
|
|
133
|
+
fps: opts.fps, start: opts.start, end, signal,
|
|
134
|
+
onFrame: async ({ index, total }) => {
|
|
135
|
+
app.engine.invalidate();
|
|
136
|
+
const r = await app.engine.process(video, s);
|
|
137
|
+
await onFrame(r, index, total, `${base(item.name)}-${String(index).padStart(4, '0')}`);
|
|
138
|
+
progress((index + 1) / total, `Frame ${index + 1} of ${total}`);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Process the frames chosen in the Animation section; calls onFrame(result, i, total). */
|
|
144
|
+
async function frames(opts, signal, progress, onFrame) {
|
|
145
|
+
if (opts.renderedFrames) {
|
|
146
|
+
for (let i = 0; i < opts.renderedFrames.length; i++) {
|
|
147
|
+
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
148
|
+
const { result, index } = opts.renderedFrames[i];
|
|
149
|
+
await onFrame(result, i, opts.renderedFrames.length, `${base(opts.item.name)}-${String(index).padStart(4, '0')}`);
|
|
150
|
+
progress((i + 1) / opts.renderedFrames.length, `Frame ${i + 1} of ${opts.renderedFrames.length}`);
|
|
151
|
+
}
|
|
152
|
+
return opts.renderedFrames.length;
|
|
153
|
+
}
|
|
154
|
+
if (opts.source === 'files') {
|
|
155
|
+
const items = app.items.filter(i => i.type === 'image');
|
|
156
|
+
let size = null;
|
|
157
|
+
for (let i = 0; i < items.length; i++) {
|
|
158
|
+
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
159
|
+
// each file keeps its own look, but every frame must share the first frame's size
|
|
160
|
+
const own = app.settingsFor(items[i]);
|
|
161
|
+
const r = await app.engine.process(items[i].source, size ? mergeSettings(own, { size }) : own);
|
|
162
|
+
if (!size) size = { mode: 'width', width: r.width, height: r.height };
|
|
163
|
+
await onFrame(r, i, items.length, items[i].name);
|
|
164
|
+
progress((i + 1) / items.length, `Frame ${i + 1} of ${items.length}`);
|
|
165
|
+
}
|
|
166
|
+
return items.length;
|
|
167
|
+
}
|
|
168
|
+
return processVideoFrames(opts, signal, progress, onFrame);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function exportAnimation(opts) {
|
|
172
|
+
opts = frameOptions(opts);
|
|
173
|
+
if (!opts) return;
|
|
174
|
+
const name = opts.source === 'files' ? 'animation' : base((opts.item || app.current)?.name);
|
|
175
|
+
const s = opts.settings || S();
|
|
176
|
+
const delay = 1000 / opts.fps;
|
|
177
|
+
if (opts.format === 'gif' || opts.format === 'png-seq') {
|
|
178
|
+
await task('Rendering frames', async (signal, progress) => {
|
|
179
|
+
const list = [];
|
|
180
|
+
await frames(opts, signal, progress, async (r, i, total, frameName) => {
|
|
181
|
+
if (opts.format === 'gif') list.push({ width: r.width, height: r.height, data: r.data, delay });
|
|
182
|
+
else list.push({ name: `${frameName}.png`, data: await encodePNG(r, pngOpts(s)) });
|
|
183
|
+
});
|
|
184
|
+
if (opts.format === 'gif') {
|
|
185
|
+
progress(1, 'Encoding GIF');
|
|
186
|
+
await new Promise(r => setTimeout(r, 0));
|
|
187
|
+
const bytes = encodeGIF(list, { scale: s.export.scale, loop: 0 });
|
|
188
|
+
downloadBlob(bytes, `${name}.gif`, 'image/gif');
|
|
189
|
+
return `Saved GIF (${list.length} frames, ${(bytes.length / 1024).toFixed(0)} KB)`;
|
|
190
|
+
}
|
|
191
|
+
downloadBlob(createZip(list), `${name}-frames.zip`, 'application/zip');
|
|
192
|
+
return `Saved ${list.length} PNG frames`;
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// MP4 / WebM through WebCodecs
|
|
197
|
+
await task(`Encoding ${opts.format.toUpperCase()}`, async (signal, progress) => {
|
|
198
|
+
const { createVideoEncoder, decodeAudio } = await import('../video.js');
|
|
199
|
+
// Keep audio: the video's own soundtrack, cut to the exported range (null when it has none)
|
|
200
|
+
const item = app.current;
|
|
201
|
+
let audio = null;
|
|
202
|
+
if (opts.audio && opts.source === 'video' && item && item.type === 'video' && item.file) {
|
|
203
|
+
progress(0, 'Reading audio');
|
|
204
|
+
const end = opts.end > opts.start ? opts.end : item.source.duration;
|
|
205
|
+
audio = await decodeAudio(item.file, { start: opts.start, end }).catch(e => { console.warn('[export] audio', e); return null; });
|
|
206
|
+
}
|
|
207
|
+
let enc = null;
|
|
208
|
+
await frames(opts, signal, progress, async r => {
|
|
209
|
+
if (!enc) {
|
|
210
|
+
// Players stretch small videos with smoothing, which blurs pixel art. So the frames are upscaled
|
|
211
|
+
// by a whole number to about the chosen size first (and H.264's colour subsampling stays inside each pixel).
|
|
212
|
+
let tries = videoScales(opts.videoSize, r, s.export.scale), err = null;
|
|
213
|
+
for (const scale of tries) {
|
|
214
|
+
try { enc = await createVideoEncoder({ format: opts.format, fps: opts.fps, width: r.width, height: r.height, scale, audio }); break; }
|
|
215
|
+
catch (e) { err = e; if (!/encoder available/.test(e.message)) throw e; }
|
|
216
|
+
}
|
|
217
|
+
if (!enc) throw err;
|
|
218
|
+
}
|
|
219
|
+
await enc.add(r);
|
|
220
|
+
});
|
|
221
|
+
if (audio && enc.audioCodec) progress(1, 'Encoding audio');
|
|
222
|
+
const blob = await enc.finish();
|
|
223
|
+
downloadBlob(blob, `${name}.${opts.format}`);
|
|
224
|
+
const sound = !opts.audio || opts.source !== 'video' ? ''
|
|
225
|
+
: enc.audioCodec === 'opus' && opts.format === 'mp4' ? ', with Opus audio: VLC and browsers play it, Windows Media Player and QuickTime may not'
|
|
226
|
+
: enc.audioCodec ? ', with audio'
|
|
227
|
+
: audio ? ", without audio (this browser can't encode it)"
|
|
228
|
+
: ', no audio track in the source';
|
|
229
|
+
return `Saved ${opts.format.toUpperCase()} (${(blob.size / 1024 / 1024).toFixed(1)} MB${sound})`;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function exportSheet(opts) {
|
|
234
|
+
opts = frameOptions(opts);
|
|
235
|
+
if (!opts) return;
|
|
236
|
+
const name = opts.source === 'files' ? 'sheet' : `${base((opts.item || app.current)?.name)}-sheet`;
|
|
237
|
+
const s = opts.settings || S();
|
|
238
|
+
await task('Building sprite sheet', async (signal, progress) => {
|
|
239
|
+
const list = [];
|
|
240
|
+
await frames(opts, signal, progress, (r, i, total, frameName) => { list.push({ width: r.width, height: r.height, data: r.data, name: frameName ? base(frameName) : undefined, delay: 1000 / opts.fps }); });
|
|
241
|
+
const { image, atlas } = packSpriteSheet(list, { columns: opts.columns, padding: opts.padding, name });
|
|
242
|
+
if (s.export.scale > 1) {
|
|
243
|
+
// scale the atlas coordinates with the image
|
|
244
|
+
const k = s.export.scale;
|
|
245
|
+
for (const f of Object.values(atlas.frames)) { for (const key of ['x', 'y', 'w', 'h']) f.frame[key] *= k; f.sourceSize.w *= k; f.sourceSize.h *= k; }
|
|
246
|
+
atlas.meta.size.w *= k; atlas.meta.size.h *= k; atlas.meta.scale = String(k);
|
|
247
|
+
}
|
|
248
|
+
const png = await encodePNG(image, pngOpts(s));
|
|
249
|
+
downloadBlob(createZip([{ name: `${name}.png`, data: png }, { name: `${name}.json`, data: JSON.stringify(atlas, null, 2) }]), `${name}.zip`, 'application/zip');
|
|
250
|
+
return `Saved a ${list.length}-frame sprite sheet`;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function exportTileset({ tile, flips }) {
|
|
255
|
+
if (blockSelectingFrames()) return;
|
|
256
|
+
if (!app.result) return toast('Open an image first', { kind: 'error' });
|
|
257
|
+
const s = S();
|
|
258
|
+
const ts = buildTileset(app.result, { tileWidth: tile, tileHeight: tile, flips });
|
|
259
|
+
const name = `${base(app.current.name)}-tiles`;
|
|
260
|
+
const png = await encodePNG(ts.image, pngOpts(s));
|
|
261
|
+
const map = { ...ts.map, image: `${name}.png`, note: 'tiles[] holds one tile index per cell, row by row' + (flips ? '; flips[] is 0 none, 1 horizontal, 2 vertical, 3 both' : '') };
|
|
262
|
+
downloadBlob(createZip([{ name: `${name}.png`, data: png }, { name: `${name}.json`, data: JSON.stringify(map, null, 2) }]), `${name}.zip`, 'application/zip');
|
|
263
|
+
toast(`Saved ${ts.count} unique tiles (${ts.map.width} × ${ts.map.height} map)`, { kind: 'ok' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { exportPNG, copyImage, exportZip, exportAnimation, exportSheet, exportTileset };
|
|
267
|
+
}
|