wenay-common2 1.0.70 → 1.0.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/demo/client.ts +230 -3
- package/demo/index.html +37 -0
- package/demo/server.ts +91 -4
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +233 -212
- package/doc/changes/1.0.71.md +9 -0
- package/doc/changes/1.0.72.md +6 -0
- package/doc/changes/1.0.73.md +6 -0
- package/doc/changes/1.0.74.md +12 -0
- package/doc/wenay-common2-rare.md +684 -627
- package/doc/wenay-common2.md +50 -5
- package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
- package/lib/Common/events/route-signal-webrtc.js +15 -5
- package/lib/Common/media/media-index.d.ts +1 -0
- package/lib/Common/media/media-index.js +1 -0
- package/lib/Common/media/media-source.d.ts +3 -0
- package/lib/Common/media/media-source.js +136 -27
- package/lib/Common/media/media-view.d.ts +42 -0
- package/lib/Common/media/media-view.js +210 -0
- package/lib/Common/peer/peer-call.d.ts +65 -0
- package/lib/Common/peer/peer-call.js +173 -0
- package/lib/Common/peer/peer-client.d.ts +21 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +22 -5
- package/lib/Common/peer/peer-host.js +49 -5
- package/lib/Common/peer/peer-index.d.ts +2 -0
- package/lib/Common/peer/peer-index.js +2 -0
- package/lib/Common/peer/peer-media-relay.d.ts +22 -0
- package/lib/Common/peer/peer-media-relay.js +155 -0
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/observe/listen-core.test.ts +1 -2
- package/package.json +1 -4
- package/replay/media-view.test.ts +164 -0
- package/replay/peer-call.test.ts +226 -0
- package/replay/peer-repair.test.ts +184 -0
- package/doc/changes/1.0.63.md +0 -8
- package/doc/changes/1.0.64.md +0 -10
package/doc/wenay-common2.md
CHANGED
|
@@ -151,8 +151,13 @@ Media.createAudioSource({format?: 'int16'|'float32', mode?: 'pcm'|'record', repl
|
|
|
151
151
|
Media.createVideoSource({fps? = 3, codec? = 'jpeg', quality?, replay?}) -> [emit, listen] & control
|
|
152
152
|
control: start() -> Promise<'idle'|'requesting'|'live'|'denied'|'no-device'|'error'> · stop() · getStats() · setDevice(id) · listDevices() · state
|
|
153
153
|
Media.encodeMediaFrame(meta, payload) / Media.decodeMediaFrame(frame) // one Uint8Array = 40-byte fixed header + raw payload
|
|
154
|
+
|
|
155
|
+
// viewer/publisher one-liners (the demo stand is built on these):
|
|
156
|
+
Media.attachVideoCanvas(line, canvas, {onError?}) -> {stats(), off} // decode+render any video line; codec/size come from frame headers
|
|
157
|
+
Media.attachAudioPlayer(line, {maxBacklogSec? = 0.35}) -> {enable(), disable(), enabled, stats(), off} // live PCM playback, backlog drops
|
|
158
|
+
Media.pipeMediaPublish(line, publish, {stamp? = true, onError?}) -> off // source -> RPC publish fn; stamp lets viewers measure latency
|
|
154
159
|
```
|
|
155
|
-
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (
|
|
160
|
+
Audio default is PCM frames from `AudioWorklet` where available (`mode:'record'` uses MediaRecorder chunks). Video default is camera snapshots (JPEG, low fps for vision) captured hidden-tab-proof: a worker timer ticks (setInterval is throttled to ~1/s in hidden tabs), `ImageCapture.grabFrame()` reads the track (a hidden `<video>` stops painting), and JPEG encode runs in a worker (main-thread `convertToBlob` stalls ~1s hidden) — `worker:false` opts back into the plain in-page path. Screen share is the same video source with an injected stream: `createVideoSource({stream: () => navigator.mediaDevices.getDisplayMedia({video: true})})`. Put `listen` into `createRpcServerAuto` like any other Listen; with `replay:true`, the returned listen is a replay line, so RPC auto exposes legacy + replay surfaces under the same key. Backpressure policy: audio is lossless queue; video `replay:true` defaults to keep-latest frame recovery. `transport:'webrtc'` is reserved for a future SFU/signaling adapter; socket binary is the default today. Living example: the demo stand (`npm run demo`) streams camera / mic / screen share between two tabs through a tiny server-side relay of replay lines (`demo/server.ts` + `demo/client.ts`).
|
|
156
161
|
|
|
157
162
|
## 🤝 Peer — accounts see each other's stores (one-call SDK)
|
|
158
163
|
> `import { Peer } from "wenay-common2"` or `import * as Peer from "wenay-common2/peer"`.
|
|
@@ -185,11 +190,54 @@ me.onRoute(ev => {}) // route transitions for metrics/UI
|
|
|
185
190
|
```
|
|
186
191
|
Key property: the relay journal stores the owner's envelopes VERBATIM (owner seq space), so a
|
|
187
192
|
relay <-> direct hand-off is a plain seq resume — no keyframe reset, no gaps, no dups. Late joiners
|
|
188
|
-
get a keyframe folded server-side even while the owner is offline.
|
|
193
|
+
get a keyframe folded server-side even while the owner is offline.
|
|
194
|
+
Reconnect correctness is self-healing: a publisher gap makes the relay reject the push WITH its last
|
|
195
|
+
seq, and the client repairs from that coordinate automatically (`repair: 'tail'` lossless (default)
|
|
196
|
+
| `'keyframe'` cheap reset for ephemeral state). Server declares journal semantics
|
|
197
|
+
(`createPeerHost({gap: 'resume' | 'sacred'})`); a declared `journal: 'sacred'` TYPE-forbids the cheap
|
|
198
|
+
repair. `me.resync()` after a transport reconnect repairs without waiting for the next write;
|
|
199
|
+
failures surface via `onPublishError`, never silently. Policy/session material:
|
|
189
200
|
`createPeerClient({session, accept, policy})` + host `authorize` — see rare docs for the envelope
|
|
190
201
|
contract and the underlying primitives (`createRouteCoordinator`, `createSignalHub`,
|
|
191
202
|
`createWebRtcConnector`). Oracle: `replay/peer-sdk.test.ts`.
|
|
192
203
|
|
|
204
|
+
### Calls, presence and the media relay (messenger-style, on the same parts)
|
|
205
|
+
```
|
|
206
|
+
// presence rides in the fragment (host >= 1.0.74): subscribe FIRST, then list()
|
|
207
|
+
c.app.func.peer.presence.changes.on(({account, online}) => {})
|
|
208
|
+
await c.app.func.peer.presence.list() // ['a', 'b', ...] — connected right now
|
|
209
|
+
|
|
210
|
+
// SERVER — media relay next to the fragment (per-account named lines, policy-gated watch):
|
|
211
|
+
const media = Peer.createMediaRelay({
|
|
212
|
+
lines: {cam: 'video', mic: 'audio', screen: 'video'},
|
|
213
|
+
canWatch?: (watcher, owner, line) => bool, // ACL, checked on paths + every forwarded frame
|
|
214
|
+
})
|
|
215
|
+
object: {peer: peer.fragment, media: {publish: media.publishOf(account), watch: media.watchOf(account)}}
|
|
216
|
+
media.dropAccount(account) // retire lines + keyspace (e.g. on presence offline); next publish revives
|
|
217
|
+
|
|
218
|
+
// CLIENT — calls are envelopes over the SAME signal port; media attach stays app code:
|
|
219
|
+
const calls = Peer.createCallManager({port: Peer.callPortOf(c.app.func.peer), self: 'a'})
|
|
220
|
+
await calls.ready // signal subscription confirmed server-side
|
|
221
|
+
calls.rings.on(call => { call.accept() /* or call.decline() */ })
|
|
222
|
+
const call = calls.call('b', {kinds: ['cam', 'mic']}) // meta rides opaquely
|
|
223
|
+
call.changed.on(state => {}) // 'active' | 'ended'
|
|
224
|
+
await call.ended // 'declined'|'busy'|'offline'|'timeout'|'hangup'|'canceled'
|
|
225
|
+
call.hangup()
|
|
226
|
+
// while active: publish own frames + attach the peer's lines (Media.attach* viewers)
|
|
227
|
+
media.publish('cam', frame, Date.now()); attachVideoCanvas(c.app.func.media.watch.b.cam, canvas)
|
|
228
|
+
```
|
|
229
|
+
Relay-first is the privacy default (mainstream messengers route calls through their servers too);
|
|
230
|
+
`promoteDirect` stays the policy-gated opt-in for the data path. Ring/accept/decline/hangup ride the
|
|
231
|
+
existing signal hub — the host `authorize` hook sees them too (single server-side policy point), and
|
|
232
|
+
an offline callee fails fast (`'offline'`), no timeout wait. The default incoming gate auto-declines
|
|
233
|
+
with `'busy'` during a live call, which also settles simultaneous cross-calls. `video` relay lines
|
|
234
|
+
are keep-latest (late joiner pulls `keyframe()` instantly), `audio` is a short lossless queue; frames
|
|
235
|
+
ride as `[frame, sentAt]` so viewer latency stats work out of the box. Watch access is an ACL, not a
|
|
236
|
+
convention: `watchOf(account)` views run `canWatch` on every new subscribe/keyframe and every live
|
|
237
|
+
frame. "Media access follows the call" is a few lines of app code (grant on `'active'`, revoke on
|
|
238
|
+
`'ended'`); after revocation an already-open policy view receives no more data. Oracle:
|
|
239
|
+
`replay/peer-call.test.ts`.
|
|
240
|
+
|
|
193
241
|
## 🔁 Observe — reactive state + store/mirror API
|
|
194
242
|
> `import { Observe } from "wenay-common2"` or `import * as Observe from "wenay-common2/observe"`.
|
|
195
243
|
> This is the documented v2 reactive/store surface.
|
|
@@ -380,6 +428,3 @@ facade — details in rare docs. New code should declare `frame` on the line ins
|
|
|
380
428
|
Wire-level proof/oracles: `npx ts-node replay/rpc-auto.test.ts` (real Socket.IO: auto-exposure, legacy
|
|
381
429
|
parity, frame equivalence, gate lag sim), plus `replay/conflate-socket.test.ts`, `replay/conflate.test.ts`,
|
|
382
430
|
`replay/coalesce.test.ts`. Full generic surface (history/time-travel, archive) → 🎞️ in rare docs.
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ReplayRemote } from './replay-wire';
|
|
2
2
|
import { RouteConnector } from './route-coordinator';
|
|
3
3
|
import { ReplayMessageChannel } from './replay-channel';
|
|
4
|
-
export type tSignalType = 'offer' | 'answer' | 'ice' | 'revoke' | 'close';
|
|
4
|
+
export type tSignalType = 'offer' | 'answer' | 'ice' | 'revoke' | 'close' | 'ring' | 'accept' | 'decline' | 'hangup';
|
|
5
5
|
export type SignalEnvelope = {
|
|
6
6
|
type: tSignalType;
|
|
7
7
|
pair: string;
|
|
@@ -11,28 +11,38 @@ function createSignalHub(deps = {}) {
|
|
|
11
11
|
const ports = new Map();
|
|
12
12
|
function register(account) {
|
|
13
13
|
const [emit, signals] = (0, Listen_1.listen)();
|
|
14
|
-
ports.
|
|
14
|
+
const accountPorts = ports.get(account) ?? [];
|
|
15
|
+
accountPorts.push(emit);
|
|
16
|
+
ports.set(account, accountPorts);
|
|
15
17
|
async function send(env) {
|
|
16
18
|
if (env == null || env.from != account)
|
|
17
19
|
return false;
|
|
18
20
|
if (authorize && !(await authorize(env)))
|
|
19
21
|
return false;
|
|
20
|
-
const
|
|
22
|
+
const targets = ports.get(env.to);
|
|
23
|
+
const target = targets?.[targets.length - 1];
|
|
21
24
|
if (!target)
|
|
22
25
|
return false;
|
|
23
26
|
target(env);
|
|
24
27
|
return true;
|
|
25
28
|
}
|
|
26
29
|
function close() {
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const accountPorts = ports.get(account);
|
|
31
|
+
if (accountPorts) {
|
|
32
|
+
const i = accountPorts.indexOf(emit);
|
|
33
|
+
if (i >= 0)
|
|
34
|
+
accountPorts.splice(i, 1);
|
|
35
|
+
if (!accountPorts.length)
|
|
36
|
+
ports.delete(account);
|
|
37
|
+
}
|
|
29
38
|
signals.close();
|
|
30
39
|
}
|
|
31
40
|
return { account, send, signals, close };
|
|
32
41
|
}
|
|
33
42
|
function revoke(pair, accounts, reason) {
|
|
34
43
|
for (const account of accounts) {
|
|
35
|
-
ports.get(account)
|
|
44
|
+
const accountPorts = ports.get(account);
|
|
45
|
+
accountPorts?.[accountPorts.length - 1]?.({ type: 'revoke', pair, from: '', to: account, reason });
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
return {
|
|
@@ -14,6 +14,7 @@ export type MediaReplayOpts = true | ReplayListenUseOptions<[Uint8Array]>;
|
|
|
14
14
|
export type AudioSourceOpts = {
|
|
15
15
|
sourceId?: string;
|
|
16
16
|
deviceId?: string;
|
|
17
|
+
stream?: any;
|
|
17
18
|
mode?: 'pcm' | 'record';
|
|
18
19
|
format?: 'int16' | 'float32';
|
|
19
20
|
channels?: number;
|
|
@@ -28,6 +29,7 @@ export type AudioSourceOpts = {
|
|
|
28
29
|
export type VideoSourceOpts = {
|
|
29
30
|
sourceId?: string;
|
|
30
31
|
deviceId?: string;
|
|
32
|
+
stream?: any;
|
|
31
33
|
fps?: number;
|
|
32
34
|
width?: number;
|
|
33
35
|
height?: number;
|
|
@@ -88,6 +90,7 @@ export type DecodedMediaFrame = MediaFrameMeta & {
|
|
|
88
90
|
export declare const MEDIA_FRAME_MAGIC = 1464028466;
|
|
89
91
|
export declare const MEDIA_FRAME_VERSION = 1;
|
|
90
92
|
export declare const MEDIA_FRAME_HEADER_BYTES = 40;
|
|
93
|
+
export declare function toBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array<ArrayBufferLike>;
|
|
91
94
|
export declare function encodeMediaFrame(meta: MediaFrameMeta, payload: ArrayBuffer | ArrayBufferView): Uint8Array<ArrayBuffer>;
|
|
92
95
|
export declare function decodeMediaFrame(frameLike: ArrayBuffer | ArrayBufferView): DecodedMediaFrame;
|
|
93
96
|
export declare function createAudioSource(opts?: AudioSourceOpts): MediaSource;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MEDIA_FRAME_HEADER_BYTES = exports.MEDIA_FRAME_VERSION = exports.MEDIA_FRAME_MAGIC = void 0;
|
|
4
|
+
exports.toBytes = toBytes;
|
|
4
5
|
exports.encodeMediaFrame = encodeMediaFrame;
|
|
5
6
|
exports.decodeMediaFrame = decodeMediaFrame;
|
|
6
7
|
exports.createAudioSource = createAudioSource;
|
|
@@ -356,7 +357,7 @@ function createAudioSource(opts = {}) {
|
|
|
356
357
|
async function start() {
|
|
357
358
|
try {
|
|
358
359
|
ensureSocketTransport(transport);
|
|
359
|
-
if (!hasGetUserMedia()) {
|
|
360
|
+
if (!opts.stream && !hasGetUserMedia()) {
|
|
360
361
|
shell.setState('no-device');
|
|
361
362
|
return shell.state;
|
|
362
363
|
}
|
|
@@ -364,7 +365,7 @@ function createAudioSource(opts = {}) {
|
|
|
364
365
|
shell.setState('requesting');
|
|
365
366
|
shell.stats.startedAt = nowMono();
|
|
366
367
|
const constraints = { audio: { deviceId: deviceId ? { exact: deviceId } : undefined, channelCount: opts.channels, sampleRate: opts.sampleRate } };
|
|
367
|
-
stream = await globalThis.navigator.mediaDevices.getUserMedia(constraints);
|
|
368
|
+
stream = await resolveMediaStream(opts.stream, () => globalThis.navigator.mediaDevices.getUserMedia(constraints));
|
|
368
369
|
if (opts.mode == 'record')
|
|
369
370
|
startRecord(stream);
|
|
370
371
|
else
|
|
@@ -395,6 +396,50 @@ function createAudioSource(opts = {}) {
|
|
|
395
396
|
};
|
|
396
397
|
return attachControl([shell.emit, shell.listenApi], control);
|
|
397
398
|
}
|
|
399
|
+
async function resolveMediaStream(injected, fallback) {
|
|
400
|
+
if (!injected)
|
|
401
|
+
return fallback();
|
|
402
|
+
return typeof injected == 'function' ? await injected() : injected;
|
|
403
|
+
}
|
|
404
|
+
function videoEncodeWorkerCode() {
|
|
405
|
+
return `
|
|
406
|
+
const canvas = new OffscreenCanvas(1, 1)
|
|
407
|
+
const ctx = canvas.getContext('2d')
|
|
408
|
+
onmessage = async function onEncodeRequest(ev) {
|
|
409
|
+
const req = ev.data
|
|
410
|
+
try {
|
|
411
|
+
canvas.width = req.w
|
|
412
|
+
canvas.height = req.h
|
|
413
|
+
ctx.drawImage(req.bitmap, 0, 0, req.w, req.h)
|
|
414
|
+
req.bitmap.close()
|
|
415
|
+
const blob = await canvas.convertToBlob({type: req.mime, quality: req.quality})
|
|
416
|
+
const buf = await blob.arrayBuffer()
|
|
417
|
+
postMessage({buf}, [buf])
|
|
418
|
+
} catch (e) {
|
|
419
|
+
postMessage({error: String((e && e.message) || e)})
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
`;
|
|
423
|
+
}
|
|
424
|
+
function startCaptureTicker(ms, tick, useWorker) {
|
|
425
|
+
const g = globalThis;
|
|
426
|
+
if (useWorker && g.Worker && g.Blob && g.URL) {
|
|
427
|
+
try {
|
|
428
|
+
const blob = new g.Blob([`setInterval(function () { postMessage(0) }, ${ms})`], { type: 'text/javascript' });
|
|
429
|
+
const url = g.URL.createObjectURL(blob);
|
|
430
|
+
const worker = new g.Worker(url);
|
|
431
|
+
worker.onmessage = function onCaptureTick() { tick(); };
|
|
432
|
+
return function stopWorkerTicker() {
|
|
433
|
+
worker.terminate();
|
|
434
|
+
g.URL.revokeObjectURL(url);
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
catch {
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const timer = setInterval(tick, ms);
|
|
441
|
+
return function stopIntervalTicker() { clearInterval(timer); };
|
|
442
|
+
}
|
|
398
443
|
function mimeForVideo(codec) {
|
|
399
444
|
if (codec == 'png')
|
|
400
445
|
return 'image/png';
|
|
@@ -422,9 +467,37 @@ function createVideoSource(opts = {}) {
|
|
|
422
467
|
let video = opts.video;
|
|
423
468
|
let canvas = opts.canvas;
|
|
424
469
|
let ctx = null;
|
|
425
|
-
let
|
|
470
|
+
let stopTicker = null;
|
|
471
|
+
let imageCapture = null;
|
|
472
|
+
let encodeWorker = null;
|
|
426
473
|
let busy = false;
|
|
427
474
|
let seq = 0;
|
|
475
|
+
async function grabBitmap() {
|
|
476
|
+
if (imageCapture) {
|
|
477
|
+
try {
|
|
478
|
+
return await imageCapture.grabFrame();
|
|
479
|
+
}
|
|
480
|
+
catch { }
|
|
481
|
+
}
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
function emitVideoFrame(payload, codec, w, h) {
|
|
485
|
+
shell.emitFrame(encodeMediaFrame({
|
|
486
|
+
kind: 'video-frame',
|
|
487
|
+
codec,
|
|
488
|
+
seq: ++seq,
|
|
489
|
+
tMono: nowMono(),
|
|
490
|
+
width: w,
|
|
491
|
+
height: h,
|
|
492
|
+
}, payload), seq);
|
|
493
|
+
}
|
|
494
|
+
function encodeInWorker(bitmap, w, h, mime, quality) {
|
|
495
|
+
return new Promise(function encodeRoundtrip(resolve, reject) {
|
|
496
|
+
encodeWorker.onmessage = (ev) => ev.data?.error ? reject(new Error(ev.data.error)) : resolve(ev.data.buf);
|
|
497
|
+
encodeWorker.onerror = (e) => reject(new Error(String(e?.message ?? 'video encode worker error')));
|
|
498
|
+
encodeWorker.postMessage({ bitmap, w, h, mime, quality }, [bitmap]);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
428
501
|
async function captureOne() {
|
|
429
502
|
if (busy || shell.state != 'live') {
|
|
430
503
|
shell.stats.dropped++;
|
|
@@ -432,26 +505,40 @@ function createVideoSource(opts = {}) {
|
|
|
432
505
|
}
|
|
433
506
|
busy = true;
|
|
434
507
|
try {
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
|
|
508
|
+
const bitmap = await grabBitmap();
|
|
509
|
+
const srcW = (bitmap ? bitmap.width : video.videoWidth ?? video.width) ?? 0;
|
|
510
|
+
const srcH = (bitmap ? bitmap.height : video.videoHeight ?? video.height) ?? 0;
|
|
511
|
+
let w = opts.width ?? srcW;
|
|
512
|
+
let h = opts.height ?? srcH;
|
|
513
|
+
if (srcW && srcH) {
|
|
514
|
+
if (opts.width && opts.height == undefined) {
|
|
515
|
+
w = Math.min(opts.width, srcW);
|
|
516
|
+
h = Math.round(w * srcH / srcW);
|
|
517
|
+
}
|
|
518
|
+
else if (opts.height && opts.width == undefined) {
|
|
519
|
+
h = Math.min(opts.height, srcH);
|
|
520
|
+
w = Math.round(h * srcW / srcH);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (!w || !h) {
|
|
524
|
+
bitmap?.close?.();
|
|
438
525
|
return;
|
|
526
|
+
}
|
|
527
|
+
const codec = opts.codec ?? 'jpeg';
|
|
528
|
+
const quality = opts.quality ?? 0.82;
|
|
529
|
+
if (encodeWorker && bitmap) {
|
|
530
|
+
const buf = await encodeInWorker(bitmap, w, h, mimeForVideo(codec), quality);
|
|
531
|
+
emitVideoFrame(new Uint8Array(buf), codec, w, h);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
439
534
|
if (canvas.width != w)
|
|
440
535
|
canvas.width = w;
|
|
441
536
|
if (canvas.height != h)
|
|
442
537
|
canvas.height = h;
|
|
443
|
-
ctx.drawImage(video, 0, 0, w, h);
|
|
444
|
-
|
|
445
|
-
const blob = await canvasToBlob(canvas, mimeForVideo(codec),
|
|
446
|
-
|
|
447
|
-
shell.emitFrame(encodeMediaFrame({
|
|
448
|
-
kind: 'video-frame',
|
|
449
|
-
codec,
|
|
450
|
-
seq: ++seq,
|
|
451
|
-
tMono: nowMono(),
|
|
452
|
-
width: w,
|
|
453
|
-
height: h,
|
|
454
|
-
}, payload), seq);
|
|
538
|
+
ctx.drawImage(bitmap ?? video, 0, 0, w, h);
|
|
539
|
+
bitmap?.close?.();
|
|
540
|
+
const blob = await canvasToBlob(canvas, mimeForVideo(codec), quality);
|
|
541
|
+
emitVideoFrame(new Uint8Array(await blob.arrayBuffer()), codec, w, h);
|
|
455
542
|
}
|
|
456
543
|
catch (e) {
|
|
457
544
|
shell.setState('error', e);
|
|
@@ -461,10 +548,11 @@ function createVideoSource(opts = {}) {
|
|
|
461
548
|
}
|
|
462
549
|
}
|
|
463
550
|
function stop() {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
551
|
+
stopTicker?.();
|
|
552
|
+
stopTicker = null;
|
|
553
|
+
imageCapture = null;
|
|
554
|
+
encodeWorker?.terminate?.();
|
|
555
|
+
encodeWorker = null;
|
|
468
556
|
stopTracks(stream);
|
|
469
557
|
stream = null;
|
|
470
558
|
if (!opts.video && video) {
|
|
@@ -478,7 +566,7 @@ function createVideoSource(opts = {}) {
|
|
|
478
566
|
async function start() {
|
|
479
567
|
try {
|
|
480
568
|
ensureSocketTransport(transport);
|
|
481
|
-
if (!hasGetUserMedia()) {
|
|
569
|
+
if (!opts.stream && !hasGetUserMedia()) {
|
|
482
570
|
shell.setState('no-device');
|
|
483
571
|
return shell.state;
|
|
484
572
|
}
|
|
@@ -490,26 +578,47 @@ function createVideoSource(opts = {}) {
|
|
|
490
578
|
stop();
|
|
491
579
|
shell.setState('requesting');
|
|
492
580
|
shell.stats.startedAt = nowMono();
|
|
493
|
-
stream = await globalThis.navigator.mediaDevices.getUserMedia({
|
|
581
|
+
stream = await resolveMediaStream(opts.stream, () => globalThis.navigator.mediaDevices.getUserMedia({
|
|
494
582
|
video: {
|
|
495
583
|
deviceId: deviceId ? { exact: deviceId } : undefined,
|
|
496
584
|
width: opts.width,
|
|
497
585
|
height: opts.height,
|
|
498
586
|
},
|
|
499
|
-
});
|
|
587
|
+
}));
|
|
500
588
|
video = video ?? doc.createElement('video');
|
|
501
589
|
video.muted = true;
|
|
502
590
|
video.playsInline = true;
|
|
503
591
|
video.srcObject = stream;
|
|
504
592
|
await video.play?.();
|
|
505
|
-
|
|
593
|
+
const track = stream?.getVideoTracks?.()?.[0];
|
|
594
|
+
const ImageCaptureCtor = globalThis.ImageCapture;
|
|
595
|
+
try {
|
|
596
|
+
imageCapture = track && typeof ImageCaptureCtor == 'function' ? new ImageCaptureCtor(track) : null;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
imageCapture = null;
|
|
600
|
+
}
|
|
601
|
+
const g = globalThis;
|
|
602
|
+
if (opts.worker != false && imageCapture && g.Worker && g.Blob && g.URL && g.OffscreenCanvas) {
|
|
603
|
+
try {
|
|
604
|
+
const blob = new g.Blob([videoEncodeWorkerCode()], { type: 'text/javascript' });
|
|
605
|
+
const url = g.URL.createObjectURL(blob);
|
|
606
|
+
encodeWorker = new g.Worker(url);
|
|
607
|
+
g.URL.revokeObjectURL(url);
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
encodeWorker = null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
const OffscreenCanvasCtor = g.OffscreenCanvas;
|
|
614
|
+
canvas = canvas ?? (OffscreenCanvasCtor ? new OffscreenCanvasCtor(1, 1) : doc.createElement('canvas'));
|
|
506
615
|
ctx = canvas.getContext('2d');
|
|
507
616
|
if (!ctx)
|
|
508
617
|
throw new Error('2d canvas context is not available');
|
|
509
618
|
shell.setState('live');
|
|
510
619
|
const ms = Math.max(1, Math.round(1000 / (opts.fps ?? 3)));
|
|
511
620
|
await captureOne();
|
|
512
|
-
|
|
621
|
+
stopTicker = startCaptureTicker(ms, captureOne, opts.worker != false);
|
|
513
622
|
return shell.state;
|
|
514
623
|
}
|
|
515
624
|
catch (e) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
type tMediaLine = {
|
|
2
|
+
on(cb: (frame: any, sentAt?: number) => void): any;
|
|
3
|
+
};
|
|
4
|
+
export type AttachVideoCanvasOpts = {
|
|
5
|
+
createBitmap?: (blob: any) => Promise<any>;
|
|
6
|
+
onError?: (e: unknown) => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function attachVideoCanvas(line: tMediaLine, canvas: any, opts?: AttachVideoCanvasOpts): {
|
|
9
|
+
stats: () => {
|
|
10
|
+
frames: number;
|
|
11
|
+
drawn: number;
|
|
12
|
+
perSec: number;
|
|
13
|
+
ageMs: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
off: () => void;
|
|
18
|
+
};
|
|
19
|
+
export type AttachAudioPlayerOpts = {
|
|
20
|
+
maxBacklogSec?: number;
|
|
21
|
+
audioContext?: () => any;
|
|
22
|
+
onError?: (e: unknown) => void;
|
|
23
|
+
};
|
|
24
|
+
export declare function attachAudioPlayer(line: tMediaLine, opts?: AttachAudioPlayerOpts): {
|
|
25
|
+
enable(): void;
|
|
26
|
+
disable(): void;
|
|
27
|
+
readonly enabled: boolean;
|
|
28
|
+
stats: () => {
|
|
29
|
+
frames: number;
|
|
30
|
+
played: number;
|
|
31
|
+
dropped: number;
|
|
32
|
+
perSec: number;
|
|
33
|
+
ageMs: number;
|
|
34
|
+
};
|
|
35
|
+
off: () => void;
|
|
36
|
+
};
|
|
37
|
+
export type PipeMediaPublishOpts = {
|
|
38
|
+
stamp?: boolean;
|
|
39
|
+
onError?: (e: unknown) => void;
|
|
40
|
+
};
|
|
41
|
+
export declare function pipeMediaPublish(line: tMediaLine, publish: (frame: Uint8Array, sentAt?: number) => unknown, opts?: PipeMediaPublishOpts): any;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.attachVideoCanvas = attachVideoCanvas;
|
|
4
|
+
exports.attachAudioPlayer = attachAudioPlayer;
|
|
5
|
+
exports.pipeMediaPublish = pipeMediaPublish;
|
|
6
|
+
const media_source_1 = require("./media-source");
|
|
7
|
+
function makeOff(raw) {
|
|
8
|
+
function unsubscribe(h) {
|
|
9
|
+
if (typeof h == 'function')
|
|
10
|
+
h();
|
|
11
|
+
else
|
|
12
|
+
h?.off?.();
|
|
13
|
+
}
|
|
14
|
+
return function off() {
|
|
15
|
+
if (typeof raw == 'function') {
|
|
16
|
+
raw();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (raw != null && typeof raw.then == 'function') {
|
|
20
|
+
void raw.then(unsubscribe);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
unsubscribe(raw);
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function mimeForCodec(codec) {
|
|
27
|
+
if (codec == 'png')
|
|
28
|
+
return 'image/png';
|
|
29
|
+
if (codec == 'webp')
|
|
30
|
+
return 'image/webp';
|
|
31
|
+
return 'image/jpeg';
|
|
32
|
+
}
|
|
33
|
+
function createAgeMeter() {
|
|
34
|
+
let ageMs = 0;
|
|
35
|
+
return {
|
|
36
|
+
note(sentAt) {
|
|
37
|
+
if (typeof sentAt != 'number')
|
|
38
|
+
return;
|
|
39
|
+
const age = Date.now() - sentAt;
|
|
40
|
+
ageMs = ageMs ? Math.round(ageMs * 0.8 + age * 0.2) : age;
|
|
41
|
+
},
|
|
42
|
+
get ageMs() { return ageMs; },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function createRateMeter() {
|
|
46
|
+
let windowStart = 0;
|
|
47
|
+
let windowCount = 0;
|
|
48
|
+
let perSec = 0;
|
|
49
|
+
return {
|
|
50
|
+
note() {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
if (now - windowStart >= 1000) {
|
|
53
|
+
perSec = windowStart ? windowCount : 0;
|
|
54
|
+
windowStart = now;
|
|
55
|
+
windowCount = 0;
|
|
56
|
+
}
|
|
57
|
+
windowCount++;
|
|
58
|
+
},
|
|
59
|
+
get perSec() { return perSec; },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function attachVideoCanvas(line, canvas, opts = {}) {
|
|
63
|
+
const ctx = canvas.getContext('2d');
|
|
64
|
+
if (!ctx)
|
|
65
|
+
throw new Error('2d canvas context is not available');
|
|
66
|
+
const makeBitmap = opts.createBitmap ?? function defaultBitmap(blob) {
|
|
67
|
+
const create = globalThis.createImageBitmap;
|
|
68
|
+
if (typeof create != 'function')
|
|
69
|
+
throw new Error('createImageBitmap is not available');
|
|
70
|
+
return create(blob);
|
|
71
|
+
};
|
|
72
|
+
const age = createAgeMeter();
|
|
73
|
+
const rate = createRateMeter();
|
|
74
|
+
let frames = 0;
|
|
75
|
+
let drawn = 0;
|
|
76
|
+
let width = 0;
|
|
77
|
+
let height = 0;
|
|
78
|
+
let busy = false;
|
|
79
|
+
const off = makeOff(line.on(async function onVideoFrame(raw, sentAt) {
|
|
80
|
+
frames++;
|
|
81
|
+
age.note(sentAt);
|
|
82
|
+
rate.note();
|
|
83
|
+
if (busy)
|
|
84
|
+
return;
|
|
85
|
+
busy = true;
|
|
86
|
+
try {
|
|
87
|
+
const f = (0, media_source_1.decodeMediaFrame)((0, media_source_1.toBytes)(raw));
|
|
88
|
+
if (f.kind != 'video-frame' || !f.width || !f.height)
|
|
89
|
+
return;
|
|
90
|
+
const bitmap = await makeBitmap(new Blob([f.payload.slice()], { type: mimeForCodec(f.codec) }));
|
|
91
|
+
if (canvas.width != f.width)
|
|
92
|
+
canvas.width = f.width;
|
|
93
|
+
if (canvas.height != f.height)
|
|
94
|
+
canvas.height = f.height;
|
|
95
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
96
|
+
bitmap?.close?.();
|
|
97
|
+
width = f.width;
|
|
98
|
+
height = f.height;
|
|
99
|
+
drawn++;
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
opts.onError?.(e);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
busy = false;
|
|
106
|
+
}
|
|
107
|
+
}));
|
|
108
|
+
return {
|
|
109
|
+
stats: () => ({ frames, drawn, perSec: rate.perSec, ageMs: age.ageMs, width, height }),
|
|
110
|
+
off,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function attachAudioPlayer(line, opts = {}) {
|
|
114
|
+
const maxBacklogSec = opts.maxBacklogSec ?? 0.35;
|
|
115
|
+
const age = createAgeMeter();
|
|
116
|
+
const rate = createRateMeter();
|
|
117
|
+
let audioCtx = null;
|
|
118
|
+
let playhead = 0;
|
|
119
|
+
let frames = 0;
|
|
120
|
+
let played = 0;
|
|
121
|
+
let dropped = 0;
|
|
122
|
+
function makeContext() {
|
|
123
|
+
if (opts.audioContext)
|
|
124
|
+
return opts.audioContext();
|
|
125
|
+
const Ctor = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
|
126
|
+
if (!Ctor)
|
|
127
|
+
throw new Error('AudioContext is not available');
|
|
128
|
+
return new Ctor();
|
|
129
|
+
}
|
|
130
|
+
function toSamples(codec, payload) {
|
|
131
|
+
const copy = payload.slice();
|
|
132
|
+
if (codec == 'float32')
|
|
133
|
+
return new Float32Array(copy.buffer);
|
|
134
|
+
if (codec != 'pcm16')
|
|
135
|
+
return null;
|
|
136
|
+
const pcm = new Int16Array(copy.buffer);
|
|
137
|
+
const out = new Float32Array(pcm.length);
|
|
138
|
+
for (let i = 0; i < pcm.length; i++)
|
|
139
|
+
out[i] = pcm[i] / 0x8000;
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function push(raw) {
|
|
143
|
+
const f = (0, media_source_1.decodeMediaFrame)((0, media_source_1.toBytes)(raw));
|
|
144
|
+
if (f.kind != 'audio-pcm')
|
|
145
|
+
return;
|
|
146
|
+
const samples = toSamples(f.codec, f.payload);
|
|
147
|
+
if (!samples)
|
|
148
|
+
return;
|
|
149
|
+
const channels = f.channels || 1;
|
|
150
|
+
const sampleRate = f.sampleRate || 48000;
|
|
151
|
+
const nFrames = Math.floor(samples.length / channels);
|
|
152
|
+
if (!nFrames)
|
|
153
|
+
return;
|
|
154
|
+
if (playhead - audioCtx.currentTime > maxBacklogSec) {
|
|
155
|
+
playhead = audioCtx.currentTime + 0.05;
|
|
156
|
+
dropped++;
|
|
157
|
+
}
|
|
158
|
+
const buf = audioCtx.createBuffer(channels, nFrames, sampleRate);
|
|
159
|
+
for (let ch = 0; ch < channels; ch++) {
|
|
160
|
+
const chan = buf.getChannelData(ch);
|
|
161
|
+
for (let i = 0; i < nFrames; i++)
|
|
162
|
+
chan[i] = samples[i * channels + ch];
|
|
163
|
+
}
|
|
164
|
+
const node = audioCtx.createBufferSource();
|
|
165
|
+
node.buffer = buf;
|
|
166
|
+
node.connect(audioCtx.destination);
|
|
167
|
+
const at = Math.max(audioCtx.currentTime + 0.05, playhead);
|
|
168
|
+
node.start(at);
|
|
169
|
+
playhead = at + nFrames / sampleRate;
|
|
170
|
+
played++;
|
|
171
|
+
}
|
|
172
|
+
const off = makeOff(line.on(function onAudioFrame(raw, sentAt) {
|
|
173
|
+
frames++;
|
|
174
|
+
age.note(sentAt);
|
|
175
|
+
rate.note();
|
|
176
|
+
if (!audioCtx)
|
|
177
|
+
return;
|
|
178
|
+
try {
|
|
179
|
+
push(raw);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
opts.onError?.(e);
|
|
183
|
+
}
|
|
184
|
+
}));
|
|
185
|
+
return {
|
|
186
|
+
enable() {
|
|
187
|
+
audioCtx = audioCtx ?? makeContext();
|
|
188
|
+
playhead = 0;
|
|
189
|
+
void audioCtx.resume?.();
|
|
190
|
+
},
|
|
191
|
+
disable() {
|
|
192
|
+
void audioCtx?.close?.();
|
|
193
|
+
audioCtx = null;
|
|
194
|
+
},
|
|
195
|
+
get enabled() { return !!audioCtx; },
|
|
196
|
+
stats: () => ({ frames, played, dropped, perSec: rate.perSec, ageMs: age.ageMs }),
|
|
197
|
+
off,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function pipeMediaPublish(line, publish, opts = {}) {
|
|
201
|
+
return line.on(function publishFrame(frame) {
|
|
202
|
+
try {
|
|
203
|
+
Promise.resolve(publish(frame, opts.stamp == false ? undefined : Date.now()))
|
|
204
|
+
.catch(function onPublishFail(e) { opts.onError?.(e); });
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
opts.onError?.(e);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|