import os import sys import json import urllib.parse import urllib.request import subprocess import threading import asyncio from aiohttp import web import folder_paths import struct import hashlib import tempfile import time CARD_THUMBNAIL_EDGE = 512 CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024 CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'} _thumbnail_cleanup_lock = threading.Lock() _thumbnail_generation_slots = threading.BoundedSemaphore(2) _thumbnail_last_cleanup = 0.0 _thumbnail_created_since_cleanup = 0 def resolve_within(base_dir, *parts): """Resolve a path and require it to stay inside base_dir, including through symlinks.""" base_real = os.path.realpath(base_dir) candidate = os.path.realpath(os.path.join(base_real, *parts)) try: if os.path.commonpath([base_real, candidate]) != base_real: raise ValueError("Path escapes the configured directory") except ValueError: raise ValueError("Path escapes the configured directory") return candidate def get_folder_root(folder_type, path_idx=0): paths = folder_paths.get_folder_paths(folder_type) if not paths or not isinstance(path_idx, int) or path_idx < 0 or path_idx >= len(paths): raise ValueError("Invalid folder path") return os.path.realpath(paths[path_idx]) def resolve_folder_subdir(folder_type, path_idx=0, subfolder='/'): base_dir = get_folder_root(folder_type, path_idx) relative = "" if subfolder in (None, "", "/") else str(subfolder).strip("/\\") return base_dir, resolve_within(base_dir, relative) def require_filename(filename): if not isinstance(filename, str) or not filename or os.path.basename(filename) != filename: raise ValueError("Invalid filename") return filename def _thumbnail_cache_directory(): try: base_dir = folder_paths.get_temp_directory() except Exception: base_dir = None if not isinstance(base_dir, (str, os.PathLike)): base_dir = tempfile.gettempdir() cache_dir = os.path.join(base_dir, "anomalous_model_browser", "card_thumbnails") os.makedirs(cache_dir, exist_ok=True) return cache_dir def _prune_thumbnail_cache(cache_dir, created=False): """Occasionally cap the derived thumbnail cache without touching source covers.""" global _thumbnail_last_cleanup, _thumbnail_created_since_cleanup now = time.monotonic() with _thumbnail_cleanup_lock: if created: _thumbnail_created_since_cleanup += 1 now = time.monotonic() if ( now - _thumbnail_last_cleanup < 3600 and _thumbnail_created_since_cleanup < 64 ): return _thumbnail_last_cleanup = now _thumbnail_created_since_cleanup = 0 entries = [] total_size = 0 try: with os.scandir(cache_dir) as iterator: for entry in iterator: if not entry.is_file() or not entry.name.endswith('.webp'): continue try: stat = entry.stat() except OSError: continue total_size += stat.st_size entries.append((stat.st_mtime_ns, stat.st_size, entry.path)) except OSError: return if total_size <= CARD_THUMBNAIL_CACHE_LIMIT: return target_size = int(CARD_THUMBNAIL_CACHE_LIMIT * 0.8) for _, size, path in sorted(entries): try: os.remove(path) total_size -= size except OSError: pass if total_size <= target_size: break def _build_card_thumbnail_impl(source_path): """Return a cached 512px WebP card image, or the original on any safe fallback.""" try: from PIL import Image, ImageOps source_stat = os.stat(source_path) cache_key = "\0".join(( os.path.realpath(source_path), str(source_stat.st_size), str(source_stat.st_mtime_ns), str(getattr(source_stat, 'st_ctime_ns', 0)), str(CARD_THUMBNAIL_EDGE), )) digest = hashlib.sha256(cache_key.encode('utf-8', errors='surrogatepass')).hexdigest() cache_dir = _thumbnail_cache_directory() cached_path = os.path.join(cache_dir, f"{digest}.webp") if os.path.isfile(cached_path): try: os.utime(cached_path, None) except OSError: pass _prune_thumbnail_cache(cache_dir) return cached_path with Image.open(source_path) as image: if getattr(image, 'is_animated', False): return source_path image = ImageOps.exif_transpose(image) if max(image.size) <= CARD_THUMBNAIL_EDGE: return source_path resampling = getattr(Image, 'Resampling', Image).LANCZOS image.thumbnail((CARD_THUMBNAIL_EDGE, CARD_THUMBNAIL_EDGE), resampling) if image.mode not in ('RGB', 'RGBA'): image = image.convert('RGBA' if 'transparency' in image.info else 'RGB') temp_path = ( f"{cached_path}.{os.getpid()}.{threading.get_ident()}.tmp" ) try: image.save(temp_path, format='WEBP', quality=84, method=4) os.replace(temp_path, cached_path) finally: if os.path.exists(temp_path): try: os.remove(temp_path) except OSError: pass _prune_thumbnail_cache(cache_dir, created=True) return cached_path except Exception: return source_path def _build_card_thumbnail(source_path): # Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM. with _thumbnail_generation_slots: return _build_card_thumbnail_impl(source_path) async def api_serve_image(request): """Dedicated image serving endpoint for model preview images.""" folder_type = request.query.get('type', 'checkpoints') try: path_idx = int(request.query.get('path_idx', 0)) except: path_idx = 0 subfolder = request.query.get('subfolder', '') filename = request.query.get('filename', '') try: filename = require_filename(filename) _, target_dir = resolve_folder_subdir(folder_type, path_idx, subfolder) file_path = resolve_within(target_dir, filename) except (ValueError, KeyError): return web.Response(status=400, text='Invalid request') if not os.path.exists(file_path) or not os.path.isfile(file_path): return web.Response(status=404, text='Image not found') ext = os.path.splitext(filename)[1].lower() content_types = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.avif': 'image/avif', '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.avi': 'video/x-msvideo' } content_type = content_types.get(ext) if content_type is None: return web.Response(status=415, text='Unsupported media type') served_path = file_path if ( request.query.get('variant') == 'card' and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS ): served_path = await asyncio.to_thread(_build_card_thumbnail, file_path) if served_path != file_path: content_type = 'image/webp' headers = {'Content-Type': content_type} if request.query.get('t'): headers['Cache-Control'] = 'public, max-age=31536000, immutable' return web.FileResponse(served_path, headers=headers) async def api_clear_cache(request): try: from .metadata import clear_metadata_cache if hasattr(folder_paths, "filename_list_cache"): folder_paths.filename_list_cache.clear() if hasattr(folder_paths, "cache_helper") and hasattr(folder_paths.cache_helper, "clear"): folder_paths.cache_helper.clear() clear_metadata_cache() return web.json_response({"status": "success"}) except Exception as e: return web.json_response({"status": "error", "message": str(e)}) async def api_translate(request): try: data = await request.json() text = data.get("text", "") tl = data.get("target_lang", "zh-CN") if not text: return web.json_response({"translated": ""}) # Try DeepL if API key exists script_dir = os.path.dirname(os.path.abspath(__file__)) config_path = os.path.join(script_dir, "config.json") deepl_key = "" if os.path.exists(config_path): try: with open(config_path, 'r', encoding='utf-8') as f: cfg = json.load(f) deepl_key = cfg.get("DEEPL_API_KEY", "").strip() except: pass if deepl_key: deepl_map = { "zh-CN": "ZH", "en": "EN", "ja": "JA", "ko": "KO", "fr": "FR", "de": "DE", "es": "ES", "ru": "RU" } d_tl = deepl_map.get(tl, "EN") url = "https://api-free.deepl.com/v2/translate" if ":fx" in deepl_key else "https://api.deepl.com/v2/translate" payload = urllib.parse.urlencode({ "auth_key": deepl_key, "text": text, "target_lang": d_tl }).encode('utf-8') req = urllib.request.Request(url, data=payload) with urllib.request.urlopen(req, timeout=5) as resp: result = json.loads(resp.read().decode('utf-8')) return web.json_response({"translated": result["translations"][0]["text"]}) else: # Fallback to Google Translate (free, no key) url = f"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl={tl}&dt=t&q={urllib.parse.quote(text)}" req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=5) as resp: result = json.loads(resp.read().decode('utf-8')) # Google Translate returns a list of fragments translated_text = "".join([part[0] for part in result[0]]) return web.json_response({"translated": translated_text}) except Exception as e: print(f"Translate Error: {e}") return web.json_response({"translated": text, "error": str(e)}) async def api_get_gallery_images(request): try: output_dir = folder_paths.get_output_directory() if not os.path.exists(output_dir): return web.json_response({"images": [], "total": 0, "page": 1, "pages": 0}) page = max(1, int(request.query.get('page', 1))) limit = min(200, max(1, int(request.query.get('limit', 50)))) valid_exts = {'.png', '.jpg', '.jpeg', '.webp', '.gif'} def collect_images(): images = [] for root, dirs, files in os.walk(output_dir): for f in files: ext = os.path.splitext(f)[1].lower() if ext in valid_exts: rel_path = os.path.relpath(root, output_dir) subfolder = "" if rel_path == "." else rel_path.replace('\\', '/') full_path = os.path.join(root, f) try: mtime = os.path.getmtime(full_path) except OSError: mtime = 0 images.append({ "filename": f, "subfolder": subfolder, "type": "output", "mtime": mtime }) images.sort(key=lambda x: x['mtime'], reverse=True) return images images = await asyncio.to_thread(collect_images) total = len(images) start_idx = (page - 1) * limit end_idx = start_idx + limit paginated_images = images[start_idx:end_idx] return web.json_response({ "images": paginated_images, "total": total, "page": page, "pages": (total + limit - 1) // limit if limit > 0 else 0 }) except Exception as e: return web.json_response({"error": str(e)}, status=500) async def api_delete_gallery_image(request): try: data = await request.json() filename = data.get("filename") subfolder = data.get("subfolder", "") output_dir = folder_paths.get_output_directory() try: filename = require_filename(filename) target_dir = resolve_within(output_dir, subfolder) file_path = resolve_within(target_dir, filename) except ValueError: return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400) if os.path.exists(file_path): os.remove(file_path) return web.json_response({"status": "success"}) else: return web.json_response({"status": "error", "message": "File not found"}) except Exception as e: return web.json_response({"status": "error", "message": str(e)}, status=500) def read_png_text_fast(path): try: with open(path, 'rb') as f: signature = f.read(8) if signature != b'\x89PNG\r\n\x1a\n': return None while True: length_bytes = f.read(4) if not length_bytes: break length = struct.unpack('>I', length_bytes)[0] chunk_type = f.read(4) if chunk_type == b'tEXt': data = f.read(length) keyword, text = data.split(b'\0', 1) if keyword == b'prompt': return text.decode('utf-8', errors='ignore') else: f.seek(length, 1) f.seek(4, 1) except Exception: pass return None async def api_get_model_images(request): model_name = request.rel_url.query.get('model_name', '') if not model_name: return web.json_response({'images': []}) base_target = os.path.basename(model_name).lower() # We will search the output directory output_dir = folder_paths.get_output_directory() if not os.path.exists(output_dir): return web.json_response({'images': []}) def find_images(): matched_images = [] for root, _, files in os.walk(output_dir): for file in files: if not file.lower().endswith('.png'): continue full_path = os.path.join(root, file) prompt_text = read_png_text_fast(full_path) if not prompt_text: continue try: prompt_data = json.loads(prompt_text) matched = any( os.path.basename(v).lower() == base_target for node in prompt_data.values() if isinstance(node, dict) and 'class_type' in node for v in node.get('inputs', {}).values() if isinstance(v, str) ) if not matched: continue rel_path = os.path.relpath(root, output_dir).replace('\\', '/') if rel_path == '.': rel_path = '' url = f'/view?filename={urllib.parse.quote(file)}&type=output' if rel_path: url += f'&subfolder={urllib.parse.quote(rel_path)}' matched_images.append({'url': url, 'mtime': os.path.getmtime(full_path)}) except (OSError, ValueError, TypeError, json.JSONDecodeError): pass matched_images.sort(key=lambda x: x['mtime'], reverse=True) return matched_images matched_images = await asyncio.to_thread(find_images) return web.json_response({'images': matched_images}) def get_folder_view_mode(): script_dir = os.path.dirname(os.path.abspath(__file__)) config_path = os.path.join(script_dir, "config.json") try: if os.path.exists(config_path): with open(config_path, 'r', encoding='utf-8') as f: cfg = json.load(f) return cfg.get("folder_view_mode", "abstract") except: pass return "abstract" def get_all_physical_basenames(): basenames = set() for t in folder_paths.folder_names_and_paths.keys(): try: paths = folder_paths.get_folder_paths(t) if not paths: continue for p in paths: bn = os.path.basename(os.path.normpath(p)) if bn: basenames.add(bn) except: pass return list(basenames) def get_active_physical_basenames(): script_dir = os.path.dirname(os.path.abspath(__file__)) config_path = os.path.join(script_dir, "config.json") all_bns = get_all_physical_basenames() active = [] configured = set() try: if os.path.exists(config_path): with open(config_path, 'r', encoding='utf-8') as f: cfg = json.load(f) pfc = cfg.get("physical_folders_config") if pfc and isinstance(pfc, list): for item in pfc: bn = item.get("type") if bn in all_bns: configured.add(bn) if item.get("visible", True): active.append(bn) for bn in all_bns: if bn not in configured: active.append(bn) return active except: pass return all_bns def get_active_scan_paths(): mode = get_folder_view_mode() paths = set() if mode == "physical": active_bns = set(get_active_physical_basenames()) for t in folder_paths.folder_names_and_paths.keys(): try: ps = folder_paths.get_folder_paths(t) if not ps: continue for p in ps: if not os.path.exists(p): continue bn = os.path.basename(os.path.normpath(p)) if bn in active_bns: paths.add(os.path.realpath(p)) except: pass else: active_types = get_active_folder_types() for t in active_types: try: ps = folder_paths.get_folder_paths(t) if not ps: continue for p in ps: if not os.path.exists(p): continue paths.add(os.path.realpath(p)) except: pass return list(paths) def get_active_folder_types(): script_dir = os.path.dirname(os.path.abspath(__file__)) config_path = os.path.join(script_dir, "config.json") all_types = list(folder_paths.folder_names_and_paths.keys()) default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae'] try: if os.path.exists(config_path): with open(config_path, 'r', encoding='utf-8') as f: cfg = json.load(f) ftc = cfg.get("folder_types_config") if ftc and isinstance(ftc, list): active = [] configured_types = set() for item in ftc: t = item.get("type") if t in all_types: configured_types.add(t) if item.get("visible", True): active.append(t) # For any newly registered types not in config, do not show them by default # unless they are in default_types for t in all_types: if t not in configured_types and t in default_types: active.append(t) return active except Exception: pass # Fallback if no config exists active = [t for t in default_types if t in all_types] return active async def api_get_all_folder_types(request): mode = get_folder_view_mode() script_dir = os.path.dirname(os.path.abspath(__file__)) config_path = os.path.join(script_dir, "config.json") result = [] if mode == "physical": all_bns = get_all_physical_basenames() configured = set() try: if os.path.exists(config_path): with open(config_path, 'r', encoding='utf-8') as f: cfg = json.load(f) pfc = cfg.get("physical_folders_config") if pfc and isinstance(pfc, list): for item in pfc: bn = item.get("type") if bn in all_bns: configured.add(bn) result.append({ "type": bn, "visible": item.get("visible", True) }) except: pass default_physical_types = ['checkpoints', 'loras', 'unet', 'diffusion_models', 'controlnet', 'vae'] for bn in all_bns: if bn not in configured: result.append({ "type": bn, "visible": bn in default_physical_types }) else: # Abstract mode all_types = list(folder_paths.folder_names_and_paths.keys()) default_types = ['checkpoints', 'loras', 'diffusion_models', 'controlnet', 'vae'] configured_types = set() try: if os.path.exists(config_path): with open(config_path, 'r', encoding='utf-8') as f: cfg = json.load(f) ftc = cfg.get("folder_types_config") if ftc and isinstance(ftc, list): for item in ftc: t = item.get("type") if t in all_types: configured_types.add(t) result.append({ "type": t, "visible": item.get("visible", True) }) except: pass for t in all_types: if t not in configured_types: result.append({ "type": t, "visible": t in default_types }) return web.json_response({"folder_types": result, "folder_view_mode": mode})