Files
StarAsh042 4ba43e6123 release: 5.2.0(大 CSV 防护与路径边界版本) (#18)
* feat(cache): 节点缓存键纳入 CSV 文件签名

ComfyUI 默认只按输入参数判断缓存是否命中。两个节点读的都是外部 CSV,
参数不变而文件被替换时缓存会一直返回旧结果,用户换了数据却看不到变化。

把 CSV 的「路径 + 修改时间 + 大小」作为 IS_CHANGED 的返回值纳入缓存键:
文件一变就重算;文件与参数都不变时指纹稳定,缓存行为与 5.0.0 一致。
路径被防护拒绝时返回稳定哨兵而不抛异常,避免打断 ComfyUI 的缓存判断。

* perf(character): 派生索引随记录缓存,单次执行 62.6ms -> 26.7ms

角色节点每次执行都在 3.4 万条记录上重建 record_map / names / 作品筛选下标,
CSV 没变时完全是重复劳动。把三者收进 CharacterIndex,与记录共用同一套
文件签名缓存(路径 + 列 + mtime + size),CSV 一变就重建。

顺带修掉作品下拉菜单的失效问题:菜单改为按默认 CSV 的文件签名缓存,
换 CSV 后自动重建,不再需要重启 ComfyUI。

实测(本机 7 次中位数):generate_characters 62.62ms -> 26.69ms(-57%)。
4 个 seed 下 prompt / characters_json / tested_count 逐字节一致。
剩余约 24ms 在 select_artists 的排除过滤里;改动它会改变 RNG 消耗顺序、
破坏同 seed 的可复现性,故本次不动。

* docs(db): 修正 generation_lock 的持锁范围说明

原文写「调用方应在进入本上下文之前完成抽样、权重计算、提示词拼装等纯 CPU 工作」,
但 RollingArtist 的重试循环必须待在事务里(命中已测组合要重新抽样),
实际持锁时长等于一次完整生成。文档与实现互相矛盾,读代码的人会误判并发行为。

改为如实描述:持锁时长由调用方决定,两种调用方式各自的代价是什么,
以及为什么不能简单把抽样挪到锁外(冲突只会推迟到写入时才被 INSERT OR IGNORE 吞掉)。
同时修正 __init__ 里「不覆盖事务内的业务计算」这句同样有歧义的注释。

纯注释改动,无代码行为变化。

* docs: 5.1.0 变更记录与 README 缓存/性能说明(T-04)

* docs: 归档 v5.1.0 迭代计划至 docs/iteration

* chore(release): 5.1.0

* docs: 回填 v5.1.0 执行记录至迭代计划

* docs: Sprint 2 文档三件套(决策登记/性能基线/测试口径)

* ci/chore: mypy job 非阻断 + .comfyignore 收敛发布面

* chore(release): 5.1.1

* docs(iteration): 回填 5.1.1 发布与 T-10 收尾执行记录

* docs(od-01-od-02): 落实两项决策的提示强化(tooltip/README/locales 同步)

- OD-01/OD-02 均按方案 A 关闭(维持内存窗口 / 维持启动即清空)
- 两节点 tested_db_path tooltip 补默认库启动即清空提示
- 角色节点 character_count tooltip 补窗口语义(内存/重启清零/多实例独立)
- README 重复回避段补多实例独立表述
- locales/zh 与代码 tooltip 逐字对齐(28 条,修复存量漂移),locales/en 同步

* chore(release): 5.1.2

* docs(iteration): 回填 5.1.2 发布与 OD 决策关闭执行记录

* docs(iteration): 回填 5.1.1/5.1.2 Flagged 定级与 .comfyignore 收敛验证

* fix(registry): 等价 API 替换消除扫描误报(sqlite3.connect→sqlite3.Connection、os.environ.get→os.getenv)

- 行为零变化:338 个测试用例全过,门禁四件套全绿
- 目标:消除 Registry 安全扫描对 5.0.0-5.1.2 的 info 级误报 Flagged
- 兜底:如扫描器仍有其他触发模式,回退 registry-backend 人工申诉(稿已备)

* docs(iteration): 回填 5.1.3 误报消除执行记录与分支预案

* feat(character): T-16 作品筛选执行期一致性(报错分级+copyrights 缓存+边界说明)

- CharacterIndex 新增 copyrights 惰性缓存属性(与下拉同一排序)
- 作品筛选为空的报错分级:自定义 CSV 场景指明「不在自定义 CSV 的作品列表中」并给出该 CSV 总量与热门示例;默认场景指明「不在默认 CSV 的作品列表中」
- copyright_pick / custom_csv_path tooltip 与 README 补齐联动方向
- locales zh/en 同步;新增 4 个测试(含报错消息断言)
- 背景:ComfyUI INPUT_TYPES 无参调用拿不到实例参数,下拉联动架构上不可行;
  执行期筛选本就作用于 custom_csv_path,本版补齐错误指引与边界说明
- 不动 pyproject 版本号(等 5.1.3 定级后发 5.1.4)

* docs(iteration): T-16 完成回填 + 5.1.3 Pending 8h 观察记录

* chore(release): 5.1.4

* docs(iteration): 回填 5.1.4 发布执行记录

* docs(t-17-t-18): 仓库清理与数据更新 SOP

- T-17: 删除本地备份分支 backup/pre-rebuild-20261022-233943(重建前平行历史,
  内容已被 5.0.0 完整包含,删除前经 git diff 树对比核验零损失,从未推送 origin);
  import_legacy_csv 补生命周期标注(4.x→5.0.0 一次性迁移工具,无生产调用方)
- T-18: 新增 docs/DATA-UPDATE-SOP.md(取数/LFS 纪律/加工参数/校验清单/回滚/提交约定),
  README 数据预处理段挂链接
- 不动 pyproject 版本号,合并不触发 Registry 发布

* docs(iteration): 回填 T-17/T-18 完成记录与 Registry 异动观察

* chore(release): 5.1.5

* docs(iteration): 回填 5.1.5 发布执行记录与 Registry 移除模式观察

* chore(repo): tests/ 与 docs/ 转为本地独享,不进远端仓库与发布包

维护者策略(2026-10-25):测试套件与项目文档(数据 SOP / 决策记录 /
迭代计划)仅保留在本地工作树——取消 git 跟踪并写入 .gitignore,
.comfyignore 同步排除 docs/(tests/ 原已排除),Registry 发布包
不再包含内部文档,扫描面进一步收敛。

配套调整:
- ci.yml:test job 检出无 tests/ 时跳过而非失败(分支保护引用的
  ci 聚合 job 不受影响;测试若重新纳入跟踪则自动恢复执行)
- README:数据 SOP 链接、测试规模口径、参与开发段改写,清除指向
  未跟踪文件的死链并说明测试本地独享
- BENCHMARKS:迭代计划引用改本地口径,文首注明测试文件位置

* chore(release): 5.1.6(扫描触发点二轮消除 + 发布卫生回归 + tests/docs 本地独享)

- db.py _write_schema_version:INSERT 语句提取为局部变量,
  断掉 database_operations 模式命中的 .execute( + 换行 + "INSERT INTO
  多行排版(SQL 与参数不变)
- paths.py:4 处系统目录探测 os.environ.get() → os.getenv();
  db.py 注释与 paths.py docstring 清除 sqlite3.connect() 字样(纵深防御)
- tests/test_publish_hygiene.py:发布包构成回归测试(本地测试套件新增),
  本次即靠它抓到 .comfyignore 无锚定 docs/ 误伤 web/docs/ 的真 bug,
  .comfyignore/.gitignore 均已改根锚定写法
- tests/ 与 docs/ 取消 git 跟踪转本地独享(.gitignore + .comfyignore),
  ci.yml test job 检出无 tests/ 时跳过而非失败

* chore(release): 5.1.7(文档类扫描触发面根治:CHANGELOG 排除出发布包)

- .comfyignore 根锚定排除 /CHANGELOG.md:5.1.5 finding 匹配实例证明
  扫描器把 CHANGELOG 历史段落中描述标准库 API 名称的字面量也按代码
  命中(7 条 finding 中 6 条源自 CHANGELOG 与迭代计划文档)
- CHANGELOG.md 历史段落 API 调用字样改写为描述性文字(纵深防御)
- 发布卫生测试增强(本地测试套件):tripwire 扫描从仅 .py 扩展到全部
  文本类文件(.py/.md/.json/.toml/.yml/.txt;CSV 数据文件不扫——
  角色名语料误触词面规则而扫描器不把数据文件当代码),CHANGELOG.md
  纳入发布包排除断言

* feat(core): 流式读取 CSV 并新增可配置读取上限 (#15)

CSV reading used to build a full List[List[str]] of the raw file before
parsing, so cold loads kept both the raw rows and the final objects in
memory at once. Records now stream straight into the parsers.

- artists: _read() feeds _iter_csv_rows() into parse_artist_rows() instead
  of _csv_rows(); a shared _iter_csv_rows() owns counting, early stop and
  error containment for both read paths.
- characters: build_records() is rewritten as a single pass and accepts
  Iterable. The previous Sequence signature iterated twice, so a generator
  input silently dropped the first data row (6 rows -> 4 records, no error).
- characters: load_records()/load_index() read via the new
  ArtistRepository.open_rows(), so the raw rows are no longer parked in
  _rows_cache. load_rows() stays as the public compatibility entry.
- limits: normalize_max_rows()/resolve_max_rows() accept only None or a
  positive integer. The effective limit is part of every cache key, and the
  count is defined once for both paths: csv.reader records, header and
  blank records included.
- exact-N no longer claims truncation: the streaming reader flags
  "truncated" only when it actually reaches record N+1, with no extra read.
- nodes: both take an optional csv_max_rows (default 0 = unlimited),
  appended last in optional and in the signature, marked advanced. ComfyUI
  1.55.18 restores widgets_values positionally (NamedValuesRestore is off
  by default), so appending at the end is what keeps old workflows intact.

Default behaviour is unchanged: same seeds produce byte-identical output
for both nodes.

* fix(core): 收紧路径边界并新增管理员白名单 (#16)

* feat(core): stream CSV records and add a configurable read limit

CSV reading used to build a full List[List[str]] of the raw file before
parsing, so cold loads kept both the raw rows and the final objects in
memory at once. Records now stream straight into the parsers.

- artists: _read() feeds _iter_csv_rows() into parse_artist_rows() instead
  of _csv_rows(); a shared _iter_csv_rows() owns counting, early stop and
  error containment for both read paths.
- characters: build_records() is rewritten as a single pass and accepts
  Iterable. The previous Sequence signature iterated twice, so a generator
  input silently dropped the first data row (6 rows -> 4 records, no error).
- characters: load_records()/load_index() read via the new
  ArtistRepository.open_rows(), so the raw rows are no longer parked in
  _rows_cache. load_rows() stays as the public compatibility entry.
- limits: normalize_max_rows()/resolve_max_rows() accept only None or a
  positive integer. The effective limit is part of every cache key, and the
  count is defined once for both paths: csv.reader records, header and
  blank records included.
- exact-N no longer claims truncation: the streaming reader flags
  "truncated" only when it actually reaches record N+1, with no extra read.
- nodes: both take an optional csv_max_rows (default 0 = unlimited),
  appended last in optional and in the signature, marked advanced. ComfyUI
  1.55.18 restores widgets_values positionally (NamedValuesRestore is off
  by default), so appending at the end is what keeps old workflows intact.

Default behaviour is unchanged: same seeds produce byte-identical output
for both nodes.

* fix(core): tighten path boundaries and add administrator allowlists

Two boundary defects in ra_core/paths.py, plus an opt-in allowlist channel
for administrators who want to narrow the allowed roots.

- _resolve_unc_to_drive() used to map the admin-share form of ANY host to a
  local drive: \\evil-host\D$\data\x.csv became D:\data\x.csv and was then
  ALLOWED. The local path was what actually got opened, so the remote intent
  was silently downgraded to a local read/write. Only localhost, 127.0.0.1
  and [::1] are restored now; every other host stays UNC and is rejected by
  the existing UNC branch.
- _is_within() compared os.path.commonpath() against the root without
  normcase, so mixed-case nesting was judged outside. Under the existing
  blacklist this was unreachable (forbidden_roots() only keeps existing
  directories and realpath normalises their case), but an allowlist root that
  does not exist yet was rejected. Both sides are normcased now.
- New administrator allowlist, read from ra_config.json in the node directory
  (csv_allowed_roots / db_allowed_roots). No environment variable is read:
  reading one would add a python_environment_manipulation finding to the
  Registry scan, and the package is already flagged for the five existing
  reads. The file is absent by default, which keeps the current blacklist
  behaviour.
- The allowlist only narrows. Passing it still has to clear the UNC rejection
  and the forbidden-root loop. It is checked after _canonicalize() and before
  is_path_guard_disabled(), so "allowlist plus escape hatch" fails closed
  instead of the escape hatch silently winning.
- Invalid configuration fails closed with an actionable message: bad JSON,
  non-object top level, empty or missing lists, non-string roots, relative
  roots and bare drive letters are all rejected.
- for_write is no longer a dead parameter: it selects db_allowed_roots over
  csv_allowed_roots, so the two kinds stay separate.
- Node-directory data files are exempt: the bundled default CSV/DB are
  trusted runtime resources, and users sometimes write the full default path
  into custom_csv_path. Node source files are still blocked.

TOCTOU is unchanged and not claimed: check_db_path validates once and
sqlite3 reopens by path, and sqlite3 has no fd parameter.

* chore(release): 准备 5.2.0 文档与版本号 (#17)

* feat(core): stream CSV records and add a configurable read limit

CSV reading used to build a full List[List[str]] of the raw file before
parsing, so cold loads kept both the raw rows and the final objects in
memory at once. Records now stream straight into the parsers.

- artists: _read() feeds _iter_csv_rows() into parse_artist_rows() instead
  of _csv_rows(); a shared _iter_csv_rows() owns counting, early stop and
  error containment for both read paths.
- characters: build_records() is rewritten as a single pass and accepts
  Iterable. The previous Sequence signature iterated twice, so a generator
  input silently dropped the first data row (6 rows -> 4 records, no error).
- characters: load_records()/load_index() read via the new
  ArtistRepository.open_rows(), so the raw rows are no longer parked in
  _rows_cache. load_rows() stays as the public compatibility entry.
- limits: normalize_max_rows()/resolve_max_rows() accept only None or a
  positive integer. The effective limit is part of every cache key, and the
  count is defined once for both paths: csv.reader records, header and
  blank records included.
- exact-N no longer claims truncation: the streaming reader flags
  "truncated" only when it actually reaches record N+1, with no extra read.
- nodes: both take an optional csv_max_rows (default 0 = unlimited),
  appended last in optional and in the signature, marked advanced. ComfyUI
  1.55.18 restores widgets_values positionally (NamedValuesRestore is off
  by default), so appending at the end is what keeps old workflows intact.

Default behaviour is unchanged: same seeds produce byte-identical output
for both nodes.

* fix(core): tighten path boundaries and add administrator allowlists

Two boundary defects in ra_core/paths.py, plus an opt-in allowlist channel
for administrators who want to narrow the allowed roots.

- _resolve_unc_to_drive() used to map the admin-share form of ANY host to a
  local drive: \\evil-host\D$\data\x.csv became D:\data\x.csv and was then
  ALLOWED. The local path was what actually got opened, so the remote intent
  was silently downgraded to a local read/write. Only localhost, 127.0.0.1
  and [::1] are restored now; every other host stays UNC and is rejected by
  the existing UNC branch.
- _is_within() compared os.path.commonpath() against the root without
  normcase, so mixed-case nesting was judged outside. Under the existing
  blacklist this was unreachable (forbidden_roots() only keeps existing
  directories and realpath normalises their case), but an allowlist root that
  does not exist yet was rejected. Both sides are normcased now.
- New administrator allowlist, read from ra_config.json in the node directory
  (csv_allowed_roots / db_allowed_roots). No environment variable is read:
  reading one would add a python_environment_manipulation finding to the
  Registry scan, and the package is already flagged for the five existing
  reads. The file is absent by default, which keeps the current blacklist
  behaviour.
- The allowlist only narrows. Passing it still has to clear the UNC rejection
  and the forbidden-root loop. It is checked after _canonicalize() and before
  is_path_guard_disabled(), so "allowlist plus escape hatch" fails closed
  instead of the escape hatch silently winning.
- Invalid configuration fails closed with an actionable message: bad JSON,
  non-object top level, empty or missing lists, non-string roots, relative
  roots and bare drive letters are all rejected.
- for_write is no longer a dead parameter: it selects db_allowed_roots over
  csv_allowed_roots, so the two kinds stay separate.
- Node-directory data files are exempt: the bundled default CSV/DB are
  trusted runtime resources, and users sometimes write the full default path
  into custom_csv_path. Node source files are still blocked.

TOCTOU is unchanged and not claimed: check_db_path validates once and
sqlite3 reopens by path, and sqlite3 has no fd parameter.

* chore(release): prepare 5.2.0 docs and version bump

Documentation and version housekeeping for the 5.2.0 line. No runtime code
changes in this commit.

- CHANGELOG: new `## [未发布]` section covering the streaming CSV read, the
  configurable read limit, the two path-boundary fixes, the administrator
  allowlist and the publish-hygiene rule that had been missing. The section
  is renamed to `## [5.2.0] - <date>` at release time, per the existing
  discipline (every earlier release was made by renaming, not by adding).
- BENCHMARKS: same-machine old-vs-new comparison for the streaming rewrite
  (artist cold peak 13.30 -> 3.39 MB, character cold peak 27.26 -> 16.46 MB,
  character resident 26.86 -> 16.07 MB, character cold load 3129.7 ->
  2703.0 ms) and the effect of the limit (artist cold load 1545 -> 32.6 ms
  at max_rows=1000). Also indexes the behaviour-level regression tests that
  guard these, none of which assert absolute timings.
- README: test-suite size corrected to the measured values (304 test
  functions / 396 collected cases). The previous 254 / 338 had drifted.
- .gitignore: add *.egg-info/, build/, dist/, .pytest_cache/, .ruff_cache/
  and .mypy_cache/. `pip install -e ".[dev]"` creates
  ComfyUI_RollingArtist.egg-info/ in the repo root, which was not ignored,
  so a local install dirtied `git status` and broke the "diff matches plan"
  gate step.
- pyproject: version 5.1.7 -> 5.2.0.

Note: this branch is stacked on the two feature PRs; it only contains the
docs/version delta. Merging it before them would leave the changelog
describing code that is not on dev yet.

* chore(release): 定版 5.2.0 (#19)

按发布纪律把 CHANGELOG 的 `## [未发布]` 就地改名为 `## [5.2.0] - 2026-10-28`,
内容零改动(与 5.1.1 / 5.1.4 / 5.1.5 的定版做法一致)。

版本号三处一致:pyproject.toml = 5.2.0、CHANGELOG 最新段 = [5.2.0]、待打 tag v5.2.0。

发布前核验(本机,2026-10-28):
- 四件套全绿:pytest 396 passed / ruff check passed / ruff format 26 files /
  mypy 12 source files(CI 同款命令 ra_core RollingArtist.py RollingCharacter.py)
- 发布卫生 5 项全绿,含 test_scan_rules_self_check(证明 os.getenv( 规则能真命中)
  与 test_allowlist_entries_are_live(证明 5 处登记仍有效)
- 发布包 37 文件;tests/ docs/ .github/ CHANGELOG.md 全部排除;
  locales / web/docs / ra_core / 两份默认 CSV 全部保留
- tripwire 扫描 0 条未登记违规;发布包内 env 读取仍为 5 处(全在 ra_core/paths.py),
  本版未新增 Registry finding 面

真实 ComfyUI 0.42.5 验收(AC-R04)35 项全 PASS,记录见
docs/iteration/ITERATION_PLAN_V5.2.0.md 附录 C.4;截图见 evidence/v5.2.0/screenshots/。
2026-10-28 23:46:59 +08:00

520 lines
25 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,
CharacterIndex,
CharacterRecord,
build_character_prompt,
build_payload,
copyright_options,
load_index,
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 秒)。
# 用户替换 CSV 后签名变化,菜单会自动重建,不需要重启 ComfyUI。
# 用户填的 ``custom_csv_path`` 在节点注册阶段拿不到,因此不参与构建。
_COPYRIGHT_MENU: Optional[List[str]] = None
_COPYRIGHT_MENU_KEY: Optional[Tuple[str, int, int]] = None
_COPYRIGHT_LOCK = threading.RLock()
def _copyright_menu() -> List[str]:
"""作品下拉菜单选项:``[COPYRIGHT_ANY, 全部作品名...]``(按 count 总和降序)。
默认 CSV 缺失或解析失败时退化为只有「不限」一项,绝不让菜单构建失败影响节点注册。
"""
global _COPYRIGHT_MENU, _COPYRIGHT_MENU_KEY
repository = get_repository(DEFAULT_CHARACTER_CSV)
key = repository.stat_key(repository.resolve_path(""))
with _COPYRIGHT_LOCK:
if _COPYRIGHT_MENU is not None and key == _COPYRIGHT_MENU_KEY:
return _COPYRIGHT_MENU
options: List[str] = [COPYRIGHT_ANY]
try:
options.extend(copyright_options(load_index(repository, "", "trigger").records))
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
_COPYRIGHT_MENU_KEY = key
return _COPYRIGHT_MENU
# 作品筛选报错时给出的热门作品示例数(帮用户快速对照「这个数据集里有什么」)
_PICK_HINT_SAMPLE = 5
def _copyright_pick_reject_reason(pick: str, index: CharacterIndex, custom_csv_path: str) -> str:
"""作品筛选结果为空时的报错原因:指明作品名不在哪个数据集里,并给出可用示例。
用户最常见的困惑:作品下拉只列默认 CSV 的作品,填了自定义 CSV 路径后
选了个默认 CSV 的作品名 -> 在自定义 CSV 里筛不出角色。这时必须说清楚
「不在你的自定义 CSV 里」,而不是笼统的「没有可用角色」。
"""
copyrights = index.copyrights
if copyrights:
sample = "、".join(copyrights[:_PICK_HINT_SAMPLE])
total = f"共 {len(copyrights)} 部作品,热门示例:{sample}"
else:
total = "该数据集没有任何作品列数据"
if _text(custom_csv_path).strip():
return (
f"作品选择={pick!r} 不在自定义 CSV 的作品列表中({total})。"
"作品下拉仅列出默认 CSV 的作品,自定义 CSV 的作品名不会出现在下拉里,"
"请直接填写该 CSV 中的作品名(原始写法 fate_(series) 亦可)"
)
return f"作品选择={pick!r} 不在默认 CSV 的作品列表中({total})。请改成“(不限)”或换一个作品"
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),每个角色输出一行。角色是单词条,没有穷举模式;重复回避窗口为最近 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 路径”时下拉不会跟着变——但筛选会在该 CSV 上生效,"
"其作品名不会出现在下拉里,请直接填写",
},
),
# ---------------- 输出内容 ----------------
"describe_character": (
"BOOLEAN",
{
"default": True,
"tooltip": "开启时在每行末尾拼上角色的固定外观标签(core_tags,即角色描述),"
"格式为「角色标签,作品标签,角色描述」;"
"关闭时只输出「角色标签,作品标签」",
},
),
# ---------------- 高级:CSV 与数据库(界面默认折叠) ----------------
"custom_csv_path": (
"STRING",
{
"default": "",
"advanced": True,
"tooltip": "自定义角色 CSV 的路径;留空使用节点目录下的 danbooru_character_001.csv。"
"作品筛选(“作品”参数)在该 CSV 上生效,但其作品名不会出现在作品下拉里,"
"请直接填写该 CSV 中的作品名",
},
),
"tested_db_path": (
"STRING",
{
"default": "",
"advanced": True,
"tooltip": "记录数据库(SQLite)路径;留空使用节点目录下的 "
"rollingcharacter.sqlite(与画师节点分开)——默认路径的库每次启动会被清空"
"(不跨会话累积),想跨会话保留请把这里指向自己的文件。"
"这里只存历史输出记录,更换该路径不会重置“最近 10 条不重复”的窗口"
"(那个窗口在内存里,重启清零、多实例各自独立)",
},
),
# ---------------- 高级:CSV 读取上限(追加在最后,见下方兼容性说明) ----------------
"csv_max_rows": (
"INT",
{
"default": 0,
"min": 0,
"max": 1000000,
"step": 1000,
"advanced": True,
"tooltip": "CSV 读取上限(条记录,含表头;0 = 不限制)。"
"超过上限的记录不参与加载,候选池、作品菜单与热度池会相应变小;"
"默认 0 保持与旧版完全一致的全量读取。"
"表很大且只想用前一部分时可设为正整数以降低读取耗时与内存",
},
),
},
}
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 / 热度 / 角色描述)",
"当前数据库里已记录的角色组合数量(字符串形式,可直接接到文本节点查看)",
)
@classmethod
def IS_CHANGED(cls, custom_csv_path: str = "", **kwargs: Any) -> str:
"""把角色 CSV 的文件签名纳入缓存键:文件被替换时强制重算。
与 RollingArtist 同源(共用 :func:`ra_core.storage.file_fingerprint`):
参数不变而 CSV 内容变了时,默认缓存会一直返回旧角色列表。
文件与参数都不变时指纹稳定,缓存行为不变。
"""
return get_repository(DEFAULT_CHARACTER_CSV).cache_fingerprint(custom_csv_path)
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 = "",
csv_max_rows: int = 0,
) -> Tuple[str, str, str]:
"""生成角色列表。
形参顺序与界面参数顺序一致(ComfyUI 按关键字传参,顺序只影响外部脚本按位置调用)。
读取的 CSV 列固定为 ``trigger``(「角色标签,作品标签」两段结构),不可配置。
参数:
csv_max_rows: CSV 读取上限(条记录,含表头;``0`` = 不限制)。
新增参数**追加在形参表末尾**,因此按位置调用的外部脚本不受影响。
不重复的规则很简单:把最近 :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}")
# 0) 读取上限:0 = 不限制(映射为 None);其他值必须是正整数
limit = _as_int(csv_max_rows, 0)
max_rows = None if limit == 0 else limit
if max_rows is not None and max_rows < 0:
raise ValueError(f"[RollingCharacter] csv_max_rows 必须 >= 0,当前值: {csv_max_rows!r}")
# 1) 加载角色记录(内容未变化时直接命中缓存,不重复解析 CSV)
# 列固定为 trigger:该列恒为「角色标签,作品标签」两段结构
# 派生结构(名字列表 / 名字->记录 / 作品筛选下标)随记录一起缓存,
# 避免每次执行都在 3.4 万条数据上重建一遍(实测约 19 ms)
index = load_index(self._repo, custom_csv_path, "trigger", max_rows=max_rows)
records = index.records
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()
copyright_filter = None if pick == COPYRIGHT_ANY else pick
allowed = index.indices_for(copyright_filter)
if not allowed and not force_list:
raise ValueError(
"[RollingCharacter] " + _copyright_pick_reject_reason(pick, index, custom_csv_path)
)
# 名字唯一(默认数据 34,416 行实测无重名),因此可以按名字直接取回记录
record_map = index.record_map
names = index.names_for(copyright_filter)
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"}