prithivMLmods commited on
Commit
e7e16d5
·
verified ·
1 Parent(s): 7e8b34a

update app

Browse files
Files changed (1) hide show
  1. app.py +1478 -464
app.py CHANGED
@@ -1,6 +1,6 @@
1
  import os
2
- import io
3
  import gc
 
4
  import uuid
5
  import json
6
  import base64
@@ -8,7 +8,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
@@ -18,11 +18,14 @@ from PIL import Image
18
  from gradio import Server
19
  from fastapi import Request, UploadFile, File, Form
20
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
 
21
  from diffusers import Flux2KleinPipeline, AutoencoderKLFlux2
22
 
23
- HF_TOKEN = os.environ.get("HF_TOKEN")
 
24
 
25
- app = Server()
 
26
 
27
  BASE_DIR = Path(__file__).resolve().parent
28
  STATIC_DIR = BASE_DIR / "static"
@@ -32,24 +35,15 @@ EXAMPLES_DIR = BASE_DIR / "examples"
32
  STATIC_DIR.mkdir(exist_ok=True)
33
  OUTPUT_DIR.mkdir(exist_ok=True)
34
 
35
- MAX_SEED = np.iinfo(np.int32).max
36
- MAX_IMAGE_SIZE = 1024
37
-
38
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
39
- dtype = torch.bfloat16
40
-
41
  if torch.cuda.is_available():
42
- print("current device:", torch.cuda.current_device())
43
- print("device name:", torch.cuda.get_device_name(torch.cuda.current_device()))
44
  DEVICE_LABEL = torch.cuda.get_device_name(torch.cuda.current_device()).lower()
45
  else:
46
- DEVICE_LABEL = str(DEVICE).lower()
47
 
48
  print("Loading 4B Distilled model (Standard VAE)...")
49
  pipe_standard = Flux2KleinPipeline.from_pretrained(
50
  "black-forest-labs/FLUX.2-klein-4B",
51
  torch_dtype=dtype,
52
- token=HF_TOKEN,
53
  )
54
  pipe_standard.enable_model_cpu_offload()
55
 
@@ -57,7 +51,6 @@ print("Loading Small Decoder VAE...")
57
  vae_small = AutoencoderKLFlux2.from_pretrained(
58
  "black-forest-labs/FLUX.2-small-decoder",
59
  torch_dtype=dtype,
60
- token=HF_TOKEN,
61
  )
62
 
63
  print("Loading 4B Distilled model (Small Decoder VAE)...")
@@ -65,7 +58,6 @@ pipe_small_decoder = Flux2KleinPipeline.from_pretrained(
65
  "black-forest-labs/FLUX.2-klein-4B",
66
  vae=vae_small,
67
  torch_dtype=dtype,
68
- token=HF_TOKEN,
69
  )
70
  pipe_small_decoder.enable_model_cpu_offload()
71
 
@@ -76,38 +68,40 @@ pipe_lock_small = threading.Lock()
76
  def calc_dimensions(pil_img: Image.Image):
77
  iw, ih = pil_img.size
78
  aspect = iw / ih
79
-
80
  if aspect >= 1:
81
  new_width = 1024
82
  new_height = int(round(1024 / aspect))
83
  else:
84
  new_height = 1024
85
  new_width = int(round(1024 * aspect))
86
-
87
  new_width = max(256, min(1024, round(new_width / 8) * 8))
88
  new_height = max(256, min(1024, round(new_height / 8) * 8))
89
  return new_width, new_height
90
 
91
 
92
  def parse_and_resize_images(input_images, width: int, height: int):
93
- if not input_images:
94
  return None
95
-
96
  raw_list = []
97
- for item in input_images:
98
- try:
99
- if isinstance(item, str):
100
- raw_list.append(Image.open(item).convert("RGB"))
101
- elif isinstance(item, Image.Image):
102
- raw_list.append(item.convert("RGB"))
103
- else:
104
- raw_list.append(Image.open(item).convert("RGB"))
105
- except Exception as e:
106
- print(f"Skipping invalid image: {e}")
107
-
 
 
 
 
 
 
108
  if not raw_list:
109
  return None
110
-
111
  return [img.resize((width, height), Image.LANCZOS) for img in raw_list]
112
 
113
 
@@ -118,6 +112,12 @@ def run_pipeline(pipe, lock, kwargs, seed):
118
  return result
119
 
120
 
 
 
 
 
 
 
121
  def save_image(img: Image.Image, prefix: str = "output") -> str:
122
  filename = f"{prefix}_{uuid.uuid4().hex}.png"
123
  path = OUTPUT_DIR / filename
@@ -128,7 +128,7 @@ def save_image(img: Image.Image, prefix: str = "output") -> str:
128
  @spaces.GPU(duration=120)
129
  def infer(
130
  prompt,
131
- input_images=None,
132
  seed=42,
133
  randomize_seed=False,
134
  width=1024,
@@ -140,27 +140,40 @@ def infer(
140
  if torch.cuda.is_available():
141
  torch.cuda.empty_cache()
142
 
143
- if not prompt or not prompt.strip():
144
  raise ValueError("Please enter a prompt.")
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  if randomize_seed:
147
  seed = random.randint(0, MAX_SEED)
148
 
149
  image_list = None
150
- if input_images and len(input_images) > 0:
151
- first_img = input_images[0]
152
- if isinstance(first_img, str):
153
- first_pil = Image.open(first_img).convert("RGB")
154
- elif isinstance(first_img, Image.Image):
155
- first_pil = first_img.convert("RGB")
156
- else:
157
- first_pil = Image.open(first_img).convert("RGB")
158
-
159
- width, height = calc_dimensions(first_pil)
160
- image_list = parse_and_resize_images(input_images, width, height)
161
- else:
162
- width = max(256, min(MAX_IMAGE_SIZE, round(int(width) / 8) * 8))
163
- height = max(256, min(MAX_IMAGE_SIZE, round(int(height) / 8) * 8))
164
 
165
  shared_kwargs = dict(
166
  prompt=prompt,
@@ -194,21 +207,21 @@ def get_example_items():
194
  "3.jpg": "Relight the image with soft golden sunset lighting while keeping all structures and subject details consistent.",
195
  "4.jpg": "Make the texture high-resolution.",
196
  }
197
-
198
  items = []
199
  if EXAMPLES_DIR.exists():
200
  for name in sorted(os.listdir(EXAMPLES_DIR)):
201
  if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
202
- items.append(
203
- {
204
- "file": name,
205
- "url": f"/example-file/{name}",
206
- "prompt": example_prompts.get(name, "Edit this image while preserving composition."),
207
- }
208
- )
209
  return items
210
 
211
 
 
 
 
212
  @app.get("/example-file/{filename}")
213
  async def example_file(filename: str):
214
  path = EXAMPLES_DIR / filename
@@ -226,14 +239,14 @@ async def download_file(filename: str):
226
 
227
 
228
  @app.post("/api/compare")
229
- async def compare_api(
230
  prompt: str = Form(...),
231
  seed: str = Form("0"),
232
  randomize_seed: str = Form("true"),
233
  width: str = Form("1024"),
234
  height: str = Form("1024"),
235
  steps: str = Form("4"),
236
- guidance: str = Form("1.0"),
237
  images: Optional[List[UploadFile]] = File(None),
238
  ):
239
  temp_paths = []
@@ -250,29 +263,35 @@ async def compare_api(
250
  temp_paths.append(str(temp_path))
251
  image_paths.append(str(temp_path))
252
 
253
- out_std, out_small, used_seed = infer(
254
- images=image_paths if image_paths else None,
255
  prompt=prompt,
256
- seed=int(seed),
257
- randomize_seed=randomize_seed.lower() == "true",
258
- width=int(width),
259
- height=int(height),
260
- num_inference_steps=int(steps),
261
- guidance_scale=float(guidance),
 
262
  )
263
 
264
- std_filename = save_image(out_std, prefix="std")
265
  small_filename = save_image(out_small, prefix="small")
266
 
267
- return JSONResponse(
268
- {
269
- "success": True,
270
- "seed": used_seed,
271
- "std_url": f"/download/{std_filename}",
272
- "small_url": f"/download/{small_filename}",
273
- "device": DEVICE_LABEL,
274
- }
275
- )
 
 
 
 
 
 
276
 
277
  except Exception as e:
278
  return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@@ -290,497 +309,1492 @@ async def homepage(request: Request):
290
  examples = get_example_items()
291
  examples_json = json.dumps(examples)
292
 
293
- return f"""
294
- <!DOCTYPE html>
295
  <html lang="en">
296
  <head>
297
  <meta charset="UTF-8" />
298
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
299
- <title>Flux.2-4B VAE Decoder Comparison</title>
300
  <style>
301
- @import url('https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&display=swap');
302
 
303
  :root {{
304
- --bg: #111111;
305
- --panel: #1E1E1E;
306
- --panel-2: #252525;
307
- --panel-3: #2d2d2d;
308
- --border: #333333;
309
- --text: #ffffff;
310
- --muted: #aaaaaa;
311
- --ubuntu-orange: #E95420;
312
- --ubuntu-orange-hover: #c74316;
313
- --ubuntu-aubergine: #2C001E;
314
- --ubuntu-aubergine-light: #44002e;
315
- --input-bg: #151515;
316
- --same-height: 800px;
317
- }}
318
-
319
- * {{ box-sizing: border-box; font-family: 'Ubuntu', sans-serif; }}
320
-
 
 
 
 
 
 
 
 
 
 
 
321
  html, body {{
322
- margin: 0; padding: 0;
323
- background: var(--bg); color: var(--text);
324
- min-height: 100%; overflow-x: hidden;
 
 
325
  }}
326
 
 
327
  .topbar {{
328
- height: 60px;
329
- background: var(--ubuntu-aubergine);
330
- border-bottom: 2px solid var(--ubuntu-orange);
331
- display: flex; align-items: center; justify-content: center;
332
- color: #fff; font-size: 16px; font-weight: 500; letter-spacing: 0.02em;
 
 
333
  }}
334
 
335
- .container {{ max-width: 1440px; margin: 0 auto; padding: 28px; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
 
337
- .hero {{ margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }}
338
- .title {{ font-size: 38px; line-height: 1.2; font-weight: 700; margin: 0 0 10px 0; }}
339
- .subtitle {{ color: var(--muted); font-size: 16px; font-weight: 300; margin: 0; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
 
341
- .layout {{
342
- display: grid; grid-template-columns: 1fr 1.2fr; gap: 24px; align-items: stretch;
 
 
343
  }}
344
 
 
 
 
 
 
 
 
 
 
345
  .panel {{
346
- background: var(--panel); border: 1px solid var(--border);
347
- min-height: var(--same-height); height: var(--same-height);
348
- display: flex; flex-direction: column; overflow: hidden;
349
- border-radius: 6px;
350
  }}
351
 
352
  .panel-header {{
353
- height: 62px; min-height: 62px; border-bottom: 1px solid var(--border);
354
- display: flex; align-items: center; justify-content: space-between;
355
- padding: 0 20px; background: #1a1a1a;
 
 
 
 
356
  }}
357
 
358
- .panel-title {{ font-size: 20px; font-weight: 500; margin: 0; color: var(--text); }}
359
-
360
- .status-pill {{
361
- padding: 6px 12px; background: var(--panel-3); border: 1px solid var(--border);
362
- color: var(--muted); font-size: 12px; font-weight: 500; border-radius: 20px;
363
- transition: all 0.2s ease;
 
364
  }}
365
- .status-pill.active {{
366
- background: rgba(233, 84, 32, 0.15); border-color: var(--ubuntu-orange); color: var(--ubuntu-orange);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  }}
368
 
369
- .panel-body {{ flex: 1; padding: 20px; overflow: auto; display: flex; flex-direction: column; gap: 20px; }}
 
 
 
 
370
 
371
- .label {{ font-size: 14px; font-weight: 500; color: #ddd; margin-bottom: 8px; display: block; }}
372
- .hint {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
 
373
 
374
  .input, .textarea {{
375
- width: 100%; background: var(--input-bg); border: 1px solid var(--border);
376
- color: var(--text); outline: none; padding: 12px; font-size: 15px; border-radius: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
  }}
378
- .input:focus, .textarea:focus {{ border-color: var(--ubuntu-orange); background: #111; }}
379
- .textarea {{ min-height: 120px; resize: vertical; }}
380
 
381
- .upload-wrap {{
382
- background: var(--input-bg); border: 2px dashed var(--border); border-radius: 6px;
383
- min-height: 200px; display: flex; flex-direction: column; gap: 14px; padding: 14px; cursor: pointer;
384
- transition: border-color 0.2s ease;
385
  }}
386
- .upload-wrap.dragover {{ border-color: var(--ubuntu-orange); background: rgba(233, 84, 32, 0.05); }}
387
- .upload-wrap input[type="file"] {{ display: none; }}
388
-
389
  .upload-placeholder {{
390
- flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
391
- gap: 12px; color: var(--muted); background: transparent; border: none; cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  }}
393
-
394
- .preview-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 10px; }}
395
- .thumb {{ position: relative; aspect-ratio: 1/1; overflow: hidden; border: 1px solid var(--border); border-radius: 4px; }}
396
- .thumb img {{ width: 100%; height: 100%; object-fit: cover; display: block; }}
397
  .thumb-remove {{
398
- position: absolute; top: 4px; right: 4px; width: 24px; height: 24px;
399
- background: rgba(0,0,0,0.7); border: none; color: white; border-radius: 50%;
400
- cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  }}
402
 
403
- .advanced {{ border: 1px solid var(--border); background: var(--input-bg); border-radius: 4px; }}
404
  .advanced-toggle {{
405
- width: 100%; height: 48px; border: none; border-bottom: 1px solid var(--border);
406
- background: transparent; color: var(--text); display: flex; align-items: center; justify-content: space-between;
407
- padding: 0 16px; cursor: pointer; font-size: 14px; font-weight: 500;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  }}
409
- .advanced-body {{ display: none; padding: 16px; }}
410
- .advanced-body.open {{ display: block; }}
411
- .advanced-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
412
 
413
- .actions {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: auto; }}
414
  .btn {{
415
- height: 48px; border: none; background: var(--panel-2); color: var(--text);
416
- cursor: pointer; font-size: 15px; font-weight: 500; border-radius: 4px; transition: background 0.2s;
 
 
 
 
 
 
 
 
 
417
  }}
418
- .btn:hover {{ background: var(--panel-3); }}
419
- .btn-primary {{ background: var(--ubuntu-orange); color: white; }}
420
- .btn-primary:hover {{ background: var(--ubuntu-orange-hover); }}
421
 
422
- /* Result Stage & Slider */
423
- .result-stage {{
424
- position: relative; flex: 1; border: 1px solid var(--border); background: #0a0a0a;
425
- border-radius: 6px; overflow: hidden; display: flex; align-items: center; justify-content: center;
426
  }}
427
-
428
- .slider-container {{
429
- position: relative; width: 100%; height: 100%; display: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  }}
431
- .slider-container img {{
432
- position: absolute; top: 0; left: 0; width: 100%; height: 100%;
433
- object-fit: contain; pointer-events: none;
434
  }}
435
-
436
- .resize-layer {{
437
- position: absolute; top: 0; left: 0; width: 50%; height: 100%; overflow: hidden;
 
 
 
 
 
 
 
 
438
  }}
439
-
440
  .slider-handle {{
441
- position: absolute; top: 0; bottom: 0; left: 50%; width: 2px;
442
- background: var(--ubuntu-orange); cursor: ew-resize; transform: translateX(-50%); z-index: 10;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
443
  }}
444
- .slider-handle::after {{
445
- content: '⟷'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
446
- background: var(--ubuntu-orange); color: white; border-radius: 50%; width: 36px; height: 36px;
447
- display: flex; align-items: center; justify-content: center; font-size: 16px; box-shadow: 0 0 10px rgba(0,0,0,0.5);
 
 
 
 
448
  }}
449
 
450
- .badge {{
451
- position: absolute; bottom: 16px; padding: 6px 12px; background: rgba(0,0,0,0.7);
452
- color: white; font-size: 13px; font-weight: 500; border-radius: 20px; z-index: 5;
453
- border: 1px solid rgba(255,255,255,0.2); backdrop-filter: blur(4px);
454
  }}
455
- .badge-left {{ left: 16px; }}
456
- .badge-right {{ right: 16px; }}
457
 
458
- .result-empty {{
459
- display: flex; flex-direction: column; align-items: center; justify-content: center;
460
- gap: 12px; color: var(--muted); text-align: center;
 
461
  }}
462
- .loader {{
463
- position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
464
- flex-direction: column; gap: 16px; background: rgba(17,17,17,0.7); backdrop-filter: blur(5px); z-index: 20;
 
 
 
 
465
  }}
466
- .spinner {{
467
- width: 50px; height: 50px; border: 4px solid rgba(255,255,255,0.1);
468
- border-top-color: var(--ubuntu-orange); border-radius: 50%; animation: spin 1s linear infinite;
 
469
  }}
470
 
471
- .meta-bar {{ display: flex; gap: 16px; }}
472
- .meta-item {{ flex: 1; padding: 12px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 4px; }}
473
- .meta-label {{ font-size: 12px; color: var(--muted); margin-bottom: 4px; }}
474
- .meta-value {{ font-size: 14px; font-weight: 500; color: var(--text); }}
 
 
 
475
 
476
- .examples-panel {{ margin-top: 24px; background: var(--panel); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }}
477
- .examples-header {{ padding: 16px 20px; font-size: 18px; font-weight: 500; background: #1a1a1a; border-bottom: 1px solid var(--border); }}
478
- .examples-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; padding: 20px; }}
479
- .example-card {{ background: var(--input-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; overflow: hidden; transition: border-color 0.2s; }}
480
- .example-card:hover {{ border-color: var(--ubuntu-orange); }}
481
- .example-card img {{ width: 100%; aspect-ratio: 1; object-fit: cover; border-bottom: 1px solid var(--border); }}
482
- .example-body {{ padding: 12px; font-size: 13px; color: var(--muted); line-height: 1.4; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
 
484
- @keyframes spin {{ to {{ transform: rotate(360deg); }} }}
 
 
 
 
 
 
 
485
 
486
- @media (max-width: 1024px) {{
487
- .layout {{ grid-template-columns: 1fr; }}
488
- .panel {{ height: auto; min-height: 500px; }}
489
- .examples-grid {{ grid-template-columns: repeat(2, 1fr); }}
 
 
 
 
 
 
 
 
 
 
 
 
490
  }}
491
  </style>
492
  </head>
493
  <body>
494
 
495
- <div class="topbar">Flux.2 Klein 4B Comparison Suite</div>
496
 
497
- <div class="container">
498
- <section class="hero">
499
- <h1 class="title">VAE Decoder Comparison</h1>
500
- <p class="subtitle">Compare <strong>FLUX.2-klein-4B Standard VAE</strong> vs <strong>Small Decoder VAE</strong> seamlessly.</p>
501
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
 
503
- <section class="layout">
504
-
505
- <div class="panel">
506
- <div class="panel-header">
507
- <h2 class="panel-title">Inputs</h2>
508
- <span class="status-pill">Ready</span>
509
- </div>
510
- <div class="panel-body">
511
-
512
- <div>
513
- <span class="label">Input Images</span>
514
- <div class="upload-wrap" id="uploadZone">
515
  <input id="fileInput" type="file" accept="image/*" multiple />
516
- <div class="upload-placeholder" id="uploadPlaceholder">
517
- <svg width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
518
- <path d="M12 4v16m-8-8h16"></path>
519
- </svg>
520
- <span>Click or drag images to upload</span>
521
- </div>
522
- <div class="preview-grid" id="previewGrid" style="display:none;"></div>
 
 
 
 
 
 
 
523
  </div>
524
- <div class="hint">Upload one or more images. The first image determines standard dimensions.</div>
525
  </div>
526
 
527
- <div>
528
- <label class="label" for="prompt">Generation Prompt</label>
529
- <textarea id="prompt" class="textarea" placeholder="A black cat holding a sign that says hello world..."></textarea>
 
 
530
  </div>
531
 
532
- <div class="advanced">
533
- <button class="advanced-toggle" id="advancedToggle">
534
- Advanced Configuration <span>+</span>
 
 
535
  </button>
536
- <div class="advanced-body" id="advancedBody">
537
- <div class="advanced-grid">
538
- <div>
539
- <label class="label">Width</label>
540
- <input id="width" class="input" type="number" step="8" value="1024" />
 
 
 
 
541
  </div>
542
- <div>
543
- <label class="label">Height</label>
544
- <input id="height" class="input" type="number" step="8" value="1024" />
545
- </div>
546
- <div>
547
- <label class="label">Inference Steps</label>
548
- <input id="steps" class="input" type="number" value="4" />
549
  </div>
550
- <div>
551
- <label class="label">Guidance Scale</label>
552
- <input id="guidance" class="input" type="number" step="0.1" value="1.0" />
 
 
 
 
553
  </div>
554
- <div>
555
- <label class="label">Seed</label>
556
- <input id="seed" class="input" type="number" value="0" />
 
 
 
 
557
  </div>
558
- <div style="display: flex; align-items: center; gap: 8px; margin-top: 24px;">
559
- <input id="randomizeSeed" type="checkbox" checked />
560
- <label for="randomizeSeed" style="font-size: 14px; color: #ddd;">Randomize Seed</label>
 
 
 
 
561
  </div>
 
 
 
 
 
562
  </div>
563
  </div>
564
  </div>
565
 
 
566
  <div class="actions">
567
- <button class="btn" id="clearBtn">Clear</button>
568
- <button class="btn btn-primary" id="runBtn">Generate Comparison</button>
569
  </div>
570
- </div>
571
- </div>
572
 
573
- <div class="panel">
574
- <div class="panel-header">
575
- <h2 class="panel-title">Output</h2>
576
- <span class="status-pill" id="resultStatus">Idle</span>
577
  </div>
578
- <div class="panel-body" style="padding-bottom: 0;">
579
-
580
- <div class="result-stage" id="resultStage">
581
- <div class="result-empty" id="outputEmpty">
582
- <svg width="48" height="48" fill="none" stroke="#555" stroke-width="1.5" viewBox="0 0 24 24">
583
- <rect x="3" y="3" width="18" height="18" rx="2"></rect>
584
- <circle cx="8.5" cy="8.5" r="1.5"></circle>
585
- <path d="M21 15l-5-5L5 21"></path>
586
- </svg>
587
- <span>Images will appear here</span>
588
- </div>
589
 
590
- <div class="slider-container" id="sliderContainer">
591
- <img id="imgSmall" alt="Small Decoder Output" />
592
- <div class="badge badge-right">Small Decoder</div>
593
-
594
- <div class="resize-layer" id="resizeLayer">
595
- <img id="imgStandard" alt="Standard VAE Output" />
596
- <div class="badge badge-left">Standard VAE</div>
597
- </div>
598
-
599
- <div class="slider-handle" id="sliderHandle"></div>
600
- </div>
601
 
602
- <div class="loader" id="loaderOverlay">
603
- <div class="spinner"></div>
604
- <span style="font-weight: 500;">Processing Pipelines Concurrently...</span>
605
- </div>
 
 
 
 
 
 
 
606
  </div>
607
-
608
- <div class="meta-bar">
609
- <div class="meta-item">
610
- <div class="meta-label">Seed Used</div>
611
- <div class="meta-value" id="usedSeed">-</div>
612
- </div>
613
- <div class="meta-item">
614
- <div class="meta-label">Hardware</div>
615
- <div class="meta-value" id="deviceValue">{DEVICE_LABEL}</div>
616
- </div>
617
  </div>
618
- <br/>
619
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620
  </div>
621
- </section>
622
 
623
- <section class="examples-panel">
624
- <div class="examples-header">Example Prompts & Reference Images</div>
625
- <div class="examples-grid" id="examplesGrid"></div>
626
- </section>
627
- </div>
 
 
 
 
 
 
 
 
 
 
628
 
629
- <script>
630
- const examples = {examples_json};
631
- const state = {{ files: [] }};
632
-
633
- // UI Elements
634
- const uploadZone = document.getElementById("uploadZone");
635
- const fileInput = document.getElementById("fileInput");
636
- const uploadPlaceholder = document.getElementById("uploadPlaceholder");
637
- const previewGrid = document.getElementById("previewGrid");
638
-
639
- const promptEl = document.getElementById("prompt");
640
- const advancedToggle = document.getElementById("advancedToggle");
641
- const advancedBody = document.getElementById("advancedBody");
642
-
643
- const runBtn = document.getElementById("runBtn");
644
- const clearBtn = document.getElementById("clearBtn");
645
-
646
- const resultStatus = document.getElementById("resultStatus");
647
- const outputEmpty = document.getElementById("outputEmpty");
648
- const sliderContainer = document.getElementById("sliderContainer");
649
- const imgStandard = document.getElementById("imgStandard");
650
- const imgSmall = document.getElementById("imgSmall");
651
- const loaderOverlay = document.getElementById("loaderOverlay");
652
-
653
- // Toggle Advanced
654
- advancedToggle.addEventListener("click", () => {{
655
- advancedBody.classList.toggle("open");
656
- advancedToggle.innerHTML = `Advanced Configuration <span>${{advancedBody.classList.contains('open') ? '−' : '+'}}</span>`;
657
- }});
658
 
659
- // Upload Logic
660
- function renderPreviews() {{
661
- previewGrid.innerHTML = "";
662
- if (!state.files.length) {{
663
- uploadPlaceholder.style.display = "flex";
664
- previewGrid.style.display = "none";
665
- return;
666
- }}
667
- uploadPlaceholder.style.display = "none";
668
- previewGrid.style.display = "grid";
669
- state.files.forEach((file, idx) => {{
670
- const div = document.createElement("div"); div.className = "thumb";
671
- const img = document.createElement("img"); img.src = URL.createObjectURL(file);
672
- const btn = document.createElement("button"); btn.className = "thumb-remove"; btn.innerHTML = "×";
673
- btn.onclick = (e) => {{ e.stopPropagation(); state.files.splice(idx, 1); renderPreviews(); }};
674
- div.append(img, btn); previewGrid.appendChild(div);
675
- }});
676
- }}
677
 
678
- uploadZone.addEventListener("click", (e) => {{ if(e.target === uploadZone || e.target.closest('.upload-placeholder')) fileInput.click(); }});
679
- fileInput.addEventListener("change", (e) => {{
680
- const valid = Array.from(e.target.files).filter(f => f.type.startsWith("image/"));
681
- state.files.push(...valid); renderPreviews(); fileInput.value = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
682
  }});
683
- uploadZone.addEventListener("dragover", (e) => {{ e.preventDefault(); uploadZone.classList.add("dragover"); }});
684
- uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
685
- uploadZone.addEventListener("drop", (e) => {{
686
- e.preventDefault(); uploadZone.classList.remove("dragover");
687
- if(e.dataTransfer.files.length) {{
688
- const valid = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith("image/"));
689
- state.files.push(...valid); renderPreviews();
690
- }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
  }});
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
 
693
- // Examples
694
- async function loadExample(url, prompt) {{
695
- try {{
696
- const res = await fetch(url);
697
- const blob = await res.blob();
698
- state.files = [new File([blob], "example.jpg", {{ type: blob.type }})];
699
- renderPreviews();
700
- promptEl.value = prompt;
701
- }} catch(err) {{ alert("Failed to load example."); }}
702
- }}
703
-
704
- const examplesGrid = document.getElementById("examplesGrid");
705
- examples.forEach(ex => {{
706
- const card = document.createElement("div"); card.className = "example-card";
707
- card.innerHTML = `<img src="${{ex.url}}" alt="Example" /><div class="example-body">${{ex.prompt}}</div>`;
708
- card.onclick = () => loadExample(ex.url, ex.prompt);
709
  examplesGrid.appendChild(card);
710
  }});
 
711
 
712
- // Clear
713
- clearBtn.onclick = () => {{
714
- state.files = []; renderPreviews();
715
- promptEl.value = ""; sliderContainer.style.display = "none";
716
- outputEmpty.style.display = "flex"; resultStatus.innerText = "Idle";
717
- resultStatus.classList.remove("active");
718
- }};
719
-
720
- // Slider Logic
721
- const resizeLayer = document.getElementById("resizeLayer");
722
- const sliderHandle = document.getElementById("sliderHandle");
723
- let isDragging = false;
724
-
725
- sliderHandle.addEventListener("mousedown", () => isDragging = true);
726
- document.addEventListener("mouseup", () => isDragging = false);
727
- document.addEventListener("mousemove", (e) => {{
728
- if (!isDragging) return;
729
- const rect = sliderContainer.getBoundingClientRect();
730
- let x = e.clientX - rect.left;
731
- let percent = Math.max(0, Math.min(100, (x / rect.width) * 100));
732
- resizeLayer.style.width = percent + "%";
733
- sliderHandle.style.left = percent + "%";
734
- }});
735
-
736
- // Form Submission
737
- runBtn.onclick = async () => {{
738
- if (!promptEl.value.trim()) return alert("Please enter a prompt.");
739
-
740
- const formData = new FormData();
741
- formData.append("prompt", promptEl.value);
742
- formData.append("width", document.getElementById("width").value);
743
- formData.append("height", document.getElementById("height").value);
744
- formData.append("steps", document.getElementById("steps").value);
745
- formData.append("guidance", document.getElementById("guidance").value);
746
- formData.append("seed", document.getElementById("seed").value);
747
- formData.append("randomize_seed", document.getElementById("randomizeSeed").checked);
748
- state.files.forEach(f => formData.append("images", f));
749
-
750
- loaderOverlay.style.display = "flex";
751
- resultStatus.innerText = "Processing..."; resultStatus.classList.add("active");
752
- runBtn.disabled = true;
753
-
754
- try {{
755
- const res = await fetch("/api/compare", {{ method: "POST", body: formData }});
756
- const data = await res.json();
757
-
758
- if (!data.success) throw new Error(data.error);
759
-
760
- // Reset Slider Position
761
- resizeLayer.style.width = "50%";
762
- sliderHandle.style.left = "50%";
763
-
764
- imgStandard.src = data.std_url + "?t=" + Date.now();
765
- imgSmall.src = data.small_url + "?t=" + Date.now();
766
-
767
- outputEmpty.style.display = "none";
768
- sliderContainer.style.display = "block";
769
- document.getElementById("usedSeed").innerText = data.seed;
770
- resultStatus.innerText = "Completed";
771
-
772
- }} catch (err) {{
773
- alert("Error: " + err.message);
774
- resultStatus.innerText = "Error";
775
- }} finally {{
776
- loaderOverlay.style.display = "none";
777
- runBtn.disabled = false;
778
- setTimeout(() => resultStatus.classList.remove("active"), 2000);
779
- }}
780
- }};
781
- </script>
782
  </body>
783
- </html>
784
- """
785
 
786
  app.launch()
 
1
  import os
 
2
  import gc
3
+ import io
4
  import uuid
5
  import json
6
  import base64
 
8
  import threading
9
  import concurrent.futures
10
  from pathlib import Path
11
+ from typing import List, Optional, Iterable
12
 
13
  import spaces
14
  import numpy as np
 
18
  from gradio import Server
19
  from fastapi import Request, UploadFile, File, Form
20
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
21
+
22
  from diffusers import Flux2KleinPipeline, AutoencoderKLFlux2
23
 
24
+ dtype = torch.bfloat16
25
+ device = "cuda" if torch.cuda.is_available() else "cpu"
26
 
27
+ MAX_SEED = np.iinfo(np.int32).max
28
+ MAX_IMAGE_SIZE = 1024
29
 
30
  BASE_DIR = Path(__file__).resolve().parent
31
  STATIC_DIR = BASE_DIR / "static"
 
35
  STATIC_DIR.mkdir(exist_ok=True)
36
  OUTPUT_DIR.mkdir(exist_ok=True)
37
 
 
 
 
 
 
 
38
  if torch.cuda.is_available():
 
 
39
  DEVICE_LABEL = torch.cuda.get_device_name(torch.cuda.current_device()).lower()
40
  else:
41
+ DEVICE_LABEL = str(device).lower()
42
 
43
  print("Loading 4B Distilled model (Standard VAE)...")
44
  pipe_standard = Flux2KleinPipeline.from_pretrained(
45
  "black-forest-labs/FLUX.2-klein-4B",
46
  torch_dtype=dtype,
 
47
  )
48
  pipe_standard.enable_model_cpu_offload()
49
 
 
51
  vae_small = AutoencoderKLFlux2.from_pretrained(
52
  "black-forest-labs/FLUX.2-small-decoder",
53
  torch_dtype=dtype,
 
54
  )
55
 
56
  print("Loading 4B Distilled model (Small Decoder VAE)...")
 
58
  "black-forest-labs/FLUX.2-klein-4B",
59
  vae=vae_small,
60
  torch_dtype=dtype,
 
61
  )
62
  pipe_small_decoder.enable_model_cpu_offload()
63
 
 
68
  def calc_dimensions(pil_img: Image.Image):
69
  iw, ih = pil_img.size
70
  aspect = iw / ih
 
71
  if aspect >= 1:
72
  new_width = 1024
73
  new_height = int(round(1024 / aspect))
74
  else:
75
  new_height = 1024
76
  new_width = int(round(1024 * aspect))
 
77
  new_width = max(256, min(1024, round(new_width / 8) * 8))
78
  new_height = max(256, min(1024, round(new_height / 8) * 8))
79
  return new_width, new_height
80
 
81
 
82
  def parse_and_resize_images(input_images, width: int, height: int):
83
+ if input_images is None:
84
  return None
 
85
  raw_list = []
86
+ if isinstance(input_images, str):
87
+ if os.path.exists(input_images):
88
+ raw_list = [Image.open(input_images).convert("RGB")]
89
+ elif isinstance(input_images, Image.Image):
90
+ raw_list = [input_images.convert("RGB")]
91
+ elif isinstance(input_images, list):
92
+ for item in input_images:
93
+ try:
94
+ src = item[0] if isinstance(item, tuple) else item
95
+ if isinstance(src, str):
96
+ raw_list.append(Image.open(src).convert("RGB"))
97
+ elif isinstance(src, Image.Image):
98
+ raw_list.append(src.convert("RGB"))
99
+ elif hasattr(src, "name"):
100
+ raw_list.append(Image.open(src.name).convert("RGB"))
101
+ except Exception as e:
102
+ print(f"Skipping invalid image: {e}")
103
  if not raw_list:
104
  return None
 
105
  return [img.resize((width, height), Image.LANCZOS) for img in raw_list]
106
 
107
 
 
112
  return result
113
 
114
 
115
+ def image_to_base64(img: Image.Image) -> str:
116
+ buf = io.BytesIO()
117
+ img.save(buf, format="PNG")
118
+ return base64.b64encode(buf.getvalue()).decode("utf-8")
119
+
120
+
121
  def save_image(img: Image.Image, prefix: str = "output") -> str:
122
  filename = f"{prefix}_{uuid.uuid4().hex}.png"
123
  path = OUTPUT_DIR / filename
 
128
  @spaces.GPU(duration=120)
129
  def infer(
130
  prompt,
131
+ image_paths=None,
132
  seed=42,
133
  randomize_seed=False,
134
  width=1024,
 
140
  if torch.cuda.is_available():
141
  torch.cuda.empty_cache()
142
 
143
+ if not prompt or not str(prompt).strip():
144
  raise ValueError("Please enter a prompt.")
145
 
146
+ if isinstance(seed, str):
147
+ seed = int(seed)
148
+ if isinstance(randomize_seed, str):
149
+ randomize_seed = randomize_seed.lower() == "true"
150
+ if isinstance(width, str):
151
+ width = int(width)
152
+ if isinstance(height, str):
153
+ height = int(height)
154
+ if isinstance(num_inference_steps, str):
155
+ num_inference_steps = int(num_inference_steps)
156
+ if isinstance(guidance_scale, str):
157
+ guidance_scale = float(guidance_scale)
158
+
159
  if randomize_seed:
160
  seed = random.randint(0, MAX_SEED)
161
 
162
  image_list = None
163
+ if image_paths:
164
+ pil_images = []
165
+ for p in image_paths:
166
+ try:
167
+ pil_images.append(Image.open(p).convert("RGB"))
168
+ except Exception as e:
169
+ print(f"Skipping image {p}: {e}")
170
+
171
+ if pil_images:
172
+ width, height = calc_dimensions(pil_images[0])
173
+ image_list = [img.resize((width, height), Image.LANCZOS) for img in pil_images]
174
+
175
+ width = max(256, min(MAX_IMAGE_SIZE, round(int(width) / 8) * 8))
176
+ height = max(256, min(MAX_IMAGE_SIZE, round(int(height) / 8) * 8))
177
 
178
  shared_kwargs = dict(
179
  prompt=prompt,
 
207
  "3.jpg": "Relight the image with soft golden sunset lighting while keeping all structures and subject details consistent.",
208
  "4.jpg": "Make the texture high-resolution.",
209
  }
 
210
  items = []
211
  if EXAMPLES_DIR.exists():
212
  for name in sorted(os.listdir(EXAMPLES_DIR)):
213
  if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
214
+ items.append({
215
+ "file": name,
216
+ "url": f"/example-file/{name}",
217
+ "prompt": example_prompts.get(name, "Edit this image while preserving composition."),
218
+ })
 
 
219
  return items
220
 
221
 
222
+ app = Server()
223
+
224
+
225
  @app.get("/example-file/{filename}")
226
  async def example_file(filename: str):
227
  path = EXAMPLES_DIR / filename
 
239
 
240
 
241
  @app.post("/api/compare")
242
+ async def compare_images(
243
  prompt: str = Form(...),
244
  seed: str = Form("0"),
245
  randomize_seed: str = Form("true"),
246
  width: str = Form("1024"),
247
  height: str = Form("1024"),
248
  steps: str = Form("4"),
249
+ guidance_scale: str = Form("1.0"),
250
  images: Optional[List[UploadFile]] = File(None),
251
  ):
252
  temp_paths = []
 
263
  temp_paths.append(str(temp_path))
264
  image_paths.append(str(temp_path))
265
 
266
+ out_standard, out_small, used_seed = infer(
 
267
  prompt=prompt,
268
+ image_paths=image_paths if image_paths else None,
269
+ seed=seed,
270
+ randomize_seed=randomize_seed,
271
+ width=width,
272
+ height=height,
273
+ num_inference_steps=steps,
274
+ guidance_scale=guidance_scale,
275
  )
276
 
277
+ std_filename = save_image(out_standard, prefix="standard")
278
  small_filename = save_image(out_small, prefix="small")
279
 
280
+ return JSONResponse({
281
+ "success": True,
282
+ "seed": used_seed,
283
+ "standard": {
284
+ "image_url": f"/download/{std_filename}",
285
+ "download_url": f"/download/{std_filename}",
286
+ "image_base64": image_to_base64(out_standard),
287
+ },
288
+ "small": {
289
+ "image_url": f"/download/{small_filename}",
290
+ "download_url": f"/download/{small_filename}",
291
+ "image_base64": image_to_base64(out_small),
292
+ },
293
+ "device": DEVICE_LABEL,
294
+ })
295
 
296
  except Exception as e:
297
  return JSONResponse({"success": False, "error": str(e)}, status_code=500)
 
309
  examples = get_example_items()
310
  examples_json = json.dumps(examples)
311
 
312
+ return f"""<!DOCTYPE html>
 
313
  <html lang="en">
314
  <head>
315
  <meta charset="UTF-8" />
316
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
317
+ <title>FLUX.2 Klein Decoder Comparator</title>
318
  <style>
319
+ @import url('https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&family=Ubuntu+Mono:wght@400;700&display=swap');
320
 
321
  :root {{
322
+ --bg: #2c001e;
323
+ --surface: #380028;
324
+ --surface-2: #44003a;
325
+ --surface-3: #3d0032;
326
+ --border: #6e1a5a;
327
+ --border-soft: #531445;
328
+ --text: #fff0f5;
329
+ --text-dim: #d4a8c7;
330
+ --text-muted: #a06080;
331
+ --orange: #e95420;
332
+ --orange-hover: #c7431a;
333
+ --orange-soft: rgba(233,84,32,0.15);
334
+ --orange-border: rgba(233,84,32,0.4);
335
+ --purple: #77216f;
336
+ --purple-light: #ad7fa8;
337
+ --green: #4caf50;
338
+ --green-soft: rgba(76,175,80,0.15);
339
+ --input-bg: #240018;
340
+ --mono: 'Ubuntu Mono', monospace;
341
+ --sans: 'Ubuntu', sans-serif;
342
+ }}
343
+
344
+ *, *::before, *::after {{
345
+ box-sizing: border-box;
346
+ margin: 0;
347
+ padding: 0;
348
+ }}
349
+
350
  html, body {{
351
+ background: var(--bg);
352
+ color: var(--text);
353
+ font-family: var(--sans);
354
+ min-height: 100%;
355
+ overflow-x: hidden;
356
  }}
357
 
358
+ /* ── topbar ── */
359
  .topbar {{
360
+ height: 52px;
361
+ background: #1a0012;
362
+ border-bottom: 1px solid var(--border);
363
+ display: flex;
364
+ align-items: center;
365
+ padding: 0 28px;
366
+ gap: 14px;
367
  }}
368
 
369
+ .topbar-logo {{
370
+ display: flex;
371
+ align-items: center;
372
+ gap: 10px;
373
+ font-weight: 700;
374
+ font-size: 15px;
375
+ color: var(--text);
376
+ letter-spacing: 0.01em;
377
+ }}
378
+
379
+ .topbar-logo-dot {{
380
+ width: 10px;
381
+ height: 10px;
382
+ border-radius: 50% !important;
383
+ background: var(--orange);
384
+ flex-shrink: 0;
385
+ }}
386
+
387
+ .topbar-sep {{
388
+ flex: 1;
389
+ }}
390
+
391
+ .topbar-badge {{
392
+ font-size: 12px;
393
+ font-family: var(--mono);
394
+ color: var(--text-muted);
395
+ border: 1px solid var(--border-soft);
396
+ padding: 3px 10px;
397
+ border-radius: 2px !important;
398
+ }}
399
+
400
+ /* ── layout ── */
401
+ .container {{
402
+ max-width: 1320px;
403
+ margin: 0 auto;
404
+ padding: 32px 28px;
405
+ }}
406
+
407
+ /* ── hero ── */
408
+ .hero {{
409
+ margin-bottom: 32px;
410
+ padding-bottom: 24px;
411
+ border-bottom: 1px solid var(--border-soft);
412
+ }}
413
+
414
+ .hero-eyebrow {{
415
+ font-family: var(--mono);
416
+ font-size: 12px;
417
+ color: var(--text-muted);
418
+ letter-spacing: 0.08em;
419
+ text-transform: uppercase;
420
+ margin-bottom: 10px;
421
+ }}
422
+
423
+ .hero-title {{
424
+ font-size: 38px;
425
+ font-weight: 700;
426
+ line-height: 1.1;
427
+ letter-spacing: -0.02em;
428
+ margin-bottom: 12px;
429
+ }}
430
+
431
+ .hero-title span {{
432
+ color: var(--orange);
433
+ }}
434
 
435
+ .hero-desc {{
436
+ font-size: 15px;
437
+ color: var(--text-dim);
438
+ line-height: 1.6;
439
+ max-width: 680px;
440
+ }}
441
+
442
+ .hero-tags {{
443
+ display: flex;
444
+ flex-wrap: wrap;
445
+ gap: 8px;
446
+ margin-top: 16px;
447
+ }}
448
+
449
+ .tag {{
450
+ display: inline-flex;
451
+ align-items: center;
452
+ gap: 6px;
453
+ height: 28px;
454
+ padding: 0 10px;
455
+ font-size: 12px;
456
+ font-weight: 500;
457
+ font-family: var(--mono);
458
+ border: 1px solid var(--border-soft);
459
+ color: var(--text-dim);
460
+ background: var(--surface-2);
461
+ }}
462
 
463
+ .tag-orange {{
464
+ color: #ffb399;
465
+ background: var(--orange-soft);
466
+ border-color: var(--orange-border);
467
  }}
468
 
469
+ /* ── main grid ── */
470
+ .main-grid {{
471
+ display: grid;
472
+ grid-template-columns: 400px 1fr;
473
+ gap: 20px;
474
+ align-items: start;
475
+ }}
476
+
477
+ /* ── panel ── */
478
  .panel {{
479
+ background: var(--surface);
480
+ border: 1px solid var(--border);
 
 
481
  }}
482
 
483
  .panel-header {{
484
+ height: 50px;
485
+ border-bottom: 1px solid var(--border);
486
+ display: flex;
487
+ align-items: center;
488
+ justify-content: space-between;
489
+ padding: 0 16px;
490
+ background: #1e0018;
491
  }}
492
 
493
+ .panel-title {{
494
+ font-size: 13px;
495
+ font-weight: 700;
496
+ font-family: var(--mono);
497
+ letter-spacing: 0.06em;
498
+ text-transform: uppercase;
499
+ color: var(--text-dim);
500
  }}
501
+
502
+ .panel-body {{
503
+ padding: 16px;
504
+ }}
505
+
506
+ /* ── form ── */
507
+ .form-stack {{
508
+ display: flex;
509
+ flex-direction: column;
510
+ gap: 14px;
511
+ }}
512
+
513
+ .form-group {{
514
+ display: flex;
515
+ flex-direction: column;
516
+ gap: 6px;
517
+ }}
518
+
519
+ .label {{
520
+ font-size: 11px;
521
+ font-weight: 700;
522
+ font-family: var(--mono);
523
+ letter-spacing: 0.08em;
524
+ text-transform: uppercase;
525
+ color: var(--text-muted);
526
  }}
527
 
528
+ .hint {{
529
+ font-size: 12px;
530
+ color: var(--text-muted);
531
+ line-height: 1.5;
532
+ }}
533
 
534
+ textarea, input, button, select {{
535
+ font-family: var(--sans);
536
+ }}
537
 
538
  .input, .textarea {{
539
+ width: 100%;
540
+ background: var(--input-bg);
541
+ border: 1px solid var(--border-soft);
542
+ color: var(--text);
543
+ outline: none;
544
+ padding: 10px 12px;
545
+ font-size: 14px;
546
+ transition: border-color 0.15s ease;
547
+ }}
548
+
549
+ .input:focus, .textarea:focus {{
550
+ border-color: var(--orange);
551
+ }}
552
+
553
+ .textarea {{
554
+ min-height: 90px;
555
+ resize: vertical;
556
+ line-height: 1.55;
557
+ }}
558
+
559
+ /* ── upload zone ── */
560
+ .upload-zone {{
561
+ background: var(--input-bg);
562
+ border: 1px dashed var(--border);
563
+ min-height: 140px;
564
+ cursor: pointer;
565
+ position: relative;
566
+ transition: border-color 0.15s ease, background 0.15s ease;
567
+ }}
568
+
569
+ .upload-zone.dragover {{
570
+ border-color: var(--orange);
571
+ background: var(--orange-soft);
572
  }}
 
 
573
 
574
+ .upload-zone input[type="file"] {{
575
+ display: none;
 
 
576
  }}
577
+
 
 
578
  .upload-placeholder {{
579
+ min-height: 138px;
580
+ display: flex;
581
+ flex-direction: column;
582
+ align-items: center;
583
+ justify-content: center;
584
+ gap: 10px;
585
+ padding: 20px;
586
+ text-align: center;
587
+ cursor: pointer;
588
+ background: transparent;
589
+ border: none;
590
+ color: var(--text-dim);
591
+ width: 100%;
592
+ }}
593
+
594
+ .upload-icon-wrap {{
595
+ width: 48px;
596
+ height: 48px;
597
+ border: 1px solid var(--border);
598
+ background: var(--surface-2);
599
+ display: flex;
600
+ align-items: center;
601
+ justify-content: center;
602
+ color: var(--orange);
603
+ flex-shrink: 0;
604
+ }}
605
+
606
+ .upload-title {{
607
+ font-size: 13px;
608
+ font-weight: 700;
609
+ color: var(--text);
610
+ }}
611
+
612
+ .upload-sub {{
613
+ font-size: 12px;
614
+ color: var(--text-muted);
615
+ }}
616
+
617
+ /* ── image preview grid ── */
618
+ .preview-strip {{
619
+ display: flex;
620
+ flex-wrap: wrap;
621
+ gap: 8px;
622
+ padding: 10px;
623
+ }}
624
+
625
+ .thumb {{
626
+ position: relative;
627
+ width: 72px;
628
+ height: 72px;
629
+ border: 1px solid var(--border);
630
+ overflow: hidden;
631
+ background: #0f0010;
632
+ flex-shrink: 0;
633
+ }}
634
+
635
+ .thumb img {{
636
+ width: 100%;
637
+ height: 100%;
638
+ object-fit: cover;
639
+ display: block;
640
  }}
641
+
 
 
 
642
  .thumb-remove {{
643
+ position: absolute;
644
+ top: 3px;
645
+ right: 3px;
646
+ width: 20px;
647
+ height: 20px;
648
+ border: 1px solid var(--border);
649
+ background: rgba(26,0,18,0.88);
650
+ color: white;
651
+ cursor: pointer;
652
+ display: flex;
653
+ align-items: center;
654
+ justify-content: center;
655
+ font-size: 13px;
656
+ line-height: 1;
657
+ }}
658
+
659
+ .thumb-add {{
660
+ width: 72px;
661
+ height: 72px;
662
+ border: 1px dashed var(--border);
663
+ background: transparent;
664
+ color: var(--text-muted);
665
+ display: flex;
666
+ align-items: center;
667
+ justify-content: center;
668
+ cursor: pointer;
669
+ font-size: 22px;
670
+ flex-shrink: 0;
671
+ transition: border-color 0.15s ease, color 0.15s ease;
672
+ }}
673
+
674
+ .thumb-add:hover {{
675
+ border-color: var(--orange);
676
+ color: var(--orange);
677
+ }}
678
+
679
+ /* ── advanced ── */
680
+ .advanced-wrap {{
681
+ border: 1px solid var(--border-soft);
682
+ background: #1e0018;
683
  }}
684
 
 
685
  .advanced-toggle {{
686
+ width: 100%;
687
+ height: 42px;
688
+ border: none;
689
+ border-bottom: 1px solid transparent;
690
+ background: transparent;
691
+ color: var(--text-dim);
692
+ display: flex;
693
+ align-items: center;
694
+ justify-content: space-between;
695
+ padding: 0 12px;
696
+ cursor: pointer;
697
+ font-size: 12px;
698
+ font-weight: 700;
699
+ font-family: var(--mono);
700
+ letter-spacing: 0.06em;
701
+ text-transform: uppercase;
702
+ transition: background 0.15s ease;
703
+ }}
704
+
705
+ .advanced-toggle:hover {{
706
+ background: var(--surface-2);
707
+ }}
708
+
709
+ .advanced-toggle.open {{
710
+ border-bottom-color: var(--border-soft);
711
+ }}
712
+
713
+ .advanced-body {{
714
+ display: none;
715
+ padding: 14px;
716
+ }}
717
+
718
+ .advanced-body.open {{
719
+ display: block;
720
+ }}
721
+
722
+ .adv-grid {{
723
+ display: grid;
724
+ grid-template-columns: 1fr 1fr;
725
+ gap: 12px;
726
+ }}
727
+
728
+ .range-wrap {{
729
+ display: flex;
730
+ flex-direction: column;
731
+ gap: 4px;
732
+ }}
733
+
734
+ .range-row {{
735
+ display: flex;
736
+ align-items: center;
737
+ gap: 10px;
738
+ }}
739
+
740
+ .range-row input[type="range"] {{
741
+ flex: 1;
742
+ accent-color: var(--orange);
743
+ cursor: pointer;
744
+ }}
745
+
746
+ .range-val {{
747
+ font-family: var(--mono);
748
+ font-size: 12px;
749
+ color: var(--text-dim);
750
+ min-width: 42px;
751
+ text-align: right;
752
+ }}
753
+
754
+ .checkbox-row {{
755
+ display: flex;
756
+ align-items: center;
757
+ gap: 8px;
758
+ font-size: 13px;
759
+ color: var(--text-dim);
760
+ margin-top: 10px;
761
+ }}
762
+
763
+ .checkbox-row input[type="checkbox"] {{
764
+ accent-color: var(--orange);
765
+ width: 14px;
766
+ height: 14px;
767
+ }}
768
+
769
+ /* ── actions ── */
770
+ .actions {{
771
+ display: grid;
772
+ grid-template-columns: 1fr 1fr;
773
+ gap: 10px;
774
+ margin-top: 4px;
775
  }}
 
 
 
776
 
 
777
  .btn {{
778
+ height: 44px;
779
+ border: 1px solid var(--border);
780
+ background: var(--surface-2);
781
+ color: var(--text);
782
+ cursor: pointer;
783
+ font-size: 13px;
784
+ font-weight: 700;
785
+ font-family: var(--mono);
786
+ letter-spacing: 0.04em;
787
+ text-transform: uppercase;
788
+ transition: background 0.15s ease, border-color 0.15s ease;
789
  }}
 
 
 
790
 
791
+ .btn:hover {{
792
+ background: var(--surface-3);
793
+ border-color: var(--purple-light);
 
794
  }}
795
+
796
+ .btn:disabled {{
797
+ opacity: 0.5;
798
+ cursor: not-allowed;
799
+ }}
800
+
801
+ .btn-primary {{
802
+ background: var(--orange);
803
+ border-color: var(--orange);
804
+ color: white;
805
+ }}
806
+
807
+ .btn-primary:hover:not(:disabled) {{
808
+ background: var(--orange-hover);
809
+ border-color: var(--orange-hover);
810
+ }}
811
+
812
+ /* ── result panel ── */
813
+ .result-panel {{
814
+ display: flex;
815
+ flex-direction: column;
816
+ gap: 0;
817
+ }}
818
+
819
+ /* ── image slider ── */
820
+ .slider-wrap {{
821
+ position: relative;
822
+ width: 100%;
823
+ aspect-ratio: 4/3;
824
+ background: #0f0010;
825
+ overflow: hidden;
826
+ user-select: none;
827
+ min-height: 400px;
828
+ }}
829
+
830
+ .slider-wrap.has-images {{
831
+ aspect-ratio: unset;
832
+ }}
833
+
834
+ .slider-img {{
835
+ position: absolute;
836
+ inset: 0;
837
+ width: 100%;
838
+ height: 100%;
839
+ object-fit: contain;
840
+ }}
841
+
842
+ .slider-img-left {{
843
+ z-index: 1;
844
+ clip-path: inset(0 50% 0 0);
845
  }}
846
+
847
+ .slider-img-right {{
848
+ z-index: 1;
849
  }}
850
+
851
+ .slider-divider {{
852
+ position: absolute;
853
+ top: 0;
854
+ bottom: 0;
855
+ left: 50%;
856
+ width: 2px;
857
+ background: white;
858
+ z-index: 3;
859
+ transform: translateX(-50%);
860
+ pointer-events: none;
861
  }}
862
+
863
  .slider-handle {{
864
+ position: absolute;
865
+ top: 50%;
866
+ left: 50%;
867
+ transform: translate(-50%, -50%);
868
+ width: 40px;
869
+ height: 40px;
870
+ background: white;
871
+ border-radius: 50% !important;
872
+ z-index: 4;
873
+ cursor: ew-resize;
874
+ display: flex;
875
+ align-items: center;
876
+ justify-content: center;
877
+ color: #1a0012;
878
+ box-shadow: 0 2px 10px rgba(0,0,0,0.4);
879
+ }}
880
+
881
+ .slider-label-left,
882
+ .slider-label-right {{
883
+ position: absolute;
884
+ top: 12px;
885
+ z-index: 5;
886
+ font-family: var(--mono);
887
+ font-size: 11px;
888
+ font-weight: 700;
889
+ letter-spacing: 0.06em;
890
+ text-transform: uppercase;
891
+ padding: 4px 10px;
892
+ pointer-events: none;
893
+ }}
894
+
895
+ .slider-label-left {{
896
+ left: 12px;
897
+ background: var(--orange);
898
+ color: white;
899
+ }}
900
+
901
+ .slider-label-right {{
902
+ right: 12px;
903
+ background: rgba(0,0,0,0.65);
904
+ color: var(--text);
905
+ border: 1px solid var(--border);
906
+ }}
907
+
908
+ .slider-empty {{
909
+ display: flex;
910
+ flex-direction: column;
911
+ align-items: center;
912
+ justify-content: center;
913
+ gap: 14px;
914
+ color: var(--text-muted);
915
+ text-align: center;
916
+ height: 100%;
917
+ min-height: 400px;
918
+ }}
919
+
920
+ .empty-icon {{
921
+ width: 64px;
922
+ height: 64px;
923
+ border: 1px solid var(--border-soft);
924
+ background: var(--surface-2);
925
+ display: flex;
926
+ align-items: center;
927
+ justify-content: center;
928
+ color: var(--purple-light);
929
+ }}
930
+
931
+ /* ── loader ── */
932
+ .loader-overlay {{
933
+ position: absolute;
934
+ inset: 0;
935
+ display: none;
936
+ flex-direction: column;
937
+ align-items: center;
938
+ justify-content: center;
939
+ gap: 16px;
940
+ background: rgba(26,0,18,0.7);
941
+ backdrop-filter: blur(8px);
942
+ -webkit-backdrop-filter: blur(8px);
943
+ z-index: 10;
944
+ }}
945
+
946
+ .spin-ring {{
947
+ width: 52px;
948
+ height: 52px;
949
+ border-radius: 50% !important;
950
+ border: 3px solid rgba(255,255,255,0.1);
951
+ border-top-color: var(--orange);
952
+ border-right-color: #ff8c66;
953
+ animation: spin 0.85s linear infinite;
954
+ }}
955
+
956
+ .loader-text {{
957
+ font-family: var(--mono);
958
+ font-size: 13px;
959
+ font-weight: 700;
960
+ color: var(--text);
961
+ letter-spacing: 0.04em;
962
+ }}
963
+
964
+ @keyframes spin {{
965
+ from {{ transform: rotate(0deg); }}
966
+ to {{ transform: rotate(360deg); }}
967
+ }}
968
+
969
+ /* ── meta bar ── */
970
+ .meta-bar {{
971
+ border-top: 1px solid var(--border);
972
+ display: flex;
973
+ gap: 0;
974
+ }}
975
+
976
+ .meta-item {{
977
+ flex: 1;
978
+ padding: 10px 14px;
979
+ border-right: 1px solid var(--border);
980
+ }}
981
+
982
+ .meta-item:last-child {{
983
+ border-right: none;
984
+ }}
985
+
986
+ .meta-label {{
987
+ font-family: var(--mono);
988
+ font-size: 10px;
989
+ font-weight: 700;
990
+ letter-spacing: 0.08em;
991
+ text-transform: uppercase;
992
+ color: var(--text-muted);
993
+ margin-bottom: 3px;
994
+ }}
995
+
996
+ .meta-value {{
997
+ font-family: var(--mono);
998
+ font-size: 13px;
999
+ font-weight: 700;
1000
+ color: var(--text);
1001
+ }}
1002
+
1003
+ /* ── download bar ── */
1004
+ .download-bar {{
1005
+ border-top: 1px solid var(--border);
1006
+ display: grid;
1007
+ grid-template-columns: 1fr 1fr;
1008
+ gap: 0;
1009
+ }}
1010
+
1011
+ .dl-btn {{
1012
+ display: flex;
1013
+ align-items: center;
1014
+ justify-content: center;
1015
+ gap: 8px;
1016
+ height: 44px;
1017
+ border: none;
1018
+ border-right: 1px solid var(--border);
1019
+ background: transparent;
1020
+ color: var(--text-dim);
1021
+ font-family: var(--mono);
1022
+ font-size: 12px;
1023
+ font-weight: 700;
1024
+ letter-spacing: 0.04em;
1025
+ text-transform: uppercase;
1026
+ cursor: pointer;
1027
+ text-decoration: none;
1028
+ transition: background 0.15s ease, color 0.15s ease;
1029
+ }}
1030
+
1031
+ .dl-btn:last-child {{
1032
+ border-right: none;
1033
+ }}
1034
+
1035
+ .dl-btn:hover {{
1036
+ background: var(--surface-2);
1037
+ color: var(--text);
1038
+ }}
1039
+
1040
+ .dl-btn:not([href]) {{
1041
+ opacity: 0.35;
1042
+ pointer-events: none;
1043
+ }}
1044
+
1045
+ /* ── status pill ── */
1046
+ .status-pill {{
1047
+ font-family: var(--mono);
1048
+ font-size: 11px;
1049
+ font-weight: 700;
1050
+ letter-spacing: 0.04em;
1051
+ padding: 3px 8px;
1052
+ border: 1px solid var(--border-soft);
1053
+ color: var(--text-muted);
1054
+ background: var(--surface-3);
1055
+ transition: all 0.2s ease;
1056
+ }}
1057
+
1058
+ .status-pill.running {{
1059
+ background: var(--orange-soft);
1060
+ border-color: var(--orange-border);
1061
+ color: #ffb399;
1062
+ }}
1063
+
1064
+ /* ── examples ── */
1065
+ .examples-section {{
1066
+ margin-top: 24px;
1067
+ background: var(--surface);
1068
+ border: 1px solid var(--border);
1069
+ }}
1070
+
1071
+ .examples-header {{
1072
+ height: 50px;
1073
+ border-bottom: 1px solid var(--border);
1074
+ display: flex;
1075
+ align-items: center;
1076
+ padding: 0 16px;
1077
+ background: #1e0018;
1078
  }}
1079
+
1080
+ .examples-title {{
1081
+ font-family: var(--mono);
1082
+ font-size: 12px;
1083
+ font-weight: 700;
1084
+ letter-spacing: 0.08em;
1085
+ text-transform: uppercase;
1086
+ color: var(--text-muted);
1087
  }}
1088
 
1089
+ .examples-body {{
1090
+ padding: 16px;
 
 
1091
  }}
 
 
1092
 
1093
+ .examples-grid {{
1094
+ display: grid;
1095
+ grid-template-columns: repeat(4, minmax(0, 1fr));
1096
+ gap: 12px;
1097
  }}
1098
+
1099
+ .example-card {{
1100
+ background: var(--input-bg);
1101
+ border: 1px solid var(--border-soft);
1102
+ cursor: pointer;
1103
+ overflow: hidden;
1104
+ transition: border-color 0.15s ease, background 0.15s ease;
1105
  }}
1106
+
1107
+ .example-card:hover {{
1108
+ border-color: var(--orange);
1109
+ background: var(--surface-2);
1110
  }}
1111
 
1112
+ .example-card img {{
1113
+ width: 100%;
1114
+ aspect-ratio: 1/1;
1115
+ object-fit: cover;
1116
+ display: block;
1117
+ border-bottom: 1px solid var(--border-soft);
1118
+ }}
1119
 
1120
+ .example-card-body {{
1121
+ padding: 10px;
1122
+ }}
1123
+
1124
+ .example-card-body p {{
1125
+ font-size: 12px;
1126
+ color: var(--text-dim);
1127
+ line-height: 1.5;
1128
+ margin: 0;
1129
+ }}
1130
+
1131
+ /* ── toast ── */
1132
+ .toast-wrap {{
1133
+ position: fixed;
1134
+ top: 16px;
1135
+ right: 16px;
1136
+ z-index: 9999;
1137
+ display: flex;
1138
+ flex-direction: column;
1139
+ gap: 8px;
1140
+ }}
1141
+
1142
+ .toast {{
1143
+ min-width: 240px;
1144
+ max-width: 340px;
1145
+ background: #1a0012;
1146
+ border: 1px solid var(--border);
1147
+ color: var(--text);
1148
+ padding: 10px 12px;
1149
+ display: flex;
1150
+ align-items: flex-start;
1151
+ justify-content: space-between;
1152
+ gap: 10px;
1153
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
1154
+ font-size: 13px;
1155
+ }}
1156
+
1157
+ .toast button {{
1158
+ border: none;
1159
+ background: transparent;
1160
+ color: var(--text-muted);
1161
+ font-size: 16px;
1162
+ cursor: pointer;
1163
+ padding: 0;
1164
+ line-height: 1;
1165
+ flex-shrink: 0;
1166
+ }}
1167
+
1168
+ .toast button:hover {{
1169
+ color: var(--text);
1170
+ }}
1171
+
1172
+ /* ── responsive ── */
1173
+ @media (max-width: 1100px) {{
1174
+ .main-grid {{
1175
+ grid-template-columns: 360px 1fr;
1176
+ }}
1177
+ }}
1178
 
1179
+ @media (max-width: 860px) {{
1180
+ .main-grid {{
1181
+ grid-template-columns: 1fr;
1182
+ }}
1183
+ .examples-grid {{
1184
+ grid-template-columns: repeat(2, 1fr);
1185
+ }}
1186
+ }}
1187
 
1188
+ @media (max-width: 520px) {{
1189
+ .container {{
1190
+ padding: 16px;
1191
+ }}
1192
+ .hero-title {{
1193
+ font-size: 28px;
1194
+ }}
1195
+ .examples-grid {{
1196
+ grid-template-columns: 1fr;
1197
+ }}
1198
+ .adv-grid {{
1199
+ grid-template-columns: 1fr;
1200
+ }}
1201
+ .actions {{
1202
+ grid-template-columns: 1fr;
1203
+ }}
1204
  }}
1205
  </style>
1206
  </head>
1207
  <body>
1208
 
1209
+ <div class="toast-wrap" id="toastWrap"></div>
1210
 
1211
+ <!-- topbar -->
1212
+ <div class="topbar">
1213
+ <div class="topbar-logo">
1214
+ <div class="topbar-logo-dot"></div>
1215
+ FLUX.2 Klein — Decoder Comparator
1216
+ </div>
1217
+ <div class="topbar-sep"></div>
1218
+ <div class="topbar-badge">FLUX.2-klein-4B</div>
1219
+ </div>
1220
+
1221
+ <div class="container">
1222
+
1223
+ <!-- hero -->
1224
+ <section class="hero">
1225
+ <div class="hero-eyebrow">black-forest-labs / flux.2-klein-4B / vae-comparison</div>
1226
+ <h1 class="hero-title">VAE <span>Decoder</span> Comparator</h1>
1227
+ <p class="hero-desc">
1228
+ Compare <strong>FLUX.2-klein-4B</strong> with Standard VAE versus the
1229
+ <strong>Small Decoder</strong> side-by-side using an interactive image slider.
1230
+ Both pipelines run simultaneously on the same seed.
1231
+ </p>
1232
+ <div class="hero-tags">
1233
+ <div class="tag tag-orange">4-step inference</div>
1234
+ <div class="tag">image-to-image</div>
1235
+ <div class="tag">parallel pipelines</div>
1236
+ <div class="tag">vae comparison</div>
1237
+ </div>
1238
+ </section>
1239
+
1240
+ <!-- main grid -->
1241
+ <div class="main-grid">
1242
+
1243
+ <!-- ── left: input panel ── -->
1244
+ <div class="panel">
1245
+ <div class="panel-header">
1246
+ <span class="panel-title">Input</span>
1247
+ <span class="status-pill" id="statusPill">Idle</span>
1248
+ </div>
1249
+ <div class="panel-body">
1250
+ <div class="form-stack">
1251
 
1252
+ <!-- upload -->
1253
+ <div class="form-group">
1254
+ <div class="label">Images (optional)</div>
1255
+ <div class="upload-zone" id="uploadZone">
 
 
 
 
 
 
 
 
1256
  <input id="fileInput" type="file" accept="image/*" multiple />
1257
+ <button class="upload-placeholder" id="uploadPlaceholder" type="button">
1258
+ <div class="upload-icon-wrap">
1259
+ <svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8">
1260
+ <path d="M12 4v10"/>
1261
+ <path d="M8.5 7.5 12 4l3.5 3.5"/>
1262
+ <rect x="3" y="15" width="18" height="5" rx="0"/>
1263
+ </svg>
1264
+ </div>
1265
+ <div>
1266
+ <div class="upload-title">Drop images here or click to browse</div>
1267
+ <div class="upload-sub">PNG, JPG, WEBP · multiple files supported</div>
1268
+ </div>
1269
+ </button>
1270
+ <div class="preview-strip" id="previewStrip" style="display:none;"></div>
1271
  </div>
1272
+ <div class="hint">First image aspect-ratio auto-sets width &amp; height.</div>
1273
  </div>
1274
 
1275
+ <!-- prompt -->
1276
+ <div class="form-group">
1277
+ <label class="label" for="prompt">Prompt</label>
1278
+ <textarea id="prompt" class="textarea" rows="3"
1279
+ placeholder="e.g. Change the weather to stormy..."></textarea>
1280
  </div>
1281
 
1282
+ <!-- advanced -->
1283
+ <div class="advanced-wrap">
1284
+ <button class="advanced-toggle" id="advToggle" type="button">
1285
+ <span>Advanced Settings</span>
1286
+ <span id="advIcon">+</span>
1287
  </button>
1288
+ <div class="advanced-body" id="advBody">
1289
+ <div class="adv-grid">
1290
+
1291
+ <div class="range-wrap form-group">
1292
+ <div class="label">Width</div>
1293
+ <div class="range-row">
1294
+ <input id="width" type="range" min="256" max="{MAX_IMAGE_SIZE}" step="8" value="1024" />
1295
+ <span class="range-val" id="widthVal">1024</span>
1296
+ </div>
1297
  </div>
1298
+
1299
+ <div class="range-wrap form-group">
1300
+ <div class="label">Height</div>
1301
+ <div class="range-row">
1302
+ <input id="height" type="range" min="256" max="{MAX_IMAGE_SIZE}" step="8" value="1024" />
1303
+ <span class="range-val" id="heightVal">1024</span>
1304
+ </div>
1305
  </div>
1306
+
1307
+ <div class="range-wrap form-group">
1308
+ <div class="label">Steps</div>
1309
+ <div class="range-row">
1310
+ <input id="steps" type="range" min="1" max="20" step="1" value="4" />
1311
+ <span class="range-val" id="stepsVal">4</span>
1312
+ </div>
1313
  </div>
1314
+
1315
+ <div class="range-wrap form-group">
1316
+ <div class="label">Guidance</div>
1317
+ <div class="range-row">
1318
+ <input id="guidance" type="range" min="0" max="10" step="0.1" value="1.0" />
1319
+ <span class="range-val" id="guidanceVal">1.0</span>
1320
+ </div>
1321
  </div>
1322
+
1323
+ <div class="range-wrap form-group" style="grid-column:1/-1;">
1324
+ <div class="label">Seed</div>
1325
+ <div class="range-row">
1326
+ <input id="seed" type="range" min="0" max="{MAX_SEED}" step="1" value="0" />
1327
+ <span class="range-val" id="seedVal">0</span>
1328
+ </div>
1329
  </div>
1330
+
1331
+ </div>
1332
+ <div class="checkbox-row">
1333
+ <input id="randomizeSeed" type="checkbox" checked />
1334
+ <label for="randomizeSeed">Randomize seed on each run</label>
1335
  </div>
1336
  </div>
1337
  </div>
1338
 
1339
+ <!-- actions -->
1340
  <div class="actions">
1341
+ <button class="btn btn-primary" id="runBtn" type="button">Compare</button>
1342
+ <button class="btn" id="clearBtn" type="button">Clear</button>
1343
  </div>
 
 
1344
 
 
 
 
 
1345
  </div>
1346
+ </div>
1347
+ </div>
 
 
 
 
 
 
 
 
 
1348
 
1349
+ <!-- ── right: result panel ── -->
1350
+ <div class="panel result-panel">
1351
+ <div class="panel-header">
1352
+ <span class="panel-title">Result — Image Slider</span>
1353
+ <span class="status-pill" id="resultStatus">Waiting</span>
1354
+ </div>
 
 
 
 
 
1355
 
1356
+ <!-- slider -->
1357
+ <div class="slider-wrap" id="sliderWrap">
1358
+
1359
+ <!-- empty state -->
1360
+ <div class="slider-empty" id="sliderEmpty">
1361
+ <div class="empty-icon">
1362
+ <svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.6">
1363
+ <rect x="3" y="5" width="18" height="14"/>
1364
+ <path d="M8 12l2.5-2.5L13 12"/>
1365
+ <path d="M13 12l2-2 2 2"/>
1366
+ </svg>
1367
  </div>
1368
+ <div>
1369
+ <div style="font-size:15px; font-weight:700; color:var(--text); margin-bottom:4px;">No output yet</div>
1370
+ <div style="font-size:13px; color:var(--text-muted);">Run a comparison to see results here</div>
 
 
 
 
 
 
 
1371
  </div>
 
1372
  </div>
1373
+
1374
+ <!-- right image (small decoder) -->
1375
+ <img id="imgRight" class="slider-img slider-img-right" alt="Small Decoder" style="display:none;" />
1376
+ <!-- left image (standard) -->
1377
+ <img id="imgLeft" class="slider-img slider-img-left" alt="Standard Decoder" style="display:none;" />
1378
+
1379
+ <!-- labels -->
1380
+ <div class="slider-label-left" id="lblLeft" style="display:none;">Standard</div>
1381
+ <div class="slider-label-right" id="lblRight" style="display:none;">Small Decoder</div>
1382
+
1383
+ <!-- divider + handle -->
1384
+ <div class="slider-divider" id="sliderDivider" style="display:none;"></div>
1385
+ <div class="slider-handle" id="sliderHandle" style="display:none;">
1386
+ <svg viewBox="0 0 20 20" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
1387
+ <path d="M7 5l-4 5 4 5"/>
1388
+ <path d="M13 5l4 5-4 5"/>
1389
+ </svg>
1390
+ </div>
1391
+
1392
+ <!-- loader -->
1393
+ <div class="loader-overlay" id="loaderOverlay">
1394
+ <div class="spin-ring"></div>
1395
+ <div class="loader-text">Running both pipelines…</div>
1396
+ </div>
1397
+
1398
  </div>
 
1399
 
1400
+ <!-- meta bar -->
1401
+ <div class="meta-bar">
1402
+ <div class="meta-item">
1403
+ <div class="meta-label">Seed Used</div>
1404
+ <div class="meta-value" id="metaSeed">—</div>
1405
+ </div>
1406
+ <div class="meta-item">
1407
+ <div class="meta-label">Device</div>
1408
+ <div class="meta-value" id="metaDevice">{DEVICE_LABEL}</div>
1409
+ </div>
1410
+ <div class="meta-item">
1411
+ <div class="meta-label">Resolution</div>
1412
+ <div class="meta-value" id="metaRes">—</div>
1413
+ </div>
1414
+ </div>
1415
 
1416
+ <!-- download bar -->
1417
+ <div class="download-bar">
1418
+ <a class="dl-btn" id="dlStandard">
1419
+ <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
1420
+ <path d="M10 3v10"/><path d="M6 9l4 4 4-4"/><path d="M3 17h14"/>
1421
+ </svg>
1422
+ Standard VAE
1423
+ </a>
1424
+ <a class="dl-btn" id="dlSmall">
1425
+ <svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
1426
+ <path d="M10 3v10"/><path d="M6 9l4 4 4-4"/><path d="M3 17h14"/>
1427
+ </svg>
1428
+ Small Decoder
1429
+ </a>
1430
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1431
 
1432
+ </div>
1433
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1434
 
1435
+ <!-- examples -->
1436
+ <section class="examples-section">
1437
+ <div class="examples-header">
1438
+ <span class="examples-title">Examples</span>
1439
+ </div>
1440
+ <div class="examples-body">
1441
+ <div class="examples-grid" id="examplesGrid"></div>
1442
+ </div>
1443
+ </section>
1444
+
1445
+ </div><!-- /container -->
1446
+
1447
+ <script>
1448
+ const examples = {examples_json};
1449
+
1450
+ /* ── state ── */
1451
+ const state = {{
1452
+ files: [],
1453
+ advOpen: false,
1454
+ sliderPct: 50,
1455
+ dragging: false,
1456
+ hasResult: false,
1457
+ }};
1458
+
1459
+ /* ── element refs ── */
1460
+ const uploadZone = document.getElementById("uploadZone");
1461
+ const fileInput = document.getElementById("fileInput");
1462
+ const uploadPlaceholder = document.getElementById("uploadPlaceholder");
1463
+ const previewStrip = document.getElementById("previewStrip");
1464
+
1465
+ const promptEl = document.getElementById("prompt");
1466
+ const seedEl = document.getElementById("seed");
1467
+ const seedValEl = document.getElementById("seedVal");
1468
+ const widthEl = document.getElementById("width");
1469
+ const widthValEl = document.getElementById("widthVal");
1470
+ const heightEl = document.getElementById("height");
1471
+ const heightValEl = document.getElementById("heightVal");
1472
+ const stepsEl = document.getElementById("steps");
1473
+ const stepsValEl = document.getElementById("stepsVal");
1474
+ const guidanceEl = document.getElementById("guidance");
1475
+ const guidanceValEl = document.getElementById("guidanceVal");
1476
+ const randomizeSeedEl = document.getElementById("randomizeSeed");
1477
+
1478
+ const advToggle = document.getElementById("advToggle");
1479
+ const advBody = document.getElementById("advBody");
1480
+ const advIcon = document.getElementById("advIcon");
1481
+
1482
+ const runBtn = document.getElementById("runBtn");
1483
+ const clearBtn = document.getElementById("clearBtn");
1484
+
1485
+ const statusPill = document.getElementById("statusPill");
1486
+ const resultStatus = document.getElementById("resultStatus");
1487
+
1488
+ const sliderWrap = document.getElementById("sliderWrap");
1489
+ const sliderEmpty = document.getElementById("sliderEmpty");
1490
+ const imgLeft = document.getElementById("imgLeft");
1491
+ const imgRight = document.getElementById("imgRight");
1492
+ const lblLeft = document.getElementById("lblLeft");
1493
+ const lblRight = document.getElementById("lblRight");
1494
+ const sliderDivider = document.getElementById("sliderDivider");
1495
+ const sliderHandle = document.getElementById("sliderHandle");
1496
+ const loaderOverlay = document.getElementById("loaderOverlay");
1497
+
1498
+ const metaSeed = document.getElementById("metaSeed");
1499
+ const metaDevice = document.getElementById("metaDevice");
1500
+ const metaRes = document.getElementById("metaRes");
1501
+ const dlStandard = document.getElementById("dlStandard");
1502
+ const dlSmall = document.getElementById("dlSmall");
1503
+
1504
+ const examplesGrid = document.getElementById("examplesGrid");
1505
+ const toastWrap = document.getElementById("toastWrap");
1506
+
1507
+ /* ── toast ── */
1508
+ function showToast(msg) {{
1509
+ const t = document.createElement("div");
1510
+ t.className = "toast";
1511
+ const txt = document.createElement("div");
1512
+ txt.textContent = msg;
1513
+ const btn = document.createElement("button");
1514
+ btn.type = "button";
1515
+ btn.innerHTML = "&times;";
1516
+ btn.onclick = () => t.remove();
1517
+ t.appendChild(txt);
1518
+ t.appendChild(btn);
1519
+ toastWrap.appendChild(t);
1520
+ setTimeout(() => t.remove(), 4500);
1521
+ }}
1522
+
1523
+ /* ── range bindings ── */
1524
+ function bindRange(input, display, isFloat) {{
1525
+ input.addEventListener("input", () => {{
1526
+ display.textContent = isFloat
1527
+ ? parseFloat(input.value).toFixed(1)
1528
+ : input.value;
1529
  }});
1530
+ }}
1531
+ bindRange(widthEl, widthValEl, false);
1532
+ bindRange(heightEl, heightValEl, false);
1533
+ bindRange(stepsEl, stepsValEl, false);
1534
+ bindRange(guidanceEl, guidanceValEl, true);
1535
+ bindRange(seedEl, seedValEl, false);
1536
+
1537
+ /* ── advanced panel ── */
1538
+ advToggle.addEventListener("click", () => {{
1539
+ state.advOpen = !state.advOpen;
1540
+ advBody.classList.toggle("open", state.advOpen);
1541
+ advToggle.classList.toggle("open", state.advOpen);
1542
+ advIcon.textContent = state.advOpen ? "−" : "+";
1543
+ }});
1544
+
1545
+ /* ── upload ── */
1546
+ function createThumb(file, index) {{
1547
+ const wrap = document.createElement("div");
1548
+ wrap.className = "thumb";
1549
+ const img = document.createElement("img");
1550
+ img.src = URL.createObjectURL(file);
1551
+ img.alt = file.name;
1552
+ const rm = document.createElement("button");
1553
+ rm.type = "button";
1554
+ rm.className = "thumb-remove";
1555
+ rm.innerHTML = "&times;";
1556
+ rm.title = "Remove";
1557
+ rm.addEventListener("click", (e) => {{
1558
+ e.stopPropagation();
1559
+ state.files.splice(index, 1);
1560
+ renderPreviews();
1561
  }});
1562
+ wrap.appendChild(img);
1563
+ wrap.appendChild(rm);
1564
+ return wrap;
1565
+ }}
1566
+
1567
+ function renderPreviews() {{
1568
+ previewStrip.innerHTML = "";
1569
+ if (!state.files.length) {{
1570
+ uploadPlaceholder.style.display = "flex";
1571
+ previewStrip.style.display = "none";
1572
+ return;
1573
+ }}
1574
+ uploadPlaceholder.style.display = "none";
1575
+ previewStrip.style.display = "flex";
1576
+ state.files.forEach((f, i) => previewStrip.appendChild(createThumb(f, i)));
1577
+
1578
+ /* add-more button */
1579
+ const addBtn = document.createElement("button");
1580
+ addBtn.type = "button";
1581
+ addBtn.className = "thumb-add";
1582
+ addBtn.title = "Add more images";
1583
+ addBtn.innerHTML = "+";
1584
+ addBtn.onclick = () => fileInput.click();
1585
+ previewStrip.appendChild(addBtn);
1586
+ }}
1587
+
1588
+ function addFiles(list) {{
1589
+ const valid = Array.from(list).filter(f => f.type.startsWith("image/"));
1590
+ if (!valid.length) {{ showToast("Please upload valid image files."); return; }}
1591
+ state.files = [...state.files, ...valid];
1592
+ renderPreviews();
1593
+ }}
1594
+
1595
+ uploadPlaceholder.addEventListener("click", () => fileInput.click());
1596
+ uploadZone.addEventListener("click", (e) => {{ if (e.target === uploadZone) fileInput.click(); }});
1597
+ fileInput.addEventListener("change", (e) => {{ addFiles(e.target.files); fileInput.value = ""; }});
1598
+ uploadZone.addEventListener("dragover", (e) => {{ e.preventDefault(); uploadZone.classList.add("dragover"); }});
1599
+ uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
1600
+ uploadZone.addEventListener("drop", (e) => {{
1601
+ e.preventDefault();
1602
+ uploadZone.classList.remove("dragover");
1603
+ if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
1604
+ }});
1605
+
1606
+ /* ── image comparison slider ── */
1607
+ function setSliderPct(pct) {{
1608
+ state.sliderPct = Math.max(2, Math.min(98, pct));
1609
+ imgLeft.style.clipPath = `inset(0 ${{100 - state.sliderPct}}% 0 0)`;
1610
+ sliderDivider.style.left = state.sliderPct + "%";
1611
+ sliderHandle.style.left = state.sliderPct + "%";
1612
+ }}
1613
+
1614
+ function pctFromEvent(e) {{
1615
+ const rect = sliderWrap.getBoundingClientRect();
1616
+ const x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
1617
+ return (x / rect.width) * 100;
1618
+ }}
1619
+
1620
+ sliderHandle.addEventListener("mousedown", () => state.dragging = true);
1621
+ sliderHandle.addEventListener("touchstart", () => state.dragging = true, {{passive: true}});
1622
+ window.addEventListener("mouseup", () => state.dragging = false);
1623
+ window.addEventListener("touchend", () => state.dragging = false);
1624
+ window.addEventListener("mousemove", (e) => {{ if (state.dragging) setSliderPct(pctFromEvent(e)); }});
1625
+ window.addEventListener("touchmove", (e) => {{ if (state.dragging) setSliderPct(pctFromEvent(e)); }}, {{passive: true}});
1626
+ sliderWrap.addEventListener("click", (e) => {{
1627
+ if (!state.hasResult) return;
1628
+ if (e.target !== sliderHandle) setSliderPct(pctFromEvent(e));
1629
+ }});
1630
+
1631
+ /* ── loading state ── */
1632
+ function setLoading(loading) {{
1633
+ loaderOverlay.style.display = loading ? "flex" : "none";
1634
+ runBtn.disabled = loading;
1635
+ clearBtn.disabled = loading;
1636
+ runBtn.style.opacity = loading ? "0.65" : "1";
1637
+ clearBtn.style.opacity = loading ? "0.65" : "1";
1638
+
1639
+ statusPill.textContent = loading ? "Running" : "Idle";
1640
+ statusPill.classList.toggle("running", loading);
1641
+ resultStatus.textContent = loading ? "Processing" : (state.hasResult ? "Done" : "Waiting");
1642
+ resultStatus.classList.toggle("running", loading);
1643
+ }}
1644
+
1645
+ /* ── show result images ── */
1646
+ function showResults(stdUrl, smallUrl, seed, device, w, h) {{
1647
+ state.hasResult = true;
1648
+
1649
+ imgLeft.src = stdUrl + "?t=" + Date.now();
1650
+ imgRight.src = smallUrl + "?t=" + Date.now();
1651
+
1652
+ imgLeft.style.display = "block";
1653
+ imgRight.style.display = "block";
1654
+ sliderEmpty.style.display = "none";
1655
+ lblLeft.style.display = "block";
1656
+ lblRight.style.display = "block";
1657
+ sliderDivider.style.display = "block";
1658
+ sliderHandle.style.display = "flex";
1659
+
1660
+ setSliderPct(50);
1661
+
1662
+ metaSeed.textContent = String(seed);
1663
+ metaDevice.textContent = (device || "{DEVICE_LABEL}").toLowerCase();
1664
+ metaRes.textContent = w && h ? `${{w}} × ${{h}}` : "—";
1665
+
1666
+ dlStandard.href = stdUrl;
1667
+ dlStandard.download = "standard_vae.png";
1668
+ dlSmall.href = smallUrl;
1669
+ dlSmall.download = "small_decoder.png";
1670
+ }}
1671
+
1672
+ /* ── clear ── */
1673
+ function clearAll() {{
1674
+ state.files = [];
1675
+ state.hasResult = false;
1676
+ renderPreviews();
1677
+ promptEl.value = "";
1678
+ widthEl.value = "1024"; widthValEl.textContent = "1024";
1679
+ heightEl.value = "1024"; heightValEl.textContent = "1024";
1680
+ stepsEl.value = "4"; stepsValEl.textContent = "4";
1681
+ guidanceEl.value = "1.0"; guidanceValEl.textContent = "1.0";
1682
+ seedEl.value = "0"; seedValEl.textContent = "0";
1683
+ randomizeSeedEl.checked = true;
1684
+
1685
+ imgLeft.style.display = "none";
1686
+ imgRight.style.display = "none";
1687
+ imgLeft.removeAttribute("src");
1688
+ imgRight.removeAttribute("src");
1689
+ sliderEmpty.style.display = "flex";
1690
+ lblLeft.style.display = "none";
1691
+ lblRight.style.display = "none";
1692
+ sliderDivider.style.display = "none";
1693
+ sliderHandle.style.display = "none";
1694
+
1695
+ metaSeed.textContent = "—";
1696
+ metaDevice.textContent = "{DEVICE_LABEL}";
1697
+ metaRes.textContent = "—";
1698
+ dlStandard.removeAttribute("href");
1699
+ dlSmall.removeAttribute("href");
1700
+
1701
+ setLoading(false);
1702
+ }}
1703
+
1704
+ clearBtn.addEventListener("click", clearAll);
1705
+
1706
+ /* ── submit ── */
1707
+ async function submitCompare() {{
1708
+ const prompt = promptEl.value.trim();
1709
+ if (!prompt) {{ showToast("Please enter a prompt."); return; }}
1710
+
1711
+ const formData = new FormData();
1712
+ formData.append("prompt", prompt);
1713
+ formData.append("seed", seedEl.value || "0");
1714
+ formData.append("randomize_seed", String(randomizeSeedEl.checked));
1715
+ formData.append("width", widthEl.value || "1024");
1716
+ formData.append("height", heightEl.value || "1024");
1717
+ formData.append("steps", stepsEl.value || "4");
1718
+ formData.append("guidance_scale", guidanceEl.value || "1.0");
1719
+ state.files.forEach(f => formData.append("images", f));
1720
+
1721
+ setLoading(true);
1722
+ try {{
1723
+ const res = await fetch("/api/compare", {{ method: "POST", body: formData }});
1724
+ const data = await res.json();
1725
+ if (!res.ok || !data.success) throw new Error(data.error || "Processing failed.");
1726
+
1727
+ showResults(
1728
+ data.standard.image_url,
1729
+ data.small.image_url,
1730
+ data.seed,
1731
+ data.device,
1732
+ parseInt(widthEl.value),
1733
+ parseInt(heightEl.value),
1734
+ );
1735
+ }} catch (err) {{
1736
+ showToast(err.message || "An unexpected error occurred.");
1737
+ }} finally {{
1738
+ setLoading(false);
1739
+ }}
1740
+ }}
1741
+
1742
+ runBtn.addEventListener("click", submitCompare);
1743
+ promptEl.addEventListener("keydown", (e) => {{
1744
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) submitCompare();
1745
+ }});
1746
+
1747
+ /* ── examples ── */
1748
+ async function fileFromUrl(url, filename) {{
1749
+ const res = await fetch(url);
1750
+ if (!res.ok) throw new Error("Failed to fetch example image.");
1751
+ const blob = await res.blob();
1752
+ return new File([blob], filename, {{ type: blob.type || "image/jpeg" }});
1753
+ }}
1754
+
1755
+ function renderExamples() {{
1756
+ examplesGrid.innerHTML = "";
1757
+ examples.forEach(item => {{
1758
+ const card = document.createElement("div");
1759
+ card.className = "example-card";
1760
+
1761
+ const img = document.createElement("img");
1762
+ img.src = item.url;
1763
+ img.alt = item.file;
1764
+ img.loading = "lazy";
1765
+
1766
+ const body = document.createElement("div");
1767
+ body.className = "example-card-body";
1768
+ const p = document.createElement("p");
1769
+ p.textContent = item.prompt;
1770
+
1771
+ body.appendChild(p);
1772
+ card.appendChild(img);
1773
+ card.appendChild(body);
1774
+
1775
+ card.addEventListener("click", async () => {{
1776
+ try {{
1777
+ const f = await fileFromUrl(item.url, item.file);
1778
+ state.files = [f];
1779
+ renderPreviews();
1780
+ promptEl.value = item.prompt;
1781
+ showToast("Example loaded.");
1782
+ }} catch (err) {{
1783
+ showToast(err.message || "Failed to load example.");
1784
+ }}
1785
+ }});
1786
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1787
  examplesGrid.appendChild(card);
1788
  }});
1789
+ }}
1790
 
1791
+ /* ── init ── */
1792
+ renderPreviews();
1793
+ renderExamples();
1794
+ setSliderPct(50);
1795
+ </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1796
  </body>
1797
+ </html>"""
1798
+
1799
 
1800
  app.launch()