@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.
- package/dist/styles.css +23 -1
- package/package.json +7 -7
- package/src/cctv/apis/client.ts +173 -11
- package/src/cctv/components/Provider.tsx +66 -8
- package/src/cctv/components/cam-list/Item.tsx +6 -0
- package/src/cctv/components/pagination/list/Item.tsx +20 -0
- package/src/cctv/components/video/Template.tsx +3 -0
- package/src/cctv/components/viewer/desktop/Video.tsx +1 -0
- package/src/cctv/hooks/streamScheduler.ts +276 -0
- package/src/cctv/hooks/useRtcStream.ts +222 -20
- package/src/cctv/styles/cam-list.scss +28 -2
- package/src/cctv/styles/variables.scss +1 -0
- package/src/cctv/types/context.ts +21 -0
- package/src/cctv/types/hook.ts +24 -0
- package/src/cctv/types/props.ts +12 -1
- package/src/cctv/types/video-state.ts +18 -3
- package/src/cctv/utils/video-state.ts +3 -0
|
@@ -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:
|
|
274
|
+
url: tokenEndpointUrl,
|
|
192
275
|
});
|
|
193
276
|
|
|
194
277
|
const { refetch: refetchRtcToken } = tokenQuery;
|
|
195
278
|
const isTokenLoading = tokenQuery.isFetching;
|
|
196
|
-
const
|
|
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 =
|
|
307
|
-
|
|
308
|
-
|
|
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(
|
|
23
|
-
);
|
|
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
|
+
}
|
|
@@ -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
|
*/
|