- Multi LoRA Loader: robust auto-height and width lock, refit on workflow load, min-height contract for Vue nodes - VideoFrameExtractor: adapt DOM structure on hot render-mode switch (classic <-> Vue, no reload); fix drag-resize stickiness; layout and badge fixes - PromptSegments / ResolutionSwitcher: stability fixes - README: document Video Frame Extractor node (EN/ZH)
271 lines
13 KiB
JavaScript
271 lines
13 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
||
|
||
// 语言配置:将下面一行的 "en" 改成 "zh" 即可切换中文
|
||
const LANG = "en";
|
||
|
||
const I18N = {
|
||
en: { w: "W", h: "H", batch: "Batch", add: "+ Add Resolution",
|
||
w_ph: "W", h_ph: "H", batch_ph: "1" },
|
||
zh: { w: "宽", h: "高", batch: "批次", add: "+ 添加分辨率",
|
||
w_ph: "宽", h_ph: "高", batch_ph: "1" },
|
||
};
|
||
const T = I18N[LANG] ?? I18N.en;
|
||
|
||
// Hide the spinner arrows on number inputs so the digits sit truly centered.
|
||
// Injected once globally (pseudo-elements can't be set via inline style).
|
||
(function injectNumStyle() {
|
||
const style = document.createElement("style");
|
||
style.textContent = `
|
||
.res-switch-num::-webkit-inner-spin-button,
|
||
.res-switch-num::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
|
||
.res-switch-num { -moz-appearance: textfield; appearance: textfield; }
|
||
`;
|
||
(document.head || document.documentElement).appendChild(style);
|
||
})();
|
||
|
||
const ROW_H = 28;
|
||
const PAD = 10;
|
||
const HEADER_H = 22;
|
||
const TOGGLE_W = 40; // 胶囊开关列
|
||
const NUM_W = 60; // W/H 输入框
|
||
const BATCH_W = 40; // batch 输入框
|
||
const DEL_W = 36; // 删除按钮列
|
||
const GAP = 5;
|
||
// 最小宽度 = 内容总宽 + 行两侧 PAD + 容器两侧 padding(4px each)
|
||
const NODE_MIN_W = PAD * 2 + TOGGLE_W + NUM_W * 2 + BATCH_W + DEL_W + 4 * GAP + 8;
|
||
|
||
function colWidths() {
|
||
// All columns fixed width. Extra space (if node is dragged wider) stays
|
||
// inside the row padding — nothing stretches or collapses.
|
||
return [TOGGLE_W, NUM_W, NUM_W, BATCH_W, DEL_W];
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "ResolutionSwitcher",
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData.name !== "ResolutionSwitcher") return;
|
||
|
||
const ROW_H_LOCAL = ROW_H;
|
||
|
||
nodeType.prototype._calcHeight = function () {
|
||
return HEADER_H + this._presets.length * (ROW_H_LOCAL + GAP) + GAP + 36;
|
||
};
|
||
|
||
nodeType.prototype._applySize = function () {
|
||
this._domWidget.element.style.height = "auto";
|
||
this._domWidget.computeSize = () => [0, this._domContentH || -4];
|
||
requestAnimationFrame(() => {
|
||
const domH = this._presetContainer.scrollHeight;
|
||
if (!domH || domH <= 0) {
|
||
if (!this._minH) {
|
||
const h = this._calcHeight();
|
||
this._minH = h;
|
||
this.setSize([this.size[0], h]);
|
||
app.graph.setDirtyCanvas(true, true);
|
||
}
|
||
return;
|
||
}
|
||
this._domContentH = domH;
|
||
const h = domH + 50;
|
||
this._minH = h;
|
||
this.setSize([this.size[0], h]);
|
||
app.graph.setDirtyCanvas(true, true);
|
||
});
|
||
};
|
||
|
||
nodeType.prototype._render = function () {
|
||
const container = this._presetContainer;
|
||
container.innerHTML = "";
|
||
|
||
const cw = colWidths();
|
||
|
||
// Helper: every column (header + rows) shares the same width and the
|
||
// same horizontal center, so cells line up exactly. Each cell uses
|
||
// flex centering so labels/inputs stay centered regardless of content.
|
||
const cellBase = (w, extra = "") =>
|
||
`width:${w}px;flex-shrink:0;display:flex;align-items:center;justify-content:center;box-sizing:border-box;${extra}`;
|
||
|
||
// Header row — centered labels matching the inputs below.
|
||
const header = document.createElement("div");
|
||
header.style.cssText = `display:flex;align-items:center;justify-content:center;gap:${GAP}px;padding:0 ${PAD}px;height:${HEADER_H}px;font-size:11px;color:#888;font-weight:bold;box-sizing:border-box;`;
|
||
const headCells = ["", T.w, T.h, T.batch, ""];
|
||
headCells.forEach((txt, i) => {
|
||
const c = document.createElement("div");
|
||
c.textContent = txt;
|
||
c.style.cssText = cellBase(cw[i]);
|
||
header.appendChild(c);
|
||
});
|
||
container.appendChild(header);
|
||
|
||
this._presets.forEach((p, i) => {
|
||
const row = document.createElement("div");
|
||
const active = !!p.active;
|
||
// Row background follows the Multi LoRA Loader palette:
|
||
// active = green tint, inactive = neutral dark.
|
||
row.style.cssText = `display:flex;align-items:center;justify-content:center;gap:${GAP}px;padding:0 ${PAD}px;height:${ROW_H_LOCAL}px;box-sizing:border-box;border-radius:6px;background:${active ? "#1a2a1a" : "#222226"};border:1px solid ${active ? "#2d5a2d" : "#2a2a2e"};`;
|
||
|
||
// Toggle capsule (radio-style: only one active at a time).
|
||
// Green when active, matching the LoRA loader's enabled capsule.
|
||
const toggle = document.createElement("div");
|
||
toggle.style.cssText = cellBase(cw[0], "cursor:pointer;");
|
||
const capsule = document.createElement("div");
|
||
capsule.style.cssText = `width:30px;height:16px;border-radius:8px;background:${active ? "#43a047" : "#555"};position:relative;transition:background 0.15s;border:1px solid ${active ? "#2e7d32" : "#424242"};`;
|
||
capsule.innerHTML = `<div style="position:absolute;top:1px;left:${active ? "15px" : "2px"};width:12px;height:12px;border-radius:50%;background:#fff;transition:left 0.15s;"></div>`;
|
||
toggle.onclick = () => {
|
||
// Radio behavior: clicking the active one is a no-op (must
|
||
// always keep exactly one active — this node switches
|
||
// resolutions, it can't disable resolution). Clicking any
|
||
// other moves the active flag to it.
|
||
if (active) return;
|
||
this._presets.forEach((q, j) => { q.active = (j === i); });
|
||
this._sync();
|
||
this._render();
|
||
};
|
||
toggle.appendChild(capsule);
|
||
row.appendChild(toggle);
|
||
|
||
// Number inputs (W / H / batch). Width keyed by column, NOT by
|
||
// the outer row index — that was the misalignment bug.
|
||
const mkNum = (key, ph, minv, colIdx) => {
|
||
const inp = document.createElement("input");
|
||
inp.type = "number";
|
||
inp.className = "res-switch-num";
|
||
inp.value = p[key];
|
||
inp.placeholder = ph;
|
||
inp.min = minv;
|
||
inp.step = key === "batch" ? 1 : 8;
|
||
inp.style.cssText = `width:${cw[colIdx]}px;height:22px;background:#252525;color:#ddd;border:1px solid #3a3a3a;border-radius:4px;padding:0;font-size:12px;outline:none;box-sizing:border-box;text-align:center;`;
|
||
inp.onfocus = () => { inp.style.borderColor = "#4a9eff"; };
|
||
inp.onblur = () => { inp.style.borderColor = "#3a3a3a"; };
|
||
inp.onchange = () => {
|
||
let v = parseInt(inp.value, 10);
|
||
if (!Number.isFinite(v) || v < minv) v = minv;
|
||
// Snap W/H to multiples of 8
|
||
if (key !== "batch") v = Math.round(v / 8) * 8;
|
||
inp.value = v;
|
||
p[key] = v;
|
||
this._sync();
|
||
};
|
||
return inp;
|
||
};
|
||
row.appendChild(mkNum("width", T.w_ph, 16, 1));
|
||
row.appendChild(mkNum("height", T.h_ph, 16, 2));
|
||
row.appendChild(mkNum("batch", T.batch_ph, 1, 3));
|
||
|
||
// Delete button — red box like the Multi LoRA Loader.
|
||
const del = document.createElement("button");
|
||
del.innerHTML = "✕";
|
||
del.style.cssText = `width:22px;height:22px;background:#6a0000;color:#ff6b6b;border:1px solid #c0392b;border-radius:5px;cursor:pointer;font-size:11px;flex-shrink:0;line-height:1;font-weight:bold;transition:filter 0.15s;`;
|
||
del.onmouseenter = () => { del.style.filter = "brightness(1.3)"; };
|
||
del.onmouseleave = () => { del.style.filter = "none"; };
|
||
del.onclick = () => {
|
||
this._presets.splice(i, 1);
|
||
this._sync();
|
||
this._render();
|
||
};
|
||
const delWrap = document.createElement("div");
|
||
delWrap.style.cssText = cellBase(cw[4]);
|
||
delWrap.appendChild(del);
|
||
row.appendChild(delWrap);
|
||
|
||
container.appendChild(row);
|
||
});
|
||
|
||
// Add button
|
||
const addBtn = document.createElement("button");
|
||
addBtn.innerHTML = T.add;
|
||
addBtn.style.cssText = `width:calc(100% - ${PAD * 2}px);margin:${GAP}px ${PAD}px;padding:6px;background:transparent;color:#4a9eff;border:1px dashed #3a5a7a;border-radius:6px;cursor:pointer;font-size:12px;transition:background 0.15s,border-color 0.15s;`;
|
||
addBtn.onmouseenter = () => { addBtn.style.background = "#1a2a3a"; addBtn.style.borderColor = "#4a9eff"; };
|
||
addBtn.onmouseleave = () => { addBtn.style.background = "transparent"; addBtn.style.borderColor = "#3a5a7a"; };
|
||
addBtn.onclick = () => {
|
||
this._presets.push({ active: false, width: 1024, height: 1024, batch: 1 });
|
||
this._sync();
|
||
this._render();
|
||
};
|
||
container.appendChild(addBtn);
|
||
|
||
this._applySize();
|
||
};
|
||
|
||
nodeType.prototype._sync = function () {
|
||
if (this._rawWidget) this._rawWidget.value = JSON.stringify(this._presets);
|
||
};
|
||
|
||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||
nodeType.prototype.onNodeCreated = function () {
|
||
onNodeCreated?.apply(this, arguments);
|
||
|
||
this._domContentH = 0;
|
||
|
||
const raw = this.widgets?.find(w => w.name === "presets");
|
||
if (raw) { raw.hidden = true; raw.computeSize = () => [0, -4]; }
|
||
this._rawWidget = raw;
|
||
|
||
// Default presets: 3 common resolutions, first one active.
|
||
this._presets = [
|
||
{ active: true, width: 1024, height: 1024, batch: 1 },
|
||
{ active: false, width: 896, height: 1152, batch: 1 },
|
||
{ active: false, width: 1152, height: 896, batch: 1 },
|
||
];
|
||
|
||
const container = document.createElement("div");
|
||
// flex:0 0 auto —— 防止 Nodes 2.0 下被 WidgetDOM 容器(*:flex-1)拉伸,
|
||
// 导致 scrollHeight 测量失真、删除预设后节点高度不收缩(经典模式此属性无副作用)。
|
||
container.style.cssText = `display:flex;flex-direction:column;gap:${GAP}px;padding:4px;box-sizing:border-box;width:100%;flex:0 0 auto;`;
|
||
this._presetContainer = container;
|
||
|
||
this._domWidget = this.addDOMWidget("_ui", "div", container, { serialize: false });
|
||
|
||
this.onResize = function (size) {
|
||
if (size[0] < NODE_MIN_W) size[0] = NODE_MIN_W;
|
||
if (this._minH && size[1] < this._minH) size[1] = this._minH;
|
||
};
|
||
|
||
// Re-measure when becoming visible (same pattern as other nodes).
|
||
this._wasVisible = false;
|
||
if ("IntersectionObserver" in window) {
|
||
this._visObserver = new IntersectionObserver((entries) => {
|
||
for (const e of entries) {
|
||
if (e.isIntersecting && !this._wasVisible) {
|
||
requestAnimationFrame(() => this._applySize());
|
||
}
|
||
this._wasVisible = e.isIntersecting;
|
||
}
|
||
}, { threshold: 0 });
|
||
requestAnimationFrame(() => {
|
||
if (this._visObserver && this._domWidget?.element) {
|
||
this._visObserver.observe(this._domWidget.element);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (this.size[0] < NODE_MIN_W) this.size = [NODE_MIN_W, this.size[1]];
|
||
|
||
requestAnimationFrame(() => this._render());
|
||
};
|
||
|
||
const onSerialize = nodeType.prototype.onSerialize;
|
||
nodeType.prototype.onSerialize = function (o) {
|
||
onSerialize?.apply(this, arguments);
|
||
// Deep copy so a pasted/duplicated node doesn't share the same array
|
||
// reference with the original.
|
||
o.resolution_presets = JSON.parse(JSON.stringify(this._presets));
|
||
};
|
||
|
||
const onConfigure = nodeType.prototype.onConfigure;
|
||
nodeType.prototype.onConfigure = function (o) {
|
||
onConfigure?.apply(this, arguments);
|
||
if (o.resolution_presets) {
|
||
this._presets = o.resolution_presets;
|
||
requestAnimationFrame(() => this._render());
|
||
}
|
||
};
|
||
|
||
const onRemoved = nodeType.prototype.onRemoved;
|
||
nodeType.prototype.onRemoved = function () {
|
||
onRemoved?.apply(this, arguments);
|
||
if (this._visObserver) { this._visObserver.disconnect(); this._visObserver = null; }
|
||
};
|
||
}
|
||
});
|