Files
Taremin a867ae3972 feat: スニペットでのシステムモデル一覧動的参照機能 (@models/<category>) を追加
- @models/<category> タグによる ComfyUI folder_paths 連携ロジックの実装
- loras, diffusion_models, checkpoints などの全登録モデルの自動取得・展開
- ホワイトリストによる無効なモデルカテゴリへの安全拒否処理
- 関連ユニットテストの追加および README.md への仕様追記
2026-08-12 04:51:26 +09:00

204 lines
7.6 KiB
Python

import glob
import os
import json
import re
snippets = None
DIR_TAG_PATTERN = re.compile(r'\${dir:([^|}]+)(?:\|([^}]+))?}')
def get_snippets():
global snippets
return snippets
def get_node_root_from_snippet_path(snippet_json_path: str, custom_nodes_path: str) -> str:
"""
スニペット JSON のパスから、所属するカスタムノードのルートディレクトリを取得します。
"""
abs_snippet_path = os.path.abspath(snippet_json_path)
abs_custom_nodes_path = os.path.abspath(custom_nodes_path)
try:
# custom_nodes_path の直下のディレクトリを取得を試みる
rel_path = os.path.relpath(abs_snippet_path, abs_custom_nodes_path)
parts = rel_path.split(os.sep)
if len(parts) > 1 and parts[0] != "..":
return os.path.join(abs_custom_nodes_path, parts[0])
except ValueError:
pass
# フォールバック: snippets フォルダの親ディレクトリ
parts = abs_snippet_path.split(os.sep)
if "snippets" in parts:
idx = parts.index("snippets")
return os.sep.join(parts[:idx])
return os.path.dirname(abs_snippet_path)
def resolve_models_files(path_pattern: str) -> list:
"""
@models/<category>/<sub_pattern> 形式のパスからモデルファイル一覧を取得します。
"""
if not path_pattern.startswith("@models/"):
return None
clean_path = path_pattern[len("@models/"):].lstrip("/")
if not clean_path:
return []
parts = clean_path.split("/", 1)
category = parts[0]
sub_pattern = parts[1] if len(parts) > 1 else None
# ComfyUI の folder_paths 連携
try:
import folder_paths
if hasattr(folder_paths, "folder_names_and_paths") and category in folder_paths.folder_names_and_paths:
raw_files = folder_paths.get_filename_list(category) or []
# 不当な上位階層参照のチェック (Path Traversal 防御)
safe_files = []
for f in raw_files:
norm_f = f.replace("\\", "/")
if ".." not in norm_f.split("/"):
safe_files.append(f)
if sub_pattern:
import fnmatch
filtered = []
for f in safe_files:
norm_f = f.replace("\\", "/")
if fnmatch.fnmatch(norm_f, sub_pattern) or fnmatch.fnmatch(os.path.basename(norm_f), sub_pattern):
filtered.append(f)
return filtered
return safe_files
except (ImportError, Exception):
pass
return None
def expand_dir_snippets(insert_text: str, base_dir: str) -> str:
"""
insert_text 内の ${dir:pattern|options} 形式のタグを解析し、特定ディレクトリのファイル一覧に展開します。
"""
if not isinstance(insert_text, str) or "${dir:" not in insert_text:
return insert_text
base_abs = os.path.abspath(base_dir)
def replace_tag(match):
path_pattern = match.group(1).strip()
options_str = match.group(2) or ""
# オプション解析
opts = {}
if options_str:
for part in options_str.split('|'):
part = part.strip()
if not part:
continue
if '=' in part:
k, v = part.split('=', 1)
opts[k.strip().lower()] = v.strip()
else:
opts[part.lower()] = True
pattern_reg = opts.get("pattern")
replace_str = opts.get("replace", "")
fmt = opts.get("format", "choice")
var_num = opts.get("var") or opts.get("index") or opts.get("num") or "1"
if not var_num.isdigit():
var_num = "1"
# 1. @models/<category> パスの判定とファイル取得
found_files = resolve_models_files(path_pattern)
# 2. 通常の自カスタムノード配下のファイル走査
if found_files is None:
target_path = os.path.abspath(os.path.join(base_abs, path_pattern))
# ワイルドカード文字直前までのパスを取得
target_path_clean = target_path.split('*')[0].split('?')[0]
search_root = target_path_clean if os.path.isdir(target_path_clean) else os.path.dirname(target_path_clean)
try:
if os.path.commonpath([base_abs, search_root]) != base_abs:
print(f"[Webui Monaco Prompt] Access denied outside base dir: {target_path}")
return ""
except ValueError:
return ""
# ファイル走査
found_entries = sorted(glob.glob(target_path, recursive=True))
found_files = [f for f in found_entries if os.path.isfile(f)]
if not found_files:
return ""
processed_names = []
for f in found_files:
rel_name = os.path.basename(f)
if pattern_reg:
try:
rel_name = re.sub(pattern_reg, replace_str, rel_name)
except re.error as e:
print(f"[Webui Monaco Prompt] Invalid regex pattern '{pattern_reg}': {e}")
processed_names.append(rel_name)
# フォーマット展開
if fmt == "lines":
return "\n".join(processed_names)
elif fmt == "comma":
return ", ".join(processed_names)
else: # choice
choices = [n.replace(",", "\\,").replace("|", "\\|") for n in processed_names]
return f"${{{var_num}|{','.join(choices)}|}}"
return DIR_TAG_PATTERN.sub(replace_tag, insert_text)
def load_snippets(target_dir: str):
global snippets
snippets = []
custom_nodes_path = target_dir
for path in glob.glob(os.path.join(custom_nodes_path, "*", "snippets", "**", "*.json"), recursive=True):
try:
with open(path, "r", encoding="utf-8") as fp:
loaded_snippets = json.load(fp)
base_dir = get_node_root_from_snippet_path(path, custom_nodes_path)
for loaded_snippet in (loaded_snippets if isinstance(loaded_snippets, list) else [loaded_snippets]):
label_text = loaded_snippet.get("label")
insert_text = loaded_snippet.get("insertText")
if (
label_text is not None and isinstance(label_text, str) and
insert_text is not None and isinstance(insert_text, str)
):
# ディレクトリタグの展開処理を適用
expanded_insert_text = expand_dir_snippets(insert_text, base_dir)
snippet = {}
snippet["label"] = label_text
snippet["insertText"] = expanded_insert_text
snippet["path"] = os.path.relpath(path, custom_nodes_path)
detail_text = loaded_snippet.get("detail")
if detail_text is not None:
snippet["detail"] = detail_text
documentation_text = loaded_snippet.get("documentation")
if documentation_text is not None:
snippet["documentation"] = documentation_text
snippets.append(snippet)
except Exception as e:
print("[SKIP] Webui Monaco Prompt: invalid json:", path, e)