feat(images): unambiguous seed on workspace items

Workspace items gain `seed`, read with the shared generation-parameter
parser from the prompt/workflow records the header open already loaded
(each at most 2 MiB; no extra file reads, videos give None). A
placeholder or non-integer seed gives None. JSON integers beyond 2**53
are recovered exactly, so the card prints the digits the inspector shows.

The same parse confirms first-line card titles: a `text` title shows only
when the file's parsed positive gives the same title. One helper applies
that rule on the card and in the inspector, which now reads the positive
from its own inspection records with the same parser.
This commit is contained in:
Vito Sansevero
2026-10-19 23:05:34 -07:00
parent 47b91b83a1
commit bc373bfbed
5 changed files with 551 additions and 24 deletions
+30
View File
@@ -0,0 +1,30 @@
// The seed the workspace inspector shows for encoded inspection responses, through
// 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.
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
import path from 'node:path';
const here = path.dirname(fileURLToPath(import.meta.url));
const web = path.join(here, '..', '..', '..', 'web', 'js');
const require = createRequire(import.meta.url);
const parser = require(path.join(web, 'comfy-metadata.js'));
const { projectInspectionPayload } = await import(pathToFileURL(path.join(web, 'workspace', 'inspection-state.mjs')).href);
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 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 };
} catch (error) {
// The page shows no record here, so no seed either.
output[name] = { status: 'error', value: String(error && error.name) };
}
}
process.stdout.write(JSON.stringify(output));
+177 -3
View File
@@ -2,28 +2,47 @@
import json
import os
import shutil
import subprocess # nosec B404
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, patch
from PIL import Image, PngImagePlugin
from utils.generation_params import extract_from_records
try:
from test_api_images import ImageAPITestCase, make_png
from open_handles import assert_closed
from generation_params_cases import CASES, encoder, ksampler
except ImportError:
from tests.test_api_images import ImageAPITestCase, make_png
from tests.open_handles import assert_closed
from tests.generation_params_cases import CASES, encoder, ksampler
SEED_HARNESS = (
Path(__file__).resolve().parent / "js" / "support" / "inspection-seed.mjs"
)
NODE_TIMEOUT_SECONDS = 120
def embedded_png(path, positive, negative=""):
"""A PNG whose API prompt graph resolves to *positive* and *negative*."""
"""A PNG whose API prompt graph resolves to *positive* and *negative*.
The sampler has a model link, as every ComfyUI KSampler does, so the shared
parser (which needs one) and the server resolver both read the prompts.
"""
graph = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
"inputs": {"positive": ["1", 0], "negative": ["2", 0], "model": ["4", 0]},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "fixture.safetensors"},
},
}
info = PngImagePlugin.PngInfo()
@@ -34,6 +53,32 @@ def embedded_png(path, positive, negative=""):
return path
def records_png(path, records, mtime=100):
"""A PNG carrying the given text records (None values are left out)."""
info = PngImagePlugin.PngInfo()
for key, text in records.items():
if text is not None:
info.add_text(key, text)
with Image.new("RGB", (4, 4)) as image:
image.save(path, pnginfo=info)
os.utime(path, (mtime, mtime))
return path
def seeded_records(positive, seed):
"""API prompt records for a one-sampler graph with *positive* and *seed*."""
graph = {
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "concept.safetensors"},
},
"6": encoder(positive),
"7": encoder("blurry"),
"3": ksampler(("6", 0), ("7", 0), seed=seed),
}
return {"prompt": json.dumps(graph)}
class TestWorkspaceImages(ImageAPITestCase):
async def tearDownAsync(self):
self.api.db.close_all()
@@ -430,7 +475,6 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertEqual(library["title"], linked["title"])
self.assertEqual(library["positive_id"], prompt)
@unittest.expectedFailure # B2 gates text titles on the file's parsed positive.
async def test_unconfirmed_text_title_is_withheld_on_card_and_inspector(self):
prompt = self._save_prompt("Secret first line\nrest")
path = self.media("plain.png")
@@ -523,6 +567,136 @@ class TestWorkspaceImages(ImageAPITestCase):
self.assertIsNone(data["images"][0]["linked"]["title_source"])
self.assertNotIn("database is gone", str(data))
async def test_items_carry_seed_from_header_records(self):
records_png(self.output_dir / "seeded.png", seeded_records("a fox", 42001))
self.media("plain.png")
self.media("clip.mp4")
self.media("broken.png").write_bytes(b"invalid")
_, data = await self.get()
seeds = {item["filename"]: item["seed"] for item in data["images"]}
self.assertEqual(
seeds,
{
"seeded.png": 42001,
"plain.png": None,
"clip.mp4": None,
"broken.png": None,
},
)
async def test_card_reads_each_header_once(self):
for index in range(3):
records_png(
self.output_dir / f"{index}.png", seeded_records("a fox", index)
)
self.media("clip.mp4")
with patch("PIL.Image.open", wraps=Image.open) as opened:
_, data = await self.get()
self.assertEqual(len(data["images"]), 4)
self.assertEqual(opened.call_count, 3)
def _seed_files(self):
"""Concept-style one-sampler files plus every shared parser case."""
expected = {}
for i in range(2):
for j in range(3):
name = f"concept-{i}-{j}.png"
seed = 42001 + i * 100 + j
records_png(
self.output_dir / name, seeded_records(f"concept {i} {j}", seed)
)
expected[name] = 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
for case, records in CASES.items():
records_png(self.output_dir / f"case-{case}.png", records)
records_png(
self.output_dir / "a1111.png",
{"parameters": "a fox\nNegative prompt: blur\nSteps: 20, Seed: 9"},
)
self.media("bare.png")
return expected
async def _inspector_seeds(self, items):
node = shutil.which("node")
if node is None: # pragma: no cover - CI runners and dev hosts ship Node
if os.environ.get("CI"):
self.fail("Node.js is required in CI for the seed parity check")
self.skipTest("Node.js is not installed")
bodies = []
for item in items:
response = await self.client.get(
f"/prompt_manager/images/inspect/{item['path']}",
params={"root": 0, "root_scope": item["root_scope"]},
)
self.assertEqual(response.status, 200, item["path"])
bodies.append({"name": item["path"], "body": await response.text()})
# Fixed local Node harness, no shell.
completed = subprocess.run( # nosec B603
[node, str(SEED_HARNESS)],
input=json.dumps(bodies).encode("utf-8"),
capture_output=True,
timeout=NODE_TIMEOUT_SECONDS,
check=False,
)
self.assertEqual(completed.returncode, 0, completed.stderr.decode("utf-8"))
return json.loads(completed.stdout)
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"]
self.assertEqual(len(items), len(CASES) + len(expected) + 2)
inspector = await self._inspector_seeds(items)
both = 0
for item in items:
with self.subTest(file=item["path"]):
card, shown = item["seed"], inspector[item["path"]]
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)})
both += 1
self.assertGreaterEqual(both, len(expected) + 10)
async def test_text_title_needs_the_files_parsed_positive(self):
prompt = self._save_prompt("Saved words\nrest")
other = records_png(
self.output_dir / "other.png", seeded_records("Different words", 1)
)
shared = records_png(
self.output_dir / "shared.png", CASES["shared-prompt-pipeline"]
)
clip = self.media("clip.mp4")
shared_prompt = self._save_prompt(
extract_from_records(CASES["shared-prompt-pipeline"]["prompt"], None)[
"positivePrompt"
]
)
for path, owner in ((other, prompt), (shared, shared_prompt), (clip, prompt)):
self.api.db.link_image_to_prompt(owner, str(path))
_, data = await self.get()
items = {item["filename"]: item for item in data["images"]}
for name in ("other.png", "clip.mp4"):
with self.subTest(file=name):
self.assertEqual(items[name]["linked"]["status"], "linked")
self.assertEqual(items[name]["linked"]["title_source"], "text")
self.assertIsNone(items[name]["linked"]["title"])
library = await self.inspected("other.png", items["other.png"])
self.assertIsNone(library["title"])
self.assertNotIn("Saved words", json.dumps(data))
# The resolver cannot decide this graph; the shared parser (and so the
# inspector's prompt) confirms the saved first line on both.
title = items["shared.png"]["linked"]["title"]
self.assertIsNotNone(title)
self.assertEqual(
(await self.inspected("shared.png", items["shared.png"]))["title"], title
)
class TestWorkspaceCursorEncoding(unittest.TestCase):
def test_ntfs_unicode_basename_cursor_is_bounded_and_round_trips(self):
+170
View File
@@ -0,0 +1,170 @@
"""Card facts from header records: the shared legacy parser, bounded and exact."""
import json
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.card_seed import ( # noqa: E402
MAX_RECORD_BYTES,
card_seed,
header_params,
parsed_positive,
)
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
)
try:
from tests.generation_params_cases import CASES, encoder, ksampler # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES, encoder, ksampler # noqa: E402
def graph(*samplers, extra=None):
nodes = {"6": encoder("a lantern at dusk"), "7": encoder("blurry")}
for index, sampler in enumerate(samplers):
nodes[str(10 + index)] = sampler
nodes.update(extra or {})
return {"prompt": json.dumps(nodes)}
def seeded(seed, **settings):
return ksampler(("6", 0), ("7", 0), seed=seed, **settings)
def info_seed(info):
return card_seed(header_params(info))
class CardSeed(unittest.TestCase):
def test_card_seed_single_sampler(self):
info = graph(seeded(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):
# Ruling: no separate heuristic; the card shows what the legacy parser
# (and so the inspector) shows for several samplers.
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)
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)
dangling = graph(seeded(["99", 0]))
self.assertEqual(
extract_from_records(dangling["prompt"], None)["seed"], "Unknown"
)
self.assertIsNone(info_seed(dangling))
def test_card_seed_advanced_sampler_noise_seed(self):
advanced = seeded(None)
advanced["class_type"] = "KSamplerAdvanced"
del advanced["inputs"]["seed"]
advanced["inputs"]["noise_seed"] = 77
self.assertEqual(info_seed(graph(advanced)), 77)
def test_card_seed_oversized_record_is_none(self):
padding = "x" * (MAX_RECORD_BYTES + 1)
big_prompt = graph(seeded(1), extra={"30": encoder(padding)})
self.assertGreater(len(big_prompt["prompt"]), MAX_RECORD_BYTES)
self.assertIsNone(header_params(big_prompt))
big_workflow = {**graph(seeded(1)), "workflow": json.dumps({"x": padding})}
self.assertIsNone(header_params(big_workflow))
# Bytes, not characters: two-byte text over the budget is oversized.
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)
def test_placeholder_and_non_number_seeds_are_none(self):
for value in ("123", "randomize", True, 1.5, None):
with self.subTest(seed=value):
self.assertIsNone(info_seed(graph(seeded(value))))
self.assertIsNone(card_seed({"seed": "Unknown"}))
self.assertIsNone(card_seed({"seed": float("inf")}))
self.assertIsNone(card_seed({"seed": 1e21}))
self.assertEqual(card_seed({"seed": -3}), -3)
self.assertEqual(card_seed({"seed": 7.0}), 7)
def test_seed_beyond_double_precision_is_the_recorded_integer(self):
# JSON.parse rounds it; the inspector shows the record's own 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)
def test_big_seed_stays_rounded_when_the_exact_parse_disagrees(self):
# Only a seed that rounds to the parsed value replaces it; otherwise the
# card prints the double as the browser does (String(2**60)).
browser = 1152921504606847000
self.assertEqual(card_seed({"seed": float(2**60)}), browser)
with patch(
"utils.card_seed.extract_generation_params",
return_value={"seed": 2**61},
):
params = header_params(graph(seeded(2**60 + 1)))
self.assertEqual(card_seed(params), browser)
with patch(
"utils.card_seed.extract_generation_params",
side_effect=GenerationParamsError("throws"),
):
params = header_params(graph(seeded(2**60 + 1)))
self.assertEqual(card_seed(params), browser)
self.assertEqual(info_seed(graph(seeded(-(2**60) - 1))), -(2**60) - 1)
def test_workflow_only_record(self):
case = next(
case
for case in CASES.values()
if case["prompt"] is None
and case["workflow"] is not None
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)
def test_records_the_parser_cannot_use_give_nothing(self):
self.assertIsNone(header_params({}))
self.assertIsNone(header_params(None))
self.assertIsNone(header_params({"parameters": "a cat\nSteps: 20, Seed: 9"}))
self.assertIsNone(header_params({"prompt": b"{}"}))
self.assertIsNone(card_seed(None))
self.assertIsNone(parsed_positive(None))
# The browser parser throws here; the card shows nothing.
thrown = CASES["null-workflow-node"]
self.assertIsNone(header_params(thrown))
unparsable = header_params(CASES["unparsable-records"])
self.assertIsNone(card_seed(unparsable))
self.assertIsNone(parsed_positive(unparsable))
def test_every_shared_case_matches_the_parser(self):
for name, case in CASES.items():
with self.subTest(case=name):
info = {k: v for k, v in case.items() if v is not None}
params = header_params(info)
if params is None:
continue
parsed = extract_from_records(case["prompt"], case["workflow"])
if name == "seed-beyond-double-precision":
parsed = {**parsed, "seed": 4974052037430000001}
self.assertEqual(params, parsed)
positive = params["positivePrompt"]
usable = (
isinstance(positive, str)
and positive != "No prompt found"
and positive.strip()
)
self.assertEqual(parsed_positive(params), positive if usable else None)
if __name__ == "__main__":
unittest.main()