added checkpoint / model quickload on main node

This commit is contained in:
cowneko
2026-03-22 23:33:10 +01:00
parent 324581b90c
commit fb7fbe99df
3 changed files with 378 additions and 48 deletions
+36
View File
@@ -6,6 +6,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [2.1.0] — 2026-03-22
### Added
#### Quick-Load Model Dropdowns on Node
- Two new **quick-load dropdown buttons** drawn directly on the node canvas, positioned side-by-side above the preset rows:
- **⚡ Checkpoint** — lists all checkpoint models
- **⚡ Diff / GGUF** — lists all diffusion models and GGUF models
- Models are **grouped by base model type** with colored badge pills: SD15, SDXL, SDXL-T (Turbo), SDXL-L (Lightning), Illust (Illustrious), Pony, Noob (NoobAI), Flux, Chroma, Qwen, Wan, ZImg, and Other
- Within each group, models are sorted alphabetically by cleaned display name (subfolder and extension stripped)
- Clicking an entry immediately loads the model with its saved preset, thumbnail, and metadata — identical to using the Model Browser panel
- Quick-load model list is fetched from `/cwk/models` at startup and **refreshed automatically** after every model load
- Hover state on the quick-load buttons with visual feedback (border and text color change)
- HTML overlay dropdown with grouped headers, badge pills, and fuzzy-scroll — positioned intelligently to stay on screen
### Changed
- `getRowsStartY()` now accounts for the quick-load area height (`QUICK_LOAD_TOTAL_H`) above the preset rows
- New layout helpers: `getQuickLoadY()`, `getQuickLoadRects()`, `_getBaseRowsY()`
- New hit-test function: `hitTestQuickLoad()` — returns `"checkpoint"` or `"diffusion"` when the pointer is over a quick-load button
- New node state property: `node._cwkHoverQL` — tracks hover state for quick-load buttons independently from row hover
- `onMouseDown` handler updated to check quick-load hit before row/button hit
- `onMouseMove` handler updated to track quick-load hover state
- `onMouseLeave` handler updated to clear quick-load hover state
- `drawNode()` updated to render the two quick-load dropdown buttons with hover states, badge text, dropdown arrow, and a separator line below
- `calcNodeHeight()` accounts for the additional quick-load area in the node height calculation
- Quick-load dropdown uses the same `_loadModelIntoNode()` path as the Model Browser for full consistency
- New constants: `QUICK_LOAD_H`, `QUICK_LOAD_GAP`, `QUICK_LOAD_PAD`, `QUICK_LOAD_TOTAL_H`
- New functions: `_loadQuickLoadModels()`, `_cleanDisplayName()`, `_getBaseBadge()`, `openQuickLoadDropdown()`, `closeQuickLoad()`
### Fixed
- Fixed `/cwk/models` response parsing in quick-load loader — correctly handles the endpoint returning a plain array (not `{ models: [...] }`)
---
## [2.0.0] — 2026-03-21
### Added
+21
View File
@@ -16,6 +16,7 @@ A [ComfyUI](https://github.com/comfyanonymous/ComfyUI) custom node that combines
- Node size auto-calculated based on content; minimum width enforced
- **Last-used model persistence** — the node remembers the last loaded model and automatically restores it on page refresh or ComfyUI restart
- **Image metadata drag-and-drop** — drag an image with embedded metadata onto the node to automatically load the model that generated it
- **Quick-load model dropdowns** — two dropdown buttons drawn directly on the node (⚡ Checkpoint and ⚡ Diff / GGUF) for instant model switching without opening the Model Browser; models are grouped and badged by base model type (SD15, SDXL, Pony, Flux, etc.)
### 💾 Per-Model Preset System
- Save and load generation presets **per checkpoint model** — each model remembers its own settings
@@ -218,6 +219,26 @@ The main node outputs an **infos** STRING pin containing a JSON dict with all re
Model, VAE, and CLIP names are automatically cleaned: `sd15\darkSushiMixMix_colorful.safetensors` becomes `darkSushiMixMix_colorful`.
### 17. Quick-load model switching
Use the **⚡ Checkpoint** and **⚡ Diff / GGUF** dropdown buttons on the node to instantly switch between models without opening the Model Browser panel. Models are grouped by base model type with colored badges:
| Badge | Architectures |
|---|---|
| **SD15** | SD 1.4, SD 1.5 |
| **SDXL** | SDXL 1.0 |
| **SDXL-T** | SDXL Turbo |
| **SDXL-L** | SDXL Lightning |
| **Illust** | Illustrious |
| **Pony** | Pony |
| **Noob** | NoobAI |
| **Flux** | Flux.1 |
| **Chroma** | Chroma |
| **Qwen** | Qwen |
| **Wan** | Wan |
| **ZImg** | ZImage |
Clicking a model loads it immediately with its saved preset, thumbnail, and metadata. The model list refreshes automatically whenever you load a new model.
---
## Outputs
+321 -48
View File
@@ -24,7 +24,7 @@ const ARROW_W = 20;
const TITLE_H = () => LiteGraph.NODE_TITLE_HEIGHT ?? 30;
const SLOT_H = () => LiteGraph.NODE_SLOT_HEIGHT ?? 20;
const N_OUTPUTS = 10;
const N_OUTPUTS = 11;
function getSlotsBottom() {
return TITLE_H() + N_OUTPUTS * SLOT_H() + 6;
@@ -44,35 +44,116 @@ let VAES = ["embedded"];
let RES_PRESETS = ["(preset)"];
let RES_PRESETS_MAP = {};
// ─── Quick-load model dropdowns ───────────────────────────────────────────────
const QUICK_LOAD_H = 28;
const QUICK_LOAD_GAP = 6;
const QUICK_LOAD_PAD = 6;
const GROUP_SEP_H = 12;
const QUICK_LOAD_TOTAL_H = QUICK_LOAD_PAD + QUICK_LOAD_H + QUICK_LOAD_PAD + GROUP_SEP_H;
let _quickLoadModels = null;
let _quickLoadReady = false;
const _BASE_BADGES = [
{ match: ["sd 1.5", "sd 1.4", "sd1"], label: "SD15", color: "#f9e2af" },
{ match: ["sdxl turbo"], label: "SDXL-T", color: "#89dceb" },
{ match: ["sdxl lightning"], label: "SDXL-L", color: "#89dceb" },
{ match: ["sdxl"], label: "SDXL", color: "#89b4fa" },
{ match: ["illustrious"], label: "Illust", color: "#cba6f7" },
{ match: ["pony"], label: "Pony", color: "#f5c2e7" },
{ match: ["noobai"], label: "Noob", color: "#f38ba8" },
{ match: ["flux"], label: "Flux", color: "#a6e3a1" },
{ match: ["chroma"], label: "Chroma", color: "#94e2d5" },
{ match: ["qwen"], label: "Qwen", color: "#fab387" },
{ match: ["wan"], label: "Wan", color: "#74c7ec" },
{ match: ["zimage"], label: "ZImg", color: "#b4befe" },
];
function _getBaseBadge(baseModel) {
if (!baseModel) return { label: "???", color: "#6c7086" };
const low = baseModel.toLowerCase();
for (const b of _BASE_BADGES) {
for (const m of b.match) {
if (low.includes(m)) return b;
}
}
return { label: "Other", color: "#6c7086" };
}
function _cleanDisplayName(name) {
let n = name.replace(/\\/g, "/").split("/").pop();
return n.replace(/\.[^.]+$/, "");
}
async function _loadQuickLoadModels() {
try {
const res = await fetch("/cwk/models");
if (!res.ok) return;
const models = await res.json(); // ← returns a plain array
if (!Array.isArray(models)) return;
const checkpoints = [];
const diffusion = [];
for (const m of models) {
const entry = {
name: m.name,
display: _cleanDisplayName(m.name),
baseModel: m.civitai?.base_model ?? m.civitai?.baseModel ?? "",
badge: _getBaseBadge(m.civitai?.base_model ?? m.civitai?.baseModel ?? ""),
type: m.type ?? "checkpoint",
};
if (m.type === "diffusion_model" || m.type === "gguf") {
diffusion.push(entry);
} else {
checkpoints.push(entry);
}
}
const sorter = (a, b) => {
const ba = a.badge.label.toLowerCase();
const bb = b.badge.label.toLowerCase();
if (ba !== bb) return ba < bb ? -1 : 1;
return a.display.toLowerCase().localeCompare(b.display.toLowerCase());
};
checkpoints.sort(sorter);
diffusion.sort(sorter);
_quickLoadModels = { checkpoints, diffusion };
_quickLoadReady = true;
} catch (e) {
console.warn("[CWK] Could not load quick-load model list:", e);
}
}
_loadQuickLoadModels();
function rowByKey(key) {
return INFO_ROWS.find(r => r.key === key);
}
// ── Group separator indices — a divider line is drawn BEFORE these row indices ──
// ── Group separator indices ───────────────────────────────────────────────────
const GROUP_SEPARATORS = new Set([2, 5, 10]);
const INFO_ROWS = [
// ── Group 1: RNG & Model Sampling ──
{ key: "rng", label: "RNG", widget: "override_rng", type: "list", options: RNGS }, // 0
{ key: "model_sampling", label: "Model Sampling", widget: "override_model_sampling", type: "list", options: MODEL_SAMPLING_TYPES }, // 1
// ── Group 2: CLIP / Clip Type / VAE ──
{ key: "clip_name", label: "CLIP", widget: "override_clip_name", type: "list", options: CLIPS }, // 2
{ key: "clip_type", label: "Clip Type", widget: "override_clip_type", type: "list", options: CLIP_TYPES }, // 3
{ key: "vae_name", label: "VAE", widget: "override_vae_name", type: "list", options: VAES }, // 4
// ── Group 3: Sampler / Scheduler / CFG / Steps / Clip skip ──
{ key: "sampler_name", label: "Sampler", widget: "override_sampler", type: "list", options: SAMPLERS }, // 5
{ key: "scheduler", label: "Scheduler", widget: "override_scheduler", type: "list", options: SCHEDULERS }, // 6
{ key: "cfg", label: "CFG", widget: "override_cfg", type: "float", min: 0, max: 30 }, // 7
{ key: "steps", label: "Steps", widget: "override_steps", type: "int", min: 1, max: 200 }, // 8
{ key: "clip_skip", label: "Clip skip", widget: "override_clip_skip", type: "int", min: -24, max: -1 }, // 9
// ── Group 4: Resolution / Batch ──
{ key: "res_preset", label: "Res Preset", widget: "resolution_preset", type: "list", options: RES_PRESETS }, // 10
{ key: "width", label: "Width", widget: "override_width", type: "int", min: 64, max: 8192 }, // 11
{ key: "height", label: "Height", widget: "override_height", type: "int", min: 64, max: 8192 }, // 12
{ key: "batch_size", label: "Batch", widget: "batch_size", type: "int", min: 1, max: 64 }, // 13
{ key: "rng", label: "RNG", widget: "override_rng", type: "list", options: RNGS },
{ key: "model_sampling", label: "Model Sampling", widget: "override_model_sampling", type: "list", options: MODEL_SAMPLING_TYPES },
{ key: "clip_name", label: "CLIP", widget: "override_clip_name", type: "list", options: CLIPS },
{ key: "clip_type", label: "Clip Type", widget: "override_clip_type", type: "list", options: CLIP_TYPES },
{ key: "vae_name", label: "VAE", widget: "override_vae_name", type: "list", options: VAES },
{ key: "sampler_name", label: "Sampler", widget: "override_sampler", type: "list", options: SAMPLERS },
{ key: "scheduler", label: "Scheduler", widget: "override_scheduler", type: "list", options: SCHEDULERS },
{ key: "cfg", label: "CFG", widget: "override_cfg", type: "float", min: 0, max: 30 },
{ key: "steps", label: "Steps", widget: "override_steps", type: "int", min: 1, max: 200 },
{ key: "clip_skip", label: "Clip skip", widget: "override_clip_skip", type: "int", min: -24, max: -1 },
{ key: "res_preset", label: "Res Preset", widget: "resolution_preset", type: "list", options: RES_PRESETS },
{ key: "width", label: "Width", widget: "override_width", type: "int", min: 64, max: 8192 },
{ key: "height", label: "Height", widget: "override_height", type: "int", min: 64, max: 8192 },
{ key: "batch_size", label: "Batch", widget: "batch_size", type: "int", min: 1, max: 64 },
];
// ─── Async data loaders (single definitions, no duplicates) ───────────────────
// ─── Async data loaders ───────────────────────────────────────────────────────
async function _loadResolutionPresets() {
try {
@@ -113,7 +194,6 @@ async function _loadSamplerOptions() {
const clipTypes = (inputs?.optional?.override_clip_type?.[0] ?? []).filter(v => v !== "(preset)");
if (clipTypes.length) { CLIP_TYPES = clipTypes; const r = rowByKey("clip_type"); if (r) r.options = clipTypes; }
// Load model sampling types from object_info
const modelSamplingOpts = (inputs?.optional?.override_model_sampling?.[0] ?? []).filter(v => v !== "(preset)");
if (modelSamplingOpts.length) {
const r = rowByKey("model_sampling");
@@ -167,7 +247,6 @@ const C = {
flashGreen: "#a6e3a1",
};
// ─── Node default colors ──────────────────────────────────────────────────────
const NODE_COLOR = "#141824";
const NODE_BGCOLOR = "#1e2335";
@@ -192,8 +271,6 @@ function loadImage(url) {
// ─── Layout ───────────────────────────────────────────────────────────────────
const GROUP_SEP_H = 12; // extra vertical space for group divider
function getThumbRect(node) {
const regionTop = TITLE_H();
const regionBottom = getSlotsBottom();
@@ -208,13 +285,33 @@ function getThumbRect(node) {
function getInfoAreaX() { return PAD + THUMB_W + PAD; }
function getInfoAreaW(node) { return node.size[0] - getInfoAreaX() - PAD; }
function getRowsStartY(node) {
function _getBaseRowsY(node) {
// The Y where quick-load area starts (before adding quick-load height)
const thumbRect = getThumbRect(node);
const thumbBottom = thumbRect.y + thumbRect.h + PAD;
const slotsBottom = getSlotsBottom() + PAD;
return Math.max(thumbBottom, slotsBottom);
}
function getQuickLoadY(node) {
return _getBaseRowsY(node) + QUICK_LOAD_PAD;
}
function getQuickLoadRects(node) {
const y = getQuickLoadY(node);
const x = PAD;
const w = node.size[0] - PAD * 2;
const half = Math.floor((w - QUICK_LOAD_GAP) / 2);
return [
{ x, y, w: half, h: QUICK_LOAD_H, kind: "checkpoint" },
{ x: x + half + QUICK_LOAD_GAP, y, w: w - half - QUICK_LOAD_GAP, h: QUICK_LOAD_H, kind: "diffusion" },
];
}
function getRowsStartY(node) {
return _getBaseRowsY(node) + QUICK_LOAD_TOTAL_H;
}
function getRowY(node, i) {
let y = getRowsStartY(node);
for (let r = 0; r < i; r++) {
@@ -274,6 +371,15 @@ function hitTestRow(node, lx, ly) {
return null;
}
function hitTestQuickLoad(node, lx, ly) {
for (const qlr of getQuickLoadRects(node)) {
if (lx >= qlr.x && lx <= qlr.x + qlr.w && ly >= qlr.y && ly <= qlr.y + qlr.h) {
return qlr.kind;
}
}
return null;
}
// ─── Value helpers ────────────────────────────────────────────────────────────
function clampValue(row, val) {
@@ -426,6 +532,138 @@ function closeDropdown() {
}
}
// ─── Quick-load model dropdown (HTML overlay) ──────────────────────────────────
function closeQuickLoad() {
const el = document.getElementById("cwk-quickload-backdrop");
if (el) el.remove();
}
function openQuickLoadDropdown(node, kind) {
closeQuickLoad();
closeInlineEditor();
closeDropdown();
if (!_quickLoadReady || !_quickLoadModels) {
console.warn("[CWK] Quick-load model list not ready yet");
return;
}
const list = kind === "checkpoint" ? _quickLoadModels.checkpoints : _quickLoadModels.diffusion;
if (!list.length) return;
const rects = getQuickLoadRects(node);
const qlr = kind === "checkpoint" ? rects[0] : rects[1];
const sc = _canvasToScreen(node, qlr);
const backdrop = document.createElement("div");
backdrop.id = "cwk-quickload-backdrop";
Object.assign(backdrop.style, {
position: "fixed", inset: "0", zIndex: "99998", background: "transparent",
});
_blockCanvasEvents(backdrop);
const drop = document.createElement("div");
Object.assign(drop.style, {
position: "fixed",
left: sc.x + "px",
top: (sc.y + sc.h + 2) + "px",
width: Math.max(280, sc.w) + "px",
maxHeight: "320px",
overflowY: "auto",
background: C.bgFull,
border: `1px solid ${C.border}`,
borderRadius: "6px",
boxShadow: "0 8px 32px rgba(0,0,0,.65)",
zIndex: "99999",
fontFamily: "Inter, system-ui, sans-serif",
padding: "4px 0",
});
_blockCanvasEvents(drop);
let lastBadge = "";
for (const m of list) {
if (m.badge.label !== lastBadge) {
lastBadge = m.badge.label;
const header = document.createElement("div");
Object.assign(header.style, {
padding: "4px 10px 2px 10px",
fontSize: "10px",
fontWeight: "700",
color: m.badge.color,
textTransform: "uppercase",
letterSpacing: "0.5px",
borderTop: lastBadge !== list[0].badge.label ? `1px solid ${C.border}` : "none",
marginTop: lastBadge !== list[0].badge.label ? "2px" : "0",
});
header.textContent = m.badge.label;
drop.appendChild(header);
}
const item = document.createElement("div");
Object.assign(item.style, {
padding: "5px 10px 5px 10px",
fontSize: "12px",
color: C.text,
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "6px",
transition: "background .1s",
});
const badge = document.createElement("span");
Object.assign(badge.style, {
fontSize: "9px",
fontWeight: "700",
background: m.badge.color,
color: "#1e1e2e",
borderRadius: "3px",
padding: "1px 4px",
flexShrink: "0",
minWidth: "32px",
textAlign: "center",
});
badge.textContent = m.badge.label;
const nameSpan = document.createElement("span");
Object.assign(nameSpan.style, {
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: "1",
});
nameSpan.textContent = m.display;
item.appendChild(badge);
item.appendChild(nameSpan);
item.title = m.name;
item.addEventListener("mouseenter", () => { item.style.background = C.hoverBg; });
item.addEventListener("mouseleave", () => { item.style.background = ""; });
item.addEventListener("click", () => {
closeQuickLoad();
_loadModelIntoNode(node, m.name);
});
drop.appendChild(item);
}
backdrop.addEventListener("pointerdown", () => closeQuickLoad());
backdrop.appendChild(drop);
document.body.appendChild(backdrop);
requestAnimationFrame(() => {
const r = drop.getBoundingClientRect();
if (r.bottom > window.innerHeight) {
drop.style.top = (sc.y - r.height - 2) + "px";
}
if (r.right > window.innerWidth) {
drop.style.left = (window.innerWidth - r.width - 8) + "px";
}
});
}
// ─── Draw ─────────────────────────────────────────────────────────────────────
function roundRect(ctx, x, y, w, h, r) {
@@ -496,7 +734,44 @@ function drawNode(node, ctx) {
ctx.fillText(node._cwkModelName.replace(/^.*[/\\]/,""), infoX, ny + 38, infoW);
}
// ── Divider ──
// ── Quick-load dropdowns ──
{
const [ckptRect, diffRect] = getQuickLoadRects(node);
const hoverQL = node._cwkHoverQL ?? null;
for (const qlr of [ckptRect, diffRect]) {
const hovered = (hoverQL === qlr.kind);
const label = qlr.kind === "checkpoint" ? "⚡ Checkpoint" : "⚡ Diff / GGUF";
roundRect(ctx, qlr.x, qlr.y, qlr.w, qlr.h, 4);
ctx.fillStyle = hovered ? C.hoverBg : C.surface;
ctx.strokeStyle = hovered ? C.arrowHov : C.border;
ctx.lineWidth = 1;
ctx.fill();
ctx.stroke();
ctx.fillStyle = hovered ? C.arrowHov : C.textDim;
ctx.font = "bold 10px Inter, system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, qlr.x + qlr.w / 2, qlr.y + qlr.h / 2);
ctx.fillStyle = hovered ? C.arrowHov : C.textDim;
ctx.textAlign = "right";
ctx.fillText("▾", qlr.x + qlr.w - 6, qlr.y + qlr.h / 2);
}
// Separator line below quick-load area
const sepY = ckptRect.y + ckptRect.h + QUICK_LOAD_PAD;
ctx.strokeStyle = C.border;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD, sepY);
ctx.lineTo(w - PAD, sepY);
ctx.stroke();
}
// ── Divider above rows ──
ctx.strokeStyle = C.border; ctx.lineWidth = 1;
ctx.beginPath();
const divY = getRowsStartY(node) - PAD/2;
@@ -511,7 +786,6 @@ function drawNode(node, ctx) {
const isHov = hover?.rowIdx === i;
const hovPart = isHov ? hover.part : null;
// ── Group separator line ──
if (GROUP_SEPARATORS.has(i)) {
const sepY = ry - GROUP_SEP_H / 2 - 1;
ctx.strokeStyle = C.border; ctx.lineWidth = 1;
@@ -565,10 +839,10 @@ function drawNode(node, ctx) {
ctx.fillText(label, r.x + r.w/2, r.y + r.h/2, r.w - 8);
}
ctx.restore(); // release the rounded-rect clip
ctx.restore();
}
// ─── Helper: fully load a model into the node (model name, meta, preset) ──────
// ─── Helper: fully load a model into the node ─────────────────────────────────
async function _loadModelIntoNode(node, modelName) {
const getW = name => node.widgets?.find(w => w.name === name);
@@ -583,9 +857,7 @@ async function _loadModelIntoNode(node, modelName) {
const { preset } = await apiFetch(`/cwk/preset?model=${encodeURIComponent(modelName)}`);
if (preset) {
node._cwkPreset = { ...preset };
// Also set batch_size default if not in preset
if (node._cwkPreset.batch_size == null) node._cwkPreset.batch_size = 1;
// Also set res_preset display
if (node._cwkPreset.res_preset == null) node._cwkPreset.res_preset = "(preset)";
if (node._cwkPreset.clip_type == null) node._cwkPreset.clip_type = "stable_diffusion";
if (node._cwkPreset.model_sampling == null) node._cwkPreset.model_sampling = "eps";
@@ -605,15 +877,14 @@ async function _loadModelIntoNode(node, modelName) {
override_height: preset.height,
batch_size: preset.batch_size ?? 1,
};
for (const [wn, val] of Object.entries(map)) {
const w = getW(wn);
if (w && val !== undefined) { w.value = val; w.callback?.(val); }
}
}
} catch {}
// Persist as last used model
try { await apiFetch("/cwk/last_model", { method: "POST", body: JSON.stringify({ model_name: modelName }) }); } catch {}
_loadQuickLoadModels();
node.setDirtyCanvas(true);
}
@@ -634,6 +905,7 @@ app.registerExtension({
node._cwkMeta = null;
node._cwkPreset = {};
node._cwkModelName = null;
node._cwkHoverQL = null;
node.color = NODE_COLOR;
node.bgcolor = NODE_BGCOLOR;
@@ -643,7 +915,6 @@ app.registerExtension({
w.type = "hidden"; w.hidden = true;
w.computeSize = () => [0, -4];
}
// ── Ensure all combo widgets start with valid defaults ──
const getW = name => node.widgets?.find(w => w.name === name);
const rp = getW("resolution_preset");
if (rp) rp.value = "(preset)";
@@ -659,9 +930,7 @@ app.registerExtension({
app.canvas.setDirty(true, true);
}, 0);
// ── NEW: Restore last-used model on node creation ────────────────────
setTimeout(async () => {
// Only auto-restore if no model is already loaded (fresh node)
if (node._cwkModelName) return;
try {
const res = await fetch("/cwk/last_model");
@@ -683,6 +952,13 @@ app.registerExtension({
};
node.onMouseDown = function (e, pos) {
// ── Quick-load dropdowns ──
const qlKind = hitTestQuickLoad(this, pos[0], pos[1]);
if (qlKind) {
openQuickLoadDropdown(node, qlKind);
return true;
}
const btnKey = hitTestButton(this, pos[0], pos[1]);
if (btnKey) { handleButtonClick(node, btnKey); return true; }
const hit = hitTestRow(this, pos[0], pos[1]);
@@ -693,7 +969,6 @@ app.registerExtension({
if (row.type === "list") {
openDropdown(node, rowIdx, cur, val => {
applyRowValue(node, rowIdx, val);
// ── NEW: If resolution preset changed, update width/height ──
if (row.key === "res_preset" && val !== "(preset)") {
const dims = RES_PRESETS_MAP[val];
if (dims && dims.width > 0 && dims.height > 0) {
@@ -724,6 +999,13 @@ app.registerExtension({
};
node.onMouseMove = function (e, pos) {
// ── Quick-load hover ──
const qlHover = hitTestQuickLoad(this, pos[0], pos[1]);
if ((node._cwkHoverQL ?? null) !== qlHover) {
node._cwkHoverQL = qlHover;
app.canvas.setDirty(true, false);
}
const btnKey = hitTestButton(this, pos[0], pos[1]);
if (btnKey) {
if (!node._cwkHover || node._cwkHover.key !== btnKey) {
@@ -740,37 +1022,30 @@ app.registerExtension({
node.onMouseLeave = function () {
if (node._cwkHover !== null) { node._cwkHover = null; app.canvas.setDirty(true, false); }
if (node._cwkHoverQL !== null) { node._cwkHoverQL = null; app.canvas.setDirty(true, false); }
};
};
},
// ── NEW: Handle image-with-metadata dropped onto canvas ──────────────────
async nodeCreated(node) {
if (node.comfyClass !== NODE_TYPE) return;
// Hook into handleFile to intercept image drops with PNG metadata
const origHandleFile = node.handleFile?.bind(node);
node.handleFile = async function (file) {
if (origHandleFile) origHandleFile(file);
if (!file || !file.type?.startsWith("image/")) return;
try {
// Read PNG text chunks for ComfyUI workflow/prompt metadata
const buffer = await file.arrayBuffer();
const text = new TextDecoder().decode(buffer);
// Try to find a ComfyUI-style "prompt" JSON embedded in the image
let promptData = null;
// Method 1: look for the "prompt" PNG tEXt chunk pattern
const promptMatch = text.match(/"prompt"\s*:\s*(\{[\s\S]*\})/);
if (promptMatch) {
try { promptData = JSON.parse(promptMatch[1]); } catch {}
}
// Method 2: look for A1111-style parameters in EXIF
const a1111Match = text.match(/(?:parameters|EXIF)\s*[:\n]([\s\S]*?)(?:Negative prompt:|$)/i);
if (promptData) {
// ComfyUI workflow metadata — find the checkpoint loader node
for (const [, nodeData] of Object.entries(promptData)) {
const inputs = nodeData?.inputs;
if (!inputs) continue;
@@ -782,11 +1057,9 @@ app.registerExtension({
}
}
// Try to find model name from A1111-style "Model: xxx" or "Model hash: xxx"
const modelMatch = text.match(/Model:\s*([^\n,]+)/i);
if (modelMatch) {
const modelHint = modelMatch[1].trim();
// Try to find a matching checkpoint by partial name
try {
const listRes = await fetch("/cwk/models");
if (listRes.ok) {