@uniai-fe/uds-templates 0.10.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-templates",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -67,17 +67,17 @@
67
67
  "react-hook-form": "^7.81.0",
68
68
  "sass": "^1.101.0",
69
69
  "typescript": "6.0.3",
70
- "@uniai-fe/eslint-config": "0.4.1",
71
70
  "@uniai-fe/next-devkit": "0.4.0",
71
+ "@uniai-fe/eslint-config": "0.4.1",
72
72
  "@uniai-fe/tsconfig": "0.2.0",
73
73
  "@uniai-fe/react-hooks": "0.3.0",
74
- "@uniai-fe/uds-foundation": "0.5.0",
74
+ "@uniai-fe/util-api": "0.2.1",
75
75
  "@uniai-fe/uds-primitives": "0.10.0",
76
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
- "@uniai-fe/util-rtc": "0.2.0",
80
- "@uniai-fe/util-jotai": "0.3.0"
77
+ "@uniai-fe/util-jotai": "0.3.0",
78
+ "@uniai-fe/util-rtc": "0.2.1",
79
+ "@uniai-fe/uds-foundation": "0.5.0",
80
+ "@uniai-fe/util-next": "0.5.0"
81
81
  },
82
82
  "scripts": {
83
83
  "check:pre-commit": "pnpm --dir ../../.. run check:pre-commit",
@@ -8,6 +8,7 @@ import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
8
8
  * @property {boolean} isStreaming WHEP 시작 promise 진행 여부
9
9
  * @property {MediaStream | null} stream 현재 수신 중인 MediaStream
10
10
  * @property {string | null} streamError 마지막 WHEP 시작 오류 메시지
11
+ * @desc 외부 subscriber에 노출하는 immutable RTC stream 상태 계약
11
12
  */
12
13
  interface CctvRtcStreamSnapshot {
13
14
  /**
@@ -35,7 +36,10 @@ interface CctvRtcStreamSnapshot {
35
36
  * @property {WhepStreamHandle | null} handle 연결 완료 뒤 PeerConnection cleanup handle
36
37
  * @property {string} identityKey camera와 endpoint 기준 stream family key
37
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
38
41
  * @property {string} streamKey token generation까지 포함한 stream key
42
+ * @desc Provider scope에서 단일 stream lifecycle과 replacement rollback resource를 소유하는 내부 entry 계약
39
43
  */
40
44
  interface CctvRtcStreamEntry extends CctvRtcStreamSnapshot {
41
45
  /**
@@ -58,6 +62,14 @@ interface CctvRtcStreamEntry extends CctvRtcStreamSnapshot {
58
62
  * entry snapshot 구독자 집합
59
63
  */
60
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>;
61
73
  /**
62
74
  * token generation까지 포함한 stream key
63
75
  */
@@ -71,6 +83,7 @@ interface CctvRtcStreamEntry extends CctvRtcStreamSnapshot {
71
83
  * @property {string} streamKey token generation까지 포함한 stream key
72
84
  * @property {string} token WHEP Bearer token
73
85
  * @property {HTMLVideoElement} video 최초 stream attach 대상 video
86
+ * @desc registry가 token generation별 WHEP lifecycle을 시작하는 입력 계약
74
87
  */
75
88
  interface CctvRtcStreamStartParams {
76
89
  /**
@@ -167,8 +180,11 @@ const closeEntry = (entry: CctvRtcStreamEntry) => {
167
180
  entry.isStreaming = false;
168
181
 
169
182
  entry.attachedVideos.forEach(video => {
170
- clearVideoIfAttachedStream(video, entry.stream);
183
+ // committed 전 replacement도 현재 entry가 video 붙인 resource로 보고 선택적으로 제거한다.
184
+ clearVideoIfAttachedStream(video, entry.pendingStream ?? entry.stream);
171
185
  });
186
+ entry.pendingStream = null;
187
+ entry.previousVideoSources.clear();
172
188
 
173
189
  // 마지막 closed snapshot을 전달한 뒤 entry가 외부 객체를 붙잡지 않도록 집합을 비운다.
174
190
  notifyEntry(entry);
@@ -184,6 +200,7 @@ const closeEntry = (entry: CctvRtcStreamEntry) => {
184
200
  * @property {(streamKey: string) => CctvRtcStreamSnapshot} getSnapshot stream snapshot 조회
185
201
  * @property {(params: CctvRtcStreamStartParams) => void} start streamKey 단위 WHEP 시작
186
202
  * @property {(streamKey: string, listener: (snapshot: CctvRtcStreamSnapshot) => void) => () => void} subscribe stream snapshot 구독
203
+ * @desc Provider scope에서 WHEP stream 시작, 연결, 구독과 cleanup을 관리하는 공개 registry 계약
187
204
  */
188
205
  export interface CctvRtcStreamRegistry {
189
206
  /**
@@ -228,12 +245,7 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
228
245
  // Map은 token generation이 포함된 streamKey마다 현재 lifecycle owner를 하나만 유지한다.
229
246
  const entries = new Map<string, CctvRtcStreamEntry>();
230
247
 
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
- */
248
+ // entry resource를 정리한 뒤 streamKey의 Map owner를 제거한다.
237
249
  const deleteEntry = (entry: CctvRtcStreamEntry) => {
238
250
  closeEntry(entry);
239
251
  entries.delete(entry.streamKey);
@@ -253,6 +265,7 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
253
265
  if (!entry) {
254
266
  // 아직 시작되지 않은 key는 stale srcObject 없이 빈 video 상태로 정규화한다.
255
267
  attachVideo(video, null);
268
+ // 소유할 registry entry가 없으므로 Map mutation 없이 현재 video만 비우는 cleanup을 반환한다.
256
269
  return () => {
257
270
  if (video.srcObject) video.srcObject = null;
258
271
  };
@@ -260,13 +273,21 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
260
273
 
261
274
  // 기존 MediaStream이 있으면 즉시 reattach하고 pending entry면 현재 화면을 보존한다.
262
275
  entry.attachedVideos.add(video);
263
- if (entry.stream || !video.srcObject) {
264
- attachVideo(video, entry.stream);
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);
265
284
  }
266
285
 
267
286
  return () => {
287
+ // detach된 video는 이후 실패 복원과 entry cleanup의 소유 대상에서 제외한다.
268
288
  entry.attachedVideos.delete(video);
269
- clearVideoIfAttachedStream(video, entry.stream);
289
+ entry.previousVideoSources.delete(video);
290
+ clearVideoIfAttachedStream(video, entry.pendingStream ?? entry.stream);
270
291
  };
271
292
  },
272
293
  /**
@@ -311,7 +332,6 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
311
332
  /**
312
333
  * CCTV Stream Registry Util; streamKey 단위 WHEP 연결 시작
313
334
  * @util
314
- * @param {CctvRtcStreamStartParams} params WHEP 시작 파라미터
315
335
  * @property {string} params.endpoint token-specific WHEP 요청 endpoint
316
336
  * @property {string} params.identityKey camera와 endpoint 기준 stream family key
317
337
  * @property {string} params.streamKey token generation까지 포함한 stream key
@@ -336,18 +356,16 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
336
356
  identityKey,
337
357
  isStreaming: true,
338
358
  listeners: new Set(),
359
+ pendingStream: null,
360
+ // 최초 video가 이미 표시하던 connected stream을 replacement 실패 rollback 값으로 보존한다.
361
+ previousVideoSources: new Map([[video, video.srcObject]]),
339
362
  stream: null,
340
363
  streamError: null,
341
364
  streamKey,
342
365
  };
343
366
 
344
367
  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
- */
368
+ // Map owner 일치와 AbortSignal 상태로 현재 entry의 lifecycle 소유권을 확인한다.
351
369
  const isCurrentEntry = () =>
352
370
  entries.get(streamKey) === entry && !controller.signal.aborted;
353
371
 
@@ -365,51 +383,69 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
365
383
  video,
366
384
  signal: controller.signal,
367
385
  onConnectionStateChange: state => {
368
- // Provider cleanup 또는 같은 key owner 교체 뒤 늦은 state event는 반영하지 않는다.
369
- if (!isCurrentEntry()) return;
386
+ if (!isCurrentEntry()) {
387
+ // Provider cleanup 또는 같은 key owner 교체 뒤 늦은 state event는 반영하지 않는다.
388
+ return;
389
+ }
370
390
 
371
391
  entry.connectionState = state;
372
392
  notifyEntry(entry);
373
393
  },
374
394
  onTrack: event => {
375
- // 닫힌 entry의 late track이 video와 snapshot을 되살리지 않게 막는다.
376
- if (!isCurrentEntry()) return;
395
+ if (!isCurrentEntry()) {
396
+ // 닫힌 entry의 late track이 video와 snapshot을 되살리지 않게 막는다.
397
+ return;
398
+ }
377
399
 
378
- entry.stream = event.streams[0] ?? null;
400
+ // track만으로 성공을 확정하지 않고 handle 반환 전까지 pending resource로 유지한다.
401
+ entry.pendingStream = event.streams[0] ?? null;
379
402
  entry.attachedVideos.forEach(attachedVideo =>
380
- attachVideo(attachedVideo, entry.stream),
403
+ attachVideo(attachedVideo, entry.pendingStream),
381
404
  );
382
- notifyEntry(entry);
383
405
  },
384
406
  })
385
407
  .then(handle => {
386
408
  // pending 중 close된 start가 늦게 resolve되면 새 handle을 즉시 회수한다.
387
409
  if (!isCurrentEntry()) {
388
410
  handle.close();
411
+ // 회수한 stale handle을 현재 entry의 성공 상태로 다시 기록하지 않는다.
389
412
  return;
390
413
  }
391
414
 
392
415
  entry.handle = handle;
393
416
  entry.isStreaming = false;
417
+ // remote SDP까지 완료된 handle만 committed snapshot으로 승격해 이전 generation을 닫게 한다.
418
+ entry.stream = entry.pendingStream;
419
+ entry.pendingStream = null;
394
420
  notifyEntry(entry);
421
+ entry.previousVideoSources.clear();
395
422
  })
396
423
  .catch(error => {
397
- // abort/delete된 이전 owner의 rejection은 현재 entry 상태를 덮어쓰지 않는다.
398
- if (entries.get(streamKey) !== entry) return;
424
+ if (entries.get(streamKey) !== entry) {
425
+ // abort/delete된 이전 owner의 rejection은 현재 entry 상태를 덮어쓰지 않는다.
426
+ return;
427
+ }
399
428
 
400
429
  // 실패 원인이 어느 negotiation 단계이든 util-rtc 내부 PeerConnection cleanup을 요청한다.
401
430
  entry.controller?.abort();
402
431
  entry.controller = null;
403
432
  entry.handle = null;
404
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;
405
443
  entry.stream = null;
444
+ entry.previousVideoSources.clear();
406
445
  entry.streamError =
407
446
  error instanceof Error
408
447
  ? error.message
409
448
  : "스트림 연결에 실패했습니다.";
410
- entry.attachedVideos.forEach(attachedVideo =>
411
- clearVideoIfAttachedStream(attachedVideo, entry.stream),
412
- );
413
449
  // subscriber에는 terminal error를 전달하되 실패 entry는 다음 명시적 start를 막지 않는다.
414
450
  notifyEntry(entry);
415
451
  entries.delete(streamKey);
@@ -425,7 +461,10 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
425
461
  */
426
462
  subscribe(streamKey, listener) {
427
463
  const entry = entries.get(streamKey);
428
- if (!entry) return () => undefined;
464
+ if (!entry) {
465
+ // 구독할 lifecycle owner가 없으므로 listener를 저장하지 않고 no-op cleanup을 반환한다.
466
+ return () => undefined;
467
+ }
429
468
 
430
469
  // 구독 직후 현재 snapshot을 전달해 registry reattach의 첫 render 지연을 줄인다.
431
470
  entry.listeners.add(listener);