fix(images): send the card seed as a decimal string

JSON.parse rounds a number past 2**53, so a listing seed such as
9223372036854788153 reached the page as 9223372036854788000 while the
inspector showed the file's digits. Every non-null card seed is now its
decimal string.

The Node parity harness now JSON.parses the real listing response and
compares String(seed) with the seed the inspector shows, including a
seed past 2**53. TESTING.md describes the string contract and drops the
removed exact re-read.
This commit is contained in:
Vito Sansevero
2026-10-19 23:29:18 -07:00
parent d565100284
commit 48f23499d3
5 changed files with 90 additions and 47 deletions
+15 -10
View File
@@ -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.
+16 -7
View File
@@ -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 }));
+31 -11
View File
@@ -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")
+19 -12
View File
@@ -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({}))
+9 -7
View File
@@ -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