fix(images): card facts read the inspector's projected graphs

The inspector hands its browser parser only graphs the inspection
projection parsed (strict JSON, depth 64, node ceiling); a deeper record
is "limited" there, so the legacy fill never runs and no seed shows. The
card parsed the raw records instead and showed a seed the inspector did
not, e.g. for a 900-deep class_type beside a normal sampler.

project_graph() is now the one projection step, used by the inspection
response and by the card. The card runs the shared parser on the
projected graphs, the same values the browser receives: big integers
arrive as their exact digits, so the separate exact-integer re-parse and
the parse_js_json flag it needed are gone.
This commit is contained in:
Vito Sansevero
2026-10-19 23:13:41 -07:00
parent 8d09b51e27
commit d565100284
5 changed files with 136 additions and 155 deletions
+8 -3
View File
@@ -629,14 +629,19 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertEqual(response.status, 200, data)
items = {item["filename"]: item for item in data["images"]}
self.assertEqual(items["ok.png"]["seed"], 5)
self.assertIsNone(items["evil.png"]["seed"])
self.assertIsNone(items["evil.png"]["linked"]["title"])
response = await self.client.get(
"/prompt_manager/images/inspect/evil.png",
params={"root": 0, "root_scope": items["evil.png"]["root_scope"]},
)
self.assertEqual(response.status, 200, await response.text())
self.assertIsNone((await response.json())["library"]["title"])
library = (await response.json())["library"]
self.assertEqual(library["title"], items["evil.png"]["linked"]["title"])
shown = (await self._inspector_seeds([items["evil.png"]]))["evil.png"]
card = items["evil.png"]["seed"]
if card is None:
self.assertNotEqual(shown["status"], "known")
else:
self.assertEqual(shown, {"status": "known", "value": str(card)})
async def test_library_previews_skip_card_facts(self):
records_png(self.output_dir / "a.png", seeded_records("a fox", 5))
+67 -69
View File
@@ -4,7 +4,6 @@ import json
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -17,8 +16,11 @@ from utils.card_seed import ( # noqa: E402
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
number_string,
parse_js_json,
extract_generation_params,
)
from utils.inspection_response import ( # noqa: E402
MAX_PROJECTION_DEPTH,
project_graph,
)
try:
@@ -96,74 +98,64 @@ class CardSeed(unittest.TestCase):
self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})}))
self.assertEqual(info_seed(graph(seeded(1))), 1)
def test_card_seed_deeply_nested_record_is_none(self):
# The Python parser recurses where JavaScript String() does not; a
# crafted record must give no card facts, never an exception.
self.assertIsNone(header_params({"workflow": nested_workflow()}))
self.assertIsNone(
header_params({**graph(seeded(1)), "workflow": nested_workflow()})
)
def test_exact_seed_reparse_that_recurses_keeps_the_parsed_seed(self):
with patch(
"utils.card_seed.extract_generation_params",
side_effect=RecursionError("deep"),
):
params = header_params(graph(seeded(2**60 + 1)))
self.assertEqual(card_seed(params), 1152921504606847000)
def test_one_json_reader_keeps_integers_exact_on_request(self):
text = '{"seed": 9007199254740993}'
self.assertEqual(parse_js_json(text), {"seed": 9007199254740992.0})
def test_records_past_the_inspection_projection_bound_give_nothing(self):
# The inspector hands the browser parser only graphs its projection
# parsed; a deeper graph is "limited" there, so the card skips it too.
self.assertEqual(
parse_js_json(text, exact_integers=True), {"seed": 9007199254740993}
project_graph(nested_workflow(), "workflow")["status"], "limited"
)
for bad in ('{"a": NaN}', "not json", "[" * 100000):
with self.subTest(text=bad[:12]):
self.assertIsNone(parse_js_json(bad, exact_integers=True))
self.assertEqual(number_string(float(2**60)), "1152921504606847000")
self.assertIsNone(header_params({"workflow": nested_workflow()}))
# A limited workflow beside a parsed prompt leaves the prompt's facts.
both = header_params({**graph(seeded(1)), "workflow": nested_workflow()})
self.assertEqual(card_seed(both), 1)
def test_depth_bound_is_the_inspectors(self):
def deep(levels):
nested = "[" * levels + "1" + "]" * levels
extra = '{"class_type":"Note","inputs":{"v":%s}}' % nested
text = graph(seeded(3))["prompt"]
return {"prompt": text[:-1] + ',"30":' + extra + "}"}
# root object, node object, inputs object: three levels before "v".
inside = MAX_PROJECTION_DEPTH - 3
self.assertEqual(info_seed(deep(inside)), 3)
self.assertIsNone(header_params(deep(inside + 1)))
def test_strict_projection_rules_apply(self):
duplicate = '{"10": {"class_type": "KSampler", "inputs": {}}, "10": {}}'
self.assertEqual(project_graph(duplicate, "prompt")["status"], "malformed")
self.assertIsNone(header_params({"prompt": duplicate}))
def test_record_size_is_utf8_bytes_at_both_bounds(self):
ascii_at = "x" * MAX_RECORD_BYTES
self.assertIsNotNone(header_params({"prompt": json.dumps(ascii_at)[1:-1]}))
four_byte = "\U0001f600" * (MAX_RECORD_BYTES // 4)
frame = '{"a":"%s"}'
ascii_at = frame % ("x" * (MAX_RECORD_BYTES - 8))
self.assertEqual(len(ascii_at), MAX_RECORD_BYTES)
self.assertIsNotNone(header_params({"prompt": ascii_at}))
four_byte = frame % ("\U0001f600" * ((MAX_RECORD_BYTES - 8) // 4))
self.assertLessEqual(len(four_byte.encode()), MAX_RECORD_BYTES)
self.assertIsNotNone(header_params({"prompt": four_byte}))
self.assertIsNone(header_params({"prompt": four_byte + "x"}))
padded = four_byte + " " * (MAX_RECORD_BYTES - len(four_byte.encode()) + 1)
self.assertIsNone(header_params({"prompt": padded}))
def test_placeholder_and_non_number_seeds_are_none(self):
for value in ("123", "randomize", True, 1.5, None):
def test_placeholder_and_non_integer_seeds_are_none(self):
for value in ("randomize", " 12", True, 1.5, 7.0, 1e21, None):
with self.subTest(seed=value):
self.assertIsNone(info_seed(graph(seeded(value))))
self.assertIsNone(card_seed({"seed": "Unknown"}))
self.assertIsNone(card_seed({"seed": float("inf")}))
self.assertIsNone(card_seed({"seed": 1e21}))
self.assertEqual(card_seed({"seed": -3}), -3)
self.assertEqual(card_seed({"seed": 7.0}), 7)
self.assertIsNone(card_seed({"seed": 7.0}))
self.assertEqual(info_seed(graph(seeded(-3))), -3)
def test_digit_string_seed_is_what_the_inspector_reads(self):
# The projection carries big integers as text and keeps text as text;
# the inspector shows a digit string as the seed, and so does the card.
self.assertEqual(info_seed(graph(seeded("123"))), 123)
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
# JSON.parse rounds it; the inspector shows the record's own digits.
# JSON.parse would round it; the projection carries the record's digits.
case = CASES["seed-beyond-double-precision"]
rounded = extract_from_records(case["prompt"], None)["seed"]
self.assertEqual(rounded, float(4974052037430000001))
self.assertEqual(info_seed({"prompt": case["prompt"]}), 4974052037430000001)
def test_big_seed_stays_rounded_when_the_exact_parse_disagrees(self):
# Only a seed that rounds to the parsed value replaces it; otherwise the
# card prints the double as the browser does (String(2**60)).
browser = 1152921504606847000
self.assertEqual(card_seed({"seed": float(2**60)}), browser)
with patch(
"utils.card_seed.extract_generation_params",
return_value={"seed": 2**61},
):
params = header_params(graph(seeded(2**60 + 1)))
self.assertEqual(card_seed(params), browser)
with patch(
"utils.card_seed.extract_generation_params",
side_effect=GenerationParamsError("throws"),
):
params = header_params(graph(seeded(2**60 + 1)))
self.assertEqual(card_seed(params), browser)
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), -(2**60) - 1)
def test_workflow_only_record(self):
@@ -191,24 +183,30 @@ class CardSeed(unittest.TestCase):
self.assertIsNone(card_seed(unparsable))
self.assertIsNone(parsed_positive(unparsable))
def test_every_shared_case_matches_the_parser(self):
def test_every_shared_case_reads_the_projected_graphs(self):
for name, case in CASES.items():
with self.subTest(case=name):
info = {k: v for k, v in case.items() if v is not None}
params = header_params(info)
if params is None:
graphs = {key: project_graph(text, key) for key, text in info.items()}
values = {
key: graph["value"]
for key, graph in graphs.items()
if graph["status"] == "parsed"
}
if not values:
self.assertIsNone(params)
continue
parsed = extract_from_records(case["prompt"], case["workflow"])
if name == "seed-beyond-double-precision":
parsed = {**parsed, "seed": 4974052037430000001}
self.assertEqual(params, parsed)
positive = params["positivePrompt"]
usable = (
isinstance(positive, str)
and positive != "No prompt found"
and positive.strip()
)
self.assertEqual(parsed_positive(params), positive if usable else None)
try:
expected = extract_generation_params(values)
except GenerationParamsError:
self.assertIsNone(params)
continue
self.assertEqual(params, expected)
# Prompt text is the same as from a plain parse of the records.
if len(values) == len(info):
raw = extract_from_records(case["prompt"], case["workflow"])
self.assertEqual(params["positivePrompt"], raw["positivePrompt"])
if __name__ == "__main__":
+34 -63
View File
@@ -1,24 +1,24 @@
"""Workspace card facts from the metadata records a header open already loaded.
The card shows what the inspector shows, so it reads the same shared parser
(``extract_generation_params``, the Python twin of the browser parser the
inspector fills from since #251) and nothing else: no separate seed heuristic,
no graph resolution and no extra file reads. One parse feeds both the seed and
the positive that confirms a first-line card title.
The card shows what the inspector shows, so it reads exactly what the
inspector's browser parser reads: each ``prompt``/``workflow`` record through
the inspection projection (``project_graph``: strict JSON, the depth and node
bounds, big integers and decimals as text), and only graphs it parsed, through
the shared parser (``extract_generation_params``, the Python twin of the
browser parser the inspector fills from since #251). No separate seed
heuristic, no graph resolution and no extra file reads. One parse feeds both
the seed and the positive that confirms a first-line card title.
"""
import math
import re
from collections.abc import Mapping
from .generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_from_records,
extract_generation_params,
number_string,
parse_js_json,
)
from .inspection_response import project_graph
__all__ = ["MAX_RECORD_BYTES", "card_seed", "header_params", "parsed_positive"]
@@ -26,7 +26,6 @@ __all__ = ["MAX_RECORD_BYTES", "card_seed", "header_params", "parsed_positive"]
MAX_RECORD_BYTES = 2 * 1024 * 1024
_RECORD_KEYS = ("prompt", "workflow")
_INTEGER = re.compile(r"-?[0-9]+\Z")
_MAX_SAFE_INTEGER = 2**53 - 1
def _utf8_size_over(text: str, limit: int) -> bool:
@@ -43,79 +42,51 @@ def _utf8_size_over(text: str, limit: int) -> bool:
def header_params(info) -> dict | None:
"""The shared parser's result for the ``prompt``/``workflow`` text in ``info``.
"""The shared parser's result for the inspector's view of ``info``'s records.
``info`` is a Pillow ``Image.info`` (or the inspection read's metadata).
None when neither record is present, a record is not text or is over
``MAX_RECORD_BYTES``, or the browser parser would throw on it.
A record the projection does not parse is left out, as the inspector
leaves it out. None when no record parses, a record is not text or is
over ``MAX_RECORD_BYTES``, or the parser cannot use the graphs.
"""
if not isinstance(info, Mapping):
return None
records = {key: info.get(key) for key in _RECORD_KEYS}
if all(text is None for text in records.values()):
return None
for text in records.values():
graphs = {}
for key in _RECORD_KEYS:
text = info.get(key)
if text is None:
continue
if not isinstance(text, str):
if not isinstance(text, str) or _utf8_size_over(text, MAX_RECORD_BYTES):
return None
if _utf8_size_over(text, MAX_RECORD_BYTES):
return None
try:
params = extract_from_records(records["prompt"], records["workflow"])
except (GenerationParamsError, RecursionError):
# RecursionError: the Python parser recurses on deeply nested arrays
# where JavaScript does not throw; a crafted file gives no card facts.
graph = project_graph(text, key)
if graph["status"] == "parsed":
graphs[key] = graph["value"]
if not graphs:
return None
return _with_exact_seed(params, records)
def _with_exact_seed(params: dict, records: dict) -> dict:
"""``params`` with a rounded seed replaced by the integer the file records.
``JSON.parse`` rounds integers beyond 2**53, but the inspector reads the
record's own digits (its projection carries big numbers as text). ComfyUI's
randomized seeds are mostly that large, so the same parser is run once more
on an exact parse of the same records, and its seed is kept only when it is
the one that rounded to the parsed value. Anything else leaves the seed
as parsed.
"""
seed = params.get("seed")
if not isinstance(seed, float) or not math.isfinite(seed):
return params
if abs(seed) <= _MAX_SAFE_INTEGER or not seed.is_integer():
return params
try:
exact = extract_generation_params(
{
key: parse_js_json(text, exact_integers=True)
for key, text in records.items()
if text is not None
}
)["seed"]
return extract_generation_params(graphs)
except (GenerationParamsError, RecursionError):
return params
if isinstance(exact, bool) or not isinstance(exact, int) or float(exact) != seed:
return params
return {**params, "seed": exact}
# Defence in depth: a parser failure gives no card facts, never a 500.
return None
def card_seed(params: dict | None) -> int | None:
"""The parsed seed as the integer the browser prints, else None.
"""The parsed seed as an integer, else None.
A placeholder, text, boolean or non-integral number gives None. A double
that ``header_params`` could not make exact gives the integer whose decimal
form equals the browser's ``String(seed)``.
The projection carries integers beyond 2**53 as their exact digits, so a
digit string is the recorded seed (the inspector shows it too). A
placeholder, other text, a boolean or a decimal gives None.
"""
if not isinstance(params, Mapping):
return None
value = params.get("seed")
if isinstance(value, bool) or not isinstance(value, (int, float)):
if isinstance(value, bool):
return None
if isinstance(value, float) and not math.isfinite(value):
return None
text = number_string(value)
return int(text) if _INTEGER.match(text) else None
if isinstance(value, int):
return value
if isinstance(value, str) and _INTEGER.match(value):
return int(value)
return None
def parsed_positive(params: dict | None) -> str | None:
+5 -12
View File
@@ -27,7 +27,6 @@ __all__ = [
"GenerationParamsError",
"extract_generation_params",
"extract_from_records",
"number_string",
"parse_js_json",
]
@@ -203,9 +202,6 @@ def _number_string(value):
return sign + body
number_string = _number_string
def _js_string(value):
"""``String(value)`` for any JSON-derived value."""
if value is UNDEFINED:
@@ -945,17 +941,14 @@ def _double_integer(text):
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
def parse_js_json(text, *, exact_integers=False):
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``).
``exact_integers`` keeps integers beyond 2**53 exact instead of rounding
them as JavaScript does; every other rule is unchanged.
"""
def parse_js_json(text):
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
if not isinstance(text, str):
return None
integers = int if exact_integers else _double_integer
try:
return json.loads(text, parse_constant=_reject_constant, parse_int=integers)
return json.loads(
text, parse_constant=_reject_constant, parse_int=_double_integer
)
except (ValueError, RecursionError):
return None
+22 -8
View File
@@ -161,6 +161,21 @@ def _graph(value: object, *, workflow: bool = False) -> dict:
return {"status": "parsed", "value": value}
def project_graph(text: str, kind: Literal["prompt", "workflow"]) -> dict:
"""One record's graph as the inspector projects it: ``{status, value}``.
``status`` is ``parsed`` (``value`` is the projected graph the browser
parser receives), ``limited`` (past the depth or node bound) or
``malformed``. Shared with the workspace card so both read one bound.
"""
try:
return _graph(_strict_projection(text, kind=kind), workflow=kind == "workflow")
except _ProjectionLimit:
return {"status": "limited", "value": None}
except (ValueError, TypeError, KeyError, OverflowError):
return {"status": "malformed", "value": None}
def _metadata(inspection: "InspectionRead | None") -> dict:
formats = (
{}
@@ -213,16 +228,15 @@ def _metadata(inspection: "InspectionRead | None") -> dict:
graph["status"] = failed or "limited"
elif formats.get(key) == "invalid":
graph["status"] = "malformed"
elif carrier is None and formats.get(key) == "reserialized-json":
graph["status"] = "limited"
elif carrier is None:
graph = project_graph(raw.get(key), key)
else:
try:
if carrier is not None:
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
elif formats.get(key) == "reserialized-json":
raise _ProjectionLimit()
else:
value = _strict_projection(raw[key], kind=key)
value = carrier.get(key)
if isinstance(value, str):
value = _strict_projection(value, kind=key)
graph = _graph(value, workflow=key == "workflow")
except _ProjectionLimit:
graph["status"] = "limited"