Compare commits

..
Author SHA1 Message Date
Vito Sansevero e6f9a68b87 fix(history): name the current file's prompt pair button apart from the frozen run's
The recorded run dialog nests the current-file inspector, whose footer repeated the "Send prompt pair" name. Name it from the prompt context, scope the real-host test to the frozen region, and add a dialog-wide duplicate-name check.
2026-10-19 16:57:39 -07:00
Vito Sansevero 80a4ba3ba4 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.
2026-10-19 16:08:34 -07:00
Vito Sansevero 1237a34f25 fix(history): name the recorded run's current-file prompts apart from its frozen fields
Now that the inspector resolves more saved files, the Recorded run dialog can
hold two regions named "Positive prompt" (and two of each Copy/Send action):
the frozen run field and the inline current file's prompt. Assistive
technology could not tell them apart, and the real-host chronology test read
two <pre> elements.

Prompt blocks take an optional context label. The inline current file inside
a recorded run names its regions "Current file positive prompt" and its
actions "Copy/Send current file positive prompt"; the visible headings stay
as in the concept. The real-host test now also scopes its frozen-field reads
to the "Frozen run prompt texts" section, and a disposable-server workflow
pins the distinct names with a resolved current file.
2026-10-19 16:07:06 -07:00
Vito Sansevero c65b2d5449 test(inspect): import unittest one way in the inspection suites
Code scanning flagged the mixed 'import unittest' and 'from unittest import
mock' forms. Use 'import unittest' plus 'import unittest.mock' everywhere.
2026-10-19 15:58:15 -07:00
Vito Sansevero 2255f91f44 fix(inspect): refuse overwritten PromptManager text inside subgraphs
Controller ruling on review M-1: PromptManager nodes inside subgraphs run
as "<instance>:<inner>" API IDs, nested instances chaining their IDs. The
overwritten-text check now walks subgraph instances (bounded in depth and
nodes) and reads a text widget stored by name, so these nodes are refused
like top-level ones, anchored or not. A definition without an instance
runs nothing and is ignored.

Review M-5: the historical adapters skip an explicit null prepend or append,
as the 07a5af3 code's "if prepend_text:" did.
2026-10-19 15:00:33 -07:00
Vito Sansevero c86002ec05 fix(inspect): match the batch index only in the settings footer
Review I-1: duplicate A1111 parameters copies were compared after blanking
"Batch index: N" anywhere in the record, so copies whose prompts differ
only by that phrase were accepted. The field is now blanked only inside
the record's one "Steps: N, ..." footer line, which must hold it exactly
once; any other difference stays a conflict.

Review M-4: the inspector parses the workflow record only when the API
graph can show the overwritten-PromptManager signature (a PromptManager
class, or a node with PromptManager's search_text input).
2026-10-19 15:00:33 -07:00
Vito Sansevero 23c4e34b75 test(inspect): cover batch copies past the role budget and orphan widgets 2026-10-19 15:00:33 -07:00
Vito Sansevero 815ece045b feat(inspect): port legacy parser rules the new resolver missed (upstream nodes)
R6 and R9 for nodes not installed beside ComfyUI here. Their input names
and output slots come from each pack's source at the commit the saved
workflows record in the node's cnr_id and ver properties (the task report
lists repository, commit, file and line):

- samplers ClownsharKSampler_Beta (RES4LYF), KSampler (Efficient) and
  ttN KSampler_v2 (latent input "latent");
- pass-throughs SeedVarianceEnhancer and ApplyInstantIDAdvanced (positive
  1, negative 2; output 0 is the model);
- selector Switch conditioning [Crystools];
- encoder smZ CLIPTextEncode: its text when with_SDXL is false; when true
  the connected CLIP decides at run time which inputs are read, so the
  prompt is known only when text, text_g and text_l agree;
- strings ttN text, CR Text, CR Prompt Text and TextBoxMira;
- facts model pass-through CR Apply LoRA Stack.

Inputs that replace what a sampler reads are not followed: RES4LYF options
(may carry a guider, and with sigmas replace the settings), an Efficiency
script and a ttN XY plot leave the prompt unsupported and the facts
unresolved. The overwritten PromptManager refusal (R1) still holds behind
SeedVarianceEnhancer. The default profile does not read the table.
2026-10-19 15:00:33 -07:00
Vito Sansevero 46aeff92ca feat(inspect): port legacy parser rules the new resolver missed (installed nodes)
R6 and R9 for nodes whose sources are installed beside ComfyUI, each
entry's input names and output slots read from the node's code (the task
report lists file and line):

- detailer stages UltimateSDUpscale (image), UltimateSDUpscaleNoUpscale
  (upscaled_image) and DetailerForEach (image), judged with their upstream
  samplers like FaceDetailer;
- pass-throughs ControlNetInpaintingAliMamaApply and LTXVConditioning
  (positive 0, negative 1) and FluxKontextMultiReferenceLatentMethod;
- encoder Text to Conditioning, strings TextInput and DisplayText;
- facts model pass-throughs Lora Loader (LoraManager), Power Lora Loader
  (rgthree), ModelPatchTorchSettings, CFGNorm, PerturbedAttentionGuidance.

Impact Pack encodes a detailer's non-empty wildcard instead of its positive
input, so FaceDetailer and DetailerForEach now list it as a prompt
override: a literal wildcard is decided at run time and a linked one names
its source, instead of resolving to the positive input. The overwritten
PromptManager refusal (R1) still holds behind the new pass-throughs. The
default profile does not read the table.
2026-10-19 15:00:33 -07:00
Vito Sansevero baf788b863 feat(inspect): port legacy parser rules the new resolver missed (A1111)
R3: an accumulating batch saver repeats the A1111 parameters record once
per batch item, identical except "Batch index: N". The bounded PNG reader
now keeps the last copy (Pillow's rule, which #165 relied on) instead of
calling the file unreadable. Any other difference, including a missing or
second batch index, is still a conflict, and decoding the copies draws from
the duplicate role budget.

R4: parameters text that starts with "Negative prompt:" has an empty
positive (#165), instead of showing the delimiter line as the positive.

R5: the settings footer may be followed by sd-dynamic-prompts' one-line
"Template:" and "Negative Template:" records, and may be "Steps: N" alone
when there is no full footer.

R10: commas inside A1111's quoted settings values no longer split them, so
an "ADetailer prompt" with a comma keeps the other facts known.

The facts' A1111 reader mirrors R4, R5 and R10; the default (ingestion)
profile keeps the historical split. Also from the Task 2.7 review: a guard
test fails on any uncoded unresolved reason without a user line (M1), and
the encoder bounds role lines to the client's 512 code units (M4).
2026-10-19 15:00:33 -07:00
Vito Sansevero c16ae99419 feat(inspect): port legacy parser rules the new resolver missed (PromptManager)
R1: before 3.2.3 a save patch overwrote every PromptManager node's saved
text with the last-run node's prompt. The legacy viewer recognizes the
signature (comfy-metadata.js createContext: two or more PromptManager
workflow nodes with one API text and different widget values). Inspection
now refuses those nodes as malformed instead of resolving to another
node's prompt; the inspector hands the parsed workflow to the resolver for
this check only. Lands before the new pass-throughs (R6) that would
otherwise make these graphs resolve to the wrong text.

R7: this repository's node classes up to 07a5af3 (PromptManagerV2,
Positive, Negative, Detailer, V2Text, PositiveText, NegativeText) resolve
with their own outputs and their exact strip and ", " join.

The default (ingestion) profile ignores both.
2026-10-19 15:00:33 -07:00
Vito Sansevero 2bda944581 feat(inspect): explain why a prompt could not be resolved 2026-10-19 15:00:33 -07:00
Vito Sansevero 72a2d0c800 fix(inspect): merge facts over unresolved stages and share stage inputs via the table 2026-10-19 15:00:33 -07:00
Vito Sansevero 9180851e18 fix(inspect): merge facts across sampler branches when ambiguous 2026-10-19 15:00:33 -07:00
Vito Sansevero 8cd8ca55ac feat(inspect): compute facts from the resolved sampler 2026-10-19 15:00:33 -07:00
Vito Sansevero f0c25b9ef3 fix(inspect): treat unlisted model-driven samplers as possible stages
Unlisted samplers that link a model with conditioning or a latent under
other input names (XlabsSampler, generic video samplers) were still walked
through, so the final sampler's prompt resolved silently. An unlisted node
with a linked model plus conditioning, neg_conditioning, samples or latent
is now unsupported-node, named; model patches such as LoraLoader and
ModelSampling nodes are not.
2026-10-19 15:00:33 -07:00
Vito Sansevero 57d39374d5 fix(inspect): walk each sampler's latent ancestry once per resolution
Every selector branch run and candidate re-walked the same latent
ancestry, so conditioning-carrying origins such as WanImageToVideo turned
a three-selector graph from ambiguous into work-limit. The walk outcome is
now cached per sampler; branch runs of one candidate pay for it once and
other candidates are charged its visit cost, as the text memo does. Walks
that ran out of work are not cached. Every link input is still walked.
2026-10-19 15:00:33 -07:00
Vito Sansevero 63e389ff14 fix(inspect): treat unlisted pipe and embed samplers as possible stages
The latent and detailer ancestry walks only flagged unlisted nodes with a
linked latent_image, guider, positive or negative input, so easy-use pipe
samplers, Impact pipe detailers and embed-driven video samplers were walked
through and the final sampler's prompt resolved silently. A separate
stage-shaped input set (adding pipes, embeds and custom-sampler parts)
now makes such nodes unsupported-node, named.
2026-10-19 15:00:33 -07:00
Vito Sansevero 7504bcd20c fix(inspect): queue each node once in breadth-first ancestry walks
The latent and detailer image walks queued every link before checking
whether its node was already seen, and popped from the list head. A dense
shared fan-in grew the queue far past the visit budget before any visit
was charged. Both walks now use a deque and enqueue each node at most
once, charging every examined edge as scanned work; outcomes are
unchanged.
2026-10-19 15:00:33 -07:00
Vito Sansevero 5b749afa35 feat(inspect): accept any non-sampler latent origin 2026-10-19 15:00:33 -07:00
Vito Sansevero 80b68f659e fix(inspect): keep pair execution evidence behind unreadable string sources 2026-10-19 15:00:33 -07:00
Vito Sansevero fbd9fba7e3 feat(inspect): support common text encoders and string sources 2026-10-19 15:00:33 -07:00
Vito Sansevero 1645691f94 fix(inspect): bound detailer stage chains by the depth limit 2026-10-19 15:00:33 -07:00
Vito Sansevero a05109b86e fix(inspect): choose selectors that share one condition together 2026-10-19 15:00:33 -07:00
Vito Sansevero 7fb8f973b4 feat(inspect): treat detailers as sampler stages 2026-10-19 15:00:33 -07:00
Vito Sansevero 4de839e4e5 feat(inspect): trace conditioning through supported pass-through and selector nodes 2026-10-19 15:00:33 -07:00
Vito Sansevero 56aa6f53b0 feat(inspect): resolve guider-based samplers
Under INSPECTION_PROFILE the resolver discovers samplers from the
semantics table and follows SamplerCustomAdvanced through its guider
link (Reroutes allowed) to a listed guider:

- CFGGuider: positive and negative, so the pair resolves.
- BasicGuider: positive only. The state is "absent" with the reason
  "Guider has no negative input", the existing missing-negative path.
- Any other guider, including DualCFGGuider (D17), is unsupported-node
  and names that guider.

Guider hops draw visits, scan and depth from the existing budgets. A
SamplerCustomAdvanced upstream in latent ancestry is an earlier stage,
so the result stays multistage-ambiguous. Positive and negative
provenance record guider_id. FaceDetailer (detailer-stage) is not a
candidate yet: deciding it needs output ancestry. DEFAULT_PROFILE never
loads the table and is unchanged.

Vector changes (inspection profile only, default vectors unchanged):
- f07b: no-sampler -> resolved (CFGGuider).
- f17: no-sampler -> unsupported-node (FluxGuidance, Task 2.3).
- f01b: no-sampler -> unsupported-node (EmptyFlux2LatentImage latent,
  Task 2.5).
- f01: no-sampler -> ambiguous (24 SamplerCustomAdvanced branches).

The no-sampler tests (resolver and inspect route) now use a sampler
class the inspector does not know, because f17 has a supported one.
2026-10-19 15:00:33 -07:00
Vito Sansevero 9424cd2e35 fix(inspect): bound and isolate the semantics table
Bound the semantics loader: at most 1 MiB is read, containers may nest
at most eight levels, and a RecursionError from the JSON parser becomes
a SemanticsError instead of escaping.

The table is no longer loaded at import. get_semantics() reads it on
first use and caches only a valid result, and the resolver imports it
lazily, only for a profile with semantics on. The default ingestion
profile never imports the module, so a missing or corrupt table cannot
break scan, monitor or admin import. Inspection of a prompt graph with
an unusable table is unresolved with the reason "Inspection node
semantics are unavailable" and an error log, rather than a crash.

INSPECTION_PROFILE now has semantics on; no strategy consumes the table
yet, so resolution is unchanged.
2026-10-19 15:00:33 -07:00
Vito Sansevero 5a6c0bdbc1 feat(inspect): add a declarative inspection node semantics table 2026-10-19 15:00:33 -07:00
15 changed files with 37 additions and 3630 deletions
+16 -52
View File
@@ -27,22 +27,10 @@ except ImportError:
try:
from ...database.operations import PromptDatabase
from ...database.pair_ingestion import ingest_resolved_pair
from ...utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
from ...utils.prompt_pair_resolver import resolve_prompt_pair
except ImportError:
from database.operations import PromptDatabase
from database.pair_ingestion import ingest_resolved_pair
from utils.generation_params import (
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
parse_js_json,
)
from utils.prompt_pair_resolver import resolve_prompt_pair
@@ -1523,21 +1511,6 @@ class AdminRoutesMixin:
counts["pair_links_added"] += outcome.get("links_added", 0)
return counts
@staticmethod
def _graph_prompt_text(metadata):
"""The positive prompt the browser parser traces from the graphs, or None."""
records = {}
for key in ("prompt", "workflow"):
value = metadata.get(key)
records[key] = parse_js_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
def _ingest_scanned_file(self, media_file, metadata, detailed=False):
"""Enrich exact pairs; retain legacy positive-only import for absent pairs."""
if not metadata:
@@ -1558,32 +1531,23 @@ class AdminRoutesMixin:
self.db, str(media_file), canonical, resolution
)
return result if detailed else result["status"]
if canonical.get("prompt") and resolution["state"] in (
"ambiguous",
"unresolved",
):
# The resolver could not decide: link the prompt the inspector and the
# legacy pages show for this file (the browser parser, in Python).
# Graphs without a recognized sampler keep the legacy extractor below.
prompt_text = self._graph_prompt_text(metadata)
if not prompt_text:
return resolution["state"]
elif resolution["state"] == "ambiguous":
if resolution["state"] == "ambiguous":
return "ambiguous"
elif resolution["state"] == "unresolved" and not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
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)
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
if resolution["state"] == "unresolved":
if canonical.get("prompt") or not isinstance(
canonical.get("parameters"), str
):
return resolution["state"]
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)
if not (prompt_text and prompt_text.strip()):
return "found"
prompt_text = prompt_text.strip()
prompt_hash = generate_prompt_hash(prompt_text)
existing = self.db.get_prompt_by_hash(prompt_hash)
+2 -2
View File
@@ -26,9 +26,9 @@ test('inspection facts survive actual API to browser transport', async ({ page,
const complete = result.complete;
expect(Object.values(complete.generation).map(fact => fact.value)).toEqual(['9007199254740993', '28', '6.5', 'dpmpp_2m', 'karras', 'fixture-model.safetensors']);
expect(Object.values(complete.generation).every(fact => fact.source === 'api-graph' && fact.evidence === 'configuration')).toBe(true);
// Differing branches show what the legacy parser shows: the first sampler's seed.
// Ambiguous branches merge per field: the differing seed is ambiguous, the shared fields known.
expect(Object.values(result.ambiguous.generation).map(fact => [fact.status, fact.value])).toEqual([
['known', '9007199254740993'], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
['ambiguous', null], ['known', '28'], ['known', '6.5'], ['known', 'dpmpp_2m'], ['known', 'karras'], ['known', 'fixture-model.safetensors']]);
expect(Object.values(result.ambiguous.generation).every(fact => fact.sampler_id === null)).toBe(true);
expect(result['workflow-only'].generation.seed).toMatchObject({ status: 'known', value: '9007199254740993', source: 'workflow', evidence: 'configuration' });
expect(result['workflow-only'].prompts.positive.source).toBe('saved-association');
File diff suppressed because one or more lines are too long
-461
View File
@@ -1,461 +0,0 @@
"""Named workflow cases shared by the generation-parameter parser tests.
Each case is the raw metadata records a file would carry, as text, so the cases
exercise ``parse_js_json`` as well as the tracing. The JS parity test runs every
case through the legacy browser parser too. Prompt texts and seeds are invented.
"""
import json
def _text(value):
return None if value is None else json.dumps(value)
def case(prompt=None, workflow=None):
return {"prompt": _text(prompt), "workflow": _text(workflow)}
def encoder(text, clip=("4", 1)):
return {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": list(clip)},
}
def ksampler(positive, negative, model=("4", 0), **settings):
inputs = {
"seed": 5,
"steps": 20,
"cfg": 7,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": list(model),
"positive": list(positive),
"negative": list(negative),
"latent_image": ["2", 0],
}
inputs.update(settings)
return {"class_type": "KSampler", "inputs": inputs}
CHECKPOINT = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "example.safetensors"},
}
LATENT = {
"class_type": "EmptyLatentImage",
"inputs": {"width": 512, "height": 512, "batch_size": 1},
}
def _basic():
return {
"4": CHECKPOINT,
"2": LATENT,
"6": encoder("a cat on a windowsill"),
"7": encoder("blurry, watermark"),
"3": ksampler(("6", 0), ("7", 0)),
"9": {"class_type": "SaveImage", "inputs": {"images": ["8", 0]}},
}
def _prompt_manager(text, prepend="", append=""):
return {
"class_type": "PromptManager",
"inputs": {
"text": text,
"category": "",
"tags": "",
"search_text": "",
"prepend_text": prepend,
"append_text": append,
"clip": ["4", 1],
},
}
def _shared_prompt_pipeline():
"""One PromptManager pair feeds a base pass, an upscale pass and a detailer."""
return {
"1": CHECKPOINT,
"5": ksampler(
("25", 0),
("26", 0),
("1", 0),
seed=111,
steps=4,
cfg=1.0,
sampler_name="lcm",
latent_image=["20", 0],
),
"6": {
"class_type": "VAEDecode",
"inputs": {"samples": ["5", 0], "vae": ["1", 2]},
},
"11": {
"class_type": "VAEEncode",
"inputs": {"pixels": ["6", 0], "vae": ["1", 2]},
},
"12": ksampler(
("25", 0),
("26", 0),
("1", 0),
seed=222,
steps=6,
cfg=1.0,
sampler_name="lcm",
denoise=0.45,
latent_image=["11", 0],
),
"13": {
"class_type": "VAEDecode",
"inputs": {"samples": ["12", 0], "vae": ["1", 2]},
},
"17": {
"class_type": "FaceDetailer",
"inputs": {
"seed": 333,
"steps": 4,
"cfg": 1.0,
"sampler_name": "lcm",
"image": ["13", 0],
"model": ["1", 0],
"clip": ["1", 1],
"vae": ["1", 2],
"positive": ["25", 0],
"negative": ["26", 0],
},
},
"20": {
"class_type": "EmptyLatentImage",
"inputs": {"width": 640, "height": 1536, "batch_size": 1},
},
"25": _prompt_manager("a lighthouse at dusk, oil painting"),
"26": _prompt_manager("blurry, watermark"),
"48": {
"class_type": "KikoSaveImage",
"inputs": {"filename_prefix": ["49", 0], "images": ["17", 0]},
},
"49": {
"class_type": "KikoProjectFilePath",
"inputs": {"project_name": "example"},
},
}
def _custom_sampler(guider):
return {
"13": CHECKPOINT,
"20": encoder("a red fox in snow", clip=("13", 1)),
"21": encoder("lowres", clip=("13", 1)),
"22": guider,
"25": {"class_type": "RandomNoise", "inputs": {"noise_seed": 42}},
"16": {"class_type": "KSamplerSelect", "inputs": {"sampler_name": "dpmpp_2m"}},
"17": {
"class_type": "BasicScheduler",
"inputs": {
"scheduler": "karras",
"steps": 8,
"denoise": 1.0,
"model": ["13", 0],
},
},
"1300:8": {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": ["25", 0],
"guider": ["22", 0],
"sampler": ["16", 0],
"sigmas": ["17", 0],
"latent_image": ["5", 0],
},
},
}
def _workflow_only():
return {
"nodes": [
{
"id": 4,
"type": "CheckpointLoaderSimple",
"mode": 0,
"inputs": [],
"widgets_values": ["example.safetensors"],
},
{
"id": 6,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 3}],
"widgets_values": ["a tidepool at noon"],
},
{
"id": 7,
"type": "CLIPTextEncode",
"mode": 0,
"inputs": [{"name": "clip", "link": 5}],
"widgets_values": ["noisy"],
},
{
"id": 10,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": 7}],
},
{
"id": 3,
"type": "KSampler",
"mode": 0,
"inputs": [
{"name": "model", "link": 1},
{"name": "positive", "link": 8},
{"name": "negative", "link": 4},
{"name": "latent_image", "link": 2},
],
"widgets_values": [314, "randomize", 25, 6.5, "dpmpp_2m", "karras", 1],
},
{
"id": 11,
"type": "CLIPTextEncode",
"mode": 2,
"inputs": [],
"widgets_values": ["muted node text"],
},
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[3, 4, 1, 6, 0, "CLIP"],
[5, 4, 1, 7, 0, "CLIP"],
[7, 6, 0, 10, 0, "CONDITIONING"],
[8, 10, 0, 3, 1, "CONDITIONING"],
[4, 7, 0, 3, 2, "CONDITIONING"],
{
"id": 2,
"origin_id": 99,
"origin_slot": 0,
"target_id": 3,
"target_slot": 3,
"type": "LATENT",
},
],
}
def _nested(depth):
"""A value nested ``depth`` arrays deep, as a crafted file can hold."""
value = ["leaf", 7]
for _ in range(depth):
value = [value, None]
return value
def _chain(length):
graph = {"4": CHECKPOINT, "1": encoder("deep chain text")}
previous = "1"
for index in range(length):
node_id = str(100 + index)
graph[node_id] = {
"class_type": "ConditioningPass",
"inputs": {"conditioning": [previous, 0]},
}
previous = node_id
graph["3"] = ksampler((previous, 0), ("1", 0))
return graph
CASES = {
"basic": case(_basic()),
"prompt-manager-prepend-append": case(
{
"4": CHECKPOINT,
"6": _prompt_manager("a cat", prepend="masterpiece", append="4k"),
"7": _prompt_manager(
"masterpiece, a dog", prepend="masterpiece", append="4k"
),
"3": ksampler(("6", 0), ("7", 0)),
}
),
"linked-text-concat": case(
{
"4": CHECKPOINT,
"10": {"class_type": "PrimitiveString", "inputs": {"value": "first part"}},
"11": {
"class_type": "StringConcatenate",
"inputs": {
"string_a": ["10", 0],
"string_b": "second part",
"delimiter": ", ",
"seed": 3,
"string_c": " ",
},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["11", 0], "clip": ["4", 1]},
},
"7": encoder(""),
"3": ksampler(("6", 0), ("7", 0)),
}
),
"prompt-manager-pair": case(
{
"4": CHECKPOINT,
"8": {
"class_type": "PromptManagerPair",
"inputs": {
"text": "pair positive",
"negative_text": "pair negative",
"prepend_text": "",
"append_text": "",
"clip": ["4", 1],
},
},
"3": ksampler(("8", 0), ("8", 1)),
}
),
"conditioning-passthrough": case(
{
"4": CHECKPOINT,
"6": encoder("through a controlnet"),
"7": encoder("negative through it"),
"12": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {
"positive": ["6", 0],
"negative": ["7", 0],
"control_net": ["13", 0],
},
},
"3": ksampler(("12", 0), ("12", 1)),
}
),
"first-sampler-in-js-key-order": case(
{
"4": CHECKPOINT,
"6": encoder("from sampler twelve"),
"7": encoder("from sampler five"),
"x": ksampler(("6", 0), ("6", 0)),
"12": ksampler(("6", 0), ("6", 0), seed=12),
"5": ksampler(("7", 0), ("7", 0), seed=55),
}
),
"custom-sampler-cfg-guider": case(
_custom_sampler(
{
"class_type": "CFGGuider",
"inputs": {
"model": ["13", 0],
"positive": ["20", 0],
"negative": ["21", 0],
"cfg": 3.5,
},
}
)
),
"custom-sampler-basic-guider": case(
_custom_sampler(
{
"class_type": "BasicGuider",
"inputs": {"model": ["13", 0], "conditioning": ["20", 0]},
}
)
),
"no-sampler-loose-settings": case(
{
"4": CHECKPOINT,
"3": {
"class_type": "MyKSamplerVariant",
"inputs": {
"noise_seed": 9,
"steps": 12,
"cfg": 2,
"sampler_name": "ddim",
},
},
}
),
"primitive-seed": case(
{
"4": CHECKPOINT,
"6": encoder("seeded"),
"30": {
"class_type": "Seed (rgthree)",
"inputs": {"other": "x", "seed": 77},
},
"3": ksampler(("6", 0), ("6", 0), seed=["30", 0]),
}
),
"workflow-only": case(workflow=_workflow_only()),
"overwritten-prompt-manager-text": case(
{
"4": CHECKPOINT,
"25": _prompt_manager("same saved text"),
"26": _prompt_manager("same saved text"),
"3": ksampler(("25", 0), ("26", 0)),
},
{
"nodes": [
{
"id": 25,
"type": "PromptManager",
"widgets_values": ["the real positive"],
},
{
"id": 26,
"type": "PromptManager",
"widgets_values": ["the real negative"],
},
],
"links": [],
},
),
"conditioning-cycle": case(
{
"4": CHECKPOINT,
"20": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["21", 0]},
},
"21": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["20", 0]},
},
"3": ksampler(("20", 0), ("21", 0)),
}
),
"chain-within-visit-limit": case(_chain(450)),
"chain-beyond-visit-limit": case(_chain(600)),
"chain-at-last-visit": case(_chain(499)),
"chain-one-past-last-visit": case(_chain(500)),
"blank-text-and-zero-seed": case(
{
"4": CHECKPOINT,
"6": encoder(" "),
"7": encoder("kept negative"),
"3": ksampler(("6", 0), ("7", 0), seed=0),
}
),
"shared-prompt-pipeline": case(_shared_prompt_pipeline()),
"null-workflow-node": case(_basic(), {"nodes": [None], "links": []}),
"unparsable-records": {"prompt": '{"3": NaN}', "workflow": "not json"},
"seed-beyond-double-precision": case(
{
"4": CHECKPOINT,
"6": encoder("x"),
"3": ksampler(("6", 0), ("6", 0), cfg=1.0, seed=4974052037430000001),
}
),
"deeply-nested-values": case(
{
"4": CHECKPOINT,
"6": encoder("nested neighbours"),
"3": ksampler(("6", 0), ("6", 0)),
"8": {"class_type": _nested(900), "inputs": {"text": "x"}},
},
{"nodes": [{"id": _nested(900), "type": _nested(900)}], "links": []},
),
"nothing": case(),
}
@@ -1,32 +0,0 @@
// Runs the legacy browser parser (comfy-metadata.js extractGenerationParams) on
// one JSON case per input line and prints one JSON result per line. The Python
// port (utils/generation_params.py) must produce the same results.
//
// Input line: {"prompt": <text or null>, "workflow": <text or null>}
// Each present record is parsed with JSON.parse; a record that fails
// to parse is treated as absent, as the browser pages do.
// Output line: {"ok": true, "result": {...}} or {"ok": false} when the parser throws.
import path from 'node:path';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const { extractGenerationParams } = require(path.join(here, '..', '..', '..', 'web', 'js', 'comfy-metadata.js'));
function parsed(text) {
if (typeof text !== 'string') return undefined;
try { return JSON.parse(text); } catch { return undefined; }
}
for (const line of fs.readFileSync(0, 'utf8').split('\n')) {
if (!line.trim()) continue;
const item = JSON.parse(line), data = {};
const prompt = parsed(item.prompt), workflow = parsed(item.workflow);
if (prompt !== undefined) data.prompt = prompt;
if (workflow !== undefined) data.workflow = workflow;
let out;
try { out = { ok: true, result: extractGenerationParams(data) }; } catch { out = { ok: false }; }
process.stdout.write(JSON.stringify(out) + '\n');
}
-104
View File
@@ -464,107 +464,3 @@ test('rooted controller keeps legacy descriptors with ignored noncloneable field
let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);});
requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose();
});
// The maintainer's standard pipeline: one PromptManager pair feeds a base pass, an
// upscale pass and a FaceDetailer, saved by a custom saver. Text and seeds are invented.
function detailerPipelineGraph() {
return {
1: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'Base/example_model.safetensors' } },
5: { class_type: 'KSampler', inputs: { seed: 111, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 1.0, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['20', 0] } },
6: { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
7: { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
8: { class_type: 'UpscaleModelLoader', inputs: { model_name: 'upscaler.pth' } },
9: { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['8', 0], image: ['6', 0] } },
10: { class_type: 'ImageScaleBy', inputs: { upscale_method: 'lanczos', scale_by: 0.75, image: ['9', 0] } },
11: { class_type: 'VAEEncode', inputs: { pixels: ['10', 0], vae: ['1', 2] } },
12: { class_type: 'KSampler', inputs: { seed: 222, steps: 6, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.45, model: ['47', 0], positive: ['25', 0], negative: ['26', 0], latent_image: ['11', 0] } },
13: { class_type: 'VAEDecode', inputs: { samples: ['12', 0], vae: ['1', 2] } },
17: { class_type: 'FaceDetailer', inputs: { seed: 333, steps: 4, cfg: 1.0, sampler_name: 'lcm', scheduler: 'normal', denoise: 0.4, image: ['13', 0], model: ['47', 0], clip: ['47', 1], vae: ['1', 2], positive: ['25', 0], negative: ['26', 0] } },
20: { class_type: 'EmptyLatentBatch', inputs: { width: 640, height: 1536, batch_size: 1 } },
25: { class_type: 'PromptManager', inputs: { text: 'a lighthouse at dusk, oil painting', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
26: { class_type: 'PromptManager', inputs: { text: 'blurry, watermark', category: '', tags: '', search_text: '', prepend_text: '', append_text: '', clip: ['47', 1] } },
47: { class_type: 'Lora Loader (LoraManager)', inputs: { text: '', model: ['1', 0], clip: ['1', 1] } },
48: { class_type: 'KikoSaveImage', inputs: { filename_prefix: ['49', 0], format: 'PNG', images: ['17', 0] } },
49: { class_type: 'KikoProjectFilePath', inputs: { project_name: 'example', filename: 'image' } },
};
}
test('workflow prompts and settings match the legacy parser when the server cannot decide', t => {
const parser = require('../../web/js/comfy-metadata.js'), { extractGenerationParams } = parser;
globalThis.ComfyMetadata = parser;
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
data.resolution = { state: 'ambiguous', sampler_id: null, reason: 'Multiple sampler branches' };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
const legacy = extractGenerationParams({ prompt: detailerPipelineGraph() });
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: legacy.positivePrompt, source: 'graph-inferred' });
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: legacy.negativePrompt, source: 'graph-inferred' });
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
assert.equal(record.prompts.negative.text, 'blurry, watermark');
for (const [field, key] of [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']]) {
assert.equal(record.generation[field].status, 'known', field);
assert.equal(record.generation[field].value, String(legacy[key]), field);
}
});
test('recorded and saved prompts are never replaced by the workflow parse', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.text, 'blurry, watermark');
});
test('a definite server prompt is kept even when the legacy parser reads it differently', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
// The legacy parser follows ConditioningZeroOut back to the positive text node.
const graph = detailerPipelineGraph();
graph[30] = { class_type: 'ConditioningZeroOut', inputs: { conditioning: ['25', 0] } };
graph[5].inputs.negative = ['30', 0];
data.metadata.prompt_graph = { status: 'parsed', value: graph };
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'resolved', text: '', source: 'zeroed-conditioning' } };
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.negative, { status: 'resolved', text: '', source: 'zeroed-conditioning' });
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
});
test('callers without the browser global pass the legacy parser explicitly', () => {
const parser = require('../../web/js/comfy-metadata.js');
const entry = image(), data = envelope(entry);
data.prompts = { positive: { status: 'ambiguous', text: null, source: null }, negative: { status: 'ambiguous', text: null, source: null } };
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const identity = { kind: 'rooted-file', key: '0:x', root_index: 0, path: entry.path, root_scope: scopeA };
const record = contract('projectInspectionPayload')(data, identity, null, extractGenerationFacts, false, parser);
assert.equal(record.prompts.positive.text, 'a lighthouse at dusk, oil painting');
assert.equal(record.prompts.negative.text, 'blurry, watermark');
});
test('a setting the file does not record is never filled from the legacy parse', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
data.metadata.prompt_graph = { status: 'parsed', value: detailerPipelineGraph() };
const extract = input => ({ ...extractGenerationFacts(input), cfg: { status: 'not-recorded', value: null, source: null, evidence: null, reason: null, sampler_id: null } });
const record = contract('toInspectionRecord')(data, entry, extract);
assert.equal(record.generation.cfg.status, 'not-recorded');
assert.equal(record.generation.cfg.value, null);
});
test('a workflow the legacy parser throws on keeps the server result instead of failing the record', t => {
globalThis.ComfyMetadata = require('../../web/js/comfy-metadata.js');
t.after(() => { delete globalThis.ComfyMetadata; });
const entry = image(), data = envelope(entry);
// The browser parser throws on a null workflow node (it reads properties of it).
data.metadata.workflow_graph = { status: 'parsed', value: { nodes: [null], links: [] } };
data.metadata.raw.workflow = '{"nodes":[null],"links":[]}';
data.metadata.record_formats.workflow = 'reserialized-json';
const record = contract('toInspectionRecord')(data, entry, extractGenerationFacts);
assert.deepEqual(record.prompts.positive, { status: 'resolved', text: 'exact\npositive', source: 'execution' });
assert.equal(record.prompts.negative.status, 'ambiguous');
});
-31
View File
@@ -158,34 +158,3 @@ test('real inspection navigation preserves slideshow across two advances and def
assert.equal(f.container.children.length,0);assert.equal(controller.state.record.identity.occurrence.output_node_id,'3');
} finally {view.dispose();controller.dispose();parentScope.dispose();globalThis.setTimeout=oldSetTimeout;globalThis.clearTimeout=oldClearTimeout;f.restore();}
});
test('an inline Viewer still loading its image is destroyed only after it has viewed it', async () => {
const {reportedLocationAdapter:adapter} = await import('../../web/js/workspace/history-media.mjs');
const f = mountedDom(), parentScope = createScope(), controller = borrowedController(), items = [occurrenceMedia('1',1,'c')];
const instances = [];
// Viewer.js's inline destroy() leaves an in-flight view running; its callback then needs a live instance.
f.win.Viewer = class { constructor(node,options) { this.node = node; this.options = options; this.destroyed = 0; this.viewed = false; instances.push(this); }
destroy() { this.destroyed++; } resize() {} };
const globals = {ResizeObserver:globalThis.ResizeObserver, requestAnimationFrame:globalThis.requestAnimationFrame, cancelAnimationFrame:globalThis.cancelAnimationFrame};
Object.assign(globalThis, {ResizeObserver:class { observe() {} disconnect() {} }, requestAnimationFrame:() => 1, cancelAnimationFrame() {}});
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}}});
try {
view.open({items,selectedKey:'c'.repeat(64)});
const [loading] = instances, image = loading.node, removed = [];
image.removeAttribute = key => removed.push(key);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
assert.equal(loading.destroyed, 0, 'not destroyed while its image view is pending');
// Viewer.js lists only images that still have a src; the pending view needs it.
assert.deepEqual(removed, [], 'the image keeps its src while the view is pending');
loading.viewed = true; loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'destroyed once its pending view settles');
assert.deepEqual(removed, ['src'], 'the src is released after the viewer');
loading.options.viewed.call(loading);
assert.equal(loading.destroyed, 1, 'never destroyed twice');
view.open({items,selectedKey:'c'.repeat(64)});
const shown = instances[1]; shown.viewed = true; shown.options.viewed.call(shown);
f.walk(f.container,n => n.attributes['aria-label'] === 'Close preview')[0].fire('click');
assert.equal(shown.destroyed, 1, 'a viewed image is destroyed at once');
} finally { view.dispose(); parentScope.dispose(); f.restore(); Object.assign(globalThis, globals); }
});
-227
View File
@@ -1,227 +0,0 @@
"""Tests for utils.generation_params, the Python port of the browser parser.
Expected values are what web/js/comfy-metadata.js extractGenerationParams
returns for the same input (tests/test_generation_params_js_parity.py checks
that directly when Node is available).
"""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.generation_params import ( # noqa: E402
PLACEHOLDERS,
GenerationParamsError,
extract_generation_params,
extract_from_records,
parse_js_json,
)
try:
from tests.generation_params_cases import CASES # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES # noqa: E402
def run(name):
return extract_from_records(CASES[name]["prompt"], CASES[name]["workflow"])
class TracesPrompts(unittest.TestCase):
def test_sampler_prompts_and_settings(self):
self.assertEqual(
run("basic"),
{
"positivePrompt": "a cat on a windowsill",
"negativePrompt": "blurry, watermark",
"checkpoint": "example.safetensors",
"steps": 20,
"cfgScale": 7,
"sampler": "euler",
"seed": 5,
},
)
def test_prompt_manager_joins_prepend_and_append_once(self):
result = run("prompt-manager-prepend-append")
self.assertEqual(result["positivePrompt"], "masterpiece a cat 4k")
# The text already starts with the prepend text, so it is not repeated.
self.assertEqual(result["negativePrompt"], "masterpiece, a dog 4k")
def test_linked_text_joins_text_inputs_with_the_delimiter(self):
result = run("linked-text-concat")
self.assertEqual(result["positivePrompt"], "first part, second part")
self.assertEqual(result["negativePrompt"], PLACEHOLDERS["negativePrompt"])
def test_prompt_manager_pair_outputs(self):
result = run("prompt-manager-pair")
self.assertEqual(result["positivePrompt"], "pair positive")
self.assertEqual(result["negativePrompt"], "pair negative")
def test_conditioning_passthrough_follows_the_role(self):
result = run("conditioning-passthrough")
self.assertEqual(result["positivePrompt"], "through a controlnet")
self.assertEqual(result["negativePrompt"], "negative through it")
def test_several_samplers_sharing_one_prompt_pair(self):
result = run("shared-prompt-pipeline")
self.assertEqual(result["positivePrompt"], "a lighthouse at dusk, oil painting")
self.assertEqual(result["negativePrompt"], "blurry, watermark")
self.assertEqual(
(result["seed"], result["steps"], result["sampler"]), (111, 4, "lcm")
)
def test_overwritten_prompt_manager_text_is_recovered_from_widgets(self):
result = run("overwritten-prompt-manager-text")
self.assertEqual(result["positivePrompt"], "the real positive")
self.assertEqual(result["negativePrompt"], "the real negative")
class ChoosesTheSampler(unittest.TestCase):
def test_first_sampler_follows_javascript_key_order(self):
# Integer-like IDs come first in ascending order, then the others.
result = run("first-sampler-in-js-key-order")
self.assertEqual(result["positivePrompt"], "from sampler five")
self.assertEqual(result["seed"], 55)
def test_custom_sampler_reads_its_guider_and_split_settings(self):
result = run("custom-sampler-cfg-guider")
self.assertEqual(result["positivePrompt"], "a red fox in snow")
self.assertEqual(result["negativePrompt"], "lowres")
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(42, 8, 3.5, "dpmpp_2m"),
)
def test_a_guider_without_prompt_inputs_leaves_prompts_unknown(self):
result = run("custom-sampler-basic-guider")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
# KSamplerSelect still matches the loose /ksampler/ settings lookup.
self.assertEqual(result["sampler"], "dpmpp_2m")
self.assertEqual(result["seed"], PLACEHOLDERS["seed"])
def test_no_sampler_reads_loose_settings(self):
result = run("no-sampler-loose-settings")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(
(result["seed"], result["steps"], result["cfgScale"], result["sampler"]),
(9, 12, 2, "ddim"),
)
self.assertEqual(result["checkpoint"], "example.safetensors")
def test_linked_seed_prefers_the_input_of_the_same_name(self):
self.assertEqual(run("primitive-seed")["seed"], 77)
class ReadsTheWorkflow(unittest.TestCase):
def test_workflow_only_file(self):
self.assertEqual(
run("workflow-only"),
{
"positivePrompt": "a tidepool at noon",
"negativePrompt": "noisy",
"checkpoint": "example.safetensors",
"steps": 25,
"cfgScale": 6.5,
"sampler": "dpmpp_2m",
"seed": 314,
},
)
class StaysBounded(unittest.TestCase):
def test_cycles_terminate(self):
result = run("conditioning-cycle")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
def test_long_chains_resolve_within_the_visit_limit(self):
self.assertEqual(
run("chain-within-visit-limit")["positivePrompt"], "deep chain text"
)
def test_chains_beyond_the_visit_limit_stop_without_recursion_errors(self):
result = run("chain-beyond-visit-limit")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "deep chain text")
def test_the_visit_limit_boundary_matches_the_browser_parser(self):
# 499 pass-through nodes and the encoder are exactly 500 visits.
self.assertEqual(
run("chain-at-last-visit")["positivePrompt"], "deep chain text"
)
self.assertEqual(
run("chain-one-past-last-visit")["positivePrompt"],
PLACEHOLDERS["positivePrompt"],
)
def test_very_long_chains_do_not_exhaust_the_python_stack(self):
graph = {"1": {"class_type": "CLIPTextEncode", "inputs": {"text": "t"}}}
for index in range(5000):
graph[str(10 + index)] = {
"class_type": "P",
"inputs": {"conditioning": [str(9 + index if index else 1), 0]},
}
graph["2"] = {
"class_type": "KSampler",
"inputs": {
"positive": ["5009", 0],
"negative": ["1", 0],
"model": ["1", 0],
},
}
result = extract_generation_params({"prompt": graph})
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "t")
class DeeplyNestedValues(unittest.TestCase):
def test_deeply_nested_values_do_not_exhaust_the_python_stack(self):
result = run("deeply-nested-values")
self.assertEqual(result["positivePrompt"], "nested neighbours")
class Placeholders(unittest.TestCase):
def test_blank_text_is_a_placeholder_and_zero_seed_is_kept(self):
result = run("blank-text-and-zero-seed")
self.assertEqual(result["positivePrompt"], PLACEHOLDERS["positivePrompt"])
self.assertEqual(result["negativePrompt"], "kept negative")
self.assertEqual(result["seed"], 0)
def test_nothing_recorded(self):
self.assertEqual(run("nothing"), dict(PLACEHOLDERS))
self.assertEqual(run("unparsable-records"), dict(PLACEHOLDERS))
class MalformedInput(unittest.TestCase):
def test_a_null_workflow_node_fails_like_the_browser_parser(self):
with self.assertRaises(GenerationParamsError):
run("null-workflow-node")
class ParsesLikeJavaScript(unittest.TestCase):
def test_rejects_non_standard_numbers(self):
for text in ('{"a": NaN}', '{"a": Infinity}', "[-Infinity]", "not json", ""):
with self.subTest(text=text):
self.assertIsNone(parse_js_json(text))
def test_large_integers_round_like_doubles(self):
self.assertEqual(
parse_js_json('{"seed": 9007199254740993}'), {"seed": 9007199254740992.0}
)
self.assertEqual(
parse_js_json('{"seed": 9007199254740991}'), {"seed": 9007199254740991}
)
def test_seeds_beyond_double_precision_round_like_the_browser(self):
self.assertEqual(
run("seed-beyond-double-precision")["seed"], 4974052037430000001.0
)
def test_duplicate_keys_keep_the_last_value(self):
self.assertEqual(parse_js_json('{"a": 1, "a": 2}'), {"a": 2})
if __name__ == "__main__":
unittest.main()
-637
View File
@@ -1,637 +0,0 @@
"""Randomized differential test: the Python parser equals the browser parser.
Generates seeded random API graphs and UI workflows, from realistic to
malformed, and requires utils.generation_params to return exactly what
web/js/comfy-metadata.js extractGenerationParams returns, or to fail where it
throws. Runs only where Node is available. Set PM_PARAMS_FUZZ_CASES to run more.
"""
import json
import os
import random
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
from tests.test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from test_generation_params_js_parity import (
NODE,
js_equal,
run_oracle,
run_python,
) # noqa: E402
CASE_COUNT = int(os.environ.get("PM_PARAMS_FUZZ_CASES", "3000"))
SEEDS = (20261020, 7, 424242)
NODE_IDS = [
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"9",
"10",
"12",
"25",
"26",
"100",
"1300:5",
"1300:8",
"x",
"05",
"4294967295",
"-1",
"constructor",
]
CLASS_TYPES = [
"KSampler",
"KSamplerAdvanced",
"SamplerCustomAdvanced",
"CFGGuider",
"BasicGuider",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"PromptManagerText",
"StringConcatenate",
"PrimitiveString",
"ControlNetApplyAdvanced",
"ConditioningCombine",
"CheckpointLoaderSimple",
"UNETLoader",
"LoraLoader",
"RandomNoise",
"KSamplerSelect",
"BasicScheduler",
"Reroute",
"MyKSamplerThing",
"SaveImage",
"FaceDetailer",
"toString",
"",
]
INPUT_KEYS = [
"text",
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"seed",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"ckpt_name",
"unet_name",
"model_name",
"prepend_text",
"append_text",
"negative_text",
"delimiter",
"string_a",
"string_b",
"value",
"text_g",
"prompt",
"0",
"10",
"clip",
]
TEXTS = [
"a cat",
" padded text ",
"",
" ",
"bom text ",
"line one\nline two",
"masterpiece",
"masterpiece, a dog",
"4k",
"a dog 4k",
"ünïcödé ✓",
", ",
]
WORKFLOW_TYPES = [
"KSampler",
"KSamplerAdvanced",
"CLIPTextEncode",
"PromptManager",
"PromptManagerPair",
"CheckpointLoaderSimple",
"CheckpointLoader",
"UNETLoader",
"MyUnetLoader",
"Reroute",
"GetNode",
"SetNode",
"Note",
"toString",
"valueOf",
]
class Generator:
def __init__(self, seed):
self.random = random.Random(seed)
def pick(self, items):
return self.random.choice(items)
def chance(self, probability):
return self.random.random() < probability
def number(self):
return self.pick(
[
0,
1,
2,
3,
5,
20,
7,
1.0,
1.5,
6.5,
0.45,
1.5e-7,
2.5e21,
-3,
9007199254740991,
]
)
def link(self):
return [
self.pick(NODE_IDS + [3, 12, 5.0]),
self.pick([0, 1, 2, 3, 1.0, 0, 0, 0, -1]),
]
def value(self, key):
roll = self.random.random()
if (
key
in (
"positive",
"negative",
"model",
"conditioning",
"conditioning_1",
"conditioning_to",
"guider",
"sigmas",
"noise",
"sampler",
"clip",
)
and roll < 0.8
):
return self.link()
if roll < 0.3:
return self.link()
if roll < 0.6:
return self.pick(TEXTS)
if roll < 0.85:
return self.number()
return self.pick(
[True, False, None, [1, 2, 3], {"nested": "x"}, [], "randomize"]
)
def inputs(self):
if self.chance(0.04):
return self.pick([[], ["text"], "text", 3, None, True])
keys = self.random.sample(INPUT_KEYS, self.random.randint(0, 9))
return {key: self.value(key) for key in keys}
def node(self):
if self.chance(0.03):
return self.pick([None, "node", 5, [1, 2]])
node = {"class_type": self.pick(CLASS_TYPES), "inputs": self.inputs()}
if self.chance(0.05):
del node["class_type"]
return node
def graph(self):
ids = self.random.sample(NODE_IDS, self.random.randint(1, 12))
if self.chance(0.03):
return [self.node() for _ in ids]
return {node_id: self.node() for node_id in ids}
def workflow_node(self, node_id):
if self.chance(0.02):
return self.pick([None, 3, "node"])
node = {
"id": node_id,
"type": self.pick(WORKFLOW_TYPES),
"mode": self.pick([0, 0, 0, 2, 4]),
}
inputs = []
for _ in range(self.random.randint(0, 4)):
item = {
"name": self.pick(INPUT_KEYS),
"link": self.pick([None, 1, 2, 3, 4, 5, 6, 7, 8, "2"]),
"type": self.pick(["CONDITIONING", "MODEL", "CLIP", "*", "STRING"]),
}
if self.chance(0.2):
item["widget"] = {"name": self.pick(INPUT_KEYS)}
inputs.append(item)
node["inputs"] = (
inputs if not self.chance(0.03) else self.pick([{"a": 1}, "abc", None])
)
node["widgets_values"] = [
self.value(self.pick(INPUT_KEYS)) for _ in range(self.random.randint(0, 6))
]
if self.chance(0.1):
node["widgets_values"].insert(1, "randomize")
if self.chance(0.05):
node["widgets_values_named"] = {
"text": self.pick(TEXTS),
"seed": self.number(),
}
return node
def workflow(self):
if self.chance(0.03):
return self.pick([[], "workflow", {"nodes": "x"}, {"links": []}])
ids = self.random.sample(
[1, 2, 3, 4, 5, 6, 7, 8, "9", 10.0], self.random.randint(1, 8)
)
links = []
for link_id in range(1, self.random.randint(1, 9)):
origin, slot = self.pick(ids), self.pick([0, 1, 2])
if self.chance(0.5):
links.append(
[
link_id,
origin,
slot,
self.pick(ids),
0,
self.pick(["CONDITIONING", "MODEL", "*", "STRING"]),
]
)
else:
links.append(
{
"id": link_id,
"origin_id": origin,
"origin_slot": slot,
"type": self.pick(["CONDITIONING", "MODEL", "*"]),
}
)
return {
"nodes": [self.workflow_node(node_id) for node_id in ids],
"links": links,
}
# --- coherent pipelines, then corrupted ------------------------------------
def text_source(self, graph, ids, depth=0):
"""A node id producing text: an encoder, PromptManager, pair or a text chain."""
node_id = ids.pop()
kind = self.pick(
["encoder", "manager", "pair", "concat", "primitive"]
if depth < 2
else ["encoder", "manager"]
)
if kind == "encoder":
text = (
self.pick(TEXTS)
if self.chance(0.7)
else [self.text_source(graph, ids, depth + 1), 0]
)
graph[node_id] = {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
}
elif kind == "manager":
graph[node_id] = {
"class_type": self.pick(["PromptManager", "PromptManagerText"]),
"inputs": {
"text": self.pick(TEXTS),
"prepend_text": self.pick(TEXTS + ["masterpiece"]),
"append_text": self.pick(TEXTS + ["4k"]),
"clip": ["4", 1],
},
}
elif kind == "pair":
graph[node_id] = {
"class_type": "PromptManagerPair",
"inputs": {
"text": self.pick(TEXTS),
"negative_text": self.pick(TEXTS),
"prepend_text": "",
"append_text": "",
},
}
elif kind == "concat":
graph[node_id] = {
"class_type": "StringConcatenate",
"inputs": {
"string_a": (
[self.text_source(graph, ids, depth + 1), 0] if ids else "x"
),
"string_b": self.pick(TEXTS),
"delimiter": self.pick([", ", " ", 3, "|"]),
},
}
else:
graph[node_id] = {
"class_type": "PrimitiveString",
"inputs": {"value": self.pick(TEXTS)},
}
return node_id
def conditioning(self, graph, ids, role):
source = self.text_source(graph, ids)
slot = (
self.pick([0, 1, 2, 3])
if graph[source]["class_type"] == "PromptManagerPair"
else 0
)
link = [source, slot]
for _ in range(self.pick([0, 0, 1, 2, 3])):
if not ids:
break
node_id = ids.pop()
key = self.pick([role, "conditioning", "conditioning_to", "conditioning_1"])
graph[node_id] = {
"class_type": self.pick(
[
"ControlNetApplyAdvanced",
"ConditioningCombine",
"ConditioningSetArea",
]
),
"inputs": {key: link, "strength": 1.0},
}
link = [node_id, 0]
return link
def pipeline(self):
ids = [str(i) for i in self.random.sample(range(1, 400), 120)] + [
"1300:7",
"x",
"constructor",
]
self.random.shuffle(ids)
graph = {
"4": {
"class_type": self.pick(["CheckpointLoaderSimple", "UNETLoader"]),
"inputs": {
self.pick(
["ckpt_name", "unet_name", "gguf_name"]
): "model.safetensors"
},
}
}
for _ in range(self.pick([1, 1, 2, 3])):
positive, negative = self.conditioning(
graph, ids, "positive"
), self.conditioning(graph, ids, "negative")
settings = {
"seed": self.number() if self.chance(0.8) else [ids.pop(), 0],
"steps": self.number(),
"cfg": self.number(),
"sampler_name": self.pick(["euler", "lcm", "dpmpp_2m"]),
}
if settings["seed"] and isinstance(settings["seed"], list):
graph[settings["seed"][0]] = {
"class_type": "Seed",
"inputs": {"seed": self.number(), "other": "x"},
}
if self.chance(0.25):
guider = ids.pop()
graph[guider] = {
"class_type": "CFGGuider",
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
"cfg": self.number(),
},
}
noise, chooser, sigmas = ids.pop(), ids.pop(), ids.pop()
graph[noise] = {
"class_type": "RandomNoise",
"inputs": {"noise_seed": self.number()},
}
graph[chooser] = {
"class_type": "KSamplerSelect",
"inputs": {"sampler_name": "euler"},
}
graph[sigmas] = {
"class_type": "BasicScheduler",
"inputs": {"steps": self.number()},
}
graph[ids.pop()] = {
"class_type": "SamplerCustomAdvanced",
"inputs": {
"noise": [noise, 0],
"guider": [guider, 0],
"sampler": [chooser, 0],
"sigmas": [sigmas, 0],
},
}
else:
graph[ids.pop()] = {
"class_type": self.pick(
["KSampler", "KSamplerAdvanced", "FaceDetailer"]
),
"inputs": {
"model": ["4", 0],
"positive": positive,
"negative": negative,
**settings,
},
}
for _ in range(self.pick([0, 0, 1, 3])): # corruption
node_id = self.pick(list(graph))
node = graph[node_id]
if (
self.chance(0.5)
and isinstance(node, dict)
and isinstance(node.get("inputs"), dict)
and node["inputs"]
):
key = self.pick(list(graph[node_id]["inputs"]))
graph[node_id]["inputs"][key] = self.value(key)
else:
graph[node_id] = self.node()
return graph
def pipeline_workflow(self):
"""A connected workflow: loader, encoders, Reroute or Set/Get hops, sampler."""
nodes, links = [], []
def link(origin, slot, kind):
links.append(
[len(links) + 1, origin, slot, 0, 0, kind]
if self.chance(0.6)
else {
"id": len(links) + 1,
"origin_id": origin,
"origin_slot": slot,
"type": kind,
}
)
return len(links)
nodes.append(
{
"id": 4,
"type": self.pick(
["CheckpointLoaderSimple", "UNETLoader", "MyUnetLoader"]
),
"mode": 0,
"inputs": [],
"widgets_values": ["model.safetensors"],
}
)
refs = []
for index, role in enumerate(("positive", "negative")):
encoder = 10 + index
nodes.append(
{
"id": encoder,
"type": self.pick(
["CLIPTextEncode", "PromptManager", "PromptManagerText"]
),
"mode": self.pick([0, 0, 0, 4]),
"inputs": [
{"name": "clip", "link": link(4, 1, "CLIP"), "type": "CLIP"}
],
"widgets_values": [
self.pick(TEXTS),
"",
"",
"",
self.pick(TEXTS),
self.pick(TEXTS),
],
}
)
ref = link(encoder, 0, "CONDITIONING")
hop = self.pick(["none", "none", "reroute", "setget"])
if hop == "reroute":
nodes.append(
{
"id": 20 + index,
"type": "Reroute",
"mode": 0,
"inputs": [{"name": "", "link": ref}],
}
)
ref = link(20 + index, 0, "*")
elif hop == "setget":
name = self.pick(["cond_" + role, "shared"])
nodes.append(
{
"id": 30 + index,
"type": "SetNode",
"mode": 0,
"inputs": [{"name": "c", "link": ref}],
"widgets_values": [name],
}
)
nodes.append(
{
"id": 40 + index,
"type": "GetNode",
"mode": 0,
"inputs": [],
"widgets_values": [name],
}
)
ref = link(40 + index, 0, "CONDITIONING")
refs.append(ref)
widgets = [
self.number(),
self.pick(["randomize", "fixed", "x"]),
self.number(),
self.number(),
"euler",
"normal",
1,
]
nodes.append(
{
"id": 3,
"type": self.pick(["KSampler", "KSamplerAdvanced"]),
"mode": 0,
"inputs": [
{"name": "model", "link": link(4, 0, "MODEL"), "type": "MODEL"},
{"name": "positive", "link": refs[0], "type": "CONDITIONING"},
{"name": "negative", "link": refs[1], "type": "CONDITIONING"},
],
"widgets_values": widgets,
}
)
if self.chance(0.3):
nodes[self.random.randrange(len(nodes))] = self.workflow_node(
self.pick([1, 2, 99])
)
return {"nodes": nodes, "links": links}
def case(self):
roll = self.random.random()
structured = self.chance(0.7)
prompt = (
json.dumps(self.pipeline() if structured else self.graph())
if roll < 0.75
else None
)
workflow = (
json.dumps(self.pipeline_workflow() if structured else self.workflow())
if roll > 0.45
else None
)
if self.chance(0.02):
prompt = self.pick(['{"1": NaN}', "{", "null", "[]", '"text"', "12"])
return {"prompt": prompt, "workflow": workflow}
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class RandomGraphs(unittest.TestCase):
def test_random_graphs_match_the_browser_parser(self):
for seed in SEEDS:
generator = Generator(seed)
cases = [generator.case() for _ in range(CASE_COUNT)]
expected = run_oracle(cases)
self.assertEqual(len(expected), len(cases))
mismatches = []
for index, (case, result) in enumerate(zip(cases, expected)):
actual = run_python(case)
if not js_equal(actual, result):
mismatches.append((index, case, actual, result))
with self.subTest(seed=seed):
outcomes = {
"ok": sum(r["ok"] for r in expected),
"throws": sum(not r["ok"] for r in expected),
}
self.assertEqual(
mismatches[:3],
[],
f"{len(mismatches)} of {len(cases)} differ; outcomes {outcomes}",
)
if __name__ == "__main__":
unittest.main()
-155
View File
@@ -1,155 +0,0 @@
"""The Python generation-parameter parser equals the browser parser.
Golden vectors (tests/fixtures/generation_params/vectors.json) hold the browser
parser's results for every named case and every PNG fixture, so this runs on
machines without Node. With Node, the live browser parser is compared too and
the vectors are checked for staleness. Regenerate them with:
python tests/test_generation_params_js_parity.py --write-vectors
"""
import json
import math
import shutil
import subprocess # nosec B404 - runs the repository's own Node oracle
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from utils.generation_params import ( # noqa: E402
GenerationParamsError,
extract_from_records,
)
try:
from tests.generation_params_cases import CASES # noqa: E402
except ImportError: # discovered with tests/ as the top-level directory
from generation_params_cases import CASES # noqa: E402
VECTORS = ROOT / "tests" / "fixtures" / "generation_params" / "vectors.json"
ORACLE = ROOT / "tests" / "js" / "support" / "generation-params-oracle.mjs"
FIXTURE_DIRS = [ROOT / "tests" / "fixtures"]
NODE = shutil.which("node")
def fixture_cases():
"""Text records of every PNG fixture, keyed by its repository path."""
from PIL import Image
cases = {}
for directory in FIXTURE_DIRS:
for path in sorted(directory.rglob("*.png")):
with Image.open(path) as image:
info = {k: v for k, v in image.info.items() if isinstance(v, str)}
if "prompt" in info or "workflow" in info:
name = path.relative_to(ROOT).as_posix()
cases[name] = {
"prompt": info.get("prompt"),
"workflow": info.get("workflow"),
}
return cases
def all_cases():
return {**{f"case:{k}": v for k, v in CASES.items()}, **fixture_cases()}
def run_oracle(cases):
"""Browser parser results for case records, in order."""
lines = "".join(json.dumps(case) + "\n" for case in cases)
completed = subprocess.run( # nosec B603 - fixed argv, no shell
[NODE, str(ORACLE)],
input=lines,
capture_output=True,
text=True,
encoding="utf-8",
check=True,
timeout=600,
)
return [json.loads(line) for line in completed.stdout.splitlines()]
def run_python(case):
try:
return {
"ok": True,
"result": extract_from_records(case["prompt"], case["workflow"]),
}
except GenerationParamsError:
return {"ok": False}
def js_equal(left, right):
"""Equality as JavaScript values: 1 == 1.0, but true is not 1."""
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if isinstance(left, (int, float)) and isinstance(right, (int, float)):
# Every JavaScript number is a double; compare as doubles.
left, right = float(left), float(right)
return left == right or (math.isnan(left) and math.isnan(right))
if isinstance(left, list) and isinstance(right, list):
return len(left) == len(right) and all(
js_equal(a, b) for a, b in zip(left, right)
)
if isinstance(left, dict) and isinstance(right, dict):
return left.keys() == right.keys() and all(
js_equal(left[k], right[k]) for k in left
)
return type(left) is type(right) and left == right
def write_vectors():
cases = all_cases()
results = run_oracle(cases.values())
vectors = [
{"name": name, **case, "expected": result}
for (name, case), result in zip(cases.items(), results)
]
VECTORS.parent.mkdir(parents=True, exist_ok=True)
VECTORS.write_text(json.dumps(vectors, indent=1, sort_keys=True) + "\n", "utf-8")
print(f"wrote {len(vectors)} vectors to {VECTORS.relative_to(ROOT)}")
class GoldenVectors(unittest.TestCase):
def test_every_vector_matches(self):
vectors = json.loads(VECTORS.read_text("utf-8"))
self.assertGreater(len(vectors), len(CASES))
for vector in vectors:
with self.subTest(vector=vector["name"]):
actual = run_python(vector)
self.assertTrue(
js_equal(actual, vector["expected"]), (actual, vector["expected"])
)
def test_vectors_cover_every_case_and_fixture(self):
names = {vector["name"] for vector in json.loads(VECTORS.read_text("utf-8"))}
self.assertEqual(names, set(all_cases()))
@unittest.skipUnless(NODE, "Node is required to run the browser parser")
class LiveBrowserParser(unittest.TestCase):
def test_live_results_match_python_and_the_vectors(self):
cases = all_cases()
stored = {
vector["name"]: vector["expected"]
for vector in json.loads(VECTORS.read_text("utf-8"))
}
for (name, case), expected in zip(cases.items(), run_oracle(cases.values())):
with self.subTest(case=name):
self.assertTrue(
js_equal(run_python(case), expected), (run_python(case), expected)
)
self.assertTrue(
js_equal(stored[name], expected),
"vectors are stale: regenerate them",
)
if __name__ == "__main__":
if "--write-vectors" in sys.argv:
write_vectors()
else:
unittest.main()
+3 -11
View File
@@ -8,7 +8,6 @@ leaves it unresolved, while the inspection profile resolves it.
import logging
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
from utils import prompt_pair_resolver as resolver
@@ -73,19 +72,12 @@ class TestIngestionKeepsDefaultProfile(unittest.TestCase):
mixin = admin.AdminRoutesMixin.__new__(admin.AdminRoutesMixin)
mixin.db = Mock()
mixin.db.get_prompt_by_hash.return_value = None
mixin.db.save_prompt.return_value = 7
spy = _Spy()
with patch.object(admin, "resolve_prompt_pair", spy):
status = mixin._ingest_scanned_file(Path("/out/f03.png"), _f03_metadata())
status = mixin._ingest_scanned_file("/out/f03.png", _f03_metadata())
self.assertEqual(status, "unresolved")
spy.assert_default_and_gated(self)
# The resolver leaves f03 unresolved; the scan links the prompt the
# browser parser traces, as the inspector shows it.
self.assertEqual(status, "added")
self.assertEqual(
mixin.db.save_prompt.call_args.args[0], "a red fox in snow, detailed fur"
)
mixin.db.link_image_to_prompt.assert_called_once()
mixin.db.assert_not_called()
if __name__ == "__main__":
+9 -67
View File
@@ -17,14 +17,6 @@ def graph(positive="cat", negative="blur"):
}
def sampled_graph(positive="cat", negative="blur"):
"""A KSampler with a model link, as real workflows have."""
g = graph(positive, negative)
g["4"] = {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "m"}}
g["3"]["inputs"]["model"] = ["4", 0]
return g
class TestPairScan(AdminAPITestCase):
def test_pair_scan_repeated_is_unchanged_and_empty_negative_is_supported(self):
path = self.output_dir / "pair.png"
@@ -36,66 +28,16 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(pairs[0]["negative_text"], "")
self.assertEqual(pairs[0]["run_count"], 0)
def _scanned(self, name, prompt_graph):
meta = {"prompt": json.dumps(prompt_graph)}
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_samplers_sharing_one_prompt_link_that_prompt(self):
g = sampled_graph()
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("multi.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_samplers_with_different_prompts_link_the_first_sampler_prompt(self):
# The prompt the inspector and the legacy panels show for this file.
g = sampled_graph()
g["6"] = {"class_type": "CLIPTextEncode", "inputs": {"text": "dog"}}
g["12"] = {**g["3"], "inputs": {**g["3"]["inputs"], "positive": ["6", 0]}}
self.assertEqual(self._scanned("different.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_an_unsupported_pass_through_still_links_the_traced_prompt(self):
g = sampled_graph()
g["7"] = {
"class_type": "MyConditioningTweak",
"inputs": {"conditioning": ["1", 0]},
}
g["3"]["inputs"]["positive"] = ["7", 0]
g["5"] = {**g["3"]}
self.assertEqual(self._scanned("tweak.png", g), "added")
self.assertEqual(self._texts(), ["cat"])
def test_a_graph_without_a_traceable_prompt_saves_nothing(self):
g = sampled_graph()
g["5"] = {**g["3"]}
for sampler in ("3", "5"):
g[sampler]["inputs"]["positive"] = ["9", 0]
g["9"] = {
"class_type": "BasicGuider",
"inputs": {"width": 2048, "height": 2048},
}
self.assertEqual(self._scanned("untraceable.png", g), "ambiguous")
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
self.assertEqual(self._texts(), [])
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
# The pre-redesign scan stored "2048" from a resolution widget here.
g = {
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": ["8", 0]}},
"8": {"class_type": "DisplayText", "inputs": {"text_size": 2048}},
"3": {
"class_type": "SamplerCustomAdvanced",
"inputs": {"guider": ["4", 0], "sigmas": ["5", 0]},
},
"4": {"class_type": "BasicGuider", "inputs": {"conditioning": ["1", 0]}},
}
self.assertIn(
self._scanned("numbers.png", g), ("ambiguous", "unresolved", "found")
def test_ambiguous_sampler_scan_does_not_save_a_guess(self):
g = graph()
g["4"] = {**g["3"]}
meta = {"prompt": json.dumps(g)}
self.assertEqual(
self.api._ingest_scanned_file(self.output_dir / "multi.png", meta),
"ambiguous",
)
self.assertEqual(self._texts(), [])
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
self.assertEqual(self.api.db.get_recent_prompts()["prompts"], [])
def test_batch_exposes_pair_counts_and_actual_unchanged_status(self):
meta = {"parameters": "cat\nNegative prompt: blur\nSteps: 20, Sampler: Euler"}
-966
View File
@@ -1,966 +0,0 @@
"""Generation parameters traced from ComfyUI metadata: the browser parser in Python.
A port of ``extractGenerationParams`` in ``web/js/comfy-metadata.js``, the parser
behind the gallery, admin and metadata pages. For the same records it returns
what that function returns; ``tests/test_generation_params_js_parity.py``
enforces it against the JavaScript itself.
It traces the API ``prompt`` graph from the sampler: the positive and negative
inputs back to their text nodes, the model link back to a loader, and the
sampler settings, following primitives and pass-through nodes. Fields the API
graph cannot give are read from the UI ``workflow`` graph.
The result depends on JavaScript value semantics, so those are reproduced
explicitly: property order, truthiness, ``String()``, ``trim()``, strict
equality, ``Map``/``Set`` keys, and the inputs on which the JavaScript throws
(raised here as ``GenerationParamsError``). The recursive tracing runs on an
explicit stack, so a crafted graph cannot exhaust the Python stack.
"""
import json
import math
import re
from types import MappingProxyType
__all__ = [
"PLACEHOLDERS",
"GenerationParamsError",
"extract_generation_params",
"extract_from_records",
"parse_js_json",
]
PLACEHOLDERS = MappingProxyType(
{
"positivePrompt": "No prompt found",
"negativePrompt": "No negative prompt found",
"checkpoint": "Unknown",
"steps": "Unknown",
"cfgScale": "Unknown",
"sampler": "Unknown",
"seed": "Unknown",
}
)
# Upper bound on nodes expanded per lookup; crafted files can contain any graph.
MAX_VISITS = 500
MODEL_NAME_KEYS = ("ckpt_name", "unet_name", "model_name", "gguf_name")
SEED_CONTROL_VALUES = frozenset(("fixed", "increment", "decrement", "randomize"))
PASS_THROUGH_TYPES = frozenset(("Reroute", "GetNode", "SetNode"))
MODE_MUTED = 2
MODE_BYPASSED = 4
# Widget order for core nodes, used when a workflow carries no widget names.
PROMPT_MANAGER_WIDGETS = (
"text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
)
WIDGET_POSITIONS = {
"KSampler": ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"),
"KSamplerAdvanced": (
"add_noise",
"noise_seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"start_at_step",
"end_at_step",
"return_with_leftover_noise",
),
"CLIPTextEncode": ("text",),
"PromptManager": PROMPT_MANAGER_WIDGETS,
"PromptManagerText": PROMPT_MANAGER_WIDGETS,
"PromptManagerFocused": PROMPT_MANAGER_WIDGETS,
"PromptManagerSplit": PROMPT_MANAGER_WIDGETS,
"PromptManagerPair": (
"text",
"negative_text",
"category",
"tags",
"search_text",
"prepend_text",
"append_text",
),
"CheckpointLoader": ("config_name", "ckpt_name"),
"UNETLoader": ("unet_name", "weight_dtype"),
}
# JavaScript regular expressions without the ``u`` flag fold case in ASCII only.
_FLAGS = re.IGNORECASE | re.ASCII
TEXT_KEYS = re.compile(r"(text|string|prompt|value)(_?[a-z0-9]+)?", _FLAGS)
_PROMPT_MANAGER = re.compile(r"promptmanager", _FLAGS)
_KSAMPLER = re.compile(r"ksampler", _FLAGS)
_CHECKPOINT_LOADER = re.compile(r"checkpointloader", _FLAGS)
_UNET = re.compile(r"unet", _FLAGS)
_SEED_NAME = re.compile(r"seed\Z")
_ARRAY_INDEX = re.compile(r"(?:0|[1-9][0-9]*)\Z")
_MAX_ARRAY_INDEX = 2**32 - 2
_MAX_SAFE_INTEGER = 2**53
# String.prototype.trim() removes WhiteSpace and LineTerminator code points.
_JS_WHITESPACE = "\t\n\v\f\r          " "    

   "
# Properties every object literal inherits; reading one yields a function or object.
_OBJECT_PROTOTYPE_KEYS = frozenset(
(
"constructor",
"hasOwnProperty",
"isPrototypeOf",
"propertyIsEnumerable",
"toLocaleString",
"toString",
"valueOf",
"__proto__",
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
)
)
class GenerationParamsError(ValueError):
"""The browser parser throws on this input, so there is no result."""
class _Undefined:
"""JavaScript ``undefined``: a missing property, distinct from JSON ``null``."""
__slots__ = ()
def __repr__(self):
return "undefined"
UNDEFINED = _Undefined()
# --- JavaScript value semantics -------------------------------------------------
def _is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def _nullish(value):
return value is None or value is UNDEFINED
def _truthy(value):
if value is None or value is UNDEFINED or value is False:
return False
if _is_number(value):
return value != 0 and not (isinstance(value, float) and math.isnan(value))
if isinstance(value, str):
return value != ""
return True
def _typeof(value):
if value is UNDEFINED:
return "undefined"
if isinstance(value, bool):
return "boolean"
if _is_number(value):
return "number"
if isinstance(value, str):
return "string"
return "object"
def _number_string(value):
"""``String(number)`` for a double, per the ECMAScript Number::toString rules."""
if isinstance(value, int):
return str(value)
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "Infinity" if value > 0 else "-Infinity"
if value == 0:
return "0"
sign = "-" if value < 0 else ""
text = repr(abs(value))
mantissa, _, exponent = text.partition("e")
whole, _, fraction = mantissa.partition(".")
raw = whole + fraction
leading = len(raw) - len(raw.lstrip("0"))
digits = raw.lstrip("0").rstrip("0")
point = len(whole) - leading + int(exponent or 0)
count = len(digits)
if count <= point <= 21:
body = digits + "0" * (point - count)
elif 0 < point <= 21:
body = digits[:point] + "." + digits[point:]
elif -6 < point <= 0:
body = "0." + "0" * -point + digits
else:
power = point - 1
mark = ("+" if power >= 0 else "-") + str(abs(power))
body = (digits if count == 1 else digits[0] + "." + digits[1:]) + "e" + mark
return sign + body
def _js_string(value):
"""``String(value)`` for any JSON-derived value."""
if value is UNDEFINED:
return "undefined"
if value is None:
return "null"
if value is True:
return "true"
if value is False:
return "false"
if _is_number(value):
return _number_string(value)
if isinstance(value, str):
return value
if isinstance(value, list):
return _array_string(value)
return "[object Object]"
def _array_string(value):
"""``String(array)``: ``join(",")`` applied to nested arrays, on an explicit stack.
null and undefined elements join as "". A crafted file can nest arrays far
deeper than the Python stack allows, so this never recurses.
"""
out = []
stack = [(value, 0)]
while stack:
items, index = stack.pop()
if index == len(items):
continue
if index:
out.append(",")
stack.append((items, index + 1))
item = items[index]
if isinstance(item, list):
stack.append((item, 0))
elif not _nullish(item):
out.append(_js_string(item))
return "".join(out)
def _key(value):
return value if isinstance(value, str) else _js_string(value)
def _index(key):
"""The array index a property key names, or None."""
if (
_is_number(key)
and not isinstance(key, bool)
and float(key).is_integer()
and key >= 0
):
number = int(key)
return number if number <= _MAX_ARRAY_INDEX else None
text = _key(key)
if _ARRAY_INDEX.match(text) and int(text) <= _MAX_ARRAY_INDEX:
return int(text)
return None
def _get(obj, key):
"""Property read ``obj[key]``; reading from null or undefined throws."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError(f"cannot read {key!r} of {_js_string(obj)}")
if isinstance(obj, dict):
return obj.get(_key(key), UNDEFINED)
if isinstance(obj, (list, str)):
if key == "length":
return len(obj)
index = _index(key)
return obj[index] if index is not None and index < len(obj) else UNDEFINED
return UNDEFINED
def _has(obj, key):
"""``key in obj``; throws on primitives."""
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, list):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
raise GenerationParamsError(f"cannot use 'in' on {_typeof(obj)}")
def _own(obj, key):
"""``Object.prototype.hasOwnProperty.call(obj, key)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("hasOwnProperty on null or undefined")
if isinstance(obj, dict):
return _key(key) in obj
if isinstance(obj, (list, str)):
index = _index(key)
return key == "length" or (index is not None and index < len(obj))
return False
def _ordered_keys(obj):
"""Own property order: array indexes ascending, then other keys as inserted."""
indexes = sorted(
(k for k in obj if _ARRAY_INDEX.match(k) and int(k) <= _MAX_ARRAY_INDEX),
key=int,
)
marked = set(indexes)
return indexes + [k for k in obj if k not in marked]
def _entries(obj):
"""``Object.entries(obj)``."""
if obj is None or obj is UNDEFINED:
raise GenerationParamsError("Object.entries of null or undefined")
if isinstance(obj, dict):
return [(k, obj[k]) for k in _ordered_keys(obj)]
if isinstance(obj, (list, str)):
return [(str(i), item) for i, item in enumerate(obj)]
return []
def _values(obj):
"""``Object.values(obj)``."""
return [value for _, value in _entries(obj)]
def _strict_equal(left, right):
"""``left === right``."""
if left is UNDEFINED or right is UNDEFINED or left is None or right is None:
return left is right
if isinstance(left, bool) or isinstance(right, bool):
return isinstance(left, bool) and isinstance(right, bool) and left == right
if _is_number(left) and _is_number(right):
return left == right
if isinstance(left, str) and isinstance(right, str):
return left == right
return left is right
def _same_value_key(value):
"""A hashable key with ``Map``/``Set`` (SameValueZero) equality."""
if value is UNDEFINED:
return ("undefined", None)
if value is None:
return ("null", None)
if isinstance(value, bool):
return ("boolean", value)
if _is_number(value):
return (
("nan", None)
if isinstance(value, float) and math.isnan(value)
else ("number", value)
)
if isinstance(value, str):
return ("string", value)
return ("object", id(value))
def _trim(text):
return text.strip(_JS_WHITESPACE)
def _first_truthy(*values):
"""``a || b || ...``: the first truthy operand, else the last."""
for value in values:
if _truthy(value):
return value
return values[-1]
def _and_chain(obj, *steps):
"""``a && a[k1] && a[k1][k2] ...``: stops at the first falsy operand."""
value = obj
for step in steps:
if not _truthy(value):
return value
value = _get(value, step)
return value
def _coalesce(value, fallback):
"""``value ?? fallback()``; the fallback runs only when needed."""
return fallback() if _nullish(value) else value
def _array(value):
"""A value used with an Array method; anything else throws."""
if isinstance(value, list):
return value
raise GenerationParamsError(f"{_typeof(value)} has no array methods")
def _iterate(value):
"""``for (const x of value)``: arrays and strings iterate, other values throw."""
if isinstance(value, (list, str)):
return value
raise GenerationParamsError(f"{_typeof(value)} is not iterable")
def _set(obj, key, value):
"""``obj[key] = value`` on an object literal (``__proto__`` is not an own key)."""
name = _key(key)
if name == "__proto__" and name not in obj:
return
obj[name] = value
def _matches(pattern, value):
"""``pattern.test(value || "")``."""
return pattern.search(_js_string(value if _truthy(value) else "")) is not None
def _run(generator):
"""Drive generator-based recursion on an explicit stack, not the call stack."""
stack = [generator]
value = None
while stack:
try:
request = stack[-1].send(value)
except StopIteration as done:
stack.pop()
value = done.value
continue
stack.append(request)
value = None
return value
# --- Tracing ---------------------------------------------------------------------
def _is_link(value):
return (
isinstance(value, list)
and len(value) == 2
and (isinstance(value[0], str) or _is_number(value[0]))
and _is_number(value[1])
)
def _is_sampler(node):
"""A KSampler or a guider (CFGGuider); the model link excludes pass-throughs."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "positive"))
and _is_link(_get(inputs, "negative"))
and _is_link(_get(inputs, "model"))
)
def _is_custom_sampler(node):
"""SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas."""
if not _truthy(node):
return False
inputs = _get(node, "inputs")
return (
_truthy(inputs)
and _is_link(_get(inputs, "guider"))
and _is_link(_get(inputs, "sigmas"))
)
def _workflow_nodes(workflow):
if not _truthy(workflow):
return []
nodes = _get(workflow, "nodes")
return nodes if isinstance(nodes, list) else []
def _widgets_of(node):
values = _get(node, "widgets_values")
return values if isinstance(values, list) else []
class _Context:
"""The graph being traced and recovered PromptManager widget text."""
__slots__ = ("graph", "widget_text")
def __init__(self, graph, widget_text):
self.graph = graph
self.widget_text = widget_text
def _create_context(graph, workflow):
"""Recover PromptManager text overwritten by the pre-3.2.3 SaveImage patch.
The signature is two or more PromptManager nodes with identical API text
whose workflow widgets differ; only those get their widget text back.
Otherwise the API graph is what actually ran and always wins.
"""
widgets = {}
for node in _workflow_nodes(workflow):
text = _get(_widgets_of(node), 0)
node_type = _get(node, "type")
if (
not _strict_equal(node_type, "PromptManagerPair")
and _matches(_PROMPT_MANAGER, node_type)
and isinstance(text, str)
):
widgets[_js_string(_get(node, "id"))] = text
by_api_text = {}
for node_id in widgets:
api_text = _and_chain(graph, node_id, "inputs", "text")
if isinstance(api_text, str):
by_api_text.setdefault(api_text, []).append(node_id)
widget_text = {}
for ids in by_api_text.values():
if len(ids) > 1 and len({widgets[node_id] for node_id in ids}) > 1:
for node_id in ids:
widget_text[node_id] = widgets[node_id]
return _Context(graph, widget_text)
def _enter(context, link, seen):
"""``(node_id, node)`` for a link; None when unresolved or already expanded."""
if not _is_link(link) or len(seen) >= MAX_VISITS:
return None
node_id = _js_string(link[0])
node = _get(context.graph, node_id)
key = f"{node_id}:{_js_string(link[1])}"
if not _truthy(node) or not _truthy(_get(node, "inputs")) or key in seen:
return None
seen.add(key)
return node_id, node
def _resolve_scalar(context, value, name, seen):
"""A scalar input, following a link; an input named like the field wins."""
if not _is_link(value):
return value
entered = _enter(context, value, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
if _truthy(name) and _own(inputs, name):
return (yield _resolve_scalar(context, _get(inputs, name), name, seen))
for item in _values(inputs):
resolved = yield _resolve_scalar(context, item, name, seen)
if resolved is not UNDEFINED and _typeof(resolved) != "object":
return resolved
return UNDEFINED
def _resolve_linked_input(context, link, keys):
"""The first of ``keys`` on the node a link points to, resolving further links."""
seen = set()
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in keys if _has(inputs, k)), None)
if key is None:
return UNDEFINED
return (yield _resolve_scalar(context, _get(inputs, key), key, seen))
def _resolve_string(context, value, seen):
"""A string input; text-producing nodes may be chained (concat, primitives)."""
if isinstance(value, str):
return value
entered = _enter(context, value, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(value[1], 3):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(value[1], 2):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
delimiter = _get(inputs, "delimiter")
if not isinstance(delimiter, str):
delimiter = " "
parts = []
for key, item in _entries(inputs):
if TEXT_KEYS.fullmatch(key):
parts.append((yield _resolve_string(context, item, seen)))
return delimiter.join(part for part in parts if _trim(part))
def _compose_node_text(context, node_id, node, seen):
"""An encoder's text, joined like PromptManager: prepend + text + append."""
inputs = _get(node, "inputs")
if node_id in context.widget_text:
raw = context.widget_text[node_id]
else:
raw = yield _resolve_string(context, _get(inputs, "text"), seen)
text = _trim(raw)
if not text:
return ""
# One shared set even across siblings keeps crafted chains linear.
prepend = _trim(
(yield _resolve_string(context, _get(inputs, "prepend_text"), seen))
)
append = _trim((yield _resolve_string(context, _get(inputs, "append_text"), seen)))
# Older saves stored the already-combined prompt in ``text``.
parts = (
prepend if prepend and not text.startswith(prepend) else "",
text,
append if append and not text.endswith(append) else "",
)
return " ".join(part for part in parts if part)
def _trace_conditioning(context, link, role, seen):
"""Follow a conditioning link upstream to the encoder that produced it."""
entered = _enter(context, link, seen)
if entered is None:
return ""
node_id, node = entered
inputs = _get(node, "inputs")
if _strict_equal(_get(node, "class_type"), "PromptManagerPair"):
if _strict_equal(link[1], 1):
return (yield _resolve_string(context, _get(inputs, "negative_text"), seen))
if _strict_equal(link[1], 0):
return (yield _compose_node_text(context, node_id, node, seen))
return ""
if _has(inputs, "text"):
return (yield _compose_node_text(context, node_id, node, seen))
# Pass-through nodes (ControlNet apply, conditioning combine/set area, ...).
following = _first_truthy(
_get(inputs, role),
_get(inputs, "conditioning"),
_get(inputs, "conditioning_to"),
_get(inputs, "conditioning_1"),
)
return (yield _trace_conditioning(context, following, role, seen))
def _trace_model_name(context, link, seen):
"""Follow the model link upstream (through LoRA loaders etc.) to a loader."""
entered = _enter(context, link, seen)
if entered is None:
return UNDEFINED
inputs = _get(entered[1], "inputs")
key = next((k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None)
if key is not None:
return _get(inputs, key)
return (yield _trace_model_name(context, _get(inputs, "model"), seen))
def _find_any_model_name(nodes):
for node in nodes:
inputs = _and_chain(node, "inputs")
if not _truthy(inputs):
inputs = {}
key = next(
(k for k in MODEL_NAME_KEYS if isinstance(_get(inputs, k), str)), None
)
if key is not None:
return _get(inputs, key)
return UNDEFINED
def _from_prompt_graph(graph, workflow):
context = _create_context(graph, workflow)
nodes = _values(graph)
custom = next((node for node in nodes if _is_custom_sampler(node)), UNDEFINED)
guider = UNDEFINED
if custom is not UNDEFINED:
entered = _enter(context, _get(_get(custom, "inputs"), "guider"), set())
guider = entered[1] if entered is not None else UNDEFINED
sampler = (
guider
if _is_sampler(guider)
else next((n for n in nodes if _is_sampler(n)), UNDEFINED)
)
if sampler is UNDEFINED:
loose = next(
(
n
for n in nodes
if _truthy(n)
and _truthy(_get(n, "inputs"))
and _matches(_KSAMPLER, _get(n, "class_type"))
),
UNDEFINED,
)
inputs = _and_chain(loose, "inputs")
if not _truthy(inputs):
inputs = {}
seed = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
return {
"checkpoint": _find_any_model_name(nodes),
"seed": (yield _resolve_scalar(context, seed, "seed", set())),
"steps": (
yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
),
"cfgScale": (
yield _resolve_scalar(context, _get(inputs, "cfg"), "cfg", set())
),
"sampler": (
yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
),
}
inputs = _get(sampler, "inputs")
def from_custom(field, keys):
if custom is UNDEFINED:
return UNDEFINED
return _run(
_resolve_linked_input(context, _get(_get(custom, "inputs"), field), keys)
)
result = {
"positivePrompt": (
yield _trace_conditioning(
context, _get(inputs, "positive"), "positive", set()
)
),
"negativePrompt": (
yield _trace_conditioning(
context, _get(inputs, "negative"), "negative", set()
)
),
}
model = yield _trace_model_name(context, _get(inputs, "model"), set())
result["checkpoint"] = model if _truthy(model) else _find_any_model_name(nodes)
seed_value = _coalesce(_get(inputs, "seed"), lambda: _get(inputs, "noise_seed"))
seed_name = "seed" if _has(inputs, "seed") else "noise_seed"
seed = yield _resolve_scalar(context, seed_value, seed_name, set())
result["seed"] = _coalesce(
seed, lambda: from_custom("noise", ("noise_seed", "seed"))
)
steps = yield _resolve_scalar(context, _get(inputs, "steps"), "steps", set())
result["steps"] = _coalesce(steps, lambda: from_custom("sigmas", ("steps",)))
result["cfgScale"] = yield _resolve_scalar(
context, _get(inputs, "cfg"), "cfg", set()
)
sampler_name = yield _resolve_scalar(
context, _get(inputs, "sampler_name"), "sampler_name", set()
)
result["sampler"] = _coalesce(
sampler_name, lambda: from_custom("sampler", ("sampler_name",))
)
return result
# --- UI workflow graph -------------------------------------------------------------
def _widget_names(node):
inputs = _first_truthy(_get(node, "inputs"), [])
from_inputs = [
_get(_get(item, "widget"), "name")
for item in _array(inputs)
if _truthy(item)
and _truthy(_get(item, "widget"))
and _truthy(_get(_get(item, "widget"), "name"))
]
if from_inputs:
return from_inputs
node_type = _get(node, "type")
key = _js_string(node_type)
if key in _OBJECT_PROTOTYPE_KEYS:
raise GenerationParamsError(f"widget positions of {key!r} are not iterable")
if key in WIDGET_POSITIONS:
return list(WIDGET_POSITIONS[key])
if _matches(_CHECKPOINT_LOADER, node_type):
return ["ckpt_name"]
if _matches(_UNET, node_type):
return ["unet_name"]
return []
def _named_widgets(node):
"""Map widget values to input names, skipping control_after_generate."""
named = _get(node, "widgets_values_named")
if _truthy(named) and isinstance(named, dict):
return dict(named)
values = _widgets_of(node)
out = {}
position = 0
for name in _iterate(_widget_names(node)):
if position >= len(values):
break
_set(out, name, values[position])
position += 1
following = values[position] if position < len(values) else UNDEFINED
if (
_SEED_NAME.search(_js_string(name))
and isinstance(following, str)
and following in SEED_CONTROL_VALUES
):
position += 1
return out
def _workflow_to_graph(workflow):
"""Convert a UI workflow ({nodes, links}) into the API graph shape.
Set/Get pairs, Reroutes and bypassed nodes resolve to their real source;
muted nodes are dropped.
"""
nodes = [
n
for n in _workflow_nodes(workflow)
if _truthy(n) and not _nullish(_get(n, "id"))
]
by_id = {}
for node in nodes:
by_id[_js_string(_get(node, "id"))] = node
links = {}
raw_links = _get(workflow, "links")
for link in raw_links if isinstance(raw_links, list) else []:
if isinstance(link, list):
links[_same_value_key(_get(link, 0))] = (
_get(link, 1),
_get(link, 2),
_get(link, 5),
)
elif _truthy(link) and _typeof(link) == "object":
links[_same_value_key(_get(link, "id"))] = (
_get(link, "origin_id"),
_get(link, "origin_slot"),
_get(link, "type"),
)
setters = {}
for node in nodes:
if _strict_equal(_get(node, "type"), "SetNode"):
setters[_same_value_key(_get(_widgets_of(node), 0))] = _and_chain(
node, "inputs", 0, "link"
)
def source(link_id):
hops = 0
while True:
link = links.get(_same_value_key(link_id))
node = by_id.get(_js_string(link[0])) if link is not None else None
if (
node is None
or _strict_equal(_get(node, "mode"), MODE_MUTED)
or hops > MAX_VISITS
):
return UNDEFINED
hops += 1
node_type = _get(node, "type")
if _strict_equal(node_type, "Reroute"):
link_id = _and_chain(node, "inputs", 0, "link")
continue
if _strict_equal(node_type, "GetNode"):
link_id = setters.get(
_same_value_key(_get(_widgets_of(node), 0)), UNDEFINED
)
continue
if _strict_equal(_get(node, "mode"), MODE_BYPASSED):
link_type = link[2]
same = next(
(
item
for item in _array(_first_truthy(_get(node, "inputs"), []))
if _truthy(item)
and not _nullish(_get(item, "link"))
and (
not _truthy(link_type)
or _strict_equal(link_type, "*")
or _strict_equal(_get(item, "type"), link_type)
)
),
UNDEFINED,
)
if same is UNDEFINED:
return UNDEFINED
link_id = _get(same, "link")
continue
return [_js_string(_get(node, "id")), link[1]]
graph = {}
for node in nodes:
mode = _get(node, "mode")
node_type = _get(node, "type")
if (
_strict_equal(mode, MODE_MUTED)
or _strict_equal(mode, MODE_BYPASSED)
or (isinstance(node_type, str) and node_type in PASS_THROUGH_TYPES)
):
continue
inputs = _named_widgets(node)
for item in _iterate(_first_truthy(_get(node, "inputs"), [])):
if not _truthy(item) or _nullish(_get(item, "link")):
continue
found = source(_get(item, "link"))
if _truthy(found):
_set(inputs, _get(item, "name"), found)
_set(graph, _get(node, "id"), {"class_type": node_type, "inputs": inputs})
return graph
# --- Public API ----------------------------------------------------------------------
def _has_value(value):
return not _nullish(value) and not (isinstance(value, str) and value == "")
def extract_generation_params(data):
"""Prompts, model and sampler settings from parsed ``prompt``/``workflow`` records.
``data`` holds the parsed records (``{"prompt": ..., "workflow": ...}``). Each
field falls back to its placeholder when neither graph provides it.
Raises ``GenerationParamsError`` where the browser parser throws.
"""
data = data if _truthy(data) else {}
prompt = _get(data, "prompt")
workflow = _get(data, "workflow")
primary = {}
if _truthy(prompt) and _typeof(prompt) == "object":
primary = _run(_from_prompt_graph(prompt, workflow))
fallback = {}
if _truthy(workflow):
fallback = _run(_from_prompt_graph(_workflow_to_graph(workflow), None))
merged = {}
for key, placeholder in PLACEHOLDERS.items():
value = primary.get(key, UNDEFINED)
if not _has_value(value):
value = fallback.get(key, UNDEFINED)
merged[key] = value if _has_value(value) else placeholder
return merged
def _reject_constant(name):
raise ValueError(f"{name} is not JSON")
def _double_integer(text):
"""Integers parse as doubles in JavaScript; beyond 2**53 they round."""
value = int(text)
return value if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER else float(text)
def parse_js_json(text):
"""``JSON.parse(text)``, or None where it throws (or the text is ``null``)."""
if not isinstance(text, str):
return None
try:
return json.loads(
text, parse_constant=_reject_constant, parse_int=_double_integer
)
except (ValueError, RecursionError):
return None
def extract_from_records(prompt_text, workflow_text):
"""``extract_generation_params`` for raw ``prompt``/``workflow`` record text.
A record that is absent or fails to parse is treated as absent, as the
browser pages do.
"""
data = {}
for key, text in (("prompt", prompt_text), ("workflow", workflow_text)):
if isinstance(text, str):
data[key] = parse_js_json(text)
return extract_generation_params(data)
+3 -36
View File
@@ -163,43 +163,11 @@ function libraryFor(value) {
return library;
}
// The legacy workflow parse (comfy-metadata.js, as of #170) also feeds the gallery and
// admin pages; it fills any prompt or setting the server could not decide, so this page
// never shows less than they do. A definite server answer is never replaced.
const LEGACY_FACTS = [['seed', 'seed'], ['steps', 'steps'], ['cfg', 'cfgScale'], ['sampler', 'sampler'], ['model', 'checkpoint']];
function comfyMetadata() {
return globalThis.ComfyMetadata || globalThis.window?.ComfyMetadata;
}
function withWorkflowParams(record, parser = comfyMetadata()) {
const graph = record.metadata.prompt_graph.status === 'parsed' ? record.metadata.prompt_graph.value : null;
const workflow = record.metadata.workflow_graph.status === 'parsed' ? record.metadata.workflow_graph.value : null;
if (typeof parser?.extractGenerationParams !== 'function' || (!graph && !workflow)) return;
let params;
// The browser parser throws on some malformed graphs; then only the server result shows.
try { params = parser.extractGenerationParams({ prompt: graph, workflow }); } catch { return; }
const found = key => typeof params[key] === 'string' || typeof params[key] === 'number' ?
String(params[key]) !== '' && params[key] !== parser.PLACEHOLDERS?.[key] : false;
const prompts = { ...record.prompts };
for (const [role, key] of [['positive', 'positivePrompt'], ['negative', 'negativePrompt']]) {
const current = prompts[role];
if (!found(key) || current?.status === 'resolved') continue;
prompts[role] = { status: 'resolved', text: params[key], source: 'graph-inferred' };
}
record.prompts = prompts;
for (const [field, key] of LEGACY_FACTS) {
// Only open answers are filled: a known value or a definite "not recorded" stays.
if (!found(key) || !['unresolved', 'ambiguous'].includes(record.generation[field]?.status)) continue;
record.generation[field] = { status: 'known', value: String(params[key]), source: graph ? 'api-graph' : 'workflow', evidence: 'configuration', reason: null, sampler_id: null };
}
}
function unavailableFacts() {
return Object.fromEntries(FACT_FIELDS.map(key => [key, { status: 'unresolved', value: null, source: null, evidence: null, reason: null, sampler_id: null }]));
}
export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, parser = comfyMetadata()) {
export function toInspectionRecord(result, image, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts) {
const scope = validateIdentity(image);
if (scope !== null && (!object(result) || result.root_scope !== scope)) throw new InspectionSourceChangedError();
if (!object(result) || result.success === false) throw new TypeError('Invalid inspection response');
@@ -209,11 +177,11 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf
if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity');
return projectInspectionPayload(result,
{ kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope },
downloadFor(image, !legacy || scope !== null), extractFacts, legacy, parser);
downloadFor(image, !legacy || scope !== null), extractFacts, legacy);
}
/** Shared bounded payload projection; adapters validate identity before entering here. */
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false, parser = comfyMetadata()) {
export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) {
const record = {
identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null,
generation: unavailableFacts(), historyTarget: null, download,
@@ -226,7 +194,6 @@ export function projectInspectionPayload(result, identity, download, extractFact
if (typeof extractFacts !== 'function') throw new TypeError('Inspection facts parser unavailable');
record.generation = extractFacts({ prompt_graph: record.metadata.prompt_graph, workflow_graph: record.metadata.workflow_graph,
parameters: record.metadata.parameters, recordFormats: record.metadata.record_formats, metadataStatus: record.metadata.status, resolution });
withWorkflowParams(record, parser);
if (record.library.status === 'linked' && record.library.positive_id !== null) record.historyTarget = `#/library/${record.library.positive_id}`;
return record;
}
+4 -23
View File
@@ -4,8 +4,6 @@ import {rootedFileAdapter} from './inspection-state.mjs';
import {mountFileInspector} from './file-inspector.mjs';
let viewerId = 0;
// Upper bound on waiting for a closed Viewer's in-flight image view before destroying it.
const PENDING_VIEW_DESTROY_MS = 10000;
export function readViewerPreferences(getStorage = () => window.localStorage, normalize = globalThis.GallerySettings.normalizeGallerySettings) {
let stored;
try { stored = JSON.parse(getStorage().getItem('gallerySettings')); } catch { stored = null; }
@@ -129,19 +127,12 @@ export function createMediaViewer({container,parentScope,inspectionController,se
} else {
node.alt = selected.filename;node.decoding = 'async';
node.hidden = !!window.Viewer;
// With Viewer.js the src is released after the viewer (see release below).
if (!window.Viewer) {
mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
if (!window.Viewer) mediaLocal.listen(node,'load',() => {if (current()) session.ready(key);});
mediaLocal.cleanup(() => node.removeAttribute('src'));
}
node.src = url.href;media.append(node);
if (!selected.is_video && window.Viewer) {
let fitFrame = 0, fitWidth, fitHeight, viewSettled = false, released = false, fallback = 0;
const release = () => {
if (released) return;
released = true;clearTimeout(fallback);instance.destroy();node.removeAttribute('src');
};
let fitFrame = 0, fitWidth, fitHeight;
const scheduleFit = () => {
if (!current() || fitFrame) return;
fitFrame = requestAnimationFrame(() => {
@@ -154,21 +145,11 @@ export function createMediaViewer({container,parentScope,inspectionController,se
};
const instance = new window.Viewer(node,{inline:true,container:media,toolbar:false,navbar:false,title:false,button:false,
keyboard:false,fullscreen:false,transition:false,viewed() {
viewSettled = true;
if (mediaLocal.signal.aborted) { release(); return; }
if (!current()) return;
session.ready(key);scheduleFit();
}});
const observer = new ResizeObserver(scheduleFit);observer.observe(stage);
viewer = instance;mediaLocal.cleanup(() => {
observer.disconnect();cancelAnimationFrame(fitFrame);
// Inline Viewer.js keeps viewing a still-loading image after destroy(), and that view
// rebuilds its list from images that still have a src; keep both until the view
// settles, fails or times out.
if (viewSettled) { release(); return; }
node.addEventListener('error',release,{once:true});
fallback = setTimeout(release,PENDING_VIEW_DESTROY_MS);
});
viewer = instance;mediaLocal.cleanup(() => {observer.disconnect();cancelAnimationFrame(fitFrame);instance.destroy();});
}
updateControls(session.state);
}