Files
2026-08-29 17:49:50 +01:00

942 lines
42 KiB
JavaScript

/**
* Node Setup - onNodeCreated wrapper logic for PowerLoadVideo
*/
import { api } from '../../../scripts/api.js';
import { app } from '../../../scripts/app.js';
import { PowerLoadVideoTopRowWidget } from './top_row_widget.js';
import { PowerLoadVideoTimelineWidget } from './timeline_widget.js';
import { PowerLoadVideoFileSelectorWidget } from './file_selector_widget.js';
import { BoxCropWidget } from './box_crop_widget.js';
/**
* Create the onNodeCreated wrapper function for PowerLoadVideo nodes
*/
export function createOnNodeCreatedWrapper(originalOnNodeCreated, nodeData) {
return function() {
// Call original first
if (originalOnNodeCreated) {
originalOnNodeCreated.apply(this, arguments);
}
// Enforce minimum width and max height
if (this.size[0] < 640) {
this.size[0] = 640;
}
if (this.size[1] > 585) {
this.size[1] = 585;
}
// Enforce min width and max height on resize
this.onResize = function(size) {
if (size[0] < 640) {
size[0] = 640;
}
if (size[1] > 585) {
size[1] = 585;
}
};
// Fix #5: Ensure widgets array exists before manipulation
this.widgets = this.widgets || [];
// Remove built-in widgets except 'video' - we need to keep the video combo widget
// in the widgets array so ComfyUI serializes its value to the backend on execute.
// It gets hidden visually below (computeSize = [0,0]).
const widgetsToRemove = ['timeline', 'frame_rate', 'looping'];
let removedCount = 0;
for (const widgetName of widgetsToRemove) {
let index;
while ((index = this.widgets.findIndex(w => w.name === widgetName)) >= 0) {
this.widgets.splice(index, 1);
removedCount++;
}
}
// ALSO hide any remaining video DOM elements that might be rendered by ComfyUI's video upload
const hideBuiltInElements = () => {
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:not([id*="power-load-video"]), canvas:not([id*="litegraph"]):not([id*="power-load-video"]), .vhs-video-container');
builtInElements.forEach(el => {
el.style.display = 'none';
});
}
};
// Try multiple times to ensure we catch dynamically added elements
hideBuiltInElements();
setTimeout(hideBuiltInElements, 50);
setTimeout(hideBuiltInElements, 100);
// Use MutationObserver to continuously hide any built-in elements that get added dynamically
const observer = new MutationObserver(() => {
hideBuiltInElements();
});
// Store reference on node for cleanup
this.vhsObserver = observer;
// Observe the node container for changes once it exists
setTimeout(() => {
const nodeContainer = document.getElementById(`node-${this.id}`);
if (nodeContainer) {
observer.observe(nodeContainer, { childList: true, subtree: true });
// Add inline style to hide built-in elements at the container level
const styleEl = document.createElement('style');
styleEl.id = `power-load-video-styles-${this.id}`;
styleEl.textContent = `
#node-${this.id} video:not([id*="power-load-video"]),
#node-${this.id} canvas.vhs-canvas,
#node-${this.id} .vhs-video-container,
#node-${this.id} [class*="vhs"] {
display: none !important;
}
`;
document.head.appendChild(styleEl);
}
}, 100);
// Add addCustomWidget polyfill if not present (same as PowerSplineEditor)
if (!this.addCustomWidget) {
this.addCustomWidget = function (widget) {
widget.parent = this;
this.widgets = this.widgets || [];
this.widgets.push(widget);
const originalMouse = widget.mouse;
widget.mouse = function (event, pos, node) {
const localPos = [pos[0], pos[1] - (widget.last_y || 0)];
return originalMouse?.call(this, event, localPos, node);
};
return widget;
};
}
// Create DOM container for video display with drag-and-drop support
const videoContainer = document.createElement('div');
videoContainer.id = `power-load-video-${this.id}`;
videoContainer.style.cssText = `
width: 100%;
height: 380px;
background-color: #000;
position: relative;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
`;
// Add placeholder text
const placeholderText = document.createElement('div');
placeholderText.id = `power-load-video-placeholder-${this.id}`;
placeholderText.textContent = 'Drop video here';
placeholderText.style.cssText = `
position: absolute;
color: #888;
font-size: 14px;
pointer-events: none;
user-select: none;
`;
// 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: none; // Hidden - we use canvas for rendering
`;
videoElement.muted = true;
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 references on node for later access
this.videoElement = videoElement;
this.displayCanvas = displayCanvas; // Canvas for displaying frames
this._frameBuffer = document.createElement('canvas'); // Offscreen buffer for last clean frame (used during seeking)
// 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); // Hidden video element for decoding
videoContainer.appendChild(displayCanvas); // Visible canvas for frame display
videoContainer.appendChild(placeholderText);
// Add drag-and-drop support directly to the video container element
let draggedFile = null;
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
videoContainer.style.backgroundColor = '#1a3a4a';
placeholderText.style.opacity = '1';
};
const handleDragLeave = (e) => {
// Only reset if leaving the container entirely (not entering a child)
if (!videoContainer.contains(e.relatedTarget)) {
e.preventDefault();
e.stopPropagation();
videoContainer.style.backgroundColor = '#000';
}
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
videoContainer.style.backgroundColor = '#000';
const file = e.dataTransfer.files[0];
if (!file) {
return;
}
if (!file.type.startsWith('video/')) {
return;
}
draggedFile = file;
// Upload the file via ComfyUI's API
try {
const formData = new FormData();
formData.append('image', file);
formData.append('type', 'input');
const resp = await api.fetchApi('/upload/image', { method: 'POST', body: formData });
if (resp.ok || resp.status === 200) {
const data = await resp.json();
// Get the uploaded filename from response
let uploadedName = data.name || data.filename || file.name;
// Store the video filename on the node for execution
this.videoFilename = uploadedName;
// Update the hidden combo widget value so ComfyUI serializes it to the backend
const comboWidget = this.widgets.find(w => w.type === 'combo');
if (comboWidget) {
comboWidget.value = uploadedName;
}
// Also update widgets_values so serialization picks it up
if (!this.widgets_values || this.widgets_values.length === 0) {
this.widgets_values = [uploadedName];
} else {
this.widgets_values[0] = uploadedName;
}
// Directly load the video into our custom display using node method
if (typeof this.loadVideoIntoDisplay === 'function') {
this.loadVideoIntoDisplay(uploadedName);
} else {
console.error('[PowerLoadVideo] loadVideoIntoDisplay not available yet, will retry');
// Retry after a short delay
setTimeout(() => {
if (typeof this.loadVideoIntoDisplay === 'function') {
this.loadVideoIntoDisplay(uploadedName);
}
}, 50);
}
} else {
console.error('[PowerLoadVideo] Upload failed:', resp.status, resp.statusText);
}
} catch (err) {
console.error('[PowerLoadVideo] Upload error:', err);
}
};
// Attach drag handlers directly to the video container element
videoContainer.addEventListener('dragover', handleDragOver);
videoContainer.addEventListener('dragleave', handleDragLeave);
videoContainer.addEventListener('drop', handleDrop);
// Audio playback on hover - uses Web Audio API independently from video element
// so it doesn't interfere with timeline's manual frame-by-frame seeking
let audioCtx = null;
let audioBuffer = null;
let audioSource = null;
let audioLoadPromise = null;
const loadAudio = (videoFilename) => {
// Fetch audio from the same video file via /view endpoint
const audioUrl = `/view?filename=${encodeURIComponent(videoFilename)}&type=input`;
audioLoadPromise = fetch(audioUrl)
.then(r => r.arrayBuffer())
.then(data => {
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
return audioCtx.decodeAudioData(data);
})
.then(buf => { audioBuffer = buf; })
.catch(() => { audioBuffer = null; });
};
const startAudio = () => {
if (!audioBuffer || !audioCtx) return;
// Resume AudioContext if suspended (browser autoplay policy)
if (audioCtx.state === 'suspended') audioCtx.resume();
stopAudio();
audioSource = audioCtx.createBufferSource();
audioSource.buffer = audioBuffer;
audioSource.connect(audioCtx.destination);
// Sync to current timeline frame position
const nativeFPS = this.timelineWidget?.nativeFPS || 24;
const playbackFPS = this.timelineWidget?.value?.fps || nativeFPS;
const currentFrame = this.timelineWidget?.value?.currentFrame || 1;
const startOffset = (currentFrame - 1) / nativeFPS;
// Clamp to marker range end
const endMarker = this.timelineWidget?.endFrameMarker;
const totalFrames = this.timelineWidget?.value?.totalFrames || audioBuffer.duration * nativeFPS;
const endMarkerFrame = endMarker || totalFrames;
const endTime = (endMarkerFrame - 1) / nativeFPS;
const remaining = Math.max(0, endTime - startOffset);
// Adjust playback rate to sync audio speed with video FPS
const playbackRate = playbackFPS / nativeFPS;
audioSource.playbackRate.value = playbackRate;
audioSource.start(0, startOffset, remaining);
audioSource.onended = () => { audioSource = null; };
};
const stopAudio = () => {
if (audioSource) {
try { audioSource.stop(); } catch(e) {}
audioSource = null;
}
};
// Track hover state on the node to ensure audio only plays when BOTH conditions are true
videoContainer.addEventListener('mouseenter', () => {
this._isHovering = true;
// Only start audio if currently playing AND now hovering
if (this.timelineWidget?.value?.isPlaying && this._startHoverAudio) this._startHoverAudio();
});
videoContainer.addEventListener('mouseleave', () => {
this._isHovering = false;
stopAudio();
});
// Keyboard shortcut: Shift+Spacebar to toggle playback
this.handlePlayPauseShortcut = (e) => {
if ((e.key === ' ' || e.code === 'Space') && e.shiftKey) {
if (!e.target.matches('input, textarea')) {
// Only toggle playback for the currently selected node
if (this.selected) {
e.preventDefault();
e.stopPropagation();
this.timelineWidget?.togglePlay(this);
}
}
}
};
document.addEventListener('keydown', this.handlePlayPauseShortcut, true);
// Expose loadAudio so it can be called when a new video is loaded
this._loadHoverAudio = loadAudio;
this._stopHoverAudio = stopAudio;
this._startHoverAudio = () => {
if (!this.timelineWidget?.value?.isPlaying) return; // Read fresh state, not stale closure
if (audioLoadPromise) {
audioLoadPromise.then(() => { if (this.timelineWidget?.value?.isPlaying) startAudio(); });
} else if (audioBuffer) {
startAudio();
}
};
// Restart audio from beginning when video loops
this._restartHoverAudioOnLoop = () => {
if (!this.timelineWidget?.value?.isPlaying) return;
if (audioLoadPromise) {
audioLoadPromise.then(() => { if (this.timelineWidget?.value?.isPlaying) startAudio(); });
} else if (audioBuffer) {
startAudio();
}
};
// Store handlers for cleanup on node removal
this.dragHandlers = { handleDragOver, handleDragLeave, handleDrop };
// === CREATE WIDGETS IN ORDER: Top Row -> Box Crop (hidden) -> File Selector -> Video Display -> Timeline ===
// 1. Create and add the top row widget FIRST (appears above video)
if (!this.topRowWidget) {
this.topRowWidget = new PowerLoadVideoTopRowWidget();
this.addCustomWidget(this.topRowWidget);
}
// 2. Create box crop widget BEFORE file selector (file selector's Crop button needs it to exist)
if (!this.boxCropWidget) {
this.boxCropWidget = new BoxCropWidget();
this.addCustomWidget(this.boxCropWidget);
}
// 3. Create and add the file selector widget (upload + dropdown, replaces old upload button)
if (!this.fileSelectorWidget) {
this.fileSelectorWidget = new PowerLoadVideoFileSelectorWidget(this);
this.addCustomWidget(this.fileSelectorWidget);
}
// 4. Add video display as DOM widget
this.videoDisplayWidget = this.addDOMWidget(nodeData.name, 'VideoDisplay', videoContainer, {
serialize: false,
hideOnZoom: false
});
// Set computeSize for proper layout
this.videoDisplayWidget.computeSize = function(width) {
return [width, 380];
};
// 5. Create and add the timeline widget LAST (appears below video)
if (!this.timelineWidget) {
this.timelineWidget = new PowerLoadVideoTimelineWidget();
this.addCustomWidget(this.timelineWidget);
}
// Set the video element reference on the timeline widget
this.timelineWidget.setVideoElement(videoElement);
// Box crop overlay is now drawn directly on displayCanvas in updateDisplayCanvas
// Helper to sync crop values to hidden widgets for backend serialization
this.syncCropToWidgets = () => {
if (!this.boxCropWidget) return;
const bv = this.boxCropWidget.value;
const mapping = [
['crop_enabled', bv.visible],
['crop_x', bv.x],
['crop_y', bv.y],
['crop_w', bv.width],
['crop_h', bv.height],
];
for (const [name, val] of mapping) {
const w = this.widgets.find(w => w.name === name);
if (w) w.value = val;
}
};
// === HIDE THE COMBO WIDGET ("choose file to upload") AND frame range widgets ===
setTimeout(() => {
const comboWidget = this.widgets.find(w => w.type === 'combo');
if (comboWidget) {
comboWidget.computeSize = () => [0, 0]; // Make it zero height
comboWidget.hidden = true; // Mark as hidden
}
// Hide start_frame, end_frame, force_fps, max_fps, and crop parameter widgets
for (const name of ['start_frame', 'end_frame', 'force_fps', 'max_fps', 'crop_enabled', 'crop_x', 'crop_y', 'crop_w', 'crop_h']) {
const w = this.widgets.find(w => w.name === name);
if (w) {
w.computeSize = () => [0, 0];
w.hidden = true;
// Ensure float widgets always have a valid numeric value
// (ComfyUI's prompt validator rejects {} or None for FLOAT inputs)
if (name === 'force_fps' || name === 'max_fps') {
if (typeof w.value !== 'number' || isNaN(w.value)) {
w.value = 0;
}
}
}
}
// Sync initial crop values to hidden widgets
this.syncCropToWidgets();
}, 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) => {
// Clear abort flag from any previous decode
this._vfrDecodeAborted = false;
// 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) => {
// Abort if a new video was loaded while decoding
if (this._vfrDecodeAborted) {
clearTimeout(safetyTimer);
videoEl.pause();
resolve();
return;
}
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);
});
});
// If decode was aborted while awaiting, skip all post-decode setup
if (this._vfrDecodeAborted) {
return;
}
// 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);
// Only set default markers if not already restored by onConfigure
if (this.timelineWidget.startFrameMarker === 1 && this.timelineWidget.endFrameMarker === 1) {
this.timelineWidget.setStartFrame(1, this);
this.timelineWidget.setEndFrame(actualFrames, this);
} else if (this.timelineWidget.endFrameMarker > actualFrames || this.timelineWidget.endFrameMarker === 1) {
// End marker exceeds new video length or wasn't set - clamp/update it
this.timelineWidget.setEndFrame(Math.min(this.timelineWidget.endFrameMarker || actualFrames, 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;
}
}
// Draw frame (clear + draw) only when a new frame is available.
// When video is mid-seek, restore the last good frame from the buffer
// to avoid both black frames and overlay accumulation.
let drewFrame = false;
// 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);
drewFrame = true;
} else {
console.warn(`[PowerLoadVideo] updateDisplayCanvas: VFR frame ${frameIndex} (idx ${idx}) not found, total=${this.vfrFrames.length}`);
}
} else if (videoEl && videoEl.readyState >= 2) { // HAVE_CURRENT_DATA (CFR path)
ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
ctx.drawImage(videoEl, 0, 0);
drewFrame = true;
}
if (drewFrame) {
// Save clean frame (without overlay) to buffer for reuse during seeking
const buf = this._frameBuffer;
if (buf.width !== this.displayCanvas.width || buf.height !== this.displayCanvas.height) {
buf.width = this.displayCanvas.width;
buf.height = this.displayCanvas.height;
}
buf.getContext('2d').clearRect(0, 0, buf.width, buf.height);
buf.getContext('2d').drawImage(this.displayCanvas, 0, 0);
} else if (this._frameBuffer.width > 0) {
// Video not ready (seeking) - restore last good clean frame
ctx.clearRect(0, 0, this.displayCanvas.width, this.displayCanvas.height);
ctx.drawImage(this._frameBuffer, 0, 0);
}
// Draw box crop overlay on top of the video frame
if (this.boxCropWidget?.value?.visible) {
this.boxCropWidget.drawBoxOverlay(ctx, this, this.displayCanvas.width, this.displayCanvas.height);
}
};
// Wire up box crop mouse interaction on the display canvas
displayCanvas.addEventListener('mousedown', (e) => {
if (!this.boxCropWidget?.value?.visible) return;
if (this.boxCropWidget.handleCanvasMouse(e, null, this)) {
e.stopPropagation();
e.preventDefault();
const onWinMouseMove = (e) => {
this.boxCropWidget.handleCanvasMouse(e, null, this);
};
const onWinMouseUp = (e) => {
this.boxCropWidget.handleCanvasMouse(e, null, this);
window.removeEventListener('mousemove', onWinMouseMove);
window.removeEventListener('mouseup', onWinMouseUp);
};
window.addEventListener('mousemove', onWinMouseMove);
window.addEventListener('mouseup', onWinMouseUp);
}
});
// Change cursor when hovering over crop handles/box
displayCanvas.addEventListener('mousemove', (e) => {
if (!this.boxCropWidget?.value?.visible || this.boxCropWidget.isDragging) return;
const videoEl = this.videoElement;
if (!videoEl || videoEl.videoWidth === 0) return;
const canvasRect = displayCanvas.getBoundingClientRect();
const relX = (e.clientX - canvasRect.left) / canvasRect.width * videoEl.videoWidth;
const relY = (e.clientY - canvasRect.top) / canvasRect.height * videoEl.videoHeight;
const videoW = videoEl.videoWidth;
const videoH = videoEl.videoHeight;
const bv = this.boxCropWidget.value;
const cx = bv.x * videoW;
const cy = bv.y * videoH;
const hw = (bv.width * videoW) / 2;
const hh = (bv.height * videoH) / 2;
const corners = [
{ x: cx - hw, y: cy - hh, cursor: 'nw-resize' },
{ x: cx + hw, y: cy - hh, cursor: 'ne-resize' },
{ x: cx + hw, y: cy + hh, cursor: 'se-resize' },
{ x: cx - hw, y: cy + hh, cursor: 'sw-resize' },
];
let cursor = 'default';
for (const corner of corners) {
if (Math.sqrt((relX - corner.x) ** 2 + (relY - corner.y) ** 2) < 15) {
cursor = corner.cursor;
break;
}
}
if (cursor === 'default') {
const isInside = relX >= cx - hw && relX <= cx + hw && relY >= cy - hh && relY <= cy + hh;
if (isInside) cursor = 'move';
}
displayCanvas.style.cursor = cursor;
});
displayCanvas.addEventListener('mouseleave', () => {
displayCanvas.style.cursor = 'default';
});
/**
* 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;
}
};
// Abort any in-progress VFR decode (used when switching videos mid-decode)
this._abortVFRDecode = () => {
if (this.isVFRDecoding) {
this._vfrDecodeAborted = true;
if (videoElement && !videoElement.paused) {
videoElement.pause();
}
this.isVFRDecoding = false;
this.isVFRVideo = false;
this.vfrFrames = null;
if (placeholderText) {
placeholderText.style.display = 'none';
}
}
};
// Function to load video into custom display - attach to node for external access
this.loadVideoIntoDisplay = (videoFilename) => {
if (!videoFilename || String(videoElement.src).includes(videoFilename)) {
return;
}
// Abort any in-progress VFR decode before switching video
this._abortVFRDecode();
// Clear VFR frames from previous video before loading new one
this.clearVFRFrames();
// Reset frame buffer so stale frames from previous video aren't shown
this._frameBuffer.width = 0;
this._frameBuffer.height = 0;
// Re-clamp crop values to fit within video bounds
if (this.boxCropWidget) {
const bv = this.boxCropWidget.value;
bv.width = Math.min(1.0, bv.width);
bv.height = Math.min(1.0, bv.height);
bv.x = Math.max(bv.width / 2, Math.min(1 - bv.width / 2, bv.x));
bv.y = Math.max(bv.height / 2, Math.min(1 - bv.height / 2, bv.y));
if (typeof this.syncCropToWidgets === 'function') {
this.syncCropToWidgets();
}
}
// Stop playback and reset timeline when loading new video
if (this.timelineWidget?.value?.isPlaying) {
this.timelineWidget.stopPlayback();
this.timelineWidget.value.isPlaying = false;
if (this._stopHoverAudio) this._stopHoverAudio();
}
// Reset current frame to 1 and video time to start
if (this.timelineWidget) {
this.timelineWidget.value.currentFrame = 1;
}
if (videoElement) {
videoElement.currentTime = 0;
}
// Preload audio for hover playback
if (this._loadHoverAudio) this._loadHoverAudio(videoFilename);
// Hide placeholder text
if (placeholderText) {
placeholderText.style.display = 'none';
}
// Update video source - use type=input for input directory files (ComfyUI's convention)
const videoSrc = `/view?filename=${encodeURIComponent(videoFilename)}&type=input`;
videoElement.src = videoSrc;
// Set up one-time listeners for this load
const onMetadataLoaded = async () => {
// Update size display immediately
if (this.topRowWidget) {
this.topRowWidget.sizeValue = `${videoElement.videoWidth}x${videoElement.videoHeight}`;
}
// Fetch native FPS from server
try {
const resp = await api.fetchApi(`/wanvideowrapper_qq/video_metadata?filename=${encodeURIComponent(videoFilename)}`);
if (resp.ok) {
const meta = await resp.json();
if (meta.success && meta.fps) {
const detectedFPS = Math.round(meta.fps);
// Set native FPS on timeline
this.timelineWidget.nativeFPS = meta.fps;
// Update UI spinner to match native FPS
this.topRowWidget.fpsValue = detectedFPS;
this.timelineWidget.setFPS(this, detectedFPS);
// Use accurate frame count from server
let totalFrames;
if (meta.frame_count > 0) {
totalFrames = meta.frame_count;
} else {
// Calculate from duration with ceiling to ensure we don't truncate the last frame
totalFrames = Math.ceil(videoElement.duration * meta.fps);
}
this.timelineWidget.setTotalFrames(this, totalFrames);
// Only set default markers if not already restored by onConfigure
// Check: start marker still at constructor default (1) AND end marker still at constructor default (1)
if (this.timelineWidget.startFrameMarker === 1 && this.timelineWidget.endFrameMarker === 1) {
this.timelineWidget.setStartFrame(1, this);
this.timelineWidget.setEndFrame(totalFrames, this);
} else if (this.timelineWidget.endFrameMarker > totalFrames || this.timelineWidget.endFrameMarker === 1) {
// End marker exceeds new video length or wasn't set - clamp/update it
this.timelineWidget.setEndFrame(Math.min(this.timelineWidget.endFrameMarker || totalFrames, 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;
}
}
} catch (e) {
console.warn('[PowerLoadVideo] Could not fetch video metadata, estimating:', e);
}
// Fallback: estimate with current FPS
const nativeFPS = this.timelineWidget.nativeFPS || this.timelineWidget.value.fps || 24;
// Use ceiling to ensure we capture the full video duration without truncation
// Add small buffer (1 frame) to account for any edge case timing issues
const totalFrames = Math.ceil(videoElement.duration * nativeFPS) + 1;
this.timelineWidget.setTotalFrames(this, totalFrames);
// Only set default markers if not already restored by onConfigure
if (this.timelineWidget.startFrameMarker === 1 && this.timelineWidget.endFrameMarker === 1) {
this.timelineWidget.setStartFrame(1, this);
this.timelineWidget.setEndFrame(totalFrames, this);
} else if (this.timelineWidget.endFrameMarker > totalFrames || this.timelineWidget.endFrameMarker === 1) {
// End marker exceeds new video length or wasn't set - clamp/update it
this.timelineWidget.setEndFrame(Math.min(this.timelineWidget.endFrameMarker || totalFrames, totalFrames), this);
}
this.timelineWidget.applyPlaybackRate();
// Force redraw
this.setDirtyCanvas(true, true);
};
const onError = (e) => {
console.error('[PowerLoadVideo] Video load error:', e);
};
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
const originalExecute = this.execute;
this.execute = async function() {
if (originalExecute) {
await originalExecute.apply(this, arguments);
}
// Get the video filename from various sources
let videoFilename = null;
// Try to get from widgets_values first (set during node creation)
if (this.widgets_values && this.widgets_values.length > 0) {
videoFilename = this.widgets_values[0];
}
// Or try to find a widget with the video filename
if (!videoFilename) {
const videoWidget = this.widgets.find(w => w.type === 'combo' || (w.options && w.options.video_upload));
if (videoWidget) {
videoFilename = videoWidget.value;
}
}
// Or try to get from node.videoFilename (set by Upload button)
if (!videoFilename) {
videoFilename = this.videoFilename;
}
if (typeof this.loadVideoIntoDisplay === 'function') {
this.loadVideoIntoDisplay(videoFilename);
}
}.bind(this);
// Watch for drag-drop changes on the combo widget (before execute)
setTimeout(() => {
const videoWidget = this.widgets.find(w => w.type === 'combo');
if (videoWidget) {
// Store original callback if any
const originalCallback = videoWidget.callback;
videoWidget.callback = function(value) {
if (typeof this.loadVideoIntoDisplay === 'function') {
this.loadVideoIntoDisplay(value);
}
if (originalCallback) {
originalCallback.apply(this, arguments);
}
}.bind(this);
}
}, 100);
};
}