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.
- package/LICENSE +21 -21
- package/README.md +2 -2
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +60 -60
- package/dist/editor.esm.js.map +4 -4
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- 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 +2 -2
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +2 -2
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +2 -2
- 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/color.js +69 -69
- package/lib/core/cpu/pipeline.js +21 -21
- package/lib/core/cpu/stages.js +227 -227
- package/lib/core/dither/bluenoise-data.js +3 -3
- package/lib/core/dither/kernels.js +27 -27
- package/lib/core/dither/matrices.js +95 -95
- package/lib/core/engine.js +158 -158
- package/lib/core/export/canvas.js +22 -22
- package/lib/core/export/crc32.js +15 -15
- package/lib/core/export/gif.js +153 -153
- package/lib/core/export/png.js +94 -94
- package/lib/core/export/scale.js +103 -103
- package/lib/core/export/sheet.js +95 -95
- package/lib/core/export/zip.js +55 -55
- package/lib/core/gpu/backend.js +159 -159
- package/lib/core/gpu/shaders.js +258 -258
- package/lib/core/palette/builtin.js +36 -36
- package/lib/core/palette/extract.js +69 -69
- package/lib/core/palette/lospec.js +24 -24
- package/lib/core/palette/parse.js +112 -112
- package/lib/core/plan.js +58 -58
- package/lib/core/presets.js +42 -42
- package/lib/core/settings.js +274 -274
- package/lib/core/source.js +89 -89
- package/lib/core/video/audio.js +159 -0
- package/lib/core/video/frames.js +38 -0
- package/lib/core/video/mux-mp4.js +213 -24
- package/lib/core/video/mux-webm.js +63 -22
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +43 -3
- package/lib/editor/icons.js +4 -0
- package/lib/editor/index.js +218 -9
- package/lib/editor/inspector.js +19 -4
- package/lib/editor/library.js +8 -2
- package/lib/editor/persist.js +18 -18
- package/lib/editor/samples.js +90 -90
- package/lib/editor/stage.js +239 -239
- package/lib/editor/store.js +80 -80
- package/lib/editor/styles/editor.css +34 -1
- package/lib/editor/styles/fonts/README.md +6 -6
- package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
- package/lib/editor/styles/tokens.css +89 -89
- package/lib/editor/ui.js +97 -97
- package/lib/editor/workspace.js +62 -0
- package/lib/index.js +31 -29
- package/lib/video.js +37 -7
- package/package.json +1 -1
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +61 -5
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
// Minimal MP4 (ISO BMFF) muxer for one H.264 video track
|
|
2
|
-
// written with the 'moov' box first ("fast start"), so the file
|
|
3
|
-
//
|
|
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
|
|
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 = [];
|
|
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
|
|
221
|
+
const videoMs = Math.round(mediaDur * 1000 / timescale);
|
|
69
222
|
|
|
70
|
-
const stts =
|
|
71
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
109
|
-
st.bytes(
|
|
110
|
-
st.bytes(
|
|
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
|
|
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
|
|
2
|
-
// a SeekHead and Cues so players can seek.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
83
|
-
uint(ID.TrackNumber,
|
|
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
|
|
94
|
-
const t = Math.round(
|
|
95
|
-
|
|
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] =
|
|
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] =
|
|
100
|
-
cur.blocks.push(el(ID.SimpleBlock, concat([head,
|
|
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,
|
|
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,
|
|
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, [
|