add vfr support

This commit is contained in:
JJ
2026-04-01 04:12:17 +01:00
parent 848aac398c
commit dcf52a9d37
+260 -10
View File
@@ -733,6 +733,7 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
* Toggle play/pause state
*/
togglePlay(node) {
if (node.isVFRDecoding) return; // Locked during VFR frame capture
this.value.isPlaying = !this.value.isPlaying;
if (this.value.isPlaying) {
@@ -765,7 +766,7 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
// Enable loop mode and start playback
if (this.videoElement && this.videoElement.duration) {
// Use native looping for seamless playback within bounds
// CFR: Use native video playback
this.videoElement.loop = true;
// Start actual video playback - let the browser handle buffering naturally
@@ -787,7 +788,36 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
}
// Read current frame from video's actual playback position
if (this.videoElement.duration && !isNaN(this.videoElement.currentTime)) {
if (node.isVFRVideo) {
// VFR: Drive frames manually using elapsed time from pre-decoded array
const now = performance.now();
const elapsed = now - this.lastUpdateTime;
if (elapsed >= frameInterval) {
this.lastUpdateTime = now - (elapsed % frameInterval);
const startMarker = this.startFrameMarker || 1;
const endMarker = this.endFrameMarker !== null && this.endFrameMarker >= 1
? this.endFrameMarker
: this.value.totalFrames;
let currentFrame = this.value.currentFrame + 1;
// Handle looping within markers
if (currentFrame > endMarker) {
currentFrame = startMarker;
if (node._isHovering && node._restartHoverAudioOnLoop) {
node._restartHoverAudioOnLoop();
}
}
this.value.currentFrame = currentFrame;
if (node.updateDisplayCanvas) {
node.updateDisplayCanvas(currentFrame);
}
node.setDirtyCanvas(true, true);
}
} else if (this.videoElement.duration && !isNaN(this.videoElement.currentTime)) {
// CFR: Read current frame from video's actual playback position
let currentTime = this.videoElement.currentTime;
// Read marker positions dynamically each frame (not captured in closure)
@@ -813,6 +843,10 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
const currentFrame = Math.floor(currentTime * nativeFPS) + 1;
if (this.value.currentFrame !== currentFrame) {
this.value.currentFrame = currentFrame;
// Draw current frame to display canvas (video is hidden)
if (node.updateDisplayCanvas) {
node.updateDisplayCanvas(currentFrame);
}
node.setDirtyCanvas(true, true);
}
}
@@ -850,6 +884,7 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
*/
seekToPosition(event, pos, node) {
this.ensureValueInitialized();
if (node.isVFRDecoding) return; // Locked during VFR frame capture
const timelineX = this.hitAreas.timeline.bounds[0];
const timelineWidth = this.hitAreas.timeline.bounds[2];
@@ -859,7 +894,7 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
const newFrame = Math.max(1, Math.round(ratio * (this.value.totalFrames - 1)) + 1);
this.value.currentFrame = newFrame;
// Update video element time if available
// For VFR videos with decoded frames, update video time but display comes from frame array
if (this.videoElement && this.videoElement.duration) {
const nativeFPS = this.nativeFPS || 24;
const newTime = (newFrame - 1) / nativeFPS;
@@ -867,6 +902,13 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
this.videoElement.currentTime = clampedTime;
}
// Update display canvas for VFR videos or if node has updateDisplayCanvas method
if (node.updateDisplayCanvas) {
node.updateDisplayCanvas(this.value.currentFrame);
} else if (this.videoElement && !node.isVFRVideo) {
// For non-VFR, just ensure video is at right time
}
node.setDirtyCanvas(true, true);
}
@@ -877,6 +919,7 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
*/
dragSeek(event, pos, node) {
this.ensureValueInitialized();
if (node.isVFRDecoding) return; // Locked during VFR frame capture
// Use the current mouse position directly for real-time scrubbing
const timelineX = this.hitAreas.timeline.bounds[0];
const timelineWidth = this.hitAreas.timeline.bounds[2];
@@ -890,13 +933,18 @@ class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget {
if (newFrame !== this.value.currentFrame) {
this.value.currentFrame = newFrame;
// Update video element time using native FPS
// For VFR videos with decoded frames, update video time but display comes from frame array
if (this.videoElement && this.videoElement.duration) {
const nativeFPS = this.nativeFPS || 24;
const newTime = (newFrame - 1) / nativeFPS;
this.videoElement.currentTime = Math.min(newTime, this.videoElement.duration);
}
// Update display canvas for VFR videos or if node has updateDisplayCanvas method
if (node.updateDisplayCanvas) {
node.updateDisplayCanvas(this.value.currentFrame);
}
node.setDirtyCanvas(true, true);
}
}
@@ -1318,7 +1366,7 @@ app.registerExtension({
const nodeContainer = document.getElementById(`node-${this.id}`);
if (nodeContainer) {
// Find and hide any video/canvas elements - be more aggressive with selector
const builtInElements = nodeContainer.querySelectorAll('video, canvas:not([id*="litegraph"]), .vhs-video-container');
const builtInElements = nodeContainer.querySelectorAll('video:not([id*="power-load-video"]), canvas:not([id*="litegraph"]):not([id*="power-load-video"]), .vhs-video-container');
builtInElements.forEach(el => {
el.style.display = 'none';
});
@@ -1402,24 +1450,44 @@ app.registerExtension({
user-select: none;
`;
// Create HTML5 video element
// Create HTML5 video element (hidden - we use canvas for display)
const videoElement = document.createElement('video');
videoElement.id = `power-load-video-element-${this.id}`;
videoElement.style.cssText = `
max-width: 100%;
max-height: 100%;
display: block;
display: none; // Hidden - we use canvas for rendering
`;
videoElement.muted = true;
videoElement.preload = 'metadata'; // Changed from 'auto' - let browser manage buffering
videoElement.preload = 'auto'; // Load full video data for scrubbing
videoElement.playsInline = true;
// Create canvas element for frame display (used for VFR and regular playback)
const displayCanvas = document.createElement('canvas');
displayCanvas.setAttribute('willReadFrequently', 'true'); // Optimize for multiple getImageData calls
displayCanvas.id = `power-load-video-canvas-${this.id}`;
displayCanvas.style.cssText = `
max-width: 100%;
max-height: 100%;
display: block;
background: #000;
`;
// Store reference on node for later access
// Store references on node for later access
this.videoElement = videoElement;
this.displayCanvas = displayCanvas; // Canvas for displaying frames
// Redraw display canvas whenever video seek completes (handles scrubbing for CFR videos)
videoElement.addEventListener('seeked', () => {
if (!this.isVFRVideo && this.displayCanvas) {
const frame = this.timelineWidget?.value?.currentFrame || 1;
this.updateDisplayCanvas(frame);
}
});
// Append elements to container
videoContainer.appendChild(videoElement);
videoContainer.appendChild(videoElement); // Hidden video element for decoding
videoContainer.appendChild(displayCanvas); // Visible canvas for frame display
videoContainer.appendChild(placeholderText);
@@ -1662,12 +1730,172 @@ app.registerExtension({
}
}, 0);
/**
* Decode all frames from a VFR video into an array of canvas elements.
* Uses requestVideoFrameCallback to capture frames during playback,
* since seek-based capture fails for VFR videos (browser stays on first frame).
*/
this.decodeVFRFrames = async (videoEl, frameCount, fps) => {
// Array to hold decoded frame canvases
this.vfrFrames = [];
this.isVFRVideo = true;
this.isVFRDecoding = true; // Lock interaction during capture
// Show decoding status overlay
placeholderText.textContent = `VFR detected, decoding 0/${frameCount} frames...`;
placeholderText.style.display = 'block';
placeholderText.style.color = '#ccc';
// Black out the display canvas
if (this.displayCanvas) {
const ctx = this.displayCanvas.getContext('2d');
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
}
try {
await new Promise((resolve, reject) => {
// Safety timeout
const safetyTimer = setTimeout(() => {
console.warn(`[PowerLoadVideo] Capture timeout: got ${this.vfrFrames.length}/${frameCount} frames`);
videoEl.pause();
resolve();
}, 30000);
// Listen for video ending (may get fewer frames than expected)
videoEl.addEventListener('ended', () => {
clearTimeout(safetyTimer);
resolve();
}, { once: true });
const captureFrame = (now, metadata) => {
const frameCanvas = document.createElement('canvas');
frameCanvas.setAttribute('willReadFrequently', 'true'); // Optimize for getImageData readbacks
frameCanvas.width = videoEl.videoWidth;
frameCanvas.height = videoEl.videoHeight;
const frameCtx = frameCanvas.getContext('2d');
frameCtx.drawImage(videoEl, 0, 0);
this.vfrFrames.push(frameCanvas);
// Update overlay text
placeholderText.textContent = `VFR detected, decoding ${this.vfrFrames.length}/${frameCount} frames...`;
if (this.vfrFrames.length >= frameCount) {
clearTimeout(safetyTimer);
videoEl.pause();
resolve();
return;
}
videoEl.requestVideoFrameCallback(captureFrame);
};
videoEl.currentTime = 0;
videoEl.requestVideoFrameCallback(captureFrame);
videoEl.playbackRate = 1;
videoEl.play().catch(err => {
clearTimeout(safetyTimer);
reject(err);
});
});
// Reset video state
videoEl.currentTime = 0;
videoEl.pause();
// Update totalFrames to actual captured count (may differ from server metadata)
const actualFrames = this.vfrFrames.length;
if (this.timelineWidget && actualFrames > 0) {
this.timelineWidget.setTotalFrames(this, actualFrames);
this.timelineWidget.setStartFrame(1, this);
this.timelineWidget.setEndFrame(actualFrames, this);
}
// Hide overlay and show first frame
placeholderText.style.display = 'none';
if (this.displayCanvas && actualFrames > 0) {
this.updateDisplayCanvas(1);
}
this.setDirtyCanvas(true, true);
} catch (err) {
console.error('[PowerLoadVideo] Error during VFR frame capture:', err);
this.isVFRVideo = false; // Fall back to normal playback on error
placeholderText.style.display = 'none';
} finally {
this.isVFRDecoding = false; // Unlock interaction
}
};
/**
* Get the canvas for a specific frame (for VFR videos)
*/
this.getVFRFrameCanvas = (frameIndex) => {
if (!this.isVFRVideo || !this.vfrFrames) return null;
const idx = Math.max(0, Math.min(frameIndex, this.vfrFrames.length - 1));
return this.vfrFrames[idx];
};
/**
* Update the display canvas with the current frame.
* For VFR videos: uses pre-decoded frame array.
* For CFR videos: draws from video element.
*/
this.updateDisplayCanvas = (frameIndex) => {
if (!this.displayCanvas) { console.warn('[PowerLoadVideo] updateDisplayCanvas: no displayCanvas'); return; }
const ctx = this.displayCanvas.getContext('2d');
const videoEl = this.videoElement;
// Ensure canvas is sized correctly
if (videoEl && videoEl.videoWidth > 0) {
if (this.displayCanvas.width !== videoEl.videoWidth) {
this.displayCanvas.width = videoEl.videoWidth;
this.displayCanvas.height = videoEl.videoHeight;
}
}
// For VFR videos, use pre-decoded frame
if (this.isVFRVideo && this.vfrFrames && frameIndex > 0) {
const idx = frameIndex - 1; // 1-based to 0-based
const frameCanvas = this.vfrFrames[idx];
if (frameCanvas) {
ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
ctx.drawImage(frameCanvas, 0, 0);
return;
} else {
console.warn(`[PowerLoadVideo] updateDisplayCanvas: VFR frame ${frameIndex} (idx ${idx}) not found, total=${this.vfrFrames.length}`);
}
}
// For CFR videos: draw from video element if ready
if (videoEl && videoEl.readyState >= 2) { // HAVE_CURRENT_DATA
ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
ctx.drawImage(videoEl, 0, 0);
}
};
/**
* Clear VFR frame cache to free memory.
* Called when switching videos or node is removed.
*/
this.clearVFRFrames = () => {
if (this.vfrFrames && this.vfrFrames.length > 0) {
// Clear the array to allow garbage collection
this.vfrFrames = null;
this.isVFRVideo = false;
}
};
// Function to load video into custom display - attach to node for external access
this.loadVideoIntoDisplay = (videoFilename) => {
if (!videoFilename || String(videoElement.src).includes(videoFilename)) {
return;
}
// Clear VFR frames from previous video before loading new one
this.clearVFRFrames();
// Stop playback and reset timeline when loading new video
if (this.timelineWidget?.value?.isPlaying) {
this.timelineWidget.stopPlayback();
@@ -1727,6 +1955,16 @@ app.registerExtension({
this.timelineWidget.setStartFrame(1, this);
this.timelineWidget.setEndFrame(totalFrames, this);
this.timelineWidget.applyPlaybackRate();
// === VFR DETECTION ===
const durationFromFrames = meta.frame_count / meta.fps;
const durationDiff = Math.abs(durationFromFrames - videoElement.duration);
const durationDiffPercent = (durationDiff / videoElement.duration) * 100;
if (durationDiffPercent > 2) {
await this.decodeVFRFrames(videoElement, meta.frame_count, meta.fps);
}
this.setDirtyCanvas(true, true);
return;
}
@@ -1755,6 +1993,13 @@ app.registerExtension({
videoElement.addEventListener('loadedmetadata', onMetadataLoaded, { once: true });
videoElement.addEventListener('error', onError, { once: true });
// Draw first frame when video data becomes available (CFR videos only)
videoElement.addEventListener('loadeddata', () => {
if (!this.isVFRVideo && this.displayCanvas) {
this.updateDisplayCanvas(1);
}
}, { once: true });
};
// Override execute to load video into our custom display
@@ -1882,6 +2127,11 @@ app.registerExtension({
this._loadHoverAudio = null;
}
// Clear VFR frame cache on node removal
if (this.clearVFRFrames) {
this.clearVFRFrames();
}
// Clean up keyboard shortcut handler
if (this.handlePlayPauseShortcut) {
document.removeEventListener('keydown', this.handlePlayPauseShortcut);