/** * 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); }; }