Files
StarAsh042 9e8d735c22 feat(nodes): RollingArtist 节点与包注册
节点层只做参数规范化、调用内核、组装输出三件事;
NODE_CLASS_MAPPINGS 的 key 一旦发布即是对外契约。
2026-10-14 15:12:00 +08:00

507 lines
22 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ComfyUI RollingArtist 节点。
从艺术家 CSV 中随机抽取艺术家并分配随机权重,生成可直接使用的提示词,
同时输出结构化 JSON(艺术家 / 权重 / 是否 Top / 状态)。
核心逻辑已拆分到 ``ra_core`` 子包,本文件仅保留:
- 节点接口定义(INPUT_TYPES / RETURN_TYPES 等)
- 单次生成的编排(参数规范化、锁与记录、输出组装)
"""
import json
import random
import threading
from typing import Any, Dict, List, Optional, Set, Tuple
from .ra_core.artists import (
ArtistRepository,
compute_top_count,
get_artist_repository,
select_artists,
split_names,
)
from .ra_core.constants import (
KIND_ARTIST,
LOGGER,
WEIGHT_STEP,
)
from .ra_core.db import get_db
from .ra_core.params import as_bool, as_float, as_int, as_text
from .ra_core.weights import (
build_prompt,
generate_weights,
normalize_dedup_mode,
)
# 兼容旧版本从本模块导入常量的用法
__all__ = [
"RollingArtist",
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"WEIGHT_STEP",
]
_STATUS_OK = "OK"
# 参数容错转换与 RollingCharacter 共用一份实现(见 ra_core/params.py)
_as_text = as_text
_as_int = as_int
_as_float = as_float
_as_bool = as_bool
_text = _as_text
class RollingArtist:
"""RollingArtist 节点类。
功能特性:
- 支持从 CSV 加载艺术家列表,自动识别表头与列,可自定义路径
- 支持 Top 艺术家优先池、排除名单、强制包含名单
- 权重范围与总和双约束(步长 0.1,总和精确且不会越界)
- 支持 4 种去重模式
- 结构化输出 artists_json,便于下游处理
线程安全:
- 艺术家 CSV 解析结果按文件签名缓存,多个实例共享
- 去重记录存在 SQLite(``tested_db_path``)里,
“判重 -> 生成 -> 记录”整段运行在同一个 ``BEGIN IMMEDIATE`` 事务中:
进程内(可重入锁)与跨进程(SQLite 文件锁)都不会生成重复组合
说明:4.1.0 起删除了"穷举模式"(把 (艺术家, 权重) 组合逐个不重复输出的玩法),
``artist_count=1`` 现在就是普通的随机抽 1 个。
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]:
"""定义节点的输入参数类型和界面显示。
界面顺序即下方字典顺序,按“抽样 → Top 池 → 权重 → 前缀/种子 → 抽样偏好 → 名单 →
去重 → 高级(CSV 数据源、数据库与旧记录路径)”归类;很少改动的 CSV / 路径标记为
``advanced``,由前端折叠到“高级输入”。
关于重排的兼容性:当前前端保存工作流时同时写 ``widgets_values_named``(按名字)与
``widgets_values``(按位置),读取时优先按名字恢复(settingStore 的 LiteGraph
``serialize`` / ``getNamedValues``),因此本版本前端存出的工作流重排后依然正确;
只有“仅含位置数组”的旧版工作流会整体错位,必要时可用官方 ``io.NodeReplace``
(``old_widget_ids`` 把位置索引映射回输入 id)在换节点类名时兜底。
注意:``generate_artists`` 的形参顺序是给外部脚本按位置调用的接口,保持不变;
ComfyUI 本身按关键字传参,与这里的顺序无关。
参数说明使用 ``tooltip`` 键(V1 的 ``description`` 界面不读;V3 schema 的通用输入
参数即 ``tooltip`` / ``advanced`` / ``display_name``)。这里是默认文案(中文);
中英文切换由 ``locales/<语言>/nodeDefs.json`` 提供,
节点帮助页(信息面板)由 ``web/docs/RollingArtist/<语言>.md`` 提供。
"""
return {
"required": {
# ---------------- 抽样:抽几个人、从哪里抽 ----------------
"artist_count": (
"INT",
{
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"display": "slider",
"tooltip": "每次生成的艺术家数量(1-10),设为 1 就是随机抽 1 个",
},
),
"artist_top_ratio": (
"FLOAT",
{
"default": 0.01,
"min": 0.01,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "Top 池占比(0.01-1.0):按 CSV 行序取前 ceil(总数×比例) 个(至少 1 个)作为 Top 池,"
"行序越靠前的艺术家通常作品越多",
},
),
"artist_top_count": (
"INT",
{
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"display": "slider",
"tooltip": "每次至少抽取几个 Top 池艺术家(1-10);"
"仅在“Top 优先抽样”开启且 Top 池非空时生效",
},
),
# ---------------- 权重:范围与总和 ----------------
"weight_min": (
"FLOAT",
{
"default": 0.2,
"min": 0.1,
"max": 2.0,
"step": 0.1,
"display": "slider",
"tooltip": "单个艺术家的权重下限(步长 0.1);与上限写反时自动交换,负数会被夹到 0",
},
),
"weight_max": (
"FLOAT",
{
"default": 1.0,
"min": 0.1,
"max": 2.0,
"step": 0.1,
"display": "slider",
"tooltip": "单个艺术家的权重上限(步长 0.1);每个权重都落在上限与下限之间",
},
),
"weight_total": (
"FLOAT",
{
"default": 2.0,
"min": 0.0,
"max": 20.0,
"step": 0.5,
"display": "slider",
"tooltip": "所有权重之和(步长 0.5),按总和精确分配,超出可行区间时夹到边界",
},
),
"weight_curve": (
["flat", "dominant", "ramp"],
{
"default": "flat",
"tooltip": "只影响“谁分得多”,不影响上下限与总和。"
"flat=平坦(现状,各位权重接近,风格平均混合);"
"dominant=主次分明(随机一位当主风格,权重明显更高);"
"ramp=阶梯(按随机名次递减)",
},
),
# ---------------- 输出形式与随机性 ----------------
"artists_prefix": (
"BOOLEAN",
{
"default": True,
"tooltip": "开启时输出 artist:名称(例如 (artist:xxx:0.8)),关闭时只输出名称本身",
},
),
"seed": (
"INT",
{
"default": 1234,
"min": 0,
"max": 4294967295,
"tooltip": "随机种子。同一种子在同一组参数下结果可复现;"
"去重模式与重试次数会改变实际的抽样序列",
},
),
},
"optional": {
# ---------------- 抽样偏好 ----------------
"use_top_priority": (
"BOOLEAN",
{
"default": True,
"tooltip": "开启时优先从 Top 池抽人,并保证含 artist_top_count 个 Top 艺术家;"
"关闭时从完整 CSV 均匀抽样",
},
),
"sort_by_weight": (
"BOOLEAN",
{
"default": True,
"tooltip": "开启时按权重从高到低重排提示词里的艺术家顺序;"
"顺序会影响 full_prompt 去重键",
},
),
# ---------------- 名单 ----------------
"exclude_artists": (
"STRING",
{
"default": "",
"tooltip": "要排除的艺术家名,英文逗号分隔(例如 a,b,c);被排除的名字不会出现在结果中",
},
),
"force_include": (
"STRING",
{
"default": "",
"tooltip": "必须出现的艺术家名,英文逗号分隔;优先级高于“排除艺术家”,"
"数量达到 artist_count 时直接从中随机抽取",
},
),
# ---------------- 去重 ----------------
"dedup_mode": (
["none", "full_prompt", "artist_set", "artist_list"],
{
"default": "none",
"tooltip": "去重依据:none=只记录不判重;full_prompt=艺术家顺序与权重完全一致;"
"artist_set=艺术家集合一致(忽略顺序与权重);"
"artist_list=排序后的名字一致(忽略权重)",
},
),
"max_attempts": (
"INT",
{
"default": 10,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "去重模式下的最大重试次数(1-100);重试后仍全部命中已测组合时接受最后一次结果,"
"并输出 WARNING 日志",
},
),
# ---------------- 高级:CSV 与数据库(界面默认折叠) ----------------
"custom_csv_path": (
"STRING",
{
"default": "",
"advanced": True,
"tooltip": "自定义艺术家 CSV 的路径;留空使用节点目录下的 danbooru_art_001.csv",
},
),
"tested_db_path": (
"STRING",
{
"default": "",
"advanced": True,
"tooltip": "记录数据库(SQLite)路径;留空使用节点目录下的 rollingartist.sqlite。"
"已测记录存在这里,更换该路径相当于重置去重进度。"
"默认路径的数据库每次启动会被清理,要跨会话保留请显式指定",
},
),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("prompt", "artists_json", "tested_count")
FUNCTION = "generate_artists"
CATEGORY = "RollingArtist"
# 与原实现保持一致:作为输出节点,即使输出未被下游使用也会执行
OUTPUT_NODE = True
DESCRIPTION = "从艺术家 CSV 中随机抽取艺术家并分配随机权重,生成带权重的提示词,并输出结构化 JSON。"
OUTPUT_TOOLTIPS = (
"生成的提示词;末尾总是带一个逗号,便于直接拼接角色节点的多行输出",
"结构化结果 JSON(艺术家 / 权重 / 是否 Top / 状态)",
"当前数据库里已记录的组合数量(字符串形式,可直接接到文本节点查看)",
)
def __init__(self) -> None:
"""初始化节点实例:准备好仓库与状态容器。
注意:真正的加载发生在 ``generate_artists`` 中(按文件签名缓存),
这里只做一次默认 CSV 的预热,且任何异常都不会影响节点注册。
"""
self._repo: ArtistRepository = get_artist_repository()
self._lock = threading.RLock()
self.artists: List[str] = []
self.top_count = 0
try:
# 文件签名缓存由 ArtistRepository 统一维护,实例不再保存签名
self.artists, _ = self._repo.load("", "trigger")
self.top_count = compute_top_count(len(self.artists), 0.01)
except Exception as error: # pragma: no cover - 初始化必须保持健壮
LOGGER.error("[RollingArtist] 初始化加载默认 CSV 失败: %s", error)
# ------------------------------------------------------------------
# 兼容接口(旧版本曾暴露的方法,保留以避免破坏外部调用)
# ------------------------------------------------------------------
@property
def top_pool(self) -> List[str]:
"""Top 艺术家列表(按当前 Top 数量惰性切片)。"""
with self._lock:
return self.artists[: self.top_count]
@property
def non_top_pool(self) -> List[str]:
"""非 Top 艺术家列表(按当前 Top 数量惰性切片)。"""
with self._lock:
return self.artists[self.top_count :]
def load_artists(self, csv_path: Optional[str] = None, column: str = "trigger") -> List[str]:
"""加载(并按文件修改时间缓存)艺术家列表。"""
artists, _ = self._repo.load(csv_path or "", column)
if artists:
with self._lock:
self.artists = artists
return artists
def update_top_pool(self, top_ratio: float) -> int:
"""按比例刷新 Top 艺术家数量,返回该数量。"""
with self._lock:
self.top_count = compute_top_count(len(self.artists), top_ratio)
return self.top_count
def generate_fixed_weights(
self, count: int, weight_min: float, weight_max: float, weight_total: float, rng: random.Random
) -> List[float]:
"""(兼容保留)生成符合约束的权重列表。"""
return generate_weights(count, weight_min, weight_max, weight_total, rng)
# ------------------------------------------------------------------
# 主流程
# ------------------------------------------------------------------
def generate_artists(
self,
artist_count: int,
artist_top_count: int,
artist_top_ratio: float,
artists_prefix: bool,
weight_min: float,
weight_max: float,
weight_total: float,
seed: int,
custom_csv_path: str = "",
exclude_artists: str = "",
sort_by_weight: bool = True,
use_top_priority: bool = True,
force_include: str = "",
dedup_mode: str = "none",
max_attempts: int = 10,
weight_curve: str = "flat",
tested_db_path: str = "",
) -> Tuple[str, str, str]:
"""生成艺术家提示词。
形参顺序是给外部脚本按位置调用的接口(与 ``INPUT_TYPES`` 的界面顺序无关,
ComfyUI 按关键字传参)。
参数:
tested_db_path: 记录数据库路径(空 = 节点目录下的 rollingartist.sqlite)
读取的 CSV 列固定为 ``trigger``(提示词可直接使用的列:空格与括号已转义),不可配置;
CSV 里没有该列时回退到首列并记 WARNING。
返回:
``(prompt, artists_json, tested_count)``;``tested_count`` 是字符串,
方便直接接到文本节点查看
"""
count = _as_int(artist_count, 1)
if count < 1:
raise ValueError(f"[RollingArtist] artist_count 必须 >= 1,当前值: {artist_count!r}")
# 1) 加载艺术家列表(内容未变化时直接命中缓存,不重复解析 CSV)
# 列固定为 trigger:该列已转义,可直接进提示词
artists, _ = self._repo.load(custom_csv_path, "trigger")
if not artists:
raise ValueError(
"[RollingArtist] 未能从 CSV 加载艺术家列表,请检查文件是否存在、"
f"编码是否为 UTF-8、是否含有 trigger 列(没有该列时会退回首列): "
f"{self._repo.resolve_path(_text(custom_csv_path))}"
"(若本节点是从旧工作流载入的,请检查“自定义 CSV 路径”是否已清空)"
)
# 2) 参数规范化(兼容 API 直接传入 None / 字符串的情况)
exclude_set = set(split_names(exclude_artists))
force_list = split_names(force_include)
candidates = artists if not exclude_set else [name for name in artists if name not in exclude_set]
if not candidates and not force_list:
raise ValueError("[RollingArtist] exclude_artists 排除了全部艺术家,无可用艺术家")
top_count = compute_top_count(len(artists), artist_top_ratio)
with self._lock:
self.artists, self.top_count = artists, top_count
prefix = "artist:" if _as_bool(artists_prefix, True) else ""
dedup = normalize_dedup_mode(dedup_mode)
attempts = max(1, _as_int(max_attempts, 10))
rng_seed = _as_int(seed, 0)
low = _as_float(weight_min, 0.2)
high = _as_float(weight_max, 1.0)
total = _as_float(weight_total, 2.0)
# 3) 记录库:SQLite(已测记录)
db = get_db(tested_db_path)
# 4) 整段生成处于同一个事务内,进程内 / 跨进程都不会产生重复组合
with db.generation_lock():
last_result: Optional[Tuple[List[str], List[float], str]] = None
for attempt in range(attempts):
rng = random.Random(rng_seed + attempt)
selected = select_artists(
rng,
count,
_as_int(artist_top_count, 1),
force_list,
artists,
top_count if _as_bool(use_top_priority, True) else 0,
exclude_set,
)
if not selected:
continue
if len(selected) < count:
LOGGER.warning(
"[RollingArtist] 可用艺术家不足:请求 %d 个,实际选中 %d 个"
"(请检查 artist_count、排除名单或 CSV 规模)",
count,
len(selected),
)
weights = generate_weights(len(selected), low, high, total, rng, weight_curve)
if _as_bool(sort_by_weight, True):
pairs = sorted(zip(selected, weights), key=lambda item: item[1], reverse=True)
selected = [item[0] for item in pairs]
weights = [item[1] for item in pairs]
prompt = build_prompt(selected, weights, prefix)
last_result = (selected, weights, prompt)
if dedup == "none" or not db.is_tested(dedup, selected, weights, KIND_ARTIST):
break
if last_result is None:
raise ValueError("[RollingArtist] 未选出任何艺术家,请检查 artist_count 与排除名单")
selected, weights, prompt = last_result
# 循环耗尽说明末次结果仍命中已测组合(dedup != none 时才可能),
# 保留原有“接受并记录”语义,仅补上信号便于排查“反复出同一图”
if dedup != "none" and db.is_tested(dedup, selected, weights, KIND_ARTIST):
LOGGER.warning(
"[RollingArtist] 已试 %d 次均命中已测组合(dedup_mode=%s),返回重复结果: %s",
attempts,
dedup,
prompt,
)
if not db.record(selected, weights, prompt, KIND_ARTIST):
LOGGER.error(
"[RollingArtist] 已测记录写入数据库失败(%s),本次组合未落盘,后续可能重复生成: %s",
db.path,
prompt,
)
# 在事务内取计数:与本次写入处于同一快照,避免事务外再查导致
# 「显示的数量」与「刚写入的结果」不一致
tested_total = db.count(KIND_ARTIST)
payload = self._build_payload(selected, weights, artists, top_count)
return prompt, json.dumps(payload, ensure_ascii=False), str(tested_total)
# ------------------------------------------------------------------
# 内部实现
# ------------------------------------------------------------------
@staticmethod
def _build_payload(
selected: List[str], weights: List[float], artists: List[str], top_count: int
) -> Dict[str, Any]:
"""组装结构化输出:艺术家 / 权重 / 是否 Top / 状态。"""
# top_count >= 总数时全员都是 Top,无需构建集合(大 CSV 下可省下可观内存)
top_names: Optional[Set[str]] = None
if top_count < len(artists):
top_names = set(artists[:top_count])
return {
"artists": [
{
"name": name,
"weight": weight,
"top": top_names is None or name in top_names,
}
for name, weight in zip(selected, weights)
],
"status": _STATUS_OK,
}
# 注册节点类
NODE_CLASS_MAPPINGS = {"RollingArtist": RollingArtist}
# 设置节点显示名称
NODE_DISPLAY_NAME_MAPPINGS = {"RollingArtist": "Rolling Artist"}