From d3f4084d8295818d9a4d30a6073b648e621a1ba0 Mon Sep 17 00:00:00 2001 From: WhatDreamsCost Date: Mon, 4 May 2026 14:29:05 -0500 Subject: [PATCH] New Node: Load Video UI --- __init__.py | 7 +- js/load_video_ui.js | 949 ++++++++++++++++++++++++++++++++++++++++++++ load_video_ui.py | 331 +++++++++++++++ pyproject.toml | 2 +- 4 files changed, 1286 insertions(+), 3 deletions(-) create mode 100644 js/load_video_ui.js create mode 100644 load_video_ui.py diff --git a/__init__.py b/__init__.py index f2ec91a..a9472f1 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,7 @@ from .multi_image_loader import MultiImageLoader from .ltx_sequencer import LTXSequencer from .speech_length_calculator import SpeechLengthCalculator from .load_audio_ui import LoadAudioUI +from .load_video_ui import LoadVideoUI # Imported new node # Register the node classes NODE_CLASS_MAPPINGS = { @@ -10,7 +11,8 @@ NODE_CLASS_MAPPINGS = { "MultiImageLoader": MultiImageLoader, "LTXSequencer": LTXSequencer, "SpeechLengthCalculator": SpeechLengthCalculator, - "LoadAudioUI": LoadAudioUI + "LoadAudioUI": LoadAudioUI, + "LoadVideoUI": LoadVideoUI # Registered new node } # Provide clean display names for the ComfyUI interface @@ -19,7 +21,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "MultiImageLoader": "Multi Image Loader", "LTXSequencer": "LTX Sequencer", "SpeechLengthCalculator": "Speech Length Calculator", - "LoadAudioUI": "Load Audio UI" + "LoadAudioUI": "Load Audio UI", + "LoadVideoUI": "Load Video UI" # Registered display name } WEB_DIRECTORY = "./js" diff --git a/js/load_video_ui.js b/js/load_video_ui.js new file mode 100644 index 0000000..a8d72dd --- /dev/null +++ b/js/load_video_ui.js @@ -0,0 +1,949 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +app.registerExtension({ + name: "Comfy.LoadVideoUI", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "LoadVideoUI") { + const onNodeCreated = nodeType.prototype.onNodeCreated; + const onConfigure = nodeType.prototype.onConfigure; + const onResize = nodeType.prototype.onResize; + const onDrawForeground = nodeType.prototype.onDrawForeground; + + // Hook into workflow loading to instantly restore the video UI + nodeType.prototype.onConfigure = function (info) { + if (onConfigure) { + onConfigure.apply(this, arguments); + } + + // Force UI synchronization + if (this.syncFramesFromTime) this.syncFramesFromTime(); + if (this.toggleWidgetVisibility) this.toggleWidgetVisibility(); + + if (this.widgets) { + const videoWidget = this.widgets.find(w => w.name === "video"); + if (videoWidget && videoWidget.value && this.updatePreview) { + this.updatePreview(videoWidget.value); + } + } + }; + + // Continuous frame-accurate check to guarantee exact height alignment + // even on initial graph load when the workflow reloads! + nodeType.prototype.onDrawForeground = function (ctx) { + if (onDrawForeground) onDrawForeground.apply(this, arguments); + + if (this.domWidget && this.domWidget.element && this.domWidget.last_y) { + const remainingHeight = this.size[1] - this.domWidget.last_y - 18; + const currentHeight = parseFloat(this.domWidget.element.style.height); + const targetHeight = Math.max(150, remainingHeight); + + // Only update DOM if the height has drifted by more than 1 pixel + if (isNaN(currentHeight) || Math.abs(currentHeight - targetHeight) > 1) { + this.domWidget.element.style.height = `${targetHeight}px`; + } + } + }; + + // Allow the node to scale nicely when resized by the user + nodeType.prototype.onResize = function (size) { + if (onResize) onResize.apply(this, arguments); + if (this.domWidget && this.domWidget.element) { + // Fill the exact width provided by LiteGraph's bounds natively + this.domWidget.element.style.width = "100%"; + this.domWidget.element.style.margin = "0"; + + // Fallback calc if last_y isn't ready + let yOffset = this.domWidget.last_y; + if (!yOffset) { + yOffset = 30; // Default LiteGraph Title Height + if (this.widgets) { + for (let w of this.widgets) { + if (w === this.domWidget) break; + yOffset += (w.computeSize ? w.computeSize()[1] : 20) + 4; + } + } + } + + const remainingHeight = size[1] - yOffset - 18; + this.domWidget.element.style.height = `${Math.max(150, remainingHeight)}px`; + } + }; + + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + const node = this; + + // Find the core widgets + const videoWidget = this.widgets.find((w) => w.name === "video"); + const frameRateWidget = this.widgets.find((w) => w.name === "frame_rate"); + const displayModeWidget = this.widgets.find((w) => w.name === "display_mode"); + + const startTimeWidget = this.widgets.find((w) => w.name === "start_time"); + const endTimeWidget = this.widgets.find((w) => w.name === "end_time"); + const durationWidget = this.widgets.find((w) => w.name === "duration"); + + const startFrameWidget = this.widgets.find((w) => w.name === "start_frame"); + const endFrameWidget = this.widgets.find((w) => w.name === "end_frame"); + const durationFramesWidget = this.widgets.find((w) => w.name === "duration_frames"); + + // ==================================================================== + // WIDGET HIDING & SYNC ENGINE + // ==================================================================== + let isSyncing = false; + + function setWidgetVisibility(w, visible, typeStr) { + if (!w) return; + w.hidden = !visible; + if (!visible) { + w.type = "hidden"; + w.computeSize = () => [0, -4]; // Suppresses gap allocation in V1 + } else { + w.type = typeStr; + delete w.computeSize; // Restores standard ComfyUI measurement + } + } + + node.toggleWidgetVisibility = function() { + const isFrames = displayModeWidget && displayModeWidget.value === "frames"; + setWidgetVisibility(startTimeWidget, !isFrames, "FLOAT"); + setWidgetVisibility(endTimeWidget, !isFrames, "FLOAT"); + setWidgetVisibility(durationWidget, !isFrames, "FLOAT"); + setWidgetVisibility(startFrameWidget, isFrames, "INT"); + setWidgetVisibility(endFrameWidget, isFrames, "INT"); + setWidgetVisibility(durationFramesWidget, isFrames, "INT"); + setWidgetVisibility(displayModeWidget, false, "combo"); // Toggle is hidden, driven by UI + + // Allow the node to calculate its required min size, but DO NOT overwrite + // the current user-defined width/height unless it's strictly smaller than the minimum. + const minSize = node.computeSize(); + node.size[0] = Math.max(node.size[0], minSize[0]); + node.size[1] = Math.max(node.size[1], minSize[1]); + + if (node.onResize) node.onResize(node.size); + app.graph.setDirtyCanvas(true, true); + }; + + node.syncFramesFromTime = function() { + if (isSyncing || !frameRateWidget) return; + isSyncing = true; + const fr = frameRateWidget.value || 24; + if (startTimeWidget && startFrameWidget) startFrameWidget.value = Math.round(startTimeWidget.value * fr); + if (endTimeWidget && endFrameWidget) endFrameWidget.value = Math.round(endTimeWidget.value * fr); + if (durationWidget && durationFramesWidget) durationFramesWidget.value = Math.round(durationWidget.value * fr); + isSyncing = false; + }; + + node.syncTimeFromFrames = function() { + if (isSyncing || !frameRateWidget) return; + isSyncing = true; + const fr = frameRateWidget.value || 24; + if (startTimeWidget && startFrameWidget) startTimeWidget.value = parseFloat((startFrameWidget.value / fr).toFixed(3)); + if (endTimeWidget && endFrameWidget) endTimeWidget.value = parseFloat((endFrameWidget.value / fr).toFixed(3)); + if (durationWidget && durationFramesWidget) durationFramesWidget.value = parseFloat((durationFramesWidget.value / fr).toFixed(3)); + isSyncing = false; + }; + + // Bind standard input callbacks to synchronize automatically + function bindWidget(w, isFrame, isFrameRate = false) { + if (!w) return; + const orig = w.callback; + w.callback = function() { + if (orig) orig.apply(this, arguments); + if (isFrame) node.syncTimeFromFrames(); + else node.syncFramesFromTime(); + + // Always force a ruler update if framerate changes so the timeline marks match the new rate + if (duration === 0 || isFrameRate) updateRuler(); + updateUI(true); + }; + } + + bindWidget(startTimeWidget, false); + bindWidget(endTimeWidget, false); + bindWidget(startFrameWidget, true); + bindWidget(endFrameWidget, true); + bindWidget(frameRateWidget, false, true); // Triggers re-sync of frames from time AND updates ruler + + // Bind update function to the node so onConfigure can access it + node.updatePreview = function(filename) { + if (!filename) { + return; + } + let url; + + // Check if absolute path (Starts with C:\ or /) + if (filename.match(/^[a-zA-Z]:\\/) || filename.startsWith('/')) { + url = api.apiURL(`/video_ui_custom_view?filename=${encodeURIComponent(filename)}`); + } else { + url = api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`); + } + + if (videoPreview) videoPreview.src = url; + }; + + if (videoWidget) { + const originalCallback = videoWidget.callback; + videoWidget.callback = function() { + if (originalCallback) originalCallback.apply(this, arguments); + if (node.updatePreview) node.updatePreview(this.value); + }; + } + + // Initialize widget visibility right away + if (displayModeWidget && !displayModeWidget.value) displayModeWidget.value = "seconds"; + node.toggleWidgetVisibility(); + + // ==================================================================== + // CHOOSE FILE BUTTON (Native ComfyUI Widget, placed below duration) + // ==================================================================== + const fileInput = document.createElement("input"); + fileInput.type = "file"; + fileInput.accept = "video/*"; + fileInput.style.display = "none"; + document.body.appendChild(fileInput); + + const btnWidget = this.addWidget("button", "choose file to upload", null, () => { + fileInput.click(); + }); + + // Define robust upload logic + const uploadFile = async (file) => { + try { + if (errorMsg) errorMsg.style.display = "none"; + + // Fast Path: If desktop environment exposes absolute file path, skip upload entirely! + if (file.path) { + videoWidget.value = file.path; + node.updatePreview(file.path); + if(startTimeWidget) startTimeWidget.value = 0; + if(endTimeWidget) endTimeWidget.value = 0; + node.syncFramesFromTime(); + return; + } + + btnWidget.name = "Uploading..."; + node.setDirtyCanvas(true, false); + + const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB chunks + + if (file.size > CHUNK_SIZE) { + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + const safeFileName = file.name.replace(/[^a-zA-Z0-9.\-_]/g, '_'); + const safeName = Date.now() + "_" + safeFileName; + + for (let i = 0; i < totalChunks; i++) { + btnWidget.name = `Uploading... ${Math.round((i / totalChunks) * 100)}%`; + node.setDirtyCanvas(true, false); + + const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE); + + const formData = new FormData(); + formData.append("file", chunk); + formData.append("filename", safeName); + formData.append("chunk_index", i); + formData.append("total_chunks", totalChunks); + + const resp = await api.fetchApi("/video_ui_upload_chunk", { + method: "POST", + body: formData, + }); + + if (resp.status !== 200) { + throw new Error("Chunk upload failed"); + } + + if (i === totalChunks - 1) { + const data = await resp.json(); + videoWidget.value = data.name; + node.updatePreview(data.name); + if(startTimeWidget) startTimeWidget.value = 0; + if(endTimeWidget) endTimeWidget.value = 0; + node.syncFramesFromTime(); + } + } + } else { + // Standard upload for small files + const body = new FormData(); + body.append("image", file); + + const resp = await api.fetchApi("/upload/image", { + method: "POST", + body: body, + }); + + if (resp.status === 413) { + throw new Error("File too large. Make sure python backend has the chunking update."); + } + + if (resp.status === 200) { + const data = await resp.json(); + videoWidget.value = data.name; + node.updatePreview(data.name); + if(startTimeWidget) startTimeWidget.value = 0; + if(endTimeWidget) endTimeWidget.value = 0; + node.syncFramesFromTime(); + } else { + throw new Error(`Upload failed: ${resp.statusText}`); + } + } + } catch (error) { + console.error("Upload failed", error); + if (errorMsg) { + errorMsg.textContent = "Upload failed. Check console."; + errorMsg.style.display = "block"; + } + } finally { + btnWidget.name = "choose file to upload"; + node.setDirtyCanvas(true, false); + fileInput.value = ""; // reset input + } + }; + + fileInput.addEventListener("change", (e) => { + if (e.target.files.length) { + uploadFile(e.target.files[0]); + } + }); + + // Attach drag & drop directly onto the LiteGraph node canvas frame + node.onDropFile = function(file) { + // Check MIME type or common video file extensions to ensure all videos are caught + if (file.type.startsWith('video/') || file.name.toLowerCase().match(/\.(mp4|webm|mkv|avi|mov|m4v|flv|wmv)$/)) { + uploadFile(file); + return true; + } + return false; + }; + + // Clean up DOM elements strictly tied to this node instance + const originalOnRemove = node.onRemoved; + node.onRemoved = function() { + if(fileInput && fileInput.parentNode) fileInput.parentNode.removeChild(fileInput); + if(originalOnRemove) originalOnRemove.apply(this, arguments); + }; + + // ==================================================================== + // UI CONTAINER (Preview & Timeline Editor) + // ==================================================================== + const container = document.createElement("div"); + const defaultBg = "rgba(30, 30, 30, 0.9)"; + Object.assign(container.style, { + display: "flex", + flexDirection: "column", + gap: "10px", + width: "100%", + margin: "0", + padding: "10px", + boxSizing: "border-box", + background: defaultBg, + borderRadius: "6px", + color: "white", + fontFamily: "sans-serif", + marginTop: "8px", + flexShrink: "0", + transition: "background 0.2s" + }); + + const errorMsg = document.createElement("div"); + Object.assign(errorMsg.style, { + color: "#ff6b6b", + fontSize: "11px", + display: "none", + marginBottom: "4px", + flexShrink: "0", + boxSizing: "border-box" + }); + container.appendChild(errorMsg); + + // Top Bar: Display Mode Toggle & Trimmed Length + const playerTop = document.createElement("div"); + Object.assign(playerTop.style, { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "0 2px", + marginBottom: "-4px", + flexShrink: "0", + boxSizing: "border-box", + flexWrap: "wrap", // Prevent squishing/overflow by letting it wrap gracefully + gap: "6px" + }); + + // Toggle Container UI + const toggleWrapper = document.createElement("div"); + Object.assign(toggleWrapper.style, { + display: "flex", + alignItems: "center", + gap: "4px", + background: "rgba(0, 0, 0, 0.2)", + padding: "0 6px", + borderRadius: "4px", + height: "22px", + boxSizing: "border-box" + }); + + const toggleTitle = document.createElement("span"); + toggleTitle.textContent = "Display:"; + Object.assign(toggleTitle.style, { + fontSize: "11px", + color: "#38bdf8", // Locked to Blue + fontWeight: "bold" + }); + + const modeText = document.createElement("span"); + Object.assign(modeText.style, { + fontSize: "11px", + color: "#38bdf8", // Locked to Blue + fontWeight: "bold", + minWidth: "40px", + textAlign: "left", // Changed to left for tighter alignment with "Display:" + marginRight: "4px" // Adds spacing before the toggle switch + }); + modeText.textContent = "Time"; + + const switchBox = document.createElement("div"); + Object.assign(switchBox.style, { + width: "26px", height: "14px", background: "#38bdf8", borderRadius: "14px", // Shorter and adjusted width + display: "flex", alignItems: "center", padding: "2px", boxSizing: "border-box", // Uses flexbox to perfectly center thumb + cursor: "pointer", transition: "background 0.3s", + flexShrink: "0" + }); + + const switchThumb = document.createElement("div"); + Object.assign(switchThumb.style, { + width: "10px", height: "10px", background: "white", borderRadius: "50%", + transition: "transform 0.3s" + }); + switchBox.appendChild(switchThumb); + + let isFramesMode = false; + switchBox.onclick = () => { + isFramesMode = !isFramesMode; + + // Switch only the toggle button itself: Custom Blue (#257eeb) for Frames, Light Blue (#38bdf8) for Time + switchBox.style.background = isFramesMode ? "#257eeb" : "#38bdf8"; + switchThumb.style.transform = isFramesMode ? "translateX(12px)" : "translateX(0px)"; // Adjusted translation to fit smaller box + modeText.textContent = isFramesMode ? "Frames" : "Time"; + + if (displayModeWidget) displayModeWidget.value = isFramesMode ? "frames" : "seconds"; + + // Sync values perfectly on flip + if (isFramesMode) node.syncFramesFromTime(); + else node.syncTimeFromFrames(); + + node.toggleWidgetVisibility(); + updateRuler(); + updateUI(true); + }; + + // Reordered appending to match requested Layout (Display: -> Mode Text -> Toggle Switch) + toggleWrapper.appendChild(toggleTitle); + toggleWrapper.appendChild(modeText); + toggleWrapper.appendChild(switchBox); + + playerTop.appendChild(toggleWrapper); + + const trimLength = document.createElement("span"); + Object.assign(trimLength.style, { + display: "flex", + alignItems: "center", + fontSize: "11px", + color: "#38bdf8", // Always remains blue + fontWeight: "bold", + background: "rgba(56, 189, 248, 0.1)", // Always remains blue + padding: "0 6px", + borderRadius: "4px", + whiteSpace: "nowrap", + height: "22px", + boxSizing: "border-box" + }); + trimLength.textContent = "Trimmed: 0:00"; + playerTop.appendChild(trimLength); + + container.appendChild(playerTop); + + // Video Preview Area (Native Controls) + const videoPreview = document.createElement("video"); + Object.assign(videoPreview.style, { + width: "100%", + background: "#000", + borderRadius: "4px", + objectFit: "contain", + flexGrow: "1", // Force player to expand and fill available vertical space dynamically calculated by onResize + minHeight: "0px", + outline: "none", + boxSizing: "border-box" + }); + videoPreview.controls = true; + videoPreview.controlsList = "nodownload nofullscreen noremoteplayback"; + videoPreview.muted = false; // Changed from true to false so the video starts unmuted + container.appendChild(videoPreview); + + // Trim Area (Time Ruler & Slider) + const trimArea = document.createElement("div"); + Object.assign(trimArea.style, { + display: "flex", + flexDirection: "column", + gap: "6px", + background: "rgba(0, 0, 0, 0.35)", + padding: "12px", + borderRadius: "6px", + border: "1px solid rgba(255, 255, 255, 0.05)", + flexShrink: "0", // Prevent timeline from squishing when shrinking node + boxSizing: "border-box" + }); + + const timeRuler = document.createElement("div"); + Object.assign(timeRuler.style, { + position: "relative", + width: "100%", + height: "22px", + fontSize: "10px", + color: "#aaa", + pointerEvents: "none", + userSelect: "none", + boxSizing: "border-box" + }); + trimArea.appendChild(timeRuler); + + const sliderBox = document.createElement("div"); + Object.assign(sliderBox.style, { + position: "relative", + width: "100%", + height: "24px", + background: "#111", + borderRadius: "4px", + cursor: "pointer", + userSelect: "none", + boxShadow: "inset 0 1px 3px rgba(0,0,0,0.5)", + boxSizing: "border-box" + }); + + const fill = document.createElement("div"); + Object.assign(fill.style, { + position: "absolute", + height: "100%", + background: "rgba(14, 165, 233, 0.35)", + pointerEvents: "none" + }); + sliderBox.appendChild(fill); + + const createHandle = (color) => { + const h = document.createElement("div"); + Object.assign(h.style, { + position: "absolute", + top: "0", + width: "8px", + height: "100%", + background: color, + transform: "translateX(-50%)", + pointerEvents: "none", + boxShadow: "0 0 4px rgba(0,0,0,0.8)", + borderRadius: "2px" + }); + return h; + }; + + const startHandle = createHandle("#38bdf8"); + const endHandle = createHandle("#38bdf8"); + sliderBox.appendChild(startHandle); + sliderBox.appendChild(endHandle); + trimArea.appendChild(sliderBox); + + container.appendChild(trimArea); + + // Delay DOM Widget creation to ensure it is added after all standard widgets + setTimeout(() => { + // Add HTML widget to LiteGraph + node.domWidget = node.addDOMWidget("VideoUI", "div", container); + + // Fixed: Return a solid minimum required bounding box. + // Bumped horizontal from 200px to 360px. This natively stops LiteGraph + // from letting the node be squished too thin, completely preventing overlap. + node.domWidget.computeSize = function() { + return [360, 250]; + }; + + // Applies the default creation bounds natively, increased default height + // to match the widgets required height out of the box. + requestAnimationFrame(() => { + if (node.size[0] < 550) { + node.size[0] = 550; + } + + // INCREASE DEFAULT HEIGHT HERE: + // Change the 620 below to adjust the starting height of the node + if (node.size[1] < 680) { + node.size[1] = 680; + } + + // Trigger manual resize call so the vertical math applies instantly + if (node.onResize) node.onResize(node.size); + + // Sync visual toggle to initial data + if (displayModeWidget && displayModeWidget.value === "frames") { + isFramesMode = false; // prime for click + switchBox.onclick(); + } + + app.graph.setDirtyCanvas(true, true); + }); + }, 100); + + // ==================================================================== + // LOGIC & SYNCING + // ==================================================================== + let duration = 0; + let dragging = null; + let dragOffset = 0; + let dragSelectionWidth = 0; + let isUpdatingDuration = false; + + // Smart helper to ensure timeline displays correctly even with no video loaded + const getActiveDuration = () => { + if (duration > 0) return duration; + let e = endTimeWidget ? parseFloat(endTimeWidget.value) || 0 : 0; + let s = startTimeWidget ? parseFloat(startTimeWidget.value) || 0 : 0; + let maxVal = Math.max(e, s); + return maxVal > 0 ? Math.max(maxVal, 1.0) : 1.0; // Default to 1.0 if completely empty + }; + + // Time Duration Hook + if (durationWidget) { + const origCallback = durationWidget.callback; + durationWidget.callback = function(v) { + if (isUpdatingDuration) { + if (origCallback) origCallback.apply(this, arguments); + return; + } + + isUpdatingDuration = true; + const activeDur = getActiveDuration(); + let d = parseFloat(v) || 0; + if (d < 0) d = 0; + if (d > activeDur) d = activeDur; + + let s = startTimeWidget ? parseFloat(startTimeWidget.value) || 0 : 0; + let newStart = s; + let newEnd = s + d; + + if (newEnd > activeDur) { + newEnd = activeDur; + newStart = activeDur - d; + } + + if (startTimeWidget) startTimeWidget.value = parseFloat(newStart.toFixed(2)); + if (endTimeWidget) endTimeWidget.value = parseFloat(newEnd.toFixed(2)); + node.syncFramesFromTime(); + + if (duration === 0) updateRuler(); + updateUI(true); + app.graph.setDirtyCanvas(true, false); + + if (origCallback) origCallback.apply(this, arguments); + isUpdatingDuration = false; + }; + } + + // Frame Duration Hook + if (durationFramesWidget) { + const origCallback = durationFramesWidget.callback; + durationFramesWidget.callback = function(v) { + if (isUpdatingDuration || !frameRateWidget) { + if (origCallback) origCallback.apply(this, arguments); + return; + } + + isUpdatingDuration = true; + const fr = frameRateWidget.value || 24; + const activeDurFrames = Math.round(getActiveDuration() * fr); + + let d = parseInt(v) || 0; + if (d < 0) d = 0; + if (d > activeDurFrames) d = activeDurFrames; + + let s = startFrameWidget ? parseInt(startFrameWidget.value) || 0 : 0; + let newStart = s; + let newEnd = s + d; + + if (newEnd > activeDurFrames) { + newEnd = activeDurFrames; + newStart = activeDurFrames - d; + } + + if (startFrameWidget) startFrameWidget.value = newStart; + if (endFrameWidget) endFrameWidget.value = newEnd; + node.syncTimeFromFrames(); + + if (duration === 0) updateRuler(); + updateUI(true); + app.graph.setDirtyCanvas(true, false); + + if (origCallback) origCallback.apply(this, arguments); + isUpdatingDuration = false; + }; + } + + // Standard Video Player Format HH:MM:SS (only shows hours if it's over an hour long) + const formatTime = (secs) => { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = Math.floor(secs % 60); + const mStr = m.toString().padStart(2, '0'); + const sStr = s.toString().padStart(2, '0'); + + if (h > 0) { + return `${h}:${mStr}:${sStr}`; + } else { + return `${m}:${sStr}`; + } + }; + + const updateRuler = () => { + timeRuler.innerHTML = ''; + const activeDur = getActiveDuration(); + const numMajorTicks = 5; + const subTicks = 4; + const totalTicks = (numMajorTicks - 1) * subTicks; + + const isFrames = displayModeWidget && displayModeWidget.value === "frames"; + const fr = frameRateWidget ? frameRateWidget.value : 24; + + for (let i = 0; i <= totalTicks; i++) { + const pct = i / totalTicks; + const t = activeDur * pct; + const isMajor = i % subTicks === 0; + const tickWrapper = document.createElement("div"); + Object.assign(tickWrapper.style, { + position: "absolute", left: `${pct * 100}%`, top: "0", + display: "flex", flexDirection: "column", alignItems: "center", transform: "translateX(-50%)" + }); + if (i === 0) { tickWrapper.style.transform = "none"; tickWrapper.style.alignItems = "flex-start"; } + if (i === totalTicks) { tickWrapper.style.transform = "translateX(-100%)"; tickWrapper.style.alignItems = "flex-end"; } + const line = document.createElement("div"); + Object.assign(line.style, { + width: isMajor ? "2px" : "1px", height: isMajor ? "6px" : "4px", + background: isMajor ? "#aaa" : "#555", marginBottom: "2px", borderRadius: "1px" + }); + tickWrapper.appendChild(line); + + if (isMajor) { + const label = document.createElement("div"); + if (isFrames) { + label.textContent = Math.round(t * fr); + } else { + label.textContent = formatTime(t); + } + tickWrapper.appendChild(label); + } + timeRuler.appendChild(tickWrapper); + } + }; + + function updateUI(syncPlayer = false) { + const activeDur = getActiveDuration(); + + let s = startTimeWidget ? parseFloat(startTimeWidget.value) || 0 : 0; + let e = endTimeWidget ? parseFloat(endTimeWidget.value) || 0 : 0; + + let visualEnd = e; + if (visualEnd === 0 || visualEnd > activeDur) visualEnd = activeDur; + if (s > visualEnd) s = visualEnd; + + let pStart = (s / activeDur) * 100; + let pEnd = (visualEnd / activeDur) * 100; + + pStart = Math.max(0, Math.min(pStart, 100)); + pEnd = Math.max(0, Math.min(pEnd, 100)); + + startHandle.style.left = `${pStart}%`; + endHandle.style.left = `${pEnd}%`; + + fill.style.left = `${pStart}%`; + fill.style.width = `${pEnd - pStart}%`; + + const currentDur = parseFloat((visualEnd - s).toFixed(2)); + const isFrames = displayModeWidget && displayModeWidget.value === "frames"; + const fr = frameRateWidget ? frameRateWidget.value : 24; + + if (isFrames) { + trimLength.textContent = `Trimmed: ${Math.round(currentDur * fr)} frames`; + // Keeps its blue styling securely + } else { + trimLength.textContent = `Trimmed: ${formatTime(currentDur)}`; + // Keeps its blue styling securely + } + + // Only automatically push data directly to durationWidget if a real video is loaded + if (duration > 0 && !isUpdatingDuration) { + isUpdatingDuration = true; + if (durationWidget && durationWidget.value !== currentDur) { + durationWidget.value = currentDur; + } + if (durationFramesWidget && durationFramesWidget.value !== Math.round(currentDur * fr)) { + durationFramesWidget.value = Math.round(currentDur * fr); + } + isUpdatingDuration = false; + } + + if (syncPlayer && duration > 0) { + videoPreview.currentTime = s; + } + } + + // Force draw default empty state on creation + setTimeout(() => { + updateRuler(); + updateUI(); + }, 50); + + videoPreview.onloadedmetadata = () => { + duration = videoPreview.duration; + if (endTimeWidget && (endTimeWidget.value === 0 || endTimeWidget.value > duration)) { + endTimeWidget.value = duration; + node.syncFramesFromTime(); + } + updateRuler(); + updateUI(); + }; + + // Loop Trim during Native Playback + videoPreview.ontimeupdate = () => { + if (!duration || dragging) return; + + let s = startTimeWidget ? parseFloat(startTimeWidget.value) || 0 : 0; + let e = endTimeWidget ? parseFloat(endTimeWidget.value) || duration : duration; + if (e === 0) e = duration; + + if (videoPreview.currentTime >= e && e > 0) { + videoPreview.currentTime = s; + } else if (videoPreview.currentTime < s) { + videoPreview.currentTime = s; + } + }; + + // --- Timeline Drag Logic (Primary state runs in Seconds format to lock playback natively) --- + sliderBox.onpointerdown = (e) => { + const activeDur = getActiveDuration(); + const rect = sliderBox.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const val = (x / rect.width) * activeDur; + + let s = startTimeWidget ? parseFloat(startTimeWidget.value) || 0 : 0; + let e_val = endTimeWidget ? parseFloat(endTimeWidget.value) || activeDur : activeDur; + if (e_val === 0) e_val = activeDur; + + const handleTolerance = (10 / rect.width) * activeDur; + + if (val > s + handleTolerance && val < e_val - handleTolerance) { + dragging = 'center'; + dragOffset = val - s; + dragSelectionWidth = e_val - s; + } else if (Math.abs(val - s) < Math.abs(val - e_val)) { + dragging = 'start'; + if(startTimeWidget) startTimeWidget.value = parseFloat(Math.min(val, e_val).toFixed(2)); + if(duration > 0) videoPreview.currentTime = startTimeWidget.value; + } else { + dragging = 'end'; + if(endTimeWidget) endTimeWidget.value = parseFloat(Math.max(val, s).toFixed(2)); + if(duration > 0) videoPreview.currentTime = endTimeWidget.value; + } + + node.syncFramesFromTime(); + updateUI(); + app.graph.setDirtyCanvas(true, false); + sliderBox.setPointerCapture(e.pointerId); + }; + + sliderBox.onpointermove = (e) => { + if (!dragging) return; + const activeDur = getActiveDuration(); + const rect = sliderBox.getBoundingClientRect(); + const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const val = (x / rect.width) * activeDur; + + if (dragging === 'start') { + let e_val = endTimeWidget ? parseFloat(endTimeWidget.value) || activeDur : activeDur; + if (e_val === 0) e_val = activeDur; + if(startTimeWidget) startTimeWidget.value = parseFloat(Math.min(val, e_val).toFixed(2)); + if(duration > 0) videoPreview.currentTime = startTimeWidget.value; + } else if (dragging === 'end') { + const s = startTimeWidget ? parseFloat(startTimeWidget.value) || 0 : 0; + if(endTimeWidget) endTimeWidget.value = parseFloat(Math.max(val, s).toFixed(2)); + if(duration > 0) videoPreview.currentTime = endTimeWidget.value; + } else if (dragging === 'center') { + let newStart = val - dragOffset; + let newEnd = newStart + dragSelectionWidth; + + if (newStart < 0) { + newStart = 0; + newEnd = dragSelectionWidth; + } else if (newEnd > activeDur) { + newEnd = activeDur; + newStart = activeDur - dragSelectionWidth; + } + + if(startTimeWidget) startTimeWidget.value = parseFloat(newStart.toFixed(2)); + if(endTimeWidget) endTimeWidget.value = parseFloat(newEnd.toFixed(2)); + if(duration > 0) videoPreview.currentTime = startTimeWidget.value; + } + + node.syncFramesFromTime(); + updateUI(); + app.graph.setDirtyCanvas(true, false); + }; + + sliderBox.onpointerup = (e) => { + dragging = null; + sliderBox.releasePointerCapture(e.pointerId); + }; + + // --- Improved Global Drag & Drop for Node Inner Content --- + let dragCounter = 0; + container.addEventListener("dragenter", (e) => { + e.preventDefault(); + dragCounter++; + if (dragCounter === 1) { + container.style.outline = "2px dashed #38bdf8"; + container.style.outlineOffset = "-2px"; + container.style.background = "rgba(14, 165, 233, 0.1)"; + } + }); + + container.addEventListener("dragover", (e) => { + e.preventDefault(); + }); + + container.addEventListener("dragleave", (e) => { + e.preventDefault(); + dragCounter--; + if (dragCounter === 0) { + container.style.outline = "none"; + container.style.background = defaultBg; + } + }); + + container.addEventListener("drop", (e) => { + e.preventDefault(); + e.stopPropagation(); + dragCounter = 0; + container.style.outline = "none"; + container.style.background = defaultBg; + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + const file = e.dataTransfer.files[0]; + if (file.type.startsWith('video/') || file.name.toLowerCase().match(/\.(mp4|webm|mkv|avi|mov|m4v|flv|wmv)$/)) { + uploadFile(file); + } + } + }); + + if (videoWidget && videoWidget.value) { + node.updatePreview(videoWidget.value); + } + + return r; + }; + } + }, +}); \ No newline at end of file diff --git a/load_video_ui.py b/load_video_ui.py new file mode 100644 index 0000000..48fd304 --- /dev/null +++ b/load_video_ui.py @@ -0,0 +1,331 @@ +import os +import torch +import numpy as np +import folder_paths +import av +from server import PromptServer +from aiohttp import web +import comfy.utils + +# Custom API route to serve video files from anywhere on the user's system for the frontend preview +@PromptServer.instance.routes.get("/video_ui_custom_view") +async def custom_view(request): + file_path = request.query.get("filename", "") + if os.path.exists(file_path) and os.path.isfile(file_path): + return web.FileResponse(file_path) + return web.Response(status=404, text="File not found") + +# Custom API route for Chunked Uploads to bypass the 413 Payload Too Large error +@PromptServer.instance.routes.post("/video_ui_upload_chunk") +async def upload_chunk(request): + post = await request.post() + file = post.get("file") + filename = post.get("filename") + chunk_index = int(post.get("chunk_index")) + total_chunks = int(post.get("total_chunks")) + + upload_dir = folder_paths.get_input_directory() + file_path = os.path.join(upload_dir, filename) + + # Append to file if it's not the first chunk, otherwise write new + mode = "ab" if chunk_index > 0 else "wb" + with open(file_path, mode) as f: + f.write(file.file.read()) + + if chunk_index == total_chunks - 1: + return web.json_response({"name": filename}) + return web.json_response({"status": "ok"}) + + +class LoadVideoUI: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "video": ("STRING", {"default": ""}), + "start_time": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100000.0, "step": 0.01}), + "end_time": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100000.0, "step": 0.01}), + "duration": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100000.0, "step": 0.01}), + "start_frame": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}), + "end_frame": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}), + "duration_frames": ("INT", {"default": 0, "min": 0, "max": 10000000, "step": 1}), + "resize_method": (["maintain aspect ratio", "stretch to fit", "pad", "crop"], {"default": "maintain aspect ratio"}), + "custom_width": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 8, "tooltip": "Custom width. 0 means original width."}), + "custom_height": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 8, "tooltip": "Custom height. 0 means original height."}), + "frame_rate": ("INT", {"default": 24, "min": 1, "max": 120, "step": 1, "tooltip": "Force the video to a specific frame rate for extraction."}), + "display_mode": (["seconds", "frames"], {"default": "seconds"}), + } + } + + RETURN_TYPES = ("IMAGE", "AUDIO", "FLOAT", "INT") + RETURN_NAMES = ("images", "audio", "duration", "frame_count") + FUNCTION = "load_video" + CATEGORY = "Custom/Video" + + def load_video(self, video, frame_rate, display_mode, start_time, end_time, duration, start_frame, end_frame, duration_frames, custom_width=0, custom_height=0, resize_method="maintain aspect ratio", **kwargs): + if not video: + # Return blank defaults if no video is loaded + empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + empty_audio = {"waveform": torch.zeros((1, 1, 44100)), "sample_rate": 44100} + return (empty_image, empty_audio, 0.0, 0) + + # 1. Resolve path using ComfyUI standard paths or Absolute Path + video_path = video # Try exact/absolute path first + if not os.path.exists(video_path): + video_path_annotated = folder_paths.get_annotated_filepath(video) + if os.path.exists(video_path_annotated): + video_path = video_path_annotated + else: + video_path_input = os.path.join(folder_paths.get_input_directory(), video) + if os.path.exists(video_path_input): + video_path = video_path_input + else: + raise FileNotFoundError(f"Video file not found: {video}") + + # Open container to read streams and metadata + container = av.open(video_path) + + # Determine video stream and duration + video_stream = container.streams.video[0] if len(container.streams.video) > 0 else None + video_duration = 0 + if video_stream and video_stream.duration and video_stream.time_base: + video_duration = float(video_stream.duration * video_stream.time_base) + + orig_w = video_stream.codec_context.width if video_stream else 512 + orig_h = video_stream.codec_context.height if video_stream else 512 + + target_w = custom_width if custom_width > 0 else orig_w + target_h = custom_height if custom_height > 0 else orig_h + + target_w = target_w - (target_w % 2) + target_h = target_h - (target_h % 2) + + scale_w, scale_h = target_w, target_h + pad_left = pad_right = pad_top = pad_bottom = 0 + crop_left = crop_right = crop_top = crop_bottom = 0 + + if custom_width > 0 or custom_height > 0: + if resize_method == "maintain aspect ratio" or resize_method == "pad": + ratio = min(target_w / orig_w, target_h / orig_h) + scale_w = int(orig_w * ratio) + scale_h = int(orig_h * ratio) + scale_w = scale_w - (scale_w % 2) + scale_h = scale_h - (scale_h % 2) + + if resize_method == "pad": + pad_x = target_w - scale_w + pad_y = target_h - scale_h + pad_left = pad_x // 2 + pad_right = pad_x - pad_left + pad_top = pad_y // 2 + pad_bottom = pad_y - pad_top + else: + target_w, target_h = scale_w, scale_h + + elif resize_method == "crop": + ratio = max(target_w / orig_w, target_h / orig_h) + scale_w = int(orig_w * ratio) + scale_h = int(orig_h * ratio) + scale_w = scale_w - (scale_w % 2) + scale_h = scale_h - (scale_h % 2) + + crop_x = scale_w - target_w + crop_y = scale_h - target_h + crop_left = crop_x // 2 + crop_right = crop_x - crop_left + crop_top = crop_y // 2 + crop_bottom = crop_y - crop_top + + elif resize_method == "stretch to fit": + scale_w, scale_h = target_w, target_h + + # Determine exact bounds based on frontend mode + if display_mode == "frames": + fr = float(frame_rate) if frame_rate > 0 else 24.0 + actual_start_time = float(start_frame) / fr + actual_end_time = float(end_frame) / fr if (end_frame > 0 and end_frame > start_frame) else video_duration + else: + actual_start_time = start_time + actual_end_time = end_time if (end_time > 0 and end_time > start_time) else video_duration + + if actual_end_time <= 0: + actual_end_time = float('inf') # Fallback if duration is unknown + + # 2. Extract Video Frames (PyAV) + frames = [] + image_tensor = None + frames_loaded = 0 + + if video_stream: + video_stream.thread_type = "AUTO" # Enable multithreaded decoding + + # Efficiently seek backwards to the nearest keyframe + if video_stream.time_base: + seek_pts = int(actual_start_time / float(video_stream.time_base)) + else: + seek_pts = int(actual_start_time * av.time_base) + + container.seek(seek_pts, stream=video_stream, backward=True) + + # Custom sampling to force specific framerate + frame_interval = 1.0 / float(frame_rate) if frame_rate > 0 else 1.0/24.0 + expected_target_time = actual_start_time + + # Pre-calculate expected frames + alloc_end_time = actual_end_time if actual_end_time != float('inf') else video_duration + expected_frames = 0 + if alloc_end_time > 0: + duration_to_extract = alloc_end_time - actual_start_time + if duration_to_extract > 0: + expected_frames = int(np.ceil(duration_to_extract / frame_interval)) + 2 + + pbar = comfy.utils.ProgressBar(expected_frames) if expected_frames > 0 else None + + for frame in container.decode(video_stream): + frame_time = frame.time + if frame_time is None: + frame_time = float(frame.pts * float(video_stream.time_base)) if frame.pts and video_stream.time_base else 0.0 + + if frame_time < actual_start_time: + continue + + # Add a slight buffer (interval) to ensure we evaluate the boundary correctly + if frame_time > actual_end_time + frame_interval: + break + + frame_rgb = frame.reformat(width=scale_w, height=scale_h, format='rgb24').to_ndarray() + + if crop_left > 0 or crop_top > 0 or crop_right > 0 or crop_bottom > 0: + frame_rgb = frame_rgb[crop_top:scale_h-crop_bottom, crop_left:scale_w-crop_right, :] + if pad_left > 0 or pad_top > 0 or pad_right > 0 or pad_bottom > 0: + frame_rgb = np.pad(frame_rgb, ((pad_top, pad_bottom), (pad_left, pad_right), (0, 0)), mode='constant', constant_values=0) + + # Duplicate or skip frames perfectly based on timestamps to meet forced framerate. + # FIX: Use strictly less than (<) for actual_end_time to prevent the loop from fetching an extra +1 frame + # at the exact boundary of the duration slice! + while expected_target_time <= frame_time and expected_target_time < actual_end_time - 1e-5: + if image_tensor is None and expected_frames > 0: + # First frame: allocate the tensor + height, width = frame_rgb.shape[:2] + alloc_frames = expected_frames + 50 # Add generous buffer to prevent reallocation + try: + image_tensor = torch.zeros((alloc_frames, height, width, 3), dtype=torch.float32) + except Exception as e: + print(f"[LoadVideoUI] Pre-allocation failed, falling back to list: {e}") + expected_frames = 0 # Disable pre-allocation + + if image_tensor is not None: + # Check bounds (just in case) + if frames_loaded >= image_tensor.shape[0]: + # Extend tensor if we underestimated + extension = torch.zeros((50, image_tensor.shape[1], image_tensor.shape[2], 3), dtype=torch.float32) + image_tensor = torch.cat((image_tensor, extension), dim=0) + + # Insert frame with minimal memory copy directly to tensor + image_tensor[frames_loaded] = torch.from_numpy(frame_rgb).float().div_(255.0) + frames_loaded += 1 + else: + # Fallback list append if pre-allocation failed + frames.append(frame_rgb) + + if pbar: + pbar.update(1) + + expected_target_time += frame_interval + + # Convert frames to ComfyUI Image standard format [N, H, W, C], float32, range 0.0-1.0 + if image_tensor is not None: + if frames_loaded > 0: + image_tensor = image_tensor[:frames_loaded] + else: + image_tensor = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + elif len(frames) > 0: + frames_np = np.array(frames, dtype=np.float32) / 255.0 + image_tensor = torch.from_numpy(frames_np) + else: + # Fallback for an empty slice + image_tensor = torch.zeros((1, 512, 512, 3), dtype=torch.float32) + + # 3. Extract Audio (PyAV) + audio_dict = {"waveform": torch.zeros((1, 1, 44100)), "sample_rate": 44100} # Default empty audio + + if len(container.streams.audio) > 0: + try: + audio_stream = container.streams.audio[0] + audio_stream.thread_type = "AUTO" + sample_rate = getattr(audio_stream, 'rate', 44100) or 44100 + + # We must seek again on the container specifically for the audio stream + if audio_stream.time_base: + seek_pts = int(actual_start_time / float(audio_stream.time_base)) + else: + seek_pts = int(actual_start_time * av.time_base) + + container.seek(seek_pts, stream=audio_stream, backward=True) + + # Resample to standard float planar format (fltp) + resampler = av.AudioResampler(format='fltp') + + audio_data = [] + first_frame_time = None + + for frame in container.decode(audio_stream): + frame_time = frame.time + if frame_time is None: + frame_time = float(frame.pts * float(audio_stream.time_base)) if frame.pts and audio_stream.time_base else 0.0 + + # Give a small 1-second buffer to ensure we catch end frames + if frame_time > actual_end_time + 1.0: + break + + if first_frame_time is None: + first_frame_time = frame_time + + resampled_frames = resampler.resample(frame) + for r_frame in resampled_frames: + audio_data.append(r_frame.to_ndarray()) + + if audio_data: + # Concatenate all frames horizontally along the sample axis + waveform_np = np.concatenate(audio_data, axis=1) + waveform = torch.from_numpy(waveform_np).float() + + if first_frame_time is None: + first_frame_time = 0.0 + + # Calculate exact slice points to trim precisely + offset_sec = max(0.0, actual_start_time - first_frame_time) + start_sample = int(offset_sec * sample_rate) + + duration_sec_audio = actual_end_time - actual_start_time + end_sample = start_sample + int(duration_sec_audio * sample_rate) + + # Trim array bounds properly + if end_sample > start_sample: + waveform = waveform[:, start_sample:end_sample] + else: + waveform = waveform[:, start_sample:] + + # Expand to ComfyUI Audio standard [batch_size, channels, samples] + waveform = waveform.unsqueeze(0) + audio_dict = {"waveform": waveform, "sample_rate": sample_rate} + + except Exception as e: + # Catch gracefully without breaking the pipeline execution + print(f"[LoadVideoUI] Audio track extraction skipped or failed: {e}") + + # Always close container to free up system memory lock + container.close() + + # Output accurate final duration in seconds + final_duration_sec = float(max(0.0, actual_end_time - actual_start_time)) + + # Accurately output the true number of extracted frames + # (Using the shape of the array provides exact 1:1 parity with the timeline's math) + frame_count = image_tensor.shape[0] if (frames_loaded > 0 or len(frames) > 0) else 0 + if frame_count == 0 and final_duration_sec > 0: + # Fallback estimation only if PyAV completely failed to decode a valid chunk + calc_fr = float(frame_rate) if frame_rate > 0 else 24.0 + frame_count = int(np.floor(final_duration_sec * calc_fr)) + + return (image_tensor, audio_dict, final_duration_sec, frame_count) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 49bc272..35a9093 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "WhatDreamsCost-ComfyUI" description = "A variety of custom ComfyUI nodes and workflows for creatives." -version = "1.2.6" +version = "1.2.7" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems)