image-to-pixel 3.0.0 → 3.0.1
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 +69 -69
- package/dist/editor.css.map +1 -1
- package/dist/editor.esm.js +1 -1
- package/dist/editor.esm.js.map +1 -1
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- package/dist/image-to-pixel.bundle.esm.js +13 -13
- package/dist/image-to-pixel.bundle.esm.js.map +2 -2
- 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 +2 -2
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.map +1 -1
- 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/frames.js +144 -144
- package/lib/core/video/mux-mp4.js +131 -131
- package/lib/core/video/mux-webm.js +127 -127
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +185 -185
- package/lib/editor/icons.js +61 -61
- package/lib/editor/index.js +793 -793
- package/lib/editor/inspector.js +328 -328
- package/lib/editor/library.js +182 -182
- 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 +385 -385
- 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/index.js +3 -1
- package/lib/video.js +92 -92
- package/package.json +92 -92
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +43 -43
|
@@ -1,131 +1,131 @@
|
|
|
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
|
|
4
|
-
// avc.format 'avc') plus the AVCDecoderConfigurationRecord ('description').
|
|
5
|
-
|
|
6
|
-
const ENC = new TextEncoder();
|
|
7
|
-
|
|
8
|
-
class Writer {
|
|
9
|
-
constructor() { this.parts = []; this.size = 0; }
|
|
10
|
-
u8(v) { return this.bytes([v & 255]); }
|
|
11
|
-
u16(v) { return this.bytes([(v >>> 8) & 255, v & 255]); }
|
|
12
|
-
u32(v) { return this.bytes([(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255]); }
|
|
13
|
-
str(s) { return this.bytes(ENC.encode(s)); }
|
|
14
|
-
bytes(b) { const a = b instanceof Uint8Array ? b : Uint8Array.from(b); this.parts.push(a); this.size += a.length; return this; }
|
|
15
|
-
zeros(n) { return this.bytes(new Uint8Array(n)); }
|
|
16
|
-
out() { const r = new Uint8Array(this.size); let o = 0; for (const p of this.parts) { r.set(p, o); o += p.length; } return r; }
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** A box: 4-byte size, 4-char type, then the body produced by fill(w). */
|
|
20
|
-
function box(type, fill) {
|
|
21
|
-
const w = new Writer();
|
|
22
|
-
fill(w);
|
|
23
|
-
const body = w.out();
|
|
24
|
-
const head = new Writer().u32(body.length + 8).str(type).out();
|
|
25
|
-
const r = new Uint8Array(body.length + 8); r.set(head); r.set(body, 8);
|
|
26
|
-
return r;
|
|
27
|
-
}
|
|
28
|
-
const fullBox = (type, version, flags, fill) => box(type, w => { w.u8(version).u8(flags >> 16).u8(flags >> 8).u8(flags); fill(w); });
|
|
29
|
-
const MATRIX = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000];
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @param {{ width: number, height: number, timescale?: number }} opts
|
|
33
|
-
*/
|
|
34
|
-
export function createMp4Muxer({ width, height, timescale = 90000 }) {
|
|
35
|
-
const samples = []; // { data, key, ts, dur } with ts/dur in microseconds
|
|
36
|
-
let description = null;
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
/** Use with a VideoEncoder `output` callback. */
|
|
40
|
-
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
|
-
}
|
|
45
|
-
const data = new Uint8Array(chunk.byteLength);
|
|
46
|
-
chunk.copyTo(data);
|
|
47
|
-
samples.push({ data, key: chunk.type === 'key', ts: chunk.timestamp, dur: chunk.duration || 0 });
|
|
48
|
-
},
|
|
49
|
-
/** Lower-level: add an AVCC sample directly. ts/dur in microseconds. */
|
|
50
|
-
addSample(data, { key, timestamp, duration }) { samples.push({ data, key: !!key, ts: timestamp, dur: duration || 0 }); },
|
|
51
|
-
/** AVCDecoderConfigurationRecord ('avcC' contents). */
|
|
52
|
-
setDescription(d) { description = d; },
|
|
53
|
-
|
|
54
|
-
/** @returns {Uint8Array} the .mp4 file */
|
|
55
|
-
finish() {
|
|
56
|
-
if (!samples.length) throw new Error('No video frames were encoded');
|
|
57
|
-
if (!description) throw new Error('The encoder gave no H.264 decoder configuration (avcC)');
|
|
58
|
-
// Samples arrive in decode order with presentation timestamps (B-frames make them non-monotonic).
|
|
59
|
-
// Decode times are the sorted presentation times; 'ctts' holds the difference when there is one.
|
|
60
|
-
const t = us => Math.round(us * timescale / 1e6);
|
|
61
|
-
const pts = samples.map(s => t(s.ts));
|
|
62
|
-
const dts = pts.slice().sort((a, b) => a - b);
|
|
63
|
-
const lastDur = t(samples[samples.length - 1].dur) || (dts.length > 1 ? dts[dts.length - 1] - dts[dts.length - 2] : t(33333));
|
|
64
|
-
const durs = dts.map((d, i) => (i + 1 < dts.length ? dts[i + 1] - d : lastDur));
|
|
65
|
-
const offsets = pts.map((p, i) => p - dts[i]);
|
|
66
|
-
const hasOffsets = offsets.some(o => o !== 0);
|
|
67
|
-
const mediaDur = durs.reduce((a, b) => a + b, 0);
|
|
68
|
-
const movieDur = Math.round(mediaDur * 1000 / timescale);
|
|
69
|
-
|
|
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]); }
|
|
74
|
-
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);
|
|
76
|
-
|
|
77
|
-
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 => {
|
|
79
|
-
w.bytes(fullBox('mvhd', 0, 0, b => {
|
|
80
|
-
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);
|
|
82
|
-
}));
|
|
83
|
-
w.bytes(box('trak', tr => {
|
|
84
|
-
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);
|
|
86
|
-
MATRIX.forEach(v => b.u32(v)); b.u32(width << 16).u32(height << 16);
|
|
87
|
-
}));
|
|
88
|
-
tr.bytes(box('mdia', md => {
|
|
89
|
-
md.bytes(fullBox('mdhd', 0, 0, b => b.u32(0).u32(0).u32(timescale).u32(mediaDur).u16(0x55c4).u16(0)));
|
|
90
|
-
md.bytes(fullBox('hdlr', 0, 0, b => b.u32(0).str('vide').zeros(12).str('VideoHandler').u8(0)));
|
|
91
|
-
md.bytes(box('minf', mi => {
|
|
92
|
-
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, () => { })); }))));
|
|
94
|
-
mi.bytes(box('stbl', st => {
|
|
95
|
-
st.bytes(fullBox('stsd', 0, 0, b => {
|
|
96
|
-
b.u32(1);
|
|
97
|
-
b.bytes(box('avc1', a => {
|
|
98
|
-
a.zeros(6).u16(1).zeros(16).u16(width).u16(height).u32(0x00480000).u32(0x00480000).u32(0).u16(1);
|
|
99
|
-
const name = ENC.encode('image-to-pixel').slice(0, 31);
|
|
100
|
-
a.u8(name.length).bytes(name).zeros(31 - name.length).u16(0x0018).u16(0xffff);
|
|
101
|
-
a.bytes(box('avcC', c => c.bytes(description)));
|
|
102
|
-
}));
|
|
103
|
-
}));
|
|
104
|
-
st.bytes(fullBox('stts', 0, 0, b => { b.u32(stts.length); for (const [n, d] of stts) b.u32(n).u32(d); }));
|
|
105
|
-
// version 1: signed offsets, so negative composition offsets are allowed
|
|
106
|
-
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
|
-
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)));
|
|
111
|
-
}));
|
|
112
|
-
}));
|
|
113
|
-
}));
|
|
114
|
-
}));
|
|
115
|
-
});
|
|
116
|
-
// the moov size doesn't depend on the offset value, so measure once and build again
|
|
117
|
-
const moovSize = buildMoov(0).length;
|
|
118
|
-
const offset = ftyp.length + moovSize + 8;
|
|
119
|
-
if (offset + dataSize > 0xffffffff) throw new Error('Video is larger than 4 GB');
|
|
120
|
-
const moov = buildMoov(offset);
|
|
121
|
-
const out = new Uint8Array(offset + dataSize);
|
|
122
|
-
out.set(ftyp, 0); out.set(moov, ftyp.length);
|
|
123
|
-
const dv = new DataView(out.buffer);
|
|
124
|
-
dv.setUint32(ftyp.length + moov.length, dataSize + 8);
|
|
125
|
-
out.set(ENC.encode('mdat'), ftyp.length + moov.length + 4);
|
|
126
|
-
let o = offset;
|
|
127
|
-
for (const s of samples) { out.set(s.data, o); o += s.data.length; }
|
|
128
|
-
return out;
|
|
129
|
-
},
|
|
130
|
-
};
|
|
131
|
-
}
|
|
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
|
|
4
|
+
// avc.format 'avc') plus the AVCDecoderConfigurationRecord ('description').
|
|
5
|
+
|
|
6
|
+
const ENC = new TextEncoder();
|
|
7
|
+
|
|
8
|
+
class Writer {
|
|
9
|
+
constructor() { this.parts = []; this.size = 0; }
|
|
10
|
+
u8(v) { return this.bytes([v & 255]); }
|
|
11
|
+
u16(v) { return this.bytes([(v >>> 8) & 255, v & 255]); }
|
|
12
|
+
u32(v) { return this.bytes([(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255]); }
|
|
13
|
+
str(s) { return this.bytes(ENC.encode(s)); }
|
|
14
|
+
bytes(b) { const a = b instanceof Uint8Array ? b : Uint8Array.from(b); this.parts.push(a); this.size += a.length; return this; }
|
|
15
|
+
zeros(n) { return this.bytes(new Uint8Array(n)); }
|
|
16
|
+
out() { const r = new Uint8Array(this.size); let o = 0; for (const p of this.parts) { r.set(p, o); o += p.length; } return r; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A box: 4-byte size, 4-char type, then the body produced by fill(w). */
|
|
20
|
+
function box(type, fill) {
|
|
21
|
+
const w = new Writer();
|
|
22
|
+
fill(w);
|
|
23
|
+
const body = w.out();
|
|
24
|
+
const head = new Writer().u32(body.length + 8).str(type).out();
|
|
25
|
+
const r = new Uint8Array(body.length + 8); r.set(head); r.set(body, 8);
|
|
26
|
+
return r;
|
|
27
|
+
}
|
|
28
|
+
const fullBox = (type, version, flags, fill) => box(type, w => { w.u8(version).u8(flags >> 16).u8(flags >> 8).u8(flags); fill(w); });
|
|
29
|
+
const MATRIX = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x40000000];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {{ width: number, height: number, timescale?: number }} opts
|
|
33
|
+
*/
|
|
34
|
+
export function createMp4Muxer({ width, height, timescale = 90000 }) {
|
|
35
|
+
const samples = []; // { data, key, ts, dur } with ts/dur in microseconds
|
|
36
|
+
let description = null;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
/** Use with a VideoEncoder `output` callback. */
|
|
40
|
+
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
|
+
}
|
|
45
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
46
|
+
chunk.copyTo(data);
|
|
47
|
+
samples.push({ data, key: chunk.type === 'key', ts: chunk.timestamp, dur: chunk.duration || 0 });
|
|
48
|
+
},
|
|
49
|
+
/** Lower-level: add an AVCC sample directly. ts/dur in microseconds. */
|
|
50
|
+
addSample(data, { key, timestamp, duration }) { samples.push({ data, key: !!key, ts: timestamp, dur: duration || 0 }); },
|
|
51
|
+
/** AVCDecoderConfigurationRecord ('avcC' contents). */
|
|
52
|
+
setDescription(d) { description = d; },
|
|
53
|
+
|
|
54
|
+
/** @returns {Uint8Array} the .mp4 file */
|
|
55
|
+
finish() {
|
|
56
|
+
if (!samples.length) throw new Error('No video frames were encoded');
|
|
57
|
+
if (!description) throw new Error('The encoder gave no H.264 decoder configuration (avcC)');
|
|
58
|
+
// Samples arrive in decode order with presentation timestamps (B-frames make them non-monotonic).
|
|
59
|
+
// Decode times are the sorted presentation times; 'ctts' holds the difference when there is one.
|
|
60
|
+
const t = us => Math.round(us * timescale / 1e6);
|
|
61
|
+
const pts = samples.map(s => t(s.ts));
|
|
62
|
+
const dts = pts.slice().sort((a, b) => a - b);
|
|
63
|
+
const lastDur = t(samples[samples.length - 1].dur) || (dts.length > 1 ? dts[dts.length - 1] - dts[dts.length - 2] : t(33333));
|
|
64
|
+
const durs = dts.map((d, i) => (i + 1 < dts.length ? dts[i + 1] - d : lastDur));
|
|
65
|
+
const offsets = pts.map((p, i) => p - dts[i]);
|
|
66
|
+
const hasOffsets = offsets.some(o => o !== 0);
|
|
67
|
+
const mediaDur = durs.reduce((a, b) => a + b, 0);
|
|
68
|
+
const movieDur = Math.round(mediaDur * 1000 / timescale);
|
|
69
|
+
|
|
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]); }
|
|
74
|
+
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);
|
|
76
|
+
|
|
77
|
+
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 => {
|
|
79
|
+
w.bytes(fullBox('mvhd', 0, 0, b => {
|
|
80
|
+
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);
|
|
82
|
+
}));
|
|
83
|
+
w.bytes(box('trak', tr => {
|
|
84
|
+
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);
|
|
86
|
+
MATRIX.forEach(v => b.u32(v)); b.u32(width << 16).u32(height << 16);
|
|
87
|
+
}));
|
|
88
|
+
tr.bytes(box('mdia', md => {
|
|
89
|
+
md.bytes(fullBox('mdhd', 0, 0, b => b.u32(0).u32(0).u32(timescale).u32(mediaDur).u16(0x55c4).u16(0)));
|
|
90
|
+
md.bytes(fullBox('hdlr', 0, 0, b => b.u32(0).str('vide').zeros(12).str('VideoHandler').u8(0)));
|
|
91
|
+
md.bytes(box('minf', mi => {
|
|
92
|
+
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, () => { })); }))));
|
|
94
|
+
mi.bytes(box('stbl', st => {
|
|
95
|
+
st.bytes(fullBox('stsd', 0, 0, b => {
|
|
96
|
+
b.u32(1);
|
|
97
|
+
b.bytes(box('avc1', a => {
|
|
98
|
+
a.zeros(6).u16(1).zeros(16).u16(width).u16(height).u32(0x00480000).u32(0x00480000).u32(0).u16(1);
|
|
99
|
+
const name = ENC.encode('image-to-pixel').slice(0, 31);
|
|
100
|
+
a.u8(name.length).bytes(name).zeros(31 - name.length).u16(0x0018).u16(0xffff);
|
|
101
|
+
a.bytes(box('avcC', c => c.bytes(description)));
|
|
102
|
+
}));
|
|
103
|
+
}));
|
|
104
|
+
st.bytes(fullBox('stts', 0, 0, b => { b.u32(stts.length); for (const [n, d] of stts) b.u32(n).u32(d); }));
|
|
105
|
+
// version 1: signed offsets, so negative composition offsets are allowed
|
|
106
|
+
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
|
+
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)));
|
|
111
|
+
}));
|
|
112
|
+
}));
|
|
113
|
+
}));
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
// the moov size doesn't depend on the offset value, so measure once and build again
|
|
117
|
+
const moovSize = buildMoov(0).length;
|
|
118
|
+
const offset = ftyp.length + moovSize + 8;
|
|
119
|
+
if (offset + dataSize > 0xffffffff) throw new Error('Video is larger than 4 GB');
|
|
120
|
+
const moov = buildMoov(offset);
|
|
121
|
+
const out = new Uint8Array(offset + dataSize);
|
|
122
|
+
out.set(ftyp, 0); out.set(moov, ftyp.length);
|
|
123
|
+
const dv = new DataView(out.buffer);
|
|
124
|
+
dv.setUint32(ftyp.length + moov.length, dataSize + 8);
|
|
125
|
+
out.set(ENC.encode('mdat'), ftyp.length + moov.length + 4);
|
|
126
|
+
let o = offset;
|
|
127
|
+
for (const s of samples) { out.set(s.data, o); o += s.data.length; }
|
|
128
|
+
return out;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -1,127 +1,127 @@
|
|
|
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. 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
|
+
}
|