# -*- coding: utf-8 -*- import os import re import io import json import base64 import hashlib import sqlite3 import urllib.request import urllib.error import numpy as np import folder_paths import comfy.sd import comfy.lora import comfy.utils from safetensors import safe_open from comfy_execution.graph_utils import ExecutionBlocker try: from PIL import Image _HAS_PIL = True except Exception: _HAS_PIL = False CACHE_FILE = os.path.join(os.path.dirname(__file__), "dolphin_tags_cache.json") # ====================================================================== # 모델 프리셋 (텍스트 LLM = 4샷 분배 / 비전 VLM = 이미지 인식) # ====================================================================== LLM_PRESETS = { "🔞 DeepSeek V3.2 (무삭제·JSON안정·추천)": "deepseek/deepseek-v3.2", "🔞 Magnum v4 72B (표현 자연스러움)": "anthracite-org/magnum-v4-72b", "🔞 Cydonia 24B v4.1 (경량·빠름)": "thedrummer/cydonia-24b-v4.1", "🔞 Dolphin Mistral Venice (무료)": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", "⚙️ custom (아래 텍스트 사용)": "__custom__", } LLM_PRESET_LABELS = list(LLM_PRESETS.keys()) # 비전 모델: 특수 상황 이해 + 다국어. Qwen2.5-VL 계열이 포즈/맥락 파악에 강함. VLM_PRESETS = { "👁 Qwen2.5-VL 72B (특수상황·추천)": "qwen/qwen2.5-vl-72b-instruct", "👁 Qwen2.5-VL 32B (균형)": "qwen/qwen2.5-vl-32b-instruct", "👁 Qwen2.5-VL 32B (무료)": "qwen/qwen2.5-vl-32b-instruct:free", "👁 Mistral Small 3.1 24B (Pixtral)": "mistralai/mistral-small-3.1-24b-instruct", "👁 Gemma 3 27B (경량)": "google/gemma-3-27b-it", "⚙️ custom (아래 텍스트 사용)": "__custom__", } VLM_PRESET_LABELS = list(VLM_PRESETS.keys()) def resolve_slug(table, label, custom_text, default): slug = table.get(label, "__custom__") if slug == "__custom__": return custom_text.strip() or default return slug def resolve_api_key(widget_value): """위젯에 키가 있으면 그대로 사용. 비어있으면 OPENROUTER_API_KEY 환경변수로 폴백. -> 워크플로우 JSON(공유/백업 시 유출 위험)에 실키를 박아둘 필요가 없어짐.""" key = (widget_value or "").strip() if key: return key return os.environ.get("OPENROUTER_API_KEY", "").strip() # ====================================================================== # 캐시 & 트리거 (검증된 로직 재사용) # ====================================================================== def _load_cache(): if os.path.exists(CACHE_FILE): try: with open(CACHE_FILE, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, OSError): return {} return {} def _save_cache(cache): try: with open(CACHE_FILE, "w", encoding="utf-8") as f: json.dump(cache, f, indent=4, ensure_ascii=False) except OSError: pass def _file_sig(path): st = os.stat(path) return f"{os.path.basename(path)}::{st.st_size}::{int(st.st_mtime)}" def _sha256(path): h = hashlib.sha256() with open(path, "rb") as f: for block in iter(lambda: f.read(4096 * 1024), b""): h.update(block) return h.hexdigest() # CivitAI 및 미러(civitaired) 해시 조회 엔드포인트. 앞에서부터 순서대로 시도. TRIGGER_HASH_APIS = [ "https://civitai.com/api/v1/model-versions/by-hash/{h}", "https://civitaired.com/api/v1/model-versions/by-hash/{h}", ] # LoRA Manager(comfyui-lora-manager)가 스캔 시 만들어두는 로컬 SQLite 캐시. # sha256으로 trained_words를 즉시 조회 가능 -> 네트워크 호출 없이 트리거워드 확보. LORA_MANAGER_DB = os.path.join( os.environ.get("LOCALAPPDATA", ""), "ComfyUI-LoRA-Manager", "cache", "model", "comfyui.sqlite") def _query_lora_manager_db(digest): """LoRA Manager SQLite 캐시에서 sha256으로 trained_words 조회. 실패/미발견 시 None.""" if not digest or not os.path.exists(LORA_MANAGER_DB): return None try: uri = f"file:{LORA_MANAGER_DB}?mode=ro" con = sqlite3.connect(uri, uri=True, timeout=3) try: cur = con.cursor() cur.execute( "SELECT trained_words FROM models WHERE model_type='lora' AND sha256=? LIMIT 1", (digest,)) row = cur.fetchone() finally: con.close() if not row or not row[0]: return None tw = json.loads(row[0]) return tw if isinstance(tw, list) else None except (sqlite3.Error, OSError, ValueError, json.JSONDecodeError) as e: print(f"⚠️ [Director] LoRA Manager DB 조회 실패: {type(e).__name__}: {e}") return None def _query_trigger_api(url): """단일 해시 조회 엔드포인트에서 trainedWords 리스트를 반환. 실패 시 ([], 사유).""" try: req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=8) as r: data = json.loads(r.read().decode('utf-8')) return (data.get("trainedWords", []) or []), None except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError, ValueError, json.JSONDecodeError) as e: return [], f"{type(e).__name__}: {e}" def get_triggers(lora_name, cache): if lora_name == "None": return [] path = folder_paths.get_full_path("loras", lora_name) if not path or not os.path.exists(path): return [] try: with safe_open(path, framework="pt", device="cpu") as f: m = f.metadata() if m and "modelspec.trigger_words" in m: tw = json.loads(m["modelspec.trigger_words"]) if isinstance(tw, list): return tw if isinstance(tw, str): return [tw] except (OSError, ValueError, json.JSONDecodeError): pass try: sig = _file_sig(path) except OSError: sig = lora_name # 캐시에 '비어있지 않은' 결과가 있을 때만 신뢰. 과거에 실패로 저장된 빈 값은 # 무시하고 재조회한다 (빈 값 영구화 회귀 방지). cached = cache.get(sig) if cached: return cached # 해시는 1회만 계산해서 LoRA Manager DB -> CivitAI -> civitaired 순으로 조회. result, base = [], os.path.basename(path) try: digest = _sha256(path) except OSError as e: print(f"⚠️ [Director] 해시 계산 실패({base}): {e}") digest = None if digest: found = _query_lora_manager_db(digest) if found: print(f"✅ [Director] 트리거 조회 성공({base}) @ LoRA Manager DB: {found}") result = found if digest and not result: for tmpl in TRIGGER_HASH_APIS: host = tmpl.split('/')[2] found, err = _query_trigger_api(tmpl.format(h=digest)) if found: print(f"✅ [Director] 트리거 조회 성공({base}) @ {host}: {found}") result = found break print(f"⚠️ [Director] 트리거 조회 실패({base}) @ {host}: {err or 'no trainedWords'}") # 성공(비어있지 않음)일 때만 캐싱. 빈 결과는 저장하지 않아 다음 실행에서 재시도됨. if result: cache[sig] = result _save_cache(cache) return result def apply_model_lora(model, lora_data, weight): try: return comfy.sd.load_lora_for_models(model, None, lora_data, weight, 0)[0] except (AttributeError, TypeError): new_model = model.clone() key_map = comfy.lora.model_lora_keys_unet(new_model.model) loaded = comfy.lora.load_lora(lora_data, key_map) new_model.add_patches(loaded, weight) return new_model def tensor_to_base64(image_tensor, max_side=1024): """ComfyUI IMAGE 텐서(B,H,W,C, 0~1 float) 첫 프레임을 JPEG base64 data URI로.""" if not _HAS_PIL: print("❌ [Director] PIL(Pillow) 없음 - 비전 불가. `pip install Pillow` 필요.") return None if image_tensor is None: print("❌ [Director] image 입력이 None - IMAGE 연결 확인.") return None try: img = image_tensor if hasattr(img, "cpu"): img = img.cpu().numpy() img = np.asarray(img) if img.ndim == 4: img = img[0] arr = np.clip(img * 255.0, 0, 255).astype(np.uint8) pil = Image.fromarray(arr) w, h = pil.size if max(w, h) > max_side: scale = max_side / float(max(w, h)) pil = pil.resize((int(w * scale), int(h * scale)), Image.LANCZOS) buf = io.BytesIO() pil.convert("RGB").save(buf, format="JPEG", quality=90) b64 = base64.b64encode(buf.getvalue()).decode("utf-8") print(f"🖼 [Director] image encoded ok: {w}x{h} -> {pil.size}, {len(b64)//1024}KB base64") return f"data:image/jpeg;base64,{b64}" except Exception as e: print(f"❌ [Director] image encode failed: {type(e).__name__}: {e}") return None # ====================================================================== # Vision Director: 이미지 인식 + 필수동작 실행 + 클립당 4로라 # ====================================================================== class DolphinVisionDirector: """ 이미지 하나와 '필수 동작'과 로라만 넣으면 4개 연속 클립이 완성되는 올인원 노드. 1) IMAGE -> 비전 VLM 이 상황(캐릭터 상태/자세/환경)만 앵커로 파악 2) 그 맥락 위에서 '내가 준 필수 동작(essential_actions)'만 4샷으로 분배 3) 클립마다 로라 4개씩 개별 적용(웨이트도 개별) + 트리거 자동 주입 """ @classmethod def INPUT_TYPES(s): loras = ["None"] + (folder_paths.get_filename_list("loras") or []) req = { "image": ("IMAGE",), "model_base": ("MODEL",), "model_high": ("MODEL",), "model_low": ("MODEL",), "essential_actions": ("STRING", {"multiline": True, "default": ("돌진한다, 검을 휘둘러 적을 쓰러뜨린다, 총알을 튕겨낸다, 숨통을 끊는다")}), "num_clips": (["4 (20s)", "3 (15s)", "2 (10s)"], {"default": "4 (20s)", "tooltip": "실제로 렌더링할 클립 수. Extend range(Fast Groups Bypasser) 토글과 맞춰서 " "설정해야 스토리가 그 길이에 맞게 배분됨. 나머지 클립 슬롯은 마지막 클립 내용을 반복."}), "character_name": ("STRING", {"default": "AUTO"}), "artistic_vibe": ("STRING", {"multiline": True, "default": "cinematic lighting, dynamic motion"}), "pacing": (["auto", "slow-burn (1 beat)", "balanced (multi beat)", "rapid (4 distinct)"], {"default": "auto"}), "detail_level": (["짧게(간결)", "필수+앵커만", "상세"], {"default": "필수+앵커만"}), "inject_triggers": ("BOOLEAN", {"default": True, "label_on": "TRIGGERS ON", "label_off": "TRIGGERS OFF"}), "use_vision": ("BOOLEAN", {"default": True, "label_on": "VISION ON", "label_off": "VISION OFF (text only)"}), "openrouter_api_key": ("STRING", {"default": ""}), "vlm_preset": (VLM_PRESET_LABELS, {"default": "👁 Qwen2.5-VL 72B (특수상황·추천)"}), "vlm_model": ("STRING", {"default": "qwen/qwen2.5-vl-72b-instruct"}), "llm_preset": (LLM_PRESET_LABELS, {"default": "🔞 DeepSeek V3.2 (무삭제·JSON안정·추천)"}), "llm_model": ("STRING", {"default": "deepseek/deepseek-v3.2"}), "creativity": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.5, "step": 0.05}), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), } opt = {} for c in range(1, 5): for n in range(1, 5): opt[f"c{c}_lora_{n}"] = (loras, {"default": "None"}) opt[f"c{c}_w_{n}"] = ("STRING", {"default": "1.0, 1.0, 1.0", "multiline": False}) opt["manual_triggers"] = ("STRING", {"default": "", "multiline": True}) opt["vision_caption"] = ("STRING", {"multiline": True, "default": "", "forceInput": True}) return {"required": req, "optional": opt} RETURN_TYPES = ( "MODEL", "MODEL", "MODEL", "STRING", "MODEL", "MODEL", "MODEL", "STRING", "MODEL", "MODEL", "MODEL", "STRING", "MODEL", "MODEL", "MODEL", "STRING", "STRING", "STRING", "STRING", "BOOLEAN", "BOOLEAN", "BOOLEAN", "BOOLEAN", ) RETURN_NAMES = ( "c1_base", "c1_high", "c1_low", "c1_prompt", "c2_base", "c2_high", "c2_low", "c2_prompt", "c3_base", "c3_high", "c3_low", "c3_prompt", "c4_base", "c4_high", "c4_low", "c4_prompt", "triggers", "scene_analysis", "debug_info", "c1_is_final", "c2_is_final", "c3_is_final", "c4_is_final", ) FUNCTION = "direct" CATEGORY = "Dolphin" def _build_final(self, action, master_scene, name, trigger_str): head = ", ".join([p for p in [name, trigger_str] if p]) body = " ".join([p for p in [master_scene.strip().strip(",. "), action.strip()] if p]) if head and body: return f"{head}\n{body}" return head or body def _pacing_hint(self, p): return { "auto": "Decide pacing yourself based on how many distinct beats the actions contain.", "slow-burn (1 beat)": "One climactic beat: clips 1-3 build tension, clip 4 detonates.", "balanced (multi beat)": "Early clips build, later clips deliver; keep motion continuous.", "rapid (4 distinct)": "Four distinct consecutive actions, one per clip, each kinetic.", }.get(p, "Decide pacing yourself.") def _detail_hint(self, d): return { "짧게(간결)": "Keep each clip to a short punchy motion phrase. Pure movement verbs only.", "필수+앵커만": "Each clip is one concise continuous motion. No description, only movement.", "상세": "Motion may be described in more detail, but ONLY body movement — no scenery.", }.get(d, "Motion only, no description.") def _extract_json(self, text): if not text: return None text = re.sub(r'```(?:json)?', '', text).strip() a, b = text.find('{'), text.rfind('}') if a == -1 or b <= a: return None blob = text[a:b + 1] try: return json.loads(blob) except json.JSONDecodeError: try: return json.loads(re.sub(r',\s*([}\]])', r'\1', blob)) except json.JSONDecodeError: return None def _call_or(self, messages, model, key, temp, timeout=120, force_json=False): payload = {"model": model.strip(), "messages": messages, "temperature": temp} if force_json: payload["response_format"] = {"type": "json_object"} req = urllib.request.Request( "https://openrouter.ai/api/v1/chat/completions", data=json.dumps(payload).encode('utf-8'), headers={'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'}) raw = json.loads(urllib.request.urlopen(req, timeout=timeout).read().decode('utf-8')) if "choices" not in raw: err = raw.get("error") or raw msg = err.get("message") if isinstance(err, dict) else str(err) code = err.get("code") if isinstance(err, dict) else "?" raise ValueError(f"API error (code={code}): {msg}") return raw['choices'][0]['message']['content'].strip() def _vision_analyze(self, data_uri, vlm_model, key, temp): sys = ("You are a visual scene analyst for a video pipeline. " "Look at the image and extract ONLY concrete anchors, no storytelling. " "Report exactly these lines:\n" "(1) character appearance & clothing.\n" "(2) current pose/posture (sitting, crouched, standing, balance, limb positions).\n" "(3) SUPPORT & CONTACT: what the character is on top of, sitting on, holding, " "leaning against, or touching (e.g. 'seated balanced on a yoga ball', " "'gripping a sword', 'kneeling on the floor'). This is critical for physics — " "state it explicitly even if obvious.\n" "(4) environment & lighting (brief).\n" "Be terse, one short line each. English only. " "Do not invent actions or narrative. If content is mature, describe it plainly and factually.") messages = [ {"role": "system", "content": sys}, {"role": "user", "content": [ {"type": "text", "text": "Extract the anchors from this image."}, {"type": "image_url", "image_url": {"url": data_uri}}, ]}, ] import time last_err = "vision error" for attempt in range(1, 4): try: resp = self._call_or(messages, vlm_model, key, temp, timeout=90).strip() if not resp: print(f"⚠️ [Director] VLM({vlm_model}) 응답이 비었음 (거부 or 빈 반환)") return "", "vision empty response" print(f"👁 [Director] VLM({vlm_model}) anchors (attempt {attempt}):\n{resp[:300]}") return resp, None except urllib.error.HTTPError as e: body = "" try: body = e.read().decode('utf-8')[:200] except Exception: pass print(f"❌ [Director] VLM HTTP {e.code} (attempt {attempt}): {body}") last_err = f"vision HTTP {e.code}" if e.code in (429, 503) and attempt < 3: wait = 2 ** attempt print(f"⏳ [Director] rate limited, {wait}s 후 재시도...") time.sleep(wait) continue return "", last_err except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as e: print(f"❌ [Director] VLM error (attempt {attempt}): {type(e).__name__}: {e}") last_err = f"vision error: {e}" if attempt < 3: time.sleep(2 ** attempt) continue return "", last_err return "", last_err def _fallback_name(self, anchors): import random a = (anchors or "").lower() east = ["asian", "east asian", "japanese", "korean", "chinese", "anime", "kimono", "hanbok", "qipao", "oriental"] western = ["western", "european", "caucasian", "american", "blonde", "redhead"] east_names = ["Yuna", "Kaede", "Jin", "Mei", "Haruka", "Rin", "Sora", "Aoi"] west_names = ["Elena", "Ryan", "Ava", "Lucas", "Mila", "Ethan", "Nora", "Leo"] if any(k in a for k in east): pool = east_names elif any(k in a for k in western): pool = west_names else: pool = east_names + west_names return random.choice(pool) def _split_actions_to_clips(self, actions, num_clips=4): parts = re.split(r'(?<=[.!?。])\s+|[,、]|\n+', actions) parts = [p.strip() for p in parts if len(p.strip()) > 1] if not parts: parts = [actions.strip()] n = len(parts) last = num_clips assign = {c: [] for c in range(1, num_clips + 1)} if num_clips == 1: assign[1] = parts return assign build_labels = ["(build-up toward)", "(begin)", "(escalate)", "(continue escalating)"] if n == 1: for c in range(1, last): label = build_labels[min(c - 1, len(build_labels) - 1)] assign[c] = [f"{label} {parts[0]}"] assign[last] = [parts[0]] elif n <= num_clips: assign[last] = [parts[-1]] head = parts[:-1] for i, ph in enumerate(head): assign[min(i + 1, last - 1)].append(ph) for c in range(1, last): if not assign[c]: nxt = parts[min(c, n - 1)] assign[c] = [f"(move toward) {nxt}"] else: assign[last] = [parts[-1]] rest = parts[:-1] k, m = divmod(len(rest), last - 1) idx = 0 for c in range(1, last): end = idx + k + (1 if (c - 1) < m else 0) assign[c] = rest[idx:end] idx = end return assign def _plan_shots(self, anchors, actions, name, vibe, pacing, detail, llm_model, key, temp, seed, num_clips=4): assign = self._split_actions_to_clips(actions, num_clips) assign_block = "\n".join( f" clip_{c}: {', '.join(assign[c]) if assign[c] else '(continue previous motion)'}" for c in range(1, num_clips + 1)) last_clip = num_clips total_seconds = num_clips * 5 if num_clips >= 3: chain = " and ".join(f"clip_{c}<-clip_{c-1}" for c in range(3, num_clips + 1)) continuity_middle = (f". The same applies {chain}: each clip picks up exactly where the " f"previous clip's body position left off") else: continuity_middle = "" continuity_rule = "" if num_clips >= 2: continuity_rule = ( "3) INTER-CLIP CONTINUITY. clip_2 MUST begin from the exact physical end-state of " "clip_1's action (same position, body orientation, and momentum) — it continues the " "motion, it does not restart from a neutral stance" + continuity_middle + f", as one unbroken {total_seconds}-second performance rather than {num_clips} separate poses. Do " "NOT explicitly narrate the hand-off (no phrases like 'continuing from', 'still', 'as " "before', 'picking up where') — simply choreograph the new action so it is kinematically " "consistent with how the previous action would have left the body positioned. This is a " "physical-continuity constraint, not a scripted transition line.\n" ) json_clip_fields = ",".join( f'"clip_{i}":""' for i in range(1, num_clips + 1)) sys = ( f"You are an elite fight/action choreographer for a {total_seconds}-second video made of " f"{num_clips} consecutive 5-second clip{'s' if num_clips != 1 else ''}. You are given SCENE ANCHORS " "(the character's starting look/pose/place from an image) and a STRICT per-clip action " "assignment.\n" "\n" "ABSOLUTE RULES:\n" "1) FOLLOW THE ASSIGNMENT EXACTLY. Each clip animates ONLY the action(s) assigned " "to it below. You MUST NOT move a later clip's action into an earlier clip. " f"clip_1 must NOT contain clip_{last_clip}'s action. The final action happens ONLY in " f"clip_{last_clip}. " "This is the most important rule - violating the order is a failure. " "(ONE exception: clip_1 may prepend a brief transition OUT of the starting physics from " "rule 2 — e.g. leaving the yoga ball — before its assigned action. This is not borrowing " "a later action; it is grounding the first one.)\n" "2) STARTING PHYSICS (use the anchors here). Read the character's CURRENT pose AND " "what they are supported by / in contact with from the SCENE ANCHORS (e.g. seated on a " "yoga ball, kneeling, gripping a weapon, leaning on a wall). clip_1 MUST begin the first " "action FROM that exact physical situation and honor its constraints. If the support is " "unstable or unusual (a yoga ball, a ledge, a moving surface), the motion MUST account " "for it — e.g. anchor 'seated balanced on a yoga ball' -> clip_1 = 'pushing off the " "wobbling ball and rising to their feet as it rolls away' BEFORE any further action. " "Never ignore or contradict the starting support (do not stand if seated on the ball " "without first leaving it; do not assume a weapon is drawn if the anchor shows it " "sheathed). This makes the motion physically continuous with the input image. Reference " "the support ONLY to ground how the motion STARTS — do NOT describe its appearance, " "color, or the wider environment.\n" + continuity_rule + "4) ACTION ONLY. Write physical body movement. NO scenery, NO lighting, NO clothing, " "NO mood, NO camera talk. If a word is not a movement or body part, delete it. " "(Referring to the starting pose in clip_1 per rule 2, or to the prior clip's ending " "position per rule 3, is allowed since it describes how the body moves.)\n" "5) FILL 5 SECONDS. Expand the assigned action into a single continuous flow of motion " "that occupies the whole clip (use 'then', 'immediately', 'without pausing'). " "Do not borrow the next action to fill time - elaborate the CURRENT action instead.\n" "6) ANTI-SLOW-MOTION: real-time speed, kinetic adverbs (swiftly, rapidly, explosively, " "in a split second). NO slow motion, NO freeze, NO holding a pose.\n" "7) CHARACTER NAME: study the SCENE ANCHORS. Judge the character's apparent ethnicity/" "setting (East Asian, Western, etc.) and INVENT a specific fitting first name that " "matches it (e.g. East Asian -> 'Yuna','Kaede','Jin'; Western -> 'Elena','Ryan'). " "NEVER output 'AUTO' or an empty name. If a NAME is given below (not 'AUTO'), use it.\n" f"8) {detail}\n" "9) MOTION ENERGY: a MOTION STYLE cue is given below (e.g. 'cinematic lighting, dynamic " "motion'). Use it ONLY to calibrate how forceful/graceful/frantic the movement FEELS " "(word choice, verb intensity). Do NOT quote it and do NOT let any scenery/lighting/mood " "words from it leak into the output — rule 4 still applies.\n" "\n" "master_scene: leave it EMPTY.\n" "\n" 'OUTPUT ONLY JSON: {"character":"","master_scene":"",' + json_clip_fields + "}" ) usr = (f"NAME: {name}\nPACING: {self._pacing_hint(pacing)}\n\n" f"MOTION STYLE (kinetic energy cue only, per rule 9 — do not describe scenery/" f"lighting/mood from this): {vibe.strip() or '(none)'}\n\n" f"SCENE ANCHORS (use the POSE to start clip_1's motion per rule 2; " f"use overall look only for naming — do NOT copy look into the clips):\n" f"{anchors or '(none — assume a neutral ready stance)'}\n\n" f"STRICT PER-CLIP ACTION ASSIGNMENT (animate ONLY what is listed per clip, " f"in this order, finisher in clip_{last_clip}):\n{assign_block}\n") messages = [{"role": "system", "content": sys}, {"role": "user", "content": usr}] t = temp last = "⚠️ plan failed (no attempts ran)" for attempt in (1, 2): try: content = self._call_or(messages, llm_model, key, t, force_json=True) parsed = self._extract_json(content) if parsed and all(parsed.get(f"clip_{i}") for i in range(1, num_clips + 1)): return parsed, f"✅ plan ok (attempt {attempt})" last = f"⚠️ incomplete JSON (attempt {attempt}): {content[:150]!r}" print(f"⚠️ [Director] plan attempt {attempt} returned incomplete JSON: {content[:300]}") except urllib.error.HTTPError as e: body = "" try: body = e.read().decode('utf-8')[:200] except Exception: pass last = f"❌ plan HTTP {e.code} (attempt {attempt}): {body}" print(f"❌ [Director] plan {last}") except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as e: last = f"❌ plan error (attempt {attempt}): {type(e).__name__}: {e}" print(f"❌ [Director] {last}") t = min(temp, 0.4) return None, last def _local_split(self, actions, num_clips=4): parts = re.split(r'(?<=[.!?。])\s+|[,、]|\n+', actions) parts = [p.strip() for p in parts if len(p.strip()) > 1] if not parts: parts = [actions.strip()] n = len(parts) last = num_clips - 1 def fast(phrase, lead="swiftly"): p = phrase.strip() return f"{lead} {p}, continuous real-time motion, no slow motion" if p else "" if num_clips == 1: return [fast(", ".join(parts), "explosively")] if n == 1: a = parts[0] templates = [ f"rapidly moves into position toward {a}, no pause", f"immediately begins {a}, brisk continuous motion, no slow motion", f"presses {a} without stopping, fast decisive movement", ] out = [templates[min(i, len(templates) - 1)] for i in range(num_clips - 1)] out.append(f"explosively completes {a} at real-time speed, no slow motion, no freeze") elif n <= num_clips: out = [""] * num_clips out[last] = fast(parts[-1], "explosively") head = parts[:-1] for i, ph in enumerate(head): s = min(i, num_clips - 2) out[s] = (out[s] + ", then " + fast(ph)).strip(", ") if out[s] else fast(ph) for i in range(num_clips - 1): if not out[i]: key = parts[min(i, n - 1)] out[i] = f"rapidly moves toward {key}, brisk continuous motion, no slow motion" else: k, m = divmod(n - 1, num_clips - 1) idx, chunks = 0, [] for i in range(num_clips - 1): end = idx + k + (1 if i < m else 0) seg = parts[idx:end] idx = end chunks.append(", then ".join(fast(p) for p in seg) if seg else "") out = chunks + [fast(parts[-1], "explosively")] return out[:num_clips] def _load(self, name): return comfy.utils.load_torch_file(folder_paths.get_full_path("loras", name)) @staticmethod def _parse_weights(text): try: nums = [float(x) for x in re.split(r'[,\s]+', text.strip()) if x != ""] except (ValueError, AttributeError): nums = [] if not nums: return 1.0, 1.0, 1.0 if len(nums) == 1: return nums[0], nums[0], nums[0] if len(nums) == 2: return nums[0], nums[1], nums[1] return nums[0], nums[1], nums[2] def _patch_clip(self, mb, mh, ml, slots, cache, inject): cb, ch, cl = mb, mh, ml used = [] for (name, w_str) in slots: if name == "None": continue w_b, w_h, w_l = self._parse_weights(w_str) if w_b == 0.0 and w_h == 0.0 and w_l == 0.0: continue data = self._load(name) if w_b != 0.0: cb = apply_model_lora(cb, data, w_b) if w_h != 0.0: ch = apply_model_lora(ch, data, w_h) if w_l != 0.0: cl = apply_model_lora(cl, data, w_l) used.append(name) trig = [] trig_dbg = [] for n in dict.fromkeys(used): found = get_triggers(n, cache) trig.extend(found) trig_dbg.append(f"{os.path.basename(n)}:{len(found)}") self._last_trig_dbg = trig_dbg trig_str = ", ".join(dict.fromkeys(t for t in trig if t)) prompt_trig = trig_str if inject else "" return cb, ch, cl, prompt_trig, trig_str def direct(self, image, model_base, model_high, model_low, essential_actions, num_clips, character_name, artistic_vibe, pacing, detail_level, inject_triggers, use_vision, openrouter_api_key, vlm_preset, vlm_model, llm_preset, llm_model, creativity, seed, **kw): n_clips = int(str(num_clips).split()[0]) vlm = resolve_slug(VLM_PRESETS, vlm_preset, vlm_model, "qwen/qwen2.5-vl-72b-instruct") llm = resolve_slug(LLM_PRESETS, llm_preset, llm_model, "deepseek/deepseek-v3.2") user_name = "" if character_name.strip().upper() in ["AUTO", ""] else character_name.strip() key = resolve_api_key(openrouter_api_key) vision_caption = str(kw.get("vision_caption", "") or "").strip() anchors, dbg_v = "", "" if vision_caption: anchors = vision_caption dbg_v = "vision ok (local caption node)" print(f"👁 [Director] 로컬 캡션 입력 사용 (JoyCaption 등):\n{anchors[:300]}") elif not use_vision: dbg_v = "vision OFF (토글 확인)" print("ℹ️ [Director] use_vision=OFF - 비전 건너뜀") elif not key: dbg_v = "no api key" print("❌ [Director] openrouter_api_key 비어있음 (위젯도, OPENROUTER_API_KEY 환경변수도 없음) - 비전/LLM 불가") elif image is None: dbg_v = "no image" print("❌ [Director] image 입력 없음 - IMAGE 연결 확인") else: data_uri = tensor_to_base64(image) if data_uri: anchors, err = self._vision_analyze(data_uri, vlm, key, min(creativity, 0.5)) dbg_v = err or "vision ok" else: dbg_v = "image encode failed / PIL missing" if not vision_caption and use_vision and key and image is not None and not anchors: print(f"⚠️ [Director] 비전 앵커가 비었음 -> 이름/씬 근거 없음. 원인: {dbg_v}") if key: parsed, dbg_p = self._plan_shots(anchors, essential_actions, character_name, artistic_vibe, pacing, self._detail_hint(detail_level), llm, key, creativity, seed, num_clips=n_clips) if parsed: clips = [str(parsed[f"clip_{i}"]).strip() for i in range(1, n_clips + 1)] master = str(parsed.get("master_scene", "")).strip() cand = str(parsed.get("character", "")).strip() if not cand or cand.upper() == "AUTO": cand = self._fallback_name(anchors) final_name = user_name or cand else: clips = self._local_split(essential_actions, num_clips=n_clips) master = anchors.replace("\n", " ")[:160] final_name = user_name or self._fallback_name(anchors) else: parsed, dbg_p = None, "no api key" clips = self._local_split(essential_actions, num_clips=n_clips) master = anchors.replace("\n", " ")[:160] final_name = user_name if len(clips) < 4: clips = clips + [clips[-1]] * (4 - len(clips)) cache = _load_cache() manual_list = [t.strip() for t in kw.get("manual_triggers", "").split(',') if t.strip()] outs = [] all_trigs_collected = [] for c in range(1, 5): if c > n_clips: cb = ch = cl = ExecutionBlocker(None) prompt = self._build_final(clips[c - 1], master, final_name, "") outs.extend([cb, ch, cl, prompt]) print(f" [clip{c}] SKIPPED (num_clips={n_clips}) - ExecutionBlocker 반환") continue slots = [(kw.get(f"c{c}_lora_{n}", "None"), kw.get(f"c{c}_w_{n}", "1.0, 1.0, 1.0")) for n in range(1, 5)] cb, ch, cl, prompt_trig, always_trig = self._patch_clip( model_base, model_high, model_low, slots, cache, inject_triggers) all_trig = ", ".join(dict.fromkeys( ([prompt_trig] if prompt_trig else []) + manual_list)) \ if (prompt_trig or manual_list) else "" if always_trig: all_trigs_collected.extend(t.strip() for t in always_trig.split(',') if t.strip()) prompt = self._build_final(clips[c - 1], master, final_name, all_trig) outs.extend([cb, ch, cl, prompt]) print(f" [clip{c}] loras/triggers: {getattr(self, '_last_trig_dbg', [])} " f"inject={inject_triggers}") triggers_out = ", ".join(dict.fromkeys( [t for t in all_trigs_collected if t] + manual_list)) debug = f"vlm={dbg_v} | plan={dbg_p} | char={final_name} | clips={n_clips} ({n_clips*5}s)" print(f"\n🎬 [Dolphin Vision Director] {debug}\nANCHORS: {anchors[:120]}") for i in range(4): print(f" CLIP {i+1}: {outs[i*4+3][:90]}") is_final = tuple(c == n_clips for c in range(1, 5)) return tuple(outs) + (triggers_out, anchors, debug) + is_final NODE_CLASS_MAPPINGS = {"DolphinVisionDirector": DolphinVisionDirector} NODE_DISPLAY_NAME_MAPPINGS = {"DolphinVisionDirector": "🎬👁 Dolphin Vision Director (Image→4Clips)"}