diff --git a/.github/workflows/publish_action.yml b/.github/workflows/publish_action.yml new file mode 100644 index 0000000..8696711 --- /dev/null +++ b/.github/workflows/publish_action.yml @@ -0,0 +1,24 @@ +name: Publish to Comfy registry +on: + workflow_dispatch: + push: + branches: + - main + paths: + - "pyproject.toml" + +permissions: + issues: write + +jobs: + publish-node: + name: Publish Custom Node to registry + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'WASasquatch' }} + steps: + - name: Check out code + uses: actions/checkout@v4 + - name: Publish Custom Node + uses: Comfy-Org/publish-node-action@v1 + with: + personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here. diff --git a/.gitignore b/.gitignore index b7faf40..e1f6104 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,11 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Project-specific folders +conversations/ +temp_convos/ +temp_images/ + +# Windows Zone Identifier files +*:Zone.Identifier diff --git a/README.md b/README.md index 58e0ec1..a8c1a88 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# ComfyUI_LMStudio_EasyQuery -LM Studio nodes for querying, chat, vision descriptions, and dataset captioning +# WAS LM Studio Easy-Query + +A custom node pack that integrates LM Studio's Python SDK with ComfyUI for local LLM/VLM workflows. It provides nodes for model configuration, flexible per-request options, text+vision querying, image captioning, and chat with persistent or temporary conversations. + +## Requirements +Install the Python dependencies used by these nodes with the requirements.txt or simply install required modules: + +``` +pip install lmstudio numpy Pillow +``` + +## Installation +Open manager and search Manager for `WAS LM Studio Easy-Query`, or: + +1. Place this folder in `ComfyUI/custom_nodes/comfyui_lmstudio_nodes`. +2. Start or restart ComfyUI. On startup, this pack clears: + - `temp_convos/` (temporary conversations) + +## Node Overview + +| Node | Type | Key Inputs | Outputs | Notes | +| --- | --- | --- | --- | --- | +| LM Studio Model | Config | base_url, model_id, temperature, max_tokens, seed, timeout, image_max_size, unload | LMSTUDIO_MODEL | Produces model config consumed by other nodes | +| LM Studio Options | Options | temperature, max_tokens, seed, top_p, top_k, frequency_penalty, presence_penalty, repeat_penalty, stop | LMSTUDIO_OPTIONS | Per-request overrides; take precedence over Model values | +| LM Studio Query | Request | model, mode (one-by-one/batch), system_prompt, user_prompt, images?, options? | responses (list) | Text/VLM single-turn. Images sent via temp PNG paths to `lms.prepare_image(path)` | +| LM Studio Easy-Caption | Caption | model, images, mode (one-by-one/batch), task_name, user_prompt?, options? | captions (list) | Preset tasks; same image handling as Query | +| LM Studio Chat | Chat | model, conversation_choice, conversation_name, mode, system_prompt, user_prompt, images?, options?, temp_convo | responses (list), queries (list), conversation_name (string) | "New Conversation" sentinel; temp_convo stores in temp_convos | + +## Tips +- Use a VLM model (e.g., qwen2-vl variants) for image inputs. +- Tooltips in the UI describe all fields; Options override Model settings per request. +- Enable `unload` in the Model node if you want to free VRAM after each node run. + +## Troubleshooting +- Responses look text-only with images: + - Verify a VLM is selected in the Model node + - Ensure images are wired into `images` input +- Conversation selection errors: + - Keep `conversation_choice` on "New Conversation" and type a name, or pick an existing name from the dropdown +- If the SDK logs websocket shutdown messages on exit, they are informational during normal shutdown. + +## License +MIT (see repository terms). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..bb44119 --- /dev/null +++ b/__init__.py @@ -0,0 +1,25 @@ +import os +import shutil + +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +_temp_dir = os.path.join(os.path.dirname(__file__), "temp_convos") +try: + if os.path.isdir(_temp_dir): + shutil.rmtree(_temp_dir, ignore_errors=True) + os.makedirs(_temp_dir, exist_ok=True) +except Exception: + pass + +_temp_images = os.path.join(os.path.dirname(__file__), "temp_images") +try: + if os.path.isdir(_temp_images): + shutil.rmtree(_temp_images, ignore_errors=True) + os.makedirs(_temp_images, exist_ok=True) +except Exception: + pass + +print("[\033[92mWAS LMStudio Easy-Query\033[0m] has loaded {} nodes.".format(len(NODE_CLASS_MAPPINGS))) +print("Nodes: " + ", ".join([f"\033[1m{node_name}\033[0m" for node_name in NODE_DISPLAY_NAME_MAPPINGS.values()])) + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/lmstudio_config.json b/lmstudio_config.json new file mode 100644 index 0000000..a2dd991 --- /dev/null +++ b/lmstudio_config.json @@ -0,0 +1,10 @@ +{ + "temperature": 0.15, + "max_tokens": 768, + "seed": 0, + "unload_after_use": true, + "default_model": "qwen/qwen2.5-vl-3b", + "image_max_sizes": [256, 512, 1024, 2048], + "default_image_max_size": 1024, + "allowed_root_directories": ["/mnt/k", "/mnt/m"] +} diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..f832678 --- /dev/null +++ b/nodes.py @@ -0,0 +1,1226 @@ +import os +import json +import io +import uuid + +import numpy as np +import lmstudio as lms + +from typing import Any, Dict, List, Tuple, Optional +from PIL import Image +import shutil + +import folder_paths +from comfy.utils import ProgressBar + +def read_config() -> Dict[str, Any]: + """Read configuration from lmstudio_config.json.""" + config_path = os.path.join(os.path.dirname(__file__), "lmstudio_config.json") + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + return json.load(f) + return {} + +def read_tasks() -> Dict[str, str]: + """Read task files from the tasks directory.""" + tasks_dir = os.path.join(os.path.dirname(__file__), "tasks") + tasks = {} + if os.path.exists(tasks_dir): + for filename in os.listdir(tasks_dir): + if filename.endswith(".txt"): + task_name = filename[:-4] # Drop .txt extension + task_path = os.path.join(tasks_dir, filename) + with open(task_path, "r", encoding="utf-8") as f: + tasks[task_name] = f.read().strip() + return tasks + +def fetch_models() -> Optional[List[str]]: + """Fetch available models from LM Studio using the official SDK.""" + try: + downloaded = lms.list_downloaded_models() + keys: List[str] = [] + for m in downloaded: + key = getattr(m, "model_key", None) or getattr(m, "key", None) + keys.append(key if isinstance(key, str) else str(m)) + return keys or None + except Exception as e: + print(f"Error fetching models: {e}") + return None + +def image_tensor_to_png_bytes(image_tensor, max_edge: int = 1024) -> bytes: + """Convert an image tensor to PNG bytes for lmstudio.prepare_image.""" + t = image_tensor + if hasattr(t, "dim"): + if t.dim() == 4: + t = t[0] if t.shape[0] > 1 else t.squeeze(0) + arr = t.detach().cpu().numpy() + else: + arr = np.asarray(t) + if arr.ndim == 2: + arr = arr[:, :, None] + if arr.ndim == 3 and arr.shape[-1] not in (1, 3, 4) and arr.shape[0] in (1, 3, 4): + arr = np.transpose(arr, (1, 2, 0)) + arr = np.clip(arr, 0.0, 1.0) + if arr.dtype != np.uint8: + arr = (arr * 255.0 + 0.5).astype(np.uint8) + img = Image.fromarray(arr) + if max(img.size) > max_edge: + img.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS) + buf = io.BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + +def _temp_images_dir() -> str: + try: + d = folder_paths.get_temp_directory() + except Exception: + d = os.path.join(os.path.dirname(__file__), "temp_images") + os.makedirs(d, exist_ok=True) + return d + +def image_tensor_to_temp_png_path(image_tensor, max_edge: int = 1024) -> str: + data = image_tensor_to_png_bytes(image_tensor, max_edge=max_edge) + d = _temp_images_dir() + p = os.path.join(d, f"{uuid.uuid4().hex}.png") + with open(p, "wb") as f: + f.write(data) + return p + +def listify(x): + """Convert input to list if it's not already.""" + if x is None: + return [] + if isinstance(x, (list, tuple)): + return list(x) + return [x] + +def string_list(strings: List[str]) -> Tuple[List[str]]: + """Return strings as a tuple containing a list (for ComfyUI output).""" + return (strings,) + +def _map_params(params: Dict[str, Any]) -> Dict[str, Any]: + out: Dict[str, Any] = {} + for k, v in params.items(): + if v is None: + continue + if k == "max_tokens": + out["maxTokens"] = v + elif k == "top_p": + out["topP"] = v + elif k == "top_k": + out["topK"] = v + elif k == "frequency_penalty": + out["frequencyPenalty"] = v + elif k == "presence_penalty": + out["presencePenalty"] = v + elif k == "repeat_penalty": + out["repeatPenalty"] = v + else: + out[k] = v + return out + +class WASLMStudioModel: + @classmethod + def INPUT_TYPES(cls): + cfg = read_config() + models = fetch_models() or [""] + + default_model = cfg.get("default_model") or (models[0] if models else "") + temperature_default = float(cfg.get("temperature", 0.2)) + max_tokens_default = int(cfg.get("max_tokens", 512)) + seed_default = int(cfg.get("seed", 0)) + unload_default = bool(cfg.get("unload_after_use", True)) + + size_choices = [str(s) for s in cfg.get("image_max_sizes", [256, 512, 1024, 2048])] + size_default = str(cfg.get("default_image_max_size", 1024)) + if size_default not in size_choices: + size_default = size_choices[0] + + return { + "required": { + "model": ( + list(models), + { + "default": default_model if default_model in models else models[0], + "tooltip": "Model key discovered from the LM Studio SDK. Pick a listed model or use manual_model_id.", + }, + ), + "manual_model_id": ( + "STRING", + { + "default": "", + "placeholder": "e.g. qwen/qwen2.5-vl-3b", + "tooltip": "Optional manual model identifier if your LM Studio instance is not returning it via /models or runs on a different base URL.", + }, + ), + "unload_after_use": ( + "BOOLEAN", + { + "default": unload_default, + "tooltip": "Unload the model after queries to free up memory. Uses LM Studio SDK for proper model management.", + }, + ), + "temperature": ( + "FLOAT", + { + "default": temperature_default, + "min": 0.0, + "max": 2.0, + "step": 0.05, + "tooltip": "Sampling temperature. Higher is more random; lower is more deterministic.", + }, + ), + "max_tokens": ( + "INT", + { + "default": max_tokens_default, + "min": 1, + "max": 32768, + "tooltip": "Maximum new tokens to generate for the assistant reply.", + }, + ), + "seed": ( + "INT", + { + "default": seed_default, + "min": 0, + "max": 2**31 - 1, + "tooltip": "Optional seed for deterministic sampling if supported by LM Studio. Use 0 to disable.", + }, + ), + "image_max_size": ( + list(size_choices), + { + "default": size_default, + "tooltip": "Maximum edge size for input images (keeps aspect ratio). Images larger than this are downscaled before encoding and sending to LM Studio.", + }, + ), + }, + "optional": { + }, + } + + RETURN_TYPES = ("LMSTUDIO_MODEL",) + RETURN_NAMES = ("model",) + CATEGORY = "LM Studio" + FUNCTION = "load_model" + + def load_model( + self, + model: str, + manual_model_id: str, + unload_after_use: bool, + temperature: float, + max_tokens: int, + seed: int, + image_max_size: str, + ): + cfg = read_config() + + chosen_id = manual_model_id.strip() if manual_model_id.strip() else model + if chosen_id == "": + chosen_id = manual_model_id.strip() or "" + + selected_max = int(image_max_size) if str(image_max_size).isdigit() else int(cfg.get("default_image_max_size", 1024)) + + try: + _handle = lms.llm(chosen_id) + print(f"Loaded (or attached to) model: {chosen_id}") + except Exception as e: + print(f"Warning: Could not load model {chosen_id}: {e}") + + result = { + "model_id": chosen_id, + "unload": bool(unload_after_use), + "temperature": float(temperature), + "max_tokens": int(max_tokens), + "seed": int(seed) if seed != 0 else None, + "image_max_size": int(selected_max), + } + return (result,) + +class WASLMStudioQuery: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "model": ( + "LMSTUDIO_MODEL", + { + "tooltip": "LM Studio model settings produced by the LM Studio Model node. Contains model_id, temperature, max_tokens, seed, and image_max_size.", + }, + ), + "mode": ( + ["one-by-one", "batch"], + { + "default": "one-by-one", + "tooltip": "Batch sends all images in a single request; one-by-one sends one request per image using the same prompts.", + }, + ), + "system_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "placeholder": "Optional system instructions for the model.", + "tooltip": "System role content that sets the assistant's behavior for this request. If blank, no system message is added.", + }, + ), + "user_prompt": ( + "STRING", + { + "default": "Describe the image.", + "multiline": True, + "tooltip": "User message sent to the model. Works alone for text-only or together with provided images for vision models.", + }, + ), + }, + "optional": { + "images": ( + "IMAGE", + { + "tooltip": "Optional IMAGE input. Provide one or more images. Resized to image_max_size before being sent.", + }, + ), + "options": ( + "LMSTUDIO_OPTIONS", + { + "tooltip": "Per-request overrides (temperature, max_tokens, seed, top_p, top_k, penalties, stop). These take precedence over values from the Model node.", + }, + ), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("responses",) + CATEGORY = "LM Studio" + FUNCTION = "run_query" + OUTPUT_IS_LIST = (True,) + + def run_query( + self, + model: Dict[str, Any], + mode: str, + system_prompt: str, + user_prompt: str, + images=None, + options: Optional[Dict[str, Any]] = None, + ): + model_id = model.get("model_id", "") + temperature = float(model.get("temperature", 0.2)) + max_tokens = int(model.get("max_tokens", 512)) + seed = model.get("seed", None) + max_edge = int(model.get("image_max_size", 1024)) + unload = model.get("unload", False) + + model_handle = None + + responses_out: List[str] = ["Error: Failed to process request"] + tmp_img_paths: List[str] = [] + try: + imgs = listify(images) + model_handle = lms.llm(model_id) if model_id else lms.llm() + if imgs: + if mode == "one-by-one": + out: List[str] = [] + pbar = ProgressBar(len(imgs)) + for idx, img in enumerate(imgs): + path = image_tensor_to_temp_png_path(img, max_edge=max_edge) + tmp_img_paths.append(path) + img_handle = lms.prepare_image(path) + chat = lms.Chat(system_prompt) if system_prompt else lms.Chat() + chat.add_user_message(user_prompt or "", images=[img_handle]) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + out.append(str(pred)) + pbar.update_absolute(idx + 1) + responses_out = out + else: + img_handles = [] + for i in imgs: + path = image_tensor_to_temp_png_path(i, max_edge=max_edge) + tmp_img_paths.append(path) + img_handles.append(lms.prepare_image(path)) + chat = lms.Chat(system_prompt) if system_prompt else lms.Chat() + chat.add_user_message(user_prompt or "", images=img_handles) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + responses_out = [str(pred)] + else: + chat = lms.Chat(system_prompt) if system_prompt else lms.Chat() + chat.add_user_message(user_prompt or "") + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + responses_out = [str(pred)] + + except Exception as e: + print(f"Error in run_query: {e}") + responses_out = [f"Error: {str(e)}"] + finally: + if unload and model_handle is not None: + try: + model_handle.unload() + print(f"Unloaded model: {model_id}") + except Exception as e: + print(f"Warning: Could not unload model {model_id}: {e}") + try: + if model_handle is not None: + del model_handle + except Exception: + pass + try: + for p in tmp_img_paths: + if isinstance(p, str) and os.path.isfile(p): + os.remove(p) + except Exception: + pass + + return string_list(responses_out) + + +class WASLMStudioOptions: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "temperature": ( + "FLOAT", + {"default": 0.2, "min": 0.0, "max": 2.0, "step": 0.05, "tooltip": "Sampling temperature. Higher = more random; lower = more deterministic."}, + ), + "max_tokens": ( + "INT", + {"default": 512, "min": 1, "max": 32768, "tooltip": "Maximum number of new tokens to generate for the response."}, + ), + "seed": ( + "INT", + {"default": 0, "min": 0, "max": 2**31 - 1, "tooltip": "Optional random seed for reproducible sampling. Use 0 to disable and let the model choose."}, + ), + "top_p": ( + "FLOAT", + {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Nucleus sampling: consider tokens with cumulative probability up to top_p. Set to 1.0 to disable."}, + ), + "top_k": ( + "INT", + {"default": 0, "min": 0, "max": 2048, "tooltip": "Top-K sampling: only consider the top_k most likely tokens. Set to 0 to disable."}, + ), + "frequency_penalty": ( + "FLOAT", + {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.05, "tooltip": "Penalize tokens proportionally to how often they have appeared. Helps reduce repetition."}, + ), + "presence_penalty": ( + "FLOAT", + {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.05, "tooltip": "Penalize tokens if they have appeared at all. Encourages introducing new topics."}, + ), + "repeat_penalty": ( + "FLOAT", + {"default": 1.0, "min": 0.0, "max": 2.0, "step": 0.01, "tooltip": "Generic repetition penalty (model/engine specific). 1.0 means no penalty."}, + ), + }, + "optional": { + "stop": ( + "STRING", + {"default": "", "placeholder": ",,", "tooltip": "Comma-separated stop strings. Generation will stop when any is encountered. Leave blank for none."}, + ), + }, + } + + RETURN_TYPES = ("LMSTUDIO_OPTIONS",) + RETURN_NAMES = ("options",) + CATEGORY = "LM Studio" + FUNCTION = "make_options" + + def make_options( + self, + temperature: float, + max_tokens: int, + seed: int, + top_p: float, + top_k: int, + frequency_penalty: float, + presence_penalty: float, + repeat_penalty: float, + stop: str = "", + ): + params: Dict[str, Any] = { + "temperature": float(temperature), + "max_tokens": int(max_tokens), + "seed": int(seed) if seed != 0 else None, + "top_p": float(top_p), + "top_k": int(top_k), + "frequency_penalty": float(frequency_penalty), + "presence_penalty": float(presence_penalty), + "repeat_penalty": float(repeat_penalty), + } + if stop.strip(): + params["stop"] = [s for s in stop.split(",") if s] + return (params,) + + +class WASLMStudioChat: + @staticmethod + def convo_dir(temp: bool = False) -> str: + folder = "temp_convos" if temp else "conversations" + d = os.path.join(os.path.dirname(__file__), folder) + os.makedirs(d, exist_ok=True) + return d + + @classmethod + def list_conversation(cls, temp: bool = False) -> List[str]: + d = cls.convo_dir(temp) + out: List[str] = [] + try: + for f in os.listdir(d): + if f.lower().endswith(".json"): + out.append(os.path.splitext(f)[0]) + except Exception: + pass + return out + + @classmethod + def list_all_conversations(cls) -> List[str]: + a = set(cls.list_conversation(False)) + b = set(cls.list_conversation(True)) + names = sorted(a.union(b)) + return names + + @classmethod + def INPUT_TYPES(cls): + names = cls.list_conversation(False) + choices = ["New Conversation"] + [n for n in names if n != "New Conversation"] + return { + "required": { + "model": ( + "LMSTUDIO_MODEL", + { + "tooltip": "Model settings including model_id, temperature, max_tokens, seed, and image_max_size.", + }, + ), + "conversation_choice": ( + list(choices), + { + "default": "New Conversation", + "tooltip": "Pick an existing conversation or select 'New Conversation' to start a new one. Provide 'conversation_name' to name it, or leave blank to auto-generate.", + }, + ), + "conversation_name": ( + "STRING", + { + "default": "", + "placeholder": "optional-new-conversation-name", + "tooltip": "If provided, creates/uses this conversation. If left blank, uses the dropdown selection.", + }, + ), + "mode": ( + ["one-by-one", "batch"], + { + "default": "one-by-one", + "tooltip": "Batch sends all images in a single request; one-by-one sends one request per image.", + }, + ), + "system_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "placeholder": "Optional system instructions for this conversation (used when creating).", + "tooltip": "System instructions for the assistant's behavior. If the conversation has no prior messages, this will be set as the initial system message.", + }, + ), + "user_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "placeholder": "User message to append and send.", + "tooltip": "User message appended to the conversation and sent to the model on this run.", + }, + ), + }, + "optional": { + "images": ( + "IMAGE", + { + "tooltip": "Optional images to include with the user message. Resized to image_max_size.", + }, + ), + "options": ( + "LMSTUDIO_OPTIONS", + { + "tooltip": "Per-request overrides (temperature, max_tokens, seed, top_p, top_k, penalties, stop). These take precedence over values from the Model node.", + }, + ), + "temp_convo": ( + "BOOLEAN", + { + "default": False, + "tooltip": "Store/load conversation in a temporary workspace (cleared on startup). New conversations use temp when enabled.", + }, + ), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("responses", "queries", "conversation_name") + CATEGORY = "LM Studio" + FUNCTION = "chat" + OUTPUT_IS_LIST = (True, True, False) + + @classmethod + def history_path(cls, name: str, temp: bool = False) -> str: + return os.path.join(cls.convo_dir(temp), f"{name}.json") + + @classmethod + def load_history(cls, name: str, temp: bool = False) -> Dict[str, Any]: + path = cls.history_path(name, temp=temp) + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return {"messages": []} + return {"messages": []} + + @classmethod + def save_history(cls, name: str, data: Dict[str, Any], temp: bool = False) -> None: + path = cls.history_path(name, temp=temp) + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"Warning: could not save conversation '{name}': {e}") + + def chat( + self, + model: Dict[str, Any], + conversation_choice: str, + conversation_name: str, + mode: str, + system_prompt: str, + user_prompt: str, + images=None, + options: Optional[Dict[str, Any]] = None, + temp_convo: bool = False, + ): + model_id = model.get("model_id", "") + temperature = float(model.get("temperature", 0.2)) + max_tokens = int(model.get("max_tokens", 512)) + seed = model.get("seed", None) + max_edge = int(model.get("image_max_size", 1024)) + unload = model.get("unload", False) + + name = conversation_name.strip() or conversation_choice + if not name or name in ("", "", "New Conversation"): + name = f"chat-{__import__('time').strftime('%Y%m%d-%H%M%S')}" + + persist_exists = os.path.exists(self.__class__.history_path(name, temp=False)) + temp_exists = os.path.exists(self.__class__.history_path(name, temp=True)) + store_temp = temp_exists or (not persist_exists and temp_convo) + + history = self.__class__.load_history(name, temp=store_temp) + msgs = history.get("messages", []) + if not msgs and system_prompt: + msgs.append({"role": "system", "content": system_prompt}) + + responses_out: List[str] = ["Error: Failed to process request"] + tmp_img_paths: List[str] = [] + try: + imgs = listify(images) + model_handle = lms.llm(model_id) if model_id else lms.llm() + if imgs: + if mode == "one-by-one": + out: List[str] = [] + pbar = ProgressBar(len(imgs)) + for idx, img in enumerate(imgs): + path = image_tensor_to_temp_png_path(img, max_edge=max_edge) + tmp_img_paths.append(path) + img_handle = lms.prepare_image(path) + chat = lms.Chat.from_history({"messages": msgs}) if msgs else (lms.Chat(system_prompt) if system_prompt else lms.Chat()) + chat.add_user_message(user_prompt or "", images=[img_handle]) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + out.append(str(pred)) + msgs.append({"role": "user", "content": user_prompt or ""}) + msgs.append({"role": "assistant", "content": str(pred)}) + pbar.update_absolute(idx + 1) + responses_out = out + else: + img_handles = [] + for i in imgs: + path = image_tensor_to_temp_png_path(i, max_edge=max_edge) + tmp_img_paths.append(path) + img_handles.append(lms.prepare_image(path)) + chat = lms.Chat.from_history({"messages": msgs}) if msgs else (lms.Chat(system_prompt) if system_prompt else lms.Chat()) + chat.add_user_message(user_prompt or "", images=img_handles) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + responses_out = [str(pred)] + msgs.append({"role": "user", "content": user_prompt or ""}) + msgs.append({"role": "assistant", "content": str(pred)}) + else: + chat = lms.Chat.from_history({"messages": msgs}) if msgs else (lms.Chat(system_prompt) if system_prompt else lms.Chat()) + chat.add_user_message(user_prompt or "") + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + responses_out = [str(pred)] + msgs.append({"role": "user", "content": user_prompt or ""}) + msgs.append({"role": "assistant", "content": str(pred)}) + except Exception as e: + print(f"Error in chat: {e}") + responses_out = [f"Error: {str(e)}"] + finally: + try: + self.__class__.save_history(name, {"messages": msgs}, temp=store_temp) + except Exception as e: + print(f"Warning: Could not save conversation history: {e}") + + if unload and model_handle is not None: + try: + model_handle.unload() + print(f"Unloaded model: {model_id}") + except Exception as e: + print(f"Warning: Could not unload model {model_id}: {e}") + try: + if model_handle is not None: + del model_handle + except Exception: + pass + try: + for p in tmp_img_paths: + if isinstance(p, str) and os.path.isfile(p): + os.remove(p) + except Exception: + pass + + responses_out = [m.get("content", "") for m in msgs if m.get("role") == "assistant"] + queries_out = [m.get("content", "") for m in msgs if m.get("role") == "user"] + return (responses_out, queries_out, name) + + +class WASLMStudioCaption: + cached_tasks: Dict[str, str] = read_tasks() + + @classmethod + def INPUT_TYPES(cls): + cls.cached_tasks = read_tasks() + names = list(cls.cached_tasks.keys()) if cls.cached_tasks else ["Photorealism Caption", "Anime Caption", "Tags"] + if not cls.cached_tasks: + cls.cached_tasks = { + "Photorealism Caption": "You are a professional photo captioner. Describe the image in natural, concise prose with attention to lighting, lens characteristics, composition, and realistic details.", + "Anime Caption": "You are an anime scene captioner. Describe the image with anime art cues, character design elements, expressions, and stylistic references.", + "Tags": "Return a comma-separated list of concise, search-friendly tags describing the image content, style, and attributes. No full sentences.", + } + names = list(cls.cached_tasks.keys()) + return { + "required": { + "model": ( + "LMSTUDIO_MODEL", + { + "tooltip": "LM Studio model settings produced by the LM Studio Model node. Contains model_id, temperature, max_tokens, seed, and image_max_size.", + }, + ), + "images": ( + "IMAGE", + { + "tooltip": "IMAGE input to caption. One or more images are accepted. Each will be resized to image_max_size before encoding.", + }, + ), + "mode": ( + ["one-by-one", "batch"], + { + "default": "one-by-one", + "tooltip": "Batch sends all images in a single request; one-by-one sends one request per image using the same task and user prompt.", + }, + ), + "task_name": ( + list(names), + { + "default": names[0], + "tooltip": "Built-in task preset name loaded from /tasks/*.txt. The file content is used as the system prompt.", + }, + ), + "user_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "placeholder": "Extra directions or context.", + "tooltip": "Optional user instructions appended to the task's system prompt. Leave blank to use only the task preset.", + }, + ), + }, + "optional": { + "options": ( + "LMSTUDIO_OPTIONS", + { + "tooltip": "Per-request overrides (temperature, max_tokens, seed, top_p, top_k, penalties, stop). These take precedence over values from the Model node.", + }, + ), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("captions",) + CATEGORY = "LM Studio" + FUNCTION = "generate_captions" + OUTPUT_IS_LIST = (True,) + + def generate_captions( + self, + model: Dict[str, Any], + images, + mode: str, + task_name: str, + user_prompt: str, + options: Optional[Dict[str, Any]] = None, + ): + model_id = model.get("model_id", "") + temperature = float(model.get("temperature", 0.2)) + max_tokens = int(model.get("max_tokens", 512)) + seed = model.get("seed", None) + max_edge = int(model.get("image_max_size", 1024)) + unload = model.get("unload", False) + + system_text = self.__class__.cached_tasks.get(task_name, task_name) + imgs = listify(images) + + model_handle = None + result = string_list(["Error: Failed to process request"]) + + try: + model_handle = lms.llm(model_id) if model_id else lms.llm() + if mode == "one-by-one": + out: List[str] = [] + tmp_img_paths: List[str] = [] + pbar = ProgressBar(len(imgs)) + for idx, img in enumerate(imgs): + path = image_tensor_to_temp_png_path(img, max_edge=max_edge) + tmp_img_paths.append(path) + img_handle = lms.prepare_image(path) + chat = lms.Chat(system_text) if system_text else lms.Chat() + chat.add_user_message(user_prompt or "", images=[img_handle]) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + out.append(str(pred)) + pbar.update_absolute(idx + 1) + result = string_list(out) + else: + img_handles = [] + tmp_img_paths: List[str] = [] + for i in imgs: + path = image_tensor_to_temp_png_path(i, max_edge=max_edge) + tmp_img_paths.append(path) + img_handles.append(lms.prepare_image(path)) + chat = lms.Chat(system_text) if system_text else lms.Chat() + chat.add_user_message(user_prompt or "", images=img_handles) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + result = string_list([str(pred)]) + + except Exception as e: + print(f"Error in generate_captions: {e}") + result = string_list([f"Error: {str(e)}"]) + finally: + if unload and model_handle is not None: + try: + model_handle.unload() + print(f"Unloaded model: {model_id}") + except Exception as e: + print(f"Warning: Could not unload model {model_id}: {e}") + try: + if model_handle is not None: + del model_handle + except Exception: + pass + + return result + + +def _is_under_allowed_root(path: str, allowed_roots: List[str]) -> bool: + try: + if not allowed_roots: + return False + p = os.path.realpath(path) + for root in allowed_roots: + if not root: + continue + r = os.path.realpath(root) + try: + if os.path.commonpath([p, r]) == r: + return True + except Exception: + continue + return False + except Exception: + return False + + +class WASLoadImageDirectory: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "directory_path": ( + "STRING", + { + "default": "", + "placeholder": "k:/datasets/images", + "tooltip": "Absolute path to a directory containing images. Must be under an allowed root.", + }, + ), + "recursive": ( + "BOOLEAN", + {"default": True, "tooltip": "Recurse into subdirectories."}, + ), + "extensions": ( + "STRING", + { + "default": ".png,.jpg,.jpeg,.webp,.bmp", + "tooltip": "Comma-separated list of image file extensions to include.", + }, + ), + "dataset_output_path": ( + "STRING", + { + "default": "", + "placeholder": "k:/datasets/captions_out", + "tooltip": "Optional output directory for captions (and images if copy is enabled). Must be under an allowed root if provided.", + }, + ), + "copy_images": ( + "BOOLEAN", + { + "default": False, + "tooltip": "If enabled and an output path is provided that differs from the image directory, copy each image alongside its caption file.", + }, + ), + "force_aspect": ( + ["none", "1:1", "4:3", "3:2", "16:9", "21:9", "3:4", "2:3", "9:16"], + { + "default": "none", + "tooltip": "Force images to a specific aspect ratio. 'none' keeps original aspect.", + }, + ), + "max_size": ( + ["512", "768", "1024", "1280", "2048"], + { + "default": "1024", + "tooltip": "Maximum resolution (longest edge) for resized images.", + }, + ), + "resize_mode": ( + ["none", "crop_center", "stretch", "pad", "fit"], + { + "default": "none", + "tooltip": "Resize/crop method: 'none' (no resize), 'crop_center' (crop to aspect), 'stretch' (distort to fit), 'pad' (letterbox), 'fit' (scale to fit).", + }, + ), + }, + "optional": { + }, + } + + RETURN_TYPES = ("LMSTUDIO_DATASET_IMAGES",) + RETURN_NAMES = ("dataset",) + CATEGORY = "LM Studio" + FUNCTION = "load_dir" + + def load_dir(self, directory_path: str, recursive: bool, extensions: str, dataset_output_path: str, copy_images: bool, force_aspect: str, max_size: str, resize_mode: str): + cfg = read_config() + allowed_roots = cfg.get("allowed_root_directories", []) + if not _is_under_allowed_root(directory_path, allowed_roots): + raise ValueError("Directory is not under any allowed_root_directories. Update lmstudio_config.json.") + + out_dir = dataset_output_path.strip() + if out_dir: + if not _is_under_allowed_root(out_dir, allowed_roots): + raise ValueError("dataset_output_path is not under any allowed_root_directories. Update lmstudio_config.json.") + os.makedirs(out_dir, exist_ok=True) + + exts = {e.strip().lower() if e.strip().startswith(".") else ("." + e.strip().lower()) for e in extensions.split(",") if e.strip()} + if not exts: + exts = {".png", ".jpg", ".jpeg", ".webp", ".bmp"} + + dataset: Dict[str, Dict[str, Any]] = { + "__output_path": out_dir, + "__copy_images": bool(copy_images), + "__force_aspect": force_aspect, + "__max_size": int(max_size) if max_size.isdigit() else 1024, + "__resize_mode": resize_mode, + } + if recursive: + walker = os.walk(directory_path) + for root, _, files in walker: + for f in files: + if os.path.splitext(f)[1].lower() in exts: + p = os.path.join(root, f) + dataset[p] = {"path": p, "filename": f} + else: + for f in os.listdir(directory_path): + p = os.path.join(directory_path, f) + if os.path.isfile(p) and os.path.splitext(f)[1].lower() in exts: + dataset[p] = {"path": p, "filename": f} + + return (dataset,) + + +class WASLMStudioCaptionDataset: + @classmethod + def INPUT_TYPES(cls): + WASLMStudioCaption.cached_tasks = read_tasks() + names = list(WASLMStudioCaption.cached_tasks.keys()) if WASLMStudioCaption.cached_tasks else ["Photorealism Caption", "Anime Caption", "Tags"] + return { + "required": { + "model": ( + "LMSTUDIO_MODEL", + {"tooltip": "Model settings from LM Studio Model node."}, + ), + "dataset": ( + "LMSTUDIO_DATASET_IMAGES", + {"tooltip": "Dictionary produced by WASLoadImageDirectory mapping file paths to metadata."}, + ), + "task_name": ( + list(names), + {"default": names[0] if names else "Photorealism Caption", "tooltip": "Caption preset (system prompt)."}, + ), + "user_prompt": ( + "STRING", + {"default": "", "multiline": True, "tooltip": "Optional extra instructions."}, + ), + }, + "optional": { + "options": ( + "LMSTUDIO_OPTIONS", + {"tooltip": "Override generation options."}, + ), + }, + } + + RETURN_TYPES = ("STRING", "STRING", "STRING") + RETURN_NAMES = ("captions", "written_caption_paths", "result") + CATEGORY = "LM Studio" + FUNCTION = "caption_dataset" + OUTPUT_IS_LIST = (True, True, False) + + def caption_dataset(self, model: Dict[str, Any], dataset: Dict[str, Any], task_name: str, user_prompt: str, options: Optional[Dict[str, Any]] = None): + model_id = model.get("model_id", "") + temperature = float(model.get("temperature", 0.2)) + max_tokens = int(model.get("max_tokens", 512)) + seed = model.get("seed", None) + unload = model.get("unload", False) + + system_text = WASLMStudioCaption.cached_tasks.get(task_name, task_name) + meta_output = "" + meta_copy = False + meta_force_aspect = "none" + meta_max_size = 1024 + meta_resize_mode = "none" + try: + meta_output = str(dataset.get("__output_path", "") or "").strip() + meta_copy = bool(dataset.get("__copy_images", False)) + meta_force_aspect = str(dataset.get("__force_aspect", "none")) + meta_max_size = int(dataset.get("__max_size", 1024)) + meta_resize_mode = str(dataset.get("__resize_mode", "none")) + except Exception: + meta_output = "" + meta_copy = False + meta_force_aspect = "none" + meta_max_size = 1024 + meta_resize_mode = "none" + + written: List[str] = [] + captions: List[str] = [] + failed_count = 0 + skipped_count = 0 + model_handle = None + try: + model_handle = lms.llm(model_id) if model_id else lms.llm() + image_paths = [k for k in dataset.keys() if not str(k).startswith("__")] + pbar = ProgressBar(len(image_paths)) + for idx, p in enumerate(image_paths): + try: + if not os.path.isfile(p): + skipped_count += 1 + pbar.update_absolute(idx + 1) + continue + img_handle = lms.prepare_image(p) + chat = lms.Chat(system_text) if system_text else lms.Chat() + chat.add_user_message(user_prompt or "", images=[img_handle]) + params: Dict[str, Any] = { + "temperature": temperature, + "max_tokens": max_tokens, + } + if seed is not None: + params["seed"] = seed + if options: + try: + for k, v in options.items(): + params[k] = v + except Exception: + pass + pred = model_handle.respond(chat, config=_map_params(params)) + text = str(pred) + captions.append(text.strip()) + # Determine output directory + src_dir = os.path.dirname(p) + out_dir = meta_output if meta_output else src_dir + os.makedirs(out_dir, exist_ok=True) + base = os.path.splitext(os.path.basename(p))[0] + txt_path = os.path.join(out_dir, base + ".txt") + # Copy image if requested and output differs from source + if meta_copy and meta_output and os.path.realpath(out_dir) != os.path.realpath(src_dir): + dst_img = os.path.join(out_dir, os.path.basename(p)) + if not os.path.exists(dst_img): + shutil.copy2(p, dst_img) + with open(txt_path, "w", encoding="utf-8") as f: + f.write(text.strip()) + written.append(txt_path) + pbar.update_absolute(idx + 1) + except Exception as ie: + print(f"Caption failed for {p}: {ie}") + failed_count += 1 + pbar.update_absolute(idx + 1) + continue + except Exception as e: + print(f"Error in caption_dataset: {e}") + finally: + if unload and model_handle is not None: + try: + model_handle.unload() + except Exception: + pass + + # Build result summary + total_images = len(image_paths) + success_count = len(written) + result_lines = [ + f"Dataset Captioning Complete", + f"="*50, + f"Total Images: {total_images}", + f"Successfully Captioned: {success_count}", + f"Failed: {failed_count}", + f"Skipped: {skipped_count}", + f"", + f"Settings:", + f" Task: {task_name}", + f" Model: {model_id}", + f" Temperature: {temperature}", + f" Max Tokens: {max_tokens}", + f" Force Aspect: {meta_force_aspect}", + f" Max Size: {meta_max_size}", + f" Resize Mode: {meta_resize_mode}", + f" Copy Images: {meta_copy}", + ] + if meta_output: + result_lines.append(f" Output Path: {meta_output}") + result_summary = "\n".join(result_lines) + + return (captions, written, result_summary) + +# Node mappings for ComfyUI +NODE_CLASS_MAPPINGS = { + "WASLMStudioModel": WASLMStudioModel, + "WASLMStudioQuery": WASLMStudioQuery, + "WASLMStudioCaption": WASLMStudioCaption, + "WASLMStudioChat": WASLMStudioChat, + "WASLMStudioOptions": WASLMStudioOptions, + "WASLoadImageDirectory": WASLoadImageDirectory, + "WASLMStudioCaptionDataset": WASLMStudioCaptionDataset, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "WASLMStudioModel": "LM Studio Model", + "WASLMStudioQuery": "LM Studio Query", + "WASLMStudioCaption": "LM Studio Easy-Caption", + "WASLMStudioChat": "LM Studio Chat", + "WASLMStudioOptions": "LM Studio Options", + "WASLoadImageDirectory": "WAS Load Image Directory", + "WASLMStudioCaptionDataset": "LM Studio Easy-Caption Dataset", +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28d01d1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "was-lmstudio-easyquery" +version = "0.1.0" +description = "LM Studio calls with image support and easy task directions." +requires-python = ">=3.10" +license = { file = "LICENSE" } +dynamic = ["dependencies"] + +[tool.setuptools.dynamic] +dependencies = { file = ["requirements.txt"] } + +[project.urls] +Repository = "https://github.com/WASasquatch/WAS_LMStudio_EasyQuery" +"Bug Tracker" = "https://github.com/WASasquatch/WAS_LMStudio_EasyQuery/issues" + +classifiers = [ + "Operating System :: OS Independent" +] + +[tool.comfy] +PublisherId = "was" +DisplayName = "WAS LMStudio EasyQuery" +# Icon = "https://raw.githubusercontent.com/WASasquatch/WAS_LMStudio_EasyQuery/main/icon.png" +# Banner = "https://raw.githubusercontent.com/WASasquatch/WAS_LMStudio_EasyQuery/main/banner.png" +requires-comfyui = ">=1.0.0" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a701d80 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +lmstudio>=1.5.0 +pillow>=10.0.0 +numpy>=1.23.0 \ No newline at end of file diff --git a/tasks/Anime Caption.txt b/tasks/Anime Caption.txt new file mode 100644 index 0000000..d1d247b --- /dev/null +++ b/tasks/Anime Caption.txt @@ -0,0 +1,81 @@ +# 🎨 ANIME SCENE PROMPT SYSTEM FOR LOCAL LLMs + +This system prompt instructs a local LLM to generate **anime-style descriptive prompts** written in natural prose. +It focuses on **character design**, **expressions**, **artistic cues**, and **stylistic visual details** consistent with anime and manga aesthetics. + +--- + +## 🎯 OBJECTIVE + +Transform any **user input** (text, image, or both) into a **single, natural-language paragraph** describing an anime scene with vivid visual cues and stylistic precision. +The result should read like a **scene caption** or **anime keyframe description**, not a list of tags. + +--- + +## 🧩 OUTPUT FORMAT + +Output must be raw prompt text, and nothing else, no commentary, notes, or additional text may appear. + +--- + +## 🧱 CONSTRUCTION RULES + +### ✅ General +- Use **rich, visual anime language** — focus on color, composition, and character expression. +- Maintain **third person**, **present tense**, and **cinematic tone**. +- Write **50–90 words**, forming one cohesive paragraph. +- Avoid non-visual senses (no sound, smell, touch, taste). +- Do **not** include camera or model parameters. +- Describe **emotion, motion, and atmosphere** through visible cues. + +--- + +## 🎨 ANIME-SPECIFIC ELEMENTS + +| Element | Description | +|----------|-------------| +| **Character Design** | Describe hairstyles, eye colors, outfits, and accessories typical of anime. | +| **Expression** | Show emotion visually — e.g., blushing, teary eyes, determined gaze, cheerful smile. | +| **Style References** | Mention aesthetic influences — e.g., studio-inspired (Ghibli, Ufotable, Kyoto Animation). | +| **Lighting & Color** | Use vivid, stylized lighting (sunset gradients, glowing highlights, neon reflections). | +| **Composition** | Include framing terms: dynamic angle, close-up, side profile, backlit silhouette. | +| **Atmosphere** | Convey emotional tone through background cues — cherry blossoms, city glow, moonlight, etc. | + +--- + +## 🧠 CREATION WORKFLOW + +1. Identify **character(s)**, **setting**, and **emotion**. +2. Describe visible **art style**, **color palette**, and **lighting**. +3. Integrate **anime composition** (e.g., soft outlines, cinematic framing, glowing highlights). +4. Focus on **expressive storytelling through visuals**. +5. Output **only** the single descriptive paragraph. + +--- + +## 🧠 EXAMPLE OUTPUTS + +**Example 1 (School Scene):** +\`\`\` +A high school girl with long chestnut hair stands beneath falling cherry blossoms, her uniform swaying gently in the breeze. Warm pink tones fill the background as petals dance across the sunlight. Her wide amber eyes shimmer with quiet emotion, a faint blush softening her expression. The scene glows with painterly light, reminiscent of Kyoto Animation’s delicate spring color palette. +\`\`\` + +**Example 2 (Urban Night Scene):** +\`\`\` +A young man in a dark hoodie walks through a rain-slicked neon street, reflections of kanji signs rippling beneath his feet. The city glows in saturated blues and purples, capturing the stylized energy of a late-night anime chase sequence. His sharp eyes gleam under the hood’s shadow as the camera follows in a dynamic tracking shot, evoking the aesthetic of a Ufotable cyber-noir scene. +\`\`\` + +**Example 3 (Fantasy Setting):** +\`\`\` +A silver-haired mage raises her staff amid glowing runes floating in the dusk air. Her eyes emit a soft teal light as strands of hair swirl around her in the magic wind. The background fades into painterly clouds and glowing particles, colored in warm violet and gold tones, evoking a fantasy adventure anime with dramatic composition and radiant energy. +\`\`\` + +--- + +## ⚙️ EXECUTION RULES +- Output **only** the raw anime-style paragraph. +- Do **not** include labels, commentary, or extra markdown. +- Ensure the language feels **vivid, emotional, and cinematic**. +- Prioritize **anime aesthetics**, **character detail**, and **expressive atmosphere** in every description. + +--- diff --git a/tasks/Basic Tags.txt b/tasks/Basic Tags.txt new file mode 100644 index 0000000..567e531 --- /dev/null +++ b/tasks/Basic Tags.txt @@ -0,0 +1,94 @@ +# 🏷️ BASIC KEYWORD TAG SYSTEM PROMPT FOR LOCAL LLMs + +This system prompt instructs a local LLM to generate **concise, keyword-style tag prompts** for image-to-video or image description tasks. +It is optimized for WAN-compatible or general AI model tagging workflows that favor **structured visual tokens** instead of full sentences. + +--- + +## 🎯 OBJECTIVE + +Transform any **user input** (text, image, or both) into a **comma-separated list of concise visual tags** that accurately describe the **content, style, composition, and attributes** visible in the image or concept. + +--- + +## 🧩 OUTPUT FORMAT + +Output must appear as a **single line** containing **only raw tags**, separated by commas. +Do **not** include any commentary, headers, or explanations. + +\`\`\` +tag1, tag2, tag3, tag4, tag5 +\`\`\` + +--- + +## 🧱 CONSTRUCTION RULES + +### ✅ Tagging Style +- Use **concise keywords** — each tag should represent a distinct visible attribute. +- Tags must be **specific**, **visual**, and **descriptive** (avoid generic or abstract terms). +- Use **lowercase** unless referencing proper names (e.g., “Mona Lisa”, “Eiffel Tower”). +- Avoid filler words like *a, the, of, with, in, is, has*. +- Separate each tag with a **comma and single space**. +- Do **not** output natural sentences, lists, or explanations. + +--- + +## 🧭 TAG CATEGORIES + +Each keyword list should mix relevant tags from the following categories: + +| Category | Examples | +|-----------|-----------| +| **Subject** | woman, child, tiger, robot, mountain, spaceship | +| **Scene/Environment** | forest, desert, city, underwater, interior, temple | +| **Action/Motion** | walking, running, floating, turning, sitting | +| **Lighting/Time** | daylight, dusk, night, backlight, rim light, moonlight | +| **Camera/Framing** | close-up, wide shot, low angle, aerial view, portrait | +| **Style/Medium** | cinematic, watercolor, cyberpunk, anime, oil painting | +| **Mood/Atmosphere** | serene, dramatic, mysterious, romantic, dark | +| **Color/Tone** | warm, cool, saturated, desaturated, monochrome | +| **Detail Attributes** | detailed, high contrast, soft focus, realistic, minimalistic | + +--- + +## 🚫 AVOID +- Full sentences or phrases (“a girl standing in a field”). +- Articles, prepositions, and conjunctions. +- Non-visual or emotional terms not reflected on-screen (“hopeful”, “lonely” unless clearly implied). +- Repetitions or redundant descriptors. +- Non-visual metadata (camera model, software, etc.). + +--- + +## 🧠 CREATION WORKFLOW + +1. Analyze user input or image. +2. Identify **primary subject**, **setting**, **style**, **lighting**, and **composition**. +3. Convert all visual elements into **short, noun/adjective-based tags**. +4. Ensure coverage across **content**, **style**, and **technical attributes**. +5. Output **only** the comma-separated tag list. + +--- + +## 🧠 EXAMPLE OUTPUTS + +**Example 1 (Text Input: “A woman in a red dress standing under neon lights in a rainy alley”):** +\`\`\` +woman, red dress, neon lights, rainy alley, night, cinematic, cyberpunk, reflective pavement, moody atmosphere, close-up +\`\`\` + +**Example 2 (Image Input: portrait of a samurai in misty mountains):** +\`\`\` +samurai, misty mountains, traditional armor, overcast, cinematic realism, soft light, epic, historical, detailed, landscape +\`\`\` + +--- + +## ⚙️ EXECUTION RULES +- Output **only** the raw comma-separated tags. +- Never include headers, punctuation beyond commas, or natural phrases. +- Always ensure tags reflect **visual reality** rather than abstract ideas. +- Maintain **conciseness**, **accuracy**, and **readability**. + +--- diff --git a/tasks/Booru-Style.txt b/tasks/Booru-Style.txt new file mode 100644 index 0000000..f815667 --- /dev/null +++ b/tasks/Booru-Style.txt @@ -0,0 +1,80 @@ +# System Prompt — Danbooru Tag Prompt Author + +You are an expert **Danbooru-style tagger** for image generation models trained on booru corpora (e.g., anime/illustration checkpoints). +Your job is to output **only one line** of **Danbooru-style tags**, **comma-separated**. +**Do not** output explanations, headings, labels, code fences, or commentary. +**Do not** wrap the output. **No quotes. No Markdown.** +Default to **SFW** content and **clear clothing** unless the user explicitly requests otherwise. + +--- + +## Core Rules + +- **Output format:** `tag, tag, tag` (all lowercase; use underscores in multi-word tags, e.g., `silver_hair`). +- **Front-load priority (left→right importance):** + **subject_count → primary_subject → key_attributes → pose/action → clothing/accessories → composition/shot → environment/background → lighting/color → style/medium → quality markers → optional meta** +- **Be atomic & objective:** prefer concrete tags over prose. Avoid story words and figurative language. +- **Disambiguate:** pick **one** main style and camera intent; avoid contradictory tags. +- **Counts first:** `1girl/1boy/2girls/solo/group` etc. +- **Specific beats generic:** `platinum_blonde_hair` over `blonde_hair` if important. +- **Safety:** if people are present and age is ambiguous, **omit age tags** and specify clothing. Do **not** imply minors. + +--- + +## 🧩 OUTPUT FORMAT + +Output must be raw prompt text, and nothing else, no commentary, notes, or additional text may appear. + +--- + +## Tag Families (pick relevant, keep concise) + +- **Subject & Count:** `solo, 1girl/1boy, 2girls, group, animal, mecha, creature` +- **Identity & Body:** `long_hair, short_hair, bangs, twin_tails, ahoge, blue_eyes, freckles, scar, muscular` +- **Expression:** `smile, neutral, serious, blush, crying, grin, wink` +- **Pose/Action:** `standing, sitting, running, walking, jumping, looking_at_viewer, looking_away, hand_on_hip, arms_crossed` +- **Clothing:** `school_uniform, sailor_collar, hoodie, jacket, dress, skirt, turtleneck, suit, armor, gloves, boots, sneakers, hat` + - **Detail:** `open_jacket, rolled_up_sleeves, pleated_skirt, necktie, ribbon, belt, scarf` +- **Accessories/Props:** `glasses, headphones, smartphone, book, sword, staff, umbrella, backpack` +- **Composition/Shot:** `full_body, upper_body, bust, close-up, portrait, profile, three-quarter_view, low_angle, high_angle, dutch_angle, pov` +- **Environment/Background:** `city, street, rooftop, classroom, shrine, temple, forest, river, beach, night_sky, starry_sky, cherry_blossoms, neon_lights, rainy, snowfall` +- **Lighting/Color:** `golden_hour, backlighting, rim_light, soft_light, hard_shadows, volumetric_light, bokeh, warm_palette, cool_palette, high_contrast` +- **Style/Medium:** `anime_style, cel_shading, digital_painting, watercolor, ink, sketch, monochrome, limited_palette` +- **Quality/Render (optional):** `highres, detailed_shading, sharp_focus, depth_of_field, motion_blur` +- **Text Integration (if needed):** `sign, billboard, neon_sign, english_text, japanese_text` (+ place with positioning tags if supported) + +--- + +## Negative Tags (include only if asked) +Use sparingly and keep relevant: +`lowres, blurry, jpeg_artifacts, extra_fingers, extra_limbs, bad_anatomy, watermark, signature, text` + +*(If the interface supports “negative prompt” separately, put these there; otherwise append at the end with a clear separator only when explicitly requested.)* + +--- + +## Output Discipline + +- **One line only**, comma-separated tags in priority order. +- No counts like “five tags”; just the tags. +- Do not include the words “prompt”, “danbooru”, or any meta commentary. +- If the user supplies constraints (e.g., “portrait at dusk with umbrella”), map them to tags and keep within the structure. + +--- + +## Structural Templates (use as scaffolds; do not emit angle brackets) + +**Portrait (character-focused):** +`, , , , , , , , ,