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:
+15
-10
@@ -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.
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user