Add FunPackStudio: unified node for all FunPack refinement tools
FunPackStudio combines Refinement Key, Scene Builder, Refiner V2, Advisor LLM, LoRA loading, and Conditioning Adjust under a single node with a tabbed popup editor. Rating widget stays on the node face; everything else is in the popup. Python: - FunPackStudio.run() chains all subsystems: scene builder prompt construction, advisor LLM load (using shared cache), LoRA loading via FunPackLoraLoader, FunPackVideoRefinerV2.refine_v2(), and FunPackConditioningAdjust.adjust(). No code duplication. - Added optional _seed parameter to FunPackVideoRefinerV2.refine_v2() so Studio can generate a seed and output it for wiring to samplers. - Added /funpack/available_loras endpoint for the LoRA picker. JS (funpack_studio.js): - Tabbed popup: Session, Scene, Refiner, Advisor, LoRA, Adjustments. - Session: refinement key, reset toggle, scene builder mode selector. - Scene: scene load/save, phrase bank from memory, positive prompt editor (active when scene builder is not Pass-through). - Refiner: all V2 settings, feedback prompt, intent override. - Advisor: LLM enable toggle, model path, dtype. - LoRA: dynamic list of LoRAs with name/type/model-weight/clip-weight, loaded from /funpack/available_loras. - Adjustments: phrase/strength pairs with session phrase bank. - Settings serialized as JSON in hidden studio_settings widget. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
3281075307
commit
cb22c8113f
@@ -8,6 +8,7 @@ if __package__:
|
||||
FunPackPromptCombiner,
|
||||
FunPackPromptEnhancer,
|
||||
FunPackStoryWriter,
|
||||
FunPackStudio,
|
||||
FunPackVideoRefinerV2,
|
||||
)
|
||||
from .image_processing import (
|
||||
@@ -30,6 +31,7 @@ else:
|
||||
FunPackPromptCombiner,
|
||||
FunPackPromptEnhancer,
|
||||
FunPackStoryWriter,
|
||||
FunPackStudio,
|
||||
FunPackVideoRefinerV2,
|
||||
)
|
||||
try:
|
||||
@@ -71,6 +73,7 @@ WEB_DIRECTORY = "./web"
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FunPackAdvisorLLM": FunPackAdvisorLLM,
|
||||
"FunPackConditioningAdjust": FunPackConditioningAdjust,
|
||||
"FunPackStudio": FunPackStudio,
|
||||
"FunPackPromptCombiner": FunPackPromptCombiner,
|
||||
"FunPackStoryMemKeyframeExtractor": FunPackStoryMemKeyframeExtractor,
|
||||
"FunPackStoryMemLastFrameExtractor": FunPackStoryMemLastFrameExtractor,
|
||||
@@ -94,6 +97,7 @@ NODE_CLASS_MAPPINGS = {name: cls for name, cls in NODE_CLASS_MAPPINGS.items() if
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FunPackAdvisorLLM": "FunPack Advisor LLM",
|
||||
"FunPackConditioningAdjust": "FunPack Conditioning Adjust",
|
||||
"FunPackStudio": "FunPack Studio",
|
||||
"FunPackPromptCombiner": "FunPack Prompt Combiner",
|
||||
"FunPackStoryMemKeyframeExtractor": "FunPack StoryMem Keyframe Extractor",
|
||||
"FunPackStoryMemLastFrameExtractor": "FunPack StoryMem Last Frame Extractor",
|
||||
|
||||
+183
-2
@@ -10347,8 +10347,8 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
|
||||
reset_session=False, lora_stack=None, im_feeling_lucky=False, user_intent_prompt="",
|
||||
refinement_key_input="", positive_conditioning=None, clip_vision_output=None,
|
||||
source_image=None, model=None, mode="Refine", advisor_mode="Off", advisor_thinking=True,
|
||||
advisor_clip=None, feedback_prompt="", prompt_repair=True):
|
||||
seed = random.randint(1, 0xffffffffffffffff)
|
||||
advisor_clip=None, feedback_prompt="", prompt_repair=True, _seed=None):
|
||||
seed = int(_seed) if _seed is not None else random.randint(1, 0xffffffffffffffff)
|
||||
encode_cache = {}
|
||||
linked_refinement_key = str(refinement_key_input or "").strip()
|
||||
if linked_refinement_key:
|
||||
@@ -11852,3 +11852,184 @@ class FunPackConditioningAdjust:
|
||||
|
||||
status = "Conditioning adjust: " + (", ".join(log) if log else "nothing applied")
|
||||
return (new_conditioning, status)
|
||||
|
||||
|
||||
class FunPackStudio:
|
||||
"""Single node combining Refinement Key, Refiner V2, Scene Builder,
|
||||
Advisor LLM, LoRA management, and Conditioning Adjust under one UI."""
|
||||
|
||||
CATEGORY = "FunPack"
|
||||
RETURN_TYPES = ("CONDITIONING", "STRING", "STRING", "IMAGE", "STRING", "MODEL", "INT")
|
||||
RETURN_NAMES = ("modified_positive", "status", "training_info", "loss_graph", "encoded_prompts", "model", "seed")
|
||||
FUNCTION = "run"
|
||||
DESCRIPTION = (
|
||||
"FunPack Studio - all FunPack refinement tools in one node. "
|
||||
"Rating widget stays on the node face; everything else lives in the popup editor."
|
||||
)
|
||||
|
||||
_lora_loader = None
|
||||
|
||||
@classmethod
|
||||
def _get_lora_loader(cls):
|
||||
if cls._lora_loader is None:
|
||||
try:
|
||||
try:
|
||||
from .model_management import FunPackLoraLoader
|
||||
except ImportError:
|
||||
from model_management import FunPackLoraLoader
|
||||
cls._lora_loader = FunPackLoraLoader()
|
||||
except Exception:
|
||||
cls._lora_loader = None
|
||||
return cls._lora_loader
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"positive_prompt": ("STRING", {
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": "Positive prompt. Ignored when Scene Builder mode is not Pass-through.",
|
||||
}),
|
||||
"rating": (V2_RATING_LABELS, {"default": "Missing action", "label": "Rating"}),
|
||||
"studio_settings": ("STRING", {
|
||||
"default": "{}",
|
||||
"multiline": False,
|
||||
"tooltip": "JSON managed by the Studio popup editor. Do not edit manually.",
|
||||
}),
|
||||
"adjustments": ("STRING", {
|
||||
"default": "[]",
|
||||
"multiline": False,
|
||||
"tooltip": "Conditioning adjustment phrase list. Managed by the Studio popup.",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"clip": ("CLIP", {"tooltip": "Text encoder. Required for prompt encoding and conditioning adjustments."}),
|
||||
"model": ("MODEL", {"tooltip": "Diffusion model. Required for LoRA loading and direction injection."}),
|
||||
"source_image": ("IMAGE",),
|
||||
"clip_vision_output": ("CLIP_VISION_OUTPUT",),
|
||||
"lora_stack": ("FUNPACK_LORA_STACK", {"tooltip": "Optional external LoRA stack. Takes precedence over LoRAs configured inside Studio."}),
|
||||
"refinement_key_input": ("STRING", {"forceInput": True}),
|
||||
"user_intent_prompt": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
|
||||
"positive_conditioning": ("CONDITIONING",),
|
||||
},
|
||||
}
|
||||
|
||||
def run(self, positive_prompt, rating, studio_settings, adjustments,
|
||||
clip=None, model=None, source_image=None, clip_vision_output=None,
|
||||
lora_stack=None, refinement_key_input="", user_intent_prompt="",
|
||||
positive_conditioning=None):
|
||||
|
||||
try:
|
||||
settings = json.loads(str(studio_settings or "{}"))
|
||||
except Exception:
|
||||
settings = {}
|
||||
|
||||
seed = random.randint(1, 0xffffffffffffffff)
|
||||
|
||||
# --- Scene Builder ---
|
||||
sb = settings.get("scene_builder", {}) if isinstance(settings.get("scene_builder"), dict) else {}
|
||||
sb_mode = str(sb.get("mode", "Pass-through")).strip()
|
||||
if sb_mode == "Pass-through" or not sb_mode:
|
||||
active_prompt = str(positive_prompt or "").strip()
|
||||
else:
|
||||
active_prompt = str(sb.get("scene_positive", "") or "").strip() or str(positive_prompt or "").strip()
|
||||
|
||||
# --- Refiner settings ---
|
||||
rf = settings.get("refiner", {}) if isinstance(settings.get("refiner"), dict) else {}
|
||||
mode = str(rf.get("mode", "Refine") or "Refine")
|
||||
advisor_mode = str(rf.get("advisor_mode", "Off") or "Off")
|
||||
advisor_thinking = bool(rf.get("advisor_thinking", True))
|
||||
prompt_repair = bool(rf.get("prompt_repair", True))
|
||||
im_feeling_lucky = bool(rf.get("im_feeling_lucky", False))
|
||||
reset_session = bool(rf.get("reset_session", False))
|
||||
feedback_prompt = str(rf.get("feedback_prompt", "") or "")
|
||||
intent_override = str(rf.get("user_intent_prompt_override", "") or "")
|
||||
effective_intent = intent_override or str(user_intent_prompt or "")
|
||||
|
||||
# --- Refinement key ---
|
||||
key = str(settings.get("refinement_key", "") or "").strip()
|
||||
|
||||
# --- Advisor LLM ---
|
||||
advisor_clip = None
|
||||
llm_cfg = settings.get("advisor_llm", {})
|
||||
if isinstance(llm_cfg, dict) and llm_cfg.get("enabled") and llm_cfg.get("model_path"):
|
||||
try:
|
||||
(advisor_clip,) = FunPackAdvisorLLM().load_advisor(
|
||||
str(llm_cfg["model_path"]),
|
||||
str(llm_cfg.get("dtype", "bfloat16")),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[FunPackStudio] Advisor LLM load failed: {e}")
|
||||
|
||||
# --- LoRA stack ---
|
||||
active_lora_stack = lora_stack
|
||||
studio_loras = settings.get("loras", [])
|
||||
if not active_lora_stack and studio_loras and model is not None:
|
||||
lora_entries = []
|
||||
for idx, entry in enumerate(studio_loras if isinstance(studio_loras, list) else []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = str(entry.get("name", "") or "").strip()
|
||||
if not name or name == "None":
|
||||
continue
|
||||
mw = float(entry.get("model_weight", 1.0) or 1.0)
|
||||
cw = float(entry.get("clip_weight", 1.0) or 1.0)
|
||||
lora_entries.append({
|
||||
"slot": idx,
|
||||
"name": name,
|
||||
"type": str(entry.get("type", "general") or "general"),
|
||||
"base_model_weight": mw,
|
||||
"base_clip_weight": cw,
|
||||
"model_weight": mw,
|
||||
"clip_weight": cw,
|
||||
})
|
||||
if lora_entries:
|
||||
raw_stack = {"refinement_key": key, "loras": lora_entries, "per_block": False}
|
||||
loader = self._get_lora_loader()
|
||||
if loader is not None:
|
||||
try:
|
||||
model, clip, active_lora_stack, lora_status = loader.load_loras(
|
||||
model, raw_stack, clip=clip
|
||||
)
|
||||
print(f"[FunPackStudio] {lora_status}")
|
||||
except Exception as e:
|
||||
print(f"[FunPackStudio] LoRA load failed: {e}")
|
||||
active_lora_stack = raw_stack
|
||||
|
||||
# --- Refiner V2 ---
|
||||
refiner = FunPackVideoRefinerV2()
|
||||
cond, status, training_info, loss_graph, encoded_prompts, out_model = refiner.refine_v2(
|
||||
active_prompt,
|
||||
clip=clip,
|
||||
rating=rating,
|
||||
refinement_key=key,
|
||||
reset_session=reset_session,
|
||||
lora_stack=active_lora_stack,
|
||||
im_feeling_lucky=im_feeling_lucky,
|
||||
user_intent_prompt=effective_intent,
|
||||
refinement_key_input=refinement_key_input,
|
||||
positive_conditioning=positive_conditioning,
|
||||
clip_vision_output=clip_vision_output,
|
||||
source_image=source_image,
|
||||
model=model,
|
||||
mode=mode,
|
||||
advisor_mode=advisor_mode,
|
||||
advisor_thinking=advisor_thinking,
|
||||
advisor_clip=advisor_clip,
|
||||
feedback_prompt=feedback_prompt,
|
||||
prompt_repair=prompt_repair,
|
||||
_seed=seed,
|
||||
)
|
||||
|
||||
# --- Conditioning Adjust ---
|
||||
adj_raw = str(adjustments or "[]").strip()
|
||||
if adj_raw not in ("[]", "", "null") and clip is not None:
|
||||
try:
|
||||
adj_node = FunPackConditioningAdjust()
|
||||
cond, adj_status = adj_node.adjust(cond, clip, adj_raw)
|
||||
status = f"{status}\n{adj_status}"
|
||||
except Exception as e:
|
||||
status = f"{status}\nConditioning adjust failed: {e}"
|
||||
|
||||
return (cond, status, training_info, loss_graph, encoded_prompts, out_model, seed)
|
||||
|
||||
@@ -1088,6 +1088,19 @@ async def funpack_refinement_keys_import(request):
|
||||
return web.json_response({"imported": key, "keys": refinement_key_names()})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/funpack/available_loras")
|
||||
async def funpack_available_loras(request):
|
||||
try:
|
||||
import folder_paths as _fp
|
||||
loras = _fp.get_filename_list("loras")
|
||||
except Exception:
|
||||
loras = []
|
||||
return web.json_response(
|
||||
{"loras": sorted(loras)},
|
||||
headers={"Cache-Control": "no-store, max-age=0"},
|
||||
)
|
||||
|
||||
|
||||
@PromptServer.instance.routes.get("/funpack/phrase_memory")
|
||||
async def funpack_phrase_memory(request):
|
||||
key = normalize_refinement_key(request.query.get("key", ""))
|
||||
|
||||
@@ -0,0 +1,827 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { api } from "../../scripts/api.js";
|
||||
|
||||
const NODE_NAME = "FunPackStudio";
|
||||
const NONE_SENTINEL = "-None-";
|
||||
const HIDDEN_WIDGETS = new Set(["studio_settings", "adjustments", "positive_prompt"]);
|
||||
const LORA_TYPES = ["general", "action", "concept", "style", "quality", "character"];
|
||||
const ADVISOR_DTYPES = ["bfloat16", "float16", "float32"];
|
||||
const REFINER_MODES = ["Refine", "Prompt only", "Learning"];
|
||||
const ADVISOR_MODES = ["Off", "Only diagnostics", "Only prompt", "Full"];
|
||||
const SB_MODES = ["Pass-through", "Manual", "Auto", "Learning"];
|
||||
const CATEGORY_ORDER = ["action", "camera", "subject", "appearance", "environment", "style", "quality", "details"];
|
||||
const TABS = ["Session", "Scene", "Refiner", "Advisor", "LoRA", "Adjustments"];
|
||||
|
||||
let activePanel = null;
|
||||
let studioSceneData = null;
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function widgetByName(node, name) {
|
||||
return (node.widgets || []).find((w) => w.name === name);
|
||||
}
|
||||
|
||||
function getSettings(node) {
|
||||
try { return JSON.parse(String(widgetByName(node, "studio_settings")?.value || "{}")); }
|
||||
catch { return {}; }
|
||||
}
|
||||
|
||||
function saveSettings(node, settings) {
|
||||
const w = widgetByName(node, "studio_settings");
|
||||
if (w) w.value = JSON.stringify(settings);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function getAdjustments(node) {
|
||||
try { const v = JSON.parse(String(widgetByName(node, "adjustments")?.value || "[]")); return Array.isArray(v) ? v : []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function saveAdjustments(node, items) {
|
||||
const w = widgetByName(node, "adjustments");
|
||||
if (w) w.value = JSON.stringify(items);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
app.graph?.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function deepMerge(target, src) {
|
||||
const out = { ...target };
|
||||
for (const k of Object.keys(src)) {
|
||||
if (src[k] && typeof src[k] === "object" && !Array.isArray(src[k]) && typeof target[k] === "object")
|
||||
out[k] = deepMerge(target[k], src[k]);
|
||||
else
|
||||
out[k] = src[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function defaultSettings() {
|
||||
return {
|
||||
refinement_key: "",
|
||||
scene_builder: { mode: "Pass-through", scene: NONE_SENTINEL, scene_name: "", aliases: "", scene_positive: "", scene_negative: "" },
|
||||
refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, prompt_repair: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "" },
|
||||
advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" },
|
||||
loras: [],
|
||||
};
|
||||
}
|
||||
|
||||
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 src = link ? app.graph?.getNodeById?.(link.origin_id) : null;
|
||||
if (!src) return "";
|
||||
const sel = String(widgetByName(src, "refinement_key")?.value || "").trim();
|
||||
const typed = String(widgetByName(src, "key_name")?.value || "").trim();
|
||||
return (sel && sel !== NONE_SENTINEL ? sel : typed) || "";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API calls ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchScenes(key = "") {
|
||||
try {
|
||||
const params = new URLSearchParams({ cache_bust: Date.now() });
|
||||
if (key) params.set("key", key);
|
||||
const res = await api.fetchApi(`/funpack/scenes?${params}`, { cache: "no-store" });
|
||||
if (!res.ok) return null;
|
||||
studioSceneData = await res.json();
|
||||
return studioSceneData;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async function saveScene(key, payload) {
|
||||
const params = key ? `?key=${encodeURIComponent(key)}` : "";
|
||||
const res = await api.fetchApi(`/funpack/scenes/scene${params}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "save", ...payload }),
|
||||
});
|
||||
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error || `HTTP ${res.status}`); }
|
||||
}
|
||||
|
||||
async function fetchLoras() {
|
||||
try {
|
||||
const res = await api.fetchApi("/funpack/available_loras", { cache: "no-store" });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return Array.isArray(data.loras) ? data.loras : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
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 []; }
|
||||
}
|
||||
|
||||
// ─── DOM helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function el(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text != null) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
function btn(label, cls = "") {
|
||||
const b = el("button", `funpack-studio-btn ${cls}`.trim(), label);
|
||||
b.type = "button";
|
||||
return b;
|
||||
}
|
||||
|
||||
function textInput(value, placeholder, cls = "") {
|
||||
const i = el("input", `funpack-studio-input ${cls}`.trim());
|
||||
i.type = "text";
|
||||
i.value = String(value || "");
|
||||
i.placeholder = placeholder || "";
|
||||
return i;
|
||||
}
|
||||
|
||||
function numInput(value, min, max, step, cls = "") {
|
||||
const i = el("input", `funpack-studio-input ${cls}`.trim());
|
||||
i.type = "number";
|
||||
i.value = String(Number.isFinite(+value) ? +value : 0);
|
||||
i.min = String(min); i.max = String(max); i.step = String(step);
|
||||
return i;
|
||||
}
|
||||
|
||||
function selectEl(values, selected, cls = "") {
|
||||
const s = el("select", `funpack-studio-select ${cls}`.trim());
|
||||
for (const v of values) {
|
||||
const o = el("option", "", v);
|
||||
o.value = v;
|
||||
o.selected = v === selected;
|
||||
s.append(o);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function toggleEl(checked, label) {
|
||||
const wrap = el("label", "funpack-studio-toggle");
|
||||
const inp = el("input");
|
||||
inp.type = "checkbox";
|
||||
inp.checked = Boolean(checked);
|
||||
const span = el("span", "", label);
|
||||
wrap.append(inp, span);
|
||||
return { wrap, inp };
|
||||
}
|
||||
|
||||
function row(label, control, cls = "") {
|
||||
const r = el("div", `funpack-studio-row ${cls}`.trim());
|
||||
const lbl = el("span", "funpack-studio-row-label", label);
|
||||
r.append(lbl, control);
|
||||
return r;
|
||||
}
|
||||
|
||||
function sectionTitle(text) { return el("div", "funpack-studio-section-title", text); }
|
||||
|
||||
// ─── Panel ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function closePanel() { activePanel?.remove(); activePanel = null; }
|
||||
|
||||
function showError(panel, msg) {
|
||||
const tgt = panel?.querySelector("[data-role='error']");
|
||||
if (tgt) tgt.textContent = msg;
|
||||
}
|
||||
|
||||
function openPanel(node) {
|
||||
closePanel();
|
||||
injectStyles();
|
||||
|
||||
const root = el("div", "funpack-studio-panel");
|
||||
root.style.cssText = "position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:10000;";
|
||||
document.body.append(root);
|
||||
activePanel = root;
|
||||
|
||||
const settings = deepMerge(defaultSettings(), getSettings(node));
|
||||
const adjItems = getAdjustments(node).map((i) => ({ ...i }));
|
||||
let activeTab = "Session";
|
||||
|
||||
// Header
|
||||
const header = el("div", "funpack-studio-header");
|
||||
const titleEl = el("div", "funpack-studio-title", "FunPack Studio");
|
||||
const closeBtn = btn("Close");
|
||||
closeBtn.addEventListener("click", () => {
|
||||
saveSettings(node, settings);
|
||||
saveAdjustments(node, adjItems.filter((i) => String(i.phrase || "").trim()));
|
||||
closePanel();
|
||||
});
|
||||
header.append(titleEl, closeBtn);
|
||||
root.append(header);
|
||||
|
||||
const errorEl = el("div", "funpack-studio-error");
|
||||
errorEl.dataset.role = "error";
|
||||
root.append(errorEl);
|
||||
|
||||
// Tab bar
|
||||
const tabBar = el("div", "funpack-studio-tabs");
|
||||
const body = el("div", "funpack-studio-body");
|
||||
root.append(tabBar, body);
|
||||
|
||||
function switchTab(name) {
|
||||
activeTab = name;
|
||||
for (const t of tabBar.querySelectorAll(".funpack-studio-tab"))
|
||||
t.classList.toggle("active", t.dataset.tab === name);
|
||||
renderTab(name);
|
||||
}
|
||||
|
||||
for (const t of TABS) {
|
||||
const tabBtn = el("button", "funpack-studio-tab", t);
|
||||
tabBtn.type = "button";
|
||||
tabBtn.dataset.tab = t;
|
||||
tabBtn.addEventListener("click", () => switchTab(t));
|
||||
tabBar.append(tabBtn);
|
||||
}
|
||||
|
||||
// ── TAB RENDERERS ──────────────────────────────────────────────────────────
|
||||
|
||||
function renderTab(name) {
|
||||
body.replaceChildren();
|
||||
errorEl.textContent = "";
|
||||
if (name === "Session") renderSession();
|
||||
else if (name === "Scene") renderScene();
|
||||
else if (name === "Refiner") renderRefiner();
|
||||
else if (name === "Advisor") renderAdvisor();
|
||||
else if (name === "LoRA") renderLora();
|
||||
else if (name === "Adjustments") renderAdjustments();
|
||||
}
|
||||
|
||||
// SESSION ──────────────────────────────────────────────────────────────────
|
||||
function renderSession() {
|
||||
body.append(sectionTitle("Refinement Session"));
|
||||
|
||||
const keyInput = textInput(settings.refinement_key, "session key name");
|
||||
keyInput.addEventListener("input", () => { settings.refinement_key = keyInput.value.trim(); });
|
||||
|
||||
const linkedKey = linkedRefinementKey(node);
|
||||
if (linkedKey) {
|
||||
const hint = el("div", "funpack-studio-hint", `Linked key from node input: ${linkedKey} (takes precedence at runtime)`);
|
||||
body.append(hint);
|
||||
}
|
||||
|
||||
body.append(row("Session key", keyInput));
|
||||
|
||||
const resetToggle = toggleEl(settings.refiner.reset_session, "Reset session on next run");
|
||||
resetToggle.inp.addEventListener("change", () => { settings.refiner.reset_session = resetToggle.inp.checked; });
|
||||
body.append(row("Reset", resetToggle.wrap));
|
||||
|
||||
body.append(sectionTitle("Scene Builder"));
|
||||
const modeSelect = selectEl(SB_MODES, settings.scene_builder.mode);
|
||||
modeSelect.addEventListener("change", () => { settings.scene_builder.mode = modeSelect.value; renderSession(); });
|
||||
body.append(row("Mode", modeSelect));
|
||||
|
||||
if (settings.scene_builder.mode !== "Pass-through") {
|
||||
const hint = el("div", "funpack-studio-hint",
|
||||
"Scene Builder active - prompt is built from the Scene tab, not from the positive_prompt input.");
|
||||
body.append(hint);
|
||||
const toScene = btn("Open Scene tab →", "secondary");
|
||||
toScene.addEventListener("click", () => switchTab("Scene"));
|
||||
body.append(toScene);
|
||||
} else {
|
||||
const hint = el("div", "funpack-studio-hint",
|
||||
"Pass-through - the positive_prompt connected to the node is used as-is.");
|
||||
body.append(hint);
|
||||
}
|
||||
}
|
||||
|
||||
// SCENE ────────────────────────────────────────────────────────────────────
|
||||
function renderScene() {
|
||||
body.append(sectionTitle("Scene Builder"));
|
||||
|
||||
if (settings.scene_builder.mode === "Pass-through") {
|
||||
body.append(el("div", "funpack-studio-hint",
|
||||
"Scene Builder is in Pass-through mode. Change mode in the Session tab to enable scene construction."));
|
||||
return;
|
||||
}
|
||||
|
||||
const key = settings.refinement_key || linkedRefinementKey(node);
|
||||
|
||||
// Scene name + scene selector
|
||||
const sceneNames = studioSceneData?.scenes || [NONE_SENTINEL];
|
||||
const sceneSelect = selectEl(sceneNames, settings.scene_builder.scene || NONE_SENTINEL);
|
||||
sceneSelect.addEventListener("change", () => {
|
||||
settings.scene_builder.scene = sceneSelect.value;
|
||||
const scene = studioSceneData?.data?.scenes?.[sceneSelect.value];
|
||||
if (scene) {
|
||||
settings.scene_builder.scene_name = scene.name || sceneSelect.value;
|
||||
settings.scene_builder.aliases = Array.isArray(scene.aliases) ? scene.aliases.join(", ") : "";
|
||||
settings.scene_builder.scene_positive = scene.positive_text || "";
|
||||
settings.scene_builder.scene_negative = scene.negative_text || "";
|
||||
renderScene();
|
||||
}
|
||||
});
|
||||
body.append(row("Saved scene", sceneSelect));
|
||||
|
||||
const nameInput = textInput(settings.scene_builder.scene_name, "Scene name");
|
||||
nameInput.addEventListener("input", () => { settings.scene_builder.scene_name = nameInput.value; });
|
||||
body.append(row("Scene name", nameInput));
|
||||
|
||||
const aliasInput = textInput(settings.scene_builder.aliases, "Aliases, comma separated");
|
||||
aliasInput.addEventListener("input", () => { settings.scene_builder.aliases = aliasInput.value; });
|
||||
body.append(row("Aliases", aliasInput));
|
||||
|
||||
body.append(sectionTitle("Positive prompt"));
|
||||
const posArea = el("textarea", "funpack-studio-textarea");
|
||||
posArea.value = settings.scene_builder.scene_positive || "";
|
||||
posArea.placeholder = "Positive prompt phrases...";
|
||||
posArea.addEventListener("input", () => { settings.scene_builder.scene_positive = posArea.value; });
|
||||
body.append(posArea);
|
||||
|
||||
// Memory phrase bank
|
||||
const memItems = studioSceneData?.memory || [];
|
||||
if (memItems.length) {
|
||||
body.append(sectionTitle("Phrase bank - click to insert"));
|
||||
const search = el("input", "funpack-studio-search");
|
||||
search.type = "search";
|
||||
search.placeholder = "Search phrases";
|
||||
body.append(search);
|
||||
|
||||
const bank = el("div", "funpack-studio-bank");
|
||||
const renderBank = () => {
|
||||
const q = search.value.toLowerCase().trim();
|
||||
const filtered = q ? memItems.filter((m) => String(m.text || "").toLowerCase().includes(q)) : memItems;
|
||||
bank.replaceChildren();
|
||||
const byGroup = new Map();
|
||||
for (const m of filtered) {
|
||||
const cat = CATEGORY_ORDER.includes(m.category) ? m.category : "details";
|
||||
if (!byGroup.has(cat)) byGroup.set(cat, []);
|
||||
byGroup.get(cat).push(m);
|
||||
}
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
const g = byGroup.get(cat);
|
||||
if (!g?.length) continue;
|
||||
bank.append(el("div", "funpack-studio-cat-label", cat));
|
||||
const chipRow = el("div", "funpack-studio-chip-row");
|
||||
for (const m of g) {
|
||||
const chip = el("button", "funpack-studio-chip", m.text);
|
||||
chip.type = "button";
|
||||
chip.addEventListener("click", () => {
|
||||
const cur = posArea.value.trim();
|
||||
posArea.value = cur ? `${cur}, ${m.text}` : m.text;
|
||||
settings.scene_builder.scene_positive = posArea.value;
|
||||
});
|
||||
chipRow.append(chip);
|
||||
}
|
||||
bank.append(chipRow);
|
||||
}
|
||||
};
|
||||
renderBank();
|
||||
search.addEventListener("input", renderBank);
|
||||
body.append(bank);
|
||||
}
|
||||
|
||||
// Save/delete scene buttons
|
||||
const footer = el("div", "funpack-studio-footer");
|
||||
const refreshBtn = btn("Refresh");
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
await fetchScenes(key);
|
||||
renderScene();
|
||||
});
|
||||
const saveBtn = btn("Save scene", "primary");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await saveScene(key, {
|
||||
name: settings.scene_builder.scene_name,
|
||||
aliases: settings.scene_builder.aliases,
|
||||
mode: settings.scene_builder.mode,
|
||||
positive_text: settings.scene_builder.scene_positive,
|
||||
negative_text: settings.scene_builder.scene_negative,
|
||||
});
|
||||
await fetchScenes(key);
|
||||
renderScene();
|
||||
} catch (e) { showError(root, e.message); }
|
||||
});
|
||||
footer.append(refreshBtn, saveBtn);
|
||||
body.append(footer);
|
||||
|
||||
// Fetch scenes if not loaded
|
||||
if (!studioSceneData) {
|
||||
fetchScenes(key).then(() => renderScene());
|
||||
}
|
||||
}
|
||||
|
||||
// REFINER ──────────────────────────────────────────────────────────────────
|
||||
function renderRefiner() {
|
||||
body.append(sectionTitle("Execution"));
|
||||
|
||||
const modeSelect = selectEl(REFINER_MODES, settings.refiner.mode);
|
||||
modeSelect.addEventListener("change", () => { settings.refiner.mode = modeSelect.value; });
|
||||
body.append(row("Mode", modeSelect));
|
||||
|
||||
const advisorSelect = selectEl(ADVISOR_MODES, settings.refiner.advisor_mode);
|
||||
advisorSelect.addEventListener("change", () => { settings.refiner.advisor_mode = advisorSelect.value; });
|
||||
body.append(row("Advisor mode", advisorSelect));
|
||||
|
||||
const thinkToggle = toggleEl(settings.refiner.advisor_thinking, "Advisor thinking");
|
||||
thinkToggle.inp.addEventListener("change", () => { settings.refiner.advisor_thinking = thinkToggle.inp.checked; });
|
||||
body.append(row("Thinking", thinkToggle.wrap));
|
||||
|
||||
const repairToggle = toggleEl(settings.refiner.prompt_repair, "Prompt repair");
|
||||
repairToggle.inp.addEventListener("change", () => { settings.refiner.prompt_repair = repairToggle.inp.checked; });
|
||||
body.append(row("Prompt repair", repairToggle.wrap));
|
||||
|
||||
const luckyToggle = toggleEl(settings.refiner.im_feeling_lucky, "I'm Feeling Lucky");
|
||||
luckyToggle.inp.addEventListener("change", () => { settings.refiner.im_feeling_lucky = luckyToggle.inp.checked; });
|
||||
body.append(row("Lucky", luckyToggle.wrap));
|
||||
|
||||
body.append(sectionTitle("Feedback"));
|
||||
|
||||
const fbArea = el("textarea", "funpack-studio-textarea short");
|
||||
fbArea.value = settings.refiner.feedback_prompt || "";
|
||||
fbArea.placeholder = "Optional feedback: describe what was wrong with the previous output...";
|
||||
fbArea.addEventListener("input", () => { settings.refiner.feedback_prompt = fbArea.value; });
|
||||
body.append(fbArea);
|
||||
|
||||
body.append(sectionTitle("Intent override"));
|
||||
const intentArea = el("textarea", "funpack-studio-textarea short");
|
||||
intentArea.value = settings.refiner.user_intent_prompt_override || "";
|
||||
intentArea.placeholder = "Optional intent override (overrides the user_intent_prompt input)...";
|
||||
intentArea.addEventListener("input", () => { settings.refiner.user_intent_prompt_override = intentArea.value; });
|
||||
body.append(intentArea);
|
||||
}
|
||||
|
||||
// ADVISOR ──────────────────────────────────────────────────────────────────
|
||||
function renderAdvisor() {
|
||||
body.append(sectionTitle("Advisor LLM"));
|
||||
|
||||
const enableToggle = toggleEl(settings.advisor_llm.enabled, "Enable Advisor LLM");
|
||||
enableToggle.inp.addEventListener("change", () => {
|
||||
settings.advisor_llm.enabled = enableToggle.inp.checked;
|
||||
renderAdvisor();
|
||||
});
|
||||
body.append(row("Enable", enableToggle.wrap));
|
||||
|
||||
if (!settings.advisor_llm.enabled) {
|
||||
body.append(el("div", "funpack-studio-hint",
|
||||
"Disabled. When enabled, Studio loads the specified HuggingFace model and uses it as the advisor CLIP (same cache as the standalone Advisor LLM node)."));
|
||||
return;
|
||||
}
|
||||
|
||||
const pathInput = textInput(settings.advisor_llm.model_path,
|
||||
"huihui-ai/Huihui-Qwen3-8B-abliterated-v2");
|
||||
pathInput.addEventListener("input", () => { settings.advisor_llm.model_path = pathInput.value; });
|
||||
body.append(row("Model path", pathInput));
|
||||
|
||||
const dtypeSelect = selectEl(ADVISOR_DTYPES, settings.advisor_llm.dtype);
|
||||
dtypeSelect.addEventListener("change", () => { settings.advisor_llm.dtype = dtypeSelect.value; });
|
||||
body.append(row("Dtype", dtypeSelect));
|
||||
|
||||
body.append(el("div", "funpack-studio-hint",
|
||||
"Model is loaded on first run and cached. bfloat16 recommended for CUDA, float32 for CPU. Also set Advisor mode in the Refiner tab."));
|
||||
}
|
||||
|
||||
// LORA ─────────────────────────────────────────────────────────────────────
|
||||
function renderLora() {
|
||||
body.append(sectionTitle("LoRA List"));
|
||||
body.append(el("div", "funpack-studio-hint",
|
||||
"LoRAs are loaded in order each run. An external lora_stack input overrides this list entirely."));
|
||||
|
||||
const list = el("div", "funpack-studio-lora-list");
|
||||
|
||||
function renderLoraRows(allLoras) {
|
||||
list.replaceChildren();
|
||||
const loras = Array.isArray(settings.loras) ? settings.loras : [];
|
||||
if (!loras.length) {
|
||||
list.append(el("div", "funpack-studio-empty", "No LoRAs configured."));
|
||||
}
|
||||
for (let idx = 0; idx < loras.length; idx++) {
|
||||
const entry = loras[idx];
|
||||
const rowEl = el("div", "funpack-studio-lora-row");
|
||||
|
||||
const nameSelect = selectEl(["None", ...allLoras], entry.name || "None", "lora-name");
|
||||
nameSelect.value = (allLoras.includes(entry.name) ? entry.name : "None");
|
||||
nameSelect.addEventListener("change", () => { entry.name = nameSelect.value; });
|
||||
|
||||
const typeSelect = selectEl(LORA_TYPES, entry.type || "general", "lora-type");
|
||||
typeSelect.addEventListener("change", () => { entry.type = typeSelect.value; });
|
||||
|
||||
const mwInput = numInput(entry.model_weight ?? 1.0, -2, 2, 0.05, "lora-weight");
|
||||
mwInput.title = "Model weight";
|
||||
mwInput.addEventListener("input", () => { entry.model_weight = parseFloat(mwInput.value) || 0; });
|
||||
|
||||
const cwInput = numInput(entry.clip_weight ?? 1.0, -2, 2, 0.05, "lora-weight");
|
||||
cwInput.title = "CLIP weight";
|
||||
cwInput.addEventListener("input", () => { entry.clip_weight = parseFloat(cwInput.value) || 0; });
|
||||
|
||||
const delBtn = btn("×", "danger compact");
|
||||
delBtn.title = "Remove";
|
||||
delBtn.addEventListener("click", () => { loras.splice(idx, 1); renderLoraRows(allLoras); });
|
||||
|
||||
rowEl.append(nameSelect, typeSelect, mwInput, cwInput, delBtn);
|
||||
list.append(rowEl);
|
||||
}
|
||||
}
|
||||
|
||||
body.append(list);
|
||||
|
||||
const footer = el("div", "funpack-studio-footer");
|
||||
const addBtn = btn("+ Add LoRA", "primary");
|
||||
addBtn.addEventListener("click", async () => {
|
||||
if (!Array.isArray(settings.loras)) settings.loras = [];
|
||||
const allLoras = await fetchLoras();
|
||||
settings.loras.push({ name: "None", type: "general", model_weight: 1.0, clip_weight: 1.0 });
|
||||
renderLoraRows(allLoras);
|
||||
});
|
||||
footer.append(addBtn);
|
||||
body.append(footer);
|
||||
|
||||
fetchLoras().then((allLoras) => renderLoraRows(allLoras));
|
||||
}
|
||||
|
||||
// ADJUSTMENTS ──────────────────────────────────────────────────────────────
|
||||
function renderAdjustments() {
|
||||
body.append(sectionTitle("Conditioning Adjustments"));
|
||||
body.append(el("div", "funpack-studio-hint",
|
||||
"Each phrase is encoded by CLIP. Positive strength pushes conditioning toward that phrase, negative away. Typical range: -0.3 to +0.3."));
|
||||
|
||||
const list = el("div", "funpack-studio-adj-list");
|
||||
|
||||
function renderAdjRows() {
|
||||
list.replaceChildren();
|
||||
if (!adjItems.length) {
|
||||
list.append(el("div", "funpack-studio-empty", "No adjustments. Add a phrase or click a session chip below."));
|
||||
}
|
||||
for (let idx = 0; idx < adjItems.length; idx++) {
|
||||
const item = adjItems[idx];
|
||||
const rowEl = el("div", "funpack-studio-adj-row");
|
||||
const phraseInput = textInput(item.phrase, "phrase or word", "adj-phrase");
|
||||
phraseInput.addEventListener("input", () => { item.phrase = phraseInput.value; });
|
||||
const strengthInput = numInput(item.strength ?? 0.1, -1, 1, 0.05, "adj-strength");
|
||||
strengthInput.addEventListener("input", () => { item.strength = parseFloat(strengthInput.value) || 0; });
|
||||
const delBtn = btn("×", "danger compact");
|
||||
delBtn.addEventListener("click", () => { adjItems.splice(idx, 1); renderAdjRows(); });
|
||||
rowEl.append(phraseInput, strengthInput, delBtn);
|
||||
list.append(rowEl);
|
||||
}
|
||||
}
|
||||
renderAdjRows();
|
||||
body.append(list);
|
||||
|
||||
const footer = el("div", "funpack-studio-footer");
|
||||
const addBtn = btn("+ Add phrase", "primary");
|
||||
addBtn.addEventListener("click", () => {
|
||||
adjItems.push({ phrase: "", strength: 0.1 });
|
||||
renderAdjRows();
|
||||
list.querySelectorAll(".adj-phrase")[adjItems.length - 1]?.focus();
|
||||
});
|
||||
const clearBtn = btn("Clear all", "danger");
|
||||
clearBtn.addEventListener("click", () => { adjItems.length = 0; renderAdjRows(); });
|
||||
footer.append(addBtn, clearBtn);
|
||||
body.append(footer);
|
||||
|
||||
// Session phrase bank
|
||||
const key = settings.refinement_key || linkedRefinementKey(node);
|
||||
if (key) {
|
||||
body.append(sectionTitle(`Session phrases (${key})`));
|
||||
const search = el("input", "funpack-studio-search");
|
||||
search.type = "search";
|
||||
search.placeholder = "Search learned phrases";
|
||||
body.append(search);
|
||||
const bank = el("div", "funpack-studio-bank");
|
||||
body.append(bank);
|
||||
|
||||
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) {
|
||||
bank.append(el("div", "funpack-studio-empty", phrases.length ? "No matches." : "No learned phrases yet."));
|
||||
return;
|
||||
}
|
||||
const byGroup = new Map();
|
||||
for (const p of filtered) {
|
||||
const cat = CATEGORY_ORDER.includes(p.category) ? p.category : "details";
|
||||
if (!byGroup.has(cat)) byGroup.set(cat, []);
|
||||
byGroup.get(cat).push(p);
|
||||
}
|
||||
for (const cat of CATEGORY_ORDER) {
|
||||
const g = byGroup.get(cat);
|
||||
if (!g?.length) continue;
|
||||
bank.append(el("div", "funpack-studio-cat-label", cat));
|
||||
const chipRow = el("div", "funpack-studio-chip-row");
|
||||
for (const p of g) {
|
||||
const chip = el("button", "funpack-studio-chip", p.text);
|
||||
chip.type = "button";
|
||||
chip.title = `${p.text} (seen ${p.evidence}x)`;
|
||||
chip.addEventListener("click", () => {
|
||||
if (!adjItems.find((i) => i.phrase.trim().toLowerCase() === p.text.toLowerCase())) {
|
||||
adjItems.push({ phrase: p.text, strength: 0.1 });
|
||||
renderAdjRows();
|
||||
}
|
||||
});
|
||||
chipRow.append(chip);
|
||||
}
|
||||
bank.append(chipRow);
|
||||
}
|
||||
};
|
||||
renderBank();
|
||||
search.addEventListener("input", renderBank);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── initial render ─────────────────────────────────────────────────────────
|
||||
switchTab("Session");
|
||||
}
|
||||
|
||||
// ─── Node face widget ─────────────────────────────────────────────────────────
|
||||
|
||||
function fitString(ctx, text, maxWidth) {
|
||||
text = String(text ?? "");
|
||||
if (ctx.measureText(text).width <= maxWidth) return text;
|
||||
let lo = 0, hi = text.length;
|
||||
while (lo < hi) {
|
||||
const mid = Math.ceil((lo + hi) / 2);
|
||||
ctx.measureText(text.slice(0, mid) + "...").width <= maxWidth ? lo = mid : hi = mid - 1;
|
||||
}
|
||||
return text.slice(0, lo) + "...";
|
||||
}
|
||||
|
||||
// ─── Setup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
|
||||
node.widgets = (node.widgets || []).filter((w) => w.name !== "funpack_studio_open");
|
||||
node.addWidget("button", "Open Studio", "funpack_studio_open", () => {
|
||||
openPanel(node);
|
||||
}, { serialize: false });
|
||||
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let stylesInjected = false;
|
||||
function injectStyles() {
|
||||
if (stylesInjected) return;
|
||||
stylesInjected = true;
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.funpack-studio-panel {
|
||||
width: min(520px, calc(100vw - 24px));
|
||||
max-height: min(680px, calc(100vh - 24px));
|
||||
display: flex; flex-direction: column;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(180,190,200,0.35); border-radius: 8px;
|
||||
background: rgba(28,30,34,0.99);
|
||||
box-shadow: 0 20px 56px rgba(0,0,0,0.5);
|
||||
color: #ddd; font: 12px sans-serif; box-sizing: border-box;
|
||||
}
|
||||
.funpack-studio-panel * { box-sizing: border-box; }
|
||||
.funpack-studio-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding-bottom: 8px; margin-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
.funpack-studio-title { font-weight: 700; font-size: 15px; letter-spacing: 0.3px; }
|
||||
.funpack-studio-error { min-height: 16px; color: #ff9f9f; padding: 3px 0; font-size: 11px; }
|
||||
.funpack-studio-tabs {
|
||||
display: flex; gap: 3px; padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
flex-shrink: 0; overflow-x: auto;
|
||||
}
|
||||
.funpack-studio-tab {
|
||||
padding: 5px 10px; border: 1px solid transparent; border-radius: 5px;
|
||||
background: transparent; color: #aaa; cursor: pointer; font: 11px sans-serif;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.funpack-studio-tab:hover { color: #eee; background: rgba(255,255,255,0.06); }
|
||||
.funpack-studio-tab.active { color: #eaffef; border-color: rgba(100,210,140,0.6); background: #244832; }
|
||||
.funpack-studio-body {
|
||||
flex: 1; overflow-y: auto; padding: 8px 2px; min-height: 0;
|
||||
display: flex; flex-direction: column; gap: 5px;
|
||||
}
|
||||
.funpack-studio-section-title {
|
||||
color: #58a6d6; font-weight: 700; font-size: 10px; text-transform: uppercase;
|
||||
margin: 8px 0 3px; letter-spacing: 0.4px;
|
||||
}
|
||||
.funpack-studio-hint { color: #9da6b0; font-size: 11px; line-height: 1.4; }
|
||||
.funpack-studio-empty { color: #9da6b0; padding: 6px 0; }
|
||||
.funpack-studio-row {
|
||||
display: grid; grid-template-columns: 110px minmax(0,1fr);
|
||||
gap: 8px; align-items: center;
|
||||
}
|
||||
.funpack-studio-row-label { color: #b8c0ca; }
|
||||
.funpack-studio-input, .funpack-studio-select {
|
||||
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-studio-textarea {
|
||||
width: 100%; min-height: 100px; resize: vertical;
|
||||
padding: 7px; border: 1px solid rgba(180,190,200,0.28); border-radius: 5px;
|
||||
background: #101216; color: #f4f4f4; line-height: 1.4; outline: none; font: 12px sans-serif;
|
||||
}
|
||||
.funpack-studio-textarea.short { min-height: 60px; }
|
||||
.funpack-studio-toggle {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
min-height: 28px; color: #d9dee5; cursor: pointer;
|
||||
}
|
||||
.funpack-studio-toggle input[type=checkbox] { width: 14px; height: 14px; cursor: pointer; }
|
||||
.funpack-studio-footer {
|
||||
display: flex; gap: 7px; padding-top: 8px;
|
||||
border-top: 1px solid rgba(255,255,255,0.08); flex-shrink: 0; margin-top: 6px;
|
||||
}
|
||||
.funpack-studio-btn {
|
||||
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-studio-btn:hover { background: #2b3037; }
|
||||
.funpack-studio-btn.primary { border-color: rgba(100,210,140,0.6); background: #244832; }
|
||||
.funpack-studio-btn.secondary { border-color: rgba(100,160,220,0.5); background: #1e2d3e; }
|
||||
.funpack-studio-btn.danger { border-color: rgba(255,130,130,0.45); background: #472626; }
|
||||
.funpack-studio-btn.compact { min-height: 24px; padding: 2px 7px; }
|
||||
.funpack-studio-lora-list { display: flex; flex-direction: column; gap: 5px; }
|
||||
.funpack-studio-lora-row {
|
||||
display: grid; grid-template-columns: minmax(0,1.8fr) 80px 54px 54px 28px;
|
||||
gap: 5px; align-items: center;
|
||||
}
|
||||
.lora-name, .lora-type { min-height: 28px; padding: 4px 6px;
|
||||
border: 1px solid rgba(180,190,200,0.28); border-radius: 5px;
|
||||
background: #17191d; color: #f2f2f2; outline: none; width: 100%; }
|
||||
.lora-weight { min-height: 28px; padding: 4px 5px; text-align: right;
|
||||
border: 1px solid rgba(180,190,200,0.28); border-radius: 5px;
|
||||
background: #17191d; color: #f2f2f2; outline: none; width: 100%; }
|
||||
.funpack-studio-adj-list { display: flex; flex-direction: column; gap: 5px; }
|
||||
.funpack-studio-adj-row {
|
||||
display: grid; grid-template-columns: minmax(0,1fr) 70px 28px;
|
||||
gap: 6px; align-items: center;
|
||||
}
|
||||
.adj-phrase { 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%; }
|
||||
.adj-strength { min-height: 28px; padding: 5px 5px; text-align: right;
|
||||
border: 1px solid rgba(180,190,200,0.28); border-radius: 5px;
|
||||
background: #17191d; color: #f2f2f2; outline: none; width: 100%; }
|
||||
.funpack-studio-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;
|
||||
}
|
||||
.funpack-studio-bank { max-height: 180px; overflow-y: auto; }
|
||||
.funpack-studio-cat-label {
|
||||
color: #58a6d6; font-weight: 700; font-size: 10px;
|
||||
text-transform: uppercase; margin: 6px 0 3px;
|
||||
}
|
||||
.funpack-studio-chip-row { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 2px; }
|
||||
.funpack-studio-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-studio-chip:hover { background: rgba(100,210,140,0.25); border-color: rgba(100,210,140,0.5); }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
// ─── Extension ────────────────────────────────────────────────────────────────
|
||||
|
||||
app.registerExtension({
|
||||
name: "funpack.studio",
|
||||
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);
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user