Merge pull request #25 from IAMCCS/fix/h3-lora-header-cache

Move H3 LoRA discovery out of ComfyUI node schemas
This commit is contained in:
Carmine Cristallo Scalzi
2026-11-08 12:28:42 +01:00
committed by GitHub
9 changed files with 294 additions and 258 deletions
+6
View File
@@ -2865,6 +2865,12 @@ def setup_api_routes() -> None:
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
@routes.get("/api/iamccs/h3/lora-catalog")
async def iamccs_h3_lora_catalog_endpoint(request):
from .iamccs_h3_lora_catalog import lora_catalog
return web.json_response(await lora_catalog(), headers={"Cache-Control": "no-store"})
@routes.post("/api/iamccs/h3/advice")
async def iamccs_h3_advice_endpoint(request):
import asyncio
+35 -1
View File
@@ -25,7 +25,11 @@ def named_family(text):
return families[0] if len(families) == 1 else ""
@functools.lru_cache(maxsize=128)
# Keep repeated schema refreshes warm even for large LoRA libraries.
H3_HEADER_CACHE_MAXSIZE = 8192
@functools.lru_cache(maxsize=H3_HEADER_CACHE_MAXSIZE)
def inspect_header(path_text, mtime_ns, size):
del mtime_ns
with open(path_text, "rb") as handle:
@@ -43,6 +47,36 @@ def inspect_header(path_text, mtime_ns, size):
"header_sha256": hashlib.sha256(raw).hexdigest(), "bytes": size}
@functools.lru_cache(maxsize=H3_HEADER_CACHE_MAXSIZE)
def classify_fasth3_dense_file(path_text: str, mtime_ns: int, size: int) -> tuple[bool, str]:
"""Fail closed unless this is a native fused-QKV FastH3 converter output."""
del mtime_ns, size
try:
from safetensors import safe_open
with safe_open(path_text, framework="pt", device="cpu") as handle:
keys = [str(key).lower() for key in handle.keys()]
metadata = {str(key).lower(): str(value) for key, value in (handle.metadata() or {}).items()}
except Exception as exc:
return False, f"unreadable safetensors header ({type(exc).__name__}: {exc})"
if metadata.get("converted_by", "").strip().lower() != "lora_convert_h3":
return False, "not a FastH3 adapter converted for native ComfyUI (converter metadata missing)"
adaln_mode = metadata.get("adaln_mode", "").strip().lower()
if adaln_mode != "drop":
return False, f"AdaLN mode '{adaln_mode or 'unknown'}' is not production-safe; reconvert with --adaln drop"
native_lora = any(
key.startswith("diffusion_model.blocks.") and (".lora_a." in key or ".lora_b." in key)
for key in keys
)
fused_qkv = any(".attn.qkv_proj.lora_a." in key for key in keys) and any(
".attn.qkv_proj.lora_b." in key for key in keys
)
if not native_lora or not fused_qkv:
return False, "converted metadata exists, but native fused-QKV H3 LoRA tensors are incomplete"
return True, "FastH3 Dense native ComfyUI adapter"
def describe_asset(name, path):
result = {"name": name, "family": named_family(name), "role": "unknown", "recipe": None,
"native": False, "conflict": False, "error": "", "bytes": 0,
+75
View File
@@ -0,0 +1,75 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Incremental H3 LoRA discovery for the UI, outside ComfyUI schema generation."""
from __future__ import annotations
import asyncio
from pathlib import Path
from .iamccs_h3_advisor import classify_fasth3_dense_file, describe_asset, preferred_sla_defaults
_entries = {}
_refresh_task: asyncio.Task | None = None
def is_native_pdd_candidate(name: str) -> bool:
name = name.lower()
return "comfy" in name and (
("pdd" in name and "acc" in name)
or ("h3" in name and "acc" in name and "8step" in name)
)
def _refresh_catalog():
import folder_paths
import nodes
import torch
names = sorted(folder_paths.get_filename_list("loras"), key=str.lower)
current = {}
assets = []
fasth3 = []
choices = []
for name in names:
path = folder_paths.get_full_path("loras", name)
if not path:
continue
try:
stat = Path(path).stat()
except OSError:
continue
fingerprint = (str(path), stat.st_mtime_ns, stat.st_size)
previous = _entries.get(name)
if previous is not None and previous[0] == fingerprint:
_, compatible, asset = previous
else:
compatible = Path(path).suffix.lower() == ".safetensors" and classify_fasth3_dense_file(*fingerprint)[0]
asset = describe_asset(name, path) if compatible or "h3" in name.lower() else None
current[name] = (fingerprint, compatible, asset)
if compatible:
fasth3.append(name)
if asset is not None:
choices.append(name)
assets.append(asset)
# Drop removed files and old fingerprints. Unchanged files need no reads,
# even when the active registry exceeds the generic header cache size.
_entries.clear()
_entries.update(current)
return {
"choices": choices,
"pdd": [name for name in names if name in current and is_native_pdd_candidate(name)],
"fasth3": fasth3,
"assets": assets,
"sla_defaults": preferred_sla_defaults(
assets, "auto_from_timeline", nodes.NODE_CLASS_MAPPINGS if torch.cuda.is_available() else {},
),
}
async def lora_catalog():
global _refresh_task
if _refresh_task is None or _refresh_task.done():
_refresh_task = asyncio.create_task(asyncio.to_thread(_refresh_catalog))
# One disconnected client must not cancel work shared by other clients.
return await asyncio.shield(_refresh_task)
+4 -35
View File
@@ -1201,36 +1201,6 @@ def _apply_pdd_lora(model, shotplan: dict[str, Any]):
return patched, f"native PDD {lora_name}@{strength:.2f} (8/euler/simple, shifts 12/3)"
@functools.lru_cache(maxsize=64)
def _classify_fasth3_dense_file(path_text: str, mtime_ns: int) -> tuple[bool, str]:
"""Fail closed unless this is a native fused-QKV FastH3 converter output."""
del mtime_ns
try:
from safetensors import safe_open
with safe_open(path_text, framework="pt", device="cpu") as handle:
keys = [str(key).lower() for key in handle.keys()]
metadata = {str(key).lower(): str(value) for key, value in (handle.metadata() or {}).items()}
except Exception as exc:
return False, f"unreadable safetensors header ({type(exc).__name__}: {exc})"
if metadata.get("converted_by", "").strip().lower() != "lora_convert_h3":
return False, "not a FastH3 adapter converted for native ComfyUI (converter metadata missing)"
adaln_mode = metadata.get("adaln_mode", "").strip().lower()
if adaln_mode != "drop":
return False, f"AdaLN mode '{adaln_mode or 'unknown'}' is not production-safe; reconvert with --adaln drop"
native_lora = any(
key.startswith("diffusion_model.blocks.") and (".lora_a." in key or ".lora_b." in key)
for key in keys
)
fused_qkv = any(".attn.qkv_proj.lora_a." in key for key in keys) and any(
".attn.qkv_proj.lora_b." in key for key in keys
)
if not native_lora or not fused_qkv:
return False, "converted metadata exists, but native fused-QKV H3 LoRA tensors are incomplete"
return True, "FastH3 Dense native ComfyUI adapter"
def _apply_fasth3_dense_lora(model, shotplan: dict[str, Any]):
settings = _fasth3_settings(shotplan)
if not bool(settings.get("requested", False)):
@@ -1247,11 +1217,10 @@ def _apply_fasth3_dense_lora(model, shotplan: dict[str, Any]):
lora_path = None
if not lora_path:
raise ValueError(f"FastH3 Dense LoRA not found in ComfyUI's models/loras paths: {lora_name}")
try:
mtime_ns = Path(lora_path).stat().st_mtime_ns
except OSError:
mtime_ns = 0
compatible, detail = _classify_fasth3_dense_file(str(lora_path), int(mtime_ns))
from .iamccs_h3_advisor import classify_fasth3_dense_file
stat = Path(lora_path).stat()
compatible, detail = classify_fasth3_dense_file(str(lora_path), stat.st_mtime_ns, stat.st_size)
if not compatible:
raise ValueError(f"Invalid FastH3 Dense selection '{lora_name}': {detail}")
+10 -88
View File
@@ -4,7 +4,6 @@
from __future__ import annotations
import copy
import functools
import gc
import json
import logging
@@ -25,6 +24,7 @@ from PIL import Image, ImageOps
import folder_paths
from .iamccs_h3_advisor import describe_asset, recipes, validate_settings
from .iamccs_h3_lora_catalog import is_native_pdd_candidate
from .iamccs_h3_face_swap import settings_schema as face_swap_schema, face_swap_settings, validate_plan as validate_face_swap_plan
from .iamccs_h3_character_swap_lora import settings_schema as character_swap_schema, swap_plan
from .iamccs_h3_vfx_edit import settings_schema as vfx_edit_schema
@@ -70,50 +70,6 @@ H3_LINKED_STAGE_PAIRS = {
}
@functools.lru_cache(maxsize=512)
def _classify_fasth3_dense_lora_header(path_text: str, mtime_ns: int) -> tuple[bool, str]:
"""Identify converted FastH3 Dense adapters without relying on filenames."""
del mtime_ns # retained in the cache key so replacing a file invalidates the result
try:
from safetensors import safe_open
with safe_open(path_text, framework="pt", device="cpu") as handle:
keys = [str(key).lower() for key in handle.keys()]
metadata = {str(key).lower(): str(value) for key, value in (handle.metadata() or {}).items()}
except Exception as exc:
return False, f"unreadable safetensors header ({type(exc).__name__}: {exc})"
converted_by = metadata.get("converted_by", "").strip().lower()
adaln_mode = metadata.get("adaln_mode", "").strip().lower()
native_lora = any(
key.startswith("diffusion_model.blocks.") and (".lora_a." in key or ".lora_b." in key)
for key in keys
)
fused_qkv = any(".attn.qkv_proj.lora_a." in key for key in keys) and any(
".attn.qkv_proj.lora_b." in key for key in keys
)
if converted_by != "lora_convert_h3":
return False, "missing FastH3 converter metadata"
if not native_lora or not fused_qkv:
return False, "FastH3 metadata found, but native fused-QKV H3 LoRA tensors are incomplete"
if adaln_mode != "drop":
return False, f"unsupported FastH3 AdaLN conversion mode '{adaln_mode or 'unknown'}'; use --adaln drop"
return True, "FastH3 Dense native ComfyUI adapter"
def _discover_fasth3_dense_loras(names: list[str]) -> list[str]:
"""Return compatible converted adapters from ComfyUI's complete LoRA registry."""
found: list[str] = []
for name in names:
try:
path = folder_paths.get_full_path("loras", name)
mtime_ns = Path(path).stat().st_mtime_ns if path else 0
except (OSError, TypeError, ValueError):
continue
if path and _classify_fasth3_dense_lora_header(str(path), int(mtime_ns))[0]:
found.append(name)
return sorted(found, key=str.lower)
def _speed_preset_contract(
*,
acceleration: str,
@@ -3031,36 +2987,11 @@ class IAMCCS_MiniMaxH3ShotPlanner:
samplers = list(comfy.samplers.SAMPLER_NAMES)
schedulers = list(comfy.samplers.SCHEDULER_NAMES)
# Discover every installed H3 LoRA by its filename or relative folder.
# Community releases do not consistently include "minimax" or
# "turbo" in the basename, so those tokens must not be a second,
# hardcoded allow-list.
all_installed_loras = list(folder_paths.get_filename_list("loras"))
installed_fasth3_loras = _discover_fasth3_dense_loras(all_installed_loras)
installed_turbo_loras = sorted(
(
name for name in all_installed_loras
if "h3" in name.lower() or name in installed_fasth3_loras
),
key=str.lower,
)
h3_lora_assets = [
describe_asset(name, folder_paths.get_full_path("loras", name))
for name in installed_turbo_loras
]
# Keep combo validation independent of compatibility discovery. Saved
# workflows may select a converted adapter with an arbitrary filename.
all_installed_loras = folder_paths.get_filename_list("loras")
installed_pdd_loras = sorted(
(
name for name in installed_turbo_loras
if (
("pdd" in name.lower() and "acc" in name.lower())
or ("h3" in name.lower() and "acc" in name.lower() and "8step" in name.lower())
)
# Native ComfyUI PDD head-bank files are the converted
# *_comfy safetensors. Legacy companion-loader files may use
# the same Acc-8Step name but have an incompatible tensor
# layout, so do not advertise them in the native dropdown.
and "comfy" in name.lower()
),
(name for name in all_installed_loras if is_native_pdd_candidate(name)),
key=str.lower,
)
# The LTX finishing slot intentionally exposes the complete ComfyUI
@@ -3082,7 +3013,7 @@ class IAMCCS_MiniMaxH3ShotPlanner:
preferred_ltx_detailer_lora = crisp_ltx_loras[0] if crisp_ltx_loras else ""
# Only expose files that really exist. The web migration converts old
# saved missing filenames to the empty/Base-H3 choice before validation.
turbo_loras = list(dict.fromkeys(("", *installed_turbo_loras)))
turbo_loras = list(dict.fromkeys(("", *all_installed_loras)))
pdd_loras = list(dict.fromkeys(("", *installed_pdd_loras)))
# Default to a real installed native PDD file when one exists. The
# value is discovered from ComfyUI's LoRA registry at startup, never a
@@ -3214,8 +3145,7 @@ class IAMCCS_MiniMaxH3ShotPlanner:
# Turbo is kept inside the typed shotplan so the selected LoRA,
# effective sampler and audio policy cannot drift apart.
"turbo_mode": (["off", "early_8_10", "ckpt500_6_8"], {"default": "off"}),
"turbo_lora_name": (turbo_loras, {"default": "", "iamccs_fasth3_values": installed_fasth3_loras,
"iamccs_h3_assets": h3_lora_assets}),
"turbo_lora_name": (turbo_loras, {"default": ""}),
"turbo_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.05}),
"turbo_sampler_mode": (["audio_fixed", "res_multistep_stock"], {"default": "audio_fixed"}),
# Preserve source pixels by default. The native H3 conditioner
@@ -3292,11 +3222,11 @@ class IAMCCS_MiniMaxH3ShotPlanner:
# changes the authored H3 sampling controls. These fields are
# appended to preserve older Shotboard widget arrays.
"secondary_lora_enabled": ("BOOLEAN", {"default": False}),
"secondary_lora_name": (turbo_loras, {"default": "", "iamccs_h3_assets": h3_lora_assets}),
"secondary_lora_name": (turbo_loras, {"default": ""}),
"secondary_lora_strength": ("FLOAT", {"default": 0.0, "min": -2.0, "max": 2.0, "step": 0.05}),
# Append-only native PDD controls. Converted PDD files use the
# stock ComfyUI LoRA registry but never share the Turbo route.
"pdd_lora_name": (pdd_loras, {"default": default_pdd_lora, "iamccs_h3_assets": h3_lora_assets}),
"pdd_lora_name": (pdd_loras, {"default": default_pdd_lora}),
"pdd_strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05}),
# Append-only native Fun ControlNet controls. Media is injected
# through IAMCCS Cine H3 Fun Control Input; these visible boxes
@@ -7297,18 +7227,10 @@ class IAMCCS_ShotboardH3Settings:
})
# User preference for new configurations. Existing workflow widget values
# remain authoritative; no migration rewrites a saved sampling recipe.
from .iamccs_h3_advisor import preferred_sla_defaults, recipes
from .iamccs_h3_advisor import recipes
import nodes as comfy_nodes
required["turbo_lora_name"][1]["iamccs_h3_recipes"] = recipes()
required["turbo_lora_name"][1]["iamccs_sla_available"] = "H3SLAAttention" in comfy_nodes.NODE_CLASS_MAPPINGS
sla_defaults = preferred_sla_defaults(
required["turbo_lora_name"][1].get("iamccs_h3_assets", []),
required["task_mode"][1].get("default", "auto_from_timeline"),
comfy_nodes.NODE_CLASS_MAPPINGS if torch.cuda.is_available() else {},
)
for name, value in sla_defaults.items():
if name in required:
required[name][1]["default"] = value
extended_av_optional = {}
for name in _H3_EXTENDED_AV_SETTINGS_NODE_FIELDS:
source_spec = copy.deepcopy(planner_inputs[name])
+48
View File
@@ -0,0 +1,48 @@
const assert = require("node:assert/strict");
const { readFileSync } = require("node:fs");
const { join } = require("node:path");
async function main() {
const source = readFileSync(join(__dirname, "..", "web", "iamccs_h3_advisor.js"), "utf8");
const ui = await import("data:text/javascript;base64," + Buffer.from(source).toString("base64"));
const data = {
choices: ["renamed.safetensors", "h3_sla.safetensors"],
pdd: [],
fasth3: ["renamed.safetensors"],
assets: [],
sla_defaults: { turbo_lora_name: "h3_sla.safetensors", steps: 4 },
};
let requests = 0;
const api = {
async fetchApi() {
requests++;
return { ok: true, json: async () => data };
},
};
await Promise.all([ui.fetchH3LoraCatalog(api), ui.fetchH3LoraCatalog(api, { force: true })]);
assert.equal(requests, 1);
const turbo = { name: "turbo_lora_name", value: "saved.safetensors" };
const steps = { name: "steps", value: 16 };
const node = { widgets: [turbo, steps], _iamccsH3LoraConfigured: true };
const initial = ui.captureH3LoraDefaults(node);
ui.applyH3LoraCatalog(node, data);
ui.applyNewH3LoraDefaults(node, data, initial);
assert.equal(turbo.value, "saved.safetensors");
assert.ok(turbo.options.values.includes(turbo.value));
assert.deepEqual(turbo.options.iamccs_fasth3_values, ["renamed.safetensors"]);
node._iamccsH3LoraConfigured = false;
steps.value = 8;
ui.applyNewH3LoraDefaults(node, data, initial);
assert.equal(turbo.value, "saved.safetensors");
assert.equal(steps.value, 8);
steps.value = 16;
ui.applyNewH3LoraDefaults(node, data, initial);
assert.equal(turbo.value, "h3_sla.safetensors");
assert.equal(steps.value, 4);
console.log("PASS: shared catalog request, saved selection, and untouched-node defaults");
}
main().catch(error => { console.error(error); process.exitCode = 1; });
+42
View File
@@ -3,6 +3,48 @@ const SAVED = "iamccs_minimax_h3_saved_settings_v1";
const widget = (node, name) => node?.widgets?.find(w => w.name === name);
const clone = value => JSON.parse(JSON.stringify(value));
const catalogFields = ["turbo_lora_name", "secondary_lora_name", "pdd_lora_name"];
let catalogRequest = null;
let catalog = null;
let catalogFetchedAt = 0;
export function fetchH3LoraCatalog(api, { force = false } = {}) {
if (catalogRequest) return catalogRequest;
if (!force && catalog && Date.now() - catalogFetchedAt < 5000) return Promise.resolve(catalog);
catalogRequest = api.fetchApi("/api/iamccs/h3/lora-catalog").then(async response => {
if (!response.ok) throw new Error(`H3 LoRA catalog HTTP ${response.status}`);
catalog = await response.json();
catalogFetchedAt = Date.now();
return catalog;
}).finally(() => { catalogRequest = null; });
return catalogRequest;
}
export function applyH3LoraCatalog(node, data) {
for (const name of catalogFields) {
const item = widget(node, name);
if (!item) continue;
const selected = typeof item.value === "string" ? item.value : "";
const available = name === "pdd_lora_name" ? data.pdd : data.choices;
item.options ||= {};
item.options.values = [...new Set(["", ...available, selected])];
item.options.iamccs_h3_assets = data.assets;
if (name === "turbo_lora_name") item.options.iamccs_fasth3_values = data.fasth3;
}
}
export function captureH3LoraDefaults(node) {
return new Map((node.widgets || []).map(item => [item.name, item.value]));
}
export function applyNewH3LoraDefaults(node, data, initial) {
if (!initial || node._iamccsH3LoraConfigured || node._iamccsH3LoraDefaultsApplied) return;
const defaults = Object.entries(data.sla_defaults || {});
if (!defaults.length || defaults.some(([name]) => !initial.has(name) || widget(node, name)?.value !== initial.get(name))) return;
node._iamccsH3LoraDefaultsApplied = true;
for (const [name, value] of defaults) widget(node, name).value = value;
}
export function h3TaskFamily(task) {
if (["v2va_controlnet", "controlnet_v2v", "h3_fun_controlnet"].includes(String(task))) return "fl2va";
return String(task).startsWith("ref2v") || ["v2va_object_swap", "v2va_face_swap"].includes(task) ? "ref2va" : "fl2va";
+31 -29
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
import { fetchH3LoraCatalog, applyH3LoraCatalog, captureH3LoraDefaults, applyNewH3LoraDefaults } from "./iamccs_h3_advisor.js";
import { mountAheadRoom } from './iamccs_ahead_control_room.js';
import { rigMedia } from './iamccs_h3_rig.js';
import { createSwapDuration, installSwapDurationQueueGuard } from './iamccs_h3_swap_duration.js';
@@ -445,7 +446,8 @@ function restoreNamedValues(node, info, nodeData) {
if (!item) continue;
const kind = spec?.[0], options = spec?.[1] || {};
let value = Object.prototype.hasOwnProperty.call(named, name) ? named[name] : options.default;
if (Array.isArray(kind) && !kind.includes(value)) value = options.default ?? kind[0];
const savedLora = ["turbo_lora_name", "secondary_lora_name", "pdd_lora_name"].includes(name) && typeof value === "string";
if (Array.isArray(kind) && !kind.includes(value) && !savedLora) value = options.default ?? kind[0];
if ((kind === "INT" || kind === "FLOAT") && (value === "" || value == null || !Number.isFinite(Number(value)) ||
(options.min !== undefined && Number(value) < Number(options.min)) ||
(options.max !== undefined && Number(value) > Number(options.max)))) value = options.default ?? 0;
@@ -471,6 +473,16 @@ function installAssetOptions(node, nodeData) {
for (const key of ["iamccs_h3_assets", "iamccs_h3_recipes", "iamccs_sla_available"]) if (key in declared) item.options[key] = declared[key];
}
}
async function refreshProLoraCatalog(node, options = {}) {
try {
const catalog = await fetchH3LoraCatalog(api, options);
applyH3LoraCatalog(node, catalog);
applyNewH3LoraDefaults(node, catalog, options.initial);
node._iamccsSettingsProRefresh?.();
} catch (error) {
console.warn("[IAMCCS H3 Settings Pro] Could not refresh the H3 LoRA catalog", error);
}
}
function serializeNamedValues(node, info, nodeData) {
if (!info || typeof info !== "object") return;
restoreNamedValues(node, {widgets_values_named: Object.fromEntries((node.widgets || []).map(item => [item.name, item.value]))}, nodeData);
@@ -1104,12 +1116,13 @@ function selectedH3AssetCompatibility(node, mode) {
return issues;
}
function firstChoice(node, name, predicate) { return choices(widget(node, name)).map(String).find((value) => value && predicate(value.toLowerCase())) || ""; }
function isFastH3LoRA(name) { const value = String(name).toLowerCase(); return (value.includes("fasth3") || value.includes("fast_h3") || value.includes("fast-h3")) && !value.includes("fused") && !value.includes("adapter_model"); }
function fastH3Asset(node, mode = shotboardMode(node)) {
return choices(widget(node, "turbo_lora_name")).map(String).find(name => {
const asset = h3AssetDescriptor(node, "turbo_lora_name", name);
return isFastH3LoRA(name) && asset?.role === "fasth3" && !asset.error && !asset.conflict
&& inferredH3AssetFamily(name, asset) === modeFamily(mode);
const verified = widget(node, "turbo_lora_name")?.options?.iamccs_fasth3_values;
const family = inferredH3AssetFamily(name, asset);
return (verified ? verified.includes(name) : asset?.role === "fasth3" && asset.native && !asset.error && !asset.conflict)
&& (!family || family === modeFamily(mode));
}) || "";
}
function turboModeGroup(mode) {
@@ -1617,29 +1630,8 @@ function normalizeSeedPolicy(node) {
if(item && (item.value == null || String(item.value).trim() === '')) item.value='fixed_per_generation';
}
function normalizeInstalledSpeedAsset(node) {
const acceleration = String(widget(node, "acceleration")?.value || "native");
const family = modeFamily(shotboardMode(node));
if (acceleration === "fasth3_dense_6step") {
const current = String(widget(node, "turbo_lora_name")?.value || "");
if (!isFastH3LoRA(current)) {
const compatible = fastH3Asset(node);
if (compatible) { setValue(node, "turbo_lora_name", compatible, false); setValue(node, "steps", 6, false); }
}
}
if (acceleration === "pdd_native_8step") {
const current = String(widget(node, "pdd_lora_name")?.value || "");
if (!isPddLoRA(current, family)) {
const compatible = firstChoice(node, "pdd_lora_name", (name) => isPddLoRA(name, family));
if (compatible) setValue(node, "pdd_lora_name", compatible, false);
}
}
normalizeSpeedPresetContract(node);
}
function mount(node) {
normalizeSeedPolicy(node);
normalizeInstalledSpeedAsset(node);
normalizeSpeedPresetContract(node);
normalizePianosequenzaStageControls(node);
installPianosequenzaStageQueueGuards(node);
@@ -2980,30 +2972,40 @@ function mount(node) {
app.registerExtension({
name: "IAMCCS.H3SettingsPro.UI",
refreshComboInNodes() {
for (const node of app.graph?._nodes || []) {
if ((node.comfyClass || node.type) === NODE_TYPE) void refreshProLoraCatalog(node, { force: true });
}
},
async beforeRegisterNodeDef(nodeType, nodeData) {
if (String(nodeData?.name || nodeData?.class_type || "") !== NODE_TYPE) return;
// ComfyUI implicitly adds a seed-control widget when the flag is absent.
// PRO already persists its compatibility slot and owns seed_policy.
const seedOptions = nodeData.input?.required?.seed?.[1];
if (seedOptions) seedOptions.control_after_generate = false;
const created = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { const result = created?.apply(this, arguments); installAssetOptions(this, nodeData); window.setTimeout(() => { mount(this); }, 0); return result; };
const created = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = created?.apply(this, arguments);
installAssetOptions(this, nodeData);
window.setTimeout(() => { mount(this); refreshProLoraCatalog(this, { initial: captureH3LoraDefaults(this) }); }, 0);
return result;
};
const configured = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (info) {
const result = configured?.apply(this, arguments);
installAssetOptions(this, nodeData);
this._iamccsH3LoraConfigured = true;
restoreNamedValues(this, info, nodeData);
normalizeSeedPolicy(this);
normalizeInstalledSpeedAsset(this);
normalizeSpeedPresetContract(this);
normalize2StageResolutionLink(this);
normalizePianosequenzaStageControls(this);
installPianosequenzaStageQueueGuards(this);
window.setTimeout(() => { mount(this); this._iamccsSettingsProRefresh?.(); }, 0);
window.setTimeout(() => { mount(this); refreshProLoraCatalog(this); }, 0);
return result;
};
const serialized = nodeType.prototype.onSerialize; nodeType.prototype.onSerialize = function (info) {
const result = serialized?.apply(this, arguments);
normalizeSeedPolicy(this);
normalizeInstalledSpeedAsset(this);
normalizeSpeedPresetContract(this);
normalize2StageResolutionLink(this);
normalizePianosequenzaStageControls(this);
+43 -105
View File
@@ -3,7 +3,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { createH3AdvisorPanel, selectH3SpeedAsset, h3TaskFamily } from "./iamccs_h3_advisor.js";
import { createH3AdvisorPanel, selectH3SpeedAsset, h3TaskFamily, fetchH3LoraCatalog, applyH3LoraCatalog, captureH3LoraDefaults, applyNewH3LoraDefaults } from "./iamccs_h3_advisor.js";
import { createH3SLAPanel, slaValues } from "./iamccs_h3_speed_panel.js";
import { H3_DELIVERY_PAIRS, deliveryPairValues, h3ModeBaselines } from "./iamccs_h3_delivery_presets.js";
import { createH3MotionContextPanel, createH3WindowPanel } from "./iamccs_h3_window_presets.js";
@@ -526,51 +526,6 @@ function getWidget(node, name) {
return node?.widgets?.find((widget) => widget?.name === name || widget?.label === name) || null;
}
let h3LoraRegistryRequest = null;
let h3LoraRegistryAt = 0;
let h3MetadataVerifiedFastLoras = [];
async function fetchInstalledH3Loras({ force = false } = {}) {
const now = Date.now();
if (!force && h3LoraRegistryRequest && now - h3LoraRegistryAt < 5000) return h3LoraRegistryRequest;
h3LoraRegistryAt = now;
h3LoraRegistryRequest = (async () => {
const response = await api.fetchApi("/object_info/LoraLoaderModelOnly");
if (!response?.ok) throw new Error(`LoRA registry HTTP ${response?.status || "error"}`);
const data = await response.json();
const values = data?.LoraLoaderModelOnly?.input?.required?.lora_name?.[0];
try {
// The generic LoRA endpoint has filenames only. IAMCCS' own schema
// adds the safetensors-header classification performed by Python,
// which is authoritative even when the adapter was renamed.
const settingsResponse = await api.fetchApi("/object_info/IAMCCS_ShotboardH3Settings");
if (settingsResponse?.ok) {
const settingsData = await settingsResponse.json();
const schema = settingsData?.IAMCCS_ShotboardH3Settings?.input;
if (schema) h3SettingsNodeSpecs = {...schema.required, ...schema.optional};
const fast = settingsData?.IAMCCS_ShotboardH3Settings?.input?.required?.turbo_lora_name?.[1]?.iamccs_fasth3_values;
h3MetadataVerifiedFastLoras = Array.isArray(fast)
? fast.map(value => String(value || "")).filter(Boolean)
: [];
}
} catch (error) {
console.warn("[IAMCCS MiniMax H3] Could not read metadata-verified FastH3 choices", error);
}
const h3 = (Array.isArray(values) ? values : [])
.map(value => String(value || ""))
.filter(value => value.toLowerCase().includes("h3"))
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
return ["", ...new Set([...h3, ...h3MetadataVerifiedFastLoras])];
})();
try {
return await h3LoraRegistryRequest;
} catch (error) {
h3LoraRegistryRequest = null;
console.warn("[IAMCCS MiniMax H3] Could not refresh the live H3 LoRA registry", error);
return [];
}
}
function replaceSelectChoices(select, choices, value) {
if (!(select instanceof HTMLSelectElement)) return;
select.replaceChildren(...choices.map(choice => {
@@ -582,64 +537,24 @@ function replaceSelectChoices(select, choices, value) {
select.value = choices.includes(String(value || "")) ? String(value || "") : "";
}
async function refreshNodeH3LoraChoices(node, { force = false } = {}) {
const choices = await fetchInstalledH3Loras({ force });
if (choices.length < 1) return false;
let changed = false;
for (const name of ["turbo_lora_name", "secondary_lora_name", "pdd_lora_name", "fused_turbo_model_name"]) {
const widget = getWidget(node, name);
if (!widget) continue;
// Python inspects safetensors metadata and publishes compatible
// converted FastH3 adapters here. Preserve that authoritative list
// even when a user renamed the file without the token "h3"; the live
// generic LoRA registry endpoint exposes filenames but not metadata.
const previousFast = name === "turbo_lora_name" && Array.isArray(widget.options?.iamccs_fasth3_values)
? widget.options.iamccs_fasth3_values.map(value => String(value || "")).filter(Boolean) : [];
const metadataVerifiedFast = name === "turbo_lora_name"
? [...new Set([...previousFast, ...h3MetadataVerifiedFastLoras])]
: [];
const serverSpec = h3SettingsNodeSpecs[name];
const effectiveChoices = Array.isArray(serverSpec?.[0]) ? serverSpec[0].slice() : ["", ...new Set([...choices.filter(Boolean), ...metadataVerifiedFast])];
const previous = Array.isArray(widget.options?.values) ? widget.options.values.map(String) : [];
widget.options = {...(widget.options || {}), ...(serverSpec?.[1] || {})};
widget.options.values = effectiveChoices.slice();
if (name === "turbo_lora_name") widget.options.iamccs_fasth3_values = metadataVerifiedFast.slice();
// Preserve a missing selection so users can identify and replace it explicitly.
changed ||= previous.length !== effectiveChoices.length || previous.some((value, index) => value !== effectiveChoices[index]);
changed ||= previousFast.length !== metadataVerifiedFast.length || previousFast.some((value, index) => value !== metadataVerifiedFast[index]);
if (
name === "turbo_lora_name"
&& String(getWidget(node, "acceleration")?.value || "") === "fasth3_dense_6step"
&& metadataVerifiedFast.length
&& !metadataVerifiedFast.includes(String(widget.value || ""))
) {
widget.value = metadataVerifiedFast[0];
changed = true;
emitH3SettingsChanged(node, "turbo_lora_name", widget.value);
async function refreshNodeH3LoraChoices(node, options = {}) {
try {
const catalog = await fetchH3LoraCatalog(api, options);
applyH3LoraCatalog(node, catalog);
applyNewH3LoraDefaults(node, catalog, options.initial);
for (const name of ["turbo_lora_name", "secondary_lora_name", "pdd_lora_name"]) {
const widget = getWidget(node, name);
const spec = h3SettingsNodeSpecs[name];
if (spec) h3SettingsNodeSpecs[name] = [spec[0], {...spec[1], iamccs_h3_assets: catalog.assets}];
if (widget) replaceSelectChoices(node?._iamccsH3SettingsControls?.get?.(name), widget.options.values, widget.value);
}
replaceSelectChoices(node?._iamccsH3SettingsControls?.get?.(name), effectiveChoices, widget.value);
}
const pddWidget = getWidget(node, "pdd_lora_name");
if (pddWidget) {
const pddChoices = choices.filter((value) => {
if (!value) return true;
const lower = value.toLowerCase();
return (lower.includes("pdd") && lower.includes("acc"))
|| (lower.includes("h3") && lower.includes("acc") && lower.includes("8step"));
});
const previous = Array.isArray(pddWidget.options?.values) ? pddWidget.options.values.map(String) : [];
pddWidget.options = pddWidget.options || {};
pddWidget.options.values = pddChoices.slice();
if (!pddChoices.includes(String(pddWidget.value || ""))) pddWidget.value = "";
changed ||= previous.length !== pddChoices.length || previous.some((value, index) => value !== pddChoices[index]);
replaceSelectChoices(node?._iamccsH3SettingsControls?.get?.("pdd_lora_name"), pddChoices, pddWidget.value);
}
if (changed) {
node?._iamccsRefreshH3SettingsUi?.();
node?.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
return true;
} catch (error) {
console.warn("[IAMCCS MiniMax H3] Could not refresh the H3 LoRA catalog", error);
return false;
}
return changed;
}
function hideWidget(widget) {
@@ -21954,7 +21869,7 @@ function renderShotboardH3Settings(node) {
const widget = getWidget(node, "turbo_lora_name");
const local = Array.isArray(widget?.options?.iamccs_fasth3_values)
? widget.options.iamccs_fasth3_values.map(value => String(value || "")).filter(Boolean) : [];
const compatible = [...new Set([...local, ...h3MetadataVerifiedFastLoras])];
const compatible = local;
const current = String(widget?.value || "");
return compatible.includes(current) ? current : (compatible[0] || "");
};
@@ -23756,6 +23671,15 @@ app.registerExtension({
},
async beforeRegisterNodeDef(nodeType, nodeData) {
const name = String(nodeData?.name || nodeData?.class_type || "");
if (name === "IAMCCS_LongVid-v1" && !nodeType.prototype._iamccsLongVidCatalogWrapped) {
nodeType.prototype._iamccsLongVidCatalogWrapped = true;
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (...args) {
const result = onConfigure?.apply(this, args);
refreshNodeH3LoraChoices(this);
return result;
};
}
if (name === "IAMCCS_ShotboardH3Settings") h3SettingsNodeSpecs = {...nodeData.input?.required, ...nodeData.input?.optional};
if (name === "IAMCCS_ShotboardH3Settings") {
if (nodeType.prototype._iamccsH3SettingsUiWrapped) return;
@@ -23763,8 +23687,9 @@ app.registerExtension({
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function (...args) {
const result = onConfigure?.apply(this, args);
this._iamccsH3LoraConfigured = true;
restoreH3SettingsWidgetState(this, args[0], nodeData);
refreshNodeH3LoraChoices(this, { force: true });
refreshNodeH3LoraChoices(this);
window.setTimeout(() => {
if (typeof this._iamccsRefreshH3SettingsUi === "function") {
this._iamccsRefreshH3SettingsUi();
@@ -23797,7 +23722,7 @@ app.registerExtension({
const result = onConfigure?.apply(this, args);
repairMiniMaxH3WidgetState(this, args[0]);
restoreMiniMaxH3NamedSettings(this, args[0]);
refreshNodeH3LoraChoices(this, { force: true });
refreshNodeH3LoraChoices(this);
enforceMiniMaxSingleCineLinxOutput(this);
window.setTimeout(() => {
repairMiniMaxH3WidgetState(this, args[0]);
@@ -23831,10 +23756,23 @@ app.registerExtension({
return result;
};
},
refreshComboInNodes(definitions) {
const settings = definitions?.IAMCCS_ShotboardH3Settings?.input;
if (settings) h3SettingsNodeSpecs = {...settings.required, ...settings.optional};
for (const node of app.graph?._nodes || []) {
if (["IAMCCS_MiniMaxH3ShotPlanner", "IAMCCS_ShotboardH3Settings", "IAMCCS_LongVid-v1"].includes(nodeClassName(node))) {
void refreshNodeH3LoraChoices(node, { force: true });
}
}
},
async nodeCreated(node) {
if (nodeClassName(node) === "IAMCCS_LongVid-v1") {
refreshNodeH3LoraChoices(node);
return;
}
if (nodeClassName(node) === "IAMCCS_ShotboardH3Settings") {
scheduleRender(node);
refreshNodeH3LoraChoices(node, { force: true });
refreshNodeH3LoraChoices(node, { initial: captureH3LoraDefaults(node) });
return;
}
if (nodeClassName(node) !== "IAMCCS_MiniMaxH3ShotPlanner") return;
@@ -23843,7 +23781,7 @@ app.registerExtension({
enforceMiniMaxSingleCineLinxOutput(node);
window.setTimeout(() => enforceMiniMaxSingleCineLinxOutput(node), 120);
scheduleRender(node);
refreshNodeH3LoraChoices(node, { force: true });
refreshNodeH3LoraChoices(node);
},
});