diff --git a/web/funpack_conditioning_adjust.js b/web/funpack_conditioning_adjust.js new file mode 100644 index 00000000..34f463a0 --- /dev/null +++ b/web/funpack_conditioning_adjust.js @@ -0,0 +1,442 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; + +const NODE_NAME = "FunPackConditioningAdjust"; +const HIDDEN_WIDGETS = new Set(["adjustments"]); +const CATEGORY_ORDER = ["action", "camera", "subject", "appearance", "environment", "style", "quality", "details"]; + +let activePanel = null; + +function widgetByName(node, name) { + return (node.widgets || []).find((w) => w.name === name); +} + +function getAdjustments(node) { + const widget = widgetByName(node, "adjustments"); + try { + const parsed = JSON.parse(String(widget?.value || "[]")); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function setAdjustments(node, items) { + const widget = widgetByName(node, "adjustments"); + if (widget) widget.value = JSON.stringify(items); + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); +} + +function hideWidget(widget) { + if (!widget) return; + widget.__funpackHidden = true; + widget.hidden = true; + widget.options = widget.options || {}; + widget.options.hidden = true; + widget.computeSize = () => [0, -4]; + widget.computedHeight = 0; + widget.type = "hidden"; + for (const key of ["element", "inputEl", "textElement", "parentEl"]) { + const el = widget[key]; + if (el?.style) { + el.style.display = "none"; + el.style.visibility = "hidden"; + el.style.pointerEvents = "none"; + } + if (el) el.hidden = true; + } +} + +function linkedRefinementKey(node) { + const input = (node.inputs || []).find((i) => i.name === "refinement_key_input"); + const linkId = Array.isArray(input?.link) ? input.link[0] : input?.link; + if (linkId == null) return ""; + const link = app.graph?.links?.[linkId]; + const source = link ? app.graph?.getNodeById?.(link.origin_id) : null; + if (!source) return ""; + const selected = String(widgetByName(source, "refinement_key")?.value || "").trim(); + const typed = String(widgetByName(source, "key_name")?.value || "").trim(); + const key = selected && selected !== "-None-" ? selected : typed; + return key || ""; +} + +async function fetchPhraseMemory(key) { + if (!key) return []; + try { + const res = await api.fetchApi(`/funpack/phrase_memory?key=${encodeURIComponent(key)}`, { cache: "no-store" }); + if (!res.ok) return []; + const data = await res.json(); + return Array.isArray(data.phrases) ? data.phrases : []; + } catch { + return []; + } +} + +function closePanel() { + activePanel?.remove(); + activePanel = null; +} + +function panelButton(label, cls = "") { + const btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = label; + btn.className = `funpack-adj-button ${cls}`.trim(); + return btn; +} + +function renderPanel(node) { + closePanel(); + injectStyles(); + + const root = document.createElement("div"); + root.className = "funpack-adj-panel"; + root.style.cssText = "position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10000;"; + document.body.append(root); + activePanel = root; + + const items = getAdjustments(node).map((item) => ({ ...item })); + + // Header + const header = document.createElement("div"); + header.className = "funpack-adj-header"; + const title = document.createElement("span"); + title.textContent = "Conditioning Adjustments"; + const closeBtn = panelButton("Close"); + closeBtn.addEventListener("click", () => { + setAdjustments(node, items.filter((i) => String(i.phrase || "").trim())); + closePanel(); + }); + header.append(title, closeBtn); + root.append(header); + + const hint = document.createElement("div"); + hint.className = "funpack-adj-hint"; + hint.textContent = "Each phrase is encoded by the connected CLIP. Positive strength pushes conditioning toward that phrase, negative away. Range: -1.0 to +1.0 (typical useful range: 0.05 - 0.30)."; + root.append(hint); + + // Row list + const list = document.createElement("div"); + list.className = "funpack-adj-list"; + root.append(list); + + function renderRows() { + list.replaceChildren(); + if (!items.length) { + const empty = document.createElement("div"); + empty.className = "funpack-adj-empty"; + empty.textContent = "No adjustments yet. Add a phrase below or click a chip from the session bank."; + list.append(empty); + return; + } + for (let idx = 0; idx < items.length; idx++) { + const item = items[idx]; + const row = document.createElement("div"); + row.className = "funpack-adj-row"; + + const phraseInput = document.createElement("input"); + phraseInput.type = "text"; + phraseInput.value = String(item.phrase || ""); + phraseInput.placeholder = "phrase or word"; + phraseInput.className = "funpack-adj-phrase"; + phraseInput.addEventListener("input", () => { item.phrase = phraseInput.value; }); + + const strengthInput = document.createElement("input"); + strengthInput.type = "number"; + strengthInput.value = String(Number.isFinite(item.strength) ? item.strength : 0.0); + strengthInput.min = "-1"; + strengthInput.max = "1"; + strengthInput.step = "0.05"; + strengthInput.className = "funpack-adj-strength"; + strengthInput.addEventListener("input", () => { + const v = parseFloat(strengthInput.value); + item.strength = Number.isFinite(v) ? Math.max(-1, Math.min(1, v)) : 0.0; + }); + + const del = panelButton("×", "danger compact"); + del.title = "Remove"; + del.addEventListener("click", () => { items.splice(idx, 1); renderRows(); }); + + row.append(phraseInput, strengthInput, del); + list.append(row); + } + } + renderRows(); + + // Footer + const footer = document.createElement("div"); + footer.className = "funpack-adj-footer"; + const addBtn = panelButton("+ Add phrase", "primary"); + addBtn.addEventListener("click", () => { + items.push({ phrase: "", strength: 0.1 }); + renderRows(); + list.querySelectorAll(".funpack-adj-phrase")[items.length - 1]?.focus(); + }); + const clearBtn = panelButton("Clear all", "danger"); + clearBtn.addEventListener("click", () => { items.length = 0; renderRows(); }); + footer.append(addBtn, clearBtn); + root.append(footer); + + // Session phrase bank + const key = linkedRefinementKey(node); + if (key) { + const bankSection = document.createElement("div"); + bankSection.className = "funpack-adj-bank-section"; + const bankTitle = document.createElement("div"); + bankTitle.className = "funpack-adj-bank-title"; + bankTitle.textContent = `Session phrases (${key})`; + bankSection.append(bankTitle); + + const search = document.createElement("input"); + search.type = "search"; + search.placeholder = "Search learned phrases"; + search.className = "funpack-adj-search"; + bankSection.append(search); + + const bank = document.createElement("div"); + bank.className = "funpack-adj-bank"; + const loading = document.createElement("div"); + loading.className = "funpack-adj-empty"; + loading.textContent = "Loading..."; + bank.append(loading); + bankSection.append(bank); + root.append(bankSection); + + fetchPhraseMemory(key).then((phrases) => { + const renderBank = () => { + const q = search.value.toLowerCase().trim(); + const filtered = q ? phrases.filter((p) => p.text.toLowerCase().includes(q)) : phrases; + bank.replaceChildren(); + if (!filtered.length) { + const empty = document.createElement("div"); + empty.className = "funpack-adj-empty"; + empty.textContent = phrases.length ? "No matches." : "No learned phrases in this session yet."; + bank.append(empty); + return; + } + const byCategory = new Map(); + for (const p of filtered) { + const cat = CATEGORY_ORDER.includes(p.category) ? p.category : "details"; + if (!byCategory.has(cat)) byCategory.set(cat, []); + byCategory.get(cat).push(p); + } + for (const cat of CATEGORY_ORDER) { + const group = byCategory.get(cat); + if (!group?.length) continue; + const label = document.createElement("div"); + label.className = "funpack-adj-cat-label"; + label.textContent = cat; + bank.append(label); + const chipRow = document.createElement("div"); + chipRow.className = "funpack-adj-chip-row"; + for (const p of group) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "funpack-adj-chip"; + chip.textContent = p.text; + chip.title = `${p.text} (seen ${p.evidence}x)`; + chip.addEventListener("click", () => { + if (!items.find((i) => i.phrase.trim().toLowerCase() === p.text.toLowerCase())) { + items.push({ phrase: p.text, strength: 0.1 }); + renderRows(); + } + }); + chipRow.append(chip); + } + bank.append(chipRow); + } + }; + renderBank(); + search.addEventListener("input", renderBank); + }); + } +} + +let stylesInjected = false; +function injectStyles() { + if (stylesInjected) return; + stylesInjected = true; + const style = document.createElement("style"); + style.textContent = ` + .funpack-adj-panel { + width: min(440px, calc(100vw - 24px)); + max-height: min(580px, calc(100vh - 24px)); + display: flex; + flex-direction: column; + padding: 10px; + border: 1px solid rgba(180,190,200,0.35); + border-radius: 8px; + background: rgba(30,32,36,0.98); + box-shadow: 0 18px 48px rgba(0,0,0,0.42); + color: #ddd; + font: 12px sans-serif; + box-sizing: border-box; + overflow: hidden; + } + .funpack-adj-panel * { box-sizing: border-box; } + .funpack-adj-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 700; + font-size: 14px; + padding-bottom: 8px; + margin-bottom: 4px; + border-bottom: 1px solid rgba(255,255,255,0.08); + flex-shrink: 0; + } + .funpack-adj-hint { + color: #9da6b0; + font-size: 11px; + margin-bottom: 8px; + line-height: 1.4; + flex-shrink: 0; + } + .funpack-adj-list { + overflow-y: auto; + min-height: 40px; + max-height: 200px; + display: flex; + flex-direction: column; + gap: 5px; + padding-right: 3px; + flex-shrink: 0; + } + .funpack-adj-empty { color: #9da6b0; padding: 6px 0; } + .funpack-adj-row { + display: grid; + grid-template-columns: minmax(0,1fr) 74px 28px; + gap: 6px; + align-items: center; + } + .funpack-adj-phrase, + .funpack-adj-strength { + min-height: 28px; + padding: 5px 7px; + border: 1px solid rgba(180,190,200,0.28); + border-radius: 5px; + background: #17191d; + color: #f2f2f2; + outline: none; + width: 100%; + } + .funpack-adj-strength { text-align: right; } + .funpack-adj-footer { + display: flex; + gap: 7px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.08); + flex-shrink: 0; + } + .funpack-adj-button { + min-height: 26px; + padding: 4px 10px; + border: 1px solid rgba(180,190,200,0.35); + border-radius: 5px; + background: #22252a; + color: #eee; + cursor: pointer; + white-space: nowrap; + } + .funpack-adj-button:hover { background: #2b3037; } + .funpack-adj-button.primary { border-color: rgba(100,210,140,0.6); background: #244832; } + .funpack-adj-button.danger { border-color: rgba(255,130,130,0.45); background: #472626; } + .funpack-adj-button.compact { min-height: 24px; padding: 2px 7px; } + .funpack-adj-bank-section { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.08); + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + .funpack-adj-bank-title { + font-weight: 700; + color: #58a6d6; + margin-bottom: 5px; + font-size: 10px; + text-transform: uppercase; + flex-shrink: 0; + } + .funpack-adj-search { + width: 100%; + min-height: 28px; + padding: 5px 7px; + border: 1px solid rgba(180,190,200,0.28); + border-radius: 5px; + background: #17191d; + color: #f2f2f2; + outline: none; + margin-bottom: 6px; + flex-shrink: 0; + } + .funpack-adj-bank { + overflow-y: auto; + flex: 1; + min-height: 0; + } + .funpack-adj-cat-label { + color: #58a6d6; + font-weight: 700; + font-size: 10px; + text-transform: uppercase; + margin: 6px 0 3px; + } + .funpack-adj-chip-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 2px; } + .funpack-adj-chip { + padding: 3px 8px; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 5px; + background: rgba(255,255,255,0.07); + color: #eee; + cursor: pointer; + font: 11px sans-serif; + } + .funpack-adj-chip:hover { background: rgba(100,210,140,0.25); border-color: rgba(100,210,140,0.5); } + `; + document.head.append(style); +} + +function setupNode(node) { + const hideInternal = () => { + for (const w of node.widgets || []) { + if (HIDDEN_WIDGETS.has(w.name)) hideWidget(w); + } + node.setDirtyCanvas?.(true, true); + app.graph?.setDirtyCanvas?.(true, true); + }; + hideInternal(); + window.requestAnimationFrame?.(hideInternal); + window.setTimeout(hideInternal, 0); + window.setTimeout(hideInternal, 250); + + // Remove any previously added button to avoid duplicates on reconfigure + node.widgets = (node.widgets || []).filter((w) => w.name !== "funpack_adj_open"); + + node.addWidget("button", "Edit Adjustments", "funpack_adj_open", () => { + renderPanel(node); + }, { serialize: false }); + + node.setDirtyCanvas?.(true, true); +} + +app.registerExtension({ + name: "funpack.conditioningAdjust", + beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== NODE_NAME) return; + const orig = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + orig?.apply(this, arguments); + setupNode(this); + }; + const origCfg = nodeType.prototype.configure; + nodeType.prototype.configure = function (info) { + origCfg?.apply(this, arguments); + setupNode(this); + }; + }, +});