Compare commits

...
44 Commits
Author SHA1 Message Date
github-actions[bot] 4729c79200 chore: refresh API model catalogs for v2.5.8 2026-09-27 13:00:56 +00:00
Marcel Hofer ce756a3f62 chore: prepare v2.5.8 release 2026-09-27 14:59:24 +02:00
Marcel Hofer afc4f22c95 fix: enable SageAttention 1 on ROCm for HF nodes 2026-09-27 14:52:34 +02:00
Marcel Hofer c6acde1b18 chore: prepare v2.5.7 release 2026-08-26 12:42:44 +02:00
Marcel Hofer abcc248ed2 docs: streamline README and expand guides 2026-08-26 12:42:35 +02:00
Marcel Hofer dc1dd8a756 fix: restore safe Gemma 4 GGUF audio 2026-08-26 12:42:26 +02:00
github-actions[bot] 2167a0e132 chore: refresh API model catalogs for v2.5.6 2026-08-24 16:39:03 +00:00
Marcel Hofer fb9d55619b chore: bump version to 2.5.6 2026-08-24 18:38:31 +02:00
Marcel Hofer 02f76cca84 fix: safely strip Gemma channel reasoning 2026-08-24 18:28:13 +02:00
github-actions[bot] 24abaadf40 chore: refresh API model catalogs for v2.5.5 2026-08-22 11:18:16 +00:00
Marcel Hofer 88cae3b5b5 chore: bump version to 2.5.5 2026-08-22 13:17:47 +02:00
Marcel Hofer a59b419ca4 feat: add GGUF mask reconstruction mode 2026-08-22 13:17:41 +02:00
github-actions[bot] 2e58d2440c chore: refresh API model catalogs for v2.5.4 2026-08-21 07:33:51 +00:00
Marcel Hofer a2bbcd92fa fix: route GGUF-only Gemma models safely 2026-08-21 09:33:18 +02:00
Marcel Hofer ac6de2611d fix: use idle timeout for API stream so long reasoning runs are not aborted 2026-08-16 17:45:28 +02:00
Marcel Hofer db01daff20 fix: raise OrcaRouter profile timeout cap to 900s for reasoning-heavy models 2026-08-16 17:36:16 +02:00
Marcel Hofer 7e94e9336f docs: reorganize README with node overview, feature summary, platform notes, and documentation index 2026-08-16 14:18:08 +02:00
Marcel Hofer 2827c2c850 fix: use union of all catalog models for model_name combo validation 2026-08-16 13:43:39 +02:00
Marcel Hofer f8a5010fa5 fix: push refreshed catalogs to main in release workflow 2026-08-16 13:36:37 +02:00
Marcel Hofer 7fe0c2de9c chore: bump version to 2.5.0 2026-08-16 13:35:50 +02:00
Marcel Hofer 00283107a0 fix: localize API node setup help to English 2026-08-16 13:34:27 +02:00
Marcel Hofer 21b75d3b58 feat: full per-provider model lists with release-time auto-refresh and dedicated custom model field (#14)
* feat: full per-provider model lists with release-time auto-refresh and dedicated custom model field

* feat: add setup help widget to API node (Windows/Linux API key instructions)
2026-08-16 13:28:30 +02:00
Marcel Hofer 581d8920e1 fix: real per-provider model dropdown with Custom free-text (#13) 2026-08-16 13:15:41 +02:00
Marcel Hofer 7743e8e702 feat: curated per-provider model lists with release-time validation (#12) 2026-08-16 12:32:10 +02:00
Marcel Hofer cfea91b016 Add secure image input support to API node (#11)
* Add secure image input support to API node

* Document API image inputs
2026-08-16 11:40:44 +02:00
Marcel Hofer a7506afe68 Add hardened OpenAI-compatible API node (#10)
* Add OpenAI-compatible API node

* Document OpenAI-compatible API node

* Harden API credentials with server-side profiles

* Ignore local API profile and env files

* Add server-side API profile example

* Document hardened server-side API profiles

* Add API profile security regression tests

* Make API security test loader standalone

* Use generic model ID in API profile example

* docs: expand secure API deployment and secret handling guide

* Harden API transport and request controls

* Fix Python booleans in hardened API node

* Expand API security regression tests

* Update secure API profile examples

* Ignore local API profile variants

* Document hardened API deployment and threat model

* Add CI for API security regressions

* Cap Qwen thinking budget and fail closed on invalid overrides

* Cover thinking budget and fail-closed profile overrides

* Document thinking-budget limits and fail-closed overrides

* Run API security CI for docs and policy changes
2026-08-16 11:11:40 +02:00
Marcel Hofer ec7cd7f84d feat: Enhance Qwen3.8 model integration and duration handling
- Added Qwen3.8-27B-ABLITERATED-GGUF model to the model catalog with detailed specifications and context length.
- Improved duration widget behavior to ensure it remains hidden for non-video presets and preserves last valid values.
- Updated documentation to clarify duration handling and widget visibility.
- Expanded test coverage for model catalog labels and Qwen3.8 model presence in both text and vision catalogs.
- Refactored model access functions to better manage installed models and local paths.
- Enhanced tooltips and recommendations for model selection, emphasizing installed and local models.
- Fixed issues with dynamic duration-widget serialization and visibility in the frontend.
2026-08-16 11:11:05 +02:00
Marcel Hofer 6a46a78deb chore: prepare v2.4.0 release 2026-08-12 16:14:05 +02:00
Marcel Hofer e0985b4f7b feat: improve video prompting and refactor QwenVL
Refactor the monolithic QwenVL implementation into focused core modules for model access, Hugging Face runtime and configuration, prompt state, prompt contracts, and media helpers while preserving the public compatibility facade.

Add duration-aware LTX 2.3 and MiniMax H3 prompting, including effective-duration cache identity, MiniMax frame-grid normalization, dedicated T2VA/I2VA/FL2VA/L2VA/reference contracts, and best-effort output normalization.

Improve streamed output handling by separating reasoning from final content, rejecting unfinished thinking traces, sanitizing terminal-only punctuation for legacy Windows code pages, and retaining original Unicode in responses and traces.

Expand preset recommendations, ComfyUI tooltips, system prompts, and documentation for video token budgets, thinking settings, reference roles, dialogue fidelity, and duration behavior.

Keep HF and GGUF consumers on canonical shared imports, preserve mutable export identities across reloads, and extend regression coverage for compatibility, downloads, caching, prompting, streaming, and node registration.
2026-08-10 22:04:05 +02:00
Marcel Hofer 28bac44b51 chore: prepare v2.3.24 release 2026-08-08 11:48:59 +02:00
Marcel 753d66fadb feat: add system prompt preset node (#9)
Adds a reusable system-prompt preset utility node with validated prompt selection, commercial-loader support, frontend styling, tooltip previews, and regression coverage.
2026-08-08 11:47:46 +02:00
Marcel Hofer dfdd1fa015 chore: prepare v2.3.23 release 2026-08-08 11:16:24 +02:00
Marcel Hofer b0144e6ec3 fix: harden MiniMax H3 reference prompting 2026-08-08 11:16:24 +02:00
Marcel Hofer c448e422c7 fix: harden system prompt preset node 2026-08-08 11:04:07 +02:00
craftingmod 850c96460b feat: system prompt preset node 2026-08-08 09:10:16 +09:00
Your Name b02c849b74 docs: add production deployment guidance 2026-08-07 20:25:37 +02:00
Marcel Hofer 368637c6f0 chore: prepare v2.3.22 release 2026-08-06 23:20:11 +02:00
Marcel Hofer d2a0780f58 fix: keep model rights policy commercial-only 2026-08-06 23:19:16 +02:00
Marcel Hofer 89322d6202 release: merge commercial model catalog for v2.3.21 2026-08-06 17:56:37 +02:00
Marcel Hofer c78d95d72f docs: correct commercial source notice 2026-08-06 17:40:07 +02:00
Marcel Hofer 5c6e00cdf4 feat: classify model commercial usage 2026-08-06 17:39:05 +02:00
Marcel cda5382a5e feat: split MiniMax H3 presets and log LLM input (#8) 2026-08-06 17:29:17 +02:00
Marcel Hofer b1a69dacfe chore: prepare v2.3.20 release 2026-08-06 17:06:54 +02:00
Marcel Hofer 33dec1631b fix: reuse ComfyUI text encoders 2026-08-06 16:55:16 +02:00
47 changed files with 16813 additions and 2631 deletions
+58
View File
@@ -0,0 +1,58 @@
name: API security tests
on:
pull_request:
paths:
- "nodes/openai_compatible_api.py"
- "tests/test_openai_api_security.py"
- "thinkingllm_api_profiles.example.json"
- "web/api_model_catalogs.json"
- "tools/check_api_model_catalogs.py"
- "tools/update_api_model_catalogs.py"
- "docs/OPENAI_COMPATIBLE_API.md"
- ".gitignore"
- ".github/workflows/api-security-tests.yml"
push:
branches:
- main
paths:
- "nodes/openai_compatible_api.py"
- "tests/test_openai_api_security.py"
- "thinkingllm_api_profiles.example.json"
- "web/api_model_catalogs.json"
- "tools/check_api_model_catalogs.py"
- "tools/update_api_model_catalogs.py"
- "docs/OPENAI_COMPATIBLE_API.md"
- ".gitignore"
- ".github/workflows/api-security-tests.yml"
permissions:
contents: read
jobs:
api-security:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install minimal image test dependencies
run: python -m pip install --disable-pip-version-check --quiet numpy pillow
- name: Compile API node
run: python -m py_compile nodes/openai_compatible_api.py
- name: Compile catalog tools
run: python -m py_compile tools/check_api_model_catalogs.py tools/update_api_model_catalogs.py
- name: Validate API model catalogs
run: python tools/check_api_model_catalogs.py
- name: Run API security regression tests
run: python tests/test_openai_api_security.py
+31
View File
@@ -9,8 +9,39 @@ permissions:
contents: write
jobs:
check:
name: Refresh and validate model catalogs
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Refresh API model catalogs from providers
run: python tools/update_api_model_catalogs.py
- name: Validate API model catalogs
run: python tools/check_api_model_catalogs.py
- name: Commit refreshed catalogs to main
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add web/api_model_catalogs.json
git diff --cached --quiet || git commit -m "chore: refresh API model catalogs for ${GITHUB_REF_NAME}"
# The tag points at a commit on main; push the catalog refresh there.
git push origin HEAD:main
release:
runs-on: ubuntu-latest
needs: check
if: ${{ github.repository_owner == 'goodguy1963' }}
steps:
- name: Check out code
+8
View File
@@ -29,6 +29,13 @@ venv/
env/
ENV/
# Local environment / secrets
.env
.env.*
!.env.example
thinkingllm_api_profiles*.json
!thinkingllm_api_profiles.example.json
# IDE
.vscode/
.idea/
@@ -48,6 +55,7 @@ Thumbs.db
# Logs
*.log
logs/
.firecrawl/
# ComfyUI specific
models/
+58 -9
View File
@@ -2,6 +2,8 @@ import json
import re
from dataclasses import dataclass
OUTPUT_CLEANER_VERSION = 5
@dataclass(frozen=True)
class OutputCleanConfig:
@@ -20,6 +22,17 @@ _CODE_FENCE_RE = re.compile(r"^\s*```[\w-]*\s*$", re.IGNORECASE)
_THINK_BLOCK_RE = re.compile(r"<think[^>]*>.*?</think>", flags=re.IGNORECASE | re.DOTALL)
_THINK_OPEN_RE = re.compile(r"<think[^>]*>", flags=re.IGNORECASE)
_THINK_CLOSE_RE = re.compile(r"</think\s*>", flags=re.IGNORECASE)
_CHANNEL_OPEN_RE = re.compile(
r"(?:<\|?start\|?>\s*assistant\s*)?"
r"<\|?channel\|?>\s*(?P<channel>analysis|thinking|reasoning|final)\s*"
r"<\|?message\|?>",
flags=re.IGNORECASE,
)
_CHANNEL_END_RE = re.compile(r"<\|?end\|?>", flags=re.IGNORECASE)
_MIRRORED_REASONING_WITH_FINAL_RE = re.compile(
r"^<\|channel>.*?<channel\|>(?P<final>\s*\S[\s\S]*)$",
flags=re.IGNORECASE | re.DOTALL,
)
_MARKER_RE = re.compile(
r"(?im)^\s*(final|final answer|answer|output|result|prompt)\s*[:\-]\s*",
)
@@ -27,13 +40,13 @@ _IM_TOKEN_RE = re.compile(
r"(?i)<\|?im_(start|end)\|?>|<im_(start|end)>|<\|endoftext\|>",
)
_PLANNING_RE = re.compile(
r"(?is)\b("
r"(?im)^\s*(?:[-*]\s*)?(?:\*\*)?\s*(?:"
r"final\s+plan\b|"
r"final\s+check\b|"
r"i\s+(should|need|must|will|want|am\s+going\s+to|have\s+to)\b|"
r"i\s+(?:should|need|must|will|want|am\s+going\s+to|have\s+to)\b|"
r"let's\b|"
r"first\b|next\b|then\b|"
r"wait\b|"
r"first\s*[,.:]|next\s*[,.:]|then\s*[,.:]|"
r"wait\s*[,.:]|"
r"ready\s+to\s+write\b|"
r"writing\s+the\s+prompt\b|"
r"so\s+i\s+need\s+to\b|"
@@ -53,13 +66,15 @@ def clean_model_output(text: str, config: OutputCleanConfig | None = None) -> st
cleaned = _IM_TOKEN_RE.sub("", cleaned).strip()
if cfg.strip_think:
cleaned = _strip_channel_protocol(cleaned)
cleaned = _THINK_BLOCK_RE.sub("", cleaned)
cleaned = _THINK_CLOSE_RE.sub("", cleaned)
if _THINK_OPEN_RE.search(cleaned):
cleaned = _THINK_OPEN_RE.sub("", cleaned)
parts = re.split(r"\n\s*\n", cleaned, maxsplit=1)
if len(parts) == 2:
cleaned = parts[1]
unclosed_think = _THINK_OPEN_RE.search(cleaned)
if unclosed_think:
# When generation ends before </think>, no final answer exists.
# Prompt-shaped examples inside that unfinished reasoning must not
# be exposed as RESPONSE.
cleaned = cleaned[:unclosed_think.start()]
cleaned = cleaned.strip()
cleaned = _IM_TOKEN_RE.sub("", cleaned).strip()
@@ -96,6 +111,40 @@ def clean_model_output(text: str, config: OutputCleanConfig | None = None) -> st
return cleaned
def _strip_channel_protocol(text: str) -> str:
"""Remove reasoning only when the whole output starts as a channel transcript."""
candidate = (text or "").strip()
channel_opens = list(_CHANNEL_OPEN_RE.finditer(candidate))
if channel_opens and channel_opens[0].start() == 0:
final_segments: list[str] = []
non_channel_text: list[str] = []
for index, channel_open in enumerate(channel_opens):
chunk_end = (
channel_opens[index + 1].start()
if index + 1 < len(channel_opens)
else len(candidate)
)
chunk = candidate[channel_open.end():chunk_end]
channel_end = _CHANNEL_END_RE.search(chunk)
content = chunk[:channel_end.start()] if channel_end else chunk
if channel_open.group("channel").lower() == "final":
final_segments.append(content.strip())
elif channel_end:
non_channel_text.append(chunk[channel_end.end():])
if final_segments:
return final_segments[-1]
return "".join(non_channel_text).strip()
mirrored = _MIRRORED_REASONING_WITH_FINAL_RE.fullmatch(candidate)
if mirrored:
return mirrored.group("final").strip()
return candidate
def _extract_from_json(text: str, mode: str) -> str | None:
candidate = text.strip()
if not candidate:
+106 -2005
View File
File diff suppressed because it is too large Load Diff
+182 -51
View File
@@ -35,6 +35,7 @@ from AILab_StreamDisplay import (
StreamDegenerationError,
StreamDegenerationGuard,
TerminalStreamDisplay,
compose_streamed_model_output,
extract_stream_token,
strip_degenerate_repetition,
)
@@ -47,33 +48,52 @@ from AILab_LlamaCppInstaller import (
import comfy.model_management as comfy_model_management
from comfy.model_management import throw_exception_if_processing_interrupted
# Import cache functions from main module
# Import shared contracts directly; AILab_QwenVL remains a compatibility facade.
sys.path.append(str(Path(__file__).parent))
from AILab_QwenVL import (
PROMPT_CACHE,
from thinkingllm_core.hf_models import ensure_cuda_vram_headroom
from thinkingllm_core.media import (
MASK_FOCUS_INSTRUCTION,
MASK_RECONSTRUCTION_INSTRUCTION,
apply_mask_highlight,
apply_qwen_soft_thinking_directive,
build_node_input_signature,
download_hf_file_to_path,
ensure_cuda_vram_headroom,
estimate_qwen_text_tokens,
get_cache_key,
get_alternative_cache_key,
get_image_hash,
get_video_hash,
save_prompt_cache,
)
from thinkingllm_core.model_access import (
download_hf_file_to_path,
enforce_model_access,
mark_catalog_files_installed,
model_catalog_options,
normalize_commercial_status,
resolve_model_catalog_name,
)
from thinkingllm_core.prompt_contracts import (
VIDEO_DURATION_INPUT,
apply_qwen_soft_thinking_directive,
apply_video_duration_context,
ensure_video_prompt_duration,
estimate_qwen_text_tokens,
resolve_qwen_thinking_mode,
resolve_video_duration,
validate_minimax_reference_request,
validate_minimax_source_duration,
)
from thinkingllm_core.prompt_state import (
NODE_PROMPT_STATE,
PROMPT_CACHE,
build_node_input_signature,
get_alternative_cache_key,
get_cache_key,
get_node_saved_prompt,
get_node_saved_prompt_with_seed,
resolve_qwen_thinking_mode,
set_node_saved_prompt,
load_node_prompt_state,
log_llm_input,
save_prompt_cache,
set_node_saved_prompt,
_make_node_state_key,
NODE_PROMPT_STATE,
)
import folder_paths
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output
from AILab_OutputCleaner import OUTPUT_CLEANER_VERSION, OutputCleanConfig, clean_model_output
# DEPRECATED: per-node state via set_node_saved_prompt / get_node_saved_prompt is used instead.
LAST_SAVED_PROMPT = None # kept only to avoid ImportError in legacy importers
@@ -84,6 +104,14 @@ load_node_prompt_state()
_GEMMA4_MULTIMODAL_MAX_CTX = 32768
_GEMMA4_MULTIMODAL_IMAGE_TOKENS = 1120
_GEMMA4_MULTIMODAL_MAX_BATCH = 2048
_GEMMA4_AUDIO_MIN_BACKEND_VERSION = (0, 3, 36)
def _version_tuple(version: str) -> tuple[int, int, int] | None:
match = re.match(r"\s*(\d+)\.(\d+)\.(\d+)", str(version or ""))
if match is None:
return None
return tuple(int(part) for part in match.groups())
def read_gguf_architecture(filepath: Path) -> str | None:
@@ -163,7 +191,7 @@ def _quant_from_filename(filename: str) -> str | None:
"""Extract quantization level from a GGUF filename stem.
E.g. 'gemma-4-E4B-it-Q4_K_M.gguf' -> 'Q4'."""
name = Path(filename).stem.upper()
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q2_K'):
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q3_K_S', 'Q2_K'):
if q in name:
return _QUANT_CANONICAL.get(q, q)
return None
@@ -173,7 +201,7 @@ _QUANT_CANONICAL = {
'Q8_0': 'Q8', 'Q6_K': 'Q6',
'Q5_K_M': 'Q5', 'Q5_K_S': 'Q5',
'Q4_K_M': 'Q4', 'Q4_K_S': 'Q4',
'Q3_K_M': 'Q3', 'Q2_K': 'Q2',
'Q3_K_M': 'Q3', 'Q3_K_S': 'Q3', 'Q2_K': 'Q2',
}
import re
@@ -331,6 +359,7 @@ class GGUFVLResolved:
gpu_layers: int
top_k: int
pool_size: int
commercial_status: str = "unclear"
local_filenames: list[str] = field(default_factory=list)
mmproj_local_filenames: list[str] = field(default_factory=list)
@@ -448,6 +477,7 @@ def _scan_local_gguf_models(base_dir: Path, existing_filenames: set[str]) -> dic
"filename": str(model_file),
"mmproj_filename": str(mmproj_path) if mmproj_path else None,
"is_local": True,
"commercial_status": "local",
"repo_id": None,
"alt_repo_ids": [],
"author": None,
@@ -509,6 +539,7 @@ def _load_gguf_vl_catalog():
resolved_mmproj_file = mmproj_files.get(model_file) or mmproj_file
flattened[display] = {
**defaults,
"commercial_status": repo.get("commercial_status", "unclear"),
"author": author,
"repo_dirname": repo_name,
"repo_id": repo_id,
@@ -545,6 +576,7 @@ def _load_gguf_vl_catalog():
resolved_mmproj_file = mmproj_files.get(model_file) or mmproj_file
flattened[display] = {
**defaults,
"commercial_status": entry.get("commercial_status", "unclear"),
"author": author,
"repo_dirname": repo_name,
"repo_id": repo_id,
@@ -560,17 +592,7 @@ def _load_gguf_vl_catalog():
# Mark catalog entries already present on disk, then add uncatalogued local models.
search_dirs = _gguf_search_dirs(base_dir)
installed_filenames: set[str] = set()
for directory in search_dirs:
try:
installed_filenames.update(path.name for path in directory.rglob("*.gguf") if path.is_file())
except (OSError, PermissionError):
pass
for display, entry in list(flattened.items()):
filenames = _filename_search_candidates(entry.get("filename", ""), entry.get("local_filenames"))
if any(filename in installed_filenames for filename in filenames):
flattened.pop(display)
flattened[f"{display} [installed]"] = {**entry, "catalog_display_name": display}
mark_catalog_files_installed(flattened, search_dirs)
existing_filenames = {Path(e.get("filename", "")).name for e in flattened.values() if e.get("filename")}
for scan_dir in search_dirs:
@@ -585,7 +607,7 @@ def _load_gguf_vl_catalog():
GGUF_VL_CATALOG = _load_gguf_vl_catalog()
GGUF_TOOLTIPS = {
"model_name": "GGUF vision model from gguf_models.json or auto-detected local files. [installed] means the catalog model file was found on disk; [local] means an uncatalogued local model. Missing GGUF or mmproj files are downloaded on first use.",
"model_name": "GGUF vision model from gguf_models.json or auto-detected local files. [installed] means the catalog model was found in a configured GGUF/LLM location; [local] means an uncatalogued local model. Missing GGUF or mmproj files are downloaded on first use.",
"audio_model_name": "Gemma 4 audio-capable GGUF model. Only Gemma 4 E2B, E4B, and 12B are listed; 26B/31B variants are image/text-only for this purpose.",
"audio_file_path": "Optional local audio file path. M4A, MP3, WAV, FLAC, and other FFmpeg-readable files are decoded to 16 kHz mono WAV before inference.",
"device": "auto prefers CUDA when PyTorch sees an NVIDIA GPU. If RAW_TRACE says GPU offload is no or unknown, verify your llama-cpp-python CUDA wheel before blaming the model.",
@@ -628,12 +650,10 @@ def _is_gemma4_audio_model_name(model_name: str) -> bool:
def _gguf_audio_model_keys() -> list[str]:
all_models = GGUF_VL_CATALOG.get("models") or {}
keys = [
key
for key, entry in all_models.items()
if (entry or {}).get("mmproj_filename") and _is_gemma4_audio_model_name(key)
]
return sorted(keys)
return model_catalog_options(
all_models,
lambda name, entry: entry.get("mmproj_filename") and _is_gemma4_audio_model_name(name),
)
def _filter_kwargs_for_callable(fn, kwargs: dict) -> dict:
@@ -1090,6 +1110,7 @@ def _find_existing_local_file_in_dirs(search_dirs: list[Path], filename: str, al
def _resolve_model_entry(model_name: str) -> GGUFVLResolved:
all_models = GGUF_VL_CATALOG.get("models") or {}
model_name = resolve_model_catalog_name(all_models, model_name)
entry = all_models.get(model_name) or {}
if not entry:
wanted = _model_name_to_filename_candidates(model_name)
@@ -1137,6 +1158,7 @@ def _resolve_model_entry(model_name: str) -> GGUFVLResolved:
gpu_layers=_int("gpu_layers", -1),
top_k=_int("top_k", 20),
pool_size=_int("pool_size", 4194304),
commercial_status=normalize_commercial_status(entry),
)
@@ -1152,6 +1174,7 @@ class QwenVLGGUFBase:
self.current_ubatch_size = None
self.current_image_token_budget = None
self.current_context_length = None
self.current_backend_info = None
self.gguf_arch = None
register_active_gguf_loader(self)
@@ -1272,8 +1295,10 @@ class QwenVLGGUFBase:
):
Llama = self._load_backend()
backend_info = get_last_llama_cpp_backend_info()
self.current_backend_info = dict(backend_info) if backend_info else None
resolved = _resolve_model_entry(model_name)
access_info = {"commercial_status": resolved.commercial_status}
# Local models store absolute paths — use them directly, skip download logic
if Path(resolved.model_filename).is_absolute():
@@ -1283,6 +1308,7 @@ class QwenVLGGUFBase:
raise FileNotFoundError(f"[QwenVL] Local GGUF model not found: {model_path}")
if mmproj_path is not None and not mmproj_path.exists():
raise FileNotFoundError(f"[QwenVL] Local mmproj not found: {mmproj_path}")
enforce_model_access(access_info, resolved.display_name, local_exists=True, hf_token=hf_token)
else:
base_dir_value = GGUF_VL_CATALOG.get("base_dir") or "llm/GGUF"
search_dirs = _gguf_search_dirs(base_dir_value)
@@ -1330,6 +1356,7 @@ class QwenVLGGUFBase:
if not model_path.exists():
if not repo_ids:
raise FileNotFoundError(f"[QwenVL] GGUF model not found locally and no repo_id provided: {model_path}")
enforce_model_access(access_info, resolved.repo_id or resolved.display_name, local_exists=False, hf_token=hf_token)
_download_single_file(
repo_ids,
resolved.model_filename,
@@ -1342,6 +1369,7 @@ class QwenVLGGUFBase:
if mmproj_path is not None and not mmproj_path.exists():
if not repo_ids:
raise FileNotFoundError(f"[QwenVL] mmproj not found locally and no repo_id provided: {mmproj_path}")
enforce_model_access(access_info, resolved.repo_id or resolved.display_name, local_exists=False, hf_token=hf_token)
_download_single_file(
repo_ids,
resolved.mmproj_filename,
@@ -1350,6 +1378,7 @@ class QwenVLGGUFBase:
progress_label=f"QwenVL GGUF Download: {Path(resolved.mmproj_filename).name}",
hf_token=hf_token,
)
enforce_model_access(access_info, resolved.display_name, local_exists=True, hf_token=hf_token)
hf_token = None
device_kind = _pick_device(device)
@@ -1387,7 +1416,11 @@ class QwenVLGGUFBase:
arch = read_gguf_architecture(model_path)
self.gguf_arch = arch
is_qwen35 = arch in ("qwen35", "qwen35moe") if arch else "qwen3.5-" in model_name.lower()
self.supports_qwen_soft_think = arch == "qwen3" if arch else "qwen3-" in model_name.lower()
self.supports_qwen_soft_think = (
bool(is_qwen35 or arch == "qwen3")
if arch
else ("qwen3-" in model_name.lower() or "qwen3.5-" in model_name.lower())
)
self.uses_qwen_template_thinking = bool(is_qwen35 or arch == "qwen3" or arch == "qwen")
if arch == "gemma4" and has_mmproj:
@@ -1448,6 +1481,7 @@ class QwenVLGGUFBase:
mmproj_kwargs = {
"clip_model_path": str(mmproj_path),
"image_min_tokens": 1024,
"image_max_tokens": img_max,
"force_reasoning": False,
"verbose": False,
@@ -1571,11 +1605,35 @@ class QwenVLGGUFBase:
if arch != "gemma4":
return
if audio_b64:
raise RuntimeError(
"Gemma 4 GGUF audio input is disabled because the installed llama.cpp backend can abort the "
"ComfyUI process for Gemma 4 audio. Use image/text input with Gemma 4 GGUF, or use a backend "
"that explicitly supports Gemma 4 audio."
)
backend_info = getattr(self, "current_backend_info", None) or {}
backend_version = str(backend_info.get("version") or "unknown")
parsed_version = _version_tuple(backend_version)
if parsed_version is None or parsed_version < _GEMMA4_AUDIO_MIN_BACKEND_VERSION:
minimum = ".".join(str(part) for part in _GEMMA4_AUDIO_MIN_BACKEND_VERSION)
raise RuntimeError(
"Gemma 4 GGUF audio requires llama-cpp-python "
f">={minimum}; detected {backend_version}. Older or unknown backends remain blocked because "
"their native Gemma 4 audio path can abort ComfyUI. Update the multimodal backend and restart ComfyUI."
)
initialize_mtmd = getattr(self.chat_handler, "_init_mtmd_context", None)
if not callable(initialize_mtmd):
raise RuntimeError(
"The installed Gemma4ChatHandler cannot verify native audio support. "
"Update llama-cpp-python and restart ComfyUI."
)
try:
initialize_mtmd(self.llm)
except Exception as exc:
raise RuntimeError(
"Gemma 4 GGUF audio could not initialize the selected multimodal projector: "
f"{exc}"
) from exc
if getattr(self.chat_handler, "is_support_audio", None) is not True:
raise RuntimeError(
"The selected Gemma 4 multimodal projector does not report audio support. "
"Use an audio-capable mmproj (BF16 is recommended for Gemma 4 E2B/E4B)."
)
context_length = int(getattr(self, "current_context_length", 0) or 0)
batch_size = int(getattr(self, "current_batch_size", 0) or 0)
ubatch_size = int(getattr(self, "current_ubatch_size", 0) or 0)
@@ -1614,6 +1672,8 @@ class QwenVLGGUFBase:
enable_thinking: bool = True,
auto_finalization_retry: bool = False,
top_k: int | None = None,
preset_name: str = "",
media_summary: dict[str, int] | None = None,
):
"""Returns (cleaned_text, raw_text) tuple."""
ensure_cuda_vram_headroom("QwenVL GGUF", min_free_gb=1.0, min_free_ratio=0.08)
@@ -1666,6 +1726,18 @@ class QwenVLGGUFBase:
{"role": "user", "content": effective_user_prompt},
]
log_llm_input(
"QwenVL GGUF",
stage_label,
preset_name,
effective_user_prompt,
system_text=system_prompt_text,
media=(media_summary or {
"visual_items": sum(bool(item) for item in images_for_call),
"audio": sum(bool(item) for item in audio_for_call),
}) if images_for_call or audio_for_call else {},
)
start = time.perf_counter()
stop_tokens = ["<|im_end|>", "<|im_start|>"]
sampler_kwargs = {
@@ -1679,6 +1751,8 @@ class QwenVLGGUFBase:
stream_display = TerminalStreamDisplay("QwenVL GGUF", suppress_planning=True, compact=True)
stream_display.start_stage(stage_label)
full_text = ""
reasoning_text = ""
content_text = ""
degeneration_guard = StreamDegenerationGuard()
stopped_for_degenerate_stream = ""
result = self._create_chat_completion(
@@ -1700,8 +1774,10 @@ class QwenVLGGUFBase:
content_token = token.get("content", "")
display_token = reasoning_token + content_token
if reasoning_token:
reasoning_text += reasoning_token
full_text += reasoning_token
if content_token:
content_text += content_token
full_text += content_token
if display_token:
try:
@@ -1715,7 +1791,9 @@ class QwenVLGGUFBase:
stream_display.push_compact(display_token)
stream_display.end_compact()
stream_display.end_stage()
raw_full = strip_degenerate_repetition(full_text).strip()
raw_full = strip_degenerate_repetition(
compose_streamed_model_output(reasoning_text, content_text)
).strip()
if stopped_for_degenerate_stream:
print(f"[QwenVL GGUF] stopped repeated-token loop: {stopped_for_degenerate_stream}")
cleaned = clean_model_output(raw_full, OutputCleanConfig(mode="text"))
@@ -1732,6 +1810,8 @@ class QwenVLGGUFBase:
abort_event = threading.Event()
chunk_queue: qmod.Queue = qmod.Queue()
full_text_acc = ""
reasoning_text_acc = ""
content_text_acc = ""
worker_error: Exception | None = None
degeneration_guard = StreamDegenerationGuard()
stopped_for_degenerate_stream = ""
@@ -1775,8 +1855,10 @@ class QwenVLGGUFBase:
reasoning_token = token.get("reasoning", "")
content_token = token.get("content", "")
if reasoning_token:
reasoning_text_acc += reasoning_token
full_text_acc += reasoning_token
if content_token:
content_text_acc += content_token
full_text_acc += content_token
display_token = reasoning_token + content_token
if display_token:
@@ -1793,7 +1875,9 @@ class QwenVLGGUFBase:
raise worker_error
throw_exception_if_processing_interrupted()
elapsed = max(time.perf_counter() - start, 1e-6)
raw_content = strip_degenerate_repetition(full_text_acc).strip()
raw_content = strip_degenerate_repetition(
compose_streamed_model_output(reasoning_text_acc, content_text_acc)
).strip()
if stopped_for_degenerate_stream:
print(f"[QwenVL GGUF] stopped repeated-token loop: {stopped_for_degenerate_stream}")
cleaned = clean_model_output(raw_content, OutputCleanConfig(mode="text"))
@@ -1931,9 +2015,20 @@ class QwenVLGGUFBase:
enable_thinking=True,
auto_finalization_retry=False,
hf_token="",
duration_seconds=5.0,
mask_mode="focus",
):
model_name = resolve_model_catalog_name(GGUF_VL_CATALOG.get("models") or {}, model_name)
validate_minimax_reference_request(preset_prompt, custom_prompt)
resolved_duration = resolve_video_duration(preset_prompt, duration_seconds)
validate_minimax_source_duration(preset_prompt, duration_seconds, custom_prompt)
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
duration_signature = ({
"effective_duration_seconds": effective_duration_seconds,
"video_prompt_contract_version": resolved_duration.get("contract_version"),
} if resolved_duration else {})
print(f"[QwenVL GGUF DEBUG] Starting run with seed={seed}")
image, mask_hash = apply_mask_highlight(image, mask)
image, mask_hash = apply_mask_highlight(image, mask, mode=mask_mode)
image_hash = get_image_hash(image)
if mask_hash:
image_hash = f"{image_hash}:{mask_hash}"
@@ -1964,10 +2059,13 @@ class QwenVLGGUFBase:
ctx_checkpoints=ctx_checkpoints,
enable_thinking=bool(enable_thinking),
auto_finalization_retry=bool(auto_finalization_retry),
mask_mode=mask_mode,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
output_cleaner_version=OUTPUT_CLEANER_VERSION,
**duration_signature,
)
requires_audio_input = node_class in {"ThinkingLLM_Gemma4_Audio_GGUF"}
skip_prompt_persistence = requires_audio_input
@@ -1990,7 +2088,7 @@ class QwenVLGGUFBase:
input_signature=input_signature,
)
if saved:
print(f"[QwenVL GGUF] Fixed seed {seed} matched — using per-node prompt: {saved[:50]}...")
print(f"[QwenVL GGUF] Preset {preset_prompt}: fixed seed {seed} matched; no LLM call was made.")
return (saved, "")
if not stream_to_terminal:
print(f"[QwenVL GGUF] Generating new prompt")
@@ -1998,7 +2096,7 @@ class QwenVLGGUFBase:
prompt_template = "" if preset_prompt == "🚫 No preset (image-only)" else SYSTEM_PROMPTS.get(preset_prompt, preset_prompt)
# Generate cache key with all inputs including seed
cache_key = get_cache_key(model_name, preset_prompt, custom_prompt, image_hash, video_hash, int(seed), max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking)
cache_key = get_cache_key(model_name, preset_prompt, custom_prompt, image_hash, video_hash, int(seed), max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
# TEMPORARILY DISABLED CACHE FOR DEBUGGING
# Check cache first (only for random mode)
@@ -2016,7 +2114,13 @@ class QwenVLGGUFBase:
else:
prompt = prompt_template
if mask_hash:
prompt = f"{prompt}\n\n{MASK_FOCUS_INSTRUCTION}".strip()
mask_instruction = (
MASK_RECONSTRUCTION_INSTRUCTION
if mask_mode == "reconstruct"
else MASK_FOCUS_INSTRUCTION
)
prompt = f"{prompt}\n\n{mask_instruction}".strip()
prompt = apply_video_duration_context(prompt, preset_prompt, duration_seconds)
print(f"[QwenVL GGUF DEBUG] Final prompt: {prompt[:100]}...")
@@ -2129,6 +2233,13 @@ class QwenVLGGUFBase:
enable_thinking=effective_thinking,
auto_finalization_retry=auto_finalization_retry,
top_k=top_k,
preset_name=preset_prompt,
media_summary={
"image_input": 1 if image is not None else 0,
"video_input": 1 if video is not None else 0,
"visual_items": len(images_b64),
"audio": len(audio_b64),
},
)
if self.last_backend_trace:
raw_trace = f"{self.last_backend_trace}\n\n{raw_trace}" if raw_trace else self.last_backend_trace
@@ -2149,6 +2260,13 @@ class QwenVLGGUFBase:
raise last_exc
raise RuntimeError("[QwenVL GGUF] Generation failed without returning text")
text = ensure_video_prompt_duration(
text,
preset_prompt,
duration_seconds,
source_prompt_text=custom_prompt,
)
print(f"[QwenVL GGUF DEBUG] Generation completed. Text length: {len(text) if text else 0}")
print(f"[QwenVL GGUF DEBUG] Generated text: {text[:100] if text else 'EMPTY'}...")
@@ -2162,7 +2280,8 @@ class QwenVLGGUFBase:
"seed": int(seed),
"image_hash": image_hash,
"mask_hash": mask_hash,
"video_hash": video_hash
"video_hash": video_hash,
**duration_signature,
}
save_prompt_cache() # Save cache to file
@@ -2189,7 +2308,7 @@ class ThinkingLLM_QwenVL_GGUF(QwenVLGGUFBase):
@classmethod
def INPUT_TYPES(cls):
all_models = GGUF_VL_CATALOG.get("models") or {}
model_keys = sorted([key for key, entry in all_models.items() if (entry or {}).get("mmproj_filename")]) or ["(no GGUF VL models found)"]
model_keys = model_catalog_options(all_models, lambda _name, entry: entry.get("mmproj_filename")) or ["(no GGUF VL models found)"]
default_model = model_keys[0]
prompts = PRESET_PROMPTS or ["🚫 No preset (image-only)", "🖼️ Detailed Description"]
@@ -2213,8 +2332,10 @@ class ThinkingLLM_QwenVL_GGUF(QwenVLGGUFBase):
"image": ("IMAGE",),
"video": ("IMAGE",),
"mask": ("MASK",),
"mask_mode": (["focus", "reconstruct"], {"default": "focus", "tooltip": "Focus describes the selected area; reconstruct conceals it and infers the surrounding scene continuation."}),
"audio": ("AUDIO",),
"audio_file_path": ("STRING", {"default": "", "multiline": False, "tooltip": GGUF_TOOLTIPS["audio_file_path"]}),
"duration_seconds": VIDEO_DURATION_INPUT,
},
"hidden": {
"unique_id": "UNIQUE_ID",
@@ -2246,6 +2367,8 @@ class ThinkingLLM_QwenVL_GGUF(QwenVLGGUFBase):
enable_thinking=True,
auto_finalization_retry=False,
hf_token="",
duration_seconds=5.0,
mask_mode="focus",
):
result = self.run(
model_name=model_name,
@@ -2283,6 +2406,8 @@ class ThinkingLLM_QwenVL_GGUF(QwenVLGGUFBase):
enable_thinking=enable_thinking,
auto_finalization_retry=auto_finalization_retry,
hf_token=hf_token,
duration_seconds=duration_seconds,
mask_mode=mask_mode,
)
hf_token = ""
return result
@@ -2381,7 +2506,7 @@ class ThinkingLLM_QwenVL_GGUF_Advanced(QwenVLGGUFBase):
@classmethod
def INPUT_TYPES(cls):
all_models = GGUF_VL_CATALOG.get("models") or {}
model_keys = sorted([key for key, entry in all_models.items() if (entry or {}).get("mmproj_filename")]) or ["(no GGUF VL models found)"]
model_keys = model_catalog_options(all_models, lambda _name, entry: entry.get("mmproj_filename")) or ["(no GGUF VL models found)"]
default_model = model_keys[0]
prompts = PRESET_PROMPTS or ["🚫 No preset (image-only)", "🖼️ Detailed Description"]
@@ -2428,8 +2553,10 @@ class ThinkingLLM_QwenVL_GGUF_Advanced(QwenVLGGUFBase):
"image": ("IMAGE",),
"video": ("IMAGE",),
"mask": ("MASK",),
"mask_mode": (["focus", "reconstruct"], {"default": "focus", "tooltip": "Focus describes the selected area; reconstruct conceals it and infers the surrounding scene continuation."}),
"audio": ("AUDIO",),
"audio_file_path": ("STRING", {"default": "", "multiline": False, "tooltip": GGUF_TOOLTIPS["audio_file_path"]}),
"duration_seconds": VIDEO_DURATION_INPUT,
},
"hidden": {
"unique_id": "UNIQUE_ID",
@@ -2480,6 +2607,8 @@ class ThinkingLLM_QwenVL_GGUF_Advanced(QwenVLGGUFBase):
enable_thinking=True,
auto_finalization_retry=False,
hf_token="",
duration_seconds=5.0,
mask_mode="focus",
):
_ = legacy_seed_mode
_ = legacy_unload_after_run
@@ -2519,9 +2648,11 @@ class ThinkingLLM_QwenVL_GGUF_Advanced(QwenVLGGUFBase):
enable_thinking=enable_thinking,
auto_finalization_retry=auto_finalization_retry,
hf_token=hf_token,
duration_seconds=duration_seconds,
mask_mode=mask_mode,
)
hf_token = ""
mask_preview, _ = apply_mask_highlight(image, mask)
mask_preview, _ = apply_mask_highlight(image, mask, mode=mask_mode)
return (*result, mask_preview)
+199 -55
View File
@@ -27,37 +27,52 @@ import torch
from huggingface_hub import snapshot_download
from AILab_LlamaCppInstaller import ensure_llama_cpp_backend
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output, prompt_output_guard
from AILab_OutputCleaner import OUTPUT_CLEANER_VERSION, OutputCleanConfig, clean_model_output, prompt_output_guard
from comfy.model_management import throw_exception_if_processing_interrupted
from comfy.model_management import throw_exception_if_processing_interrupted
# Import cache functions from main module
# Import shared contracts directly; AILab_QwenVL remains a compatibility facade.
import sys
sys.path.append(str(Path(__file__).parent))
from AILab_StreamDisplay import (
StreamDegenerationError,
StreamDegenerationGuard,
TerminalStreamDisplay,
compose_streamed_model_output,
extract_stream_token,
strip_degenerate_repetition,
)
from AILab_QwenVL import (
PROMPT_CACHE,
apply_qwen_soft_thinking_directive,
build_node_input_signature,
from thinkingllm_core.hf_models import ensure_cuda_vram_headroom
from thinkingllm_core.model_access import (
download_hf_file_to_path,
ensure_cuda_vram_headroom,
enforce_model_access,
mark_catalog_files_installed,
model_catalog_options,
resolve_model_catalog_name,
)
from thinkingllm_core.prompt_contracts import (
VIDEO_DURATION_INPUT,
apply_qwen_soft_thinking_directive,
apply_video_duration_context,
ensure_video_prompt_duration,
estimate_qwen_text_tokens,
get_cache_key,
resolve_qwen_thinking_mode,
resolve_video_duration,
validate_minimax_source_duration,
)
from thinkingllm_core.prompt_state import (
PROMPT_CACHE,
_build_workflow_fingerprint,
_make_node_state_key,
build_node_input_signature,
get_alternative_cache_key,
save_prompt_cache,
get_cache_key,
get_node_saved_prompt,
get_node_saved_prompt_with_seed,
resolve_qwen_thinking_mode,
set_node_saved_prompt,
load_node_prompt_state,
_make_node_state_key,
_build_workflow_fingerprint,
log_llm_input,
save_prompt_cache,
set_node_saved_prompt,
)
from AILab_QwenVL_GGUF import (
construct_llama_safely,
@@ -71,7 +86,7 @@ from AILab_QwenVL_GGUF import (
GGUF_PROMPT_TOOLTIPS = {
"model_name": "GGUF text model from config or auto-detected local files. First run downloads the selected GGUF when it is not already on disk.",
"model_name": "GGUF text model from config or auto-detected local files. [installed] means the catalog model was found in a configured GGUF/LLM location; [local] means an uncatalogued local model. Missing GGUF files download on first use.",
"prompt_text": "Prompt text to enhance. Leave blank to emit the selected preset instruction as the base prompt.",
"preset_system_prompt": "Preset enhancement style. Use Custom Only when you want custom_system_prompt to fully control the instruction.",
"custom_system_prompt": "Optional extra instruction. Required when using Custom Only; otherwise it is prepended to the selected style.",
@@ -79,7 +94,7 @@ GGUF_PROMPT_TOOLTIPS = {
"temperature": "Sampling randomness. Lower is more stable; higher is more varied.",
"top_p": "Nucleus sampling cutoff. Lower values restrict token choice; 0.9 is a balanced default.",
"repetition_penalty": "Values above 1.0 reduce repeated phrases in the enhanced prompt.",
"english_output": "Ask the model to return the final enhanced prompt in English.",
"english_output": "Ask the model to return the final enhanced prompt in English. MiniMax H3 presets already require English and bypass the generic paragraph translation pass so their official field structure remains intact.",
"device": "auto prefers GPU when available. If generation is unexpectedly slow, run tools/check_llama_backend.py to verify llama.cpp GPU offload.",
"keep_model_loaded": "Keep the GGUF model in memory after generation so repeated prompt enhancement skips model loading.",
"seed": "Sampling seed. Reusing it with identical inputs can reuse the saved prompt result.",
@@ -112,7 +127,7 @@ def _quant_from_filename(filename: str) -> str | None:
E.g. 'gemma-4-E4B-it-Q4_K_M.gguf' -> 'Q4'."""
from pathlib import Path
name = Path(filename).stem.upper()
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q2_K'):
for q in ('BF16', 'F16', 'F32', 'Q8_0', 'Q6_K', 'Q5_K_M', 'Q5_K_S', 'Q4_K_M', 'Q4_K_S', 'Q3_K_M', 'Q3_K_S', 'Q2_K'):
if q in name:
return _QUANT_CANONICAL.get(q, q)
return None
@@ -122,10 +137,14 @@ _QUANT_CANONICAL = {
'Q8_0': 'Q8', 'Q6_K': 'Q6',
'Q5_K_M': 'Q5', 'Q5_K_S': 'Q5',
'Q4_K_M': 'Q4', 'Q4_K_S': 'Q4',
'Q3_K_M': 'Q3', 'Q2_K': 'Q2',
'Q3_K_M': 'Q3', 'Q3_K_S': 'Q3', 'Q2_K': 'Q2',
}
_EMPTY_THINK_RE = re.compile(r"<think[^>]*>\s*</think>", flags=re.IGNORECASE | re.DOTALL)
_LEADING_CLOSED_THINK_RE = re.compile(
r"^\s*(?:<think[^>]*>.*?</think>\s*)+",
flags=re.IGNORECASE | re.DOTALL,
)
_STREAM_HEARTBEAT_INTERVAL_SECONDS = 3.0
_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS = 3
_STREAM_POLL_INTERVAL_SECONDS = 0.25
@@ -169,6 +188,64 @@ def _prompt_output_is_usable(cleaned_text: str) -> bool:
return bool(cleaned_text and not _looks_like_prompt_planning(cleaned_text))
def _preset_output_is_usable(cleaned_text: str, preset_name: str) -> bool:
"""Accept cleaned video output without imposing a second model schema gate."""
resolved = resolve_video_duration(preset_name, 5.0)
if resolved:
# OutputCleaner already removes completed thinking blocks and returns
# an empty string for unfinished ones. The target video model can
# tolerate minor field and formatting irregularities, so rejecting a
# non-empty final prompt here only creates false empty outputs.
return bool((cleaned_text or "").strip())
return _prompt_output_is_usable(cleaned_text)
def _merge_assistant_prefill(content_text: str, assistant_prefill: str) -> str:
"""Restore a chat-template prefill without duplicating a model-emitted field.
Some Qwen GGUF chat templates return a literal empty ``<think>`` block
before repeating the assistant prefill. Checking only the first visible
characters made us prepend the field a second time, so an otherwise valid
MiniMax result was rejected. Inspect the first final-answer text after any
closed leading think block and insert the prefill there only when missing.
"""
content = (content_text or "").strip()
prefill = (assistant_prefill or "").strip()
if not content or not prefill:
return content
leading_think = _LEADING_CLOSED_THINK_RE.match(content)
answer_start = leading_think.end() if leading_think else 0
answer = content[answer_start:].lstrip()
if answer.casefold().startswith(prefill.casefold()):
return content
# An unfinished thinking block has no final answer to prefix.
if not leading_think and re.match(r"^\s*<think\b", content, flags=re.IGNORECASE):
return content
separator = "" if prefill.endswith((" ", "\n")) else " "
if leading_think:
return f"{content[:answer_start]}{prefill}{separator}{answer}"
return f"{prefill}{separator}{content}"
def _video_assistant_prefill(preset_name: str) -> str:
"""Start schema-only retries inside the target model's native format."""
resolved = resolve_video_duration(preset_name, 5.0)
if not resolved:
return ""
if resolved.get("provider") == "minimax_h3":
return (
"subject_definitions:"
if resolved.get("video_mode") == "ref2va"
else "integrated_multimodal_description:"
)
if resolved.get("provider") == "ltx_2_3":
return "A"
return ""
def _maybe_emit_prompt_stream_heartbeat(stage_label: str, started_at: float, last_status_at: float, full_text: str) -> float:
now = time.monotonic()
if (now - last_status_at) < _STREAM_HEARTBEAT_INTERVAL_SECONDS:
@@ -375,6 +452,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
local_models[display] = {
"filename": str(gguf_file),
"is_local": True,
"commercial_status": "local",
"repo_id": None,
"alt_repo_ids": [],
"author": None,
@@ -441,6 +519,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
entry = dict(defaults)
entry.update(
{
"commercial_status": repo.get("commercial_status", "unclear"),
"author": author,
"repo_dirname": repo_name,
"repo_id": repo_id,
@@ -453,8 +532,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
# Scan filesystem for locally available models not in JSON config
# Collect all directories to scan: the configured base_dir + any extra LLM paths from ComfyUI
search_dirs = _gguf_search_dirs(base_dir)
mark_catalog_files_installed(models, search_dirs)
existing_filenames = {Path(e.get("filename", "")).name for e in models.values() if e.get("filename")}
for scan_dir in _gguf_search_dirs(base_dir):
for scan_dir in search_dirs:
local_models = ThinkingLLM_QwenVL_GGUF_PromptEnhancer._scan_local_gguf_text_models(scan_dir, existing_filenames)
models.update(local_models)
existing_filenames.update(Path(e.get("filename", "")).name for e in local_models.values() if e.get("filename"))
@@ -467,7 +548,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
preferred_style = "📝 Enhance"
default_style = preferred_style if preferred_style in styles else (styles[0] if styles else "📝 Enhance")
temp = cls.load_gguf_models()
model_keys = sorted(list((temp.get("models") or {}).keys())) or ["(no GGUF models found)"]
model_keys = model_catalog_options(temp.get("models") or {}) or ["(no GGUF models found)"]
default_model = model_keys[0]
return {
"required": {
@@ -489,6 +570,9 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
"auto_finalization_retry": ("BOOLEAN", {"default": False, "tooltip": GGUF_PROMPT_TOOLTIPS["auto_finalization_retry"]}),
"hf_token": ("STRING", {"default": "", "multiline": False, "tooltip": GGUF_PROMPT_TOOLTIPS["hf_token"]}),
},
"optional": {
"duration_seconds": VIDEO_DURATION_INPUT,
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
@@ -551,6 +635,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
def _resolve_model_path(self, model_name):
models = self.gguf_models.get("models") or {}
model_name = resolve_model_catalog_name(models, model_name)
entry = models.get(model_name) or {}
# Back-compat: allow workflows to pass a filename instead of a catalog key.
@@ -600,13 +685,9 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
return base_dir / model_name
def _maybe_download_model(self, model_name, resolved, unique_id=None, hf_token: str | None = None):
if resolved.exists():
return
# Local models should already exist on disk — don't attempt download
models = self.gguf_models.get("models") or {}
model_name = resolve_model_catalog_name(models, model_name)
entry = models.get(model_name) or {}
if entry.get("is_local"):
raise FileNotFoundError(f"[QwenVL] Local GGUF model not found: {resolved}")
if not entry:
wanted = _model_name_to_filename_candidates(model_name)
for candidate in models.values():
@@ -614,6 +695,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
if filename and Path(filename).name in wanted:
entry = candidate
break
if resolved.exists():
enforce_model_access(entry, model_name, local_exists=True, hf_token=hf_token)
return
enforce_model_access(entry, entry.get("repo_id") or model_name, local_exists=False, hf_token=hf_token)
repo_ids = [rid for rid in (entry.get("alt_repo_ids") or []) + [entry.get("repo_id")] if rid]
filename = entry.get("filename") or resolved.name
@@ -643,6 +728,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
raise FileNotFoundError(f"[QwenVL] GGUF model not found after download: {resolved} (tried: {', '.join(attempted)})")
def _load_model(self, model_name, device, enable_thinking=True, unique_id=None, hf_token: str | None = None, context_length_override: int | None = None, quiet: bool = False):
model_name = resolve_model_catalog_name(self.gguf_models.get("models") or {}, model_name)
Llama = self._load_backend()
resolved = self._resolve_model_path(model_name)
self._maybe_download_model(model_name, resolved, unique_id=unique_id, hf_token=hf_token)
@@ -703,10 +789,17 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
arch = read_gguf_architecture(resolved)
self.gguf_arch = arch
is_qwen = (arch and any(a in arch for a in ("qwen35", "qwen35moe", "qwen3", "qwen"))) or "qwen3.5-" in model_name.lower() or "qwen3-" in model_name.lower()
self.supports_qwen_soft_think = arch == "qwen3" if arch else "qwen3-" in model_name.lower()
self.supports_qwen_soft_think = (
bool(arch and (arch == "qwen3" or arch.startswith("qwen35")))
if arch
else ("qwen3-" in model_name.lower() or "qwen3.5-" in model_name.lower())
)
if is_qwen:
thinking_state = bool(enable_thinking)
kwargs["chat_template_kwargs"] = {"enable_thinking": thinking_state}
kwargs["chat_template_kwargs"] = {
"enable_thinking": thinking_state,
"thinking_mode": "enabled" if thinking_state else "disabled",
}
state_label = "enabled" if thinking_state else "disabled"
if not quiet:
print(f"[QwenVL] Qwen architecture detected (arch={arch}): Thinking {state_label} via chat template.")
@@ -739,10 +832,12 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
initial_stage_label="INITIAL GENERATION",
enable_thinking=True,
auto_finalization_retry=False,
preset_name="",
):
"""Returns (cleaned_prompt, raw_trace) tuple."""
stream_display = TerminalStreamDisplay("QwenVL GGUF", suppress_planning=True, compact=True) if stream_to_terminal else None
supports_soft_think = getattr(self, "supports_qwen_soft_think", False)
is_video_preset = bool(resolve_video_duration(preset_name, 5.0))
if supports_soft_think:
directive = "/think" if enable_thinking else "/no_think"
if not stream_to_terminal:
@@ -756,6 +851,7 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
stage_label: str,
*,
attempt_enable_thinking: bool | None = None,
assistant_prefill: str = "",
) -> str:
current_enable_thinking = enable_thinking if attempt_enable_thinking is None else bool(attempt_enable_thinking)
effective_user_prompt = apply_qwen_soft_thinking_directive(
@@ -776,7 +872,18 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
{"role": "system", "content": system},
{"role": "user", "content": effective_user_prompt},
]
if assistant_prefill:
messages.append({"role": "assistant", "content": assistant_prefill})
log_llm_input(
"QwenVL PromptEnhancer GGUF",
stage_label,
preset_name,
effective_user_prompt,
system_text=system,
)
full_text = ""
reasoning_text = ""
content_text = ""
stage_started_at = time.monotonic()
last_status_at = stage_started_at
if stream_to_terminal:
@@ -806,6 +913,11 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
"stop": ["<|im_end|>", "<|im_start|>"],
"stream": True,
}
if supports_soft_think:
completion_kwargs["chat_template_kwargs"] = {
"enable_thinking": current_enable_thinking,
"thinking_mode": "enabled" if current_enable_thinking else "disabled",
}
completion_kwargs = _filter_kwargs_for_callable(getattr(llm, "create_chat_completion"), completion_kwargs)
response = llm.create_chat_completion(**completion_kwargs)
for chunk in response:
@@ -843,8 +955,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
content_token = token.get("content", "")
display_token = reasoning_token + content_token
if reasoning_token:
reasoning_text += reasoning_token
full_text += reasoning_token
if content_token:
content_text += content_token
full_text += content_token
if display_token and stream_display is not None:
try:
@@ -876,18 +990,20 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
raise RuntimeError("[QwenVL] llama_cpp streaming returned empty response")
if stopped_for_degenerate_stream:
print(f"[QwenVL GGUF] stopped repeated-token loop: {stopped_for_degenerate_stream}")
return strip_degenerate_repetition(full_text).strip()
if assistant_prefill and content_text.strip():
content_text = _merge_assistant_prefill(content_text, assistant_prefill)
separated_output = compose_streamed_model_output(reasoning_text, content_text)
return strip_degenerate_repetition(separated_output).strip()
raw = _call(system_prompt, user_prompt, float(temperature), int(seed), initial_stage_label)
cleaned = clean_model_output(raw, OutputCleanConfig(mode="prompt"))
raw_trace_parts = [f"[{initial_stage_label}]\n{raw}"]
best_cleaned = cleaned.strip()
if _prompt_output_is_usable(best_cleaned):
best_cleaned = cleaned.strip() if _preset_output_is_usable(cleaned.strip(), preset_name) else ""
if best_cleaned:
return best_cleaned, "\n\n".join(raw_trace_parts)
if not auto_finalization_retry:
return best_cleaned or "", "\n\n".join(raw_trace_parts)
if not (auto_finalization_retry or is_video_preset):
return "", "\n\n".join(raw_trace_parts)
current_raw = raw
for attempt_number in range(2, _PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS + 1):
if stream_to_terminal:
print(
@@ -896,16 +1012,15 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
"— prior output was empty or reasoning-only"
)
retry_system = (
"You are a professional photography prompt writer.\n"
"Output ONLY ONE final photography prompt paragraph.\n"
"No analysis, no planning steps, no first-person, and no <think>.\n"
"No bullet points, no headings, no JSON, no markdown, no quotes."
f"{system_prompt}\n\n"
"The prior response did not contain final prompt text. "
"Return the final output again using exactly the schema, field order, tags, and constraints above. "
"Do not collapse structured output into one paragraph. Do not include analysis or <think>."
)
retry_user = (
"Rewrite the following into the final prompt paragraph:\n\n"
f"{current_raw}\n"
"Return the final prompt for this original user request. Preserve supported user facts and verbatim dialogue:\n\n"
f"{user_prompt}\n"
)
force_non_thinking = attempt_number == _PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS
stage_label = f"FINALIZATION ATTEMPT {attempt_number}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}"
raw_retry = _call(
retry_system,
@@ -913,23 +1028,23 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
0.4,
int(seed) + 999 + attempt_number,
stage_label,
attempt_enable_thinking=False if force_non_thinking else enable_thinking,
attempt_enable_thinking=False,
assistant_prefill=_video_assistant_prefill(preset_name),
)
raw_trace_parts.append(f"[{stage_label}]\n{raw_retry}")
cleaned_retry = clean_model_output(raw_retry, OutputCleanConfig(mode="prompt")).strip()
if cleaned_retry and len(cleaned_retry) >= len(best_cleaned):
retry_is_usable = _preset_output_is_usable(cleaned_retry, preset_name)
if retry_is_usable and len(cleaned_retry) >= len(best_cleaned):
best_cleaned = cleaned_retry
if _prompt_output_is_usable(cleaned_retry):
if retry_is_usable:
return cleaned_retry, "\n\n".join(raw_trace_parts)
current_raw = raw_retry
if stream_to_terminal:
print(
"[QwenVL PromptEnhancer GGUF] "
f"Finalization limit reached ({_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}/{_PROMPT_ENHANCER_MAX_FINALIZATION_ATTEMPTS}) "
"— returning the best cleaned prompt available"
)
return best_cleaned or "", "\n\n".join(raw_trace_parts)
return best_cleaned, "\n\n".join(raw_trace_parts)
def process(
self,
@@ -952,32 +1067,45 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
enable_thinking=True,
auto_finalization_retry=False,
hf_token="",
duration_seconds=5.0,
):
model_name = resolve_model_catalog_name(self.gguf_models.get("models") or {}, model_name)
node_class = "ThinkingLLM_QwenVL_GGUF_PromptEnhancer"
resolved_duration = resolve_video_duration(preset_system_prompt, duration_seconds)
validate_minimax_source_duration(preset_system_prompt, duration_seconds, prompt_text)
is_minimax_preset = bool(resolved_duration and resolved_duration.get("provider") == "minimax_h3")
effective_english_output = bool(english_output and not is_minimax_preset)
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
duration_signature = ({
"effective_duration_seconds": effective_duration_seconds,
"video_prompt_contract_version": resolved_duration.get("contract_version"),
} if resolved_duration else {})
input_signature = build_node_input_signature(
model_name=model_name,
prompt_text=prompt_text,
preset_system_prompt=preset_system_prompt,
custom_system_prompt=custom_system_prompt,
english_output=bool(english_output),
english_output=effective_english_output,
device=device,
enable_thinking=bool(enable_thinking),
auto_finalization_retry=bool(auto_finalization_retry),
output_cleaner_version=OUTPUT_CLEANER_VERSION,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
**duration_signature,
)
# Auto-retrieve saved prompt when seed is fixed (no keep_last_prompt needed)
saved_prompt = get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
if saved_prompt and not stream_tokens_to_terminal:
print(f"[QwenVL PromptEnhancer GGUF] Fixed seed {seed} matched — using per-node prompt: {saved_prompt[:50]}...")
print(f"[QwenVL PromptEnhancer GGUF] Preset {preset_system_prompt}: fixed seed {seed} matched; no LLM call was made.")
return (saved_prompt, "")
if saved_prompt and stream_tokens_to_terminal:
pass
if keep_last_prompt:
print(f"[QwenVL PromptEnhancer GGUF] Keep last prompt enabled but no saved prompt found — returning empty")
print(f"[QwenVL PromptEnhancer GGUF] Preset {preset_system_prompt}: no saved prompt found; no LLM call was made.")
return ("", "")
# Always generate unless keep_last_prompt was requested
@@ -989,16 +1117,17 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
part for part in (
prompt_text.strip(),
custom_system_prompt.strip(),
f"english_output={bool(english_output)}",
f"english_output={effective_english_output}",
f"output_cleaner_version={OUTPUT_CLEANER_VERSION}",
) if part
)
cache_key = get_cache_key(model_name, preset_system_prompt, cache_prompt, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking)
cache_key = get_cache_key(model_name, preset_system_prompt, cache_prompt, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
# Check cache first (only for random mode)
if cache_key in PROMPT_CACHE and not stream_tokens_to_terminal:
cached_text = PROMPT_CACHE[cache_key].get("text", "")
if cached_text:
print(f"[QwenVL PromptEnhancer GGUF] Using cached prompt for seed {seed}: {cache_key[:8]}...")
print(f"[QwenVL PromptEnhancer GGUF] Preset {preset_system_prompt}: cache hit for seed {seed}; no LLM call was made.")
return (cached_text.strip(), "")
if stream_tokens_to_terminal and cache_key in PROMPT_CACHE:
cached_text = PROMPT_CACHE[cache_key].get("text", "")
@@ -1015,11 +1144,17 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
raise ValueError("custom_system_prompt is required when using Custom Only (no preset).")
raise ValueError("system_prompt is empty; check AILab_System_Prompts.json or preset selection.")
system_prompt = f"{system_prompt}\n\n{prompt_output_guard()}"
system_prompt = apply_video_duration_context(system_prompt, preset_system_prompt, duration_seconds)
merged_prompt = prompt_text.strip() or "Describe a scene vividly."
model_cfg = self.gguf_models["models"].get(model_name, {})
context_window = model_cfg.get("context_length", 32768)
estimated_prompt_tokens = estimate_qwen_text_tokens(system_prompt, merged_prompt)
effective_context_window = _resolve_prompt_enhancer_context_length(context_window, estimated_prompt_tokens, max_tokens)
if resolved_duration:
effective_context_window = max(
effective_context_window,
min(int(context_window or 8192), 8192),
)
effective_thinking = resolve_qwen_thinking_mode(
enable_thinking,
max_tokens,
@@ -1050,9 +1185,10 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
initial_stage_label="INITIAL GENERATION",
enable_thinking=effective_thinking,
auto_finalization_retry=auto_finalization_retry,
preset_name=preset_system_prompt,
)
full_raw_trace = raw_trace
if english_output:
if effective_english_output:
translated, trans_trace = self._invoke_llama(
system_prompt=(
PROMPT_CONFIG.get("translation_prompt")
@@ -1069,11 +1205,18 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
initial_stage_label="TRANSLATION STAGE",
enable_thinking=effective_thinking,
auto_finalization_retry=auto_finalization_retry,
preset_name=preset_system_prompt,
)
full_raw_trace = f"{raw_trace}\n\n{trans_trace}"
final = clean_model_output(translated, OutputCleanConfig(mode="prompt")) or translated.strip()
final = clean_model_output(translated, OutputCleanConfig(mode="prompt"))
else:
final = clean_model_output(enhanced, OutputCleanConfig(mode="prompt")) or enhanced.strip()
final = clean_model_output(enhanced, OutputCleanConfig(mode="prompt"))
final = ensure_video_prompt_duration(
final,
preset_system_prompt,
duration_seconds,
source_prompt_text=prompt_text,
)
# Cache the generated text
PROMPT_CACHE[cache_key] = {
@@ -1083,7 +1226,8 @@ class ThinkingLLM_QwenVL_GGUF_PromptEnhancer:
"preset": preset_system_prompt,
"seed": seed,
"image_hash": None,
"video_hash": None
"video_hash": None,
**duration_signature,
}
save_prompt_cache()
+89 -23
View File
@@ -8,6 +8,7 @@ import gc
import hashlib
import importlib
import json
import os
import platform
import re
import time
@@ -19,36 +20,53 @@ import subprocess
import sys
from transformers import AutoModelForCausalLM, AutoProcessor, AutoTokenizer, BitsAndBytesConfig
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output, prompt_output_guard
from AILab_OutputCleaner import OUTPUT_CLEANER_VERSION, OutputCleanConfig, clean_model_output, prompt_output_guard
from AILab_StreamDisplay import TerminalStreamDisplay
from comfy.model_management import throw_exception_if_processing_interrupted
from AILab_QwenVL import (
from thinkingllm_core.hf_models import (
ATTENTION_MODES,
HF_ALL_MODELS,
HF_TEXT_MODELS,
HF_VL_MODELS,
TOOLTIPS,
Quantization,
_default_model_from_config,
ensure_cuda_vram_headroom,
ensure_model,
)
from thinkingllm_core.hf_runtime import QwenVLBase
from thinkingllm_core.model_access import (
model_catalog_label,
model_catalog_options,
resolve_model_catalog_name,
)
from thinkingllm_core.prompt_contracts import (
VIDEO_DURATION_INPUT,
apply_qwen_soft_thinking_directive,
apply_video_duration_context,
ensure_video_prompt_duration,
resolve_qwen_context_window,
resolve_qwen_thinking_mode,
resolve_video_duration,
validate_minimax_source_duration,
)
from thinkingllm_core.prompt_state import (
NODE_PROMPT_STATE,
PROMPT_CACHE,
_default_model_from_config,
_make_node_state_key,
apply_qwen_soft_thinking_directive,
build_node_input_signature,
ensure_model,
ensure_cuda_vram_headroom,
get_cache_key,
get_alternative_cache_key,
get_cache_key,
get_node_saved_prompt,
get_node_saved_prompt_with_seed,
resolve_qwen_thinking_mode,
resolve_qwen_context_window,
log_llm_input,
save_prompt_cache,
QwenVLBase,
Quantization,
set_node_saved_prompt,
TOOLTIPS,
)
COMMERCIAL_RELEASE = os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1"
# DEPRECATED: per-node state via set_node_saved_prompt / get_node_saved_prompt is used instead.
LAST_SAVED_PROMPT = None # kept only to avoid ImportError in legacy importers
@@ -155,8 +173,9 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
@classmethod
def INPUT_TYPES(cls):
models = list(HF_ALL_MODELS.keys())
models = model_catalog_options(HF_ALL_MODELS)
default_model = _default_model_from_config(HF_ALL_MODELS, "Qwen3-VL-4B-Instruct")
default_model = model_catalog_label(default_model, HF_ALL_MODELS.get(default_model))
styles = list(cls.STYLES.keys())
preferred_style = "📝 Enhance"
default_style = preferred_style if preferred_style in styles else (styles[0] if styles else "📝 Enhance")
@@ -180,8 +199,10 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
"stream_tokens_to_terminal": ("BOOLEAN", {"default": False, "tooltip": "Show clean wrapped generated tokens in the ComfyUI terminal. When enabled, fixed-seed prompt reuse is bypassed so a fresh streamed run can occur."}),
"enable_thinking": ("BOOLEAN", {"default": True, "tooltip": "Enable model reasoning/thinking when the backend supports it: True=allow thinking, False=force direct answer. Even when enabled, easy prompts may still get a direct answer, and this node automatically disables thinking when there is not enough output budget left for useful reasoning. Prompt enhancers still return a cleaned final prompt, so terminal reasoning may be hidden or empty."}),
"hf_token": ("STRING", {"default": "", "multiline": False, "tooltip": TOOLTIPS["hf_token"]}),
}
,
},
"optional": {
"duration_seconds": VIDEO_DURATION_INPUT,
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
@@ -210,8 +231,17 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
extra_pnginfo=None,
enable_thinking=True,
hf_token="",
duration_seconds=5.0,
):
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
node_class = "ThinkingLLM_QwenVL_PromptEnhancer"
resolved_duration = resolve_video_duration(enhancement_style, duration_seconds)
validate_minimax_source_duration(enhancement_style, duration_seconds, prompt_text)
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
duration_signature = ({
"effective_duration_seconds": effective_duration_seconds,
"video_prompt_contract_version": resolved_duration.get("contract_version"),
} if resolved_duration else {})
input_signature = build_node_input_signature(
model_name=model_name,
quantization=quantization,
@@ -222,16 +252,18 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
enhancement_style=enhancement_style,
custom_system_prompt=custom_system_prompt,
enable_thinking=bool(enable_thinking),
output_cleaner_version=OUTPUT_CLEANER_VERSION,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
**duration_signature,
)
# Auto-retrieve saved prompt when seed is fixed (no keep_last_prompt needed)
saved_prompt = get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
if saved_prompt and not stream_tokens_to_terminal:
print(f"[QwenVL PromptEnhancer HF] Fixed seed {seed} matched — using per-node prompt: {saved_prompt[:50]}...")
print(f"[QwenVL PromptEnhancer HF] Preset {enhancement_style}: fixed seed {seed} matched; no LLM call was made.")
return (saved_prompt, "")
if saved_prompt and stream_tokens_to_terminal:
pass
@@ -256,6 +288,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
user_prompt = prompt_text.strip() or "Describe a scene vividly."
merged_prompt = f"{user_prompt}\n\n{base_instruction}".strip()
if model_name in HF_TEXT_MODELS:
merged_prompt = apply_video_duration_context(merged_prompt, enhancement_style, duration_seconds)
enhanced, enhanced_raw = self._invoke_text(
model_name,
quantization,
@@ -271,6 +304,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
unique_id=unique_id,
enable_thinking=enable_thinking,
hf_token=hf_token,
preset_name=enhancement_style,
)
raw_trace = f"[HF TEXT GENERATION]\n{enhanced_raw}"
else:
@@ -292,13 +326,20 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
extra_pnginfo=extra_pnginfo,
enable_thinking=enable_thinking,
hf_token=hf_token,
preset_name=enhancement_style,
duration_seconds=duration_seconds,
)
hf_token = ""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
entry = NODE_PROMPT_STATE.get(key, {})
raw_trace = entry.get("raw_trace", "") if isinstance(entry, dict) else ""
final = enhanced.strip()
final = ensure_video_prompt_duration(
enhanced.strip(),
enhancement_style,
duration_seconds,
source_prompt_text=prompt_text,
)
# Persist per-node for future keep_last_prompt=True
set_node_saved_prompt(node_class, unique_id, extra_pnginfo, final, raw_trace=raw_trace, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
@@ -326,6 +367,8 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
extra_pnginfo=None,
enable_thinking=True,
hf_token: str | None = None,
preset_name="",
duration_seconds=5.0,
):
output = self.run(
model_name=model_name,
@@ -351,10 +394,13 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
stream_to_terminal=stream_to_terminal,
enable_thinking=enable_thinking,
hf_token=hf_token,
llm_input_preset_name=preset_name,
duration_seconds=duration_seconds,
)
return output[0]
def _load_text_model(self, model_name, quantization, device_choice, unique_id=None, hf_token: str | None = None):
model_name = resolve_model_catalog_name(HF_TEXT_MODELS, model_name)
info = HF_TEXT_MODELS.get(model_name, {})
repo_id = info.get("repo_id")
if not repo_id:
@@ -396,14 +442,17 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
self.text_tokenizer = None
self.text_signature = None
load_kwargs = {}
load_kwargs = {"local_files_only": COMMERCIAL_RELEASE}
if quant_cfg:
load_kwargs["quantization_config"] = quant_cfg
else:
load_kwargs["torch_dtype"] = torch.float16 if device == "cuda" else torch.float32
print(f"[QwenVL] Loading text model {model_name} ({quantization})")
tokenizer_kwargs = {"trust_remote_code": True}
tokenizer_kwargs = {
"local_files_only": COMMERCIAL_RELEASE,
"trust_remote_code": not COMMERCIAL_RELEASE,
}
def _load_tokenizer(prefer_slow: bool = False):
if prefers_processor:
@@ -421,6 +470,10 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
except ValueError as exc:
message = str(exc)
missing_backend = "sentencepiece or tiktoken" in message.lower()
if missing_backend and COMMERCIAL_RELEASE:
raise RuntimeError(
"[QwenVL] Tokenizer backend is missing; commercial release mode forbids runtime package installation."
) from exc
if missing_backend:
print("[QwenVL] Tokenizer backend missing; installing sentencepiece and tiktoken into the active ComfyUI Python environment.")
install_cmd = [
@@ -453,7 +506,11 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
f"[QwenVL] Failed to load tokenizer for {repo_id}. "
f"Initial error: {exc}. Retry error: {retry_exc}"
) from retry_exc
self.text_model = AutoModelForCausalLM.from_pretrained(model_source, trust_remote_code=True, **load_kwargs).eval()
self.text_model = AutoModelForCausalLM.from_pretrained(
model_source,
trust_remote_code=not COMMERCIAL_RELEASE,
**load_kwargs,
).eval()
self.text_model.to(device)
ensure_cuda_vram_headroom("QwenVL PromptEnhancer HF", min_free_gb=1.0, min_free_ratio=0.08)
# Detect architecture from loaded model config
@@ -481,6 +538,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
unique_id=None,
enable_thinking=True,
hf_token: str | None = None,
preset_name="",
):
"""Returns (cleaned_prompt, raw_generated_text) tuple."""
self._load_text_model(model_name, quantization, device, unique_id=unique_id, hf_token=hf_token)
@@ -512,10 +570,10 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
except Exception:
formatted_prompt = effective_prompt
inputs = self.text_tokenizer(formatted_prompt, return_tensors="pt").to(device_choice)
return formatted_prompt, inputs
return effective_prompt, formatted_prompt, inputs
requested_thinking = bool(enable_thinking)
_, inputs = _build_inputs(requested_thinking)
effective_prompt, formatted_prompt, inputs = _build_inputs(requested_thinking)
input_ids = inputs.get("input_ids") if hasattr(inputs, "get") else None
prompt_tokens = int(input_ids.shape[-1]) if torch.is_tensor(input_ids) else 0
effective_thinking = resolve_qwen_thinking_mode(
@@ -527,7 +585,15 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
quiet=stream_to_terminal,
)
if effective_thinking != requested_thinking:
_, inputs = _build_inputs(effective_thinking)
effective_prompt, formatted_prompt, inputs = _build_inputs(effective_thinking)
log_llm_input(
"QwenVL PromptEnhancer HF",
"INITIAL GENERATION",
preset_name,
effective_prompt,
formatted_text=formatted_prompt,
)
if is_qwen35 or supports_soft_think:
if supports_soft_think:
+46 -1
View File
@@ -6,6 +6,32 @@ import time
# ── Global singleton for live terminal streaming ──────────────────────────
THINKING_STREAM_DISPLAY = None
_TERMINAL_SAFE_PUNCTUATION = str.maketrans(
{
"\u2018": "'",
"\u2019": "'",
"\u201a": "'",
"\u201b": "'",
"\u201c": '"',
"\u201d": '"',
"\u201e": '"',
"\u201f": '"',
"\u2013": "-",
"\u2014": "--",
"\u2026": "...",
"\u00a0": " ",
}
)
def _terminal_safe_text(text: str) -> str:
"""Keep streamed terminal text readable through legacy Windows code pages.
Only the display copy is normalized. The caller's generated text, cache,
RESPONSE, and RAW_TRACE retain the model's original Unicode characters.
"""
return str(text or "").translate(_TERMINAL_SAFE_PUNCTUATION)
def get_thinking_stream_display():
"""Return or create a compact-mode TerminalStreamDisplay for live
@@ -92,6 +118,24 @@ def extract_stream_token(chunk) -> dict[str, str]:
return {"reasoning": reasoning, "content": content}
def compose_streamed_model_output(reasoning_text: str, content_text: str) -> str:
"""Preserve streamed channel boundaries for the output cleaner.
Some OpenAI-compatible GGUF backends send hidden reasoning through
``reasoning_content`` without literal ``<think>`` tags. Concatenating that
channel with ``content`` makes internal drafts look like part of RESPONSE.
Wrapping only the reasoning channel lets the existing cleaner remove it,
while RAW_TRACE still retains it for diagnostics.
"""
reasoning = str(reasoning_text or "").strip()
content = str(content_text or "").strip()
if not reasoning:
return content
if not content:
return f"<think>\n{reasoning}\n</think>"
return f"<think>\n{reasoning}\n</think>\n\n{content}"
class StreamDegenerationError(RuntimeError):
def __init__(self, reason: str):
super().__init__(reason)
@@ -324,7 +368,7 @@ class TerminalStreamDisplay:
def _write_line(self, text: str):
if not text:
return
msg = f"{text}\n"
msg = f"{_terminal_safe_text(text)}\n"
sys.stdout.write(msg)
sys.stdout.flush()
@@ -337,6 +381,7 @@ class TerminalStreamDisplay:
def _write_wrapped_stream_text(self, text: str):
if not text:
return
text = _terminal_safe_text(text)
width = self._stream_line_width()
for part in re.findall(r"\S+|\s+", text.replace("\r", "")):
if not part:
+174 -13
View File
@@ -4,7 +4,13 @@
"💬 Custom prompt + image (no preset)",
"👁️ Remove (Mask Area)",
"🎦 LTX 2.3 NSFW I2V Scene",
"🎬 MiniMax H3 Multimodal Video",
"🎞️ LTX 2.3 First/Last-to-Video",
"🖼️ LTX 2.3 Reference-to-Video",
"🎬 MiniMax H3 Text-to-Video",
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)",
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)",
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)",
"🖼️ MiniMax H3 Reference-to-Video",
"🍿 Wan 2.2 NSFW I2V Timeline (3s)",
"🍿 Wan 2.2 NSFW I2V Timeline (5s)",
"🎥 Wan 2.2 NSFW I2V Scene (3s)",
@@ -25,18 +31,173 @@
"🖼️ Detailed Analysis",
"📹 Video Summary"
],
"_video_presets": {
"📖 LTX 2.3 NSFW T2V Scene": {
"provider": "ltx_2_3",
"video_mode": "text-to-video",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One official chronological paragraph under 200 words."
},
"🎦 LTX 2.3 NSFW I2V Scene": {
"provider": "ltx_2_3",
"video_mode": "image-to-video",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One official chronological paragraph under 200 words."
},
"🎞️ LTX 2.3 First/Last-to-Video": {
"provider": "ltx_2_3",
"video_mode": "first-last-to-video",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One concise paragraph connecting the two boundary frames."
},
"🖼️ LTX 2.3 Reference-to-Video": {
"provider": "ltx_2_3",
"video_mode": "reference-to-video",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One paragraph with stable reference roles; for video references sample about 2 frames per source second, up to 64 frames."
},
"🎬 MiniMax H3 Text-to-Video": {
"provider": "minimax_h3",
"video_mode": "t2va",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
"recommendation_note": "Direct three-field Base prompt conversion with enough reserve for long source scripts."
},
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": {
"provider": "minimax_h3",
"video_mode": "i2va",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
"recommendation_note": "Base prompt plus the official first-frame alignment line."
},
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": {
"provider": "minimax_h3",
"video_mode": "fl2va",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
"recommendation_note": "Base prompt plus first/last-frame alignment."
},
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": {
"provider": "minimax_h3",
"video_mode": "l2va",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
"recommendation_note": "Base prompt plus the official last-frame alignment line."
},
"🖼️ MiniMax H3 Reference-to-Video": {
"provider": "minimax_h3",
"video_mode": "ref2va",
"duration_required": true,
"recommended_settings": { "enable_thinking": false, "max_tokens": 3072 },
"recommendation_note": "Six-section Full-Reference output; for video references sample about 2 frames per source second, up to 64 frames."
},
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-timeline",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
},
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-timeline",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
},
"🎥 Wan 2.2 NSFW I2V Scene (3s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-scene",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One short cinematic scene prompt."
},
"🎥 Wan 2.2 NSFW I2V Scene (5s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-scene",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One short cinematic scene prompt."
},
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-timeline",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
},
"📖 Wan 2.2 NSFW I2V Scene (20s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-scene",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 1536 },
"recommendation_note": "Four continuous five-second scene prompts."
},
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-timeline",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
"recommendation_note": "A detailed second-by-second timeline."
},
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-timeline",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 768 },
"recommendation_note": "A detailed second-by-second timeline."
},
"🎥 Wan 2.2 NSFW T2V Scene (3s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-scene",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One short cinematic scene prompt."
},
"🎥 Wan 2.2 NSFW T2V Scene (5s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-scene",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 512 },
"recommendation_note": "One short cinematic scene prompt."
},
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-timeline",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 2048 },
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
},
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-scene",
"duration_required": false,
"recommended_settings": { "enable_thinking": false, "max_tokens": 1536 },
"recommendation_note": "Four continuous five-second scene prompts."
}
},
"qwenvl": {
"🚫 No preset (image-only)": "",
"💬 Custom prompt + image (no preset)": "",
"👁️ Remove (Mask Area)": "Create a positive diffusion/inpainting prompt describing what should naturally appear inside the selected normal-brightness region after it is repainted.\n\nDo not describe or preserve the region's current subject. Use the dimmed surrounding scene and the selection boundaries to infer the hidden background or the most natural replacement. Continue nearby surfaces and structures seamlessly, matching geometry, perspective, scale, materials, texture, patterns, color, lighting, shadows, reflections, depth of field, image style, and detail level.\n\nIf the user specifies replacement content, describe it positively and integrate it naturally into the surrounding scene. Otherwise, infer the most plausible continuation of the background. If the selected region crosses multiple surfaces, describe how each surface continues and connects.\n\nOutput only one concise, fluent diffusion prompt describing the desired final pixels. Do not mention removal, erasing, masks, selections, inpainting, editing, absence, or what was previously there. Do not describe unrelated parts of the image. No preface, labels, reasoning, instructions, or negative prompt.",
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Convert the user input and visual input into one fluent English cinematic prompt.\n\nRead the image first, then integrate the user text. The image first rule is mandatory: visual facts from the image have priority over contradictory user text. Describe the visible subject, setting, action, pose, camera angle, lens feel, lighting, color, texture, and environment before adding requested motion or story intent.\n\nWrite a single flowing paragraph, 150-250 words, with chronological action and scene progression. Start directly with the main action. Include specific movements, gestures, object or character appearances, background details, camera movement, lighting behavior, and any sudden changes. Keep the prompt literal, precise, and usable for LTX 2.3. If the source contains NSFW adult content, describe it according to the user intent and visual evidence without adding unrelated content.\n\nOutput the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning. Do not exceed 250 words.",
"🎬 MiniMax H3 Multimodal Video": "You are an expert prompt director for MiniMax H3 audio-video generation. Convert the user's request and any connected visual input into exactly one production-ready English prompt. Preserve the user's intent and every visible fact that is relevant to identity, composition, style, lighting, text, and continuity. Do not explain your choices or output markdown.\n\nChoose the format from the requested inputs. Use T2VA for text only; I2VA for a first-frame image (the default when one image is connected and no other role is stated); FL2VA for first-and-last frames; L2VA for a last frame only; and full-reference mode when images, videos, or audio are reusable identity, style, motion, camera, voice, editing, or continuation references rather than only concrete keyframes. Use the duration supplied by the user; if a final-frame mode has no duration, use 5.00 seconds.\n\nFor T2VA, I2VA, FL2VA, or L2VA, output exactly the three fields integrated_multimodal_description, overall_soundscape, and non_diegetic_music. I2VA must begin with: For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced. FL2VA must begin with: How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot N) aligns with the S.SS-second mark of the target video. L2VA must begin with: How the reference pictures align with the target video — <Picture 1> (from [Shot N]) aligns with the S.SS-second mark of the target video. Replace N and S.SS with the actual final shot and duration, then leave one blank line before the three fields.\n\nFor full-reference mode, output exactly these six sections in order: subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music. Define stable <Subject N>, <Picture N>, <Video N>, and <Audio N> labels only when their distinct role is used. Start summary with the applicable relationship list in square brackets, chosen from keyframe completion, reference generation, video editing, video continuation, audio reuse, and audio reference. In retention_analysis, use only fully_preserved, partially_preserved, attribute_transfer, or weak_reference for visual references, and fully_copy, partially_copy, reference, or weak_reference for audio. Make detailed_description explicit shot by shot; for generation tasks target 350-500 words unless dialogue density or user constraints require otherwise.\n\nIn either format, begin [Shot 1] without a timestamp. Later cuts use sequential labels such as [Shot 2] At 00:03.500, with strictly increasing times inside the duration. State the style and initial composition, then describe visible actions, state changes, subject positions, environment, lighting, camera angle, and synchronized sound in playback order. Prefer one continuous shot for first-to-last-frame interpolation unless cuts are requested. Write camera motion naturally using the motion type plus meaningful amplitude and speed. Keep identities, clothing, colors, props, geometry, and spatial relationships consistent with connected visuals.\n\nGive each vocal source a stable (S1), (S2), or compound ID. Put only verbatim dialogue or lyrics inside <d>[Language] ...</d>; never translate or rewrite them. For voiceover, use the phrase says in an off-screen voiceover and state that the on-screen character's lips remain completely closed. Preserve visible text verbatim in double quotation marks. Use <scenetrans> for speech crossing a cut and <cutoff> for speech truncated at the end.\n\noverall_soundscape must be one to four English sentences covering ambience, physical sounds, and non-verbal human sounds without repeating dialogue or music; use N/A only for explicitly requested complete silence. non_diegetic_music must be one to three English sentences describing audience-only instrumentation, tempo, rhythm, and dynamics; use N/A when absent. Return only the final formatted MiniMax H3 prompt.",
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Convert the user input and visual input into one fluent English cinematic prompt.\n\nRead the image first, then integrate the user text. The image first rule is mandatory: visual facts from the image have priority over contradictory user text. Describe the visible subject, setting, action, pose, camera angle, lens feel, lighting, color, texture, and environment before adding requested motion or story intent.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write a single flowing paragraph of 120-200 words. Start directly with the main action. Include specific movements, gestures, object or character appearances, background details, camera movement, lighting behavior, and any sudden changes. Keep the prompt literal, precise, and usable for LTX 2.3. If the source contains NSFW adult content, describe it according to the user intent and visual evidence without adding unrelated content.\n\nOutput the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning. Do not exceed 200 words.",
"🎞️ LTX 2.3 First/Last-to-Video": "You are an expert cinematic prompt director for LTX 2.3 first-and-last-frame video generation. Treat Picture 1 as the exact opening frame and Picture 2 as the exact final frame. Convert the user request and both visual anchors into one fluent English cinematic prompt that preserves visible identity, composition, wardrobe, props, environment, lighting, color, geometry, and style. Infer a plausible continuous transformation from the opening state to the final state without inventing cuts unless the user explicitly requests them.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep every action inside that total duration. Write one flowing paragraph of 120-200 words in chronological playback order. Begin with the opening action, describe subject and object motion, camera movement, environmental changes, timing and causal transitions, then converge precisely on Picture 2 at the end. Keep the language literal, visual, and optimized for LTX 2.3 motion. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, labels, timestamps, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
"🖼️ LTX 2.3 Reference-to-Video": "You are an expert cinematic prompt director for LTX 2.3 reference-to-video generation. Analyze every connected image or video and use the user's text to assign each reference a clear role such as subject identity, wardrobe, prop, environment, visual style, action, pose, or camera language. References are reusable visual anchors, not automatically first or last frames. Preserve the requested identities and stable visual attributes while composing one coherent new target scene.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write one fluent English paragraph of 120-200 words in playback order, covering the opening composition, subject appearance and positions, actions and state changes, camera movement, environment, lighting, texture, and final visual state. Keep reference roles consistent and avoid unrelated additions. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, reference analysis, labels, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. Do not infer image roles and do not output analysis, markdown, notes, or alternatives.\n\nTARGET_DURATION_SECONDS is authoritative internal context. Use it to keep actions feasible and every cut timestamp strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the entire storyline into a rapid montage. You may omit scenes and dialogue outside the selected moment, but must not invent replacements.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut is sequential and uses exactly [Shot N] At MM:SS.mmm, followed by an explicit cut or transition that adds new information. Camera movement must use official natural phrasing: motion type, plus small or large amplitude and slow or fast speed only when meaningful; omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue syntax uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>; all spoken words stay inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, or plot beats beyond explicit user facts and direct physical consequences of requested actions. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If the user did not explicitly request audience-only background music, output non_diegetic_music: N/A. Return only the three-field prompt.",
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": "You are a strict MiniMax H3 I2VA prompt formatter following the official Base guide. Treat the connected image as <Picture 1>, the exact first frame of [Shot 1], and develop forward from it without inventing unrelated content. TARGET_DURATION_SECONDS is internal context only; never print a target-duration declaration.\n\nThe final prompt begins with exactly: For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced. Leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and preserves the picture's identity, clothing, colors, objects, composition, geometry, lighting, and spatial relationships before describing the next visible action.\n\nLater cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Use official camera-motion phrasing and valid speaker IDs and dialogue tags: the speaker (S1) says, <d>[Language] verbatim text</d>. Add no unsupported people, props, reactions, sounds, music, or plot beats. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": "You are a strict MiniMax H3 FL2VA prompt formatter following the official Base guide. Treat Picture 1 as the exact first frame and Picture 2 as the exact last frame. Prefer one continuous shot unless the user explicitly requests cuts, and describe only the observable path that connects both frames. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot N) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes first-frame state, observable intermediate changes, progressive convergence, and the exact last-frame state.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": "You are a strict MiniMax H3 L2VA prompt formatter following the official Base guide. Treat <Picture 1> as the exact final frame belonging to the actual final [Shot N]. Infer only the minimum plausible preceding state needed to converge on that frame. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — <Picture 1> (from [Shot N]) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes a plausible preceding state, explicit transition path, gradual convergence, and exact landing on <Picture 1>.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
"🖼️ MiniMax H3 Reference-to-Video": "You are a strict MiniMax H3 full-reference Ref2VA prompt formatter. Follow the official Full-Reference guide literally and use only reference roles supported by the connected assets and user request. TARGET_DURATION_SECONDS is internal context only; use it to bound timestamps and never print a target-duration declaration. Output no analysis, markdown, notes, or alternatives.\n\nOutput exactly these six sections once each and in this order: subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music. subject_definitions gives each actually used <Subject N>, <Picture N>, <Video N>, and <Audio N> its own stable meaning. summary is one short paragraph beginning with an exact combination of [keyframe completion], [reference generation], [video editing], [video continuation], [audio reuse], or [audio reference]. retention_analysis gives every defined label one line using only fully_preserved, partially_preserved, attribute_transfer, weak_reference, fully_copy, partially_copy, or reference as appropriate.\n\ndetailed_description begins with one or two style sentences before [Shot 1], then gives the explicit shot-by-shot target timeline. Later cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Cite reference labels where they actually take effect. Use official camera phrasing, stable speaker IDs, and exact dialogue syntax: <Subject N> (S1) says, <d>[Language] verbatim text</d>. Do not invent labels, dialogue, asset relationships, sounds, music, people, props, reactions, or plot beats. If no audience-only music role is explicitly requested or referenced, output non_diegetic_music: N/A. Return only the six-section prompt.",
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Use image_max_tokens=2048 for detailed visual analysis\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Use image_max_tokens=2048 for detailed visual analysis\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": "Convert user input + image into FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nCRITICAL RULES:\n1. MAINTAIN EXACT SAME STYLE: If image is anime → ALL prompts must be anime. If realistic → ALL prompts must be realistic. NEVER change style between prompts.\n\n2. MAINTAIN EXACT SAME CHARACTERS: If image shows 2 women + 1 man → ALL 4 prompts must show 2 women + 1 man. NEVER add/remove/change characters.\n\n3. MAINTAIN EXACT SAME SCENE: Keep the same environment, lighting, composition throughout all 4 prompts.\n\n4. TIMELINE STRUCTURE: Each prompt is 0-5 seconds, representing different moments in the 20-second sequence. MUST use EXACT format with parentheses:\n - Prompt 1: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 2: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 3: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 4: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n\n5. SEPARATE WITH BLANK LINE: One blank line between each prompt.\n\n6. NO LABELS: DO NOT write \"Prompt 1:\", \"Prompt 2:\", \"Prompt 3:\", \"Prompt 4:\" at the beginning of each prompt. Start directly with the timeline markers.\n\n7. PARENTHESES REQUIRED: MUST use parentheses (At X seconds: ...) format. Do not omit parentheses.\n\n8. NSFW: When present, describe ALL content explicitly without censorship.\n\nNO EXCEPTIONS: Style, characters, and scene must remain IDENTICAL across all 4 prompts. Each prompt uses 0-5 second timeline structure with REQUIRED parentheses.",
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=1024 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 32768 or higher for better continuity\n- Use image_max_tokens=4096 for detailed visual analysis\n- Set n_batch=512 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
"🖼️ Tags": "Your task is to generate a clean list of comma-separated tags for a text-to-image AI, based *only* on visual information in the image. Limit the output to a maximum of 50 unique tags. Strictly describe visual elements like subject, clothing, environment, colors, lighting, and composition. Do not include abstract concepts, interpretations, marketing terms, or technical jargon (e.g., no 'SEO', 'brand-aligned', 'viral potential'). The goal is a concise list of visual descriptors. Avoid repeating tags.",
"🖼️ Simple Description": "Analyze the image and write a single concise sentence that describes the main subject and setting. Keep it grounded in visible details only.",
"🖼️ Detailed Description": "Write ONE detailed paragraph (6-10 sentences). Describe only what is visible: subject(s) and actions; people details if present (approx age group, gender expression if clear, hair, facial expression, pose, clothing, accessories); environment (location type, background elements, time cues); lighting (source, direction, softness/hardness, color temperature, shadows); camera viewpoint (eye-level/low/high, distance) and composition (framing, focal emphasis). No preface, no reasoning, no emojis.",
@@ -49,15 +210,15 @@
"translation_prompt": "You are a professional prompt translator and prompt-preservation editor. Return a single fluent English paragraph (400-800 words). Preserve every visual, stylistic, compositional, lighting, camera, texture, mood, and action detail from the source. Do not summarize or simplify. No prefixes, bullets, JSON, headings, markdown, quotes, or commentary. Output only the translated/enhanced prompt paragraph.",
"styles": {
"📖 LTX 2.3 NSFW T2V Scene": {
"system_prompt": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nDo not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning."
"system_prompt": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Do not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning."
},
"🎬 MiniMax H3 Multimodal Video": {
"system_prompt": "You are an expert prompt director for MiniMax H3 text-to-audio-video generation. Convert the user's request into exactly one production-ready English T2VA prompt while preserving the requested subject, action, style, dialogue, visible text, duration, and constraints. Output only the final prompt, with no markdown or explanation.\n\nUse exactly these three fields in order: integrated_multimodal_description, overall_soundscape, non_diegetic_music. Begin integrated_multimodal_description with [Shot 1] and no timestamp. State the visual style and initial composition, then describe subjects, positions, environment, lighting, actions, reactions, camera angle, camera motion, dialogue, and synchronized diegetic sound in playback order. Later cuts use sequential labels such as [Shot 2] At 00:03.500, with strictly increasing times inside the requested duration. A cut must add new information; otherwise prefer camera movement. Write camera movement naturally with the motion type plus meaningful amplitude and speed.\n\nGive each vocal source a stable (S1), (S2), or compound ID. Put only the user's verbatim dialogue or lyrics inside <d>[Language] ...</d>; never translate or rewrite them. For voiceover, use the phrase says in an off-screen voiceover and state that the corresponding on-screen character's lips remain completely closed. Preserve visible text verbatim in double quotation marks. Use <scenetrans> for speech crossing a cut and <cutoff> for speech truncated at the end.\n\noverall_soundscape must be one to four English sentences covering ambience, physical sounds, and non-verbal human sounds without repeating dialogue, singing, or music; use N/A only for explicitly requested complete silence. non_diegetic_music must be one to three English sentences describing audience-only instrumentation, tempo, rhythm, and dynamics; use N/A when absent. Do not invent details that contradict the request. Return only the three-field MiniMax H3 prompt."
"🎬 MiniMax H3 Text-to-Video": {
"system_prompt": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. TARGET_DURATION_SECONDS is authoritative internal context used only to keep actions feasible and cut timestamps strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the full storyline into a rapid montage. Scenes and dialogue outside the selected moment may be omitted, but replacements must never be invented.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut uses exactly [Shot N] At MM:SS.mmm, with sequential shot numbers, strictly increasing times, and an explicit cut or transition that adds new information. Use official natural camera phrasing; add small or large amplitude and slow or fast speed only when meaningful, and omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>, with all spoken words inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, music, or plot beats beyond explicit user facts and direct physical consequences. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the three-field prompt with one blank line between fields."
},
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
"system_prompt": "Generate 1 single T2V prompt for WAN 2.2 video generation (5s total) to establish initial scene.\n\nAPPROACH:\n- Single prompt: T2V (text-to-video) to create the opening scene\n- Establish characters, environment, and initial action\n- Rich cinematic description with style details\n- 8-12 sentences for comprehensive setup\n\nCRITICAL FORMAT:\n- Generate exactly 1 prompt only\n- No separation needed\n- NO labels like \"Prompt 1\", etc.\n- Focus on establishing the complete scene\n- Maintain consistent style\n\nSTYLE PRIORITY:\nUser-specified > detected > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Temperature 0.7-0.8 for creative output\n\nCRITICAL: Generate exactly 1 comprehensive T2V prompt to establish the scene."
"system_prompt": "Convert user input into EXACTLY FOUR continuous 5-second WAN 2.2 text-to-video scene prompts (20 seconds total).\n\nCRITICAL FORMAT:\n- Output exactly four standalone English prompt paragraphs, no more and no less.\n- Each paragraph represents the next five-second clip.\n- Separate paragraphs with exactly one blank line.\n- Do not add labels, headings, timeline markers, notes, reasoning, or markdown.\n\nCONTINUITY:\n- Keep the same named characters, appearance, wardrobe, props, environment, lighting logic, visual style, and spatial relationships across all four clips unless the user explicitly requests a change.\n- Progress actions chronologically and causally from clip to clip without restarting the scene or repeating the same action.\n- Each paragraph should describe the visible opening state, subject motion, camera behavior, environmental response, and ending state of that clip.\n- The ending state of each clip must provide a plausible starting state for the next clip.\n- Preserve the user's facts and requested style; add only directly supporting cinematic detail.\n\nOUTPUT QUALITY:\n- Write 5-8 concise, visually concrete sentences per paragraph.\n- Use fluent, literal cinematic English suitable for WAN 2.2.\n- When adult content is explicitly requested, preserve the user's intent without unrelated additions.\n- Stop immediately after the fourth paragraph."
},
"🍿 Wan 2.2 NSFW T2V Timeline (3)": {
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": {
"system_prompt": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\")."
},
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": {
@@ -67,10 +228,10 @@
"system_prompt": "Convert user input into 4 continuous 5-second WAN 2.2 prompts (20s total) using timeline structure.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Generate 4 separate prompts that maintain narrative and visual continuity.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Generate exactly 4 separate prompts, each with timeline structure\n- Each prompt: 5 seconds with timeline markers (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\")\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- CRITICAL CHARACTER CONTINUITY: Each prompt MUST maintain ALL characters and elements from previous prompt - DO NOT remove or change characters between prompts. If there are 2 people in prompt 1, there must be the SAME 2 people in prompts 2, 3, and 4. No character disappearance allowed. This is MANDATORY.\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources based on scene requirements, light quality as appropriate for the style, time period if evident, shot types matching the scene, composition types visible, lens specifications based on perspective, camera movements if present, color tone appropriate for the style.\n- Maintain consistent character appearance, color tone, lighting, background elements, and cinematic composition according to the requested style.\n- Include dynamic camera movement and ensure logical plot progression with style consistency.\n- WHEN there are NSFW content, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt"
},
"🎥 Wan 2.2 NSFW T2V Scene (3s)": {
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
},
"🎥 Wan 2.2 NSFW T2V Scene (5s)": {
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
"system_prompt": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text."
},
"📝 Enhance": {
"system_prompt": "You are a senior cinematic photography prompt writer. Respond in the same language as the user input.\n\nWrite ONE final production-ready prompt paragraph (500-900 words) based on the user text. Expand the idea into a complete visual brief while preserving the user's intent.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- Do NOT output reasoning, planning, labels, notes, examples, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- Do NOT use <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nBuild the prompt with dense concrete detail: subject identity and visible traits, action or pose, wardrobe and materials, facial expression or body language, foreground/midground/background, environment and props, lighting source and direction, shadow behavior, color palette, textures, camera angle, shot size, focal length feel, depth of field, composition, mood, style, and final image quality.\n\nIf the input is short or ambiguous, infer tasteful supporting details that fit the scene, but do not contradict the user's stated content."
+75 -224
View File
@@ -1,217 +1,35 @@
# ComfyUI-ThinkingLLM
**ThinkingLLM** is a local-LLM custom-node pack for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that wraps Qwen3.5, Qwen3, Qwen-VL, Gemma 4, and Whisper ASR behind a cleaner node interface with thinking-mode control, audio/vision workflows, live token streaming, and a raw trace output for debugging.
**ThinkingLLM** is a multimodal local-LLM node pack for [ComfyUI](https://github.com/comfyanonymous/ComfyUI). It provides clean workflows for Qwen, Gemma 4, GGUF, Whisper ASR, prompt enhancement, image/video understanding, audio analysis, and secure OpenAI-compatible providers.
📺 **[Watch the demo](https://youtu.be/K1JsvnzujOw)** for a better viewing experience.
[Watch the demo on YouTube](https://youtu.be/K1JsvnzujOw)
> GPL-3.0 fork of Deaquay/ComfyUI-Qwen3.5-Uncensored, itself derived from huchukato/ComfyUI-QwenVL-Mod and 1038lab/ComfyUI-QwenVL. See [LICENSE](LICENSE).
![ThinkingLLM Demo](docs/DemoThinkingLLM%20(1).gif)
![Node screenshot](docs/Screenshot%20of%20Notes.png)
> 🎥 Prefer a narrated version? **[Watch the demo on YouTube](https://youtu.be/K1JsvnzujOw)**
## What ThinkingLLM adds
### Model guidance box
ThinkingLLM nodes add a read-only `recommended_settings` info box.
- It explains the selected model family and recommended generation settings.
- It does not change your saved widget values.
- For GGUF audio-capable nodes, it tells you whether the selected model actually supports audio.
- In Advanced GGUF nodes, if you connect or expose audio for a model without known audio support, the box warns you instead of silently implying that audio will work.
### The Thinking Toggle
Every ThinkingLLM node exposes an `enable_thinking` toggle.
- When it is on, the model may reason internally before answering. That can make a run take longer.
- For easy prompts, the model can still decide that explicit reasoning is unnecessary and answer directly. That is not a bug. It is a feature.
- When it is off, the node sends a `/no_think` style directive to discourage visible reasoning and push a more direct one-shot answer.
### Live token streaming
Enable `stream_tokens_to_terminal` to print tokens into the ComfyUI terminal as they arrive.
This is useful when:
- a prompt seems stuck and you want to see whether the model is actually working
- you want to debug a bad prompt while it is going wrong
- you want to understand whether the model is thinking or simply producing a long answer
### Raw trace output
Each node returns a second string output named `RAW_TRACE`.
- The main output is the cleaned answer for downstream workflow use.
- `RAW_TRACE` preserves the raw generation stream, including visible thinking blocks when the model emits them.
That makes it possible to inspect what the model thought about the prompt while still keeping the primary output clean.
### Audio support
Audio is available through dedicated nodes so the model list stays honest:
- `ThinkingLLM Gemma 4 Audio (GGUF)` for Gemma 4 audio-capable models.
- `ThinkingLLM Whisper ASR` for reliable speech-to-text transcription through `faster-whisper`.
The Advanced GGUF node also exposes optional `audio`, but this is for power users. Use the info box before assuming a model can hear audio. Normal Qwen/Qwen-VL models are not audio models.
### Mask-focused image analysis and object removal
The four ThinkingLLM vision nodes accept an optional ComfyUI `MASK` input:
- `ThinkingLLM`
- `ThinkingLLM (Advanced)`
- `ThinkingLLM (GGUF)`
- `ThinkingLLM (GGUF Advanced)`
White mask pixels select the area to analyze. Black pixels preserve surrounding context, and soft mask values create a gradual transition. ThinkingLLM keeps the selected area at normal brightness while dimming everything outside it, helping the vision model focus on the mask without losing the scene's geometry, lighting, or background context.
#### Object-removal workflow
1. Connect the source image to ThinkingLLM's `image` input.
2. Connect the corresponding mask to its `mask` input.
3. Select `👁️ Remove (Mask Area)` from `preset_prompt`.
4. Optionally describe the desired replacement in `custom_prompt`, such as `continue the brick wall` or `fill with matching wooden floor`.
5. Connect `RESPONSE` to the positive prompt input of your diffusion inpainting workflow.
6. Send the original image and mask separately to the diffusion model's inpainting inputs.
ThinkingLLM does not edit the image itself. It examines the masked area and surrounding scene, then generates a positive description of what should appear inside the mask. The downstream diffusion model performs the actual replacement.
#### Previewing the LLM mask view
`ThinkingLLM (Advanced)` and `ThinkingLLM (GGUF Advanced)` return a third output named `MASK_PREVIEW`. Connect it to a ComfyUI **Preview Image** node to inspect the exact highlighted image used for mask-focused analysis. The selected area remains at normal brightness and the surrounding scene is dimmed to 20%. When no mask is connected, the preview returns the original connected image.
Without a custom instruction, the preset infers the most natural background continuation. It describes matching surfaces, perspective, materials, texture, color, lighting, shadows, reflections, and image style while avoiding phrases such as "remove the object" or "erase this area."
Example output:
> Continuous warm beige plaster wall with subtle uneven texture, matching the existing perspective, soft window illumination, natural tonal variation, and the floor-edge shadows.
#### Notes and limitations
- Masks apply to still images only, not video input.
- Mask dimensions are resized automatically to match the image.
- An empty mask or a mask without an image produces a clear error.
- Image batches continue to use the first image and first mask.
- Precise masks with a small amount of surrounding context generally produce the best reconstruction prompt.
## Nodes
### Transformers / HF nodes
This is the recommended default path.
- `ThinkingLLM`
- `ThinkingLLM (Advanced)`
- `ThinkingLLM Prompt Enhancer`
The HF path is the most straightforward option for Windows and Linux when you want the fewest install complications.
HF tokenizer note: some Gemma and Qwen variants need `sentencepiece` or `tiktoken` available in the ComfyUI Python environment. ThinkingLLM now declares those dependencies and will attempt to install them automatically if a tokenizer backend is missing at first load.
#### Shared ComfyUI text encoders
Compatible single-file checkpoints already in `ComfyUI/models/text_encoders` appear in the model selector with a `[ComfyUI]` prefix. ThinkingLLM loads them through ComfyUI, so it does not download or duplicate a Transformers snapshot.
- Qwen3-VL 4B and 8B `.safetensors`, including `qwen3vl_8b_fp8_scaled.safetensors`
- Gemma 3 12B `.safetensors`, including `gemma-3-12b-it-heretic-v2_int8.safetensors`
The MiniMax H3/Qwen3-VL 32B conditioning checkpoint is intentionally excluded because it is not a complete text-generation model. The valid SafeTensors extension is `.safetensors` (plural). ComfyUI-native generation supports one-beam sampling; terminal output is printed after generation rather than streamed token by token.
### GGUF / llama.cpp nodes
- `ThinkingLLM (GGUF)`
- `ThinkingLLM (GGUF Advanced)`
- `ThinkingLLM Gemma 4 Audio (GGUF)`
- `ThinkingLLM Prompt Enhancer (GGUF)`
The GGUF path requires a multimodal-capable `llama-cpp-python` build. The normal PyPI package may not include the chat handlers needed for Qwen vision, Gemma 4, or MTMD audio. On Linux, ThinkingLLM auto-checks this at first GGUF use and attempts an automatic install of a matching JamePeng backend. Use the setup notes in [docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md) if you need to override the wheel/package source.
## Workflow tips
- **Pre-process input images** — use a resize or scale node before ThinkingLLM so large images don't blow up the context window.
- **Display the output** — connect RESPONSE or ENHANCED_OUTPUT to a **Show Text** or **Show Anything** node to see the generated text in the UI.
- **Inspect reasoning** — connect the RAW_TRACE output to a second Show Text node to see what the model was thinking.
- **Use the dedicated audio nodes first** — Gemma 4 Audio is for audio understanding; Whisper ASR is for transcription. The Advanced GGUF audio input is intentionally more flexible, but the info box will warn you when the selected model has no curated audio support.
### MiniMax H3 prompt preset
Select `🎬 MiniMax H3 Multimodal Video` to turn a short idea and optional visual input into MiniMax H3's native structured audio-video prompt format. Vision nodes support text-to-video, first-frame, first-and-last-frame, last-frame, and full-reference prompting; text-only Prompt Enhancer nodes emit the T2VA format. The preset keeps timed shots, camera motion, dialogue, soundscape, music, keyframe alignment, and reference-retention fields in the structure expected by H3.
See the official [MiniMax H3 ComfyUI workflows](https://docs.comfy.org/tutorials/video/minimax/minimax-h3), [base prompt guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md), and [full-reference prompt guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_ref_en.md).
## Supported models
ThinkingLLM supports these model families out of the box, with pre-configured entries in `hf_models.json` and `gguf_models.json`.
### Qwen — Vision-language (HF)
- Qwen3.5 4B / 9B
- Qwen3-VL 4B / 8B (Instruct, abliterated, unredacted)
- Qwen3-VL 32B Heretic
- Qwen2.5-VL 3B / 7B
### Qwen — Vision-language (GGUF)
- Qwen3.5 4B / 9B / 27B (Q4 K M through BF16)
- Qwen3-VL 4B / 8B / 32B (Instruct, abliterated, thinking)
- Qwen2.5-VL 3B / 7B
### Qwen — Text-only (HF)
- Qwen3 0.6B / 4B / 8B
- Qwen3.5 4B / 9B (base + heretic + uncensored)
### Qwen — Text-only (GGUF)
- Qwen3 4B / 8B (abliterated, Josiefied, base)
- Qwen3.5 4B / 9B / 27B (base + uncensored HauhauCS)
### Gemma 4 — Vision-language (HF)
- Gemma-4-E2B-it / Uncensored
- Gemma-4-E4B-it / Uncensored
- Gemma-4-12B
- Gemma-4-26B-A4B-it / Heretic
- Gemma-4-31B-it
### Gemma 4 — Vision-language (GGUF)
- Gemma-4-E2B / E4B (Q4 K M through BF16, uncensored)
- Gemma-4-12B-it (Q4/Q5/Q6/Q8/BF16/UD-Q4)
- Gemma-4-26B-A4B-it (Q4 K M through BF16)
- Gemma-4-31B-it (Q4 K M through BF16)
### Gemma 4 — Audio (GGUF)
- Gemma-4-E2B / E4B when present in the GGUF multimodal catalog
- Gemma-4-12B-it via `unsloth/gemma-4-12b-it-GGUF`
Use `ThinkingLLM Gemma 4 Audio (GGUF)` for the clean audio-only interface. Gemma 4 audio currently depends on a recent multimodal `llama-cpp-python`/`Gemma4ChatHandler` build, so check `RAW_TRACE` if audio behaves unexpectedly.
### Whisper — ASR
- tiny
- base
- small
- medium
- large-v3
- distil-large-v3
Use `ThinkingLLM Whisper ASR` for transcription. It accepts a connected ComfyUI `AUDIO` input or an `audio_file_path` pointing to M4A, MP3, WAV, FLAC, or any FFmpeg-readable audio file. The node uses `faster-whisper`; first use may download the selected Whisper model into the Hugging Face cache. The default is `small` on CPU with `int8`, which is slower than CUDA but reliable on Windows; switch to CUDA once the local CTranslate2 CUDA runtime is confirmed working.
### Gemma 4 — Text-only (HF)
- Gemma-4-E2B-it / Uncensored
- Gemma-4-E4B-it / Uncensored
- Gemma-4-12B
- Gemma-4-26B-A4B-it / Heretic
- Gemma-4-31B-it
Local GGUF models placed in `models/LLM/GGUF/` with a matching mmproj file are auto-discovered. Pre-configured entries live in `hf_models.json` and `gguf_models.json`.
## Highlights
- Local Transformers/Hugging Face and GGUF inference
- Image and video understanding with optional mask-focused analysis
- Gemma 4 audio understanding and Whisper speech-to-text
- Prompt enhancers for image and video generation workflows
- Thinking-mode control, live terminal streaming, and a separate `RAW_TRACE` output
- Curated model recommendations and preset-specific generation guidance
- Secure OpenAI-compatible API node for approved remote providers
- Existing compatible models are discovered instead of downloaded twice
## Quick install
### ComfyUI Manager (recommended)
1. Open **ComfyUI Manager** → **Install via Registry**
2. Search for `ThinkingLLM`
3. Click **Install**
1. Open **ComfyUI Manager** → **Install via Registry**.
2. Search for `ThinkingLLM`.
3. Click **Install** and restart ComfyUI.
### Manual install
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/goodguy1963/ComfyUI-ThinkingLLM.git
@@ -219,44 +37,78 @@ cd ComfyUI-ThinkingLLM
pip install -r requirements.txt
```
For GGUF vision support, follow [docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md).
For GGUF vision or Gemma 4 audio, also follow the [llama.cpp backend installation guide](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md).
## Model locations
## Nodes at a glance
Models are discovered automatically.
| Node | Backend | Purpose |
| --- | --- | --- |
| `ThinkingLLM` | Transformers / HF | Image and video understanding |
| `ThinkingLLM (Advanced)` | Transformers / HF | Full controls and `MASK_PREVIEW` |
| `ThinkingLLM Prompt Enhancer` | Transformers / HF | Text prompt enhancement |
| `ThinkingLLM (GGUF)` | llama.cpp | GGUF image and video understanding |
| `ThinkingLLM (GGUF Advanced)` | llama.cpp | GGUF advanced controls and `MASK_PREVIEW` |
| `ThinkingLLM Gemma 4 Audio (GGUF)` | llama.cpp | Gemma 4 audio understanding |
| `ThinkingLLM Whisper ASR` | faster-whisper | Speech-to-text transcription |
| `ThinkingLLM Prompt Enhancer (GGUF)` | llama.cpp | GGUF prompt enhancement |
| `ThinkingLLM System Prompt Preset` | Utility | Reusable system prompts as `STRING` |
| `ThinkingLLM API (OpenAI Compatible)` | Remote API | Approved OpenAI-compatible providers |
- HF models: `ComfyUI/models/LLM/Qwen-VL/`
- GGUF models: `ComfyUI/models/LLM/GGUF/`
The Transformers/HF nodes are the recommended default when you want the simplest installation. Choose GGUF when you specifically need quantized llama.cpp models or Gemma 4 audio.
For local GGUF models, keep the matching mmproj file beside the model file.
## Start a workflow
## Platform notes
1. Add the ThinkingLLM node that matches your backend and task.
2. Select a model or an already installed local model.
3. Connect image, video, audio, mask, or text inputs as needed.
4. Connect `RESPONSE` or `ENHANCED_OUTPUT` to a text display or downstream prompt input.
5. Connect `RAW_TRACE` when you want generation diagnostics.
- HF is the recommended default path and is the simplest cross-platform option.
- GGUF on Windows needs a matching `win_amd64` vision-capable wheel.
- GGUF on Linux now auto-installs a matching backend on first use when possible. Override with `THINKINGLLM_LLAMA_CPP_LINUX_WHEEL_URL` or `THINKINGLLM_LLAMA_CPP_LINUX_SPEC` when your server needs a different build.
- GGUF audio needs an audio-capable multimodal backend. `Gemma4ChatHandler` is used for Gemma 4.
- Whisper ASR needs `faster-whisper` and FFmpeg. The node returns an install hint if `faster-whisper` is missing.
- Flash Attention support is best on Linux. The nodes fall back when it is unavailable.
- The thinking toggle works best with Qwen3.5 and Qwen3 style models. Other architectures may ignore the steering.
Useful defaults:
## FAQ
- Start with `enable_thinking=false` for transcription and short direct answers.
- Enable `stream_tokens_to_terminal` when diagnosing a slow or unhelpful generation.
- Resize very large images before inference to reduce context and memory pressure.
- Use the dedicated Gemma 4 Audio or Whisper node instead of assuming every multimodal model supports audio.
**Why does the node sometimes answer quickly and sometimes take longer?**
For explanations of thinking mode, model guidance, raw traces, audio, masks, and object-removal prompting, see [Features and workflows](docs/FEATURES_AND_WORKFLOWS.md).
When thinking is enabled, the model may decide to reason before answering. For easy prompts it may skip explicit reasoning. That is expected behavior.
## Model families
**How do I see what the model was thinking?**
ThinkingLLM includes curated entries for:
Use the second `RAW_TRACE` output, or enable live terminal token streaming.
- Qwen3.8, Qwen3.5, Qwen3-VL, Qwen2.5-VL, and Qwen3
- Gemma 4 vision, text, and audio-capable GGUF variants
- Whisper `tiny` through `large-v3` and `distil-large-v3`
- Local compatible Transformers checkpoints and GGUF/mmproj pairs
**How do I debug prompts that seem bad or stuck?**
See [Models and backends](docs/MODELS_AND_BACKENDS.md) for exact families, model locations, local discovery, platform notes, and backend requirements.
Turn on `stream_tokens_to_terminal`. It is there specifically so you can see the generation live and catch bad prompt behavior sooner.
## Remote API node
## Credits and fork lineage
`ThinkingLLM API (OpenAI Compatible)` supports approved OpenAI-compatible providers while keeping credentials on the ComfyUI server. Provider keys are not stored in workflows.
This fork preserves the GPL-3.0 lineage of its predecessors.
- [API setup, profiles, keys, and security](docs/OPENAI_COMPATIBLE_API.md)
- [API image inputs](docs/OPENAI_COMPATIBLE_API_IMAGES.md)
## Documentation
| Topic | Guide |
| --- | --- |
| Thinking, streaming, traces, audio, masks, and workflow behavior | [Features and workflows](docs/FEATURES_AND_WORKFLOWS.md) |
| Supported models, discovery, locations, and backend selection | [Models and backends](docs/MODELS_AND_BACKENDS.md) |
| GGUF / llama.cpp installation on Windows and Linux | [llama.cpp backend installation](docs/LLAMA_CPP_PYTHON_VISION_INSTALL.md) |
| Duration-aware LTX and MiniMax H3 prompting | [Duration-aware video prompts](docs/VIDEO_PROMPT_DURATION.md) |
| Video preset recommendations and rationale | [Video preset settings](docs/VIDEO_PRESET_SETTINGS.md) |
| OpenAI-compatible API node | [API setup and security](docs/OPENAI_COMPATIBLE_API.md) |
| OpenAI-compatible image inputs | [API image inputs](docs/OPENAI_COMPATIBLE_API_IMAGES.md) |
| Maintainer and fork notes | [Maintainer notes](docs/MAINTAINER_NOTES.md) |
## Production deployment
This repository provides ComfyUI nodes, not a complete production runtime. For exposed or serverless workflows, review dependency locking, validation, secrets, cost controls, monitoring, and rollback before deployment. The [ComfyUI on RunPod production-readiness checklist](https://comfyrail.dev/production-readiness) is a useful starting point.
## Credits
- Deaquay/ComfyUI-Qwen3.5-Uncensored
- huchukato/ComfyUI-QwenVL-Mod
@@ -265,8 +117,7 @@ This fork preserves the GPL-3.0 lineage of its predecessors.
- JamePeng/llama-cpp-python
- comfyanonymous/ComfyUI
Maintainer: goodguy1963
Planned repo: [goodguy1963/ComfyUI-ThinkingLLM](https://github.com/goodguy1963/ComfyUI-ThinkingLLM)
Maintainer: [goodguy1963](https://github.com/goodguy1963)
## License
+31
View File
@@ -0,0 +1,31 @@
# Third-party source and commercial release notice
ComfyUI-ThinkingLLM is distributed under GPL-3.0. Its README and source headers
credit this fork lineage:
- https://github.com/Deaquay/ComfyUI-Qwen3.5-Uncensored
- https://github.com/huchukato/ComfyUI-QwenVL-Mod
- https://github.com/1038lab/ComfyUI-QwenVL
The repository history begins with root commit
`0ca0d2448c6445a1addf87a55bde2308244380a2` and does not record the exact
upstream revisions used for the inherited files. The inherited source remains
under GPL-3.0; this notice does not replace its copyright notices.
The Comfy Rail commercial release pins an exact commit from this repository,
recorded in Comfy Rail's custom-node lock. Commercial release mode limits the
runtime to the locally installed, release-locked Apache-2.0 models below and
disables runtime downloads, runtime package installation, remote model code,
GGUF, and Whisper registration:
- `Qwen/Qwen3-4B-Instruct-2507` at `cdbee75f17c01a7cc42f958dc650907174af0554`
- `Qwen/Qwen3-VL-4B-Instruct` at `ebb281ec70b05090aa6165b016eac8ec08e71b17`
The exact GPL-3.0 source and its modification notices must be provided together
with distributed commercial images.
Catalog status labels are operational safeguards, not legal advice or a model
license grant. External/gated models remain subject to their Hugging Face access
terms, and rights-unclear weights are neither shipped nor automatically fetched
by commercial Comfy Rail releases. Only catalog entries marked `cleared`, pinned by full revision and
component ID, are eligible for the locked commercial runtime.
+26 -2
View File
@@ -3,7 +3,7 @@
"name": "ComfyUI-ThinkingLLM",
"description": "A multimodal ComfyUI AI node with Qwen3.5, Qwen3-VL, Qwen2.5-VL, Qwen3, and Gemma 4 integrations. Features live thinking in the terminal to see what the LLM is doing in real time.",
"author": "goodguy1963",
"version": "2.3.20",
"version": "2.5.8",
"url": "https://github.com/goodguy1963/ComfyUI-ThinkingLLM",
"category": "image"
}
@@ -19,7 +19,19 @@ sys.path.insert(0, current_dir)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
WEB_DIRECTORY = "./web"
COMMERCIAL_RELEASE = os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1"
WEB_DIRECTORY = None if COMMERCIAL_RELEASE else "./web"
COMMERCIAL_MODULES = {
"AILab_OutputCleaner",
"AILab_QwenVL",
"AILab_QwenVL_PromptEnhancer",
"AILab_StreamDisplay",
}
COMMERCIAL_NODE_MODULES = {
"story_split_node",
"system_prompt_preset",
"vram_cleanup",
}
LEGACY_NODE_REPLACEMENTS = {
"AILab_QwenVL": "ThinkingLLM_QwenVL",
@@ -29,6 +41,10 @@ LEGACY_NODE_REPLACEMENTS = {
"AILab_QwenVL_PromptEnhancer": "ThinkingLLM_QwenVL_PromptEnhancer",
"AILab_QwenVL_GGUF_PromptEnhancer": "ThinkingLLM_QwenVL_GGUF_PromptEnhancer",
}
if COMMERCIAL_RELEASE:
LEGACY_NODE_REPLACEMENTS = {
old: new for old, new in LEGACY_NODE_REPLACEMENTS.items() if "GGUF" not in old
}
def load_modules_from_directory(directory):
for file in os.listdir(directory):
@@ -37,6 +53,14 @@ def load_modules_from_directory(directory):
module_name = os.path.basename(file)[:-3]
if module_name == os.path.basename(__file__)[:-3]:
continue
if COMMERCIAL_RELEASE:
allowed = (
COMMERCIAL_MODULES
if os.path.abspath(directory) == os.path.abspath(current_dir)
else COMMERCIAL_NODE_MODULES
)
if module_name not in allowed:
continue
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
+1 -1
View File
@@ -9,5 +9,5 @@
"description": "A multimodal ComfyUI AI node with Qwen3.5, Qwen3-VL, Qwen2.5-VL, Qwen3, and Gemma 4 integrations. Features live thinking in the terminal to see what the LLM is doing in real time.",
"category": "image",
"tags": ["vision", "language", "multimodal", "thinkingllm", "qwen", "gguf", "flash_attention", "performance", "wan2.2", "video", "i2v", "t2v", "storyboard", "caching", "fork", "gpl-3.0"],
"version": "2.3.20"
"version": "2.5.8"
}
+109
View File
@@ -0,0 +1,109 @@
# Features and workflows
This guide explains how ThinkingLLM's user-facing controls affect inference and how to choose the right workflow. For installation and the short node list, start with the [README](../README.md).
## Model guidance box
ThinkingLLM nodes add a read-only `recommended_settings` box. It explains the selected model family and suggests generation settings without changing saved widget values.
For LTX 2.3, MiniMax H3, and Wan 2.2 presets, the box also provides preset-specific `enable_thinking` and `max_tokens` guidance. Model sampling recommendations and video output budgets remain separate so a video preset does not silently overwrite model-family advice.
Audio-capable GGUF nodes use the same box to distinguish models with curated audio support. The Advanced GGUF node warns when audio is connected to a model that is not known to support it.
The complete video recommendation table and its rationale are in [Video preset settings](VIDEO_PRESET_SETTINGS.md).
## Thinking mode
Every inference node exposes `enable_thinking`.
- When enabled, a compatible model may reason before answering. This can improve difficult responses but uses more time and output budget.
- A model may still answer directly when it considers the task simple.
- When disabled, ThinkingLLM applies the appropriate direct-answer steering for the selected model family, such as a `/no_think` directive where supported.
- For transcription, short classification, and concise prompt enhancement, begin with thinking disabled.
ThinkingLLM keeps incomplete or visible reasoning out of the cleaned primary response. When supported, reasoning remains available in `RAW_TRACE` for diagnostics.
## Live terminal streaming
Enable `stream_tokens_to_terminal` to display generation in the ComfyUI terminal as tokens arrive. This is useful when a run appears stuck, when you want to catch a poor prompt early, or when you need to distinguish model reasoning from slow loading.
Terminal streaming is diagnostic output. Downstream nodes should consume the normal response output instead.
## Response and raw trace
Inference nodes return a cleaned answer and a second string named `RAW_TRACE`.
- `RESPONSE` or `ENHANCED_OUTPUT` is intended for display and downstream workflow use.
- `RAW_TRACE` preserves the raw generation stream and backend diagnostics, including visible reasoning when the model emits it.
Connect `RAW_TRACE` to a separate text display when diagnosing empty output, unexpected thinking, backend selection, model loading, or generation formatting.
## Audio workflows
### Gemma 4 audio understanding
Use `ThinkingLLM Gemma 4 Audio (GGUF)` for audio questions, descriptions, classification, and transcription with a compatible Gemma 4 model.
The node accepts a ComfyUI `AUDIO` input or an `audio_file_path`. Audio is normalized to 16 kHz mono WAV before inference. Native Gemma 4 audio requires:
- multimodal `llama-cpp-python` 0.3.36 or newer;
- `Gemma4ChatHandler`;
- an mmproj that positively reports audio support.
ThinkingLLM verifies these conditions before sending audio to inference. Older or unknown native paths remain blocked because they can terminate the ComfyUI process instead of returning a Python exception. BF16 mmproj is recommended for Gemma 4 E2B/E4B audio.
### Whisper speech-to-text
Use `ThinkingLLM Whisper ASR` when the primary goal is dependable transcription. It accepts ComfyUI audio and common FFmpeg-readable paths such as M4A, MP3, WAV, and FLAC.
The node uses `faster-whisper`. The first run may download the selected model. The `small` model on CPU/int8 is a practical Windows starting point; use CUDA after confirming that the local CTranslate2 CUDA runtime works.
### Advanced GGUF audio input
The Advanced GGUF node exposes optional audio for expert workflows, but most Qwen and Qwen-VL models are not audio models. Prefer the dedicated nodes unless the selected model and mmproj explicitly support audio.
## Mask-focused image analysis
The standard and Advanced HF/GGUF vision nodes accept an optional ComfyUI `MASK` input.
- White pixels select the area of interest.
- Black pixels preserve surrounding context.
- Soft values create a gradual transition.
- Mask dimensions are resized to the image automatically.
In `focus` mode, ThinkingLLM keeps the selected area at normal brightness and dims the surroundings. This directs the vision model without removing spatial, lighting, or scene context.
In `reconstruct` mode, the selected area is concealed and the model is asked to infer a natural continuation from visible surroundings. This is useful for producing a positive inpainting prompt.
### Object-removal prompt workflow
1. Connect the original image and mask to a ThinkingLLM vision node.
2. Select `Remove (Mask Area)` from `preset_prompt`.
3. Optionally describe the desired replacement in `custom_prompt`.
4. Connect `RESPONSE` to the positive prompt of a diffusion inpainting workflow.
5. Send the original image and mask separately to the diffusion model's inpainting inputs.
ThinkingLLM does not edit the image. It describes what should naturally appear inside the masked region; the downstream diffusion model performs the replacement.
Advanced nodes return `MASK_PREVIEW`. Connect it to **Preview Image** to inspect exactly what the LLM receives.
Mask limitations:
- Masks apply to still images, not video.
- An empty mask or a mask without an image returns a clear error.
- Image batches use the first image and first mask.
- Precise masks with a modest amount of surrounding context generally produce the best reconstruction prompt.
## Video prompt workflows
ThinkingLLM provides preset-specific guidance for LTX 2.3, MiniMax H3, and Wan 2.2 workflows. The optional `duration_seconds` input appears only for registered duration-aware presets.
Use the preset that matches the downstream workflow and connected media. MiniMax H3 offers separate T2VA, I2VA, FL2VA, L2VA, and Reference-to-Video contracts; text-only Prompt Enhancer nodes expose T2VA.
Detailed duration resolution, MiniMax frame-grid normalization, output structures, long-script handling, best-effort cleanup, and recommended budgets are documented in [Duration-aware video prompts](VIDEO_PROMPT_DURATION.md).
## Shared model files and caching
ThinkingLLM reuses compatible model files discovered in configured ComfyUI locations. Fixed-seed local generations may reuse saved prompt output when all relevant inputs match. Audio-node results skip prompt persistence because the external audio content must not be mistaken for a reusable text-only result.
For supported file layouts and model labels, see [Models and backends](MODELS_AND_BACKENDS.md).
+1
View File
@@ -4,6 +4,7 @@ This plugin’s **QwenVL (GGUF)** vision nodes require a `llama-cpp-python` buil
- `Qwen3VLChatHandler`
- `Qwen25VLChatHandler`
- `Gemma4ChatHandler` (`llama-cpp-python` 0.3.36 or newer for Gemma 4 audio)
The upstream `llama-cpp-python` from PyPI often does **not** include these vision handlers. Use a fork/build that provides them (e.g. JamePeng’s fork) and install a **Release wheel**.
+120
View File
@@ -0,0 +1,120 @@
# Models and backends
This guide describes ThinkingLLM's inference paths, curated model families, local discovery, and platform requirements.
## Choose a backend
### Transformers / Hugging Face
The HF path is the recommended default for Windows and Linux when you want the fewest installation complications. It powers:
- `ThinkingLLM`
- `ThinkingLLM (Advanced)`
- `ThinkingLLM Prompt Enhancer`
Some Gemma and Qwen tokenizers require `sentencepiece` or `tiktoken`. ThinkingLLM declares both dependencies and attempts to install a missing tokenizer backend at first load.
### GGUF / llama.cpp
The GGUF path powers:
- `ThinkingLLM (GGUF)`
- `ThinkingLLM (GGUF Advanced)`
- `ThinkingLLM Gemma 4 Audio (GGUF)`
- `ThinkingLLM Prompt Enhancer (GGUF)`
Multimodal GGUF inference requires a llama.cpp Python build with the chat handlers used by the selected architecture. The regular PyPI package may not include the required Qwen vision, Gemma 4, or MTMD audio support. Follow [Install llama-cpp-python](LLAMA_CPP_PYTHON_VISION_INSTALL.md) for platform-specific setup and verification.
### Whisper ASR
`ThinkingLLM Whisper ASR` uses `faster-whisper` for speech-to-text. It is independent of the Gemma 4 GGUF audio path and requires FFmpeg for general audio-file decoding.
### OpenAI-compatible API
The API node calls approved remote providers with credentials bound on the ComfyUI server. See [ThinkingLLM API](OPENAI_COMPATIBLE_API.md) for setup and security boundaries.
## Curated model families
ThinkingLLM ships model metadata in `hf_models.json` and `gguf_models.json`.
### Qwen vision-language
- **HF:** Qwen3.5 4B/9B; Qwen3-VL 4B/8B/32B; Qwen2.5-VL 3B/7B
- **GGUF:** Qwen3.8 27B; Qwen3.5 4B/9B/27B; Qwen3-VL 4B/8B/32B; Qwen2.5-VL 3B/7B
Catalog variants include selected Instruct, thinking, abliterated, heretic, unredacted, and uncensored releases.
### Qwen text-only
- **HF:** Qwen3 0.6B/4B/8B; Qwen3.5 4B/9B
- **GGUF:** Qwen3.8 27B; Qwen3 4B/8B; Qwen3.5 4B/9B/27B
### Gemma 4 vision-language
- **HF:** Gemma 4 E2B/E4B, 12B, 26B-A4B, and 31B families
- **GGUF:** Gemma 4 E2B/E4B, 12B, 26B-A4B, and 31B families across selected quantizations
### Gemma 4 text-only
Selected E2B, E4B, 12B, 26B-A4B, and 31B HF variants are available in text workflows.
### Gemma 4 audio
The audio selector includes curated audio-capable Gemma 4 E2B, E4B, and 12B GGUF entries. Audio also depends on the selected mmproj and a compatible `Gemma4ChatHandler`; a model name alone does not guarantee audio support.
### Whisper
Available sizes include `tiny`, `base`, `small`, `medium`, `large-v3`, and `distil-large-v3`.
The catalog files are the authoritative current list. Dropdown contents can evolve between releases as models and compatibility information change.
## Model locations
ThinkingLLM searches configured ComfyUI model paths and common defaults, including:
- HF snapshots: `ComfyUI/models/LLM/Qwen-VL/`
- GGUF models: `ComfyUI/models/LLM/GGUF/`
- compatible single-file checkpoints: `ComfyUI/models/text_encoders/`
Keep a GGUF model and its matching mmproj together. Local GGUF files with an mmproj are discovered even when they are not in the bundled catalog.
## Model labels
- `[installed]` means a catalog model was found in a configured `LLM`, `GGUF`, or `Qwen-VL` location.
- `[local]` means an uncatalogued compatible local model was discovered.
Labels are display-only. They do not alter saved model identities, and older saved display labels remain compatible where supported.
## Shared ComfyUI text encoders
Compatible checkpoints in `ComfyUI/models/text_encoders` appear in applicable selectors and are loaded through ComfyUI rather than copied into a second Transformers snapshot.
Supported examples include:
- Qwen3-VL 4B and 8B `.safetensors`, including `qwen3vl_8b_fp8_scaled.safetensors`
- Gemma 3 12B `.safetensors`, including `gemma-3-12b-it-heretic-v2_int8.safetensors`
The MiniMax H3/Qwen3-VL 32B conditioning checkpoint is excluded because it is not a complete text-generation model. ComfyUI-native generation supports one-beam sampling and prints terminal output after generation rather than streaming token-by-token.
## Community and commercial catalogs
Community dropdowns preserve normal model names and download behavior. Gated Hugging Face repositories still require acceptance of the model owner's terms and a read token.
Commercial-release dropdowns include only reviewed, locked models and group entries by rights status. This restriction is intentionally separate from Community behavior.
## Platform notes
- **Windows GGUF:** install a matching `win_amd64` multimodal wheel for the Python version bundled with ComfyUI.
- **Linux GGUF:** ThinkingLLM checks the backend at first use and can install a verified matching JamePeng wheel. Environment overrides are documented in the installation guide.
- **Gemma 4 audio:** requires multimodal `llama-cpp-python` 0.3.36 or newer and an mmproj that reports audio support.
- **Whisper:** requires `faster-whisper`; file-path decoding also requires FFmpeg.
- **Hugging Face attention:** set `attention_mode` to `sage` to request SageAttention instead of Flash Attention 2. `auto` tries SageAttention, then Flash Attention 2, then PyTorch SDPA. On ROCm, SageAttention 1.0.6 uses its Triton backend when available in the same Python environment as ComfyUI. Unsupported models or attention inputs use SDPA. The GGUF node's `flash_attn` setting belongs to llama.cpp and does not select SageAttention.
- **Flash Attention:** support is strongest on Linux and falls back when unavailable.
- **Thinking controls:** Qwen3/Qwen3.5-style models generally respond most consistently to explicit thinking steering; other architectures may interpret it differently.
## Troubleshooting direction
- Missing or incompatible GGUF handlers: use [the llama.cpp installation guide](LLAMA_CPP_PYTHON_VISION_INSTALL.md).
- Unexpected response versus reasoning output: inspect `RAW_TRACE` and [Features and workflows](FEATURES_AND_WORKFLOWS.md).
- Remote provider setup or security errors: use [the API guide](OPENAI_COMPATIBLE_API.md).
- Duration or MiniMax structure questions: use [Duration-aware video prompts](VIDEO_PROMPT_DURATION.md).
+681
View File
@@ -0,0 +1,681 @@
# ThinkingLLM API (OpenAI Compatible)
`ThinkingLLM API (OpenAI Compatible)` lets a ComfyUI workflow call approved OpenAI-compatible chat-completions backends without exposing provider credentials to the workflow or to clients that submit ComfyUI API jobs.
The node returns:
- `RESPONSE` — the final assistant text, with literal `<think>...</think>` blocks removed.
- `RAW_TRACE` — the reasoning channel plus final text when the provider exposes reasoning separately, or the raw literal think block when a compatible backend emits one.
> [!IMPORTANT]
> **Provider API keys are host/runtime secrets, not ThinkingLLM configuration.** A real API key should exist only in the environment or secret store of the machine/service/container that launches ComfyUI. Never put a real provider key in a workflow, ComfyUI `/prompt` request, profile JSON, repository file, Dockerfile, README, issue, PR comment, or Git commit.
## Recommended production architecture
```text
External client / application
|
| ComfyUI API request
| api_profile = "OpenRouter Production"
| model_name = "approved/model-id"
| prompt = "..."
v
ComfyUI
|
| loads trusted server-side profile
| validates model, input size, output/thinking limits and timeout
| reads provider credential from process environment
v
OpenRouter / OpenAI / QwenCloud / other provider
```
The external client never sends or receives the provider key. The workflow only names a server-approved profile.
For a public or multi-user deployment, the recommended baseline is:
1. Keep provider keys in the host/service/container secret environment.
2. Keep the real profile file outside the Git checkout.
3. Set `THINKINGLLM_API_PROFILES_FILE` to that external file.
4. Set `THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1`.
5. Configure exact `allowed_models` values.
6. Configure `max_tokens_limit`, `max_input_chars`, and `max_timeout_seconds`; for Qwen thinking profiles also configure `max_thinking_budget`.
7. Keep `allowed_extra_body_fields` empty unless specific provider options are required.
8. Protect ComfyUI itself with authentication/network controls and rate/spending limits.
---
## Security boundaries
Treat workflow/API input as untrusted. A workflow is intentionally unable to provide:
- API keys or bearer tokens
- the name of the environment variable containing a key
- arbitrary remote `base_url` values
- custom HTTP headers
- a provider credential directly
Instead, the workflow provides only `api_profile`, `model_name`, prompts, and generation controls. The profile is read from trusted server configuration and binds the endpoint and authentication policy together.
This design prevents a workflow from combining an arbitrary server environment variable with an attacker-controlled URL.
### The profile file is not a secret, but it is security-sensitive
`thinkingllm_api_profiles.json` must contain configuration only, never a literal API key. Nevertheless, its **integrity matters**: a profile chooses the destination endpoint and the environment-variable reference. If an untrusted user can modify this file, they could redirect an approved credential to a different endpoint.
Therefore:
- only the ComfyUI administrator/service account should be able to modify the production profile file;
- use restrictive filesystem permissions where practical;
- for production, store the file outside the Git checkout;
- do not let workflow/API clients choose its path;
- restart ComfyUI after changing profile configuration.
### Redirects are deliberately rejected
The API transport does **not follow HTTP redirects**. This is intentional. Redirecting an authenticated request is dangerous because an authorization header can otherwise be forwarded beyond the originally approved endpoint.
Use the provider's canonical final API base URL in the server-side profile. A 3xx response is treated as an error and should be fixed by correcting the profile URL.
### HTTPS and local HTTP
Authenticated profiles (`auth: "bearer_env"`) must use HTTPS.
Unauthenticated plain HTTP is accepted automatically only for loopback hosts such as:
```text
http://127.0.0.1:8000/v1
http://localhost:11434/v1
```
For an intentionally cleartext LAN/internal endpoint with `auth: "none"`, the administrator must explicitly opt in:
```json
"allow_insecure_http": true
```
Do not enable this for Internet-facing endpoints.
---
## Built-in profiles
Convenience profiles are included for:
| Profile | Base URL | Server environment variable |
| --- | --- | --- |
| OpenAI | `https://api.openai.com/v1` | `OPENAI_API_KEY` |
| QwenCloud (Singapore) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `DASHSCOPE_API_KEY` |
| OpenRouter | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` |
| Together AI | `https://api.together.ai/v1` | `TOGETHER_API_KEY` |
| Fireworks AI | `https://api.fireworks.ai/inference/v1` | `FIREWORKS_API_KEY` |
| DeepInfra | `https://api.deepinfra.com/v1/openai` | `DEEPINFRA_TOKEN` |
| Groq | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` |
| Featherless | `https://api.featherless.ai/v1` | `FEATHERLESS_API_KEY` |
| OrcaRouter | `https://api.orcarouter.ai/v1` | `ORCAROUTER_API_KEY` |
| Ollama (local) | `http://127.0.0.1:11434/v1` | none |
| vLLM (local) | `http://127.0.0.1:8000/v1` | none |
| llama.cpp (local) | `http://127.0.0.1:8080/v1` | none |
The built-ins are convenient for local single-user use. For a remotely accessible/multi-user ComfyUI server, prefer explicit custom profiles and disable the built-ins.
### QwenCloud Singapore note
The built-in Singapore profile uses the shared `dashscope-intl` endpoint because a built-in profile cannot know your Alibaba Model Studio workspace ID. Alibaba currently keeps that shared endpoint functional, but recommends the workspace-dedicated Singapore domain for production:
```text
https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
```
For production QwenCloud use, create a custom server-side profile with your workspace-specific URL instead of relying on the convenience built-in.
---
## Environment variables and secret ownership
### Windows PowerShell — current process
```powershell
$env:OPENROUTER_PRODUCTION_API_KEY="your-secret-key"
$env:THINKINGLLM_DISABLE_BUILTIN_API_PROFILES="1"
$env:THINKINGLLM_API_PROFILES_FILE="D:\ComfyUI-Secrets\thinkingllm_api_profiles.json"
python main.py
```
The variable exists only for that PowerShell process and child processes.
### Windows — persistent service/user setup
If ComfyUI is launched by a service, task scheduler, wrapper, or another account, configure the secret in the environment of the **actual process that launches ComfyUI**. Setting a variable in your interactive terminal does not modify an already-running service.
After changing persistent environment values, restart ComfyUI.
### Windows — user environment variable + `.bat` launcher (ComfyUI Easy-Install)
Recommended for a local single-user ComfyUI that is started from a `.bat` file (for example the `ComfyUI-Easy-Install` `run_nvidia_gpu.bat`). The key lives in the **Windows user environment**, not in any file in the install tree, so it is not exposed in a `.bat`, a profile JSON, a workflow, or a Git commit.
Set the variable once, from any PowerShell:
```powershell
[System.Environment]::SetEnvironmentVariable("OPENROUTER_API_KEY", "sk-or-xxxx", "User")
```
Use the variable name that matches your provider (see the built-in profiles table above). The `.bat` needs **no** changes: the embedded Python inherits the user environment automatically. A `.bat` comment can simply point to this documentation:
```bat
REM API keys are provided via Windows user environment variables (see docs/OPENAI_COMPATIBLE_API.md).
```
If you already have a key in the current process or want to add one for this session only, you can export it too:
```powershell
$env:OPENROUTER_API_KEY="sk-or-xxxx"
```
Verification:
```powershell
[System.Environment]::GetEnvironmentVariable("OPENROUTER_API_KEY", "User")
```
> [!NOTE]
> The user environment is read when a new process is launched. If ComfyUI is already running, restart it after setting the variable. Never put a real key directly into a `.bat` that is stored, backed up, or could end up in version control.
### Linux/macOS shell
```bash
export OPENROUTER_PRODUCTION_API_KEY='your-secret-key'
export THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1
export THINKINGLLM_API_PROFILES_FILE=/etc/thinkingllm/api_profiles.json
python main.py
```
### systemd/service deployments
Prefer a service-specific environment file or secret mechanism with restrictive permissions. Keep it outside the Git checkout. The unit should expose the key to the ComfyUI process without copying the key into the ThinkingLLM source tree.
### Containers
Do **not** bake provider credentials into the image:
```dockerfile
# Do not do this
ENV OPENROUTER_PRODUCTION_API_KEY=real-secret
```
Inject credentials at runtime through your container/orchestrator secret mechanism. Mount the profile file read-only and point `THINKINGLLM_API_PROFILES_FILE` at the mounted path.
---
## Server-side profile file
Start from `thinkingllm_api_profiles.example.json`, but keep your real production file outside the repository when practical.
Example:
```json
{
"profiles": {
"OpenRouter Production": {
"provider": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"auth": "bearer_env",
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
"allowed_models": [
"provider/model-id"
],
"max_tokens_limit": 8192,
"max_input_chars": 131072,
"max_timeout_seconds": 300,
"allowed_extra_body_fields": [],
"send_seed": false
}
}
}
```
The string `OPENROUTER_PRODUCTION_API_KEY` is only the **name** of an environment variable. The actual provider key must not appear in this JSON.
### Supported profile fields
| Field | Meaning |
| --- | --- |
| `provider` | Display/log label. |
| `base_url` | Fixed administrator-approved API base URL. |
| `auth` | `bearer_env` or `none`. |
| `api_key_env` | Environment-variable name used only with `bearer_env`. |
| `allowed_models` | Optional exact model allowlist. Recommended for production. |
| `max_tokens_limit` | Maximum output-token request accepted from a workflow. |
| `max_input_chars` | Maximum combined `system_prompt + prompt` character count. Default: 1,000,000. |
| `max_timeout_seconds` | Maximum timeout a workflow may request. Default: 300. |
| `max_thinking_budget` | Maximum Qwen `thinking_budget` accepted from a workflow. Default/max: 262,144; set lower for production. |
| `allowed_extra_body_fields` | Exact top-level provider-specific request fields allowed from `extra_body_json`. Empty means disabled. |
| `send_seed` | Whether to send the common `seed` parameter. Must be a JSON boolean. |
| `thinking_mode` | Currently empty or `qwen`. |
| `max_tokens_field` | `max_tokens` or `max_completion_tokens`. |
| `allow_insecure_http` | Explicit opt-in for unauthenticated non-loopback HTTP. |
Unknown profile fields are rejected instead of silently ignored. This is intentional: a typo such as `max_token_limit` must not quietly disable a security limit.
A custom profile with the same name as a built-in is treated as an override. If that override is invalid, the built-in with that name is removed rather than silently used as a fallback. This is fail-closed behavior: a broken restrictive `OpenRouter` override must not accidentally reactivate the permissive convenience built-in.
Literal secret/header fields such as `api_key`, `token`, `Authorization`, `headers`, or `secret` are rejected.
---
## Production lock-down mode
For public-facing or multi-user ComfyUI:
```text
THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1
```
Then expose only explicit entries in your trusted profile file.
Recommended location examples:
```text
Windows: D:\ComfyUI-Secrets\thinkingllm_api_profiles.json
Linux: /etc/thinkingllm/api_profiles.json
```
Point ThinkingLLM to the external file:
```text
THINKINGLLM_API_PROFILES_FILE=/secure/path/thinkingllm_api_profiles.json
```
This also keeps credentials/profile policy independent of `git pull`, branch changes, reinstalls, or replacing the custom-node directory.
---
## ComfyUI UI usage
Add:
```text
ThinkingLLM/API
ThinkingLLM API (OpenAI Compatible)
```
Typical values:
```text
api_profile: OpenRouter Production
model_name: provider/approved-model-id
system_prompt: You are a helpful assistant.
prompt: ...
max_tokens: 2048
temperature: 0.6
top_p: 0.95
stream_tokens_to_terminal: false
```
The node never asks for the real provider credential.
Terminal streaming defaults to **off** for the remote API node. When enabled, ThinkingLLM prints a display-only copy with C0/C1 control characters removed; the actual `RESPONSE`/`RAW_TRACE` strings remain unchanged.
---
## Curated model lists (UX only)
The node ships convenience model lists in `web/api_model_catalogs.json`, one per provider. `model_name` is a dropdown filled from the list for the selected `api_profile`. For anything not in the list, use the separate **`custom_model_name`** field (optional) — when filled, it overrides the dropdown selection.
Provider coverage:
- **Public `/models` endpoints (auto-refreshed at release):** OpenRouter, OrcaRouter, DeepInfra, Featherless. These ship the *full* current model list (OpenRouter ~413, OrcaRouter ~190, DeepInfra ~185, Featherless capped at 500).
- **Key-protected providers (hand-curated):** OpenAI, QwenCloud (Singapore), Together AI, Fireworks AI, Groq.
**The catalog is UI convenience only. It is never a security boundary:**
- It does **not** override a server-side profile's `allowed_models` allowlist.
- It does **not** whitelist models for profiles without an allowlist — any ID can still be typed in `custom_model_name`.
- It contains no credentials and no endpoints; it only suggests model IDs.
### Keeping the lists fresh at release time
The public-provider lists are refreshed automatically at every release:
- `tools/update_api_model_catalogs.py` fetches the public `/models` endpoints and rewrites `web/api_model_catalogs.json` (keeps hand-curated providers unchanged).
- `tools/check_api_model_catalogs.py` validates schema, required provider entries, well-formed IDs, and duplicate detection.
- `release.yml` runs **both** tools before creating the release: it refreshes the lists, validates, and commits any changes back to the tag. CI (`api-security-tests.yml`) validates on every PR and push to `main`.
To refresh locally:
```bash
python tools/update_api_model_catalogs.py
python tools/check_api_model_catalogs.py
```
Open a PR with the refreshed catalog. The release workflow will not publish until the catalog validates.
---
## ComfyUI API usage
A client calling ComfyUI sends only profile/model/prompt/generation settings. Conceptually:
```json
{
"api_profile": "OpenRouter Production",
"model_name": "provider/approved-model-id",
"system_prompt": "You are a helpful assistant.",
"prompt": "Describe the scene",
"max_tokens": 2048,
"temperature": 0.6,
"top_p": 0.95,
"timeout_seconds": 120
}
```
It must not contain:
```text
api_key
bearer_token
api_key_env
base_url
Authorization
custom secret headers
```
### Remote calls always execute
ComfyUI normally caches a custom node when its inputs have not changed. That is wrong for a remote API call because the remote service is external state and can return a different answer even for identical inputs.
This node therefore declares itself changed on every queue run via `IS_CHANGED`, ensuring a repeated workflow actually performs a new provider request instead of reusing a previous response.
---
## Cost and abuse controls
Hiding a key is only one layer. Anyone authorized to submit a workflow that can select a cloud profile can potentially consume provider quota.
### Model allowlist
```json
"allowed_models": [
"provider/approved-model-a",
"provider/approved-model-b"
]
```
### Output-token ceiling
```json
"max_tokens_limit": 8192
```
### Input-size ceiling
```json
"max_input_chars": 131072
```
This limit is character based, not tokenizer based. It is a defensive bound against extremely large API payloads and uncontrolled input cost; provider/model context limits still apply separately.
### Timeout ceiling
```json
"max_timeout_seconds": 300
```
A workflow may request a shorter timeout but cannot exceed the server-defined ceiling.
### Qwen thinking-budget ceiling
For profiles using `"thinking_mode": "qwen"`, also cap the maximum thinking budget that a workflow may request:
```json
"max_thinking_budget": 8192
```
`thinking_budget` controls the maximum token budget available to Qwen's thinking process. The node enforces the profile ceiling before sending the provider request. A production deployment should choose a value appropriate to its model/cost policy rather than leaving the broad default maximum.
Also use provider-side budget caps/alerts and gateway/reverse-proxy rate limiting where available.
---
## Provider-specific request fields (`extra_body_json`)
The old boolean `allow_extra_body` switch is intentionally **not supported**. It was too broad: once enabled, a workflow could send arbitrary top-level provider parameters.
Instead, every extra top-level field must be individually approved:
```json
"allowed_extra_body_fields": [
"reasoning"
]
```
Then the workflow may send, for example:
```json
{
"reasoning": {
"effort": "high"
}
}
```
A field not on the allowlist is rejected.
The following core fields can never be delegated through `extra_body_json`:
- `model`
- `messages`
- `stream`
- `max_tokens`
- `max_completion_tokens`
- `temperature`
- `top_p`
- `seed`
- `enable_thinking`
- `thinking_budget`
`extra_body_json` also has a size limit and rejects non-finite JSON constants such as `NaN`.
---
## Thinking / reasoning traces
The node recognizes common OpenAI-compatible reasoning channels, including:
- `reasoning_content`
- `reasoning`
- `thinking`
- OpenRouter-style `reasoning_details` text/summary entries
For OpenRouter streaming, `reasoning_details` may appear in `choices[].delta.reasoning_details`; the node extracts readable text/summary portions for `RAW_TRACE`.
If a provider returns a mid-stream error inside an HTTP-200 SSE stream, the node detects a top-level `error` / `finish_reason: "error"` event and fails the ComfyUI execution rather than returning silently truncated output.
### Qwen thinking
Profiles with:
```json
"thinking_mode": "qwen"
```
send `enable_thinking` and, when greater than zero, `thinking_budget`. `max_thinking_budget` is the server-side ceiling on the workflow-requested thinking budget.
Example production policy:
```json
{
"thinking_mode": "qwen",
"max_thinking_budget": 8192
}
```
For other providers, expose only the specific provider reasoning field you need via `allowed_extra_body_fields`.
---
## Key rotation
Rotating a provider credential should require **zero workflow changes**:
1. Create a new provider key.
2. Replace the value of the existing server environment variable.
3. Restart/reload the ComfyUI process so it inherits the new value.
4. Test the configured profile.
5. Revoke the old provider key.
Workflows continue using the same alias:
```text
api_profile = OpenRouter Production
```
---
## Updating or reinstalling ThinkingLLM safely
Keep repository-managed source and host-managed secrets separate:
```text
Git repository Host configuration
------------------------------- ---------------------------------
ThinkingLLM source code provider API key environment variable
example profile production profile JSON
public documentation service/container secret settings
git pull / reinstall remains unchanged
```
Before updating:
```bash
git status
git check-ignore -v thinkingllm_api_profiles.json
```
Do not use `git add -f` on secret/config files.
After updating, restart ComfyUI so it reloads current profiles and process environment. No provider key needs to be copied into the repository.
### Gitignore is a secondary safeguard, not a secret manager
The repository ignores `.env`, `.env.*`, and local `thinkingllm_api_profiles*.json` variants while explicitly keeping `thinkingllm_api_profiles.example.json` trackable.
Git ignore rules do not protect a file that was already committed. If a real provider key ever entered Git history, logs, an issue, or a PR comment, treat it as compromised and rotate/revoke it at the provider.
---
## Multiple environments/accounts
Use distinct aliases and environment-variable names:
```json
{
"profiles": {
"OpenRouter Development": {
"provider": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"auth": "bearer_env",
"api_key_env": "OPENROUTER_DEV_API_KEY",
"allowed_models": ["provider/dev-model"],
"max_tokens_limit": 4096,
"max_input_chars": 65536,
"max_timeout_seconds": 120
},
"OpenRouter Production": {
"provider": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"auth": "bearer_env",
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
"allowed_models": ["provider/prod-model"],
"max_tokens_limit": 8192,
"max_input_chars": 131072,
"max_timeout_seconds": 300
}
}
}
```
The actual keys remain separate host secrets.
---
## Troubleshooting
### `Unknown or unavailable server-side API profile`
Check:
- `THINKINGLLM_API_PROFILES_FILE` points to the intended file;
- the JSON parses correctly;
- the alias matches exactly;
- if built-ins are disabled, the profile exists in the custom file;
- no unknown/deprecated profile field caused the entry to be rejected.
Invalid profiles are ignored and the reason is printed in the ComfyUI server terminal. If a custom profile uses the same name as a built-in and the custom override is invalid, that built-in name is removed rather than used as a fallback.
### `API credential is not configured for the selected server-side profile`
The ComfyUI process cannot see the required environment variable. Configure it in the environment of the **actual process/service/container** that starts ComfyUI, then restart ComfyUI.
### Redirect rejected
The configured provider URL returned HTTP 3xx. Set `base_url` to the provider's canonical final API URL. Redirect following is intentionally disabled for credential safety.
### LAN HTTP profile is rejected
Plain HTTP is loopback-only by default. Prefer HTTPS. If an administrator intentionally uses an unauthenticated trusted-LAN endpoint, set `allow_insecure_http: true` in that server-side profile.
### Model rejected
The exact `model_name` is absent from `allowed_models`. Model IDs are provider-specific.
### Input rejected
The combined system/user prompt exceeds `max_input_chars`.
### Output limit rejected
The workflow's `max_tokens` exceeds `max_tokens_limit`.
### Thinking budget rejected
For a Qwen thinking profile, the workflow's `thinking_budget` exceeds `max_thinking_budget`.
### Timeout rejected
The workflow's `timeout_seconds` exceeds `max_timeout_seconds`.
### `extra_body_json` rejected
Each top-level field must appear in `allowed_extra_body_fields`. The old `allow_extra_body` flag is deliberately rejected.
### Works in a terminal but not as a service
Your interactive shell and service likely have different environments. Configure secrets in the service/container/runtime environment rather than relying on the login shell.
---
## Production security checklist
Before exposing a ThinkingLLM API profile through ComfyUI:
- [ ] The real provider key is absent from the Git working tree and Git history.
- [ ] The real provider key is absent from workflow/API JSON and profile JSON.
- [ ] Production profile configuration is stored outside the repository where practical.
- [ ] The production profile file is writable only by trusted administrators/service accounts.
- [ ] `THINKINGLLM_API_PROFILES_FILE` points to the intended trusted file.
- [ ] `THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1` is enabled for public/multi-user deployments.
- [ ] Every cloud profile uses HTTPS.
- [ ] Every cloud profile has an exact `allowed_models` list.
- [ ] Every cloud profile has a reasonable `max_tokens_limit`.
- [ ] Every cloud profile has a reasonable `max_input_chars`.
- [ ] Every cloud profile has a reasonable `max_timeout_seconds`.
- [ ] Every Qwen thinking profile has a reasonable `max_thinking_budget`.
- [ ] `allowed_extra_body_fields` is empty unless specific fields are needed.
- [ ] Redirects are not required by the configured provider URL.
- [ ] Terminal token streaming is disabled unless intentionally needed for debugging.
- [ ] ComfyUI itself is protected by authentication/network controls and rate limits.
- [ ] Provider-side spending limits/alerts are configured where available.
- [ ] Key rotation can be performed by changing only the host secret and restarting/reloading ComfyUI.
Following this model keeps provider credentials local to the ComfyUI runtime, constrains what an API client can spend/change, and keeps secret ownership independent from ThinkingLLM source-code updates.
+252
View File
@@ -0,0 +1,252 @@
# ThinkingLLM API image inputs
`ThinkingLLM API (OpenAI Compatible)` supports a single image input in addition to text. Video input is intentionally not part of this node.
The image path follows the common OpenAI-style chat-completions multimodal message format:
```json
{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,..."
}
}
]
}
```
## Two mutually exclusive image inputs
The node exposes two optional inputs:
- `image` — a normal ComfyUI `IMAGE`. This is the **default/recommended path** for local images.
- `image_url` — an alternative public HTTPS URL for an image already hosted elsewhere.
Use one or the other. If both are supplied, the node rejects the request before contacting the provider.
There is no `video` or `video_url` input.
## Default path: ComfyUI IMAGE -> PNG -> Base64 data URL
When `image` is connected, ThinkingLLM:
1. accepts exactly one ComfyUI image (`[1,H,W,C]` or `[H,W,C]`);
2. validates dimensions and server-side pixel limits before conversion;
3. converts the image to 8-bit PNG in memory;
4. validates the PNG byte size;
5. Base64-encodes the PNG;
6. sends it as `data:image/png;base64,...` in an OpenAI-style `image_url` content item.
No temporary public file, upload service, or image-hosting credential is required.
The Base64 value exists only in the outbound provider request. It is not an API key and is not written to the server-side profile file.
### Why PNG
PNG avoids an additional lossy JPEG conversion and keeps the ComfyUI image content deterministic. The tradeoff is a larger request payload, which is why production profiles should set explicit image-size limits.
## Alternative path: public HTTPS `image_url`
When `image_url` is used, ThinkingLLM does **not** download the image itself. It validates the URL and passes it to the selected provider in the multimodal request.
Accepted URL form:
```text
https://example.com/path/image.png
```
Signed HTTPS URLs with query parameters are supported.
Rejected forms include:
```text
http://example.com/image.png
data:image/png;base64,...
file:///path/to/image.png
URLs containing embedded user:password credentials
URLs containing control characters
```
The URL alternative is intentionally restricted to public HTTPS URLs. Base64 data URLs must come from the local ComfyUI `IMAGE` path so the node can enforce local pixel/byte limits.
Because ThinkingLLM does not fetch `image_url`, the URL is not a server-side HTTP fetch performed by the ComfyUI host. The downstream provider decides whether it can retrieve and process the URL.
## Server-side profile policy
Custom production profiles are fail-closed for images. Image support is disabled unless the administrator explicitly enables it.
Example vision profile:
```json
{
"profiles": {
"OpenRouter Vision Production": {
"provider": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"auth": "bearer_env",
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
"allowed_models": [
"provider/vision-model-id"
],
"max_tokens_limit": 8192,
"max_input_chars": 131072,
"max_timeout_seconds": 300,
"allow_images": true,
"allow_image_url": true,
"max_image_pixels": 16777216,
"max_image_bytes": 20000000,
"allowed_extra_body_fields": [],
"send_seed": false
}
}
}
```
### `allow_images`
```json
"allow_images": true
```
Enables image input for the profile. Default for custom profiles is `false`.
If false, both local `IMAGE` and `image_url` requests are rejected before any provider request is sent.
### `allow_image_url`
```json
"allow_image_url": true
```
Allows the public HTTPS `image_url` alternative.
This option requires `allow_images: true`. A profile cannot enable URL images while general image input is disabled.
For production deployments that only need locally supplied ComfyUI images, use:
```json
"allow_images": true,
"allow_image_url": false
```
That is the tighter policy.
### `max_image_pixels`
```json
"max_image_pixels": 16777216
```
Maximum width x height accepted for a local ComfyUI `IMAGE` before PNG encoding.
The default is 16,777,216 pixels (4096 x 4096). Administrators can lower this substantially for API workloads.
This protects server RAM/CPU and limits accidentally huge multimodal requests.
### `max_image_bytes`
```json
"max_image_bytes": 20000000
```
Maximum PNG byte size before Base64 encoding. Default is 20,000,000 bytes.
Base64 adds approximately one third of transport overhead, so choose this limit with provider request-size limits and network costs in mind.
This limit applies to the locally encoded ComfyUI image. ThinkingLLM cannot know the remote byte size of an `image_url` without downloading it, which the node deliberately does not do.
## Built-in profiles
The convenience built-in profiles allow the standard image transport so a local/single-user installation can try vision-capable models without creating a custom profile.
That does **not** mean every model exposed by a provider supports images. Image capability is model-specific.
For public/multi-user deployments, disable convenience built-ins:
```text
THINKINGLLM_DISABLE_BUILTIN_API_PROFILES=1
```
Then create explicit image-enabled production profiles with an exact `allowed_models` list.
## What happens with a text-only model
A provider can expose both text-only and vision-capable models through the same OpenAI-compatible API.
ThinkingLLM therefore cannot infer image capability reliably from the provider name alone. It sends the standard multimodal request when the profile permits images.
If the provider/model rejects a request containing an image with a typical image/content validation response, the node raises an actionable ComfyUI error similar to:
```text
Image input was rejected by API profile 'OpenRouter Production' / model '...'.
Use a vision-capable model supported by that provider, or remove the IMAGE/image_url input.
```
The provider's detailed response remains available in the ComfyUI server log, subject to existing credential redaction.
## ComfyUI UI usage
Typical local-image workflow:
```text
Load Image
|
v
ThinkingLLM API (OpenAI Compatible)
image = connected IMAGE
prompt = Describe the scene in detail
api_profile = OpenRouter Vision Production
model_name = provider/vision-model-id
|
+--> RESPONSE
+--> RAW_TRACE
```
Typical URL workflow:
```text
ThinkingLLM API (OpenAI Compatible)
image = not connected
image_url = https://example.com/image.jpg
prompt = Describe the scene in detail
```
## ComfyUI API-format workflow
For a local ComfyUI image, the normal ComfyUI graph connection supplies the `IMAGE` tensor. The external application does not need to manually Base64-encode the image for the provider; ThinkingLLM performs that conversion after the upstream ComfyUI image-loading/input node has produced an `IMAGE`.
For the URL alternative, the API-format workflow may contain the public URL string:
```json
{
"image_url": "https://example.com/image.jpg"
}
```
Provider credentials remain server-side exactly as for text-only requests.
## Current limitations
- One image per API-node execution.
- Image batches are rejected rather than silently using only the first frame.
- Video is intentionally unsupported by this node.
- Local image transport is PNG Base64 only.
- `image_url` is HTTPS only.
- Actual model vision support is determined by the provider/model, not by the provider profile name.
- Remote URL image size cannot be preflighted by ThinkingLLM because the ComfyUI host does not fetch the URL.
## Production checklist for image-enabled profiles
- [ ] Use an exact vision-capable model ID in `allowed_models`.
- [ ] Set `allow_images: true` only on profiles that need image input.
- [ ] Keep `allow_image_url: false` unless public URL input is actually needed.
- [ ] Set a realistic `max_image_pixels` for the application.
- [ ] Set a realistic `max_image_bytes` for provider/network limits.
- [ ] Keep the provider API key only in the ComfyUI host/runtime secret environment.
- [ ] Keep production profile configuration outside the Git checkout where practical.
- [ ] Test an intentionally text-only model once to verify the user-facing image rejection is understandable.
- [ ] Keep provider-side spend limits/alerts enabled where available.
+51
View File
@@ -0,0 +1,51 @@
# Video preset settings
ThinkingLLM shows two independent recommendation blocks in the read-only `recommended_settings` info box:
1. The selected model family's sampler guidance.
2. The selected video preset's output budget and thinking-mode guidance.
The info box never changes saved widget values. Preset guidance is centrally defined in `AILab_System_Prompts.json` and mirrored to the web payload used by ComfyUI.
## Preset recommendations
| Preset family | `enable_thinking` | `max_tokens` |
| --- | ---: | ---: |
| LTX 2.3 T2V, I2V, First/Last, Reference | `false` | 512 |
| MiniMax H3 T2VA, I2VA, FL2VA, L2VA | `false` | 2048 |
| MiniMax H3 Full Reference | `false` | 3072 |
| Wan 2.2 Scene 3s/5s, T2V and I2V | `false` | 512 |
| Wan 2.2 Timeline 3s/5s, T2V and I2V | `false` | 768 |
| Wan 2.2 Scene 20s, T2V and I2V | `false` | 1536 |
| Wan 2.2 Timeline 20s, T2V and I2V | `false` | 2048 |
Thinking is disabled in these recommendations because the selected system prompts already define the planning and output format. Direct generation is faster and less likely to spend the output budget on a reasoning trace or deviate from the requested final structure.
The token budgets are upper bounds for the expected final format rather than settings from the downstream video generator. Increase the budget only when a useful final prompt is genuinely truncated.
## Sampling settings
Temperature, `top_p`, `top_k`, and repetition penalties remain model- and input-mode recommendations:
- Qwen3/Qwen3.5 vision input generally uses `temperature=0.7`, `top_p=0.8`, `top_k=20`, and `repetition_penalty=1.0`.
- Text-only input follows the selected model card. Qwen3.5 uses `temperature=1.0`, `top_p=1.0`, `top_k=20`, and `repetition_penalty=1.0`; Qwen3-VL Instruct uses `temperature=1.0`, `top_p=1.0`, `top_k=40`, and `repetition_penalty=1.0`.
- Widgets not exposed by the selected node are omitted from the info box summary.
For a connected reference video, begin around two sampled frames per source second and stay within the node's 64-frame limit. Image-only presets do not need a `frame_count` recommendation.
## Format rationale
- LTX requests one literal chronological paragraph under 200 words, so 512 output tokens provide sufficient headroom.
- MiniMax Base uses three structured fields; Full Reference uses six sections and needs a larger budget.
- Wan single-scene prompts fit within 512 tokens. Timeline formats and four-part 20-second sequences need progressively larger budgets to avoid truncation.
Runtime-setting instructions such as `max_tokens`, context length, batch size, and temperature are kept out of the system prompts themselves. They belong in the info box and must not become part of the generated video prompt.
## Official references
- [Qwen3-VL 4B Instruct generation hyperparameters](https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct)
- [Qwen3.5 sampling recommendations](https://huggingface.co/Qwen/Qwen3.5-2B)
- [LTX-2 prompting guide](https://github.com/Lightricks/LTX-2/blob/main/README.md#%EF%B8%8F-prompting-for-ltx-2)
- [MiniMax H3 Base prompt guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md)
- [MiniMax H3 Full-Reference prompt guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_ref_en.md)
- [Wan 2.2 prompt extension](https://github.com/Wan-Video/Wan2.2#2-using-prompt-extention)
+118
View File
@@ -0,0 +1,118 @@
# Duration-aware video prompts
ThinkingLLM can make LTX 2.3 and MiniMax H3 prompts match the duration configured in the downstream video generator. The duration affects prompt planning and best-effort formatting only; ThinkingLLM does not change the generator's own frame or length setting.
## Supported nodes
The optional `duration_seconds` input is available on:
- `ThinkingLLM`
- `ThinkingLLM (Advanced)`
- `ThinkingLLM (GGUF)`
- `ThinkingLLM (GGUF Advanced)`
- `ThinkingLLM Prompt Enhancer`
- `ThinkingLLM Prompt Enhancer (GGUF)`
The widget is shown only for registered LTX 2.3 and MiniMax H3 video presets. It remains hidden and has no effect for `Custom Only`, image, analysis, tagging, and other non-video presets. Hiding the widget does not change its type or serialization position, so switching presets cannot move the read-only recommendation text into `duration_seconds`. The last valid duration is preserved. Older workflows without the input use `5.0` seconds for a duration-aware preset, and workflows containing a corrupted non-numeric duration are repaired to the last valid value or `5.0` seconds by the frontend.
Connect the same requested duration to ThinkingLLM and the downstream video generator. No separate enable switch is required.
## Duration resolution
LTX uses the requested value directly. Its final prompt contains the duration once as natural language, for example `an 8-second continuous shot`, and remains one chronological paragraph.
MiniMax uses the same 24 fps `17k+5` frame grid as the ComfyUI H3 node:
1. Convert the requested seconds to frames with `round(seconds × 24)`.
2. Use at least five frames.
3. Raise the result to the next valid `17k+5` frame count.
4. Divide the valid frame count by 24 to obtain the effective prompt duration.
Examples:
| Requested | Effective frames | Effective duration |
| ---: | ---: | ---: |
| 5.0 s | 124 | 5.17 s |
| 8.0 s | 192 | 8.00 s |
| 12.0 s | 294 | 12.25 s |
Two requested MiniMax values that resolve to the same effective duration share the same duration-aware cache identity. A change to a different effective duration invalidates the cached and saved prompt state.
## MiniMax H3 output contracts
### Base formats
T2VA, I2VA, FL2VA, and L2VA use the official base fields in this order:
```text
integrated_multimodal_description: [Shot 1] ...
overall_soundscape: ...
non_diegetic_music: ...
```
The preset asks for `[Shot 1]` without a timestamp and later cuts as `[Shot N] At MM:SS.mmm`, with sequential shot numbers and timestamps below the effective duration. I2VA, FL2VA, and L2VA additionally request their official picture-alignment preamble. These are generation instructions rather than post-generation rejection rules.
The effective duration is internal planning context. T2VA and I2VA do not receive an invented `Target duration:` field. FL2VA and L2VA print the duration only where the official picture-alignment sentence requires it.
### Full-reference format
Reference-to-Video uses exactly these six sections in order:
1. `subject_definitions`
2. `summary`
3. `retention_analysis`
4. `detailed_description`
5. `overall_soundscape`
6. `non_diegetic_music`
Use Reference-to-Video only when the request assigns connected assets a reusable role such as identity, wardrobe, prop, environment, style, motion, camera, or voice. To animate one image as the exact first frame, use the I2VA preset.
## Long scripts and short clips
A long multi-scene script is treated as source material, not as content that must all be compressed into one short generation. For an 8-second target, ThinkingLLM instructs the model to select one coherent contiguous moment that fits instead of turning the complete story into a rapid montage.
Standalone quoted paragraphs are recognized as source dialogue. The rules are:
- Scenes and dialogue outside the selected moment may be omitted.
- The selected moment may be visual-only even when the master script contains dialogue.
- Every selected dialogue block must match a source block verbatim and remain in source order.
- Dialogue cannot be translated, paraphrased, merged, shortened, or invented.
- Selected speech uses `<d>[Language] exact source text</d>` and a stable speaker ID such as `(S1)`. The square brackets are literal, so English is written as `<d>[English] Exact source text.</d>`, not `<d>English ...</d>`.
- `<cutoff>` is allowed only when the user explicitly requests truncated speech.
For example, a 99-word comedy script and an 8-second target do not fail before or after model generation. The preset asks the LLM to select one short exchange and its directly related action, or a visual-only beat. Any remaining mismatch is forwarded for MiniMax's downstream prompt processing.
## Best-effort normalization
After generation, ThinkingLLM deterministically normalizes only safe, unambiguous formatting differences:
- uniquely identifiable field boundaries;
- recognizable timestamp precision and `At` capitalization;
- removal of the redundant zero timestamp from `[Shot 1]`;
- the common `<d>English ...</d>` variant to `<d>[English] ...</d>` syntax;
- removal of an obsolete standalone `Target duration:` declaration.
ThinkingLLM does **not** enforce exact field count or order, speaker IDs, dialogue length or source fidelity, sequential or in-range timestamps, or the full reference-section schema. It does not run an automatic schema-repair pass for minor mismatches; those are forwarded to the downstream MiniMax generator, which performs its own prompt interpretation. It retries only when no usable final prompt exists, such as an unfinished `<think>` block. The system prompt still strongly requests the official format without turning ThinkingLLM into a blocking schema gateway.
## Recommended settings
- The read-only info box shows the active video preset's recommendation without changing node values. See the [complete video preset settings table](VIDEO_PRESET_SETTINGS.md).
- You can switch from LTX or MiniMax back to `Custom Only` and queue immediately; the hidden duration remains numeric and is ignored by the non-video preset.
- Connect the same requested duration to ThinkingLLM and the H3 generator.
- Start with `enable_thinking=false` for direct prompt conversion.
- Start with `max_tokens=2048` for Base/T2VA, I2VA, FL2VA, and L2VA prompts.
- Start with `max_tokens=3072` for full-reference prompts.
- If a video-preset response ends inside an unfinished `<think>` block, ThinkingLLM discards that reasoning and automatically retries with thinking disabled and the native output-field prefill. It never forwards the incomplete reasoning as the main prompt.
- Increase the token limit only when a useful response is genuinely truncated.
- Inspect `RAW_TRACE` for model reasoning and connect the main output to Show Text or Show Anything to inspect the exact downstream string.
After updating the custom node, restart ComfyUI completely and refresh the browser with `Ctrl+F5` so Python node definitions and frontend widget visibility are both reloaded. If an old browser tab still reports `Failed to convert ... duration_seconds` followed by recommendation text, that tab is still using the stale frontend script; close it or perform another hard refresh.
## Official references
- [ComfyUI MiniMax H3 workflow guide](https://docs.comfy.org/tutorials/video/minimax/minimax-h3)
- [MiniMax H3 Base prompt-writing guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_base_en.md)
- [MiniMax H3 Full-Reference prompt-writing guide](https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/main/docs/VIDEO_PROMPT_WRITING_GUIDE_ref_en.md)
- [LTX-Video repository](https://github.com/Lightricks/LTX-Video)
+180
View File
@@ -5,6 +5,7 @@
"author": "mradermacher",
"repo_name": "Qwen3-4B-abliterated-GGUF",
"repo_id": "mradermacher/Qwen3-4B-abliterated-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3-4B-abliterated.Q4_K_M.gguf",
"Qwen3-4B-abliterated.Q5_K_M.gguf",
@@ -20,6 +21,7 @@
"author": "mradermacher",
"repo_name": "Josiefied-Qwen3-4B-abliterated-v1-GGUF",
"repo_id": "mradermacher/Josiefied-Qwen3-4B-abliterated-v1-GGUF",
"commercial_status": "unclear",
"model_files": [
"Josiefied-Qwen3-4B-abliterated-v1.Q4_K_M.gguf",
"Josiefied-Qwen3-4B-abliterated-v1.Q5_K_M.gguf",
@@ -35,6 +37,7 @@
"author": "mradermacher",
"repo_name": "Josiefied-Qwen3-8B-abliterated-v1-GGUF",
"repo_id": "mradermacher/Josiefied-Qwen3-8B-abliterated-v1-GGUF",
"commercial_status": "unclear",
"model_files": [
"Josiefied-Qwen3-8B-abliterated-v1.Q4_K_M.gguf",
"Josiefied-Qwen3-8B-abliterated-v1.Q5_K_M.gguf",
@@ -50,6 +53,7 @@
"author": "mradermacher",
"repo_name": "Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
"repo_id": "mradermacher/Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3-VL-4B-Instruct-c_abliterated-v2.Q4_K_M.gguf",
"Qwen3-VL-4B-Instruct-c_abliterated-v2.Q5_K_M.gguf",
@@ -65,6 +69,7 @@
"author": "mradermacher",
"repo_name": "Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
"repo_id": "mradermacher/Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3-VL-8B-Instruct-c_abliterated-v3.Q4_K_M.gguf",
"Qwen3-VL-8B-Instruct-c_abliterated-v3.Q5_K_M.gguf",
@@ -80,6 +85,7 @@
"author": "DevQuasar",
"repo_name": "Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1-GGUF",
"repo_id": "DevQuasar/Goekdeniz-Guelmez.Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1-GGUF",
"commercial_status": "unclear",
"model_files": [
"Goekdeniz-Guelmez.Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1.Q4_K_M.gguf",
"Goekdeniz-Guelmez.Josiefied-Qwen3-4B-Instruct-2507-gabliterated-v1.Q5_K_M.gguf",
@@ -95,6 +101,7 @@
"author": "Qwen",
"repo_name": "Qwen3-4B-GGUF",
"repo_id": "Qwen/Qwen3-4B-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3-4B-Q4_K_M.gguf",
"Qwen3-4B-Q5_K_M.gguf",
@@ -109,6 +116,7 @@
"author": "unsloth",
"repo_name": "Qwen3.5-4B-GGUF",
"repo_id": "unsloth/Qwen3.5-4B-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3.5-4B-Q4_K_M.gguf",
"Qwen3.5-4B-Q6_K.gguf",
@@ -123,6 +131,7 @@
"author": "unsloth",
"repo_name": "Qwen3.5-9B-GGUF",
"repo_id": "unsloth/Qwen3.5-9B-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3.5-9B-Q4_K_M.gguf",
"Qwen3.5-9B-Q6_K.gguf",
@@ -137,6 +146,7 @@
"author": "unsloth",
"repo_name": "Qwen3.5-27B-GGUF",
"repo_id": "unsloth/Qwen3.5-27B-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3.5-27B-Q4_K_M.gguf",
"Qwen3.5-27B-Q5_K_M.gguf",
@@ -151,6 +161,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-GGUF",
"repo_id": "HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"model_files": [
"Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
"Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf",
@@ -165,6 +176,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-GGUF",
"repo_id": "HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"model_files": [
"Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
"Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf",
@@ -179,6 +191,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-GGUF",
"repo_id": "HauhauCS/Qwen3.5-27B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"model_files": [
"Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
"Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf",
@@ -193,6 +206,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"model_files": [
"Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf",
"Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
@@ -210,10 +224,31 @@
"context_length": 131072
}
},
"Qwen3.8-27B-ABLITERATED-GGUF [Q2:10.7GB|Q3:12.1-13.3GB|Q4:15.6-16.5GB|Q5:18.7-19.2GB|Q6:22.1GB|Q8:28.6GB|VRAM:~20GB+]": {
"author": "Blackfrost-AI",
"repo_name": "Qwen3.8-27B-ABLITERATED-GGUF",
"repo_id": "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf"
],
"defaults": {
"context_length": 262144
}
},
"Gemma-4-E2B-it-GGUF [Q4:3.2GB|Q8:4.7GB|BF16:8.7GB|VRAM:~5GB]": {
"author": "bartowski",
"repo_name": "google_gemma-4-E2B-it-GGUF",
"repo_id": "bartowski/google_gemma-4-E2B-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"google_gemma-4-E2B-it-Q4_K_S.gguf",
"google_gemma-4-E2B-it-Q8_0.gguf",
@@ -231,6 +266,7 @@
"author": "unsloth",
"repo_name": "gemma-4-E4B-it-GGUF",
"repo_id": "unsloth/gemma-4-E4B-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"gemma-4-E4B-it-Q4_K_M.gguf",
"gemma-4-E4B-it-Q6_K.gguf",
@@ -245,6 +281,7 @@
"author": "unsloth",
"repo_name": "gemma-4-26B-A4B-it-GGUF",
"repo_id": "unsloth/gemma-4-26B-A4B-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"gemma-4-26B-A4B-it-Q4_K_M.gguf",
"gemma-4-26B-A4B-it-Q6_K.gguf",
@@ -259,6 +296,7 @@
"author": "unsloth",
"repo_name": "gemma-4-12b-it-GGUF",
"repo_id": "unsloth/gemma-4-12b-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"gemma-4-12b-it-Q4_K_M.gguf",
"gemma-4-12b-it-Q4_K_S.gguf",
@@ -277,6 +315,7 @@
"author": "unsloth",
"repo_name": "gemma-4-31B-it-GGUF",
"repo_id": "unsloth/gemma-4-31B-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"gemma-4-31B-it-Q4_K_M.gguf",
"gemma-4-31B-it-Q6_K.gguf",
@@ -291,6 +330,7 @@
"author": "bartowski",
"repo_name": "google_gemma-4-E4B-it-GGUF",
"repo_id": "bartowski/google_gemma-4-E4B-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"google_gemma-4-E4B-it-Q4_K_M.gguf",
"google_gemma-4-E4B-it-Q5_K_M.gguf",
@@ -313,6 +353,7 @@
"author": "bartowski",
"repo_name": "google_gemma-4-E2B-it-GGUF",
"repo_id": "bartowski/google_gemma-4-E2B-it-GGUF",
"commercial_status": "unclear",
"model_files": [
"google_gemma-4-E2B-it-Q4_K_S.gguf",
"google_gemma-4-E2B-it-Q8_0.gguf",
@@ -325,6 +366,46 @@
"defaults": {
"context_length": 131072
}
},
"Gemma-4-E4B-Uncensored-Aggressive-GGUF [Q2:4.4GB|Q3:4.7-4.9GB|Q4:5.1-5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8.1GB|VRAM:~7GB]": {
"author": "HauhauCS",
"repo_name": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"model_files": [
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
],
"defaults": {
"context_length": 131072
}
},
"Gemma-4-E2B-Uncensored-Aggressive-GGUF [Q2:3GB|Q3:3.1GB|Q4:3.4GB|Q5:3.7GB|Q6:3.9GB|Q8:5GB|VRAM:~5GB]": {
"author": "HauhauCS",
"repo_name": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"model_files": [
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
],
"defaults": {
"context_length": 131072
}
}
},
"qwenVL_model": {
@@ -332,6 +413,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.5-4B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Qwen3.5-4B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-BF16.gguf",
"model_files": [
"Qwen3.5-4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
@@ -352,6 +434,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.5-9B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf",
"model_files": [
"Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
@@ -372,6 +455,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.5-27B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Qwen3.5-27B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-f16.gguf",
"model_files": [
"Qwen3.5-27B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
@@ -392,6 +476,7 @@
"author": "HauhauCS",
"repo_name": "Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-f16.gguf",
"model_files": [
"Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf",
@@ -415,10 +500,42 @@
"pool_size": 4194304
}
},
"Qwen3.8-27B-ABLITERATED-GGUF [Q2:10.7GB|Q3:12.1-13.3GB|Q4:15.6-16.5GB|Q5:18.7-19.2GB|Q6:22.1GB|Q8:28.6GB|VRAM:~20GB+]": {
"author": "Blackfrost-AI",
"repo_name": "Qwen3.8-27B-ABLITERATED-GGUF",
"repo_id": "Blackfrost-AI/Qwen3.8-27B-ABLITERATED-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf",
"mmproj_local_filenames": {
"mmproj-Qwen3.8-27B-ABLITERATED-Q8_0.gguf": [
"mmproj-Qwen3.8-27B-ABLITERATED-F16.gguf"
]
},
"model_files": [
"Qwen3.8-27B-ABLITERATED-Q2_K.gguf",
"Qwen3.8-27B-ABLITERATED-Q3_K_S.gguf",
"Qwen3.8-27B-ABLITERATED-Q3_K_M.gguf",
"Qwen3.8-27B-ABLITERATED-Q4_K_S.gguf",
"Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf",
"Qwen3.8-27B-ABLITERATED-Q5_K_S.gguf",
"Qwen3.8-27B-ABLITERATED-Q5_K_M.gguf",
"Qwen3.8-27B-ABLITERATED-Q6_K.gguf",
"Qwen3.8-27B-ABLITERATED-Q8_0.gguf"
],
"defaults": {
"context_length": 262144,
"image_max_tokens": 4096,
"n_batch": 512,
"gpu_layers": -1,
"top_k": 20,
"pool_size": 4194304
}
},
"Qwen3.5-4B [Q4:2.4GB|Q6:3.2GB|Q8:4.2GB|BF16:8GB|VRAM:~4GB]": {
"author": "unsloth",
"repo_name": "Qwen3.5-4B-GGUF",
"repo_id": "unsloth/Qwen3.5-4B-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-BF16.gguf",
"model_files": [
"Qwen3.5-4B-Q4_K_M.gguf",
@@ -439,6 +556,7 @@
"author": "unsloth",
"repo_name": "Qwen3.5-9B-GGUF",
"repo_id": "unsloth/Qwen3.5-9B-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-F32.gguf",
"model_files": [
"Qwen3.5-9B-Q4_K_M.gguf",
@@ -459,6 +577,7 @@
"author": "unsloth",
"repo_name": "Qwen3.5-27B-GGUF",
"repo_id": "unsloth/Qwen3.5-27B-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-F32.gguf",
"model_files": [
"Qwen3.5-27B-Q4_K_M.gguf",
@@ -479,6 +598,7 @@
"author": "unsloth",
"repo_name": "gemma-4-12b-it-GGUF",
"repo_id": "unsloth/gemma-4-12b-it-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-F16.gguf",
"model_files": [
"gemma-4-12b-it-Q4_K_M.gguf",
@@ -503,6 +623,7 @@
"author": "bartowski",
"repo_name": "google_gemma-4-E4B-it-GGUF",
"repo_id": "bartowski/google_gemma-4-E4B-it-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-google_gemma-4-E4B-it-f16.gguf",
"model_files": [
"google_gemma-4-E4B-it-Q4_K_M.gguf",
@@ -530,10 +651,63 @@
"pool_size": 4194304
}
},
"Gemma-4-E4B-Uncensored-Aggressive-GGUF [Q2:4.4GB|Q3:4.7-4.9GB|Q4:5.1-5.4GB|Q5:5.8GB|Q6:6.3GB|Q8:8.1GB|VRAM:~7GB]": {
"author": "HauhauCS",
"repo_name": "Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-f16.gguf",
"model_files": [
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-IQ4_XS.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_M.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
"Gemma-4-E4B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
],
"defaults": {
"context_length": 131072,
"image_max_tokens": 4096,
"n_batch": 512,
"gpu_layers": -1,
"top_k": 64,
"pool_size": 4194304
}
},
"Gemma-4-E2B-Uncensored-Aggressive-GGUF [Q2:3GB|Q3:3.1GB|Q4:3.4GB|Q5:3.7GB|Q6:3.9GB|Q8:5GB|VRAM:~5GB]": {
"author": "HauhauCS",
"repo_name": "Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-f16.gguf",
"model_files": [
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-IQ3_M.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q2_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q3_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q4_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q5_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q6_K_P.gguf",
"Gemma-4-E2B-Uncensored-HauhauCS-Aggressive-Q8_K_P.gguf"
],
"defaults": {
"context_length": 131072,
"image_max_tokens": 4096,
"n_batch": 512,
"gpu_layers": -1,
"top_k": 64,
"pool_size": 4194304
}
},
"Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF [Q4:2.4GB|Q5:2.8GB|Q6:3.2GB|Q8:4.2GB|F16:8GB|VRAM:~4GB]": {
"author": "mradermacher",
"repo_name": "Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
"repo_id": "mradermacher/Qwen3-VL-4B-Instruct-c_abliterated-v2-GGUF",
"commercial_status": "unclear",
"mmproj_file": "Qwen3-VL-4B-Instruct-c_abliterated-v2.mmproj-f16.gguf",
"model_files": [
"Qwen3-VL-4B-Instruct-c_abliterated-v2.Q4_K_M.gguf",
@@ -555,6 +729,7 @@
"author": "mradermacher",
"repo_name": "Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
"repo_id": "mradermacher/Qwen3-VL-8B-Instruct-c_abliterated-v3-GGUF",
"commercial_status": "unclear",
"mmproj_file": "Qwen3-VL-8B-Instruct-c_abliterated-v3.mmproj-f16.gguf",
"model_files": [
"Qwen3-VL-8B-Instruct-c_abliterated-v3.Q4_K_M.gguf",
@@ -576,6 +751,7 @@
"author": "mradermacher",
"repo_name": "Qwen3-VL-32B-Heretic-v2-GGUF",
"repo_id": "mradermacher/Qwen3-VL-32B-Heretic-v2-GGUF",
"commercial_status": "unclear",
"model_files": [
"Qwen3-VL-32B-Heretic-v2.Q4_K_M.gguf",
"Qwen3-VL-32B-Heretic-v2.Q5_K_M.gguf",
@@ -595,6 +771,7 @@
"author": "Qwen",
"repo_name": "Qwen3-VL-4B-Instruct-GGUF",
"repo_id": "Qwen/Qwen3-VL-4B-Instruct-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3VL-4B-Instruct-F16.gguf",
"model_files": [
"Qwen3VL-4B-Instruct-Q4_K_M.gguf",
@@ -614,6 +791,7 @@
"author": "Qwen",
"repo_name": "Qwen3-VL-8B-Instruct-GGUF",
"repo_id": "Qwen/Qwen3-VL-8B-Instruct-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3VL-8B-Instruct-F16.gguf",
"model_files": [
"Qwen3VL-8B-Instruct-Q4_K_M.gguf",
@@ -633,6 +811,7 @@
"author": "Qwen",
"repo_name": "Qwen3-VL-4B-Thinking-GGUF",
"repo_id": "Qwen/Qwen3-VL-4B-Thinking-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3VL-4B-Thinking-F16.gguf",
"model_files": [
"Qwen3VL-4B-Thinking-Q4_K_M.gguf",
@@ -652,6 +831,7 @@
"author": "Qwen",
"repo_name": "Qwen3-VL-8B-Thinking-GGUF",
"repo_id": "Qwen/Qwen3-VL-8B-Thinking-GGUF",
"commercial_status": "unclear",
"mmproj_file": "mmproj-Qwen3VL-8B-Thinking-F16.gguf",
"model_files": [
"Qwen3VL-8B-Thinking-Q4_K_M.gguf",
+24
View File
@@ -0,0 +1,24 @@
{
"hf_vl_models": {
"Qwen3-VL-4B-Instruct (locked commercial model)": {
"repo_id": "Qwen/Qwen3-VL-4B-Instruct",
"commercial_status": "cleared",
"revision": "ebb281ec70b05090aa6165b016eac8ec08e71b17",
"component_id": "MODEL-014",
"default": true,
"quantized": false,
"vram_requirement": {"full": 9.0}
}
},
"hf_text_models": {
"Qwen3-4B-Instruct-2507 (locked commercial model)": {
"repo_id": "Qwen/Qwen3-4B-Instruct-2507",
"commercial_status": "cleared",
"revision": "cdbee75f17c01a7cc42f958dc650907174af0554",
"component_id": "MODEL-006",
"default": true,
"quantized": false,
"vram_requirement": {"full": 9.0}
}
}
}
+52 -40
View File
@@ -1,7 +1,17 @@
{
"hf_vl_models": {
"Qwen3-VL-4B-Instruct (locked commercial model)": {
"repo_id": "Qwen/Qwen3-VL-4B-Instruct",
"commercial_status": "cleared",
"revision": "ebb281ec70b05090aa6165b016eac8ec08e71b17",
"component_id": "MODEL-014",
"default": false,
"quantized": false,
"vram_requirement": {"full": 9.0}
},
"Qwen3.5-4B-heretic-v2 [DL: 7.5GB, VRAM: 9.0GB]": {
"repo_id": "tvall43/Qwen3.5-4B-heretic-v2",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -10,6 +20,7 @@
},
"Qwen3.5-9B-ultra-uncensored-heretic-v2 [DL: 17GB, VRAM: 19.0GB]": {
"repo_id": "llmfan46/Qwen3.5-9B-ultra-uncensored-heretic-v2",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -18,6 +29,7 @@
},
"Qwen3.5-4B [DL: 7.5GB, VRAM: 9.0GB]": {
"repo_id": "Qwen/Qwen3.5-4B",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -26,6 +38,7 @@
},
"Qwen3.5-9B [DL: 17GB, VRAM: 19.0GB]": {
"repo_id": "Qwen/Qwen3.5-9B",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -34,6 +47,7 @@
},
"Qwen3-VL-4B-Instruct-Abliterated [DL: 7.5GB, VRAM: 6.0GB]": {
"repo_id": "huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated",
"commercial_status": "unclear",
"default": true,
"quantized": true,
"vram_requirement": {
@@ -44,6 +58,7 @@
},
"Qwen3-VL-8B-Instruct-Abliterated [DL: 15GB, VRAM: 12.0GB]": {
"repo_id": "huihui-ai/Huihui-Qwen3-VL-8B-Instruct-abliterated",
"commercial_status": "unclear",
"default": false,
"quantized": true,
"vram_requirement": {
@@ -54,6 +69,7 @@
},
"Qwen3-VL-4B-Instruct-Unredacted-MAX [DL: 7.5GB, VRAM: 6.0GB]": {
"repo_id": "prithivMLmods/Qwen3-VL-4B-Instruct-Unredacted-MAX",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -64,6 +80,7 @@
},
"Qwen3-VL-8B-Instruct-Unredacted-MAX [DL: 15GB, VRAM: 12.0GB]": {
"repo_id": "prithivMLmods/Qwen3-VL-8B-Instruct-Unredacted-MAX",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -74,6 +91,7 @@
},
"Qwen3-VL-8B-Instruct-abliterated-v1 [DL: 15GB, VRAM: 12.0GB]": {
"repo_id": "prithivMLmods/Qwen3-VL-8B-Instruct-abliterated-v1",
"commercial_status": "external_gated",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -84,6 +102,7 @@
},
"Qwen3-VL-4B-Instruct-abliterated-v1 [DL: 7.5GB, VRAM: 6.0GB]": {
"repo_id": "prithivMLmods/Qwen3-VL-4B-Instruct-abliterated-v1",
"commercial_status": "external_gated",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -94,6 +113,7 @@
},
"Qwen3-VL-32B-Heretic-v2 [DL: 60GB, VRAM: 48.0GB]": {
"repo_id": "rpDungeon/Qwen3-VL-32B-Heretic-v2",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -104,6 +124,7 @@
},
"Gemma-4-26B-A4B-it [DL: 51.6GB, VRAM: 42.0GB]": {
"repo_id": "google/gemma-4-26B-A4B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -114,6 +135,7 @@
},
"Gemma-4-E4B-it [DL: 16GB, VRAM: 7.0GB]": {
"repo_id": "google/gemma-4-E4B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -124,6 +146,7 @@
},
"Gemma-4-E2B-it [DL: 10.3GB, VRAM: 4.0GB]": {
"repo_id": "google/gemma-4-E2B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -134,6 +157,7 @@
},
"Gemma-4-31B-it [DL: 62.6GB, VRAM: 48.0GB]": {
"repo_id": "google/gemma-4-31B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -144,6 +168,7 @@
},
"Gemma-4-12B [DL: 24GB, VRAM: 24.0GB]": {
"repo_id": "google/gemma-4-12B",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -154,6 +179,7 @@
},
"Gemma-4-E4B-Uncensored [DL: 16GB, VRAM: 7.0GB]": {
"repo_id": "prithivMLmods/gemma-4-E4B-it-Uncensored-MAX",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -164,6 +190,7 @@
},
"Gemma-4-E2B-Uncensored [DL: 10.3GB, VRAM: 4.0GB]": {
"repo_id": "prithivMLmods/gemma-4-E2B-it-Uncensored-MAX",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -174,6 +201,7 @@
},
"Gemma-4-26B-A4B-Heretic [DL: 51.6GB, VRAM: 42.0GB]": {
"repo_id": "coder3101/gemma-4-26B-A4B-it-heretic",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -181,31 +209,21 @@
"8bit": 22.0,
"4bit": 14.0
}
},
"Gemma-4-E4B-Uncensored-Aggressive [DL: 16GB, VRAM: 7.0GB]": {
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"default": false,
"quantized": false,
"vram_requirement": {
"full": 7.0,
"8bit": 4.5,
"4bit": 2.5
}
},
"Gemma-4-E2B-Uncensored-Aggressive [DL: 10.3GB, VRAM: 4.0GB]": {
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
"default": false,
"quantized": false,
"vram_requirement": {
"full": 4.0,
"8bit": 2.5,
"4bit": 1.5
}
}
},
"hf_text_models": {
"Qwen3-4B-Instruct-2507 (locked commercial model)": {
"repo_id": "Qwen/Qwen3-4B-Instruct-2507",
"commercial_status": "cleared",
"revision": "cdbee75f17c01a7cc42f958dc650907174af0554",
"component_id": "MODEL-006",
"default": false,
"quantized": false,
"vram_requirement": {"full": 9.0}
},
"Qwen3-VL-4B-Instruct-Abliterated [DL: 7.5GB, VRAM: 6.0GB]": {
"repo_id": "huihui-ai/Huihui-Qwen3-VL-4B-Instruct-abliterated",
"commercial_status": "unclear",
"default": true,
"quantized": true,
"vram_requirement": {
@@ -216,6 +234,7 @@
},
"Qwen3-VL-8B-Instruct-Abliterated [DL: 15GB, VRAM: 12.0GB]": {
"repo_id": "huihui-ai/Huihui-Qwen3-VL-8B-Instruct-abliterated",
"commercial_status": "unclear",
"default": false,
"quantized": true,
"vram_requirement": {
@@ -226,6 +245,7 @@
},
"qwen3-4b-Z-Image-Engineer [DL: 7.5GB, VRAM: 9.0GB]": {
"repo_id": "BennyDaBall/qwen3-4b-Z-Image-Engineer",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -234,6 +254,7 @@
},
"Qwen3.5-4B-heretic-v2 [DL: 7.5GB, VRAM: 8.0GB]": {
"repo_id": "tvall43/Qwen3.5-4B-heretic-v2",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -242,6 +263,7 @@
},
"Qwen3.5-9B-ultra-uncensored-heretic-v2 [DL: 17GB, VRAM: 18.0GB]": {
"repo_id": "llmfan46/Qwen3.5-9B-ultra-uncensored-heretic-v2",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -250,6 +272,7 @@
},
"Qwen3.5-4B [DL: 7.5GB, VRAM: 8.0GB]": {
"repo_id": "Qwen/Qwen3.5-4B",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -258,6 +281,7 @@
},
"Qwen3.5-9B [DL: 17GB, VRAM: 18.0GB]": {
"repo_id": "Qwen/Qwen3.5-9B",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -266,6 +290,7 @@
},
"Gemma-4-26B-A4B-it [DL: 51.6GB, VRAM: 42.0GB]": {
"repo_id": "google/gemma-4-26B-A4B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -276,6 +301,7 @@
},
"Gemma-4-E4B-it [DL: 16GB, VRAM: 7.0GB]": {
"repo_id": "google/gemma-4-E4B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -286,6 +312,7 @@
},
"Gemma-4-E2B-it [DL: 10.3GB, VRAM: 4.0GB]": {
"repo_id": "google/gemma-4-E2B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -296,6 +323,7 @@
},
"Gemma-4-31B-it [DL: 62.6GB, VRAM: 48.0GB]": {
"repo_id": "google/gemma-4-31B-it",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -306,6 +334,7 @@
},
"Gemma-4-12B [DL: 24GB, VRAM: 24.0GB]": {
"repo_id": "google/gemma-4-12B",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -316,6 +345,7 @@
},
"Gemma-4-E4B-Uncensored [DL: 16GB, VRAM: 7.0GB]": {
"repo_id": "prithivMLmods/gemma-4-E4B-it-Uncensored-MAX",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -326,6 +356,7 @@
},
"Gemma-4-E2B-Uncensored [DL: 10.3GB, VRAM: 4.0GB]": {
"repo_id": "prithivMLmods/gemma-4-E2B-it-Uncensored-MAX",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -336,6 +367,7 @@
},
"Gemma-4-26B-A4B-Heretic [DL: 51.6GB, VRAM: 42.0GB]": {
"repo_id": "coder3101/gemma-4-26B-A4B-it-heretic",
"commercial_status": "unclear",
"default": false,
"quantized": false,
"vram_requirement": {
@@ -343,26 +375,6 @@
"8bit": 22.0,
"4bit": 14.0
}
},
"Gemma-4-E4B-Uncensored-Aggressive [DL: 16GB, VRAM: 7.0GB]": {
"repo_id": "HauhauCS/Gemma-4-E4B-Uncensored-HauhauCS-Aggressive",
"default": false,
"quantized": false,
"vram_requirement": {
"full": 7.0,
"8bit": 4.5,
"4bit": 2.5
}
},
"Gemma-4-E2B-Uncensored-Aggressive [DL: 10.3GB, VRAM: 4.0GB]": {
"repo_id": "HauhauCS/Gemma-4-E2B-Uncensored-HauhauCS-Aggressive",
"default": false,
"quantized": false,
"vram_requirement": {
"full": 4.0,
"8bit": 2.5,
"4bit": 1.5
}
}
}
}
+902
View File
@@ -0,0 +1,902 @@
"""Secure OpenAI-compatible API node for ThinkingLLM.
Workflow/API input never receives provider credentials, credential environment
variable names, or arbitrary provider endpoints. It selects a server-side
profile that binds endpoint, authentication, capabilities, and request limits.
"""
from __future__ import annotations
import base64
import io
import ipaddress
import json
import math
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from AILab_OutputCleaner import OutputCleanConfig, clean_model_output
from AILab_StreamDisplay import compose_streamed_model_output, extract_stream_token, get_thinking_stream_display
PACKAGE_DIR = Path(__file__).resolve().parent.parent
DEFAULT_PROFILE_FILE = PACKAGE_DIR / "thinkingllm_api_profiles.json"
PROFILE_FILE_ENV = "THINKINGLLM_API_PROFILES_FILE"
DISABLE_BUILTINS_ENV = "THINKINGLLM_DISABLE_BUILTIN_API_PROFILES"
DEFAULT_MAX_INPUT_CHARS = 1_000_000
DEFAULT_MAX_TIMEOUT_SECONDS = 300
DEFAULT_MAX_IMAGE_PIXELS = 16_777_216
DEFAULT_MAX_IMAGE_BYTES = 20_000_000
MAX_EXTRA_BODY_JSON_CHARS = 65_536
MAX_MODEL_NAME_CHARS = 512
MAX_PROFILE_LABEL_CHARS = 128
MAX_IMAGE_URL_CHARS = 8_192
# Curated convenience model catalog for the node UI (see web/api_model_catalogs.json).
# It is UX only: it never overrides a server-side profile's allowed_models allowlist.
CATALOG_PATH = PACKAGE_DIR / "web" / "api_model_catalogs.json"
_CATALOG_CACHE: dict | None = None
_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f-\x9f]")
_IMAGE_ERROR_RE = re.compile(
r"(?i)(image|vision|multimodal|image_url|modality|unsupported\s+(?:content|input)|invalid\s+(?:content|input))"
)
_RESERVED_EXTRA_BODY_FIELDS = {
"model", "messages", "stream", "max_tokens", "max_completion_tokens",
"temperature", "top_p", "seed", "enable_thinking", "thinking_budget",
}
_PROFILE_ALLOWED_FIELDS = {
"provider", "base_url", "auth", "api_key_env", "allowed_models",
"max_tokens_limit", "max_input_chars", "max_timeout_seconds", "max_thinking_budget",
"allowed_extra_body_fields", "send_seed", "thinking_mode",
"max_tokens_field", "allow_insecure_http",
"allow_images", "allow_image_url", "max_image_pixels", "max_image_bytes",
}
def _cloud_profile(provider: str, base_url: str, api_key_env: str, **extra) -> dict:
profile = {
"provider": provider,
"base_url": base_url,
"auth": "bearer_env",
"api_key_env": api_key_env,
"allow_images": True,
"allow_image_url": True,
}
profile.update(extra)
return profile
def _local_profile(provider: str, base_url: str, *, send_seed: bool = False) -> dict:
return {
"provider": provider,
"base_url": base_url,
"auth": "none",
"allow_images": True,
"allow_image_url": True,
"send_seed": send_seed,
}
_BUILTIN_PROFILES = {
"OpenAI": _cloud_profile(
"OpenAI", "https://api.openai.com/v1", "OPENAI_API_KEY",
max_tokens_field="max_completion_tokens",
),
"QwenCloud (Singapore)": _cloud_profile(
"QwenCloud", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
"DASHSCOPE_API_KEY", thinking_mode="qwen",
),
"OpenRouter": _cloud_profile(
"OpenRouter", "https://openrouter.ai/api/v1", "OPENROUTER_API_KEY"
),
"Together AI": _cloud_profile(
"Together AI", "https://api.together.ai/v1", "TOGETHER_API_KEY"
),
"Fireworks AI": _cloud_profile(
"Fireworks AI", "https://api.fireworks.ai/inference/v1", "FIREWORKS_API_KEY"
),
"DeepInfra": _cloud_profile(
"DeepInfra", "https://api.deepinfra.com/v1/openai", "DEEPINFRA_TOKEN"
),
"Groq": _cloud_profile(
"Groq", "https://api.groq.com/openai/v1", "GROQ_API_KEY"
),
"Featherless": _cloud_profile(
"Featherless", "https://api.featherless.ai/v1", "FEATHERLESS_API_KEY"
),
# OrcaRouter serves reasoning-heavy models with long TTFT (e.g. obsidian/*
# uncensored variants) that can exceed the default 300 s deadline, so it gets
# a higher server-side cap. The widget still enforces a hard maximum of 3600.
"OrcaRouter": _cloud_profile(
"OrcaRouter", "https://api.orcarouter.ai/v1", "ORCAROUTER_API_KEY",
max_timeout_seconds=900,
),
"Ollama (local)": _local_profile("Ollama", "http://127.0.0.1:11434/v1"),
"vLLM (local)": _local_profile("vLLM", "http://127.0.0.1:8000/v1", send_seed=True),
"llama.cpp (local)": _local_profile("llama.cpp", "http://127.0.0.1:8080/v1", send_seed=True),
}
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject redirects so bearer credentials never cross an origin boundary."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler())
class _ProviderStreamError(RuntimeError):
pass
def _profile_file_path() -> Path:
configured = os.environ.get(PROFILE_FILE_ENV, "").strip()
return Path(configured).expanduser() if configured else DEFAULT_PROFILE_FILE
def _has_control_chars(value: str) -> bool:
return bool(_CONTROL_CHAR_RE.search(str(value or "")))
def _safe_label(value: str, field: str, max_chars: int = MAX_PROFILE_LABEL_CHARS) -> str:
text = str(value or "").strip()
if not text:
raise ValueError(f"{field} must not be empty.")
if len(text) > max_chars:
raise ValueError(f"{field} is too long (maximum {max_chars} characters).")
if _has_control_chars(text):
raise ValueError(f"{field} must not contain control characters.")
return text
def _strict_bool(raw: dict, key: str, default: bool = False) -> bool:
value = raw.get(key, default)
if not isinstance(value, bool):
raise ValueError(f"API profile {key} must be a JSON boolean.")
return value
def _profile_int(raw: dict, key: str, default=None, minimum: int = 1, maximum: int | None = None):
value = raw.get(key, default)
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"API profile {key} must be an integer.")
if value < minimum or (maximum is not None and value > maximum):
range_text = f">= {minimum}" if maximum is None else f"between {minimum} and {maximum}"
raise ValueError(f"API profile {key} must be {range_text}.")
return value
def _is_loopback_host(hostname: str | None) -> bool:
host = str(hostname or "").strip().rstrip(".").lower()
if host == "localhost":
return True
try:
return ipaddress.ip_address(host).is_loopback
except ValueError:
return False
def _validate_base_url(base_url: str, auth: str, allow_insecure_http: bool = False) -> str:
value = str(base_url or "").strip().rstrip("/")
if not value or _has_control_chars(value):
raise ValueError("API profile base_url is empty or contains control characters.")
parsed = urllib.parse.urlparse(value)
if parsed.scheme not in {"http", "https"} or not parsed.netloc or not parsed.hostname:
raise ValueError(f"Invalid API profile base_url: {value!r}")
if parsed.username or parsed.password:
raise ValueError("API profile base_url must not contain embedded credentials.")
if parsed.query or parsed.fragment:
raise ValueError("API profile base_url must not contain a query string or fragment.")
try:
_ = parsed.port
except ValueError as exc:
raise ValueError("API profile base_url contains an invalid port.") from exc
if auth != "none" and parsed.scheme != "https":
raise ValueError("Authenticated API profiles must use HTTPS.")
if parsed.scheme == "http" and auth == "none" and not _is_loopback_host(parsed.hostname) and not allow_insecure_http:
raise ValueError(
"Plain HTTP is allowed only for loopback endpoints by default. "
"Set allow_insecure_http=true only for administrator-approved cleartext LAN/internal traffic."
)
return value
def _validate_model_identifier(value: str, field: str = "model_name") -> str:
model = str(value or "").strip()
if not model:
raise ValueError(f"{field} must not be empty.")
if len(model) > MAX_MODEL_NAME_CHARS:
raise ValueError(f"{field} is too long (maximum {MAX_MODEL_NAME_CHARS} characters).")
if _has_control_chars(model):
raise ValueError(f"{field} must not contain control characters.")
return model
def _normalize_profile(name: str, raw: dict) -> dict:
profile_name = _safe_label(name, "API profile name")
if not isinstance(raw, dict):
raise ValueError(f"API profile {profile_name!r} must be a JSON object.")
forbidden = sorted({"api_key", "token", "bearer_token", "authorization", "headers", "secret"} & set(raw))
if forbidden:
raise ValueError(f"API profile {profile_name!r} contains forbidden secret/header fields: {', '.join(forbidden)}.")
if "allow_extra_body" in raw:
raise ValueError(f"API profile {profile_name!r} uses deprecated allow_extra_body; use allowed_extra_body_fields.")
unknown = sorted(set(raw) - _PROFILE_ALLOWED_FIELDS)
if unknown:
raise ValueError(f"API profile {profile_name!r} contains unknown fields: {', '.join(unknown)}")
provider = _safe_label(raw.get("provider") or profile_name, "provider")
auth = str(raw.get("auth") or "bearer_env").strip().lower()
if auth not in {"bearer_env", "none"}:
raise ValueError(f"API profile {profile_name!r} auth must be 'bearer_env' or 'none'.")
allow_insecure_http = _strict_bool(raw, "allow_insecure_http", False)
base_url = _validate_base_url(raw.get("base_url", ""), auth, allow_insecure_http)
api_key_env = str(raw.get("api_key_env") or "").strip()
if auth == "bearer_env":
if not _ENV_NAME_RE.fullmatch(api_key_env):
raise ValueError(f"API profile {profile_name!r} requires a valid environment-variable name in api_key_env.")
elif api_key_env:
raise ValueError(f"API profile {profile_name!r} must not set api_key_env when auth='none'.")
raw_models = raw.get("allowed_models")
allowed_models = None
if raw_models is not None:
if not isinstance(raw_models, list) or not all(isinstance(item, str) for item in raw_models):
raise ValueError(f"API profile {profile_name!r} allowed_models must be a list of strings.")
allowed_models = [_validate_model_identifier(item, "allowed model") for item in raw_models]
raw_extra = raw.get("allowed_extra_body_fields") or []
if not isinstance(raw_extra, list) or not all(isinstance(item, str) for item in raw_extra):
raise ValueError(f"API profile {profile_name!r} allowed_extra_body_fields must be a list of strings.")
allowed_extra = []
for item in raw_extra:
field = item.strip()
if not field or len(field) > 128 or _has_control_chars(field):
raise ValueError(f"API profile {profile_name!r} contains an invalid extra-body field name.")
if field in _RESERVED_EXTRA_BODY_FIELDS:
raise ValueError(f"API profile {profile_name!r} cannot allow protected extra-body field {field!r}.")
if field not in allowed_extra:
allowed_extra.append(field)
thinking_mode = str(raw.get("thinking_mode") or "").strip().lower()
if thinking_mode not in {"", "qwen"}:
raise ValueError(f"API profile {profile_name!r} has unsupported thinking_mode {thinking_mode!r}.")
max_tokens_field = str(raw.get("max_tokens_field") or "max_tokens").strip()
if max_tokens_field not in {"max_tokens", "max_completion_tokens"}:
raise ValueError(f"API profile {profile_name!r} has invalid max_tokens_field.")
allow_images = _strict_bool(raw, "allow_images", False)
allow_image_url = _strict_bool(raw, "allow_image_url", False)
if allow_image_url and not allow_images:
raise ValueError(f"API profile {profile_name!r} cannot enable allow_image_url while allow_images is false.")
return {
"name": profile_name,
"provider": provider,
"base_url": base_url,
"auth": auth,
"api_key_env": api_key_env,
"allowed_models": allowed_models,
"max_tokens_limit": _profile_int(raw, "max_tokens_limit", None, maximum=65_536),
"max_input_chars": _profile_int(raw, "max_input_chars", DEFAULT_MAX_INPUT_CHARS, maximum=20_000_000),
"max_timeout_seconds": _profile_int(raw, "max_timeout_seconds", DEFAULT_MAX_TIMEOUT_SECONDS, maximum=3_600),
"max_thinking_budget": _profile_int(raw, "max_thinking_budget", 262_144, minimum=0, maximum=262_144),
"allowed_extra_body_fields": allowed_extra,
"thinking_mode": thinking_mode,
"send_seed": _strict_bool(raw, "send_seed", False),
"max_tokens_field": max_tokens_field,
"allow_insecure_http": allow_insecure_http,
"allow_images": allow_images,
"allow_image_url": allow_image_url,
"max_image_pixels": _profile_int(raw, "max_image_pixels", DEFAULT_MAX_IMAGE_PIXELS, maximum=100_000_000),
"max_image_bytes": _profile_int(raw, "max_image_bytes", DEFAULT_MAX_IMAGE_BYTES, maximum=100_000_000),
}
def _load_api_profiles() -> dict[str, dict]:
profiles = {}
if os.environ.get(DISABLE_BUILTINS_ENV, "").strip() != "1":
for name, raw in _BUILTIN_PROFILES.items():
profiles[name] = _normalize_profile(name, raw)
path = _profile_file_path()
if not path.exists():
return profiles
try:
with path.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
print(f"[ThinkingLLM API] Failed to load API profiles from {path}: {exc}")
return profiles
custom = payload.get("profiles", payload) if isinstance(payload, dict) else {}
if not isinstance(custom, dict):
print(f"[ThinkingLLM API] Ignoring invalid API profile file {path}: expected object.")
return profiles
for name, raw in custom.items():
profile_name = str(name or "").strip()
if not profile_name:
continue
profiles.pop(profile_name, None)
try:
profiles[profile_name] = _normalize_profile(profile_name, raw)
except ValueError as exc:
print(f"[ThinkingLLM API] Ignoring invalid profile {profile_name!r}: {exc}")
return profiles
def _resolve_profile(profile_name: str) -> dict:
name = str(profile_name or "").strip()
profile = _load_api_profiles().get(name)
if profile is None:
print(f"[ThinkingLLM API] Unknown API profile {name!r}; profile file is {_profile_file_path()}.")
raise ValueError("Unknown or unavailable server-side API profile.")
return profile
def _load_model_catalog() -> dict:
"""Return the curated convenience model catalog (UX only, never an allowlist).
Read-only and failure-tolerant: a missing or broken catalog must never
block the node. The catalog only feeds the front-end dropdown; request
security is enforced by the profile's allowed_models, not by this file.
"""
global _CATALOG_CACHE
if _CATALOG_CACHE is not None:
return _CATALOG_CACHE
try:
with CATALOG_PATH.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
print(f"[ThinkingLLM API] Failed to load model catalog from {CATALOG_PATH}: {exc}")
_CATALOG_CACHE = {}
return _CATALOG_CACHE
profiles = payload.get("profiles") if isinstance(payload, dict) else None
_CATALOG_CACHE = profiles if isinstance(profiles, dict) else {}
return _CATALOG_CACHE
def _catalog_models_for_profile(profile_name: str) -> list[str]:
"""Convenience model IDs for a profile name, used by the node UI only."""
entry = _load_model_catalog().get(str(profile_name or ""))
if not isinstance(entry, dict):
return []
models = entry.get("models")
if not isinstance(models, list):
return []
ids: list[str] = []
for item in models:
if isinstance(item, dict) and isinstance(item.get("id"), str):
ids.append(item["id"])
return ids
def _catalog_all_model_ids() -> list[str]:
"""Union of every curated model ID across all profiles (deduplicated).
Used as the backend combo list so that any saved model_name from any
profile still passes ComfyUI's front-end validation ("Value not in list").
The front-end narrows the visible dropdown to the selected profile.
"""
seen: dict[str, None] = {}
for entry in _load_model_catalog().values():
if not isinstance(entry, dict):
continue
models = entry.get("models")
if not isinstance(models, list):
continue
for item in models:
if isinstance(item, dict) and isinstance(item.get("id"), str):
seen.setdefault(item["id"], None)
return list(seen)
def _credential_value(profile: dict) -> str:
if profile["auth"] == "none":
return ""
env_name = profile["api_key_env"]
value = os.environ.get(env_name, "").strip()
if not value:
print(f"[ThinkingLLM API] Profile {profile['name']!r} requires environment variable {env_name!r}, but it is not set.")
raise RuntimeError("API credential is not configured for the selected server-side profile.")
return value
def _authorization_headers(profile: dict) -> dict[str, str]:
value = _credential_value(profile)
return {"Authorization": f"Bearer {value}"} if value else {}
def _reject_json_constant(value: str):
raise ValueError(f"Non-finite JSON constant {value!r} is not allowed.")
def _parse_extra_json(extra_body_json: str, allowed_fields) -> dict:
raw = str(extra_body_json or "").strip()
if not raw:
return {}
if len(raw) > MAX_EXTRA_BODY_JSON_CHARS:
raise ValueError(f"extra_body_json exceeds the {MAX_EXTRA_BODY_JSON_CHARS}-character safety limit.")
allowed = set(allowed_fields or [])
if not allowed:
raise ValueError("extra_body_json is disabled by the selected server-side API profile.")
try:
value = json.loads(raw, parse_constant=_reject_json_constant)
except json.JSONDecodeError as exc:
raise ValueError(f"extra_body_json is not valid JSON: {exc}") from exc
if not isinstance(value, dict):
raise ValueError("extra_body_json must contain a JSON object.")
reserved = sorted(_RESERVED_EXTRA_BODY_FIELDS & set(value))
if reserved:
raise ValueError(f"extra_body_json cannot override protected request fields: {', '.join(reserved)}")
unapproved = sorted(set(value) - allowed)
if unapproved:
raise ValueError(f"extra_body_json contains fields not allowed by the server-side profile: {', '.join(unapproved)}")
return value
def _validate_model(profile: dict, model_name: str) -> str:
model = _validate_model_identifier(model_name)
if profile.get("allowed_models") is not None and model not in profile["allowed_models"]:
raise ValueError(f"Model {model!r} is not allowed by API profile {profile['name']!r}.")
return model
def _validate_max_tokens(profile: dict, value) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 65_536:
raise ValueError("max_tokens must be an integer between 1 and 65536.")
limit = profile.get("max_tokens_limit")
if limit is not None and value > limit:
raise ValueError(f"Requested max_tokens={value} exceeds server-side limit {limit} for API profile {profile['name']!r}.")
return value
def _validate_timeout(profile: dict, value) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= 3_600:
raise ValueError("timeout_seconds must be an integer between 1 and 3600.")
limit = profile.get("max_timeout_seconds", DEFAULT_MAX_TIMEOUT_SECONDS)
if value > limit:
raise ValueError(f"Requested timeout_seconds={value} exceeds server-side limit {limit} for API profile {profile['name']!r}.")
return value
def _validate_sampling(temperature, top_p, seed, thinking_budget):
if isinstance(temperature, bool) or not isinstance(temperature, (int, float)):
raise ValueError("temperature must be a numeric value.")
if isinstance(top_p, bool) or not isinstance(top_p, (int, float)):
raise ValueError("top_p must be a numeric value.")
t, p = float(temperature), float(top_p)
if not math.isfinite(t) or not 0.0 <= t <= 2.0:
raise ValueError("temperature must be a finite value between 0.0 and 2.0.")
if not math.isfinite(p) or not 0.0 <= p <= 1.0:
raise ValueError("top_p must be a finite value between 0.0 and 1.0.")
if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed <= 2**32 - 1:
raise ValueError("seed must be an integer in the supported range.")
if isinstance(thinking_budget, bool) or not isinstance(thinking_budget, int) or not 0 <= thinking_budget <= 262_144:
raise ValueError("thinking_budget must be an integer between 0 and 262144.")
return t, p, seed, thinking_budget
def _validate_thinking_budget(profile: dict, value: int) -> int:
if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= 262_144:
raise ValueError("thinking_budget must be an integer between 0 and 262144.")
limit = profile.get("max_thinking_budget", 262_144)
if value > limit:
raise ValueError(
f"Requested thinking_budget={value} exceeds server-side limit {limit} "
f"for API profile {profile['name']!r}."
)
return value
def _validate_input_text(profile: dict, system_prompt, prompt) -> tuple[str, str]:
if system_prompt is not None and not isinstance(system_prompt, str):
raise ValueError("system_prompt must be a string.")
if prompt is not None and not isinstance(prompt, str):
raise ValueError("prompt must be a string.")
system_text, prompt_text = system_prompt or "", prompt or ""
total = len(system_text) + len(prompt_text)
limit = profile.get("max_input_chars", DEFAULT_MAX_INPUT_CHARS)
if total > limit:
raise ValueError(f"Combined system_prompt + prompt length ({total}) exceeds server-side max_input_chars={limit} for API profile {profile['name']!r}.")
return system_text, prompt_text
def _validate_image_url(profile: dict, image_url) -> str:
value = str(image_url or "").strip()
if not value:
return ""
if not profile.get("allow_images", False):
raise ValueError(f"Image input is disabled by API profile {profile['name']!r}.")
if not profile.get("allow_image_url", False):
raise ValueError(f"image_url input is disabled by API profile {profile['name']!r}.")
if len(value) > MAX_IMAGE_URL_CHARS or _has_control_chars(value):
raise ValueError("image_url is too long or contains control characters.")
parsed = urllib.parse.urlparse(value)
if parsed.scheme != "https" or not parsed.netloc or not parsed.hostname:
raise ValueError("image_url must be a public HTTPS URL.")
if parsed.username or parsed.password:
raise ValueError("image_url must not contain embedded credentials.")
if parsed.fragment:
raise ValueError("image_url must not contain a URL fragment.")
return value
def _image_to_data_url(profile: dict, image) -> str:
if image is None:
return ""
if not profile.get("allow_images", False):
raise ValueError(f"Image input is disabled by API profile {profile['name']!r}.")
try:
import numpy as np
from PIL import Image
except ImportError as exc:
raise RuntimeError("Image input requires NumPy and Pillow in the ComfyUI Python environment.") from exc
shape = tuple(int(part) for part in getattr(image, "shape", ()))
if len(shape) == 4:
if shape[0] != 1:
raise ValueError("The remote API node currently accepts exactly one IMAGE, not an image batch.")
height, width, channels = shape[1], shape[2], shape[3]
frame = image[0]
elif len(shape) == 3:
height, width, channels = shape
frame = image
else:
raise ValueError("IMAGE input must have shape [B,H,W,C] or [H,W,C].")
if height < 1 or width < 1 or channels not in {1, 3, 4}:
raise ValueError("IMAGE input has unsupported dimensions or channel count.")
pixels = height * width
pixel_limit = profile.get("max_image_pixels", DEFAULT_MAX_IMAGE_PIXELS)
if pixels > pixel_limit:
raise ValueError(
f"IMAGE input has {pixels} pixels, exceeding server-side max_image_pixels={pixel_limit} "
f"for API profile {profile['name']!r}."
)
if hasattr(frame, "detach"):
frame = frame.detach()
if hasattr(frame, "cpu"):
frame = frame.cpu()
if hasattr(frame, "numpy"):
frame = frame.numpy()
array = np.asarray(frame, dtype=np.float32)
if array.ndim != 3 or array.shape[2] not in {1, 3, 4}:
raise ValueError("IMAGE input could not be converted to a supported HWC array.")
array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=0.0)
array = np.rint(np.clip(array, 0.0, 1.0) * 255.0).astype(np.uint8)
if array.shape[2] == 1:
array = array[:, :, 0]
mode = "L"
elif array.shape[2] == 4:
mode = "RGBA"
else:
mode = "RGB"
buffer = io.BytesIO()
Image.fromarray(array, mode=mode).save(buffer, format="PNG")
encoded_bytes = buffer.getvalue()
byte_limit = profile.get("max_image_bytes", DEFAULT_MAX_IMAGE_BYTES)
if len(encoded_bytes) > byte_limit:
raise ValueError(
f"PNG-encoded IMAGE is {len(encoded_bytes)} bytes, exceeding server-side "
f"max_image_bytes={byte_limit} for API profile {profile['name']!r}."
)
return "data:image/png;base64," + base64.b64encode(encoded_bytes).decode("ascii")
def _build_user_content(profile: dict, prompt_text: str, image=None, image_url=""):
url = str(image_url or "").strip()
if image is not None and url:
raise ValueError("Use either the IMAGE input or image_url, not both in the same request.")
if image is None and not url:
return prompt_text, False
if image is not None:
source = _image_to_data_url(profile, image)
else:
source = _validate_image_url(profile, url)
return [
{"type": "text", "text": prompt_text},
{"type": "image_url", "image_url": {"url": source}},
], True
def _read_error_body(exc: urllib.error.HTTPError) -> str:
try:
data = exc.read()
except Exception:
return ""
return data.decode("utf-8", errors="replace").strip() if isinstance(data, bytes) else str(data or "").strip()
def _terminal_display_safe(text: str) -> str:
return "".join(ch for ch in str(text or "") if ch in {"\n", "\t"} or (ord(ch) >= 0x20 and not 0x7F <= ord(ch) <= 0x9F))
def _redact_for_log(text: str, profile: dict) -> str:
value = _terminal_display_safe(text)
if profile.get("auth") == "bearer_env":
secret = os.environ.get(profile.get("api_key_env", ""), "")
if secret:
value = value.replace(secret, "<redacted>")
return value[:4096]
def _reasoning_details_text(container) -> str:
if not isinstance(container, dict) or not isinstance(container.get("reasoning_details"), list):
return ""
parts = []
for item in container["reasoning_details"]:
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
for key in ("text", "summary", "content", "reasoning"):
if isinstance(item.get(key), str) and item[key]:
parts.append(item[key])
break
return "".join(parts)
def _extract_stream_token_extended(chunk) -> dict[str, str]:
token = extract_stream_token(chunk)
if token.get("reasoning") or not isinstance(chunk, dict):
return token
choices = chunk.get("choices") or []
choice = choices[0] if isinstance(choices, list) and choices else {}
delta = choice.get("delta") if isinstance(choice, dict) else {}
message = choice.get("message") if isinstance(choice, dict) else {}
for container in (delta, message, choice, chunk):
reasoning = _reasoning_details_text(container)
if reasoning:
return {"reasoning": reasoning, "content": token.get("content", "")}
return token
def _provider_error_from_payload(payload) -> str:
if not isinstance(payload, dict):
return ""
error = payload.get("error")
if error:
if isinstance(error, dict):
code = error.get("code")
message = error.get("message") or error.get("type") or "provider stream error"
return f"{code}: {message}" if code is not None else str(message)
return str(error)
choices = payload.get("choices") or []
if isinstance(choices, list) and choices and isinstance(choices[0], dict) and choices[0].get("finish_reason") == "error":
return "provider reported finish_reason=error"
return ""
def _extract_nonstream_response(payload: dict) -> tuple[str, str]:
error = _provider_error_from_payload(payload)
if error:
raise _ProviderStreamError(error)
choices = payload.get("choices") or []
choice = choices[0] if choices else {}
message = choice.get("message") or {}
content = message.get("content") or choice.get("text") or ""
reasoning = (
message.get("reasoning_content") or message.get("reasoning") or message.get("thinking")
or choice.get("reasoning_content") or _reasoning_details_text(message)
or _reasoning_details_text(choice) or ""
)
return str(content or ""), str(reasoning or "")
def _open_request(req: urllib.request.Request, timeout_seconds: int):
return _NO_REDIRECT_OPENER.open(req, timeout=timeout_seconds)
def _image_rejection_message(profile: dict, model: str, status=None) -> str:
status_text = f" (HTTP {status})" if status is not None else ""
return (
f"Image input was rejected by API profile {profile['name']!r} / model {model!r}{status_text}. "
"Use a vision-capable model supported by that provider, or remove the IMAGE/image_url input."
)
def _looks_like_image_error(detail: str) -> bool:
return bool(_IMAGE_ERROR_RE.search(str(detail or "")))
class ThinkingLLMOpenAICompatibleAPI:
@classmethod
def INPUT_TYPES(cls):
profiles = list(_load_api_profiles()) or ["No API profiles configured"]
default_profile = "OpenRouter" if "OpenRouter" in profiles else profiles[0]
default_models = _catalog_models_for_profile(default_profile)
if not default_models:
default_models = ["provider/model-id"]
# Use the union of all profile models so any saved model_name from any
# profile passes ComfyUI's front-end combo validation. The JS narrows
# the visible dropdown to the selected api_profile.
all_models = _catalog_all_model_ids()
if not all_models:
all_models = default_models
return {
"required": {
"api_profile": (profiles, {"default": default_profile, "tooltip": "Server-side profile binding endpoint, authentication, credential reference, capabilities, and safety limits."}),
"model_name": (all_models, {"default": default_models[0], "tooltip": "Curated model for the selected profile. For anything not listed, leave this as-is and type the ID in custom_model_name below."}),
"system_prompt": ("STRING", {"default": "You are a helpful assistant.", "multiline": True}),
"prompt": ("STRING", {"default": "", "multiline": True}),
"max_tokens": ("INT", {"default": 8192, "min": 1, "max": 65536}),
"temperature": ("FLOAT", {"default": 0.6, "min": 0.0, "max": 2.0, "step": 0.05}),
"top_p": ("FLOAT", {"default": 0.95, "min": 0.0, "max": 1.0, "step": 0.01}),
"seed": ("INT", {"default": 1, "min": 0, "max": 2**32 - 1, "tooltip": "Sent only when the server profile enables send_seed."}),
"enable_thinking": ("BOOLEAN", {"default": True, "tooltip": "Used by profiles with thinking_mode='qwen'."}),
"thinking_budget": ("INT", {"default": 8192, "min": 0, "max": 262144}),
"stream_tokens_to_terminal": ("BOOLEAN", {"default": False, "tooltip": "Print a control-character-sanitized display copy to the ComfyUI terminal."}),
"timeout_seconds": ("INT", {"default": 300, "min": 1, "max": 3600, "tooltip": "Idle timeout: aborts only if the provider sends nothing for this many seconds. Long reasoning streams are allowed to continue. Server profile enforces max_timeout_seconds (300 by default; OrcaRouter 900)."}),
},
"optional": {
"custom_model_name": ("STRING", {"default": "", "multiline": False, "tooltip": "Optional: exact model ID for anything not in the curated list. When set, it overrides model_name."}),
"image": ("IMAGE", {"tooltip": "Single ComfyUI IMAGE. Encoded as PNG Base64 data URL and sent as OpenAI-style image_url content."}),
"image_url": ("STRING", {"default": "", "multiline": False, "tooltip": "Alternative to IMAGE: public HTTPS image URL. Do not connect IMAGE at the same time."}),
"extra_body_json": ("STRING", {"default": "", "multiline": True, "tooltip": "Every top-level field must be explicitly allowlisted by the server profile."}),
},
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("RESPONSE", "RAW_TRACE")
FUNCTION = "generate"
CATEGORY = "ThinkingLLM/API"
DESCRIPTION = "Calls a server-approved OpenAI-compatible /chat/completions profile with optional image input; credentials and endpoint configuration stay server-side."
@classmethod
def IS_CHANGED(cls, *args, **kwargs):
return float("NaN")
def generate(
self, api_profile, model_name, system_prompt, prompt, max_tokens, temperature, top_p,
seed, enable_thinking, thinking_budget, stream_tokens_to_terminal, timeout_seconds,
custom_model_name="", image=None, image_url="", extra_body_json="",
):
profile = _resolve_profile(api_profile)
if not isinstance(enable_thinking, bool) or not isinstance(stream_tokens_to_terminal, bool):
raise ValueError("Boolean node inputs must be booleans.")
# A custom model ID overrides the curated dropdown selection.
if str(custom_model_name or "").strip():
model_name = custom_model_name
model = _validate_model(profile, model_name)
max_out = _validate_max_tokens(profile, max_tokens)
timeout = _validate_timeout(profile, timeout_seconds)
temperature, top_p, seed, thinking_budget = _validate_sampling(temperature, top_p, seed, thinking_budget)
thinking_budget = _validate_thinking_budget(profile, thinking_budget)
system_text, prompt_text = _validate_input_text(profile, system_prompt, prompt)
user_content, image_supplied = _build_user_content(profile, prompt_text, image=image, image_url=image_url)
messages = []
if system_text.strip():
messages.append({"role": "system", "content": system_text})
messages.append({"role": "user", "content": user_content})
request_body = {
"model": model,
"messages": messages,
profile.get("max_tokens_field", "max_tokens"): max_out,
"temperature": temperature,
"top_p": top_p,
"stream": True,
}
if profile.get("send_seed"):
request_body["seed"] = seed
if profile.get("thinking_mode") == "qwen":
request_body["enable_thinking"] = enable_thinking
if thinking_budget > 0:
request_body["thinking_budget"] = thinking_budget
request_body.update(_parse_extra_json(extra_body_json, profile.get("allowed_extra_body_fields", [])))
request_body["stream"] = True
headers = {
"Content-Type": "application/json",
"Accept": "text/event-stream",
"User-Agent": "ComfyUI-ThinkingLLM/OpenAI-Compatible-API",
}
headers.update(_authorization_headers(profile))
try:
payload = json.dumps(request_body, ensure_ascii=False, allow_nan=False).encode("utf-8")
except (TypeError, ValueError) as exc:
raise ValueError("Unable to serialize the API request safely.") from exc
req = urllib.request.Request(profile["base_url"] + "/chat/completions", data=payload, headers=headers, method="POST")
reasoning_parts, content_parts = [], []
display = get_thinking_stream_display() if stream_tokens_to_terminal else None
if display is not None:
display.start_stage(_terminal_display_safe(f"API {profile['provider']} / {model}"))
# Idle timeout: abort only when the provider sends nothing for `timeout`
# seconds. A long reasoning stream that keeps emitting tokens must be
# allowed to run — reasoning-heavy models (e.g. obsidian/* uncensored)
# routinely stream for longer than the configured timeout in total.
deadline = time.monotonic() + timeout
try:
with _open_request(req, timeout) as response:
if "text/event-stream" not in str(response.headers.get("Content-Type", "")).lower():
raw = response.read().decode("utf-8", errors="replace")
content, reasoning = _extract_nonstream_response(json.loads(raw, parse_constant=_reject_json_constant))
content_parts.append(content)
reasoning_parts.append(reasoning)
if display is not None:
display.push_compact(_terminal_display_safe(reasoning or content))
else:
for raw_line in response:
# Idle detection: reset the deadline whenever any line arrives.
deadline = time.monotonic() + timeout
line = raw_line.decode("utf-8", errors="replace").strip()
if not line or line.startswith(":"):
# Heartbeat/keep-alive lines also count as activity.
deadline = time.monotonic() + timeout
continue
line = line[5:].strip() if line.startswith("data:") else line
if line == "[DONE]":
break
try:
chunk = json.loads(line, parse_constant=_reject_json_constant)
except json.JSONDecodeError:
continue
error = _provider_error_from_payload(chunk)
if error:
raise _ProviderStreamError(error)
token = _extract_stream_token_extended(chunk)
reasoning, content = token.get("reasoning", ""), token.get("content", "")
if reasoning:
reasoning_parts.append(reasoning)
if display is not None:
display.push_compact(_terminal_display_safe(reasoning))
if content:
content_parts.append(content)
if display is not None:
display.push_compact(_terminal_display_safe(content))
if time.monotonic() > deadline:
raise TimeoutError("provider stream idle timeout: no data received within the configured request deadline")
except urllib.error.HTTPError as exc:
detail = f"HTTP {exc.code} {exc.reason}"
body = _redact_for_log(_read_error_body(exc), profile)
if body:
detail += f": {body}"
print(f"[ThinkingLLM API] Request failed for profile {profile['name']!r}: {detail}")
if 300 <= int(exc.code) < 400:
raise RuntimeError(f"ThinkingLLM API redirect rejected for profile {profile['name']!r}.") from exc
if image_supplied and (int(exc.code) in {400, 415, 422} or _looks_like_image_error(body)):
raise RuntimeError(_image_rejection_message(profile, model, exc.code)) from exc
raise RuntimeError(f"ThinkingLLM API request failed for profile {profile['name']!r} with HTTP status {exc.code}.") from exc
except urllib.error.URLError as exc:
print(f"[ThinkingLLM API] Network error for profile {profile['name']!r}: {_redact_for_log(str(exc.reason), profile)}")
raise RuntimeError(f"ThinkingLLM API network request failed for profile {profile['name']!r}.") from exc
except (TimeoutError, _ProviderStreamError) as exc:
print(f"[ThinkingLLM API] Provider stream failed for profile {profile['name']!r}: {_redact_for_log(str(exc), profile)}")
if image_supplied and _looks_like_image_error(str(exc)):
raise RuntimeError(_image_rejection_message(profile, model)) from exc
raise RuntimeError(f"ThinkingLLM API stream failed for profile {profile['name']!r}.") from exc
finally:
if display is not None:
display.end_stage()
content_text = "".join(content_parts).strip()
reasoning_text = "".join(reasoning_parts).strip()
raw_trace = compose_streamed_model_output(reasoning_text, content_text)
response_text = clean_model_output(
content_text,
OutputCleanConfig(
mode="text", strip_think=True, strip_code_fences=False, strip_role_prefixes=False,
strip_json_wrappers=False, strip_leading_preamble=False, strip_planning=False,
),
)
return (response_text, raw_trace)
NODE_CLASS_MAPPINGS = {"ThinkingLLM_OpenAICompatibleAPI": ThinkingLLMOpenAICompatibleAPI}
NODE_DISPLAY_NAME_MAPPINGS = {"ThinkingLLM_OpenAICompatibleAPI": "ThinkingLLM API (OpenAI Compatible)"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+84
View File
@@ -0,0 +1,84 @@
"""Standalone system-prompt preset selector for ThinkingLLM."""
from __future__ import annotations
import json
from pathlib import Path
SYSTEM_PROMPTS_PATH = Path(__file__).resolve().parent.parent / "AILab_System_Prompts.json"
NO_PRESET_PROMPT = "🚫 No preset (image-only)"
def _load_system_prompt_presets() -> tuple[list[str], dict[str, str]]:
"""Load the same preset order and prompt strings used by ThinkingLLM."""
try:
with SYSTEM_PROMPTS_PATH.open("r", encoding="utf-8") as file:
data = json.load(file) or {}
except (OSError, json.JSONDecodeError) as exc:
print(f"[ThinkingLLM System Prompt Preset] Failed to load presets: {exc}")
return [NO_PRESET_PROMPT], {NO_PRESET_PROMPT: ""}
raw_prompts = data.get("qwenvl") or {}
system_prompts = raw_prompts if isinstance(raw_prompts, dict) else {}
system_prompts = {
str(name): str(prompt)
for name, prompt in system_prompts.items()
if isinstance(prompt, str)
}
system_prompts.setdefault(NO_PRESET_PROMPT, "")
raw_presets = data.get("_preset_prompts") or []
preset_prompts = [
name
for name in raw_presets
if isinstance(name, str) and name in system_prompts
]
if not preset_prompts:
preset_prompts = list(system_prompts)
if NO_PRESET_PROMPT not in preset_prompts:
preset_prompts.insert(0, NO_PRESET_PROMPT)
return preset_prompts, system_prompts
class ThinkingLLMSystemPromptPreset:
"""Select a ThinkingLLM preset and return its full system prompt."""
@classmethod
def INPUT_TYPES(cls):
preset_prompts, _ = _load_system_prompt_presets()
return {
"required": {
"preset_prompt": (
preset_prompts,
{
"default": preset_prompts[0],
"tooltip": "Select a ThinkingLLM system-prompt preset.",
},
)
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("system_prompt",)
OUTPUT_TOOLTIPS = ("The full system prompt text for the selected preset.",)
FUNCTION = "get_system_prompt"
CATEGORY = "ThinkingLLM/Utils"
DESCRIPTION = "Outputs the full system prompt string for a ThinkingLLM preset."
def get_system_prompt(self, preset_prompt: str):
_, system_prompts = _load_system_prompt_presets()
return (system_prompts.get(preset_prompt, ""),)
NODE_CLASS_MAPPINGS = {
"ThinkingLLM_SystemPromptPreset": ThinkingLLMSystemPromptPreset,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ThinkingLLM_SystemPromptPreset": "ThinkingLLM System Prompt Preset",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-ThinkingLLM"
description = "A multimodal ComfyUI AI node with Qwen3.5, Qwen3-VL, Qwen2.5-VL, Qwen3, and Gemma 4 integrations. Features live thinking in the terminal to see what the LLM is doing in real time."
version = "2.3.20"
version = "2.5.8"
license = {file = "LICENSE"}
dependencies = ["transformers>=5.2.0", "torch", "huggingface-hub", "hf_xet", "psutil", "numpy", "Pillow", "opencv-python", "bitsandbytes", "accelerate", "sentencepiece", "tiktoken", "faster-whisper", "llama-cpp-python"]
+94 -109
View File
@@ -1,144 +1,129 @@
"""
SageAttention patching functions for QwenVL models.
This module contains the safe SageAttention implementation with proper error handling.
"""
"""Model-local SageAttention patching for supported Qwen attention layers."""
import torch
def set_sage_attention(model):
"""
Apply SageAttention patching to the model.
Patches Qwen2Attention and Qwen3VLTextAttention modules to use SageAttention kernels.
"""
from AILab_QwenVL import sage_attn_available, get_sage_attention_config
if not sage_attn_available():
raise ImportError("SageAttention library is not installed or GPU doesn't support it.")
"""Patch supported Qwen attention layers, falling back per call when needed."""
from thinkingllm_core.hf_models import get_sage_attention_config
SAGE_ATTN_FUNC, QK_QUANT_GRAN, PV_ACCUM_DTYPE = get_sage_attention_config()
if SAGE_ATTN_FUNC is None:
raise RuntimeError("No compatible SageAttention kernel found for this GPU.")
attn_func, qk_quant_gran, pv_accum_dtype = get_sage_attention_config()
if attn_func is None:
raise RuntimeError("No compatible SageAttention kernel found for this GPU")
# Try to import different attention classes for different Qwen models
attention_classes = []
# Qwen2 models
try:
from transformers.models.qwen2.modeling_qwen2 import Qwen2Attention, apply_rotary_pos_emb as qwen2_apply_rotary
attention_classes.append((Qwen2Attention, qwen2_apply_rotary))
from transformers.models.qwen2.modeling_qwen2 import Qwen2Attention, apply_rotary_pos_emb
attention_classes.append((Qwen2Attention, apply_rotary_pos_emb))
except ImportError:
pass
try:
from transformers.models.qwen3.modeling_qwen3 import Qwen3Attention, apply_rotary_pos_emb
attention_classes.append((Qwen3Attention, apply_rotary_pos_emb))
except ImportError:
pass
try:
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextAttention, apply_rotary_pos_emb
attention_classes.append((Qwen3VLTextAttention, apply_rotary_pos_emb))
except ImportError:
pass
# Qwen3 models (Qwen3-VL, etc.)
try:
from transformers.models.qwen3.modeling_qwen3 import Qwen3Attention, apply_rotary_pos_emb as qwen3_apply_rotary
attention_classes.append((Qwen3Attention, qwen3_apply_rotary))
except ImportError:
pass
# Qwen3-VL specific
try:
from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLTextAttention, apply_rotary_pos_emb as qwen3vl_apply_rotary
attention_classes.append((Qwen3VLTextAttention, qwen3vl_apply_rotary))
except ImportError:
pass
if not attention_classes:
print("[QwenVL] Could not import any attention classes for SageAttention patching")
return
def make_sage_forward(AttentionClass, apply_rotary_pos_emb_func):
def sage_attention_forward(
def make_sage_forward(original_forward, apply_rotary):
def sage_forward(
self,
hidden_states: torch.Tensor,
position_embeddings: tuple = None,
attention_mask: torch.Tensor = None,
past_key_values=None,
cache_position: torch.LongTensor = None,
position_ids: torch.LongTensor = None,
**kwargs,
):
original_dtype = hidden_states.dtype
def fallback():
return original_forward(
hidden_states,
position_embeddings=position_embeddings,
attention_mask=attention_mask,
past_key_values=past_key_values,
cache_position=cache_position,
**kwargs,
)
# Determine target dtype
is_4bit = hasattr(self.q_proj, 'quant_state')
if is_4bit:
target_dtype = torch.bfloat16
else:
target_dtype = self.q_proj.weight.dtype
if hidden_states.dtype != target_dtype:
hidden_states = hidden_states.to(target_dtype)
# The Triton v1 kernel supports these dimensions and causal
# attention only when query and key sequence lengths agree.
if (
hidden_states.ndim != 3
or hidden_states.shape[1] < 1
or (self.head_dim not in (64, 96, 128) if qk_quant_gran is None
else not 0 < self.head_dim <= 128)
or attention_mask is not None
or position_embeddings is None
or len(position_embeddings) != 2
or getattr(self, "sliding_window", None) is not None
or self.training
or any(name not in ("position_ids", "use_cache") for name in kwargs)
or (kwargs.get("use_cache") is False and past_key_values is not None)
or (past_key_values is not None and hidden_states.shape[1] != 1)
or hidden_states.dtype not in (torch.float16, torch.bfloat16)
):
return fallback()
input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, self.head_dim)
bsz, q_len = input_shape[0], input_shape[1] if len(input_shape) > 1 else hidden_states.size(1)
query_states = self.q_proj(hidden_states).view(hidden_shape)
key_states = self.k_proj(hidden_states).view(hidden_shape)
value_states = self.v_proj(hidden_states).view(hidden_shape)
if hasattr(self, "q_norm"):
query_states = self.q_norm(query_states)
if hasattr(self, "k_norm"):
key_states = self.k_norm(key_states)
query_states = query_states.transpose(1, 2)
key_states = key_states.transpose(1, 2)
value_states = value_states.transpose(1, 2)
cos, sin = position_embeddings
query_states, key_states = apply_rotary(query_states, key_states, cos, sin)
# Handle q_norm and k_norm for Qwen3-VL
query_states = self.q_proj(hidden_states)
key_states = self.k_proj(hidden_states)
value_states = self.v_proj(hidden_states)
# Apply normalization if available (Qwen3-VL specific)
if hasattr(self, 'q_norm'):
query_states = self.q_norm(query_states.view(hidden_shape)).transpose(1, 2)
else:
query_states = query_states.view(hidden_shape).transpose(1, 2)
if hasattr(self, 'k_norm'):
key_states = self.k_norm(key_states.view(hidden_shape)).transpose(1, 2)
else:
key_states = key_states.view(hidden_shape).transpose(1, 2)
value_states = value_states.view(hidden_shape).transpose(1, 2)
# Apply rotary embeddings
if position_embeddings is not None:
cos, sin = position_embeddings
query_states, key_states = apply_rotary_pos_emb_func(query_states, key_states, cos, sin)
# This check precedes cache.update(), since calling the original
# forward after an update would append the same token twice.
if (query_states.dtype != key_states.dtype or key_states.dtype != value_states.dtype
or query_states.dtype not in (torch.float16, torch.bfloat16)):
return fallback()
if past_key_values is not None:
cache_kwargs = {"sin": sin if position_embeddings else None, "cos": cos if position_embeddings else None, "cache_position": cache_position}
key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx, cache_kwargs)
is_causal = attention_mask is None and q_len > 1
attn_output = SAGE_ATTN_FUNC(
query_states.to(target_dtype),
key_states.to(target_dtype),
value_states.to(target_dtype),
tensor_layout="HND",
is_causal=is_causal,
qk_quant_gran=QK_QUANT_GRAN,
pv_accum_dtype=PV_ACCUM_DTYPE,
)
cache_kwargs = {"sin": sin, "cos": cos, "cache_position": cache_position}
key_states, value_states = past_key_values.update(
key_states, value_states, self.layer_idx, cache_kwargs
)
is_causal = input_shape[1] > 1
if qk_quant_gran is None:
# SageAttention 1 smooths K in place, including cached K.
attn_output = attn_func(
query_states, key_states.clone(), value_states,
tensor_layout="HND", is_causal=is_causal,
sm_scale=getattr(self, "scaling", self.head_dim ** -0.5),
)
else:
attn_output = attn_func(
query_states, key_states, value_states,
tensor_layout="HND", is_causal=is_causal,
qk_quant_gran=qk_quant_gran, pv_accum_dtype=pv_accum_dtype,
)
if isinstance(attn_output, tuple):
attn_output = attn_output[0]
attn_output = attn_output.transpose(1, 2).contiguous().reshape(*input_shape, -1)
return self.o_proj(attn_output), None
attn_output = attn_output.transpose(1, 2).contiguous()
attn_output = attn_output.reshape(*input_shape, -1)
return sage_forward
attn_output = self.o_proj(attn_output)
if attn_output.dtype != original_dtype:
attn_output = attn_output.to(original_dtype)
return attn_output, None
return sage_attention_forward
# Apply patching to all supported attention modules
patched_count = 0
for AttentionClass, apply_rotary_func in attention_classes:
sage_forward = make_sage_forward(AttentionClass, apply_rotary_func)
for module in model.modules():
if isinstance(module, AttentionClass):
module.forward = sage_forward.__get__(module, AttentionClass)
for module in model.modules():
for attention_class, apply_rotary in attention_classes:
if isinstance(module, attention_class):
sage_forward = make_sage_forward(module.forward, apply_rotary)
module.forward = sage_forward.__get__(module, attention_class)
patched_count += 1
break
if patched_count > 0:
print(f"[QwenVL] SageAttention: Patched {patched_count} attention layers")
else:
print("[QwenVL] SageAttention: No compatible attention layers found to patch")
if not patched_count:
raise RuntimeError("SageAttention: No compatible attention layers found to patch")
print(f"[QwenVL] SageAttention: Patched {patched_count} attention layers")
+5
View File
@@ -0,0 +1,5 @@
# Test runtimes
| Command | Environment | Successful runtime | Measured | Recommended timeout |
| --- | --- | ---: | --- | ---: |
| `python -c "import sys,unittest; sys.path[:0]=['.','tests']; import test_new_features as m; unittest.main(m, argv=['unittest','-v'], exit=False)"` | Windows, ComfyUI embedded Python 3.12, CPU test path | 48.8 s | 2026-08-24 | 120 s |
+1782 -74
View File
File diff suppressed because it is too large Load Diff
+360
View File
@@ -0,0 +1,360 @@
from __future__ import annotations
import base64
import importlib.util
import json
import math
import os
import sys
import tempfile
import types
import unittest
from pathlib import Path
from unittest import mock
HERE = Path(__file__).resolve().parent
PKG = HERE.parent
MODULE_PATH = PKG / 'nodes' / 'openai_compatible_api.py'
if str(PKG) not in sys.path:
sys.path.insert(0, str(PKG))
output_cleaner = types.ModuleType('AILab_OutputCleaner')
class OutputCleanConfig:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
output_cleaner.OutputCleanConfig = OutputCleanConfig
output_cleaner.clean_model_output = lambda text, config=None: text.replace('<think>x</think>', '').strip()
sys.modules['AILab_OutputCleaner'] = output_cleaner
stream_display = types.ModuleType('AILab_StreamDisplay')
stream_display.compose_streamed_model_output = lambda reasoning, content: (
f'<think>\n{reasoning}\n</think>\n\n{content}' if reasoning and content
else f'<think>\n{reasoning}\n</think>' if reasoning else content
)
stream_display.extract_stream_token = lambda chunk: {
'reasoning': ((chunk.get('choices') or [{}])[0].get('delta') or {}).get('reasoning_content', ''),
'content': ((chunk.get('choices') or [{}])[0].get('delta') or {}).get('content', ''),
}
stream_display.get_thinking_stream_display = lambda: None
sys.modules['AILab_StreamDisplay'] = stream_display
SPEC = importlib.util.spec_from_file_location('thinkingllm_openai_compatible_api_test', MODULE_PATH)
assert SPEC and SPEC.loader
api_node = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(api_node)
class SecurityTests(unittest.TestCase):
def profile(self, **overrides):
raw = {
'provider': 'Test',
'base_url': 'https://example.test/v1',
'auth': 'bearer_env',
'api_key_env': 'TEST_API_KEY',
}
raw.update(overrides)
return api_node._normalize_profile('Production', raw)
def test_no_workflow_secret_or_endpoint_inputs(self):
inputs = api_node.ThinkingLLMOpenAICompatibleAPI.INPUT_TYPES()
names = set(inputs.get('required', {})) | set(inputs.get('optional', {}))
self.assertIn('api_profile', names)
self.assertIn('image', names)
self.assertIn('image_url', names)
self.assertNotIn('video', names)
self.assertNotIn('video_url', names)
for forbidden in ('api_key', 'api_key_env', 'base_url', 'headers'):
self.assertNotIn(forbidden, names)
def test_model_name_is_a_curated_combo(self):
inputs = api_node.ThinkingLLMOpenAICompatibleAPI.INPUT_TYPES()
model_def = inputs['required']['model_name']
options = model_def[0]
self.assertIsInstance(options, list)
self.assertGreaterEqual(len(options), 1)
# The dropdown should carry real curated models, not a placeholder.
self.assertNotIn('provider/model-id', options)
# OrcaRouter's catalog includes the free Qwen model.
self.assertIn('qwen/qwen3.8-27b-free', api_node._catalog_models_for_profile('OrcaRouter'))
def test_custom_model_field_overrides_dropdown(self):
inputs = api_node.ThinkingLLMOpenAICompatibleAPI.INPUT_TYPES()
self.assertIn('custom_model_name', inputs.get('optional', {}))
p = self.profile()
self.assertEqual(api_node._validate_model(p, 'qwen/qwen3.8-27b-free'), 'qwen/qwen3.8-27b-free')
self.assertEqual(api_node._validate_model(p, 'any/custom-id'), 'any/custom-id')
def test_authenticated_profiles_require_https(self):
with self.assertRaisesRegex(ValueError, 'must use HTTPS'):
api_node._normalize_profile('Unsafe', {
'base_url': 'http://example.test/v1',
'auth': 'bearer_env',
'api_key_env': 'TEST_KEY',
})
def test_plain_http_non_loopback_requires_explicit_opt_in(self):
with self.assertRaisesRegex(ValueError, 'Plain HTTP'):
api_node._normalize_profile('LAN', {
'base_url': 'http://192.168.1.10:8000/v1',
'auth': 'none',
})
p = api_node._normalize_profile('LAN', {
'base_url': 'http://192.168.1.10:8000/v1',
'auth': 'none',
'allow_insecure_http': True,
})
self.assertTrue(p['allow_insecure_http'])
def test_loopback_http_allowed(self):
p = api_node._normalize_profile('Local', {
'base_url': 'http://127.0.0.1:8000/v1',
'auth': 'none',
})
self.assertEqual(p['base_url'], 'http://127.0.0.1:8000/v1')
def test_unknown_profile_fields_fail_closed(self):
with self.assertRaisesRegex(ValueError, 'unknown fields'):
self.profile(max_token_limit=10)
def test_deprecated_boolean_extra_body_switch_rejected(self):
with self.assertRaisesRegex(ValueError, 'deprecated allow_extra_body'):
self.profile(allow_extra_body=True)
def test_exact_extra_body_allowlist(self):
p = self.profile(allowed_extra_body_fields=['reasoning'])
parsed = api_node._parse_extra_json('{"reasoning":{"effort":"high"}}', allowed_fields=p['allowed_extra_body_fields'])
self.assertIn('reasoning', parsed)
with self.assertRaisesRegex(ValueError, 'not allowed'):
api_node._parse_extra_json('{"n":4}', allowed_fields=p['allowed_extra_body_fields'])
with self.assertRaisesRegex(ValueError, 'protected request fields'):
api_node._parse_extra_json('{"model":"evil"}', allowed_fields=['model'])
def test_extra_json_size_limit(self):
p = self.profile(allowed_extra_body_fields=['x'])
payload = '{"x":"' + ('a' * api_node.MAX_EXTRA_BODY_JSON_CHARS) + '"}'
with self.assertRaisesRegex(ValueError, 'safety limit'):
api_node._parse_extra_json(payload, allowed_fields=p['allowed_extra_body_fields'])
def test_nonfinite_extra_json_rejected(self):
with self.assertRaisesRegex(ValueError, 'Non-finite'):
api_node._parse_extra_json('{"reasoning":NaN}', allowed_fields=['reasoning'])
def test_model_and_limits(self):
p = self.profile(allowed_models=['approved/model'], max_tokens_limit=2048, max_input_chars=20, max_timeout_seconds=30)
self.assertEqual(api_node._validate_model(p, 'approved/model'), 'approved/model')
with self.assertRaisesRegex(ValueError, 'not allowed'):
api_node._validate_model(p, 'other/model')
with self.assertRaisesRegex(ValueError, 'control characters'):
api_node._validate_model_identifier('bad\x1bmodel')
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
api_node._validate_max_tokens(p, 2049)
with self.assertRaisesRegex(ValueError, 'max_input_chars'):
api_node._validate_input_text(p, '1234567890', '12345678901')
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
api_node._validate_timeout(p, 31)
def test_sampling_rejects_nan_and_wrong_types(self):
with self.assertRaisesRegex(ValueError, 'finite'):
api_node._validate_sampling(float('nan'), 0.9, 1, 0)
with self.assertRaisesRegex(ValueError, 'seed must be an integer'):
api_node._validate_sampling(0.5, 0.9, 1.2, 0)
with self.assertRaisesRegex(ValueError, 'temperature must be a numeric'):
api_node._validate_sampling('0.5', 0.9, 1, 0)
def test_thinking_budget_has_server_side_cap(self):
p = self.profile(max_thinking_budget=4096)
self.assertEqual(api_node._validate_thinking_budget(p, 4096), 4096)
with self.assertRaisesRegex(ValueError, 'exceeds server-side limit'):
api_node._validate_thinking_budget(p, 4097)
def test_allowed_models_requires_strings(self):
with self.assertRaisesRegex(ValueError, 'list of strings'):
self.profile(allowed_models=[123])
def test_profile_booleans_are_strict(self):
with self.assertRaisesRegex(ValueError, 'JSON boolean'):
self.profile(send_seed='false')
with self.assertRaisesRegex(ValueError, 'JSON boolean'):
self.profile(allow_images='true')
def test_environment_variable_name_is_validated(self):
with self.assertRaisesRegex(ValueError, 'environment-variable name'):
self.profile(api_key_env='BAD-NAME')
def test_missing_key_does_not_expose_env_name_in_client_error(self):
p = self.profile(api_key_env='THINKINGLLM_TEST_SECRET_KEY')
with mock.patch.dict(os.environ, {}, clear=False):
os.environ.pop('THINKINGLLM_TEST_SECRET_KEY', None)
with self.assertRaises(RuntimeError) as ctx:
api_node._authorization_headers(p)
self.assertNotIn('THINKINGLLM_TEST_SECRET_KEY', str(ctx.exception))
def test_redirects_are_rejected(self):
handler = api_node._NoRedirectHandler()
req = __import__('urllib.request').request.Request('https://example.test/v1/chat/completions')
self.assertIsNone(handler.redirect_request(req, None, 302, 'Found', {}, 'https://evil.test/steal'))
def test_terminal_copy_strips_controls(self):
value = api_node._terminal_display_safe('hello\x1b[31mred\x07\rworld')
self.assertNotIn('\x1b', value)
self.assertNotIn('\x07', value)
self.assertNotIn('\r', value)
self.assertIn('[31mredworld', value)
def test_openrouter_reasoning_details_are_captured(self):
chunk = {
'choices': [{
'delta': {
'reasoning_details': [
{'type': 'reasoning.text', 'text': 'step one '},
{'type': 'reasoning.summary', 'summary': 'step two'},
],
'content': 'answer',
}
}]
}
token = api_node._extract_stream_token_extended(chunk)
self.assertEqual(token['reasoning'], 'step one step two')
self.assertEqual(token['content'], 'answer')
def test_stream_error_payload_detected(self):
self.assertIn('429', api_node._provider_error_from_payload({'error': {'code': 429, 'message': 'rate limit'}}))
def test_remote_node_always_reruns(self):
self.assertTrue(math.isnan(api_node.ThinkingLLMOpenAICompatibleAPI.IS_CHANGED()))
def test_image_support_is_fail_closed_for_custom_profiles(self):
p = self.profile()
self.assertFalse(p['allow_images'])
self.assertFalse(p['allow_image_url'])
with self.assertRaisesRegex(ValueError, 'Image input is disabled'):
api_node._validate_image_url(p, 'https://example.com/image.png')
def test_image_url_requires_image_capability_and_https(self):
with self.assertRaisesRegex(ValueError, 'cannot enable allow_image_url'):
self.profile(allow_image_url=True)
p = self.profile(allow_images=True, allow_image_url=True)
signed = 'https://example.com/image.png?signature=abc123'
self.assertEqual(api_node._validate_image_url(p, signed), signed)
with self.assertRaisesRegex(ValueError, 'public HTTPS'):
api_node._validate_image_url(p, 'http://example.com/image.png')
with self.assertRaisesRegex(ValueError, 'public HTTPS'):
api_node._validate_image_url(p, 'data:image/png;base64,AAAA')
def test_comfyui_image_is_encoded_as_png_base64(self):
import numpy as np
p = self.profile(allow_images=True, max_image_pixels=4, max_image_bytes=10000)
image = np.zeros((1, 2, 2, 3), dtype=np.float32)
image[..., 0] = 1.0
data_url = api_node._image_to_data_url(p, image)
self.assertTrue(data_url.startswith('data:image/png;base64,'))
png = base64.b64decode(data_url.split(',', 1)[1])
self.assertTrue(png.startswith(b'\x89PNG\r\n\x1a\n'))
def test_image_size_and_batch_limits_are_enforced(self):
import numpy as np
p = self.profile(allow_images=True, max_image_pixels=3, max_image_bytes=10000)
with self.assertRaisesRegex(ValueError, 'max_image_pixels'):
api_node._image_to_data_url(p, np.zeros((1, 2, 2, 3), dtype=np.float32))
p2 = self.profile(allow_images=True)
with self.assertRaisesRegex(ValueError, 'exactly one IMAGE'):
api_node._image_to_data_url(p2, np.zeros((2, 1, 1, 3), dtype=np.float32))
def test_image_and_image_url_are_alternatives(self):
p = self.profile(allow_images=True, allow_image_url=True)
with self.assertRaisesRegex(ValueError, 'either the IMAGE input or image_url'):
api_node._build_user_content(p, 'describe', image=object(), image_url='https://example.com/x.png')
def test_multimodal_content_uses_text_first_then_image_url(self):
p = self.profile(allow_images=True, allow_image_url=True)
content, supplied = api_node._build_user_content(p, 'describe', image_url='https://example.com/x.png')
self.assertTrue(supplied)
self.assertEqual(content[0], {'type': 'text', 'text': 'describe'})
self.assertEqual(content[1]['type'], 'image_url')
self.assertEqual(content[1]['image_url']['url'], 'https://example.com/x.png')
def test_image_rejection_message_is_actionable(self):
p = self.profile(allow_images=True)
message = api_node._image_rejection_message(p, 'text-only/model', 400)
self.assertIn('Image input was rejected', message)
self.assertIn('vision-capable', message)
def test_builtins_enable_standard_image_transport(self):
profiles = api_node._load_api_profiles()
self.assertTrue(profiles['OpenRouter']['allow_images'])
self.assertTrue(profiles['OpenRouter']['allow_image_url'])
def test_builtins_can_be_disabled(self):
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.dict(os.environ, {
api_node.DISABLE_BUILTINS_ENV: '1',
api_node.PROFILE_FILE_ENV: str(Path(tmp) / 'missing.json'),
}, clear=False):
self.assertEqual(api_node._load_api_profiles(), {})
def test_invalid_custom_override_does_not_fall_back_to_builtin(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'profiles.json'
path.write_text(json.dumps({'profiles': {'OpenRouter': {
'provider': 'OpenRouter',
'base_url': 'https://openrouter.ai/api/v1',
'auth': 'bearer_env',
'api_key_env': 'OPENROUTER_API_KEY',
'max_token_limit': 1
}}}), encoding='utf-8')
with mock.patch.dict(os.environ, {api_node.PROFILE_FILE_ENV: str(path)}, clear=False):
profiles = api_node._load_api_profiles()
self.assertNotIn('OpenRouter', profiles)
def test_custom_profile_file_has_configuration_not_secret(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / 'profiles.json'
path.write_text(json.dumps({'profiles': {'Production': {
'provider': 'Test',
'base_url': 'https://example.test/v1',
'auth': 'bearer_env',
'api_key_env': 'TEST_API_KEY',
'allowed_models': ['approved/model'],
'max_tokens_limit': 1024,
'max_input_chars': 10000,
'max_timeout_seconds': 60,
'allow_images': True,
'allow_image_url': False,
'max_image_pixels': 16777216,
'max_image_bytes': 20000000,
}}}), encoding='utf-8')
with mock.patch.dict(os.environ, {
api_node.DISABLE_BUILTINS_ENV: '1',
api_node.PROFILE_FILE_ENV: str(path),
}, clear=False):
profiles = api_node._load_api_profiles()
self.assertEqual(list(profiles), ['Production'])
self.assertEqual(profiles['Production']['api_key_env'], 'TEST_API_KEY')
self.assertTrue(profiles['Production']['allow_images'])
self.assertNotIn('api_key', profiles['Production'])
def test_catalog_is_ux_only_and_never_becomes_an_allowlist(self):
# The curated catalog must not be interpreted as a security boundary.
catalog = api_node._load_model_catalog()
self.assertIsInstance(catalog, dict)
self.assertIn('OrcaRouter', catalog)
orca = catalog['OrcaRouter']
self.assertIsInstance(orca.get('models'), list)
self.assertIn('qwen/qwen3.8-27b-free', api_node._catalog_models_for_profile('OrcaRouter'))
def test_catalog_missing_or_broken_is_failure_tolerant(self):
with mock.patch.object(api_node, 'CATALOG_PATH', Path('does/not/exist.json')):
api_node._CATALOG_CACHE = None
self.assertEqual(api_node._load_model_catalog(), {})
self.assertEqual(api_node._catalog_models_for_profile('OrcaRouter'), [])
api_node._CATALOG_CACHE = None
def test_catalog_does_not_replace_allowed_models(self):
# Even if a profile has no allowlist, a request for a non-listed model must
# be passed through unchanged — the catalog is UI only.
p = self.profile()
self.assertIsNone(p['allowed_models'])
self.assertEqual(api_node._validate_model(p, 'qwen/qwen3.8-27b-free'), 'qwen/qwen3.8-27b-free')
self.assertEqual(api_node._validate_model(p, 'anything/else'), 'anything/else')
if __name__ == '__main__':
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
"""Regression checks for SageAttention selection and model-local fallback."""
import ast
import importlib.util
import sys
import types
import unittest
from pathlib import Path
from unittest import mock
import torch
ROOT = Path(__file__).resolve().parent.parent
def load_attention_selector(*, hip, sage_version="1.0.6", triton_kernel=None, cuda_kernels=False):
tree = ast.parse((ROOT / "thinkingllm_core/hf_models.py").read_text(encoding="utf-8"))
names = {"sage_attn_available", "get_sage_attention_config", "resolve_attention_mode"}
functions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names]
cuda = types.SimpleNamespace(
is_available=lambda: True,
get_device_capability=(
(lambda: (_ for _ in ()).throw(AssertionError("HIP queried CUDA SM")))
if hip else (lambda: (8, 6))
),
)
metadata = types.SimpleNamespace(
version=lambda package: sage_version,
PackageNotFoundError=Exception,
)
namespace = {
"torch": types.SimpleNamespace(cuda=cuda, version=types.SimpleNamespace(hip=hip)),
"importlib": types.SimpleNamespace(metadata=metadata),
"sageattn_triton": triton_kernel,
"SAGE_ATTENTION_AVAILABLE": cuda_kernels,
"sageattn_qk_int8_pv_fp16_cuda": triton_kernel if cuda_kernels else None,
"sageattn_qk_int8_pv_fp8_cuda": None,
"sageattn_qk_int8_pv_fp8_cuda_sm90": None,
"flash_attn_available": lambda: False,
}
exec(compile(ast.Module(body=functions, type_ignores=[]), "hf_models.py", "exec"), namespace)
return namespace
class TestSageBackendSelection(unittest.TestCase):
def test_rocm_v1_selects_triton_without_nvidia_capability_query(self):
kernel = lambda *args, **kwargs: None
selector = load_attention_selector(hip="7.0", triton_kernel=kernel)
self.assertIs(selector["get_sage_attention_config"]()[0], kernel)
self.assertEqual("sage", selector["resolve_attention_mode"]("sage"))
self.assertEqual("sage", selector["resolve_attention_mode"]("auto"))
def test_rocm_unavailable_or_unsupported_version_falls_back(self):
for version, kernel in (("2.2.0", lambda: None), ("1.0.6", None)):
with self.subTest(version=version, installed=kernel is not None):
selector = load_attention_selector(hip="7.0", sage_version=version, triton_kernel=kernel)
self.assertEqual("sdpa", selector["resolve_attention_mode"]("sage"))
def test_forced_sdpa_still_wins(self):
selector = load_attention_selector(hip="7.0", triton_kernel=lambda: None)
self.assertEqual("sdpa", selector["resolve_attention_mode"]("sage", force_sdpa=True))
def test_nvidia_cuda_kernel_selection_is_preserved(self):
kernel = lambda *args, **kwargs: None
selector = load_attention_selector(hip=None, triton_kernel=kernel, cuda_kernels=True)
self.assertEqual((kernel, "per_warp", "fp32"), selector["get_sage_attention_config"]())
class FakeAttention(torch.nn.Module):
def __init__(self, head_dim=64):
super().__init__()
self.head_dim = head_dim
self.layer_idx = 0
self.scaling = head_dim ** -0.5
self.q_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
self.k_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
self.v_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
self.o_proj = torch.nn.Linear(head_dim, head_dim, bias=False)
self.original_calls = 0
def forward(self, hidden_states, **kwargs):
self.original_calls += 1
return hidden_states, None
class FakeCache:
def __init__(self):
self.keys = None
def update(self, keys, values, layer_idx, cache_kwargs):
self.keys = keys
return keys, values
class TestSageModelPatch(unittest.TestCase):
def setUp(self):
self.calls = []
def kernel(q, k, v, **kwargs):
self.calls.append(kwargs)
k.add_(1) # SageAttention 1 smooths K in place.
return q
hf_models = types.ModuleType("thinkingllm_core.hf_models")
hf_models.get_sage_attention_config = lambda: (kernel, None, None)
qwen2 = types.ModuleType("transformers.models.qwen2.modeling_qwen2")
qwen2.Qwen2Attention = FakeAttention
qwen2.apply_rotary_pos_emb = lambda q, k, cos, sin: (q, k)
self.modules = mock.patch.dict(sys.modules, {
"thinkingllm_core.hf_models": hf_models,
"transformers.models.qwen2.modeling_qwen2": qwen2,
})
self.modules.start()
self.addCleanup(self.modules.stop)
spec = importlib.util.spec_from_file_location("_test_sageattention_patch", ROOT / "sageattention_patch.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
self.patch_model = module.set_sage_attention
@staticmethod
def embeddings():
return torch.zeros(1, 1, 64), torch.zeros(1, 1, 64)
def test_unmasked_prefill_and_cached_decode_use_sage_without_mutating_cache(self):
layer = FakeAttention().half().eval()
self.patch_model(torch.nn.Sequential(layer))
prefill = torch.randn(1, 2, 64, dtype=torch.float16)
output, weights = layer(prefill, position_embeddings=self.embeddings(), use_cache=True, position_ids=None)
self.assertEqual((1, 2, 64), tuple(output.shape))
self.assertIsNone(weights)
self.assertTrue(self.calls[-1]["is_causal"])
self.assertEqual(0, layer.original_calls)
cache = FakeCache()
token = torch.randn(1, 1, 64, dtype=torch.float16)
layer(token, position_embeddings=self.embeddings(), past_key_values=cache, use_cache=True)
self.assertFalse(self.calls[-1]["is_causal"])
self.assertEqual(2, len(self.calls))
self.assertIsNotNone(cache.keys)
expected_keys = layer.k_proj(token).view(1, 1, 1, 64).transpose(1, 2)
torch.testing.assert_close(cache.keys, expected_keys)
def test_mask_and_unsupported_head_size_call_original_forward(self):
layer = FakeAttention().half().eval()
self.patch_model(torch.nn.Sequential(layer))
inputs = torch.randn(1, 2, 64, dtype=torch.float16)
layer(inputs, position_embeddings=self.embeddings(), attention_mask=torch.zeros(1, 1, 2, 2))
self.assertEqual(1, layer.original_calls)
self.assertEqual([], self.calls)
unsupported = FakeAttention(head_dim=80).half().eval()
self.patch_model(torch.nn.Sequential(unsupported))
unsupported(torch.randn(1, 2, 80, dtype=torch.float16), position_embeddings=self.embeddings())
self.assertEqual(1, unsupported.original_calls)
self.assertEqual([], self.calls)
def test_no_supported_layers_is_an_error(self):
with self.assertRaisesRegex(RuntimeError, "No compatible attention layers"):
self.patch_model(torch.nn.Sequential(torch.nn.Linear(2, 2)))
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
{
"profiles": {
"OpenRouter Production": {
"provider": "OpenRouter",
"base_url": "https://openrouter.ai/api/v1",
"auth": "bearer_env",
"api_key_env": "OPENROUTER_PRODUCTION_API_KEY",
"allowed_models": [
"provider/vision-model-id"
],
"max_tokens_limit": 8192,
"max_input_chars": 131072,
"max_timeout_seconds": 300,
"allow_images": true,
"allow_image_url": true,
"max_image_pixels": 16777216,
"max_image_bytes": 20000000,
"allowed_extra_body_fields": [],
"send_seed": false
},
"Internal vLLM": {
"provider": "vLLM",
"base_url": "http://127.0.0.1:8000/v1",
"auth": "none",
"allowed_models": [
"my-qwen-model"
],
"max_tokens_limit": 8192,
"max_input_chars": 1000000,
"max_timeout_seconds": 300,
"allowed_extra_body_fields": [],
"send_seed": true
}
}
}
+1
View File
@@ -0,0 +1 @@
"""Internal implementation package for ComfyUI-ThinkingLLM."""
+790
View File
@@ -0,0 +1,790 @@
"""Hugging Face model configuration, discovery, device, and attention support."""
import gc
import importlib.metadata
import json
import os
import platform
from enum import Enum
from pathlib import Path
import psutil
import torch
try:
from transformers import BitsAndBytesConfig
except ImportError:
BitsAndBytesConfig = None
import folder_paths
from thinkingllm_core.model_access import (
COMMERCIAL_RELEASE,
_download_model_snapshot,
_model_snapshot_has_required_files,
enforce_model_access,
normalize_commercial_status,
resolve_model_catalog_name,
)
from thinkingllm_core.prompt_contracts import VIDEO_PRESET_METADATA
try:
from sageattention.core import (
sageattn_qk_int8_pv_fp16_cuda,
sageattn_qk_int8_pv_fp8_cuda,
sageattn_qk_int8_pv_fp8_cuda_sm90,
)
SAGE_ATTENTION_AVAILABLE = True
except Exception:
sageattn_qk_int8_pv_fp16_cuda = None
sageattn_qk_int8_pv_fp8_cuda = None
sageattn_qk_int8_pv_fp8_cuda_sm90 = None
SAGE_ATTENTION_AVAILABLE = False
try:
from sageattention import sageattn as sageattn_triton
except Exception:
sageattn_triton = None
NODE_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = NODE_DIR / ("hf_models.commercial.json" if COMMERCIAL_RELEASE else "hf_models.json")
SYSTEM_PROMPTS_PATH = NODE_DIR / "AILab_System_Prompts.json"
HF_VL_MODELS: dict[str, dict] = {}
HF_TEXT_MODELS: dict[str, dict] = {}
HF_ALL_MODELS: dict[str, dict] = {}
SYSTEM_PROMPTS = {}
NO_PRESET_PROMPT = "🚫 No preset (image-only)"
PRESET_PROMPTS: list[str] = [NO_PRESET_PROMPT, "Describe this image in detail."]
TOOLTIPS = {
"model_name": "Pick the checkpoint. [installed] means a catalog model is available in a configured LLM location; [local] means an uncatalogued local model, including compatible ComfyUI text_encoders. Missing catalog models download on first use.",
"quantization": "Precision vs VRAM. FP16 gives the best quality if memory allows; 8-bit suits 8–16 GB GPUs; 4-bit fits 6 GB or lower but is slower.",
"attention_mode": "auto tries compatible SageAttention (including Triton Sage 1 on ROCm) → FlashAttention 2 → SDPA in order. SDPA is stable and recommended. Only override when debugging attention backends.",
"preset_prompt": "Built-in instruction describing how Qwen-VL should analyze the media input.",
"custom_prompt": "Additional user input that gets combined with the preset template. Leave empty to use only the template.",
"max_tokens": "Maximum number of new tokens to decode. Larger values yield longer answers but consume more time and memory.",
"keep_model_loaded": "Keeps the model resident in VRAM/RAM after the run so the next prompt skips loading.",
"seed": "Seed controlling sampling and frame picking; reuse it to reproduce results.",
"use_torch_compile": "Enable torch.compile('reduce-overhead') on supported CUDA/Torch 2.1+ builds for extra throughput after the first compile.",
"device": "Choose where to run the model: auto, cpu, mps, or cuda:x for multi-GPU systems.",
"temperature": "Sampling randomness when num_beams == 1. 0.2–0.4 is focused, 0.7+ is creative.",
"top_p": "Nucleus sampling cutoff when num_beams == 1. Lower values keep only top tokens; 0.9–0.95 allows more variety.",
"num_beams": "Beam-search width. Values >1 disable temperature/top_p and trade speed for more stable answers.",
"repetition_penalty": "Values >1 (e.g., 1.1–1.3) penalize repeated phrases; 1.0 leaves logits untouched.",
"frame_count": "Number of frames extracted from video inputs before prompting Qwen-VL. More frames provide context but cost time.",
"hf_token": "Optional Hugging Face access token for private or gated model downloads. It is passed only to the download call, never logged or cached, and the in-memory copy is dropped after the download attempt. Clear this field before saving or sharing workflows.",
}
def _configured_llm_roots() -> list[Path]:
roots: list[Path] = []
try:
if "LLM" in folder_paths.folder_names_and_paths:
roots.extend(Path(path) for path in folder_paths.get_folder_paths("LLM"))
except (AttributeError, KeyError, TypeError):
pass
default_root = Path(folder_paths.models_dir) / "LLM"
if default_root not in roots:
roots.append(default_root)
return roots
def _hf_qwen_vl_dirs() -> list[Path]:
return [root / "Qwen-VL" for root in _configured_llm_roots()]
def _hf_catalog_search_dirs() -> list[Path]:
search_dirs: list[Path] = []
for root in _configured_llm_roots():
for directory in (root / "Qwen-VL", root):
if directory not in search_dirs:
search_dirs.append(directory)
return search_dirs
def _find_installed_hf_model_dir(info: dict, *, require_processor: bool = False) -> Path | None:
repo_id = info.get("repo_id") if isinstance(info, dict) else None
if not isinstance(repo_id, str) or not repo_id.strip():
return None
directory_name = repo_id.rstrip("/").rsplit("/", 1)[-1]
installed_path = info.get("installed_path")
candidates = [Path(installed_path)] if installed_path else []
candidates.extend(directory / directory_name for directory in _hf_catalog_search_dirs())
seen: set[Path] = set()
for candidate in candidates:
if candidate in seen:
continue
seen.add(candidate)
if _model_snapshot_has_required_files(candidate, require_processor=require_processor):
return candidate
return None
def _mark_installed_hf_catalog_models() -> None:
for info in HF_ALL_MODELS.values():
if not isinstance(info, dict) or info.get("is_local"):
continue
installed_path = _find_installed_hf_model_dir(info)
if installed_path is None:
info.pop("is_installed", None)
info.pop("installed_path", None)
continue
info["is_installed"] = True
info["installed_path"] = str(installed_path)
def ensure_cuda_vram_headroom(module_name="QwenVL", min_free_gb=1.0, min_free_ratio=0.08):
if not torch.cuda.is_available():
return True
try:
torch.cuda.synchronize()
except Exception:
pass
try:
free_before, total = torch.cuda.mem_get_info()
except Exception:
gc.collect()
torch.cuda.empty_cache()
return True
allocated = torch.cuda.memory_allocated()
reserved = torch.cuda.memory_reserved()
reclaimable = max(reserved - allocated, 0)
threshold = max(int(min_free_gb * 1024**3), int(total * min_free_ratio))
if free_before >= threshold and reclaimable < 512 * 1024**2:
return True
print(
f"[{module_name}] VRAM headroom low before run: "
f"free={free_before / 1024**3:.2f}GB, "
f"reserved={reserved / 1024**3:.2f}GB, "
f"allocated={allocated / 1024**3:.2f}GB. Cleaning CUDA cache..."
)
gc.collect()
torch.cuda.empty_cache()
try:
torch.cuda.ipc_collect()
except Exception:
pass
try:
torch.cuda.synchronize()
except Exception:
pass
try:
free_after, _ = torch.cuda.mem_get_info()
print(f"[{module_name}] VRAM after cleanup: free={free_after / 1024**3:.2f}GB")
return free_after >= threshold
except Exception:
return True
class Quantization(str, Enum):
Q4 = "4-bit (VRAM-friendly)"
Q8 = "8-bit (Balanced)"
FP16 = "None (FP16)"
@classmethod
def get_values(cls):
return [item.value for item in cls]
@classmethod
def from_value(cls, value):
for item in cls:
if item.value == value:
return item
raise ValueError(f"Unsupported quantization: {value}")
ATTENTION_MODES = ["auto", "sage", "flash_attention_2", "sdpa"]
# Debug: Check SageAttention availability
print(f"[QwenVL Debug] SAGE_ATTENTION_AVAILABLE: {SAGE_ATTENTION_AVAILABLE}")
if torch.cuda.is_available():
if getattr(torch.version, "hip", None):
print(f"[QwenVL Debug] ROCm/HIP: {torch.version.hip}")
else:
major, minor = torch.cuda.get_device_capability()
print(f"[QwenVL Debug] CUDA capability: {major}.{minor}")
else:
print("[QwenVL Debug] CUDA not available")
print(f"[QwenVL Debug] Final ATTENTION_MODES: {ATTENTION_MODES}")
# Temporarily show sage option even if not available for testing
print("[QwenVL] NOTE: SageAttention option shown for testing. Install with: pip install sageattention")
def load_model_configs():
next_vl_models: dict[str, dict] = {}
next_text_models: dict[str, dict] = {}
next_system_prompts: dict = {}
next_preset_prompts = list(PRESET_PROMPTS)
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh) or {}
if "hf_vl_models" in data or "hf_text_models" in data:
next_vl_models = data.get("hf_vl_models") or {}
next_text_models = data.get("hf_text_models") or {}
else:
next_vl_models = {k: v for k, v in data.items() if not k.startswith("_")}
if COMMERCIAL_RELEASE:
next_vl_models = {name: info for name, info in next_vl_models.items() if normalize_commercial_status(info) == "cleared"}
next_text_models = {name: info for name, info in next_text_models.items() if normalize_commercial_status(info) == "cleared"}
next_system_prompts = data.get("_system_prompts", {})
next_preset_prompts = data.get("_preset_prompts", next_preset_prompts)
except Exception as exc:
print(f"[QwenVL] Config load failed: {exc}")
try:
with open(SYSTEM_PROMPTS_PATH, "r", encoding="utf-8") as fh:
data = json.load(fh) or {}
qwenvl_prompts = data.get("qwenvl") or {}
preset_override = data.get("_preset_prompts") or []
video_metadata = data.get("_video_presets") or {}
if isinstance(qwenvl_prompts, dict) and qwenvl_prompts:
next_system_prompts = qwenvl_prompts
if isinstance(preset_override, list) and preset_override:
next_preset_prompts = preset_override
if isinstance(video_metadata, dict):
VIDEO_PRESET_METADATA.clear()
VIDEO_PRESET_METADATA.update(
{
name: dict(entry)
for name, entry in video_metadata.items()
if isinstance(name, str) and isinstance(entry, dict)
}
)
except FileNotFoundError:
pass
except Exception as exc:
print(f"[QwenVL] System prompts load failed: {exc}")
if NO_PRESET_PROMPT not in next_preset_prompts:
next_preset_prompts = [NO_PRESET_PROMPT, *next_preset_prompts]
if isinstance(next_system_prompts, dict):
next_system_prompts.setdefault(NO_PRESET_PROMPT, "")
custom = NODE_DIR / "custom_models.json"
if custom.exists() and not COMMERCIAL_RELEASE:
try:
with open(custom, "r", encoding="utf-8") as fh:
data = json.load(fh) or {}
custom_vl = data.get("hf_vl_models") or {}
custom_text = data.get("hf_text_models") or {}
legacy = data.get("hf_models", {}) or data.get("models", {})
if isinstance(custom_vl, dict) and custom_vl:
next_vl_models.update(custom_vl)
print(f"[QwenVL] Loaded {len(custom_vl)} custom VL models")
if isinstance(custom_text, dict) and custom_text:
next_text_models.update(custom_text)
print(f"[QwenVL] Loaded {len(custom_text)} custom text models")
if isinstance(legacy, dict) and legacy:
next_vl_models.update(legacy)
print(f"[QwenVL] Loaded {len(legacy)} custom legacy models")
except Exception as exc:
print(f"[QwenVL] custom_models.json skipped: {exc}")
HF_VL_MODELS.clear()
HF_VL_MODELS.update(next_vl_models)
HF_TEXT_MODELS.clear()
HF_TEXT_MODELS.update(next_text_models)
HF_ALL_MODELS.clear()
HF_ALL_MODELS.update(HF_VL_MODELS)
HF_ALL_MODELS.update(HF_TEXT_MODELS)
SYSTEM_PROMPTS.clear()
SYSTEM_PROMPTS.update(next_system_prompts)
PRESET_PROMPTS[:] = next_preset_prompts
# Commercial releases expose only reviewed catalog entries, but still show
# whether those entries are available in any configured LLM location.
if COMMERCIAL_RELEASE:
_mark_installed_hf_catalog_models()
else:
_scan_local_hf_models()
def _scan_local_hf_models():
"""Scan local HF directories and compatible ComfyUI text encoders."""
_mark_installed_hf_catalog_models()
scan_dirs = _hf_qwen_vl_dirs()
# Collect known local directory names from JSON config
known_dirs = set()
for info in HF_ALL_MODELS.values():
repo_id = info.get("repo_id", "")
if isinstance(repo_id, str) and "/" in repo_id:
known_dirs.add(repo_id.split("/")[-1])
local_path = info.get("local_path")
if local_path:
known_dirs.add(Path(local_path).name)
count = 0
for models_dir in scan_dirs:
if not models_dir.exists() or not models_dir.is_dir():
continue
try:
for entry in models_dir.iterdir():
if not entry.is_dir():
continue
if entry.name in known_dirs:
continue
# Check if the directory contains model weights
has_weights = any(entry.glob("*.safetensors")) or any(entry.glob("*.bin"))
if not has_weights:
continue
display = f"[local] {entry.name}"
model_info = {
"local_path": str(entry),
"repo_id": None,
"is_local": True,
"commercial_status": "local",
"quantized": False,
}
HF_VL_MODELS[display] = model_info
HF_ALL_MODELS[display] = model_info
known_dirs.add(entry.name)
count += 1
except PermissionError:
pass
try:
text_encoders = folder_paths.get_filename_list("text_encoders")
except (AttributeError, KeyError):
text_encoders = []
for filename in text_encoders:
normalized = Path(filename).name.lower().replace("-", "").replace("_", "")
family = None
if "gemma312b" in normalized:
family = "gemma3"
elif "qwen3vl" in normalized and ("4b" in normalized or "8b" in normalized):
family = "qwen3vl"
if not family or not normalized.endswith(".safetensors"):
continue
display = f"[ComfyUI] {filename}"
model_info = {
"comfy_text_encoder": filename,
"repo_id": None,
"is_local": True,
"local_display_name": filename,
"storage_label": "text_encoders",
"commercial_status": "local",
"quantized": True,
"native_family": family,
}
HF_VL_MODELS[display] = model_info
HF_ALL_MODELS[display] = model_info
count += 1
if count:
print(f"[QwenVL] Discovered {count} local model(s) on disk")
if not HF_ALL_MODELS:
load_model_configs()
def _default_model_from_config(models: dict[str, dict], fallback: str) -> str:
for name, info in models.items():
if isinstance(info, dict) and info.get("default"):
return name
return next(iter(models.keys()), fallback)
def read_hf_model_type(model_dir: str) -> str | None:
"""Read model_type from a HuggingFace model's config.json."""
try:
config_path = Path(model_dir) / "config.json"
if config_path.exists():
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
return config.get("model_type")
except Exception:
pass
return None
def check_pytorch_memory():
"""Check current PyTorch memory settings and allow user to set fraction"""
try:
import torch
print(f"[QwenVL] PyTorch {torch.__version__}")
print(f"[QwenVL] CUDA Available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
current_fraction = torch.cuda.get_per_process_memory_fraction()
print(f"[QwenVL] Current Memory Fraction: {current_fraction:.3f} ({current_fraction*100:.1f}% of GPU)")
print(f"[QwenVL] GPU Memory: {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f} GB")
# Allow user to set new fraction
try:
prompt = f"[QwenVL] Enter new memory fraction (0.1-0.9"
if current_fraction is not None:
prompt += f", current={current_fraction:.3f})"
new_fraction = float(input(prompt + ": ") or current_fraction)
if 0.1 <= new_fraction <= 0.9:
torch.cuda.set_per_process_memory_fraction(new_fraction)
print(f"[QwenVL] ✅ Memory fraction set to: {new_fraction:.3f} ({new_fraction*100:.1f}%)")
else:
print(f"[QwenVL] ❌ Invalid fraction. Must be between 0.1 and 0.9")
except KeyboardInterrupt:
print(f"[QwenVL] ✅ Keeping current fraction: {current_fraction:.3f}")
except Exception as e:
print(f"[QwenVL] ❌ Error setting fraction: {e}")
else:
print("[QwenVL] CUDA not available")
except Exception as e:
print(f"[QwenVL] Error checking PyTorch: {e}")
def set_pytorch_memory_fraction(fraction):
"""Set PyTorch memory fraction if CUDA is available"""
try:
import torch
if torch.cuda.is_available():
if 0.1 <= fraction <= 0.9:
torch.cuda.set_per_process_memory_fraction(fraction)
print(f"[QwenVL] Memory fraction set to: {fraction:.3f} ({fraction*100:.1f}%)")
return True
else:
print(f"[QwenVL] Invalid fraction: {fraction:.3f}. Must be between 0.1 and 0.9")
return False
else:
print("[QwenVL] CUDA not available")
return False
except Exception as e:
print(f"[QwenVL] Error: {e}")
return False
def get_device_info():
gpu = {"available": False, "total_memory": 0, "free_memory": 0}
device_type = "cpu"
recommended = "cpu"
if torch.cuda.is_available():
props = torch.cuda.get_device_properties(0)
total = props.total_memory / 1024**3
allocated = torch.cuda.memory_allocated(0) / 1024**3
reserved = torch.cuda.memory_reserved(0) / 1024**3
free = total - allocated - reserved
gpu = {
"available": True,
"total_memory": total,
"allocated_memory": allocated,
"reserved_memory": reserved,
"free_memory": free,
}
device_type = "nvidia_gpu"
recommended = "cuda"
# Detailed memory debugging
print(f"[QwenVL] GPU Memory Debug:")
print(f" Total VRAM: {total / 1024**3:.2f} GB")
print(f" Allocated: {allocated / 1024**3:.2f} GB")
print(f" Reserved: {reserved / 1024**3:.2f} GB")
print(f" Free: {free / 1024**3:.2f} GB")
print(f" Model requires: 0.74 GB")
print(f" Available ratio: {(free / total) * 100:.1f}%")
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
device_type = "apple_silicon"
recommended = "mps"
gpu = {"available": True, "total_memory": 0, "free_memory": 0}
sys_mem = psutil.virtual_memory()
return {
"gpu": gpu,
"system_memory": {
"total": sys_mem.total / 1024**3,
"available": sys_mem.available / 1024**3,
},
"device_type": device_type,
"recommended_device": recommended,
}
def normalize_device_choice(device: str) -> str:
device = (device or "auto").strip()
if device == "auto":
return "auto"
if device.isdigit():
device = f"cuda:{int(device)}"
if device == "cuda":
if not torch.cuda.is_available():
print("[QwenVL] CUDA requested but not available, falling back to CPU")
return "cpu"
return "cuda"
if device.startswith("cuda"):
if not torch.cuda.is_available():
print("[QwenVL] CUDA requested but not available, falling back to CPU")
return "cpu"
if ":" in device:
try:
device_idx = int(device.split(":", 1)[1])
if device_idx >= torch.cuda.device_count():
print(f"[QwenVL] CUDA device {device_idx} not available, using cuda:0")
return "cuda:0"
except (ValueError, IndexError):
print(f"[QwenVL] Invalid CUDA device format '{device}', using cuda:0")
return "cuda:0"
return device
if device == "mps":
if not (getattr(torch.backends, "mps", None) and torch.backends.mps.is_available()):
print("[QwenVL] MPS requested but not available, falling back to CPU")
return "cpu"
return "mps"
return device
def flash_attn_available():
if not torch.cuda.is_available():
return False
if platform.system().lower() != "linux":
return False
major, _ = torch.cuda.get_device_capability()
if major < 8:
return False
try:
import flash_attn # noqa: F401
except Exception:
return False
try:
import importlib.metadata as importlib_metadata
_ = importlib_metadata.version("flash_attn")
except Exception:
return False
return True
def sage_attn_available():
"""Check if SageAttention is available and GPU supports it."""
return get_sage_attention_config()[0] is not None
def get_sage_attention_config():
"""Get the appropriate SageAttention kernel based on GPU architecture."""
if not torch.cuda.is_available():
return None, None, None
if getattr(torch.version, "hip", None):
try:
is_v1 = importlib.metadata.version("sageattention").split(".", 1)[0] == "1"
except importlib.metadata.PackageNotFoundError:
is_v1 = False
if is_v1 and callable(sageattn_triton):
return sageattn_triton, None, None
return None, None, None
if not SAGE_ATTENTION_AVAILABLE:
return None, None, None
major, minor = torch.cuda.get_device_capability()
arch_code = major * 10 + minor
if arch_code < 80:
return None, None, None
attn_func = None
pv_accum_dtype = "fp32"
if arch_code >= 120: # Blackwell
pv_accum_dtype = "fp32+fp32"
attn_func = sageattn_qk_int8_pv_fp8_cuda
print(f"[QwenVL] SageAttention: Using SM120 (Blackwell) FP8 kernel")
elif arch_code >= 90: # Hopper
pv_accum_dtype = "fp32+fp32"
attn_func = sageattn_qk_int8_pv_fp8_cuda_sm90
print(f"[QwenVL] SageAttention: Using SM90 (Hopper) FP8 kernel")
elif arch_code == 89: # Ada Lovelace
pv_accum_dtype = "fp32+fp32"
attn_func = sageattn_qk_int8_pv_fp8_cuda
print(f"[QwenVL] SageAttention: Using SM89 (Ada) FP8 kernel")
elif arch_code >= 80: # Ampere
pv_accum_dtype = "fp32"
attn_func = sageattn_qk_int8_pv_fp16_cuda
print(f"[QwenVL] SageAttention: Using SM80+ (Ampere) FP16 kernel")
else:
print(f"[QwenVL] SageAttention not supported on SM{arch_code}")
return None, None, None
return (attn_func, "per_warp", pv_accum_dtype) if callable(attn_func) else (None, None, None)
def is_fp8_model(model_name: str) -> bool:
"""Check if model name indicates it's a pre-quantized FP8 model."""
fp8_indicators = ["-fp8", "_fp8", "-FP8", "_FP8"]
return any(indicator in model_name for indicator in fp8_indicators)
def resolve_attention_mode(mode, force_sdpa=False):
"""Resolve attention mode with fallback logic.
Args:
mode: The requested attention mode
force_sdpa: If True, always return SDPA (for FP8/BnB models)
"""
if force_sdpa:
return "sdpa"
if mode == "sdpa":
return "sdpa"
if mode == "sage":
if sage_attn_available():
return "sage"
print("[QwenVL] SageAttention forced but unavailable, falling back to SDPA")
return "sdpa"
if mode == "flash_attention_2":
if flash_attn_available():
return "flash_attention_2"
print("[QwenVL] Flash-Attn forced but unavailable, falling back to SDPA")
return "sdpa"
# Auto mode: try sage → flash → sdpa
if sage_attn_available():
print("[QwenVL] Auto mode: Using SageAttention")
return "sage"
if flash_attn_available():
print("[QwenVL] Auto mode: Using Flash Attention 2")
return "flash_attention_2"
print("[QwenVL] Auto mode: Using SDPA")
return "sdpa"
def ensure_model(model_name, require_processor=False, node_id=None, progress_label: str | None = None, hf_token: str | None = None):
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
info = HF_ALL_MODELS.get(model_name)
if not info:
raise ValueError(f"Model '{model_name}' not in config")
# Local models have a direct path — use it, skip download
local_path = info.get("local_path")
if local_path:
target = Path(local_path)
if target.exists() and target.is_dir():
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
return str(target)
enforce_model_access(info, model_name, local_exists=False, hf_token=hf_token)
raise FileNotFoundError(f"[QwenVL] Local HF model directory not found: {target}")
repo_id = info.get("repo_id")
if not repo_id:
raise ValueError(f"Model '{model_name}' has no repo_id or local_path")
installed_target = _find_installed_hf_model_dir(info, require_processor=require_processor)
if installed_target is not None:
info["is_installed"] = True
info["installed_path"] = str(installed_target)
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
return str(installed_target)
if COMMERCIAL_RELEASE:
enforce_model_access(info, model_name, local_exists=False, hf_token=hf_token)
target = Path(folder_paths.models_dir) / "LLM" / repo_id.split("/")[-1]
if _model_snapshot_has_required_files(target, require_processor=require_processor):
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
return str(target)
raise FileNotFoundError(
f"[QwenVL] Locked commercial model is missing or incomplete: {target}"
)
# Download into ComfyUI's first configured LLM location.
models_dir = _hf_qwen_vl_dirs()[0]
models_dir.mkdir(parents=True, exist_ok=True)
target = models_dir / repo_id.split("/")[-1]
# Only trust an existing snapshot if it also contains the metadata needed
# by the selected backend. This avoids reusing stale or partial downloads.
if _model_snapshot_has_required_files(target, require_processor=require_processor):
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
return str(target)
enforce_model_access(info, repo_id, local_exists=False, hf_token=hf_token)
if target.exists() and target.is_dir():
print(f"[QwenVL] Existing model snapshot is incomplete, refreshing: {target}")
_download_model_snapshot(
repo_id,
target,
require_processor=require_processor,
node_id=node_id,
progress_label=progress_label or f"QwenVL HF Download: {model_name}",
hf_token=hf_token,
)
if not _model_snapshot_has_required_files(target, require_processor=require_processor):
missing = []
if not (target / "config.json").exists():
missing.append("config.json")
if require_processor and not any(
(target / name).exists()
for name in (
"preprocessor_config.json",
"processor_config.json",
"image_processor_config.json",
"video_preprocessor_config.json",
)
):
missing.append("processor metadata")
if not _model_snapshot_has_weights(target):
missing.append("model weights")
missing_str = ", ".join(missing) if missing else "required files"
raise FileNotFoundError(
f"[QwenVL] Downloaded model snapshot is incomplete for {repo_id}: missing {missing_str} in {target}"
)
return str(target)
def enforce_memory(model_name, quantization, device_info):
info = HF_ALL_MODELS.get(model_name, {})
requirements = info.get("vram_requirement", {})
mapping = {
Quantization.Q4: requirements.get("4bit", 0),
Quantization.Q8: requirements.get("8bit", 0),
Quantization.FP16: requirements.get("full", 0),
}
needed = mapping.get(quantization, 0)
if not needed:
return quantization # Always return quantization
if device_info["recommended_device"] in {"cpu", "mps"}:
needed *= 1.5
available = device_info["system_memory"]["available"]
else:
available = device_info["gpu"]["free_memory"]
# More conservative memory management
if needed * 1.5 > available: # More conservative threshold
if quantization == Quantization.FP16:
print("[QwenVL] ⚠️ Auto-switch to 8-bit due to VRAM pressure")
return Quantization.Q8
if quantization == Quantization.Q8:
print("[QwenVL] ⚠️ Auto-switch to 4-bit due to VRAM pressure")
return Quantization.Q4
raise RuntimeError(f"Insufficient memory for {quantization.value} mode. Required: {needed * 1.5:.1f}GB, Available: {available / 1024**3:.1f}GB")
# Conservative memory check with safety margin
if needed * 1.3 > available: # Reduced from 1.5 to 1.3
if quantization == Quantization.FP16:
print("[QwenVL] ⚠️ Auto-switch to 8-bit due to VRAM pressure")
return Quantization.Q8
if quantization == Quantization.Q8:
print("[QwenVL] ⚠️ Auto-switch to 4-bit due to VRAM pressure")
return Quantization.Q4
print(f"[QwenVL] Memory pressure detected. Consider: 1) Use 4-bit quantization, 2) Reduce max_tokens below 1024, 3) Close other applications")
return quantization # Always return quantization
def quantization_config(model_name, quantization):
info = HF_ALL_MODELS.get(model_name, {})
if info.get("quantized"):
return None, None
if quantization == Quantization.Q4:
cfg = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
)
return cfg, None
if quantization == Quantization.Q8:
return BitsAndBytesConfig(load_in_8bit=True), None
return None, torch.float16
+665
View File
@@ -0,0 +1,665 @@
"""Hugging Face multimodal runtime independent from ComfyUI node declarations."""
import gc
import time
import numpy as np
import torch
from PIL import Image
try:
from transformers import AutoProcessor, AutoTokenizer
except ImportError:
AutoProcessor = None
AutoTokenizer = None
try:
from transformers import AutoModelForVision2Seq
except ImportError:
from transformers import AutoModelForImageTextToText as AutoModelForVision2Seq
import folder_paths
from AILab_StreamDisplay import TerminalStreamDisplay
from comfy.model_management import throw_exception_if_processing_interrupted
from thinkingllm_core.hf_models import (
COMMERCIAL_RELEASE,
HF_ALL_MODELS,
NO_PRESET_PROMPT,
SYSTEM_PROMPTS,
Quantization,
ensure_cuda_vram_headroom,
ensure_model,
get_device_info,
is_fp8_model,
normalize_device_choice,
quantization_config,
read_hf_model_type,
resolve_attention_mode,
)
from thinkingllm_core.media import (
MASK_FOCUS_INSTRUCTION,
apply_mask_highlight,
get_image_hash,
get_video_hash,
)
from thinkingllm_core.model_access import resolve_model_catalog_name
from thinkingllm_core.prompt_contracts import (
apply_qwen_soft_thinking_directive,
apply_video_duration_context,
ensure_video_prompt_duration,
resolve_qwen_context_window,
resolve_qwen_thinking_mode,
resolve_video_duration,
validate_minimax_reference_request,
validate_minimax_source_duration,
)
from thinkingllm_core.prompt_state import (
PROMPT_CACHE,
build_node_input_signature,
get_cache_key,
get_node_saved_prompt_with_seed,
log_llm_input,
save_prompt_cache,
set_node_saved_prompt,
)
_STREAM_HEARTBEAT_INTERVAL_SECONDS = 3.0
def _maybe_emit_hf_stream_heartbeat(stage_label: str, started_at: float, last_status_at: float, full_text: str) -> float:
now = time.monotonic()
if (now - last_status_at) < _STREAM_HEARTBEAT_INTERVAL_SECONDS:
return last_status_at
elapsed = max(0.0, now - started_at)
print(f"[QwenVL HF] {stage_label}: still generating... ({len(full_text)} chars, {elapsed:.1f}s)")
return now
class QwenVLBase:
def __init__(self):
self.device_info = get_device_info()
self.model = None
self.processor = None
self.tokenizer = None
self.current_signature = None
print(f"[QwenVL] Node on {self.device_info['device_type']}")
def clear(self):
self.model = None
self.processor = None
self.tokenizer = None
self.current_signature = None
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
try:
torch.cuda.ipc_collect()
except Exception:
pass
def load_model(
self,
model_name,
quant_value,
attention_mode,
use_compile,
device_choice,
keep_model_loaded,
unique_id=None,
hf_token: str | None = None,
):
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
model_info = HF_ALL_MODELS.get(model_name, {})
comfy_filename = model_info.get("comfy_text_encoder")
if comfy_filename:
model_path = folder_paths.get_full_path_or_raise("text_encoders", comfy_filename)
signature = (model_name, "comfy", model_path)
if keep_model_loaded and self.model is not None and self.current_signature == signature:
return
self.clear()
from comfy import sd as comfy_sd
self.model = comfy_sd.load_clip(
ckpt_paths=[model_path],
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
self.current_signature = signature
print(f"[QwenVL] Loaded shared ComfyUI text encoder: {comfy_filename}")
return
quant = Quantization.from_value(quant_value) # Skip enforce_memory for now
# Safety check: ensure quant is not None
if quant is None:
print(f"[QwenVL] Invalid quantization value: {quant_value}, falling back to FP16")
quant = Quantization.FP16
# Check if BitsAndBytes quantization is being used
is_bnb_quantization = quant in [Quantization.Q4, Quantization.Q8]
# Check if this is a pre-quantized FP8 model
is_prequantized_fp8 = is_fp8_model(model_name) or HF_ALL_MODELS.get(model_name, {}).get("quantized", False)
# Determine if we need to force SDPA (for FP8 or BitsAndBytes models)
force_sdpa = is_prequantized_fp8 or is_bnb_quantization
# Resolve attention mode with force_sdpa flag
attn_impl = resolve_attention_mode(attention_mode, force_sdpa=force_sdpa)
# Additional info messages for forced SDPA
if force_sdpa and attention_mode in ["auto", "sage", "flash_attention_2"]:
if is_prequantized_fp8:
print("[QwenVL] FP8 model detected - forcing SDPA attention")
elif is_bnb_quantization:
print("[QwenVL] BitsAndBytes quantization detected - forcing SDPA attention")
print(f"[QwenVL] Attention backend selected: {attn_impl}")
device_requested = self.device_info["recommended_device"] if device_choice == "auto" else device_choice
device = normalize_device_choice(device_requested)
signature = (model_name, quant.value, attn_impl, device, use_compile)
if keep_model_loaded and self.model is not None and self.current_signature == signature:
ensure_cuda_vram_headroom("QwenVL", min_free_gb=1.0, min_free_ratio=0.08)
return
self.clear()
model_path = ensure_model(
model_name,
require_processor=True,
node_id=unique_id,
progress_label=f"QwenVL HF Download: {model_name}",
hf_token=hf_token,
)
quant_config, dtype = quantization_config(model_name, quant)
# Handle attention mode for loading
# SageAttention requires loading with SDPA first, then patching
actual_attn_impl = attn_impl
if attn_impl == "sage":
actual_attn_impl = "sdpa"
# MEMORY DEBUGGING: Check memory before loading
if torch.cuda.is_available():
torch.cuda.empty_cache()
allocated_before = torch.cuda.memory_allocated() / 1024**3
reserved_before = torch.cuda.memory_reserved() / 1024**3
total_memory = torch.cuda.get_device_properties(0).total_memory / 1024**3
print(f"[QwenVL] 📊 Memory BEFORE load - Allocated: {allocated_before:.1f}GB, Reserved: {reserved_before:.1f}GB, Total: {total_memory:.1f}GB")
# Check what's using memory
if allocated_before > 2.0:
print(f"[QwenVL] ⚠️ WARNING: {allocated_before:.1f}GB already allocated before loading!")
# DEBUG: Print quantization config details
print(f"[QwenVL] 🔍 DEBUG - Model: {model_name}, Quant: {quant}, Quant config: {quant_config}")
print(f"[QwenVL] 🔍 DEBUG - Device: {device}, Dtype: {dtype}")
print(f"[QwenVL] 🔍 DEBUG - Attention impl: {actual_attn_impl}")
# Quantization is back - enforce_memory was the problem
load_kwargs = {
"device_map": "auto" if device != "cpu" and torch.cuda.is_available() else "cpu",
"dtype": dtype or torch.float16,
"attn_implementation": actual_attn_impl,
"local_files_only": COMMERCIAL_RELEASE,
"use_safetensors": True,
"low_cpu_mem_usage": True,
}
if device != "cpu" and torch.cuda.is_available():
gpu_mem = torch.cuda.get_device_properties(0).total_memory
gpu_mem_gb = max(1, int(gpu_mem / (1024**3)))
load_kwargs["max_memory"] = {
0: f"{gpu_mem_gb}GB",
"cpu": "16GB",
}
if quant_config:
load_kwargs["quantization_config"] = quant_config
self.model = AutoModelForVision2Seq.from_pretrained(model_path, **load_kwargs).eval()
# Apply SageAttention patching if needed
if attn_impl == "sage":
try:
from sageattention_patch import set_sage_attention
set_sage_attention(self.model)
print("[QwenVL] SageAttention patching applied successfully")
except Exception as e:
print(f"[QwenVL] SageAttention patching failed: {e}")
print("[QwenVL] Falling back to SDPA attention")
# Model is already loaded with SDPA, so we can continue
# MEMORY CLEANUP: Clear cache after model loading
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
print(f"[QwenVL] GPU memory after load: {torch.cuda.memory_allocated() / 1024**3:.1f}GB / {torch.cuda.get_device_properties(0).total_memory / 1024**3:.1f}GB")
self.model.config.use_cache = True
if hasattr(self.model, "generation_config"):
self.model.generation_config.use_cache = True
if use_compile and device.startswith("cuda") and torch.cuda.is_available():
try:
self.model = torch.compile(self.model, mode="reduce-overhead")
print("[QwenVL] torch.compile enabled")
except Exception as exc:
print(f"[QwenVL] torch.compile skipped: {exc}")
self.processor = AutoProcessor.from_pretrained(
model_path,
local_files_only=COMMERCIAL_RELEASE,
trust_remote_code=not COMMERCIAL_RELEASE,
)
self.tokenizer = AutoTokenizer.from_pretrained(
model_path,
local_files_only=COMMERCIAL_RELEASE,
trust_remote_code=not COMMERCIAL_RELEASE,
)
# Detect architecture from config.json instead of relying on model name
hf_model_type = read_hf_model_type(model_path)
self.hf_model_type = hf_model_type
self.is_qwen35 = hf_model_type in ("qwen3_5", "qwen3_5_moe", "qwen3_5_vl") if hf_model_type else "qwen3.5-" in model_name.lower()
self.supports_qwen_soft_think = hf_model_type == "qwen3" if hf_model_type else "qwen3-" in model_name.lower()
if self.is_qwen35:
print(f"[QwenVL] Qwen3.5 detected (model_type={hf_model_type}): Will disable thinking in chat template.")
self.current_signature = signature
@staticmethod
def tensor_to_pil(tensor):
if tensor is None:
return None
if tensor.dim() == 4:
tensor = tensor[0]
array = (tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
if array.ndim == 2:
return Image.fromarray(array, mode="L")
if array.shape[-1] == 1:
return Image.fromarray(array[..., 0], mode="L")
if array.shape[-1] == 4:
return Image.fromarray(array, mode="RGBA")
return Image.fromarray(array[..., :3], mode="RGB")
@torch.no_grad()
def generate(
self,
prompt_text,
image,
video,
frame_count,
max_tokens,
temperature,
top_p,
num_beams,
repetition_penalty,
model_name="",
stream_to_terminal=False,
enable_thinking=True,
seed=0,
preset_name="",
):
model_info = HF_ALL_MODELS.get(model_name, {})
native_family = model_info.get("native_family")
if native_family:
if num_beams != 1:
raise ValueError("[QwenVL] ComfyUI text encoders support num_beams=1 only")
if native_family == "gemma3" and video is not None:
raise ValueError("[QwenVL] Gemma 3 shared text encoders support text and image input, not video")
tokenize_kwargs = {"skip_template": False, "thinking": bool(enable_thinking)}
if native_family == "gemma3":
tokenize_kwargs["image"] = image[:1] if image is not None else None
else:
images = []
if image is not None:
if image.dim() == 4 and image.shape[0] > 1:
print(f"[QwenVL] IMAGE input contains {image.shape[0]} items; using the first item only. Use the video input for multi-frame analysis.")
images.append(image[:1] if image.dim() == 4 else image)
if video is not None:
frame_indexes = list(range(video.shape[0]))
if len(frame_indexes) > frame_count:
frame_indexes = np.linspace(0, len(frame_indexes) - 1, frame_count, dtype=int).tolist()
images.extend(video[index:index + 1] for index in frame_indexes)
tokenize_kwargs["images"] = images
throw_exception_if_processing_interrupted()
log_llm_input(
"QwenVL ComfyUI Native",
"INITIAL GENERATION",
preset_name,
prompt_text,
media={
"image": 1 if image is not None else 0,
"video_frames": max(0, len(tokenize_kwargs.get("images", [])) - (1 if image is not None else 0)),
},
)
tokens = self.model.tokenize(prompt_text, **tokenize_kwargs)
output_tokens = self.model.generate(
tokens,
do_sample=True,
max_length=max_tokens,
temperature=temperature,
top_k=50,
top_p=top_p,
min_p=0.0,
repetition_penalty=repetition_penalty,
seed=seed,
presence_penalty=0.0,
)
throw_exception_if_processing_interrupted()
raw_text = self.model.decode(output_tokens).strip()
if not raw_text:
raise RuntimeError(
"[QwenVL] The model ended generation without producing text. Change the seed or add a more "
"specific custom prompt; the empty result was not cached."
)
if stream_to_terminal:
print("[QwenVL] ComfyUI native generation completed (live token streaming is unavailable):")
print(raw_text)
return raw_text, raw_text
# Memory optimization: clear cache before generation
ensure_cuda_vram_headroom("QwenVL", min_free_gb=1.0, min_free_ratio=0.08)
supports_soft_think = getattr(self, "supports_qwen_soft_think", False)
is_qwen35 = getattr(self, "is_qwen35", False)
context_window = resolve_qwen_context_window(getattr(self.model, "config", None))
def _build_processed(thinking_enabled: bool):
effective_prompt_text = apply_qwen_soft_thinking_directive(
prompt_text,
thinking_enabled,
supports_soft_switch=supports_soft_think,
)
conversation = [{"role": "user", "content": []}]
if image is not None:
if image.dim() == 4 and image.shape[0] > 1:
print(f"[QwenVL] IMAGE input contains {image.shape[0]} items; using the first item only. Use the video input for multi-frame analysis.")
conversation[0]["content"].append({"type": "image", "image": self.tensor_to_pil(image)})
if video is not None:
frames = [self.tensor_to_pil(frame) for frame in video]
if len(frames) > frame_count:
idx = np.linspace(0, len(frames) - 1, frame_count, dtype=int)
frames = [frames[i] for i in idx]
if frames:
conversation[0]["content"].append({"type": "video", "video": frames})
conversation[0]["content"].append({"type": "text", "text": effective_prompt_text})
chat_kwargs = {}
if is_qwen35 or supports_soft_think:
chat_kwargs["enable_thinking"] = thinking_enabled
chat = self.processor.apply_chat_template(
conversation,
tokenize=False,
add_generation_prompt=True,
**chat_kwargs
)
images = [item["image"] for item in conversation[0]["content"] if item["type"] == "image"]
video_frames = [frame for item in conversation[0]["content"] if item["type"] == "video" for frame in item["video"]]
videos = [video_frames] if video_frames else None
processed = self.processor(text=chat, images=images or None, videos=videos, return_tensors="pt")
return effective_prompt_text, chat, processed, {
"image": len(images),
"video_frames": len(video_frames),
}
requested_thinking = bool(enable_thinking)
effective_prompt_text, chat, processed, media = _build_processed(requested_thinking)
input_ids = processed.get("input_ids")
prompt_tokens = int(input_ids.shape[-1]) if torch.is_tensor(input_ids) else 0
effective_thinking = resolve_qwen_thinking_mode(
enable_thinking,
max_tokens,
label="QwenVL HF",
prompt_tokens=prompt_tokens,
context_window=context_window,
quiet=stream_to_terminal,
)
if effective_thinking != requested_thinking:
effective_prompt_text, chat, processed, media = _build_processed(effective_thinking)
if supports_soft_think:
directive = "/think" if effective_thinking else "/no_think"
print(f"[QwenVL] Qwen3 detected: Thinking {'enabled' if effective_thinking else 'disabled'} via chat template and {directive}.")
# Move to device more efficiently
model_device = next(self.model.parameters()).device
model_inputs = {
key: value.to(model_device, non_blocking=True) if torch.is_tensor(value) else value
for key, value in processed.items()
}
stop_tokens = [self.tokenizer.eos_token_id]
if hasattr(self.tokenizer, "eot_id") and self.tokenizer.eot_id is not None:
stop_tokens.append(self.tokenizer.eot_id)
# Memory-efficient generation parameters
kwargs = {
"max_new_tokens": max_tokens,
"repetition_penalty": repetition_penalty,
"num_beams": num_beams,
"eos_token_id": stop_tokens,
"pad_token_id": self.tokenizer.pad_token_id,
}
if num_beams == 1:
kwargs.update({"do_sample": True, "temperature": temperature, "top_p": top_p})
# --- Qwen3.5 Heretic Logic: Top K ---
if is_qwen35:
kwargs["top_k"] = 20
print("[QwenVL] Qwen3.5 detected: Forcing top_k=20 for recommended tuning.")
else:
kwargs["do_sample"] = False
log_llm_input(
"QwenVL HF",
"INITIAL GENERATION",
preset_name,
effective_prompt_text,
formatted_text=chat,
media=media,
)
# Optional: staged readable terminal streaming
if stream_to_terminal:
try:
from AILab_StreamDisplay import TerminalStreamDisplay
from transformers import TextIteratorStreamer
from threading import Thread
stage_label = "STREAMING"
stream_display = TerminalStreamDisplay("QwenVL HF", suppress_planning=True, compact=True)
streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True, skip_special_tokens=True)
kwargs["streamer"] = streamer
thread = Thread(target=self.model.generate, kwargs={**model_inputs, **kwargs})
stream_display.start_stage(stage_label)
thread.start()
full_streamed = ""
for token_str in streamer:
if token_str:
throw_exception_if_processing_interrupted()
full_streamed += token_str
stream_display.push_compact(token_str)
thread.join()
stream_display.end_compact()
stream_display.end_stage()
if not full_streamed.strip():
raise RuntimeError("[QwenVL] HF streaming returned empty response")
cleaned_text = full_streamed.strip()
raw_text = full_streamed.strip()
return cleaned_text, raw_text
except ImportError:
print("[QwenVL] TextIteratorStreamer not available — falling back to non-streaming")
stream_to_terminal = False
# Generate with interrupt support via background thread
import threading, queue as qmod
abort_event = threading.Event()
output_queue: qmod.Queue = qmod.Queue()
worker_error: Exception | None = None
def _generate_worker():
nonlocal worker_error
try:
result = self.model.generate(**model_inputs, **kwargs)
output_queue.put(("ok", result))
except Exception as exc:
worker_error = exc
output_queue.put(("error", None))
worker = threading.Thread(target=_generate_worker, daemon=True)
worker.start()
try:
while True:
try:
kind, result = output_queue.get(timeout=0.25)
except qmod.Empty:
throw_exception_if_processing_interrupted()
continue
if kind == "error":
break
outputs = result
break
finally:
abort_event.set()
worker.join(timeout=5.0)
if worker_error:
raise worker_error
throw_exception_if_processing_interrupted()
if torch.cuda.is_available():
torch.cuda.synchronize()
input_len = model_inputs["input_ids"].shape[-1]
raw_text = self.tokenizer.decode(outputs[0, input_len:], skip_special_tokens=True).strip()
cleaned_text = raw_text # generate() returns raw; caller cleans if needed
return cleaned_text, raw_text
def run(self, model_name, quantization, preset_prompt, custom_prompt, image, video, frame_count, max_tokens, temperature, top_p, num_beams, repetition_penalty, seed, keep_model_loaded, attention_mode, use_torch_compile, device, keep_last_prompt=False, unique_id=None, extra_pnginfo=None, node_class="QwenVL", stream_to_terminal=False, enable_thinking=True, hf_token: str | None = None, mask=None, llm_input_preset_name: str | None = None, duration_seconds=5.0):
model_name = resolve_model_catalog_name(HF_ALL_MODELS, model_name)
validate_minimax_reference_request(preset_prompt, custom_prompt)
duration_preset_name = llm_input_preset_name or preset_prompt
resolved_duration = resolve_video_duration(duration_preset_name, duration_seconds)
validate_minimax_source_duration(duration_preset_name, duration_seconds, custom_prompt)
effective_duration_seconds = resolved_duration["effective_seconds"] if resolved_duration else None
duration_signature = ({
"effective_duration_seconds": effective_duration_seconds,
"video_prompt_contract_version": resolved_duration.get("contract_version"),
} if resolved_duration else {})
torch.manual_seed(seed)
image, mask_hash = apply_mask_highlight(image, mask)
image_hash = get_image_hash(image)
if mask_hash:
image_hash = f"{image_hash}:{mask_hash}"
video_hash = get_video_hash(video)
input_signature = build_node_input_signature(
model_name=model_name,
quantization=quantization,
preset_prompt=preset_prompt,
custom_prompt=custom_prompt,
image_hash=image_hash,
video_hash=video_hash,
frame_count=frame_count,
attention_mode=attention_mode,
use_torch_compile=bool(use_torch_compile),
device=device,
enable_thinking=bool(enable_thinking),
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
**duration_signature,
)
# Auto-retrieve saved prompt when seed is fixed
saved = get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
if saved and not stream_to_terminal:
print(f"[QwenVL] Preset {llm_input_preset_name or preset_prompt}: fixed seed {seed} matched; no LLM call was made.")
return (saved,)
if saved and stream_to_terminal:
pass
if keep_last_prompt:
print(f"[QwenVL] Keep last prompt enabled — looking up per-node state")
if saved:
print(f"[QwenVL] Preset {llm_input_preset_name or preset_prompt}: using saved prompt; no LLM call was made.")
return (saved,)
else:
print(f"[QwenVL] No per-node prompt found, returning empty")
return ("",)
# Always generate unless keep_last_prompt was requested
if not stream_to_terminal:
print(f"[QwenVL] Generating new prompt")
prompt_template = "" if preset_prompt == NO_PRESET_PROMPT else SYSTEM_PROMPTS.get(preset_prompt, preset_prompt)
# Generate cache key with all inputs including seed
cache_key = get_cache_key(model_name, preset_prompt, custom_prompt, image_hash, video_hash, seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, enable_thinking=enable_thinking, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
# Check cache first (only for random mode)
if cache_key in PROMPT_CACHE:
cached_text = PROMPT_CACHE[cache_key].get("text", "")
if cached_text:
print(f"[QwenVL] Preset {llm_input_preset_name or preset_prompt}: cache hit for seed {seed}; no LLM call was made.")
return (cached_text,)
if custom_prompt and custom_prompt.strip():
# Combine user input with template - custom prompt first for priority
prompt = f"{custom_prompt.strip()}\n\n{prompt_template}" if prompt_template else custom_prompt.strip()
else:
prompt = prompt_template
if mask_hash:
prompt = f"{prompt}\n\n{MASK_FOCUS_INSTRUCTION}".strip()
prompt = apply_video_duration_context(prompt, duration_preset_name, duration_seconds)
self.load_model(
model_name,
quantization,
attention_mode,
use_torch_compile,
device,
keep_model_loaded,
unique_id=unique_id,
hf_token=hf_token,
)
hf_token = None
try:
text, raw_text = self.generate(
prompt,
image,
video,
frame_count,
max_tokens,
temperature,
top_p,
num_beams,
repetition_penalty,
model_name=model_name,
stream_to_terminal=stream_to_terminal,
enable_thinking=enable_thinking,
seed=seed,
preset_name=llm_input_preset_name or preset_prompt,
)
text = ensure_video_prompt_duration(
text,
duration_preset_name,
duration_seconds,
source_prompt_text=custom_prompt,
)
# Cache the generated text
PROMPT_CACHE[cache_key] = {
"text": text,
"timestamp": torch.cuda.Event().record() if torch.cuda.is_available() else None,
"model": model_name,
"preset": preset_prompt,
"seed": seed,
"image_hash": image_hash,
"mask_hash": mask_hash,
"video_hash": video_hash,
**duration_signature,
}
save_prompt_cache() # Save cache to file
print(f"[QwenVL] Cached new prompt for seed {seed}: {cache_key[:8]}...")
# Save the generated prompt for future per-node keep-last-prompt
set_node_saved_prompt(node_class, unique_id, extra_pnginfo, text, raw_trace=raw_text, seed=seed, max_tokens=max_tokens, temperature=temperature, top_p=top_p, repetition_penalty=repetition_penalty, input_signature=input_signature)
print(f"[QwenVL] Saved per-node prompt: {text[:50]}...")
return (text,)
finally:
if not keep_model_loaded:
self.clear()
+141
View File
@@ -0,0 +1,141 @@
"""Media hashing and mask-focus helpers shared by HF and GGUF backends."""
import hashlib
import torch
MASK_FOCUS_INSTRUCTION = (
"The image has a mask highlight: the selected area is shown at normal brightness and the surrounding "
"context is dimmed. Analyze and describe only the normal-brightness selected area. Use the dimmed "
"surroundings only for spatial context and do not describe them."
)
MASK_RECONSTRUCTION_INSTRUCTION = (
"The selected area is deliberately concealed and contains no usable information about its former "
"contents. Never identify or describe anything that may have occupied it. Use only the visible "
"surroundings to infer the most natural scene continuation through that area, and describe the "
"completed scene without mentioning masks, removal, editing, or missing content."
)
def tensor_to_pil(tensor):
"""Convert tensor to PIL Image with memory optimization"""
if tensor is None:
return None
try:
if tensor.dim() == 4:
tensor = tensor[0]
# More aggressive memory management
if tensor.is_floating_point():
# Scale to 0-255 range more efficiently
tensor = tensor.clamp(0, 1)
# Reduce sample size for memory efficiency
array = tensor.cpu().numpy()
if tensor.numel() > 0:
# Take smaller sample for hash to save memory
sample_size = min(50, tensor.numel() // 4) # Reduced from 100
sample_pixels = array.flatten()[:sample_size].tolist() if array.size > 0 else []
else:
sample_pixels = []
content = f"{tensor.shape}_{tensor.dtype}_{sample_pixels[:10]}"
return hashlib.md5(content.encode()).hexdigest()[:16]
except:
return None
def get_image_hash(image):
"""Generate hash for image tensor"""
if image is None:
return None
try:
# Use image tensor properties for hash
shape = str(image.shape)
dtype = str(image.dtype)
# Sample a few pixels for content hash (avoid full tensor for performance)
if len(image.shape) >= 3:
sample_pixels = image.flatten()[:100].tolist() if image.numel() > 0 else []
else:
sample_pixels = image.flatten().tolist() if image.numel() > 0 else []
content = f"{shape}_{dtype}_{sample_pixels[:10]}" # Limit sample size
return hashlib.md5(content.encode()).hexdigest()[:16]
except:
return None
def apply_mask_highlight(image, mask, mode="focus"):
"""Prepare a masked image for focus analysis or background reconstruction."""
if mask is None:
return image, None
if image is None:
raise ValueError("[QwenVL] A mask requires an image input.")
if not torch.is_tensor(image) or image.ndim not in (3, 4):
raise ValueError("[QwenVL] IMAGE must have shape [H,W,C] or [B,H,W,C].")
if not torch.is_tensor(mask):
raise ValueError("[QwenVL] MASK must be a tensor.")
if mode not in {"focus", "reconstruct"}:
raise ValueError("[QwenVL] mask mode must be 'focus' or 'reconstruct'.")
if mask.ndim == 2:
selected_mask = mask
elif mask.ndim == 3:
selected_mask = mask[0]
else:
raise ValueError("[QwenVL] MASK must have shape [H,W] or [B,H,W].")
selected_mask = selected_mask.detach().float().clamp(0.0, 1.0)
if not torch.isfinite(selected_mask).all():
raise ValueError("[QwenVL] MASK contains non-finite values.")
if not torch.any(selected_mask > 0):
raise ValueError("[QwenVL] MASK is empty; select at least one pixel.")
frame = image[0] if image.ndim == 4 else image
resized_mask = torch.nn.functional.interpolate(
selected_mask[None, None],
size=frame.shape[:2],
mode="bilinear",
align_corners=False,
)[0, 0].to(device=frame.device, dtype=frame.dtype)
highlighted_frame = frame.clone()
color_channels = min(int(frame.shape[-1]), 3)
if mode == "reconstruct":
# Fully hide the selected subject, including anti-aliased mask edges, so the
# vision model can reason from the surrounding scene instead of naming it.
binary_mask = (resized_mask > 0.01).to(dtype=frame.dtype)
expanded_mask = torch.nn.functional.max_pool2d(
binary_mask[None, None], kernel_size=9, stride=1, padding=4,
)[0, 0]
visible = 1.0 - expanded_mask
visible_weight = visible.sum()
if float(visible_weight.detach().cpu()) > 0:
fill = (
frame[..., :color_channels] * visible.unsqueeze(-1)
).sum(dim=(0, 1)) / visible_weight
else:
fill = torch.full(
(color_channels,), 0.5, device=frame.device, dtype=frame.dtype,
)
highlighted_frame[..., :color_channels] = (
frame[..., :color_channels] * visible.unsqueeze(-1)
+ fill.view(1, 1, -1) * expanded_mask.unsqueeze(-1)
)
else:
brightness = 0.2 + 0.8 * resized_mask
highlighted_frame[..., :color_channels] *= brightness.unsqueeze(-1)
highlighted = image.clone()
if image.ndim == 4:
highlighted[0] = highlighted_frame
else:
highlighted = highlighted_frame
digest = hashlib.md5()
digest.update(mode.encode("ascii"))
digest.update(str(tuple(mask.shape)).encode("ascii"))
digest.update(mask.detach().contiguous().cpu().numpy().tobytes())
return highlighted, digest.hexdigest()[:16]
def get_video_hash(video):
"""Generate hash for video tensor (same as image)"""
return get_image_hash(video)
+567
View File
@@ -0,0 +1,567 @@
"""Model catalog policy and authenticated Hugging Face download helpers."""
import inspect
import os
import shutil
import tempfile
import time
from pathlib import Path
try:
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
except ImportError:
from huggingface_hub import snapshot_download
HfApi = None
hf_hub_download = None
try:
from tqdm.auto import tqdm
except ImportError:
tqdm = None
try:
from comfy.utils import ProgressBar
except Exception:
ProgressBar = None
try:
from server import PromptServer
except Exception:
PromptServer = None
COMMERCIAL_RELEASE = os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1"
_DOWNLOAD_STATUS_INTERVAL_SECONDS = 0.25
_HF_TOKEN_ENV_VARS = ("HF_TOKEN", "HUGGING_FACE_HUB_TOKEN")
VALID_COMMERCIAL_STATUSES = {"cleared", "external_gated", "unclear", "noncommercial"}
MODEL_STATUS_PRESENTATION = {
"cleared": (0, "✅ Commercial | "),
"external_gated": (1, "🔑 External / gated | "),
"local": (2, "📁 Local / user-supplied | "),
"unclear": (3, "⚠ Rights unclear | "),
"noncommercial": (4, "🚫 Non-commercial | "),
}
MODEL_INSTALLED_SUFFIX = " [installed]"
MODEL_LOCAL_PREFIX = "[local] "
_PROCESSOR_CONFIG_FILENAMES = (
"preprocessor_config.json",
"processor_config.json",
"image_processor_config.json",
"video_preprocessor_config.json",
)
def _format_download_size(num_bytes) -> str:
if num_bytes is None:
return "? B"
value = float(num_bytes)
units = ["B", "KB", "MB", "GB", "TB"]
for unit in units:
if value < 1024.0 or unit == units[-1]:
if unit == "B":
return f"{int(value)} {unit}"
return f"{value:.1f} {unit}"
value /= 1024.0
return f"{value:.1f} TB"
class _DownloadProgressReporter:
def __init__(self, node_id=None, label: str = "QwenVL Download", repo_id: str | None = None):
self.node_id = node_id
self.label = label
self.repo_id = repo_id
self.progress_bar = ProgressBar(1, node_id=node_id) if ProgressBar is not None else None
self._last_text_at = 0.0
self._last_terminal_at = 0.0
self._last_percent = None
def _emit_ui_text(self, text: str, *, force: bool = False) -> None:
if PromptServer is None or self.node_id is None:
return
now = time.monotonic()
if not force and (now - self._last_text_at) < _DOWNLOAD_STATUS_INTERVAL_SECONDS:
return
PromptServer.instance.send_progress_text(text, self.node_id)
self._last_text_at = now
def _emit_terminal_text(self, text: str, *, force: bool = False) -> None:
now = time.monotonic()
if not force and (now - self._last_terminal_at) < _DOWNLOAD_STATUS_INTERVAL_SECONDS:
return
print(text)
self._last_terminal_at = now
def _build_message(self, stage: str, detail: str | None = None, current: int | float | None = None, total: int | float | None = None) -> str:
lines = [self.label, f"Stage: {stage}"]
if self.repo_id:
lines.append(f"Source: {self.repo_id}")
if detail:
lines.append(f"File: {detail}")
if total and total > 0:
percent = int(min(100, max(0, round((float(current or 0) / float(total)) * 100))))
lines.append(f"Progress: {percent}% ({_format_download_size(current)} / {_format_download_size(total)})")
elif current is not None:
lines.append(f"Downloaded: {_format_download_size(current)}")
return "\n".join(lines)
def stage(self, stage: str, *, detail: str | None = None, force: bool = True) -> None:
message = self._build_message(stage, detail=detail)
self._emit_ui_text(message, force=force)
self._emit_terminal_text(f"[{self.label}] {stage}{f' - {detail}' if detail else ''}", force=force)
def progress(self, stage: str, *, detail: str | None = None, current: int | float | None = None, total: int | float | None = None, force: bool = False) -> None:
if self.progress_bar is not None and total is not None and total > 0:
self.progress_bar.update_absolute(min(float(current or 0), float(total)), total=float(total))
percent = None
if total and total > 0:
percent = int(min(100, max(0, round((float(current or 0) / float(total)) * 100))))
message = self._build_message(stage, detail=detail, current=current, total=total)
self._emit_ui_text(message, force=force or percent != self._last_percent)
suffix = ""
if percent is not None:
suffix = f" {percent}% ({_format_download_size(current)} / {_format_download_size(total)})"
elif current is not None:
suffix = f" {_format_download_size(current)}"
self._emit_terminal_text(f"[{self.label}] {stage}{f' - {detail}' if detail else ''}{suffix}", force=force or percent != self._last_percent)
self._last_percent = percent
def finish(self, *, detail: str | None = None, force: bool = True) -> None:
if self.progress_bar is not None:
self.progress_bar.update_absolute(1, total=1)
message = self._build_message("Completed", detail=detail, current=1, total=1)
self._emit_ui_text(message, force=force)
self._emit_terminal_text(f"[{self.label}] Completed{f' - {detail}' if detail else ''}", force=force)
def fail(self, detail: str) -> None:
message = self._build_message("Failed", detail=detail)
self._emit_ui_text(message, force=True)
self._emit_terminal_text(f"[{self.label}] Failed - {detail}", force=True)
def make_tqdm_class(self):
if tqdm is None:
return None
reporter = self
class _NodeDownloadTqdm(tqdm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
reporter.progress(
"Downloading",
detail=getattr(self, "desc", None),
current=getattr(self, "n", 0),
total=getattr(self, "total", None),
force=True,
)
def update(self, n=1):
result = super().update(n)
reporter.progress(
"Downloading",
detail=getattr(self, "desc", None),
current=getattr(self, "n", 0),
total=getattr(self, "total", None),
)
return result
def refresh(self, *args, **kwargs):
result = super().refresh(*args, **kwargs)
reporter.progress(
"Downloading",
detail=getattr(self, "desc", None),
current=getattr(self, "n", 0),
total=getattr(self, "total", None),
)
return result
def close(self):
reporter.progress(
"Downloading",
detail=getattr(self, "desc", None),
current=(getattr(self, "total", None) or getattr(self, "n", 0)),
total=(getattr(self, "total", None) or getattr(self, "n", 0) or None),
force=True,
)
return super().close()
return _NodeDownloadTqdm
def _iter_hf_token_candidates(hf_token: str | None):
token = str(hf_token or "").strip()
if token:
yield token
for env_name in _HF_TOKEN_ENV_VARS:
token = str(os.environ.get(env_name) or "").strip()
if token:
yield token
def _clean_hf_token(hf_token: str | None) -> str | None:
return next(_iter_hf_token_candidates(hf_token), None)
def normalize_commercial_status(info: dict | None) -> str:
info = info if isinstance(info, dict) else {}
if info.get("is_local"):
return "local"
status = str(info.get("commercial_status") or "unclear").strip().lower()
if status == "local":
return "local"
return status if status in VALID_COMMERCIAL_STATUSES else "unclear"
def _model_display_name(name: str, info: dict | None) -> str:
info = info if isinstance(info, dict) else {}
local_display_name = str(info.get("local_display_name") or "").strip()
if info.get("is_local") and local_display_name:
label = f"{MODEL_LOCAL_PREFIX}{local_display_name}"
storage_label = str(info.get("storage_label") or "").strip()
if storage_label:
label = f"{label} ({storage_label})"
return label
if info.get("is_installed") and not info.get("is_local") and not name.endswith(MODEL_INSTALLED_SUFFIX):
return f"{name}{MODEL_INSTALLED_SUFFIX}"
return name
def model_catalog_label(name: str, info: dict | None) -> str:
display_name = _model_display_name(name, info)
if not COMMERCIAL_RELEASE:
return display_name
return f"{MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]}{display_name}"
def model_catalog_options(models: dict[str, dict], predicate=None) -> list[str]:
entries = [
(name, info)
for name, info in models.items()
if predicate is None or predicate(name, info or {})
]
if COMMERCIAL_RELEASE:
entries.sort(
key=lambda item: (
MODEL_STATUS_PRESENTATION[normalize_commercial_status(item[1])][0],
item[0].casefold(),
),
)
return [model_catalog_label(name, info) for name, info in entries]
def resolve_model_catalog_name(models: dict[str, dict], selected: str) -> str:
if selected in models:
return selected
for name, info in models.items():
prefix = MODEL_STATUS_PRESENTATION[normalize_commercial_status(info)][1]
if selected in {model_catalog_label(name, info), f"{prefix}{name}"}:
return name
undecorated = selected
for _order, prefix in MODEL_STATUS_PRESENTATION.values():
if undecorated.startswith(prefix):
undecorated = undecorated[len(prefix):]
break
if undecorated.endswith(MODEL_INSTALLED_SUFFIX):
legacy_name = undecorated[:-len(MODEL_INSTALLED_SUFFIX)]
if legacy_name in models:
return legacy_name
return selected
def _catalog_filename_candidates(info: dict) -> set[str]:
candidates: set[str] = set()
for key in ("filename", "local_filenames", "local_model_files"):
value = info.get(key)
if isinstance(value, str):
values = [value]
elif isinstance(value, (list, tuple, set)):
values = value
elif isinstance(value, dict):
values = [*value.keys(), *value.values()]
else:
values = []
for candidate in values:
if isinstance(candidate, (list, tuple, set)):
nested = candidate
else:
nested = [candidate]
for item in nested:
filename = Path(str(item)).name.casefold()
if filename:
candidates.add(filename)
return candidates
def mark_catalog_files_installed(models: dict[str, dict], search_dirs) -> None:
"""Mark catalog entries found in any configured directory without changing their keys."""
installed_files: dict[str, Path] = {}
for directory in search_dirs:
directory = Path(directory)
if not directory.exists() or not directory.is_dir():
continue
try:
for path in directory.rglob("*.gguf"):
if path.is_file():
installed_files.setdefault(path.name.casefold(), path)
except (OSError, PermissionError):
continue
for info in models.values():
if not isinstance(info, dict) or info.get("is_local"):
continue
installed_path = next(
(installed_files[name] for name in _catalog_filename_candidates(info) if name in installed_files),
None,
)
if installed_path is None:
info.pop("is_installed", None)
info.pop("installed_path", None)
continue
info["is_installed"] = True
info["installed_path"] = str(installed_path)
def enforce_model_access(info: dict | None, model_name: str, *, local_exists: bool, hf_token: str | None = None) -> None:
status = normalize_commercial_status(info)
if COMMERCIAL_RELEASE:
if status == "cleared":
return
raise PermissionError(f"[QwenVL] Commercial mode rejects model '{model_name}' with status '{status}'.")
if not local_exists and status == "external_gated" and not _clean_hf_token(hf_token):
raise PermissionError(
f"[QwenVL] Cannot download {model_name}: no Hugging Face access token was supplied. "
"Accept the model's access terms on Hugging Face, then add a read token to the hf_token field "
"or set the HF_TOKEN environment variable."
)
if not local_exists and status == "local":
raise FileNotFoundError(f"[QwenVL] Local user-supplied model not found: {model_name}")
def _redact_hf_token(text: str, hf_token: str | None) -> str:
redacted = str(text)
for token in _iter_hf_token_candidates(hf_token):
redacted = redacted.replace(token, "<redacted HF token>")
return redacted
def _hf_download_error_message(exc: Exception, *, repo_id: str, hf_token: str | None) -> str:
message = _redact_hf_token(str(exc), hf_token)
lower = message.lower()
access_markers = (
"401",
"403",
"unauthorized",
"forbidden",
"gated",
"private",
"repository not found",
"requires authentication",
)
if any(marker in lower for marker in access_markers):
if _clean_hf_token(hf_token):
message += (
f"\n[QwenVL] Hugging Face access hint for {repo_id}: a token was supplied but access was rejected. "
"Confirm the token has read permission and that you accepted the model license/gate on Hugging Face."
)
else:
message += (
f"\n[QwenVL] Cannot download {repo_id}: no Hugging Face access token was supplied. "
"Accept the model's access terms on Hugging Face, then add a read token to the hf_token field "
"or set the HF_TOKEN environment variable."
)
return message
def _call_hf_hub_download(*, repo_id: str, filename: str, local_dir: Path, token: str | None, reporter: _DownloadProgressReporter) -> Path:
if hf_hub_download is None:
raise RuntimeError("huggingface_hub.hf_hub_download is unavailable in this environment")
download_kwargs = {
"repo_id": repo_id,
"repo_type": "model",
"filename": filename,
"local_dir": str(local_dir),
}
if token:
download_kwargs["token"] = token
tqdm_cls = reporter.make_tqdm_class()
if tqdm_cls is not None:
try:
if "tqdm_class" in inspect.signature(hf_hub_download).parameters:
download_kwargs["tqdm_class"] = tqdm_cls
except (TypeError, ValueError):
pass
try:
return Path(hf_hub_download(**download_kwargs))
finally:
download_kwargs.pop("token", None)
def _model_snapshot_has_weights(target: Path) -> bool:
return any(target.glob("*.safetensors")) or any(target.glob("*.bin"))
def _cleanup_unneeded_snapshot_weights(target: Path) -> None:
unwanted_markers = ("fp32", "bf16", "fp8", "q2_k", "q3_k", "q4_k", "q5_k", "q6_k", "q8_0", "f32")
for weight_file in target.rglob("*.safetensors"):
name = weight_file.name.lower()
if any(marker in name for marker in unwanted_markers):
print(f"[QwenVL] Removing unneeded quantized weight: {weight_file.name}")
weight_file.unlink(missing_ok=True)
def _model_snapshot_has_required_files(target: Path, require_processor: bool = False) -> bool:
if not target.exists() or not target.is_dir():
return False
if not _model_snapshot_has_weights(target):
return False
if not (target / "config.json").exists():
return False
if require_processor and not any((target / name).exists() for name in _PROCESSOR_CONFIG_FILENAMES):
return False
return True
def _validate_transformers_repo_files(repo_id: str, repo_files: list[str], *, require_processor: bool = False) -> None:
root_files = {Path(str(filename)).as_posix() for filename in repo_files if filename}
missing = []
if "config.json" not in root_files:
missing.append("config.json")
if not any("/" not in filename and filename.endswith((".safetensors", ".bin")) for filename in root_files):
missing.append("Transformers model weights")
if require_processor and not any(filename in root_files for filename in _PROCESSOR_CONFIG_FILENAMES):
missing.append("processor metadata")
if not missing:
return
gguf_hint = " The repository contains GGUF files; select it through a ThinkingLLM GGUF node instead." if any(
filename.lower().endswith(".gguf") for filename in root_files
) else ""
raise ValueError(
f"[QwenVL] {repo_id} is not a compatible Transformers snapshot: missing {', '.join(missing)}.{gguf_hint}"
)
def _download_model_snapshot(repo_id: str, target: Path, *, require_processor: bool = False, node_id=None, progress_label: str | None = None, hf_token: str | None = None) -> None:
if COMMERCIAL_RELEASE:
raise RuntimeError("[QwenVL] Commercial release mode only permits preinstalled, hash-locked models.")
reporter = _DownloadProgressReporter(node_id=node_id, label=progress_label or "QwenVL HF Download", repo_id=repo_id)
token_for_download = _clean_hf_token(hf_token)
download_kwargs = {}
target.parent.mkdir(parents=True, exist_ok=True)
reporter.stage("Checking repository", detail=target.name)
try:
if HfApi is None:
raise RuntimeError("huggingface_hub.HfApi is unavailable; update huggingface-hub before downloading models")
api = HfApi(token=token_for_download) if token_for_download else HfApi()
repo_files = api.list_repo_files(repo_id=repo_id, repo_type="model")
_validate_transformers_repo_files(repo_id, repo_files, require_processor=require_processor)
with tempfile.TemporaryDirectory(prefix=f".{target.name}.download-", dir=target.parent) as temp_dir:
staging_target = Path(temp_dir) / "snapshot"
reporter.stage("Preparing download", detail=target.name)
download_kwargs = {
"repo_id": repo_id,
"local_dir": str(staging_target),
"ignore_patterns": ["*.md", ".git*"],
}
if token_for_download:
download_kwargs["token"] = token_for_download
tqdm_cls = reporter.make_tqdm_class()
if tqdm_cls is not None:
download_kwargs["tqdm_class"] = tqdm_cls
snapshot_download(**download_kwargs)
_cleanup_unneeded_snapshot_weights(staging_target)
if not _model_snapshot_has_required_files(staging_target, require_processor=require_processor):
raise FileNotFoundError(
f"[QwenVL] Downloaded model snapshot is incomplete for {repo_id}; the existing target was preserved"
)
reporter.stage("Installing validated snapshot", detail=target.name)
if target.exists():
if not target.is_dir():
raise FileExistsError(f"[QwenVL] Model target exists and is not a directory: {target}")
shutil.copytree(staging_target, target, dirs_exist_ok=True)
else:
shutil.move(str(staging_target), str(target))
except Exception as exc:
reporter.fail(_hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download))
raise RuntimeError(_hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)) from exc
finally:
download_kwargs.pop("token", None)
token_for_download = None
reporter.finish(detail=target.name)
def download_hf_file_to_path(repo_ids: list[str], filename: str, target_path: Path, *, node_id=None, progress_label: str = "QwenVL GGUF Download", hf_token: str | None = None) -> None:
if target_path.exists():
print(f"[QwenVL] Using cached file: {target_path}")
return
if COMMERCIAL_RELEASE:
raise RuntimeError("[QwenVL] Commercial release mode only permits preinstalled, hash-locked models.")
target_path.parent.mkdir(parents=True, exist_ok=True)
last_exc: Exception | None = None
wanted_name = Path(filename).name
token_for_download = _clean_hf_token(hf_token)
exact_candidates = []
for candidate in (filename, wanted_name):
if candidate and candidate not in exact_candidates:
exact_candidates.append(candidate)
try:
for repo_id in repo_ids:
reporter = _DownloadProgressReporter(node_id=node_id, label=progress_label, repo_id=repo_id)
auth_label = "Preparing authenticated download" if token_for_download else "Preparing download"
reporter.stage(auth_label, detail=wanted_name)
if hf_hub_download is not None:
for candidate in exact_candidates:
try:
reporter.stage("Resolving exact file", detail=candidate, force=False)
downloaded_path = _call_hf_hub_download(
repo_id=repo_id,
filename=candidate,
local_dir=target_path.parent,
token=token_for_download,
reporter=reporter,
)
if downloaded_path.exists() and downloaded_path.resolve() != target_path.resolve():
target_path.parent.mkdir(parents=True, exist_ok=True)
downloaded_path.replace(target_path)
if target_path.exists():
reporter.finish(detail=wanted_name)
return
except Exception as exc:
hint = _hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)
last_exc = RuntimeError(hint)
print(f"[QwenVL] exact file download failed from {repo_id}/{candidate}: {hint}")
reporter.stage("Falling back to snapshot scan", detail=wanted_name, force=False)
download_kwargs = {}
try:
download_kwargs = {
"repo_id": repo_id,
"repo_type": "model",
"local_dir": str(target_path.parent),
"allow_patterns": [filename, wanted_name, f"**/{wanted_name}"],
}
if token_for_download:
download_kwargs["token"] = token_for_download
tqdm_cls = reporter.make_tqdm_class()
if tqdm_cls is not None:
download_kwargs["tqdm_class"] = tqdm_cls
snapshot_download(**download_kwargs)
found = list(target_path.parent.rglob(wanted_name))
if found:
downloaded_path = found[0]
if downloaded_path.exists() and downloaded_path.resolve() != target_path.resolve():
target_path.parent.mkdir(parents=True, exist_ok=True)
downloaded_path.replace(target_path)
if target_path.exists():
reporter.finish(detail=wanted_name)
return
reporter.fail(f"{wanted_name} was not found after snapshot download")
except Exception as exc:
hint = _hf_download_error_message(exc, repo_id=repo_id, hf_token=token_for_download)
last_exc = RuntimeError(hint)
reporter.fail(hint)
print(f"[QwenVL] snapshot_download failed from {repo_id}: {hint}")
finally:
download_kwargs.pop("token", None)
finally:
token_for_download = None
raise FileNotFoundError(f"[QwenVL] Download failed for {wanted_name}: {last_exc}")
+386
View File
@@ -0,0 +1,386 @@
"""Prompt budgeting plus duration-aware video prompt contracts."""
import math
import re
QWEN_MIN_REASONING_BUDGET_TOKENS = 1024
QWEN_FINAL_ANSWER_RESERVE_TOKENS = 256
QWEN_CONTEXT_OVERHEAD_TOKENS = 128
_QWEN_SOFT_SWITCHES = {"/think", "/no_think", "/nothink"}
MINIMAX_H3_REFERENCE_PRESET = "🖼️ MiniMax H3 Reference-to-Video"
VIDEO_PROMPT_CONTRACT_VERSION = 10
_MINIMAX_BASE_FIELDS = (
"integrated_multimodal_description",
"overall_soundscape",
"non_diegetic_music",
)
_MINIMAX_REFERENCE_FIELDS = (
"subject_definitions",
"summary",
"retention_analysis",
"detailed_description",
"overall_soundscape",
"non_diegetic_music",
)
VIDEO_DURATION_INPUT = (
"FLOAT",
{
"default": 5.0,
"min": 0.2,
"max": 150.0,
"step": 0.1,
"tooltip": (
"Target video duration in seconds. It is used only by registered LTX 2.3 and MiniMax H3 "
"video presets. Connect the same requested duration to the video generator; MiniMax values "
"are normalized to its 17k+5 frame grid at 24 fps. For longer MiniMax scripts, ThinkingLLM "
"selects a coherent moment that fits while keeping any selected dialogue verbatim."
),
},
)
VIDEO_PRESET_METADATA: dict[str, dict] = {}
def estimate_qwen_text_tokens(*parts):
"""Conservatively estimate prompt tokens when an exact tokenizer is unavailable."""
total_chars = 0
non_empty_parts = 0
for part in parts:
if not part:
continue
normalized = " ".join(str(part).split())
if not normalized:
continue
total_chars += len(normalized)
non_empty_parts += 1
if total_chars <= 0:
return 0
return max(1, (total_chars + 3) // 4 + max(0, non_empty_parts - 1))
def resolve_qwen_context_window(config, fallback=32768):
"""Return the best available HF context window from the model config."""
candidates = []
for cfg in (config, getattr(config, "text_config", None)):
if cfg is None:
continue
for attr in ("max_position_embeddings", "n_positions", "seq_length"):
value = getattr(cfg, attr, None)
if isinstance(value, int) and 0 < value <= 2_000_000:
candidates.append(value)
if candidates:
return max(candidates)
return int(fallback)
def resolve_qwen_thinking_mode(
requested_enable,
max_tokens,
label="QwenVL",
*,
prompt_tokens=None,
context_window=None,
min_reasoning_tokens=QWEN_MIN_REASONING_BUDGET_TOKENS,
answer_reserve_tokens=QWEN_FINAL_ANSWER_RESERVE_TOKENS,
context_overhead_tokens=QWEN_CONTEXT_OVERHEAD_TOKENS,
quiet=False,
):
"""Enable thinking only when requested and enough generation budget remains."""
if not bool(requested_enable):
return False
requested_output = max(0, int(max_tokens or 0))
available_output = requested_output
prompt_token_count = max(0, int(prompt_tokens or 0))
context_limit = int(context_window) if context_window is not None else None
if context_limit is not None:
remaining_context = max(0, context_limit - prompt_token_count - int(context_overhead_tokens))
available_output = min(available_output, remaining_context) if available_output else remaining_context
minimum_required = int(min_reasoning_tokens) + int(answer_reserve_tokens)
if available_output <= 0 or available_output < minimum_required:
if context_limit is not None:
budget_note = (
f"max_tokens={requested_output}, prompt_tokens={prompt_token_count}, "
f"context_window={context_limit}, available_output={available_output}"
)
else:
budget_note = f"max_tokens={requested_output}, available_output={available_output}"
if not quiet:
print(
f"[{label}] Thinking requested but {budget_note} is below the required {minimum_required} tokens "
f"({int(min_reasoning_tokens)} reasoning + {int(answer_reserve_tokens)} answer reserve); "
"forcing non-thinking mode to preserve answer space."
)
return False
return True
def apply_qwen_soft_thinking_directive(prompt_text, enable_thinking, supports_soft_switch=False):
"""Append the latest Qwen3 soft switch when the backend supports it."""
text = prompt_text or ""
if not supports_soft_switch:
return text
lines = [line for line in text.splitlines() if line.strip().lower() not in _QWEN_SOFT_SWITCHES]
lines.append("/think" if enable_thinking else "/no_think")
return "\n".join(lines).strip()
def validate_minimax_reference_request(preset_prompt, custom_prompt):
"""Require an explicit target and reference role for MiniMax H3 R2V prompting."""
if preset_prompt == MINIMAX_H3_REFERENCE_PRESET and not (custom_prompt or "").strip():
raise ValueError(
"[QwenVL] MiniMax H3 Reference-to-Video requires a custom prompt describing the target video "
"and how each reference should be used. To animate a single image as the first frame, use "
"MiniMax H3 Text-to-Video instead."
)
def get_video_preset_metadata(preset_name):
"""Return duration metadata only for a registered duration-aware video preset."""
metadata = VIDEO_PRESET_METADATA.get(preset_name)
if not isinstance(metadata, dict) or not metadata.get("duration_required"):
return None
return metadata
def resolve_video_duration(preset_name, duration_seconds=5.0):
"""Resolve an optional node duration to the provider-native effective duration."""
metadata = get_video_preset_metadata(preset_name)
if metadata is None:
return None
try:
requested_seconds = float(5.0 if duration_seconds is None else duration_seconds)
except (TypeError, ValueError) as exc:
raise ValueError("[QwenVL] duration_seconds must be a finite number between 0.2 and 150.0.") from exc
if not math.isfinite(requested_seconds) or not 0.2 <= requested_seconds <= 150.0:
raise ValueError("[QwenVL] duration_seconds must be a finite number between 0.2 and 150.0.")
provider = metadata.get("provider")
frames = None
effective_seconds = requested_seconds
if provider == "minimax_h3":
frames = max(5, round(requested_seconds * 24))
frames += (5 - (frames % 17)) % 17
effective_seconds = frames / 24
duration_text = f"{effective_seconds:.2f}"
elif provider == "ltx_2_3":
duration_text = f"{effective_seconds:.2f}".rstrip("0").rstrip(".")
else:
return None
return {
"provider": provider,
"video_mode": metadata.get("video_mode"),
"requested_seconds": requested_seconds,
"effective_seconds": effective_seconds,
"duration_text": duration_text,
"frames": frames,
"contract_version": VIDEO_PROMPT_CONTRACT_VERSION if provider == "minimax_h3" else None,
}
def apply_video_duration_context(prompt_text, preset_name, duration_seconds=5.0):
"""Append authoritative duration context for the LLM without changing non-video presets."""
resolved = resolve_video_duration(preset_name, duration_seconds)
if resolved is None:
return prompt_text
duration_text = resolved["duration_text"]
provider_instruction = (
f'Express the total duration naturally as a "{duration_text}-second continuous shot" in the single paragraph.'
if resolved["provider"] == "ltx_2_3"
else (
"Use this duration only to plan feasible actions, bound every cut timestamp, and fill any "
"official FL2VA/L2VA alignment line. Do not print TARGET_DURATION_SECONDS or a separate "
"target-duration declaration in the final MiniMax prompt."
)
)
duration_context = (
f"TARGET_DURATION_SECONDS: {duration_text}\n"
"This node value is authoritative and overrides any conflicting duration in the user text. "
+ ("Mention the total target duration exactly once in the final prompt. " if resolved["provider"] == "ltx_2_3" else "")
+ provider_instruction
)
return "\n\n".join(part for part in ((prompt_text or "").strip(), duration_context) if part)
def _normalize_minimax_dialogue_text(text):
return " ".join((text or "").split())
_MINIMAX_LANGUAGE_LABELS = (
"English", "German", "French", "Spanish", "Italian", "Portuguese", "Dutch", "Polish",
"Russian", "Ukrainian", "Czech", "Slovak", "Swedish", "Norwegian", "Danish", "Finnish",
"Greek", "Turkish", "Arabic", "Hebrew", "Hindi", "Bengali", "Urdu", "Persian", "Thai",
"Vietnamese", "Indonesian", "Malay", "Chinese", "Mandarin", "Cantonese", "Japanese", "Korean",
)
def _normalize_minimax_dialogue_tags(text):
"""Normalize safe language-tag variants to official <d>[Language] ...</d> syntax."""
language_pattern = "|".join(re.escape(language) for language in _MINIMAX_LANGUAGE_LABELS)
def _normalize_tag(match):
language = match.group(1).strip()
payload = match.group(2).strip()
return f"<d>[{language}] {payload}</d>"
normalized = re.sub(
r"(?is)<d>\s*\[\s*([^\]\r\n]+?)\s*\]\s*(.+?)\s*</d>",
_normalize_tag,
text,
)
return re.sub(
rf"(?is)<d>\s*({language_pattern})\s*:?[ \t\r\n]+(.+?)\s*</d>",
_normalize_tag,
normalized,
)
def extract_minimax_source_dialogue(source_prompt_text):
"""Extract explicitly quoted, standalone dialogue blocks from a text request."""
source = (source_prompt_text or "").strip()
if not source:
return []
dialogue = []
tagged = re.findall(r"<d>\[[^\]\r\n]+\]\s*(.+?)</d>", source, flags=re.DOTALL | re.IGNORECASE)
dialogue.extend(_normalize_minimax_dialogue_text(item) for item in tagged if item.strip())
quote_pairs = (("“", "”"), ("„", "“"), ("«", "»"), ('"', '"'))
for paragraph in re.split(r"\r?\n[ \t]*\r?\n", source):
candidate = paragraph.strip()
if not candidate or candidate.lower().startswith("<d>"):
continue
for opening, closing in quote_pairs:
if len(candidate) >= 2 and candidate.startswith(opening) and candidate.endswith(closing):
payload = _normalize_minimax_dialogue_text(candidate[len(opening):-len(closing)])
if payload:
dialogue.append(payload)
break
unique = []
for item in dialogue:
if item not in unique:
unique.append(item)
return unique
def validate_minimax_source_duration(preset_name, duration_seconds=5.0, source_prompt_text=None):
"""Return source dialogue for MiniMax presets without rejecting longer master scripts."""
resolved = resolve_video_duration(preset_name, duration_seconds)
if resolved is None or resolved["provider"] != "minimax_h3":
return []
return extract_minimax_source_dialogue(source_prompt_text)
def _normalize_minimax_section_boundaries(text, expected_fields):
"""Put uniquely identifiable official fields on their own lines without rewriting content."""
found = []
for field in expected_fields:
matches = list(re.finditer(rf"(?i)(?<![A-Za-z0-9_]){re.escape(field)}[ \t]*:", text))
if len(matches) != 1:
return text
found.append((field, matches[0]))
if [match.start() for _, match in found] != sorted(match.start() for _, match in found):
return text
normalized = text
for _, match in reversed(found[1:]):
start = match.start()
normalized = normalized[:start].rstrip() + "\n\n" + normalized[start:].lstrip()
return normalized
def _normalize_minimax_shot_syntax(description):
"""Normalize only unambiguous MiniMax shot syntax without validating the timeline."""
timestamp_pattern = re.compile(
r"(\[Shot\s+(\d+)\]\s+At\s+)(\d{1,2}):(\d{1,2})\.(\d{1,3})\s*,?",
flags=re.IGNORECASE,
)
def _normalize_timestamp(match):
minutes = int(match.group(3))
seconds = int(match.group(4))
millis = int(match.group(5).ljust(3, "0"))
if seconds >= 60:
return match.group(0)
return f"[Shot {int(match.group(2))}] At {minutes:02d}:{seconds:02d}.{millis:03d},"
normalized = timestamp_pattern.sub(_normalize_timestamp, description)
normalized = re.sub(
r"(?i)\[Shot\s+1\]\s+At\s+00:00\.000,?[ \t]*",
"[Shot 1] ",
normalized,
count=1,
)
return normalized
def normalize_minimax_prompt_best_effort(prompt_text, preset_name, duration_seconds=5.0):
"""Apply safe MiniMax formatting fixes without rejecting or repairing model output."""
resolved = resolve_video_duration(preset_name, duration_seconds)
if resolved is None or resolved["provider"] != "minimax_h3":
return prompt_text
original = (prompt_text or "").strip()
if not original:
return original
try:
text = re.sub(
r"(?i)\bTarget\s+duration\s*:\s*\d+(?:[.,]\d+)?\s*seconds?\.?[ \t]*",
"",
original,
).strip()
text = _normalize_minimax_dialogue_tags(text)
expected_fields = (
_MINIMAX_REFERENCE_FIELDS
if resolved["video_mode"] == "ref2va"
else _MINIMAX_BASE_FIELDS
)
text = _normalize_minimax_section_boundaries(text, expected_fields)
text = _normalize_minimax_shot_syntax(text)
return text.strip() or original
except Exception:
# Formatting assistance must never become a second schema gate in front of H3.
return original
def ensure_video_prompt_duration(prompt_text, preset_name, duration_seconds=5.0, source_prompt_text=None):
"""Apply provider-native duration guidance and safe output normalization."""
resolved = resolve_video_duration(preset_name, duration_seconds)
if resolved is None:
return prompt_text
text = (prompt_text or "").strip()
duration_text = resolved["duration_text"]
if resolved["provider"] == "ltx_2_3":
text = " ".join(text.split())
marker = f"{duration_text}-second continuous shot"
number_pattern = re.escape(duration_text).replace(r"\.", r"[.,]")
marker_pattern = re.compile(
rf"\b{number_pattern}(?:\.0+)?[- ]second continuous shot\b",
flags=re.IGNORECASE,
)
seen = False
def _canonicalize_ltx_marker(_match):
nonlocal seen
if seen:
return "continuous shot"
seen = True
return marker
text = marker_pattern.sub(_canonicalize_ltx_marker, text)
if not seen:
text = f"A {marker}. {text}".strip()
return " ".join(text.split())
if resolved["provider"] == "minimax_h3":
return normalize_minimax_prompt_best_effort(
text,
preset_name,
duration_seconds,
)
return text
+256
View File
@@ -0,0 +1,256 @@
"""Persistent prompt cache and per-node workflow state shared by all backends."""
import hashlib
import json
import sys
from pathlib import Path
PLUGIN_ROOT = Path(__file__).resolve().parent.parent
PROMPT_CACHE: dict = {}
CACHE_FILE = PLUGIN_ROOT / "prompt_cache.json"
NODE_PROMPT_STATE: dict = {}
NODE_STATE_FILE = PLUGIN_ROOT / "node_prompt_state.json"
def _build_workflow_fingerprint(extra_pnginfo):
"""Return a short stable fingerprint for a workflow dict so node state
can be scoped to a particular workflow window, avoiding cross-workflow
leakage while remaining stable across save/reload cycles."""
if not isinstance(extra_pnginfo, dict):
return None
workflow = extra_pnginfo.get("workflow")
if not isinstance(workflow, dict):
return None
try:
last_id = workflow.get("last_node_id", 0)
node_ids = sorted(
str(n.get("id", "")) for n in workflow.get("nodes", []) if isinstance(n, dict)
)
seed = f"{last_id}|{','.join(node_ids[:50])}"
return hashlib.md5(seed.encode()).hexdigest()[:12]
except Exception:
return None
def _make_node_state_key(node_class, unique_id, extra_pnginfo):
"""Produce a stable per-node key from the node class name, its numeric
identity within the workflow, and the workflow fingerprint when available."""
wf_fp = _build_workflow_fingerprint(extra_pnginfo)
if wf_fp:
return f"{node_class}|{wf_fp}|{unique_id}"
return f"{node_class}|{unique_id}"
def _normalize_state_signature_value(value):
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, (list, tuple)):
return [_normalize_state_signature_value(item) for item in value]
if isinstance(value, dict):
return {
str(key): _normalize_state_signature_value(val)
for key, val in sorted(value.items(), key=lambda item: str(item[0]))
}
return str(value)
def build_node_input_signature(**kwargs):
"""Return a stable hash for the node inputs that should invalidate saved prompt reuse."""
normalized = {
str(key): _normalize_state_signature_value(value)
for key, value in sorted(kwargs.items(), key=lambda item: str(item[0]))
}
payload = json.dumps(normalized, sort_keys=True, ensure_ascii=True, separators=(",", ":"))
return hashlib.md5(payload.encode("utf-8")).hexdigest()
def load_node_prompt_state():
"""Load per-node prompt state from the sidecar JSON file."""
try:
if NODE_STATE_FILE.exists():
with open(NODE_STATE_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("node prompt state must be a JSON object")
NODE_PROMPT_STATE.clear()
NODE_PROMPT_STATE.update(loaded)
print(f"[QwenVL] Loaded {len(NODE_PROMPT_STATE)} node prompt states")
except Exception as e:
print(f"[QwenVL] Failed to load node prompt state: {e}")
NODE_PROMPT_STATE.clear()
def save_node_prompt_state():
"""Persist per-node prompt state to the sidecar JSON file."""
try:
with open(NODE_STATE_FILE, "w", encoding="utf-8") as f:
json.dump(NODE_PROMPT_STATE, f, indent=2)
except Exception as e:
print(f"[QwenVL] Failed to save node prompt state: {e}")
def get_node_saved_prompt(node_class, unique_id, extra_pnginfo):
"""Return the saved prompt text for a specific node, or None."""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
entry = NODE_PROMPT_STATE.get(key)
if isinstance(entry, dict) and entry.get("text"):
return entry["text"]
return None
def get_node_saved_prompt_with_seed(node_class, unique_id, extra_pnginfo, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, input_signature=None):
"""Return saved prompt text only if the generation parameters haven't changed."""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
entry = NODE_PROMPT_STATE.get(key)
if not isinstance(entry, dict) or not entry.get("text"):
return None
# If seed or params changed, invalidate and return None
if seed is not None and entry.get("seed") != seed:
return None
if max_tokens is not None and entry.get("max_tokens") != max_tokens:
return None
if temperature is not None and entry.get("temperature") != temperature:
return None
if top_p is not None and entry.get("top_p") != top_p:
return None
if repetition_penalty is not None and entry.get("repetition_penalty") != repetition_penalty:
return None
if input_signature is not None and entry.get("input_signature") != input_signature:
return None
return entry["text"]
def set_node_saved_prompt(node_class, unique_id, extra_pnginfo, text, raw_trace=None, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, input_signature=None):
"""Persist the generated prompt (and optional raw trace) for a specific node."""
key = _make_node_state_key(node_class, unique_id, extra_pnginfo)
NODE_PROMPT_STATE[key] = {
"text": text,
"raw_trace": raw_trace or "",
"timestamp": None,
"seed": seed,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"repetition_penalty": repetition_penalty,
"input_signature": input_signature,
}
save_node_prompt_state()
def load_prompt_cache():
"""Load prompt cache from file"""
try:
if CACHE_FILE.exists():
with open(CACHE_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
if not isinstance(loaded, dict):
raise ValueError("prompt cache must be a JSON object")
PROMPT_CACHE.clear()
PROMPT_CACHE.update(loaded)
print(f"[QwenVL] Loaded {len(PROMPT_CACHE)} cached prompts")
except Exception as e:
print(f"[QwenVL] Failed to load prompt cache: {e}")
PROMPT_CACHE.clear()
def save_prompt_cache():
"""Save prompt cache to file"""
try:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(PROMPT_CACHE, f, indent=2)
except Exception as e:
print(f"[QwenVL] Failed to save prompt cache: {e}")
def log_llm_input(
backend: str,
stage: str,
preset_name: str,
user_text: str,
*,
system_text: str = "",
formatted_text: str | None = None,
media: dict[str, int] | None = None,
) -> None:
"""Print the complete text input sent to an LLM without dumping media payloads."""
lines = [
f"\n[{backend}] LLM INPUT",
f"Stage: {stage}",
f"Preset: {preset_name or '(none)'}",
"Media: " + (", ".join(f"{kind}={count}" for kind, count in (media or {}).items()) or "none"),
"--- SYSTEM ---",
system_text or "(none)",
"--- USER ---",
user_text or "(empty)",
]
if formatted_text is not None:
lines.extend(("--- FORMATTED CHAT TEMPLATE ---", formatted_text))
lines.append("--- END LLM INPUT ---")
message = "\n".join(lines)
try:
print(message)
except UnicodeEncodeError:
encoding = getattr(sys.stdout, "encoding", None) or "ascii"
print(message.encode(encoding, errors="replace").decode(encoding))
def get_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, video_hash=None, seed=None, max_tokens=None, temperature=None, top_p=None, repetition_penalty=None, enable_thinking=None, effective_duration_seconds=None, video_prompt_contract_version=None):
"""Generate cache key from inputs including all generation parameters.
All generation-parameter fields are optional so existing callers that omit them
continue to work; when provided, changing any parameter produces a different key.
"""
key_data = {
"model": model_name,
"preset": preset_prompt,
"custom": custom_prompt.strip() if custom_prompt else "",
"image": image_hash,
"video": video_hash,
"seed": seed, # Always include seed to ensure proper caching behavior
"max_tokens": max_tokens,
"temperature": temperature if temperature is not None else None,
"top_p": top_p if top_p is not None else None,
"repetition_penalty": repetition_penalty if repetition_penalty is not None else None,
"enable_thinking": bool(enable_thinking) if enable_thinking is not None else None,
}
if effective_duration_seconds is not None:
key_data["effective_duration_seconds"] = effective_duration_seconds
if video_prompt_contract_version is not None:
key_data["video_prompt_contract_version"] = video_prompt_contract_version
# Create deterministic hash
key_str = json.dumps(key_data, sort_keys=True)
return hashlib.md5(key_str.encode()).hexdigest()
def get_alternative_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, video_hash=None, seed=None, module_name="QwenVL"):
"""Generate alternative cache key for fixed seed mode to find random prompts"""
# Only for fixed seed mode (when user wants consistent prompts)
# We consider any seed that the user keeps fixed as "fixed seed mode"
print(f"[{module_name} DEBUG] Searching through cache for model={model_name}, preset={preset_prompt}")
# Try to find any cached prompt with same model/preset/custom/image but different seed
for cached_key, cached_data in PROMPT_CACHE.items():
cached_model = cached_data.get("model")
cached_preset = cached_data.get("preset")
cached_seed = cached_data.get("seed")
print(f"[{module_name} DEBUG] Checking entry: model={cached_model}, preset={cached_preset}, seed={cached_seed}")
if (cached_model == model_name and
cached_preset == preset_prompt and
cached_seed != seed): # Different seed
# Generate the cache key that would have been created for this cached data
# to check if image/video hashes match
cached_image_hash = cached_data.get("image_hash")
cached_video_hash = cached_data.get("video_hash")
print(f"[{module_name} DEBUG] Found potential match with hashes: image={cached_image_hash}, video={cached_video_hash}")
# If the cached data doesn't have hash info, try to match by other criteria
if cached_image_hash is None and cached_video_hash is None:
# Fallback: if both current and cached have no image/video, consider it a match
if image_hash is None and video_hash is None:
print(f"[{module_name} DEBUG] Match found (no images/videos)!")
return cached_key
else:
# Match if hashes are the same (including None)
if cached_image_hash == image_hash and cached_video_hash == video_hash:
print(f"[{module_name} DEBUG] Match found (hashes match)!")
return cached_key
print(f"[{module_name} DEBUG] No alternative cache found")
return None
+177
View File
@@ -0,0 +1,177 @@
"""Validate the ThinkingLLM API model catalogs.
Checks that web/api_model_catalogs.json stays well-formed, that every
cloud profile shipped by the API node has a curated entry, and that model
IDs are safe to send in a request body (no control characters, no
leading/trailing whitespace, no duplicates).
Run in CI on every PR, push to main, and before every release:
python tools/check_api_model_catalogs.py
Exit code 0 on success, 1 on any validation failure.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "web" / "api_model_catalogs.json"
# Cloud profiles exposed by the API node (nodes/openai_compatible_api.py).
# Local endpoints (Ollama, vLLM, llama.cpp) are intentionally excluded
# because their model IDs are runtime/environment specific.
REQUIRED_PROFILE_KEYS = {
"OpenAI": "OpenAI",
"QwenCloud (Singapore)": "QwenCloud",
"OpenRouter": "OpenRouter",
"Together AI": "Together AI",
"Fireworks AI": "Fireworks AI",
"DeepInfra": "DeepInfra",
"Groq": "Groq",
"Featherless": "Featherless",
"OrcaRouter": "OrcaRouter",
}
# Reasonable bound to catch accidental mass-paste or generated blobs.
# Public providers ship full lists (OpenRouter 413, Featherless up to 500).
MAX_MODELS_PER_PROFILE = 600
MAX_MODEL_ID_CHARS = 512
MAX_MODEL_LABEL_CHARS = 128
MAX_NOTE_CHARS = 512
_MODEL_ID_BLOCKLIST = re.compile(
r"[\x00-\x1f\x7f-\x9f]"
)
_ERRORS: list[str] = []
def _error(message: str) -> None:
_ERRORS.append(message)
def _require(condition: bool, message: str) -> None:
if not condition:
_error(message)
def _validate_model_entry(profile_name: str, index: int, entry: object) -> None:
if not isinstance(entry, dict):
_error(f"profile {profile_name!r}: models[{index}] must be an object.")
return
model_id = entry.get("id")
if not isinstance(model_id, str) or not model_id.strip():
_error(f"profile {profile_name!r}: models[{index}].id must be a non-empty string.")
else:
model_id = model_id.strip()
if model_id != entry["id"]:
_error(f"profile {profile_name!r}: models[{index}].id has leading/trailing whitespace.")
if len(model_id) > MAX_MODEL_ID_CHARS:
_error(f"profile {profile_name!r}: models[{index}].id exceeds {MAX_MODEL_ID_CHARS} characters.")
if _MODEL_ID_BLOCKLIST.search(model_id):
_error(f"profile {profile_name!r}: models[{index}].id contains control characters.")
label = entry.get("label")
if not isinstance(label, str) or not label.strip():
_error(f"profile {profile_name!r}: models[{index}].label must be a non-empty string.")
elif len(label) > MAX_MODEL_LABEL_CHARS:
_error(f"profile {profile_name!r}: models[{index}].label exceeds {MAX_MODEL_LABEL_CHARS} characters.")
if "vision" in entry and not isinstance(entry["vision"], bool):
_error(f"profile {profile_name!r}: models[{index}].vision must be a boolean.")
if "notes" in entry:
if not isinstance(entry["notes"], str):
_error(f"profile {profile_name!r}: models[{index}].notes must be a string.")
elif len(entry["notes"]) > MAX_NOTE_CHARS:
_error(f"profile {profile_name!r}: models[{index}].notes exceeds {MAX_NOTE_CHARS} characters.")
def _validate_profile(profile_name: str, raw: object) -> None:
if not isinstance(raw, dict):
_error(f"profile {profile_name!r} must be an object.")
return
provider = raw.get("provider")
_require(
isinstance(provider, str) and provider.strip(),
f"profile {profile_name!r}: missing non-empty 'provider'.",
)
models = raw.get("models")
if not isinstance(models, list):
_error(f"profile {profile_name!r}: 'models' must be a list.")
return
if len(models) > MAX_MODELS_PER_PROFILE:
_error(f"profile {profile_name!r}: more than {MAX_MODELS_PER_PROFILE} models; is this intentional?")
seen_ids: set[str] = set()
for index, entry in enumerate(models):
_validate_model_entry(profile_name, index, entry)
if isinstance(entry, dict) and isinstance(entry.get("id"), str):
model_id = entry["id"].strip()
if model_id in seen_ids:
_error(f"profile {profile_name!r}: duplicate model id {model_id!r}.")
seen_ids.add(model_id)
def main() -> int:
try:
with CATALOG_PATH.open("r", encoding="utf-8") as handle:
payload = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
print(f"ERROR: cannot read {CATALOG_PATH}: {exc}", file=sys.stderr)
return 1
if not isinstance(payload, dict):
print(f"ERROR: {CATALOG_PATH} must contain a JSON object.", file=sys.stderr)
return 1
version = payload.get("version")
if version != 1:
_error(f"catalog 'version' must be 1, got {version!r}.")
updated_at = payload.get("updated_at")
if not isinstance(updated_at, str) or not updated_at.strip():
_error("catalog is missing 'updated_at'.")
profiles = payload.get("profiles")
if not isinstance(profiles, dict):
_error("catalog is missing 'profiles' object.")
profiles = {}
for required_key, expected_provider in REQUIRED_PROFILE_KEYS.items():
if required_key not in profiles:
_error(f"missing required profile {required_key!r} for provider {expected_provider!r}.")
continue
raw = profiles[required_key]
_validate_profile(required_key, raw)
if isinstance(raw, dict):
actual_provider = (raw.get("provider") or "").strip()
if actual_provider != expected_provider:
_error(
f"profile {required_key!r}: provider {actual_provider!r} "
f"does not match expected {expected_provider!r}.",
)
for profile_name, raw in profiles.items():
if profile_name not in REQUIRED_PROFILE_KEYS:
_validate_profile(profile_name, raw)
if _ERRORS:
print(f"ERROR: {CATALOG_PATH} failed validation:", file=sys.stderr)
for message in _ERRORS:
print(f" - {message}", file=sys.stderr)
return 1
model_count = sum(
len(p.get("models") or []) for p in profiles.values() if isinstance(p, dict)
)
print(f"OK: {CATALOG_PATH} valid ({len(profiles)} profiles, {model_count} models).")
return 0
if __name__ == "__main__":
sys.exit(main())
+188
View File
@@ -0,0 +1,188 @@
"""Fetch live provider model lists and regenerate web/api_model_catalogs.json.
For providers that expose a public (key-less) OpenAI-compatible /models
endpoint we fetch the full list and refresh the catalog automatically. For
providers that require an API key (OpenAI, QwenCloud, Groq, Together AI,
Fireworks) the curated entries in web/api_model_catalogs.json are kept as-is.
Usage:
python tools/update_api_model_catalogs.py # fetch public providers
python tools/update_api_model_catalogs.py --offline # keep current data
python tools/update_api_model_catalogs.py --dry-run # print what would change
After running, run the validator:
python tools/check_api_model_catalogs.py
Run from the repository root. This is intended to run in CI at release time
so the shipped lists stay current.
"""
from __future__ import annotations
import argparse
import json
import sys
import urllib.error
import urllib.request
from datetime import date
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "web" / "api_model_catalogs.json"
# Providers with a public, key-less /models endpoint.
PUBLIC_PROVIDERS: dict[str, str] = {
"OpenRouter": "https://openrouter.ai/api/v1/models",
"OrcaRouter": "https://api.orcarouter.ai/v1/models",
"DeepInfra": "https://api.deepinfra.com/v1/openai/models",
"Featherless": "https://api.featherless.ai/v1/models",
}
# Providers that require a key and are therefore hand-curated.
CURATED_PROVIDERS = ["OpenAI", "QwenCloud (Singapore)", "Together AI", "Fireworks AI", "Groq"]
# Maximum number of entries kept per provider (to keep the node dropdown usable).
MAX_MODELS_PER_PROVIDER = 500
def _fetch_json(url: str, timeout: int = 30) -> dict | list:
req = urllib.request.Request(url, headers={"User-Agent": "curl/8"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.load(resp)
def _extract_models_openrouter(payload: dict) -> list[str]:
items = payload.get("data") or []
# Sort by context_length descending so the most capable models come first.
items.sort(key=lambda m: m.get("context_length") or 0, reverse=True)
return [m["id"] for m in items if isinstance(m.get("id"), str)]
def _extract_models_openai_style(payload: dict | list) -> list[str]:
if isinstance(payload, list):
items = payload
else:
items = payload.get("data") or payload.get("models") or []
ids = [m.get("id") for m in items if isinstance(m, dict)]
return [i for i in ids if isinstance(i, str)]
def _filter_featherless(ids: list[str]) -> list[str]:
"""Featherless exposes thousands of raw HF names; keep the usable chat/vision ones."""
keywords = ("instruct", "chat", "vision", "vl", "turbo", "-it", "1.5", "3.5")
blocked = ("gensyn-swarm", "-gensyn-", "swarm-")
out = [
i for i in ids
if any(k.lower() in i.lower() for k in keywords)
and not any(b.lower() in i.lower() for b in blocked)
]
# De-dup case-insensitively, keep order.
seen: set[str] = set()
result: list[str] = []
for model_id in out:
key = model_id.lower()
if key not in seen:
seen.add(key)
result.append(model_id)
return result
def _sort_models_for_dropdown(ids: list[str], *, by_context: bool = False,
context_map: dict[str, int] | None = None) -> list[str]:
"""Sort for the dropdown: real models first, special router models last.
When by_context is set (OpenRouter), order real models by context length
descending so the most capable models appear first.
"""
def sort_key(model_id: str):
lowered = model_id.lower()
is_special = lowered.startswith("orcarouter/") or lowered in {"openrouter/auto", "openrouter/auto-beta"}
ctx = -(context_map.get(model_id, 0) or 0) if by_context else 0
return (1 if is_special else 0, ctx, lowered)
return sorted(ids, key=sort_key)
def _fetch_public_catalog() -> dict[str, dict]:
catalog: dict[str, dict] = {}
for provider, url in PUBLIC_PROVIDERS.items():
try:
payload = _fetch_json(url)
except Exception as exc: # noqa: BLE001 - report and continue
print(f"[update] WARNING: could not fetch {provider}: {exc}", file=sys.stderr)
continue
if provider == "OpenRouter":
ids = _extract_models_openrouter(payload)
context_map = {
m["id"]: (m.get("context_length") or 0)
for m in (payload.get("data") or []) if isinstance(m, dict) and isinstance(m.get("id"), str)
}
elif provider == "Featherless":
ids = _filter_featherless(_extract_models_openai_style(payload))
context_map = {}
else:
ids = _extract_models_openai_style(payload)
context_map = {}
if not ids:
print(f"[update] WARNING: {provider} returned no models", file=sys.stderr)
continue
ids = _sort_models_for_dropdown(ids, by_context=(provider == "OpenRouter"), context_map=context_map)
models = [
{"id": model_id, "label": model_id, "vision": False}
for model_id in ids[:MAX_MODELS_PER_PROVIDER]
]
catalog[provider] = {"provider": provider, "models": models}
print(f"[update] {provider}: {len(ids)} models (kept {len(models)}).")
return catalog
def _merge_catalogs(previous: dict, fresh: dict) -> dict:
"""Replace public providers with fresh data, keep curated providers as-is."""
profiles = dict(previous.get("profiles", {}))
for provider, entry in fresh.items():
profiles[provider] = entry
return profiles
def main() -> int:
parser = argparse.ArgumentParser(description="Update the API model catalogs.")
parser.add_argument("--offline", action="store_true", help="Do not fetch; just rewrite metadata.")
parser.add_argument("--dry-run", action="store_true", help="Print changes without writing.")
args = parser.parse_args()
if not CATALOG_PATH.exists():
print(f"ERROR: {CATALOG_PATH} not found.", file=sys.stderr)
return 1
with CATALOG_PATH.open("r", encoding="utf-8") as handle:
previous = json.load(handle)
fresh = {} if args.offline else _fetch_public_catalog()
profiles = _merge_catalogs(previous, fresh)
now = date.today().isoformat()
result = {
"version": 1,
"description": previous.get(
"description",
"Curated convenience model lists for the ThinkingLLM API node. "
"These are suggestions only and never override a server-side profile allowed_models allowlist.",
),
"updated_at": now,
"profiles": profiles,
}
if args.dry_run:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
with CATALOG_PATH.open("w", encoding="utf-8") as handle:
json.dump(result, handle, indent=2, ensure_ascii=False)
handle.write("\n")
print(f"[update] Wrote {CATALOG_PATH} (updated_at={now}).")
return 0
if __name__ == "__main__":
sys.exit(main())
+100
View File
@@ -1,5 +1,105 @@
# ComfyUI-QwenVL Update Log
## Version 2.5.8 (2026/09/27) - ROCm SageAttention 1 Support
### 2.5.8 Changes
- Added a SageAttention 1.x Triton path for Hugging Face Qwen nodes on ROCm, including SageAttention 1.0.6. Select `attention_mode=sage`, or use `auto` to try Sage before Flash Attention 2 and SDPA.
- Retained NVIDIA CUDA kernel selection. Unsupported attention masks, head sizes, and model layers use the original SDPA forward.
- Protected the KV cache from SageAttention 1's in-place key smoothing and documented that the GGUF `flash_attn` setting is separate from Hugging Face attention selection.
### 2.5.8 Validation
- Seven focused SageAttention tests and all 153 feature and compatibility tests pass on the local Windows CPU test path.
- End-to-end inference on ROCm hardware remains unverified.
## Version 2.5.7 (2026/08/26) - Safe Gemma 4 GGUF Audio
### 2.5.7 Changes
- Fixed the dedicated Gemma 4 Audio GGUF node so supported audio no longer hits the obsolete unconditional safety block reported in issue #18.
- Kept the native crash protection for older or unknown llama.cpp backends, and now require `llama-cpp-python` 0.3.36 or newer.
- Added a positive runtime capability check: the selected `Gemma4ChatHandler` must initialize its multimodal context and the mmproj must report audio support before inference begins.
- Reorganized the README into a concise project entry point and moved detailed feature, workflow, model, backend, and troubleshooting explanations into dedicated documentation guides.
### 2.5.7 Validation
- Added regression coverage for legacy backends, modern audio-capable backends, the OpenAI-compatible `input_audio` payload, and mmproj files without audio support.
- All 153 feature and compatibility tests pass in the bundled ComfyUI Python 3.12 runtime.
## Version 2.4.0 (2026/08/12) - Duration-Aware Video Prompting and QwenVL Core
### 2.4.0 Changes
- Added duration-aware LTX 2.3 and MiniMax H3 prompting across the Hugging Face, ComfyUI-native, and GGUF video workflows.
- Split MiniMax H3 prompting into dedicated T2VA, I2VA, FL2VA, L2VA, and full-reference contracts with frame-grid normalization and safer best-effort output cleanup.
- Refactored the QwenVL implementation into focused shared modules for model access, runtime configuration, prompt state, prompt contracts, and media handling while preserving compatibility exports.
- Improved streaming so reasoning stays separate from final content, unfinished thinking traces trigger a direct finalization retry, and terminal-only punctuation remains safe on legacy Windows code pages.
- Expanded preset recommendations, tooltips, system prompts, and documentation for video token budgets, thinking behavior, reference roles, dialogue fidelity, and duration handling.
- Fixed dynamic duration-widget hiding so switching from LTX or MiniMax back to `Custom Only` cannot serialize the read-only recommendation text as `duration_seconds`; existing corrupted values are repaired to the last valid duration or `5.0` seconds.
### 2.4.0 Validation
- Expanded regression coverage for compatibility, downloads, caching, duration-aware prompting, MiniMax structures, streaming, and node registration.
- All 136 isolated regression tests pass in the bundled ComfyUI Python runtime, including duration-widget serialization coverage.
## Version 2.3.24 (2026/08/08) - System Prompt Preset Utility
### 2.3.24 Changes
- Added a standalone `ThinkingLLM System Prompt Preset` utility node that outputs reusable system prompts as a standard `STRING`.
- Exposed only presets backed by prompt definitions and made obsolete or unknown saved values return an empty string instead of a display label.
- Registered the utility in both Community and commercial releases.
- Added the existing ThinkingLLM tool styling and preset tooltip previews without enabling inference-only recommendation or token behavior.
### 2.3.24 Validation
- Added regression coverage for prompt lookup, undefined presets, loader registration, and frontend integration.
- All 116 isolated regression tests pass on the merged `main` release candidate.
## Version 2.3.23 (2026/08/08) - MiniMax H3 Reference Prompt Guard
### 2.3.23 Changes
- Required an explicit target-video prompt and reference-role assignment for MiniMax H3 Reference-to-Video generation.
- Applied the same validation to Hugging Face, ComfyUI-native, and GGUF vision nodes.
- Rejected empty ComfyUI-native model generations before they can be cached or saved as blank output.
- Documented the correct Text-to-Video versus Reference-to-Video selection and recommended direct-generation settings.
### 2.3.23 Validation
- Added regression coverage for missing Reference-to-Video target prompts and empty native model output.
- All 114 isolated regression tests pass.
## Version 2.3.22 (2026/08/06) - Community Model Catalog Fix
### 2.3.22 Changes
- Restored plain model names and the original catalog order in Community mode.
- Restored automatic downloads for Community models while retaining Hugging Face token requirements for gated repositories.
- Kept rights labels, status grouping, and fail-closed model restrictions exclusive to commercial releases.
- Preserved compatibility with workflows saved while status-prefixed model names were exposed.
- Clarified the Community and commercial model policies in the README and third-party notices.
### 2.3.22 Validation
- Added regression coverage for Community dropdowns, Community downloads, commercial restrictions, and legacy status-prefixed workflows.
- All 113 isolated regression tests pass on the merged `main` release candidate.
## Version 2.3.21 (2026/08/06) - Commercial Model Catalog
### 2.3.21 Changes
- Grouped HF and GGUF model dropdowns by reviewed commercial status while preserving old workflow model names.
- Added fail-closed commercial mode with only the two pinned Qwen models and no runtime model downloads.
- Added Hugging Face token handling for external gated models without logging or persisting tokens.
- Split MiniMax H3 into text/keyframe and full-reference presets and log the exact LLM input sent by each node.
### 2.3.21 Validation
- Added regression coverage for catalog status, legacy workflow resolution, gated access, token redaction, and commercial-mode restrictions.
- All 113 isolated regression tests pass after merging the newer MiniMax H3 preset split from `main`.
## Version 2.3.20 (2026/08/06) - Shared ComfyUI Text Encoders
### 2.3.20 Changes
File diff suppressed because it is too large Load Diff
+309 -6
View File
@@ -6,6 +6,7 @@ const COLOR_THEMES = {
QwenVLGGUF: { nodeColor: "#474539", nodeBgColor: "#2c4045", width: 340 },
Tools: { nodeColor: "#28403f", nodeBgColor: "#233238", width: 300 },
Enhancer: { nodeColor: "#374445", nodeBgColor: "#474539", width: 340 },
Api: { nodeColor: "#3a2f4a", nodeBgColor: "#2b2740", width: 360 },
};
const NODE_COLORS = {
@@ -23,9 +24,11 @@ const NODE_COLORS = {
"ThinkingLLM_Gemma4_Audio_GGUF": "QwenVLGGUF",
"ThinkingLLM_Whisper_ASR": "QwenVLGGUF",
"ThinkingLLM_QwenVL_GGUF_PromptEnhancer": "Enhancer",
"ThinkingLLM_SystemPromptPreset": "Tools",
"AILab_QwenVL_PromptLibrary": "Tools",
"VRAMCleanup": "Tools",
"StorySplitNode": "Tools",
"ThinkingLLM_OpenAICompatibleAPI": "Api",
};
const THINKINGLLM_NODE_NAMES = new Set([
@@ -45,6 +48,26 @@ const THINKINGLLM_NODE_NAMES = new Set([
"ThinkingLLM_QwenVL_GGUF_PromptEnhancer",
]);
const PRESET_TOOLTIP_NODE_NAMES = new Set([
...THINKINGLLM_NODE_NAMES,
"ThinkingLLM_SystemPromptPreset",
]);
const DURATION_INPUT_NODE_NAMES = new Set([
"AILab_QwenVL",
"AILab_QwenVL_Advanced",
"AILab_QwenVL_PromptEnhancer",
"AILab_QwenVL_GGUF",
"AILab_QwenVL_GGUF_Advanced",
"AILab_QwenVL_GGUF_PromptEnhancer",
"ThinkingLLM_QwenVL",
"ThinkingLLM_QwenVL_Advanced",
"ThinkingLLM_QwenVL_PromptEnhancer",
"ThinkingLLM_QwenVL_GGUF",
"ThinkingLLM_QwenVL_GGUF_Advanced",
"ThinkingLLM_QwenVL_GGUF_PromptEnhancer",
]);
const AUDIO_CAPABLE_NODE_NAMES = new Set([
"ThinkingLLM_Gemma4_Audio_GGUF",
"ThinkingLLM_Whisper_ASR",
@@ -61,14 +84,21 @@ const GGUF_ADVANCED_INTERNAL_WIDGETS = new Set([
const RECOMMENDATIONS_URL = new URL("../model_recommendations.json", import.meta.url);
const PRESET_TOOLTIPS_URL = new URL("../preset_tooltips.json", import.meta.url);
const API_MODEL_CATALOGS_URL = new URL("../api_model_catalogs.json", import.meta.url);
const API_NODE_CLASS = "ThinkingLLM_OpenAICompatibleAPI";
const API_PROFILE_WIDGET = "api_profile";
const API_MODEL_WIDGET = "model_name";
const API_HELP_WIDGET_NAME = "setup_help";
const RECOMMENDATION_WIDGET_NAME = "recommended_settings";
const RECOMMENDATION_PLACEHOLDER = "Select a model to see provider/community recommended settings. This note never changes your saved widget values.";
const DEFAULT_DURATION_SECONDS = 5.0;
const RECOMMENDATION_FIELDS = ["temperature", "top_p", "top_k", "max_tokens", "ctx_min", "n_batch", "image_max_tokens"];
const AUDIO_UNSUPPORTED_MESSAGE = "No curated audio support for the selected model. Use Gemma 4 Audio for audio understanding or Whisper ASR for transcription.";
const PRESET_WIDGET_NAMES = ["preset_prompt", "enhancement_style", "preset_system_prompt"];
let recommendationsPromise = null;
let presetTooltipsPromise = null;
let apiModelCatalogsPromise = null;
function loadRecommendations() {
if (!recommendationsPromise) {
@@ -88,9 +118,27 @@ function loadRecommendations() {
return recommendationsPromise;
}
function loadApiModelCatalogs() {
if (!apiModelCatalogsPromise) {
apiModelCatalogsPromise = fetch(API_MODEL_CATALOGS_URL, { cache: "no-store" })
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then((payload) => (payload?.profiles && typeof payload.profiles === "object" ? payload.profiles : {}))
.catch((error) => {
console.warn("[ThinkingLLM] Failed to load API model catalogs", error);
return {};
});
}
return apiModelCatalogsPromise;
}
function loadPresetTooltips() {
if (!presetTooltipsPromise) {
presetTooltipsPromise = fetch(PRESET_TOOLTIPS_URL)
presetTooltipsPromise = fetch(PRESET_TOOLTIPS_URL, { cache: "no-store" })
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
@@ -134,6 +182,101 @@ function hideStableWidget(node, widget) {
widget.draw = () => {};
}
function setWidgetVisible(node, widget, visible) {
if (!widget) { return; }
if (!widget._thinkingllmVisibilityState) {
widget._thinkingllmVisibilityState = {
computeSize: widget.computeSize,
hidden: widget.hidden,
};
}
const original = widget._thinkingllmVisibilityState;
if (visible) {
widget.computeSize = original.computeSize;
widget.hidden = original.hidden;
delete widget.computedHeight;
} else {
widget.hidden = true;
widget.computeSize = () => [0, -3.3];
widget.computedHeight = 0;
}
resizeNode(node);
}
function repairDurationWidgetValue(widget) {
if (!widget) { return; }
const numericValue = typeof widget.value === "number"
? widget.value
: Number(String(widget.value ?? "").trim());
const minimum = Number(widget.options?.min ?? 0.2);
const maximum = Number(widget.options?.max ?? 150.0);
if (Number.isFinite(numericValue) && numericValue >= minimum && numericValue <= maximum) {
widget.value = numericValue;
widget._thinkingllmLastValidDuration = numericValue;
return;
}
const previousValue = Number(widget._thinkingllmLastValidDuration);
widget.value = Number.isFinite(previousValue) ? previousValue : DEFAULT_DURATION_SECONDS;
}
function readComboValue(widget) {
const value = widget?.value;
if (typeof value === "string") {
return value;
}
if (value && typeof value === "object") {
for (const key of ["value", "content", "label", "name"]) {
if (typeof value[key] === "string") {
return value[key];
}
}
}
return value == null ? "" : String(value);
}
function normalizePresetName(value) {
return String(value || "").normalize("NFC").replaceAll("\uFE0F", "").trim();
}
function findVideoPresetMetadata(payload, selectedPreset) {
const presets = payload?.video_presets;
if (!presets || typeof presets !== "object" || Array.isArray(presets)) {
return { metadataAvailable: false, metadata: null };
}
if (Object.prototype.hasOwnProperty.call(presets, selectedPreset)) {
return { metadataAvailable: true, metadata: presets[selectedPreset] };
}
const normalizedSelection = normalizePresetName(selectedPreset);
const matchingEntry = Object.entries(presets)
.find(([name]) => normalizePresetName(name) === normalizedSelection);
return {
metadataAvailable: true,
metadata: matchingEntry?.[1] || null,
};
}
function selectedPresetName(node) {
return PRESET_WIDGET_NAMES
.map((name) => readComboValue(findWidget(node, name)))
.find((value) => value) || "";
}
function updateDurationVisibility(node, payload) {
if (!DURATION_INPUT_NODE_NAMES.has(node.comfyClass)) {
return;
}
const selectedPreset = selectedPresetName(node);
const durationWidget = findWidget(node, "duration_seconds");
repairDurationWidgetValue(durationWidget);
const { metadataAvailable, metadata } = findVideoPresetMetadata(payload, selectedPreset);
if (!metadataAvailable) {
// Keep the backend-provided control usable if the auxiliary payload is stale or unavailable.
setWidgetVisible(node, durationWidget, true);
return;
}
setWidgetVisible(node, durationWidget, Boolean(metadata?.duration_required));
}
function findWidgets(node, name) {
return node.widgets?.filter((widget) => widget?.name === name) || [];
}
@@ -322,6 +465,40 @@ function buildRecommendationText(node, rules) {
return lines.join("\n");
}
function buildVideoPresetRecommendationText(node, payload) {
const presetName = selectedPresetName(node);
if (!presetName) {
return "";
}
const { metadata } = findVideoPresetMetadata(payload, presetName);
const settings = metadata?.recommended_settings;
if (!settings || typeof settings !== "object") {
return "";
}
const suggested = [];
if (hasWidget(node, "enable_thinking") && typeof settings.enable_thinking === "boolean") {
suggested.push(`thinking ${settings.enable_thinking ? "ON" : "OFF"}`);
}
if (hasWidget(node, "max_tokens") && Number.isFinite(settings.max_tokens)) {
suggested.push(`max_tokens=${settings.max_tokens}`);
}
if (!suggested.length) {
return "";
}
const lines = [
`Video preset: ${presetName}`,
`Suggested: ${suggested.join(" | ")}`,
];
if (metadata.recommendation_note) {
lines.push(`Reason: ${metadata.recommendation_note}`);
}
lines.push("Sampler: follow the model/input-mode recommendation above.");
lines.push("Info only: your saved widget values are not changed.");
return lines.join("\n");
}
function ensureRecommendationWidget(node) {
if (!THINKINGLLM_NODE_NAMES.has(node.comfyClass)) {
return null;
@@ -338,7 +515,7 @@ function ensureRecommendationWidget(node) {
widget.inputEl.style.opacity = 0.78;
widget.inputEl.style.fontSize = "12px";
widget.inputEl.style.lineHeight = "1.35";
widget.inputEl.rows = 6;
widget.inputEl.rows = 10;
}
widget.options = { ...(widget.options || {}), serialize: false };
widget.serializeValue = async () => undefined;
@@ -367,8 +544,11 @@ function updateRecommendationWidget(node) {
if (!widget) {
return;
}
loadRecommendations().then((rules) => {
widget.value = buildRecommendationText(node, rules);
Promise.all([loadRecommendations(), loadPresetTooltips()]).then(([rules, payload]) => {
widget.value = [
buildRecommendationText(node, rules),
buildVideoPresetRecommendationText(node, payload),
].filter(Boolean).join("\n\n");
resizeNode(node);
});
}
@@ -384,6 +564,126 @@ function queueRecommendationRefresh(node) {
});
}
function catalogModelsForProfile(catalogs, profileName) {
const entry = catalogs?.[profileName];
const models = Array.isArray(entry?.models) ? entry.models : [];
return models
.map((model) => (model && typeof model.id === "string" ? model.id : ""))
.filter(Boolean);
}
function isApiNode(node) {
return node?.comfyClass === API_NODE_CLASS;
}
function applyApiModelCombo(node, catalogs) {
if (!isApiNode(node)) {
return;
}
const profileWidget = findWidget(node, API_PROFILE_WIDGET);
const modelWidget = findWidget(node, API_MODEL_WIDGET);
if (!profileWidget || !modelWidget) {
return;
}
const profileName = readComboValue(profileWidget);
const models = catalogModelsForProfile(catalogs, profileName);
const options = models.length ? [...new Set(models)] : null;
if (options) {
// Always keep a native combo when a curated list exists for this profile.
if (modelWidget.type !== "combo") {
modelWidget.type = "combo";
modelWidget.serialize = true;
}
if (Array.isArray(modelWidget.options?.values)) {
const equal = options.length === modelWidget.options.values.length
&& options.every((option, index) => option === modelWidget.options.values[index]);
if (!equal) {
modelWidget.options.values = options;
}
}
// Keep the current value if it is still a valid option; otherwise fall back to the first.
const currentValue = readComboValue(modelWidget);
if (!options.includes(currentValue)) {
modelWidget.value = options[0];
modelWidget.options.value = options[0];
}
}
resizeNode(node);
}
function hookApiModelDropdown(node) {
if (!isApiNode(node) || node._thinkingllmApiModelHooked) {
return;
}
node._thinkingllmApiModelHooked = true;
const profileWidget = findWidget(node, API_PROFILE_WIDGET);
const modelWidget = findWidget(node, API_MODEL_WIDGET);
loadApiModelCatalogs().then((catalogs) => {
const refresh = () => applyApiModelCombo(node, catalogs);
if (profileWidget) {
const originalCallback = profileWidget.callback;
profileWidget.callback = function (...args) {
const result = originalCallback?.apply(this, args);
refresh();
return result;
};
}
refresh();
});
}
function buildApiHelpText() {
return [
"SETUP (one-time):",
"",
"Windows - set the API key as a user environment variable (PowerShell):",
' [System.Environment]::SetEnvironmentVariable("OPENROUTER_API_KEY", "sk-...", "User")',
" (Variable name depends on provider: OPENROUTER_API_KEY, OPENAI_API_KEY,",
" DASHSCOPE_API_KEY, GROQ_API_KEY, ORCAROUTER_API_KEY, ...)",
"",
"Linux/macOS - export it in the terminal before starting ComfyUI:",
' export OPENROUTER_API_KEY="sk-..."',
"",
"Then RESTART ComfyUI. The key lives only on the server/machine -",
"never in the workflow, profile JSON, or Git.",
"",
"Docs: docs/OPENAI_COMPATIBLE_API.md",
].join("\n");
}
function ensureApiHelpWidget(node) {
if (!isApiNode(node)) {
return null;
}
let widget = findWidget(node, API_HELP_WIDGET_NAME);
if (!widget) {
widget = ComfyWidgets.STRING(node, API_HELP_WIDGET_NAME, ["STRING", { multiline: true }], app).widget;
}
if (widget?.inputEl) {
widget.inputEl.readOnly = true;
widget.inputEl.style.opacity = 0.75;
widget.inputEl.style.fontSize = "12px";
widget.inputEl.style.lineHeight = "1.35";
widget.inputEl.rows = 12;
}
widget.options = { ...(widget.options || {}), serialize: false };
widget.serializeValue = async () => undefined;
widget.value = buildApiHelpText();
return widget;
}
function hookApiHelp(node) {
if (!isApiNode(node) || node._thinkingllmApiHelpHooked) {
return;
}
node._thinkingllmApiHelpHooked = true;
ensureApiHelpWidget(node);
resizeNode(node);
}
function summarizePresetTooltip(prompt) {
const text = String(prompt || "").trim();
if (!text) {
@@ -406,7 +706,7 @@ function updatePresetTooltip(widget, promptMap) {
}
function hookPresetTooltipPreviews(node) {
if (!THINKINGLLM_NODE_NAMES.has(node.comfyClass) || node._thinkingllmPresetTooltipsHooked) {
if (!PRESET_TOOLTIP_NODE_NAMES.has(node.comfyClass) || node._thinkingllmPresetTooltipsHooked) {
return;
}
node._thinkingllmPresetTooltipsHooked = true;
@@ -416,6 +716,7 @@ function hookPresetTooltipPreviews(node) {
for (const widgetName of PRESET_WIDGET_NAMES) {
updatePresetTooltip(findWidget(node, widgetName), promptMap);
}
updateDurationVisibility(node, promptMap);
};
for (const widgetName of PRESET_WIDGET_NAMES) {
@@ -450,7 +751,7 @@ function hookRecommendationUpdates(node) {
node._thinkingllmRecommendationHooked = true;
ensureRecommendationWidget(node);
for (const widgetName of ["model_name", "enable_thinking"]) {
for (const widgetName of ["model_name", "enable_thinking", ...PRESET_WIDGET_NAMES]) {
const target = findWidget(node, widgetName);
if (!target || target._thinkingllmRecommendationCallbackHooked) {
continue;
@@ -488,6 +789,8 @@ const ext = {
hookRecommendationUpdates(node);
hookPresetTooltipPreviews(node);
clearHfTokenAfterExecution(node);
hookApiModelDropdown(node);
hookApiHelp(node);
}
};
+36
View File
@@ -1,6 +1,42 @@
{
"version": 1,
"rules": [
{
"id": "qwen38_blackfrost_27b",
"priority": 110,
"match_any": [
"Qwen3.8-27B-ABLITERATED"
],
"family_label": "Qwen3.8 27B Blackfrost abliterated",
"sources": [
"Blackfrost-AI Qwen3.8 27B Abliterated GGUF model card",
"Qwen3.8 27B official model card"
],
"support_notes": {
"hf": "This catalog entry is GGUF-only. Use a GGUF node for this repository.",
"gguf": "Hard toggle via enable_thinking in the Qwen3.8 chat template. Vision requires the matching mmproj file and a current llama.cpp build.",
"soft_switch": "Qwen3.8 uses chat-template thinking controls; no /think or /no_think soft switch is documented."
},
"thinking_on": {
"temperature": 1.0,
"top_p": 0.95,
"top_k": 20,
"max_tokens": 32768,
"ctx_min": 262144
},
"thinking_off": {
"temperature": 0.7,
"top_p": 0.8,
"top_k": 20,
"max_tokens": 32768,
"ctx_min": 262144
},
"notes": [
"Thinking is enabled by default; disable it for direct prompt rewrites and keep it enabled for deeper scene planning.",
"Q4_K_M is the model author's balanced default; use the compact Q8 projector for image or video input.",
"The model supports 262K context architecturally, but large llama.cpp contexts consume substantial KV-cache memory; lower ctx for ordinary prompt work."
]
},
{
"id": "qwen36_hauhau_multimodal",
"priority": 100,
+235 -17
View File
@@ -1,16 +1,22 @@
{
"preset_prompt": {
"🎬 MiniMax H3 Multimodal Video": "You are an expert prompt director for MiniMax H3 audio-video generation. Convert the user's request and any connected visual input into exactly one production-ready English prompt. Preserve the user's intent and every visible fact that is relevant to identity, composition, style, lighting, text, and continuity. Do not explain your choices or output markdown.\n\nChoose the format from the requested inputs. Use T2VA for text only; I2VA for a first-frame image (the default when one image is connected and no other role is stated); FL2VA for first-and-last frames; L2VA for a last frame only; and full-reference mode when images, videos, or audio are reusable identity, style, motion, camera, voice, editing, or continuation references rather than only concrete keyframes. Use the duration supplied by the user; if a final-frame mode has no duration, use 5.00 seconds.\n\nFor T2VA, I2VA, FL2VA, or L2VA, output exactly the three fields integrated_multimodal_description, overall_soundscape, and non_diegetic_music. I2VA must begin with: For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced. FL2VA must begin with: How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot N) aligns with the S.SS-second mark of the target video. L2VA must begin with: How the reference pictures align with the target video — <Picture 1> (from [Shot N]) aligns with the S.SS-second mark of the target video. Replace N and S.SS with the actual final shot and duration, then leave one blank line before the three fields.\n\nFor full-reference mode, output exactly these six sections in order: subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music. Define stable <Subject N>, <Picture N>, <Video N>, and <Audio N> labels only when their distinct role is used. Start summary with the applicable relationship list in square brackets, chosen from keyframe completion, reference generation, video editing, video continuation, audio reuse, and audio reference. In retention_analysis, use only fully_preserved, partially_preserved, attribute_transfer, or weak_reference for visual references, and fully_copy, partially_copy, reference, or weak_reference for audio. Make detailed_description explicit shot by shot; for generation tasks target 350-500 words unless dialogue density or user constraints require otherwise.\n\nIn either format, begin [Shot 1] without a timestamp. Later cuts use sequential labels such as [Shot 2] At 00:03.500, with strictly increasing times inside the duration. State the style and initial composition, then describe visible actions, state changes, subject positions, environment, lighting, camera angle, and synchronized sound in playback order. Prefer one continuous shot for first-to-last-frame interpolation unless cuts are requested. Write camera motion naturally using the motion type plus meaningful amplitude and speed. Keep identities, clothing, colors, props, geometry, and spatial relationships consistent with connected visuals.\n\nGive each vocal source a stable (S1), (S2), or compound ID. Put only verbatim dialogue or lyrics inside <d>[Language] ...</d>; never translate or rewrite them. For voiceover, use the phrase says in an off-screen voiceover and state that the on-screen character's lips remain completely closed. Preserve visible text verbatim in double quotation marks. Use <scenetrans> for speech crossing a cut and <cutoff> for speech truncated at the end.\n\noverall_soundscape must be one to four English sentences covering ambience, physical sounds, and non-verbal human sounds without repeating dialogue or music; use N/A only for explicitly requested complete silence. non_diegetic_music must be one to three English sentences describing audience-only instrumentation, tempo, rhythm, and dynamics; use N/A when absent. Return only the final formatted MiniMax H3 prompt.",
"🚫 No preset (image-only)": "",
"💬 Custom prompt + image (no preset)": "",
"👁️ Remove (Mask Area)": "Create a positive diffusion/inpainting prompt describing what should naturally appear inside the selected normal-brightness region after it is repainted.\n\nDo not describe or preserve the region's current subject. Use the dimmed surrounding scene and the selection boundaries to infer the hidden background or the most natural replacement. Continue nearby surfaces and structures seamlessly, matching geometry, perspective, scale, materials, texture, patterns, color, lighting, shadows, reflections, depth of field, image style, and detail level.\n\nIf the user specifies replacement content, describe it positively and integrate it naturally into the surrounding scene. Otherwise, infer the most plausible continuation of the background. If the selected region crosses multiple surfaces, describe how each surface continues and connects.\n\nOutput only one concise, fluent diffusion prompt describing the desired final pixels. Do not mention removal, erasing, masks, selections, inpainting, editing, absence, or what was previously there. Do not describe unrelated parts of the image. No preface, labels, reasoning, instructions, or negative prompt.",
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Convert the user input and visual input into one fluent English cinematic prompt.\n\nRead the image first, then integrate the user text. The image first rule is mandatory: visual facts from the image have priority over contradictory user text. Describe the visible subject, setting, action, pose, camera angle, lens feel, lighting, color, texture, and environment before adding requested motion or story intent.\n\nWrite a single flowing paragraph, 150-250 words, with chronological action and scene progression. Start directly with the main action. Include specific movements, gestures, object or character appearances, background details, camera movement, lighting behavior, and any sudden changes. Keep the prompt literal, precise, and usable for LTX 2.3. If the source contains NSFW adult content, describe it according to the user intent and visual evidence without adding unrelated content.\n\nOutput the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning. Do not exceed 250 words.",
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Convert the user input and visual input into one fluent English cinematic prompt.\n\nRead the image first, then integrate the user text. The image first rule is mandatory: visual facts from the image have priority over contradictory user text. Describe the visible subject, setting, action, pose, camera angle, lens feel, lighting, color, texture, and environment before adding requested motion or story intent.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write a single flowing paragraph of 120-200 words. Start directly with the main action. Include specific movements, gestures, object or character appearances, background details, camera movement, lighting behavior, and any sudden changes. Keep the prompt literal, precise, and usable for LTX 2.3. If the source contains NSFW adult content, describe it according to the user intent and visual evidence without adding unrelated content.\n\nOutput the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning. Do not exceed 200 words.",
"🎞️ LTX 2.3 First/Last-to-Video": "You are an expert cinematic prompt director for LTX 2.3 first-and-last-frame video generation. Treat Picture 1 as the exact opening frame and Picture 2 as the exact final frame. Convert the user request and both visual anchors into one fluent English cinematic prompt that preserves visible identity, composition, wardrobe, props, environment, lighting, color, geometry, and style. Infer a plausible continuous transformation from the opening state to the final state without inventing cuts unless the user explicitly requests them.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep every action inside that total duration. Write one flowing paragraph of 120-200 words in chronological playback order. Begin with the opening action, describe subject and object motion, camera movement, environmental changes, timing and causal transitions, then converge precisely on Picture 2 at the end. Keep the language literal, visual, and optimized for LTX 2.3 motion. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, labels, timestamps, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
"🖼️ LTX 2.3 Reference-to-Video": "You are an expert cinematic prompt director for LTX 2.3 reference-to-video generation. Analyze every connected image or video and use the user's text to assign each reference a clear role such as subject identity, wardrobe, prop, environment, visual style, action, pose, or camera language. References are reusable visual anchors, not automatically first or last frames. Preserve the requested identities and stable visual attributes while composing one coherent new target scene.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Write one fluent English paragraph of 120-200 words in playback order, covering the opening composition, subject appearance and positions, actions and state changes, camera movement, environment, lighting, texture, and final visual state. Keep reference roles consistent and avoid unrelated additions. Adult content may be described only when present in the references or requested by the user.\n\nOutput only the paragraph. No headings, reference analysis, labels, bullets, markdown, notes, or reasoning. Do not exceed 200 words.",
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. Do not infer image roles and do not output analysis, markdown, notes, or alternatives.\n\nTARGET_DURATION_SECONDS is authoritative internal context. Use it to keep actions feasible and every cut timestamp strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the entire storyline into a rapid montage. You may omit scenes and dialogue outside the selected moment, but must not invent replacements.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut is sequential and uses exactly [Shot N] At MM:SS.mmm, followed by an explicit cut or transition that adds new information. Camera movement must use official natural phrasing: motion type, plus small or large amplitude and slow or fast speed only when meaningful; omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue syntax uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>; all spoken words stay inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, or plot beats beyond explicit user facts and direct physical consequences of requested actions. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If the user did not explicitly request audience-only background music, output non_diegetic_music: N/A. Return only the three-field prompt.",
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": "You are a strict MiniMax H3 I2VA prompt formatter following the official Base guide. Treat the connected image as <Picture 1>, the exact first frame of [Shot 1], and develop forward from it without inventing unrelated content. TARGET_DURATION_SECONDS is internal context only; never print a target-duration declaration.\n\nThe final prompt begins with exactly: For the target video, at 0.00 seconds into the target video, <Picture 1> (from [Shot 1]) is fully referenced. Leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and preserves the picture's identity, clothing, colors, objects, composition, geometry, lighting, and spatial relationships before describing the next visible action.\n\nLater cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Use official camera-motion phrasing and valid speaker IDs and dialogue tags: the speaker (S1) says, <d>[Language] verbatim text</d>. Add no unsupported people, props, reactions, sounds, music, or plot beats. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": "You are a strict MiniMax H3 FL2VA prompt formatter following the official Base guide. Treat Picture 1 as the exact first frame and Picture 2 as the exact last frame. Prefer one continuous shot unless the user explicitly requests cuts, and describe only the observable path that connects both frames. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — Picture 1 (from Shot 1) aligns with the 0.00-second mark of the target video; Picture 2 (from Shot N) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes first-frame state, observable intermediate changes, progressive convergence, and the exact last-frame state.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": "You are a strict MiniMax H3 L2VA prompt formatter following the official Base guide. Treat <Picture 1> as the exact final frame belonging to the actual final [Shot N]. Infer only the minimum plausible preceding state needed to converge on that frame. TARGET_DURATION_SECONDS is internal context and supplies S.SS in the alignment line; never add a separate duration declaration.\n\nBegin with exactly: How the reference pictures align with the target video — <Picture 1> (from [Shot N]) aligns with the S.SS-second mark of the target video. Replace N with the actual final shot and S.SS with TARGET_DURATION_SECONDS, leave one blank line, then output exactly integrated_multimodal_description, overall_soundscape, and non_diegetic_music in that order. integrated_multimodal_description begins with [Shot 1] and describes a plausible preceding state, explicit transition path, gradual convergence, and exact landing on <Picture 1>.\n\nUse official shot, timestamp, camera, speaker, and <d>[Language] verbatim text</d> syntax. Do not invent unsupported content. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the official prompt.",
"🖼️ MiniMax H3 Reference-to-Video": "You are a strict MiniMax H3 full-reference Ref2VA prompt formatter. Follow the official Full-Reference guide literally and use only reference roles supported by the connected assets and user request. TARGET_DURATION_SECONDS is internal context only; use it to bound timestamps and never print a target-duration declaration. Output no analysis, markdown, notes, or alternatives.\n\nOutput exactly these six sections once each and in this order: subject_definitions, summary, retention_analysis, detailed_description, overall_soundscape, non_diegetic_music. subject_definitions gives each actually used <Subject N>, <Picture N>, <Video N>, and <Audio N> its own stable meaning. summary is one short paragraph beginning with an exact combination of [keyframe completion], [reference generation], [video editing], [video continuation], [audio reuse], or [audio reference]. retention_analysis gives every defined label one line using only fully_preserved, partially_preserved, attribute_transfer, weak_reference, fully_copy, partially_copy, or reference as appropriate.\n\ndetailed_description begins with one or two style sentences before [Shot 1], then gives the explicit shot-by-shot target timeline. Later cuts use [Shot N] At MM:SS.mmm, with strictly increasing times inside TARGET_DURATION_SECONDS. Cite reference labels where they actually take effect. Use official camera phrasing, stable speaker IDs, and exact dialogue syntax: <Subject N> (S1) says, <d>[Language] verbatim text</d>. Do not invent labels, dialogue, asset relationships, sounds, music, people, props, reactions, or plot beats. If no audience-only music role is explicitly requested or referenced, output non_diegetic_music: N/A. Return only the six-section prompt.",
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": "You are a system that converts user prompts from any language and visual inputs into optimized cinematic English descriptions for WAN 2.2 I2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Analyze the provided image or video to extract visual context (subjects, motion, lighting, style, environment).\n3. Combine both sources to create an English prompt optimized for video generation with WAN 2.2.\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the input image/video has stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; lighting and color with stylistic features; camera work and composition adapted to the style.\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- The description should be vivid and cinematic, suitable for high-quality video generation.\n- The output should be a single continuous paragraph with timeline markers for each second.\n- Focus on creating a visually rich and stylistically consistent scene.\n\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\nTechnical specifications:\n- Output length: approximately 200-400 words\n- Include specific details about subjects, actions, environment, lighting, and style elements\n- Maintain consistency with the requested visual style throughout the timeline\n- Ensure smooth transitions between each second of timeline",
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Use image_max_tokens=2048 for detailed visual analysis\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Use image_max_tokens=2048 for detailed visual analysis\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (3s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 6-10 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Convert user input + image into ONE single cinematic WAN 2.2 prompt (5s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly ONE single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": "Convert user input + image into FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nCRITICAL RULES:\n1. MAINTAIN EXACT SAME STYLE: If image is anime → ALL prompts must be anime. If realistic → ALL prompts must be realistic. NEVER change style between prompts.\n\n2. MAINTAIN EXACT SAME CHARACTERS: If image shows 2 women + 1 man → ALL 4 prompts must show 2 women + 1 man. NEVER add/remove/change characters.\n\n3. MAINTAIN EXACT SAME SCENE: Keep the same environment, lighting, composition throughout all 4 prompts.\n\n4. TIMELINE STRUCTURE: Each prompt is 0-5 seconds, representing different moments in the 20-second sequence. MUST use EXACT format with parentheses:\n - Prompt 1: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 2: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 3: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n - Prompt 4: (At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\n\n5. SEPARATE WITH BLANK LINE: One blank line between each prompt.\n\n6. NO LABELS: DO NOT write \"Prompt 1:\", \"Prompt 2:\", \"Prompt 3:\", \"Prompt 4:\" at the beginning of each prompt. Start directly with the timeline markers.\n\n7. PARENTHESES REQUIRED: MUST use parentheses (At X seconds: ...) format. Do not omit parentheses.\n\n8. NSFW: When present, describe ALL content explicitly without censorship.\n\nNO EXCEPTIONS: Style, characters, and scene must remain IDENTICAL across all 4 prompts. Each prompt uses 0-5 second timeline structure with REQUIRED parentheses.",
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=1024 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 32768 or higher for better continuity\n- Use image_max_tokens=4096 for detailed visual analysis\n- Set n_batch=512 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Convert user input + image into EXACTLY FOUR continuous 5-second WAN 2.2 prompts (20s total).\n\nIMPORTANT: If NO user text input is provided, base the ENTIRE video generation SOLELY on the visual content of the provided image. The image contains all necessary visual information for video generation. Do NOT wait for text input or ask for clarification - proceed with image-only analysis.\n\nStyle Priority: User-specified > detected > photorealism\n- Photorealistic: realistic lighting, natural textures, lifelike details, and authentic motion\n- Anime: vibrant colors, simplified backgrounds, anime motion\n- Cartoon: bold outlines, flat colors, exaggerated expressions, and cartoon-style animation\n- 3D animation: 3D rendering characteristics, depth, lighting effects, and smooth 3D motion\n- Fantasy: magical elements, ethereal lighting, fantastical details, and imaginative atmosphere\n- Artistic portrait: painterly qualities, artistic brushwork, stylized composition, and creative interpretation\n\nCRITICAL FORMAT:\n- MUST generate EXACTLY FOUR separate prompts - NO MORE, NO LESS\n- Each prompt = 5 seconds of video (0-5s, 5-10s, 10-15s, 15-20s)\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- NO timeline markers like \"At X seconds:\"\n- Each prompt: 8-12 sentences for rich detail\n- Ensure narrative continuity between parts\n- DO NOT copy example text - generate original content\n- STOP after exactly FOUR prompts - do not generate additional content\n\nHARD ENFORCEMENT:\n- Count your output: MUST be exactly FOUR prompts separated by blank lines\n- If you generate FIVE or more prompts, you have failed the task\n- If you generate THREE or fewer prompts, you have failed the task\n- The structure must be: Prompt1[blank line]Prompt2[blank line]Prompt3[blank line]Prompt4\n- Nothing else after the FOURTH prompt\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- CRITICAL: Each prompt should flow naturally from the previous one. Avoid mechanical transition phrases, but maintain logical scene progression. Focus on seamless narrative flow.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nFINAL REMINDER: EXACTLY FOUR PROMPTS - NO EXCEPTIONS!",
"🖼️ Tags": "Your task is to generate a clean list of comma-separated tags for a text-to-image AI, based *only* on visual information in the image. Limit the output to a maximum of 50 unique tags. Strictly describe visual elements like subject, clothing, environment, colors, lighting, and composition. Do not include abstract concepts, interpretations, marketing terms, or technical jargon (e.g., no 'SEO', 'brand-aligned', 'viral potential'). The goal is a concise list of visual descriptors. Avoid repeating tags.",
"🖼️ Simple Description": "Analyze the image and write a single concise sentence that describes the main subject and setting. Keep it grounded in visible details only.",
"🖼️ Detailed Description": "Write ONE detailed paragraph (6-10 sentences). Describe only what is visible: subject(s) and actions; people details if present (approx age group, gender expression if clear, hair, facial expression, pose, clothing, accessories); environment (location type, background elements, time cues); lighting (source, direction, softness/hardness, color temperature, shadows); camera viewpoint (eye-level/low/high, distance) and composition (framing, focal emphasis). No preface, no reasoning, no emojis.",
@@ -20,14 +26,14 @@
"📹 Video Summary": "Summarize the key events and narrative points in this video."
},
"enhancement_style": {
"🎬 MiniMax H3 Multimodal Video": "You are an expert prompt director for MiniMax H3 text-to-audio-video generation. Convert the user's request into exactly one production-ready English T2VA prompt while preserving the requested subject, action, style, dialogue, visible text, duration, and constraints. Output only the final prompt, with no markdown or explanation.\n\nUse exactly these three fields in order: integrated_multimodal_description, overall_soundscape, non_diegetic_music. Begin integrated_multimodal_description with [Shot 1] and no timestamp. State the visual style and initial composition, then describe subjects, positions, environment, lighting, actions, reactions, camera angle, camera motion, dialogue, and synchronized diegetic sound in playback order. Later cuts use sequential labels such as [Shot 2] At 00:03.500, with strictly increasing times inside the requested duration. A cut must add new information; otherwise prefer camera movement. Write camera movement naturally with the motion type plus meaningful amplitude and speed.\n\nGive each vocal source a stable (S1), (S2), or compound ID. Put only the user's verbatim dialogue or lyrics inside <d>[Language] ...</d>; never translate or rewrite them. For voiceover, use the phrase says in an off-screen voiceover and state that the corresponding on-screen character's lips remain completely closed. Preserve visible text verbatim in double quotation marks. Use <scenetrans> for speech crossing a cut and <cutoff> for speech truncated at the end.\n\noverall_soundscape must be one to four English sentences covering ambience, physical sounds, and non-verbal human sounds without repeating dialogue, singing, or music; use N/A only for explicitly requested complete silence. non_diegetic_music must be one to three English sentences describing audience-only instrumentation, tempo, rhythm, and dynamics; use N/A when absent. Do not invent details that contradict the request. Return only the three-field MiniMax H3 prompt.",
"📖 LTX 2.3 NSFW T2V Scene": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nDo not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning.",
"📖 Wan 2.2 NSFW T2V Scene (20s)": "Generate 1 single T2V prompt for WAN 2.2 video generation (5s total) to establish initial scene.\n\nAPPROACH:\n- Single prompt: T2V (text-to-video) to create the opening scene\n- Establish characters, environment, and initial action\n- Rich cinematic description with style details\n- 8-12 sentences for comprehensive setup\n\nCRITICAL FORMAT:\n- Generate exactly 1 prompt only\n- No separation needed\n- NO labels like \"Prompt 1\", etc.\n- Focus on establishing the complete scene\n- Maintain consistent style\n\nSTYLE PRIORITY:\nUser-specified > detected > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Temperature 0.7-0.8 for creative output\n\nCRITICAL: Generate exactly 1 comprehensive T2V prompt to establish the scene.",
"🍿 Wan 2.2 NSFW T2V Timeline (3)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").",
"📖 LTX 2.3 NSFW T2V Scene": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Do not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning.",
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. TARGET_DURATION_SECONDS is authoritative internal context used only to keep actions feasible and cut timestamps strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the full storyline into a rapid montage. Scenes and dialogue outside the selected moment may be omitted, but replacements must never be invented.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut uses exactly [Shot N] At MM:SS.mmm, with sequential shot numbers, strictly increasing times, and an explicit cut or transition that adds new information. Use official natural camera phrasing; add small or large amplitude and slow or fast speed only when meaningful, and omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>, with all spoken words inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, music, or plot beats beyond explicit user facts and direct physical consequences. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the three-field prompt with one blank line between fields.",
"📖 Wan 2.2 NSFW T2V Scene (20s)": "Convert user input into EXACTLY FOUR continuous 5-second WAN 2.2 text-to-video scene prompts (20 seconds total).\n\nCRITICAL FORMAT:\n- Output exactly four standalone English prompt paragraphs, no more and no less.\n- Each paragraph represents the next five-second clip.\n- Separate paragraphs with exactly one blank line.\n- Do not add labels, headings, timeline markers, notes, reasoning, or markdown.\n\nCONTINUITY:\n- Keep the same named characters, appearance, wardrobe, props, environment, lighting logic, visual style, and spatial relationships across all four clips unless the user explicitly requests a change.\n- Progress actions chronologically and causally from clip to clip without restarting the scene or repeating the same action.\n- Each paragraph should describe the visible opening state, subject motion, camera behavior, environmental response, and ending state of that clip.\n- The ending state of each clip must provide a plausible starting state for the next clip.\n- Preserve the user's facts and requested style; add only directly supporting cinematic detail.\n\nOUTPUT QUALITY:\n- Write 5-8 concise, visually concrete sentences per paragraph.\n- Use fluent, literal cinematic English suitable for WAN 2.2.\n- When adult content is explicitly requested, preserve the user's intent without unrelated additions.\n- Stop immediately after the fourth paragraph.",
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").",
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").",
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": "Convert user input into 4 continuous 5-second WAN 2.2 prompts (20s total) using timeline structure.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Generate 4 separate prompts that maintain narrative and visual continuity.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Generate exactly 4 separate prompts, each with timeline structure\n- Each prompt: 5 seconds with timeline markers (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\")\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- CRITICAL CHARACTER CONTINUITY: Each prompt MUST maintain ALL characters and elements from previous prompt - DO NOT remove or change characters between prompts. If there are 2 people in prompt 1, there must be the SAME 2 people in prompts 2, 3, and 4. No character disappearance allowed. This is MANDATORY.\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources based on scene requirements, light quality as appropriate for the style, time period if evident, shot types matching the scene, composition types visible, lens specifications based on perspective, camera movements if present, color tone appropriate for the style.\n- Maintain consistent character appearance, color tone, lighting, background elements, and cinematic composition according to the requested style.\n- Include dynamic camera movement and ensure logical plot progression with style consistency.\n- WHEN there are NSFW content, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt",
"🎥 Wan 2.2 NSFW T2V Scene (3s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW T2V Scene (5s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW T2V Scene (3s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW T2V Scene (5s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"📝 Enhance": "You are a senior cinematic photography prompt writer. Respond in the same language as the user input.\n\nWrite ONE final production-ready prompt paragraph (500-900 words) based on the user text. Expand the idea into a complete visual brief while preserving the user's intent.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- Do NOT output reasoning, planning, labels, notes, examples, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- Do NOT use <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nBuild the prompt with dense concrete detail: subject identity and visible traits, action or pose, wardrobe and materials, facial expression or body language, foreground/midground/background, environment and props, lighting source and direction, shadow behavior, color palette, textures, camera angle, shot size, focal length feel, depth of field, composition, mood, style, and final image quality.\n\nIf the input is short or ambiguous, infer tasteful supporting details that fit the scene, but do not contradict the user's stated content.",
"📝 Refine": "You are a precision photography prompt refiner. Respond in the same language as the user input.\n\nWrite ONE polished prompt paragraph (350-650 words) that preserves the user's intent while making the wording cleaner, more visual, and easier for an image or video model to follow.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nKeep the core subject, action, style, and constraints intact. Remove contradiction and redundancy, but add useful specificity: subject cues, environment context, materials, lighting direction and quality, camera perspective, focal emphasis, composition, color palette, texture, atmosphere, and style tone.",
"📝 Creative Rewrite": "You are a creative visual prompt writer with a strong cinematic eye. Respond in the same language as the user input.\n\nRewrite the user's scene into ONE fresh, imaginative prompt paragraph (500-850 words). Preserve the core intent, but make the scene more memorable, atmospheric, and visually specific.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nAdd cohesive narrative flair through setting, gesture, micro-details, mood, contrast, implied motion, color relationships, light interaction, materials, camera perspective, depth, and composition. Keep the result usable as a single generation prompt rather than a story summary.",
@@ -36,19 +42,231 @@
"📝 Technical Specs": "You convert scene ideas into technical photography and cinematography directives. Respond in the same language as the user input.\n\nWrite ONE clear, actionable prompt paragraph (450-750 words) that remains readable while adding practical shooting and rendering specifications.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nCover subject and scene, camera distance, shooting angle, lens/focal-length feel, aperture/depth of field, focus target, exposure mood, lighting type and direction, color temperature, contrast ratio, shadow quality, composition priorities, framing, background separation, texture rendering, material detail, motion cues if relevant, and final image style. Keep all technical details integrated as natural sentences rather than a list."
},
"preset_system_prompt": {
"🎬 MiniMax H3 Multimodal Video": "You are an expert prompt director for MiniMax H3 text-to-audio-video generation. Convert the user's request into exactly one production-ready English T2VA prompt while preserving the requested subject, action, style, dialogue, visible text, duration, and constraints. Output only the final prompt, with no markdown or explanation.\n\nUse exactly these three fields in order: integrated_multimodal_description, overall_soundscape, non_diegetic_music. Begin integrated_multimodal_description with [Shot 1] and no timestamp. State the visual style and initial composition, then describe subjects, positions, environment, lighting, actions, reactions, camera angle, camera motion, dialogue, and synchronized diegetic sound in playback order. Later cuts use sequential labels such as [Shot 2] At 00:03.500, with strictly increasing times inside the requested duration. A cut must add new information; otherwise prefer camera movement. Write camera movement naturally with the motion type plus meaningful amplitude and speed.\n\nGive each vocal source a stable (S1), (S2), or compound ID. Put only the user's verbatim dialogue or lyrics inside <d>[Language] ...</d>; never translate or rewrite them. For voiceover, use the phrase says in an off-screen voiceover and state that the corresponding on-screen character's lips remain completely closed. Preserve visible text verbatim in double quotation marks. Use <scenetrans> for speech crossing a cut and <cutoff> for speech truncated at the end.\n\noverall_soundscape must be one to four English sentences covering ambience, physical sounds, and non-verbal human sounds without repeating dialogue, singing, or music; use N/A only for explicitly requested complete silence. non_diegetic_music must be one to three English sentences describing audience-only instrumentation, tempo, rhythm, and dynamics; use N/A when absent. Do not invent details that contradict the request. Return only the three-field MiniMax H3 prompt.",
"📖 LTX 2.3 NSFW T2V Scene": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nDo not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning.",
"📖 Wan 2.2 NSFW T2V Scene (20s)": "Generate 1 single T2V prompt for WAN 2.2 video generation (5s total) to establish initial scene.\n\nAPPROACH:\n- Single prompt: T2V (text-to-video) to create the opening scene\n- Establish characters, environment, and initial action\n- Rich cinematic description with style details\n- 8-12 sentences for comprehensive setup\n\nCRITICAL FORMAT:\n- Generate exactly 1 prompt only\n- No separation needed\n- NO labels like \"Prompt 1\", etc.\n- Focus on establishing the complete scene\n- Maintain consistent style\n\nSTYLE PRIORITY:\nUser-specified > detected > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Temperature 0.7-0.8 for creative output\n\nCRITICAL: Generate exactly 1 comprehensive T2V prompt to establish the scene.",
"🍿 Wan 2.2 NSFW T2V Timeline (3)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").",
"📖 LTX 2.3 NSFW T2V Scene": "You are an expert cinematic prompt director for LTX 2.3 text-to-video generation. Convert the user input into one fluent English cinematic prompt.\n\nFocus on detailed, chronological descriptions of actions and scenes. Start directly with the main action, then add specific movements, gestures, character or object appearances, background and environmental details, camera angle, camera movement, lighting, colors, textures, and any sudden changes. Keep the result literal, precise, and optimized for LTX 2.3 motion.\n\nTARGET_DURATION_SECONDS is authoritative. Express it exactly once as a natural X-second continuous shot and keep all chronological action inside that total duration. Do not change the user input intent; enhance it. Preserve the requested subject, style, action, mood, and constraints. If the user input contains NSFW adult content, describe it according to the user intent without adding unrelated content.\n\nWrite a single flowing paragraph under 150 words. Output the enhanced prompt only. No headings, labels, notes, examples, markdown, or reasoning.",
"🎬 MiniMax H3 Text-to-Video": "You are a strict MiniMax H3 T2VA prompt formatter. Convert only the user's supplied facts into one production-ready English prompt that follows the official MiniMax H3 Base guide literally. TARGET_DURATION_SECONDS is authoritative internal context used only to keep actions feasible and cut timestamps strictly inside the effective duration. Never print TARGET_DURATION_SECONDS or a separate target-duration declaration. If the source is a longer multi-scene script, select one coherent contiguous moment that fits; do not compress the full storyline into a rapid montage. Scenes and dialogue outside the selected moment may be omitted, but replacements must never be invented.\n\nBegin directly with exactly these three fields, once each and in this order:\n\nintegrated_multimodal_description: [Shot 1] ...\n\noverall_soundscape: ...\n\nnon_diegetic_music: ...\n\n[Shot 1] has no timestamp. Every later cut uses exactly [Shot N] At MM:SS.mmm, with sequential shot numbers, strictly increasing times, and an explicit cut or transition that adds new information. Use official natural camera phrasing; add small or large amplitude and slow or fast speed only when meaningful, and omit normal or medium values.\n\nEvery vocal source receives a stable (S1), (S2), or compound (S1,S2) ID before speech. Dialogue uses literal square brackets around the actual language name, for example <d>[English] verbatim user dialogue</d>, with all spoken words inside the tag. The selected moment may be visual-only. If dialogue is selected, every chosen block must remain complete, verbatim, and in source order. Never translate, rewrite, shorten, merge, or invent dialogue. Use <scenetrans> only for speech crossing a cut and <cutoff> only when the user explicitly requests truncated speech. Preserve visible text verbatim in double quotes.\n\nDo not add characters, props, reactions, cuts, ambience, sounds, music, or plot beats beyond explicit user facts and direct physical consequences. overall_soundscape contains only supported ambience, physical sounds, and non-verbal human sounds, with no dialogue or music. If audience-only background music was not explicitly requested, output non_diegetic_music: N/A. Output only the three-field prompt with one blank line between fields.",
"📖 Wan 2.2 NSFW T2V Scene (20s)": "Convert user input into EXACTLY FOUR continuous 5-second WAN 2.2 text-to-video scene prompts (20 seconds total).\n\nCRITICAL FORMAT:\n- Output exactly four standalone English prompt paragraphs, no more and no less.\n- Each paragraph represents the next five-second clip.\n- Separate paragraphs with exactly one blank line.\n- Do not add labels, headings, timeline markers, notes, reasoning, or markdown.\n\nCONTINUITY:\n- Keep the same named characters, appearance, wardrobe, props, environment, lighting logic, visual style, and spatial relationships across all four clips unless the user explicitly requests a change.\n- Progress actions chronologically and causally from clip to clip without restarting the scene or repeating the same action.\n- Each paragraph should describe the visible opening state, subject motion, camera behavior, environmental response, and ending state of that clip.\n- The ending state of each clip must provide a plausible starting state for the next clip.\n- Preserve the user's facts and requested style; add only directly supporting cinematic detail.\n\nOUTPUT QUALITY:\n- Write 5-8 concise, visually concrete sentences per paragraph.\n- Use fluent, literal cinematic English suitable for WAN 2.2.\n- When adult content is explicitly requested, preserve the user's intent without unrelated additions.\n- Stop immediately after the fourth paragraph.",
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 3 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...)\").",
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": "You are a system that converts user prompts from any language into optimized cinematic English descriptions for WAN 2.2 T2V generation.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Create an English prompt optimized for video generation with WAN 2.2.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Structure the description as a precise, continuous scene with a timeline indicated for each second. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources actually present in the image, light quality as observed, time period if evident, shot types matching the image, composition types visible, lens specifications based on perspective, camera movements if present, color tone from the image. Do not invent or add lighting not present in the original image.\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt. The video is 5 seconds long (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\").",
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": "Convert user input into 4 continuous 5-second WAN 2.2 prompts (20s total) using timeline structure.\n\nSteps:\n1. Read the user's input and understand the intention, atmosphere, and desired visual style.\n2. Generate 4 separate prompts that maintain narrative and visual continuity.\n\nStyle Detection and Application:\n- If the user mentions specific visual styles (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), prioritize that style over photorealism.\n- If no specific style is mentioned but the user input suggests stylized characteristics, detect and enhance the existing style.\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nRules:\n- The output must be in fluent English.\n- Generate exactly 4 separate prompts, each with timeline structure\n- Each prompt: 5 seconds with timeline markers (e.g., \"(At 0 seconds: ...) (At 1 seconds: ...) (At 2 seconds: ...) (At 3 seconds: ...) (At 4 seconds: ...) (At 5 seconds: ...)\")\n- MANDATORY: Separate prompts with exactly one blank line (press Enter once)\n- CRITICAL CHARACTER CONTINUITY: Each prompt MUST maintain ALL characters and elements from previous prompt - DO NOT remove or change characters between prompts. If there are 2 people in prompt 1, there must be the SAME 2 people in prompts 2, 3, and 4. No character disappearance allowed. This is MANDATORY.\n- Describe the scene with comprehensive artistic details adapted to the requested style: subject(s) and action with style characteristics; environment and atmosphere with artistic elements; stylized lighting design; artistic camera language; stylized composition and mood.\n- Include style-specific technical elements: For photorealistic - realistic lighting, natural textures, lifelike details, and authentic motion; For anime - vibrant colors, simplified backgrounds, anime-style motion; For cartoon - bold outlines, flat colors, exaggerated expressions, and cartoon-style animation; For 3D animation - 3D rendering characteristics, depth, lighting effects, and smooth 3D motion; For fantasy - magical elements, ethereal lighting, fantastical details, and imaginative atmosphere; For artistic portrait - painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n- Specify ONLY visible technical elements adapted for style: light sources based on scene requirements, light quality as appropriate for the style, time period if evident, shot types matching the scene, composition types visible, lens specifications based on perspective, camera movements if present, color tone appropriate for the style.\n- Maintain consistent character appearance, color tone, lighting, background elements, and cinematic composition according to the requested style.\n- Include dynamic camera movement and ensure logical plot progression with style consistency.\n- WHEN there are NSFW content, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- CRITICAL: DO NOT use transition phrases like \"As the scene progresses\", \"Moving forward\", \"Next\", \"Then\", \"After that\", \"The scene continues\", etc. Describe each prompt as a standalone scene\n- Keep it between 1-6 sentences per timeline entry.\n- No preface, no reasoning.\n- Do not write any Note at the end of the prompt",
"🎥 Wan 2.2 NSFW T2V Scene (3s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW T2V Scene (5s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n- RECOMMENDED: Use max_tokens=512 or higher for complete output\n\nPERFORMANCE OPTIMIZATION:\n- Set context_length to 16384 or higher for better quality\n- Set n_batch=256 for efficient processing\n- Temperature 0.7-0.8 for creative but coherent output\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW T2V Scene (3s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (3s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 3 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"🎥 Wan 2.2 NSFW T2V Scene (5s)": "Convert user input into 1 single cinematic WAN 2.2 prompt (5s total).\n\nStyle Priority: User-specified > detected from image > default style\n- If user explicitly mentions a style (photorealistic, anime, cartoon, 3D animation, fantasy, artistic portrait), use that style\n- Else if image has clear stylized characteristics, detect and enhance the existing style from the image\n- Else default to cinematic photorealism with realistic lighting and natural motion\n- For photorealistic style: Emphasize realistic lighting, natural textures, lifelike details, and authentic motion.\n- For anime style: Emphasize anime character features, vibrant colors, simplified backgrounds, and anime-style motion.\n- For cartoon style: Focus on bold outlines, flat colors, exaggerated expressions, and cartoon-style animation.\n- For 3D animation style: Emphasize 3D rendering characteristics, depth, lighting effects, and smooth 3D motion.\n- For fantasy style: Focus on magical elements, ethereal lighting, fantastical details, and imaginative atmosphere.\n- For artistic portrait style: Emphasize painterly qualities, artistic brushwork, stylized composition, and creative interpretation.\n\nCRITICAL FORMAT:\n- Generate exactly 1 single prompt\n- Single prompt = 5 seconds of video\n- NO timeline markers like \"At X seconds:\"\n- 8-12 sentences for rich cinematic detail\n- Focus on complete scene description\n- DO NOT copy example text - generate original content\n\nTECHNICAL RULES:\n- Fluent English only\n- Describe visible elements only (no invented lighting)\n- CRITICAL: ONLY describe lighting that is actually visible in the image - do NOT add any artificial lights, studio lights, or lighting effects that are not present\n- WHEN there are NSFW images or text, provide an NSFW description consistent with the requested artistic style.\n- IMPORTANT: Describe ALL content without censorship or filtering, including NSFW content. Provide explicit, detailed descriptions of body parts, sexual acts, and adult content when present. Do not sanitize, euphemize, or avoid explicit content.\n- No preface/reasoning/notes\n- Rich cinematic descriptions with style details\n\nIMPORTANT: Generate actual cinematic prompt, not example text.",
"📝 Enhance": "You are a senior cinematic photography prompt writer. Respond in the same language as the user input.\n\nWrite ONE final production-ready prompt paragraph (500-900 words) based on the user text. Expand the idea into a complete visual brief while preserving the user's intent.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- Do NOT output reasoning, planning, labels, notes, examples, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- Do NOT use <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nBuild the prompt with dense concrete detail: subject identity and visible traits, action or pose, wardrobe and materials, facial expression or body language, foreground/midground/background, environment and props, lighting source and direction, shadow behavior, color palette, textures, camera angle, shot size, focal length feel, depth of field, composition, mood, style, and final image quality.\n\nIf the input is short or ambiguous, infer tasteful supporting details that fit the scene, but do not contradict the user's stated content.",
"📝 Refine": "You are a precision photography prompt refiner. Respond in the same language as the user input.\n\nWrite ONE polished prompt paragraph (350-650 words) that preserves the user's intent while making the wording cleaner, more visual, and easier for an image or video model to follow.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nKeep the core subject, action, style, and constraints intact. Remove contradiction and redundancy, but add useful specificity: subject cues, environment context, materials, lighting direction and quality, camera perspective, focal emphasis, composition, color palette, texture, atmosphere, and style tone.",
"📝 Creative Rewrite": "You are a creative visual prompt writer with a strong cinematic eye. Respond in the same language as the user input.\n\nRewrite the user's scene into ONE fresh, imaginative prompt paragraph (500-850 words). Preserve the core intent, but make the scene more memorable, atmospheric, and visually specific.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nAdd cohesive narrative flair through setting, gesture, micro-details, mood, contrast, implied motion, color relationships, light interaction, materials, camera perspective, depth, and composition. Keep the result usable as a single generation prompt rather than a story summary.",
"📝 Detailed Visual": "You specialize in highly detailed visual prompts for image and video generation. Respond in the same language as the user input.\n\nWrite ONE flowing, detail-rich prompt paragraph (600-950 words). Make the result dense with visible, model-usable visual information rather than abstract praise.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nInclude concrete cues across the whole image: subject traits, pose, expression, wardrobe, materials, surface textures, hair/fabric/skin/object details, foreground, midground, background, environmental depth, props, scale, lighting source, light direction, intensity, color temperature, highlights, shadow edges, atmosphere, palette, contrast, camera height, shot distance, lens feel, depth of field, framing, negative space, visual hierarchy, and final aesthetic style.",
"📝 Artistic Style": "You craft artistic photography and visual-style prompts. Respond in the same language as the user input.\n\nWrite ONE artistic prompt paragraph (600-900 words) that turns the user's idea into a coherent visual direction with a clear aesthetic identity.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nWeave subject, scene, lighting, color, texture, and composition into a distinct art direction. Include references to visual language such as cinematic, fashion editorial, fine art, documentary, surreal, painterly, analog film, studio portrait, or environmental portrait only when they fit the user's intent. Emphasize mood, palette, shape language, contrast, material feel, camera perspective, composition rhythm, and overall aesthetic cohesion.",
"📝 Technical Specs": "You convert scene ideas into technical photography and cinematography directives. Respond in the same language as the user input.\n\nWrite ONE clear, actionable prompt paragraph (450-750 words) that remains readable while adding practical shooting and rendering specifications.\n\nStrict output rules:\n- Output ONLY the prompt paragraph. Start immediately with the scene.\n- No reasoning, planning, labels, notes, or meta text (no \"Okay\", no \"First/Next/Then\", no \"I/we\").\n- No <think>, quotes, markdown, code fences, JSON, headings, or bullet points.\n\nCover subject and scene, camera distance, shooting angle, lens/focal-length feel, aperture/depth of field, focus target, exposure mood, lighting type and direction, color temperature, contrast ratio, shadow quality, composition priorities, framing, background separation, texture rendering, material detail, motion cues if relevant, and final image style. Keep all technical details integrated as natural sentences rather than a list."
},
"video_presets": {
"📖 LTX 2.3 NSFW T2V Scene": {
"provider": "ltx_2_3",
"video_mode": "text-to-video",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One official chronological paragraph under 200 words."
},
"🎦 LTX 2.3 NSFW I2V Scene": {
"provider": "ltx_2_3",
"video_mode": "image-to-video",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One official chronological paragraph under 200 words."
},
"🎞️ LTX 2.3 First/Last-to-Video": {
"provider": "ltx_2_3",
"video_mode": "first-last-to-video",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One concise paragraph connecting the two boundary frames."
},
"🖼️ LTX 2.3 Reference-to-Video": {
"provider": "ltx_2_3",
"video_mode": "reference-to-video",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One paragraph with stable reference roles; for video references sample about 2 frames per source second, up to 64 frames."
},
"🎬 MiniMax H3 Text-to-Video": {
"provider": "minimax_h3",
"video_mode": "t2va",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 2048
},
"recommendation_note": "Direct three-field Base prompt conversion with enough reserve for long source scripts."
},
"🖼️ MiniMax H3 First-Frame-to-Video (I2VA)": {
"provider": "minimax_h3",
"video_mode": "i2va",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 2048
},
"recommendation_note": "Base prompt plus the official first-frame alignment line."
},
"🎞️ MiniMax H3 First/Last-to-Video (FL2VA)": {
"provider": "minimax_h3",
"video_mode": "fl2va",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 2048
},
"recommendation_note": "Base prompt plus first/last-frame alignment."
},
"🏁 MiniMax H3 Last-Frame-to-Video (L2VA)": {
"provider": "minimax_h3",
"video_mode": "l2va",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 2048
},
"recommendation_note": "Base prompt plus the official last-frame alignment line."
},
"🖼️ MiniMax H3 Reference-to-Video": {
"provider": "minimax_h3",
"video_mode": "ref2va",
"duration_required": true,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 3072
},
"recommendation_note": "Six-section Full-Reference output; for video references sample about 2 frames per source second, up to 64 frames."
},
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-timeline",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 768
},
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
},
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-timeline",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 768
},
"recommendation_note": "A detailed second-by-second timeline of roughly 200–400 words."
},
"🎥 Wan 2.2 NSFW I2V Scene (3s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-scene",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One short cinematic scene prompt."
},
"🎥 Wan 2.2 NSFW I2V Scene (5s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-scene",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One short cinematic scene prompt."
},
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-timeline",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 2048
},
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
},
"📖 Wan 2.2 NSFW I2V Scene (20s)": {
"provider": "wan_2_2",
"video_mode": "image-to-video-scene",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 1536
},
"recommendation_note": "Four continuous five-second scene prompts."
},
"🍿 Wan 2.2 NSFW T2V Timeline (3s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-timeline",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 768
},
"recommendation_note": "A detailed second-by-second timeline."
},
"🍿 Wan 2.2 NSFW T2V Timeline (5s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-timeline",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 768
},
"recommendation_note": "A detailed second-by-second timeline."
},
"🎥 Wan 2.2 NSFW T2V Scene (3s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-scene",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One short cinematic scene prompt."
},
"🎥 Wan 2.2 NSFW T2V Scene (5s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-scene",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 512
},
"recommendation_note": "One short cinematic scene prompt."
},
"🎬 Wan 2.2 NSFW T2V Timeline (20s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-timeline",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 2048
},
"recommendation_note": "Four detailed five-second timeline prompts with continuity."
},
"📖 Wan 2.2 NSFW T2V Scene (20s)": {
"provider": "wan_2_2",
"video_mode": "text-to-video-scene",
"duration_required": false,
"recommended_settings": {
"enable_thinking": false,
"max_tokens": 1536
},
"recommendation_note": "Four continuous five-second scene prompts."
}
}
}