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:
Michele Rossi
2026-07-07 16:16:54 +02:00
parent 2cf58aaa5c
commit 9564202e6d
6 changed files with 355 additions and 22 deletions

View 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)