The Python parser recurses over nested arrays where JavaScript String() does not, so a crafted 2 KB PNG turned every workspace page holding it (and its inspect route) into a 500. Card facts now treat RecursionError like any record the parser cannot use: no seed and no confirmed title. Also: Library previews skip the card parse they never show; the parse runs after the file handle closes; the exact-seed read uses the one JSON reader (parse_js_json(..., exact_integers=True)) and the public number_string; record size is decided from the text length unless only the UTF-8 byte count can tell.
129 lines
4.7 KiB
Python
129 lines
4.7 KiB
Python
"""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.
|
|
"""
|
|
|
|
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,
|
|
)
|
|
|
|
__all__ = ["MAX_RECORD_BYTES", "card_seed", "header_params", "parsed_positive"]
|
|
|
|
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
|
|
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:
|
|
"""True when ``text`` is over ``limit`` UTF-8 bytes, encoding only if unclear.
|
|
|
|
A code point takes one to four bytes, so most records are decided from
|
|
their length alone without copying them.
|
|
"""
|
|
if len(text) > limit:
|
|
return True
|
|
if len(text) * 4 <= limit:
|
|
return False
|
|
return len(text.encode("utf-8", "surrogatepass")) > limit
|
|
|
|
|
|
def header_params(info) -> dict | None:
|
|
"""The shared parser's result for the ``prompt``/``workflow`` text in ``info``.
|
|
|
|
``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.
|
|
"""
|
|
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():
|
|
if text is None:
|
|
continue
|
|
if not isinstance(text, str):
|
|
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.
|
|
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"]
|
|
except (GenerationParamsError, RecursionError):
|
|
return params
|
|
if isinstance(exact, bool) or not isinstance(exact, int) or float(exact) != seed:
|
|
return params
|
|
return {**params, "seed": exact}
|
|
|
|
|
|
def card_seed(params: dict | None) -> int | None:
|
|
"""The parsed seed as the integer the browser prints, 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)``.
|
|
"""
|
|
if not isinstance(params, Mapping):
|
|
return None
|
|
value = params.get("seed")
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
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
|
|
|
|
|
|
def parsed_positive(params: dict | None) -> str | None:
|
|
"""The parsed positive prompt text, or None for the placeholder or blank."""
|
|
if not isinstance(params, Mapping):
|
|
return None
|
|
text = params.get("positivePrompt")
|
|
if not isinstance(text, str) or text == PLACEHOLDERS["positivePrompt"]:
|
|
return None
|
|
return text if text.strip() else None
|