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
|
@@ -1,127 +1,168 @@
|
|
|
1
|
-
// Minimal WebM (Matroska) muxer for one VP8/VP9/AV1 video track
|
|
2
|
-
// a SeekHead and Cues so players can seek.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
1
|
+
// Minimal WebM (Matroska) muxer for one VP8/VP9/AV1 video track and, optionally, one Opus audio track.
|
|
2
|
+
// Buffered in memory, written with a SeekHead and Cues so players can seek.
|
|
3
|
+
// Input: encoded frames from WebCodecs' VideoEncoder (and AudioEncoder for the audio).
|
|
4
|
+
|
|
5
|
+
import { opusHead, parseOpusHead } from './audio.js';
|
|
6
|
+
|
|
7
|
+
const ENC = new TextEncoder();
|
|
8
|
+
|
|
9
|
+
/** EBML variable-length size, always 8 bytes (simple and valid for any size). */
|
|
10
|
+
function size8(n) {
|
|
11
|
+
const b = new Uint8Array(8);
|
|
12
|
+
b[0] = 0x01;
|
|
13
|
+
let v = n;
|
|
14
|
+
for (let i = 7; i >= 1; i--) { b[i] = v % 256; v = Math.floor(v / 256); }
|
|
15
|
+
return b;
|
|
16
|
+
}
|
|
17
|
+
function idBytes(id) {
|
|
18
|
+
const out = [];
|
|
19
|
+
for (let v = id; v > 0; v = Math.floor(v / 256)) out.unshift(v % 256);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
function concat(parts) {
|
|
23
|
+
let n = 0; for (const p of parts) n += p.length;
|
|
24
|
+
const out = new Uint8Array(n); let o = 0;
|
|
25
|
+
for (const p of parts) { out.set(p, o); o += p.length; }
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
/** An element: id, 8-byte size, data. */
|
|
29
|
+
const el = (id, data) => concat([Uint8Array.from(idBytes(id)), size8(data.length), data]);
|
|
30
|
+
const uint = (id, v, bytes = 0) => {
|
|
31
|
+
const b = [];
|
|
32
|
+
for (let x = v; x > 0 || b.length === 0; x = Math.floor(x / 256)) b.unshift(x % 256);
|
|
33
|
+
while (b.length < bytes) b.unshift(0);
|
|
34
|
+
return el(id, Uint8Array.from(b));
|
|
35
|
+
};
|
|
36
|
+
const str = (id, s) => el(id, ENC.encode(s));
|
|
37
|
+
const float64 = (id, v) => { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); return el(id, b); };
|
|
38
|
+
const master = (id, children) => el(id, concat(children));
|
|
39
|
+
|
|
40
|
+
const ID = {
|
|
41
|
+
EBML: 0x1A45DFA3, EBMLVersion: 0x4286, EBMLReadVersion: 0x42F7, EBMLMaxIDLength: 0x42F2, EBMLMaxSizeLength: 0x42F3,
|
|
42
|
+
DocType: 0x4282, DocTypeVersion: 0x4287, DocTypeReadVersion: 0x4285,
|
|
43
|
+
Segment: 0x18538067, SeekHead: 0x114D9B74, Seek: 0x4DBB, SeekID: 0x53AB, SeekPosition: 0x53AC,
|
|
44
|
+
Info: 0x1549A966, TimestampScale: 0x2AD7B1, Duration: 0x4489, MuxingApp: 0x4D80, WritingApp: 0x5741,
|
|
45
|
+
Tracks: 0x1654AE6B, TrackEntry: 0xAE, TrackNumber: 0xD7, TrackUID: 0x73C5, TrackType: 0x83, FlagLacing: 0x9C,
|
|
46
|
+
CodecID: 0x86, CodecPrivate: 0x63A2, DefaultDuration: 0x23E383, Video: 0xE0, PixelWidth: 0xB0, PixelHeight: 0xBA,
|
|
47
|
+
CodecDelay: 0x56AA, SeekPreRoll: 0x56BB, Audio: 0xE1, SamplingFrequency: 0xB5, Channels: 0x9F,
|
|
48
|
+
Cluster: 0x1F43B675, Timestamp: 0xE7, SimpleBlock: 0xA3,
|
|
49
|
+
Cues: 0x1C53BB6B, CuePoint: 0xBB, CueTime: 0xB3, CueTrackPositions: 0xB7, CueTrack: 0xF7, CueClusterPosition: 0xF1,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const CODEC_IDS = { vp8: 'V_VP8', vp9: 'V_VP9', av1: 'V_AV1' };
|
|
53
|
+
const VIDEO = 1, AUDIO = 2; // track numbers
|
|
54
|
+
|
|
55
|
+
const toBytes = d => new Uint8Array(d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {{ width: number, height: number, codec: 'vp8' | 'vp9' | 'av1', fps?: number,
|
|
59
|
+
* audio?: { codec: 'opus', sampleRate: number, numberOfChannels: number } }} opts
|
|
60
|
+
* audio: add an Opus track (WebM holds Opus or Vorbis only). Feed it with addAudioChunk() or addAudioFrame().
|
|
61
|
+
*/
|
|
62
|
+
export function createWebmMuxer({ width, height, codec = 'vp9', fps = 0, audio = null }) {
|
|
63
|
+
if (audio && audio.codec && audio.codec !== 'opus') throw new Error(`WebM audio must be Opus, not ${audio.codec}`);
|
|
64
|
+
const frames = []; // video: { data, key, ms }
|
|
65
|
+
const audioFrames = []; // audio: { data, ms, dms }
|
|
66
|
+
let codecPrivate = null;
|
|
67
|
+
let audioPrivate = null;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
addChunk(chunk, meta) {
|
|
71
|
+
if (meta && meta.decoderConfig && meta.decoderConfig.description && !codecPrivate) codecPrivate = toBytes(meta.decoderConfig.description);
|
|
72
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
73
|
+
chunk.copyTo(data);
|
|
74
|
+
frames.push({ data, key: chunk.type === 'key', ms: chunk.timestamp / 1000 });
|
|
75
|
+
},
|
|
76
|
+
/** Lower-level: add one encoded frame. timestamp in microseconds. */
|
|
77
|
+
addFrame(data, { key, timestamp }) { frames.push({ data, key: !!key, ms: timestamp / 1000 }); },
|
|
78
|
+
|
|
79
|
+
/** Use with an AudioEncoder `output` callback (needs the `audio` option). */
|
|
80
|
+
addAudioChunk(chunk, meta) {
|
|
81
|
+
if (meta && meta.decoderConfig && meta.decoderConfig.description && !audioPrivate) audioPrivate = toBytes(meta.decoderConfig.description);
|
|
82
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
83
|
+
chunk.copyTo(data);
|
|
84
|
+
audioFrames.push({ data, ms: chunk.timestamp / 1000, dms: (chunk.duration || 0) / 1000 });
|
|
85
|
+
},
|
|
86
|
+
/** Lower-level: add one Opus packet, in order. timestamp/duration in microseconds. */
|
|
87
|
+
addAudioFrame(data, { timestamp, duration }) { audioFrames.push({ data, ms: timestamp / 1000, dms: (duration || 0) / 1000 }); },
|
|
88
|
+
|
|
89
|
+
/** @returns {Uint8Array} the .webm file */
|
|
90
|
+
finish() {
|
|
91
|
+
if (!frames.length) throw new Error('No video frames were encoded');
|
|
92
|
+
const hasAudio = !!(audio && audioFrames.length);
|
|
93
|
+
// VP8/VP9/AV1 frames from WebCodecs arrive in decode order, which is the order they're stored in
|
|
94
|
+
const frameMs = fps ? 1000 / fps : (frames.length > 1 ? (frames[frames.length - 1].ms - frames[0].ms) / (frames.length - 1) : 33.333);
|
|
95
|
+
let duration = frames[frames.length - 1].ms + frameMs;
|
|
96
|
+
if (hasAudio) { const a = audioFrames[audioFrames.length - 1]; duration = Math.max(duration, a.ms + (a.dms || 20)); }
|
|
97
|
+
|
|
98
|
+
const info = master(ID.Info, [
|
|
99
|
+
uint(ID.TimestampScale, 1000000), // 1 ms
|
|
100
|
+
float64(ID.Duration, duration),
|
|
101
|
+
str(ID.MuxingApp, 'image-to-pixel'), str(ID.WritingApp, 'image-to-pixel'),
|
|
102
|
+
]);
|
|
103
|
+
const trackEntries = [master(ID.TrackEntry, [
|
|
104
|
+
uint(ID.TrackNumber, VIDEO), uint(ID.TrackUID, VIDEO), uint(ID.TrackType, 1), uint(ID.FlagLacing, 0),
|
|
105
|
+
str(ID.CodecID, CODEC_IDS[codec] || codec),
|
|
106
|
+
...(codecPrivate ? [el(ID.CodecPrivate, codecPrivate)] : []),
|
|
107
|
+
uint(ID.DefaultDuration, Math.round(frameMs * 1e6)),
|
|
108
|
+
master(ID.Video, [uint(ID.PixelWidth, width), uint(ID.PixelHeight, height)]),
|
|
109
|
+
])];
|
|
110
|
+
if (hasAudio) {
|
|
111
|
+
// Matroska requires the OpusHead as CodecPrivate; build one when the encoder didn't provide it
|
|
112
|
+
const head = parseOpusHead(audioPrivate) ? audioPrivate : opusHead(audio.numberOfChannels, audio.sampleRate);
|
|
113
|
+
const preSkip = parseOpusHead(head).preSkip;
|
|
114
|
+
trackEntries.push(master(ID.TrackEntry, [
|
|
115
|
+
uint(ID.TrackNumber, AUDIO), uint(ID.TrackUID, AUDIO), uint(ID.TrackType, 2), uint(ID.FlagLacing, 0),
|
|
116
|
+
str(ID.CodecID, 'A_OPUS'),
|
|
117
|
+
el(ID.CodecPrivate, head),
|
|
118
|
+
uint(ID.CodecDelay, Math.round(preSkip * 1e9 / 48000)), // nanoseconds
|
|
119
|
+
uint(ID.SeekPreRoll, 80000000), // 80 ms, as the Opus mapping recommends
|
|
120
|
+
master(ID.Audio, [float64(ID.SamplingFrequency, audio.sampleRate), uint(ID.Channels, audio.numberOfChannels)]),
|
|
121
|
+
]));
|
|
122
|
+
}
|
|
123
|
+
const tracks = master(ID.Tracks, trackEntries);
|
|
124
|
+
|
|
125
|
+
// Blocks of both tracks in time order (video first on a tie, so a cluster starts on its keyframe).
|
|
126
|
+
const blocks = frames.map((f, i) => ({ track: VIDEO, data: f.data, key: f.key, ms: f.ms, order: i }));
|
|
127
|
+
if (hasAudio) audioFrames.forEach((f, i) => blocks.push({ track: AUDIO, data: f.data, key: true, ms: f.ms, order: frames.length + i }));
|
|
128
|
+
if (hasAudio) blocks.sort((a, b) => a.ms - b.ms || a.track - b.track || a.order - b.order);
|
|
129
|
+
|
|
130
|
+
// clusters: a new one at each video keyframe, and before the 16-bit relative timestamp overflows
|
|
131
|
+
const clusters = [];
|
|
132
|
+
let cur = null;
|
|
133
|
+
for (const b of blocks) {
|
|
134
|
+
const t = Math.round(b.ms);
|
|
135
|
+
const videoKey = b.track === VIDEO && b.key;
|
|
136
|
+
if (!cur || videoKey || t - cur.start > 30000) { cur = { start: t, key: videoKey, blocks: [] }; clusters.push(cur); }
|
|
137
|
+
const head = new Uint8Array(4);
|
|
138
|
+
head[0] = 0x80 | b.track; // track number as a 1-byte EBML vint
|
|
139
|
+
new DataView(head.buffer).setInt16(1, t - cur.start);
|
|
140
|
+
head[3] = b.key ? 0x80 : 0x00;
|
|
141
|
+
cur.blocks.push(el(ID.SimpleBlock, concat([head, b.data])));
|
|
142
|
+
}
|
|
143
|
+
const clusterBytes = clusters.map(c => master(ID.Cluster, [uint(ID.Timestamp, c.start), ...c.blocks]));
|
|
144
|
+
|
|
145
|
+
// SeekHead with fixed-width positions, so its size is known before the positions are
|
|
146
|
+
const seekHead = (posInfo, posTracks, posCues) => master(ID.SeekHead, [
|
|
147
|
+
[ID.Info, posInfo], [ID.Tracks, posTracks], [ID.Cues, posCues],
|
|
148
|
+
].map(([id, pos]) => master(ID.Seek, [el(ID.SeekID, Uint8Array.from(idBytes(id))), uint(ID.SeekPosition, pos, 8)])));
|
|
149
|
+
const shSize = seekHead(0, 0, 0).length;
|
|
150
|
+
const posInfo = shSize, posTracks = posInfo + info.length;
|
|
151
|
+
let pos = posTracks + tracks.length;
|
|
152
|
+
const cuePoints = [];
|
|
153
|
+
clusters.forEach((c, i) => {
|
|
154
|
+
if (c.key) cuePoints.push(master(ID.CuePoint, [uint(ID.CueTime, c.start), master(ID.CueTrackPositions, [uint(ID.CueTrack, VIDEO), uint(ID.CueClusterPosition, pos)])]));
|
|
155
|
+
pos += clusterBytes[i].length;
|
|
156
|
+
});
|
|
157
|
+
const cues = master(ID.Cues, cuePoints.length ? cuePoints : [master(ID.CuePoint, [uint(ID.CueTime, 0), master(ID.CueTrackPositions, [uint(ID.CueTrack, VIDEO), uint(ID.CueClusterPosition, posTracks + tracks.length)])])]);
|
|
158
|
+
const posCues = pos;
|
|
159
|
+
|
|
160
|
+
const header = master(ID.EBML, [
|
|
161
|
+
uint(ID.EBMLVersion, 1), uint(ID.EBMLReadVersion, 1), uint(ID.EBMLMaxIDLength, 4), uint(ID.EBMLMaxSizeLength, 8),
|
|
162
|
+
str(ID.DocType, 'webm'), uint(ID.DocTypeVersion, 4), uint(ID.DocTypeReadVersion, 2),
|
|
163
|
+
]);
|
|
164
|
+
const segment = master(ID.Segment, [seekHead(posInfo, posTracks, posCues), info, tracks, ...clusterBytes, cues]);
|
|
165
|
+
return concat([header, segment]);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
package/lib/editor/exporter.js
CHANGED
|
@@ -20,10 +20,48 @@ export function exportSize(s, r, item) {
|
|
|
20
20
|
/** PNG options for one exported image. */
|
|
21
21
|
const imageOpts = (s, r, item) => (s.export.size === 'source' && item ? { ...exportSize(s, r, item), indexed: s.export.indexed } : pngOpts(s));
|
|
22
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
|
+
|
|
23
50
|
export function createExporter(app) {
|
|
24
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
|
+
};
|
|
25
62
|
|
|
26
63
|
async function exportPNG() {
|
|
64
|
+
if (blockSelectingFrames()) return;
|
|
27
65
|
if (!app.result) return toast('Open an image first', { kind: 'error' });
|
|
28
66
|
const s = S();
|
|
29
67
|
const bytes = await encodePNG(app.result, imageOpts(s, app.result, app.current));
|
|
@@ -34,6 +72,7 @@ export function createExporter(app) {
|
|
|
34
72
|
}
|
|
35
73
|
|
|
36
74
|
async function copyImage() {
|
|
75
|
+
if (blockSelectingFrames()) return;
|
|
37
76
|
if (!app.result) return;
|
|
38
77
|
try {
|
|
39
78
|
const bytes = await encodePNG(app.result, { ...imageOpts(S(), app.result, app.current), indexed: false });
|
|
@@ -44,6 +83,7 @@ export function createExporter(app) {
|
|
|
44
83
|
|
|
45
84
|
/** Run `fn` with a cancellable progress toast. */
|
|
46
85
|
async function task(label, fn) {
|
|
86
|
+
if (app.exporting) return toast('An export is already running');
|
|
47
87
|
const ctrl = new AbortController();
|
|
48
88
|
const t = toast(label, { progress: true, onCancel: () => ctrl.abort() });
|
|
49
89
|
app.exporting = true;
|
|
@@ -83,8 +123,34 @@ export function createExporter(app) {
|
|
|
83
123
|
}
|
|
84
124
|
|
|
85
125
|
/** Process the frames chosen in the Animation section; calls onFrame(result, i, total). */
|
|
86
|
-
async function
|
|
126
|
+
async function processVideoFrames(opts, signal, progress, onFrame) {
|
|
87
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
|
+
}
|
|
88
154
|
if (opts.source === 'files') {
|
|
89
155
|
const items = app.items.filter(i => i.type === 'image');
|
|
90
156
|
let size = null;
|
|
@@ -99,24 +165,14 @@ export function createExporter(app) {
|
|
|
99
165
|
}
|
|
100
166
|
return items.length;
|
|
101
167
|
}
|
|
102
|
-
|
|
103
|
-
if (!item || item.type !== 'video') throw new Error('Open a video first');
|
|
104
|
-
const video = item.source;
|
|
105
|
-
const end = opts.end > opts.start ? opts.end : video.duration;
|
|
106
|
-
return forEachFrame(video, {
|
|
107
|
-
fps: opts.fps, start: opts.start, end, signal,
|
|
108
|
-
onFrame: async ({ index, total }) => {
|
|
109
|
-
app.engine.invalidate();
|
|
110
|
-
const r = await app.engine.process(video, s);
|
|
111
|
-
await onFrame(r, index, total, `${base(item.name)}-${String(index).padStart(4, '0')}`);
|
|
112
|
-
progress((index + 1) / total, `Frame ${index + 1} of ${total}`);
|
|
113
|
-
},
|
|
114
|
-
});
|
|
168
|
+
return processVideoFrames(opts, signal, progress, onFrame);
|
|
115
169
|
}
|
|
116
170
|
|
|
117
171
|
async function exportAnimation(opts) {
|
|
118
|
-
|
|
119
|
-
|
|
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();
|
|
120
176
|
const delay = 1000 / opts.fps;
|
|
121
177
|
if (opts.format === 'gif' || opts.format === 'png-seq') {
|
|
122
178
|
await task('Rendering frames', async (signal, progress) => {
|
|
@@ -139,21 +195,46 @@ export function createExporter(app) {
|
|
|
139
195
|
}
|
|
140
196
|
// MP4 / WebM through WebCodecs
|
|
141
197
|
await task(`Encoding ${opts.format.toUpperCase()}`, async (signal, progress) => {
|
|
142
|
-
const { createVideoEncoder } = await import('../video.js');
|
|
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
|
+
}
|
|
143
207
|
let enc = null;
|
|
144
208
|
await frames(opts, signal, progress, async r => {
|
|
145
|
-
|
|
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
|
+
}
|
|
146
219
|
await enc.add(r);
|
|
147
220
|
});
|
|
221
|
+
if (audio && enc.audioCodec) progress(1, 'Encoding audio');
|
|
148
222
|
const blob = await enc.finish();
|
|
149
223
|
downloadBlob(blob, `${name}.${opts.format}`);
|
|
150
|
-
|
|
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})`;
|
|
151
230
|
});
|
|
152
231
|
}
|
|
153
232
|
|
|
154
233
|
async function exportSheet(opts) {
|
|
155
|
-
|
|
156
|
-
|
|
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();
|
|
157
238
|
await task('Building sprite sheet', async (signal, progress) => {
|
|
158
239
|
const list = [];
|
|
159
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 }); });
|
|
@@ -171,6 +252,7 @@ export function createExporter(app) {
|
|
|
171
252
|
}
|
|
172
253
|
|
|
173
254
|
async function exportTileset({ tile, flips }) {
|
|
255
|
+
if (blockSelectingFrames()) return;
|
|
174
256
|
if (!app.result) return toast('Open an image first', { kind: 'error' });
|
|
175
257
|
const s = S();
|
|
176
258
|
const ts = buildTileset(app.result, { tileWidth: tile, tileHeight: tile, flips });
|