@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
package/package.json
CHANGED
package/src/cctv/apis/client.ts
CHANGED
|
@@ -7,16 +7,42 @@ import type {
|
|
|
7
7
|
API_Res_CctvRtcToken,
|
|
8
8
|
} from "../types";
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* CCTV; RTC token fallback max age
|
|
12
|
+
* @desc
|
|
13
|
+
* JWT exp를 읽을 수 없는 token은 React Query dataUpdatedAt 기준으로 이 시간까지만 신규 WHEP 연결에 재사용한다.
|
|
14
|
+
*/
|
|
10
15
|
export const CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* CCTV; RTC token query cache retention
|
|
19
|
+
* @desc
|
|
20
|
+
* focus/reconnect 자동 refetch는 막되, 오래된 token이 무기한 신규 연결에 쓰이지 않게 fallback max age와 동일하게 제한한다.
|
|
21
|
+
*/
|
|
11
22
|
export const CCTV_RTC_TOKEN_GC_TIME_MS = CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS;
|
|
12
23
|
|
|
24
|
+
/**
|
|
25
|
+
* CCTV; client company-list fetcher
|
|
26
|
+
* @param {object} params 요청 파라미터
|
|
27
|
+
* @param {string} [params.url] service에서 주입한 company-list API 경로
|
|
28
|
+
* @returns {Promise<API_Res_CctvCompany>} CCTV company-list 응답
|
|
29
|
+
*/
|
|
13
30
|
export const getClientCctvCompanyList = async ({
|
|
14
31
|
url,
|
|
15
32
|
}: {
|
|
16
33
|
url?: string;
|
|
17
34
|
}): Promise<API_Res_CctvCompany> =>
|
|
35
|
+
// Provider에서 listUrl을 주입하지 않으면 UDS 기본 Next route를 사용한다.
|
|
18
36
|
await (await fetch(url ?? "/api/cctv/company-list")).json();
|
|
19
37
|
|
|
38
|
+
/**
|
|
39
|
+
* CCTV; company-list query hook
|
|
40
|
+
* @hook
|
|
41
|
+
* @param {object} params query 파라미터
|
|
42
|
+
* @param {string} [params.url] service에서 주입한 company-list API 경로
|
|
43
|
+
* @param {readonly unknown[]} [params.queryKeyDeps] service별 목록 cache 분리용 key dependency
|
|
44
|
+
* @returns {UseQueryResult<API_Res_CctvCompany>} company-list query 결과
|
|
45
|
+
*/
|
|
20
46
|
export const useQueryCctvCompanyList = ({
|
|
21
47
|
url,
|
|
22
48
|
queryKeyDeps = [],
|
|
@@ -25,17 +51,28 @@ export const useQueryCctvCompanyList = ({
|
|
|
25
51
|
queryKeyDeps?: readonly unknown[];
|
|
26
52
|
}): UseQueryResult<API_Res_CctvCompany> =>
|
|
27
53
|
useQuery({
|
|
54
|
+
// username/site 등 service-local 조건이 바뀌면 queryKeyDeps로 cache를 분리한다.
|
|
28
55
|
queryKey: ["cctv_company_list", url, ...queryKeyDeps],
|
|
29
56
|
queryFn: () => getClientCctvCompanyList({ url }),
|
|
30
57
|
});
|
|
31
58
|
|
|
59
|
+
/**
|
|
60
|
+
* CCTV; RTC token POST fetcher
|
|
61
|
+
* @param {API_Req_CctvRtcToken & { url?: string }} params token 요청 파라미터
|
|
62
|
+
* @param {string} params.company_id 업체 id코드
|
|
63
|
+
* @param {string} params.cam_id 카메라 id코드
|
|
64
|
+
* @param {string} params.username CCTV token 권한 확인용 계정명
|
|
65
|
+
* @param {string} [params.url] service에서 주입한 token API 경로
|
|
66
|
+
* @returns {Promise<API_Res_CctvRtcToken>} RTC token 응답
|
|
67
|
+
*/
|
|
32
68
|
export const postCctvRtcToken = async ({
|
|
33
69
|
company_id,
|
|
34
70
|
cam_id,
|
|
35
71
|
username,
|
|
36
72
|
url,
|
|
37
73
|
}: API_Req_CctvRtcToken & { url?: string }): Promise<API_Res_CctvRtcToken> =>
|
|
38
|
-
await
|
|
74
|
+
await // token route는 service-local proxy가 있으면 그 경로를 우선 사용한다.
|
|
75
|
+
(
|
|
39
76
|
await fetch(url ?? "/api/cctv/token", {
|
|
40
77
|
method: "POST",
|
|
41
78
|
headers: {
|
|
@@ -45,20 +82,37 @@ export const postCctvRtcToken = async ({
|
|
|
45
82
|
})
|
|
46
83
|
).json();
|
|
47
84
|
|
|
85
|
+
/**
|
|
86
|
+
* CCTV; RTC token query hook
|
|
87
|
+
* @hook
|
|
88
|
+
* @param {API_Req_CctvRtcToken & { enabled?: boolean; url?: string }} params token query 파라미터
|
|
89
|
+
* @param {string} params.company_id 업체 id코드
|
|
90
|
+
* @param {string} params.cam_id 카메라 id코드
|
|
91
|
+
* @param {boolean} [params.enabled] scheduler grant 이후 token query를 열기 위한 외부 gate
|
|
92
|
+
* @param {string} params.username CCTV token 권한 확인용 계정명
|
|
93
|
+
* @param {string} [params.url] service에서 주입한 token API 경로
|
|
94
|
+
* @returns {UseQueryResult<API_Res_CctvRtcToken>} RTC token query 결과
|
|
95
|
+
*/
|
|
48
96
|
export const useQueryCctvRtcToken = ({
|
|
49
97
|
company_id,
|
|
50
98
|
cam_id,
|
|
99
|
+
enabled = true,
|
|
51
100
|
username,
|
|
52
101
|
url,
|
|
53
102
|
}: API_Req_CctvRtcToken & {
|
|
103
|
+
enabled?: boolean;
|
|
54
104
|
url?: string;
|
|
55
105
|
}): UseQueryResult<API_Res_CctvRtcToken> =>
|
|
56
106
|
useQuery({
|
|
107
|
+
// token은 username/company/cam/API route 조합별로 분리한다.
|
|
57
108
|
queryKey: ["cctv_rtc_token", username, company_id, cam_id, url],
|
|
58
109
|
queryFn: () => postCctvRtcToken({ company_id, cam_id, username, url }),
|
|
59
|
-
|
|
110
|
+
// scheduler grant 전에는 token burst가 생기지 않도록 enabled를 false로 유지한다.
|
|
111
|
+
enabled: enabled && Boolean(username && company_id && cam_id),
|
|
112
|
+
// exp decode가 불가한 token은 fallback age를 넘기면 신규 WHEP 연결에 쓰지 않는다.
|
|
60
113
|
staleTime: CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
|
|
61
114
|
gcTime: CCTV_RTC_TOKEN_GC_TIME_MS,
|
|
115
|
+
// focus/reconnect/mount가 곧바로 다수 WHEP 재협상으로 이어지지 않도록 자동 refetch를 막는다.
|
|
62
116
|
refetchOnMount: false,
|
|
63
117
|
refetchOnReconnect: false,
|
|
64
118
|
refetchOnWindowFocus: false,
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
createCctvRtcStreamRegistry,
|
|
15
15
|
type CctvRtcStreamRegistry,
|
|
16
16
|
} from "../hooks/streamRegistry";
|
|
17
|
+
import {
|
|
18
|
+
createCctvRtcStreamScheduler,
|
|
19
|
+
type CctvRtcStreamScheduler,
|
|
20
|
+
} from "../hooks/streamScheduler";
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* CCTV; API 경로 컨텍스트
|
|
@@ -28,6 +32,9 @@ const ApiUrlContext = createContext<CctvApiUrlContext>({
|
|
|
28
32
|
const RtcStreamRegistryContext = createContext<CctvRtcStreamRegistry | null>(
|
|
29
33
|
null,
|
|
30
34
|
);
|
|
35
|
+
const RtcStreamSchedulerContext = createContext<CctvRtcStreamScheduler | null>(
|
|
36
|
+
null,
|
|
37
|
+
);
|
|
31
38
|
|
|
32
39
|
/**
|
|
33
40
|
* CCTV; API 경로 컨텍스트
|
|
@@ -40,6 +47,13 @@ export function useCctvApiUrl(): CctvApiUrlContext {
|
|
|
40
47
|
return useContext(ApiUrlContext);
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* CCTV; RTC stream registry context hook
|
|
52
|
+
* @hook
|
|
53
|
+
* @returns {CctvRtcStreamRegistry} Provider instance 단위 stream registry
|
|
54
|
+
* @desc
|
|
55
|
+
* registry는 WHEP PeerConnection/MediaStream을 Provider lifecycle 안에서 유지하고 list/viewer video reattach를 담당한다.
|
|
56
|
+
*/
|
|
43
57
|
export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
44
58
|
const registry = useContext(RtcStreamRegistryContext);
|
|
45
59
|
if (!registry) {
|
|
@@ -50,6 +64,23 @@ export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
50
64
|
return registry;
|
|
51
65
|
}
|
|
52
66
|
|
|
67
|
+
/**
|
|
68
|
+
* CCTV; RTC stream scheduler context hook
|
|
69
|
+
* @hook
|
|
70
|
+
* @returns {CctvRtcStreamScheduler} Provider instance 단위 stream scheduler
|
|
71
|
+
* @desc
|
|
72
|
+
* scheduler는 item UI 렌더와 token/WHEP 시작 시점을 분리해 초기 요청 burst를 batch 단위로 줄인다.
|
|
73
|
+
*/
|
|
74
|
+
export function useCctvRtcStreamScheduler(): CctvRtcStreamScheduler {
|
|
75
|
+
const scheduler = useContext(RtcStreamSchedulerContext);
|
|
76
|
+
if (!scheduler) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
"CCTV.Provider 내부에서 useCctvRtcStreamScheduler를 사용해야 합니다.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return scheduler;
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
/**
|
|
54
85
|
* CCTV; Provider props
|
|
55
86
|
* @component
|
|
@@ -60,6 +91,7 @@ export function useCctvRtcStreamRegistry(): CctvRtcStreamRegistry {
|
|
|
60
91
|
* @property {string} [username] CCTV 조회시 권한 확인을 위한 계정명
|
|
61
92
|
* @property {string} [whepUsername] WHEP endpoint username 덮어쓰기 값
|
|
62
93
|
* @property {ContextExtension} [defaultValues] 서비스 확장 context 기본값
|
|
94
|
+
* @property {CctvRtcStreamOptions} [streamOptions] stream lazy loading 옵션
|
|
63
95
|
* @property {React.ReactNode} children
|
|
64
96
|
*/
|
|
65
97
|
export default function CCTVProvider<ContextExtension extends object = object>({
|
|
@@ -70,15 +102,39 @@ export default function CCTVProvider<ContextExtension extends object = object>({
|
|
|
70
102
|
listUrl,
|
|
71
103
|
tokenUrl,
|
|
72
104
|
defaultValues,
|
|
105
|
+
streamOptions,
|
|
73
106
|
children,
|
|
74
107
|
}: CctvProviderProps<ContextExtension>) {
|
|
108
|
+
// streamOptions object identity가 매 렌더 바뀌어도 primitive option 값 기준으로만 scheduler를 교체한다.
|
|
109
|
+
const streamBatchSize = streamOptions?.batchSize;
|
|
110
|
+
const streamBatchIntervalMs = streamOptions?.batchIntervalMs;
|
|
111
|
+
const hasStreamOptions =
|
|
112
|
+
streamBatchSize !== undefined || streamBatchIntervalMs !== undefined;
|
|
113
|
+
|
|
114
|
+
// registry는 Provider lifetime 동안 유지해 list/viewer 전환 시 기존 MediaStream을 재사용한다.
|
|
75
115
|
const streamRegistry = useMemo(() => createCctvRtcStreamRegistry(), []);
|
|
76
116
|
|
|
117
|
+
// scheduler는 Provider option이 바뀌는 경우에만 새 batch 정책으로 다시 만든다.
|
|
118
|
+
const streamScheduler = useMemo(
|
|
119
|
+
() =>
|
|
120
|
+
createCctvRtcStreamScheduler(
|
|
121
|
+
hasStreamOptions
|
|
122
|
+
? {
|
|
123
|
+
batchIntervalMs: streamBatchIntervalMs,
|
|
124
|
+
batchSize: streamBatchSize,
|
|
125
|
+
}
|
|
126
|
+
: undefined,
|
|
127
|
+
),
|
|
128
|
+
[hasStreamOptions, streamBatchIntervalMs, streamBatchSize],
|
|
129
|
+
);
|
|
130
|
+
|
|
77
131
|
useEffect(() => {
|
|
78
132
|
return () => {
|
|
133
|
+
// Provider unmount가 CCTV stream lifecycle의 최종 정리 지점이다.
|
|
79
134
|
streamRegistry.closeAll();
|
|
135
|
+
streamScheduler.clear();
|
|
80
136
|
};
|
|
81
|
-
}, [streamRegistry]);
|
|
137
|
+
}, [streamRegistry, streamScheduler]);
|
|
82
138
|
|
|
83
139
|
const mergedDefaultValues = {
|
|
84
140
|
// 변경 설명: service 확장 context 기본값을 core CCTV form context와 한 RHF scope에서 병합한다.
|
|
@@ -93,13 +149,15 @@ export default function CCTVProvider<ContextExtension extends object = object>({
|
|
|
93
149
|
return (
|
|
94
150
|
<ApiUrlContext.Provider value={{ listUrl, tokenUrl }}>
|
|
95
151
|
<RtcStreamRegistryContext.Provider value={streamRegistry}>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
152
|
+
<RtcStreamSchedulerContext.Provider value={streamScheduler}>
|
|
153
|
+
<Form.Provider<CctvContext<ContextExtension>>
|
|
154
|
+
options={{
|
|
155
|
+
defaultValues: mergedDefaultValues,
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</Form.Provider>
|
|
160
|
+
</RtcStreamSchedulerContext.Provider>
|
|
103
161
|
</RtcStreamRegistryContext.Provider>
|
|
104
162
|
</ApiUrlContext.Provider>
|
|
105
163
|
);
|
|
@@ -25,8 +25,10 @@ export default function CCTVCamListItem({
|
|
|
25
25
|
renderOverlay,
|
|
26
26
|
...cam
|
|
27
27
|
}: CctvCamListItemProps) {
|
|
28
|
+
// item은 즉시 렌더하지만 token/WHEP 시작은 hook 내부 scheduler grant 이후로 지연된다.
|
|
28
29
|
const { videoRef, ...rtcCtx } = useCctvRtcStream({ cam });
|
|
29
30
|
|
|
31
|
+
// live/error/message는 video-state util 하나를 기준으로 계산해 CamList와 Viewer 표시 규칙을 맞춘다.
|
|
30
32
|
const isLive = useMemo(() => getIsLive({ cam, ...rtcCtx }), [cam, rtcCtx]);
|
|
31
33
|
const isError = useMemo(() => getIsError({ cam, ...rtcCtx }), [cam, rtcCtx]);
|
|
32
34
|
const overlayMessage = useMemo(
|
|
@@ -44,9 +46,11 @@ export default function CCTVCamListItem({
|
|
|
44
46
|
)}
|
|
45
47
|
>
|
|
46
48
|
<CCTVVideoTemplate
|
|
49
|
+
// hook이 반환한 ref에 registry MediaStream이 attach된다.
|
|
47
50
|
ref={videoRef}
|
|
48
51
|
cam={cam}
|
|
49
52
|
className="cctv-cam-list-video-container"
|
|
53
|
+
// list card에서는 live/share 상태를 header에 노출하고 제목은 footer로 내려 보낸다.
|
|
50
54
|
headerOptions={{
|
|
51
55
|
activeLiveState: true,
|
|
52
56
|
activeTitle: false,
|
|
@@ -58,9 +62,11 @@ export default function CCTVCamListItem({
|
|
|
58
62
|
// 변경 설명: list item custom overlay는 template seam 하나만 따라간다.
|
|
59
63
|
renderOverlay={renderOverlay}
|
|
60
64
|
{...{
|
|
65
|
+
// Video.Template이 overlay/body/footer를 한 번에 판단할 수 있도록 stream 상태를 함께 넘긴다.
|
|
61
66
|
isError,
|
|
62
67
|
overlayMessage,
|
|
63
68
|
isLive,
|
|
69
|
+
streamStage: rtcCtx.streamStage,
|
|
64
70
|
canReconnect: rtcCtx.canReconnect,
|
|
65
71
|
reconnectStream: rtcCtx.reconnectStream,
|
|
66
72
|
}}
|
|
@@ -11,14 +11,29 @@ import {
|
|
|
11
11
|
getIsError,
|
|
12
12
|
} from "../../../utils/video-state";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* CCTV; pagination list item
|
|
16
|
+
* @component
|
|
17
|
+
* @param {object} props
|
|
18
|
+
* @param {string} [props.className] pagination thumbnail item 클래스
|
|
19
|
+
* @param {string} props.cam_id 카메라 id코드
|
|
20
|
+
* @param {string} props.cam_name 카메라명
|
|
21
|
+
* @param {string} props.company_id 업체 id코드
|
|
22
|
+
* @param {boolean} props.cam_online 카메라 온라인 여부
|
|
23
|
+
* @param {string} props.cam_rtc WebRTC endpoint base URL
|
|
24
|
+
* @desc
|
|
25
|
+
* viewer 하단 thumbnail에서도 동일한 `useCctvRtcStream` 경로를 사용해 list/viewer 전환 시 registry reattach 계약을 유지한다.
|
|
26
|
+
*/
|
|
14
27
|
export default function CCTVPaginationListItem({
|
|
15
28
|
className,
|
|
16
29
|
...cam
|
|
17
30
|
}: {
|
|
18
31
|
className?: string;
|
|
19
32
|
} & CctvCompanyCameraData) {
|
|
33
|
+
// thumbnail은 즉시 렌더하고, 실제 token/WHEP 시작은 Provider scheduler가 batch 단위로 grant한다.
|
|
20
34
|
const { videoRef, ...rtcCtx } = useCctvRtcStream({ cam });
|
|
21
35
|
|
|
36
|
+
// CamList item과 동일한 util을 사용해 overlay 상태 판정 drift를 막는다.
|
|
22
37
|
const isLive = useMemo(() => getIsLive({ cam, ...rtcCtx }), [cam, rtcCtx]);
|
|
23
38
|
const isError = useMemo(() => getIsError({ cam, ...rtcCtx }), [cam, rtcCtx]);
|
|
24
39
|
const overlayMessage = useMemo(
|
|
@@ -26,6 +41,7 @@ export default function CCTVPaginationListItem({
|
|
|
26
41
|
[cam, rtcCtx],
|
|
27
42
|
);
|
|
28
43
|
|
|
44
|
+
// cam.onSelect는 render data에 주입된 선택 handler라 있을 때만 호출한다.
|
|
29
45
|
const handleSelect = useCallback(() => {
|
|
30
46
|
if (typeof cam.onSelect === "function") cam.onSelect();
|
|
31
47
|
}, [cam]);
|
|
@@ -47,9 +63,11 @@ export default function CCTVPaginationListItem({
|
|
|
47
63
|
aria-label={`${cam.cam_name} 선택`}
|
|
48
64
|
>
|
|
49
65
|
<CCTVVideoTemplate
|
|
66
|
+
// registry가 같은 streamKey의 MediaStream을 thumbnail video에 attach한다.
|
|
50
67
|
ref={videoRef}
|
|
51
68
|
cam={cam}
|
|
52
69
|
className="cctv-pagination-list-video-container"
|
|
70
|
+
// thumbnail에서는 header live/share 상태만 보이고 footer action은 최소화한다.
|
|
53
71
|
headerOptions={{
|
|
54
72
|
activeLiveState: true,
|
|
55
73
|
activeTitle: false,
|
|
@@ -59,9 +77,11 @@ export default function CCTVPaginationListItem({
|
|
|
59
77
|
}}
|
|
60
78
|
footerOptions={{ cam }}
|
|
61
79
|
{...{
|
|
80
|
+
// Video.Template overlay가 queued/token-loading/connecting/error 상태를 통합 표시한다.
|
|
62
81
|
isError,
|
|
63
82
|
overlayMessage,
|
|
64
83
|
isLive,
|
|
84
|
+
streamStage: rtcCtx.streamStage,
|
|
65
85
|
canReconnect: rtcCtx.canReconnect,
|
|
66
86
|
reconnectStream: rtcCtx.reconnectStream,
|
|
67
87
|
}}
|
|
@@ -17,6 +17,7 @@ import type { CctvVideoTemplateProps } from "../../types/props";
|
|
|
17
17
|
* @property {boolean} [isError]
|
|
18
18
|
* @property {React.ReactNode} [overlayMessage]
|
|
19
19
|
* @property {boolean} [isLive]
|
|
20
|
+
* @property {CctvRtcStreamStage} [streamStage]
|
|
20
21
|
* @property {CctvCompanyCameraData} [cam]
|
|
21
22
|
* @property {boolean} [canReconnect]
|
|
22
23
|
* @property {CctvRtcReconnectTrigger} [reconnectStream]
|
|
@@ -33,6 +34,7 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
|
|
|
33
34
|
isError,
|
|
34
35
|
isLive,
|
|
35
36
|
overlayMessage,
|
|
37
|
+
streamStage,
|
|
36
38
|
canReconnect,
|
|
37
39
|
reconnectStream,
|
|
38
40
|
renderOverlay,
|
|
@@ -60,6 +62,7 @@ const CCTVVideoTemplate = forwardRef<HTMLVideoElement, CctvVideoTemplateProps>(
|
|
|
60
62
|
isError,
|
|
61
63
|
isLive,
|
|
62
64
|
overlayMessage,
|
|
65
|
+
streamStage,
|
|
63
66
|
canReconnect,
|
|
64
67
|
reconnectStream,
|
|
65
68
|
})
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import type {
|
|
5
|
+
CctvRtcStreamOptions,
|
|
6
|
+
CctvRtcStreamScheduleStatus,
|
|
7
|
+
} from "../types";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STREAM_SCHEDULER_OPTIONS = {
|
|
10
|
+
batchSize: 4,
|
|
11
|
+
batchIntervalMs: 1200,
|
|
12
|
+
} satisfies Required<CctvRtcStreamOptions>;
|
|
13
|
+
|
|
14
|
+
type StreamSchedulerListener = () => void;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* CCTV; RTC stream scheduler contract
|
|
18
|
+
* @property {(identityKey: string) => () => void} request stream 시작 순번 요청 함수
|
|
19
|
+
* @property {(identityKey: string) => CctvRtcStreamScheduleStatus} getStatus stream 시작 순번 상태 조회 함수
|
|
20
|
+
* @property {(listener: StreamSchedulerListener) => () => void} subscribe scheduler 변경 구독 함수
|
|
21
|
+
* @property {() => void} clear scheduler 내부 상태 정리 함수
|
|
22
|
+
*/
|
|
23
|
+
export interface CctvRtcStreamScheduler {
|
|
24
|
+
/**
|
|
25
|
+
* stream 시작 순번 요청 함수
|
|
26
|
+
*/
|
|
27
|
+
request: (identityKey: string) => () => void;
|
|
28
|
+
/**
|
|
29
|
+
* stream 시작 순번 상태 조회 함수
|
|
30
|
+
*/
|
|
31
|
+
getStatus: (identityKey: string) => CctvRtcStreamScheduleStatus;
|
|
32
|
+
/**
|
|
33
|
+
* scheduler 변경 구독 함수
|
|
34
|
+
*/
|
|
35
|
+
subscribe: (listener: StreamSchedulerListener) => () => void;
|
|
36
|
+
/**
|
|
37
|
+
* scheduler 내부 상태 정리 함수
|
|
38
|
+
*/
|
|
39
|
+
clear: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* CCTV; RTC stream scheduler 옵션 정규화
|
|
44
|
+
* @param {CctvRtcStreamOptions} [options] Provider에서 전달한 stream lazy loading 옵션
|
|
45
|
+
* @returns {Required<CctvRtcStreamOptions>} scheduler 내부에서 사용할 확정 옵션
|
|
46
|
+
* @desc
|
|
47
|
+
* service가 잘못된 수치를 넘겨도 기본값과 최소 clamp만 적용해 scheduler가 멈추지 않게 한다.
|
|
48
|
+
*/
|
|
49
|
+
const getNormalizedSchedulerOptions = (
|
|
50
|
+
options?: CctvRtcStreamOptions,
|
|
51
|
+
): Required<CctvRtcStreamOptions> => {
|
|
52
|
+
// optional 값을 먼저 지역 변수로 좁혀 TypeScript가 number 판정을 안정적으로 추론하게 한다.
|
|
53
|
+
const batchSizeCandidate = options?.batchSize;
|
|
54
|
+
const batchIntervalCandidate = options?.batchIntervalMs;
|
|
55
|
+
|
|
56
|
+
// batchSize는 한 번에 grant할 stream 수라 1보다 작으면 scheduler가 진행되지 않는다.
|
|
57
|
+
const batchSize =
|
|
58
|
+
typeof batchSizeCandidate === "number" &&
|
|
59
|
+
Number.isFinite(batchSizeCandidate)
|
|
60
|
+
? Math.max(1, Math.floor(batchSizeCandidate))
|
|
61
|
+
: DEFAULT_STREAM_SCHEDULER_OPTIONS.batchSize;
|
|
62
|
+
|
|
63
|
+
// batchIntervalMs는 batch 사이 대기시간이며 0은 연속 flush 허용값으로 둔다.
|
|
64
|
+
const batchIntervalMs =
|
|
65
|
+
typeof batchIntervalCandidate === "number" &&
|
|
66
|
+
Number.isFinite(batchIntervalCandidate)
|
|
67
|
+
? Math.max(0, Math.floor(batchIntervalCandidate))
|
|
68
|
+
: DEFAULT_STREAM_SCHEDULER_OPTIONS.batchIntervalMs;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
batchSize,
|
|
72
|
+
batchIntervalMs,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* CCTV; RTC stream scheduler 생성
|
|
78
|
+
* @param {CctvRtcStreamOptions} [options] stream lazy loading 옵션
|
|
79
|
+
* @returns {CctvRtcStreamScheduler} stream 시작 순번 scheduler
|
|
80
|
+
* @desc
|
|
81
|
+
* item UI는 즉시 렌더하되 token/WHEP 시작만 batch 단위로 열어 초기 요청 burst를 줄인다.
|
|
82
|
+
*/
|
|
83
|
+
export function createCctvRtcStreamScheduler(
|
|
84
|
+
options?: CctvRtcStreamOptions,
|
|
85
|
+
): CctvRtcStreamScheduler {
|
|
86
|
+
// Provider option은 scheduler 생성 시점에 확정해 queue 처리 중 정책 drift를 막는다.
|
|
87
|
+
const { batchSize, batchIntervalMs } = getNormalizedSchedulerOptions(options);
|
|
88
|
+
|
|
89
|
+
// queue는 아직 token/WHEP 시작 권한을 받지 못한 stream identity 순서를 보관한다.
|
|
90
|
+
const queue: string[] = [];
|
|
91
|
+
|
|
92
|
+
// granted는 token query를 열 수 있는 stream identity 집합이다.
|
|
93
|
+
const granted = new Set<string>();
|
|
94
|
+
|
|
95
|
+
// requestCounts는 같은 stream identity를 여러 component가 동시에 쓰는 경우 premature release를 막는다.
|
|
96
|
+
const requestCounts = new Map<string, number>();
|
|
97
|
+
|
|
98
|
+
// listeners는 hook state와 scheduler 내부 상태를 동기화하는 구독자 집합이다.
|
|
99
|
+
const listeners = new Set<StreamSchedulerListener>();
|
|
100
|
+
|
|
101
|
+
// timer는 다음 batch flush 예약을 단일화해 중복 setTimeout을 방지한다.
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
103
|
+
|
|
104
|
+
const notify = () => {
|
|
105
|
+
// queue/grant 상태가 바뀌면 모든 hook subscriber가 status를 다시 읽는다.
|
|
106
|
+
listeners.forEach(listener => listener());
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const clearTimer = () => {
|
|
110
|
+
// 예약된 flush가 없으면 timer cleanup은 no-op으로 둔다.
|
|
111
|
+
if (!timer) return;
|
|
112
|
+
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
timer = null;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const flush = () => {
|
|
118
|
+
// 현재 timer callback이 실행됐으므로 다음 예약이 가능하도록 비운다.
|
|
119
|
+
timer = null;
|
|
120
|
+
|
|
121
|
+
// FIFO 순서대로 이번 batch 크기만큼 grant한다.
|
|
122
|
+
const nextBatch = queue.splice(0, batchSize);
|
|
123
|
+
nextBatch.forEach(identityKey => {
|
|
124
|
+
granted.add(identityKey);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// 실제 grant 변화가 있을 때만 subscriber를 깨운다.
|
|
128
|
+
if (nextBatch.length > 0) {
|
|
129
|
+
notify();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 아직 대기열이 남아 있으면 다음 batch를 interval 뒤에 예약한다.
|
|
133
|
+
if (queue.length > 0) {
|
|
134
|
+
timer = setTimeout(flush, batchIntervalMs);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const scheduleFlush = () => {
|
|
139
|
+
// 이미 예약된 flush가 있으면 같은 queue에 중복 timer를 만들지 않는다.
|
|
140
|
+
if (timer) return;
|
|
141
|
+
|
|
142
|
+
// 첫 batch는 즉시 열고, 이후 batch만 interval을 적용한다.
|
|
143
|
+
timer = setTimeout(flush, granted.size === 0 ? 0 : batchIntervalMs);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
request(identityKey) {
|
|
148
|
+
// 같은 stream identity를 여러 video가 요청하면 reference count만 증가시킨다.
|
|
149
|
+
const currentCount = requestCounts.get(identityKey) ?? 0;
|
|
150
|
+
requestCounts.set(identityKey, currentCount + 1);
|
|
151
|
+
|
|
152
|
+
// 이미 grant됐거나 queue에 있으면 중복 등록하지 않는다.
|
|
153
|
+
if (!granted.has(identityKey) && !queue.includes(identityKey)) {
|
|
154
|
+
queue.push(identityKey);
|
|
155
|
+
notify();
|
|
156
|
+
scheduleFlush();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return () => {
|
|
160
|
+
// cleanup은 hook unmount 또는 identity 변경 때 호출되며 reference count를 먼저 낮춘다.
|
|
161
|
+
const nextCount = (requestCounts.get(identityKey) ?? 1) - 1;
|
|
162
|
+
|
|
163
|
+
// 아직 같은 identity를 쓰는 component가 남아 있으면 grant/queue 상태를 유지한다.
|
|
164
|
+
if (nextCount > 0) {
|
|
165
|
+
requestCounts.set(identityKey, nextCount);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 마지막 subscriber가 사라지면 scheduler 상태에서 identity를 제거한다.
|
|
170
|
+
requestCounts.delete(identityKey);
|
|
171
|
+
granted.delete(identityKey);
|
|
172
|
+
|
|
173
|
+
// 아직 grant 전 queue에 남아 있던 identity라면 대기열에서도 제거한다.
|
|
174
|
+
const queuedIndex = queue.indexOf(identityKey);
|
|
175
|
+
if (queuedIndex > -1) {
|
|
176
|
+
queue.splice(queuedIndex, 1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 더 이상 처리할 queue가 없으면 다음 flush 예약도 취소한다.
|
|
180
|
+
if (queue.length === 0) {
|
|
181
|
+
clearTimer();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// release 결과를 subscriber에게 알려 `queued -> idle` 또는 `granted -> idle` 전환을 반영한다.
|
|
185
|
+
notify();
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
getStatus(identityKey) {
|
|
189
|
+
// token query를 열 수 있는 상태를 최우선으로 반환한다.
|
|
190
|
+
if (granted.has(identityKey)) return "granted";
|
|
191
|
+
|
|
192
|
+
// queue에 들어갔지만 아직 grant되지 않은 상태는 overlay에서 대기 상태로 표시된다.
|
|
193
|
+
if (queue.includes(identityKey)) return "queued";
|
|
194
|
+
|
|
195
|
+
// 요청 전이거나 release 이후면 idle이다.
|
|
196
|
+
return "idle";
|
|
197
|
+
},
|
|
198
|
+
subscribe(listener) {
|
|
199
|
+
// hook이 scheduler 상태 변경을 React state로 동기화할 수 있게 listener를 등록한다.
|
|
200
|
+
listeners.add(listener);
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
// hook cleanup 시 listener 누수를 막는다.
|
|
204
|
+
listeners.delete(listener);
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
clear() {
|
|
208
|
+
// Provider unmount 시 timer와 모든 mutable scheduler 상태를 비운다.
|
|
209
|
+
clearTimer();
|
|
210
|
+
queue.splice(0, queue.length);
|
|
211
|
+
granted.clear();
|
|
212
|
+
requestCounts.clear();
|
|
213
|
+
|
|
214
|
+
// 마지막 상태 변화를 알린 뒤 listener 집합도 비운다.
|
|
215
|
+
notify();
|
|
216
|
+
listeners.clear();
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* CCTV; RTC stream scheduler 상태 구독
|
|
223
|
+
* @hook
|
|
224
|
+
* @param {CctvRtcStreamScheduler} scheduler stream 시작 순번 scheduler
|
|
225
|
+
* @param {string} identityKey stream identity key
|
|
226
|
+
* @param {boolean} enabled scheduler 요청 활성화 여부
|
|
227
|
+
* @returns {CctvRtcStreamScheduleStatus} stream 시작 순번 상태
|
|
228
|
+
*/
|
|
229
|
+
export function useCctvRtcStreamScheduleStatus({
|
|
230
|
+
enabled,
|
|
231
|
+
identityKey,
|
|
232
|
+
scheduler,
|
|
233
|
+
}: {
|
|
234
|
+
/**
|
|
235
|
+
* scheduler 요청 활성화 여부
|
|
236
|
+
*/
|
|
237
|
+
enabled: boolean;
|
|
238
|
+
/**
|
|
239
|
+
* stream identity key
|
|
240
|
+
*/
|
|
241
|
+
identityKey: string;
|
|
242
|
+
/**
|
|
243
|
+
* stream 시작 순번 scheduler
|
|
244
|
+
*/
|
|
245
|
+
scheduler: CctvRtcStreamScheduler;
|
|
246
|
+
}): CctvRtcStreamScheduleStatus {
|
|
247
|
+
const [status, setStatus] = useState<CctvRtcStreamScheduleStatus>("idle");
|
|
248
|
+
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
// 필수 identity가 없거나 offline이면 scheduler에 등록하지 않고 idle로 되돌린다.
|
|
251
|
+
if (!enabled || !identityKey) {
|
|
252
|
+
setStatus("idle");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// scheduler mutable state를 React state로 복사해 render가 status 변화를 감지하게 한다.
|
|
257
|
+
const syncStatus = () => {
|
|
258
|
+
setStatus(scheduler.getStatus(identityKey));
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// subscribe 이후 request해야 request 직후 notify도 놓치지 않는다.
|
|
262
|
+
const unsubscribe = scheduler.subscribe(syncStatus);
|
|
263
|
+
const release = scheduler.request(identityKey);
|
|
264
|
+
|
|
265
|
+
// request 직후 현재 상태를 즉시 반영해 queued/granted 표시 지연을 줄인다.
|
|
266
|
+
syncStatus();
|
|
267
|
+
|
|
268
|
+
return () => {
|
|
269
|
+
// cleanup 순서는 scheduler reference release 후 listener 제거로 둬 release notify를 받을 수 있게 한다.
|
|
270
|
+
release();
|
|
271
|
+
unsubscribe();
|
|
272
|
+
};
|
|
273
|
+
}, [enabled, identityKey, scheduler]);
|
|
274
|
+
|
|
275
|
+
return status;
|
|
276
|
+
}
|