@livedesk/hub 0.1.58 → 0.1.61
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/auth/workspace-access.js +176 -0
- package/src/console-direct.js +329 -10
- package/src/console-direct.test.mjs +332 -3
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/http/hub-ui-session.js +155 -32
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/remote-hub.js +108 -56
- package/src/server.js +1324 -396
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
|
@@ -34,33 +34,97 @@ function randomToken() {
|
|
|
34
34
|
return crypto.randomBytes(32).toString('base64url');
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function createHubUiSessionAuthority({
|
|
38
|
-
ttlMs = HUB_UI_SESSION_TTL_MS,
|
|
39
|
-
maxSessions = 32,
|
|
40
|
-
now = () => Date.now()
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
export function createHubUiSessionAuthority({
|
|
38
|
+
ttlMs = HUB_UI_SESSION_TTL_MS,
|
|
39
|
+
maxSessions = 32,
|
|
40
|
+
now = () => Date.now(),
|
|
41
|
+
onRevoke = () => {}
|
|
42
|
+
} = {}) {
|
|
43
|
+
const sessions = new Map();
|
|
44
|
+
|
|
45
|
+
function dropSession(sessionId, reason) {
|
|
46
|
+
const session = sessions.get(sessionId);
|
|
47
|
+
if (!session) return null;
|
|
48
|
+
sessions.delete(sessionId);
|
|
49
|
+
try {
|
|
50
|
+
onRevoke({ sessionId, ...session }, String(reason || 'hub-ui-session-revoked'));
|
|
51
|
+
} catch {
|
|
52
|
+
// Revocation remains authoritative even if a best-effort socket cleanup
|
|
53
|
+
// listener fails. The next protected request still fails closed.
|
|
54
|
+
}
|
|
55
|
+
return session;
|
|
56
|
+
}
|
|
43
57
|
|
|
44
58
|
function purgeExpired() {
|
|
45
|
-
const current = now();
|
|
46
|
-
for (const [sessionId, session] of sessions) {
|
|
47
|
-
if (session.expiresAt <= current
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
59
|
+
const current = now();
|
|
60
|
+
for (const [sessionId, session] of sessions) {
|
|
61
|
+
if (session.expiresAt <= current
|
|
62
|
+
|| (session.accessExpiresAt > 0 && session.accessExpiresAt <= current)) {
|
|
63
|
+
dropSession(sessionId, 'hub-ui-session-expired');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
while (sessions.size >= maxSessions) {
|
|
67
|
+
dropSession(sessions.keys().next().value, 'hub-ui-session-capacity-evicted');
|
|
51
68
|
}
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
function
|
|
55
|
-
const normalizedUserId = String(userId || '').trim();
|
|
56
|
-
if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
function normalizedSessionAccess(userId, access = {}) {
|
|
72
|
+
const normalizedUserId = String(userId || '').trim();
|
|
73
|
+
if (!normalizedUserId) throw new Error('hub-ui-session-user-required');
|
|
74
|
+
const workspaceId = String(access.workspaceId || '').trim();
|
|
75
|
+
const workspaceKind = String(access.workspaceKind || 'personal').trim().toLowerCase();
|
|
76
|
+
const role = String(access.role || 'owner').trim().toLowerCase();
|
|
77
|
+
const entitlementStatus = String(access.entitlementStatus || 'active').trim().toLowerCase();
|
|
78
|
+
const membershipRevision = Math.max(0, Number(access.membershipRevision || 0));
|
|
79
|
+
const accessExpiresAt = Math.max(0, Number(access.accessExpiresAt || 0));
|
|
80
|
+
if (workspaceId && !['owner', 'operator'].includes(role)) {
|
|
81
|
+
throw new Error('hub-ui-session-workspace-role-invalid');
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
userId: normalizedUserId,
|
|
85
|
+
workspaceId,
|
|
86
|
+
workspaceKind,
|
|
87
|
+
role,
|
|
88
|
+
plan: String(access.plan || 'free').trim().toLowerCase(),
|
|
89
|
+
entitlementStatus,
|
|
90
|
+
membershipRevision,
|
|
91
|
+
accessExpiresAt
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function issue(userId, access = {}) {
|
|
96
|
+
const normalized = normalizedSessionAccess(userId, access);
|
|
97
|
+
purgeExpired();
|
|
98
|
+
const sessionId = randomToken();
|
|
99
|
+
const csrfToken = randomToken();
|
|
100
|
+
const expiresAt = now() + ttlMs;
|
|
101
|
+
sessions.set(sessionId, {
|
|
102
|
+
...normalized,
|
|
103
|
+
csrfToken,
|
|
104
|
+
expiresAt
|
|
105
|
+
});
|
|
106
|
+
return { sessionId, csrfToken, expiresAt };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function renew(req, userId, access = {}) {
|
|
110
|
+
purgeExpired();
|
|
111
|
+
const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
|
|
112
|
+
const session = sessions.get(sessionId);
|
|
113
|
+
const normalized = normalizedSessionAccess(userId, access);
|
|
114
|
+
if (!session
|
|
115
|
+
|| session.userId !== normalized.userId
|
|
116
|
+
|| session.workspaceId !== normalized.workspaceId
|
|
117
|
+
|| session.role !== normalized.role) {
|
|
118
|
+
return issue(userId, access);
|
|
119
|
+
}
|
|
120
|
+
const expiresAt = now() + ttlMs;
|
|
121
|
+
sessions.set(sessionId, {
|
|
122
|
+
...session,
|
|
123
|
+
...normalized,
|
|
124
|
+
expiresAt
|
|
125
|
+
});
|
|
126
|
+
return { sessionId, csrfToken: session.csrfToken, expiresAt, renewed: true };
|
|
127
|
+
}
|
|
64
128
|
|
|
65
129
|
function authorize(req, currentUserId, {
|
|
66
130
|
requireCsrf = !SAFE_HTTP_METHODS.has(String(req.method || 'GET').toUpperCase())
|
|
@@ -74,23 +138,82 @@ export function createHubUiSessionAuthority({
|
|
|
74
138
|
if (!session) {
|
|
75
139
|
return { ok: false, status: 401, error: 'hub-ui-session-expired' };
|
|
76
140
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
141
|
+
const expected = currentUserId && typeof currentUserId === 'object'
|
|
142
|
+
? currentUserId
|
|
143
|
+
: { userId: currentUserId };
|
|
144
|
+
const normalizedUserId = String(expected.userId || '').trim();
|
|
145
|
+
const expectedWorkspaceId = String(expected.workspaceId || '').trim();
|
|
146
|
+
const allowedRoles = new Set(Array.isArray(expected.allowedRoles)
|
|
147
|
+
? expected.allowedRoles.map(role => String(role || '').trim().toLowerCase())
|
|
148
|
+
: ['owner', 'operator']);
|
|
149
|
+
const identityMismatch = expectedWorkspaceId
|
|
150
|
+
? session.workspaceId !== expectedWorkspaceId || !allowedRoles.has(session.role)
|
|
151
|
+
: !normalizedUserId || session.userId !== normalizedUserId;
|
|
152
|
+
if (identityMismatch || (session.accessExpiresAt > 0 && session.accessExpiresAt <= now())) {
|
|
153
|
+
dropSession(sessionId, 'hub-ui-session-account-mismatch');
|
|
154
|
+
return { ok: false, status: 403, error: 'hub-ui-session-account-mismatch' };
|
|
81
155
|
}
|
|
82
156
|
if (requireCsrf && !secureEqual(req.headers?.['x-livedesk-csrf'], session.csrfToken)) {
|
|
83
157
|
return { ok: false, status: 403, error: 'hub-ui-csrf-invalid' };
|
|
84
158
|
}
|
|
85
|
-
return {
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
sessionId,
|
|
162
|
+
userId: session.userId,
|
|
163
|
+
workspaceId: session.workspaceId,
|
|
164
|
+
workspaceKind: session.workspaceKind,
|
|
165
|
+
role: session.role,
|
|
166
|
+
plan: session.plan,
|
|
167
|
+
entitlementStatus: session.entitlementStatus,
|
|
168
|
+
membershipRevision: session.membershipRevision,
|
|
169
|
+
accessExpiresAt: session.accessExpiresAt,
|
|
170
|
+
expiresAt: session.expiresAt
|
|
171
|
+
};
|
|
86
172
|
}
|
|
87
173
|
|
|
88
174
|
return {
|
|
89
|
-
issue,
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
175
|
+
issue,
|
|
176
|
+
renew,
|
|
177
|
+
authorize,
|
|
178
|
+
sessionDeadline(sessionId) {
|
|
179
|
+
purgeExpired();
|
|
180
|
+
const session = sessions.get(String(sessionId || ''));
|
|
181
|
+
if (!session) return 0;
|
|
182
|
+
return session.accessExpiresAt > 0
|
|
183
|
+
? Math.min(session.expiresAt, session.accessExpiresAt)
|
|
184
|
+
: session.expiresAt;
|
|
185
|
+
},
|
|
186
|
+
revokeAll(reason = 'hub-ui-session-revoked-all') {
|
|
187
|
+
let revoked = 0;
|
|
188
|
+
for (const sessionId of [...sessions.keys()]) {
|
|
189
|
+
if (dropSession(sessionId, reason)) revoked += 1;
|
|
190
|
+
}
|
|
191
|
+
return revoked;
|
|
192
|
+
},
|
|
193
|
+
revokeWorkspaceMember(workspaceId, userId) {
|
|
194
|
+
const normalizedWorkspaceId = String(workspaceId || '').trim();
|
|
195
|
+
const normalizedUserId = String(userId || '').trim();
|
|
196
|
+
let revoked = 0;
|
|
197
|
+
for (const [sessionId, session] of sessions) {
|
|
198
|
+
if (session.workspaceId !== normalizedWorkspaceId || session.userId !== normalizedUserId) continue;
|
|
199
|
+
if (dropSession(sessionId, 'workspace-member-revoked')) revoked += 1;
|
|
200
|
+
}
|
|
201
|
+
return revoked;
|
|
202
|
+
},
|
|
203
|
+
revoke(req) {
|
|
204
|
+
const sessionId = parseCookies(req?.headers?.cookie).get(HUB_UI_SESSION_COOKIE) || '';
|
|
205
|
+
if (!sessionId) return null;
|
|
206
|
+
const session = dropSession(sessionId, 'hub-ui-session-signed-out');
|
|
207
|
+
return session ? {
|
|
208
|
+
sessionId,
|
|
209
|
+
userId: session.userId,
|
|
210
|
+
workspaceId: session.workspaceId,
|
|
211
|
+
workspaceKind: session.workspaceKind,
|
|
212
|
+
role: session.role,
|
|
213
|
+
plan: session.plan,
|
|
214
|
+
entitlementStatus: session.entitlementStatus
|
|
215
|
+
} : null;
|
|
216
|
+
},
|
|
94
217
|
sessionCount: () => sessions.size
|
|
95
218
|
};
|
|
96
219
|
}
|
|
@@ -10,7 +10,7 @@ export function parseExactLiveStreamMonitorIndex(value) {
|
|
|
10
10
|
: null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
|
|
13
|
+
export function isReusedLiveStreamFrameReady(result, activeStream, expectedBinding) {
|
|
14
14
|
const expectedMonitorIndex = parseExactLiveStreamMonitorIndex(expectedBinding?.monitorIndex);
|
|
15
15
|
const activeMonitorIndex = parseExactLiveStreamMonitorIndex(activeStream?.monitorIndex);
|
|
16
16
|
const latestFrame = activeStream?.latestFrame;
|
|
@@ -34,5 +34,197 @@ export function isReusedLiveStreamFrameReady(result, activeStream, expectedBindi
|
|
|
34
34
|
&& Number(latestFrame?.captureGeneration || 0) === expectedCaptureGeneration
|
|
35
35
|
&& expectedMonitorIndex !== null
|
|
36
36
|
&& activeMonitorIndex === expectedMonitorIndex
|
|
37
|
-
&& frameMonitorIndex === expectedMonitorIndex;
|
|
38
|
-
}
|
|
37
|
+
&& frameMonitorIndex === expectedMonitorIndex;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS = 3_000;
|
|
41
|
+
export const READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS = 250;
|
|
42
|
+
|
|
43
|
+
function normalizedPurpose(value) {
|
|
44
|
+
return String(value || '').trim().toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function positiveInteger(value) {
|
|
48
|
+
const parsed = Number(value);
|
|
49
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Own exactly one short stop-reconcile timer per device. A replacement Control
|
|
54
|
+
* transition cancels that timer before a PWA Wall can claim the released
|
|
55
|
+
* native capture; its Ready event then rebinds observers immediately.
|
|
56
|
+
*/
|
|
57
|
+
export function createReadOnlyControlPresentationReconcileCoordinator({
|
|
58
|
+
onReconcile,
|
|
59
|
+
graceMs = READ_ONLY_CONTROL_STOP_RECONCILE_GRACE_MS,
|
|
60
|
+
setTimeoutFn = setTimeout,
|
|
61
|
+
clearTimeoutFn = clearTimeout
|
|
62
|
+
} = {}) {
|
|
63
|
+
if (typeof onReconcile !== 'function') {
|
|
64
|
+
throw new TypeError('onReconcile must be a function');
|
|
65
|
+
}
|
|
66
|
+
if (typeof setTimeoutFn !== 'function' || typeof clearTimeoutFn !== 'function') {
|
|
67
|
+
throw new TypeError('timer functions must be callable');
|
|
68
|
+
}
|
|
69
|
+
const normalizedGraceMs = Number(graceMs);
|
|
70
|
+
if (!Number.isFinite(normalizedGraceMs)
|
|
71
|
+
|| normalizedGraceMs < 1
|
|
72
|
+
|| normalizedGraceMs > 5_000) {
|
|
73
|
+
throw new RangeError('graceMs must be between 1 and 5000 milliseconds');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const pendingByDeviceId = new Map();
|
|
77
|
+
let closed = false;
|
|
78
|
+
|
|
79
|
+
function normalizeDeviceId(deviceId) {
|
|
80
|
+
return String(deviceId || '').trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cancelForControlTransition(deviceId) {
|
|
84
|
+
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
|
85
|
+
const owner = normalizedDeviceId ? pendingByDeviceId.get(normalizedDeviceId) : null;
|
|
86
|
+
if (!owner) return false;
|
|
87
|
+
pendingByDeviceId.delete(normalizedDeviceId);
|
|
88
|
+
clearTimeoutFn(owner.timer);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scheduleAfterConfirmedStop(deviceId) {
|
|
93
|
+
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
|
94
|
+
if (closed || !normalizedDeviceId || pendingByDeviceId.has(normalizedDeviceId)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const owner = { timer: null };
|
|
98
|
+
pendingByDeviceId.set(normalizedDeviceId, owner);
|
|
99
|
+
try {
|
|
100
|
+
owner.timer = setTimeoutFn(() => {
|
|
101
|
+
if (closed || pendingByDeviceId.get(normalizedDeviceId) !== owner) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
pendingByDeviceId.delete(normalizedDeviceId);
|
|
105
|
+
onReconcile(normalizedDeviceId);
|
|
106
|
+
}, normalizedGraceMs);
|
|
107
|
+
owner.timer?.unref?.();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (pendingByDeviceId.get(normalizedDeviceId) === owner) {
|
|
110
|
+
pendingByDeviceId.delete(normalizedDeviceId);
|
|
111
|
+
}
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function reconcileReadyControl(deviceId) {
|
|
118
|
+
const normalizedDeviceId = normalizeDeviceId(deviceId);
|
|
119
|
+
if (closed || !normalizedDeviceId) return false;
|
|
120
|
+
cancelForControlTransition(normalizedDeviceId);
|
|
121
|
+
onReconcile(normalizedDeviceId);
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function close() {
|
|
126
|
+
if (closed) return;
|
|
127
|
+
closed = true;
|
|
128
|
+
for (const owner of pendingByDeviceId.values()) {
|
|
129
|
+
clearTimeoutFn(owner.timer);
|
|
130
|
+
}
|
|
131
|
+
pendingByDeviceId.clear();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
scheduleAfterConfirmedStop,
|
|
136
|
+
cancelForControlTransition,
|
|
137
|
+
reconcileReadyControl,
|
|
138
|
+
close,
|
|
139
|
+
getPendingCount: () => pendingByDeviceId.size
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Bind a Wall presentation observer to an already healthy Control encoder.
|
|
145
|
+
* This returns metadata only: it never starts, stops, or replaces native work.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveReadOnlyControlPresentationBorrow({
|
|
148
|
+
device,
|
|
149
|
+
liveOptions,
|
|
150
|
+
monitorIndex,
|
|
151
|
+
nowEpochMs = Date.now()
|
|
152
|
+
} = {}) {
|
|
153
|
+
if (liveOptions?.allowReadOnlyControlBorrow !== true
|
|
154
|
+
|| normalizedPurpose(liveOptions?.streamPurpose) !== 'wall') {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const active = device?.activeLiveStream;
|
|
159
|
+
const latest = active?.latestFrame;
|
|
160
|
+
const sessionId = String(device?.sessionId || '').trim();
|
|
161
|
+
const streamId = String(active?.streamId || '').trim();
|
|
162
|
+
const commandId = String(active?.commandId || '').trim();
|
|
163
|
+
const captureGeneration = positiveInteger(active?.captureGeneration);
|
|
164
|
+
const activeMonitorIndex = parseExactLiveStreamMonitorIndex(active?.monitorIndex);
|
|
165
|
+
const requestedMonitorIndex = parseExactLiveStreamMonitorIndex(monitorIndex);
|
|
166
|
+
const frameMode = String(active?.frameMode || active?.mode || '').trim().toLowerCase();
|
|
167
|
+
const requestedFrameMode = String(liveOptions?.frameMode || liveOptions?.mode || '').trim().toLowerCase();
|
|
168
|
+
const lastFrameAtEpochMs = Date.parse(String(active?.lastFrameAt || ''));
|
|
169
|
+
const currentEpochMs = Number(nowEpochMs);
|
|
170
|
+
const frameIdleMs = Number.isFinite(lastFrameAtEpochMs) && Number.isFinite(currentEpochMs)
|
|
171
|
+
? Math.max(0, currentEpochMs - lastFrameAtEpochMs)
|
|
172
|
+
: Number.POSITIVE_INFINITY;
|
|
173
|
+
const latestMonitorIndex = parseExactLiveStreamMonitorIndex(latest?.monitorIndex);
|
|
174
|
+
|
|
175
|
+
if (active?.active !== true
|
|
176
|
+
|| active?.open !== true
|
|
177
|
+
|| active?.stopPending === true
|
|
178
|
+
|| !!String(active?.stopCommandId || '').trim()
|
|
179
|
+
|| !!String(active?.pendingDescriptor?.commandId || '').trim()
|
|
180
|
+
|| normalizedPurpose(active?.streamPurpose) !== 'control'
|
|
181
|
+
|| frameMode !== 'mode3-h264-hw'
|
|
182
|
+
|| requestedFrameMode !== 'mode3-h264-hw'
|
|
183
|
+
|| !sessionId
|
|
184
|
+
|| !streamId
|
|
185
|
+
|| !commandId
|
|
186
|
+
|| captureGeneration <= 0
|
|
187
|
+
|| requestedMonitorIndex === null
|
|
188
|
+
|| activeMonitorIndex !== requestedMonitorIndex
|
|
189
|
+
|| active?.readyFrameReceived !== true
|
|
190
|
+
|| Number(active?.framesReceived || 0) <= 0
|
|
191
|
+
|| frameIdleMs > READ_ONLY_CONTROL_PRESENTATION_MAX_IDLE_MS
|
|
192
|
+
|| latest?.currentGenerationVerified !== true
|
|
193
|
+
|| String(latest?.sessionId || '') !== sessionId
|
|
194
|
+
|| String(latest?.streamId || '') !== streamId
|
|
195
|
+
|| String(latest?.commandId || '') !== commandId
|
|
196
|
+
|| Number(latest?.captureGeneration || 0) !== captureGeneration
|
|
197
|
+
|| latestMonitorIndex !== activeMonitorIndex
|
|
198
|
+
|| normalizedPurpose(latest?.streamPurpose) !== 'control') {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
ok: true,
|
|
204
|
+
commandId,
|
|
205
|
+
sessionId,
|
|
206
|
+
streamId,
|
|
207
|
+
streamPurpose: 'control',
|
|
208
|
+
fps: positiveInteger(active?.fps) || 1,
|
|
209
|
+
mode: String(active?.mode || active?.frameMode || 'mode3-h264-hw'),
|
|
210
|
+
frameMode: 'mode3-h264-hw',
|
|
211
|
+
monitorIndex: activeMonitorIndex,
|
|
212
|
+
captureGeneration,
|
|
213
|
+
ready: true,
|
|
214
|
+
reused: true,
|
|
215
|
+
readOnlyControlBorrow: true,
|
|
216
|
+
presentationPurpose: 'wall',
|
|
217
|
+
requestedProfile: {
|
|
218
|
+
fps: Number(liveOptions?.fps || 0),
|
|
219
|
+
maxWidth: Number(liveOptions?.maxWidth || 0),
|
|
220
|
+
maxHeight: Number(liveOptions?.maxHeight || 0),
|
|
221
|
+
quality: Number(liveOptions?.quality || 0)
|
|
222
|
+
},
|
|
223
|
+
effectiveProfile: {
|
|
224
|
+
fps: Number(active?.fps || 0),
|
|
225
|
+
maxWidth: Number(active?.maxWidth || 0),
|
|
226
|
+
maxHeight: Number(active?.maxHeight || 0),
|
|
227
|
+
quality: Number(active?.quality || 0)
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|