wenay-common2 1.0.69 → 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 +11 -1
- package/demo/client.ts +222 -0
- package/demo/index.html +58 -0
- package/demo/server.ts +93 -0
- package/doc/INTENT.md +31 -0
- package/doc/ROADMAP.md +19 -1
- package/doc/changes/1.0.70.md +4 -0
- 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/wenay-common2-rare.md +29 -8
- package/doc/wenay-common2.md +13 -2
- 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 +191 -0
- package/lib/Common/peer/peer-client.d.ts +12 -5
- package/lib/Common/peer/peer-client.js +53 -3
- package/lib/Common/peer/peer-host.d.ts +8 -4
- package/lib/Common/peer/peer-host.js +2 -2
- package/lib/Common/peer/peer-relay.d.ts +10 -2
- package/lib/Common/peer/peer-relay.js +40 -18
- package/observe/PLAN.md +131 -0
- package/observe/README.md +226 -0
- package/observe/hot-write.test.ts +95 -0
- package/observe/listen-core.test.ts +66 -0
- package/observe/listen-store.test.ts +56 -0
- package/observe/listen.test.ts +92 -0
- package/observe/reactive.test.ts +270 -0
- package/observe/reactive.ts +1 -0
- package/observe/store-manager.test.ts +118 -0
- package/observe/store-mirror.example.ts +74 -0
- package/observe/store.test.ts +235 -0
- package/observe/store.ts +1 -0
- package/observe/usage-real-socket.ts +174 -0
- package/observe/usage.ts +200 -0
- package/oracle/PASSED.md +27 -0
- package/oracle/README.md +12 -0
- package/oracle/fixes-primitives.spec.ts +90 -0
- package/oracle/realsocket/_rs.ts +106 -0
- package/oracle/realsocket/auth.spec.ts +91 -0
- package/oracle/realsocket/callbacks.spec.ts +122 -0
- package/oracle/realsocket/caps.spec.ts +124 -0
- package/oracle/realsocket/core.spec.ts +49 -0
- package/oracle/realsocket/dedupe.spec.ts +142 -0
- package/oracle/realsocket/errors.spec.ts +121 -0
- package/oracle/realsocket/lifecycle.spec.ts +128 -0
- package/oracle/realsocket/limits.spec.ts +98 -0
- package/oracle/realsocket/pipe.spec.ts +101 -0
- package/oracle/realsocket/shape.spec.ts +124 -0
- package/oracle/realsocket/slimv2.spec.ts +116 -0
- package/oracle/realsocket/stress.spec.ts +132 -0
- package/oracle/regression/_clientapiall-replay.fixture.ts +57 -0
- package/oracle/regression/async-queues.spec.ts +254 -0
- package/oracle/regression/bytestream.spec.ts +152 -0
- package/oracle/regression/clientapiall-replay-types.spec.ts +47 -0
- package/oracle/regression/core-clone-equal.spec.ts +124 -0
- package/oracle/regression/data-structures.spec.ts +135 -0
- package/oracle/regression/listen-events.spec.ts +206 -0
- package/oracle/regression/observe-core.spec.ts +278 -0
- package/oracle/regression/package-export.spec.ts +120 -0
- package/oracle/regression/rpc-dedupe-callbacks.spec.ts +195 -0
- package/oracle/regression/rpc-lifecycle.spec.ts +150 -0
- package/oracle/regression/store-each.spec.ts +209 -0
- package/package.json +6 -1
- package/replay/PLAN.md +171 -0
- package/replay/canvas-socket.test.ts +187 -0
- package/replay/coalesce.test.ts +260 -0
- package/replay/conflate-socket.test.ts +288 -0
- package/replay/conflate.test.ts +225 -0
- package/replay/history.test.ts +222 -0
- package/replay/media-socket.test.ts +157 -0
- package/replay/media-view.test.ts +164 -0
- package/replay/offline-store-socket.test.ts +290 -0
- package/replay/offline-store.test.ts +156 -0
- package/replay/peer-repair.test.ts +184 -0
- package/replay/peer-sdk.test.ts +228 -0
- package/replay/replay-listen.test.ts +156 -0
- package/replay/route-coordinator.test.ts +314 -0
- package/replay/route-handoff.test.ts +150 -0
- package/replay/route-webrtc.test.ts +321 -0
- package/replay/rpc-auto.test.ts +256 -0
- package/replay/socket-replay.test.ts +199 -0
- package/replay/staleness.test.ts +176 -0
- package/replay/store-replay.test.ts +151 -0
- package/replay/video-socket.demo.ts +200 -0
- package/doc/changes/1.0.63.md +0 -8
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/history.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Слой C: архиватор (каданс keyframe'ов) + читатель истории.
|
|
5
|
+
// Часть 1 — seek по seq/ts, часть 2 — playback и дыра в архиве,
|
|
6
|
+
// часть 3 — handover архив → живой журнал → live, часть 4 —
|
|
7
|
+
// «перемотка на 12:00» с бесшовным доигрыванием до live,
|
|
8
|
+
// часть 5 — машина времени для стора, часть 6 — файловое
|
|
9
|
+
// хранилище (jsonl) как доказательство «лямбды = что угодно».
|
|
10
|
+
// Запуск:
|
|
11
|
+
// npx ts-node replay/history.test.ts
|
|
12
|
+
// ============================================================
|
|
13
|
+
|
|
14
|
+
import * as fs from 'fs'
|
|
15
|
+
import * as os from 'os'
|
|
16
|
+
import * as path from 'path'
|
|
17
|
+
import {applyStorePatch, createStore, StorePatch} from '../src/Common/Observe/store'
|
|
18
|
+
import {flushReactive} from '../src/Common/Observe/reactive'
|
|
19
|
+
import {replayListen, ReplayEvent} from '../src/Common/events/replay-index'
|
|
20
|
+
import {archiveReplay, createMemoryReplayStorage, openHistory, ReplayStorage} from '../src/Common/events/replay-index'
|
|
21
|
+
import {exposeStoreReplay, storeReplayAt} from '../src/Common/Observe/store-replay'
|
|
22
|
+
|
|
23
|
+
let fails = 0
|
|
24
|
+
const ok = (condition: any, message: string) => {
|
|
25
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
26
|
+
else console.log(' OK ', message)
|
|
27
|
+
}
|
|
28
|
+
const json = (v: any) => JSON.stringify(v)
|
|
29
|
+
const ascendingUnique = (seqs: number[]) => seqs.every((s, i) => i == 0 || s > seqs[i - 1])
|
|
30
|
+
|
|
31
|
+
type World = {
|
|
32
|
+
units: Record<string, {hp: number, x: number}>
|
|
33
|
+
tick: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// счётчик-линия с подменяемыми часами: событие n = абсолютное значение n,
|
|
37
|
+
// ts события n = 1000 + 1000*n → последний конверт = состояние
|
|
38
|
+
function makeCounterLine(storage: ReplayStorage<[number]>, everyEvents: number, useAsJournal = false) {
|
|
39
|
+
let clock = 1000
|
|
40
|
+
let value = 0
|
|
41
|
+
const [emit, replay] = replayListen<[number]>({
|
|
42
|
+
current: () => [value],
|
|
43
|
+
// «память снаружи»: живой журнал = тот же архив → playback→live без скачка
|
|
44
|
+
...(useAsJournal
|
|
45
|
+
? {getSince: (s: number) => storage.getEvents(s, Infinity)}
|
|
46
|
+
: {history: 5}),
|
|
47
|
+
now: () => clock,
|
|
48
|
+
})
|
|
49
|
+
const arch = archiveReplay(replay, {storage, everyEvents})
|
|
50
|
+
const push = (n: number) => { value = n; clock = 1000 + 1000 * n; emit(n) }
|
|
51
|
+
return {replay, arch, push}
|
|
52
|
+
}
|
|
53
|
+
const lastValue = (envs: ReplayEvent<[number]>[] | undefined) => envs ? envs[envs.length - 1].event[0] : undefined
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
console.log('\n[history] archiver cadence + seek by seq and ts')
|
|
57
|
+
{
|
|
58
|
+
const storage = createMemoryReplayStorage<[number]>()
|
|
59
|
+
const {arch, push} = makeCounterLine(storage, 10)
|
|
60
|
+
for (let n = 1; n <= 35; n++) push(n)
|
|
61
|
+
ok(storage.size().events == 35, 'every line event landed in the archive')
|
|
62
|
+
ok(storage.size().keyframes == 4, `base + one keyframe per 10 events (got ${storage.size().keyframes})`)
|
|
63
|
+
|
|
64
|
+
const h = openHistory(storage)
|
|
65
|
+
const at17 = h.at({seq: 17})
|
|
66
|
+
ok(lastValue(at17) == 17, 'seek by seq: keyframe + deltas reconstruct the state')
|
|
67
|
+
ok(at17![0].seq == 10 && at17!.length == 8, 'reconstruction starts at the NEAREST keyframe, not from zero')
|
|
68
|
+
ok(lastValue(h.at({ts: 1000 + 1000 * 12})) == 12, '«rewind to 12:00»: ts resolves to keyframe <= ts + deltas to ts')
|
|
69
|
+
ok(lastValue(h.at({seq: 3})) == 3, 'before the first cadence frame the base keyframe anchors the replay')
|
|
70
|
+
ok(lastValue(h.at()) == 35, 'no target = latest archived state')
|
|
71
|
+
arch.close()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
console.log('\n[history] time-based cadence (GOP by wall clock)')
|
|
75
|
+
{
|
|
76
|
+
const storage = createMemoryReplayStorage<[number]>()
|
|
77
|
+
let clock = 0
|
|
78
|
+
let value = 0
|
|
79
|
+
const [emit, replay] = replayListen<[number]>({current: () => [value], history: 5, now: () => clock})
|
|
80
|
+
const arch = archiveReplay(replay, {storage, everyEvents: 1000, everyMs: 5000})
|
|
81
|
+
for (let n = 1; n <= 12; n++) { value = n; clock = n * 1000; emit(n) }
|
|
82
|
+
// кадры на ts 5000 и 10000 (+ базовый) — событий мало, время истекло
|
|
83
|
+
ok(arch.stats().keyframes == 3, `everyMs cuts keyframes by time when events are sparse (got ${arch.stats().keyframes})`)
|
|
84
|
+
arch.close()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
console.log('\n[history] pure playback + hole in the archive')
|
|
88
|
+
{
|
|
89
|
+
const storage = createMemoryReplayStorage<[number]>()
|
|
90
|
+
const {arch, push} = makeCounterLine(storage, 10)
|
|
91
|
+
for (let n = 1; n <= 35; n++) push(n)
|
|
92
|
+
const h = openHistory(storage)
|
|
93
|
+
|
|
94
|
+
const got: number[] = []
|
|
95
|
+
const sub = h.subscribe(n => got.push(n), {since: 30})
|
|
96
|
+
ok(json(got) == json([31, 32, 33, 34, 35]), 'playback from since = pure tail, no keyframe')
|
|
97
|
+
ok(sub.seq() == 35, 'reader reports the reconnect point')
|
|
98
|
+
arch.close()
|
|
99
|
+
|
|
100
|
+
// архив с вытеснением: события 1..25 потеряны, keyframe'ы целы
|
|
101
|
+
const capped = createMemoryReplayStorage<[number]>({maxEvents: 10})
|
|
102
|
+
const line2 = makeCounterLine(capped, 10)
|
|
103
|
+
for (let n = 1; n <= 35; n++) line2.push(n)
|
|
104
|
+
const got2: number[] = []
|
|
105
|
+
openHistory(capped).subscribe(n => got2.push(n), {since: 5})
|
|
106
|
+
ok(got2[0] == 30, 'hole in the archive → fresh start from the latest keyframe (reset down allowed)')
|
|
107
|
+
ok(json(got2) == json([30, 31, 32, 33, 34, 35]), 'consistent tail after the keyframe, no state hole')
|
|
108
|
+
line2.arch.close()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
console.log('\n[history] handover: archive replay → live journal → live')
|
|
112
|
+
{
|
|
113
|
+
const storage = createMemoryReplayStorage<[number]>()
|
|
114
|
+
const {replay, arch, push} = makeCounterLine(storage, 10) // живой журнал: кольцо на 5
|
|
115
|
+
for (let n = 1; n <= 35; n++) push(n)
|
|
116
|
+
|
|
117
|
+
const got: number[] = []
|
|
118
|
+
const seqs: number[] = []
|
|
119
|
+
const h = openHistory(storage, replay)
|
|
120
|
+
const sub = h.subscribe(n => got.push(n), {since: 20, onSeq: s => seqs.push(s)})
|
|
121
|
+
ok(json(got) == json([21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]),
|
|
122
|
+
'archive covers what the live ring (5) evicted long ago')
|
|
123
|
+
push(36)
|
|
124
|
+
ok(got[got.length - 1] == 36, 'after catch-up the subscriber is LIVE on the same line')
|
|
125
|
+
ok(ascendingUnique(seqs), `no gap, no dup at the handover boundary: ${seqs.join(',')}`)
|
|
126
|
+
sub()
|
|
127
|
+
push(37)
|
|
128
|
+
ok(got[got.length - 1] == 36, 'off() disconnects the live part too')
|
|
129
|
+
arch.close()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log('\n[history] rewind to a moment, then play forward into live (no jump)')
|
|
133
|
+
{
|
|
134
|
+
const storage = createMemoryReplayStorage<[number]>()
|
|
135
|
+
// getSince линии смотрит в ЭТОТ ЖЕ архив → зазор архив→live закрыт по построению
|
|
136
|
+
const {replay, arch, push} = makeCounterLine(storage, 10, true)
|
|
137
|
+
for (let n = 1; n <= 35; n++) push(n)
|
|
138
|
+
|
|
139
|
+
const got: number[] = []
|
|
140
|
+
const seqs: number[] = []
|
|
141
|
+
const h = openHistory(storage, replay)
|
|
142
|
+
h.subscribe(n => got.push(n), {ts: 1000 + 1000 * 25, onSeq: s => seqs.push(s)})
|
|
143
|
+
ok(got[0] == 20 && got.includes(25), 'rewind: nearest keyframe <= ts, deltas up to ts')
|
|
144
|
+
ok(json(got.slice(got.indexOf(25))) == json([25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35]),
|
|
145
|
+
'played CONTINUOUSLY from ts to the head — no keyframe jump')
|
|
146
|
+
push(36)
|
|
147
|
+
ok(got[got.length - 1] == 36, '…and seamlessly into live')
|
|
148
|
+
ok(ascendingUnique(seqs), `seqs strictly ascending through both handovers: ${seqs.join(',')}`)
|
|
149
|
+
arch.close()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log('\n[history] store time machine: snapshot at any seq/ts from the patch archive')
|
|
153
|
+
{
|
|
154
|
+
let clock = 0
|
|
155
|
+
const backend = createStore<World>({units: {a: {hp: 100, x: 0}}, tick: 0}, {drain: 'micro'})
|
|
156
|
+
const exposed = exposeStoreReplay(backend, {history: 8, now: () => clock})
|
|
157
|
+
const storage = createMemoryReplayStorage<[StorePatch]>()
|
|
158
|
+
const arch = archiveReplay(exposed.replay, {storage, everyEvents: 7})
|
|
159
|
+
|
|
160
|
+
const snapAt: Record<number, string> = {}
|
|
161
|
+
const seqAt: Record<number, number> = {}
|
|
162
|
+
for (let i = 1; i <= 30; i++) {
|
|
163
|
+
clock = i * 1000
|
|
164
|
+
backend.state.tick = i
|
|
165
|
+
if (i % 10 == 0) backend.state.units['a'].hp = 100 - i
|
|
166
|
+
await flushReactive(backend.state)
|
|
167
|
+
snapAt[i] = json(backend.snapshot())
|
|
168
|
+
seqAt[i] = exposed.replay.head()
|
|
169
|
+
}
|
|
170
|
+
ok(json(storeReplayAt<World>(storage, {seq: seqAt[11]})) == snapAt[11], 'state at seq — bit-exact, though the live ring holds only 8')
|
|
171
|
+
ok(json(storeReplayAt<World>(storage, {ts: 23 * 1000})) == snapAt[23], 'state at ts («what did the world look like at 12:00»)')
|
|
172
|
+
ok(json(storeReplayAt<World>(storage)) == snapAt[30], 'no target = latest archived state')
|
|
173
|
+
|
|
174
|
+
// зеркало целиком из архива + живой хвост
|
|
175
|
+
const mirror = createStore<World>({units: {}, tick: -1})
|
|
176
|
+
const h = openHistory(storage, exposed.replay)
|
|
177
|
+
h.subscribe(function applyToMirror(patch) { applyStorePatch(mirror, patch) })
|
|
178
|
+
ok(json(mirror.state) == json(backend.snapshot()), 'mirror rebuilt from the archive matches the backend')
|
|
179
|
+
backend.state.tick = 31
|
|
180
|
+
await flushReactive(backend.state)
|
|
181
|
+
ok(mirror.state.tick == 31, 'and keeps following live')
|
|
182
|
+
arch.close()
|
|
183
|
+
exposed.close()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log('\n[history] file-backed storage: the lambda interface is enough')
|
|
187
|
+
{
|
|
188
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'replay-history-'))
|
|
189
|
+
const evFile = path.join(dir, 'events.jsonl')
|
|
190
|
+
const kfFile = path.join(dir, 'keyframes.jsonl')
|
|
191
|
+
const readAll = (file: string): ReplayEvent<[number]>[] =>
|
|
192
|
+
fs.existsSync(file) ? fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)) : []
|
|
193
|
+
// наивный jsonl-архив: append на запись, полный скан на чтение — для оракула достаточно
|
|
194
|
+
const fileStorage: ReplayStorage<[number]> = {
|
|
195
|
+
putEvent: ev => fs.appendFileSync(evFile, JSON.stringify(ev) + '\n'),
|
|
196
|
+
putKeyframe: kf => fs.appendFileSync(kfFile, JSON.stringify(kf) + '\n'),
|
|
197
|
+
getKeyframe: (at = {}) => {
|
|
198
|
+
const all = readAll(kfFile)
|
|
199
|
+
const fits = all.filter(kf => at.ts != null ? kf.ts <= at.ts! : kf.seq <= (at.seq ?? Infinity))
|
|
200
|
+
return fits[fits.length - 1]
|
|
201
|
+
},
|
|
202
|
+
getEvents: (from, to) => readAll(evFile).filter(ev => ev.seq > from && ev.seq <= to),
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const {arch, push} = makeCounterLine(fileStorage, 5)
|
|
206
|
+
for (let n = 1; n <= 12; n++) push(n)
|
|
207
|
+
const h = openHistory(fileStorage)
|
|
208
|
+
ok(lastValue(h.at({seq: 8})) == 8, 'seek works over a file the same as over memory')
|
|
209
|
+
const got: number[] = []
|
|
210
|
+
h.subscribe(n => got.push(n), {since: 3})
|
|
211
|
+
ok(json(got) == json([4, 5, 6, 7, 8, 9, 10, 11, 12]), 'playback works over a file the same as over memory')
|
|
212
|
+
arch.close()
|
|
213
|
+
} finally {
|
|
214
|
+
fs.rmSync(dir, {recursive: true, force: true})
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
219
|
+
if (fails) process.exit(1)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/media-socket.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Media over socket smoke oracle:
|
|
5
|
+
// - fixed binary header + payload for audio/video frames
|
|
6
|
+
// - createAudioSource/createVideoSource are plain binary Listens
|
|
7
|
+
// - frames cross real Socket.IO/RPC as native binary, not a {0:...} object
|
|
8
|
+
// - browser capture absence returns typed state in Node, not throw
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
import express from 'express'
|
|
12
|
+
import {createServer} from 'http'
|
|
13
|
+
import type {AddressInfo} from 'net'
|
|
14
|
+
import {Server as SocketIOServer} from 'socket.io'
|
|
15
|
+
import {io} from 'socket.io-client'
|
|
16
|
+
import {
|
|
17
|
+
MEDIA_FRAME_HEADER_BYTES,
|
|
18
|
+
createAudioSource,
|
|
19
|
+
createVideoSource,
|
|
20
|
+
decodeMediaFrame,
|
|
21
|
+
encodeMediaFrame,
|
|
22
|
+
} from '../src/Common/media/media-index'
|
|
23
|
+
import {listen as createListenPair} from '../src/Common/events/Listen'
|
|
24
|
+
import {createRpcClientHub} from '../src/Common/rcp/rpc-clientHub'
|
|
25
|
+
import {createRpcServerAuto} from '../src/Common/rcp/rpc-server-auto'
|
|
26
|
+
|
|
27
|
+
let fails = 0
|
|
28
|
+
const ok = (condition: any, message: string) => {
|
|
29
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
30
|
+
else console.log(' OK ', message)
|
|
31
|
+
}
|
|
32
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
33
|
+
|
|
34
|
+
function sameBytes(a: Uint8Array, b: Uint8Array) {
|
|
35
|
+
return a.byteLength == b.byteLength
|
|
36
|
+
&& Buffer.compare(Buffer.from(a.buffer, a.byteOffset, a.byteLength), Buffer.from(b.buffer, b.byteOffset, b.byteLength)) == 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function asBytes(v: any): Uint8Array {
|
|
40
|
+
if (v instanceof Uint8Array) return v
|
|
41
|
+
if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
|
|
42
|
+
return new Uint8Array(v)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function startRealServer(object: object) {
|
|
46
|
+
const app = express()
|
|
47
|
+
const httpServer = createServer(app)
|
|
48
|
+
const ioServer = new SocketIOServer(httpServer, {maxHttpBufferSize: 1e8})
|
|
49
|
+
ioServer.on('connection', socket => {
|
|
50
|
+
const [disconnect, disconnectListen] = createListenPair<[]>()
|
|
51
|
+
socket.on('disconnect', () => disconnect())
|
|
52
|
+
createRpcServerAuto({
|
|
53
|
+
socket: {
|
|
54
|
+
emit: (key, data) => socket.emit(key, data),
|
|
55
|
+
on: (key, cb) => socket.on(key, cb),
|
|
56
|
+
},
|
|
57
|
+
socketKey: 'media',
|
|
58
|
+
object,
|
|
59
|
+
disconnectListen,
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
await new Promise<void>((resolve, reject) => {
|
|
63
|
+
httpServer.once('error', reject)
|
|
64
|
+
httpServer.listen(0, '127.0.0.1', resolve)
|
|
65
|
+
})
|
|
66
|
+
return {
|
|
67
|
+
port: (httpServer.address() as AddressInfo).port,
|
|
68
|
+
close: () => new Promise<void>(resolve => {
|
|
69
|
+
ioServer.close()
|
|
70
|
+
httpServer.close(() => resolve())
|
|
71
|
+
}),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function connectClient<T extends object>(port: number) {
|
|
76
|
+
const hub = createRpcClientHub(
|
|
77
|
+
() => io(`http://127.0.0.1:${port}`, {transports: ['websocket'], forceNew: true}),
|
|
78
|
+
r => ({api: r<T>('media')}) as const,
|
|
79
|
+
)
|
|
80
|
+
const clients = await hub.setToken(null)
|
|
81
|
+
await clients.api.readyStrict()
|
|
82
|
+
return {client: clients.api, close: () => hub.socket?.disconnect?.()}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitFor(label: string, cond: () => boolean) {
|
|
86
|
+
for (let i = 0; i < 100; i++) {
|
|
87
|
+
if (cond()) return
|
|
88
|
+
await delay(20)
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`timeout: ${label}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function main() {
|
|
94
|
+
console.log('\n[media] fixed binary frames over a real Socket.IO wire')
|
|
95
|
+
|
|
96
|
+
const pcm = new Int16Array([0, 1000, -1000, 32767, -32768])
|
|
97
|
+
const audioFrame = encodeMediaFrame({
|
|
98
|
+
kind: 'audio-pcm',
|
|
99
|
+
codec: 'pcm16',
|
|
100
|
+
seq: 7,
|
|
101
|
+
tMono: 123.5,
|
|
102
|
+
sampleRate: 16000,
|
|
103
|
+
channels: 1,
|
|
104
|
+
nSamples: pcm.length,
|
|
105
|
+
}, new Uint8Array(pcm.buffer))
|
|
106
|
+
const decodedAudio = decodeMediaFrame(audioFrame)
|
|
107
|
+
ok(audioFrame.byteLength == MEDIA_FRAME_HEADER_BYTES + pcm.byteLength, 'audio frame is fixed header + raw PCM payload')
|
|
108
|
+
ok(decodedAudio.kind == 'audio-pcm' && decodedAudio.codec == 'pcm16', 'audio kind/codec decode from binary header')
|
|
109
|
+
ok(decodedAudio.sampleRate == 16000 && decodedAudio.nSamples == pcm.length, 'audio sample metadata decode from binary header')
|
|
110
|
+
ok(sameBytes(decodedAudio.payload, new Uint8Array(pcm.buffer)), 'audio payload stays byte-for-byte')
|
|
111
|
+
|
|
112
|
+
const jpg = new Uint8Array([0xff, 0xd8, 1, 2, 3, 0xff, 0xd9])
|
|
113
|
+
const videoFrame = encodeMediaFrame({
|
|
114
|
+
kind: 'video-frame',
|
|
115
|
+
codec: 'jpeg',
|
|
116
|
+
seq: 2,
|
|
117
|
+
tMono: 200,
|
|
118
|
+
width: 320,
|
|
119
|
+
height: 240,
|
|
120
|
+
}, jpg)
|
|
121
|
+
const decodedVideo = decodeMediaFrame(videoFrame)
|
|
122
|
+
ok(decodedVideo.width == 320 && decodedVideo.height == 240, 'video dimensions decode from binary header')
|
|
123
|
+
ok(sameBytes(decodedVideo.payload, jpg), 'video payload stays byte-for-byte')
|
|
124
|
+
|
|
125
|
+
const audio = createAudioSource({sourceId: 'mic'})
|
|
126
|
+
const video = createVideoSource({sourceId: 'cam'})
|
|
127
|
+
const replayVideo = createVideoSource({sourceId: 'cam-replay', replay: true})
|
|
128
|
+
ok(audio.kind == 'audio' && video.kind == 'video', 'media factories return control + [emit, listen] tuple')
|
|
129
|
+
ok(!!(replayVideo[1] as any).line && typeof (replayVideo[1] as any).frame == 'function', 'media replay:true returns replay-listen surface for RPC auto exposure')
|
|
130
|
+
ok(await audio.start() == 'no-device', 'Node without getUserMedia returns typed no-device state, not throw')
|
|
131
|
+
ok(await video.start() == 'no-device', 'video source also returns typed no-device state without browser globals')
|
|
132
|
+
|
|
133
|
+
const [emitAudio, audioListen] = audio
|
|
134
|
+
const server = await startRealServer({audio: audioListen})
|
|
135
|
+
const client = await connectClient<{audio: typeof audioListen}>(server.port)
|
|
136
|
+
try {
|
|
137
|
+
const got: Uint8Array[] = []
|
|
138
|
+
const deep = client.client.func as any
|
|
139
|
+
deep.audio.callback((frame: any) => got.push(asBytes(frame)))
|
|
140
|
+
await delay(100)
|
|
141
|
+
emitAudio(audioFrame)
|
|
142
|
+
await waitFor('binary media frame over rpc', () => got.length == 1)
|
|
143
|
+
const overWire = decodeMediaFrame(got[0])
|
|
144
|
+
ok(ArrayBuffer.isView(got[0]), 'RPC delivered media frame as native binary view')
|
|
145
|
+
ok(sameBytes(overWire.payload, decodedAudio.payload), 'RPC media payload survived byte-for-byte')
|
|
146
|
+
ok(overWire.seq == 7 && overWire.sampleRate == 16000, 'RPC media header survived byte-for-byte')
|
|
147
|
+
} finally {
|
|
148
|
+
client.close()
|
|
149
|
+
await delay(20)
|
|
150
|
+
await server.close()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
154
|
+
if (fails) process.exit(1)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
main().catch(e => { console.error(e); process.exit(1) })
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// replay/media-view.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Media viewer helpers oracle (Node, no browser):
|
|
5
|
+
// - attachAudioPlayer: PCM decode, sequential playhead, live
|
|
6
|
+
// backlog drop, enable/disable, non-audio frames ignored
|
|
7
|
+
// - attachVideoCanvas: injected bitmap decoder, sizing follows
|
|
8
|
+
// frames, busy-skip keeps latest, stats
|
|
9
|
+
// - pipeMediaPublish: sentAt stamp, onError on rejection
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
import {listen} from '../src/Common/events/Listen'
|
|
13
|
+
import {
|
|
14
|
+
attachAudioPlayer,
|
|
15
|
+
attachVideoCanvas,
|
|
16
|
+
encodeMediaFrame,
|
|
17
|
+
pipeMediaPublish,
|
|
18
|
+
} from '../src/Common/media/media-index'
|
|
19
|
+
|
|
20
|
+
let fails = 0
|
|
21
|
+
const ok = (condition: any, message: string) => {
|
|
22
|
+
if (!condition) { fails++; console.log(' FAIL', message) }
|
|
23
|
+
else console.log(' OK ', message)
|
|
24
|
+
}
|
|
25
|
+
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms))
|
|
26
|
+
|
|
27
|
+
function pcmFrame(seq: number, nSamples = 480, sampleRate = 48000) {
|
|
28
|
+
const pcm = new Int16Array(nSamples)
|
|
29
|
+
for (let i = 0; i < nSamples; i++) pcm[i] = (i % 2 ? 1 : -1) * 1000
|
|
30
|
+
return encodeMediaFrame({kind: 'audio-pcm', codec: 'pcm16', seq, tMono: seq, sampleRate, channels: 1, nSamples}, new Uint8Array(pcm.buffer))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function videoFrame(seq: number, width: number, height: number) {
|
|
34
|
+
return encodeMediaFrame({kind: 'video-frame', codec: 'jpeg', seq, tMono: seq, width, height}, new Uint8Array([1, 2, 3]))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createFakeAudioContext() {
|
|
38
|
+
const scheduled: {at: number, frames: number}[] = []
|
|
39
|
+
const ctx = {
|
|
40
|
+
currentTime: 0,
|
|
41
|
+
destination: {},
|
|
42
|
+
resumed: 0,
|
|
43
|
+
closed: 0,
|
|
44
|
+
resume() { ctx.resumed++ },
|
|
45
|
+
close() { ctx.closed++ },
|
|
46
|
+
createBuffer: (_ch: number, frames: number, _rate: number) => ({
|
|
47
|
+
frames,
|
|
48
|
+
getChannelData: () => new Float32Array(frames),
|
|
49
|
+
}),
|
|
50
|
+
createBufferSource: () => {
|
|
51
|
+
const node: any = {connect: () => {}, start: (at: number) => scheduled.push({at, frames: node.buffer.frames})}
|
|
52
|
+
return node
|
|
53
|
+
},
|
|
54
|
+
scheduled,
|
|
55
|
+
}
|
|
56
|
+
return ctx
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
console.log('\n[media-view] audio player: sequential playhead + live backlog drop')
|
|
61
|
+
{
|
|
62
|
+
const [emit, line] = listen<[Uint8Array, number]>()
|
|
63
|
+
const ctx = createFakeAudioContext()
|
|
64
|
+
const player = attachAudioPlayer(line as any, {audioContext: () => ctx as any})
|
|
65
|
+
|
|
66
|
+
emit(pcmFrame(1), Date.now())
|
|
67
|
+
ok(player.stats().frames == 1 && player.stats().played == 0, 'disabled player counts frames but schedules nothing')
|
|
68
|
+
|
|
69
|
+
player.enable()
|
|
70
|
+
ok(player.enabled && ctx.resumed == 1, 'enable() builds the injected context and resumes it')
|
|
71
|
+
|
|
72
|
+
emit(pcmFrame(2), Date.now() - 40)
|
|
73
|
+
ok(player.stats().played == 1 && ctx.scheduled.length == 1, 'enabled player schedules a decoded pcm16 buffer')
|
|
74
|
+
ok(ctx.scheduled[0].frames == 480, 'sample count survives decode (480 frames)')
|
|
75
|
+
ok(player.stats().ageMs >= 40, 'sentAt stamp turns into a real age measurement')
|
|
76
|
+
|
|
77
|
+
const before = ctx.scheduled.length
|
|
78
|
+
emit(videoFrame(3, 320, 240), Date.now())
|
|
79
|
+
ok(ctx.scheduled.length == before && player.stats().frames == 3, 'video frames on an audio player are counted, not scheduled')
|
|
80
|
+
|
|
81
|
+
// 40 x 10ms chunks pushed instantly = 0.4s of queued audio while currentTime stays 0
|
|
82
|
+
for (let i = 0; i < 40; i++) emit(pcmFrame(10 + i), Date.now())
|
|
83
|
+
ok(player.stats().dropped > 0, 'backlog past maxBacklogSec is dropped (live policy), not queued forever')
|
|
84
|
+
const last = ctx.scheduled[ctx.scheduled.length - 1]
|
|
85
|
+
ok(last.at <= 0.45, `playhead rebased near "now" after the drop (last at=${last.at.toFixed(2)}s)`)
|
|
86
|
+
|
|
87
|
+
player.disable()
|
|
88
|
+
ok(!player.enabled && ctx.closed == 1, 'disable() closes the context')
|
|
89
|
+
player.off()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\n[media-view] video canvas: sizing follows frames, busy-skip keeps latest')
|
|
93
|
+
{
|
|
94
|
+
const [emit, line] = listen<[Uint8Array, number]>()
|
|
95
|
+
const drawnImages: any[] = []
|
|
96
|
+
const canvas: any = {
|
|
97
|
+
width: 0,
|
|
98
|
+
height: 0,
|
|
99
|
+
getContext: () => ({drawImage: (img: any) => drawnImages.push(img)}),
|
|
100
|
+
}
|
|
101
|
+
let decodeDelay = 0
|
|
102
|
+
const view = attachVideoCanvas(line as any, canvas, {
|
|
103
|
+
createBitmap: async blob => {
|
|
104
|
+
if (decodeDelay) await delay(decodeDelay)
|
|
105
|
+
return {size: blob.size, close: () => {}}
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
emit(videoFrame(1, 640, 480), Date.now() - 25)
|
|
110
|
+
await delay(10)
|
|
111
|
+
ok(view.stats().drawn == 1 && drawnImages.length == 1, 'video frame decoded via injected bitmap factory and drawn')
|
|
112
|
+
ok(canvas.width == 640 && canvas.height == 480, 'canvas takes the frame size')
|
|
113
|
+
ok(view.stats().width == 640 && view.stats().ageMs >= 25, 'stats expose frame size and age')
|
|
114
|
+
|
|
115
|
+
emit(pcmFrame(2), Date.now())
|
|
116
|
+
await delay(10)
|
|
117
|
+
ok(view.stats().drawn == 1 && view.stats().frames == 2, 'audio frame on a video canvas is counted, not drawn')
|
|
118
|
+
|
|
119
|
+
decodeDelay = 30
|
|
120
|
+
emit(videoFrame(3, 1280, 720), Date.now())
|
|
121
|
+
emit(videoFrame(4, 1280, 720), Date.now()) // arrives while frame 3 decodes -> skipped
|
|
122
|
+
await delay(60)
|
|
123
|
+
ok(view.stats().frames == 4 && view.stats().drawn == 2, 'busy-skip: overlapping frame dropped, not queued')
|
|
124
|
+
ok(canvas.width == 1280 && canvas.height == 720, 'canvas resized to the new resolution')
|
|
125
|
+
|
|
126
|
+
view.off()
|
|
127
|
+
emit(videoFrame(5, 320, 240), Date.now())
|
|
128
|
+
await delay(10)
|
|
129
|
+
ok(view.stats().frames == 4, 'off() unsubscribes the line')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log('\n[media-view] publish pipe: stamp + onError')
|
|
133
|
+
{
|
|
134
|
+
const [emit, line] = listen<[Uint8Array]>()
|
|
135
|
+
const sent: {frame: Uint8Array, sentAt?: number}[] = []
|
|
136
|
+
const errors: unknown[] = []
|
|
137
|
+
const off = pipeMediaPublish(line as any, (frame, sentAt) => {
|
|
138
|
+
sent.push({frame, sentAt})
|
|
139
|
+
return frame.byteLength == 0 ? Promise.reject(new Error('boom')) : Promise.resolve(true)
|
|
140
|
+
}, {onError: e => errors.push(e)})
|
|
141
|
+
|
|
142
|
+
emit(pcmFrame(1))
|
|
143
|
+
ok(sent.length == 1 && typeof sent[0].sentAt == 'number', 'frames are published with a wall-clock stamp by default')
|
|
144
|
+
|
|
145
|
+
emit(new Uint8Array(0))
|
|
146
|
+
await delay(10)
|
|
147
|
+
ok(errors.length == 1, 'publish rejection lands in onError, capture loop survives')
|
|
148
|
+
|
|
149
|
+
off()
|
|
150
|
+
emit(pcmFrame(2))
|
|
151
|
+
ok(sent.length == 2, 'off() stops publishing')
|
|
152
|
+
|
|
153
|
+
const noStamp: (number | undefined)[] = []
|
|
154
|
+
const [emit2, line2] = listen<[Uint8Array]>()
|
|
155
|
+
pipeMediaPublish(line2 as any, (_f, sentAt) => { noStamp.push(sentAt) }, {stamp: false})
|
|
156
|
+
emit2(pcmFrame(3))
|
|
157
|
+
ok(noStamp.length == 1 && noStamp[0] == undefined, 'stamp:false publishes without a timestamp')
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(fails ? `\n${fails} FAILED` : '\nall passed')
|
|
161
|
+
if (fails) process.exit(1)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch(e => { console.error(e); process.exit(1) })
|