@uniai-fe/uds-templates 0.6.26 → 0.6.27

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.
@@ -12,8 +12,10 @@ import {
12
12
  import {
13
13
  useCctvApiUrl,
14
14
  useCctvRtcStreamRegistry,
15
+ useCctvRtcStreamScheduler,
15
16
  } from "../components/Provider";
16
17
  import type {
18
+ CctvRtcStreamStage,
17
19
  CctvRtcReconnectTrigger,
18
20
  UseCctvRtcStreamParams,
19
21
  UseCctvRtcStreamReturn,
@@ -21,6 +23,7 @@ import type {
21
23
  import { cctvRtcLiveRegistryAtom } from "../jotai/rtc";
22
24
  import { getIsLive } from "../utils/video-state";
23
25
  import { useFormContext, useWatch } from "react-hook-form";
26
+ import { useCctvRtcStreamScheduleStatus } from "./streamScheduler";
24
27
 
25
28
  const AUTO_RECONNECT_INTERVAL_MS = 3000;
26
29
  const POST_CONNECTED_RECONNECT_GRACE_MS = 5000;
@@ -77,9 +80,19 @@ const getPostConnectedReconnectReason = ({
77
80
  return null;
78
81
  };
79
82
 
83
+ /**
84
+ * CCTV; base64url 문자열 decode
85
+ * @param {string} value base64url 인코딩 문자열
86
+ * @returns {string | null} decode된 문자열 또는 실패 시 null
87
+ * @desc
88
+ * JWT payload exp 확인용이며 실패를 throw하지 않아 token fallback age 판정으로 이어지게 한다.
89
+ */
80
90
  const decodeBase64Url = (value: string): string | null => {
81
91
  try {
92
+ // JWT payload는 base64url이라 브라우저 atob가 읽을 수 있는 base64 문자로 치환한다.
82
93
  const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
94
+
95
+ // base64 길이가 4의 배수가 아니면 padding을 더해 decode 실패 가능성을 줄인다.
83
96
  const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
84
97
 
85
98
  return atob(padded);
@@ -88,14 +101,24 @@ const decodeBase64Url = (value: string): string | null => {
88
101
  }
89
102
  };
90
103
 
104
+ /**
105
+ * CCTV; JWT 만료 시각 추출
106
+ * @param {string} token RTC token 문자열
107
+ * @returns {number | null} exp 기반 만료 시각(ms) 또는 확인 불가 시 null
108
+ * @desc
109
+ * token claim 구조를 신뢰하지 않고 exp가 유효한 number일 때만 신규 연결 재사용 기준으로 쓴다.
110
+ */
91
111
  const getJwtExpiresAt = (token: string): number | null => {
112
+ // JWT는 header.payload.signature 구조이며 exp는 payload에 있다.
92
113
  const payload = token.split(".")[1];
93
114
  if (!payload) return null;
94
115
 
116
+ // payload decode 실패 시 fallback max age 기준으로 넘긴다.
95
117
  const decodedPayload = decodeBase64Url(payload);
96
118
  if (!decodedPayload) return null;
97
119
 
98
120
  try {
121
+ // payload shape는 외부 token claim이므로 unknown으로 읽고 exp만 좁힌다.
99
122
  const parsedPayload = JSON.parse(decodedPayload) as { exp?: unknown };
100
123
  const exp = parsedPayload.exp;
101
124
 
@@ -105,6 +128,13 @@ const getJwtExpiresAt = (token: string): number | null => {
105
128
  }
106
129
  };
107
130
 
131
+ /**
132
+ * CCTV; 신규 WHEP 연결에 token을 재사용할 수 있는지 판단
133
+ * @param {object} params token 판정 파라미터
134
+ * @param {number} params.dataUpdatedAt React Query token 갱신 시각(ms)
135
+ * @param {string} params.token RTC token 문자열
136
+ * @returns {boolean} 신규 연결 재사용 가능 여부
137
+ */
108
138
  const canUseTokenForNewConnection = ({
109
139
  dataUpdatedAt,
110
140
  token,
@@ -112,13 +142,16 @@ const canUseTokenForNewConnection = ({
112
142
  dataUpdatedAt: number;
113
143
  token: string;
114
144
  }): boolean => {
145
+ // JWT exp가 있으면 fallback age보다 exp claim을 우선한다.
115
146
  const expiresAt = getJwtExpiresAt(token);
116
147
  const now = Date.now();
117
148
 
118
149
  if (expiresAt) {
150
+ // 신규 WHEP 연결 중 만료되지 않도록 safety window를 둔다.
119
151
  return now + TOKEN_EXPIRY_SAFETY_MS < expiresAt;
120
152
  }
121
153
 
154
+ // exp 확인이 불가능한 token은 query 갱신 시각 기준 max age까지만 허용한다.
122
155
  return now - dataUpdatedAt <= CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS;
123
156
  };
124
157
 
@@ -149,8 +182,17 @@ export function useCctvRtcStream({
149
182
  }: UseCctvRtcStreamParams): UseCctvRtcStreamReturn {
150
183
  // Provider를 통해 주입된 기본 토큰 발급 URL을 확보한다.
151
184
  const { tokenUrl: contextTokenUrl } = useCctvApiUrl();
185
+
186
+ // registry는 WHEP connection/MediaStream을 Provider scope에서 유지한다.
152
187
  const streamRegistry = useCctvRtcStreamRegistry();
188
+
189
+ // scheduler는 list item 렌더와 token/WHEP 시작 시점을 분리한다.
190
+ const streamScheduler = useCctvRtcStreamScheduler();
191
+
192
+ // live registry는 여러 hook instance가 같은 cam의 live 상태를 공유하는 Jotai atom이다.
153
193
  const setLiveRegistry = useSetAtom(cctvRtcLiveRegistryAtom);
194
+
195
+ // 같은 cam을 여러 위치에서 렌더할 수 있어 hook instance별 key를 한 번만 만든다.
154
196
  const instanceKeyRef = useRef<string | null>(null);
155
197
  if (!instanceKeyRef.current) {
156
198
  instanceKeyRef.current = Math.random().toString(36).slice(2);
@@ -158,9 +200,17 @@ export function useCctvRtcStream({
158
200
 
159
201
  // WebRTC MediaStream을 연결할 video 요소 ref.
160
202
  const videoRef = useRef<HTMLVideoElement | null>(null);
203
+
204
+ // 현재 hook instance가 attach 중인 streamKey를 기억해 stale token/refresh 분기를 구분한다.
161
205
  const activeStreamKeyRef = useRef<string | null>(null);
206
+
207
+ // stream identity는 token dataUpdatedAt을 제외한 카메라/사용자/endpoint 기준이다.
162
208
  const activeStreamIdentityKeyRef = useRef<string | null>(null);
209
+
210
+ // focus/visibility 자동 재연결이 짧은 시간에 반복되지 않도록 마지막 호출 시각을 저장한다.
163
211
  const lastAutoReconnectAtRef = useRef(0);
212
+
213
+ // stale token refetch를 같은 streamKeyCandidate마다 한 번만 수행하기 위한 guard다.
164
214
  const staleTokenRefreshKeyRef = useRef<string | null>(null);
165
215
 
166
216
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
@@ -183,10 +233,42 @@ export function useCctvRtcStream({
183
233
  const endpointUsername =
184
234
  whepUsername === undefined ? tokenUsername : whepUsername;
185
235
 
236
+ // 카메라의 RTC 엔드포인트와 사용자명을 조합해 실제 WHEP endpoint를 계산한다.
237
+ const endpoint = useMemo(() => {
238
+ // cam_rtc가 없으면 WHEP endpoint를 만들 수 없어 이후 identity/token gate도 닫힌다.
239
+ if (!cam?.cam_rtc) return undefined;
240
+
241
+ // WHEP username override가 있으면 token username과 별개로 endpoint query에만 반영한다.
242
+ const query = endpointUsername
243
+ ? `?username=${encodeURIComponent(endpointUsername)}`
244
+ : "";
245
+
246
+ // cam_rtc 뒤의 slash 중복을 제거하고 WHEP path를 붙인다.
247
+ return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
248
+ }, [cam?.cam_rtc, endpointUsername]);
249
+
250
+ const streamIdentityKey = useMemo(() => {
251
+ // identity 필수값이 모두 있어야 scheduler와 registry identity cleanup이 가능하다.
252
+ if (!tokenUsername || !cam?.company_id || !cam.cam_id || !endpoint)
253
+ return "";
254
+
255
+ // token 갱신 시각은 제외해 같은 카메라 stream family를 하나의 identity로 묶는다.
256
+ return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
257
+ }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
258
+
259
+ // scheduler grant 전에는 token query enabled가 false라 초기 token burst가 발생하지 않는다.
260
+ const streamScheduleStatus = useCctvRtcStreamScheduleStatus({
261
+ enabled: Boolean(streamIdentityKey && cam?.cam_online),
262
+ identityKey: streamIdentityKey,
263
+ scheduler: streamScheduler,
264
+ });
265
+ const isStreamStartGranted = streamScheduleStatus === "granted";
266
+
186
267
  // 카메라/회사/사용자 정보를 토대로 WHEP 토큰을 발급받는다.
187
268
  const tokenQuery = useQueryCctvRtcToken({
188
269
  company_id: cam?.company_id ?? "",
189
270
  cam_id: cam?.cam_id ?? "",
271
+ enabled: isStreamStartGranted,
190
272
  username: tokenUsername,
191
273
  url: tokenUrl ?? contextTokenUrl,
192
274
  });
@@ -195,19 +277,12 @@ export function useCctvRtcStream({
195
277
  const isTokenLoading = tokenQuery.isFetching;
196
278
  const isTokenError = tokenQuery.isError;
197
279
 
198
- // 카메라의 RTC 엔드포인트와 사용자명을 조합해 실제 WHEP endpoint를 계산한다.
199
- const endpoint = useMemo(() => {
200
- if (!cam?.cam_rtc) return undefined;
201
- const query = endpointUsername
202
- ? `?username=${encodeURIComponent(endpointUsername)}`
203
- : "";
204
- return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
205
- }, [cam?.cam_rtc, endpointUsername]);
206
-
207
280
  const streamKeyCandidate = useMemo(() => {
281
+ // streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
208
282
  if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
209
283
  return "";
210
284
 
285
+ // token dataUpdatedAt을 포함해 명시 refetch 후에는 새 WHEP 연결 후보로 분리한다.
211
286
  return [
212
287
  tokenUsername,
213
288
  cam.company_id,
@@ -225,13 +300,8 @@ export function useCctvRtcStream({
225
300
  tokenUsername,
226
301
  ]);
227
302
 
228
- const streamIdentityKey = useMemo(() => {
229
- if (!cam?.cam_id || !endpoint) return "";
230
-
231
- return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
232
- }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
233
-
234
303
  useEffect(() => {
304
+ // identity가 바뀌면 이전 연결 성공 이력을 버려 reconnect gate를 초기 연결 상태로 되돌린다.
235
305
  setHasConnected(false);
236
306
  }, [streamIdentityKey]);
237
307
 
@@ -246,6 +316,7 @@ export function useCctvRtcStream({
246
316
  );
247
317
 
248
318
  useEffect(() => {
319
+ // 연결 이후 장애가 감지되고 현재 token/stream 작업이 없을 때만 reconnect timer를 연다.
249
320
  const canStartReconnectTimer =
250
321
  hasConnected &&
251
322
  Boolean(streamIdentityKey) &&
@@ -254,20 +325,24 @@ export function useCctvRtcStream({
254
325
  !isStreaming;
255
326
 
256
327
  if (!canStartReconnectTimer) {
328
+ // timer 조건이 사라지면 재연결 가능 표시도 즉시 닫는다.
257
329
  setPostConnectedReconnectReady(false);
258
330
  return;
259
331
  }
260
332
 
333
+ // 새 장애 reason이 들어올 때마다 grace/stagger 대기 상태부터 다시 시작한다.
261
334
  setPostConnectedReconnectReady(false);
262
335
  const reconnectDelayMs = getPostConnectedReconnectDelayMs(
263
336
  `${streamIdentityKey}|${reconnectReason}`,
264
337
  );
265
338
 
339
+ // cam identity 기반 delay 이후에만 UI/auto reconnect가 재시도할 수 있게 한다.
266
340
  const timeout = setTimeout(() => {
267
341
  setPostConnectedReconnectReady(true);
268
342
  }, reconnectDelayMs);
269
343
 
270
344
  return () => {
345
+ // 상태가 정상화되거나 identity가 바뀌면 이전 timer가 늦게 열리지 않게 취소한다.
271
346
  clearTimeout(timeout);
272
347
  };
273
348
  }, [
@@ -279,6 +354,7 @@ export function useCctvRtcStream({
279
354
  ]);
280
355
 
281
356
  const isPostConnectedRecoverableState =
357
+ // 이미 연결됐던 stream의 transient disconnected/failed는 화면을 바로 reset하지 않는다.
282
358
  hasConnected &&
283
359
  Boolean(streamIdentityKey) &&
284
360
  !isTokenError &&
@@ -286,6 +362,7 @@ export function useCctvRtcStream({
286
362
  DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
287
363
 
288
364
  const isPostConnectedReplacementLoading =
365
+ // token refetch 또는 새 stream start 중에도 기존 live 화면을 유지한다.
289
366
  hasConnected &&
290
367
  Boolean(streamIdentityKey) &&
291
368
  !isTokenError &&
@@ -294,6 +371,9 @@ export function useCctvRtcStream({
294
371
 
295
372
  const shouldPreserveConnectedDisplay =
296
373
  isPostConnectedReplacementLoading || isPostConnectedRecoverableState;
374
+ const isWaitingForStreamGrant =
375
+ // scheduler 대기 중에는 token loading으로 보이지 않게 별도 queued stage로 표시한다.
376
+ Boolean(streamIdentityKey && cam?.cam_online) && !isStreamStartGranted;
297
377
 
298
378
  // 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
299
379
  // post-connected recovery/replacement 중에는 canReconnect만 열고, 기존 화면은 유지해 UI reset 파동을 막는다.
@@ -303,13 +383,16 @@ export function useCctvRtcStream({
303
383
  const displayIsStreaming = shouldPreserveConnectedDisplay
304
384
  ? false
305
385
  : isStreaming;
306
- const displayIsTokenLoading = shouldPreserveConnectedDisplay
307
- ? false
308
- : isTokenLoading;
386
+ const displayIsTokenLoading =
387
+ shouldPreserveConnectedDisplay || isWaitingForStreamGrant
388
+ ? false
389
+ : isTokenLoading;
309
390
 
310
391
  const hasReusableRegistryStream = useMemo(() => {
392
+ // streamKey 후보가 없으면 registry snapshot도 조회하지 않는다.
311
393
  if (!streamKeyCandidate) return false;
312
394
 
395
+ // 같은 Provider registry에 이미 시작/연결된 stream이 있으면 token freshness와 별개로 reattach할 수 있다.
313
396
  const snapshot = streamRegistry.getSnapshot(streamKeyCandidate);
314
397
 
315
398
  return Boolean(
@@ -320,10 +403,16 @@ export function useCctvRtcStream({
320
403
  }, [streamKeyCandidate, streamRegistry]);
321
404
 
322
405
  const canUseTokenForStream = useMemo(() => {
406
+ // token 또는 streamKey 후보가 없으면 WHEP start 조건이 닫힌다.
323
407
  if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
408
+
409
+ // 현재 hook이 이미 붙어 있는 streamKey면 재판정 없이 유지한다.
324
410
  if (activeStreamKeyRef.current === streamKeyCandidate) return true;
411
+
412
+ // registry에 재사용 가능한 stream이 있으면 stale token이어도 새 WHEP POST 없이 attach만 한다.
325
413
  if (hasReusableRegistryStream) return true;
326
414
 
415
+ // 신규 WHEP POST는 token exp/fallback age를 통과해야만 허용한다.
327
416
  return canUseTokenForNewConnection({
328
417
  dataUpdatedAt: tokenQuery.dataUpdatedAt,
329
418
  token: tokenQuery.data.token,
@@ -335,14 +424,17 @@ export function useCctvRtcStream({
335
424
  tokenQuery.dataUpdatedAt,
336
425
  ]);
337
426
 
427
+ // streamKey가 빈 문자열이면 아래 stream effect가 WHEP start를 하지 않는다.
338
428
  const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
339
429
 
340
430
  useEffect(() => {
431
+ // streamKey가 없거나 token을 그대로 쓸 수 있으면 stale refresh guard를 초기화한다.
341
432
  if (!streamKeyCandidate || canUseTokenForStream) {
342
433
  staleTokenRefreshKeyRef.current = null;
343
434
  return;
344
435
  }
345
436
 
437
+ // token이 없거나 이미 요청 중/에러 상태면 여기서 추가 refetch를 만들지 않는다.
346
438
  if (
347
439
  !tokenQuery.data?.token ||
348
440
  tokenQuery.isFetching ||
@@ -351,10 +443,12 @@ export function useCctvRtcStream({
351
443
  return;
352
444
  }
353
445
 
446
+ // 같은 stale stream 후보에 대해 refetch를 반복 호출하지 않는다.
354
447
  if (staleTokenRefreshKeyRef.current === streamKeyCandidate) {
355
448
  return;
356
449
  }
357
450
 
451
+ // stale token으로 WHEP start를 막고 token을 먼저 갱신한다.
358
452
  staleTokenRefreshKeyRef.current = streamKeyCandidate;
359
453
  void refetchRtcToken();
360
454
  }, [
@@ -369,14 +463,20 @@ export function useCctvRtcStream({
369
463
 
370
464
  // 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
371
465
  useEffect(() => {
466
+ // effect 시작 시점의 video element를 고정해 cleanup/subscribe에서 같은 노드를 다룬다.
372
467
  const currentVideo = videoRef.current;
373
468
 
374
469
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
375
470
  if (tokenQuery.isError || !cam?.cam_online) {
471
+ // identity가 있으면 같은 카메라 family의 registry stream도 닫아 stale frame을 제거한다.
376
472
  if (streamIdentityKey) {
377
473
  streamRegistry.closeByIdentity(streamIdentityKey);
378
474
  }
475
+
476
+ // DOM video에는 registry cleanup과 별개로 srcObject 잔상을 즉시 비운다.
379
477
  if (currentVideo) currentVideo.srcObject = null;
478
+
479
+ // UI state도 초기 연결 전 상태로 되돌린다.
380
480
  setConnectionState("new");
381
481
  setStreamError(null);
382
482
  setStreaming(false);
@@ -387,23 +487,31 @@ export function useCctvRtcStream({
387
487
  if (!streamKey || !tokenQuery.data?.token || !endpoint || !currentVideo)
388
488
  return;
389
489
 
490
+ // 같은 identity에서 token dataUpdatedAt만 바뀌면 새 stream attach 이후 이전 stream을 닫는다.
390
491
  const previousStreamKey =
391
492
  activeStreamIdentityKeyRef.current === streamIdentityKey &&
392
493
  activeStreamKeyRef.current !== streamKey
393
494
  ? activeStreamKeyRef.current
394
495
  : null;
496
+
497
+ // track 수신 전 premature close를 막기 위해 실제 새 stream 확보 후 한 번만 이전 stream을 닫는다.
395
498
  let didClosePreviousStream = false;
396
499
 
397
500
  const closePreviousStreamAfterTrack = () => {
501
+ // 이전 stream이 없거나 이미 닫았다면 no-op으로 둔다.
398
502
  if (!previousStreamKey || didClosePreviousStream) return;
399
503
 
400
504
  didClosePreviousStream = true;
505
+
506
+ // 현재 streamKey는 보존하고 같은 identity의 이전 registry entry만 정리한다.
401
507
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
402
508
  };
403
509
 
510
+ // 이후 effect 실행에서 같은 hook instance의 active stream을 판정할 수 있게 저장한다.
404
511
  activeStreamKeyRef.current = streamKey;
405
512
  activeStreamIdentityKeyRef.current = streamIdentityKey;
406
513
 
514
+ // registry.start는 같은 streamKey가 이미 있으면 새 WHEP POST 없이 기존 entry를 유지한다.
407
515
  streamRegistry.start({
408
516
  streamKey,
409
517
  identityKey: streamIdentityKey,
@@ -412,21 +520,32 @@ export function useCctvRtcStream({
412
520
  video: currentVideo,
413
521
  });
414
522
 
523
+ // 현재 video element를 registry entry에 붙여 MediaStream reattach 대상에 포함한다.
415
524
  const detachVideo = streamRegistry.attach(streamKey, currentVideo);
525
+
526
+ // registry snapshot 변경을 hook local state와 DOM video에 반영한다.
416
527
  const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
417
528
  setConnectionState(snapshot.connectionState);
418
529
  setStreamError(snapshot.streamError);
419
530
  setStreaming(snapshot.isStreaming);
531
+
532
+ // 한 번이라도 connected가 되면 이후 장애를 post-connected recovery로 취급한다.
420
533
  if (snapshot.connectionState === "connected") {
421
534
  setHasConnected(true);
422
535
  }
536
+
537
+ // onTrack으로 MediaStream이 들어온 뒤에만 video.srcObject를 교체한다.
423
538
  if (snapshot.stream) {
424
539
  if (currentVideo.srcObject !== snapshot.stream) {
425
540
  currentVideo.srcObject = snapshot.stream;
426
541
  }
542
+
543
+ // 새 stream이 실제로 화면에 붙은 뒤 이전 token stream을 닫는다.
427
544
  closePreviousStreamAfterTrack();
428
545
  }
429
546
  });
547
+
548
+ // subscribe 직후 현재 snapshot을 즉시 반영해 기존 registry stream reattach 지연을 줄인다.
430
549
  const snapshot = streamRegistry.getSnapshot(streamKey);
431
550
  setConnectionState(snapshot.connectionState);
432
551
  setStreamError(snapshot.streamError);
@@ -434,9 +553,11 @@ export function useCctvRtcStream({
434
553
  if (snapshot.connectionState === "connected") setHasConnected(true);
435
554
 
436
555
  if (snapshot.stream) {
556
+ // 이미 registry에 MediaStream이 있으면 첫 render cycle에서도 즉시 video에 붙인다.
437
557
  if (currentVideo.srcObject !== snapshot.stream) {
438
558
  currentVideo.srcObject = snapshot.stream;
439
559
  }
560
+
440
561
  closePreviousStreamAfterTrack();
441
562
  }
442
563
 
@@ -456,16 +577,28 @@ export function useCctvRtcStream({
456
577
  ]);
457
578
 
458
579
  const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
580
+ // public reconnect API는 React Query refetch를 그대로 감싸 token refresh trigger로 사용한다.
459
581
  options => refetchRtcToken(options),
460
582
  [refetchRtcToken],
461
583
  );
462
584
 
463
585
  const canReconnect = useMemo(() => {
586
+ // offline camera는 재연결 대상이 아니다.
464
587
  if (!cam?.cam_online) return false;
588
+
589
+ // 초기 연결 전 실패는 자동 reconnect UX가 아니라 일반 연결 오류로 둔다.
465
590
  if (!hasConnected) return false;
591
+
592
+ // identity가 없으면 어떤 registry stream을 재시도할지 알 수 없다.
466
593
  if (!streamIdentityKey) return false;
594
+
595
+ // 이미 token/stream 작업 중이면 중복 reconnect를 허용하지 않는다.
467
596
  if (isTokenLoading || isStreaming) return false;
597
+
598
+ // token/stream/failed 중 재연결 사유가 있어야 한다.
468
599
  if (!reconnectReason) return false;
600
+
601
+ // grace/stagger timer가 끝난 뒤에만 public reconnect 가능 상태를 연다.
469
602
  return isPostConnectedReconnectReady;
470
603
  }, [
471
604
  cam?.cam_online,
@@ -480,14 +613,20 @@ export function useCctvRtcStream({
480
613
  const refetchToken = reconnectStream;
481
614
 
482
615
  useEffect(() => {
616
+ // SSR/테스트 환경에서는 focus/visibility listener를 등록하지 않는다.
483
617
  if (typeof window === "undefined" || typeof document === "undefined")
484
618
  return;
485
619
 
486
620
  const reconnectOnFocus = () => {
621
+ // visibilitychange는 hidden 상태에서도 발생하므로 visible 복귀만 처리한다.
487
622
  if (document.visibilityState !== "visible") return;
623
+
624
+ // canReconnect gate가 닫혀 있으면 focus 복귀가 token refetch를 만들지 않는다.
488
625
  if (!canReconnect) return;
489
626
 
490
627
  const now = Date.now();
628
+
629
+ // 브라우저 focus와 visibilitychange가 연달아 들어와도 reconnect는 interval당 한 번만 호출한다.
491
630
  if (now - lastAutoReconnectAtRef.current < AUTO_RECONNECT_INTERVAL_MS)
492
631
  return;
493
632
 
@@ -495,17 +634,70 @@ export function useCctvRtcStream({
495
634
  void reconnectStream();
496
635
  };
497
636
 
637
+ // focus와 visibility 복귀 둘 다 장시간 미사용 후 재연결 trigger로 본다.
498
638
  window.addEventListener("focus", reconnectOnFocus);
499
639
  document.addEventListener("visibilitychange", reconnectOnFocus);
500
640
 
501
641
  return () => {
642
+ // hook unmount 시 전역 listener 누수를 막는다.
502
643
  window.removeEventListener("focus", reconnectOnFocus);
503
644
  document.removeEventListener("visibilitychange", reconnectOnFocus);
504
645
  };
505
646
  }, [canReconnect, reconnectStream]);
506
647
 
648
+ const streamStage = useMemo<CctvRtcStreamStage>(() => {
649
+ // cam이 없으면 hook consumer가 아직 stream 대상 없이 렌더된 상태다.
650
+ if (!cam) return "idle";
651
+
652
+ // offline camera는 scheduler/token/WHEP 대상에서 제외된 별도 상태로 표시한다.
653
+ if (!cam.cam_online) return "skipped-offline";
654
+
655
+ // token 또는 WHEP 에러는 다른 loading/queued 상태보다 우선한다.
656
+ if (isTokenError || streamError) return "error";
657
+
658
+ // post-connected 장애가 grace/stagger 이후 재연결 가능한 상태로 열린 경우다.
659
+ if (canReconnect) return "recovering";
660
+
661
+ // scheduler grant 전 대기 상태를 token-loading과 분리한다.
662
+ if (isWaitingForStreamGrant) return "queued";
663
+
664
+ // token query가 실제로 실행 중인 상태다.
665
+ if (displayIsTokenLoading) return "token-loading";
666
+
667
+ // WHEP offer/answer 또는 registry start가 진행 중인 상태다.
668
+ if (displayIsStreaming) return "connecting";
669
+
670
+ // UI 표시 기준 연결 상태가 connected면 live stage로 본다.
671
+ if (displayConnectionState === "connected") return "live";
672
+
673
+ // WebRTC가 아직 connected 전이면 사용자에게 연결 중으로 표시한다.
674
+ if (
675
+ displayConnectionState === "new" ||
676
+ displayConnectionState === "connecting" ||
677
+ displayConnectionState === "disconnected"
678
+ ) {
679
+ return "connecting";
680
+ }
681
+
682
+ // RTCPeerConnection failed는 streamError가 없어도 오류 stage로 승격한다.
683
+ if (displayConnectionState === "failed") return "error";
684
+
685
+ // 위 조건에 걸리지 않는 closed/unknown 계열은 기본 idle로 둔다.
686
+ return "idle";
687
+ }, [
688
+ cam,
689
+ canReconnect,
690
+ displayConnectionState,
691
+ displayIsStreaming,
692
+ displayIsTokenLoading,
693
+ isTokenError,
694
+ isWaitingForStreamGrant,
695
+ streamError,
696
+ ]);
697
+
507
698
  const liveState = useMemo(
508
699
  () =>
700
+ // live registry에는 cam이 있을 때만 util 판정 결과를 반영한다.
509
701
  cam
510
702
  ? getIsLive({
511
703
  cam,
@@ -527,11 +719,14 @@ export function useCctvRtcStream({
527
719
  );
528
720
 
529
721
  useEffect(() => {
722
+ // cam_id가 없으면 live registry key를 만들 수 없다.
530
723
  const camId = cam?.cam_id;
531
724
  if (!camId) return;
532
725
 
726
+ // 같은 cam을 여러 위치에서 렌더할 수 있어 instance 단위 live state를 보관한다.
533
727
  const instanceKey = instanceKeyRef.current as string;
534
728
  setLiveRegistry(prev => {
729
+ // jotai atom update는 기존 cam map을 얕은 복사해 React 변경 감지를 보장한다.
535
730
  const next = { ...prev };
536
731
  const perCam = { ...(next[camId] ?? {}) };
537
732
  perCam[instanceKey] = liveState;
@@ -541,15 +736,19 @@ export function useCctvRtcStream({
541
736
  }, [cam?.cam_id, liveState, setLiveRegistry]);
542
737
 
543
738
  useEffect(() => {
739
+ // unmount cleanup도 cam_id 기준이라 cam이 없으면 등록할 cleanup이 없다.
544
740
  const camId = cam?.cam_id;
545
741
  if (!camId) return;
546
742
 
547
743
  const instanceKey = instanceKeyRef.current as string;
548
744
  return () => {
745
+ // 현재 hook instance만 제거하고 같은 cam의 다른 렌더 위치는 유지한다.
549
746
  setLiveRegistry(prev => {
550
747
  const next = { ...prev };
551
748
  const perCam = { ...(next[camId] ?? {}) };
552
749
  delete perCam[instanceKey];
750
+
751
+ // 마지막 instance가 사라지면 cam entry 자체를 제거한다.
553
752
  if (Object.keys(perCam).length === 0) {
554
753
  delete next[camId];
555
754
  } else {
@@ -568,6 +767,7 @@ export function useCctvRtcStream({
568
767
  isStreaming: displayIsStreaming,
569
768
  isTokenLoading: displayIsTokenLoading,
570
769
  isTokenError,
770
+ streamStage,
571
771
  canReconnect,
572
772
  refetchToken,
573
773
  reconnectStream,
@@ -55,6 +55,22 @@ export interface CctvApiUrlContext {
55
55
  tokenUrl?: string;
56
56
  }
57
57
 
58
+ /**
59
+ * CCTV; RTC stream lazy loading 옵션
60
+ * @property {number} [batchSize] 한 번에 token/WHEP 시작 권한을 받을 stream 개수 (default: 4)
61
+ * @property {number} [batchIntervalMs] 다음 batch 시작까지 대기할 시간(ms) (default: 1200)
62
+ */
63
+ export interface CctvRtcStreamOptions {
64
+ /**
65
+ * 한 번에 token/WHEP 시작 권한을 받을 stream 개수
66
+ */
67
+ batchSize?: number;
68
+ /**
69
+ * 다음 batch 시작까지 대기할 시간(ms)
70
+ */
71
+ batchIntervalMs?: number;
72
+ }
73
+
58
74
  /**
59
75
  * CCTV; 실시간 영상보기 컨텍스트
60
76
  * @property {string} [company_id] 선택된 업체 id코드
@@ -125,6 +141,7 @@ export type CctvContext<ContextExtension extends object = object> =
125
141
  * @property {string} [username] CCTV 조회시 권한 확인을 위한 계정명
126
142
  * @property {string} [whepUsername] WHEP endpoint username 덮어쓰기 값
127
143
  * @property {ContextExtension} [defaultValues] 서비스 확장 context 기본값
144
+ * @property {CctvRtcStreamOptions} [streamOptions] stream lazy loading 옵션
128
145
  * @property {React.ReactNode} children
129
146
  */
130
147
  export type CctvProviderProps<ContextExtension extends object = object> =
@@ -142,6 +159,10 @@ export type CctvProviderProps<ContextExtension extends object = object> =
142
159
  * 서비스 확장 context 기본값
143
160
  */
144
161
  defaultValues?: ContextExtension;
162
+ /**
163
+ * stream lazy loading 옵션
164
+ */
165
+ streamOptions?: CctvRtcStreamOptions;
145
166
  /**
146
167
  * Provider 하위 콘텐츠
147
168
  */
@@ -20,6 +20,24 @@ export type CctvRtcReconnectTrigger = (
20
20
  options?: RefetchOptions,
21
21
  ) => Promise<QueryObserverResult<API_Res_CctvRtcToken>>;
22
22
 
23
+ /**
24
+ * CCTV; RTC stream scheduler 상태
25
+ */
26
+ export type CctvRtcStreamScheduleStatus = "idle" | "queued" | "granted";
27
+
28
+ /**
29
+ * CCTV; RTC stream 표시 단계
30
+ */
31
+ export type CctvRtcStreamStage =
32
+ | "idle"
33
+ | "queued"
34
+ | "token-loading"
35
+ | "connecting"
36
+ | "live"
37
+ | "recovering"
38
+ | "error"
39
+ | "skipped-offline";
40
+
23
41
  /**
24
42
  * CCTV; useCctvRtcStream params
25
43
  * @property {CctvCompanyCameraData} [cam] 스트리밍 카메라 정보
@@ -64,6 +82,7 @@ export interface UseCctvRtcStreamError {
64
82
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
65
83
  * @property {boolean} isTokenError 토큰 발급 실패 여부
66
84
  * @property {string | null} streamError 스트림 오류 메시지
85
+ * @property {CctvRtcStreamStage} streamStage stream 표시 단계
67
86
  */
68
87
  export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
69
88
  /**
@@ -88,6 +107,10 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
88
107
  * 재발급 중에는 기존 화면과 live count를 유지하기 위해 false로 smoothing될 수 있다.
89
108
  */
90
109
  isTokenLoading: boolean;
110
+ /**
111
+ * stream 표시 단계
112
+ */
113
+ streamStage: CctvRtcStreamStage;
91
114
  }
92
115
 
93
116
  /**
@@ -98,6 +121,7 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
98
121
  * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
99
122
  * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
100
123
  * @property {boolean} isTokenError 토큰 발급 실패 여부
124
+ * @property {CctvRtcStreamStage} streamStage stream 표시 단계
101
125
  * @property {boolean} canReconnect UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
102
126
  * @property {() => Promise<void>} refetchToken 토큰 재발급 함수
103
127
  * @property {() => Promise<QueryObserverResult<API_Res_CctvRtcToken>>} reconnectStream 재연결 trigger 함수