add wildcards to image metadata and note separate template from prompt field

Signed-off-by: Vladimir Mandic <mandic00@live.com>
This commit is contained in:
Vladimir Mandic
2026-05-19 09:45:18 +02:00
parent 46f37232b3
commit a56d1d299e
23 changed files with 138 additions and 57 deletions
+9 -4
View File
@@ -1,23 +1,28 @@
# Change Log for SD.Next
## Update for 2026-05-18
## Update for 2026-05-19
- **Models**
- [CircleStone Anima 1.0](https://huggingface.co/circlestone-labs/Anima) in *Base* and *Turbo* (distilled) variants
in both original precision and SDNQ-4bit quantiztion
- **Changes**
- all **Guidance** params are now set to *-1* by default to allow using model defaults and avoid confusion with different model behaviour
log will print default values used by model if not set by user
- **Features**
- **SDNQ** additional quantization algorithm: *Hadamard Rotations*
much higher quality than base SDNQ, but runs slightly slower
still faster than SVD and can be combined together with SVD for combined benefits
- **Metadata**
add *wildcards* (if used) info to image metadata
if wildcards or styles modify prompt, add original prompt to image metadata as *template*
- **Captioning** new feature: analyze existing images for prompt adherence
*tip*: image analysis requires larger VLM model to produce quality output
new api endpoint: `/sdapi/v1/analyze`
- **HF download** use `XET` by default
see *settings -> huggingface -> download method* for options
- **Nunchaku** consider DEV builds when auto-installing
- **Changes**
- all **Guidance** params are now set to *-1* by default to allow using model defaults and avoid confusion with different model behaviour
log will print default values used by model if not set by user
- restore params from image metadata will now prefer *template* field if present, otherwise use *prompt* field
this allows to preserve original prompt in case of wildcards or styles modifying the prompt
- **Compute**
- update `torch==2.12` for *CUDA, ROCm, IPEX*
- **AI**
+1 -1
View File
@@ -1,7 +1,7 @@
from fastapi.exceptions import HTTPException
import gradio as gr
from modules.api import models
from modules.errors import log
from modules.logger import log
from modules import scripts_manager
+1 -1
View File
@@ -1,7 +1,7 @@
from functools import wraps
import torch
from modules import rocm
from modules.errors import log
from modules.logger import log
from installer import install, installed, torch_info
+1 -1
View File
@@ -2,7 +2,7 @@ import platform
from typing import NamedTuple, Optional
from collections.abc import Callable
import torch
from modules.errors import log
from modules.logger import log
from modules.sd_hijack_utils import CondFunc
+1 -1
View File
@@ -232,7 +232,7 @@ def connect_paste_params_buttons():
if binding.only_prompt:
paste_field_names = ['Prompt', 'Negative prompt']
else:
paste_field_names = ['Prompt', 'Negative prompt', 'Steps'] + (["Seed"] if shared.opts.send_seed else []) + binding.paste_field_names
paste_field_names = ['Prompt', 'Negative prompt', 'Steps', 'Seed'] + binding.paste_field_names
if "fields" in paste_fields[binding.source_tabname] and paste_fields[binding.source_tabname]["fields"] is not None:
binding.paste_button.click(
fn=lambda *x: x,
+1 -1
View File
@@ -116,7 +116,7 @@ def make_diffusers_transformer_block(block_class: Type[torch.nn.Module]) -> Type
B, _N, C = x.shape
x = x.view(B,H,W,C)
if H % 2 != 0 or W % 2 != 0:
from modules.errors import log
from modules.logger import log
log.warning('HiDiffusion: The feature size is not divisible by 2')
x = F.interpolate(x.permute(0,3,1,2).contiguous(), size=(window_size[0]*2, window_size[1]*2), mode='bicubic').permute(0,2,3,1).contiguous()
if type(shift_size) == list or type(shift_size) == tuple:
+34 -24
View File
@@ -4,7 +4,7 @@ import json
if os.environ.get('SD_PASTE_DEBUG', None) is not None:
from modules.errors import log
from modules.logger import log
debug = log.trace
else:
debug = lambda *args, **kwargs: None # pylint: disable=unnecessary-lambda-assignment
@@ -20,6 +20,8 @@ def quote(text):
def unquote(text):
if text is None:
return ''
if len(text) == 0 or text[0] != '"' or text[-1] != '"':
return text
try:
@@ -29,41 +31,49 @@ def unquote(text):
def parse(infotext):
if not isinstance(infotext, str):
if not isinstance(infotext, str) or len(infotext.strip()) == 0:
return {}
debug(f'Raw: {infotext}')
remaining = infotext.replace('\nSteps:', ' Steps:')
params = [' steps:', ' seed:', ' width:', ' height:', ' sampler:', ' size:', ' cfg scale:', ' pipeline:'] # first param is one of those
params += ['\nsteps:', '\nseed:', '\nwidth:', '\nheight:', '\nsampler:', '\nsize:', '\ncfg scale:', '\npipeline:']
params += ['.steps:', '.seed:', '.width:', '.height:', '.sampler:', '.size:', '.cfg scale:', '.pipeline:']
params = ['steps: ', 'seed: ', 'width: ', 'height: ', 'sampler: ', 'size: ', 'cfg scale: ', 'pipeline: '] # first param is one of those
prompts = ['negative prompt: ', 'template: ', 'negative template: '] # first prompt field is implied
search = params + prompts
prompt_end = [remaining.lower().find(p) for p in params if p in remaining.lower()]
prompt_end += [remaining.lower().find('negative prompt:')]
prompt_end = [p for p in prompt_end if p > -1]
prompt_end = min(prompt_end) if len(prompt_end) > 0 else 0
prompt = remaining[:prompt_end]
remaining = remaining.replace(prompt, '')
if prompt.lower().startswith('prompt: '):
prompt = prompt[8:]
# debug(f'Prompt: {prompt}')
def extract(keyword, text): # attempt to extract prompt from params
debug(f'Params extract: keyword="{keyword}" text="{text}"')
if keyword not in text.lower():
return '', text
if text.lower().startswith(f'{keyword}:'):
text = text[len(keyword) + 1:]
text = text.strip("\n").strip()
idxs = [text.lower().find(param) for param in search if param in text.lower()]
idx = min(idxs) if len(idxs) > 0 else 0
res = text[:idx]
text = text.replace(res, '')
res = res.strip("\n").strip()
debug(f'Params extract: keyword="{keyword}" idx={idx} res="{res}"')
return res, text
param_idx = [remaining.lower().find(p) for p in params if p in remaining.lower()]
param_idx = [p for p in param_idx if p > -1]
param_idx = min(param_idx) if len(param_idx) > 0 else 0
negative = remaining[:param_idx] if param_idx > 0 else ''
remaining = remaining.replace(negative, '')
if negative.lower().startswith('negative prompt: '):
negative = negative[16:]
# debug(f'Negative: {negative}')
prompt, remaining = extract('', remaining) # prompt is always first
negative, remaining = extract('negative prompt', remaining)
template, remaining = extract('template', remaining)
negative_template, remaining = extract('negative template', remaining)
params = dict(re_param.findall(remaining))
if len(list(params)) == 0:
params['Prompt'] = infotext
return params
params['Prompt'] = prompt
params['Negative prompt'] = negative
params['Prompt'] = prompt if len(prompt) > 0 else None
params['Negative prompt'] = negative if len(negative) > 0 else None
params['Template'] = template if len(template) > 0 else None
params['Negative template'] = negative_template if len(negative_template) > 0 else None
if params['Template'] == params['Prompt']:
params.pop('Template', None)
if params['Negative template'] == params['Negative prompt']:
params.pop('Negative template', None)
debug(f'Params: {params}')
for key, val in params.copy().items():
val = unquote(val).strip(" ,\n").replace('\\\n', '')
size = re_size.match(val)
+1 -1
View File
@@ -1,5 +1,5 @@
from collections import defaultdict
from modules.errors import log
from modules.logger import log
def patch(key, obj, field, replacement, add_if_not_exists:bool = False):
+1 -1
View File
@@ -279,7 +279,7 @@ def process_init(p: StableDiffusionProcessing):
p.all_subseeds = [int(subseed) + x for x in range(len(p.all_prompts))]
if reset_prompts:
if not hasattr(p, 'keep_prompts'):
p.all_prompts, p.all_negative_prompts = shared.prompt_styles.apply_styles_to_prompts(p.all_prompts, p.all_negative_prompts, p.styles, p.all_seeds)
p.all_prompts, p.all_negative_prompts = shared.prompt_styles.apply_styles_to_prompts(p.all_prompts, p.all_negative_prompts, p.styles, p.all_seeds, p=p)
p.prompts = p.all_prompts[(p.iteration * p.batch_size):((p.iteration+1) * p.batch_size)]
p.negative_prompts = p.all_negative_prompts[(p.iteration * p.batch_size):((p.iteration+1) * p.batch_size)]
+2
View File
@@ -579,6 +579,8 @@ class StableDiffusionProcessing:
self.negative_prompts = []
self.all_prompts = []
self.all_negative_prompts = []
self.all_templates = []
self.all_negative_templates = []
self.seeds = []
self.subseeds = []
self.all_seeds = []
+21 -2
View File
@@ -22,6 +22,7 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
return ''
if not hasattr(shared.sd_model, 'sd_checkpoint_info'):
return ''
if index is None:
index = position_in_batch + iteration * p.batch_size
if all_prompts is None:
@@ -40,6 +41,13 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
all_subseeds.insert(0, int(p.subseed))
while len(all_negative_prompts) <= index:
all_negative_prompts.insert(0, p.negative_prompt)
if p.all_templates is not None:
while len(p.all_templates) <= index:
p.all_templates.insert(0, '')
if p.all_negative_templates is not None:
while len(p.all_negative_templates) <= index:
p.all_negative_templates.insert(0, '')
comment = ', '.join(comments) if comments is not None and type(comments) is list else None
ops = list(set(p.ops))
args = {
@@ -208,7 +216,12 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
if type(v) is float or type(v) is int:
if v <= -1:
del args[k]
if isinstance(v, str):
if type(v) is list:
if len(v) == 0:
del args[k]
else:
args[k] = ', '.join([str(x) for x in v])
if type(v) is str:
if len(v) == 0 or v == '0x0':
del args[k]
debug(f'Infotext: args={args}')
@@ -219,7 +232,13 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts=None, all_seeds=No
if hasattr(p, 'original_negative'):
args['Original negative'] = p.original_negative
template = p.all_templates[index] if p.all_templates is not None and p.all_templates[index] else None
negative_template = p.all_negative_templates[index] if p.all_negative_templates is not None and p.all_negative_templates[index] else None
template_text = f"\nTemplate: {template}" if template else ''
negative_template_text = f"\nNegative template: {negative_template}" if negative_template else ''
negative_prompt_text = f"\nNegative prompt: {all_negative_prompts[index] if all_negative_prompts[index] else ''}"
infotext = f"{all_prompts[index]}{negative_prompt_text}\n{params_text}".strip()
infotext = f"{all_prompts[index]}{negative_prompt_text}{template_text}{negative_template_text}\n{params_text}".strip()
debug(f'Infotext: "{infotext}"')
return infotext
+2 -1
View File
@@ -4,7 +4,8 @@ import sys
import uuid
import time
import datetime
from modules.errors import log, display
from modules.logger import log
from modules.errors import display
debug_output = os.environ.get('SD_STATE_DEBUG', None)
+20 -8
View File
@@ -1,4 +1,5 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import re
import os
import csv
@@ -8,6 +9,8 @@ import random
from modules import files_cache, shared, infotext, sd_models, sd_vae
from modules.logger import log
if TYPE_CHECKING:
from modules.processing_class import StableDiffusionProcessing
debug_enabled = os.environ.get('SD_STYLES_DEBUG', None) is not None
@@ -109,7 +112,7 @@ def apply_curly_braces_to_prompt(prompt, seed=-1):
return prompt
def apply_file_wildcards(prompt, replaced = None, not_found = None, recursion=0, seed=-1):
def apply_file_wildcards(prompt, replaced = None, not_found = None, recursion=0, seed=-1, p: StableDiffusionProcessing | None = None):
if not_found is None:
not_found = []
if replaced is None:
@@ -135,6 +138,8 @@ def apply_file_wildcards(prompt, replaced = None, not_found = None, recursion=0,
prompt = prompt.replace(f"__{wildcard}__", choice, 1)
log.debug(f'Apply wildcard: select="{wildcard}" choice="{choice}" file="{file}" choices={len(lines)}')
replaced.append(wildcard)
if p is not None:
p.extra_generation_params['Wildcards'] = p.extra_generation_params.get('Wildcards', []) + [trimmed]
return prompt, True
except Exception as e:
log.error(f'Wildcards: wildcard={wildcard} file={file} {e}')
@@ -167,7 +172,7 @@ def apply_file_wildcards(prompt, replaced = None, not_found = None, recursion=0,
return prompt, replaced, not_found
def apply_wildcards_to_prompt(prompt, all_wildcards, seed=-1, silent=False):
def apply_wildcards_to_prompt(prompt, all_wildcards, seed=-1, silent=False, p: StableDiffusionProcessing | None = None):
if prompt is None or len(prompt) == 0:
return prompt
old_state = None
@@ -189,7 +194,7 @@ def apply_wildcards_to_prompt(prompt, all_wildcards, seed=-1, silent=False):
except Exception as e:
log.error(f'Wildcards: wildcard="{wildcard}" error={e}')
t1 = time.time()
prompt, replaced_file, not_found = apply_file_wildcards(prompt, [], [], recursion=0, seed=seed)
prompt, replaced_file, not_found = apply_file_wildcards(prompt, [], [], recursion=0, seed=seed, p=p)
t2 = time.time()
if replaced and not silent:
log.debug(f'Apply wildcards: {replaced} path="{shared.opts.wildcards_dir}" type=style time={t1-t0:.2f}')
@@ -231,7 +236,7 @@ def apply_styles_to_extra(p, style: Style):
]
reference_style = get_reference_style()
extra = infotext.parse(reference_style) if shared.opts.extra_network_reference_values else {}
style_extra = apply_wildcards_to_prompt(style.extra, [style.wildcards], silent=True)
style_extra = apply_wildcards_to_prompt(style.extra, [style.wildcards], silent=True, p=p)
style_extra = ' ' + style_extra.lower()
extra.update(infotext.parse(style_extra))
extra.pop('Prompt', None)
@@ -374,7 +379,7 @@ class StyleDatabase:
return []
return [self.find_style(x).negative_prompt for x in styles]
def apply_styles_to_prompts(self, prompts, negatives, styles, seeds):
def apply_styles_to_prompts(self, prompts, negatives, styles, seeds, p: StableDiffusionProcessing | None = None):
if styles is None:
return prompts, negatives
if not isinstance(styles, list):
@@ -383,9 +388,15 @@ class StyleDatabase:
if prompts is None or not isinstance(prompts, list):
log.error(f'Styles invalid prompts: {prompts}')
return prompts, negatives
if seeds is None or not isinstance(prompts, list):
if seeds is None or not isinstance(seeds, list):
log.error(f'Styles invalid seeds: {seeds}')
return prompts, negatives
if p is not None:
if p.all_templates is None or len(p.all_templates) < len(prompts):
p.all_templates = prompts
if p.all_negative_templates is None or len(p.all_negative_templates) < len(negatives):
p.all_negative_templates = negatives
jobid = shared.state.begin('Styles')
parsed_positive = []
parsed_negative = []
@@ -397,7 +408,7 @@ class StyleDatabase:
prompt = prompts[i]
prompt = apply_curly_braces_to_prompt(prompt, seeds[i])
prompt = apply_styles_to_prompt(prompt, [self.find_style(x).prompt for x in styles])
prompt = apply_wildcards_to_prompt(prompt, [self.find_style(x).wildcards for x in styles], seeds[i])
prompt = apply_wildcards_to_prompt(prompt, [self.find_style(x).wildcards for x in styles], seeds[i], p=p)
parsed_positive.append(prompt)
for i in range(len(negatives)):
if seeds[i]> 0:
@@ -405,7 +416,7 @@ class StyleDatabase:
prompt = negatives[i]
prompt = apply_curly_braces_to_prompt(prompt, seeds[i])
prompt = apply_styles_to_prompt(prompt, [self.find_style(x).negative_prompt for x in styles])
prompt = apply_wildcards_to_prompt(prompt, [self.find_style(x).wildcards for x in styles], seeds[i])
prompt = apply_wildcards_to_prompt(prompt, [self.find_style(x).wildcards for x in styles], seeds[i], p=p)
parsed_negative.append(prompt)
random.setstate(random_state)
@@ -445,6 +456,7 @@ class StyleDatabase:
if p.styles is None or not isinstance(p.styles, list):
log.error(f'Styles invalid: {p.styles}')
return
for style in p.styles:
if style is None or style == '':
continue
+29 -4
View File
@@ -53,17 +53,42 @@ def plaintext_to_html(text, elem_classes=None):
def infotext_to_html(text):
res = infotext.parse(text)
prompt = res.get('Prompt', '')
negative = res.get('Negative prompt', '')
res.pop('Prompt', None)
negative = res.get('Negative prompt', '')
res.pop('Negative prompt', None)
template = res.get('Template', '')
res.pop('Template', None)
negative_template = res.get('Negative template', '')
res.pop('Negative template', None)
runtime = {}
runtime['App'] = res.get('App', '')
res.pop('App', None)
runtime['Version'] = res.get('Version', '')
res.pop('Version', None)
runtime['Pipeline'] = res.get('Pipeline', '')
res.pop('Pipeline', None)
runtime['Operations'] = res.get('Operations', '')
res.pop('Operations', None)
params = [f'{k}: {v}' for k, v in res.items() if v is not None and not k.endswith('-1') and not k.endswith('-2')]
params = '| '.join(params) if len(params) > 0 else ''
runtime = [f'{k}: {v}' for k, v in runtime.items() if v is not None and not k.endswith('-1') and not k.endswith('-2')]
runtime = '| '.join(runtime) if len(runtime) > 0 else ''
code = ''
if len(prompt) > 0:
if prompt is not None and len(prompt) > 0:
code += f'<p><b>Prompt:</b> {html.escape(prompt)}</p>'
if len(negative) > 0:
if negative is not None and len(negative) > 0:
code += f'<p><b>Negative:</b> {html.escape(negative)}</p>'
if len(params) > 0:
if template is not None and len(template) > 0:
code += f'<p><b>Template:</b> {html.escape(template)}</p>'
if negative_template is not None and len(negative_template) > 0:
code += f'<p><b>Negative Template:</b> {html.escape(negative_template)}</p>'
if runtime is not None and len(runtime) > 0:
code += f'<p><b>Runtime:</b> {html.escape(runtime)}</p>'
if params is not None and len(params) > 0:
code += f'<p><b>Parameters:</b> {html.escape(params)}</p>'
return code
+2
View File
@@ -354,6 +354,8 @@ def create_ui(_blocks: gr.Blocks=None):
# prompt
(prompt, "Prompt"),
(negative, "Negative prompt"),
(prompt, "Template"), # override prompt with template if available
(negative, "Negative template"),
(styles, "Styles"),
# input
(denoising_strength, "Denoising strength"),
+2
View File
@@ -247,6 +247,8 @@ def create_ui():
# prompt
(img2img_prompt, "Prompt"),
(img2img_negative_prompt, "Negative prompt"),
(img2img_prompt, "Template"), # override prompt with template if available
(img2img_negative_prompt, "Negative template"),
(img2img_prompt_styles, "Styles"),
# sampler
(sampler_index, "Sampler"),
+2
View File
@@ -98,6 +98,8 @@ def create_ui():
# prompt
(txt2img_prompt, "Prompt"),
(txt2img_negative_prompt, "Negative prompt"),
(txt2img_prompt, "Template"), # override prompt with template if available
(txt2img_negative_prompt, "Negative template"),
(txt2img_prompt_styles, "Styles"),
# main
(width, "Size-1"),
+2
View File
@@ -53,6 +53,8 @@ def create_ui():
paste_fields = [
(prompt, "Prompt"), # cannot add more fields as they are not defined yet
(negative, "Negative prompt"),
(prompt, "Template"), # override prompt with template if available
(negative, "Negative template"),
(width, "Width"),
(height, "Height"),
(frames, "Frames"),
+1 -1
View File
@@ -4,7 +4,7 @@ Credit and original implementation: <https://github.com/ToTheBeginning/PuLID>
import os
import sys
from modules.errors import log
from modules.logger import log
sys.path.append(os.path.dirname(__file__))
try:
from pulid_sdxl import StableDiffusionXLPuLIDPipeline, StableDiffusionXLPuLIDPipelineImage, StableDiffusionXLPuLIDPipelineInpaint
+1 -1
View File
@@ -23,7 +23,7 @@ from .pulid_utils import img2tensor, tensor2img
from eva_clip import create_model_and_transforms
from eva_clip.constants import OPENAI_DATASET_MEAN, OPENAI_DATASET_STD
from encoders_transformer import IDFormer, IDEncoder
from modules.errors import log
from modules.logger import log
debug = log.trace if os.environ.get('SD_PULID_DEBUG', None) is not None else lambda *args, **kwargs: None
+1 -1
View File
@@ -50,7 +50,7 @@ finally:
installer.add_args(modules.cmd_args.parser)
modules.cmd_args.parsed, _ = modules.cmd_args.parser.parse_known_args([])
from modules.errors import log # pylint: disable=wrong-import-position
from modules.logger import log # pylint: disable=wrong-import-position
from modules import shared # pylint: disable=wrong-import-position
from modules.lora import ( # pylint: disable=wrong-import-position
network, network_lora, network_lokr, network_hada, network_oft, network_boft,
+2 -3
View File
@@ -18,7 +18,6 @@ import time
import types
import torch
import numpy as np
from types import SimpleNamespace
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, script_dir)
@@ -40,7 +39,7 @@ _mock_taesd.CQYAN_MODELS = {}
_mock_taesd.encode = lambda x: torch.zeros(1, 4, 8, 8)
sys.modules['modules.vae.sd_vae_taesd'] = _mock_taesd
from modules.errors import log
from modules.logger import log
# Results tracking
results = {
@@ -417,7 +416,7 @@ def _make_mock_p(**overrides):
'extra_generation_params': {},
}
defaults.update(overrides)
return SimpleNamespace(**defaults)
return types.SimpleNamespace(**defaults)
def test_correction_noop():
+1 -1
View File
@@ -7,7 +7,7 @@ import torch
# Ensure we can import modules
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
from modules.errors import log
from modules.logger import log
from modules.res4lyf import (
BASE, SIMPLE, VARIANTS,
RESUnifiedScheduler, RESMultistepScheduler, RESDEISMultistepScheduler,