Spaces:
Runtime error
Runtime error
Commit Β·
0614a22
1
Parent(s): 55650fa
fix(zerogpu): cap queue concurrency and raise @spaces.GPU duration (Fast 120 / Quality 180)
Browse filesThe 60s Fast budget could not cover ZeroGPU GPU-attach + the 58GB tensor unpack +
inference, so tasks were killed ('GPU task aborted'). A buggy duration lambda
(positional slice on a bound method) also mis-requested the budget, causing
'Expired ZeroGPU proxy token' on Quality. Use a robust arg-extractor with budgets
matching the official Qwen-Image-Edit-2511 Space; cap queue concurrency to 1 so
one edit = one GPU task.
- .gitignore +3 -0
- app.py +3 -1
- backend.py +20 -9
- docs/superpowers/specs/2026-06-15-multi-lora-design.md +396 -0
- tests/test_backend.py +11 -11
.gitignore
CHANGED
|
@@ -38,3 +38,6 @@ flagged/
|
|
| 38 |
|
| 39 |
# CI workflow retained locally; pushing it needs a PAT with the "workflow" scope
|
| 40 |
.github/workflows/
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
# CI workflow retained locally; pushing it needs a PAT with the "workflow" scope
|
| 40 |
.github/workflows/
|
| 41 |
+
|
| 42 |
+
# Playwright MCP artifacts + local test assets
|
| 43 |
+
.playwright-mcp/
|
app.py
CHANGED
|
@@ -183,4 +183,6 @@ if models.on_spaces():
|
|
| 183 |
|
| 184 |
|
| 185 |
if __name__ == "__main__":
|
| 186 |
-
|
|
|
|
|
|
|
|
|
| 183 |
|
| 184 |
|
| 185 |
if __name__ == "__main__":
|
| 186 |
+
# default_concurrency_limit=1 β one ZeroGPU task at a time (a 58 GB model can't
|
| 187 |
+
# share a slot; uncapped queueing also spawns multiple GPU workers).
|
| 188 |
+
build_app().queue(default_concurrency_limit=1).launch()
|
backend.py
CHANGED
|
@@ -21,19 +21,30 @@ def _identity(fn):
|
|
| 21 |
|
| 22 |
|
| 23 |
def duration_for(mode: str, params: dict[str, Any]) -> int:
|
| 24 |
-
"""Estimate ZeroGPU duration for a request
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
| 29 |
"""
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
return
|
| 34 |
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def _build_pipeline() -> Any:
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def duration_for(mode: str, params: dict[str, Any]) -> int:
|
| 24 |
+
"""Estimate the ZeroGPU slot duration (seconds) for a request; clamped to [60, 180].
|
| 25 |
|
| 26 |
+
The dominant cost on a ~58 GB model is the ZeroGPU GPU-attach + tensor unpack
|
| 27 |
+
to the assigned GPU, NOT the step count β so both presets need a generous
|
| 28 |
+
budget (the official Qwen-Image-Edit-2511 Space reserves 180 s). Fast gets a
|
| 29 |
+
smaller-but-ample budget; Quality reserves the full window.
|
| 30 |
"""
|
| 31 |
+
p = params if isinstance(params, dict) else {}
|
| 32 |
+
if p.get("speed") == "Fast":
|
| 33 |
+
return 120
|
| 34 |
+
return 180
|
| 35 |
|
| 36 |
|
| 37 |
+
def _duration_arg(*args: Any, **kwargs: Any) -> int:
|
| 38 |
+
"""Locate the params dict among the decorated method's call args.
|
| 39 |
+
|
| 40 |
+
spaces passes generate()'s args (which include ``self``) to this callable, so
|
| 41 |
+
we scan for the params dict rather than relying on a fixed positional index.
|
| 42 |
+
"""
|
| 43 |
+
params = next((a for a in args if isinstance(a, dict)), {})
|
| 44 |
+
return duration_for("", params)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
_GPU = spaces.GPU(duration=_duration_arg) if (spaces is not None and _ON_SPACES) else _identity
|
| 48 |
|
| 49 |
|
| 50 |
def _build_pipeline() -> Any:
|
docs/superpowers/specs/2026-06-15-multi-lora-design.md
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-LoRA Support β Design Spec
|
| 2 |
+
|
| 3 |
+
> Date: 2026-06-15 Β· Status: proposed (pending operator approval)
|
| 4 |
+
> Refs: `docs/superpowers/specs/2026-06-15-qwen-image-editor-design.md` (base design)
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## 1. Overview
|
| 9 |
+
|
| 10 |
+
This document specifies multi-LoRA stacking for **Quality mode** on the Qwen Image Editor. The Lightning LoRA (`lightx2v/Qwen-Image-Edit-2511-Lightning`) remains Fast-mode-only and is never combined with Quality LoRAs. In Quality mode, users may enable 0βN LoRAs from a curated built-in gallery, optionally augmented by a user-supplied custom LoRA, each with an independent weight slider. The feature applies identically to both Edit and Compose tabs (with one compose-specific nuance documented in Β§4).
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 2. LoRA Stacking β Technical Approach
|
| 15 |
+
|
| 16 |
+
### 2.1 Diffusers multi-adapter API
|
| 17 |
+
|
| 18 |
+
`QwenImageEditPlusPipeline` inherits from `FluxPipeline` / `DiffusionPipeline` and carries the `LoraLoaderMixin` mixin. The public multi-adapter API is:
|
| 19 |
+
|
| 20 |
+
```python
|
| 21 |
+
# Load each adapter (once, at startup or lazily):
|
| 22 |
+
pipe.load_lora_weights(repo_id, weight_name=filename, adapter_name=unique_name)
|
| 23 |
+
|
| 24 |
+
# Before each inference call β activate all desired adapters with per-LoRA weight:
|
| 25 |
+
pipe.set_adapters([name_a, name_b, ...], [weight_a, weight_b, ...])
|
| 26 |
+
|
| 27 |
+
# For Quality mode with no LoRAs active:
|
| 28 |
+
pipe.disable_lora()
|
| 29 |
+
|
| 30 |
+
# For Fast mode (Lightning only):
|
| 31 |
+
pipe.set_adapters([LORA_ADAPTER_NAME], [1.0])
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
`set_adapters` replaces the active adapter set atomically β no separate enable/disable calls are needed. Weights are float scalers applied as `lora_scale` per adapter during the attention forward pass; they do **not** modify stored weights, so switching between requests is free and safe.
|
| 35 |
+
|
| 36 |
+
### 2.2 Coexistence with the Lightning adapter
|
| 37 |
+
|
| 38 |
+
The Lightning adapter (`adapter_name="lightning"`) is loaded at startup and stays resident in PEFT's adapter registry throughout the process lifetime. The critical invariant:
|
| 39 |
+
|
| 40 |
+
- **Fast mode:** `set_adapters(["lightning"], [1.0])` β only Lightning is active.
|
| 41 |
+
- **Quality mode, no Quality LoRAs:** `disable_lora()` β no adapters active.
|
| 42 |
+
- **Quality mode, with Quality LoRAs:** `set_adapters([qa, qb, ...], [wa, wb, ...])` β Lightning is **not** in this list and therefore **not** active.
|
| 43 |
+
|
| 44 |
+
Lightning is **never** included in a Quality-mode `set_adapters` call. This is enforced by construction: `_apply_speed` in `modes.py` will be extended to accept an optional `quality_adapters: list[tuple[str, float]]` parameter; when speed is "Quality", it builds the adapter list exclusively from that parameter (never touching "lightning").
|
| 45 |
+
|
| 46 |
+
### 2.3 Fuse vs. dynamic β decision: always dynamic
|
| 47 |
+
|
| 48 |
+
Fusing (`fuse_lora`) bakes adapter weights into the base model in-place. This is irreversible without `unfuse_lora`, and `unfuse_lora` is approximate (accumulates floating-point error). On ZeroGPU, the pipeline object is shared across requests and must be reusable; fusing one user's LoRA selection would corrupt the next user's run. **Always use dynamic (non-fused) `set_adapters`.**
|
| 49 |
+
|
| 50 |
+
### 2.4 Adapter lifecycle β load-once, lazy, cached
|
| 51 |
+
|
| 52 |
+
Loading a LoRA from HuggingFace Hub takes 1β5 seconds (download + PEFT registration). To avoid per-request reload churn:
|
| 53 |
+
|
| 54 |
+
1. **Built-in LoRAs** are loaded lazily on first use and cached in PEFT's adapter registry for the process lifetime. Since ZeroGPU recycles the GPU context between requests but keeps CPU memory, the PEFT registry survives. Re-loading is skipped if `adapter_name` is already registered (checked via `pipe.get_list_adapters()`).
|
| 55 |
+
|
| 56 |
+
2. **Custom LoRAs** uploaded by the user are also loaded lazily and cached under a deterministic key derived from the content hash of the file (for uploads) or `repo_id + weight_name` (for HF pull). The cache is bounded: a simple LRU of 4 custom adapters evicts the least recently used when full.
|
| 57 |
+
|
| 58 |
+
3. **Memory implications on ZeroGPU (H200, ~80 GB HBM):** Each Qwen-Image-Edit-2511 LoRA is ~50β200 MB on disk / VRAM. With 5 built-ins loaded simultaneously, the overhead is <1 GB β negligible relative to the ~20 GB base model. The dynamic (non-fused) path stores adapter delta weights alongside the base model; no duplication occurs.
|
| 59 |
+
|
| 60 |
+
### 2.5 Extended `_apply_speed` signature (pseudo-code, not implementation)
|
| 61 |
+
|
| 62 |
+
```python
|
| 63 |
+
# modes.py β proposed signature change
|
| 64 |
+
def _apply_speed(
|
| 65 |
+
pipe: Any,
|
| 66 |
+
speed: str,
|
| 67 |
+
quality_adapters: list[tuple[str, float]] | None = None,
|
| 68 |
+
) -> None:
|
| 69 |
+
if speed == "Fast":
|
| 70 |
+
pipe.scheduler = pipe._qie_lightning_scheduler
|
| 71 |
+
pipe.set_adapters([models.LORA_ADAPTER_NAME], [1.0])
|
| 72 |
+
else:
|
| 73 |
+
pipe.scheduler = pipe._qie_default_scheduler
|
| 74 |
+
if quality_adapters:
|
| 75 |
+
names = [a[0] for a in quality_adapters]
|
| 76 |
+
weights = [a[1] for a in quality_adapters]
|
| 77 |
+
pipe.set_adapters(names, weights)
|
| 78 |
+
else:
|
| 79 |
+
pipe.disable_lora()
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
`quality_adapters` is built by the backend from the LoRA gallery state passed in `params`.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## 3. Curated Built-in LoRA Gallery
|
| 87 |
+
|
| 88 |
+
All proposed built-ins use `Qwen/Qwen-Image-Edit-2511` as their base model and are released under Apache-2.0.
|
| 89 |
+
|
| 90 |
+
### 3.1 Proposed gallery (6 entries)
|
| 91 |
+
|
| 92 |
+
| # | Display Name | Repo ID | Filename | Trigger phrase | Rec. weight | What it does | License |
|
| 93 |
+
|---|---|---|---|---|---|---|---|
|
| 94 |
+
| 1 | **Multi-Angle** | `fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA` | `qwen-image-edit-2511-multiple-angles-lora.safetensors` | `<sks> [azimuth] [elevation] [distance]` β e.g. `<sks> 90 0 1.5` | 0.9 | Re-renders subject from a precise camera angle across 96 poses (4 elevations Γ 8 azimuths Γ 3 distances); trained on 3,000+ Gaussian Splatting renders | Apache-2.0 |
|
| 95 |
+
| 2 | **Anime Style** | `prithivMLmods/Qwen-Image-Edit-2511-Anime` | *(single safetensors in repo)* | `Transform into anime.` | 0.85 | Converts photo to anime flat-cel shading while preserving pose, proportions, and camera angle | Apache-2.0 |
|
| 96 |
+
| 3 | **Hyper-Realistic Portrait** | `prithivMLmods/Qwen-Image-Edit-2511-Hyper-Realistic-Portrait` | `HRP_20.safetensors` | `Transform into a hyper-realistic face portrait` | 0.8 | Photorealistic portrait with pore-level textures, cool-toned lighting, shallow DoF | Apache-2.0 |
|
| 97 |
+
| 4 | **Any-Pose Transfer** | `lilylilith/AnyPose` | *(base safetensors in repo)* | Natural-language pose description; see trigger note* | 0.7 (base) + 0.7 (helper) | Transfers any pose from a reference image to the subject without ControlNet | Apache-2.0 |
|
| 98 |
+
| 5 | **ICEdit (In-Context Edit)** | `DiffSynth-Studio/Qwen-Image-Edit-2511-ICEdit-LoRA` | `model.safetensors` | None required; transformation is driven by example image pair | 0.85 | Three-image prompting: show beforeβafter pair, apply same transformation to third image | Apache-2.0 |
|
| 99 |
+
| 6 | **Upscale 2K** | `starsfriday/Qwen-Image-Edit-2511-Upscale2K` | `qwen_image_edit_2511_upscale.safetensors` | `Upscale this picture to 4K resolution.` (no fixed trigger; any upscale instruction works) | 0.9 | Lossless enlargement to ~2K; structure-preserving super-resolution | Apache-2.0 |
|
| 100 |
+
|
| 101 |
+
*AnyPose trigger note: recommended prompt template is `"Make the person in image 1 do the exact same pose of the person in image 2. Changing the style and background is undesirable. The new pose should be pixel accurate..."` β the UI should prepend this template when AnyPose is active and the user has not included pose language themselves.
|
| 102 |
+
|
| 103 |
+
### 3.2 Licensing notes
|
| 104 |
+
|
| 105 |
+
All 6 proposed adapters are Apache-2.0. No license flags. The base model `Qwen/Qwen-Image-Edit-2511` is also Apache-2.0, making the entire stack permissively licensed.
|
| 106 |
+
|
| 107 |
+
### 3.3 Candidates considered but not included
|
| 108 |
+
|
| 109 |
+
- **Manga Tone / Noir Comic** (`prithivMLmods/Qwen-Image-Edit-2511-Noir-Comic-Book-Panel`): interesting but highly niche; kept as a future addition. License: Apache-2.0.
|
| 110 |
+
- **WaveSpeedAI LoRA**: proprietary / non-HF-hosted β excluded.
|
| 111 |
+
- **Lightning LoRA** (`lightx2v/Qwen-Image-Edit-2511-Lightning`): already loaded as the Fast-mode adapter; not exposed in the Quality gallery.
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
## 4. Custom LoRA Uploader / Picker
|
| 116 |
+
|
| 117 |
+
### 4.1 Two input paths
|
| 118 |
+
|
| 119 |
+
**Path A β HuggingFace repo pull**
|
| 120 |
+
- User enters a repo ID (e.g. `someuser/my-lora`) and optionally a filename (`my_lora.safetensors`).
|
| 121 |
+
- Backend calls `pipe.load_lora_weights(repo_id, weight_name=weight_name or None, adapter_name=cache_key)`.
|
| 122 |
+
- If `weight_name` is omitted, diffusers auto-selects the single `.safetensors` file in the repo; if multiple exist, we surface an error asking the user to specify the filename.
|
| 123 |
+
|
| 124 |
+
**Path B β File upload**
|
| 125 |
+
- User uploads a `.safetensors` file directly (via Gradio `gr.File`).
|
| 126 |
+
- File is saved to a temp path; backend calls `pipe.load_lora_weights(temp_path, adapter_name=sha256_of_file[:8])`.
|
| 127 |
+
|
| 128 |
+
### 4.2 Validation
|
| 129 |
+
|
| 130 |
+
1. **Extension gate:** reject anything that is not `.safetensors` before attempting to load (Gradio `file_types=[".safetensors"]` on the upload widget).
|
| 131 |
+
2. **Load-time catch:** `pipe.load_lora_weights` raises if the file is corrupt, incompatible architecture, or missing expected keys. Wrap in `try/except` and surface a user-facing error: `"LoRA could not be loaded β it may be incompatible with Qwen-Image-Edit-2511 (wrong architecture or corrupt file)."`.
|
| 132 |
+
3. **Compatibility signal (best-effort):** after loading, check that at least one PEFT adapter parameter with a `qwen` or `transformer_blocks` namespace was registered. If none, warn the user that the LoRA may not affect this model. This is heuristic β do not hard-block.
|
| 133 |
+
4. **Weight slider range:** custom LoRA weight slider is 0.0β2.0 (default 0.8). Range extends to 2.0 to accommodate LoRAs trained at high scale, unlike built-ins which cap at 1.2.
|
| 134 |
+
|
| 135 |
+
### 4.3 Security considerations
|
| 136 |
+
|
| 137 |
+
- **Arbitrary downloads (Path A):** any HF repo ID can be passed. Mitigations: (a) diffusers uses `safetensors` format which executes no Python code, unlike legacy `.pt` pickle files β no arbitrary code execution risk; (b) on ZeroGPU Spaces the process is sandboxed; (c) HuggingFace Hub downloads go through the Hub CDN, not arbitrary URLs. No additional URL-allow-list is needed on ZeroGPU.
|
| 138 |
+
- **File uploads (Path B):** files are validated by extension and then by the `safetensors` library's header parser before any tensor is loaded. The `safetensors` format is deliberately sandboxed. Temp files are cleaned up after the session.
|
| 139 |
+
- **Recommended note in UI:** "Only load LoRAs trained on `Qwen/Qwen-Image-Edit-2511`. LoRAs from other base models will produce incorrect results."
|
| 140 |
+
|
| 141 |
+
### 4.4 Custom LoRA cache
|
| 142 |
+
|
| 143 |
+
```
|
| 144 |
+
_custom_lora_cache: OrderedDict[str, str] # cache_key -> adapter_name in PEFT
|
| 145 |
+
MAX_CUSTOM_LORAS = 4 # LRU eviction
|
| 146 |
+
```
|
| 147 |
+
|
| 148 |
+
On eviction, call `pipe.delete_adapters([evicted_adapter_name])` to free PEFT state. (PEFT's `delete_adapters` does not free GPU memory immediately β a subsequent inference call will GC unused delta weights.)
|
| 149 |
+
|
| 150 |
+
---
|
| 151 |
+
|
| 152 |
+
## 5. Edit vs. Compose Mode Behavior
|
| 153 |
+
|
| 154 |
+
### 5.1 Pipeline unification
|
| 155 |
+
|
| 156 |
+
Both `call_edit` and `call_compose` ultimately call `_run`, which calls `_apply_speed`. The LoRA stack is set once in `_apply_speed` before the pipeline call; neither the edit nor compose code path diverges at the LoRA layer. Stacked LoRAs therefore apply identically to both modes.
|
| 157 |
+
|
| 158 |
+
### 5.2 Per-LoRA suitability by mode
|
| 159 |
+
|
| 160 |
+
| LoRA | Edit | Compose | Notes |
|
| 161 |
+
|---|---|---|---|
|
| 162 |
+
| Multi-Angle | β
primary use case | β
useful | Works on the first (target) image |
|
| 163 |
+
| Anime Style | β
| β
| Applies to the composed output |
|
| 164 |
+
| Hyper-Realistic Portrait | β
| β οΈ marginal | Useful only if the compose output is a portrait; may conflict with reference images that are non-photorealistic |
|
| 165 |
+
| AnyPose | β
| β
primary use case | In Compose, image 2 naturally becomes the pose reference β synergistic |
|
| 166 |
+
| ICEdit | β οΈ | β
primary use case | ICEdit is designed for 3-image prompting, which maps directly to Compose's 3-slot layout; in single-image Edit it still works but the "beforeβafter pair" must be embedded in the prompt |
|
| 167 |
+
| Upscale 2K | β
| β
| Output-level; mode-agnostic |
|
| 168 |
+
| Custom | β
| β
| No restriction; user's responsibility |
|
| 169 |
+
|
| 170 |
+
UI annotation: in the gallery cards, each built-in LoRA has a small "Best in: Edit / Compose / Both" badge for discoverability, but no hard lock-out. Users can use any LoRA in either mode.
|
| 171 |
+
|
| 172 |
+
### 5.3 Fast vs. Quality with multi-LoRA
|
| 173 |
+
|
| 174 |
+
**Multi-LoRA is Quality-only.** Rationale:
|
| 175 |
+
|
| 176 |
+
1. The Lightning LoRA was distilled specifically for 4-step inference with `shift=ln(3)` exponential time shifting. Stacking a style/pose LoRA on top of a distillation LoRA is untested and likely to produce degraded outputs β the combined deltas were not co-trained.
|
| 177 |
+
2. The Lightning LoRA's `set_adapters(["lightning"], [1.0])` call in Fast mode makes it the sole active adapter. Introducing additional adapters would require retuning the Lightning scale, which is outside the scope of this feature.
|
| 178 |
+
3. In Fast mode the user gets a 4-step run (~10β15 s); style LoRAs may need 20β40 steps to fully express. Quality mode's default 40 steps gives LoRAs adequate budget.
|
| 179 |
+
|
| 180 |
+
**Implementation:** the LoRA gallery and custom uploader are rendered inside the `quality_grp` Group (visible only when Quality is selected). When the user switches back to Fast, the LoRA selection state is preserved in Gradio state but not transmitted to the backend β `_apply_speed("Fast", ...)` always uses only Lightning.
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
## 6. UX Design β LoRA Picker
|
| 185 |
+
|
| 186 |
+
### 6.1 Placement
|
| 187 |
+
|
| 188 |
+
The LoRA section lives inside the existing `quality_grp` (the `gr.Group(visible=False)` that appears when Quality is selected). It is injected below `true_cfg` and above `negative_prompt`, inside a collapsible `gr.Accordion("LoRA", open=False)`.
|
| 189 |
+
|
| 190 |
+
This preserves the current tab layout β the accordion is collapsed by default so the panel does not grow on first open.
|
| 191 |
+
|
| 192 |
+
### 6.2 Component structure (Gradio)
|
| 193 |
+
|
| 194 |
+
```
|
| 195 |
+
gr.Accordion("LoRA", open=False)
|
| 196 |
+
gr.Markdown("Quality-mode style adapters β stack up to 4. Active only in Quality mode.")
|
| 197 |
+
|
| 198 |
+
# --- Built-in gallery (6 cards) ---
|
| 199 |
+
gr.Row()
|
| 200 |
+
[for each built-in LoRA]:
|
| 201 |
+
gr.Group(elem_classes=["qie-lora-card"])
|
| 202 |
+
gr.Checkbox(label="<display name>", value=False) # toggle
|
| 203 |
+
gr.Markdown("<one-line description>", visible=True)
|
| 204 |
+
gr.Slider(0.0, 1.5, value=rec_weight, step=0.05, # weight; visible only when checked
|
| 205 |
+
label="Weight", visible=False)
|
| 206 |
+
|
| 207 |
+
# --- Custom LoRA accordion ---
|
| 208 |
+
gr.Accordion("Custom LoRA", open=False)
|
| 209 |
+
gr.Radio(["HuggingFace repo", "Upload file"], value="HuggingFace repo",
|
| 210 |
+
label="Source")
|
| 211 |
+
|
| 212 |
+
# Path A β HF repo
|
| 213 |
+
gr.Textbox(label="Repo ID", placeholder="username/my-lora-name")
|
| 214 |
+
gr.Textbox(label="Filename (optional)", placeholder="lora.safetensors")
|
| 215 |
+
|
| 216 |
+
# Path B β file upload (visible when "Upload file" selected)
|
| 217 |
+
gr.File(label="Upload .safetensors", file_types=[".safetensors"],
|
| 218 |
+
elem_classes=["qie-lora-file"])
|
| 219 |
+
|
| 220 |
+
gr.Slider(0.0, 2.0, value=0.8, step=0.05, label="Weight")
|
| 221 |
+
gr.Button("Add LoRA", variant="secondary")
|
| 222 |
+
gr.Markdown("", elem_id="qie-custom-lora-status") # error/success feedback
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
The gallery cards use a `.qie-lora-card` CSS class (added to `theme.py`'s `CSS` string) for a bordered, padded tile layout. Weight sliders become visible only when their checkbox is ticked (`.change()` handler toggles `visible`).
|
| 226 |
+
|
| 227 |
+
### 6.3 ASCII wireframe β Edit tab, Quality mode expanded, LoRA accordion open
|
| 228 |
+
|
| 229 |
+
```
|
| 230 |
+
ββ Edit βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 231 |
+
β [Input image ] β [Output image ] β
|
| 232 |
+
β β β
|
| 233 |
+
β Prompt ββββββββββββββββββββββββββ β ... β
|
| 234 |
+
β βββββββββββββββββββββββββββββββββββ β β
|
| 235 |
+
β β Make the sky dramaticβ¦ β β β
|
| 236 |
+
β βββββββββββββββββββββββββββββββββββ β β
|
| 237 |
+
β β β
|
| 238 |
+
β Speed: β Fast β Quality β β
|
| 239 |
+
β β β
|
| 240 |
+
β βββ Quality settings βββββββββββββ β β
|
| 241 |
+
β β CFG scale ββββββββββββββββββββ β β β
|
| 242 |
+
β β β β β
|
| 243 |
+
β β βΌ LoRA ββββββββββββββββββββββ β β β
|
| 244 |
+
β β Quality-mode adapters (β€4) β β β
|
| 245 |
+
β β β β β
|
| 246 |
+
β β ββββββββββββββ ββββββββββββββ β β β
|
| 247 |
+
β β β β Multi- β β β Anime β β β β
|
| 248 |
+
β β β Angle β β Style β β β β
|
| 249 |
+
β β β Re-render β β Cel-shad- β β β β
|
| 250 |
+
β β β from any β β ing, pres- β β β β
|
| 251 |
+
β β β viewpoint β β erves pose β β β β
|
| 252 |
+
β β β Best: Both β β Best: Both β β β β
|
| 253 |
+
β β β Weight βββ β β β β β β
|
| 254 |
+
β β β [0.90] β β β β β β
|
| 255 |
+
β β ββββββββββββββ ββββββββββββββ β β β
|
| 256 |
+
β β ββββββββββββββ ββββββββββββββ β β β
|
| 257 |
+
β β β β Portrait β β β AnyPose β β β β
|
| 258 |
+
β β β Hyper- β β Pose from β β β β
|
| 259 |
+
β β β realistic β β reference β β β β
|
| 260 |
+
β β β face β β Best: β β β β
|
| 261 |
+
β β β Best: Edit β β Compose β β β β
|
| 262 |
+
β β ββββββββββββββ ββββββββββββββ β β β
|
| 263 |
+
β β ββββββββββββββ ββββββββββββββ β β β
|
| 264 |
+
β β β β ICEdit β β β Upscale β β β β
|
| 265 |
+
β β β 3-image β β 2K β β β β
|
| 266 |
+
β β β example- β β Lossless β β β β
|
| 267 |
+
β β β driven β β enlarge β β β β
|
| 268 |
+
β β β Best: β β Best: Both β β β β
|
| 269 |
+
β β β Compose β β β β β β
|
| 270 |
+
β β ββββββββββββββ ββββββββββββββ β β β
|
| 271 |
+
β β β β β
|
| 272 |
+
β β βΆ Custom LoRA ββββββββββββββ β β β
|
| 273 |
+
β β β β β
|
| 274 |
+
β β Negative prompt βββββββββββββ β β β
|
| 275 |
+
β ββββββββββββββββββββββββββββββββββ β β
|
| 276 |
+
β β β
|
| 277 |
+
β βΆ Advanced ββββββββββββββββββββββ β β
|
| 278 |
+
β β β
|
| 279 |
+
β [Generate] β β
|
| 280 |
+
ββββββββββββββββββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββ
|
| 281 |
+
```
|
| 282 |
+
|
| 283 |
+
### 6.4 Visual treatment (Soft Dark Restraint)
|
| 284 |
+
|
| 285 |
+
- **Card background:** `#14110F` (input_bg) with `1px solid #2A241E` border and `6px` radius.
|
| 286 |
+
- **Active card (checkbox ticked):** border upgrades to `1px solid rgba(124,92,255,0.5)`, subtle violet left-border accent `3px solid #7C5CFF`, background lifts to `rgba(124,92,255,0.04)`.
|
| 287 |
+
- **Badge "Best in: Compose":** 10px capsule, `color: #988B7C`, `background: #1F1B17`.
|
| 288 |
+
- **Weight slider:** inherits `slider_color: #7C5CFF` from theme β no extra CSS needed.
|
| 289 |
+
- **Card grid:** `gr.Row(equal_height=True)` with 2 columns, wrapping across rows. On mobile (<600px), single column.
|
| 290 |
+
- **"Add LoRA" button:** `variant="secondary"` (bordered, not filled). On success, status line reads `"β my-lora loaded (sha: abc12345)"` in `#7C5CFF`. On error, `"β Could not load β β¦"` in `#E05555`.
|
| 291 |
+
|
| 292 |
+
### 6.5 State passed to backend
|
| 293 |
+
|
| 294 |
+
The LoRA state is serialized into `params` as:
|
| 295 |
+
|
| 296 |
+
```python
|
| 297 |
+
params["loras"] = [
|
| 298 |
+
{"adapter_name": "fal__multi-angle", "weight": 0.9},
|
| 299 |
+
{"adapter_name": "custom__abc12345", "weight": 1.1},
|
| 300 |
+
]
|
| 301 |
+
```
|
| 302 |
+
|
| 303 |
+
The backend resolves each entry: if the adapter is not yet in PEFT's registry, it loads it; then calls `set_adapters([...], [...])` with the full list. The `adapter_name` for built-ins is the repo ID with `/` replaced by `__` and a fixed suffix (e.g. `fal__Qwen-Image-Edit-2511-Multiple-Angles-LoRA`).
|
| 304 |
+
|
| 305 |
+
---
|
| 306 |
+
|
| 307 |
+
## 7. `models.py` Constants (additions, not modifications)
|
| 308 |
+
|
| 309 |
+
The following constants would be added to `models.py` to centralize built-in gallery metadata. They are read-only config, not runtime state:
|
| 310 |
+
|
| 311 |
+
```python
|
| 312 |
+
BUILTIN_LORAS: list[dict] = [
|
| 313 |
+
{
|
| 314 |
+
"display_name": "Multi-Angle",
|
| 315 |
+
"adapter_name": "fal__multi-angle",
|
| 316 |
+
"repo_id": "fal/Qwen-Image-Edit-2511-Multiple-Angles-LoRA",
|
| 317 |
+
"weight_name": "qwen-image-edit-2511-multiple-angles-lora.safetensors",
|
| 318 |
+
"trigger": "<sks> [azimuth] [elevation] [distance]",
|
| 319 |
+
"default_weight": 0.9,
|
| 320 |
+
"description": "Re-render subject from any camera angle (96 poses).",
|
| 321 |
+
"best_mode": "Both",
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"display_name": "Anime Style",
|
| 325 |
+
"adapter_name": "prithiv__anime",
|
| 326 |
+
"repo_id": "prithivMLmods/Qwen-Image-Edit-2511-Anime",
|
| 327 |
+
"weight_name": None, # auto-detect single safetensors
|
| 328 |
+
"trigger": "Transform into anime.",
|
| 329 |
+
"default_weight": 0.85,
|
| 330 |
+
"description": "Flat cel-shading; preserves pose and proportions.",
|
| 331 |
+
"best_mode": "Both",
|
| 332 |
+
},
|
| 333 |
+
{
|
| 334 |
+
"display_name": "Hyper-Realistic Portrait",
|
| 335 |
+
"adapter_name": "prithiv__portrait",
|
| 336 |
+
"repo_id": "prithivMLmods/Qwen-Image-Edit-2511-Hyper-Realistic-Portrait",
|
| 337 |
+
"weight_name": "HRP_20.safetensors",
|
| 338 |
+
"trigger": "Transform into a hyper-realistic face portrait",
|
| 339 |
+
"default_weight": 0.8,
|
| 340 |
+
"description": "Pore-level textures, shallow DoF, cool-toned lighting.",
|
| 341 |
+
"best_mode": "Edit",
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"display_name": "AnyPose",
|
| 345 |
+
"adapter_name": "lilylilith__anypose",
|
| 346 |
+
"repo_id": "lilylilith/AnyPose",
|
| 347 |
+
"weight_name": None,
|
| 348 |
+
"trigger": None, # template injected by backend
|
| 349 |
+
"default_weight": 0.7,
|
| 350 |
+
"description": "Transfer any pose from a reference image.",
|
| 351 |
+
"best_mode": "Compose",
|
| 352 |
+
},
|
| 353 |
+
{
|
| 354 |
+
"display_name": "ICEdit",
|
| 355 |
+
"adapter_name": "diffsynth__icedit",
|
| 356 |
+
"repo_id": "DiffSynth-Studio/Qwen-Image-Edit-2511-ICEdit-LoRA",
|
| 357 |
+
"weight_name": "model.safetensors",
|
| 358 |
+
"trigger": None, # example-pair driven
|
| 359 |
+
"default_weight": 0.85,
|
| 360 |
+
"description": "Apply transformations from an example beforeβafter pair.",
|
| 361 |
+
"best_mode": "Compose",
|
| 362 |
+
},
|
| 363 |
+
{
|
| 364 |
+
"display_name": "Upscale 2K",
|
| 365 |
+
"adapter_name": "starsfriday__upscale2k",
|
| 366 |
+
"repo_id": "starsfriday/Qwen-Image-Edit-2511-Upscale2K",
|
| 367 |
+
"weight_name": "qwen_image_edit_2511_upscale.safetensors",
|
| 368 |
+
"trigger": "Upscale this picture to 4K resolution.",
|
| 369 |
+
"default_weight": 0.9,
|
| 370 |
+
"description": "Lossless upscale to ~2K; structure-preserving.",
|
| 371 |
+
"best_mode": "Both",
|
| 372 |
+
},
|
| 373 |
+
]
|
| 374 |
+
```
|
| 375 |
+
|
| 376 |
+
---
|
| 377 |
+
|
| 378 |
+
## 8. Risks and Open Questions
|
| 379 |
+
|
| 380 |
+
| Risk | Severity | Mitigation |
|
| 381 |
+
|---|---|---|
|
| 382 |
+
| AnyPose has separate "base" and "helper" adapter files β load_lora_weights may need two calls | Medium | Research AnyPose repo file list; may require `adapter_name="anypose_base"` + `"anypose_helper"` with separate sliders or a single averaged weight |
|
| 383 |
+
| Qwen anime LoRA filename not confirmed (auto-detect path) | Low | diffusers auto-selects if exactly one `.safetensors` exists; add explicit check at load time |
|
| 384 |
+
| ZeroGPU cold-start latency increases if multiple LoRAs load on first request | Medium | Pre-warm: load all built-in LoRAs during `_build_pipeline()` (pipeline construction), not lazily β trading startup time for predictable per-request latency |
|
| 385 |
+
| set_adapters with empty list behavior | Low | Always call `disable_lora()` for the empty-list case; never pass `set_adapters([], [])` |
|
| 386 |
+
| LoRA stacking interactions between style + pose + upscale | Medium | UX: warn when >2 LoRAs are active; order weight sliders with a note "effects may interact unpredictably" |
|
| 387 |
+
| Custom LoRA architecture incompatibility (wrong base model) | Medium | Load-time catch + heuristic namespace check (Β§4.2); surface clear error |
|
| 388 |
+
|
| 389 |
+
---
|
| 390 |
+
|
| 391 |
+
## 9. Out of Scope for This Spec
|
| 392 |
+
|
| 393 |
+
- Training new LoRAs via the UI (no fine-tuning surface).
|
| 394 |
+
- LoRA merging / averaging (separate feature).
|
| 395 |
+
- Exposing LoRAs in Fast mode (architecturally incompatible with Lightning distillation β explicitly excluded).
|
| 396 |
+
- Inpainting/ControlNet integration (excluded from base design Β§2).
|
tests/test_backend.py
CHANGED
|
@@ -26,32 +26,32 @@ def fake_backend(monkeypatch):
|
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
|
| 28 |
|
| 29 |
-
def
|
| 30 |
-
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def test_duration_for_fast_ignores_steps():
|
| 34 |
-
"""Fast
|
| 35 |
-
assert backend.duration_for("compose", {"speed": "Fast", "steps": 100}) ==
|
| 36 |
|
| 37 |
|
| 38 |
def test_duration_for_quality_default_steps():
|
| 39 |
-
#
|
| 40 |
result = backend.duration_for("edit", {"speed": "Quality"})
|
| 41 |
-
assert result ==
|
| 42 |
assert 60 <= result <= 180
|
| 43 |
|
| 44 |
|
| 45 |
def test_duration_for_quality_40_steps_explicit():
|
| 46 |
result = backend.duration_for("edit", {"speed": "Quality", "steps": 40})
|
| 47 |
-
assert result ==
|
| 48 |
assert 60 <= result <= 180
|
| 49 |
|
| 50 |
|
| 51 |
-
def
|
| 52 |
-
#
|
| 53 |
-
|
| 54 |
-
assert result == 60
|
| 55 |
|
| 56 |
|
| 57 |
def test_duration_for_clamps_to_maximum():
|
|
|
|
| 26 |
# ---------------------------------------------------------------------------
|
| 27 |
|
| 28 |
|
| 29 |
+
def test_duration_for_fast_returns_120():
|
| 30 |
+
# Fast reserves a generous-but-smaller budget to cover the ZeroGPU unpack + 4-step run.
|
| 31 |
+
assert backend.duration_for("edit", {"speed": "Fast"}) == 120
|
| 32 |
|
| 33 |
|
| 34 |
def test_duration_for_fast_ignores_steps():
|
| 35 |
+
"""Fast is a fixed budget regardless of steps."""
|
| 36 |
+
assert backend.duration_for("compose", {"speed": "Fast", "steps": 100}) == 120
|
| 37 |
|
| 38 |
|
| 39 |
def test_duration_for_quality_default_steps():
|
| 40 |
+
# Quality reserves the full window (matches the official Space's 180 s).
|
| 41 |
result = backend.duration_for("edit", {"speed": "Quality"})
|
| 42 |
+
assert result == 180
|
| 43 |
assert 60 <= result <= 180
|
| 44 |
|
| 45 |
|
| 46 |
def test_duration_for_quality_40_steps_explicit():
|
| 47 |
result = backend.duration_for("edit", {"speed": "Quality", "steps": 40})
|
| 48 |
+
assert result == 180
|
| 49 |
assert 60 <= result <= 180
|
| 50 |
|
| 51 |
|
| 52 |
+
def test_duration_for_quality_ignores_low_steps():
|
| 53 |
+
# Quality is a fixed 180 s budget regardless of the step count.
|
| 54 |
+
assert backend.duration_for("edit", {"speed": "Quality", "steps": 0}) == 180
|
|
|
|
| 55 |
|
| 56 |
|
| 57 |
def test_duration_for_clamps_to_maximum():
|