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)