A PromptSearchList wired straight into CLIPTextEncode left the legacy-parser
fallback tracing the positive to the node's search query ("fox"). Both the
output scan and the live monitor stored that query as a prompt and linked
every image of the batch to it.
The fallback now leaves such files unlinked (the usual unresolved outcome)
when its answer is the search text of a PromptSearchList upstream of a
sampler's positive. An empty search text, and a search feeding only the
negative, keep their previous outcome. The parity gate states f22 as a
deliberate departure from the #170 parser, and the README says to wire the
Search List through Prompt Manager Text to get the images linked.
678 lines
24 KiB
Python
678 lines
24 KiB
Python
"""The per-file prompt decision shared by the output scan and the monitor (#247).
|
|
|
|
``decide_file_prompt`` is the ladder: which prompt one output file names,
|
|
from its metadata alone plus, when the graph cannot decide, the save node
|
|
recorded history says wrote it. It does no file or database I/O; the caller
|
|
reads the metadata, the file size and history (through ``lookup``) and
|
|
stores the ``Decision``.
|
|
|
|
The default resolver profile decides first, as ingestion always has. Only
|
|
for a file it leaves ambiguous or unresolved does the ladder ask the
|
|
inspector's resolve (``inspection_resolution``: the inspection profile, then
|
|
the recorded saver anchor; never the file-name saver anchor, whose files go
|
|
to Needs review by the #390 ruling), then the #170 browser parser (the legacy floor).
|
|
The policies differ in one place: for a file the inspector calls ambiguous,
|
|
the scan keeps the legacy floor and the monitor stores nothing. Junk text
|
|
(``junk_text``) is never stored, whichever step found it.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any, Callable, Mapping, Optional, Union
|
|
|
|
from .generation_params import (
|
|
PLACEHOLDERS,
|
|
GenerationParamsError,
|
|
extract_generation_params,
|
|
parse_page_json,
|
|
)
|
|
from .prompt_graph import (
|
|
PROMPT_MANAGER_TYPES,
|
|
_positive_roots,
|
|
is_negative_input,
|
|
metadata_opted_out,
|
|
resolve_text,
|
|
save_setting,
|
|
side_opted_out,
|
|
)
|
|
from .prompt_inspector import inspection_resolution
|
|
from .prompt_pair_resolver import _numeric, resolve_prompt_pair, sampler_less_prompt
|
|
from .review_capture import reviewable
|
|
|
|
try:
|
|
from ..database.pair_ingestion import blank_positive
|
|
except ImportError:
|
|
from database.pair_ingestion import blank_positive
|
|
|
|
|
|
class Policy(Enum):
|
|
SCAN = "scan"
|
|
MONITOR = "monitor"
|
|
|
|
|
|
PAIR = "pair"
|
|
POSITIVE = "positive"
|
|
HISTORY = "history"
|
|
NOT_SAVED = "not_saved"
|
|
NOT_SAVED_WIRED = "not_saved_wired"
|
|
NEVER = "never"
|
|
KINDS = (PAIR, POSITIVE, HISTORY, NOT_SAVED, NOT_SAVED_WIRED, NEVER)
|
|
|
|
_UNDECIDED = ("ambiguous", "unresolved")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Decision:
|
|
"""What to store for one file (see the design's "The ladder").
|
|
|
|
``kind``: one of ``KINDS``. ``text``: the storage text of the positive.
|
|
``canonical``: the parsed metadata stored with the link. ``resolution``:
|
|
the resolution that decided a ``pair``, else the default profile's
|
|
result. ``source``: which step named the text (log lines and the scan's
|
|
outcome detail). ``review``: ``ambiguous`` or ``untraced`` when the
|
|
default profile was ambiguous (the scan's Needs review lists).
|
|
``try_recorded_run``: the scan tries the recorded run's stored prompt
|
|
when nothing else links. ``unlinked_status``: the scan status when
|
|
nothing links (None: no prompt metadata).
|
|
"""
|
|
|
|
kind: str
|
|
text: Optional[str] = None
|
|
canonical: dict = field(default_factory=dict)
|
|
resolution: Optional[dict] = None
|
|
source: str = "none"
|
|
review: Optional[str] = None
|
|
try_recorded_run: bool = False
|
|
unlinked_status: Optional[str] = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _File:
|
|
"""The ladder's inputs beside the metadata."""
|
|
|
|
metadata: Mapping
|
|
canonical: dict
|
|
resolution: dict
|
|
review: Optional[str]
|
|
file_name: Optional[str]
|
|
in_output: bool
|
|
lookup: Optional[Callable[[], Any]]
|
|
policy: Policy
|
|
|
|
|
|
def decide_file_prompt(
|
|
metadata: Any,
|
|
*,
|
|
file_name: Optional[str],
|
|
file_size: Union[None, int, Callable[[], Optional[int]]],
|
|
in_output: bool,
|
|
lookup: Optional[Callable[[], Any]],
|
|
policy: Policy,
|
|
) -> Decision:
|
|
"""The ladder for one file's raw ``metadata``.
|
|
|
|
``prompt``/``workflow`` may be JSON strings (the scan) or parsed values
|
|
(the monitor). ``lookup`` returns the file's ``RecordedOutput`` or None;
|
|
it is None when history must not be asked yet. It is called at most
|
|
once, and only for a graph the default profile leaves undecided.
|
|
``file_size`` may be a callable: it is read only for a file with prompt
|
|
metadata whose ``file_info`` has no size.
|
|
"""
|
|
if not metadata:
|
|
return Decision(NEVER, try_recorded_run=True)
|
|
canonical = _parsed(metadata)
|
|
if metadata_opted_out(canonical):
|
|
# Save to library was off for the run's prompt: nothing is stored,
|
|
# no recorded-run fallback, no Needs review item (#90)
|
|
return Decision(NOT_SAVED, canonical=canonical)
|
|
if metadata_opted_out(canonical, unreadable=True):
|
|
# The switch is wired from a node the file cannot show: only the
|
|
# live run (the node saw the value) can link it
|
|
return Decision(NOT_SAVED_WIRED, canonical=canonical)
|
|
switched = _switched_off_upstream(canonical.get("prompt"))
|
|
if switched is not None:
|
|
# The #90 walk follows only prompt-like inputs; any input name
|
|
# upstream of a positive can carry a switched-off node's text
|
|
return Decision(switched, canonical=canonical)
|
|
if not canonical.get("prompt") and not canonical.get("parameters"):
|
|
return Decision(NEVER, canonical=canonical, try_recorded_run=True)
|
|
_with_size(canonical, file_size)
|
|
resolution = resolve_prompt_pair(canonical)
|
|
switched = (
|
|
_side_switch(canonical.get("prompt"), resolution["positive"])
|
|
if resolution["state"] == "resolved"
|
|
else None
|
|
)
|
|
if switched is not None:
|
|
# The positive passes a node that is off, though the #90 graph walk
|
|
# did not reach it (steps 5 and 7)
|
|
return Decision(switched, canonical=canonical)
|
|
if resolution["state"] == "resolved" and side_opted_out(
|
|
canonical.get("prompt"), resolution["negative"]
|
|
):
|
|
# Only the negative's node is off: the positive alone, never a pair
|
|
positive = resolution["positive"]
|
|
text = positive.get("storage_text", positive["text"]).strip()
|
|
return _gated(
|
|
Decision(
|
|
POSITIVE,
|
|
text=text,
|
|
canonical=canonical,
|
|
resolution=resolution,
|
|
source="opted-out-negative",
|
|
),
|
|
None,
|
|
)
|
|
if blank_positive(resolution):
|
|
# Only prepend/append text: no stored prompt can own it (#342)
|
|
resolution = {**resolution, "state": "unresolved"}
|
|
found = _File(
|
|
metadata,
|
|
canonical,
|
|
resolution,
|
|
_review(resolution),
|
|
file_name,
|
|
in_output,
|
|
lookup,
|
|
policy,
|
|
)
|
|
return _gated(_decide(found), found)
|
|
|
|
|
|
def _decide(found: _File) -> Decision:
|
|
state = found.resolution["state"]
|
|
graph = found.canonical.get("prompt")
|
|
if state == "resolved":
|
|
positive = found.resolution["positive"]
|
|
text = positive.get("storage_text", positive["text"])
|
|
return _decision(found, PAIR, text, "resolver")
|
|
if graph and state in _UNDECIDED:
|
|
return _undecided_graph(found)
|
|
if state == "ambiguous":
|
|
return _never(found, _unlinked(found))
|
|
if state == "unresolved" and not isinstance(found.canonical.get("parameters"), str):
|
|
return _never(found, "unresolved")
|
|
if graph:
|
|
return _sampler_less(found, graph)
|
|
text = _a1111_positive(found.metadata)
|
|
if text is None:
|
|
return _never(found, "found")
|
|
return _decision(found, POSITIVE, text, "a1111")
|
|
|
|
|
|
def _undecided_graph(found: _File) -> Decision:
|
|
recorded = found.lookup() if found.lookup is not None else None
|
|
saver = recorded.saver_id if recorded is not None else None
|
|
inspected = inspection_resolution(
|
|
found.metadata, found.file_name, saver, name_anchor=False
|
|
)
|
|
text = _inspected_text(inspected.resolution)
|
|
switched = (
|
|
_side_switch(found.canonical["prompt"], inspected.resolution["positive"])
|
|
if text is not None
|
|
else None
|
|
)
|
|
if switched is not None:
|
|
# The inspector follows pass-throughs the #90 graph walk does not
|
|
return Decision(switched, canonical=found.canonical)
|
|
if text is not None:
|
|
source = "recorded-run" if inspected.by_record else "inspector"
|
|
return _decision(found, POSITIVE, text, source)
|
|
if found.policy is Policy.MONITOR:
|
|
if found.lookup is None and found.in_output:
|
|
return _decision(found, HISTORY, None, "none")
|
|
if not inspected.malformed and inspected.resolution["state"] == "ambiguous":
|
|
return _never(found, _unlinked(found))
|
|
text = graph_prompt_text(found.metadata)
|
|
switched = _off_text(found.canonical["prompt"], text) if text else None
|
|
if switched is not None:
|
|
# The legacy answer is a switched-off node's own text (#90)
|
|
return Decision(switched, canonical=found.canonical)
|
|
if text is not None and _search_text(found.canonical["prompt"], text):
|
|
# The query of a Search List batch is a search, never a prompt (#548)
|
|
return _never(found, _unlinked(found), try_recorded_run=True)
|
|
if text is not None and _joined_text(found.canonical["prompt"], text):
|
|
# The legacy parser joins prepend + text + append: the text a
|
|
# PromptManager node stores cannot be told from it (#343).
|
|
return _never(found, _unlinked(found), try_recorded_run=True)
|
|
if text is not None:
|
|
return _decision(found, POSITIVE, text, "legacy-parser")
|
|
return _never(found, _unlinked(found), try_recorded_run=True)
|
|
|
|
|
|
def _sampler_less(found: _File, graph: Any) -> Decision:
|
|
# No node takes a positive or negative input (a guider or wrapper graph):
|
|
# the one wired text, never a guess (#252).
|
|
wired = sampler_less_prompt(graph)
|
|
if wired["text"] is not None:
|
|
return _decision(found, POSITIVE, wired["text"], "sampler-less")
|
|
if isinstance(found.canonical.get("parameters"), str):
|
|
# A metadata saver also wrote A1111 text: its positive prompt.
|
|
text = _a1111_positive(found.metadata)
|
|
if text is not None:
|
|
return _decision(found, POSITIVE, text, "a1111")
|
|
status = "unresolved" if wired["state"] == "ambiguous" else "found"
|
|
return _never(found, status, try_recorded_run=True)
|
|
|
|
|
|
_UNDECIDED_SOURCES = ("inspector", "recorded-run", "legacy-parser")
|
|
|
|
|
|
def _switch_kind(settings: Any) -> Optional[str]:
|
|
"""``not_saved`` when a setting is off, ``not_saved_wired`` when one is
|
|
unknowable (wired from a node the graph cannot read), else None."""
|
|
settings = list(settings)
|
|
if False in settings:
|
|
return NOT_SAVED
|
|
if None in settings:
|
|
return NOT_SAVED_WIRED
|
|
return None
|
|
|
|
|
|
def _prompt_manager_ids(graph: Any) -> list:
|
|
if not isinstance(graph, dict):
|
|
return []
|
|
return [
|
|
str(node_id)
|
|
for node_id, node in graph.items()
|
|
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
|
|
]
|
|
|
|
|
|
def _side_switch(graph: Any, side: Any) -> Optional[str]:
|
|
"""The switch outcome of the PromptManager nodes on a side's path."""
|
|
if not isinstance(graph, dict) or not side_opted_out(graph, side):
|
|
return None
|
|
provenance = side.get("provenance")
|
|
path = provenance.get("path") if isinstance(provenance, dict) else None
|
|
path = path if isinstance(path, list) else []
|
|
nodes = {str(step.get("node_id")) for step in path if isinstance(step, dict)} & set(
|
|
_prompt_manager_ids(graph)
|
|
)
|
|
return _switch_kind(save_setting(graph, node_id) for node_id in nodes)
|
|
|
|
|
|
# Inputs that never carry prompt text or positive conditioning.
|
|
_NOT_TEXT_INPUTS = frozenset(
|
|
{
|
|
"model",
|
|
"clip",
|
|
"vae",
|
|
"latent_image",
|
|
"samples",
|
|
"pixels",
|
|
"image",
|
|
"images",
|
|
"mask",
|
|
"noise",
|
|
"sigmas",
|
|
"sampler",
|
|
"control_net",
|
|
"upscale_model",
|
|
}
|
|
)
|
|
_MAX_SWITCH_VISITS = 5000
|
|
# The batch node whose ``text`` input is a library search query (#548).
|
|
SEARCH_LIST_TYPE = "PromptSearchList"
|
|
|
|
|
|
def _negative_name(key: Any) -> bool:
|
|
"""#90's negative rule, plus any name containing "negative"
|
|
(``negative2``, ``NegativePrompt``)."""
|
|
return is_negative_input(key) or "negative" in str(key).lower()
|
|
|
|
|
|
def _switched_off_upstream(graph: Any) -> Optional[str]:
|
|
"""The switch outcome of every PromptManager node upstream of a positive.
|
|
|
|
Follows every input except negative and non-text ones (#90's walk follows
|
|
only prompt-like names). A graph without a recognisable sampler checks
|
|
every PromptManager node, as #90 does.
|
|
"""
|
|
if not isinstance(graph, dict):
|
|
return None
|
|
upstream = _positive_upstream(graph)
|
|
if upstream is None:
|
|
ids = _prompt_manager_ids(graph)
|
|
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
|
|
found = [
|
|
node_id
|
|
for node_id in upstream
|
|
if graph[node_id].get("class_type") in PROMPT_MANAGER_TYPES
|
|
]
|
|
return _switch_kind(save_setting(graph, node_id) for node_id in found)
|
|
|
|
|
|
def _positive_upstream(graph: dict) -> Optional[set]:
|
|
"""The ids of the nodes upstream of a sampler's positive, or None when
|
|
the graph has no recognisable sampler.
|
|
|
|
Follows every input except negative and non-text ones (#90's walk follows
|
|
only prompt-like names).
|
|
"""
|
|
roots = _positive_roots(graph)
|
|
if not roots:
|
|
return None
|
|
seen, found = set(), set()
|
|
stack = [str(link[0]) for link in roots]
|
|
while stack and len(seen) < _MAX_SWITCH_VISITS:
|
|
node_id = stack.pop()
|
|
if node_id in seen:
|
|
continue
|
|
seen.add(node_id)
|
|
node = graph.get(node_id)
|
|
if not isinstance(node, dict):
|
|
continue
|
|
found.add(node_id)
|
|
inputs = node.get("inputs")
|
|
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
|
|
if key in _NOT_TEXT_INPUTS or _negative_name(key):
|
|
continue
|
|
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
|
|
stack.append(str(value[0]))
|
|
return found
|
|
|
|
|
|
def _search_text(graph: Any, text: str) -> bool:
|
|
"""Whether ``text`` is the search query of a PromptSearchList node that
|
|
feeds a positive (any such node when no sampler is recognised).
|
|
|
|
The legacy parser traces a positive wired from a Search List to the
|
|
node's ``text`` input, which is the database search, never the prompt
|
|
the batch ran (#548). Each item is counted by the node at run time.
|
|
"""
|
|
if not isinstance(graph, dict):
|
|
return False
|
|
upstream = _positive_upstream(graph)
|
|
ids = graph if upstream is None else upstream
|
|
wanted = text.strip()
|
|
return any(
|
|
isinstance(graph[node_id], dict)
|
|
and graph[node_id].get("class_type") == SEARCH_LIST_TYPE
|
|
and resolve_text(graph, node_id) == wanted
|
|
for node_id in ids
|
|
)
|
|
|
|
|
|
def _off_text(graph: Any, text: str) -> Optional[str]:
|
|
"""The switch outcome when ``text`` is the text (resolved, else literal)
|
|
of a PromptManager-family node whose Save to library is not on."""
|
|
wanted = text.strip()
|
|
matched = []
|
|
for node_id in _prompt_manager_ids(graph):
|
|
node_text = resolve_text(graph, node_id)
|
|
if node_text is None:
|
|
inputs = graph[node_id].get("inputs")
|
|
literal = inputs.get("text") if isinstance(inputs, dict) else None
|
|
node_text = literal if isinstance(literal, str) else None
|
|
if node_text is not None and node_text.strip() == wanted:
|
|
matched.append(save_setting(graph, node_id))
|
|
return _switch_kind(matched)
|
|
|
|
|
|
def _joined_text(graph: Any, text: str) -> bool:
|
|
"""Whether ``text`` may hold a PromptManager node's prepend or append.
|
|
|
|
True when a PromptManager-family node has a prepend or append part and
|
|
``text`` is not the literal ``text`` of one such node (the legacy
|
|
parser's answer when it does not follow a linked part).
|
|
"""
|
|
own = _prompt_manager_parts(graph)
|
|
return own is not None and text.strip() not in own
|
|
|
|
|
|
def _prompt_manager_parts(graph: Any) -> Optional[set]:
|
|
"""The literal texts of PromptManager-family nodes with a prepend or
|
|
append part, or None when no such node exists."""
|
|
if not isinstance(graph, dict):
|
|
return None
|
|
found = None
|
|
for node in graph.values():
|
|
if not isinstance(node, dict):
|
|
continue
|
|
name, inputs = node.get("class_type"), node.get("inputs")
|
|
if not isinstance(name, str) or "promptmanager" not in name.lower():
|
|
continue
|
|
if not isinstance(inputs, dict) or not _has_part(inputs):
|
|
continue
|
|
found = found if found is not None else set()
|
|
if isinstance(inputs.get("text"), str):
|
|
found.add(inputs["text"].strip())
|
|
return found
|
|
|
|
|
|
def _has_part(inputs: dict) -> bool:
|
|
for key in ("prepend_text", "append_text"):
|
|
part = inputs.get(key)
|
|
if part is not None and not (isinstance(part, str) and not part.strip()):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _gated(decision: Decision, found: Optional[_File]) -> Decision:
|
|
"""Junk text is never stored (step 13).
|
|
|
|
A junk answer of the undecided-graph step keeps that step's unlinked
|
|
status, so an ambiguous file is still a Needs review case; any other
|
|
step reports ``found``.
|
|
"""
|
|
if decision.kind not in (PAIR, POSITIVE):
|
|
return decision
|
|
junk = junk_text(decision.text)
|
|
if junk is None:
|
|
return decision
|
|
return Decision(
|
|
NEVER,
|
|
canonical=decision.canonical,
|
|
resolution=decision.resolution,
|
|
source=f"junk:{junk}",
|
|
review=decision.review,
|
|
try_recorded_run=True,
|
|
unlinked_status=(
|
|
_unlinked(found)
|
|
if found is not None and decision.source in _UNDECIDED_SOURCES
|
|
else "found"
|
|
),
|
|
)
|
|
|
|
|
|
def _decision(found: _File, kind: str, text: Optional[str], source: str) -> Decision:
|
|
return Decision(
|
|
kind,
|
|
text=text,
|
|
canonical=found.canonical,
|
|
resolution=found.resolution,
|
|
source=source,
|
|
review=found.review,
|
|
)
|
|
|
|
|
|
def _never(found: _File, status: str, *, try_recorded_run: bool = False) -> Decision:
|
|
return Decision(
|
|
NEVER,
|
|
canonical=found.canonical,
|
|
resolution=found.resolution,
|
|
review=found.review,
|
|
try_recorded_run=try_recorded_run,
|
|
unlinked_status=status,
|
|
)
|
|
|
|
|
|
def _review(resolution: dict) -> Optional[str]:
|
|
if resolution["state"] != "ambiguous":
|
|
return None
|
|
return "ambiguous" if reviewable(resolution) else "untraced"
|
|
|
|
|
|
def _unlinked(found: _File) -> str:
|
|
"""Ambiguous without a choice is reported unresolved."""
|
|
if found.review == "untraced":
|
|
return "unresolved"
|
|
return found.resolution["state"]
|
|
|
|
|
|
def _inspected_text(resolution: dict) -> Optional[str]:
|
|
"""The storage text of a positive the inspector shows, else None."""
|
|
if resolution.get("state") in _UNDECIDED:
|
|
return None
|
|
positive = resolution.get("positive")
|
|
if not isinstance(positive, dict):
|
|
return None
|
|
text = positive.get("storage_text", positive.get("text"))
|
|
return text if isinstance(text, str) and text.strip() else None
|
|
|
|
|
|
def _parsed(metadata: Mapping) -> dict:
|
|
canonical = dict(metadata)
|
|
for key in ("prompt", "workflow"):
|
|
if isinstance(canonical.get(key), str):
|
|
try:
|
|
canonical[key] = json.loads(canonical[key])
|
|
except (ValueError, TypeError):
|
|
# Preserve malformed metadata for conservative resolver rejection.
|
|
pass
|
|
return canonical
|
|
|
|
|
|
def _with_size(canonical: dict, file_size: Any) -> None:
|
|
"""Record the file's size in ``file_info`` unless the metadata carries one.
|
|
|
|
Stored as ``generated_images.file_size``; the "new" scan scope skips a
|
|
known file only while its size is unchanged.
|
|
"""
|
|
info = canonical.get("file_info")
|
|
info = dict(info) if isinstance(info, dict) else {}
|
|
if info.get("size") is None:
|
|
size = file_size() if callable(file_size) else file_size
|
|
if size is not None:
|
|
info["size"] = size
|
|
if info:
|
|
canonical["file_info"] = info
|
|
|
|
|
|
def _a1111_positive(metadata: Mapping) -> Optional[str]:
|
|
text = parse_comfyui_prompt(metadata)["positive_prompt"]
|
|
return text.strip() if text and text.strip() else None
|
|
|
|
|
|
_LORA_TAG = re.compile(r"<lora:[^>]*>")
|
|
_LORA_SEPARATORS = " \t\r\n\f\v,;|"
|
|
_MODEL_FILE = re.compile(r"\S*\.(?:safetensors|ckpt|pt|pth|bin|gguf|sft)", re.I)
|
|
|
|
|
|
def junk_text(text: Any) -> Optional[str]:
|
|
"""The #252 junk class of ``text``, or None when it may be a prompt."""
|
|
stripped = text.strip() if isinstance(text, str) else ""
|
|
if not stripped:
|
|
return "blank"
|
|
if _numeric(stripped):
|
|
return "number"
|
|
if not _LORA_TAG.sub("", stripped).strip(_LORA_SEPARATORS):
|
|
return "lora-only"
|
|
if _MODEL_FILE.fullmatch(stripped):
|
|
return "model-file"
|
|
return None
|
|
|
|
|
|
def graph_prompt_text(metadata: Mapping) -> Optional[str]:
|
|
"""The positive prompt the browser parser traces from the graphs, or None.
|
|
|
|
Records parse as the legacy pages parsed them (NaN reads as null).
|
|
"""
|
|
records = {}
|
|
for key in ("prompt", "workflow"):
|
|
value = metadata.get(key)
|
|
records[key] = parse_page_json(value) if isinstance(value, str) else value
|
|
try:
|
|
positive = extract_generation_params(records)["positivePrompt"]
|
|
except GenerationParamsError:
|
|
return None
|
|
if not isinstance(positive, str) or positive == PLACEHOLDERS["positivePrompt"]:
|
|
return None
|
|
return positive.strip() or None
|
|
|
|
|
|
_COMMON_FIELDS = (
|
|
"positive",
|
|
"negative",
|
|
"steps",
|
|
"cfg",
|
|
"sampler",
|
|
"scheduler",
|
|
"seed",
|
|
)
|
|
|
|
|
|
def _json_or_raw(value: Any) -> Any:
|
|
if not isinstance(value, str):
|
|
return value
|
|
try:
|
|
return json.loads(value)
|
|
except json.JSONDecodeError:
|
|
return value
|
|
|
|
|
|
def parse_comfyui_prompt(metadata: Mapping) -> dict:
|
|
"""Parse ComfyUI and A1111 prompt data from metadata.
|
|
|
|
Accepts the file's strings or parsed values: a parsed ``prompt`` or
|
|
``workflow`` is kept as it is and a non-string ``parameters`` is ignored.
|
|
"""
|
|
result = {
|
|
"prompt": None,
|
|
"workflow": None,
|
|
"parameters": {},
|
|
"positive_prompt": None,
|
|
"negative_prompt": None,
|
|
}
|
|
params = metadata.get("parameters")
|
|
if isinstance(params, str):
|
|
_parse_a1111(params, result)
|
|
if result["positive_prompt"] is None:
|
|
for key in ("prompt", "workflow"):
|
|
if key in metadata:
|
|
result[key] = _json_or_raw(metadata[key])
|
|
for name in _COMMON_FIELDS:
|
|
if name in metadata:
|
|
result["parameters"][name] = _json_or_raw(metadata[name])
|
|
return result
|
|
|
|
|
|
_SETTINGS_FIELDS = ("Steps", "Sampler", "Schedule type", "CFG scale", "Seed")
|
|
|
|
|
|
def _parse_a1111(params: str, result: dict) -> None:
|
|
"""A1111/Forge ``parameters``: a possibly multi-line positive, then a
|
|
"Negative prompt:" section, then a trailing "Steps: ..." settings line.
|
|
Taking the first line as the positive would truncate a multi-line prompt,
|
|
so the text is split on the settings line instead.
|
|
"""
|
|
body = params
|
|
settings = ""
|
|
found = re.search(r"\nSteps:\s", body)
|
|
if found:
|
|
settings = body[found.start() + 1 :].strip()
|
|
body = body[: found.start()]
|
|
negative = None
|
|
found = re.search(r"(?im)^\s*negative prompt:\s*", body)
|
|
if found:
|
|
negative = body[found.end() :].strip()
|
|
body = body[: found.start()]
|
|
elif body.lower().lstrip().startswith("negative prompt:"):
|
|
negative = body.lower().lstrip()[len("negative prompt:") :].strip()
|
|
body = ""
|
|
result["positive_prompt"] = body.strip() or None
|
|
if negative:
|
|
result["negative_prompt"] = negative
|
|
result["parameters"]["parameters"] = params
|
|
for name in _SETTINGS_FIELDS if settings else ():
|
|
found = re.search(
|
|
r"(?i)(?:^|,\s*)" + re.escape(name) + r":\s*([^,]+)", settings
|
|
)
|
|
if found:
|
|
key = name.lower().replace(" ", "_")
|
|
result["parameters"][key] = found.group(1).strip()
|