ComfyUI->Obsidian prompt note node: dual prompt source (string/conditioning), bilingual README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,232 @@
|
||||
# ComfyUI → Obsidian Prompt Note
|
||||
|
||||
Кастомная нода для ComfyUI, которая после генерации **автоматически сохраняет
|
||||
готовое изображение и позитивный промт в отдельную заметку Obsidian**.
|
||||
|
||||
A ComfyUI custom node that, after each generation, **automatically saves the
|
||||
finished image and the positive prompt into a dedicated Obsidian note**.
|
||||
|
||||
---
|
||||
|
||||
## 🇷🇺 Русский
|
||||
|
||||
### Что делает нода
|
||||
|
||||
Нода **`Save Image + Obsidian Note`** (категория **Obsidian**) для каждого
|
||||
изображения:
|
||||
|
||||
1. Сохраняет PNG в стандартную папку `output` ComfyUI (с обычной нумерацией и
|
||||
вшитыми метаданными workflow — как штатная нода `Save Image`).
|
||||
2. Копирует этот PNG в папку для заметок (по умолчанию `Comfy-Promt` внутри
|
||||
вашего хранилища Obsidian).
|
||||
3. Создаёт рядом заметку `.md` **с тем же именем, что у изображения, но без
|
||||
расширения** (например, `ComfyUI_00042_.md`). Внутри:
|
||||
- встроенное изображение `![[ComfyUI_00042_.png]]`;
|
||||
- позитивный промт в **блоке кода** — чтобы его можно было скопировать одним
|
||||
кликом.
|
||||
|
||||
Пример содержимого заметки:
|
||||
|
||||
````markdown
|
||||
![[ComfyUI_00042_.png]]
|
||||
|
||||
```
|
||||
a cinematic portrait of a fox, golden hour, 85mm, highly detailed
|
||||
```
|
||||
````
|
||||
|
||||
### Установка
|
||||
|
||||
1. Скопируйте папку `comfyui-obsidian-promt` в каталог `custom_nodes` вашего
|
||||
ComfyUI.
|
||||
- ComfyUI Desktop / portable: `...\ComfyUI\custom_nodes\`
|
||||
- В этой системе: `C:\Users\dimir\Documents\ComfyUI\custom_nodes\`
|
||||
|
||||
Либо клонируйте репозиторий прямо туда:
|
||||
```bash
|
||||
cd C:\Users\dimir\Documents\ComfyUI\custom_nodes
|
||||
git clone http://192.168.1.171:3000/dimon/comfyui-obsidian-promt.git
|
||||
```
|
||||
2. **Настройте путь сохранения** (см. раздел ниже) — иначе заметки будут падать
|
||||
в чужую папку.
|
||||
3. **Полностью перезапустите ComfyUI.** Нода появится в меню добавления нод в
|
||||
категории **Obsidian** под именем **Save Image + Obsidian Note**.
|
||||
|
||||
Дополнительные зависимости не нужны: `numpy`, `Pillow` и `folder_paths` уже
|
||||
входят в любую сборку ComfyUI.
|
||||
|
||||
### Как использовать
|
||||
|
||||
Добавьте ноду **Save Image + Obsidian Note** в конец workflow (вместо или рядом
|
||||
со штатной `Save Image`) и подключите `images` к выходу `VAE Decode`.
|
||||
|
||||
Текст промта нода может получать двумя способами — выбирается переключателем
|
||||
**`prompt_source`**:
|
||||
|
||||
| Режим | Что подключать | Откуда берётся текст |
|
||||
|-------|----------------|----------------------|
|
||||
| `string` (по умолчанию) | вход `positive_prompt` (тип **STRING**) | строка ровно как вы её ввели |
|
||||
| `conditioning` | вход `positive_conditioning` (тип **CONDITIONING**) | текст прослеживается по графу workflow до ноды `CLIP Text Encode` и читается её поле text |
|
||||
|
||||
**Режим `string`** — подключите строковую ноду с текстом (например `String`/`Text`
|
||||
из comfyui-custom-scripts или KJNodes). Удобно, если промт формируется
|
||||
динамически (wildcards / dynamic prompts): берите строку уже после раскрытия,
|
||||
тогда в заметку попадёт финальный текст.
|
||||
|
||||
**Режим `conditioning`** — подключите тот же выход `CLIP Text Encode`, что идёт в
|
||||
`KSampler`. Отдельная строковая нода не нужна.
|
||||
|
||||
> ⚠️ **Важно про режим `conditioning`.** Из CONDITIONING (это числовые
|
||||
> эмбеддинги) исходный текст восстановить невозможно — кодирование одностороннее.
|
||||
> Поэтому нода идёт по графу workflow назад от подключённого входа до ноды
|
||||
> `CLIP Text Encode` и читает её текстовое поле. Для обычных статических промтов
|
||||
> это точно. Промежуточные ноды (`Conditioning Combine/Concat` и т. п.)
|
||||
> проходятся насквозь, тексты склеиваются. Если же текст подставляется в
|
||||
> `CLIP Text Encode` нестандартным способом (некоторые wildcard-/BNK-ноды), из
|
||||
> графа может прочитаться шаблон или ничего — тогда в заметку попадёт пометка
|
||||
> `[промт не найден: ...]`. В таких случаях используйте режим `string`.
|
||||
|
||||
Опциональный вход `filename_prefix` (по умолчанию `ComfyUI`) задаёт префикс имени
|
||||
файла — как у штатной `Save Image`.
|
||||
|
||||
### Как изменить папку для сохранения
|
||||
|
||||
Откройте файл `__init__.py` и найдите вверху блок
|
||||
**`НАСТРОЙКА ПУТЕЙ / PATH CONFIGURATION`**. Там две строки:
|
||||
|
||||
```python
|
||||
VAULT_DIR = r"C:\Users\dimir\Documents\ObsidianTask"
|
||||
OBSIDIAN_SUBFOLDER = "Comfy-Promt"
|
||||
```
|
||||
|
||||
- **`VAULT_DIR`** — полный путь к хранилищу Obsidian (или к любой другой папке).
|
||||
На Windows используйте префикс `r"..."` и обратные слэши.
|
||||
- **`OBSIDIAN_SUBFOLDER`** — имя подпапки внутри `VAULT_DIR`, куда складываются
|
||||
`.md` и копии `.png`. Папка создаётся автоматически. Чтобы писать прямо в
|
||||
`VAULT_DIR`, поставьте пустую строку `""`.
|
||||
|
||||
Примеры:
|
||||
|
||||
```python
|
||||
VAULT_DIR = r"D:\MyVault" # другой диск
|
||||
VAULT_DIR = r"\\NAS\share\Obsidian" # сетевая папка
|
||||
VAULT_DIR = "/home/user/Obsidian" # Linux / macOS
|
||||
OBSIDIAN_SUBFOLDER = "" # писать прямо в корень vault
|
||||
```
|
||||
|
||||
После изменения **сохраните файл и перезапустите ComfyUI.**
|
||||
|
||||
---
|
||||
|
||||
## 🇬🇧 English
|
||||
|
||||
### What the node does
|
||||
|
||||
The **`Save Image + Obsidian Note`** node (category **Obsidian**) does the
|
||||
following for every image:
|
||||
|
||||
1. Saves the PNG to ComfyUI's standard `output` folder (normal numbering, with
|
||||
the workflow metadata embedded — just like the built-in `Save Image` node).
|
||||
2. Copies that PNG into the notes folder (by default `Comfy-Promt` inside your
|
||||
Obsidian vault).
|
||||
3. Creates a `.md` note next to it **with the same name as the image but without
|
||||
the extension** (e.g. `ComfyUI_00042_.md`). Inside:
|
||||
- the embedded image `![[ComfyUI_00042_.png]]`;
|
||||
- the positive prompt inside a **code block**, so you can copy it in one click.
|
||||
|
||||
Example note content:
|
||||
|
||||
````markdown
|
||||
![[ComfyUI_00042_.png]]
|
||||
|
||||
```
|
||||
a cinematic portrait of a fox, golden hour, 85mm, highly detailed
|
||||
```
|
||||
````
|
||||
|
||||
### Installation
|
||||
|
||||
1. Copy the `comfyui-obsidian-promt` folder into your ComfyUI `custom_nodes`
|
||||
directory.
|
||||
- ComfyUI Desktop / portable: `...\ComfyUI\custom_nodes\`
|
||||
|
||||
Or clone the repository directly there:
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone http://192.168.1.171:3000/dimon/comfyui-obsidian-promt.git
|
||||
```
|
||||
2. **Configure the save path** (see the section below) — otherwise notes will end
|
||||
up in the wrong folder.
|
||||
3. **Fully restart ComfyUI.** The node appears in the add-node menu under the
|
||||
**Obsidian** category as **Save Image + Obsidian Note**.
|
||||
|
||||
No extra dependencies are required: `numpy`, `Pillow` and `folder_paths` already
|
||||
ship with every ComfyUI build.
|
||||
|
||||
### How to use
|
||||
|
||||
Add the **Save Image + Obsidian Note** node at the end of your workflow (instead
|
||||
of or alongside the built-in `Save Image`) and connect `images` to the
|
||||
`VAE Decode` output.
|
||||
|
||||
The node can obtain the prompt text in two ways, selected with the
|
||||
**`prompt_source`** switch:
|
||||
|
||||
| Mode | What to connect | Where the text comes from |
|
||||
|------|-----------------|---------------------------|
|
||||
| `string` (default) | the `positive_prompt` input (**STRING**) | the string exactly as you typed it |
|
||||
| `conditioning` | the `positive_conditioning` input (**CONDITIONING**) | the text is traced through the workflow graph back to the `CLIP Text Encode` node and read from its text field |
|
||||
|
||||
**`string` mode** — connect a string/text node (e.g. `String`/`Text` from
|
||||
comfyui-custom-scripts or KJNodes). Best when the prompt is built dynamically
|
||||
(wildcards / dynamic prompts): tap the string *after* it has been expanded so the
|
||||
final text lands in the note.
|
||||
|
||||
**`conditioning` mode** — connect the same `CLIP Text Encode` output that feeds
|
||||
your `KSampler`. No separate string node needed.
|
||||
|
||||
> ⚠️ **Important about `conditioning` mode.** The original text cannot be
|
||||
> recovered from a CONDITIONING tensor (encoding is one-way). Instead the node
|
||||
> walks the workflow graph backwards from the connected input to the
|
||||
> `CLIP Text Encode` node and reads its text field. This is exact for ordinary
|
||||
> static prompts. Intermediate nodes (`Conditioning Combine/Concat`, etc.) are
|
||||
> traversed and their texts are concatenated. If the text is fed into
|
||||
> `CLIP Text Encode` in a non-standard way (some wildcard/BNK nodes), the graph
|
||||
> may only contain a template or nothing — the note will then contain a
|
||||
> `[prompt not found: ...]` marker. Use `string` mode in those cases.
|
||||
|
||||
The optional `filename_prefix` input (default `ComfyUI`) sets the filename prefix,
|
||||
just like the built-in `Save Image`.
|
||||
|
||||
### How to change the save folder
|
||||
|
||||
Open `__init__.py` and find the **`НАСТРОЙКА ПУТЕЙ / PATH CONFIGURATION`** block
|
||||
near the top. It contains two lines:
|
||||
|
||||
```python
|
||||
VAULT_DIR = r"C:\Users\dimir\Documents\ObsidianTask"
|
||||
OBSIDIAN_SUBFOLDER = "Comfy-Promt"
|
||||
```
|
||||
|
||||
- **`VAULT_DIR`** — full path to the Obsidian vault (or any other folder). On
|
||||
Windows use an `r"..."` prefix and backslashes.
|
||||
- **`OBSIDIAN_SUBFOLDER`** — subfolder name inside `VAULT_DIR` where the `.md`
|
||||
notes and `.png` copies go. It is created automatically. Set it to an empty
|
||||
string `""` to write directly into `VAULT_DIR`.
|
||||
|
||||
Examples:
|
||||
|
||||
```python
|
||||
VAULT_DIR = r"D:\MyVault" # different drive
|
||||
VAULT_DIR = r"\\NAS\share\Obsidian" # network share
|
||||
VAULT_DIR = "/home/user/Obsidian" # Linux / macOS
|
||||
OBSIDIAN_SUBFOLDER = "" # write into the vault root
|
||||
```
|
||||
|
||||
After editing, **save the file and restart ComfyUI.**
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
"""ComfyUI -> Obsidian: сохраняет изображение и позитивный промт в заметку Obsidian.
|
||||
|
||||
Нода SaveImageObsidian:
|
||||
- сохраняет PNG в output ComfyUI (стандартная нумерация),
|
||||
- копирует PNG в папку Comfy-Promt внутри vault Obsidian,
|
||||
- создаёт .md с тем же базовым именем (без расширения):
|
||||
встроенное изображение + позитивный промт в блоке кода.
|
||||
|
||||
Режимы получения текста промта (prompt_source):
|
||||
- "string" : текст берётся со строкового входа positive_prompt.
|
||||
- "conditioning" : текст НЕ извлекается из тензора (это невозможно), а
|
||||
прослеживается по графу workflow от подключённого
|
||||
CONDITIONING-входа до породившей его ноды CLIP Text Encode
|
||||
и читается её текстовое поле.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
import folder_paths
|
||||
|
||||
# =============================================================================
|
||||
# НАСТРОЙКА ПУТЕЙ / PATH CONFIGURATION
|
||||
# -----------------------------------------------------------------------------
|
||||
# RU: Чтобы изменить, куда сохраняются заметка и копия изображения, отредактируйте
|
||||
# две строки ниже.
|
||||
# VAULT_DIR — полный путь к хранилищу Obsidian (или к ЛЮБОЙ папке).
|
||||
# Используйте префикс r"" и обратные слэши на Windows.
|
||||
# OBSIDIAN_SUBFOLDER — имя подпапки внутри VAULT_DIR, куда кладутся .md и .png.
|
||||
# Если нужно писать прямо в VAULT_DIR — поставьте "".
|
||||
# После изменения сохраните файл и перезапустите ComfyUI.
|
||||
#
|
||||
# EN: To change where the note and the image copy are saved, edit the two lines
|
||||
# below.
|
||||
# VAULT_DIR — full path to the Obsidian vault (or ANY folder).
|
||||
# Use an r"" prefix and backslashes on Windows.
|
||||
# OBSIDIAN_SUBFOLDER — subfolder name inside VAULT_DIR for the .md and .png.
|
||||
# Set it to "" to write directly into VAULT_DIR.
|
||||
# Save the file and restart ComfyUI after editing.
|
||||
#
|
||||
# Примеры / Examples:
|
||||
# VAULT_DIR = r"D:\MyVault"
|
||||
# VAULT_DIR = r"\\NAS\share\Obsidian" # сетевая папка / network share
|
||||
# VAULT_DIR = "/home/user/Obsidian" # Linux / macOS
|
||||
# =============================================================================
|
||||
VAULT_DIR = r"C:\Users\dimir\Documents\ObsidianTask"
|
||||
OBSIDIAN_SUBFOLDER = "Comfy-Promt"
|
||||
# =============================================================================
|
||||
|
||||
# Имена текстовых полей, которые встречаются у разных текст-нод.
|
||||
_TEXT_KEYS = ("text", "string", "value", "prompt", "Text",
|
||||
"wildcard_text", "populated_text", "text_g", "text_l")
|
||||
|
||||
|
||||
def _resolve_string(prompt, val):
|
||||
"""val — это либо готовая строка, либо ссылка [node_id, out_idx] на ноду,
|
||||
из которой нужно достать строковое значение."""
|
||||
if isinstance(val, str):
|
||||
return val
|
||||
if isinstance(val, list) and len(val) == 2:
|
||||
src = prompt.get(str(val[0]), {})
|
||||
sinputs = src.get("inputs", {})
|
||||
for key in _TEXT_KEYS:
|
||||
if isinstance(sinputs.get(key), str):
|
||||
return sinputs[key]
|
||||
for v in sinputs.values(): # запасной вариант: любое строковое поле
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def _walk_for_text(prompt, node_id, depth=0, seen=None):
|
||||
"""Рекурсивно идём по графу от node_id, собирая текст из всех найденных
|
||||
CLIP Text Encode. Промежуточные conditioning-ноды (Combine, Concat, и т.п.)
|
||||
проходятся насквозь по их входам-ссылкам."""
|
||||
if seen is None:
|
||||
seen = set()
|
||||
node_id = str(node_id)
|
||||
if depth > 30 or node_id in seen or node_id not in prompt:
|
||||
return []
|
||||
seen.add(node_id)
|
||||
|
||||
node = prompt[node_id]
|
||||
ctype = node.get("class_type", "")
|
||||
inputs = node.get("inputs", {})
|
||||
|
||||
if "CLIPTextEncode" in ctype or "CLIP Text Encode" in ctype:
|
||||
t = _resolve_string(prompt, inputs.get("text"))
|
||||
return [t] if t else []
|
||||
|
||||
texts = []
|
||||
for v in inputs.values():
|
||||
if isinstance(v, list) and len(v) == 2 and isinstance(v[0], (str, int)):
|
||||
texts.extend(_walk_for_text(prompt, v[0], depth + 1, seen))
|
||||
return texts
|
||||
|
||||
|
||||
def _text_from_conditioning(prompt, unique_id, input_name):
|
||||
"""Находит текст для CONDITIONING-входа input_name текущей ноды (unique_id)."""
|
||||
if not prompt or unique_id is None:
|
||||
return ""
|
||||
me = prompt.get(str(unique_id), {})
|
||||
link = me.get("inputs", {}).get(input_name)
|
||||
if not (isinstance(link, list) and len(link) == 2):
|
||||
return ""
|
||||
texts = _walk_for_text(prompt, link[0])
|
||||
# уникализируем, сохраняя порядок
|
||||
seen, out = set(), []
|
||||
for t in texts:
|
||||
if t and t not in seen:
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
return "\n\n".join(out)
|
||||
|
||||
|
||||
class SaveImageObsidian:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.compress_level = 4
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"prompt_source": (["string", "conditioning"], {"default": "string"}),
|
||||
},
|
||||
"optional": {
|
||||
"positive_prompt": ("STRING", {"forceInput": True}),
|
||||
"positive_conditioning": ("CONDITIONING",),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
"unique_id": "UNIQUE_ID",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "save"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "Obsidian"
|
||||
|
||||
def save(self, images, prompt_source="string", positive_prompt=None,
|
||||
positive_conditioning=None, filename_prefix="ComfyUI",
|
||||
prompt=None, extra_pnginfo=None, unique_id=None):
|
||||
|
||||
# --- Определяем текст промта по выбранному режиму -------------------
|
||||
if prompt_source == "conditioning":
|
||||
prompt_text = _text_from_conditioning(prompt, unique_id,
|
||||
"positive_conditioning")
|
||||
if not prompt_text:
|
||||
prompt_text = ("[промт не найден: подключите CONDITIONING-выход "
|
||||
"ноды CLIP Text Encode напрямую]")
|
||||
else:
|
||||
prompt_text = positive_prompt if positive_prompt is not None else \
|
||||
"[промт не найден: подключите строковый вход positive_prompt]"
|
||||
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = \
|
||||
folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir,
|
||||
images[0].shape[1], images[0].shape[0])
|
||||
|
||||
obsidian_dir = os.path.join(VAULT_DIR, OBSIDIAN_SUBFOLDER)
|
||||
os.makedirs(obsidian_dir, exist_ok=True)
|
||||
|
||||
results = []
|
||||
for batch_number, image in enumerate(images):
|
||||
arr = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
|
||||
|
||||
# Метаданные workflow в PNG (как у штатной SaveImage)
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for k in extra_pnginfo:
|
||||
metadata.add_text(k, json.dumps(extra_pnginfo[k]))
|
||||
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
out_path = os.path.join(full_output_folder, file)
|
||||
img.save(out_path, pnginfo=metadata, compress_level=self.compress_level)
|
||||
|
||||
# Копия PNG в Comfy-Promt
|
||||
shutil.copy2(out_path, os.path.join(obsidian_dir, file))
|
||||
|
||||
# Заметка с тем же базовым именем (без расширения)
|
||||
note_name = os.path.splitext(file)[0]
|
||||
note_body = (
|
||||
f"![[{file}]]\n\n"
|
||||
f"```\n{prompt_text}\n```\n"
|
||||
)
|
||||
with open(os.path.join(obsidian_dir, note_name + ".md"),
|
||||
"w", encoding="utf-8") as f:
|
||||
f.write(note_body)
|
||||
|
||||
results.append({"filename": file, "subfolder": subfolder, "type": self.type})
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SaveImageObsidian": SaveImageObsidian}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SaveImageObsidian": "Save Image + Obsidian Note"}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
Reference in New Issue
Block a user