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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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; });
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user