fix(inspect): drop preview-only stages from unanchored candidates
A stage whose outputs reach only non-file outputs (PreviewImage and the other
non_file_outputs) cannot have written a saved file, yet it stayed an
unanchored candidate, so a preview-only FaceDetailer made the saved branch
ambiguous (f01b).
The inspection profile now drops such stages before detailer absorption,
using two linear backward passes over the graph (charged like the consumer
map; a failed pass keeps every stage). A stage without consumers, or one that
also reaches any other terminal, is kept, and nothing is dropped when every
stage is preview-only. Raw plus detailed double saves both write files and
stay ambiguous. A file in the host temp folder may itself be a preview, so it
is inspected with PREVIEW_INSPECTION_PROFILE, which keeps those stages.
Workspace facts follow the same choice: an absent or unresolved result that
names a sampler merges facts across that sampler's stages only, so the
dropped detailer's settings no longer blur the saved file's facts.
f01b now resolves (absent negative, guider has none); its vector is updated
and the expected failure removed. The default profile is unchanged:
differential against 39dcc40 identical over 160,256 cases. Over 60,054
inspection cases no previously resolved or absent result changed.
This commit is contained in:
@@ -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",
|
||||
|
||||
+9
-9
@@ -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": "<absent>",
|
||||
"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",
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user