@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.
@@ -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
+ }
@@ -0,0 +1,173 @@
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 `MediaFrame.data` 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) 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 { nowUs } from "@synapse-sdk/core";
12
+ import { mediaFrameFromAnnexb } from "./annexb.js";
13
+ import { requireWebCodecs } from "./webcodecs.js";
14
+ /** Lowest H.264 level (level_idc) whose Table A-1 limits cover the picture rate, frame size and Baseline bitrate. */
15
+ export function h264LevelIdc(width, height, fps, bitrateBps) {
16
+ const mbs = Math.ceil(width / 16) * Math.ceil(height / 16);
17
+ const mbps = mbs * fps;
18
+ const kbps = bitrateBps / 1000;
19
+ // [level_idc, MaxMBPS, MaxFS, MaxBR (kbit/s, Baseline/Main)]
20
+ const table = [
21
+ [0x0a, 1485, 99, 64], [0x0b, 3000, 396, 192], [0x0c, 6000, 396, 384], [0x0d, 11880, 396, 768], [0x14, 11880, 396, 2000],
22
+ [0x15, 19800, 792, 4000], [0x16, 20250, 1620, 4000], [0x1e, 40500, 1620, 10000], [0x1f, 108000, 3600, 14000], [0x20, 216000, 5120, 20000],
23
+ [0x28, 245760, 8192, 20000], [0x29, 245760, 8192, 50000], [0x2a, 522240, 8704, 50000], [0x32, 589824, 22080, 135000], [0x33, 983040, 36864, 240000], [0x34, 2073600, 36864, 240000],
24
+ ];
25
+ for (const [idc, maxMbps, maxFs, maxBr] of table)
26
+ if (mbps <= maxMbps && mbs <= maxFs && kbps <= maxBr)
27
+ return idc;
28
+ return 0x34;
29
+ }
30
+ export class H264Encoder {
31
+ opts;
32
+ cfg;
33
+ #enc;
34
+ #sinks = new Set();
35
+ #forceKey = true;
36
+ #sinceKey = 0;
37
+ #inflight = new Map();
38
+ #encodeMs = 0;
39
+ #stats = { encoded: 0, keyframes: 0, dropped: 0, errors: 0, bytes: 0 };
40
+ #closed = false;
41
+ constructor(opts) {
42
+ this.opts = opts;
43
+ requireWebCodecs("H264Encoder");
44
+ const fps = opts.fps ?? 30;
45
+ const bitrate = opts.bitrate ?? Math.round(opts.width * opts.height * fps * 0.15);
46
+ const codec = opts.codec ?? `avc1.4200${h264LevelIdc(opts.width, opts.height, fps, bitrate).toString(16).padStart(2, "0")}`;
47
+ this.cfg = {
48
+ codec, width: opts.width, height: opts.height, framerate: fps, bitrate,
49
+ bitrateMode: opts.bitrateMode ?? "constant", latencyMode: opts.latencyMode ?? "realtime", hardwareAcceleration: opts.hardwareAcceleration ?? "no-preference",
50
+ avc: { format: "annexb" },
51
+ };
52
+ 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); } });
53
+ this.#enc.configure(this.cfg);
54
+ this.#log(`encoder configured: ${codec} ${opts.width}x${opts.height}@${fps} ${bitrate} bps`);
55
+ }
56
+ /** Whether WebCodecs can encode this configuration here (Annex-B, realtime). */
57
+ static async isSupported(opts) {
58
+ if (typeof VideoEncoder !== "function")
59
+ return false;
60
+ const fps = opts.fps ?? 30;
61
+ const bitrate = opts.bitrate ?? Math.round(opts.width * opts.height * fps * 0.15);
62
+ const codec = opts.codec ?? `avc1.4200${h264LevelIdc(opts.width, opts.height, fps, bitrate).toString(16).padStart(2, "0")}`;
63
+ try {
64
+ 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" } });
65
+ return r.supported === true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** Adds a consumer of encoded access units; returns a detach function. */
72
+ attachSink(sink) { this.#sinks.add(sink); return () => { this.#sinks.delete(sink); }; }
73
+ /**
74
+ * Encodes one picture: a `VideoFrame` (camera, `MediaStreamTrackProcessor`) or any
75
+ * `CanvasImageSource` (a canvas, video element, image bitmap). `tUs` is the capture time in the
76
+ * node clock (default `nowUs()` now) and rides through `VideoFrame.timestamp` to the chunk, so
77
+ * the published `t_us` is the capture stamp. The caller keeps ownership of a `VideoFrame` source
78
+ * (close it afterwards). Returns `false` when the frame was dropped (encoder behind, or closed).
79
+ */
80
+ encode(source, opts = {}) {
81
+ if (this.#closed)
82
+ return false;
83
+ if (this.#enc.encodeQueueSize >= (this.opts.maxQueue ?? 2)) {
84
+ this.#stats.dropped++;
85
+ return false;
86
+ }
87
+ const tUs = Math.round(opts.tUs ?? nowUs());
88
+ const interval = this.opts.keyframeInterval ?? 0;
89
+ const key = this.#forceKey || opts.keyFrame === true || (interval > 0 && this.#sinceKey >= interval);
90
+ let vf;
91
+ try {
92
+ vf = new VideoFrame(source, { timestamp: tUs });
93
+ }
94
+ catch (e) {
95
+ this.#stats.errors++;
96
+ this.opts.onError?.(e);
97
+ return false;
98
+ }
99
+ try {
100
+ this.#enc.encode(vf, { keyFrame: key });
101
+ }
102
+ catch (e) {
103
+ this.#stats.errors++;
104
+ this.#log(`encode() threw: ${e instanceof Error ? e.message : String(e)}`);
105
+ this.opts.onError?.(e);
106
+ return false;
107
+ }
108
+ finally {
109
+ vf.close();
110
+ }
111
+ this.#inflight.set(tUs, performance.now());
112
+ this.#forceKey = false;
113
+ this.#sinceKey = key ? 1 : this.#sinceKey + 1;
114
+ return true;
115
+ }
116
+ /** The next encoded frame is an IDR (the `<topic>/idr` service calls this). */
117
+ forceIdr() { this.#forceKey = true; }
118
+ /** Changes the target bitrate (reconfigures; the next frame is an IDR so the new rate starts clean). */
119
+ setBitrate(bps) {
120
+ if (this.#closed || bps <= 0)
121
+ return;
122
+ this.cfg.bitrate = Math.round(bps);
123
+ try {
124
+ this.#enc.configure(this.cfg);
125
+ this.#forceKey = true;
126
+ this.#log(`bitrate → ${Math.round(bps)} bps`);
127
+ }
128
+ catch (e) {
129
+ this.#stats.errors++;
130
+ this.opts.onError?.(e);
131
+ }
132
+ }
133
+ /** Waits for every queued frame to come out. */
134
+ async flush() { if (!this.#closed && this.#enc.state === "configured")
135
+ await this.#enc.flush(); }
136
+ #onChunk(chunk, _meta) {
137
+ const data = new Uint8Array(chunk.byteLength);
138
+ chunk.copyTo(data);
139
+ const f = mediaFrameFromAnnexb(data, chunk.timestamp);
140
+ if (chunk.type === "key")
141
+ f.key = true; // R27 §4: a key chunk is an IDR (the bitstream says so too)
142
+ f.stages = { encodedUs: nowUs(), sentUs: 0 };
143
+ const started = this.#inflight.get(chunk.timestamp);
144
+ if (started !== undefined) {
145
+ this.#inflight.delete(chunk.timestamp);
146
+ const ms = performance.now() - started;
147
+ this.#encodeMs = this.#encodeMs === 0 ? ms : this.#encodeMs * 0.9 + ms * 0.1;
148
+ }
149
+ this.#stats.encoded++;
150
+ if (f.key) {
151
+ this.#stats.keyframes++;
152
+ this.#sinceKey = Math.min(this.#sinceKey, 1);
153
+ }
154
+ this.#stats.bytes += data.length;
155
+ for (const s of this.#sinks)
156
+ s(f);
157
+ }
158
+ stats() {
159
+ return { ...this.#stats, queue: this.#closed ? 0 : this.#enc.encodeQueueSize, bitrate: this.cfg.bitrate ?? 0, encodeMs: this.#encodeMs, codec: this.cfg.codec };
160
+ }
161
+ close() {
162
+ if (this.#closed)
163
+ return;
164
+ this.#closed = true;
165
+ try {
166
+ this.#enc.close();
167
+ }
168
+ catch { /* already closed */ }
169
+ this.#sinks.clear();
170
+ this.#inflight.clear();
171
+ }
172
+ #log(msg) { this.opts.onLog?.(msg); }
173
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./annexb.ts";
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 * from "./camera.ts";
10
+ export type { MediaFrame, MediaLayout, 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 over Synapse in the browser (stage plan J1/J2).
2
+ // - `annexb`: the Annex-B walkers shared with `crates/synapse-media` (flags from the bitstream).
3
+ // - `ParamSetCache` / `DecoderSync`: the publisher re-insertion and decoder sync policies, identical in every SDK.
4
+ // - `H264Decoder` / `H264Encoder`: WebCodecs (browser only; constructors throw `not_supported` elsewhere).
5
+ // - `attachCanvas`, `VideoPublisher`, `VideoSubscriber`, `h264Topic`, camera helpers.
6
+ // The React hook lives in `@synapse-sdk/media/react`.
7
+ export * from "./annexb.js";
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 * from "./camera.js";
16
+ export { isStartPoint, mediaFlags, mediaFrameFromHeader, mediaHeader, stagesOf } from "@synapse-sdk/core";
@@ -0,0 +1,24 @@
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
+ import { type H264DecoderOptions } from "./decoder.ts";
5
+ import { VideoSubscriber, type VideoStats, type VideoSubscriberOptions } from "./video.ts";
6
+ export interface UseVideoOptions extends VideoSubscriberOptions, CanvasSinkOptions {
7
+ /** The node to use instead of the one from `<SynapseProvider>`. */
8
+ node?: Synapse;
9
+ decoder?: H264DecoderOptions;
10
+ /** Stats re-render rate; default 2 Hz; 0 disables. */
11
+ statsHz?: number;
12
+ }
13
+ export interface UseVideoResult {
14
+ /** Put this on the `<canvas>`: `<canvas ref={ref} />`. */
15
+ ref: (el: HTMLCanvasElement | null) => void;
16
+ /** Last stats snapshot, undefined until the first tick. */
17
+ stats: VideoStats | undefined;
18
+ /** `not_supported` when WebCodecs is missing, or whatever failed while setting up. */
19
+ error: SynapseError | undefined;
20
+ /** The live subscriber (undefined while there is no canvas or the topic is `"skip"`). */
21
+ subscriber: VideoSubscriber | undefined;
22
+ }
23
+ /** Decodes an H.264 topic into a canvas. `topic` may be `"skip"` to render nothing without subscribing. */
24
+ export declare function useVideo(topic: TopicRef<Uint8Array, Uint8Array> | "skip", opts?: UseVideoOptions): UseVideoResult;
package/dist/react.js ADDED
@@ -0,0 +1,48 @@
1
+ // `@synapse-sdk/media/react`: `useVideo(topic)` — a canvas ref callback plus live stats. The
2
+ // per-frame path (decoder → canvas) never touches React; only the stats snapshot re-renders, at
3
+ // `statsHz` (default 2). Needs a `<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 { H264Decoder } from "./decoder.js";
9
+ import { VideoSubscriber } from "./video.js";
10
+ /** Decodes an H.264 topic into a canvas. `topic` may be `"skip"` to render nothing without subscribing. */
11
+ export function useVideo(topic, opts = {}) {
12
+ const ctxNode = useSynapseOptional();
13
+ const node = opts.node ?? ctxNode;
14
+ const [canvas, setCanvas] = useState(null);
15
+ const [stats, setStats] = useState(undefined);
16
+ const [error, setError] = useState(undefined);
17
+ const [subscriber, setSubscriber] = useState(undefined);
18
+ const optsRef = useRef(opts);
19
+ optsRef.current = opts;
20
+ const ref = useCallback((el) => setCanvas(el), []);
21
+ const topicName = topic === "skip" ? undefined : typeof topic === "string" ? topic : topic.name;
22
+ const statsHz = opts.statsHz ?? 2;
23
+ useEffect(() => {
24
+ if (!canvas || !node || topic === "skip") {
25
+ setSubscriber(undefined);
26
+ setStats(undefined);
27
+ return;
28
+ }
29
+ const o = optsRef.current;
30
+ let decoder, sub;
31
+ try {
32
+ decoder = new H264Decoder(o.decoder);
33
+ const sink = attachCanvas(decoder, canvas, { vsync: o.vsync, resize: o.resize });
34
+ sub = new VideoSubscriber(node, topic, decoder, o);
35
+ setSubscriber(sub);
36
+ setError(undefined);
37
+ const timer = statsHz > 0 ? setInterval(() => setStats(sub.stats()), 1000 / statsHz) : undefined;
38
+ return () => { if (timer)
39
+ clearInterval(timer); sub.close(); sink.detach(); decoder.close(); };
40
+ }
41
+ catch (e) {
42
+ setError(toSynapseError(e, "not_supported", "sdk"));
43
+ return;
44
+ }
45
+ // eslint-disable-next-line react-hooks/exhaustive-deps
46
+ }, [node, canvas, topicName, statsHz]);
47
+ return { ref, stats, error, subscriber };
48
+ }
@@ -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
+ }
@@ -0,0 +1,41 @@
1
+ import { auParamSets, concatBytes } from "./annexb.js";
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,86 @@
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
+ constructor(cfg = {}) { this.cfg = { ...SYNC_DEFAULT, ...cfg }; }
14
+ get state() { return this.#state; }
15
+ get paramSets() { return this.cache.paramSets; }
16
+ /** Frames fed since the decoder last produced output. */
17
+ get fedSinceOutput() { return this.#fedSinceOutput; }
18
+ /** A received media frame. Decides whether to feed it and how to label it, and raises the stuck/wedged signals. */
19
+ push(frame) {
20
+ this.stats.frames++;
21
+ let started = false;
22
+ // Parameter sets are cached from every frame, synced or not (the cache primes later starts).
23
+ const inBand = this.cache.observe(frame.data);
24
+ let data = frame.data;
25
+ if (this.#state === "waiting") {
26
+ if (frame.key) {
27
+ this.#startedAtRecovery = false;
28
+ }
29
+ else if (this.cfg.startAtRecovery && frame.recovery && (inBand || this.cache.isPrimed)) {
30
+ if (!inBand) {
31
+ const primed = this.cache.prime(frame.data);
32
+ if (primed)
33
+ data = primed;
34
+ }
35
+ this.#startedAtRecovery = true;
36
+ this.stats.startsAtRecovery++;
37
+ }
38
+ else {
39
+ this.stats.dropped++;
40
+ return { feed: undefined, keyChunk: false, started: false, signal: undefined };
41
+ }
42
+ this.#state = "primed";
43
+ this.#syncChunk = true;
44
+ this.#fedSinceOutput = 0;
45
+ this.stats.starts++;
46
+ started = true;
47
+ }
48
+ const keyChunk = frame.key || this.#syncChunk;
49
+ this.#syncChunk = false;
50
+ this.#fedSinceOutput++;
51
+ this.stats.fed++;
52
+ return { feed: data, keyChunk, started, signal: this.#checkStuck() };
53
+ }
54
+ #checkStuck() {
55
+ const tolerated = this.#startedAtRecovery ? this.cfg.recoverySilenceFrames : this.cfg.stuckFrames;
56
+ if (this.#fedSinceOutput >= this.cfg.wedgedFrames) {
57
+ this.#reset();
58
+ return "reset";
59
+ }
60
+ if (this.#fedSinceOutput >= tolerated) {
61
+ // Give the IDR a full window before asking again.
62
+ this.#fedSinceOutput = 0;
63
+ this.#startedAtRecovery = false;
64
+ this.stats.idrRequests++;
65
+ return "request_idr";
66
+ }
67
+ return undefined;
68
+ }
69
+ /** The decoder produced a frame. */
70
+ onOutput() {
71
+ this.#fedSinceOutput = 0;
72
+ this.#startedAtRecovery = false;
73
+ this.stats.output++;
74
+ if (this.#state === "primed")
75
+ this.#state = "streaming";
76
+ }
77
+ /** The decoder reported an error: drop it and wait for the next start point. */
78
+ onError() { this.#reset(); }
79
+ #reset() {
80
+ this.#state = "waiting";
81
+ this.#syncChunk = false;
82
+ this.#startedAtRecovery = false;
83
+ this.#fedSinceOutput = 0;
84
+ this.stats.resets++;
85
+ }
86
+ }