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:
co-authored by
Claude Opus 5
parent
975bd4958c
commit
2fa4d5e9a3
@@ -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** |
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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]}}
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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">×</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 = "×";
|
||||
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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
@@ -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)",
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user