New Node: Load Video UI
This commit is contained in:
@@ -3,6 +3,7 @@ from .multi_image_loader import MultiImageLoader
|
|||||||
from .ltx_sequencer import LTXSequencer
|
from .ltx_sequencer import LTXSequencer
|
||||||
from .speech_length_calculator import SpeechLengthCalculator
|
from .speech_length_calculator import SpeechLengthCalculator
|
||||||
from .load_audio_ui import LoadAudioUI
|
from .load_audio_ui import LoadAudioUI
|
||||||
|
from .load_video_ui import LoadVideoUI # Imported new node
|
||||||
|
|
||||||
# Register the node classes
|
# Register the node classes
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
@@ -10,7 +11,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
"MultiImageLoader": MultiImageLoader,
|
"MultiImageLoader": MultiImageLoader,
|
||||||
"LTXSequencer": LTXSequencer,
|
"LTXSequencer": LTXSequencer,
|
||||||
"SpeechLengthCalculator": SpeechLengthCalculator,
|
"SpeechLengthCalculator": SpeechLengthCalculator,
|
||||||
"LoadAudioUI": LoadAudioUI
|
"LoadAudioUI": LoadAudioUI,
|
||||||
|
"LoadVideoUI": LoadVideoUI # Registered new node
|
||||||
}
|
}
|
||||||
|
|
||||||
# Provide clean display names for the ComfyUI interface
|
# Provide clean display names for the ComfyUI interface
|
||||||
@@ -19,7 +21,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"MultiImageLoader": "Multi Image Loader",
|
"MultiImageLoader": "Multi Image Loader",
|
||||||
"LTXSequencer": "LTX Sequencer",
|
"LTXSequencer": "LTX Sequencer",
|
||||||
"SpeechLengthCalculator": "Speech Length Calculator",
|
"SpeechLengthCalculator": "Speech Length Calculator",
|
||||||
"LoadAudioUI": "Load Audio UI"
|
"LoadAudioUI": "Load Audio UI",
|
||||||
|
"LoadVideoUI": "Load Video UI" # Registered display name
|
||||||
}
|
}
|
||||||
|
|
||||||
WEB_DIRECTORY = "./js"
|
WEB_DIRECTORY = "./js"
|
||||||
|
|||||||
949
js/load_video_ui.js
Normal file
949
js/load_video_ui.js
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
331
load_video_ui.py
Normal file
331
load_video_ui.py
Normal file
@@ -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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "WhatDreamsCost-ComfyUI"
|
name = "WhatDreamsCost-ComfyUI"
|
||||||
description = "A variety of custom ComfyUI nodes and workflows for creatives."
|
description = "A variety of custom ComfyUI nodes and workflows for creatives."
|
||||||
version = "1.2.6"
|
version = "1.2.7"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
# classifiers = [
|
# classifiers = [
|
||||||
# # For OS-independent nodes (works on all operating systems)
|
# # For OS-independent nodes (works on all operating systems)
|
||||||
|
|||||||
Reference in New Issue
Block a user