| |
| 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_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()) |
|
|
| |
| 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() |
|
|
|
|
| |
| 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_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 |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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}":"<only clip_{i} action{", the finisher" if i == last_clip else ""}, ' |
| f'continuous & fast>"' 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":"<a real name, never AUTO>","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)"} |