diff --git a/tests/browser/workspace_concept_fixture.py b/tests/browser/workspace_concept_fixture.py index 82afe1a..ed27cb2 100644 --- a/tests/browser/workspace_concept_fixture.py +++ b/tests/browser/workspace_concept_fixture.py @@ -24,10 +24,15 @@ from uuid import UUID, uuid5 from PIL import Image, ImageDraw from PIL.PngImagePlugin import PngInfo -from tests.browser.workspace_history_fixture import _snapshot -from utils.execution_contexts import ContextBundle, RecipeContext +from utils.execution_contexts import ( + ContextBundle, + PromptSnapshot, + RecipeContext, + TextEvidence, +) from utils.execution_history_events import make_finish, make_start from utils.execution_outputs import OutputBundle, OutputReference, snapshot_output_roots +from utils.pair_execution import EXECUTION_KEY if TYPE_CHECKING: from py.api import PromptManagerAPI @@ -147,6 +152,8 @@ CONCEPT_DIMENSIONS = ( ) CONCEPT_IMAGE_ROUNDS = 4 CONCEPT_RUN_COUNT = 4 +# The concept's Model fact (app.js:408). +CONCEPT_MODEL = "illustrative-model.safetensors" MAX_EXTRA_IMAGES = 120 SESSION = "5c1f9a7e-3f0b-4a55-9d0e-2b8c4f6a7d10" _TABLES = ( @@ -180,48 +187,91 @@ def concept_filename(prompt: ConceptPrompt, round_index: int) -> str: def _graph(positive: str, negative: str, seed: int) -> dict: - """A ComfyUI API prompt with one sampler, so the seed is unambiguous.""" + """A ComfyUI API prompt with the concept's facts (app.js:406-408). + + The recipe is a Prompt Manager pair node, so with its embedded execution record + the inspector reports both roles as execution evidence, which the panel badges + "Execution recorded" (D21). One sampler keeps the seed unambiguous. + """ return { - "1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}}, - "2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}}, + "1": { + "class_type": "PromptManagerPair", + "inputs": {"text": positive, "negative_text": negative, "clip": ["2", 1]}, + }, + "2": { + "class_type": "CheckpointLoaderSimple", + "inputs": {"ckpt_name": CONCEPT_MODEL}, + }, "3": { "class_type": "KSampler", "inputs": { + "model": ["2", 0], "positive": ["1", 0], - "negative": ["2", 0], + "negative": ["1", 1], "seed": seed, "steps": 28, "cfg": 6.5, - "sampler_name": "euler", - "scheduler": "normal", + "sampler_name": "dpmpp_2m", + "scheduler": "karras", }, }, "4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}}, } -def _write_png( - path: Path, prompt: ConceptPrompt, size: tuple[int, int], graph: dict -) -> None: - """An original flat artwork in the prompt's concept palette.""" - width, height = size +def _execution_record(positive: str, negative: str) -> dict: + """The pair node's embedded execution evidence (utils/pair_execution.py).""" + record = { + "text": positive, + "negative_text": negative, + "positive_text": positive, + "negative_effective_text": negative, + "prepend_text": "", + "append_text": "", + } + return {"version": 1, "nodes": {"1": {"records": [record]}}} + + +def _snapshot(text: str) -> PromptSnapshot: + """A recorded prompt snapshot whose raw and effective texts are ``text``.""" + + def known(value: str) -> TextEvidence: + return TextEvidence(value, "recorded", "runtime_return", None) + + return PromptSnapshot(known(text), known(text), known(""), known("")) + + +def write_concept_png(path: Path, prompt_index: int, round_index: int) -> dict: + """Write the concept's sample image ``round_index`` of prompt ``prompt_index``. + + Returns its facts: variant, negative, seed and dimensions. + """ + prompt = CONCEPT_PROMPTS[prompt_index] + index = round_index * len(CONCEPT_PROMPTS) + prompt_index + variant = round_index % len(prompt.negatives) + negative = prompt.negatives[variant][1] + seed = concept_seed(prompt_index, round_index) + width, height = size = CONCEPT_DIMENSIONS[index % len(CONCEPT_DIMENSIONS)] sky, ground, light = prompt.color info = PngInfo() - info.add_text("prompt", json.dumps(graph)) + info.add_text("prompt", json.dumps(_graph(prompt.positive, negative, seed))) + info.add_text( + EXECUTION_KEY, json.dumps(_execution_record(prompt.positive, negative)) + ) with Image.new("RGB", size, sky) as image: draw = ImageDraw.Draw(image) draw.rectangle((0, height * 2 // 3, width, height), fill=ground) radius = min(width, height) // 9 - draw.ellipse( - ( - width * 2 // 3, - height // 6, - width * 2 // 3 + radius, - height // 6 + radius, - ), - fill=light, - ) + left, top = width * 2 // 3, height // 6 + draw.ellipse((left, top, left + radius, top + radius), fill=light) image.save(path, pnginfo=info) + return { + "variant": variant, + "negative": negative, + "seed": seed, + "width": width, + "height": height, + } def _clear(api: PromptManagerAPI, root: Path) -> None: @@ -315,13 +365,10 @@ def seed_workspace_concept( index = round_index * len(CONCEPT_PROMPTS) + prompt_index if index >= total: break - variant = round_index % len(prompt.negatives) - seed = concept_seed(prompt_index, round_index) name = concept_filename(prompt, round_index) path = folder / name - negative = prompt.negatives[variant][1] - size = CONCEPT_DIMENSIONS[index % len(CONCEPT_DIMENSIONS)] - _write_png(path, prompt, size, _graph(prompt.positive, negative, seed)) + facts = write_concept_png(path, prompt_index, round_index) + variant, negative, seed = facts["variant"], facts["negative"], facts["seed"] # The concept lists images newest first in this order. stamp = now - 60 - index * 60 os.utime(path, (stamp, stamp)) @@ -353,8 +400,8 @@ def seed_workspace_concept( "prompt": prompt.id, "variant": variant, "seed": seed, - "width": size[0], - "height": size[1], + "width": facts["width"], + "height": facts["height"], } ) with db.model.get_connection() as conn: diff --git a/tests/test_workspace_concept_fixture.py b/tests/test_workspace_concept_fixture.py new file mode 100644 index 0000000..f301de6 --- /dev/null +++ b/tests/test_workspace_concept_fixture.py @@ -0,0 +1,84 @@ +"""The concept fixture files carry the concept inspector facts (app.js:400-414).""" + +import json +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +try: + from tests.open_handles import assert_closed +except ImportError: # discovered with tests/ as the top-level directory + from open_handles import assert_closed + +from tests.browser.workspace_concept_fixture import ( + CONCEPT_MODEL, + CONCEPT_PROMPTS, + write_concept_png, +) +from utils.prompt_inspector import inspect_prompt_evidence, read_inspection_file + + +class TestWorkspaceConceptFixture(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.addCleanup(assert_closed, self, self.tmp.name) + + def write(self, prompt_index, round_index): + prompt = CONCEPT_PROMPTS[prompt_index] + path = Path(self.tmp.name) / f"{prompt.id}_{round_index + 1:05}.png" + image = write_concept_png(path, prompt_index, round_index) + return path, image + + def test_each_image_carries_execution_evidence_and_the_concept_facts(self): + for prompt_index, round_index in ((0, 0), (0, 2), (3, 1), (5, 3)): + with self.subTest(prompt=prompt_index, round=round_index): + prompt = CONCEPT_PROMPTS[prompt_index] + path, image = self.write(prompt_index, round_index) + negative = prompt.negatives[round_index % len(prompt.negatives)][1] + inspection = read_inspection_file(path) + evidence = inspect_prompt_evidence(inspection.metadata) + # D21: "execution" is the source the panel badges "Execution recorded". + self.assertEqual( + evidence["prompts"]["positive"], + { + "status": "resolved", + "text": prompt.positive, + "source": "execution", + }, + ) + self.assertEqual(evidence["prompts"]["negative"]["text"], negative) + self.assertEqual(evidence["prompts"]["negative"]["source"], "execution") + graph = json.loads(inspection.metadata["prompt"]) + sampler = next( + node["inputs"] + for node in graph.values() + if node["class_type"] == "KSampler" + ) + self.assertEqual( + sampler["seed"], 42001 + prompt_index * 100 + round_index + ) + self.assertEqual(sampler["seed"], image["seed"]) + self.assertEqual( + (sampler["sampler_name"], sampler["scheduler"]), + ("dpmpp_2m", "karras"), + ) + self.assertEqual((sampler["steps"], sampler["cfg"]), (28, 6.5)) + loader = graph[sampler["model"][0]] + self.assertEqual(loader["class_type"], "CheckpointLoaderSimple") + self.assertEqual(loader["inputs"]["ckpt_name"], CONCEPT_MODEL) + self.assertEqual(CONCEPT_MODEL, "illustrative-model.safetensors") + with Image.open(path) as source: + self.assertEqual(source.size, (image["width"], image["height"])) + + def test_concept_dimensions_follow_the_prompt_index(self): + expected = [(1536, 1152), (1024, 1536), (1280, 1280)] + for prompt_index, size in enumerate(expected): + _, image = self.write(prompt_index, 1) + self.assertEqual((image["width"], image["height"]), size) + + +if __name__ == "__main__": + unittest.main()