From d56510028405cdcb1cb310c73d577d47a5ada84d Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 19 Oct 2026 23:13:41 -0700 Subject: [PATCH] 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. --- tests/test_api_workspace_images.py | 11 ++- tests/test_card_seed.py | 136 ++++++++++++++--------------- utils/card_seed.py | 97 ++++++++------------ utils/generation_params.py | 17 ++-- utils/inspection_response.py | 30 +++++-- 5 files changed, 136 insertions(+), 155 deletions(-) diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 909e14d..9b4464e 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -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)) diff --git a/tests/test_card_seed.py b/tests/test_card_seed.py index 97786af..b90cd07 100644 --- a/tests/test_card_seed.py +++ b/tests/test_card_seed.py @@ -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__": diff --git a/utils/card_seed.py b/utils/card_seed.py index 6846e7d..2806f0f 100644 --- a/utils/card_seed.py +++ b/utils/card_seed.py @@ -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: diff --git a/utils/generation_params.py b/utils/generation_params.py index 8023053..e65ecd1 100644 --- a/utils/generation_params.py +++ b/utils/generation_params.py @@ -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 diff --git a/utils/inspection_response.py b/utils/inspection_response.py index 02c37d3..8dffb4e 100644 --- a/utils/inspection_response.py +++ b/utils/inspection_response.py @@ -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"