fix(resolver): read NaN as null in the scan's legacy-parser fallback (#304)
ComfyUI stamps "is_changed": [NaN] on nodes that always re-run, the PromptManager node among them. The legacy gallery and admin pages replaced NaN with null before JSON.parse, so they traced these files' prompts. The scan's fallback parsed records strictly, so every NaN-stamped file the resolver could not decide (hires-fix and refiner chains, custom latent nodes) fell to "Needs review" although legacy linked its prompt. parse_page_json parses as the legacy pages did, but only the NaN number reads as null: the word inside prompt text is kept. Infinity still makes the record unreadable, as it did in the pages.
This commit is contained in:
+7
-4
@@ -41,7 +41,7 @@ try:
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
parse_page_json,
|
||||
)
|
||||
from ...utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
except ImportError:
|
||||
@@ -51,7 +51,7 @@ except ImportError:
|
||||
PLACEHOLDERS,
|
||||
GenerationParamsError,
|
||||
extract_generation_params,
|
||||
parse_js_json,
|
||||
parse_page_json,
|
||||
)
|
||||
from utils.prompt_pair_resolver import resolve_prompt_pair
|
||||
|
||||
@@ -1603,11 +1603,14 @@ class AdminRoutesMixin:
|
||||
|
||||
@staticmethod
|
||||
def _graph_prompt_text(metadata):
|
||||
"""The positive prompt the browser parser traces from the graphs, or None."""
|
||||
"""The positive prompt the browser parser traces from the graphs, or None.
|
||||
|
||||
Records parse as the legacy pages parsed them (NaN reads as null).
|
||||
"""
|
||||
records = {}
|
||||
for key in ("prompt", "workflow"):
|
||||
value = metadata.get(key)
|
||||
records[key] = parse_js_json(value) if isinstance(value, str) else value
|
||||
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
||||
try:
|
||||
positive = extract_generation_params(records)["positivePrompt"]
|
||||
except GenerationParamsError:
|
||||
|
||||
@@ -5,7 +5,10 @@ returns for the same input (tests/test_generation_params_js_parity.py checks
|
||||
that directly when Node is available).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404 - runs a fixed Node one-liner
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
@@ -17,6 +20,7 @@ from utils.generation_params import ( # noqa: E402
|
||||
extract_generation_params,
|
||||
extract_from_records,
|
||||
parse_js_json,
|
||||
parse_page_json,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -219,6 +223,47 @@ class ParsesLikeJavaScript(unittest.TestCase):
|
||||
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
|
||||
)
|
||||
|
||||
def test_page_parse_reads_nan_numbers_as_null(self):
|
||||
# gallery.js and admin.js replace NaN with null before JSON.parse.
|
||||
self.assertEqual(
|
||||
parse_page_json('{"a": NaN, "b": [NaN], "t": "NaN"}'),
|
||||
{"a": None, "b": [None], "t": "NaN"},
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_page_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
|
||||
)
|
||||
for text in ('{"a": Infinity}', "[-Infinity]", "not json", "", None):
|
||||
with self.subTest(text=text):
|
||||
self.assertIsNone(parse_page_json(text))
|
||||
|
||||
@unittest.skipUnless(shutil.which("node"), "Node is not installed")
|
||||
def test_page_parse_equals_the_legacy_pages_for_nan_numbers(self):
|
||||
# gallery.js/admin.js: two NaN regexes, then JSON.parse (Infinity throws).
|
||||
texts = [
|
||||
'{"1": {"is_changed": [NaN]}, "2": {"is_changed": NaN, "v": 1.5}}',
|
||||
'{"a":NaN,"b":[NaN, 2]}',
|
||||
'{"a": Infinity}',
|
||||
'{"seed": 9007199254740993}',
|
||||
]
|
||||
script = (
|
||||
"const texts = JSON.parse(require('fs').readFileSync(0, 'utf8'));"
|
||||
"console.log(JSON.stringify(texts.map((t) => { try { return {v: JSON.parse("
|
||||
"t.replace(/:\\s*NaN\\b/g, ': null').replace(/\\bNaN\\b/g, 'null'))}; }"
|
||||
" catch (e) { return null; } })));"
|
||||
)
|
||||
completed = subprocess.run( # nosec B603 B607 - fixed argv, no shell
|
||||
["node", "-e", script],
|
||||
input=json.dumps(texts),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=60,
|
||||
)
|
||||
expected = json.loads(completed.stdout)
|
||||
for text, page in zip(texts, expected):
|
||||
with self.subTest(text=text):
|
||||
self.assertEqual(parse_page_json(text), page and page["v"])
|
||||
|
||||
def test_duplicate_keys_keep_the_last_value(self):
|
||||
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
|
||||
|
||||
|
||||
@@ -165,6 +165,122 @@ class TestPairScan(AdminAPITestCase):
|
||||
)
|
||||
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
|
||||
|
||||
def _nan_scanned(self, name, prompt_graph):
|
||||
"""Scan ``prompt_graph`` as ComfyUI writes it: ``is_changed`` is NaN."""
|
||||
stamped = {
|
||||
node_id: {**node, "is_changed": [float("nan")]}
|
||||
for node_id, node in prompt_graph.items()
|
||||
}
|
||||
text = json.dumps(stamped)
|
||||
self.assertIn("NaN", text)
|
||||
return self._scanned_text(name, text)
|
||||
|
||||
def _scanned_text(self, name, prompt_text):
|
||||
meta = {"prompt": prompt_text}
|
||||
return self.api._ingest_scanned_file(self.output_dir / name, meta)
|
||||
|
||||
@staticmethod
|
||||
def _hires_fix_graph(positive):
|
||||
"""A PromptManager prompt on a base sampler and a hires-fix sampler.
|
||||
|
||||
The base latent comes from a custom batch node and the hires pass from
|
||||
a custom image filter: no sampler branch traces, so the scan links the
|
||||
prompt the legacy pages show.
|
||||
"""
|
||||
return {
|
||||
"1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "m.safetensors"},
|
||||
},
|
||||
"2": {
|
||||
"class_type": "MyTriggerWords",
|
||||
"inputs": {"trigger_words": ["1", 2]},
|
||||
},
|
||||
"3": {
|
||||
"class_type": "PromptManager",
|
||||
"inputs": {
|
||||
"text": positive,
|
||||
"prepend_text": ["2", 0],
|
||||
"append_text": "",
|
||||
"clip": ["1", 1],
|
||||
},
|
||||
},
|
||||
"4": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {"text": "blur", "clip": ["1", 1]},
|
||||
},
|
||||
"5": {"class_type": "MyLatentBatch", "inputs": {"batch_size": 6}},
|
||||
"6": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 7,
|
||||
"model": ["1", 0],
|
||||
"positive": ["3", 0],
|
||||
"negative": ["4", 0],
|
||||
"latent_image": ["5", 0],
|
||||
},
|
||||
},
|
||||
"7": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["6", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"8": {"class_type": "MyImageFilter", "inputs": {"images": ["7", 0]}},
|
||||
"9": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["8", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"14": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"seed": 7,
|
||||
"denoise": 0.25,
|
||||
"model": ["1", 0],
|
||||
"positive": ["3", 0],
|
||||
"negative": ["4", 0],
|
||||
"latent_image": ["9", 0],
|
||||
},
|
||||
},
|
||||
"15": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["14", 0], "vae": ["1", 2]},
|
||||
},
|
||||
"16": {"class_type": "SaveImage", "inputs": {"images": ["15", 0]}},
|
||||
}
|
||||
|
||||
def test_hires_fix_prompt_links_with_or_without_nan_is_changed(self):
|
||||
# Legacy parity: the legacy pages read NaN as null before parsing, so they
|
||||
# showed this prompt; the scan left NaN-stamped files in review.
|
||||
for name, scan in (("plain.png", None), ("stamped.png", "nan")):
|
||||
with self.subTest(name=name):
|
||||
g = self._hires_fix_graph("cat on a sofa")
|
||||
if scan:
|
||||
status = self._nan_scanned(name, g)
|
||||
else:
|
||||
status = self._scanned(name, g)
|
||||
self.assertIn(status, ("added", "linked"))
|
||||
self.assertEqual(self._texts(), ["cat on a sofa"])
|
||||
|
||||
def test_untraced_single_sampler_links_the_legacy_prompt_with_nan(self):
|
||||
g = self._hires_fix_graph("cat on a sofa")
|
||||
for node_id in ("7", "8", "9", "14", "15"):
|
||||
del g[node_id]
|
||||
g["16"]["inputs"]["images"] = ["6", 0]
|
||||
self.assertEqual(self._nan_scanned("single.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["cat on a sofa"])
|
||||
|
||||
def test_nan_inside_prompt_text_is_kept(self):
|
||||
# Only the NaN number reads as null; legacy's regex also rewrote text.
|
||||
g = self._hires_fix_graph("NaN cat, value: NaN")
|
||||
self.assertEqual(self._nan_scanned("text.png", g), "added")
|
||||
self.assertEqual(self._texts(), ["NaN cat, value: NaN"])
|
||||
|
||||
def test_infinity_is_still_unreadable_like_the_legacy_pages(self):
|
||||
text = json.dumps(self._hires_fix_graph("cat")).replace(
|
||||
'"seed": 7', '"seed": Infinity'
|
||||
)
|
||||
self.assertIn(self._scanned_text("inf.png", text), ("ambiguous", "unresolved"))
|
||||
self.assertEqual(self._texts(), [])
|
||||
|
||||
def test_raw_png_workflow_is_stored_as_object_and_repeat_is_unchanged(self):
|
||||
path = self.output_dir / "workflow.png"
|
||||
workflow = {"nodes": [{"id": 1, "type": "PromptManagerPair"}]}
|
||||
|
||||
@@ -28,6 +28,7 @@ __all__ = [
|
||||
"extract_generation_params",
|
||||
"extract_from_records",
|
||||
"parse_js_json",
|
||||
"parse_page_json",
|
||||
]
|
||||
|
||||
PLACEHOLDERS = MappingProxyType(
|
||||
@@ -953,6 +954,30 @@ def parse_js_json(text):
|
||||
return None
|
||||
|
||||
|
||||
def _nan_as_null(name):
|
||||
"""The legacy pages' NaN cleaning; Infinity still throws in ``JSON.parse``."""
|
||||
if name == "NaN":
|
||||
return None
|
||||
raise ValueError(f"{name} is not JSON")
|
||||
|
||||
|
||||
def parse_page_json(text):
|
||||
"""A metadata record as the legacy gallery and admin pages parsed it.
|
||||
|
||||
They replace ``NaN`` with ``null`` and then ``JSON.parse`` (ComfyUI writes
|
||||
``"is_changed": [NaN]`` for nodes that always re-run, PromptManager among
|
||||
them). Only the NaN number reads as null here: the pages' regex also
|
||||
rewrote the word inside strings, which changed prompt text. None where the
|
||||
parse throws.
|
||||
"""
|
||||
if not isinstance(text, str):
|
||||
return None
|
||||
try:
|
||||
return json.loads(text, parse_constant=_nan_as_null, parse_int=_double_integer)
|
||||
except (ValueError, RecursionError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_from_records(prompt_text, workflow_text):
|
||||
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user