@synapse-sdk/media 0.0.0-stage → 0.3.3
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/camera.d.ts +11 -0
- package/dist/camera.js +45 -0
- package/dist/canvas.d.ts +18 -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 +171 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +16 -0
- package/dist/react.d.ts +15 -0
- package/dist/react.js +52 -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 +101 -0
- package/dist/video.d.ts +47 -0
- package/dist/video.js +172 -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/camera.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** `MediaStreamTrackProcessor` is available (Chrome 94+). */
|
|
2
|
+
export declare function trackProcessorSupported(): boolean;
|
|
3
|
+
/** The `VideoFrame`s of a video track as a `ReadableStream` (`maxBufferSize` 1: the newest frame wins, older ones are dropped by the browser). */
|
|
4
|
+
export declare function trackFrames(track: MediaStreamTrack): ReadableStream<VideoFrame>;
|
|
5
|
+
/**
|
|
6
|
+
* Pumps a camera track into `publish` (a video publisher's) until the track ends or `signal`
|
|
7
|
+
* aborts; each frame is closed after the call. Resolves with the number of frames handed over.
|
|
8
|
+
*/
|
|
9
|
+
export declare function pumpTrack(track: MediaStreamTrack, publish: (frame: VideoFrame, tUs: number) => void, opts?: {
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}): Promise<number>;
|
package/dist/camera.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Camera frames into a video publisher: `MediaStreamTrackProcessor` (Chrome-only, not in
|
|
2
|
+
// TypeScript's DOM lib, so it is reached through `globalThis`) turns a `MediaStreamTrack` into a
|
|
3
|
+
// stream of `VideoFrame`s. Each frame is published with `tUs = nowUs()` at dequeue (stamp at
|
|
4
|
+
// capture, as 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 `publish` (a video publisher's) until the track ends or `signal`
|
|
17
|
+
* aborts; each frame is closed after the call. Resolves with the number of frames handed over.
|
|
18
|
+
*/
|
|
19
|
+
export async function pumpTrack(track, publish, opts = {}) {
|
|
20
|
+
const reader = trackFrames(track).getReader();
|
|
21
|
+
const onAbort = () => { void reader.cancel(); };
|
|
22
|
+
if (opts.signal?.aborted)
|
|
23
|
+
onAbort();
|
|
24
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
25
|
+
let n = 0;
|
|
26
|
+
try {
|
|
27
|
+
for (;;) {
|
|
28
|
+
const { value, done } = await reader.read();
|
|
29
|
+
if (done || !value)
|
|
30
|
+
break;
|
|
31
|
+
try {
|
|
32
|
+
publish(value, nowUs());
|
|
33
|
+
n++;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
value.close();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
42
|
+
reader.releaseLock();
|
|
43
|
+
}
|
|
44
|
+
return n;
|
|
45
|
+
}
|
package/dist/canvas.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CanvasSinkOptions {
|
|
2
|
+
/** Coalesce to the display refresh (drops superseded pictures). Default false: draw immediately. */
|
|
3
|
+
vsync?: boolean;
|
|
4
|
+
/** Resize the canvas bitmap to the picture (default true). `false` scales the picture into the canvas as it is. */
|
|
5
|
+
resize?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface CanvasSink {
|
|
8
|
+
/** Pictures drawn. */
|
|
9
|
+
readonly drawn: number;
|
|
10
|
+
/** Pictures closed undrawn because a newer one arrived first (`vsync` only). */
|
|
11
|
+
readonly dropped: number;
|
|
12
|
+
detach(): void;
|
|
13
|
+
}
|
|
14
|
+
/** Anything with a decoder-style `attachSink` that hands over pictures the sink then owns (an `H264Decoder`). */
|
|
15
|
+
export interface VideoSource {
|
|
16
|
+
attachSink(sink: (vf: VideoFrame) => void): () => void;
|
|
17
|
+
}
|
|
18
|
+
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 "@synapse-sdk/core";
|
|
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`; the sync policy raises it while waiting too).
|
|
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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type MediaFrame } from "@synapse-sdk/core";
|
|
2
|
+
export interface H264EncoderOptions {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
/** Frames per second the source will deliver (rate control and level selection); default 30. */
|
|
6
|
+
fps?: number;
|
|
7
|
+
/** Target bits per second; default `width × height × fps × 0.15` (about 260 kbps at 320×180@30). */
|
|
8
|
+
bitrate?: number;
|
|
9
|
+
/** Codec string; default Baseline (`avc1.4200XX`) with the level chosen from width × height × fps × bitrate (H.264 Table A-1). */
|
|
10
|
+
codec?: string;
|
|
11
|
+
/** Default `"no-preference"`. */
|
|
12
|
+
hardwareAcceleration?: HardwareAcceleration;
|
|
13
|
+
/** Default `"realtime"`. */
|
|
14
|
+
latencyMode?: LatencyMode;
|
|
15
|
+
/** Default `"constant"` (CBR, the dylithium encoder option block). */
|
|
16
|
+
bitrateMode?: VideoEncoderBitrateMode;
|
|
17
|
+
/** Force a key frame every N encoded frames; 0 (default) = only the first frame, `forceIdr()` and the IDR service. */
|
|
18
|
+
keyframeInterval?: number;
|
|
19
|
+
/** Captured frames are dropped, not queued, while the encoder holds this many (default 2): drop captured, never encoded. */
|
|
20
|
+
maxQueue?: number;
|
|
21
|
+
onError?: (e: unknown) => void;
|
|
22
|
+
onLog?: (msg: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface H264EncoderStats {
|
|
25
|
+
/** Access units produced. */
|
|
26
|
+
encoded: number;
|
|
27
|
+
keyframes: number;
|
|
28
|
+
/** Captured frames dropped because the encoder was behind (`maxQueue`). */
|
|
29
|
+
dropped: number;
|
|
30
|
+
/** `encode()` throws and encoder `error` callbacks. */
|
|
31
|
+
errors: number;
|
|
32
|
+
/** Encoder queue depth right now. */
|
|
33
|
+
queue: number;
|
|
34
|
+
/** Bits per second in force. */
|
|
35
|
+
bitrate: number;
|
|
36
|
+
/** Smoothed `encode()`-to-chunk time, ms. */
|
|
37
|
+
encodeMs: number;
|
|
38
|
+
/** Encoded bytes produced. */
|
|
39
|
+
bytes: number;
|
|
40
|
+
codec: string;
|
|
41
|
+
}
|
|
42
|
+
/** Receives encoded access units (`stages.encodedUs` stamped, `stages.sentUs` 0 until the publisher writes). */
|
|
43
|
+
export type MediaSink = (frame: MediaFrame) => void;
|
|
44
|
+
/** Lowest H.264 level (level_idc) whose Table A-1 limits cover the picture rate, frame size and Baseline bitrate. */
|
|
45
|
+
export declare function h264LevelIdc(width: number, height: number, fps: number, bitrateBps: number): number;
|
|
46
|
+
export declare class H264Encoder {
|
|
47
|
+
#private;
|
|
48
|
+
private readonly opts;
|
|
49
|
+
readonly cfg: VideoEncoderConfig;
|
|
50
|
+
constructor(opts: H264EncoderOptions);
|
|
51
|
+
/** Whether WebCodecs can encode this configuration here (Annex-B, realtime). */
|
|
52
|
+
static isSupported(opts: Pick<H264EncoderOptions, "width" | "height" | "fps" | "bitrate" | "codec" | "hardwareAcceleration">): Promise<boolean>;
|
|
53
|
+
/** Adds a consumer of encoded access units; returns a detach function. */
|
|
54
|
+
attachSink(sink: MediaSink): () => void;
|
|
55
|
+
/**
|
|
56
|
+
* Encodes one picture: a `VideoFrame` (camera, `MediaStreamTrackProcessor`) or any
|
|
57
|
+
* `CanvasImageSource` (a canvas, video element, image bitmap). `tUs` is the capture time in the
|
|
58
|
+
* node clock (default `nowUs()` now) and rides through `VideoFrame.timestamp` to the chunk, so
|
|
59
|
+
* the published `t_us` is the capture stamp. The caller keeps ownership of a `VideoFrame` source
|
|
60
|
+
* (close it afterwards). Returns `false` when the frame was dropped (encoder behind, or closed).
|
|
61
|
+
*/
|
|
62
|
+
encode(source: VideoFrame | CanvasImageSource, opts?: {
|
|
63
|
+
tUs?: number;
|
|
64
|
+
keyFrame?: boolean;
|
|
65
|
+
}): boolean;
|
|
66
|
+
/** The next encoded frame is an IDR (the `<topic>/idr` service calls this). */
|
|
67
|
+
forceIdr(): void;
|
|
68
|
+
/** Changes the target bitrate (reconfigures; the next frame is an IDR so the new rate starts clean). */
|
|
69
|
+
setBitrate(bps: number): void;
|
|
70
|
+
/** Waits for every queued frame to come out. */
|
|
71
|
+
flush(): Promise<void>;
|
|
72
|
+
stats(): H264EncoderStats;
|
|
73
|
+
close(): void;
|
|
74
|
+
}
|
package/dist/encoder.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// WebCodecs H.264 encoder for a live stream (R27 §4; dylithium had no browser encoder).
|
|
2
|
+
//
|
|
3
|
+
// `avc: { format: "annexb" }` puts SPS/PPS in-band on every key chunk, so the bytes are exactly
|
|
4
|
+
// what a `video/h264` topic carries (no avcC, no repackaging) and the flags are read off the
|
|
5
|
+
// bitstream like every other Synapse publisher. `latencyMode: "realtime"`, `bitrateMode:
|
|
6
|
+
// "constant"`, `hardwareAcceleration: "no-preference"` (Chrome picks; software OpenH264 in
|
|
7
|
+
// headless Chromium). WebCodecs has no GOP and no intra-refresh: keyframes happen on the first
|
|
8
|
+
// frame, on `forceIdr()` (the `<topic>/idr` service `videoPublisher` serves) and optionally every `keyframeInterval`
|
|
9
|
+
// frames; a receiver's policy for a browser publisher is therefore IDR-only, which is fine.
|
|
10
|
+
// Captured frames are dropped when the encoder queue is behind, encoded ones never (lessons register).
|
|
11
|
+
import { auParamSets, auSyncPoints, nowUs } from "@synapse-sdk/core";
|
|
12
|
+
import { requireWebCodecs } from "./webcodecs.js";
|
|
13
|
+
/** Lowest H.264 level (level_idc) whose Table A-1 limits cover the picture rate, frame size and Baseline bitrate. */
|
|
14
|
+
export function h264LevelIdc(width, height, fps, bitrateBps) {
|
|
15
|
+
const mbs = Math.ceil(width / 16) * Math.ceil(height / 16);
|
|
16
|
+
const mbps = mbs * fps;
|
|
17
|
+
const kbps = bitrateBps / 1000;
|
|
18
|
+
// [level_idc, MaxMBPS, MaxFS, MaxBR (kbit/s, Baseline/Main)]
|
|
19
|
+
const table = [
|
|
20
|
+
[0x0a, 1485, 99, 64], [0x0b, 3000, 396, 192], [0x0c, 6000, 396, 384], [0x0d, 11880, 396, 768], [0x14, 11880, 396, 2000],
|
|
21
|
+
[0x15, 19800, 792, 4000], [0x16, 20250, 1620, 4000], [0x1e, 40500, 1620, 10000], [0x1f, 108000, 3600, 14000], [0x20, 216000, 5120, 20000],
|
|
22
|
+
[0x28, 245760, 8192, 20000], [0x29, 245760, 8192, 50000], [0x2a, 522240, 8704, 50000], [0x32, 589824, 22080, 135000], [0x33, 983040, 36864, 240000], [0x34, 2073600, 36864, 240000],
|
|
23
|
+
];
|
|
24
|
+
for (const [idc, maxMbps, maxFs, maxBr] of table)
|
|
25
|
+
if (mbps <= maxMbps && mbs <= maxFs && kbps <= maxBr)
|
|
26
|
+
return idc;
|
|
27
|
+
return 0x34;
|
|
28
|
+
}
|
|
29
|
+
export class H264Encoder {
|
|
30
|
+
opts;
|
|
31
|
+
cfg;
|
|
32
|
+
#enc;
|
|
33
|
+
#sinks = new Set();
|
|
34
|
+
#forceKey = true;
|
|
35
|
+
#sinceKey = 0;
|
|
36
|
+
#inflight = new Map();
|
|
37
|
+
#encodeMs = 0;
|
|
38
|
+
#stats = { encoded: 0, keyframes: 0, dropped: 0, errors: 0, bytes: 0 };
|
|
39
|
+
#closed = false;
|
|
40
|
+
constructor(opts) {
|
|
41
|
+
this.opts = opts;
|
|
42
|
+
requireWebCodecs("H264Encoder");
|
|
43
|
+
const fps = opts.fps ?? 30;
|
|
44
|
+
const bitrate = opts.bitrate ?? Math.round(opts.width * opts.height * fps * 0.15);
|
|
45
|
+
const codec = opts.codec ?? `avc1.4200${h264LevelIdc(opts.width, opts.height, fps, bitrate).toString(16).padStart(2, "0")}`;
|
|
46
|
+
this.cfg = {
|
|
47
|
+
codec, width: opts.width, height: opts.height, framerate: fps, bitrate,
|
|
48
|
+
bitrateMode: opts.bitrateMode ?? "constant", latencyMode: opts.latencyMode ?? "realtime", hardwareAcceleration: opts.hardwareAcceleration ?? "no-preference",
|
|
49
|
+
avc: { format: "annexb" },
|
|
50
|
+
};
|
|
51
|
+
this.#enc = new VideoEncoder({ output: (chunk, meta) => this.#onChunk(chunk, meta), error: e => { this.#stats.errors++; this.#log(`encoder error: ${e.message}`); this.opts.onError?.(e); } });
|
|
52
|
+
this.#enc.configure(this.cfg);
|
|
53
|
+
this.#log(`encoder configured: ${codec} ${opts.width}x${opts.height}@${fps} ${bitrate} bps`);
|
|
54
|
+
}
|
|
55
|
+
/** Whether WebCodecs can encode this configuration here (Annex-B, realtime). */
|
|
56
|
+
static async isSupported(opts) {
|
|
57
|
+
if (typeof VideoEncoder !== "function")
|
|
58
|
+
return false;
|
|
59
|
+
const fps = opts.fps ?? 30;
|
|
60
|
+
const bitrate = opts.bitrate ?? Math.round(opts.width * opts.height * fps * 0.15);
|
|
61
|
+
const codec = opts.codec ?? `avc1.4200${h264LevelIdc(opts.width, opts.height, fps, bitrate).toString(16).padStart(2, "0")}`;
|
|
62
|
+
try {
|
|
63
|
+
const r = await VideoEncoder.isConfigSupported({ codec, width: opts.width, height: opts.height, framerate: fps, bitrate, latencyMode: "realtime", hardwareAcceleration: opts.hardwareAcceleration ?? "no-preference", avc: { format: "annexb" } });
|
|
64
|
+
return r.supported === true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Adds a consumer of encoded access units; returns a detach function. */
|
|
71
|
+
attachSink(sink) { this.#sinks.add(sink); return () => { this.#sinks.delete(sink); }; }
|
|
72
|
+
/**
|
|
73
|
+
* Encodes one picture: a `VideoFrame` (camera, `MediaStreamTrackProcessor`) or any
|
|
74
|
+
* `CanvasImageSource` (a canvas, video element, image bitmap). `tUs` is the capture time in the
|
|
75
|
+
* node clock (default `nowUs()` now) and rides through `VideoFrame.timestamp` to the chunk, so
|
|
76
|
+
* the published `t_us` is the capture stamp. The caller keeps ownership of a `VideoFrame` source
|
|
77
|
+
* (close it afterwards). Returns `false` when the frame was dropped (encoder behind, or closed).
|
|
78
|
+
*/
|
|
79
|
+
encode(source, opts = {}) {
|
|
80
|
+
if (this.#closed)
|
|
81
|
+
return false;
|
|
82
|
+
if (this.#enc.encodeQueueSize >= (this.opts.maxQueue ?? 2)) {
|
|
83
|
+
this.#stats.dropped++;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const tUs = Math.round(opts.tUs ?? nowUs());
|
|
87
|
+
const interval = this.opts.keyframeInterval ?? 0;
|
|
88
|
+
const key = this.#forceKey || opts.keyFrame === true || (interval > 0 && this.#sinceKey >= interval);
|
|
89
|
+
let vf;
|
|
90
|
+
try {
|
|
91
|
+
vf = new VideoFrame(source, { timestamp: tUs });
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
this.#stats.errors++;
|
|
95
|
+
this.opts.onError?.(e);
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
this.#enc.encode(vf, { keyFrame: key });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
this.#stats.errors++;
|
|
103
|
+
this.#log(`encode() threw: ${e instanceof Error ? e.message : String(e)}`);
|
|
104
|
+
this.opts.onError?.(e);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
vf.close();
|
|
109
|
+
}
|
|
110
|
+
this.#inflight.set(tUs, performance.now());
|
|
111
|
+
this.#forceKey = false;
|
|
112
|
+
this.#sinceKey = key ? 1 : this.#sinceKey + 1;
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
/** The next encoded frame is an IDR (the `<topic>/idr` service calls this). */
|
|
116
|
+
forceIdr() { this.#forceKey = true; }
|
|
117
|
+
/** Changes the target bitrate (reconfigures; the next frame is an IDR so the new rate starts clean). */
|
|
118
|
+
setBitrate(bps) {
|
|
119
|
+
if (this.#closed || bps <= 0)
|
|
120
|
+
return;
|
|
121
|
+
this.cfg.bitrate = Math.round(bps);
|
|
122
|
+
try {
|
|
123
|
+
this.#enc.configure(this.cfg);
|
|
124
|
+
this.#forceKey = true;
|
|
125
|
+
this.#log(`bitrate → ${Math.round(bps)} bps`);
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
this.#stats.errors++;
|
|
129
|
+
this.opts.onError?.(e);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Waits for every queued frame to come out. */
|
|
133
|
+
async flush() { if (!this.#closed && this.#enc.state === "configured")
|
|
134
|
+
await this.#enc.flush(); }
|
|
135
|
+
#onChunk(chunk, _meta) {
|
|
136
|
+
const data = new Uint8Array(chunk.byteLength);
|
|
137
|
+
chunk.copyTo(data);
|
|
138
|
+
// Flags from the bitstream like every Synapse publisher; R27 §4: a key chunk is an IDR (the bitstream says so too).
|
|
139
|
+
const { idr, recovery } = auSyncPoints(data);
|
|
140
|
+
const f = { data, tUs: chunk.timestamp, key: idr || chunk.type === "key", config: auParamSets(data) !== undefined, recovery, stages: { encodedUs: nowUs(), sentUs: 0 } };
|
|
141
|
+
const started = this.#inflight.get(chunk.timestamp);
|
|
142
|
+
if (started !== undefined) {
|
|
143
|
+
this.#inflight.delete(chunk.timestamp);
|
|
144
|
+
const ms = performance.now() - started;
|
|
145
|
+
this.#encodeMs = this.#encodeMs === 0 ? ms : this.#encodeMs * 0.9 + ms * 0.1;
|
|
146
|
+
}
|
|
147
|
+
this.#stats.encoded++;
|
|
148
|
+
if (f.key) {
|
|
149
|
+
this.#stats.keyframes++;
|
|
150
|
+
this.#sinceKey = Math.min(this.#sinceKey, 1);
|
|
151
|
+
}
|
|
152
|
+
this.#stats.bytes += data.length;
|
|
153
|
+
for (const s of this.#sinks)
|
|
154
|
+
s(f);
|
|
155
|
+
}
|
|
156
|
+
stats() {
|
|
157
|
+
return { ...this.#stats, queue: this.#closed ? 0 : this.#enc.encodeQueueSize, bitrate: this.cfg.bitrate ?? 0, encodeMs: this.#encodeMs, codec: this.cfg.codec };
|
|
158
|
+
}
|
|
159
|
+
close() {
|
|
160
|
+
if (this.#closed)
|
|
161
|
+
return;
|
|
162
|
+
this.#closed = true;
|
|
163
|
+
try {
|
|
164
|
+
this.#enc.close();
|
|
165
|
+
}
|
|
166
|
+
catch { /* already closed */ }
|
|
167
|
+
this.#sinks.clear();
|
|
168
|
+
this.#inflight.clear();
|
|
169
|
+
}
|
|
170
|
+
#log(msg) { this.opts.onLog?.(msg); }
|
|
171
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { AuSplitter, NAL_TYPE, SEI_RECOVERY_POINT, auParamSets, auSyncPoints, codecStringFromSps, concatBytes, findSps, findStartCode, hasVcl, isKeyframe, isVclType, nalIsVcl, nalPayload, nalType, nalUnits, nalWithStartCode, rbspUnescape, seiHasRecoveryPoint, seiPayloadTypes, splitAccessUnits, type Nal, } from "@synapse-sdk/core";
|
|
2
|
+
export * from "./reinsert.ts";
|
|
3
|
+
export * from "./sync.ts";
|
|
4
|
+
export * from "./webcodecs.ts";
|
|
5
|
+
export * from "./decoder.ts";
|
|
6
|
+
export * from "./canvas.ts";
|
|
7
|
+
export * from "./encoder.ts";
|
|
8
|
+
export * from "./video.ts";
|
|
9
|
+
export { trackFrames, trackProcessorSupported } from "./camera.ts";
|
|
10
|
+
export type { MediaFrame, Stages } from "@synapse-sdk/core";
|
|
11
|
+
export { isStartPoint, mediaFlags, mediaFrameFromHeader, mediaHeader, stagesOf } from "@synapse-sdk/core";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @synapse-sdk/media: H.264 video over Synapse in the browser.
|
|
2
|
+
// - `videoPublisher(node, topic)` / `videoSubscriber(node, topic)`: pictures in, pictures out.
|
|
3
|
+
// - `H264Encoder` / `H264Decoder`: WebCodecs, for a two-step path of your own (constructors throw `not_supported` without WebCodecs).
|
|
4
|
+
// - `ParamSetCache` / `DecoderSync`: the publisher re-insertion and decoder sync policies, identical in every SDK.
|
|
5
|
+
// - The Annex-B walkers (flags from the bitstream), `attachCanvas`, camera helpers.
|
|
6
|
+
// The React hook lives in `@synapse-sdk/media/react`.
|
|
7
|
+
export { AuSplitter, NAL_TYPE, SEI_RECOVERY_POINT, auParamSets, auSyncPoints, codecStringFromSps, concatBytes, findSps, findStartCode, hasVcl, isKeyframe, isVclType, nalIsVcl, nalPayload, nalType, nalUnits, nalWithStartCode, rbspUnescape, seiHasRecoveryPoint, seiPayloadTypes, splitAccessUnits, } from "@synapse-sdk/core";
|
|
8
|
+
export * from "./reinsert.js";
|
|
9
|
+
export * from "./sync.js";
|
|
10
|
+
export * from "./webcodecs.js";
|
|
11
|
+
export * from "./decoder.js";
|
|
12
|
+
export * from "./canvas.js";
|
|
13
|
+
export * from "./encoder.js";
|
|
14
|
+
export * from "./video.js";
|
|
15
|
+
export { trackFrames, trackProcessorSupported } from "./camera.js";
|
|
16
|
+
export { isStartPoint, mediaFlags, mediaFrameFromHeader, mediaHeader, stagesOf } from "@synapse-sdk/core";
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Synapse, TopicRef } from "@synapse-sdk/client";
|
|
2
|
+
import { type SynapseError } from "@synapse-sdk/core";
|
|
3
|
+
import { type CanvasSinkOptions } from "./canvas.ts";
|
|
4
|
+
export interface UseVideoOptions extends CanvasSinkOptions {
|
|
5
|
+
/** The node to use instead of the one from `<SynapseProvider>`. */
|
|
6
|
+
node?: Synapse;
|
|
7
|
+
}
|
|
8
|
+
export interface UseVideoResult {
|
|
9
|
+
/** Put this on the `<canvas>`: `<canvas ref={ref} />`. */
|
|
10
|
+
ref: (el: HTMLCanvasElement | null) => void;
|
|
11
|
+
/** `not_supported` when WebCodecs is missing, or whatever failed while setting up. */
|
|
12
|
+
error: SynapseError | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Decodes a video topic into a canvas. `topic` may be `"skip"` to render nothing without subscribing. */
|
|
15
|
+
export declare function useVideo(topic: TopicRef<Uint8Array, Uint8Array> | "skip", opts?: UseVideoOptions): UseVideoResult;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// `@synapse-sdk/media/react`: `useVideo(topic)` — a canvas ref callback for a video topic, on
|
|
2
|
+
// `videoSubscriber`. The per-frame path (decoder → canvas) never touches React. Needs a
|
|
3
|
+
// `<SynapseProvider>` (from `@synapse-sdk/react`) or `opts.node`.
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { toSynapseError } from "@synapse-sdk/core";
|
|
6
|
+
import { useSynapseOptional } from "@synapse-sdk/react";
|
|
7
|
+
import { attachCanvas } from "./canvas.js";
|
|
8
|
+
import { videoSubscriber } from "./video.js";
|
|
9
|
+
/** Decodes a video topic into a canvas. `topic` may be `"skip"` to render nothing without subscribing. */
|
|
10
|
+
export function useVideo(topic, opts = {}) {
|
|
11
|
+
const ctxNode = useSynapseOptional();
|
|
12
|
+
const node = opts.node ?? ctxNode;
|
|
13
|
+
const [canvas, setCanvas] = useState(null);
|
|
14
|
+
const [error, setError] = useState(undefined);
|
|
15
|
+
const optsRef = useRef(opts);
|
|
16
|
+
optsRef.current = opts;
|
|
17
|
+
const ref = useCallback((el) => setCanvas(el), []);
|
|
18
|
+
const topicName = topic === "skip" ? undefined : typeof topic === "string" ? topic : topic.name;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!canvas || !node || topic === "skip")
|
|
21
|
+
return;
|
|
22
|
+
const o = optsRef.current;
|
|
23
|
+
try {
|
|
24
|
+
// The canvas sink owns each picture handed to it (draws, then closes it).
|
|
25
|
+
let sink;
|
|
26
|
+
const drawn = attachCanvas({ attachSink: s => { sink = s; return () => { if (sink === s)
|
|
27
|
+
sink = undefined; }; } }, canvas, { vsync: o.vsync, resize: o.resize });
|
|
28
|
+
let video;
|
|
29
|
+
try {
|
|
30
|
+
video = videoSubscriber(node, topic);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
drawn.detach();
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
void (async () => { for await (const vf of video) {
|
|
37
|
+
if (sink)
|
|
38
|
+
sink(vf);
|
|
39
|
+
else
|
|
40
|
+
vf.close();
|
|
41
|
+
} })();
|
|
42
|
+
setError(undefined);
|
|
43
|
+
return () => { video.close(); drawn.detach(); };
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
setError(toSynapseError(e, "not_supported", "sdk"));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
|
+
}, [node, canvas, topicName]);
|
|
51
|
+
return { ref, error };
|
|
52
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MediaFrame } from "@synapse-sdk/core";
|
|
2
|
+
/** Remembers the most recent SPS+PPS (with start codes) and re-inserts them where a decoder needs them. */
|
|
3
|
+
export declare class ParamSetCache {
|
|
4
|
+
#private;
|
|
5
|
+
/** Frames the publisher policy re-inserted into (diagnostics). */
|
|
6
|
+
reinserted: number;
|
|
7
|
+
/** The cached SPS+PPS bytes, if any were seen. */
|
|
8
|
+
get paramSets(): Uint8Array | undefined;
|
|
9
|
+
get isPrimed(): boolean;
|
|
10
|
+
/** Looks at an AU: caches its parameter sets when it carries some. Returns `true` if it did. */
|
|
11
|
+
observe(au: Uint8Array): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Publisher policy on an outgoing frame: cache parameter sets when present; when absent on a
|
|
14
|
+
* recovery point (or when `force`), prepend the cached ones and set `config`. Frames that need
|
|
15
|
+
* nothing pass through unchanged (no copy).
|
|
16
|
+
*/
|
|
17
|
+
prepare(frame: MediaFrame, force?: boolean): MediaFrame;
|
|
18
|
+
/**
|
|
19
|
+
* Receiver side: the AU with parameter sets in front when it lacks them and the cache has some
|
|
20
|
+
* (`undefined` if neither is available, i.e. the AU cannot start a decoder on its own). Caches
|
|
21
|
+
* the AU's own parameter sets first, so the newest ones always win.
|
|
22
|
+
*/
|
|
23
|
+
prime(au: Uint8Array): Uint8Array | undefined;
|
|
24
|
+
}
|
package/dist/reinsert.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { auParamSets, concatBytes } from "@synapse-sdk/core";
|
|
2
|
+
/** Remembers the most recent SPS+PPS (with start codes) and re-inserts them where a decoder needs them. */
|
|
3
|
+
export class ParamSetCache {
|
|
4
|
+
#paramSets;
|
|
5
|
+
/** Frames the publisher policy re-inserted into (diagnostics). */
|
|
6
|
+
reinserted = 0;
|
|
7
|
+
/** The cached SPS+PPS bytes, if any were seen. */
|
|
8
|
+
get paramSets() { return this.#paramSets; }
|
|
9
|
+
get isPrimed() { return this.#paramSets !== undefined; }
|
|
10
|
+
/** Looks at an AU: caches its parameter sets when it carries some. Returns `true` if it did. */
|
|
11
|
+
observe(au) {
|
|
12
|
+
const ps = auParamSets(au);
|
|
13
|
+
if (!ps)
|
|
14
|
+
return false;
|
|
15
|
+
this.#paramSets = ps;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Publisher policy on an outgoing frame: cache parameter sets when present; when absent on a
|
|
20
|
+
* recovery point (or when `force`), prepend the cached ones and set `config`. Frames that need
|
|
21
|
+
* nothing pass through unchanged (no copy).
|
|
22
|
+
*/
|
|
23
|
+
prepare(frame, force = false) {
|
|
24
|
+
if (this.observe(frame.data))
|
|
25
|
+
return frame.config ? frame : { ...frame, config: true };
|
|
26
|
+
if (!(frame.recovery || force) || !this.#paramSets)
|
|
27
|
+
return frame;
|
|
28
|
+
this.reinserted++;
|
|
29
|
+
return { ...frame, data: concatBytes([this.#paramSets, frame.data]), config: true };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Receiver side: the AU with parameter sets in front when it lacks them and the cache has some
|
|
33
|
+
* (`undefined` if neither is available, i.e. the AU cannot start a decoder on its own). Caches
|
|
34
|
+
* the AU's own parameter sets first, so the newest ones always win.
|
|
35
|
+
*/
|
|
36
|
+
prime(au) {
|
|
37
|
+
if (this.observe(au))
|
|
38
|
+
return au;
|
|
39
|
+
return this.#paramSets ? concatBytes([this.#paramSets, au]) : undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { MediaFrame } from "@synapse-sdk/core";
|
|
2
|
+
import { ParamSetCache } from "./reinsert.ts";
|
|
3
|
+
export interface SyncConfig {
|
|
4
|
+
/** Frames fed without output before an IDR is requested (about a third of a second at 30 fps). */
|
|
5
|
+
stuckFrames: number;
|
|
6
|
+
/** Frames fed without output before the decoder is dropped outright. */
|
|
7
|
+
wedgedFrames: number;
|
|
8
|
+
/** Silence tolerated after a start at a recovery point (a refresh period plus slack). */
|
|
9
|
+
recoverySilenceFrames: number;
|
|
10
|
+
/**
|
|
11
|
+
* A recovery point with parameter sets is a start point (default true, the Rust/libav rule).
|
|
12
|
+
* `false` for decoders that can only start on an IDR: WebCodecs in Chromium verifies that a
|
|
13
|
+
* chunk labelled `key` carries an IDR and rejects the recovery-point start (`H264Decoder` sets this).
|
|
14
|
+
*/
|
|
15
|
+
startAtRecovery: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** `synapse_media::SyncConfig::default()`. */
|
|
18
|
+
export declare const SYNC_DEFAULT: Readonly<SyncConfig>;
|
|
19
|
+
/** `waiting`: no start point yet, frames are dropped. `primed`: started, nothing decoded yet. `streaming`: output seen since the start. */
|
|
20
|
+
export type SyncState = "waiting" | "primed" | "streaming";
|
|
21
|
+
/** What the owner should do about the decoder after a frame: ask the publisher for an IDR, or drop the decoder (the machine is back in `waiting`). */
|
|
22
|
+
export type SyncSignal = "request_idr" | "reset";
|
|
23
|
+
/** The verdict on one pushed frame. */
|
|
24
|
+
export interface SyncDecision {
|
|
25
|
+
/** Bytes to hand to the decoder (parameter sets prepended when needed), or `undefined` = dropped. */
|
|
26
|
+
feed: Uint8Array | undefined;
|
|
27
|
+
/** Label the chunk `key` for the decoder (an IDR, or the first chunk after a start). */
|
|
28
|
+
keyChunk: boolean;
|
|
29
|
+
/** The decoder was just (re)started with this frame: (re)configure it first. */
|
|
30
|
+
started: boolean;
|
|
31
|
+
signal: SyncSignal | undefined;
|
|
32
|
+
}
|
|
33
|
+
export interface SyncStats {
|
|
34
|
+
frames: number;
|
|
35
|
+
dropped: number;
|
|
36
|
+
fed: number;
|
|
37
|
+
output: number;
|
|
38
|
+
starts: number;
|
|
39
|
+
startsAtRecovery: number;
|
|
40
|
+
resets: number;
|
|
41
|
+
idrRequests: number;
|
|
42
|
+
}
|
|
43
|
+
export declare class DecoderSync {
|
|
44
|
+
#private;
|
|
45
|
+
readonly cfg: SyncConfig;
|
|
46
|
+
readonly stats: SyncStats;
|
|
47
|
+
/** The SPS/PPS cache (primed from every AU that carries them, even while waiting). */
|
|
48
|
+
readonly cache: ParamSetCache;
|
|
49
|
+
constructor(cfg?: Partial<SyncConfig>);
|
|
50
|
+
get state(): SyncState;
|
|
51
|
+
get paramSets(): Uint8Array | undefined;
|
|
52
|
+
/** Frames fed since the decoder last produced output. */
|
|
53
|
+
get fedSinceOutput(): number;
|
|
54
|
+
/** A received media frame. Decides whether to feed it and how to label it, and raises the stuck/wedged signals. */
|
|
55
|
+
push(frame: MediaFrame): SyncDecision;
|
|
56
|
+
/** The decoder produced a frame. */
|
|
57
|
+
onOutput(): void;
|
|
58
|
+
/** The decoder reported an error: drop it and wait for the next start point. */
|
|
59
|
+
onError(): void;
|
|
60
|
+
}
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ParamSetCache } from "./reinsert.js";
|
|
2
|
+
/** `synapse_media::SyncConfig::default()`. */
|
|
3
|
+
export const SYNC_DEFAULT = { stuckFrames: 10, wedgedFrames: 150, recoverySilenceFrames: 60, startAtRecovery: true };
|
|
4
|
+
export class DecoderSync {
|
|
5
|
+
cfg;
|
|
6
|
+
stats = { frames: 0, dropped: 0, fed: 0, output: 0, starts: 0, startsAtRecovery: 0, resets: 0, idrRequests: 0 };
|
|
7
|
+
/** The SPS/PPS cache (primed from every AU that carries them, even while waiting). */
|
|
8
|
+
cache = new ParamSetCache();
|
|
9
|
+
#state = "waiting";
|
|
10
|
+
#startedAtRecovery = false;
|
|
11
|
+
#syncChunk = false;
|
|
12
|
+
#fedSinceOutput = 0;
|
|
13
|
+
/** Frames since the last IDR request (or since the output or start): the stuck rule. */
|
|
14
|
+
#fedSinceRequest = 0;
|
|
15
|
+
constructor(cfg = {}) { this.cfg = { ...SYNC_DEFAULT, ...cfg }; }
|
|
16
|
+
get state() { return this.#state; }
|
|
17
|
+
get paramSets() { return this.cache.paramSets; }
|
|
18
|
+
/** Frames fed since the decoder last produced output. */
|
|
19
|
+
get fedSinceOutput() { return this.#fedSinceOutput; }
|
|
20
|
+
/** A received media frame. Decides whether to feed it and how to label it, and raises the stuck/wedged signals. */
|
|
21
|
+
push(frame) {
|
|
22
|
+
this.stats.frames++;
|
|
23
|
+
let started = false;
|
|
24
|
+
// Parameter sets are cached from every frame, synced or not (the cache primes later starts).
|
|
25
|
+
const inBand = this.cache.observe(frame.data);
|
|
26
|
+
let data = frame.data;
|
|
27
|
+
if (this.#state === "waiting") {
|
|
28
|
+
if (frame.key) {
|
|
29
|
+
this.#startedAtRecovery = false;
|
|
30
|
+
}
|
|
31
|
+
else if (this.cfg.startAtRecovery && frame.recovery && (inBand || this.cache.isPrimed)) {
|
|
32
|
+
if (!inBand) {
|
|
33
|
+
const primed = this.cache.prime(frame.data);
|
|
34
|
+
if (primed)
|
|
35
|
+
data = primed;
|
|
36
|
+
}
|
|
37
|
+
this.#startedAtRecovery = true;
|
|
38
|
+
this.stats.startsAtRecovery++;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// still waiting: after a stuck window of dropped frames, ask for an IDR
|
|
42
|
+
this.stats.dropped++;
|
|
43
|
+
this.#fedSinceOutput++;
|
|
44
|
+
this.#fedSinceRequest++;
|
|
45
|
+
let signal;
|
|
46
|
+
if (this.#fedSinceRequest >= this.cfg.stuckFrames) {
|
|
47
|
+
this.#fedSinceRequest = 0;
|
|
48
|
+
this.stats.idrRequests++;
|
|
49
|
+
signal = "request_idr";
|
|
50
|
+
}
|
|
51
|
+
return { feed: undefined, keyChunk: false, started: false, signal };
|
|
52
|
+
}
|
|
53
|
+
this.#state = "primed";
|
|
54
|
+
this.#syncChunk = true;
|
|
55
|
+
this.#fedSinceOutput = 0;
|
|
56
|
+
this.#fedSinceRequest = 0;
|
|
57
|
+
this.stats.starts++;
|
|
58
|
+
started = true;
|
|
59
|
+
}
|
|
60
|
+
const keyChunk = frame.key || this.#syncChunk;
|
|
61
|
+
this.#syncChunk = false;
|
|
62
|
+
this.#fedSinceOutput++;
|
|
63
|
+
this.#fedSinceRequest++;
|
|
64
|
+
this.stats.fed++;
|
|
65
|
+
return { feed: data, keyChunk, started, signal: this.#checkStuck() };
|
|
66
|
+
}
|
|
67
|
+
#checkStuck() {
|
|
68
|
+
const tolerated = this.#startedAtRecovery ? this.cfg.recoverySilenceFrames : this.cfg.stuckFrames;
|
|
69
|
+
if (this.#fedSinceOutput >= this.cfg.wedgedFrames) {
|
|
70
|
+
this.#reset();
|
|
71
|
+
return "reset";
|
|
72
|
+
}
|
|
73
|
+
if (this.#fedSinceRequest >= tolerated) {
|
|
74
|
+
// Give the IDR a full window before asking again (the wedged count runs on).
|
|
75
|
+
this.#fedSinceRequest = 0;
|
|
76
|
+
this.#startedAtRecovery = false;
|
|
77
|
+
this.stats.idrRequests++;
|
|
78
|
+
return "request_idr";
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
/** The decoder produced a frame. */
|
|
83
|
+
onOutput() {
|
|
84
|
+
this.#fedSinceOutput = 0;
|
|
85
|
+
this.#fedSinceRequest = 0;
|
|
86
|
+
this.#startedAtRecovery = false;
|
|
87
|
+
this.stats.output++;
|
|
88
|
+
if (this.#state === "primed")
|
|
89
|
+
this.#state = "streaming";
|
|
90
|
+
}
|
|
91
|
+
/** The decoder reported an error: drop it and wait for the next start point. */
|
|
92
|
+
onError() { this.#reset(); }
|
|
93
|
+
#reset() {
|
|
94
|
+
this.#state = "waiting";
|
|
95
|
+
this.#syncChunk = false;
|
|
96
|
+
this.#startedAtRecovery = false;
|
|
97
|
+
this.#fedSinceOutput = 0;
|
|
98
|
+
this.#fedSinceRequest = 0;
|
|
99
|
+
this.stats.resets++;
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/video.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type TopicRef } from "@synapse-sdk/core";
|
|
2
|
+
import type { Envelope, Synapse } from "@synapse-sdk/client";
|
|
3
|
+
import { H264Encoder, type H264EncoderOptions } from "./encoder.ts";
|
|
4
|
+
/** `videoPublisher` settings: the `H264Encoder` knobs a caller may want, all with defaults. */
|
|
5
|
+
export type VideoPublisherOptions = Pick<H264EncoderOptions, "fps" | "bitrate" | "hardwareAcceleration">;
|
|
6
|
+
/** Pictures in, `video/h264` out (`videoPublisher`). */
|
|
7
|
+
export interface VideoPublisher {
|
|
8
|
+
/**
|
|
9
|
+
* Encodes and publishes `frame`, stamped `tUs` (the capture time, if you have one; default now).
|
|
10
|
+
* Synchronous; the encoder drops a picture rather than queue behind (see `encoder.stats()`). The
|
|
11
|
+
* first picture opens the encoder at its size. The frame stays yours to close.
|
|
12
|
+
*/
|
|
13
|
+
publish(frame: VideoFrame, tUs?: number): void;
|
|
14
|
+
/** Publishes a camera track's frames until it ends or `signal` aborts (Chrome's `MediaStreamTrackProcessor`); resolves with the frames handed over. */
|
|
15
|
+
pumpTrack(track: MediaStreamTrack, opts?: {
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}): Promise<number>;
|
|
18
|
+
/** Ends the topic, the `<topic>/idr` service and the encoder. */
|
|
19
|
+
close(): void;
|
|
20
|
+
/** The encoder, once the first picture made it. */
|
|
21
|
+
readonly encoder: H264Encoder | undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A video publisher on `topic` (a name, or a `video/h264` topic: datagrams with FEC unless the
|
|
25
|
+
* contract says otherwise). Resolves once the topic and its `<topic>/idr` service are admitted.
|
|
26
|
+
*/
|
|
27
|
+
export declare function videoPublisher(node: Synapse, topic: TopicRef<Uint8Array, Uint8Array>, opts?: VideoPublisherOptions): Promise<VideoPublisher>;
|
|
28
|
+
/** Decoded pictures of a `video/h264` topic (`videoSubscriber`): `for await` them (you close each), or get them through `onFrame`. */
|
|
29
|
+
export interface VideoSubscriber extends AsyncIterable<VideoFrame> {
|
|
30
|
+
/** Stops the subscription and the decoder; pictures not yet read are closed. */
|
|
31
|
+
close(): void;
|
|
32
|
+
}
|
|
33
|
+
export interface VideoSubscriberOptions {
|
|
34
|
+
/**
|
|
35
|
+
* Each decoded picture with the envelope of the access unit it came from (`tUs` = capture time,
|
|
36
|
+
* `key`, `dom`, publisher). The picture is closed when the callback returns; `clone()` it to keep
|
|
37
|
+
* it. With `onFrame` the subscriber yields nothing.
|
|
38
|
+
*/
|
|
39
|
+
onFrame?: (frame: VideoFrame, meta: Envelope<Uint8Array>) => void;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Decoded pictures of the video topic `topic` (by name; the established descriptor says what it
|
|
43
|
+
* carries). Every frame is fed to the decoder in order and every picture it gives is delivered.
|
|
44
|
+
* Frames before a start point are dropped by the decoder's policy, which asks the publisher for an
|
|
45
|
+
* IDR when stuck or waiting. Throws `not_supported` where WebCodecs is missing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function videoSubscriber(node: Synapse, topic: TopicRef<Uint8Array, Uint8Array>, opts?: VideoSubscriberOptions): VideoSubscriber;
|
package/dist/video.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Video in one call each way (PLAN v0.25 §2), the browser spelling of the Rust client's `video.rs`:
|
|
2
|
+
// `videoPublisher(node, topic)` takes `VideoFrame`s (WebCodecs H.264 encode + publish in the
|
|
3
|
+
// caller's `publish`), `videoSubscriber(node, topic)` gives decoded `VideoFrame`s. Underneath they
|
|
4
|
+
// are the two-step path anyone may still take: an `H264Encoder` feeding a publisher on a
|
|
5
|
+
// `video/h264` topic, and a subscription feeding an `H264Decoder` (its `DecoderSync` policy decides
|
|
6
|
+
// where decoding starts and when to ask for an IDR).
|
|
7
|
+
//
|
|
8
|
+
// The publisher serves `<topic>/idr`: the next picture is an IDR with SPS/PPS. The subscriber calls
|
|
9
|
+
// it when its decoder's policy says `request_idr` (one call in flight at a time).
|
|
10
|
+
import { SynapseError, defineTopic } from "@synapse-sdk/core";
|
|
11
|
+
import { pumpTrack } from "./camera.js";
|
|
12
|
+
import { H264Decoder } from "./decoder.js";
|
|
13
|
+
import { H264Encoder } from "./encoder.js";
|
|
14
|
+
const H264 = "video/h264";
|
|
15
|
+
/** The key of a client publisher handle's encoder-frame path (`@synapse-sdk/client` `PUBLISH_MEDIA_FRAME`): flags and `STAGES` as the encoder made them. */
|
|
16
|
+
const PUBLISH_MEDIA_FRAME = Symbol.for("@synapse-sdk/client:publishMediaFrame");
|
|
17
|
+
/** The `video/h264` contract of `topic`: a name gets one; a contract must already be `video/h264`. */
|
|
18
|
+
function videoContract(topic) {
|
|
19
|
+
if (typeof topic === "string")
|
|
20
|
+
return defineTopic({ name: topic, contentType: H264 });
|
|
21
|
+
if (topic.contentType !== H264)
|
|
22
|
+
throw new SynapseError("invalid_input", `topic ${topic.name}: a video publisher needs contentType ${H264} (it has ${topic.contentType})`);
|
|
23
|
+
return topic;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A video publisher on `topic` (a name, or a `video/h264` topic: datagrams with FEC unless the
|
|
27
|
+
* contract says otherwise). Resolves once the topic and its `<topic>/idr` service are admitted.
|
|
28
|
+
*/
|
|
29
|
+
export async function videoPublisher(node, topic, opts = {}) {
|
|
30
|
+
const contract = videoContract(topic);
|
|
31
|
+
const publisher = await node.publisher(contract);
|
|
32
|
+
let encoder;
|
|
33
|
+
let requests = 0;
|
|
34
|
+
let idr;
|
|
35
|
+
try {
|
|
36
|
+
idr = await node.serve(`${contract.name}/idr`, () => { requests++; encoder?.forceIdr(); return { ok: true, requests }; }, { minRole: "observer", description: "force the next picture to be an IDR with SPS/PPS" });
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
publisher.close();
|
|
40
|
+
throw e;
|
|
41
|
+
}
|
|
42
|
+
let closed = false;
|
|
43
|
+
const send = publisher;
|
|
44
|
+
const publish = (frame, tUs) => {
|
|
45
|
+
if (closed)
|
|
46
|
+
throw new SynapseError("not_connected", "video publisher closed");
|
|
47
|
+
if (encoder && (encoder.cfg.width !== frame.displayWidth || encoder.cfg.height !== frame.displayHeight)) {
|
|
48
|
+
// TODO(video, D 2026-10-08): one publisher per video topic is assumed, not enforced;
|
|
49
|
+
// resolution and settings do not change while a publisher lives. Codify later.
|
|
50
|
+
throw new SynapseError("invalid_input", "frame size changed; one publisher, one size");
|
|
51
|
+
}
|
|
52
|
+
if (!encoder) {
|
|
53
|
+
encoder = new H264Encoder({ width: frame.displayWidth, height: frame.displayHeight, ...opts, onError: e => node.warn(`video ${contract.name}: encoder: ${e instanceof Error ? e.message : String(e)}`) });
|
|
54
|
+
// flags and `STAGES` as the encoder made them; `sentUs` 0 is stamped at the write
|
|
55
|
+
encoder.attachSink(f => {
|
|
56
|
+
try {
|
|
57
|
+
send[PUBLISH_MEDIA_FRAME](f);
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
node.warn(`video ${contract.name}: publish failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
encoder.encode(frame, tUs === undefined ? {} : { tUs });
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
publish,
|
|
68
|
+
pumpTrack: (track, o = {}) => pumpTrack(track, publish, o),
|
|
69
|
+
close() {
|
|
70
|
+
if (closed)
|
|
71
|
+
return;
|
|
72
|
+
closed = true;
|
|
73
|
+
encoder?.close();
|
|
74
|
+
idr.unregister();
|
|
75
|
+
publisher.close();
|
|
76
|
+
},
|
|
77
|
+
get encoder() { return encoder; },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Decoded pictures of the video topic `topic` (by name; the established descriptor says what it
|
|
82
|
+
* carries). Every frame is fed to the decoder in order and every picture it gives is delivered.
|
|
83
|
+
* Frames before a start point are dropped by the decoder's policy, which asks the publisher for an
|
|
84
|
+
* IDR when stuck or waiting. Throws `not_supported` where WebCodecs is missing.
|
|
85
|
+
*/
|
|
86
|
+
export function videoSubscriber(node, topic, opts = {}) {
|
|
87
|
+
const name = typeof topic === "string" ? topic : topic.name;
|
|
88
|
+
const idrService = `${name}/idr`;
|
|
89
|
+
const decoder = new H264Decoder();
|
|
90
|
+
// every frame reaches the decoder (a P picture references the one before it)
|
|
91
|
+
const sub = node.subscribe(name, { queueDepth: 0 });
|
|
92
|
+
const pending = [];
|
|
93
|
+
let wake;
|
|
94
|
+
let ended = false;
|
|
95
|
+
const signal = () => { const w = wake; wake = undefined; w?.(); };
|
|
96
|
+
let idrInFlight = false;
|
|
97
|
+
// `<topic>/idr` unless a call is still in flight; a failure is the policy's business (it asks again after another stuck window).
|
|
98
|
+
decoder.onRequestIdr = () => {
|
|
99
|
+
if (idrInFlight || ended)
|
|
100
|
+
return;
|
|
101
|
+
let run;
|
|
102
|
+
try {
|
|
103
|
+
run = node.call(idrService, {}, { deadlineMs: 1000 });
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return;
|
|
107
|
+
} // no session: the next window asks again
|
|
108
|
+
idrInFlight = true;
|
|
109
|
+
run.result.then(() => { }, () => { }).finally(() => { idrInFlight = false; });
|
|
110
|
+
};
|
|
111
|
+
decoder.attachSink((vf, meta) => {
|
|
112
|
+
if (ended) {
|
|
113
|
+
vf.close();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!opts.onFrame) {
|
|
117
|
+
pending.push(vf);
|
|
118
|
+
signal();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const env = meta.frame;
|
|
122
|
+
const m = "seq" in env ? env : { data: env.data, tUs: env.tUs, seq: 0, publisher: "", dom: "", flags: 0, type: 0, tlvs: [], key: env.key, config: env.config, recovery: env.recovery };
|
|
123
|
+
try {
|
|
124
|
+
opts.onFrame(vf, m);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
vf.close();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
const close = () => {
|
|
131
|
+
if (ended)
|
|
132
|
+
return;
|
|
133
|
+
ended = true;
|
|
134
|
+
sub.unsubscribe();
|
|
135
|
+
decoder.close();
|
|
136
|
+
for (const vf of pending.splice(0))
|
|
137
|
+
vf.close();
|
|
138
|
+
signal();
|
|
139
|
+
};
|
|
140
|
+
void (async () => {
|
|
141
|
+
for (;;) {
|
|
142
|
+
const batch = await sub.next();
|
|
143
|
+
if (!batch || ended)
|
|
144
|
+
break;
|
|
145
|
+
// an envelope is the decoder's `MediaFrame` (data, tUs, key, config, recovery): the picture's meta is the envelope itself
|
|
146
|
+
for (const env of batch)
|
|
147
|
+
if (env.data instanceof Uint8Array)
|
|
148
|
+
decoder.push(env);
|
|
149
|
+
}
|
|
150
|
+
close();
|
|
151
|
+
})();
|
|
152
|
+
return {
|
|
153
|
+
close,
|
|
154
|
+
async *[Symbol.asyncIterator]() {
|
|
155
|
+
try {
|
|
156
|
+
for (;;) {
|
|
157
|
+
const vf = pending.shift();
|
|
158
|
+
if (vf) {
|
|
159
|
+
yield vf;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (ended)
|
|
163
|
+
return;
|
|
164
|
+
await new Promise(r => { wake = r; });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
close();
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -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.3",
|
|
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.3",
|
|
22
|
+
"@synapse-sdk/client": "0.3.3"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@synapse-sdk/react": "0.3.3",
|
|
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
|
+
}
|