prithivMLmods commited on
Commit
9e3d4eb
·
verified ·
1 Parent(s): 02534d9

update app

Browse files
Files changed (1) hide show
  1. app.py +1137 -387
app.py CHANGED
@@ -1,106 +1,69 @@
1
  import os
 
2
  import gc
3
- import gradio as gr
4
- import numpy as np
 
5
  import random
 
 
 
 
 
6
  import spaces
 
7
  import torch
8
- from diffusers import Flux2KleinPipeline, AutoencoderKLFlux2
9
  from PIL import Image
10
- from pathlib import Path
11
- import concurrent.futures
12
- import threading
13
- from typing import Iterable
14
-
15
- from gradio.themes import Soft
16
- from gradio.themes.utils import colors, fonts, sizes
17
-
18
- colors.orange_red = colors.Color(
19
- name="orange_red",
20
- c50="#FFF0E5",
21
- c100="#FFE0CC",
22
- c200="#FFC299",
23
- c300="#FFA366",
24
- c400="#FF8533",
25
- c500="#FF4500",
26
- c600="#E63E00",
27
- c700="#CC3700",
28
- c800="#B33000",
29
- c900="#992900",
30
- c950="#802200",
31
- )
32
 
33
- class OrangeRedTheme(Soft):
34
- def __init__(
35
- self,
36
- *,
37
- primary_hue: colors.Color | str = colors.gray,
38
- secondary_hue: colors.Color | str = colors.orange_red,
39
- neutral_hue: colors.Color | str = colors.slate,
40
- text_size: sizes.Size | str = sizes.text_lg,
41
- font: fonts.Font | str | Iterable[fonts.Font | str] = (
42
- fonts.GoogleFont("Outfit"), "Arial", "sans-serif",
43
- ),
44
- font_mono: fonts.Font | str | Iterable[fonts.Font | str] = (
45
- fonts.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace",
46
- ),
47
- ):
48
- super().__init__(
49
- primary_hue=primary_hue,
50
- secondary_hue=secondary_hue,
51
- neutral_hue=neutral_hue,
52
- text_size=text_size,
53
- font=font,
54
- font_mono=font_mono,
55
- )
56
- super().set(
57
- background_fill_primary="*primary_50",
58
- background_fill_primary_dark="*primary_900",
59
- body_background_fill="linear-gradient(135deg, *primary_200, *primary_100)",
60
- body_background_fill_dark="linear-gradient(135deg, *primary_900, *primary_800)",
61
- button_primary_text_color="white",
62
- button_primary_text_color_hover="white",
63
- button_primary_background_fill="linear-gradient(90deg, *secondary_500, *secondary_600)",
64
- button_primary_background_fill_hover="linear-gradient(90deg, *secondary_600, *secondary_700)",
65
- button_primary_background_fill_dark="linear-gradient(90deg, *secondary_600, *secondary_700)",
66
- button_primary_background_fill_hover_dark="linear-gradient(90deg, *secondary_500, *secondary_600)",
67
- button_secondary_text_color="black",
68
- button_secondary_text_color_hover="white",
69
- button_secondary_background_fill="linear-gradient(90deg, *primary_300, *primary_300)",
70
- button_secondary_background_fill_hover="linear-gradient(90deg, *primary_400, *primary_400)",
71
- button_secondary_background_fill_dark="linear-gradient(90deg, *primary_500, *primary_600)",
72
- button_secondary_background_fill_hover_dark="linear-gradient(90deg, *primary_500, *primary_500)",
73
- slider_color="*secondary_500",
74
- slider_color_dark="*secondary_600",
75
- block_title_text_weight="600",
76
- block_border_width="3px",
77
- block_shadow="*shadow_drop_lg",
78
- button_primary_shadow="*shadow_drop_lg",
79
- button_large_padding="11px",
80
- color_accent_soft="*primary_100",
81
- block_label_background_fill="*primary_200",
82
- )
83
 
84
- orange_red_theme = OrangeRedTheme()
85
 
86
- dtype = torch.bfloat16
87
- device = "cuda" if torch.cuda.is_available() else "cpu"
 
 
 
 
 
 
 
88
 
89
  MAX_SEED = np.iinfo(np.int32).max
90
  MAX_IMAGE_SIZE = 1024
91
- EXAMPLES_DIR = Path("examples")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
 
93
  print("Loading 4B Distilled model (Standard VAE)...")
94
  pipe_standard = Flux2KleinPipeline.from_pretrained(
95
  "black-forest-labs/FLUX.2-klein-4B",
96
  torch_dtype=dtype,
 
97
  )
98
  pipe_standard.enable_model_cpu_offload()
 
99
 
100
  print("Loading Small Decoder VAE...")
101
  vae_small = AutoencoderKLFlux2.from_pretrained(
102
  "black-forest-labs/FLUX.2-small-decoder",
103
  torch_dtype=dtype,
 
104
  )
105
 
106
  print("Loading 4B Distilled model (Small Decoder VAE)...")
@@ -108,89 +71,54 @@ pipe_small_decoder = Flux2KleinPipeline.from_pretrained(
108
  "black-forest-labs/FLUX.2-klein-4B",
109
  vae=vae_small,
110
  torch_dtype=dtype,
 
111
  )
112
  pipe_small_decoder.enable_model_cpu_offload()
 
113
 
114
  pipe_lock_standard = threading.Lock()
115
  pipe_lock_small = threading.Lock()
116
 
 
 
 
117
  def calc_dimensions(pil_img: Image.Image):
118
- """
119
- Given a PIL image return (width, height) snapped to multiples of 8,
120
- fitting within 1024 px on the long side, min 256 px on each side.
121
- Uses round() so we match the reference app exactly.
122
- """
123
  iw, ih = pil_img.size
124
  aspect = iw / ih
125
-
126
- if aspect >= 1: # landscape / square
127
  new_width = 1024
128
  new_height = int(round(1024 / aspect))
129
- else: # portrait
130
  new_height = 1024
131
  new_width = int(round(1024 * aspect))
132
-
133
- # snap to 8-pixel grid with round(), clamp to [256, 1024]
134
  new_width = max(256, min(1024, round(new_width / 8) * 8))
135
  new_height = max(256, min(1024, round(new_height / 8) * 8))
136
  return new_width, new_height
137
 
138
 
139
- def update_dimensions_from_image(image_list):
140
- """
141
- Called by the gallery .upload() event.
142
- Returns updated slider values for width and height.
143
- """
144
- if not image_list:
145
- return 1024, 1024
146
-
147
- # gallery items arrive as PIL images when type="pil"
148
- item = image_list[0]
149
- img = item[0] if isinstance(item, tuple) else item
150
-
151
- if isinstance(img, str):
152
- img = Image.open(img).convert("RGB")
153
- elif not isinstance(img, Image.Image):
154
- return 1024, 1024
155
-
156
- return calc_dimensions(img)
157
-
158
- def parse_and_resize_images(input_images, width: int, height: int):
159
- """
160
- Parse the gallery input and resize every frame to (width, height).
161
- Returns a list[PIL.Image] or None.
162
- """
163
- if input_images is None:
164
- return None
165
-
166
- raw_list = []
167
-
168
- if isinstance(input_images, str):
169
- if os.path.exists(input_images):
170
- raw_list = [Image.open(input_images).convert("RGB")]
171
- elif isinstance(input_images, Image.Image):
172
- raw_list = [input_images.convert("RGB")]
173
- elif isinstance(input_images, list):
174
- for item in input_images:
175
- try:
176
- src = item[0] if isinstance(item, tuple) else item
177
- if isinstance(src, str):
178
- raw_list.append(Image.open(src).convert("RGB"))
179
- elif isinstance(src, Image.Image):
180
- raw_list.append(src.convert("RGB"))
181
- elif hasattr(src, "name"):
182
- raw_list.append(Image.open(src.name).convert("RGB"))
183
- except Exception as e:
184
- print(f"Skipping invalid image: {e}")
185
-
186
- if not raw_list:
187
- return None
188
-
189
- resized = [
190
- img.resize((width, height), Image.LANCZOS)
191
- for img in raw_list
192
- ]
193
- return resized
194
 
195
  def run_pipeline(pipe, lock, kwargs, seed):
196
  with lock:
@@ -198,51 +126,55 @@ def run_pipeline(pipe, lock, kwargs, seed):
198
  result = pipe(**kwargs, generator=gen).images[0]
199
  return result
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  @spaces.GPU(duration=120)
202
  def infer(
203
- prompt,
204
- input_images=None,
205
- seed=42,
206
- randomize_seed=False,
207
- width=1024,
208
- height=1024,
209
- num_inference_steps=4,
210
- guidance_scale=1.0,
211
- progress=gr.Progress(track_tqdm=True),
212
  ):
213
  gc.collect()
214
- torch.cuda.empty_cache()
 
215
 
216
- if not prompt or not prompt.strip():
217
- raise gr.Error("Please enter a prompt.")
218
 
219
  if randomize_seed:
220
  seed = random.randint(0, MAX_SEED)
221
 
222
- # ── width / height: derive from the first uploaded image if present ──
223
  image_list = None
224
- if input_images:
225
- # Re-derive dimensions from the actual first image so they are
226
- # always consistent with what the pipeline will receive.
227
- item = (
228
- input_images[0][0]
229
- if isinstance(input_images[0], tuple)
230
- else input_images[0]
231
- )
232
- if isinstance(item, str):
233
- first_pil = Image.open(item).convert("RGB")
234
- elif isinstance(item, Image.Image):
235
- first_pil = item.convert("RGB")
236
- else:
237
- first_pil = None
238
 
239
- if first_pil is not None:
240
- width, height = calc_dimensions(first_pil)
241
-
242
- # parse + resize all images to the final (width, height)
243
- image_list = parse_and_resize_images(input_images, width, height)
244
-
245
- # ensure dims are multiples of 8 even for text-only runs
246
  width = max(256, min(MAX_IMAGE_SIZE, round(int(width) / 8) * 8))
247
  height = max(256, min(MAX_IMAGE_SIZE, round(int(height) / 8) * 8))
248
 
@@ -250,236 +182,1054 @@ def infer(
250
  prompt=prompt,
251
  height=height,
252
  width=width,
253
- num_inference_steps=num_inference_steps,
254
  guidance_scale=guidance_scale,
255
  )
256
  if image_list is not None:
257
  shared_kwargs["image"] = image_list
258
 
259
- progress(0.30, desc="Launching both pipelines simultaneously...")
260
-
261
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
262
- future_std = executor.submit(
263
- run_pipeline, pipe_standard, pipe_lock_standard, shared_kwargs, seed
264
- )
265
- future_small = executor.submit(
266
- run_pipeline, pipe_small_decoder, pipe_lock_small, shared_kwargs, seed
267
- )
268
- concurrent.futures.wait(
269
- [future_std, future_small],
270
- return_when=concurrent.futures.ALL_COMPLETED,
271
- )
272
-
273
- progress(0.80, desc="✅ Both pipelines done!")
274
 
275
  out_standard = future_std.result()
276
  out_small = future_small.result()
277
 
278
  gc.collect()
279
- torch.cuda.empty_cache()
 
280
 
281
  return out_standard, out_small, seed
282
 
283
 
284
- @spaces.GPU(duration=120)
285
- def infer_example(prompt):
286
- out_std, out_small, seed_used = infer(
287
- prompt=prompt,
288
- input_images=None,
289
- seed=0,
290
- randomize_seed=True,
291
- width=1024,
292
- height=1024,
293
- num_inference_steps=4,
294
- guidance_scale=1.0,
295
- )
296
- return out_std, out_small, seed_used
297
 
 
 
 
 
 
 
298
 
299
- def get_example_items():
300
- example_prompts = {
301
- "1.jpg": "Change the weather to stormy.",
302
- "2.jpg": "Transform the scene into a snowy winter day while preserving the original subject identity, framing, and composition.",
303
- "3.jpg": "Relight the image with soft golden sunset lighting while keeping all structures and subject details consistent.",
304
- "4.jpg": "Make the texture high-resolution.",
305
- }
306
- items = []
307
- if EXAMPLES_DIR.exists():
308
- for name in sorted(os.listdir(EXAMPLES_DIR)):
309
- if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
310
- items.append({
311
- "file": name,
312
- "path": str(EXAMPLES_DIR / name),
313
- "prompt": example_prompts.get(
314
- name, "Edit this image while preserving composition."
315
- ),
316
- })
317
- return items
318
 
319
- EXAMPLE_ITEMS = get_example_items()
320
-
321
- css = """
322
- #col-container {
323
- margin: 0 auto;
324
- max-width: 1100px;
325
- }
326
- #main-title h1 {
327
- font-size: 2.4em !important;
328
- }
329
- .vae-badge {
330
- font-weight: 700;
331
- font-size: 0.95em;
332
- text-align: center;
333
- padding: 4px 16px;
334
- border-radius: 20px;
335
- display: block;
336
- margin-bottom: 6px;
337
- }
338
- """
339
-
340
- with gr.Blocks() as demo:
341
-
342
- with gr.Column(elem_id="col-container"):
343
-
344
- gr.Markdown(
345
- "# **Flux.2-4B-Decoder-Comparator**",
346
- elem_id="main-title",
347
- )
348
- gr.Markdown(
349
- "Compare **FLUX.2-klein-4B** side-by-side with "
350
- "[small decoder](https://huggingface.co/black-forest-labs/FLUX.2-small-decoder)."
351
- )
352
 
353
- with gr.Row(equal_height=True):
354
-
355
- with gr.Column():
356
- input_images = gr.Gallery(
357
- label="Input Images",
358
- type="pil",
359
- columns=2,
360
- rows=1,
361
- height=300,
362
- allow_preview=True,
363
- )
364
-
365
- prompt = gr.Text(
366
- label="Prompt",
367
- max_lines=1,
368
- show_label=True,
369
- placeholder="e.g., A black cat holding a sign that says hello world...",
370
- )
371
-
372
- run_button = gr.Button("Run Comparison", variant="primary")
373
-
374
- with gr.Column():
375
- with gr.Row():
376
- with gr.Column():
377
- result_standard = gr.Image(
378
- label="Standard Decoder",
379
- show_label=True,
380
- interactive=False,
381
- format="png",
382
- height=250,
383
- )
384
- with gr.Column():
385
- result_small = gr.Image(
386
- label="Small Decoder",
387
- show_label=True,
388
- interactive=False,
389
- format="png",
390
- height=250,
391
- )
392
-
393
- seed_output = gr.Number(label="Seed Used", precision=0, visible=False)
394
-
395
- with gr.Accordion("Advanced Settings", open=False, visible=False):
396
- seed = gr.Slider(
397
- label="Seed",
398
- minimum=0,
399
- maximum=MAX_SEED,
400
- step=1,
401
- value=0,
402
- )
403
- randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
404
-
405
- with gr.Row():
406
- width = gr.Slider(
407
- label="Width",
408
- minimum=256,
409
- maximum=MAX_IMAGE_SIZE,
410
- step=8,
411
- value=1024,
412
- )
413
- height_slider = gr.Slider(
414
- label="Height",
415
- minimum=256,
416
- maximum=MAX_IMAGE_SIZE,
417
- step=8,
418
- value=1024,
419
- )
420
-
421
- with gr.Row():
422
- num_inference_steps = gr.Slider(
423
- label="Inference Steps",
424
- minimum=1,
425
- maximum=20,
426
- step=1,
427
- value=4,
428
- )
429
- guidance_scale = gr.Slider(
430
- label="Guidance Scale",
431
- minimum=0.0,
432
- maximum=10.0,
433
- step=0.1,
434
- value=1.0,
435
- )
436
-
437
- gr.Examples(
438
- examples=[
439
- [["examples/I1.jpg", "examples/I2.jpg"], "Make her wear these glasses in Image 2."],
440
- [["examples/1.jpg"], "Change the weather to stormy."],
441
- [["examples/2.jpg"], "Transform the scene into a snowy winter day while preserving the original subject identity, framing, and composition."],
442
- [["examples/3.jpg"], "Relight the image with soft golden sunset lighting while keeping all structures and subject details consistent."],
443
- [["examples/4.jpg"], "Make the texture high-resolution."],
444
- ],
445
- inputs=[input_images, prompt],
446
- outputs=[result_standard, result_small, seed_output],
447
- fn=infer_example,
448
- cache_examples=False,
449
- label="Examples",
450
- )
451
 
452
- gr.Markdown(
453
- "[*](https://huggingface.co/black-forest-labs/FLUX.2-klein-4B) "
454
- "Experimental Space — FLUX.2 [klein] 4B VAE Decoder Comparison."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
  )
456
 
457
- input_images.upload(
458
- fn=update_dimensions_from_image,
459
- inputs=[input_images],
460
- outputs=[width, height_slider],
461
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
462
 
463
- gr.on(
464
- triggers=[run_button.click, prompt.submit],
465
- fn=infer,
466
- inputs=[
467
- prompt,
468
- input_images,
469
- seed,
470
- randomize_seed,
471
- width,
472
- height_slider,
473
- num_inference_steps,
474
- guidance_scale,
475
- ],
476
- outputs=[result_standard, result_small, seed_output],
477
- )
478
 
479
- if __name__ == "__main__":
480
- demo.queue(max_size=20).launch(
481
- theme=orange_red_theme, css=css,
482
- mcp_server=True,
483
- ssr_mode=False,
484
- show_error=True,
485
- )
 
1
  import os
2
+ import io
3
  import gc
4
+ import uuid
5
+ import json
6
+ import base64
7
  import random
8
+ import threading
9
+ import concurrent.futures
10
+ from pathlib import Path
11
+ from typing import List, Optional
12
+
13
  import spaces
14
+ import numpy as np
15
  import torch
 
16
  from PIL import Image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ from gradio import Server
19
+ from fastapi import Request, UploadFile, File, Form
20
+ from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ HF_TOKEN = os.environ.get("HF_TOKEN")
23
 
24
+ app = Server()
25
+
26
+ BASE_DIR = Path(__file__).resolve().parent
27
+ STATIC_DIR = BASE_DIR / "static"
28
+ OUTPUT_DIR = BASE_DIR / "outputs"
29
+ EXAMPLES_DIR = BASE_DIR / "examples"
30
+
31
+ STATIC_DIR.mkdir(exist_ok=True)
32
+ OUTPUT_DIR.mkdir(exist_ok=True)
33
 
34
  MAX_SEED = np.iinfo(np.int32).max
35
  MAX_IMAGE_SIZE = 1024
36
+
37
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
38
+ dtype = torch.bfloat16
39
+
40
+ if torch.cuda.is_available():
41
+ print("current device:", torch.cuda.current_device())
42
+ print("device name:", torch.cuda.get_device_name(torch.cuda.current_device()))
43
+ DEVICE_LABEL = torch.cuda.get_device_name(torch.cuda.current_device()).lower()
44
+ else:
45
+ DEVICE_LABEL = str(DEVICE).lower()
46
+
47
+ print("CUDA_VISIBLE_DEVICES =", os.environ.get("CUDA_VISIBLE_DEVICES"))
48
+ print("torch.__version__ =", torch.__version__)
49
+ print("Using device:", DEVICE)
50
+
51
+ from diffusers import Flux2KleinPipeline, AutoencoderKLFlux2
52
 
53
  print("Loading 4B Distilled model (Standard VAE)...")
54
  pipe_standard = Flux2KleinPipeline.from_pretrained(
55
  "black-forest-labs/FLUX.2-klein-4B",
56
  torch_dtype=dtype,
57
+ token=HF_TOKEN,
58
  )
59
  pipe_standard.enable_model_cpu_offload()
60
+ print("Standard pipeline loaded.")
61
 
62
  print("Loading Small Decoder VAE...")
63
  vae_small = AutoencoderKLFlux2.from_pretrained(
64
  "black-forest-labs/FLUX.2-small-decoder",
65
  torch_dtype=dtype,
66
+ token=HF_TOKEN,
67
  )
68
 
69
  print("Loading 4B Distilled model (Small Decoder VAE)...")
 
71
  "black-forest-labs/FLUX.2-klein-4B",
72
  vae=vae_small,
73
  torch_dtype=dtype,
74
+ token=HF_TOKEN,
75
  )
76
  pipe_small_decoder.enable_model_cpu_offload()
77
+ print("Small-decoder pipeline loaded.")
78
 
79
  pipe_lock_standard = threading.Lock()
80
  pipe_lock_small = threading.Lock()
81
 
82
+
83
+ # ─────────────────────────── helpers ────────────────────────────────────────
84
+
85
  def calc_dimensions(pil_img: Image.Image):
 
 
 
 
 
86
  iw, ih = pil_img.size
87
  aspect = iw / ih
88
+ if aspect >= 1:
 
89
  new_width = 1024
90
  new_height = int(round(1024 / aspect))
91
+ else:
92
  new_height = 1024
93
  new_width = int(round(1024 * aspect))
 
 
94
  new_width = max(256, min(1024, round(new_width / 8) * 8))
95
  new_height = max(256, min(1024, round(new_height / 8) * 8))
96
  return new_width, new_height
97
 
98
 
99
+ def parse_and_resize_images(paths: list[str], width: int, height: int):
100
+ result = []
101
+ for p in paths:
102
+ try:
103
+ img = Image.open(p).convert("RGB")
104
+ result.append(img.resize((width, height), Image.LANCZOS))
105
+ except Exception as e:
106
+ print(f"Skipping invalid image {p}: {e}")
107
+ return result or None
108
+
109
+
110
+ def image_to_base64(img: Image.Image) -> str:
111
+ buf = io.BytesIO()
112
+ img.save(buf, format="PNG")
113
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
114
+
115
+
116
+ def save_image(img: Image.Image, prefix: str = "output") -> str:
117
+ filename = f"{prefix}_{uuid.uuid4().hex}.png"
118
+ path = OUTPUT_DIR / filename
119
+ img.save(path, format="PNG")
120
+ return filename
121
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
 
123
  def run_pipeline(pipe, lock, kwargs, seed):
124
  with lock:
 
126
  result = pipe(**kwargs, generator=gen).images[0]
127
  return result
128
 
129
+
130
+ def get_example_items():
131
+ example_prompts = {
132
+ "1.jpg": "Change the weather to stormy.",
133
+ "2.jpg": "Transform the scene into a snowy winter day while preserving the original subject identity, framing, and composition.",
134
+ "3.jpg": "Relight the image with soft golden sunset lighting while keeping all structures and subject details consistent.",
135
+ "4.jpg": "Make the texture high-resolution.",
136
+ }
137
+ items = []
138
+ if EXAMPLES_DIR.exists():
139
+ for name in sorted(os.listdir(EXAMPLES_DIR)):
140
+ if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
141
+ items.append({
142
+ "file": name,
143
+ "url": f"/example-file/{name}",
144
+ "prompt": example_prompts.get(name, "Edit this image while preserving composition."),
145
+ })
146
+ return items
147
+
148
+
149
+ # ─────────────────────────── inference ──────────────────────────────────────
150
+
151
  @spaces.GPU(duration=120)
152
  def infer(
153
+ image_paths: list[str],
154
+ prompt: str,
155
+ seed: int,
156
+ randomize_seed: bool,
157
+ width: int,
158
+ height: int,
159
+ steps: int,
160
+ guidance_scale: float,
 
161
  ):
162
  gc.collect()
163
+ if torch.cuda.is_available():
164
+ torch.cuda.empty_cache()
165
 
166
+ if not prompt or not str(prompt).strip():
167
+ raise ValueError("Please enter a prompt.")
168
 
169
  if randomize_seed:
170
  seed = random.randint(0, MAX_SEED)
171
 
 
172
  image_list = None
173
+ if image_paths:
174
+ first = Image.open(image_paths[0]).convert("RGB")
175
+ width, height = calc_dimensions(first)
176
+ image_list = parse_and_resize_images(image_paths, width, height)
 
 
 
 
 
 
 
 
 
 
177
 
 
 
 
 
 
 
 
178
  width = max(256, min(MAX_IMAGE_SIZE, round(int(width) / 8) * 8))
179
  height = max(256, min(MAX_IMAGE_SIZE, round(int(height) / 8) * 8))
180
 
 
182
  prompt=prompt,
183
  height=height,
184
  width=width,
185
+ num_inference_steps=steps,
186
  guidance_scale=guidance_scale,
187
  )
188
  if image_list is not None:
189
  shared_kwargs["image"] = image_list
190
 
 
 
191
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
192
+ future_std = executor.submit(run_pipeline, pipe_standard, pipe_lock_standard, shared_kwargs, seed)
193
+ future_small = executor.submit(run_pipeline, pipe_small_decoder, pipe_lock_small, shared_kwargs, seed)
194
+ concurrent.futures.wait([future_std, future_small], return_when=concurrent.futures.ALL_COMPLETED)
 
 
 
 
 
 
 
 
 
195
 
196
  out_standard = future_std.result()
197
  out_small = future_small.result()
198
 
199
  gc.collect()
200
+ if torch.cuda.is_available():
201
+ torch.cuda.empty_cache()
202
 
203
  return out_standard, out_small, seed
204
 
205
 
206
+ # ─────────────────────────── routes ─────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
207
 
208
+ @app.get("/example-file/{filename}")
209
+ async def example_file(filename: str):
210
+ path = EXAMPLES_DIR / filename
211
+ if not path.exists():
212
+ return JSONResponse({"error": "Example not found"}, status_code=404)
213
+ return FileResponse(path)
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
216
+ @app.get("/download/{filename}")
217
+ async def download_file(filename: str):
218
+ path = OUTPUT_DIR / filename
219
+ if not path.exists():
220
+ return JSONResponse({"error": "File not found"}, status_code=404)
221
+ return FileResponse(path, filename=filename, media_type="image/png")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
+ @app.post("/api/compare")
225
+ async def compare_images(
226
+ prompt: str = Form(...),
227
+ seed: str = Form("0"),
228
+ randomize_seed: str = Form("true"),
229
+ width: str = Form("1024"),
230
+ height: str = Form("1024"),
231
+ steps: str = Form("4"),
232
+ guidance_scale: str = Form("1.0"),
233
+ images: Optional[List[UploadFile]] = File(None),
234
+ ):
235
+ temp_paths = []
236
+ try:
237
+ image_paths = []
238
+ if images:
239
+ for upload in images:
240
+ suffix = Path(upload.filename).suffix or ".png"
241
+ temp_name = f"upload_{uuid.uuid4().hex}{suffix}"
242
+ temp_path = OUTPUT_DIR / temp_name
243
+ content = await upload.read()
244
+ with open(temp_path, "wb") as f:
245
+ f.write(content)
246
+ temp_paths.append(str(temp_path))
247
+ image_paths.append(str(temp_path))
248
+
249
+ out_std, out_small, used_seed = infer(
250
+ image_paths = image_paths,
251
+ prompt = prompt,
252
+ seed = int(seed),
253
+ randomize_seed= randomize_seed.lower() == "true",
254
+ width = int(width),
255
+ height = int(height),
256
+ steps = int(steps),
257
+ guidance_scale= float(guidance_scale),
258
  )
259
 
260
+ fn_std = save_image(out_std, prefix="std")
261
+ fn_small = save_image(out_small, prefix="small")
262
+
263
+ return JSONResponse({
264
+ "success": True,
265
+ "seed": used_seed,
266
+ "standard": {
267
+ "image_url": f"/download/{fn_std}",
268
+ "download_url": f"/download/{fn_std}",
269
+ "image_base64": image_to_base64(out_std),
270
+ },
271
+ "small": {
272
+ "image_url": f"/download/{fn_small}",
273
+ "download_url": f"/download/{fn_small}",
274
+ "image_base64": image_to_base64(out_small),
275
+ },
276
+ "device": DEVICE_LABEL,
277
+ })
278
+
279
+ except Exception as e:
280
+ return JSONResponse({"success": False, "error": str(e)}, status_code=500)
281
+ finally:
282
+ for p in temp_paths:
283
+ try:
284
+ if os.path.exists(p):
285
+ os.remove(p)
286
+ except Exception:
287
+ pass
288
+
289
+
290
+ @app.get("/", response_class=HTMLResponse)
291
+ async def homepage(request: Request):
292
+ examples = get_example_items()
293
+ examples_json = json.dumps(examples)
294
+
295
+ return f"""<!DOCTYPE html>
296
+ <html lang="en">
297
+ <head>
298
+ <meta charset="UTF-8" />
299
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
300
+ <title>FLUX.2 Klein – Decoder Comparator</title>
301
+ <style>
302
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap');
303
+
304
+ :root {{
305
+ --bg: #0b0b10;
306
+ --panel: #111218;
307
+ --panel-2: #151621;
308
+ --panel-3: #1b1d2a;
309
+ --border: #242638;
310
+ --muted: #9ca3af;
311
+ --text: #f5f7fb;
312
+ --text-dim: #c5cad3;
313
+ --purple: #7c3aed;
314
+ --purple-hover:#6d28d9;
315
+ --purple-soft: rgba(124,58,237,0.14);
316
+ --green: #22c55e;
317
+ --green-soft: rgba(34,197,94,0.14);
318
+ --red: #ef4444;
319
+ --red-soft: rgba(239,68,68,0.14);
320
+ --orange: #f97316;
321
+ --orange-soft: rgba(249,115,22,0.14);
322
+ --input-bg: #0f1017;
323
+ }}
324
+
325
+ * {{ box-sizing: border-box; border-radius: 0 !important; }}
326
+
327
+ html, body {{
328
+ margin: 0; padding: 0;
329
+ background: var(--bg);
330
+ color: var(--text);
331
+ font-family: 'Outfit', sans-serif;
332
+ min-height: 100%;
333
+ }}
334
+
335
+ body {{ overflow-x: hidden; }}
336
+
337
+ .app-shell {{
338
+ min-height: 100vh;
339
+ background: linear-gradient(to bottom, rgba(124,58,237,0.08), transparent 160px), var(--bg);
340
+ }}
341
+
342
+ .topbar {{
343
+ height: 56px;
344
+ border-bottom: 1px solid var(--border);
345
+ background: #0a0b11;
346
+ display: flex;
347
+ align-items: center;
348
+ justify-content: center;
349
+ padding: 0 24px;
350
+ color: #d7cdfc;
351
+ font-size: 14px;
352
+ font-weight: 600;
353
+ letter-spacing: 0.02em;
354
+ }}
355
+
356
+ .container {{
357
+ max-width: 1440px;
358
+ margin: 0 auto;
359
+ padding: 28px;
360
+ }}
361
+
362
+ /* ── hero ── */
363
+ .hero {{
364
+ display: flex;
365
+ align-items: flex-start;
366
+ justify-content: space-between;
367
+ gap: 16px;
368
+ margin-bottom: 24px;
369
+ padding-bottom: 20px;
370
+ border-bottom: 1px solid var(--border);
371
+ }}
372
+ .hero-left {{ display: flex; flex-direction: column; gap: 14px; }}
373
+ .eyebrow {{ color: var(--muted); font-size: 13px; font-weight: 500; }}
374
+ .title-row {{ display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }}
375
+ .title {{ font-size: 40px; line-height: 1; font-weight: 800; margin: 0; letter-spacing: -0.03em; }}
376
+ .hero-tags {{ display: flex; flex-wrap: wrap; gap: 10px; }}
377
+ .tag {{
378
+ display: inline-flex; align-items: center; gap: 8px;
379
+ height: 34px; padding: 0 12px;
380
+ border: 1px solid var(--border);
381
+ font-size: 13px; font-weight: 700; letter-spacing: 0.01em;
382
+ }}
383
+ .tag svg {{ width: 15px; height: 15px; flex-shrink: 0; }}
384
+ .tag-purple {{ color: #d8ccff; background: var(--purple-soft); border-color: rgba(124,58,237,0.35); }}
385
+ .tag-green {{ color: #bbf7d0; background: var(--green-soft); border-color: rgba(34,197,94,0.35); }}
386
+ .tag-orange {{ color: #fed7aa; background: var(--orange-soft); border-color: rgba(249,115,22,0.35); }}
387
+ .hero-actions {{ display: flex; gap: 10px; flex-shrink: 0; }}
388
+ .ghost-btn {{
389
+ height: 40px; padding: 0 14px;
390
+ border: 1px solid var(--border);
391
+ background: var(--panel); color: var(--text);
392
+ font-family: 'Outfit', sans-serif; font-size: 14px; font-weight: 600; cursor: pointer;
393
+ }}
394
+ .ghost-btn:hover {{ background: var(--panel-2); }}
395
+
396
+ /* ── two-col layout ── */
397
+ .layout {{
398
+ display: grid;
399
+ grid-template-columns: 420px 1fr;
400
+ gap: 24px;
401
+ align-items: start;
402
+ }}
403
+
404
+ /* ── generic panel ── */
405
+ .panel {{
406
+ background: var(--panel);
407
+ border: 1px solid var(--border);
408
+ display: flex;
409
+ flex-direction: column;
410
+ overflow: hidden;
411
+ }}
412
+ .panel-header {{
413
+ height: 62px; min-height: 62px;
414
+ border-bottom: 1px solid var(--border);
415
+ display: flex; align-items: center; justify-content: space-between;
416
+ padding: 0 18px; background: #101119;
417
+ }}
418
+ .panel-title {{ font-size: 20px; font-weight: 700; letter-spacing: -0.02em; margin: 0; }}
419
+ .panel-header-right {{ display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; font-weight: 600; }}
420
+ .status-pill {{
421
+ padding: 5px 8px;
422
+ background: var(--panel-3); border: 1px solid var(--border);
423
+ color: var(--muted); font-size: 12px; line-height: 1;
424
+ transition: all 0.2s ease;
425
+ }}
426
+ .status-pill.active {{ background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.35); color: #fbbf24; }}
427
+ .status-pill.idle {{ background: var(--panel-3); border: 1px solid var(--border); color: var(--muted); }}
428
+ .panel-body {{ flex: 1; padding: 18px; overflow: auto; }}
429
+
430
+ /* ── form ── */
431
+ .form-stack {{ display: flex; flex-direction: column; gap: 18px; }}
432
+ .form-group {{ display: flex; flex-direction: column; gap: 10px; }}
433
+ .label {{ font-size: 14px; font-weight: 600; color: var(--muted); letter-spacing: 0.02em; }}
434
+ .hint {{ color: var(--muted); font-size: 13px; line-height: 1.5; margin-top: -4px; }}
435
+
436
+ textarea, input, button, select {{ font-family: 'Outfit', sans-serif; }}
437
+
438
+ .input, .textarea {{
439
+ width: 100%; background: var(--input-bg); border: 1px solid var(--border);
440
+ color: var(--text); outline: none; padding: 14px; font-size: 15px;
441
+ }}
442
+ .input:focus, .textarea:focus {{ border-color: #3a3d56; background: #11131b; }}
443
+ .textarea {{ min-height: 110px; resize: vertical; line-height: 1.55; }}
444
+
445
+ /* ── upload zone ── */
446
+ .upload-wrap {{
447
+ background: var(--input-bg); border: 1px dashed #32354b;
448
+ min-height: 140px;
449
+ display: flex; flex-direction: column; gap: 14px; padding: 14px; cursor: pointer;
450
+ }}
451
+ .upload-wrap.dragover {{ border-color: var(--purple); background: rgba(124,58,237,0.08); }}
452
+ .upload-wrap input[type="file"] {{ display: none; }}
453
+ .upload-placeholder {{
454
+ min-height: 110px; display: flex; flex-direction: column;
455
+ align-items: center; justify-content: center; gap: 14px;
456
+ background: transparent; border: none; color: var(--text-dim);
457
+ cursor: pointer; padding: 16px; text-align: center;
458
+ }}
459
+ .upload-icon {{
460
+ width: 48px; height: 48px;
461
+ border: 1px solid var(--border); background: var(--panel-2);
462
+ display: flex; align-items: center; justify-content: center; color: #d8ccff;
463
+ }}
464
+ .preview-grid {{
465
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); gap: 10px;
466
+ }}
467
+ .thumb {{ position: relative; aspect-ratio: 1/1; overflow: hidden; border: 1px solid var(--border); background: #0b0c12; }}
468
+ .thumb img {{ width: 100%; height: 100%; object-fit: cover; display: block; }}
469
+ .thumb-remove {{
470
+ position: absolute; top: 5px; right: 5px; width: 24px; height: 24px;
471
+ border: 1px solid var(--border); background: rgba(11,11,16,0.88);
472
+ color: white; cursor: pointer; display: flex;
473
+ align-items: center; justify-content: center; font-size: 15px; line-height: 1;
474
+ }}
475
+
476
+ /* ── advanced ── */
477
+ .advanced {{ border: 1px solid var(--border); background: #0f1017; }}
478
+ .advanced-toggle {{
479
+ width: 100%; height: 48px; border: none;
480
+ border-bottom: 1px solid var(--border); background: transparent;
481
+ color: var(--text); display: flex; align-items: center; justify-content: space-between;
482
+ padding: 0 14px; cursor: pointer; font-size: 14px; font-weight: 600;
483
+ }}
484
+ .advanced-toggle:hover {{ background: #121420; }}
485
+ .advanced-body {{ display: none; padding: 14px; }}
486
+ .advanced-body.open {{ display: block; }}
487
+ .advanced-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }}
488
+ .checkbox-row {{
489
+ margin-top: 14px; display: flex; align-items: center; gap: 10px;
490
+ color: var(--text-dim); font-size: 14px; font-weight: 500;
491
+ }}
492
+ .checkbox-row input {{ width: 16px; height: 16px; accent-color: var(--purple); }}
493
+
494
+ /* ── actions ── */
495
+ .actions {{
496
+ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding-top: 8px;
497
+ }}
498
+ .btn {{
499
+ height: 48px; border: 1px solid var(--border);
500
+ background: var(--panel-2); color: var(--text);
501
+ cursor: pointer; font-size: 15px; font-weight: 700; letter-spacing: 0.01em;
502
+ }}
503
+ .btn:hover {{ background: #1a1d29; }}
504
+ .btn-primary {{ background: var(--purple); border-color: var(--purple); color: white; }}
505
+ .btn-primary:hover {{ background: var(--purple-hover); border-color: var(--purple-hover); }}
506
+ .btn:disabled {{ opacity: 0.6; cursor: not-allowed; }}
507
+
508
+ /* ── result panel ── */
509
+ .result-shell {{ display: flex; flex-direction: column; gap: 16px; }}
510
+
511
+ /* ── comparison slider ── */
512
+ .compare-wrap {{
513
+ position: relative;
514
+ width: 100%;
515
+ aspect-ratio: 1 / 1;
516
+ border: 1px solid var(--border);
517
+ background: #0d0e14;
518
+ overflow: hidden;
519
+ user-select: none;
520
+ touch-action: none;
521
+ }}
522
+
523
+ .compare-empty {{
524
+ position: absolute; inset: 0;
525
+ display: flex; flex-direction: column;
526
+ align-items: center; justify-content: center;
527
+ gap: 14px; color: var(--text-dim); text-align: center; padding: 24px;
528
+ z-index: 1;
529
+ }}
530
+ .compare-empty-box {{
531
+ width: 72px; height: 72px;
532
+ border: 1px solid var(--border); background: var(--panel-2);
533
+ display: flex; align-items: center; justify-content: center; color: #d8ccff;
534
+ }}
535
+
536
+ /* right image fills whole area */
537
+ .cmp-right {{
538
+ position: absolute; inset: 0;
539
+ width: 100%; height: 100%;
540
+ object-fit: contain;
541
+ display: none;
542
+ }}
543
+
544
+ /* left image clipped */
545
+ .cmp-left-clip {{
546
+ position: absolute; inset: 0;
547
+ overflow: hidden;
548
+ width: 50%; /* updated by JS */
549
+ }}
550
+ .cmp-left {{
551
+ position: absolute; inset: 0;
552
+ width: 100%; /* viewport width, not clip width */
553
+ height: 100%;
554
+ object-fit: contain;
555
+ display: none;
556
+ }}
557
+
558
+ /* divider line */
559
+ .cmp-divider {{
560
+ position: absolute; top: 0; bottom: 0;
561
+ left: 50%; /* updated by JS */
562
+ width: 2px;
563
+ background: white;
564
+ transform: translateX(-50%);
565
+ z-index: 10;
566
+ display: none;
567
+ pointer-events: none;
568
+ }}
569
+
570
+ /* handle knob */
571
+ .cmp-handle {{
572
+ position: absolute;
573
+ top: 50%; left: 50%; /* updated by JS */
574
+ transform: translate(-50%, -50%);
575
+ width: 44px; height: 44px;
576
+ border-radius: 50% !important;
577
+ background: white;
578
+ border: 3px solid rgba(0,0,0,0.25);
579
+ box-shadow: 0 2px 12px rgba(0,0,0,0.4);
580
+ z-index: 11;
581
+ display: none;
582
+ cursor: ew-resize;
583
+ align-items: center; justify-content: center;
584
+ color: #111;
585
+ }}
586
+ .cmp-handle svg {{ width: 20px; height: 20px; flex-shrink: 0; }}
587
+
588
+ /* labels */
589
+ .cmp-label {{
590
+ position: absolute; top: 10px;
591
+ padding: 4px 10px;
592
+ background: rgba(11,11,16,0.82);
593
+ border: 1px solid var(--border);
594
+ font-size: 12px; font-weight: 700; letter-spacing: 0.05em;
595
+ z-index: 9; display: none; pointer-events: none;
596
+ }}
597
+ .cmp-label-left {{ left: 10px; color: #d8ccff; border-color: rgba(124,58,237,0.5); }}
598
+ .cmp-label-right {{ right: 10px; color: #bbf7d0; border-color: rgba(34,197,94,0.5); }}
599
+
600
+ /* download buttons row */
601
+ .dl-row {{
602
+ display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
603
+ }}
604
+ .dl-btn {{
605
+ height: 38px; border: 1px solid var(--border);
606
+ background: var(--panel-2); color: var(--text-dim);
607
+ cursor: pointer; font-size: 13px; font-weight: 600;
608
+ display: flex; align-items: center; justify-content: center; gap: 8px;
609
+ text-decoration: none;
610
+ }}
611
+ .dl-btn:hover {{ background: #1a1d29; color: var(--text); }}
612
+ .dl-btn svg {{ width: 15px; height: 15px; flex-shrink: 0; }}
613
+
614
+ /* ── meta cards ── */
615
+ .result-meta {{ display: flex; align-items: stretch; gap: 12px; flex-wrap: wrap; }}
616
+ .meta-card {{
617
+ border: 1px solid var(--border); background: var(--panel-2);
618
+ padding: 12px 14px; flex: 1 1 160px;
619
+ }}
620
+ .meta-label {{ font-size: 12px; color: var(--muted); letter-spacing: 0.04em; margin-bottom: 6px; }}
621
+ .meta-value {{ font-size: 14px; font-weight: 700; color: var(--text); word-break: break-word; line-height: 1.45; text-transform: lowercase; }}
622
+
623
+ /* ── loader ── */
624
+ .loader {{
625
+ position: absolute; inset: 0;
626
+ display: none; align-items: center; justify-content: center;
627
+ flex-direction: column; gap: 14px;
628
+ background: rgba(7,8,12,0.5);
629
+ backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px);
630
+ z-index: 20; pointer-events: none;
631
+ }}
632
+ .circle-loader {{
633
+ width: 58px; height: 58px;
634
+ border-radius: 50% !important;
635
+ border: 4px solid rgba(255,255,255,0.14);
636
+ border-top-color: #ffffff; border-right-color: #c4b5fd;
637
+ animation: spin 0.9s linear infinite;
638
+ box-shadow: 0 0 20px rgba(124,58,237,0.18);
639
+ }}
640
+ .loader span {{ font-size: 14px; font-weight: 600; color: #fff; letter-spacing: 0.02em; text-shadow: 0 1px 2px rgba(0,0,0,0.35); }}
641
+
642
+ /* ── examples ── */
643
+ .examples-panel {{
644
+ margin-top: 24px; background: var(--panel); border: 1px solid var(--border); overflow: hidden;
645
+ }}
646
+ .examples-header {{
647
+ height: 58px; border-bottom: 1px solid var(--border);
648
+ display: flex; align-items: center; padding: 0 18px;
649
+ font-size: 20px; font-weight: 700; background: #101119;
650
+ }}
651
+ .examples-body {{ padding: 18px; }}
652
+ .examples-grid {{
653
+ display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 14px;
654
+ }}
655
+ .example-card {{
656
+ background: #0f1017; border: 1px solid var(--border); cursor: pointer; overflow: hidden;
657
+ }}
658
+ .example-card:hover {{ border-color: #3a3d56; background: #121420; }}
659
+ .example-card img {{
660
+ width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block;
661
+ border-bottom: 1px solid var(--border);
662
+ }}
663
+ .example-body {{ padding: 12px; }}
664
+ .example-body p {{ margin: 0; color: var(--text-dim); font-size: 13px; line-height: 1.5; font-weight: 500; }}
665
+
666
+ /* ── toast ── */
667
+ .toast-wrap {{
668
+ position: fixed; top: 18px; right: 18px; z-index: 9999;
669
+ display: flex; flex-direction: column; gap: 10px;
670
+ }}
671
+ .toast {{
672
+ min-width: 260px; max-width: 360px;
673
+ background: #141623; border: 1px solid var(--border);
674
+ color: var(--text); padding: 12px 14px;
675
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
676
+ box-shadow: 0 10px 30px rgba(0,0,0,0.35);
677
+ }}
678
+ .toast button {{ border: none; background: transparent; color: var(--text); font-size: 18px; cursor: pointer; padding: 0; line-height: 1; }}
679
+
680
+ @keyframes spin {{ from {{ transform: rotate(0deg); }} to {{ transform: rotate(360deg); }} }}
681
+
682
+ @media (max-width: 1100px) {{
683
+ .layout {{ grid-template-columns: 1fr; }}
684
+ .examples-grid {{ grid-template-columns: repeat(2, minmax(0,1fr)); }}
685
+ }}
686
+ @media (max-width: 640px) {{
687
+ .container {{ padding: 16px; }}
688
+ .title {{ font-size: 28px; }}
689
+ .advanced-grid, .actions, .dl-row {{ grid-template-columns: 1fr; }}
690
+ .examples-grid {{ grid-template-columns: 1fr; }}
691
+ }}
692
+ </style>
693
+ </head>
694
+ <body>
695
+ <div class="toast-wrap" id="toastWrap"></div>
696
+
697
+ <div class="app-shell">
698
+ <div class="topbar">FLUX.2-klein-4B · Standard vs Small Decoder · Side-by-Side Comparison</div>
699
+
700
+ <div class="container">
701
+
702
+ <!-- hero -->
703
+ <section class="hero">
704
+ <div class="hero-left">
705
+ <div class="eyebrow">black-forest-labs / flux.2-klein-4b / decoder-compare</div>
706
+ <div class="title-row">
707
+ <h1 class="title">Decoder Comparator</h1>
708
+ </div>
709
+ <div class="hero-tags">
710
+ <div class="tag tag-purple">
711
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
712
+ <path d="M12 3v18"/><path d="M3 12h18"/>
713
+ </svg>
714
+ <span>4B Distilled</span>
715
+ </div>
716
+ <div class="tag tag-green">
717
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
718
+ <rect x="3" y="5" width="18" height="14"/>
719
+ <path d="M8 13l2.5-2.5L13 13"/><path d="M13 13l2-2 3 3"/>
720
+ </svg>
721
+ <span>Standard VAE vs Small Decoder</span>
722
+ </div>
723
+ <div class="tag tag-orange">
724
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
725
+ <path d="M13 2L4 14h7l-1 8 9-12h-7l1-8z"/>
726
+ </svg>
727
+ <span>Parallel Inference</span>
728
+ </div>
729
+ </div>
730
+ </div>
731
+ <div class="hero-actions">
732
+ <button class="ghost-btn" type="button"
733
+ onclick="document.getElementById('examplesSection').scrollIntoView({{behavior:'smooth'}})">
734
+ Examples
735
+ </button>
736
+ </div>
737
+ </section>
738
+
739
+ <!-- main layout -->
740
+ <section class="layout">
741
+
742
+ <!-- LEFT: Input panel -->
743
+ <div class="panel">
744
+ <div class="panel-header">
745
+ <h2 class="panel-title">Input</h2>
746
+ <div class="panel-header-right">
747
+ <span class="status-pill idle" id="runStatus">Idle</span>
748
+ </div>
749
+ </div>
750
+ <div class="panel-body">
751
+ <div class="form-stack">
752
+
753
+ <div class="form-group">
754
+ <div class="label">Images <span style="color:var(--muted);font-weight:400;">(optional)</span></div>
755
+ <div class="upload-wrap" id="uploadZone">
756
+ <input id="fileInput" type="file" accept="image/*" multiple />
757
+ <button class="upload-placeholder" id="uploadPlaceholder" type="button">
758
+ <div class="upload-icon">
759
+ <svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.8">
760
+ <path d="M12 4v10"/><path d="M8.5 7.5 12 4l3.5 3.5"/>
761
+ <path d="M4 16.5h16"/><path d="M6 20h12"/>
762
+ </svg>
763
+ </div>
764
+ <div>
765
+ <div style="font-weight:700;color:var(--text);margin-bottom:4px;">Upload images</div>
766
+ <div style="font-size:13px;color:var(--muted);">Drag & drop or click to browse</div>
767
+ </div>
768
+ </button>
769
+ <div class="preview-grid" id="previewGrid" style="display:none;"></div>
770
+ </div>
771
+ <div class="hint">First image auto-fits width/height to preserve aspect ratio.</div>
772
+ </div>
773
+
774
+ <div class="form-group">
775
+ <label class="label" for="prompt">Prompt</label>
776
+ <textarea id="prompt" class="textarea"
777
+ placeholder="e.g. Change the weather to stormy…"></textarea>
778
+ </div>
779
+
780
+ <div class="advanced">
781
+ <button class="advanced-toggle" id="advancedToggle" type="button">
782
+ <span>Advanced Settings</span>
783
+ <span id="advancedIcon" style="font-size:22px;font-weight:700;line-height:1;">+</span>
784
+ </button>
785
+ <div class="advanced-body" id="advancedBody">
786
+ <div class="advanced-grid">
787
+ <div class="form-group">
788
+ <label class="label" for="seed">Seed</label>
789
+ <input id="seed" class="input" type="number" min="0" max="{MAX_SEED}" value="0" />
790
+ </div>
791
+ <div class="form-group">
792
+ <label class="label" for="steps">Steps</label>
793
+ <input id="steps" class="input" type="number" min="1" max="20" value="4" />
794
+ </div>
795
+ <div class="form-group">
796
+ <label class="label" for="width">Width</label>
797
+ <input id="width" class="input" type="number" min="256" max="{MAX_IMAGE_SIZE}" step="8" value="1024" />
798
+ </div>
799
+ <div class="form-group">
800
+ <label class="label" for="height">Height</label>
801
+ <input id="height" class="input" type="number" min="256" max="{MAX_IMAGE_SIZE}" step="8" value="1024" />
802
+ </div>
803
+ <div class="form-group">
804
+ <label class="label" for="guidance">Guidance Scale</label>
805
+ <input id="guidance" class="input" type="number" min="0" max="10" step="0.1" value="1.0" />
806
+ </div>
807
+ </div>
808
+ <div class="checkbox-row">
809
+ <input id="randomizeSeed" type="checkbox" checked />
810
+ <label for="randomizeSeed">Randomize seed</label>
811
+ </div>
812
+ </div>
813
+ </div>
814
+
815
+ <div class="actions">
816
+ <button class="btn btn-primary" id="runBtn" type="button">Run Comparison</button>
817
+ <button class="btn" id="clearBtn" type="button">Clear</button>
818
+ </div>
819
+
820
+ </div>
821
+ </div>
822
+ </div>
823
+
824
+ <!-- RIGHT: Result panel -->
825
+ <div class="panel">
826
+ <div class="panel-header">
827
+ <h2 class="panel-title">Result</h2>
828
+ <div class="panel-header-right">
829
+ <span style="font-size:12px;color:var(--muted);">Drag the handle to compare</span>
830
+ <span class="status-pill idle" id="resultStatus">Idle</span>
831
+ </div>
832
+ </div>
833
+ <div class="panel-body">
834
+ <div class="result-shell">
835
+
836
+ <!-- Comparison slider -->
837
+ <div class="compare-wrap" id="compareWrap">
838
+
839
+ <!-- empty state -->
840
+ <div class="compare-empty" id="compareEmpty">
841
+ <div class="compare-empty-box">
842
+ <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.8">
843
+ <rect x="4" y="5" width="16" height="11"/>
844
+ <path d="M8 12l2.5-2.5L13 12"/><path d="M13 12l2-2 2 2"/>
845
+ <path d="M12 16v4"/>
846
+ </svg>
847
+ </div>
848
+ <div>
849
+ <div style="font-size:17px;font-weight:700;color:var(--text);margin-bottom:4px;">No output yet</div>
850
+ <div style="font-size:14px;color:var(--muted);">Run the comparison to see results here</div>
851
+ </div>
852
+ </div>
853
+
854
+ <!-- right image (small decoder) — full area -->
855
+ <img id="cmpRight" class="cmp-right" alt="Small Decoder" />
856
+
857
+ <!-- left image clip + image (standard) -->
858
+ <div class="cmp-left-clip" id="cmpLeftClip">
859
+ <img id="cmpLeft" class="cmp-left" alt="Standard Decoder" />
860
+ </div>
861
+
862
+ <!-- divider & handle -->
863
+ <div class="cmp-divider" id="cmpDivider"></div>
864
+ <div class="cmp-handle" id="cmpHandle">
865
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
866
+ <path d="M8 5l-5 7 5 7"/><path d="M16 5l5 7-5 7"/>
867
+ </svg>
868
+ </div>
869
+
870
+ <!-- labels -->
871
+ <div class="cmp-label cmp-label-left" id="labelLeft">STANDARD</div>
872
+ <div class="cmp-label cmp-label-right" id="labelRight">SMALL&nbsp;DEC</div>
873
+
874
+ <!-- loader overlay -->
875
+ <div class="loader" id="loaderOverlay">
876
+ <div class="circle-loader"></div>
877
+ <span>Running both pipelines…</span>
878
+ </div>
879
+
880
+ </div>
881
+
882
+ <!-- download row -->
883
+ <div class="dl-row">
884
+ <a id="dlStd" class="dl-btn" download>
885
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
886
+ <path d="M12 4v10"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/><path d="M5 20h14"/>
887
+ </svg>
888
+ Download Standard
889
+ </a>
890
+ <a id="dlSmall" class="dl-btn" download>
891
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
892
+ <path d="M12 4v10"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/><path d="M5 20h14"/>
893
+ </svg>
894
+ Download Small Dec
895
+ </a>
896
+ </div>
897
+
898
+ <!-- meta -->
899
+ <div class="result-meta">
900
+ <div class="meta-card">
901
+ <div class="meta-label">seed used</div>
902
+ <div class="meta-value" id="usedSeed">—</div>
903
+ </div>
904
+ <div class="meta-card">
905
+ <div class="meta-label">device</div>
906
+ <div class="meta-value" id="deviceValue">{DEVICE_LABEL}</div>
907
+ </div>
908
+ <div class="meta-card">
909
+ <div class="meta-label">pipelines</div>
910
+ <div class="meta-value">parallel · 2×</div>
911
+ </div>
912
+ </div>
913
+
914
+ </div>
915
+ </div>
916
+ </div>
917
+ </section>
918
+
919
+ <!-- examples -->
920
+ <section class="examples-panel" id="examplesSection">
921
+ <div class="examples-header">Examples</div>
922
+ <div class="examples-body">
923
+ <div class="examples-grid" id="examplesGrid"></div>
924
+ </div>
925
+ </section>
926
+
927
+ </div><!-- /container -->
928
+ </div><!-- /app-shell -->
929
+
930
+ <script>
931
+ /* ─────────── data from server ─────────── */
932
+ const examples = {examples_json};
933
+ const MAX_SEED = {MAX_SEED};
934
+ const DEVICE_LBL = "{DEVICE_LABEL}";
935
+
936
+ /* ─────────── state ─────────── */
937
+ const state = {{ files: [], advancedOpen: false, hasResult: false }};
938
+
939
+ /* ─────────── element refs ─────────── */
940
+ const uploadZone = document.getElementById("uploadZone");
941
+ const fileInput = document.getElementById("fileInput");
942
+ const uploadPlaceholder = document.getElementById("uploadPlaceholder");
943
+ const previewGrid = document.getElementById("previewGrid");
944
+ const promptEl = document.getElementById("prompt");
945
+ const seedEl = document.getElementById("seed");
946
+ const stepsEl = document.getElementById("steps");
947
+ const widthEl = document.getElementById("width");
948
+ const heightEl = document.getElementById("height");
949
+ const guidanceEl = document.getElementById("guidance");
950
+ const randomizeSeedEl = document.getElementById("randomizeSeed");
951
+ const advancedToggle = document.getElementById("advancedToggle");
952
+ const advancedBody = document.getElementById("advancedBody");
953
+ const advancedIcon = document.getElementById("advancedIcon");
954
+ const runBtn = document.getElementById("runBtn");
955
+ const clearBtn = document.getElementById("clearBtn");
956
+ const runStatus = document.getElementById("runStatus");
957
+ const resultStatus = document.getElementById("resultStatus");
958
+ const compareEmpty = document.getElementById("compareEmpty");
959
+ const compareWrap = document.getElementById("compareWrap");
960
+ const cmpLeft = document.getElementById("cmpLeft");
961
+ const cmpLeftClip = document.getElementById("cmpLeftClip");
962
+ const cmpRight = document.getElementById("cmpRight");
963
+ const cmpDivider = document.getElementById("cmpDivider");
964
+ const cmpHandle = document.getElementById("cmpHandle");
965
+ const labelLeft = document.getElementById("labelLeft");
966
+ const labelRight = document.getElementById("labelRight");
967
+ const loaderOverlay = document.getElementById("loaderOverlay");
968
+ const usedSeed = document.getElementById("usedSeed");
969
+ const deviceValue = document.getElementById("deviceValue");
970
+ const dlStd = document.getElementById("dlStd");
971
+ const dlSmall = document.getElementById("dlSmall");
972
+ const examplesGrid = document.getElementById("examplesGrid");
973
+ const toastWrap = document.getElementById("toastWrap");
974
+
975
+ /* ─────────── toast ─────────── */
976
+ function showToast(msg) {{
977
+ const t = document.createElement("div");
978
+ t.className = "toast";
979
+ const txt = document.createElement("div");
980
+ txt.textContent = msg;
981
+ const btn = document.createElement("button");
982
+ btn.type = "button"; btn.innerHTML = "&times;";
983
+ btn.addEventListener("click", () => t.remove());
984
+ t.appendChild(txt); t.appendChild(btn);
985
+ toastWrap.appendChild(t);
986
+ setTimeout(() => t.remove(), 4500);
987
+ }}
988
+
989
+ /* ─────────── status helpers ─────────── */
990
+ function setStatus(pill, active) {{
991
+ pill.textContent = active ? "Active" : "Idle";
992
+ pill.classList.remove("active", "idle");
993
+ pill.classList.add(active ? "active" : "idle");
994
+ }}
995
+
996
+ function setLoading(loading) {{
997
+ loaderOverlay.style.display = loading ? "flex" : "none";
998
+ runBtn.disabled = loading;
999
+ clearBtn.disabled = loading;
1000
+ setStatus(runStatus, loading);
1001
+ setStatus(resultStatus, loading);
1002
+ }}
1003
+
1004
+ /* ─────────── advanced ─────────── */
1005
+ function setAdvanced(open) {{
1006
+ state.advancedOpen = open;
1007
+ advancedBody.classList.toggle("open", open);
1008
+ advancedIcon.textContent = open ? "−" : "+";
1009
+ }}
1010
+ advancedToggle.addEventListener("click", () => setAdvanced(!state.advancedOpen));
1011
+
1012
+ /* ─────────── upload ─────────── */
1013
+ function createThumb(file, index) {{
1014
+ const wrap = document.createElement("div");
1015
+ wrap.className = "thumb";
1016
+ const img = document.createElement("img");
1017
+ img.src = URL.createObjectURL(file); img.alt = file.name;
1018
+ const rm = document.createElement("button");
1019
+ rm.type = "button"; rm.className = "thumb-remove"; rm.innerHTML = "&times;";
1020
+ rm.addEventListener("click", e => {{
1021
+ e.stopPropagation();
1022
+ state.files.splice(index, 1);
1023
+ renderPreviews();
1024
+ }});
1025
+ wrap.appendChild(img); wrap.appendChild(rm);
1026
+ return wrap;
1027
+ }}
1028
+
1029
+ function renderPreviews() {{
1030
+ previewGrid.innerHTML = "";
1031
+ if (!state.files.length) {{
1032
+ uploadPlaceholder.style.display = "flex";
1033
+ previewGrid.style.display = "none";
1034
+ return;
1035
+ }}
1036
+ uploadPlaceholder.style.display = "none";
1037
+ previewGrid.style.display = "grid";
1038
+ state.files.forEach((f, i) => previewGrid.appendChild(createThumb(f, i)));
1039
+ }}
1040
+
1041
+ function addFiles(list) {{
1042
+ const valid = Array.from(list).filter(f => f.type.startsWith("image/"));
1043
+ if (!valid.length) {{ showToast("Please upload valid image files."); return; }}
1044
+ state.files = [...state.files, ...valid];
1045
+ renderPreviews();
1046
+ }}
1047
+
1048
+ uploadPlaceholder.addEventListener("click", () => fileInput.click());
1049
+ uploadZone.addEventListener("click", e => {{ if (e.target === uploadZone) fileInput.click(); }});
1050
+ fileInput.addEventListener("change", e => {{ addFiles(e.target.files); fileInput.value = ""; }});
1051
+ uploadZone.addEventListener("dragover", e => {{ e.preventDefault(); uploadZone.classList.add("dragover"); }});
1052
+ uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
1053
+ uploadZone.addEventListener("drop", e => {{
1054
+ e.preventDefault(); uploadZone.classList.remove("dragover");
1055
+ if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
1056
+ }});
1057
+
1058
+ /* ─────────── comparison slider ─────────── */
1059
+ let sliderPct = 50; // 0–100
1060
+
1061
+ function applySlider(pct) {{
1062
+ sliderPct = Math.max(1, Math.min(99, pct));
1063
+ const p = sliderPct + "%";
1064
+ cmpLeftClip.style.width = p;
1065
+ cmpLeft.style.width = compareWrap.offsetWidth + "px";
1066
+ cmpDivider.style.left = p;
1067
+ cmpHandle.style.left = p;
1068
+ }}
1069
+
1070
+ function pctFromEvent(e) {{
1071
+ const rect = compareWrap.getBoundingClientRect();
1072
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
1073
+ return ((clientX - rect.left) / rect.width) * 100;
1074
+ }}
1075
+
1076
+ let dragging = false;
1077
+
1078
+ cmpHandle.addEventListener("mousedown", () => {{ dragging = true; }});
1079
+ cmpHandle.addEventListener("touchstart", () => {{ dragging = true; }}, {{ passive: true }});
1080
+
1081
+ window.addEventListener("mousemove", e => {{ if (dragging) applySlider(pctFromEvent(e)); }});
1082
+ window.addEventListener("touchmove", e => {{ if (dragging) applySlider(pctFromEvent(e)); }}, {{ passive: true }});
1083
+ window.addEventListener("mouseup", () => {{ dragging = false; }});
1084
+ window.addEventListener("touchend", () => {{ dragging = false; }});
1085
+
1086
+ /* also let clicking anywhere on the wrap jump the slider */
1087
+ compareWrap.addEventListener("click", e => {{
1088
+ if (!state.hasResult) return;
1089
+ if (e.target === cmpHandle) return;
1090
+ applySlider(pctFromEvent(e));
1091
+ }});
1092
+
1093
+ window.addEventListener("resize", () => {{
1094
+ if (state.hasResult) applySlider(sliderPct);
1095
+ }});
1096
+
1097
+ function showCompareResult(stdUrl, smallUrl) {{
1098
+ /* reveal images */
1099
+ cmpRight.src = smallUrl + "?t=" + Date.now();
1100
+ cmpLeft.src = stdUrl + "?t=" + Date.now();
1101
+
1102
+ let loaded = 0;
1103
+ function onLoad() {{
1104
+ loaded++;
1105
+ if (loaded < 2) return;
1106
+ /* show all slider elements */
1107
+ compareEmpty.style.display = "none";
1108
+ cmpRight.style.display = "block";
1109
+ cmpLeft.style.display = "block";
1110
+ cmpDivider.style.display = "block";
1111
+ cmpHandle.style.display = "flex";
1112
+ labelLeft.style.display = "block";
1113
+ labelRight.style.display = "block";
1114
+ state.hasResult = true;
1115
+ sliderPct = 50;
1116
+ applySlider(50);
1117
+ }}
1118
+
1119
+ cmpRight.onload = onLoad;
1120
+ cmpLeft.onload = onLoad;
1121
+ }}
1122
+
1123
+ /* ─────────── clear ─────────── */
1124
+ function clearAll() {{
1125
+ state.files = []; state.hasResult = false;
1126
+ renderPreviews();
1127
+ promptEl.value = "";
1128
+ seedEl.value = "0";
1129
+ stepsEl.value = "4";
1130
+ widthEl.value = "1024";
1131
+ heightEl.value = "1024";
1132
+ guidanceEl.value = "1.0";
1133
+ randomizeSeedEl.checked = true;
1134
+
1135
+ /* reset compare area */
1136
+ cmpRight.style.display = "none"; cmpRight.src = "";
1137
+ cmpLeft.style.display = "none"; cmpLeft.src = "";
1138
+ cmpDivider.style.display = "none";
1139
+ cmpHandle.style.display = "none";
1140
+ labelLeft.style.display = "none";
1141
+ labelRight.style.display = "none";
1142
+ compareEmpty.style.display = "flex";
1143
+
1144
+ dlStd.removeAttribute("href");
1145
+ dlSmall.removeAttribute("href");
1146
+ usedSeed.textContent = "—";
1147
+ deviceValue.textContent = DEVICE_LBL;
1148
+
1149
+ setLoading(false);
1150
+ setAdvanced(false);
1151
+ }}
1152
+ clearBtn.addEventListener("click", clearAll);
1153
+
1154
+ /* ─────────── submit ─────────── */
1155
+ async function submitCompare() {{
1156
+ const prompt = promptEl.value.trim();
1157
+ if (!prompt) {{ showToast("Please enter a prompt."); return; }}
1158
+
1159
+ const fd = new FormData();
1160
+ fd.append("prompt", prompt);
1161
+ fd.append("seed", seedEl.value || "0");
1162
+ fd.append("randomize_seed", String(randomizeSeedEl.checked));
1163
+ fd.append("width", widthEl.value || "1024");
1164
+ fd.append("height", heightEl.value || "1024");
1165
+ fd.append("steps", stepsEl.value || "4");
1166
+ fd.append("guidance_scale", guidanceEl.value || "1.0");
1167
+ state.files.forEach(f => fd.append("images", f));
1168
+
1169
+ setLoading(true);
1170
+
1171
+ try {{
1172
+ const res = await fetch("/api/compare", {{ method: "POST", body: fd }});
1173
+ const data = await res.json();
1174
+ if (!res.ok || !data.success) throw new Error(data.error || "Processing failed.");
1175
+
1176
+ usedSeed.textContent = String(data.seed);
1177
+ deviceValue.textContent = (data.device || DEVICE_LBL).toLowerCase();
1178
+
1179
+ dlStd.href = data.standard.download_url;
1180
+ dlSmall.href = data.small.download_url;
1181
+
1182
+ showCompareResult(data.standard.image_url, data.small.image_url);
1183
+
1184
+ }} catch (err) {{
1185
+ showToast(err.message || "An unexpected error occurred.");
1186
+ }} finally {{
1187
+ setLoading(false);
1188
+ }}
1189
+ }}
1190
+
1191
+ runBtn.addEventListener("click", submitCompare);
1192
+
1193
+ /* ─────────── examples ─────────── */
1194
+ async function fileFromUrl(url, filename="example.jpg") {{
1195
+ const r = await fetch(url);
1196
+ if (!r.ok) throw new Error("Failed to fetch example image.");
1197
+ const blob = await r.blob();
1198
+ return new File([blob], filename, {{ type: blob.type || "image/jpeg" }});
1199
+ }}
1200
+
1201
+ function renderExamples() {{
1202
+ examplesGrid.innerHTML = "";
1203
+ examples.forEach(item => {{
1204
+ const card = document.createElement("div");
1205
+ card.className = "example-card";
1206
+ const img = document.createElement("img");
1207
+ img.src = item.url; img.alt = item.file;
1208
+ const body = document.createElement("div");
1209
+ body.className = "example-body";
1210
+ const p = document.createElement("p");
1211
+ p.textContent = item.prompt;
1212
+ body.appendChild(p); card.appendChild(img); card.appendChild(body);
1213
+ card.addEventListener("click", async () => {{
1214
+ try {{
1215
+ const f = await fileFromUrl(item.url, item.file);
1216
+ state.files = [f]; renderPreviews();
1217
+ promptEl.value = item.prompt;
1218
+ showToast("Example loaded — click Run Comparison.");
1219
+ }} catch (e) {{ showToast(e.message || "Failed to load example."); }}
1220
+ }});
1221
+ examplesGrid.appendChild(card);
1222
+ }});
1223
+ }}
1224
+
1225
+ /* ─────────── init ─────────── */
1226
+ setAdvanced(false);
1227
+ setLoading(false);
1228
+ renderExamples();
1229
+ renderPreviews();
1230
+ </script>
1231
+ </body>
1232
+ </html>"""
1233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1234
 
1235
+ app.launch()