diff --git a/TESTING.md b/TESTING.md index 78a40d5..22ea1dd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -446,16 +446,21 @@ and skip the parse. records by the shared parser, gives the same display title. Otherwise the title is null on the card and in the inspector header. Videos and unreadable files have no parsed positive, so their `text` titles are withheld. -- `seed`: `extract_generation_params` from `utils/generation_params.py` (the - inspector's legacy parser, #251), run on the `prompt`/`workflow` header records - read by the same open that reads the dimensions. The parse runs after the file - handle closes. A record over 2 MiB (UTF-8), a parser error, a recursion error, a - placeholder, or a non-integral, boolean or non-finite seed gives null. A seed - past 2**53 is re-read exactly from the same records and kept only if it rounds - to the parsed double. A card never shows a seed that differs from the - inspector's (`test_card_seed_equals_inspector_seed_when_both_present`). The card - shows no seed for A1111 `parameters` files or for a BasicGuider custom sampler, - where the inspector does. +- `seed`: a decimal string, or null. Always a string, because `JSON.parse` rounds + numbers past 2**53 and ComfyUI's randomized seeds are often that large. It is + `extract_generation_params` from `utils/generation_params.py` (the inspector's + legacy parser, #251), run on the `prompt`/`workflow` header records read by the + same open that reads the dimensions. The records go through the inspection + projection first (`utils/inspection_response.project_graph`), which carries + integers past 2**53 as their exact digits, so the card and the inspector read + the same digits. The parse runs after the file handle closes. A record over + 2 MiB (UTF-8), a parser error, a recursion error, a placeholder, or a + non-integral, boolean or non-finite seed gives null. A card never shows a seed + that differs from the inspector's + (`test_card_seed_equals_inspector_seed_when_both_present`, which `JSON.parse`s + the listing in Node and compares `String(seed)` with the inspector's seed, + including one past 2**53). The card shows no seed for A1111 `parameters` files + or for a BasicGuider custom sampler, where the inspector does. - `favorite: bool`. One favorites SELECT per root per page. A read failure logs a warning and shows false. diff --git a/tests/js/support/inspection-seed.mjs b/tests/js/support/inspection-seed.mjs index 4655236..9aaa338 100644 --- a/tests/js/support/inspection-seed.mjs +++ b/tests/js/support/inspection-seed.mjs @@ -2,8 +2,12 @@ // its own path: projectInspectionPayload with the server facts and the legacy // parser fill (comfy-metadata.js, passed explicitly; Node has no window global). // Used by tests/test_api_workspace_images.py to compare card and inspector seeds. -// stdin: [{name, body}] (body is the exact response JSON text). -// stdout: {name: {status, value}}; status 'error' where the page cannot project it. +// stdin: {listing, inspections: [{name, body}]}; each body is the exact response +// JSON text, and listing (optional) is the exact workspace listing response text. +// stdout: {inspector: {name: {status, value}}, card: {path: String(seed) | null}}. +// Inspector status is 'error' where the page cannot project the record. The card +// values come from JSON.parse of the listing, as the browser reads them, so a +// seed sent as a JSON number past 2**53 shows up rounded here. import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import path from 'node:path'; @@ -16,15 +20,20 @@ const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, ' const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); -const output = {}; -for (const { name, body } of JSON.parse(Buffer.concat(chunks).toString('utf8'))) { +const { listing = null, inspections } = JSON.parse(Buffer.concat(chunks).toString('utf8')); +const inspector = {}; +for (const { name, body } of inspections) { const identity = { kind: 'rooted-file', key: `0:${name}`, root_index: 0, path: name, root_scope: null }; try { const record = projectInspectionPayload(JSON.parse(body), identity, null, parser.extractGenerationFacts, false, parser); - output[name] = { status: record.generation.seed.status, value: record.generation.seed.value }; + inspector[name] = { status: record.generation.seed.status, value: record.generation.seed.value }; } catch (error) { // The page shows no record here, so no seed either. - output[name] = { status: 'error', value: String(error && error.name) }; + inspector[name] = { status: 'error', value: String(error && error.name) }; } } -process.stdout.write(JSON.stringify(output)); +const card = {}; +for (const item of listing === null ? [] : JSON.parse(listing).images) { + card[item.path] = item.seed === null || item.seed === undefined ? null : String(item.seed); +} +process.stdout.write(JSON.stringify({ inspector, card })); diff --git a/tests/test_api_workspace_images.py b/tests/test_api_workspace_images.py index 9b4464e..5770f7e 100644 --- a/tests/test_api_workspace_images.py +++ b/tests/test_api_workspace_images.py @@ -597,7 +597,7 @@ class TestWorkspaceImages(ImageAPITestCase): self.assertEqual( seeds, { - "seeded.png": 42001, + "seeded.png": "42001", "plain.png": None, "clip.mp4": None, "broken.png": None, @@ -628,7 +628,7 @@ class TestWorkspaceImages(ImageAPITestCase): response, data = await self.get() self.assertEqual(response.status, 200, data) items = {item["filename"]: item for item in data["images"]} - self.assertEqual(items["ok.png"]["seed"], 5) + self.assertEqual(items["ok.png"]["seed"], "5") response = await self.client.get( "/prompt_manager/images/inspect/evil.png", params={"root": 0, "root_scope": items["evil.png"]["root_scope"]}, @@ -641,7 +641,7 @@ class TestWorkspaceImages(ImageAPITestCase): if card is None: self.assertNotEqual(shown["status"], "known") else: - self.assertEqual(shown, {"status": "known", "value": str(card)}) + self.assertEqual(shown, {"status": "known", "value": card}) async def test_library_previews_skip_card_facts(self): records_png(self.output_dir / "a.png", seeded_records("a fox", 5)) @@ -672,7 +672,7 @@ class TestWorkspaceImages(ImageAPITestCase): patch("py.api.images.header_params", side_effect=parsing), ): _, data = await self.get() - self.assertEqual(data["images"][0]["seed"], 5) + self.assertEqual(data["images"][0]["seed"], "5") self.assertEqual(closed, [True]) def _seed_files(self): @@ -685,11 +685,11 @@ class TestWorkspaceImages(ImageAPITestCase): records_png( self.output_dir / name, seeded_records(f"concept {i} {j}", seed) ) - expected[name] = seed + expected[name] = str(seed) # ComfyUI's randomized seeds mostly exceed 2**53, where JSON.parse rounds. big = 2**63 + 12345 records_png(self.output_dir / "big-seed.png", seeded_records("big", big)) - expected["big-seed.png"] = big + expected["big-seed.png"] = str(big) for case, records in CASES.items(): records_png(self.output_dir / f"case-{case}.png", records) records_png( @@ -700,6 +700,15 @@ class TestWorkspaceImages(ImageAPITestCase): return expected async def _inspector_seeds(self, items): + """The seed the inspector shows for each listed item (Node harness).""" + return (await self._node_seeds(items))["inspector"] + + async def _node_seeds(self, items, listing=None): + """The harness output: inspector seeds and, from *listing*, card seeds. + + *listing* is the exact listing response text; the harness JSON.parses + it as the browser does, so the card seeds are what a page would read. + """ node = shutil.which("node") if node is None: # pragma: no cover - CI runners and dev hosts ship Node if os.environ.get("CI"): @@ -716,7 +725,9 @@ class TestWorkspaceImages(ImageAPITestCase): # Fixed local Node harness, no shell. completed = subprocess.run( # nosec B603 [node, str(SEED_HARNESS)], - input=json.dumps(bodies).encode("utf-8"), + input=json.dumps({"listing": listing, "inspections": bodies}).encode( + "utf-8" + ), capture_output=True, timeout=NODE_TIMEOUT_SECONDS, check=False, @@ -726,22 +737,31 @@ class TestWorkspaceImages(ImageAPITestCase): async def test_card_seed_equals_inspector_seed_when_both_present(self): expected = self._seed_files() - _, data = await self.get(limit=200) - items = data["images"] + response = await self.client.get( + "/prompt_manager/images/output", + params={"mode": "workspace", "limit": 200}, + ) + listing = await response.text() + items = json.loads(listing)["images"] self.assertEqual(len(items), len(CASES) + len(expected) + 2) - inspector = await self._inspector_seeds(items) + seeds = await self._node_seeds(items, listing) + inspector, browser = seeds["inspector"], seeds["card"] both = 0 for item in items: with self.subTest(file=item["path"]): card, shown = item["seed"], inspector[item["path"]] + # Sent as a decimal string, so JSON.parse keeps every digit. + self.assertTrue(card is None or isinstance(card, str), card) + self.assertEqual(browser[item["path"]], card) if item["path"] in expected: self.assertEqual(card, expected[item["path"]]) if card is None: continue # The card never shows a seed the inspector does not. - self.assertEqual(shown, {"status": "known", "value": str(card)}) + self.assertEqual(shown, {"status": "known", "value": card}) both += 1 self.assertGreaterEqual(both, len(expected) + 10) + self.assertGreater(int(browser["big-seed.png"]), 2**53) async def test_text_title_needs_the_files_parsed_positive(self): prompt = self._save_prompt("Saved words\nrest") diff --git a/tests/test_card_seed.py b/tests/test_card_seed.py index b90cd07..4f73396 100644 --- a/tests/test_card_seed.py +++ b/tests/test_card_seed.py @@ -57,7 +57,7 @@ def info_seed(info): class CardSeed(unittest.TestCase): def test_card_seed_single_sampler(self): info = graph(seeded(42001)) - self.assertEqual(info_seed(info), 42001) + self.assertEqual(info_seed(info), "42001") self.assertEqual(parsed_positive(header_params(info)), "a lantern at dusk") def test_card_seed_two_different_seeds_follows_shared_parser(self): @@ -66,12 +66,12 @@ class CardSeed(unittest.TestCase): info = graph(seeded(111), seeded(222)) expected = extract_from_records(info["prompt"], None)["seed"] self.assertIn(expected, (111, 222)) - self.assertEqual(info_seed(info), expected) + self.assertEqual(info_seed(info), str(expected)) def test_card_seed_linked_input_follows_shared_parser(self): primitive = {"class_type": "PrimitiveNode", "inputs": {"value": 555}} info = graph(seeded(["20", 0]), extra={"20": primitive}) - self.assertEqual(info_seed(info), 555) + self.assertEqual(info_seed(info), "555") dangling = graph(seeded(["99", 0])) self.assertEqual( extract_from_records(dangling["prompt"], None)["seed"], "Unknown" @@ -83,7 +83,7 @@ class CardSeed(unittest.TestCase): advanced["class_type"] = "KSamplerAdvanced" del advanced["inputs"]["seed"] advanced["inputs"]["noise_seed"] = 77 - self.assertEqual(info_seed(graph(advanced)), 77) + self.assertEqual(info_seed(graph(advanced)), "77") def test_card_seed_oversized_record_is_none(self): padding = "x" * (MAX_RECORD_BYTES + 1) @@ -96,7 +96,7 @@ class CardSeed(unittest.TestCase): wide = "é" * (MAX_RECORD_BYTES // 2 + 1) self.assertLessEqual(len(wide), MAX_RECORD_BYTES) self.assertIsNone(header_params({"prompt": json.dumps({"a": wide})})) - self.assertEqual(info_seed(graph(seeded(1))), 1) + self.assertEqual(info_seed(graph(seeded(1))), "1") def test_records_past_the_inspection_projection_bound_give_nothing(self): # The inspector hands the browser parser only graphs its projection @@ -107,7 +107,7 @@ class CardSeed(unittest.TestCase): 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) + self.assertEqual(card_seed(both), "1") def test_depth_bound_is_the_inspectors(self): def deep(levels): @@ -118,7 +118,7 @@ class CardSeed(unittest.TestCase): # root object, node object, inputs object: three levels before "v". inside = MAX_PROJECTION_DEPTH - 3 - self.assertEqual(info_seed(deep(inside)), 3) + self.assertEqual(info_seed(deep(inside)), "3") self.assertIsNone(header_params(deep(inside + 1))) def test_strict_projection_rules_apply(self): @@ -143,20 +143,27 @@ class CardSeed(unittest.TestCase): self.assertIsNone(info_seed(graph(seeded(value)))) self.assertIsNone(card_seed({"seed": "Unknown"})) self.assertIsNone(card_seed({"seed": 7.0})) - self.assertEqual(info_seed(graph(seeded(-3))), -3) + 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) + self.assertEqual(info_seed(graph(seeded("123"))), "123") def test_seed_beyond_double_precision_is_the_recorded_integer(self): # 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) - self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), -(2**60) - 1) + self.assertEqual(info_seed({"prompt": case["prompt"]}), "4974052037430000001") + self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), str(-(2**60) - 1)) + + def test_seed_is_always_a_decimal_string(self): + # JSON.parse rounds numbers past 2**53, so every seed travels as text. + for value in (0, 7, 2**53, 2**53 + 1, 2**64, -(2**63)): + with self.subTest(seed=value): + self.assertEqual(card_seed({"seed": value}), str(value)) + self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64)) def test_workflow_only_record(self): case = next( @@ -167,7 +174,7 @@ class CardSeed(unittest.TestCase): and isinstance(extract_from_records(None, case["workflow"])["seed"], int) ) expected = extract_from_records(None, case["workflow"])["seed"] - self.assertEqual(info_seed({"workflow": case["workflow"]}), expected) + self.assertEqual(info_seed({"workflow": case["workflow"]}), str(expected)) def test_records_the_parser_cannot_use_give_nothing(self): self.assertIsNone(header_params({})) diff --git a/utils/card_seed.py b/utils/card_seed.py index 2806f0f..c92493b 100644 --- a/utils/card_seed.py +++ b/utils/card_seed.py @@ -70,12 +70,14 @@ def header_params(info) -> dict | None: return None -def card_seed(params: dict | None) -> int | None: - """The parsed seed as an integer, else None. +def card_seed(params: dict | None) -> str | None: + """The parsed seed as a decimal string, else None. - 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. + 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. """ if not isinstance(params, Mapping): return None @@ -83,9 +85,9 @@ def card_seed(params: dict | None) -> int | None: if isinstance(value, bool): return None if isinstance(value, int): - return value + return str(value) if isinstance(value, str) and _INTEGER.match(value): - return int(value) + return str(int(value)) return None