Files
nic-schi-ComfyUI-OpenImage/open_image_node.py
T
2025-12-13 20:25:00 +01:00

433 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import shutil
def find_program(possible_paths, exe_name):
for path in possible_paths:
expanded = os.path.expandvars(path)
if os.path.isfile(expanded):
return expanded
# fallback: check in PATH
return shutil.which(exe_name)
# --- Module-level helpers & settings ------------------------------------
# Choices for the UI dropdown
PROGRAM_CHOICES = [
"Default-Program",
"Default-Browser",
"Firefox",
"Chrome",
"Microsoft Edge",
"Paint",
"Paint.NET",
"Adobe Photoshop",
]
# Resolve known program executable paths once at import time
PROGRAM_MAP = {
"Firefox": find_program([
r"C:\Program Files\Mozilla Firefox\firefox.exe",
r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"
], "firefox.exe"),
"Chrome": find_program([
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"
], "chrome.exe"),
"Microsoft Edge": find_program([
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"
], "msedge.exe"),
# "VLC Media Player": find_program([
# r"C:\Program Files\VideoLAN\VLC\vlc.exe",
# r"C:\Program Files (x86)\VideoLAN\VLC\vlc.exe"
# ], "vlc.exe"),
"Paint": find_program([
"%LOCALAPPDATA%\Microsoft\WindowsApps\mspaint.exe",
r"C:\Windows\System32\mspaint.exe",
], "mspaint.exe"),
"Paint.NET": find_program([
r"C:\Program Files\Paint.NET\paintdotnet.exe"
], "paintdotnet.exe"),
"Adobe Photoshop": find_program([
r"C:\Program Files\Adobe\Adobe Photoshop\Photoshop.exe",
r"C:\Program Files (x86)\Adobe\Adobe Photoshop\Photoshop.exe"
], "Photoshop.exe"),
}
def get_browser_executables():
"""Return a list of available browser executable paths in preference order.
This extends beyond PROGRAM_MAP to include Brave, Opera, Vivaldi, Chromium.
Computed at call time to pick up PATH-installed browsers too.
"""
browsers = []
def add_if_found(paths, exe):
p = find_program(paths, exe)
if p and os.path.isfile(p) and p not in browsers:
browsers.append(p)
# Edge / Chrome / Firefox first (match PROGRAM_MAP locations + PATH)
add_if_found([
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
], "msedge.exe")
add_if_found([
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
], "chrome.exe")
add_if_found([
r"C:\Program Files\Mozilla Firefox\firefox.exe",
r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe",
], "firefox.exe")
# Brave
add_if_found([
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
r"C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe",
r"%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe",
], "brave.exe")
# Opera and Opera GX (launcher.exe is the typical entry)
add_if_found([
r"C:\Program Files\Opera\launcher.exe",
r"C:\Program Files (x86)\Opera\launcher.exe",
r"%LOCALAPPDATA%\Programs\Opera\launcher.exe",
], "opera.exe")
add_if_found([
r"%LOCALAPPDATA%\Programs\Opera GX\launcher.exe",
], "opera.exe")
# Vivaldi
add_if_found([
r"C:\Program Files\Vivaldi\Application\vivaldi.exe",
r"C:\Program Files (x86)\Vivaldi\Application\vivaldi.exe",
], "vivaldi.exe")
# Chromium
add_if_found([
r"C:\Program Files\Chromium\Application\chromium.exe",
r"C:\Program Files (x86)\Chromium\Application\chromium.exe",
], "chromium.exe")
return browsers
def detect_default_browser_exe():
"""Detect the default browser executable on Windows by reading registry.
Returns absolute path to the browser executable if detected, else None.
"""
if os.name != 'nt':
return None
try:
import winreg # type: ignore
def _read_default(key_path: str):
try:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, key_path) as k:
val, _ = winreg.QueryValueEx(k, None)
return val
except OSError:
return None
# Get ProgId for http/https
prog_id = None
for scheme in ("http", "https"):
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
fr"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice",
) as key:
prog_id, _ = winreg.QueryValueEx(key, "ProgId")
if prog_id:
break
except OSError:
continue
# If no ProgId, fall back to the default handler under HKCR for http
command = None
if prog_id:
command = _read_default(fr"{prog_id}\shell\open\command")
if not command:
command = _read_default(r"http\shell\open\command")
if not command:
return None
# Parse the executable path from the command string
cmd = command.strip()
exe_path = None
if cmd.startswith('"'):
# path is between first pair of quotes
end = cmd.find('"', 1)
if end != -1:
exe_path = cmd[1:end]
if not exe_path:
# take up to .exe
lower = cmd.lower()
idx = lower.find('.exe')
if idx != -1:
exe_path = cmd[: idx + 4]
if exe_path and os.path.isfile(exe_path):
return exe_path
# If parsing didn't yield a valid file, try mapping by ProgId keywords
if prog_id:
name = prog_id.upper()
if "CHROME" in name:
return PROGRAM_MAP.get("Chrome")
if "EDGE" in name or "MSEDGE" in name:
return PROGRAM_MAP.get("Microsoft Edge")
if "FIREFOX" in name:
return PROGRAM_MAP.get("Firefox")
except Exception:
return None
return None
def generate_unique_filename(idx, ext=".png"):
"""Generate a unique, ordered filename using an index prefix and a time-based hash.
Result looks like: 0001_<40-hex-sha1>.png
Using the index as a prefix keeps album ordering stable while ensuring uniqueness.
"""
import time
import uuid
import hashlib
# Combine high-resolution time with a random salt and the index, then hash
raw = f"{time.time_ns()}_{uuid.uuid4().hex}_{idx}".encode("utf-8")
digest = hashlib.sha1(raw).hexdigest()
return f"{idx+1:04d}_{digest}{ext}"
def create_temp_image_files(image):
"""Converts a ComfyUI image tensor to PNG files in a unique temp directory.
Supports single images and batches. Returns (temp_dir, [file_paths])."""
import PIL.Image as Image
import numpy as np
import tempfile
import uuid
# Prepare temp directory unique to this invocation
base_temp = tempfile.gettempdir()
temp_dir = os.path.join(base_temp, f"openimage_{uuid.uuid4().hex}")
os.makedirs(temp_dir, exist_ok=True)
# Convert to numpy
try:
arr = image.cpu().numpy()
except AttributeError:
# Fallback if already numpy array
arr = image
# Normalize and handle shape
# Expected shapes: [B,H,W,C] or [H,W,C]. Values assumed in [0,1].
if getattr(arr, 'ndim', None) == 3:
arr = arr[None, ...] # add batch dim
if getattr(arr, 'ndim', None) != 4:
return temp_dir, []
file_paths = []
for idx in range(arr.shape[0]):
frame = arr[idx]
# Scale to uint8 and clamp
import numpy as _np # local alias to avoid shadowing
img_np = _np.clip(255.0 * frame, 0, 255).astype(_np.uint8)
# Some pipelines may store channels first/last; assume last as per ComfyUI [H,W,C]
# If needed, adjust when detected as [C,H,W]
if img_np.ndim == 3 and img_np.shape[0] in (1, 3, 4) and img_np.shape[2] not in (1, 3, 4):
# Likely [C,H,W] -> transpose to [H,W,C]
img_np = img_np.transpose(1, 2, 0)
# Drop extra channels if any
if img_np.shape[2] > 4:
img_np = img_np[:, :, :4]
img = Image.fromarray(img_np)
filename = generate_unique_filename(idx, ext=".png")
file_path = os.path.join(temp_dir, filename)
img.save(file_path)
file_paths.append(file_path)
return temp_dir, file_paths
def create_temp_image_file(image):
"""Legacy helper: saves only a single image to a temp file and returns the path.
Kept for backward compatibility; the new logic uses create_temp_image_files."""
temp_dir, files = create_temp_image_files(image)
return files[0] if files else None
def resolve_executable(program, custom_executable):
"""Resolves which executable to use. Returns (exe_path, use_browser)."""
exe_path = None
use_browser = False
# Custom executable path takes precedence
if custom_executable and os.path.isfile(custom_executable):
exe_path = custom_executable
else:
if program == "Default-Browser":
use_browser = True
else:
exe_path = PROGRAM_MAP.get(program)
return exe_path, use_browser
def open_in_default_browser(target_path_or_url):
"""Open a local file path or URL in the system default browser, reliably.
- Properly converts Windows paths to a file URI using Path.as_uri()
so spaces and backslashes are handled.
- Uses open_new_tab to force tabbed behavior when possible.
- Falls back to os.startfile on Windows if the webbrowser module fails.
"""
import os
import webbrowser
from pathlib import Path
url: str
t = target_path_or_url
# Convert local file paths to a valid file:// URI
if isinstance(t, str) and not t.lower().startswith(("http://", "https://", "file://")):
try:
url = Path(t).resolve().as_uri()
except Exception:
# Last‑resort formatting; replace backslashes for URI compatibility
p = str(Path(t).resolve()).replace("\\", "/")
if not p.startswith("/"):
# Ensure an absolute path has a leading slash in the URI component on Windows
p = "/" + p
url = f"file://{p}"
else:
url = t
# On Windows, prefer launching the default browser executable directly
try:
if os.name == 'nt':
exe = detect_default_browser_exe()
if exe and os.path.isfile(exe):
import subprocess
subprocess.Popen([exe, url])
return
except Exception:
pass
# Try Python's webbrowser controller next (may fall back to file handler for file://)
try:
if webbrowser.open_new_tab(url):
return
except Exception:
pass
# Try common browsers explicitly (broader list, includes Brave/Opera/Vivaldi)
try:
import subprocess
for exe2 in get_browser_executables():
try:
subprocess.Popen([exe2, url])
return
except Exception:
continue
except Exception:
pass
# Last resort: fall back to the OS default handler
try:
if os.name == 'nt':
os.startfile(url)
else:
import subprocess
subprocess.Popen(['xdg-open', url])
except Exception:
# Give up silently; ComfyUI shouldn't crash because the browser couldn't open
pass
def open_with_specific_executable(targets, exe_path):
"""Open one or many targets (file paths) with a specific executable."""
import subprocess
if isinstance(targets, str):
args = [exe_path, targets]
else:
args = [exe_path] + list(targets)
subprocess.Popen(args)
def open_with_system_default(file_path):
"""Opens the file using the OS default associated application."""
import subprocess
if os.name == 'nt':
os.startfile(file_path)
else:
subprocess.Popen(['xdg-open', file_path])
def open_album_with_system_default(file_paths):
"""Opens the first image with the system default. Most viewers will let the user browse the folder."""
if not file_paths:
return
open_with_system_default(file_paths[0])
class OpenImageNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
},
"optional": {
# Proper dropdown list so it shows in the UI
"program": (PROGRAM_CHOICES, {"default": "Default-Browser"}),
"custom_executable": ("STRING", {"default": "", "file_path": True, "file_ext": "exe"}),
},
}
CATEGORY = "image"
RETURN_TYPES = ()
FUNCTION = "execute_node"
OUTPUT_NODE = True
def execute_node(self, images, program="Default-Browser", custom_executable=""):
"""
Entry point for the node execution.
Splits responsibilities into smaller helper functions.
"""
# Create temp files (supports single image or batches)
_temp_dir, file_paths = create_temp_image_files(images)
exe_path, use_browser = resolve_executable(program, custom_executable)
# Nothing to open
if not file_paths:
return ()
# Browsers: open one image per tab
if use_browser or program in ("Firefox", "Chrome", "Microsoft Edge"):
if use_browser or not exe_path:
# Use system default browser (or fallback if specific browser not found)
for fp in file_paths:
open_in_default_browser(fp)
else:
# Specific browser executable: pass all files so they open as separate tabs
open_with_specific_executable(file_paths, exe_path)
else:
# Specific executable or Default-Program (system default)
if exe_path and os.path.isfile(exe_path):
# Try to open all files with the selected program (many apps support multiple args)
open_with_specific_executable(file_paths, exe_path)
else:
# Default-Program: open first image; most viewers will let user browse folder
open_album_with_system_default(file_paths)
return ()
# Backwards-compat: keep the old entry but delegate to the new split logic
def open_image(self, image, program="Default-Browser", custom_executable=""):
return self.execute_node(image, program, custom_executable)
# (No class-level helpers; all helpers are module-level now)