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:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user