From 0d5e4873853bc8e634c6dd801677013272cb5a80 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:25:44 -0400 Subject: [PATCH] fps controls --- src/renderer/components/BottomBar.tsx | 42 ++++++ src/renderer/components/ScopePopoutBridge.tsx | 29 ++-- src/renderer/components/Strip.tsx | 20 ++- src/renderer/popouts/ScopePopoutWindow.tsx | 8 +- src/renderer/stores/performanceStore.ts | 139 ++++++++++++++++++ src/renderer/styles/globals.css | 16 ++ src/renderer/visualizers/frameScheduler.ts | 108 +++++++++++++- src/types/performance.ts | 7 + test/profile-library.test.ts | 1 + test/renderer-helpers.test.ts | 123 +++++++++++++++- 10 files changed, 475 insertions(+), 18 deletions(-) create mode 100644 src/renderer/stores/performanceStore.ts create mode 100644 src/types/performance.ts diff --git a/src/renderer/components/BottomBar.tsx b/src/renderer/components/BottomBar.tsx index 63bddaf..bc8a95c 100644 --- a/src/renderer/components/BottomBar.tsx +++ b/src/renderer/components/BottomBar.tsx @@ -1,8 +1,10 @@ import { useEffect, useLayoutEffect, useRef, type CSSProperties, type JSX } from 'react' import { useAudioStore } from '../stores/audioStore' +import { usePerformanceStore } from '../stores/performanceStore' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore, PRESETS, PRESET_IDS } from '../stores/themeStore' import type { ScopeKind } from '../../types/scope' +import { VISUALIZER_FRAME_TARGETS, type VisualizerFrameTarget } from '../../types/performance' import { SCOPE_KINDS } from '../../types/scope' const SCOPE_LABELS: Record = { @@ -20,11 +22,23 @@ interface BottomBarProps { onHeightChange?: (height: number) => void } +const FRAME_TARGET_LABELS: Record = { + 10: '10', + 30: '30', + 60: '60', + 120: '120', + 144: '144', + 'display-sync': 'Sync', +} + export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): JSX.Element { const rootRef = useRef(null) const hiddenScopes = useSettingsStore((s) => s.hiddenScopes) const toggleScope = useSettingsStore((s) => s.toggleScope) + const frameTarget = usePerformanceStore((s) => s.frameTarget) + const dockedRenderFps = usePerformanceStore((s) => s.dockedRenderFps) + const setFrameTarget = usePerformanceStore((s) => s.setFrameTarget) const { presetId, accent, setPreset, setCustomAccent, customAccent } = useThemeStore() const { @@ -113,6 +127,7 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps): : 'Idle' const trimPercent = Math.min(100, Math.max(0, ((inputGainDb + 12) / 24) * 100)) + const roundedDockedRenderFps = Math.max(0, Math.round(dockedRenderFps)) return (
@@ -226,6 +241,33 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
+
+
Performance
+
+
+
+ {VISUALIZER_FRAME_TARGETS.map((target) => ( + + ))} +
+ +
+ {roundedDockedRenderFps} FPS +
+
+
+
+ +
+
Trim
diff --git a/src/renderer/components/ScopePopoutBridge.tsx b/src/renderer/components/ScopePopoutBridge.tsx index 195e3a2..d9fcba4 100644 --- a/src/renderer/components/ScopePopoutBridge.tsx +++ b/src/renderer/components/ScopePopoutBridge.tsx @@ -1,7 +1,9 @@ import { useEffect, useMemo, useRef } from 'react' import { audioRouter } from '../audio/AudioRouter' +import { usePerformanceStore } from '../stores/performanceStore' import { useSettingsStore } from '../stores/settingsStore' import { useThemeStore } from '../stores/themeStore' +import { FrameScheduler } from '../visualizers/frameScheduler' import type { ScopePopoutAudioBatch, ScopePopoutSessionState, @@ -59,14 +61,20 @@ export default function ScopePopoutBridge(): null { const updatePopoutBounds = useSettingsStore((s) => s.updatePopoutBounds) const updateScopeSettings = useSettingsStore((s) => s.updateScopeSettings) const accent = useThemeStore((s) => s.accent) + const frameTarget = usePerformanceStore((s) => s.frameTarget) const activePopoutKinds = useMemo( () => SCOPE_KINDS.filter((kind) => scopePopouts[kind]?.poppedOut && !hiddenScopes.has(kind)), [hiddenScopes, scopePopouts], ) + const flushScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) const activePopoutKindsRef = useRef(activePopoutKinds) const sessionStateRef = useRef(audioRouter.getSessionState()) + useEffect(() => { + flushScheduler.setFrameTarget(frameTarget) + }, [flushScheduler, frameTarget]) + useEffect(() => { activePopoutKindsRef.current = activePopoutKinds }, [activePopoutKinds]) @@ -153,10 +161,9 @@ export default function ScopePopoutBridge(): null { }, [activePopoutKinds]) useEffect(() => { - let frameId = 0 + let unsubscribeFlush: (() => void) | null = null const flushFrame = (): void => { - frameId = 0 if (!sessionStateRef.current.capturing || activePopoutKindsRef.current.length === 0) { return } @@ -167,22 +174,20 @@ export default function ScopePopoutBridge(): null { window.electronAPI.sendScopePopoutAudio(kind, batch) } } - - frameId = window.requestAnimationFrame(flushFrame) } const syncFlushLoop = (): void => { const shouldRun = sessionStateRef.current.capturing && activePopoutKindsRef.current.length > 0 if (!shouldRun) { - if (frameId) { - window.cancelAnimationFrame(frameId) - frameId = 0 + if (unsubscribeFlush) { + unsubscribeFlush() + unsubscribeFlush = null } return } - if (!frameId) { - frameId = window.requestAnimationFrame(flushFrame) + if (!unsubscribeFlush) { + unsubscribeFlush = flushScheduler.subscribe(flushFrame) } } @@ -194,12 +199,12 @@ export default function ScopePopoutBridge(): null { syncFlushLoop() return () => { - if (frameId) { - window.cancelAnimationFrame(frameId) + if (unsubscribeFlush) { + unsubscribeFlush() } unsubscribeSession() } - }, [activePopoutKinds]) + }, [activePopoutKinds, flushScheduler]) useEffect(() => { return audioRouter.subscribeToSessionChanges((state) => { diff --git a/src/renderer/components/Strip.tsx b/src/renderer/components/Strip.tsx index 1e25d18..b0962c0 100644 --- a/src/renderer/components/Strip.tsx +++ b/src/renderer/components/Strip.tsx @@ -6,6 +6,7 @@ import type { WindowBounds } from '../../types/popout' import ScopeModule from './ScopeModule' import { buildAnalyzerGridTemplateColumns } from '../analyzerLayout' import { audioRouter } from '../audio/AudioRouter' +import { usePerformanceStore } from '../stores/performanceStore' import { FrameScheduler } from '../visualizers/frameScheduler' export default function Strip(): JSX.Element { @@ -17,7 +18,9 @@ export default function Strip(): JSX.Element { const setScopeWidthWeight = useSettingsStore((s) => s.setScopeWidthWeight) const popOutScope = useSettingsStore((s) => s.popOutScope) const accent = useThemeStore((s) => s.accent) - const frameScheduler = useMemo(() => new FrameScheduler(), []) + const frameTarget = usePerformanceStore((s) => s.frameTarget) + const setDockedRenderFps = usePerformanceStore((s) => s.setDockedRenderFps) + const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) const stripRef = useRef(null) const gridRef = useRef(null) const scopeRefs = useRef>>({}) @@ -35,6 +38,21 @@ export default function Strip(): JSX.Element { return { gridTemplateColumns } as CSSProperties }, [gridTemplateColumns]) + useEffect(() => { + frameScheduler.setFrameTarget(frameTarget) + }, [frameScheduler, frameTarget]) + + useEffect(() => { + const unsubscribe = frameScheduler.subscribeToActualFps((fps) => { + setDockedRenderFps(fps) + }) + + return () => { + unsubscribe() + setDockedRenderFps(0) + } + }, [frameScheduler, setDockedRenderFps]) + const updateHandleOffsets = useCallback((): void => { if (dockedScopes.length < 2) { setHandleOffsets([]) diff --git a/src/renderer/popouts/ScopePopoutWindow.tsx b/src/renderer/popouts/ScopePopoutWindow.tsx index b87374d..252a935 100644 --- a/src/renderer/popouts/ScopePopoutWindow.tsx +++ b/src/renderer/popouts/ScopePopoutWindow.tsx @@ -4,6 +4,7 @@ import { SCOPE_LABELS, type ScopeKind } from '../../types/scope' import { DEFAULT_SCOPE_SETTINGS, type ScopeSettings } from '../../types/settings' import ScopeModule from '../components/ScopeModule' import ScopeSettingsSection from '../components/ScopeSettingsSection' +import { usePerformanceStore } from '../stores/performanceStore' import { applyAccentToDOM } from '../stores/themeStore' import { ScopePopoutDataSource } from './ScopePopoutDataSource' import { FrameScheduler } from '../visualizers/frameScheduler' @@ -48,9 +49,14 @@ export default function ScopePopoutWindow({ scopeKind }: ScopePopoutWindowProps) const [snapshot, setSnapshot] = useState | null>(null) const [miniSettingsOpen, setMiniSettingsOpen] = useState(false) const prevMiniSettingsOpenRef = useRef(false) - const frameScheduler = useMemo(() => new FrameScheduler(), []) + const frameTarget = usePerformanceStore((s) => s.frameTarget) + const frameScheduler = useMemo(() => new FrameScheduler({ frameTarget }), []) const dataSource = useMemo(() => new ScopePopoutDataSource(scopeKind), [scopeKind]) + useEffect(() => { + frameScheduler.setFrameTarget(frameTarget) + }, [frameScheduler, frameTarget]) + useEffect(() => { const unsubscribeSnapshot = window.electronAPI.onScopePopoutSnapshot((nextSnapshot) => { if (nextSnapshot.kind !== scopeKind) return diff --git a/src/renderer/stores/performanceStore.ts b/src/renderer/stores/performanceStore.ts new file mode 100644 index 0000000..92cb727 --- /dev/null +++ b/src/renderer/stores/performanceStore.ts @@ -0,0 +1,139 @@ +import { create } from 'zustand' +import { isVisualizerFrameTarget, type VisualizerFrameTarget } from '../../types/performance' + +const STORAGE_KEY = 'prism:performance' +const SYNC_CHANNEL_NAME = 'prism:performance' + +interface PersistedPerformanceState { + frameTarget: VisualizerFrameTarget +} + +interface PerformanceState { + frameTarget: VisualizerFrameTarget + dockedRenderFps: number + setFrameTarget: (target: VisualizerFrameTarget) => void + setDockedRenderFps: (fps: number) => void +} + +interface StorageLike { + getItem: (key: string) => string | null + setItem: (key: string, value: string) => void +} + +function getStorage(): StorageLike | null { + if (typeof localStorage === 'undefined') { + return null + } + + return localStorage +} + +export function normalizePerformancePreferences(raw: unknown): PersistedPerformanceState { + const parsed = typeof raw === 'object' && raw !== null + ? raw as Partial + : {} + + return { + frameTarget: isVisualizerFrameTarget(parsed.frameTarget) ? parsed.frameTarget : 'display-sync', + } +} + +export function loadPerformancePreferences(storage = getStorage()): PersistedPerformanceState { + if (!storage) { + return normalizePerformancePreferences(null) + } + + try { + const raw = storage.getItem(STORAGE_KEY) + if (!raw) { + return normalizePerformancePreferences(null) + } + + return normalizePerformancePreferences(JSON.parse(raw)) + } catch { + return normalizePerformancePreferences(null) + } +} + +function persistPerformancePreferences(target: VisualizerFrameTarget, storage = getStorage()): void { + if (!storage) return + + try { + storage.setItem(STORAGE_KEY, JSON.stringify({ frameTarget: target })) + } catch { + // Ignore localStorage write failures. + } +} + +const storedPreferences = loadPerformancePreferences() + +export const usePerformanceStore = create((set) => ({ + frameTarget: storedPreferences.frameTarget, + dockedRenderFps: 0, + + setFrameTarget: (target: VisualizerFrameTarget) => { + persistPerformancePreferences(target) + broadcastFrameTarget(target) + set((state) => { + if (state.frameTarget === target) return state + return { ...state, frameTarget: target } + }) + }, + + setDockedRenderFps: (fps: number) => { + const nextFps = Number.isFinite(fps) && fps > 0 ? fps : 0 + set((state) => { + if (state.dockedRenderFps === nextFps) return state + return { ...state, dockedRenderFps: nextFps } + }) + }, +})) + +let syncChannel: BroadcastChannel | null = null +let syncBound = false + +function getSyncChannel(): BroadcastChannel | null { + if (syncChannel !== null || typeof window === 'undefined' || typeof BroadcastChannel === 'undefined') { + return syncChannel + } + + syncChannel = new BroadcastChannel(SYNC_CHANNEL_NAME) + return syncChannel +} + +function applyExternalFrameTarget(raw: unknown): void { + if (!isVisualizerFrameTarget(raw)) return + if (usePerformanceStore.getState().frameTarget === raw) return + usePerformanceStore.setState({ frameTarget: raw }) +} + +function broadcastFrameTarget(target: VisualizerFrameTarget): void { + getSyncChannel()?.postMessage({ frameTarget: target }) +} + +function bindCrossWindowSync(): void { + if (syncBound || typeof window === 'undefined') { + return + } + + syncBound = true + + if (typeof window.addEventListener === 'function') { + window.addEventListener('storage', (event: StorageEvent) => { + if (event.key !== STORAGE_KEY || typeof event.newValue !== 'string') return + + try { + const parsed = JSON.parse(event.newValue) as PersistedPerformanceState + applyExternalFrameTarget(parsed.frameTarget) + } catch { + // Ignore invalid sync payloads. + } + }) + } + + getSyncChannel()?.addEventListener('message', (event: MessageEvent<{ frameTarget?: unknown }>) => { + applyExternalFrameTarget(event.data?.frameTarget) + }) +} + +bindCrossWindowSync() diff --git a/src/renderer/styles/globals.css b/src/renderer/styles/globals.css index 4ef93af..eee5aa3 100644 --- a/src/renderer/styles/globals.css +++ b/src/renderer/styles/globals.css @@ -906,6 +906,10 @@ select { min-width: 360px; } +.bottom-bar__section--performance { + min-width: 328px; +} + .bottom-bar__section--trim { min-width: 196px; } @@ -946,6 +950,10 @@ select { gap: 6px; } +.bottom-bar__inline--performance { + gap: 10px; +} + .bottom-bar__divider { width: 1px; align-self: stretch; @@ -974,6 +982,14 @@ select { max-width: 280px; } +.bottom-bar__fps-pill { + min-width: 84px; + justify-content: center; + color: rgba(255, 255, 255, 0.84); + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.08em; +} + .bottom-bar__error-text { margin-top: 0; max-width: 360px; diff --git a/src/renderer/visualizers/frameScheduler.ts b/src/renderer/visualizers/frameScheduler.ts index 589da13..94e2385 100644 --- a/src/renderer/visualizers/frameScheduler.ts +++ b/src/renderer/visualizers/frameScheduler.ts @@ -1,8 +1,25 @@ export type FrameSchedulerCallback = () => void +import type { VisualizerFrameTarget } from '../../types/performance' + +const FPS_WINDOW_MS = 1000 +const TARGET_EPSILON_MS = 0.5 + +interface FrameSchedulerOptions { + frameTarget?: VisualizerFrameTarget +} + export class FrameScheduler { private callbacks = new Set() + private fpsListeners = new Set<(fps: number) => void>() + private dispatchTimestamps: number[] = [] private frameId: number | null = null + private actualFps = 0 + private frameTarget: VisualizerFrameTarget + + constructor(options: FrameSchedulerOptions = {}) { + this.frameTarget = options.frameTarget ?? 'display-sync' + } subscribe(callback: FrameSchedulerCallback): () => void { this.callbacks.add(callback) @@ -16,6 +33,30 @@ export class FrameScheduler { } } + setFrameTarget(target: VisualizerFrameTarget): void { + if (this.frameTarget === target) return + this.frameTarget = target + this.dispatchTimestamps = [] + this.updateActualFps(0) + } + + getFrameTarget(): VisualizerFrameTarget { + return this.frameTarget + } + + getActualFps(): number { + return this.actualFps + } + + subscribeToActualFps(listener: (fps: number) => void): () => void { + this.fpsListeners.add(listener) + listener(this.actualFps) + + return () => { + this.fpsListeners.delete(listener) + } + } + private start(): void { if (this.frameId !== null || this.callbacks.size === 0) { return @@ -29,18 +70,79 @@ export class FrameScheduler { window.cancelAnimationFrame(this.frameId) this.frameId = null } + + this.dispatchTimestamps = [] + this.updateActualFps(0) } - private tick = (): void => { + private tick = (timestamp: number): void => { this.frameId = null if (this.callbacks.size === 0) { return } - for (const callback of [...this.callbacks]) { - callback() + const now = Number.isFinite(timestamp) + ? timestamp + : typeof performance !== 'undefined' + ? performance.now() + : Date.now() + + if (this.shouldDispatchFrame(now)) { + this.recordDispatch(now) + for (const callback of [...this.callbacks]) { + callback() + } } this.start() } + + private shouldDispatchFrame(timestamp: number): boolean { + if (this.frameTarget === 'display-sync') { + return true + } + + const lastDispatchTimestamp = this.dispatchTimestamps[this.dispatchTimestamps.length - 1] + if (lastDispatchTimestamp === undefined) { + return true + } + + return timestamp - lastDispatchTimestamp >= (1000 / this.frameTarget) - TARGET_EPSILON_MS + } + + private recordDispatch(timestamp: number): void { + this.dispatchTimestamps.push(timestamp) + + const cutoff = timestamp - FPS_WINDOW_MS + while (this.dispatchTimestamps.length > 0 && this.dispatchTimestamps[0] < cutoff) { + this.dispatchTimestamps.shift() + } + + const nextFps = this.computeActualFps() + this.updateActualFps(nextFps) + } + + private computeActualFps(): number { + if (this.dispatchTimestamps.length < 2) { + return 0 + } + + const firstTimestamp = this.dispatchTimestamps[0] + const lastTimestamp = this.dispatchTimestamps[this.dispatchTimestamps.length - 1] + const elapsed = lastTimestamp - firstTimestamp + if (elapsed <= 0) { + return 0 + } + + return ((this.dispatchTimestamps.length - 1) * 1000) / elapsed + } + + private updateActualFps(fps: number): void { + if (this.actualFps === fps) return + + this.actualFps = fps + for (const listener of this.fpsListeners) { + listener(fps) + } + } } diff --git a/src/types/performance.ts b/src/types/performance.ts new file mode 100644 index 0000000..3d85d18 --- /dev/null +++ b/src/types/performance.ts @@ -0,0 +1,7 @@ +export const VISUALIZER_FRAME_TARGETS = [10, 30, 60, 120, 144, 'display-sync'] as const + +export type VisualizerFrameTarget = typeof VISUALIZER_FRAME_TARGETS[number] + +export function isVisualizerFrameTarget(value: unknown): value is VisualizerFrameTarget { + return VISUALIZER_FRAME_TARGETS.includes(value as VisualizerFrameTarget) +} diff --git a/test/profile-library.test.ts b/test/profile-library.test.ts index fd30422..e2dee73 100644 --- a/test/profile-library.test.ts +++ b/test/profile-library.test.ts @@ -56,6 +56,7 @@ test('profile file serialization excludes geometry and round-trips with local me assert.equal(file.format, PROFILE_FILE_FORMAT) assert.equal(file.version, PROFILE_FILE_VERSION) assert.equal(JSON.stringify(file).includes('windowBounds'), false) + assert.equal(JSON.stringify(file).includes('frameTarget'), false) assert.deepEqual(file.scopePopouts.spectrum, { poppedOut: true }) const restored = profileFileToProfile(file, extractLocalProfileMetadata(profile)) diff --git a/test/renderer-helpers.test.ts b/test/renderer-helpers.test.ts index 060312b..e01d813 100644 --- a/test/renderer-helpers.test.ts +++ b/test/renderer-helpers.test.ts @@ -6,11 +6,18 @@ import { parseColorToRgb, resolveColorToRgb, } from '../src/renderer/utils/color' +import { + createDefaultProfile, +} from '../src/shared/profileState' +import { usePerformanceStore } from '../src/renderer/stores/performanceStore' +import { + moveDockedScopeOrder, + useSettingsStore, +} from '../src/renderer/stores/settingsStore' import { applyInputGainToStereoSamples, inputGainDbToLinear, } from '../src/renderer/audio/inputGain' -import { moveDockedScopeOrder } from '../src/renderer/stores/settingsStore' import { SCOPE_KINDS, type ScopeKind } from '../src/types/scope' import type { ScopePopoutStateMap } from '../src/types/popout' import { @@ -19,6 +26,7 @@ import { VU_METER_MIN_DB, VU_PEAK_HOLD_MS, } from '../src/renderer/visualizers/vuMeterBallistics' +import { FrameScheduler } from '../src/renderer/visualizers/frameScheduler' import { VisualizerFrameLoop } from '../src/renderer/visualizers/visualizerFrameLoop' type WindowWithRaf = typeof globalThis & Pick @@ -153,6 +161,89 @@ test('applyInputGainToStereoSamples is a no-op for unity gain', () => { assert.deepEqual(Array.from(right), initialRight) }) +test('FrameScheduler dispatches every animation frame in display-sync mode', () => { + const raf = installFakeAnimationFrame() + + try { + const scheduler = new FrameScheduler({ frameTarget: 'display-sync' }) + let frameCount = 0 + const unsubscribe = scheduler.subscribe(() => { + frameCount += 1 + }) + + assert.equal(raf.pendingCount(), 1) + + raf.runFrame(0) + raf.runFrame(16.7) + raf.runFrame(33.4) + + assert.equal(frameCount, 3) + assert.equal(raf.pendingCount(), 1) + + unsubscribe() + assert.equal(raf.pendingCount(), 0) + } finally { + raf.restore() + } +}) + +test('FrameScheduler caps numeric frame targets by skipping intermediate animation frames', () => { + const raf = installFakeAnimationFrame() + + try { + const scheduler = new FrameScheduler({ frameTarget: 30 }) + let frameCount = 0 + const unsubscribe = scheduler.subscribe(() => { + frameCount += 1 + }) + + raf.runFrame(0) + raf.runFrame(16.7) + raf.runFrame(33.4) + raf.runFrame(50.1) + raf.runFrame(66.8) + + assert.equal(frameCount, 3) + assert.equal(raf.pendingCount(), 1) + + unsubscribe() + assert.equal(raf.pendingCount(), 0) + } finally { + raf.restore() + } +}) + +test('FrameScheduler updates cadence when the frame target changes without duplicating subscriptions', () => { + const raf = installFakeAnimationFrame() + + try { + const scheduler = new FrameScheduler({ frameTarget: 30 }) + let frameCount = 0 + const unsubscribe = scheduler.subscribe(() => { + frameCount += 1 + }) + + raf.runFrame(0) + raf.runFrame(16.7) + assert.equal(frameCount, 1) + assert.equal(raf.pendingCount(), 1) + + scheduler.setFrameTarget(60) + + raf.runFrame(33.4) + raf.runFrame(41.7) + raf.runFrame(50.1) + + assert.equal(frameCount, 3) + assert.equal(raf.pendingCount(), 1) + + unsubscribe() + assert.equal(raf.pendingCount(), 0) + } finally { + raf.restore() + } +}) + test('VisualizerFrameLoop renders one invalidated frame while idle', () => { const raf = installFakeAnimationFrame() @@ -258,6 +349,36 @@ test('moveDockedScopeOrder swaps a middle docked scope with its adjacent docked ]) }) +test('applying a profile snapshot does not change the machine-local frame target', () => { + const previousPerformanceState = usePerformanceStore.getState() + const previousSettingsState = useSettingsStore.getState() + + try { + usePerformanceStore.getState().setFrameTarget(120) + + const defaultProfile = createDefaultProfile('Default') + const alternateProfile = createDefaultProfile('Live Mix') + alternateProfile.hiddenScopes = [] + alternateProfile.scopeSettings.waveform.gainDb = 6 + + useSettingsStore.getState().applyExternalProfileSnapshot({ + activeProfileId: 'profile_live_mix', + profiles: { + profile_default: defaultProfile, + profile_live_mix: alternateProfile, + }, + }) + + assert.equal(usePerformanceStore.getState().frameTarget, 120) + } finally { + usePerformanceStore.setState({ + frameTarget: previousPerformanceState.frameTarget, + dockedRenderFps: previousPerformanceState.dockedRenderFps, + }) + useSettingsStore.setState(previousSettingsState) + } +}) + test('moveDockedScopeOrder is a no-op at the docked boundaries', () => { const initialOrder = [...SCOPE_KINDS]