Compare commits

...
Author SHA1 Message Date
Vito Sansevero 5ec3178195 Merge remote-tracking branch 'origin/main' into fix/398-select-width 2026-10-27 18:19:27 -07:00
Vito Sansevero 00d31614e8 fix(system): bound Existing tags by its row at phone width (#398 review) 2026-10-27 17:54:08 -07:00
Vito Sansevero c3ef047341 fix(system): Tagging selects show their whole chosen option at 390 px (#398)
The concept's phone cap (120px) cut Existing tags ("Replace after review").
#tag-existing now never shrinks for its row text and keeps its natural width
at phone width, beside the text. The comparison expects selectsFit for every
select, and a new test walks every option of both selects at 390 and 320 px
in both themes. TESTING.md D132 records the decision.
2026-10-27 17:42:13 -07:00
Vito 44177974cb fix(scan): read sampler-less graphs from the one wired text, retire the heuristic (#252) (#397)
* fix(scan): read sampler-less graphs from the one wired text, retire the heuristic (#252)

A graph where no node takes a positive or negative input fell back to the
pre-redesign _extract_readable_prompt heuristic: the last node with any string
text input won, and with none it read stale UI workflow widgets. It stored LoRA
syntax, display-node reports, install notes, model file names and numbers.

The scan now asks sampler_less_prompt: only the executed API graph counts; a
candidate is a node with a text input that another node consumes; blank and
numeric texts are never prompts; this repository's own positive nodes are
authoritative; one distinct text links, several (or an unreadable wired text
that no candidate builds) stay unlinked and try the recorded-run attribution.
The heuristic and its helpers are removed; A1111 files read the parsed
positive prompt directly.

* fix(scan): refuse loader side outputs, seeded templates and keep A1111 text (#252)

Review findings: an unreadable text link counts as built from a candidate
only through that node's first output, so a LoRA loader's trigger-word output
is unknown, not its text. A node with a seed input holds a template, never the
prompt that ran. A sampler-less graph the rule cannot decide still links the
A1111 positive a metadata saver wrote beside it, as the old scan did. Only
plain numbers are numeric; words such as nan or inf are prompts.

* fix(scan): linear plain-number check for sampler-less texts (#252)

The numeric pattern had overlapping quantifiers that backtrack quadratically
on a long digit run ending in a non-digit; it now has none.
2026-10-28 00:32:30 +00:00
10 changed files with 458 additions and 337 deletions
+10 -6
View File
@@ -3200,12 +3200,16 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
again"; an Unload that found nothing loaded only reads the status again. At phone width
the concept's 120px select cap cuts every engine label, so `#tag-model` alone keeps its
natural width there and its row wraps the select under the text (`workspace.css`
`/* D132 */`); Existing tags stays as the concept draws it, cut at 390 as there. The
comparison maps `selectsFit` only for the engine select and measures each row's
`flexWrap` and whether its control sits under the text. Pinned by `auto-tagging matches
the concept` (both themes and sizes), `auto-tagging at 390 wraps only the engine row`,
`auto-tagging engine select fits at 320 wide` and the mutation check (it includes a
wrapped row) (system-tagging-concept.spec), and the model, Download, Unload and Preview
`/* D132 */`). The concept's cap cuts Existing tags too ("Replace after review"); by the
maintainer's decision (#398) that parity is not kept: `#tag-existing` never shrinks for
its row text and at phone width keeps its natural width beside the text (its row does
not wrap; the text column narrows). 1600 is as the concept draws it. The comparison
expects `selectsFit` true for every select and measures each row's `flexWrap` and
whether its control sits under the text. Pinned by `auto-tagging matches the concept`
(both themes and sizes), `auto-tagging at 390 wraps only the engine row`, `auto-tagging
selects fit every option at 390/320 wide` (every option of both selects, both themes, no
page scroll) and the mutation check (a wrapped row, a squeezed engine and Existing tags
select) (system-tagging-concept.spec), and the model, Download, Unload and Preview
tests of workspace-system-tagging.spec.
- **D133** (Auto-tagging, S11): a "Suggestions per image" row (`#tag-suggestions`,
number 1–100, host key `autotag_suggestion_limit`) follows the two confidence rows:
-193
View File
@@ -1107,196 +1107,3 @@ class PromptManagerAPI(
result["parameters"][field] = metadata[field]
return result
def _extract_readable_prompt(self, parsed_data):
"""Extract human-readable prompt text from ComfyUI/A1111 data."""
def safe_to_string(value):
if isinstance(value, str):
return value
elif isinstance(value, list):
return " ".join(str(item) for item in value if item)
elif value is not None:
return str(value)
return None
# First check if we already extracted a positive prompt (A1111 format)
if parsed_data.get("positive_prompt"):
return parsed_data["positive_prompt"]
# Check if prompt is already a string
if isinstance(parsed_data.get("prompt"), str):
return parsed_data["prompt"]
# Check if prompt is a simple value that can be converted
if parsed_data.get("prompt") and not isinstance(
parsed_data.get("prompt"), dict
):
return safe_to_string(parsed_data["prompt"])
prompt_data = parsed_data.get("prompt")
if isinstance(prompt_data, dict):
# Use the enhanced logic from parse-metadata.py
positive_prompt = self._extract_positive_prompt_from_comfyui_data(
prompt_data
)
if positive_prompt:
return positive_prompt
# Check workflow data if available
workflow_data = parsed_data.get("workflow")
if isinstance(workflow_data, dict):
positive_prompt = self._extract_positive_prompt_from_comfyui_data(
workflow_data
)
if positive_prompt:
return positive_prompt
# Check parameters for positive prompt
if parsed_data.get("parameters", {}).get("positive"):
return safe_to_string(parsed_data["parameters"]["positive"])
return None
def _get_node_inputs(self, node):
"""Safely get inputs from a node, handling both dict and list formats.
Old format: inputs is a dict with direct key-value pairs
inputs = {"text": "my prompt", "seed": 123}
New format: inputs is a list of connection objects
inputs = [
{"name": "text", "type": "STRING", "link": null,
"widget": {"name": "text"}},
{"name": "clip", "type": "CLIP", "link": 11}
]
"""
if not isinstance(node, dict):
return {}
inputs = node.get("inputs", {})
# If inputs is already a dict, return it
if isinstance(inputs, dict):
return inputs
# If inputs is a list, convert to dict format
if isinstance(inputs, list):
inputs_dict = {}
for input_item in inputs:
if isinstance(input_item, dict) and "name" in input_item:
name = input_item["name"]
inputs_dict[name] = input_item
return inputs_dict
return {}
def _find_text_in_node(self, node):
"""Try to find text content in a node using various strategies.
Handles both old and new workflow formats.
"""
if not isinstance(node, dict):
return None
# Strategy 1: Check normalized inputs for 'text' field
inputs = self._get_node_inputs(node)
if "text" in inputs and isinstance(inputs["text"], str):
return inputs["text"]
# Strategy 2: For text encoder nodes, check widgets_values
class_type = node.get("class_type", node.get("type", ""))
text_encoder_types = [
"CLIPTextEncode",
"CLIPTextEncodeSDXL",
"CLIPTextEncodeSDXLRefiner",
"CLIPTextEncodeFlux",
"PromptManager",
"PromptManagerText",
"BNK_CLIPTextEncoder",
"Text Encoder",
"CLIP Text Encode",
]
if any(
encoder_type.lower() in class_type.lower()
for encoder_type in text_encoder_types
):
widgets_values = node.get("widgets_values", [])
if widgets_values and len(widgets_values) > 0:
if isinstance(widgets_values[0], str) and widgets_values[0].strip():
return widgets_values[0]
return None
def _extract_positive_prompt_from_comfyui_data(self, data):
"""Extract positive prompt from ComfyUI data.
Handles both old and new formats.
"""
if not isinstance(data, dict):
return None
# Build nodes dictionary
nodes_by_id = {}
if "nodes" in data:
# Handle nodes array format
for node in data["nodes"]:
if isinstance(node, dict):
nid = node.get("id")
if nid is not None:
nodes_by_id[nid] = node
else:
# Handle flat dictionary format (node_id -> node_data)
for nid_str, node in data.items():
try:
nid = int(nid_str)
except (ValueError, TypeError):
nid = nid_str
if isinstance(node, dict):
if "id" in node:
nid = node["id"]
nodes_by_id[nid] = node
if not nodes_by_id:
return None
# First, try to find positive/negative connection pattern
pos_id = None
for node in nodes_by_id.values():
if isinstance(node, dict):
inputs = self._get_node_inputs(node)
if "positive" in inputs and "negative" in inputs:
try:
pos_input = inputs["positive"]
if isinstance(pos_input, list) and len(pos_input) > 0:
pos_id = int(pos_input[0])
break
except (ValueError, TypeError, IndexError):
continue
# Get text from the positive node
if pos_id is not None and pos_id in nodes_by_id:
text_val = self._find_text_in_node(nodes_by_id[pos_id])
if text_val:
return text_val
# Fallback: find any text encoder node with text content
text_nodes = []
for node in nodes_by_id.values():
if isinstance(node, dict):
text_val = self._find_text_in_node(node)
if text_val:
# Try to determine if this is positive or negative
node_title = node.get("title", "").lower()
if "neg" not in node_title and "negative" not in node_title:
# Prioritize non-negative prompts
text_nodes.insert(0, text_val)
else:
text_nodes.append(text_val)
# Return the first positive-looking prompt
if text_nodes:
return text_nodes[0]
return None
+19 -8
View File
@@ -47,7 +47,7 @@ try:
extract_generation_params,
parse_page_json,
)
from ...utils.prompt_pair_resolver import resolve_prompt_pair
from ...utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
from ...utils.review_capture import reviewable
except ImportError:
from database.pair_ingestion import blank_positive, ingest_resolved_pair
@@ -62,7 +62,7 @@ except ImportError:
extract_generation_params,
parse_page_json,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
from utils.prompt_pair_resolver import resolve_prompt_pair, sampler_less_prompt
from utils.review_capture import reviewable
@@ -1754,13 +1754,24 @@ class AdminRoutesMixin:
canonical.get("parameters"), str
):
return resolution["state"]
elif canonical.get("prompt"):
# No node takes a positive or negative input (a guider or wrapper
# graph): the one wired text, never a guess (#252).
found = sampler_less_prompt(canonical["prompt"])
prompt_text = found["text"]
if prompt_text is None and isinstance(canonical.get("parameters"), str):
# A metadata saver also wrote A1111 text: its positive prompt.
parsed = self._parse_comfyui_prompt(metadata)["positive_prompt"]
prompt_text = parsed.strip() if parsed and parsed.strip() else None
if prompt_text is None:
linked = self._link_recorded_run(
media_file, canonical, detailed, history
)
if linked is not None:
return linked
return "unresolved" if found["state"] == "ambiguous" else "found"
else:
parsed = self._parse_comfyui_prompt(metadata)
if not (parsed.get("prompt") or parsed.get("parameters")):
return None
prompt_text = self._extract_readable_prompt(parsed)
if prompt_text is not None and not isinstance(prompt_text, str):
prompt_text = str(prompt_text)
prompt_text = self._parse_comfyui_prompt(metadata)["positive_prompt"]
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
+30 -15
View File
@@ -127,9 +127,9 @@ function mapTaggingConcept(reference, { template, phone }) {
actions: { ...reference.actions, buttons: [preview, ['Download again', ''], ['Unload', '']] },
// D134: the prompt-tag batch, reviewed first.
operations: [{ tree: ['div', 'div.row-actions'], title: 'Tag output images', description: BATCH, actions: [['Review', '']] }],
// D132: the engine select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts it;
// `#tag-model` alone may grow there). Existing tags keeps the concept's own fit, cut or not.
selectsFit: reference.selectsFit.map((fit, index) => (index === 0 ? true : fit)),
// D132 (#398): every select shows its whole chosen option at every width (at 390 the concept's 120px cap cuts
// them; production lets each grow past it).
selectsFit: reference.selectsFit.map(() => true),
};
}
@@ -168,16 +168,28 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
});
}
// D132 below the concept's smallest size: on a 320px phone the engine select still shows its whole option.
for (const theme of THEMES) test(`auto-tagging engine select fits at 320 wide ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 320, height: 700 });
const production = await productionTagging(page, theme);
for (const model of ['hf', 'gguf', 'wd14-swinv2']) {
await page.locator('#workspace-main #tag-model').selectOption(model);
expect((await page.evaluate(measureTagging, '#workspace-main')).selectsFit[0], model).toBe(true);
}
expect(production.pageScroll).toBe(false);
});
// D132 (#398) at phone width and below the concept's smallest size: every option of every select, once chosen, shows
// whole, with no page scroll.
for (const theme of THEMES) for (const width of [390, 320]) {
test(`auto-tagging selects fit every option at ${width} wide ${theme}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 844 });
await productionTagging(page, theme);
await page.locator('#workspace-main #tag-existing').selectOption('replace_after_review');
await page.locator('#workspace-main section.surface').screenshot({ path: testInfo.outputPath(`production-tagging-selects-${theme}-${width}.png`) });
const selects = page.locator('#workspace-main section.surface > .setting-row > select');
await expect(selects).toHaveCount(2);
for (let index = 0; index < 2; index += 1) {
const select = selects.nth(index);
const values = await select.evaluate(node => [...node.options].map(option => option.value));
for (const value of values) {
await select.selectOption(value);
const measured = await page.evaluate(measureTagging, '#workspace-main');
expect(measured.selectsFit, `${await select.getAttribute('id')}=${value}`).toEqual([true, true]);
expect(measured.pageScroll, value).toBe(false);
}
}
});
}
test('auto-tagging comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
@@ -222,6 +234,9 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => {
const undoSqueeze = await mutate(page, { css: '#workspace-main #tag-model { max-width: 60px !important; min-width: 0 !important; }' });
expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.0']));
await undoSqueeze();
const undoSqueezeExisting = await mutate(page, { css: '#workspace-main #tag-existing { max-width: 60px !important; min-width: 0 !important; }' });
expect(await paths()).toEqual(expect.arrayContaining(['selectsFit.1']));
await undoSqueezeExisting();
// A row that wraps where the concept's does not.
const undoWrap = await mutate(page, { css: '#workspace-main .setting-row:has(> #tag-existing) { flex-wrap: wrap; }' });
expect(await paths()).toEqual(['rows.1.flexWrap']);
@@ -233,14 +248,14 @@ test('auto-tagging comparison detects mutations', async ({ page, request }) => {
expect(diffInventory(expected, await measure())).toEqual([]);
});
// D132 at phone width, unmapped: only the engine row wraps; Existing tags keeps the concept's cut select.
// D132 at phone width: only the engine row wraps; Existing tags grows beside its text instead (#398).
test('auto-tagging at 390 wraps only the engine row', async ({ page, request }) => {
await page.setViewportSize({ width: 390, height: 844 });
const concept = await conceptTagging(page, 'dark');
const production = await productionTagging(page, 'dark');
expect(diffInventory(mapTaggingConcept(concept, await savedTemplate(request, page)), production)).toEqual([]);
expect(production.rows.map(row => row.flexWrap)).toEqual(['wrap', 'nowrap', 'nowrap', 'nowrap', 'nowrap']);
expect(production.selectsFit[1]).toBe(concept.selectsFit[1]);
expect(production.selectsFit).toEqual([true, true]);
});
/** Runs in the page: the open review dialog: its title, body and the concept's tokens. */
+13 -21
View File
@@ -846,6 +846,14 @@ class TestSettingsReadMonitorThroughModule(AdminAPITestCase):
self.assertEqual(data["settings"]["gallery_root_paths"], ["output"])
def _wired_prompt(text):
"""A sampler-less graph whose one encoder feeds a guider (#252)."""
return {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": text}},
"2": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
}
def _raise(*args, **kwargs):
raise RuntimeError("boom")
@@ -2186,11 +2194,7 @@ class TestOutputScanInternals(AdminAPITestCase):
def test_ingest_outcomes(self):
f = Path("gen.png")
meta = {
"prompt": json.dumps(
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}}}
)
}
meta = {"prompt": json.dumps(_wired_prompt("a cat"))}
self.assertIsNone(self.api._ingest_scanned_file(f, {}))
self.assertIsNone(self.api._ingest_scanned_file(f, {"prompt": "{}"}))
self.assertEqual(
@@ -2202,31 +2206,19 @@ class TestOutputScanInternals(AdminAPITestCase):
def test_ingest_link_failures_are_tolerated(self):
f = Path("gen.png")
meta = {
"prompt": json.dumps(
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a dog"}}}
)
}
meta = {"prompt": json.dumps(_wired_prompt("a dog"))}
self.api.db.link_image_to_prompt = _raise
self.assertEqual(self.api._ingest_scanned_file(f, meta), "added")
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
def test_ingest_save_failure_counts_as_found(self):
f = Path("gen.png")
meta = {
"prompt": json.dumps(
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a bird"}}}
)
}
meta = {"prompt": json.dumps(_wired_prompt("a bird"))}
self.api.db.save_prompt = lambda *a, **k: None
self.assertEqual(self.api._ingest_scanned_file(f, meta), "found")
def test_batch_counts_and_per_file_errors(self):
meta = {
"prompt": json.dumps(
{"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a fish"}}}
)
}
meta = {"prompt": json.dumps(_wired_prompt("a fish"))}
self.api.db.get_prompt_by_hash = _raise
counts = self.api._ingest_scan_batch_sync(
[(Path("a.png"), meta), (Path("b.png"), {})]
@@ -2915,7 +2907,7 @@ class TestScanReadsVideos(AdminAPITestCase):
shutil.which("ffmpeg") and shutil.which("ffprobe"), "ffmpeg not installed"
)
async def test_scan_adds_prompts_found_in_videos(self):
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}}}
graph = _wired_prompt("a cat")
_write_mp4(
self.output_dir / "clip.mp4", json.dumps({"prompt": json.dumps(graph)})
)
-92
View File
@@ -835,7 +835,6 @@ class TestMetadataParsing(unittest.TestCase):
self.assertEqual(parsed["positive_prompt"], "a cat")
self.assertEqual(parsed["negative_prompt"], "dog")
self.assertEqual(parsed["parameters"]["parameters"], meta["parameters"])
self.assertEqual(self.api._extract_readable_prompt(parsed), "a cat")
def test_parse_comfyui_fields(self):
meta = {
@@ -856,97 +855,6 @@ class TestMetadataParsing(unittest.TestCase):
self.assertEqual(parsed["prompt"], "plain text")
self.assertEqual(parsed["workflow"], {"nodes": []})
def test_extract_readable_prompt_variants(self):
extract = self.api._extract_readable_prompt
self.assertEqual(extract({"prompt": "plain"}), "plain")
self.assertEqual(extract({"prompt": ["a", "", "b"]}), "a b")
self.assertEqual(extract({"prompt": 42}), "42")
self.assertEqual(
extract({"prompt": None, "parameters": {"positive": ["x", "y"]}}), "x y"
)
self.assertIsNone(extract({"prompt": {}, "parameters": {}}))
graph = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "neg"}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
},
}
self.assertEqual(extract({"prompt": graph}), "pos")
self.assertEqual(extract({"prompt": {"x": 1}, "workflow": graph}), "pos")
def test_node_inputs_and_text_lookup(self):
self.assertEqual(self.api._get_node_inputs("nope"), {})
self.assertEqual(self.api._get_node_inputs({"inputs": 5}), {})
self.assertEqual(self.api._get_node_inputs({"inputs": {"a": 1}}), {"a": 1})
listed = self.api._get_node_inputs(
{"inputs": [{"name": "text", "link": None}, {"nope": 1}, "junk"]}
)
self.assertEqual(list(listed), ["text"])
self.assertIsNone(self.api._find_text_in_node(None))
self.assertEqual(self.api._find_text_in_node({"inputs": {"text": "hi"}}), "hi")
self.assertEqual(
self.api._find_text_in_node(
{"type": "CLIPTextEncode", "widgets_values": ["w"]}
),
"w",
)
self.assertIsNone(
self.api._find_text_in_node(
{"type": "CLIPTextEncode", "widgets_values": [" "]}
)
)
self.assertIsNone(self.api._find_text_in_node({"type": "KSampler"}))
def test_positive_prompt_from_nodes_array_and_fallbacks(self):
extract = self.api._extract_positive_prompt_from_comfyui_data
self.assertIsNone(extract("x"))
self.assertIsNone(extract({}))
self.assertIsNone(extract({"nodes": []}))
workflow = {
"nodes": [
{"id": 1, "type": "CLIPTextEncode", "widgets_values": ["pos"]},
{
"id": 2,
"type": "CLIPTextEncode",
"widgets_values": ["neg"],
"title": "Negative",
},
{"id": 3, "type": "KSampler", "inputs": [{"name": "seed", "link": 4}]},
"junk",
]
}
self.assertEqual(extract(workflow), "pos")
only_negative = {
"nodes": [
{
"id": 2,
"type": "CLIPTextEncode",
"widgets_values": ["neg"],
"title": "neg",
},
]
}
self.assertEqual(extract(only_negative), "neg")
bad_link = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "pos"}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": "oops", "negative": []},
},
"4": {
"class_type": "KSampler",
"inputs": {"positive": ["zz", 0], "negative": []},
},
}
self.assertEqual(extract(bad_link), "pos")
if __name__ == "__main__":
unittest.main()
+158 -1
View File
@@ -115,7 +115,12 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(second["pairs_added"], 0)
def test_legacy_single_prompt_second_pass_not_reported_as_new_link(self):
meta = {"prompt": json.dumps({"1": graph()["1"]})}
# A sampler-less graph: one encoder wired into a guider (#252).
single = {
"1": graph()["1"],
"2": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
}
meta = {"prompt": json.dumps(single)}
path = self.output_dir / "old.png"
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
self.assertEqual(self.api._ingest_scanned_file(path, meta), "unchanged")
@@ -292,3 +297,155 @@ class TestPairScan(AdminAPITestCase):
self.api.db.get_image_prompt_info(str(path))["workflow_data"], workflow
)
self.assertEqual(self.api._ingest_scanned_file(path, metadata), "unchanged")
def flux_graph(text="a red fox", encoder="CLIPTextEncode"):
"""A sampler-less Flux graph: the prompt reaches a guider's conditioning (#252)."""
return {
"1": {"class_type": "UNETLoader", "inputs": {"unet_name": "flux.safetensors"}},
"2": {"class_type": "DualCLIPLoader", "inputs": {"clip_name1": "t5"}},
"6": {"class_type": encoder, "inputs": {"text": text, "clip": ["2", 0]}},
"22": {
"class_type": "BasicGuider",
"inputs": {"model": ["1", 0], "conditioning": ["6", 0]},
},
"13": {
"class_type": "SamplerCustomAdvanced",
"inputs": {"guider": ["22", 0], "sigmas": ["17", 0]},
},
"17": {"class_type": "BasicScheduler", "inputs": {"steps": 20}},
"9": {"class_type": "SaveImage", "inputs": {"images": ["13", 0]}},
}
class TestSamplerLessScan(AdminAPITestCase):
"""Graphs where no node takes a positive or negative input (#252)."""
def _scanned(self, name, prompt_graph, workflow=None):
meta = {"prompt": json.dumps(prompt_graph)}
if workflow is not None:
meta["workflow"] = json.dumps(workflow)
return self.api._ingest_scanned_file(self.output_dir / name, meta)
def _texts(self):
return [p["text"] for p in self.api.db.get_recent_prompts()["prompts"]]
def test_the_one_wired_prompt_is_linked(self):
self.assertEqual(self._scanned("flux.png", flux_graph()), "added")
self.assertEqual(self._texts(), ["a red fox"])
def test_a_text_node_feeding_the_encoder_is_one_prompt(self):
g = flux_graph(text=["30", 0])
g["30"] = {"class_type": "TextInput", "inputs": {"text": "a red fox"}}
self.assertEqual(self._scanned("textinput.png", g), "added")
self.assertEqual(self._texts(), ["a red fox"])
def test_a_display_node_that_feeds_nothing_is_never_the_prompt(self):
# A real scan stored a "=== GIT CLONE REPORT ===" display text this way.
g = flux_graph()
g["40"] = {
"class_type": "easy showAnything",
"inputs": {"text": "=== GIT CLONE REPORT ===\nTotal repositories: 1"},
}
self.assertEqual(self._scanned("report.png", g), "added")
self.assertEqual(self._texts(), ["a red fox"])
def test_only_unconsumed_texts_store_nothing(self):
g = {
"40": {"class_type": "easy showAnything", "inputs": {"text": "report"}},
"41": {"class_type": "Textbox", "inputs": {"text": "install note"}},
}
self.assertEqual(self._scanned("notes.png", g), "found")
self.assertEqual(self._texts(), [])
def test_two_wired_texts_are_ambiguous_and_stay_unlinked(self):
# A LoRA loader's text input is wired too: the scan stored "<lora:...>".
g = flux_graph()
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {
"model": ["1", 0],
"clip": ["2", 0],
"text": "<lora:style:1.00>",
},
}
g["22"]["inputs"]["model"] = ["50", 0]
g["6"]["inputs"]["clip"] = ["50", 1]
self.assertEqual(self._scanned("lora.png", g), "unresolved")
self.assertEqual(self._texts(), [])
def test_an_untraceable_wired_text_makes_the_graph_ambiguous(self):
# The encoder's text comes from a node no rule reads: never guess the other.
g = flux_graph(text=["60", 0])
g["60"] = {"class_type": "SomeLLMNode", "inputs": {"seed": 1}}
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
}
g["22"]["inputs"]["model"] = ["50", 0]
self.assertEqual(self._scanned("llm.png", g), "unresolved")
self.assertEqual(self._texts(), [])
def test_this_repositorys_prompt_node_is_authoritative(self):
g = flux_graph(text="a red fox", encoder="PromptManager")
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
}
g["22"]["inputs"]["model"] = ["50", 0]
self.assertEqual(self._scanned("own.png", g), "added")
self.assertEqual(self._texts(), ["a red fox"])
def test_a_numeric_text_is_never_stored_as_a_prompt(self):
self.assertEqual(self._scanned("number.png", flux_graph(text="2048")), "found")
self.assertEqual(self._texts(), [])
def test_workflow_widget_values_are_never_read(self):
# The API text is linked from a node no rule reads; the UI workflow
# still holds a stale widget value, which the old scan stored.
g = flux_graph(text=["70", 0])
g["70"] = {"class_type": "PromptBuilder", "inputs": {"prompt": "x", "n": 2}}
workflow = {
"nodes": [
{"id": 6, "type": "CLIPTextEncode", "widgets_values": ["stale text"]}
]
}
self.assertEqual(self._scanned("stale.png", g, workflow), "unresolved")
self.assertEqual(self._texts(), [])
def test_a_lora_loaders_trigger_word_output_is_not_its_text(self):
# The encoder reads the loader's trigger-word output (slot 2), not its
# text input: the prompt is unknown, never the "<lora:...>" syntax.
g = flux_graph(text=["50", 2])
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
}
g["22"]["inputs"]["model"] = ["50", 0]
self.assertEqual(self._scanned("trigger.png", g), "unresolved")
self.assertEqual(self._texts(), [])
def test_a_seeded_text_node_holds_a_template_not_the_prompt(self):
g = flux_graph(text=["80", 0])
g["80"] = {
"class_type": "RandomPrompt",
"inputs": {"text": "a {red|blue} fox", "seed": 5},
}
self.assertEqual(self._scanned("random.png", g), "unresolved")
self.assertEqual(self._texts(), [])
def test_a1111_parameters_beside_an_undecided_graph_are_read(self):
# Metadata savers write both; the old scan read the A1111 positive.
g = flux_graph()
g["50"] = {
"class_type": "Lora Loader (LoraManager)",
"inputs": {"model": ["1", 0], "text": "<lora:style:1.00>"},
}
g["22"]["inputs"]["model"] = ["50", 0]
meta = {
"prompt": json.dumps(g),
"parameters": "a red fox\nNegative prompt: blur\nSteps: 20",
}
path = self.output_dir / "saver.png"
self.assertEqual(self.api._ingest_scanned_file(path, meta), "added")
self.assertEqual(self._texts(), ["a red fox"])
+112
View File
@@ -0,0 +1,112 @@
"""``sampler_less_prompt``: the one wired text of a graph with no sampler (#252)."""
import time
import unittest
from utils.prompt_pair_resolver import sampler_less_prompt
def _guided(**nodes):
"""``nodes`` plus a guider consuming node ``"1"``."""
graph = {"9": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}}}
graph.update(nodes)
return graph
class TestSamplerLessPrompt(unittest.TestCase):
def test_not_a_graph_or_too_large_is_absent(self):
for graph in (None, [], {}, "text"):
self.assertEqual(sampler_less_prompt(graph)["state"], "absent")
big = {str(i): {"class_type": "X", "inputs": {}} for i in range(3)}
self.assertEqual(sampler_less_prompt(big, max_visits=2)["state"], "absent")
def test_one_literal_text(self):
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": " a fox "}}})
self.assertEqual(
sampler_less_prompt(graph),
{"state": "resolved", "text": "a fox", "texts": ["a fox"]},
)
def test_a_number_literal_is_never_a_prompt(self):
for value in (2048, "2048", "1.5", " 7 "):
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": value}}})
self.assertEqual(sampler_less_prompt(graph)["state"], "absent", value)
def test_a_long_digit_run_with_a_letter_is_text_and_fast(self):
text = "9" * 50_000 + "x"
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": text}}})
started = time.monotonic()
self.assertEqual(sampler_less_prompt(graph)["text"], text)
self.assertLess(time.monotonic() - started, 1.0)
def test_number_like_words_are_prompts(self):
for value in ("nan", "inf", "Infinity", "1_000"):
graph = _guided(**{"1": {"class_type": "Enc", "inputs": {"text": value}}})
self.assertEqual(sampler_less_prompt(graph)["text"], value)
def test_an_own_node_with_unreadable_text_beside_another_text_is_ambiguous(self):
graph = _guided(
**{
"1": {"class_type": "PromptManager", "inputs": {"text": ["5", 0]}},
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
"2": {"class_type": "Enc", "inputs": {"text": "<lora:x:1>"}},
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
}
)
self.assertEqual(sampler_less_prompt(graph)["state"], "ambiguous")
def test_unhashable_class_type_is_an_unknown_node_not_a_crash(self):
graph = _guided(**{"1": {"class_type": ["x"], "inputs": {"text": "a fox"}}})
self.assertEqual(sampler_less_prompt(graph)["text"], "a fox")
def test_own_negative_node_is_not_authoritative(self):
graph = _guided(
**{
"1": {"class_type": "Enc", "inputs": {"text": "a fox"}},
"2": {
"class_type": "PromptManagerNegative",
"inputs": {"text": "blur"},
},
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
}
)
found = sampler_less_prompt(graph)
self.assertEqual(found["state"], "ambiguous")
self.assertEqual(found["texts"], ["a fox", "blur"])
def test_own_node_text_wins_over_an_untraceable_text(self):
graph = _guided(
**{
"1": {"class_type": "PromptManagerText", "inputs": {"text": "a fox"}},
"2": {"class_type": "Enc", "inputs": {"text": ["5", 0]}},
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
"6": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
}
)
self.assertEqual(sampler_less_prompt(graph)["text"], "a fox")
def test_two_own_texts_are_ambiguous(self):
graph = _guided(
**{
"1": {"class_type": "PromptManager", "inputs": {"text": "a fox"}},
"2": {"class_type": "PromptManager", "inputs": {"text": "a cat"}},
"3": {"class_type": "Sink", "inputs": {"c": ["2", 0]}},
}
)
self.assertEqual(sampler_less_prompt(graph)["state"], "ambiguous")
def test_only_an_untraceable_text_is_ambiguous_with_nothing_to_show(self):
graph = _guided(
**{
"1": {"class_type": "Enc", "inputs": {"text": ["5", 0]}},
"5": {"class_type": "LLM", "inputs": {"seed": 3}},
}
)
self.assertEqual(
sampler_less_prompt(graph),
{"state": "ambiguous", "text": None, "texts": []},
)
if __name__ == "__main__":
unittest.main()
+112
View File
@@ -2578,3 +2578,115 @@ def resolve_text_input(
return resolver.text(inputs.get(input_name), "string")[0]
except _Unresolved:
return None
_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
def _numeric(text: str) -> bool:
"""Whether ``text`` is a plain number (a widget value such as ``2048``)."""
return _NUMBER.fullmatch(text) is not None
def sampler_less_prompt(graph: Any, *, max_visits: int = 500) -> Dict[str, Any]:
"""The positive prompt of an API graph with no sampler pair, if one (#252).
For graphs ``_no_sampler`` finds ``absent`` (no node takes a ``positive``
or ``negative`` input, such as a Flux guider or a video wrapper). Only the
executed API graph is read, never the UI workflow. A candidate is a node
with a ``text`` input whose output another node consumes: a text that
feeds nothing did not make the image. Its text is traced as a STRING
(``resolve_text_input``); a linked text no rule reads is unknown unless
it is built from a candidate's text (``_covered``). Blank and
numeric texts are never prompts, and a node with a seed input holds a
template, not the prompt (unknown). This repository's own positive-text
nodes are authoritative: when they give a text, only they count.
Returns ``{"state", "text", "texts"}``: ``resolved`` with the one distinct
text; ``ambiguous`` when several texts or an unknown one could be the
prompt (``texts`` lists the readable ones); ``absent`` when none.
"""
absent = {"state": "absent", "text": None, "texts": []}
if not isinstance(graph, dict) or not graph or len(graph) > max_visits:
return absent
nodes = {str(key): value for key, value in graph.items()}
consumed = {
str(value[0])
for node in nodes.values()
if isinstance(node, dict) and isinstance(node.get("inputs"), dict)
for value in node["inputs"].values()
if _link(value)
}
own: list = []
other: list = []
unknown = False
for node_id, node in nodes.items():
inputs = node.get("inputs") if isinstance(node, dict) else None
if node_id not in consumed or not _has_text_input(node):
continue
if _seeded(inputs):
# Generated at run time (a wildcard or a language model): its
# text input is a template, not the prompt that ran.
unknown = True
continue
value = inputs["text"]
text = resolve_text_input(nodes, node_id, "text", max_visits=max_visits)
if text is None:
# A literal that is not a string (a number) is never a prompt.
if _link(value) and not _covered(nodes, value):
unknown = True
continue
text = text.strip()
if not text or _numeric(text):
continue
kind = node.get("class_type")
if isinstance(kind, str) and kind in _OWN_CLASSES and "Negative" not in kind:
own.append(text)
else:
other.append(text)
texts = list(dict.fromkeys(own or other))
if len(texts) == 1 and (own or not unknown):
return {"state": "resolved", "text": texts[0], "texts": texts}
if texts or unknown:
return {"state": "ambiguous", "text": None, "texts": texts}
return absent
def _has_text_input(node: Any) -> bool:
inputs = node.get("inputs") if isinstance(node, dict) else None
return isinstance(inputs, dict) and "text" in inputs
def _seeded(inputs: dict) -> bool:
"""A seed input: the node's string output is a run-time value (the
resolver's ``runtime-value`` rule)."""
return any(isinstance(name, str) and name.endswith("seed") for name in inputs)
def _covered(nodes: dict, link: list) -> bool:
"""Whether an unreadable text link is built from a candidate's text.
True when a node upstream of ``link`` is read through its first output
(slot 0, the legacy encoder rule's text) and has an unseeded ``text``
input: that candidate is read on its own, and whatever else is joined to
it at run time (a caption, a language model) is no stored prompt to
choose. Another output of such a node (a loader's trigger words) is not
its text. False when the text comes only from inputs no candidate holds
(a builder's ``prompt`` field): the prompt is unknown then.
"""
seen, stack = set(), [(str(link[0]), link[1])]
while stack:
node_id, slot = stack.pop()
if (node_id, slot) in seen:
continue
seen.add((node_id, slot))
node = nodes.get(node_id)
inputs = node.get("inputs") if isinstance(node, dict) else None
if not isinstance(inputs, dict):
continue
if "text" in inputs and slot == 0 and not _seeded(inputs):
return True
stack.extend(
(str(value[0]), value[1]) for value in inputs.values() if _link(value)
)
return False
+4 -1
View File
@@ -433,10 +433,13 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
}
/* D132: at phone width the concept's 120px cap cuts every engine label ("WD14 SwinV2 · ~400 MB"); the Tagging model
select alone keeps its natural width there (its widest engine label) and, with no room beside the row text, wraps
under it. */
under it. Existing tags (#398) never shrinks for its row text and, past the cap, keeps its natural width beside the
text ("Replace after review"), bounded by its row like the engine select. */
.setting-row #tag-existing { flex-shrink: 0; /* D132 */ }
@media (max-width: 700px) {
#workspace-main .setting-row:has(> #tag-model) { flex-wrap: wrap; /* D132 */ }
#workspace-main .setting-row #tag-model { flex-shrink: 0; max-width: 100% !important; /* D132 */ }
#workspace-main .setting-row #tag-existing { max-width: 100% !important; /* D132 */ }
}
/* D109: System › Diagnostics & logs' added Lines field sits in the concept's toolbar like its Level select, and a value