From 1c5b33226502e30f806bd771e00f5ee42d74456f Mon Sep 17 00:00:00 2001 From: rikanrino Date: Sun, 17 May 2026 05:16:21 +0800 Subject: [PATCH] Add files via upload --- web/custom_image_latent.js | 81 ++++++-- web/multi_lora_gate.js | 388 ++++++++++++++++++++++++++++------- web/multi_lora_loader.js | 406 +++++++++++++++++++++++++++++++++++++ 3 files changed, 787 insertions(+), 88 deletions(-) create mode 100644 web/multi_lora_loader.js diff --git a/web/custom_image_latent.js b/web/custom_image_latent.js index abd2337..33f64fb 100644 --- a/web/custom_image_latent.js +++ b/web/custom_image_latent.js @@ -11,6 +11,7 @@ app.registerExtension({ const resizeWidget = this.widgets?.find(w => w.name === "resize_mode"); const textWidget = this.widgets?.find(w => w.name === "text"); + const themeWidget = this.widgets?.find(w => w.name === "prompt_theme"); const presetWidget = this.widgets?.find(w => w.name === "resolution_preset"); const orientWidget = this.widgets?.find(w => w.name === "orientation"); const widthWidget = this.widgets?.find(w => w.name === "custom_width"); @@ -20,28 +21,59 @@ app.registerExtension({ textWidget.inputEl.placeholder = "[WRITE YOUR PROMPT HERE]\n\nThe selected theme's addon will be appended automatically."; } - const allWidgets = [...this.widgets]; + // Simpan opsi dan tipe asli ke dalam memori untuk direstorasi nanti + if (themeWidget) { + themeWidget.origType = themeWidget.type; + themeWidget.origComputeSize = themeWidget.computeSize; + } + const origOrientOptions = orientWidget ? [...orientWidget.options.values] : []; - // Menampilkan UI jika mode membutuhkan prompt tambahan + // 1. Fungsi Show/Hide Widget (DIPERBAIKI) const toggleVisibility = () => { const isAdvancedMode = resizeWidget.value === "Generative Fill" || resizeWidget.value === "Zoom Out"; - this.widgets = allWidgets.filter(w => { - if (w.name === "text" || w.name === "prompt_theme") { - return isAdvancedMode; + if (themeWidget) { + if (isAdvancedMode) { + // Munculkan kembali widget secara normal + themeWidget.type = themeWidget.origType; + themeWidget.computeSize = themeWidget.origComputeSize; + } else { + // Sembunyikan widget secara aman tanpa menghapusnya dari data node + themeWidget.type = "hidden"; + themeWidget.computeSize = () => [0, -4]; // Ukuran negatif agar tidak menyisakan ruang kosong/gap di node } - return true; - }); - - if (textWidget.inputEl) { - textWidget.inputEl.style.display = isAdvancedMode ? "block" : "none"; } - this.onResize?.(this.size); + // Teks area selalu dipaksa muncul + if (textWidget && textWidget.inputEl) { + textWidget.inputEl.style.display = "block"; + } + + // Paksa node menghitung ulang tingginya agar rapi + if (this.computeSize) { + this.size[1] = this.computeSize()[1]; + } this.setDirtyCanvas(true, true); }; - // Memperbarui angka resolusi otomatis + // 2. Fungsi: Kunci Orientasi jika Full Body + const checkFullBodyLock = () => { + if (!orientWidget || !themeWidget || !resizeWidget) return; + + const isAdvancedMode = resizeWidget.value === "Generative Fill" || resizeWidget.value === "Zoom Out"; + + if (themeWidget.value === "Full Body" && isAdvancedMode) { + // Paksa nilai menjadi Vertical + orientWidget.value = "Vertical (Portrait)"; + // Kunci dropdown dengan hanya menyisakan 1 opsi + orientWidget.options.values = ["Vertical (Portrait)"]; + } else { + // Kembalikan semua opsi jika mode lain dipilih + orientWidget.options.values = origOrientOptions; + } + }; + + // 3. Fungsi Kontrol Angka Resolusi const updateDimensions = () => { if (presetWidget.value === "Custom") return; let bL = 1280, bS = 720; @@ -59,12 +91,31 @@ app.registerExtension({ } }; - if (resizeWidget) resizeWidget.callback = () => toggleVisibility(); - if (presetWidget) presetWidget.callback = () => updateDimensions(); - if (orientWidget) orientWidget.callback = () => updateDimensions(); + // Memasang Listener ke setiap menu dropdown + if (resizeWidget) { + resizeWidget.callback = () => { + toggleVisibility(); + checkFullBodyLock(); + updateDimensions(); + }; + } + if (themeWidget) { + themeWidget.callback = () => { + checkFullBodyLock(); + updateDimensions(); + }; + } + if (presetWidget) { + presetWidget.callback = () => updateDimensions(); + } + if (orientWidget) { + orientWidget.callback = () => updateDimensions(); + } + // Jalankan inisialisasi awal saat node muncul di layar setTimeout(() => { toggleVisibility(); + checkFullBodyLock(); updateDimensions(); }, 100); diff --git a/web/multi_lora_gate.js b/web/multi_lora_gate.js index 84f89fc..91eef68 100644 --- a/web/multi_lora_gate.js +++ b/web/multi_lora_gate.js @@ -6,56 +6,316 @@ app.registerExtension({ if (nodeData.name === "RikanPromptRelayMultiLoraGate") { const syncData = (node) => { - if (!node || !node.widgets) return; const dataWidget = node.widgets.find(w => w.name === "lora_data"); if (!dataWidget) return; const loras = []; - // Hanya memproses widget lora yang benar-benar eksis - const loraWidgets = node.widgets.filter(w => w.name && w.name.startsWith("lora_")); + const loraWidgets = node.widgets.filter(w => w.type === "RIKAN_LORA_ROW"); - loraWidgets.forEach(lw => { - const idx = lw.name.split("_")[1]; - const wToggle = node.widgets.find(w => w.name === `enable_${idx}`); - const wSeg = node.widgets.find(w => w.name === `segment_${idx}`); - const wMod = node.widgets.find(w => w.name === `modelStr_${idx}`); - - if (wToggle && wSeg && wMod) { - loras.push({ - enable: wToggle.value, - name: lw.value, - segment: parseInt(wSeg.value) || 0, - modelStr: parseFloat(wMod.value) || 0.0 - }); - } + loraWidgets.forEach(w => { + loras.push({ + enable: w.value.enable, + name: w.value.name, + segment: w.value.segment, + modelStr: w.value.modelStr + }); }); dataWidget.value = JSON.stringify(loras); }; - const addLoraRow = function(node, index, loraName = "None") { - const getLoraList = () => { - const loraLoader = LiteGraph.registered_node_types["LoraLoader"]; - if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) { - return ["None"].concat(loraLoader.nodeData.input.required.lora_name[0] || []); + const showInfoPopup = (data) => { + const overlay = document.createElement("div"); + overlay.style.cssText = "position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.85); z-index: 10000; display: flex; justify-content: center; align-items: center; font-family: sans-serif;"; + + const modal = document.createElement("div"); + modal.style.cssText = "background: #2b2b2b; width: 900px; max-height: 90vh; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #444; color: #ddd;"; + modal.onclick = (e) => e.stopPropagation(); + + let wordsHtml = "None"; + if (data.trained_words && data.trained_words.length > 0) { + wordsHtml = data.trained_words.map(w => + `${w}` + ).join(''); + } + + const infoSection = document.createElement("div"); + infoSection.style.cssText = "padding: 20px; border-bottom: 1px solid #444;"; + infoSection.innerHTML = ` +

${data.model_name || "Unknown Model"}

+
+ LORA + ${data.base_model || "Unknown Base"} +
+ + + + + + +
File${data.filename || "-"}
Hash (SHA256)${data.hash || "-"}
Civitai🌐 View on Civitai
Trained Words${wordsHtml}
+ `; + modal.appendChild(infoSection); + + const mediaSection = document.createElement("div"); + mediaSection.style.cssText = "padding: 20px; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; background: #1f1f1f;"; + + if (data.media && data.media.length > 0) { + data.media.forEach(m => { + let mediaEl = ""; + if (m.type === "video") { + mediaEl = ``; + } else { + mediaEl = ``; + } + + let promptHtml = ""; + if (m.prompt) { + promptHtml = ` +
+ positive${m.prompt} +
+ `; + } + + const overlayHtml = ` +
+ civitai 🔗 + ${promptHtml} +
+ `; + + const containerHtml = ` +
+ ${mediaEl} + ${overlayHtml} +
+ `; + + mediaSection.innerHTML += containerHtml; + }); + } else { + mediaSection.innerHTML = `

No preview media available.

`; + } + modal.appendChild(mediaSection); + + const footer = document.createElement("div"); + footer.style.cssText = "padding: 15px; text-align: center; border-top: 1px solid #444; background: #2b2b2b;"; + const closeBtn = document.createElement("button"); + closeBtn.innerText = "Close"; + closeBtn.style.cssText = "padding: 8px 30px; background: #444; color: white; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 14px;"; + closeBtn.onmouseover = () => closeBtn.style.background = "#555"; + closeBtn.onmouseout = () => closeBtn.style.background = "#444"; + + const destroyPopup = () => overlay.remove(); + closeBtn.onclick = destroyPopup; + overlay.onclick = destroyPopup; + + footer.appendChild(closeBtn); + modal.appendChild(footer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + }; + + const addLoraRow = function(node, loraData) { + const w = node.addWidget("custom", "lora_row_" + Math.random().toString(36).substr(2, 5), loraData, () => syncData(node)); + w.type = "RIKAN_LORA_ROW"; + w.civitaiData = null; + + w.fetchCivitaiData = function(name) { + if (name === "None" || !name) { + this.civitaiData = null; + return; } - return ["None"]; + this.civitaiData = { loading: true }; + node.setDirtyCanvas(true, true); + + fetch("/rikan/get_lora_info", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({lora_name: name}) + }) + .then(r => r.json()) + .then(data => { + this.civitaiData = data; + node.setDirtyCanvas(true, true); + }).catch(err => { + this.civitaiData = null; + node.setDirtyCanvas(true, true); + }); + }; + + w.fetchCivitaiData(loraData.name); + + w.computeSize = function(width) { + return [width, 28]; }; - const wToggle = node.addWidget("toggle", `enable_${index}`, true, () => syncData(node)); - const wLora = node.addWidget("combo", `lora_${index}`, loraName, () => syncData(node), { values: getLoraList() }); - - // Segment default SELALU 0, dengan step 10 agar klik tombol panah bernilai +1 - const wSeg = node.addWidget("number", `segment_${index}`, 0, () => syncData(node), { min: 0, max: 99, step: 10, precision: 0 }); - // Strength default SELALU 0.80, dengan step 0.1 agar klik tombol panah bernilai +0.01 - const wMod = node.addWidget("number", `modelStr_${index}`, 0.80, () => syncData(node), { min: -10.0, max: 10.0, step: 0.1, precision: 2 }); + w.draw = function(ctx, node, width, y, margin) { + // PERBAIKAN: Menyimpan koordinat Y agar klik mouse tidak meleset + this.last_y = y; + + ctx.save(); + const v = this.value; + const isEnabled = v.enable; + const fgColor = isEnabled ? "#ddd" : "#666"; + const bgColor = isEnabled ? "#262626" : "#1e1e1e"; + + // Background + ctx.fillStyle = bgColor; + ctx.beginPath(); + ctx.roundRect(margin, y + 2, width - margin * 2, 24, 6); + ctx.fill(); + + // Toggle Switch + const togX = margin + 10; + ctx.fillStyle = isEnabled ? "#4285f4" : "#444"; + ctx.beginPath(); + ctx.roundRect(togX, y + 7, 24, 14, 7); + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.arc(isEnabled ? togX + 17 : togX + 7, y + 14, 5, 0, Math.PI * 2); + ctx.fill(); + + // Info Button + const infoX = width - margin - 110; + if (this.civitaiData && this.civitaiData.loading) { + ctx.fillStyle = "#b45309"; + ctx.beginPath(); ctx.roundRect(infoX, y + 6, 16, 16, 3); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "bold 11px Arial"; ctx.fillText("⏳", infoX + 2, y + 18); + } else if (this.civitaiData && this.civitaiData.found) { + ctx.fillStyle = "#1d4ed8"; + ctx.beginPath(); ctx.roundRect(infoX, y + 6, 16, 16, 3); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "bold 12px Arial"; ctx.fillText("i", infoX + 6, y + 18); + } else { + ctx.fillStyle = "#444"; + ctx.beginPath(); ctx.roundRect(infoX, y + 6, 16, 16, 3); ctx.fill(); + ctx.fillStyle = "#888"; ctx.font = "bold 12px Arial"; ctx.fillText("x", infoX + 4, y + 18); + } + + // Segment + const segX = width - margin - 85; + ctx.fillStyle = isEnabled ? "#111" : "#1a1a1a"; + ctx.beginPath(); ctx.roundRect(segX, y + 5, 35, 18, 3); ctx.fill(); + ctx.fillStyle = fgColor; ctx.textAlign = "center"; ctx.font = "11px Arial"; + ctx.fillText("S: " + v.segment, segX + 17, y + 18); + + // Strength + const strX = width - margin - 45; + ctx.fillStyle = isEnabled ? "#111" : "#1a1a1a"; + ctx.beginPath(); ctx.roundRect(strX, y + 5, 40, 18, 3); ctx.fill(); + ctx.fillStyle = fgColor; ctx.textAlign = "center"; ctx.font = "11px Arial"; + ctx.fillText(v.modelStr.toFixed(2), strX + 20, y + 18); + + // LoRA Name + ctx.textAlign = "left"; + ctx.fillStyle = fgColor; + ctx.font = "13px Arial"; + let displayName = v.name; + + const maxTextWidth = (infoX - 10) - (togX + 35); + if (ctx.measureText(displayName).width > maxTextWidth) { + while (displayName.length > 0 && ctx.measureText(displayName + "...").width > maxTextWidth) { + displayName = displayName.slice(0, -1); + } + displayName += "..."; + } + ctx.fillText(displayName, togX + 35, y + 18); + + ctx.restore(); + }; + + w.mouse = function(event, pos, node) { + // PERBAIKAN: Deteksi pointerdown untuk kompatibilitas ComfyUI modern + if (event.type !== "pointerdown" && event.type !== "mousedown") return false; + + const [x, y] = pos; + const wY = this.last_y || 0; // Mengambil koordinat Y dari w.draw + + // Cek apakah klik persis berada di baris Y ini + if (y < wY || y > wY + 28) return false; + + const width = node.size[0]; + const margin = LiteGraph.NODE_WIDGET_MARGIN || 15; + const v = this.value; + + // Toggle + if (x >= margin + 10 && x <= margin + 35) { + v.enable = !v.enable; + syncData(node); + node.setDirtyCanvas(true, true); + return true; + } + + // Info Button + const infoX = width - margin - 110; + if (x >= infoX && x <= infoX + 16) { + if (this.civitaiData && this.civitaiData.found) { + showInfoPopup(this.civitaiData); + } else if (this.civitaiData && !this.civitaiData.found) { + alert("This LoRA is not found on Civitai's Database."); + } + return true; + } + + // Segment + const segX = width - margin - 85; + if (x >= segX && x <= segX + 35) { + app.canvas.prompt("Set Segment (0-99):", v.segment, (val) => { + const parsed = parseInt(val); + if (!isNaN(parsed)) { + v.segment = parsed; + syncData(node); + node.setDirtyCanvas(true, true); + } + }, event); + return true; + } + + // Strength + const strX = width - margin - 45; + if (x >= strX && x <= strX + 40) { + app.canvas.prompt("Set Strength (-10.0 to 10.0):", v.modelStr, (val) => { + const parsed = parseFloat(val); + if (!isNaN(parsed)) { + v.modelStr = parsed; + syncData(node); + node.setDirtyCanvas(true, true); + } + }, event); + return true; + } + + // Name Dropdown + if (x > margin + 35 && x < infoX - 5) { + const loraLoader = LiteGraph.registered_node_types["LoraLoader"]; + let loras = ["None"]; + if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) { + loras = ["None"].concat(loraLoader.nodeData.input.required.lora_name[0] || []); + } + const menuItems = loras.map(l => ({ + content: l, + callback: () => { + v.name = l; + this.fetchCivitaiData(l); + syncData(node); + node.setDirtyCanvas(true, true); + } + })); + new LiteGraph.ContextMenu(menuItems, { event: event, title: "Select LoRA" }); + return true; + } + + return false; + }; if (node.btnAddLora && node.btnRemoveLora) { const idx2 = node.widgets.indexOf(node.btnAddLora); if (idx2 !== -1) node.widgets.splice(idx2, 1); - const idx3 = node.widgets.indexOf(node.btnRemoveLora); if (idx3 !== -1) node.widgets.splice(idx3, 1); - node.widgets.push(node.btnAddLora); node.widgets.push(node.btnRemoveLora); } @@ -67,7 +327,6 @@ app.registerExtension({ const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; this.serialize_widgets = true; - this.loraCount = 0; const wData = this.widgets.find(w => w.name === "lora_data"); if (wData) { @@ -76,19 +335,15 @@ app.registerExtension({ wData.computeSize = () => [0, -4]; } - this.btnToggleAll = this.addWidget("button", "🔄 Toggle All", "toggle_all", () => { + this.btnToggleAll = this.addWidget("button", "🔄 Toggle All Enable/Disable", "toggle_all", () => { let allOn = true; - for (const w of this.widgets) { - if (w.name && w.name.startsWith("enable_") && w.value === false) { - allOn = false; - break; - } + const loraWidgets = this.widgets.filter(w => w.type === "RIKAN_LORA_ROW"); + for (const w of loraWidgets) { + if (w.value.enable === false) { allOn = false; break; } } const targetState = !allOn; - for (const w of this.widgets) { - if (w.name && w.name.startsWith("enable_")) { - w.value = targetState; - } + for (const w of loraWidgets) { + w.value.enable = targetState; } syncData(this); this.setDirtyCanvas(true, true); @@ -104,33 +359,27 @@ app.registerExtension({ const menuItems = loras.map(loraName => ({ content: loraName, callback: () => { - this.loraCount++; - addLoraRow(this, this.loraCount, loraName); + addLoraRow(this, { enable: true, name: loraName, segment: 0, modelStr: 0.80 }); this.computeSize(); this.setDirtyCanvas(true, true); } })); - if (menuItems.length === 0) { - menuItems.push({ content: "No LoRAs found!", callback: () => {} }); - } - + if (menuItems.length === 0) menuItems.push({ content: "No LoRAs found!", callback: () => {} }); + const e = event || app.canvas.last_mouse_event; new LiteGraph.ContextMenu(menuItems, { event: e, title: "Choose a LoRA" }); }); this.btnRemoveLora = this.addWidget("button", "➖ Remove Last LoRA", "remove_lora", () => { - if (this.loraCount > 0) { - const suffix = `_${this.loraCount}`; - for (let i = this.widgets.length - 1; i >= 0; i--) { - if (this.widgets[i].name && this.widgets[i].name.endsWith(suffix)) { - this.widgets.splice(i, 1); - } + for (let i = this.widgets.length - 1; i >= 0; i--) { + if (this.widgets[i].type === "RIKAN_LORA_ROW") { + this.widgets.splice(i, 1); + syncData(this); + this.computeSize(); + this.setDirtyCanvas(true, true); + break; } - this.loraCount--; - syncData(this); - this.computeSize(); - this.setDirtyCanvas(true, true); } }); @@ -140,8 +389,6 @@ app.registerExtension({ const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (info) { if (info && info.widgets_values) { - this.loraCount = 0; - let jsonData = "[]"; for(let v of info.widgets_values) { if (typeof v === "string" && v.startsWith("[") && v.endsWith("]")) { @@ -151,20 +398,16 @@ app.registerExtension({ } try { + for (let i = this.widgets.length - 1; i >= 0; i--) { + if (this.widgets[i].type === "RIKAN_LORA_ROW" || this.widgets[i].name.startsWith("enable_")) { + this.widgets.splice(i, 1); + } + } + const savedLoras = JSON.parse(jsonData); for (let i = 0; i < savedLoras.length; i++) { const lora = savedLoras[i]; - const idx = i + 1; - this.loraCount = idx; - addLoraRow(this, idx, lora.name); - - const wToggle = this.widgets.find(w => w.name === `enable_${idx}`); - const wSegment = this.widgets.find(w => w.name === `segment_${idx}`); - const wModel = this.widgets.find(w => w.name === `modelStr_${idx}`); - - if (wToggle) wToggle.value = lora.enable; - if (wSegment) wSegment.value = lora.segment; - if (wModel) wModel.value = lora.modelStr; + addLoraRow(this, { enable: lora.enable, name: lora.name, segment: lora.segment, modelStr: lora.modelStr }); } const wData = this.widgets.find(w => w.name === "lora_data"); @@ -174,7 +417,6 @@ app.registerExtension({ wData.computeSize = () => [0, -4]; wData.value = jsonData; } - } catch (e) { console.error("[Rikan MultiLoraGate] Failed to parse saved JSON data:", e); } diff --git a/web/multi_lora_loader.js b/web/multi_lora_loader.js new file mode 100644 index 0000000..192b1b4 --- /dev/null +++ b/web/multi_lora_loader.js @@ -0,0 +1,406 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "rikan.MultiLoraLoaderNative", // Ubah nama internal ekstensi + async beforeRegisterNodeDef(nodeType, nodeData, app) { + // PERBAIKAN: Menargetkan nama node yang baru + if (nodeData.name === "RikanMultiLoraLoader") { + + const syncData = (node) => { + const dataWidget = node.widgets.find(w => w.name === "lora_data"); + if (!dataWidget) return; + + const loras = []; + const loraWidgets = node.widgets.filter(w => w.type === "RIKAN_LORA_LOADER_ROW"); + + loraWidgets.forEach(w => { + loras.push({ + enable: w.value.enable, + name: w.value.name, + modelStr: w.value.modelStr + }); + }); + dataWidget.value = JSON.stringify(loras); + }; + + const showInfoPopup = (data) => { + const overlay = document.createElement("div"); + overlay.style.cssText = "position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.85); z-index: 10000; display: flex; justify-content: center; align-items: center; font-family: sans-serif;"; + + const modal = document.createElement("div"); + modal.style.cssText = "background: #2b2b2b; width: 900px; max-height: 90vh; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); display: flex; flex-direction: column; overflow: hidden; border: 1px solid #444; color: #ddd;"; + modal.onclick = (e) => e.stopPropagation(); + + let wordsHtml = "None"; + if (data.trained_words && data.trained_words.length > 0) { + wordsHtml = data.trained_words.map(w => + `${w}` + ).join(''); + } + + const infoSection = document.createElement("div"); + infoSection.style.cssText = "padding: 20px; border-bottom: 1px solid #444;"; + infoSection.innerHTML = ` +

${data.model_name || "Unknown Model"}

+
+ LORA + ${data.base_model || "Unknown Base"} +
+ + + + + + +
File${data.filename || "-"}
Hash (SHA256)${data.hash || "-"}
Civitai🌐 View on Civitai
Trained Words${wordsHtml}
+ `; + modal.appendChild(infoSection); + + const mediaSection = document.createElement("div"); + mediaSection.style.cssText = "padding: 20px; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; background: #1f1f1f;"; + + if (data.media && data.media.length > 0) { + data.media.forEach(m => { + let mediaEl = m.type === "video" ? + `` : + ``; + + let promptHtml = m.prompt ? ` +
+ positive${m.prompt} +
+ ` : ""; + + const overlayHtml = ` +
+ civitai 🔗 + ${promptHtml} +
+ `; + + const containerHtml = ` +
+ ${mediaEl} + ${overlayHtml} +
+ `; + mediaSection.innerHTML += containerHtml; + }); + } else { + mediaSection.innerHTML = `

No preview media available.

`; + } + modal.appendChild(mediaSection); + + const footer = document.createElement("div"); + footer.style.cssText = "padding: 15px; text-align: center; border-top: 1px solid #444; background: #2b2b2b;"; + const closeBtn = document.createElement("button"); + closeBtn.innerText = "Close"; + closeBtn.style.cssText = "padding: 8px 30px; background: #444; color: white; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 14px;"; + closeBtn.onmouseover = () => closeBtn.style.background = "#555"; + closeBtn.onmouseout = () => closeBtn.style.background = "#444"; + + const destroyPopup = () => overlay.remove(); + closeBtn.onclick = destroyPopup; + overlay.onclick = destroyPopup; + + footer.appendChild(closeBtn); + modal.appendChild(footer); + overlay.appendChild(modal); + document.body.appendChild(overlay); + }; + + const addLoraRow = function(node, loraData) { + const w = node.addWidget("custom", "lora_row_" + Math.random().toString(36).substr(2, 5), loraData, () => syncData(node)); + w.type = "RIKAN_LORA_LOADER_ROW"; + w.civitaiData = null; + + w.fetchCivitaiData = function(name) { + if (name === "None" || !name) { + this.civitaiData = null; + return; + } + this.civitaiData = { loading: true }; + node.setDirtyCanvas(true, true); + + fetch("/rikan/get_lora_info", { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({lora_name: name}) + }) + .then(r => r.json()) + .then(data => { + this.civitaiData = data; + node.setDirtyCanvas(true, true); + }).catch(err => { + this.civitaiData = null; + node.setDirtyCanvas(true, true); + }); + }; + + w.fetchCivitaiData(loraData.name); + + w.computeSize = function(width) { + return [width, 28]; + }; + + w.draw = function(ctx, node, width, y, margin) { + this.last_y = y; + + ctx.save(); + const v = this.value; + const isEnabled = v.enable; + const fgColor = isEnabled ? "#ddd" : "#666"; + const bgColor = isEnabled ? "#262626" : "#1e1e1e"; + + // Background + ctx.fillStyle = bgColor; + ctx.beginPath(); + ctx.roundRect(margin, y + 2, width - margin * 2, 24, 6); + ctx.fill(); + + // Toggle Switch + const togX = margin + 10; + ctx.fillStyle = isEnabled ? "#4285f4" : "#444"; + ctx.beginPath(); + ctx.roundRect(togX, y + 7, 24, 14, 7); + ctx.fill(); + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.arc(isEnabled ? togX + 17 : togX + 7, y + 14, 5, 0, Math.PI * 2); + ctx.fill(); + + // Info Button + const infoX = width - margin - 75; + if (this.civitaiData && this.civitaiData.loading) { + ctx.fillStyle = "#b45309"; + ctx.beginPath(); ctx.roundRect(infoX, y + 6, 16, 16, 3); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "bold 11px Arial"; ctx.fillText("⏳", infoX + 2, y + 18); + } else if (this.civitaiData && this.civitaiData.found) { + ctx.fillStyle = "#1d4ed8"; + ctx.beginPath(); ctx.roundRect(infoX, y + 6, 16, 16, 3); ctx.fill(); + ctx.fillStyle = "#fff"; ctx.font = "bold 12px Arial"; ctx.fillText("i", infoX + 6, y + 18); + } else { + ctx.fillStyle = "#444"; + ctx.beginPath(); ctx.roundRect(infoX, y + 6, 16, 16, 3); ctx.fill(); + ctx.fillStyle = "#888"; ctx.font = "bold 12px Arial"; ctx.fillText("x", infoX + 4, y + 18); + } + + // Strength + const strX = width - margin - 45; + ctx.fillStyle = isEnabled ? "#111" : "#1a1a1a"; + ctx.beginPath(); ctx.roundRect(strX, y + 5, 40, 18, 3); ctx.fill(); + ctx.fillStyle = fgColor; ctx.textAlign = "center"; ctx.font = "11px Arial"; + ctx.fillText(v.modelStr.toFixed(2), strX + 20, y + 18); + + // LoRA Name + ctx.textAlign = "left"; + ctx.fillStyle = fgColor; + ctx.font = "13px Arial"; + let displayName = v.name; + + const maxTextWidth = (infoX - 10) - (togX + 35); + if (ctx.measureText(displayName).width > maxTextWidth) { + while (displayName.length > 0 && ctx.measureText(displayName + "...").width > maxTextWidth) { + displayName = displayName.slice(0, -1); + } + displayName += "..."; + } + ctx.fillText(displayName, togX + 35, y + 18); + + ctx.restore(); + }; + + w.mouse = function(event, pos, node) { + if (event.type !== "pointerdown" && event.type !== "mousedown") return false; + + const [x, y] = pos; + const wY = this.last_y || 0; + + if (y < wY || y > wY + 28) return false; + + const width = node.size[0]; + const margin = LiteGraph.NODE_WIDGET_MARGIN || 15; + const v = this.value; + + // Toggle + if (x >= margin + 10 && x <= margin + 35) { + v.enable = !v.enable; + syncData(node); + node.setDirtyCanvas(true, true); + return true; + } + + // Info Button + const infoX = width - margin - 75; + if (x >= infoX && x <= infoX + 16) { + if (this.civitaiData && this.civitaiData.found) { + showInfoPopup(this.civitaiData); + } else if (this.civitaiData && !this.civitaiData.found) { + alert("This LoRA is not found on Civitai's Database."); + } + return true; + } + + // Strength + const strX = width - margin - 45; + if (x >= strX && x <= strX + 40) { + app.canvas.prompt("Set Strength (-10.0 to 10.0):", v.modelStr, (val) => { + const parsed = parseFloat(val); + if (!isNaN(parsed)) { + v.modelStr = parsed; + syncData(node); + node.setDirtyCanvas(true, true); + } + }, event); + return true; + } + + // Name Dropdown + if (x > margin + 35 && x < infoX - 5) { + const loraLoader = LiteGraph.registered_node_types["LoraLoader"]; + let loras = ["None"]; + if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) { + loras = ["None"].concat(loraLoader.nodeData.input.required.lora_name[0] || []); + } + const menuItems = loras.map(l => ({ + content: l, + callback: () => { + v.name = l; + this.fetchCivitaiData(l); + syncData(node); + node.setDirtyCanvas(true, true); + } + })); + new LiteGraph.ContextMenu(menuItems, { event: event, title: "Select LoRA" }); + return true; + } + + return false; + }; + + if (node.btnAddLora && node.btnRemoveLora) { + const idx2 = node.widgets.indexOf(node.btnAddLora); + if (idx2 !== -1) node.widgets.splice(idx2, 1); + const idx3 = node.widgets.indexOf(node.btnRemoveLora); + if (idx3 !== -1) node.widgets.splice(idx3, 1); + node.widgets.push(node.btnAddLora); + node.widgets.push(node.btnRemoveLora); + } + syncData(node); + }; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + + this.serialize_widgets = true; + + const wData = this.widgets.find(w => w.name === "lora_data"); + if (wData) { + wData.type = "hidden"; + wData.hidden = true; + wData.computeSize = () => [0, -4]; + } + + this.btnToggleAll = this.addWidget("button", "🔄 Toggle All Enable/Disable", "toggle_all", () => { + let allOn = true; + const loraWidgets = this.widgets.filter(w => w.type === "RIKAN_LORA_LOADER_ROW"); + for (const w of loraWidgets) { + if (w.value.enable === false) { allOn = false; break; } + } + const targetState = !allOn; + for (const w of loraWidgets) { + w.value.enable = targetState; + } + syncData(this); + this.setDirtyCanvas(true, true); + }); + + this.btnAddLora = this.addWidget("button", "➕ Add LoRA", "add_lora", (value, widget, node, pos, event) => { + const loraLoader = LiteGraph.registered_node_types["LoraLoader"]; + let loras = ["None"]; + if (loraLoader && loraLoader.nodeData && loraLoader.nodeData.input && loraLoader.nodeData.input.required && loraLoader.nodeData.input.required.lora_name) { + loras = loraLoader.nodeData.input.required.lora_name[0].filter(l => l !== "None"); + } + + const menuItems = loras.map(loraName => ({ + content: loraName, + callback: () => { + addLoraRow(this, { enable: true, name: loraName, modelStr: 1.00 }); + this.computeSize(); + this.setDirtyCanvas(true, true); + } + })); + + if (menuItems.length === 0) menuItems.push({ content: "No LoRAs found!", callback: () => {} }); + + const e = event || app.canvas.last_mouse_event; + new LiteGraph.ContextMenu(menuItems, { event: e, title: "Choose a LoRA" }); + }); + + this.btnRemoveLora = this.addWidget("button", "➖ Remove Last LoRA", "remove_lora", () => { + for (let i = this.widgets.length - 1; i >= 0; i--) { + if (this.widgets[i].type === "RIKAN_LORA_LOADER_ROW") { + this.widgets.splice(i, 1); + syncData(this); + this.computeSize(); + this.setDirtyCanvas(true, true); + break; + } + } + }); + + return r; + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + if (info && info.widgets_values) { + let jsonData = "[]"; + for(let v of info.widgets_values) { + if (typeof v === "string" && v.startsWith("[") && v.endsWith("]")) { + jsonData = v; + break; + } + } + + try { + for (let i = this.widgets.length - 1; i >= 0; i--) { + if (this.widgets[i].type === "RIKAN_LORA_LOADER_ROW" || this.widgets[i].name.startsWith("enable_")) { + this.widgets.splice(i, 1); + } + } + + const savedLoras = JSON.parse(jsonData); + for (let i = 0; i < savedLoras.length; i++) { + const lora = savedLoras[i]; + addLoraRow(this, { enable: lora.enable, name: lora.name, modelStr: lora.modelStr }); + } + + const wData = this.widgets.find(w => w.name === "lora_data"); + if (wData) { + wData.type = "hidden"; + wData.hidden = true; + wData.computeSize = () => [0, -4]; + wData.value = jsonData; + } + } catch (e) { + console.error("[Rikan MultiLoraLoader] Failed to parse saved JSON data:", e); + } + } + + if (onConfigure) { + onConfigure.apply(this, arguments); + } + }; + + const onSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function (o) { + if (onSerialize) onSerialize.apply(this, arguments); + syncData(this); + }; + } + } +}); \ No newline at end of file