- Add Wildcard Prompt Composer and Wildcard Character Composer builder nodes (core/builder, web/prompt_builder.js, builders/, vp wildcard lists) - Add browser-based wildcard review tool (tools/wildcard_review) with manifest-based undo - Add wildcard catalog/curation, surreal compiler/lint/scene, profile token budget - README: document all 18 nodes, bump to v2.2.0, fix stale node/platform lists; CHANGELOG 2.2.0 - Rewrite NODE_COMPARISON for 18 nodes; fix QUICKSTART (dead test_setup.py ref, menu path) and QUICK_REFERENCE node list - gitignore review manifests and CLAUDE.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
477 lines
22 KiB
Python
477 lines
22 KiB
Python
"""
|
|
Platform-profile support shared by the nodes.
|
|
|
|
One place for: the profile library, the cache folder for vendor rewriter prompts, the dropdown
|
|
lists, and the single call that turns an idea into a platform prompt through the existing
|
|
LLMBackend.
|
|
|
|
Dropdown rule: a node's platform list is its ORIGINAL list, in its original order, with profile
|
|
ids it did not know appended after it. ComfyUI stores a dropdown's value as text, so appending
|
|
never disturbs a saved workflow; the original order is kept anyway so the menus look as they did.
|
|
|
|
Author: Eric Hiss (GitHub: EricRollei)
|
|
License: Dual License (Non-Commercial and Commercial Use). See LICENSE.
|
|
"""
|
|
|
|
import os
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
|
|
|
from .core.profiles import (
|
|
MODE_AUTO,
|
|
MODES,
|
|
Profile,
|
|
ProfileLibrary,
|
|
RunFacts,
|
|
check,
|
|
get_library,
|
|
negative_prompt,
|
|
plan_rewrite,
|
|
to_legacy_config,
|
|
)
|
|
|
|
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
CACHE_DIR = os.path.join(PACKAGE_DIR, "cache")
|
|
|
|
OFFICIAL_PROMPT_CHOICES = list(MODES)
|
|
TASK_CHOICES = ["auto", "t2i", "edit", "t2v", "i2v", "flf2v", "ref2v"]
|
|
MODALITY_ORDER = ("image", "edit", "video")
|
|
|
|
|
|
def library() -> ProfileLibrary:
|
|
return get_library()
|
|
|
|
|
|
def get_profile(profile_id: str) -> Optional[Profile]:
|
|
try:
|
|
return library().get(profile_id)
|
|
except Exception as error: # a broken profile folder must never stop a node from loading
|
|
print(f"[Prompt Profiles] could not load profiles: {error}")
|
|
return None
|
|
|
|
|
|
def profile_ids(modalities: Sequence[str] = MODALITY_ORDER) -> List[str]:
|
|
"""Profile ids grouped by modality (image, edit, video), alphabetical inside each group."""
|
|
ordered: List[str] = []
|
|
try:
|
|
for modality in modalities:
|
|
ordered.extend(sorted(library().ids(modality=modality)))
|
|
except Exception as error:
|
|
print(f"[Prompt Profiles] could not list profiles: {error}")
|
|
return ordered
|
|
|
|
|
|
LEGACY_NODE_EXCLUDED_STYLES = ("json", "fields")
|
|
|
|
|
|
def extended_choices(original: Iterable[str], modalities: Sequence[str],
|
|
exclude_styles: Sequence[str] = LEGACY_NODE_EXCLUDED_STYLES) -> List[str]:
|
|
"""
|
|
A node's original dropdown list with the profile ids it did not have appended after it.
|
|
JSON and named-field formats are left out: the older nodes post-process the LLM's answer as
|
|
plain text (keyword appends, density padding), which would corrupt them. Those formats are
|
|
served by the Platform Prompt Rewriter node.
|
|
"""
|
|
choices = list(original)
|
|
for profile_id in profile_ids(modalities):
|
|
if profile_id in choices:
|
|
continue
|
|
profile = get_profile(profile_id)
|
|
if profile is None or profile.caption_style in exclude_styles:
|
|
continue
|
|
choices.append(profile_id)
|
|
return choices
|
|
|
|
|
|
def legacy_config(profile_id: str, task: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
"""platforms.py-shaped dict for a profile, or None when there is no such profile."""
|
|
profile = get_profile(profile_id)
|
|
if profile is None:
|
|
return None
|
|
return to_legacy_config(profile, task)
|
|
|
|
|
|
def legacy_negative(profile_id: str, custom_negatives: Optional[Sequence[str]] = None) -> Optional[str]:
|
|
profile = get_profile(profile_id)
|
|
if profile is None:
|
|
return None
|
|
return negative_prompt(profile, custom_negatives)
|
|
|
|
|
|
# ---- the video expander nodes -------------------------------------------------------------------
|
|
WAN_TARGET = "wan22_video" # selecting this keeps the nodes' original Wan 2.2 behaviour, untouched
|
|
DEFAULT_VIDEO_TARGET = "minimax_h3"
|
|
DEFAULT_CLIP_SECONDS = 6.0
|
|
|
|
# Where inside a model's own length range each detail tier sits (fractions of low..high).
|
|
TIER_FRACTIONS = {
|
|
"basic": (0.0, 0.35),
|
|
"enhanced": (0.25, 0.65),
|
|
"advanced": (0.55, 0.9),
|
|
"cinematic": (0.8, 1.0),
|
|
}
|
|
|
|
|
|
def video_target_choices() -> List[str]:
|
|
"""Video profile ids: the default first, Wan 2.2 second, the rest alphabetical. Values are stored as text."""
|
|
ids = []
|
|
for profile_id in profile_ids(("video",)):
|
|
profile = get_profile(profile_id)
|
|
if profile is not None and any(task in profile.tasks for task in ("t2v", "i2v")): # not video-edit-only profiles
|
|
ids.append(profile_id)
|
|
head = [profile_id for profile_id in (DEFAULT_VIDEO_TARGET, WAN_TARGET) if profile_id in ids]
|
|
choices = head + [profile_id for profile_id in ids if profile_id not in head]
|
|
return choices or [WAN_TARGET]
|
|
|
|
|
|
def video_target_inputs() -> Dict[str, Any]:
|
|
"""Optional inputs appended to the three video nodes. Append-only: never insert these before an existing input."""
|
|
choices = video_target_choices()
|
|
default = DEFAULT_VIDEO_TARGET if DEFAULT_VIDEO_TARGET in choices else choices[0]
|
|
return {
|
|
"target_model": (choices, {
|
|
"default": default,
|
|
"tooltip": (
|
|
"The video model the prompt is written for.\n"
|
|
"wan22_video = this node's original behaviour, unchanged: Wan 2.2 tiers and shot structure.\n"
|
|
"Any other model: the prompt is built from that model's profile (profiles/video/<id>.yaml) - its format,\n"
|
|
"length, camera vocabulary, sound and dialogue rules - together with this node's preset, aesthetic\n"
|
|
"controls and creativity settings. The tier then sets how long the prompt is inside the model's own range.\n"
|
|
"MiniMax H3 prompts come out as its three named fields, not as a paragraph."
|
|
),
|
|
}),
|
|
"duration_seconds": ("FLOAT", {
|
|
"default": DEFAULT_CLIP_SECONDS, "min": 0.0, "max": 60.0, "step": 0.5,
|
|
"tooltip": (
|
|
"Length of the clip you will generate. 0 = not stated. Ignored for wan22_video.\n"
|
|
"Models with timed shots and sound (MiniMax H3, Seedance, Kling, Veo) need it: the timeline and the\n"
|
|
"amount of action and dialogue are fitted to it. Set it to the same value as your sampler."
|
|
),
|
|
}),
|
|
}
|
|
|
|
|
|
def tier_word_range(profile: Profile, task: str, tier: str, duration_seconds: float = 0.0) -> "tuple[int, int]":
|
|
"""
|
|
Length for one run of a video node. A model that states words per second gets its length from the clip
|
|
length (the tier cannot sensibly override that); otherwise the tier picks a band inside the model's range.
|
|
"""
|
|
if duration_seconds and profile.words_per_second[1] > 0:
|
|
return profile.words_for_run(task, duration_seconds)
|
|
low, high = profile.words_for(task)
|
|
start, end = TIER_FRACTIONS.get(tier, TIER_FRACTIONS["enhanced"])
|
|
span = max(0, high - low)
|
|
return int(low + span * start), max(int(low + span * end), int(low + span * start) + 1)
|
|
|
|
|
|
def append_terms(prompt: str, terms: Sequence[str], profile: Optional[Profile]) -> "tuple[str, List[str]]":
|
|
"""
|
|
Add required terms (LoRA triggers, verbatim picks) the LLM dropped. Returns (prompt, terms that could NOT be added).
|
|
Prose and tag prompts take them at the end. Named-field prompts take them at the end of the FIRST field, because
|
|
text after the last field would land in the music or sound field. JSON is left alone: the caller warns instead.
|
|
"""
|
|
style = profile.caption_style if profile is not None else "prose"
|
|
if style == "fields" and prompt:
|
|
prompt = _lift_stray_terms(prompt, terms)
|
|
prompt = _strip_terms_after_first_field(prompt, terms)
|
|
missing = [term for term in terms if term and term.lower() not in (prompt or "").lower()]
|
|
if not missing:
|
|
return prompt, []
|
|
if style == "json":
|
|
return prompt, missing
|
|
addition = ", ".join(missing)
|
|
if style == "fields":
|
|
import re
|
|
labels = list(re.finditer(r"(?m)^[a-z_]{4,}:", prompt))
|
|
if len(labels) >= 2:
|
|
cut = labels[1].start()
|
|
head = prompt[:cut].rstrip()
|
|
return f"{head} {addition}\n\n{prompt[cut:]}", []
|
|
# fewer than two field names: the answer is not in the field format anyway, so the end is as good as anywhere
|
|
separator = ", " if style == "tags" else " "
|
|
return f"{prompt.rstrip()}{separator}{addition}", []
|
|
|
|
|
|
def _lift_stray_terms(prompt: str, terms: Sequence[str]) -> str:
|
|
"""
|
|
Field formats: an LLM told to include a trigger word often drops it on a line of its own after the last
|
|
field, where the video model reads it as part of the music field. Such lines (nothing but required terms)
|
|
are removed here; append_terms then puts the terms at the end of the first field.
|
|
"""
|
|
import re
|
|
wanted = {term.strip().lower() for term in terms if term and term.strip()}
|
|
if not wanted:
|
|
return prompt
|
|
kept = []
|
|
for line in prompt.split("\n"):
|
|
parts = [part.strip().lower() for part in re.split(r"[,;]", line) if part.strip()]
|
|
if parts and all(part in wanted for part in parts):
|
|
continue
|
|
kept.append(line)
|
|
return "\n".join(kept).rstrip()
|
|
|
|
|
|
def _strip_terms_after_first_field(prompt: str, terms: Sequence[str]) -> str:
|
|
"""
|
|
Field formats: a required term only counts inside the FIRST field (the visual description). A term the LLM
|
|
tucked into a later field (seen live: at the end of non_diegetic_music) is removed from there, so that
|
|
append_terms sees it as missing and places it at the end of the first field.
|
|
"""
|
|
import re
|
|
labels = list(re.finditer(r"(?m)^[a-z_]{4,}:", prompt))
|
|
if len(labels) < 2:
|
|
return prompt
|
|
cut = labels[1].start()
|
|
head, tail = prompt[:cut], prompt[cut:]
|
|
for term in terms:
|
|
term = (term or "").strip()
|
|
if not term or term.lower() in head.lower():
|
|
continue
|
|
tail = re.sub(r"[ \t]*[,;]?[ \t]*(?<![\w-])" + re.escape(term) + r"(?![\w-])", "", tail, flags=re.I)
|
|
return head + tail
|
|
|
|
|
|
def add_required_terms(prompt: str, keywords: Sequence[str], resolution: Any, target_model: str) -> "tuple[str, List[str]]":
|
|
"""
|
|
Profile-path replacement for the video nodes' two "append what the LLM dropped" steps (positive keywords and
|
|
verbatim wildcard picks), which would otherwise put text after the last field of a structured prompt.
|
|
Returns (prompt, warnings).
|
|
"""
|
|
from .wildcard_support import enforce_verbatim
|
|
|
|
_unused, verbatim_missing = enforce_verbatim(prompt, resolution)
|
|
wanted = [term for term in keywords if term] + [term for term in verbatim_missing if term not in keywords]
|
|
repaired, not_added = append_terms(prompt, wanted, get_profile(target_model))
|
|
warnings = []
|
|
if not_added:
|
|
warnings.append("required terms missing from the answer and NOT added, to keep the format valid: " + ", ".join(not_added))
|
|
return repaired, warnings
|
|
|
|
|
|
def target_report_lines(breakdown: Dict[str, Any]) -> List[str]:
|
|
"""Lines for a video node's breakdown output describing the target model and what its checker found."""
|
|
if not breakdown or not breakdown.get("target_model"):
|
|
return []
|
|
lines = [f"Target model: {breakdown.get('target_name') or breakdown['target_model']} ({breakdown['target_model']}, task {breakdown.get('target_task', '?')})"]
|
|
if breakdown.get("target_format"):
|
|
low, high = breakdown.get("target_words") or (0, 0)
|
|
lines.append(f"Format: {breakdown['target_format']} | length asked for: {low}-{high} words | instructions: {breakdown.get('target_base', '')}")
|
|
if breakdown.get("target_api_note"):
|
|
lines.append(f"API: {breakdown['target_api_note']}")
|
|
lines.extend(f"Note: {note}" for note in breakdown.get("target_notes") or [])
|
|
lines.extend(f"WARNING: {warning}" for warning in breakdown.get("target_warnings") or [])
|
|
return lines
|
|
|
|
|
|
CAPTION_PROMPTS = {
|
|
"start": (
|
|
"Describe this image as the FIRST FRAME of a video, for someone who cannot see it. Cover: each subject "
|
|
"(appearance, clothing, pose, where they look), the setting, the light (source, direction, colour), the "
|
|
"composition and camera angle. Literal and objective, 60-120 words, one paragraph. Do not guess what "
|
|
"happens next and do not comment on quality."
|
|
),
|
|
"end": (
|
|
"Describe this image as the LAST FRAME of a video, for someone who cannot see it. Cover: each subject "
|
|
"(appearance, clothing, pose), the setting, the light, the composition and camera angle. Literal and "
|
|
"objective, 60-120 words, one paragraph."
|
|
),
|
|
"edit": (
|
|
"Describe this image for an editor who will change one thing in it. Name every distinct subject and object "
|
|
"with the attributes that identify it (colour, material, position in frame), the background, the light and "
|
|
"any visible text, quoted exactly. Literal and objective, 60-120 words, one paragraph."
|
|
),
|
|
}
|
|
|
|
|
|
def caption_image_tensor(image: Any, purpose: str, backend: str, endpoint: str, model: str,
|
|
temperature: float = 0.3) -> "tuple[str, str]":
|
|
"""
|
|
(caption, note) for a ComfyUI IMAGE tensor, through the same LLMBackend the node talks to.
|
|
Never raises. An empty caption comes with a note saying why (no vision model loaded, call failed).
|
|
"""
|
|
try:
|
|
import io
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
from .llm_backend import LLMBackend
|
|
|
|
array = image[0].detach().cpu().numpy()
|
|
array = (np.clip(array, 0, 1) * 255).astype(np.uint8)
|
|
if array.ndim == 2:
|
|
array = np.stack([array] * 3, -1)
|
|
buffer = io.BytesIO()
|
|
Image.fromarray(array[..., :3]).save(buffer, format="PNG")
|
|
|
|
llm = LLMBackend(backend, endpoint, model or None, temperature)
|
|
if not llm.supports_images():
|
|
return "", (f"the {purpose} image was ignored: the loaded model is not vision-capable. Load a vision model "
|
|
f"(for example a Qwen3-VL) or wire a caption into the description input instead")
|
|
response = llm.caption_image(buffer.getvalue(), label=f"{purpose} frame",
|
|
prompt=CAPTION_PROMPTS.get(purpose, CAPTION_PROMPTS["start"]))
|
|
if response.get("success") and (response.get("caption") or "").strip():
|
|
return response["caption"].strip(), f"the {purpose} image was captioned by the LLM backend"
|
|
return "", f"the {purpose} image could not be captioned: {response.get('error', 'no caption returned')}"
|
|
except Exception as error:
|
|
return "", f"the {purpose} image could not be captioned: {error}"
|
|
|
|
|
|
@dataclass
|
|
class RewriteResult:
|
|
ok: bool
|
|
prompt: str = ""
|
|
negative: str = ""
|
|
task: str = ""
|
|
base: str = ""
|
|
system_prompt: str = ""
|
|
user_prompt: str = ""
|
|
warnings: List[str] = field(default_factory=list)
|
|
notes: List[str] = field(default_factory=list)
|
|
error: str = ""
|
|
word_range: tuple = (0, 0) # the length this run asked for; (0, 0) = the profile's standing range
|
|
|
|
def report(self, profile: Optional[Profile]) -> str:
|
|
lines: List[str] = []
|
|
if profile is not None:
|
|
low, high = self.word_range if self.word_range[1] else profile.words_for(self.task or profile.default_task())
|
|
lines.append(f"{profile.name} | task: {self.task} | format: {profile.caption_style} | target: {low}-{high} words")
|
|
negative_text = {"yes": "used", "no": "none - this model has no negative prompt",
|
|
"ignored": "ignored - distilled, runs without CFG",
|
|
"inline": "none as a field - exclusions are written into the prompt"}.get(profile.negative, profile.negative)
|
|
lines.append(f"negative prompt: {negative_text}" + (f" ({profile.negative_note})" if profile.negative_note else ""))
|
|
if profile.confidence != "high":
|
|
lines.append(f"profile confidence: {profile.confidence} (facts checked {profile.checked})")
|
|
if profile.api_note:
|
|
lines.append(f"API: {profile.api_note}")
|
|
if self.error:
|
|
lines.append(f"ERROR: {self.error}")
|
|
if self.prompt:
|
|
lines.append(f"result: {len(self.prompt.split())} words, {len(self.prompt)} characters")
|
|
lines.extend(f"note: {note}" for note in self.notes)
|
|
lines.extend(f"WARNING: {warning}" for warning in self.warnings)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def rewrite_for_platform(
|
|
idea: str,
|
|
profile_id: str,
|
|
*,
|
|
task: str = "auto",
|
|
backend: str = "lm_studio",
|
|
endpoint: str = "http://localhost:1234/v1",
|
|
model: str = "",
|
|
temperature: float = 0.7,
|
|
max_tokens: int = 2048,
|
|
facts: Optional[RunFacts] = None,
|
|
official_mode: str = MODE_AUTO,
|
|
extra_system: str = "",
|
|
custom_negatives: Optional[Sequence[str]] = None,
|
|
llm: Any = None,
|
|
length_guard: str = "retry+trim",
|
|
) -> RewriteResult:
|
|
"""
|
|
One idea -> one platform prompt. Fail-soft: when the LLM cannot be reached the original idea
|
|
comes back as the prompt with ok=False, so a workflow keeps running.
|
|
`llm` lets a caller (or a test) pass an object with send_prompt(system, user, max_tokens).
|
|
"""
|
|
profile = get_profile(profile_id)
|
|
if profile is None:
|
|
return RewriteResult(ok=False, prompt=idea, error=f"no platform profile named '{profile_id}'")
|
|
|
|
plan = plan_rewrite(
|
|
profile, idea, task=None if task == "auto" else task, facts=facts,
|
|
cache_dir=CACHE_DIR, official_mode=official_mode, extra_system=extra_system,
|
|
)
|
|
result = RewriteResult(
|
|
ok=False, task=plan.task, base=plan.base, system_prompt=plan.system, user_prompt=plan.user,
|
|
notes=list(plan.notes), negative=negative_prompt(profile, custom_negatives),
|
|
word_range=profile.words_for_run(plan.task, (facts or RunFacts()).duration_seconds, (facts or RunFacts()).length_words),
|
|
)
|
|
|
|
try:
|
|
if llm is None:
|
|
from .llm_backend import LLMBackend
|
|
llm = LLMBackend(backend, endpoint, model or None, temperature)
|
|
response = llm.send_prompt(plan.system, plan.user, max_tokens=max_tokens)
|
|
except Exception as error:
|
|
result.prompt = idea
|
|
result.error = f"LLM backend error: {error}. The idea was passed through unchanged."
|
|
return result
|
|
|
|
if not response.get("success"):
|
|
result.prompt = idea
|
|
result.error = f"{response.get('error', 'LLM call failed')}. The idea was passed through unchanged."
|
|
return result
|
|
|
|
text, warnings = check(response.get("response", ""), profile, plan.task, word_range=result.word_range)
|
|
if not text:
|
|
result.prompt = idea
|
|
result.warnings = warnings
|
|
result.error = "the LLM returned nothing usable. The idea was passed through unchanged."
|
|
return result
|
|
|
|
if "SURREAL PREMISE" in (extra_system or "") and profile.caption_style not in ("json", "fields"):
|
|
# A surreal brief rode along: remove what has no pixels, report substitution grammar and named replaced objects.
|
|
try:
|
|
from .core.strategies.surreal import lint_prompt, suppress_from_instructions
|
|
text, surreal_warnings = lint_prompt(text, suppress_from_instructions(extra_system))
|
|
warnings = list(warnings) + surreal_warnings
|
|
except Exception as error: # a guard must never cost the user their prompt
|
|
warnings = list(warnings) + [f"surreal lint failed ({error}); prompt passed through"]
|
|
|
|
if length_guard != "off" and profile.caption_style not in ("json", "fields"):
|
|
try:
|
|
text, budget_notes, budget_warnings = _apply_token_budget(
|
|
text, profile, plan, llm, max_tokens, length_guard, result.word_range)
|
|
result.notes.extend(budget_notes)
|
|
warnings = list(warnings) + budget_warnings
|
|
except Exception as error: # a guard must never cost the user their prompt
|
|
warnings = list(warnings) + [f"token budget check failed ({error}); prompt passed through"]
|
|
|
|
result.ok = True
|
|
result.prompt = text
|
|
result.warnings = warnings
|
|
return result
|
|
|
|
|
|
def _apply_token_budget(text, profile, plan, llm, max_tokens, length_guard, word_range):
|
|
"""Keep the answer inside the encoder's token limit minus headroom for LoRA triggers.
|
|
retry+trim: one LLM retry asking for a tighter version, then a sentence-boundary trim.
|
|
trim: trim only. Returns (text, notes, warnings)."""
|
|
from .core.profiles.token_budget import budget_for, counter_for, fit_to_budget
|
|
from .core.profiles.validate import word_count
|
|
limit, target = budget_for(profile)
|
|
if not limit:
|
|
return text, [], []
|
|
count, how = counter_for(profile)
|
|
notes, warns = [], []
|
|
n = count(text)
|
|
if n > target and length_guard == "retry+trim" and llm is not None:
|
|
per_word = n / max(1, word_count(text))
|
|
want = max(20, int(target / per_word * 0.9))
|
|
user = (f"{plan.user}\n\nYour previous answer below is {n} tokens. This model reads at most {limit} "
|
|
f"tokens and silently cuts off the rest. Rewrite it in at most {want} words. Keep every subject, "
|
|
"action, setting, style term, artist name, quoted text and unusual or invented word exactly as "
|
|
"written; drop repetition and minor detail first. Answer with the prompt only.\n\n"
|
|
f"Previous answer:\n{text}")
|
|
try:
|
|
response = llm.send_prompt(plan.system, user, max_tokens=max_tokens)
|
|
except Exception as error:
|
|
response = {"success": False, "error": str(error)}
|
|
if response.get("success"):
|
|
shorter, _ = check(response.get("response", ""), profile, plan.task, word_range=word_range)
|
|
if shorter and count(shorter) < n:
|
|
n2 = count(shorter)
|
|
notes.append(f"length: {n} tokens was over the {target}-token target; the LLM tightened it to {n2}")
|
|
text, n = shorter, n2
|
|
else:
|
|
notes.append(f"length: tighten request failed ({response.get('error', 'no answer')}); trimming instead")
|
|
if n > target:
|
|
trimmed, n2, dropped = fit_to_budget(text, target, count)
|
|
warns.append(f"prompt trimmed {n} -> {n2} tokens to fit the {target}-token target (encoder limit {limit})"
|
|
+ (f"; removed: \"{dropped[0]}\"" if dropped else ""))
|
|
text, n = trimmed, n2
|
|
notes.append(f"length: {n}/{limit} tokens ({how}); target {target}, the rest is headroom for LoRA triggers")
|
|
return text, notes, warns
|