@synapse-sdk/media 0.0.0-stage → 0.3.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/README.md +2 -2
- package/dist/annexb.d.ts +93 -0
- package/dist/annexb.js +227 -0
- package/dist/camera.d.ts +12 -0
- package/dist/camera.js +43 -0
- package/dist/canvas.d.ts +19 -0
- package/dist/canvas.js +63 -0
- package/dist/decoder.d.ts +57 -0
- package/dist/decoder.js +131 -0
- package/dist/encoder.d.ts +74 -0
- package/dist/encoder.js +173 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +16 -0
- package/dist/react.d.ts +24 -0
- package/dist/react.js +48 -0
- package/dist/reinsert.d.ts +24 -0
- package/dist/reinsert.js +41 -0
- package/dist/sync.d.ts +60 -0
- package/dist/sync.js +86 -0
- package/dist/video.d.ts +124 -0
- package/dist/video.js +250 -0
- package/dist/webcodecs.d.ts +10 -0
- package/dist/webcodecs.js +39 -0
- package/package.json +42 -4
package/README.md
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @synapse-sdk/media
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Part of the Synapse SDK for JavaScript. Documentation: the repository.
|
package/dist/annexb.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { MediaFrame } from "@synapse-sdk/core";
|
|
2
|
+
/** NAL unit types this package cares about (H.264 Table 7-1). */
|
|
3
|
+
export declare const NAL_TYPE: {
|
|
4
|
+
readonly SLICE: 1;
|
|
5
|
+
readonly SLICE_DPA: 2;
|
|
6
|
+
readonly SLICE_DPB: 3;
|
|
7
|
+
readonly SLICE_DPC: 4;
|
|
8
|
+
readonly IDR: 5;
|
|
9
|
+
readonly SEI: 6;
|
|
10
|
+
readonly SPS: 7;
|
|
11
|
+
readonly PPS: 8;
|
|
12
|
+
readonly AUD: 9;
|
|
13
|
+
};
|
|
14
|
+
/** SEI payload type of a recovery point (H.264 D.1.8). */
|
|
15
|
+
export declare const SEI_RECOVERY_POINT = 6;
|
|
16
|
+
/** One NAL unit inside an Annex-B buffer. */
|
|
17
|
+
export interface Nal {
|
|
18
|
+
/** Offset of the start code in the buffer. */
|
|
19
|
+
offset: number;
|
|
20
|
+
/** 3 or 4 (bytes of `00 00 01` / `00 00 00 01`). */
|
|
21
|
+
startCodeLen: number;
|
|
22
|
+
/** The NAL unit bytes after the start code: header byte, then the payload (still escaped). A view, not a copy. */
|
|
23
|
+
bytes: Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
/** `nal_unit_type` (low 5 bits of the header byte); 0 for an empty NAL. */
|
|
26
|
+
export declare function nalType(n: Nal): number;
|
|
27
|
+
/** Payload after the 1-byte NAL header (escaped RBSP). */
|
|
28
|
+
export declare function nalPayload(n: Nal): Uint8Array;
|
|
29
|
+
/** A VCL NAL type: a coded slice (types 1-5). */
|
|
30
|
+
export declare function isVclType(t: number): boolean;
|
|
31
|
+
export declare function nalIsVcl(n: Nal): boolean;
|
|
32
|
+
/** Start code plus NAL unit, as it sits in the stream (a view). */
|
|
33
|
+
export declare function nalWithStartCode(buf: Uint8Array, n: Nal): Uint8Array;
|
|
34
|
+
/** Next Annex-B start code (`00 00 01` or `00 00 00 01`) at or after `from`: `[offset, len]`. */
|
|
35
|
+
export declare function findStartCode(buf: Uint8Array, from: number): [offset: number, len: number] | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* The NAL units of an Annex-B buffer, in order. Bytes before the first start code are skipped;
|
|
38
|
+
* trailing zero bytes (`trailing_zero_8bits`) stay attached to the NAL before them, which is
|
|
39
|
+
* harmless for type/flag decisions and keeps the bytes of a walked AU identical to the input.
|
|
40
|
+
*/
|
|
41
|
+
export declare function nalUnits(buf: Uint8Array): Nal[];
|
|
42
|
+
/** The SPS + PPS NAL units of an access unit (with their start codes, in stream order), if it carries any. Defined ⇒ the AU deserves the `CONFIG` flag. */
|
|
43
|
+
export declare function auParamSets(au: Uint8Array): Uint8Array | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* The sync points an Annex-B access unit offers.
|
|
46
|
+
* - `idr`: an IDR slice (NAL type 5) is present → instantaneous start point (`KEY`).
|
|
47
|
+
* - `recovery`: a recovery point SEI (NAL 6, payload type 6) is present → gradual start point,
|
|
48
|
+
* clean one refresh period later (`RECOVERY`; x264 and nvenc both emit it at each intra-refresh sweep).
|
|
49
|
+
* Decided from the bitstream rather than the encoder's keyframe flag, which libx264 raises for
|
|
50
|
+
* both kinds and nvenc only for IDRs.
|
|
51
|
+
*/
|
|
52
|
+
export declare function auSyncPoints(au: Uint8Array): {
|
|
53
|
+
idr: boolean;
|
|
54
|
+
recovery: boolean;
|
|
55
|
+
};
|
|
56
|
+
/** An IDR is present: the AU is a keyframe in the `KEY` sense. */
|
|
57
|
+
export declare function isKeyframe(au: Uint8Array): boolean;
|
|
58
|
+
/** The AU carries at least one coded slice (otherwise it is parameter sets / SEI only). */
|
|
59
|
+
export declare function hasVcl(au: Uint8Array): boolean;
|
|
60
|
+
/** Whether an SEI NAL payload (after the NAL header byte) contains a recovery point message (payload type 6). */
|
|
61
|
+
export declare function seiHasRecoveryPoint(payload: Uint8Array): boolean;
|
|
62
|
+
/** The payload types of the SEI messages in an SEI NAL payload (D.1.1), in order. Emulation-prevention bytes are removed first. */
|
|
63
|
+
export declare function seiPayloadTypes(payload: Uint8Array): number[];
|
|
64
|
+
/** Strip H.264 emulation-prevention bytes (the `03` in `00 00 03`), giving the RBSP. */
|
|
65
|
+
export declare function rbspUnescape(nal: Uint8Array): Uint8Array;
|
|
66
|
+
/** The SPS of an AU (NAL bytes without start code), if present. */
|
|
67
|
+
export declare function findSps(au: Uint8Array): Uint8Array | undefined;
|
|
68
|
+
/** The WebCodecs / RFC 6381 codec string for an AU that carries an SPS: `avc1.` + profile_idc + constraint flags + level_idc as hex (e.g. `avc1.42c01e`). */
|
|
69
|
+
export declare function codecStringFromSps(au: Uint8Array): string | undefined;
|
|
70
|
+
/** `parts` joined into one new buffer (a single part is copied too, so the result never aliases the input). */
|
|
71
|
+
export declare function concatBytes(parts: readonly Uint8Array[]): Uint8Array;
|
|
72
|
+
/**
|
|
73
|
+
* Splits a continuous Annex-B byte stream (a file, an encoder pipe) into access units. Rule
|
|
74
|
+
* (dylithium's reader loop): an AU ends with its coded slice; the next NAL of any kind starts a
|
|
75
|
+
* new AU, so non-VCL NALs (SPS, PPS, SEI, AUD) are attached to the picture that follows them.
|
|
76
|
+
* Sufficient for one-slice-per-picture streams (x264 `slices=1`, nvenc, WebCodecs), which is what
|
|
77
|
+
* the encoder options enforce.
|
|
78
|
+
*/
|
|
79
|
+
export declare class AuSplitter {
|
|
80
|
+
#private;
|
|
81
|
+
/** Feeds bytes; returns every access unit completed by them (in order). */
|
|
82
|
+
push(bytes: Uint8Array): Uint8Array[];
|
|
83
|
+
/** End of stream: the last NAL (whose end no start code marks) closes the last AU, or starts one more if the current AU already has its slice. */
|
|
84
|
+
finish(): Uint8Array[];
|
|
85
|
+
}
|
|
86
|
+
/** Splits a whole Annex-B buffer into access units (see {@link AuSplitter}). */
|
|
87
|
+
export declare function splitAccessUnits(stream: Uint8Array): Uint8Array[];
|
|
88
|
+
/**
|
|
89
|
+
* A `MediaFrame` for an H.264 Annex-B access unit with `key`/`config`/`recovery` derived from the
|
|
90
|
+
* NAL units (IDR present / SPS+PPS present / recovery-point SEI present), so the header flags
|
|
91
|
+
* always agree with the bytes (R27 §2 publisher rule). `tUs` is the capture time (0 = stamp at publish).
|
|
92
|
+
*/
|
|
93
|
+
export declare function mediaFrameFromAnnexb(au: Uint8Array, tUs?: number): MediaFrame;
|
package/dist/annexb.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/** NAL unit types this package cares about (H.264 Table 7-1). */
|
|
2
|
+
export const NAL_TYPE = { SLICE: 1, SLICE_DPA: 2, SLICE_DPB: 3, SLICE_DPC: 4, IDR: 5, SEI: 6, SPS: 7, PPS: 8, AUD: 9 };
|
|
3
|
+
/** SEI payload type of a recovery point (H.264 D.1.8). */
|
|
4
|
+
export const SEI_RECOVERY_POINT = 6;
|
|
5
|
+
/** `nal_unit_type` (low 5 bits of the header byte); 0 for an empty NAL. */
|
|
6
|
+
export function nalType(n) { return n.bytes.length ? n.bytes[0] & 0x1f : 0; }
|
|
7
|
+
/** Payload after the 1-byte NAL header (escaped RBSP). */
|
|
8
|
+
export function nalPayload(n) { return n.bytes.subarray(1); }
|
|
9
|
+
/** A VCL NAL type: a coded slice (types 1-5). */
|
|
10
|
+
export function isVclType(t) { return t >= NAL_TYPE.SLICE && t <= NAL_TYPE.IDR; }
|
|
11
|
+
export function nalIsVcl(n) { return isVclType(nalType(n)); }
|
|
12
|
+
/** Start code plus NAL unit, as it sits in the stream (a view). */
|
|
13
|
+
export function nalWithStartCode(buf, n) { return buf.subarray(n.offset, n.offset + n.startCodeLen + n.bytes.length); }
|
|
14
|
+
/** Next Annex-B start code (`00 00 01` or `00 00 00 01`) at or after `from`: `[offset, len]`. */
|
|
15
|
+
export function findStartCode(buf, from) {
|
|
16
|
+
const n = buf.length;
|
|
17
|
+
for (let i = from; i + 3 <= n; i++) {
|
|
18
|
+
if (buf[i] === 0 && buf[i + 1] === 0) {
|
|
19
|
+
if (buf[i + 2] === 1)
|
|
20
|
+
return [i, 3];
|
|
21
|
+
if (i + 4 <= n && buf[i + 2] === 0 && buf[i + 3] === 1)
|
|
22
|
+
return [i, 4];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The NAL units of an Annex-B buffer, in order. Bytes before the first start code are skipped;
|
|
29
|
+
* trailing zero bytes (`trailing_zero_8bits`) stay attached to the NAL before them, which is
|
|
30
|
+
* harmless for type/flag decisions and keeps the bytes of a walked AU identical to the input.
|
|
31
|
+
*/
|
|
32
|
+
export function nalUnits(buf) {
|
|
33
|
+
const out = [];
|
|
34
|
+
let sc = findStartCode(buf, 0);
|
|
35
|
+
while (sc) {
|
|
36
|
+
const [s, l] = sc;
|
|
37
|
+
const start = s + l;
|
|
38
|
+
const next = findStartCode(buf, start);
|
|
39
|
+
out.push({ offset: s, startCodeLen: l, bytes: buf.subarray(start, next ? next[0] : buf.length) });
|
|
40
|
+
sc = next;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** The SPS + PPS NAL units of an access unit (with their start codes, in stream order), if it carries any. Defined ⇒ the AU deserves the `CONFIG` flag. */
|
|
45
|
+
export function auParamSets(au) {
|
|
46
|
+
const parts = [];
|
|
47
|
+
for (const n of nalUnits(au)) {
|
|
48
|
+
const t = nalType(n);
|
|
49
|
+
if (t === NAL_TYPE.SPS || t === NAL_TYPE.PPS)
|
|
50
|
+
parts.push(nalWithStartCode(au, n));
|
|
51
|
+
}
|
|
52
|
+
return parts.length ? concatBytes(parts) : undefined;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The sync points an Annex-B access unit offers.
|
|
56
|
+
* - `idr`: an IDR slice (NAL type 5) is present → instantaneous start point (`KEY`).
|
|
57
|
+
* - `recovery`: a recovery point SEI (NAL 6, payload type 6) is present → gradual start point,
|
|
58
|
+
* clean one refresh period later (`RECOVERY`; x264 and nvenc both emit it at each intra-refresh sweep).
|
|
59
|
+
* Decided from the bitstream rather than the encoder's keyframe flag, which libx264 raises for
|
|
60
|
+
* both kinds and nvenc only for IDRs.
|
|
61
|
+
*/
|
|
62
|
+
export function auSyncPoints(au) {
|
|
63
|
+
let idr = false, recovery = false;
|
|
64
|
+
for (const n of nalUnits(au)) {
|
|
65
|
+
const t = nalType(n);
|
|
66
|
+
if (t === NAL_TYPE.IDR)
|
|
67
|
+
idr = true;
|
|
68
|
+
else if (t === NAL_TYPE.SEI)
|
|
69
|
+
recovery ||= seiHasRecoveryPoint(nalPayload(n));
|
|
70
|
+
}
|
|
71
|
+
return { idr, recovery };
|
|
72
|
+
}
|
|
73
|
+
/** An IDR is present: the AU is a keyframe in the `KEY` sense. */
|
|
74
|
+
export function isKeyframe(au) { return auSyncPoints(au).idr; }
|
|
75
|
+
/** The AU carries at least one coded slice (otherwise it is parameter sets / SEI only). */
|
|
76
|
+
export function hasVcl(au) { return nalUnits(au).some(nalIsVcl); }
|
|
77
|
+
/** Whether an SEI NAL payload (after the NAL header byte) contains a recovery point message (payload type 6). */
|
|
78
|
+
export function seiHasRecoveryPoint(payload) { return seiPayloadTypes(payload).includes(SEI_RECOVERY_POINT); }
|
|
79
|
+
/** The payload types of the SEI messages in an SEI NAL payload (D.1.1), in order. Emulation-prevention bytes are removed first. */
|
|
80
|
+
export function seiPayloadTypes(payload) {
|
|
81
|
+
const rbsp = rbspUnescape(payload);
|
|
82
|
+
const out = [];
|
|
83
|
+
let i = 0;
|
|
84
|
+
// 0x80 is the rbsp_trailing_bits byte that ends the SEI NAL.
|
|
85
|
+
while (i < rbsp.length && rbsp[i] !== 0x80) {
|
|
86
|
+
// payloadType and payloadSize: runs of 0xFF each adding 255, then a final byte.
|
|
87
|
+
let ty = 0;
|
|
88
|
+
while (i < rbsp.length && rbsp[i] === 0xff) {
|
|
89
|
+
ty += 255;
|
|
90
|
+
i++;
|
|
91
|
+
}
|
|
92
|
+
if (i >= rbsp.length)
|
|
93
|
+
break;
|
|
94
|
+
ty += rbsp[i];
|
|
95
|
+
i++;
|
|
96
|
+
let sz = 0;
|
|
97
|
+
while (i < rbsp.length && rbsp[i] === 0xff) {
|
|
98
|
+
sz += 255;
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
if (i >= rbsp.length)
|
|
102
|
+
break;
|
|
103
|
+
sz += rbsp[i];
|
|
104
|
+
i++;
|
|
105
|
+
out.push(ty);
|
|
106
|
+
i += sz;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
/** Strip H.264 emulation-prevention bytes (the `03` in `00 00 03`), giving the RBSP. */
|
|
111
|
+
export function rbspUnescape(nal) {
|
|
112
|
+
const out = new Uint8Array(nal.length);
|
|
113
|
+
let o = 0, zeros = 0;
|
|
114
|
+
for (const b of nal) {
|
|
115
|
+
if (zeros >= 2 && b === 3) {
|
|
116
|
+
zeros = 0;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
out[o++] = b;
|
|
120
|
+
zeros = b === 0 ? zeros + 1 : 0;
|
|
121
|
+
}
|
|
122
|
+
return out.subarray(0, o);
|
|
123
|
+
}
|
|
124
|
+
/** The SPS of an AU (NAL bytes without start code), if present. */
|
|
125
|
+
export function findSps(au) {
|
|
126
|
+
for (const n of nalUnits(au))
|
|
127
|
+
if (nalType(n) === NAL_TYPE.SPS)
|
|
128
|
+
return n.bytes;
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/** The WebCodecs / RFC 6381 codec string for an AU that carries an SPS: `avc1.` + profile_idc + constraint flags + level_idc as hex (e.g. `avc1.42c01e`). */
|
|
132
|
+
export function codecStringFromSps(au) {
|
|
133
|
+
const sps = findSps(au);
|
|
134
|
+
if (!sps || sps.length < 4)
|
|
135
|
+
return undefined;
|
|
136
|
+
const hex = (b) => b.toString(16).padStart(2, "0");
|
|
137
|
+
return `avc1.${hex(sps[1])}${hex(sps[2])}${hex(sps[3])}`;
|
|
138
|
+
}
|
|
139
|
+
/** `parts` joined into one new buffer (a single part is copied too, so the result never aliases the input). */
|
|
140
|
+
export function concatBytes(parts) {
|
|
141
|
+
let n = 0;
|
|
142
|
+
for (const p of parts)
|
|
143
|
+
n += p.length;
|
|
144
|
+
const out = new Uint8Array(n);
|
|
145
|
+
let o = 0;
|
|
146
|
+
for (const p of parts) {
|
|
147
|
+
out.set(p, o);
|
|
148
|
+
o += p.length;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Splits a continuous Annex-B byte stream (a file, an encoder pipe) into access units. Rule
|
|
154
|
+
* (dylithium's reader loop): an AU ends with its coded slice; the next NAL of any kind starts a
|
|
155
|
+
* new AU, so non-VCL NALs (SPS, PPS, SEI, AUD) are attached to the picture that follows them.
|
|
156
|
+
* Sufficient for one-slice-per-picture streams (x264 `slices=1`, nvenc, WebCodecs), which is what
|
|
157
|
+
* the encoder options enforce.
|
|
158
|
+
*/
|
|
159
|
+
export class AuSplitter {
|
|
160
|
+
#pending = new Uint8Array(0);
|
|
161
|
+
#au = [];
|
|
162
|
+
#auHasVcl = false;
|
|
163
|
+
/** Feeds bytes; returns every access unit completed by them (in order). */
|
|
164
|
+
push(bytes) {
|
|
165
|
+
this.#pending = this.#pending.length ? concatBytes([this.#pending, bytes]) : bytes.slice();
|
|
166
|
+
const out = [];
|
|
167
|
+
const p = this.#pending;
|
|
168
|
+
let pos = 0;
|
|
169
|
+
for (;;) {
|
|
170
|
+
const sc0 = findStartCode(p, pos);
|
|
171
|
+
if (!sc0)
|
|
172
|
+
break; // no start code yet (maybe a partial one): keep the bytes
|
|
173
|
+
const [s0, l0] = sc0;
|
|
174
|
+
// The NAL is complete only once the next start code is visible.
|
|
175
|
+
const sc1 = findStartCode(p, s0 + l0);
|
|
176
|
+
if (!sc1) {
|
|
177
|
+
pos = s0;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
const isVcl = s0 + l0 < p.length && isVclType(p[s0 + l0] & 0x1f);
|
|
181
|
+
if (this.#auHasVcl)
|
|
182
|
+
out.push(this.#take()); // the previous AU already has its slice: this NAL belongs to the next one
|
|
183
|
+
this.#au.push(p.slice(s0, sc1[0]));
|
|
184
|
+
if (isVcl)
|
|
185
|
+
this.#auHasVcl = true;
|
|
186
|
+
pos = sc1[0];
|
|
187
|
+
}
|
|
188
|
+
this.#pending = pos > 0 ? p.slice(pos) : p;
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
/** End of stream: the last NAL (whose end no start code marks) closes the last AU, or starts one more if the current AU already has its slice. */
|
|
192
|
+
finish() {
|
|
193
|
+
const out = [];
|
|
194
|
+
const sc0 = findStartCode(this.#pending, 0);
|
|
195
|
+
if (sc0) {
|
|
196
|
+
if (this.#auHasVcl)
|
|
197
|
+
out.push(this.#take());
|
|
198
|
+
this.#au.push(this.#pending.slice(sc0[0]));
|
|
199
|
+
}
|
|
200
|
+
if (this.#au.length)
|
|
201
|
+
out.push(this.#take());
|
|
202
|
+
this.#pending = new Uint8Array(0);
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
#take() {
|
|
206
|
+
const au = this.#au.length === 1 ? this.#au[0] : concatBytes(this.#au);
|
|
207
|
+
this.#au = [];
|
|
208
|
+
this.#auHasVcl = false;
|
|
209
|
+
return au;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/** Splits a whole Annex-B buffer into access units (see {@link AuSplitter}). */
|
|
213
|
+
export function splitAccessUnits(stream) {
|
|
214
|
+
const sp = new AuSplitter();
|
|
215
|
+
const out = sp.push(stream);
|
|
216
|
+
out.push(...sp.finish());
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* A `MediaFrame` for an H.264 Annex-B access unit with `key`/`config`/`recovery` derived from the
|
|
221
|
+
* NAL units (IDR present / SPS+PPS present / recovery-point SEI present), so the header flags
|
|
222
|
+
* always agree with the bytes (R27 §2 publisher rule). `tUs` is the capture time (0 = stamp at publish).
|
|
223
|
+
*/
|
|
224
|
+
export function mediaFrameFromAnnexb(au, tUs = 0) {
|
|
225
|
+
const { idr, recovery } = auSyncPoints(au);
|
|
226
|
+
return { data: au, tUs, key: idr, config: auParamSets(au) !== undefined, recovery };
|
|
227
|
+
}
|
package/dist/camera.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { H264Encoder } from "./encoder.ts";
|
|
2
|
+
/** `MediaStreamTrackProcessor` is available (Chrome 94+). */
|
|
3
|
+
export declare function trackProcessorSupported(): boolean;
|
|
4
|
+
/** The `VideoFrame`s of a video track as a `ReadableStream` (`maxBufferSize` 1: the newest frame wins, older ones are dropped by the browser). */
|
|
5
|
+
export declare function trackFrames(track: MediaStreamTrack): ReadableStream<VideoFrame>;
|
|
6
|
+
/**
|
|
7
|
+
* Pumps a camera track into the encoder until the track ends or `signal` aborts. Resolves with
|
|
8
|
+
* the number of frames offered to the encoder (dropped ones included; see `encoder.stats()`).
|
|
9
|
+
*/
|
|
10
|
+
export declare function pumpTrack(track: MediaStreamTrack, encoder: H264Encoder, opts?: {
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}): Promise<number>;
|
package/dist/camera.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Camera frames into the encoder: `MediaStreamTrackProcessor` (Chrome-only, not in TypeScript's
|
|
2
|
+
// DOM lib, so it is reached through `globalThis`) turns a `MediaStreamTrack` into a stream of
|
|
3
|
+
// `VideoFrame`s. Each frame is encoded with `tUs = nowUs()` at dequeue (stamp at capture, as
|
|
4
|
+
// dylithium did) and closed right after.
|
|
5
|
+
import { SynapseError, nowUs } from "@synapse-sdk/core";
|
|
6
|
+
/** `MediaStreamTrackProcessor` is available (Chrome 94+). */
|
|
7
|
+
export function trackProcessorSupported() { return typeof globalThis.MediaStreamTrackProcessor === "function"; }
|
|
8
|
+
/** The `VideoFrame`s of a video track as a `ReadableStream` (`maxBufferSize` 1: the newest frame wins, older ones are dropped by the browser). */
|
|
9
|
+
export function trackFrames(track) {
|
|
10
|
+
const Ctor = globalThis.MediaStreamTrackProcessor;
|
|
11
|
+
if (!Ctor)
|
|
12
|
+
throw new SynapseError("not_supported", "trackFrames: MediaStreamTrackProcessor is not available (Chrome 94+ only)");
|
|
13
|
+
return new Ctor({ track, maxBufferSize: 1 }).readable;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Pumps a camera track into the encoder until the track ends or `signal` aborts. Resolves with
|
|
17
|
+
* the number of frames offered to the encoder (dropped ones included; see `encoder.stats()`).
|
|
18
|
+
*/
|
|
19
|
+
export async function pumpTrack(track, encoder, opts = {}) {
|
|
20
|
+
const reader = trackFrames(track).getReader();
|
|
21
|
+
const onAbort = () => { void reader.cancel(); };
|
|
22
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
23
|
+
let n = 0;
|
|
24
|
+
try {
|
|
25
|
+
for (;;) {
|
|
26
|
+
const { value, done } = await reader.read();
|
|
27
|
+
if (done || !value)
|
|
28
|
+
break;
|
|
29
|
+
try {
|
|
30
|
+
encoder.encode(value, { tUs: nowUs() });
|
|
31
|
+
n++;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
value.close();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
40
|
+
reader.releaseLock();
|
|
41
|
+
}
|
|
42
|
+
return n;
|
|
43
|
+
}
|
package/dist/canvas.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { VideoSink } from "./decoder.ts";
|
|
2
|
+
export interface CanvasSinkOptions {
|
|
3
|
+
/** Coalesce to the display refresh (drops superseded pictures). Default false: draw immediately. */
|
|
4
|
+
vsync?: boolean;
|
|
5
|
+
/** Resize the canvas bitmap to the picture (default true). `false` scales the picture into the canvas as it is. */
|
|
6
|
+
resize?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface CanvasSink {
|
|
9
|
+
/** Pictures drawn. */
|
|
10
|
+
readonly drawn: number;
|
|
11
|
+
/** Pictures closed undrawn because a newer one arrived first (`vsync` only). */
|
|
12
|
+
readonly dropped: number;
|
|
13
|
+
detach(): void;
|
|
14
|
+
}
|
|
15
|
+
/** Anything with a decoder-style `attachSink` (an `H264Decoder`, or a `VideoSubscriber` through its decoder). */
|
|
16
|
+
export interface VideoSource {
|
|
17
|
+
attachSink(sink: VideoSink): () => void;
|
|
18
|
+
}
|
|
19
|
+
export declare function attachCanvas(source: VideoSource, canvas: HTMLCanvasElement, opts?: CanvasSinkOptions): CanvasSink;
|
package/dist/canvas.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Canvas sink — dylithium-web-2 `react.tsx:233-252` as a framework-free helper. Per-frame
|
|
2
|
+
// presentation bypasses React: decoder output → `drawImage(vf)` → `vf.close()`.
|
|
3
|
+
//
|
|
4
|
+
// Default (`vsync: false`): draw the moment the decoder outputs, the lowest-latency path
|
|
5
|
+
// (`drawImage` of a `VideoFrame` is synchronous). `vsync: true`: hold the newest picture and draw
|
|
6
|
+
// it on the next animation frame; a picture superseded before it was drawn is closed unseen and
|
|
7
|
+
// counted `dropped`, so a decoder that runs ahead of the display never queues pictures.
|
|
8
|
+
import { SynapseError } from "@synapse-sdk/core";
|
|
9
|
+
export function attachCanvas(source, canvas, opts = {}) {
|
|
10
|
+
const ctx = canvas.getContext("2d");
|
|
11
|
+
if (!ctx)
|
|
12
|
+
throw new SynapseError("not_supported", "attachCanvas: the canvas has no 2d context (already used with another context type?)");
|
|
13
|
+
const resize = opts.resize ?? true;
|
|
14
|
+
let drawn = 0, dropped = 0;
|
|
15
|
+
let latest;
|
|
16
|
+
let raf = 0;
|
|
17
|
+
const draw = (vf) => {
|
|
18
|
+
try {
|
|
19
|
+
if (resize) {
|
|
20
|
+
if (canvas.width !== vf.displayWidth || canvas.height !== vf.displayHeight) {
|
|
21
|
+
canvas.width = vf.displayWidth;
|
|
22
|
+
canvas.height = vf.displayHeight;
|
|
23
|
+
}
|
|
24
|
+
ctx.drawImage(vf, 0, 0);
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
ctx.drawImage(vf, 0, 0, canvas.width, canvas.height);
|
|
28
|
+
drawn++;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
vf.close();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
const detachSink = source.attachSink(vf => {
|
|
35
|
+
if (!opts.vsync) {
|
|
36
|
+
draw(vf);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (latest) {
|
|
40
|
+
latest.close();
|
|
41
|
+
dropped++;
|
|
42
|
+
}
|
|
43
|
+
latest = vf;
|
|
44
|
+
if (!raf)
|
|
45
|
+
raf = requestAnimationFrame(() => { raf = 0; const f = latest; latest = undefined; if (f)
|
|
46
|
+
draw(f); });
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
get drawn() { return drawn; },
|
|
50
|
+
get dropped() { return dropped; },
|
|
51
|
+
detach() {
|
|
52
|
+
detachSink();
|
|
53
|
+
if (raf) {
|
|
54
|
+
cancelAnimationFrame(raf);
|
|
55
|
+
raf = 0;
|
|
56
|
+
}
|
|
57
|
+
if (latest) {
|
|
58
|
+
latest.close();
|
|
59
|
+
latest = undefined;
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { type MediaFrame } from "@synapse-sdk/core";
|
|
2
|
+
import { DecoderSync, type SyncConfig, type SyncState, type SyncStats } from "./sync.ts";
|
|
3
|
+
/** What travelled with a decoded frame. */
|
|
4
|
+
export interface DecodedFrameMeta {
|
|
5
|
+
/** The access unit this picture came from (header facts included). */
|
|
6
|
+
frame: MediaFrame;
|
|
7
|
+
/** `nowUs()` when the AU was pushed into the decoder. */
|
|
8
|
+
recvUs: number;
|
|
9
|
+
/** `decode()` call to output, ms. */
|
|
10
|
+
decodeMs: number;
|
|
11
|
+
}
|
|
12
|
+
/** Receives decoded pictures; owns `vf` and must `vf.close()` it. */
|
|
13
|
+
export type VideoSink = (vf: VideoFrame, meta: DecodedFrameMeta) => void;
|
|
14
|
+
export interface H264DecoderOptions {
|
|
15
|
+
/** `"no-preference"` (default) lets Chrome pick the hardware decoder; `"prefer-software"` is about 1 ms at low resolutions. */
|
|
16
|
+
hardwareAcceleration?: HardwareAcceleration;
|
|
17
|
+
/** Sync policy overrides; the defaults are the shared 10/150/60 (`SYNC_DEFAULT`) with `startAtRecovery: false` (see the constructor). */
|
|
18
|
+
sync?: Partial<SyncConfig>;
|
|
19
|
+
/** The decoder has produced nothing for `stuckFrames` chunks: ask the publisher for an IDR (`VideoSubscriber` wires this to the `<topic>/idr` service). */
|
|
20
|
+
onRequestIdr?: () => void;
|
|
21
|
+
/** Diagnostics (configure, sync, reset, error lines). */
|
|
22
|
+
onLog?: (msg: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface H264DecoderStats extends SyncStats {
|
|
25
|
+
state: SyncState;
|
|
26
|
+
/** Decoder queue depth right now. */
|
|
27
|
+
queue: number;
|
|
28
|
+
/** Smoothed `decode()`-to-output time, ms. */
|
|
29
|
+
decodeMs: number;
|
|
30
|
+
width: number;
|
|
31
|
+
height: number;
|
|
32
|
+
/** The codec string the decoder is configured with. */
|
|
33
|
+
codec: string | undefined;
|
|
34
|
+
/** Decoded pictures closed unseen because no sink was attached. */
|
|
35
|
+
sinkless: number;
|
|
36
|
+
/** WebCodecs `error` callbacks and `decode()` throws (each one resets). */
|
|
37
|
+
errors: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class H264Decoder {
|
|
40
|
+
#private;
|
|
41
|
+
private readonly opts;
|
|
42
|
+
/** The shared sync policy (read `sync.state`, `sync.stats`). */
|
|
43
|
+
readonly sync: DecoderSync;
|
|
44
|
+
/** Stuck signal; set by the options or later by the owner (`VideoSubscriber` does). */
|
|
45
|
+
onRequestIdr: (() => void) | undefined;
|
|
46
|
+
/** Observer of every decoded picture (stats, latency), called before the sink. */
|
|
47
|
+
onOutput: ((meta: DecodedFrameMeta) => void) | undefined;
|
|
48
|
+
constructor(opts?: H264DecoderOptions);
|
|
49
|
+
/** Sets the sink that owns decoded pictures (one at a time; replacing detaches the previous). Returns a detach function. */
|
|
50
|
+
attachSink(sink: VideoSink): () => void;
|
|
51
|
+
/** One received access unit. Never throws: decoder failures reset the decoder and count. */
|
|
52
|
+
push(frame: MediaFrame): void;
|
|
53
|
+
/** Drop the decoder now and wait for the next start point (counts as a reset). */
|
|
54
|
+
reset(reason?: string): void;
|
|
55
|
+
stats(): H264DecoderStats;
|
|
56
|
+
close(): void;
|
|
57
|
+
}
|
package/dist/decoder.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// WebCodecs H.264 playback tuned for a live, low-latency stream — dylithium-web-2 `player.ts`
|
|
2
|
+
// (`H264Player`) rebuilt on the Synapse `MediaFrame` and the shared `DecoderSync` state machine.
|
|
3
|
+
//
|
|
4
|
+
// What stays from dylithium (lessons register): `optimizeForLatency: true`; Annex-B chunks with
|
|
5
|
+
// SPS/PPS in-band and **no** `description` (avcC), so the decoder takes the bytes as they travel;
|
|
6
|
+
// the codec string read from the SPS (`avc1.` + profile/constraints/level) so any encoder profile
|
|
7
|
+
// decodes; the first chunk after `configure()` labelled `key` even when it is a recovery point;
|
|
8
|
+
// prime a recovery point with the cached SPS/PPS; stuck → ask for an IDR; wedged or decoder error
|
|
9
|
+
// → drop the decoder and wait for the next start point. Frames are handed to one sink as they
|
|
10
|
+
// decode; the sink owns them and must `close()` them (`attachCanvas` does).
|
|
11
|
+
import { nowUs } from "@synapse-sdk/core";
|
|
12
|
+
import { codecStringFromSps } from "./annexb.js";
|
|
13
|
+
import { DecoderSync } from "./sync.js";
|
|
14
|
+
import { DEFAULT_H264_CODEC, requireWebCodecs } from "./webcodecs.js";
|
|
15
|
+
export class H264Decoder {
|
|
16
|
+
opts;
|
|
17
|
+
/** The shared sync policy (read `sync.state`, `sync.stats`). */
|
|
18
|
+
sync;
|
|
19
|
+
/** Stuck signal; set by the options or later by the owner (`VideoSubscriber` does). */
|
|
20
|
+
onRequestIdr;
|
|
21
|
+
/** Observer of every decoded picture (stats, latency), called before the sink. */
|
|
22
|
+
onOutput;
|
|
23
|
+
#dec;
|
|
24
|
+
#codec;
|
|
25
|
+
#sink;
|
|
26
|
+
#ts = 0;
|
|
27
|
+
/** Pending chunks by synthetic timestamp (the key WebCodecs hands back on output). */
|
|
28
|
+
#meta = new Map();
|
|
29
|
+
#dims = { width: 0, height: 0 };
|
|
30
|
+
#decodeMs = 0;
|
|
31
|
+
#sinkless = 0;
|
|
32
|
+
#errors = 0;
|
|
33
|
+
#closed = false;
|
|
34
|
+
constructor(opts = {}) {
|
|
35
|
+
this.opts = opts;
|
|
36
|
+
requireWebCodecs("H264Decoder");
|
|
37
|
+
// Chromium's VideoDecoder checks that a `key` chunk carries an IDR (Annex-B without `description`):
|
|
38
|
+
// a recovery-point start throws "A key frame is required after configure()", so WebCodecs waits for
|
|
39
|
+
// an IDR (the owner asks for one through `onRequestIdr`; `VideoSubscriber` does so while waiting).
|
|
40
|
+
this.sync = new DecoderSync({ startAtRecovery: false, ...opts.sync });
|
|
41
|
+
this.onRequestIdr = opts.onRequestIdr;
|
|
42
|
+
}
|
|
43
|
+
/** Sets the sink that owns decoded pictures (one at a time; replacing detaches the previous). Returns a detach function. */
|
|
44
|
+
attachSink(sink) {
|
|
45
|
+
this.#sink = sink;
|
|
46
|
+
return () => { if (this.#sink === sink)
|
|
47
|
+
this.#sink = undefined; };
|
|
48
|
+
}
|
|
49
|
+
/** One received access unit. Never throws: decoder failures reset the decoder and count. */
|
|
50
|
+
push(frame) {
|
|
51
|
+
if (this.#closed)
|
|
52
|
+
return;
|
|
53
|
+
const recvUs = nowUs();
|
|
54
|
+
const d = this.sync.push(frame);
|
|
55
|
+
if (d.feed) {
|
|
56
|
+
if (d.started || !this.#dec)
|
|
57
|
+
this.#configure(d.feed, d.started);
|
|
58
|
+
const ts = this.#ts++ * 33_333; // timestamps are only keys to pair outputs with inputs (any spacing works)
|
|
59
|
+
this.#meta.set(ts, { frame, recvUs, pushedMs: performance.now() });
|
|
60
|
+
try {
|
|
61
|
+
this.#dec.decode(new EncodedVideoChunk({ type: d.keyChunk ? "key" : "delta", timestamp: ts, data: d.feed }));
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
this.#meta.delete(ts);
|
|
65
|
+
this.#fail(`decode() threw: ${e instanceof Error ? e.message : String(e)}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (d.signal === "reset") {
|
|
70
|
+
this.#log(`decoder produced nothing for ${this.sync.cfg.wedgedFrames} chunks: dropping it`);
|
|
71
|
+
this.#drop();
|
|
72
|
+
}
|
|
73
|
+
else if (d.signal === "request_idr") {
|
|
74
|
+
this.#log(`no output for ${this.sync.cfg.stuckFrames} chunks: requesting an IDR`);
|
|
75
|
+
this.onRequestIdr?.();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
#configure(feed, restart) {
|
|
79
|
+
if (this.#dec)
|
|
80
|
+
this.#drop();
|
|
81
|
+
const codec = codecStringFromSps(feed) ?? this.#codec ?? DEFAULT_H264_CODEC;
|
|
82
|
+
const dec = new VideoDecoder({ output: vf => this.#onOutput(vf), error: e => this.#fail(`decoder error: ${e.message}`) });
|
|
83
|
+
dec.configure({ codec, optimizeForLatency: true, hardwareAcceleration: this.opts.hardwareAcceleration ?? "no-preference" });
|
|
84
|
+
this.#dec = dec;
|
|
85
|
+
this.#codec = codec;
|
|
86
|
+
this.#log(`decoder configured: ${codec}${restart ? " (start point)" : ""}`);
|
|
87
|
+
}
|
|
88
|
+
#onOutput(vf) {
|
|
89
|
+
this.sync.onOutput();
|
|
90
|
+
const m = this.#meta.get(vf.timestamp);
|
|
91
|
+
this.#meta.delete(vf.timestamp);
|
|
92
|
+
this.#dims = { width: vf.displayWidth, height: vf.displayHeight };
|
|
93
|
+
const decodeMs = m ? performance.now() - m.pushedMs : 0;
|
|
94
|
+
if (m)
|
|
95
|
+
this.#decodeMs = this.#decodeMs === 0 ? decodeMs : this.#decodeMs * 0.9 + decodeMs * 0.1;
|
|
96
|
+
const meta = { frame: m?.frame ?? { data: new Uint8Array(0), tUs: 0, key: false, config: false, recovery: false }, recvUs: m?.recvUs ?? 0, decodeMs };
|
|
97
|
+
this.onOutput?.(meta);
|
|
98
|
+
if (this.#sink)
|
|
99
|
+
this.#sink(vf, meta);
|
|
100
|
+
else {
|
|
101
|
+
vf.close();
|
|
102
|
+
this.#sinkless++;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** A decoder error or throw: count it, drop the decoder, wait for the next start point. */
|
|
106
|
+
#fail(reason) {
|
|
107
|
+
this.#errors++;
|
|
108
|
+
this.#log(`reset: ${reason}`);
|
|
109
|
+
this.sync.onError();
|
|
110
|
+
this.#drop();
|
|
111
|
+
}
|
|
112
|
+
#drop() {
|
|
113
|
+
try {
|
|
114
|
+
this.#dec?.close();
|
|
115
|
+
}
|
|
116
|
+
catch { /* already closed */ }
|
|
117
|
+
this.#dec = undefined;
|
|
118
|
+
this.#meta.clear();
|
|
119
|
+
}
|
|
120
|
+
/** Drop the decoder now and wait for the next start point (counts as a reset). */
|
|
121
|
+
reset(reason = "requested") { this.#log(`reset: ${reason}`); this.sync.onError(); this.#drop(); }
|
|
122
|
+
stats() {
|
|
123
|
+
return { ...this.sync.stats, state: this.sync.state, queue: this.#dec?.decodeQueueSize ?? 0, decodeMs: this.#decodeMs, ...this.#dims, codec: this.#codec, sinkless: this.#sinkless, errors: this.#errors };
|
|
124
|
+
}
|
|
125
|
+
close() {
|
|
126
|
+
this.#closed = true;
|
|
127
|
+
this.#drop();
|
|
128
|
+
this.#sink = undefined;
|
|
129
|
+
}
|
|
130
|
+
#log(msg) { this.opts.onLog?.(msg); }
|
|
131
|
+
}
|