@uniai-fe/uds-templates 0.10.2 → 0.10.4
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/package.json +10 -10
- package/src/cctv/hooks/streamRegistry.ts +280 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniai-fe/uds-templates",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "UNIAI Design System; UI Templates Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -56,27 +56,27 @@
|
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@svgr/webpack": "^8.1.0",
|
|
59
|
-
"@tanstack/react-query": "^5.101.
|
|
60
|
-
"@types/node": "^24.
|
|
59
|
+
"@tanstack/react-query": "^5.101.2",
|
|
60
|
+
"@types/node": "^24.13.2",
|
|
61
61
|
"@types/react": "^19.2.17",
|
|
62
62
|
"@types/react-dom": "^19.2.3",
|
|
63
|
-
"eslint": "^9.39.
|
|
64
|
-
"jotai": "^2.20.
|
|
63
|
+
"eslint": "^9.39.4",
|
|
64
|
+
"jotai": "^2.20.2",
|
|
65
65
|
"next": "16.2.10",
|
|
66
|
-
"prettier": "^3.8.
|
|
67
|
-
"react-hook-form": "^7.
|
|
66
|
+
"prettier": "^3.8.5",
|
|
67
|
+
"react-hook-form": "^7.81.0",
|
|
68
68
|
"sass": "^1.101.0",
|
|
69
69
|
"typescript": "6.0.3",
|
|
70
70
|
"@uniai-fe/next-devkit": "0.4.0",
|
|
71
71
|
"@uniai-fe/eslint-config": "0.4.1",
|
|
72
|
-
"@uniai-fe/util-api": "0.2.1",
|
|
73
72
|
"@uniai-fe/tsconfig": "0.2.0",
|
|
74
73
|
"@uniai-fe/react-hooks": "0.3.0",
|
|
74
|
+
"@uniai-fe/util-api": "0.2.1",
|
|
75
|
+
"@uniai-fe/uds-primitives": "0.10.0",
|
|
75
76
|
"@uniai-fe/util-functions": "0.4.3",
|
|
76
77
|
"@uniai-fe/util-jotai": "0.3.0",
|
|
78
|
+
"@uniai-fe/util-rtc": "0.2.1",
|
|
77
79
|
"@uniai-fe/uds-foundation": "0.5.0",
|
|
78
|
-
"@uniai-fe/uds-primitives": "0.10.0",
|
|
79
|
-
"@uniai-fe/util-rtc": "0.2.0",
|
|
80
80
|
"@uniai-fe/util-next": "0.5.0"
|
|
81
81
|
},
|
|
82
82
|
"scripts": {
|
|
@@ -2,30 +2,119 @@
|
|
|
2
2
|
|
|
3
3
|
import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* CCTV Stream Registry Types; 외부 구독 snapshot
|
|
7
|
+
* @property {RTCPeerConnectionState} connectionState PeerConnection 연결 상태
|
|
8
|
+
* @property {boolean} isStreaming WHEP 시작 promise 진행 여부
|
|
9
|
+
* @property {MediaStream | null} stream 현재 수신 중인 MediaStream
|
|
10
|
+
* @property {string | null} streamError 마지막 WHEP 시작 오류 메시지
|
|
11
|
+
* @desc 외부 subscriber에 노출하는 immutable RTC stream 상태 계약
|
|
12
|
+
*/
|
|
5
13
|
interface CctvRtcStreamSnapshot {
|
|
14
|
+
/**
|
|
15
|
+
* PeerConnection 연결 상태
|
|
16
|
+
*/
|
|
6
17
|
connectionState: RTCPeerConnectionState;
|
|
18
|
+
/**
|
|
19
|
+
* WHEP 시작 promise 진행 여부
|
|
20
|
+
*/
|
|
7
21
|
isStreaming: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* 현재 수신 중인 MediaStream
|
|
24
|
+
*/
|
|
8
25
|
stream: MediaStream | null;
|
|
26
|
+
/**
|
|
27
|
+
* 마지막 WHEP 시작 오류 메시지
|
|
28
|
+
*/
|
|
9
29
|
streamError: string | null;
|
|
10
30
|
}
|
|
11
31
|
|
|
32
|
+
/**
|
|
33
|
+
* CCTV Stream Registry Types; Provider scope stream entry
|
|
34
|
+
* @property {Set<HTMLVideoElement>} attachedVideos 같은 stream을 표시하는 video 집합
|
|
35
|
+
* @property {AbortController | null} controller 진행 중이거나 연결된 WHEP cleanup controller
|
|
36
|
+
* @property {WhepStreamHandle | null} handle 연결 완료 뒤 PeerConnection cleanup handle
|
|
37
|
+
* @property {string} identityKey camera와 endpoint 기준 stream family key
|
|
38
|
+
* @property {Set<(snapshot: CctvRtcStreamSnapshot) => void>} listeners snapshot 구독자 집합
|
|
39
|
+
* @property {MediaStream | null} pendingStream handle 반환 전 수신한 replacement stream
|
|
40
|
+
* @property {Map<HTMLVideoElement, MediaProvider | null>} previousVideoSources replacement 실패 시 복원할 video source
|
|
41
|
+
* @property {string} streamKey token generation까지 포함한 stream key
|
|
42
|
+
* @desc Provider scope에서 단일 stream lifecycle과 replacement rollback resource를 소유하는 내부 entry 계약
|
|
43
|
+
*/
|
|
12
44
|
interface CctvRtcStreamEntry extends CctvRtcStreamSnapshot {
|
|
45
|
+
/**
|
|
46
|
+
* 같은 stream을 표시하는 video element 집합
|
|
47
|
+
*/
|
|
13
48
|
attachedVideos: Set<HTMLVideoElement>;
|
|
49
|
+
/**
|
|
50
|
+
* 진행 중이거나 연결된 WHEP를 중단하는 controller
|
|
51
|
+
*/
|
|
14
52
|
controller: AbortController | null;
|
|
53
|
+
/**
|
|
54
|
+
* 연결 완료 뒤 PeerConnection을 닫는 handle
|
|
55
|
+
*/
|
|
15
56
|
handle: WhepStreamHandle | null;
|
|
57
|
+
/**
|
|
58
|
+
* camera와 endpoint 기준 stream family key
|
|
59
|
+
*/
|
|
16
60
|
identityKey: string;
|
|
61
|
+
/**
|
|
62
|
+
* entry snapshot 구독자 집합
|
|
63
|
+
*/
|
|
17
64
|
listeners: Set<(snapshot: CctvRtcStreamSnapshot) => void>;
|
|
65
|
+
/**
|
|
66
|
+
* handle 반환 전 수신한 replacement MediaStream
|
|
67
|
+
*/
|
|
68
|
+
pendingStream: MediaStream | null;
|
|
69
|
+
/**
|
|
70
|
+
* replacement 실패 시 복원할 video별 기존 source
|
|
71
|
+
*/
|
|
72
|
+
previousVideoSources: Map<HTMLVideoElement, MediaProvider | null>;
|
|
73
|
+
/**
|
|
74
|
+
* token generation까지 포함한 stream key
|
|
75
|
+
*/
|
|
18
76
|
streamKey: string;
|
|
19
77
|
}
|
|
20
78
|
|
|
79
|
+
/**
|
|
80
|
+
* CCTV Stream Registry Types; RTC stream 시작 파라미터
|
|
81
|
+
* @property {string} endpoint token-specific WHEP 요청 endpoint
|
|
82
|
+
* @property {string} identityKey camera와 endpoint 기준 stream family key
|
|
83
|
+
* @property {string} streamKey token generation까지 포함한 stream key
|
|
84
|
+
* @property {string} token WHEP Bearer token
|
|
85
|
+
* @property {HTMLVideoElement} video 최초 stream attach 대상 video
|
|
86
|
+
* @desc registry가 token generation별 WHEP lifecycle을 시작하는 입력 계약
|
|
87
|
+
*/
|
|
21
88
|
interface CctvRtcStreamStartParams {
|
|
89
|
+
/**
|
|
90
|
+
* token-specific WHEP 요청 endpoint
|
|
91
|
+
*/
|
|
22
92
|
endpoint: string;
|
|
93
|
+
/**
|
|
94
|
+
* camera와 endpoint 기준 stream family key
|
|
95
|
+
*/
|
|
23
96
|
identityKey: string;
|
|
97
|
+
/**
|
|
98
|
+
* token generation까지 포함한 stream key
|
|
99
|
+
*/
|
|
24
100
|
streamKey: string;
|
|
101
|
+
/**
|
|
102
|
+
* WHEP Bearer token
|
|
103
|
+
*/
|
|
25
104
|
token: string;
|
|
105
|
+
/**
|
|
106
|
+
* 최초 stream attach 대상 video element
|
|
107
|
+
*/
|
|
26
108
|
video: HTMLVideoElement;
|
|
27
109
|
}
|
|
28
110
|
|
|
111
|
+
/**
|
|
112
|
+
* CCTV Stream Registry Util; 외부 구독용 immutable snapshot 생성
|
|
113
|
+
* @util
|
|
114
|
+
* @param {CctvRtcStreamEntry} [entry] snapshot을 만들 registry entry
|
|
115
|
+
* @desc Map에 entry가 없을 때는 새 연결 전 기본 상태를 반환한다.
|
|
116
|
+
* @return {CctvRtcStreamSnapshot} 현재 entry 상태 또는 기본 idle 상태
|
|
117
|
+
*/
|
|
29
118
|
const getEntrySnapshot = (
|
|
30
119
|
entry?: CctvRtcStreamEntry,
|
|
31
120
|
): CctvRtcStreamSnapshot => ({
|
|
@@ -35,12 +124,26 @@ const getEntrySnapshot = (
|
|
|
35
124
|
streamError: entry?.streamError ?? null,
|
|
36
125
|
});
|
|
37
126
|
|
|
127
|
+
/**
|
|
128
|
+
* CCTV Stream Registry Util; video element에 RTC stream 연결
|
|
129
|
+
* @util
|
|
130
|
+
* @param {HTMLVideoElement} video stream attach 대상 video
|
|
131
|
+
* @param {MediaStream | null} stream 표시할 MediaStream 또는 초기화용 null
|
|
132
|
+
* @desc inline autoplay 속성과 srcObject를 함께 적용한다.
|
|
133
|
+
*/
|
|
38
134
|
const attachVideo = (video: HTMLVideoElement, stream: MediaStream | null) => {
|
|
39
135
|
video.playsInline = true;
|
|
40
136
|
video.autoplay = true;
|
|
41
137
|
video.srcObject = stream;
|
|
42
138
|
};
|
|
43
139
|
|
|
140
|
+
/**
|
|
141
|
+
* CCTV Stream Registry Util; 지정 stream의 video 연결 해제
|
|
142
|
+
* @util
|
|
143
|
+
* @param {HTMLVideoElement} video cleanup 대상 video
|
|
144
|
+
* @param {MediaStream | null} stream 제거 대상 MediaStream
|
|
145
|
+
* @desc replacement stream이 이미 붙은 video를 이전 entry cleanup이 비우지 않게 한다.
|
|
146
|
+
*/
|
|
44
147
|
const clearVideoIfAttachedStream = (
|
|
45
148
|
video: HTMLVideoElement,
|
|
46
149
|
stream: MediaStream | null,
|
|
@@ -50,12 +153,25 @@ const clearVideoIfAttachedStream = (
|
|
|
50
153
|
}
|
|
51
154
|
};
|
|
52
155
|
|
|
156
|
+
/**
|
|
157
|
+
* CCTV Stream Registry Util; entry snapshot 구독자 통지
|
|
158
|
+
* @util
|
|
159
|
+
* @param {CctvRtcStreamEntry} entry 변경된 registry entry
|
|
160
|
+
* @desc 현재 entry의 immutable snapshot을 생성해 등록된 listener에 전달한다.
|
|
161
|
+
*/
|
|
53
162
|
const notifyEntry = (entry: CctvRtcStreamEntry) => {
|
|
54
163
|
const snapshot = getEntrySnapshot(entry);
|
|
55
164
|
entry.listeners.forEach(listener => listener(snapshot));
|
|
56
165
|
};
|
|
57
166
|
|
|
167
|
+
/**
|
|
168
|
+
* CCTV Stream Registry Util; 단일 registry entry resource 정리
|
|
169
|
+
* @util
|
|
170
|
+
* @param {CctvRtcStreamEntry} entry 종료할 registry entry
|
|
171
|
+
* @desc AbortController와 WHEP handle cleanup은 각 구현의 idempotent 계약을 재사용한다.
|
|
172
|
+
*/
|
|
58
173
|
const closeEntry = (entry: CctvRtcStreamEntry) => {
|
|
174
|
+
// pending WHEP와 연결 완료 handle을 모두 닫되 다음 close가 중복 실행되지 않게 참조를 비운다.
|
|
59
175
|
entry.controller?.abort();
|
|
60
176
|
entry.controller = null;
|
|
61
177
|
entry.handle?.close();
|
|
@@ -64,59 +180,135 @@ const closeEntry = (entry: CctvRtcStreamEntry) => {
|
|
|
64
180
|
entry.isStreaming = false;
|
|
65
181
|
|
|
66
182
|
entry.attachedVideos.forEach(video => {
|
|
67
|
-
|
|
183
|
+
// committed 전 replacement도 현재 entry가 video에 붙인 resource로 보고 선택적으로 제거한다.
|
|
184
|
+
clearVideoIfAttachedStream(video, entry.pendingStream ?? entry.stream);
|
|
68
185
|
});
|
|
186
|
+
entry.pendingStream = null;
|
|
187
|
+
entry.previousVideoSources.clear();
|
|
69
188
|
|
|
189
|
+
// 마지막 closed snapshot을 전달한 뒤 entry가 외부 객체를 붙잡지 않도록 집합을 비운다.
|
|
70
190
|
notifyEntry(entry);
|
|
71
191
|
entry.attachedVideos.clear();
|
|
72
192
|
entry.listeners.clear();
|
|
73
193
|
};
|
|
74
194
|
|
|
195
|
+
/**
|
|
196
|
+
* CCTV Stream Registry Types; Provider scope registry 계약
|
|
197
|
+
* @property {(streamKey: string, video: HTMLVideoElement) => () => void} attach video attach와 detach cleanup 등록
|
|
198
|
+
* @property {() => void} closeAll Provider scope의 모든 stream 종료
|
|
199
|
+
* @property {(identityKey: string, exceptStreamKey?: string) => void} closeByIdentity 같은 camera family의 stream 종료
|
|
200
|
+
* @property {(streamKey: string) => CctvRtcStreamSnapshot} getSnapshot stream snapshot 조회
|
|
201
|
+
* @property {(params: CctvRtcStreamStartParams) => void} start streamKey 단위 WHEP 시작
|
|
202
|
+
* @property {(streamKey: string, listener: (snapshot: CctvRtcStreamSnapshot) => void) => () => void} subscribe stream snapshot 구독
|
|
203
|
+
* @desc Provider scope에서 WHEP stream 시작, 연결, 구독과 cleanup을 관리하는 공개 registry 계약
|
|
204
|
+
*/
|
|
75
205
|
export interface CctvRtcStreamRegistry {
|
|
206
|
+
/**
|
|
207
|
+
* video attach와 detach cleanup 등록
|
|
208
|
+
*/
|
|
76
209
|
attach: (streamKey: string, video: HTMLVideoElement) => () => void;
|
|
210
|
+
/**
|
|
211
|
+
* Provider scope의 모든 stream 종료
|
|
212
|
+
*/
|
|
77
213
|
closeAll: () => void;
|
|
214
|
+
/**
|
|
215
|
+
* 같은 camera family의 stream을 optional 예외 key 외에는 모두 종료
|
|
216
|
+
*/
|
|
78
217
|
closeByIdentity: (identityKey: string, exceptStreamKey?: string) => void;
|
|
218
|
+
/**
|
|
219
|
+
* streamKey의 현재 외부 구독 snapshot 조회
|
|
220
|
+
*/
|
|
79
221
|
getSnapshot: (streamKey: string) => CctvRtcStreamSnapshot;
|
|
222
|
+
/**
|
|
223
|
+
* streamKey 단위 WHEP 시작
|
|
224
|
+
*/
|
|
80
225
|
start: (params: CctvRtcStreamStartParams) => void;
|
|
226
|
+
/**
|
|
227
|
+
* streamKey snapshot 구독과 unsubscribe cleanup 등록
|
|
228
|
+
*/
|
|
81
229
|
subscribe: (
|
|
82
230
|
streamKey: string,
|
|
83
231
|
listener: (snapshot: CctvRtcStreamSnapshot) => void,
|
|
84
232
|
) => () => void;
|
|
85
233
|
}
|
|
86
234
|
|
|
235
|
+
/**
|
|
236
|
+
* CCTV Stream Registry Util; Provider scope stream registry 생성
|
|
237
|
+
* @util
|
|
238
|
+
* @desc 같은 streamKey의 중복 WHEP start를 막고 camera identity별 replacement cleanup을 관리한다.
|
|
239
|
+
* @return {CctvRtcStreamRegistry} 독립된 Provider scope stream registry
|
|
240
|
+
* @example
|
|
241
|
+
* const registry = createCctvRtcStreamRegistry();
|
|
242
|
+
* registry.start({ endpoint, identityKey, streamKey, token, video });
|
|
243
|
+
*/
|
|
87
244
|
export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
245
|
+
// Map은 token generation이 포함된 streamKey마다 현재 lifecycle owner를 하나만 유지한다.
|
|
88
246
|
const entries = new Map<string, CctvRtcStreamEntry>();
|
|
89
247
|
|
|
248
|
+
// entry resource를 정리한 뒤 streamKey의 Map owner를 제거한다.
|
|
90
249
|
const deleteEntry = (entry: CctvRtcStreamEntry) => {
|
|
91
250
|
closeEntry(entry);
|
|
92
251
|
entries.delete(entry.streamKey);
|
|
93
252
|
};
|
|
94
253
|
|
|
95
254
|
return {
|
|
255
|
+
/**
|
|
256
|
+
* CCTV Stream Registry Util; streamKey entry에 video 연결
|
|
257
|
+
* @util
|
|
258
|
+
* @param {string} streamKey attach할 stream key
|
|
259
|
+
* @param {HTMLVideoElement} video attach 대상 video
|
|
260
|
+
* @desc 현재 stream을 video에 연결하고 attach 집합에 등록한다.
|
|
261
|
+
* @return {() => void} 현재 video attach만 해제하는 cleanup
|
|
262
|
+
*/
|
|
96
263
|
attach(streamKey, video) {
|
|
97
264
|
const entry = entries.get(streamKey);
|
|
98
265
|
if (!entry) {
|
|
266
|
+
// 아직 시작되지 않은 key는 stale srcObject 없이 빈 video 상태로 정규화한다.
|
|
99
267
|
attachVideo(video, null);
|
|
268
|
+
// 소유할 registry entry가 없으므로 Map mutation 없이 현재 video만 비우는 cleanup을 반환한다.
|
|
100
269
|
return () => {
|
|
101
270
|
if (video.srcObject) video.srcObject = null;
|
|
102
271
|
};
|
|
103
272
|
}
|
|
104
273
|
|
|
274
|
+
// 기존 MediaStream이 있으면 즉시 reattach하고 pending entry면 현재 화면을 보존한다.
|
|
105
275
|
entry.attachedVideos.add(video);
|
|
106
|
-
if (entry.
|
|
107
|
-
|
|
276
|
+
if (!entry.previousVideoSources.has(video)) {
|
|
277
|
+
// pending replacement 실패 시 이 attach 직전 source로 돌아갈 수 있게 최초 값만 보관한다.
|
|
278
|
+
entry.previousVideoSources.set(video, video.srcObject);
|
|
279
|
+
}
|
|
280
|
+
// handle 성공 전 track도 새 subscriber video에는 보이되 committed snapshot으로 승격하지 않는다.
|
|
281
|
+
const attachedStream = entry.stream ?? entry.pendingStream;
|
|
282
|
+
if (attachedStream || !video.srcObject) {
|
|
283
|
+
attachVideo(video, attachedStream);
|
|
108
284
|
}
|
|
109
285
|
|
|
110
286
|
return () => {
|
|
287
|
+
// detach된 video는 이후 실패 복원과 entry cleanup의 소유 대상에서 제외한다.
|
|
111
288
|
entry.attachedVideos.delete(video);
|
|
112
|
-
|
|
289
|
+
entry.previousVideoSources.delete(video);
|
|
290
|
+
clearVideoIfAttachedStream(video, entry.pendingStream ?? entry.stream);
|
|
113
291
|
};
|
|
114
292
|
},
|
|
293
|
+
/**
|
|
294
|
+
* CCTV Stream Registry Util; Provider scope의 모든 stream 정리
|
|
295
|
+
* @util
|
|
296
|
+
* @desc 모든 entry resource를 닫고 registry Map을 비운다.
|
|
297
|
+
*/
|
|
115
298
|
closeAll() {
|
|
299
|
+
// Provider unmount에서 모든 entry를 한 번 닫은 뒤 registry owner Map을 비운다.
|
|
116
300
|
entries.forEach(entry => closeEntry(entry));
|
|
117
301
|
entries.clear();
|
|
118
302
|
},
|
|
303
|
+
/**
|
|
304
|
+
* CCTV Stream Registry Util; 같은 identity의 이전 stream 정리
|
|
305
|
+
* @util
|
|
306
|
+
* @param {string} identityKey cleanup할 camera stream family key
|
|
307
|
+
* @param {string} [exceptStreamKey] 유지할 replacement stream key
|
|
308
|
+
* @desc optional 예외 key를 제외한 같은 camera family entry를 닫는다.
|
|
309
|
+
*/
|
|
119
310
|
closeByIdentity(identityKey, exceptStreamKey) {
|
|
311
|
+
// replacement track이 붙은 뒤 같은 camera family의 이전 generation만 선택적으로 닫는다.
|
|
120
312
|
entries.forEach(entry => {
|
|
121
313
|
if (
|
|
122
314
|
entry.identityKey === identityKey &&
|
|
@@ -126,15 +318,35 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
126
318
|
}
|
|
127
319
|
});
|
|
128
320
|
},
|
|
321
|
+
/**
|
|
322
|
+
* CCTV Stream Registry Util; streamKey snapshot 조회
|
|
323
|
+
* @util
|
|
324
|
+
* @param {string} streamKey 조회할 stream key
|
|
325
|
+
* @desc 내부 mutable entry를 노출하지 않고 외부 구독용 snapshot만 반환한다.
|
|
326
|
+
* @return {CctvRtcStreamSnapshot} 현재 entry 상태 또는 기본 idle 상태
|
|
327
|
+
*/
|
|
129
328
|
getSnapshot(streamKey) {
|
|
329
|
+
// 외부 consumer가 내부 mutable entry를 직접 소유하지 않도록 snapshot만 반환한다.
|
|
130
330
|
return getEntrySnapshot(entries.get(streamKey));
|
|
131
331
|
},
|
|
332
|
+
/**
|
|
333
|
+
* CCTV Stream Registry Util; streamKey 단위 WHEP 연결 시작
|
|
334
|
+
* @util
|
|
335
|
+
* @property {string} params.endpoint token-specific WHEP 요청 endpoint
|
|
336
|
+
* @property {string} params.identityKey camera와 endpoint 기준 stream family key
|
|
337
|
+
* @property {string} params.streamKey token generation까지 포함한 stream key
|
|
338
|
+
* @property {string} params.token WHEP Bearer token
|
|
339
|
+
* @property {HTMLVideoElement} params.video 최초 stream attach 대상 video element
|
|
340
|
+
* @desc 같은 streamKey의 중복 start를 막고 현재 entry의 lifecycle만 갱신한다.
|
|
341
|
+
*/
|
|
132
342
|
start({ endpoint, identityKey, streamKey, token, video }) {
|
|
133
343
|
const existingEntry = entries.get(streamKey);
|
|
134
344
|
if (existingEntry) {
|
|
345
|
+
// 같은 token generation의 concurrent attach는 기존 WHEP start 하나를 공유한다.
|
|
135
346
|
return;
|
|
136
347
|
}
|
|
137
348
|
|
|
349
|
+
// WHEP promise보다 entry를 먼저 등록해 같은 tick의 중복 start를 차단한다.
|
|
138
350
|
const controller = new AbortController();
|
|
139
351
|
const entry: CctvRtcStreamEntry = {
|
|
140
352
|
attachedVideos: new Set([video]),
|
|
@@ -144,13 +356,21 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
144
356
|
identityKey,
|
|
145
357
|
isStreaming: true,
|
|
146
358
|
listeners: new Set(),
|
|
359
|
+
pendingStream: null,
|
|
360
|
+
// 최초 video가 이미 표시하던 connected stream을 replacement 실패 rollback 값으로 보존한다.
|
|
361
|
+
previousVideoSources: new Map([[video, video.srcObject]]),
|
|
147
362
|
stream: null,
|
|
148
363
|
streamError: null,
|
|
149
364
|
streamKey,
|
|
150
365
|
};
|
|
151
366
|
|
|
152
367
|
entries.set(streamKey, entry);
|
|
368
|
+
// Map owner 일치와 AbortSignal 상태로 현재 entry의 lifecycle 소유권을 확인한다.
|
|
369
|
+
const isCurrentEntry = () =>
|
|
370
|
+
entries.get(streamKey) === entry && !controller.signal.aborted;
|
|
371
|
+
|
|
153
372
|
if (!video.srcObject) {
|
|
373
|
+
// cold start는 빈 video를 초기화하고 replacement는 기존 화면을 그대로 유지한다.
|
|
154
374
|
attachVideo(video, null);
|
|
155
375
|
} else {
|
|
156
376
|
video.playsInline = true;
|
|
@@ -163,41 +383,90 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
163
383
|
video,
|
|
164
384
|
signal: controller.signal,
|
|
165
385
|
onConnectionStateChange: state => {
|
|
386
|
+
if (!isCurrentEntry()) {
|
|
387
|
+
// Provider cleanup 또는 같은 key owner 교체 뒤 늦은 state event는 반영하지 않는다.
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
166
391
|
entry.connectionState = state;
|
|
167
392
|
notifyEntry(entry);
|
|
168
393
|
},
|
|
169
394
|
onTrack: event => {
|
|
170
|
-
|
|
395
|
+
if (!isCurrentEntry()) {
|
|
396
|
+
// 닫힌 entry의 late track이 video와 snapshot을 되살리지 않게 막는다.
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// track만으로 성공을 확정하지 않고 handle 반환 전까지 pending resource로 유지한다.
|
|
401
|
+
entry.pendingStream = event.streams[0] ?? null;
|
|
171
402
|
entry.attachedVideos.forEach(attachedVideo =>
|
|
172
|
-
attachVideo(attachedVideo, entry.
|
|
403
|
+
attachVideo(attachedVideo, entry.pendingStream),
|
|
173
404
|
);
|
|
174
|
-
notifyEntry(entry);
|
|
175
405
|
},
|
|
176
406
|
})
|
|
177
407
|
.then(handle => {
|
|
408
|
+
// pending 중 close된 start가 늦게 resolve되면 새 handle을 즉시 회수한다.
|
|
409
|
+
if (!isCurrentEntry()) {
|
|
410
|
+
handle.close();
|
|
411
|
+
// 회수한 stale handle을 현재 entry의 성공 상태로 다시 기록하지 않는다.
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
178
415
|
entry.handle = handle;
|
|
179
416
|
entry.isStreaming = false;
|
|
417
|
+
// remote SDP까지 완료된 handle만 committed snapshot으로 승격해 이전 generation을 닫게 한다.
|
|
418
|
+
entry.stream = entry.pendingStream;
|
|
419
|
+
entry.pendingStream = null;
|
|
180
420
|
notifyEntry(entry);
|
|
421
|
+
entry.previousVideoSources.clear();
|
|
181
422
|
})
|
|
182
423
|
.catch(error => {
|
|
424
|
+
if (entries.get(streamKey) !== entry) {
|
|
425
|
+
// abort/delete된 이전 owner의 rejection은 현재 entry 상태를 덮어쓰지 않는다.
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// 실패 원인이 어느 negotiation 단계이든 util-rtc 내부 PeerConnection cleanup을 요청한다.
|
|
430
|
+
entry.controller?.abort();
|
|
183
431
|
entry.controller = null;
|
|
184
432
|
entry.handle = null;
|
|
185
433
|
entry.isStreaming = false;
|
|
434
|
+
const failedStream = entry.pendingStream ?? entry.stream;
|
|
435
|
+
entry.attachedVideos.forEach(attachedVideo => {
|
|
436
|
+
// 외부에서 다른 source로 교체한 video는 건드리지 않고 이 entry의 실패 stream만 rollback한다.
|
|
437
|
+
if (failedStream && attachedVideo.srcObject === failedStream) {
|
|
438
|
+
attachedVideo.srcObject =
|
|
439
|
+
entry.previousVideoSources.get(attachedVideo) ?? null;
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
entry.pendingStream = null;
|
|
186
443
|
entry.stream = null;
|
|
444
|
+
entry.previousVideoSources.clear();
|
|
187
445
|
entry.streamError =
|
|
188
446
|
error instanceof Error
|
|
189
447
|
? error.message
|
|
190
448
|
: "스트림 연결에 실패했습니다.";
|
|
191
|
-
entry
|
|
192
|
-
clearVideoIfAttachedStream(attachedVideo, entry.stream),
|
|
193
|
-
);
|
|
449
|
+
// subscriber에는 terminal error를 전달하되 실패 entry는 다음 명시적 start를 막지 않는다.
|
|
194
450
|
notifyEntry(entry);
|
|
451
|
+
entries.delete(streamKey);
|
|
195
452
|
});
|
|
196
453
|
},
|
|
454
|
+
/**
|
|
455
|
+
* CCTV Stream Registry Util; streamKey snapshot listener 등록
|
|
456
|
+
* @util
|
|
457
|
+
* @param {string} streamKey 구독할 stream key
|
|
458
|
+
* @param {(snapshot: CctvRtcStreamSnapshot) => void} listener snapshot listener
|
|
459
|
+
* @desc 등록 직후 현재 snapshot을 전달하고 이후 상태 변경을 구독한다.
|
|
460
|
+
* @return {() => void} 현재 listener만 제거하는 unsubscribe
|
|
461
|
+
*/
|
|
197
462
|
subscribe(streamKey, listener) {
|
|
198
463
|
const entry = entries.get(streamKey);
|
|
199
|
-
if (!entry)
|
|
464
|
+
if (!entry) {
|
|
465
|
+
// 구독할 lifecycle owner가 없으므로 listener를 저장하지 않고 no-op cleanup을 반환한다.
|
|
466
|
+
return () => undefined;
|
|
467
|
+
}
|
|
200
468
|
|
|
469
|
+
// 구독 직후 현재 snapshot을 전달해 registry reattach의 첫 render 지연을 줄인다.
|
|
201
470
|
entry.listeners.add(listener);
|
|
202
471
|
listener(getEntrySnapshot(entry));
|
|
203
472
|
|