WIP: snapshot before clean ComfyUI rebuild

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

Claude-Session: https://claude.ai/code/session_012uM7Ru1JYn48n6c5Rzwd99
This commit is contained in:
Moser9815
2026-08-24 13:49:00 -04:00
co-authored by Claude Opus 5
parent 975bd4958c
commit 2fa4d5e9a3
24 changed files with 3578 additions and 169 deletions
+58
View File
@@ -0,0 +1,58 @@
@echo off
rem ---------------------------------------------------------------------------
rem Double-click launcher for csv_to_styles.py
rem
rem Editors such as Cursor and VS Code claim the .py extension via the per-user
rem UserChoice registry key, so double-clicking a .py file opens the editor
rem instead of running it. .cmd is always executed by the shell, so this gets
rem the converter running without changing any file associations.
rem
rem Drag a CSV onto this file to preselect it, or just double-click and pick
rem both files from the dialogs.
rem ---------------------------------------------------------------------------
setlocal
title Convert Styles CSV
set "SCRIPT=%~dp0csv_to_styles.py"
if not exist "%SCRIPT%" goto :nofile
rem Prefer the py launcher; fall back to python on PATH.
where py >nul 2>&1
if %errorlevel%==0 (
py "%SCRIPT%" %*
goto :done
)
where python >nul 2>&1
if %errorlevel%==0 (
python "%SCRIPT%" %*
goto :done
)
goto :nopython
:nofile
echo.
echo Could not find csv_to_styles.py next to this launcher.
echo Expected it at:
echo %SCRIPT%
echo.
echo Keep this .cmd file in the same folder as the script.
echo.
pause
exit /b 1
:nopython
echo.
echo Python was not found.
echo.
echo Neither "py" nor "python" is on your PATH. Install Python 3 from
echo https://www.python.org/downloads/ and tick "Add python.exe to PATH".
echo.
pause
exit /b 1
:done
endlocal
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env python
"""
csv_to_styles.py - Convert a styles CSV into the JSON format the RM Styles nodes read.
Run with no arguments to get file-picker dialogs for the input CSV and the output
JSON location:
python tools/csv_to_styles.py
Or pass paths directly:
python tools/csv_to_styles.py "C:\\...\\styles\\StylesSFW.csv" "C:\\...\\my_styles.json"
The converter is built around the real defects found in the ComfyUI styles CSVs on
this machine, not a hypothetical clean file. It repairs:
* Encoding - files saved as cp1252/latin-1 that fail to decode as UTF-8
(Styles.csv has a 0xa0 non-breaking space at byte 45146)
* BOM - leading UTF-8 byte-order mark
* Headers - Number/Name/Positive/Negative/Lora, the A1111
name/prompt/negative_prompt shape, and a trailing comma in the
header row producing a nameless column
* Lora syntax - "<lora:Raven.safetensors:1)" (closing paren instead of >),
space-separated instead of comma-separated entries, leading-dot
weights (".8"), stray trailing commas
* Text - non-breaking spaces, control characters, embedded newlines,
doubled commas, leading/trailing whitespace and commas
* Numbering - blank, non-numeric, zero, negative and duplicate style numbers
(the node silently drops number <= 0 and lets a duplicate
overwrite the earlier style)
* Junk rows - fully empty rows and placeholder rows such as "999,,,,,"
Every style is emitted with the full field set the node expects, so no output is
ever missing a key.
"""
import argparse
import json
import os
import re
import sys
import unicodedata
from pathlib import Path
# Encodings tried in order. utf-8-sig first so a BOM is consumed rather than
# becoming part of the first header name.
ENCODINGS = ("utf-8-sig", "utf-8", "cp1252", "latin-1")
# Header aliases -> canonical field. Compared lowercased with punctuation stripped.
HEADER_ALIASES = {
"number": "number", "num": "number", "#": "number", "id": "number", "index": "number",
"name": "name", "title": "name", "style": "name", "stylename": "name",
"positive": "positive", "prompt": "positive", "positiveprompt": "positive",
"pos": "positive", "text": "positive",
"negative": "negative", "negativeprompt": "negative", "neg": "negative",
"motion": "motion", "motionprompt": "motion", "video": "motion",
"lora": "imageLoras", "loras": "imageLoras", "imagelora": "imageLoras",
"imageloras": "imageLoras", "pdxllora": "imageLoras", "pdxlloras": "imageLoras",
"motionlora": "motionLoras", "motionloras": "motionLoras",
"motionlorahigh": "motionLorasHigh", "motionlorashigh": "motionLorasHigh",
"motionhigh": "motionLorasHigh",
"motionloralow": "motionLorasLow", "motionloraslow": "motionLorasLow",
"motionlow": "motionLorasLow",
}
TEXT_FIELDS = ("name", "positive", "negative", "motion")
LORA_FIELDS = ("imageLoras", "motionLoras", "motionLorasHigh", "motionLorasLow")
# Tolerant lora matcher. Accepts >, ), ] or end-of-string as the terminator, any
# amount of internal whitespace, and weights written as "1", "0.8" or ".8".
LORA_RE = re.compile(
r"<\s*lora\s*:\s*([^:<>)\]]+?)\s*:\s*([0-9]*\.?[0-9]+)\s*(?:[>)\]]|$)",
re.IGNORECASE,
)
class Report:
"""Collects what was repaired so the run can be summarised honestly."""
def __init__(self):
self.counts = {}
self.notes = []
def fix(self, label, n=1):
if n:
self.counts[label] = self.counts.get(label, 0) + n
def note(self, msg):
self.notes.append(msg)
def read_csv_text(path, forced_encoding=None, report=None):
"""Read the CSV as text, finding an encoding that actually decodes it."""
raw = Path(path).read_bytes()
if forced_encoding:
return raw.decode(forced_encoding), forced_encoding
for enc in ENCODINGS:
try:
text = raw.decode(enc)
except UnicodeDecodeError:
continue
if report and enc not in ("utf-8", "utf-8-sig"):
report.fix(f"decoded as {enc} (not valid UTF-8)")
if report and enc == "utf-8-sig" and raw[:3] == b"\xef\xbb\xbf":
report.fix("stripped UTF-8 BOM")
return text, enc
# latin-1 maps every byte, so this is effectively unreachable
return raw.decode("utf-8", errors="replace"), "utf-8 (with replacements)"
def normalise_key(key):
"""Reduce a header cell to a comparable token."""
if key is None:
return ""
return re.sub(r"[^a-z0-9#]", "", key.strip().lower())
def map_headers(fieldnames, report):
"""Map the CSV's headers onto canonical field names."""
mapping = {}
unknown = []
for raw in fieldnames or []:
key = normalise_key(raw)
if not key:
# trailing comma in the header row yields a nameless column
if raw is not None:
report.fix("ignored nameless trailing column")
continue
canonical = HEADER_ALIASES.get(key)
if canonical:
# first header wins if a file repeats a column
mapping.setdefault(raw, canonical)
else:
unknown.append(raw)
if unknown:
report.note(f"Ignored unrecognised column(s): {', '.join(repr(u) for u in unknown)}")
return mapping
def clean_text(value, report=None):
"""Normalise a free-text prompt cell into something safe for the node."""
if value is None:
return ""
original = value
text = unicodedata.normalize("NFC", str(value))
if "\xa0" in text:
if report:
report.fix("replaced non-breaking space")
text = text.replace("\xa0", " ")
# Embedded newlines and control characters survive CSV quoting but make the
# prompt unreadable in the editor and mangle the node's console preview.
if any(ch == "\n" or ch == "\r" for ch in text):
if report:
report.fix("flattened embedded newline")
text = re.sub(r"[\r\n\t]+", " ", text)
stripped = "".join(ch for ch in text if ch >= " " or ch == " ")
if stripped != text and report:
report.fix("removed control character")
text = stripped
text = re.sub(r"[ ]{2,}", " ", text)
# ",," and stray edge commas are harmless to JSON but produce empty prompt
# tokens that some samplers weight oddly.
collapsed = re.sub(r"\s*,(\s*,)+", ",", text)
if collapsed != text and report:
report.fix("collapsed doubled comma")
text = collapsed
text = text.strip().strip(",").strip()
if text != original and report is not None and text == original.strip():
report.fix("trimmed whitespace")
return text
def parse_loras(value, report=None):
"""Parse a lora cell into [{'path': ..., 'weight': ...}], repairing bad syntax."""
if not value:
return []
text = clean_text(value)
if not text:
return []
loras = []
matches = list(LORA_RE.finditer(text))
if matches:
# Flag the malformed spellings we actually repaired
for m in matches:
terminator = m.group(0)[-1]
if terminator in ")]":
if report:
report.fix("repaired lora closing bracket")
elif terminator not in ">":
if report:
report.fix("repaired unterminated lora tag")
if len(matches) > 1:
between = text[matches[0].end():matches[1].start()]
if between.strip() == "" and report:
report.fix("split space-separated lora tags")
for m in matches:
path = m.group(1).strip().strip(",").strip()
if not path:
continue
try:
weight = float(m.group(2))
except ValueError:
weight = 1.0
if report:
report.fix("defaulted unparseable lora weight to 1.0")
loras.append({"path": path, "weight": round(weight, 4)})
return loras
# No <lora:...> syntax at all - treat as bare comma-separated filenames
for part in text.split(","):
part = part.strip()
if not part:
continue
loras.append({"path": part, "weight": 1.0})
if loras and report:
report.fix("converted bare lora name(s) to tagged form")
return loras
def row_is_empty(style):
"""A row with no prompt, no name and no loras carries no information."""
if any(style[f] for f in TEXT_FIELDS):
return False
if any(style[f] for f in LORA_FIELDS):
return False
return True
def convert(csv_path, forced_encoding=None, start_number=1):
"""Convert a CSV file into the node's styles structure. Returns (data, report)."""
import csv as csv_module
report = Report()
text, encoding = read_csv_text(csv_path, forced_encoding, report)
report.note(f"Read {csv_path} as {encoding}")
reader = csv_module.DictReader(text.splitlines(True))
header_map = map_headers(reader.fieldnames, report)
if "positive" not in header_map.values():
raise SystemExit(
f"Could not find a prompt column in {csv_path}.\n"
f" Headers found: {reader.fieldnames}\n"
f" Expected one of: Positive / Prompt / Positive Prompt / Text"
)
parsed = []
dropped_empty = 0
for raw_row in reader:
style = {
"number": None, "name": "", "positive": "", "negative": "", "motion": "",
"imageLoras": [], "motionLoras": [], "motionLorasHigh": [], "motionLorasLow": [],
}
raw_number = None
for column, canonical in header_map.items():
cell = raw_row.get(column)
if canonical == "number":
raw_number = cell
elif canonical in LORA_FIELDS:
style[canonical] = parse_loras(cell, report)
else:
style[canonical] = clean_text(cell, report)
if row_is_empty(style):
dropped_empty += 1
continue
style["number"] = raw_number
parsed.append(style)
if dropped_empty:
report.fix("dropped empty/placeholder row", dropped_empty)
# --- numbering -------------------------------------------------------
# Keep valid, unique, positive numbers; assign fresh ones to everything else.
taken = set()
needs_number = []
for style in parsed:
raw = (style["number"] or "").strip() if isinstance(style["number"], str) else style["number"]
value = None
if raw not in (None, ""):
try:
value = int(float(raw))
except (TypeError, ValueError):
report.fix("replaced non-numeric style number")
value = None
if value is None:
needs_number.append(style)
continue
if value < 1:
# the node skips any style with number <= 0
report.fix("renumbered style with number <= 0")
needs_number.append(style)
continue
if value in taken:
# the node keys styles by number, so a duplicate silently wins
report.fix("renumbered duplicate style number")
needs_number.append(style)
continue
style["number"] = value
taken.add(value)
next_free = max(start_number, 1)
for style in needs_number:
while next_free in taken:
next_free += 1
style["number"] = next_free
taken.add(next_free)
parsed.sort(key=lambda s: s["number"])
data = {"styles": [
{
"number": s["number"],
"name": s["name"],
"positive": s["positive"],
"negative": s["negative"],
"motion": s["motion"],
"imageLoras": s["imageLoras"],
"motionLoras": s["motionLoras"],
"motionLorasHigh": s["motionLorasHigh"],
"motionLorasLow": s["motionLorasLow"],
}
for s in parsed
]}
return data, report
def verify(json_path):
"""Re-read the written file exactly the way the node does, and report the count."""
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
loadable = {}
for style in data.get("styles", []):
num = style.get("number")
if num and num > 0:
loadable[num] = style
return loadable
# --- file pickers --------------------------------------------------------
# Set when a dialog or console prompt was used, so the launcher knows to hold the
# window open afterwards (a dropped CSV still needs a Save As dialog).
USED_DIALOG = False
def pick_files(csv_path, json_path):
"""Fill in whichever paths weren't given, using dialogs when available."""
global USED_DIALOG
if csv_path and json_path:
return csv_path, json_path
USED_DIALOG = True
try:
import tkinter as tk
from tkinter import filedialog
except Exception:
# No GUI available - fall back to prompting on the console
if not csv_path:
csv_path = input("Path to source CSV: ").strip().strip('"').strip("'")
if not json_path:
json_path = input("Path for output JSON: ").strip().strip('"').strip("'")
return csv_path, json_path
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
# tools/ -> ComfyUI-RMAutomation -> custom_nodes -> ComfyUI root
try:
default_dir = str(Path(__file__).resolve().parents[3] / "styles")
except IndexError:
default_dir = ""
if not default_dir or not Path(default_dir).is_dir():
default_dir = os.getcwd()
if not csv_path:
csv_path = filedialog.askopenfilename(
title="Select the styles CSV to convert",
initialdir=default_dir,
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
)
if not csv_path:
root.destroy()
raise SystemExit("Cancelled - no input file selected.")
if not json_path:
suggested = Path(csv_path).stem + ".json"
json_path = filedialog.asksaveasfilename(
title="Save the converted styles JSON as",
initialdir=str(Path(csv_path).parent),
initialfile=suggested,
defaultextension=".json",
filetypes=[("JSON files", "*.json"), ("All files", "*.*")],
)
if not json_path:
root.destroy()
raise SystemExit("Cancelled - no output file selected.")
root.destroy()
return csv_path, json_path
def main():
parser = argparse.ArgumentParser(
description="Convert a styles CSV into RM Styles JSON.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Run with no arguments to pick both files with a dialog.",
)
parser.add_argument("csv", nargs="?", help="source CSV (prompts if omitted)")
parser.add_argument("json", nargs="?", help="destination JSON (prompts if omitted)")
parser.add_argument("--encoding", help="force a source encoding instead of detecting")
parser.add_argument("--start-number", type=int, default=1,
help="first number to hand out when renumbering (default 1)")
parser.add_argument("--dry-run", action="store_true",
help="report what would change without writing the file")
args = parser.parse_args()
csv_path, json_path = pick_files(args.csv, args.json)
csv_path = os.path.expandvars(os.path.expanduser(str(csv_path).strip().strip('"').strip("'")))
json_path = os.path.expandvars(os.path.expanduser(str(json_path).strip().strip('"').strip("'")))
if not Path(csv_path).is_file():
raise SystemExit(f"Input file not found: {csv_path}")
data, report = convert(csv_path, args.encoding, args.start_number)
print()
print("=" * 68)
print(f" {Path(csv_path).name} -> {Path(json_path).name}")
print("=" * 68)
for note in report.notes:
print(f" {note}")
print()
if report.counts:
print(" Repairs applied:")
for label, count in sorted(report.counts.items(), key=lambda kv: -kv[1]):
print(f" {count:>5} {label}")
else:
print(" No repairs needed.")
print()
styles = data["styles"]
with_loras = sum(1 for s in styles if s["imageLoras"])
with_names = sum(1 for s in styles if s["name"])
numbers = [s["number"] for s in styles]
print(f" Styles converted : {len(styles)}")
if numbers:
print(f" Number range : {min(numbers)}-{max(numbers)}")
print(f" With a name : {with_names}")
print(f" With image loras : {with_loras}")
print()
if args.dry_run:
print(" --dry-run: nothing written.")
return
out = Path(json_path)
os.makedirs(out.parent, exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
loadable = verify(out)
print(f" Written to {out}")
print(f" Verified: the node will load {len(loadable)} of {len(styles)} styles.")
if len(loadable) != len(styles):
missing = [s["number"] for s in styles if s["number"] not in loadable]
print(f" WARNING: unreadable style numbers: {missing[:20]}")
sys.exit(1)
print()
print(" Point a RM Styles node's custom_json_path at that file.")
print()
def run():
"""Entry point that survives being double-clicked.
Launched from Explorer the console closes the moment the process exits, which
would take the repair summary - or a traceback - with it. Hold the window open
whenever the run was interactive: no arguments at all, or any run that had to
ask for a path (dragging a CSV onto the launcher still needs a Save As dialog).
"""
interactive = len(sys.argv) == 1
code = 0
try:
main()
except SystemExit as exc:
# argparse and this script's own error paths both exit this way; print
# the message here so it isn't swallowed along with the window.
if isinstance(exc.code, str):
print(f"\n{exc.code}")
code = 1
elif exc.code:
code = exc.code
except KeyboardInterrupt:
print("\nCancelled.")
code = 1
except Exception:
import traceback
traceback.print_exc()
code = 1
if interactive or USED_DIALOG:
try:
input("Press Enter to close this window...")
except EOFError:
pass
sys.exit(code)
if __name__ == "__main__":
run()