fix: model detection for diffusion models, %date% filenames, preview resize

Real-world testing feedback (PR #9):
- Detect models loaded via UNETLoader ("Load Diffusion Model", unet_name in
  the diffusion_models folder) + GGUF unet loaders, not just checkpoints;
  carry the folder so AutoV2 hashing looks in the right place. Fixes
  "model (?)" on Flux/SD3/Krea workflows. Mirrored in the JS Test button.
- Expand %date:FORMAT% filename tokens (frontend feature that doesn't reach a
  custom node's widget) before get_save_image_path, so e.g.
  "%date:yyyy-MM-dd%/Krea-Turbo" works. New comfy-free filenames module.
- Test-detection preview panel now reserves its natural height and grows the
  node to fit (measured, like the Prompt Batch editor) instead of overflowing.

Advanced node sockets kept as-is per user preference. +10 unit tests (39 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris
2026-07-12 17:51:50 +02:00
co-authored by Claude Opus 4.8
parent b7c7875a8b
commit 5380fb336d
6 changed files with 170 additions and 18 deletions
+32
View File
@@ -0,0 +1,32 @@
# Filename token expansion — part of ComfyUI-AI2Go-Utils. GPL-3.0.
#
# ComfyUI core's folder_paths.get_save_image_path expands %year%/%month%/%day%/... and subfolders,
# but NOT the %date:FORMAT% token the stock Save Image tooltip advertises (that lives in the web
# frontend and doesn't reach a custom node's widget). This expands %date:FORMAT% (and bare %date%)
# so filename prefixes like "%date:yyyy-MM-dd%/Krea-Turbo" work. Pure/comfy-free — the caller passes
# `now`; other tokens are left for get_save_image_path.
import re
# .NET/luxon-style format tokens -> strftime. Longest-first so yyyy wins over yy in a single pass,
# and case distinguishes MM (month) from mm (minute), HH/hh (hour).
_FMT_TOKENS = [("yyyy", "%Y"), ("yy", "%y"), ("MM", "%m"), ("dd", "%d"),
("HH", "%H"), ("hh", "%I"), ("mm", "%M"), ("ss", "%S")]
_FMT_MAP = dict(_FMT_TOKENS)
_FMT_RE = re.compile("|".join(re.escape(tok) for tok, _ in _FMT_TOKENS))
_DATE_RE = re.compile(r"%date:([^%]*)%")
def _to_strftime(fmt):
return _FMT_RE.sub(lambda m: _FMT_MAP[m.group(0)], fmt)
def expand_date_tokens(prefix, now):
"""Expand ``%date:FORMAT%`` (and bare ``%date%`` -> yyyy-MM-dd) in ``prefix`` using ``now``.
``now`` is a ``datetime``. Non-date tokens (``%year%``, ``%width%``, subfolders) are left
untouched for ``folder_paths.get_save_image_path``.
"""
if not prefix or "%date" not in prefix:
return prefix
prefix = _DATE_RE.sub(lambda m: now.strftime(_to_strftime(m.group(1))), prefix)
return prefix.replace("%date%", now.strftime("%Y-%m-%d"))
+19 -6
View File
@@ -14,7 +14,17 @@ from ..prompt_batch_core import parse_prompts, select_prompt
SAMPLER_CLASSES = {"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"}
CLIP_ENCODE_CLASSES = {"CLIPTextEncode"}
BATCH_CLASS = "AI2GoPromptBatch"
CHECKPOINT_CLASSES = {"CheckpointLoaderSimple", "CheckpointLoader", "unCLIPCheckpointLoader"}
# Model-source loaders → (widget holding the filename, folder_paths folder to hash it from).
# Covers checkpoints AND standalone diffusion models (UNETLoader "Load Diffusion Model" — Flux/SD3/
# Krea/etc.), plus common GGUF unet loaders. Unknown loaders fall through as pass-throughs.
MODEL_SOURCES = {
"CheckpointLoaderSimple": ("ckpt_name", "checkpoints"),
"CheckpointLoader": ("ckpt_name", "checkpoints"),
"unCLIPCheckpointLoader": ("ckpt_name", "checkpoints"),
"UNETLoader": ("unet_name", "diffusion_models"),
"UnetLoaderGGUF": ("unet_name", "diffusion_models"),
"UnetLoaderGGUFAdvanced": ("unet_name", "diffusion_models"),
}
LORA_CLASSES = {"LoraLoader", "LoraLoaderModelOnly"}
@@ -31,6 +41,7 @@ class TraceResult:
clip_skip: object = None
model_name: str = None
model_file: str = None
model_folder: str = None
loras: list = field(default_factory=list)
unresolved: list = field(default_factory=list)
@@ -190,11 +201,13 @@ def _trace_model_chain(prompt, sampler, r):
nxt = ins.get("model")
nid = str(nxt[0]) if _is_link(nxt) else None
continue
if cls in CHECKPOINT_CLASSES:
ck = ins.get("ckpt_name")
if isinstance(ck, str):
r.model_name = _stem(ck)
r.model_file = ck
if cls in MODEL_SOURCES:
widget, folder = MODEL_SOURCES[cls]
name = ins.get(widget)
if isinstance(name, str):
r.model_name = _stem(name)
r.model_file = name
r.model_folder = folder
break
nxt = ins.get("model") # pass-through node (e.g. ModelSamplingDiscrete)
nid = str(nxt[0]) if _is_link(nxt) else None
+8 -1
View File
@@ -6,6 +6,7 @@
# web/js/save_civitai_metadata.js (the Test button) — keep the two in sync.
import os
import logging
from datetime import datetime
import numpy as np
from PIL import Image
@@ -24,6 +25,7 @@ from .civitai_metadata.sampler_names import to_a1111_sampler
from .civitai_metadata.a1111 import format_parameters
from .civitai_metadata.hashing import HashCache
from .civitai_metadata.png_info import build_pnginfo
from .civitai_metadata.filenames import expand_date_tokens
logger = logging.getLogger(__name__)
@@ -49,7 +51,9 @@ def _save_with_metadata(cls, images, filename_prefix, save_workflow, overrides):
# Resolve model + LoRA file paths to AutoV2 hashes (cached).
cache = HashCache(_CACHE_PATH)
model_hash = cache.get(folder_paths.get_full_path("checkpoints", result.model_file)) if result.model_file else None
model_hash = cache.get(
folder_paths.get_full_path(result.model_folder or "checkpoints", result.model_file)
) if result.model_file else None
for lo in result.loras:
path = folder_paths.get_full_path("loras", lo["file"]) if lo.get("file") else None
lo["hash"] = cache.get(path) if path else None
@@ -63,6 +67,9 @@ def _save_with_metadata(cls, images, filename_prefix, save_workflow, overrides):
denoise=result.denoise, clip_skip=result.clip_skip,
)
# Expand %date:FORMAT% (frontend feature that doesn't reach a custom node's widget) before the
# rest of the tokens (%year%/subfolders) that get_save_image_path itself handles.
filename_prefix = expand_date_tokens(filename_prefix, datetime.now())
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory(), width, height)
+38
View File
@@ -0,0 +1,38 @@
# Tests for filename %date:FORMAT% token expansion — part of ComfyUI-AI2Go-Utils. GPL-3.0.
from datetime import datetime
from nodes.civitai_metadata.filenames import expand_date_tokens
NOW = datetime(2026, 7, 12, 9, 8, 7) # 2026-07-12 09:08:07
def test_date_with_format_and_subfolder():
assert expand_date_tokens("%date:yyyy-MM-dd%/Krea-Turbo_9-16", NOW) == "2026-07-12/Krea-Turbo_9-16"
def test_date_year_only():
assert expand_date_tokens("%date:yyyy%/x", NOW) == "2026/x"
def test_date_full_datetime():
assert expand_date_tokens("%date:yyyyMMdd_HHmmss%", NOW) == "20260712_090807"
def test_bare_date_defaults_to_iso():
assert expand_date_tokens("%date%/x", NOW) == "2026-07-12/x"
def test_month_vs_minute_case_sensitive():
# MM = month (07), mm = minute (08)
assert expand_date_tokens("%date:MM-mm%", NOW) == "07-08"
def test_no_token_left_untouched():
assert expand_date_tokens("plain/name", NOW) == "plain/name"
# %year% is get_save_image_path's job — expand_date_tokens must not touch it.
assert expand_date_tokens("%year%/name", NOW) == "%year%/name"
def test_empty_and_none():
assert expand_date_tokens("", NOW) == ""
assert expand_date_tokens(None, NOW) is None
+29
View File
@@ -43,3 +43,32 @@ def test_model_only_lora_and_passthrough():
r = trace(p, "7")
assert [lo["name"] for lo in r.loras] == ["C"]
assert r.model_name == "myCkpt"
def test_checkpoint_folder_is_checkpoints():
r = trace(_base(), "7")
assert r.model_folder == "checkpoints"
def test_unet_loader_diffusion_model():
# Flux/Krea-style: model comes from a Load Diffusion Model (UNETLoader), not a checkpoint.
p = _base()
del p["1"]["inputs"]["ckpt_name"]
p["1"] = {"class_type": "UNETLoader", "inputs": {"unet_name": "flux/krea-turbo.safetensors",
"weight_dtype": "default"}}
r = trace(p, "7")
assert r.model_name == "krea-turbo"
assert r.model_file == "flux/krea-turbo.safetensors"
assert r.model_folder == "diffusion_models"
def test_unet_loader_with_model_only_lora():
# sampler <- LoraLoaderModelOnly <- UNETLoader (common Flux LoRA wiring)
p = _base()
p["1"] = {"class_type": "UNETLoader", "inputs": {"unet_name": "krea.safetensors", "weight_dtype": "default"}}
p["8"] = {"class_type": "LoraLoaderModelOnly", "inputs": {"model": ["1", 0],
"lora_name": "styleFlux.safetensors", "strength_model": 0.9}}
p["4"]["inputs"]["model"] = ["8", 0]
r = trace(p, "7")
assert r.model_name == "krea" and r.model_folder == "diffusion_models"
assert [lo["name"] for lo in r.loras] == ["styleFlux"]
+44 -11
View File
@@ -13,7 +13,13 @@ const { app } = window.comfyAPI.app;
const NODE_IDS = ["AI2GoSaveCivitaiMetadata", "AI2GoSaveCivitaiMetadataAdvanced"];
const SAMPLER_CLASSES = new Set(["KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"]);
const CLIP_CLASSES = new Set(["CLIPTextEncode"]);
const CHECKPOINT_CLASSES = new Set(["CheckpointLoaderSimple", "CheckpointLoader", "unCLIPCheckpointLoader"]);
// class_type -> widget holding the model filename (mirror of tracer.MODEL_SOURCES; the Python side
// also tracks the folder for hashing, which the preview doesn't need). Covers checkpoints and
// standalone diffusion models (UNETLoader "Load Diffusion Model" — Flux/SD3/Krea/etc.).
const MODEL_SOURCES = {
CheckpointLoaderSimple: "ckpt_name", CheckpointLoader: "ckpt_name", unCLIPCheckpointLoader: "ckpt_name",
UNETLoader: "unet_name", UnetLoaderGGUF: "unet_name", UnetLoaderGGUFAdvanced: "unet_name",
};
const LORA_CLASSES = new Set(["LoraLoader", "LoraLoaderModelOnly"]);
const BATCH_CLASS = "AI2GoPromptBatch";
@@ -133,9 +139,9 @@ function traceLive(node) {
const name = widget(cur, "lora_name");
if (typeof name === "string") r.loras.push({ name: stem(name), strength: widget(cur, "strength_model") });
cur = originNode(cur, "model");
} else if (CHECKPOINT_CLASSES.has(cls)) {
const ck = widget(cur, "ckpt_name");
if (typeof ck === "string") r.model_name = stem(ck);
} else if (MODEL_SOURCES[cls]) {
const name = widget(cur, MODEL_SOURCES[cls]);
if (typeof name === "string") r.model_name = stem(name);
break;
} else {
cur = originNode(cur, "model");
@@ -190,16 +196,43 @@ app.registerExtension({
ensureStyles();
chainCallback(nodeType.prototype, "onNodeCreated", function () {
const node = this;
const panel = document.createElement("div");
panel.className = "ai2go-scm";
panel.textContent = 'Press "🔎 Test detection" to preview what will be saved.';
const panel = document.createElement("div"); // widget root; ComfyUI pins its height each frame
const content = document.createElement("div"); // natural-height content we measure
content.className = "ai2go-scm";
content.textContent = 'Press "🔎 Test detection" to preview what will be saved.';
panel.appendChild(content);
const btn = node.addWidget("button", "🔎 Test detection", null, () => {
try { renderPreview(panel, traceLive(node)); }
catch (e) { panel.textContent = "Trace error: " + e.message; }
node.setDirtyCanvas?.(true, true);
try { renderPreview(content, traceLive(node)); }
catch (e) { content.textContent = "Trace error: " + e.message; }
fit();
});
btn.serialize = false;
node.addDOMWidget("scm_preview", "info", panel, { serialize: false });
const w = node.addDOMWidget("scm_preview", "info", panel, { serialize: false });
// Reserve the content's natural height so the node grows to fit its text (measured, like the
// Prompt Batch rows editor) — otherwise ComfyUI gives the DOM widget a fixed height and the
// preview overflows below the node border. Measure `content` (not the pinned `panel`) to avoid
// a grow-by-its-own-padding feedback loop.
function fit() {
const h = Math.max(content.scrollHeight, 8);
w.computeSize = () => [node.size?.[0] || 320, h + 8];
const want = node.computeSize?.();
if (want) node.setSize([node.size[0], want[1]]);
node.setDirtyCanvas?.(true, true);
}
w.computeSize = () => [node.size?.[0] || 320, Math.max(content.scrollHeight, 8) + 8];
// The element attaches after onNodeCreated, so observe it and re-fit the moment it gains (or
// changes) its natural height — self-corrects with no user interaction.
let lastH = 0;
const ro = new ResizeObserver(() => {
const h = content.scrollHeight;
if (h && h !== lastH) { lastH = h; fit(); }
});
ro.observe(content);
chainCallback(node, "onRemoved", () => ro.disconnect());
});
},
});