@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/dist/video.d.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type MediaFrame, type MediaLayout, type TopicContract, type TopicRef } from "@synapse-sdk/core";
|
|
2
|
+
import type { Envelope, Publisher, Registration, Subscription, Synapse } from "@synapse-sdk/client";
|
|
3
|
+
import type { H264Decoder } from "./decoder.ts";
|
|
4
|
+
import type { H264Encoder } from "./encoder.ts";
|
|
5
|
+
import type { SyncState } from "./sync.ts";
|
|
6
|
+
export declare const H264 = "video/h264";
|
|
7
|
+
export interface H264TopicOptions {
|
|
8
|
+
/** RaptorQ repair percentage; default 30 (`repairPct: 0` = source symbols only). */
|
|
9
|
+
repairPct?: number;
|
|
10
|
+
/** FEC give-up budget, ms; default 50 (video). */
|
|
11
|
+
deadlineMs?: number;
|
|
12
|
+
/** One MoQ group per frame with the relay caching the latest, instead of datagrams (a poster-frame topic, not live video). */
|
|
13
|
+
retain?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** A raw topic for H.264 access units: `contentType: "video/h264"`, `layout: {codec: "h264", stream: "annexb", ...}`, datagrams with FEC. */
|
|
16
|
+
export declare function h264Topic(name: string, layout?: Omit<MediaLayout, "codec" | "stream">, opts?: H264TopicOptions): TopicContract<Uint8Array, Uint8Array>;
|
|
17
|
+
/** The IDR-on-demand service a video publisher serves (decision 4): `<topic>/idr`, no arguments, `{ ok: true }`. */
|
|
18
|
+
export declare function idrServiceName(topic: string): string;
|
|
19
|
+
export interface VideoPublisherOptions {
|
|
20
|
+
/** Serve `<topic>/idr` → `encoder.forceIdr()`; default true. */
|
|
21
|
+
idrService?: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface VideoPublisherStats {
|
|
24
|
+
/** Access units handed to `publishMedia`. */
|
|
25
|
+
sent: number;
|
|
26
|
+
/** `<topic>/idr` calls served. */
|
|
27
|
+
idrCalls: number;
|
|
28
|
+
/** Frames the SPS/PPS re-insertion policy touched (WebCodecs streams: none, key chunks carry them). */
|
|
29
|
+
reinserted: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Encoder output → `publisher.publishMedia()`: `t_us` = capture (the `VideoFrame.timestamp`),
|
|
33
|
+
* `STAGES` = encoder stamp + `sentUs` 0 (the publisher stamps it at the write, WP §4.1), SPS/PPS
|
|
34
|
+
* re-insertion at recovery points, plus the `<topic>/idr` service that forces the next frame to be
|
|
35
|
+
* an IDR. `VideoPublisher.open()` resolves once the runtime admitted the topic (and the service).
|
|
36
|
+
*/
|
|
37
|
+
export declare class VideoPublisher {
|
|
38
|
+
#private;
|
|
39
|
+
readonly node: Synapse;
|
|
40
|
+
readonly topic: TopicContract<Uint8Array, Uint8Array>;
|
|
41
|
+
readonly publisher: Publisher<Uint8Array>;
|
|
42
|
+
readonly registration: Registration | undefined;
|
|
43
|
+
readonly encoder: H264Encoder;
|
|
44
|
+
private constructor();
|
|
45
|
+
static open(node: Synapse, topic: TopicRef<Uint8Array, Uint8Array>, encoder: H264Encoder, opts?: VideoPublisherOptions): Promise<VideoPublisher>;
|
|
46
|
+
stats(): VideoPublisherStats;
|
|
47
|
+
/** Stops publishing (the encoder stays yours to close). */
|
|
48
|
+
close(): void;
|
|
49
|
+
}
|
|
50
|
+
export interface VideoSubscriberOptions {
|
|
51
|
+
/** Call `<topic>/idr` when the decoder is stuck and while it waits for a start point; default true. */
|
|
52
|
+
requestIdr?: boolean;
|
|
53
|
+
/** Minimum spacing between IDR requests, ms; default 1000. */
|
|
54
|
+
idrMinIntervalMs?: number;
|
|
55
|
+
/** Subscription priority 0..7; default 2. */
|
|
56
|
+
priority?: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Latency split over the last stats window (averages, ms). Undefined when the stamps needed are
|
|
60
|
+
* not there. `e2eMs` and `netMs` cross the publisher's clock and ours: exact when it shares our time
|
|
61
|
+
* domain, otherwise through the broker's clock (`node.toLocalUs`, SDK §4.4) and reported with
|
|
62
|
+
* `epsMs`, the error bound; undefined while the publisher's domain has no offset yet (no judgment).
|
|
63
|
+
*/
|
|
64
|
+
export interface VideoLatency {
|
|
65
|
+
/** Capture → decoded picture, `± epsMs`. */
|
|
66
|
+
e2eMs: number | undefined;
|
|
67
|
+
/** Capture → encoder output (`STAGES.encoded_us`; one clock). */
|
|
68
|
+
encMs: number | undefined;
|
|
69
|
+
/** Encoder output → transport write (`STAGES.sent_us`; one clock). */
|
|
70
|
+
sendMs: number | undefined;
|
|
71
|
+
/** Transport write → our receive, `± epsMs`. */
|
|
72
|
+
netMs: number | undefined;
|
|
73
|
+
/** `decode()` → output. */
|
|
74
|
+
decodeMs: number | undefined;
|
|
75
|
+
/** Error bound on `e2eMs`/`netMs` from the clock conversion: 0 in our own domain, the two estimates' bounds added across domains, undefined when the clocks are not comparable (then both are undefined too). */
|
|
76
|
+
epsMs: number | undefined;
|
|
77
|
+
}
|
|
78
|
+
export interface VideoStats {
|
|
79
|
+
/** Decoded pictures per second over the window. */
|
|
80
|
+
fps: number;
|
|
81
|
+
/** Received bit rate over the window. */
|
|
82
|
+
kbps: number;
|
|
83
|
+
/** Totals since the subscriber was created. */
|
|
84
|
+
received: number;
|
|
85
|
+
decoded: number;
|
|
86
|
+
/** Frames not decoded: dropped while waiting for a start point, plus frames superseded before they were read. */
|
|
87
|
+
dropped: number;
|
|
88
|
+
superseded: number;
|
|
89
|
+
resets: number;
|
|
90
|
+
idrRequests: number;
|
|
91
|
+
/** Decoder queue depth right now. */
|
|
92
|
+
queue: number;
|
|
93
|
+
width: number;
|
|
94
|
+
height: number;
|
|
95
|
+
state: SyncState;
|
|
96
|
+
latency: VideoLatency;
|
|
97
|
+
}
|
|
98
|
+
/** A received envelope on a raw media topic as a `MediaFrame` (the header's flags and `STAGES`). */
|
|
99
|
+
export declare function mediaFrameOf(env: Envelope<Uint8Array>): MediaFrame;
|
|
100
|
+
/**
|
|
101
|
+
* A raw subscription feeding an `H264Decoder`. Every envelope becomes a `MediaFrame` and goes to
|
|
102
|
+
* `decoder.push()`; the decoder's stuck signal and the waiting state (a late joiner on an IDR-only
|
|
103
|
+
* stream sees nothing until an IDR comes) both call the publisher's `<topic>/idr` service, at most
|
|
104
|
+
* once per `idrMinIntervalMs`. Stats: fps, totals, and the latency split from `t_us` and `STAGES`.
|
|
105
|
+
*/
|
|
106
|
+
export declare class VideoSubscriber {
|
|
107
|
+
#private;
|
|
108
|
+
readonly node: Synapse;
|
|
109
|
+
readonly decoder: H264Decoder;
|
|
110
|
+
private readonly opts;
|
|
111
|
+
readonly topic: TopicContract<Uint8Array, Uint8Array>;
|
|
112
|
+
readonly sub: Subscription<Uint8Array>;
|
|
113
|
+
constructor(node: Synapse, topic: TopicRef<Uint8Array, Uint8Array>, decoder: H264Decoder, opts?: VideoSubscriberOptions);
|
|
114
|
+
/** Window averages since the previous call plus totals; resets the window. */
|
|
115
|
+
stats(): VideoStats;
|
|
116
|
+
/** IDR calls that failed (no publisher serving `<topic>/idr`, or timed out). */
|
|
117
|
+
get idrFailed(): number;
|
|
118
|
+
/**
|
|
119
|
+
* Stops the subscription and gives the decoder back its previous callbacks (the decoder stays
|
|
120
|
+
* yours to close). Close subscribers sharing one decoder newest first: a subscriber whose
|
|
121
|
+
* callbacks another one wrapped since cannot take them back, so the newer one still calls them.
|
|
122
|
+
*/
|
|
123
|
+
close(): void;
|
|
124
|
+
}
|
package/dist/video.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Glue between the codecs and a Synapse node: `VideoPublisher` (encoder → `publishMedia`, serves
|
|
2
|
+
// `<topic>/idr`), `VideoSubscriber` (raw subscription → `H264Decoder`, calls `<topic>/idr` when
|
|
3
|
+
// stuck or waiting, keeps stats), `h264Topic` (the raw `fec` datagram topic with an H.264 layout).
|
|
4
|
+
import { SynapseError, defineTopic, nowUs, resolveTopic } from "@synapse-sdk/core";
|
|
5
|
+
import { ParamSetCache } from "./reinsert.js";
|
|
6
|
+
export const H264 = "video/h264";
|
|
7
|
+
/** A raw topic for H.264 access units: `contentType: "video/h264"`, `layout: {codec: "h264", stream: "annexb", ...}`, datagrams with FEC. */
|
|
8
|
+
export function h264Topic(name, layout = {}, opts = {}) {
|
|
9
|
+
const l = { codec: "h264", stream: "annexb", ...layout };
|
|
10
|
+
if (opts.retain)
|
|
11
|
+
return defineTopic({ name, contentType: H264, layout: l, delivery: "stream" });
|
|
12
|
+
return defineTopic({ name, contentType: H264, layout: l, delivery: "datagram", fec: { repairPct: opts.repairPct ?? 30, deadlineMs: opts.deadlineMs ?? 50 } });
|
|
13
|
+
}
|
|
14
|
+
/** The IDR-on-demand service a video publisher serves (decision 4): `<topic>/idr`, no arguments, `{ ok: true }`. */
|
|
15
|
+
export function idrServiceName(topic) { return `${topic}/idr`; }
|
|
16
|
+
function rawContract(topic, who) {
|
|
17
|
+
const c = resolveTopic(topic);
|
|
18
|
+
if (!c.raw)
|
|
19
|
+
throw new SynapseError("invalid_input", `${who}: topic ${c.name} must be a raw topic (use h264Topic())`);
|
|
20
|
+
return c;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Encoder output → `publisher.publishMedia()`: `t_us` = capture (the `VideoFrame.timestamp`),
|
|
24
|
+
* `STAGES` = encoder stamp + `sentUs` 0 (the publisher stamps it at the write, WP §4.1), SPS/PPS
|
|
25
|
+
* re-insertion at recovery points, plus the `<topic>/idr` service that forces the next frame to be
|
|
26
|
+
* an IDR. `VideoPublisher.open()` resolves once the runtime admitted the topic (and the service).
|
|
27
|
+
*/
|
|
28
|
+
export class VideoPublisher {
|
|
29
|
+
node;
|
|
30
|
+
topic;
|
|
31
|
+
publisher;
|
|
32
|
+
registration;
|
|
33
|
+
encoder;
|
|
34
|
+
#cache = new ParamSetCache();
|
|
35
|
+
#detach;
|
|
36
|
+
#sent = 0;
|
|
37
|
+
#idrCalls = 0;
|
|
38
|
+
constructor(node, topic, publisher, registration, encoder) {
|
|
39
|
+
this.node = node;
|
|
40
|
+
this.topic = topic;
|
|
41
|
+
this.publisher = publisher;
|
|
42
|
+
this.registration = registration;
|
|
43
|
+
this.encoder = encoder;
|
|
44
|
+
this.#detach = encoder.attachSink(f => this.#onFrame(f));
|
|
45
|
+
}
|
|
46
|
+
static async open(node, topic, encoder, opts = {}) {
|
|
47
|
+
const contract = rawContract(topic, "VideoPublisher");
|
|
48
|
+
const publisher = await node.publisher(contract);
|
|
49
|
+
let reg;
|
|
50
|
+
if (opts.idrService !== false) {
|
|
51
|
+
let self;
|
|
52
|
+
try {
|
|
53
|
+
reg = await node.serve(idrServiceName(contract.name), () => { if (self)
|
|
54
|
+
self.#idrCalls++; encoder.forceIdr(); return { ok: true }; }, { minRole: "observer", description: `force the next ${contract.name} frame to be an IDR` });
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
publisher.close();
|
|
58
|
+
throw e;
|
|
59
|
+
}
|
|
60
|
+
self = new VideoPublisher(node, contract, publisher, reg, encoder);
|
|
61
|
+
return self;
|
|
62
|
+
}
|
|
63
|
+
return new VideoPublisher(node, contract, publisher, undefined, encoder);
|
|
64
|
+
}
|
|
65
|
+
#onFrame(f) {
|
|
66
|
+
const p = this.#cache.prepare(f, false);
|
|
67
|
+
p.stages = { encodedUs: f.stages?.encodedUs ?? nowUs(), sentUs: 0 };
|
|
68
|
+
this.#sent++;
|
|
69
|
+
try {
|
|
70
|
+
this.publisher.publishMedia(p);
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
this.node.warn(`VideoPublisher ${this.topic.name}: publish failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
stats() { return { sent: this.#sent, idrCalls: this.#idrCalls, reinserted: this.#cache.reinserted }; }
|
|
77
|
+
/** Stops publishing (the encoder stays yours to close). */
|
|
78
|
+
close() {
|
|
79
|
+
this.#detach();
|
|
80
|
+
this.registration?.unregister();
|
|
81
|
+
this.publisher.close();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const freshWindow = () => ({ t0: performance.now(), frames: 0, bytes: 0, e2e: 0, e2eN: 0, enc: 0, encN: 0, send: 0, sendN: 0, net: 0, netN: 0, dec: 0, decN: 0, eps: 0, epsN: 0 });
|
|
85
|
+
/** A received envelope on a raw media topic as a `MediaFrame` (the header's flags and `STAGES`). */
|
|
86
|
+
export function mediaFrameOf(env) {
|
|
87
|
+
const f = { data: env.data, tUs: env.tUs, key: (env.flags & 1) !== 0, config: (env.flags & 32) !== 0, recovery: (env.flags & 64) !== 0 };
|
|
88
|
+
for (const t of env.tlvs)
|
|
89
|
+
if (t.kind === "stages") {
|
|
90
|
+
f.stages = { encodedUs: Number(t.encodedUs), sentUs: Number(t.sentUs) };
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
return f;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* A raw subscription feeding an `H264Decoder`. Every envelope becomes a `MediaFrame` and goes to
|
|
97
|
+
* `decoder.push()`; the decoder's stuck signal and the waiting state (a late joiner on an IDR-only
|
|
98
|
+
* stream sees nothing until an IDR comes) both call the publisher's `<topic>/idr` service, at most
|
|
99
|
+
* once per `idrMinIntervalMs`. Stats: fps, totals, and the latency split from `t_us` and `STAGES`.
|
|
100
|
+
*/
|
|
101
|
+
export class VideoSubscriber {
|
|
102
|
+
node;
|
|
103
|
+
decoder;
|
|
104
|
+
opts;
|
|
105
|
+
topic;
|
|
106
|
+
sub;
|
|
107
|
+
#win = freshWindow();
|
|
108
|
+
#received = 0;
|
|
109
|
+
#idrRequests = 0;
|
|
110
|
+
#idrFailed = 0;
|
|
111
|
+
#lastIdrMs = -Infinity;
|
|
112
|
+
#waitingDropped = 0;
|
|
113
|
+
/** Capture stamp of each frame in flight, converted to our clock (`toLocalUs`); absent when the clocks are not comparable. */
|
|
114
|
+
#captureLocal = new WeakMap();
|
|
115
|
+
#closed = false;
|
|
116
|
+
constructor(node, topic, decoder, opts = {}) {
|
|
117
|
+
this.node = node;
|
|
118
|
+
this.decoder = decoder;
|
|
119
|
+
this.opts = opts;
|
|
120
|
+
this.topic = rawContract(topic, "VideoSubscriber");
|
|
121
|
+
const prevIdr = decoder.onRequestIdr;
|
|
122
|
+
const onIdr = () => { prevIdr?.(); this.#requestIdr("stuck"); };
|
|
123
|
+
decoder.onRequestIdr = onIdr;
|
|
124
|
+
const prevOut = decoder.onOutput;
|
|
125
|
+
const onOut = (meta) => { prevOut?.(meta); this.#onOutput(meta); };
|
|
126
|
+
decoder.onOutput = onOut;
|
|
127
|
+
// `close()` puts the decoder's previous callbacks back when ours are still the ones installed.
|
|
128
|
+
this.#restore = () => {
|
|
129
|
+
if (decoder.onRequestIdr === onIdr)
|
|
130
|
+
decoder.onRequestIdr = prevIdr;
|
|
131
|
+
if (decoder.onOutput === onOut)
|
|
132
|
+
decoder.onOutput = prevOut;
|
|
133
|
+
};
|
|
134
|
+
// Every frame reaches the decoder (queueDepth 0): the decoder's own queue sheds, in picture order.
|
|
135
|
+
this.sub = node.subscribe(this.topic, { priority: opts.priority ?? 2, queueDepth: 0 });
|
|
136
|
+
void this.#read();
|
|
137
|
+
}
|
|
138
|
+
async #read() {
|
|
139
|
+
for (;;) {
|
|
140
|
+
const batch = await this.sub.next();
|
|
141
|
+
if (!batch || this.#closed)
|
|
142
|
+
return;
|
|
143
|
+
for (const env of batch)
|
|
144
|
+
this.#onEnvelope(env);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
#onEnvelope(env) {
|
|
148
|
+
if (this.#closed)
|
|
149
|
+
return;
|
|
150
|
+
const f = mediaFrameOf(env);
|
|
151
|
+
this.#received++;
|
|
152
|
+
this.#win.frames++;
|
|
153
|
+
this.#win.bytes += f.data.length;
|
|
154
|
+
const recvUs = nowUs();
|
|
155
|
+
// Stamps within the publisher's clock need no conversion; those compared with ours go through `toLocalUs` (SDK §4.4).
|
|
156
|
+
if (f.stages) {
|
|
157
|
+
if (f.stages.encodedUs > 0 && f.tUs > 0) {
|
|
158
|
+
this.#win.enc += (f.stages.encodedUs - f.tUs) / 1000;
|
|
159
|
+
this.#win.encN++;
|
|
160
|
+
}
|
|
161
|
+
if (f.stages.sentUs > 0 && f.stages.encodedUs > 0) {
|
|
162
|
+
this.#win.send += (f.stages.sentUs - f.stages.encodedUs) / 1000;
|
|
163
|
+
this.#win.sendN++;
|
|
164
|
+
}
|
|
165
|
+
if (f.stages.sentUs > 0) {
|
|
166
|
+
const sent = this.node.toLocalUs(f.stages.sentUs, env.dom);
|
|
167
|
+
if (sent) {
|
|
168
|
+
this.#win.net += (recvUs - sent.localUs) / 1000;
|
|
169
|
+
this.#win.netN++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (f.tUs > 0) {
|
|
174
|
+
const capture = this.node.toLocalUs(f.tUs, env.dom);
|
|
175
|
+
if (capture) {
|
|
176
|
+
this.#captureLocal.set(f, capture);
|
|
177
|
+
this.#win.eps += capture.epsUs / 1000;
|
|
178
|
+
this.#win.epsN++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const droppedBefore = this.decoder.sync.stats.dropped;
|
|
182
|
+
this.decoder.push(f);
|
|
183
|
+
if (this.decoder.sync.stats.dropped > droppedBefore) {
|
|
184
|
+
// Waiting for a start point: a browser publisher emits IDRs only on demand, so ask (first frame, then every stuck window).
|
|
185
|
+
this.#waitingDropped++;
|
|
186
|
+
if (this.#waitingDropped === 1 || this.#waitingDropped % this.decoder.sync.cfg.stuckFrames === 0)
|
|
187
|
+
this.#requestIdr("waiting");
|
|
188
|
+
}
|
|
189
|
+
else
|
|
190
|
+
this.#waitingDropped = 0;
|
|
191
|
+
}
|
|
192
|
+
#onOutput(meta) {
|
|
193
|
+
this.#win.dec += meta.decodeMs;
|
|
194
|
+
this.#win.decN++;
|
|
195
|
+
const capture = this.#captureLocal.get(meta.frame);
|
|
196
|
+
if (capture) {
|
|
197
|
+
this.#win.e2e += (nowUs() - capture.localUs) / 1000;
|
|
198
|
+
this.#win.e2eN++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
#requestIdr(why) {
|
|
202
|
+
if (this.opts.requestIdr === false || this.#closed)
|
|
203
|
+
return;
|
|
204
|
+
const now = performance.now();
|
|
205
|
+
if (now - this.#lastIdrMs < (this.opts.idrMinIntervalMs ?? 1000))
|
|
206
|
+
return;
|
|
207
|
+
this.#lastIdrMs = now;
|
|
208
|
+
this.#idrRequests++;
|
|
209
|
+
try {
|
|
210
|
+
this.node.call(idrServiceName(this.topic.name), {}, { deadlineMs: 1000 }).result.catch(() => { this.#idrFailed++; });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
this.#idrFailed++;
|
|
214
|
+
}
|
|
215
|
+
void why;
|
|
216
|
+
}
|
|
217
|
+
/** Window averages since the previous call plus totals; resets the window. */
|
|
218
|
+
stats() {
|
|
219
|
+
const w = this.#win;
|
|
220
|
+
const dt = (performance.now() - w.t0) / 1000;
|
|
221
|
+
const avg = (sum, n) => (n ? sum / n : undefined);
|
|
222
|
+
const d = this.decoder.stats();
|
|
223
|
+
const superseded = this.sub.stats.superseded;
|
|
224
|
+
const s = {
|
|
225
|
+
fps: dt > 0 ? (d.output - this.#lastOutput) / dt : 0, kbps: dt > 0 ? (w.bytes * 8) / dt / 1000 : 0,
|
|
226
|
+
received: this.#received, decoded: d.output, dropped: d.dropped + superseded, superseded, resets: d.resets, idrRequests: this.#idrRequests,
|
|
227
|
+
queue: d.queue, width: d.width, height: d.height, state: d.state,
|
|
228
|
+
latency: { e2eMs: avg(w.e2e, w.e2eN), encMs: avg(w.enc, w.encN), sendMs: avg(w.send, w.sendN), netMs: avg(w.net, w.netN), decodeMs: avg(w.dec, w.decN), epsMs: avg(w.eps, w.epsN) },
|
|
229
|
+
};
|
|
230
|
+
this.#lastOutput = d.output;
|
|
231
|
+
this.#win = freshWindow();
|
|
232
|
+
return s;
|
|
233
|
+
}
|
|
234
|
+
#lastOutput = 0;
|
|
235
|
+
/** IDR calls that failed (no publisher serving `<topic>/idr`, or timed out). */
|
|
236
|
+
get idrFailed() { return this.#idrFailed; }
|
|
237
|
+
/**
|
|
238
|
+
* Stops the subscription and gives the decoder back its previous callbacks (the decoder stays
|
|
239
|
+
* yours to close). Close subscribers sharing one decoder newest first: a subscriber whose
|
|
240
|
+
* callbacks another one wrapped since cannot take them back, so the newer one still calls them.
|
|
241
|
+
*/
|
|
242
|
+
close() {
|
|
243
|
+
if (this.#closed)
|
|
244
|
+
return;
|
|
245
|
+
this.#closed = true;
|
|
246
|
+
this.sub.unsubscribe();
|
|
247
|
+
this.#restore();
|
|
248
|
+
}
|
|
249
|
+
#restore;
|
|
250
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** `VideoDecoder`, `VideoEncoder`, `EncodedVideoChunk` and `VideoFrame` all exist in this runtime. */
|
|
2
|
+
export declare function webCodecsSupported(): boolean;
|
|
3
|
+
/** Throws `SynapseError("not_supported")` naming `what` when WebCodecs is missing. */
|
|
4
|
+
export declare function requireWebCodecs(what: string): void;
|
|
5
|
+
/** WebCodecs H.264 decode support for a codec string (default Constrained Baseline L3.1); `false` where WebCodecs is missing. */
|
|
6
|
+
export declare function h264DecodeSupported(codec?: string): Promise<boolean>;
|
|
7
|
+
/** WebCodecs H.264 *Annex-B* encode support for a frame size; `false` where WebCodecs is missing. */
|
|
8
|
+
export declare function h264EncodeSupported(width: number, height: number, codec?: string): Promise<boolean>;
|
|
9
|
+
/** Constrained Baseline, level 3.1: what a decoder is configured with when the stream has not shown its SPS yet (dylithium's default). */
|
|
10
|
+
export declare const DEFAULT_H264_CODEC = "avc1.42e01f";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// WebCodecs availability. The package must import cleanly where WebCodecs does not exist (Bun,
|
|
2
|
+
// Node, Firefox before 130, Safari before 16.4): nothing touches `VideoDecoder`/`VideoEncoder` at
|
|
3
|
+
// module load; the classes that need them throw `not_supported` from their constructors.
|
|
4
|
+
import { SynapseError } from "@synapse-sdk/core";
|
|
5
|
+
/** `VideoDecoder`, `VideoEncoder`, `EncodedVideoChunk` and `VideoFrame` all exist in this runtime. */
|
|
6
|
+
export function webCodecsSupported() {
|
|
7
|
+
const g = globalThis;
|
|
8
|
+
return typeof g.VideoDecoder === "function" && typeof g.VideoEncoder === "function" && typeof g.EncodedVideoChunk === "function" && typeof g.VideoFrame === "function";
|
|
9
|
+
}
|
|
10
|
+
/** Throws `SynapseError("not_supported")` naming `what` when WebCodecs is missing. */
|
|
11
|
+
export function requireWebCodecs(what) {
|
|
12
|
+
if (webCodecsSupported())
|
|
13
|
+
return;
|
|
14
|
+
throw new SynapseError("not_supported", `${what}: WebCodecs (VideoDecoder/VideoEncoder/VideoFrame) is not available in this runtime; H.264 in the browser needs Chrome/Chromium 94+ (the project decision is Chrome-only)`);
|
|
15
|
+
}
|
|
16
|
+
/** WebCodecs H.264 decode support for a codec string (default Constrained Baseline L3.1); `false` where WebCodecs is missing. */
|
|
17
|
+
export async function h264DecodeSupported(codec = DEFAULT_H264_CODEC) {
|
|
18
|
+
if (!webCodecsSupported())
|
|
19
|
+
return false;
|
|
20
|
+
try {
|
|
21
|
+
return (await VideoDecoder.isConfigSupported({ codec, optimizeForLatency: true })).supported === true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** WebCodecs H.264 *Annex-B* encode support for a frame size; `false` where WebCodecs is missing. */
|
|
28
|
+
export async function h264EncodeSupported(width, height, codec = DEFAULT_H264_CODEC) {
|
|
29
|
+
if (!webCodecsSupported())
|
|
30
|
+
return false;
|
|
31
|
+
try {
|
|
32
|
+
return (await VideoEncoder.isConfigSupported({ codec, width, height, avc: { format: "annexb" }, latencyMode: "realtime" })).supported === true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Constrained Baseline, level 3.1: what a decoder is configured with when the stream has not shown its SPS yet (dylithium's default). */
|
|
39
|
+
export const DEFAULT_H264_CODEC = "avc1.42e01f";
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapse-sdk/media",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Synapse SDK media: H.264 video in the browser — encoder, decoder, canvas sink, video publisher/subscriber and the React useVideo hook.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./react": {
|
|
13
|
+
"types": "./dist/react.d.ts",
|
|
14
|
+
"default": "./dist/react.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@synapse-sdk/core": "0.3.0",
|
|
22
|
+
"@synapse-sdk/client": "0.3.0"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@synapse-sdk/react": "0.3.0",
|
|
26
|
+
"react": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"peerDependenciesMeta": {
|
|
29
|
+
"@synapse-sdk/react": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"react": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"license": "UNLICENSED",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/taiga-robotics/synapse"
|
|
43
|
+
}
|
|
44
|
+
}
|