@uniai-fe/uds-templates 0.6.12 → 0.6.14

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.6.12",
3
+ "version": "0.6.14",
4
4
  "description": "UNIAI Design System; UI Templates Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { startWhepStream, type WhepStreamHandle } from "@uniai-fe/util-rtc";
4
4
  import {
5
+ getCctvDebugJwtMetadata,
5
6
  getCctvDebugKeyLabel,
6
7
  getCctvDebugUrlLabel,
7
8
  logCctvDebugEvent,
@@ -31,6 +32,8 @@ interface CctvRtcStreamStartParams {
31
32
  video: HTMLVideoElement;
32
33
  }
33
34
 
35
+ const WHEP_ERROR_BODY_PREVIEW_LIMIT = 1000;
36
+
34
37
  const getEntrySnapshot = (
35
38
  entry?: CctvRtcStreamEntry,
36
39
  ): CctvRtcStreamSnapshot => ({
@@ -46,11 +49,70 @@ const attachVideo = (video: HTMLVideoElement, stream: MediaStream | null) => {
46
49
  video.srcObject = stream;
47
50
  };
48
51
 
52
+ const clearVideoIfAttachedStream = (
53
+ video: HTMLVideoElement,
54
+ stream: MediaStream | null,
55
+ ) => {
56
+ if (stream && video.srcObject === stream) {
57
+ video.srcObject = null;
58
+ }
59
+ };
60
+
49
61
  const notifyEntry = (entry: CctvRtcStreamEntry) => {
50
62
  const snapshot = getEntrySnapshot(entry);
51
63
  entry.listeners.forEach(listener => listener(snapshot));
52
64
  };
53
65
 
66
+ const getWhepResponseBodyPreview = async (
67
+ response: Response,
68
+ ): Promise<string | null> => {
69
+ try {
70
+ const text = await response.clone().text();
71
+ return text.slice(0, WHEP_ERROR_BODY_PREVIEW_LIMIT);
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ /**
78
+ * WHEP 실패 body를 startWhepStream 외부에서 관찰하기 위한 fetch wrapper.
79
+ * @desc
80
+ * 성공 응답은 그대로 통과시키고, 실패 응답에서만 clone body를 읽어 debug log에 남긴다.
81
+ */
82
+ const createCctvDebugWhepFetcher =
83
+ ({
84
+ endpoint,
85
+ identityKey,
86
+ streamKey,
87
+ token,
88
+ }: {
89
+ endpoint: string;
90
+ identityKey: string;
91
+ streamKey: string;
92
+ token: string;
93
+ }): typeof fetch =>
94
+ async (input, init) => {
95
+ const response = await fetch(input, init);
96
+ if (response.ok) return response;
97
+
98
+ logCctvDebugEvent({
99
+ event: "whep:response-error",
100
+ level: "error",
101
+ payload: {
102
+ endpoint: getCctvDebugUrlLabel(endpoint),
103
+ identityKey: getCctvDebugKeyLabel(identityKey),
104
+ responseBody: await getWhepResponseBodyPreview(response),
105
+ status: response.status,
106
+ statusText: response.statusText,
107
+ streamKey: getCctvDebugKeyLabel(streamKey),
108
+ token: getCctvDebugJwtMetadata(token),
109
+ },
110
+ source: "streamRegistry",
111
+ });
112
+
113
+ return response;
114
+ };
115
+
54
116
  const closeEntry = (entry: CctvRtcStreamEntry) => {
55
117
  logCctvDebugEvent({
56
118
  event: "entry:close",
@@ -72,9 +134,7 @@ const closeEntry = (entry: CctvRtcStreamEntry) => {
72
134
  entry.isStreaming = false;
73
135
 
74
136
  entry.attachedVideos.forEach(video => {
75
- if (!entry.stream || video.srcObject === entry.stream) {
76
- video.srcObject = null;
77
- }
137
+ clearVideoIfAttachedStream(video, entry.stream);
78
138
  });
79
139
 
80
140
  notifyEntry(entry);
@@ -113,13 +173,13 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
113
173
  }
114
174
 
115
175
  entry.attachedVideos.add(video);
116
- attachVideo(video, entry.stream);
176
+ if (entry.stream || !video.srcObject) {
177
+ attachVideo(video, entry.stream);
178
+ }
117
179
 
118
180
  return () => {
119
181
  entry.attachedVideos.delete(video);
120
- if (!entry.stream || video.srcObject === entry.stream) {
121
- video.srcObject = null;
122
- }
182
+ clearVideoIfAttachedStream(video, entry.stream);
123
183
  };
124
184
  },
125
185
  closeAll() {
@@ -180,7 +240,12 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
180
240
  };
181
241
 
182
242
  entries.set(streamKey, entry);
183
- attachVideo(video, null);
243
+ if (!video.srcObject) {
244
+ attachVideo(video, null);
245
+ } else {
246
+ video.playsInline = true;
247
+ video.autoplay = true;
248
+ }
184
249
  logCctvDebugEvent({
185
250
  event: "start:create-entry",
186
251
  payload: {
@@ -188,12 +253,19 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
188
253
  endpoint: getCctvDebugUrlLabel(endpoint),
189
254
  identityKey: getCctvDebugKeyLabel(identityKey),
190
255
  streamKey: getCctvDebugKeyLabel(streamKey),
256
+ token: getCctvDebugJwtMetadata(token),
191
257
  },
192
258
  source: "streamRegistry",
193
259
  });
194
260
 
195
261
  startWhepStream({
196
262
  endpoint,
263
+ fetcher: createCctvDebugWhepFetcher({
264
+ endpoint,
265
+ identityKey,
266
+ streamKey,
267
+ token,
268
+ }),
197
269
  token,
198
270
  video,
199
271
  signal: controller.signal,
@@ -268,7 +340,7 @@ export function createCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
268
340
  source: "streamRegistry",
269
341
  });
270
342
  entry.attachedVideos.forEach(attachedVideo =>
271
- attachVideo(attachedVideo, null),
343
+ clearVideoIfAttachedStream(attachedVideo, entry.stream),
272
344
  );
273
345
  notifyEntry(entry);
274
346
  });
@@ -140,8 +140,8 @@ const canUseTokenForNewConnection = ({
140
140
  * videoRef, // WebRTC MediaStream을 연결할 video 요소 ref
141
141
  * connectionState, // RTCPeerConnectionState 상태값
142
142
  * streamError, // 스트림 시도 중 발생한 오류 메시지
143
- * isStreaming, // 현재 스트림 연결 절차가 진행 중인지 여부
144
- * isTokenLoading, // 토큰 발급 요청이 진행 중인지 여부
143
+ * isStreaming, // UI 표시 기준 스트림 연결 절차 진행 여부
144
+ * isTokenLoading, // UI 표시 기준 토큰 발급 요청 진행 여부
145
145
  * isTokenError, // 토큰 발급 요청이 실패했는지 여부
146
146
  * canReconnect, // UDS 내부 grace/stagger 이후 재연결 호출이 가능한지 여부
147
147
  * refetchToken, // 토큰 발급을 재시도하는 함수
@@ -328,16 +328,30 @@ export function useCctvRtcStream({
328
328
  streamIdentityKey,
329
329
  ]);
330
330
 
331
- const shouldPreserveConnectedDisplay =
331
+ const isPostConnectedReplacementLoading =
332
332
  hasConnected &&
333
- !isPostConnectedReconnectReady &&
334
- DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState);
333
+ Boolean(streamIdentityKey) &&
334
+ !isTokenError &&
335
+ !streamError &&
336
+ (isTokenLoading || isStreaming);
337
+
338
+ const shouldPreserveConnectedDisplay =
339
+ isPostConnectedReplacementLoading ||
340
+ (hasConnected &&
341
+ !isPostConnectedReconnectReady &&
342
+ DISPLAY_CONNECTED_DURING_RECOVERY_STATES.has(connectionState));
335
343
 
336
344
  // 반환 state는 CamList/Viewer/overlay의 live/error/message 계산에 직접 쓰인다.
337
- // post-connected disconnected는 자동 재연결 대신 기존 화면을 유지해 UI reset 파동을 막는다.
345
+ // post-connected recovery/replacement 중에는 기존 화면을 유지해 UI reset 파동을 막는다.
338
346
  const displayConnectionState = shouldPreserveConnectedDisplay
339
347
  ? "connected"
340
348
  : connectionState;
349
+ const displayIsStreaming = shouldPreserveConnectedDisplay
350
+ ? false
351
+ : isStreaming;
352
+ const displayIsTokenLoading = shouldPreserveConnectedDisplay
353
+ ? false
354
+ : isTokenLoading;
341
355
 
342
356
  const skippedDisconnectedDebugKeyRef = useRef<string | null>(null);
343
357
 
@@ -468,20 +482,39 @@ export function useCctvRtcStream({
468
482
  if (!streamKey || !tokenQuery.data?.token || !endpoint || !currentVideo)
469
483
  return;
470
484
 
471
- if (
485
+ const previousStreamKey =
472
486
  activeStreamIdentityKeyRef.current === streamIdentityKey &&
473
487
  activeStreamKeyRef.current !== streamKey
474
- ) {
488
+ ? activeStreamKeyRef.current
489
+ : null;
490
+ let didClosePreviousStream = false;
491
+
492
+ const closePreviousStreamAfterTrack = () => {
493
+ if (!previousStreamKey || didClosePreviousStream) return;
494
+
495
+ didClosePreviousStream = true;
475
496
  logCctvDebugEvent({
476
- event: "stream-effect:close-previous-identity",
497
+ event: "stream-effect:close-previous-after-track",
477
498
  payload: {
478
499
  ...debugBasePayload,
479
- activeStreamKey: getCctvDebugKeyLabel(activeStreamKeyRef.current),
480
- nextStreamKey: getCctvDebugKeyLabel(streamKey),
500
+ previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
501
+ streamKey: getCctvDebugKeyLabel(streamKey),
481
502
  },
482
503
  source: "useRtcStream",
483
504
  });
484
505
  streamRegistry.closeByIdentity(streamIdentityKey, streamKey);
506
+ };
507
+
508
+ if (previousStreamKey) {
509
+ logCctvDebugEvent({
510
+ event: "stream-effect:prepare-replacement",
511
+ payload: {
512
+ ...debugBasePayload,
513
+ previousStreamKey: getCctvDebugKeyLabel(previousStreamKey),
514
+ streamKey: getCctvDebugKeyLabel(streamKey),
515
+ },
516
+ source: "useRtcStream",
517
+ });
485
518
  }
486
519
 
487
520
  activeStreamKeyRef.current = streamKey;
@@ -520,8 +553,11 @@ export function useCctvRtcStream({
520
553
  });
521
554
  setHasConnected(true);
522
555
  }
523
- if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
524
- currentVideo.srcObject = snapshot.stream;
556
+ if (snapshot.stream) {
557
+ if (currentVideo.srcObject !== snapshot.stream) {
558
+ currentVideo.srcObject = snapshot.stream;
559
+ }
560
+ closePreviousStreamAfterTrack();
525
561
  }
526
562
  });
527
563
  const snapshot = streamRegistry.getSnapshot(streamKey);
@@ -530,8 +566,11 @@ export function useCctvRtcStream({
530
566
  setStreaming(snapshot.isStreaming);
531
567
  if (snapshot.connectionState === "connected") setHasConnected(true);
532
568
 
533
- if (snapshot.stream && currentVideo.srcObject !== snapshot.stream) {
534
- currentVideo.srcObject = snapshot.stream;
569
+ if (snapshot.stream) {
570
+ if (currentVideo.srcObject !== snapshot.stream) {
571
+ currentVideo.srcObject = snapshot.stream;
572
+ }
573
+ closePreviousStreamAfterTrack();
535
574
  }
536
575
 
537
576
  // effect cleanup: current video attach만 해제하고 registry stream은 유지한다.
@@ -602,17 +641,9 @@ export function useCctvRtcStream({
602
641
  },
603
642
  source: "useRtcStream",
604
643
  });
605
- if (result.isSuccess && streamIdentityKey) {
606
- logCctvDebugEvent({
607
- event: "reconnect-stream:close-identity",
608
- payload: resultDebugState.debugBasePayload,
609
- source: "useRtcStream",
610
- });
611
- streamRegistry.closeByIdentity(streamIdentityKey);
612
- }
613
644
  return result;
614
645
  },
615
- [refetchRtcToken, streamIdentityKey, streamRegistry],
646
+ [refetchRtcToken],
616
647
  );
617
648
 
618
649
  const canReconnect = useMemo(() => {
@@ -681,18 +712,18 @@ export function useCctvRtcStream({
681
712
  ? getIsLive({
682
713
  cam,
683
714
  connectionState: displayConnectionState,
684
- isTokenLoading,
715
+ isTokenLoading: displayIsTokenLoading,
685
716
  isTokenError,
686
- isStreaming,
717
+ isStreaming: displayIsStreaming,
687
718
  streamError,
688
719
  })
689
720
  : false,
690
721
  [
691
722
  cam,
692
723
  displayConnectionState,
693
- isTokenLoading,
724
+ displayIsTokenLoading,
694
725
  isTokenError,
695
- isStreaming,
726
+ displayIsStreaming,
696
727
  streamError,
697
728
  ],
698
729
  );
@@ -706,7 +737,10 @@ export function useCctvRtcStream({
706
737
  connectionState,
707
738
  displayConnectionState,
708
739
  hasConnected,
740
+ isPostConnectedReplacementLoading,
709
741
  isPostConnectedReconnectReady,
742
+ displayIsStreaming,
743
+ displayIsTokenLoading,
710
744
  isStreaming,
711
745
  isTokenError,
712
746
  isTokenLoading,
@@ -721,7 +755,10 @@ export function useCctvRtcStream({
721
755
  connectionState,
722
756
  debugBasePayload,
723
757
  displayConnectionState,
758
+ displayIsStreaming,
759
+ displayIsTokenLoading,
724
760
  hasConnected,
761
+ isPostConnectedReplacementLoading,
725
762
  isPostConnectedReconnectReady,
726
763
  isStreaming,
727
764
  isTokenError,
@@ -770,8 +807,8 @@ export function useCctvRtcStream({
770
807
  videoRef,
771
808
  connectionState: displayConnectionState,
772
809
  streamError,
773
- isStreaming,
774
- isTokenLoading,
810
+ isStreaming: displayIsStreaming,
811
+ isTokenLoading: displayIsTokenLoading,
775
812
  isTokenError,
776
813
  canReconnect,
777
814
  refetchToken,
@@ -60,8 +60,8 @@ export interface UseCctvRtcStreamError {
60
60
  /**
61
61
  * CCTV; useCctvRtcStream 연결 상태
62
62
  * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
63
- * @property {boolean} isStreaming startWhepStream 진행 여부
64
- * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
63
+ * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
64
+ * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
65
65
  * @property {boolean} isTokenError 토큰 발급 실패 여부
66
66
  * @property {string | null} streamError 스트림 오류 메시지
67
67
  */
@@ -71,11 +71,17 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
71
71
  */
72
72
  connectionState: RTCPeerConnectionState;
73
73
  /**
74
- * startWhepStream 진행 여부
74
+ * UI 표시 기준 startWhepStream 진행 여부
75
+ * @desc
76
+ * 최초 연결 전에는 실제 진행 상태를 나타낸다. 이미 한 번 연결된 stream을 교체하는 동안에는
77
+ * 기존 화면과 live count를 유지하기 위해 false로 smoothing될 수 있다.
75
78
  */
76
79
  isStreaming: boolean;
77
80
  /**
78
- * 토큰 발급 요청 진행 여부
81
+ * UI 표시 기준 토큰 발급 요청 진행 여부
82
+ * @desc
83
+ * 최초 연결 전에는 실제 요청 상태를 나타낸다. 이미 한 번 연결된 stream의 재연결 token
84
+ * 재발급 중에는 기존 화면과 live count를 유지하기 위해 false로 smoothing될 수 있다.
79
85
  */
80
86
  isTokenLoading: boolean;
81
87
  }
@@ -85,8 +91,8 @@ export interface UseCctvRtcStreamState extends UseCctvRtcStreamError {
85
91
  * @property {React.RefObject<HTMLVideoElement>} videoRef <video /> ref
86
92
  * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
87
93
  * @property {string | null} streamError 스트림 오류 메시지
88
- * @property {boolean} isStreaming startWhepStream 진행 여부
89
- * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
94
+ * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
95
+ * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
90
96
  * @property {boolean} isTokenError 토큰 발급 실패 여부
91
97
  * @property {boolean} canReconnect UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
92
98
  * @property {() => Promise<void>} refetchToken 토큰 재발급 함수
@@ -7,8 +7,8 @@ import type { UseCctvRtcStreamError, UseCctvRtcStreamState } from "./hook";
7
7
  * @property {boolean} [hasCamProp] 직접 cam prop을 받은 경우 true
8
8
  * @property {boolean} [isFetching] 서버에서 cam 리스트를 가져오는 중인지 여부
9
9
  * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
10
- * @property {boolean} isStreaming startWhepStream 진행 여부
11
- * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
10
+ * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
11
+ * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
12
12
  * @property {boolean} isTokenError 토큰 발급 실패 여부
13
13
  * @property {string | null} streamError 스트림 오류 메시지
14
14
  * @property {boolean} [canReconnect] UDS 내부 허용 사유와 grace/stagger를 통과해 재연결 호출이 가능한지 여부
@@ -36,8 +36,8 @@ export interface CctvVideoOverlayMessageParams extends UseCctvRtcStreamState {
36
36
  * CCTV; getIsLive() params
37
37
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
38
38
  * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
39
- * @property {boolean} isStreaming startWhepStream 진행 여부
40
- * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
39
+ * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
40
+ * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
41
41
  * @property {boolean} isTokenError 토큰 발급 실패 여부
42
42
  * @property {string | null} streamError 스트림 오류 메시지
43
43
  */
@@ -32,6 +32,15 @@ export interface CctvDebugSummary {
32
32
  total: number;
33
33
  }
34
34
 
35
+ export interface CctvDebugJwtMetadata {
36
+ expiresAt: string | null;
37
+ expiresInSec: number | null;
38
+ headerAlg: string | null;
39
+ headerKid: string | null;
40
+ tokenLabel: string | null;
41
+ tokenPartCount: number;
42
+ }
43
+
35
44
  export interface CctvDebugBuffer {
36
45
  clear: () => void;
37
46
  dump: () => CctvDebugEvent[];
@@ -108,6 +117,13 @@ const getDebugStorageOverride = (keys: readonly string[]): boolean | null => {
108
117
  return null;
109
118
  };
110
119
 
120
+ const getDebugConsoleOverride = (): boolean | null => {
121
+ const queryOverride = getDebugQueryOverride(DEBUG_CONSOLE_QUERY_KEYS);
122
+ if (queryOverride !== null) return queryOverride;
123
+
124
+ return getDebugStorageOverride(DEBUG_CONSOLE_STORAGE_KEYS);
125
+ };
126
+
111
127
  const isLocalhostDebugDefaultEnabled = (): boolean => {
112
128
  const { hostname } = window.location;
113
129
  return (
@@ -221,6 +237,57 @@ const summarizeCctvDebugEvents = (
221
237
  };
222
238
  };
223
239
 
240
+ const decodeBase64UrlJson = (value: string): Record<string, unknown> | null => {
241
+ try {
242
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
243
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
244
+ const parsed = JSON.parse(atob(padded));
245
+
246
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
247
+ ? (parsed as Record<string, unknown>)
248
+ : null;
249
+ } catch {
250
+ return null;
251
+ }
252
+ };
253
+
254
+ const getJwtStringClaim = (
255
+ claims: Record<string, unknown> | null,
256
+ key: string,
257
+ ): string | null => {
258
+ const value = claims?.[key];
259
+ return typeof value === "string" ? value : null;
260
+ };
261
+
262
+ const getJwtExpiresAt = (
263
+ claims: Record<string, unknown> | null,
264
+ ): { expiresAt: string | null; expiresInSec: number | null } => {
265
+ const exp = claims?.exp;
266
+ if (typeof exp !== "number" || !Number.isFinite(exp)) {
267
+ return { expiresAt: null, expiresInSec: null };
268
+ }
269
+
270
+ const expiresAtMs = exp * 1000;
271
+ return {
272
+ expiresAt: new Date(expiresAtMs).toISOString(),
273
+ expiresInSec: Math.floor((expiresAtMs - Date.now()) / 1000),
274
+ };
275
+ };
276
+
277
+ const stringifyCctvDebugEvent = (entry: CctvDebugEvent): string => {
278
+ try {
279
+ return JSON.stringify(entry);
280
+ } catch {
281
+ return JSON.stringify({
282
+ at: entry.at,
283
+ event: entry.event,
284
+ level: entry.level,
285
+ seq: entry.seq,
286
+ source: entry.source,
287
+ });
288
+ }
289
+ };
290
+
224
291
  /**
225
292
  * identity/stream key를 원문 대신 추적 가능한 hash label로 바꾼다.
226
293
  */
@@ -253,11 +320,37 @@ export const getCctvDebugUrlLabel = (
253
320
  }
254
321
  };
255
322
 
323
+ /**
324
+ * JWT 원문 대신 헤더/만료시각과 hash label만 기록한다.
325
+ * @desc
326
+ * token signature mismatch를 추적하기 위한 메타 정보이며, Bearer token 원문과 payload claim은
327
+ * debug log에 남기지 않는다.
328
+ */
329
+ export const getCctvDebugJwtMetadata = (
330
+ token: string | null | undefined,
331
+ ): CctvDebugJwtMetadata | null => {
332
+ if (!token) return null;
333
+
334
+ const [headerPart, payloadPart] = token.split(".");
335
+ const header = headerPart ? decodeBase64UrlJson(headerPart) : null;
336
+ const payload = payloadPart ? decodeBase64UrlJson(payloadPart) : null;
337
+ const { expiresAt, expiresInSec } = getJwtExpiresAt(payload);
338
+
339
+ return {
340
+ expiresAt,
341
+ expiresInSec,
342
+ headerAlg: getJwtStringClaim(header, "alg"),
343
+ headerKid: getJwtStringClaim(header, "kid"),
344
+ tokenLabel: getCctvDebugKeyLabel(token),
345
+ tokenPartCount: token.split(".").length,
346
+ };
347
+ };
348
+
256
349
  /**
257
350
  * CCTV debug logging 활성화 여부를 확인한다.
258
351
  * @desc
259
- * production default는 off다. `?udsCctvDebug=1` 또는
260
- * `localStorage.setItem("uds:cctv:debug", "1")`로 runtime에서 있다.
352
+ * production default는 off, localhost default는 ring buffer on이다.
353
+ * console 출력은 별도 console flag를 켠 경우에만 활성화한다.
261
354
  */
262
355
  export const isCctvDebugEnabled = (): boolean => {
263
356
  if (!isBrowser()) return false;
@@ -265,6 +358,9 @@ export const isCctvDebugEnabled = (): boolean => {
265
358
  const queryOverride = getDebugQueryOverride(DEBUG_QUERY_KEYS);
266
359
  if (queryOverride !== null) return queryOverride;
267
360
 
361
+ const consoleOverride = getDebugConsoleOverride();
362
+ if (consoleOverride === true) return true;
363
+
268
364
  const storageOverride = getDebugStorageOverride(DEBUG_STORAGE_KEYS);
269
365
  if (storageOverride !== null) return storageOverride;
270
366
 
@@ -274,19 +370,7 @@ export const isCctvDebugEnabled = (): boolean => {
274
370
  const isCctvDebugConsoleEnabled = (): boolean => {
275
371
  if (!isBrowser()) return false;
276
372
 
277
- const consoleQueryOverride = getDebugQueryOverride(DEBUG_CONSOLE_QUERY_KEYS);
278
- if (consoleQueryOverride !== null) return consoleQueryOverride;
279
-
280
- const consoleStorageOverride = getDebugStorageOverride(
281
- DEBUG_CONSOLE_STORAGE_KEYS,
282
- );
283
- if (consoleStorageOverride !== null) return consoleStorageOverride;
284
-
285
- return (
286
- getDebugQueryOverride(DEBUG_QUERY_KEYS) === true ||
287
- getDebugStorageOverride(DEBUG_STORAGE_KEYS) === true ||
288
- isLocalhostDebugDefaultEnabled()
289
- );
373
+ return getDebugConsoleOverride() === true;
290
374
  };
291
375
 
292
376
  /**
@@ -373,5 +457,5 @@ export const logCctvDebugEvent = ({
373
457
  ? console.info
374
458
  : console.debug;
375
459
 
376
- logger.call(console, "[UDS:CCTV]", entry);
460
+ logger.call(console, `[UDS:CCTV] ${stringifyCctvDebugEvent(entry)}`);
377
461
  };
@@ -32,8 +32,8 @@ const RTC_SESSION_ENDED_RECONNECT_STATES = new Set<RTCPeerConnectionState>([
32
32
  * @property {boolean} [hasCamProp] 직접 cam prop을 받은 경우 true
33
33
  * @property {boolean} [isFetching] 서버에서 cam 리스트를 가져오는 중인지 여부
34
34
  * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
35
- * @property {boolean} isStreaming startWhepStream 진행 여부
36
- * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
35
+ * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
36
+ * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
37
37
  * @property {boolean} isTokenError 토큰 발급 실패 여부
38
38
  * @property {string | null} streamError 스트림 오류 메시지
39
39
  * @returns {string | null} 상태에 따른 메시지 반환 (문제가 없으면 null)
@@ -73,8 +73,8 @@ export function getOverlayMessage({
73
73
  * @param {CctvVideoLiveParams} params 상태 파라미터
74
74
  * @property {CctvCompanyCameraData} [cam] 카메라 데이터
75
75
  * @property {RTCPeerConnectionState} connectionState WebRTC 연결 상태
76
- * @property {boolean} isStreaming startWhepStream 진행 여부
77
- * @property {boolean} isTokenLoading 토큰 발급 요청 진행 여부
76
+ * @property {boolean} isStreaming UI 표시 기준 startWhepStream 진행 여부
77
+ * @property {boolean} isTokenLoading UI 표시 기준 토큰 발급 요청 진행 여부
78
78
  * @property {boolean} isTokenError 토큰 발급 실패 여부
79
79
  * @property {string | null} streamError 스트림 오류 메시지
80
80
  * @returns {boolean} 라이브 상태 여부 반환