feat: Add video playback support and settings validation

- Implemented a continuous animation loop in 'MagnifyGlass' that activates automatically when video widgets are detected, ensuring smooth playback for standard videos and VHS previews.
- Added robust validation to 'StateManager' for loading settings, including bounds checking for dimensions/opacity and format verification for colors and enums.
- Updated widget detection logic to correctly identify and handle 'videopreview' and 'audiopreview' widgets.
- Ensured animation loops are properly stopped when the glass is hidden or destroyed to preserve resources.
This commit is contained in:
AEmotionStudio
2026-01-16 02:05:42 -08:00
parent 40c70bdba4
commit 51ad38bfb6
5 changed files with 277 additions and 15 deletions
+104 -1
View File
@@ -47,6 +47,12 @@ export class MagnifyGlass {
/** Current media element under cursor */
currentMediaElement: HTMLImageElement | HTMLVideoElement | null;
/** Animation loop ID for continuous video rendering */
private animationLoopId: number | null;
/** Whether videos are currently visible in magnified view */
private hasVisibleVideos: boolean;
constructor() {
this.config = new ConfigManager();
this.state = new MagnifierState();
@@ -74,6 +80,10 @@ export class MagnifyGlass {
// Media tracking
this.isOverMedia = false;
this.currentMediaElement = null;
// Animation loop for video rendering
this.animationLoopId = null;
this.hasVisibleVideos = false;
}
/**
@@ -147,6 +157,8 @@ export class MagnifyGlass {
if (this.eventHandler) {
this.eventHandler.updateInitialPosition();
}
// Start continuous animation loop for smooth video playback
this.startAnimationLoop();
}
}
@@ -156,6 +168,8 @@ export class MagnifyGlass {
*/
private forceHideAllComponents(): void {
this.state.active = false;
this.stopAnimationLoop();
this.hasVisibleVideos = false;
this.ui.hide();
// 1. Nuclear option: Direct DOM ID targeting
@@ -370,6 +384,9 @@ export class MagnifyGlass {
this.ui.htmlOverlayContainer.innerHTML = '';
// Track if any videos are found for animation loop management
let foundVideos = false;
const magnifyRect: Rectangle = {
x: this.state.sourceX,
y: this.state.sourceY,
@@ -425,7 +442,19 @@ export class MagnifyGlass {
let isImageElement = false;
let elementToProcess: HTMLElement | null = null;
if (widget.element) {
// Check for VHS-style widgets with videoEl property (VideoHelperSuite pattern)
const widgetName = String(widget.name || '').toLowerCase();
if (widgetName === 'videopreview' || widgetName === 'audiopreview') {
const videoEl = widget.videoEl as HTMLVideoElement | undefined;
if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) {
isVideoElement = true;
foundVideos = true;
elementToProcess = videoEl;
}
}
// Check for standard DOM element widgets (existing logic)
if (!elementToProcess && widget.element) {
const element = widget.element as HTMLElement;
// Skip text elements - now rendered natively on canvas via OffscreenRenderer
if (widget.type === "text" || widget.type === "string" || element.tagName === 'TEXTAREA') {
@@ -433,6 +462,7 @@ export class MagnifyGlass {
// elementToProcess = element;
} else if (element.tagName === 'VIDEO') {
isVideoElement = true;
foundVideos = true;
elementToProcess = element;
} else if (element.tagName === 'IMG') {
isImageElement = true;
@@ -441,6 +471,7 @@ export class MagnifyGlass {
const potentialVideo = element.querySelector('video');
if (potentialVideo) {
isVideoElement = true;
foundVideos = true;
elementToProcess = potentialVideo;
} else {
const potentialImage = element.querySelector('img');
@@ -581,6 +612,77 @@ export class MagnifyGlass {
}
}
}
// Manage animation loop based on video presence
if (foundVideos && !this.hasVisibleVideos) {
this.hasVisibleVideos = true;
this.startAnimationLoop();
} else if (!foundVideos && this.hasVisibleVideos) {
this.hasVisibleVideos = false;
// Loop will stop itself on next iteration
}
}
/**
* Start the animation loop for continuous video rendering.
* Only runs when videos are visible in the magnified view.
* This loop directly renders without going through updateMagnifiedView's
* scheduling logic to ensure continuous frame updates for video playback.
*/
private startAnimationLoop(): void {
if (this.animationLoopId !== null) return; // Already running
const animate = () => {
// Stop if glass is deactivated or renderer unavailable
if (!this.state.active || !this.renderer || !this.litegraphCanvas) {
this.stopAnimationLoop();
return;
}
// Skip if glass preview is hidden
if (this.state.isPreviewHidden) {
this.animationLoopId = requestAnimationFrame(animate);
return;
}
// Update canvas transformation and source region
this.updateCanvasTransformation();
this.calculateSourceRegion();
// Direct render (bypass isRenderScheduled to ensure continuous updates)
let sourceCanvas: HTMLCanvasElement = this.litegraphCanvas;
if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) {
const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas);
if (highResCanvas) {
sourceCanvas = highResCanvas;
}
}
// Render the magnified view
this.renderer.render(sourceCanvas);
// Render HTML overlays for video widgets
this.renderHtmlOverlays();
// Send frame to pop-out tab if open
if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) {
this.popOutManager.sendFrame(this.ui.glassCanvas);
}
this.animationLoopId = requestAnimationFrame(animate);
};
this.animationLoopId = requestAnimationFrame(animate);
}
/**
* Stop the animation loop.
*/
private stopAnimationLoop(): void {
if (this.animationLoopId !== null) {
cancelAnimationFrame(this.animationLoopId);
this.animationLoopId = null;
}
}
/**
@@ -666,6 +768,7 @@ export class MagnifyGlass {
* Cleanup all resources.
*/
cleanup(): void {
this.stopAnimationLoop();
this.eventHandler.detachListeners();
this.popOutManager.cleanup();
this.ui.cleanup();
+91 -3
View File
@@ -1,7 +1,7 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { getSettingValue } from "../shared/utils.js";
import { getSettingValue, clamp } from "../shared/utils.js";
import { DEFAULT_PANEL_SETTINGS } from "../shared/settings/defaults.js";
import "/scripts/app.js";
class StateManager {
@@ -187,13 +187,101 @@ class StateManager {
}
}
loadSettings() {
Object.keys(DEFAULT_PANEL_SETTINGS).forEach((key) => {
const defaults = DEFAULT_PANEL_SETTINGS;
Object.keys(defaults).forEach((key) => {
if (key !== "🔍MagnifyGlass.InfoPanelTheme") {
this.state.settings[key] = getSettingValue(key, DEFAULT_PANEL_SETTINGS[key]);
this.state.settings[key] = getSettingValue(key, defaults[key]);
}
});
this.state.settings["🔍MagnifyGlass.InfoPanelWidth"] = this.validateNumber(
this.state.settings["🔍MagnifyGlass.InfoPanelWidth"],
200,
600,
defaults["🔍MagnifyGlass.InfoPanelWidth"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelMaxHeight"] = this.validateNumber(
this.state.settings["🔍MagnifyGlass.InfoPanelMaxHeight"],
200,
1500,
defaults["🔍MagnifyGlass.InfoPanelMaxHeight"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelOpacity"] = this.validateNumber(
this.state.settings["🔍MagnifyGlass.InfoPanelOpacity"],
10,
100,
defaults["🔍MagnifyGlass.InfoPanelOpacity"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelFontSize"] = this.validateNumber(
this.state.settings["🔍MagnifyGlass.InfoPanelFontSize"],
8,
24,
defaults["🔍MagnifyGlass.InfoPanelFontSize"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelTextColor"] = this.validateColor(
this.state.settings["🔍MagnifyGlass.InfoPanelTextColor"],
defaults["🔍MagnifyGlass.InfoPanelTextColor"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelAccentColor"] = this.validateColor(
this.state.settings["🔍MagnifyGlass.InfoPanelAccentColor"],
defaults["🔍MagnifyGlass.InfoPanelAccentColor"]
);
const validPanelPositions = ["Bottom", "Top", "Left", "Right"];
this.state.settings["🔍MagnifyGlass.InfoPanelPosition"] = this.validateStringOption(
this.state.settings["🔍MagnifyGlass.InfoPanelPosition"],
validPanelPositions,
defaults["🔍MagnifyGlass.InfoPanelPosition"]
);
const validControlPositions = ["top-left", "top-right", "bottom-left", "bottom-right", "top", "bottom", "left", "right"];
this.state.settings["🔍MagnifyGlass.ControlsPosition"] = this.validateStringOption(
this.state.settings["🔍MagnifyGlass.ControlsPosition"],
validControlPositions,
defaults["🔍MagnifyGlass.ControlsPosition"]
);
const validFonts = [
"System Default",
"Inter",
"Roboto",
"JetBrains Mono",
"Fira Code",
"IBM Plex Sans",
"Space Grotesk",
"Lexend",
"Outfit",
"monospace",
"system-ui"
];
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"] = this.validateStringOption(
this.state.settings["🔍MagnifyGlass.InfoPanelFontFamily"],
validFonts,
defaults["🔍MagnifyGlass.InfoPanelFontFamily"]
);
this.state.settings["🔍MagnifyGlass.InfoPanelTheme"] = this.state.currentTheme;
}
/**
* Validate numeric input with bounds checking.
*/
validateNumber(value, min, max, fallback) {
const num = Number(value);
if (isNaN(num)) return fallback;
return clamp(num, min, max);
}
/**
* Validate color string (hex).
*/
validateColor(color, fallback) {
if (!color || typeof color !== "string") return fallback;
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(color)) {
return color;
}
return fallback;
}
/**
* Validate string against an allowlist.
*/
validateStringOption(value, options, fallback) {
if (typeof value !== "string") return fallback;
return options.includes(value) ? value : fallback;
}
updateSettings() {
const oldSettings = { ...this.state.settings };
this.loadSettings();
File diff suppressed because one or more lines are too long
+80 -9
View File
@@ -32,6 +32,10 @@ class MagnifyGlass {
__publicField(this, "isOverMedia");
/** Current media element under cursor */
__publicField(this, "currentMediaElement");
/** Animation loop ID for continuous video rendering */
__publicField(this, "animationLoopId");
/** Whether videos are currently visible in magnified view */
__publicField(this, "hasVisibleVideos");
this.config = new ConfigManager();
this.state = new MagnifierState();
this.popOutManager = new PopOutManager();
@@ -48,6 +52,8 @@ class MagnifyGlass {
this.lastKnownMousePosition = { x: 0, y: 0 };
this.isOverMedia = false;
this.currentMediaElement = null;
this.animationLoopId = null;
this.hasVisibleVideos = false;
}
/**
* Initialize the magnifying glass.
@@ -93,6 +99,7 @@ class MagnifyGlass {
if (this.eventHandler) {
this.eventHandler.updateInitialPosition();
}
this.startAnimationLoop();
}
}
/**
@@ -101,6 +108,8 @@ class MagnifyGlass {
*/
forceHideAllComponents() {
this.state.active = false;
this.stopAnimationLoop();
this.hasVisibleVideos = false;
this.ui.hide();
const panelEl = document.getElementById(INFO_PANEL_ID);
if (panelEl) {
@@ -247,6 +256,7 @@ class MagnifyGlass {
return;
}
this.ui.htmlOverlayContainer.innerHTML = "";
let foundVideos = false;
const magnifyRect = {
x: this.state.sourceX,
y: this.state.sourceY,
@@ -290,11 +300,21 @@ class MagnifyGlass {
let isVideoElement = false;
let isImageElement = false;
let elementToProcess = null;
if (widget.element) {
const widgetName = String(widget.name || "").toLowerCase();
if (widgetName === "videopreview" || widgetName === "audiopreview") {
const videoEl = widget.videoEl;
if (videoEl && !videoEl.hidden && videoEl.videoWidth > 0) {
isVideoElement = true;
foundVideos = true;
elementToProcess = videoEl;
}
}
if (!elementToProcess && widget.element) {
const element = widget.element;
if (widget.type === "text" || widget.type === "string" || element.tagName === "TEXTAREA") ;
else if (element.tagName === "VIDEO") {
isVideoElement = true;
foundVideos = true;
elementToProcess = element;
} else if (element.tagName === "IMG") {
isImageElement = true;
@@ -303,6 +323,7 @@ class MagnifyGlass {
const potentialVideo = element.querySelector("video");
if (potentialVideo) {
isVideoElement = true;
foundVideos = true;
elementToProcess = potentialVideo;
} else {
const potentialImage = element.querySelector("img");
@@ -315,16 +336,15 @@ class MagnifyGlass {
}
if (elementToProcess && (isVideoElement || isImageElement)) {
const widgetRect = elementToProcess.getBoundingClientRect();
const canvasRect = this.litegraphCanvas.getBoundingClientRect();
const dpr2 = canvasRect.width > 0 ? this.litegraphCanvas.width / canvasRect.width : 1;
const canvasRect = rect;
const currentScale = this.state.canvasScale;
const isVirtualZoomMode = currentScale < 0.7;
const widgetCssX = widgetRect.left - canvasRect.left;
const widgetCssY = widgetRect.top - canvasRect.top;
const widgetCssWidth = widgetRect.width;
const widgetCssHeight = widgetRect.height;
const pivotCssX = this.state.x / dpr2;
const pivotCssY = this.state.y / dpr2;
const pivotCssX = this.state.x / dpr;
const pivotCssY = this.state.y / dpr;
let finalWidgetCssX;
let finalWidgetCssY;
let finalWidgetCssWidth;
@@ -340,10 +360,10 @@ class MagnifyGlass {
finalWidgetCssWidth = widgetCssWidth;
finalWidgetCssHeight = widgetCssHeight;
}
const widgetCanvasX = finalWidgetCssX * dpr2;
const widgetCanvasY = finalWidgetCssY * dpr2;
const widgetCanvasWidth = finalWidgetCssWidth * dpr2;
const widgetCanvasHeight = finalWidgetCssHeight * dpr2;
const widgetCanvasX = finalWidgetCssX * dpr;
const widgetCanvasY = finalWidgetCssY * dpr;
const widgetCanvasWidth = finalWidgetCssWidth * dpr;
const widgetCanvasHeight = finalWidgetCssHeight * dpr;
const widgetSourceRect = {
x: widgetCanvasX,
y: widgetCanvasY,
@@ -392,6 +412,56 @@ class MagnifyGlass {
}
}
}
if (foundVideos && !this.hasVisibleVideos) {
this.hasVisibleVideos = true;
this.startAnimationLoop();
} else if (!foundVideos && this.hasVisibleVideos) {
this.hasVisibleVideos = false;
}
}
/**
* Start the animation loop for continuous video rendering.
* Only runs when videos are visible in the magnified view.
* This loop directly renders without going through updateMagnifiedView's
* scheduling logic to ensure continuous frame updates for video playback.
*/
startAnimationLoop() {
if (this.animationLoopId !== null) return;
const animate = () => {
if (!this.state.active || !this.renderer || !this.litegraphCanvas) {
this.stopAnimationLoop();
return;
}
if (this.state.isPreviewHidden) {
this.animationLoopId = requestAnimationFrame(animate);
return;
}
this.updateCanvasTransformation();
this.calculateSourceRegion();
let sourceCanvas = this.litegraphCanvas;
if (this.offscreenRenderer && this.offscreenRenderer.isAvailable()) {
const highResCanvas = this.offscreenRenderer.renderHighResRegion(this.litegraphCanvas);
if (highResCanvas) {
sourceCanvas = highResCanvas;
}
}
this.renderer.render(sourceCanvas);
this.renderHtmlOverlays();
if (this.popOutManager.isPopOutOpen() && this.ui.glassCanvas) {
this.popOutManager.sendFrame(this.ui.glassCanvas);
}
this.animationLoopId = requestAnimationFrame(animate);
};
this.animationLoopId = requestAnimationFrame(animate);
}
/**
* Stop the animation loop.
*/
stopAnimationLoop() {
if (this.animationLoopId !== null) {
cancelAnimationFrame(this.animationLoopId);
this.animationLoopId = null;
}
}
/**
* Update config from current settings values.
@@ -457,6 +527,7 @@ class MagnifyGlass {
* Cleanup all resources.
*/
cleanup() {
this.stopAnimationLoop();
this.eventHandler.detachListeners();
this.popOutManager.cleanup();
this.ui.cleanup();
File diff suppressed because one or more lines are too long