@fuzionx/player 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * @fuzionx/player — FuzionXPublisher (송출자 모드)
3
+ *
4
+ * 로컬 카메라/마이크 → 서버 전송.
5
+ * 2가지 방식 지원:
6
+ * A. WebSocket 방식 (videochat 양방향)
7
+ * B. WHIP 방식 (OBS/단방향 방송)
8
+ *
9
+ * @example WebSocket
10
+ * const pub = new FuzionXPublisher({
11
+ * url: 'wss://media:50002',
12
+ * channelId: 'my-live',
13
+ * mode: 'videochat',
14
+ * nickname: '발표자',
15
+ * });
16
+ * pub.on('ready', () => console.log('Publishing!'));
17
+ * pub.connect();
18
+ *
19
+ * @example WHIP
20
+ * const pub = new FuzionXPublisher({
21
+ * whipUrl: 'https://media:7777/whip/my-live',
22
+ * });
23
+ * pub.connect();
24
+ */
25
+
26
+ import { FuzionXSignaling } from './FuzionXSignaling.js';
27
+ import { DEFAULT_ICE_SERVERS, SignalType, SessionMode, MAX_SLOTS } from './constants.js';
28
+
29
+ export class FuzionXPublisher {
30
+ /**
31
+ * @param {Object} opts
32
+ * @param {string} [opts.url] - WebSocket URL (WS 방식)
33
+ * @param {string} [opts.whipUrl] - WHIP URL (WHIP 방식)
34
+ * @param {string} [opts.channelId] - 채널 ID (WS 방식 필수)
35
+ * @param {string} [opts.mode='broadcast'] - 'broadcast' | 'videochat'
36
+ * @param {string} [opts.nickname] - 닉네임
37
+ * @param {string} [opts.token] - 인증 토큰
38
+ * @param {string} [opts.peerId] - 피어 ID
39
+ * @param {MediaStreamConstraints} [opts.media] - getUserMedia 제약
40
+ * @param {MediaStream} [opts.stream] - 이미 획득한 MediaStream
41
+ * @param {RTCConfiguration} [opts.rtcConfig] - WebRTC 설정
42
+ * @param {boolean} [opts.autoReconnect=true]
43
+ */
44
+ constructor(opts) {
45
+ // WHIP or WebSocket
46
+ this.whipUrl = opts.whipUrl || null;
47
+ this.url = opts.url || null;
48
+ this.channelId = opts.channelId || null;
49
+ this.mode = opts.mode || SessionMode.BROADCAST;
50
+ this.nickname = opts.nickname || null;
51
+ this.token = opts.token || null;
52
+ this.peerId = opts.peerId || `pub-${Math.random().toString(36).slice(2, 10)}`;
53
+ this.autoReconnect = opts.autoReconnect !== false;
54
+
55
+ this.mediaConstraints = opts.media || { video: true, audio: true };
56
+ this._externalStream = opts.stream || null;
57
+
58
+ this.rtcConfig = opts.rtcConfig || {
59
+ iceServers: DEFAULT_ICE_SERVERS,
60
+ bundlePolicy: 'max-bundle',
61
+ rtcpMuxPolicy: 'require',
62
+ };
63
+
64
+ /** @private */
65
+ this._signaling = null;
66
+ this._pc = null;
67
+ this._localStream = null;
68
+ this._listeners = {};
69
+ this._candidateQueue = [];
70
+ this._whipResourceUrl = null;
71
+ this._maxSlots = MAX_SLOTS[this.mode] || 1;
72
+ this._slots = new Map();
73
+ this._connected = false;
74
+ }
75
+
76
+ // ── Event System ──
77
+
78
+ on(event, handler) {
79
+ if (!this._listeners[event]) this._listeners[event] = [];
80
+ this._listeners[event].push(handler);
81
+ return this;
82
+ }
83
+
84
+ /** @private */
85
+ _emit(event, ...args) {
86
+ (this._listeners[event] || []).forEach((fn) => fn(...args));
87
+ }
88
+
89
+ // ── Lifecycle ──
90
+
91
+ /** 연결 시작 (미디어 획득 → WebSocket 또는 WHIP). */
92
+ async connect() {
93
+ try {
94
+ await this._acquireMedia();
95
+
96
+ if (this.whipUrl) {
97
+ await this._connectWhip();
98
+ } else if (this.url) {
99
+ this._connectWebSocket();
100
+ } else {
101
+ throw new Error('url 또는 whipUrl 중 하나를 지정해야 합니다.');
102
+ }
103
+ } catch (e) {
104
+ this._emit('error', e);
105
+ }
106
+ }
107
+
108
+ /** 연결 종료. */
109
+ async disconnect() {
110
+ if (this.whipUrl && this._whipResourceUrl) {
111
+ // WHIP DELETE
112
+ try {
113
+ const baseUrl = new URL(this.whipUrl).origin;
114
+ await fetch(`${baseUrl}${this._whipResourceUrl}`, { method: 'DELETE' });
115
+ } catch (e) {
116
+ console.warn('[FuzionX] WHIP DELETE error:', e);
117
+ }
118
+ this._whipResourceUrl = null;
119
+ }
120
+
121
+ if (this._signaling) {
122
+ this._signaling.sendLeave();
123
+ this._signaling.disconnect();
124
+ }
125
+
126
+ this._closePeerConnection();
127
+ this._stopMedia();
128
+ this._connected = false;
129
+ }
130
+
131
+ /** 채팅 전송. */
132
+ chat(text) {
133
+ if (this._signaling) {
134
+ this._signaling.sendChat(text, this.nickname);
135
+ }
136
+ }
137
+
138
+ /** @returns {MediaStream|null} 로컬 스트림 */
139
+ get localStream() {
140
+ return this._localStream;
141
+ }
142
+
143
+ /** @returns {Map} 슬롯 정보 (videochat: 다른 참가자) */
144
+ get slots() {
145
+ return this._slots;
146
+ }
147
+
148
+ // ── Media ──
149
+
150
+ /** @private 미디어 획득 */
151
+ async _acquireMedia() {
152
+ if (this._externalStream) {
153
+ this._localStream = this._externalStream;
154
+ } else {
155
+ this._localStream = await navigator.mediaDevices.getUserMedia(this.mediaConstraints);
156
+ }
157
+ this._emit('media', this._localStream);
158
+ }
159
+
160
+ /** @private */
161
+ _stopMedia() {
162
+ if (this._localStream && !this._externalStream) {
163
+ this._localStream.getTracks().forEach((t) => t.stop());
164
+ }
165
+ this._localStream = null;
166
+ }
167
+
168
+ // ── WHIP ──
169
+
170
+ /** @private WHIP 연결 */
171
+ async _connectWhip() {
172
+ this._pc = new RTCPeerConnection(this.rtcConfig);
173
+
174
+ // 트랙 추가
175
+ this._localStream.getTracks().forEach((track) => {
176
+ this._pc.addTrack(track, this._localStream);
177
+ });
178
+
179
+ // ICE Gathering 완료 대기
180
+ const offer = await this._pc.createOffer();
181
+ offer.sdp = FuzionXPublisher._forceCodecs(offer.sdp);
182
+ await this._pc.setLocalDescription(offer);
183
+
184
+ // Gathering 완료 대기
185
+ await new Promise((resolve) => {
186
+ if (this._pc.iceGatheringState === 'complete') {
187
+ resolve();
188
+ } else {
189
+ this._pc.onicegatheringstatechange = () => {
190
+ if (this._pc.iceGatheringState === 'complete') resolve();
191
+ };
192
+ // 안전 타임아웃
193
+ setTimeout(resolve, 3000);
194
+ }
195
+ });
196
+
197
+ const localDesc = this._pc.localDescription;
198
+ // Hub API의 whip_url은 이미 ?token=xxx 포함 가능
199
+ let url = this.whipUrl;
200
+ if (this.token && !url.includes('token=')) {
201
+ url += (url.includes('?') ? '&' : '?') + `token=${this.token}`;
202
+ }
203
+
204
+ const response = await fetch(url, {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/sdp' },
207
+ body: localDesc.sdp,
208
+ });
209
+
210
+ if (response.status !== 201) {
211
+ throw new Error(`WHIP failed: ${response.status} ${await response.text()}`);
212
+ }
213
+
214
+ const answerSdp = await response.text();
215
+ this._whipResourceUrl = response.headers.get('location');
216
+
217
+ await this._pc.setRemoteDescription(
218
+ new RTCSessionDescription({ type: 'answer', sdp: answerSdp })
219
+ );
220
+
221
+ this._pc.onconnectionstatechange = () => {
222
+ const state = this._pc?.connectionState;
223
+ if (state === 'connected') {
224
+ this._connected = true;
225
+ this._emit('ready');
226
+ } else if (state === 'failed' || state === 'disconnected') {
227
+ this._emit('error', new Error(`WHIP PeerConnection ${state}`));
228
+ }
229
+ };
230
+
231
+ this._emit('ready');
232
+ }
233
+
234
+ // ── WebSocket ──
235
+
236
+ /** @private WS 연결 */
237
+ _connectWebSocket() {
238
+ this._signaling = new FuzionXSignaling({
239
+ url: this.url,
240
+ autoReconnect: this.autoReconnect,
241
+ onOpen: () => this._onSignalingOpen(),
242
+ onMessage: (msg) => this._onSignalingMessage(msg),
243
+ onClose: (evt) => {
244
+ this._closePeerConnection();
245
+ this._connected = false;
246
+ this._emit('close', evt);
247
+ },
248
+ onError: (err) => this._emit('error', err),
249
+ });
250
+ this._signaling.connect();
251
+ }
252
+
253
+ /** @private */
254
+ async _onSignalingOpen() {
255
+ this._signaling.sendJoin(this.peerId, this.channelId, {
256
+ nickname: this.nickname,
257
+ token: this.token,
258
+ mode: this.mode,
259
+ });
260
+ await this._createPeerConnection();
261
+ }
262
+
263
+ /** @private */
264
+ async _createPeerConnection() {
265
+ this._pc = new RTCPeerConnection(this.rtcConfig);
266
+
267
+ // Non-Trickle: ICE candidate는 SDP에 포함
268
+
269
+ // 로컬 트랙 추가 (송출)
270
+ this._localStream.getTracks().forEach((track) => {
271
+ this._pc.addTrack(track, this._localStream);
272
+ });
273
+
274
+ // videochat 모드: 수신 트랙도 준비 (양방향)
275
+ if (this.mode === SessionMode.VIDEOCHAT) {
276
+ for (let i = 0; i < this._maxSlots; i++) {
277
+ this._pc.addTransceiver('video', { direction: 'recvonly' });
278
+ this._pc.addTransceiver('audio', { direction: 'recvonly' });
279
+ }
280
+
281
+ // 송출 트랙은 addTrack으로 추가됨 → transceiver방향을 sendrecv로 업그레이드
282
+ const transceivers = this._pc.getTransceivers();
283
+ transceivers.forEach((t) => {
284
+ if (t.sender.track && t.direction === 'recvonly') {
285
+ t.direction = 'sendrecv';
286
+ }
287
+ });
288
+
289
+ let videoTrackCount = 0;
290
+ this._pc.ontrack = (event) => {
291
+ const track = event.track;
292
+ if (track.kind === 'video') {
293
+ const slotIndex = videoTrackCount++;
294
+ let stream = event.streams[0];
295
+ if (!stream) {
296
+ stream = new MediaStream();
297
+ stream.addTrack(track);
298
+ }
299
+ const slot = this._slots.get(slotIndex) || { slotIndex };
300
+ slot.stream = stream;
301
+ this._slots.set(slotIndex, slot);
302
+ this._emit('stream', stream, slotIndex);
303
+ }
304
+ };
305
+ }
306
+
307
+ this._pc.onconnectionstatechange = () => {
308
+ const state = this._pc?.connectionState;
309
+ if (state === 'connected' && !this._connected) {
310
+ this._connected = true;
311
+ this._emit('ready');
312
+ } else if (state === 'failed' || state === 'disconnected') {
313
+ this._emit('error', new Error(`PeerConnection ${state}`));
314
+ }
315
+ };
316
+
317
+ // Offer + H264/Opus SDP 강제
318
+ const offer = await this._pc.createOffer();
319
+ offer.sdp = FuzionXPublisher._forceCodecs(offer.sdp);
320
+ await this._pc.setLocalDescription(offer);
321
+
322
+ // Non-Trickle: ICE gathering 완료 대기
323
+ await this._waitForIceGathering();
324
+
325
+ const finalSdp = this._pc.localDescription?.sdp;
326
+ if (finalSdp) {
327
+ this._signaling.sendOffer(finalSdp);
328
+ }
329
+ }
330
+
331
+ /** @private */
332
+ _waitForIceGathering() {
333
+ return new Promise((resolve) => {
334
+ if (this._pc.iceGatheringState === 'complete') {
335
+ return resolve();
336
+ }
337
+ const check = () => {
338
+ if (this._pc?.iceGatheringState === 'complete') {
339
+ this._pc.removeEventListener('icegatheringstatechange', check);
340
+ resolve();
341
+ }
342
+ };
343
+ this._pc.addEventListener('icegatheringstatechange', check);
344
+ setTimeout(() => {
345
+ if (this._pc) {
346
+ this._pc.removeEventListener('icegatheringstatechange', check);
347
+ }
348
+ resolve();
349
+ }, 3000);
350
+ });
351
+ }
352
+
353
+ /** @private */
354
+ static _forceCodecs(sdp) {
355
+ let lines = sdp.split('\r\n');
356
+ const videoIdx = lines.findIndex((l) => l.startsWith('m=video'));
357
+ if (videoIdx !== -1) {
358
+ const h264Pts = [];
359
+ const h264Scores = new Map();
360
+ lines.forEach((l) => {
361
+ const m = l.match(/a=rtpmap:(\d+) H264\/90000/);
362
+ if (m) { h264Pts.push(m[1]); h264Scores.set(m[1], 0); }
363
+ });
364
+ lines.forEach((l) => {
365
+ if (l.startsWith('a=fmtp:')) {
366
+ const pt = l.split(' ')[0].split(':')[1];
367
+ if (h264Scores.has(pt)) {
368
+ if (l.includes('profile-level-id=42e01f')) h264Scores.set(pt, 100);
369
+ else if (l.includes('profile-level-id=42001f')) h264Scores.set(pt, 80);
370
+ if (l.includes('packetization-mode=1')) h264Scores.set(pt, (h264Scores.get(pt) || 0) + 10);
371
+ }
372
+ }
373
+ });
374
+ if (h264Pts.length > 0) {
375
+ h264Pts.sort((a, b) => h264Scores.get(b) - h264Scores.get(a));
376
+ const parts = lines[videoIdx].split(' ');
377
+ const otherPts = parts.slice(3).filter((pt) => !h264Pts.includes(pt));
378
+ lines[videoIdx] = [...parts.slice(0, 3), ...h264Pts, ...otherPts].join(' ');
379
+ }
380
+ }
381
+ const audioIdx = lines.findIndex((l) => l.startsWith('m=audio'));
382
+ if (audioIdx !== -1) {
383
+ const opusPts = [];
384
+ lines.forEach((l) => {
385
+ const m = l.match(/a=rtpmap:(\d+) opus\/48000/);
386
+ if (m) opusPts.push(m[1]);
387
+ });
388
+ if (opusPts.length > 0) {
389
+ const parts = lines[audioIdx].split(' ');
390
+ const otherPts = parts.slice(3).filter((pt) => !opusPts.includes(pt));
391
+ lines[audioIdx] = [...parts.slice(0, 3), ...opusPts, ...otherPts].join(' ');
392
+ }
393
+ }
394
+ return lines.join('\r\n');
395
+ }
396
+
397
+ /** @private */
398
+ _onSignalingMessage(msg) {
399
+ switch (msg.type) {
400
+ case SignalType.ANSWER:
401
+ this._handleAnswer(msg);
402
+ break;
403
+ case SignalType.CANDIDATE:
404
+ this._handleCandidate(msg);
405
+ break;
406
+ case SignalType.SLOT_INFO:
407
+ this._handleSlotInfo(msg);
408
+ break;
409
+ case SignalType.CHAT:
410
+ this._emit('chat', {
411
+ peerId: msg.peer_id,
412
+ nickname: msg.nickname,
413
+ text: msg.text,
414
+ });
415
+ break;
416
+ case SignalType.ERROR:
417
+ this._emit('error', new Error(msg.message));
418
+ break;
419
+ }
420
+ }
421
+
422
+ /** @private */
423
+ async _handleAnswer(msg) {
424
+ if (!this._pc) return;
425
+ try {
426
+ await this._pc.setRemoteDescription(
427
+ new RTCSessionDescription({ type: 'answer', sdp: msg.sdp })
428
+ );
429
+ for (const c of this._candidateQueue) {
430
+ await this._pc.addIceCandidate(c);
431
+ }
432
+ this._candidateQueue = [];
433
+ } catch (e) {
434
+ this._emit('error', e);
435
+ }
436
+ }
437
+
438
+ /** @private */
439
+ async _handleCandidate(msg) {
440
+ const candidate = new RTCIceCandidate({
441
+ candidate: msg.candidate,
442
+ sdpMid: msg.sdp_mid,
443
+ sdpMLineIndex: msg.sdp_m_line_index,
444
+ });
445
+ if (this._pc && this._pc.remoteDescription) {
446
+ try { await this._pc.addIceCandidate(candidate); } catch (e) { /* ignore */ }
447
+ } else {
448
+ this._candidateQueue.push(candidate);
449
+ }
450
+ }
451
+
452
+ /** @private */
453
+ _handleSlotInfo(msg) {
454
+ const slotIndex = parseInt(msg.stream_id.replace('stream_', ''), 10);
455
+ if (!msg.nickname || msg.nickname === '') {
456
+ this._slots.delete(slotIndex);
457
+ this._emit('slot_remove', { slotIndex, senderId: msg.sender_id });
458
+ return;
459
+ }
460
+ const existing = this._slots.get(slotIndex) || {};
461
+ this._slots.set(slotIndex, {
462
+ ...existing,
463
+ slotIndex,
464
+ streamId: msg.stream_id,
465
+ nickname: msg.nickname,
466
+ senderId: msg.sender_id,
467
+ });
468
+ this._emit('slot', this._slots.get(slotIndex));
469
+ }
470
+
471
+ /** @private */
472
+ _closePeerConnection() {
473
+ if (this._pc) {
474
+ this._pc.close();
475
+ this._pc = null;
476
+ }
477
+ this._candidateQueue = [];
478
+ }
479
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @fuzionx/player — WebSocket Signaling Layer
3
+ *
4
+ * FuzionX Media Server의 JSON 시그널링 프로토콜 캡슐화.
5
+ * 자동 재연결 + 이벤트 시스템.
6
+ */
7
+
8
+ import { SignalType, RECONNECT } from './constants.js';
9
+
10
+ export class FuzionXSignaling {
11
+ /**
12
+ * @param {Object} opts
13
+ * @param {string} opts.url - WebSocket URL (ws:// or wss://)
14
+ * @param {Function} [opts.onMessage] - 메시지 수신 콜백
15
+ * @param {Function} [opts.onOpen] - 연결 성공 콜백
16
+ * @param {Function} [opts.onClose] - 연결 종료 콜백
17
+ * @param {Function} [opts.onError] - 에러 콜백
18
+ * @param {boolean} [opts.autoReconnect=true]
19
+ */
20
+ constructor(opts) {
21
+ this.url = opts.url;
22
+ this.onMessage = opts.onMessage || (() => {});
23
+ this.onOpen = opts.onOpen || (() => {});
24
+ this.onClose = opts.onClose || (() => {});
25
+ this.onError = opts.onError || (() => {});
26
+ this.autoReconnect = opts.autoReconnect !== false;
27
+
28
+ /** @private */
29
+ this._ws = null;
30
+ this._retryCount = 0;
31
+ this._reconnectTimer = null;
32
+ this._intentionalClose = false;
33
+ }
34
+
35
+ /** WebSocket 연결. */
36
+ connect() {
37
+ this._intentionalClose = false;
38
+ this._doConnect();
39
+ }
40
+
41
+ /** @private */
42
+ _doConnect() {
43
+ try {
44
+ this._ws = new WebSocket(this.url);
45
+ } catch (e) {
46
+ this.onError(e);
47
+ this._scheduleReconnect();
48
+ return;
49
+ }
50
+
51
+ this._ws.onopen = () => {
52
+ this._retryCount = 0;
53
+ this.onOpen();
54
+ };
55
+
56
+ this._ws.onmessage = (event) => {
57
+ try {
58
+ const msg = JSON.parse(event.data);
59
+ this.onMessage(msg);
60
+ } catch (e) {
61
+ console.warn('[FuzionX] Invalid JSON:', event.data);
62
+ }
63
+ };
64
+
65
+ this._ws.onclose = (event) => {
66
+ this.onClose(event);
67
+ if (!this._intentionalClose && this.autoReconnect) {
68
+ this._scheduleReconnect();
69
+ }
70
+ };
71
+
72
+ this._ws.onerror = (event) => {
73
+ this.onError(event);
74
+ };
75
+ }
76
+
77
+ /** JSON 메시지 전송. */
78
+ send(msg) {
79
+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
80
+ this._ws.send(JSON.stringify(msg));
81
+ return true;
82
+ }
83
+ return false;
84
+ }
85
+
86
+ // ── 시그널링 헬퍼 ──
87
+
88
+ /** Join 전송 */
89
+ sendJoin(peerId, channelId, opts = {}) {
90
+ return this.send({
91
+ type: SignalType.JOIN,
92
+ peer_id: peerId,
93
+ channel_id: channelId,
94
+ nickname: opts.nickname || null,
95
+ token: opts.token || null,
96
+ mode: opts.mode || null,
97
+ });
98
+ }
99
+
100
+ /** SDP Offer 전송 */
101
+ sendOffer(sdp) {
102
+ return this.send({ type: SignalType.OFFER, sdp });
103
+ }
104
+
105
+ /** SDP Answer 전송 */
106
+ sendAnswer(sdp) {
107
+ return this.send({ type: SignalType.ANSWER, sdp });
108
+ }
109
+
110
+ /** ICE Candidate 전송 */
111
+ sendCandidate(candidate) {
112
+ return this.send({
113
+ type: SignalType.CANDIDATE,
114
+ candidate: candidate.candidate,
115
+ sdp_mid: candidate.sdpMid,
116
+ sdp_m_line_index: candidate.sdpMLineIndex,
117
+ });
118
+ }
119
+
120
+ /** Chat 전송 */
121
+ sendChat(text, nickname) {
122
+ return this.send({
123
+ type: SignalType.CHAT,
124
+ text,
125
+ nickname: nickname || null,
126
+ peer_id: null,
127
+ });
128
+ }
129
+
130
+ /** PLI 요청 */
131
+ sendPLI() {
132
+ return this.send({ type: SignalType.PLI });
133
+ }
134
+
135
+ /** Leave 전송 */
136
+ sendLeave() {
137
+ return this.send({ type: SignalType.LEAVE });
138
+ }
139
+
140
+ /** 연결 종료 (재연결 안 함). */
141
+ disconnect() {
142
+ this._intentionalClose = true;
143
+ clearTimeout(this._reconnectTimer);
144
+ if (this._ws) {
145
+ this._ws.close();
146
+ this._ws = null;
147
+ }
148
+ }
149
+
150
+ /** @returns {boolean} 연결 상태 */
151
+ get connected() {
152
+ return this._ws && this._ws.readyState === WebSocket.OPEN;
153
+ }
154
+
155
+ /** @private 재연결 스케줄 (Exponential Backoff). */
156
+ _scheduleReconnect() {
157
+ if (this._retryCount >= RECONNECT.MAX_RETRIES) {
158
+ console.error('[FuzionX] Max reconnect retries reached.');
159
+ this.onError(new Error('Max reconnect retries'));
160
+ return;
161
+ }
162
+ const delay = Math.min(
163
+ RECONNECT.BASE_DELAY_MS * Math.pow(2, this._retryCount),
164
+ RECONNECT.MAX_DELAY_MS
165
+ );
166
+ this._retryCount++;
167
+ console.log(`[FuzionX] Reconnecting in ${delay}ms (${this._retryCount}/${RECONNECT.MAX_RETRIES})`);
168
+ this._reconnectTimer = setTimeout(() => this._doConnect(), delay);
169
+ }
170
+ }