From 2fa4d5e9a39e3fb91c76ffbedc378c48ca20bcd7 Mon Sep 17 00:00:00 2001 From: Moser9815 Date: Mon, 24 Aug 2026 13:49:00 -0400 Subject: [PATCH] WIP: snapshot before clean ComfyUI rebuild Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uM7Ru1JYn48n6c5Rzwd99 --- .claude/PLAN-styles-selection.md | 83 +++++ BUGS.md | 96 ++++++ README.md | 63 +++- __init__.py | 16 + data/.selection_state.json | 1 + data/blocked.log | 26 ++ data/content_policy.json.migrated | 274 +++++++++++++++ data/policy/allowed_phrases.txt | 35 ++ data/policy/blocked_terms.txt | 119 +++++++ data/policy/context_terms.txt | 116 +++++++ js/rm_content_policy.js | 449 ++++++++++++++++++++++++ js/rm_styles_full.js | 75 ++-- js/rm_styles_overrides.js | 114 +++++++ nodes/content_policy.py | 302 +++++++++++++++++ nodes/lineage.py | 175 ++++++++++ nodes/load_image_tracked.py | 102 ++++++ nodes/policy_api.py | 182 ++++++++++ nodes/power_lora_loader.py | 122 +++---- nodes/save_image_with_metadata.py | 151 ++++++++- nodes/save_lineage.py | 236 +++++++++++++ nodes/styles_full.py | 348 +++++++++++++++++-- nodes/styles_pipe.py | 58 +++- tools/Convert Styles CSV.cmd | 58 ++++ tools/csv_to_styles.py | 546 ++++++++++++++++++++++++++++++ 24 files changed, 3578 insertions(+), 169 deletions(-) create mode 100644 .claude/PLAN-styles-selection.md create mode 100644 BUGS.md create mode 100644 data/.selection_state.json create mode 100644 data/blocked.log create mode 100644 data/content_policy.json.migrated create mode 100644 data/policy/allowed_phrases.txt create mode 100644 data/policy/blocked_terms.txt create mode 100644 data/policy/context_terms.txt create mode 100644 js/rm_content_policy.js create mode 100644 js/rm_styles_overrides.js create mode 100644 nodes/content_policy.py create mode 100644 nodes/lineage.py create mode 100644 nodes/load_image_tracked.py create mode 100644 nodes/policy_api.py create mode 100644 nodes/save_lineage.py create mode 100644 tools/Convert Styles CSV.cmd create mode 100644 tools/csv_to_styles.py diff --git a/.claude/PLAN-styles-selection.md b/.claude/PLAN-styles-selection.md new file mode 100644 index 0000000..2b7f211 --- /dev/null +++ b/.claude/PLAN-styles-selection.md @@ -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** | diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 0000000..8812f1c --- /dev/null +++ b/BUGS.md @@ -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. diff --git a/README.md b/README.md index a461db1..c8d65e3 100644 --- a/README.md +++ b/README.md @@ -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 +(` { + if (e.target === this.backdrop) this._close(); + }); + + this.backdrop.innerHTML = ` +
+
+
+ Content Policy + Applied to every prompt before it reaches the sampler + +
+
+
+
+
+
+
+
+
+
+
Test a prompt
+ +
Type above to preview the outcome.
+
+
+
+ + + +
+
`; + + 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}${(this.lists[t.key] || []).length}`; + 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 = `
Nothing here yet. Add an entry above.
`; + 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 = ` +
+ ${label} + score ${d.score} / threshold ${d.threshold} +
+
${escapeHtml(why)}${this.dirty ? " — against saved rules; save to test your edits" : ""}
+ ${showOut ? `
${d.status === "blocked" ? "Replaced with" : "Generates from"}
+
${escapeHtml(d.result)}
` : ""}`; + } 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()); + }; + }, +}); diff --git a/js/rm_styles_full.js b/js/rm_styles_full.js index 8415458..443d422 100644 --- a/js/rm_styles_full.js +++ b/js/rm_styles_full.js @@ -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) => { diff --git a/js/rm_styles_overrides.js b/js/rm_styles_overrides.js new file mode 100644 index 0000000..8aa0e21 --- /dev/null +++ b/js/rm_styles_overrides.js @@ -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; + }; + }, +}); diff --git a/nodes/content_policy.py b/nodes/content_policy.py new file mode 100644 index 0000000..7475cd4 --- /dev/null +++ b/nodes/content_policy.py @@ -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"(?/". [] = 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 "/" 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 "/" 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 / 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 "/" 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)) diff --git a/nodes/load_image_tracked.py b/nodes/load_image_tracked.py new file mode 100644 index 0000000..f95b6c9 --- /dev/null +++ b/nodes/load_image_tracked.py @@ -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)", +} diff --git a/nodes/policy_api.py b/nodes/policy_api.py new file mode 100644 index 0000000..4627e52 --- /dev/null +++ b/nodes/policy_api.py @@ -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) diff --git a/nodes/power_lora_loader.py b/nodes/power_lora_loader.py index 60c17f0..7db9050 100644 --- a/nodes/power_lora_loader.py +++ b/nodes/power_lora_loader.py @@ -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", } diff --git a/nodes/save_image_with_metadata.py b/nodes/save_image_with_metadata.py index ec2738d..48f58f9 100644 --- a/nodes/save_image_with_metadata.py +++ b/nodes/save_image_with_metadata.py @@ -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 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 = { diff --git a/nodes/save_lineage.py b/nodes/save_lineage.py new file mode 100644 index 0000000..13a1a93 --- /dev/null +++ b/nodes/save_lineage.py @@ -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)", +} diff --git a/nodes/styles_full.py b/nodes/styles_full.py index e6a1563..68bd66e 100644 --- a/nodes/styles_full.py +++ b/nodes/styles_full.py @@ -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.""" diff --git a/nodes/styles_pipe.py b/nodes/styles_pipe.py index 06320e8..301d30b 100644 --- a/nodes/styles_pipe.py +++ b/nodes/styles_pipe.py @@ -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: diff --git a/tools/Convert Styles CSV.cmd b/tools/Convert Styles CSV.cmd new file mode 100644 index 0000000..b4eeac7 --- /dev/null +++ b/tools/Convert Styles CSV.cmd @@ -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 diff --git a/tools/csv_to_styles.py b/tools/csv_to_styles.py new file mode 100644 index 0000000..a679350 --- /dev/null +++ b/tools/csv_to_styles.py @@ -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 - "), + 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 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()