@uniai-fe/uds-templates 0.10.2 → 0.10.3

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