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.
@@ -1,127 +1,168 @@
1
- // Minimal WebM (Matroska) muxer for one VP8/VP9/AV1 video track. Buffered in memory, written with
2
- // a SeekHead and Cues so players can seek. Input: encoded frames from WebCodecs' VideoEncoder.
3
-
4
- const ENC = new TextEncoder();
5
-
6
- /** EBML variable-length size, always 8 bytes (simple and valid for any size). */
7
- function size8(n) {
8
- const b = new Uint8Array(8);
9
- b[0] = 0x01;
10
- let v = n;
11
- for (let i = 7; i >= 1; i--) { b[i] = v % 256; v = Math.floor(v / 256); }
12
- return b;
13
- }
14
- function idBytes(id) {
15
- const out = [];
16
- for (let v = id; v > 0; v = Math.floor(v / 256)) out.unshift(v % 256);
17
- return out;
18
- }
19
- function concat(parts) {
20
- let n = 0; for (const p of parts) n += p.length;
21
- const out = new Uint8Array(n); let o = 0;
22
- for (const p of parts) { out.set(p, o); o += p.length; }
23
- return out;
24
- }
25
- /** An element: id, 8-byte size, data. */
26
- const el = (id, data) => concat([Uint8Array.from(idBytes(id)), size8(data.length), data]);
27
- const uint = (id, v, bytes = 0) => {
28
- const b = [];
29
- for (let x = v; x > 0 || b.length === 0; x = Math.floor(x / 256)) b.unshift(x % 256);
30
- while (b.length < bytes) b.unshift(0);
31
- return el(id, Uint8Array.from(b));
32
- };
33
- const str = (id, s) => el(id, ENC.encode(s));
34
- const float64 = (id, v) => { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); return el(id, b); };
35
- const master = (id, children) => el(id, concat(children));
36
-
37
- const ID = {
38
- EBML: 0x1A45DFA3, EBMLVersion: 0x4286, EBMLReadVersion: 0x42F7, EBMLMaxIDLength: 0x42F2, EBMLMaxSizeLength: 0x42F3,
39
- DocType: 0x4282, DocTypeVersion: 0x4287, DocTypeReadVersion: 0x4285,
40
- Segment: 0x18538067, SeekHead: 0x114D9B74, Seek: 0x4DBB, SeekID: 0x53AB, SeekPosition: 0x53AC,
41
- Info: 0x1549A966, TimestampScale: 0x2AD7B1, Duration: 0x4489, MuxingApp: 0x4D80, WritingApp: 0x5741,
42
- Tracks: 0x1654AE6B, TrackEntry: 0xAE, TrackNumber: 0xD7, TrackUID: 0x73C5, TrackType: 0x83, FlagLacing: 0x9C,
43
- CodecID: 0x86, CodecPrivate: 0x63A2, DefaultDuration: 0x23E383, Video: 0xE0, PixelWidth: 0xB0, PixelHeight: 0xBA,
44
- Cluster: 0x1F43B675, Timestamp: 0xE7, SimpleBlock: 0xA3,
45
- Cues: 0x1C53BB6B, CuePoint: 0xBB, CueTime: 0xB3, CueTrackPositions: 0xB7, CueTrack: 0xF7, CueClusterPosition: 0xF1,
46
- };
47
-
48
- const CODEC_IDS = { vp8: 'V_VP8', vp9: 'V_VP9', av1: 'V_AV1' };
49
-
50
- /**
51
- * @param {{ width: number, height: number, codec: 'vp8' | 'vp9' | 'av1', fps?: number }} opts
52
- */
53
- export function createWebmMuxer({ width, height, codec = 'vp9', fps = 0 }) {
54
- const frames = []; // { data, key, ms }
55
- let codecPrivate = null;
56
-
57
- return {
58
- addChunk(chunk, meta) {
59
- if (meta && meta.decoderConfig && meta.decoderConfig.description && !codecPrivate) {
60
- const d = meta.decoderConfig.description;
61
- codecPrivate = new Uint8Array(d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength));
62
- }
63
- const data = new Uint8Array(chunk.byteLength);
64
- chunk.copyTo(data);
65
- frames.push({ data, key: chunk.type === 'key', ms: chunk.timestamp / 1000 });
66
- },
67
- /** Lower-level: add one encoded frame. timestamp in microseconds. */
68
- addFrame(data, { key, timestamp }) { frames.push({ data, key: !!key, ms: timestamp / 1000 }); },
69
-
70
- /** @returns {Uint8Array} the .webm file */
71
- finish() {
72
- if (!frames.length) throw new Error('No video frames were encoded');
73
- // VP8/VP9/AV1 frames from WebCodecs arrive in decode order, which is the order they're stored in
74
- const frameMs = fps ? 1000 / fps : (frames.length > 1 ? (frames[frames.length - 1].ms - frames[0].ms) / (frames.length - 1) : 33.333);
75
- const duration = frames[frames.length - 1].ms + frameMs;
76
-
77
- const info = master(ID.Info, [
78
- uint(ID.TimestampScale, 1000000), // 1 ms
79
- float64(ID.Duration, duration),
80
- str(ID.MuxingApp, 'image-to-pixel'), str(ID.WritingApp, 'image-to-pixel'),
81
- ]);
82
- const tracks = master(ID.Tracks, [master(ID.TrackEntry, [
83
- uint(ID.TrackNumber, 1), uint(ID.TrackUID, 1), uint(ID.TrackType, 1), uint(ID.FlagLacing, 0),
84
- str(ID.CodecID, CODEC_IDS[codec] || codec),
85
- ...(codecPrivate ? [el(ID.CodecPrivate, codecPrivate)] : []),
86
- uint(ID.DefaultDuration, Math.round(frameMs * 1e6)),
87
- master(ID.Video, [uint(ID.PixelWidth, width), uint(ID.PixelHeight, height)]),
88
- ])]);
89
-
90
- // clusters: a new one at each keyframe, and before the 16-bit relative timestamp overflows
91
- const clusters = [];
92
- let cur = null;
93
- for (const f of frames) {
94
- const t = Math.round(f.ms);
95
- if (!cur || f.key || t - cur.start > 30000) { cur = { start: t, key: f.key, blocks: [] }; clusters.push(cur); }
96
- const head = new Uint8Array(4);
97
- head[0] = 0x81; // track 1
98
- new DataView(head.buffer).setInt16(1, t - cur.start);
99
- head[3] = f.key ? 0x80 : 0x00;
100
- cur.blocks.push(el(ID.SimpleBlock, concat([head, f.data])));
101
- }
102
- const clusterBytes = clusters.map(c => master(ID.Cluster, [uint(ID.Timestamp, c.start), ...c.blocks]));
103
-
104
- // SeekHead with fixed-width positions, so its size is known before the positions are
105
- const seekHead = (posInfo, posTracks, posCues) => master(ID.SeekHead, [
106
- [ID.Info, posInfo], [ID.Tracks, posTracks], [ID.Cues, posCues],
107
- ].map(([id, pos]) => master(ID.Seek, [el(ID.SeekID, Uint8Array.from(idBytes(id))), uint(ID.SeekPosition, pos, 8)])));
108
- const shSize = seekHead(0, 0, 0).length;
109
- const posInfo = shSize, posTracks = posInfo + info.length;
110
- let pos = posTracks + tracks.length;
111
- const cuePoints = [];
112
- clusters.forEach((c, i) => {
113
- if (c.key) cuePoints.push(master(ID.CuePoint, [uint(ID.CueTime, c.start), master(ID.CueTrackPositions, [uint(ID.CueTrack, 1), uint(ID.CueClusterPosition, pos)])]));
114
- pos += clusterBytes[i].length;
115
- });
116
- const cues = master(ID.Cues, cuePoints.length ? cuePoints : [master(ID.CuePoint, [uint(ID.CueTime, 0), master(ID.CueTrackPositions, [uint(ID.CueTrack, 1), uint(ID.CueClusterPosition, posTracks + tracks.length)])])]);
117
- const posCues = pos;
118
-
119
- const header = master(ID.EBML, [
120
- uint(ID.EBMLVersion, 1), uint(ID.EBMLReadVersion, 1), uint(ID.EBMLMaxIDLength, 4), uint(ID.EBMLMaxSizeLength, 8),
121
- str(ID.DocType, 'webm'), uint(ID.DocTypeVersion, 4), uint(ID.DocTypeReadVersion, 2),
122
- ]);
123
- const segment = master(ID.Segment, [seekHead(posInfo, posTracks, posCues), info, tracks, ...clusterBytes, cues]);
124
- return concat([header, segment]);
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
+ }
@@ -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 frames(opts, signal, progress, onFrame) {
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
- const item = app.current;
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
- const name = opts.source === 'files' ? 'animation' : base(app.current?.name);
119
- const s = S();
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
- enc ||= await createVideoEncoder({ format: opts.format, fps: opts.fps, width: r.width, height: r.height, scale: Math.max(s.export.scale, Math.ceil(240 / r.height)) });
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
- return `Saved ${opts.format.toUpperCase()} (${(blob.size / 1024 / 1024).toFixed(1)} MB)`;
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
- const name = opts.source === 'files' ? 'sheet' : `${base(app.current?.name)}-sheet`;
156
- const s = S();
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 });