Files
StarAsh042 da16f976ff feat(nodes): 新增 RollingCharacter 节点
角色节点输出不带权重的提示词,支持作品筛选与角色描述开关;
两个节点共用 ra_core,节点层不重复实现解析逻辑。
2026-10-16 09:12:00 +08:00

443 lines
21 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 RollingCharacter 节点。
从角色 CSV(默认 ``danbooru_character_001.csv``)中随机抽取角色,
按 ``角色标签,作品标签,core_tags`` 逐行输出(多个角色用「逗号 + 换行」分隔),
同时输出结构化 JSON 与已测数量。
与 RollingArtist 的关系:抽样与进度输出的做法一致;差异来自数据与产物:
- **不做权重分配**:角色标签直接输出,不套 ``(名称:权重)``,因此没有权重相关参数
- **名字取自 ``trigger`` 列的第 1 段**:该列经 34,416 行全量校验是恒定的
「角色标签,作品标签」两段结构,第 1 段已转义、可直接使用,第 2 段是作品标签
- **每个角色一行**:``角色标签,作品标签,core_tags``(空的部分自动省略);
多个角色之间用「逗号 + 换行」分隔(行尾补逗号,拼成一行时标签不会粘在一起)
- **可按作品过滤**:``copyright_pick`` 下拉列出**全部作品**,可打字筛选
- **不重复靠一个简单的内存窗口**:最近 10 条输出记录里出现过的角色名会被回避
(见 :data:`_RECENT_RECORDS`),没有去重模式参数
- **不做穷举**:角色是单词条,穷举意义不大,因此没有 ``mode`` 参数
"""
import json
import random
import threading
from collections import deque
from typing import Any, Deque, Dict, List, Optional, Sequence, Set, Tuple
from .ra_core.artists import (
available_names,
compute_top_count,
get_repository,
select_artists,
split_names,
)
from .ra_core.characters import (
COPYRIGHT_ANY,
CharacterRecord,
build_character_prompt,
build_payload,
copyright_options,
filter_by_copyright,
load_records,
)
from .ra_core.constants import (
DEFAULT_CHARACTER_CSV,
DEFAULT_CHARACTER_DB,
KIND_CHARACTER,
LOGGER,
)
from .ra_core.db import get_db
from .ra_core.params import as_bool as _as_bool
from .ra_core.params import as_int as _as_int
from .ra_core.params import as_text as _text
__all__ = ["RollingCharacter", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
# 「最近多少条输出记录内角色不重复」:这里的一条 = 一次生成(可能含多个角色)。
# 窗口放在节点实例的内存里,因此重启 ComfyUI 会清零,多个实例也各算各的。
_RECENT_RECORDS = 10
def _key_weights(count: int) -> List[float]:
"""记录用的权重占位值:角色输出不带权重,键里统一按 1.0 计算。"""
return [1.0] * count
# 作品下拉菜单的选项:注册节点时解析一次默认 CSV(3.4 万行,约 0.5 秒),
# 之后所有实例共用;用户填的 ``custom_csv_path`` 拿不到,因此不参与构建
_COPYRIGHT_MENU: Optional[List[str]] = None
def _copyright_menu() -> List[str]:
"""作品下拉菜单选项:``[COPYRIGHT_ANY, 全部作品名...]``(按 count 总和降序)。
默认 CSV 缺失或解析失败时退化为只有「不限」一项,绝不让菜单构建失败影响节点注册。
"""
global _COPYRIGHT_MENU
if _COPYRIGHT_MENU is None:
options: List[str] = [COPYRIGHT_ANY]
try:
repository = get_repository(DEFAULT_CHARACTER_CSV)
options.extend(copyright_options(load_records(repository, "", "trigger")))
except Exception as error: # pragma: no cover - 菜单构建必须保持健壮
LOGGER.error("[RollingCharacter] 构建作品下拉菜单失败: %s", error)
if len(options) == 1:
LOGGER.warning(
"[RollingCharacter] 作品下拉菜单只有“不限”一项(默认 CSV 缺失或没有读到作品列),"
"请检查 danbooru_character_001.csv 是否就位"
)
_COPYRIGHT_MENU = options
return _COPYRIGHT_MENU
class RollingCharacter:
"""RollingCharacter 节点类。
功能特性:
- 从角色 CSV 加载角色(默认 ``trigger`` 列,已转义、可直接使用)
- 支持 Top 池优先、排除/强制包含名单、按作品筛选(下拉列出全部作品)
- 逐行输出 ``角色标签,作品标签,core_tags``(不带权重;角色描述可开关);
多个角色之间用「逗号 + 换行」分隔
- 结构化输出 characters_json,便于下游处理
- 最近 10 条输出记录内不重复(内存窗口,无去重模式参数)
线程安全:
- 角色 CSV 解析结果按文件签名缓存,多个实例共享
- 「选角色 -> 记录」整段处于数据库事务中;不重复判定用的是节点实例自己的内存窗口
- 记录带 ``kind=character`` 标记:即使被指向画师节点的数据库也不会互相冒充
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Dict[str, Any]]:
"""定义节点的输入参数类型和界面显示。
界面顺序即下方字典顺序,按「抽样 → Top 池 → 种子 → 抽样偏好 → 名单 →
作品 → 输出内容 → 高级(CSV 与数据库路径)」归类;
很少改动的 CSV / 路径标记为 ``advanced``,由前端折叠到“高级输入”。
角色节点不做权重分配、不做穷举;输出格式固定为 ``角色标签,作品标签,core_tags``,
只提供「开启角色描述」这一个内容开关。
"""
return {
"required": {
# ---------------- 抽样:抽几个角色、从哪里抽 ----------------
"character_count": (
"INT",
{
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"display": "slider",
"tooltip": "每次生成的角色数量(1-10),每个角色输出一行。角色是单词条,没有穷举模式",
},
),
"character_top_ratio": (
"FLOAT",
{
"default": 0.01,
"min": 0.01,
"max": 1.0,
"step": 0.01,
"display": "slider",
"tooltip": "人气池占比(0.01-1.0):按 CSV 行序取前 ceil(候选数×比例) 个"
"(至少 1 个)作为 Top 池,行序越靠前的角色作品数越多。"
"候选数指“作品筛选之后”剩下的角色数",
},
),
"character_top_count": (
"INT",
{
"default": 1,
"min": 1,
"max": 10,
"step": 1,
"display": "slider",
"tooltip": "每次至少抽取几个人气池角色(1-10);"
"仅在“Top 优先抽样”开启且人气池非空时生效",
},
),
"seed": (
"INT",
{
"default": 1234,
"min": 0,
"max": 4294967295,
"tooltip": "随机种子。同一种子在同一组参数下结果可复现;"
"最近 10 条输出记录的回避窗口也会影响实际结果",
},
),
},
"optional": {
# ---------------- 抽样偏好 ----------------
"use_top_priority": (
"BOOLEAN",
{
"default": True,
"tooltip": "开启时优先从人气池抽角色,并保证含 character_top_count 个人气角色;"
"关闭时从完整候选列表均匀抽样",
},
),
# ---------------- 名单 ----------------
"exclude_characters": (
"STRING",
{"default": "", "tooltip": "要排除的角色名,英文逗号分隔;被排除的名字不会出现在结果中"},
),
"force_include": (
"STRING",
{
"default": "",
"tooltip": "必须出现的角色名,英文逗号分隔;优先级高于“排除角色”,"
"数量达到角色数量时直接从中随机抽取。"
"不在 CSV 里的名字也能用,只是没有作品与角色描述信息",
},
),
# ---------------- 作品 ----------------
"copyright_pick": (
_copyright_menu(),
{
"default": COPYRIGHT_ANY,
"tooltip": "从下拉里挑一个作品。下拉列出 CSV 里的全部作品"
"(默认数据约 3460 个,按作品热度降序:该作品下所有角色的 count 之和),"
"并且可以直接打字筛选、忽略大小写(输入 a 只留 a 开头的,"
"继续输入 ab 会进一步收窄)。“(不限)”表示不筛选。"
"同样接受 copyright 列的原始写法 fate_(series) 与 trigger 里"
"转义后的写法 fate_\\(series\\)。注意:下拉项来自默认 CSV,"
"换“自定义 CSV 路径”时下拉不会跟着变",
},
),
# ---------------- 输出内容 ----------------
"describe_character": (
"BOOLEAN",
{
"default": True,
"tooltip": "开启时在每行末尾拼上角色的固定外观标签(core_tags,即角色描述),"
"格式为「角色标签,作品标签,角色描述」;"
"关闭时只输出「角色标签,作品标签」",
},
),
# ---------------- 高级:CSV 与数据库(界面默认折叠) ----------------
"custom_csv_path": (
"STRING",
{
"default": "",
"advanced": True,
"tooltip": "自定义角色 CSV 的路径;留空使用节点目录下的 danbooru_character_001.csv",
},
),
"tested_db_path": (
"STRING",
{
"default": "",
"advanced": True,
"tooltip": "记录数据库(SQLite)路径;留空使用节点目录下的 "
"rollingcharacter.sqlite(与画师节点分开)。"
"这里只存历史输出记录,更换该路径不会重置“最近 10 条不重复”的窗口"
"(那个窗口在内存里,重启即清零)",
},
),
},
}
RETURN_TYPES = ("STRING", "STRING", "STRING")
RETURN_NAMES = ("prompt", "characters_json", "tested_count")
FUNCTION = "generate_characters"
CATEGORY = "RollingArtist"
# 作为输出节点,即使输出未被下游使用也会执行
OUTPUT_NODE = True
DESCRIPTION = (
"从角色 CSV 中随机抽取角色,按「角色标签,作品标签,角色描述」逐行输出"
"(多个角色用「逗号+换行」分隔、不带权重),并输出结构化 JSON 与已测数量。"
)
OUTPUT_TOOLTIPS = (
"角色列表:每个角色一行,格式为「角色标签,作品标签,角色描述」,不带权重;"
"关闭“开启角色描述”后为「角色标签,作品标签」",
"结构化结果 JSON(角色 / 作品 / 是否 Top / 热度 / 角色描述)",
"当前数据库里已记录的角色组合数量(字符串形式,可直接接到文本节点查看)",
)
def __init__(self) -> None:
"""初始化节点实例:准备角色仓库、状态容器与“最近记录”窗口。
真正的加载发生在 ``generate_characters`` 中(按文件签名缓存),
这里只做一次默认 CSV 的预热,且任何异常都不会影响节点注册。
"""
self._repo = get_repository(DEFAULT_CHARACTER_CSV)
self._lock = threading.RLock()
self.characters: List[CharacterRecord] = []
# 最近若干条输出记录(每条 = 一次生成的角色名),用于回避重复
self._recent: Deque[Tuple[str, ...]] = deque(maxlen=_RECENT_RECORDS)
try:
self.characters = load_records(self._repo, "", "trigger")
except Exception as error: # pragma: no cover - 初始化必须保持健壮
LOGGER.error("[RollingCharacter] 初始化加载默认角色 CSV 失败: %s", error)
# ------------------------------------------------------------------
# 最近记录窗口
# ------------------------------------------------------------------
def recent_names(self) -> Set[str]:
"""最近 :data:`_RECENT_RECORDS` 条输出记录里出现过的角色名(并集)。"""
with self._lock:
names: Set[str] = set()
for record_names in self._recent:
names.update(record_names)
return names
def _remember(self, names: Sequence[str]) -> None:
"""把本次输出的角色名记进窗口。"""
with self._lock:
self._recent.append(tuple(names))
def reset_recent(self) -> None:
"""清空“最近记录”窗口(手动重置重复回避时使用)。"""
with self._lock:
self._recent.clear()
# ------------------------------------------------------------------
# 主流程
# ------------------------------------------------------------------
def generate_characters(
self,
character_count: int,
character_top_ratio: float,
character_top_count: int,
seed: int,
use_top_priority: bool = True,
exclude_characters: str = "",
force_include: str = "",
copyright_pick: str = COPYRIGHT_ANY,
describe_character: bool = True,
custom_csv_path: str = "",
tested_db_path: str = "",
) -> Tuple[str, str, str]:
"""生成角色列表。
形参顺序与界面参数顺序一致(ComfyUI 按关键字传参,顺序只影响外部脚本按位置调用)。
读取的 CSV 列固定为 ``trigger``(「角色标签,作品标签」两段结构),不可配置。
不重复的规则很简单:把最近 :data:`_RECENT_RECORDS` 条输出记录里出现过的角色名
从候选里排除;只有当窗口把候选全部吃掉时才退回「允许重复」并输出 WARNING。
返回:
``(prompt, characters_json, tested_count)``;``tested_count`` 是字符串,
方便直接接到文本节点查看
"""
count = _as_int(character_count, 1)
if count < 1:
raise ValueError(f"[RollingCharacter] character_count 必须 >= 1,当前值: {character_count!r}")
# 1) 加载角色记录(内容未变化时直接命中缓存,不重复解析 CSV)
# 列固定为 trigger:该列恒为「角色标签,作品标签」两段结构
records = load_records(self._repo, custom_csv_path, "trigger")
if not records:
raise ValueError(
"[RollingCharacter] 未能从 CSV 加载角色列表,请检查文件是否存在、"
f"编码是否为 UTF-8、是否含有 trigger 列: "
f"{self._repo.resolve_path(_text(custom_csv_path))}"
"(若本节点是从旧工作流载入的,请检查“自定义 CSV 路径”是否已清空)"
)
with self._lock:
self.characters = records
# 2) 作品筛选(下拉单选,“(不限)”表示不筛选)+ 名单
force_list = split_names(force_include)
exclude_set = set(split_names(exclude_characters))
pick = _text(copyright_pick).strip()
allowed = filter_by_copyright(records, "" if pick == COPYRIGHT_ANY else pick)
if not allowed and not force_list:
raise ValueError(
f"[RollingCharacter] 作品选择={copyright_pick!r} 筛选后没有可用角色"
"(请改成“(不限)”或换一个作品)"
)
# 名字唯一(默认数据 34,416 行实测无重名),因此可以按名字直接取回记录
record_map = {record.name: record for record in records}
names = [records[index].name for index in allowed]
top_count = compute_top_count(len(names), character_top_ratio)
rng_seed = _as_int(seed, 0)
top_priority = top_count if _as_bool(use_top_priority, True) else 0
with_core = _as_bool(describe_character, True)
# 3) 回避最近 _RECENT_RECORDS 条输出记录里出现过的角色
# 分两步走:先算"排除名单后还剩哪些候选",再决定是否启用回避窗口。
# 这样"窗口把候选吃光时退回允许重复"的降级是显式的,而不是事后修正一个
# 已经拼好的屏蔽集。
recent = self.recent_names()
candidates = available_names(names, exclude_set)
blocked = exclude_set
if recent:
survivors = available_names(candidates, recent)
if survivors:
blocked = exclude_set | recent
elif candidates and not force_list:
# 候选本来就少、且已被窗口全部覆盖:退回允许重复,并明确告警
LOGGER.warning(
"[RollingCharacter] 最近 %d 条输出记录已覆盖全部 %d 个候选角色,"
"本轮不再回避重复(可用角色太少时属于正常现象)",
_RECENT_RECORDS,
len(candidates),
)
# 4) 记录库:角色专用 SQLite(记录带 kind 标记,与画师互不干扰)
db = get_db(tested_db_path, DEFAULT_CHARACTER_DB)
# 5) 「选角色 -> 记录」处于同一个事务内,进程内 / 跨进程都不会重复落盘
with db.generation_lock():
rng = random.Random(rng_seed)
selected = select_artists(
rng,
count,
_as_int(character_top_count, 1),
force_list,
names,
top_priority,
blocked,
)
if not selected:
raise ValueError(
"[RollingCharacter] 未选出任何角色,请检查 character_count、排除名单与作品选择"
)
if len(selected) < count:
LOGGER.warning(
"[RollingCharacter] 可用角色不足:请求 %d 个,实际选中 %d 个"
"(请检查 character_count、作品选择、排除名单或 CSV 规模)",
count,
len(selected),
)
selected_records = [
record_map.get(name) or CharacterRecord(name, "", "", "", 0) for name in selected
]
prompt = build_character_prompt(selected_records, with_core)
# 记录里的权重列统一用等长的 1.0 占位(角色不带权重)
if not db.record(selected, _key_weights(len(selected)), prompt, KIND_CHARACTER):
LOGGER.error(
"[RollingCharacter] 记录写入数据库失败(%s),本次组合未落盘:\n%s",
db.path,
prompt,
)
# 在事务内取计数:与本次写入处于同一快照,避免事务外再查导致不一致
tested_total = db.count(KIND_CHARACTER)
# 记进“最近记录”窗口(放在事务之后:只有真正输出的组合才占用窗口)
self._remember(selected)
# 人气池覆盖全部候选时,所有人都是 Top,无需构建集合
top_names: Optional[Set[str]] = None
if top_count < len(names):
top_names = set(names[:top_count])
payload = build_payload(selected_records, top_names)
return (
prompt,
json.dumps(payload, ensure_ascii=False),
str(tested_total),
)
# 注册节点类
NODE_CLASS_MAPPINGS = {"RollingCharacter": RollingCharacter}
# 设置节点显示名称
NODE_DISPLAY_NAME_MAPPINGS = {"RollingCharacter": "Rolling Character"}