@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 +22 -0
- package/README.md +120 -0
- package/dist/index.d.ts +212 -0
- package/dist/index.js +656 -0
- package/package.json +59 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|