""" MiniMax H3 Prompt Enhancer — GGUF planner node for MiniMax H3 (omni-modal video model). Planner model: Gemma 4 12B / any llama.cpp GGUF multimodal model. Output: H3-style prompts with multi-material references (image + video + audio). Materials are UPLOADED in the node panel (js/h3_panel.js), not wired as inputs: the front-end POSTs files to /upload/image and stores a JSON manifest in the hidden `media_manifest` widget. This module resolves those filenames from the ComfyUI input directory, decodes them (PIL / PyAV / torchaudio), feeds the planner, and passes them through as compact media_0..N outputs. """ import base64, gc, json, os, re, subprocess, sys, threading, time, atexit from io import BytesIO from typing import Dict import folder_paths import numpy as np import torch # Clean up orphan llama-server processes from crashed sessions try: subprocess.run(['taskkill', '/F', '/IM', 'llama-server.exe'], capture_output=True, timeout=5) except Exception: pass from PIL import Image _MODEL_ROOTS = [] for key in ("clip", "text_encoders"): for dp in folder_paths.get_folder_paths(key): if os.path.isdir(dp): _MODEL_ROOTS.append(dp) if not _MODEL_ROOTS: _MODEL_ROOTS = [os.path.join(folder_paths.models_dir, "clip"), os.path.join(folder_paths.models_dir, "text_encoders")] def _scan(): m: Dict[str, str] = {} for root in _MODEL_ROOTS: if not os.path.isdir(root): continue for dp, _, fns in os.walk(root): for fn in fns: if fn.endswith(".gguf"): rel = os.path.relpath(dp, root) if rel == ".": rel = "" display = f"{rel}\\{fn}" if rel else fn m[display] = os.path.join(dp, fn) return m def _list_models(mp): m = _scan() return sorted(k for k in m if ("mmproj" in k.lower()) == mp) def _resolve(d): if os.path.isfile(d): return d return _scan().get(d, d) _W = os.path.join(os.path.dirname(os.path.abspath(__file__)), "worker", "h3_worker.py") class _MLLM: def __init__(self, mp, mm, ctx, gpu, seed, thinking=False): self.mp = mp; self.mm = mm or ""; self.ctx = ctx; self.gpu = gpu; self.seed = seed self.thinking = thinking self.p = None; self.last_error = '' def _ensure(self): if self.p and self.p.poll() is None: return self.p = subprocess.Popen([sys.executable, "-u", _W], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", bufsize=1, env={**os.environ}) threading.Thread(target=self._pump_stderr, daemon=True).start() print(f"[H3PE] worker pid={self.p.pid}", flush=True) def _pump_stderr(self): """Forward useful worker logs (handler, loading, errors) to the ComfyUI console. Suppresses llama.cpp chat template rendering noise.""" _KEEP = ("using ", "loaded in", "handler=", "failed:", "error", "Traceback", "File ", "line ", "Error", "Exception") _NOISE = ("add_text:", "add_media:", "image_tokens->", "audio_tokens->", "batch_f32", "clip_image", "encoding ", "encoded in", "image decoded", "decoding ", "slice", "<|turn", "", "", "llama_kv_cache", "preproc_out", "grid_x", "mtmd_") try: for line in self.p.stderr: l = line.strip() if not l: continue if any(n in l for n in _NOISE): continue if any(k in l for k in _KEEP): pfx = "" if l.startswith("[h3-worker]") else "[h3-worker] " print(f"{pfx}{line}", end="", flush=True) except Exception: pass def _send(self, req, timeout=3600): self._ensure() self.p.stdin.write(json.dumps(req, ensure_ascii=False) + "\n") self.p.stdin.flush() box = {} def r(): try: box["l"] = self.p.stdout.readline() except: box["l"] = "" t = threading.Thread(target=r, daemon=True); t.start(); t.join(timeout) if t.is_alive(): self._kill(); return None resp = json.loads(box.get("l") or "{}") if not resp.get("ok"): self.last_error = resp.get('error', 'unknown') print(f"[H3PE] worker error: {self.last_error}", flush=True) return None self.last_error = '' return resp.get("text") def _kill(self): if self.p and self.p.poll() is None: try: self.p.kill() except: pass self.p = None def _pay(self, **kw): return dict(model_path=self.mp, mmproj_path=self.mm, n_ctx=self.ctx, n_gpu_layers=self.gpu, n_threads=0, use_mmap=True, use_mlock=False, seed=self.seed, verbose=False, **kw) def chat(self, msgs, mt=8192, temp=0.6, rp=1.15): return self._send({"cmd": "run", "payload": self._pay(messages=msgs, max_tokens=mt, temperature=temp, top_p=0.9, repeat_penalty=rp, enable_thinking=self.thinking)}) def unload(self): if self.p and self.p.poll() is None: try: self._send({"cmd": "exit"}) except: pass try: self.p.wait(timeout=10) except: self._kill() self.p = None def _t2p(img): """IMAGE (B,H,W,C float) -> list of PIL Images.""" if img is None: return [] t = img.detach().cpu().numpy() out = [] for i in range(t.shape[0]): a = np.clip(t[i] * 255.0, 0, 255).astype(np.uint8) out.append(Image.fromarray(a)) return out def _p2t(pil): """Single PIL Image -> IMAGE tensor (1,H,W,C float 0-1).""" a = np.asarray(pil.convert('RGB'), dtype=np.float32) / 255.0 return torch.from_numpy(a).unsqueeze(0) def _b64(pil, max_side): if max_side and max_side > 0: w, h = pil.size scale = max_side / max(w, h) if scale < 1.0: pil = pil.resize((max(1, int(w*scale)), max(1, int(h*scale))), Image.LANCZOS) buf = BytesIO() pil.save(buf, format="PNG") return base64.b64encode(buf.getvalue()).decode() def _sample(pils, n): if not pils: return [] if len(pils) <= n: return pils idx = np.linspace(0, len(pils)-1, n).round().astype(int) return [pils[i] for i in idx] def _smart_sample(pils, n): """智能均帧(与 Qwen3.5 节点一致):64x64 缩略图做相邻帧差异, 首帧必留,其余按画面变化幅度选变化最大的 n-1 帧(运动/切换处优先)。""" if not pils: return [] if len(pils) <= n: return pils ts = 64 thumbs = [np.asarray(im.resize((ts, ts), Image.NEAREST), np.float32) for im in pils] diffs = [np.abs(thumbs[i] - thumbs[i-1]).mean() for i in range(1, len(pils))] scores = [(max(diffs[i-1], diffs[i]), i) if i < len(pils)-1 else (diffs[i-1], i) for i in range(1, len(pils))] scores.sort(key=lambda x: x[0], reverse=True) picked = {0} for _, i in scores: if len(picked) >= n: break picked.add(i) return [pils[i] for i in sorted(picked)] # ------------------------------------------------------- uploaded media I/O _IMG_EXTS = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'} _AUD_EXTS = {'.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac'} _VID_EXTS = {'.mp4', '.webm', '.mov', '.mkv', '.avi', '.m4v'} def _media_path(entry): """Manifest entry -> absolute path under the ComfyUI input directory.""" if isinstance(entry, dict): name = entry.get('name', '') sub = entry.get('subfolder', '') else: name, sub = str(entry), '' if not name: return None rel = os.path.join(sub, name) if sub else name base = folder_paths.get_input_directory() path = os.path.abspath(os.path.join(base, os.path.normpath(rel))) if os.path.commonpath((os.path.abspath(base), path)) != os.path.abspath(base): return None return path if os.path.isfile(path) else None def _load_image_pils(path): try: im = Image.open(path).convert('RGB') return [im] except Exception as e: print(f'[H3PE] image load failed {path}: {e}', flush=True) return [] def _load_video_pils_and_obj(path): """Decode video via PyAV (ComfyUI's VideoFromFile). Returns (pils, VideoFromFile, real_fps, seconds).""" try: from comfy_api.latest._input_impl.video_types import VideoFromFile vf = VideoFromFile(path) comp = vf.get_components() imgs = comp.images t = imgs.detach().cpu().numpy() if hasattr(imgs, 'detach') else np.asarray(imgs) pils = [] for i in range(t.shape[0]): a = t[i] if a.dtype != np.uint8: a = np.clip(a * 255.0, 0, 255).astype(np.uint8) pils.append(Image.fromarray(a)) real_fps, seconds = 24.0, 0.0 try: real_fps = float(comp.frame_rate) if comp.frame_rate else 24.0 seconds = vf.get_duration() except Exception: seconds = len(pils) / real_fps if real_fps else 0.0 if not seconds: seconds = len(pils) / (real_fps or 24.0) return pils, vf, (real_fps or 24.0), seconds except Exception as e: print(f'[H3PE] video load failed {path}: {e}', flush=True) return [], None, 24.0, 0.0 def _sample_by_fps(pils, fps, seconds, max_seconds): """Uniform sampling at `fps` fps, capped to max_seconds of content. Frame count = fps * min(seconds, max_seconds) + 1 (first frame inclusive).""" if not pils: return [] total = len(pils) use_seconds = min(seconds, max_seconds) if (max_seconds and max_seconds > 0) else seconds n = max(1, int(round(fps * use_seconds)) + 1) n = min(n, total) idx = np.linspace(0, total - 1, n).round().astype(int) return [pils[i] for i in idx] def _load_audio(path, trim=None): """Audio file -> ComfyUI AUDIO dict {waveform, sample_rate}. Optional trim {start,end} seconds. Loader chain: scipy(wav) -> soundfile(flac/ogg/mp3) -> torchaudio(fallback).""" wf, sr = None, None ext = os.path.splitext(path)[1].lower() try: if ext == '.wav': import scipy.io.wavfile as _wv sr, data = _wv.read(path) a = data.astype(np.float32) if a.ndim == 2: a = a.T # (samples,ch) -> (ch,samples) if a.ndim == 1: a = a[None, :] mx = float(np.iinfo(data.dtype).max) if data.dtype.kind == 'i' else 1.0 wf = torch.from_numpy(a / (mx if mx else 1.0)) else: import soundfile as _sf a, sr = _sf.read(path, dtype='float32', always_2d=True) wf = torch.from_numpy(a.T) # (samples,ch) -> (ch,samples) except Exception: try: import torchaudio wf, sr = torchaudio.load(path) except Exception as e: print(f'[H3PE] audio load failed {path}: {e}', flush=True) return None try: wf = wf.float() if wf.ndim == 1: wf = wf.unsqueeze(0) if wf.shape[0] > 2: wf = wf[:2] if trim: s = max(0, int(float(trim.get('start', 0)) * sr)) e0 = float(trim.get('end', 0)) e = int(e0 * sr) if e0 > 0 else wf.shape[-1] e = min(e, wf.shape[-1]) if e > s: wf = wf[..., s:e] return {'waveform': wf.unsqueeze(0).float(), 'sample_rate': int(sr)} except Exception as e: print(f'[H3PE] audio load failed {path}: {e}', flush=True) return None def _parse_manifest(s): if not s: return {} try: d = json.loads(s) return d if isinstance(d, dict) else {} except Exception: return {} # ---------------------------------------------------------------- H3 templates # # H3 specs: 5-15s output, 24FPS, native stereo audio, aspect 21:9/16:9/4:3/1:1/3:4/9:16 # Material limits: <=9 images + <=3 videos + <=3 audios (12 files max) SYSTEM = ("You are an H3 video prompt planner. Observe materials, analyze roles, " "then output structured sections. The prompt text (EXECUTE, FINAL_PROMPT, " "soundscape, music) must be written entirely in English. ") def _build_msgs(sp, ut, imgs, labels=None): """imgs: list of base64 strings. labels: optional per-image labels.""" if not imgs: return [{"role": "system", "content": sp}, {"role": "user", "content": ut}] content = [] if labels is None: labels = [f"Ref-{i}" for i in range(len(imgs))] for i, b in enumerate(imgs): content.append({"type": "text", "text": f"[{labels[i]}]:\n"}) content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b}"}}) content.append({"type": "text", "text": "\nAbove are the reference materials. Observe them."}) return [{"role": "system", "content": sp}, {"role": "user", "content": content}, {"role": "user", "content": ut}] def _route(tt, prompt, n_src=0, n_img=0, n_vid=0, audio_files=None, vid_nums=None, img_nums=None, aud_nums=None): """Builds dynamic template. Numbering matches official H3 Reference node: 1-based (Image 1, Video 1, Audio 1). vid_nums/img_nums/aud_nums are the user's manifest numbers (may have gaps after deletions). 节点特色:先观察思考(OBSERVATION/UNDERSTAND)再规划增强(EXECUTE)。 素材只按类型中性列出(Video N/Picture N/Audio N),角色分工不设死—— 模型根据实际画面内容和用户 @引用 在 UNDERSTAND 里自由判断。""" vnums = vid_nums or [] inums = img_nums or [] anums = aud_nums or (list(range(1, len(audio_files) + 1)) if audio_files else []) # ---- neutral material list with H3-style role hints ------------------------ items = [] if n_src: items.append(f"