diff --git a/py/api/images.py b/py/api/images.py index f5a02bb..5adb8d5 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -2351,6 +2351,23 @@ class ImageRoutesMixin: self.logger.warning("Workspace link summaries could not be read") return {} + def _card_parse(self, path: Path, records: dict | None) -> tuple: + """One card's ``(seed, parsed positive)``; ``(None, None)`` on any error. + + One file's unexpected parse failure must not fail the whole page: its + card shows no seed and withholds a first-line title. + """ + try: + params = header_params(records) + return card_seed(params), parsed_positive(params) + except Exception: + self.logger.warning( + "Workspace card facts could not be parsed for %s", + path.name, + exc_info=True, + ) + return None, None + def _format_workspace_page( self, page: list[tuple], *, linked: bool = True ) -> list[dict]: @@ -2384,10 +2401,10 @@ class ImageRoutesMixin: continue # Parsed after the handle closes, so a concurrent rename or delete # (which Windows refuses on open files) is not held up by it. - params = header_params(records) - image["seed"] = card_seed(params) + seed, positive = self._card_parse(path, records) + image["seed"] = seed summary = summaries.get(str(path)) or _unavailable_link() - image["linked"] = _card_link(summary, parsed_positive(params)) + image["linked"] = _card_link(summary, positive) if linked: # Image cards carry the star; Library previews show none. favorites = self._workspace_favorites(page) diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 5770f7e..4f73b3c 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -643,6 +643,48 @@ class TestWorkspaceImages(ImageAPITestCase): else: self.assertEqual(shown, {"status": "known", "value": card}) + async def test_unbounded_digit_string_seed_still_lists(self): + records_png( + self.output_dir / "huge.png", seeded_records("a fox", "9" * 5000), 200 + ) + records_png(self.output_dir / "ok.png", seeded_records("a fox", 5)) + response, data = await self.get() + self.assertEqual(response.status, 200, data) + items = {item["filename"]: item for item in data["images"]} + self.assertIsNone(items["huge.png"]["seed"]) + self.assertEqual(items["ok.png"]["seed"], "5") + + async def test_one_cards_parse_error_spares_the_page(self): + prompt = self._save_prompt("a fox") + for name, mtime in (("bad.png", 200), ("ok.png", 100)): + path = records_png( + self.output_dir / name, seeded_records("a fox", 5), mtime + ) + self.api.db.link_image_to_prompt(prompt, str(path)) + real_parse = header_params_of_images() + + def parsing(info): + if parsing.calls == 0: + parsing.calls += 1 + raise RuntimeError("unexpected parser bug") + return real_parse(info) + + parsing.calls = 0 + with ( + patch("py.api.images.header_params", side_effect=parsing), + self.assertLogs(self.api.logger, "WARNING") as logged, + ): + response, data = await self.get() + self.assertEqual(response.status, 200, data) + items = {item["filename"]: item for item in data["images"]} + self.assertIsNone(items["bad.png"]["seed"]) + self.assertIsNone(items["bad.png"]["linked"]["title"]) + self.assertEqual(items["bad.png"]["linked"]["status"], "linked") + self.assertEqual(items["ok.png"]["seed"], "5") + self.assertEqual(items["ok.png"]["linked"]["title"], "a fox") + self.assertNotIn("unexpected parser bug", json.dumps(data)) + self.assertIn("unexpected parser bug", "\n".join(logged.output)) + async def test_library_previews_skip_card_facts(self): records_png(self.output_dir / "a.png", seeded_records("a fox", 5)) page = await self.api._collect_output_images( diff --git a/tests/test_card_seed.py b/tests/test_card_seed.py index 4f73396..466c92c 100644 --- a/tests/test_card_seed.py +++ b/tests/test_card_seed.py @@ -165,6 +165,22 @@ class CardSeed(unittest.TestCase): self.assertEqual(card_seed({"seed": value}), str(value)) self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64)) + def test_digit_string_seed_is_bounded(self): + # 2**64 has 20 digits; a longer string is no seed, and an unbounded one + # must not reach int() (Python's int max-str-digits raises ValueError). + self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20) + self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20) + self.assertIsNone(card_seed({"seed": "1" * 21})) + self.assertIsNone(card_seed({"seed": "9" * 5000})) + self.assertIsNone(info_seed(graph(seeded("9" * 5000)))) + + def test_seed_conversion_error_gives_none(self): + class Huge(int): + def __str__(self): + raise ValueError("Exceeds the limit for integer string conversion") + + self.assertIsNone(card_seed({"seed": Huge(5)})) + def test_workflow_only_record(self): case = next( case diff --git a/utils/card_seed.py b/utils/card_seed.py index c92493b..5fd0ebb 100644 --- a/utils/card_seed.py +++ b/utils/card_seed.py @@ -20,12 +20,21 @@ from .generation_params import ( ) from .inspection_response import project_graph -__all__ = ["MAX_RECORD_BYTES", "card_seed", "header_params", "parsed_positive"] +__all__ = [ + "MAX_RECORD_BYTES", + "MAX_SEED_DIGITS", + "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") +# A digit-string seed has at most this many digits (2**64 has 20). Longer text +# is no seed, and never reaches int(), whose max-str-digits guard raises. +MAX_SEED_DIGITS = 20 +_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS) def _utf8_size_over(text: str, limit: int) -> bool: @@ -76,18 +85,23 @@ def card_seed(params: dict | None) -> str | None: Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and ComfyUI's randomized seeds are often that large. 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. + recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS`` + digits. A placeholder, other or longer text, a boolean or a decimal gives + None. """ if not isinstance(params, Mapping): return None value = params.get("seed") if isinstance(value, bool): return None - if isinstance(value, int): - return str(value) - if isinstance(value, str) and _INTEGER.match(value): - return str(int(value)) + try: + if isinstance(value, int): + return str(value) + if isinstance(value, str) and _INTEGER.match(value): + return str(int(value)) + except ValueError: + # Python's integer string conversion limit: no seed, never a 500. + return None return None