From 428aaae7fb8b59f88d72c388b6420a1d88127252 Mon Sep 17 00:00:00 2001 From: dimon Date: Tue, 16 Jun 2026 21:06:41 +0800 Subject: [PATCH] ComfyUI->Obsidian prompt note node: dual prompt source (string/conditioning), bilingual README Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 + README.md | 232 ++++++++++++++++++++++++++++++++++++++++++++++++++++ __init__.py | 213 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..faa3900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a95ffc --- /dev/null +++ b/README.md @@ -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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..5fecefd --- /dev/null +++ b/__init__.py @@ -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"]