diff --git a/__init__.py b/__init__.py index d87b193..d86442e 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/iamccs_h3_advisor.py b/iamccs_h3_advisor.py index 28c538b..d0f092a 100644 --- a/iamccs_h3_advisor.py +++ b/iamccs_h3_advisor.py @@ -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, diff --git a/iamccs_h3_lora_catalog.py b/iamccs_h3_lora_catalog.py new file mode 100644 index 0000000..6a74647 --- /dev/null +++ b/iamccs_h3_lora_catalog.py @@ -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) diff --git a/iamccs_minimax_h3_atomic_backend.py b/iamccs_minimax_h3_atomic_backend.py index db1f084..f1f4a5d 100644 --- a/iamccs_minimax_h3_atomic_backend.py +++ b/iamccs_minimax_h3_atomic_backend.py @@ -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}") diff --git a/iamccs_minimax_h3_shotboard.py b/iamccs_minimax_h3_shotboard.py index 6d89088..7ad822b 100644 --- a/iamccs_minimax_h3_shotboard.py +++ b/iamccs_minimax_h3_shotboard.py @@ -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]) diff --git a/tests/test_h3_lora_catalog_ui.cjs b/tests/test_h3_lora_catalog_ui.cjs new file mode 100644 index 0000000..992d832 --- /dev/null +++ b/tests/test_h3_lora_catalog_ui.cjs @@ -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; }); diff --git a/web/iamccs_h3_advisor.js b/web/iamccs_h3_advisor.js index 06c45b3..f3b4e8f 100644 --- a/web/iamccs_h3_advisor.js +++ b/web/iamccs_h3_advisor.js @@ -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"; diff --git a/web/iamccs_h3_settings_pro_ui.js b/web/iamccs_h3_settings_pro_ui.js index 4910ebd..94874a0 100644 --- a/web/iamccs_h3_settings_pro_ui.js +++ b/web/iamccs_h3_settings_pro_ui.js @@ -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); diff --git a/web/iamccs_minimax_h3_shotboard_ui.js b/web/iamccs_minimax_h3_shotboard_ui.js index fc4d233..a995850 100644 --- a/web/iamccs_minimax_h3_shotboard_ui.js +++ b/web/iamccs_minimax_h3_shotboard_ui.js @@ -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); }, });