@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.
- package/package.json +1 -1
- package/src/cctv/apis/client.ts +56 -2
- 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 +218 -18
- 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,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 =
|
|
307
|
-
|
|
308
|
-
|
|
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
|
*/
|
package/src/cctv/types/hook.ts
CHANGED
|
@@ -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 함수
|