- 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
228 lines
6.9 KiB
Markdown
228 lines
6.9 KiB
Markdown
# 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)
|