| """JoyEcho Script Picker — a dropdown node for selecting a saved prompt JSON. |
| |
| The stock JoyEcho_TextEncode 'prompts' field only accepts pasted JSON / a typed |
| path, which is painful in the canvas. This node lists every *.json in |
| <ComfyUI>/input/joyecho_prompts/ |
| as a COMBO dropdown and outputs the file's contents, wired straight into |
| JoyEcho_TextEncode's 'prompts' input (which accepts inline {"prompts":[...]} JSON). |
| |
| Add a .json to that folder, hit the ComfyUI refresh button (or press R) to |
| repopulate the dropdown, pick it, run. Editing the file re-triggers execution |
| automatically (IS_CHANGED tracks mtime) — no need to reselect. |
| """ |
|
|
| import json |
| import re |
| from pathlib import Path |
|
|
| import folder_paths |
|
|
| _PROMPTS_SUBDIR = "joyecho_prompts" |
| _EMPTY = "(no .json in input/joyecho_prompts)" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _SPEAKER_RE = re.compile(r"\b(ID_[A-Z]|[A-Z][a-z]+)\s+is\s+talking\b") |
| LAST_SPEAKERS: list[str] = [] |
|
|
| |
| |
| |
| |
| |
| LAST_VOICE_REFS: dict = {} |
|
|
|
|
| def set_last_voice_refs(refs: dict) -> None: |
| """Stash the current script's voice anchors (empty dict clears).""" |
| global LAST_VOICE_REFS |
| LAST_VOICE_REFS = dict(refs) if isinstance(refs, dict) else {} |
|
|
|
|
| def set_last_speakers(speakers: list[str]) -> None: |
| """Stash the current script's speaker order for the Generate node. |
| |
| Wiring the `speakers` output is the explicit path; this module-level stash is |
| the zero-rewiring fallback so existing saved workflows and queued runs pick |
| it up with no canvas edits. ComfyUI executes a graph's nodes in dependency |
| order within one prompt, so the picker always runs before the generator it |
| feeds, and each execution overwrites the previous value. |
| """ |
| global LAST_SPEAKERS |
| LAST_SPEAKERS = list(speakers) |
|
|
|
|
| def derive_speakers(data: dict, shots: list) -> list[str]: |
| """Speaker tag per shot: an explicit "speakers" array wins, else the prose. |
| |
| Returns [] when the script declares nothing and no shot names a speaker - |
| callers then fall back to character-blind memory, i.e. old behaviour. |
| """ |
| if isinstance(data, dict): |
| declared = data.get("speakers") or data.get("speaker_order") |
| if isinstance(declared, str): |
| declared = [t for t in re.split(r"[,\s]+", declared.strip()) if t] |
| if isinstance(declared, list) and declared: |
| return [str(declared[i % len(declared)]) for i in range(len(shots))] |
|
|
| out, seen_any = [], False |
| for shot in shots: |
| m = _SPEAKER_RE.search(str(shot)) |
| if m: |
| out.append(m.group(1)) |
| seen_any = True |
| else: |
| |
| |
| |
| out.append(out[-1] if out else "") |
| return out if seen_any and all(out) else [] |
|
|
|
|
| def _scripts_dir() -> Path: |
| d = Path(folder_paths.get_input_directory()) / _PROMPTS_SUBDIR |
| try: |
| d.mkdir(parents=True, exist_ok=True) |
| except OSError: |
| pass |
| return d |
|
|
|
|
| def _list_scripts() -> list[str]: |
| d = _scripts_dir() |
| try: |
| files = sorted(p.name for p in d.glob("*.json")) |
| except OSError: |
| files = [] |
| return files if files else [_EMPTY] |
|
|
|
|
| class JoyEcho_ScriptPicker: |
| """Pick a prompt-script .json from input/joyecho_prompts via a dropdown.""" |
|
|
| @classmethod |
| def INPUT_TYPES(cls): |
| return {"required": {"script": (_list_scripts(),)}} |
|
|
| RETURN_TYPES = ("STRING", "STRING", "STRING",) |
| RETURN_NAMES = ("prompts_json", "path", "speakers",) |
| FUNCTION = "load" |
| CATEGORY = "JoyAI-Echo" |
|
|
| @classmethod |
| def IS_CHANGED(cls, script): |
| |
| p = _scripts_dir() / script |
| try: |
| return f"{script}:{p.stat().st_mtime}" |
| except OSError: |
| return script |
|
|
| def load(self, script): |
| if script == _EMPTY: |
| raise ValueError( |
| f"No .json scripts found. Put your prompt JSON in {_scripts_dir()} " |
| "and press the refresh button (or R) to repopulate the dropdown." |
| ) |
| p = _scripts_dir() / script |
| if not p.exists(): |
| raise FileNotFoundError( |
| f"Script not found: {p}. Refresh the node list (R) after adding files." |
| ) |
| text = p.read_text(encoding="utf-8") |
| |
| try: |
| data = json.loads(text) |
| except json.JSONDecodeError as e: |
| raise ValueError(f"{script} is not valid JSON: {e}") |
| arr = data.get("prompts") if isinstance(data, dict) else None |
| if arr is None and isinstance(data, dict): |
| arr = data.get("shots") |
| if not isinstance(arr, list) or not arr: |
| raise ValueError(f"{script} must contain a non-empty 'prompts' (or 'shots') array.") |
| speakers = derive_speakers(data, arr) |
| set_last_speakers(speakers) |
| set_last_voice_refs(data.get("voice_refs") or {}) |
| print(f"[JoyEcho] ScriptPicker: {script} ({len(arr)} shots)" |
| + (f", speakers: {' '.join(speakers)}" if speakers else ", no speaker tags") |
| + ".", flush=True) |
| return (text, str(p), " ".join(speakers),) |
|
|
|
|
| NODE_CLASS_MAPPINGS = {"JoyEcho_ScriptPicker": JoyEcho_ScriptPicker} |
| NODE_DISPLAY_NAME_MAPPINGS = {"JoyEcho_ScriptPicker": "JoyEcho Script Picker (JSON dropdown)"} |
|
|