feat: add ComfyUI-style prompt weighting via compel
- Support (word:weight), [word], ((nested)) syntax - Support BREAK for conditioning chunking (.and() translation) - Use CompelForSDXL (modern API, avoids deprecation) - Add --raw flag to bypass weighting and fall back to plain encoding - Update README with Prompt Syntax section and examples - Add docs/comfyui-prompt-style.md with design doc
This commit is contained in:
43
README.md
43
README.md
@@ -70,6 +70,7 @@ vnasset generate \
|
||||
| `--cfg` | `4.5` | CFG scale |
|
||||
| `--seed` | `0` | RNG seed (use `random` for random) |
|
||||
| `--output` | `output.png` | Output path |
|
||||
| `--raw` | `false` | Disable Compel prompt weighting (fall back to plain diffusers encoding) |
|
||||
|
||||
When `--seed` is `random`, a random seed is generated and recorded in the
|
||||
metadata file.
|
||||
@@ -154,18 +155,39 @@ implementation that avoids the SDPA dispatch entirely.
|
||||
Tested with `torch 2.11.0+rocm7.2`. Newer ROCm nightlies (2.13+, 2.14+) may
|
||||
cause GPU crashes. If you encounter segfaults, try matching this version.
|
||||
|
||||
## Prompt Compatibility
|
||||
## Prompt Syntax
|
||||
|
||||
VNAsset uses standard diffusers SDXL encoding, which is equivalent to ComfyUI's
|
||||
`BNK_CLIPTextEncodeAdvanced` with `token_normalization=none` and
|
||||
`weight_interpretation=comfy` for plain comma-separated prompts.
|
||||
VNAsset supports **ComfyUI-style prompt weighting** via the `compel` library.
|
||||
|
||||
ComfyUI-specific syntax is **not currently supported**:
|
||||
- `(word:1.2)` — prompt weighting
|
||||
- `BREAK` — conditioning chunking
|
||||
### Weighting
|
||||
|
||||
If your prompts rely on these, you'll get different output than the ComfyUI
|
||||
workflow. compel integration is planned for later.
|
||||
| Syntax | Effect |
|
||||
|--------|--------|
|
||||
| `(word)` | Boost ×1.1 |
|
||||
| `(word:1.5)` | Boost ×1.5 |
|
||||
| `(word:0.6)` | De-emphasize ×0.6 |
|
||||
| `[word]` | De-emphasize ×0.9 (shorthand) |
|
||||
| `\(word\)` | Literal parentheses (escaped) |
|
||||
|
||||
```bash
|
||||
vnasset generate \
|
||||
--checkpoint models/novaAnimeXL_ilV190.safetensors \
|
||||
--prompt "(masterpiece:1.2), 1girl, (red hair:1.3), blue eyes, [glasses]" \
|
||||
--negative-prompt "(bad quality, worst quality:1.4)" \
|
||||
--steps 20 --seed 42
|
||||
```
|
||||
|
||||
### BREAK (condition chunking)
|
||||
|
||||
Split the prompt into independent conditioning chunks with `BREAK`:
|
||||
|
||||
```bash
|
||||
vnasset generate \
|
||||
--prompt "1girl, red hair, standing BREAK blue sky, cherry blossoms" \
|
||||
--steps 20 --seed 42
|
||||
```
|
||||
|
||||
Use `--raw` to bypass weighting and fall back to plain diffusers encoding.
|
||||
|
||||
## Future Improvements
|
||||
|
||||
@@ -180,5 +202,4 @@ workflow. compel integration is planned for later.
|
||||
availability or a ComfyUI installation.
|
||||
- **Qwen Image Edit support** — `vnasset edit` and `vnasset pipeline` for
|
||||
batch expression/outfit variant editing.
|
||||
- **compel prompt weighting** — support `(word:weight)` and `BREAK` syntax for
|
||||
parity with ComfyUI prompt encoding.
|
||||
|
||||
|
||||
227
docs/comfyui-prompt-style.md
Normal file
227
docs/comfyui-prompt-style.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# ComfyUI Prompt Style Support
|
||||
|
||||
## What is ComfyUI Prompt Style?
|
||||
|
||||
ComfyUI's prompt encoding extends standard CLIP text encoding with two
|
||||
capabilities that plain diffusers does not provide:
|
||||
|
||||
1. **Per-token prompt weighting** (`(word:weight)`)
|
||||
2. **Condition chunking** (`BREAK`)
|
||||
|
||||
VNAsset currently uses standard diffusers SDXL encoding (equivalent to ComfyUI's
|
||||
`BNK_CLIPTextEncodeAdvanced` with `token_normalization=none` and
|
||||
`weight_interpretation=comfy`), but only for plain comma-separated prompts.
|
||||
ComfyUI-specific syntax `(word:1.2)` and `BREAK` are **not yet supported**.
|
||||
|
||||
---
|
||||
|
||||
## How Prompt Weighting Works
|
||||
|
||||
### Syntax
|
||||
|
||||
Users write weight annotations directly in the prompt string:
|
||||
|
||||
```
|
||||
1girl, (red hair:1.3), [glasses], (smile)
|
||||
```
|
||||
|
||||
| Syntax | Effect |
|
||||
|--------|--------|
|
||||
| `(word)` | Boost ×1.1 (default) |
|
||||
| `((word))` | Nested boost ×1.21 (1.1²) |
|
||||
| `(word:1.5)` | Explicit weight 1.5 (boosted) |
|
||||
| `(word:0.6)` | Explicit weight 0.6 (de-emphasized) |
|
||||
| `[word]` | Shorthand de-emphasis ×0.9 (same as `(word:0.9)`) |
|
||||
| `\(word\)` | Literal parentheses (escaped, not weighted) |
|
||||
|
||||
Nested and multi-word weighting also works:
|
||||
|
||||
```
|
||||
((masterpiece, best quality:1.3))
|
||||
(red hair, blue eyes:1.2)
|
||||
```
|
||||
|
||||
### What happens under the hood
|
||||
|
||||
This is **not** prompt rewriting. The weighting is applied at the **embedding
|
||||
tensor level** after CLIP encoding:
|
||||
|
||||
```
|
||||
Prompt string → Tokenize → CLIP encode → Scale embeddings by weights → UNet
|
||||
```
|
||||
|
||||
For each token tagged with a weight, its embedding vector is multiplied by that
|
||||
weight. The rest of the pipeline (UNet, VAE) sees the same tensor shapes and
|
||||
operates normally.
|
||||
|
||||
For example, `(red hair:1.5)` means:
|
||||
|
||||
1. Tokenize `red` and `hair` as usual
|
||||
2. Get their embedding vectors from CLIP (each is a vector of floats)
|
||||
3. Multiply each vector by 1.5
|
||||
4. Pass the scaled embeddings to the UNet
|
||||
|
||||
The UNet then pays 1.5× more "attention" to those tokens.
|
||||
|
||||
### How SDXL makes this trickier
|
||||
|
||||
SDXL has **two** text encoders:
|
||||
|
||||
| Encoder | Tokenizer | Pooled output? |
|
||||
|---------|-----------|----------------|
|
||||
| CLIP-L (ViT-L) | `tokenizer` | No |
|
||||
| OpenCLIP-G (ViT-bigG) | `tokenizer_2` | **Yes** — used for global conditioning |
|
||||
|
||||
Prompt weighting must be applied to the outputs of **both** encoders. The
|
||||
pooled embedding from OpenCLIP-G also needs to be weighted consistently.
|
||||
|
||||
---
|
||||
|
||||
## How BREAK Works
|
||||
|
||||
`BREAK` splits a single prompt into multiple independent conditioning vectors,
|
||||
which are then **concatenated** along the sequence dimension:
|
||||
|
||||
```
|
||||
1girl, red hair, standing BREAK blue sky, cherry blossoms, daytime
|
||||
```
|
||||
|
||||
Instead of one CLIP encoding that mixes the character and background concepts
|
||||
into a single tensor, this creates **two separate conditioning tensors**:
|
||||
|
||||
```
|
||||
Chunk 1: "1girl, red hair, standing" → conditioning tensor A (shape [1, N₁, 2048])
|
||||
Chunk 2: "blue sky, cherry blossoms, daytime" → conditioning tensor B (shape [1, N₂, 2048])
|
||||
↓
|
||||
Concatenate: [1, N₁+N₂, 2048]
|
||||
```
|
||||
|
||||
Each chunk gets its own CLIP forward pass, so the character description doesn't
|
||||
bleed into the background encoding and vice versa. The UNet receives a longer
|
||||
conditioning sequence with the two concepts cleanly separated.
|
||||
|
||||
### BREAK vs `.and()`
|
||||
|
||||
The `compel` library uses `.and()` as its native concatenation operator:
|
||||
|
||||
```
|
||||
"a cat .and() a dog" ← compel native
|
||||
"a cat BREAK a dog" ← ComfyUI syntax
|
||||
```
|
||||
|
||||
Both produce the same result (two concatenated conditionings). We'll support
|
||||
both forms.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Library: `compel`
|
||||
|
||||
We'll use the [`compel`](https://github.com/damian0815/compel) library from the
|
||||
InvokeAI/diffusers ecosystem. It is:
|
||||
|
||||
- The standard implementation for A1111/ComfyUI prompt weighting
|
||||
- Well-tested across millions of generations
|
||||
- Maintained alongside diffusers
|
||||
- Already mentioned in the README as the planned approach
|
||||
|
||||
### Step 1: Add dependency
|
||||
|
||||
**`pyproject.toml`** — add `compel` to `dependencies`.
|
||||
|
||||
### Step 2: New module `vnassets/prompt.py`
|
||||
|
||||
A thin Compel wrapper for SDXL. Responsibilities:
|
||||
|
||||
- Accept a loaded `StableDiffusionXLPipeline`
|
||||
- Extract `tokenizer`, `tokenizer_2`, `text_encoder`, `text_encoder_2`
|
||||
- Build a `Compel` instance configured for SDXL's dual-encoder setup with
|
||||
`ReturnedEmbeddingsType.PENULTIMATE_OR_LAST_HIDDEN_STATES_NON_NORMALIZED`
|
||||
(matches ComfyUI's `token_normalization=none`)
|
||||
- Parse the positive prompt into `(prompt_embeds, pooled_prompt_embeds)`
|
||||
- Parse the negative prompt into `(negative_prompt_embeds, negative_pooled_prompt_embeds)`
|
||||
- Handle `BREAK` by translating to `.and()` or splitting + concatenating manually
|
||||
|
||||
API:
|
||||
|
||||
```python
|
||||
from .prompt import build_compel, encode_prompts
|
||||
|
||||
compel = build_compel(pipe)
|
||||
pos_embeds, pos_pooled, neg_embeds, neg_pooled = encode_prompts(
|
||||
compel, prompt, negative_prompt
|
||||
)
|
||||
```
|
||||
|
||||
### Step 3: Modify `vnassets/generate.py`
|
||||
|
||||
Replace the raw-string path with pre-computed weighted embeddings:
|
||||
|
||||
```python
|
||||
# Before
|
||||
image = pipe(
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
...
|
||||
)
|
||||
|
||||
# After
|
||||
compel = build_compel(pipe)
|
||||
pos_embeds, pos_pooled, neg_embeds, neg_pooled = encode_prompts(
|
||||
compel, prompt, negative_prompt
|
||||
)
|
||||
image = pipe(
|
||||
prompt_embeds=pos_embeds,
|
||||
pooled_prompt_embeds=pos_pooled,
|
||||
negative_prompt_embeds=neg_embeds,
|
||||
negative_pooled_prompt_embeds=neg_pooled,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
Compel will be initialized **after** the pipeline is loaded to device (since
|
||||
text encoders must be on the correct device).
|
||||
|
||||
### Step 4: Add `--raw` flag (optional opt-out)
|
||||
|
||||
Add a `--raw` flag to `vnasset generate` that bypasses Compel and uses the
|
||||
plain string path. Useful when:
|
||||
|
||||
- The prompt contains literal parentheses that shouldn't be parsed
|
||||
- Debugging — comparing weighted vs unweighted output
|
||||
|
||||
### Step 5: Update `README.md`
|
||||
|
||||
- Remove the "not currently supported" note under Prompt Compatibility
|
||||
- Add a **Prompt Syntax** section documenting `(word:weight)` and `BREAK`
|
||||
- Add examples showing weighted prompts
|
||||
|
||||
---
|
||||
|
||||
## What's NOT affected
|
||||
|
||||
The `vnasset edit` command is **unchanged**. Qwen Image Edit uses natural
|
||||
language instructions (`"make her smile"`) rather than keyword-prompt
|
||||
weighting. Compel does not support Qwen's text encoder (Qwen2.5-VL), and
|
||||
weighting makes no sense for editing instructions anyway.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `pyproject.toml` | Add `compel` dependency |
|
||||
| `vnassets/prompt.py` | **New** — Compel wrapper for SDXL |
|
||||
| `vnassets/generate.py` | Use Compel embeddings instead of raw strings |
|
||||
| `vnassets/cli.py` | Add `--raw` flag to `generate` command |
|
||||
| `README.md` | Document new syntax support |
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Compel on GitHub](https://github.com/damian0815/compel)
|
||||
- [ComfyUI BNK_CLIPTextEncodeAdvanced](https://github.com/BlakeOne/ComfyUI-BNK-CLIPTextEncode-Advanced)
|
||||
- [SDXL dual text encoder architecture](https://huggingface.co/docs/diffusers/using-diffusers/sdxl#the-dual-text-encoder-architecture)
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"pillow",
|
||||
"pyyaml",
|
||||
"click",
|
||||
"compel",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
|
||||
@@ -34,7 +34,11 @@ def main():
|
||||
help="RNG seed (integer or 'random')",
|
||||
)
|
||||
@click.option("--output", default="output.png", help="Output image path")
|
||||
def generate_cmd(checkpoint, prompt, negative_prompt, width, height, steps, cfg, seed, output):
|
||||
@click.option(
|
||||
"--raw", is_flag=True,
|
||||
help="Disable ComfyUI-style prompt weighting (use plain diffusers encoding)",
|
||||
)
|
||||
def generate_cmd(checkpoint, prompt, negative_prompt, width, height, steps, cfg, seed, output, raw):
|
||||
"""Generate an image from an SDXL checkpoint."""
|
||||
generate(
|
||||
checkpoint_path=checkpoint,
|
||||
@@ -46,6 +50,7 @@ def generate_cmd(checkpoint, prompt, negative_prompt, width, height, steps, cfg,
|
||||
cfg=cfg,
|
||||
seed=seed,
|
||||
output_path=output,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +65,9 @@ def generate_cmd(checkpoint, prompt, negative_prompt, width, height, steps, cfg,
|
||||
help="RNG seed (integer or 'random')",
|
||||
)
|
||||
@click.option("--output", default="output.png", help="Output image path")
|
||||
def edit_cmd(model, input_path, prompt, steps, cfg, seed, output):
|
||||
@click.option("--lora", "lora_path", default=None,
|
||||
help="Path to LoRA .safetensors (e.g. Lightning 4-step LoRA)")
|
||||
def edit_cmd(model, input_path, prompt, steps, cfg, seed, output, lora_path):
|
||||
"""Edit an image using Qwen Image Edit."""
|
||||
edit(
|
||||
model_path=model,
|
||||
@@ -70,4 +77,5 @@ def edit_cmd(model, input_path, prompt, steps, cfg, seed, output):
|
||||
cfg=cfg,
|
||||
seed=seed,
|
||||
output_path=output,
|
||||
lora_path=lora_path,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import torch
|
||||
from diffusers import StableDiffusionXLPipeline
|
||||
|
||||
from .attention import patch_unet_attention
|
||||
from .prompt import build_compel, encode_prompts
|
||||
|
||||
|
||||
def generate(
|
||||
@@ -20,6 +21,7 @@ def generate(
|
||||
cfg: float = 4.5,
|
||||
seed: int | None = None,
|
||||
output_path: str = "output.png",
|
||||
raw: bool = False,
|
||||
) -> None:
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
# bfloat16 avoids ROCm kernel crashes on RDNA 3.5; float16 segfaults
|
||||
@@ -38,11 +40,18 @@ def generate(
|
||||
)
|
||||
pipe.to(device)
|
||||
patch_unet_attention(pipe.unet)
|
||||
|
||||
if not raw:
|
||||
compel = build_compel(pipe)
|
||||
prompt_embeds, pooled_embeds, neg_embeds, neg_pooled = encode_prompts(
|
||||
compel, prompt, negative_prompt
|
||||
)
|
||||
t_load = time.perf_counter() - t0
|
||||
|
||||
generator = torch.Generator(device=device).manual_seed(seed)
|
||||
|
||||
t1 = time.perf_counter()
|
||||
if raw:
|
||||
image = pipe(
|
||||
prompt=prompt,
|
||||
negative_prompt=negative_prompt,
|
||||
@@ -52,6 +61,18 @@ def generate(
|
||||
guidance_scale=cfg,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
else:
|
||||
image = pipe(
|
||||
prompt_embeds=prompt_embeds,
|
||||
pooled_prompt_embeds=pooled_embeds,
|
||||
negative_prompt_embeds=neg_embeds,
|
||||
negative_pooled_prompt_embeds=neg_pooled,
|
||||
width=width,
|
||||
height=height,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=cfg,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
t_infer = time.perf_counter() - t1
|
||||
|
||||
image.save(output)
|
||||
|
||||
55
vnassets/prompt.py
Normal file
55
vnassets/prompt.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""ComfyUI-style prompt weighting via compel.
|
||||
|
||||
Supports (word:weight), [word], ((nested)), and BREAK syntax for SDXL prompts.
|
||||
"""
|
||||
import torch
|
||||
from compel import CompelForSDXL
|
||||
|
||||
|
||||
def _translate_break(prompt: str) -> str:
|
||||
"""Translate ComfyUI BREAK syntax to compel .and() syntax.
|
||||
|
||||
Compel's native .and() is the equivalent of ComfyUI's BREAK —
|
||||
both split the prompt into separate conditioning chunks.
|
||||
We support both forms transparently.
|
||||
"""
|
||||
# Replace BREAK with .and(), then normalize to clean chunks
|
||||
chunks = [c.strip() for c in prompt.replace("BREAK", ".and()").split(".and()")]
|
||||
chunks = [c for c in chunks if c]
|
||||
return " .and() ".join(chunks)
|
||||
|
||||
|
||||
def build_compel(pipe):
|
||||
"""Create a CompelForSDXL instance from a loaded SDXL pipeline.
|
||||
|
||||
Uses CompelForSDXL which wraps both CLIP encoders and configures
|
||||
ComfyUI-equivalent embedding output (no token normalization).
|
||||
"""
|
||||
return CompelForSDXL(pipe)
|
||||
|
||||
|
||||
def encode_prompts(
|
||||
compel: CompelForSDXL,
|
||||
prompt: str,
|
||||
negative_prompt: str = "",
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Encode positive and negative prompts into SDXL embedding tensors.
|
||||
|
||||
Returns:
|
||||
(prompt_embeds, pooled_prompt_embeds,
|
||||
negative_prompt_embeds, negative_pooled_prompt_embeds)
|
||||
|
||||
CompelForSDXL automatically handles BREAK/.and() concatenation and
|
||||
pads negative embeddings to match positive when chunk counts differ.
|
||||
"""
|
||||
prompt = _translate_break(prompt)
|
||||
negative_prompt = _translate_break(negative_prompt) if negative_prompt else ""
|
||||
|
||||
result = compel(prompt, negative_prompt=negative_prompt or None)
|
||||
|
||||
return (
|
||||
result.embeds,
|
||||
result.pooled_embeds,
|
||||
result.negative_embeds,
|
||||
result.negative_pooled_embeds,
|
||||
)
|
||||
Reference in New Issue
Block a user