"""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"]