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 +245 -0
- ComfyUI_Oz/nodes/input_nodes/reality_prompt_generator.py +24 -0
- ComfyUI_Oz/nodes/output_nodes/save_with_metadata.py +1 -1
- ComfyUI_Oz/nodes/utility_nodes/__init__.py +24 -0
- ComfyUI_Oz/nodes/utility_nodes/lora_character_picker.py +210 -0
- ComfyUI_Oz/nodes/utility_nodes/oz_shot_analyzer.py +608 -0
- ComfyUI_Oz/nodes/utility_nodes/oz_stitch_reel.py +404 -0
- ComfyUI_Oz/nodes/utility_nodes/oz_string_at_index.py +65 -0
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 =
|
| 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 |
+
}
|