@uniai-fe/uds-templates 0.6.26 → 0.6.28

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,18 @@ export function useCctvRtcStream({
149
182
  }: UseCctvRtcStreamParams): UseCctvRtcStreamReturn {
150
183
  // Provider를 통해 주입된 기본 토큰 발급 URL을 확보한다.
151
184
  const { tokenUrl: contextTokenUrl } = useCctvApiUrl();
185
+ const tokenEndpointUrl = tokenUrl ?? contextTokenUrl;
186
+
187
+ // registry는 WHEP connection/MediaStream을 Provider scope에서 유지한다.
152
188
  const streamRegistry = useCctvRtcStreamRegistry();
189
+
190
+ // scheduler는 list item 렌더와 token/WHEP 시작 시점을 분리한다.
191
+ const streamScheduler = useCctvRtcStreamScheduler();
192
+
193
+ // live registry는 여러 hook instance가 같은 cam의 live 상태를 공유하는 Jotai atom이다.
153
194
  const setLiveRegistry = useSetAtom(cctvRtcLiveRegistryAtom);
195
+
196
+ // 같은 cam을 여러 위치에서 렌더할 수 있어 hook instance별 key를 한 번만 만든다.
154
197
  const instanceKeyRef = useRef<string | null>(null);
155
198
  if (!instanceKeyRef.current) {
156
199
  instanceKeyRef.current = Math.random().toString(36).slice(2);
@@ -158,9 +201,17 @@ export function useCctvRtcStream({
158
201
 
159
202
  // WebRTC MediaStream을 연결할 video 요소 ref.
160
203
  const videoRef = useRef<HTMLVideoElement | null>(null);
204
+
205
+ // 현재 hook instance가 attach 중인 streamKey를 기억해 stale token/refresh 분기를 구분한다.
161
206
  const activeStreamKeyRef = useRef<string | null>(null);
207
+
208
+ // stream identity는 token dataUpdatedAt을 제외한 카메라/사용자/endpoint 기준이다.
162
209
  const activeStreamIdentityKeyRef = useRef<string | null>(null);
210
+
211
+ // focus/visibility 자동 재연결이 짧은 시간에 반복되지 않도록 마지막 호출 시각을 저장한다.
163
212
  const lastAutoReconnectAtRef = useRef(0);
213
+
214
+ // stale token refetch를 같은 streamKeyCandidate마다 한 번만 수행하기 위한 guard다.
164
215
  const staleTokenRefreshKeyRef = useRef<string | null>(null);
165
216
 
166
217
  // RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
@@ -183,31 +234,57 @@ export function useCctvRtcStream({
183
234
  const endpointUsername =
184
235
  whepUsername === undefined ? tokenUsername : whepUsername;
185
236
 
237
+ // 카메라의 RTC 엔드포인트와 사용자명을 조합해 실제 WHEP endpoint를 계산한다.
238
+ const endpoint = useMemo(() => {
239
+ // cam_rtc가 없으면 WHEP endpoint를 만들 수 없어 이후 identity/token gate도 닫힌다.
240
+ if (!cam?.cam_rtc) return undefined;
241
+
242
+ // WHEP username override가 있으면 token username과 별개로 endpoint query에만 반영한다.
243
+ const query = endpointUsername
244
+ ? `?username=${encodeURIComponent(endpointUsername)}`
245
+ : "";
246
+
247
+ // cam_rtc 뒤의 slash 중복을 제거하고 WHEP path를 붙인다.
248
+ return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
249
+ }, [cam?.cam_rtc, endpointUsername]);
250
+
251
+ const streamIdentityKey = useMemo(() => {
252
+ // identity 필수값이 모두 있어야 scheduler와 registry identity cleanup이 가능하다.
253
+ if (!tokenUsername || !cam?.company_id || !cam.cam_id || !endpoint)
254
+ return "";
255
+
256
+ // token 갱신 시각은 제외해 같은 카메라 stream family를 하나의 identity로 묶는다.
257
+ return [tokenUsername, cam.company_id, cam.cam_id, endpoint].join("|");
258
+ }, [cam?.cam_id, cam?.company_id, endpoint, tokenUsername]);
259
+
260
+ // scheduler grant 전에는 token query enabled가 false라 초기 token burst가 발생하지 않는다.
261
+ const streamScheduleStatus = useCctvRtcStreamScheduleStatus({
262
+ enabled: Boolean(streamIdentityKey && cam?.cam_online),
263
+ identityKey: streamIdentityKey,
264
+ scheduler: streamScheduler,
265
+ });
266
+ const isStreamStartGranted = streamScheduleStatus === "granted";
267
+
186
268
  // 카메라/회사/사용자 정보를 토대로 WHEP 토큰을 발급받는다.
187
269
  const tokenQuery = useQueryCctvRtcToken({
188
270
  company_id: cam?.company_id ?? "",
189
271
  cam_id: cam?.cam_id ?? "",
272
+ enabled: isStreamStartGranted,
190
273
  username: tokenUsername,
191
- url: tokenUrl ?? contextTokenUrl,
274
+ url: tokenEndpointUrl,
192
275
  });
193
276
 
194
277
  const { refetch: refetchRtcToken } = tokenQuery;
195
278
  const isTokenLoading = tokenQuery.isFetching;
196
- const isTokenError = tokenQuery.isError;
197
-
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]);
279
+ const isTokenEndpointMissing = isStreamStartGranted && !tokenEndpointUrl;
280
+ const isTokenError = tokenQuery.isError || isTokenEndpointMissing;
206
281
 
207
282
  const streamKeyCandidate = useMemo(() => {
283
+ // streamKey는 online 카메라와 endpoint/token이 모두 준비된 뒤에만 만들 수 있다.
208
284
  if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
209
285
  return "";
210
286
 
287
+ // token dataUpdatedAt을 포함해 명시 refetch 후에는 새 WHEP 연결 후보로 분리한다.
211
288
  return [
212
289
  tokenUsername,
213
290
  cam.company_id,
@@ -225,13 +302,8 @@ export function useCctvRtcStream({
225
302
  tokenUsername,
226
303
  ]);
227
304
 
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
305
  useEffect(() => {
306
+ // identity가 바뀌면 이전 연결 성공 이력을 버려 reconnect gate를 초기 연결 상태로 되돌린다.
235
307
  setHasConnected(false);
236
308
  }, [streamIdentityKey]);
237
309
 
@@ -246,6 +318,7 @@ export function useCctvRtcStream({
246
318
  );
247
319
 
248
320
  useEffect(() => {
321
+ // 연결 이후 장애가 감지되고 현재 token/stream 작업이 없을 때만 reconnect timer를 연다.
249
322
  const canStartReconnectTimer =
250
323
  hasConnected &&
251
324
  Boolean(streamIdentityKey) &&
@@ -254,20 +327,24 @@ export function useCctvRtcStream({
254
327
  !isStreaming;
255
328
 
256
329
  if (!canStartReconnectTimer) {
330
+ // timer 조건이 사라지면 재연결 가능 표시도 즉시 닫는다.
257
331
  setPostConnectedReconnectReady(false);
258
332
  return;
259
333
  }
260
334
 
335
+ // 새 장애 reason이 들어올 때마다 grace/stagger 대기 상태부터 다시 시작한다.
261
336
  setPostConnectedReconnectReady(false);
262
337
  const reconnectDelayMs = getPostConnectedReconnectDelayMs(
263
338
  `${streamIdentityKey}|${reconnectReason}`,
264
339
  );
265
340
 
341
+ // cam identity 기반 delay 이후에만 UI/auto reconnect가 재시도할 수 있게 한다.
266
342
  const timeout = setTimeout(() => {
267
343
  setPostConnectedReconnectReady(true);
268
344
  }, reconnectDelayMs);
269
345
 
270
346
  return () => {
347
+ // 상태가 정상화되거나 identity가 바뀌면 이전 timer가 늦게 열리지 않게 취소한다.
271
348
  clearTimeout(timeout);
272
349
  };
273
350
  }, [
@@ -279,6 +356,7 @@ export function useCctvRtcStream({
279
356
  ]);
280
357
 
281
358
  const isPostConnectedRecoverableState =
359
+ // 이미 연결됐던 stream의 transient disconnected/failed는 화면을 바로 reset하지 않는다.
282
360
  hasConnected &&
283
361
  Boolean(streamIdentityKey) &&
284
362
  !isTokenError &&
@@ -286,6 +364,7 @@ export function useCctvRtcStream({
286
364
  DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
287
365
 
288
366
  const isPostConnectedReplacementLoading =
367
+ // token refetch 또는 새 stream start 중에도 기존 live 화면을 유지한다.
289
368
  hasConnected &&
290
369
  Boolean(streamIdentityKey) &&
291
370
  !isTokenError &&
@@ -294,6 +373,9 @@ export function useCctvRtcStream({
294
373
 
295
374
  const shouldPreserveConnectedDisplay =
296
375
  isPostConnectedReplacementLoading || isPostConnectedRecoverableState;
376
+ const isWaitingForStreamGrant =
377
+ // scheduler 대기 중에는 token loading으로 보이지 않게 별도 queued stage로 표시한다.
378
+ Boolean(streamIdentityKey && cam?.cam_online) && !isStreamStartGranted;
297
379
 
298
380
  // 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
299
381
  // post-connected recovery/replacement 중에는 canReconnect만 열고, 기존 화면은 유지해 UI reset 파동을 막는다.
@@ -303,13 +385,16 @@ export function useCctvRtcStream({
303
385
  const displayIsStreaming = shouldPreserveConnectedDisplay
304
386
  ? false
305
387
  : isStreaming;
306
- const displayIsTokenLoading = shouldPreserveConnectedDisplay
307
- ? false
308
- : isTokenLoading;
388
+ const displayIsTokenLoading =
389
+ shouldPreserveConnectedDisplay || isWaitingForStreamGrant
390
+ ? false
391
+ : isTokenLoading;
309
392
 
310
393
  const hasReusableRegistryStream = useMemo(() => {
394
+ // streamKey 후보가 없으면 registry snapshot도 조회하지 않는다.
311
395
  if (!streamKeyCandidate) return false;
312
396
 
397
+ // 같은 Provider registry에 이미 시작/연결된 stream이 있으면 token freshness와 별개로 reattach할 수 있다.
313
398
  const snapshot = streamRegistry.getSnapshot(streamKeyCandidate);
314
399
 
315
400
  return Boolean(
@@ -320,10 +405,16 @@ export function useCctvRtcStream({
320
405
  }, [streamKeyCandidate, streamRegistry]);
321
406
 
322
407
  const canUseTokenForStream = useMemo(() => {
408
+ // token 또는 streamKey 후보가 없으면 WHEP start 조건이 닫힌다.
323
409
  if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
410
+
411
+ // 현재 hook이 이미 붙어 있는 streamKey면 재판정 없이 유지한다.
324
412
  if (activeStreamKeyRef.current === streamKeyCandidate) return true;
413
+
414
+ // registry에 재사용 가능한 stream이 있으면 stale token이어도 새 WHEP POST 없이 attach만 한다.
325
415
  if (hasReusableRegistryStream) return true;
326
416
 
417
+ // 신규 WHEP POST는 token exp/fallback age를 통과해야만 허용한다.
327
418
  return canUseTokenForNewConnection({
328
419
  dataUpdatedAt: tokenQuery.dataUpdatedAt,
329
420
  token: tokenQuery.data.token,
@@ -335,14 +426,17 @@ export function useCctvRtcStream({
335
426
  tokenQuery.dataUpdatedAt,
336
427
  ]);
337
428
 
429
+ // streamKey가 빈 문자열이면 아래 stream effect가 WHEP start를 하지 않는다.
338
430
  const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
339
431
 
340
432
  useEffect(() => {
433
+ // streamKey가 없거나 token을 그대로 쓸 수 있으면 stale refresh guard를 초기화한다.
341
434
  if (!streamKeyCandidate || canUseTokenForStream) {
342
435
  staleTokenRefreshKeyRef.current = null;
343
436
  return;
344
437
  }
345
438
 
439
+ // token이 없거나 이미 요청 중/에러 상태면 여기서 추가 refetch를 만들지 않는다.
346
440
  if (
347
441
  !tokenQuery.data?.token ||
348
442
  tokenQuery.isFetching ||
@@ -351,10 +445,12 @@ export function useCctvRtcStream({
351
445
  return;
352
446
  }
353
447
 
448
+ // 같은 stale stream 후보에 대해 refetch를 반복 호출하지 않는다.
354
449
  if (staleTokenRefreshKeyRef.current === streamKeyCandidate) {
355
450
  return;
356
451
  }
357
452
 
453
+ // stale token으로 WHEP start를 막고 token을 먼저 갱신한다.
358
454
  staleTokenRefreshKeyRef.current = streamKeyCandidate;
359
455
  void refetchRtcToken();
360
456
  }, [
@@ -369,14 +465,20 @@ export function useCctvRtcStream({
369
465
 
370
466
  // 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
371
467
  useEffect(() => {
468
+ // effect 시작 시점의 video element를 고정해 cleanup/subscribe에서 같은 노드를 다룬다.
372
469
  const currentVideo = videoRef.current;
373
470
 
374
471
  // 토큰/카메라 에러로 전환되면 직전 MediaStream이 화면에 남지 않도록 비운다.
375
472
  if (tokenQuery.isError || !cam?.cam_online) {
473
+ // identity가 있으면 같은 카메라 family의 registry stream도 닫아 stale frame을 제거한다.
376
474
  if (streamIdentityKey) {
377
475
  streamRegistry.closeByIdentity(streamIdentityKey);
378
476
  }
477
+
478
+ // DOM video에는 registry cleanup과 별개로 srcObject 잔상을 즉시 비운다.
379
479
  if (currentVideo) currentVideo.srcObject = null;
480
+
481
+ // UI state도 초기 연결 전 상태로 되돌린다.
380
482
  setConnectionState("new");
381
483
  setStreamError(null);
382
484
  setStreaming(false);
@@ -387,23 +489,31 @@ export function useCctvRtcStream({
387
489
  if (!streamKey || !tokenQuery.data?.token || !endpoint || !currentVideo)
388
490
  return;
389
491
 
492
+ // 같은 identity에서 token dataUpdatedAt만 바뀌면 새 stream attach 이후 이전 stream을 닫는다.
390
493
  const previousStreamKey =
391
494
  activeStreamIdentityKeyRef.current === streamIdentityKey &&
392
495
  activeStreamKeyRef.current !== streamKey
393
496
  ? activeStreamKeyRef.current
394
497
  : null;
498
+
499
+ // track 수신 전 premature close를 막기 위해 실제 새 stream 확보 후 한 번만 이전 stream을 닫는다.
395
500
  let didClosePreviousStream = false;
396
501
 
397
502
  const closePreviousStreamAfterTrack = () => {
503
+ // 이전 stream이 없거나 이미 닫았다면 no-op으로 둔다.
398
504
  if (!previousStreamKey || didClosePreviousStream) return;
399
505
 
400
506
  didClosePreviousStream = true;
507
+
508
+ // 현재 streamKey는 보존하고 같은 identity의 이전 registry entry만 정리한다.
401
509
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
402
510
  };
403
511
 
512
+ // 이후 effect 실행에서 같은 hook instance의 active stream을 판정할 수 있게 저장한다.
404
513
  activeStreamKeyRef.current = streamKey;
405
514
  activeStreamIdentityKeyRef.current = streamIdentityKey;
406
515
 
516
+ // registry.start는 같은 streamKey가 이미 있으면 새 WHEP POST 없이 기존 entry를 유지한다.
407
517
  streamRegistry.start({
408
518
  streamKey,
409
519
  identityKey: streamIdentityKey,
@@ -412,21 +522,32 @@ export function useCctvRtcStream({
412
522
  video: currentVideo,
413
523
  });
414
524
 
525
+ // 현재 video element를 registry entry에 붙여 MediaStream reattach 대상에 포함한다.
415
526
  const detachVideo = streamRegistry.attach(streamKey, currentVideo);
527
+
528
+ // registry snapshot 변경을 hook local state와 DOM video에 반영한다.
416
529
  const unsubscribe = streamRegistry.subscribe(streamKey, snapshot => {
417
530
  setConnectionState(snapshot.connectionState);
418
531
  setStreamError(snapshot.streamError);
419
532
  setStreaming(snapshot.isStreaming);
533
+
534
+ // 한 번이라도 connected가 되면 이후 장애를 post-connected recovery로 취급한다.
420
535
  if (snapshot.connectionState === "connected") {
421
536
  setHasConnected(true);
422
537
  }
538
+
539
+ // onTrack으로 MediaStream이 들어온 뒤에만 video.srcObject를 교체한다.
423
540
  if (snapshot.stream) {
424
541
  if (currentVideo.srcObject !== snapshot.stream) {
425
542
  currentVideo.srcObject = snapshot.stream;
426
543
  }
544
+
545
+ // 새 stream이 실제로 화면에 붙은 뒤 이전 token stream을 닫는다.
427
546
  closePreviousStreamAfterTrack();
428
547
  }
429
548
  });
549
+
550
+ // subscribe 직후 현재 snapshot을 즉시 반영해 기존 registry stream reattach 지연을 줄인다.
430
551
  const snapshot = streamRegistry.getSnapshot(streamKey);
431
552
  setConnectionState(snapshot.connectionState);
432
553
  setStreamError(snapshot.streamError);
@@ -434,9 +555,11 @@ export function useCctvRtcStream({
434
555
  if (snapshot.connectionState === "connected") setHasConnected(true);
435
556
 
436
557
  if (snapshot.stream) {
558
+ // 이미 registry에 MediaStream이 있으면 첫 render cycle에서도 즉시 video에 붙인다.
437
559
  if (currentVideo.srcObject !== snapshot.stream) {
438
560
  currentVideo.srcObject = snapshot.stream;
439
561
  }
562
+
440
563
  closePreviousStreamAfterTrack();
441
564
  }
442
565
 
@@ -456,16 +579,28 @@ export function useCctvRtcStream({
456
579
  ]);
457
580
 
458
581
  const reconnectStream = useCallback<CctvRtcReconnectTrigger>(
582
+ // public reconnect API는 React Query refetch를 그대로 감싸 token refresh trigger로 사용한다.
459
583
  options => refetchRtcToken(options),
460
584
  [refetchRtcToken],
461
585
  );
462
586
 
463
587
  const canReconnect = useMemo(() => {
588
+ // offline camera는 재연결 대상이 아니다.
464
589
  if (!cam?.cam_online) return false;
590
+
591
+ // 초기 연결 전 실패는 자동 reconnect UX가 아니라 일반 연결 오류로 둔다.
465
592
  if (!hasConnected) return false;
593
+
594
+ // identity가 없으면 어떤 registry stream을 재시도할지 알 수 없다.
466
595
  if (!streamIdentityKey) return false;
596
+
597
+ // 이미 token/stream 작업 중이면 중복 reconnect를 허용하지 않는다.
467
598
  if (isTokenLoading || isStreaming) return false;
599
+
600
+ // token/stream/failed 중 재연결 사유가 있어야 한다.
468
601
  if (!reconnectReason) return false;
602
+
603
+ // grace/stagger timer가 끝난 뒤에만 public reconnect 가능 상태를 연다.
469
604
  return isPostConnectedReconnectReady;
470
605
  }, [
471
606
  cam?.cam_online,
@@ -480,14 +615,20 @@ export function useCctvRtcStream({
480
615
  const refetchToken = reconnectStream;
481
616
 
482
617
  useEffect(() => {
618
+ // SSR/테스트 환경에서는 focus/visibility listener를 등록하지 않는다.
483
619
  if (typeof window === "undefined" || typeof document === "undefined")
484
620
  return;
485
621
 
486
622
  const reconnectOnFocus = () => {
623
+ // visibilitychange는 hidden 상태에서도 발생하므로 visible 복귀만 처리한다.
487
624
  if (document.visibilityState !== "visible") return;
625
+
626
+ // canReconnect gate가 닫혀 있으면 focus 복귀가 token refetch를 만들지 않는다.
488
627
  if (!canReconnect) return;
489
628
 
490
629
  const now = Date.now();
630
+
631
+ // 브라우저 focus와 visibilitychange가 연달아 들어와도 reconnect는 interval당 한 번만 호출한다.
491
632
  if (now - lastAutoReconnectAtRef.current < AUTO_RECONNECT_INTERVAL_MS)
492
633
  return;
493
634
 
@@ -495,17 +636,70 @@ export function useCctvRtcStream({
495
636
  void reconnectStream();
496
637
  };
497
638
 
639
+ // focus와 visibility 복귀 둘 다 장시간 미사용 후 재연결 trigger로 본다.
498
640
  window.addEventListener("focus", reconnectOnFocus);
499
641
  document.addEventListener("visibilitychange", reconnectOnFocus);
500
642
 
501
643
  return () => {
644
+ // hook unmount 시 전역 listener 누수를 막는다.
502
645
  window.removeEventListener("focus", reconnectOnFocus);
503
646
  document.removeEventListener("visibilitychange", reconnectOnFocus);
504
647
  };
505
648
  }, [canReconnect, reconnectStream]);
506
649
 
650
+ const streamStage = useMemo<CctvRtcStreamStage>(() => {
651
+ // cam이 없으면 hook consumer가 아직 stream 대상 없이 렌더된 상태다.
652
+ if (!cam) return "idle";
653
+
654
+ // offline camera는 scheduler/token/WHEP 대상에서 제외된 별도 상태로 표시한다.
655
+ if (!cam.cam_online) return "skipped-offline";
656
+
657
+ // token 또는 WHEP 에러는 다른 loading/queued 상태보다 우선한다.
658
+ if (isTokenError || streamError) return "error";
659
+
660
+ // post-connected 장애가 grace/stagger 이후 재연결 가능한 상태로 열린 경우다.
661
+ if (canReconnect) return "recovering";
662
+
663
+ // scheduler grant 전 대기 상태를 token-loading과 분리한다.
664
+ if (isWaitingForStreamGrant) return "queued";
665
+
666
+ // token query가 실제로 실행 중인 상태다.
667
+ if (displayIsTokenLoading) return "token-loading";
668
+
669
+ // WHEP offer/answer 또는 registry start가 진행 중인 상태다.
670
+ if (displayIsStreaming) return "connecting";
671
+
672
+ // UI 표시 기준 연결 상태가 connected면 live stage로 본다.
673
+ if (displayConnectionState === "connected") return "live";
674
+
675
+ // WebRTC가 아직 connected 전이면 사용자에게 연결 중으로 표시한다.
676
+ if (
677
+ displayConnectionState === "new" ||
678
+ displayConnectionState === "connecting" ||
679
+ displayConnectionState === "disconnected"
680
+ ) {
681
+ return "connecting";
682
+ }
683
+
684
+ // RTCPeerConnection failed는 streamError가 없어도 오류 stage로 승격한다.
685
+ if (displayConnectionState === "failed") return "error";
686
+
687
+ // 위 조건에 걸리지 않는 closed/unknown 계열은 기본 idle로 둔다.
688
+ return "idle";
689
+ }, [
690
+ cam,
691
+ canReconnect,
692
+ displayConnectionState,
693
+ displayIsStreaming,
694
+ displayIsTokenLoading,
695
+ isTokenError,
696
+ isWaitingForStreamGrant,
697
+ streamError,
698
+ ]);
699
+
507
700
  const liveState = useMemo(
508
701
  () =>
702
+ // live registry에는 cam이 있을 때만 util 판정 결과를 반영한다.
509
703
  cam
510
704
  ? getIsLive({
511
705
  cam,
@@ -527,11 +721,14 @@ export function useCctvRtcStream({
527
721
  );
528
722
 
529
723
  useEffect(() => {
724
+ // cam_id가 없으면 live registry key를 만들 수 없다.
530
725
  const camId = cam?.cam_id;
531
726
  if (!camId) return;
532
727
 
728
+ // 같은 cam을 여러 위치에서 렌더할 수 있어 instance 단위 live state를 보관한다.
533
729
  const instanceKey = instanceKeyRef.current as string;
534
730
  setLiveRegistry(prev => {
731
+ // jotai atom update는 기존 cam map을 얕은 복사해 React 변경 감지를 보장한다.
535
732
  const next = { ...prev };
536
733
  const perCam = { ...(next[camId] ?? {}) };
537
734
  perCam[instanceKey] = liveState;
@@ -541,15 +738,19 @@ export function useCctvRtcStream({
541
738
  }, [cam?.cam_id, liveState, setLiveRegistry]);
542
739
 
543
740
  useEffect(() => {
741
+ // unmount cleanup도 cam_id 기준이라 cam이 없으면 등록할 cleanup이 없다.
544
742
  const camId = cam?.cam_id;
545
743
  if (!camId) return;
546
744
 
547
745
  const instanceKey = instanceKeyRef.current as string;
548
746
  return () => {
747
+ // 현재 hook instance만 제거하고 같은 cam의 다른 렌더 위치는 유지한다.
549
748
  setLiveRegistry(prev => {
550
749
  const next = { ...prev };
551
750
  const perCam = { ...(next[camId] ?? {}) };
552
751
  delete perCam[instanceKey];
752
+
753
+ // 마지막 instance가 사라지면 cam entry 자체를 제거한다.
553
754
  if (Object.keys(perCam).length === 0) {
554
755
  delete next[camId];
555
756
  } else {
@@ -568,6 +769,7 @@ export function useCctvRtcStream({
568
769
  isStreaming: displayIsStreaming,
569
770
  isTokenLoading: displayIsTokenLoading,
570
771
  isTokenError,
772
+ streamStage,
571
773
  canReconnect,
572
774
  refetchToken,
573
775
  reconnectStream,
@@ -1,4 +1,5 @@
1
1
  .cctv-cam-list-container {
2
+ container: cctv-cam-list / inline-size;
2
3
  width: 100%;
3
4
  height: 100%;
4
5
 
@@ -19,8 +20,8 @@
19
20
  width: 100%;
20
21
  grid-template-columns: repeat(
21
22
  auto-fit,
22
- minmax(320px, 1fr)
23
- ); // 최소 너비 비율을 4:3으로 맞춤
23
+ minmax(min(100%, var(--cctv-list-item-min-width)), 1fr)
24
+ );
24
25
  gap: var(--cctv-list-gap);
25
26
  padding: 0;
26
27
  margin: 0;
@@ -33,3 +34,28 @@
33
34
  .cctv-cam-list-item .cctv-video-container {
34
35
  max-height: 240px;
35
36
  }
37
+
38
+ // 4:3 card ratio는 video container가 책임지고, list는 320px card + 12px gap 기준으로 열 수만 조정한다.
39
+ @container cctv-cam-list (min-width: 652px) {
40
+ .cctv-cam-list-track {
41
+ grid-template-columns: repeat(2, minmax(0, 1fr));
42
+ }
43
+ }
44
+
45
+ @container cctv-cam-list (min-width: 984px) {
46
+ .cctv-cam-list-track {
47
+ grid-template-columns: repeat(3, minmax(0, 1fr));
48
+ }
49
+ }
50
+
51
+ @container cctv-cam-list (min-width: 1316px) {
52
+ .cctv-cam-list-track {
53
+ grid-template-columns: repeat(4, minmax(0, 1fr));
54
+ }
55
+ }
56
+
57
+ @container cctv-cam-list (min-width: 1648px) {
58
+ .cctv-cam-list-track {
59
+ grid-template-columns: repeat(5, minmax(0, 1fr));
60
+ }
61
+ }
@@ -2,6 +2,7 @@
2
2
  /* Card layout */
3
3
  --cctv-video-radius: 12px;
4
4
  --cctv-list-gap: var(--spacing-gap-5);
5
+ --cctv-list-item-min-width: 320px;
5
6
 
6
7
  --cctv-overlay-padding-x: var(--spacing-padding-8);
7
8
  --cctv-overlay-padding-y: var(--spacing-padding-6);
@@ -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
  */