From 86e8889f2ef2d43d0e57afcd22169d9a6ddb5462 Mon Sep 17 00:00:00 2001 From: zeeoale Date: Sat, 7 Feb 2026 14:42:33 +0100 Subject: [PATCH] Add v2 skeleton: core/utils/nodes + manifest + DirectorNode stub --- DirectorNode.py | 79 ++++++++++++++++++++++++++++++++++++++++++ core/prompt_engine.py | 69 ++++++++++++++++++++++++++++++++++++ core/world_loader.py | 35 +++++++++++++++++++ core/world_registry.py | 40 +++++++++++++++++++++ manifest.py | 10 ++++++ nodes/director.py | 34 ++++++++++++++++++ utils/logger.py | 8 +++++ utils/paths.py | 10 ++++++ 8 files changed, 285 insertions(+) create mode 100644 DirectorNode.py create mode 100644 core/prompt_engine.py create mode 100644 core/world_loader.py create mode 100644 core/world_registry.py create mode 100644 manifest.py create mode 100644 nodes/director.py create mode 100644 utils/logger.py create mode 100644 utils/paths.py diff --git a/DirectorNode.py b/DirectorNode.py new file mode 100644 index 0000000..de4b754 --- /dev/null +++ b/DirectorNode.py @@ -0,0 +1,79 @@ +# DirectorNode.py +# PromptCreatorNode - DirectorNode (0.1-dev) + +class DirectorNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "prompt": ("STRING", {"multiline": True, "default": ""}), + "framing": (["extreme_closeup", "closeup", "portrait", "half_body", "full_body"], {"default": "portrait"}), + "camera_mode": (["random", "manual", "lock"], {"default": "random"}), + "camera_index": ("INT", {"default": 0, "min": 0, "max": 200, "step": 1}), + "show_director_preview": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("prompt", "director_preview") + FUNCTION = "direct" + CATEGORY = "PromptCreatorNode/Director" + + # A small default camera library (node-owned, not world-owned) + DEFAULT_CAMERA_ANGLES = [ + "eye-level portrait, 85mm look", + "three-quarter view, 50mm look", + "slight low angle, 35mm look", + "profile close-up, 105mm look", + "top-down framing, 24mm look", + "centered frontal composition, 70mm look", + ] + + def direct(self, prompt, framing, camera_mode, camera_index, show_director_preview): + cams = list(self.DEFAULT_CAMERA_ANGLES) + + # pick camera + chosen = "" + if camera_mode == "manual": + if cams: + i = max(0, min(int(camera_index), len(cams) - 1)) + chosen = cams[i] + elif camera_mode == "lock": + # 0.1-dev: lock behaves like manual (true persistence later) + if cams: + i = max(0, min(int(camera_index), len(cams) - 1)) + chosen = cams[i] + else: # random + import random + chosen = random.choice(cams) if cams else "" + + # apply framing + camera to prompt + out_prompt = prompt.strip() + if framing: + out_prompt = (out_prompt + ", " if out_prompt else "") + framing.replace("_", " ") + if chosen: + out_prompt = out_prompt + ", " + chosen + + # preview + preview = "" + if show_director_preview: + lines = [f"FRAMING: {framing}"] + lines.append(f"CAMERA_MODE: {camera_mode}") + if chosen: + lines.append(f"CHOSEN_CAMERA: {chosen}") + lines.append("CAMERA_LIBRARY:") + for idx, c in enumerate(cams): + marker = "->" if c == chosen else " " + lines.append(f"{marker} {idx}: {c}") + preview = "\n".join(lines) + + return (out_prompt, preview) + + +NODE_CLASS_MAPPINGS = { + "DirectorNode": DirectorNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "DirectorNode": "Director Node (0.1-dev)", +} diff --git a/core/prompt_engine.py b/core/prompt_engine.py new file mode 100644 index 0000000..9554f2f --- /dev/null +++ b/core/prompt_engine.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import random +from typing import Any, Dict, List, Optional + + +def _pick(arr: Any, fallback: str = "") -> str: + if isinstance(arr, list) and arr: + return str(random.choice(arr)) + return fallback + + +def _join_nonempty(parts: List[str]) -> str: + return ", ".join([p.strip() for p in parts if p and p.strip()]) + + +def build_prompt_from_world( + world: Dict[str, Any], + seed: Optional[int] = None, + custom_intro_index: Optional[int] = None, +) -> str: + if seed is not None: + random.seed(seed) + + # 1) system prompt (se ti serve per debug/log o per ritorni futuri) + system_prompt = str(world.get("SYSTEM_PROMPT", "")).strip() + + # 2) prendi un custom_intro (se c’è) + custom_intro = "" + ci = world.get("CUSTOM_INTRO") + if isinstance(ci, dict) and ci: + if custom_intro_index is not None and str(custom_intro_index) in ci: + custom_intro = str(ci[str(custom_intro_index)]) + else: + # prova 0..6, altrimenti random tra valori + for k in ["0", "1", "2", "3", "4", "5", "6"]: + if k in ci: + custom_intro = str(ci[k]) + break + if not custom_intro: + custom_intro = str(random.choice(list(ci.values()))) + + # 3) selezioni modulari (liste) + outfits = _pick(world.get("OUTFITS"), "simple dark outfit") + lighting = _pick(world.get("LIGHTING"), "low ambient light") + backgrounds = _pick(world.get("BACKGROUNDS"), "intimate interior") + objects = _pick(world.get("OBJECTS"), "personal items") + poses = _pick(world.get("POSES"), "relaxed pose") + expressions = _pick(world.get("EXPRESSIONS"), "calm expression") + camera = _pick(world.get("CAMERA_ANGLES"), "eye-level framing") + atmos = _pick(world.get("ATMOSPHERES"), "quiet cinematic mood") + accessories = _pick(world.get("ACCESSORIES"), "") + + # 4) compone una frase unica (poi la raffiniamo nello step successivo) + core = _join_nonempty([ + custom_intro, + outfits, + lighting, + backgrounds, + objects, + poses, + expressions, + camera, + atmos, + accessories, + ]) + + # system_prompt lo teniamo separato: qui ritorniamo solo il prompt “artistico” + return core.strip(), system_prompt diff --git a/core/world_loader.py b/core/world_loader.py new file mode 100644 index 0000000..f897ae3 --- /dev/null +++ b/core/world_loader.py @@ -0,0 +1,35 @@ +import json +from pathlib import Path +from typing import Any, Dict + +def load_world_file(path: Path) -> Dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(f"World file not found: {path}") + + if path.suffix.lower() != ".json": + raise ValueError(f"World file is not a .json: {path}") + + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + + if not isinstance(data, dict): + raise ValueError(f"World JSON must be an object/dict: {path}") + + return data + + +def load_all_worlds_from_dir(dir_path: Path) -> Dict[str, Dict[str, Any]]: + worlds: Dict[str, Dict[str, Any]] = {} + if not dir_path.exists(): + return worlds + + for p in sorted(dir_path.glob("*.json")): + try: + world = load_world_file(p) + key = world.get("WORLD_NAME") or p.stem + worlds[str(key)] = world + except Exception: + # Qui volutamente silenzioso: il registry/log deciderà se stampare errori + continue + + return worlds diff --git a/core/world_registry.py b/core/world_registry.py new file mode 100644 index 0000000..2980921 --- /dev/null +++ b/core/world_registry.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from ..utils.paths import worlds_dir +from ..utils.logger import log +from .world_loader import load_all_worlds_from_dir + + +@dataclass(frozen=True) +class WorldInfo: + name: str + data: Dict[str, Any] + + +class WorldRegistry: + _cache: Optional[Dict[str, Dict[str, Any]]] = None + + @classmethod + def reload(cls) -> None: + cls._cache = load_all_worlds_from_dir(worlds_dir()) + log(f"[PFN] Loaded worlds: {len(cls._cache)} from {worlds_dir()}") + + @classmethod + def _ensure_loaded(cls) -> None: + if cls._cache is None: + cls.reload() + + @classmethod + def list_names(cls) -> List[str]: + cls._ensure_loaded() + return sorted(cls._cache.keys()) if cls._cache else [] + + @classmethod + def get(cls, name: str) -> Optional[Dict[str, Any]]: + cls._ensure_loaded() + if not cls._cache: + return None + return cls._cache.get(name) diff --git a/manifest.py b/manifest.py new file mode 100644 index 0000000..9e3176b --- /dev/null +++ b/manifest.py @@ -0,0 +1,10 @@ +MANIFEST = { + "name": "Prompt Creator Node", + "version": (0, 1, 0), # nuova linea di sviluppo + "author": "TK-traumakom", + "project": "https://github.com/traumakom/PromptCreatorNode", + "description": ( + "Next-gen prompt suite for ComfyUI: modular worlds, consistent identities, " + "director-style composition, and prompt building utilities." + ), +} diff --git a/nodes/director.py b/nodes/director.py new file mode 100644 index 0000000..4decb70 --- /dev/null +++ b/nodes/director.py @@ -0,0 +1,34 @@ +from ..core.world_registry import WorldRegistry +from ..core.prompt_engine import build_prompt_from_world + + +class DirectorNode: + @classmethod + def INPUT_TYPES(cls): + names = WorldRegistry.list_names() + if not names: + names = ["(no worlds found)"] + + return { + "required": { + "world": (names,), + "seed": ("INT", {"default": 0, "min": 0, "max": 2**31 - 1}), + }, + "optional": { + "custom_intro_index": ("INT", {"default": -1, "min": -1, "max": 6}), + }, + } + + RETURN_TYPES = ("STRING", "STRING") + RETURN_NAMES = ("prompt", "system_prompt") + FUNCTION = "run" + CATEGORY = "PFN/Director" + + def run(self, world: str, seed: int, custom_intro_index: int = -1): + w = WorldRegistry.get(world) + if not w: + return ("(world not found)", "") + + idx = None if custom_intro_index < 0 else int(custom_intro_index) + prompt, system_prompt = build_prompt_from_world(w, seed=seed, custom_intro_index=idx) + return (prompt, system_prompt) diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..5d6df60 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,8 @@ +import os + +def is_debug() -> bool: + return os.environ.get("PFN_DEBUG", "0") == "1" + +def log(msg: str) -> None: + if is_debug(): + print(msg) diff --git a/utils/paths.py b/utils/paths.py new file mode 100644 index 0000000..ca71217 --- /dev/null +++ b/utils/paths.py @@ -0,0 +1,10 @@ +from pathlib import Path + +def package_root() -> Path: + return Path(__file__).resolve().parents[1] + +def data_dir() -> Path: + return package_root() / "data" + +def worlds_dir() -> Path: + return data_dir() / "worlds"