mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 03:33:40 +02:00
fps controls
This commit is contained in:
@@ -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<ScopeKind, string> = {
|
||||
@@ -20,11 +22,23 @@ interface BottomBarProps {
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
const FRAME_TARGET_LABELS: Record<VisualizerFrameTarget, string> = {
|
||||
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<HTMLDivElement | null>(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 (
|
||||
<div className="bottom-bar" ref={rootRef}>
|
||||
@@ -226,6 +241,33 @@ export default function BottomBar({ onClose, onHeightChange }: BottomBarProps):
|
||||
|
||||
<div className="bottom-bar__divider" />
|
||||
|
||||
<section className="bottom-bar__section bottom-bar__section--performance">
|
||||
<div className="bottom-bar__section-title">Performance</div>
|
||||
<div className="bottom-bar__section-body">
|
||||
<div className="bottom-bar__inline bottom-bar__inline--performance">
|
||||
<div className="bottom-bar__inline bottom-bar__inline--chips">
|
||||
{VISUALIZER_FRAME_TARGETS.map((target) => (
|
||||
<button
|
||||
key={String(target)}
|
||||
type="button"
|
||||
className={`settings-chip ${frameTarget === target ? 'is-active' : ''}`.trim()}
|
||||
onClick={() => setFrameTarget(target)}
|
||||
title={target === 'display-sync' ? 'Display Sync' : `Cap visualizers at ${target} FPS`}
|
||||
>
|
||||
{FRAME_TARGET_LABELS[target]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="settings-status-pill bottom-bar__fps-pill" title="Docked visualizer render FPS">
|
||||
<span>{roundedDockedRenderFps} FPS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="bottom-bar__divider" />
|
||||
|
||||
<section className="bottom-bar__section bottom-bar__section--trim">
|
||||
<div className="bottom-bar__section-title">Trim</div>
|
||||
<div className="bottom-bar__section-body">
|
||||
|
||||
@@ -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<ScopeKind[]>(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) => {
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const scopeRefs = useRef<Partial<Record<ScopeKind, HTMLDivElement | null>>>({})
|
||||
@@ -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([])
|
||||
|
||||
@@ -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<ScopePopoutSnapshot<ScopeKind> | 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
|
||||
|
||||
@@ -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<PersistedPerformanceState>
|
||||
: {}
|
||||
|
||||
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<PerformanceState>((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()
|
||||
@@ -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;
|
||||
|
||||
@@ -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<FrameSchedulerCallback>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user