1import * as realtimeKit from "@distilled.cloud/cloudflare/realtime-kit";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.RealtimeKit.Preset" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Media quality tier for video / screenshare streams.
17 */
18export type MediaQuality = "hd" | "vga" | "qvga";
19
20/**
21 * Meeting layout the preset applies to.
22 */
23export type ViewType = "GROUP_CALL" | "WEBINAR" | "AUDIO_ROOM";
24
25/**
26 * Whether a participant may produce a media kind.
27 */
28export type CanProduce = "ALLOWED" | "NOT_ALLOWED" | "CAN_REQUEST";
29
30/**
31 * What a participant joining with this preset records as.
32 */
33export type RecorderType = "RECORDER" | "LIVESTREAMER" | "NONE";
34
35/**
36 * Waiting-room behavior for participants joining with this preset.
37 */
38export type WaitingRoomType =
39 | "SKIP"
40 | "ON_PRIVILEGED_USER_ENTRY"
41 | "SKIP_ON_ACCEPT";
42
43/**
44 * Media configuration of a preset (stream counts, quality, frame rates).
45 */
46export type PresetConfig = {
47 /**
48 * Maximum number of simultaneous screenshares.
49 */
50 maxScreenshareCount: number;
51 /**
52 * Maximum simultaneous video streams per device class.
53 */
54 maxVideoStreams: { desktop: number; mobile: number };
55 /**
56 * Quality / frame-rate settings for each media kind.
57 */
58 media: {
59 screenshare: { frameRate: number; quality: MediaQuality };
60 video: { frameRate: number; quality: MediaQuality };
61 audio?: { enableHighBitrate?: boolean; enableStereo?: boolean };
62 };
63 /**
64 * Meeting layout this preset applies to.
65 */
66 viewType: ViewType;
67};
68
69/**
70 * UI design tokens of a preset (colors, logo, spacing).
71 */
72/** Corner rounding of UI elements. */
73export type BorderRadius = "sharp" | "rounded" | "extra-rounded" | "circular";
74
75/** Border width of UI elements. */
76export type BorderWidth = "none" | "thin" | "fat";
77
78/** Base color theme of the meeting UI. */
79export type Theme = "darkest" | "dark" | "light";
80
81export type PresetUi = {
82 designTokens: {
83 borderRadius: BorderRadius;
84 borderWidth: BorderWidth;
85 colors: {
86 background: {
87 "600": string;
88 "700": string;
89 "800": string;
90 "900": string;
91 "1000": string;
92 };
93 brand: {
94 "300": string;
95 "400": string;
96 "500": string;
97 "600": string;
98 "700": string;
99 };
100 danger: string;
101 success: string;
102 text: string;
103 textOnBrand: string;
104 videoBg: string;
105 warning: string;
106 };
107 logo: string;
108 spacingBase: number;
109 theme: Theme;
110 };
111 /**
112 * Raw UI-kit config diff applied on top of the design tokens. Required by
113 * the live API (defaulted to `{}` when omitted).
114 * @default {}
115 */
116 configDiff?: unknown;
117};
118
119/**
120 * Participant permissions granted by a preset.
121 */
122export type PresetPermissions = {
123 acceptWaitingRequests: boolean;
124 canAcceptProductionRequests: boolean;
125 canChangeParticipantPermissions: boolean;
126 canEditDisplayName: boolean;
127 canLivestream: boolean;
128 canRecord: boolean;
129 canSpotlight: boolean;
130 chat: {
131 private: {
132 canReceive: boolean;
133 canSend: boolean;
134 files: boolean;
135 text: boolean;
136 };
137 public: { canSend: boolean; files: boolean; text: boolean };
138 };
139 connectedMeetings: {
140 canAlterConnectedMeetings: boolean;
141 canSwitchConnectedMeetings: boolean;
142 canSwitchToParentMeeting: boolean;
143 };
144 disableParticipantAudio: boolean;
145 disableParticipantScreensharing: boolean;
146 disableParticipantVideo: boolean;
147 hiddenParticipant: boolean;
148 kickParticipant: boolean;
149 media: {
150 audio: { canProduce: CanProduce };
151 screenshare: { canProduce: CanProduce };
152 video: { canProduce: CanProduce };
153 };
154 pinParticipant: boolean;
155 plugins: {
156 canClose: boolean;
157 canEditConfig: boolean;
158 canStart: boolean;
159 /**
160 * Per-plugin access config keyed by plugin UUID.
161 * @default {}
162 */
163 config?: unknown;
164 };
165 polls: { canCreate: boolean; canView: boolean; canVote: boolean };
166 recorderType: RecorderType;
167 showParticipantList: boolean;
168 waitingRoomType: WaitingRoomType;
169 isRecorder?: boolean;
170};
171
172export type PresetProps = {
173 /**
174 * The RealtimeKit app the preset belongs to. Changing the app triggers a
175 * replacement.
176 */
177 appId: string;
178 /**
179 * Human readable preset name (e.g. `host`, `guest`). If omitted, a unique
180 * name is generated from the app, stage, and logical ID.
181 * @default ${app}-${stage}-${id}
182 */
183 name?: string;
184 /**
185 * Media configuration (stream counts, quality, frame rates).
186 * @default a group-call config (hd video at 30fps, hd screenshare at 5fps, 9 desktop / 4 mobile streams)
187 */
188 config?: PresetConfig;
189 /**
190 * UI design tokens.
191 * @default RealtimeKit's dark theme defaults
192 */
193 ui?: PresetUi;
194 /**
195 * Participant permissions. The API requires the full object on create, so
196 * unspecified permissions fall back to conservative attendee defaults
197 * (chat and polls allowed, no recording / livestreaming / moderation).
198 * @default conservative attendee defaults
199 */
200 permissions?: PresetPermissions;
201};
202
203export type PresetAttributes = {
204 /**
205 * Server-generated preset identifier. Stable across updates.
206 */
207 presetId: string;
208 /**
209 * The Cloudflare account the preset belongs to.
210 */
211 accountId: string;
212 /**
213 * The RealtimeKit app the preset belongs to.
214 */
215 appId: string;
216 /**
217 * Human readable preset name.
218 */
219 name: string;
220 /**
221 * Media configuration as stored by Cloudflare.
222 */
223 config: PresetConfig;
224 /**
225 * UI design tokens as stored by Cloudflare.
226 */
227 ui: PresetUi;
228 /**
229 * Participant permissions as stored by Cloudflare.
230 */
231 permissions: PresetPermissions | undefined;
232};
233
234export type Preset = Resource<
235 TypeId,
236 PresetProps,
237 PresetAttributes,
238 never,
239 Providers
240>;
241
242/**
243 * A Cloudflare RealtimeKit preset — a named participant role (e.g. `host`,
244 * `guest`) bundling permissions, media quality, and UI design tokens for a
245 * RealtimeKit app.
246 *
247 * Name, config, UI, and permissions are all mutable in place; only moving
248 * the preset to a different app forces a replacement. The create API
249 * requires the full config / UI / permissions objects, so the resource fills
250 * unspecified sections with sensible defaults.
251 * ### Creating a Preset
252 * **Example:** Default group-call preset
253 * ```typescript
254 * const app = yield* Cloudflare.RealtimeKit.App("Meetings", {});
255 *
256 * const guest = yield* Cloudflare.RealtimeKit.Preset("Guest", {
257 * appId: app.appId,
258 * name: "guest",
259 * });
260 * ```
261 *
262 * **Example:** Host preset with moderation permissions
263 * ```typescript
264 * const host = yield* Cloudflare.RealtimeKit.Preset("Host", {
265 * appId: app.appId,
266 * name: "host",
267 * permissions: {
268 * ...Cloudflare.RealtimeKit.defaultRealtimeKitPresetPermissions(),
269 * canRecord: true,
270 * kickParticipant: true,
271 * pinParticipant: true,
272 * acceptWaitingRequests: true,
273 * },
274 * });
275 * ```
276 *
277 * ### Updating a Preset
278 * **Example:** Switch to a webinar layout
279 * ```typescript
280 * const preset = yield* Cloudflare.RealtimeKit.Preset("Guest", {
281 * appId: app.appId,
282 * name: "guest",
283 * config: {
284 * ...Cloudflare.RealtimeKit.defaultRealtimeKitPresetConfig(),
285 * viewType: "WEBINAR",
286 * },
287 * });
288 * ```
289 *
290 * @see https://developers.cloudflare.com/realtime/realtimekit/
291 *
292 * @resource
293 * @product Realtime Kit
294 * @category Media
295 */
296export const Preset = Resource<Preset>(TypeId);
297
298/**
299 * Returns true if the given value is a Preset resource.
300 */
301export const isPreset = (value: unknown): value is Preset =>
302 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
303
304/**
305 * The default media configuration used when `config` is omitted: a group
306 * call with hd video at 30fps, hd screenshare at 5fps, and 9 desktop /
307 * 4 mobile video streams.
308 */
309export const defaultRealtimeKitPresetConfig = (): PresetConfig => ({
310 maxScreenshareCount: 1,
311 maxVideoStreams: { desktop: 9, mobile: 4 },
312 media: {
313 screenshare: { frameRate: 5, quality: "hd" },
314 video: { frameRate: 30, quality: "hd" },
315 },
316 viewType: "GROUP_CALL",
317});
318
319/**
320 * The default UI design tokens used when `ui` is omitted: RealtimeKit's
321 * dark theme.
322 */
323export const defaultRealtimeKitPresetUi = (): PresetUi => ({
324 designTokens: {
325 borderRadius: "rounded",
326 borderWidth: "thin",
327 colors: {
328 background: {
329 "600": "#1f2937",
330 "700": "#1a232e",
331 "800": "#141d26",
332 "900": "#10171e",
333 "1000": "#0b1116",
334 },
335 brand: {
336 "300": "#93c5fd",
337 "400": "#60a5fa",
338 "500": "#3b82f6",
339 "600": "#2563eb",
340 "700": "#1d4ed8",
341 },
342 danger: "#ff2d2d",
343 success: "#62a504",
344 text: "#ffffff",
345 textOnBrand: "#ffffff",
346 videoBg: "#191919",
347 warning: "#ffc107",
348 },
349 logo: "",
350 spacingBase: 4,
351 theme: "dark",
352 },
353 configDiff: {},
354});
355
356/**
357 * The default permissions used when `permissions` is omitted: a conservative
358 * attendee role — chat, polls, and media production allowed; recording,
359 * livestreaming, and moderation denied.
360 */
361export const defaultRealtimeKitPresetPermissions = (): PresetPermissions => ({
362 acceptWaitingRequests: false,
363 canAcceptProductionRequests: false,
364 canChangeParticipantPermissions: false,
365 canEditDisplayName: false,
366 canLivestream: false,
367 canRecord: false,
368 canSpotlight: false,
369 chat: {
370 private: { canReceive: true, canSend: true, files: true, text: true },
371 public: { canSend: true, files: true, text: true },
372 },
373 connectedMeetings: {
374 canAlterConnectedMeetings: false,
375 canSwitchConnectedMeetings: false,
376 canSwitchToParentMeeting: false,
377 },
378 disableParticipantAudio: false,
379 disableParticipantScreensharing: false,
380 disableParticipantVideo: false,
381 hiddenParticipant: false,
382 kickParticipant: false,
383 media: {
384 audio: { canProduce: "ALLOWED" },
385 screenshare: { canProduce: "ALLOWED" },
386 video: { canProduce: "ALLOWED" },
387 },
388 pinParticipant: false,
389 plugins: {
390 canClose: true,
391 canEditConfig: true,
392 canStart: true,
393 config: {},
394 },
395 polls: { canCreate: true, canView: true, canVote: true },
396 recorderType: "NONE",
397 showParticipantList: true,
398 waitingRoomType: "SKIP",
399});
400
401export const PresetProvider = () =>
402 Provider.succeed(Preset, {
403 stables: ["presetId", "accountId", "appId"],
404 diff: Effect.fn(function* ({ olds, news, output }) {
405 if (!isResolved(news)) return undefined;
406 const { accountId } = yield* yield* CloudflareEnvironment;
407 if ((output?.accountId ?? accountId) !== accountId) {
408 return { action: "replace" } as const;
409 }
410 // The app is a path parameter — a preset cannot move between apps in
411 // place. By diff time both sides are resolved strings.
412 const oldAppId = output?.appId ?? olds?.appId;
413 if (
414 typeof oldAppId === "string" &&
415 typeof news.appId === "string" &&
416 oldAppId !== news.appId
417 ) {
418 return { action: "replace" } as const;
419 }
420 return undefined;
421 }),
422 read: Effect.fn(function* ({ id, olds, output }) {
423 const { accountId } = yield* yield* CloudflareEnvironment;
424 const acct = output?.accountId ?? accountId;
425 const appId = output?.appId ?? (olds?.appId as string | undefined);
426 if (appId === undefined) return undefined;
427
428 if (output?.presetId) {
429 const observed = yield* getPreset(acct, appId, output.presetId);
430 return observed ? toAttributes(observed, acct, appId) : undefined;
431 }
432 // Cold read — recover from lost state by matching the deterministic
433 // physical name. Names are not unique on Cloudflare's side; an exact
434 // match on our generated/explicit name is the best identity we have.
435 const name = yield* createPresetName(id, olds?.name);
436 const match = yield* findByName(acct, appId, name);
437 if (match?.id) {
438 const observed = yield* getPreset(acct, appId, match.id);
439 return observed ? toAttributes(observed, acct, appId) : undefined;
440 }
441 return undefined;
442 }),
443 reconcile: Effect.fn(function* ({ id, news, output }) {
444 const { accountId } = yield* yield* CloudflareEnvironment;
445 const appId = news.appId as string;
446 const name = yield* createPresetName(id, news.name);
447 const desired = {
448 name,
449 config: news.config ?? defaultRealtimeKitPresetConfig(),
450 ui: withUiDefaults(news.ui),
451 permissions: withPermissionsDefaults(news.permissions),
452 };
453
454 // Observe — the presetId cached on `output` is a hint, not a
455 // guarantee: a missing preset falls through and we recreate.
456 const observed = output?.presetId
457 ? yield* getPreset(
458 output.accountId ?? accountId,
459 appId,
460 output.presetId,
461 )
462 : undefined;
463
464 if (!observed) {
465 // Ensure — greenfield (or out-of-band delete): create with the full
466 // desired body. The API rejects a duplicate name in the same app
467 // with a 409 (`RealtimeKitPresetExists`); this happens when a prior
468 // run leaked a same-named preset (lost state) or when a retried
469 // create races a 500 that actually persisted. Adopt the existing
470 // preset by name and converge it to the desired body instead of
471 // leaking / failing.
472 const created = yield* realtimeKit
473 .createPreset({ accountId, appId, ...desired })
474 .pipe(
475 Effect.catchTag("RealtimeKitPresetExists", () =>
476 Effect.succeed(undefined),
477 ),
478 );
479 if (created) {
480 return toAttributes(created.data, accountId, appId);
481 }
482 const existing = yield* findByName(accountId, appId, name);
483 if (!existing?.id) {
484 // Conflict reported but the preset isn't visible yet — let the
485 // engine retry the reconcile rather than silently succeeding.
486 return yield* realtimeKit
487 .createPreset({ accountId, appId, ...desired })
488 .pipe(
489 Effect.map((res) => toAttributes(res.data, accountId, appId)),
490 );
491 }
492 yield* realtimeKit.patchPreset({
493 accountId,
494 appId,
495 presetId: existing.id,
496 ...desired,
497 });
498 const adopted = yield* realtimeKit.getPresetByIdPreset({
499 accountId,
500 appId,
501 presetId: existing.id,
502 });
503 return toAttributes(adopted.data, accountId, appId);
504 }
505
506 // Sync — diff what the user asked for against observed cloud state.
507 // The API fills/echoes extra server-side fields (e.g. `simulcast`),
508 // so compare only the desired keys against the observed echo.
509 if (subsetEquals(desired, observed)) {
510 return toAttributes(observed, accountId, appId);
511 }
512 yield* realtimeKit.patchPreset({
513 accountId,
514 appId,
515 presetId: observed.id,
516 ...desired,
517 });
518 // The PATCH response omits the preset id — re-read for fresh state.
519 const fresh = yield* realtimeKit.getPresetByIdPreset({
520 accountId,
521 appId,
522 presetId: observed.id,
523 });
524 return toAttributes(fresh.data, accountId, appId);
525 }),
526 delete: Effect.fn(function* ({ output }) {
527 yield* realtimeKit
528 .deletePreset({
529 accountId: output.accountId,
530 appId: output.appId,
531 presetId: output.presetId,
532 })
533 .pipe(Effect.catchTag("RealtimeKitPresetNotFound", () => Effect.void));
534 }),
535 // Presets are sub-resources of a RealtimeKit app. There is no
536 // account-wide preset enumeration API, so fan out: list every app in the
537 // account, then list every preset within each app. The per-app preset
538 // list returns only a slim `{ id, name }` shape, so each row is hydrated
539 // via `getPresetByIdPreset` into the full `read` Attributes shape.
540 list: Effect.fn(function* () {
541 const { accountId } = yield* yield* CloudflareEnvironment;
542 const apps = yield* listAllApps(accountId);
543 const rows = yield* Effect.forEach(
544 apps,
545 (app) =>
546 Effect.gen(function* () {
547 const appId = app.id;
548 if (!appId) return [];
549 const presets = yield* listAllPresets(accountId, appId);
550 const hydrated = yield* Effect.forEach(
551 presets,
552 (preset) =>
553 preset.id
554 ? getPreset(accountId, appId, preset.id).pipe(
555 Effect.map((observed) =>
556 observed
557 ? toAttributes(observed, accountId, appId)
558 : undefined,
559 ),
560 )
561 : Effect.succeed(undefined),
562 { concurrency: 10 },
563 );
564 return hydrated.filter(
565 (row): row is PresetAttributes => row !== undefined,
566 );
567 }),
568 { concurrency: 10 },
569 );
570 return rows.flat();
571 }),
572 });
573
574const LIST_PER_PAGE = 100;
575
576/**
577 * Exhaustively enumerate every RealtimeKit app in the account. The list op is
578 * not generated as a paginated method, so fetch the first page, derive the
579 * page count from `paging.totalCount`, then fan out the remaining pages.
580 */
581const listAllApps = (accountId: string) =>
582 Effect.gen(function* () {
583 const first = yield* realtimeKit.getApp({
584 accountId,
585 pageNo: 1,
586 perPage: LIST_PER_PAGE,
587 });
588 const apps = (first.data ?? []).filter(
589 (a): a is NonNullable<typeof a> => a !== null,
590 );
591 const total = first.paging?.totalCount ?? apps.length;
592 const pages = Math.ceil(total / LIST_PER_PAGE);
593 if (pages <= 1) return apps;
594 const rest = yield* Effect.forEach(
595 Array.from({ length: pages - 1 }, (_, i) => i + 2),
596 (pageNo) =>
597 realtimeKit
598 .getApp({ accountId, pageNo, perPage: LIST_PER_PAGE })
599 .pipe(
600 Effect.map((res) =>
601 (res.data ?? []).filter(
602 (a): a is NonNullable<typeof a> => a !== null,
603 ),
604 ),
605 ),
606 { concurrency: 10 },
607 );
608 return [...apps, ...rest.flat()];
609 });
610
611/**
612 * Exhaustively enumerate every preset within a single app, paginating off
613 * `paging.totalCount` the same way as {@link listAllApps}.
614 */
615const listAllPresets = (accountId: string, appId: string) =>
616 Effect.gen(function* () {
617 const first = yield* realtimeKit.getPreset({
618 accountId,
619 appId,
620 pageNo: 1,
621 perPage: LIST_PER_PAGE,
622 });
623 const presets = [...first.data];
624 const total = first.paging?.totalCount ?? presets.length;
625 const pages = Math.ceil(total / LIST_PER_PAGE);
626 if (pages <= 1) return presets;
627 const rest = yield* Effect.forEach(
628 Array.from({ length: pages - 1 }, (_, i) => i + 2),
629 (pageNo) =>
630 realtimeKit
631 .getPreset({ accountId, appId, pageNo, perPage: LIST_PER_PAGE })
632 .pipe(Effect.map((res) => [...res.data])),
633 { concurrency: 10 },
634 );
635 return [...presets, ...rest.flat()];
636 });
637
638type ObservedPreset = realtimeKit.GetPresetByIdPresetResponse["data"];
639
640/**
641 * Read a preset by id, mapping "gone" (`RealtimeKitPresetNotFound`,
642 * HTTP 404) to `undefined`.
643 */
644const getPreset = (accountId: string, appId: string, presetId: string) =>
645 realtimeKit.getPresetByIdPreset({ accountId, appId, presetId }).pipe(
646 Effect.map((res) => res.data),
647 Effect.catchTag("RealtimeKitPresetNotFound", () =>
648 Effect.succeed(undefined),
649 ),
650 );
651
652/**
653 * Find a preset by exact name in the app's preset list. If several presets
654 * carry the same name, pick the oldest for determinism.
655 */
656const findByName = (accountId: string, appId: string, name: string) =>
657 realtimeKit.getPreset({ accountId, appId, perPage: 100 }).pipe(
658 Effect.map((list) =>
659 [...list.data]
660 .filter((p) => p.name === name)
661 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
662 .at(0),
663 ),
664 );
665
666const createPresetName = (id: string, name: string | undefined) =>
667 Effect.gen(function* () {
668 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
669 });
670
671const withUiDefaults = (
672 ui: PresetUi | undefined,
673): PresetUi & { configDiff: unknown } => {
674 const base = ui ?? defaultRealtimeKitPresetUi();
675 // `config_diff` is required by the API even though it's incidental —
676 // default it so users don't have to care.
677 return { ...base, configDiff: base.configDiff ?? {} };
678};
679
680/**
681 * Fill `plugins.config` (required by the create API even though it's
682 * incidental) so users don't have to care. The return type intentionally
683 * stays inferred: `CreatePresetRequest` requires `plugins.config` to be
684 * present, and annotating `PresetPermissions` (where it is
685 * optional) would erase the guarantee the literal provides.
686 */
687const withPermissionsDefaults = (
688 permissions: PresetPermissions | undefined,
689) => {
690 const base = permissions ?? defaultRealtimeKitPresetPermissions();
691 return {
692 ...base,
693 plugins: {
694 ...base.plugins,
695 config: (base.plugins.config ?? {}) as unknown,
696 },
697 };
698};
699
700/**
701 * Deep "is `desired` a subset of `observed`" comparison. The RealtimeKit
702 * API echoes extra server-computed fields (e.g. `simulcast`) alongside what
703 * we sent, so a strict deep-equal would always report drift.
704 */
705const subsetEquals = (desired: unknown, observed: unknown): boolean => {
706 if (
707 typeof desired === "object" &&
708 desired !== null &&
709 typeof observed === "object" &&
710 observed !== null &&
711 !Array.isArray(desired) &&
712 !Array.isArray(observed)
713 ) {
714 return Object.entries(desired as Record<string, unknown>).every(
715 ([key, value]) =>
716 value === undefined ||
717 subsetEquals(value, (observed as Record<string, unknown>)[key]),
718 );
719 }
720 if (Array.isArray(desired) && Array.isArray(observed)) {
721 return (
722 desired.length === observed.length &&
723 desired.every((value, i) => subsetEquals(value, observed[i]))
724 );
725 }
726 return desired === observed;
727};
728
729const toAttributes = (
730 preset: ObservedPreset,
731 accountId: string,
732 appId: string,
733): PresetAttributes => ({
734 presetId: preset.id,
735 accountId,
736 appId,
737 name: preset.name,
738 // Distilled widens generated string enums to open unions (`string & {}`)
739 // and marks echoes readonly — re-shape into our prop types.
740 config: {
741 maxScreenshareCount: preset.config.maxScreenshareCount,
742 maxVideoStreams: {
743 desktop: preset.config.maxVideoStreams.desktop,
744 mobile: preset.config.maxVideoStreams.mobile,
745 },
746 media: {
747 screenshare: {
748 frameRate: preset.config.media.screenshare.frameRate,
749 quality: preset.config.media.screenshare.quality as MediaQuality,
750 },
751 video: {
752 frameRate: preset.config.media.video.frameRate,
753 quality: preset.config.media.video.quality as MediaQuality,
754 },
755 ...(preset.config.media.audio
756 ? {
757 audio: {
758 ...(preset.config.media.audio.enableHighBitrate != null
759 ? {
760 enableHighBitrate:
761 preset.config.media.audio.enableHighBitrate,
762 }
763 : {}),
764 ...(preset.config.media.audio.enableStereo != null
765 ? { enableStereo: preset.config.media.audio.enableStereo }
766 : {}),
767 },
768 }
769 : {}),
770 },
771 viewType: preset.config.viewType as ViewType,
772 },
773 ui: {
774 designTokens: {
775 borderRadius: preset.ui.designTokens.borderRadius as BorderRadius,
776 borderWidth: preset.ui.designTokens.borderWidth as BorderWidth,
777 colors: {
778 background: { ...preset.ui.designTokens.colors.background },
779 brand: { ...preset.ui.designTokens.colors.brand },
780 danger: preset.ui.designTokens.colors.danger,
781 success: preset.ui.designTokens.colors.success,
782 text: preset.ui.designTokens.colors.text,
783 textOnBrand: preset.ui.designTokens.colors.textOnBrand,
784 videoBg: preset.ui.designTokens.colors.videoBg,
785 warning: preset.ui.designTokens.colors.warning,
786 },
787 logo: preset.ui.designTokens.logo ?? "",
788 spacingBase: preset.ui.designTokens.spacingBase,
789 theme: preset.ui.designTokens.theme as Theme,
790 },
791 configDiff: preset.ui.configDiff ?? {},
792 },
793 permissions: preset.permissions
794 ? {
795 acceptWaitingRequests: preset.permissions.acceptWaitingRequests,
796 canAcceptProductionRequests:
797 preset.permissions.canAcceptProductionRequests,
798 canChangeParticipantPermissions:
799 preset.permissions.canChangeParticipantPermissions,
800 canEditDisplayName: preset.permissions.canEditDisplayName,
801 canLivestream: preset.permissions.canLivestream,
802 canRecord: preset.permissions.canRecord,
803 canSpotlight: preset.permissions.canSpotlight,
804 chat: {
805 private: { ...preset.permissions.chat.private },
806 public: { ...preset.permissions.chat.public },
807 },
808 connectedMeetings: { ...preset.permissions.connectedMeetings },
809 disableParticipantAudio: preset.permissions.disableParticipantAudio,
810 disableParticipantScreensharing:
811 preset.permissions.disableParticipantScreensharing,
812 disableParticipantVideo: preset.permissions.disableParticipantVideo,
813 hiddenParticipant: preset.permissions.hiddenParticipant,
814 kickParticipant: preset.permissions.kickParticipant,
815 media: {
816 audio: {
817 canProduce: preset.permissions.media.audio.canProduce as CanProduce,
818 },
819 screenshare: {
820 canProduce: preset.permissions.media.screenshare
821 .canProduce as CanProduce,
822 },
823 video: {
824 canProduce: preset.permissions.media.video.canProduce as CanProduce,
825 },
826 },
827 pinParticipant: preset.permissions.pinParticipant,
828 plugins: {
829 canClose: preset.permissions.plugins.canClose,
830 canEditConfig: preset.permissions.plugins.canEditConfig,
831 canStart: preset.permissions.plugins.canStart,
832 config: preset.permissions.plugins.config ?? {},
833 },
834 polls: { ...preset.permissions.polls },
835 recorderType: preset.permissions.recorderType as RecorderType,
836 showParticipantList: preset.permissions.showParticipantList,
837 waitingRoomType: preset.permissions.waitingRoomType as WaitingRoomType,
838 ...(preset.permissions.isRecorder != null
839 ? { isRecorder: preset.permissions.isRecorder }
840 : {}),
841 }
842 : undefined,
843});