diff --git a/js/load_video_ui.js b/js/load_video_ui.js index a8d72dd..51988e7 100644 --- a/js/load_video_ui.js +++ b/js/load_video_ui.js @@ -15,11 +15,12 @@ app.registerExtension({ if (onConfigure) { onConfigure.apply(this, arguments); } - + // Force UI synchronization if (this.syncFramesFromTime) this.syncFramesFromTime(); if (this.toggleWidgetVisibility) this.toggleWidgetVisibility(); - + if (this.syncToggleVisual) this.syncToggleVisual(); + if (this.widgets) { const videoWidget = this.widgets.find(w => w.name === "video"); if (videoWidget && videoWidget.value && this.updatePreview) { @@ -27,17 +28,17 @@ app.registerExtension({ } } }; - + // 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`; @@ -52,7 +53,7 @@ app.registerExtension({ // 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) { @@ -64,7 +65,7 @@ app.registerExtension({ } } } - + const remainingHeight = size[1] - yOffset - 18; this.domWidget.element.style.height = `${Math.max(150, remainingHeight)}px`; } @@ -78,20 +79,25 @@ app.registerExtension({ 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"); + const cropXWidget = this.widgets.find((w) => w.name === "crop_x"); + const cropYWidget = this.widgets.find((w) => w.name === "crop_y"); + const cropWWidget = this.widgets.find((w) => w.name === "crop_w"); + const cropHWidget = this.widgets.find((w) => w.name === "crop_h"); + // ==================================================================== // WIDGET HIDING & SYNC ENGINE // ==================================================================== let isSyncing = false; - + function setWidgetVisibility(w, visible, typeStr) { if (!w) return; w.hidden = !visible; @@ -103,8 +109,8 @@ app.registerExtension({ delete w.computeSize; // Restores standard ComfyUI measurement } } - - node.toggleWidgetVisibility = function() { + + node.toggleWidgetVisibility = function () { const isFrames = displayModeWidget && displayModeWidget.value === "frames"; setWidgetVisibility(startTimeWidget, !isFrames, "FLOAT"); setWidgetVisibility(endTimeWidget, !isFrames, "FLOAT"); @@ -113,18 +119,23 @@ app.registerExtension({ setWidgetVisibility(endFrameWidget, isFrames, "INT"); setWidgetVisibility(durationFramesWidget, isFrames, "INT"); setWidgetVisibility(displayModeWidget, false, "combo"); // Toggle is hidden, driven by UI - + + setWidgetVisibility(cropXWidget, false, "FLOAT"); + setWidgetVisibility(cropYWidget, false, "FLOAT"); + setWidgetVisibility(cropWWidget, false, "FLOAT"); + setWidgetVisibility(cropHWidget, false, "FLOAT"); + // 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() { + + node.syncFramesFromTime = function () { if (isSyncing || !frameRateWidget) return; isSyncing = true; const fr = frameRateWidget.value || 24; @@ -134,7 +145,7 @@ app.registerExtension({ isSyncing = false; }; - node.syncTimeFromFrames = function() { + node.syncTimeFromFrames = function () { if (isSyncing || !frameRateWidget) return; isSyncing = true; const fr = frameRateWidget.value || 24; @@ -148,17 +159,17 @@ app.registerExtension({ function bindWidget(w, isFrame, isFrameRate = false) { if (!w) return; const orig = w.callback; - w.callback = function() { + 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); @@ -166,12 +177,12 @@ app.registerExtension({ 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) { + 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)}`); @@ -184,9 +195,9 @@ app.registerExtension({ if (videoWidget) { const originalCallback = videoWidget.callback; - videoWidget.callback = function() { + videoWidget.callback = function () { if (originalCallback) originalCallback.apply(this, arguments); - if (node.updatePreview) node.updatePreview(this.value); + if (node.updatePreview) node.updatePreview(this.value); }; } @@ -202,7 +213,7 @@ app.registerExtension({ fileInput.accept = "video/*"; fileInput.style.display = "none"; document.body.appendChild(fileInput); - + const btnWidget = this.addWidget("button", "choose file to upload", null, () => { fileInput.click(); }); @@ -216,8 +227,8 @@ app.registerExtension({ if (file.path) { videoWidget.value = file.path; node.updatePreview(file.path); - if(startTimeWidget) startTimeWidget.value = 0; - if(endTimeWidget) endTimeWidget.value = 0; + if (startTimeWidget) startTimeWidget.value = 0; + if (endTimeWidget) endTimeWidget.value = 0; node.syncFramesFromTime(); return; } @@ -235,9 +246,9 @@ app.registerExtension({ 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); @@ -252,13 +263,13 @@ app.registerExtension({ 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; + if (startTimeWidget) startTimeWidget.value = 0; + if (endTimeWidget) endTimeWidget.value = 0; node.syncFramesFromTime(); } } @@ -280,8 +291,8 @@ app.registerExtension({ const data = await resp.json(); videoWidget.value = data.name; node.updatePreview(data.name); - if(startTimeWidget) startTimeWidget.value = 0; - if(endTimeWidget) endTimeWidget.value = 0; + if (startTimeWidget) startTimeWidget.value = 0; + if (endTimeWidget) endTimeWidget.value = 0; node.syncFramesFromTime(); } else { throw new Error(`Upload failed: ${resp.statusText}`); @@ -307,20 +318,20 @@ app.registerExtension({ }); // Attach drag & drop directly onto the LiteGraph node canvas frame - node.onDropFile = function(file) { + 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 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); + node.onRemoved = function () { + if (fileInput && fileInput.parentNode) fileInput.parentNode.removeChild(fileInput); + if (originalOnRemove) originalOnRemove.apply(this, arguments); }; // ==================================================================== @@ -331,11 +342,11 @@ app.registerExtension({ Object.assign(container.style, { display: "flex", flexDirection: "column", - gap: "10px", - width: "100%", - margin: "0", - padding: "10px", - boxSizing: "border-box", + gap: "10px", + width: "100%", + margin: "0", + padding: "10px", + boxSizing: "border-box", background: defaultBg, borderRadius: "6px", color: "white", @@ -348,7 +359,7 @@ app.registerExtension({ const errorMsg = document.createElement("div"); Object.assign(errorMsg.style, { color: "#ff6b6b", - fontSize: "11px", + fontSize: "12px", display: "none", marginBottom: "4px", flexShrink: "0", @@ -364,91 +375,138 @@ app.registerExtension({ alignItems: "center", padding: "0 2px", marginBottom: "-4px", - flexShrink: "0", + flexShrink: "0", boxSizing: "border-box", flexWrap: "wrap", // Prevent squishing/overflow by letting it wrap gracefully - gap: "6px" + gap: "6px", + position: "relative" }); - + // Toggle Container UI const toggleWrapper = document.createElement("div"); Object.assign(toggleWrapper.style, { display: "flex", alignItems: "center", - gap: "4px", + gap: "6px", background: "rgba(0, 0, 0, 0.2)", - padding: "0 6px", + padding: "0 8px", 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); + const toggleTitle = document.createElement("span"); + toggleTitle.textContent = "Display Mode"; + Object.assign(toggleTitle.style, { + fontSize: "12px", + color: "#38bdf8", + fontWeight: "bold", + whiteSpace: "nowrap" + }); + + // Segmented pill control + const segmentedToggle = document.createElement("div"); + Object.assign(segmentedToggle.style, { + display: "flex", + alignItems: "center", + background: "rgba(0, 0, 0, 0.35)", + border: "1px solid rgba(56, 189, 248, 0.3)", + borderRadius: "4px", + overflow: "hidden", + height: "18px", + flexShrink: "0", + cursor: "pointer" + }); + + const createSegBtn = (label) => { + const btn = document.createElement("span"); + btn.textContent = label; + Object.assign(btn.style, { + fontSize: "11px", + fontWeight: "bold", + padding: "0 8px", + lineHeight: "18px", + color: "rgba(255,255,255,0.45)", + background: "transparent", + transition: "background 0.2s, color 0.2s", + userSelect: "none", + whiteSpace: "nowrap" + }); + return btn; + }; + + const segTime = createSegBtn("Time"); + const segDivider = document.createElement("span"); + segDivider.style.cssText = "width:1px;height:12px;background:rgba(56,189,248,0.25);flex-shrink:0;"; + const segFrames = createSegBtn("Frames"); + + segmentedToggle.appendChild(segTime); + segmentedToggle.appendChild(segDivider); + segmentedToggle.appendChild(segFrames); + + const applySegmentState = (frames) => { + if (frames) { + segTime.style.background = "transparent"; + segTime.style.color = "rgba(255,255,255,0.45)"; + segFrames.style.background = "rgba(37,126,235,0.85)"; + segFrames.style.color = "#fff"; + } else { + segTime.style.background = "rgba(56,189,248,0.85)"; + segTime.style.color = "#fff"; + segFrames.style.background = "transparent"; + segFrames.style.color = "rgba(255,255,255,0.45)"; + } + }; + + // Keep a reference so the init block below can call it let isFramesMode = false; - switchBox.onclick = () => { + applySegmentState(false); // Default: Time is active + + const doToggle = () => { 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"; - + applySegmentState(isFramesMode); + 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) + + segmentedToggle.onclick = doToggle; + + // Expose the switch activation so the init requestAnimationFrame below can call it + const switchBox = { onclick: doToggle }; + + // Allow onConfigure (workflow reload) to re-sync the visual highlight + node.syncToggleVisual = function () { + const savedIsFrames = displayModeWidget && displayModeWidget.value === "frames"; + isFramesMode = savedIsFrames; + applySegmentState(savedIsFrames); + }; + toggleWrapper.appendChild(toggleTitle); - toggleWrapper.appendChild(modeText); - toggleWrapper.appendChild(switchBox); - - playerTop.appendChild(toggleWrapper); + toggleWrapper.appendChild(segmentedToggle); + + const leftContainer = document.createElement("div"); + Object.assign(leftContainer.style, { + flex: "1 1 0%", + display: "flex", + justifyContent: "flex-start", + minWidth: "max-content" + }); + leftContainer.appendChild(toggleWrapper); + playerTop.appendChild(leftContainer); const trimLength = document.createElement("span"); Object.assign(trimLength.style, { display: "flex", alignItems: "center", - fontSize: "11px", + fontSize: "12px", color: "#38bdf8", // Always remains blue fontWeight: "bold", background: "rgba(56, 189, 248, 0.1)", // Always remains blue @@ -456,29 +514,298 @@ app.registerExtension({ borderRadius: "4px", whiteSpace: "nowrap", height: "22px", - boxSizing: "border-box" + boxSizing: "border-box", + cursor: "pointer" }); trimLength.textContent = "Trimmed: 0:00"; - playerTop.appendChild(trimLength); - + + const cropBtn = document.createElement("button"); + cropBtn.textContent = "Crop"; + Object.assign(cropBtn.style, { + background: "rgba(255, 255, 255, 0.1)", + color: "white", + border: "none", + borderRadius: "4px", + padding: "0 8px", + height: "22px", + fontSize: "12px", + fontWeight: "bold", + cursor: "pointer" + }); + + let isCropVisible = false; + + const cropUIContainer = document.createElement("div"); + Object.assign(cropUIContainer.style, { + display: "flex", alignItems: "center", gap: "6px", zIndex: "11" + }); + + const cropDims = document.createElement("span"); + Object.assign(cropDims.style, { + fontSize: "12px", color: "#38bdf8", fontWeight: "bold", + display: "none", padding: "0 6px", pointerEvents: "none" + }); + + const cropEditContainer = document.createElement("div"); + Object.assign(cropEditContainer.style, { + display: "none", alignItems: "center", gap: "4px" + }); + + const arSelect = document.createElement("select"); + Object.assign(arSelect.style, { + background: "#222", color: "#fff", border: "1px solid #555", + borderRadius: "3px", fontSize: "12px", padding: "2px", outline: "none", + cursor: "pointer" + }); + const ratios = [ + { name: "Freeform", val: 0 }, + { name: "Original", val: -1 }, + { name: "1:1", val: 1 }, + { name: "4:5", val: 4 / 5 }, + { name: "5:4", val: 5 / 4 }, + { name: "16:9", val: 16 / 9 }, + { name: "9:16", val: 9 / 16 }, + { name: "4:3", val: 4 / 3 }, + { name: "3:4", val: 3 / 4 }, + { name: "3:2", val: 3 / 2 }, + { name: "2:3", val: 2 / 3 }, + { name: "2:1", val: 2 }, + { name: "1:2", val: 1 / 2 } + ]; + ratios.forEach(r => { + const opt = document.createElement("option"); + opt.textContent = r.name; + opt.value = r.val; + arSelect.appendChild(opt); + }); + + const wInput = document.createElement("input"); + const hInput = document.createElement("input"); + const inputStyle = { + width: "40px", background: "rgba(0,0,0,0.5)", color: "#38bdf8", + border: "1px solid #555", borderRadius: "3px", fontSize: "12px", + textAlign: "center", padding: "2px", outline: "none" + }; + Object.assign(wInput.style, inputStyle); + Object.assign(hInput.style, inputStyle); + wInput.type = "text"; + hInput.type = "text"; + + const xSpan = document.createElement("span"); + xSpan.textContent = "x"; + xSpan.style.color = "#888"; + xSpan.style.fontSize = "12px"; + + cropEditContainer.appendChild(arSelect); + cropEditContainer.appendChild(wInput); + cropEditContainer.appendChild(xSpan); + cropEditContainer.appendChild(hInput); + + cropUIContainer.appendChild(cropDims); + cropUIContainer.appendChild(cropEditContainer); + playerTop.appendChild(cropUIContainer); + + const rightContainer = document.createElement("div"); + Object.assign(rightContainer.style, { + flex: "1 1 0%", + display: "flex", + justifyContent: "flex-end", + gap: "6px", + minWidth: "max-content" + }); + rightContainer.appendChild(cropBtn); + rightContainer.appendChild(trimLength); + playerTop.appendChild(rightContainer); + + let currentAspectRatio = 0; + + const handleManualDimensionInput = (isWidth) => { + const vw = videoPreview.videoWidth; + const vh = videoPreview.videoHeight; + if (!vw || !vh) return; + + let newW = parseInt(wInput.value) || Math.round((cropWWidget ? parseFloat(cropWWidget.value) || 1 : 1) * vw); + let newH = parseInt(hInput.value) || Math.round((cropHWidget ? parseFloat(cropHWidget.value) || 1 : 1) * vh); + + if (currentAspectRatio > 0) { + if (isWidth) { + newH = Math.round(newW / currentAspectRatio); + } else { + newW = Math.round(newH * currentAspectRatio); + } + } + + newW = Math.max(1, Math.min(newW, vw)); + newH = Math.max(1, Math.min(newH, vh)); + + let cw_val = newW / vw; + let ch_val = newH / vh; + + let cx = cropXWidget ? parseFloat(cropXWidget.value) || 0 : 0; + let cy = cropYWidget ? parseFloat(cropYWidget.value) || 0 : 0; + + if (cx + cw_val > 1) cx = 1 - cw_val; + if (cy + ch_val > 1) cy = 1 - ch_val; + + if (cropXWidget) cropXWidget.value = parseFloat(cx.toFixed(3)); + if (cropYWidget) cropYWidget.value = parseFloat(cy.toFixed(3)); + if (cropWWidget) cropWWidget.value = parseFloat(cw_val.toFixed(3)); + if (cropHWidget) cropHWidget.value = parseFloat(ch_val.toFixed(3)); + + updateCropUI(); + app.graph.setDirtyCanvas(true, false); + }; + + wInput.addEventListener("change", () => handleManualDimensionInput(true)); + hInput.addEventListener("change", () => handleManualDimensionInput(false)); + wInput.addEventListener("keydown", (e) => { if (e.key === "Enter") handleManualDimensionInput(true); }); + hInput.addEventListener("keydown", (e) => { if (e.key === "Enter") handleManualDimensionInput(false); }); + + arSelect.onchange = () => { + currentAspectRatio = parseFloat(arSelect.value); + if (currentAspectRatio === -1 && videoPreview.videoWidth) { + currentAspectRatio = videoPreview.videoWidth / videoPreview.videoHeight; + } + if (currentAspectRatio > 0 && videoPreview.videoWidth) { + const vw = videoPreview.videoWidth; + const vh = videoPreview.videoHeight; + let cw_val = cropWWidget ? parseFloat(cropWWidget.value) || 1 : 1; + let cx = cropXWidget ? parseFloat(cropXWidget.value) || 0 : 0; + let cy = cropYWidget ? parseFloat(cropYWidget.value) || 0 : 0; + + const actualW = cw_val * vw; + let actualH = actualW / currentAspectRatio; + let ch_val = actualH / vh; + + if (ch_val > 1) { + ch_val = 1; + const newActualW = vh * currentAspectRatio; + cw_val = newActualW / vw; + } + if (cy + ch_val > 1) cy = 1 - ch_val; + if (cx + cw_val > 1) cx = 1 - cw_val; + + if (cropXWidget) cropXWidget.value = parseFloat(cx.toFixed(3)); + if (cropYWidget) cropYWidget.value = parseFloat(cy.toFixed(3)); + if (cropWWidget) cropWWidget.value = parseFloat(cw_val.toFixed(3)); + if (cropHWidget) cropHWidget.value = parseFloat(ch_val.toFixed(3)); + + updateCropUI(); + app.graph.setDirtyCanvas(true, false); + } + }; + + cropBtn.onclick = () => { + isCropVisible = !isCropVisible; + cropBtn.style.background = isCropVisible ? "#38bdf8" : "rgba(255, 255, 255, 0.1)"; + cropBtn.style.color = isCropVisible ? "black" : "white"; + if (isCropVisible) { + cropBox.style.display = "block"; + cropEditContainer.style.display = "flex"; + cropDims.style.display = "none"; + } else { + cropBox.style.display = "none"; + cropEditContainer.style.display = "none"; + // updateCropUI handles cropDims visibility when off + } + if (isCropVisible) { + videoPreview.pause(); + videoPreview.controls = false; + } else { + videoPreview.controls = true; + } + updateCropUI(); + }; + container.appendChild(playerTop); // Video Preview Area (Native Controls) + const videoWrapper = document.createElement("div"); + Object.assign(videoWrapper.style, { + position: "relative", + width: "100%", + flexGrow: "1", + minHeight: "0px", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "#000", + borderRadius: "4px", + overflow: "hidden" + }); + const videoPreview = document.createElement("video"); Object.assign(videoPreview.style, { width: "100%", - background: "#000", - borderRadius: "4px", + height: "100%", 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.controls = true; + videoPreview.controlsList = "nodownload nofullscreen noremoteplayback"; videoPreview.muted = false; // Changed from true to false so the video starts unmuted - container.appendChild(videoPreview); + videoWrapper.appendChild(videoPreview); + + const cropBox = document.createElement("div"); + Object.assign(cropBox.style, { + position: "absolute", + border: "2px dashed #38bdf8", + display: "none", + pointerEvents: "auto", + cursor: "move", + boxSizing: "border-box", + boxShadow: "0 0 0 9999px rgba(0, 0, 0, 0.5)", + zIndex: "10", + overflow: "hidden" + }); + + // 3x3 Grid lines + for (let i = 1; i <= 2; i++) { + const vLine = document.createElement("div"); + Object.assign(vLine.style, { + position: "absolute", left: `${i * 33.33}%`, top: "0", bottom: "0", + borderLeft: "1px dashed rgba(255,255,255,0.3)", pointerEvents: "none" + }); + const hLine = document.createElement("div"); + Object.assign(hLine.style, { + position: "absolute", top: `${i * 33.33}%`, left: "0", right: "0", + borderTop: "1px dashed rgba(255,255,255,0.3)", pointerEvents: "none" + }); + cropBox.appendChild(vLine); + cropBox.appendChild(hLine); + } + + const createCropHandle = (cursor, pos, borders) => { + const h = document.createElement("div"); + Object.assign(h.style, { + position: "absolute", + width: "20px", + height: "20px", + background: "transparent", + cursor: cursor, + pointerEvents: "auto", + ...borders, + ...pos + }); + return h; + }; + + const tlHandle = createCropHandle("nwse-resize", { top: "-3px", left: "-3px" }, { borderTop: "6px solid #38bdf8", borderLeft: "6px solid #38bdf8" }); + const trHandle = createCropHandle("nesw-resize", { top: "-3px", right: "-3px" }, { borderTop: "6px solid #38bdf8", borderRight: "6px solid #38bdf8" }); + const blHandle = createCropHandle("nesw-resize", { bottom: "-3px", left: "-3px" }, { borderBottom: "6px solid #38bdf8", borderLeft: "6px solid #38bdf8" }); + const brHandle = createCropHandle("nwse-resize", { bottom: "-3px", right: "-3px" }, { borderBottom: "6px solid #38bdf8", borderRight: "6px solid #38bdf8" }); + + const tmHandle = createCropHandle("ns-resize", { top: "-3px", left: "50%", transform: "translateX(-50%)" }, { borderTop: "6px solid #38bdf8", width: "16px", height: "10px" }); + const bmHandle = createCropHandle("ns-resize", { bottom: "-3px", left: "50%", transform: "translateX(-50%)" }, { borderBottom: "6px solid #38bdf8", width: "16px", height: "10px" }); + const lmHandle = createCropHandle("ew-resize", { top: "50%", left: "-3px", transform: "translateY(-50%)" }, { borderLeft: "6px solid #38bdf8", width: "10px", height: "16px" }); + const rmHandle = createCropHandle("ew-resize", { top: "50%", right: "-3px", transform: "translateY(-50%)" }, { borderRight: "6px solid #38bdf8", width: "10px", height: "16px" }); + + const handles = [tlHandle, trHandle, blHandle, brHandle, tmHandle, bmHandle, lmHandle, rmHandle]; + handles.forEach(h => cropBox.appendChild(h)); + videoWrapper.appendChild(cropBox); + + container.appendChild(videoWrapper); // Trim Area (Time Ruler & Slider) const trimArea = document.createElement("div"); @@ -491,7 +818,7 @@ app.registerExtension({ borderRadius: "6px", border: "1px solid rgba(255, 255, 255, 0.05)", flexShrink: "0", // Prevent timeline from squishing when shrinking node - boxSizing: "border-box" + boxSizing: "border-box" }); const timeRuler = document.createElement("div"); @@ -499,7 +826,7 @@ app.registerExtension({ position: "relative", width: "100%", height: "22px", - fontSize: "10px", + fontSize: "11px", color: "#aaa", pointerEvents: "none", userSelect: "none", @@ -550,43 +877,43 @@ app.registerExtension({ 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]; + 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; + if (node.size[0] < 690) { + node.size[0] = 690; } - + // 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; + if (node.size[1] < 740) { + node.size[1] = 740; } - + // 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); @@ -600,6 +927,243 @@ app.registerExtension({ let dragSelectionWidth = 0; let isUpdatingDuration = false; + // Crop logic + let cropDragging = null; + let dragStartX = 0; + let dragStartY = 0; + let dragStartCropX = 0; + let dragStartCropY = 0; + let dragStartCropW = 1; + let dragStartCropH = 1; + + const updateCropUI = () => { + const vw = videoPreview.videoWidth; + const vh = videoPreview.videoHeight; + + let cx = cropXWidget ? parseFloat(cropXWidget.value) || 0 : 0; + let cy = cropYWidget ? parseFloat(cropYWidget.value) || 0 : 0; + let cw_val = cropWWidget ? parseFloat(cropWWidget.value) || 1 : 1; + let ch_val = cropHWidget ? parseFloat(cropHWidget.value) || 1 : 1; + + const actualW = vw ? Math.round(cw_val * vw) : 0; + const actualH = vh ? Math.round(ch_val * vh) : 0; + + if (!isCropVisible || !vw) { + cropBox.style.display = "none"; + cropEditContainer.style.display = "none"; + if (cw_val < 0.999 || ch_val < 0.999 || cx > 0.001 || cy > 0.001) { + cropDims.textContent = `Crop: ${actualW}x${actualH}`; + cropDims.style.display = "inline-block"; + } else { + cropDims.style.display = "none"; + } + return; + } + + cropDims.style.display = "none"; + cropEditContainer.style.display = "flex"; + cropBox.style.display = "block"; + + if (document.activeElement !== wInput) wInput.value = actualW; + if (document.activeElement !== hInput) hInput.value = actualH; + + const cw = videoPreview.clientWidth; + const ch = videoPreview.clientHeight; + + const ratio = Math.min(cw / vw, ch / vh); + const renderedW = vw * ratio; + const renderedH = vh * ratio; + const xOffset = (cw - renderedW) / 2; + const yOffset = (ch - renderedH) / 2; + + cropBox.style.left = `${xOffset + cx * renderedW}px`; + cropBox.style.top = `${yOffset + cy * renderedH}px`; + cropBox.style.width = `${cw_val * renderedW}px`; + cropBox.style.height = `${ch_val * renderedH}px`; + }; + + const onCropPointerDown = (e, handle) => { + if (!isCropVisible) return; + e.preventDefault(); + e.stopPropagation(); + cropDragging = handle; + e.target.setPointerCapture(e.pointerId); + + dragStartX = e.clientX; + dragStartY = e.clientY; + dragStartCropX = cropXWidget ? parseFloat(cropXWidget.value) || 0 : 0; + dragStartCropY = cropYWidget ? parseFloat(cropYWidget.value) || 0 : 0; + dragStartCropW = cropWWidget ? parseFloat(cropWWidget.value) || 1 : 1; + dragStartCropH = cropHWidget ? parseFloat(cropHWidget.value) || 1 : 1; + + e.target.addEventListener("pointermove", onCropPointerMove); + e.target.addEventListener("pointerup", onCropPointerUp); + }; + + const onCropPointerMove = (e) => { + if (!cropDragging) return; + e.preventDefault(); + + const vw = videoPreview.videoWidth; + const vh = videoPreview.videoHeight; + const cw = videoPreview.clientWidth; + const ch = videoPreview.clientHeight; + + const ratio = Math.min(cw / vw, ch / vh); + const renderedW = vw * ratio; + const renderedH = vh * ratio; + + const dx = (e.clientX - dragStartX) / renderedW; + const dy = (e.clientY - dragStartY) / renderedH; + + let new_cw = dragStartCropW; + let new_ch = dragStartCropH; + let new_cx = dragStartCropX; + let new_cy = dragStartCropY; + + if (cropDragging === "tl") { + new_cw = dragStartCropW - dx; + new_ch = dragStartCropH - dy; + } else if (cropDragging === "tr") { + new_cw = dragStartCropW + dx; + new_ch = dragStartCropH - dy; + } else if (cropDragging === "bl") { + new_cw = dragStartCropW - dx; + new_ch = dragStartCropH + dy; + } else if (cropDragging === "br") { + new_cw = dragStartCropW + dx; + new_ch = dragStartCropH + dy; + } else if (cropDragging === "tm") { + new_ch = dragStartCropH - dy; + } else if (cropDragging === "bm") { + new_ch = dragStartCropH + dy; + } else if (cropDragging === "lm") { + new_cw = dragStartCropW - dx; + } else if (cropDragging === "rm") { + new_cw = dragStartCropW + dx; + } + + if (currentAspectRatio > 0 && cropDragging !== "center") { + const R = currentAspectRatio * (vh / vw); + if (["tm", "bm"].includes(cropDragging)) { + new_cw = new_ch * R; + new_cx = dragStartCropX + (dragStartCropW - new_cw) / 2; + } else if (["lm", "rm"].includes(cropDragging)) { + new_ch = new_cw / R; + new_cy = dragStartCropY + (dragStartCropH - new_ch) / 2; + } else { + new_ch = new_cw / R; + } + } + + if (cropDragging === "tl") { + new_cx = dragStartCropX + dragStartCropW - new_cw; + new_cy = dragStartCropY + dragStartCropH - new_ch; + } else if (cropDragging === "tr") { + new_cx = dragStartCropX; + new_cy = dragStartCropY + dragStartCropH - new_ch; + } else if (cropDragging === "bl") { + new_cx = dragStartCropX + dragStartCropW - new_cw; + new_cy = dragStartCropY; + } else if (cropDragging === "br") { + new_cx = dragStartCropX; + new_cy = dragStartCropY; + } else if (cropDragging === "tm") { + new_cy = dragStartCropY + dragStartCropH - new_ch; + if (!(currentAspectRatio > 0)) new_cx = dragStartCropX; + } else if (cropDragging === "bm") { + new_cy = dragStartCropY; + if (!(currentAspectRatio > 0)) new_cx = dragStartCropX; + } else if (cropDragging === "lm") { + new_cx = dragStartCropX + dragStartCropW - new_cw; + if (!(currentAspectRatio > 0)) new_cy = dragStartCropY; + } else if (cropDragging === "rm") { + new_cx = dragStartCropX; + if (!(currentAspectRatio > 0)) new_cy = dragStartCropY; + } else if (cropDragging === "center") { + new_cx = dragStartCropX + dx; + new_cy = dragStartCropY + dy; + } + + if (new_cw < 0.02) { + new_cw = 0.02; + if (currentAspectRatio > 0) new_ch = new_cw / (currentAspectRatio * (vh / vw)); + } + if (new_ch < 0.02) { + new_ch = 0.02; + if (currentAspectRatio > 0) new_cw = new_ch * (currentAspectRatio * (vh / vw)); + } + + if (cropDragging === "center") { + new_cx = Math.max(0, Math.min(new_cx, 1 - new_cw)); + new_cy = Math.max(0, Math.min(new_cy, 1 - new_ch)); + } else { + if (new_cx < 0) { + if (["tl", "bl", "lm"].includes(cropDragging)) { new_cw += new_cx; new_cx = 0; } + } + if (new_cy < 0) { + if (["tl", "tr", "tm"].includes(cropDragging)) { new_ch += new_cy; new_cy = 0; } + } + if (new_cx + new_cw > 1) { + if (["tr", "br", "rm"].includes(cropDragging)) new_cw = 1 - new_cx; + } + if (new_cy + new_ch > 1) { + if (["bl", "br", "bm"].includes(cropDragging)) new_ch = 1 - new_cy; + } + + if (currentAspectRatio > 0) { + const R = currentAspectRatio * (vh / vw); + if (new_cw / new_ch > R + 0.001) { + new_cw = new_ch * R; + if (["tl", "bl", "lm"].includes(cropDragging)) new_cx = dragStartCropX + dragStartCropW - new_cw; + } else if (new_cw / new_ch < R - 0.001) { + new_ch = new_cw / R; + if (["tl", "tr", "tm"].includes(cropDragging)) new_cy = dragStartCropY + dragStartCropH - new_ch; + } + } + } + + if (cropXWidget) cropXWidget.value = parseFloat(new_cx.toFixed(3)); + if (cropYWidget) cropYWidget.value = parseFloat(new_cy.toFixed(3)); + if (cropWWidget) cropWWidget.value = parseFloat(new_cw.toFixed(3)); + if (cropHWidget) cropHWidget.value = parseFloat(new_ch.toFixed(3)); + + updateCropUI(); + app.graph.setDirtyCanvas(true, false); + }; + + const onCropPointerUp = (e) => { + cropDragging = null; + e.target.releasePointerCapture(e.pointerId); + e.target.removeEventListener("pointermove", onCropPointerMove); + e.target.removeEventListener("pointerup", onCropPointerUp); + }; + + cropBox.onpointerdown = (e) => { + if (e.target === cropBox) onCropPointerDown(e, "center"); + }; + tlHandle.onpointerdown = (e) => onCropPointerDown(e, "tl"); + trHandle.onpointerdown = (e) => onCropPointerDown(e, "tr"); + blHandle.onpointerdown = (e) => onCropPointerDown(e, "bl"); + brHandle.onpointerdown = (e) => onCropPointerDown(e, "br"); + tmHandle.onpointerdown = (e) => onCropPointerDown(e, "tm"); + bmHandle.onpointerdown = (e) => onCropPointerDown(e, "bm"); + lmHandle.onpointerdown = (e) => onCropPointerDown(e, "lm"); + rmHandle.onpointerdown = (e) => onCropPointerDown(e, "rm"); + + // Add a resize observer to the video wrapper so crop handles stay pinned + const resizeObserver = new ResizeObserver(() => { + if (isCropVisible) updateCropUI(); + }); + resizeObserver.observe(videoWrapper); + + // Ensure we clean up observer + const oldOnRemoved = node.onRemoved; + node.onRemoved = function () { + resizeObserver.disconnect(); + if (oldOnRemoved) oldOnRemoved.apply(this, arguments); + } + // Smart helper to ensure timeline displays correctly even with no video loaded const getActiveDuration = () => { if (duration > 0) return duration; @@ -612,12 +1176,12 @@ app.registerExtension({ // Time Duration Hook if (durationWidget) { const origCallback = durationWidget.callback; - durationWidget.callback = function(v) { + durationWidget.callback = function (v) { if (isUpdatingDuration) { if (origCallback) origCallback.apply(this, arguments); return; } - + isUpdatingDuration = true; const activeDur = getActiveDuration(); let d = parseFloat(v) || 0; @@ -640,7 +1204,7 @@ app.registerExtension({ if (duration === 0) updateRuler(); updateUI(true); app.graph.setDirtyCanvas(true, false); - + if (origCallback) origCallback.apply(this, arguments); isUpdatingDuration = false; }; @@ -649,16 +1213,16 @@ app.registerExtension({ // Frame Duration Hook if (durationFramesWidget) { const origCallback = durationFramesWidget.callback; - durationFramesWidget.callback = function(v) { + 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; @@ -679,7 +1243,7 @@ app.registerExtension({ if (duration === 0) updateRuler(); updateUI(true); app.graph.setDirtyCanvas(true, false); - + if (origCallback) origCallback.apply(this, arguments); isUpdatingDuration = false; }; @@ -692,7 +1256,7 @@ app.registerExtension({ 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 { @@ -705,8 +1269,8 @@ app.registerExtension({ const activeDur = getActiveDuration(); const numMajorTicks = 5; const subTicks = 4; - const totalTicks = (numMajorTicks - 1) * subTicks; - + const totalTicks = (numMajorTicks - 1) * subTicks; + const isFrames = displayModeWidget && displayModeWidget.value === "frames"; const fr = frameRateWidget ? frameRateWidget.value : 24; @@ -727,7 +1291,7 @@ app.registerExtension({ background: isMajor ? "#aaa" : "#555", marginBottom: "2px", borderRadius: "1px" }); tickWrapper.appendChild(line); - + if (isMajor) { const label = document.createElement("div"); if (isFrames) { @@ -743,10 +1307,10 @@ app.registerExtension({ 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; @@ -759,7 +1323,7 @@ app.registerExtension({ startHandle.style.left = `${pStart}%`; endHandle.style.left = `${pEnd}%`; - + fill.style.left = `${pStart}%`; fill.style.width = `${pEnd - pStart}%`; @@ -774,7 +1338,7 @@ app.registerExtension({ 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; @@ -806,12 +1370,13 @@ app.registerExtension({ } updateRuler(); updateUI(); + updateCropUI(); }; // 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; @@ -829,29 +1394,29 @@ app.registerExtension({ 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; + 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; + if (endTimeWidget) endTimeWidget.value = parseFloat(Math.max(val, s).toFixed(2)); + if (duration > 0) videoPreview.currentTime = endTimeWidget.value; } - + node.syncFramesFromTime(); - updateUI(); + updateUI(); app.graph.setDirtyCanvas(true, false); sliderBox.setPointerCapture(e.pointerId); }; @@ -862,20 +1427,20 @@ app.registerExtension({ 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; + 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; + 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; @@ -883,20 +1448,20 @@ app.registerExtension({ 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; + + 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(); + updateUI(); app.graph.setDirtyCanvas(true, false); }; - sliderBox.onpointerup = (e) => { - dragging = null; - sliderBox.releasePointerCapture(e.pointerId); + sliderBox.onpointerup = (e) => { + dragging = null; + sliderBox.releasePointerCapture(e.pointerId); }; // --- Improved Global Drag & Drop for Node Inner Content --- @@ -910,9 +1475,9 @@ app.registerExtension({ container.style.background = "rgba(14, 165, 233, 0.1)"; } }); - + container.addEventListener("dragover", (e) => { - e.preventDefault(); + e.preventDefault(); }); container.addEventListener("dragleave", (e) => { @@ -923,10 +1488,10 @@ app.registerExtension({ container.style.background = defaultBg; } }); - + container.addEventListener("drop", (e) => { e.preventDefault(); - e.stopPropagation(); + e.stopPropagation(); dragCounter = 0; container.style.outline = "none"; container.style.background = defaultBg; diff --git a/load_video_ui.py b/load_video_ui.py index 48fd304..81c030b 100644 --- a/load_video_ui.py +++ b/load_video_ui.py @@ -54,6 +54,10 @@ class LoadVideoUI: "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"}), + "crop_x": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "crop_y": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "crop_w": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}), + "crop_h": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.001}), } } @@ -62,7 +66,7 @@ class LoadVideoUI: 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): + 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", crop_x=0.0, crop_y=0.0, crop_w=1.0, crop_h=1.0, **kwargs): if not video: # Return blank defaults if no video is loaded empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32) @@ -94,21 +98,75 @@ class LoadVideoUI: orig_w = video_stream.codec_context.width if video_stream else 512 orig_h = video_stream.codec_context.height if video_stream else 512 + # Determine correct colorspace and color range for PyAV conversion to prevent color shift + try: + from av.video.reformatter import Colorspace, ColorRange + # Improve fallback heuristic to check both dimensions (e.g. 720x1280 vertical video is HD) + fallback_cs = Colorspace.ITU709 if max(orig_w, orig_h) >= 720 else Colorspace.ITU601 + fallback_cr = ColorRange.MPEG + dst_range = ColorRange.JPEG # RGB should always be full range + except ImportError: + fallback_cs = "itu709" if max(orig_w, orig_h) >= 720 else "itu601" + fallback_cr = "mpeg" + dst_range = "jpeg" + + src_colorspace = fallback_cs + src_color_range = fallback_cr + + if video_stream and video_stream.codec_context: + cc = video_stream.codec_context + + c_space = getattr(cc, 'colorspace', getattr(cc, 'color_space', None)) + if c_space and hasattr(c_space, 'name') and c_space.name != "UNSPECIFIED": + src_colorspace = c_space + elif c_space and isinstance(c_space, str) and "unspecified" not in c_space.lower(): + src_colorspace = c_space + + c_range = getattr(cc, 'color_range', None) + if c_range and hasattr(c_range, 'name') and c_range.name != "UNSPECIFIED": + src_color_range = c_range + elif c_range and isinstance(c_range, str) and "unspecified" not in c_range.lower(): + src_color_range = c_range + 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) + # Calculate manual crop from interactive UI first + manual_crop_left = int(orig_w * crop_x) + manual_crop_top = int(orig_h * crop_y) + manual_crop_right = orig_w - int(orig_w * (crop_x + crop_w)) + manual_crop_bottom = orig_h - int(orig_h * (crop_y + crop_h)) + + # Ensure we don't crop more than the image + manual_crop_left = max(0, min(manual_crop_left, orig_w - 1)) + manual_crop_top = max(0, min(manual_crop_top, orig_h - 1)) + manual_crop_right = max(0, min(manual_crop_right, orig_w - manual_crop_left - 1)) + manual_crop_bottom = max(0, min(manual_crop_bottom, orig_h - manual_crop_top - 1)) + + # After manual crop, the new original dimensions are: + cropped_orig_w = orig_w - manual_crop_left - manual_crop_right + cropped_orig_h = orig_h - manual_crop_top - manual_crop_bottom + + # If no custom width/height is provided, use the cropped original dimensions + if custom_width == 0: + target_w = cropped_orig_w + target_w = target_w - (target_w % 2) + if custom_height == 0: + target_h = cropped_orig_h + 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) + ratio = min(target_w / cropped_orig_w, target_h / cropped_orig_h) + scale_w = int(cropped_orig_w * ratio) + scale_h = int(cropped_orig_h * ratio) scale_w = scale_w - (scale_w % 2) scale_h = scale_h - (scale_h % 2) @@ -123,9 +181,9 @@ class LoadVideoUI: 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) + ratio = max(target_w / cropped_orig_w, target_h / cropped_orig_h) + scale_w = int(cropped_orig_w * ratio) + scale_h = int(cropped_orig_h * ratio) scale_w = scale_w - (scale_w % 2) scale_h = scale_h - (scale_h % 2) @@ -193,7 +251,30 @@ class LoadVideoUI: if frame_time > actual_end_time + frame_interval: break - frame_rgb = frame.reformat(width=scale_w, height=scale_h, format='rgb24').to_ndarray() + # Fix PyAV color shift by forcing proper colorspace and range conversion. + # Omit dst_colorspace so swscale defaults naturally for RGB output + # (passing it can cause the YUV matrix to be applied incorrectly). + try: + frame = frame.reformat( + format="rgb24", + src_colorspace=src_colorspace, + src_color_range=src_color_range, + dst_color_range=dst_range + ) + frame_rgb = frame.to_ndarray(format='rgb24') + except Exception as e: + # Fallback: if explicit color reformat fails, use PyAV's default conversion + print(f"[LoadVideoUI] Color reformat failed, using default: {e}") + frame_rgb = frame.to_ndarray(format='rgb24') + + # Apply interactive crop first + if manual_crop_left > 0 or manual_crop_top > 0 or manual_crop_right > 0 or manual_crop_bottom > 0: + frame_rgb = frame_rgb[manual_crop_top:orig_h-manual_crop_bottom, manual_crop_left:orig_w-manual_crop_right, :] + + # Now resize to the scaled dimensions + if scale_w != cropped_orig_w or scale_h != cropped_orig_h: + import cv2 + frame_rgb = cv2.resize(frame_rgb, (scale_w, scale_h), interpolation=cv2.INTER_AREA) 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, :] diff --git a/pyproject.toml b/pyproject.toml index 35a9093..c1b0b57 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.7" +version = "1.2.8" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems)