Added Crop feature to Load Video UI Node

This commit is contained in:
WhatDreamsCost
2026-05-05 11:28:52 -05:00
parent d3f4084d82
commit 03f31dcac9
3 changed files with 824 additions and 178 deletions

View File

@@ -19,6 +19,7 @@ app.registerExtension({
// Force UI synchronization // Force UI synchronization
if (this.syncFramesFromTime) this.syncFramesFromTime(); if (this.syncFramesFromTime) this.syncFramesFromTime();
if (this.toggleWidgetVisibility) this.toggleWidgetVisibility(); if (this.toggleWidgetVisibility) this.toggleWidgetVisibility();
if (this.syncToggleVisual) this.syncToggleVisual();
if (this.widgets) { if (this.widgets) {
const videoWidget = this.widgets.find(w => w.name === "video"); const videoWidget = this.widgets.find(w => w.name === "video");
@@ -87,6 +88,11 @@ app.registerExtension({
const endFrameWidget = this.widgets.find((w) => w.name === "end_frame"); const endFrameWidget = this.widgets.find((w) => w.name === "end_frame");
const durationFramesWidget = this.widgets.find((w) => w.name === "duration_frames"); 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 // WIDGET HIDING & SYNC ENGINE
// ==================================================================== // ====================================================================
@@ -114,6 +120,11 @@ app.registerExtension({
setWidgetVisibility(durationFramesWidget, isFrames, "INT"); setWidgetVisibility(durationFramesWidget, isFrames, "INT");
setWidgetVisibility(displayModeWidget, false, "combo"); // Toggle is hidden, driven by UI 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 // 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. // the current user-defined width/height unless it's strictly smaller than the minimum.
const minSize = node.computeSize(); const minSize = node.computeSize();
@@ -348,7 +359,7 @@ app.registerExtension({
const errorMsg = document.createElement("div"); const errorMsg = document.createElement("div");
Object.assign(errorMsg.style, { Object.assign(errorMsg.style, {
color: "#ff6b6b", color: "#ff6b6b",
fontSize: "11px", fontSize: "12px",
display: "none", display: "none",
marginBottom: "4px", marginBottom: "4px",
flexShrink: "0", flexShrink: "0",
@@ -367,7 +378,8 @@ app.registerExtension({
flexShrink: "0", flexShrink: "0",
boxSizing: "border-box", boxSizing: "border-box",
flexWrap: "wrap", // Prevent squishing/overflow by letting it wrap gracefully flexWrap: "wrap", // Prevent squishing/overflow by letting it wrap gracefully
gap: "6px" gap: "6px",
position: "relative"
}); });
// Toggle Container UI // Toggle Container UI
@@ -375,56 +387,84 @@ app.registerExtension({
Object.assign(toggleWrapper.style, { Object.assign(toggleWrapper.style, {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "4px", gap: "6px",
background: "rgba(0, 0, 0, 0.2)", background: "rgba(0, 0, 0, 0.2)",
padding: "0 6px", padding: "0 8px",
borderRadius: "4px", borderRadius: "4px",
height: "22px", height: "22px",
boxSizing: "border-box" boxSizing: "border-box"
}); });
const toggleTitle = document.createElement("span"); const toggleTitle = document.createElement("span");
toggleTitle.textContent = "Display:"; toggleTitle.textContent = "Display Mode";
Object.assign(toggleTitle.style, { Object.assign(toggleTitle.style, {
fontSize: "11px", fontSize: "12px",
color: "#38bdf8", // Locked to Blue color: "#38bdf8",
fontWeight: "bold"
});
const modeText = document.createElement("span");
Object.assign(modeText.style, {
fontSize: "11px",
color: "#38bdf8", // Locked to Blue
fontWeight: "bold", fontWeight: "bold",
minWidth: "40px", whiteSpace: "nowrap"
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"); // Segmented pill control
Object.assign(switchThumb.style, { const segmentedToggle = document.createElement("div");
width: "10px", height: "10px", background: "white", borderRadius: "50%", Object.assign(segmentedToggle.style, {
transition: "transform 0.3s" 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"
}); });
switchBox.appendChild(switchThumb);
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; let isFramesMode = false;
switchBox.onclick = () => { applySegmentState(false); // Default: Time is active
isFramesMode = !isFramesMode;
// Switch only the toggle button itself: Custom Blue (#257eeb) for Frames, Light Blue (#38bdf8) for Time const doToggle = () => {
switchBox.style.background = isFramesMode ? "#257eeb" : "#38bdf8"; isFramesMode = !isFramesMode;
switchThumb.style.transform = isFramesMode ? "translateX(12px)" : "translateX(0px)"; // Adjusted translation to fit smaller box applySegmentState(isFramesMode);
modeText.textContent = isFramesMode ? "Frames" : "Time";
if (displayModeWidget) displayModeWidget.value = isFramesMode ? "frames" : "seconds"; if (displayModeWidget) displayModeWidget.value = isFramesMode ? "frames" : "seconds";
@@ -437,18 +477,36 @@ app.registerExtension({
updateUI(true); updateUI(true);
}; };
// Reordered appending to match requested Layout (Display: -> Mode Text -> Toggle Switch) segmentedToggle.onclick = doToggle;
toggleWrapper.appendChild(toggleTitle);
toggleWrapper.appendChild(modeText);
toggleWrapper.appendChild(switchBox);
playerTop.appendChild(toggleWrapper); // 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(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"); const trimLength = document.createElement("span");
Object.assign(trimLength.style, { Object.assign(trimLength.style, {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
fontSize: "11px", fontSize: "12px",
color: "#38bdf8", // Always remains blue color: "#38bdf8", // Always remains blue
fontWeight: "bold", fontWeight: "bold",
background: "rgba(56, 189, 248, 0.1)", // Always remains blue background: "rgba(56, 189, 248, 0.1)", // Always remains blue
@@ -456,29 +514,298 @@ app.registerExtension({
borderRadius: "4px", borderRadius: "4px",
whiteSpace: "nowrap", whiteSpace: "nowrap",
height: "22px", height: "22px",
boxSizing: "border-box" boxSizing: "border-box",
cursor: "pointer"
}); });
trimLength.textContent = "Trimmed: 0:00"; 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); container.appendChild(playerTop);
// Video Preview Area (Native Controls) // 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"); const videoPreview = document.createElement("video");
Object.assign(videoPreview.style, { Object.assign(videoPreview.style, {
width: "100%", width: "100%",
background: "#000", height: "100%",
borderRadius: "4px",
objectFit: "contain", objectFit: "contain",
flexGrow: "1", // Force player to expand and fill available vertical space dynamically calculated by onResize
minHeight: "0px",
outline: "none", outline: "none",
boxSizing: "border-box" boxSizing: "border-box"
}); });
videoPreview.controls = true; videoPreview.controls = true;
videoPreview.controlsList = "nodownload nofullscreen noremoteplayback"; videoPreview.controlsList = "nodownload nofullscreen noremoteplayback";
videoPreview.muted = false; // Changed from true to false so the video starts unmuted 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) // Trim Area (Time Ruler & Slider)
const trimArea = document.createElement("div"); const trimArea = document.createElement("div");
@@ -499,7 +826,7 @@ app.registerExtension({
position: "relative", position: "relative",
width: "100%", width: "100%",
height: "22px", height: "22px",
fontSize: "10px", fontSize: "11px",
color: "#aaa", color: "#aaa",
pointerEvents: "none", pointerEvents: "none",
userSelect: "none", userSelect: "none",
@@ -568,14 +895,14 @@ app.registerExtension({
// Applies the default creation bounds natively, increased default height // Applies the default creation bounds natively, increased default height
// to match the widgets required height out of the box. // to match the widgets required height out of the box.
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (node.size[0] < 550) { if (node.size[0] < 690) {
node.size[0] = 550; node.size[0] = 690;
} }
// INCREASE DEFAULT HEIGHT HERE: // INCREASE DEFAULT HEIGHT HERE:
// Change the 620 below to adjust the starting height of the node // Change the 620 below to adjust the starting height of the node
if (node.size[1] < 680) { if (node.size[1] < 740) {
node.size[1] = 680; node.size[1] = 740;
} }
// Trigger manual resize call so the vertical math applies instantly // Trigger manual resize call so the vertical math applies instantly
@@ -600,6 +927,243 @@ app.registerExtension({
let dragSelectionWidth = 0; let dragSelectionWidth = 0;
let isUpdatingDuration = false; 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 // Smart helper to ensure timeline displays correctly even with no video loaded
const getActiveDuration = () => { const getActiveDuration = () => {
if (duration > 0) return duration; if (duration > 0) return duration;
@@ -806,6 +1370,7 @@ app.registerExtension({
} }
updateRuler(); updateRuler();
updateUI(); updateUI();
updateCropUI();
}; };
// Loop Trim during Native Playback // Loop Trim during Native Playback

View File

@@ -54,6 +54,10 @@ class LoadVideoUI:
"custom_height": ("INT", {"default": 0, "min": 0, "max": 100000, "step": 8, "tooltip": "Custom height. 0 means original height."}), "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."}), "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"}), "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" FUNCTION = "load_video"
CATEGORY = "Custom/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: if not video:
# Return blank defaults if no video is loaded # Return blank defaults if no video is loaded
empty_image = torch.zeros((1, 512, 512, 3), dtype=torch.float32) 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_w = video_stream.codec_context.width if video_stream else 512
orig_h = video_stream.codec_context.height 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_w = custom_width if custom_width > 0 else orig_w
target_h = custom_height if custom_height > 0 else orig_h target_h = custom_height if custom_height > 0 else orig_h
target_w = target_w - (target_w % 2) target_w = target_w - (target_w % 2)
target_h = target_h - (target_h % 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 scale_w, scale_h = target_w, target_h
pad_left = pad_right = pad_top = pad_bottom = 0 pad_left = pad_right = pad_top = pad_bottom = 0
crop_left = crop_right = crop_top = crop_bottom = 0 crop_left = crop_right = crop_top = crop_bottom = 0
if custom_width > 0 or custom_height > 0: if custom_width > 0 or custom_height > 0:
if resize_method == "maintain aspect ratio" or resize_method == "pad": if resize_method == "maintain aspect ratio" or resize_method == "pad":
ratio = min(target_w / orig_w, target_h / orig_h) ratio = min(target_w / cropped_orig_w, target_h / cropped_orig_h)
scale_w = int(orig_w * ratio) scale_w = int(cropped_orig_w * ratio)
scale_h = int(orig_h * ratio) scale_h = int(cropped_orig_h * ratio)
scale_w = scale_w - (scale_w % 2) scale_w = scale_w - (scale_w % 2)
scale_h = scale_h - (scale_h % 2) scale_h = scale_h - (scale_h % 2)
@@ -123,9 +181,9 @@ class LoadVideoUI:
target_w, target_h = scale_w, scale_h target_w, target_h = scale_w, scale_h
elif resize_method == "crop": elif resize_method == "crop":
ratio = max(target_w / orig_w, target_h / orig_h) ratio = max(target_w / cropped_orig_w, target_h / cropped_orig_h)
scale_w = int(orig_w * ratio) scale_w = int(cropped_orig_w * ratio)
scale_h = int(orig_h * ratio) scale_h = int(cropped_orig_h * ratio)
scale_w = scale_w - (scale_w % 2) scale_w = scale_w - (scale_w % 2)
scale_h = scale_h - (scale_h % 2) scale_h = scale_h - (scale_h % 2)
@@ -193,7 +251,30 @@ class LoadVideoUI:
if frame_time > actual_end_time + frame_interval: if frame_time > actual_end_time + frame_interval:
break 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: 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, :] frame_rgb = frame_rgb[crop_top:scale_h-crop_bottom, crop_left:scale_w-crop_right, :]

View File

@@ -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.7" version = "1.2.8"
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)