diff --git a/py/api/inspection_service.py b/py/api/inspection_service.py index 83ce648..787025c 100644 --- a/py/api/inspection_service.py +++ b/py/api/inspection_service.py @@ -11,6 +11,10 @@ try: read_inspection_file, unreadable_evidence, ) + from ...utils.prompt_pair_resolver import ( + INSPECTION_PROFILE, + PREVIEW_INSPECTION_PROFILE, + ) from ...utils.video_metadata import is_video_path except ImportError: from utils.prompt_inspector import ( @@ -19,6 +23,10 @@ except ImportError: read_inspection_file, unreadable_evidence, ) + from utils.prompt_pair_resolver import ( + INSPECTION_PROFILE, + PREVIEW_INSPECTION_PROFILE, + ) from utils.video_metadata import is_video_path @@ -62,6 +70,28 @@ def _current_file_facts(path: Path) -> os.stat_result: return observed +def _in_host_temp(path: Path) -> bool: + """Whether a file lies in ComfyUI's temp folder, where previews are written. + + An unknown temp folder reads as no: the file is then inspected as saved + output. + """ + try: + import folder_paths + + temp = folder_paths.get_temp_directory() + except (ImportError, AttributeError, OSError): + return False + if type(temp) is not str or not temp: + return False + try: + folder = os.path.normcase(os.path.realpath(temp)) + target = os.path.normcase(os.path.realpath(path)) + return os.path.commonpath([folder, target]) == folder + except (OSError, ValueError): + return False + + def inspect_current_file( path: Path, *, source_label: str, saved: dict | None, library: dict ) -> dict: @@ -80,7 +110,13 @@ def inspect_current_file( role_saved = ( None if saved and saved.get("association_status") == "ambiguous" else saved ) - evidence = inspect_prompt_evidence(inspection.metadata, role_saved) + # A preview may be the output of a stage that reaches only previews. + profile = ( + PREVIEW_INSPECTION_PROFILE if _in_host_temp(path) else INSPECTION_PROFILE + ) + evidence = inspect_prompt_evidence( + inspection.metadata, role_saved, profile=profile + ) return { "file": { "observation": "current-file", diff --git a/tests/fixtures/inspection_parity/vectors.json b/tests/fixtures/inspection_parity/vectors.json index 9e22beb..6f32f6c 100644 --- a/tests/fixtures/inspection_parity/vectors.json +++ b/tests/fixtures/inspection_parity/vectors.json @@ -48,24 +48,24 @@ "anchor": null, "expect": { "metadata_status": "available", - "state": "ambiguous", - "reason": "Multiple sampler branches", + "state": "absent", + "reason": "Guider has no negative input", "reason_code": null, "selection": null, - "positive": null, - "negative": null, + "positive": "a red fox in snow, detailed fur, Scene 0: unique shot description soft winter light,", + "negative": "", "candidate_count": 0, "facts": { "seed": { - "status": "ambiguous", - "value": null + "status": "known", + "value": "1000" }, "steps": { "status": "known", "value": "20" }, "cfg": { - "status": "unresolved", + "status": "not-recorded", "value": null }, "sampler": { @@ -73,8 +73,8 @@ "value": "euler" }, "scheduler": { - "status": "ambiguous", - "value": null + "status": "known", + "value": "Flux2Scheduler" }, "model": { "status": "known", diff --git a/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js index 984ac7e..7e45d2e 100644 --- a/tests/js/comfy-metadata.test.js +++ b/tests/js/comfy-metadata.test.js @@ -300,6 +300,21 @@ test("unresolved and absent results over several stages merge facts per field", assert.ok(factFields.every(field => single[field].status === "known" && single[field].sampler_id === "s")); }); +test("a named sampler limits absent and unresolved facts to its own stages", () => { + // Codex P2: the server dropped a preview-only detailer and named the saved sampler, so + // the detailer's settings never leak into the saved file's facts. + const graph = detailerFactGraph(); + graph.save = { class_type: "SaveImage", inputs: { images: ["decode", 0] } }; + graph.preview = { class_type: "PreviewImage", inputs: { images: ["d", 0] } }; + const own = { + seed: ["known", "42"], steps: ["known", "25"], cfg: ["known", "6.5"], + sampler: ["known", "euler"], scheduler: ["known", "normal"], model: ["known", "model.safetensors"], + }; + for (const state of ["absent", "unresolved"]) assert.deepEqual(statusValues(factsFor(graph, { resolution: { state, sampler_id: "s" } })), own, state); + // Without a named sampler every listed sampler still merges. + assert.equal(factsFor(graph, { resolution: { state: "absent", sampler_id: null } }).seed.status, "ambiguous"); +}); + test("stage walks follow only the resolver's links", () => { // Review M-2: `["eff", -1]` is not a link to the server, so it is not walked. for (const slot of [-1, 0.5, "0"]) { diff --git a/tests/test_history_current.py b/tests/test_history_current.py index 18589ad..d9836eb 100644 --- a/tests/test_history_current.py +++ b/tests/test_history_current.py @@ -449,6 +449,46 @@ class CurrentLocationTests(unittest.TestCase): ) self.assertEqual(reads, [], "Unsupported media must not reach the parser") + def test_preview_files_in_the_host_temp_folder_keep_preview_only_stages(self): + # Saved raw image plus a FaceDetailer that only previews (Codex P2). + import json + + from PIL import Image + from PIL.PngImagePlugin import PngInfo + from tests import inspection_parity as parity + + service = self.module("py.api.inspection_service") + graph = parity.build("f19")["prompt"] + graph["9"]["class_type"] = "PreviewImage" + graph["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + info = PngInfo() + info.add_text("prompt", json.dumps(graph)) + observed = {} + for name, folder in (("output", self.output), ("temp", self.temp)): + path = folder / "nested" / "same.png" + path.parent.mkdir() + with Image.new("RGB", (4, 4)) as image: + image.save(path, pnginfo=info) + observed[name] = service.inspect_current_file( + path, source_label=name, saved=None, library={"status": "none"} + ) + saved = observed["output"]["prompts"] + self.assertEqual(saved["positive"]["status"], "resolved") + self.assertEqual(saved["positive"]["text"], parity.POS) + self.assertEqual(observed["output"]["resolution"]["sampler_id"], "3") + preview = observed["temp"]["prompts"] + self.assertEqual(preview["positive"]["status"], "ambiguous") + self.assertIsNone(preview["positive"]["text"]) + # An unknown host temp folder treats the file as saved output. + self.host_temp = None + again = service.inspect_current_file( + self.output / "nested" / "same.png", + source_label="output", + saved=None, + library={"status": "none"}, + ) + self.assertEqual(again["prompts"]["positive"]["status"], "resolved") + class HistoryImportTests(unittest.TestCase): def test_api_import_does_not_require_a_running_host_server(self): diff --git a/tests/test_prompt_pair_resolver.py b/tests/test_prompt_pair_resolver.py index 1ba3f8b..cda53ad 100644 --- a/tests/test_prompt_pair_resolver.py +++ b/tests/test_prompt_pair_resolver.py @@ -1996,9 +1996,7 @@ class TestEncoderAndStringSemantics(unittest.TestCase): self.assertEqual(evidence["prompts"]["positive"]["status"], "unresolved") self.assertIsNone(evidence["prompts"]["positive"]["text"]) - # TODO(PR3): flip with the f01b vector once output ancestry excludes the - # preview-only FaceDetailer (D19); the latent origin is accepted since 2.5. - @unittest.expectedFailure + # The preview-only FaceDetailer no longer competes with the saved branch. def test_f01b_single_flux2_branch_resolves_with_absent_negative(self): result = _inspect(parity.build("f01b")["prompt"]) self.assertEqual(result["state"], "absent") @@ -2388,6 +2386,106 @@ class TestDetailerStages(unittest.TestCase): result = _inspect(source, output_node_id="9") self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "d19")) + def test_a_preview_only_detailer_never_blocks_the_saved_branch(self): + # Codex P2: the detailer feeds only a PreviewImage; the raw image is saved. + source = _detailed() + source["9"]["class_type"] = "PreviewImage" + source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + result = _inspect(source) + self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3")) + self.assertEqual(_sides(result), (parity.POS, parity.NEG)) + self.assertEqual(result["candidates"], []) + # Every non-file output counts as a preview, through any image chain. + for name in sorted(inspection_semantics.load_semantics().non_file_outputs): + with self.subTest(output=name): + source["15"] = { + "class_type": "ImageScale", + "inputs": {"image": ["50", 0]}, + } + source["9"] = {"class_type": name, "inputs": {"images": ["15", 0]}} + self.assertEqual(_inspect(source)["sampler_id"], "3") + + def test_a_preview_only_sampler_never_blocks_the_saved_sampler(self): + source = _detailed() + source["9"]["inputs"]["images"] = ["8", 0] + source["16"] = {"class_type": "VAEDecode", "inputs": {"samples": ["50", 0]}} + del source["50"] + source["50"] = { + "class_type": "KSampler", + "inputs": {**source["3"]["inputs"], "positive": ["10", 0]}, + } + source["17"] = {"class_type": "PreviewImage", "inputs": {"images": ["16", 0]}} + result = _inspect(source) + self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3")) + # Ingestion keeps its historical answer (D1). + legacy = resolve_prompt_pair({"prompt": source}) + self.assertEqual(legacy["state"], "ambiguous") + + def test_stages_that_write_files_stay_candidates(self): + # Raw and detailed double saves both write files (the H1 exception). + source = _detailed() + source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + source["15"] = {"class_type": "PreviewImage", "inputs": {"images": ["50", 0]}} + result = _inspect(source) + self.assertEqual(result["state"], "ambiguous") + self.assertEqual( + sorted(item["sampler_id"] for item in result["candidates"]), ["3", "50"] + ) + # A stage with no output at all is not known to be preview-only. + source = _detailed() + source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + del source["9"] + self.assertEqual(_inspect(source)["state"], "ambiguous") + + def test_when_every_stage_is_preview_only_none_is_dropped(self): + source = _detailed() + source["9"]["class_type"] = "PreviewImage" + source["14"] = {"class_type": "PreviewImage", "inputs": {"images": ["8", 0]}} + result = _inspect(source) + self.assertEqual(result["reason_code"], "multistage-differs") + self.assertEqual(len(result["candidates"]), 2) + + def test_preview_files_keep_preview_only_stages(self): + # A preview file in the temp folder may be the preview stage's own output. + source = _detailed() + source["9"]["class_type"] = "PreviewImage" + source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + result = resolve_prompt_pair( + {"prompt": source}, profile=resolver.PREVIEW_INSPECTION_PROFILE + ) + self.assertEqual(result["state"], "ambiguous") + self.assertEqual(len(result["candidates"]), 2) + + def test_a_failed_preview_walk_keeps_every_stage(self): + source = _detailed() + source["9"]["class_type"] = "PreviewImage" + source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + limit = resolver._Unresolved("Resolution work limit reached", "work-limit") + with mock.patch.object( + resolver._GraphResolver, "producers_of", side_effect=limit + ): + result = _inspect(source) + # More ambiguity, never a chosen prompt. + self.assertEqual(result["state"], "ambiguous") + self.assertEqual(len(result["candidates"]), 2) + self.assertIsNone(result["positive"]) + + def test_preview_only_walks_are_linear_in_long_chains(self): + # 600 chained detailers ending in a preview, beside a saved raw image: + # one pass decides every stage, so the walk itself never runs out. + source = _detailed(same_prompt=True) + previous = "50" + for index in range(600): + source[f"d{index}"] = _detailer([previous, 0], ["6", 0]) + previous = f"d{index}" + source["9"] = { + "class_type": "PreviewImage", + "inputs": {"images": [previous, 0]}, + } + source["14"] = {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}} + result = _inspect(source) + self.assertEqual((result["state"], result["sampler_id"]), ("resolved", "3")) + def test_default_profile_keeps_the_upstream_sampler(self): # Ingestion is unchanged (D1): the detailer is not a sampler there. result = resolve_prompt_pair({"prompt": _detailed()}) diff --git a/utils/prompt_pair_resolver.py b/utils/prompt_pair_resolver.py index 7ba0d2b..1e8d4d2 100644 --- a/utils/prompt_pair_resolver.py +++ b/utils/prompt_pair_resolver.py @@ -129,6 +129,10 @@ class ResolverProfile: max_path_entries: Optional[int] = None max_built_chars: Optional[int] = None max_scanned_units: Optional[int] = None + # Unanchored inspection of a saved file drops stages whose outputs reach + # only non-file outputs (PreviewImage and the like): they cannot have + # written it. A preview file itself may be such a stage's output. + drop_preview_only_stages: bool = False @property def legacy(self) -> bool: @@ -157,6 +161,11 @@ INSPECTION_PROFILE = ResolverProfile( max_built_chars=16 * 100_000, # About four passes over the 2 MiB role-record budget. max_scanned_units=8_000_000, + drop_preview_only_stages=True, +) +# Inspection of a file in the host temp folder (where previews are written). +PREVIEW_INSPECTION_PROFILE = replace( + INSPECTION_PROFILE, name="inspection-preview", drop_preview_only_stages=False ) # Without the semantics table every sampler reads its pair directly. _DIRECT_SAMPLER = { @@ -345,6 +354,7 @@ class _GraphResolver: self.path_entries = profile.max_path_entries self.built_chars = profile.max_built_chars self.scanned = profile.max_scanned_units + self.drop_preview_only = profile.drop_preview_only_stages self.checked: dict = {} # Unfolded visit cost of each memo entry (inspection): a memo hit charges # it to the current candidate, so each candidate's outcome is independent @@ -1159,7 +1169,11 @@ class _GraphResolver: A sampler whose output also reaches a terminal node other than a later stage or a preview stays a candidate of its own (the file may be its output). Any walk that fails or runs out of budget absorbs nothing. + With ``drop_preview_only_stages``, a stage whose outputs reach only + non-file outputs is dropped first, unless every stage is. """ + if self.drop_preview_only: + sampler_ids = self.file_stages(sampler_ids) detailers = [ node_id for node_id in sampler_ids @@ -1186,6 +1200,69 @@ class _GraphResolver: kept = [node_id for node_id in sampler_ids if node_id not in absorbed] return kept or sampler_ids + def file_stages(self, sampler_ids: list) -> list: + """Stages not proven preview-only; all of them when every one is. + + A stage is preview-only when its outputs, followed through any later + stage, reach a non-file output and no other terminal node. A stage + without consumers is a terminal of its own and is kept. Two backward + passes over the graph decide every stage at once (linear work, charged + like ``consumers``); a pass that fails keeps every stage. + """ + if len(sampler_ids) < 2: + return sampler_ids + try: + consumers = self.consumers() + previews = self.non_file_outputs() + terminals = [ + node_id + for node_id, node in self.graph.items() + if isinstance(node, dict) + and node_id not in previews + and not consumers.get(node_id) + ] + to_file = self.producers_of(terminals, previews) + to_preview = self.producers_of(list(previews), previews) + except _Unresolved: + return sampler_ids + kept = [ + node_id + for node_id in sampler_ids + if node_id in to_file or node_id not in to_preview + ] + return kept or sampler_ids + + def non_file_outputs(self) -> set: + names = self.semantics.non_file_outputs + return { + node_id + for node_id, node in self.graph.items() + if isinstance(node, dict) and node.get("class_type") in names + } + + def producers_of(self, starts: list, previews: set) -> set: + """Nodes whose output reaches one of ``starts``, never through a preview.""" + found, queue = set(starts), deque(starts) + while queue: + node = self.graph[queue.popleft()] + inputs = node.get("inputs") + if not isinstance(inputs, dict): + continue + self.scan(len(inputs)) + for value in inputs.values(): + if not _link(value): + continue + producer = str(value[0]) + if ( + producer in found + or producer in previews + or not isinstance(self.graph.get(producer), dict) + ): + continue + found.add(producer) + queue.append(producer) + return found + def consumers(self) -> dict: """Reverse links: node ID to the IDs of nodes reading one of its outputs.""" consumers: dict = {} @@ -1579,7 +1656,8 @@ def resolve_prompt_pair( Accepts ``{"prompt": api_graph, "parameters": optional_a1111_text}`` or a direct API graph. An optional saver/sampler node ID anchors branch selection. - Unanchored multiple samplers are ambiguous, even when their texts are equal. + Unanchored multiple samplers are ambiguous, even when their texts are equal + (the inspection profile first drops stages that only reach previews). ``profile`` selects the bounds; explicit ``max_*`` keywords override it. """ profile = _profile_with(profile, max_visits, max_depth, max_text_length) diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js index dcacb28..a74e2de 100644 --- a/web/js/comfy-metadata.js +++ b/web/js/comfy-metadata.js @@ -623,13 +623,14 @@ } /** - * Facts follow the server's resolution. Its `sampler_id` drives them only when - * resolved, or for an ambiguous result naming the final stage of a multistage - * chain (merged per field across the stages, independent of any selection: D5). - * Ambiguous without a named sampler (no candidates on the wire until PR3), and - * unresolved or absent over several listed samplers, merge the same way across - * every listed sampler and its stages. Otherwise (no resolution, or one listed - * sampler) exactly one listed sampler supplies them; several leave every field ambiguous. + * Facts follow the server's resolution. Its `sampler_id` drives them alone when + * resolved. Any other result naming a sampler (an ambiguous multistage chain, or an + * absent or unresolved prompt of the branch the server chose, e.g. after dropping a + * preview-only stage) merges per field across that sampler's stages, independent of + * any selection (D5). Ambiguous without a named sampler (no candidates on the wire + * until PR3), and unresolved or absent over several listed samplers, merge the same + * way across every listed sampler and its stages. Otherwise (no resolution, or one + * listed sampler) exactly one listed sampler supplies them; several leave every field ambiguous. */ function graphFacts(graph, source, resolution) { if (!object(graph)) return factSet("unresolved", source); @@ -640,7 +641,7 @@ const ctx = createContext(graph, null, true); const state = object(resolution) ? resolution.state : undefined; const named = typeof resolution?.sampler_id === "string" ? resolution.sampler_id : null; - if ((state === "resolved" || state === "ambiguous") && named !== null) { + if (["resolved", "ambiguous", "absent", "unresolved"].includes(state) && named !== null) { if (!samplerEntry(graph, named)) return factSet("unresolved", source); if (state === "resolved") return samplerFacts(ctx, named, source); const stages = new Set();