release: v3.1.1

This commit is contained in:
Marcel Hofer
2026-11-01 09:54:01 +01:00
parent 911809c9d8
commit e4bfb22f77
25 changed files with 727 additions and 86 deletions
+47 -21
View File
@@ -47,12 +47,21 @@ from AILab_LlamaCppInstaller import (
)
import comfy.model_management as comfy_model_management
from comfy.model_management import throw_exception_if_processing_interrupted
from thinkingllm_core.audio_security import (
FFMPEG_TIMEOUT_SECONDS,
MAX_AUDIO_SECONDS,
resolve_local_audio_path,
validate_audio_duration,
validate_audio_file,
validate_audio_shape,
)
# Import shared contracts directly; AILab_QwenVL remains a compatibility facade.
sys.path.append(str(Path(__file__).parent))
from thinkingllm_core.hf_models import ensure_cuda_vram_headroom
from thinkingllm_core.media import (
MASK_FOCUS_INSTRUCTION,
MASK_PROMPT_CONTRACT_VERSION,
MASK_RECONSTRUCTION_INSTRUCTION,
apply_mask_highlight,
get_image_hash,
@@ -609,7 +618,7 @@ 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 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.",
"audio_file_path": "Optional file inside ComfyUI's input directory or an administrator-configured audio root. Files up to 256 MiB and 10 minutes are decoded to 16 kHz mono WAV.",
"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.",
"max_tokens": "Maximum new tokens to generate. Larger values give more room for reasoning but increase runtime and memory use.",
"keep_model_loaded": "Keep the GGUF model in RAM/VRAM after the run so repeated prompts skip model loading. Disable if you need memory back for other nodes.",
@@ -738,21 +747,14 @@ def _resample_mono_audio(samples, source_rate: int, target_rate: int = GGUF_AUDI
def _clean_audio_file_path(audio_file_path) -> Path | None:
if audio_file_path is None:
return None
raw_path = str(audio_file_path).strip().strip("\"'")
if not raw_path:
return None
expanded = os.path.expandvars(raw_path)
return Path(expanded).expanduser()
return resolve_local_audio_path(audio_file_path)
def _audio_file_path_to_wav_base64(audio_file_path) -> list[str]:
path = _clean_audio_file_path(audio_file_path)
if path is None:
return []
if not path.exists() or not path.is_file():
raise FileNotFoundError(f"audio_file_path does not exist or is not a file: {path}")
validate_audio_file(path)
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
raise RuntimeError("ffmpeg was not found on PATH; install FFmpeg or connect a decoded ComfyUI AUDIO input")
@@ -772,12 +774,15 @@ def _audio_file_path_to_wav_base64(audio_file_path) -> list[str]:
str(GGUF_AUDIO_TARGET_SAMPLE_RATE),
"-acodec",
"pcm_s16le",
"-t",
str(MAX_AUDIO_SECONDS + 1),
"-f",
"s16le",
"pipe:1",
],
check=False,
capture_output=True,
timeout=FFMPEG_TIMEOUT_SECONDS,
)
if result.returncode != 0:
stderr = result.stderr.decode("utf-8", errors="replace").strip()
@@ -787,6 +792,7 @@ def _audio_file_path_to_wav_base64(audio_file_path) -> list[str]:
if len(result.stdout) % 2 != 0:
raise RuntimeError("ffmpeg produced malformed 16-bit PCM audio")
validate_audio_duration(len(result.stdout) // 2, GGUF_AUDIO_TARGET_SAMPLE_RATE)
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(1)
@@ -808,6 +814,9 @@ def _audio_to_wav_base64(audio) -> list[str]:
sample_rate = int(audio.get("sample_rate") or 0)
if sample_rate <= 0:
raise ValueError(f"invalid sample_rate={audio.get('sample_rate')!r}")
shape = tuple(getattr(waveform, "shape", ()))
if shape:
validate_audio_shape(shape, sample_rate)
tensor = waveform.detach().cpu() if hasattr(waveform, "detach") else waveform
tensor = tensor.cpu() if hasattr(tensor, "cpu") else tensor
array = tensor.numpy() if hasattr(tensor, "numpy") else np.asarray(tensor)
@@ -825,6 +834,7 @@ def _audio_to_wav_base64(audio) -> list[str]:
if not np.all(np.isfinite(array)):
array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=-1.0)
mono = np.mean(array, axis=0)
validate_audio_duration(mono.size, sample_rate)
mono = _resample_mono_audio(mono, sample_rate, GGUF_AUDIO_TARGET_SAMPLE_RATE)
pcm = np.clip(mono, -1.0, 1.0)
pcm16 = (pcm * 32767.0).astype(np.int16)
@@ -850,17 +860,17 @@ def _audio_inputs_to_wav_base64(audio=None, audio_file_path="") -> tuple[list[st
else:
notes.append("Connected ComfyUI AUDIO input could not be converted to 16 kHz WAV.")
clean_path = _clean_audio_file_path(audio_file_path)
if clean_path is not None:
try:
try:
clean_path = _clean_audio_file_path(audio_file_path)
if clean_path is not None:
converted = _audio_file_path_to_wav_base64(str(clean_path))
if converted:
items.extend(converted)
print(f"[QwenVL] Audio file decoded: {clean_path}")
except Exception as exc:
note = f"audio_file_path failed: {exc}"
notes.append(note)
print(f"[QwenVL] Warning: {note}")
except Exception as exc:
note = f"audio_file_path failed: {exc}"
notes.append(note)
print(f"[QwenVL] Warning: {note}")
return items, notes
@@ -884,10 +894,10 @@ def _get_audio_hash(audio) -> str | None:
def _get_audio_file_hash(audio_file_path) -> str | None:
path = _clean_audio_file_path(audio_file_path)
if path is None:
return None
try:
path = _clean_audio_file_path(audio_file_path)
if path is None:
return None
stat = path.stat()
content = f"{path.resolve()}|{stat.st_size}|{stat.st_mtime_ns}"
return hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()[:16]
@@ -2065,6 +2075,7 @@ class QwenVLGGUFBase:
top_p=top_p,
repetition_penalty=repetition_penalty,
output_cleaner_version=OUTPUT_CLEANER_VERSION,
**({"mask_prompt_contract_version": MASK_PROMPT_CONTRACT_VERSION} if mask_hash else {}),
**duration_signature,
)
requires_audio_input = node_class in {"ThinkingLLM_Gemma4_Audio_GGUF"}
@@ -2096,7 +2107,22 @@ 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, effective_duration_seconds=effective_duration_seconds, video_prompt_contract_version=resolved_duration.get("contract_version") if resolved_duration else None)
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,
mask_prompt_contract_version=MASK_PROMPT_CONTRACT_VERSION if mask_hash else None,
)
# TEMPORARILY DISABLED CACHE FOR DEBUGGING
# Check cache first (only for random mode)
+1 -1
View File
@@ -87,7 +87,7 @@ from AILab_QwenVL_GGUF import (
GGUF_PROMPT_TOOLTIPS = {
"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.",
"prompt_text": "Type your scene idea or video description here; no separate text node is needed. The selected style transforms this text into a generation prompt. For image-to-video styles, include any source-frame details in your text because this node does not receive images.",
"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.",
"max_tokens": "Maximum new tokens for the enhanced prompt. Increase only when the model truncates useful detail.",
+5 -3
View File
@@ -41,6 +41,7 @@ from thinkingllm_core.model_access import (
model_catalog_options,
resolve_model_catalog_name,
)
from thinkingllm_core.hf_trust import trust_remote_code_for_snapshot
from thinkingllm_core.prompt_contracts import (
VIDEO_DURATION_INPUT,
apply_qwen_soft_thinking_directive,
@@ -144,7 +145,7 @@ PROMPT_STYLES = _load_prompt_styles()
PROMPT_STYLES = {CUSTOM_ONLY_STYLE: "", **PROMPT_STYLES}
PROMPT_ENHANCER_TOOLTIPS = {
"prompt_text": "Prompt text to enhance. Leave blank to emit the selected preset instruction as the base prompt.",
"prompt_text": "Type your scene idea or video description here; no separate text node is needed. The selected style transforms this text into a generation prompt. For image-to-video styles, include any source-frame details in your text because this node does not receive images.",
"enhancement_style": "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.",
"max_tokens": "Maximum new tokens for the enhanced prompt. Increase only when the model truncates useful detail.",
@@ -414,6 +415,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
progress_label=f"QwenVL PromptEnhancer HF Download: {model_name}",
hf_token=hf_token,
)
trust_remote_code = trust_remote_code_for_snapshot(model_source, repo_id)
if device_choice == "auto":
device = "cuda" if torch.cuda.is_available() else ("mps" if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() else "cpu")
@@ -451,7 +453,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
print(f"[QwenVL] Loading text model {model_name} ({quantization})")
tokenizer_kwargs = {
"local_files_only": COMMERCIAL_RELEASE,
"trust_remote_code": not COMMERCIAL_RELEASE,
"trust_remote_code": trust_remote_code,
}
def _load_tokenizer(prefer_slow: bool = False):
@@ -508,7 +510,7 @@ class ThinkingLLM_QwenVL_PromptEnhancer(QwenVLBase):
) from retry_exc
self.text_model = AutoModelForCausalLM.from_pretrained(
model_source,
trust_remote_code=not COMMERCIAL_RELEASE,
trust_remote_code=trust_remote_code,
**load_kwargs,
).eval()
self.text_model.to(device)
+7 -2
View File
@@ -25,12 +25,17 @@ _TERMINAL_SAFE_PUNCTUATION = str.maketrans(
def _terminal_safe_text(text: str) -> str:
"""Keep streamed terminal text readable through legacy Windows code pages.
"""Render untrusted streamed text without terminal control sequences.
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)
translated = str(text or "").translate(_TERMINAL_SAFE_PUNCTUATION)
return "".join(
char
for char in translated
if char in "\n\t" or (ord(char) >= 0x20 and not 0x7F <= ord(char) <= 0x9F)
)
def get_thinking_stream_display():
+25 -13
View File
@@ -3,7 +3,8 @@
"🚫 No preset (image-only)",
"💬 Custom prompt + image (no preset)",
"👁️ Remove (Mask Area)",
"🎦 LTX 2.3 NSFW I2V Scene",
"🎭 Creative Image Remix",
"🎨 Style Conversion",
"🎞️ LTX 2.3 First/Last-to-Video",
"🖼️ LTX 2.3 Reference-to-Video",
"🎬 MiniMax H3 Text-to-Video",
@@ -11,18 +12,6 @@
"🎞️ 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)",
"🎥 Wan 2.2 NSFW I2V Scene (5s)",
"🎬 Wan 2.2 NSFW I2V Timeline (20s)",
"📖 Wan 2.2 NSFW I2V Scene (20s)",
"🍿 Wan 2.2 NSFW T2V Timeline (3s)",
"🍿 Wan 2.2 NSFW T2V Timeline (5s)",
"🎥 Wan 2.2 NSFW T2V Scene (3s)",
"🎥 Wan 2.2 NSFW T2V Scene (5s)",
"🎬 Wan 2.2 NSFW T2V Timeline (20s)",
"📖 Wan 2.2 NSFW T2V Scene (20s)",
"🖼️ Tags",
"🖼️ Simple Description",
"🖼️ Detailed Description",
@@ -183,6 +172,8 @@
"qwenvl": {
"🚫 No preset (image-only)": "",
"💬 Custom prompt + image (no preset)": "",
"🎭 Creative Image Remix": "Use the connected image as the source and create one bold, cohesive image-generation prompt that transforms it into a fresh visual concept. Keep the main subject and its defining traits recognizable, but invent a surprising, fitting setting, action, atmosphere, and visual metaphor. Make the transformation feel intentional and striking rather than merely describing the source. Adapt to any image type, including photographs, illustrations, products, landscapes, renders, diagrams, and abstract art. Respect any direction in custom_prompt. Do not add adult, nude, or sexual content. Output only the final prompt, with no preface or explanation.",
"🎨 Style Conversion": "Use the connected image as the source and write one image-generation prompt that reimagines it in a clearly defined visual style. If custom_prompt specifies a target style, follow it; otherwise choose a distinctive style suited to the subject, such as paper-cut collage, clay sculpture, woodblock print, gouache illustration, or retro-futurist graphic art. Preserve the recognizable subject, key objects, action, and composition while translating materials, shapes, palette, texture, and lighting into the new style. Adapt to any image type and keep the result coherent and usable as a generation prompt. Do not add adult, nude, or sexual content. Output only the final prompt, with no preface or explanation.",
"👁️ 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\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.",
@@ -233,6 +224,27 @@
"🎥 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\nIMPORTANT: Generate actual cinematic prompt, not example text."
},
"🎦 LTX 2.3 NSFW I2V Scene": {
"system_prompt": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Turn the user's text into one fluent English video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text, never claim to have inspected a frame, and do not invent image-specific facts. Preserve the user's intent and focus on visible subject, action, pose, environment, camera movement, lighting, color, texture, and chronological motion. TARGET_DURATION_SECONDS is authoritative; express it exactly once as a natural X-second continuous shot and keep all action within that duration. Write one flowing paragraph of 120-200 words, starting directly with the main action. Output only the prompt, with no headings, notes, or reasoning."
},
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": {
"system_prompt": "Convert the user's text into one fluent English WAN 2.2 image-to-video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text and do not claim to have analyzed a frame. Preserve intent and describe one coherent 3-second action with timeline markers from 0 to 3 seconds, using a concise visual beat for each second. Keep motion physically plausible and consistent; add no unrelated characters or events. Output one continuous paragraph with no heading or reasoning."
},
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": {
"system_prompt": "Convert the user's text into one fluent English WAN 2.2 image-to-video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text and do not claim to have analyzed a frame. Preserve intent and describe one coherent 5-second action with timeline markers from 0 to 5 seconds, using a concise visual beat for each second. Keep motion physically plausible and consistent; add no unrelated characters or events. Output one continuous paragraph with no heading or reasoning."
},
"🎥 Wan 2.2 NSFW I2V Scene (3s)": {
"system_prompt": "Write one fluent English WAN 2.2 image-to-video prompt for a single 3-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's subject, action, style, and constraints. Describe a clear opening state, a compact continuous movement, camera behavior, and a natural ending state in one cinematic paragraph. No timestamps, headings, or reasoning."
},
"🎥 Wan 2.2 NSFW I2V Scene (5s)": {
"system_prompt": "Write one fluent English WAN 2.2 image-to-video prompt for a single 5-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's subject, action, style, and constraints. Describe a clear opening state, a continuous evolving movement, camera behavior, and a natural ending state in one cinematic paragraph. No timestamps, headings, or reasoning."
},
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": {
"system_prompt": "Convert the user's text into exactly four fluent English WAN 2.2 image-to-video prompts, each describing the next continuous 5-second segment of a 20-second sequence. This text-only prompt enhancer cannot see an image: rely only on details in prompt_text and do not claim to have analyzed a frame. Preserve the same subjects, appearance, setting, and visual style across all segments; progress the action causally without restarting or repeating it. Separate the four standalone prompt paragraphs with one blank line. Do not add headings, timeline labels, notes, or reasoning."
},
"📖 Wan 2.2 NSFW I2V Scene (20s)": {
"system_prompt": "Write one fluent English WAN 2.2 image-to-video prompt for a continuous 20-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's intent and maintain consistent subject identity, setting, and style throughout. Describe a clear opening state, progressive action with natural pacing, camera behavior, and a resolved ending state in one cinematic paragraph. No headings, notes, or reasoning."
},
"📝 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."
},
+24 -11
View File
@@ -1,5 +1,4 @@
import json
import os
import shutil
import subprocess
import tempfile
@@ -7,6 +6,14 @@ import wave
from pathlib import Path
import numpy as np
from thinkingllm_core.audio_security import (
FFMPEG_TIMEOUT_SECONDS,
MAX_AUDIO_SECONDS,
resolve_local_audio_path,
validate_audio_duration,
validate_audio_file,
validate_audio_shape,
)
WHISPER_MODEL_CHOICES = [
@@ -41,12 +48,7 @@ WHISPER_INSTALL_MESSAGE = (
def _clean_audio_file_path(audio_file_path) -> Path | None:
if audio_file_path is None:
return None
raw_path = str(audio_file_path).strip().strip("\"'")
if not raw_path:
return None
return Path(os.path.expandvars(raw_path)).expanduser()
return resolve_local_audio_path(audio_file_path)
def _ffmpeg_path() -> str:
@@ -60,8 +62,7 @@ def _decode_audio_file_to_wav(audio_file_path, target_path: Path) -> str:
source_path = _clean_audio_file_path(audio_file_path)
if source_path is None:
raise ValueError("audio_file_path is empty")
if not source_path.exists() or not source_path.is_file():
raise FileNotFoundError(f"audio_file_path does not exist or is not a file: {source_path}")
validate_audio_file(source_path)
result = subprocess.run(
[
@@ -78,14 +79,19 @@ def _decode_audio_file_to_wav(audio_file_path, target_path: Path) -> str:
"16000",
"-acodec",
"pcm_s16le",
"-t",
str(MAX_AUDIO_SECONDS + 1),
str(target_path),
],
check=False,
capture_output=True,
text=True,
timeout=FFMPEG_TIMEOUT_SECONDS,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg failed to decode audio_file_path: {result.stderr.strip() or 'unknown ffmpeg error'}")
with wave.open(str(target_path), "rb") as wav:
validate_audio_duration(wav.getnframes(), wav.getframerate())
return str(source_path)
@@ -98,6 +104,9 @@ def _audio_to_wav_file(audio, target_path: Path) -> str:
sample_rate = int(audio.get("sample_rate") or 0)
if sample_rate <= 0:
raise ValueError(f"invalid sample_rate={audio.get('sample_rate')!r}")
shape = tuple(getattr(waveform, "shape", ()))
if shape:
validate_audio_shape(shape, sample_rate)
tensor = waveform.detach().cpu() if hasattr(waveform, "detach") else waveform
tensor = tensor.cpu() if hasattr(tensor, "cpu") else tensor
@@ -117,6 +126,7 @@ def _audio_to_wav_file(audio, target_path: Path) -> str:
array = np.nan_to_num(array, nan=0.0, posinf=1.0, neginf=-1.0)
mono = np.mean(array, axis=0).astype(np.float32)
validate_audio_duration(mono.size, sample_rate)
if sample_rate != 16000:
target_length = max(1, int(round(float(mono.size) * 16000.0 / float(sample_rate))))
old_positions = np.linspace(0.0, float(mono.size - 1), num=mono.size, dtype=np.float64)
@@ -188,7 +198,7 @@ class ThinkingLLM_Whisper_ASR:
},
"optional": {
"audio": ("AUDIO",),
"audio_file_path": ("STRING", {"default": "", "multiline": False, "tooltip": "Optional local audio file path. M4A, MP3, WAV, FLAC, and other FFmpeg-readable files are decoded to 16 kHz mono WAV before Whisper."}),
"audio_file_path": ("STRING", {"default": "", "multiline": False, "tooltip": "Optional audio file inside ComfyUI's input directory or an administrator-configured audio root. Files up to 256 MiB and 10 minutes are decoded to 16 kHz mono WAV."}),
},
}
@@ -210,7 +220,10 @@ class ThinkingLLM_Whisper_ASR:
audio_file_path="",
):
notes: list[str] = []
clean_path = _clean_audio_file_path(audio_file_path)
try:
clean_path = _clean_audio_file_path(audio_file_path)
except Exception as exc:
return _missing_audio_result([str(exc)])
if audio is None and clean_path is None:
return _missing_audio_result()
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "ComfyUI-ThinkingLLM",
"description": "A multimodal ComfyUI AI node pack with Qwen, Gemma 4, GGUF, Whisper, image/text decision routing, typed decision fields, and live thinking output.",
"author": "goodguy1963",
"version": "3.1.0",
"version": "3.1.1",
"url": "https://github.com/goodguy1963/ComfyUI-ThinkingLLM",
"category": "image"
}
+1 -1
View File
@@ -9,5 +9,5 @@
"description": "A multimodal ComfyUI AI node pack with Qwen, Gemma 4, GGUF, Whisper, image and text decision routing, typed Boolean and Integer decision fields, and live thinking output.",
"category": "image",
"tags": ["vision", "language", "multimodal", "thinkingllm", "decision-router", "decision-fields", "qwen", "gguf", "flash_attention", "performance", "wan2.2", "video", "i2v", "t2v", "storyboard", "caching", "fork", "gpl-3.0"],
"version": "3.1.0"
"version": "3.1.1"
}
+6 -4
View File
@@ -62,7 +62,7 @@ Connect `RAW_TRACE` to a separate text display when diagnosing empty output, une
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:
The node accepts a ComfyUI `AUDIO` input or an `audio_file_path`. File paths must resolve inside ComfyUI's input directory or an administrator-configured root. Set `THINKINGLLM_AUDIO_ALLOWED_ROOTS` to add roots, separated by the operating system's path separator. Audio is limited to 10 minutes; files supplied by path are limited to 256 MiB. 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`;
@@ -72,7 +72,7 @@ ThinkingLLM verifies these conditions before sending audio to inference. Older o
### 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.
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, subject to the same path and size limits.
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.
@@ -91,6 +91,8 @@ The standard and Advanced HF/GGUF vision nodes accept an optional ComfyUI `MASK`
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.
The node instructs the model to describe only the masked content. A broader preset such as `Detailed Description` keeps its requested detail level but is scoped to that content; the model should not narrate the mask or the rest of the image.
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
@@ -114,9 +116,9 @@ Mask limitations:
## 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.
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. Adult-oriented video prompt formats are available as styles in the text-only Prompt Enhancer nodes; type the scene idea directly into their `prompt_text` field, with no separate text node required. For image-to-video styles, include a written description of the source frame because Prompt Enhancer nodes do not receive images.
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.
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. For image transformations, the vision nodes include `Creative Image Remix` and `Style Conversion`: both adapt to any connected image, preserve its recognizable subject, and avoid adult content. `Style Conversion` follows a style named in `custom_prompt`, or chooses a fitting style when none is specified.
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).
+4
View File
@@ -23,6 +23,10 @@
- Keep the same version mirrored in the `__init__.py` metadata block and `custom_nodes.json` so local ComfyUI metadata and manager-facing metadata stay aligned.
- README still contains historical upstream links and changelog references beyond the top-level fork identity; that is acceptable for attribution, but a docs pass should normalize publish-facing instructions before release.
## ThinkingLLM 3.1.1 release record
Version metadata is synchronized at **3.1.1** in `pyproject.toml`, `__init__.py`, and `custom_nodes.json`. This release expands audio, mask-focused image, and video prompt workflows, and adds security controls for audio paths and resource use, pinned Transformers remote code, and terminal output. See `update.md` for the change summary and focused validation.
## ThinkingLLM 3.1.0 release record
Version metadata is synchronized at **3.1.0** in `pyproject.toml`, `__init__.py`, and `custom_nodes.json`. The release adds typed Boolean and bounded Integer Decision Fields for image and text inputs, local and API inference, dynamic native-type output sockets, and a readable `decision` summary alongside raw `decision_json`. The feature idea was contributed by [JMarimon1](https://www.reddit.com/user/JMarimon1/); see `update.md` for detailed changes and verification.
+2
View File
@@ -100,6 +100,8 @@ The MiniMax H3/Qwen3-VL 32B conditioning checkpoint is excluded because it is no
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.
Transformers remote Python code is disabled by default. To enable a custom architecture, an administrator must review it and set `THINKINGLLM_TRUSTED_HF_MODELS` to one or more comma-separated `org/repository@<40-character-commit-SHA>` entries. ThinkingLLM downloads those repositories at the pinned commit and enables custom code only for that verified snapshot. Local model directories and unpinned downloads continue to load with remote code disabled.
Commercial-release dropdowns include only reviewed, locked models and group entries by rights status. This restriction is intentionally separate from Community behavior.
## Platform notes
+2 -2
View File
@@ -1,9 +1,9 @@
[project]
name = "ComfyUI-ThinkingLLM"
description = "A multimodal ComfyUI AI node pack with Qwen, Gemma 4, GGUF, Whisper, image/text decision routing, typed decision fields, and live thinking output."
version = "3.1.0"
version = "3.1.1"
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"]
dependencies = ["transformers>=5.2.0", "torch", "huggingface-hub", "hf_xet", "psutil", "numpy", "Pillow", "opencv-python", "bitsandbytes", "accelerate", "sentencepiece", "tiktoken", "faster-whisper", "av>=17,<19", "llama-cpp-python"]
[project.urls]
Repository = "https://github.com/goodguy1963/ComfyUI-ThinkingLLM"
+2
View File
@@ -14,6 +14,8 @@ accelerate
sentencepiece
tiktoken
faster-whisper
# ComfyUI requires PyAV >=17; faster-whisper's metadata_errors call breaks on PyAV 19.
av>=17,<19
# For running GGUF models with vision support
# The [server] extra installs necessary components for multimodal models
+168 -12
View File
@@ -127,6 +127,7 @@ def build_loader_test_stubs() -> dict[str, types.ModuleType]:
"folder_paths",
models_dir=str(PKG),
folder_names_and_paths={},
get_input_directory=lambda: str(PKG),
get_folder_paths=lambda *args, **kwargs: [],
get_filename_list=lambda *args, **kwargs: [],
get_full_path_or_raise=lambda category, filename: str(PKG / filename),
@@ -368,14 +369,22 @@ def iter_catalog_display_names() -> list[str]:
class TestMaskFocusedImageAnalysis(unittest.TestCase):
@classmethod
def setUpClass(cls):
module = parse_source("thinkingllm_core/media.py")
function_node = next(
node
for node in parse_source("thinkingllm_core/media.py").body
for node in module.body
if isinstance(node, ast.FunctionDef) and node.name == "apply_mask_highlight"
)
namespace = {"hashlib": hashlib, "torch": torch}
exec(compile(ast.fix_missing_locations(ast.Module(body=[function_node], type_ignores=[])), "thinkingllm_core/media.py", "exec"), namespace)
cls.apply_mask_highlight = staticmethod(namespace["apply_mask_highlight"])
focus_assignment = next(
node
for node in module.body
if isinstance(node, ast.Assign)
and any(isinstance(target, ast.Name) and target.id == "MASK_FOCUS_INSTRUCTION" for target in node.targets)
)
cls.mask_focus_instruction = ast.literal_eval(focus_assignment.value)
def test_mask_dims_rgb_and_preserves_alpha_without_mutating_input(self):
image = torch.ones((1, 2, 2, 4), dtype=torch.float32)
@@ -445,11 +454,23 @@ class TestMaskFocusedImageAnalysis(unittest.TestCase):
self.assertIn('image_hash = f"{image_hash}:{mask_hash}"', gguf_source)
self.assertIn('prompt = f"{prompt}\\n\\n{MASK_FOCUS_INSTRUCTION}".strip()', hf_source)
self.assertIn("MASK_RECONSTRUCTION_INSTRUCTION", gguf_source)
self.assertIn("MASK_PROMPT_CONTRACT_VERSION", hf_source)
self.assertIn("MASK_PROMPT_CONTRACT_VERSION", gguf_source)
self.assertIn(
"mask_preview, _ = apply_mask_highlight(image, mask, mode=mask_mode)",
gguf_source,
)
def test_focus_instruction_suppresses_meta_language_and_overrides_full_image_scope(self):
instruction = self.mask_focus_instruction.lower()
self.assertIn("start directly with its concrete identity or category", instruction)
self.assertIn("omit every other subject, background, scenery", instruction)
self.assertIn("do not mention the visual distinction, a mask", instruction)
self.assertIn("overrides any earlier request", instruction)
self.assertNotIn("normal brightness", instruction)
self.assertNotIn("selected area", instruction)
class TestTerminalDisplayHelper(unittest.TestCase):
def test_helper_file_exists(self):
@@ -667,8 +688,11 @@ class TestModelRecommendations(unittest.TestCase):
self.assertIn("if COMMERCIAL_RELEASE:\n _mark_installed_hf_catalog_models()", hf_models_source)
self.assertIn("else:\n _scan_local_hf_models()", hf_models_source)
self.assertIn('"local_files_only": COMMERCIAL_RELEASE', hf_runtime_source)
self.assertIn("trust_remote_code=not COMMERCIAL_RELEASE", hf_runtime_source)
self.assertIn("trust_remote_code_for_snapshot", hf_runtime_source)
self.assertIn("trust_remote_code=trust_remote_code", hf_runtime_source)
self.assertIn('"local_files_only": COMMERCIAL_RELEASE', enhancer_source)
self.assertIn("trust_remote_code_for_snapshot", enhancer_source)
self.assertIn('"trust_remote_code": trust_remote_code', enhancer_source)
self.assertIn("commercial release mode forbids runtime package installation", enhancer_source)
def test_recommendation_rules_have_sources(self):
@@ -950,22 +974,60 @@ class TestModelRecommendations(unittest.TestCase):
self.assertIn("log_llm_input(", source)
self.assertIn("no LLM call was made", source)
def test_ltx_presets_are_available_for_vision_and_prompt_enhancers(self):
def test_nsfw_i2v_presets_are_only_selectable_from_prompt_enhancers(self):
data = json.loads(Path("AILab_System_Prompts.json").read_text(encoding="utf-8"))
self.assertIn("🎦 LTX 2.3 NSFW I2V Scene", data["_preset_prompts"])
self.assertNotIn("🎦 LTX 2.3 NSFW I2V Scene", data["_preset_prompts"])
self.assertIn("🎦 LTX 2.3 NSFW I2V Scene", data["qwenvl"])
self.assertIn("🎦 LTX 2.3 NSFW I2V Scene", data["qwen_text"]["styles"])
self.assertIn("📖 LTX 2.3 NSFW T2V Scene", data["qwen_text"]["styles"])
vision_prompt = data["qwenvl"]["🎦 LTX 2.3 NSFW I2V Scene"]
text_prompt = data["qwen_text"]["styles"]["📖 LTX 2.3 NSFW T2V Scene"]["system_prompt"]
text_prompt = data["qwen_text"]["styles"]["🎦 LTX 2.3 NSFW I2V Scene"]["system_prompt"]
self.assertIn("LTX 2.3", vision_prompt)
self.assertIn("image first", vision_prompt)
self.assertIn("cannot see an image", text_prompt)
self.assertIn("TARGET_DURATION_SECONDS", text_prompt)
self.assertIn("LTX 2.3", text_prompt)
self.assertIn("Do not change the user input intent", text_prompt)
self.assertNotIn("Example:", vision_prompt)
self.assertNotIn("Examples:", text_prompt)
with mock.patch.dict(sys.modules, build_loader_test_stubs(), clear=False):
package = load_thinkingllm_loader_subset(
[
"AILab_QwenVL.py",
"AILab_QwenVL_GGUF.py",
"AILab_QwenVL_PromptEnhancer.py",
"AILab_QwenVL_GGUF_PromptEnhancer.py",
]
)
nsfw_labels = {label for label in data["_video_presets"] if "NSFW" in label}
for node_name in (
"ThinkingLLM_QwenVL",
"ThinkingLLM_QwenVL_Advanced",
"ThinkingLLM_QwenVL_GGUF",
"ThinkingLLM_QwenVL_GGUF_Advanced",
):
values = package.NODE_CLASS_MAPPINGS[node_name].INPUT_TYPES()["required"]["preset_prompt"][0]
self.assertTrue(nsfw_labels.isdisjoint(values), node_name)
for node_name, widget_name in (
("ThinkingLLM_QwenVL_PromptEnhancer", "enhancement_style"),
("ThinkingLLM_QwenVL_GGUF_PromptEnhancer", "preset_system_prompt"),
):
values = package.NODE_CLASS_MAPPINGS[node_name].INPUT_TYPES()["required"][widget_name][0]
self.assertTrue(nsfw_labels.issubset(values), node_name)
def test_creative_and_style_conversion_presets_are_general_and_non_adult(self):
data = json.loads(Path("AILab_System_Prompts.json").read_text(encoding="utf-8"))
presets = ("🎭 Creative Image Remix", "🎨 Style Conversion")
self.assertTrue(set(presets).issubset(data["_preset_prompts"]))
for label in presets:
with self.subTest(label=label):
prompt = data["qwenvl"][label]
self.assertIn("any image type", prompt)
self.assertIn("Do not add adult, nude, or sexual content", prompt)
self.assertIn("Output only the final prompt", prompt)
def test_video_preset_duration_and_recommendation_metadata_are_complete(self):
data = json.loads((PKG / "AILab_System_Prompts.json").read_text(encoding="utf-8"))
metadata = data["_video_presets"]
@@ -997,6 +1059,10 @@ class TestModelRecommendations(unittest.TestCase):
self.assertEqual(set(metadata), duration_labels | wan_labels)
self.assertNotIn("🖼️ MiniMax H3 Reference-to-Video", data["qwen_text"]["styles"])
nsfw_labels = {label for label in metadata if "NSFW" in label}
styles = data["qwen_text"]["styles"]
self.assertTrue(nsfw_labels.issubset(styles))
self.assertTrue(nsfw_labels.isdisjoint(data["_preset_prompts"]))
for label, entry in metadata.items():
with self.subTest(label=label):
self.assertIn(entry["provider"], {"ltx_2_3", "minimax_h3", "wan_2_2"})
@@ -1006,7 +1072,6 @@ class TestModelRecommendations(unittest.TestCase):
self.assertIn(entry["recommended_settings"]["max_tokens"], {512, 768, 1024, 1536, 2048, 3072})
self.assertTrue(entry["recommendation_note"])
styles = data["qwen_text"]["styles"]
self.assertIn("🍿 Wan 2.2 NSFW T2V Timeline (3s)", styles)
self.assertNotIn("🍿 Wan 2.2 NSFW T2V Timeline (3)", styles)
wan_t2v_20 = styles["📖 Wan 2.2 NSFW T2V Scene (20s)"]["system_prompt"]
@@ -1119,6 +1184,14 @@ class TestModelRecommendations(unittest.TestCase):
video_prompt_contract_version=module.VIDEO_PROMPT_CONTRACT_VERSION,
),
)
self.assertNotEqual(
module.get_cache_key("m", "p", "x", mask_prompt_contract_version=1),
module.get_cache_key("m", "p", "x", mask_prompt_contract_version=2),
)
self.assertNotEqual(
module.build_node_input_signature(mask_prompt_contract_version=1),
module.build_node_input_signature(mask_prompt_contract_version=2),
)
self.assertEqual(
module.build_node_input_signature(effective_duration_seconds=five_seconds),
module.build_node_input_signature(effective_duration_seconds=five_point_one_seconds),
@@ -1673,6 +1746,11 @@ class TestModelRecommendations(unittest.TestCase):
self.assertIn("metadata?.recommended_settings", source)
self.assertIn("Info only: your saved widget values are not changed.", source)
self.assertIn('["model_name", "enable_thinking", ...PRESET_WIDGET_NAMES]', source)
self.assertIn("function buildPromptEnhancerPreviewText(node, payload)", source)
self.assertIn('buildPromptEnhancerPreviewText(node, payload)', source)
self.assertIn('findWidget(node, "prompt_text")', source)
self.assertIn('findWidget(node, "custom_system_prompt")', source)
self.assertIn('widget.inputEl.addEventListener("input", () => queueRecommendationRefresh(node))', source)
def test_duration_visibility_preserves_widget_type_and_serialization_slot(self):
source = read_source("web/js/appearance.js")
@@ -1865,6 +1943,24 @@ class TestModelRecommendations(unittest.TestCase):
self.assertEqual(stream.getvalue(), '"That\'s useful... really" -- Peter\n')
self.assertEqual(original, "\u201cThat\u2019s useful\u2026 really\u201d \u2014 Peter")
def test_terminal_display_strips_terminal_control_sequences(self):
from AILab_StreamDisplay import TerminalStreamDisplay, _terminal_safe_text
import io
display = TerminalStreamDisplay("UnitTest", compact=True, line_width=80)
stream = io.StringIO()
malicious = "safe\x1b]52;c;SGVsbG8=\x07\x1b[2J\x1b[999Dtext"
with contextlib.redirect_stdout(stream):
display.start_stage("STREAMING")
display.push_compact(malicious)
display.end_stage()
output = stream.getvalue()
self.assertIn("safe", output)
self.assertIn("text", output)
self.assertNotRegex(output, "[\\x00-\\x08\\x0b-\\x1f\\x7f-\\x9f]")
self.assertEqual("first line\nsecond\tline", _terminal_safe_text("first line\nsecond\tline"))
def test_stream_degeneracy_guard_catches_repeated_word_loop(self):
from AILab_StreamDisplay import StreamDegenerationError, StreamDegenerationGuard, strip_degenerate_repetition
@@ -2325,6 +2421,44 @@ class TestHuggingFaceTokenSupport(unittest.TestCase):
self.assertEqual(1, len(download_targets))
self.assertNotEqual(target, download_targets[0])
def test_hf_snapshot_downloads_and_marks_only_the_pinned_revision(self):
module = self._load_qwenvl_with_stubs()
access_globals = module._download_model_snapshot.__globals__
repo_id = "example/trusted-model"
revision = "a" * 40
list_revisions = []
download_revisions = []
class FakeHfApi:
def __init__(self, *args, **kwargs):
pass
def list_repo_files(self, **kwargs):
list_revisions.append(kwargs.get("revision"))
return ["config.json", "model.safetensors", "processor_config.json"]
def fake_snapshot_download(**kwargs):
download_revisions.append(kwargs.get("revision"))
staging = Path(kwargs["local_dir"])
staging.mkdir(parents=True)
(staging / "config.json").write_text("{}", encoding="utf-8")
(staging / "processor_config.json").write_text("{}", encoding="utf-8")
(staging / "model.safetensors").write_bytes(b"weights")
with tempfile.TemporaryDirectory() as temp_dir:
target = Path(temp_dir) / "pinned-model" / revision
with mock.patch.dict(
access_globals,
{"HfApi": FakeHfApi, "snapshot_download": fake_snapshot_download},
):
module._download_model_snapshot(repo_id, target, require_processor=True, revision=revision)
marker = target.parent / f".{target.name}.thinkingllm-trust.json"
self.assertEqual([revision], list_revisions)
self.assertEqual([revision], download_revisions)
self.assertTrue(marker.is_file())
self.assertEqual(b"weights", (target / "model.safetensors").read_bytes())
def test_hf_snapshot_validation_failure_does_not_modify_existing_target(self):
module = self._load_qwenvl_with_stubs()
access_globals = module._download_model_snapshot.__globals__
@@ -2879,7 +3013,8 @@ class TestGGUFAdvancedWorkflowCompatibility(unittest.TestCase):
def test_whisper_asr_returns_install_message_when_backend_missing(self):
node_cls = load_thinkingllm_loader_subset(["AILab_WhisperASR.py"]).NODE_CLASS_MAPPINGS["ThinkingLLM_Whisper_ASR"]
with mock.patch.dict(sys.modules, {"faster_whisper": None}, clear=False):
folder_paths_stub = build_stub_module("folder_paths", get_input_directory=lambda: str(PKG))
with mock.patch.dict(sys.modules, {"folder_paths": folder_paths_stub, "faster_whisper": None}, clear=False):
transcript, segments_json, raw_trace = node_cls().process(
model_size="base",
language="auto",
@@ -2927,7 +3062,12 @@ class TestGGUFAdvancedWorkflowCompatibility(unittest.TestCase):
wav.setframerate(16000)
wav.writeframes(b"\0\0" * 1600)
with mock.patch.dict(sys.modules, {"faster_whisper": fake_backend}, clear=False):
folder_paths_stub = build_stub_module("folder_paths", get_input_directory=lambda: str(PKG))
with mock.patch.dict(
sys.modules,
{"folder_paths": folder_paths_stub, "faster_whisper": fake_backend},
clear=False,
), mock.patch.dict(module.os.environ, {"THINKINGLLM_AUDIO_ALLOWED_ROOTS": temp_dir}, clear=False):
transcript, segments_json, raw_trace = module.ThinkingLLM_Whisper_ASR().process(
model_size="base",
language="de",
@@ -2975,7 +3115,12 @@ class TestGGUFAdvancedWorkflowCompatibility(unittest.TestCase):
wav.setframerate(16000)
wav.writeframes(b"\0\0" * 1600)
with mock.patch.dict(sys.modules, {"faster_whisper": fake_backend}, clear=False):
folder_paths_stub = build_stub_module("folder_paths", get_input_directory=lambda: str(PKG))
with mock.patch.dict(
sys.modules,
{"folder_paths": folder_paths_stub, "faster_whisper": fake_backend},
clear=False,
), mock.patch.dict(module.os.environ, {"THINKINGLLM_AUDIO_ALLOWED_ROOTS": temp_dir}, clear=False):
transcript, _, raw_trace = module.ThinkingLLM_Whisper_ASR().process(
model_size="small",
language="auto",
@@ -3023,7 +3168,12 @@ class TestGGUFAdvancedWorkflowCompatibility(unittest.TestCase):
wav.setframerate(16000)
wav.writeframes(b"\0\0" * 1600)
with mock.patch.dict(sys.modules, {"faster_whisper": fake_backend}, clear=False):
folder_paths_stub = build_stub_module("folder_paths", get_input_directory=lambda: str(PKG))
with mock.patch.dict(
sys.modules,
{"folder_paths": folder_paths_stub, "faster_whisper": fake_backend},
clear=False,
), mock.patch.dict(module.os.environ, {"THINKINGLLM_AUDIO_ALLOWED_ROOTS": temp_dir}, clear=False):
transcript, _, raw_trace = module.ThinkingLLM_Whisper_ASR().process(
model_size="small",
language="auto",
@@ -3107,7 +3257,13 @@ class TestGGUFAdvancedWorkflowCompatibility(unittest.TestCase):
capture_output=True,
)
encoded = module._audio_file_path_to_wav_base64(str(m4a_path))
folder_paths_stub = build_stub_module("folder_paths", get_input_directory=lambda: str(PKG))
with mock.patch.dict(sys.modules, {"folder_paths": folder_paths_stub}, clear=False), mock.patch.dict(
module.os.environ,
{"THINKINGLLM_AUDIO_ALLOWED_ROOTS": temp_dir},
clear=False,
):
encoded = module._audio_file_path_to_wav_base64(str(m4a_path))
self.assertEqual(len(encoded), 1)
with wave.open(io.BytesIO(base64.b64decode(encoded[0])), "rb") as wav:
+96
View File
@@ -0,0 +1,96 @@
import os
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from thinkingllm_core.audio_security import (
AUDIO_ALLOWED_ROOTS_ENV,
MAX_AUDIO_FILE_BYTES,
MAX_AUDIO_SECONDS,
resolve_local_audio_path,
validate_audio_duration,
validate_audio_file,
validate_audio_shape,
)
from thinkingllm_core.hf_trust import (
TRUSTED_HF_MODELS_ENV,
get_trusted_hf_revision,
mark_trusted_hf_snapshot,
trust_remote_code_for_snapshot,
)
class TestAudioPathSecurity(unittest.TestCase):
def test_audio_path_must_stay_under_an_allowed_root(self):
with tempfile.TemporaryDirectory() as allowed, tempfile.TemporaryDirectory() as outside:
inside_file = Path(allowed) / "inside.wav"
inside_file.touch()
outside_file = Path(outside) / "private.wav"
outside_file.touch()
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(inside_file.resolve(), resolve_local_audio_path(inside_file, default_roots=[allowed]))
with self.assertRaisesRegex(ValueError, "inside an allowed audio directory"):
resolve_local_audio_path(outside_file, default_roots=[allowed])
def test_audio_path_symlink_cannot_escape_allowed_root(self):
with tempfile.TemporaryDirectory() as allowed, tempfile.TemporaryDirectory() as outside:
outside_file = Path(outside) / "private.wav"
outside_file.touch()
link = Path(allowed) / "linked.wav"
try:
link.symlink_to(outside_file)
except (OSError, NotImplementedError) as exc:
self.skipTest(f"symlink creation is unavailable: {exc}")
with mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ValueError, "inside an allowed audio directory"):
resolve_local_audio_path(link, default_roots=[allowed])
def test_admin_audio_roots_and_processing_limits_are_enforced(self):
with tempfile.TemporaryDirectory() as configured:
audio = Path(configured) / "audio.wav"
audio.touch()
with mock.patch.dict(os.environ, {AUDIO_ALLOWED_ROOTS_ENV: configured}, clear=True):
self.assertEqual(audio.resolve(), resolve_local_audio_path("audio.wav", default_roots=[]))
with self.assertRaisesRegex(ValueError, f"{MAX_AUDIO_SECONDS}-second"):
validate_audio_duration((MAX_AUDIO_SECONDS + 1) * 16000, 16000)
with self.assertRaisesRegex(ValueError, "sample rate"):
validate_audio_duration(16000, 384000)
with self.assertRaisesRegex(ValueError, "sample limit"):
validate_audio_shape((1, 2, MAX_AUDIO_SECONDS * 96_000), 96000)
with tempfile.TemporaryDirectory() as root:
too_large = Path(root) / "large.wav"
with too_large.open("wb") as stream:
stream.truncate(MAX_AUDIO_FILE_BYTES + 1)
with self.assertRaisesRegex(ValueError, "size limit"):
validate_audio_file(too_large)
class TestHuggingFaceRemoteCodeTrust(unittest.TestCase):
def test_trust_requires_explicit_repo_commit_and_matching_snapshot_marker(self):
repo_id = "example/custom-model"
revision = "a" * 40
with tempfile.TemporaryDirectory() as temp_dir:
snapshot = Path(temp_dir) / revision
snapshot.mkdir()
with mock.patch.dict(os.environ, {TRUSTED_HF_MODELS_ENV: f"{repo_id}@{revision}"}, clear=True):
self.assertEqual(revision, get_trusted_hf_revision(repo_id))
self.assertFalse(trust_remote_code_for_snapshot(snapshot, repo_id))
mark_trusted_hf_snapshot(snapshot, repo_id, revision)
self.assertTrue(trust_remote_code_for_snapshot(snapshot, repo_id))
self.assertFalse(trust_remote_code_for_snapshot(snapshot, "example/other-model"))
os.environ["THINKINGLLM_COMMERCIAL_RELEASE"] = "1"
self.assertFalse(trust_remote_code_for_snapshot(snapshot, repo_id))
def test_trust_configuration_rejects_non_commit_revisions(self):
with mock.patch.dict(os.environ, {TRUSTED_HF_MODELS_ENV: "example/model@main"}, clear=True):
with self.assertRaisesRegex(ValueError, "40-character commit SHA"):
get_trusted_hf_revision("example/model")
if __name__ == "__main__":
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
"""Constrain workflow-provided audio inputs and their processing cost."""
import math
import os
from pathlib import Path
MAX_AUDIO_SECONDS = 600
MAX_AUDIO_FILE_BYTES = 256 * 1024 * 1024
FFMPEG_TIMEOUT_SECONDS = 120
MAX_AUDIO_SAMPLE_RATE = 192_000
MAX_AUDIO_ELEMENTS = MAX_AUDIO_SECONDS * 48_000 * 2
AUDIO_ALLOWED_ROOTS_ENV = "THINKINGLLM_AUDIO_ALLOWED_ROOTS"
def _configured_roots(default_roots=None) -> list[Path]:
if default_roots is None:
try:
import folder_paths
default_roots = [folder_paths.get_input_directory()]
except Exception as exc:
raise RuntimeError("Unable to determine ComfyUI's input directory for audio path validation") from exc
roots: list[Path] = []
configured = list(default_roots or [])
extra_roots = os.environ.get(AUDIO_ALLOWED_ROOTS_ENV, "")
configured.extend(root for root in extra_roots.split(os.pathsep) if root.strip())
for value in configured:
root = Path(os.path.expandvars(str(value))).expanduser().resolve(strict=True)
if not root.is_dir():
raise ValueError(f"Configured audio root is not a directory: {root}")
if root not in roots:
roots.append(root)
if not roots:
raise RuntimeError("No audio input directories are configured")
return roots
def resolve_local_audio_path(audio_file_path, *, default_roots=None) -> Path | None:
"""Resolve an audio path only when its canonical target is in an allowed root."""
if audio_file_path is None:
return None
raw_path = str(audio_file_path).strip().strip("\"'")
if not raw_path:
return None
roots = _configured_roots(default_roots)
candidate = Path(os.path.expandvars(raw_path)).expanduser()
candidates = [candidate] if candidate.is_absolute() else [root / candidate for root in roots] + [candidate]
for path in candidates:
resolved = path.resolve(strict=False)
if any(resolved == root or root in resolved.parents for root in roots):
return resolved
allowed = ", ".join(str(root) for root in roots)
raise ValueError(f"audio_file_path must resolve inside an allowed audio directory: {allowed}")
def validate_audio_file(path: Path) -> None:
if not path.exists() or not path.is_file():
raise FileNotFoundError(f"audio_file_path does not exist or is not a file: {path}")
if path.stat().st_size > MAX_AUDIO_FILE_BYTES:
raise ValueError(f"audio_file_path exceeds the {MAX_AUDIO_FILE_BYTES // (1024 * 1024)} MiB size limit")
def validate_audio_duration(sample_count: int, sample_rate: int) -> None:
if sample_rate <= 0:
raise ValueError(f"invalid sample_rate={sample_rate!r}")
if sample_rate > MAX_AUDIO_SAMPLE_RATE:
raise ValueError(f"audio sample rate exceeds the {MAX_AUDIO_SAMPLE_RATE} Hz limit")
if sample_count < 0 or sample_count > MAX_AUDIO_SECONDS * sample_rate:
raise ValueError(f"audio input exceeds the {MAX_AUDIO_SECONDS}-second duration limit")
def validate_audio_shape(shape, sample_rate: int) -> None:
dimensions = tuple(int(value) for value in shape)
if not dimensions or any(value < 0 for value in dimensions):
raise ValueError("invalid audio waveform shape")
validate_audio_duration(max(dimensions), sample_rate)
if math.prod(dimensions) > MAX_AUDIO_ELEMENTS:
raise ValueError("audio waveform exceeds the decoded sample limit")
+19 -2
View File
@@ -25,6 +25,7 @@ from thinkingllm_core.model_access import (
normalize_commercial_status,
resolve_model_catalog_name,
)
from thinkingllm_core.hf_trust import get_trusted_hf_revision, trust_remote_code_for_snapshot
from thinkingllm_core.prompt_contracts import VIDEO_PRESET_METADATA
try:
@@ -668,7 +669,8 @@ def ensure_model(model_name, require_processor=False, node_id=None, progress_lab
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)
trusted_revision = get_trusted_hf_revision(repo_id)
installed_target = None if trusted_revision else _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)
@@ -689,13 +691,27 @@ def ensure_model(model_name, require_processor=False, node_id=None, progress_lab
models_dir = _hf_qwen_vl_dirs()[0]
models_dir.mkdir(parents=True, exist_ok=True)
target = models_dir / repo_id.split("/")[-1]
target = (
models_dir / ".thinkingllm-trusted" / repo_id.split("/")[-1] / trusted_revision
if trusted_revision
else 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):
if trusted_revision and not trust_remote_code_for_snapshot(target, repo_id):
raise PermissionError(
f"[QwenVL] Existing snapshot for {repo_id} is not verified for the configured pinned revision; "
"move the directory aside before retrying the trusted download."
)
enforce_model_access(info, model_name, local_exists=True, hf_token=hf_token)
return str(target)
if trusted_revision and target.exists():
raise PermissionError(
f"[QwenVL] Incomplete trusted snapshot for {repo_id}: {target}. "
"Move the directory aside before retrying the pinned download."
)
enforce_model_access(info, repo_id, local_exists=False, hf_token=hf_token)
@@ -709,6 +725,7 @@ def ensure_model(model_name, require_processor=False, node_id=None, progress_lab
node_id=node_id,
progress_label=progress_label or f"QwenVL HF Download: {model_name}",
hf_token=hf_token,
revision=trusted_revision,
)
if not _model_snapshot_has_required_files(target, require_processor=require_processor):
+27 -4
View File
@@ -37,11 +37,13 @@ from thinkingllm_core.hf_models import (
)
from thinkingllm_core.media import (
MASK_FOCUS_INSTRUCTION,
MASK_PROMPT_CONTRACT_VERSION,
apply_mask_highlight,
get_image_hash,
get_video_hash,
)
from thinkingllm_core.model_access import resolve_model_catalog_name
from thinkingllm_core.hf_trust import trust_remote_code_for_snapshot
from thinkingllm_core.prompt_contracts import (
apply_qwen_soft_thinking_directive,
apply_video_duration_context,
@@ -167,6 +169,7 @@ class QwenVLBase:
progress_label=f"QwenVL HF Download: {model_name}",
hf_token=hf_token,
)
trust_remote_code = trust_remote_code_for_snapshot(model_path, model_info.get("repo_id"))
quant_config, dtype = quantization_config(model_name, quant)
# Handle attention mode for loading
@@ -214,7 +217,11 @@ class QwenVLBase:
if quant_config:
load_kwargs["quantization_config"] = quant_config
self.model = AutoModelForVision2Seq.from_pretrained(model_path, **load_kwargs).eval()
self.model = AutoModelForVision2Seq.from_pretrained(
model_path,
trust_remote_code=trust_remote_code,
**load_kwargs,
).eval()
# Apply SageAttention patching if needed
if attn_impl == "sage":
@@ -245,12 +252,12 @@ class QwenVLBase:
self.processor = AutoProcessor.from_pretrained(
model_path,
local_files_only=COMMERCIAL_RELEASE,
trust_remote_code=not COMMERCIAL_RELEASE,
trust_remote_code=trust_remote_code,
)
self.tokenizer = AutoTokenizer.from_pretrained(
model_path,
local_files_only=COMMERCIAL_RELEASE,
trust_remote_code=not COMMERCIAL_RELEASE,
trust_remote_code=trust_remote_code,
)
# Detect architecture from config.json instead of relying on model name
hf_model_type = read_hf_model_type(model_path)
@@ -560,6 +567,7 @@ class QwenVLBase:
temperature=temperature,
top_p=top_p,
repetition_penalty=repetition_penalty,
**({"mask_prompt_contract_version": MASK_PROMPT_CONTRACT_VERSION} if mask_hash else {}),
**duration_signature,
)
@@ -586,7 +594,22 @@ class QwenVLBase:
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)
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,
mask_prompt_contract_version=MASK_PROMPT_CONTRACT_VERSION if mask_hash else None,
)
# Check cache first (only for random mode)
if cache_key in PROMPT_CACHE:
+85
View File
@@ -0,0 +1,85 @@
"""Operator-controlled, revision-pinned trust for Hugging Face repository code."""
import json
import os
import re
import tempfile
from pathlib import Path
TRUSTED_HF_MODELS_ENV = "THINKINGLLM_TRUSTED_HF_MODELS"
_COMMIT_REVISION = re.compile(r"[0-9a-fA-F]{40}\Z")
def get_trusted_hf_revision(repo_id: str) -> str | None:
"""Return an explicitly configured immutable commit for a repository."""
if os.environ.get("THINKINGLLM_COMMERCIAL_RELEASE") == "1" or not repo_id:
return None
raw = os.environ.get(TRUSTED_HF_MODELS_ENV, "").strip()
if not raw:
return None
trusted: dict[str, str] = {}
for item in raw.split(","):
entry = item.strip()
configured_repo, separator, revision = entry.rpartition("@")
if not separator or not configured_repo or not _COMMIT_REVISION.fullmatch(revision):
raise ValueError(
f"{TRUSTED_HF_MODELS_ENV} entries must use repo_id@<40-character commit SHA>"
)
normalized_revision = revision.lower()
existing = trusted.get(configured_repo)
if existing is not None and existing != normalized_revision:
raise ValueError(f"Conflicting pinned revisions configured for Hugging Face repository {configured_repo}")
trusted[configured_repo] = normalized_revision
return trusted.get(repo_id)
def _trust_marker_path(model_path) -> Path:
path = Path(model_path)
return path.parent / f".{path.name}.thinkingllm-trust.json"
def mark_trusted_hf_snapshot(model_path, repo_id: str, revision: str) -> None:
"""Record that this isolated snapshot was downloaded from the pinned commit."""
if not _COMMIT_REVISION.fullmatch(str(revision)):
raise ValueError("A 40-character commit SHA is required to mark a trusted snapshot")
marker = _trust_marker_path(model_path)
marker.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"repo_id": repo_id, "revision": revision.lower()}, sort_keys=True)
temporary_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=marker.parent,
prefix=f".{marker.name}.",
suffix=".tmp",
delete=False,
) as stream:
temporary_path = Path(stream.name)
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_path, marker)
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
def trust_remote_code_for_snapshot(model_path, repo_id: str | None) -> bool:
"""Enable Transformers custom Python only for a verified pinned snapshot."""
if not repo_id:
return False
revision = get_trusted_hf_revision(repo_id)
if revision is None:
return False
try:
marker = json.loads(_trust_marker_path(model_path).read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return False
return marker == {"repo_id": repo_id, "revision": revision}
+8 -3
View File
@@ -4,10 +4,15 @@ import hashlib
import torch
MASK_PROMPT_CONTRACT_VERSION = 2
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."
"Describe only the subject or object visually distinguished from the rest of the image. Start directly with its "
"concrete identity or category, then its visible appearance or action; no preface. Use the rest of the image "
"only to recognize the target. Omit every other subject, background, scenery, and unrelated scene detail. Do not "
"mention the visual distinction, a mask, highlighting, selection, brightness, dimming, or image processing. This "
"scope overrides any earlier request to describe the whole image. Preserve its requested level of detail, but "
"apply it only to the target."
)
MASK_RECONSTRUCTION_INSTRUCTION = (
+15 -2
View File
@@ -2,10 +2,12 @@
import inspect
import os
import re
import shutil
import tempfile
import time
from pathlib import Path
from thinkingllm_core.hf_trust import get_trusted_hf_revision, mark_trusted_hf_snapshot
try:
from huggingface_hub import HfApi, hf_hub_download, snapshot_download
@@ -436,9 +438,13 @@ def _validate_transformers_repo_files(repo_id: str, repo_files: list[str], *, re
)
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:
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, revision: str | None = None) -> None:
if COMMERCIAL_RELEASE:
raise RuntimeError("[QwenVL] Commercial release mode only permits preinstalled, hash-locked models.")
if revision is not None and not re.fullmatch(r"[0-9a-f]{40}", revision):
raise ValueError("Trusted Hugging Face downloads require a 40-character commit SHA")
if revision is not None and target.exists():
raise FileExistsError(f"Refusing to reuse an existing trusted model directory: {target}")
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 = {}
@@ -448,7 +454,10 @@ def _download_model_snapshot(repo_id: str, target: Path, *, require_processor: b
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")
list_kwargs = {"repo_id": repo_id, "repo_type": "model"}
if revision is not None:
list_kwargs["revision"] = revision
repo_files = api.list_repo_files(**list_kwargs)
_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:
@@ -459,6 +468,8 @@ def _download_model_snapshot(repo_id: str, target: Path, *, require_processor: b
"local_dir": str(staging_target),
"ignore_patterns": ["*.md", ".git*"],
}
if revision is not None:
download_kwargs["revision"] = revision
if token_for_download:
download_kwargs["token"] = token_for_download
tqdm_cls = reporter.make_tqdm_class()
@@ -478,6 +489,8 @@ def _download_model_snapshot(repo_id: str, target: Path, *, require_processor: b
shutil.copytree(staging_target, target, dirs_exist_ok=True)
else:
shutil.move(str(staging_target), str(target))
if revision is not None:
mark_trusted_hf_snapshot(target, repo_id, revision)
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
+20 -3
View File
@@ -188,11 +188,26 @@ def log_llm_input(
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):
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,
mask_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.
Input and contract-version fields are optional so existing callers that omit them
continue to work; when provided, changing a field produces a different key.
"""
key_data = {
"model": model_name,
@@ -211,6 +226,8 @@ def get_cache_key(model_name, preset_prompt, custom_prompt, image_hash=None, vid
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
if mask_prompt_contract_version is not None:
key_data["mask_prompt_contract_version"] = mask_prompt_contract_version
# Create deterministic hash
key_str = json.dumps(key_data, sort_keys=True)
return hashlib.md5(key_str.encode()).hexdigest()
+14
View File
@@ -1,5 +1,19 @@
# ComfyUI-QwenVL Update Log
## Version 3.1.1 (2026/11/01) - Audio Workflows and Security Hardening
### 3.1.1 Changes
- Expanded audio, mask-focused image, and duration-aware video prompt workflows, with related frontend and guidance updates.
- Restricted workflow audio paths to approved roots and bounded file size, decoded duration, sample rate, waveform size, and FFmpeg runtime.
- Disabled Transformers remote code by default; custom code now requires an administrator-approved repository pinned to a full commit SHA.
- Filtered terminal control characters from streamed model output.
### 3.1.1 Validation
- Focused audio, HF snapshot, commercial-mode, terminal-display, and security-control tests passed; one Windows symlink test was skipped because symlink creation was unavailable.
- Python compilation and `git diff --check` passed. No live ComfyUI deployment or full test suite was run.
## Version 3.1.0 (2026/10/30) - Typed Decision Fields and Readable Results
### 3.1.0 Changes
+46
View File
@@ -499,6 +499,34 @@ function buildVideoPresetRecommendationText(node, payload) {
return lines.join("\n");
}
function buildPromptEnhancerPreviewText(node, payload) {
const styleWidget = findWidget(node, "enhancement_style") || findWidget(node, "preset_system_prompt");
const promptWidget = findWidget(node, "prompt_text");
if (!styleWidget || !promptWidget) {
return "";
}
const styleName = readComboValue(styleWidget);
const stylePrompt = String(payload?.[styleWidget.name]?.[styleName] || "").trim();
const customSystemWidget = findWidget(node, "custom_system_prompt");
const customSystemPrompt = String(customSystemWidget?.inputEl?.value ?? customSystemWidget?.value ?? "").trim();
const userText = String(promptWidget.inputEl?.value ?? promptWidget.value ?? "").trim();
const systemParts = [customSystemPrompt, stylePrompt].filter(Boolean);
const lines = ["Prompt preview (before generation)"];
if (systemParts.length) {
lines.push(`System instructions:\n${systemParts.join("\n\n")}`);
} else if (styleName === "✍️ Custom Only (no preset)") {
lines.push("System instructions:\nNo preset instruction is selected; add instructions in custom_system_prompt.");
} else {
lines.push("System instructions:\nThe selected style prompt is not available in the preview data.");
}
lines.push(`Your text (prompt_text):\n${userText || "Describe a scene vividly. (default because prompt_text is blank)"}`);
lines.push("This shows the selected instructions and source text; the generated prompt appears after execution.");
return lines.join("\n\n");
}
function ensureRecommendationWidget(node) {
if (!THINKINGLLM_NODE_NAMES.has(node.comfyClass)) {
return null;
@@ -548,6 +576,7 @@ function updateRecommendationWidget(node) {
widget.value = [
buildRecommendationText(node, rules),
buildVideoPresetRecommendationText(node, payload),
buildPromptEnhancerPreviewText(node, payload),
].filter(Boolean).join("\n\n");
resizeNode(node);
});
@@ -564,6 +593,22 @@ function queueRecommendationRefresh(node) {
});
}
function hookPromptEnhancerPreviewInputs(node) {
if (!findWidget(node, "prompt_text")
|| (!findWidget(node, "enhancement_style") && !findWidget(node, "preset_system_prompt"))) {
return;
}
for (const widgetName of ["prompt_text", "custom_system_prompt"]) {
const widget = findWidget(node, widgetName);
if (!widget?.inputEl || widget._thinkingllmPreviewInputHooked) {
continue;
}
widget._thinkingllmPreviewInputHooked = true;
widget.inputEl.addEventListener("input", () => queueRecommendationRefresh(node));
}
}
function catalogModelsForProfile(catalogs, profileName) {
const entry = catalogs?.[profileName];
const models = Array.isArray(entry?.models) ? entry.models : [];
@@ -750,6 +795,7 @@ function hookRecommendationUpdates(node) {
}
node._thinkingllmRecommendationHooked = true;
ensureRecommendationWidget(node);
hookPromptEnhancerPreviewInputs(node);
for (const widgetName of ["model_name", "enable_thinking", ...PRESET_WIDGET_NAMES]) {
const target = findWidget(node, widgetName);
+17 -1
View File
@@ -2,6 +2,8 @@
"preset_prompt": {
"🚫 No preset (image-only)": "",
"💬 Custom prompt + image (no preset)": "",
"🎭 Creative Image Remix": "Use the connected image as the source and create one bold, cohesive image-generation prompt that transforms it into a fresh visual concept. Keep the main subject and its defining traits recognizable, but invent a surprising, fitting setting, action, atmosphere, and visual metaphor. Make the transformation feel intentional and striking rather than merely describing the source. Adapt to any image type, including photographs, illustrations, products, landscapes, renders, diagrams, and abstract art. Respect any direction in custom_prompt. Do not add adult, nude, or sexual content. Output only the final prompt, with no preface or explanation.",
"🎨 Style Conversion": "Use the connected image as the source and write one image-generation prompt that reimagines it in a clearly defined visual style. If custom_prompt specifies a target style, follow it; otherwise choose a distinctive style suited to the subject, such as paper-cut collage, clay sculpture, woodblock print, gouache illustration, or retro-futurist graphic art. Preserve the recognizable subject, key objects, action, and composition while translating materials, shapes, palette, texture, and lighting into the new style. Adapt to any image type and keep the result coherent and usable as a generation prompt. Do not add adult, nude, or sexual content. Output only the final prompt, with no preface or explanation.",
"👁️ 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\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.",
@@ -34,6 +36,13 @@
"🎬 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\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.",
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Turn the user's text into one fluent English video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text, never claim to have inspected a frame, and do not invent image-specific facts. Preserve the user's intent and focus on visible subject, action, pose, environment, camera movement, lighting, color, texture, and chronological motion. TARGET_DURATION_SECONDS is authoritative; express it exactly once as a natural X-second continuous shot and keep all action within that duration. Write one flowing paragraph of 120-200 words, starting directly with the main action. Output only the prompt, with no headings, notes, or reasoning.",
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": "Convert the user's text into one fluent English WAN 2.2 image-to-video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text and do not claim to have analyzed a frame. Preserve intent and describe one coherent 3-second action with timeline markers from 0 to 3 seconds, using a concise visual beat for each second. Keep motion physically plausible and consistent; add no unrelated characters or events. Output one continuous paragraph with no heading or reasoning.",
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": "Convert the user's text into one fluent English WAN 2.2 image-to-video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text and do not claim to have analyzed a frame. Preserve intent and describe one coherent 5-second action with timeline markers from 0 to 5 seconds, using a concise visual beat for each second. Keep motion physically plausible and consistent; add no unrelated characters or events. Output one continuous paragraph with no heading or reasoning.",
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Write one fluent English WAN 2.2 image-to-video prompt for a single 3-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's subject, action, style, and constraints. Describe a clear opening state, a compact continuous movement, camera behavior, and a natural ending state in one cinematic paragraph. No timestamps, headings, or reasoning.",
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Write one fluent English WAN 2.2 image-to-video prompt for a single 5-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's subject, action, style, and constraints. Describe a clear opening state, a continuous evolving movement, camera behavior, and a natural ending state in one cinematic paragraph. No timestamps, headings, or reasoning.",
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": "Convert the user's text into exactly four fluent English WAN 2.2 image-to-video prompts, each describing the next continuous 5-second segment of a 20-second sequence. This text-only prompt enhancer cannot see an image: rely only on details in prompt_text and do not claim to have analyzed a frame. Preserve the same subjects, appearance, setting, and visual style across all segments; progress the action causally without restarting or repeating it. Separate the four standalone prompt paragraphs with one blank line. Do not add headings, timeline labels, notes, or reasoning.",
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Write one fluent English WAN 2.2 image-to-video prompt for a continuous 20-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's intent and maintain consistent subject identity, setting, and style throughout. Describe a clear opening state, progressive action with natural pacing, camera behavior, and a resolved ending state in one cinematic paragraph. No headings, notes, or reasoning.",
"📝 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.",
@@ -50,6 +59,13 @@
"🎬 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\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.",
"🎦 LTX 2.3 NSFW I2V Scene": "You are an expert cinematic prompt director for LTX 2.3 image-to-video generation. Turn the user's text into one fluent English video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text, never claim to have inspected a frame, and do not invent image-specific facts. Preserve the user's intent and focus on visible subject, action, pose, environment, camera movement, lighting, color, texture, and chronological motion. TARGET_DURATION_SECONDS is authoritative; express it exactly once as a natural X-second continuous shot and keep all action within that duration. Write one flowing paragraph of 120-200 words, starting directly with the main action. Output only the prompt, with no headings, notes, or reasoning.",
"🍿 Wan 2.2 NSFW I2V Timeline (3s)": "Convert the user's text into one fluent English WAN 2.2 image-to-video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text and do not claim to have analyzed a frame. Preserve intent and describe one coherent 3-second action with timeline markers from 0 to 3 seconds, using a concise visual beat for each second. Keep motion physically plausible and consistent; add no unrelated characters or events. Output one continuous paragraph with no heading or reasoning.",
"🍿 Wan 2.2 NSFW I2V Timeline (5s)": "Convert the user's text into one fluent English WAN 2.2 image-to-video prompt. This text-only prompt enhancer cannot see an image: use only scene details provided in prompt_text and do not claim to have analyzed a frame. Preserve intent and describe one coherent 5-second action with timeline markers from 0 to 5 seconds, using a concise visual beat for each second. Keep motion physically plausible and consistent; add no unrelated characters or events. Output one continuous paragraph with no heading or reasoning.",
"🎥 Wan 2.2 NSFW I2V Scene (3s)": "Write one fluent English WAN 2.2 image-to-video prompt for a single 3-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's subject, action, style, and constraints. Describe a clear opening state, a compact continuous movement, camera behavior, and a natural ending state in one cinematic paragraph. No timestamps, headings, or reasoning.",
"🎥 Wan 2.2 NSFW I2V Scene (5s)": "Write one fluent English WAN 2.2 image-to-video prompt for a single 5-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's subject, action, style, and constraints. Describe a clear opening state, a continuous evolving movement, camera behavior, and a natural ending state in one cinematic paragraph. No timestamps, headings, or reasoning.",
"🎬 Wan 2.2 NSFW I2V Timeline (20s)": "Convert the user's text into exactly four fluent English WAN 2.2 image-to-video prompts, each describing the next continuous 5-second segment of a 20-second sequence. This text-only prompt enhancer cannot see an image: rely only on details in prompt_text and do not claim to have analyzed a frame. Preserve the same subjects, appearance, setting, and visual style across all segments; progress the action causally without restarting or repeating it. Separate the four standalone prompt paragraphs with one blank line. Do not add headings, timeline labels, notes, or reasoning.",
"📖 Wan 2.2 NSFW I2V Scene (20s)": "Write one fluent English WAN 2.2 image-to-video prompt for a continuous 20-second shot, based only on scene information in prompt_text. This text-only prompt enhancer cannot see an image, so never claim to have inspected one or invent frame-specific facts. Preserve the user's intent and maintain consistent subject identity, setting, and style throughout. Describe a clear opening state, progressive action with natural pacing, camera behavior, and a resolved ending state in one cinematic paragraph. No headings, notes, or reasoning.",
"📝 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.",
@@ -269,4 +285,4 @@
"recommendation_note": "Four continuous five-second scene prompts."
}
}
}
}