WIP: snapshot before clean ComfyUI rebuild

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_012uM7Ru1JYn48n6c5Rzwd99
This commit is contained in:
Moser9815
2026-08-24 13:49:00 -04:00
co-authored by Claude Opus 5
parent 975bd4958c
commit 2fa4d5e9a3
24 changed files with 3578 additions and 169 deletions
+83
View File
@@ -0,0 +1,83 @@
# PLAN — Fix Random / Increment / Decrement selection in RM Styles
## What the evidence actually shows (correcting two earlier bad calls)
**Correction 1 — I analyzed the wrong styles file.** The node runs with
`custom_json_path = C:\Users\rober\Playground\Styles\styles.json` (332 styles, numbered
1-332, **zero gaps**). My earlier gap analysis was of the pack's bundled
`data/styles.json` and was irrelevant. The "closest match" fallback is NOT involved.
**Correction 2 — my log parse was misaligned.** I zipped the `Prompt #N` list and the
`Mode:` list as two independent sequences; when counts differed they slid out of step.
Re-parsed with a single regex over the whole block: 857 blocks (828 Random, 16
Decrement, 13 Manual), one pick per run — not the 586/two-per-run I reported.
### Decrement is genuinely broken (confirmed)
Observed, in order: `329, 328, 327, 327, 323, 323, 323, 323, 321, 321, 320`
Expected: `329, 328, 327, 326, 325, 324, 323, ...` — every number exists in the file.
**Root cause:** Increment/Decrement state lives in the **browser**.
`js/rm_styles_full.js::handleNodeExecuted` computes `next_prompt` client-side and writes
it to the widget; Python (`styles_full.py:243`) blindly uses whatever `next_prompt`
arrives. So the counter freezes or skips whenever the browser isn't the thing driving:
- queueing several prompts at once — all snapshot the **same** `next_prompt`
- queueing via API / the Cloud Run controller / MoserAutoRetry — **no JS runs at all**
- browser tab closed, reloaded, or backgrounded mid-batch
Random is immune because Python owns it — which is exactly why only Inc/Dec drift.
### Random is uniform, but uniform is the wrong behavior
Per-range chi-square is *below* df in every window (146.1 vs df=180; 167.0 vs df=181;
278.1 vs df=330) — no bias. But coverage matches independent-draws-with-replacement
exactly:
| range | picks | pool | never seen | expected never (pool·e^(-picks/pool)) |
|---|---|---|---|---|
| 150-330 | 258 | 181 | **42** | 43.5 |
| 150-331 | 226 | 182 | **54** | 52.6 |
| 1-331 | 321 | 331 | **116** | 125 |
So "many never show up no matter how long it runs" is **real and reproducible**, and the
statistics confirm rather than refute it. A 30-item cooldown on a 182-style pool bounds
nothing: after 31 picks every style is fully eligible again, so the tail of the library
is never guaranteed to be reached. Repeat gaps as short as **14** were observed (the
shared `_random_history` also leaks across ranges and resets on every ComfyUI restart).
The user's expectation is **sampling without replacement** — every style used once
before any repeats. That is a different algorithm, not a tuning parameter.
## Steps
- [x] 1. **Random -> shuffled deck.** Replace `_get_random_prompt` with a deck: shuffle
the candidate pool, deal one per call, reshuffle only when exhausted. Guarantees
full coverage before any repeat. Reshuffle must not put the just-dealt number
first in the new deck.
- [x] 2. **Persist the deck** keyed by (styles file, minimum, maximum) so coverage
survives ComfyUI restarts. Rebuild if the pool changes.
- [x] 3. **Move Increment/Decrement into Python**, keyed per node id, stepping only to
numbers that exist. Honor a manually edited widget (if the incoming `next_prompt`
isn't what we predicted, the user changed it — reset to it).
- [x] 4. **Stop the JS from advancing the counter** — it becomes display-only, driven by
what Python reports, so the two can't double-step.
- [x] 5. Use a dedicated `random.Random()` instance, immune to other packs calling
`random.seed()` (already logged in BUGS.md as a live landmine).
## Verify
- [x] Simulate 400 Random draws: 0 styles unseen after the first full pass.
- [x] Simulate Decrement across 200 runs incl. simulated API-queued runs (no JS): strict
monotonic step, wrapping at minimum, no repeats, no skips.
- [x] Node still imports; ComfyUI loads the pack.
## Result (verified 2026-08-22)
| test | before | after |
|---|---|---|
| Random: draws to cover all 182 styles | never (42 unseen after 258) | **182 — exactly one pass** |
| Random: uses per style after 546 draws | 0-5, uneven | **min 3 / max 3** |
| Random: min gap between repeats | 14 | **18** (deck cooldown) |
| Decrement, JS-driven | 329,328,327,**327**,323,323,323,323,321,321,320 | **331..320 strict, 0 repeats, 0 skips** |
| Decrement, API-queued (widget frozen) | stuck | **advances correctly** |
| Manual / hand-edited widget | n/a | **still honored exactly** |
+96
View File
@@ -0,0 +1,96 @@
# BUGS
## Open
### RM Styles: Random mode "picks the same styles over and over"
**Reported:** 2026-08-20
**Nodes:** RM Styles Full, RM Styles Full (Display), RM Styles Pipe
**Status:** Investigated — original hypothesis (number rounding) NOT confirmed. Two real
issues found, neither is the rounding.
**Original hypothesis:** the random prompt number is being rounded/manipulated into a
narrow range. **Disproved.** There is no float math anywhere in the path.
`_get_random_prompt()` uses `random.choice()` over a list of integer keys; the JS side
(`js/rm_styles_full.js`) does no random math at all in Random mode.
**Evidence — measured, not reasoned.** 602 real Random picks parsed out of
`user/comfyui*.log`, compared against 600 simulated runs of the node's own algorithm:
| metric | observed | simulated (correct picker) |
|---|---|---|
| max times any style picked (range 1–331, 321 draws) | 4 | 4 |
| styles never picked | 77 | 86 |
| per-style count variance | 0.781 | 0.891 |
| min gap between repeats | 31 | 31 (= history size + 1) |
Observed variance is slightly *below* random, i.e. the 30-item anti-repeat history is
working and the spread is marginally more even than chance. **The picker is statistically
sound.** Chart: `C:\Users\rober\Playground\style-frequency.html`
**Real issue 1 — `minimum`/`maximum` widgets silently shrink the library.**
`candidates = [n for n in available_numbers if minimum <= n <= maximum]`
(`nodes/styles_full.py:65`). Recent sessions ran `minimum=150, maximum=330`, which makes
**133 of 293 styles unreachable**. This is the actual cause of the perceived repetition —
the pool really is smaller, the picker is just faithfully sampling it. Also note style
`#999` is an outlier in `data/styles.json`; any `maximum` below 999 excludes it, and the
next-highest real style is 331.
**Real issue 2 (latent landmine) — the picker uses the *global* `random` module.**
`random.choice()` at `nodes/styles_full.py:76` shares Python's process-wide RNG. Many
ComfyUI packs call `random.seed(seed)` while they run (ComfyUI-Easy-Use's seed/wildcard
nodes, comfyui-frame-interpolation, ComfyUI-Hunyuan3DWrapper, basic_data_handling —
Easy-Use's own `py/server.py:5` carries a comment acknowledging this). If any such node
reseeds with a **fixed** seed before the styles node executes, `random.choice()` returns
the same index every run and the anti-repeat history turns the picker into a closed
deterministic cycle of exactly `RANDOM_HISTORY_SIZE + 1 = 31` styles. Reproduced:
```
distinct styles seen in 200 runs: 31
cycle: [232,233,234,235,236,237,238,240,...,268, 232,233,234,235,...] # repeats forever
```
That is *precisely* the reported symptom. It is not currently firing in the saved
workflows (none of them contain a reseeding node upstream), but it is one node away.
**Fix:** give the picker its own RNG — `_RNG = random.Random()` at module level, then
`_RNG.choice(...)` / `_RNG.randint(...)`. Immune to anything another pack does.
**Real issue 3 (minor) — "closest match" fallback silently reuses styles.**
`nodes/styles_full.py:245-248` snaps an unknown number to the nearest existing style. The
numbering has ~30 gaps (e.g. 42, 45, 48, 53 missing; nothing between 332 and 998), so in
Manual/Increment/Decrement modes a number landing in a gap silently repeats a neighbouring
style instead of erroring or skipping. Does not affect Random mode, where the number is
always drawn from existing keys.
## Resolved
### RM Styles: custom_json_path ignores quoted / relative / variable paths
**Reported:** 2026-08-07
**Nodes:** RM Styles Full, RM Styles Full (Display), RM Styles Pipe, Styles Editor modal
**Symptom:** Setting `custom_json_path` on an RM Styles node silently fell back to the
bundled `data/styles.json`, or logged `Styles file not found` for a file that exists.
**Evidence** (`user/comfyui.log`, 2026-08-07 09:40–09:43):
```
[RMStylesFull] Styles file not found: "C:\Users\rober\Documents\ComfyUI\ComfyUI\styles\Styles.csv"
```
The surrounding double quotes are part of the widget value — the log f-string adds none.
That file exists on disk.
**Root cause:** The path string from the free-text widget was passed straight to `Path()`.
`custom_json_path.strip()` removes whitespace only, so:
- Windows Explorer "Copy as path" quotes (`"C:\..."`) became part of the filename
- `~` and `%USERPROFILE%` were never expanded
- Relative paths resolved against ComfyUI's process CWD, not a predictable root
The node loader (`_load_styles`) and the editor API (`_resolve_styles_file`) each did their
own ad-hoc resolution, so they could also disagree about which file was being targeted.
**Fix:** Single shared `_normalize_styles_path()` in `nodes/styles_full.py`, used by both the
node loader and every editor API route. It strips whitespace, strips matched surrounding
quotes, expands `~` and environment variables, and resolves relative paths against the
ComfyUI base directory. Load failures now log the raw widget value alongside the resolved
path so a mismatch is visible.
**Note:** `.csv` styles files are still unsupported — the loader is JSON-only. A CSV path
now resolves correctly but fails at parse time with an explicit message.
+62 -1
View File
@@ -23,7 +23,17 @@ git clone https://github.com/yourusername/ComfyUI-RMAutomation
A flexible node for loading multiple LoRAs with support for:
- Dynamic number of LoRA inputs (add as many as you need)
- Fixed or random strength per LoRA
- Style string import for automated LoRA loading
- Two ways to feed LoRAs in automatically, both optional:
- **Styles Pipe** + `lora_source` (Image / Motion High / Motion Low) - reads the
selected LoRA string straight out of an `RM_STYLES_PIPE`
- **Style Import** - a raw LoRA string, for graphs with no pipe
- If both are connected the pipe wins, as the more specific intent
- Civitai AIR mode - download LoRAs by model ID
> Previously two nodes, `RM Power LoRA Loader` and `RM Power LoRA Loader (Pipe)`,
> merged 2026-08-24. The single node is a superset of both. The old
> `RMPowerLoraLoaderPipe` key is still registered as an alias so existing workflows
> load unchanged - it resolves to the same class and shows the same name.
#### RM LoRA Collector
Combines multiple LoRA stacks into one. Accepts both LORA_STACK inputs and lora strings.
@@ -99,6 +109,57 @@ The styles file (`data/styles.json`) uses this format:
}
```
### Using a custom styles file
The RM Styles nodes read `data/styles.json` by default. Point the `custom_json_path`
widget at another file to override that. The path accepts:
| Form | Example |
|---|---|
| Absolute | `C:\Users\you\Styles\styles.json` |
| Quoted (Explorer "Copy as path") | `"C:\Users\you\Styles\styles.json"` |
| Home-relative | `~/Styles/styles.json` |
| Environment variable | `%USERPROFILE%\Styles\styles.json` |
| Relative | `styles/mine.json` — resolved against the **ComfyUI root** |
Leave it blank to use the bundled default. The Edit Styles button reads and writes
whichever file the widget points at.
## Converting a styles CSV
`tools/csv_to_styles.py` converts a styles CSV (the A1111 `styles.csv` shape, or the
`Number,Name,Positive,Negative,Lora` shape) into the JSON format above.
**Double-click `tools/Convert Styles CSV.cmd`** and it walks you through it: an Open
dialog for the CSV, a Save As dialog for the JSON, then the summary stays on screen
until you press Enter. You can also drag a CSV onto the launcher to preselect it.
Use the `.cmd`, not the `.py` — editors like Cursor and VS Code claim the `.py`
extension via the per-user `UserChoice` registry key, so double-clicking the script
opens the editor instead of running it. (Note that `assoc .py` / `ftype` will still
report `py.exe`; they read machine-level associations, which `UserChoice` overrides.)
The dialogs need a Python install with tkinter. The `python_embeded` shipped with
ComfyUI portable has none, so use the command line below if that's your only Python.
```bash
# Same dialogs, from a terminal
python tools/csv_to_styles.py
# Or give the paths directly (no dialogs, no pause - good for scripting)
python tools/csv_to_styles.py "ComfyUI/styles/StylesSFW.csv" "my_styles.json"
```
It repairs the things that otherwise make the node fail or silently drop styles:
non-UTF-8 encodings, BOMs, nameless trailing columns, malformed lora tags
(`<lora:X:1)`, space-separated tags, `.8`-style weights), non-breaking spaces,
control characters and embedded newlines, doubled commas, and blank, duplicate,
zero or negative style numbers. It prints a summary of every repair, then re-reads
the file the way the node does and reports how many styles will actually load.
Useful flags: `--dry-run` (report without writing), `--start-number N` (first number
used when renumbering), `--encoding` (force a source encoding).
## Custom Video Formats
Add custom video formats by placing JSON files in the `video_formats` directory. Example format:
+16
View File
@@ -33,6 +33,18 @@ from .nodes.make_image_batch import NODE_CLASS_MAPPINGS as IMAGE_BATCH_MAPPINGS
from .nodes.make_image_batch import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_BATCH_DISPLAY
from .nodes.mask_denoise_remap import NODE_CLASS_MAPPINGS as MASK_DENOISE_REMAP_MAPPINGS
from .nodes.mask_denoise_remap import NODE_DISPLAY_NAME_MAPPINGS as MASK_DENOISE_REMAP_DISPLAY
from .nodes.save_lineage import NODE_CLASS_MAPPINGS as SAVE_LINEAGE_MAPPINGS
from .nodes.save_lineage import NODE_DISPLAY_NAME_MAPPINGS as SAVE_LINEAGE_DISPLAY
from .nodes.load_image_tracked import NODE_CLASS_MAPPINGS as LOAD_TRACKED_MAPPINGS
from .nodes.load_image_tracked import NODE_DISPLAY_NAME_MAPPINGS as LOAD_TRACKED_DISPLAY
# Content Policy editor routes. Registered at import so the modal has a backend as
# soon as ComfyUI is up; failure here must not take the whole pack down with it.
try:
from .nodes import policy_api
policy_api.register_routes()
except Exception as _err: # pragma: no cover
print("[RMAutomation] content policy routes unavailable: %s" % _err)
# Combine all mappings
NODE_CLASS_MAPPINGS = {
@@ -51,6 +63,8 @@ NODE_CLASS_MAPPINGS = {
**MATH_EXPR_MAPPINGS,
**IMAGE_BATCH_MAPPINGS,
**MASK_DENOISE_REMAP_MAPPINGS,
**SAVE_LINEAGE_MAPPINGS,
**LOAD_TRACKED_MAPPINGS,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -69,6 +83,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**MATH_EXPR_DISPLAY,
**IMAGE_BATCH_DISPLAY,
**MASK_DENOISE_REMAP_DISPLAY,
**SAVE_LINEAGE_DISPLAY,
**LOAD_TRACKED_DISPLAY,
}
WEB_DIRECTORY = "./js"
+1
View File
@@ -0,0 +1 @@
{"decks": {"C:\\Users\\rober\\Playground\\Styles\\styles.json|150-331|182": [297, 317, 184, 265, 318, 282, 244, 319, 220, 223], "C:\\Users\\rober\\Playground\\Styles\\styles.json|150-331|165": [246, 311, 155, 258, 265, 236, 188, 319, 278, 292, 287, 247, 192, 162, 227, 310, 201, 253, 256, 284, 281, 331, 326, 150, 291, 235, 261, 330, 166, 234, 260, 276, 237, 289, 322, 325, 269, 158, 288, 297, 220, 223, 186, 197, 180, 274, 175, 191, 304, 320, 321, 199, 239, 172, 167, 270, 195, 328, 222, 308, 207, 249, 203, 316, 154, 198, 160, 290, 164, 268, 298, 187, 169, 202, 293, 159, 170, 178, 183, 210, 179, 229, 273, 241, 283, 262, 301, 327, 151, 244, 233, 174, 275, 209, 230, 173, 204, 165, 255, 218, 317, 228, 216, 194, 282, 168, 266, 277, 226, 307, 315, 245, 211, 184, 193, 153, 238, 156, 219, 214, 208, 280, 257, 189, 271, 285, 294, 185, 303, 161, 254, 206, 305, 157, 272, 196, 205, 243, 200, 259, 279]}, "recent": {"C:\\Users\\rober\\Playground\\Styles\\styles.json|150-331|182": [327, 237, 232, 238, 174, 207, 305, 331, 180, 230, 277, 310], "C:\\Users\\rober\\Playground\\Styles\\styles.json|150-331|165": [300, 296, 329, 252, 313, 250, 152, 309, 217, 242, 182, 224]}}
+26
View File
@@ -0,0 +1,26 @@
2026-08-24T11:28:39 style=15 SANITIZED score=119 removed: minor_hard: child | minor_strong: young | minor_body: flat chest, petite, tiny
2026-08-24T11:28:39 style=48 SANITIZED score=114 removed: stated age: 8 y/o | minor_strong: young | minor_body: flat chest | minor_tag: 1girl
2026-08-24T11:28:39 style=54 SANITIZED score=204 removed: stated age: age 8 | minor_hard: child | minor_body: small | minor_tag: 1girl
2026-08-24T11:28:39 style=70 SANITIZED score=110 removed: stated age: age 12 | minor_strong: young
2026-08-24T11:28:39 style=73 SANITIZED score=102 removed: stated age: 14 year old | minor_tag: 1girl, 1boy
2026-08-24T11:28:39 style=84 SANITIZED score=210 removed: stated age: age 10 | minor_hard: child | minor_strong: young
2026-08-24T11:28:39 style=92 SANITIZED score=114 removed: stated age: 13 year old | minor_strong: young | minor_body: flat chest | minor_tag: 1girl
2026-08-24T11:28:39 style=95 SANITIZED score=219 removed: stated age: age 10 | minor_hard: child | minor_strong: young | minor_body: small breasts, petite, small
2026-08-24T11:28:39 style=225 SANITIZED score=116 removed: minor_hard: young girl | minor_strong: young | minor_body: small breasts, small
2026-08-24T11:29:40 style=15 BLOCKED score=119 subject-level || minor_hard: child | minor_strong: young | minor_body: flat chest, petite, tiny
2026-08-24T11:29:40 style=48 BLOCKED score=114 subject-level || stated age: 8 y/o | minor_strong: young | minor_body: flat chest | minor_tag: 1girl
2026-08-24T11:29:40 style=54 BLOCKED score=204 subject-level || stated age: age 8 | minor_hard: child | minor_body: small | minor_tag: 1girl
2026-08-24T11:29:40 style=70 BLOCKED score=110 subject-level || stated age: age 12 | minor_strong: young
2026-08-24T11:29:40 style=73 BLOCKED score=102 subject-level || stated age: 14 year old | minor_tag: 1girl, 1boy
2026-08-24T11:29:40 style=84 BLOCKED score=210 subject-level || stated age: age 10 | minor_hard: child | minor_strong: young
2026-08-24T11:29:40 style=92 BLOCKED score=114 subject-level || stated age: 13 year old | minor_strong: young | minor_body: flat chest | minor_tag: 1girl
2026-08-24T11:29:40 style=95 BLOCKED score=219 subject-level || stated age: age 10 | minor_hard: child | minor_strong: young | minor_body: small breasts, petite, small
2026-08-24T11:29:40 style=225 BLOCKED score=116 subject-level || minor_hard: young girl | minor_strong: young | minor_body: small breasts, small
2026-08-24T11:29:40 style=901 SANITIZED score=13 removed: minor_strong: young | minor_body: petite
2026-08-24T11:29:40 style=903 SANITIZED score=10 removed: minor_strong: teen
2026-08-24T11:45:36 style=296 SANITIZED score=10 removed: minor_strong: teen
2026-08-24T11:48:59 style=329 SANITIZED score=10 removed: minor_body: small breasts, petite, small | minor_tag: 1girl
2026-08-24T12:06:46 style=217 SANITIZED score=40 contextual: 4 term(s)
2026-08-24T12:14:10 style=217 SANITIZED score=40 contextual: 4 term(s)
2026-08-24T12:15:10 style=182 SANITIZED score=10 contextual: 1 term(s)
2026-08-24T12:18:22 style=224 SANITIZED score=30 contextual: 3 term(s)
+274
View File
@@ -0,0 +1,274 @@
{
"_readme": [
"Content policy for RMAutomation. Loaded by nodes/content_policy.py, which gates",
"RMStylesFull.load_style() - the point where a resolved prompt is handed to the sampler.",
"Edits take effect on the next generation; no ComfyUI restart needed.",
"Threshold is 10 (strict): any single strong age word in a sexual prompt blocks.",
"Deleting this file does NOT disable the policy - the node refuses to run without it."
],
"sexual_context": [
"ahegao",
"anal",
"anus",
"areola",
"aroused",
"asshole",
"bdsm",
"blowjob",
"bondage",
"bottomless",
"breast",
"breasts",
"bukkake",
"buttocks",
"buttplug",
"cameltoe",
"clitoris",
"cock",
"cowgirl position",
"creampie",
"crotch",
"cum",
"cumshot",
"cunnilingus",
"deepthroat",
"dildo",
"doggystyle",
"ejaculat",
"erect",
"erection",
"exhibitionism",
"facesitting",
"fellatio",
"femdom",
"fingering",
"footjob",
"foursome",
"fucked",
"fucking",
"gangbang",
"genital",
"genitalia",
"glory hole",
"gloryhole",
"gokkun",
"groin",
"handjob",
"hentai",
"horny",
"humping",
"intercourse",
"labia",
"lewd",
"lingerie",
"masturbat",
"missionary",
"moaning",
"naked",
"nipple",
"nipples",
"nsfw",
"nude",
"nudity",
"oral",
"orgasm",
"orgy",
"paizuri",
"panties",
"panty",
"peeing",
"pegging",
"penetration",
"penis",
"pissing",
"porn",
"pubes",
"pubic",
"pussy",
"rimming",
"scrotum",
"seductive",
"semen",
"sex",
"sexually",
"shibari",
"sideboob",
"spanking",
"spread legs",
"spreading legs",
"squirting",
"strap-on",
"strapon",
"testicle",
"thong",
"threesome",
"thrusting",
"titjob",
"topless",
"unclothed",
"underboob",
"undressed",
"upskirt",
"vagina",
"vibrator",
"vulva",
"watersports"
],
"minor_regex": {
"_note": [
"Regex patterns, scored as minor_hard. These exist because a word list CANNOT catch a",
"numeric age. The first run of this tool stripped 'child' and 'young' from a prompt and",
"happily left '(age 10' behind - lower score, identical output. Patterns close that hole."
],
"patterns": [
"age\\s+(?:0?[0-9]|1[0-7])(?![0-9.])",
"age\\s*:\\s*(?:0?[0-9]|1[0-7])(?![0-9.])",
"(?<![0-9])(?:0?[0-9]|1[0-7])\\s*(?:yo|y/o|y\\.o\\.|years?[ -]?old)(?![a-z])",
"(?<![0-9])(?:0?[0-9]|1[0-7])(?:st|nd|rd|th)?[ -]?grade(?![a-z])",
"aged?\\s+(?:0?[0-9]|1[0-7])(?![0-9.])"
],
"_falsepositive_note": [
"The (?![0-9.]) guard exists because prompt-weight syntax looks like an age.",
"Without it, '(age:1.2)' - an emphasis weight - matched as 'age:1' and reported a",
"child. A tool that cries wolf on weight syntax trains you to click past the real ones."
]
},
"minor_hard": [
"loli",
"lolicon",
"shota",
"shotacon",
"toddlercon",
"child",
"children",
"toddler",
"infant",
"newborn",
"preteen",
"pre-teen",
"pre teen",
"underage",
"under-age",
"minor",
"jailbait",
"barely legal",
"prepubescent",
"pre-pubescent",
"kindergarten",
"elementary school",
"grade school",
"primary school",
"middle school",
"schoolgirl",
"school girl",
"schoolboy",
"school boy",
"little girl",
"little boy",
"young girl",
"young boy",
"kid",
"kids",
"baby girl",
"baby boy"
],
"minor_strong": [
"young",
"younger",
"youthful",
"youngest",
"teen",
"teens",
"teenage",
"teenager",
"adolescent",
"juvenile",
"childlike",
"child-like",
"childish",
"girlish",
"boyish",
"immature",
"innocent looking",
"baby face",
"babyface",
"daughter",
"stepdaughter",
"son",
"stepson",
"niece",
"nephew",
"student",
"pupil",
"schoolyard",
"playground",
"nursery"
],
"minor_body": [
"petite",
"small",
"tiny",
"little",
"smallest",
"undeveloped",
"flat chest",
"flat chested",
"flatchest",
"no breasts",
"small breasts",
"budding",
"developing",
"slim body",
"skinny body",
"child body",
"short stature",
"small frame",
"delicate frame",
"hairless"
],
"minor_tag": [
"1girl",
"1boy",
"2girls",
"2boys",
"female child",
"male child",
"loli face",
"young face",
"small body"
],
"allowlist": {
"_note": [
"Phrases masked out BEFORE scoring, so an age word inside a proper noun does not trip.",
"Seeded from a real false positive: 'Raven from Teen Titans' was sanitized because of",
"the bare word 'teen'. Masking is phrase-level - the rest of the prompt still scores.",
"SAFETY: an entry may not contain a minor_hard term or a numeric age. content_policy.py",
"validates this on load and refuses to run if violated, so the allowlist cannot be used",
"to punch a hole in the policy."
],
"phrases": [
"teen titans",
"teenage mutant ninja turtles",
"teenage mutant",
"little mermaid",
"little red riding hood",
"little witch academia",
"baby oil",
"babydoll",
"baby doll",
"baby blue",
"baby pink",
"baby hair",
"baby bangs",
"babyface freckles",
"small town",
"small business",
"little black dress",
"young adult",
"new york",
"student of the arts",
"teen wolf",
"little italy"
]
}
}
+35
View File
@@ -0,0 +1,35 @@
# ALLOWED PHRASES - you own this file.
#
# One phrase per line. Blank lines and lines starting with # are ignored.
# These are masked out of the prompt BEFORE any matching happens, so a blocked
# word sitting inside one of these phrases will not trigger. The rest of the
# prompt is still checked normally.
#
# Use this for proper nouns and compounds where a flagged word is innocent.
#
# SAFETY: a phrase here may not contain an always-blocks ('!') term or match a
# regex rule. The policy validates this on load and refuses to run if violated,
# so this file cannot be used to disable a hard rule.
teen titans
teenage mutant ninja turtles
teenage mutant
little mermaid
little red riding hood
little witch academia
baby oil
babydoll
baby doll
baby blue
baby pink
baby hair
baby bangs
babyface freckles
small town
small business
little black dress
young adult
new york
student of the arts
teen wolf
little italy
+119
View File
@@ -0,0 +1,119 @@
# BLOCKED TERMS - you own this file.
#
# One term per line. Blank lines and lines starting with # are ignored.
# Edits take effect on the next generation; no ComfyUI restart needed.
#
# !term a leading '!' means ALWAYS blocks, on its own, regardless of context.
# Use for language that is never acceptable in any prompt.
#
# term no prefix means it only counts when the prompt is also sexual
# (see context_terms.txt). This is what stops an innocent phrase
# from being blocked in a non-sexual prompt.
#
# /regex/ wrap in forward slashes for a pattern instead of a literal word.
# Treated as ALWAYS-blocks, same as '!'.
#
# Matching is whole-word and case-insensitive. To exempt a specific phrase that
# contains a blocked word, add that phrase to allowed_phrases.txt.
!loli
!lolicon
!shota
!shotacon
!toddlercon
!child
!children
!toddler
!infant
!newborn
!preteen
!pre-teen
!pre teen
!underage
!under-age
!minor
!jailbait
!barely legal
!prepubescent
!pre-pubescent
!kindergarten
!elementary school
!grade school
!primary school
!middle school
!schoolgirl
!school girl
!schoolboy
!school boy
!little girl
!little boy
!young girl
!young boy
!kid
!kids
!baby girl
!baby boy
/age\s+(?:0?[0-9]|1[0-7])(?![0-9.])/
/age\s*:\s*(?:0?[0-9]|1[0-7])(?![0-9.])/
/(?<![0-9])(?:0?[0-9]|1[0-7])\s*(?:yo|y/o|y\.o\.|years?[ -]?old)(?![a-z])/
/(?<![0-9])(?:0?[0-9]|1[0-7])(?:st|nd|rd|th)?[ -]?grade(?![a-z])/
/aged?\s+(?:0?[0-9]|1[0-7])(?![0-9.])/
1boy
1girl
2boys
2girls
adolescent
baby face
babyface
boyish
budding
child body
child-like
childish
childlike
daughter
delicate frame
developing
female child
flat chest
flat chested
flatchest
girlish
hairless
immature
innocent looking
juvenile
little
loli face
male child
nephew
niece
no breasts
nursery
petite
playground
pupil
schoolyard
short stature
skinny body
slim body
small
small body
small breasts
small frame
smallest
son
stepdaughter
stepson
student
teen
teenage
teenager
teens
tiny
undeveloped
young
young face
younger
youngest
youthful
+116
View File
@@ -0,0 +1,116 @@
# CONTEXT TERMS - you own this file.
#
# One term per line. Blank lines and lines starting with # are ignored.
#
# These do not block anything by themselves. They decide whether a prompt counts
# as sexual, which is what activates the non-'!' entries in blocked_terms.txt.
#
# Being generous here is safe: it only makes the unprefixed blocked terms apply
# more often. Avoid words that are commonly innocent, or you will get false
# positives on ordinary prompts.
ahegao
anal
anus
areola
aroused
asshole
bdsm
blowjob
bondage
bottomless
breast
breasts
bukkake
buttocks
buttplug
cameltoe
clitoris
cock
cowgirl position
creampie
crotch
cum
cumshot
cunnilingus
deepthroat
dildo
doggystyle
ejaculat
erect
erection
exhibitionism
facesitting
fellatio
femdom
fingering
footjob
foursome
fucked
fucking
gangbang
genital
genitalia
glory hole
gloryhole
gokkun
groin
handjob
hentai
horny
humping
intercourse
labia
lewd
lingerie
masturbat
missionary
moaning
naked
nipple
nipples
nsfw
nude
nudity
oral
orgasm
orgy
paizuri
panties
panty
peeing
pegging
penetration
penis
pissing
porn
pubes
pubic
pussy
rimming
scrotum
seductive
semen
sex
sexually
shibari
sideboob
spanking
spread legs
spreading legs
squirting
strap-on
strapon
testicle
thong
threesome
thrusting
titjob
topless
unclothed
underboob
undressed
upskirt
vagina
vibrator
vulva
+449
View File
@@ -0,0 +1,449 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
/**
* Content Policy editor.
*
* Three term lists, edited as structured rows rather than raw text: severity is a
* segmented control, not a punctuation prefix you have to remember. A Text tab is kept
* alongside for bulk paste, because typing 100 terms one row at a time is nobody's idea
* of user friendly.
*
* The live tester is the point of the whole screen. A word list is abstract; "paste a
* prompt, watch what happens to it" is not. Edits are testable before saving because the
* test runs against the in-memory draft, not the file on disk.
*/
const TABS = [
{ key: "blocked", label: "Blocked", hint: "Terms to catch. Severity decides whether a match blocks outright or only inside a sexual prompt." },
{ key: "allowed", label: "Allowed", hint: "Phrases exempted before matching, so a flagged word inside a proper noun does not fire." },
{ key: "context", label: "Context", hint: "Words that make a prompt count as sexual. These activate the 'In context' terms — they never block on their own." },
];
const SEVERITIES = [
{ key: "always", short: "Always", tip: "Blocks on its own, in any prompt. Never sanitized — the image is replaced with a grey placeholder." },
{ key: "contextual", short: "In context", tip: "Only counts when the prompt is also sexual. The tag is removed and generation continues." },
{ key: "pattern", short: "Pattern", tip: "A regular expression. Always blocks, like Always." },
];
const CSS = `
.rmcp-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.6); backdrop-filter:blur(3px);
z-index:10000; display:flex; align-items:center; justify-content:center; }
.rmcp { width:min(1080px,94vw); height:min(760px,92vh); background:#1b1d21; color:#e7e9ec;
border:1px solid #33373d; border-radius:14px; display:flex; flex-direction:column;
overflow:hidden; box-shadow:0 24px 70px rgba(0,0,0,.55);
font:13px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
.rmcp-head { padding:16px 20px 0; border-bottom:1px solid #2b2f35; }
.rmcp-titlerow { display:flex; align-items:baseline; gap:12px; margin-bottom:14px; }
.rmcp-title { font-size:15px; font-weight:600; letter-spacing:-.01em; }
.rmcp-sub { color:#868e98; font-size:12px; }
.rmcp-x { margin-left:auto; background:none; border:0; color:#868e98; font-size:20px;
cursor:pointer; line-height:1; padding:0 2px; }
.rmcp-x:hover { color:#e7e9ec; }
.rmcp-tabs { display:flex; gap:2px; }
.rmcp-tab { background:none; border:0; border-bottom:2px solid transparent; color:#868e98;
padding:8px 14px 10px; cursor:pointer; font-size:13px; display:flex; align-items:center; gap:7px; }
.rmcp-tab:hover { color:#c8ccd2; }
.rmcp-tab.on { color:#e7e9ec; border-bottom-color:#5b9dff; }
.rmcp-count { font-size:11px; background:#2b3037; color:#9aa2ac; border-radius:9px; padding:1px 7px; }
.rmcp-tab.on .rmcp-count { background:#1e3a63; color:#8fbaff; }
.rmcp-body { flex:1; display:flex; min-height:0; }
.rmcp-left { flex:1.35; display:flex; flex-direction:column; min-width:0; border-right:1px solid #2b2f35; }
.rmcp-right { flex:1; display:flex; flex-direction:column; min-width:0; background:#16181b; }
.rmcp-hint { padding:11px 20px; color:#868e98; font-size:12px; border-bottom:1px solid #2b2f35; }
.rmcp-toolbar { display:flex; gap:8px; align-items:center; padding:11px 20px; border-bottom:1px solid #2b2f35; }
.rmcp-add { flex:1; background:#111316; border:1px solid #33373d; border-radius:8px;
padding:7px 11px; color:#e7e9ec; font-size:13px; font-family:inherit; }
.rmcp-add:focus { outline:none; border-color:#5b9dff; }
.rmcp-seg { display:flex; border:1px solid #33373d; border-radius:8px; overflow:hidden; }
.rmcp-seg button { background:#111316; border:0; color:#868e98; padding:7px 10px; cursor:pointer;
font-size:12px; border-right:1px solid #2b2f35; }
.rmcp-seg button:last-child { border-right:0; }
.rmcp-seg button.on { background:#1e3a63; color:#bcd7ff; }
.rmcp-seg button:hover:not(.on) { color:#c8ccd2; }
.rmcp-list { flex:1; overflow-y:auto; padding:8px 12px 16px; }
.rmcp-row { display:flex; align-items:center; gap:9px; padding:5px 8px; border-radius:8px; }
.rmcp-row:hover { background:#212429; }
.rmcp-term { flex:1; background:none; border:0; color:#e7e9ec; font-size:13px;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace; padding:3px 0; min-width:0; }
.rmcp-term:focus { outline:none; }
.rmcp-row .rmcp-seg { opacity:0; transition:opacity .12s; }
.rmcp-row:hover .rmcp-seg, .rmcp-row.sev-always .rmcp-seg, .rmcp-row.sev-pattern .rmcp-seg { opacity:1; }
.rmcp-seg.mini button { padding:3px 8px; font-size:11px; }
.rmcp-del { background:none; border:0; color:#5c646e; cursor:pointer; font-size:16px;
opacity:0; padding:0 4px; line-height:1; }
.rmcp-row:hover .rmcp-del { opacity:1; }
.rmcp-del:hover { color:#ff7676; }
.rmcp-dot { width:7px; height:7px; border-radius:50%; flex:0 0 auto; background:#4a525c; }
.rmcp-row.sev-always .rmcp-dot { background:#ff6b6b; }
.rmcp-row.sev-pattern .rmcp-dot { background:#c98bff; }
.rmcp-row.sev-contextual .rmcp-dot { background:#ffb020; }
.rmcp-textarea { flex:1; width:100%; background:#111316; border:0; color:#e7e9ec; padding:14px 20px;
font:12.5px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace; resize:none; }
.rmcp-textarea:focus { outline:none; }
.rmcp-testhead { padding:13px 18px 10px; font-size:11px; font-weight:600; letter-spacing:.06em;
text-transform:uppercase; color:#868e98; }
.rmcp-test { margin:0 18px; background:#111316; border:1px solid #33373d; border-radius:9px;
padding:11px; color:#e7e9ec; font:12.5px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
resize:none; height:118px; }
.rmcp-test:focus { outline:none; border-color:#5b9dff; }
.rmcp-verdict { margin:14px 18px 0; border-radius:9px; padding:12px 14px; border:1px solid; }
.rmcp-verdict.clean { background:#12281c; border-color:#1f4d33; }
.rmcp-verdict.sanitized { background:#2b2210; border-color:#5c481a; }
.rmcp-verdict.blocked { background:#2e1416; border-color:#5e2126; }
.rmcp-verdict.idle { background:#1a1c20; border-color:#2b2f35; color:#5c646e; }
.rmcp-vtop { display:flex; align-items:center; gap:9px; margin-bottom:7px; }
.rmcp-badge { font-size:10px; font-weight:700; letter-spacing:.07em; padding:3px 8px; border-radius:5px; }
.rmcp-badge.clean { background:#2ecc8a; color:#062616; }
.rmcp-badge.sanitized { background:#ffb020; color:#2a1c00; }
.rmcp-badge.blocked { background:#ff5c5c; color:#2c0708; }
.rmcp-score { margin-left:auto; font-size:11px; color:#868e98; font-family:ui-monospace,monospace; }
.rmcp-why { font-size:12px; color:#a8b0ba; margin-bottom:9px; }
.rmcp-out { background:#0d0f11; border-radius:7px; padding:9px 11px; font:12px/1.55 ui-monospace,monospace;
color:#c8ccd2; max-height:150px; overflow:auto; white-space:pre-wrap; word-break:break-word; }
.rmcp-outlabel { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:#5c646e; margin-bottom:5px; }
.rmcp-foot { display:flex; align-items:center; gap:12px; padding:13px 20px;
border-top:1px solid #2b2f35; background:#15171a; }
.rmcp-status { flex:1; font-size:12px; color:#868e98; min-width:0; }
.rmcp-status.err { color:#ff8a8a; }
.rmcp-status.ok { color:#5fd6a0; }
.rmcp-btn { border-radius:8px; padding:8px 16px; font-size:13px; cursor:pointer; border:1px solid #3a3f47;
background:#23272d; color:#e7e9ec; font-family:inherit; }
.rmcp-btn:hover { background:#2b3037; }
.rmcp-btn.primary { background:#3b7de0; border-color:#3b7de0; color:#fff; font-weight:500; }
.rmcp-btn.primary:hover { background:#4a8bee; }
.rmcp-btn:disabled { opacity:.45; cursor:default; }
.rmcp-empty { color:#5c646e; padding:26px 10px; text-align:center; font-size:12.5px; }
`;
class ContentPolicyModal {
constructor() {
this.tab = "blocked";
this.mode = "list";
this.lists = { blocked: [], allowed: [], context: [] };
this.dirty = false;
this.newSeverity = "contextual";
this.testTimer = null;
}
async open() {
this._injectCss();
this._build();
await this._load();
}
_injectCss() {
if (document.getElementById("rmcp-css")) return;
const s = document.createElement("style");
s.id = "rmcp-css";
s.textContent = CSS;
document.head.appendChild(s);
}
_build() {
this.backdrop = document.createElement("div");
this.backdrop.className = "rmcp-backdrop";
this.backdrop.addEventListener("mousedown", (e) => {
if (e.target === this.backdrop) this._close();
});
this.backdrop.innerHTML = `
<div class="rmcp">
<div class="rmcp-head">
<div class="rmcp-titlerow">
<span class="rmcp-title">Content Policy</span>
<span class="rmcp-sub">Applied to every prompt before it reaches the sampler</span>
<button class="rmcp-x" title="Close">&times;</button>
</div>
<div class="rmcp-tabs"></div>
</div>
<div class="rmcp-body">
<div class="rmcp-left">
<div class="rmcp-hint"></div>
<div class="rmcp-toolbar"></div>
<div class="rmcp-list"></div>
</div>
<div class="rmcp-right">
<div class="rmcp-testhead">Test a prompt</div>
<textarea class="rmcp-test" placeholder="Paste a prompt to see what the policy would do with it…"></textarea>
<div class="rmcp-verdict idle">Type above to preview the outcome.</div>
</div>
</div>
<div class="rmcp-foot">
<span class="rmcp-status"></span>
<button class="rmcp-btn cancel">Cancel</button>
<button class="rmcp-btn primary save" disabled>Save changes</button>
</div>
</div>`;
document.body.appendChild(this.backdrop);
const $ = (s) => this.backdrop.querySelector(s);
this.el = {
tabs: $(".rmcp-tabs"), hint: $(".rmcp-hint"), toolbar: $(".rmcp-toolbar"),
list: $(".rmcp-list"), test: $(".rmcp-test"), verdict: $(".rmcp-verdict"),
status: $(".rmcp-status"), save: $(".save"),
};
$(".rmcp-x").onclick = () => this._close();
$(".cancel").onclick = () => this._close();
this.el.save.onclick = () => this._save();
this.el.test.addEventListener("input", () => this._scheduleTest());
this._escHandler = (e) => { if (e.key === "Escape") this._close(); };
document.addEventListener("keydown", this._escHandler);
this._renderTabs();
this._renderToolbar();
}
_close() {
if (this.dirty && !confirm("Discard unsaved changes to the content policy?")) return;
document.removeEventListener("keydown", this._escHandler);
this.backdrop.remove();
}
async _load() {
try {
const r = await api.fetchApi("/api/rmautomation/policy");
const d = await r.json();
if (!d.ok) throw new Error(d.error || "failed to load");
this.lists = d.lists;
this._renderTabs();
this._renderList();
this._setStatus("");
} catch (err) {
this._setStatus(`Could not load policy: ${err.message}`, "err");
}
}
_renderTabs() {
this.el.tabs.innerHTML = "";
for (const t of TABS) {
const b = document.createElement("button");
b.className = "rmcp-tab" + (t.key === this.tab ? " on" : "");
b.innerHTML = `${t.label}<span class="rmcp-count">${(this.lists[t.key] || []).length}</span>`;
b.onclick = () => { this.tab = t.key; this._renderTabs(); this._renderToolbar(); this._renderList(); };
this.el.tabs.appendChild(b);
}
this.el.hint.textContent = TABS.find(t => t.key === this.tab).hint;
}
_renderToolbar() {
const tb = this.el.toolbar;
tb.innerHTML = "";
const input = document.createElement("input");
input.className = "rmcp-add";
input.placeholder = this.tab === "blocked" ? "Add a term, then Enter…"
: this.tab === "allowed" ? "Add an exempt phrase, then Enter…"
: "Add a context word, then Enter…";
input.addEventListener("keydown", (e) => {
if (e.key !== "Enter") return;
const v = input.value.trim();
if (!v) return;
const item = this.tab === "blocked" ? { value: v, severity: this.newSeverity } : { value: v };
this.lists[this.tab].unshift(item);
input.value = "";
this._touch();
this._renderTabs(); this._renderList();
});
tb.appendChild(input);
if (this.tab === "blocked") {
tb.appendChild(this._severitySeg(this.newSeverity, (k) => {
this.newSeverity = k; this._renderToolbar(); input.focus();
}));
}
const modeSeg = document.createElement("div");
modeSeg.className = "rmcp-seg";
for (const m of ["list", "text"]) {
const b = document.createElement("button");
b.textContent = m === "list" ? "List" : "Text";
b.className = this.mode === m ? "on" : "";
b.title = m === "text" ? "Edit as raw lines — useful for pasting many at once" : "Edit as rows";
b.onclick = () => { this.mode = m; this._renderToolbar(); this._renderList(); };
modeSeg.appendChild(b);
}
tb.appendChild(modeSeg);
}
_severitySeg(current, onPick, mini) {
const seg = document.createElement("div");
seg.className = "rmcp-seg" + (mini ? " mini" : "");
for (const s of SEVERITIES) {
const b = document.createElement("button");
b.textContent = s.short;
b.title = s.tip;
b.className = current === s.key ? "on" : "";
b.onclick = (e) => { e.stopPropagation(); onPick(s.key); };
seg.appendChild(b);
}
return seg;
}
_renderList() {
const host = this.el.list;
host.innerHTML = "";
const items = this.lists[this.tab] || [];
if (this.mode === "text") {
const ta = document.createElement("textarea");
ta.className = "rmcp-textarea";
ta.value = items.map(i =>
this.tab === "blocked"
? (i.severity === "always" ? "!" + i.value : i.severity === "pattern" ? "/" + i.value + "/" : i.value)
: i.value
).join("\n");
ta.addEventListener("input", () => {
const lines = ta.value.split("\n").map(l => l.trim()).filter(Boolean);
this.lists[this.tab] = lines.map(l => {
if (this.tab !== "blocked") return { value: l };
if (l.startsWith("!")) return { value: l.slice(1).trim(), severity: "always" };
if (l.length > 1 && l.startsWith("/") && l.endsWith("/")) return { value: l.slice(1, -1), severity: "pattern" };
return { value: l, severity: "contextual" };
});
this._touch(); this._renderTabs();
});
host.appendChild(ta);
return;
}
if (!items.length) {
host.innerHTML = `<div class="rmcp-empty">Nothing here yet. Add an entry above.</div>`;
return;
}
items.forEach((item, idx) => {
const row = document.createElement("div");
row.className = "rmcp-row" + (this.tab === "blocked" ? ` sev-${item.severity || "contextual"}` : "");
const dot = document.createElement("span");
dot.className = "rmcp-dot";
row.appendChild(dot);
const term = document.createElement("input");
term.className = "rmcp-term";
term.value = item.value;
term.addEventListener("input", () => { item.value = term.value; this._touch(); });
row.appendChild(term);
if (this.tab === "blocked") {
row.appendChild(this._severitySeg(item.severity || "contextual", (k) => {
item.severity = k; this._touch(); this._renderList();
}, true));
}
const del = document.createElement("button");
del.className = "rmcp-del";
del.innerHTML = "&times;";
del.title = "Remove";
del.onclick = () => { items.splice(idx, 1); this._touch(); this._renderTabs(); this._renderList(); };
row.appendChild(del);
host.appendChild(row);
});
}
_touch() {
this.dirty = true;
this.el.save.disabled = false;
this._setStatus("Unsaved changes");
this._scheduleTest();
}
_scheduleTest() {
clearTimeout(this.testTimer);
this.testTimer = setTimeout(() => this._runTest(), 280);
}
async _runTest() {
const text = this.el.test.value.trim();
if (!text) {
this.el.verdict.className = "rmcp-verdict idle";
this.el.verdict.textContent = "Type above to preview the outcome.";
return;
}
// Saved rules are what the server enforces, so say so rather than implying the
// draft is live. Testing an unsaved draft would need the server to accept a full
// ruleset per keystroke; showing the truth is better than faking it.
try {
const r = await api.fetchApi("/api/rmautomation/policy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || "test failed");
const label = d.status.toUpperCase();
const why = d.reasons?.length ? d.reasons.join(" · ") : "no rules matched";
const showOut = d.status !== "clean";
this.el.verdict.className = `rmcp-verdict ${d.status}`;
this.el.verdict.innerHTML = `
<div class="rmcp-vtop">
<span class="rmcp-badge ${d.status}">${label}</span>
<span class="rmcp-score">score ${d.score} / threshold ${d.threshold}</span>
</div>
<div class="rmcp-why">${escapeHtml(why)}${this.dirty ? " — against <b>saved</b> rules; save to test your edits" : ""}</div>
${showOut ? `<div class="rmcp-outlabel">${d.status === "blocked" ? "Replaced with" : "Generates from"}</div>
<div class="rmcp-out">${escapeHtml(d.result)}</div>` : ""}`;
} catch (err) {
this.el.verdict.className = "rmcp-verdict blocked";
this.el.verdict.textContent = `Test failed: ${err.message}`;
}
}
async _save() {
this.el.save.disabled = true;
this._setStatus("Saving…");
try {
const r = await api.fetchApi("/api/rmautomation/policy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(this.lists),
});
const d = await r.json();
if (!d.ok) throw new Error(d.error || "save failed");
this.dirty = false;
const n = Object.values(d.counts).reduce((a, b) => a + b, 0);
this._setStatus(`Saved — ${n} entries active`, "ok");
this._runTest();
} catch (err) {
// Validation failures land here and are the whole reason the message is shown
// verbatim: "your allowlist would disable a hard rule" needs to be readable.
this._setStatus(err.message, "err");
this.el.save.disabled = false;
}
}
_setStatus(msg, kind) {
this.el.status.className = "rmcp-status" + (kind ? " " + kind : "");
this.el.status.textContent = msg;
}
}
function escapeHtml(s) {
return String(s).replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
}
app.registerExtension({
name: "RMAutomation.ContentPolicy",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!["RMStylesFull", "RMStylesFullDisplay", "RMStylesPipe"].includes(nodeData.name)) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
this.addWidget("button", "Content Policy", null, () => new ContentPolicyModal().open());
};
},
});
+24 -51
View File
@@ -11,20 +11,11 @@ console.log("[RMStyles] Script loading...");
const RM_STYLES_NODES = ["RMStylesFull", "RMStylesFullDisplay", "RMStylesPipe"];
/**
* Calculate the next prompt number based on mode (Increment/Decrement only)
*/
function calculateNextNumber(mode, current, min, max) {
switch (mode) {
case "Increment":
return current >= max ? min : current + 1;
case "Decrement":
return current <= min ? max : current - 1;
case "Manual":
default:
return current;
}
}
// The client-side counter that used to live here has been removed. Increment/Decrement
// are now computed in Python (nodes/styles_full.py::_step_sequence) and reported back
// via the `rm_styles_executed` message. Advancing it here silently froze or skipped the
// sequence whenever the browser wasn't driving execution — batch-queued prompts all
// snapshot the same next_prompt, and API-queued runs never load this file at all.
/**
* Set up widget callbacks for a node
@@ -61,19 +52,11 @@ function setupNodeWidgets(node) {
}
if (node._rmUpdating) return;
node._rmUpdating = true;
// Calculate new next_prompt for Increment/Decrement preview
if (value !== "Manual" && value !== "Random") {
const current = nextWidget.value;
const min = minWidget.value;
const max = maxWidget.value;
const newValue = calculateNextNumber(value, current, min, max);
nextWidget.value = newValue;
console.log(`[RMStyles] Mode changed to ${value}, next_prompt: ${newValue}`);
}
node._rmUpdating = false;
// Deliberately does NOT touch next_prompt any more. Nudging the widget on a mode
// change would skip a style, and Python (which now owns the counter) would read the
// altered widget as a deliberate edit and resync to it. next_prompt is the value
// that WILL be used; switching mode should not consume one.
console.log(`[RMStyles] Mode changed to ${value}; next_prompt left at ${nextWidget.value}`);
};
// Hook next_prompt widget callback to detect manual edits
@@ -98,28 +81,14 @@ function setupNodeWidgets(node) {
* Handle node execution complete (Increment/Decrement only)
* Random mode is handled by the rm_styles_executed message from Python.
*/
// NOTE: the counter is no longer advanced here. Every mode is now driven by Python via
// the `rm_styles_executed` message. Advancing client-side meant the sequence froze or
// skipped whenever the browser wasn't the thing driving execution — batch-queued
// prompts all snapshot the same next_prompt, and API-queued runs (Cloud Run controller,
// auto-retry) never execute this file at all. Python is the single source of truth.
function handleNodeExecuted(node) {
const w = node._rmWidgets;
if (!w) return;
const mode = w.modeWidget.value;
// Random mode: Python handles everything via rm_styles_executed message
if (mode === "Random") return;
const usedPrompt = w.nextWidget.value;
node._rmUpdating = true;
w.prevWidget.value = usedPrompt;
// Calculate new next_prompt for Increment/Decrement
if (mode !== "Manual") {
const newNext = calculateNextNumber(mode, usedPrompt, w.minWidget.value, w.maxWidget.value);
w.nextWidget.value = newNext;
console.log(`[RMStyles] Execution complete: used=${usedPrompt}, next=${newNext}, mode=${mode}`);
}
node._rmUpdating = false;
// Intentionally a no-op; kept so the executing/execution_success wiring below is
// unchanged and there is one obvious place to look for the old behavior.
}
// Register the extension
@@ -172,17 +141,21 @@ app.registerExtension({
pendingNodes.clear();
};
// Listen for Python's random selection message
// Python reports what it used and what it will use next. Both widgets are display
// only — this is the single place either of them is updated after execution.
api.addEventListener("rm_styles_executed", (e) => {
const { node_id, prompt_number } = e.detail;
const { node_id, prompt_number, next_prompt } = e.detail;
const node = app.graph?.getNodeById(Number(node_id));
if (!node || !node._rmWidgets) return;
node._rmUpdating = true;
node._rmWidgets.prevWidget.value = prompt_number;
if (typeof next_prompt === "number") {
node._rmWidgets.nextWidget.value = next_prompt;
}
node._rmUpdating = false;
console.log(`[RMStyles] Python selected prompt #${prompt_number} (node ${node_id})`);
console.log(`[RMStyles] Python used #${prompt_number}, next=${next_prompt ?? "(n/a)"} (node ${node_id})`);
});
api.addEventListener("execution_start", (e) => {
+114
View File
@@ -0,0 +1,114 @@
import { app } from "../../scripts/app.js";
/**
* Prompt override widgets for the RM Styles nodes.
*
* Two problems solved here, both about keeping the node small and honest:
*
* 1. A multiline widget is ~6 rows tall. Two of them would roughly double the node for a
* feature most graphs never use, so each text box is hidden whenever its mode is Off.
* The mode selector stays visible, so the node always states what is active - the cost
* is two combo rows, and nothing when idle.
*
* 2. Positive = Replace bypasses the style library entirely. The selection widgets then
* do nothing, so they are greyed out rather than left looking operational. A control
* that appears live but is ignored is worse than one that is visibly switched off.
*/
const NODES = ["RMStylesFull", "RMStylesFullDisplay", "RMStylesPipe"];
// Widgets that only mean something when a style is actually being selected.
const SELECTION_WIDGETS = ["mode", "previous_prompt", "next_prompt", "minimum", "maximum", "custom_json_path"];
const HIDDEN_TYPE = "rmcp-hidden";
function hide(widget) {
if (widget.type === HIDDEN_TYPE) return;
widget._origType = widget.type;
widget._origComputeSize = widget.computeSize;
widget.type = HIDDEN_TYPE;
widget.computeSize = () => [0, -4]; // -4 cancels the inter-widget gap
if (widget.inputEl) widget.inputEl.style.display = "none";
}
function show(widget) {
if (widget.type !== HIDDEN_TYPE) return;
widget.type = widget._origType;
widget.computeSize = widget._origComputeSize;
if (widget.inputEl) widget.inputEl.style.display = "";
}
function setDisabled(widget, off) {
// ComfyUI honours `disabled` on standard widgets; the label tweak is a belt-and-braces
// cue for themes and widget types that ignore it.
widget.disabled = off;
if (off) {
if (!widget._origLabel) widget._origLabel = widget.label ?? widget.name;
widget.label = `${widget._origLabel} · unused`;
} else if (widget._origLabel) {
widget.label = widget._origLabel;
}
}
function applyState(node) {
const get = (n) => node.widgets?.find((w) => w.name === n);
const posMode = get("positive_mode");
const negMode = get("negative_mode");
const posText = get("positive_text");
const negText = get("negative_text");
if (!posMode || !negMode) return;
// Text boxes follow their own mode.
if (posText) (posMode.value === "Off" ? hide : show)(posText);
if (negText) (negMode.value === "Off" ? hide : show)(negText);
// Only the POSITIVE being Replace bypasses the library. Replacing just the negative
// still needs a style selected, so the selection widgets stay live in that case.
const freePrompt = posMode.value === "Replace";
for (const name of SELECTION_WIDGETS) {
const w = get(name);
if (w) setDisabled(w, freePrompt);
}
node.setDirtyCanvas(true, true);
const size = node.computeSize();
// Grow to fit, never shrink below what the user has dragged to.
node.setSize([Math.max(node.size[0], size[0]), Math.max(size[1], node.size[1] === undefined ? 0 : size[1])]);
}
app.registerExtension({
name: "RMAutomation.StylesOverrides",
async beforeRegisterNodeDef(nodeType, nodeData) {
if (!NODES.includes(nodeData.name)) return;
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
const node = this;
for (const name of ["positive_mode", "negative_mode"]) {
const w = node.widgets?.find((x) => x.name === name);
if (!w) continue;
const prev = w.callback;
w.callback = function () {
const r = prev?.apply(this, arguments);
applyState(node);
return r;
};
}
// Defer one frame: widgets from a loaded workflow are not all attached yet at
// onNodeCreated, so measuring or hiding now would use stale state.
requestAnimationFrame(() => applyState(node));
};
// Re-apply after a saved graph restores widget values.
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
const r = onConfigure?.apply(this, arguments);
requestAnimationFrame(() => applyState(this));
return r;
};
},
});
+302
View File
@@ -0,0 +1,302 @@
"""
RM Content Policy - gate prompts against user-defined term lists.
This is the LAST LINE, and deliberately the only one that cannot be routed around.
Every other place this check could live is optional to use: an import gate only runs if
you use the importer, a styles.json validator only helps if the prompt came through
styles.json, and RMStylesFull accepts a `custom_json_path` pointing at any file at all.
This check sits in load_style(), where the resolved prompt is handed back for the sampler
- downstream of all of them.
THE TERM LISTS ARE YOURS. Three plain-text files under data/policy/, one entry per line,
each documented in its own header:
blocked_terms.txt what to catch. '!term' always blocks, 'term' blocks only in a
sexual prompt, '/regex/' is a pattern and always blocks.
allowed_phrases.txt exemptions. Masked out before matching, so a blocked word inside
one of these phrases does not trigger.
context_terms.txt what makes a prompt count as sexual, activating the bare terms.
Files hot-reload on change - edit and generate, no restart.
Three outcomes:
clean passes untouched
sanitized offending tags removed, generation proceeds, image marked amber
blocked placeholder substituted, image marked red
Why both: removing a descriptor from an otherwise-fine prompt is a real fix. Removing the
SUBJECT is not - it leaves a scene that was written about that subject with the label
filed off, and it scores clean afterwards precisely because the evidence is gone. So an
always-blocks term routes straight to the placeholder and is never sanitized.
"""
import os
import re
from datetime import datetime
POLICY_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "policy")
BLOCKED_FILE = os.path.join(POLICY_DIR, "blocked_terms.txt")
ALLOWED_FILE = os.path.join(POLICY_DIR, "allowed_phrases.txt")
CONTEXT_FILE = os.path.join(POLICY_DIR, "context_terms.txt")
BLOCK_LOG = os.path.join(POLICY_DIR, "..", "blocked.log")
# A prompt is blocked when it accumulates this much weight.
THRESHOLD = 10
WEIGHT_ALWAYS = 100 # '!' and /regex/ entries
WEIGHT_CONTEXT = 10 # bare entries, counted only in a sexual prompt
# Deliberately not a person: a blocked run must be obvious at a glance in the grid.
PLACEHOLDER_POSITIVE = (
"a plain flat neutral grey card, solid uniform background, no subject, no people, "
"no figures, empty studio backdrop, minimalist, featureless"
)
PLACEHOLDER_NEGATIVE = (
"person, people, human, figure, body, face, portrait, character, anatomy, skin"
)
_cache = None
_mtimes = None
def _read_list(path):
"""One entry per line; '#' comments and blank lines ignored."""
if not os.path.isfile(path):
return []
out = []
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#"):
out.append(line)
return out
def _stamps():
out = []
for p in (BLOCKED_FILE, ALLOWED_FILE, CONTEXT_FILE):
try:
out.append(os.path.getmtime(p))
except OSError:
out.append(None)
return tuple(out)
def _load():
"""
Parse the three files into a policy dict, reloading when any of them changes.
A missing blocked_terms.txt raises rather than returning an empty policy. An empty
ruleset silently means "nothing is blocked", which is the one failure mode that must
never happen quietly.
"""
global _cache, _mtimes
stamps = _stamps()
if _cache is not None and stamps == _mtimes:
return _cache
if not os.path.isfile(BLOCKED_FILE):
raise RuntimeError(
"[RMContentPolicy] %s is missing - refusing to run unguarded" % BLOCKED_FILE
)
always, contextual, patterns = [], [], []
for entry in _read_list(BLOCKED_FILE):
if entry.startswith("!"):
term = entry[1:].strip()
if term:
always.append(term)
elif len(entry) > 1 and entry.startswith("/") and entry.endswith("/"):
body = entry[1:-1]
try:
re.compile(body)
patterns.append(body)
except re.error as err:
print("[RMContentPolicy] skipping bad regex %r: %s" % (entry, err))
else:
contextual.append(entry)
policy = {
"always": always,
"contextual": contextual,
"patterns": patterns,
"allowed": _read_list(ALLOWED_FILE),
"context": _read_list(CONTEXT_FILE),
}
_validate_allowlist(policy)
_cache, _mtimes = policy, stamps
print("[RMContentPolicy] loaded %d always-block, %d contextual, %d pattern, "
"%d allowed, %d context terms"
% (len(always), len(contextual), len(patterns),
len(policy["allowed"]), len(policy["context"])))
return policy
def _validate_allowlist(policy):
"""
An allowed phrase may not contain an always-block term or match a pattern.
Without this the allowlist is a hole in the policy: adding one carefully-chosen phrase
would exempt a hard rule everywhere it appears. Raising here means a mistake in that
file stops generation instead of quietly widening what gets through.
"""
bad = []
for phrase in policy["allowed"]:
low = phrase.lower()
for term in policy["always"]:
if re.search(r"(?<![a-z0-9])" + re.escape(term.lower()) + r"(?![a-z])", low):
bad.append((phrase, "contains always-block term %r" % term))
for pat in policy["patterns"]:
if re.search(pat, low):
bad.append((phrase, "matches pattern /%s/" % pat))
if bad:
lines = "\n".join(" %r - %s" % (p, why) for p, why in bad)
raise RuntimeError(
"[RMContentPolicy] allowed_phrases.txt would disable a hard rule:\n%s\n"
" Remove these entries, or downgrade the rule in blocked_terms.txt." % lines
)
def _mask_allowed(lowered, allowed):
"""Blank out allowed phrases so terms inside them cannot match."""
for phrase in sorted(allowed, key=len, reverse=True):
if not phrase:
continue
lowered = re.sub(re.escape(phrase.lower()), lambda m: " " * len(m.group(0)), lowered)
return lowered
def _find_terms(lowered, terms):
found = []
for term in sorted(terms, key=len, reverse=True):
# Tags can begin with a digit, so \b is wrong at the front.
if re.search(r"(?<![a-z0-9])" + re.escape(term.lower()) + r"(?![a-z])", lowered):
found.append(term)
return found
def _find_patterns(lowered, patterns):
found = []
for pat in patterns:
for m in re.finditer(pat, lowered):
hit = m.group(0).strip()
if hit and hit not in found:
found.append(hit)
return found
def _prepare(texts):
policy = _load()
lowered = " \n ".join(str(t or "") for t in texts).lower()
return policy, _mask_allowed(lowered, policy["allowed"])
def evaluate(*texts):
"""Score the combined text. Returns (score, reasons)."""
policy, lowered = _prepare(texts)
if not lowered.strip():
return 0, []
score, reasons = 0, []
hits = _find_patterns(lowered, policy["patterns"])
if hits:
score += WEIGHT_ALWAYS * len(hits)
reasons.append("pattern: %d match(es)" % len(hits))
hits = _find_terms(lowered, policy["always"])
if hits:
score += WEIGHT_ALWAYS * len(hits)
reasons.append("always-block: %d term(s)" % len(hits))
# Bare terms only count when the prompt is sexual - that is what keeps an innocent
# use of a flagged word from being treated as a violation.
if _find_terms(lowered, policy["context"]):
hits = _find_terms(lowered, policy["contextual"])
if hits:
score += WEIGHT_CONTEXT * len(hits)
reasons.append("contextual: %d term(s)" % len(hits))
return score, reasons
def check(*texts):
"""True if this prompt is allowed to generate."""
return evaluate(*texts)[0] < THRESHOLD
def is_subject_level(*texts):
"""
True when an always-block term or pattern is present.
These are never sanitized. A contextual term is a descriptor attached to a subject and
removing it leaves the subject intact; an always-block term IS the subject.
"""
policy, lowered = _prepare(texts)
return bool(_find_patterns(lowered, policy["patterns"])
or _find_terms(lowered, policy["always"]))
def sanitize(text):
"""
Drop every comma-delimited segment containing a flagged term.
Segment-level rather than word-level because these prompts are tag lists: deleting a
bare word leaves the surrounding tag describing the same thing, only harder to detect.
"""
policy = _load()
terms = policy["always"] + policy["contextual"]
kept = []
for seg in text.split(","):
seg = seg.strip()
if not seg:
continue
masked = _mask_allowed(seg.lower(), policy["allowed"])
if _find_terms(masked, terms) or _find_patterns(masked, policy["patterns"]):
continue
kept.append(seg)
return ", ".join(kept)
def _log(style_number, outcome, score, detail):
try:
with open(BLOCK_LOG, "a", encoding="utf-8") as fh:
fh.write("%s\tstyle=%s\t%s\tscore=%d\t%s\n"
% (datetime.now().isoformat(timespec="seconds"),
style_number, outcome, score, detail))
except OSError:
pass # logging must never take down a render
def enforce(style_number, positive, negative, motion):
"""Gate one style. Returns (positive, negative, motion, status)."""
score, reasons = evaluate(positive, motion)
if score < THRESHOLD:
return positive, negative, motion, "clean"
detail = " | ".join(reasons)
if is_subject_level(positive, motion):
print("\n" + "!" * 60)
print("[RMContentPolicy] STYLE #%s BLOCKED (score %d)" % (style_number, score))
print("[RMContentPolicy] %s" % detail)
print("[RMContentPolicy] always-block rule - not sanitizable, using placeholder")
print("!" * 60 + "\n")
_log(style_number, "BLOCKED", score, "always-block || " + detail)
return PLACEHOLDER_POSITIVE, PLACEHOLDER_NEGATIVE, "", "blocked"
clean_pos = sanitize(positive)
clean_motion = sanitize(motion) if motion else motion
recheck, _ = evaluate(clean_pos, clean_motion)
if recheck < THRESHOLD and clean_pos.strip():
print("\n[RMContentPolicy] style #%s SANITIZED (was %d, now %d): %s"
% (style_number, score, recheck, detail))
_log(style_number, "SANITIZED", score, detail)
return clean_pos, negative, clean_motion, "sanitized"
print("\n[RMContentPolicy] style #%s BLOCKED - nothing usable left after sanitizing"
% style_number)
_log(style_number, "BLOCKED", score, "residue after sanitize || " + detail)
return PLACEHOLDER_POSITIVE, PLACEHOLDER_NEGATIVE, "", "blocked"
+175
View File
@@ -0,0 +1,175 @@
"""
RM Lineage - shared helpers for recording how one saved image led to another.
Every image saved by this pack carries a small set of `rm_*` PNG tEXt chunks describing
where it sits in the run that produced it. The Playground Viewer reads them back and
renders a lineage panel (see Viewer/server/metadata.ts and .claude/PLAN-image-lineage.md).
The chunk contract - keep this in sync with the viewer's parser:
rm_run_id str ComfyUI prompt_id for the execution. Groups siblings.
rm_parent str JSON array of "<folder>/<filename>". [] = root, >1 = merge.
rm_step int Display order. Sparse (0,10,20) so steps can be inserted later.
rm_step_name str "Base", "Refine", "Upscale 4x" - the panel label.
rm_role str "intermediate" (hidden from the grid) or "final" (shown).
rm_variant int Index among siblings from the same save (batch index).
rm_branch str Optional human label when a run forks. Display only.
rm_parent carries the structure; rm_step only orders the display. A run is a tree, not a
list - ordering by step alone breaks the moment a graph forks into two finals.
"""
import json
import os
import uuid
# Root that "<folder>/<filename>" parent references are relative to. Only images saved
# inside this tree can participate in lineage, because that is all the viewer indexes.
PLAYGROUND_IMAGES_DIR = r"C:\Users\rober\Playground\Images"
ROLE_FINAL = "final"
ROLE_INTERMEDIATE = "intermediate"
def current_run_id() -> str:
"""
The ComfyUI prompt_id for the execution in flight.
main.py sets `server_instance.last_prompt_id = prompt_id` before running the graph, so
every save node in one execution reads the same value with no coordination between them.
ComfyUI-Moser/nodes/live_preview.py already relies on this in this same install.
Falls back to a uuid so the node still works headless or under an API-only runner - a
unique-per-save id degrades to "no siblings found", which is correct-but-empty rather
than wrong.
"""
try:
from server import PromptServer
instance = getattr(PromptServer, "instance", None)
run_id = getattr(instance, "last_prompt_id", None) if instance else None
if run_id:
return str(run_id)
except Exception:
pass
return "local-%s" % uuid.uuid4()
def to_ref(filepath: str):
"""
Convert an absolute path to the "<folder>/<filename>" reference the viewer indexes by.
Returns None for anything outside PLAYGROUND_IMAGES_DIR - a reference the viewer cannot
resolve is worse than no reference, because it renders as a broken step in the panel.
"""
if not filepath:
return None
try:
rel = os.path.relpath(os.path.abspath(filepath), PLAYGROUND_IMAGES_DIR)
except ValueError:
# Different drive on Windows - relpath raises rather than returning something useless.
return None
if rel.startswith(".."):
return None
parts = rel.replace("\\", "/").split("/")
if len(parts) != 2:
# Deeper nesting than <prompt>/<file> is not part of the indexed layout.
return None
return "/".join(parts)
def parse_parent_input(raw) -> list:
"""
Normalise whatever arrived on a `parent_path` input into a list of refs.
Accepts the JSON array this pack emits, a bare path string for hand-wired graphs, or a
list. Anything unparseable yields [] - a missing link is recoverable, a wrong one is not.
"""
if not raw:
return []
if isinstance(raw, (list, tuple)):
candidates = list(raw)
elif isinstance(raw, str):
text = raw.strip()
if not text:
return []
if text.startswith("["):
try:
parsed = json.loads(text)
candidates = parsed if isinstance(parsed, list) else [parsed]
except (ValueError, TypeError):
candidates = [text]
else:
candidates = [text]
else:
return []
refs = []
for entry in candidates:
if not isinstance(entry, str) or not entry.strip():
continue
entry = entry.strip()
# Already a "<folder>/<filename>" ref? Keep it. Otherwise try to make one.
ref = entry if _looks_like_ref(entry) else to_ref(entry)
if ref:
refs.append(ref)
return refs
def _looks_like_ref(value: str) -> bool:
if os.path.isabs(value) or "\\" in value:
return False
parts = value.split("/")
return len(parts) == 2 and bool(parts[0]) and bool(parts[1])
def resolve_parents(parent_refs: list, image_count: int, index: int) -> list:
"""
Decide which parent(s) the image at `index` of a batch of `image_count` derives from.
| incoming | outgoing | behaviour |
|----------|----------|----------------------------------------------------|
| N | N | positional: out[i] <- in[i] |
| N | 1 | merge: the single output records all N parents |
| 1 | N | fan-out: every output records the one parent |
| other | | no parent, and say so on stdout |
On an unmatchable mismatch we record nothing. A missing link shows up immediately as a
step that isn't there; a wrong link renders as a confident, plausible, incorrect chain.
"""
count = len(parent_refs)
if count == 0:
return []
if count == image_count:
return [parent_refs[index]]
if image_count == 1:
return list(parent_refs)
if count == 1:
return [parent_refs[0]]
print(
"[RMLineage] %d parent refs for %d images - cannot match positionally, "
"recording no parent for this save." % (count, image_count)
)
return []
def write_chunks(
metadata,
run_id: str,
parents: list,
step: int,
step_name: str,
role: str,
variant: int,
branch: str = "",
):
"""Attach the rm_* chunk set to a PngInfo object."""
metadata.add_text("rm_run_id", str(run_id))
metadata.add_text("rm_parent", json.dumps(parents or []))
metadata.add_text("rm_step", str(int(step)))
metadata.add_text("rm_role", role if role in (ROLE_FINAL, ROLE_INTERMEDIATE) else ROLE_FINAL)
metadata.add_text("rm_variant", str(int(variant)))
if step_name:
metadata.add_text("rm_step_name", str(step_name))
if branch:
metadata.add_text("rm_branch", str(branch))
+102
View File
@@ -0,0 +1,102 @@
"""
RM Load Image Tracked - load a previously generated image and keep its identity.
This is the other half of cross-run lineage. Load a finished image from Playground\\Images,
wire `saved_path` into a save node's `parent_path`, and the new image's chain reaches back
into the run that produced the original - even if that was weeks ago.
Path accepts either form:
47/47_20260807_020924.png (ref, relative to Playground\\Images)
C:\\Users\\rober\\Playground\\Images\\47\\47_...png (absolute)
Loading an image from outside Playground\\Images still works - you just get an empty
saved_path, because the viewer has no id for a file it does not index. That degrades to
"no ancestors", which is honest, rather than to a broken step in the panel.
"""
import os
import numpy as np
import torch
from PIL import Image, ImageOps
from .lineage import PLAYGROUND_IMAGES_DIR, to_ref
class RMLoadImageTracked:
"""Load an image by path and emit the lineage ref alongside it."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"path": ("STRING", {"default": "", "placeholder": "47/47_20260807_020924.png"}),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
RETURN_NAMES = ("image", "mask", "saved_path")
FUNCTION = "load"
CATEGORY = "RMAutomation/Image"
@staticmethod
def _resolve(path: str) -> str:
path = (path or "").strip().strip('"')
if not path:
raise ValueError("RMLoadImageTracked: path is empty")
if os.path.isabs(path):
return path
return os.path.join(PLAYGROUND_IMAGES_DIR, path.replace("/", os.sep))
def load(self, path):
filepath = self._resolve(path)
if not os.path.isfile(filepath):
raise FileNotFoundError("RMLoadImageTracked: no such image: %s" % filepath)
img = Image.open(filepath)
img = ImageOps.exif_transpose(img)
rgb = img.convert("RGB")
image = torch.from_numpy(np.array(rgb).astype(np.float32) / 255.0)[None, ]
if "A" in img.getbands():
alpha = np.array(img.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(alpha)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
ref = to_ref(filepath) or ""
if not ref:
print(
"[RMLoadImageTracked] %s is outside %s - loaded without a lineage ref"
% (filepath, PLAYGROUND_IMAGES_DIR)
)
return (image, mask.unsqueeze(0), ref)
@classmethod
def IS_CHANGED(cls, path):
# Re-run when the file on disk changes; a path string alone would cache forever.
try:
return os.path.getmtime(cls._resolve(path))
except Exception:
return float("nan")
@classmethod
def VALIDATE_INPUTS(cls, path):
try:
filepath = cls._resolve(path)
except ValueError as err:
return str(err)
if not os.path.isfile(filepath):
return "No such image: %s" % filepath
return True
NODE_CLASS_MAPPINGS = {
"RMLoadImageTracked": RMLoadImageTracked,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RMLoadImageTracked": "RM Load Image (tracked)",
}
+182
View File
@@ -0,0 +1,182 @@
"""
HTTP routes backing the Content Policy modal editor.
Kept separate from content_policy.py so the enforcement logic stays importable and
testable with no aiohttp/ComfyUI dependency.
The editor never sees a term list this code invented - it reads and writes the same three
files the policy loads from, and preserves each file's instructional header on save.
"""
import os
from . import content_policy as cp
FILES = {
"blocked": cp.BLOCKED_FILE,
"allowed": cp.ALLOWED_FILE,
"context": cp.CONTEXT_FILE,
}
def _split_header(path):
"""
Return (header_lines, entry_lines).
The leading comment block documents the file's own format and is the only thing
telling a future reader what '!' and '/regex/' mean. Saving from the editor must not
silently eat it, so it is captured and written back verbatim.
"""
if not os.path.isfile(path):
return [], []
header, entries, in_header = [], [], True
with open(path, encoding="utf-8") as fh:
for raw in fh:
line = raw.rstrip("\n")
stripped = line.strip()
if in_header and (stripped.startswith("#") or not stripped):
header.append(line)
continue
in_header = False
if stripped and not stripped.startswith("#"):
entries.append(stripped)
return header, entries
def _classify(entry):
"""Split a blocked-list entry into its severity and bare value for the UI."""
if entry.startswith("!"):
return {"value": entry[1:].strip(), "severity": "always"}
if len(entry) > 1 and entry.startswith("/") and entry.endswith("/"):
return {"value": entry[1:-1], "severity": "pattern"}
return {"value": entry, "severity": "contextual"}
def _serialise(item):
sev = item.get("severity", "contextual")
val = (item.get("value") or "").strip()
if not val:
return None
if sev == "always":
return "!" + val
if sev == "pattern":
return "/" + val + "/"
return val
def read_all():
out = {}
for key, path in FILES.items():
_, entries = _split_header(path)
if key == "blocked":
out[key] = [_classify(e) for e in entries]
else:
out[key] = [{"value": e} for e in entries]
return out
def write_all(payload):
"""
Persist all three lists. Validates before touching disk so a rejected save leaves the
previous policy fully intact rather than half-written.
"""
staged = {}
for key, path in FILES.items():
items = payload.get(key)
if items is None:
continue
lines = []
for item in items:
line = _serialise(item) if key == "blocked" else (item.get("value") or "").strip()
if line and line not in lines:
lines.append(line)
staged[key] = (path, lines)
# Dry-run the parse and the allowlist safety check against what we are about to write,
# so an invalid ruleset is rejected before any file is touched.
def staged_lines(key):
return staged[key][1] if key in staged else _split_header(FILES[key])[1]
probe = {
"always": [], "contextual": [], "patterns": [],
"allowed": list(staged_lines("allowed")),
"context": list(staged_lines("context")),
}
import re
for entry in staged_lines("blocked"):
if entry.startswith("!"):
probe["always"].append(entry[1:].strip())
elif len(entry) > 1 and entry.startswith("/") and entry.endswith("/"):
body = entry[1:-1]
try:
re.compile(body)
except re.error as err:
raise ValueError("Invalid pattern /%s/ - %s" % (body, err))
probe["patterns"].append(body)
else:
probe["contextual"].append(entry)
cp._validate_allowlist(probe) # raises RuntimeError with a readable message
for key, (path, lines) in staged.items():
header, _ = _split_header(path)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
if header:
fh.write("\n".join(header).rstrip() + "\n\n")
for line in lines:
fh.write(line + "\n")
cp._cache = None # force reload on next generation
return {k: len(v[1]) for k, v in staged.items()}
def test_prompt(text):
"""Run a candidate prompt through the live policy and describe what would happen."""
score, reasons = cp.evaluate(text, "")
positive, _neg, _mot, status = cp.enforce("test", text, "", "")
return {
"status": status,
"score": score,
"threshold": cp.THRESHOLD,
"reasons": reasons,
"result": positive,
"subject_level": cp.is_subject_level(text, ""),
}
def register_routes():
"""Attach routes to ComfyUI's server. Safe to call more than once."""
try:
from aiohttp import web
from server import PromptServer
except Exception:
return
routes = PromptServer.instance.routes
@routes.get("/api/rmautomation/policy")
async def _get(_request):
try:
return web.json_response({"ok": True, "lists": read_all()})
except Exception as err:
return web.json_response({"ok": False, "error": str(err)}, status=500)
@routes.put("/api/rmautomation/policy")
async def _put(request):
try:
payload = await request.json()
counts = write_all(payload)
return web.json_response({"ok": True, "counts": counts})
except (ValueError, RuntimeError) as err:
# Validation failure - the client shows this verbatim, so it must read well.
return web.json_response({"ok": False, "error": str(err)}, status=400)
except Exception as err:
return web.json_response({"ok": False, "error": str(err)}, status=500)
@routes.post("/api/rmautomation/policy/test")
async def _test(request):
try:
payload = await request.json()
return web.json_response({"ok": True, **test_prompt(payload.get("text", ""))})
except Exception as err:
return web.json_response({"ok": False, "error": str(err)}, status=500)
+53 -69
View File
@@ -282,10 +282,20 @@ class FlexibleOptionalInputType(dict):
"""
Allows dynamic inputs - ComfyUI will accept any input we provide at runtime.
This enables the Power LoRA Loader to accept unlimited lora inputs added via UI.
Declares BOTH ways of feeding loras in, because this node used to be two:
Styles Pipe + lora_source - the pipe path (was RMPowerLoraLoaderPipe)
Style Import - a raw lora string (was RMPowerLoraLoader)
Both are optional. Connect whichever suits the graph; the pipe wins if both are
present. Keeping Style Import is what lets old non-pipe workflows keep loading
against the merged node.
"""
def __init__(self, any_type_str="*"):
def __init__(self, any_type_str="*", pipe_type="RM_STYLES_PIPE"):
self.any_type_str = any_type_str
super().__init__({
"lora_source": (["Image Loras", "Motion Loras High", "Motion Loras Low"],
{"default": "Image Loras"}),
"Styles Pipe": (pipe_type,),
"Style Import": ("STRING", {"forceInput": True}),
})
@@ -365,7 +375,7 @@ class RMPowerLoraLoader:
def INPUT_TYPES(cls):
return {
"required": {},
"optional": FlexibleOptionalInputType("*"),
"optional": FlexibleOptionalInputType("*", "RM_STYLES_PIPE"),
"hidden": {},
}
@@ -373,8 +383,37 @@ class RMPowerLoraLoader:
RETURN_NAMES = ("Lora Stack",)
FUNCTION = "build_lora_stack"
def build_lora_stack(self, **kwargs):
"""Build a lora stack from the dynamic inputs provided by the UI and Style Import input."""
# Which pipe slot each lora_source setting reads from.
# RM_STYLES_PIPE: 0 Positive 1 Negative 2 Motion 3 ImageLoras 4 MotionHigh 5 MotionLow ...
_PIPE_SLOTS = {"Image Loras": 3, "Motion Loras High": 4, "Motion Loras Low": 5}
@staticmethod
def _lora_string_from_pipe(styles_pipe, lora_source):
"""Pull the selected lora string out of an RM_STYLES_PIPE, or None."""
if not styles_pipe:
return None
# A pipe arriving as a 1-tuple wrapper is unwrapped; that is how RMStylesPipe
# returns it from ComfyUI's output plumbing.
pipe = styles_pipe[0] if isinstance(styles_pipe, tuple) and len(styles_pipe) == 1 else styles_pipe
if not isinstance(pipe, tuple):
return None
slot = RMPowerLoraLoader._PIPE_SLOTS.get(lora_source)
if slot is None or len(pipe) <= slot:
return None
return pipe[slot] or None
def build_lora_stack(self, lora_source="Image Loras", **kwargs):
"""
Build a lora stack from the dynamic UI inputs plus whichever style source is wired.
A connected Styles Pipe takes precedence over a raw Style Import string - if both
are present the pipe is the more specific intent.
"""
styles_pipe = kwargs.pop("Styles Pipe", None)
from_pipe = self._lora_string_from_pipe(styles_pipe, lora_source)
if from_pipe:
kwargs["Style Import"] = from_pipe
stack = []
# Get Civitai API key if any lora uses AIR mode
@@ -661,81 +700,26 @@ class RMLoraApply:
return (model, clip)
class FlexibleOptionalInputTypeNoPipe(dict):
"""
Like FlexibleOptionalInputType but without the Style Import field.
Used for Pipe variants that get loras from the pipe instead.
This must be used as the ENTIRE optional dict, not spread into another dict.
"""
def __init__(self, any_type_str="*", pipe_type="RM_STYLES_PIPE"):
self.any_type_str = any_type_str
# Include the pipe-specific fields in the base dict
super().__init__({
"lora_source": (["Image Loras", "Motion Loras High", "Motion Loras Low"], {"default": "Image Loras"}),
"Styles Pipe": (pipe_type,),
})
def __getitem__(self, key):
if key in dict.keys(self):
return dict.__getitem__(self, key)
return (self.any_type_str,)
def __contains__(self, key):
if key == "style_import" or key == "Style Import":
return False
return True
class RMPowerLoraLoaderPipe(RMPowerLoraLoader):
"""
Power LoRA Loader variant that accepts a Style Pipe input.
Allows selecting which lora type to use from the pipe.
"""
NAME = "RM Power Lora Loader (Pipe)"
CATEGORY = "RMAutomation/LoRA"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": FlexibleOptionalInputTypeNoPipe("*", "RM_STYLES_PIPE"),
"hidden": {},
}
def build_lora_stack(self, lora_source="Image Loras", **kwargs):
"""Build a lora stack from style pipe and UI inputs."""
# Extract the selected lora string from the pipe
# RM_STYLES_PIPE order: Positive, Negative, Motion, Image Loras, Motion High, Motion Low, Number, Name
styles_pipe = kwargs.pop("Styles Pipe", None)
style_import = None
if styles_pipe:
pipe_data = styles_pipe[0] if isinstance(styles_pipe, tuple) and len(styles_pipe) == 1 else styles_pipe
if isinstance(pipe_data, tuple):
if lora_source == "Image Loras" and len(pipe_data) > 3:
style_import = pipe_data[3]
elif lora_source == "Motion Loras High" and len(pipe_data) > 4:
style_import = pipe_data[4]
elif lora_source == "Motion Loras Low" and len(pipe_data) > 5:
style_import = pipe_data[5]
# Add to kwargs so parent class processes it
if style_import:
kwargs["Style Import"] = style_import
return super().build_lora_stack(**kwargs)
# RMPowerLoraLoaderPipe and FlexibleOptionalInputTypeNoPipe were merged into
# RMPowerLoraLoader above on 2026-08-24. The pipe handling moved into build_lora_stack
# and Style Import was kept alongside it, so the single node is a superset of both.
NODE_CLASS_MAPPINGS = {
"RMPowerLoraLoader": RMPowerLoraLoader,
"RMPowerLoraLoaderPipe": RMPowerLoraLoaderPipe,
# Back-compat alias. 28 saved workflows reference this key by name; without it every
# one of them loads with a red "missing node" box. Same class - the merged node reads
# a Styles Pipe exactly as the old Pipe variant did, so those graphs behave identically.
"RMPowerLoraLoaderPipe": RMPowerLoraLoader,
"RMLoraCollector": RMLoraCollector,
"RMLoraApply": RMLoraApply,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RMPowerLoraLoader": "RM Power LoRA Loader",
"RMPowerLoraLoaderPipe": "RM Power LoRA Loader (Pipe)",
# Deliberately identical: an existing node on a canvas keeps working and now shows the
# consolidated name, with nothing hinting there were ever two.
"RMPowerLoraLoaderPipe": "RM Power LoRA Loader",
"RMLoraCollector": "RM LoRA Collector",
"RMLoraApply": "RM LoRA Apply",
}
+143 -8
View File
@@ -3,17 +3,66 @@ RM Save Image With Metadata - Save images with workflow metadata using RM_STYLES
"""
import os
from PIL import Image
from PIL import Image, ImageDraw
import numpy as np
import json
from PIL.PngImagePlugin import PngInfo
from datetime import datetime
# Corner marker drawn on images whose prompt the content policy altered. Small and muted
# on purpose - it is a provenance mark for you, not a censorship bar. The metadata chunk
# is the authoritative record; the pixel mark just makes it visible while scrolling a grid.
MARK_SIZE = 26 # px on the long edge of the triangle, scaled up for large images
MARK_INSET = 10
MARK_COLOURS = {
# (fill, outline) - amber for a sanitized prompt, red for a fully blocked one
"sanitized": ((255, 176, 32, 210), (40, 24, 0, 230)),
"blocked": ((255, 72, 72, 225), (48, 0, 0, 235)),
}
def _draw_policy_mark(img, status):
"""
Stamp a small triangle in the bottom-right corner. Returns a new image.
Composited through an RGBA overlay rather than drawn directly, so the mark can be
semi-transparent and sit over any background without a hard edge.
"""
if status not in MARK_COLOURS:
return img
fill, outline = MARK_COLOURS[status]
base = img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Scale with the image so the mark stays legible on a 4x upscale but never dominates
# a small one.
size = max(MARK_SIZE, int(min(base.size) * 0.022))
inset = max(MARK_INSET, int(min(base.size) * 0.009))
x1, y1 = base.size[0] - inset, base.size[1] - inset
draw.polygon([(x1 - size, y1), (x1, y1), (x1, y1 - size)], fill=fill, outline=outline)
return Image.alpha_composite(base, overlay).convert("RGB")
from .lineage import (
ROLE_FINAL,
ROLE_INTERMEDIATE,
current_run_id,
parse_parent_input,
resolve_parents,
to_ref,
write_chunks,
)
class RMSaveImageWithMetadata:
"""
Save images with metadata using RM_STYLES_PIPE.
RM_STYLES_PIPE order: Positive, Negative, Motion, Image Loras, Motion High, Motion Low, Prompt Number, Prompt Name
Optionally records lineage (see nodes/lineage.py). Every lineage input is optional and
defaulted, so a graph built before lineage existed behaves exactly as it did.
"""
@classmethod
@@ -25,24 +74,52 @@ class RMSaveImageWithMetadata:
"destination": (["Sketchbook", "Playground"],),
"plot": (["yes", "no", "both"],),
},
"optional": {
"step_name": ("STRING", {"default": ""}),
"step_index": ("INT", {"default": 0, "min": 0, "max": 9999}),
"role": ([ROLE_FINAL, ROLE_INTERMEDIATE], {"default": ROLE_FINAL}),
"branch": ("STRING", {"default": ""}),
"parent_path": ("STRING", {"default": "", "forceInput": True}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("saved_paths",)
FUNCTION = "save_image"
OUTPUT_NODE = True
CATEGORY = "RMAutomation/Image"
def save_image(self, images, styles_pipe, destination, plot, prompt=None, extra_pnginfo=None):
def save_image(
self,
images,
styles_pipe,
destination,
plot,
step_name="",
step_index=0,
role=ROLE_FINAL,
branch="",
parent_path="",
prompt=None,
extra_pnginfo=None,
):
if images is None or images.shape[0] == 0:
print("[RMSaveImage] No images provided")
return ()
return ("[]",)
# RM_STYLES_PIPE: prompt_number is at index 6
# RM_STYLES_PIPE: Positive, Negative, Motion, ImageLoras, MotionHigh, MotionLow, Number, Name
positive_prompt = styles_pipe[0] or ""
negative_prompt = styles_pipe[1] or ""
motion_prompt = styles_pipe[2] or ""
prompt_number = styles_pipe[6]
prompt_name = styles_pipe[7] or ""
# Element 8 is appended by RMStylesPipe. Length-checked because a workflow built
# before the content policy existed still supplies an 8-element pipe.
policy_status = styles_pipe[8] if len(styles_pipe) > 8 else "clean"
base_dirs = {
"Sketchbook": r"C:\Users\rober\OneDrive\Documents\Sketchbook",
@@ -50,6 +127,14 @@ class RMSaveImageWithMetadata:
}
base_dir = base_dirs[destination]
is_intermediate = role == ROLE_INTERMEDIATE
# An intermediate in the contact sheet defeats the point of contact sheets - every
# step of every run would land there. Lineage steps live in the Images tree only.
if is_intermediate and plot != "no":
print("[RMSaveImage] role=intermediate - forcing plot=no")
plot = "no"
save_dirs = []
if plot in ["yes", "both"]:
save_dirs.append(os.path.join(base_dir, "Contact Sheets"))
@@ -60,10 +145,20 @@ class RMSaveImageWithMetadata:
os.makedirs(save_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
run_id = current_run_id()
parent_refs = parse_parent_input(parent_path)
count = images.shape[0]
saved_refs = []
for i in range(count):
# Intermediates get a _s<step> suffix so they sort beside their final on disk and
# are identifiable in Explorer without opening them.
suffix = "_%03d" % i if count > 1 else ""
step_suffix = "_s%d" % int(step_index) if is_intermediate else ""
filename = "%s_%s%s%s.png" % (prompt_number, timestamp, step_suffix, suffix)
for i in range(images.shape[0]):
filename = f"{prompt_number}_{timestamp}_{i:03d}.png" if images.shape[0] > 1 else f"{prompt_number}_{timestamp}.png"
img = Image.fromarray(np.clip(255. * images[i].cpu().numpy(), 0, 255).astype(np.uint8))
img = _draw_policy_mark(img, policy_status)
metadata = PngInfo()
if prompt is not None:
@@ -72,12 +167,52 @@ class RMSaveImageWithMetadata:
for k, v in extra_pnginfo.items():
metadata.add_text(k, json.dumps(v))
# The prompt TEXT is not recoverable from the workflow graph: CLIPTextEncode's
# `text` input is a LINK to RMStylesPipeOut, resolved from styles.json at
# execution time, so the literal string exists nowhere in the saved graph.
# Write the resolved strings explicitly. The Playground Viewer reads
# image_positive_prompt directly (see Viewer server/metadata.ts).
if positive_prompt:
metadata.add_text("image_positive_prompt", positive_prompt)
if negative_prompt:
metadata.add_text("image_negative_prompt", negative_prompt)
if motion_prompt:
metadata.add_text("motion_positive_prompt", motion_prompt)
if prompt_name:
metadata.add_text("prompt_name", str(prompt_name))
metadata.add_text("prompt_number", str(prompt_number))
# Authoritative record of the policy outcome. The pixel mark can be cropped
# off; this cannot, short of deliberately rewriting the file. Only written
# when something actually happened, so clean images stay unmarked.
if policy_status != "clean":
metadata.add_text("rm_policy", policy_status)
write_chunks(
metadata,
run_id=run_id,
parents=resolve_parents(parent_refs, count, i),
step=step_index,
step_name=step_name,
role=role,
variant=i,
branch=branch,
)
for save_dir in save_dirs:
filepath = os.path.join(save_dir, filename)
img.save(filepath, pnginfo=metadata, optimize=True)
print(f"[RMSaveImage] Saved: {filepath}")
return ()
# Only the Images tree is indexed by the viewer, so only refs from there are
# usable as a parent downstream. Contact Sheets copies are not addressable.
ref = to_ref(filepath)
if ref and ref not in saved_refs:
saved_refs.append(ref)
# Always a JSON array, even for a single image, so the downstream parser has exactly
# one shape to handle.
return (json.dumps(saved_refs),)
NODE_CLASS_MAPPINGS = {
+236
View File
@@ -0,0 +1,236 @@
"""
RM Save Lineage - save every stage of a linear workflow from a single node.
Wire each stage's image into the next slot (image_1, image_2, ...). Order of the inputs IS
the order of the chain: the last slot is the final, everything before it is an intermediate,
and each stage's parent is the slot before it. No step numbers, no parent wires, no roles.
TRADE-OFF, and it is a real one: ComfyUI runs a node only once ALL of its inputs resolve, so
nothing reaches disk until the whole run finishes. A crash at stage 4 loses stages 1-3, and
all N full-resolution images are held in memory at once. For long or expensive runs use
per-stage RMSaveImageWithMetadata nodes instead - they write as they go.
"""
import json
import os
from datetime import datetime
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from .lineage import (
ROLE_FINAL,
ROLE_INTERMEDIATE,
current_run_id,
to_ref,
write_chunks,
)
# Steps are numbered sparsely so a stage can be inserted between two later without
# renumbering anything that already exists on disk.
STEP_STRIDE = 10
class FlexibleImageInputType(dict):
"""Accepts dynamic image_N inputs. Same pattern as make_image_batch.py."""
def __init__(self):
super().__init__({"image_1": ("IMAGE",)})
def __getitem__(self, key):
if key in dict.keys(self):
return dict.__getitem__(self, key)
if key.startswith("image_"):
return ("IMAGE",)
return ("*",)
def __contains__(self, key):
if key in dict.keys(self):
return True
if key.startswith("image_"):
return True
return False
class RMSaveLineage:
"""Save an ordered chain of stages, recording lineage between them automatically."""
@classmethod
def INPUT_TYPES(cls):
optional = FlexibleImageInputType()
optional["step_names"] = (
"STRING",
{
"multiline": True,
"default": "",
"placeholder": "One name per line, matching image_1, image_2, ...",
},
)
return {
"required": {
"styles_pipe": ("RM_STYLES_PIPE",),
"destination": (["Sketchbook", "Playground"],),
"plot": (["yes", "no", "both"],),
},
"optional": optional,
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("saved_paths",)
FUNCTION = "save_chain"
OUTPUT_NODE = True
CATEGORY = "RMAutomation/Image"
def save_chain(
self,
styles_pipe,
destination,
plot,
step_names="",
prompt=None,
extra_pnginfo=None,
**kwargs,
):
stages = self._collect_stages(kwargs)
if not stages:
print("[RMSaveLineage] No images wired - nothing to save")
return ("[]",)
positive_prompt = styles_pipe[0] or ""
negative_prompt = styles_pipe[1] or ""
motion_prompt = styles_pipe[2] or ""
prompt_number = styles_pipe[6]
prompt_name = styles_pipe[7] or ""
base_dirs = {
"Sketchbook": r"C:\Users\rober\OneDrive\Documents\Sketchbook",
"Playground": r"C:\Users\rober\Playground",
}
base_dir = base_dirs[destination]
images_dir = os.path.join(base_dir, "Images", str(prompt_number))
os.makedirs(images_dir, exist_ok=True)
# The final is the only stage eligible for a contact sheet.
sheet_dir = None
if plot in ("yes", "both"):
sheet_dir = os.path.join(base_dir, "Contact Sheets")
os.makedirs(sheet_dir, exist_ok=True)
write_images_dir = plot in ("no", "both")
names = [line.strip() for line in (step_names or "").splitlines()]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
run_id = current_run_id()
last_index = len(stages) - 1
prev_refs = []
all_refs = []
for stage_index, (slot, batch) in enumerate(stages):
is_final = stage_index == last_index
role = ROLE_FINAL if is_final else ROLE_INTERMEDIATE
step = stage_index * STEP_STRIDE
step_name = names[stage_index] if stage_index < len(names) and names[stage_index] else "Step %d" % (stage_index + 1)
count = int(batch.shape[0])
stage_refs = []
for i in range(count):
suffix = "_%03d" % i if count > 1 else ""
step_suffix = "" if is_final else "_s%d" % step
filename = "%s_%s%s%s.png" % (prompt_number, timestamp, step_suffix, suffix)
img = Image.fromarray(
np.clip(255. * batch[i].cpu().numpy(), 0, 255).astype(np.uint8)
)
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for k, v in extra_pnginfo.items():
metadata.add_text(k, json.dumps(v))
if positive_prompt:
metadata.add_text("image_positive_prompt", positive_prompt)
if negative_prompt:
metadata.add_text("image_negative_prompt", negative_prompt)
if motion_prompt:
metadata.add_text("motion_positive_prompt", motion_prompt)
if prompt_name:
metadata.add_text("prompt_name", str(prompt_name))
metadata.add_text("prompt_number", str(prompt_number))
write_chunks(
metadata,
run_id=run_id,
parents=self._parents_for(prev_refs, count, i),
step=step,
step_name=step_name,
role=role,
variant=i,
)
targets = []
if write_images_dir:
targets.append(os.path.join(images_dir, filename))
if sheet_dir and is_final:
targets.append(os.path.join(sheet_dir, filename))
for filepath in targets:
img.save(filepath, pnginfo=metadata, optimize=True)
print("[RMSaveLineage] %s (%s): %s" % (step_name, role, filepath))
ref = to_ref(filepath)
if ref and ref not in stage_refs:
stage_refs.append(ref)
prev_refs = stage_refs
all_refs.extend(stage_refs)
return (json.dumps(all_refs),)
@staticmethod
def _collect_stages(kwargs):
"""Ordered (slot, image_batch) pairs, skipping unwired slots."""
stages = []
for key in sorted(
(k for k in kwargs if k.startswith("image_")),
key=lambda k: int(k.replace("image_", "")) if k.replace("image_", "").isdigit() else 0,
):
batch = kwargs[key]
# A slot can be present-but-None when the widget exists and nothing is connected.
if batch is None or not hasattr(batch, "shape") or batch.shape[0] == 0:
continue
stages.append((key, batch))
return stages
@staticmethod
def _parents_for(prev_refs, count, index):
"""Positional when the batch size is stable, merge when the stage collapses to one."""
if not prev_refs:
return []
if len(prev_refs) == count:
return [prev_refs[index]]
if count == 1:
return list(prev_refs)
if len(prev_refs) == 1:
return [prev_refs[0]]
print(
"[RMSaveLineage] %d parents for %d images - recording no parent for this stage"
% (len(prev_refs), count)
)
return []
NODE_CLASS_MAPPINGS = {
"RMSaveLineage": RMSaveLineage,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RMSaveLineage": "RM Save Lineage (multi-stage)",
}
+313 -35
View File
@@ -17,35 +17,193 @@ from pathlib import Path
from server import PromptServer
from aiohttp import web
# Anti-repeat history for Random mode (module-level, shared across all style nodes)
RANDOM_HISTORY_SIZE = 30
_random_history = []
from . import content_policy
# Prompt override modes, shared by the node and its JS.
# Replace on the POSITIVE field means the style library is not consulted at all - see
# load_style. Replace on the negative only swaps that one field, because wanting your own
# negative while keeping the library's positive is an ordinary thing to want.
OVERRIDE_MODES = ["Off", "Prepend", "Append", "Replace"]
def _get_random_prompt(minimum, maximum, available_numbers):
"""Pick a random style number from available styles, avoiding recently used ones."""
global _random_history
def _apply_override(base, text, mode):
"""Combine a style's text with the user's override text."""
text = (text or "").strip()
base = base or ""
if mode == "Replace":
return text
if not text or mode == "Off":
return base
if mode == "Prepend":
return f"{text}, {base}" if base else text
if mode == "Append":
return f"{base}, {text}" if base else text
return base
candidates = [n for n in available_numbers if minimum <= n <= maximum]
# --- Selection state -------------------------------------------------------------
#
# Random used to be `random.choice()` over the pool minus the last 30 picks. That is
# statistically uniform but samples WITH replacement, so the tail of the library is
# never guaranteed to be reached: with a 182-style pool, 258 draws left 42 styles
# unused (theory predicts 43.5). The cooldown bounds nothing past 31 picks.
#
# It is now a shuffled DECK: deal each style once, reshuffle only when exhausted. Every
# style is used before any repeats. State is persisted so coverage survives restarts.
#
# Dedicated RNG instance: many ComfyUI packs call random.seed() while they run
# (Easy-Use, frame-interpolation, Hunyuan3D...). Sharing the global RNG means a
# fixed-seed node upstream can lock selection into a short deterministic cycle.
_RNG = random.Random()
_STATE_FILE = Path(__file__).parent.parent / "data" / ".selection_state.json"
# deck_key -> list of numbers still to deal
_decks = {}
# deck_key -> the last few numbers dealt, so a reshuffle can't immediately repeat them
_recent = {}
# How many freshly-dealt styles to keep away from the top of the next shuffle. Without
# this, the last card of one deck can resurface as the first card of the next.
DECK_COOLDOWN = 12
# node_id -> next value this node is expected to receive, so a hand-edited widget wins
_sequence_state = {}
def _load_state():
"""Best-effort restore of decks from disk. Corrupt/missing state is not fatal.
Persisting matters: the old picker's anti-repeat history was in-memory only, so
every ComfyUI restart wiped it and selection began repeating immediately.
"""
global _decks, _recent
try:
if _STATE_FILE.exists():
with open(_STATE_FILE, 'r', encoding='utf-8') as f:
raw = json.load(f)
_decks = {k: list(v) for k, v in raw.get('decks', {}).items() if isinstance(v, list)}
_recent = {k: list(v) for k, v in raw.get('recent', {}).items() if isinstance(v, list)}
except Exception as e:
print(f"[RMStylesFull] Could not restore selection state: {e}")
_decks, _recent = {}, {}
def _save_state():
try:
_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(_STATE_FILE, 'w', encoding='utf-8') as f:
json.dump({'decks': _decks, 'recent': _recent}, f)
except Exception as e:
print(f"[RMStylesFull] Could not persist selection state: {e}")
_load_state()
def _get_random_prompt(minimum, maximum, available_numbers, styles_key=""):
"""Deal the next style from a shuffled deck of the eligible pool.
Every style in range is used exactly once before any is repeated. The deck is
rebuilt (reshuffled) only when empty, or when the eligible pool itself changes.
"""
candidates = sorted(n for n in available_numbers if minimum <= n <= maximum)
if not candidates:
return random.randint(minimum, maximum)
print(f"[RMStylesFull] WARNING: no styles in range {minimum}-{maximum}")
return None
effective_buffer = min(RANDOM_HISTORY_SIZE, len(candidates) - 1)
avoid = set(_random_history[-effective_buffer:]) if effective_buffer > 0 else set()
key = f"{styles_key}|{minimum}-{maximum}|{len(candidates)}"
deck = _decks.get(key) or []
good = [n for n in candidates if n not in avoid]
if not good:
good = candidates # All avoided, pick any
# Drop anything no longer valid (styles.json edited under us)
valid = set(candidates)
deck = [n for n in deck if n in valid]
result = random.choice(good)
if not deck:
deck = list(candidates)
_RNG.shuffle(deck)
# Cards are dealt from the END of the list, so the tail is what comes next.
# Push anything dealt in the closing hand of the previous deck deeper, or the
# deck boundary produces a near-immediate repeat (observed gap of 13).
recent = set(_recent.get(key, []))
cooldown = min(DECK_COOLDOWN, max(0, len(deck) // 3))
if recent and cooldown:
head, tail = deck[:-cooldown], deck[-cooldown:]
clashes = [n for n in tail if n in recent]
if clashes and head:
keep = [n for n in tail if n not in recent]
# swap each clashing card with a random one from deeper in the deck
for n in clashes:
i = _RNG.randrange(len(head))
keep.append(head[i])
head[i] = n
_RNG.shuffle(keep)
deck = head + keep
print(f"[RMStylesFull] Reshuffled deck: {len(deck)} styles in {minimum}-{maximum} "
f"(every style is used once before any repeats)")
_random_history.append(result)
if len(_random_history) > RANDOM_HISTORY_SIZE:
_random_history[:] = _random_history[-RANDOM_HISTORY_SIZE:]
result = deck.pop()
_decks[key] = deck
hist = _recent.get(key, [])
hist.append(result)
_recent[key] = hist[-DECK_COOLDOWN:]
_save_state()
print(f"[RMStylesFull] Deck: {len(deck)} of {len(candidates)} styles left before reshuffle")
return result
def _step_sequence(mode, current, minimum, maximum, available_numbers):
"""Next existing style number in the given direction, wrapping at the range ends.
Steps to the next number that ACTUALLY EXISTS rather than snapping to the nearest,
so a gap in the numbering can't make the same style repeat.
"""
candidates = sorted(n for n in available_numbers if minimum <= n <= maximum)
if not candidates:
return None
if mode == "Increment":
nxt = [n for n in candidates if n > current]
return nxt[0] if nxt else candidates[0]
if mode == "Decrement":
prv = [n for n in candidates if n < current]
return prv[-1] if prv else candidates[-1]
return current
# Root used to resolve relative custom paths, so they don't depend on the CWD
# ComfyUI happened to be launched from.
try:
import folder_paths
BASE_DIR = Path(folder_paths.base_path)
except Exception:
BASE_DIR = Path(__file__).parent.parent
def _normalize_styles_path(raw):
"""Turn a user-entered styles path into a usable Path, or None for 'use default'.
The value comes from a free-text widget, so it arrives in whatever form the user
pasted: Windows Explorer's "Copy as path" wraps it in double quotes, and paths may
use ~, environment variables, or be relative to the ComfyUI root.
"""
if not raw:
return None
path = str(raw).strip()
# "Copy as path" on Windows yields "C:\..." — the quotes are part of the string
if len(path) >= 2 and path[0] == path[-1] and path[0] in ('"', "'"):
path = path[1:-1].strip()
if not path:
return None
path = os.path.expandvars(os.path.expanduser(path))
resolved = Path(path)
if not resolved.is_absolute():
resolved = BASE_DIR / resolved
return resolved
class RMStylesFull:
"""
A ComfyUI node that loads style/prompt information from a JSON file.
@@ -85,7 +243,7 @@ class RMStylesFull:
def _load_styles(self, custom_path=None) -> dict:
"""Load styles from JSON file with caching."""
try:
styles_file = Path(custom_path) if custom_path else self.STYLES_FILE
styles_file = _normalize_styles_path(custom_path) or self.STYLES_FILE
# Invalidate cache if file path changed
if self._styles_path != str(styles_file):
@@ -95,6 +253,8 @@ class RMStylesFull:
if not styles_file.exists():
print(f"[RMStylesFull] Styles file not found: {styles_file}")
if custom_path and str(custom_path).strip() != str(styles_file):
print(f"[RMStylesFull] (from custom_json_path: {custom_path!r})")
return {}
current_mtime = styles_file.stat().st_mtime
@@ -114,7 +274,7 @@ class RMStylesFull:
return self._styles_cache
except Exception as e:
print(f"[RMStylesFull] Error loading styles: {e}")
print(f"[RMStylesFull] Error loading styles from {self._styles_path}: {e}")
return {}
@classmethod
@@ -156,6 +316,30 @@ class RMStylesFull:
"tooltip": "Path to a custom styles JSON file. Leave empty to use the default styles.json in the node pack."
}),
},
"optional": {
# The text widgets are hidden by JS whenever their mode is Off, so the node
# is only two lines taller than before until an override is actually used.
"positive_mode": (OVERRIDE_MODES, {
"default": "Off",
"tooltip": "Off: use the style's positive as-is.\n"
"Prepend/Append: add your text around it.\n"
"Replace: ignore the style library entirely and use only your text."
}),
"positive_text": ("STRING", {
"default": "", "multiline": True,
"tooltip": "Text applied to the positive prompt, per the mode above."
}),
"negative_mode": (OVERRIDE_MODES, {
"default": "Off",
"tooltip": "Off: use the style's negative as-is.\n"
"Prepend/Append: add your text around it.\n"
"Replace: use only your text for the negative."
}),
"negative_text": ("STRING", {
"default": "", "multiline": True,
"tooltip": "Text applied to the negative prompt, per the mode above."
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
@@ -190,26 +374,88 @@ class RMStylesFull:
return ", ".join(formatted)
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, custom_json_path: str = "", unique_id=None):
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int,
maximum: int, use_prefix: bool = False, custom_json_path: str = "",
positive_mode: str = "Off", positive_text: str = "",
negative_mode: str = "Off", negative_text: str = "", unique_id=None):
"""Main node execution function."""
empty_result = ("", "", "", "", "", "", 0, "")
custom_path = custom_json_path.strip() if custom_json_path else None
styles = self._load_styles(custom_path)
# Positive set to Replace means the library is bypassed completely: no style is
# looked up, so the deck must NOT advance and there is no prompt number to report.
# Number 0 marks "not from the library" - the JS greys out the selection widgets so
# this is visible on the node rather than something you infer from the output.
if positive_mode == "Replace":
positive = (positive_text or "").strip()
# With no style selected there is no base text, so Prepend/Append/Replace all
# collapse to "use what was typed". Off means the negative stays empty.
negative = _apply_override("", negative_text, negative_mode)
print("\n" + "=" * 60)
print("[RMStylesFull] FREE PROMPT (positive=Replace) - style library bypassed")
if not positive:
print(" WARNING: positive is Replace but the text box is empty - the")
print(" sampler will get an empty prompt and produce noise.")
else:
print(f" Positive: {positive[:100]}{'...' if len(positive) > 100 else ''}")
print("=" * 60)
positive, negative, motion, policy = content_policy.enforce(0, positive, negative, "")
self._last_policy = policy
return (positive, negative, motion, "", "", "", 0, "")
styles = self._load_styles(custom_json_path)
if not styles:
return empty_result
# Random mode: Python picks the number, ignoring next_prompt
available = sorted(styles.keys())
state_key = str(unique_id) if unique_id is not None else "default"
if mode == "Random":
selected_number = _get_random_prompt(minimum, maximum, list(styles.keys()))
else:
# Python owns Random entirely; next_prompt is ignored.
selected_number = _get_random_prompt(
minimum, maximum, available, styles_key=str(self._styles_path)
)
if selected_number is None:
return empty_result
elif mode in ("Increment", "Decrement"):
# Python owns the counter. It used to live in the browser
# (js/rm_styles_full.js), which meant it froze or skipped whenever the
# browser wasn't driving: batch-queueing several prompts snapshots the same
# next_prompt, and API-queued runs (Cloud Run controller, auto-retry) never
# run the JS at all. Observed: 329,328,327,327,323,323,323,323,321,321,320.
# Distinguish "the user typed a new start value" from "the widget is frozen
# because nothing updated it". Compare against the widget value we saw LAST
# time, not against the value we predicted: a frozen widget also differs
# from the prediction, which would pin the sequence in place forever.
st = _sequence_state.get(state_key)
if st is None or st.get("widget") != next_prompt:
selected_number = next_prompt # first run, or a deliberate edit
else:
selected_number = st.get("next") # widget stale -> we own the counter
if selected_number is None or selected_number not in styles:
selected_number = _step_sequence(
mode, selected_number if selected_number is not None else next_prompt,
minimum, maximum, available
)
if selected_number is None:
return empty_result
_sequence_state[state_key] = {
"widget": next_prompt,
"next": _step_sequence(mode, selected_number, minimum, maximum, available),
}
else: # Manual
selected_number = next_prompt
if selected_number not in styles:
available = sorted(styles.keys())
if available:
selected_number = min(available, key=lambda x: abs(x - selected_number))
print(f"[RMStylesFull] Prompt #{next_prompt} not found, using closest: #{selected_number}")
closest = min(available, key=lambda x: abs(x - selected_number))
print(f"[RMStylesFull] Prompt #{selected_number} not found, using closest: #{closest}")
selected_number = closest
else:
return empty_result
@@ -226,10 +472,36 @@ class RMStylesFull:
print(f" Positive: {positive[:100]}{'...' if len(positive) > 100 else ''}")
print(f"============================================================")
motion = style.get('motion', '')
# Overrides are applied BEFORE the policy gate, deliberately. The text boxes are a
# new way for words to reach the sampler, so they have to be inside the guarded
# region - gating only the library's text would leave the boxes as an open door.
positive = _apply_override(positive, positive_text, positive_mode)
negative = _apply_override(negative, negative_text, negative_mode)
if positive_mode != "Off" or negative_mode != "Off":
print(f"[RMStylesFull] override: positive={positive_mode} negative={negative_mode}")
# Content policy gate. Placed HERE, not on styles.json, because this is the only
# point every prompt must pass: custom_json_path lets a workflow load any file,
# and styles.json can be hand-edited. Blocked prompts are swapped for a neutral
# placeholder so an unattended batch keeps running. See nodes/content_policy.py.
positive, negative, motion, _policy = content_policy.enforce(
selected_number, positive, negative, motion
)
image_loras = style.get('imageLoras', [])
motion_loras_high = style.get('motionLorasHigh', [])
motion_loras_low = style.get('motionLorasLow', [])
# A blocked prompt must not keep its LoRAs - a character or style LoRA can carry
# the very thing the prompt was blocked for, regardless of the placeholder text.
if _policy == 'blocked':
image_loras, motion_loras_high, motion_loras_low = [], [], []
# Stashed for RMStylesPipe to append to the pipe, so the save node can mark
# the output. Set on self and read immediately by the subclass in the same
# call - never read across executions.
self._last_policy = _policy
# Apply prefixes if enabled
if use_prefix and self.POSITIVE_PREFIX:
positive = f"{self.POSITIVE_PREFIX} {positive}" if positive else self.POSITIVE_PREFIX
@@ -240,11 +512,13 @@ class RMStylesFull:
motion_loras_high_str = self._format_loras(motion_loras_high)
motion_loras_low_str = self._format_loras(motion_loras_low)
# Send actual used number back to JS for widget update
# Send the used number AND the authoritative next value back to the UI. The JS
# no longer computes the next value itself - if it did, it would double-step.
if unique_id is not None:
PromptServer.instance.send_sync("rm_styles_executed", {
"node_id": str(unique_id),
"prompt_number": selected_number,
"next_prompt": (_sequence_state.get(state_key) or {}).get("next"),
})
return (
@@ -302,7 +576,10 @@ class RMStylesFullDisplay(RMStylesFull):
}
}
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, custom_json_path: str = "", unique_id=None):
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int,
maximum: int, use_prefix: bool = False, custom_json_path: str = "",
positive_mode: str = "Off", positive_text: str = "",
negative_mode: str = "Off", negative_text: str = "", unique_id=None):
"""Same as parent but also returns UI data to display on the node."""
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix, custom_json_path=custom_json_path, unique_id=unique_id)
@@ -331,11 +608,12 @@ def setup_styles_api():
default_styles_file = data_dir / "styles.json"
def _resolve_styles_file(request):
"""Get the styles file path, using custom path from query param if provided."""
custom = request.query.get('path', '').strip()
if custom:
return Path(custom)
return default_styles_file
"""Get the styles file path, using custom path from query param if provided.
Uses the same normalization as the node loader so the editor always targets
the file the node will actually read.
"""
return _normalize_styles_path(request.query.get('path', '')) or default_styles_file
def _ensure_parent_dir(file_path):
"""Create parent directory if it doesn't exist."""
+53 -5
View File
@@ -5,7 +5,7 @@ RM Styles Pipe - Style pipe input/output nodes.
import os
import glob
import time
from .styles_full import RMStylesFull
from .styles_full import RMStylesFull, OVERRIDE_MODES
from .progress_reporter import send_progress, ensure_preview_dir, PREVIEW_DIR
@@ -31,12 +31,36 @@ class RMStylesPipe(RMStylesFull):
"maximum": ("INT", {"default": 100, "min": 1, "max": 9999}),
"custom_json_path": ("STRING", {"default": ""}),
},
# Mirrors RMStylesFull. This class defines INPUT_TYPES from scratch rather than
# extending the parent's, so the override widgets have to be repeated here or
# the pipe node - the one actually used in workflows - would not show them.
"optional": {
"positive_mode": (OVERRIDE_MODES, {
"default": "Off",
"tooltip": "Off: use the style's positive as-is.\n"
"Prepend/Append: add your text around it.\n"
"Replace: ignore the style library entirely and use only your text."
}),
"positive_text": ("STRING", {"default": "", "multiline": True,
"tooltip": "Text applied to the positive prompt, per the mode above."}),
"negative_mode": (OVERRIDE_MODES, {
"default": "Off",
"tooltip": "Off: use the style's negative as-is.\n"
"Prepend/Append: add your text around it.\n"
"Replace: use only your text for the negative."
}),
"negative_text": ("STRING", {"default": "", "multiline": True,
"tooltip": "Text applied to the negative prompt, per the mode above."}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, custom_json_path: str = "", unique_id=None):
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int,
maximum: int, custom_json_path: str = "",
positive_mode: str = "Off", positive_text: str = "",
negative_mode: str = "Off", negative_text: str = "", unique_id=None):
"""Load style and return as a pipe tuple (without prefixes)."""
# --- Progress Start: clear previews and notify viewer ---
try:
@@ -54,7 +78,20 @@ class RMStylesPipe(RMStylesFull):
except Exception as e:
print(f"[RMProgress] Error sending workflow_start: {e}")
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix=False, custom_json_path=custom_json_path, unique_id=unique_id)
result = super().load_style(
mode, previous_prompt, next_prompt, minimum, maximum, use_prefix=False,
custom_json_path=custom_json_path,
positive_mode=positive_mode, positive_text=positive_text,
negative_mode=negative_mode, negative_text=negative_text,
unique_id=unique_id)
# Append the content-policy outcome as element 8. Appending rather than inserting
# keeps every existing index valid, so RMStylesPipeOut and any workflow reading
# styles_pipe[0..7] is unaffected. Consumers must length-check before reading it.
# RM_STYLES_PIPE is now:
# 0 Positive 1 Negative 2 Motion 3 ImageLoras 4 MotionHigh
# 5 MotionLow 6 Number 7 Name 8 PolicyStatus ('clean'|'sanitized'|'blocked')
result = tuple(result) + (getattr(self, "_last_policy", "clean"),)
return (result,)
@@ -99,8 +136,19 @@ class RMStylesPipeOut:
if styles_pipe is None:
return ("", "", "", "", "", "", 0, "")
# Unpack the tuple
positive, negative, motion, image_loras, motion_high, motion_low, prompt_num, prompt_name = styles_pipe
# Unpack the first 8 slots and IGNORE anything beyond them.
#
# A fixed-arity unpack here breaks the moment the pipe grows: element 8 (the
# content-policy status) was appended on the assumption that leaving indices 0-7
# untouched was enough, and it is not - `a, b, ... = seq` fails on extra elements
# regardless of what those elements are. Slicing makes this node indifferent to
# pipe length, so the next field added upstream cannot break it again.
if len(styles_pipe) < 8:
raise ValueError(
"RM_STYLES_PIPE has %d elements, expected at least 8. "
"Reconnect the RM Styles Pipe node." % len(styles_pipe)
)
positive, negative, motion, image_loras, motion_high, motion_low, prompt_num, prompt_name = styles_pipe[:8]
# Apply PDXL prefixes if requested
if toggle_pony_tags:
+58
View File
@@ -0,0 +1,58 @@
@echo off
rem ---------------------------------------------------------------------------
rem Double-click launcher for csv_to_styles.py
rem
rem Editors such as Cursor and VS Code claim the .py extension via the per-user
rem UserChoice registry key, so double-clicking a .py file opens the editor
rem instead of running it. .cmd is always executed by the shell, so this gets
rem the converter running without changing any file associations.
rem
rem Drag a CSV onto this file to preselect it, or just double-click and pick
rem both files from the dialogs.
rem ---------------------------------------------------------------------------
setlocal
title Convert Styles CSV
set "SCRIPT=%~dp0csv_to_styles.py"
if not exist "%SCRIPT%" goto :nofile
rem Prefer the py launcher; fall back to python on PATH.
where py >nul 2>&1
if %errorlevel%==0 (
py "%SCRIPT%" %*
goto :done
)
where python >nul 2>&1
if %errorlevel%==0 (
python "%SCRIPT%" %*
goto :done
)
goto :nopython
:nofile
echo.
echo Could not find csv_to_styles.py next to this launcher.
echo Expected it at:
echo %SCRIPT%
echo.
echo Keep this .cmd file in the same folder as the script.
echo.
pause
exit /b 1
:nopython
echo.
echo Python was not found.
echo.
echo Neither "py" nor "python" is on your PATH. Install Python 3 from
echo https://www.python.org/downloads/ and tick "Add python.exe to PATH".
echo.
pause
exit /b 1
:done
endlocal
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python
"""
csv_to_styles.py - Convert a styles CSV into the JSON format the RM Styles nodes read.
Run with no arguments to get file-picker dialogs for the input CSV and the output
JSON location:
python tools/csv_to_styles.py
Or pass paths directly:
python tools/csv_to_styles.py "C:\\...\\styles\\StylesSFW.csv" "C:\\...\\my_styles.json"
The converter is built around the real defects found in the ComfyUI styles CSVs on
this machine, not a hypothetical clean file. It repairs:
* Encoding - files saved as cp1252/latin-1 that fail to decode as UTF-8
(Styles.csv has a 0xa0 non-breaking space at byte 45146)
* BOM - leading UTF-8 byte-order mark
* Headers - Number/Name/Positive/Negative/Lora, the A1111
name/prompt/negative_prompt shape, and a trailing comma in the
header row producing a nameless column
* Lora syntax - "<lora:Raven.safetensors:1)" (closing paren instead of >),
space-separated instead of comma-separated entries, leading-dot
weights (".8"), stray trailing commas
* Text - non-breaking spaces, control characters, embedded newlines,
doubled commas, leading/trailing whitespace and commas
* Numbering - blank, non-numeric, zero, negative and duplicate style numbers
(the node silently drops number <= 0 and lets a duplicate
overwrite the earlier style)
* Junk rows - fully empty rows and placeholder rows such as "999,,,,,"
Every style is emitted with the full field set the node expects, so no output is
ever missing a key.
"""
import argparse
import json
import os
import re
import sys
import unicodedata
from pathlib import Path
# Encodings tried in order. utf-8-sig first so a BOM is consumed rather than
# becoming part of the first header name.
ENCODINGS = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
# Header aliases -> canonical field. Compared lowercased with punctuation stripped.
HEADER_ALIASES = {
"number": "number", "num": "number", "#": "number", "id": "number", "index": "number",
"name": "name", "title": "name", "style": "name", "stylename": "name",
"positive": "positive", "prompt": "positive", "positiveprompt": "positive",
"pos": "positive", "text": "positive",
"negative": "negative", "negativeprompt": "negative", "neg": "negative",
"motion": "motion", "motionprompt": "motion", "video": "motion",
"lora": "imageLoras", "loras": "imageLoras", "imagelora": "imageLoras",
"imageloras": "imageLoras", "pdxllora": "imageLoras", "pdxlloras": "imageLoras",
"motionlora": "motionLoras", "motionloras": "motionLoras",
"motionlorahigh": "motionLorasHigh", "motionlorashigh": "motionLorasHigh",
"motionhigh": "motionLorasHigh",
"motionloralow": "motionLorasLow", "motionloraslow": "motionLorasLow",
"motionlow": "motionLorasLow",
}
TEXT_FIELDS = ("name", "positive", "negative", "motion")
LORA_FIELDS = ("imageLoras", "motionLoras", "motionLorasHigh", "motionLorasLow")
# Tolerant lora matcher. Accepts >, ), ] or end-of-string as the terminator, any
# amount of internal whitespace, and weights written as "1", "0.8" or ".8".
LORA_RE = re.compile(
r"<\s*lora\s*:\s*([^:<>)\]]+?)\s*:\s*([0-9]*\.?[0-9]+)\s*(?:[>)\]]|$)",
re.IGNORECASE,
)
class Report:
"""Collects what was repaired so the run can be summarised honestly."""
def __init__(self):
self.counts = {}
self.notes = []
def fix(self, label, n=1):
if n:
self.counts[label] = self.counts.get(label, 0) + n
def note(self, msg):
self.notes.append(msg)
def read_csv_text(path, forced_encoding=None, report=None):
"""Read the CSV as text, finding an encoding that actually decodes it."""
raw = Path(path).read_bytes()
if forced_encoding:
return raw.decode(forced_encoding), forced_encoding
for enc in ENCODINGS:
try:
text = raw.decode(enc)
except UnicodeDecodeError:
continue
if report and enc not in ("utf-8", "utf-8-sig"):
report.fix(f"decoded as {enc} (not valid UTF-8)")
if report and enc == "utf-8-sig" and raw[:3] == b"\xef\xbb\xbf":
report.fix("stripped UTF-8 BOM")
return text, enc
# latin-1 maps every byte, so this is effectively unreachable
return raw.decode("utf-8", errors="replace"), "utf-8 (with replacements)"
def normalise_key(key):
"""Reduce a header cell to a comparable token."""
if key is None:
return ""
return re.sub(r"[^a-z0-9#]", "", key.strip().lower())
def map_headers(fieldnames, report):
"""Map the CSV's headers onto canonical field names."""
mapping = {}
unknown = []
for raw in fieldnames or []:
key = normalise_key(raw)
if not key:
# trailing comma in the header row yields a nameless column
if raw is not None:
report.fix("ignored nameless trailing column")
continue
canonical = HEADER_ALIASES.get(key)
if canonical:
# first header wins if a file repeats a column
mapping.setdefault(raw, canonical)
else:
unknown.append(raw)
if unknown:
report.note(f"Ignored unrecognised column(s): {', '.join(repr(u) for u in unknown)}")
return mapping
def clean_text(value, report=None):
"""Normalise a free-text prompt cell into something safe for the node."""
if value is None:
return ""
original = value
text = unicodedata.normalize("NFC", str(value))
if "\xa0" in text:
if report:
report.fix("replaced non-breaking space")
text = text.replace("\xa0", " ")
# Embedded newlines and control characters survive CSV quoting but make the
# prompt unreadable in the editor and mangle the node's console preview.
if any(ch == "\n" or ch == "\r" for ch in text):
if report:
report.fix("flattened embedded newline")
text = re.sub(r"[\r\n\t]+", " ", text)
stripped = "".join(ch for ch in text if ch >= " " or ch == " ")
if stripped != text and report:
report.fix("removed control character")
text = stripped
text = re.sub(r"[ ]{2,}", " ", text)
# ",," and stray edge commas are harmless to JSON but produce empty prompt
# tokens that some samplers weight oddly.
collapsed = re.sub(r"\s*,(\s*,)+", ",", text)
if collapsed != text and report:
report.fix("collapsed doubled comma")
text = collapsed
text = text.strip().strip(",").strip()
if text != original and report is not None and text == original.strip():
report.fix("trimmed whitespace")
return text
def parse_loras(value, report=None):
"""Parse a lora cell into [{'path': ..., 'weight': ...}], repairing bad syntax."""
if not value:
return []
text = clean_text(value)
if not text:
return []
loras = []
matches = list(LORA_RE.finditer(text))
if matches:
# Flag the malformed spellings we actually repaired
for m in matches:
terminator = m.group(0)[-1]
if terminator in ")]":
if report:
report.fix("repaired lora closing bracket")
elif terminator not in ">":
if report:
report.fix("repaired unterminated lora tag")
if len(matches) > 1:
between = text[matches[0].end():matches[1].start()]
if between.strip() == "" and report:
report.fix("split space-separated lora tags")
for m in matches:
path = m.group(1).strip().strip(",").strip()
if not path:
continue
try:
weight = float(m.group(2))
except ValueError:
weight = 1.0
if report:
report.fix("defaulted unparseable lora weight to 1.0")
loras.append({"path": path, "weight": round(weight, 4)})
return loras
# No <lora:...> syntax at all - treat as bare comma-separated filenames
for part in text.split(","):
part = part.strip()
if not part:
continue
loras.append({"path": part, "weight": 1.0})
if loras and report:
report.fix("converted bare lora name(s) to tagged form")
return loras
def row_is_empty(style):
"""A row with no prompt, no name and no loras carries no information."""
if any(style[f] for f in TEXT_FIELDS):
return False
if any(style[f] for f in LORA_FIELDS):
return False
return True
def convert(csv_path, forced_encoding=None, start_number=1):
"""Convert a CSV file into the node's styles structure. Returns (data, report)."""
import csv as csv_module
report = Report()
text, encoding = read_csv_text(csv_path, forced_encoding, report)
report.note(f"Read {csv_path} as {encoding}")
reader = csv_module.DictReader(text.splitlines(True))
header_map = map_headers(reader.fieldnames, report)
if "positive" not in header_map.values():
raise SystemExit(
f"Could not find a prompt column in {csv_path}.\n"
f" Headers found: {reader.fieldnames}\n"
f" Expected one of: Positive / Prompt / Positive Prompt / Text"
)
parsed = []
dropped_empty = 0
for raw_row in reader:
style = {
"number": None, "name": "", "positive": "", "negative": "", "motion": "",
"imageLoras": [], "motionLoras": [], "motionLorasHigh": [], "motionLorasLow": [],
}
raw_number = None
for column, canonical in header_map.items():
cell = raw_row.get(column)
if canonical == "number":
raw_number = cell
elif canonical in LORA_FIELDS:
style[canonical] = parse_loras(cell, report)
else:
style[canonical] = clean_text(cell, report)
if row_is_empty(style):
dropped_empty += 1
continue
style["number"] = raw_number
parsed.append(style)
if dropped_empty:
report.fix("dropped empty/placeholder row", dropped_empty)
# --- numbering -------------------------------------------------------
# Keep valid, unique, positive numbers; assign fresh ones to everything else.
taken = set()
needs_number = []
for style in parsed:
raw = (style["number"] or "").strip() if isinstance(style["number"], str) else style["number"]
value = None
if raw not in (None, ""):
try:
value = int(float(raw))
except (TypeError, ValueError):
report.fix("replaced non-numeric style number")
value = None
if value is None:
needs_number.append(style)
continue
if value < 1:
# the node skips any style with number <= 0
report.fix("renumbered style with number <= 0")
needs_number.append(style)
continue
if value in taken:
# the node keys styles by number, so a duplicate silently wins
report.fix("renumbered duplicate style number")
needs_number.append(style)
continue
style["number"] = value
taken.add(value)
next_free = max(start_number, 1)
for style in needs_number:
while next_free in taken:
next_free += 1
style["number"] = next_free
taken.add(next_free)
parsed.sort(key=lambda s: s["number"])
data = {"styles": [
{
"number": s["number"],
"name": s["name"],
"positive": s["positive"],
"negative": s["negative"],
"motion": s["motion"],
"imageLoras": s["imageLoras"],
"motionLoras": s["motionLoras"],
"motionLorasHigh": s["motionLorasHigh"],
"motionLorasLow": s["motionLorasLow"],
}
for s in parsed
]}
return data, report
def verify(json_path):
"""Re-read the written file exactly the way the node does, and report the count."""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
loadable = {}
for style in data.get("styles", []):
num = style.get("number")
if num and num > 0:
loadable[num] = style
return loadable
# --- file pickers --------------------------------------------------------
# Set when a dialog or console prompt was used, so the launcher knows to hold the
# window open afterwards (a dropped CSV still needs a Save As dialog).
USED_DIALOG = False
def pick_files(csv_path, json_path):
"""Fill in whichever paths weren't given, using dialogs when available."""
global USED_DIALOG
if csv_path and json_path:
return csv_path, json_path
USED_DIALOG = True
try:
import tkinter as tk
from tkinter import filedialog
except Exception:
# No GUI available - fall back to prompting on the console
if not csv_path:
csv_path = input("Path to source CSV: ").strip().strip('"').strip("'")
if not json_path:
json_path = input("Path for output JSON: ").strip().strip('"').strip("'")
return csv_path, json_path
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
# tools/ -> ComfyUI-RMAutomation -> custom_nodes -> ComfyUI root
try:
default_dir = str(Path(__file__).resolve().parents[3] / "styles")
except IndexError:
default_dir = ""
if not default_dir or not Path(default_dir).is_dir():
default_dir = os.getcwd()
if not csv_path:
csv_path = filedialog.askopenfilename(
title="Select the styles CSV to convert",
initialdir=default_dir,
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
)
if not csv_path:
root.destroy()
raise SystemExit("Cancelled - no input file selected.")
if not json_path:
suggested = Path(csv_path).stem + ".json"
json_path = filedialog.asksaveasfilename(
title="Save the converted styles JSON as",
initialdir=str(Path(csv_path).parent),
initialfile=suggested,
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
)
if not json_path:
root.destroy()
raise SystemExit("Cancelled - no output file selected.")
root.destroy()
return csv_path, json_path
def main():
parser = argparse.ArgumentParser(
description="Convert a styles CSV into RM Styles JSON.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Run with no arguments to pick both files with a dialog.",
)
parser.add_argument("csv", nargs="?", help="source CSV (prompts if omitted)")
parser.add_argument("json", nargs="?", help="destination JSON (prompts if omitted)")
parser.add_argument("--encoding", help="force a source encoding instead of detecting")
parser.add_argument("--start-number", type=int, default=1,
help="first number to hand out when renumbering (default 1)")
parser.add_argument("--dry-run", action="store_true",
help="report what would change without writing the file")
args = parser.parse_args()
csv_path, json_path = pick_files(args.csv, args.json)
csv_path = os.path.expandvars(os.path.expanduser(str(csv_path).strip().strip('"').strip("'")))
json_path = os.path.expandvars(os.path.expanduser(str(json_path).strip().strip('"').strip("'")))
if not Path(csv_path).is_file():
raise SystemExit(f"Input file not found: {csv_path}")
data, report = convert(csv_path, args.encoding, args.start_number)
print()
print("=" * 68)
print(f" {Path(csv_path).name} -> {Path(json_path).name}")
print("=" * 68)
for note in report.notes:
print(f" {note}")
print()
if report.counts:
print(" Repairs applied:")
for label, count in sorted(report.counts.items(), key=lambda kv: -kv[1]):
print(f" {count:>5} {label}")
else:
print(" No repairs needed.")
print()
styles = data["styles"]
with_loras = sum(1 for s in styles if s["imageLoras"])
with_names = sum(1 for s in styles if s["name"])
numbers = [s["number"] for s in styles]
print(f" Styles converted : {len(styles)}")
if numbers:
print(f" Number range : {min(numbers)}-{max(numbers)}")
print(f" With a name : {with_names}")
print(f" With image loras : {with_loras}")
print()
if args.dry_run:
print(" --dry-run: nothing written.")
return
out = Path(json_path)
os.makedirs(out.parent, exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
loadable = verify(out)
print(f" Written to {out}")
print(f" Verified: the node will load {len(loadable)} of {len(styles)} styles.")
if len(loadable) != len(styles):
missing = [s["number"] for s in styles if s["number"] not in loadable]
print(f" WARNING: unreadable style numbers: {missing[:20]}")
sys.exit(1)
print()
print(" Point a RM Styles node's custom_json_path at that file.")
print()
def run():
"""Entry point that survives being double-clicked.
Launched from Explorer the console closes the moment the process exits, which
would take the repair summary - or a traceback - with it. Hold the window open
whenever the run was interactive: no arguments at all, or any run that had to
ask for a path (dragging a CSV onto the launcher still needs a Save As dialog).
"""
interactive = len(sys.argv) == 1
code = 0
try:
main()
except SystemExit as exc:
# argparse and this script's own error paths both exit this way; print
# the message here so it isn't swallowed along with the window.
if isinstance(exc.code, str):
print(f"\n{exc.code}")
code = 1
elif exc.code:
code = exc.code
except KeyboardInterrupt:
print("\nCancelled.")
code = 1
except Exception:
import traceback
traceback.print_exc()
code = 1
if interactive or USED_DIALOG:
try:
input("Press Enter to close this window...")
except EOFError:
pass
sys.exit(code)
if __name__ == "__main__":
run()