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