parlorsky commited on
Commit
c76f614
·
verified ·
1 Parent(s): a0408fd

Update Oz custom node: multi-frame ShotAnalyzer, LoRACharacterPicker, ApplyCharacterLoRA, StitchReel, StringAtIndex, character_picker_sync.js, save_with_metadata OUTPUT_NODE fix

Browse files
ComfyUI_Oz/js/character_picker_sync.js ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ==========================================================================
2
+ // Oz LoRA Character Picker -> RealityPromptGenerator sync
3
+ // ==========================================================================
4
+ // Two mechanisms:
5
+ // 1) api.fetchApi hijack: when JS calls /oz/generate_creative_prompts,
6
+ // we replace `character_description` (the field Grok sees) with the
7
+ // picker's computed grok_instruction. This is the SOURCE OF TRUTH.
8
+ // 2) Live sync of picker widget changes -> RPG.properties.character_text_input
9
+ // (UI feedback only — Grok uses path 1 even if the textarea is stale).
10
+ //
11
+ // Activates ONLY if at least one ozW_LoRACharacterPicker is present in the
12
+ // graph. Falls back to existing behavior otherwise.
13
+ // ==========================================================================
14
+
15
+ import { app } from "../../scripts/app.js";
16
+ import { api } from "../../scripts/api.js";
17
+
18
+ const PICKER_TYPE = "ozW_LoRACharacterPicker";
19
+ const RPG_TYPE = "ozW_RealityPromptGenerator";
20
+ const SYNC_WIDGETS = new Set(["lora_name", "trigger_override", "character_description"]);
21
+ const TARGET_ENDPOINT = "/oz/generate_creative_prompts";
22
+ const LOG_PREFIX = "[ozw.picker_sync]";
23
+
24
+ // ---------- Helpers ----------
25
+ function getNodes() {
26
+ return app.graph?.nodes ?? app.graph?._nodes ?? [];
27
+ }
28
+
29
+ function findPickers() {
30
+ return getNodes().filter(
31
+ (n) => n && (n.type === PICKER_TYPE || n.comfyClass === PICKER_TYPE)
32
+ );
33
+ }
34
+
35
+ function findRPGs() {
36
+ return getNodes().filter(
37
+ (n) => n && (n.type === RPG_TYPE || n.comfyClass === RPG_TYPE)
38
+ );
39
+ }
40
+
41
+ function readWidget(node, name) {
42
+ return node?.widgets?.find((w) => w.name === name)?.value ?? "";
43
+ }
44
+
45
+ function escapeRegex(s) {
46
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ }
48
+
49
+ function stripTriggerPrefix(description, trigger) {
50
+ // Start-anchored, case-insensitive. Only strips when trigger is a whole
51
+ // token at the start (followed by end-of-string or a separator). Matches
52
+ // the Python `_build_grok_instruction` dedup exactly.
53
+ const re = new RegExp(
54
+ `^${escapeRegex(trigger)}(?=$|(?:\\s|[,;:\\-–—]))(?:\\s*[,;:\\-–—]\\s*|\\s+)*`,
55
+ "i"
56
+ );
57
+ return description.replace(re, "").trim();
58
+ }
59
+
60
+ function buildGrokInstruction(picker) {
61
+ if (!picker) return "";
62
+ const overrideRaw = readWidget(picker, "trigger_override");
63
+ const loraName = readWidget(picker, "lora_name") || "";
64
+ let description = (readWidget(picker, "character_description") || "")
65
+ .toString()
66
+ .trim();
67
+
68
+ let trigger = (overrideRaw || "").toString().trim();
69
+ if (!trigger) {
70
+ if (loraName && loraName !== "none") {
71
+ const base = String(loraName).split("/").pop() || "";
72
+ trigger = base.replace(/\.[^.]+$/, "");
73
+ }
74
+ }
75
+ if (!trigger) return "";
76
+
77
+ if (description) {
78
+ description = stripTriggerPrefix(description, trigger);
79
+ }
80
+
81
+ if (description) {
82
+ return `always start each prompt exactly with "${trigger}, ${description}"`;
83
+ }
84
+ return `always start each prompt exactly with "${trigger},"`;
85
+ }
86
+
87
+ // Pick the picker that should drive a given Grok call. Strategy:
88
+ // 1) If a picker has any output link to an RPG, use it.
89
+ // 2) Else, if there's exactly one picker in the graph, use it.
90
+ // 3) Else, return null (no override).
91
+ function selectPickerForCall() {
92
+ const pickers = findPickers();
93
+ if (pickers.length === 0) return null;
94
+
95
+ const rpgIds = new Set(findRPGs().map((n) => n.id));
96
+
97
+ // Prefer a picker that has a link reaching any RPG node
98
+ for (const p of pickers) {
99
+ for (const out of p.outputs ?? []) {
100
+ const links = out.links;
101
+ if (!links) continue;
102
+ for (const lid of links) {
103
+ const link = p.graph?.links?.[lid] ?? app.graph?.links?.[lid];
104
+ if (!link) continue;
105
+ if (rpgIds.has(link.target_id)) {
106
+ return p;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // Fallback: single picker in graph
113
+ if (pickers.length === 1) return pickers[0];
114
+
115
+ return null;
116
+ }
117
+
118
+ // ---------- Push sync (UI feedback) ----------
119
+ function syncPickerToAllRPGs(picker) {
120
+ if (!picker) return;
121
+ const text = buildGrokInstruction(picker);
122
+ // Mirror to picker.properties for inspection
123
+ picker.properties = picker.properties || {};
124
+ picker.properties.grok_instruction = text;
125
+
126
+ const rpgs = findRPGs();
127
+ for (const rpg of rpgs) {
128
+ rpg.properties = rpg.properties || {};
129
+ rpg.properties.character_text_input = text;
130
+ // If a real ComfyUI widget exists with that name, mirror to it
131
+ const w = rpg.widgets?.find((x) => x.name === "character_text_input");
132
+ if (w) {
133
+ w.value = text;
134
+ }
135
+ rpg.setDirtyCanvas?.(true, true);
136
+ }
137
+ }
138
+
139
+ // ---------- Hook picker widget callbacks ----------
140
+ function attachWidgetHooks(picker) {
141
+ if (!picker || picker._ozPickerHooked) return;
142
+ picker._ozPickerHooked = true;
143
+ for (const w of picker.widgets ?? []) {
144
+ if (!SYNC_WIDGETS.has(w.name)) continue;
145
+ const original = w.callback;
146
+ w.callback = function (...args) {
147
+ const result = original?.apply(this, args);
148
+ try {
149
+ syncPickerToAllRPGs(picker);
150
+ } catch (e) {
151
+ console.warn(LOG_PREFIX, "sync error:", e);
152
+ }
153
+ return result;
154
+ };
155
+ }
156
+ }
157
+
158
+ // ---------- api.fetchApi hijack ----------
159
+ const _originalFetchApi = api.fetchApi.bind(api);
160
+ api.fetchApi = async function (path, options = {}) {
161
+ try {
162
+ const isTarget =
163
+ typeof path === "string" &&
164
+ path.endsWith(TARGET_ENDPOINT) &&
165
+ options &&
166
+ (options.method === "POST" || options.method === "post") &&
167
+ options.body;
168
+
169
+ if (isTarget) {
170
+ const picker = selectPickerForCall();
171
+ if (picker) {
172
+ const instruction = buildGrokInstruction(picker);
173
+ if (instruction) {
174
+ let body;
175
+ try {
176
+ body =
177
+ typeof options.body === "string"
178
+ ? JSON.parse(options.body)
179
+ : options.body;
180
+ } catch (e) {
181
+ body = null;
182
+ }
183
+ if (body && typeof body === "object") {
184
+ // Override every character-description-ish field the
185
+ // backend might consume.
186
+ body.character_description = instruction;
187
+ body.character_reference = instruction;
188
+ body.character_text = instruction;
189
+ // Force server to rebuild system_prompt with our value.
190
+ // The server's build_system_prompt() will re-inject this
191
+ // into the CHARACTER CONSISTENCY section automatically.
192
+ if ("system_prompt" in body) {
193
+ // Replace any pre-built system_prompt; server rebuilds
194
+ // it from character_description anyway.
195
+ body.system_prompt = "";
196
+ }
197
+ options = { ...options, body: JSON.stringify(body) };
198
+ console.log(
199
+ LOG_PREFIX,
200
+ "Overrode character_description for Grok:",
201
+ instruction.slice(0, 120) + (instruction.length > 120 ? "..." : "")
202
+ );
203
+ }
204
+ }
205
+ }
206
+ }
207
+ } catch (e) {
208
+ console.warn(LOG_PREFIX, "hijack failed:", e);
209
+ }
210
+ return _originalFetchApi(path, options);
211
+ };
212
+
213
+ // ---------- Extension registration ----------
214
+ app.registerExtension({
215
+ name: "ozw.character_picker_sync",
216
+
217
+ async nodeCreated(node) {
218
+ if (!node) return;
219
+ if (node.comfyClass === PICKER_TYPE || node.type === PICKER_TYPE) {
220
+ // Defer: widgets may not be ready immediately
221
+ setTimeout(() => {
222
+ attachWidgetHooks(node);
223
+ try {
224
+ syncPickerToAllRPGs(node);
225
+ } catch (e) {
226
+ /* noop */
227
+ }
228
+ }, 100);
229
+ }
230
+ },
231
+
232
+ async afterConfigureGraph() {
233
+ // Workflow loaded from JSON: hook all pickers and run initial sync
234
+ try {
235
+ for (const p of findPickers()) {
236
+ attachWidgetHooks(p);
237
+ syncPickerToAllRPGs(p);
238
+ }
239
+ } catch (e) {
240
+ console.warn(LOG_PREFIX, "afterConfigureGraph failed:", e);
241
+ }
242
+ },
243
+ });
244
+
245
+ console.log(LOG_PREFIX, "loaded — character picker sync active");
ComfyUI_Oz/nodes/input_nodes/reality_prompt_generator.py CHANGED
@@ -82,6 +82,18 @@ class Oz_RealityPromptGenerator:
82
  "tooltip": "Aspect ratio label (e.g., '16:9'). Connect from Oz Aspect Ratio Selector.",
83
  },
84
  ),
 
 
 
 
 
 
 
 
 
 
 
 
85
  },
86
  "hidden": {
87
  "node_id": "UNIQUE_ID",
@@ -134,6 +146,7 @@ class Oz_RealityPromptGenerator:
134
  images4=None,
135
  character_image=None,
136
  aspect_label="1:1",
 
137
  node_id=None,
138
  prompt_batch_data="[]",
139
  resolved_mode="txt2img",
@@ -157,6 +170,11 @@ class Oz_RealityPromptGenerator:
157
  print(f"[RPG] Error parsing prompt_batch_data: {e}")
158
  prompt_batch = []
159
 
 
 
 
 
 
160
  # 2) Get image count from actual connected images
161
  image_count = 0
162
  if images is not None:
@@ -183,6 +201,10 @@ class Oz_RealityPromptGenerator:
183
  # Normal mode - use positive_prompt field
184
  pos = positive_prompt.strip()
185
 
 
 
 
 
186
  neg = (entry.get("negative_prompt") or "").strip()
187
  rc = max(1, int(entry.get("repeat_count", 1)))
188
 
@@ -229,12 +251,14 @@ class Oz_RealityPromptGenerator:
229
  prompt_batch_data = kwargs.get("prompt_batch_data", "[]")
230
  global_negative = kwargs.get("global_negative", "")
231
  expected_image_count = kwargs.get("expected_image_count", -1)
 
232
 
233
  # Create a hash of all inputs that affect output
234
  hasher = hashlib.sha256()
235
  hasher.update(prompt_batch_data.encode("utf-8"))
236
  hasher.update(global_negative.encode("utf-8"))
237
  hasher.update(str(expected_image_count).encode("utf-8"))
 
238
 
239
  return hasher.hexdigest()
240
 
 
82
  "tooltip": "Aspect ratio label (e.g., '16:9'). Connect from Oz Aspect Ratio Selector.",
83
  },
84
  ),
85
+ "prompt_prefix": (
86
+ "STRING",
87
+ {
88
+ "default": "",
89
+ "multiline": False,
90
+ "tooltip": (
91
+ "Optional prefix prepended to every positive prompt before output.\n"
92
+ "Useful for LoRA trigger words (e.g., 'su4ka').\n"
93
+ "Trailing commas/spaces are normalized."
94
+ ),
95
+ },
96
+ ),
97
  },
98
  "hidden": {
99
  "node_id": "UNIQUE_ID",
 
146
  images4=None,
147
  character_image=None,
148
  aspect_label="1:1",
149
+ prompt_prefix="",
150
  node_id=None,
151
  prompt_batch_data="[]",
152
  resolved_mode="txt2img",
 
170
  print(f"[RPG] Error parsing prompt_batch_data: {e}")
171
  prompt_batch = []
172
 
173
+ # 1b) Normalize optional prompt prefix (e.g., LoRA trigger word)
174
+ prefix = (prompt_prefix or "").strip(" ,")
175
+ if prefix:
176
+ print(f"[RPG] Applying prompt prefix: '{prefix}'")
177
+
178
  # 2) Get image count from actual connected images
179
  image_count = 0
180
  if images is not None:
 
201
  # Normal mode - use positive_prompt field
202
  pos = positive_prompt.strip()
203
 
204
+ # Apply prefix to positive prompt only (LoRA triggers, style hints, etc.)
205
+ if prefix:
206
+ pos = f"{prefix}, {pos}" if pos else prefix
207
+
208
  neg = (entry.get("negative_prompt") or "").strip()
209
  rc = max(1, int(entry.get("repeat_count", 1)))
210
 
 
251
  prompt_batch_data = kwargs.get("prompt_batch_data", "[]")
252
  global_negative = kwargs.get("global_negative", "")
253
  expected_image_count = kwargs.get("expected_image_count", -1)
254
+ prompt_prefix = (kwargs.get("prompt_prefix", "") or "").strip(" ,")
255
 
256
  # Create a hash of all inputs that affect output
257
  hasher = hashlib.sha256()
258
  hasher.update(prompt_batch_data.encode("utf-8"))
259
  hasher.update(global_negative.encode("utf-8"))
260
  hasher.update(str(expected_image_count).encode("utf-8"))
261
+ hasher.update(prompt_prefix.encode("utf-8"))
262
 
263
  return hasher.hexdigest()
264
 
ComfyUI_Oz/nodes/output_nodes/save_with_metadata.py CHANGED
@@ -18,7 +18,7 @@ def is_tool(name):
18
  EXIFTOOL_AVAILABLE = is_tool("exiftool") or is_tool("exiftool.exe")
19
 
20
  class Oz_SaveWithAuthenticMetadata:
21
- OUTPUT_NODE = False
22
  CATEGORY = "Oz/Authenticity"
23
  FUNCTION = "save_image"
24
 
 
18
  EXIFTOOL_AVAILABLE = is_tool("exiftool") or is_tool("exiftool.exe")
19
 
20
  class Oz_SaveWithAuthenticMetadata:
21
+ OUTPUT_NODE = True
22
  CATEGORY = "Oz/Authenticity"
23
  FUNCTION = "save_image"
24
 
ComfyUI_Oz/nodes/utility_nodes/__init__.py CHANGED
@@ -105,6 +105,22 @@ from .load_image_from_path import NODE_CLASS_MAPPINGS as LOADER_MAPPINGS, NODE_D
105
  from .line_splitter import Oz_LineSplitter
106
  from .image_prompt_iterator import Oz_ImagePromptIterator
107
  from .debug_prompt_overlay import Oz_DebugPromptOverlay
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  from .prompt_batch_preview import (
109
  NODE_CLASS_MAPPINGS as PREVIEW_MAPPINGS,
110
  NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_DISPLAY_MAPPINGS,
@@ -163,6 +179,10 @@ NODE_CLASS_MAPPINGS = {
163
  "ozW_DebugPromptOverlay": Oz_DebugPromptOverlay,
164
  **PREVIEW_MAPPINGS,
165
  **MASK_TO_CROP_MAPPINGS,
 
 
 
 
166
  }
167
 
168
  NODE_DISPLAY_NAME_MAPPINGS = {
@@ -212,6 +232,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
212
  "ozW_DebugPromptOverlay": "🐛 Oz Debug Prompt Overlay",
213
  **PREVIEW_DISPLAY_MAPPINGS,
214
  **MASK_TO_CROP_DISPLAY_MAPPINGS,
 
 
 
 
215
  }
216
 
217
  __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
 
105
  from .line_splitter import Oz_LineSplitter
106
  from .image_prompt_iterator import Oz_ImagePromptIterator
107
  from .debug_prompt_overlay import Oz_DebugPromptOverlay
108
+ from .oz_string_at_index import (
109
+ NODE_CLASS_MAPPINGS as STRING_AT_INDEX_MAPPINGS,
110
+ NODE_DISPLAY_NAME_MAPPINGS as STRING_AT_INDEX_DISPLAY_MAPPINGS,
111
+ )
112
+ from .oz_stitch_reel import (
113
+ NODE_CLASS_MAPPINGS as STITCH_REEL_MAPPINGS,
114
+ NODE_DISPLAY_NAME_MAPPINGS as STITCH_REEL_DISPLAY_MAPPINGS,
115
+ )
116
+ from .oz_shot_analyzer import (
117
+ NODE_CLASS_MAPPINGS as SHOT_ANALYZER_MAPPINGS,
118
+ NODE_DISPLAY_NAME_MAPPINGS as SHOT_ANALYZER_DISPLAY_MAPPINGS,
119
+ )
120
+ from .lora_character_picker import (
121
+ NODE_CLASS_MAPPINGS as LORA_PICKER_MAPPINGS,
122
+ NODE_DISPLAY_NAME_MAPPINGS as LORA_PICKER_DISPLAY_MAPPINGS,
123
+ )
124
  from .prompt_batch_preview import (
125
  NODE_CLASS_MAPPINGS as PREVIEW_MAPPINGS,
126
  NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_DISPLAY_MAPPINGS,
 
179
  "ozW_DebugPromptOverlay": Oz_DebugPromptOverlay,
180
  **PREVIEW_MAPPINGS,
181
  **MASK_TO_CROP_MAPPINGS,
182
+ **LORA_PICKER_MAPPINGS,
183
+ **SHOT_ANALYZER_MAPPINGS,
184
+ **STITCH_REEL_MAPPINGS,
185
+ **STRING_AT_INDEX_MAPPINGS,
186
  }
187
 
188
  NODE_DISPLAY_NAME_MAPPINGS = {
 
232
  "ozW_DebugPromptOverlay": "🐛 Oz Debug Prompt Overlay",
233
  **PREVIEW_DISPLAY_MAPPINGS,
234
  **MASK_TO_CROP_DISPLAY_MAPPINGS,
235
+ **LORA_PICKER_DISPLAY_MAPPINGS,
236
+ **SHOT_ANALYZER_DISPLAY_MAPPINGS,
237
+ **STITCH_REEL_DISPLAY_MAPPINGS,
238
+ **STRING_AT_INDEX_DISPLAY_MAPPINGS,
239
  }
240
 
241
  __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
ComfyUI_Oz/nodes/utility_nodes/lora_character_picker.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---
2
+ # Filename: ../Oz/nodes/utility_nodes/lora_character_picker.py
3
+ # Central character control for a workflow:
4
+ # 1. Picks a LoRA from models/loras/ (auto-discovered).
5
+ # 2. Outputs the trigger word (for RPG.prompt_prefix).
6
+ # 3. Outputs a Grok instruction (for RPG.character_text_input).
7
+ # 4. Outputs the lora_name STRING (for Oz_ApplyCharacterLoRA).
8
+ #
9
+ # Switch character in one click: change picker -> three downstream
10
+ # nodes (RPG.prompt_prefix, RPG.character_text_input, ApplyCharacterLoRA)
11
+ # automatically follow.
12
+ # ---
13
+
14
+ import os
15
+ import re
16
+ import folder_paths
17
+ import comfy.sd
18
+ import comfy.utils
19
+
20
+
21
+ def _build_grok_instruction(trigger, description):
22
+ """
23
+ Build the Grok system prompt instruction that prepends the LoRA
24
+ trigger word to every generated prompt.
25
+
26
+ Dedup: strip the trigger from the start of the description if it
27
+ appears as a complete token (not inside another word). Avoids the
28
+ `papa`/`papaya` false positive.
29
+
30
+ Returns empty string when there's nothing to inject.
31
+ """
32
+ trigger = (trigger or "").strip()
33
+ description = (description or "").strip()
34
+ if not trigger:
35
+ return ""
36
+
37
+ if description:
38
+ # Start-anchored, case-insensitive, with lookahead for end-of-string
39
+ # or separator. Only fires when the trigger is a whole token.
40
+ pattern = re.compile(
41
+ rf"^{re.escape(trigger)}(?=$|(?:\s|[,;:\-–—]))(?:\s*[,;:\-–—]\s*|\s+)*",
42
+ re.IGNORECASE,
43
+ )
44
+ description = pattern.sub("", description, count=1).strip()
45
+
46
+ if description:
47
+ return f'always start each prompt exactly with "{trigger}, {description}"'
48
+ return f'always start each prompt exactly with "{trigger},"'
49
+
50
+
51
+ class Oz_LoRACharacterPicker:
52
+ """
53
+ Picks a character LoRA and exposes everything a downstream prompt
54
+ generator needs in one place.
55
+
56
+ Wire outputs:
57
+ trigger_word -> RealityPromptGenerator.prompt_prefix
58
+ grok_instruction -> RealityPromptGenerator.character_text_input
59
+ lora_name -> Oz_ApplyCharacterLoRA.lora_name
60
+ """
61
+
62
+ @classmethod
63
+ def INPUT_TYPES(cls):
64
+ try:
65
+ loras = folder_paths.get_filename_list("loras")
66
+ except Exception:
67
+ loras = []
68
+ choices = ["none"] + sorted(loras)
69
+ return {
70
+ "required": {
71
+ "lora_name": (choices, {"default": "none"}),
72
+ },
73
+ "optional": {
74
+ "trigger_override": (
75
+ "STRING",
76
+ {
77
+ "default": "",
78
+ "multiline": False,
79
+ "tooltip": (
80
+ "Optional override. If non-empty, used INSTEAD of the LoRA filename stem.\n"
81
+ "Useful when the LoRA's trigger word differs from its filename."
82
+ ),
83
+ },
84
+ ),
85
+ "character_description": (
86
+ "STRING",
87
+ {
88
+ "default": "",
89
+ "multiline": True,
90
+ "tooltip": (
91
+ "Free-form character description appended after the trigger in the\n"
92
+ "Grok instruction. Example: 'woman with long black hair and ice blue eyes'.\n"
93
+ "Leave empty to only force the trigger prefix."
94
+ ),
95
+ },
96
+ ),
97
+ },
98
+ }
99
+
100
+ # NOTE: slot 0 (trigger_word) and slot 1 (lora_name) are unchanged for
101
+ # backward compatibility. Slot 2 (grok_instruction) is added at the end.
102
+ RETURN_TYPES = ("STRING", "STRING", "STRING")
103
+ RETURN_NAMES = ("trigger_word", "lora_name", "grok_instruction")
104
+ FUNCTION = "pick"
105
+ CATEGORY = "Oz/Prompts"
106
+
107
+ def pick(self, lora_name, trigger_override="", character_description=""):
108
+ override = (trigger_override or "").strip()
109
+ if override:
110
+ trigger = override
111
+ elif not lora_name or lora_name == "none":
112
+ trigger = ""
113
+ else:
114
+ base = os.path.basename(lora_name)
115
+ trigger = os.path.splitext(base)[0]
116
+
117
+ out_lora = lora_name if lora_name else "none"
118
+ instruction = _build_grok_instruction(trigger, character_description)
119
+ return (trigger, out_lora, instruction)
120
+
121
+
122
+ class Oz_ApplyCharacterLoRA:
123
+ """
124
+ Loads a LoRA from a STRING input (driven by Oz_LoRACharacterPicker)
125
+ and applies it to a MODEL/CLIP pair.
126
+
127
+ Standard LoraLoader pattern (see ComfyUI nodes.py LoraLoader) but with
128
+ `lora_name` as a wireable STRING input instead of a widget dropdown.
129
+
130
+ Pass-through behavior:
131
+ - lora_name "" or "none" -> returns model, clip unchanged
132
+ - strength_model == 0 and strength_clip == 0 -> returns unchanged
133
+ - clip not connected -> applies model-only
134
+ """
135
+
136
+ def __init__(self):
137
+ self.loaded_lora = None # (path, lora_state_dict)
138
+
139
+ @classmethod
140
+ def INPUT_TYPES(cls):
141
+ return {
142
+ "required": {
143
+ "model": ("MODEL",),
144
+ "lora_name": (
145
+ "STRING",
146
+ {
147
+ "default": "",
148
+ "multiline": False,
149
+ "forceInput": True,
150
+ "tooltip": "LoRA filename. Wire from Oz_LoRACharacterPicker.lora_name.",
151
+ },
152
+ ),
153
+ "strength_model": (
154
+ "FLOAT",
155
+ {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01},
156
+ ),
157
+ },
158
+ "optional": {
159
+ "clip": ("CLIP",),
160
+ "strength_clip": (
161
+ "FLOAT",
162
+ {"default": 1.0, "min": -10.0, "max": 10.0, "step": 0.01},
163
+ ),
164
+ },
165
+ }
166
+
167
+ RETURN_TYPES = ("MODEL", "CLIP")
168
+ RETURN_NAMES = ("MODEL", "CLIP")
169
+ FUNCTION = "apply"
170
+ CATEGORY = "Oz/Prompts"
171
+
172
+ def apply(self, model, lora_name, strength_model, clip=None, strength_clip=1.0):
173
+ # Fast paths
174
+ name = (lora_name or "").strip()
175
+ if not name or name.lower() == "none":
176
+ return (model, clip)
177
+ if strength_model == 0 and strength_clip == 0:
178
+ return (model, clip)
179
+
180
+ try:
181
+ lora_path = folder_paths.get_full_path_or_raise("loras", name)
182
+ except Exception as e:
183
+ print(f"[Oz_ApplyCharacterLoRA] LoRA not found: {name} ({e}) — passthrough")
184
+ return (model, clip)
185
+
186
+ # Cached load
187
+ lora = None
188
+ if self.loaded_lora is not None and self.loaded_lora[0] == lora_path:
189
+ lora = self.loaded_lora[1]
190
+ else:
191
+ self.loaded_lora = None
192
+ lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
193
+ self.loaded_lora = (lora_path, lora)
194
+
195
+ # comfy.sd.load_lora_for_models supports clip=None for model-only
196
+ model_lora, clip_lora = comfy.sd.load_lora_for_models(
197
+ model, clip, lora, strength_model, strength_clip
198
+ )
199
+ return (model_lora, clip_lora)
200
+
201
+
202
+ NODE_CLASS_MAPPINGS = {
203
+ "ozW_LoRACharacterPicker": Oz_LoRACharacterPicker,
204
+ "ozW_ApplyCharacterLoRA": Oz_ApplyCharacterLoRA,
205
+ }
206
+
207
+ NODE_DISPLAY_NAME_MAPPINGS = {
208
+ "ozW_LoRACharacterPicker": "🎭 Oz LoRA Character Picker",
209
+ "ozW_ApplyCharacterLoRA": "🎬 Oz Apply Character LoRA",
210
+ }
ComfyUI_Oz/nodes/utility_nodes/oz_shot_analyzer.py ADDED
@@ -0,0 +1,608 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---
2
+ # Filename: ../Oz/nodes/utility_nodes/oz_shot_analyzer.py
3
+ # Automatic per-shot Grok analysis — multi-frame mode (reads clip_paths from
4
+ # ShotSplitter's manifest_json, samples N frames per clip, single Grok call
5
+ # per shot) with optional glamour-style bias and safety fallback.
6
+ # ---
7
+
8
+ import asyncio
9
+ import base64
10
+ import io
11
+ import hashlib
12
+ import json
13
+ import random
14
+ import re
15
+ import traceback
16
+
17
+ import cv2
18
+ import torch
19
+ import numpy as np
20
+ from PIL import Image
21
+
22
+ # Reuse existing Grok plumbing
23
+ from ..api_nodes.creative_api import (
24
+ generate_with_grok,
25
+ )
26
+
27
+
28
+ DEFAULT_NEGATIVE = (
29
+ "unrealistic, illustration, painting, drawing, art, artistic, low quality, "
30
+ "deformed, bad anatomy, blurry, amateur, watermark, text, cartoon, 3d render, "
31
+ "flat chest, covered up, baggy, plain figure, frumpy"
32
+ )
33
+
34
+
35
+ # ==========================================================================
36
+ # System prompt builder (custom — does NOT use creative_api.build_system_prompt
37
+ # because we need precise control over multi-frame + glamour bias blocks)
38
+ # ==========================================================================
39
+ def _build_system_prompt(
40
+ character_instruction: str,
41
+ multi_frame: bool,
42
+ glamour_bias: bool,
43
+ ) -> str:
44
+ parts = [
45
+ "You are an expert AI prompt engineer specializing in creating "
46
+ "high-quality, detailed prompts for REALISTIC photographic image "
47
+ "generation models.",
48
+ "",
49
+ "IMPORTANT: All outputs are REALISTIC photography. Do not include "
50
+ "illustration, painting, drawing, or non-photographic elements.",
51
+ ]
52
+
53
+ if character_instruction.strip():
54
+ parts.append("")
55
+ parts.append("CHARACTER CONSISTENCY:")
56
+ parts.append(character_instruction.strip())
57
+
58
+ parts.append("")
59
+ parts.append("MODE: Image-to-Image Generation")
60
+ parts.append(
61
+ "Describe the input as it appears: preserve the environment, setting, "
62
+ "lighting, props, outfit type/color/material, and subject's action. Do "
63
+ "not invent elements that are not visible in the source."
64
+ )
65
+
66
+ if multi_frame:
67
+ parts.append("")
68
+ parts.append("MULTI-FRAME INPUT:")
69
+ parts.append(
70
+ "You will receive 3–5 frames sampled evenly from a single video shot "
71
+ "(frame 1 = start, last frame = end). Treat them as ONE continuous "
72
+ "moment. Identify the dominant pose, the most revealing body angle "
73
+ "visible, and any outfit details visible across frames. Output a "
74
+ "SINGLE unified positive prompt describing the best representative "
75
+ "still from this shot — do NOT list frames separately."
76
+ )
77
+
78
+ if glamour_bias:
79
+ parts.append("")
80
+ parts.append("PRESENTATION STYLE:")
81
+ parts.append(
82
+ "Output prompts must render the character in an OnlyFans-style "
83
+ "glamour photography aesthetic. Even when the source shows modest "
84
+ "or loose clothing, always describe the figure as visibly "
85
+ "curvaceous — full, rounded breasts visible through the fabric, "
86
+ "hourglass silhouette prominent, seductive body language. Use "
87
+ "clinical photography/fashion language (not erotica). Never "
88
+ "describe a flat or androgynous body."
89
+ )
90
+ parts.append("")
91
+ parts.append("OUTFIT FIDELITY:")
92
+ parts.append(
93
+ "Preserve garment type, color, material, and layering EXACTLY as "
94
+ "seen in the source. Adjust only how the garment fits — describe "
95
+ "it as form-fitting, body-hugging, or slightly form-revealing "
96
+ "rather than baggy or oversized, unless the source is heavy "
97
+ "outerwear (coat, parka). Same outfit, same scene, same props, "
98
+ "but the body underneath reads as curvaceous and feminine."
99
+ )
100
+
101
+ parts.append("")
102
+ parts.append(
103
+ "OUTPUT FORMAT: Return a single valid JSON object (NOT an array) "
104
+ "with these keys:"
105
+ )
106
+ parts.append('- "positive": a detailed positive prompt (string)')
107
+ parts.append('- "negative": a negative prompt (string, can be empty)')
108
+ parts.append('- "tags": array of short tag strings')
109
+ parts.append("")
110
+ parts.append("Example:")
111
+ parts.append('{"positive": "woman in a black mini dress, neon lights behind, '
112
+ '85mm f/1.8, shallow depth of field", "negative": "", "tags": '
113
+ '["portrait", "night", "neon"]}')
114
+
115
+ return "\n".join(parts)
116
+
117
+
118
+ def _build_user_prompt(multi_frame: bool, n_frames: int) -> str:
119
+ if multi_frame:
120
+ return (
121
+ f"Analyze the {n_frames} frames from this video shot and generate "
122
+ "ONE unified positive prompt that best describes the dominant "
123
+ "moment. Follow the system instructions strictly. Return JSON only."
124
+ )
125
+ return (
126
+ "Analyze this image and generate ONE detailed positive prompt. "
127
+ "Follow the system instructions strictly. Return JSON only."
128
+ )
129
+
130
+
131
+ # ==========================================================================
132
+ # Frame helpers
133
+ # ==========================================================================
134
+ def _load_clip_frames(clip_path: str, max_resolution: int = 768):
135
+ """
136
+ Load all frames of a video clip via OpenCV. Returns a list of
137
+ (H, W, 3) uint8 numpy arrays in RGB. Frames are resized so the longest
138
+ side does not exceed `max_resolution` (to cap base64 payload size).
139
+ """
140
+ cap = cv2.VideoCapture(clip_path)
141
+ if not cap.isOpened():
142
+ print(f"[Oz_ShotAnalyzer] cv2 cannot open: {clip_path}")
143
+ return []
144
+
145
+ frames = []
146
+ while True:
147
+ ok, frame_bgr = cap.read()
148
+ if not ok:
149
+ break
150
+ frame = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
151
+ h, w = frame.shape[:2]
152
+ if max(h, w) > max_resolution:
153
+ scale = max_resolution / max(h, w)
154
+ new_w = int(round(w * scale))
155
+ new_h = int(round(h * scale))
156
+ frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA)
157
+ frames.append(frame)
158
+ cap.release()
159
+ return frames
160
+
161
+
162
+ def _sample_evenly(frames, n: int):
163
+ """Sample n evenly spaced frames from a list."""
164
+ total = len(frames)
165
+ if total == 0:
166
+ return []
167
+ if total <= n:
168
+ return list(frames)
169
+ indices = [round(i * (total - 1) / (n - 1)) for i in range(n)]
170
+ return [frames[i] for i in indices]
171
+
172
+
173
+ def _numpy_to_base64_png(arr: "np.ndarray") -> str:
174
+ pil = Image.fromarray(arr.astype(np.uint8), "RGB")
175
+ buf = io.BytesIO()
176
+ pil.save(buf, format="PNG", optimize=False)
177
+ return base64.b64encode(buf.getvalue()).decode("ascii")
178
+
179
+
180
+ def _tensor_to_base64_png(img_tensor: torch.Tensor, max_resolution: int = 768) -> str:
181
+ if img_tensor.dim() == 4:
182
+ img_tensor = img_tensor[0]
183
+ arr = img_tensor.detach().cpu().numpy()
184
+ arr = np.clip(arr, 0.0, 1.0)
185
+ arr = (arr * 255.0).astype(np.uint8)
186
+ h, w = arr.shape[:2]
187
+ if max(h, w) > max_resolution:
188
+ scale = max_resolution / max(h, w)
189
+ new_w = int(round(w * scale))
190
+ new_h = int(round(h * scale))
191
+ arr = cv2.resize(arr, (new_w, new_h), interpolation=cv2.INTER_AREA)
192
+ return _numpy_to_base64_png(arr)
193
+
194
+
195
+ # ==========================================================================
196
+ # Grok call helpers (async)
197
+ # ==========================================================================
198
+ async def _grok_call(
199
+ system_prompt: str,
200
+ user_prompt: str,
201
+ frames_b64: list,
202
+ api_key: str,
203
+ model: str,
204
+ temperature: float,
205
+ top_p: float,
206
+ ):
207
+ """
208
+ Single Grok call with 1 or more images. Returns the parsed dict
209
+ {positive, negative, tags} or an empty dict on failure.
210
+ """
211
+ try:
212
+ prompts = await generate_with_grok(
213
+ system_prompt=system_prompt,
214
+ user_prompt=user_prompt,
215
+ model=model,
216
+ api_key=api_key,
217
+ temperature=temperature,
218
+ top_p=top_p,
219
+ images=frames_b64,
220
+ )
221
+ except Exception as e:
222
+ print(f"[Oz_ShotAnalyzer] Grok call failed: {e}")
223
+ return {}
224
+ if not prompts:
225
+ return {}
226
+ return prompts[0] if isinstance(prompts, list) else prompts
227
+
228
+
229
+ def _run_async(coro):
230
+ try:
231
+ loop = asyncio.get_event_loop()
232
+ if loop.is_running():
233
+ import concurrent.futures
234
+ with concurrent.futures.ThreadPoolExecutor() as ex:
235
+ return ex.submit(lambda: asyncio.run(coro)).result()
236
+ except RuntimeError:
237
+ pass
238
+ return asyncio.run(coro)
239
+
240
+
241
+ # ==========================================================================
242
+ # Extract trigger word from character_instruction for fallback injection
243
+ # ==========================================================================
244
+ def _extract_trigger(char_instr: str) -> str:
245
+ """
246
+ Pull the trigger word out of an instruction like:
247
+ 'always start each prompt exactly with "papeech, woman with..."'
248
+ Returns empty string on failure.
249
+ """
250
+ if not char_instr:
251
+ return ""
252
+ m = re.search(r'"([^",]+)', char_instr)
253
+ if m:
254
+ return m.group(1).strip()
255
+ return ""
256
+
257
+
258
+ def _force_trigger_prefix(prompt: str, trigger: str) -> str:
259
+ """Add trigger to start of prompt if not already there (word-boundary check)."""
260
+ if not trigger or not prompt:
261
+ return prompt
262
+ has_trigger = re.match(
263
+ rf"^{re.escape(trigger)}(?=$|(?:\s|[,;:\-–—]))",
264
+ prompt,
265
+ re.IGNORECASE,
266
+ )
267
+ if has_trigger:
268
+ return prompt
269
+ return f"{trigger}, {prompt}"
270
+
271
+
272
+ # ==========================================================================
273
+ # Main node
274
+ # ==========================================================================
275
+ class Oz_ShotAnalyzer:
276
+ """
277
+ Per-shot Grok analysis for a video split by Oz_ShotSplitter.
278
+
279
+ Two modes:
280
+ 1) Multi-frame (preferred): wire `manifest_json` from ShotSplitter.
281
+ For each shot in the manifest, this loads the clip file, samples
282
+ N evenly-spaced frames, encodes them all, and sends ONE Grok
283
+ request per shot with multiple images for synthesis.
284
+
285
+ 2) Single-frame (fallback): uses the `images` IMAGE batch
286
+ (typically first_frames from ShotSplitter) and sends one frame
287
+ per Grok call.
288
+
289
+ Optional `glamour_bias` injects PRESENTATION STYLE + OUTFIT FIDELITY
290
+ blocks into the system prompt. If Grok returns empty on a bias-on
291
+ call, the analyzer retries that shot with bias OFF as a safety
292
+ fallback.
293
+ """
294
+
295
+ @classmethod
296
+ def INPUT_TYPES(cls):
297
+ return {
298
+ "required": {
299
+ "images": ("IMAGE",),
300
+ "character_instruction": (
301
+ "STRING",
302
+ {
303
+ "default": "",
304
+ "multiline": True,
305
+ "forceInput": True,
306
+ "tooltip": (
307
+ "Character instruction from LoRA Character Picker.\n"
308
+ "Wire from picker.grok_instruction."
309
+ ),
310
+ },
311
+ ),
312
+ "xai_api_key": (
313
+ "STRING",
314
+ {"default": "", "multiline": False},
315
+ ),
316
+ },
317
+ "optional": {
318
+ "manifest_json": (
319
+ "STRING",
320
+ {
321
+ "default": "",
322
+ "multiline": False,
323
+ "forceInput": True,
324
+ "tooltip": (
325
+ "Wire from Oz_ShotSplitter.manifest_json for multi-frame "
326
+ "mode. If empty, falls back to single-frame on `images`."
327
+ ),
328
+ },
329
+ ),
330
+ "frames_per_shot": (
331
+ "INT",
332
+ {"default": 3, "min": 1, "max": 10},
333
+ ),
334
+ "glamour_bias": ("BOOLEAN", {"default": True}),
335
+ "model": (
336
+ "STRING",
337
+ {
338
+ "default": "grok-4.20-beta-0309-reasoning",
339
+ "multiline": False,
340
+ },
341
+ ),
342
+ "temperature": (
343
+ "FLOAT",
344
+ {"default": 0.9, "min": 0.0, "max": 2.0, "step": 0.05},
345
+ ),
346
+ "top_p": (
347
+ "FLOAT",
348
+ {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.05},
349
+ ),
350
+ "max_resolution": (
351
+ "INT",
352
+ {"default": 768, "min": 256, "max": 2048},
353
+ ),
354
+ "negative_prompt": (
355
+ "STRING",
356
+ {"default": DEFAULT_NEGATIVE, "multiline": True},
357
+ ),
358
+ "seed_base": (
359
+ "INT",
360
+ {
361
+ "default": 1111111,
362
+ "min": 0,
363
+ "max": 0xFFFFFFFFFFFFFFFF,
364
+ },
365
+ ),
366
+ "max_shots": (
367
+ "INT",
368
+ {
369
+ "default": 50,
370
+ "min": 1,
371
+ "max": 500,
372
+ "tooltip": "Hard cap to prevent runaway Grok costs.",
373
+ },
374
+ ),
375
+ },
376
+ }
377
+
378
+ RETURN_TYPES = ("STRING", "STRING", "INT", "INT")
379
+ RETURN_NAMES = (
380
+ "prompt_list_positive",
381
+ "prompt_list_negative",
382
+ "seed_list",
383
+ "generation_count",
384
+ )
385
+
386
+ INPUT_IS_LIST = False
387
+ OUTPUT_IS_LIST = (True, True, True, False)
388
+
389
+ FUNCTION = "analyze"
390
+ CATEGORY = "Oz/Prompts"
391
+
392
+ @classmethod
393
+ def IS_CHANGED(cls, **kwargs):
394
+ h = hashlib.sha256()
395
+ for key in (
396
+ "character_instruction",
397
+ "xai_api_key",
398
+ "manifest_json",
399
+ "frames_per_shot",
400
+ "glamour_bias",
401
+ "model",
402
+ "temperature",
403
+ "top_p",
404
+ "max_resolution",
405
+ "negative_prompt",
406
+ "seed_base",
407
+ "max_shots",
408
+ ):
409
+ h.update(repr(kwargs.get(key, "")).encode("utf-8"))
410
+ imgs = kwargs.get("images")
411
+ if isinstance(imgs, torch.Tensor):
412
+ h.update(str(imgs.shape).encode("utf-8"))
413
+ try:
414
+ h.update(str(imgs.sum().item()).encode("utf-8"))
415
+ except Exception:
416
+ pass
417
+ return h.hexdigest()
418
+
419
+ # ----- analysis paths -----
420
+ def _run_one_shot(
421
+ self,
422
+ frames_b64: list,
423
+ char_instr: str,
424
+ api_key: str,
425
+ model: str,
426
+ temperature: float,
427
+ top_p: float,
428
+ multi_frame: bool,
429
+ glamour_bias: bool,
430
+ trigger: str,
431
+ negative_default: str,
432
+ ) -> dict:
433
+ """
434
+ One Grok call with optional safety fallback: if bias-on returns empty,
435
+ retry with bias off. Returns {positive, negative}.
436
+ """
437
+ def _call(bias):
438
+ system_prompt = _build_system_prompt(
439
+ char_instr, multi_frame=multi_frame, glamour_bias=bias
440
+ )
441
+ user_prompt = _build_user_prompt(multi_frame, len(frames_b64))
442
+ return _run_async(
443
+ _grok_call(
444
+ system_prompt=system_prompt,
445
+ user_prompt=user_prompt,
446
+ frames_b64=frames_b64,
447
+ api_key=api_key,
448
+ model=model,
449
+ temperature=temperature,
450
+ top_p=top_p,
451
+ )
452
+ )
453
+
454
+ result = _call(glamour_bias)
455
+ pos = (result.get("positive") or "").strip()
456
+ if glamour_bias and not pos:
457
+ print("[Oz_ShotAnalyzer] Empty response with bias ON — retrying bias OFF")
458
+ result = _call(False)
459
+ pos = (result.get("positive") or "").strip()
460
+
461
+ neg = (result.get("negative") or "").strip() or negative_default
462
+ pos = _force_trigger_prefix(pos, trigger)
463
+ return {"positive": pos, "negative": neg}
464
+
465
+ def analyze(
466
+ self,
467
+ images,
468
+ character_instruction,
469
+ xai_api_key,
470
+ manifest_json="",
471
+ frames_per_shot=3,
472
+ glamour_bias=True,
473
+ model="grok-4.20-beta-0309-reasoning",
474
+ temperature=0.9,
475
+ top_p=0.9,
476
+ max_resolution=768,
477
+ negative_prompt=DEFAULT_NEGATIVE,
478
+ seed_base=1111111,
479
+ max_shots=50,
480
+ ):
481
+ char_instr = (character_instruction or "").strip()
482
+ api_key = (xai_api_key or "").strip()
483
+ manifest = (manifest_json or "").strip()
484
+ trigger = _extract_trigger(char_instr)
485
+
486
+ # ----- Multi-frame mode via manifest_json -----
487
+ shots = []
488
+ if manifest:
489
+ try:
490
+ parsed = json.loads(manifest)
491
+ if isinstance(parsed, list):
492
+ shots = parsed
493
+ except Exception as e:
494
+ print(f"[Oz_ShotAnalyzer] manifest_json parse failed: {e}")
495
+ shots = []
496
+
497
+ if shots:
498
+ shots = shots[:max_shots]
499
+ print(
500
+ f"[Oz_ShotAnalyzer] MULTI-FRAME mode: {len(shots)} shots, "
501
+ f"{frames_per_shot} frames/shot, bias={glamour_bias}"
502
+ )
503
+ positives, negatives, seeds = [], [], []
504
+ for i, shot in enumerate(shots):
505
+ path = shot.get("path", "")
506
+ if not path:
507
+ print(f"[Oz_ShotAnalyzer] shot {i + 1} has no path, skipping")
508
+ positives.append("")
509
+ negatives.append(negative_prompt)
510
+ seeds.append(seed_base + i)
511
+ continue
512
+ try:
513
+ raw_frames = _load_clip_frames(path, max_resolution=max_resolution)
514
+ if not raw_frames:
515
+ print(f"[Oz_ShotAnalyzer] shot {i + 1}: no frames loaded")
516
+ positives.append("")
517
+ negatives.append(negative_prompt)
518
+ seeds.append(seed_base + i)
519
+ continue
520
+ sampled = _sample_evenly(raw_frames, frames_per_shot)
521
+ frames_b64 = [_numpy_to_base64_png(f) for f in sampled]
522
+ print(
523
+ f"[Oz_ShotAnalyzer] shot {i + 1}/{len(shots)}: "
524
+ f"loaded={len(raw_frames)}, sent={len(frames_b64)}"
525
+ )
526
+ result = self._run_one_shot(
527
+ frames_b64=frames_b64,
528
+ char_instr=char_instr,
529
+ api_key=api_key,
530
+ model=model,
531
+ temperature=temperature,
532
+ top_p=top_p,
533
+ multi_frame=True,
534
+ glamour_bias=glamour_bias,
535
+ trigger=trigger,
536
+ negative_default=negative_prompt,
537
+ )
538
+ pos = result["positive"]
539
+ neg = result["negative"]
540
+ print(f"[Oz_ShotAnalyzer] shot {i + 1}: {pos[:100]}{'...' if len(pos) > 100 else ''}")
541
+ except Exception as e:
542
+ print(f"[Oz_ShotAnalyzer] shot {i + 1} failed: {e}")
543
+ traceback.print_exc()
544
+ pos = ""
545
+ neg = negative_prompt
546
+ positives.append(pos)
547
+ negatives.append(neg)
548
+ seeds.append(seed_base + i)
549
+ print(f"[Oz_ShotAnalyzer] Done. {len(shots)} shots processed.")
550
+ return (positives, negatives, seeds, len(shots))
551
+
552
+ # ----- Single-frame fallback mode -----
553
+ if not isinstance(images, torch.Tensor):
554
+ print("[Oz_ShotAnalyzer] ERROR: images is not a tensor and no manifest")
555
+ return ([""], [""], [0], 0)
556
+
557
+ if images.dim() == 3:
558
+ batch = images.unsqueeze(0)
559
+ else:
560
+ batch = images
561
+ count = batch.shape[0]
562
+ count = min(count, max_shots)
563
+ if count == 0:
564
+ print("[Oz_ShotAnalyzer] ERROR: empty batch")
565
+ return ([""], [""], [0], 0)
566
+
567
+ print(f"[Oz_ShotAnalyzer] SINGLE-FRAME mode: {count} images, bias={glamour_bias}")
568
+ positives, negatives, seeds = [], [], []
569
+ for i in range(count):
570
+ try:
571
+ frame_b64 = _tensor_to_base64_png(
572
+ batch[i:i + 1], max_resolution=max_resolution
573
+ )
574
+ result = self._run_one_shot(
575
+ frames_b64=[frame_b64],
576
+ char_instr=char_instr,
577
+ api_key=api_key,
578
+ model=model,
579
+ temperature=temperature,
580
+ top_p=top_p,
581
+ multi_frame=False,
582
+ glamour_bias=glamour_bias,
583
+ trigger=trigger,
584
+ negative_default=negative_prompt,
585
+ )
586
+ pos = result["positive"]
587
+ neg = result["negative"]
588
+ print(f"[Oz_ShotAnalyzer] shot {i + 1}: {pos[:100]}{'...' if len(pos) > 100 else ''}")
589
+ except Exception as e:
590
+ print(f"[Oz_ShotAnalyzer] shot {i + 1} failed: {e}")
591
+ traceback.print_exc()
592
+ pos = ""
593
+ neg = negative_prompt
594
+ positives.append(pos)
595
+ negatives.append(neg)
596
+ seeds.append(seed_base + i)
597
+
598
+ print(f"[Oz_ShotAnalyzer] Done. {count} shots processed.")
599
+ return (positives, negatives, seeds, count)
600
+
601
+
602
+ NODE_CLASS_MAPPINGS = {
603
+ "ozW_ShotAnalyzer": Oz_ShotAnalyzer,
604
+ }
605
+
606
+ NODE_DISPLAY_NAME_MAPPINGS = {
607
+ "ozW_ShotAnalyzer": "🎞️ Oz Shot Analyzer (Grok)",
608
+ }
ComfyUI_Oz/nodes/utility_nodes/oz_stitch_reel.py ADDED
@@ -0,0 +1,404 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---
2
+ # Filename: ../Oz/nodes/utility_nodes/oz_stitch_reel.py
3
+ # Auto-stitches the per-shot outputs from TSVideoCombine into a single
4
+ # final reel using ffmpeg concat demuxer (-c copy, no re-encode, no A/V
5
+ # desync). Designed to run ONCE per Queue Prompt via INPUT_IS_LIST=True.
6
+ # ---
7
+
8
+ import glob
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import time
14
+ import traceback
15
+
16
+ import folder_paths
17
+
18
+
19
+ def _ffprobe_duration(path: str) -> float:
20
+ """Return video duration in seconds, or 0 on failure."""
21
+ try:
22
+ r = subprocess.run(
23
+ [
24
+ "ffprobe", "-v", "error",
25
+ "-show_entries", "format=duration",
26
+ "-of", "default=noprint_wrappers=1:nokey=1",
27
+ path,
28
+ ],
29
+ capture_output=True,
30
+ text=True,
31
+ check=False,
32
+ )
33
+ return float(r.stdout.strip()) if r.stdout.strip() else 0.0
34
+ except Exception:
35
+ return 0.0
36
+
37
+
38
+ def _ffmpeg_available():
39
+ return shutil.which("ffmpeg") is not None
40
+
41
+
42
+ class Oz_StitchReel:
43
+ """
44
+ Collects the N per-shot mp4 files emitted by TSVideoCombine during a
45
+ multi-shot Run and concatenates them into one reel using ffmpeg
46
+ concat demuxer with stream copy (no re-encode, no A/V desync).
47
+
48
+ Wire ANY list-producing output from the iterating pipeline (e.g. the
49
+ seed_list from Oz_ShotAnalyzer, or the IMAGE batch from VAEDecode) to
50
+ the `trigger` input. INPUT_IS_LIST=True guarantees this node runs
51
+ exactly once after all iterations have completed.
52
+
53
+ The node scans the ComfyUI output directory for files matching
54
+ `pattern` created in the last `window_seconds` seconds, sorts them
55
+ lexicographically (zero-padded counters), and concatenates with
56
+ `ffmpeg -f concat -safe 0 -i list.txt -c copy reel.mp4`.
57
+ """
58
+
59
+ INPUT_IS_LIST = True
60
+
61
+ @classmethod
62
+ def INPUT_TYPES(cls):
63
+ return {
64
+ "required": {
65
+ "trigger": (
66
+ "IMAGE",
67
+ {
68
+ "tooltip": (
69
+ "Wire from WanVideoDecode.images (or any IMAGE "
70
+ "output AFTER the Wan pipeline). This ensures "
71
+ "the stitcher waits until all per-shot iterations "
72
+ "complete before scanning the output folder."
73
+ ),
74
+ },
75
+ ),
76
+ "pattern": (
77
+ "STRING",
78
+ {
79
+ "default": "wan_shot_002_*.mp4",
80
+ "multiline": False,
81
+ "tooltip": (
82
+ "Glob pattern (relative to ComfyUI output dir) "
83
+ "to pick up per-shot .mp4 files."
84
+ ),
85
+ },
86
+ ),
87
+ "output_prefix": (
88
+ "STRING",
89
+ {"default": "reel", "multiline": False},
90
+ ),
91
+ },
92
+ "optional": {
93
+ "source_video_path": (
94
+ "STRING",
95
+ {
96
+ "default": "",
97
+ "multiline": False,
98
+ "tooltip": (
99
+ "Optional: full path to the original source video "
100
+ "uploaded to ShotSplitter. When set, the stitcher "
101
+ "pads each Wan shot with its frozen last frame to "
102
+ "match the original source-shot duration, then "
103
+ "overlays the source's full audio track on the "
104
+ "stitched video. Leaves the result perfectly "
105
+ "synced with the original audio."
106
+ ),
107
+ },
108
+ ),
109
+ "wait_seconds": (
110
+ "FLOAT",
111
+ {
112
+ "default": 3.0,
113
+ "min": 0.0,
114
+ "max": 60.0,
115
+ "step": 0.5,
116
+ "tooltip": (
117
+ "Delay before scanning, to ensure TSVideoCombine "
118
+ "has flushed all files to disk."
119
+ ),
120
+ },
121
+ ),
122
+ "window_seconds": (
123
+ "FLOAT",
124
+ {
125
+ "default": 600.0,
126
+ "min": 10.0,
127
+ "max": 36000.0,
128
+ "step": 10.0,
129
+ "tooltip": (
130
+ "Only consider files modified within this many "
131
+ "seconds. Avoids picking up stale files from "
132
+ "previous runs."
133
+ ),
134
+ },
135
+ ),
136
+ "genpts": (
137
+ "BOOLEAN",
138
+ {
139
+ "default": False,
140
+ "tooltip": (
141
+ "Add -fflags +genpts to regenerate timestamps. "
142
+ "Enable only if you see A/V desync."
143
+ ),
144
+ },
145
+ ),
146
+ },
147
+ }
148
+
149
+ RETURN_TYPES = ("STRING",)
150
+ RETURN_NAMES = ("reel_path",)
151
+ FUNCTION = "stitch"
152
+ CATEGORY = "Oz/Output"
153
+ OUTPUT_NODE = True
154
+
155
+ @classmethod
156
+ def IS_CHANGED(cls, **kwargs):
157
+ # Always re-run so each Queue Prompt produces a fresh reel.
158
+ return time.time()
159
+
160
+ def stitch(
161
+ self,
162
+ trigger,
163
+ pattern,
164
+ output_prefix,
165
+ source_video_path="",
166
+ wait_seconds=3.0,
167
+ window_seconds=600.0,
168
+ genpts=False,
169
+ ):
170
+ # With INPUT_IS_LIST=True every input arrives as a list even widgets
171
+ def _scalar(v, default=None):
172
+ if isinstance(v, list):
173
+ return v[0] if v else default
174
+ return v
175
+
176
+ pattern = _scalar(pattern, "wan_shot_002_*.mp4")
177
+ output_prefix = _scalar(output_prefix, "reel")
178
+ source_video_path = (_scalar(source_video_path, "") or "").strip()
179
+ wait_seconds = float(_scalar(wait_seconds, 3.0))
180
+ window_seconds = float(_scalar(window_seconds, 600.0))
181
+ genpts = bool(_scalar(genpts, False))
182
+
183
+ # trigger is also a list — its length tells us how many shots ran.
184
+ # With IMAGE trigger + INPUT_IS_LIST=True, each iteration contributes
185
+ # one IMAGE tensor to the list; len(trigger) == number of shots.
186
+ if isinstance(trigger, list):
187
+ expected_count = len(trigger)
188
+ else:
189
+ expected_count = 1
190
+
191
+ if not _ffmpeg_available():
192
+ msg = "ffmpeg not found on PATH"
193
+ print(f"[Oz_StitchReel] ERROR: {msg}")
194
+ return {"ui": {"text": [msg]}, "result": ("",)}
195
+
196
+ print(
197
+ f"[Oz_StitchReel] waiting {wait_seconds}s for TSVideoCombine "
198
+ f"to flush files (expected_count={expected_count})"
199
+ )
200
+ time.sleep(wait_seconds)
201
+
202
+ output_dir = folder_paths.get_output_directory()
203
+ search = os.path.join(output_dir, pattern)
204
+ now = time.time()
205
+
206
+ candidates = [
207
+ fp
208
+ for fp in glob.glob(search)
209
+ if os.path.isfile(fp)
210
+ and (now - os.path.getmtime(fp)) <= window_seconds
211
+ ]
212
+
213
+ if not candidates:
214
+ msg = f"No files matched {search} in the last {window_seconds}s"
215
+ print(f"[Oz_StitchReel] ERROR: {msg}")
216
+ return {"ui": {"text": [msg]}, "result": ("",)}
217
+
218
+ # Sort lexicographically (zero-padded counter keeps chronological order)
219
+ candidates.sort()
220
+
221
+ # If we know expected_count, take the most recent N by mtime then
222
+ # re-sort by name. This avoids catching stale files from a prior Run
223
+ # that happen to share the pattern.
224
+ if expected_count > 0 and len(candidates) > expected_count:
225
+ by_mtime = sorted(
226
+ candidates, key=lambda f: os.path.getmtime(f), reverse=True
227
+ )
228
+ candidates = sorted(by_mtime[:expected_count])
229
+
230
+ print(f"[Oz_StitchReel] {len(candidates)} clip(s) to stitch:")
231
+ for fp in candidates:
232
+ print(f" - {os.path.basename(fp)}")
233
+
234
+ concat_dir = os.path.join(output_dir, "_concat")
235
+ os.makedirs(concat_dir, exist_ok=True)
236
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
237
+
238
+ # ----- Option C branch: source_video_path given -----
239
+ # Pad each Wan shot with frozen last frame to match the corresponding
240
+ # source shot's duration, concat video-only, then mux the FULL source
241
+ # audio track on top. Result: perfect original audio timing.
242
+ if source_video_path and os.path.exists(source_video_path):
243
+ src_stem = os.path.splitext(os.path.basename(source_video_path))[0]
244
+ shots_dir = os.path.join(output_dir, "shots", src_stem)
245
+ source_shots = sorted(
246
+ glob.glob(os.path.join(shots_dir, "shot_*.mp4"))
247
+ ) if os.path.isdir(shots_dir) else []
248
+
249
+ source_durations = [_ffprobe_duration(p) for p in source_shots]
250
+ print(
251
+ f"[Oz_StitchReel] source_video={source_video_path}\n"
252
+ f"[Oz_StitchReel] {len(source_shots)} source shots in "
253
+ f"{shots_dir}, durations={source_durations}"
254
+ )
255
+
256
+ if len(source_shots) != len(candidates):
257
+ print(
258
+ f"[Oz_StitchReel] WARN: {len(source_shots)} source shots "
259
+ f"vs {len(candidates)} wan shots — will match by index"
260
+ )
261
+
262
+ n = min(len(source_shots), len(candidates))
263
+ padded_dir = os.path.join(concat_dir, f"padded_{timestamp}")
264
+ os.makedirs(padded_dir, exist_ok=True)
265
+
266
+ padded_files = []
267
+ for i in range(n):
268
+ wan_fp = candidates[i]
269
+ target_dur = source_durations[i] if i < len(source_durations) else 0.0
270
+ wan_dur = _ffprobe_duration(wan_fp)
271
+ padded_fp = os.path.join(padded_dir, f"padded_{i:04d}.mp4")
272
+ if target_dur <= 0 or wan_dur <= 0 or abs(target_dur - wan_dur) < 0.02:
273
+ # No padding needed (or no info) — copy as-is (video only, no audio)
274
+ cmd_p = [
275
+ "ffmpeg", "-y", "-i", wan_fp,
276
+ "-c:v", "copy", "-an",
277
+ padded_fp,
278
+ ]
279
+ else:
280
+ pad_dur = max(0.0, target_dur - wan_dur)
281
+ # tpad with clone mode freezes the last frame for pad_dur seconds;
282
+ # -t trims to exact target. -an drops any audio stream.
283
+ cmd_p = [
284
+ "ffmpeg", "-y", "-i", wan_fp,
285
+ "-vf", f"tpad=stop_mode=clone:stop_duration={pad_dur:.4f}",
286
+ "-t", f"{target_dur:.4f}",
287
+ "-an",
288
+ "-c:v", "libx264", "-preset", "veryfast",
289
+ "-pix_fmt", "yuv420p",
290
+ padded_fp,
291
+ ]
292
+ print(
293
+ f"[Oz_StitchReel] shot {i+1}/{n}: "
294
+ f"wan_dur={wan_dur:.3f}s target={target_dur:.3f}s"
295
+ )
296
+ r = subprocess.run(cmd_p, capture_output=True, text=True)
297
+ if r.returncode != 0:
298
+ print(f"[Oz_StitchReel] pad failed for shot {i+1}: {r.stderr[-500:]}")
299
+ return {
300
+ "ui": {"text": [f"pad failed shot {i+1}"]},
301
+ "result": ("",),
302
+ }
303
+ padded_files.append(padded_fp)
304
+
305
+ # Build concat list of padded files
306
+ list_path = os.path.join(concat_dir, f"_concat_padded_{timestamp}.txt")
307
+ with open(list_path, "w") as f:
308
+ for fp in padded_files:
309
+ safe = fp.replace("'", "'\\''")
310
+ f.write(f"file '{safe}'\n")
311
+
312
+ reel_path = os.path.join(output_dir, f"{output_prefix}_{timestamp}.mp4")
313
+
314
+ # Concat video-only + overlay source audio
315
+ cmd = [
316
+ "ffmpeg", "-y",
317
+ "-f", "concat", "-safe", "0", "-i", list_path,
318
+ "-i", source_video_path,
319
+ "-map", "0:v:0", "-map", "1:a:0?",
320
+ "-c:v", "copy",
321
+ "-c:a", "aac", "-b:a", "192k",
322
+ "-shortest",
323
+ reel_path,
324
+ ]
325
+ print(f"[Oz_StitchReel] {' '.join(cmd)}")
326
+ else:
327
+ # ----- Simple concat (original behavior) -----
328
+ list_path = os.path.join(concat_dir, f"_concat_{timestamp}.txt")
329
+ with open(list_path, "w") as f:
330
+ for fp in candidates:
331
+ safe = fp.replace("'", "'\\''")
332
+ f.write(f"file '{safe}'\n")
333
+
334
+ reel_path = os.path.join(output_dir, f"{output_prefix}_{timestamp}.mp4")
335
+
336
+ cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0"]
337
+ if genpts:
338
+ cmd += ["-fflags", "+genpts"]
339
+ cmd += ["-i", list_path, "-c", "copy", reel_path]
340
+
341
+ print(f"[Oz_StitchReel] {' '.join(cmd)}")
342
+ try:
343
+ result = subprocess.run(
344
+ cmd,
345
+ capture_output=True,
346
+ text=True,
347
+ check=False,
348
+ )
349
+ if result.returncode != 0:
350
+ print(f"[Oz_StitchReel] ffmpeg failed (code {result.returncode}):")
351
+ print(result.stderr[-2000:] if result.stderr else "<no stderr>")
352
+ # Fallback: try with genpts
353
+ if not genpts:
354
+ print("[Oz_StitchReel] retrying with -fflags +genpts")
355
+ cmd2 = [
356
+ "ffmpeg",
357
+ "-y",
358
+ "-f",
359
+ "concat",
360
+ "-safe",
361
+ "0",
362
+ "-fflags",
363
+ "+genpts",
364
+ "-i",
365
+ list_path,
366
+ "-c",
367
+ "copy",
368
+ reel_path,
369
+ ]
370
+ result = subprocess.run(
371
+ cmd2, capture_output=True, text=True, check=False
372
+ )
373
+ if result.returncode != 0:
374
+ return {
375
+ "ui": {"text": [f"ffmpeg failed: {result.stderr[-500:]}"]},
376
+ "result": ("",),
377
+ }
378
+ except Exception as e:
379
+ print(f"[Oz_StitchReel] subprocess error: {e}")
380
+ traceback.print_exc()
381
+ return {"ui": {"text": [f"subprocess error: {e}"]}, "result": ("",)}
382
+
383
+ if not os.path.exists(reel_path):
384
+ return {
385
+ "ui": {"text": ["ffmpeg reported success but output missing"]},
386
+ "result": ("",),
387
+ }
388
+
389
+ size_mb = os.path.getsize(reel_path) / 1024 / 1024
390
+ msg = (
391
+ f"[Oz_StitchReel] ✅ reel saved: "
392
+ f"{os.path.basename(reel_path)} ({size_mb:.1f} MB)"
393
+ )
394
+ print(msg)
395
+ return {"ui": {"text": [msg]}, "result": (reel_path,)}
396
+
397
+
398
+ NODE_CLASS_MAPPINGS = {
399
+ "ozW_StitchReel": Oz_StitchReel,
400
+ }
401
+
402
+ NODE_DISPLAY_NAME_MAPPINGS = {
403
+ "ozW_StitchReel": "🎬 Oz Stitch Reel (ffmpeg concat)",
404
+ }
ComfyUI_Oz/nodes/utility_nodes/oz_string_at_index.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---
2
+ # Filename: ../Oz/nodes/utility_nodes/oz_string_at_index.py
3
+ # Takes a STRING list (with INPUT_IS_LIST=True) and returns ONE element
4
+ # at the given index as a scalar STRING. Safety net for nodes that don't
5
+ # support ComfyUI's list auto-iteration (e.g. VHS_LoadVideoPath).
6
+ # ---
7
+
8
+
9
+ class Oz_StringAtIndex:
10
+ """
11
+ Extracts one scalar STRING from a STRING list.
12
+
13
+ INPUT_IS_LIST=True: receives the full upstream list in one call.
14
+ Returns a single string at the clamped index.
15
+ """
16
+
17
+ INPUT_IS_LIST = True
18
+
19
+ @classmethod
20
+ def INPUT_TYPES(cls):
21
+ return {
22
+ "required": {
23
+ "items": (
24
+ "STRING",
25
+ {"forceInput": True},
26
+ ),
27
+ "index": (
28
+ "INT",
29
+ {"default": 0, "min": 0, "max": 10000},
30
+ ),
31
+ },
32
+ }
33
+
34
+ RETURN_TYPES = ("STRING", "INT")
35
+ RETURN_NAMES = ("item", "total")
36
+ FUNCTION = "pick"
37
+ CATEGORY = "Oz/Utils"
38
+
39
+ def pick(self, items, index):
40
+ # With INPUT_IS_LIST=True each arg arrives as a list even widgets
41
+ if isinstance(index, list):
42
+ idx = index[0] if index else 0
43
+ else:
44
+ idx = index
45
+
46
+ if items is None:
47
+ return ("", 0)
48
+ if not isinstance(items, list):
49
+ items = [items]
50
+
51
+ total = len(items)
52
+ if total == 0:
53
+ return ("", 0)
54
+
55
+ idx = max(0, min(idx, total - 1))
56
+ return (items[idx], total)
57
+
58
+
59
+ NODE_CLASS_MAPPINGS = {
60
+ "ozW_StringAtIndex": Oz_StringAtIndex,
61
+ }
62
+
63
+ NODE_DISPLAY_NAME_MAPPINGS = {
64
+ "ozW_StringAtIndex": "📌 Oz String At Index",
65
+ }