@zafu/media 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Penumbra Labs (upstream: prax-wallet/prax, penumbra-zone/web)
4
+ Copyright (c) 2025-2026 Rotko Networks OÜ
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # @zafu/media
2
+
3
+ Framework-agnostic P2P media primitives for [zafu](https://zafu.pro) - opt-in WebRTC voice/video with perfect negotiation, Meet-style background blur, and pluggable signaling.
4
+
5
+ ```sh
6
+ npm install @zafu/media
7
+ ```
8
+
9
+ ## What it is
10
+
11
+ A call is **direct P2P**: audio and video never touch a server. Only the SDP/ICE setup has to travel, and you inject that transport - so pointing it at a `@zafu/zid` Noise channel makes call setup end-to-end, post-quantum encrypted as well.
12
+
13
+ Nothing heavy is dragged in unless you ask for it. No UI framework is imported - state is exposed as `Readable<T>` (a getter that is also `.subscribe`-able). Blur is an injected factory. `@mediapipe/tasks-vision` is an _optional_ peer dependency, loaded lazily and only when you pass `blur`.
14
+
15
+ ## Quick start
16
+
17
+ ```ts
18
+ import { createCall, createVideoBlur, zidSignaling } from '@zafu/media';
19
+
20
+ const call = createCall({
21
+ signaling: zidSignaling(zidChannel), // SDP/ICE over the E2EE channel
22
+ polite: myId < peerId, // exactly one side must be polite
23
+ blur: () => createVideoBlur(), // optional - omit it and blur costs nothing
24
+ });
25
+
26
+ call.acknowledge(); // consent gate - nothing connects before this
27
+ await call.toggleMic();
28
+ await call.toggleCam();
29
+
30
+ call.remoteStream.subscribe(s => {
31
+ if (s) videoEl.srcObject = s;
32
+ });
33
+ ```
34
+
35
+ ## Opt-in, or nothing happens
36
+
37
+ Media is hard opt-in, because a direct connection reveals each peer's IP to the other. Until `acknowledge()` is called:
38
+
39
+ - `getUserMedia` is never called and no `RTCPeerConnection` is created;
40
+ - inbound SDP/ICE is **ignored**, so a remote peer cannot force a connection (or a candidate-gathering IP probe);
41
+ - `incomingPending` flips to `true` when the peer offers - render your own prompt, then `acknowledge()` or `dismissIncoming()`.
42
+
43
+ `revoke()` stops the tracks, closes the connection and re-arms the gate; the `Call` stays subscribed, so a later `acknowledge()` can connect again. `cleanup()` is the final teardown - it also unsubscribes from signaling.
44
+
45
+ ## ICE defaults (privacy-first)
46
+
47
+ `iceServers` defaults to `[]` - host candidates only. No TURN (it would relay your media through a server, defeating "media is direct") and no third-party STUN (it would hand a third party your reflexive IP). This connects on reachable networks and may fail behind symmetric NAT; that is the deliberate default. Pass your own `iceServers` - a self-hosted STUN, typically - to change it.
48
+
49
+ ## Background blur (optional)
50
+
51
+ `createVideoBlur()` segments each outgoing frame (MediaPipe selfie segmenter) and composites a sharp person over a blurred or replaced background, exposed as a canvas `MediaStream` and swapped into the sender with `replaceTrack()` - no renegotiation.
52
+
53
+ ```ts
54
+ const blur = createVideoBlur({ assetBase: '/mediapipe', blurPx: 14 });
55
+ const call = createCall({ signaling, polite, blur: () => blur });
56
+
57
+ await call.setBlurMode('blur'); // 'off' | 'blur' | 'image'
58
+ call.setBlurImage(myImageBitmap); // used by 'image' mode
59
+ ```
60
+
61
+ **Assets are same-origin.** The wasm runtime and the `.tflite` model are served from `assetBase` (default `/mediapipe`) - vendor `@mediapipe/tasks-vision`'s wasm directory and `selfie_segmenter.tflite` there. Nothing is fetched from a CDN. MediaPipe compiles wasm at load time, so a strict CSP needs `script-src 'wasm-unsafe-eval'` (this does not re-open JS `eval`).
62
+
63
+ If the model fails to initialise, `setBlurMode` rejects, `blurUnavailable` goes true, and the **raw camera track keeps being sent** - never a black frame.
64
+
65
+ ## Errors
66
+
67
+ `lastError` carries the last unrecovered failure, tagged with the step to re-run and a kind you can branch on:
68
+
69
+ ```ts
70
+ call.lastError.subscribe(e => {
71
+ if (e) showBanner(e.message); // { step: 'mic'|'cam'|'connect', kind: 'denied'|'busy'|'notfound'|'negotiation'|'unknown' }
72
+ });
73
+ await call.retry(); // re-runs the failed step; idempotent
74
+ call.clearError(); // or just dismiss the banner
75
+ ```
76
+
77
+ ## Bridging to your framework
78
+
79
+ ```ts
80
+ // React - Readable is a getter plus a subscribe, i.e. exactly useSyncExternalStore's shape
81
+ const micOn = useSyncExternalStore(call.micEnabled.subscribe, call.micEnabled);
82
+
83
+ // SolidJS / plain JS
84
+ const stop = call.connected.subscribe(up => setState({ up }));
85
+ ```
86
+
87
+ ## Signaling
88
+
89
+ `Signaling` is `send(msg)` + `onSignal(handler)` over two message kinds: `_sdp` and `_ice`. `zidSignaling(channel, tag = 0xf0)` bridges a `@zafu/zid` `ZidChannel` (or anything with `send`/`on('message')`) by JSON-framing those messages behind a one-byte tag, so media control never collides with your own chat frames - frames that do not start with the tag are left untouched for the app's own handler.
90
+
91
+ Written your own transport? Implement the two methods:
92
+
93
+ ```ts
94
+ const signaling: Signaling = {
95
+ send: msg => socket.send(JSON.stringify(msg)),
96
+ onSignal: handler => {
97
+ socket.on('message', handler);
98
+ return () => socket.off('message', handler);
99
+ },
100
+ };
101
+ ```
102
+
103
+ One `Call` talks to exactly one peer.
104
+
105
+ ## API
106
+
107
+ | Export | Purpose |
108
+ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
109
+ | `createCall(options)` | Start a call session → `Call`. `options`: `signaling`, `polite`, `iceServers?`, `video?` (default 320x240 front camera), `blur?`. |
110
+ | `createVideoBlur(options?)` | Background processing for an outgoing track → `VideoBlur`. `options`: `assetBase?`, `modelFile?`, `blurPx?`. |
111
+ | `zidSignaling(channel, tag?)` | Bridge a byte channel into `Signaling` for SDP/ICE. |
112
+ | `writable(initial)` | `[read, write]` - the reactive primitive the call state is built on, if you want your own. |
113
+
114
+ `Call`: `localStream`, `remoteStream`, `micEnabled`, `camEnabled`, `connected`, `acknowledged`, `incomingPending`, `blurMode`, `blurUnavailable`, `lastError` (all `Readable`), plus `acknowledge()`, `revoke()`, `dismissIncoming()`, `toggleMic()`, `toggleCam()`, `setBlurMode(mode)`, `setBlurImage(img)`, `retry()`, `clearError()`, `cleanup()`.
115
+
116
+ `VideoBlur`: `outputTrack()`, `mode()`, `ready()`, `setMode(mode, source?)`, `setBackgroundImage(img)`, `stop()`.
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Blur types with NO dependency on `@mediapipe/tasks-vision`.
3
+ *
4
+ * Kept separate from blur.ts (the implementation, which type-imports MediaPipe)
5
+ * so that consumers using only createCall - and injecting or omitting blur - can
6
+ * type-check without the optional mediapipe peer dep being installed.
7
+ */
8
+ type BlurMode = 'off' | 'blur' | 'image';
9
+ interface VideoBlur {
10
+ /** the processed outgoing track (canvas capture). null until a non-off setMode(). */
11
+ outputTrack: () => MediaStreamTrack | null;
12
+ /** current mode. */
13
+ mode: () => BlurMode;
14
+ /**
15
+ * Switch modes at runtime. 'off' stops the processing loop and the caller
16
+ * should replaceTrack() back to the raw camera track. Resolves once applied.
17
+ * Throws if the segmenter could not initialise (caller falls back to raw).
18
+ */
19
+ setMode: (m: BlurMode, source?: MediaStreamTrack) => Promise<void>;
20
+ /** provide/replace the background image (used by 'image' mode). */
21
+ setBackgroundImage: (img: HTMLImageElement | ImageBitmap | null) => void;
22
+ /** true once MediaPipe initialised successfully. */
23
+ ready: () => boolean;
24
+ /** tear everything down and release the model + canvas track. */
25
+ stop: () => void;
26
+ }
27
+ interface VideoBlurOptions {
28
+ /**
29
+ * Same-origin base URL holding the vendored MediaPipe wasm + model. The host
30
+ * app copies `@mediapipe/tasks-vision`'s wasm dir and the selfie segmenter
31
+ * `.tflite` here. Default '/mediapipe'.
32
+ */
33
+ assetBase?: string;
34
+ /** model file name under assetBase. Default 'selfie_segmenter.tflite'. */
35
+ modelFile?: string;
36
+ /** background blur strength in px (canvas filter). Default 10. */
37
+ blurPx?: number;
38
+ }
39
+
40
+ /**
41
+ * Pluggable signaling transport for the media session.
42
+ *
43
+ * The call only needs to move two kinds of tiny control messages to the peer -
44
+ * SDP descriptions and ICE candidates. It does NOT care how they travel. The
45
+ * host app injects a `Signaling` and thereby chooses the transport:
46
+ *
47
+ * - zitadel: over a `@zafu/zid` Noise channel (see zidSignaling) - so call
48
+ * setup is itself end-to-end, post-quantum encrypted.
49
+ * - poker: over its existing encrypted blind relay.
50
+ *
51
+ * Media (the audio/video RTP) never flows through here - it is direct P2P once
52
+ * ICE connects. Only offer/answer/candidate setup rides this channel.
53
+ */
54
+ type MediaSignal = {
55
+ t: '_sdp';
56
+ d: {
57
+ sdp: RTCSessionDescriptionInit;
58
+ };
59
+ } | {
60
+ t: '_ice';
61
+ d: {
62
+ candidate: RTCIceCandidateInit;
63
+ };
64
+ };
65
+ interface Signaling {
66
+ /** Send a control message to the single remote peer. */
67
+ send(msg: MediaSignal): void;
68
+ /** Register the inbound handler. Returns an unsubscribe function. */
69
+ onSignal(handler: (msg: MediaSignal) => void): () => void;
70
+ }
71
+ /** A `@zafu/zid`-style encrypted channel: send bytes, receive bytes. */
72
+ interface ByteChannel {
73
+ send(data: string | Uint8Array): void;
74
+ on(event: 'message', handler: (data: Uint8Array) => void): void;
75
+ }
76
+ /**
77
+ * Bridge a `@zafu/zid` ZidChannel (or any ByteChannel) into `Signaling`, so
78
+ * SDP/ICE ride the same E2EE channel as the DMs. Messages are JSON, framed with
79
+ * a 1-byte tag so media control never collides with the app's own chat frames.
80
+ *
81
+ * @param tag byte prefix distinguishing media-signal frames (default 0xF0).
82
+ */
83
+ declare function zidSignaling(channel: ByteChannel, tag?: number): Signaling;
84
+
85
+ /**
86
+ * A minimal framework-agnostic reactive value.
87
+ *
88
+ * `Readable<T>` is BOTH a getter (`value()`) - so it drops straight into a
89
+ * SolidJS template like a signal - AND a `.subscribe(fn)` source, so a React
90
+ * consumer can bridge it with `useSyncExternalStore` and plain JS can just
91
+ * listen. No framework is imported here; consumers adapt it to theirs.
92
+ */
93
+ interface Readable<T> {
94
+ (): T;
95
+ subscribe(fn: (value: T) => void): () => void;
96
+ }
97
+ type Writable<T> = [read: Readable<T>, write: (value: T) => void];
98
+ declare function writable<T>(initial: T): Writable<T>;
99
+
100
+ /**
101
+ * Opt-in P2P voice/video call primitive (framework-agnostic).
102
+ *
103
+ * Ported from the poker media layer. All reactive state is exposed as
104
+ * `Readable<T>` (a getter that is also `.subscribe`-able), so it drops into
105
+ * SolidJS as a signal, bridges to React via useSyncExternalStore, or is polled
106
+ * from plain JS. No UI framework is imported.
107
+ *
108
+ * PRIVACY / OPT-IN. Media connects the two clients DIRECTLY (audio/video never
109
+ * touches a server), which necessarily reveals each peer's IP to the other.
110
+ * Therefore media is HARD opt-in: nothing calls getUserMedia and no
111
+ * RTCPeerConnection is created until `acknowledge()`. Incoming SDP/ICE is
112
+ * ignored until then, so a remote peer cannot force a connection (or a
113
+ * candidate-gathering IP probe) before we consent. `revoke()` tears down and
114
+ * re-arms the gate.
115
+ *
116
+ * ICE default: EMPTY iceServers (host candidates only) - no TURN (would relay
117
+ * media through a server, defeating "media is direct"), no third-party STUN
118
+ * (would hand a third party the reflexive IP). Connects on reachable networks;
119
+ * may fail behind symmetric NAT - the privacy-first default. Pass your own
120
+ * `iceServers` (e.g. a self-hosted STUN) to change this.
121
+ */
122
+
123
+ type MediaErrorStep = 'mic' | 'cam' | 'connect';
124
+ type MediaErrorKind = 'denied' | 'busy' | 'notfound' | 'negotiation' | 'unknown';
125
+ interface MediaError {
126
+ step: MediaErrorStep;
127
+ kind: MediaErrorKind;
128
+ message: string;
129
+ }
130
+ interface CallOptions {
131
+ /** SDP/ICE transport. See Signaling / zidSignaling. */
132
+ signaling: Signaling;
133
+ /**
134
+ * Perfect-negotiation role: exactly one side must be `polite`. A stable rule
135
+ * both peers can compute independently is e.g. `myId < peerId`.
136
+ */
137
+ polite: boolean;
138
+ /** ICE servers. Default [] (host candidates only, privacy-first). */
139
+ iceServers?: RTCIceServer[];
140
+ /** outgoing video constraints. Default 320x240 front camera. */
141
+ video?: MediaTrackConstraints;
142
+ /**
143
+ * Background-blur pipeline factory (e.g. () => createVideoBlur()). Optional -
144
+ * omit it and blur controls become no-ops (and the mediapipe dep is never
145
+ * loaded). Injected so a consumer that does not want blur pays nothing.
146
+ */
147
+ blur?: () => VideoBlur;
148
+ }
149
+ interface Call {
150
+ localStream: Readable<MediaStream | null>;
151
+ remoteStream: Readable<MediaStream | null>;
152
+ micEnabled: Readable<boolean>;
153
+ camEnabled: Readable<boolean>;
154
+ connected: Readable<boolean>;
155
+ /** last unrecovered error (tagged with the step to re-run), or null. */
156
+ lastError: Readable<MediaError | null>;
157
+ /** clear the error banner without retrying. */
158
+ clearError: () => void;
159
+ /** re-run the step that last failed. Idempotent; the "never get stuck" button. */
160
+ retry: () => Promise<void>;
161
+ /** true once the user consented to direct-P2P media + IP exposure. */
162
+ acknowledged: Readable<boolean>;
163
+ /** record consent; media stays inert until this is called. */
164
+ acknowledge: () => void;
165
+ /** withdraw consent: stop tracks, close the peer connection, re-arm the gate. */
166
+ revoke: () => void;
167
+ /** true when the peer offered but we have not opted in (drives a prompt). */
168
+ incomingPending: Readable<boolean>;
169
+ /** dismiss the incoming-media prompt without opting in. */
170
+ dismissIncoming: () => void;
171
+ /** outgoing-webcam background mode. */
172
+ blurMode: Readable<BlurMode>;
173
+ /** change the outgoing-webcam background mode (falls back to raw on failure). */
174
+ setBlurMode: (m: BlurMode) => Promise<void>;
175
+ /** background image for 'image' mode. */
176
+ setBlurImage: (img: HTMLImageElement | ImageBitmap | null) => void;
177
+ /** true if blur was requested but the model failed to init (sending raw). */
178
+ blurUnavailable: Readable<boolean>;
179
+ toggleMic: () => Promise<void>;
180
+ toggleCam: () => Promise<void>;
181
+ /** fully stop media and close the connection (also re-arms the gate). */
182
+ cleanup: () => void;
183
+ }
184
+ declare function createCall(options: CallOptions): Call;
185
+
186
+ /**
187
+ * Google-Meet-style webcam BACKGROUND processing for an OUTGOING video track.
188
+ *
189
+ * Modes:
190
+ * - 'off' : pass the raw camera track through unchanged (no processing).
191
+ * - 'blur' : keep the person sharp, blur the background.
192
+ * - 'image' : keep the person sharp, replace the background with an image.
193
+ *
194
+ * A per-frame person/background segmentation mask (MediaPipe ImageSegmenter,
195
+ * the selfie segmenter) composites a sharp foreground over a blurred/image
196
+ * background on a canvas, exposed as a MediaStream via captureStream(). The
197
+ * processed track is swapped into the RTCRtpSender via replaceTrack() - no
198
+ * renegotiation.
199
+ *
200
+ * FULLY OFFLINE / STRICT-CSP: `@mediapipe/tasks-vision` is an OPTIONAL peer dep
201
+ * loaded lazily, and the wasm runtime + .tflite model are served SAME-ORIGIN
202
+ * from `assetBase` (default '/mediapipe'). The host app vendors those assets;
203
+ * nothing is fetched from a CDN. MediaPipe compiles its wasm at load time, so a
204
+ * strict CSP needs script-src 'wasm-unsafe-eval' (does not re-open JS eval).
205
+ *
206
+ * GRACEFUL FALLBACK: if the model fails to init, setMode() rejects and the
207
+ * caller keeps sending the RAW track - never a black frame.
208
+ */
209
+
210
+ declare function createVideoBlur(options?: VideoBlurOptions): VideoBlur;
211
+
212
+ export { type BlurMode, type ByteChannel, type Call, type CallOptions, type MediaError, type MediaErrorKind, type MediaErrorStep, type MediaSignal, type Readable, type Signaling, type VideoBlur, type VideoBlurOptions, type Writable, createCall, createVideoBlur, writable, zidSignaling };
package/dist/index.js ADDED
@@ -0,0 +1,656 @@
1
+ // src/store.ts
2
+ function writable(initial) {
3
+ let value = initial;
4
+ const subscribers = /* @__PURE__ */ new Set();
5
+ const read = (() => value);
6
+ read.subscribe = (fn) => {
7
+ subscribers.add(fn);
8
+ return () => subscribers.delete(fn);
9
+ };
10
+ const write = (next) => {
11
+ if (Object.is(next, value)) {
12
+ return;
13
+ }
14
+ value = next;
15
+ for (const fn of subscribers) {
16
+ fn(value);
17
+ }
18
+ };
19
+ return [read, write];
20
+ }
21
+
22
+ // src/call.ts
23
+ var DEFAULT_VIDEO = { width: 320, height: 240, facingMode: "user" };
24
+ function createCall(options) {
25
+ const { signaling, polite } = options;
26
+ const iceServers = options.iceServers ?? [];
27
+ const videoConstraints = options.video ?? DEFAULT_VIDEO;
28
+ const [localStream, setLocalStream] = writable(null);
29
+ const remoteStreamsMap = /* @__PURE__ */ new Map();
30
+ const PEER = "peer";
31
+ const [remoteStream, setRemoteStream] = writable(null);
32
+ const [micEnabled, setMicEnabled] = writable(false);
33
+ const [camEnabled, setCamEnabled] = writable(false);
34
+ const [connected, setConnected] = writable(false);
35
+ const [acknowledged, setAcknowledged] = writable(false);
36
+ const [incomingPending, setIncomingPending] = writable(false);
37
+ const [blurMode, setBlurModeState] = writable("off");
38
+ const [blurUnavailable, setBlurUnavailable] = writable(false);
39
+ const [lastError, setLastError] = writable(null);
40
+ function classifyError(step, e) {
41
+ const name = e?.name ?? "";
42
+ let kind = "unknown";
43
+ let message;
44
+ switch (name) {
45
+ case "NotAllowedError":
46
+ case "SecurityError":
47
+ kind = "denied";
48
+ message = step === "cam" ? "Camera permission was blocked. Allow camera access, then retry." : "Microphone permission was blocked. Allow mic access, then retry.";
49
+ break;
50
+ case "NotReadableError":
51
+ case "AbortError":
52
+ kind = "busy";
53
+ message = `${step === "cam" ? "Camera" : "Microphone"} is in use by another app or tab. Close it, then retry.`;
54
+ break;
55
+ case "NotFoundError":
56
+ case "OverconstrainedError":
57
+ kind = "notfound";
58
+ message = step === "cam" ? "No camera found. Connect one, then retry." : "No microphone found. Connect one, then retry.";
59
+ break;
60
+ default:
61
+ message = `Could not start ${step === "cam" ? "camera" : step === "mic" ? "microphone" : "the connection"}. Retry.`;
62
+ }
63
+ return { step, kind, message };
64
+ }
65
+ let pc = null;
66
+ let makingOffer = false;
67
+ let ignoreOffer = false;
68
+ const blur = options.blur?.() ?? null;
69
+ let rawCamTrack = null;
70
+ function ensurePeerConnection() {
71
+ if (pc) {
72
+ return pc;
73
+ }
74
+ pc = new RTCPeerConnection({ iceServers });
75
+ pc.onicecandidate = (e) => {
76
+ if (e.candidate) {
77
+ signaling.send({ t: "_ice", d: { candidate: e.candidate.toJSON() } });
78
+ }
79
+ };
80
+ pc.ontrack = () => {
81
+ const stream = new MediaStream();
82
+ for (const r of pc.getReceivers()) {
83
+ if (r.track) {
84
+ stream.addTrack(r.track);
85
+ }
86
+ }
87
+ remoteStreamsMap.set(PEER, stream);
88
+ setRemoteStream(stream);
89
+ };
90
+ pc.onconnectionstatechange = () => {
91
+ const st = pc?.connectionState;
92
+ setConnected(st === "connected");
93
+ if (st === "connected") {
94
+ if (lastError()?.step === "connect") {
95
+ setLastError(null);
96
+ }
97
+ } else if (st === "failed") {
98
+ setLastError({
99
+ step: "connect",
100
+ kind: "negotiation",
101
+ message: "The direct connection failed (restrictive network). Retry to reconnect."
102
+ });
103
+ }
104
+ };
105
+ pc.onnegotiationneeded = async () => {
106
+ try {
107
+ makingOffer = true;
108
+ await pc.setLocalDescription();
109
+ signaling.send({ t: "_sdp", d: { sdp: pc.localDescription.toJSON() } });
110
+ } catch {
111
+ setLastError({
112
+ step: "connect",
113
+ kind: "negotiation",
114
+ message: "Could not negotiate the media connection. Retry."
115
+ });
116
+ } finally {
117
+ makingOffer = false;
118
+ }
119
+ };
120
+ return pc;
121
+ }
122
+ async function getLocalMedia(audio, video) {
123
+ let stream = localStream();
124
+ let changed = false;
125
+ if (audio && !stream?.getAudioTracks().length) {
126
+ const s = await navigator.mediaDevices.getUserMedia({ audio: true });
127
+ if (!stream) {
128
+ stream = new MediaStream();
129
+ }
130
+ s.getAudioTracks().forEach((t) => stream.addTrack(t));
131
+ changed = true;
132
+ }
133
+ if (video && !stream?.getVideoTracks().length) {
134
+ const s = await navigator.mediaDevices.getUserMedia({ video: videoConstraints });
135
+ if (!stream) {
136
+ stream = new MediaStream();
137
+ }
138
+ s.getVideoTracks().forEach((t) => stream.addTrack(t));
139
+ changed = true;
140
+ }
141
+ if (!stream) {
142
+ stream = new MediaStream();
143
+ }
144
+ if (changed) {
145
+ setLocalStream(new MediaStream(stream.getTracks()));
146
+ }
147
+ return localStream();
148
+ }
149
+ function addTracksToPC(stream) {
150
+ const conn = ensurePeerConnection();
151
+ const existingSenders = conn.getSenders();
152
+ for (const track of stream.getTracks()) {
153
+ if (!existingSenders.some((s) => s.track?.id === track.id)) {
154
+ conn.addTrack(track, stream);
155
+ }
156
+ }
157
+ }
158
+ function videoSender() {
159
+ return pc?.getSenders().find((s) => s.track?.kind === "video") ?? null;
160
+ }
161
+ async function swapVideoTrack(next) {
162
+ const sender = videoSender();
163
+ if (sender && sender.track?.id !== next.id) {
164
+ try {
165
+ await sender.replaceTrack(next);
166
+ } catch (e) {
167
+ console.warn("[zafu-media] replaceTrack failed:", e);
168
+ }
169
+ }
170
+ const stream = localStream();
171
+ if (stream) {
172
+ const cur = stream.getVideoTracks()[0];
173
+ if (cur && cur.id !== next.id) {
174
+ stream.removeTrack(cur);
175
+ stream.addTrack(next);
176
+ setLocalStream(new MediaStream(stream.getTracks()));
177
+ }
178
+ }
179
+ }
180
+ async function applyBlurMode(mode) {
181
+ setBlurModeState(mode);
182
+ setBlurUnavailable(false);
183
+ if (!blur) {
184
+ return;
185
+ }
186
+ if (!camEnabled() || !rawCamTrack) {
187
+ return;
188
+ }
189
+ if (mode === "off") {
190
+ await blur.setMode("off");
191
+ await swapVideoTrack(rawCamTrack);
192
+ return;
193
+ }
194
+ try {
195
+ await blur.setMode(mode, rawCamTrack);
196
+ const out = blur.outputTrack();
197
+ if (out) {
198
+ await swapVideoTrack(out);
199
+ } else {
200
+ throw new Error("no processed track");
201
+ }
202
+ } catch {
203
+ setBlurUnavailable(true);
204
+ await swapVideoTrack(rawCamTrack);
205
+ }
206
+ }
207
+ async function enableMic() {
208
+ const stream = await getLocalMedia(true, camEnabled());
209
+ addTracksToPC(stream);
210
+ stream.getAudioTracks().forEach((t) => {
211
+ t.enabled = true;
212
+ });
213
+ setMicEnabled(true);
214
+ if (lastError()?.step === "mic") {
215
+ setLastError(null);
216
+ }
217
+ }
218
+ async function toggleMic() {
219
+ if (!acknowledged()) {
220
+ console.warn("[zafu-media] mic toggle blocked: not acknowledged");
221
+ return;
222
+ }
223
+ if (micEnabled()) {
224
+ localStream()?.getAudioTracks().forEach((t) => {
225
+ t.enabled = false;
226
+ });
227
+ setMicEnabled(false);
228
+ if (lastError()?.step === "mic") {
229
+ setLastError(null);
230
+ }
231
+ } else {
232
+ try {
233
+ await enableMic();
234
+ } catch (e) {
235
+ setMicEnabled(false);
236
+ setLastError(classifyError("mic", e));
237
+ }
238
+ }
239
+ }
240
+ async function enableCam() {
241
+ const stream = await getLocalMedia(micEnabled(), true);
242
+ rawCamTrack = stream.getVideoTracks()[0] ?? null;
243
+ if (rawCamTrack) {
244
+ rawCamTrack.enabled = true;
245
+ }
246
+ addTracksToPC(stream);
247
+ setCamEnabled(true);
248
+ if (lastError()?.step === "cam") {
249
+ setLastError(null);
250
+ }
251
+ if (blurMode() !== "off") {
252
+ await applyBlurMode(blurMode());
253
+ }
254
+ }
255
+ async function toggleCam() {
256
+ if (!acknowledged()) {
257
+ console.warn("[zafu-media] cam toggle blocked: not acknowledged");
258
+ return;
259
+ }
260
+ if (camEnabled()) {
261
+ localStream()?.getVideoTracks().forEach((t) => {
262
+ t.enabled = false;
263
+ });
264
+ blur?.setMode("off").catch(() => {
265
+ });
266
+ setCamEnabled(false);
267
+ if (lastError()?.step === "cam") {
268
+ setLastError(null);
269
+ }
270
+ } else {
271
+ try {
272
+ await enableCam();
273
+ } catch (e) {
274
+ setCamEnabled(false);
275
+ setLastError(classifyError("cam", e));
276
+ }
277
+ }
278
+ }
279
+ async function handleSignal(msg) {
280
+ if (!acknowledged()) {
281
+ if (msg.t === "_sdp" && msg.d.sdp?.type === "offer") {
282
+ setIncomingPending(true);
283
+ }
284
+ return;
285
+ }
286
+ if (msg.t === "_sdp") {
287
+ const conn = ensurePeerConnection();
288
+ const desc = new RTCSessionDescription(msg.d.sdp);
289
+ const offerCollision = desc.type === "offer" && (makingOffer || conn.signalingState !== "stable");
290
+ ignoreOffer = !polite && offerCollision;
291
+ if (ignoreOffer) {
292
+ return;
293
+ }
294
+ if (offerCollision) {
295
+ await conn.setLocalDescription({ type: "rollback" });
296
+ }
297
+ await conn.setRemoteDescription(desc);
298
+ if (desc.type === "offer") {
299
+ await conn.setLocalDescription();
300
+ signaling.send({ t: "_sdp", d: { sdp: conn.localDescription.toJSON() } });
301
+ }
302
+ }
303
+ if (msg.t === "_ice") {
304
+ const conn = ensurePeerConnection();
305
+ try {
306
+ await conn.addIceCandidate(new RTCIceCandidate(msg.d.candidate));
307
+ } catch (e) {
308
+ if (!ignoreOffer) {
309
+ console.warn("[zafu-media] ICE error:", e);
310
+ }
311
+ }
312
+ }
313
+ }
314
+ function teardown() {
315
+ blur?.stop();
316
+ rawCamTrack?.stop();
317
+ rawCamTrack = null;
318
+ localStream()?.getTracks().forEach((t) => t.stop());
319
+ setLocalStream(null);
320
+ remoteStreamsMap.clear();
321
+ setRemoteStream(null);
322
+ pc?.close();
323
+ pc = null;
324
+ makingOffer = false;
325
+ ignoreOffer = false;
326
+ setMicEnabled(false);
327
+ setCamEnabled(false);
328
+ setConnected(false);
329
+ setBlurUnavailable(false);
330
+ setLastError(null);
331
+ }
332
+ async function retry() {
333
+ const err = lastError();
334
+ if (!err) {
335
+ return;
336
+ }
337
+ setLastError(null);
338
+ try {
339
+ if (err.step === "mic") {
340
+ await enableMic();
341
+ } else if (err.step === "cam") {
342
+ await enableCam();
343
+ } else {
344
+ if (pc && pc.connectionState !== "closed") {
345
+ try {
346
+ pc.restartIce();
347
+ } catch {
348
+ }
349
+ }
350
+ if (!pc || pc.connectionState === "closed" || pc.connectionState === "failed") {
351
+ const wasCam = camEnabled();
352
+ pc?.close();
353
+ pc = null;
354
+ const stream = localStream();
355
+ if (stream) {
356
+ ensurePeerConnection();
357
+ addTracksToPC(stream);
358
+ if (blurMode() !== "off" && wasCam) {
359
+ await applyBlurMode(blurMode());
360
+ }
361
+ }
362
+ }
363
+ }
364
+ } catch (e) {
365
+ setLastError(classifyError(err.step, e));
366
+ }
367
+ }
368
+ const unsubscribe = signaling.onSignal((msg) => {
369
+ void handleSignal(msg);
370
+ });
371
+ return {
372
+ localStream,
373
+ remoteStream,
374
+ micEnabled,
375
+ camEnabled,
376
+ connected,
377
+ lastError,
378
+ clearError: () => setLastError(null),
379
+ retry,
380
+ acknowledged,
381
+ acknowledge: () => {
382
+ setAcknowledged(true);
383
+ setIncomingPending(false);
384
+ },
385
+ revoke: () => {
386
+ teardown();
387
+ setAcknowledged(false);
388
+ setIncomingPending(false);
389
+ },
390
+ incomingPending,
391
+ dismissIncoming: () => setIncomingPending(false),
392
+ blurMode,
393
+ setBlurMode: applyBlurMode,
394
+ setBlurImage: (img) => blur?.setBackgroundImage(img),
395
+ blurUnavailable,
396
+ toggleMic,
397
+ toggleCam,
398
+ cleanup: () => {
399
+ unsubscribe();
400
+ teardown();
401
+ setAcknowledged(false);
402
+ setIncomingPending(false);
403
+ }
404
+ };
405
+ }
406
+
407
+ // src/blur.ts
408
+ function createVideoBlur(options = {}) {
409
+ const BASE = options.assetBase ?? "/mediapipe";
410
+ const MODEL_URL = `${BASE}/${options.modelFile ?? "selfie_segmenter.tflite"}`;
411
+ const BLUR_PX = options.blurPx ?? 10;
412
+ let segmenter = null;
413
+ let initPromise = null;
414
+ let isReady = false;
415
+ let currentMode = "off";
416
+ let sourceTrack = null;
417
+ let bgImage = null;
418
+ let video = null;
419
+ let canvas = null;
420
+ let ctx = null;
421
+ let maskCanvas = null;
422
+ let maskCtx = null;
423
+ let outStream = null;
424
+ let rafId = null;
425
+ let running = false;
426
+ async function ensureSegmenter() {
427
+ if (isReady) {
428
+ return;
429
+ }
430
+ if (initPromise) {
431
+ return initPromise;
432
+ }
433
+ initPromise = (async () => {
434
+ const vision = await import('@mediapipe/tasks-vision');
435
+ const { FilesetResolver, ImageSegmenter: Segmenter } = vision;
436
+ const fileset = await FilesetResolver.forVisionTasks(BASE);
437
+ segmenter = await Segmenter.createFromOptions(fileset, {
438
+ baseOptions: {
439
+ modelAssetPath: MODEL_URL,
440
+ delegate: "GPU"
441
+ },
442
+ runningMode: "VIDEO",
443
+ outputCategoryMask: false,
444
+ outputConfidenceMasks: true
445
+ });
446
+ isReady = true;
447
+ })();
448
+ try {
449
+ await initPromise;
450
+ } catch (e) {
451
+ initPromise = null;
452
+ isReady = false;
453
+ console.warn("[zafu-media/blur] segmenter init failed, falling back to raw:", e);
454
+ throw e;
455
+ }
456
+ }
457
+ function ensureSurfaces(w, h) {
458
+ if (!canvas) {
459
+ canvas = document.createElement("canvas");
460
+ ctx = canvas.getContext("2d", { willReadFrequently: false });
461
+ }
462
+ if (!maskCanvas) {
463
+ maskCanvas = document.createElement("canvas");
464
+ maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
465
+ }
466
+ if (canvas.width !== w || canvas.height !== h) {
467
+ canvas.width = w;
468
+ canvas.height = h;
469
+ maskCanvas.width = w;
470
+ maskCanvas.height = h;
471
+ }
472
+ }
473
+ async function attachVideo(track) {
474
+ if (!video) {
475
+ video = document.createElement("video");
476
+ video.muted = true;
477
+ video.playsInline = true;
478
+ video.autoplay = true;
479
+ }
480
+ video.srcObject = new MediaStream([track]);
481
+ await video.play().catch(() => {
482
+ });
483
+ if (!video.videoWidth) {
484
+ await new Promise((res) => {
485
+ const onMeta = () => {
486
+ video?.removeEventListener("loadedmetadata", onMeta);
487
+ res();
488
+ };
489
+ video?.addEventListener("loadedmetadata", onMeta);
490
+ });
491
+ }
492
+ }
493
+ function composite(mask, w, h) {
494
+ if (!ctx || !canvas || !video || !maskCtx || !maskCanvas) {
495
+ return;
496
+ }
497
+ const imgData = maskCtx.createImageData(w, h);
498
+ const data = imgData.data;
499
+ for (let i = 0; i < mask.length; i++) {
500
+ data[i * 4 + 3] = (mask[i] ?? 0) > 0.5 ? 255 : Math.round((mask[i] ?? 0) * 255);
501
+ }
502
+ maskCtx.putImageData(imgData, 0, 0);
503
+ ctx.save();
504
+ ctx.filter = "none";
505
+ if (currentMode === "image" && bgImage) {
506
+ drawCover(ctx, bgImage, w, h);
507
+ } else {
508
+ ctx.filter = `blur(${BLUR_PX}px)`;
509
+ ctx.drawImage(video, 0, 0, w, h);
510
+ ctx.filter = "none";
511
+ }
512
+ ctx.restore();
513
+ maskCtx.save();
514
+ maskCtx.globalCompositeOperation = "source-in";
515
+ maskCtx.filter = "none";
516
+ maskCtx.drawImage(video, 0, 0, w, h);
517
+ maskCtx.restore();
518
+ ctx.drawImage(maskCanvas, 0, 0, w, h);
519
+ }
520
+ function drawCover(c, img, w, h) {
521
+ const iw = img.naturalWidth || img.width;
522
+ const ih = img.naturalHeight || img.height;
523
+ if (!iw || !ih) {
524
+ return;
525
+ }
526
+ const scale = Math.max(w / iw, h / ih);
527
+ const dw = iw * scale;
528
+ const dh = ih * scale;
529
+ c.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh);
530
+ }
531
+ function loop() {
532
+ if (!running || !video || !segmenter || !canvas || !ctx) {
533
+ return;
534
+ }
535
+ const w = canvas.width;
536
+ const h = canvas.height;
537
+ if (video.readyState >= 2 && w && h) {
538
+ try {
539
+ const result = segmenter.segmentForVideo(video, performance.now());
540
+ const masks = result.confidenceMasks;
541
+ if (masks && masks[0]) {
542
+ composite(masks[0].getAsFloat32Array(), w, h);
543
+ } else {
544
+ ctx.drawImage(video, 0, 0, w, h);
545
+ }
546
+ result.close();
547
+ } catch {
548
+ ctx.drawImage(video, 0, 0, w, h);
549
+ }
550
+ }
551
+ rafId = requestAnimationFrame(loop);
552
+ }
553
+ function startLoop() {
554
+ if (running) {
555
+ return;
556
+ }
557
+ running = true;
558
+ rafId = requestAnimationFrame(loop);
559
+ }
560
+ function stopLoop() {
561
+ running = false;
562
+ if (rafId != null) {
563
+ cancelAnimationFrame(rafId);
564
+ rafId = null;
565
+ }
566
+ }
567
+ async function setMode(m, source) {
568
+ if (source) {
569
+ sourceTrack = source;
570
+ }
571
+ currentMode = m;
572
+ if (m === "off") {
573
+ stopLoop();
574
+ return;
575
+ }
576
+ if (!sourceTrack) {
577
+ throw new Error("[zafu-media/blur] no source track");
578
+ }
579
+ await ensureSegmenter();
580
+ await attachVideo(sourceTrack);
581
+ const settings = sourceTrack.getSettings();
582
+ const w = video?.videoWidth || settings.width || 320;
583
+ const h = video?.videoHeight || settings.height || 240;
584
+ ensureSurfaces(w, h);
585
+ if (!outStream) {
586
+ outStream = canvas.captureStream(settings.frameRate || 30);
587
+ }
588
+ startLoop();
589
+ }
590
+ function stop() {
591
+ stopLoop();
592
+ outStream?.getTracks().forEach((t) => t.stop());
593
+ outStream = null;
594
+ if (video) {
595
+ video.srcObject = null;
596
+ video = null;
597
+ }
598
+ canvas = null;
599
+ ctx = null;
600
+ maskCanvas = null;
601
+ maskCtx = null;
602
+ try {
603
+ segmenter?.close();
604
+ } catch {
605
+ }
606
+ segmenter = null;
607
+ initPromise = null;
608
+ isReady = false;
609
+ currentMode = "off";
610
+ sourceTrack = null;
611
+ }
612
+ return {
613
+ outputTrack: () => outStream?.getVideoTracks()[0] ?? null,
614
+ mode: () => currentMode,
615
+ setMode,
616
+ setBackgroundImage: (img) => {
617
+ bgImage = img;
618
+ },
619
+ ready: () => isReady,
620
+ stop
621
+ };
622
+ }
623
+
624
+ // src/signaling.ts
625
+ function zidSignaling(channel, tag = 240) {
626
+ const enc = new TextEncoder();
627
+ const dec = new TextDecoder();
628
+ const handlers = /* @__PURE__ */ new Set();
629
+ channel.on("message", (data) => {
630
+ if (data[0] !== tag) {
631
+ return;
632
+ }
633
+ try {
634
+ const msg = JSON.parse(dec.decode(data.subarray(1)));
635
+ for (const h of handlers) {
636
+ h(msg);
637
+ }
638
+ } catch {
639
+ }
640
+ });
641
+ return {
642
+ send(msg) {
643
+ const body = enc.encode(JSON.stringify(msg));
644
+ const framed = new Uint8Array(body.length + 1);
645
+ framed[0] = tag;
646
+ framed.set(body, 1);
647
+ channel.send(framed);
648
+ },
649
+ onSignal(handler) {
650
+ handlers.add(handler);
651
+ return () => handlers.delete(handler);
652
+ }
653
+ };
654
+ }
655
+
656
+ export { createCall, createVideoBlur, writable, zidSignaling };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@zafu/media",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Framework-agnostic P2P media primitives - opt-in WebRTC voice/video with perfect negotiation, local background blur, and pluggable (ZID-encrypted) signaling",
6
+ "type": "module",
7
+ "keywords": [
8
+ "webrtc",
9
+ "p2p",
10
+ "voice",
11
+ "video",
12
+ "background-blur",
13
+ "mediapipe",
14
+ "zid",
15
+ "zafu",
16
+ "primitives"
17
+ ],
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/rotkonetworks/zafu.git",
21
+ "directory": "packages/media"
22
+ },
23
+ "homepage": "https://github.com/rotkonetworks/zafu/tree/main/packages/media#readme",
24
+ "sideEffects": false,
25
+ "files": [
26
+ "dist",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "peerDependencies": {
40
+ "@mediapipe/tasks-vision": "^0.10.22"
41
+ },
42
+ "peerDependenciesMeta": {
43
+ "@mediapipe/tasks-vision": {
44
+ "optional": true
45
+ }
46
+ },
47
+ "devDependencies": {
48
+ "@mediapipe/tasks-vision": "^0.10.22",
49
+ "tsup": "^8.5.0"
50
+ },
51
+ "scripts": {
52
+ "build": "tsup src/index.ts --format esm --dts --clean --treeshake",
53
+ "lint": "eslint \"**/*.ts*\"",
54
+ "test": "vitest run"
55
+ },
56
+ "main": "./dist/index.js",
57
+ "module": "./dist/index.js",
58
+ "types": "./dist/index.d.ts"
59
+ }