Files
2026-02-16 22:56:21 -08:00

452 lines
16 KiB
Python

"""
ComfyUI_Viewer Extension Auto-Installer
This script runs at ComfyUI startup before nodes are imported.
It automatically installs View Extensions from .zip files placed in the extensions folder.
Usage:
1. Download an extension from GitHub as a .zip file
2. Place the .zip in ComfyUI_Viewer/extensions/
3. Restart ComfyUI - the extension will be automatically installed
The script will:
- Extract nodes/, web/views/, and modules/parsers/ to ComfyUI_Viewer
- Install requirements.txt using ComfyUI's Python interpreter
- Create a log file to track installation (skip if already installed)
"""
import sys
import zipfile
import shutil
import subprocess
import datetime
import json
from pathlib import Path
# Get paths
SCRIPT_DIR = Path(__file__).parent.resolve()
EXTENSIONS_DIR = SCRIPT_DIR / "extensions"
LOGS_DIR = EXTENSIONS_DIR / "logs"
EXTENSION_VIEWS_JSON = SCRIPT_DIR / "web" / "views" / "extension_views.json"
# Folders to extract from extension zips
EXTRACT_FOLDERS = {
"nodes": SCRIPT_DIR / "nodes",
"web/views": SCRIPT_DIR / "web" / "views",
"modules/parsers": SCRIPT_DIR / "modules" / "parsers",
"routes": SCRIPT_DIR / "routes",
"apps": SCRIPT_DIR / "apps",
}
def print_progress(message: str, level: str = "INFO"):
"""Print progress message."""
prefix = {
"INFO": "\033[94m•\033[0m",
"OK": "\033[92m✓\033[0m",
"WARN": "\033[93m!\033[0m",
"ERROR": "\033[91m✗\033[0m",
"PROGRESS": "\033[96m→\033[0m",
"HEADER": "\033[95m■\033[0m",
}.get(level, "•")
print(f" {prefix} {message}")
def get_python_executable() -> str:
"""Get the Python executable used by ComfyUI."""
return sys.executable
def ensure_directories():
"""Ensure extensions and logs directories exist."""
EXTENSIONS_DIR.mkdir(exist_ok=True)
LOGS_DIR.mkdir(exist_ok=True)
# Create .gitkeep to preserve empty folders
gitkeep = EXTENSIONS_DIR / ".gitkeep"
if not gitkeep.exists():
gitkeep.write_text("# Place extension .zip files here\n")
def get_log_path(zip_name: str) -> Path:
"""Get the log file path for an extension."""
base_name = zip_name.rsplit(".", 1)[0]
return LOGS_DIR / f"{base_name}_install.log"
def is_installed(zip_name: str) -> bool:
"""Check if an extension is already installed (log file exists)."""
return get_log_path(zip_name).exists()
def find_extension_root(zip_ref: zipfile.ZipFile) -> str:
"""
Find the root folder inside the zip (GitHub adds branch suffix).
e.g., ComfyUI_Viewer_Image_Search-main/
"""
names = zip_ref.namelist()
# Find the common root folder
roots = set()
for name in names:
parts = name.split("/")
if len(parts) > 1:
roots.add(parts[0])
if len(roots) == 1:
return list(roots)[0]
return ""
def extract_extension(zip_path: Path, log_lines: list) -> bool:
"""
Extract extension folders from zip to ComfyUI_Viewer.
Returns True if successful.
"""
try:
with zipfile.ZipFile(zip_path, "r") as zip_ref:
root = find_extension_root(zip_ref)
if not root:
log_lines.append(
"ERROR: Could not determine extension root folder in zip"
)
return False
log_lines.append(f"Extension root: {root}")
extracted_count = 0
for source_folder, dest_folder in EXTRACT_FOLDERS.items():
source_prefix = f"{root}/{source_folder}/"
# Find matching files
matching_files = [
name
for name in zip_ref.namelist()
if name.startswith(source_prefix) and not name.endswith("/")
]
if not matching_files:
log_lines.append(f" No files found in {source_folder}/")
continue
log_lines.append(
f" Extracting {len(matching_files)} files from {source_folder}/"
)
for file_path in matching_files:
# Calculate relative path and destination
rel_path = file_path[len(source_prefix) :]
dest_path = dest_folder / rel_path
# Ensure parent directory exists
dest_path.parent.mkdir(parents=True, exist_ok=True)
# Extract file
with zip_ref.open(file_path) as src:
with open(dest_path, "wb") as dst:
dst.write(src.read())
extracted_count += 1
log_lines.append(f" -> {dest_path.relative_to(SCRIPT_DIR)}")
log_lines.append(f"Extracted {extracted_count} files total")
return extracted_count > 0
except zipfile.BadZipFile:
log_lines.append("ERROR: Invalid or corrupted zip file")
return False
except Exception as e:
log_lines.append(f"ERROR: Extraction failed: {e}")
return False
def extract_and_install_requirements(zip_path: Path, log_lines: list) -> bool:
"""
Extract requirements.txt from zip and install using pip.
Returns True if successful (or no requirements).
"""
try:
with zipfile.ZipFile(zip_path, "r") as zip_ref:
root = find_extension_root(zip_ref)
requirements_path = f"{root}/requirements.txt"
# Check if requirements.txt exists in zip
if requirements_path not in zip_ref.namelist():
log_lines.append("No requirements.txt found - skipping pip install")
return True
# Extract to extensions folder with unique name
base_name = zip_path.stem
dest_requirements = EXTENSIONS_DIR / f"{base_name}_requirements.txt"
with zip_ref.open(requirements_path) as src:
content = src.read().decode("utf-8")
dest_requirements.write_text(content)
log_lines.append(f"Extracted requirements to: {dest_requirements.name}")
log_lines.append(f"Requirements content:\n{content}")
# Install using pip
python_exe = get_python_executable()
log_lines.append(f"Installing requirements with: {python_exe}")
result = subprocess.run(
[python_exe, "-m", "pip", "install", "-r", str(dest_requirements)],
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
log_lines.append(f"pip stdout:\n{result.stdout}")
if result.stderr:
log_lines.append(f"pip stderr:\n{result.stderr}")
if result.returncode != 0:
log_lines.append(
f"ERROR: pip install failed with code {result.returncode}"
)
return False
log_lines.append("Requirements installed successfully")
return True
except subprocess.TimeoutExpired:
log_lines.append("ERROR: pip install timed out after 5 minutes")
return False
except Exception as e:
log_lines.append(f"ERROR: Requirements installation failed: {e}")
return False
def install_extension(zip_path: Path) -> bool:
"""
Install a single extension from a zip file.
Returns True if successful.
"""
zip_name = zip_path.name
# Extract friendly name (remove -main/-master suffix and .zip)
friendly_name = (
zip_name.replace("-main.zip", "").replace("-master.zip", "").replace(".zip", "")
)
log_lines = []
log_lines.append("=" * 60)
log_lines.append("ComfyUI_Viewer Extension Installation Log")
log_lines.append(f"Extension: {zip_name}")
log_lines.append(f"Date: {datetime.datetime.now().isoformat()}")
log_lines.append("=" * 60)
log_lines.append("")
print_progress(f"Installing: {friendly_name}", "HEADER")
# Step 1: Extract folders
print_progress("Extracting files...", "PROGRESS")
log_lines.append("Step 1: Extracting files")
log_lines.append("-" * 40)
if not extract_extension(zip_path, log_lines):
log_lines.append("\nINSTALLATION FAILED: Extraction error")
print_progress("Extraction failed", "ERROR")
return False
log_lines.append("")
# Step 2: Install requirements
print_progress("Installing dependencies...", "PROGRESS")
log_lines.append("Step 2: Installing requirements")
log_lines.append("-" * 40)
if not extract_and_install_requirements(zip_path, log_lines):
log_lines.append("\nINSTALLATION FAILED: Requirements error")
print_progress("Dependency installation failed", "ERROR")
return False
log_lines.append("")
log_lines.append("=" * 60)
log_lines.append("INSTALLATION COMPLETED SUCCESSFULLY")
log_lines.append("=" * 60)
# Write log file (marks as installed)
log_path = get_log_path(zip_name)
log_path.write_text("\n".join(log_lines))
print_progress("Installed successfully", "OK")
return True
def sync_extension_directories():
"""
Sync sibling ComfyUI_Viewer_* extension directories into ComfyUI_Viewer.
Extensions can be installed either from .zip files (handled by run_extension_installer)
or placed as sibling directories alongside ComfyUI_Viewer. This function handles the
latter by copying all extension assets into ComfyUI_Viewer so the node loader, parser
loader, and view loader can discover them.
Runs every startup to keep files in sync. Uses the same folder mapping as
EXTRACT_FOLDERS to stay consistent with zip-based installation.
"""
workspace_dir = SCRIPT_DIR.parent
if not workspace_dir.is_dir():
return
for entry in sorted(workspace_dir.iterdir()):
if not entry.name.startswith("ComfyUI_Viewer_") or not entry.is_dir():
continue
for source_folder, dest_folder in EXTRACT_FOLDERS.items():
ext_source = entry / source_folder
if not ext_source.is_dir():
continue
dest_folder.mkdir(parents=True, exist_ok=True)
# Walk the source tree and copy all files
for src_file in ext_source.rglob("*"):
if src_file.is_dir() or src_file.name.startswith("_"):
continue
rel = src_file.relative_to(ext_source)
dest_path = dest_folder / rel
try:
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_file, dest_path)
except Exception as e:
print_progress(
f"Failed to copy {rel} from {entry.name}: {e}", "WARN"
)
def update_extension_views_json():
"""
Update extension_views.json with list of installed extension view files.
This allows the view_loader to dynamically discover extension views.
Scans two locations:
1. Installed views in ComfyUI_Viewer/web/views/ (from zip installs)
2. Sibling ComfyUI_Viewer_* directories for dev/unzipped extensions
"""
views_dir = SCRIPT_DIR / "web" / "views"
# Core views from view_manifest.js (these are not extension views)
core_views = {
"canvas.js",
"html.js",
"svg.js",
"markdown.js",
"json.js",
"csv.js",
"yaml.js",
"ansi.js",
"python.js",
"javascript.js",
"css.js",
"object.js",
"text.js",
}
skip_files = {
"view_manifest.js",
"view_loader.js",
"base_view.js",
"code_scripts.js",
}
# Find all .js files in views folder that aren't core views or utility files
extension_views = []
seen = set()
if views_dir.exists():
for js_file in views_dir.glob("*.js"):
filename = js_file.name
if filename in core_views or filename in skip_files:
continue
extension_views.append(filename)
seen.add(filename)
# Scan sibling ComfyUI_Viewer_* extension directories for additional views
workspace_dir = SCRIPT_DIR.parent
if workspace_dir.is_dir():
for entry in sorted(workspace_dir.iterdir()):
if not entry.name.startswith("ComfyUI_Viewer_") or not entry.is_dir():
continue
ext_views_dir = entry / "web" / "views"
if not ext_views_dir.is_dir():
continue
for js_file in ext_views_dir.glob("*.js"):
filename = js_file.name
if filename in core_views or filename in skip_files or filename in seen:
continue
# Copy the view file into our views directory so the frontend can load it
dest = views_dir / filename
try:
shutil.copy2(js_file, dest)
extension_views.append(filename)
seen.add(filename)
except Exception as e:
print_progress(
f"Failed to copy extension view {filename} from {entry.name}: {e}",
"WARN",
)
# Write to JSON file
try:
EXTENSION_VIEWS_JSON.write_text(json.dumps(sorted(extension_views), indent=2))
except Exception as e:
print_progress(f"Failed to update extension_views.json: {e}", "WARN")
def run_extension_installer():
"""Main entry point - scan and install extensions."""
ensure_directories()
# Find all .zip files in extensions folder
zip_files = list(EXTENSIONS_DIR.glob("*.zip"))
if not zip_files:
return # No extensions to install, silent exit
# Filter to only uninstalled extensions
to_install = [z for z in zip_files if not is_installed(z.name)]
if not to_install:
return # All installed, silent exit
print("")
print("\033[95m ╔══════════════════════════════════════════════════╗\033[0m")
print(
"\033[95m ║\033[0m \033[1mComfyUI_Viewer Extension Installer\033[0m \033[95m║\033[0m"
)
print("\033[95m ╚══════════════════════════════════════════════════╝\033[0m")
print("")
print_progress(f"Found {len(to_install)} new extension(s) to install", "INFO")
print("")
success_count = 0
fail_count = 0
for i, zip_path in enumerate(to_install, 1):
if install_extension(zip_path):
success_count += 1
else:
fail_count += 1
print("")
if fail_count == 0:
print_progress(f"Done! {success_count} extension(s) installed", "OK")
else:
print_progress(f"Installed: {success_count}, Failed: {fail_count}", "WARN")
print("")
# Update extension views manifest for view_loader auto-discovery
if success_count > 0:
update_extension_views_json()
# Run when imported by ComfyUI
run_extension_installer()
# Sync sibling extension directories (nodes, parsers, web assets) into ComfyUI_Viewer
sync_extension_directories()
# Update extension views manifest for view_loader auto-discovery
update_extension_views_json()