"""ComfyUI PromptForge - prompt enhancement nodes. - PromptForge Model: picks a connected CLIP, a safetensors model or a GGUF and plans quant / context / GPU layers for the current GPU - PromptForge Enhancer: writes the prompt with a preset system prompt, optional images / video frames, and output cleanup - PromptForge Config Finder: reports the planned settings for local models """ import base64 import ctypes import glob import io import json import gc import importlib.util import inspect import os import re import struct import time from collections import Counter from pathlib import Path import psutil import torch import torch.nn.functional as F def _preload_nvrtc_builtins(): """PyTorch compiles a few CUDA kernels (for example an int64 prod) at run time with NVRTC, which opens its builtins library by name; a pip install puts it where the loader does not look, and the kernel fails with "failed to open libnvrtc-builtins". Loading it by path makes the name resolve.""" nvidia = importlib.util.find_spec("nvidia") for folder in (nvidia.submodule_search_locations if nvidia else None) or []: for path in glob.glob(os.path.join(folder, "*", "lib", "libnvrtc-builtins.so.*")): if ".alt." not in path: ctypes.CDLL(path, mode=ctypes.RTLD_GLOBAL) if os.name == "posix": _preload_nvrtc_builtins() from .frame_interpolation import NODE_CLASS_MAPPINGS as VFI_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as VFI_DISPLAY_NAMES from .video_save import NODE_CLASS_MAPPINGS as SAVE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SAVE_DISPLAY_NAMES from .lora_loader import NODE_CLASS_MAPPINGS as LORA_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LORA_DISPLAY_NAMES from .unified_loader import NODE_CLASS_MAPPINGS as LOADER_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LOADER_DISPLAY_NAMES from .empty_latent import NODE_CLASS_MAPPINGS as LATENT_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as LATENT_DISPLAY_NAMES from .sampler import NODE_CLASS_MAPPINGS as SAMPLER_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as SAMPLER_DISPLAY_NAMES from .quantize_model import NODE_CLASS_MAPPINGS as QUANT_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as QUANT_DISPLAY_NAMES from .minimax_decode import NODE_CLASS_MAPPINGS as DECODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as DECODE_DISPLAY_NAMES from . import image_library, image_actions from .image_actions_node import PROMPTFORGE_ImageAction from .moss_nodes import NODE_CLASS_MAPPINGS as MOSS_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as MOSS_DISPLAY_NAMES from .h3_generate import NODE_CLASS_MAPPINGS as ENDLESS_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as ENDLESS_DISPLAY_NAMES from .h3_settings import NODE_CLASS_MAPPINGS as H3_SETTINGS_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as H3_SETTINGS_DISPLAY_NAMES from .preview_override import NODE_CLASS_MAPPINGS as PREVIEW_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as PREVIEW_DISPLAY_NAMES from .load_image import NODE_CLASS_MAPPINGS as IMAGE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as IMAGE_DISPLAY_NAMES from . import h3_ideas # noqa: F401 (registers the saved-ideas routes) from .load_video import NODE_CLASS_MAPPINGS as VIDEO_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as VIDEO_DISPLAY_NAMES from .load_audio import NODE_CLASS_MAPPINGS as AUDIO_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS as AUDIO_DISPLAY_NAMES import transformers from transformers import AutoModelForCausalLM, AutoProcessor, AutoTokenizer, BitsAndBytesConfig from transformers import LogitsProcessor, LogitsProcessorList, StoppingCriteria, StoppingCriteriaList from transformers.utils.chat_template_utils import render_jinja_template try: from transformers import AutoModelForImageTextToText as AutoVision except Exception: # fallback for older transformers naming from transformers import AutoModelForVision2Seq as AutoVision try: import folder_paths except Exception: # ComfyUI injects this module; keep a safe fallback folder_paths = None PACK_DIR = Path(__file__).resolve().parent PRESETS_PATH = PACK_DIR / "presets" / "promptforge_presets.json" MODELS_PATH = PACK_DIR / "catalog" / "models.json" with open(PRESETS_PATH, "r", encoding="utf-8") as _f: PROMPTFORGE_PRESETS = json.load(_f).get("presets") or {} with open(MODELS_PATH, "r", encoding="utf-8") as _f: PROMPTFORGE_MODELS = json.load(_f) or {} OUTPUT_GUARD = ( "Output rules that override all style preferences: return only the final usable prompt text. " "Do not include analysis, planning, bullet points, headings, markdown, JSON, explanations, alternatives, " "self-corrections, notes, apologies, or messages to the user. Do not write phrases such as Final Plan, Final Check, " "Wait, Okay, First, Next, Then, I will, or I need. " "Never write in first person and never comment on your own work: do not say you followed the instructions, " "did a good job, respected the rules, avoided something, or met the request. Never add a closing note, " "self-assessment, or summary about what you wrote. Start directly with the required prompt text. " "Never repeat, quote, paraphrase, or answer any instruction, rule, constraint, policy, or note from this system prompt; " "the output must contain only the prompt text for the target model." ) _META_PATTERNS = ( r"^#{1,6}\s", r"^(note|remember|important|rule|constraint|instruction|instructions|output|response)\b\s*[:\-]", r"^(here is|here's|this is)\b[^.!?]{0,40}\bprompt\b", r"^(okay|ok|sure|certainly|alright|absolutely|great|perfect)\s*[,!:.]\s*", r"\b(explicitly stated|as requested|as instructed|anything other than)\b", r"\b(the user's (request|text|wording|prompt)|system prompt|this prompt|the generated prompt|output contract|content policy|negative constraints?)\b", r"\b(output only|only output|in the output)\b", r"\b(the prompt|the response|the answer)\s+(should|must|is|will|needs to)\b", r"\b(do not|don't|never|avoid|must not|should not)\b[^.!?]{0,80}\b(add|include|mention|name|output|write|describe|use|state|say|deviate|copy|repeat|paraphrase)\b", r"\b(I will|I'll|we need to|we should|the goal is|my task|the task is)\b", r"\b(here is|here's|below is)\s+(the|your|an?)\s+(enhanced|final|generated|resulting)?\s*(prompt|output|response)\b", r"\b(i'?ve|i have|we'?ve|we have)\b[^.!?]{0,90}\b(done|followed|listened|obeyed|applied|respected|honou?red|ensured|made sure|crafted|created|generated|written|wrote|included|avoided|kept|used|focused|paid attention)\b", r"\b(i|we)\b[^.!?]{0,90}\b(followed|listened|obeyed|applied|respected|honou?red|ensured|made sure|crafted|generated|avoided|paid attention)\b[^.!?]{0,60}\b(instruction|instructions|rules?|constraints?|guidelines?|request|prompt)\b", r"\b(i|we)\s+(made sure|ensured)\b", r"\b(done a good job|did a good job|good job|well done|task complete|successfully completed)\b", r"\b(the|this|your)\s+(enhanced|final|generated|resulting)?\s*(prompt|output|response)\s+(now\s+)?(is|includes|contains|follows|respects|meets|has been|was|will)\b", r"\b(the user|user'?s)\s+(asked|requested|wants|wanted|instructions?)\b", r"\b(i hope|hope (this|that) helps|let me know|feel free|if you (want|need|would like))\b", r"\b(note|disclaimer|warning)\s*[:\-]", r"\b(i|we)\s+(will|would|can|am going to|are going to)\s+(now\s+)?(write|create|generate|provide|describe)\b", ) _META_RE = re.compile("|".join(f"(?:{p})" for p in _META_PATTERNS), re.I) _CHAT_MARKER_RE = re.compile( r"<\|(?:im_start|im_end|endoftext|begin_of_text|start_header_id|end_header_id|eot_id|end|system|user|assistant|model|think|/think)\|>\s*(?:system|user|assistant|model)?\s*>?", re.I, ) _CHAT_TAG_RE = re.compile( r"|\[/?INST\]|<>|<\|?/?channel\|?>", re.I, ) _ROLE_LINE_RE = re.compile(r"^(system|user|assistant|model)\s*:\s*", re.I) def _extract_last_turn(text): """Keep only the final assistant/model turn when template markers are present.""" patterns = ( (r"<\|im_start\|>\s*(system|user|assistant|model)\s*>?", r"<\|im_end\|>", ("assistant", "model")), (r"\s*(system|user|model|assistant)\s*>?", r"", ("model", "assistant")), (r"<\|start_header_id\|>\s*(system|user|assistant)\s*<\|end_header_id\|>[ \t]*", r"<\|eot_id\|>", ("assistant",)), ) for start_pattern, end_pattern, roles in patterns: matches = list(re.finditer(start_pattern, text, re.I)) if not matches: continue for match in reversed(matches): if match.group(1).lower() in roles: tail = text[match.end():] end = re.search(end_pattern, tail) return tail[:end.start()] if end else tail return "" return text def _strip_chat_tokens(text): """Remove ChatML/Gemma/Llama role markers and keep the assistant answer.""" text = _extract_last_turn(text or "") text = _CHAT_MARKER_RE.sub("", text) text = _CHAT_TAG_RE.sub("", text) lines = text.splitlines() role_positions = [] assistant_positions = [] for index, line in enumerate(lines): role = line.strip().lower().rstrip(":") if role in ("system", "user", "assistant", "model"): role_positions.append(index) if role in ("assistant", "model"): assistant_positions.append(index) if role_positions: if not assistant_positions: return "" lines = lines[assistant_positions[-1] + 1:] kept_lines = [] for line in lines: stripped = line.strip() if not stripped: kept_lines.append("") continue if stripped.lower().rstrip(":") in ("system", "user", "assistant", "model"): continue kept_lines.append(_ROLE_LINE_RE.sub("", stripped)) cleaned = "\n".join(kept_lines) return re.sub(r"\n{3,}", "\n\n", cleaned).strip() _NEGATIVE_PROMPT_TERMS = ( "blur", "blurry", "watermark", "logo", "extra fingers", "extra limbs", "bad anatomy", "low quality", "worst quality", "deformed", "sweat", "wetness", "moisture", "droplets", ) _NEGATIVE_PROMPT_RE = re.compile( r"\b(no|without|avoid|free of|don't|do not|must not|should not)\b[^.!?]{0,50}\b(" + "|".join(re.escape(term) for term in _NEGATIVE_PROMPT_TERMS) + r")\b", re.I, ) def _clean_prompt_output(text): """Remove leaked instructions, reasoning and self-commentary while keeping the prompt.""" text = _strip_chat_tokens(text) kept_lines = [] for line in text.splitlines(): stripped = line.strip() if not stripped: kept_lines.append("") continue if re.match(r"^negative prompt\s*[:\-]", stripped, re.I): continue stripped = re.sub(r"^(?:enhanced prompt|positive prompt|prompt)\s*[:\-]\s*", "", stripped, flags=re.I) stripped = re.sub(r"^(?:okay|ok|sure|certainly|alright|absolutely|great|perfect)\s*[,!:.]\s*", "", stripped, flags=re.I) stripped = re.sub(r"^(?:here is|here's|below is)\s+(?:the|your|an?)\s+(?:enhanced|final|generated|resulting)?\s*(?:prompt|output|response)\s*[:\-]?\s*", "", stripped, flags=re.I) if not stripped: continue sentences = re.split(r"(?<=[.!?])\s+", stripped) kept = [sentence.strip() for sentence in sentences if sentence.strip() and not _META_RE.search(sentence)] if kept: kept_lines.append(" ".join(kept)) cleaned = "\n".join(kept_lines) cleaned = "\n".join(line for line in cleaned.splitlines() if not line.strip() or re.search(r"[A-Za-z0-9]", line)) cleaned = cleaned.replace("**", "").replace("__", "") cleaned = re.sub(r"^\s*[-*•]\s+", "", cleaned, flags=re.M) cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip() if not re.search(r"[A-Za-z0-9]", cleaned): return "" return cleaned.strip('"').strip("`").strip() THINK_TAGS = ( ("\u27e8think\u27e9", "\u27e8/think\u27e9"), ("", ""), ("", ""), ) REFUSAL_MARKERS = ( "i cannot fulfill", "i can't fulfill", "i cannot comply", "i can't comply", "i cannot assist", "i can't assist", "i cannot help", "i can't help", "i cannot create", "i can't create", "i cannot generate", "i can't generate", "cannot provide", "can't provide", "unable to comply", "unable to help", "i must decline", "i have to decline", "i won't", "i will not", "as an ai", "against my guidelines", "violates my", "not able to help", "not appropriate", "inappropriate content", "sorry, but i can", "i'm sorry, but", "i am sorry, but", "i apologize, but", ) def _hard_loop(text): """Only the unmistakable loops, used to stop a running sampler without cutting good text.""" normalized = re.sub(r"\s+", " ", text.lower()).strip()[-6000:] words = re.findall(r"\w+", normalized) if len(words) < 80: return False grams = [" ".join(words[i:i + 12]) for i in range(len(words) - 11)] counts = Counter(grams) return any(count >= 4 for count in counts.values()) def _is_repetitive_text(text): """True for degenerate loops; structured prompts that reuse a few phrases are fine.""" if not text or len(text) < 60: return False normalized = re.sub(r"\s+", " ", text.lower()).strip()[:8000] sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", normalized) if len(s.strip()) >= 30] if sentences: counts = Counter(sentences) # a long prompt may reuse a sentence or two; a loop is mostly copies if max(counts.values()) >= 5 or sum(len(s) * (c - 1) for s, c in counts.items()) > 0.3 * len(normalized): return True words = re.findall(r"\w+", normalized) for size, min_count, share in ((8, 3, 0.5), (5, 4, 0.75)): if len(words) < size * 4: continue grams = [" ".join(words[i:i + size]) for i in range(len(words) - size + 1)] counts = Counter(grams) covered = set() for i, gram in enumerate(grams): if counts[gram] >= min_count: covered.update(range(i, i + size)) if len(covered) > share * len(words): return True lines = [line.strip().lower() for line in text.splitlines() if len(line.strip()) >= 30] if lines: counts = Counter(lines) if max(counts.values()) >= 5 or sum(len(l) * (c - 1) for l, c in counts.items()) > 0.3 * len(text): return True return False def _dedupe_repeats(text): """Drop repeated sentences and lines, keeping the first occurrence.""" seen = set() lines = [] for line in text.splitlines(): kept = [] for sentence in re.split(r"(?<=[.!?])\s+", line.strip()): key = re.sub(r"\s+", " ", sentence.lower()).strip() if len(key) >= 30: if key in seen: continue seen.add(key) kept.append(sentence) lines.append(" ".join(kept)) return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip() _VIDEO_FIELD_RE = re.compile( r"^(?:integrated_multimodal_description|overall_soundscape|non_diegetic_music|soundscape|audio(?:_design)?|sound_effects?|music)\s*:", re.I, ) _VIDEO_SENTENCE_RE = re.compile( r"\b(?:non-diegetic music|soundscape|sound effects?|audio cue|voice-?over|background music|diegetic sound)\b", re.I, ) _SHOT_LINE_RE = re.compile(r"^\s*(?:\[\s*)?shot\s+\d+", re.I) def _strip_video_meta(text): """Remove video/audio fields from image-only presets.""" lines = [] for line in text.splitlines(): stripped = line.strip() if not stripped: lines.append("") continue if _VIDEO_FIELD_RE.match(stripped) or _SHOT_LINE_RE.match(stripped): continue sentences = re.split(r"(?<=[.!?])\s+", stripped) kept = [sentence for sentence in sentences if not _VIDEO_SENTENCE_RE.search(sentence)] if kept: lines.append(" ".join(kept)) cleaned = "\n".join(lines) return re.sub(r"\n{3,}", "\n\n", cleaned).strip() def _next_seed(seed): return (int(seed) + 1) % (2 ** 32 - 1) or 1 _TOP_P_WARNED = False def _clip_top_p_supported(): try: major, minor = (int(part) for part in str(torch.__version__).split("+")[0].split(".")[:2]) return (major, minor) < (2, 13) except Exception: return True _REASONING_OPENER_RE = re.compile( r"^\W*(?:okay|ok|alright|let me|let's|first,|the user (?:wants|asked|is asking|provided)|i need to|we need to|i should|" r"breaking (?:this|it) down|to (?:start|begin) with|so,? the)\b", re.I, ) def _bad_output_reason(text, min_chars=20): """Return a short reason when output is empty, short, degenerate, leaked reasoning or a refusal.""" answer, _ = _split_thinking(text or "") if _REASONING_OPENER_RE.match(answer): return "reasoning leak" clean = _clean_prompt_output(answer) if len(clean) < min_chars: return "too short" if _is_repetitive_text(clean): return "repetitive" if _NEGATIVE_PROMPT_RE.search(clean): return "negative-prompt wording" if re.search(r"(.)\1{15,}", clean): return "runaway repeat" if len(clean) > 200 and len(set(clean)) < 8: return "low variety" words = re.findall(r"\w+", clean.lower()) if len(words) > 30: if Counter(words).most_common(1)[0][1] / len(words) > 0.45: return "repeated words" lines = [line.strip() for line in clean.splitlines() if line.strip()] if len(lines) >= 4: if Counter(lines).most_common(1)[0][1] >= 4: return "repeated lines" lowered = clean.lower() if len(clean) < 600 and any(marker in lowered for marker in REFUSAL_MARKERS): return "refusal" if any(lowered.startswith(marker) for marker in REFUSAL_MARKERS): return "refusal" return "" _MINIMAX_SECTIONS = { "ref2va": ("subject_definitions", "summary", "retention_analysis", "detailed_description", "overall_soundscape", "non_diegetic_music"), "fl2va": ("integrated_multimodal_description", "overall_soundscape", "non_diegetic_music"), "i2va": ("integrated_multimodal_description", "overall_soundscape", "non_diegetic_music"), "l2va": ("integrated_multimodal_description", "overall_soundscape", "non_diegetic_music"), "t2va": ("integrated_multimodal_description", "overall_soundscape", "non_diegetic_music"), } _MINIMAX_FALLBACK = { "subject_definitions": " is the referenced person.", "summary": "The clip shows the requested action.", "retention_analysis": ": fully_preserved - appearance retained.", "detailed_description": "The requested action runs from the first frame to the last.", "integrated_multimodal_description": "[Shot 1] The requested action runs from the first frame to the last.", "overall_soundscape": "Breathing, skin contact and movement sounds throughout.", "non_diegetic_music": "N/A", } def _ensure_minimax_sections(text, mode): """Append any section the model left out, so the prompt keeps the whole contract.""" for name in _MINIMAX_SECTIONS.get(mode, ()): if not re.search(rf"(?im)^{name}:", text): text = text.rstrip() + f"\n\n{name}:\n{_MINIMAX_FALLBACK[name]}" return text def _normalize_minimax_sections(text, mode): """Rewrite the section labels to the exact lowercase contract, one per line.""" for name in _MINIMAX_SECTIONS.get(mode, ()): text = re.sub(rf"(?im)^{name}:\s*", name + ":\n", text, count=1) return text def _strip_inner_timestamps(text, mode="ref2va"): """Fold timestamps inside a single-shot action prompt into plain prose and drop repeated [Shot 1] labels.""" marker = "detailed_description:" if mode == "ref2va" else "integrated_multimodal_description:" cut = text.find(marker) head, body = (text[:cut], text[cut:]) if cut >= 0 else ("", text) body = body.replace(marker, marker + "\n", 1) start = body.find("\n") + 1 segment = body[start:] first = segment.find("[Shot 1]") if first >= 0: segment = segment[:first + 8] + re.sub(r"\[Shot 1\]", "", segment[first + 8:]) else: segment = "[Shot 1] " + segment.lstrip() body = body[:start] + segment body = re.sub(r"\b(?:From|Between)\s+\d{2}:\d{2}\.\d{3}\s+(?:to|and)\s+\d{2}:\d{2}\.\d{3}\s*,?\s*", "Then ", body) body = re.sub(r"\b(?:[Aa]t|[Bb]y|[Uu]ntil|[Bb]efore|[Aa]fter)\s+\d{2}:\d{2}\.\d{3}\s*,?\s*", "", body) body = re.sub(r"\b(?:[Aa]t|[Aa]round|[Bb]y|[Aa]fter|[Bb]efore|[Uu]ntil|[Ff]rom)\s+\d{1,2}(?:\.\d+)?\s*(?:seconds?|s)\b\s*,?\s*", "", body) body = re.sub(r"\b(?:[Aa]pproximately|[Rr]oughly|[Aa]bout)\s+\d{1,2}(?:\.\d+)?\s*seconds?\b\s*,?\s*", "", body) body = re.sub(r"\b(?:[Aa]t|[Aa]round|[Bb]y|[Uu]ntil|[Bb]efore|[Aa]fter)\s*,", "", body) body = re.sub(r"\(\s*[–-]?\s*\)", "", body) body = re.sub(r"\d{2}:\d{2}\.\d{3}", "", body) body = re.sub(r"((?:^|[.!?]\s+|\n)|\[Shot 1\]\s+)([a-z])", lambda match: match.group(1) + match.group(2).upper(), body) return head + re.sub(r"[ \t]{2,}", " ", body) def _inject_reference_labels(text, user_text): """Attach the source picture/video to every subject line that forgot to cite it.""" sources = {} for match in re.finditer(r"(?im)^Subject (\d+)\b[^:\n]*:\s*.*?(|)", user_text): sources[int(match.group(1))] = match.group(2) if not sources: return text start = re.search(r"(?im)^subject_definitions:", text) if not start: return text end = re.search(r"(?im)^summary:", text[start.end():]) body = text[start.end():start.end() + end.start()] if end else text[start.end():] def fix(match): number = int(match.group(1)) line = match.group(0) if number in sources and not re.search(r"<(?:Picture|Video)\s+\d+>", line): line = line.rstrip(" .") + f" from {sources[number]}." return line body = re.sub(r"(?im)^[^\n]*", fix, body) return text[:start.end()] + body + (text[start.end() + end.start():] if end else "") def _minimax_format_problem(text, mode, one_shot, hint="", picture_count=0): """Return what breaks the H3 output contract, or '' when the prompt is usable.""" sections = _MINIMAX_SECTIONS.get(mode) if not sections: return "" positions = [] for name in sections: match = re.search(rf"^{name}:", text, re.M | re.I) if not match: return f"missing section {name}" positions.append(match.start()) if positions != sorted(positions): return "sections out of order" if mode == "ref2va" and picture_count: block = text[positions[0]:positions[1]] if len(positions) > 1 else text[positions[0]:] first = re.search(r"(?im)^.*$", block) if first and not re.search(r"<(?:Picture|Video)\s+\d+>", first.group(0)): return "Subject 1 does not cite its reference picture" if not re.search(r"", block): return "subject_definitions cites no reference picture" body = text[positions[sections.index("detailed_description")]:] if "detailed_description" in sections else text[positions[0]:] body = body.split("overall_soundscape:")[0] shots = [int(number) for number in re.findall(r"\[Shot (\d+)\]", body)] if one_shot: if shots != [1]: return "the action must be a single [Shot 1]" if re.search(r"\d{2}:\d{2}\.\d{3}", text): return "timestamps inside the single shot" elif shots != list(range(1, len(shots) + 1)): return "shot numbers must start at 1 and rise by one without repeats" elif len(shots) < 2 and re.search(r"\d{2}:\d{2}\.\d{3}", body): return "timestamps without a new shot label" if one_shot and "striptease" not in hint.lower(): opening = " ".join(re.findall(r"\w+", text.split("[Shot 1]", 1)[-1])[:150]).lower() if not any(word in opening for word in ("penis", "vagina", "vulva", "clitoris", "shaft", "glans", "genitals", "tongue", "mouth")): return "the act starts too late (contact must be in the opening sentences)" words = len(re.findall(r"\w+", body)) if words < 450: return f"body too short ({words} words, 500-800 required)" if words > 950: return f"body too long ({words} words, 500-800 required)" return "" RUNAWAY_LOOP = 6 # the same word this many times in a row RUNAWAY_SENTENCE = 110 # words in one sentence; real prompt sentences are far shorter RUNAWAY_KEEP = 35 # words of a runaway sentence that are still kept def _land_text(text): """End on a complete sentence, so a token cap never leaves a half word or clause.""" text = text.rstrip() if not text or text[-1] in '.!?…"\')]': return text for index in range(len(text) - 1, 0, -1): if text[index] in '.!?': return text[:index + 1].rstrip() return text + "." def _cut_runaway_tail(text): """Cut the degenerate tail of a prompt the model lost track of: a looping word, or one endless run-on sentence. Returns (text, whether something was cut).""" cut = None words = list(re.finditer(r"\S+", text)) run = 1 for index in range(1, len(words)): run = run + 1 if words[index].group().lower().strip(".,;:!?") == words[index - 1].group().lower().strip(".,;:!?") else 1 if run >= RUNAWAY_LOOP: cut = words[index - run + 2].start() # one copy of the word stays break for sentence in re.finditer(r"[^.!?\n]+", text): inside = list(re.finditer(r"\S+", sentence.group())) if len(inside) > RUNAWAY_SENTENCE: start = sentence.start() + inside[RUNAWAY_KEEP].start() cut = start if cut is None else min(cut, start) break if cut is None: return text, False return text[:cut].rstrip(" ,;:-") + ".", True def _generate_checked(attempt, seed, min_chars, auto_retry, tries=3): """Run attempt(seed) until the output passes the checks, up to `tries` seeds; keep the best on failure.""" texts = [] for index in range(tries if auto_retry else 1): text = attempt(seed) reason = _bad_output_reason(_split_thinking(text)[0], min_chars) if not reason: return text texts.append(text) seed = _next_seed(seed) if index + 1 < tries and auto_retry: print(f"[PromptForge] output rejected ({reason}); retrying with a new seed ({index + 2}/{tries}).") if reason == "repetitive": return _dedupe_repeats(max(texts, key=len)) return texts[-1] # MiniMax H3 modes: how many images the mode can use, whether a reference video is allowed, and each image's role. # Labels follow the H3 text encoder: /