import os import json import glob import time import subprocess import shutil from pathlib import Path import folder_paths from aiohttp import web from server import PromptServer NODE_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIG_PATH = os.path.join(NODE_DIR, 'config.json') SUBFOLDER = "one-node-z-image" def _favorites_path(): return os.path.join(NODE_DIR, "favorites.json") def _load_favorites(): path = _favorites_path() if os.path.exists(path): try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) return set(data) if isinstance(data, list) else set() except Exception: return set() favs = set() try: subf_dir = os.path.join(_get_output_dir(), SUBFOLDER) if os.path.isdir(subf_dir): scan_dirs = [subf_dir, os.path.join(subf_dir, "metadata")] for d in scan_dirs: if not os.path.isdir(d): continue for jf in glob.glob(os.path.join(d, "*.json")): try: with open(jf, "r", encoding="utf-8") as f: md = json.load(f) if md.get("favorite") is True: png = os.path.splitext(os.path.basename(jf))[0] + ".png" favs.add(png) except Exception: pass if favs: _save_favorites(favs) except Exception: pass return favs def _save_favorites(favset): path = _favorites_path() os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(sorted(favset), f, ensure_ascii=False, indent=2) def _favorites_add(filename): favs = _load_favorites() favs.add(filename) _save_favorites(favs) def _favorites_remove(filename): favs = _load_favorites() favs.discard(filename) _save_favorites(favs) def _safe_resolve_output_path(output_dir, subfolder="", filename=""): base = Path(output_dir).resolve() target = base if subfolder: target = target / subfolder if filename: target = target / filename target = target.resolve() try: target.relative_to(base) except Exception: raise ValueError("invalid path") return str(target) def _safe_resolve_input_path(filename=""): base = Path(folder_paths.get_input_directory()).resolve() target = (base / filename).resolve() try: target.relative_to(base) except Exception: raise ValueError("invalid input path") return str(target) def _file_key(filename, subfolder=""): return f"{subfolder}/{filename}" if subfolder else filename def _load_config(): try: with open(CONFIG_PATH, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save_config(cfg): with open(CONFIG_PATH, "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=2) def _get_output_dir(): try: return str(Path(folder_paths.get_output_directory()).resolve()) except Exception: return str(Path(os.path.join(os.path.dirname(NODE_DIR), "output")).resolve()) def _find_ffmpeg(): try: from custom_nodes.ComfyUI_VideoHelperSuite.videohelpersuite.utils import ffmpeg_path if os.path.isfile(ffmpeg_path): return ffmpeg_path except Exception: pass try: import custom_nodes.ComfyUI_VideoHelperSuite.videohelpersuite.ffmpeg_path as vhs_fp p = vhs_fp.get_ffmpeg_path() if hasattr(vhs_fp, 'get_ffmpeg_path') else getattr(vhs_fp, 'ffmpeg_path', '') if p and os.path.isfile(p): return p except Exception: pass exe = "ffmpeg.exe" if os.name == "nt" else "ffmpeg" root = NODE_DIR for _ in range(6): if os.path.isdir(os.path.join(root, "custom_nodes")): break root = os.path.dirname(root) for vhs_name in ["ComfyUI-VideoHelperSuite", "ComfyUI_VideoHelperSuite", "comfyui-videohelpersuite"]: vhs_dir = os.path.join(root, "custom_nodes", vhs_name) if os.path.isdir(vhs_dir): for r2, _, files in os.walk(vhs_dir): if exe in files: return os.path.join(r2, exe) portable = os.path.dirname(root) for candidate in [os.path.join(portable, exe), os.path.join(root, exe), os.path.join(portable, "bin", exe)]: if os.path.isfile(candidate): return candidate found = shutil.which("ffmpeg") if found: return found return None _ffmpeg_path = None def _ff(): global _ffmpeg_path if _ffmpeg_path is None: _ffmpeg_path = _find_ffmpeg() or "" return _ffmpeg_path or None def _meta_dir(image_path): return os.path.join(os.path.dirname(image_path), "metadata") def _meta_path(image_path): fname = os.path.splitext(os.path.basename(image_path))[0] + ".json" return os.path.join(_meta_dir(image_path), fname) def _meta_path_legacy(image_path): base, _ = os.path.splitext(image_path) return base + ".json" def _migrate_meta_sidecars(): try: subf_dir = os.path.join(_get_output_dir(), SUBFOLDER) if not os.path.isdir(subf_dir): return meta_dir = os.path.join(subf_dir, "metadata") os.makedirs(meta_dir, exist_ok=True) moved = 0 for jf in glob.glob(os.path.join(subf_dir, "*.json")): basename = os.path.basename(jf) dest = os.path.join(meta_dir, basename) if not os.path.exists(dest): try: shutil.move(jf, dest) moved += 1 except Exception as e: print(f"[ZImageOneNode] migrate sidecar {basename}: {e}") else: try: os.remove(jf) except Exception: pass if moved: print(f"[ZImageOneNode] Migrated {moved} metadata sidecar(s) to metadata/") except Exception as e: print(f"[ZImageOneNode] migrate_meta_sidecars error: {e}") def _png_embed_meta(png_path, meta_dict): import struct, zlib try: with open(png_path, "rb") as f: data = f.read() if data[:8] != b'\x89PNG\r\n\x1a\n': return False meta_json = json.dumps(meta_dict, ensure_ascii=False, separators=(',', ':')) keyword = b'Comment' text_data = keyword + b'\x00' + meta_json.encode('utf-8') crc = zlib.crc32(b'tEXt' + text_data) & 0xFFFFFFFF chunk = struct.pack('>I', len(text_data)) + b'tEXt' + text_data + struct.pack('>I', crc) sig = data[:8] pos = 8 ihdr_len = struct.unpack('>I', data[8:12])[0] pos += 12 + ihdr_len new_body = bytearray() i = 8 while i < len(data) - 4: try: clen = struct.unpack('>I', data[i:i+4])[0] ctype = data[i+4:i+8] if ctype == b'tEXt': chunk_data = data[i+8:i+8+clen] if chunk_data.startswith(b'Comment\x00'): i += 12 + clen continue new_body += data[i:i+12+clen] if ctype == b'IEND': break i += 12 + clen except Exception: new_body += data[i:] break final = bytearray(sig) j = 0 inserted = False while j < len(new_body): try: clen = struct.unpack('>I', bytes(new_body[j:j+4]))[0] ctype = new_body[j+4:j+8] final += new_body[j:j+12+clen] j += 12 + clen if not inserted and ctype == b'IHDR': final += chunk inserted = True except Exception: final += new_body[j:] break if not inserted: final += chunk tmp = png_path + ".zimeta.tmp" try: with open(tmp, "wb") as f: f.write(final) import time for attempt in range(5): try: os.replace(tmp, png_path) break except OSError: if attempt == 4: raise time.sleep(0.3) except Exception: try: os.remove(tmp) except OSError: pass raise return True except Exception as e: print(f"[ZImageOneNode] png_embed_meta error: {e}") return False def _png_read_meta(png_path): import struct try: with open(png_path, "rb") as f: data = f.read() if data[:8] != b'\x89PNG\r\n\x1a\n': return None i = 8 while i < len(data) - 4: try: clen = struct.unpack('>I', data[i:i+4])[0] ctype = data[i+4:i+8] if ctype == b'tEXt': chunk_data = data[i+8:i+8+clen] if chunk_data.startswith(b'Comment\x00'): raw = chunk_data[8:].decode('utf-8', errors='replace') parsed = json.loads(raw) if isinstance(parsed, dict): return parsed if ctype == b'IEND': break i += 12 + clen except Exception: break return None except Exception as e: print(f"[ZImageOneNode] png_read_meta error: {e}") return None def _read_json_meta(image_path): _VALID = ("v", "prompt", "w", "h", "mode", "favorite", "favourite") if image_path.lower().endswith('.png') and os.path.exists(image_path): meta = _png_read_meta(image_path) if meta and isinstance(meta, dict) and any(k in meta for k in _VALID): return meta for mp in (_meta_path(image_path), _meta_path_legacy(image_path)): if not os.path.exists(mp): continue try: with open(mp, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict) and any(k in data for k in _VALID): return data except Exception as e: print(f"[ZImageOneNode] read_json_meta error: {e}") return None def _write_json_meta(image_path, meta_dict): ok_png = False if image_path.lower().endswith('.png') and os.path.exists(image_path): orig_mtime = os.path.getmtime(image_path) ok_png = _png_embed_meta(image_path, meta_dict) if ok_png: try: os.utime(image_path, (orig_mtime, orig_mtime)) except Exception: pass print(f"[ZImageOneNode] Meta embedded in PNG: {os.path.basename(image_path)}") mp = _meta_path(image_path) tmp = mp + ".tmp" try: os.makedirs(os.path.dirname(mp), exist_ok=True) with open(tmp, "w", encoding="utf-8") as f: json.dump(meta_dict, f, ensure_ascii=False, indent=2) os.replace(tmp, mp) return True except Exception as e: print(f"[ZImageOneNode] write_json_meta error: {e}") if os.path.exists(tmp): try: os.remove(tmp) except Exception: pass return ok_png def _serve_json(filename): async def handler(request): path = os.path.join(NODE_DIR, filename) if not os.path.exists(path): return web.Response(status=404, text=f"{filename} not found") with open(path, "r", encoding="utf-8") as f: data = json.load(f) return web.json_response(data) return handler PromptServer.instance.routes.get("/z_image/workflow_t2i")(_serve_json("workflows/t2i_workflow.json")) PromptServer.instance.routes.get("/z_image/workflow_i2i")(_serve_json("workflows/i2i_workflow.json")) @PromptServer.instance.routes.get("/z_image/bgremoval_models") async def get_bgremoval_models(request): exts = [".safetensors", ".onnx", ".pt", ".pth"] found = [] try: bases = folder_paths.get_folder_paths("background_removal") for base in bases: if os.path.isdir(base): for fn in os.listdir(base): if any(fn.lower().endswith(e) for e in exts): found.append(fn) except Exception: pass if not found: try: models_dir = folder_paths.models_dir except Exception: models_dir = os.path.join(os.path.dirname(os.path.dirname(NODE_DIR)), "models") bg_dir = os.path.join(models_dir, "background_removal") if os.path.isdir(bg_dir): for fn in os.listdir(bg_dir): if any(fn.lower().endswith(e) for e in exts): found.append(fn) found = sorted(set(found)) return web.json_response({"models": found}) @PromptServer.instance.routes.get("/z_image/config") async def get_config(request): cfg = _load_config() return web.json_response({ "dummy": cfg.get("dummy", ""), "lora_triggers_custom": cfg.get("lora_triggers_custom", {}), "t2i_templates": cfg.get("t2i_templates", []), "discover_prompts": cfg.get("discover_prompts", {}), "autofill_prompts": cfg.get("autofill_prompts", {}), }) @PromptServer.instance.routes.post("/z_image/config") async def save_config_route(request): try: patch = await request.json() if not isinstance(patch, dict): return web.json_response({"ok": False, "error": "invalid payload"}, status=400) cfg = _load_config() cfg.update({k: v for k, v in patch.items()}) _save_config(cfg) return web.json_response({"ok": True}) except Exception as e: print(f"[ZImageOneNode] config save error: {e}") return web.json_response({"ok": False, "error": str(e)}, status=500) @PromptServer.instance.routes.get("/z_image/gallery") async def get_gallery(request): output_dir = _get_output_dir() try: offset = max(0, int(request.query.get("offset", 0))) except Exception: offset = 0 try: limit = min(max(1, int(request.query.get("limit", 20))), 200) except Exception: limit = 20 subf = request.query.get("subfolder", "") favonly = request.query.get("favonly", "0") == "1" try: search = _safe_resolve_output_path(output_dir, subf) if subf else output_dir except ValueError: return web.json_response({"images": [], "total": 0, "offset": offset, "limit": limit, "error": "invalid subfolder"}, status=400) assets_dir = os.path.normpath(_safe_resolve_output_path(output_dir, os.path.join(SUBFOLDER, "assets"))) if favonly: fav_names = _load_favorites() subf_dir = os.path.normpath(_safe_resolve_output_path(output_dir, SUBFOLDER)) unique = [] missing = set() for name in fav_names: p = os.path.join(subf_dir, name) if os.path.isfile(p): unique.append(p) else: missing.add(name) if missing: _save_favorites(fav_names - missing) unique.sort(key=os.path.getmtime, reverse=True) else: search_norm = os.path.normpath(search) exclude_assets = not search_norm.startswith(assets_dir + os.sep) and search_norm != assets_dir unique = [] if os.path.isdir(search): pngs = glob.glob(os.path.join(search, "**", "*.png"), recursive=True) filtered = [p for p in pngs if not exclude_assets or not os.path.normpath(p).startswith(assets_dir + os.sep)] unique = sorted(set(filtered), key=os.path.getmtime, reverse=True) fav_set = _load_favorites() if not favonly else fav_names images = [] for f in unique[offset:offset + limit]: rel = os.path.relpath(os.path.dirname(f), output_dir) fname = os.path.basename(f) images.append({ "filename": fname, "subfolder": "" if rel == "." else rel, "mtime": os.path.getmtime(f), "key": _file_key(fname, "" if rel == "." else rel), "has_meta": os.path.exists(_meta_path(f)) or os.path.exists(_meta_path_legacy(f)), "favorite": fname in fav_set, }) return web.json_response({"images": images, "total": len(unique), "offset": offset, "limit": limit}) @PromptServer.instance.routes.post("/z_image/save_meta") async def save_meta(request): try: data = await request.json() filename = data.get("filename", "") subfolder = data.get("subfolder", "") meta = data.get("meta", {}) if not filename: return web.json_response({"ok": False, "error": "no filename"}) output_dir = _get_output_dir() try: vpath = _safe_resolve_output_path(output_dir, subfolder, filename) except ValueError: return web.json_response({"ok": False, "error": "invalid path"}, status=400) if not os.path.exists(vpath): return web.json_response({"ok": False, "error": f"not found: {vpath}"}) ok = _write_json_meta(vpath, meta) return web.json_response({"ok": ok, "filename": filename}) except Exception as e: print(f"[ZImageOneNode] save_meta error: {e}") return web.json_response({"ok": False, "error": str(e)}) @PromptServer.instance.routes.post("/z_image/update_meta") async def update_meta(request): try: data = await request.json() filename = data.get("filename", "") subfolder = data.get("subfolder", "") patch = data.get("patch", {}) if not filename or not isinstance(patch, dict): return web.json_response({"ok": False, "error": "bad request"}) output_dir = _get_output_dir() try: vpath = _safe_resolve_output_path(output_dir, subfolder, filename) except ValueError: return web.json_response({"ok": False, "error": "invalid path"}, status=400) existing = _read_json_meta(vpath) or {} existing.update(patch) ok = _write_json_meta(vpath, existing) if "favorite" in patch: if patch["favorite"] is True: _favorites_add(filename) else: _favorites_remove(filename) return web.json_response({"ok": ok}) except Exception as e: print(f"[ZImageOneNode] update_meta error: {e}") return web.json_response({"ok": False, "error": str(e)}) @PromptServer.instance.routes.get("/z_image/meta") async def get_meta(request): filename = request.query.get("filename", "") subfolder = request.query.get("subfolder", "") if not filename: return web.json_response({"ok": False, "error": "no filename"}) output_dir = _get_output_dir() try: vpath = _safe_resolve_output_path(output_dir, subfolder, filename) except ValueError: return web.json_response({"ok": False, "error": "invalid path"}, status=400) if not os.path.exists(vpath): return web.json_response({"ok": False, "error": "image not found"}) meta = _read_json_meta(vpath) if meta is None: return web.json_response({"ok": False, "error": "no metadata"}) return web.json_response({"ok": True, "meta": meta}) @PromptServer.instance.routes.post("/z_image/open_folder") async def open_folder(request): try: data = await request.json() filename = data.get("filename", "") subfolder = data.get("subfolder", "") if not filename: return web.json_response({"ok": False, "error": "no filename"}) output_dir = _get_output_dir() try: vpath = _safe_resolve_output_path(output_dir, subfolder, filename) except ValueError: return web.json_response({"ok": False, "error": "invalid path"}, status=400) if not os.path.exists(vpath): return web.json_response({"ok": False, "error": "file not found"}) import platform import subprocess as _sp system = platform.system() if system == "Windows": _sp.Popen(["explorer", "/select,", vpath.replace("/", "\\")]) elif system == "Darwin": _sp.Popen(["open", "-R", vpath]) else: _sp.Popen(["xdg-open", os.path.dirname(vpath)]) return web.json_response({"ok": True}) except Exception as e: return web.json_response({"ok": False, "error": str(e)}) @PromptServer.instance.routes.post("/z_image/delete") async def delete_image(request): try: data = await request.json() filename = data.get("filename", "") subfolder = data.get("subfolder", "") if not filename: return web.json_response({"ok": False, "error": "filename required"}, status=400) output_dir = _get_output_dir() try: img_path = _safe_resolve_output_path(output_dir, subfolder, filename) except ValueError: return web.json_response({"ok": False, "error": "invalid path"}, status=400) if not os.path.exists(img_path): return web.json_response({"ok": False, "error": "file not found"}, status=404) os.remove(img_path) for json_path in (_meta_path(img_path), _meta_path_legacy(img_path)): if os.path.exists(json_path): try: os.remove(json_path) except Exception: pass _favorites_remove(filename) return web.json_response({"ok": True}) except Exception as e: return web.json_response({"ok": False, "error": str(e)}) def _scan(folder_key, extensions=None): exts = extensions or [".safetensors", ".ckpt", ".pt", ".pth"] try: bases = folder_paths.get_folder_paths(folder_key) except Exception: return ["none"] found = [] for base in bases: if not os.path.isdir(base): continue for root, _, files in os.walk(base): for fn in files: if any(fn.lower().endswith(e) for e in exts): found.append(os.path.relpath(os.path.join(root, fn), base)) return sorted(found) if found else ["none"] def _scan_path(path, extensions=None): exts = extensions or [".safetensors", ".ckpt", ".pt", ".pth"] if not os.path.isdir(path): return ["none"] found = [] for root, _, files in os.walk(path): for fn in files: if any(fn.lower().endswith(e) for e in exts): found.append(os.path.relpath(os.path.join(root, fn), path)) return sorted(found) if found else ["none"] @PromptServer.instance.routes.get("/z_image/models") async def get_models(request): try: diff = _scan("diffusion_models") except Exception: try: import folder_paths as fp diff = _scan_path(os.path.join(os.path.dirname(getattr(fp, "models_dir", "")), "models", "diffusion_models")) except Exception: diff = ["none"] try: te = _scan("text_encoders") except Exception: te = ["none"] try: vaes = _scan("vae") except Exception: vaes = ["none"] try: loras = _scan("loras") except Exception: loras = ["none"] return web.json_response({ "diffusion_models": diff, "text_encoders": te, "vaes": vaes, "loras": loras, }) def _read_safetensors_header(path): try: with open(path, "rb") as f: length_bytes = f.read(8) if len(length_bytes) < 8: return None import struct header_len = struct.unpack(" 100 * 1024 * 1024: return None header_bytes = f.read(header_len) return json.loads(header_bytes.decode("utf-8")) except Exception: return None def _extract_trigger_words(header): if not header: return [] meta = header.get("__metadata__", {}) if not isinstance(meta, dict): return [] triggers = [] v = meta.get("modelspec.trigger_phrase") or meta.get("trigger_phrase") or meta.get("trigger_word") if v and isinstance(v, str) and v.strip(): triggers.extend([t.strip() for t in v.split(",") if t.strip()]) raw = meta.get("ss_trigger_words") if raw: if isinstance(raw, str): try: parsed = json.loads(raw) if isinstance(parsed, list): triggers.extend([str(t).strip() for t in parsed if str(t).strip()]) elif isinstance(parsed, str) and parsed.strip(): triggers.extend([t.strip() for t in parsed.split(",") if t.strip()]) except Exception: triggers.extend([t.strip() for t in raw.split(",") if t.strip()]) elif isinstance(raw, list): triggers.extend([str(t).strip() for t in raw if str(t).strip()]) tag_freq_raw = meta.get("ss_tag_frequency") if tag_freq_raw and not triggers: try: tag_freq = json.loads(tag_freq_raw) if isinstance(tag_freq_raw, str) else tag_freq_raw if isinstance(tag_freq, dict): all_tags = {} for ds_tags in tag_freq.values(): if isinstance(ds_tags, dict): for tag, count in ds_tags.items(): all_tags[tag] = all_tags.get(tag, 0) + (count if isinstance(count, int) else 0) if all_tags: top = sorted(all_tags.items(), key=lambda x: x[1], reverse=True)[:5] triggers.extend([t for t, _ in top]) except Exception: pass seen = set() result = [] for t in triggers: if t.lower() not in seen: seen.add(t.lower()) result.append(t) return result @PromptServer.instance.routes.get("/z_image/lora_triggers") async def lora_triggers(request): lora_name = request.query.get("name", "") if not lora_name: return web.json_response({"ok": False, "error": "no name"}, status=400) try: bases = folder_paths.get_folder_paths("loras") except Exception: return web.json_response({"ok": False, "error": "cannot resolve loras folder"}, status=500) for base in bases: candidate = os.path.normpath(os.path.join(base, lora_name)) try: Path(candidate).resolve().relative_to(Path(base).resolve()) except Exception: continue if os.path.isfile(candidate) and candidate.lower().endswith(".safetensors"): header = _read_safetensors_header(candidate) triggers = _extract_trigger_words(header) return web.json_response({"ok": True, "triggers": triggers, "name": lora_name}) return web.json_response({"ok": False, "error": "file not found", "triggers": []}) class ZImageOneNode: @classmethod def INPUT_TYPES(cls): return {"required": {}, "hidden": {"unique_id": "UNIQUE_ID"}} RETURN_TYPES = () FUNCTION = "noop" CATEGORY = "One Node" OUTPUT_NODE = True def noop(self, **kwargs): return {} @classmethod def IS_CHANGED(cls, **kwargs): return float("nan") NODE_CLASS_MAPPINGS = {"ZImageOneNode": ZImageOneNode} NODE_DISPLAY_NAME_MAPPINGS = {"ZImageOneNode": "One Node ยท Z-Image"} _migrate_meta_sidecars()