Initial Smart Metadata Reader implementation

This commit is contained in:
CyberShadowX
2026-05-28 10:37:23 +08:00
commit 23f81f91da
37 changed files with 6085 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.pytest_cache/
.venv/
venv/
.env
.DS_Store
+133
View File
@@ -0,0 +1,133 @@
# ComfyUI-Smart-Metadata-Reader
语言 / Language: 中文 | [English](README_EN.md)
`ComfyUI-Smart-Metadata-Reader` 是一个 Python-only 的 ComfyUI 自定义节点。它的主节点 `Smart Metadata Reader` 是图片加载器型节点,可以选择或上传图片,读取图片里已经保存的 AI 生成 metadata,并输出图片、遮罩、真实正负提示词和常用生成参数。
## 项目简介
这个节点不是“看图反推提示词”。它不会分析图片内容来猜 prompt,而是读取图片文件里已有的 metadata,尤其是 ComfyUI 生成 PNG 中保存的 `prompt` 和 `workflow` JSON。
它的核心目标是处理复杂 ComfyUI workflow:从最终 selected sampler 的 `positive` / `negative` conditioning 链路反向追踪,提取真正进入最终生图步骤的正面提示词、负面提示词、模型、VAE、LoRA、seed、steps、CFG、sampler、scheduler 和尺寸等信息。
## 解决什么问题
普通 prompt reader 往往只能读取简单的 A1111 参数,或者在 ComfyUI workflow 里直接查找 `CLIPTextEncode.inputs.text`。但复杂 workflow 里,`CLIPTextEncode.inputs.text` 可能是上游链接,例如字符串拼接节点、ShowText 缓存、翻译节点、ControlNet conditioning、detailer 或其他 custom node。
本项目把 ComfyUI metadata 当成 graph 解析,而不是把 workflow 里所有文本字段扫一遍。
## 与普通 Prompt Reader 的区别
- 从最终输出图片链路选择 sampler,而不是随便取最后一个节点。
- positive / negative 只从 selected sampler 的 conditioning 链路解析。
- 不扫描整个 workflow 的所有 `text` 字段。
- 不会把 ControlNet 的 image、strength、control_net、model_name 等参数混进 prompt。
- 不会把 Gemini / OpenAI / ChatGPT / Claude / LLM 节点的 `system_instruction`、`system_prompt`、`prompt`、`instruction`、`messages` 这类模板当成最终提示词。
- `ShowText|pysssss` 有 `text_0` 缓存结果时,会优先使用缓存结果。
- 不过滤 LoRA trigger words、角色词、风格词、NSFW 词、质量词、翻译文本或用户手写标签。
## 安装方式
进入你的 ComfyUI 安装目录:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/CyberShadowX/ComfyUI-Smart-Metadata-Reader.git
cd ComfyUI-Smart-Metadata-Reader
pip install -r requirements.txt
```
安装后重启 ComfyUI。
## 使用方式
1. 在 ComfyUI 中添加节点 `Smart Metadata Reader`。
2. 从 ComfyUI `input` 目录选择图片,或通过图片控件上传图片。
3. 使用节点输出连接后续节点,或直接查看输出文本。
## 输出端口
主节点输出:
- `image`
- `mask`
- `positive`
- `negative`
- `seed`
- `steps`
- `cfg`
- `width`
- `height`
- `model_name`
- `filename`
- `setting`
`setting` 是给人看的整合参数文本,包含文件名、来源、模型、VAE、LoRA 列表、seed、steps、CFG、sampler、scheduler、尺寸、状态、置信度和 unresolved 信息。
## 支持的 Metadata 格式
- ComfyUI `prompt` metadata 优先,因为它更接近实际执行 graph。
- ComfyUI `workflow` metadata 用于补充 UI 缓存和 fallback,例如 `ShowText|pysssss` 的显示缓存。
- A1111 / Forge `parameters` 会在 ComfyUI graph 无法启动解析时作为 fallback。
- 通过 Pillow 读取 PNG、JPEG、WEBP 中的 metadata。
如果图片被平台压缩、聊天软件转发或图床处理后丢失 metadata,本节点无法恢复原始提示词。这种情况只能依赖其他图像反推工具。
## 支持的主要节点类型
Phase 1 初始支持包括:
- `KSampler`
- `KSamplerAdvanced`
- `SamplerCustom`
- `SamplerCustomAdvanced`
- `CLIPTextEncode`
- `CLIPTextEncodeSDXL`
- `StringFunction|pysssss`
- `ShowText|pysssss`
- `DeepTranslatorTextNode`
- `ControlNetApply`
- `ControlNetApplyAdvanced`
- `ConditioningCombine`
- `ConditioningConcat`
- `InpaintModelConditioning`
- `FaceDetailer` 和常见 detailer passthrough 模式
- `CheckpointLoaderSimple`
- `CheckpointLoader`
- `VAELoader`
- `LoraLoader`
- `LoraLoaderModelOnly`
## 重要解析原则
- positive 必须来自 selected sampler 的 `positive` conditioning 链路。
- negative 必须来自 selected sampler 的 `negative` conditioning 链路。
- 不扫描整个 workflow 的所有文本节点。
- 未连接到最终 sampler 的 Gemini、ShowText、StringFunction、PrimitiveString、备注文本或旧 CLIPTextEncode 不会污染结果。
- ControlNet、Detailer、Inpaint、ConditioningCombine、ConditioningConcat 只在最终 conditioning 链路上作为 passthrough 或 branch 解析。
- LoRA loader 的文件名和强度会单独进入 setting;但 LoRA trigger words 如果通过文本节点进入 prompt,就会保留在 positive / negative 中。
- 不做内容审查,不做提示词优化,不删 token。
## 当前限制
- Phase 1 是 Python-only。节点内部三个大文本框和更像 SD Prompt Reader 的图片预览 UI 留到 Phase 2 JS 前端增强。
- 部分未知 custom conditioning 节点会返回 `PARTIAL` / unresolved,而不是猜测 prompt。
- 某些 custom node 的运行结果如果没有写入 metadata 缓存,可能无法还原。
- 被压缩或重新保存后丢失 metadata 的图片无法读取原始生成参数。
## 本地验证
在插件目录运行:
```bash
python -m pytest -q
python -m compileall .
```
## 手动 ComfyUI 验证
1. 重启 ComfyUI。
2. 搜索并添加 `Smart Metadata Reader`。
3. 选择或上传一张带有 metadata 的图片。
4. 确认节点输出 `image`、`mask`、`positive`、`negative` 和 `setting`。
5. 如果解析状态是 `PARTIAL`,查看 `setting` 中的 unresolved 信息,定位不支持的 custom node。
+133
View File
@@ -0,0 +1,133 @@
# ComfyUI-Smart-Metadata-Reader
Language / 语言: [中文](README.md) | English
`ComfyUI-Smart-Metadata-Reader` is a Python-only ComfyUI custom node. Its main node, `Smart Metadata Reader`, works as an image loader: it lets you select or upload an image, reads the AI generation metadata already stored inside that image, and outputs the image, mask, real positive/negative prompts, and common generation settings.
## Overview
This node is not an image captioning or prompt guessing tool. It does not look at pixels and infer a prompt. It reads metadata that already exists in the image file, especially the `prompt` and `workflow` JSON saved by ComfyUI PNG outputs.
The core goal is to support complex ComfyUI workflows. The parser starts from the final selected sampler, walks backward through its `positive` and `negative` conditioning chains, and extracts the prompts and settings that actually reached the final generation step.
## What Problem It Solves
Many prompt readers can parse simple A1111 settings or basic ComfyUI images where `CLIPTextEncode.inputs.text` is a plain string. In complex workflows, that field may be a graph link to upstream string functions, cached `ShowText` nodes, translator nodes, ControlNet conditioning, detailers, or other custom node chains.
This project parses ComfyUI metadata as a graph instead of scanning every text field in the workflow.
## Difference From Regular Prompt Readers
- It selects the sampler connected to the final image output when possible.
- It resolves positive and negative prompts only from the selected sampler's conditioning chain.
- It does not scan every `text` field in the workflow.
- It does not mix ControlNet parameters such as image, strength, control_net, or model_name into the prompt.
- It does not treat Gemini / OpenAI / ChatGPT / Claude / LLM fields such as `system_instruction`, `system_prompt`, `prompt`, `instruction`, or `messages` as final prompts.
- When `ShowText|pysssss` has a `text_0` cached display result, that cached result is preferred.
- It does not filter LoRA trigger words, character tags, style tags, NSFW tags, quality tags, translated text, or user-written tags.
## Installation
From your ComfyUI installation:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/CyberShadowX/ComfyUI-Smart-Metadata-Reader.git
cd ComfyUI-Smart-Metadata-Reader
pip install -r requirements.txt
```
Restart ComfyUI after installing.
## Usage
1. Add the `Smart Metadata Reader` node in ComfyUI.
2. Select an image from the ComfyUI `input` directory, or upload one through the image widget.
3. Use the node outputs in your workflow, or inspect the text outputs directly.
## Outputs
The main node outputs:
- `image`
- `mask`
- `positive`
- `negative`
- `seed`
- `steps`
- `cfg`
- `width`
- `height`
- `model_name`
- `filename`
- `setting`
`setting` is human-readable text containing filename, source, model, VAE, LoRA list, seed, steps, CFG, sampler, scheduler, size, status, confidence, and unresolved information when available.
## Supported Metadata
- ComfyUI `prompt` metadata is preferred because it is closest to the executed graph.
- ComfyUI `workflow` metadata is used for UI cache and fallback values, such as `ShowText|pysssss` display cache.
- A1111 / Forge `parameters` metadata is used as fallback when ComfyUI graph parsing cannot start.
- PNG, JPEG, and WEBP metadata are read through Pillow.
If an image was compressed, forwarded through a chat app, or processed by a hosting platform that stripped metadata, this node cannot recover the original prompt. In that case, you need a separate image captioning or prompt inference tool.
## Supported Node Types
Initial Phase 1 support includes:
- `KSampler`
- `KSamplerAdvanced`
- `SamplerCustom`
- `SamplerCustomAdvanced`
- `CLIPTextEncode`
- `CLIPTextEncodeSDXL`
- `StringFunction|pysssss`
- `ShowText|pysssss`
- `DeepTranslatorTextNode`
- `ControlNetApply`
- `ControlNetApplyAdvanced`
- `ConditioningCombine`
- `ConditioningConcat`
- `InpaintModelConditioning`
- `FaceDetailer` and common detailer passthrough patterns
- `CheckpointLoaderSimple`
- `CheckpointLoader`
- `VAELoader`
- `LoraLoader`
- `LoraLoaderModelOnly`
## Important Rules
- `positive` must come from the selected sampler's `positive` conditioning chain.
- `negative` must come from the selected sampler's `negative` conditioning chain.
- The parser does not scan every text node in the workflow.
- Unconnected Gemini, ShowText, StringFunction, PrimitiveString, note text, or old CLIPTextEncode nodes do not contaminate the result.
- ControlNet, Detailer, Inpaint, ConditioningCombine, and ConditioningConcat are treated as passthrough or branch nodes only when they are on the final conditioning chain.
- LoRA loader file names and strengths are extracted separately for settings; LoRA trigger words remain in the prompt if they entered the text chain.
- The parser does not perform content moderation, prompt optimization, or token deletion.
## Current Limitations
- Phase 1 is Python-only. In-node large text boxes and a richer SD Prompt Reader style preview UI are planned for the Phase 2 JavaScript enhancement.
- Some unknown custom conditioning nodes may return `PARTIAL` / unresolved instead of guessing.
- If a custom node did not save its generated result into metadata cache, the parser may not be able to recover it.
- Images with stripped metadata cannot provide original generation settings.
## Local Verification
Run these from the plugin directory:
```bash
python -m pytest -q
python -m compileall .
```
## Manual ComfyUI Verification
1. Restart ComfyUI.
2. Search for and add `Smart Metadata Reader`.
3. Select or upload an image with metadata.
4. Confirm the node outputs `image`, `mask`, `positive`, `negative`, and `setting`.
5. If the status is `PARTIAL`, inspect unresolved information in `setting` to identify unsupported custom nodes.
+14
View File
@@ -0,0 +1,14 @@
try:
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
except ImportError:
if __package__:
raise
try:
from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
except ModuleNotFoundError as exc:
if exc.name != "nodes":
raise
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+830
View File
@@ -0,0 +1,830 @@
# Smart Metadata Reader Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the Python-only MVP for `Smart Metadata Reader`, a ComfyUI image-loader node that loads an image, reads metadata, extracts real positive and negative prompt text through ComfyUI graph traversal, and outputs common image and settings ports.
**Architecture:** The ComfyUI node in `nodes.py` will stay thin. It will call a testable parser package under `smart_metadata_reader/`. Metadata reading, graph indexing, sampler selection, conditioning resolution, prompt merging, LoRA extraction, settings formatting, and A1111 fallback will be separate modules with dataclass-based interfaces.
**Tech Stack:** Python 3, Pillow, torch tensors for ComfyUI `IMAGE` and `MASK` outputs, pytest for parser tests, standard-library `json`, `dataclasses`, `pathlib`, and `typing`.
---
## Phase 1 Scope
Phase 1 includes:
- Python-only `Smart Metadata Reader` node.
- Image selection and upload through ComfyUI `image_upload=True`.
- Metadata reading from PNG, JPEG, and WEBP.
- Image and mask tensor output.
- ComfyUI `prompt` and `workflow` parsing.
- Final sampler selection.
- Positive and negative conditioning graph traversal.
- Initial adapters for CLIP text, pysssss text nodes, generic text nodes, conditioning passthrough nodes, samplers, loaders, and LoRA loaders.
- Human-readable `setting` output.
- A1111/Forge `parameters` fallback for common fields.
- pytest coverage for the parser core and node contract.
Phase 1 excludes:
- Full frontend JavaScript node layout.
- Embedded large read-only positive, negative, and setting text boxes.
- Civitai hash calculation.
- Metadata writing.
- Exhaustive custom node support.
## File Map
Create these files:
```text
__init__.py
nodes.py
requirements.txt
pyproject.toml
smart_metadata_reader/__init__.py
smart_metadata_reader/models.py
smart_metadata_reader/image_io.py
smart_metadata_reader/metadata_reader.py
smart_metadata_reader/graph.py
smart_metadata_reader/sampler_selector.py
smart_metadata_reader/resolver.py
smart_metadata_reader/prompt_merge.py
smart_metadata_reader/lora_extractor.py
smart_metadata_reader/settings_formatter.py
smart_metadata_reader/a1111_parser.py
smart_metadata_reader/trace.py
smart_metadata_reader/adapters/__init__.py
smart_metadata_reader/adapters/base.py
smart_metadata_reader/adapters/clip_text.py
smart_metadata_reader/adapters/text_sources.py
smart_metadata_reader/adapters/pysssss.py
smart_metadata_reader/adapters/conditioning.py
smart_metadata_reader/adapters/samplers.py
smart_metadata_reader/adapters/loaders.py
tests/conftest.py
tests/fixtures.py
tests/test_metadata_reader.py
tests/test_graph.py
tests/test_sampler_selector.py
tests/test_conditioning_resolver.py
tests/test_text_adapters.py
tests/test_lora_extractor.py
tests/test_node_id_independence.py
tests/test_node_contract.py
```
## Core Interfaces
`smart_metadata_reader/models.py` will define:
```python
@dataclass
class MetadataBundle:
filename: str
width: int
height: int
prompt_raw: str | None
workflow_raw: str | None
parameters_raw: str | None
source_format: str
@dataclass
class NodeRecord:
node_id: str
class_type: str
inputs: dict[str, Any]
widgets_values: list[Any] | dict[str, Any] | None = None
@dataclass
class PromptSegment:
text: str
node_id: str | None
class_type: str | None
field: str | None
path: list[str]
confidence: float = 1.0
@dataclass
class LoraInfo:
node_id: str
class_type: str
lora_name: str
strength_model: float | None
strength_clip: float | None
path: list[str]
@dataclass
class ParseResult:
positive: str
negative: str
seed: int
steps: int
cfg: float
width: int
height: int
model_name: str
filename: str
setting: str
vae_name: str
sampler_name: str
scheduler: str
loras: list[LoraInfo]
raw_prompt_json: str
raw_workflow_json: str
partial_result: dict[str, Any]
debug_trace: str
confidence: float
status_message: str
```
`smart_metadata_reader/graph.py` will define:
- `GraphIndex.__init__(prompt: dict[str, Any] | None, workflow: dict[str, Any] | None)`: normalize prompt and workflow nodes.
- `GraphIndex.get_node(node_id: str) -> NodeRecord | None`: return a normalized node by string ID.
- `GraphIndex.is_link(value: Any) -> bool`: detect ComfyUI link references such as `[node_id, output_index]`.
- `GraphIndex.link_target(value: Any) -> tuple[str, int] | None`: return the linked node ID and output index.
- `GraphIndex.upstream_link_inputs(node_id: str) -> list[tuple[str, Any]]`: list linked inputs for a node.
- `GraphIndex.downstream_nodes(node_id: str) -> list[NodeRecord]`: list nodes that consume the node.
- `GraphIndex.workflow_cache_for(node_id: str, field_names: list[str]) -> str | None`: read supplemental cache text from workflow metadata.
`smart_metadata_reader/resolver.py` will define:
- `ConditioningResolver.__init__(graph: GraphIndex, adapters: AdapterRegistry, max_depth: int, prefer_cached_text: bool)`: store resolver dependencies and traversal limits.
- `ConditioningResolver.resolve_positive(sampler: NodeRecord) -> list[PromptSegment]`: resolve `sampler.inputs.positive`.
- `ConditioningResolver.resolve_negative(sampler: NodeRecord) -> list[PromptSegment]`: resolve `sampler.inputs.negative`.
- `ConditioningResolver.resolve_value(value: Any, role: str, path: list[str], depth: int) -> list[PromptSegment]`: resolve strings, links, text nodes, and conditioning passthrough nodes.
`nodes.py` will define:
```python
class SmartMetadataReader:
CATEGORY = "metadata/image"
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING", "INT", "INT", "FLOAT", "INT", "INT", "STRING", "STRING", "STRING")
RETURN_NAMES = ("image", "mask", "positive", "negative", "seed", "steps", "cfg", "width", "height", "model_name", "filename", "setting")
FUNCTION = "read_metadata"
```
## Task 1: Project Bootstrap
**Files:**
- Create: `requirements.txt`
- Create: `pyproject.toml`
- Create: `__init__.py`
- Create: `smart_metadata_reader/__init__.py`
- Create: `smart_metadata_reader/adapters/__init__.py`
- [ ] **Step 1: Initialize Git when implementation starts**
Run from `F:\AI\AIProjects\ComfyUI-Smart-Metadata-Reader`:
```powershell
git init
git branch -M main
git remote add origin https://github.com/CyberShadowX/ComfyUI-Smart-Metadata-Reader.git
git status --short --branch
```
Expected: repository on branch `main` with untracked files only.
- [ ] **Step 2: Create package metadata**
`requirements.txt`:
```text
Pillow>=10.0.0
```
`pyproject.toml`:
```toml
[project]
name = "comfyui-smart-metadata-reader"
version = "0.1.0"
description = "ComfyUI image loader node that reads AI image metadata and traces real prompt conditioning."
requires-python = ">=3.10"
dependencies = ["Pillow>=10.0.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
```
- [ ] **Step 3: Register the node package**
`__init__.py` will export:
```python
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
```
- [ ] **Step 4: Run bootstrap check**
Run:
```powershell
python -m compileall .
```
Expected: Python files compile.
## Task 2: Data Models
**Files:**
- Create: `smart_metadata_reader/models.py`
- Test: `tests/test_graph.py`
- [ ] **Step 1: Write model import test**
Create a test that imports `MetadataBundle`, `NodeRecord`, `PromptSegment`, `LoraInfo`, and `ParseResult`.
Run:
```powershell
python -m pytest tests/test_graph.py -q
```
Expected before implementation: import failure.
- [ ] **Step 2: Add dataclasses**
Create the dataclasses listed in the Core Interfaces section. Use explicit defaults for unresolved scalar values:
```python
seed = -1
steps = 0
cfg = 0.0
width = 0
height = 0
```
- [ ] **Step 3: Run model test**
Run:
```powershell
python -m pytest tests/test_graph.py -q
```
Expected: imports pass.
## Task 3: Image and Metadata Reading
**Files:**
- Create: `smart_metadata_reader/image_io.py`
- Create: `smart_metadata_reader/metadata_reader.py`
- Test: `tests/test_metadata_reader.py`
- [ ] **Step 1: Write PNG metadata test**
Create a synthetic PNG in the test using Pillow with text chunks:
```python
pnginfo.add_text("prompt", json.dumps(prompt_graph))
pnginfo.add_text("workflow", json.dumps(workflow_graph))
pnginfo.add_text("parameters", "positive\nNegative prompt: negative\nSteps: 20, Sampler: Euler, CFG scale: 7, Seed: 123, Size: 512x768")
```
Assert that `read_metadata(path)` returns `prompt_raw`, `workflow_raw`, `parameters_raw`, `width`, `height`, and `filename`.
- [ ] **Step 2: Implement `read_metadata`**
`metadata_reader.py` will expose:
Signature: `read_metadata(image_path: str | Path) -> MetadataBundle`.
Use Pillow `Image.open`. Read `image.info` keys `prompt`, `workflow`, and `parameters`.
- [ ] **Step 3: Implement image tensor conversion**
`image_io.py` will expose:
Signature: `load_image_and_mask(image_path: str | Path) -> tuple[Any, Any, int, int]`.
Return ComfyUI-compatible image tensor shape `[1, H, W, 3]` and mask shape `[1, H, W]`.
- [ ] **Step 4: Run metadata tests**
Run:
```powershell
python -m pytest tests/test_metadata_reader.py -q
```
Expected: metadata and dimensions are read from the synthetic image.
## Task 4: Graph Index
**Files:**
- Create: `smart_metadata_reader/graph.py`
- Test: `tests/test_graph.py`
- [ ] **Step 1: Write graph tests**
Cover:
- API prompt format with node IDs as dictionary keys.
- UI workflow format with `nodes` array.
- Link detection for `["6", 0]` and `[6, 0]`.
- `workflow_cache_for` reading cached `text_0` from workflow node inputs or widget values.
- [ ] **Step 2: Implement `GraphIndex`**
Normalize all node IDs to strings. Store prompt nodes as primary records. Attach workflow supplemental data by matching node ID.
- [ ] **Step 3: Run graph tests**
Run:
```powershell
python -m pytest tests/test_graph.py -q
```
Expected: graph lookup, link parsing, and workflow cache lookup pass.
## Task 5: Prompt Merge and Trace
**Files:**
- Create: `smart_metadata_reader/prompt_merge.py`
- Create: `smart_metadata_reader/trace.py`
- Test: `tests/test_text_adapters.py`
- [ ] **Step 1: Write prompt merge tests**
Assert:
- Empty segments are removed.
- Extra commas from joins are collapsed.
- LoRA trigger words and tags remain unchanged.
- Repeated meaningful tokens remain.
- [ ] **Step 2: Implement `merge_prompt_segments`**
Expose:
Signature: `merge_prompt_segments(segments: list[PromptSegment]) -> str`.
Only perform formatting cleanup allowed by `docs/spec.md`.
- [ ] **Step 3: Implement trace collector**
Expose:
`TraceCollector` methods:
- `add(section: str, line: str) -> None`
- `render() -> str`
- [ ] **Step 4: Run merge tests**
Run:
```powershell
python -m pytest tests/test_text_adapters.py -q
```
Expected: formatting cleanup passes without deleting trigger words.
## Task 6: Adapter Registry and Text Adapters
**Files:**
- Create: `smart_metadata_reader/adapters/base.py`
- Create: `smart_metadata_reader/adapters/clip_text.py`
- Create: `smart_metadata_reader/adapters/text_sources.py`
- Create: `smart_metadata_reader/adapters/pysssss.py`
- Test: `tests/test_text_adapters.py`
- [ ] **Step 1: Write adapter tests**
Cover:
- `CLIPTextEncode.inputs.text` direct string.
- `CLIPTextEncode.inputs.text` linked to generic text node.
- `CLIPTextEncodeSDXL` with multiple text fields.
- `StringFunction|pysssss` append from `text_a`, `text_b`, `text_c`.
- `ShowText|pysssss` prefers `text_0` cache.
- `DeepTranslatorTextNode.inputs.text`.
- [ ] **Step 2: Implement base adapter protocol**
Expose:
`AdapterRegistry` methods:
- `register(adapter: NodeAdapter) -> None`
- `adapter_for(node: NodeRecord) -> NodeAdapter | None`
`NodeAdapter` protocol methods:
- `matches(node: NodeRecord) -> bool`
- [ ] **Step 3: Implement text adapters**
Text adapters return `PromptSegment` instances and recursively call the resolver for linked input values.
- [ ] **Step 4: Run text adapter tests**
Run:
```powershell
python -m pytest tests/test_text_adapters.py -q
```
Expected: all text source cases pass.
## Task 7: Conditioning Resolver
**Files:**
- Create: `smart_metadata_reader/adapters/conditioning.py`
- Create: `smart_metadata_reader/resolver.py`
- Test: `tests/test_conditioning_resolver.py`
- [ ] **Step 1: Write resolver tests**
Cover:
- Simple `KSampler -> CLIPTextEncode`.
- `ControlNetApply` passthrough.
- `ControlNetApplyAdvanced` passthrough.
- `ConditioningCombine` two-branch merge.
- Multiple `CLIPTextEncode` nodes entering positive.
- Unknown passthrough produces partial result and trace entry.
- [ ] **Step 2: Implement conditioning adapter**
Support fields:
```python
[
"conditioning",
"positive",
"negative",
"positive_conditioning",
"negative_conditioning",
"base_positive",
"base_negative",
]
```
Branch adapters must resolve all conditioning-like linked inputs.
- [ ] **Step 3: Implement depth and cycle guards**
`ConditioningResolver` must stop at `max_depth` and track visited `(node_id, field, role)` keys.
- [ ] **Step 4: Run resolver tests**
Run:
```powershell
python -m pytest tests/test_conditioning_resolver.py -q
```
Expected: passthrough and branch resolution pass, unknown nodes produce partial data.
## Task 8: Sampler Selection and Parameter Extraction
**Files:**
- Create: `smart_metadata_reader/adapters/samplers.py`
- Create: `smart_metadata_reader/sampler_selector.py`
- Test: `tests/test_sampler_selector.py`
- [ ] **Step 1: Write sampler selector tests**
Cover:
- `SaveImage <- VAEDecode <- KSampler`.
- `PreviewImage <- VAEDecode <- KSampler`.
- Multiple samplers where only one reaches final output.
- Fallback when no output node exists.
- [ ] **Step 2: Implement sampler candidates**
Initial candidate classes:
```python
{"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"}
```
- [ ] **Step 3: Implement output-chain selection**
Walk backward from image output nodes through image, latent, sample, and VAE decode fields. Select nearest upstream sampler.
- [ ] **Step 4: Extract sampler values**
Expose:
Signature: `extract_sampler_settings(sampler: NodeRecord) -> dict[str, Any]`.
Return seed, steps, cfg, sampler_name, scheduler, and denoise when present.
- [ ] **Step 5: Run sampler tests**
Run:
```powershell
python -m pytest tests/test_sampler_selector.py -q
```
Expected: final output sampler is selected and fallback is flagged with reduced confidence.
## Task 9: Loader and LoRA Extraction
**Files:**
- Create: `smart_metadata_reader/adapters/loaders.py`
- Create: `smart_metadata_reader/lora_extractor.py`
- Test: `tests/test_lora_extractor.py`
- [ ] **Step 1: Write loader tests**
Cover:
- `CheckpointLoaderSimple` model name.
- `VAELoader` VAE name.
- One `LoraLoader`.
- Multiple chained `LoraLoader` nodes.
- LoRA trigger words in text remain in positive prompt.
- [ ] **Step 2: Implement loader extraction**
Expose:
Required functions:
- `extract_model_name(graph: GraphIndex, sampler: NodeRecord) -> str`
- `extract_vae_name(graph: GraphIndex) -> str`
- `extract_loras(graph: GraphIndex, sampler: NodeRecord) -> list[LoraInfo]`
LoRA extraction follows model and clip chains but never filters prompt text.
- [ ] **Step 3: Run LoRA tests**
Run:
```powershell
python -m pytest tests/test_lora_extractor.py -q
```
Expected: LoRA loader info is exported and trigger words remain in prompt output.
## Task 10: A1111 and Forge Fallback
**Files:**
- Create: `smart_metadata_reader/a1111_parser.py`
- Test: `tests/test_metadata_reader.py`
- [ ] **Step 1: Write fallback tests**
Input:
```text
masterpiece, character trigger
Negative prompt: blurry, low quality
Steps: 25, Sampler: Euler a, CFG scale: 7.5, Seed: 12345, Size: 768x1024, Model: demo_model
```
Assert positive, negative, seed, steps, cfg, sampler, size, and model.
- [ ] **Step 2: Implement `parse_a1111_parameters`**
Expose:
Signature: `parse_a1111_parameters(parameters: str, parameter_index: int = 0) -> ParseResult`.
- [ ] **Step 3: Run fallback tests**
Run:
```powershell
python -m pytest tests/test_metadata_reader.py -q
```
Expected: common A1111 fields parse correctly.
## Task 11: Settings Formatter
**Files:**
- Create: `smart_metadata_reader/settings_formatter.py`
- Test: `tests/test_node_contract.py`
- [ ] **Step 1: Write setting formatter tests**
Assert the setting text includes:
- Filename.
- Source.
- Model.
- VAE.
- LoRA list.
- Seed.
- Steps.
- CFG.
- Sampler.
- Scheduler.
- Size.
- Status.
- Confidence.
- Unresolved nodes for partial results.
- [ ] **Step 2: Implement `format_setting`**
Expose:
Signature: `format_setting(result: ParseResult) -> str`.
The output is human-readable text, not JSON.
- [ ] **Step 3: Run formatter tests**
Run:
```powershell
python -m pytest tests/test_node_contract.py -q
```
Expected: required setting lines are present.
## Task 12: Parser Orchestration
**Files:**
- Create: `smart_metadata_reader/metadata_reader.py`
- Modify: parser-facing modules from earlier tasks
- Test: `tests/test_conditioning_resolver.py`
- Test: `tests/test_node_id_independence.py`
- [ ] **Step 1: Write end-to-end parser tests**
Cover a ComfyUI prompt graph with:
- Final `KSampler`.
- Positive through `ControlNetApply`.
- `ConditioningCombine` with two branches.
- `StringFunction|pysssss` append.
- `ShowText|pysssss.text_0`.
- `DeepTranslatorTextNode`.
- Negative through a separate `CLIPTextEncode`.
- LoRA loaders.
- [ ] **Step 2: Write node ID independence test**
Create a helper that remaps all node IDs and link references to random strings. Assert that positive, negative, sampler values, and LoRA values match the original result.
- [ ] **Step 3: Implement orchestration**
Expose:
Signature:
```python
def parse_metadata_bundle(
bundle: MetadataBundle,
parameter_index: int = 0,
prefer_cached_text: bool = True,
include_raw_json: bool = True,
max_depth: int = 40,
) -> ParseResult
```
Use ComfyUI graph parsing first. Use A1111 fallback when ComfyUI graph parsing cannot start.
- [ ] **Step 4: Run end-to-end tests**
Run:
```powershell
python -m pytest tests/test_conditioning_resolver.py tests/test_node_id_independence.py -q
```
Expected: graph traversal succeeds and random node IDs do not change results.
## Task 13: ComfyUI Node Contract
**Files:**
- Create: `nodes.py`
- Modify: `__init__.py`
- Test: `tests/test_node_contract.py`
- [ ] **Step 1: Write node contract tests**
Assert:
- `NODE_CLASS_MAPPINGS` contains `SmartMetadataReader`.
- `NODE_DISPLAY_NAME_MAPPINGS` contains `Smart Metadata Reader`.
- `INPUT_TYPES()` includes `image` with `image_upload=True`.
- `RETURN_TYPES` and `RETURN_NAMES` match the spec.
- [ ] **Step 2: Implement node class**
`SmartMetadataReader.INPUT_TYPES()` uses ComfyUI `folder_paths.get_input_directory()` and lists image files from the input directory.
`SmartMetadataReader.read_metadata(image, parameter_index, prefer_cached_text, include_raw_json, max_depth)`:
1. Resolves the selected image path.
2. Loads image and mask.
3. Reads metadata.
4. Parses metadata bundle.
5. Returns `(image, mask, positive, negative, seed, steps, cfg, width, height, model_name, filename, setting)`.
- [ ] **Step 3: Run node contract tests**
Run:
```powershell
python -m pytest tests/test_node_contract.py -q
```
Expected: node mappings, inputs, and outputs match the spec.
## Task 14: Phase 1 Verification
**Files:**
- All Phase 1 files
- [ ] **Step 1: Run full test suite**
Run:
```powershell
python -m pytest -q
```
Expected: all tests pass.
- [ ] **Step 2: Compile all Python files**
Run:
```powershell
python -m compileall .
```
Expected: all Python files compile.
- [ ] **Step 3: Check Git status**
Run:
```powershell
git status --short --branch
```
Expected: only intended project files are changed or untracked.
- [ ] **Step 4: Commit Phase 1**
Run:
```powershell
git add .
git commit -m "feat: add python smart metadata reader mvp"
```
Expected: one commit containing the Python-only MVP.
## Test Execution Order
Run tests in this order during implementation:
```powershell
python -m pytest tests/test_graph.py -q
python -m pytest tests/test_metadata_reader.py -q
python -m pytest tests/test_text_adapters.py -q
python -m pytest tests/test_conditioning_resolver.py -q
python -m pytest tests/test_sampler_selector.py -q
python -m pytest tests/test_lora_extractor.py -q
python -m pytest tests/test_node_id_independence.py -q
python -m pytest tests/test_node_contract.py -q
python -m pytest -q
```
## Phase 1 Completion Criteria
Phase 1 is complete when:
- `Smart Metadata Reader` appears in ComfyUI node mappings.
- The node can select or upload an image.
- The node returns `IMAGE` and `MASK`.
- The node returns common ports defined in `docs/spec.md`.
- ComfyUI `prompt` metadata is parsed before `workflow` fallback.
- Positive and negative prompts are derived from selected sampler conditioning traversal.
- LoRA loader data is separated from prompt text.
- LoRA trigger words remain in prompt text when they enter conditioning.
- Unknown passthrough nodes create partial results instead of fabricated text.
- The full pytest suite passes.
+512
View File
@@ -0,0 +1,512 @@
# ComfyUI Smart Metadata Reader Specification
## 1. Project Positioning
`ComfyUI-Smart-Metadata-Reader` is an image-loader style ComfyUI custom node package. It is not only a parser library.
The primary node, `Smart Metadata Reader`, must behave like a practical image loader and metadata reader inside ComfyUI:
- Let the user select an image from ComfyUI's `input` directory.
- Let the user upload an image through the node UI.
- Load and output the image and mask.
- Read image metadata from PNG, JPEG, and WEBP files.
- Extract common generation values and prompt text from the metadata.
- Output common ports that can be wired directly into other ComfyUI nodes.
The core technical difference from ordinary prompt readers is graph-aware ComfyUI parsing. For ComfyUI images, the node must prefer the backend execution `prompt` JSON, select the final sampler that generated the image, and trace backward through the sampler's positive and negative conditioning inputs to recover the real text that entered generation.
The package may expose internal parser modules for testing and reuse, but the user-facing deliverable is the ComfyUI node.
## 2. Node Split
### 2.1 Smart Metadata Reader
`Smart Metadata Reader` is the primary node and must be implemented first.
Suggested category:
```text
metadata/image
```
Required inputs:
```text
image: COMBO[STRING], image_upload=True
parameter_index: INT, default 0
prefer_cached_text: BOOLEAN, default True
include_raw_json: BOOLEAN, default True
max_depth: INT, default 40
```
Required outputs:
```text
image: IMAGE
mask: MASK
positive: STRING
negative: STRING
seed: INT
steps: INT
cfg: FLOAT
width: INT
height: INT
model_name: STRING
filename: STRING
setting: STRING
```
The main node must be useful on its own. If development time is limited, this node takes priority over every debug or advanced node.
### 2.2 Smart Metadata Debug
`Smart Metadata Debug` is an enhanced node that can be added after the main node is stable.
Suggested category:
```text
metadata/debug
```
Enhanced outputs:
```text
vae_name: STRING
sampler_name: STRING
scheduler: STRING
loras_json: STRING
raw_prompt_json: STRING
raw_workflow_json: STRING
partial_result_json: STRING
debug_trace: STRING
confidence: FLOAT
status_message: STRING
```
The debug node should expose parser internals without making the primary node difficult to use.
## 3. UI Phases
### 3.1 Phase 1: Python-Only MVP
Phase 1 must deliver a working ComfyUI image loader node with:
- Image selection and upload input.
- Image and mask output.
- Metadata reading.
- ComfyUI `prompt` and `workflow` parsing.
- A1111/Forge `parameters` fallback.
- Graph traversal for positive and negative prompt extraction.
- Common numeric and string outputs.
- A human-readable `setting` output.
- Basic UI payload feedback when practical.
Phase 1 does not require a full SD Prompt Reader style frontend layout with three embedded large text boxes. The node may rely on output ports and simple ComfyUI UI payload feedback.
### 3.2 Phase 2: Frontend UI Enhancement
Phase 2 adds a ComfyUI JavaScript extension through:
```text
WEB_DIRECTORY = "./web/js"
```
The frontend enhancement should display these read-only areas inside the node:
- `positive` text box.
- `negative` text box.
- `setting` text box.
- Image preview.
- Image dimensions.
- OK, PARTIAL, or FAILED status.
JavaScript is only for presentation. Parser logic must remain in Python.
### 3.3 Phase 3: Compatibility Expansion
Phase 3 adds:
- More custom node adapters.
- Stronger A1111/Forge `parameters` fallback.
- More precise multi-sampler, refiner, and hires-fix handling.
- The `Smart Metadata Debug` node if it was not completed earlier.
- More machine-readable debug outputs.
## 4. Metadata Priority
For ComfyUI images, parsing priority is:
1. Use backend execution `prompt` JSON as the primary source because it is closest to the actual generation graph.
2. Use `workflow` JSON as supplemental data for UI cache, `widgets_values`, ShowText cache, preview context, and fallback.
3. Use A1111/Forge `parameters` when ComfyUI `prompt` data is absent or unusable.
The parser must not treat stale workflow widget text as the true execution prompt when execution `prompt` JSON provides a better source. If workflow fallback is used for a prompt segment, the trace and status must say so.
## 5. Prompt Parsing Principles
`positive` must be the merged text that truly entered the selected sampler's positive conditioning.
`negative` must be the merged text that truly entered the selected sampler's negative conditioning.
Hard rules:
- Do not hard-code node IDs.
- Do not hard-code a single test workflow shape.
- Do not assume a fixed node order.
- Parse by graph traversal, `class_type`, `inputs`, and link references such as `[node_id, output_index]`.
- Preserve all text that enters final conditioning.
- Do not remove LoRA trigger words.
- Do not remove character tags.
- Do not remove style tags.
- Do not remove NSFW terms.
- Do not remove quality tags.
- Do not remove translated text.
- Do not remove user-written prompt text.
- Do not rewrite words.
- Do not optimize prompts.
- Do not perform content moderation.
Allowed formatting cleanup:
- Remove empty segments.
- Trim obvious leading and trailing whitespace.
- Collapse clearly repeated commas produced by joining segments.
- Collapse excessive blank lines.
- Normalize obvious spacing around separators.
Formatting cleanup must never delete meaningful tokens.
## 6. LoRA Rules
LoRA loader nodes and LoRA trigger words are separate concerns.
LoRA loader nodes such as `LoraLoader`, `LoraLoaderModelOnly`, and compatible custom loaders should be extracted to `loras_json` with:
- Node ID.
- `class_type`.
- LoRA file name.
- `strength_model`.
- `strength_clip`.
- Connection path.
Example:
```json
[
{
"node_id": "17",
"class_type": "LoraLoader",
"lora_name": "character_lora.safetensors",
"strength_model": 0.8,
"strength_clip": 1.0,
"path": "KSampler 72.model <- LoraLoader 17"
}
]
```
If LoRA trigger words, character tags, or style tags enter `CLIPTextEncode` or final conditioning through text nodes, they must remain in `positive` or `negative`. The parser must not filter text because it looks like a LoRA trigger word.
## 7. Final Sampler Selection
Sampler selection must prefer the sampler that generated the loaded image.
Selection order:
1. Start from final image output nodes when possible, including `SaveImage`, `PreviewImage`, `SaveAnimatedWEBP`, and common image save nodes.
2. Walk backward through `image`, `images`, `samples`, `latent`, and VAE decode chains.
3. Select the nearest valid sampler upstream of the final output chain.
4. If no final output chain can be determined, choose a valid sampler through fallback heuristics.
5. If fallback heuristics are used, `setting`, `status_message`, and `debug_trace` must clearly state the reason and confidence.
Initial sampler candidates:
```text
KSampler
KSamplerAdvanced
SamplerCustom
SamplerCustomAdvanced
```
Additional sampler types should be added through adapters.
The parser must not pretend fallback selection is fully certain.
## 8. Conditioning Trace Rules
Conditioning resolution begins at:
```text
selected_sampler.inputs.positive
selected_sampler.inputs.negative
```
The resolver must follow links backward through passthrough and branch nodes, including:
```text
ControlNetApply
ControlNetApplyAdvanced
FaceDetailer
DetailerForEach
Impact Pack detailer nodes
InpaintModelConditioning
ConditioningCombine
ConditioningConcat
ConditioningSet
```
The resolver should also support adapter-driven matching for custom conditioning nodes that expose fields such as:
```text
conditioning
positive
negative
positive_conditioning
negative_conditioning
base_positive
base_negative
```
For branch nodes such as `ConditioningCombine` and `ConditioningConcat`, every actual branch that reaches the final sampler must be resolved and merged. `debug_trace` must show which prompt segment came from which node.
Unknown passthrough nodes must produce a partial result instead of fabricated prompt text.
## 9. Text Adapters
Initial text adapters must support:
```text
CLIPTextEncode
CLIPTextEncodeSDXL
StringFunction|pysssss
ShowText|pysssss
DeepTranslatorTextNode
Primitive/String/Text-like nodes
```
Common text fields:
```text
text
text_0
text_a
text_b
text_c
string
value
prompt
result
```
Adapter rules:
- `CLIPTextEncode`: resolve `inputs.text`; if it is a link, continue upstream.
- `CLIPTextEncodeSDXL`: resolve all prompt text fields that enter conditioning, such as `text`, `text_g`, and `text_l`.
- `StringFunction|pysssss`: for append-like actions, resolve `text_a`, `text_b`, and `text_c` in graph order and merge the segments.
- `ShowText|pysssss`: prefer `inputs.text_0` or workflow/cache display results. Only follow upstream when no cache exists. When upstream is used, reduce confidence and record the reason in trace.
- `DeepTranslatorTextNode`: resolve `inputs.text` for Phase 1; future adapters may prefer cached translated output when reliable metadata is available.
- Primitive/String/Text-like nodes: read common text fields without assuming exact node names.
`ShowText|pysssss` must avoid treating upstream `GeminiChatNode` prompt templates or system instructions as final image prompt text when cached displayed text is available.
## 10. Prompt Merge Strategy
Prompt segments should be represented internally with:
- Text value.
- Source node ID.
- Source class type.
- Source input field.
- Trace path.
- Confidence contribution.
Merging must follow actual graph path order where known. Branch nodes should preserve branch order from graph inputs.
The merger may clean separators and empty segments, but it must not classify, censor, or rewrite prompt content.
## 11. Setting Field
`setting` is human-readable text, not JSON.
Recommended format:
```text
Filename: 001.png
Source: ComfyUI prompt/workflow
Model: model.safetensors
VAE: vae.safetensors
LoRA:
- character_lora.safetensors (model 0.80, clip 1.00)
Seed: 123456789
Steps: 30
CFG: 7.0
Sampler: euler
Scheduler: normal
Size: 1200 x 1600
Status: OK
Confidence: 0.94
```
For partial results:
```text
Status: PARTIAL
Confidence: 0.52
Unresolved:
- Node 88 FooBarConditioning input positive_conditioning
```
Machine-readable data belongs in debug outputs such as `loras_json`, `partial_result_json`, and `debug_trace`.
## 12. Debug Trace
`debug_trace` should explain how the result was reached.
Example:
```text
SAMPLER_SELECTION:
SaveImage 91.images <- VAEDecode 89.samples <- KSampler 72.samples
Selected KSampler 72 because it is upstream of final SaveImage.
POSITIVE_TRACE:
KSampler 72.positive
-> ControlNetApply 65.conditioning
-> ConditioningCombine 61.conditioning_1
-> CLIPTextEncode 40.text
-> StringFunction|pysssss 22 [append]
text_a: direct string
text_b: ShowText|pysssss 143.text_0 cache
text_c: DeepTranslatorTextNode 24.text
NEGATIVE_TRACE:
KSampler 72.negative
-> CLIPTextEncode 41.text
-> StringFunction|pysssss 48.text_a
LORA_TRACE:
KSampler 72.model <- LoraLoader 33 <- LoraLoader 28 <- CheckpointLoaderSimple 4
```
When fallback or workflow cache is used, trace must say so.
## 13. Failure and Partial Results
The parser must not guess when it cannot determine a value.
Failure behavior:
- Return all fields that were resolved.
- Return empty or sentinel values for fields that were not resolved.
- Set status to `PARTIAL` or `FAILED`.
- Lower confidence.
- Include unresolved node IDs, class types, and input fields in trace or partial result.
Examples:
```text
Status: PARTIAL
Unresolved:
- Node 88 UnknownConditioningRouter input positive
Reason: no adapter matched this conditioning passthrough node
```
## 14. A1111 and Forge Fallback
When ComfyUI `prompt` metadata is missing, invalid, or unusable, the parser should attempt A1111/Forge `parameters` parsing.
Phase 1 should parse common fields:
- Positive prompt.
- Negative prompt.
- Seed.
- Steps.
- CFG scale.
- Sampler.
- Size.
- Model name when present.
`parameter_index` selects among multiple parameter sets when the metadata contains more than one set.
## 15. Package Architecture
Planned layout:
```text
ComfyUI-Smart-Metadata-Reader/
__init__.py
nodes.py
requirements.txt
README.md
pyproject.toml
docs/
spec.md
implementation_plan.md
smart_metadata_reader/
__init__.py
metadata_reader.py
image_io.py
models.py
graph.py
sampler_selector.py
resolver.py
prompt_merge.py
lora_extractor.py
settings_formatter.py
a1111_parser.py
trace.py
adapters/
__init__.py
base.py
clip_text.py
text_sources.py
pysssss.py
conditioning.py
samplers.py
loaders.py
web/
js/
smart_metadata_reader.js
tests/
fixtures/
test_metadata_reader.py
test_graph.py
test_sampler_selector.py
test_conditioning_resolver.py
test_text_adapters.py
test_lora_extractor.py
test_node_id_independence.py
test_node_contract.py
```
Parser modules must be testable without launching ComfyUI.
## 16. Test Requirements
Tests must cover:
- Simple `KSampler -> CLIPTextEncode`.
- `CLIPTextEncode.text` connected to upstream text node.
- `StringFunction|pysssss` three-part append.
- `ShowText|pysssss` `text_0` cache priority.
- `DeepTranslatorTextNode`.
- `ControlNetApply` passthrough.
- `ConditioningCombine` multi-branch merge.
- Multiple `CLIPTextEncode` nodes entering positive conditioning.
- LoRA trigger words retained in positive prompt.
- Multiple LoRA loader nodes exported to `loras_json`.
- Multiple samplers with final output sampler selection.
- Randomized node IDs with unchanged parse result.
- Unknown passthrough node returns partial result instead of invented prompt text.
## 17. Relationship to comfyui-prompt-reader-node
This project may use `receyuki/comfyui-prompt-reader-node` as a user experience reference:
- Image selection and upload.
- Positive prompt area.
- Negative prompt area.
- Setting area.
- Image preview.
- Common output ports.
This project must not copy its implementation and must not depend on its submodule. The parser must be owned by this project and designed around final sampler and conditioning graph traversal.
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
import hashlib
import os
from pathlib import Path
from typing import Any
from smart_metadata_reader.image_io import load_image_and_mask
from smart_metadata_reader.metadata_reader import parse_metadata_bundle, read_metadata
try:
import folder_paths # type: ignore
except ImportError:
folder_paths = None # type: ignore
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tif", ".tiff"}
class SmartMetadataReader:
RETURN_TYPES = (
"IMAGE",
"MASK",
"STRING",
"STRING",
"INT",
"INT",
"FLOAT",
"INT",
"INT",
"STRING",
"STRING",
"STRING",
)
RETURN_NAMES = (
"image",
"mask",
"positive",
"negative",
"seed",
"steps",
"cfg",
"width",
"height",
"model_name",
"filename",
"setting",
)
FUNCTION = "read_metadata"
CATEGORY = "metadata/image"
@classmethod
def INPUT_TYPES(cls) -> dict[str, dict[str, Any]]:
return {
"required": {
"image": (_input_images(), {"image_upload": True}),
"parameter_index": ("INT", {"default": 0, "min": 0}),
"prefer_cached_text": ("BOOLEAN", {"default": True}),
"include_raw_json": ("BOOLEAN", {"default": True}),
"max_depth": ("INT", {"default": 40, "min": 1, "max": 200}),
}
}
@classmethod
def IS_CHANGED(cls, image: str, **kwargs: Any) -> str:
del kwargs
image_path = _resolve_image_path(image)
return _file_digest(image_path)
@classmethod
def VALIDATE_INPUTS(cls, image: str, **kwargs: Any) -> bool | str:
del kwargs
if folder_paths is not None and hasattr(folder_paths, "exists_annotated_filepath"):
if folder_paths.exists_annotated_filepath(image):
return True
return f"Invalid image file: {image}"
image_path = _resolve_image_path(image)
if Path(image_path).exists():
return True
return f"Invalid image file: {image_path}"
def read_metadata(
self,
image: str,
parameter_index: int = 0,
prefer_cached_text: bool = True,
include_raw_json: bool = True,
max_depth: int = 40,
) -> tuple[Any, Any, str, str, int, int, float, int, int, str, str, str]:
image_path = _resolve_image_path(image)
image_tensor, mask_tensor, image_width, image_height = _load_image_or_raise(image_path)
filename = Path(image_path).name
try:
bundle = read_metadata(image_path)
result = parse_metadata_bundle(
bundle,
parameter_index=parameter_index,
prefer_cached_text=prefer_cached_text,
include_raw_json=include_raw_json,
max_depth=max_depth,
)
except Exception as exc:
setting = _failed_setting(filename, image_width, image_height, exc)
return (
image_tensor,
mask_tensor,
"",
"",
-1,
0,
0.0,
image_width,
image_height,
"",
filename,
setting,
)
return (
image_tensor,
mask_tensor,
result.positive,
result.negative,
result.seed,
result.steps,
result.cfg,
result.width or image_width,
result.height or image_height,
result.model_name,
result.filename or filename,
result.setting,
)
def _input_images() -> list[str]:
if folder_paths is not None and hasattr(folder_paths, "get_filename_list"):
return list(folder_paths.get_filename_list("input"))
if folder_paths is None or not hasattr(folder_paths, "get_input_directory"):
return []
input_dir = Path(folder_paths.get_input_directory())
if not input_dir.exists():
return []
return sorted(
entry.name
for entry in input_dir.iterdir()
if entry.is_file() and entry.suffix.lower() in IMAGE_EXTENSIONS
)
def _resolve_image_path(image: str) -> str:
if folder_paths is not None and hasattr(folder_paths, "get_annotated_filepath"):
return str(folder_paths.get_annotated_filepath(image))
if folder_paths is not None and hasattr(folder_paths, "get_input_directory"):
return str(Path(folder_paths.get_input_directory()) / image)
return os.fspath(image)
def _load_image_or_raise(image_path: str) -> tuple[Any, Any, int, int]:
try:
return load_image_and_mask(image_path)
except Exception as exc:
raise RuntimeError(f"Failed to load image '{image_path}': {exc}") from exc
def _file_digest(image_path: str) -> str:
path = Path(image_path)
try:
hasher = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest()
except OSError:
return f"missing:{image_path}"
def _failed_setting(filename: str, width: int, height: int, exc: Exception) -> str:
size = f"{width} x {height}" if width > 0 and height > 0 else "unknown"
return "\n".join(
[
f"Filename: {filename or 'unknown'}",
"Source: unknown",
"Model: unknown",
"VAE: unknown",
"LoRA: none",
"Seed: -1",
"Steps: 0",
"CFG: 0.0",
"Sampler: unknown",
"Scheduler: unknown",
f"Size: {size}",
"Status: FAILED",
"Confidence: 0.00",
f"Error: {exc}",
]
)
NODE_CLASS_MAPPINGS = {"SmartMetadataReader": SmartMetadataReader}
NODE_DISPLAY_NAME_MAPPINGS = {"SmartMetadataReader": "Smart Metadata Reader"}
+10
View File
@@ -0,0 +1,10 @@
[project]
name = "comfyui-smart-metadata-reader"
version = "0.1.0"
description = "ComfyUI image loader node that reads AI image metadata and traces real prompt conditioning."
requires-python = ">=3.10"
dependencies = ["Pillow>=10.0.0"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
+1
View File
@@ -0,0 +1 @@
Pillow>=10.0.0
+1
View File
@@ -0,0 +1 @@
"""Core parser package for ComfyUI Smart Metadata Reader."""
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import re
from .models import ParseResult
KNOWN_SETTING_KEYS = (
"Steps",
"Sampler",
"CFG scale",
"Seed",
"Size",
"Model",
"Model hash",
"Scheduler",
"Denoising strength",
)
def parse_a1111_parameters(parameters: str, parameter_index: int = 0) -> ParseResult:
del parameter_index
prompt_block, settings_line = _split_prompt_and_settings(parameters)
positive, negative = _split_positive_negative(prompt_block)
settings = _parse_settings(settings_line)
partial_result = {"source_format": "A1111 parameters"}
if model_hash := settings.get("model_hash"):
partial_result["model_hash"] = model_hash
if denoising_strength := settings.get("denoising_strength"):
partial_result["denoising_strength"] = denoising_strength
return ParseResult(
positive=positive,
negative=negative,
seed=_int_or_default(settings.get("seed"), -1),
steps=_int_or_default(settings.get("steps"), 0),
cfg=_float_or_default(settings.get("cfg"), 0.0),
width=_int_or_default(settings.get("width"), 0),
height=_int_or_default(settings.get("height"), 0),
model_name=settings.get("model_name", ""),
sampler_name=settings.get("sampler_name", ""),
scheduler=settings.get("scheduler", ""),
partial_result=partial_result,
confidence=0.75,
status_message="A1111 parameters fallback",
)
def _split_prompt_and_settings(parameters: str) -> tuple[str, str]:
normalized = parameters.replace("\r\n", "\n").replace("\r", "\n").strip()
if not normalized:
return "", ""
lines = normalized.split("\n")
for index in range(len(lines) - 1, -1, -1):
line = lines[index].strip()
if _looks_like_settings_line(line):
return "\n".join(lines[:index]).strip(), line
return normalized, ""
def _looks_like_settings_line(line: str) -> bool:
if not line:
return False
key_count = sum(1 for key in KNOWN_SETTING_KEYS if f"{key}:" in line)
return key_count >= 1 and ("," in line or line.startswith("Steps:"))
def _split_positive_negative(prompt_block: str) -> tuple[str, str]:
match = re.search(r"(?im)^Negative prompt:\s*", prompt_block)
if match is None:
return prompt_block.strip(), ""
positive = prompt_block[: match.start()].strip()
negative = prompt_block[match.end() :].strip()
return positive, negative
def _parse_settings(settings_line: str) -> dict[str, str]:
settings: dict[str, str] = {}
if not settings_line:
return settings
settings["steps"] = _extract_value(settings_line, "Steps")
settings["sampler_name"] = _extract_value(settings_line, "Sampler")
settings["scheduler"] = _extract_value(settings_line, "Scheduler")
settings["cfg"] = _extract_value(settings_line, "CFG scale")
settings["seed"] = _extract_value(settings_line, "Seed")
settings["model_name"] = _extract_value(settings_line, "Model")
settings["model_hash"] = _extract_value(settings_line, "Model hash")
settings["denoising_strength"] = _extract_value(settings_line, "Denoising strength")
size = _extract_value(settings_line, "Size")
size_match = re.match(r"\s*(\d+)\s*x\s*(\d+)\s*$", size, flags=re.IGNORECASE)
if size_match:
settings["width"] = size_match.group(1)
settings["height"] = size_match.group(2)
return {key: value for key, value in settings.items() if value != ""}
def _extract_value(settings_line: str, key: str) -> str:
known_keys_pattern = "|".join(re.escape(known_key) for known_key in KNOWN_SETTING_KEYS)
pattern = (
rf"(?:^|,\s*){re.escape(key)}:\s*"
rf"(.*?)"
rf"(?=,\s*(?:{known_keys_pattern}):|$)"
)
match = re.search(pattern, settings_line)
if match is None:
return ""
return match.group(1).strip()
def _int_or_default(value: str | None, default: int) -> int:
if value is None:
return default
try:
return int(value)
except ValueError:
return default
def _float_or_default(value: str | None, default: float) -> float:
if value is None:
return default
try:
return float(value)
except ValueError:
return default
@@ -0,0 +1 @@
"""Node adapters for graph-aware metadata parsing."""
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from typing import Protocol
from smart_metadata_reader.graph import GraphIndex
from smart_metadata_reader.models import NodeRecord, PromptSegment
from smart_metadata_reader.trace import TraceCollector
class NodeAdapter(Protocol):
def matches(self, node: NodeRecord) -> bool:
...
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
...
class AdapterRegistry:
def __init__(self) -> None:
self._adapters: list[NodeAdapter] = []
def register(self, adapter: NodeAdapter) -> None:
self._adapters.append(adapter)
def adapter_for(self, node: NodeRecord) -> NodeAdapter | None:
for adapter in self._adapters:
if adapter.matches(node):
return adapter
return None
class TextResolutionContext:
def __init__(
self,
graph: GraphIndex,
registry: AdapterRegistry | None = None,
prefer_cached_text: bool = True,
trace: TraceCollector | None = None,
max_depth: int = 40,
) -> None:
self.graph = graph
self.registry = registry or AdapterRegistry()
self.prefer_cached_text = prefer_cached_text
self.trace = trace or TraceCollector()
self.max_depth = max_depth
def resolve_node(
self,
node_id: str | int,
role: str,
path: list[str],
depth: int = 0,
) -> list[PromptSegment]:
if depth > self.max_depth:
self.trace.add("TEXT_TRACE", f"max depth reached at node {node_id}")
return []
node = self.graph.get_node(node_id)
if node is None:
self.trace.add("TEXT_TRACE", f"missing node {node_id}")
return []
if self.is_llm_template_node(node):
self.trace.add(
"TEXT_TRACE",
(
f"{node.class_type} {node.node_id}: low confidence "
"LLM template input skipped, no cached generated output found"
),
)
return []
adapter = self.registry.adapter_for(node)
if adapter is None:
self.trace.add("TEXT_TRACE", f"{node.class_type} {node.node_id}: no text adapter")
return []
return adapter.resolve(
node=node,
context=self,
role=role,
path=path + [f"{node.class_type} {node.node_id}"],
depth=depth + 1,
)
def resolve_input(
self,
node: NodeRecord,
field: str,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
return self.resolve_value(
value=node.inputs.get(field),
role=role,
path=path + [f"{node.class_type} {node.node_id}.{field}"],
depth=depth,
source_node=node,
field=field,
)
def resolve_value(
self,
value: object,
role: str,
path: list[str],
depth: int,
source_node: NodeRecord | None = None,
field: str | None = None,
) -> list[PromptSegment]:
if depth > self.max_depth:
self.trace.add("TEXT_TRACE", "max depth reached while resolving value")
return []
if isinstance(value, str):
if not value.strip():
return []
return [
PromptSegment(
text=value,
node_id=source_node.node_id if source_node else None,
class_type=source_node.class_type if source_node else None,
field=field,
path=path,
)
]
target = self.graph.link_target(value)
if target is not None:
target_id, output_index = target
return self.resolve_node(
node_id=target_id,
role=role,
path=path + [f"link[{target_id}, {output_index}]"],
depth=depth + 1,
)
if isinstance(value, list):
segments: list[PromptSegment] = []
for item in value:
segments.extend(
self.resolve_value(
value=item,
role=role,
path=path,
depth=depth + 1,
source_node=source_node,
field=field,
)
)
return segments
return []
def workflow_cache_for(self, node: NodeRecord, field_names: list[str]) -> str | None:
return self.graph.workflow_cache_for(node.node_id, field_names)
def workflow_cache_entry(
self,
node: NodeRecord,
field_names: list[str],
) -> tuple[str, str] | None:
return self.graph.workflow_cache_entry(node.node_id, field_names)
def is_llm_template_node(self, node: NodeRecord) -> bool:
lowered = node.class_type.lower()
markers = ("gemini", "chatgpt", "claude", "llm", "language_model")
return any(marker in lowered for marker in markers)
@@ -0,0 +1,38 @@
from __future__ import annotations
from smart_metadata_reader.adapters.base import TextResolutionContext
from smart_metadata_reader.models import NodeRecord, PromptSegment
class CLIPTextEncodeAdapter:
def matches(self, node: NodeRecord) -> bool:
return node.class_type == "CLIPTextEncode"
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
return context.resolve_input(node, "text", role, path, depth)
class CLIPTextEncodeSDXLAdapter:
def matches(self, node: NodeRecord) -> bool:
return node.class_type == "CLIPTextEncodeSDXL"
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
segments: list[PromptSegment] = []
for field in ("text", "text_g", "text_l"):
if field in node.inputs:
segments.extend(context.resolve_input(node, field, role, path, depth))
return segments
@@ -0,0 +1,59 @@
from __future__ import annotations
from smart_metadata_reader.models import NodeRecord
ROLE_FIELD_ORDER = {
"positive": ("positive", "positive_conditioning", "base_positive"),
"negative": ("negative", "negative_conditioning", "base_negative"),
}
KNOWN_CONDITIONING_MARKERS = (
"controlnetapply",
"inpaintmodelconditioning",
"conditioningcombine",
"conditioningconcat",
"conditioningset",
"facedetailer",
"detailerforeach",
"detailer",
"impact",
)
class ConditioningAdapter:
def matches(self, node: NodeRecord) -> bool:
lowered = node.class_type.lower()
return any(marker in lowered for marker in KNOWN_CONDITIONING_MARKERS)
def fields_for_role(self, node: NodeRecord, role: str) -> list[str]:
role_fields = [
field
for field in ROLE_FIELD_ORDER.get(role, ())
if field in node.inputs
]
if role_fields:
return role_fields
conditioning_fields = [
field
for field in node.inputs
if _is_conditioning_field(field)
]
if conditioning_fields:
return conditioning_fields
return [
field
for field in node.inputs
if field in {"positive", "negative", "base_positive", "base_negative"}
]
def _is_conditioning_field(field: str) -> bool:
lowered = field.lower()
return (
lowered == "conditioning"
or "conditioning" in lowered
or lowered in {"positive", "negative", "base_positive", "base_negative"}
)
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from typing import Any
from smart_metadata_reader.models import NodeRecord
CHECKPOINT_NAME_FIELDS = ("ckpt_name", "checkpoint", "model_name", "name")
VAE_NAME_FIELDS = ("vae_name", "name", "model_name")
LORA_NAME_FIELDS = ("lora_name", "name")
def is_checkpoint_loader(node: NodeRecord) -> bool:
lowered = node.class_type.lower()
if node.class_type in {"CheckpointLoaderSimple", "CheckpointLoader"}:
return True
return "checkpoint" in lowered and "loader" in lowered
def is_vae_loader(node: NodeRecord) -> bool:
lowered = node.class_type.lower()
return node.class_type == "VAELoader" or ("vae" in lowered and "loader" in lowered)
def is_lora_loader(node: NodeRecord) -> bool:
lowered = node.class_type.lower()
if node.class_type in {"LoraLoader", "LoraLoaderModelOnly"}:
return True
return "lora" in lowered and any(field in node.inputs for field in LORA_NAME_FIELDS)
def checkpoint_name(node: NodeRecord) -> str:
return _first_string(node, CHECKPOINT_NAME_FIELDS)
def vae_name(node: NodeRecord) -> str:
return _first_string(node, VAE_NAME_FIELDS)
def lora_name(node: NodeRecord) -> str:
return _first_string(node, LORA_NAME_FIELDS)
def strength_model(node: NodeRecord) -> float | None:
return _number_or_none(node.inputs.get("strength_model"))
def strength_clip(node: NodeRecord) -> float | None:
return _number_or_none(node.inputs.get("strength_clip"))
def _first_string(node: NodeRecord, field_names: tuple[str, ...]) -> str:
for field_name in field_names:
value = node.inputs.get(field_name)
if isinstance(value, str) and value:
return value
return ""
def _number_or_none(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value))
except ValueError:
return None
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
from smart_metadata_reader.adapters.base import TextResolutionContext
from smart_metadata_reader.models import NodeRecord, PromptSegment
class StringFunctionAdapter:
def matches(self, node: NodeRecord) -> bool:
return node.class_type == "StringFunction|pysssss"
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
action = str(node.inputs.get("action", "append")).lower()
fields = ("text_a", "text_b", "text_c")
if action != "append":
context.trace.add(
"TEXT_TRACE",
f"{node.class_type} {node.node_id}: unsupported action {action}",
)
segments: list[PromptSegment] = []
for field in fields:
if field in node.inputs:
segments.extend(context.resolve_input(node, field, role, path, depth))
return segments
class ShowTextAdapter:
def matches(self, node: NodeRecord) -> bool:
return node.class_type == "ShowText|pysssss"
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
cached_text = self._cached_text(node, context)
if cached_text:
text, source = cached_text
confidence = 0.75 if source == "workflow widget cache fallback" else 1.0
if source == "workflow widget cache fallback":
context.trace.add(
"TEXT_TRACE",
(
f"{node.class_type} {node.node_id}: "
"workflow widget cache fallback; lowering confidence"
),
)
return [
PromptSegment(
text=text,
node_id=node.node_id,
class_type=node.class_type,
field="text_0",
path=path + [f"{node.class_type} {node.node_id}.text_0 cache"],
confidence=confidence,
)
]
context.trace.add(
"TEXT_TRACE",
(
f"{node.class_type} {node.node_id}: no cached display text; "
"following upstream with low confidence"
),
)
segments = context.resolve_input(node, "text", role, path, depth)
for segment in segments:
segment.confidence = min(segment.confidence, 0.5)
return segments
def _cached_text(
self,
node: NodeRecord,
context: TextResolutionContext,
) -> tuple[str, str] | None:
if context.prefer_cached_text:
direct = node.inputs.get("text_0")
if isinstance(direct, str) and direct.strip():
return direct, "prompt input text_0"
workflow = context.workflow_cache_entry(node, ["text_0", "text"])
if workflow:
return workflow
return None
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any
from smart_metadata_reader.models import NodeRecord
SAMPLER_CLASS_TYPES = {
"KSampler",
"KSamplerAdvanced",
"SamplerCustom",
"SamplerCustomAdvanced",
}
def is_sampler(node: NodeRecord) -> bool:
return node.class_type in SAMPLER_CLASS_TYPES
def extract_sampler_settings(sampler: NodeRecord) -> dict[str, Any]:
settings: dict[str, Any] = {}
field_map = {
"seed": ("seed", "noise_seed"),
"steps": ("steps",),
"cfg": ("cfg", "cfg_scale"),
"sampler_name": ("sampler_name", "sampler"),
"scheduler": ("scheduler", "schedule"),
"denoise": ("denoise",),
}
for output_name, field_names in field_map.items():
value = _first_existing_input(sampler, field_names)
if value is not None:
settings[output_name] = value
return settings
def _first_existing_input(node: NodeRecord, field_names: tuple[str, ...]) -> Any | None:
for field_name in field_names:
if field_name in node.inputs:
return node.inputs[field_name]
return None
@@ -0,0 +1,64 @@
from __future__ import annotations
from smart_metadata_reader.adapters.base import TextResolutionContext
from smart_metadata_reader.models import NodeRecord, PromptSegment
COMMON_TEXT_FIELDS = (
"text",
"text_0",
"text_a",
"text_b",
"text_c",
"string",
"value",
"prompt",
"result",
)
class DeepTranslatorTextAdapter:
def matches(self, node: NodeRecord) -> bool:
return node.class_type == "DeepTranslatorTextNode"
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
return context.resolve_input(node, "text", role, path, depth)
class GenericTextAdapter:
def matches(self, node: NodeRecord) -> bool:
lowered = node.class_type.lower()
if context_marker_is_llm(lowered):
return False
return (
"primitive" in lowered
or "string" in lowered
or "text" in lowered
or lowered in {"string", "text"}
)
def resolve(
self,
node: NodeRecord,
context: TextResolutionContext,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
segments: list[PromptSegment] = []
for field in COMMON_TEXT_FIELDS:
if field in node.inputs:
segments.extend(context.resolve_input(node, field, role, path, depth))
return segments
def context_marker_is_llm(lowered_class_type: str) -> bool:
markers = ("gemini", "chatgpt", "claude", "llm", "language_model")
return any(marker in lowered_class_type for marker in markers)
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
from typing import Any
from .models import NodeRecord
class GraphIndex:
def __init__(
self,
prompt: dict[str, Any] | None,
workflow: dict[str, Any] | None,
) -> None:
self._workflow_nodes = self._normalize_workflow_nodes(workflow)
self._nodes = self._normalize_prompt_nodes(prompt)
if not self._nodes:
self._nodes = dict(self._workflow_nodes)
else:
self._attach_workflow_supplements()
def get_node(self, node_id: str | int) -> NodeRecord | None:
return self._nodes.get(str(node_id))
def nodes(self) -> list[NodeRecord]:
return list(self._nodes.values())
def is_link(self, value: Any) -> bool:
return self.link_target(value) is not None
def link_target(self, value: Any) -> tuple[str, int] | None:
if not isinstance(value, (list, tuple)) or len(value) != 2:
return None
node_id, output_index = value
if not isinstance(node_id, (str, int)):
return None
if not isinstance(output_index, int):
return None
return str(node_id), output_index
def upstream_link_inputs(self, node_id: str | int) -> list[tuple[str, Any]]:
node = self.get_node(node_id)
if node is None:
return []
return [
(field_name, value)
for field_name, value in node.inputs.items()
if self.is_link(value)
]
def downstream_nodes(self, node_id: str | int) -> list[NodeRecord]:
source_id = str(node_id)
downstream: list[NodeRecord] = []
for node in self._nodes.values():
for value in node.inputs.values():
target = self.link_target(value)
if target and target[0] == source_id:
downstream.append(node)
break
return downstream
def workflow_cache_for(
self,
node_id: str | int,
field_names: list[str],
) -> str | None:
cache_entry = self.workflow_cache_entry(node_id, field_names)
if cache_entry is None:
return None
value, _source = cache_entry
return value
def workflow_cache_entry(
self,
node_id: str | int,
field_names: list[str],
) -> tuple[str, str] | None:
node = self._workflow_nodes.get(str(node_id))
if node is None:
return None
for field_name in field_names:
value = node.inputs.get(field_name)
if isinstance(value, str) and value:
return value, f"workflow input {field_name}"
widgets = node.widgets_values
if isinstance(widgets, dict):
for field_name in field_names:
value = widgets.get(field_name)
if isinstance(value, str) and value:
return value, f"workflow widget {field_name}"
elif isinstance(widgets, list):
for value in widgets:
if isinstance(value, str) and value:
return value, "workflow widget cache fallback"
return None
def _normalize_prompt_nodes(
self,
prompt: dict[str, Any] | None,
) -> dict[str, NodeRecord]:
if not isinstance(prompt, dict):
return {}
nodes: dict[str, NodeRecord] = {}
for raw_id, raw_node in prompt.items():
if not isinstance(raw_node, dict):
continue
node_id = str(raw_id)
class_type = str(raw_node.get("class_type") or raw_node.get("type") or "")
inputs = raw_node.get("inputs")
nodes[node_id] = NodeRecord(
node_id=node_id,
class_type=class_type,
inputs=dict(inputs) if isinstance(inputs, dict) else {},
widgets_values=None,
)
return nodes
def _normalize_workflow_nodes(
self,
workflow: dict[str, Any] | None,
) -> dict[str, NodeRecord]:
if not isinstance(workflow, dict):
return {}
raw_nodes = workflow.get("nodes")
if not isinstance(raw_nodes, list):
return {}
nodes: dict[str, NodeRecord] = {}
for raw_node in raw_nodes:
if not isinstance(raw_node, dict):
continue
raw_id = raw_node.get("id")
if raw_id is None:
continue
node_id = str(raw_id)
class_type = str(raw_node.get("class_type") or raw_node.get("type") or "")
inputs = raw_node.get("inputs")
nodes[node_id] = NodeRecord(
node_id=node_id,
class_type=class_type,
inputs=dict(inputs) if isinstance(inputs, dict) else {},
widgets_values=raw_node.get("widgets_values"),
)
return nodes
def _attach_workflow_supplements(self) -> None:
for node_id, workflow_node in self._workflow_nodes.items():
prompt_node = self._nodes.get(node_id)
if prompt_node is None:
continue
if prompt_node.widgets_values is None:
prompt_node.widgets_values = workflow_node.widgets_values
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from PIL import Image, ImageOps
@dataclass(frozen=True)
class ShapeOnlyTensor:
shape: tuple[int, ...]
def _torch_module() -> Any | None:
try:
import torch
except ImportError:
return None
return torch
def _image_tensor(image: Image.Image) -> Any:
torch = _torch_module()
width, height = image.size
if torch is None:
return ShapeOnlyTensor((1, height, width, 3))
rgb_image = image.convert("RGB")
data = list(rgb_image.getdata())
return torch.tensor(data, dtype=torch.float32).reshape(1, height, width, 3) / 255.0
def _mask_tensor(image: Image.Image) -> Any:
torch = _torch_module()
width, height = image.size
if torch is None:
return ShapeOnlyTensor((1, height, width))
if "A" in image.getbands():
alpha = image.getchannel("A")
mask_data = [1.0 - (value / 255.0) for value in alpha.getdata()]
else:
mask_data = [0.0] * (width * height)
return torch.tensor(mask_data, dtype=torch.float32).reshape(1, height, width)
def load_image_and_mask(image_path: str | Path) -> tuple[Any, Any, int, int]:
path = Path(image_path)
with Image.open(path) as loaded:
image = ImageOps.exif_transpose(loaded)
width, height = image.size
return _image_tensor(image), _mask_tensor(image), width, height
+230
View File
@@ -0,0 +1,230 @@
from __future__ import annotations
from collections import deque
from .adapters.loaders import (
checkpoint_name,
is_checkpoint_loader,
is_lora_loader,
is_vae_loader,
lora_name,
strength_clip,
strength_model,
vae_name,
)
from .graph import GraphIndex
from .models import LoraInfo, NodeRecord
def extract_model_name(graph: GraphIndex, sampler: NodeRecord) -> str:
for node, _path in _walk_upstream(
graph=graph,
start_node=sampler,
start_fields=("model",),
):
if is_checkpoint_loader(node):
return checkpoint_name(node)
return ""
def extract_vae_name(graph: GraphIndex) -> str:
output_chain_name = _extract_vae_name_from_output_chain(graph)
if output_chain_name:
return output_chain_name
for node in graph.nodes():
if _is_vae_decode(node):
name = _vae_name_from_decode_node(graph, node)
if name:
return name
for node in graph.nodes():
if is_vae_loader(node):
name = vae_name(node)
if name:
return name
return ""
def extract_loras(graph: GraphIndex, sampler: NodeRecord) -> list[LoraInfo]:
found: dict[str, LoraInfo] = {}
for field in ("model", "clip"):
for node, path in _walk_upstream(
graph=graph,
start_node=sampler,
start_fields=(field,),
):
if not is_lora_loader(node):
continue
name = lora_name(node)
if not name:
continue
existing = found.get(node.node_id)
full_path = _extend_path_to_source_loader(graph, node, path)
lora = LoraInfo(
node_id=node.node_id,
class_type=node.class_type,
lora_name=name,
strength_model=strength_model(node),
strength_clip=strength_clip(node),
path=full_path,
)
if existing is None or len(lora.path) < len(existing.path):
found[node.node_id] = lora
loras = list(found.values())
loras.sort(key=_checkpoint_to_sampler_sort_key)
return loras
def _walk_upstream(
graph: GraphIndex,
start_node: NodeRecord,
start_fields: tuple[str, ...],
) -> list[tuple[NodeRecord, list[str]]]:
discovered: list[tuple[NodeRecord, list[str]]] = []
visited: set[tuple[str, str]] = set()
def visit_from_node(node: NodeRecord, fields: tuple[str, ...], path: list[str]) -> None:
for field in fields:
value = node.inputs.get(field)
target = graph.link_target(value)
if target is None:
continue
linked_node = graph.get_node(target[0])
if linked_node is None:
continue
key = (linked_node.node_id, field)
if key in visited:
continue
visited.add(key)
linked_path = path + [f"{linked_node.class_type} {linked_node.node_id}"]
discovered.append((linked_node, linked_path))
visit_from_node(linked_node, _upstream_fields_for(linked_node), linked_path)
visit_from_node(
start_node,
start_fields,
[f"{start_node.class_type} {start_node.node_id}.{start_fields[0]}"],
)
return discovered
def _upstream_fields_for(node: NodeRecord) -> tuple[str, ...]:
fields = []
for field in ("model", "clip", "vae"):
if field in node.inputs:
fields.append(field)
return tuple(fields)
def _extend_path_to_source_loader(
graph: GraphIndex,
node: NodeRecord,
path: list[str],
) -> list[str]:
extended = list(path)
visited = {node.node_id}
current = node
while True:
next_node = _first_upstream_node(graph, current)
if next_node is None or next_node.node_id in visited:
return extended
visited.add(next_node.node_id)
extended.append(f"{next_node.class_type} {next_node.node_id}")
if is_checkpoint_loader(next_node) or is_vae_loader(next_node):
return extended
current = next_node
def _first_upstream_node(graph: GraphIndex, node: NodeRecord) -> NodeRecord | None:
for field in ("model", "clip", "vae"):
target = graph.link_target(node.inputs.get(field))
if target is None:
continue
linked_node = graph.get_node(target[0])
if linked_node is not None:
return linked_node
return None
def _checkpoint_to_sampler_sort_key(info: LoraInfo) -> tuple[int, str]:
marker = f"{info.class_type} {info.node_id}"
try:
index = info.path.index(marker)
except ValueError:
index = 0
# Paths are sampler -> source. Larger index means closer to checkpoint.
return (-index, info.node_id)
def _is_vae_decode(node: NodeRecord) -> bool:
lowered = node.class_type.lower().replace(" ", "")
return "vaedecode" in lowered
def _extract_vae_name_from_output_chain(graph: GraphIndex) -> str:
for output_node in graph.nodes():
if not _is_image_output_node(output_node):
continue
name = _walk_output_chain_for_vae(graph, output_node)
if name:
return name
return ""
def _is_image_output_node(node: NodeRecord) -> bool:
lowered = node.class_type.lower()
if lowered in {"saveimage", "previewimage", "saveanimatedwebp"}:
return True
has_image_input = "images" in node.inputs or "image" in node.inputs
return has_image_input and (
("save" in lowered and ("image" in lowered or "webp" in lowered))
or ("preview" in lowered and ("image" in lowered or "webp" in lowered))
)
def _walk_output_chain_for_vae(graph: GraphIndex, output_node: NodeRecord) -> str:
queue: deque[NodeRecord] = deque()
for field in ("images", "image"):
target = graph.link_target(output_node.inputs.get(field))
if target is None:
continue
node = graph.get_node(target[0])
if node is not None:
queue.append(node)
visited: set[str] = set()
while queue:
node = queue.popleft()
if node.node_id in visited:
continue
visited.add(node.node_id)
if _is_vae_decode(node):
name = _vae_name_from_decode_node(graph, node)
if name:
return name
for field in ("images", "image", "samples", "sample", "latent", "latent_image", "pixels"):
target = graph.link_target(node.inputs.get(field))
if target is None:
continue
linked_node = graph.get_node(target[0])
if linked_node is not None:
queue.append(linked_node)
return ""
def _vae_name_from_decode_node(graph: GraphIndex, decode_node: NodeRecord) -> str:
target = graph.link_target(decode_node.inputs.get("vae"))
if target is None:
return ""
vae_node = graph.get_node(target[0])
if vae_node and is_vae_loader(vae_node):
return vae_name(vae_node)
return ""
+345
View File
@@ -0,0 +1,345 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from PIL import Image
from .a1111_parser import parse_a1111_parameters
from .adapters.samplers import extract_sampler_settings
from .graph import GraphIndex
from .lora_extractor import extract_loras, extract_model_name, extract_vae_name
from .models import MetadataBundle, ParseResult, PromptSegment
from .prompt_merge import merge_prompt_segments
from .resolver import ConditioningResolver
from .sampler_selector import select_final_sampler
from .settings_formatter import format_setting
def _metadata_value(info: dict[str, Any], key: str) -> str | None:
value = info.get(key)
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return str(value)
def _source_format(prompt_raw: str | None, workflow_raw: str | None, parameters_raw: str | None) -> str:
if prompt_raw or workflow_raw:
return "ComfyUI prompt/workflow"
if parameters_raw:
return "A1111 parameters"
return "none"
def read_metadata(image_path: str | Path) -> MetadataBundle:
path = Path(image_path)
with Image.open(path) as image:
prompt_raw = _metadata_value(image.info, "prompt")
workflow_raw = _metadata_value(image.info, "workflow")
parameters_raw = _metadata_value(image.info, "parameters")
width, height = image.size
return MetadataBundle(
filename=path.name,
width=width,
height=height,
prompt_raw=prompt_raw,
workflow_raw=workflow_raw,
parameters_raw=parameters_raw,
source_format=_source_format(prompt_raw, workflow_raw, parameters_raw),
)
def parse_metadata_bundle(
bundle: MetadataBundle,
parameter_index: int = 0,
prefer_cached_text: bool = True,
include_raw_json: bool = True,
max_depth: int = 40,
) -> ParseResult:
if bundle.prompt_raw:
try:
return _parse_comfyui_bundle(
bundle=bundle,
prefer_cached_text=prefer_cached_text,
include_raw_json=include_raw_json,
max_depth=max_depth,
)
except _ComfyGraphStartError as exc:
if bundle.parameters_raw:
return _parse_a1111_bundle(
bundle=bundle,
parameter_index=parameter_index,
fallback_reason=str(exc),
)
return _failed_result(bundle, "FAILED", str(exc), include_raw_json)
if bundle.parameters_raw:
return _parse_a1111_bundle(
bundle=bundle,
parameter_index=parameter_index,
fallback_reason="missing ComfyUI prompt JSON",
)
return _failed_result(
bundle=bundle,
status_message="NO_METADATA",
reason="no ComfyUI prompt/workflow or A1111 parameters metadata found",
include_raw_json=include_raw_json,
)
class _ComfyGraphStartError(Exception):
pass
def _parse_comfyui_bundle(
bundle: MetadataBundle,
prefer_cached_text: bool,
include_raw_json: bool,
max_depth: int,
) -> ParseResult:
prompt = _load_required_json(bundle.prompt_raw, "prompt")
workflow, workflow_error = _load_optional_json(bundle.workflow_raw, "workflow")
graph = GraphIndex(prompt=prompt, workflow=workflow)
try:
sampler_selection = select_final_sampler(graph)
except ValueError as exc:
raise _ComfyGraphStartError(str(exc)) from exc
sampler = sampler_selection.sampler
resolver = ConditioningResolver(
graph=graph,
max_depth=max_depth,
prefer_cached_text=prefer_cached_text,
)
positive_segments = resolver.resolve_positive(sampler)
negative_segments = resolver.resolve_negative(sampler)
settings = extract_sampler_settings(sampler)
loras = extract_loras(graph, sampler)
partial_result = _partial_result(
sampler_selection=sampler_selection,
resolver=resolver,
workflow_error=workflow_error,
)
debug_trace = _debug_trace(
sampler_trace=sampler_selection.debug_trace,
resolver_trace=resolver.debug_trace,
loras=loras,
partial_result=partial_result,
workflow_error=workflow_error,
)
confidence = _confidence(
selection_confidence=sampler_selection.confidence,
segments=positive_segments + negative_segments,
has_unresolved=bool(resolver.unresolved),
used_fallback_sampler=sampler_selection.selected_by == "fallback",
has_workflow_error=workflow_error is not None,
has_llm_template_skip=_has_llm_template_skip(debug_trace),
)
status_message = _status_message(
has_unresolved=bool(resolver.unresolved),
used_fallback_sampler=sampler_selection.selected_by == "fallback",
has_workflow_error=workflow_error is not None,
has_llm_template_skip=_has_llm_template_skip(debug_trace),
)
result = ParseResult(
positive=merge_prompt_segments(positive_segments),
negative=merge_prompt_segments(negative_segments),
seed=_int_or_default(settings.get("seed"), -1),
steps=_int_or_default(settings.get("steps"), 0),
cfg=_float_or_default(settings.get("cfg"), 0.0),
width=bundle.width,
height=bundle.height,
model_name=extract_model_name(graph, sampler),
filename=bundle.filename,
vae_name=extract_vae_name(graph),
sampler_name=_str_or_empty(settings.get("sampler_name")),
scheduler=_str_or_empty(settings.get("scheduler")),
loras=loras,
raw_prompt_json=bundle.prompt_raw if include_raw_json and bundle.prompt_raw else "",
raw_workflow_json=bundle.workflow_raw if include_raw_json and bundle.workflow_raw else "",
partial_result=partial_result,
debug_trace=debug_trace,
confidence=confidence,
status_message=status_message,
)
result.setting = format_setting(result)
return result
def _load_required_json(raw: str | None, label: str) -> dict[str, Any]:
if raw is None or not raw.strip():
raise _ComfyGraphStartError(f"missing {label} JSON")
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
raise _ComfyGraphStartError(f"invalid {label} JSON: {exc}") from exc
if not isinstance(value, dict):
raise _ComfyGraphStartError(f"{label} JSON is not an object")
return value
def _load_optional_json(
raw: str | None,
label: str,
) -> tuple[dict[str, Any] | None, str | None]:
if raw is None or not raw.strip():
return None, None
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
return None, f"invalid {label} JSON: {exc}"
if not isinstance(value, dict):
return None, f"{label} JSON is not an object"
return value, None
def _partial_result(
sampler_selection: Any,
resolver: ConditioningResolver,
workflow_error: str | None,
) -> dict[str, Any]:
partial: dict[str, Any] = {
"source_format": "ComfyUI prompt/workflow",
"sampler_selection": {
"node_id": sampler_selection.sampler.node_id,
"class_type": sampler_selection.sampler.class_type,
"selected_by": sampler_selection.selected_by,
"confidence": sampler_selection.confidence,
},
}
if resolver.unresolved:
partial["unresolved"] = list(resolver.unresolved)
if workflow_error:
partial["workflow_error"] = workflow_error
return partial
def _debug_trace(
sampler_trace: str,
resolver_trace: str,
loras: list[Any],
partial_result: dict[str, Any],
workflow_error: str | None,
) -> str:
parts = [sampler_trace]
if workflow_error:
parts.append(f"WORKFLOW_FALLBACK:\n{workflow_error}")
if resolver_trace:
parts.append(resolver_trace)
if loras:
parts.append(_lora_trace(loras))
if partial_result.get("unresolved"):
parts.append("PARTIAL:\n" + json.dumps(partial_result["unresolved"], indent=2))
return "\n\n".join(part for part in parts if part)
def _lora_trace(loras: list[Any]) -> str:
lines = ["LORA_TRACE:"]
for lora in loras:
path = " <- ".join(lora.path)
lines.append(f"{lora.lora_name}: {path}")
return "\n".join(lines)
def _confidence(
selection_confidence: float,
segments: list[PromptSegment],
has_unresolved: bool,
used_fallback_sampler: bool,
has_workflow_error: bool,
has_llm_template_skip: bool,
) -> float:
confidence = min(selection_confidence, 0.95)
if used_fallback_sampler:
confidence = min(confidence, 0.7)
if segments:
confidence = min(confidence, min(segment.confidence for segment in segments))
if has_workflow_error:
confidence = min(confidence, 0.85)
if has_unresolved or has_llm_template_skip:
confidence = min(confidence, 0.6)
return max(0.0, round(confidence, 2))
def _status_message(
has_unresolved: bool,
used_fallback_sampler: bool,
has_workflow_error: bool,
has_llm_template_skip: bool,
) -> str:
if has_unresolved or used_fallback_sampler or has_workflow_error or has_llm_template_skip:
return "PARTIAL"
return "OK"
def _has_llm_template_skip(debug_trace: str) -> bool:
return "LLM template input skipped" in debug_trace
def _parse_a1111_bundle(
bundle: MetadataBundle,
parameter_index: int,
fallback_reason: str,
) -> ParseResult:
result = parse_a1111_parameters(bundle.parameters_raw or "", parameter_index)
result.filename = bundle.filename
result.width = bundle.width or result.width
result.height = bundle.height or result.height
result.partial_result.setdefault("source_format", "A1111 parameters")
result.partial_result["fallback_reason"] = fallback_reason
result.debug_trace = (
"A1111_FALLBACK:\n"
f"{fallback_reason}\n"
"Parsed metadata from parameters field."
)
result.setting = format_setting(result)
return result
def _failed_result(
bundle: MetadataBundle,
status_message: str,
reason: str,
include_raw_json: bool,
) -> ParseResult:
result = ParseResult(
width=bundle.width,
height=bundle.height,
filename=bundle.filename,
raw_prompt_json=bundle.prompt_raw if include_raw_json and bundle.prompt_raw else "",
raw_workflow_json=bundle.workflow_raw if include_raw_json and bundle.workflow_raw else "",
partial_result={"source_format": bundle.source_format, "reason": reason},
debug_trace=f"{status_message}:\n{reason}",
confidence=0.0,
status_message=status_message,
)
result.setting = format_setting(result)
return result
def _int_or_default(value: Any, default: int) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _float_or_default(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _str_or_empty(value: Any) -> str:
if value is None:
return ""
return str(value)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class MetadataBundle:
filename: str
width: int
height: int
prompt_raw: str | None
workflow_raw: str | None
parameters_raw: str | None
source_format: str
@dataclass
class NodeRecord:
node_id: str
class_type: str
inputs: dict[str, Any]
widgets_values: list[Any] | dict[str, Any] | None = None
@dataclass
class PromptSegment:
text: str
node_id: str | None
class_type: str | None
field: str | None
path: list[str]
confidence: float = 1.0
@dataclass
class LoraInfo:
node_id: str
class_type: str
lora_name: str
strength_model: float | None
strength_clip: float | None
path: list[str]
@dataclass
class ParseResult:
positive: str = ""
negative: str = ""
seed: int = -1
steps: int = 0
cfg: float = 0.0
width: int = 0
height: int = 0
model_name: str = ""
filename: str = ""
setting: str = ""
vae_name: str = ""
sampler_name: str = ""
scheduler: str = ""
loras: list[LoraInfo] = field(default_factory=list)
raw_prompt_json: str = ""
raw_workflow_json: str = ""
partial_result: dict[str, Any] = field(default_factory=dict)
debug_trace: str = ""
confidence: float = 0.0
status_message: str = ""
+24
View File
@@ -0,0 +1,24 @@
from __future__ import annotations
import re
from .models import PromptSegment
def _clean_segment(text: str) -> str:
cleaned = text.strip()
cleaned = re.sub(r"\n\s*\n+", "\n", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned)
cleaned = re.sub(r"\s*,\s*", ", ", cleaned)
cleaned = re.sub(r"(,\s*)+", ", ", cleaned)
cleaned = cleaned.strip(" ,\t\r\n")
return cleaned
def merge_prompt_segments(segments: list[PromptSegment]) -> str:
cleaned_segments = [
cleaned
for segment in segments
if (cleaned := _clean_segment(segment.text))
]
return _clean_segment(", ".join(cleaned_segments))
+292
View File
@@ -0,0 +1,292 @@
from __future__ import annotations
from typing import Any
from .adapters.base import AdapterRegistry, TextResolutionContext
from .adapters.clip_text import CLIPTextEncodeAdapter, CLIPTextEncodeSDXLAdapter
from .adapters.conditioning import ConditioningAdapter
from .adapters.pysssss import ShowTextAdapter, StringFunctionAdapter
from .adapters.text_sources import DeepTranslatorTextAdapter, GenericTextAdapter
from .graph import GraphIndex
from .models import NodeRecord, PromptSegment
from .trace import TraceCollector
class ConditioningResolver:
def __init__(
self,
graph: GraphIndex,
adapters: AdapterRegistry | None = None,
max_depth: int = 40,
prefer_cached_text: bool = True,
) -> None:
self.graph = graph
self.max_depth = max_depth
self.prefer_cached_text = prefer_cached_text
self.trace = TraceCollector()
self.unresolved: list[dict[str, Any]] = []
self._active: set[tuple[str, str, str]] = set()
self._text_registry = adapters or self._default_text_registry()
self._conditioning_adapter = ConditioningAdapter()
@property
def debug_trace(self) -> str:
return self.trace.render()
@property
def partial_result(self) -> dict[str, Any]:
return {"unresolved": list(self.unresolved)}
def resolve_positive(self, sampler: NodeRecord) -> list[PromptSegment]:
return self._resolve_sampler_field(sampler, "positive", "positive")
def resolve_negative(self, sampler: NodeRecord) -> list[PromptSegment]:
return self._resolve_sampler_field(sampler, "negative", "negative")
def resolve_value(
self,
value: Any,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
return self._resolve_value(
value=value,
role=role,
path=path,
depth=depth,
field=None,
)
def _resolve_sampler_field(
self,
sampler: NodeRecord,
field: str,
role: str,
) -> list[PromptSegment]:
section = f"{role.upper()}_TRACE"
self.trace.add(section, f"{sampler.class_type} {sampler.node_id}.{field}")
if field not in sampler.inputs:
self._record_unresolved(
node=sampler,
field=field,
role=role,
reason="missing sampler conditioning input",
)
return []
return self._resolve_value(
value=sampler.inputs[field],
role=role,
path=[f"{sampler.class_type} {sampler.node_id}.{field}"],
depth=0,
field=field,
)
def _resolve_value(
self,
value: Any,
role: str,
path: list[str],
depth: int,
field: str | None,
) -> list[PromptSegment]:
if depth > self.max_depth:
self._record_unresolved_by_values(
node_id="",
class_type="",
field=field or "",
role=role,
reason="max_depth",
)
return []
if isinstance(value, str):
if not value.strip():
return []
return [
PromptSegment(
text=value,
node_id=None,
class_type=None,
field=field,
path=path,
)
]
target = self.graph.link_target(value)
if target is not None:
node_id, output_index = target
return self._resolve_node(
node_id=node_id,
role=role,
path=path + [f"link[{node_id}, {output_index}]"],
depth=depth + 1,
field=field or "",
)
return []
def _resolve_node(
self,
node_id: str,
role: str,
path: list[str],
depth: int,
field: str,
) -> list[PromptSegment]:
node = self.graph.get_node(node_id)
if node is None:
self._record_unresolved_by_values(
node_id=node_id,
class_type="",
field=field,
role=role,
reason="missing node",
)
return []
section = f"{role.upper()}_TRACE"
self.trace.add(section, f" -> {node.class_type} {node.node_id}")
active_key = (node.node_id, field, role)
if active_key in self._active:
self._record_unresolved(
node=node,
field=field,
role=role,
reason="cycle",
)
return []
if depth > self.max_depth:
self._record_unresolved(
node=node,
field=field,
role=role,
reason="max_depth",
)
return []
self._active.add(active_key)
try:
text_adapter = self._text_registry.adapter_for(node)
if text_adapter is not None:
text_context = TextResolutionContext(
graph=self.graph,
registry=self._text_registry,
prefer_cached_text=self.prefer_cached_text,
trace=self.trace,
max_depth=self.max_depth - depth,
)
return text_context.resolve_node(
node_id=node.node_id,
role=role,
path=path,
depth=0,
)
if self._conditioning_adapter.matches(node):
return self._resolve_conditioning_node(node, role, path, depth)
problem_field = self._first_linked_field(node) or field
self._record_unresolved(
node=node,
field=problem_field,
role=role,
reason="no conditioning adapter matched",
)
return []
finally:
self._active.remove(active_key)
def _resolve_conditioning_node(
self,
node: NodeRecord,
role: str,
path: list[str],
depth: int,
) -> list[PromptSegment]:
fields = self._conditioning_adapter.fields_for_role(node, role)
linked_fields = [
field
for field in fields
if self.graph.is_link(node.inputs.get(field))
]
if not linked_fields:
self._record_unresolved(
node=node,
field=fields[0] if fields else self._first_linked_field(node) or "",
role=role,
reason="no linked conditioning input",
)
return []
segments: list[PromptSegment] = []
for field in linked_fields:
segments.extend(
self._resolve_value(
value=node.inputs[field],
role=role,
path=path + [f"{node.class_type} {node.node_id}.{field}"],
depth=depth + 1,
field=field,
)
)
return segments
def _first_linked_field(self, node: NodeRecord) -> str | None:
linked_inputs = self.graph.upstream_link_inputs(node.node_id)
if not linked_inputs:
return None
field, _value = linked_inputs[0]
return field
def _record_unresolved(
self,
node: NodeRecord,
field: str,
role: str,
reason: str,
) -> None:
self._record_unresolved_by_values(
node_id=node.node_id,
class_type=node.class_type,
field=field,
role=role,
reason=reason,
)
def _record_unresolved_by_values(
self,
node_id: str,
class_type: str,
field: str,
role: str,
reason: str,
) -> None:
self.unresolved.append(
{
"node_id": node_id,
"class_type": class_type,
"field": field,
"role": role,
"reason": reason,
}
)
self.trace.add(
f"{role.upper()}_TRACE",
(
f"UNRESOLVED {class_type} {node_id} "
f"field={field} role={role} reason={reason}"
),
)
def _default_text_registry(self) -> AdapterRegistry:
registry = AdapterRegistry()
registry.register(CLIPTextEncodeAdapter())
registry.register(CLIPTextEncodeSDXLAdapter())
registry.register(StringFunctionAdapter())
registry.register(ShowTextAdapter())
registry.register(DeepTranslatorTextAdapter())
registry.register(GenericTextAdapter())
return registry
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from .adapters.samplers import is_sampler
from .graph import GraphIndex
from .models import NodeRecord
OUTPUT_INPUT_FIELDS = ("images", "image")
UPSTREAM_FIELDS = (
"images",
"image",
"samples",
"sample",
"latent",
"latent_image",
"pixels",
)
@dataclass
class SamplerSelection:
sampler: NodeRecord
confidence: float
debug_trace: str
selected_by: str
def select_final_sampler(graph: GraphIndex) -> SamplerSelection:
for output_node in _output_nodes(graph):
selection = _select_from_output_node(graph, output_node)
if selection is not None:
return selection
fallback = _fallback_sampler(graph)
if fallback is None:
raise ValueError("No sampler candidate found")
return SamplerSelection(
sampler=fallback,
confidence=0.5,
debug_trace=(
"SAMPLER_SELECTION:\n"
f"sampler selected by fallback heuristic: "
f"{fallback.class_type} {fallback.node_id}; "
"no final SaveImage/PreviewImage output chain was found"
),
selected_by="fallback",
)
def _output_nodes(graph: GraphIndex) -> list[NodeRecord]:
return [node for node in graph.nodes() if _is_output_image_node(node)]
def _is_output_image_node(node: NodeRecord) -> bool:
lowered = node.class_type.lower()
if lowered in {"saveimage", "previewimage", "saveanimatedwebp"}:
return True
has_output_image_field = any(field in node.inputs for field in OUTPUT_INPUT_FIELDS)
return has_output_image_field and (
("save" in lowered and ("image" in lowered or "webp" in lowered))
or ("preview" in lowered and ("image" in lowered or "webp" in lowered))
)
def _select_from_output_node(
graph: GraphIndex,
output_node: NodeRecord,
) -> SamplerSelection | None:
queue: deque[tuple[NodeRecord, list[str]]] = deque()
for field in OUTPUT_INPUT_FIELDS:
target = graph.link_target(output_node.inputs.get(field))
if target is None:
continue
target_node = graph.get_node(target[0])
if target_node is None:
continue
queue.append(
(
target_node,
[f"{output_node.class_type} {output_node.node_id}.{field}"],
)
)
visited: set[str] = set()
while queue:
node, chain = queue.popleft()
if node.node_id in visited:
continue
visited.add(node.node_id)
if is_sampler(node):
trace_chain = " <- ".join(chain + [f"{node.class_type} {node.node_id}"])
return SamplerSelection(
sampler=node,
confidence=1.0,
debug_trace=(
"SAMPLER_SELECTION:\n"
f"{trace_chain}\n"
f"Selected {node.class_type} {node.node_id} because it is "
f"upstream of final {output_node.class_type}."
),
selected_by="output_chain",
)
for field in UPSTREAM_FIELDS:
target = graph.link_target(node.inputs.get(field))
if target is None:
continue
target_node = graph.get_node(target[0])
if target_node is None:
continue
queue.append(
(
target_node,
chain + [f"{node.class_type} {node.node_id}.{field}"],
)
)
return None
def _fallback_sampler(graph: GraphIndex) -> NodeRecord | None:
candidates = [
node
for node in graph.nodes()
if is_sampler(node) and _has_conditioning_inputs(node)
]
if candidates:
return candidates[-1]
samplers = [node for node in graph.nodes() if is_sampler(node)]
if samplers:
return samplers[-1]
return None
def _has_conditioning_inputs(node: NodeRecord) -> bool:
return "positive" in node.inputs or "negative" in node.inputs
@@ -0,0 +1,93 @@
from __future__ import annotations
from typing import Any
from .models import LoraInfo, ParseResult
def format_setting(result: ParseResult) -> str:
lines = [
f"Filename: {_text_or_unknown(result.filename)}",
f"Source: {_source(result)}",
f"Model: {_text_or_unknown(result.model_name)}",
f"VAE: {_text_or_unknown(result.vae_name)}",
]
lines.extend(_format_loras(result.loras))
lines.extend(
[
f"Seed: {result.seed}",
f"Steps: {result.steps}",
f"CFG: {result.cfg}",
f"Sampler: {_text_or_unknown(result.sampler_name)}",
f"Scheduler: {_text_or_unknown(result.scheduler)}",
f"Size: {_format_size(result.width, result.height)}",
f"Status: {result.status_message or 'OK'}",
f"Confidence: {result.confidence:.2f}",
]
)
unresolved_lines = _format_unresolved(result.partial_result)
if unresolved_lines:
lines.append("Unresolved:")
lines.extend(unresolved_lines)
return "\n".join(lines)
def _source(result: ParseResult) -> str:
source = result.partial_result.get("source_format")
if isinstance(source, str) and source:
return source
if result.raw_prompt_json or result.raw_workflow_json:
return "ComfyUI prompt/workflow"
return "unknown"
def _format_loras(loras: list[LoraInfo]) -> list[str]:
if not loras:
return ["LoRA: none"]
lines = ["LoRA:"]
for lora in loras:
lines.append(
"* "
f"{lora.lora_name} "
f"(model {_format_strength(lora.strength_model)}, "
f"clip {_format_strength(lora.strength_clip)})"
)
return lines
def _format_strength(value: float | None) -> str:
if value is None:
return "unknown"
return f"{value:.2f}"
def _format_size(width: int, height: int) -> str:
if width <= 0 or height <= 0:
return "unknown"
return f"{width} x {height}"
def _format_unresolved(partial_result: dict[str, Any]) -> list[str]:
unresolved = partial_result.get("unresolved") or partial_result.get("unresolved_nodes")
if not isinstance(unresolved, list):
return []
lines: list[str] = []
for entry in unresolved:
if not isinstance(entry, dict):
continue
node_id = _text_or_unknown(str(entry.get("node_id", "")))
class_type = _text_or_unknown(str(entry.get("class_type", "")))
field = _text_or_unknown(str(entry.get("field", "")))
role = _text_or_unknown(str(entry.get("role", "")))
reason = _text_or_unknown(str(entry.get("reason", "")))
lines.append(
f"* Node {node_id} {class_type} input {field} "
f"role={role} reason={reason}"
)
return lines
def _text_or_unknown(value: str) -> str:
return value if value else "unknown"
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from collections import OrderedDict
class TraceCollector:
def __init__(self) -> None:
self._sections: OrderedDict[str, list[str]] = OrderedDict()
def add(self, section: str, line: str) -> None:
self._sections.setdefault(section, []).append(line)
def render(self) -> str:
rendered_sections: list[str] = []
for section, lines in self._sections.items():
rendered_sections.append(f"{section}:\n" + "\n".join(lines))
return "\n\n".join(rendered_sections)
+518
View File
@@ -0,0 +1,518 @@
import json
from smart_metadata_reader.graph import GraphIndex
from smart_metadata_reader.metadata_reader import parse_metadata_bundle
from smart_metadata_reader.models import MetadataBundle
from smart_metadata_reader.prompt_merge import merge_prompt_segments
def resolver_for(prompt, max_depth=40):
from smart_metadata_reader.resolver import ConditioningResolver
graph = GraphIndex(prompt=prompt, workflow=None)
sampler = graph.get_node("1")
resolver = ConditioningResolver(
graph=graph,
max_depth=max_depth,
prefer_cached_text=True,
)
return resolver, sampler
def segment_texts(segments):
return [segment.text for segment in segments]
def test_resolves_simple_ksampler_positive_to_clip_text():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["2", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "simple positive"}},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["simple positive"]
def test_resolves_controlnet_apply_passthrough():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "controlnet positive"}},
"3": {
"class_type": "ControlNetApply",
"inputs": {"conditioning": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["controlnet positive"]
def test_resolves_controlnet_apply_advanced_passthrough():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "advanced positive"}},
"3": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {"positive": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["advanced positive"]
def test_resolves_inpaint_model_conditioning_passthrough():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["3", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "inpaint positive"}},
"3": {
"class_type": "InpaintModelConditioning",
"inputs": {"positive": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == ["inpaint positive"]
def test_conditioning_combine_merges_both_branches():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["4", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "first branch"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "second branch"}},
"4": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["2", 0], "conditioning_2": ["3", 0]},
},
}
resolver, sampler = resolver_for(prompt)
segments = resolver.resolve_positive(sampler)
assert segment_texts(segments) == ["first branch", "second branch"]
assert merge_prompt_segments(segments) == "first branch, second branch"
def test_conditioning_concat_merges_multiple_clip_text_nodes_into_positive():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["4", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "subject tags"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "style tags"}},
"4": {
"class_type": "ConditioningConcat",
"inputs": {"conditioning_to": ["2", 0], "conditioning_from": ["3", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_positive(sampler)) == [
"subject tags",
"style tags",
]
def test_resolves_negative_chain_separately():
prompt = {
"1": {
"class_type": "KSampler",
"inputs": {"positive": ["2", 0], "negative": ["3", 0]},
},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive text"}},
"3": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative text"}},
}
resolver, sampler = resolver_for(prompt)
assert segment_texts(resolver.resolve_negative(sampler)) == ["negative text"]
def test_unknown_conditioning_node_returns_unresolved_without_prompt_text():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": "should not guess"}},
"9": {
"class_type": "UnknownConditioningRouter",
"inputs": {"mystery": ["2", 0]},
},
}
resolver, sampler = resolver_for(prompt)
assert resolver.resolve_positive(sampler) == []
assert resolver.unresolved[0]["node_id"] == "9"
assert resolver.unresolved[0]["class_type"] == "UnknownConditioningRouter"
assert resolver.unresolved[0]["field"] == "mystery"
assert resolver.unresolved[0]["role"] == "positive"
assert "UnknownConditioningRouter 9" in resolver.debug_trace
def test_cycle_guard_prevents_infinite_recursion():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["4", 0]}},
"4": {"class_type": "ControlNetApply", "inputs": {"conditioning": ["5", 0]}},
"5": {"class_type": "ControlNetApply", "inputs": {"conditioning": ["4", 0]}},
}
resolver, sampler = resolver_for(prompt, max_depth=10)
assert resolver.resolve_positive(sampler) == []
assert resolver.unresolved[0]["reason"] == "cycle"
assert resolver.unresolved[0]["role"] == "positive"
def bundle_for_prompt(prompt, workflow=None, filename="sample.png", width=1200, height=1600):
return MetadataBundle(
filename=filename,
width=width,
height=height,
prompt_raw=json.dumps(prompt),
workflow_raw=json.dumps(workflow) if workflow is not None else None,
parameters_raw=None,
source_format="ComfyUI prompt/workflow",
)
def test_parse_metadata_bundle_resolves_full_comfyui_graph_end_to_end():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["4", 0]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["6", 0],
"clip": ["6", 1],
"positive": ["10", 0],
"negative": ["20", 0],
"seed": 123456789,
"steps": 30,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
},
},
"4": {"class_type": "VAELoader", "inputs": {"vae_name": "final.vae.safetensors"}},
"5": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "anime_model.safetensors"},
},
"6": {
"class_type": "LoraLoader",
"inputs": {
"model": ["5", 0],
"clip": ["5", 1],
"lora_name": "role_lora.safetensors",
"strength_model": 0.8,
"strength_clip": 1.0,
},
},
"10": {
"class_type": "ControlNetApply",
"inputs": {
"conditioning": ["11", 0],
"image": "control image filename should not enter prompt",
"strength": 0.7,
"control_net": "control_v11p_sd15",
},
},
"11": {"class_type": "CLIPTextEncode", "inputs": {"text": ["12", 0]}},
"12": {
"class_type": "StringFunction|pysssss",
"inputs": {
"action": "append",
"text_a": "role_lora_trigger, character_name, NSFW",
"text_b": ["13", 0],
"text_c": ["14", 0],
},
},
"13": {
"class_type": "ShowText|pysssss",
"inputs": {
"text": ["15", 0],
"text_0": "1girl, solo, squatting, stage lights, anime style",
},
},
"14": {
"class_type": "DeepTranslatorTextNode",
"inputs": {"text": "translated supplement, dramatic lighting"},
},
"15": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "You are an anime image prompt extraction assistant",
"prompt": "Analyze this image and convert it into a prompt template",
},
},
"20": {"class_type": "CLIPTextEncode", "inputs": {"text": ["21", 0]}},
"21": {
"class_type": "StringFunction|pysssss",
"inputs": {"action": "append", "text_a": "bad hands, low quality"},
},
"50": {
"class_type": "GeminiChatNode",
"inputs": {"prompt": "unconnected prompt template must be ignored"},
},
"51": {
"class_type": "PrimitiveString",
"inputs": {"value": "unconnected primitive text must be ignored"},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert "role_lora_trigger" in result.positive
assert "character_name" in result.positive
assert "NSFW" in result.positive
assert "1girl, solo, squatting, stage lights, anime style" in result.positive
assert "translated supplement, dramatic lighting" in result.positive
assert result.negative == "bad hands, low quality"
assert "Analyze this image" not in result.positive
assert "control_v11p_sd15" not in result.positive
assert "control image filename" not in result.positive
assert "unconnected prompt template" not in result.positive
assert "unconnected primitive text" not in result.positive
assert result.seed == 123456789
assert result.steps == 30
assert result.cfg == 7.0
assert result.sampler_name == "euler"
assert result.scheduler == "normal"
assert result.model_name == "anime_model.safetensors"
assert result.vae_name == "final.vae.safetensors"
assert [lora.lora_name for lora in result.loras] == ["role_lora.safetensors"]
assert result.status_message == "OK"
assert result.confidence >= 0.9
assert "Seed: 123456789" in result.setting
assert "Model: anime_model.safetensors" in result.setting
assert "SAMPLER_SELECTION" in result.debug_trace
assert "POSITIVE_TRACE" in result.debug_trace
assert "NEGATIVE_TRACE" in result.debug_trace
def test_parse_metadata_bundle_uses_a1111_parameters_when_comfyui_graph_cannot_start():
bundle = MetadataBundle(
filename="a1111.png",
width=0,
height=0,
prompt_raw=None,
workflow_raw=None,
parameters_raw=(
"role_lora_trigger, NSFW, cinematic lighting\n"
"Negative prompt: low quality, bad hands\n"
"Steps: 25, Sampler: Euler a, CFG scale: 7.5, "
"Seed: 12345, Size: 768x1024, Model: demo_model"
),
source_format="A1111 parameters",
)
result = parse_metadata_bundle(bundle)
assert result.positive == "role_lora_trigger, NSFW, cinematic lighting"
assert result.negative == "low quality, bad hands"
assert result.seed == 12345
assert result.steps == 25
assert result.cfg == 7.5
assert result.width == 768
assert result.height == 1024
assert result.model_name == "demo_model"
assert result.status_message == "A1111 parameters fallback"
assert "Source: A1111 parameters" in result.setting
def test_parse_metadata_bundle_omits_raw_json_when_requested_but_still_parses():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "visible positive"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": "visible negative"}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt), include_raw_json=False)
assert result.positive == "visible positive"
assert result.negative == "visible negative"
assert result.raw_prompt_json == ""
assert result.raw_workflow_json == ""
def test_parse_metadata_bundle_keeps_partial_result_for_unknown_conditioning_node():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["9", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "do not guess"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative ok"}},
"9": {
"class_type": "UnknownConditioningRouter",
"inputs": {"positive_conditioning": ["4", 0]},
},
"10": {
"class_type": "PrimitiveString",
"inputs": {"value": "nearby text must not be scanned"},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == ""
assert result.negative == "negative ok"
assert "do not guess" not in result.positive
assert "nearby text" not in result.positive
assert result.status_message == "PARTIAL"
assert result.confidence <= 0.6
assert result.partial_result["unresolved"][0]["node_id"] == "9"
assert "Unresolved:" in result.setting
def test_parse_metadata_bundle_ignores_unconnected_text_and_llm_nodes():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "real prompt"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
"6": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "unconnected system prompt",
"prompt": "unconnected user prompt template",
},
},
"7": {"class_type": "PrimitiveString", "inputs": {"value": "unconnected text"}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "real prompt"
assert "unconnected system prompt" not in result.positive
assert "unconnected user prompt template" not in result.positive
assert "unconnected text" not in result.positive
def test_parse_metadata_bundle_controlnet_parameters_do_not_enter_prompt():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["6", 0], "negative": ["5", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "real control prompt"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
"6": {
"class_type": "ControlNetApply",
"inputs": {
"conditioning": ["4", 0],
"image": "pose_control_image.png",
"strength": 0.55,
"control_net": "controlnet-depth.safetensors",
},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "real control prompt"
assert "pose_control_image" not in result.positive
assert "0.55" not in result.positive
assert "controlnet-depth" not in result.positive
def test_parse_metadata_bundle_conditioning_combine_merges_both_branches():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["8", 0], "negative": ["7", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": "branch alpha"}},
"5": {"class_type": "CLIPTextEncode", "inputs": {"text": "branch beta"}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
"8": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["4", 0], "conditioning_2": ["5", 0]},
},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "branch alpha, branch beta"
def test_llm_template_upstream_of_showtext_without_cache_is_not_used():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["7", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": ["5", 0]}},
"5": {"class_type": "ShowText|pysssss", "inputs": {"text": ["6", 0]}},
"6": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "You are an anime image prompt extraction assistant",
"prompt": "Analyze this image and convert it into a final prompt",
},
},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert "You are an anime image prompt extraction assistant" not in result.positive
assert "Analyze this image" not in result.positive
assert result.positive == ""
assert "LLM template input skipped, no cached generated output found" in result.debug_trace
def test_showtext_text_0_cache_beats_upstream_llm_template():
prompt = {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["3", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"positive": ["4", 0], "negative": ["7", 0]},
},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": ["5", 0]}},
"5": {
"class_type": "ShowText|pysssss",
"inputs": {
"text": ["6", 0],
"text_0": "1girl, solo, squatting, stage lights, anime style",
},
},
"6": {
"class_type": "GeminiChatNode",
"inputs": {
"system_instruction": "system prompt template should stay hidden",
"prompt": "user prompt template should stay hidden",
},
},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": ""}},
}
result = parse_metadata_bundle(bundle_for_prompt(prompt))
assert result.positive == "1girl, solo, squatting, stage lights, anime style"
assert "system prompt template" not in result.positive
assert "user prompt template" not in result.positive
+153
View File
@@ -0,0 +1,153 @@
def test_model_dataclasses_are_importable():
from smart_metadata_reader.models import (
LoraInfo,
MetadataBundle,
NodeRecord,
ParseResult,
PromptSegment,
)
bundle = MetadataBundle(
filename="sample.png",
width=512,
height=768,
prompt_raw=None,
workflow_raw=None,
parameters_raw=None,
source_format="none",
)
node = NodeRecord(node_id="1", class_type="CLIPTextEncode", inputs={})
segment = PromptSegment(
text="character trigger",
node_id="1",
class_type="CLIPTextEncode",
field="text",
path=["CLIPTextEncode 1.text"],
)
lora = LoraInfo(
node_id="2",
class_type="LoraLoader",
lora_name="character.safetensors",
strength_model=0.8,
strength_clip=1.0,
path=["KSampler 3.model", "LoraLoader 2"],
)
result = ParseResult(
positive="character trigger",
negative="",
filename=bundle.filename,
loras=[lora],
)
assert node.node_id == "1"
assert segment.text == "character trigger"
assert result.seed == -1
assert result.steps == 0
assert result.cfg == 0.0
assert result.width == 0
assert result.height == 0
def test_graph_index_reads_api_prompt_nodes_as_primary_records():
from smart_metadata_reader.graph import GraphIndex
prompt = {
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["22", 0]},
},
"22": {
"class_type": "StringFunction|pysssss",
"inputs": {"text_a": "character trigger"},
},
}
workflow = {
"nodes": [
{
"id": 6,
"type": "CLIPTextEncode",
"inputs": {"text": "stale widget text"},
}
]
}
graph = GraphIndex(prompt=prompt, workflow=workflow)
node = graph.get_node("6")
assert node.class_type == "CLIPTextEncode"
assert node.inputs["text"] == ["22", 0]
assert graph.get_node(22).class_type == "StringFunction|pysssss"
def test_graph_index_reads_ui_workflow_nodes_when_prompt_is_absent():
from smart_metadata_reader.graph import GraphIndex
workflow = {
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"inputs": {"text": "workflow text"},
"widgets_values": ["workflow widget"],
}
]
}
graph = GraphIndex(prompt=None, workflow=workflow)
node = graph.get_node("1")
assert node.class_type == "PrimitiveString"
assert node.inputs["text"] == "workflow text"
assert node.widgets_values == ["workflow widget"]
def test_graph_index_detects_links_and_downstream_nodes():
from smart_metadata_reader.graph import GraphIndex
prompt = {
"1": {"class_type": "PrimitiveString", "inputs": {"text": "hello"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 0]}},
"3": {"class_type": "KSampler", "inputs": {"positive": [2, 0]}},
}
graph = GraphIndex(prompt=prompt, workflow=None)
assert graph.is_link(["1", 0])
assert graph.is_link([1, 0])
assert graph.link_target([1, 0]) == ("1", 0)
assert graph.upstream_link_inputs("3") == [("positive", [2, 0])]
assert [node.node_id for node in graph.downstream_nodes("1")] == ["2"]
def test_workflow_cache_prefers_named_inputs_then_widget_values():
from smart_metadata_reader.graph import GraphIndex
prompt = {
"10": {
"class_type": "ShowText|pysssss",
"inputs": {"text": ["9", 0]},
},
"11": {
"class_type": "ShowText|pysssss",
"inputs": {"text": ["9", 0]},
},
}
workflow = {
"nodes": [
{
"id": 10,
"type": "ShowText|pysssss",
"inputs": {"text_0": "cached shown text"},
},
{
"id": 11,
"type": "ShowText|pysssss",
"widgets_values": ["widget cached text"],
},
]
}
graph = GraphIndex(prompt=prompt, workflow=workflow)
assert graph.workflow_cache_for("10", ["text_0", "text"]) == "cached shown text"
assert graph.workflow_cache_for("11", ["text_0"]) == "widget cached text"
+256
View File
@@ -0,0 +1,256 @@
import json
from smart_metadata_reader.graph import GraphIndex
from smart_metadata_reader.models import NodeRecord, PromptSegment
from smart_metadata_reader.prompt_merge import merge_prompt_segments
def graph_and_sampler(prompt, sampler_id="9"):
graph = GraphIndex(prompt=prompt, workflow=None)
return graph, graph.get_node(sampler_id)
def test_extract_model_name_from_checkpoint_loader_simple_ckpt_name():
from smart_metadata_reader.lora_extractor import extract_model_name
prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "dream_model.safetensors"},
},
"9": {"class_type": "KSampler", "inputs": {"model": ["1", 0]}},
}
graph, sampler = graph_and_sampler(prompt)
assert extract_model_name(graph, sampler) == "dream_model.safetensors"
def test_extract_model_name_from_checkpoint_loader_field_variants():
from smart_metadata_reader.lora_extractor import extract_model_name
for field_name in ("checkpoint", "model_name", "name"):
prompt = {
"1": {
"class_type": "CheckpointLoader",
"inputs": {field_name: f"{field_name}_model.safetensors"},
},
"9": {"class_type": "KSampler", "inputs": {"model": ["1", 0]}},
}
graph, sampler = graph_and_sampler(prompt)
assert extract_model_name(graph, sampler) == f"{field_name}_model.safetensors"
def test_sampler_model_lora_loader_chain_extracts_model_and_lora():
from smart_metadata_reader.lora_extractor import extract_loras, extract_model_name
prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "base_model.safetensors"},
},
"2": {
"class_type": "LoraLoader",
"inputs": {
"model": ["1", 0],
"clip": ["1", 1],
"lora_name": "character_lora.safetensors",
"strength_model": 0.8,
"strength_clip": 1.0,
},
},
"9": {"class_type": "KSampler", "inputs": {"model": ["2", 0]}},
}
graph, sampler = graph_and_sampler(prompt)
loras = extract_loras(graph, sampler)
assert extract_model_name(graph, sampler) == "base_model.safetensors"
assert [(lora.lora_name, lora.strength_model, lora.strength_clip) for lora in loras] == [
("character_lora.safetensors", 0.8, 1.0)
]
assert loras[0].path == [
"KSampler 9.model",
"LoraLoader 2",
"CheckpointLoaderSimple 1",
]
def test_multiple_chained_lora_loaders_are_returned_checkpoint_to_sampler_order():
from smart_metadata_reader.lora_extractor import extract_loras
prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "base_model.safetensors"},
},
"2": {
"class_type": "LoraLoader",
"inputs": {
"model": ["1", 0],
"clip": ["1", 1],
"lora_name": "first_lora.safetensors",
"strength_model": 0.5,
"strength_clip": 0.6,
},
},
"3": {
"class_type": "LoraLoader",
"inputs": {
"model": ["2", 0],
"clip": ["2", 1],
"lora_name": "second_lora.safetensors",
"strength_model": 0.7,
"strength_clip": 0.8,
},
},
"9": {"class_type": "KSampler", "inputs": {"model": ["3", 0]}},
}
graph, sampler = graph_and_sampler(prompt)
assert [lora.lora_name for lora in extract_loras(graph, sampler)] == [
"first_lora.safetensors",
"second_lora.safetensors",
]
def test_lora_loader_model_only_extracts_lora_name_and_strength_model():
from smart_metadata_reader.lora_extractor import extract_loras
prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "base_model.safetensors"},
},
"2": {
"class_type": "LoraLoaderModelOnly",
"inputs": {
"model": ["1", 0],
"lora_name": "model_only.safetensors",
"strength_model": 0.35,
},
},
"9": {"class_type": "KSampler", "inputs": {"model": ["2", 0]}},
}
graph, sampler = graph_and_sampler(prompt)
[lora] = extract_loras(graph, sampler)
assert lora.node_id == "2"
assert lora.class_type == "LoraLoaderModelOnly"
assert lora.lora_name == "model_only.safetensors"
assert lora.strength_model == 0.35
assert lora.strength_clip is None
def test_sampler_clip_chain_lora_is_discovered():
from smart_metadata_reader.lora_extractor import extract_loras
prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "base_model.safetensors"},
},
"2": {
"class_type": "LoraLoader",
"inputs": {
"model": ["1", 0],
"clip": ["1", 1],
"lora_name": "clip_chain_lora.safetensors",
"strength_model": 0.1,
"strength_clip": 0.9,
},
},
"9": {"class_type": "KSampler", "inputs": {"clip": ["2", 1]}},
}
graph, sampler = graph_and_sampler(prompt)
assert [lora.lora_name for lora in extract_loras(graph, sampler)] == [
"clip_chain_lora.safetensors"
]
def test_extract_vae_name_from_vae_decode_chain_and_vae_loader_fallback():
from smart_metadata_reader.lora_extractor import extract_vae_name
prompt_with_decode_chain = {
"1": {
"class_type": "VAELoader",
"inputs": {"vae_name": "main_vae.safetensors"},
},
"2": {
"class_type": "VAEDecode",
"inputs": {"samples": ["9", 0], "vae": ["1", 0]},
},
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"9": {"class_type": "KSampler", "inputs": {}},
}
assert extract_vae_name(GraphIndex(prompt_with_decode_chain, None)) == "main_vae.safetensors"
fallback_prompt = {
"4": {"class_type": "VAELoader", "inputs": {"name": "fallback_vae.safetensors"}},
}
assert extract_vae_name(GraphIndex(fallback_prompt, None)) == "fallback_vae.safetensors"
def test_extract_vae_name_prefers_final_save_image_output_chain_with_multiple_vae_branches():
from smart_metadata_reader.lora_extractor import extract_vae_name
prompt = {
"1": {
"class_type": "VAELoader",
"inputs": {"vae_name": "unused_branch_vae.safetensors"},
},
"2": {
"class_type": "VAEDecode",
"inputs": {"samples": ["8", 0], "vae": ["1", 0]},
},
"3": {
"class_type": "VAELoader",
"inputs": {"vae_name": "final_output_vae.safetensors"},
},
"4": {
"class_type": "VAEDecode",
"inputs": {"samples": ["9", 0], "vae": ["3", 0]},
},
"5": {"class_type": "SaveImage", "inputs": {"images": ["4", 0]}},
"8": {"class_type": "KSampler", "inputs": {}},
"9": {"class_type": "KSampler", "inputs": {}},
}
assert extract_vae_name(GraphIndex(prompt, None)) == "final_output_vae.safetensors"
def test_lora_trigger_words_remain_prompt_text_and_are_not_filtered_by_extractor():
from smart_metadata_reader.lora_extractor import extract_loras
prompt = {
"1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "base_model.safetensors"},
},
"2": {
"class_type": "LoraLoader",
"inputs": {
"model": ["1", 0],
"lora_name": "role_lora.safetensors",
"strength_model": 0.8,
},
},
"9": {"class_type": "KSampler", "inputs": {"model": ["2", 0]}},
}
graph, sampler = graph_and_sampler(prompt)
prompt_text = merge_prompt_segments(
[
PromptSegment(
text="role_lora_trigger, character_name, style tag",
node_id="22",
class_type="PrimitiveString",
field="text",
path=["PrimitiveString 22.text"],
)
]
)
loras_json = json.dumps([lora.__dict__ for lora in extract_loras(graph, sampler)])
assert "role_lora.safetensors" in loras_json
assert prompt_text == "role_lora_trigger, character_name, style tag"
+173
View File
@@ -0,0 +1,173 @@
import json
from PIL import Image
from PIL.PngImagePlugin import PngInfo
def test_read_metadata_extracts_png_text_chunks_and_dimensions(tmp_path):
from smart_metadata_reader.metadata_reader import read_metadata
image_path = tmp_path / "sample.png"
prompt_graph = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "positive trigger"},
}
}
workflow_graph = {
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"inputs": {"text": "positive trigger"},
}
]
}
pnginfo = PngInfo()
pnginfo.add_text("prompt", json.dumps(prompt_graph))
pnginfo.add_text("workflow", json.dumps(workflow_graph))
pnginfo.add_text(
"parameters",
(
"positive\n"
"Negative prompt: negative\n"
"Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 123, Size: 512x768"
),
)
Image.new("RGBA", (64, 32), (255, 0, 0, 128)).save(
image_path, pnginfo=pnginfo
)
bundle = read_metadata(image_path)
assert bundle.filename == "sample.png"
assert bundle.width == 64
assert bundle.height == 32
assert json.loads(bundle.prompt_raw) == prompt_graph
assert json.loads(bundle.workflow_raw) == workflow_graph
assert "Negative prompt: negative" in bundle.parameters_raw
assert bundle.source_format == "ComfyUI prompt/workflow"
def test_load_image_and_mask_returns_batched_image_and_mask_shapes(tmp_path):
from smart_metadata_reader.image_io import load_image_and_mask
image_path = tmp_path / "transparent.png"
Image.new("RGBA", (10, 12), (10, 20, 30, 128)).save(image_path)
image, mask, width, height = load_image_and_mask(image_path)
assert width == 10
assert height == 12
assert tuple(image.shape) == (1, 12, 10, 3)
assert tuple(mask.shape) == (1, 12, 10)
def test_parse_standard_a1111_parameters():
from smart_metadata_reader.a1111_parser import parse_a1111_parameters
parameters = (
"masterpiece, character trigger\n"
"Negative prompt: blurry, low quality\n"
"Steps: 25, Sampler: Euler a, CFG scale: 7.5, Seed: 12345, "
"Size: 768x1024, Model: demo_model"
)
result = parse_a1111_parameters(parameters)
assert result.positive == "masterpiece, character trigger"
assert result.negative == "blurry, low quality"
assert result.steps == 25
assert result.sampler_name == "Euler a"
assert result.cfg == 7.5
assert result.seed == 12345
assert result.width == 768
assert result.height == 1024
assert result.model_name == "demo_model"
assert result.status_message == "A1111 parameters fallback"
assert result.partial_result["source_format"] == "A1111 parameters"
def test_parse_a1111_preserves_multiline_positive_and_negative_prompts():
from smart_metadata_reader.a1111_parser import parse_a1111_parameters
parameters = (
"masterpiece, best quality\n"
"character trigger, detailed eyes\n"
"Negative prompt: low quality\n"
"bad hands, extra fingers\n"
"Steps: 30, Sampler: DPM++ 2M, CFG scale: 6, Seed: 999, Size: 512x768"
)
result = parse_a1111_parameters(parameters)
assert result.positive == "masterpiece, best quality\ncharacter trigger, detailed eyes"
assert result.negative == "low quality\nbad hands, extra fingers"
assert result.steps == 30
assert result.seed == 999
def test_parse_a1111_preserves_lora_triggers_nsfw_and_user_tags():
from smart_metadata_reader.a1111_parser import parse_a1111_parameters
parameters = (
"<lora:role_lora:0.8>, role_lora_trigger, nsfw, nude, user handwritten tag\n"
"Negative prompt: low quality\n"
"Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 101, Size: 640x960"
)
result = parse_a1111_parameters(parameters)
assert result.positive == (
"<lora:role_lora:0.8>, role_lora_trigger, nsfw, nude, user handwritten tag"
)
assert result.negative == "low quality"
def test_parse_a1111_without_negative_prompt_keeps_positive_and_empty_negative():
from smart_metadata_reader.a1111_parser import parse_a1111_parameters
parameters = (
"solo, dramatic lighting\n"
"Steps: 12, Sampler: Euler, CFG scale: 4.5, Seed: 42, Size: 320x448"
)
result = parse_a1111_parameters(parameters)
assert result.positive == "solo, dramatic lighting"
assert result.negative == ""
assert result.steps == 12
assert result.seed == 42
def test_parse_a1111_missing_partial_parameters_uses_defaults():
from smart_metadata_reader.a1111_parser import parse_a1111_parameters
result = parse_a1111_parameters("simple prompt only")
assert result.positive == "simple prompt only"
assert result.negative == ""
assert result.seed == -1
assert result.steps == 0
assert result.cfg == 0.0
assert result.width == 0
assert result.height == 0
assert result.model_name == ""
def test_parse_a1111_scheduler_and_model_hash_metadata():
from smart_metadata_reader.a1111_parser import parse_a1111_parameters
parameters = (
"positive text\n"
"Negative prompt: negative text\n"
"Steps: 25, Sampler: DPM++ 2M, Scheduler: Karras, CFG scale: 7.25, "
"Seed: 123, Size: 768x1024, Model hash: abc123, Model: forge_model"
)
result = parse_a1111_parameters(parameters)
assert result.scheduler == "Karras"
assert result.model_name == "forge_model"
assert result.partial_result["model_hash"] == "abc123"
+464
View File
@@ -0,0 +1,464 @@
import json
import importlib.util
import sys
from types import SimpleNamespace
import pytest
from smart_metadata_reader.models import LoraInfo, MetadataBundle, ParseResult
def test_format_setting_outputs_complete_human_readable_text():
from smart_metadata_reader.settings_formatter import format_setting
result = ParseResult(
filename="001.png",
model_name="model.safetensors",
vae_name="vae.vae.safetensors",
loras=[
LoraInfo(
node_id="10",
class_type="LoraLoader",
lora_name="lora_a.safetensors",
strength_model=0.8,
strength_clip=1.0,
path=["KSampler 9.model", "LoraLoader 10"],
),
LoraInfo(
node_id="11",
class_type="LoraLoader",
lora_name="lora_b.safetensors",
strength_model=0.6,
strength_clip=0.75,
path=["KSampler 9.model", "LoraLoader 11"],
),
],
seed=123456789,
steps=30,
cfg=7.0,
sampler_name="euler",
scheduler="normal",
width=1200,
height=1600,
status_message="OK",
confidence=0.94,
partial_result={"source_format": "ComfyUI prompt/workflow"},
)
setting = format_setting(result)
assert "Filename: 001.png" in setting
assert "Source: ComfyUI prompt/workflow" in setting
assert "Model: model.safetensors" in setting
assert "VAE: vae.vae.safetensors" in setting
assert "LoRA:" in setting
assert "* lora_a.safetensors (model 0.80, clip 1.00)" in setting
assert "* lora_b.safetensors (model 0.60, clip 0.75)" in setting
assert "Seed: 123456789" in setting
assert "Steps: 30" in setting
assert "CFG: 7.0" in setting
assert "Sampler: euler" in setting
assert "Scheduler: normal" in setting
assert "Size: 1200 x 1600" in setting
assert "Status: OK" in setting
assert "Confidence: 0.94" in setting
def test_format_setting_handles_missing_fields_without_error():
from smart_metadata_reader.settings_formatter import format_setting
setting = format_setting(ParseResult())
assert "Filename: unknown" in setting
assert "Source: unknown" in setting
assert "Model: unknown" in setting
assert "VAE: unknown" in setting
assert "LoRA: none" in setting
assert "Seed: -1" in setting
assert "Steps: 0" in setting
assert "CFG: 0.0" in setting
assert "Sampler: unknown" in setting
assert "Scheduler: unknown" in setting
assert "Size: unknown" in setting
def test_format_setting_displays_unresolved_partial_result_entries():
from smart_metadata_reader.settings_formatter import format_setting
result = ParseResult(
filename="partial.png",
status_message="PARTIAL",
confidence=0.52,
partial_result={
"source_format": "ComfyUI prompt/workflow",
"unresolved": [
{
"node_id": "88",
"class_type": "UnknownConditioningRouter",
"field": "positive_conditioning",
"role": "positive",
"reason": "no conditioning adapter matched",
}
],
},
)
setting = format_setting(result)
assert "Status: PARTIAL" in setting
assert "Confidence: 0.52" in setting
assert "Unresolved:" in setting
assert (
"* Node 88 UnknownConditioningRouter input positive_conditioning "
"role=positive reason=no conditioning adapter matched"
) in setting
def test_format_setting_displays_a1111_source_and_status():
from smart_metadata_reader.settings_formatter import format_setting
result = ParseResult(
filename="",
positive="<lora:role:0.8>, nsfw",
negative="low quality",
seed=123,
steps=25,
cfg=7.5,
sampler_name="Euler a",
width=768,
height=1024,
model_name="demo_model",
status_message="A1111 parameters fallback",
confidence=0.75,
partial_result={"source_format": "A1111 parameters"},
)
setting = format_setting(result)
assert "Source: A1111 parameters" in setting
assert "Status: A1111 parameters fallback" in setting
assert "Model: demo_model" in setting
def test_format_setting_is_plain_text_not_json():
from smart_metadata_reader.settings_formatter import format_setting
setting = format_setting(
ParseResult(
filename="plain.png",
partial_result={"source_format": "ComfyUI prompt/workflow"},
)
)
assert not setting.strip().startswith("{")
with pytest.raises(json.JSONDecodeError):
json.loads(setting)
def test_node_mappings_expose_smart_metadata_reader():
import nodes
assert nodes.NODE_CLASS_MAPPINGS["SmartMetadataReader"] is nodes.SmartMetadataReader
assert nodes.NODE_DISPLAY_NAME_MAPPINGS["SmartMetadataReader"] == "Smart Metadata Reader"
def test_plugin_root_init_exports_comfyui_mappings():
root_dir = pytest.importorskip("pathlib").Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location(
"comfyui_smart_metadata_reader_plugin",
root_dir / "__init__.py",
submodule_search_locations=[str(root_dir)],
)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
try:
spec.loader.exec_module(module)
finally:
sys.modules.pop(spec.name, None)
sys.modules.pop(f"{spec.name}.nodes", None)
assert "SmartMetadataReader" in module.NODE_CLASS_MAPPINGS
assert module.NODE_DISPLAY_NAME_MAPPINGS["SmartMetadataReader"] == "Smart Metadata Reader"
def test_input_types_prefers_comfyui_filename_list(monkeypatch):
import nodes
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_filename_list=lambda category: ["z.png", "nested/a.webp"]),
)
input_types = nodes.SmartMetadataReader.INPUT_TYPES()
assert input_types["required"]["image"][0] == ["z.png", "nested/a.webp"]
def test_smart_metadata_reader_input_types_include_uploadable_image(tmp_path, monkeypatch):
import nodes
(tmp_path / "a.png").write_bytes(b"fake")
(tmp_path / "b.webp").write_bytes(b"fake")
(tmp_path / "note.txt").write_text("ignore me")
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_input_directory=lambda: str(tmp_path)),
)
input_types = nodes.SmartMetadataReader.INPUT_TYPES()
required = input_types["required"]
assert required["image"][0] == ["a.png", "b.webp"]
assert required["image"][1]["image_upload"] is True
assert required["parameter_index"][0] == "INT"
assert required["parameter_index"][1]["default"] == 0
assert required["parameter_index"][1]["min"] == 0
assert required["prefer_cached_text"] == ("BOOLEAN", {"default": True})
assert required["include_raw_json"] == ("BOOLEAN", {"default": True})
assert required["max_depth"][0] == "INT"
assert required["max_depth"][1]["default"] == 40
assert required["max_depth"][1]["min"] == 1
assert required["max_depth"][1]["max"] == 200
def test_smart_metadata_reader_return_contract():
import nodes
node_cls = nodes.SmartMetadataReader
assert node_cls.RETURN_TYPES == (
"IMAGE",
"MASK",
"STRING",
"STRING",
"INT",
"INT",
"FLOAT",
"INT",
"INT",
"STRING",
"STRING",
"STRING",
)
assert node_cls.RETURN_NAMES == (
"image",
"mask",
"positive",
"negative",
"seed",
"steps",
"cfg",
"width",
"height",
"model_name",
"filename",
"setting",
)
assert len(node_cls.RETURN_TYPES) == len(node_cls.RETURN_NAMES)
assert hasattr(node_cls, node_cls.FUNCTION)
assert node_cls.CATEGORY
def test_smart_metadata_reader_method_returns_expected_tuple_order(tmp_path, monkeypatch):
import nodes
image_path = tmp_path / "sample.png"
image_path.write_bytes(b"fake image bytes")
calls = {}
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_annotated_filepath=lambda image: str(image_path)),
)
def fake_read_metadata(path):
calls["read_metadata"] = path
return MetadataBundle(
filename="sample.png",
width=1200,
height=1600,
prompt_raw="{}",
workflow_raw=None,
parameters_raw=None,
source_format="ComfyUI prompt/workflow",
)
def fake_parse_metadata_bundle(
bundle,
parameter_index,
prefer_cached_text,
include_raw_json,
max_depth,
):
calls["parse"] = {
"bundle": bundle,
"parameter_index": parameter_index,
"prefer_cached_text": prefer_cached_text,
"include_raw_json": include_raw_json,
"max_depth": max_depth,
}
return ParseResult(
positive="real positive",
negative="real negative",
seed=123,
steps=30,
cfg=7.5,
width=1200,
height=1600,
model_name="model.safetensors",
filename="sample.png",
setting="Filename: sample.png\nStatus: OK",
)
monkeypatch.setattr(nodes, "read_metadata", fake_read_metadata)
monkeypatch.setattr(nodes, "parse_metadata_bundle", fake_parse_metadata_bundle)
monkeypatch.setattr(nodes, "load_image_and_mask", lambda path: ("IMAGE_TENSOR", "MASK_TENSOR", 1200, 1600))
output = nodes.SmartMetadataReader().read_metadata(
"sample.png",
parameter_index=2,
prefer_cached_text=False,
include_raw_json=False,
max_depth=12,
)
assert output == (
"IMAGE_TENSOR",
"MASK_TENSOR",
"real positive",
"real negative",
123,
30,
7.5,
1200,
1600,
"model.safetensors",
"sample.png",
"Filename: sample.png\nStatus: OK",
)
assert calls["read_metadata"] == str(image_path)
assert calls["parse"]["parameter_index"] == 2
assert calls["parse"]["prefer_cached_text"] is False
assert calls["parse"]["include_raw_json"] is False
assert calls["parse"]["max_depth"] == 12
def test_smart_metadata_reader_parse_failure_returns_failed_setting(tmp_path, monkeypatch):
import nodes
image_path = tmp_path / "broken-metadata.png"
image_path.write_bytes(b"fake image bytes")
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_annotated_filepath=lambda image: str(image_path)),
)
monkeypatch.setattr(
nodes,
"read_metadata",
lambda path: MetadataBundle(
filename="broken-metadata.png",
width=640,
height=480,
prompt_raw=None,
workflow_raw=None,
parameters_raw=None,
source_format="none",
),
)
monkeypatch.setattr(nodes, "parse_metadata_bundle", lambda *args, **kwargs: (_ for _ in ()).throw(ValueError("boom")))
monkeypatch.setattr(nodes, "load_image_and_mask", lambda path: ("IMAGE_TENSOR", "MASK_TENSOR", 640, 480))
output = nodes.SmartMetadataReader().read_metadata("broken-metadata.png", 0, True, True, 40)
assert output[:2] == ("IMAGE_TENSOR", "MASK_TENSOR")
assert output[2] == ""
assert output[3] == ""
assert output[4] == -1
assert output[5] == 0
assert output[6] == 0.0
assert output[7] == 640
assert output[8] == 480
assert output[9] == ""
assert output[10] == "broken-metadata.png"
assert "Status: FAILED" in output[11]
assert "boom" in output[11]
def test_smart_metadata_reader_image_load_failure_raises_clear_error(monkeypatch):
import nodes
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_annotated_filepath=lambda image: "missing.png"),
)
monkeypatch.setattr(
nodes,
"load_image_and_mask",
lambda path: (_ for _ in ()).throw(OSError("cannot identify image file")),
)
with pytest.raises(RuntimeError, match="Failed to load image 'missing.png'"):
nodes.SmartMetadataReader().read_metadata("missing.png", 0, True, True, 40)
def test_smart_metadata_reader_is_changed_tracks_file_content(tmp_path, monkeypatch):
import nodes
image_path = tmp_path / "changed.png"
image_path.write_bytes(b"first")
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_annotated_filepath=lambda image: str(image_path)),
)
first = nodes.SmartMetadataReader.IS_CHANGED("changed.png")
image_path.write_bytes(b"second")
second = nodes.SmartMetadataReader.IS_CHANGED("changed.png")
assert first
assert second
assert first != second
def test_smart_metadata_reader_validate_inputs_checks_image_exists(tmp_path, monkeypatch):
import nodes
existing = tmp_path / "exists.png"
existing.write_bytes(b"fake")
missing = tmp_path / "missing.png"
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(get_annotated_filepath=lambda image: str(tmp_path / image)),
)
assert nodes.SmartMetadataReader.VALIDATE_INPUTS("exists.png") is True
invalid = nodes.SmartMetadataReader.VALIDATE_INPUTS("missing.png")
assert isinstance(invalid, str)
assert "Invalid image file" in invalid
assert str(missing) in invalid
def test_smart_metadata_reader_validate_inputs_uses_comfyui_exists_helper(monkeypatch):
import nodes
monkeypatch.setattr(
nodes,
"folder_paths",
SimpleNamespace(exists_annotated_filepath=lambda image: image == "ok.png"),
)
assert nodes.SmartMetadataReader.VALIDATE_INPUTS("ok.png") is True
assert "Invalid image file" in nodes.SmartMetadataReader.VALIDATE_INPUTS("bad.png")
+110
View File
@@ -0,0 +1,110 @@
import json
import random
from copy import deepcopy
from smart_metadata_reader.metadata_reader import parse_metadata_bundle
from smart_metadata_reader.models import MetadataBundle
def bundle_for_prompt(prompt):
return MetadataBundle(
filename="randomized.png",
width=768,
height=1024,
prompt_raw=json.dumps(prompt),
workflow_raw=None,
parameters_raw=None,
source_format="ComfyUI prompt/workflow",
)
def base_prompt():
return {
"1": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"2": {
"class_type": "VAEDecode",
"inputs": {"samples": ["3", 0], "vae": ["6", 0]},
},
"3": {
"class_type": "KSampler",
"inputs": {
"model": ["5", 0],
"clip": ["5", 1],
"positive": ["7", 0],
"negative": ["8", 0],
"seed": 98765,
"steps": 18,
"cfg": 6.25,
"sampler_name": "dpmpp_2m",
"scheduler": "karras",
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "stable_model.safetensors"},
},
"5": {
"class_type": "LoraLoader",
"inputs": {
"model": ["4", 0],
"clip": ["4", 1],
"lora_name": "style_lora.safetensors",
"strength_model": 0.6,
"strength_clip": 0.75,
},
},
"6": {"class_type": "VAELoader", "inputs": {"vae_name": "main.vae.safetensors"}},
"7": {"class_type": "CLIPTextEncode", "inputs": {"text": "subject trigger, style token"}},
"8": {"class_type": "CLIPTextEncode", "inputs": {"text": "low quality"}},
"9": {"class_type": "PrimitiveString", "inputs": {"value": "unconnected text"}},
}
def test_parser_results_do_not_depend_on_specific_node_ids():
prompt = base_prompt()
remapped_prompt = remap_node_ids(prompt)
original = parse_metadata_bundle(bundle_for_prompt(prompt))
remapped = parse_metadata_bundle(bundle_for_prompt(remapped_prompt))
assert remapped.positive == original.positive
assert remapped.negative == original.negative
assert remapped.seed == original.seed
assert remapped.steps == original.steps
assert remapped.cfg == original.cfg
assert remapped.sampler_name == original.sampler_name
assert remapped.scheduler == original.scheduler
assert remapped.model_name == original.model_name
assert remapped.vae_name == original.vae_name
assert lora_values(remapped) == lora_values(original)
def remap_node_ids(prompt):
ids = list(prompt.keys())
rng = random.Random(1234)
new_ids = [str(value) for value in rng.sample(range(100, 999), len(ids))]
mapping = dict(zip(ids, new_ids))
remapped = {}
for old_id, node in prompt.items():
new_node = deepcopy(node)
new_node["inputs"] = remap_value(new_node.get("inputs", {}), mapping)
remapped[mapping[old_id]] = new_node
return remapped
def remap_value(value, mapping):
if isinstance(value, list):
if len(value) == 2 and str(value[0]) in mapping and isinstance(value[1], int):
return [mapping[str(value[0])], value[1]]
return [remap_value(item, mapping) for item in value]
if isinstance(value, dict):
return {key: remap_value(item, mapping) for key, item in value.items()}
return value
def lora_values(result):
return [
(lora.lora_name, lora.strength_model, lora.strength_clip)
for lora in result.loras
]
+154
View File
@@ -0,0 +1,154 @@
from smart_metadata_reader.graph import GraphIndex
def select_sampler(prompt):
from smart_metadata_reader.sampler_selector import select_final_sampler
return select_final_sampler(GraphIndex(prompt=prompt, workflow=None))
def test_selects_ksampler_upstream_of_save_image_via_vae_decode():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"9": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive"}},
}
selection = select_sampler(prompt)
assert selection.sampler.node_id == "1"
assert selection.confidence == 1.0
assert "SaveImage 3.images <- VAEDecode 2.samples <- KSampler 1" in selection.debug_trace
def test_selects_ksampler_upstream_of_preview_image_via_vae_decode():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
"3": {"class_type": "PreviewImage", "inputs": {"images": ["2", 0]}},
"9": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive"}},
}
selection = select_sampler(prompt)
assert selection.sampler.node_id == "1"
assert "PreviewImage 3.images" in selection.debug_trace
def test_selects_sampler_through_image_passthrough_before_save_image():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
"3": {"class_type": "ImagePassThrough", "inputs": {"image": ["2", 0]}},
"4": {"class_type": "SaveImage", "inputs": {"images": ["3", 0]}},
"9": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive"}},
}
selection = select_sampler(prompt)
assert selection.sampler.node_id == "1"
assert "ImagePassThrough 3.image" in selection.debug_trace
def test_multiple_samplers_selects_one_connected_to_final_save_image():
prompt = {
"1": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"2": {"class_type": "VAEDecode", "inputs": {"samples": ["1", 0]}},
"3": {"class_type": "SaveImage", "inputs": {"images": ["2", 0]}},
"8": {"class_type": "KSampler", "inputs": {"positive": ["9", 0]}},
"9": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive"}},
}
selection = select_sampler(prompt)
assert selection.sampler.node_id == "1"
assert "Selected KSampler 1 because it is upstream of final SaveImage" in selection.debug_trace
def test_fallback_selects_sampler_with_conditioning_inputs_when_no_output_node_exists():
prompt = {
"5": {"class_type": "PrimitiveString", "inputs": {"text": "not a sampler"}},
"6": {
"class_type": "KSampler",
"inputs": {"positive": ["9", 0], "negative": ["10", 0]},
},
"9": {"class_type": "CLIPTextEncode", "inputs": {"text": "positive"}},
"10": {"class_type": "CLIPTextEncode", "inputs": {"text": "negative"}},
}
selection = select_sampler(prompt)
assert selection.sampler.node_id == "6"
assert selection.confidence < 1.0
assert "sampler selected by fallback heuristic" in selection.debug_trace
def test_extracts_ksampler_settings():
from smart_metadata_reader.adapters.samplers import extract_sampler_settings
from smart_metadata_reader.models import NodeRecord
sampler = NodeRecord(
node_id="1",
class_type="KSampler",
inputs={
"seed": 123,
"steps": 30,
"cfg": 7.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.85,
},
)
assert extract_sampler_settings(sampler) == {
"seed": 123,
"steps": 30,
"cfg": 7.5,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.85,
}
def test_extracts_ksampler_advanced_and_custom_field_variants():
from smart_metadata_reader.adapters.samplers import extract_sampler_settings
from smart_metadata_reader.models import NodeRecord
advanced = NodeRecord(
node_id="2",
class_type="KSamplerAdvanced",
inputs={
"noise_seed": 456,
"steps": 22,
"cfg_scale": 6.0,
"sampler": "dpmpp_2m",
"schedule": "karras",
},
)
custom = NodeRecord(
node_id="3",
class_type="SamplerCustom",
inputs={
"noise_seed": 789,
"steps": 18,
"cfg": 5.5,
"sampler_name": "custom_sampler",
"scheduler": "sgm_uniform",
},
)
assert extract_sampler_settings(advanced) == {
"seed": 456,
"steps": 22,
"cfg": 6.0,
"sampler_name": "dpmpp_2m",
"scheduler": "karras",
}
assert extract_sampler_settings(custom) == {
"seed": 789,
"steps": 18,
"cfg": 5.5,
"sampler_name": "custom_sampler",
"scheduler": "sgm_uniform",
}
+325
View File
@@ -0,0 +1,325 @@
from smart_metadata_reader.models import PromptSegment
def segment(text: str) -> PromptSegment:
return PromptSegment(
text=text,
node_id="1",
class_type="TestTextNode",
field="text",
path=["TestTextNode 1.text"],
)
def test_prompt_merge_removes_empty_segments_and_extra_commas_only():
from smart_metadata_reader.prompt_merge import merge_prompt_segments
merged = merge_prompt_segments(
[
segment(" masterpiece,, "),
segment(""),
segment(" , , best quality "),
segment("\n\ncinematic lighting\n\n"),
]
)
assert merged == "masterpiece, best quality, cinematic lighting"
def test_prompt_merge_preserves_lora_trigger_words_and_tags():
from smart_metadata_reader.prompt_merge import merge_prompt_segments
merged = merge_prompt_segments(
[
segment("<lora:character_lora:0.8>"),
segment("raiden_shogun, purple hair"),
segment("style trigger phrase"),
]
)
assert merged == (
"<lora:character_lora:0.8>, "
"raiden_shogun, purple hair, "
"style trigger phrase"
)
def test_prompt_merge_preserves_nsfw_terms():
from smart_metadata_reader.prompt_merge import merge_prompt_segments
merged = merge_prompt_segments(
[
segment("nsfw"),
segment("nude, explicit"),
segment("user handwritten tag"),
]
)
assert merged == "nsfw, nude, explicit, user handwritten tag"
def test_prompt_merge_preserves_repeated_meaningful_tokens():
from smart_metadata_reader.prompt_merge import merge_prompt_segments
merged = merge_prompt_segments(
[
segment("masterpiece"),
segment("masterpiece"),
segment("score_9, score_9"),
]
)
assert merged == "masterpiece, masterpiece, score_9, score_9"
def test_trace_collector_groups_lines_by_section():
from smart_metadata_reader.trace import TraceCollector
trace = TraceCollector()
trace.add("POSITIVE_TRACE", "KSampler 3.positive")
trace.add("POSITIVE_TRACE", " -> CLIPTextEncode 6.text")
trace.add("NEGATIVE_TRACE", "KSampler 3.negative")
assert trace.render() == (
"POSITIVE_TRACE:\n"
"KSampler 3.positive\n"
" -> CLIPTextEncode 6.text\n\n"
"NEGATIVE_TRACE:\n"
"KSampler 3.negative"
)
def resolve_text(prompt, workflow=None, node_id="1", prefer_cached_text=True):
from smart_metadata_reader.adapters.base import TextResolutionContext
from smart_metadata_reader.adapters.clip_text import (
CLIPTextEncodeAdapter,
CLIPTextEncodeSDXLAdapter,
)
from smart_metadata_reader.adapters.pysssss import (
ShowTextAdapter,
StringFunctionAdapter,
)
from smart_metadata_reader.adapters.text_sources import (
DeepTranslatorTextAdapter,
GenericTextAdapter,
)
from smart_metadata_reader.graph import GraphIndex
from smart_metadata_reader.trace import TraceCollector
trace = TraceCollector()
context = TextResolutionContext(
graph=GraphIndex(prompt=prompt, workflow=workflow),
prefer_cached_text=prefer_cached_text,
trace=trace,
)
context.registry.register(CLIPTextEncodeAdapter())
context.registry.register(CLIPTextEncodeSDXLAdapter())
context.registry.register(StringFunctionAdapter())
context.registry.register(ShowTextAdapter())
context.registry.register(DeepTranslatorTextAdapter())
context.registry.register(GenericTextAdapter())
segments = context.resolve_node(node_id=node_id, role="positive", path=[])
return segments, trace.render()
def test_clip_text_encode_direct_string_returns_prompt_segment():
prompt = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "masterpiece, character trigger"},
}
}
segments, _trace = resolve_text(prompt)
assert [segment.text for segment in segments] == ["masterpiece, character trigger"]
assert segments[0].node_id == "1"
assert segments[0].class_type == "CLIPTextEncode"
assert segments[0].field == "text"
def test_clip_text_encode_resolves_upstream_text_node():
prompt = {
"1": {
"class_type": "CLIPTextEncode",
"inputs": {"text": ["2", 0]},
},
"2": {
"class_type": "PrimitiveString",
"inputs": {"text": "upstream user text"},
},
}
segments, _trace = resolve_text(prompt)
assert [segment.text for segment in segments] == ["upstream user text"]
assert segments[0].node_id == "2"
assert segments[0].class_type == "PrimitiveString"
def test_clip_text_encode_sdxl_resolves_multiple_prompt_fields():
prompt = {
"1": {
"class_type": "CLIPTextEncodeSDXL",
"inputs": {
"text_g": "global style text",
"text_l": "local subject text",
},
}
}
segments, _trace = resolve_text(prompt)
assert [segment.text for segment in segments] == [
"global style text",
"local subject text",
]
def test_string_function_append_resolves_three_parts_in_order():
prompt = {
"1": {
"class_type": "StringFunction|pysssss",
"inputs": {
"action": "append",
"text_a": "role trigger",
"text_b": ["2", 0],
"text_c": ["3", 0],
},
},
"2": {
"class_type": "PrimitiveString",
"inputs": {"text": "cached model text"},
},
"3": {
"class_type": "DeepTranslatorTextNode",
"inputs": {"text": "translated user supplement"},
},
}
segments, _trace = resolve_text(prompt)
assert [segment.text for segment in segments] == [
"role trigger",
"cached model text",
"translated user supplement",
]
def test_show_text_prefers_text_0_cache_over_upstream_llm_template():
prompt = {
"1": {
"class_type": "ShowText|pysssss",
"inputs": {
"text": ["2", 0],
"text_0": "final shown prompt",
},
},
"2": {
"class_type": "GeminiChatNode",
"inputs": {
"prompt": "describe this image as danbooru tags",
"system_instruction": "you are a prompt generator",
},
},
}
segments, trace = resolve_text(prompt)
assert [segment.text for segment in segments] == ["final shown prompt"]
assert "GeminiChatNode" not in trace
def test_show_text_without_cache_does_not_treat_gemini_template_as_prompt():
prompt = {
"1": {
"class_type": "ShowText|pysssss",
"inputs": {"text": ["2", 0]},
},
"2": {
"class_type": "GeminiChatNode",
"inputs": {
"prompt": "describe this image as danbooru tags",
"system_instruction": "you are a prompt generator",
},
},
}
segments, trace = resolve_text(prompt)
assert segments == []
assert "GeminiChatNode" in trace
assert "low confidence" in trace
def test_show_text_uses_workflow_cache_when_prompt_lacks_text_0():
prompt = {
"1": {
"class_type": "ShowText|pysssss",
"inputs": {"text": ["2", 0]},
},
"2": {
"class_type": "GeminiChatNode",
"inputs": {"prompt": "template, not final prompt"},
},
}
workflow = {
"nodes": [
{
"id": 1,
"type": "ShowText|pysssss",
"inputs": {"text_0": "workflow cached shown result"},
}
]
}
segments, _trace = resolve_text(prompt, workflow=workflow)
assert [segment.text for segment in segments] == ["workflow cached shown result"]
def test_show_text_widget_cache_fallback_lowers_confidence_and_traces_source():
prompt = {
"1": {
"class_type": "ShowText|pysssss",
"inputs": {"text": ["2", 0]},
},
"2": {
"class_type": "GeminiChatNode",
"inputs": {"prompt": "template, not final prompt"},
},
}
workflow = {
"nodes": [
{
"id": 1,
"type": "ShowText|pysssss",
"widgets_values": ["widget cached shown result"],
},
{
"id": 2,
"type": "GeminiChatNode",
"widgets_values": ["do not use this llm template"],
},
]
}
segments, trace = resolve_text(prompt, workflow=workflow)
assert [segment.text for segment in segments] == ["widget cached shown result"]
assert segments[0].confidence < 1.0
assert "workflow widget cache fallback" in trace
assert "do not use this llm template" not in [segment.text for segment in segments]
def test_deep_translator_text_node_uses_text_input():
prompt = {
"1": {
"class_type": "DeepTranslatorTextNode",
"inputs": {"text": "translated prompt text"},
}
}
segments, _trace = resolve_text(prompt)
assert [segment.text for segment in segments] == ["translated prompt text"]