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.
Files changed (39) hide show
  1. package/README.md +5 -3
  2. package/demo/client.ts +230 -3
  3. package/demo/index.html +37 -0
  4. package/demo/server.ts +91 -4
  5. package/doc/INTENT.md +31 -0
  6. package/doc/ROADMAP.md +233 -212
  7. package/doc/changes/1.0.71.md +9 -0
  8. package/doc/changes/1.0.72.md +6 -0
  9. package/doc/changes/1.0.73.md +6 -0
  10. package/doc/changes/1.0.74.md +12 -0
  11. package/doc/wenay-common2-rare.md +684 -627
  12. package/doc/wenay-common2.md +50 -5
  13. package/lib/Common/events/route-signal-webrtc.d.ts +1 -1
  14. package/lib/Common/events/route-signal-webrtc.js +15 -5
  15. package/lib/Common/media/media-index.d.ts +1 -0
  16. package/lib/Common/media/media-index.js +1 -0
  17. package/lib/Common/media/media-source.d.ts +3 -0
  18. package/lib/Common/media/media-source.js +136 -27
  19. package/lib/Common/media/media-view.d.ts +42 -0
  20. package/lib/Common/media/media-view.js +210 -0
  21. package/lib/Common/peer/peer-call.d.ts +65 -0
  22. package/lib/Common/peer/peer-call.js +173 -0
  23. package/lib/Common/peer/peer-client.d.ts +21 -5
  24. package/lib/Common/peer/peer-client.js +53 -3
  25. package/lib/Common/peer/peer-host.d.ts +22 -5
  26. package/lib/Common/peer/peer-host.js +49 -5
  27. package/lib/Common/peer/peer-index.d.ts +2 -0
  28. package/lib/Common/peer/peer-index.js +2 -0
  29. package/lib/Common/peer/peer-media-relay.d.ts +22 -0
  30. package/lib/Common/peer/peer-media-relay.js +155 -0
  31. package/lib/Common/peer/peer-relay.d.ts +10 -2
  32. package/lib/Common/peer/peer-relay.js +40 -18
  33. package/observe/listen-core.test.ts +1 -2
  34. package/package.json +1 -4
  35. package/replay/media-view.test.ts +164 -0
  36. package/replay/peer-call.test.ts +226 -0
  37. package/replay/peer-repair.test.ts +184 -0
  38. package/doc/changes/1.0.63.md +0 -8
  39. package/doc/changes/1.0.64.md +0 -10
@@ -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 (`video->canvas`, JPEG, low fps for vision). 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.
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. Policy/session material:
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.set(account, emit);
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 target = ports.get(env.to);
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
- if (ports.get(account) == emit)
28
- ports.delete(account);
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)?.({ type: 'revoke', pair, from: '', to: account, reason });
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 {
@@ -1 +1,2 @@
1
1
  export * from './media-source';
2
+ export * from './media-view';
@@ -15,3 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./media-source"), exports);
18
+ __exportStar(require("./media-view"), exports);
@@ -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 timer = null;
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 w = opts.width ?? video.videoWidth ?? video.width ?? 0;
436
- const h = opts.height ?? video.videoHeight ?? video.height ?? 0;
437
- if (!w || !h)
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
- const codec = opts.codec ?? 'jpeg';
445
- const blob = await canvasToBlob(canvas, mimeForVideo(codec), opts.quality ?? 0.82);
446
- const payload = new Uint8Array(await blob.arrayBuffer());
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
- if (timer) {
465
- clearInterval(timer);
466
- timer = null;
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
- canvas = canvas ?? doc.createElement('canvas');
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
- timer = setInterval(captureOne, ms);
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
+ }