wenay-common2 1.0.70 → 1.0.73

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 CHANGED
@@ -13,7 +13,8 @@
13
13
  ## Living examples (shipped in the npm package)
14
14
 
15
15
  - [`demo/`](demo/) — runnable stand (`npm run demo`): shared cursors in two browser tabs over the
16
- Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key.
16
+ Peer SDK, relay ⇄ WebRTC direct hand-off next to a legacy rpc key; plus live media — camera,
17
+ microphone and screen share captured with the `Media` sources and streamed to the watching tab.
17
18
  - [`replay/`](replay/) · [`observe/`](observe/) · [`oracle/`](oracle/) — the oracle suites CI runs on
18
19
  every push; each file doubles as a worked usage example of one subsystem.
19
20
  - Note: examples import from the repo's `src/`; in the installed package the same API comes from
package/demo/client.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  // Demo stand client: shared cursors over the Peer SDK — relay by default,
2
2
  // "Go direct" promotes to a real RTCPeerConnection datachannel, "Back to relay"
3
3
  // re-interposes. The route hand-off is gap-free by seq; the cursor never jumps.
4
+ // Plus live media: camera / mic / screen share captured with the Media sources and
5
+ // streamed to the watching tab through the demo server's media relay.
4
6
  import {io} from 'socket.io-client'
5
7
  import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
6
8
  import {createPeerClient} from '../src/Common/peer/peer-index'
9
+ import {attachAudioPlayer, attachVideoCanvas, createAudioSource, createVideoSource, pipeMediaPublish} from '../src/Common/media/media-index'
7
10
 
8
11
  type World = {cursor: {x: number, y: number}, color: string, name: string}
9
12
 
@@ -27,7 +30,7 @@ async function main() {
27
30
  el('who').textContent = `me: ${me} · watching: ${other}`
28
31
 
29
32
  const hub = createRpcClientHub(
30
- () => io({transports: ['websocket'], auth: {account: me}}),
33
+ () => io({transports: ['websocket'], auth: {account: me, watch: other}}),
31
34
  r => ({app: r<any>('app')}) as const,
32
35
  )
33
36
  const clients = await hub.setToken(null)
@@ -95,6 +98,125 @@ async function main() {
95
98
  const res = await peer.reinterposeRelay('manual')
96
99
  log(res.ok ? 'back on relay' : `re-interpose failed: ${String(res.reason)}`)
97
100
  })
101
+
102
+ setupMedia(clients.app.func.media)
103
+ }
104
+
105
+ // ============== media: capture own cam/mic/screen, watch the peer's ==============
106
+ type tMediaKind = 'cam' | 'mic' | 'screen'
107
+
108
+ function setupMedia(media: any) {
109
+ // -------- publish own frames through the demo relay (fire-and-forget) --------
110
+ function pipePublish(kind: tMediaKind, src: any) {
111
+ pipeMediaPublish(src[1], (frame, sentAt) => media.publish(kind, frame, sentAt), {
112
+ onError: e => log(`media publish ${kind} failed: ${e}`),
113
+ })
114
+ return src
115
+ }
116
+
117
+ const camResEl = el('camRes') as HTMLSelectElement
118
+ function makeCam() {
119
+ // library fps default (3) targets machine vision; a live stand wants motion
120
+ return pipePublish('cam', createVideoSource({sourceId: 'cam', fps: 60, width: Number(camResEl.value) || 640, codec: 'jpeg'}))
121
+ }
122
+
123
+ const sources = {
124
+ cam: makeCam(),
125
+ screen: pipePublish('screen', createVideoSource({
126
+ sourceId: 'screen',
127
+ fps: 10,
128
+ codec: 'jpeg',
129
+ quality: 0.5, // full-screen JPEGs get large fast; favor latency
130
+ // the documented `stream` injection point: skip getUserMedia, bring getDisplayMedia
131
+ stream: () => (navigator.mediaDevices as any).getDisplayMedia({video: true}),
132
+ })),
133
+ // big buffers on purpose: the worklet's 128-sample chunks would be ~375 socket
134
+ // messages per second and drown the shared connection (lag for everything)
135
+ mic: pipePublish('mic', createAudioSource({sourceId: 'mic', worklet: false, bufferSize: 4096})),
136
+ }
137
+
138
+ // resolution stress test: swap the camera source on the fly, keep it live if it was
139
+ camResEl.addEventListener('change', async function onCamResChange() {
140
+ const wasLive = sources.cam.state == 'live'
141
+ sources.cam.stop()
142
+ sources.cam = makeCam()
143
+ if (wasLive) {
144
+ const state = await sources.cam.start()
145
+ log(`cam @${camResEl.value}p: ${state}`)
146
+ }
147
+ })
148
+
149
+ // -------- capture toggles --------
150
+ function bindToggle(id: string, kind: tMediaKind, label: string) {
151
+ const btn = el(id) as HTMLButtonElement
152
+ btn.addEventListener('click', async function toggleCapture() {
153
+ const src = sources[kind]
154
+ if (src.state == 'live') {
155
+ src.stop()
156
+ btn.textContent = label
157
+ log(`${kind}: stopped`)
158
+ return
159
+ }
160
+ btn.textContent = `${label} …`
161
+ const state = await src.start()
162
+ btn.textContent = state == 'live' ? `${label} ⏹` : label
163
+ log(`${kind}: ${state}${state != 'live' && src.getStats().error ? ' — ' + src.getStats().error : ''}`)
164
+ })
165
+ }
166
+ bindToggle('cam', 'cam', '📷 camera')
167
+ bindToggle('mic', 'mic', '🎙 mic')
168
+ bindToggle('screen', 'screen', '🖥 screen')
169
+
170
+ // -------- watch the peer's lines (one call per channel) --------
171
+ function watchVideo(line: any, canvasId: string, caption: string) {
172
+ const canvas = el(canvasId) as HTMLCanvasElement
173
+ const captionEl = el(canvasId + 'Cap')
174
+ canvas.addEventListener('click', function goFullscreen() {
175
+ void (document.fullscreenElement == canvas ? document.exitFullscreen() : canvas.requestFullscreen?.())
176
+ })
177
+ const view = attachVideoCanvas(line, canvas, {onError: e => log('video frame render failed: ' + e)})
178
+ return {view, captionEl, caption}
179
+ }
180
+ const peerCam = watchVideo(media.peer.cam, 'peerCam', 'peer camera')
181
+ const peerScreen = watchVideo(media.peer.screen, 'peerScreen', 'peer screen')
182
+
183
+ const player = attachAudioPlayer(media.peer.mic, {onError: e => log('audio frame failed: ' + e)})
184
+ const audioBtn = el('audio') as HTMLButtonElement
185
+ audioBtn.addEventListener('click', function togglePeerAudio() {
186
+ if (player.enabled) {
187
+ player.disable()
188
+ audioBtn.textContent = '🔊 peer audio'
189
+ } else {
190
+ player.enable()
191
+ audioBtn.textContent = '🔊 peer audio ⏹'
192
+ }
193
+ })
194
+
195
+ // -------- stats line: own capture + helper-provided rx metrics --------
196
+ const statsEl = el('mediaStats')
197
+ let prevTx = {cam: 0, mic: 0, screen: 0}
198
+ setInterval(function renderMediaStats() {
199
+ const parts: string[] = []
200
+ const nextTx = {cam: 0, mic: 0, screen: 0}
201
+ for (const kind of ['cam', 'mic', 'screen'] as const) {
202
+ const s = sources[kind].getStats()
203
+ nextTx[kind] = s.frames
204
+ if (s.state != 'idle') parts.push(`${kind}: ${s.state} ${s.frames}f ${s.frames - prevTx[kind]}/s${s.rms != null ? ` rms=${s.rms.toFixed(3)}` : ''}`)
205
+ }
206
+ prevTx = nextTx
207
+ const cam = peerCam.view.stats()
208
+ const screen = peerScreen.view.stats()
209
+ const mic = player.stats()
210
+ for (const v of [peerCam, peerScreen]) {
211
+ const s = v.view.stats()
212
+ if (s.width) v.captionEl.textContent = `${v.caption} · ${s.width}×${s.height} · click = fullscreen`
213
+ }
214
+ if (cam.frames || screen.frames || mic.frames) parts.push(
215
+ `rx: cam ${cam.frames}f/${cam.drawn}d ${cam.perSec}/s ~${cam.ageMs}ms` +
216
+ ` · screen ${screen.frames}f/${screen.drawn}d ${screen.perSec}/s ~${screen.ageMs}ms` +
217
+ ` · mic ${mic.frames}f ${mic.perSec}/s ~${mic.ageMs}ms`)
218
+ statsEl.textContent = parts.join(' · ')
219
+ }, 1000)
98
220
  }
99
221
 
100
222
  main().catch(e => { console.error(e); log('FATAL: ' + e) })
package/demo/index.html CHANGED
@@ -11,6 +11,13 @@
11
11
  button { padding: 6px 14px; border-radius: 6px; border: 1px solid #888; background: #fff; cursor: pointer; }
12
12
  button:hover { background: #f0f0f0; }
13
13
  #log { font-family: monospace; font-size: 12px; margin-top: 8px; max-height: 180px; overflow-y: auto; color: #555; }
14
+ #mediaView { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; }
15
+ #mediaView figure { margin: 0; }
16
+ #mediaView figcaption { font-size: 12px; color: #777; margin-bottom: 4px; }
17
+ #peerCam, #peerScreen { border: 1px solid #bbb; border-radius: 6px; background: #111; max-width: 480px; height: auto; cursor: zoom-in; }
18
+ #peerCam:fullscreen, #peerScreen:fullscreen { width: 100vw; height: 100vh; object-fit: contain; background: #000; border: none; }
19
+ select { padding: 6px 8px; border-radius: 6px; border: 1px solid #888; background: #fff; }
20
+ #mediaStats { font-family: monospace; font-size: 12px; color: #777; }
14
21
  </style>
15
22
  </head>
16
23
  <body>
@@ -22,6 +29,29 @@
22
29
  <span id="route"></span>
23
30
  </div>
24
31
  <canvas id="canvas" width="640" height="360"></canvas>
32
+ <div id="bar">
33
+ <button id="cam">📷 camera</button>
34
+ <select id="camRes" title="camera capture width">
35
+ <option value="320">320p</option>
36
+ <option value="640" selected>640p</option>
37
+ <option value="1280">1280p</option>
38
+ <option value="1920">1920p</option>
39
+ </select>
40
+ <button id="mic">🎙 mic</button>
41
+ <button id="screen">🖥 screen</button>
42
+ <button id="audio">🔊 peer audio</button>
43
+ <span id="mediaStats"></span>
44
+ </div>
45
+ <div id="mediaView">
46
+ <figure>
47
+ <figcaption id="peerCamCap">peer camera · click = fullscreen</figcaption>
48
+ <canvas id="peerCam" width="320" height="240"></canvas>
49
+ </figure>
50
+ <figure>
51
+ <figcaption id="peerScreenCap">peer screen · click = fullscreen</figcaption>
52
+ <canvas id="peerScreen" width="480" height="270"></canvas>
53
+ </figure>
54
+ </div>
25
55
  <div id="log"></div>
26
56
  <script src="client.js"></script>
27
57
  </body>
package/demo/server.ts CHANGED
@@ -5,21 +5,66 @@ import {createServer} from 'http'
5
5
  import path from 'path'
6
6
  import {Server as SocketIOServer} from 'socket.io'
7
7
  import {listen} from '../src/Common/events/Listen'
8
+ import {replayListen} from '../src/Common/events/replay-listen'
8
9
  import {createPeerHost} from '../src/Common/peer/peer-index'
9
10
  import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
10
11
 
11
12
  const PORT = Number(process.env.PORT ?? 8390)
12
13
 
14
+ // ============== demo media hub: per-account replay lines ==============
15
+ // Browser capture (cam/mic/screen) originates on the CLIENT, so the server side is a
16
+ // tiny relay: publish() feeds the publisher's line, watchers get plain Listen surfaces
17
+ // exposed through createRpcServerAuto like any other Listen. Video lines are keep-latest
18
+ // (a late joiner instantly gets the last frame), audio is a short lossless queue.
19
+ type tMediaKind = 'cam' | 'mic' | 'screen'
20
+
21
+ function createDemoMediaHub() {
22
+ // [frame, sentAt]: wall-clock publish time rides along so viewers can show real latency
23
+ function createLines() {
24
+ return {
25
+ cam: replayListen<[Uint8Array, number]>({history: 8, current: 'last', frame: tail => tail.length ? [tail[tail.length - 1]] : []}),
26
+ screen: replayListen<[Uint8Array, number]>({history: 8, current: 'last', frame: tail => tail.length ? [tail[tail.length - 1]] : []}),
27
+ mic: replayListen<[Uint8Array, number]>({history: 64}),
28
+ }
29
+ }
30
+ const accounts = new Map<string, ReturnType<typeof createLines>>()
31
+ function linesOf(account: string) {
32
+ let lines = accounts.get(account)
33
+ if (!lines) {
34
+ lines = createLines()
35
+ accounts.set(account, lines)
36
+ }
37
+ return lines
38
+ }
39
+ return {
40
+ // what the publishing account calls
41
+ publishOf(account: string) {
42
+ return function publish(kind: tMediaKind, frame: Uint8Array, sentAt: number) {
43
+ const line = linesOf(account)[kind]
44
+ if (line) line[0](frame, sentAt)
45
+ }
46
+ },
47
+ // what a watcher subscribes to
48
+ watchOf(account: string) {
49
+ const lines = linesOf(account)
50
+ return {cam: lines.cam[1], mic: lines.mic[1], screen: lines.screen[1]}
51
+ },
52
+ }
53
+ }
54
+
13
55
  const host = createPeerHost()
56
+ const mediaHub = createDemoMediaHub()
14
57
  const app = express()
15
58
  app.use(express.static(path.resolve(__dirname, 'public')))
16
59
  app.get('/', (_req, res) => res.sendFile(path.resolve(__dirname, 'public', 'index.html')))
17
60
 
18
61
  const httpServer = createServer(app)
19
- const ioServer = new SocketIOServer(httpServer)
62
+ // screen-share JPEG frames can exceed the 1MB Socket.IO default
63
+ const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
20
64
 
21
65
  ioServer.on('connection', function onDemoConnection(socket) {
22
66
  const account = String(socket.handshake.auth?.account ?? 'anon')
67
+ const watch = String(socket.handshake.auth?.watch ?? (account == 'a' ? 'b' : 'a'))
23
68
  const peer = host.connection(account)
24
69
  const [disconnect, disconnectListen] = listen<[]>()
25
70
  socket.on('disconnect', () => { disconnect(); peer.close() })
@@ -30,10 +75,15 @@ ioServer.on('connection', function onDemoConnection(socket) {
30
75
  // legacy key on the SAME connection — the SDK does not displace old code
31
76
  serverTime: () => new Date().toISOString(),
32
77
  peer: peer.fragment,
78
+ media: {
79
+ publish: mediaHub.publishOf(account),
80
+ // the watched account's live lines, declared at connect time (auth.watch)
81
+ peer: mediaHub.watchOf(watch),
82
+ },
33
83
  },
34
84
  disconnectListen,
35
85
  })
36
- console.log(`[demo] ${account} connected`)
86
+ console.log(`[demo] ${account} connected (watching ${watch})`)
37
87
  })
38
88
 
39
89
  httpServer.listen(PORT, function onDemoListen() {
package/doc/INTENT.md ADDED
@@ -0,0 +1,31 @@
1
+ # wenay-common2 — project intent
2
+
3
+ ## What this is
4
+
5
+ A personal, open-source common/transport library, matured over many years. The published `1.x`
6
+ line is really a **second generation** ("version 1" the number, version 2 the design) — the earlier
7
+ generation and its plan/history docs were deliberately removed; the canonical surface today is
8
+ `doc/wenay-common2.md` (brief) + `doc/wenay-common2-rare.md` (full reference).
9
+
10
+ The current stack is a small distributed-state runtime: a typed RPC core, an `Observe` reactive
11
+ store, a universal replay layer (`seq` + keyframe + deltas), a policy-gated route coordinator
12
+ (relay ⇄ WebRTC direct), a WebRTC signaling adapter, a media-over-socket layer, and a one-call
13
+ `Peer` SDK on top. It is oracle-covered (CI on every push) and shipped with living examples and a
14
+ runnable demo stand in the npm package.
15
+
16
+ ## Intent (2026-07)
17
+
18
+ - **Open source, not promoted.** The library is public for use and as a portfolio/reference, but the
19
+ author is **not** going to actively market or grow a community around it. Other side-projects are
20
+ the current focus.
21
+ - **No pressure to "finish" transport.** The transport/routing/replay lower half is built and good
22
+ enough; ROADMAP transport items are 🧊 deferred (super-low priority, not forbidden). They reopen
23
+ only when a real consumer hits a wall — never speculatively.
24
+ - **Value is in being legible and demoable.** The measure of progress is "what can be shown
25
+ working," not layers added. The demo stand and the oracle examples exist to make the design
26
+ readable and hireable, not to chase feature completeness.
27
+ - **Direction if picked up again:** the consumption layer (framework adapters — React hooks first),
28
+ and the showcase (a hero relay→direct demo). Everything else waits for a concrete need.
29
+
30
+ This file is the durable record of that intent. It is not a worklist; the forward backlog (kept for
31
+ reference, not commitment) lives in `doc/ROADMAP.md` and `doc/target/`.
package/doc/ROADMAP.md CHANGED
@@ -141,7 +141,25 @@ and `serveReplayChannel`/`channelReplayRemote` (replay wire over any ordered cha
141
141
  path bypasses the RPC core by design). Oracle `replay/route-webrtc.test.ts` drives promotion, endpoint
142
142
  denial, session rejection, and server revoke over both an in-proc hub and a real Socket.IO/RPC wire.
143
143
  Still open: browser/Node WebRTC glue (step 10 — now a one-line `rtc` factory plus media re-emit) and
144
- the account-map lifecycle integration (step 7).
144
+ the account-map lifecycle integration (step 7). Media-side candidates (2026-07-10, after the demo
145
+ stand stress test): a real `transport:'webrtc'` media track — SDP over the existing signal hub,
146
+ media bypassing the socket relay for call-grade smoothness; and `MediaStreamTrackProcessor` capture
147
+ for true 30fps (`grabFrame`'s ~50ms serial latency caps snapshot capture at ~15-20fps).
148
+
149
+ Consumption-layer candidates (2026-07-10, author's ask):
150
+ - **Call system** — one account calls another, messenger-style: presence (the host already knows
151
+ who is connected), ring / accept / decline envelopes over the existing signal hub, then media
152
+ over the socket relay (mainstream messengers route calls through their servers too — relay-first
153
+ is the privacy default, `promoteDirect` stays the opt-in). Builds entirely on shipped parts:
154
+ peer host + signal hub + media lines + `Media.attach*` viewers.
155
+ - **Store-descriptor media bridge** ("streaming center") — the store carries a small descriptor
156
+ (`{kind: 'video', sourceId, state}`), a bridge watches the mirror and auto-attaches/detaches
157
+ `Media.attachVideoCanvas`/`attachAudioPlayer` to the matching binary line. Bytes flow only while
158
+ subscribed (native Listen-over-RPC semantics). Pairs with the React-hooks direction.
159
+ - 🧊 **Zero-parse patch format** (super-far future) — Cap'n Proto / FlatBuffers-style layout for
160
+ store patches: fields read directly from the received buffer, no parse stage. Only if a real
161
+ consumer hits a serialization wall; JSON envelopes are nowhere near the bottleneck today
162
+ (measured 2-3ms e2e on the stand).
145
163
 
146
164
  ## 1. Connection hand-off — relay ↔ direct promotion ("port forwarding") 🟡
147
165
 
@@ -0,0 +1,9 @@
1
+ # 1.0.71
2
+
3
+ - Publish-path reconnect correctness for the Peer SDK: the relay journal never lies after a publisher gap.
4
+ - `Peer.createPatchRelayJournal({gap: 'resume' | 'sacred'})` — the server declares journal semantics: `'resume'` (default) folds a keyframe and takes a ROOT patch as the one legitimate reset point; `'sacred'` never invents (no folded keyframe, no root reset, strict contiguity, `frame()` throws on an evicted tail).
5
+ - `push()` now returns `true | false | {seq}` — a rejected gap carries the relay's last seq, and that coordinate IS the repair request (self-healing protocol, no separate handshake); duplicates are idempotent no-ops; a non-root FIRST envelope is rejected (a partial-state fold would lie to late joiners); rejection never corrupts the fold.
6
+ - `Peer.createPeerClient`: rejection-driven repair — `repair: 'tail'` (missed envelopes verbatim, keyframe fallback on local eviction) or `'keyframe'` (one cheap root reset for ephemeral state); `journal: 'sacred'` narrows `repair` to `'tail'` at the TYPE level (`tPublishRepair<J>`) — incompatible asks are forbidden by typing, not runtime checks, and a lying client just keeps getting rejections, loudly via `onPublishError`.
7
+ - `client.resync()` — call after a transport reconnect: compares the relay's `seq()` (additive on the wire) with the local line and repairs the gap without waiting for the next write.
8
+ - Add oracle `replay/peer-repair.test.ts`: full gap matrix (folding + sacred), both repair modes over a lossy transport, sacred local-eviction fault surfacing, `resync()`.
9
+ - Media sources: `replay` now also accepts custom `ReplayListenUseOptions` (`replay: {history, current, frame}`), not just `true`; defaults stay per-kind (audio — sacred queue `history:1024`, video — keep-latest `history:256`).
@@ -0,0 +1,6 @@
1
+ # 1.0.72
2
+
3
+ - Media video sources survive hidden/occluded tabs (Chrome throttling). Three measured stalls, three escape hatches, all on by default (`worker: false` opts out): capture tick moves from `setInterval` (~1/s hidden) to a Blob-worker timer; frames come from `ImageCapture.grabFrame()` (a hidden `<video>` stops painting); JPEG encode moves into a worker with a transferred `ImageBitmap` (main-thread `convertToBlob` is gated to ~1000ms hidden, ~5-10ms in the worker). Measured result: a hidden tab streams a stable ~16fps instead of decaying to 1fps after ~30s.
4
+ - Demo stand: live media — camera, microphone and screen share, captured with the public `Media` sources and streamed between the two tabs. Screen share uses the documented `stream` injection point (`createVideoSource({stream: () => getDisplayMedia(...)})`); the demo server grows a tiny relay of per-account replay lines next to `peer.fragment` (video keep-latest, audio short queue, watched account declared via `auth.watch`, frames ride with a `sentAt` wall-clock stamp).
5
+ - Demo client: capture toggles + per-second tx/rx rates and publish->receive latency in the stats line (`rx: cam Nf/Nd 16/s ~3ms`), peer camera/screen on canvases, peer mic through a sequential-playhead PCM player that drops >350ms backlog (live beats lossless for a call-like demo). Mic capture uses `worker:false, bufferSize:4096` — big PCM chunks instead of ~375 socket messages/s.
6
+ - Docs: screen-share recipe + hidden-tab capture note in the Media section; README demo line mentions the media part.
@@ -0,0 +1,6 @@
1
+ # 1.0.73
2
+
3
+ - Media viewer helpers — the consumer side of a media line in one call (`media-view`, additive): `Media.attachVideoCanvas(line, canvas)` (header-driven codec/size, busy-skip keep-latest, `stats()` with frames/drawn/perSec/ageMs), `Media.attachAudioPlayer(line, {maxBacklogSec})` (sequential-playhead PCM, live backlog drop, gesture-gated `enable()`), `Media.pipeMediaPublish(line, publish, {stamp})` (fire-and-forget publish pipe; the `Date.now()` stamp feeds viewer latency stats). DI for tests: `createBitmap` / `audioContext` injection points.
4
+ - Demo stand client rewired onto the helpers — the hand-written viewer plumbing (~90 lines of JPEG decode, PCM playhead, counters) collapses into one call per channel; behavior verified live (cam 30/s ~3ms, mic 12/s ~2ms, zero drops).
5
+ - Route hand-off under media load verified on the live stand: relay -> direct (real ICE/SDP) -> relay while camera + mic stream; zero errors, media rates/latency untouched through both switches (media rides the socket relay; peer-store routes are per-direction by design).
6
+ - Export `Media.toBytes` (binary view normalizer used by the helpers).
@@ -223,9 +223,16 @@ Audio source:
223
223
  - `getStats().rms` gives a VU-meter signal; permission denied/no device returns typed state, not a thrown public failure.
224
224
 
225
225
  Video source:
226
- - default snapshots, not a 30fps video stream: `video->canvas`, JPEG, `fps` default 3, `quality` default 0.82.
226
+ - default snapshots, not a 30fps video stream: JPEG, `fps` default 3, `quality` default 0.82.
227
227
  - each frame carries absolute image bytes, so `replay:true` can safely keep the latest frame for lag recovery.
228
- - `worker` is API-reserved; current implementation stays main-thread. Any future worker path must transfer `ArrayBuffer` payloads, never structured-clone frame objects.
228
+ - capture is hidden-tab-proof by default (Chrome throttles hidden tabs three ways, each stage has its own escape): the tick comes from a Blob-worker timer (in-page `setInterval` drops to ~1/s), the frame comes from `ImageCapture.grabFrame()` when available (a hidden `<video>` stops painting; `<video>->canvas` stays as the fallback), and JPEG encode runs in a worker over a transferred `ImageBitmap`, returning a transferred `ArrayBuffer` never a structured-cloned frame (main-thread `convertToBlob` is gated to ~1s per call when hidden). `worker: false` opts out of all three into the plain in-page path.
229
+ - one explicit dimension (`width` or `height`) scales the other proportionally from the track resolution, downscale-only; pass both to force an exact size. `grabFrame`'s ~50ms serial latency caps the pipeline around ~15-20fps regardless of `fps`.
230
+
231
+ Viewer helpers (`media-view`): the consumer side of any media line (local pair or RPC surface).
232
+ - `attachVideoCanvas(line, canvas, {createBitmap?, onError?})` — per-frame codec/size come from the 40-byte header, canvas resizes to follow; decode overload is busy-skipped (keep-latest, `stats().frames` vs `stats().drawn` shows the gap); `createBitmap` injects a custom decoder (tests, OffscreenCanvas pipelines).
233
+ - `attachAudioPlayer(line, {maxBacklogSec? = 0.35, audioContext?, onError?})` — pcm16/float32 through a sequential playhead; a backlog past `maxBacklogSec` is dropped and the playhead rebases near "now" (live beats lossless; `stats().dropped` counts rebases). `enable()` must come from a user gesture (browser autoplay rules); `audioContext` injects a factory for tests/custom routing.
234
+ - `pipeMediaPublish(line, publish, {stamp? = true, onError?})` — fire-and-forget pipe into an RPC call; the default `Date.now()` stamp is what viewer `stats().ageMs` measures against. Both attach helpers also expose `stats().perSec` (rolling 1s rate).
235
+ - Oracle: `replay/media-view.test.ts`.
229
236
 
230
237
  Replay/RPC wiring:
231
238
  ```ts
@@ -529,13 +536,27 @@ acceptWebRtcDirect({port, rtc, self, serve, accept?}) -> close()
529
536
  // responder side: on offer, negotiates answer/ICE and serves serve(env) (exposeReplay(...) as is) into
530
537
  // the incoming datachannel; accept(env) validates session material and rejects with a loud revoke
531
538
  // (the initiator fails fast, not by timeout). Repeated offer for a pair recreates the session.
532
- Peer.createPatchRelayJournal({history?}) -> {push(env), remote, seq(), snapshot(), close()}
533
- // server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM
534
- // (dedup by seq; a root patch with a LOWER seq = owner restart, legitimate reset point), keyframe is
535
- // folded server-side (late joiners don't need the owner online), frame condenses last-patch-per-path.
539
+ Peer.createPatchRelayJournal({history?, gap?: 'resume'|'sacred'}) -> {push(env) -> true|false|{seq}, remote, gap, seq(), snapshot(), close()}
540
+ // server-side mirror of an OWNER-sequenced patch line: push() takes the owner's envelopes VERBATIM.
536
541
  // Owner seq space is the point: relay and direct routes share coordinates -> hand-off is a seq resume.
537
- // remote is ReplayRemote-shaped and rpc-exposable as is (line is a REAL Listen — the rpc layer detects
538
- // listen nodes by registry, a hand-rolled {on: cb => ...} wrapper would not stream).
542
+ // CORRECTNESS CONTRACT (gap = the SERVER's data-type decision):
543
+ // 'resume' (default, folding): keyframe folded server-side (late joiners never need the owner online);
544
+ // a ROOT patch always resets the journal (owner restart AND keyframe repair share one rule); a
545
+ // non-root envelope with a seq gap — and a non-root FIRST envelope (partial-state lie) — is
546
+ // REJECTED as {seq: N}: that coordinate IS the repair request, no separate handshake exists.
547
+ // 'sacred': the journal never invents — no folded keyframe, no root-reset, strict contiguity only;
548
+ // frame() on an evicted tail THROWS. For data where an invented snapshot is unacceptable.
549
+ // Duplicates (reconnect/repair overlap) are idempotent no-ops. Rejection never corrupts the fold.
550
+ // remote is ReplayRemote-shaped (+ additive `seq()` for publisher resync) and rpc-exposable as is
551
+ // (line is a REAL Listen — the rpc layer detects listen nodes by registry, a hand-rolled
552
+ // {on: cb => ...} wrapper would not stream).
553
+ // Publisher side (createPeerClient): {journal?: 'resume'|'sacred', repair?: 'tail'|'keyframe',
554
+ // onPublishError?, resync()}. 'tail' = missed envelopes verbatim (falls back to a root keyframe if
555
+ // the local journal evicted them — resume only); 'keyframe' = one cheap root reset (ephemeral state).
556
+ // TYPE RULE, not a runtime check: journal:'sacred' narrows repair to 'tail' (tPublishRepair<J>) —
557
+ // lie about the journal kind and the relay simply keeps rejecting you (loud via onPublishError).
558
+ // resync() = call after transport reconnect: compares relay seq() with the local line, repairs the
559
+ // gap without waiting for the next write. Oracle: replay/peer-repair.test.ts (full gap matrix).
539
560
  // The full SDK on top (createPeerHost/createPeerClient) is most-used surface -> wenay-common2.md.
540
561
  serveReplayChannel(source, channel) <-> channelReplayRemote(channel) -> ReplayRemote
541
562
  // replay wire over ANY ordered string channel (datachannel/MessagePort/worker/pipe): tiny JSON
@@ -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,7 +190,13 @@ 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`.
@@ -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;