File size: 8,851 Bytes
38fe5dd 20a8ff6 38fe5dd 556efaa 38fe5dd 556efaa 38fe5dd 556efaa 38fe5dd 556efaa 38fe5dd 556efaa 38fe5dd 556efaa 38fe5dd 20a8ff6 38fe5dd 20a8ff6 38fe5dd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 | """JoyEcho Prompt Source - ONE dropdown for both prompt pipelines.
Lists LPFF-style brief/JSON-block .txt files (from the inspire-pack prompts
tree) AND passthrough .json scripts (from <ComfyUI>/input/joyecho_prompts/) in
a single combo, and always emits the same outputs:
story_idea (STRING, list) -> JoyEcho_LLMEnhance.story_idea
character (STRING, list) -> JoyEcho_RefPicker.character
count (INT)
.txt files are parsed LPFF-style (blocks split on ---, positive:/negative:/
name: fields); each block becomes one queue item, so multi-block files fan out
exactly like LoadPromptsFromFile. .json files load as a single passthrough item
(the raw JSON text). Blocks without a name: line emit "" (RefPicker falls
through to prompt-scan / fallback), never the filename.
Replaces the LPFF -> UnzipPrompt chain and the Script Picker: wire once,
switch sources by picking a different file.
"""
import json
import re
from pathlib import Path
import folder_paths
_JSON_SUBDIR = "joyecho_prompts"
_TXT_PREFIX = "TXT: "
_JSON_PREFIX = "JSON: "
_EMPTY = "(no prompt files found)"
_BLOCK_SPLIT = re.compile(r"\n\s*-+\s*\n")
_BLOCK_PATTERN = re.compile(
r"^(?:(?:name:(?P<name>.*?)|positive:(?P<positive>.*?)|negative:(?P<negative>.*?))\n*)+$",
re.DOTALL,
)
def _txt_root() -> Path | None:
try:
roots = folder_paths.get_folder_paths("inspire_prompts")
return Path(roots[0]) if roots else None
except Exception:
return None
def _json_root() -> Path:
d = Path(folder_paths.get_input_directory()) / _JSON_SUBDIR
try:
d.mkdir(parents=True, exist_ok=True)
except OSError:
pass
return d
def _list_files() -> list[str]:
out = []
root = _txt_root()
if root and root.is_dir():
for p in sorted(root.rglob("*.txt")):
out.append(_TXT_PREFIX + str(p.relative_to(root)))
jroot = _json_root()
# rglob, matching the .txt branch above: a flat glob hides scripts filed in
# subfolders, which is how anyone organises more than a handful of them.
# _resolve() already joins the relative path, so nested names round-trip.
for p in sorted(jroot.rglob("*.json")):
out.append(_JSON_PREFIX + str(p.relative_to(jroot)).replace("\\", "/"))
return out or [_EMPTY]
def _resolve(choice: str) -> Path | None:
if choice.startswith(_TXT_PREFIX):
root = _txt_root()
return (root / choice[len(_TXT_PREFIX):]) if root else None
if choice.startswith(_JSON_PREFIX):
return _json_root() / choice[len(_JSON_PREFIX):]
return None
class JoyEcho_PromptSource:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_file": (_list_files(),),
},
"optional": {
"load_cap": ("INT", {"default": 0, "min": 0,
"tooltip": "TXT files only: 0 = all blocks, N = first N from start_index."}),
"start_index": ("INT", {"default": 0, "min": 0,
"tooltip": "TXT files only: first block index to load."}),
"character_override": ("STRING", {"default": "",
"tooltip": "Force this character for EVERY emitted item "
"(useful for .json scripts, which carry no name field)."}),
"manual_path": ("STRING", {"default": "",
"tooltip": "Point at ANY .txt (LPFF blocks) or .json (passthrough script) "
"file by full path, ignoring the dropdown. Removes any dependency "
"on the inspire-pack prompts tree or input/joyecho_prompts/. "
"Leave empty to use source_file."}),
},
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("story_idea", "character", "count")
OUTPUT_IS_LIST = (True, True, False)
FUNCTION = "load"
CATEGORY = "JoyAI-Echo"
@classmethod
def IS_CHANGED(cls, source_file, load_cap=0, start_index=0, character_override="", manual_path=""):
p = Path(manual_path.strip()) if manual_path.strip() else _resolve(source_file)
key = manual_path.strip() or source_file
try:
return f"{key}:{p.stat().st_mtime}:{load_cap}:{start_index}:{character_override}"
except Exception:
return key
def load(self, source_file, load_cap=0, start_index=0, character_override="", manual_path=""):
manual = manual_path.strip()
if manual:
p = Path(manual).expanduser()
is_json = p.suffix.lower() == ".json"
else:
if source_file == _EMPTY:
raise ValueError("PromptSource: no prompt files found in the inspire prompts tree "
"or input/joyecho_prompts/. Set manual_path to a file instead.")
p = _resolve(source_file)
is_json = source_file.startswith(_JSON_PREFIX)
if p is None or not p.exists():
raise FileNotFoundError(f"PromptSource: {manual or source_file} -> {p} not found. "
"Refresh the node list (R) after adding files, or check manual_path.")
text = p.read_text(encoding="utf-8")
override = character_override.strip().lower()
if is_json:
data = json.loads(text) # fail early with a clear error
arr = data.get("prompts") or data.get("shots")
if not isinstance(arr, list) or not arr:
raise ValueError(f"{p.name} must contain a non-empty 'prompts' (or 'shots') array.")
# Per-character audio memory: derive the speaker order from the
# script (an explicit "speakers" array, else the "<ID> is talking"
# attribution in each shot) and stash it for JoyEcho_Generate. The
# derivation lives in joyecho_script_picker; both loaders feed the
# same stash so it works regardless of which node the graph uses -
# the first wiring of this feature went ONLY into ScriptPicker and
# the live workflow loads through THIS node, so the 11:23 render
# silently ran with per-character memory off.
try:
from .joyecho_script_picker import (derive_speakers, set_last_speakers,
set_last_voice_refs)
except ImportError:
from joyecho_script_picker import (derive_speakers, set_last_speakers,
set_last_voice_refs)
speakers = derive_speakers(data, arr)
set_last_speakers(speakers)
vrefs = data.get("voice_refs") or {}
set_last_voice_refs(vrefs)
print(f"[JoyEcho] PromptSource: {p.name} (json, {len(arr)} shots, 1 item)"
+ (f", speakers: {' '.join(speakers)}" if speakers else ", no speaker tags")
+ (f", voice anchors: {', '.join(vrefs)}" if vrefs else "")
+ ".", flush=True)
return ([text], [override], 1)
# TXT: LPFF-style blocks. Clear the speaker stash - it holds whatever the
# LAST json load derived, and stale speakers applied to an unrelated
# script would filter the audio bank to the wrong characters.
try:
from .joyecho_script_picker import set_last_speakers, set_last_voice_refs
except ImportError:
from joyecho_script_picker import set_last_speakers, set_last_voice_refs
set_last_speakers([])
set_last_voice_refs({})
items, names = [], []
for blk in _BLOCK_SPLIT.split(text):
m = _BLOCK_PATTERN.search(blk)
if not m or m.group("positive") is None:
continue
items.append(m.group("positive").strip())
nm = (m.group("name") or "").strip().lower()
names.append(override or nm)
total = len(items)
items = items[start_index:]
names = names[start_index:]
if load_cap > 0:
items = items[:load_cap]
names = names[:load_cap]
if not items:
raise ValueError(f"PromptSource: {p.name} yielded no blocks "
f"(total {total}, start_index {start_index}, load_cap {load_cap}).")
print(f"[JoyEcho] PromptSource: {p.name} (txt, {len(items)}/{total} blocks).", flush=True)
return (items, names, len(items))
NODE_CLASS_MAPPINGS = {"JoyEcho_PromptSource": JoyEcho_PromptSource}
NODE_DISPLAY_NAME_MAPPINGS = {"JoyEcho_PromptSource": "JoyEcho Prompt Source (txt briefs + json scripts)"}
|