image-to-pixel 3.0.0 → 3.1.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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -2
  3. package/dist/editor.css +1 -1
  4. package/dist/editor.css.map +2 -2
  5. package/dist/editor.esm.js +60 -60
  6. package/dist/editor.esm.js.map +4 -4
  7. package/dist/fonts/README.md +6 -6
  8. package/dist/fonts/geist-LICENSE.txt +93 -93
  9. package/dist/image-to-pixel.bundle.esm.js +16 -16
  10. package/dist/image-to-pixel.bundle.esm.js.map +3 -3
  11. package/dist/image-to-pixel.cjs +2 -2
  12. package/dist/image-to-pixel.cjs.map +2 -2
  13. package/dist/image-to-pixel.esm.js +2 -2
  14. package/dist/image-to-pixel.esm.js.map +2 -2
  15. package/dist/image-to-pixel.js +2 -2
  16. package/dist/image-to-pixel.js.map +2 -2
  17. package/dist/image-to-pixel.min.js +11 -11
  18. package/dist/image-to-pixel.min.js.map +3 -3
  19. package/dist/video.esm.js +2 -2
  20. package/dist/video.esm.js.map +4 -4
  21. package/lib/core/color.js +69 -69
  22. package/lib/core/cpu/pipeline.js +21 -21
  23. package/lib/core/cpu/stages.js +227 -227
  24. package/lib/core/dither/bluenoise-data.js +3 -3
  25. package/lib/core/dither/kernels.js +27 -27
  26. package/lib/core/dither/matrices.js +95 -95
  27. package/lib/core/engine.js +158 -158
  28. package/lib/core/export/canvas.js +22 -22
  29. package/lib/core/export/crc32.js +15 -15
  30. package/lib/core/export/gif.js +153 -153
  31. package/lib/core/export/png.js +94 -94
  32. package/lib/core/export/scale.js +103 -103
  33. package/lib/core/export/sheet.js +95 -95
  34. package/lib/core/export/zip.js +55 -55
  35. package/lib/core/gpu/backend.js +159 -159
  36. package/lib/core/gpu/shaders.js +258 -258
  37. package/lib/core/palette/builtin.js +36 -36
  38. package/lib/core/palette/extract.js +69 -69
  39. package/lib/core/palette/lospec.js +24 -24
  40. package/lib/core/palette/parse.js +112 -112
  41. package/lib/core/plan.js +58 -58
  42. package/lib/core/presets.js +42 -42
  43. package/lib/core/settings.js +274 -274
  44. package/lib/core/source.js +89 -89
  45. package/lib/core/video/audio.js +159 -0
  46. package/lib/core/video/frames.js +38 -0
  47. package/lib/core/video/mux-mp4.js +213 -24
  48. package/lib/core/video/mux-webm.js +63 -22
  49. package/lib/editor/controls.js +139 -139
  50. package/lib/editor/dom.js +65 -65
  51. package/lib/editor/exporter.js +43 -3
  52. package/lib/editor/icons.js +4 -0
  53. package/lib/editor/index.js +218 -9
  54. package/lib/editor/inspector.js +19 -4
  55. package/lib/editor/library.js +8 -2
  56. package/lib/editor/persist.js +18 -18
  57. package/lib/editor/samples.js +90 -90
  58. package/lib/editor/stage.js +239 -239
  59. package/lib/editor/store.js +80 -80
  60. package/lib/editor/styles/editor.css +34 -1
  61. package/lib/editor/styles/fonts/README.md +6 -6
  62. package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
  63. package/lib/editor/styles/tokens.css +89 -89
  64. package/lib/editor/ui.js +97 -97
  65. package/lib/editor/workspace.js +62 -0
  66. package/lib/index.js +31 -29
  67. package/lib/video.js +37 -7
  68. package/package.json +1 -1
  69. package/types/editor.d.ts +34 -34
  70. package/types/index.d.ts +196 -196
  71. package/types/video.d.ts +61 -5
@@ -1,7 +1,12 @@
1
- // Minimal MP4 (ISO BMFF) muxer for one H.264 video track. Everything is buffered in memory and
2
- // written with the 'moov' box first ("fast start"), so the file plays and seeks while downloading.
3
- // Input: AVCC samples (length-prefixed NAL units, as WebCodecs' VideoEncoder emits with
1
+ // Minimal MP4 (ISO BMFF) muxer for one H.264 video track and, optionally, one AAC or Opus audio track.
2
+ // Everything is buffered in memory and written with the 'moov' box first ("fast start"), so the file
3
+ // plays and seeks while downloading.
4
+ // Video input: AVCC samples (length-prefixed NAL units, as WebCodecs' VideoEncoder emits with
4
5
  // avc.format 'avc') plus the AVCDecoderConfigurationRecord ('description').
6
+ // Audio input: raw AAC frames plus the AudioSpecificConfig ('description'), as AudioEncoder emits with
7
+ // aac.format 'aac'; or Opus packets.
8
+
9
+ import { parseOpusHead } from './audio.js';
5
10
 
6
11
  const ENC = new TextEncoder();
7
12
 
@@ -28,20 +33,151 @@ function box(type, fill) {
28
33
  const fullBox = (type, version, flags, fill) => box(type, w => { w.u8(version).u8(flags >> 16).u8(flags >> 8).u8(flags); fill(w); });
29
34
  const MATRIX = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000];
30
35
 
36
+ const toBytes = d => new Uint8Array(d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength));
37
+
38
+ /** MPEG-4 descriptor: tag, one-byte length (every descriptor here is under 128 bytes), body. */
39
+ const descriptor = (tag, body) => [tag, body.length, ...body];
40
+ const be32 = v => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
41
+
42
+ /** 'esds' for AAC: ES_Descriptor > DecoderConfigDescriptor > DecoderSpecificInfo (the AudioSpecificConfig). */
43
+ function esds(asc, bitrate) {
44
+ const decSpecific = descriptor(0x05, [...asc]);
45
+ // 0x40 = MPEG-4 audio, 0x15 = audio stream type; buffer size 0; max and average bitrate
46
+ const decConfig = descriptor(0x04, [0x40, 0x15, 0, 0, 0, ...be32(bitrate), ...be32(bitrate), ...decSpecific]);
47
+ const sl = descriptor(0x06, [0x02]);
48
+ return fullBox('esds', 0, 0, b => b.bytes(descriptor(0x03, [0x00, 0x02, 0x00, ...decConfig, ...sl]))); // ES_ID 2
49
+ }
50
+
51
+ // ---- H.264 decoder configuration (avcC)
52
+ // Some encoders hand WebCodecs a malformed avcC: Chrome on Windows (Media Foundation) has been seen
53
+ // repeating each parameter set's NAL header byte, which strict players (Windows Media Player, QuickTime)
54
+ // refuse. The encoder also writes SPS/PPS into the keyframes, so the muxer checks the record and, if it
55
+ // doesn't hold together, rebuilds it from those in-band parameter sets.
56
+
57
+ const HIGH_PROFILES = new Set([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135]);
58
+
59
+ /** Is this a well-formed AVCDecoderConfigurationRecord whose SPS agrees with its header? */
60
+ export function isValidAvcC(d) {
61
+ if (!d || d.length < 7 || d[0] !== 1) return false;
62
+ let o = 5;
63
+ const nSps = d[o++] & 31;
64
+ if (!nSps) return false;
65
+ for (let i = 0; i < nSps; i++) {
66
+ if (o + 2 > d.length) return false;
67
+ const len = (d[o] << 8) | d[o + 1]; o += 2;
68
+ if (len < 4 || o + len > d.length) return false;
69
+ const sps = d.subarray(o, o + len);
70
+ if ((sps[0] & 31) !== 7) return false;
71
+ if (i === 0 && (sps[1] !== d[1] || sps[2] !== d[2] || sps[3] !== d[3])) return false; // profile, constraints, level
72
+ o += len;
73
+ }
74
+ if (o >= d.length) return false;
75
+ const nPps = d[o++];
76
+ if (!nPps) return false;
77
+ for (let i = 0; i < nPps; i++) {
78
+ if (o + 2 > d.length) return false;
79
+ const len = (d[o] << 8) | d[o + 1]; o += 2;
80
+ if (len < 1 || o + len > d.length || (d[o] & 31) !== 8) return false;
81
+ o += len;
82
+ }
83
+ return true;
84
+ }
85
+
86
+ /** NAL units of an AVCC sample (4-byte lengths). */
87
+ function nalUnits(data) {
88
+ const out = [];
89
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
90
+ for (let o = 0; o + 4 <= data.length;) {
91
+ const len = dv.getUint32(o); o += 4;
92
+ if (!len || o + len > data.length) break;
93
+ out.push(data.subarray(o, o + len)); o += len;
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /** chroma_format_idc and bit depths from an SPS (High profiles), for the avcC extension. */
99
+ function spsChroma(sps) {
100
+ const rbsp = [];
101
+ for (let i = 1; i < sps.length; i++) { // skip the NAL header; drop emulation-prevention bytes
102
+ if (i >= 3 && sps[i] === 3 && sps[i - 1] === 0 && sps[i - 2] === 0) continue;
103
+ rbsp.push(sps[i]);
104
+ }
105
+ let bit = 24; // after profile_idc, constraint flags, level_idc
106
+ const u1 = () => { const v = (rbsp[bit >> 3] >> (7 - (bit & 7))) & 1; bit++; return v; };
107
+ const ue = () => { let z = 0; while (bit < rbsp.length * 8 && !u1()) z++; let v = 0; for (let i = 0; i < z; i++) v = v * 2 + u1(); return (2 ** z) - 1 + v; };
108
+ ue(); // seq_parameter_set_id
109
+ const chroma = ue();
110
+ if (chroma === 3) u1(); // separate_colour_plane_flag
111
+ return { chroma, lumaBits: ue(), chromaBits: ue() };
112
+ }
113
+
114
+ /** Build an avcC from the SPS/PPS carried inside a keyframe, or null if it has none. */
115
+ export function avcCFromSample(data) {
116
+ const nals = nalUnits(data);
117
+ const sps = nals.filter(n => (n[0] & 31) === 7);
118
+ const pps = nals.filter(n => (n[0] & 31) === 8);
119
+ if (!sps.length || !pps.length || sps[0].length < 4) return null;
120
+ const out = [1, sps[0][1], sps[0][2], sps[0][3], 0xff, 0xe0 | sps.length];
121
+ for (const s of sps) out.push(s.length >> 8, s.length & 255, ...s);
122
+ out.push(pps.length);
123
+ for (const p of pps) out.push(p.length >> 8, p.length & 255, ...p);
124
+ if (HIGH_PROFILES.has(sps[0][1])) {
125
+ const c = spsChroma(sps[0]);
126
+ out.push(0xfc | (c.chroma & 3), 0xf8 | (c.lumaBits & 7), 0xf8 | (c.chromaBits & 7), 0);
127
+ }
128
+ return Uint8Array.from(out);
129
+ }
130
+
131
+ /** Collapse a list of numbers into [count, value] runs (stts / ctts). */
132
+ function runs(list) {
133
+ const out = [];
134
+ for (const v of list) { const last = out[out.length - 1]; if (last && last[1] === v) last[0]++; else out.push([1, v]); }
135
+ return out;
136
+ }
137
+
31
138
  /**
32
- * @param {{ width: number, height: number, timescale?: number }} opts
139
+ * @param {{ width: number, height: number, timescale?: number,
140
+ * audio?: { codec: 'aac' | 'opus', sampleRate: number, numberOfChannels: number, bitrate?: number } }} opts
141
+ * audio: add a second track. Feed it with addAudioChunk() (AudioEncoder output) or addAudioSample().
33
142
  */
34
- export function createMp4Muxer({ width, height, timescale = 90000 }) {
35
- const samples = []; // { data, key, ts, dur } with ts/dur in microseconds
143
+ export function createMp4Muxer({ width, height, timescale = 90000, audio = null }) {
144
+ const samples = []; // video: { data, key, ts, dur }, ts/dur in microseconds
145
+ const audioSamples = []; // audio: { data, ts, dur }, microseconds
36
146
  let description = null;
147
+ let audioDescription = null;
148
+
149
+ /** Durations, sample entry and length of the audio track; null when there is none. */
150
+ function audioTrack() {
151
+ if (!audio || !audioSamples.length) return null;
152
+ const rate = audio.sampleRate;
153
+ const ch = audio.numberOfChannels;
154
+ const t0 = audioSamples[0].ts;
155
+ const starts = audioSamples.map(s => Math.round((s.ts - t0) * rate / 1e6));
156
+ const lastDur = Math.round(audioSamples[audioSamples.length - 1].dur * rate / 1e6) || (audio.codec === 'aac' ? 1024 : 960);
157
+ const durs = starts.map((s, i) => (i + 1 < starts.length ? Math.max(1, starts[i + 1] - s) : lastDur));
158
+ let entry;
159
+ if (audio.codec === 'aac') {
160
+ if (!audioDescription) throw new Error('The audio encoder gave no AAC decoder configuration (AudioSpecificConfig)');
161
+ entry = box('mp4a', a => {
162
+ a.zeros(6).u16(1).zeros(8).u16(ch).u16(16).u16(0).u16(0).u32(rate <= 0xffff ? rate * 65536 : 0);
163
+ a.bytes(esds(audioDescription, audio.bitrate || 128000));
164
+ });
165
+ } else {
166
+ // Opus in ISO BMFF: an 'Opus' sample entry (always 48 kHz) with a 'dOps' box (big-endian, unlike OpusHead)
167
+ const head = parseOpusHead(audioDescription);
168
+ const preSkip = head ? head.preSkip : 312;
169
+ entry = box('Opus', a => {
170
+ a.zeros(6).u16(1).zeros(8).u16(ch).u16(16).u16(0).u16(0).u32(48000 * 65536);
171
+ a.bytes(box('dOps', d => d.u8(0).u8(ch).u16(preSkip).u32(rate).u16(0).u8(0)));
172
+ });
173
+ }
174
+ return { rate, durs, entry, mediaDur: durs.reduce((a, b) => a + b, 0) };
175
+ }
37
176
 
38
177
  return {
39
178
  /** Use with a VideoEncoder `output` callback. */
40
179
  addChunk(chunk, meta) {
41
- if (meta && meta.decoderConfig && meta.decoderConfig.description && !description) {
42
- const d = meta.decoderConfig.description;
43
- description = new Uint8Array(d instanceof ArrayBuffer ? d : d.buffer.slice(d.byteOffset, d.byteOffset + d.byteLength));
44
- }
180
+ if (meta && meta.decoderConfig && meta.decoderConfig.description && !description) description = toBytes(meta.decoderConfig.description);
45
181
  const data = new Uint8Array(chunk.byteLength);
46
182
  chunk.copyTo(data);
47
183
  samples.push({ data, key: chunk.type === 'key', ts: chunk.timestamp, dur: chunk.duration || 0 });
@@ -51,9 +187,26 @@ export function createMp4Muxer({ width, height, timescale = 90000 }) {
51
187
  /** AVCDecoderConfigurationRecord ('avcC' contents). */
52
188
  setDescription(d) { description = d; },
53
189
 
190
+ /** Use with an AudioEncoder `output` callback (needs the `audio` option). */
191
+ addAudioChunk(chunk, meta) {
192
+ if (meta && meta.decoderConfig && meta.decoderConfig.description && !audioDescription) audioDescription = toBytes(meta.decoderConfig.description);
193
+ const data = new Uint8Array(chunk.byteLength);
194
+ chunk.copyTo(data);
195
+ audioSamples.push({ data, ts: chunk.timestamp, dur: chunk.duration || 0 });
196
+ },
197
+ /** Lower-level: add one encoded audio frame, in order. ts/dur in microseconds. */
198
+ addAudioSample(data, { timestamp, duration }) { audioSamples.push({ data, ts: timestamp, dur: duration || 0 }); },
199
+ /** AAC AudioSpecificConfig (for Opus, an optional OpusHead to take the pre-skip from). */
200
+ setAudioDescription(d) { audioDescription = d; },
201
+
54
202
  /** @returns {Uint8Array} the .mp4 file */
55
203
  finish() {
56
204
  if (!samples.length) throw new Error('No video frames were encoded');
205
+ // Trust the encoder's avcC only if it holds together; otherwise use the keyframe's own SPS/PPS.
206
+ if (!isValidAvcC(description)) {
207
+ const key = samples.find(s => s.key) || samples[0];
208
+ description = avcCFromSample(key.data) || description;
209
+ }
57
210
  if (!description) throw new Error('The encoder gave no H.264 decoder configuration (avcC)');
58
211
  // Samples arrive in decode order with presentation timestamps (B-frames make them non-monotonic).
59
212
  // Decode times are the sorted presentation times; 'ctts' holds the difference when there is one.
@@ -65,24 +218,35 @@ export function createMp4Muxer({ width, height, timescale = 90000 }) {
65
218
  const offsets = pts.map((p, i) => p - dts[i]);
66
219
  const hasOffsets = offsets.some(o => o !== 0);
67
220
  const mediaDur = durs.reduce((a, b) => a + b, 0);
68
- const movieDur = Math.round(mediaDur * 1000 / timescale);
221
+ const videoMs = Math.round(mediaDur * 1000 / timescale);
69
222
 
70
- const stts = [];
71
- for (const d of durs) { const last = stts[stts.length - 1]; if (last && last[1] === d) last[0]++; else stts.push([1, d]); }
72
- const ctts = [];
73
- for (const o of offsets) { const last = ctts[ctts.length - 1]; if (last && last[1] === o) last[0]++; else ctts.push([1, o]); }
223
+ const stts = runs(durs);
224
+ const ctts = runs(offsets);
74
225
  const keys = samples.map((s, i) => (s.key ? i + 1 : 0)).filter(Boolean);
75
- const dataSize = samples.reduce((a, s) => a + s.data.length, 0);
226
+ const videoSize = samples.reduce((a, s) => a + s.data.length, 0);
227
+
228
+ const at = audioTrack();
229
+ const audioMs = at ? Math.round(at.mediaDur * 1000 / at.rate) : 0;
230
+ const audioSize = at ? audioSamples.reduce((a, s) => a + s.data.length, 0) : 0;
231
+ const movieDur = Math.max(videoMs, audioMs);
232
+ const dataSize = videoSize + audioSize;
76
233
 
77
234
  const ftyp = box('ftyp', w => w.str('isom').u32(512).str('isom').str('iso2').str('avc1').str('mp41'));
78
- const buildMoov = chunkOffset => box('moov', w => {
235
+ const dinf = () => box('dinf', di => di.bytes(fullBox('dref', 0, 0, b => { b.u32(1); b.bytes(fullBox('url ', 0, 1, () => { })); })));
236
+ // one chunk per track: all video samples, then all audio samples
237
+ const stsc = n => fullBox('stsc', 0, 0, b => b.u32(1).u32(1).u32(n).u32(1));
238
+ const stsz = list => fullBox('stsz', 0, 0, b => { b.u32(0).u32(list.length); for (const s of list) b.u32(s.data.length); });
239
+ const stco = offset => fullBox('stco', 0, 0, b => b.u32(1).u32(offset));
240
+
241
+ const buildMoov = dataStart => box('moov', w => {
79
242
  w.bytes(fullBox('mvhd', 0, 0, b => {
80
243
  b.u32(0).u32(0).u32(1000).u32(movieDur).u32(0x00010000).u16(0x0100).zeros(10);
81
- MATRIX.forEach(v => b.u32(v)); b.zeros(24).u32(2);
244
+ MATRIX.forEach(v => b.u32(v)); b.zeros(24).u32(at ? 3 : 2); // next track ID
82
245
  }));
246
+ // ---- video
83
247
  w.bytes(box('trak', tr => {
84
248
  tr.bytes(fullBox('tkhd', 0, 3, b => {
85
- b.u32(0).u32(0).u32(1).u32(0).u32(movieDur).zeros(8).u16(0).u16(0).u16(0).u16(0);
249
+ b.u32(0).u32(0).u32(1).u32(0).u32(videoMs).zeros(8).u16(0).u16(0).u16(0).u16(0);
86
250
  MATRIX.forEach(v => b.u32(v)); b.u32(width << 16).u32(height << 16);
87
251
  }));
88
252
  tr.bytes(box('mdia', md => {
@@ -90,7 +254,7 @@ export function createMp4Muxer({ width, height, timescale = 90000 }) {
90
254
  md.bytes(fullBox('hdlr', 0, 0, b => b.u32(0).str('vide').zeros(12).str('VideoHandler').u8(0)));
91
255
  md.bytes(box('minf', mi => {
92
256
  mi.bytes(fullBox('vmhd', 0, 1, b => b.u16(0).u16(0).u16(0).u16(0)));
93
- mi.bytes(box('dinf', di => di.bytes(fullBox('dref', 0, 0, b => { b.u32(1); b.bytes(fullBox('url ', 0, 1, () => { })); }))));
257
+ mi.bytes(dinf());
94
258
  mi.bytes(box('stbl', st => {
95
259
  st.bytes(fullBox('stsd', 0, 0, b => {
96
260
  b.u32(1);
@@ -105,15 +269,39 @@ export function createMp4Muxer({ width, height, timescale = 90000 }) {
105
269
  // version 1: signed offsets, so negative composition offsets are allowed
106
270
  if (hasOffsets) st.bytes(fullBox('ctts', 1, 0, b => { b.u32(ctts.length); for (const [n, o] of ctts) b.u32(n).u32(o >>> 0); }));
107
271
  if (keys.length < samples.length) st.bytes(fullBox('stss', 0, 0, b => { b.u32(keys.length); for (const k of keys) b.u32(k); }));
108
- st.bytes(fullBox('stsc', 0, 0, b => b.u32(1).u32(1).u32(samples.length).u32(1)));
109
- st.bytes(fullBox('stsz', 0, 0, b => { b.u32(0).u32(samples.length); for (const s of samples) b.u32(s.data.length); }));
110
- st.bytes(fullBox('stco', 0, 0, b => b.u32(1).u32(chunkOffset)));
272
+ st.bytes(stsc(samples.length));
273
+ st.bytes(stsz(samples));
274
+ st.bytes(stco(dataStart));
275
+ }));
276
+ }));
277
+ }));
278
+ }));
279
+ // ---- audio
280
+ if (at) w.bytes(box('trak', tr => {
281
+ tr.bytes(fullBox('tkhd', 0, 3, b => {
282
+ // layer 0, alternate group 1, volume 1.0
283
+ b.u32(0).u32(0).u32(2).u32(0).u32(audioMs).zeros(8).u16(0).u16(1).u16(0x0100).u16(0);
284
+ MATRIX.forEach(v => b.u32(v)); b.u32(0).u32(0);
285
+ }));
286
+ tr.bytes(box('mdia', md => {
287
+ md.bytes(fullBox('mdhd', 0, 0, b => b.u32(0).u32(0).u32(at.rate).u32(at.mediaDur).u16(0x55c4).u16(0)));
288
+ md.bytes(fullBox('hdlr', 0, 0, b => b.u32(0).str('soun').zeros(12).str('SoundHandler').u8(0)));
289
+ md.bytes(box('minf', mi => {
290
+ mi.bytes(fullBox('smhd', 0, 0, b => b.u16(0).u16(0)));
291
+ mi.bytes(dinf());
292
+ mi.bytes(box('stbl', st => {
293
+ st.bytes(fullBox('stsd', 0, 0, b => { b.u32(1); b.bytes(at.entry); }));
294
+ const r = runs(at.durs);
295
+ st.bytes(fullBox('stts', 0, 0, b => { b.u32(r.length); for (const [n, d] of r) b.u32(n).u32(d); }));
296
+ st.bytes(stsc(audioSamples.length));
297
+ st.bytes(stsz(audioSamples));
298
+ st.bytes(stco(dataStart + videoSize));
111
299
  }));
112
300
  }));
113
301
  }));
114
302
  }));
115
303
  });
116
- // the moov size doesn't depend on the offset value, so measure once and build again
304
+ // the moov size doesn't depend on the offset values, so measure once and build again
117
305
  const moovSize = buildMoov(0).length;
118
306
  const offset = ftyp.length + moovSize + 8;
119
307
  if (offset + dataSize > 0xffffffff) throw new Error('Video is larger than 4 GB');
@@ -125,6 +313,7 @@ export function createMp4Muxer({ width, height, timescale = 90000 }) {
125
313
  out.set(ENC.encode('mdat'), ftyp.length + moov.length + 4);
126
314
  let o = offset;
127
315
  for (const s of samples) { out.set(s.data, o); o += s.data.length; }
316
+ if (at) for (const s of audioSamples) { out.set(s.data, o); o += s.data.length; }
128
317
  return out;
129
318
  },
130
319
  };
@@ -1,5 +1,8 @@
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.
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';
3
6
 
4
7
  const ENC = new TextEncoder();
5
8
 
@@ -41,25 +44,31 @@ const ID = {
41
44
  Info: 0x1549A966, TimestampScale: 0x2AD7B1, Duration: 0x4489, MuxingApp: 0x4D80, WritingApp: 0x5741,
42
45
  Tracks: 0x1654AE6B, TrackEntry: 0xAE, TrackNumber: 0xD7, TrackUID: 0x73C5, TrackType: 0x83, FlagLacing: 0x9C,
43
46
  CodecID: 0x86, CodecPrivate: 0x63A2, DefaultDuration: 0x23E383, Video: 0xE0, PixelWidth: 0xB0, PixelHeight: 0xBA,
47
+ CodecDelay: 0x56AA, SeekPreRoll: 0x56BB, Audio: 0xE1, SamplingFrequency: 0xB5, Channels: 0x9F,
44
48
  Cluster: 0x1F43B675, Timestamp: 0xE7, SimpleBlock: 0xA3,
45
49
  Cues: 0x1C53BB6B, CuePoint: 0xBB, CueTime: 0xB3, CueTrackPositions: 0xB7, CueTrack: 0xF7, CueClusterPosition: 0xF1,
46
50
  };
47
51
 
48
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));
49
56
 
50
57
  /**
51
- * @param {{ width: number, height: number, codec: 'vp8' | 'vp9' | 'av1', fps?: number }} opts
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().
52
61
  */
53
- export function createWebmMuxer({ width, height, codec = 'vp9', fps = 0 }) {
54
- const frames = []; // { data, key, ms }
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 }
55
66
  let codecPrivate = null;
67
+ let audioPrivate = null;
56
68
 
57
69
  return {
58
70
  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
- }
71
+ if (meta && meta.decoderConfig && meta.decoderConfig.description && !codecPrivate) codecPrivate = toBytes(meta.decoderConfig.description);
63
72
  const data = new Uint8Array(chunk.byteLength);
64
73
  chunk.copyTo(data);
65
74
  frames.push({ data, key: chunk.type === 'key', ms: chunk.timestamp / 1000 });
@@ -67,37 +76,69 @@ export function createWebmMuxer({ width, height, codec = 'vp9', fps = 0 }) {
67
76
  /** Lower-level: add one encoded frame. timestamp in microseconds. */
68
77
  addFrame(data, { key, timestamp }) { frames.push({ data, key: !!key, ms: timestamp / 1000 }); },
69
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
+
70
89
  /** @returns {Uint8Array} the .webm file */
71
90
  finish() {
72
91
  if (!frames.length) throw new Error('No video frames were encoded');
92
+ const hasAudio = !!(audio && audioFrames.length);
73
93
  // VP8/VP9/AV1 frames from WebCodecs arrive in decode order, which is the order they're stored in
74
94
  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;
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)); }
76
97
 
77
98
  const info = master(ID.Info, [
78
99
  uint(ID.TimestampScale, 1000000), // 1 ms
79
100
  float64(ID.Duration, duration),
80
101
  str(ID.MuxingApp, 'image-to-pixel'), str(ID.WritingApp, 'image-to-pixel'),
81
102
  ]);
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),
103
+ const trackEntries = [master(ID.TrackEntry, [
104
+ uint(ID.TrackNumber, VIDEO), uint(ID.TrackUID, VIDEO), uint(ID.TrackType, 1), uint(ID.FlagLacing, 0),
84
105
  str(ID.CodecID, CODEC_IDS[codec] || codec),
85
106
  ...(codecPrivate ? [el(ID.CodecPrivate, codecPrivate)] : []),
86
107
  uint(ID.DefaultDuration, Math.round(frameMs * 1e6)),
87
108
  master(ID.Video, [uint(ID.PixelWidth, width), uint(ID.PixelHeight, height)]),
88
- ])]);
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);
89
129
 
90
- // clusters: a new one at each keyframe, and before the 16-bit relative timestamp overflows
130
+ // clusters: a new one at each video keyframe, and before the 16-bit relative timestamp overflows
91
131
  const clusters = [];
92
132
  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); }
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); }
96
137
  const head = new Uint8Array(4);
97
- head[0] = 0x81; // track 1
138
+ head[0] = 0x80 | b.track; // track number as a 1-byte EBML vint
98
139
  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])));
140
+ head[3] = b.key ? 0x80 : 0x00;
141
+ cur.blocks.push(el(ID.SimpleBlock, concat([head, b.data])));
101
142
  }
102
143
  const clusterBytes = clusters.map(c => master(ID.Cluster, [uint(ID.Timestamp, c.start), ...c.blocks]));
103
144
 
@@ -110,10 +151,10 @@ export function createWebmMuxer({ width, height, codec = 'vp9', fps = 0 }) {
110
151
  let pos = posTracks + tracks.length;
111
152
  const cuePoints = [];
112
153
  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)])]));
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)])]));
114
155
  pos += clusterBytes[i].length;
115
156
  });
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)])])]);
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)])])]);
117
158
  const posCues = pos;
118
159
 
119
160
  const header = master(ID.EBML, [