Initial Smart Metadata Reader implementation
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
@@ -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
@@ -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.
|
||||
@@ -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"}
|
||||
@@ -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 = ["."]
|
||||
@@ -0,0 +1 @@
|
||||
Pillow>=10.0.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Core parser package for ComfyUI Smart Metadata Reader."""
|
||||
@@ -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."""
|
||||
@@ -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"}
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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 = ""
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
@@ -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
|
||||
]
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user