import { app } from "../../scripts/app.js"; // Compacts DolphinVisionDirector's 16 lora slots (each: combo + on/off + weight // string = 3 separate stock widgets) into single power-lora-loader-style rows: // [toggle] [lora name] [weight]. The 3 original widgets are kept exactly where // they are in node.widgets (never reordered/removed) so saved-workflow // widgets_values arrays keep working; they are only visually hidden (0 height). // The compact rows are appended at the END of node.widgets via addCustomWidget, // which never shifts the index of any pre-existing widget. const VISION_NODE_NAME = "DolphinVisionDirector"; const ARCH_NODE_NAME = "DolphinArchitectV16"; const NUM_CLIPS = 4; const NUM_LORAS = 4; const NUM_ARCH_LORAS = 6; const ROW_HEIGHT = 24; const HEADER_HEIGHT = 18; function fitText(ctx, str, maxWidth) { if (!str) return ""; const full = ctx.measureText(str).width; if (full <= maxWidth) return str; const ellipsis = "…"; let lo = 0, hi = str.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (ctx.measureText(str.slice(0, mid) + ellipsis).width <= maxWidth) lo = mid; else hi = mid - 1; } return str.slice(0, lo) + ellipsis; } function hideWidget(widget, suffix = "") { if (!widget.__dolphinHidden) { widget.__dolphinHidden = true; widget.origType = widget.type; widget.origComputeSize = widget.computeSize; widget.origSerializeValue = widget.serializeValue; // Zero, not negative: with 48 widgets hidden at once, the usual "-4" // margin-collapse trick (fine for a single hidden widget) compounds to a // large negative offset that throws off LiteGraph's remaining-space // distribution to flexible multiline widgets (essential_actions, // artistic_vibe), ballooning their height. widget.computeSize = () => [0, 0]; widget.type = "converted-widget" + suffix; } // This ComfyUI build renders stock combo/string/boolean widgets as real DOM // elements (widget.element) absolutely positioned over the canvas, not pure // canvas draws — computeSize/type alone only affects canvas layout and does // NOT hide the floating DOM element. Same fix the bundled tripleksampler // custom node's own UI extension uses for its dynamic widgets. widget.hidden = true; if (widget.options) widget.options.hidden = true; if (widget.element) { widget.element.style.display = "none"; const label = widget.element.previousElementSibling; if (label && label.textContent === widget.name + ":") { label.style.display = "none"; } } } // SERIALIZE_FALSE: every custom widget in this file is presentation-only — the real // values live on the stock widgets it wraps. LGraphNode.serialize() in this build does // NOT call serializeValue(); it reads `widget.value` directly and writes `value ?? null`, // skipping a widget only when `widget.serialize === false` (note: the plain property, // not `options.serialize`). Without this flag every custom widget appended a trailing // null to widgets_values — the Architect node was saving 26 entries where 19 are real. // The nulls were harmless (custom widgets are appended AFTER the stock ones, so no index // shifted) but they grow every saved workflow, and old files with the extra trailing // values still load correctly since only the tail is affected. class DolphinSectionHeaderWidget { constructor(label) { this.type = "custom"; this.name = "dolphin_header_" + label; this.__dolphinHidden = true; // never touched by hideWidget, just marks "not a real value widget" this.serialize = false; this.label = label; } computeSize(width) { return [width, HEADER_HEIGHT]; } draw(ctx, node, widgetWidth, posY, height) { ctx.save(); ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.globalAlpha = 0.75; ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.font = "bold 11px Arial"; ctx.fillText(this.label, 15, posY + height * 0.5); ctx.restore(); } mouse() { return false; } serializeValue() { return undefined; } } class DolphinLoraRowWidget { constructor(name, comboW, onW, weightW) { this.type = "custom"; this.name = name; this.serialize = false; // SERIALIZE_FALSE 참고 this.comboW = comboW; this.onW = onW; this.weightW = weightW; this.hitAreas = { toggle: [0, 0], lora: [0, 0], weight: [0, 0], }; } computeSize(width) { return [width, ROW_HEIGHT]; } serializeValue() { return undefined; // real values live on comboW/onW/weightW, not on this row } draw(ctx, node, widgetWidth, posY, height) { const margin = 15; const inner = 6; const on = this.onW.value !== false; ctx.save(); // row background ctx.beginPath(); ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; ctx.roundRect(margin, posY + 1, widgetWidth - margin * 2, height - 2, [height * 0.25]); ctx.fill(); ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; ctx.stroke(); const midY = posY + height * 0.5; let x = margin + inner; // toggle switch const toggleH = height - 10; const toggleW = toggleH * 1.7; ctx.beginPath(); ctx.fillStyle = "#00000055"; ctx.roundRect(x, posY + 5, toggleW, toggleH, [toggleH * 0.5]); ctx.fill(); ctx.beginPath(); ctx.fillStyle = on ? "#8ab4ff" : "#777"; const knobX = on ? x + toggleW - toggleH * 0.5 : x + toggleH * 0.5; ctx.arc(knobX, posY + height * 0.5, toggleH * 0.36, 0, Math.PI * 2); ctx.fill(); this.hitAreas.toggle = [x, toggleW]; x += toggleW + inner * 1.5; ctx.globalAlpha = on ? 1 : 0.45; // weight field (measure first, right-aligned) ctx.font = "11px Arial"; const weightText = (this.weightW.value ?? "").toString(); const weightBoxW = Math.min(100, Math.max(46, ctx.measureText(weightText).width + 14)); const weightX = widgetWidth - margin - inner - weightBoxW; // lora name (fills remaining space) const nameX = x; const nameW = Math.max(20, weightX - inner - nameX); const hasLora = this.comboW.value && this.comboW.value !== "None"; ctx.fillStyle = hasLora ? LiteGraph.WIDGET_TEXT_COLOR : LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillText(fitText(ctx, hasLora ? this.comboW.value : "— pick lora —", nameW), nameX, midY); this.hitAreas.lora = [nameX, nameW]; // weight box ctx.beginPath(); ctx.fillStyle = "#00000055"; ctx.roundRect(weightX, posY + 4, weightBoxW, height - 8, [4]); ctx.fill(); ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.textAlign = "center"; ctx.fillText(fitText(ctx, weightText, weightBoxW - 10), weightX + weightBoxW / 2, midY); this.hitAreas.weight = [weightX, weightBoxW]; ctx.restore(); } _within(pos, bounds) { return pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[1]; } mouse(event, pos, node) { if (event.type !== "pointerdown") return false; if (this._within(pos, this.hitAreas.toggle)) { this.onW.value = this.onW.value === false ? true : false; node.setDirtyCanvas(true, true); return true; } if (this._within(pos, this.hitAreas.lora)) { let values = this.comboW.options?.values ?? []; if (typeof values === "function") values = values(this.comboW, node) ?? []; const canvas = app.canvas; // Plain string array + single top-level callback is what makes // LiteGraph.ContextMenu's built-in search/filter box appear (same // pattern rgthree's showLoraChooser and KJNodes use) — passing // {content, callback} objects per item silently skips that filter. new LiteGraph.ContextMenu([...values], { event, scale: Math.max(1, canvas.ds?.scale ?? 1), className: "dark", title: "Choose a lora", callback: (value) => { if (typeof value !== "string") return; this.comboW.value = value; if (typeof this.comboW.callback === "function") { this.comboW.callback(value, canvas, node, pos, event); } node.setDirtyCanvas(true, true); }, }); return true; } if (this._within(pos, this.hitAreas.weight)) { app.canvas.prompt( "Weight (base, high, low)", this.weightW.value ?? "", (v) => { if (v !== null) { this.weightW.value = v; node.setDirtyCanvas(true, true); } }, event, ); return true; } return false; } } class DolphinTextFieldWidget { constructor(label, targetW, multiline = false) { this.type = "custom"; this.name = "dolphin_field_" + targetW.name; this.serialize = false; // SERIALIZE_FALSE 참고 this.label = label; this.targetW = targetW; this.multiline = multiline; this.hitAreas = { field: [0, 0] }; } computeSize(width) { return [width, ROW_HEIGHT]; } serializeValue() { return undefined; // real value lives on targetW } draw(ctx, node, widgetWidth, posY, height) { const margin = 15; const inner = 8; ctx.save(); ctx.beginPath(); ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; ctx.roundRect(margin, posY + 1, widgetWidth - margin * 2, height - 2, [height * 0.25]); ctx.fill(); ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; ctx.stroke(); const midY = posY + height * 0.5; ctx.textAlign = "left"; ctx.textBaseline = "middle"; ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.fillText(this.label, margin + inner, midY); const labelW = ctx.measureText(this.label).width; const valueX = margin + inner + labelW + inner; const valueW = Math.max(20, widgetWidth - margin - inner - valueX); const text = (this.targetW.value ?? "").toString().replace(/\n/g, " ⏎ "); ctx.fillStyle = text ? LiteGraph.WIDGET_TEXT_COLOR : LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.fillText(fitText(ctx, text || "(empty, click to edit)", valueW), valueX, midY); this.hitAreas.field = [margin, widgetWidth - margin * 2]; ctx.restore(); } _within(pos, bounds) { return pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[1]; } mouse(event, pos, node) { if (event.type !== "pointerdown") return false; if (this._within(pos, this.hitAreas.field)) { app.canvas.prompt( this.label, this.targetW.value ?? "", (v) => { if (v !== null) { this.targetW.value = v; node.setDirtyCanvas(true, true); } }, event, this.multiline, ); return true; } return false; } } // 숨겨진 스톡 위젯은 .value만 바꿔도 직렬화(widgets_values)에는 제대로 반영되지만, // 이 빌드는 combo/string/boolean을 실제 DOM 엘리먼트로도 들고 있어서 그쪽 값이 남아 // 있으면 나중에 ComfyUI의 per-frame DOM 싱크가 옛 값을 되돌려 쓸 수 있다. 그래서 // .value를 바꿀 때 엘리먼트도 같이 맞춰준다. function syncWidgetElement(widget) { const el = widget?.element; if (!el) return; try { if (el.type === "checkbox") el.checked = widget.value !== false; else if ("value" in el) el.value = widget.value ?? ""; } catch (e) { console.error("[Dolphin.LoraRows] element sync failed:", e); } } // 6개 슬롯을 Python 쪽 기본값(lora="None", on=True, weights="0.0, 0.0, 0.0")으로 // 되돌리는 버튼. 되돌릴 수 없는 조작이라 ContextMenu로 한 번 확인을 받는다 - // 파일 상단의 lora chooser와 같은 방식이라 별도 UI 의존성이 없다. class DolphinResetButtonWidget { constructor(label, slots, extraW) { this.type = "custom"; this.name = "dolphin_reset_button"; this.serialize = false; // SERIALIZE_FALSE 참고 this.label = label; this.slots = slots; // [{comboW, onW, weightW}, ...] this.extraW = extraW; // manual_triggers (없을 수도 있음) this.hitAreas = { button: [0, 0] }; } computeSize(width) { return [width, ROW_HEIGHT]; } serializeValue() { return undefined; // 값이 없는 순수 액션 위젯 } draw(ctx, node, widgetWidth, posY, height) { const margin = 15; ctx.save(); ctx.beginPath(); ctx.fillStyle = "#00000055"; ctx.roundRect(margin, posY + 1, widgetWidth - margin * 2, height - 2, [height * 0.25]); ctx.fill(); ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; ctx.stroke(); ctx.font = "11px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = LiteGraph.WIDGET_SECONDARY_TEXT_COLOR; ctx.fillText(this.label, widgetWidth * 0.5, posY + height * 0.5); this.hitAreas.button = [margin, widgetWidth - margin * 2]; ctx.restore(); } _within(pos, bounds) { return pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[1]; } _reset(node) { for (const { comboW, onW, weightW } of this.slots) { comboW.value = "None"; onW.value = true; weightW.value = "0.0, 0.0, 0.0"; syncWidgetElement(comboW); syncWidgetElement(onW); syncWidgetElement(weightW); } if (this.extraW) { this.extraW.value = ""; syncWidgetElement(this.extraW); } node.setDirtyCanvas(true, true); } mouse(event, pos, node) { if (event.type !== "pointerdown") return false; if (!this._within(pos, this.hitAreas.button)) return false; const CONFIRM = `Reset all ${this.slots.length} slots`; new LiteGraph.ContextMenu([CONFIRM, "Cancel"], { event, scale: Math.max(1, app.canvas.ds?.scale ?? 1), className: "dark", title: "Clear every lora, weight and trigger?", callback: (value) => { if (value === CONFIRM) this._reset(node); }, }); return true; } } // buildRows(node) does the type-specific widget lookup + addCustomWidget calls // and returns the array of stock widgets that got compacted into rows (so they // can be hidden). Everything past this point (hiding, retry timers, size snap) // is identical for every node type this gets installed on. function installLoraRows(nodeType, buildRows) { const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const ret = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; const node = this; // Everything below is purely cosmetic (compact lora rows). If anything // in here throws, it must NEVER propagate — an uncaught error here // during graph.configure() can abort loading the rest of the workflow, // leaving a blank canvas. Fail loud in the console, fail safe for the user. try { if (!node.__dolphinLoraRowsBuilt) { node.__dolphinLoraRowsBuilt = true; node.__dolphinHiddenWidgets = buildRows(node) || []; } // widget.element (the real DOM ) may not exist yet at // construction time, and ComfyUI's own per-frame DOM-sync can re-show // it later — so (re-)apply hiding now and on a couple of deferred // passes to make sure it sticks. This does NOT touch node.size, so it // never fights a manual resize the user makes afterwards. const applyHiding = () => { try { for (const w of node.__dolphinHiddenWidgets || []) hideWidget(w); node.setDirtyCanvas(true, true); } catch (e) { console.error("[Dolphin.LoraRows] applyHiding failed:", e); } }; applyHiding(); setTimeout(applyHiding, 50); setTimeout(applyHiding, 250); // Snap node.size to the freshly computed minimum ONCE on (re)build. // Collapsing many stock widgets into compact rows frees up a lot of // height; a stale saved node.size (from before this UI existed) would // otherwise leave that freed space around, and flexible multiline // widgets auto-grow to fill it. setTimeout(() => { try { const computed = node.computeSize(); node.size = node.size || [0, 0]; node.size[1] = computed[1]; node.setDirtyCanvas(true, true); } catch (e) { console.error("[Dolphin.LoraRows] size snap failed:", e); } }, 300); } catch (e) { console.error("[Dolphin.LoraRows] onNodeCreated hook failed:", e); } return ret; }; } function buildVisionDirectorRows(node) { const hidden = []; for (let c = 1; c <= NUM_CLIPS; c++) { node.addCustomWidget(new DolphinSectionHeaderWidget(`── Clip ${c} Loras ──`)); for (let n = 1; n <= NUM_LORAS; n++) { const comboW = node.widgets.find((w) => w.name === `c${c}_lora_${n}`); const onW = node.widgets.find((w) => w.name === `c${c}_lora_${n}_on`); const weightW = node.widgets.find((w) => w.name === `c${c}_w_${n}`); if (!comboW || !onW || !weightW) continue; hidden.push(comboW, onW, weightW); node.addCustomWidget(new DolphinLoraRowWidget(`c${c}_lora_${n}_row`, comboW, onW, weightW)); } } const manualTriggersW = node.widgets.find((w) => w.name === "manual_triggers"); if (manualTriggersW) { hidden.push(manualTriggersW); node.addCustomWidget(new DolphinTextFieldWidget("manual_triggers:", manualTriggersW, true)); } return hidden; } // DolphinArchitectV16 (🐬 Dolphin Architect V16): one flat bank of lora_1..6 / // lora_1_on..6_on / weights_1..6 (each already base+high+low as one comma // string, e.g. "0.4, 0.7, 0") — no per-clip grouping, so no section headers. // 파일명에 HIGH/LOW가 들어있으면 Wan2.2 분리 로라로 보고 그쪽 모델 슬롯만 켠다 // (base,high,low). 둘 다 없으면 Wan2.1 등 범용 로라로 보고 셋 다 켠다. "high_noise"/ // "low_noise" 같은 변형도 그냥 substring 매치라 자동으로 걸린다. // HN/LN 약어(-HN-, _HN_, ...-HN.safetensors 식)는 앞뒤가 영숫자가 아닐 때만 매치하는 // 토큰 경계 정규식을 쓴다 - 그냥 substring이면 "krea2DepthControlnet"(contro-L-N-et) // 같은 무관한 파일명이 "ln"을 우연히 포함해서 Low Noise로 오인식된다. const HN_TOKEN = /(?:^|[^a-z0-9])hn(?:[^a-z0-9]|$)/; const LN_TOKEN = /(?:^|[^a-z0-9])ln(?:[^a-z0-9]|$)/; // 한 글자 H/L 토큰(-H-, _H_, -L-, _L_, 그리고 확장자/이름 끝의 _H·-L 등). // hn/ln보다 한 글자 짧아서 오탐 위험이 훨씬 크기 때문에 두 가지로 좁혔다: // (1) 구분자를 '-'와 '_'로만 한정한다. hn/ln이 쓰는 일반 토큰 경계([^a-z0-9])까지 // 허용하면 "flaccid-v1.0"의 점·공백·괄호 같은 문자도 경계가 되어 범위가 급격히 넓어진다. // (2) 대문자만 매치한다(그래서 소문자화한 n이 아니라 원본 raw로 검사). HIGH/LOW 약어는 // 관례적으로 대문자이고, 소문자까지 받으면 "..._v2_h_final" 같은 무관한 이름이 걸린다. const H_TOKEN = /[-_]H(?=[-_]|\.[A-Za-z0-9]+$|$)/; const L_TOKEN = /[-_]L(?=[-_]|\.[A-Za-z0-9]+$|$)/; function guessLoraWeights(name) { if (!name || name === "None") return null; const raw = String(name); const n = raw.toLowerCase(); if (n.includes("high") || HN_TOKEN.test(n) || H_TOKEN.test(raw)) return "1, 1, 0"; if (n.includes("low") || LN_TOKEN.test(n) || L_TOKEN.test(raw)) return "0, 0, 1"; return "1, 1, 1"; } // comboW.callback은 유저가 드롭다운/행-UI에서 실제로 선택했을 때만 불린다 - 워크플로우 // 로드 시 configure()가 widgets_values를 복원하는 경로는 .value를 직접 대입할 뿐 이 // callback을 거치지 않으므로, 저장된 수동 가중치를 자동으로 덮어쓸 위험이 없다. function wireAutoWeight(comboW, weightW) { const orig = comboW.callback; comboW.callback = function (value, ...rest) { const guess = guessLoraWeights(value); if (guess !== null) weightW.value = guess; return orig ? orig.apply(this, [value, ...rest]) : undefined; }; } function buildArchitectRows(node) { const hidden = []; const slots = []; for (let n = 1; n <= NUM_ARCH_LORAS; n++) { const comboW = node.widgets.find((w) => w.name === `lora_${n}`); const onW = node.widgets.find((w) => w.name === `lora_${n}_on`); const weightW = node.widgets.find((w) => w.name === `weights_${n}`); if (!comboW || !onW || !weightW) continue; wireAutoWeight(comboW, weightW); hidden.push(comboW, onW, weightW); slots.push({ comboW, onW, weightW }); node.addCustomWidget(new DolphinLoraRowWidget(`lora_${n}_row`, comboW, onW, weightW)); } const manualTriggersW = node.widgets.find((w) => w.name === "manual_triggers"); if (manualTriggersW) { hidden.push(manualTriggersW); node.addCustomWidget(new DolphinTextFieldWidget("manual_triggers:", manualTriggersW, true)); } if (slots.length) { node.addCustomWidget(new DolphinResetButtonWidget("⟲ Reset all slots", slots, manualTriggersW)); } return hidden; } app.registerExtension({ name: "Dolphin.LoraRows", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData?.name === VISION_NODE_NAME) { installLoraRows(nodeType, buildVisionDirectorRows); } else if (nodeData?.name === ARCH_NODE_NAME) { installLoraRows(nodeType, buildArchitectRows); } }, });