techfreakworm commited on
Commit
0614a22
Β·
1 Parent(s): 55650fa

fix(zerogpu): cap queue concurrency and raise @spaces.GPU duration (Fast 120 / Quality 180)

Browse files

The 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 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
- build_app().queue().launch()
 
 
 
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. Pure function; clamped to [60, 180].
25
 
26
- Fast mode always returns 60 (Lightning LoRA runs in ~10-15 s on H200).
27
- Quality mode scales linearly with the number of inference steps,
28
- clamped to the [60, 180] second range.
 
29
  """
30
- if params.get("speed") == "Fast":
31
- return 60
32
- steps = int(params.get("steps", 40))
33
- return int(min(180, max(60, 30 + steps * 3.5)))
34
 
35
 
36
- _GPU = spaces.GPU(duration=lambda *a, **kw: duration_for(*a[1:3])) if (spaces is not None and _ON_SPACES) else _identity
 
 
 
 
 
 
 
 
 
 
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 test_duration_for_fast_returns_60():
30
- assert backend.duration_for("edit", {"speed": "Fast"}) == 60
 
31
 
32
 
33
  def test_duration_for_fast_ignores_steps():
34
- """Fast always returns 60 regardless of steps."""
35
- assert backend.duration_for("compose", {"speed": "Fast", "steps": 100}) == 60
36
 
37
 
38
  def test_duration_for_quality_default_steps():
39
- # steps defaults to 40: 30 + 40 * 3.5 = 170, in range [60, 180]
40
  result = backend.duration_for("edit", {"speed": "Quality"})
41
- assert result == 170
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 == 170
48
  assert 60 <= result <= 180
49
 
50
 
51
- def test_duration_for_clamps_to_minimum():
52
- # steps=0: 30 + 0 * 3.5 = 30, clamped up to 60
53
- result = backend.duration_for("edit", {"speed": "Quality", "steps": 0})
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():