From ab70a3b369fc1182d3e28bea123e2a1961ef9297 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Sun, 17 Aug 2025 21:51:35 +0200 Subject: [PATCH 1/3] feat: add performance monitoring to Inspector --- src/main-api/Inspector.ts | 135 ++++++++++++++++++++++++++++++++++++++ src/main-api/Renderer.ts | 12 +++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/src/main-api/Inspector.ts b/src/main-api/Inspector.ts index e1a6bcbd..af8d6070 100644 --- a/src/main-api/Inspector.ts +++ b/src/main-api/Inspector.ts @@ -9,6 +9,34 @@ import type { IAnimationController } from '../common/IAnimationController.js'; import { isProductionEnvironment } from '../utils.js'; import { CoreTextNode, type CoreTextNodeProps } from '../core/CoreTextNode.js'; +/** + * Inspector Options + * + * Configuration options for the Inspector's performance monitoring features. + */ +export interface InspectorOptions { + /** + * Enable performance monitoring for setter calls + * + * @defaultValue true + */ + enablePerformanceMonitoring: boolean; + + /** + * Threshold for excessive setter calls before logging a warning + * + * @defaultValue 100 + */ + excessiveCallThreshold: number; + + /** + * Time interval in milliseconds to reset the setter call counters + * + * @defaultValue 5000 + */ + resetInterval: number; +} + /** * Inspector * @@ -171,6 +199,15 @@ export class Inspector { private scaleX = 1; private scaleY = 1; + // Performance monitoring for frequent setter calls + private static setterCallCount = new Map< + string, + { count: number; lastReset: number; nodeId: number } + >(); + + // Performance monitoring settings (configured via constructor) + private performanceSettings!: InspectorOptions; + constructor(canvas: HTMLCanvasElement, settings: RendererMainSettings) { if (isProductionEnvironment === true) return; @@ -178,6 +215,15 @@ export class Inspector { throw new Error('settings is required'); } + // Initialize performance monitoring settings with defaults + this.performanceSettings = { + enablePerformanceMonitoring: + settings.inspectorOptions?.enablePerformanceMonitoring ?? true, + excessiveCallThreshold: + settings.inspectorOptions?.excessiveCallThreshold ?? 100, + resetInterval: settings.inspectorOptions?.resetInterval ?? 5000, + }; + // calc dimensions based on the devicePixelRatio this.height = Math.ceil( settings.appHeight ?? 1080 / (settings.deviceLogicalPixelRatio ?? 1), @@ -215,6 +261,92 @@ export class Inspector { console.warn('Inspector is enabled, this will impact performance'); } + /** + * Track setter calls for performance monitoring + * Only active when Inspector is loaded + */ + private trackSetterCall(nodeId: number, setterName: string): void { + if (!this.performanceSettings.enablePerformanceMonitoring) { + return; + } + + const key = `${nodeId}_${setterName}`; + const now = Date.now(); + const existing = Inspector.setterCallCount.get(key); + + if (!existing) { + Inspector.setterCallCount.set(key, { count: 1, lastReset: now, nodeId }); + return; + } + + // Reset counter if enough time has passed + if (now - existing.lastReset > this.performanceSettings.resetInterval) { + existing.count = 1; + existing.lastReset = now; + return; + } + + existing.count++; + + // Log if threshold exceeded + if (existing.count === this.performanceSettings.excessiveCallThreshold) { + console.warn( + `🚨 Inspector Performance Warning: Setter '${setterName}' called ${existing.count} times in ${this.performanceSettings.resetInterval}ms on node ${nodeId}`, + ); + } else if ( + existing.count > this.performanceSettings.excessiveCallThreshold && + existing.count % 50 === 0 + ) { + console.warn( + `🚨 Inspector Performance Warning: Setter '${setterName}' called ${existing.count} times in ${this.performanceSettings.resetInterval}ms on node ${nodeId} (continuing...)`, + ); + } + } + + /** + * Get current performance monitoring statistics + */ + public static getPerformanceStats(): Array<{ + nodeId: number; + setterName: string; + count: number; + timeWindow: number; + }> { + const stats: Array<{ + nodeId: number; + setterName: string; + count: number; + timeWindow: number; + }> = []; + const now = Date.now(); + + Inspector.setterCallCount.forEach((data, key) => { + const parts = key.split('_'); + const nodeIdStr = parts[0]; + const setterName = parts[1]; + + if (nodeIdStr && setterName) { + const timeWindow = now - data.lastReset; + + stats.push({ + nodeId: parseInt(nodeIdStr, 10), + setterName, + count: data.count, + timeWindow, + }); + } + }); + + return stats.sort((a, b) => b.count - a.count); + } + + /** + * Clear performance monitoring statistics + */ + public static clearPerformanceStats(): void { + Inspector.setterCallCount.clear(); + } + setRootPosition() { if (this.root === null || this.canvas === null) { return; @@ -322,6 +454,9 @@ export class Inspector { return originalProp?.get?.call(node); }, set: (value) => { + // Track setter call for performance monitoring + this.trackSetterCall(node.id, property); + originalProp?.set?.call(node, value); this.updateNodeProperty( div, diff --git a/src/main-api/Renderer.ts b/src/main-api/Renderer.ts index b81d1f30..075dba79 100644 --- a/src/main-api/Renderer.ts +++ b/src/main-api/Renderer.ts @@ -28,7 +28,7 @@ import type { TextureMemoryManagerSettings } from '../core/TextureMemoryManager. import type { TextRenderer } from '../core/text-rendering/TextRenderer.js'; import type { CanvasRenderer } from '../core/renderers/canvas/CanvasRenderer.js'; import type { WebGlRenderer } from '../core/renderers/webgl/WebGlRenderer.js'; -import type { Inspector } from './Inspector.js'; +import type { Inspector, InspectorOptions } from './Inspector.js'; import type { CoreShaderNode } from '../core/renderers/CoreShaderNode.js'; import type { ExtractShaderProps, @@ -127,6 +127,15 @@ export interface RendererRuntimeSettings { */ inspector: typeof Inspector | false; + /** + * Inspector Options + * + * @remarks + * Configuration options for the Inspector's performance monitoring features. + * Only used when inspector is enabled. + */ + inspectorOptions?: Partial; + /** * Texture Processing Limit (in milliseconds) * @@ -386,6 +395,7 @@ export class RendererMain extends EventEmitter { enableContextSpy: settings.enableContextSpy ?? false, forceWebGL2: settings.forceWebGL2 ?? false, inspector: settings.inspector ?? false, + inspectorOptions: settings.inspectorOptions ?? {}, renderEngine: settings.renderEngine, quadBufferSize: settings.quadBufferSize ?? 4 * 1024 * 1024, fontEngines: settings.fontEngines ?? [], From ac766a1da1444e9bd6845c41d518c81039ded45d Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Sun, 17 Aug 2025 22:30:42 +0200 Subject: [PATCH 2/3] feat: add animation monitoring to Inspector --- src/main-api/Inspector.ts | 379 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 367 insertions(+), 12 deletions(-) diff --git a/src/main-api/Inspector.ts b/src/main-api/Inspector.ts index af8d6070..26fbd877 100644 --- a/src/main-api/Inspector.ts +++ b/src/main-api/Inspector.ts @@ -5,7 +5,10 @@ import { } from '../core/CoreNode.js'; import { type RendererMainSettings } from './Renderer.js'; import type { AnimationSettings } from '../core/animations/CoreAnimation.js'; -import type { IAnimationController } from '../common/IAnimationController.js'; +import type { + IAnimationController, + AnimationControllerState, +} from '../common/IAnimationController.js'; import { isProductionEnvironment } from '../utils.js'; import { CoreTextNode, type CoreTextNodeProps } from '../core/CoreTextNode.js'; @@ -35,6 +38,27 @@ export interface InspectorOptions { * @defaultValue 5000 */ resetInterval: number; + + /** + * Enable animation monitoring and statistics tracking + * + * @defaultValue true + */ + enableAnimationMonitoring: boolean; + + /** + * Maximum number of animations to keep in history for statistics + * + * @defaultValue 1000 + */ + maxAnimationHistory: number; + + /** + * Automatically print animation statistics every X seconds (0 to disable) + * + * @defaultValue 0 + */ + animationStatsInterval: number; } /** @@ -205,11 +229,47 @@ export class Inspector { { count: number; lastReset: number; nodeId: number } >(); + // Animation monitoring structures + private static activeAnimations = new Map< + string, + { + nodeId: number; + animationId: string; + startTime: number; + props: CoreNodeAnimateProps; + settings: AnimationSettings; + controller: IAnimationController; + state: AnimationControllerState; + } + >(); + + private static animationHistory: Array<{ + nodeId: number; + animationId: string; + startTime: number; + endTime: number; + duration: number; + actualDuration: number; + props: CoreNodeAnimateProps; + settings: AnimationSettings; + completionType: 'finished' | 'stopped' | 'cancelled'; + }> = []; + // Performance monitoring settings (configured via constructor) - private performanceSettings!: InspectorOptions; + private performanceSettings: InspectorOptions = { + enablePerformanceMonitoring: true, + excessiveCallThreshold: 100, + resetInterval: 5000, + enableAnimationMonitoring: true, + maxAnimationHistory: 1000, + animationStatsInterval: 15, + }; + + // Animation stats printing timer + private animationStatsTimer: NodeJS.Timeout | null = null; constructor(canvas: HTMLCanvasElement, settings: RendererMainSettings) { - if (isProductionEnvironment === true) return; + // if (isProductionEnvironment === true) return; if (!settings) { throw new Error('settings is required'); @@ -222,6 +282,12 @@ export class Inspector { excessiveCallThreshold: settings.inspectorOptions?.excessiveCallThreshold ?? 100, resetInterval: settings.inspectorOptions?.resetInterval ?? 5000, + enableAnimationMonitoring: + settings.inspectorOptions?.enableAnimationMonitoring ?? true, + maxAnimationHistory: + settings.inspectorOptions?.maxAnimationHistory ?? 1000, + animationStatsInterval: + settings.inspectorOptions?.animationStatsInterval ?? 15, }; // calc dimensions based on the devicePixelRatio @@ -258,6 +324,9 @@ export class Inspector { //listen for changes on window window.addEventListener('resize', this.setRootPosition.bind(this)); + // Start animation stats timer if enabled + this.startAnimationStatsTimer(); + console.warn('Inspector is enabled, this will impact performance'); } @@ -347,6 +416,287 @@ export class Inspector { Inspector.setterCallCount.clear(); } + /** + * Generate a unique animation ID + */ + private static generateAnimationId(): string { + return `anim_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; + } + + /** + * Wrap animation controller with monitoring capabilities + */ + private wrapAnimationController( + controller: IAnimationController, + nodeId: number, + props: CoreNodeAnimateProps, + settings: AnimationSettings, + div: HTMLElement, + ): IAnimationController { + if (!this.performanceSettings.enableAnimationMonitoring) { + // Just add the basic DOM animation without tracking + const originalStart = controller.start.bind(controller); + controller.start = () => { + this.animateNode(div, props, settings); + return originalStart(); + }; + return controller; + } + + const animationId = Inspector.generateAnimationId(); + + // Create wrapper controller + const wrappedController: IAnimationController = { + start: () => { + this.trackAnimationStart( + animationId, + nodeId, + props, + settings, + controller, + ); + this.animateNode(div, props, settings); + return controller.start(); + }, + + stop: () => { + this.trackAnimationEnd(animationId, 'stopped'); + return controller.stop(); + }, + + pause: () => { + this.updateAnimationState(animationId, 'paused'); + return controller.pause(); + }, + + restore: () => { + this.trackAnimationEnd(animationId, 'cancelled'); + return controller.restore(); + }, + + waitUntilStopped: () => { + return controller.waitUntilStopped().then(() => { + this.trackAnimationEnd(animationId, 'finished'); + }); + }, + + get state() { + return controller.state; + }, + + // Event emitter methods + on: controller.on.bind(controller), + off: controller.off.bind(controller), + once: controller.once.bind(controller), + emit: controller.emit.bind(controller), + }; + + // Track animation events + controller.on('animating', () => { + this.updateAnimationState(animationId, 'running'); + }); + + controller.on('stopped', () => { + this.trackAnimationEnd(animationId, 'finished'); + }); + + return wrappedController; + } + + /** + * Track animation start + */ + private trackAnimationStart( + animationId: string, + nodeId: number, + props: CoreNodeAnimateProps, + settings: AnimationSettings, + controller: IAnimationController, + ): void { + const startTime = Date.now(); + + Inspector.activeAnimations.set(animationId, { + nodeId, + animationId, + startTime, + props, + settings, + controller, + state: 'scheduled', + }); + } + + /** + * Update animation state + */ + private updateAnimationState( + animationId: string, + state: AnimationControllerState, + ): void { + const animation = Inspector.activeAnimations.get(animationId); + if (animation) { + animation.state = state; + } + } + + /** + * Track animation end + */ + private trackAnimationEnd( + animationId: string, + completionType: 'finished' | 'stopped' | 'cancelled', + ): void { + const animation = Inspector.activeAnimations.get(animationId); + if (!animation) return; + + const endTime = Date.now(); + const actualDuration = endTime - animation.startTime; + const expectedDuration = animation.settings.duration || 1000; + + // Move to history + Inspector.animationHistory.unshift({ + nodeId: animation.nodeId, + animationId: animation.animationId, + startTime: animation.startTime, + endTime, + duration: expectedDuration, + actualDuration, + props: animation.props, + settings: animation.settings, + completionType, + }); + + // Limit history size for performance + if ( + Inspector.animationHistory.length > + this.performanceSettings.maxAnimationHistory + ) { + Inspector.animationHistory.splice( + this.performanceSettings.maxAnimationHistory, + ); + } + + // Remove from active animations + Inspector.activeAnimations.delete(animationId); + } + + /** + * Get currently active animations + */ + public static getActiveAnimations(): Array<{ + nodeId: number; + animationId: string; + startTime: number; + duration: number; + elapsedTime: number; + props: CoreNodeAnimateProps; + settings: AnimationSettings; + state: AnimationControllerState; + }> { + const now = Date.now(); + const activeAnimations: Array<{ + nodeId: number; + animationId: string; + startTime: number; + duration: number; + elapsedTime: number; + props: CoreNodeAnimateProps; + settings: AnimationSettings; + state: AnimationControllerState; + }> = []; + + Inspector.activeAnimations.forEach((animation) => { + activeAnimations.push({ + nodeId: animation.nodeId, + animationId: animation.animationId, + startTime: animation.startTime, + duration: animation.settings.duration || 1000, + elapsedTime: now - animation.startTime, + props: animation.props, + settings: animation.settings, + state: animation.state, + }); + }); + + return activeAnimations.sort((a, b) => b.startTime - a.startTime); + } + + /** + * Get animation statistics + */ + public static getAnimationStats(): { + totalAnimations: number; + activeCount: number; + averageDuration: number; + } { + const totalAnimations = Inspector.animationHistory.length; + const activeCount = Inspector.activeAnimations.size; + + // Calculate average duration from finished animations only + const finishedAnimations = Inspector.animationHistory.filter( + (anim) => anim.completionType === 'finished', + ); + + const averageDuration = + finishedAnimations.length > 0 + ? finishedAnimations.reduce( + (sum, anim) => sum + anim.actualDuration, + 0, + ) / finishedAnimations.length + : 0; + + return { + totalAnimations, + activeCount, + averageDuration, + }; + } + + /** + * Clear animation monitoring data + */ + public static clearAnimationStats(): void { + Inspector.activeAnimations.clear(); + Inspector.animationHistory.length = 0; + } + + /** + * Start the animation stats timer if enabled + */ + private startAnimationStatsTimer(): void { + console.log( + `Starting animation stats timer with interval: ${this.performanceSettings.animationStatsInterval} seconds`, + ); + + if (this.performanceSettings.animationStatsInterval > 0) { + this.animationStatsTimer = setInterval(() => { + this.printAnimationStats(); + }, this.performanceSettings.animationStatsInterval * 1000); + } + } + + /** + * Stop the animation stats timer + */ + private stopAnimationStatsTimer(): void { + if (this.animationStatsTimer) { + clearInterval(this.animationStatsTimer); + this.animationStatsTimer = null; + } + } + + /** + * Print current animation statistics to console + */ + private printAnimationStats(): void { + const stats = Inspector.getAnimationStats(); + + console.log( + `🎬 Animation Stats: ${stats.activeCount} active, ${ + stats.totalAnimations + } completed, ${Math.round(stats.averageDuration)}ms avg duration`, + ); + } setRootPosition() { if (this.root === null || this.canvas === null) { return; @@ -487,15 +837,14 @@ export class Inspector { ): IAnimationController => { const animationController = originalAnimate.call(node, props, settings); - const originalStart = - animationController.start.bind(animationController); - animationController.start = () => { - this.animateNode(div, props, settings); - - return originalStart(); - }; - - return animationController; + // Wrap animation controller with monitoring + return this.wrapAnimationController( + animationController, + node.id, + props, + settings, + div, + ); }, configurable: true, }); @@ -504,6 +853,9 @@ export class Inspector { } public destroy() { + // Stop animation stats timer + this.stopAnimationStatsTimer(); + // Remove DOM observers this.mutationObserver.disconnect(); this.resizeObserver.disconnect(); @@ -513,6 +865,9 @@ export class Inspector { if (this.root && this.root.parentNode) { this.root.remove(); } + + // Clean up animation monitoring data + Inspector.clearAnimationStats(); } destroyNode(id: number) { From 783be088349c95a7a43fb88c8615ab23c081b270 Mon Sep 17 00:00:00 2001 From: wouterlucas Date: Sun, 17 Aug 2025 22:34:25 +0200 Subject: [PATCH 3/3] fix: restore defaults --- src/main-api/Inspector.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main-api/Inspector.ts b/src/main-api/Inspector.ts index 26fbd877..b978382b 100644 --- a/src/main-api/Inspector.ts +++ b/src/main-api/Inspector.ts @@ -257,19 +257,19 @@ export class Inspector { // Performance monitoring settings (configured via constructor) private performanceSettings: InspectorOptions = { - enablePerformanceMonitoring: true, + enablePerformanceMonitoring: false, excessiveCallThreshold: 100, resetInterval: 5000, - enableAnimationMonitoring: true, + enableAnimationMonitoring: false, maxAnimationHistory: 1000, - animationStatsInterval: 15, + animationStatsInterval: 0, }; // Animation stats printing timer private animationStatsTimer: NodeJS.Timeout | null = null; constructor(canvas: HTMLCanvasElement, settings: RendererMainSettings) { - // if (isProductionEnvironment === true) return; + if (isProductionEnvironment === true) return; if (!settings) { throw new Error('settings is required'); @@ -278,16 +278,16 @@ export class Inspector { // Initialize performance monitoring settings with defaults this.performanceSettings = { enablePerformanceMonitoring: - settings.inspectorOptions?.enablePerformanceMonitoring ?? true, + settings.inspectorOptions?.enablePerformanceMonitoring ?? false, excessiveCallThreshold: settings.inspectorOptions?.excessiveCallThreshold ?? 100, resetInterval: settings.inspectorOptions?.resetInterval ?? 5000, enableAnimationMonitoring: - settings.inspectorOptions?.enableAnimationMonitoring ?? true, + settings.inspectorOptions?.enableAnimationMonitoring ?? false, maxAnimationHistory: settings.inspectorOptions?.maxAnimationHistory ?? 1000, animationStatsInterval: - settings.inspectorOptions?.animationStatsInterval ?? 15, + settings.inspectorOptions?.animationStatsInterval ?? 0, }; // calc dimensions based on the devicePixelRatio