Reported on Discord 2026-09-21: "Standard Save Image Nodes from ComfyUI save the image files ending with a _ after the counter. With Save Image Pixaroma I can't get the _." He appends a description after the counter and wanted the same shape core produces. He was right, and the suggested %input% workaround does not help either. _sanitize_segment stripped underscores from BOTH edges, so `name_%counter%_` came out `name_00001` with no way to get the underscore back. Measured before the change: the pattern really did lose it. The strip is FRONT-ONLY now, and the asymmetry is the point. A trailing underscore is legal on every filesystem we support, so there was nothing being protected against - it was tidying that removed something deliberate. A LEADING one is different: it is nearly always the residue of a token that resolved to nothing (an unwired `%input%_%counter%` leaves `_%counter%`), so that strip stays. Two accepted consequences, both cosmetic and both stated in the code: a pasted Windows path now keeps the underscore its drive colon became (`C_/Users/x`, the colon is still neutralised so containment is untouched), and `%counter%_%input%` with nothing wired keeps its trailing underscore instead of tidying it away. The JS preview mirror moves with it. save-image.md #21/#22 are two review rounds of bugs caused by those two drifting, so there is now a parity harness that runs both implementations over one corpus: D:\Claude Tests\_save_prefix_parity.py, mutation-validated (reverting the JS half alone gives 8 mismatches). Its 4 known-different cases are listed rather than skipped. Verified live end to end: `name_%counter%_` writes name_00001_.png across a batch, a second run continues at 00003 through the new suffix regex, a folder segment keeps its underscore too, a leading one is still tidied, and the node's own "Will save as" line agrees with what lands on disk. Harnesses: save_prefix_check 42, _save_image_test 70, _save_image_webp_test 78, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
126 lines
5.9 KiB
Python
126 lines
5.9 KiB
Python
"""Check _safe_prefix (filename_prefix sanitizer) behavior.
|
|
|
|
Run: python scripts/save_prefix_check.py
|
|
Exits 0 on PREFIX PASS, 1 on any mismatch.
|
|
|
|
Locks in the June-2026 fix for the Korean filename_prefix report: the
|
|
sanitizer must only neutralize genuinely dangerous characters (Windows-
|
|
illegal chars, control chars, path traversal) and let every other
|
|
character through verbatim - non-Latin scripts, accented letters, and
|
|
spaces included - matching native ComfyUI SaveImage, which does no
|
|
character filtering at all (folder_paths.get_save_image_path only
|
|
enforces that the final path stays inside output/).
|
|
"""
|
|
import os
|
|
import sys
|
|
import time
|
|
|
|
# Import the helper directly off the nodes/ dir. Do NOT use `from nodes._save...`
|
|
# - `nodes` collides with ComfyUI's top-level nodes.py when it's on sys.path.
|
|
# _save_helpers.py has no relative imports, so loading it standalone is safe.
|
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "nodes"))
|
|
from _save_helpers import _safe_prefix # noqa: E402
|
|
|
|
_today = time.strftime("%Y-%m-%d")
|
|
_hour = time.strftime("%H") # 24-hour, zero-padded (what hh / HH must expand to)
|
|
|
|
# (input, expected) - expected None means "unrecoverable, caller falls back"
|
|
CASES = [
|
|
# ── The reported Korean cases (parity with native SaveImage) ──────────
|
|
("프로젝트/테스트/이미지", "프로젝트/테스트/이미지"),
|
|
("project/te 스 st/image", "project/te 스 st/image"),
|
|
|
|
# ── Unicode + spaces survive verbatim ─────────────────────────────────
|
|
("café/naïve", "café/naïve"),
|
|
("日本語/画像", "日本語/画像"),
|
|
("Bunny WithCubes - Copy.png", "Bunny WithCubes - Copy.png"),
|
|
("a b", "a b"), # interior spaces untouched
|
|
(" padded /img", "padded/img"), # segment-edge whitespace trimmed
|
|
|
|
# ── Windows-illegal characters are neutralized to '_' ─────────────────
|
|
('a<b>c:d"e|f?g*h', "a_b_c_d_e_f_g_h"),
|
|
("a\tb", "a_b"), # control chars too
|
|
# The drive colon becomes '_' and that '_' now SURVIVES, because the strip
|
|
# is front-only since 2026-09-22 (see _sanitize_segment). Containment is
|
|
# unchanged: the colon is still neutralized, so this stays a plain relative
|
|
# segment inside the save folder.
|
|
("C:\\Users\\x", "C_/Users/x"), # drive colon -> '_', \ -> /
|
|
|
|
# ── Windows reserved device names get a '_' suffix ────────────────────
|
|
("CON/img", "CON_/img"),
|
|
("nul.txt/img", "nul.txt_/img"),
|
|
("com1", "com1_"),
|
|
|
|
# ── Windows trailing-dot/space rule (OS strips them silently) ─────────
|
|
("test.../img", "test/img"),
|
|
("teasda......////", "teasda"), # docstring example, must keep working
|
|
|
|
# ── Unchanged ASCII behavior (regression guards) ──────────────────────
|
|
("img", "img"),
|
|
("SDXL/portrait", "SDXL/portrait"),
|
|
("a__b", "a_b"), # underscore collapse retained
|
|
("???", None), # nothing usable -> fallback
|
|
|
|
# ── Underscores: FRONT stripped, TRAILING kept (2026-09-22) ───────────
|
|
# Reported: "Save Image Pixaroma I can't get the _". ComfyUI's own
|
|
# SaveImage writes name_00001_.png and `name_%counter%_` silently lost the
|
|
# underscore, with no way to get it back. A leading one is nearly always
|
|
# the residue of a token that resolved to nothing, so that strip stays.
|
|
("_edge_", "edge_"),
|
|
("_lead", "lead"), # leading residue still tidied
|
|
("trail_", "trail_"), # THE REPORTED CASE
|
|
("name_%counter%_", "name_%counter%_"), # what the reporter types
|
|
("a/b_/c_", "a/b_/c_"), # per segment, not only the last
|
|
("_", None), # front strip leaves nothing
|
|
("__", None), # collapses to '_', then stripped
|
|
("x__", "x_"), # the run still collapses to one
|
|
("test._", "test._"), # legal: does not END in a dot
|
|
|
|
# ── Tokens ────────────────────────────────────────────────────────────
|
|
("%date:yyyy-MM-dd%/img", _today + "/img"),
|
|
("%year%-%month%/img", "%year%-%month%/img"), # native tokens preserved
|
|
# Hour token: native ComfyUI uses lowercase hh (24-hour). It must expand,
|
|
# not come out literal. HH is kept as a backward-compat alias.
|
|
("%date:hh%/x", _hour + "/x"),
|
|
("%date:HH%/x", _hour + "/x"),
|
|
("%date:yyyy-MM-dd hh%", _today + " " + _hour),
|
|
|
|
# ── Traversal / absolute / garbage rejection (must stay rejected) ─────
|
|
("../evil", None),
|
|
("a/../b", None),
|
|
("/absolute", None),
|
|
("", None),
|
|
(" ", None),
|
|
(None, None),
|
|
(123, None),
|
|
("a" * 300, None), # input length cap
|
|
]
|
|
|
|
|
|
def main():
|
|
failures = []
|
|
for raw, expected in CASES:
|
|
got = _safe_prefix(raw)
|
|
if got != expected:
|
|
failures.append((raw, expected, got))
|
|
|
|
# Output-length cap: 121-char input truncates to 100
|
|
long_in = "x" * 60 + "/" + "y" * 60
|
|
long_got = _safe_prefix(long_in)
|
|
if long_got is None or len(long_got) > 100 or not long_got.startswith("x" * 60 + "/"):
|
|
failures.append((long_in, "<=100 chars keeping x.../y...", long_got))
|
|
|
|
if failures:
|
|
print(f"PREFIX FAIL - {len(failures)} case(s):")
|
|
for raw, expected, got in failures:
|
|
# ascii() so a cp1252 Windows console can't choke on Korean output
|
|
print(f" input={ascii(raw)}\n expected={ascii(expected)}\n got ={ascii(got)}")
|
|
return 1
|
|
|
|
print(f"PREFIX PASS ({len(CASES) + 1} cases)")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|