Files
ComfyAssets-ComfyUI_PromptM…/utils/file_prompt.py
T
Vito 39877202b2 feat(ingestion): one prompt decision for scan and monitor; scans link each dataset branch's own prompt (#247, PR 1) (#422)
* feat(inspector): use recorded run history when the graph alone can't name the prompt (#386)

When a file's own graph leaves the prompt open, both inspect routes ask
recorded run history about the file (one run reported it and it was written
during that run). The save node that run reported it from anchors the walk
like a file-name match, so dataset branches that share one saver prefix
show their own prompt, labelled "Recorded run". Still open at one sampler
whose PromptManager text was recorded at execution (a language model's
output), the recorded text shows as "Execution recorded". Results the file
resolves alone never change; a failed lookup judges the file alone.

* fix(inspector): recorded texts follow history's saver; conflicts anchor nothing (#386)

Review follow-ups: the texts PromptManager recorded are looked up at the
sampler history's saver leads to, never at a sampler the unanchored walk
named; a file name naming a different saver than history anchors nothing;
outputs and texts are read in one transaction; "Recorded run" ranks below
every workflow-inferred source; unexpected lookup errors log at error level.

* fix(history): look up recorded runs by history's own spelling of the file (#386)

The history route inspects a case-normalized target on Windows, while
history matches file names exactly, so the occurrence's reference names
the file.

* refactor(inspector): share the inspector's resolve as inspection_resolution

inspect_prompt_evidence now calls inspection_resolution, which returns the
resolution to use, the anchored result and whether the recorded saver chose
it. Ingestion can call the same helper for files the default profile cannot
decide (#247).

* feat(ingestion): one per-file prompt decision for the scan and the monitor

utils/file_prompt.py holds decide_file_prompt, the ladder the output scan
runs today plus the inspector's rule (inspection profile, then the recorded
saver) before the legacy parser, with a SCAN and a MONITOR policy. Junk
text (blank, numbers, LoRA tags only, model file names) is never stored.
graph_prompt_text and parse_comfyui_prompt move here; the parser accepts
parsed metadata (#247).

* feat(ingestion): store a file's prompt in one transaction, one prompt per file

database/scanned_prompts.py links a file to the prompt of one text, creating
it when new, inside one BEGIN IMMEDIATE transaction: a file linked to
another prompt is a conflict, a failed link leaves no prompt behind and two
writers store one prompt. pair_ingestion gains file_link_rows,
link_conflict and Marks/mark_created; ingest_resolved_pair takes optional
marks for the components it creates. recorded_runs and named_prompt in
run_attribution become public (#247).

* feat(scan): decide each scanned file with the shared ladder (#247)

_ingest_scanned_file calls decide_file_prompt (scan policy) and stores the
result: pairs through ingest_resolved_pair and positives through
store_scanned_prompt, both with the scan marks. Files the default profile
leaves undecided now get the inspector's resolve, with the saver recorded
history names, before the legacy parser, so each branch of a shared-prefix
dataset run links its own prompt. Junk text is never stored, a file linked
to another prompt is a conflict instead of a second link, and a failed link
leaves no prompt behind. _BatchHistory reads the stored prompt and the
recorded saver from one recorded_runs pass. _parse_comfyui_prompt calls the
moved parse_comfyui_prompt. TESTING.md documents the decision.

* fix(ingestion): keep the file-name saver anchor out of the ladder (#390)

The #390 ruling queues a file that only its saver's file name resolves for
Needs review, never linking it. inspection_resolution gains name_anchor;
the ladder passes False, so such a file falls to the legacy floor in the
scan (and to Needs review) and is never stored by the monitor, while the
recorded saver still decides. The inspector keeps the name anchor.

* fix(ingestion): no joined PromptManager text from the legacy floor; junk keeps its step's status

The legacy parser returns prepend + text + append, so its answer is refused
when a PromptManager-family node has a prepend or append part (#343). Junk
found at the undecided-graph step keeps that step's unlinked status and
review, so an ambiguous file is still queued for Needs review. The monitor
policy's ambiguous file no longer asks for the recorded run, and the file
size may be read lazily.

* fix(ingestion): keep the legacy answer that is a PromptManager node's own text

The joined-text refusal also refused the #170 parity files whose
PromptManager prepend is a link the browser parser does not follow: its
answer is then the node's literal text, which is what the node stores.
Refuse only an answer that is not such a node's own text.

* fix(scan): one prompt per file for recorded-run links; writes survive a library move

The recorded run's stored prompt now links in one transaction with the file's
link rows, so a file linked to another prompt is a conflict instead of a
second link. The scan's stores run through survive_library_switch. A single
file asks history once (a one-file _BatchHistory), the scan passes
in_output=False and reads the file size only for files with prompt
metadata. A conflict row reads "Kept: already linked to a different
prompt". TESTING.md records the rules.

* fix(ingestion): match a file's link rows under its resolved spelling too

The scan stores realpath spellings and the monitor the watcher's raw path,
so a file reached through a symlinked or junction output folder could get a
second generated_images row. file_link_rows now queries the path and its
realpath (each normalized and raw) and dedupes by row id. TESTING.md notes
the reverse order is matched only when the raw spelling is the realpath.

* fix(ingestion): a switched-off PromptManager node behind a pass-through stores nothing (#90)

The #90 graph walk follows only prompt-like inputs, so a PromptManagerText
with Save to library off reaching the encoder through another input name
(easy showAnything.anything, a custom node's value) was invisible to it,
and the inspector's resolve stored its text. The ladder now checks the
positive every step names: a resolved or inspected positive whose path
passes a node that is off, or a legacy answer that is such a node's own
text, makes the file not_saved.

* fix(ingestion): any switched-off node upstream of a positive stores nothing (#90)

The ladder walks every input upstream of each sampler or guider positive
(not negative, model, clip, latent or image inputs) for PromptManager nodes
whose Save to library is off or wired from an unreadable node, so the
sampler-less step, an off node with a prepend part and a PromptManagerPair
behind a pass-through no longer store its text. The legacy-floor guard reads
a node's resolved text. Every guard reports not_saved for a switch that is
off and not_saved_wired for one it cannot read.

* fix(ingestion): the switch walk skips negative inputs by #90's own rule

The upstream walk treated only names containing "negative" as negative, so
an off node on a neg, neg_text or text_neg input blocked an enabled
positive. prompt_graph gains is_negative_input (the _NEGATIVE_KEY rule, also
used by follows_input) and the walk uses it. The guards accept a node with
null inputs and a side whose provenance is not an object.

* fix(ingestion): the switch walk also skips any input named with "negative"

#90's rule matches only whole underscore-separated words, so negative2,
negativeprompt and NegativePrompt counted as positive inputs and an off
node wired only to one of them blocked the file. The walk skips those too;
follows_input is unchanged.
2026-10-28 20:45:15 +00:00

635 lines
22 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 _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
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
roots = _positive_roots(graph)
if not roots:
ids = _prompt_manager_ids(graph)
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
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
if node.get("class_type") in PROMPT_MANAGER_TYPES:
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 _switch_kind(save_setting(graph, node_id) for node_id in found)
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()