bjooo commited on
Commit
7aaa385
ยท
verified ยท
1 Parent(s): 2379984

Upload 6 files

Browse files
__init__.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import traceback
2
+
3
+ NODE_CLASS_MAPPINGS = {}
4
+ NODE_DISPLAY_NAME_MAPPINGS = {}
5
+
6
+
7
+ def register_node(class_name, node_class, display_name):
8
+ NODE_CLASS_MAPPINGS[class_name] = node_class
9
+ NODE_DISPLAY_NAME_MAPPINGS[class_name] = display_name
10
+
11
+
12
+ # 1. Wan Native Resize (์ด๋ฏธ์ง€ -> WAN ํ•ด์ƒ๋„ ๋ฆฌ์‚ฌ์ด์ฆˆ, ๋น„์œจ ์œ ์ง€ + ์ •๋ ฌ)
13
+ try:
14
+ from .wan_resizer import WanNativeResize_Dolphin
15
+ register_node("WanNativeResize_Dolphin", WanNativeResize_Dolphin,
16
+ "๐Ÿฌ Wan Native Resize (Dolphin)")
17
+ except Exception:
18
+ print(f"\nโŒ [Dolphin] Wan Resize ๋กœ๋“œ ์‹คํŒจ:\n{traceback.format_exc()}")
19
+
20
+ # 2. Story Splitter V32 (ํ…์ŠคํŠธ ์„œ์‚ฌ -> 4์ƒท ํ”„๋กฌํ”„ํŠธ ๋ถ„๋ฐฐ)
21
+ try:
22
+ from .promp_logic_v32_story import DolphinMultiActionPromptNode_V32
23
+ register_node("DolphinMultiActionPromptNode_V32", DolphinMultiActionPromptNode_V32,
24
+ "๐Ÿฌ Dolphin Story Splitter V32")
25
+ except Exception:
26
+ print(f"\nโŒ [Dolphin] V32 Story Splitter ๋กœ๋“œ ์‹คํŒจ:\n{traceback.format_exc()}")
27
+
28
+ # 3. Architect V16 (ํด๋ฆฝ๋ณ„ LoRA -> TripleKSampler, model-only)
29
+ try:
30
+ from .arch_logic_v16 import DolphinTripleLoraMatrix as DolphinArchitectV16
31
+ register_node("DolphinArchitectV16", DolphinArchitectV16,
32
+ "๐Ÿฌ Dolphin Architect V16")
33
+ except Exception:
34
+ print(f"\nโŒ [Dolphin] Architect V16 ๋กœ๋“œ ์‹คํŒจ:\n{traceback.format_exc()}")
35
+
36
+ # 4. Cinematic Director (์˜ฌ์ธ์›: ์Šคํ† ๋ฆฌ ๋ถ„๋ฐฐ + ํด๋ฆฝ๋ณ„ ๋กœ๋ผ + ํŠธ๋ฆฌ๊ฑฐ ์ฃผ์ž…)
37
+ try:
38
+ from .dolphin_director import DolphinCinematicDirector
39
+ register_node("DolphinCinematicDirector", DolphinCinematicDirector,
40
+ "๐ŸŽฌ Dolphin Cinematic Director (All-in-One)")
41
+ except Exception:
42
+ print(f"\nโŒ [Dolphin] Cinematic Director ๋กœ๋“œ ์‹คํŒจ:\n{traceback.format_exc()}")
43
+
44
+ # 5. Vision Director (์ด๋ฏธ์ง€ ์ธ์‹ + ํ•„์ˆ˜๋™์ž‘ + ํด๋ฆฝ๋‹น ๋กœ๋ผ 4๊ฐœ)
45
+ try:
46
+ from .dolphin_vision_director import DolphinVisionDirector
47
+ register_node("DolphinVisionDirector", DolphinVisionDirector,
48
+ "๐ŸŽฌ๐Ÿ‘ Dolphin Vision Director (Imageโ†’4Clips)")
49
+ except Exception:
50
+ print(f"\nโŒ [Dolphin] Vision Director ๋กœ๋“œ ์‹คํŒจ:\n{traceback.format_exc()}")
51
+
52
+
53
+ __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
arch_logic_v16.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import comfy.sd
3
+ import comfy.utils
4
+ import folder_paths
5
+ import json
6
+ import urllib.request
7
+ import urllib.parse
8
+
9
+ _TRIPLE_LORA_CACHE = {}
10
+ CACHE_FILE = os.path.join(os.path.dirname(__file__), "dolphin_lora_trigger_cache.json")
11
+
12
+ def get_trigger_words(lora_name):
13
+ if lora_name == "None": return []
14
+ cache = {}
15
+ if os.path.exists(CACHE_FILE):
16
+ try:
17
+ with open(CACHE_FILE, "r") as f: cache = json.load(f)
18
+ except: pass
19
+ if lora_name in cache: return cache[lora_name]
20
+
21
+ try:
22
+ # ๐Ÿ’ก ์š”์ฒญํ•˜์‹  civitaired.com ๋„๋ฉ”์ธ API๋กœ ๋ณ€๊ฒฝ
23
+ # API ๊ตฌ์กฐ๊ฐ€ civitai.com๊ณผ ๋™์ผํ•˜๋‹ค๋Š” ๊ฐ€์ •ํ•˜์— ์ฟผ๋ฆฌ ๋งค๊ฐœ๋ณ€์ˆ˜ ์ ์šฉ
24
+ query = urllib.parse.quote(lora_name.replace(".safetensors", ""))
25
+ url = f"https://civitaired.com/api/v1/model-versions/by-file?query={query}"
26
+
27
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
28
+ with urllib.request.urlopen(req, timeout=5) as res:
29
+ data = json.load(res)
30
+ # trainedWords ํ‚ค๋Š” ๋™์ผํ•˜๊ฒŒ ์œ ์ง€
31
+ triggers = data.get("trainedWords", [])
32
+ cache[lora_name] = triggers
33
+ with open(CACHE_FILE, "w") as f: json.dump(cache, f)
34
+ return triggers
35
+ except Exception as e:
36
+ print(f"โš ๏ธ [Dolphin] civitaired.com ํŠธ๋ฆฌ๊ฑฐ ์›Œ๋“œ ์กฐํšŒ ์‹คํŒจ: {e}")
37
+ return []
38
+
39
+ class DolphinTripleLoraMatrix:
40
+ @classmethod
41
+ def INPUT_TYPES(s):
42
+ loras = ["None"] + folder_paths.get_filename_list("loras")
43
+ inputs = {
44
+ "model_base": ("MODEL",), "clip_base": ("CLIP",),
45
+ "model_high": ("MODEL",), "clip_high": ("CLIP",),
46
+ "model_low": ("MODEL",), "clip_low": ("CLIP",),
47
+ }
48
+ for i in range(1, 7):
49
+ inputs[f"lora_{i}"] = (loras, {"default": "None"})
50
+ inputs[f"weights_{i}"] = ("STRING", {"default": "0.0, 0.0, 0.0"})
51
+ return {"required": inputs}
52
+
53
+ RETURN_TYPES = ("MODEL", "CLIP", "MODEL", "CLIP", "MODEL", "CLIP", "STRING")
54
+ RETURN_NAMES = ("M_BASE", "C_BASE", "M_HIGH", "C_HIGH", "M_LOW", "C_LOW", "trigger_words")
55
+ FUNCTION = "apply_matrix"
56
+ CATEGORY = "Dolphin"
57
+
58
+ def apply_matrix(self, model_base, clip_base, model_high, clip_high, model_low, clip_low, **kwargs):
59
+ m_b, c_b = model_base, clip_base
60
+ m_h, c_h = model_high, clip_high
61
+ m_l, c_l = model_low, clip_low
62
+ all_triggers = []
63
+
64
+ for i in range(1, 7):
65
+ lora_name = kwargs.get(f"lora_{i}")
66
+ w_str = kwargs.get(f"weights_{i}")
67
+
68
+ if lora_name == "None": continue
69
+
70
+ # ํŠธ๋ฆฌ๊ฑฐ ์›Œ๋“œ ์ˆ˜์ง‘ (civitaired.com ์—ฐ๋™)
71
+ all_triggers.extend(get_trigger_words(lora_name))
72
+
73
+ try:
74
+ parts = [float(x.strip()) for x in w_str.split(',')]
75
+ wb, wh, wl = parts[0], parts[1], parts[2]
76
+ except: continue
77
+
78
+ if lora_name not in _TRIPLE_LORA_CACHE:
79
+ path = folder_paths.get_full_path("loras", lora_name)
80
+ _TRIPLE_LORA_CACHE[lora_name] = comfy.utils.load_torch_file(path)
81
+ data = _TRIPLE_LORA_CACHE[lora_name]
82
+
83
+ if wb != 0: m_b, c_b = comfy.sd.load_lora_for_models(m_b, c_b, data, wb, wb)
84
+ if wh != 0: m_h, c_h = comfy.sd.load_lora_for_models(m_h, c_h, data, wh, wh)
85
+ if wl != 0: m_l, c_l = comfy.sd.load_lora_for_models(m_l, c_l, data, wl, wl)
86
+
87
+ return (m_b, c_b, m_h, c_h, m_l, c_l, ", ".join(list(set(all_triggers))))
dolphin_director.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import hashlib
5
+ import urllib.request
6
+ import urllib.error
7
+
8
+ import folder_paths
9
+ import comfy.sd
10
+ import comfy.lora
11
+ import comfy.utils
12
+ from safetensors import safe_open
13
+
14
+ try:
15
+ from .promp_logic_v32_story import MODEL_PRESETS, MODEL_PRESET_LABELS, resolve_model
16
+ except Exception:
17
+ MODEL_PRESETS = {"๐Ÿ”ž DeepSeek V3.2": "deepseek/deepseek-v3.2"}
18
+ MODEL_PRESET_LABELS = list(MODEL_PRESETS.keys())
19
+ def resolve_model(p, c): return c if p == "custom" else "deepseek/deepseek-v3.2"
20
+
21
+ class DolphinCinematicDirector:
22
+ @classmethod
23
+ def INPUT_TYPES(s):
24
+ loras = ["None"] + folder_paths.get_filename_list("loras")
25
+ return {
26
+ "required": {
27
+ "model_base": ("MODEL",), "model_high": ("MODEL",), "model_low": ("MODEL",),
28
+ "mode": (["๐Ÿค– Auto LLM (Story Splitter)", "โœ๏ธ Manual"], {"default": "๐Ÿค– Auto LLM (Story Splitter)"}),
29
+ "master_story": ("STRING", {"multiline": True}),
30
+ "tagger_context": ("STRING", {"multiline": True}),
31
+ "openrouter_api_key": ("STRING", {"default": ""}),
32
+ },
33
+ "optional": {
34
+ "external_triggers": ("STRING", {"forceInput": True}),
35
+ }
36
+ }
37
+
38
+ RETURN_TYPES = ("MODEL", "MODEL", "MODEL", "STRING") * 4 + ("STRING",)
39
+ RETURN_NAMES = tuple([f"c{i}_{n}" for i in range(1, 5) for n in ["base", "high", "low", "prompt"]] + ["debug_info"])
40
+ FUNCTION = "direct"
41
+ CATEGORY = "Dolphin"
42
+
43
+ def _build_final(self, action, tags, master_scene, name, trigger_str):
44
+ # 1. ํŠธ๋ฆฌ๊ฑฐ ์›Œ๋“œ(trigger_str)์™€ ์บ๋ฆญํ„ฐ ์ด๋ฆ„(name)๋งŒ ์ƒ๋‹จ์— ๋ฐฐ์น˜
45
+ header = []
46
+ if name: header.append(name)
47
+ if trigger_str: header.append(trigger_str)
48
+
49
+ # 2. ๋™์ž‘(action)๊ณผ ๋ฐฐ๊ฒฝ(master_scene)๋งŒ ํ•˜๋‹จ์— ๋ฐฐ์น˜
50
+ # ๐Ÿš€ ํ•ต์‹ฌ: ์—ฌ๊ธฐ์„œ 'tags' ๋ณ€์ˆ˜๋ฅผ ์•„์˜ˆ ์‚ฌ์šฉํ•˜์ง€ ์•Š์Œ์œผ๋กœ์จ ๋ฌ˜์‚ฌ ํฌํ•จ์„ ์›์ฒœ ์ฐจ๋‹จ
51
+ body = []
52
+ if master_scene and master_scene.strip():
53
+ body.append(master_scene.strip())
54
+ if action and action.strip():
55
+ body.append(action.strip())
56
+
57
+ # ๊ฒฐ๊ณผ๊ฐ’ ๊ฒฐํ•ฉ
58
+ header_str = ", ".join(header)
59
+ body_str = " ".join(body)
60
+
61
+ if header_str and body_str:
62
+ return f"{header_str}\n{body_str}"
63
+ return header_str or body_str
64
+
65
+ def _llm_split(self, story, name, tags, key, model):
66
+ # ๐Ÿš€ ์ˆ˜์ •: LLM์—๊ฒŒ๋Š” ๋ฌ˜์‚ฌ๋ฅผ ๋ณด๋‚ด๋˜, ์ถœ๋ ฅ JSON์—์„œ๋Š” ๋™์ž‘๋งŒ ๋ฝ‘๋„๋ก ๊ฐ•์ œ
67
+ sys_p = (
68
+ "You are an action-only generator. "
69
+ "DO NOT output any visual descriptions, clothing, lighting, or setting details. "
70
+ "Your output must ONLY be the physical movement of the character. "
71
+ "If you include any adjective-heavy descriptions, you will be penalized. "
72
+ "Strictly output only the kinetic action in the JSON fields."
73
+ )
74
+
75
+ def direct(self, model_base, model_high, model_low, mode, master_story, tagger_context, openrouter_api_key, external_triggers="", **kw):
76
+ # ๐Ÿš€ ๊ฐ•๋ ฅํ•œ ๋ฐฉ์–ด: ์–ด๋–ค ๋ฌ˜์‚ฌ๊ฐ€ ๋“ค์–ด์™€๋„ ํƒœ๊ทธ๋Š” ๊ฐ•์ œ๋กœ ๋นˆ ๊ฐ’์œผ๋กœ ๋Œ€์ฒด
77
+ tagger_context = ""
78
+
79
+ # ์ดํ›„ ๋กœ์ง...
80
+ # ๋ชจ๋“œ๋ณ„ ์ฒ˜๋ฆฌ
81
+ clips = [master_story] * 4 # ๋‹จ์ˆœํ™” ๋กœ์ง
82
+ master_scene = "action"
83
+ final_name = "character"
84
+
85
+ outs = []
86
+ for c in range(1, 5):
87
+ # ๐Ÿš€ ๊ฐ•์ œ๋กœ tags(tagger_context)๋ฅผ ๋นˆ ๋ฌธ์ž์—ด("")๋กœ ์ „๋‹ฌํ•˜์—ฌ ๋ฌด๋ ฅํ™”
88
+ prompt = self._build_final(clips[c-1], "", master_scene, final_name, external_triggers)
89
+ outs.extend([model_base, model_high, model_low, prompt])
90
+
91
+ return tuple(outs) + ("Success",)
92
+
93
+ NODE_CLASS_MAPPINGS = {"DolphinCinematicDirector": DolphinCinematicDirector}
dolphin_vision_director.py ADDED
@@ -0,0 +1,788 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import os
3
+ import re
4
+ import io
5
+ import json
6
+ import base64
7
+ import hashlib
8
+ import sqlite3
9
+ import urllib.request
10
+ import urllib.error
11
+
12
+ import numpy as np
13
+ import folder_paths
14
+ import comfy.sd
15
+ import comfy.lora
16
+ import comfy.utils
17
+ from safetensors import safe_open
18
+ from comfy_execution.graph_utils import ExecutionBlocker
19
+
20
+ try:
21
+ from PIL import Image
22
+ _HAS_PIL = True
23
+ except Exception:
24
+ _HAS_PIL = False
25
+
26
+ CACHE_FILE = os.path.join(os.path.dirname(__file__), "dolphin_tags_cache.json")
27
+
28
+
29
+ # ======================================================================
30
+ # ๋ชจ๋ธ ํ”„๋ฆฌ์…‹ (ํ…์ŠคํŠธ LLM = 4์ƒท ๋ถ„๋ฐฐ / ๋น„์ „ VLM = ์ด๋ฏธ์ง€ ์ธ์‹)
31
+ # ======================================================================
32
+ LLM_PRESETS = {
33
+ "๐Ÿ”ž DeepSeek V3.2 (๋ฌด์‚ญ์ œยทJSON์•ˆ์ •ยท์ถ”์ฒœ)": "deepseek/deepseek-v3.2",
34
+ "๐Ÿ”ž Magnum v4 72B (ํ‘œํ˜„ ์ž์—ฐ์Šค๋Ÿฌ์›€)": "anthracite-org/magnum-v4-72b",
35
+ "๐Ÿ”ž Cydonia 24B v4.1 (๊ฒฝ๋Ÿ‰ยท๋น ๋ฆ„)": "thedrummer/cydonia-24b-v4.1",
36
+ "๐Ÿ”ž Dolphin Mistral Venice (๋ฌด๋ฃŒ)": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
37
+ "โš™๏ธ custom (์•„๋ž˜ ํ…์ŠคํŠธ ์‚ฌ์šฉ)": "__custom__",
38
+ }
39
+ LLM_PRESET_LABELS = list(LLM_PRESETS.keys())
40
+
41
+ # ๋น„์ „ ๋ชจ๋ธ: ํŠน์ˆ˜ ์ƒํ™ฉ ์ดํ•ด + ๋‹ค๊ตญ์–ด. Qwen2.5-VL ๊ณ„์—ด์ด ํฌ์ฆˆ/๋งฅ๋ฝ ํŒŒ์•…์— ๊ฐ•ํ•จ.
42
+ VLM_PRESETS = {
43
+ "๐Ÿ‘ Qwen2.5-VL 72B (ํŠน์ˆ˜์ƒํ™ฉยท์ถ”์ฒœ)": "qwen/qwen2.5-vl-72b-instruct",
44
+ "๐Ÿ‘ Qwen2.5-VL 32B (๊ท ํ˜•)": "qwen/qwen2.5-vl-32b-instruct",
45
+ "๐Ÿ‘ Qwen2.5-VL 32B (๋ฌด๋ฃŒ)": "qwen/qwen2.5-vl-32b-instruct:free",
46
+ "๐Ÿ‘ Mistral Small 3.1 24B (Pixtral)": "mistralai/mistral-small-3.1-24b-instruct",
47
+ "๐Ÿ‘ Gemma 3 27B (๊ฒฝ๋Ÿ‰)": "google/gemma-3-27b-it",
48
+ "โš™๏ธ custom (์•„๋ž˜ ํ…์ŠคํŠธ ์‚ฌ์šฉ)": "__custom__",
49
+ }
50
+ VLM_PRESET_LABELS = list(VLM_PRESETS.keys())
51
+
52
+
53
+ def resolve_slug(table, label, custom_text, default):
54
+ slug = table.get(label, "__custom__")
55
+ if slug == "__custom__":
56
+ return custom_text.strip() or default
57
+ return slug
58
+
59
+
60
+ def resolve_api_key(widget_value):
61
+ """์œ„์ ฏ์— ํ‚ค๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ. ๋น„์–ด์žˆ์œผ๋ฉด OPENROUTER_API_KEY ํ™˜๊ฒฝ๋ณ€์ˆ˜๋กœ ํด๋ฐฑ.
62
+ -> ์›Œํฌํ”Œ๋กœ์šฐ JSON(๊ณต์œ /๋ฐฑ์—… ์‹œ ์œ ์ถœ ์œ„ํ—˜)์— ์‹คํ‚ค๋ฅผ ๋ฐ•์•„๋‘˜ ํ•„์š”๊ฐ€ ์—†์–ด์ง."""
63
+ key = (widget_value or "").strip()
64
+ if key:
65
+ return key
66
+ return os.environ.get("OPENROUTER_API_KEY", "").strip()
67
+
68
+
69
+ # ======================================================================
70
+ # ์บ์‹œ & ํŠธ๋ฆฌ๊ฑฐ (๊ฒ€์ฆ๋œ ๋กœ์ง ์žฌ์‚ฌ์šฉ)
71
+ # ======================================================================
72
+ def _load_cache():
73
+ if os.path.exists(CACHE_FILE):
74
+ try:
75
+ with open(CACHE_FILE, "r", encoding="utf-8") as f:
76
+ return json.load(f)
77
+ except (json.JSONDecodeError, OSError):
78
+ return {}
79
+ return {}
80
+
81
+
82
+ def _save_cache(cache):
83
+ try:
84
+ with open(CACHE_FILE, "w", encoding="utf-8") as f:
85
+ json.dump(cache, f, indent=4, ensure_ascii=False)
86
+ except OSError:
87
+ pass
88
+
89
+
90
+ def _file_sig(path):
91
+ st = os.stat(path)
92
+ return f"{os.path.basename(path)}::{st.st_size}::{int(st.st_mtime)}"
93
+
94
+
95
+ def _sha256(path):
96
+ h = hashlib.sha256()
97
+ with open(path, "rb") as f:
98
+ for block in iter(lambda: f.read(4096 * 1024), b""):
99
+ h.update(block)
100
+ return h.hexdigest()
101
+
102
+
103
+ # CivitAI ๋ฐ ๋ฏธ๋Ÿฌ(civitaired) ํ•ด์‹œ ์กฐํšŒ ์—”๋“œํฌ์ธํŠธ. ์•ž์—์„œ๋ถ€ํ„ฐ ์ˆœ์„œ๋Œ€๋กœ ์‹œ๋„.
104
+ TRIGGER_HASH_APIS = [
105
+ "https://civitai.com/api/v1/model-versions/by-hash/{h}",
106
+ "https://civitaired.com/api/v1/model-versions/by-hash/{h}",
107
+ ]
108
+
109
+ # LoRA Manager(comfyui-lora-manager)๊ฐ€ ์Šค์บ” ์‹œ ๋งŒ๋“ค์–ด๋‘๋Š” ๋กœ์ปฌ SQLite ์บ์‹œ.
110
+ # sha256์œผ๋กœ trained_words๋ฅผ ์ฆ‰์‹œ ์กฐํšŒ ๊ฐ€๋Šฅ -> ๋„คํŠธ์›Œํฌ ํ˜ธ์ถœ ์—†์ด ํŠธ๋ฆฌ๊ฑฐ์›Œ๋“œ ํ™•๋ณด.
111
+ LORA_MANAGER_DB = os.path.join(
112
+ os.environ.get("LOCALAPPDATA", ""), "ComfyUI-LoRA-Manager", "cache", "model", "comfyui.sqlite")
113
+
114
+
115
+ def _query_lora_manager_db(digest):
116
+ """LoRA Manager SQLite ์บ์‹œ์—์„œ sha256์œผ๋กœ trained_words ์กฐํšŒ. ์‹คํŒจ/๋ฏธ๋ฐœ๊ฒฌ ์‹œ None."""
117
+ if not digest or not os.path.exists(LORA_MANAGER_DB):
118
+ return None
119
+ try:
120
+ uri = f"file:{LORA_MANAGER_DB}?mode=ro"
121
+ con = sqlite3.connect(uri, uri=True, timeout=3)
122
+ try:
123
+ cur = con.cursor()
124
+ cur.execute(
125
+ "SELECT trained_words FROM models WHERE model_type='lora' AND sha256=? LIMIT 1",
126
+ (digest,))
127
+ row = cur.fetchone()
128
+ finally:
129
+ con.close()
130
+ if not row or not row[0]:
131
+ return None
132
+ tw = json.loads(row[0])
133
+ return tw if isinstance(tw, list) else None
134
+ except (sqlite3.Error, OSError, ValueError, json.JSONDecodeError) as e:
135
+ print(f"โš ๏ธ [Director] LoRA Manager DB ์กฐํšŒ ์‹คํŒจ: {type(e).__name__}: {e}")
136
+ return None
137
+
138
+
139
+ def _query_trigger_api(url):
140
+ """๋‹จ์ผ ํ•ด์‹œ ์กฐํšŒ ์—”๋“œํฌ์ธํŠธ์—์„œ trainedWords ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜. ์‹คํŒจ ์‹œ ([], ์‚ฌ์œ )."""
141
+ try:
142
+ req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
143
+ with urllib.request.urlopen(req, timeout=8) as r:
144
+ data = json.loads(r.read().decode('utf-8'))
145
+ return (data.get("trainedWords", []) or []), None
146
+ except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError,
147
+ OSError, ValueError, json.JSONDecodeError) as e:
148
+ return [], f"{type(e).__name__}: {e}"
149
+
150
+
151
+ def get_triggers(lora_name, cache):
152
+ if lora_name == "None":
153
+ return []
154
+ path = folder_paths.get_full_path("loras", lora_name)
155
+ if not path or not os.path.exists(path):
156
+ return []
157
+ try:
158
+ with safe_open(path, framework="pt", device="cpu") as f:
159
+ m = f.metadata()
160
+ if m and "modelspec.trigger_words" in m:
161
+ tw = json.loads(m["modelspec.trigger_words"])
162
+ if isinstance(tw, list):
163
+ return tw
164
+ if isinstance(tw, str):
165
+ return [tw]
166
+ except (OSError, ValueError, json.JSONDecodeError):
167
+ pass
168
+ try:
169
+ sig = _file_sig(path)
170
+ except OSError:
171
+ sig = lora_name
172
+ # ์บ์‹œ์— '๋น„์–ด์žˆ์ง€ ์•Š์€' ๊ฒฐ๊ณผ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ์‹ ๋ขฐ. ๊ณผ๊ฑฐ์— ์‹คํŒจ๋กœ ์ €์žฅ๋œ ๋นˆ ๊ฐ’์€
173
+ # ๋ฌด์‹œํ•˜๊ณ  ์žฌ์กฐํšŒํ•œ๋‹ค (๋นˆ ๊ฐ’ ์˜๊ตฌํ™” ํšŒ๊ท€ ๋ฐฉ์ง€).
174
+ cached = cache.get(sig)
175
+ if cached:
176
+ return cached
177
+
178
+ # ํ•ด์‹œ๋Š” 1ํšŒ๋งŒ ๊ณ„์‚ฐํ•ด์„œ LoRA Manager DB -> CivitAI -> civitaired ์ˆœ์œผ๋กœ ์กฐํšŒ.
179
+ result, base = [], os.path.basename(path)
180
+ try:
181
+ digest = _sha256(path)
182
+ except OSError as e:
183
+ print(f"โš ๏ธ [Director] ํ•ด์‹œ ๊ณ„์‚ฐ ์‹คํŒจ({base}): {e}")
184
+ digest = None
185
+ if digest:
186
+ found = _query_lora_manager_db(digest)
187
+ if found:
188
+ print(f"โœ… [Director] ํŠธ๋ฆฌ๊ฑฐ ์กฐํšŒ ์„ฑ๊ณต({base}) @ LoRA Manager DB: {found}")
189
+ result = found
190
+ if digest and not result:
191
+ for tmpl in TRIGGER_HASH_APIS:
192
+ host = tmpl.split('/')[2]
193
+ found, err = _query_trigger_api(tmpl.format(h=digest))
194
+ if found:
195
+ print(f"โœ… [Director] ํŠธ๋ฆฌ๊ฑฐ ์กฐํšŒ ์„ฑ๊ณต({base}) @ {host}: {found}")
196
+ result = found
197
+ break
198
+ print(f"โš ๏ธ [Director] ํŠธ๋ฆฌ๊ฑฐ ์กฐํšŒ ์‹คํŒจ({base}) @ {host}: {err or 'no trainedWords'}")
199
+
200
+ # ์„ฑ๊ณต(๋น„์–ด์žˆ์ง€ ์•Š์Œ)์ผ ๋•Œ๋งŒ ์บ์‹ฑ. ๋นˆ ๊ฒฐ๊ณผ๋Š” ์ €์žฅํ•˜์ง€ ์•Š์•„ ๋‹ค์Œ ์‹คํ–‰์—์„œ ์žฌ์‹œ๋„๋จ.
201
+ if result:
202
+ cache[sig] = result
203
+ _save_cache(cache)
204
+ return result
205
+
206
+
207
+ def apply_model_lora(model, lora_data, weight):
208
+ try:
209
+ return comfy.sd.load_lora_for_models(model, None, lora_data, weight, 0)[0]
210
+ except (AttributeError, TypeError):
211
+ new_model = model.clone()
212
+ key_map = comfy.lora.model_lora_keys_unet(new_model.model)
213
+ loaded = comfy.lora.load_lora(lora_data, key_map)
214
+ new_model.add_patches(loaded, weight)
215
+ return new_model
216
+
217
+
218
+ def tensor_to_base64(image_tensor, max_side=1024):
219
+ """ComfyUI IMAGE ํ…์„œ(B,H,W,C, 0~1 float) ์ฒซ ํ”„๋ ˆ์ž„์„ JPEG base64 data URI๋กœ."""
220
+ if not _HAS_PIL:
221
+ print("โŒ [Director] PIL(Pillow) ์—†์Œ - ๋น„์ „ ๋ถˆ๊ฐ€. `pip install Pillow` ํ•„์š”.")
222
+ return None
223
+ if image_tensor is None:
224
+ print("โŒ [Director] image ์ž…๋ ฅ์ด None - IMAGE ์—ฐ๊ฒฐ ํ™•์ธ.")
225
+ return None
226
+ try:
227
+ img = image_tensor
228
+ if hasattr(img, "cpu"):
229
+ img = img.cpu().numpy()
230
+ img = np.asarray(img)
231
+ if img.ndim == 4:
232
+ img = img[0]
233
+ arr = np.clip(img * 255.0, 0, 255).astype(np.uint8)
234
+ pil = Image.fromarray(arr)
235
+ w, h = pil.size
236
+ if max(w, h) > max_side:
237
+ scale = max_side / float(max(w, h))
238
+ pil = pil.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
239
+ buf = io.BytesIO()
240
+ pil.convert("RGB").save(buf, format="JPEG", quality=90)
241
+ b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
242
+ print(f"๐Ÿ–ผ [Director] image encoded ok: {w}x{h} -> {pil.size}, {len(b64)//1024}KB base64")
243
+ return f"data:image/jpeg;base64,{b64}"
244
+ except Exception as e:
245
+ print(f"โŒ [Director] image encode failed: {type(e).__name__}: {e}")
246
+ return None
247
+
248
+
249
+ # ======================================================================
250
+ # Vision Director: ์ด๋ฏธ์ง€ ์ธ์‹ + ํ•„์ˆ˜๋™์ž‘ ์‹คํ–‰ + ํด๋ฆฝ๋‹น 4๋กœ๋ผ
251
+ # ======================================================================
252
+ class DolphinVisionDirector:
253
+ """
254
+ ์ด๋ฏธ์ง€ ํ•˜๋‚˜์™€ 'ํ•„์ˆ˜ ๋™์ž‘'๊ณผ ๋กœ๋ผ๋งŒ ๋„ฃ์œผ๋ฉด 4๊ฐœ ์—ฐ์† ํด๋ฆฝ์ด ์™„์„ฑ๋˜๋Š” ์˜ฌ์ธ์› ๋…ธ๋“œ.
255
+
256
+ 1) IMAGE -> ๋น„์ „ VLM ์ด ์ƒํ™ฉ(์บ๋ฆญํ„ฐ ์ƒํƒœ/์ž์„ธ/ํ™˜๊ฒฝ)๋งŒ ์•ต์ปค๋กœ ํŒŒ์•…
257
+ 2) ๊ทธ ๋งฅ๋ฝ ์œ„์—์„œ '๋‚ด๊ฐ€ ์ค€ ํ•„์ˆ˜ ๋™์ž‘(essential_actions)'๋งŒ 4์ƒท์œผ๋กœ ๋ถ„๋ฐฐ
258
+ 3) ํด๋ฆฝ๋งˆ๋‹ค ๋กœ๋ผ 4๊ฐœ์”ฉ ๊ฐœ๋ณ„ ์ ์šฉ(์›จ์ดํŠธ๋„ ๊ฐœ๋ณ„) + ํŠธ๋ฆฌ๊ฑฐ ์ž๋™ ์ฃผ์ž…
259
+ """
260
+
261
+ @classmethod
262
+ def INPUT_TYPES(s):
263
+ loras = ["None"] + (folder_paths.get_filename_list("loras") or [])
264
+ req = {
265
+ "image": ("IMAGE",),
266
+ "model_base": ("MODEL",),
267
+ "model_high": ("MODEL",),
268
+ "model_low": ("MODEL",),
269
+ "essential_actions": ("STRING", {"multiline": True,
270
+ "default": ("๋Œ์ง„ํ•œ๋‹ค, ๊ฒ€์„ ํœ˜๋‘˜๋Ÿฌ ์ ์„ ์“ฐ๋Ÿฌ๋œจ๋ฆฐ๋‹ค, ์ด์•Œ์„ ํŠ•๊ฒจ๋‚ธ๋‹ค, ์ˆจํ†ต์„ ๋Š๋Š”๋‹ค")}),
271
+ "num_clips": (["4 (20s)", "3 (15s)", "2 (10s)"], {"default": "4 (20s)",
272
+ "tooltip": "์‹ค์ œ๋กœ ๋ Œ๋”๋งํ•  ํด๋ฆฝ ์ˆ˜. Extend range(Fast Groups Bypasser) ํ† ๊ธ€๊ณผ ๋งž์ถฐ์„œ "
273
+ "์„ค์ •ํ•ด์•ผ ์Šคํ† ๋ฆฌ๊ฐ€ ๊ทธ ๊ธธ์ด์— ๋งž๊ฒŒ ๋ฐฐ๋ถ„๋จ. ๋‚˜๋จธ์ง€ ํด๋ฆฝ ์Šฌ๋กฏ์€ ๋งˆ์ง€๋ง‰ ํด๋ฆฝ ๋‚ด์šฉ์„ ๋ฐ˜๋ณต."}),
274
+ "character_name": ("STRING", {"default": "AUTO"}),
275
+ "artistic_vibe": ("STRING", {"multiline": True,
276
+ "default": "cinematic lighting, dynamic motion"}),
277
+ "pacing": (["auto", "slow-burn (1 beat)", "balanced (multi beat)", "rapid (4 distinct)"],
278
+ {"default": "auto"}),
279
+ "detail_level": (["์งง๊ฒŒ(๊ฐ„๊ฒฐ)", "ํ•„์ˆ˜+์•ต์ปค๋งŒ", "์ƒ์„ธ"], {"default": "ํ•„์ˆ˜+์•ต์ปค๋งŒ"}),
280
+ "inject_triggers": ("BOOLEAN", {"default": True,
281
+ "label_on": "TRIGGERS ON", "label_off": "TRIGGERS OFF"}),
282
+ "use_vision": ("BOOLEAN", {"default": True,
283
+ "label_on": "VISION ON", "label_off": "VISION OFF (text only)"}),
284
+ "openrouter_api_key": ("STRING", {"default": ""}),
285
+ "vlm_preset": (VLM_PRESET_LABELS, {"default": "๐Ÿ‘ Qwen2.5-VL 72B (ํŠน์ˆ˜์ƒํ™ฉยท์ถ”์ฒœ)"}),
286
+ "vlm_model": ("STRING", {"default": "qwen/qwen2.5-vl-72b-instruct"}),
287
+ "llm_preset": (LLM_PRESET_LABELS, {"default": "๐Ÿ”ž DeepSeek V3.2 (๋ฌด์‚ญ์ œยทJSON์•ˆ์ •ยท์ถ”์ฒœ)"}),
288
+ "llm_model": ("STRING", {"default": "deepseek/deepseek-v3.2"}),
289
+ "creativity": ("FLOAT", {"default": 0.7, "min": 0.1, "max": 1.5, "step": 0.05}),
290
+ "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
291
+ }
292
+ opt = {}
293
+ for c in range(1, 5):
294
+ for n in range(1, 5):
295
+ opt[f"c{c}_lora_{n}"] = (loras, {"default": "None"})
296
+ opt[f"c{c}_w_{n}"] = ("STRING", {"default": "1.0, 1.0, 1.0", "multiline": False})
297
+ opt["manual_triggers"] = ("STRING", {"default": "", "multiline": True})
298
+ opt["vision_caption"] = ("STRING", {"multiline": True, "default": "",
299
+ "forceInput": True})
300
+ return {"required": req, "optional": opt}
301
+
302
+ RETURN_TYPES = (
303
+ "MODEL", "MODEL", "MODEL", "STRING",
304
+ "MODEL", "MODEL", "MODEL", "STRING",
305
+ "MODEL", "MODEL", "MODEL", "STRING",
306
+ "MODEL", "MODEL", "MODEL", "STRING",
307
+ "STRING", "STRING", "STRING",
308
+ "BOOLEAN", "BOOLEAN", "BOOLEAN", "BOOLEAN",
309
+ )
310
+ RETURN_NAMES = (
311
+ "c1_base", "c1_high", "c1_low", "c1_prompt",
312
+ "c2_base", "c2_high", "c2_low", "c2_prompt",
313
+ "c3_base", "c3_high", "c3_low", "c3_prompt",
314
+ "c4_base", "c4_high", "c4_low", "c4_prompt",
315
+ "triggers", "scene_analysis", "debug_info",
316
+ "c1_is_final", "c2_is_final", "c3_is_final", "c4_is_final",
317
+ )
318
+ FUNCTION = "direct"
319
+ CATEGORY = "Dolphin"
320
+
321
+ def _build_final(self, action, master_scene, name, trigger_str):
322
+ head = ", ".join([p for p in [name, trigger_str] if p])
323
+ body = " ".join([p for p in [master_scene.strip().strip(",. "), action.strip()] if p])
324
+ if head and body:
325
+ return f"{head}\n{body}"
326
+ return head or body
327
+
328
+ def _pacing_hint(self, p):
329
+ return {
330
+ "auto": "Decide pacing yourself based on how many distinct beats the actions contain.",
331
+ "slow-burn (1 beat)": "One climactic beat: clips 1-3 build tension, clip 4 detonates.",
332
+ "balanced (multi beat)": "Early clips build, later clips deliver; keep motion continuous.",
333
+ "rapid (4 distinct)": "Four distinct consecutive actions, one per clip, each kinetic.",
334
+ }.get(p, "Decide pacing yourself.")
335
+
336
+ def _detail_hint(self, d):
337
+ return {
338
+ "์งง๊ฒŒ(๊ฐ„๊ฒฐ)": "Keep each clip to a short punchy motion phrase. Pure movement verbs only.",
339
+ "ํ•„์ˆ˜+์•ต์ปค๋งŒ": "Each clip is one concise continuous motion. No description, only movement.",
340
+ "์ƒ์„ธ": "Motion may be described in more detail, but ONLY body movement โ€” no scenery.",
341
+ }.get(d, "Motion only, no description.")
342
+
343
+ def _extract_json(self, text):
344
+ if not text:
345
+ return None
346
+ text = re.sub(r'```(?:json)?', '', text).strip()
347
+ a, b = text.find('{'), text.rfind('}')
348
+ if a == -1 or b <= a:
349
+ return None
350
+ blob = text[a:b + 1]
351
+ try:
352
+ return json.loads(blob)
353
+ except json.JSONDecodeError:
354
+ try:
355
+ return json.loads(re.sub(r',\s*([}\]])', r'\1', blob))
356
+ except json.JSONDecodeError:
357
+ return None
358
+
359
+ def _call_or(self, messages, model, key, temp, timeout=120, force_json=False):
360
+ payload = {"model": model.strip(), "messages": messages, "temperature": temp}
361
+ if force_json:
362
+ payload["response_format"] = {"type": "json_object"}
363
+ req = urllib.request.Request(
364
+ "https://openrouter.ai/api/v1/chat/completions",
365
+ data=json.dumps(payload).encode('utf-8'),
366
+ headers={'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'})
367
+ raw = json.loads(urllib.request.urlopen(req, timeout=timeout).read().decode('utf-8'))
368
+ if "choices" not in raw:
369
+ err = raw.get("error") or raw
370
+ msg = err.get("message") if isinstance(err, dict) else str(err)
371
+ code = err.get("code") if isinstance(err, dict) else "?"
372
+ raise ValueError(f"API error (code={code}): {msg}")
373
+ return raw['choices'][0]['message']['content'].strip()
374
+
375
+ def _vision_analyze(self, data_uri, vlm_model, key, temp):
376
+ sys = ("You are a visual scene analyst for a video pipeline. "
377
+ "Look at the image and extract ONLY concrete anchors, no storytelling. "
378
+ "Report exactly these lines:\n"
379
+ "(1) character appearance & clothing.\n"
380
+ "(2) current pose/posture (sitting, crouched, standing, balance, limb positions).\n"
381
+ "(3) SUPPORT & CONTACT: what the character is on top of, sitting on, holding, "
382
+ "leaning against, or touching (e.g. 'seated balanced on a yoga ball', "
383
+ "'gripping a sword', 'kneeling on the floor'). This is critical for physics โ€” "
384
+ "state it explicitly even if obvious.\n"
385
+ "(4) environment & lighting (brief).\n"
386
+ "Be terse, one short line each. English only. "
387
+ "Do not invent actions or narrative. If content is mature, describe it plainly and factually.")
388
+ messages = [
389
+ {"role": "system", "content": sys},
390
+ {"role": "user", "content": [
391
+ {"type": "text", "text": "Extract the anchors from this image."},
392
+ {"type": "image_url", "image_url": {"url": data_uri}},
393
+ ]},
394
+ ]
395
+ import time
396
+ last_err = "vision error"
397
+ for attempt in range(1, 4):
398
+ try:
399
+ resp = self._call_or(messages, vlm_model, key, temp, timeout=90).strip()
400
+ if not resp:
401
+ print(f"โš ๏ธ [Director] VLM({vlm_model}) ์‘๋‹ต์ด ๋น„์—ˆ์Œ (๊ฑฐ๋ถ€ or ๋นˆ ๋ฐ˜ํ™˜)")
402
+ return "", "vision empty response"
403
+ print(f"๐Ÿ‘ [Director] VLM({vlm_model}) anchors (attempt {attempt}):\n{resp[:300]}")
404
+ return resp, None
405
+ except urllib.error.HTTPError as e:
406
+ body = ""
407
+ try:
408
+ body = e.read().decode('utf-8')[:200]
409
+ except Exception:
410
+ pass
411
+ print(f"โŒ [Director] VLM HTTP {e.code} (attempt {attempt}): {body}")
412
+ last_err = f"vision HTTP {e.code}"
413
+ if e.code in (429, 503) and attempt < 3:
414
+ wait = 2 ** attempt
415
+ print(f"โณ [Director] rate limited, {wait}s ํ›„ ์žฌ์‹œ๋„...")
416
+ time.sleep(wait)
417
+ continue
418
+ return "", last_err
419
+ except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as e:
420
+ print(f"โŒ [Director] VLM error (attempt {attempt}): {type(e).__name__}: {e}")
421
+ last_err = f"vision error: {e}"
422
+ if attempt < 3:
423
+ time.sleep(2 ** attempt)
424
+ continue
425
+ return "", last_err
426
+ return "", last_err
427
+
428
+ def _fallback_name(self, anchors):
429
+ import random
430
+ a = (anchors or "").lower()
431
+ east = ["asian", "east asian", "japanese", "korean", "chinese", "anime",
432
+ "kimono", "hanbok", "qipao", "oriental"]
433
+ western = ["western", "european", "caucasian", "american", "blonde", "redhead"]
434
+ east_names = ["Yuna", "Kaede", "Jin", "Mei", "Haruka", "Rin", "Sora", "Aoi"]
435
+ west_names = ["Elena", "Ryan", "Ava", "Lucas", "Mila", "Ethan", "Nora", "Leo"]
436
+ if any(k in a for k in east):
437
+ pool = east_names
438
+ elif any(k in a for k in western):
439
+ pool = west_names
440
+ else:
441
+ pool = east_names + west_names
442
+ return random.choice(pool)
443
+
444
+ def _split_actions_to_clips(self, actions, num_clips=4):
445
+ parts = re.split(r'(?<=[.!?ใ€‚])\s+|[,ใ€]|\n+', actions)
446
+ parts = [p.strip() for p in parts if len(p.strip()) > 1]
447
+ if not parts:
448
+ parts = [actions.strip()]
449
+ n = len(parts)
450
+ last = num_clips
451
+ assign = {c: [] for c in range(1, num_clips + 1)}
452
+ if num_clips == 1:
453
+ assign[1] = parts
454
+ return assign
455
+ build_labels = ["(build-up toward)", "(begin)", "(escalate)", "(continue escalating)"]
456
+ if n == 1:
457
+ for c in range(1, last):
458
+ label = build_labels[min(c - 1, len(build_labels) - 1)]
459
+ assign[c] = [f"{label} {parts[0]}"]
460
+ assign[last] = [parts[0]]
461
+ elif n <= num_clips:
462
+ assign[last] = [parts[-1]]
463
+ head = parts[:-1]
464
+ for i, ph in enumerate(head):
465
+ assign[min(i + 1, last - 1)].append(ph)
466
+ for c in range(1, last):
467
+ if not assign[c]:
468
+ nxt = parts[min(c, n - 1)]
469
+ assign[c] = [f"(move toward) {nxt}"]
470
+ else:
471
+ assign[last] = [parts[-1]]
472
+ rest = parts[:-1]
473
+ k, m = divmod(len(rest), last - 1)
474
+ idx = 0
475
+ for c in range(1, last):
476
+ end = idx + k + (1 if (c - 1) < m else 0)
477
+ assign[c] = rest[idx:end]
478
+ idx = end
479
+ return assign
480
+
481
+ def _plan_shots(self, anchors, actions, name, vibe, pacing, detail, llm_model, key, temp, seed, num_clips=4):
482
+ assign = self._split_actions_to_clips(actions, num_clips)
483
+ assign_block = "\n".join(
484
+ f" clip_{c}: {', '.join(assign[c]) if assign[c] else '(continue previous motion)'}"
485
+ for c in range(1, num_clips + 1))
486
+
487
+ last_clip = num_clips
488
+ total_seconds = num_clips * 5
489
+ if num_clips >= 3:
490
+ chain = " and ".join(f"clip_{c}<-clip_{c-1}" for c in range(3, num_clips + 1))
491
+ continuity_middle = (f". The same applies {chain}: each clip picks up exactly where the "
492
+ f"previous clip's body position left off")
493
+ else:
494
+ continuity_middle = ""
495
+
496
+ continuity_rule = ""
497
+ if num_clips >= 2:
498
+ continuity_rule = (
499
+ "3) INTER-CLIP CONTINUITY. clip_2 MUST begin from the exact physical end-state of "
500
+ "clip_1's action (same position, body orientation, and momentum) โ€” it continues the "
501
+ "motion, it does not restart from a neutral stance" + continuity_middle +
502
+ f", as one unbroken {total_seconds}-second performance rather than {num_clips} separate poses. Do "
503
+ "NOT explicitly narrate the hand-off (no phrases like 'continuing from', 'still', 'as "
504
+ "before', 'picking up where') โ€” simply choreograph the new action so it is kinematically "
505
+ "consistent with how the previous action would have left the body positioned. This is a "
506
+ "physical-continuity constraint, not a scripted transition line.\n"
507
+ )
508
+
509
+ json_clip_fields = ",".join(
510
+ f'"clip_{i}":"<only clip_{i} action{", the finisher" if i == last_clip else ""}, '
511
+ f'continuous & fast>"' for i in range(1, num_clips + 1))
512
+
513
+ sys = (
514
+ f"You are an elite fight/action choreographer for a {total_seconds}-second video made of "
515
+ f"{num_clips} consecutive 5-second clip{'s' if num_clips != 1 else ''}. You are given SCENE ANCHORS "
516
+ "(the character's starting look/pose/place from an image) and a STRICT per-clip action "
517
+ "assignment.\n"
518
+ "\n"
519
+ "ABSOLUTE RULES:\n"
520
+ "1) FOLLOW THE ASSIGNMENT EXACTLY. Each clip animates ONLY the action(s) assigned "
521
+ "to it below. You MUST NOT move a later clip's action into an earlier clip. "
522
+ f"clip_1 must NOT contain clip_{last_clip}'s action. The final action happens ONLY in "
523
+ f"clip_{last_clip}. "
524
+ "This is the most important rule - violating the order is a failure. "
525
+ "(ONE exception: clip_1 may prepend a brief transition OUT of the starting physics from "
526
+ "rule 2 โ€” e.g. leaving the yoga ball โ€” before its assigned action. This is not borrowing "
527
+ "a later action; it is grounding the first one.)\n"
528
+ "2) STARTING PHYSICS (use the anchors here). Read the character's CURRENT pose AND "
529
+ "what they are supported by / in contact with from the SCENE ANCHORS (e.g. seated on a "
530
+ "yoga ball, kneeling, gripping a weapon, leaning on a wall). clip_1 MUST begin the first "
531
+ "action FROM that exact physical situation and honor its constraints. If the support is "
532
+ "unstable or unusual (a yoga ball, a ledge, a moving surface), the motion MUST account "
533
+ "for it โ€” e.g. anchor 'seated balanced on a yoga ball' -> clip_1 = 'pushing off the "
534
+ "wobbling ball and rising to their feet as it rolls away' BEFORE any further action. "
535
+ "Never ignore or contradict the starting support (do not stand if seated on the ball "
536
+ "without first leaving it; do not assume a weapon is drawn if the anchor shows it "
537
+ "sheathed). This makes the motion physically continuous with the input image. Reference "
538
+ "the support ONLY to ground how the motion STARTS โ€” do NOT describe its appearance, "
539
+ "color, or the wider environment.\n"
540
+ + continuity_rule +
541
+ "4) ACTION ONLY. Write physical body movement. NO scenery, NO lighting, NO clothing, "
542
+ "NO mood, NO camera talk. If a word is not a movement or body part, delete it. "
543
+ "(Referring to the starting pose in clip_1 per rule 2, or to the prior clip's ending "
544
+ "position per rule 3, is allowed since it describes how the body moves.)\n"
545
+ "5) FILL 5 SECONDS. Expand the assigned action into a single continuous flow of motion "
546
+ "that occupies the whole clip (use 'then', 'immediately', 'without pausing'). "
547
+ "Do not borrow the next action to fill time - elaborate the CURRENT action instead.\n"
548
+ "6) ANTI-SLOW-MOTION: real-time speed, kinetic adverbs (swiftly, rapidly, explosively, "
549
+ "in a split second). NO slow motion, NO freeze, NO holding a pose.\n"
550
+ "7) CHARACTER NAME: study the SCENE ANCHORS. Judge the character's apparent ethnicity/"
551
+ "setting (East Asian, Western, etc.) and INVENT a specific fitting first name that "
552
+ "matches it (e.g. East Asian -> 'Yuna','Kaede','Jin'; Western -> 'Elena','Ryan'). "
553
+ "NEVER output 'AUTO' or an empty name. If a NAME is given below (not 'AUTO'), use it.\n"
554
+ f"8) {detail}\n"
555
+ "9) MOTION ENERGY: a MOTION STYLE cue is given below (e.g. 'cinematic lighting, dynamic "
556
+ "motion'). Use it ONLY to calibrate how forceful/graceful/frantic the movement FEELS "
557
+ "(word choice, verb intensity). Do NOT quote it and do NOT let any scenery/lighting/mood "
558
+ "words from it leak into the output โ€” rule 4 still applies.\n"
559
+ "\n"
560
+ "master_scene: leave it EMPTY.\n"
561
+ "\n"
562
+ 'OUTPUT ONLY JSON: {"character":"<a real name, never AUTO>","master_scene":"",'
563
+ + json_clip_fields + "}"
564
+ )
565
+ usr = (f"NAME: {name}\nPACING: {self._pacing_hint(pacing)}\n\n"
566
+ f"MOTION STYLE (kinetic energy cue only, per rule 9 โ€” do not describe scenery/"
567
+ f"lighting/mood from this): {vibe.strip() or '(none)'}\n\n"
568
+ f"SCENE ANCHORS (use the POSE to start clip_1's motion per rule 2; "
569
+ f"use overall look only for naming โ€” do NOT copy look into the clips):\n"
570
+ f"{anchors or '(none โ€” assume a neutral ready stance)'}\n\n"
571
+ f"STRICT PER-CLIP ACTION ASSIGNMENT (animate ONLY what is listed per clip, "
572
+ f"in this order, finisher in clip_{last_clip}):\n{assign_block}\n")
573
+ messages = [{"role": "system", "content": sys}, {"role": "user", "content": usr}]
574
+ t = temp
575
+ last = "โš ๏ธ plan failed (no attempts ran)"
576
+ for attempt in (1, 2):
577
+ try:
578
+ content = self._call_or(messages, llm_model, key, t, force_json=True)
579
+ parsed = self._extract_json(content)
580
+ if parsed and all(parsed.get(f"clip_{i}") for i in range(1, num_clips + 1)):
581
+ return parsed, f"โœ… plan ok (attempt {attempt})"
582
+ last = f"โš ๏ธ incomplete JSON (attempt {attempt}): {content[:150]!r}"
583
+ print(f"โš ๏ธ [Director] plan attempt {attempt} returned incomplete JSON: {content[:300]}")
584
+ except urllib.error.HTTPError as e:
585
+ body = ""
586
+ try:
587
+ body = e.read().decode('utf-8')[:200]
588
+ except Exception:
589
+ pass
590
+ last = f"โŒ plan HTTP {e.code} (attempt {attempt}): {body}"
591
+ print(f"โŒ [Director] plan {last}")
592
+ except (urllib.error.URLError, TimeoutError, KeyError, ValueError) as e:
593
+ last = f"โŒ plan error (attempt {attempt}): {type(e).__name__}: {e}"
594
+ print(f"โŒ [Director] {last}")
595
+ t = min(temp, 0.4)
596
+ return None, last
597
+
598
+ def _local_split(self, actions, num_clips=4):
599
+ parts = re.split(r'(?<=[.!?ใ€‚])\s+|[,ใ€]|\n+', actions)
600
+ parts = [p.strip() for p in parts if len(p.strip()) > 1]
601
+ if not parts:
602
+ parts = [actions.strip()]
603
+ n = len(parts)
604
+ last = num_clips - 1
605
+
606
+ def fast(phrase, lead="swiftly"):
607
+ p = phrase.strip()
608
+ return f"{lead} {p}, continuous real-time motion, no slow motion" if p else ""
609
+
610
+ if num_clips == 1:
611
+ return [fast(", ".join(parts), "explosively")]
612
+
613
+ if n == 1:
614
+ a = parts[0]
615
+ templates = [
616
+ f"rapidly moves into position toward {a}, no pause",
617
+ f"immediately begins {a}, brisk continuous motion, no slow motion",
618
+ f"presses {a} without stopping, fast decisive movement",
619
+ ]
620
+ out = [templates[min(i, len(templates) - 1)] for i in range(num_clips - 1)]
621
+ out.append(f"explosively completes {a} at real-time speed, no slow motion, no freeze")
622
+ elif n <= num_clips:
623
+ out = [""] * num_clips
624
+ out[last] = fast(parts[-1], "explosively")
625
+ head = parts[:-1]
626
+ for i, ph in enumerate(head):
627
+ s = min(i, num_clips - 2)
628
+ out[s] = (out[s] + ", then " + fast(ph)).strip(", ") if out[s] else fast(ph)
629
+ for i in range(num_clips - 1):
630
+ if not out[i]:
631
+ key = parts[min(i, n - 1)]
632
+ out[i] = f"rapidly moves toward {key}, brisk continuous motion, no slow motion"
633
+ else:
634
+ k, m = divmod(n - 1, num_clips - 1)
635
+ idx, chunks = 0, []
636
+ for i in range(num_clips - 1):
637
+ end = idx + k + (1 if i < m else 0)
638
+ seg = parts[idx:end]
639
+ idx = end
640
+ chunks.append(", then ".join(fast(p) for p in seg) if seg else "")
641
+ out = chunks + [fast(parts[-1], "explosively")]
642
+ return out[:num_clips]
643
+
644
+ def _load(self, name):
645
+ return comfy.utils.load_torch_file(folder_paths.get_full_path("loras", name))
646
+
647
+ @staticmethod
648
+ def _parse_weights(text):
649
+ try:
650
+ nums = [float(x) for x in re.split(r'[,\s]+', text.strip()) if x != ""]
651
+ except (ValueError, AttributeError):
652
+ nums = []
653
+ if not nums:
654
+ return 1.0, 1.0, 1.0
655
+ if len(nums) == 1:
656
+ return nums[0], nums[0], nums[0]
657
+ if len(nums) == 2:
658
+ return nums[0], nums[1], nums[1]
659
+ return nums[0], nums[1], nums[2]
660
+
661
+ def _patch_clip(self, mb, mh, ml, slots, cache, inject):
662
+ cb, ch, cl = mb, mh, ml
663
+ used = []
664
+ for (name, w_str) in slots:
665
+ if name == "None":
666
+ continue
667
+ w_b, w_h, w_l = self._parse_weights(w_str)
668
+ if w_b == 0.0 and w_h == 0.0 and w_l == 0.0:
669
+ continue
670
+ data = self._load(name)
671
+ if w_b != 0.0:
672
+ cb = apply_model_lora(cb, data, w_b)
673
+ if w_h != 0.0:
674
+ ch = apply_model_lora(ch, data, w_h)
675
+ if w_l != 0.0:
676
+ cl = apply_model_lora(cl, data, w_l)
677
+ used.append(name)
678
+ trig = []
679
+ trig_dbg = []
680
+ for n in dict.fromkeys(used):
681
+ found = get_triggers(n, cache)
682
+ trig.extend(found)
683
+ trig_dbg.append(f"{os.path.basename(n)}:{len(found)}")
684
+ self._last_trig_dbg = trig_dbg
685
+ trig_str = ", ".join(dict.fromkeys(t for t in trig if t))
686
+ prompt_trig = trig_str if inject else ""
687
+ return cb, ch, cl, prompt_trig, trig_str
688
+
689
+ def direct(self, image, model_base, model_high, model_low, essential_actions, num_clips,
690
+ character_name, artistic_vibe, pacing, detail_level, inject_triggers,
691
+ use_vision, openrouter_api_key, vlm_preset, vlm_model, llm_preset, llm_model,
692
+ creativity, seed, **kw):
693
+
694
+ n_clips = int(str(num_clips).split()[0])
695
+ vlm = resolve_slug(VLM_PRESETS, vlm_preset, vlm_model, "qwen/qwen2.5-vl-72b-instruct")
696
+ llm = resolve_slug(LLM_PRESETS, llm_preset, llm_model, "deepseek/deepseek-v3.2")
697
+ user_name = "" if character_name.strip().upper() in ["AUTO", ""] else character_name.strip()
698
+ key = resolve_api_key(openrouter_api_key)
699
+
700
+ vision_caption = str(kw.get("vision_caption", "") or "").strip()
701
+ anchors, dbg_v = "", ""
702
+ if vision_caption:
703
+ anchors = vision_caption
704
+ dbg_v = "vision ok (local caption node)"
705
+ print(f"๐Ÿ‘ [Director] ๋กœ์ปฌ ์บก์…˜ ์ž…๋ ฅ ์‚ฌ์šฉ (JoyCaption ๋“ฑ):\n{anchors[:300]}")
706
+ elif not use_vision:
707
+ dbg_v = "vision OFF (ํ† ๊ธ€ ํ™•์ธ)"
708
+ print("โ„น๏ธ [Director] use_vision=OFF - ๋น„์ „ ๊ฑด๋„ˆ๋œ€")
709
+ elif not key:
710
+ dbg_v = "no api key"
711
+ print("โŒ [Director] openrouter_api_key ๋น„์–ด์žˆ์Œ (์œ„์ ฏ๋„, OPENROUTER_API_KEY ํ™˜๊ฒฝ๋ณ€์ˆ˜๋„ ์—†์Œ) - ๋น„์ „/LLM ๋ถˆ๊ฐ€")
712
+ elif image is None:
713
+ dbg_v = "no image"
714
+ print("โŒ [Director] image ์ž…๋ ฅ ์—†์Œ - IMAGE ์—ฐ๊ฒฐ ํ™•์ธ")
715
+ else:
716
+ data_uri = tensor_to_base64(image)
717
+ if data_uri:
718
+ anchors, err = self._vision_analyze(data_uri, vlm, key, min(creativity, 0.5))
719
+ dbg_v = err or "vision ok"
720
+ else:
721
+ dbg_v = "image encode failed / PIL missing"
722
+ if not vision_caption and use_vision and key and image is not None and not anchors:
723
+ print(f"โš ๏ธ [Director] ๋น„์ „ ์•ต์ปค๊ฐ€ ๋น„์—ˆ์Œ -> ์ด๋ฆ„/์”ฌ ๊ทผ๊ฑฐ ์—†์Œ. ์›์ธ: {dbg_v}")
724
+
725
+ if key:
726
+ parsed, dbg_p = self._plan_shots(anchors, essential_actions, character_name,
727
+ artistic_vibe, pacing, self._detail_hint(detail_level),
728
+ llm, key, creativity, seed, num_clips=n_clips)
729
+ if parsed:
730
+ clips = [str(parsed[f"clip_{i}"]).strip() for i in range(1, n_clips + 1)]
731
+ master = str(parsed.get("master_scene", "")).strip()
732
+ cand = str(parsed.get("character", "")).strip()
733
+ if not cand or cand.upper() == "AUTO":
734
+ cand = self._fallback_name(anchors)
735
+ final_name = user_name or cand
736
+ else:
737
+ clips = self._local_split(essential_actions, num_clips=n_clips)
738
+ master = anchors.replace("\n", " ")[:160]
739
+ final_name = user_name or self._fallback_name(anchors)
740
+ else:
741
+ parsed, dbg_p = None, "no api key"
742
+ clips = self._local_split(essential_actions, num_clips=n_clips)
743
+ master = anchors.replace("\n", " ")[:160]
744
+ final_name = user_name
745
+
746
+ if len(clips) < 4:
747
+ clips = clips + [clips[-1]] * (4 - len(clips))
748
+
749
+ cache = _load_cache()
750
+ manual_list = [t.strip() for t in kw.get("manual_triggers", "").split(',') if t.strip()]
751
+ outs = []
752
+ all_trigs_collected = []
753
+ for c in range(1, 5):
754
+ if c > n_clips:
755
+ cb = ch = cl = ExecutionBlocker(None)
756
+ prompt = self._build_final(clips[c - 1], master, final_name, "")
757
+ outs.extend([cb, ch, cl, prompt])
758
+ print(f" [clip{c}] SKIPPED (num_clips={n_clips}) - ExecutionBlocker ๋ฐ˜ํ™˜")
759
+ continue
760
+ slots = [(kw.get(f"c{c}_lora_{n}", "None"),
761
+ kw.get(f"c{c}_w_{n}", "1.0, 1.0, 1.0")) for n in range(1, 5)]
762
+ cb, ch, cl, prompt_trig, always_trig = self._patch_clip(
763
+ model_base, model_high, model_low, slots, cache, inject_triggers)
764
+ all_trig = ", ".join(dict.fromkeys(
765
+ ([prompt_trig] if prompt_trig else []) + manual_list)) \
766
+ if (prompt_trig or manual_list) else ""
767
+ if always_trig:
768
+ all_trigs_collected.extend(t.strip() for t in always_trig.split(',') if t.strip())
769
+ prompt = self._build_final(clips[c - 1], master, final_name, all_trig)
770
+ outs.extend([cb, ch, cl, prompt])
771
+ print(f" [clip{c}] loras/triggers: {getattr(self, '_last_trig_dbg', [])} "
772
+ f"inject={inject_triggers}")
773
+
774
+ triggers_out = ", ".join(dict.fromkeys(
775
+ [t for t in all_trigs_collected if t] + manual_list))
776
+
777
+ debug = f"vlm={dbg_v} | plan={dbg_p} | char={final_name} | clips={n_clips} ({n_clips*5}s)"
778
+ print(f"\n๐ŸŽฌ [Dolphin Vision Director] {debug}\nANCHORS: {anchors[:120]}")
779
+ for i in range(4):
780
+ print(f" CLIP {i+1}: {outs[i*4+3][:90]}")
781
+
782
+ is_final = tuple(c == n_clips for c in range(1, 5))
783
+
784
+ return tuple(outs) + (triggers_out, anchors, debug) + is_final
785
+
786
+
787
+ NODE_CLASS_MAPPINGS = {"DolphinVisionDirector": DolphinVisionDirector}
788
+ NODE_DISPLAY_NAME_MAPPINGS = {"DolphinVisionDirector": "๐ŸŽฌ๐Ÿ‘ Dolphin Vision Director (Imageโ†’4Clips)"}
promp_logic_v32_vision.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import re
4
+ import json
5
+ import urllib.request
6
+ import urllib.error
7
+ import base64
8
+ import io
9
+ import time
10
+ import numpy as np
11
+ from PIL import Image
12
+ import torch
13
+
14
+
15
+ class DolphinMultiActionPromptNode_V32:
16
+ @classmethod
17
+ def INPUT_TYPES(s):
18
+ return {
19
+ "required": {
20
+ "image": ("IMAGE",),
21
+ "mode": (["๐Ÿค– Auto Vision+LLM", "โœ๏ธ Manual Override"], {"default": "๐Ÿค– Auto Vision+LLM"}),
22
+ "character_name": ("STRING", {"multiline": False, "default": "AUTO"}),
23
+ "artistic_vibe": ("STRING", {"multiline": True, "default": "cinematic lighting, high-speed action, dark fantasy"}),
24
+
25
+ "master_story": ("STRING", {
26
+ "multiline": True,
27
+ "default": "์–ด๋‘์šด ๊ณจ๋ชฉ๊ธธ. ๊ฐ‘์ž๊ธฐ ๋‚˜ํƒ€๋‚œ ์ ๋“ค์„ ํ–ฅํ•ด ๋Œ์ง„ํ•œ๋‹ค, ํ™”๋ คํ•˜๊ฒŒ ๊ฒ€์„ ํœ˜๋‘˜๋Ÿฌ ์ ์„ ์“ฐ๋Ÿฌ๋œจ๋ฆฐ๋‹ค, ๋‚ ์•„์˜ค๋Š” ์ด์•Œ์„ ํŠ•๊ฒจ๋‚ธ๋‹ค, ์ ์—๊ฒŒ ๋‹ค๊ฐ€๊ฐ€ ์ˆจํ†ต์„ ๋Š๋Š”๋‹ค."
28
+ }),
29
+
30
+ "openrouter_api_key": ("STRING", {"multiline": False, "default": ""}),
31
+ "openrouter_model": ("STRING", {"multiline": False, "default": "qwen/qwen-2-vl-72b-instruct"}),
32
+ "creativity": ("FLOAT", {"default": 0.85, "min": 0.1, "max": 1.5, "step": 0.05}),
33
+ "max_tokens": ("INT", {"default": 1500, "min": 256, "max": 8192, "step": 64}),
34
+ "retries": ("INT", {"default": 2, "min": 0, "max": 5}),
35
+ "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
36
+ },
37
+ }
38
+
39
+ RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
40
+ RETURN_NAMES = (
41
+ "prompt_1 (Clip 1: 0-5s)",
42
+ "prompt_2 (Clip 2: 0-5s)",
43
+ "prompt_3 (Clip 3: 0-5s)",
44
+ "prompt_4 (Clip 4: 0-5s)",
45
+ "raw_llm_output",
46
+ )
47
+ FUNCTION = "generate_sequence"
48
+ CATEGORY = "Dolphin"
49
+
50
+ # -----------------------------------------------------------------
51
+ def _encode_image(self, image):
52
+ img_tensor = image[0]
53
+ i = 255. * img_tensor.cpu().numpy()
54
+ img_pil = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
55
+ if img_pil.mode != "RGB":
56
+ img_pil = img_pil.convert("RGB")
57
+ buffered = io.BytesIO()
58
+ img_pil.save(buffered, format="JPEG", quality=90)
59
+ b64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
60
+ return f"data:image/jpeg;base64,{b64}"
61
+
62
+ def _split_sentences(self, master_story):
63
+ clean_story = re.sub(r'([.!?,\n])', r'\1|', master_story)
64
+ raw_sentences = clean_story.split('|')
65
+ return [s.strip() for s in raw_sentences if len(s.strip()) > 1]
66
+
67
+ def _build_chunks(self, sentences):
68
+ n = len(sentences)
69
+ if n == 0:
70
+ base = "dynamic high-speed action"
71
+ return (base, base, base, base)
72
+ if n == 1:
73
+ s = sentences[0]
74
+ return (
75
+ f"Phase 1: Rapid approach and high-speed dynamic movement. DO NOT stand still. (Target: {s})",
76
+ f"Phase 2: Swift, explosive execution of the action. (Target: {s})",
77
+ f"Phase 3: The climax at full 1x real-time speed. Lightning fast! (Target: {s})",
78
+ f"Phase 4: Fast-paced completion and quick recovery. (Target: {s})",
79
+ )
80
+ if n == 2:
81
+ return (
82
+ f"Phase 1: High-speed buildup and rapid preparation. (Target: {sentences[0]})",
83
+ f"Phase 2: Explosively execute -> {sentences[0]}",
84
+ f"Phase 3: Rapid transition, sprinting or moving quickly. (Target: {sentences[1]})",
85
+ f"Phase 4: Lightning-fast execution -> {sentences[1]}",
86
+ )
87
+ if n == 3:
88
+ return (
89
+ f"Phase 1: Start this action rapidly -> {sentences[0]}",
90
+ f"Phase 2: Explosively complete -> {sentences[0]}",
91
+ sentences[1],
92
+ sentences[2],
93
+ )
94
+ # n >= 4: ๊ท ๋“ฑ ๋ถ„๋ฐฐ
95
+ k, m = divmod(n, 4)
96
+ chunks = []
97
+ start = 0
98
+ for idx in range(4):
99
+ end = start + k + (1 if idx < m else 0)
100
+ chunks.append(" ".join(sentences[start:end]))
101
+ start = end
102
+ return tuple(chunks)
103
+
104
+ def _call_llm(self, url, payload, api_key, retries, timeout=120):
105
+ last_err = None
106
+ for attempt in range(retries + 1):
107
+ try:
108
+ req = urllib.request.Request(
109
+ url,
110
+ data=json.dumps(payload).encode('utf-8'),
111
+ headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'}
112
+ )
113
+ response = urllib.request.urlopen(req, timeout=timeout)
114
+ body = json.loads(response.read().decode('utf-8'))
115
+ return body['choices'][0]['message']['content'].strip(), None
116
+ except Exception as e:
117
+ last_err = e
118
+ if attempt < retries:
119
+ time.sleep(1.5 * (attempt + 1))
120
+ return None, last_err
121
+
122
+ # -----------------------------------------------------------------
123
+ def generate_sequence(self, image, mode, character_name, artistic_vibe, master_story,
124
+ openrouter_api_key, openrouter_model, creativity, max_tokens, retries, seed):
125
+
126
+ user_defined_name = "" if character_name.upper() in ["AUTO", ""] else character_name.strip()
127
+
128
+ def build_final(action, master_scene, name):
129
+ tags_list = []
130
+ if name:
131
+ tags_list.append(name)
132
+ tag_block = ", ".join(tags_list)
133
+
134
+ sentence_list = []
135
+ if master_scene.strip():
136
+ sentence_list.append(master_scene.strip().strip(",. "))
137
+ if action.strip():
138
+ sentence_list.append(action.strip())
139
+ sentence_block = " ".join(sentence_list)
140
+
141
+ if tag_block and sentence_block:
142
+ return f"{tag_block}\n{sentence_block}"
143
+ elif tag_block:
144
+ return tag_block
145
+ return sentence_block
146
+
147
+ # ---- Manual Override ----
148
+ if mode == "โœ๏ธ Manual Override":
149
+ fp = build_final(master_story, "", user_defined_name)
150
+ return (fp, fp, fp, fp, "[Manual Override]")
151
+
152
+ random.seed(seed)
153
+
154
+ base64_image = self._encode_image(image)
155
+ sentences = self._split_sentences(master_story)
156
+ chunk_1, chunk_2, chunk_3, chunk_4 = self._build_chunks(sentences)
157
+
158
+ sys_prompt = (
159
+ "You are an Elite Action Director prioritizing RAW SPEED and KINETIC ENERGY.\n"
160
+ f"1. CHARACTER: If NAME is 'AUTO', assign a name. If '{user_defined_name}', use it.\n"
161
+ "2. VISUAL ANALYSIS: You MUST base your descriptions EXACTLY on the character's clothing and weapons in the attached IMAGE.\n"
162
+ "3. MASTER SCENE: Write a 1-sentence environment description (lighting, weather).\n"
163
+ "4. SPEED-FOCUSED CHOREOGRAPHY (CRITICAL):\n"
164
+ " - ๐Ÿšซ BAN SLOW-MOTION TRIGGERS: NEVER use words like 'micro-expressions', 'muscle tension', 'slowly turning', 'floating', or 'gradually'. These cause AI video models to render in slow-motion.\n"
165
+ " - โœ… FORCE 1x REAL-TIME SPEED: Describe large, sweeping, high-velocity movements. Use aggressive verbs (dashing, sprinting, whipping, snapping).\n"
166
+ " - โœ… KINETIC ADVERBS: Inject phrases like 'in a flash', 'at lightning speed', 'with explosive real-time velocity' into EVERY part.\n"
167
+ " - Example: 'suddenly dashes forward at full speed and delivers a lightning-fast horizontal strike, moving so quickly the rain splatters'.\n"
168
+ " - Strictly confine the actions. DO NOT animate future events early.\n"
169
+ " - ๐Ÿ”ฅ OUTPUT RULE: DO NOT quote the Korean text. Only output English.\n"
170
+ "Format EXACTLY:\nCHARACTER: [Name]\nMASTER SCENE: [Description]\n"
171
+ "PART 1: [0-1s] [Action A] [2-3s] [Action B] [4-5s] [Action C]\n"
172
+ "PART 2: [0-1s] [Action D] [2-3s] [Action E] [4-5s] [Action F]\n"
173
+ "PART 3: [0-1s] [Action G] [2-3s] [Action H] [4-5s] [Action I]\n"
174
+ "PART 4: [0-1s] [Action J] [2-3s] [Action K] [4-5s] [Action L]"
175
+ )
176
+
177
+ usr_text = (
178
+ f"NAME: {character_name}\n"
179
+ f"VIBE: {artistic_vibe}\n\n"
180
+ "=== HIGH-SPEED ACTION SCRIPT ===\n"
181
+ f"โ–ถ For PART 1 (0-5s), ONLY animate this: \"{chunk_1}\"\n"
182
+ f"โ–ถ For PART 2 (5-10s), ONLY animate this: \"{chunk_2}\"\n"
183
+ f"โ–ถ For PART 3 (10-15s), ONLY animate this: \"{chunk_3}\"\n"
184
+ f"โ–ถ For PART 4 (15-20s), ONLY animate this: \"{chunk_4}\"\n"
185
+ "CRITICAL: Keep the action moving FAST. Avoid still poses or micro-details that look like slow-mo!"
186
+ )
187
+
188
+ url = "https://openrouter.ai/api/v1/chat/completions"
189
+ payload = {
190
+ "model": openrouter_model.strip(),
191
+ "messages": [
192
+ {"role": "system", "content": sys_prompt},
193
+ {"role": "user", "content": [
194
+ {"type": "text", "text": usr_text},
195
+ {"type": "image_url", "image_url": {"url": base64_image}}
196
+ ]}
197
+ ],
198
+ "temperature": creativity,
199
+ "max_tokens": max_tokens,
200
+ }
201
+
202
+ if not openrouter_api_key.strip():
203
+ err_msg = "โš ๏ธ API Error: OpenRouter API key is empty."
204
+ return (err_msg, err_msg, err_msg, err_msg, err_msg)
205
+
206
+ llm_prompt, err = self._call_llm(url, payload, openrouter_api_key, retries)
207
+ if llm_prompt:
208
+ print(f"\nโœ… [Dolphin V32 - Action Speed Optimized]\n{llm_prompt}\n")
209
+ else:
210
+ print(f"โŒ [์—๋Ÿฌ] API ํ˜ธ์ถœ ์‹คํŒจ: {err}")
211
+ llm_prompt = ""
212
+
213
+ final_char_name = user_defined_name
214
+ r_master = ""
215
+ p1 = p2 = p3 = p4 = ""
216
+
217
+ if llm_prompt and "[removed]" not in llm_prompt:
218
+ cl = re.sub(r'[*#]', '', llm_prompt)
219
+ m_char = re.search(r'CHARACTER:\s*(.*?)(?=MASTER SCENE|$)', cl, re.I | re.S)
220
+ m_master = re.search(r'MASTER SCENE:\s*(.*?)(?=PART 1|$)', cl, re.I | re.S)
221
+
222
+ m1 = re.search(r'PART 1:\s*(.*?)(?=PART 2|$)', cl, re.I | re.S)
223
+ m2 = re.search(r'PART 2:\s*(.*?)(?=PART 3|$)', cl, re.I | re.S)
224
+ m3 = re.search(r'PART 3:\s*(.*?)(?=PART 4|$)', cl, re.I | re.S)
225
+ m4 = re.search(r'PART 4:\s*(.*?)(?=\n\n|===|Note:|$)', cl, re.I | re.S)
226
+
227
+ if not user_defined_name and m_char:
228
+ final_char_name = m_char.group(1).strip()
229
+
230
+ r_master = m_master.group(1).strip() if m_master else ""
231
+
232
+ # ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ํ•ด๋‹น ์ฒญํฌ(์˜๋ฌธ ์ง€์‹œ๋ฌธ)๋ฅผ ํด๋ฐฑ์œผ๋กœ ์‚ฌ์šฉํ•ด ๋น„๋””์˜ค ํ”„๋กฌํ”„ํŠธ๊ฐ€ ๋น„์ง€ ์•Š๋„๋ก ํ•จ
233
+ p1 = m1.group(1).strip() if m1 else chunk_1
234
+ p2 = m2.group(1).strip() if m2 else chunk_2
235
+ p3 = m3.group(1).strip() if m3 else chunk_3
236
+ p4 = m4.group(1).strip() if m4 else chunk_4
237
+ else:
238
+ # API ์‹คํŒจ ์‹œ์—๋„ ์Šคํฌ๋ฆฝํŠธ ์ฒญํฌ๋ฅผ ํด๋ฐฑ์œผ๋กœ ๋ฐ˜ํ™˜ (์™„์ „ ์‹คํŒจ๋ณด๋‹ค ์œ ์šฉ)
239
+ p1, p2, p3, p4 = chunk_1, chunk_2, chunk_3, chunk_4
240
+
241
+ return (
242
+ build_final(p1, r_master, final_char_name),
243
+ build_final(p2, r_master, final_char_name),
244
+ build_final(p3, r_master, final_char_name),
245
+ build_final(p4, r_master, final_char_name),
246
+ llm_prompt if llm_prompt else "โš ๏ธ API Error / empty response",
247
+ )
wan_resizer.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+ import torch
3
+ import comfy.utils
4
+
5
+ class WanNativeResize_Dolphin:
6
+ @classmethod
7
+ def INPUT_TYPES(cls):
8
+ return {
9
+ "required": {
10
+ "image": ("IMAGE",),
11
+ "target_model": (["480P (832x480)", "720P (1280x720)", "1080P (1920x1080)"], {"default": "720P (1280x720)"}),
12
+ "upscale_method": (["nearest-exact", "bilinear", "area", "bicubic", "lanczos"], {"default": "lanczos"}),
13
+ "alignment": ("INT", {"default": 16, "min": 8, "max": 64, "step": 8}),
14
+ }
15
+ }
16
+
17
+ RETURN_TYPES = ("IMAGE", "INT", "INT")
18
+ RETURN_NAMES = ("IMAGE", "width", "height")
19
+ FUNCTION = "resize_for_wan"
20
+ CATEGORY = "Dolphin Node/Wan Video"
21
+
22
+ def resize_for_wan(self, image, target_model, upscale_method, alignment):
23
+ # 1. ComfyUI ์ด๋ฏธ์ง€ ํ…์„œ์˜ ํ˜•ํƒœ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค: (Batch/Frames, Height, Width, Channels)
24
+ b, h, w, c = image.shape
25
+
26
+ # 2. ํƒ€๊ฒŸ ๋ชจ๋ธ์— ๋”ฐ๋ฅธ ์ด ํ”ฝ์…€ ๋ฉด์ (Area) ์„ธํŒ…
27
+ if "480P" in target_model:
28
+ target_area = 832 * 480
29
+ elif "720P" in target_model:
30
+ target_area = 1280 * 720
31
+ else:
32
+ target_area = 1920 * 1080
33
+
34
+ # 3. Byungjoo๋‹˜์˜ ์ˆ˜ํ•™ ๋กœ์ง (๋น„์œจ ์œ ์ง€ ๊ณ„์‚ฐ)
35
+ aspect_ratio = w / h
36
+ new_h = math.sqrt(target_area / aspect_ratio)
37
+ new_w = new_h * aspect_ratio
38
+
39
+ # 4. [ํ•ต์‹ฌ] VAE๊ฐ€ ์ข‹์•„ํ•˜๋Š” ๋ฐฐ์ˆ˜(Alignment, ๋ณดํ†ต 16)๋กœ ๋ฐ˜์˜ฌ๋ฆผ ์ฒ˜๋ฆฌ
40
+ new_w = int(round(new_w / alignment) * alignment)
41
+ new_h = int(round(new_h / alignment) * alignment)
42
+
43
+ # 5. [์ตœ์ ํ™”] ์ด๋ฏธ ๋ชฉํ‘œ ํ•ด์ƒ๋„์™€ ์ผ์น˜ํ•œ๋‹ค๋ฉด, ๋ฌด๊ฑฐ์šด ์—ฐ์‚ฐ ์—†์ด ์›๋ณธ ํ†ต๊ณผ (ํŒจ์Šค์Šค๋ฃจ)
44
+ if w == new_w and h == new_h:
45
+ return (image, new_w, new_h)
46
+
47
+ # 6. [ํ•ต์‹ฌ] ํ…์„œ ์ฐจ์› ๋ณ€๊ฒฝ: ComfyUI (B, H, W, C) -> ๋ฆฌ์‚ฌ์ด์ฆˆ ์—”์ง„์šฉ (B, C, H, W)
48
+ image = image.movedim(-1, 1)
49
+
50
+ # 7. ComfyUI ๋‚ด์žฅ ์—”์ง„์„ ์‚ฌ์šฉํ•ด ์ดˆ๊ณ ํ™”์งˆ ๋ฆฌ์‚ฌ์ด์ฆˆ (Lanczos ์ง€์›)
51
+ resized_image = comfy.utils.common_upscale(image, new_w, new_h, upscale_method, "disabled")
52
+
53
+ # 8. ํ…์„œ ์ฐจ์› ์›์ƒ ๋ณต๊ตฌ: (B, C, H, W) -> ComfyUI (B, H, W, C)
54
+ resized_image = resized_image.movedim(1, -1)
55
+
56
+ return (resized_image, new_w, new_h)